Ubuntu immutable QEMU images

The qemu-ubuntu-2404-immutable target builds an Ubuntu 24.04 Cluster API image with a read-only root filesystem and a separate persistent data partition. Packer and Ansible provision the image while the root filesystem is still writable. The final immutable runtime step creates the persistent data partition, bind mounts the writable runtime paths, configures tmpfs scratch paths, and writes / as read-only in /etc/fstab before Goss runs.

Build the target from images/capi:

PACKER_VAR_FILES="packer/config/kubernetes.json packer/config/cni.json packer/config/containerd.json" \
PACKER_FLAGS="\
  --var 'format=qcow2' \
  --var 'kubernetes_semver=v1.36.1' \
  --var 'kubernetes_series=v1.36' \
  --var 'kubernetes_deb_version=1.36.1-1.1' \
  --var 'immutable_read_only_root=true'" \
  make build-qemu-ubuntu-2404-immutable

Use --var 'format=raw' when the consuming provider expects a raw disk image.

Immutable configuration

The target enables the data partition and read-only root by default. These variables can be overridden through Packer variables when a provider needs a different contract:

VariableDefault
immutable_data_partitiontrue
immutable_data_partition_fstypeext4
immutable_data_partition_labelCAPI-DATA
immutable_data_partition_mount/.capi-data
immutable_data_partition_mount_optionsdefaults,x-systemd.device-timeout=30s
immutable_root_partition_size12884901888
immutable_read_only_roottrue
immutable_persistent_paths/etc,/home,/root,/mnt,/media,/opt,/srv,/usr/local,/var/backups,/var/cache,/var/crash,/var/lib,/var/local,/var/log,/var/mail,/var/opt,/var/spool
immutable_tmpfs_paths/tmp,/var/tmp

The persistent path list covers first-boot configuration under /etc, user home directories, SSH host keys, /mnt, /media, /opt, /usr/local, /srv, and the common mutable /var subtrees used by package state, cloud-init, kubelet, containerd, CNI, systemd, dbus, NetworkManager, control-plane etcd data, caches, crash dumps, spools, and logs. The data partition is mounted outside /var so /var/lib can be persistent as a whole. Extend immutable_persistent_paths when a provider writes additional bootstrap files after the root filesystem is remounted read-only.

The data partition is required for the booted node contract. Do not add nofail to the data or bind mount options unless the consuming image target has a separate recovery path for missing writable runtime storage. The immutable runtime step also writes systemd drop-ins so cloud-init, containerd, kubelet, and SSH wait for the data, persistent, and tmpfs mounts.

The default QEMU disk is 20 GiB and immutable_root_partition_size reserves 12 GiB for the read-only root. The data partition receives the remaining disk space, which must be large enough for /var/lib/containerd, kubelet state, logs, and bootstrap data. Increase disk_size when the workload or provider needs more writable runtime capacity.

Validation

Run the focused local checks after changing this target:

make test-qemu-immutable
make validate-qemu-ubuntu-2404-immutable

These checks validate the autoinstall renderer, immutable runtime helper, Packer target, Packer variable wiring, and Goss contract. They do not replace a provider-backed Cluster API boot test for the exact artifact format and network path that will consume the image.

At minimum, provider validation should boot the produced image through Cluster API, wait for the infrastructure provider machine and CAPI Machine readiness contract, and verify the guest contract over SSH:

findmnt -no OPTIONS / | tr ',' '\n' | grep -qx ro
test -w /.capi-data
for path in \
  /etc \
  /home \
  /root \
  /mnt \
  /media \
  /opt \
  /srv \
  /usr/local \
  /var/backups \
  /var/cache \
  /var/crash \
  /var/lib \
  /var/local \
  /var/log \
  /var/mail \
  /var/opt \
  /var/spool; do
    test -w "${path}"
done

Write sentinels under at least /etc/kubernetes, /home, and /var/lib/kubelet, reboot the guest, then verify the sentinels are still present and / is still mounted read-only. The same boot test should also verify that cloud-init finished, /etc/hostname contains the provider-assigned hostname, and /etc/machine-id is non-empty after first boot.