From e9d80952284381e8cbe8ba9c04a5945b16e39999 Mon Sep 17 00:00:00 2001 From: Fabian Wiesel Date: Tue, 27 Oct 2026 16:46:23 +0100 Subject: [PATCH 1/5] feat: sysext build engine, seven extensions, byte-reproducible builds Node-local packages belong to the node owner, not to the shared boot root: the engine builds every SCI extension as an EROFS-root GPT image, standalone on the pinned published base, so a package change rebuilds exactly its own extension and the base image stops churning for every host. Seven extensions ship: base identities, compute, network, storage, libvirt and the two hypervisor flavours. Builds are byte-reproducible, so a produced raw traces to its inputs: version-keyed timestamps, per-image seeds and three conscious-bump pins (apt suite, toolchain manifest, resolved configuration). The tools tree is bootstrapped from the pinned suite and accepted only when its manifest digest matches the committed pin. The numeric name prefixes state the merge order systemd-sysext applies, the virt drivers exclude libvirt-owned paths through RemoveFiles= lists because the overlay merge provides them at runtime, and every path has exactly one owning extension. The qemu extension carries the VNC port policy that the legacy image kept in /etc/libvirt/qemu.conf, as a template with a tmpfiles C rule, because /etc is outside the merge. `mkosi/build.sh --bootstrap` prepares sandbox, tools tree and base without building images, for a caller that only needs the toolchain. Signed-off-by: Fabian Wiesel --- .gitignore | 4 + extensions/tests/conftest.py | 23 ++ extensions/tests/data/summary-sample.json | 43 +++ .../tests/data/summary-sample.normalized.json | 48 +++ extensions/tests/pytest.ini | 6 + extensions/tests/summary.py | 236 +++++++++++++ extensions/tests/test_artifact_contracts.py | 206 +++++++++++ extensions/tests/test_summary.py | 72 ++++ mkosi/base-image.pin | 11 + mkosi/base-image.sh | 333 ++++++++++++++++++ mkosi/build.sh | 154 ++++++++ mkosi/mkosi.conf | 12 + mkosi/mkosi.images/sci-10-base/mkosi.conf | 15 + .../extension-release.sci-10-base | 1 + mkosi/mkosi.images/sci-20-compute/mkosi.conf | 15 + .../extension-release.sci-20-compute | 2 + .../usr/lib/sysusers.d/sci-compute.conf | 14 + .../usr/lib/tmpfiles.d/sci-compute.conf | 8 + .../sci-20-compute-activation.service | 13 + mkosi/mkosi.images/sci-21-network/mkosi.conf | 15 + .../extension-release.sci-21-network | 3 + .../usr/lib/modules-load.d/sci-network.conf | 1 + .../usr/lib/sysusers.d/sci-network.conf | 6 + .../usr/lib/tmpfiles.d/sci-network.conf | 8 + .../mkosi.extra/usr/sbin/ovs-vswitchd | 1 + .../sci-21-network-activation.service | 16 + mkosi/mkosi.images/sci-22-storage/mkosi.conf | 15 + .../mkosi.extra/usr/bin/iscsiadm | 1 + .../extension-release.sci-22-storage | 3 + .../usr/lib/modprobe.d/nfsclient.conf | 2 + .../usr/lib/tmpfiles.d/sci-storage.conf | 4 + .../sci-22-storage-activation.service | 14 + mkosi/mkosi.images/sci-40-libvirt/mkosi.conf | 15 + .../extension-release.sci-40-libvirt | 3 + .../usr/lib/modprobe.d/kvm-amd.conf | 1 + .../usr/lib/modprobe.d/kvm-intel.conf | 1 + .../usr/lib/modules-load.d/kvm.conf | 3 + .../libvirtd.service.d/sci-resident.conf | 7 + .../usr/lib/sysusers.d/libvirt-qemu.conf | 9 + .../usr/lib/sysusers.d/libvirt.conf | 3 + .../usr/lib/tmpfiles.d/sci-libvirt.conf | 3 + .../sci-40-libvirt-activation.service | 17 + mkosi/mkosi.images/sci-50-virt-ch/mkosi.conf | 97 +++++ .../extension-release.sci-50-virt-ch | 2 + .../usr/lib/tmpfiles.d/sci-virt-ch.conf | 4 + .../mkosi.images/sci-51-virt-qemu/mkosi.conf | 51 +++ .../extension-release.sci-51-virt-qemu | 2 + .../usr/lib/tmpfiles.d/sci-virt-qemu.conf | 12 + .../usr/share/sci/etc/libvirt/qemu.conf | 5 + .../etc/apt/sources.list.d/mkosi.sources | 5 + mkosi/suite.pin | 2 + mkosi/summary.pin | 4 + mkosi/tools.pin | 5 + 53 files changed, 1556 insertions(+) create mode 100644 extensions/tests/conftest.py create mode 100644 extensions/tests/data/summary-sample.json create mode 100644 extensions/tests/data/summary-sample.normalized.json create mode 100644 extensions/tests/pytest.ini create mode 100644 extensions/tests/summary.py create mode 100644 extensions/tests/test_artifact_contracts.py create mode 100644 extensions/tests/test_summary.py create mode 100644 mkosi/base-image.pin create mode 100755 mkosi/base-image.sh create mode 100755 mkosi/build.sh create mode 100644 mkosi/mkosi.conf create mode 100644 mkosi/mkosi.images/sci-10-base/mkosi.conf create mode 100644 mkosi/mkosi.images/sci-10-base/mkosi.extra/usr/lib/extension-release.d/extension-release.sci-10-base create mode 100644 mkosi/mkosi.images/sci-20-compute/mkosi.conf create mode 100644 mkosi/mkosi.images/sci-20-compute/mkosi.extra/usr/lib/extension-release.d/extension-release.sci-20-compute create mode 100644 mkosi/mkosi.images/sci-20-compute/mkosi.extra/usr/lib/sysusers.d/sci-compute.conf create mode 100644 mkosi/mkosi.images/sci-20-compute/mkosi.extra/usr/lib/tmpfiles.d/sci-compute.conf create mode 100644 mkosi/mkosi.images/sci-20-compute/mkosi.extra/usr/share/sci/activation/sci-20-compute-activation.service create mode 100644 mkosi/mkosi.images/sci-21-network/mkosi.conf create mode 100644 mkosi/mkosi.images/sci-21-network/mkosi.extra/usr/lib/extension-release.d/extension-release.sci-21-network create mode 100644 mkosi/mkosi.images/sci-21-network/mkosi.extra/usr/lib/modules-load.d/sci-network.conf create mode 100644 mkosi/mkosi.images/sci-21-network/mkosi.extra/usr/lib/sysusers.d/sci-network.conf create mode 100644 mkosi/mkosi.images/sci-21-network/mkosi.extra/usr/lib/tmpfiles.d/sci-network.conf create mode 120000 mkosi/mkosi.images/sci-21-network/mkosi.extra/usr/sbin/ovs-vswitchd create mode 100644 mkosi/mkosi.images/sci-21-network/mkosi.extra/usr/share/sci/activation/sci-21-network-activation.service create mode 100644 mkosi/mkosi.images/sci-22-storage/mkosi.conf create mode 120000 mkosi/mkosi.images/sci-22-storage/mkosi.extra/usr/bin/iscsiadm create mode 100644 mkosi/mkosi.images/sci-22-storage/mkosi.extra/usr/lib/extension-release.d/extension-release.sci-22-storage create mode 100644 mkosi/mkosi.images/sci-22-storage/mkosi.extra/usr/lib/modprobe.d/nfsclient.conf create mode 100644 mkosi/mkosi.images/sci-22-storage/mkosi.extra/usr/lib/tmpfiles.d/sci-storage.conf create mode 100644 mkosi/mkosi.images/sci-22-storage/mkosi.extra/usr/share/sci/activation/sci-22-storage-activation.service create mode 100644 mkosi/mkosi.images/sci-40-libvirt/mkosi.conf create mode 100644 mkosi/mkosi.images/sci-40-libvirt/mkosi.extra/usr/lib/extension-release.d/extension-release.sci-40-libvirt create mode 100644 mkosi/mkosi.images/sci-40-libvirt/mkosi.extra/usr/lib/modprobe.d/kvm-amd.conf create mode 100644 mkosi/mkosi.images/sci-40-libvirt/mkosi.extra/usr/lib/modprobe.d/kvm-intel.conf create mode 100644 mkosi/mkosi.images/sci-40-libvirt/mkosi.extra/usr/lib/modules-load.d/kvm.conf create mode 100644 mkosi/mkosi.images/sci-40-libvirt/mkosi.extra/usr/lib/systemd/system/libvirtd.service.d/sci-resident.conf create mode 100644 mkosi/mkosi.images/sci-40-libvirt/mkosi.extra/usr/lib/sysusers.d/libvirt-qemu.conf create mode 100644 mkosi/mkosi.images/sci-40-libvirt/mkosi.extra/usr/lib/sysusers.d/libvirt.conf create mode 100644 mkosi/mkosi.images/sci-40-libvirt/mkosi.extra/usr/lib/tmpfiles.d/sci-libvirt.conf create mode 100644 mkosi/mkosi.images/sci-40-libvirt/mkosi.extra/usr/share/sci/activation/sci-40-libvirt-activation.service create mode 100644 mkosi/mkosi.images/sci-50-virt-ch/mkosi.conf create mode 100644 mkosi/mkosi.images/sci-50-virt-ch/mkosi.extra/usr/lib/extension-release.d/extension-release.sci-50-virt-ch create mode 100644 mkosi/mkosi.images/sci-50-virt-ch/mkosi.extra/usr/lib/tmpfiles.d/sci-virt-ch.conf create mode 100644 mkosi/mkosi.images/sci-51-virt-qemu/mkosi.conf create mode 100644 mkosi/mkosi.images/sci-51-virt-qemu/mkosi.extra/usr/lib/extension-release.d/extension-release.sci-51-virt-qemu create mode 100644 mkosi/mkosi.images/sci-51-virt-qemu/mkosi.extra/usr/lib/tmpfiles.d/sci-virt-qemu.conf create mode 100644 mkosi/mkosi.images/sci-51-virt-qemu/mkosi.extra/usr/share/sci/etc/libvirt/qemu.conf create mode 100644 mkosi/sandbox/etc/apt/sources.list.d/mkosi.sources create mode 100644 mkosi/suite.pin create mode 100644 mkosi/summary.pin create mode 100644 mkosi/tools.pin diff --git a/.gitignore b/.gitignore index 19fd6f02..0254c18d 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,7 @@ .envrc .venv shell.nix +__pycache__/ +.pytest_cache/ +docs/sysext-handover.md +.actrc diff --git a/extensions/tests/conftest.py b/extensions/tests/conftest.py new file mode 100644 index 00000000..0807ff63 --- /dev/null +++ b/extensions/tests/conftest.py @@ -0,0 +1,23 @@ +"""Minimal pytest configuration for the extension boundary tests. + +The guest suite under ``guest/`` runs inside the VM image, not on the host. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +collect_ignore = ["guest"] + +_HERE = Path(__file__).resolve().parent +if str(_HERE) not in sys.path: + sys.path.insert(0, str(_HERE)) + + +@pytest.fixture(scope="session") +def repo_root() -> Path: + """The resolved repository root.""" + return Path(__file__).resolve().parents[2] diff --git a/extensions/tests/data/summary-sample.json b/extensions/tests/data/summary-sample.json new file mode 100644 index 00000000..de862dbd --- /dev/null +++ b/extensions/tests/data/summary-sample.json @@ -0,0 +1,43 @@ +{ + "Images": [ + { + "Architecture": "x86-64", + "BaseTrees": [ + "/repo/.build/mkosi/base-image/current", + "/repo/mkosi/mkosi.images/sci-libvirt" + ], + "Distribution": "gardenlinux", + "Format": "sysext", + "Image": "sci-base", + "Note": "h\u00e9llo w\u00f6rld \u2713", + "Output": "/repo/.build/mkosi/output/sci-base.raw", + "Packages": [ + "conntrack", + "parted" + ], + "Release": "2150.9.0", + "Seed": "8b7f2b0e-9c4a-4a1e-9a55-0c3d2e1f6a77", + "Scale": 1.5, + "SourceDateEpoch": 1771372800, + "Tags": [] + }, + { + "Architecture": "x86-64", + "Distribution": "gardenlinux", + "Flag": true, + "Format": "disk", + "Image": "main", + "Output": "/var/tmp/machine-local/main.raw", + "RepoRoot": "/repo", + "Release": "2150.9.0", + "Settings": { + "CaBundle": "/etc/ssl/certs/ca-certificates.crt", + "Empty": {}, + "Relative": "mkosi.conf", + "Value": null + }, + "SourceDateEpoch": 1771372800 + } + ], + "Version": "27.1" +} diff --git a/extensions/tests/data/summary-sample.normalized.json b/extensions/tests/data/summary-sample.normalized.json new file mode 100644 index 00000000..0ff0fb1e --- /dev/null +++ b/extensions/tests/data/summary-sample.normalized.json @@ -0,0 +1,48 @@ +{ + "Images": [ + { + "Architecture": "x86-64", + "BaseTrees": [ + "@REPO/.build/mkosi/base-image/current", + "@REPO/mkosi/mkosi.images/sci-libvirt" + ], + "DelegateRanges": null, + "Distribution": "gardenlinux", + "Format": "sysext", + "Image": "sci-base", + "Note": "héllo wörld ✓", + "Output": "@REPO/.build/mkosi/output/sci-base.raw", + "Packages": [ + "conntrack", + "parted" + ], + "ProxyExclude": null, + "Release": "2150.9.0", + "Scale": 1.5, + "Seed": null, + "SourceDateEpoch": 1771372800, + "Tags": [] + }, + { + "Architecture": "x86-64", + "DelegateRanges": null, + "Distribution": "gardenlinux", + "Flag": true, + "Format": "disk", + "Image": "main", + "Output": "@ABS", + "ProxyExclude": null, + "Release": "2150.9.0", + "RepoRoot": "@ABS", + "Seed": null, + "Settings": { + "CaBundle": "@ABS", + "Empty": {}, + "Relative": "mkosi.conf", + "Value": null + }, + "SourceDateEpoch": 1771372800 + } + ], + "Version": "27.1" +} diff --git a/extensions/tests/pytest.ini b/extensions/tests/pytest.ini new file mode 100644 index 00000000..32312c26 --- /dev/null +++ b/extensions/tests/pytest.ini @@ -0,0 +1,6 @@ +[pytest] +addopts = -ra +markers = + vm: boots a VM or needs the packaged guest runtime + build: expensive image builds or resolved-config/tools pins + build_state_mutating: mutates the shared .build state diff --git a/extensions/tests/summary.py b/extensions/tests/summary.py new file mode 100644 index 00000000..6840bcce --- /dev/null +++ b/extensions/tests/summary.py @@ -0,0 +1,236 @@ +"""The resolved-config pin machinery: `mkosi summary --json` normalization.""" + +from __future__ import annotations + +import hashlib +import json +import os +import re +import subprocess +import sys +from pathlib import Path +from typing import Any + + +class SummaryError(RuntimeError): + """A summary invocation or pin precondition failed (the build would too).""" + + +class SummaryShapeError(SummaryError): + """The JSON shape is not what `normalize_summary` expects (jq parity).""" + + +def _repo_prefix(repo_root: Path | str) -> str: + """`$repo` with one trailing "/" stripped, plus the "/" suffix.""" + repo = str(repo_root) + if repo.endswith("/"): + repo = repo[:-1] + return repo + "/" + + +def _rewrite(value: Any, prefix: str) -> Any: + """Recursive `walk(norm($prefix))`: values only, never dict keys.""" + if isinstance(value, dict): + return {key: _rewrite(item, prefix) for key, item in value.items()} + if isinstance(value, list): + return [_rewrite(item, prefix) for item in value] + if isinstance(value, str): + if value.startswith(prefix): + return "@REPO/" + value[len(prefix) :] + if value.startswith("/"): + return "@ABS" + return value + return value + + +def _null_seeds(data: Any) -> Any: + """`.Images |= map(.Seed = null)` with the shape errors made explicit.""" + if not isinstance(data, dict): + raise SummaryShapeError( + "the summary top level is not a JSON object; .Images cannot be indexed" + ) + if "Images" not in data: + # An absent `Images` key is a no-op, unlike jq 1.7 (documented contract). + return data + images = data["Images"] + if not isinstance(images, list): + raise SummaryShapeError(f"Images is {type(images).__name__}, not a list") + for index, image in enumerate(images): + if not isinstance(image, dict): + raise SummaryShapeError(f"Images[{index}] is not an object") + image["Seed"] = None + # uid-dependent mkosi default (root=0, non-root=3): null it like Seed + image["DelegateRanges"] = None + # environment-derived via no_proxy/NO_PROXY: null it like Seed/DelegateRanges + image["ProxyExclude"] = None + return data + + +def normalize_summary_data(data: Any, repo_root: Path | str) -> bytes: + """Normalize an already-parsed summary object into the pinned bytes.""" + prefix = _repo_prefix(repo_root) + normalized = _null_seeds(_rewrite(data, prefix)) + text = json.dumps(normalized, sort_keys=True, indent=2, ensure_ascii=False) + "\n" + return text.encode("utf-8") + + +def normalize_summary(summary_json: Path | str, repo_root: Path | str) -> bytes: + """Normalize `` against `` (byte-exact).""" + data = json.loads(Path(summary_json).read_text(encoding="utf-8")) + return normalize_summary_data(data, repo_root) + + +def normalize_summary_text(text: str, repo_root: Path | str) -> bytes: + """Normalize raw `mkosi summary --json` stdout.""" + return normalize_summary_data(json.loads(text), repo_root) + + +def normalized_digest(summary_json: Path | str, repo_root: Path | str) -> str: + """sha256 hex of the normalized bytes.""" + return hashlib.sha256(normalize_summary(summary_json, repo_root)).hexdigest() + + +def summary_command(mkosi_bin: Path | str, epoch: int) -> list[str]: + """build.sh's invocation shape; pure, therefore unit-testable.""" + return [str(mkosi_bin), f"--source-date-epoch={epoch}", "summary", "--json"] + + +def _abspath(path: Path | str) -> Path: + """Absolute paths pass through; a relative path resolves against the cwd.""" + p = Path(path) + return p if p.is_absolute() else (Path.cwd() / p) + + +def _release_suite(config_dir: Path) -> str: + """The one `^Release=` value of `/mkosi.conf`.""" + conf = config_dir / "mkosi.conf" + if not conf.is_file(): + raise SummaryError(f"{conf} missing") + values = [ + line[len("Release=") :].strip() + for line in conf.read_text(encoding="utf-8").splitlines() + if line.startswith("Release=") + ] + if len(values) != 1: + raise SummaryError(f"{conf} carries {len(values)} Release= lines (expected one)") + if not values[0]: + raise SummaryError(f"{conf} has a Release= line but no suite value") + return values[0] + + +def _tail(text: str, n: int = 20) -> str: + return "\n".join(text.splitlines()[-n:]) + + +def run_summary( + mkosi_bin: Path | str, + config_dir: Path | str, + garden_version: Path | str, + *, + repo_root: Path | str, + timeout: int | None = 600, +) -> str: + """Return the raw `--json` summary on stdout.""" + mkosi_path = _abspath(mkosi_bin) + config_path = _abspath(config_dir) + version_path = _abspath(garden_version) + _ = str(repo_root) # kept for API symmetry; the invocation does not use it + + suite = _release_suite(config_path) + if not (version_path.is_file() and os.access(version_path, os.X_OK)): + raise SummaryError( + f"{version_path} missing or not executable (git submodule update " + "--init gardenlinux)" + ) + + try: + epoch_res = subprocess.run( + [str(version_path), "--epoch", suite], + capture_output=True, + text=True, + timeout=timeout, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + raise SummaryError(f"cannot derive the epoch from Release={suite}: {exc}") from exc + epoch = epoch_res.stdout.strip() + if epoch_res.returncode != 0: + raise SummaryError( + f"cannot derive the epoch from Release={suite}:\n" + f"{_tail(epoch_res.stderr, 5)}" + ) + if not re.fullmatch(r"[0-9]+", epoch): + raise SummaryError(f"'{version_path} --epoch {suite}' is no timestamp: '{epoch}'") + + try: + res = subprocess.run( + summary_command(mkosi_path, int(epoch)), + cwd=config_path, + capture_output=True, + text=True, + timeout=timeout, + ) + except subprocess.TimeoutExpired as exc: + tail = _tail(exc.stderr) if isinstance(exc.stderr, str) else "" + raise SummaryError(f"timeout: mkosi summary took over {timeout}s\n{tail}") from exc + except OSError as exc: + raise SummaryError(f"cannot execute {mkosi_path}: {exc}") from exc + if res.returncode != 0: + raise SummaryError( + f"mkosi summary exited {res.returncode}\n{_tail(res.stderr)}" + ) + return res.stdout + + +def resolved_config_digest( + mkosi_bin: Path | str, + config_dir: Path | str, + garden_version: Path | str, + repo_root: Path | str, + *, + runs: int = 2, +) -> str: + """Digest of the normalized resolved config, stable across repeated runs.""" + digests: list[str] = [] + for index in range(1, max(runs, 1) + 1): + raw = run_summary( + mkosi_bin, config_dir, garden_version, repo_root=repo_root + ) + try: + normalized = normalize_summary_text(raw, repo_root) + except ValueError as exc: # json.JSONDecodeError: the shape moved + raise SummaryShapeError( + f"normalization of the mkosi summary failed: {exc}" + ) from exc + digests.append(hashlib.sha256(normalized).hexdigest()) + if len(set(digests)) > 1: + raise SummaryError( + "mkosi summary is NOT stable run-to-run after normalization " + f"(digests: {', '.join(digests)}): a field is volatile, not skipped" + ) + return digests[0] + + +USAGE = "usage: python3 extensions/tests/summary.py " + + +def main(argv: list[str] | None = None) -> int: + args = list(sys.argv[1:] if argv is None else argv) + if len(args) != 2 or not Path(args[0]).is_file(): + print(USAGE, file=sys.stderr) + return 2 + try: + normalized = normalize_summary(args[0], args[1]) + except SummaryError as exc: + print(f"summary-normalize: {exc}", file=sys.stderr) + return 1 + except (OSError, ValueError) as exc: + print(f"summary-normalize: cannot parse {args[0]}: {exc}", file=sys.stderr) + return 1 + # bytes, not text: stdout must be byte-identical to jq's output + sys.stdout.buffer.write(normalized) + sys.stdout.buffer.flush() + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/extensions/tests/test_artifact_contracts.py b/extensions/tests/test_artifact_contracts.py new file mode 100644 index 00000000..01fbded4 --- /dev/null +++ b/extensions/tests/test_artifact_contracts.py @@ -0,0 +1,206 @@ +"""Public input and raw-image contracts. Build checks require explicit consent.""" +import hashlib +import json +import os +from pathlib import Path +import re +import subprocess +import uuid + +import pytest + +import summary + +ROOT = Path(__file__).resolve().parents[2] +MKOSI = ROOT / "mkosi" +STATE = ROOT / ".build/mkosi" +IMAGES = ("sci-10-base", "sci-20-compute", "sci-21-network", "sci-22-storage", "sci-40-libvirt", "sci-50-virt-ch", "sci-51-virt-qemu") +TYPES = ("4f68bce3-e8cd-4db1-96e7-fbcaf984b709", "2c7357ed-ebd2-46d9-aec1-23d437ec2bf5", + "41092b05-9fc8-4523-994f-2def0408b176") + + +def run(*args, cwd=ROOT): + result = subprocess.run(args, cwd=cwd, capture_output=True, text=True, timeout=1800) + assert result.returncode == 0, f"{args} in {cwd}: {result.stdout}\n{result.stderr}" + return result.stdout + + +def values(path, key): + return re.findall(rf"^{re.escape(key)}=(.*)$", path.read_text(), re.M) + + +def pin(path): + lines = [line.strip() for line in path.read_text().splitlines() + if line.strip() and not line.lstrip().startswith("#")] + assert len(lines) == 1 and re.fullmatch(r"[0-9a-f]{64}", lines[0]), path + return lines[0] + + +def epoch(): + release, = values(MKOSI / "mkosi.conf", "Release") + return run(str(ROOT / "bin/garden-version"), "--epoch", release).strip() + + +@pytest.fixture(scope="session") +def evidence_root() -> Path: + """A fresh per-run extraction root under the repo's persistent .build.""" + base = ROOT / ".build" / "sysext-tests" + base.mkdir(parents=True, exist_ok=True) + root = base / uuid.uuid4().hex + root.mkdir() + return root + + +def extract_root(image, dest): + """EROFS root of .raw extracted under dest/root, idempotent: an + extraction left by an earlier test in this session is reused as-is.""" + root = dest / "root" + if (root / "usr").exists(): + return root + raw = STATE / "output" / f"{image}.raw" + assert raw.is_file(), f"build artifact missing: {raw}" + table = json.loads(run("sfdisk", "--json", str(raw)))["partitiontable"] + assert table["label"] == "gpt" and table["sectorsize"] == 512 + partitions = table["partitions"] + assert [p["type"].lower() for p in partitions] == list(TYPES), partitions + assert all(p["size"] > 0 for p in partitions) + dest.mkdir(parents=True, exist_ok=True) + part = dest / "root.erofs" + run("dd", f"if={raw}", f"of={part}", "bs=512", f"skip={partitions[0]['start']}", + f"count={partitions[0]['size']}", "status=none") + builder = os.environ.get("MKOSI_BUILDER_IMAGE") + if not builder: + builder, = values(ROOT / "build", "container_image") + assert re.search(r"(?:@sha256:[0-9a-f]{64}|:[0-9a-f]{40})$", builder), builder + root.mkdir(exist_ok=True) + run(os.environ.get("MKOSI_CONTAINER_ENGINE", "docker"), "run", "--rm", "--pull=never", + "--network=none", "--security-opt", "seccomp=unconfined", "--security-opt", "apparmor=unconfined", + "--user", f"{os.getuid()}:{os.getgid()}", "--entrypoint", "fsck.erofs", + "-v", f"{ROOT}:{ROOT}", builder, f"--extract={root}", "--force", "--preserve-perms", str(part)) + return root + + +def root_files(root): + """Every file and symlink under an extracted root, as posix relative paths.""" + return {p.relative_to(root).as_posix() for p in root.rglob("*") if p.is_file() or p.is_symlink()} + + +def overlapping_paths(a, b): + """Paths present in both path sets: non-empty means one layer re-ships the other.""" + return a & b + + +def test_static_input_contract(): + release, = values(MKOSI / "mkosi.conf", "Release") + assert release == "2150.9.0" + assert values(MKOSI / "suite.pin", "SUITE") == [release] + assert not (MKOSI / "mkosi.images/base").exists() + assert not (MKOSI / "generate-base-packages.sh").exists() + deployables = {p.parent.name for p in (MKOSI / "mkosi.images").glob("*/mkosi.conf") + if values(p, "Format") == ["sysext"]} + assert deployables == set(IMAGES) + assert not (MKOSI / "mkosi.images/sci-40-libvirt-tree").exists(), ( + "the directory-tree twin of sci-40-libvirt existed only as an Overlay dedup parent for the " + "virt images; the decoupling removed that role") + for image in IMAGES: + conf = MKOSI / "mkosi.images" / image / "mkosi.conf" + # The seed derives from the directory name, so a rename recomputes it. + digest = hashlib.sha256(f"sci:{release}:{image}".encode()).hexdigest()[:32] + seed = "-".join(digest[a:b] for a, b in ((0, 8), (8, 12), (12, 16), (16, 20), (20, 32))) + assert values(conf, "Seed") == [seed], image + assert values(conf, "Overlay") == ["yes"] + base, = values(conf, "BaseTrees") + parents = base.split() + assert (conf.parent / parents[0]).resolve() == (STATE / "base-image/current").resolve() + assert len(parents) == 1, ( + image, "every extension must build standalone on the published base: a second " + "base-tree entry re-introduces a build-time parent that couples rebuilds") + assert values(conf, "Dependencies") == [], ( + image, "inter-extension build dependencies couple rebuilds: a package change must " + "rebuild exactly one extension; runtime merge order is handled by the deployment " + "naming, not by build topology") + assert "usr/lib/verity.d" in values(conf, "RemoveFiles") + assert "usr/lib/modules/*/vmlinuz" in values(conf, "RemoveFiles") + if image in {"sci-50-virt-ch", "sci-51-virt-qemu"}: + # apt installs the full libvirt set; explicit globs keep it out of the delta. + globs = values(conf, "RemoveFiles") + for path in ("usr/share/libvirt/cpu_map", "usr/share/libvirt/schemas", + "usr/lib/sysusers.d/libvirt.conf"): + assert any(path in g for g in globs), ( + image, path, "the driver packages' Depends pulls the libvirt set into the " + "virt builds; without explicit removal the delta double-ships the " + "libvirt-owned paths (420 in ch, 265 in qemu) and the lexical overlay merge " + "silently shadows one copy") + for name in ("tools.pin", "summary.pin"): + pin(MKOSI / name) + assert values(MKOSI / "base-image.pin", "VERSION") == [release] + for field in ("TARBALL_SHA256", "ROOTFS_TAR_SHA256"): + digest, = values(MKOSI / "base-image.pin", field) + assert re.fullmatch(r"[0-9a-f]{64}", digest) + + +def test_qemu_vnc_range_carrier(): + extra = ROOT / "mkosi/mkosi.images/sci-51-virt-qemu/mkosi.extra" + rules = [line.strip() for line in + (extra / "usr/lib/tmpfiles.d/sci-virt-qemu.conf").read_text().splitlines()] + payload = extra / "usr/share/sci/etc/libvirt/qemu.conf" + assert payload.is_file(), f"the qemu VNC range payload must exist at {payload}" + lines = [line.strip() for line in payload.read_text().splitlines()] + assert any(re.fullmatch(r"d\s+/etc/libvirt\s+0755\s+root\s+root\s+-", line) for line in rules), ( + "the qemu fragment must create /etc/libvirt before the C rule copies into it") + assert any(re.fullmatch( + r"C\s+/etc/libvirt/qemu\.conf\s+0644\s+root\s+root\s+-\s+/usr/share/sci/etc/libvirt/qemu\.conf", + line) for line in rules), ( + "the qemu fragment must materialize /etc/libvirt/qemu.conf from the /usr payload with type C") + assert sum("remote_display_port" in line for line in lines) == 2, ( + "the payload must declare exactly the two VNC port settings") + assert sum(bool(re.fullmatch(r"remote_display_port_min\s*=\s*32200", line)) for line in lines) == 1, ( + "the payload must set remote_display_port_min = 32200 for the NodePort range") + assert sum(bool(re.fullmatch(r"remote_display_port_max\s*=\s*32299", line)) for line in lines) == 1, ( + "the payload must set remote_display_port_max = 32299 for the NodePort range") + assert not [line for line in lines if line.startswith("#") and "remote_display_port" in line], ( + "the payload must be a partial conffile with no commented-out port settings") + + +@pytest.mark.build +def test_tools_manifest_pin(): + manifest = STATE / "tools/mkosi.tools.manifest" + assert manifest.is_file(), f"pinned tools manifest missing: {manifest}" + assert hashlib.sha256(manifest.read_bytes()).hexdigest() == pin(MKOSI / "tools.pin") + + +@pytest.mark.build +def test_resolved_summary_pin(): + binary = STATE / "venv/bin/mkosi" + assert binary.is_file(), f"mkosi venv missing: {binary}" + digest = summary.resolved_config_digest(binary, MKOSI, ROOT / "bin/garden-version", ROOT) + assert digest == pin(MKOSI / "summary.pin") + + +@pytest.mark.build +def test_virt_deltas_do_not_reship_libvirt(evidence_root): + libvirt = root_files(extract_root("sci-40-libvirt", evidence_root / "extracted" / "sci-40-libvirt")) + for image in ("sci-50-virt-ch", "sci-51-virt-qemu"): + virt = root_files(extract_root(image, evidence_root / "extracted" / image)) + overlap = overlapping_paths(virt, libvirt) + assert not overlap, ( + image, "the virt deltas must not re-ship any sci-40-libvirt-owned path: with the " + "parent-tree dedup gone, RemoveFiles= is the only guard, and a package update that " + "adds a file outside the globs must fail here rather than silently shadow in the " + "overlay merge; first offenders: " + ", ".join(sorted(overlap)[:10])) + + +@pytest.mark.build +@pytest.mark.build_state_mutating +def test_reproducible(): + run("bash", str(MKOSI / "build.sh")) + def hashes(): + return {image: hashlib.sha256((STATE / "output" / f"{image}.raw").read_bytes()).hexdigest() + for image in IMAGES} + first = hashes() + run(str(STATE / "venv/bin/mkosi"), f"--source-date-epoch={epoch()}", "-f", "build", cwd=MKOSI) + second = hashes() + assert second == first, f"rebuild changed the output hashes: {first} != {second}" + for image in IMAGES: + print(f"build1 {image} {first[image]}") + print(f"build2 {image} {second[image]}") diff --git a/extensions/tests/test_summary.py b/extensions/tests/test_summary.py new file mode 100644 index 00000000..95ca1e73 --- /dev/null +++ b/extensions/tests/test_summary.py @@ -0,0 +1,72 @@ +"""Golden and CLI contract for the retained summary normalizer.""" + +import json +import subprocess +import sys +from pathlib import Path + +import summary + +HERE = Path(__file__).resolve().parent +SAMPLE = HERE / "data/summary-sample.json" +GOLDEN = HERE / "data/summary-sample.normalized.json" +REPO = "/repo" +GOLDEN_SHA = "db139583aba7cb540a3f371d458a45cc9267f5449b352d69de38b8e0a50178de" + + +def test_golden_normalize_and_digest(): + assert summary.normalize_summary(SAMPLE, REPO) == GOLDEN.read_bytes() + assert summary.normalized_digest(SAMPLE, REPO) == GOLDEN_SHA + + +def test_cli_parity(): + out = subprocess.run([sys.executable, str(HERE / "summary.py"), str(SAMPLE), REPO], + capture_output=True, check=True) + assert out.stdout == GOLDEN.read_bytes() + + +def test_changed_release_changes_normalization(tmp_path): + data = json.loads(SAMPLE.read_text(encoding="utf-8")) + data["Images"][0]["Release"] = "2150.9.1" + changed = tmp_path / "changed.json" + changed.write_text(json.dumps(data), encoding="utf-8") + assert summary.normalize_summary(changed, REPO) != GOLDEN.read_bytes() + + +def test_delegate_ranges_uid_default_does_not_move_digest(tmp_path): + # mkosi resolves DelegateRanges from uid (root=0, non-root=3); the pin must not move. + root_run = json.loads(SAMPLE.read_text(encoding="utf-8")) + for image in root_run["Images"]: + image["DelegateRanges"] = 0 + nonroot_run = json.loads(SAMPLE.read_text(encoding="utf-8")) + for image in nonroot_run["Images"]: + image["DelegateRanges"] = 3 + root_file = tmp_path / "root-run.json" + root_file.write_text(json.dumps(root_run), encoding="utf-8") + nonroot_file = tmp_path / "nonroot-run.json" + nonroot_file.write_text(json.dumps(nonroot_run), encoding="utf-8") + assert summary.normalized_digest(root_file, REPO) == \ + summary.normalized_digest(nonroot_file, REPO), ( + "DelegateRanges is a uid-dependent mkosi default (root=0, non-root=3); " + "the pin must not depend on who ran mkosi" + ) + + +def test_proxy_exclude_env_default_does_not_move_digest(tmp_path): + # mkosi emits no_proxy/NO_PROXY as ProxyExclude; the pin must not depend on the shell. + no_proxy_run = json.loads(SAMPLE.read_text(encoding="utf-8")) + for image in no_proxy_run["Images"]: + image["ProxyExclude"] = ["127.0.0.1", "localhost", "::1"] + clean_run = json.loads(SAMPLE.read_text(encoding="utf-8")) + no_proxy_file = tmp_path / "no-proxy-run.json" + no_proxy_file.write_text(json.dumps(no_proxy_run), encoding="utf-8") + clean_file = tmp_path / "clean-run.json" + clean_file.write_text(json.dumps(clean_run), encoding="utf-8") + assert summary.normalized_digest(clean_file, REPO) == \ + summary.normalized_digest(no_proxy_file, REPO), ( + "ProxyExclude is an environment-derived mkosi default (read from " + "no_proxy/NO_PROXY); the pin must not depend on the invoking " + "shell's proxy variables, or contributors with no_proxy set (and " + "the development harness here) fail the pin test for reasons unrelated " + "to the tree" + ) diff --git a/mkosi/base-image.pin b/mkosi/base-image.pin new file mode 100644 index 00000000..1fbd56dd --- /dev/null +++ b/mkosi/base-image.pin @@ -0,0 +1,11 @@ +# Published Garden Linux base image the extensions diff against; prepared by mkosi/base-image.sh. +# Fields: FLAVOR, ARCH, VERSION (= Release=), COMMIT, TARBALL_SHA256, ROOTFS_TAR_SHA256, URL (derived). +# Check: extensions/tests/test_host_contracts.py. +# Update VERSION/COMMIT/hashes together with Release=, mkosi/suite.pin, and mkosi/summary.pin. +FLAVOR=openstack-gardener_prod +ARCH=amd64 +VERSION=2150.9.0 +COMMIT=a6e35cf0c955dde584c67163a3a8dff6d60e9d56 +TARBALL_SHA256=3b5e705fa9d683048bd8657893f31d8a2f4a639ddda0249ca31b2e1c7b679bed +ROOTFS_TAR_SHA256=3baa1c461d87bb3a25ee86a73aca6b0f060a2b2a918c84c9fe4ad4bd7bbd4cb8 +URL=https://github.com/gardenlinux/gardenlinux/releases/download/2150.9.0/openstack-gardener_prod-amd64-2150.9.0-a6e35cf0.tar.xz diff --git a/mkosi/base-image.sh b/mkosi/base-image.sh new file mode 100755 index 00000000..fa4a3a01 --- /dev/null +++ b/mkosi/base-image.sh @@ -0,0 +1,333 @@ +#!/usr/bin/env bash +# Prepare and verify the published Garden Linux base tree used as the diff baseline. +# Usage: bash mkosi/base-image.sh [ensure|extract |digest ] +# Contract: docs/sysext-virt.md; behaviour pinned by extensions/tests/test_host_contracts.py. + +set -Eeuo pipefail + +SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P) +REPO_ROOT=$(cd -- "$SCRIPT_DIR/.." && pwd -P) + +PIN=${BASE_IMAGE_PIN:-$SCRIPT_DIR/base-image.pin} +CACHE_ROOT=${BASE_IMAGE_DIR:-$REPO_ROOT/.build/mkosi/base-image} +MKOSI_CONF=$SCRIPT_DIR/mkosi.conf + +OFFLINE=0 +case ${BASE_IMAGE_OFFLINE:-0} in + ''|0|no|false) ;; + *) OFFLINE=1 ;; +esac + +TMP_DIRS=() +cleanup() { + local d + for d in "${TMP_DIRS[@]}"; do + rm -rf -- "$d" + done +} +trap cleanup EXIT + +die() { + printf 'base-image: error: %s\n' "$*" >&2 + exit 1 +} + +log() { + printf 'base-image: %s\n' "$*" >&2 +} + +usage() { + cat <<'EOF' +usage: base-image.sh [ensure] + base-image.sh extract + base-image.sh digest + +Environment overrides: BASE_IMAGE_PIN, BASE_IMAGE_DIR, BASE_IMAGE_TARBALL, +BASE_IMAGE_URL, BASE_IMAGE_OFFLINE=1 +EOF +} + +# Last KEY=VALUE field of a pin file (empty when absent). +pin_field() { # + sed -n "s/^$2[[:space:]]*=[[:space:]]*//p" -- "$1" | tail -n 1 +} + +validate_pin() { + [ -f "$PIN" ] || die "missing pin $PIN (the committed pin of the published base image)" + local field count line + for field in FLAVOR ARCH VERSION COMMIT TARBALL_SHA256 ROOTFS_TAR_SHA256 URL; do + count=$(grep -cE "^${field}[[:space:]]*=" -- "$PIN") || count=0 + [ "$count" -eq 1 ] \ + || die "$PIN: expected exactly one $field= line, found $count" + done + while IFS= read -r line; do + if [[ $line =~ ^[[:space:]]*(#|$) ]]; then + continue + fi + if [[ ! $line =~ ^(FLAVOR|ARCH|VERSION|COMMIT|TARBALL_SHA256|ROOTFS_TAR_SHA256|URL)[[:space:]]*= ]]; then + die "$PIN: unrecognized line '$line'" + fi + done <"$PIN" + + PIN_FLAVOR=$(pin_field "$PIN" FLAVOR) + PIN_ARCH=$(pin_field "$PIN" ARCH) + PIN_VERSION=$(pin_field "$PIN" VERSION) + PIN_COMMIT=$(pin_field "$PIN" COMMIT) + PIN_OUTER_SHA=$(pin_field "$PIN" TARBALL_SHA256) + PIN_INNER_SHA=$(pin_field "$PIN" ROOTFS_TAR_SHA256) + PIN_URL=$(pin_field "$PIN" URL) + + [[ $PIN_FLAVOR =~ ^[A-Za-z0-9][A-Za-z0-9._-]*$ ]] \ + || die "$PIN: FLAVOR='$PIN_FLAVOR' is malformed" + [[ $PIN_ARCH =~ ^[A-Za-z0-9][A-Za-z0-9-]*$ ]] \ + || die "$PIN: ARCH='$PIN_ARCH' is malformed" + [[ $PIN_VERSION =~ ^[0-9]+(\.[0-9]+)+$ ]] \ + || die "$PIN: VERSION='$PIN_VERSION' is malformed (expected e.g. 2150.9.0)" + [[ $PIN_COMMIT =~ ^[0-9a-f]{40}$ ]] \ + || die "$PIN: COMMIT='$PIN_COMMIT' is not a full 40-hex-digit commit" + [[ $PIN_OUTER_SHA =~ ^[0-9a-f]{64}$ ]] \ + || die "$PIN: TARBALL_SHA256='$PIN_OUTER_SHA' is not a 64-hex-digit sha256" + [[ $PIN_INNER_SHA =~ ^[0-9a-f]{64}$ ]] \ + || die "$PIN: ROOTFS_TAR_SHA256='$PIN_INNER_SHA' is not a 64-hex-digit sha256" + + [ -f "$MKOSI_CONF" ] || die "missing $MKOSI_CONF (the Release= source of truth)" + local release_count release + release_count=$(grep -c '^Release=' -- "$MKOSI_CONF") || release_count=0 + [ "$release_count" -eq 1 ] \ + || die "$MKOSI_CONF carries $release_count Release= lines; expected exactly one" + release=$(sed -n 's/^Release=//p' -- "$MKOSI_CONF" | head -n 1) + [ "$PIN_VERSION" = "$release" ] \ + || die "$PIN: VERSION=$PIN_VERSION does not match Release=$release in $MKOSI_CONF (the pin must name the suite the extensions are built for)" + + local derived + derived="https://github.com/gardenlinux/gardenlinux/releases/download/$PIN_VERSION/$PIN_FLAVOR-$PIN_ARCH-$PIN_VERSION-${PIN_COMMIT:0:8}.tar.xz" + [ "$PIN_URL" = "$derived" ] \ + || die "$PIN: URL='$PIN_URL' does not match the URL derived from the pin's fields ('$derived')" +} + +cache_key() { + printf '%s-%s-%s-%s' "$PIN_FLAVOR" "$PIN_ARCH" "$PIN_VERSION" "${PIN_COMMIT:0:8}" +} + +inner_member() { + printf '%s.tar' "$(cache_key)" +} + +tree_digest() { # + tar -C "$1" -cf - --sort=name --owner=0 --group=0 --numeric-owner --mtime='@0' . \ + | sha256sum | awk '{print $1}' +} + +verify_sha256() { #