Ctrl K
ComunidadGuías…

scripts/firecracker-menual/install-rocky-rootfs.sh

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env bash
# Build a Firecracker-ready version-pinned Rocky Linux rootfs with its EL9 kernel.
#
# The result deliberately stays a direct ext4 file rather than Rocky's cloud
# QCOW2 image: firecrab resizes and customizes rootfs files with e2fsprogs.
# The official Rocky Container-Base tarball is unpacked as a temporary chroot;
# Docker and other OCI runtimes are not used.
#
# EL9 x86_64 uses virtio-pci because its kernel leaves CONFIG_VIRTIO_MMIO off.
# Rocky's aarch64 kernel supports Firecracker's normal virtio-mmio transport.
# Both initramfs variants carry their architecture's transport plus
# virtio_blk/net and ext4 for the guest rootfs.

set -euo pipefail

script_dir=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd -P)
repo_dir=$(CDPATH='' cd -- "${script_dir}/../.." && pwd -P)
script_path="${script_dir}/$(basename -- "$0")"

artifact_dir="${repo_dir}/images/rootfs"
kernel_artifact_dir="${repo_dir}/images/kernel"
build_dir="${repo_dir}/build/rocky-rootfs"
rocky_release=${M2IMAGE_DISTRO_VERSION:-9.8}
rocky_repository_base=${ROCKY_REPOSITORY_BASE:-https://download.rockylinux.org/pub/rocky}
rocky_container_build=${ROCKY_CONTAINER_BUILD:-20260525.0}
rootfs_size='2G'
rootfs_hostname='firecrab'
m2image_alias=${M2IMAGE_ALIAS:-}
sbom_output=${M2IMAGE_SBOM_OUTPUT:-}
sbom_generator="${repo_dir}/scripts/m2image_sbom.py"
extract_vmlinux="${script_dir}/extract-vmlinux"
extract_arm64_image="${script_dir}/extract-arm64-image"
container_mounts=()

case "${M2IMAGE_ARCH:-$(uname -m 2>/dev/null || printf unknown)}" in
  x86_64|amd64)
    rocky_arch='x86_64'
    kernel_image_name="vmlinux-rocky-${rocky_release}-x86_64"
    ;;
  aarch64|arm64)
    rocky_arch='aarch64'
    kernel_image_name="Image-rocky-${rocky_release}-aarch64"
    ;;
  *)
    printf '[FAIL] Unsupported architecture. Rocky Linux supports x86_64 and aarch64.\n' >&2
    exit 1
    ;;
esac
initrd_image_name="initramfs-rocky-${rocky_release}-${rocky_arch}"
rootfs_image_name="rocky-rootfs-${rocky_release}-${rocky_arch}.ext4"
container_name="Rocky-9-Container-Base-${rocky_release}-${rocky_container_build}.${rocky_arch}.oci.tar.xz"
container_url="${rocky_repository_base}/${rocky_release}/images/${rocky_arch}/${container_name}"

# `kernel` provides the matching kernel-core/modules pair. Rocky's generic
# kernel has virtio/ext4 as modules, so dracut below produces a generic initrd
# containing the Firecracker storage/network drivers.
# dnf (+ rpm via deps) must live *inside* the guest: host `dnf --installroot`
# only stages packages and never installs the package manager itself. Without
# it the dashboard package actions (`dnf -y install …`) fail with "command not
# found" on every Rocky VM.
rootfs_packages='kernel dracut systemd systemd-udev NetworkManager iproute iputils bind-utils curl ca-certificates procps-ng openssh-server kmod util-linux dhcp-client e2fsprogs dnf'

info() { printf '[INFO] %s\n' "$*"; }
fail() { printf '[FAIL] %s\n' "$*" >&2; exit 1; }

require_command() {
  command -v "$1" >/dev/null 2>&1 || fail "Required command not found: $1"
}

abs_dir() {
  mkdir -p "$1"
  cd "$1" && pwd -P
}

verify_native_architecture() {
  local host_arch=''

  case "$(uname -m 2>/dev/null || printf unknown)" in
    x86_64|amd64) host_arch='x86_64' ;;
    aarch64|arm64) host_arch='aarch64' ;;
    *) fail 'Unsupported build host architecture.' ;;
  esac
  [ "$host_arch" = "$rocky_arch" ] \
    || fail "Host-native Rocky chroot requires a ${rocky_arch} host (current: ${host_arch})."
}

