From f2658b24f7c4dc96dad173624e166c8c0882a505 Mon Sep 17 00:00:00 2001 From: justme Date: Wed, 21 Oct 2026 15:07:46 +0800 Subject: [PATCH] ci: read the built binaries from an OCI archive instead of mounting the image [CI] buildah unshare fails on the CI host with memfd_create: Invalid argument, so extract-node-binaries.sh failed right after a successful native build. Exporting an OCI archive needs no mount (the toolchain archive already works that way); extract-oci-files.py reads the files from the layers, later layers win and whiteouts delete. --- ci/extract-node-binaries.sh | 16 +++-- ci/extract-oci-files.py | 99 +++++++++++++++++++++++++++++++ scripts/test-linux-agent-build.py | 78 ++++++++++++++---------- 3 files changed, 154 insertions(+), 39 deletions(-) mode change 100644 => 100755 ci/extract-node-binaries.sh create mode 100755 ci/extract-oci-files.py diff --git a/ci/extract-node-binaries.sh b/ci/extract-node-binaries.sh old mode 100644 new mode 100755 index e04750b..aacf29d --- a/ci/extract-node-binaries.sh +++ b/ci/extract-node-binaries.sh @@ -1,13 +1,11 @@ #!/usr/bin/env bash -# Keep mount, reads and unmount inside the same rootless user/mount namespace. +# 从构建出的镜像里取出 moshiro-agent 和 fleet-enroll,不挂载镜像: +# buildah unshare / mount 在 CI 主机上报 memfd_create: Invalid argument,而导出 OCI 归档不需要挂载(工具链归档也是这样做的)。 set -euo pipefail image="${1:?usage: extract-node-binaries.sh }" destination="${2:?}" -mkdir -p "$destination" -buildah unshare bash -s -- "$image" "$destination" <<'SH' -set -euo pipefail -container="$(buildah from "$1")" -trap 'buildah unmount "$container" >/dev/null 2>&1 || true; buildah rm "$container" >/dev/null' EXIT -mount="$(buildah mount "$container")" -install -m 0755 "$mount/out/agent/moshiro-agent" "$mount/out/enroll/fleet-enroll" "$2/" -SH +root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT +buildah push --format oci "$image" "oci-archive:$work/image.tar" +python3 "$root/ci/extract-oci-files.py" "$work/image.tar" "$destination" out/agent/moshiro-agent out/enroll/fleet-enroll diff --git a/ci/extract-oci-files.py b/ci/extract-oci-files.py new file mode 100755 index 0000000..76faa8d --- /dev/null +++ b/ci/extract-oci-files.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 +"""Copy files out of an OCI image archive without mounting the image. + +usage: extract-oci-files.py ... + +`buildah mount` needs a `buildah unshare` session, and that fails on the CI host (memfd_create: Invalid argument, +no subuid range for the nested user namespace). Exporting an OCI archive needs no mount, so the files are read from +the layers directly. Later layers win; whiteouts delete earlier files. Each file is written with mode 0755. +""" + +from __future__ import annotations + +import gzip +import io +import json +import os +import sys +import tarfile +from pathlib import Path + + +def normalize(name: str) -> str: + return os.path.normpath(name).lstrip("/") + + +def blob(archive: tarfile.TarFile, digest: str) -> bytes: + algorithm, _, value = digest.partition(":") + member = archive.extractfile(f"blobs/{algorithm}/{value}") + if member is None: + raise SystemExit(f"extract-oci-files: blob {digest} is missing from the archive") + return member.read() + + +def layers_of(archive: tarfile.TarFile) -> list[dict]: + index = json.loads(archive.extractfile("index.json").read()) + manifest = None + for entry in index["manifests"]: + document = json.loads(blob(archive, entry["digest"])) + if "layers" in document: + manifest = document + break + # an image index: take its first manifest + for child in document.get("manifests", []): + candidate = json.loads(blob(archive, child["digest"])) + if "layers" in candidate: + manifest = candidate + break + if manifest: + break + if manifest is None: + raise SystemExit("extract-oci-files: no image manifest in the archive") + return manifest["layers"] + + +def open_layer(data: bytes, media_type: str) -> tarfile.TarFile: + if media_type.endswith("zstd"): + raise SystemExit("extract-oci-files: zstd layers are not supported; export with gzip compression") + stream = io.BytesIO(gzip.decompress(data) if media_type.endswith("gzip") else data) + return tarfile.open(fileobj=stream, mode="r:") + + +def main() -> int: + if len(sys.argv) < 4: + print(__doc__, file=sys.stderr) + return 2 + image, destination = sys.argv[1], Path(sys.argv[2]) + wanted = [normalize(path) for path in sys.argv[3:]] + found: dict[str, bytes | None] = {} + with tarfile.open(image) as archive: + for layer in layers_of(archive): + with open_layer(blob(archive, layer["digest"]), layer["mediaType"]) as content: + for member in content: + name = normalize(member.name) + directory, base = os.path.split(name) + if base == ".wh..wh..opq": + for path in wanted: + if path.startswith(directory + "/"): + found[path] = None + elif base.startswith(".wh."): + removed = os.path.join(directory, base[4:]) + for path in wanted: + if path == removed or path.startswith(removed + "/"): + found[path] = None + elif name in wanted and member.isfile(): + found[name] = content.extractfile(member).read() + missing = [path for path in wanted if found.get(path) is None] + if missing: + print("extract-oci-files: not found in the image: " + ", ".join(missing), file=sys.stderr) + return 1 + destination.mkdir(parents=True, exist_ok=True) + for path in wanted: + target = destination / os.path.basename(path) + target.write_bytes(found[path]) + target.chmod(0o755) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/test-linux-agent-build.py b/scripts/test-linux-agent-build.py index b973b21..288600d 100644 --- a/scripts/test-linux-agent-build.py +++ b/scripts/test-linux-agent-build.py @@ -89,45 +89,63 @@ esac subprocess.run(['bash', str(ROOT / 'ci/cleanup-build-cache.sh')], env=env, check=True, capture_output=True) self.assertEqual((base / 'removed').read_text(), 'rmi localhost/moshiro-agent-binaries:linux-musl-x64\nrmi old\nrmi version\nrmi restore\n') - def test_export_requires_one_unshare_session_and_cleans_up_on_copy_failure(self): + @staticmethod + def oci_archive(path, layers): + """A minimal OCI archive: each layer is a dict name -> bytes (None = whiteout of that name).""" + import gzip, hashlib, io, json + blobs = {} + def add(data): + digest = hashlib.sha256(data).hexdigest() + blobs[f'blobs/sha256/{digest}'] = data + return 'sha256:' + digest + descriptors = [] + for files in layers: + raw = io.BytesIO() + with tarfile.open(fileobj=raw, mode='w') as layer: + for name, content in files.items(): + if content is None: + directory, _, base = name.rpartition('/') + name, content = f'{directory}/.wh.{base}', b'' + entry = tarfile.TarInfo(name) + entry.size = len(content) + layer.addfile(entry, io.BytesIO(content)) + descriptors.append({'mediaType': 'application/vnd.oci.image.layer.v1.tar+gzip', 'digest': add(gzip.compress(raw.getvalue()))}) + manifest = add(json.dumps({'schemaVersion': 2, 'layers': descriptors}).encode()) + with tarfile.open(path, 'w') as archive: + for name, data in {**blobs, 'index.json': json.dumps({'schemaVersion': 2, 'manifests': [{'digest': manifest}]}).encode()}.items(): + entry = tarfile.TarInfo(name) + entry.size = len(data) + archive.addfile(entry, io.BytesIO(data)) + + def test_node_binaries_are_read_from_an_oci_archive_without_mounting(self): with tempfile.TemporaryDirectory() as directory: base = Path(directory) (base / 'bin').mkdir() - (base / 'image/out/agent').mkdir(parents=True) - (base / 'image/out/enroll').mkdir(parents=True) - (base / 'image/out/agent/moshiro-agent').write_bytes(b'agent') - enroll = base / 'image/out/enroll/fleet-enroll' - enroll.write_bytes(b'enroll') + self.oci_archive(base / 'image.tar', [ + {'out/agent/moshiro-agent': b'old agent', 'out/enroll/fleet-enroll': b'enroll', 'out/agent/extra': b'x'}, + {'out/agent/moshiro-agent': b'agent'}, + ]) buildah = base / 'bin/buildah' buildah.write_text('''#!/usr/bin/env bash -set -eu -if [[ "$1" == unshare ]]; then - shift - export TEST_UNSHARE=yes - exec "$@" -fi -[[ "${TEST_UNSHARE:-}" == yes ]] || exit 125 -case "$1" in - from) echo test-container ;; - mount) echo "$FIXTURE/image" ;; - unmount|rm) echo "$1" >> "$FIXTURE/cleanup" ;; - *) exit 2 ;; -esac +# only `push` exists: any mount/unshare use fails like it does on the CI host +[[ "$1" == push ]] || { echo "memfd_create(): Invalid argument" >&2; exit 1; } +destination="${!#}" +cp "$FIXTURE/image.tar" "${destination#oci-archive:}" ''') buildah.chmod(0o755) env = dict(os.environ, PATH=str(base / 'bin') + ':' + os.environ['PATH'], FIXTURE=str(base)) command = ['bash', str(ROOT / 'ci/extract-node-binaries.sh'), 'test-image', str(base / 'output')] - for succeeds in (True, False): - with self.subTest(succeeds=succeeds): - if not succeeds: - enroll.unlink() - result = subprocess.run(command, env=env, capture_output=True, text=True) - self.assertEqual(result.returncode == 0, succeeds, result.stderr) - self.assertEqual((base / 'cleanup').read_text().splitlines(), ['unmount', 'rm']) - (base / 'cleanup').unlink() - if succeeds: - self.assertEqual((base / 'output/moshiro-agent').read_bytes(), b'agent') - self.assertEqual((base / 'output/fleet-enroll').read_bytes(), b'enroll') + result = subprocess.run(command, env=env, capture_output=True, text=True) + self.assertEqual(result.returncode, 0, result.stderr) + self.assertEqual((base / 'output/moshiro-agent').read_bytes(), b'agent') # the upper layer wins + self.assertEqual((base / 'output/fleet-enroll').read_bytes(), b'enroll') + self.assertEqual(os.stat(base / 'output/moshiro-agent').st_mode & 0o777, 0o755) + self.assertFalse((base / 'output/extra').exists()) + # a file removed by a later layer is an error, not a stale copy + self.oci_archive(base / 'image.tar', [{'out/agent/moshiro-agent': b'a', 'out/enroll/fleet-enroll': b'e'}, {'out/enroll/fleet-enroll': None}]) + result = subprocess.run(command, env=env, capture_output=True, text=True) + self.assertNotEqual(result.returncode, 0) + self.assertIn('out/enroll/fleet-enroll', result.stderr) def test_an_image_with_several_tags_still_has_one_id(self): # buildah images prints one row per tag of the image; identical restore images accumulate one tag per commit