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
39 changes: 34 additions & 5 deletions Justfile
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
export image_name := env("IMAGE_NAME", "server4home")
export default_tag := env("DEFAULT_TAG", "stable")
# Registry the published, signed images live in. Used by build-raw-ghcr so a disk
# image written to real hardware gets an origin that can actually be updated.
export registry := env("REGISTRY", "ghcr.io/dx4homelab")
export bib_image := env("BIB_IMAGE", "quay.io/centos-bootc/bootc-image-builder:latest@sha256:903c01d110b8533f8891f07c69c0ba2377f8d4bc7e963311082b7028c04d529d")

alias build-vm := build-qcow2
Expand Down Expand Up @@ -299,11 +302,19 @@ _build-bib $target_image $tag $type $config: (_rootful_load_image target_image t
# BIB writes its output into per-type subdirs (e.g. output/qcow2/disk.qcow2).
# `mv -f` does not replace non-empty directories, so clear the type-specific
# output dir first if a prior build of the same type left one behind.
if [[ "${type}" == "iso" ]]; then
sudo rm -rf output/bootiso
else
sudo rm -rf "output/${type}"
fi
# BIB's output subdir name does not always match the --type it was given:
# --type iso -> output/bootiso
# --type raw -> output/image <-- not output/raw
# --type qcow2 -> output/qcow2
# Clearing the wrong one leaves the real dir non-empty and `mv -f` fails with
# "cannot overwrite 'output/image': Directory not empty", silently leaving the
# previous build's artifact in place.
case "${type}" in
iso) bib_outdir=bootiso ;;
raw) bib_outdir=image ;;
*) bib_outdir="${type}" ;;
esac
sudo rm -rf "output/${bib_outdir}"
sudo mv -f $BUILDTMP/* output/
sudo rmdir $BUILDTMP
sudo chown -R $USER:$USER output/
Expand All @@ -326,6 +337,24 @@ build-qcow2 $target_image=("localhost/" + image_name) $tag=default_tag: && (_bui
[group('Build Virtal Machine Image')]
build-raw $target_image=("localhost/" + image_name) $tag=default_tag: && (_build-bib target_image tag "raw" "iso/disk.toml")

# Build a RAW disk image FOR REAL HARDWARE, from the published signed registry image.
#
# Use this — not `build-raw` — for anything you intend to dd onto a machine.
# bib bakes the image reference it was given as the installed system's ostree
# origin. Building from `localhost/server4home` therefore produces a host whose
# origin is `ostree-image-signed:docker://localhost/server4home:stable`, which no
# amount of enabling rpm-ostreed-automatic.timer can ever update: there is no
# such registry to pull from. Building from the ghcr reference bakes
# `ostree-image-signed:docker://ghcr.io/dx4homelab/server4home:stable` instead,
# so the machine is self-updating from first boot and needs no post-install rebase.
#
# _rootful_load_image pulls the reference when it is not present locally, so this
# works from a clean checkout.

# Build a RAW disk image for real hardware (signed registry origin, self-updating)
[group('Build Virtal Machine Image')]
build-raw-ghcr $tag=default_tag: && (_build-bib (registry + "/" + image_name) tag "raw" "iso/disk.toml")

# Build the base ("plain" / storage) installer ISO — non-LVM, no K3s rebase
[group('Build Virtal Machine Image')]
build-iso-plain $target_image=("localhost/" + image_name) $tag=default_tag: && (_build-bib target_image tag "iso" "iso/iso-plain.toml")
Expand Down
17 changes: 17 additions & 0 deletions build/files/etc/server4home/root-size
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Target size for the root partition, applied once on first boot by
# server4home-firstboot-grow.service.
#
# The disk image ships a small (20 GiB) root so `dd` stays fast; this is the size
# root is grown to on the real hardware. Whatever is left over becomes an LVM PV
# and an empty VG named vg4data, for app data (see docs/storage-build-resume.md).
#
# Accepts IEC sizes: 250G, 512G, 1T, ...
# The literal value "max" grows root over the entire disk and creates no VG.
#
# To change it for a given host, edit this file BEFORE the first boot (loop-mount
# the raw, or write it during provisioning). After the grow has run the stamp at
# /var/lib/server4home/firstboot-grow.done makes this file inert — root cannot be
# shrunk afterwards, since XFS only grows.
#
# Comments and blank lines are ignored; the first real line is used.
250G
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
[Unit]
Description=Grow root into the boot drive and create the app-data VG (first boot)
Documentation=https://github.com/dx4homelab/server4home
# Needs udev populated, the root filesystem mounted rw, and LVM tooling available.
# basic.target covers all three; nothing here is boot-critical, so running late
# is correct — a failure must degrade, not hang a headless server.
After=basic.target
Wants=basic.target
ConditionPathExists=/usr/libexec/server4home/firstboot-grow
# The stamp makes this a genuine one-shot across reboots.
ConditionPathExists=!/var/lib/server4home/firstboot-grow.done
# Nothing to grow in a container build.
ConditionVirtualization=!container

[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/libexec/server4home/firstboot-grow
# Partition edits must not be interrupted part-way.
TimeoutStartSec=600
# Deliberately no Restart=: the script keeps its own attempt marker and refuses
# to retry partition edits unattended. A failure should be looked at, not looped.

[Install]
WantedBy=multi-user.target
192 changes: 192 additions & 0 deletions build/files/usr/libexec/server4home/firstboot-grow
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
#!/usr/bin/env bash
# Grow root into the target drive on first boot, and hand the remainder to an
# LVM PV for app data.
#
# Why this exists
# ---------------
# The disk image ships a deliberately small root (20 GiB) so the raw stays quick
# to write — a 250 GiB root would make `dd` push a quarter-terabyte of zeros on
# every install. The real sizing therefore has to happen on the target hardware,
# which is also the only place that knows how big the drive actually is. Doing it
# here rather than by hand means a freshly dd'd server reaches its final layout
# unattended, and the same image works on any boot drive size.
#
# What it does
# ------------
# 1. sgdisk -e — relocate the backup GPT header to the true end of the disk.
# A small image written to a big drive always leaves the GPT
# describing the *image's* size, so until this runs there is
# literally no free space to grow into.
# 2. create the app-data partition at the point where root should stop, which
# bounds root's growth to the target size. growpart then fills exactly the
# gap — root is never rewritten by hand.
# 3. growpart + xfs_growfs to expand root online.
# 4. pvcreate/vgcreate the app-data partition (empty VG; LVs are created later
# per app — see docs/storage-build-resume.md).
#
# Sizing
# ------
# Target root size is read from /etc/server4home/root-size, e.g. "250G", "1T".
# The literal value "max" grows root over the whole disk and creates no VG.
# If the drive is too small for the target, or the leftover would be under
# MIN_VG, root simply takes the whole disk and no VG is created.
#
# Why no LVM for root itself: FCOS/uCore cannot boot a root/boot/var on LVM —
# the initramfs has no LVM support at all. See iso/disk.toml for the three
# documented failure modes. LVM is only safe for storage mounted late, which is
# exactly what the app-data VG is.

set -euo pipefail

CONF="/etc/server4home/root-size"
DEFAULT_TARGET="250G"
VG_NAME="vg4data"
# Not worth carving a VG out of scraps; below this root just takes everything.
MIN_VG_BYTES=$((50 * 1024 * 1024 * 1024))
STAMP="/var/lib/server4home/firstboot-grow.done"
ATTEMPT="/var/lib/server4home/firstboot-grow.attempted"

log() { echo "firstboot-grow: $*"; }
die() { log "ERROR: $*"; exit 1; }

if [ -e "${STAMP}" ]; then
log "already completed on an earlier boot; nothing to do"
exit 0
fi

# Loop guard. If a previous boot got part-way and failed, do not keep retrying
# partition edits unattended on a server — surface it and stop.
if [ -e "${ATTEMPT}" ]; then
die "a previous attempt did not complete. Refusing to retry partition edits
automatically. Inspect the disk, then remove ${ATTEMPT} to allow a retry."
fi

# ---------------------------------------------------------------------------
# Locate root by filesystem LABEL, never by a hardcoded /dev/nvme0n1 — kernel
# NVMe enumeration is not stable across reboots.
# ---------------------------------------------------------------------------
ROOT="$(lsblk -pnro NAME,LABEL | awk '$2=="root"{print $1; exit}')"
[ -n "${ROOT}" ] || die "no partition with filesystem label 'root' found"
[ -b "${ROOT}" ] || die "${ROOT} is not a block device"

DISK="$(lsblk -pnro PKNAME "${ROOT}" | head -1)"
[ -n "${DISK}" ] && [ -b "${DISK}" ] || die "could not derive parent disk of ${ROOT}"

NUM="${ROOT##*[!0-9]}"
[ -n "${NUM}" ] || die "could not derive partition number from ${ROOT}"

# Only XFS can be grown by this script, and only forward.
FSTYPE="$(lsblk -pnro FSTYPE "${ROOT}")"
[ "${FSTYPE}" = "xfs" ] || die "root filesystem is '${FSTYPE}', expected xfs"

# The XFS is mounted at /sysroot on an ostree system; / is a composefs overlay
# and is NOT growable, so resolve the real mountpoint rather than assuming "/".
MP=""
for cand in /sysroot /; do
if [ "$(findmnt -nro SOURCE --target "${cand}" 2>/dev/null)" = "${ROOT}" ]; then
MP="${cand}"
break
fi
done
[ -n "${MP}" ] || die "could not find the mountpoint backed by ${ROOT}"

log "root=${ROOT} disk=${DISK} partnum=${NUM} mountpoint=${MP}"

# ---------------------------------------------------------------------------
# Step 1 — make the whole drive visible to the GPT.
# ---------------------------------------------------------------------------
mkdir -p "$(dirname "${ATTEMPT}")"
: >"${ATTEMPT}"

log "relocating backup GPT header to the end of ${DISK}"
sgdisk -e "${DISK}"
partprobe "${DISK}" 2>/dev/null || true

SS="$(blockdev --getss "${DISK}")"
ROOT_START="$(sgdisk -i "${NUM}" "${DISK}" | awk '/^First sector/{print $3}')"
LAST_USABLE="$(sgdisk -p "${DISK}" | awk '/last usable sector/{print $NF}')"
[ -n "${SS}" ] && [ -n "${ROOT_START}" ] && [ -n "${LAST_USABLE}" ] ||
die "could not read disk geometry (ss=${SS} start=${ROOT_START} last=${LAST_USABLE})"

log "sector size=${SS} root start=${ROOT_START} last usable=${LAST_USABLE}"

# ---------------------------------------------------------------------------
# Step 2 — decide where root should stop, and bound it with the VG partition.
# ---------------------------------------------------------------------------
TARGET="${DEFAULT_TARGET}"
if [ -r "${CONF}" ]; then
# First non-comment, non-blank line wins, so the shipped file can document itself.
CONF_VAL="$(sed -e 's/#.*//' -e 's/[[:space:]]//g' "${CONF}" | grep -m1 . || true)"
[ -n "${CONF_VAL}" ] && TARGET="${CONF_VAL}"
fi
log "target root size: ${TARGET} (from ${CONF} if present, else default)"