resolve_ssh_public_key() {
  local candidate=${FIRECRAB_SSH_PUBLIC_KEY:-}
  local sudo_home=''

  if [ -n "$candidate" ]; then
    [ -s "$candidate" ] \
      || fail "FIRECRAB_SSH_PUBLIC_KEY is not a readable public key: ${candidate}"
    printf '%s\n' "$candidate"
    return
  fi

  if [ -n "${SUDO_UID:-}" ] && command -v getent >/dev/null 2>&1; then
    sudo_home=$(getent passwd "$SUDO_UID" | cut -d: -f6 || true)
  elif [ -n "${HOME:-}" ]; then
    sudo_home=$HOME
  fi

  if [ -n "$sudo_home" ]; then
    for candidate in \
      "$sudo_home/.ssh/id_ed25519.pub" \
      "$sudo_home/.ssh/id_ecdsa.pub" \
      "$sudo_home/.ssh/id_rsa.pub"; do
      [ ! -s "$candidate" ] || { printf '%s\n' "$candidate"; return; }
    done
  fi

  candidate="${build_dir}/no-authorized-key.pub"
  : >"$candidate"
  info 'no host SSH public key found; building with serial-console access only' >&2
  printf '%s\n' "$candidate"
}

write_configure_script() {
  cat >"$1" <<'EOF'
#!/usr/bin/env bash
set -euo pipefail

staging=/work/rootfs
rootfs_size=$1
rootfs_hostname=$2
rootfs_packages=$3
initrd_image_name=$4
rocky_release=$5
rocky_arch=$6
rootfs_image_name=$7
rocky_repository_base=$8

baseos_url="${rocky_repository_base}/${rocky_release}/BaseOS/${rocky_arch}/os/"
appstream_url="${rocky_repository_base}/${rocky_release}/AppStream/${rocky_arch}/os/"

info() { printf '[ROCKY] %s\n' "$*"; }
fail() { printf '[ROCKY:FAIL] %s\n' "$*" >&2; exit 1; }

chroot_mounts=''

cleanup_chroot_mounts() {
  local target
  for target in $chroot_mounts; do
    umount -R "$target" 2>/dev/null || umount -l "$target" 2>/dev/null || true
  done
  chroot_mounts=''
}

mount_chroot_fs() {
  mount -t proc proc "$staging/proc"
  chroot_mounts="$staging/proc"
  mount --rbind /sys "$staging/sys"
  mount --make-rslave "$staging/sys"
  chroot_mounts="$staging/sys $chroot_mounts"
  mount --rbind /dev "$staging/dev"
  mount --make-rslave "$staging/dev"
  chroot_mounts="$staging/dev $chroot_mounts"
  mount --rbind /run "$staging/run"
  mount --make-rslave "$staging/run"
  chroot_mounts="$staging/run $chroot_mounts"
}

trap cleanup_chroot_mounts EXIT

# This exact mount point is supplied by the host builder; removing only its
# previous contents makes repeat runs deterministic without touching anything
# outside the Rocky staging root.
rm -rf /work/rootfs/* /work/rootfs/.[!.]* 2>/dev/null || true
mkdir -p "$staging/etc/pki" "$staging/dev" "$staging/proc" "$staging/sys" "$staging/run"
cp -a /etc/pki/rpm-gpg "$staging/etc/pki/"

dnf_common=(
  --disablerepo='*'
  --enablerepo=baseos,appstream
  --setopt=baseos.mirrorlist=
  --setopt="baseos.baseurl=${baseos_url}"
  --setopt=appstream.mirrorlist=
  --setopt="appstream.baseurl=${appstream_url}"
  --setopt=install_weak_deps=False
  --setopt=keepcache=False
)

# The builder container needs mkfs.ext4. The guest itself receives its own
# e2fsprogs package below, so it can later be resized by firecrab normally.
info 'installing e2fsprogs in the throwaway builder container'
dnf -q -y "${dnf_common[@]}" install e2fsprogs

info "installing Rocky Linux ${rocky_release} guest packages into the staging root"
# EL9 kernel RPM post-processing invokes dracut under the install root. Give
# that chroot the normal pseudo-filesystems before the transaction so its own
# first initramfs pass succeeds; the explicit generic pass below then replaces
# it with Firecracker's driver set.
mount_chroot_fs
# shellcheck disable=SC2086 -- package names are a deliberate whitespace list.
dnf -q -y --installroot="$staging" --releasever="$rocky_release" --setopt=reposdir=/etc/yum.repos.d \
  "${dnf_common[@]}" install $rootfs_packages

# Stock rocky.repo mirrorlists expand $rltype, which this image never sets
# (same Docker BaseOS 404 the build avoids). Pin public baseurls for guest dnf.
cat >"$staging/etc/yum.repos.d/rocky-firecrab.repo" <<EOF_REPOS
[baseos]
name=Rocky Linux \$releasever - BaseOS (firecrab)
baseurl=${rocky_repository_base}/\$releasever/BaseOS/\$basearch/os/
gpgcheck=1
enabled=1
gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-Rocky-9

[appstream]
name=Rocky Linux \$releasever - AppStream (firecrab)
baseurl=${rocky_repository_base}/\$releasever/AppStream/\$basearch/os/
gpgcheck=1
enabled=1
gpgkey=file:///etc/pki/rpm-gpg/RPM-GPG-KEY-Rocky-9
EOF_REPOS
# Disable stock enabled sections so only the fixed-url repos above are used.
if [ -f "$staging/etc/yum.repos.d/rocky.repo" ]; then
  sed -i 's/^enabled=1/enabled=0/' "$staging/etc/yum.repos.d/rocky.repo"
fi

test -x "$staging/usr/bin/dnf" || test -x "$staging/bin/dnf" || \
  fail 'Rocky rootfs is missing /usr/bin/dnf after package install'

rm -rf "$staging/var/cache/dnf" "$staging/var/log/dnf"* \
  "$staging/var/cache/yum" "$staging/var/log/yum"* 2>/dev/null || true

cat >"$staging/etc/hostname" <<EOF_HOSTNAME
${rootfs_hostname}
EOF_HOSTNAME

cat >"$staging/etc/hosts" <<EOF_HOSTS
127.0.0.1 localhost
127.0.1.1 ${rootfs_hostname}
EOF_HOSTS

cat >"$staging/etc/fstab" <<'EOF_FSTAB'
/dev/vda / ext4 defaults 0 1
EOF_FSTAB

rm -f "$staging/etc/resolv.conf"
cat >"$staging/etc/resolv.conf" <<'EOF_RESOLV'
nameserver 172.30.0.1
EOF_RESOLV

: >"$staging/etc/machine-id"
install -d -m 0755 "$staging/etc/modules-load.d"
cat >"$staging/etc/modules-load.d/firecrab-network.conf" <<'EOF_MODULES'
virtio_net
EOF_MODULES
install -d -m 0755 "$staging/etc/NetworkManager/system-connections"
cat >"$staging/etc/NetworkManager/system-connections/firecrab-ethernet.nmconnection" <<'EOF_NETWORK'
[connection]
id=firecrab-ethernet
type=ethernet
autoconnect=true

[ipv4]
method=auto
may-fail=false

[ipv6]
method=disabled
EOF_NETWORK
chmod 0600 "$staging/etc/NetworkManager/system-connections/firecrab-ethernet.nmconnection"

install -d -m 0755 \
  "$staging/etc/systemd/system/multi-user.target.wants" \
  "$staging/etc/systemd/system/network-online.target.wants" \
  "$staging/etc/systemd/system/getty.target.wants" \
  "$staging/etc/systemd/system/serial-getty@ttyS0.service.d"
ln -sfn /usr/lib/systemd/system/NetworkManager.service \
  "$staging/etc/systemd/system/multi-user.target.wants/NetworkManager.service"
ln -sfn /usr/lib/systemd/system/NetworkManager-wait-online.service \
  "$staging/etc/systemd/system/network-online.target.wants/NetworkManager-wait-online.service"
ln -sfn /usr/lib/systemd/system/serial-getty@.service \
  "$staging/etc/systemd/system/getty.target.wants/serial-getty@ttyS0.service"
ln -sfn /usr/lib/systemd/system/sshd.service \
  "$staging/etc/systemd/system/multi-user.target.wants/sshd.service"

cat >"$staging/etc/systemd/system/serial-getty@ttyS0.service.d/autologin.conf" <<'EOF_GETTY'
[Unit]
BindsTo=
After=
After=systemd-user-sessions.service getty-pre.target

[Service]
ExecStart=
ExecStart=-/sbin/agetty --autologin root --noclear --keep-baud 115200,57600,38400,9600 %I $TERM
EOF_GETTY

install -d -m 0755 "$staging/usr/local/sbin"
cat >"$staging/usr/local/sbin/firecrab-network-ready.sh" <<'EOF_SENTINEL'
#!/bin/sh
set -eu

ipv4=""
for _ in $(seq 1 15); do
    # Firecracker's default MMIO transport calls this interface eth0, while
    # Rocky's PCI transport assigns a predictable name such as ens2. The
    # profile above deliberately matches any Ethernet device, so readiness
    # must likewise use the first non-loopback global IPv4 address instead
    # of pinning a transport-specific device name.
    ipv4=$(ip -4 -o addr show scope global 2>/dev/null | \
        awk '$2 != "lo" { split($4, address, "/"); print address[1]; exit }')
    [ -n "$ipv4" ] && break
    sleep 1
done

if [ -z "$ipv4" ]; then
    echo "FIRECRAB_NETWORK_FAILED no-ipv4-address"
    exit 0
fi
gw=$(ip -4 route show default 2>/dev/null | awk '{print $3; exit}')
if [ -n "$gw" ] && [ ! -e /run/systemd/resolve/stub-resolv.conf ]; then
    if [ -L /etc/resolv.conf ] || [ ! -s /etc/resolv.conf ]; then
        rm -f /etc/resolv.conf
        printf 'nameserver %s\n' "$gw" > /etc/resolv.conf
    fi
fi
dns_ok() {
    getent hosts example.com >/dev/null 2>&1 && return 0
    if [ -n "$gw" ] && command -v dig >/dev/null 2>&1; then
        ans=$(dig +short +time=2 +tries=1 @"$gw" example.com A 2>/dev/null || true)
        [ -n "$ans" ] && return 0
    fi
    return 1
}
for _ in $(seq 1 15); do
    if dns_ok; then
        echo "FIRECRAB_NETWORK_READY $ipv4"
        exit 0
    fi
    sleep 1
done
echo "FIRECRAB_NETWORK_FAILED dns-unreachable"
EOF_SENTINEL
chmod 0755 "$staging/usr/local/sbin/firecrab-network-ready.sh"

cat >"$staging/etc/systemd/system/firecrab-network-ready.service" <<'EOF_SERVICE'
[Unit]
Description=Firecrab network readiness sentinel
After=NetworkManager-wait-online.service
Wants=NetworkManager-wait-online.service

[Service]
Type=oneshot
StandardOutput=tty
TTYPath=/dev/console
ExecStart=/usr/local/sbin/firecrab-network-ready.sh

[Install]
WantedBy=multi-user.target
EOF_SERVICE
ln -sfn /etc/systemd/system/firecrab-network-ready.service \
  "$staging/etc/systemd/system/multi-user.target.wants/firecrab-network-ready.service"

if [ -s /input/id_ed25519.pub ]; then
  install -d -m 0700 "$staging/root/.ssh"
  install -m 0600 /input/id_ed25519.pub "$staging/root/.ssh/authorized_keys"
fi

# EL9's kernel-install layout keeps the raw kernel under
# /usr/lib/modules/<version>/vmlinuz; unlike Debian-family packages it does
# not have to create a /boot/vmlinuz-* copy. Select that authoritative file
# and keep the initramfs in /boot where dracut writes it.
vmlinuz_path=$(find "$staging/usr/lib/modules" -mindepth 2 -maxdepth 2 -type f -name vmlinuz -printf '%p\n' | sort -V | tail -n 1)
[ -n "$vmlinuz_path" ] || fail 'Rocky kernel package did not install usr/lib/modules/*/vmlinuz'
kernel_version=$(basename "$(dirname "$vmlinuz_path")")
initrd_path="$staging/boot/initramfs-${kernel_version}.img"
kernel_config="$staging/usr/lib/modules/${kernel_version}/config"

# Guard each architecture's transport contract so a kernel package change
# cannot silently produce a template the runtime cannot boot.
virtio_drivers='virtio_blk virtio_net ext4'
case "$rocky_arch" in
  x86_64)
    grep -Eq '^CONFIG_VIRTIO_PCI=(y|m)$' "$kernel_config" \
      || fail "Rocky x86_64 kernel lacks CONFIG_VIRTIO_PCI: ${kernel_config}"
    if grep -q '^CONFIG_VIRTIO_PCI=m$' "$kernel_config"; then
      virtio_drivers="${virtio_drivers} virtio_pci"
    fi
    ;;
  aarch64)
    grep -Eq '^CONFIG_VIRTIO_MMIO=(y|m)$' "$kernel_config" \
      || fail "Rocky aarch64 kernel lacks CONFIG_VIRTIO_MMIO: ${kernel_config}"
    if grep -q '^CONFIG_VIRTIO_MMIO=m$' "$kernel_config"; then
      virtio_drivers="${virtio_drivers} virtio_mmio"
    fi
    ;;
