diff --git a/docs/os-image.md b/docs/os-image.md index 9719c370..efadce18 100644 --- a/docs/os-image.md +++ b/docs/os-image.md @@ -1,6 +1,6 @@ # OS Images -Official pre-built OCI VM images (Ubuntu, Android), the Windows artifact, and the `os-image/` build harness. +Official pre-built OCI VM images (Ubuntu, Debian, Android), the Windows artifact, and the `os-image/` build harness. Pre-built OS images are hosted on [GitHub Container Registry](https://github.com/orgs/cocoonstack/packages?repo_name=cocoon). @@ -18,6 +18,16 @@ Multi-arch (`linux/amd64`, `linux/arm64`). | Ubuntu 24.04 + Xfce | `24.04-xface` | `ghcr.io/cocoonstack/cocoon/ubuntu:24.04-xface` | | Ubuntu 24.04 + PicoClaw | `24.04-picoclaw` | `ghcr.io/cocoonstack/cocoon/ubuntu:24.04-picoclaw` | +### Debian + +Multi-arch (`linux/amd64`, `linux/arm64`). Built from `os-image/debian/13/` with +the Debian 13 cloud kernel, Cocoon boot and networking hooks, and the +checksum-verified agent. + +| Image | Tag | IMAGE_NAME | +|-------|-----|------------| +| Debian 13 (Trixie) | `13` | `ghcr.io/cocoonstack/cocoon/debian:13` | + ### Android (Redroid) `linux/amd64` only. Runs Android via [Redroid](https://github.com/remote-android/redroid-doc) directly as PID 1 in the VM — no Ubuntu/systemd layer. diff --git a/os-image/debian/13/Dockerfile b/os-image/debian/13/Dockerfile new file mode 100644 index 00000000..29e53ae1 --- /dev/null +++ b/os-image/debian/13/Dockerfile @@ -0,0 +1,59 @@ +# Use the latest Debian 13 (Trixie) +FROM docker.io/library/debian:13 + +ARG TARGETARCH +ENV DEBIAN_FRONTEND=noninteractive + +# Combined System Setup & Optimization (Single Layer) +# Install packages first so /etc/initramfs-tools/scripts/ exists, then inject the hook. +RUN --mount=type=secret,id=cocoon_overlay \ + --mount=type=secret,id=cocoon_network \ + --mount=type=secret,id=cocoon_install_agent \ + apt-get update && apt-get install -y --no-install-recommends \ + linux-image-cloud-${TARGETARCH} \ + initramfs-tools \ + # initramfs-tools only recommends busybox and --no-install-recommends skips + # recommends; without it the initramfs has only klibc-utils (no tr, and + # klibc mount rejects options after operands). + busybox \ + systemd \ + systemd-sysv \ + systemd-timesyncd \ + systemd-resolved \ + udev \ + kmod \ + iproute2 iputils-ping curl wget arping tcpdump \ + openssh-server \ + ca-certificates \ + && \ + cp /run/secrets/cocoon_overlay /etc/initramfs-tools/scripts/cocoon-overlay && \ + chmod 0755 /etc/initramfs-tools/scripts/cocoon-overlay && \ + cp /run/secrets/cocoon_network /etc/initramfs-tools/scripts/init-bottom/cocoon-network && \ + chmod 0755 /etc/initramfs-tools/scripts/init-bottom/cocoon-network && \ + # [Kernel Setup] Force critical modules and set gzip compression + printf "erofs\noverlay\next4\nvirtio_blk\nvirtio_pci\nvirtio_ring\nvirtio_net\nvsock\nvmw_vsock_virtio_transport\n" >> /etc/initramfs-tools/modules && \ + sed -i 's/^COMPRESS=.*/COMPRESS=gzip/' /etc/initramfs-tools/initramfs.conf && \ + # [Networking] IP=off prevents initramfs from running DHCP during boot. + # Kernel ip= parameters (when present) override this and still trigger ipconfig. + # Without this, IP="" (default) causes dhcpcd to run unconditionally, which + # blocks boot on --nics 0 VMs and persists DHCP leases as static configs. + sed -i '/^IP=/d' /etc/initramfs-tools/initramfs.conf && \ + echo 'IP=off' >> /etc/initramfs-tools/initramfs.conf && \ + # Regenerate initramfs inside this layer (consumes the secret script) + update-initramfs -u -k all && \ + # [Shift-Left Hacks] Neuter systemd to avoid boot-time filesystem checks + truncate -s 0 /etc/fstab && \ + systemctl mask systemd-fsck-root.service systemd-remount-fs.service systemd-fsck@.service && \ + # [Networking] Enable networkd/resolved and configure DHCP + systemctl enable systemd-networkd systemd-resolved systemd-timesyncd && \ + mkdir -p /etc/systemd/network && \ + printf "[Match]\nName=e* v*\n[Network]\nDHCP=yes\n\n[DHCPv4]\nClientIdentifier=mac\n" > /etc/systemd/network/20-wired.network && \ + # [Cocoon agent + sshd] vsock exec daemon and SSH access. + sh /run/secrets/cocoon_install_agent && \ + # [Access] Set root password + echo 'root:cocoon' | chpasswd && \ + # [Cleanup] Purge APT cache to minimize EROFS size + rm -rf /var/lib/apt/lists/* + +# Final configuration +CMD ["/sbin/init"] diff --git a/os-image/debian/README.md b/os-image/debian/README.md new file mode 100644 index 00000000..953833bd --- /dev/null +++ b/os-image/debian/README.md @@ -0,0 +1,48 @@ +# Cocoon-Compatible Debian 13 Image + +This directory builds a bootable Debian 13 (`trixie`) OCI image for Cocoon on +`linux/amd64` and `linux/arm64`. Each platform includes its matching Debian +cloud kernel and checksum-verified Cocoon agent, plus the shared initramfs, +EROFS/overlay boot hooks, systemd networking, and SSH configuration. + +## Build and Validate Locally + +The validator uses Podman directly when it is available and otherwise falls +back to Docker; set `CONTAINER_ENGINE=podman` or `CONTAINER_ENGINE=docker` to +select one explicitly. Building or validating a non-native platform also +requires QEMU user emulation with the corresponding `binfmt` handler +registered; Podman Desktop normally provides this, while Linux Podman hosts +must configure it separately. + +Build each platform with its own tag so Podman stores both architecture-specific +images locally. Run from the repository root: + +```bash +AMD64_IMAGE=cocoon-debian-13:local-amd64 + +podman build \ + --platform linux/amd64 \ + --no-cache \ + --file os-image/debian/13/Dockerfile \ + --secret id=cocoon_overlay,src=os-image/debian/overlay.sh \ + --secret id=cocoon_network,src=os-image/debian/network.sh \ + --secret id=cocoon_install_agent,src=os-image/debian/install-agent.sh \ + --tag "$AMD64_IMAGE" \ + os-image/debian + +os-image/debian/validate-image.sh "$AMD64_IMAGE" + +ARM64_IMAGE=cocoon-debian-13:local-arm64 + +podman build \ + --platform linux/arm64 \ + --no-cache \ + --file os-image/debian/13/Dockerfile \ + --secret id=cocoon_overlay,src=os-image/debian/overlay.sh \ + --secret id=cocoon_network,src=os-image/debian/network.sh \ + --secret id=cocoon_install_agent,src=os-image/debian/install-agent.sh \ + --tag "$ARM64_IMAGE" \ + os-image/debian + +os-image/debian/validate-image.sh "$ARM64_IMAGE" +``` diff --git a/os-image/debian/install-agent.sh b/os-image/debian/install-agent.sh new file mode 100755 index 00000000..22a10cf3 --- /dev/null +++ b/os-image/debian/install-agent.sh @@ -0,0 +1,62 @@ +#!/bin/sh +# Vendored from cocoon/os-image/ubuntu/install-agent.sh at Cocoon v0.5.9 +# (144927060c3e90dbe2f3e1a15143572c402958de). +# Install cocoon-agent (vsock exec) and sshd into a Debian/Ubuntu image. +# Caller is expected to have already installed `openssh-server` via apt +# in the same RUN, and to have curl available. +# +# Idempotent: re-running the script overwrites the binary and unit file, +# `systemctl enable` is a no-op when the symlinks are already in place. +set -eu + +AGENT_VERSION="${COCOON_AGENT_VERSION:-0.2.0}" +ARCH="${TARGETARCH:-$(dpkg --print-architecture)}" +case "$ARCH" in + amd64) AGENT_ARCH="x86_64"; AGENT_SHA256="73dc18e588828630f0ad5f3a1b86511f315d0df1f2efcbb409e821d258d45234" ;; + arm64) AGENT_ARCH="arm64"; AGENT_SHA256="548f4729a11797c4a6abe1b65d3791606a9a4f1de5592bf2ea367c573e0f6768" ;; + *) echo "install-agent: unsupported arch '$ARCH'" >&2; exit 1 ;; +esac + +# 1. sshd: permit root login (cocoon images use root:cocoon by default). +mkdir -p /run/sshd +sed -i 's/^#*PermitRootLogin.*/PermitRootLogin yes/' /etc/ssh/sshd_config +systemctl enable ssh + +# 2. cocoon-agent binary: pinned-version tarball from upstream releases. +# Per-arch SHA256 — bumping AGENT_VERSION without updating both checksums +# fails the sha256sum -c check instead of silently shipping a wrong binary. +TARBALL="cocoon-agent_${AGENT_VERSION}_Linux_${AGENT_ARCH}.tar.gz" +URL="https://github.com/cocoonstack/cocoon-agent/releases/download/v${AGENT_VERSION}/${TARBALL}" +TMP_TARBALL="$(mktemp)" +trap 'rm -f "$TMP_TARBALL"' EXIT +curl -fsSL "$URL" -o "$TMP_TARBALL" +echo "$AGENT_SHA256 $TMP_TARBALL" | sha256sum -c - +tar -xz -C /usr/local/bin/ -f "$TMP_TARBALL" cocoon-agent +chmod 0755 /usr/local/bin/cocoon-agent + +# 3. systemd unit. Mirrors upstream packaging/cocoon-agent.service so the +# in-VM service stays in sync with what cocoon-agent is tested against. +cat > /etc/systemd/system/cocoon-agent.service <<'EOF' +[Unit] +Description=Cocoon agent (vsock command exec) +Documentation=https://github.com/cocoonstack/cocoon-agent + +[Service] +Type=simple +User=root +Group=root +# Best-effort load — most kernels build the transport in or auto-load on +# virtio-vsock device probe; the leading dash keeps the unit alive on +# minimal kernels (e.g. ubuntu linux-image-virtual). +ExecStartPre=-/sbin/modprobe vhost_vsock +ExecStart=/usr/local/bin/cocoon-agent serve +Environment=AGENT_LOG_LEVEL=info +Restart=always +RestartSec=2s +LimitNOFILE=65536 + +[Install] +WantedBy=multi-user.target +EOF + +systemctl enable cocoon-agent.service diff --git a/os-image/debian/network.sh b/os-image/debian/network.sh new file mode 100755 index 00000000..1a05f2d0 --- /dev/null +++ b/os-image/debian/network.sh @@ -0,0 +1,110 @@ +#!/bin/sh +# Vendored from cocoon/os-image/ubuntu/network.sh at Cocoon v0.5.9 +# (144927060c3e90dbe2f3e1a15143572c402958de). +# Target path: /etc/initramfs-tools/scripts/init-bottom/cocoon-network +# +# Runs in init-bottom phase — AFTER configure_networking has parsed kernel ip= +# parameters into /run/net-*.conf, and AFTER mountroot has assembled the overlay. +# Converts initramfs network config into systemd-networkd .network files so +# the IP configuration persists after switch_root, and writes /etc/resolv.conf +# for immediate DNS availability regardless of init system. + +PREREQ="" +prereqs() { echo "$PREREQ"; } +case "$1" in prereqs) prereqs; exit 0 ;; esac + +. /scripts/functions + +# $rootmnt is set by initramfs — points to the mounted root filesystem. +[ -z "$rootmnt" ] && exit 0 + +# Set hostname from cocoon.hostname= kernel parameter. +for _arg in $(cat /proc/cmdline); do + case "$_arg" in + cocoon.hostname=*) echo "${_arg#cocoon.hostname=}" > "${rootmnt}/etc/hostname" ;; + esac +done + +_dns_servers="" +_has_static=false + +for conf_file in /run/net-*.conf; do + [ -f "$conf_file" ] || continue + + unset DEVICE IPV4ADDR IPV4NETMASK IPV4GATEWAY IPV4DNS0 IPV4DNS1 HOSTNAME HWADDR + . "$conf_file" + [ -z "$DEVICE" ] && continue + [ -z "$IPV4ADDR" ] && continue + + # Read MAC from sysfs if HWADDR not in conf (older klibc). + [ -z "$HWADDR" ] && [ -e "/sys/class/net/${DEVICE}/address" ] && HWADDR=$(cat "/sys/class/net/${DEVICE}/address") + [ -z "$HWADDR" ] && continue + + _has_static=true + + # Convert dotted netmask to prefix length. + prefix=0 + IFS=. read -r a b c d < "${rootmnt}/etc/systemd/network/10-${mac_sanitized}.network" + + # Collect DNS servers for resolv.conf. + [ -n "$IPV4DNS0" ] && [ "$IPV4DNS0" != "0.0.0.0" ] && _dns_servers="${_dns_servers} ${IPV4DNS0}" + [ -n "$IPV4DNS1" ] && [ "$IPV4DNS1" != "0.0.0.0" ] && _dns_servers="${_dns_servers} ${IPV4DNS1}" + +done + +# Fallback: no kernel ip= configured — write DHCP config per NIC matched by MAC. +# This covers macvlan / external DHCP scenarios where CNI does not assign IPs. +if [ "$_has_static" = false ]; then + mkdir -p "${rootmnt}/etc/systemd/network" + for sysdev in /sys/class/net/*; do + [ -e "$sysdev" ] || continue + dev=$(basename "$sysdev") + # Skip loopback and virtual devices. + case "$dev" in lo|bonding_masters) continue ;; esac + [ -e "${sysdev}/address" ] || continue + mac=$(cat "${sysdev}/address") + # Skip zero/empty MACs. + case "$mac" in ""|00:00:00:00:00:00) continue ;; esac + mac_sanitized=$(echo "$mac" | tr -d ':') + { + printf "[Match]\nMACAddress=%s\n\n[Network]\nDHCP=ipv4\n\n[DHCPv4]\nClientIdentifier=mac\n" "$mac" + } > "${rootmnt}/etc/systemd/network/10-${mac_sanitized}.network" + done +fi + +# Write /etc/resolv.conf from DNS servers collected above. +[ -z "$_dns_servers" ] && _dns_servers="8.8.8.8 8.8.4.4" +: > "${rootmnt}/etc/resolv.conf" +for _ns in $_dns_servers; do + printf "nameserver %s\n" "$_ns" >> "${rootmnt}/etc/resolv.conf" +done diff --git a/os-image/debian/overlay.sh b/os-image/debian/overlay.sh new file mode 100755 index 00000000..82f68934 --- /dev/null +++ b/os-image/debian/overlay.sh @@ -0,0 +1,136 @@ +#!/bin/sh +# Vendored from cocoon/os-image/ubuntu/overlay.sh at Cocoon v0.5.9 +# (144927060c3e90dbe2f3e1a15143572c402958de), with the overlay mount +# operands reordered. +# Target path: /etc/initramfs-tools/scripts/cocoon-overlay + +. /scripts/functions + +resolve_disk() { + local serial="$1" timeout="${COCOON_TIMEOUT:-10}" i=0 + case "$timeout" in ''|*[!0-9]*) timeout=10 ;; esac + + # Direct device path (Firecracker uses /dev/vdX, no virtio serial support) + case "$serial" in + /dev/*) + while [ $i -lt $timeout ]; do + [ -b "$serial" ] && echo "$serial" && return 0 + sleep 1 + i=$((i + 1)) + done + return 1 + ;; + esac + + # Serial name lookup (Cloud Hypervisor virtio-blk serial) + while [ $i -lt $timeout ]; do + for sysdev in /sys/block/vd*; do + [ -d "$sysdev" ] || continue + local s="" + [ -f "$sysdev/serial" ] && s=$(cat "$sysdev/serial") + [ -f "$sysdev/device/serial" ] && s=$(cat "$sysdev/device/serial") + + # Trim trailing whitespace + while :; do case "$s" in *[[:space:]]) s="${s%[[:space:]]}" ;; *) break ;; esac; done + + if [ "$s" = "$serial" ]; then + echo "/dev/${sysdev##*/}" + return 0 + fi + done + sleep 1 + i=$((i + 1)) + done + return 1 +} + +mountroot() { + log_begin_msg "Cocoon: mounting stealth overlay rootfs" + + # Process kernel ip= parameters if present (creates /run/net-*.conf). + # Only call configure_networking when ip= is on the cmdline — without it, + # the function still probes for devices and waits for udev, adding ~180s + # delay on VMs with no NICs (--nics 0). + if ! ls /run/net-*.conf >/dev/null 2>&1; then + for _x in $(cat /proc/cmdline); do + case $_x in ip=*) configure_networking; break ;; esac + done + fi + + # Native environment: modprobe automatically resolves all underlying dependencies. + modprobe erofs 2>/dev/null || true + modprobe overlay 2>/dev/null || true + modprobe ext4 2>/dev/null || true + + for x in $(cat /proc/cmdline); do + case $x in + cocoon.layers=*) LAYERS="${x#cocoon.layers=}" ;; + cocoon.cow=*) COW="${x#cocoon.cow=}" ;; + cocoon.timeout=*) COCOON_TIMEOUT="${x#cocoon.timeout=}" ;; + esac + done + + [ -z "$LAYERS" ] && panic "cocoon.layers= not set" + [ -z "$COW" ] && panic "cocoon.cow= not set" + + # Wait for udev to finish processing all pending events once, before any disk lookups. + udevadm settle 2>/dev/null || true + + COCOON_INTERNAL="/.cocoon" + mkdir -p "$COCOON_INTERNAL" + + # Mount read-only EROFS layers + LOWER="" + LAYER_DEVS="" + IFS=, + for serial in $LAYERS; do + dev=$(resolve_disk "$serial") || panic "device ${serial} not found" + mnt="${COCOON_INTERNAL}/layers/${serial}" + mkdir -p "$mnt" + mount -t erofs -o ro "$dev" "$mnt" || panic "mount ${serial} failed" + [ -n "$LOWER" ] && LOWER="${LOWER}:" + LOWER="${LOWER}${mnt}" + LAYER_DEVS="${LAYER_DEVS} ${dev}" + done + unset IFS + + # Mount COW disk + cow_dev=$(resolve_disk "$COW") || panic "COW device ${COW} not found" + mkdir -p "${COCOON_INTERNAL}/cow" + # [Performance] Added noatime to reduce unnecessary write operations on the COW disk. + mount -t ext4 -o noatime "$cow_dev" "${COCOON_INTERNAL}/cow" || panic "mount COW failed" + mkdir -p "${COCOON_INTERNAL}/cow/upper" "${COCOON_INTERNAL}/cow/work" + + # Assemble Overlayfs + # [Optimized OverlayFS Options] + # index=on: Prevents broken file handles and ensures inode consistency during copy-up. + # redirect_dir=on: Enables renaming of directories that exist in the lower (read-only) layers. + # metacopy=on: Optimizes metadata-only changes (like chmod/chown) to avoid full file copy-up. + OVL_OPTS="lowerdir=${LOWER},upperdir=${COCOON_INTERNAL}/cow/upper,workdir=${COCOON_INTERNAL}/cow/work,index=on,redirect_dir=on,metacopy=on,xino=on" + + # Options before operands is accepted by util-linux, busybox, and klibc + # mount alike, so keep the portable form for all initramfs variants. + mount -t overlay -o "$OVL_OPTS" overlay "$rootmnt" || panic "overlay failed" + + mkdir -p "${rootmnt}/dev" "${rootmnt}/proc" "${rootmnt}/sys" "${rootmnt}/run" + + # [IO Performance Optimization] + # EROFS layers are read-only and shared; "none" removes guest-side scheduling + # overhead on the guest block device for pure-read lower layers. + # COW disk gets mq-deadline to prevent write starvation under mixed read/write load. + for dev in $LAYER_DEVS; do + blk="${dev##*/}" + [ -e "/sys/block/${blk}/queue/scheduler" ] && echo "none" > "/sys/block/${blk}/queue/scheduler" 2>/dev/null || true + done + cow_blk="${cow_dev##*/}" + [ -e "/sys/block/${cow_blk}/queue/scheduler" ] && echo "mq-deadline" > "/sys/block/${cow_blk}/queue/scheduler" 2>/dev/null || true + + # Note: The systemd compatibility hacks (clearing fstab, masking fsck) + # are handled natively in the Dockerfile. The rootfs is clean here. + + # The only remaining requirement is Machine-ID isolation for cloned VMs. + rm -f "${rootmnt}/etc/machine-id" 2>/dev/null || true + : > "${rootmnt}/etc/machine-id" + + log_success_msg "Cocoon: stealth overlay rootfs ready" +} diff --git a/os-image/debian/validate-image.sh b/os-image/debian/validate-image.sh new file mode 100755 index 00000000..a32acc6d --- /dev/null +++ b/os-image/debian/validate-image.sh @@ -0,0 +1,472 @@ +#!/bin/bash +# Statically validate a locally loaded Cocoon-compatible Debian 13 image. +set -o errexit +set -o nounset +set -o pipefail + +IMAGE="" +CONTAINER_ENGINE="${CONTAINER_ENGINE:-}" +IMAGE_ARCHITECTURE="" +EXPECTED_TARGET_PLATFORM="" +EXPECTED_KERNEL_VERSION_SUFFIX="" +SCRIPT_DIR="" +TEMP_DIR="" +LAST_CONTAINER="" +FAILURES=0 +declare -a CONTAINER_IDS=() + +log() { + printf '%s\n' "$*" >&2 +} + +fatal() { + printf 'ERROR: %s\n' "$*" >&2 + exit 1 +} + +record_failure() { + printf 'FAIL: %s\n' "$*" >&2 + FAILURES=$((FAILURES + 1)) +} + +usage() { + printf 'Usage: %s IMAGE\n' "${0##*/}" >&2 +} + +cleanup() { + local status=$? + local container_id + + trap - EXIT + set +o errexit + for container_id in "${CONTAINER_IDS[@]}"; do + [[ -n "$container_id" ]] && container_cli rm --force "$container_id" >/dev/null 2>&1 + done + [[ -n "$TEMP_DIR" ]] && rm -rf -- "$TEMP_DIR" + exit "$status" +} + +container_cli() { + "$CONTAINER_ENGINE" "$@" +} + +select_container_engine() { + local candidate + + if [[ -n "$CONTAINER_ENGINE" ]]; then + command -v "$CONTAINER_ENGINE" >/dev/null 2>&1 \ + || fatal "configured container engine not found: $CONTAINER_ENGINE" + return + fi + + for candidate in podman docker; do + if command -v "$candidate" >/dev/null 2>&1; then + CONTAINER_ENGINE=$candidate + return + fi + done + fatal "required command not found: podman or docker" +} + +check_prerequisites() { + local command_name + local -a required_commands=(awk cmp dirname find grep mkdir mktemp readlink rm) + + for command_name in "${required_commands[@]}"; do + command -v "$command_name" >/dev/null 2>&1 || fatal "required command not found: $command_name" + done + select_container_engine + container_cli info >/dev/null 2>&1 || fatal "$CONTAINER_ENGINE is unavailable" + + for command_name in overlay.sh network.sh; do + [[ -r "$SCRIPT_DIR/$command_name" ]] || fatal "missing vendored input: $SCRIPT_DIR/$command_name" + done +} + +inspect_image() { + local format=$1 + container_cli image inspect --format "$format" "$IMAGE" +} + +create_container() { + LAST_CONTAINER=$(container_cli create "$@") || fatal "could not create validation container from $IMAGE" + [[ -n "$LAST_CONTAINER" ]] || fatal "$CONTAINER_ENGINE create returned an empty container ID" + CONTAINER_IDS+=("$LAST_CONTAINER") +} + +assert_equal() { + local description=$1 + local expected=$2 + local actual=$3 + + if [[ "$actual" != "$expected" ]]; then + record_failure "$description (expected '$expected', got '$actual')" + fi +} + +assert_regular_file() { + local path=$1 + local description=$2 + + if [[ ! -f "$path" || -L "$path" ]]; then + record_failure "$description is not a regular file" + fi +} + +assert_nonempty_file() { + local path=$1 + local description=$2 + + if [[ ! -s "$path" || -L "$path" ]]; then + record_failure "$description is missing or empty" + fi +} + +assert_contains_fixed() { + local path=$1 + local expected=$2 + local description=$3 + + if [[ ! -f "$path" ]] || ! grep -Fq -- "$expected" "$path"; then + record_failure "$description" + fi +} + +assert_contains_line() { + local path=$1 + local expected=$2 + local description=$3 + + if [[ ! -f "$path" ]] || ! grep -Fxq -- "$expected" "$path"; then + record_failure "$description" + fi +} + +assert_contains_regex() { + local path=$1 + local expression=$2 + local description=$3 + + if [[ ! -f "$path" ]] || ! grep -Eq -- "$expression" "$path"; then + record_failure "$description" + fi +} + +assert_enabled_service() { + local rootfs=$1 + local unit=$2 + local match + + match=$(find "$rootfs/etc/systemd/system" -type l -path '*.wants/*' -name "$unit" -print -quit 2>/dev/null || true) + [[ -n "$match" ]] || record_failure "$unit is not enabled" +} + +assert_masked_service() { + local rootfs=$1 + local unit=$2 + local path="$rootfs/etc/systemd/system/$unit" + local target="" + + if [[ -L "$path" ]]; then + target=$(readlink "$path") + fi + [[ "$target" == "/dev/null" ]] || record_failure "$unit is not masked to /dev/null" +} + +assert_kernel_feature() { + local rootfs=$1 + local kernel_version=$2 + local config=$3 + local initrd_listing=$4 + local symbol=$5 + local module=$6 + local setting + local module_path + + setting=$(awk -F= -v symbol="$symbol" '$1 == symbol { print $2; exit }' "$config") + case "$setting" in + y) + ;; + m) + module_path=$(find "$rootfs/usr/lib/modules/$kernel_version" -type f -name "${module}.ko*" -print -quit 2>/dev/null || true) + [[ -n "$module_path" ]] || record_failure "$symbol=m but module $module is absent for $kernel_version" + grep -Eq "(^|/)${module}\\.ko(\\.(gz|xz|zst))?$" "$initrd_listing" \ + || record_failure "$symbol=m but module $module is absent from initrd.img-$kernel_version" + ;; + *) + record_failure "$symbol is neither built in nor modular for $kernel_version" + ;; + esac +} + +copy_image_filesystem() { + local rootfs=$1 + local container_id + local path + + create_container --entrypoint /bin/true "$IMAGE" + container_id=$LAST_CONTAINER + for path in boot etc usr sbin; do + container_cli cp "${container_id}:/${path}" "$rootfs/" \ + || fatal "could not copy /$path from validation container $container_id" + done +} + +select_architecture_contract() { + local operating_system=$1 + local architecture=$2 + + [[ "$operating_system" == "linux" ]] \ + || fatal "unsupported OCI operating system '$operating_system' (expected linux)" + + IMAGE_ARCHITECTURE=$architecture + EXPECTED_TARGET_PLATFORM="linux/$architecture" + case "$architecture" in + amd64) + EXPECTED_KERNEL_VERSION_SUFFIX=-cloud-amd64 + ;; + arm64) + EXPECTED_KERNEL_VERSION_SUFFIX=-cloud-arm64 + ;; + *) + fatal "unsupported OCI architecture '$architecture' (expected amd64 or arm64)" + ;; + esac +} + +validate_image_metadata() { + local architecture + local operating_system + local command_metadata + local entrypoint_metadata + local user_metadata + + container_cli image inspect "$IMAGE" >/dev/null 2>&1 || fatal "image is not present locally: $IMAGE" + + architecture=$(inspect_image '{{.Architecture}}') + operating_system=$(inspect_image '{{.Os}}') + command_metadata=$(inspect_image '{{json .Config.Cmd}}') + entrypoint_metadata=$(inspect_image '{{json .Config.Entrypoint}}') + user_metadata=$(inspect_image '{{.Config.User}}') + + select_architecture_contract "$operating_system" "$architecture" + assert_equal "OCI architecture" "$IMAGE_ARCHITECTURE" "$architecture" + assert_equal "OCI operating system" "linux" "$operating_system" + assert_equal "OCI command" '["/sbin/init"]' "$command_metadata" + assert_equal "OCI entrypoint" "null" "$entrypoint_metadata" + case "$user_metadata" in + ''|0|root) ;; + *) record_failure "OCI user must be root (got '$user_metadata')" ;; + esac +} + +validate_identity_and_repositories() { + local rootfs=$1 + local apt_sources="$TEMP_DIR/apt-sources" + + assert_contains_regex "$rootfs/usr/lib/os-release" '^ID=debian$' "guest identity is not Debian" + assert_contains_regex "$rootfs/usr/lib/os-release" '^VERSION_ID="?13"?$' "guest version is not Debian 13" + assert_contains_regex "$rootfs/usr/lib/os-release" '^VERSION_CODENAME=trixie$' "guest codename is not trixie" + + # Official Debian images retain snapshot URLs in comments to document how + # the base rootfs was produced. Validate only enabled APT source lines. + find "$rootfs/etc/apt" -type f \( -name '*.list' -o -name '*.sources' \) \ + -exec awk '!/^[[:space:]]*#/' {} + > "$apt_sources" + assert_contains_fixed "$apt_sources" 'deb.debian.org' "APT sources are not the moving Debian repositories" + assert_contains_fixed "$apt_sources" 'trixie' "APT sources do not select trixie" + if grep -Fq 'snapshot.debian.org' "$apt_sources"; then + record_failure "APT sources unexpectedly claim snapshot policy" + fi +} + +validate_boot_contract() { + local rootfs=$1 + local modules_file="$rootfs/etc/initramfs-tools/modules" + local config + local initrd + local initrd_container + local initrd_listing + local kernel + local kernel_version + local module + local -a kernels=("$rootfs"/boot/vmlinuz-*) + local -a required_modules=( + erofs + overlay + ext4 + virtio_blk + virtio_pci + virtio_ring + virtio_net + vsock + vmw_vsock_virtio_transport + ) + + for module in "${required_modules[@]}"; do + assert_contains_line "$modules_file" "$module" "required initramfs module $module is not configured" + done + + if [[ ${#kernels[@]} -eq 1 && ! -e "${kernels[0]}" ]]; then + record_failure "no versioned kernel was installed in /boot" + return + fi + + for kernel in "${kernels[@]}"; do + kernel_version=${kernel##*/vmlinuz-} + [[ "$kernel_version" == *"$EXPECTED_KERNEL_VERSION_SUFFIX" ]] \ + || record_failure "kernel $kernel_version does not match $IMAGE_ARCHITECTURE cloud-kernel naming" + config="$rootfs/boot/config-$kernel_version" + initrd="$rootfs/boot/initrd.img-$kernel_version" + initrd_listing="$TEMP_DIR/initramfs-$kernel_version.list" + + assert_nonempty_file "$kernel" "kernel /boot/vmlinuz-$kernel_version" + assert_nonempty_file "$initrd" "initramfs /boot/initrd.img-$kernel_version" + assert_nonempty_file "$config" "kernel configuration /boot/config-$kernel_version" + [[ -d "$rootfs/usr/lib/modules/$kernel_version" ]] \ + || record_failure "module tree is missing for $kernel_version" + + if [[ ! -s "$initrd" ]]; then + continue + fi + create_container --entrypoint /usr/bin/lsinitramfs "$IMAGE" "/boot/initrd.img-$kernel_version" + initrd_container=$LAST_CONTAINER + if ! container_cli start --attach "$initrd_container" > "$initrd_listing"; then + record_failure "lsinitramfs failed for /boot/initrd.img-$kernel_version" + continue + fi + + grep -Fxq 'scripts/cocoon-overlay' "$initrd_listing" \ + || record_failure "cocoon-overlay hook is absent from initrd.img-$kernel_version" + grep -Fxq 'scripts/init-bottom/cocoon-network' "$initrd_listing" \ + || record_failure "cocoon-network hook is absent from initrd.img-$kernel_version" + grep -Fxq 'usr/bin/busybox' "$initrd_listing" \ + || record_failure "busybox is absent from initrd.img-$kernel_version" + + if [[ -f "$config" ]]; then + assert_kernel_feature "$rootfs" "$kernel_version" "$config" "$initrd_listing" CONFIG_EROFS_FS erofs + assert_kernel_feature "$rootfs" "$kernel_version" "$config" "$initrd_listing" CONFIG_OVERLAY_FS overlay + assert_kernel_feature "$rootfs" "$kernel_version" "$config" "$initrd_listing" CONFIG_EXT4_FS ext4 + assert_kernel_feature "$rootfs" "$kernel_version" "$config" "$initrd_listing" CONFIG_VIRTIO_BLK virtio_blk + assert_kernel_feature "$rootfs" "$kernel_version" "$config" "$initrd_listing" CONFIG_VIRTIO_PCI virtio_pci + assert_kernel_feature "$rootfs" "$kernel_version" "$config" "$initrd_listing" CONFIG_VIRTIO virtio_ring + assert_kernel_feature "$rootfs" "$kernel_version" "$config" "$initrd_listing" CONFIG_VIRTIO_NET virtio_net + assert_kernel_feature "$rootfs" "$kernel_version" "$config" "$initrd_listing" CONFIG_VSOCKETS vsock + assert_kernel_feature "$rootfs" "$kernel_version" "$config" "$initrd_listing" CONFIG_VIRTIO_VSOCKETS vmw_vsock_virtio_transport + fi + done + + [[ -e "$rootfs/sbin/init" || -L "$rootfs/sbin/init" ]] \ + || record_failure "/sbin/init is absent" + [[ -x "$rootfs/usr/lib/systemd/systemd" ]] \ + || record_failure "systemd executable is absent" + + assert_nonempty_file "$rootfs/etc/initramfs-tools/scripts/cocoon-overlay" "installed cocoon-overlay hook" + assert_nonempty_file "$rootfs/etc/initramfs-tools/scripts/init-bottom/cocoon-network" "installed cocoon-network hook" + if [[ -f "$rootfs/etc/initramfs-tools/scripts/cocoon-overlay" ]] \ + && ! cmp -s "$SCRIPT_DIR/overlay.sh" "$rootfs/etc/initramfs-tools/scripts/cocoon-overlay"; then + record_failure "installed cocoon-overlay hook differs from the vendored source" + fi + if [[ -f "$rootfs/etc/initramfs-tools/scripts/init-bottom/cocoon-network" ]] \ + && ! cmp -s "$SCRIPT_DIR/network.sh" "$rootfs/etc/initramfs-tools/scripts/init-bottom/cocoon-network"; then + record_failure "installed cocoon-network hook differs from the vendored source" + fi + [[ -x "$rootfs/etc/initramfs-tools/scripts/cocoon-overlay" ]] \ + || record_failure "installed cocoon-overlay hook is not executable" + [[ -x "$rootfs/etc/initramfs-tools/scripts/init-bottom/cocoon-network" ]] \ + || record_failure "installed cocoon-network hook is not executable" + + assert_contains_regex "$rootfs/etc/initramfs-tools/initramfs.conf" '^COMPRESS=gzip$' "initramfs compression is not gzip" + assert_contains_regex "$rootfs/etc/initramfs-tools/initramfs.conf" '^IP=off$' "unrequested initramfs DHCP is not disabled" + + if [[ ! -f "$rootfs/etc/fstab" || -s "$rootfs/etc/fstab" ]]; then + record_failure "/etc/fstab is not neutralized" + fi + assert_masked_service "$rootfs" systemd-fsck-root.service + assert_masked_service "$rootfs" systemd-remount-fs.service + assert_masked_service "$rootfs" 'systemd-fsck@.service' +} + +validate_network_and_services() { + local rootfs=$1 + local default_network="$rootfs/etc/systemd/network/20-wired.network" + local agent_unit="$rootfs/etc/systemd/system/cocoon-agent.service" + local sshd_development_config="$rootfs/etc/ssh/sshd_config.d/00-cocoon-development.conf" + local shadow_line + local root_hash + + assert_regular_file "$default_network" "default networkd configuration" + assert_contains_regex "$default_network" '^DHCP=yes$' "default networkd configuration does not enable DHCP" + assert_contains_regex "$default_network" '^ClientIdentifier=mac$' "default networkd DHCP is not clone-safe" + + assert_enabled_service "$rootfs" systemd-networkd.service + assert_enabled_service "$rootfs" systemd-resolved.service + assert_enabled_service "$rootfs" systemd-timesyncd.service + assert_enabled_service "$rootfs" ssh.service + assert_enabled_service "$rootfs" cocoon-agent.service + + assert_nonempty_file "$rootfs/usr/local/bin/cocoon-agent" "Cocoon agent binary" + [[ -x "$rootfs/usr/local/bin/cocoon-agent" ]] \ + || record_failure "Cocoon agent binary is not executable" + assert_contains_fixed "$agent_unit" 'ExecStart=/usr/local/bin/cocoon-agent serve' "Cocoon agent service command is wrong" + assert_contains_fixed "$agent_unit" 'WantedBy=multi-user.target' "Cocoon agent service has no boot target" + + shadow_line=$(grep '^root:' "$rootfs/etc/shadow" || true) + root_hash=${shadow_line#root:} + root_hash=${root_hash%%:*} + case "$root_hash" in + ''|\!*|\**) + record_failure "root account is locked or has no password" + ;; + esac + assert_contains_regex "$sshd_development_config" '^PermitRootLogin[[:space:]]+yes$' "sshd development config does not permit root login" + assert_contains_regex "$sshd_development_config" '^PasswordAuthentication[[:space:]]+yes$' "sshd development config does not permit password authentication" +} + +validate_sshd_effective_config() { + local sshd_container + local sshd_config="$TEMP_DIR/sshd-effective-config" + + create_container --entrypoint /usr/sbin/sshd "$IMAGE" -T + sshd_container=$LAST_CONTAINER + if ! container_cli start --attach "$sshd_container" > "$sshd_config"; then + record_failure "sshd could not evaluate its installed configuration" + return + fi + assert_contains_regex "$sshd_config" '^permitrootlogin yes$' "effective sshd configuration denies root login" + assert_contains_regex "$sshd_config" '^passwordauthentication yes$' "effective sshd configuration denies password authentication" +} + +main() { + local rootfs + + if [[ $# -ne 1 || -z ${1:-} ]]; then + usage + exit 2 + fi + IMAGE=$1 + SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) + + check_prerequisites + trap cleanup EXIT + + validate_image_metadata + + TEMP_DIR=$(mktemp -d) + rootfs="$TEMP_DIR/rootfs" + mkdir -p "$rootfs" + copy_image_filesystem "$rootfs" + + validate_identity_and_repositories "$rootfs" + validate_boot_contract "$rootfs" + validate_network_and_services "$rootfs" + validate_sshd_effective_config + + if [[ $FAILURES -ne 0 ]]; then + fatal "$IMAGE failed $FAILURES static validation assertion(s)" + fi + log "PASS: $IMAGE satisfies the Debian 13 $EXPECTED_TARGET_PLATFORM Cocoon static image contract" +} + +if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then + main "$@" +fi