make_vg=0
if [ "${TARGET}" = "max" ]; then
log "target is 'max' — root takes the whole disk, no ${VG_NAME}"
else
TARGET_BYTES="$(numfmt --from=iec "${TARGET}" 2>/dev/null)" ||
die "could not parse target size '${TARGET}' (expected e.g. 250G, 1T, or max)"
TARGET_SECTORS=$((TARGET_BYTES / SS))
# Align the next partition start to 2048 sectors (1 MiB) — root ends just before it.
NEXT_START=$(((ROOT_START + TARGET_SECTORS + 2047) / 2048 * 2048))

if [ "${NEXT_START}" -ge "${LAST_USABLE}" ]; then
log "drive is too small for a ${TARGET} root plus a VG — root takes the whole disk"
else
REMAIN_BYTES=$(((LAST_USABLE - NEXT_START + 1) * SS))
if [ "${REMAIN_BYTES}" -lt "${MIN_VG_BYTES}" ]; then
log "only $((REMAIN_BYTES / 1024 / 1024 / 1024))G would remain, under the"
log "$((MIN_VG_BYTES / 1024 / 1024 / 1024))G minimum — root takes the whole disk"
elif [ -n "$(sgdisk -p "${DISK}" | awk -v n="${NUM}" '$1 ~ /^[0-9]+$/ && $1+0 > n+0 {print $1; exit}')" ]; then
log "a partition already exists after root — leaving it alone, not creating ${VG_NAME}"
else
log "creating ${VG_NAME} partition at sector ${NEXT_START} (bounds root to ~${TARGET})"
sgdisk -n "0:${NEXT_START}:0" -t "0:8e00" -c "0:${VG_NAME}" "${DISK}"
partprobe "${DISK}" 2>/dev/null || true
make_vg=1
fi
fi
fi