esac

# A generic initramfs is necessary: it must not inherit the build host's
# host hardware and must contain the architecture-appropriate virtio
# transport plus block/network/ext4 modules needed before / is mounted. The
# temporary mounts are private to this privileged builder
# container; they let target-root dracut see normal /proc, /sys, /dev, and
# /run while retaining the guest's own kernel modules and dracut files.
info "building generic dracut initramfs for ${kernel_version}"
chroot "$staging" /usr/bin/dracut --force --no-hostonly \
  --add-drivers "$virtio_drivers" \
  "/boot/initramfs-${kernel_version}.img" "$kernel_version"
cleanup_chroot_mounts

[ -s "$initrd_path" ] || fail "dracut did not create ${initrd_path}"
test -e "$staging/etc/os-release" || fail 'missing /etc/os-release'
grep -Eq "^VERSION_ID=\"?${rocky_release//./\\.}\"?$" "$staging/etc/os-release" \
  || fail "Rocky rootfs is not pinned to VERSION_ID ${rocky_release}"
test -e "$staging/sbin/init" || fail 'missing /sbin/init'
test -x "$staging/usr/sbin/sshd" || fail 'missing sshd'
if [ -s /input/id_ed25519.pub ]; then
  test -s "$staging/root/.ssh/authorized_keys" || fail 'missing root authorized_keys'
