#!/usr/bin/bash # # Runs inside an aarch64 Fedora container (see build/build-image.sh). # # Stages a Fedora root filesystem with dnf, applies the C630 overlay, then # assembles a GPT disk image. Deliberately avoids loop devices: filesystems are # built from directory trees with `mke2fs -d` and `mcopy`, then dd'd into a # partitioned sparse file. That keeps the whole thing working on CI runners # where /dev/loop-control is not available to the job. set -euo pipefail SRC=/src OUT=/out WORK=/work # persists between builds; see --work in build-image.sh BASE="$WORK/base" # pristine post-dnf tree, reused between builds ROOTFS="$WORK/rootfs" # disposable working copy of the above STAMP="$WORK/base.stamp" DNF_CACHE=/var/cache/c630-dnf # bind-mounted from the host, survives the run # shellcheck source=../config/device.env source "$SRC/config/device.env" : "${VARIANT:=minimal}" : "${BUILD_REF:=unknown}" : "${BUILD_DATE:=unknown}" : "${KEEP_ROOTFS:=0}" : "${FRESH:=0}" : "${COMPRESS_IN_CONTAINER:=1}" # Bump when the *procedure* for staging the base changes in a way that alters # its contents. The stamp otherwise hashes only the package list, so a change # like excluding a weak dependency would silently reuse a stale base. BASE_RECIPE=2 # dracut-config-rescue arrives as a weak dependency of dracut and only costs us: # it makes kernel-install build a second, rescue initramfs — another emulated # dracut run — and leaves a rescue entry in the boot menu we never use. On a # machine this slow it would also double the cost of every future kernel update. DNF_EXCLUDE=(--exclude=dracut-config-rescue) IMAGE_NAME="fedora-${FEDORA_RELEASE}-${VARIANT}-${DEVICE_NAME}-${BUILD_DATE}-${BUILD_REF}" IMAGE_PATH="$OUT/${IMAGE_NAME}.img" log() { printf '\n\033[1;34m==> %s\033[0m\n' "$*"; } MOUNTED=() unbind_all() { local i for (( i=${#MOUNTED[@]}-1; i>=0; i-- )); do umount "${MOUNTED[i]}" 2>/dev/null || umount -l "${MOUNTED[i]}" 2>/dev/null || true done MOUNTED=() } trap unbind_all EXIT bind() { mount --bind "$1" "$2" && MOUNTED+=("$2"); } mkdir -p "$WORK" "$DNF_CACHE" # $WORK persists between builds so the staged base can be reused — which means # everything else in it persists too, including intermediates from a build that # was cancelled or failed before its cleanup ran. That is not inert: mkfs.vfat # -C refuses to overwrite an existing file, and `mv dir $WORK/boot` nests inside # a surviving directory rather than replacing it. So state the invariant # positively — only the staged base and its stamp survive a new run. find "$WORK" -mindepth 1 -maxdepth 1 \ ! -name base ! -name base.stamp -exec rm -rf {} + # --------------------------------------------------------------------------- # Build tooling. Every dnf transaction in here runs emulated, so the prebuilt # base image (gongfoo's build-fedora-44-aarch64) carries these already and this # becomes a no-op. Only a stock Fedora image pays for it. # --------------------------------------------------------------------------- if command -v mke2fs >/dev/null && command -v mcopy >/dev/null \ && command -v sgdisk >/dev/null && command -v setfiles >/dev/null; then echo "build tooling already present in the container image" else log "Installing build tooling into the container" dnf -y install --setopt=install_weak_deps=False \ --setopt=cachedir="$DNF_CACHE" --setopt=keepcache=1 \ e2fsprogs dosfstools mtools gdisk util-linux rsync zstd findutils \ policycoreutils \ >/dev/null fi # --------------------------------------------------------------------------- log "Resolving package list (base + ${VARIANT})" # --------------------------------------------------------------------------- read_pkgs() { # Strip comments and blank lines. '@^env' is dnf4 spelling for an # environment group; dnf5 wants a plain '@env'. sed -e 's/#.*//' -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e '/^$/d' \ -e 's/^@\^/@/' "$1" } mapfile -t PACKAGES < <( read_pkgs "$SRC/config/packages/base.pkgs" read_pkgs "$SRC/config/packages/${VARIANT}.pkgs" ) echo "${#PACKAGES[@]} package specs" # --------------------------------------------------------------------------- # The dnf transaction is the only genuinely expensive step — an hour or more of # emulated rpm scriptlets. Everything after it is minutes. So stage it once into # a pristine tree keyed on the inputs that would change it, and copy that tree # per build. Iterating on the kernel command line or the overlay then costs a # copy instead of a reinstall. # --------------------------------------------------------------------------- WANT_STAMP="$(printf '%s\n' "$FEDORA_RELEASE" "$TARGET_ARCH" "$VARIANT" \ "recipe=$BASE_RECIPE" "${DNF_EXCLUDE[@]}" "${PACKAGES[@]}" \ | sha256sum | cut -d' ' -f1)" if [ "$FRESH" = 1 ]; then log "Discarding the staged base (--fresh)" rm -rf "$BASE" "$STAMP" fi if [ -d "$BASE" ] && [ "$(cat "$STAMP" 2>/dev/null || true)" = "$WANT_STAMP" ]; then log "Reusing the staged base — package set is unchanged" echo "pass --fresh to force a reinstall" else rm -rf "$BASE" "$STAMP" mkdir -p "$BASE/etc/yum.repos.d" "$BASE/etc/pki/rpm-gpg" "$BASE/etc/dnf" # dnf reads its repo definitions from inside --installroot. Seed them from # the container (same release, same arch) so the first transaction has # somewhere to fetch from and something to verify signatures against. The # fedora-repos package overwrites these with its own during the transaction. cp -a /etc/yum.repos.d/. "$BASE/etc/yum.repos.d/" cp -a /etc/pki/rpm-gpg/. "$BASE/etc/pki/rpm-gpg/" if [ -d /etc/dnf/vars ]; then cp -a /etc/dnf/vars "$BASE/etc/dnf/"; fi # The kernel's %posttrans runs kernel-install, which runs dracut — before # this build has written /etc/dracut.conf.d/10-c630.conf and before /proc is # bind-mounted. The result is an initramfs that is both wrong and expensive: # two emulated dracut runs (normal + rescue) costing roughly 40 minutes, # immediately superseded by the one stage2 builds later with the right # config. Both 50-dracut.install and 51-dracut-rescue.install bail out when # KERNEL_INSTALL_INITRD_GENERATOR is anything other than "dracut". # # Expected side effect: with no initramfs present, 95-set-boot-entry.install # logs "Error: /boot/initramfs-.img not found" and exits 1 during the # transaction. rpm reports the scriptlet failure and carries on. It does not # matter here — stage2 writes its own BLS entry below — and it cannot happen # on the device, where this file is gone and dracut runs normally. # # This file must not survive into the image — see the removal after the # working copy is made. mkdir -p "$BASE/etc/kernel" printf 'initrd_generator=none\n' > "$BASE/etc/kernel/install.conf" log "Installing Fedora ${FEDORA_RELEASE} (${TARGET_ARCH}) — this is the slow part" # keepcache=1 with a cachedir outside the install root: the downloaded rpms # outlive both the transaction and the staged tree, so a --fresh rebuild # re-runs the scriptlets but does not re-download 500-odd packages. dnf -y \ --installroot="$BASE" \ --releasever="$FEDORA_RELEASE" \ --setopt=cachedir="$DNF_CACHE" \ --setopt=keepcache=1 \ --setopt=install_weak_deps=True \ "${DNF_EXCLUDE[@]}" \ install "${PACKAGES[@]}" printf '%s\n' "$WANT_STAMP" > "$STAMP" fi # --------------------------------------------------------------------------- log "Copying the staged base into a working tree" # --------------------------------------------------------------------------- # --reflink=auto is near-instant on btrfs (Fedora's default) and degrades to a # real copy elsewhere. The working tree gets mutated heavily below — accounts, # initramfs, bootloader — so the base has to stay untouched. rm -rf "$ROOTFS" cp -a --reflink=auto "$BASE" "$ROOTFS" mkdir -p "$WORK/esp" # Undo the build-time suppression of initramfs generation. Shipping this would # mean the laptop generates no initramfs on its next kernel update and does not # come back up — the worst kind of bug, because it appears weeks later and looks # nothing like an image problem. Nothing in Fedora owns this path, so removing # it restores stock behaviour exactly. rm -f "$ROOTFS/etc/kernel/install.conf" KVER="$(rpm --root "$ROOTFS" -q kernel-core --qf '%{VERSION}-%{RELEASE}.%{ARCH}\n' \ | sort -V | tail -1)" [ -n "$KVER" ] || { echo "could not determine installed kernel version" >&2; exit 1; } echo "kernel: $KVER" if [ ! -e "$ROOTFS/boot/dtb-${KVER}/${DEVICE_DTB}" ]; then echo "error: ${DEVICE_DTB} is not in this kernel's device trees." >&2 echo " Check DEVICE_DTB in config/device.env against:" >&2 ls "$ROOTFS/boot/dtb-${KVER}/qcom/" | grep -i yoga >&2 || true exit 1 fi # --------------------------------------------------------------------------- log "Applying overlay" # --------------------------------------------------------------------------- render() { sed -e "s|@DEVICE_DTB@|${DEVICE_DTB}|g" \ -e "s|@DEVICE_CMDLINE@|${DEVICE_CMDLINE}|g" \ -e "s|@DEVICE_DESC@|${DEVICE_DESC}|g" \ -e "s|@DEVICE_NAME@|${DEVICE_NAME}|g" \ -e "s|@FEDORA_RELEASE@|${FEDORA_RELEASE}|g" \ -e "s|@BUILD_REF@|${BUILD_REF}|g" \ -e "s|@BUILD_DATE@|${BUILD_DATE}|g" } while IFS= read -r rel; do src="$SRC/overlay/$rel" dst="$ROOTFS/$rel" if [[ "$rel" == *.in ]]; then dst="${dst%.in}" mkdir -p "$(dirname "$dst")" render < "$src" > "$dst" else mkdir -p "$(dirname "$dst")" cp "$src" "$dst" fi chmod --reference="$src" "$dst" done < <(cd "$SRC/overlay" && find . -type f -printf '%P\n') # Locally-supplied firmware, if the operator dropped any in. Contents mirror # /usr/lib/firmware/updates/ and are gitignored — see docs/firmware.md. if compgen -G "$SRC/firmware/local/*" >/dev/null; then log "Baking in firmware from firmware/local/" mkdir -p "$ROOTFS/usr/lib/firmware/updates" rsync -a --exclude=.gitkeep "$SRC/firmware/local/" \ "$ROOTFS/usr/lib/firmware/updates/" find "$ROOTFS/usr/lib/firmware/updates" -type f -printf ' %P\n' fi # --------------------------------------------------------------------------- log "Generating identifiers and filesystem tables" # --------------------------------------------------------------------------- ROOT_UUID="$(uuidgen)" BOOT_UUID="$(uuidgen)" ESP_ID="$(od -An -tx1 -N4 /dev/urandom | tr -d ' \n' | tr 'a-f' 'A-F')" ESP_UUID="${ESP_ID:0:4}-${ESP_ID:4:4}" cat > "$ROOTFS/etc/fstab" < "$ROOTFS/etc/kernel/cmdline" # Empty (not missing) machine-id marks this as a first boot for systemd, which # then generates a unique one per device rather than cloning the builder's. : > "$ROOTFS/etc/machine-id" ln -sf ../run/systemd/resolve/stub-resolv.conf "$ROOTFS/etc/resolv.conf" echo "$DEVICE_NAME" > "$ROOTFS/etc/hostname" # SELinux labelling happens later, once the bind mounts are gone — see # "Labelling the filesystem for SELinux" below. # --------------------------------------------------------------------------- log "Configuring the target system" # --------------------------------------------------------------------------- bind /proc "$ROOTFS/proc" bind /sys "$ROOTFS/sys" bind /dev "$ROOTFS/dev" bind /dev/pts "$ROOTFS/dev/pts" chroot "$ROOTFS" useradd -m -G wheel -s /bin/bash "$DEFAULT_USER" echo "${DEFAULT_USER}:${DEFAULT_PASSWORD}" | chroot "$ROOTFS" chpasswd chroot "$ROOTFS" chage -d 0 "$DEFAULT_USER" # force a change at first login chroot "$ROOTFS" passwd -l root # rmtfs and tqftpserv are both shipped disabled by Fedora. They are inert # without modem firmware and cost nothing to have running, so enable them here # rather than leaving a step to rediscover later — onboard WiFi cannot work # without tqftpserv, since the modem fetches the WLAN firmware over TFTP. chroot "$ROOTFS" systemctl enable \ c630-growfs.service \ c630-bt-addr.service \ sshd.service \ NetworkManager.service \ systemd-resolved.service \ rmtfs.service \ tqftpserv.service if [ "$VARIANT" = workstation ]; then chroot "$ROOTFS" systemctl set-default graphical.target else chroot "$ROOTFS" systemctl set-default multi-user.target fi # --------------------------------------------------------------------------- log "Building initramfs for ${KVER}" # --------------------------------------------------------------------------- chroot "$ROOTFS" dracut --force --no-hostonly --no-hostonly-cmdline \ "/boot/initramfs-${KVER}.img" "$KVER" # --------------------------------------------------------------------------- log "Writing bootloader configuration" # --------------------------------------------------------------------------- mkdir -p "$ROOTFS/boot/loader/entries" # kernel-install already wrote an entry during the dnf transaction, from inside # the install root — where /boot is an ordinary directory, not a partition. It # is wrong in three ways: its paths are /boot/vmlinuz-… which resolve nowhere # once /boot is a filesystem in its own right; its options line is empty, so no # root= and no command line; and it has no devicetree, so even if it loaded, the # kernel would not know what machine it was on. Being named after the machine-id # it also sorts before ours, so GRUB picks it. Delete anything we did not write. rm -f "$ROOTFS/boot/loader/entries/"*.conf # The bootstrap BLS entry. Subsequent kernels get theirs from kernel-install, # with the devicetree line supplied by 95-c630-devicetree.install. cat > "$ROOTFS/boot/loader/entries/c630-${KVER}.conf" < "$ROOTFS/boot/grub2/grub.cfg" < # # takes effect for exactly one boot and then reverts, so a kernel that hangs # costs a power cycle rather than a rescue. Entry ids are the BLS file names in # /boot/loader/entries without the .conf. if [ "\$next_entry" ] ; then set default="\$next_entry" set next_entry= save_env next_entry set boot_once=true fi blscfg EOF : > "$ROOTFS/boot/grub2/grubenv" # ESP: GRUB at the removable-media path, because that is the only thing the # C630's firmware will find on a freshly written USB stick or SD card, and a # one-line stub telling it where the real configuration lives. GRUB_EFI="$ROOTFS/boot/efi/EFI/fedora/grubaa64.efi" [ -f "$GRUB_EFI" ] || { echo "grubaa64.efi missing from the install root" >&2; exit 1; } mkdir -p "$WORK/esp/EFI/BOOT" "$WORK/esp/EFI/fedora" cp "$GRUB_EFI" "$WORK/esp/EFI/BOOT/BOOTAA64.EFI" cp "$GRUB_EFI" "$WORK/esp/EFI/fedora/grubaa64.efi" if [ -f "$ROOTFS/boot/efi/EFI/fedora/shimaa64.efi" ]; then cp "$ROOTFS/boot/efi/EFI/fedora/shimaa64.efi" "$WORK/esp/EFI/fedora/" fi # grubaa64.efi is built with a compiled-in prefix of /EFI/fedora, so this is # the file it looks for regardless of which path it was launched from. cat > "$WORK/esp/EFI/fedora/grub.cfg" <&2 exit 1 fi done # --------------------------------------------------------------------------- log "Labelling the filesystem for SELinux" # --------------------------------------------------------------------------- # mke2fs -d builds the filesystem from a directory tree and carries security.* # xattrs across, but nothing has set them: the tree came out of dnf, not out of # a running SELinux system. Boot an unlabelled root and systemd cannot set a # context on anything under /dev, logs a screenful of "Permission denied", and # dies with "Failed to allocate manager object". /.autorelabel does not rescue # it — PID 1 never survives long enough to act on the flag. # # So label it here, after the bind mounts are gone (or setfiles would walk the # builder's /proc) and before /boot is split out, so /boot's files are labelled # with everything else. FILE_CONTEXTS="$ROOTFS/etc/selinux/targeted/contexts/files/file_contexts" LABELLED=0 if [ -f "$FILE_CONTEXTS" ] && command -v setfiles >/dev/null; then if setfiles -F -r "$ROOTFS" "$FILE_CONTEXTS" "$ROOTFS"; then LABELLED=1 echo "filesystem labelled" else echo "warning: setfiles failed — falling back to a first-boot relabel" >&2 fi else echo "warning: no SELinux policy or no setfiles in this container" >&2 fi mkdir -p "$ROOTFS/etc/selinux" cat > "$ROOTFS/etc/selinux/config" < "$ROOTFS/.autorelabel" fi mv "$ROOTFS/boot" "$WORK/boot" mkdir -p "$ROOTFS/boot" rm -rf "$WORK/boot/efi" mkdir -p "$WORK/boot/efi" # --------------------------------------------------------------------------- log "Partitioning ${IMAGE_SIZE_MIB} MiB image" # --------------------------------------------------------------------------- rm -f "$IMAGE_PATH" truncate -s "${IMAGE_SIZE_MIB}M" "$IMAGE_PATH" sgdisk --zap-all "$IMAGE_PATH" >/dev/null sgdisk \ --new "1:1M:+${ESP_SIZE_MIB}M" --typecode 1:ef00 --change-name 1:ESP \ --new "2:0:+${BOOT_SIZE_MIB}M" --typecode 2:8300 --change-name 2:boot \ --new "3:0:0" --typecode 3:8300 --change-name 3:root \ "$IMAGE_PATH" >/dev/null sgdisk --print "$IMAGE_PATH" part_first() { sgdisk --info="$1" "$IMAGE_PATH" | awk '/First sector/ {print $3}'; } part_last() { sgdisk --info="$1" "$IMAGE_PATH" | awk '/Last sector/ {print $3}'; } ESP_START=$(part_first 1); ESP_SECTORS=$(( $(part_last 1) - ESP_START + 1 )) BOOT_START=$(part_first 2); BOOT_SECTORS=$(( $(part_last 2) - BOOT_START + 1 )) ROOT_START=$(part_first 3); ROOT_SECTORS=$(( $(part_last 3) - ROOT_START + 1 )) # --------------------------------------------------------------------------- log "Building filesystems from the staged trees" # --------------------------------------------------------------------------- # orphan_file and metadata_csum_seed are recent ext4 features that older GRUB # builds refuse to read. /boot has to be readable by whatever GRUB the firmware # ends up running, so keep both filesystems conservative. EXT4_OPTS="^orphan_file,^metadata_csum_seed" mkfs.vfat -F 32 -n ESP -i "$ESP_ID" -C "$WORK/esp.img" $(( ESP_SECTORS / 2 )) >/dev/null mcopy -i "$WORK/esp.img" -s "$WORK/esp/EFI" :: mke2fs -q -t ext4 -b 4096 -O "$EXT4_OPTS" -L boot -U "$BOOT_UUID" \ -d "$WORK/boot" "$WORK/boot.img" $(( BOOT_SECTORS / 8 )) # Last chance to catch the build-time initramfs suppression leaking into the # image. If it shipped, the laptop would boot fine and then fail to come back # after its next kernel update — far from here, and looking nothing like an # image bug. Cheap to assert, so assert it. if [ -e "$ROOTFS/etc/kernel/install.conf" ]; then echo "error: /etc/kernel/install.conf is about to ship — it disables" >&2 echo " initramfs generation and would brick the next kernel update" >&2 exit 1 fi mke2fs -q -t ext4 -b 4096 -O "$EXT4_OPTS" -L fedora -U "$ROOT_UUID" \ -d "$ROOTFS" "$WORK/root.img" $(( ROOT_SECTORS / 8 )) # --------------------------------------------------------------------------- log "Assembling the disk image" # --------------------------------------------------------------------------- dd if="$WORK/esp.img" of="$IMAGE_PATH" bs=512 seek="$ESP_START" conv=notrunc,sparse status=none dd if="$WORK/boot.img" of="$IMAGE_PATH" bs=512 seek="$BOOT_START" conv=notrunc,sparse status=none dd if="$WORK/root.img" of="$IMAGE_PATH" bs=512 seek="$ROOT_START" conv=notrunc,sparse status=none # --------------------------------------------------------------------------- # Compression is architecture-independent, so running it here runs it under # emulation — roughly twenty minutes for work the host does in two. When the # host has zstd, build-image.sh handles it and this just hands over the name. # --------------------------------------------------------------------------- printf '%s\n' "$IMAGE_NAME" > "$OUT/.build-result" if [ "$COMPRESS_IN_CONTAINER" = 1 ]; then log "Compressing (in-container: the host had no zstd)" zstd -12 -T0 --rm -f -o "${IMAGE_PATH}.zst" "$IMAGE_PATH" ( cd "$OUT" && sha256sum "${IMAGE_NAME}.img.zst" > "${IMAGE_NAME}.img.zst.sha256" ) fi # The intermediate filesystem images are multi-gigabyte and worthless once # they are inside the disk image. The staged base is the opposite: expensive to # produce and the whole point of the cache, so it always stays. rm -f "$WORK/esp.img" "$WORK/boot.img" "$WORK/root.img" rm -rf "$WORK/esp" if [ "$KEEP_ROOTFS" != "1" ]; then rm -rf "$ROOTFS" "$WORK/boot" fi log "Done" ls -lh "$OUT" printf '\nstaged base kept (%s) — the next build reuses it unless the package set changes\n' \ "$(du -sh "$BASE" 2>/dev/null | cut -f1)"