Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 10 additions & 0 deletions debian/changelog
Original file line number Diff line number Diff line change
@@ -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 <mendez.foto@gmail.com> 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
Expand Down
2 changes: 1 addition & 1 deletion keel/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 2 additions & 1 deletion keel/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
7 changes: 7 additions & 0 deletions keel/layers/assemble.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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)
105 changes: 105 additions & 0 deletions keel/layers/container.py
Original file line number Diff line number Diff line change
@@ -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),
)
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
15 changes: 15 additions & 0 deletions tests/layers_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand All @@ -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),
])


Expand Down
Loading
Loading