fi
network_profile="$staging/etc/NetworkManager/system-connections/firecrab-ethernet.nmconnection"
test -e "$network_profile" || fail 'missing transport-independent DHCP profile'
if grep -q '^interface-name=' "$network_profile"; then
  fail 'Rocky DHCP profile must not pin a Firecracker transport-specific interface name'
fi
test -e "$staging/etc/systemd/system/firecrab-network-ready.service" || fail 'missing network readiness service'

cp "$vmlinuz_path" /kernel-out/vmlinuz-rocky-raw
cp "$initrd_path" "/kernel-out/${initrd_image_name}"
chmod 0644 /kernel-out/vmlinuz-rocky-raw "/kernel-out/${initrd_image_name}"

# Dropped from the rootfs now that it is in /kernel-out: the runtime boots
# the initrd artifact this just produced, never one from inside the guest
# filesystem, so keeping the 27.9 MB duplicate only inflates every package.
# Same reasoning (and the same %ghost ownership) as
# bootstrap-rocky-in-guest.sh's copy of this step.
rm -f "$initrd_path"

rootfs_image="/out/${rootfs_image_name}"
rootfs_tmp="${rootfs_image}.tmp"
rm -f "$rootfs_tmp"
truncate -s "$rootfs_size" "$rootfs_tmp"
mkfs.ext4 -F -L rootfs -d "$staging" "$rootfs_tmp" >/dev/null
chmod 0644 "$rootfs_tmp"
mv "$rootfs_tmp" "$rootfs_image"

