diff --git a/README.md b/README.md index 60d2a43..4e21db9 100644 --- a/README.md +++ b/README.md @@ -121,7 +121,7 @@ Every command accepts the same three options, so a caller never has to branch: | `--cache-dir DIR` | The layer cache. Default: `$KEEL_CACHE_DIR`, else `/var/cache/keel/layers` | | `--source URL-or-DIR` | `pull` only, required: where manifests and tarballs are served, for example `http://[2001:db8:19::1]/layers` or `/mnt/builds/layers` | | `--rootfs DIR` | `assemble` only, required: the directory to extract into; created, must be empty | -| `--template FILE` | `assemble` only: also pack the rootfs into this `.tar.zst`, with `FILE.sha512` next to it | +| `--template FILE` | `assemble` only: also pack the rootfs into this `.tar.zst`, with `FILE.sha512` next to it. A template is a container template: it is refused, and nothing is written, when the rootfs's dpkg database records a kernel, initrd, firmware, bootloader or other package only a machine boots with (`keel.layers.container`, decision 0052); the ISO is made from a rootfs assembled without `--template` | | `--sha256 HEX` | `assemble` only: which cached version of `LAYER`, when more than one is cached | `keel` and `python3 -m keel` are the same program. diff --git a/debian/changelog b/debian/changelog index 391d3bf..522dc5c 100644 --- a/debian/changelog +++ b/debian/changelog @@ -1,3 +1,13 @@ +keel (0.23.13) unstable; urgency=medium + + * keel assemble refuses to pack a template whose rootfs records a + package only a machine boots with: the kernel, the initrd tools, + firmware, GRUB and the other bootloaders, the live boot and the + installer, the QEMU guest agent, acpid (keel.layers.container, + handbook decision 0052). They belong in the boot layer the ISO is + made from; a rootfs assembled without --template is not checked. + + -- Marcos Mendez Fri, 09 Oct 2026 22:30:00 +0000 keel (0.23.12) unstable; urgency=medium * A member that keel mesh sync adds live is kept with no handshake diff --git a/keel/__init__.py b/keel/__init__.py index 1fa1c9f..a13e005 100644 --- a/keel/__init__.py +++ b/keel/__init__.py @@ -12,7 +12,7 @@ # an image's tree read offline): debian/changelog's and pyproject.toml's, # which tests/test_version.py keeps in step. Installed, the metadata # dpkg put beside the package answers (keel#98). -FALLBACK_VERSION = "0.23.12" +FALLBACK_VERSION = "0.23.13" def version() -> str: diff --git a/keel/cli.py b/keel/cli.py index 6680b08..8c9c8da 100644 --- a/keel/cli.py +++ b/keel/cli.py @@ -504,7 +504,8 @@ def add_assemble_options(parser: argparse.ArgumentParser) -> None: default=None, metavar="FILE", help="also pack the rootfs into this .tar.zst, with FILE.sha512" - " next to it", + " next to it; refused when the rootfs has a kernel, bootloader or" + " other package only a machine boots with (decision 0052)", ) parser.add_argument( "--sha256", diff --git a/keel/layers/assemble.py b/keel/layers/assemble.py index 0f10a2e..9af5ffa 100644 --- a/keel/layers/assemble.py +++ b/keel/layers/assemble.py @@ -10,6 +10,11 @@ Extracting a rootfs restores owners, device nodes and extended attributes, which only root can do, so the command refuses to start as anyone else instead of producing a tree that looks right and is not. + +A template is a container template: before it is packed, the rootfs is +checked for the packages only a machine boots with (keel.layers.container, +decision 0052), and refused when it has any. A rootfs assembled without +a template, which the ISO is made from, is not checked. """ import os @@ -19,6 +24,7 @@ from keel import exits from keel.layers.cache import Cache +from keel.layers.container import check as check_container from keel.layers.errors import LayerError from keel.layers.extract import Applied, apply_layer from keel.layers.manifest import Manifest @@ -112,5 +118,6 @@ def assemble( shutil.rmtree(work) packed = None if template is not None: + check_container(rootfs) packed = pack(rootfs, template, top.source_date_epoch) return AssembleReport(rootfs, applied, packed) diff --git a/keel/layers/container.py b/keel/layers/container.py new file mode 100644 index 0000000..444b6da --- /dev/null +++ b/keel/layers/container.py @@ -0,0 +1,105 @@ +# Copyright (c) 2026 KeelLinux maintainers +"""A container template holds no kernel, initrd or bootloader + +Decision 0052: every release ships a container template and a bootable +ISO from the same build. The packages only a virtual or a real machine +needs (the kernel, the initrd, firmware, GRUB, the installer) are in a +boot layer of their own, on top of the appliance layer and used by the +ISO alone; the layers a template is assembled from never carry them. + +`check` is the guard: assemble refuses to pack a template whose dpkg +database records any of them, so a plan that pulls a kernel back into a +regular layer fails the release instead of shipping 190 MB an LXC +container never runs. +""" + +import fnmatch +import os + +from keel import exits +from keel.layers.errors import LayerError + +# fnmatch patterns of package names, and why each has no use in an LXC +# container, which runs on the host's kernel and boots nothing itself +MACHINE_ONLY = ( + ("linux-image-*", "the kernel"), + ("linux-headers-*", "headers of a kernel the container never runs"), + ("linux-base", "kernel install hooks"), + ("initramfs-tools*", "builds an initrd"), + ("dracut*", "builds an initrd"), + ("klibc-utils", "initrd early userspace"), + ("libklibc", "initrd early userspace"), + ("live-boot*", "boots the live ISO"), + ("live-config*", "boots the live ISO"), + ("live-tools", "boots the live ISO"), + ("tkl-installer", "installs the live ISO onto a disk"), + ("firmware-*", "device firmware, loaded by a machine's kernel"), + ("*-microcode", "CPU microcode, loaded at boot"), + ("grub*", "bootloader"), + ("shim*", "UEFI Secure Boot loader"), + ("efibootmgr", "UEFI boot entries"), + ("mokutil", "UEFI Secure Boot keys"), + ("efivar", "UEFI variables"), + ("syslinux*", "bootloader of the ISO"), + ("isolinux", "bootloader of the ISO"), + ("extlinux", "bootloader"), + ("os-prober", "finds other systems for the GRUB menu"), + ("hdparm", "tunes physical disks"), + ("qemu-guest-agent", "answers a QEMU host over virtio-serial"), + ("acpi-support-base", "ACPI events come from a machine's firmware"), + ("acpid", "ACPI events come from a machine's firmware"), + ("jitterentropy-rngd", "feeds the entropy pool of the kernel, the" + " host's in a container"), +) + +STATUS = "var/lib/dpkg/status" + + +def installed(status: str) -> list[str]: + """Names of the packages dpkg's status text records as present + + A package removed but not purged still has its conffiles, so it + counts: only "not-installed" is absent. + """ + names = [] + for stanza in status.split("\n\n"): + fields = {} + for line in stanza.splitlines(): + key, sep, value = line.partition(":") + if sep and not line[:1].isspace(): + fields[key] = value.strip() + state = fields.get("Status", "").split() + if "Package" in fields and state and state[-1] != "not-installed": + names.append(fields["Package"]) + return sorted(names) + + +def machine_only(names: list[str]) -> list[str]: + """The names that match a MACHINE_ONLY pattern""" + return [ + name for name in names + if any(fnmatch.fnmatchcase(name, pattern) for pattern, _ in MACHINE_ONLY) + ] + + +def check(rootfs: str) -> None: + """Refuse a rootfs to be packed as a container template + + Raises LayerError(ASSEMBLE_FAILED) when its dpkg database cannot be + read or records a machine-only package. + """ + path = os.path.join(rootfs, STATUS) + try: + with open(path, encoding="utf-8") as fob: + status = fob.read() + except (OSError, UnicodeDecodeError) as e: + raise LayerError( + exits.ASSEMBLE_FAILED, f"{path}: cannot read the dpkg database: {e}" + ) from e + present = machine_only(installed(status)) + if present: + raise LayerError( + exits.ASSEMBLE_FAILED, + "not a container template: the rootfs has packages only a" + " machine boots with (decision 0052): " + " ".join(present), + ) diff --git a/pyproject.toml b/pyproject.toml index 9f6f763..9f0f843 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "keel" -version = "0.23.12" +version = "0.23.13" authors = [{name = "KeelLinux maintainers"}] description = "Declarative management of one appliance instance" readme = "README.md" diff --git a/tests/layers_helpers.py b/tests/layers_helpers.py index 246c866..571944b 100644 --- a/tests/layers_helpers.py +++ b/tests/layers_helpers.py @@ -163,6 +163,17 @@ def zstd(data: bytes) -> bytes: ).stdout +# what dpkg records of a container image: no kernel, a kernel removed and +# purged, so a template may be packed from it (keel.layers.container) +DPKG_STATUS = b"""Package: base-files +Status: install ok installed +Version: 13.8 + +Package: linux-image-amd64 +Status: purge ok not-installed +""" + + def core_tar() -> bytes: return tar_bytes([ member("./", "dir"), @@ -176,6 +187,10 @@ def core_tar() -> bytes: member("./usr/bin", "dir"), member("./usr/bin/tool", data=b"#!/bin/sh\n", mode=0o755, xattrs=dict([TOOL_XATTR])), + member("./var", "dir"), + member("./var/lib", "dir"), + member("./var/lib/dpkg", "dir"), + member("./var/lib/dpkg/status", data=DPKG_STATUS), ]) diff --git a/tests/test_layers_container.py b/tests/test_layers_container.py new file mode 100644 index 0000000..53d9593 --- /dev/null +++ b/tests/test_layers_container.py @@ -0,0 +1,189 @@ +# Copyright (c) 2026 KeelLinux maintainers +"""A container template never holds a kernel or a bootloader (decision 0052) + +The package list of the published core 19.0-9 template (which still had +them) is the fixture of the patterns; the assemble tests use the +synthetic layers of tests/layers_helpers.py with a dpkg status file +added to the top layer. +""" + +import os +import shutil +import tempfile +import unittest +from os.path import exists, join +from unittest import mock + +from layers_helpers import ( + build_source, + member, + tar_bytes, + zstd, +) + +from keel import exits +from keel.layers import LayerError, assemble, pull +from keel.layers.container import ( + MACHINE_ONLY, + check, + installed, + machine_only, +) + +AS_ROOT = mock.patch("os.geteuid", return_value=0) + +# What the core 19.0-9 template carried that a container never uses +CORE_19_0_9_MACHINE_ONLY = [ + "acpi-support-base", "acpid", "dracut-install", "efivar", + "firmware-linux-free", "grub-common", "grub-pc", "grub-pc-bin", + "grub2-common", "hdparm", "initramfs-tools", "initramfs-tools-bin", + "initramfs-tools-core", "isolinux", "jitterentropy-rngd", "klibc-utils", + "libklibc", "linux-base", "linux-image-6.12.111+deb13-amd64", + "linux-image-amd64", "live-boot", "live-boot-initramfs-tools", + "live-tools", "os-prober", "qemu-guest-agent", "syslinux", + "syslinux-common", "tkl-installer", +] +# and packages of that template a container keeps, some of them close in +# name to a machine-only one +CORE_19_0_9_KEPT = [ + "base-files", "busybox", "dmsetup", "eject", "fdisk", "kmod", + "libefiboot1t64", "libgrubby", "libudev1", "linux-libc-dev", "lvm2", + "parted", "systemd", "systemd-sysv", "udev", "webmin-fdisk", + "webmin-lvm", "zstd", +] + + +def stanza(name: str, status: str = "install ok installed") -> str: + return f"Package: {name}\nStatus: {status}\nVersion: 1\n" + + +def status_of(*names: str) -> str: + return "\n".join(stanza(name) for name in names) + + +class TestPatterns(unittest.TestCase): + def test_every_pattern_says_why(self): + for pattern, reason in MACHINE_ONLY: + self.assertTrue(pattern and reason.strip(), pattern) + + def test_the_kernel_and_bootloader_of_core_19_0_9_are_machine_only(self): + names = sorted(CORE_19_0_9_MACHINE_ONLY + CORE_19_0_9_KEPT) + self.assertEqual(machine_only(names), CORE_19_0_9_MACHINE_ONLY) + + def test_future_kernel_and_uefi_packages_are_machine_only(self): + names = [ + "linux-image-6.12.200+deb13-cloud-amd64", "linux-headers-amd64", + "grub-efi-amd64-signed", "shim-signed", "efibootmgr", "mokutil", + "firmware-misc-nonfree", "intel-microcode", "amd64-microcode", + "dracut", "live-config-systemd", "extlinux", + ] + self.assertEqual(machine_only(names), names) + + +class TestInstalled(unittest.TestCase): + def test_installed_half_configured_and_config_files_count(self): + text = "\n".join([ + stanza("a"), + stanza("b", "install ok half-configured"), + stanza("c", "deinstall ok config-files"), + stanza("d", "purge ok not-installed"), + ]) + self.assertEqual(installed(text), ["a", "b", "c"]) + + def test_continuation_lines_and_stanzas_without_a_status_are_skipped(self): + text = ( + "Package: a\nStatus: install ok installed\nDescription: x\n" + " Package: not-a-field\n\n" + "Package: no-status\n\n" + "Status: install ok installed\n\n\n" + ) + self.assertEqual(installed(text), ["a"]) + + +class TestCheck(unittest.TestCase): + def setUp(self): + self.rootfs = tempfile.mkdtemp() + os.makedirs(join(self.rootfs, "var/lib/dpkg")) + + def tearDown(self): + shutil.rmtree(self.rootfs) + + def write_status(self, text: str) -> None: + with open(join(self.rootfs, "var/lib/dpkg/status"), "w", + encoding="utf-8") as fob: + fob.write(text) + + def test_a_rootfs_without_machine_only_packages_passes(self): + self.write_status(status_of(*CORE_19_0_9_KEPT)) + check(self.rootfs) + + def test_a_rootfs_with_a_kernel_is_refused_and_named(self): + self.write_status(status_of("base-files", "linux-image-amd64", + "grub-pc")) + with self.assertRaises(LayerError) as raised: + check(self.rootfs) + self.assertEqual(raised.exception.code, exits.ASSEMBLE_FAILED) + self.assertIn("grub-pc linux-image-amd64", str(raised.exception)) + self.assertIn("decision 0052", str(raised.exception)) + + def test_a_kernel_removed_but_not_purged_is_refused(self): + self.write_status(stanza("linux-image-amd64", + "deinstall ok config-files")) + with self.assertRaises(LayerError): + check(self.rootfs) + + def test_a_rootfs_without_a_dpkg_database_is_refused(self): + with self.assertRaises(LayerError) as raised: + check(self.rootfs) + self.assertEqual(raised.exception.code, exits.ASSEMBLE_FAILED) + self.assertIn("cannot read the dpkg database", str(raised.exception)) + + def test_a_dpkg_database_that_is_not_text_is_refused(self): + with open(join(self.rootfs, "var/lib/dpkg/status"), "wb") as fob: + fob.write(b"\xff\xfe\x00") + with self.assertRaises(LayerError): + check(self.rootfs) + + +class TestAssembleRefusesAKernel(unittest.TestCase): + """A chain whose rootfs has a kernel assembles, and packs no template""" + + def setUp(self): + self.tmpdir = tempfile.mkdtemp() + self.source = join(self.tmpdir, "source") + self.cache = join(self.tmpdir, "cache") + self.rootfs = join(self.tmpdir, "rootfs") + self.template = join(self.tmpdir, "core.tar.zst") + kernel = tar_bytes([ + member("./", "dir"), + member("./var", "dir"), + member("./var/lib", "dir"), + member("./var/lib/dpkg", "dir"), + member("./var/lib/dpkg/status", + data=status_of("base-files", "linux-image-amd64").encode()), + ]) + build_source(self.source, core_data=zstd(kernel)) + pull("core", self.source, self.cache) + + def tearDown(self): + shutil.rmtree(self.tmpdir) + + def test_the_template_is_refused_and_not_written(self): + with AS_ROOT, self.assertRaises(LayerError) as raised: + assemble("core", self.cache, self.rootfs, self.template) + self.assertEqual(raised.exception.code, exits.ASSEMBLE_FAILED) + self.assertIn("linux-image-amd64", str(raised.exception)) + self.assertFalse(exists(self.template)) + self.assertFalse(exists(self.template + ".sha512")) + + def test_the_rootfs_alone_is_assembled_for_the_iso(self): + with AS_ROOT: + report = assemble("core", self.cache, self.rootfs) + self.assertIsNone(report.packed) + with open(join(self.rootfs, "var/lib/dpkg/status"), + encoding="utf-8") as fob: + self.assertIn("linux-image-amd64", fob.read()) + + +if __name__ == "__main__": + unittest.main()