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.
This commit is contained in:
1 parent
bfcac805af
commit
f2658b24f7
3 files changed
+154
-39
No files matched your search
Regular → Executable
+7
-9
@@ -1,13 +1,11 @@
|
|||||||
#!/usr/bin/env bash
|
#!/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
|
set -euo pipefail
|
||||||
image="${1:?usage: extract-node-binaries.sh <image> <destination>}"
|
image="${1:?usage: extract-node-binaries.sh <image> <destination>}"
|
||||||
destination="${2:?}"
|
destination="${2:?}"
|
||||||
mkdir -p "$destination"
|
root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
buildah unshare bash -s -- "$image" "$destination" <<'SH'
|
work="$(mktemp -d)"
|
||||||
set -euo pipefail
|
trap 'rm -rf "$work"' EXIT
|
||||||
container="$(buildah from "$1")"
|
buildah push --format oci "$image" "oci-archive:$work/image.tar"
|
||||||
trap 'buildah unmount "$container" >/dev/null 2>&1 || true; buildah rm "$container" >/dev/null' EXIT
|
python3 "$root/ci/extract-oci-files.py" "$work/image.tar" "$destination" out/agent/moshiro-agent out/enroll/fleet-enroll
|
||||||
mount="$(buildah mount "$container")"
|
|
||||||
install -m 0755 "$mount/out/agent/moshiro-agent" "$mount/out/enroll/fleet-enroll" "$2/"
|
|
||||||
SH
|
|
||||||
Executable
+99
@@ -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 <image.tar> <destination> <path-in-image>...
|
||||||
|
|
||||||
|
`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())
|
||||||
@@ -89,45 +89,63 @@ esac
|
|||||||
subprocess.run(['bash', str(ROOT / 'ci/cleanup-build-cache.sh')], env=env, check=True, capture_output=True)
|
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')
|
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:
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
base = Path(directory)
|
base = Path(directory)
|
||||||
(base / 'bin').mkdir()
|
(base / 'bin').mkdir()
|
||||||
(base / 'image/out/agent').mkdir(parents=True)
|
self.oci_archive(base / 'image.tar', [
|
||||||
(base / 'image/out/enroll').mkdir(parents=True)
|
{'out/agent/moshiro-agent': b'old agent', 'out/enroll/fleet-enroll': b'enroll', 'out/agent/extra': b'x'},
|
||||||
(base / 'image/out/agent/moshiro-agent').write_bytes(b'agent')
|
{'out/agent/moshiro-agent': b'agent'},
|
||||||
enroll = base / 'image/out/enroll/fleet-enroll'
|
])
|
||||||
enroll.write_bytes(b'enroll')
|
|
||||||
buildah = base / 'bin/buildah'
|
buildah = base / 'bin/buildah'
|
||||||
buildah.write_text('''#!/usr/bin/env bash
|
buildah.write_text('''#!/usr/bin/env bash
|
||||||
set -eu
|
# only `push` exists: any mount/unshare use fails like it does on the CI host
|
||||||
if [[ "$1" == unshare ]]; then
|
[[ "$1" == push ]] || { echo "memfd_create(): Invalid argument" >&2; exit 1; }
|
||||||
shift
|
destination="${!#}"
|
||||||
export TEST_UNSHARE=yes
|
cp "$FIXTURE/image.tar" "${destination#oci-archive:}"
|
||||||
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
|
|
||||||
''')
|
''')
|
||||||
buildah.chmod(0o755)
|
buildah.chmod(0o755)
|
||||||
env = dict(os.environ, PATH=str(base / 'bin') + ':' + os.environ['PATH'], FIXTURE=str(base))
|
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')]
|
command = ['bash', str(ROOT / 'ci/extract-node-binaries.sh'), 'test-image', str(base / 'output')]
|
||||||
for succeeds in (True, False):
|
result = subprocess.run(command, env=env, capture_output=True, text=True)
|
||||||
with self.subTest(succeeds=succeeds):
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
if not succeeds:
|
self.assertEqual((base / 'output/moshiro-agent').read_bytes(), b'agent') # the upper layer wins
|
||||||
enroll.unlink()
|
self.assertEqual((base / 'output/fleet-enroll').read_bytes(), b'enroll')
|
||||||
result = subprocess.run(command, env=env, capture_output=True, text=True)
|
self.assertEqual(os.stat(base / 'output/moshiro-agent').st_mode & 0o777, 0o755)
|
||||||
self.assertEqual(result.returncode == 0, succeeds, result.stderr)
|
self.assertFalse((base / 'output/extra').exists())
|
||||||
self.assertEqual((base / 'cleanup').read_text().splitlines(), ['unmount', 'rm'])
|
# a file removed by a later layer is an error, not a stale copy
|
||||||
(base / 'cleanup').unlink()
|
self.oci_archive(base / 'image.tar', [{'out/agent/moshiro-agent': b'a', 'out/enroll/fleet-enroll': b'e'}, {'out/enroll/fleet-enroll': None}])
|
||||||
if succeeds:
|
result = subprocess.run(command, env=env, capture_output=True, text=True)
|
||||||
self.assertEqual((base / 'output/moshiro-agent').read_bytes(), b'agent')
|
self.assertNotEqual(result.returncode, 0)
|
||||||
self.assertEqual((base / 'output/fleet-enroll').read_bytes(), b'enroll')
|
self.assertIn('out/enroll/fleet-enroll', result.stderr)
|
||||||
|
|
||||||
def test_an_image_with_several_tags_still_has_one_id(self):
|
def test_an_image_with_several_tags_still_has_one_id(self):
|
||||||
# buildah images <name> prints one row per tag of the image; identical restore images accumulate one tag per commit
|
# buildah images <name> prints one row per tag of the image; identical restore images accumulate one tag per commit
|
||||||
|
|||||||
Reference in new issue
Block a user