echo "ROOTFS_IMAGE=${rootfs_image}"
EOF
}

# True when the file is a raw Linux/arm64 Image: ARM64_IMAGE_MAGIC ("ARMd")
# at offset 0x38, the field Firecracker's loader checks before it jumps into
# the image. Read, never written — a wrapped kernel (a UKI or an EFI zboot
# image) with the magic stamped on top passes the loader and then dies
# silently in the guest instead of failing here.
is_arm64_image() {
  [ -s "$1" ] && [ "$(od -An -tx1 -j56 -N4 "$1" | tr -d ' \n')" = '41524d64' ]
}

prepare_kernel() {
  local raw_path="${kernel_artifact_dir}/vmlinuz-rocky-raw"
  local kernel_image_path="${kernel_artifact_dir}/${kernel_image_name}"
  local kernel_image_tmp="${kernel_image_path}.tmp"

  [ -s "$raw_path" ] || fail "Rocky kernel was not copied out to ${raw_path}"
  if [ "$rocky_arch" = aarch64 ]; then
    # Rocky's arm64 vmlinuz is an EFI zboot image (a PE wrapper around a
    # gzip-compressed Image), which Firecracker cannot boot — unwrap it.
    info "extracting raw ARM64 Image from: ${raw_path}"
    if ! "$extract_arm64_image" "$raw_path" >"$kernel_image_tmp" \
      || ! is_arm64_image "$kernel_image_tmp"; then
      rm -f "$kernel_image_tmp"
      fail "extract-arm64-image could not extract a raw ARM64 Image from ${raw_path}"
    fi
  else
    info "extracting x86_64 ELF vmlinux from: ${raw_path}"
    if ! "$extract_vmlinux" "$raw_path" >"$kernel_image_tmp"; then
      rm -f "$kernel_image_tmp"
      fail "extract-vmlinux could not extract an ELF vmlinux from ${raw_path}"
    fi
    if ! file "$kernel_image_tmp" | grep -q 'ELF'; then
      rm -f "$kernel_image_tmp"
      fail "extracted kernel is not an ELF image: ${raw_path}"
    fi
  fi
  chmod 0644 "$kernel_image_tmp"
  mv "$kernel_image_tmp" "$kernel_image_path"
  rm -f "$raw_path"

  [ -s "${kernel_artifact_dir}/${initrd_image_name}" ] || \
    fail "Rocky initramfs was not copied out to ${kernel_artifact_dir}/${initrd_image_name}"
  info "Rocky kernel image: ${kernel_image_path}"
  info "Rocky initramfs: ${kernel_artifact_dir}/${initrd_image_name}"
}