# ---------------------------------------------------------------------------
# Step 3 — grow root into whatever space is now available before the next partition.
# ---------------------------------------------------------------------------
log "growing partition ${NUM} on ${DISK}"
rc=0
growpart "${DISK}" "${NUM}" || rc=$?
# growpart: 0 = resized, 2 = nothing to do. Anything else is a real failure.
if [ "${rc}" -ne 0 ] && [ "${rc}" -ne 2 ]; then
die "growpart failed with exit code ${rc}"
fi
[ "${rc}" -eq 2 ] && log "partition already at its maximum; nothing to grow"
partprobe "${DISK}" 2>/dev/null || true

log "growing the xfs filesystem at ${MP}"
xfs_growfs "${MP}"

# ---------------------------------------------------------------------------
# Step 4 — initialise the app-data VG (empty; LVs come later, per app).
# ---------------------------------------------------------------------------
if [ "${make_vg}" -eq 1 ]; then
VGPART="$(lsblk -pnro NAME,PARTLABEL "${DISK}" | awk -v l="${VG_NAME}" '$2==l{print $1; exit}')"
if [ -n "${VGPART}" ] && [ -b "${VGPART}" ]; then
if pvs "${VGPART}" >/dev/null 2>&1; then
log "${VGPART} is already a PV; leaving it alone"
else
log "initialising ${VGPART} as PV and creating VG ${VG_NAME}"
pvcreate "${VGPART}"
vgcreate "${VG_NAME}" "${VGPART}"
fi
else
log "WARNING: could not locate the ${VG_NAME} partition after partprobe;"
log "WARNING: create the PV/VG by hand (see docs/storage-build-resume.md)"
fi
fi

log "final layout:"
lsblk -o NAME,SIZE,FSTYPE,PARTLABEL,MOUNTPOINT "${DISK}" || true

: >"${STAMP}"
rm -f "${ATTEMPT}"
log "complete"
Loading
Loading