cleanup_container_mounts() {
  local target

  for target in "${container_mounts[@]}"; do
    umount -R "$target" 2>/dev/null || umount -l "$target" 2>/dev/null || true
  done
  container_mounts=()
}

mount_container_tree() {
  local source=$1
  local target=$2

  mkdir -p "$target"
  mount --rbind "$source" "$target"
  mount --make-rslave "$target"
  container_mounts=("$target" "${container_mounts[@]}")
}

download_container_base() {
  local archive_path=$1
  local checksum_path=$2
  local checksum_tmp="${checksum_path}.tmp"
  local expected_sha256=''

  if [ ! -s "$archive_path" ]; then
    info "downloading Rocky ${rocky_release} ${rocky_arch} Container-Base"
    curl -fsSL "$container_url" -o "${archive_path}.tmp" \
      || { rm -f "${archive_path}.tmp"; fail "Could not download ${container_url}"; }
    mv "${archive_path}.tmp" "$archive_path"
  else
    info "reusing Rocky Container-Base archive: ${archive_path}"
  fi

  if curl -fsSL "${container_url}.CHECKSUM" -o "$checksum_tmp"; then
    mv "$checksum_tmp" "$checksum_path"
  else
    rm -f "$checksum_tmp"
    [ -s "$checksum_path" ] \
      || fail "Could not download ${container_url}.CHECKSUM"
    info "reusing Rocky Container-Base checksum: ${checksum_path}"
  fi

  expected_sha256=$(sed -n "s/^SHA256 (${container_name}) = //p" "$checksum_path" | head -n 1)
  if [ -z "$expected_sha256" ]; then
    expected_sha256=$(awk -v file="$container_name" '$2 == file || $2 == "*" file { print $1; exit }' "$checksum_path")
  fi
  [ -n "$expected_sha256" ] \
    || fail "Could not parse the checksum for ${container_name}"
  printf '%s  %s\n' "$expected_sha256" "$archive_path" | sha256sum -c - >/dev/null \
    || fail 'Rocky Container-Base checksum verification failed'
}

extract_container_base() {
  local archive_path=$1
  local container_root=$2
  local archive_tree=$3
  local manifest_digest=''
  local -a layers=()
  local layer

  rm -rf "$container_root" "$archive_tree"
  mkdir -p "$container_root" "$archive_tree"
  tar -xJf "$archive_path" -C "$archive_tree"

  if [ -f "$archive_tree/index.json" ]; then
    manifest_digest=$(jq -r '.manifests[0].digest | sub("^sha256:"; "")' \
      "$archive_tree/index.json")
    if [ -z "$manifest_digest" ] || [ "$manifest_digest" = null ]; then
      fail 'Could not read Container-Base manifest digest from index.json'
    fi
    mapfile -t layers < <(
      jq -r '.layers[].digest | sub("^sha256:"; "")' \
        "$archive_tree/blobs/sha256/${manifest_digest}"
    )
    [ "${#layers[@]}" -gt 0 ] || fail 'Container-Base OCI manifest has no layers'
    for layer in "${layers[@]}"; do
      [ -f "$archive_tree/blobs/sha256/${layer}" ] \
        || fail "Container-Base layer is missing: ${layer}"
      tar -xf "$archive_tree/blobs/sha256/${layer}" -C "$container_root"
    done
  elif [ -f "$archive_tree/manifest.json" ]; then
    mapfile -t layers < <(jq -r '.[0].Layers[]' "$archive_tree/manifest.json")
    [ "${#layers[@]}" -gt 0 ] || fail 'Container-Base Docker manifest has no layers'
    for layer in "${layers[@]}"; do
      [ -f "$archive_tree/$layer" ] || fail "Container-Base layer is missing: ${layer}"
      tar -xf "$archive_tree/$layer" -C "$container_root"
    done
  else
    fail 'Container-Base is neither an OCI nor Docker archive'
  fi

  [ -x "$container_root/usr/bin/dnf" ] || fail 'Container-Base is missing usr/bin/dnf'
  [ -x "$container_root/bin/bash" ] || fail 'Container-Base is missing bin/bash'
}

restore_output_ownership() {
  if [ -z "${SUDO_UID:-}" ] || [ -z "${SUDO_GID:-}" ]; then
    return 0
  fi

  chown "${SUDO_UID}:${SUDO_GID}" \
    "${artifact_dir}/${rootfs_image_name}" \
    "${kernel_artifact_dir}/${kernel_image_name}" \
    "${kernel_artifact_dir}/${initrd_image_name}"
  chmod u+rw,go+r \
    "${artifact_dir}/${rootfs_image_name}" \
    "${kernel_artifact_dir}/${kernel_image_name}" \
    "${kernel_artifact_dir}/${initrd_image_name}"
}

main() {
  [ "$#" -eq 0 ] || fail 'install-rocky-rootfs.sh does not accept arguments.'

  for command in awk chmod chown chroot cp curl file find grep head id install jq \
    mkdir mount mv od rm sed sha256sum sort tail tar truncate umount uname xz; do
    require_command "$command"
  done
  if [ "$rocky_arch" = x86_64 ]; then
    [ -x "$extract_vmlinux" ] || fail "extract-vmlinux helper not found or not executable: ${extract_vmlinux}"
  else
    [ -x "$extract_arm64_image" ] || fail "extract-arm64-image helper not found or not executable: ${extract_arm64_image}"
  fi

  if [ "$(id -u)" -ne 0 ]; then
    require_command sudo
    exec sudo env \
      "M2IMAGE_ARCH=${M2IMAGE_ARCH:-}" \
      "M2IMAGE_DISTRO_SERIES=${M2IMAGE_DISTRO_SERIES:-9.8}" \
      "M2IMAGE_DISTRO_VERSION=${rocky_release}" \
      "M2IMAGE_ALIAS=${m2image_alias}" \
      "M2IMAGE_SBOM_OUTPUT=${sbom_output}" \
      "ROCKY_REPOSITORY_BASE=${rocky_repository_base}" \
      "ROCKY_CONTAINER_BUILD=${rocky_container_build}" \
      "FIRECRAB_SSH_PUBLIC_KEY=${FIRECRAB_SSH_PUBLIC_KEY:-}" \
      "$script_path"
  fi

  build_dir=$(abs_dir "$build_dir")
  artifact_dir=$(abs_dir "$artifact_dir")
  kernel_artifact_dir=$(abs_dir "$kernel_artifact_dir")
  verify_native_architecture
  trap cleanup_container_mounts EXIT

  local ssh_public_key
  ssh_public_key=$(resolve_ssh_public_key)
  local staging_dir="${build_dir}/mnt"
  local configure_script="${build_dir}/configure.sh"
  local download_dir="${build_dir}/downloads"
  local archive_path="${download_dir}/${container_name}"
  local checksum_path="${download_dir}/${container_name}.CHECKSUM"
  local archive_tree="${build_dir}/container-archive"
  local container_root="${build_dir}/container-root"

  mkdir -p "$download_dir" "$staging_dir"
  download_container_base "$archive_path" "$checksum_path"
  info "extracting Rocky ${rocky_release} ${rocky_arch} Container-Base tarball"
  extract_container_base "$archive_path" "$container_root" "$archive_tree"

  write_configure_script "$configure_script"
  install -m 0755 "$configure_script" "$container_root/configure.sh"
  install -d -m 0755 "$container_root/input"
  install -m 0644 "$ssh_public_key" "$container_root/input/id_ed25519.pub"
  rm -f "$container_root/etc/resolv.conf"
  cp /etc/resolv.conf "$container_root/etc/resolv.conf"

  mkdir -p "$container_root/proc"
  mount -t proc proc "$container_root/proc"
  container_mounts=("$container_root/proc" "${container_mounts[@]}")
  mount_container_tree /sys "$container_root/sys"
  mount_container_tree /dev "$container_root/dev"
  mount_container_tree /run "$container_root/run"
  mount_container_tree "$staging_dir" "$container_root/work/rootfs"
  mount_container_tree "$artifact_dir" "$container_root/out"
  mount_container_tree "$kernel_artifact_dir" "$container_root/kernel-out"

  info "building Rocky Linux ${rocky_release} ${rocky_arch} via native chroot + direct ext4"
  chroot "$container_root" /bin/bash /configure.sh \
      "$rootfs_size" "$rootfs_hostname" "$rootfs_packages" \
      "$initrd_image_name" "$rocky_release" "$rocky_arch" "$rootfs_image_name" \
      "$rocky_repository_base"

  if [ -n "$sbom_output" ]; then
    [ -n "$m2image_alias" ] || fail 'M2IMAGE_ALIAS is required with M2IMAGE_SBOM_OUTPUT'
    require_command python3
    local package_db="${build_dir}/rpm-packages.tsv"
    chroot "$container_root" /usr/bin/rpm --root /work/rootfs -qa \
      --qf '%{NAME}\t%{EPOCHNUM}:%{VERSION}-%{RELEASE}\t%{ARCH}\t%{LICENSE}\t%{SOURCERPM}\n' \
      >"$package_db"
    [ -s "$package_db" ] || fail 'Rocky rpm package query returned no installed packages'
    python3 "$sbom_generator" \
      --format rpm-tsv --distribution rocky \
      --image-alias "$m2image_alias" --image-version "$rocky_release" \
      --architecture "$rocky_arch" \
      --package-db "$package_db" --output "$sbom_output"
    if [ -n "${SUDO_UID:-}" ] && [ -n "${SUDO_GID:-}" ]; then
      chown "${SUDO_UID}:${SUDO_GID}" "$sbom_output"
    fi
  fi

  prepare_kernel
  [ -s "${artifact_dir}/${rootfs_image_name}" ] || \
    fail "Rocky rootfs image was not created: ${artifact_dir}/${rootfs_image_name}"
  restore_output_ownership
  info "Rocky rootfs image: ${artifact_dir}/${rootfs_image_name}"
}

main "$@"