Files
spinifex/scripts/mkosi-build.sh
T
Josh Siviour 272b4b22e8 feat(ochre): add the vLLM serving AMI and give the daemon a route to it (#740)
* feat(images): add a vllm-serving AMI as a stacked mkosi profile

LaunchServingVM resolves its AMI by the tags spinifex:managed-by=bedrock and
spinifex:bedrock-role=vllm-serving. No image carried either, so every Ensure
failed at resolveServingAMI. The only GPU image ships the NVIDIA driver and
Docker but no Python, no vLLM and no serving unit, so tagging it would have
moved the failure from admission to first boot.

Add a vllm profile that stacks onto gpu-nvidia and owns the whole guest side
of the launcher contract: the runtime, vllm-serve.service, the weights mount
and the tags. vLLM is baked into a uv-managed venv rather than pulled as a
container at boot — a runtime docker pull of vllm/vllm-openai is known to
stall this platform's write plane, and mkosi's unprivileged build has no
daemon to bake an image from anyway.

Also bring images/mkosi.profiles/ under make test-images. Its scripts have no
.sh suffix, so the existing name glob skipped exactly the files worth linting;
sources are found by shebang instead.

* fix(images): make the vllm postinst survive the build sandbox

Two failures found by actually running the build.

uv installs to /usr/local/bin, which mkosi's postinst PATH need not carry, so
`command -v uv` reported a good install as missing. Call it by absolute path,
and check it the same way.

`vllm --version` initialises device config and dies with "Failed to infer
device type" on a builder with no GPU, so it could never have verified
anything. Assert the pinned version through package metadata instead, which
also catches a version mismatch rather than merely a broken entrypoint.

* fix(images): pin the serving AMI datasource and mirror cloud-init to the console

A serving VM boots through LaunchSystemInstance, so it comes up dual-NIC with a
fw_cfg netcfg blob and takes its user-data solely from IMDS — the same shape as
the EKS control plane. That path carries a cloud-init datasource pin; the vllm
profile never got one, and the base config deliberately leaves datasource_list
unpinned, so the serving image was the only system VM booting without it.

Reuse the EKS pin rather than copying it. The 99- prefix is load-bearing:
read_conf_d() applies cloud.cfg.d in REVERSE and mergemanydict() is
first-source-wins, so a 90- name loses to cloud-init's own 90_dpkg.cfg ('_'
sorts above '-') and the pin is discarded in silence. The postinst asserts the
MERGED datasource_list via cloud-init's own read_conf_with_confd rather than
checking the file exists, because it existed at 90- too and did nothing there.

Also mirror every cloud-init stage to the serial console. A serving VM is
headless, and a guest that wedges before handover never surrenders
/var/log/cloud-init-output.log, so the console get-console-output reads is the
only channel an operator has.

The images_test.go case covers the two tags resolveServingAMI filters on; it
matches on those alone and never on name, so a typo hides the image entirely.

* fix(images): make the dhcpcd data-NIC lease genuinely one-shot

dhcp_oneshot() promised to configure the link and exit "leaving no daemon
to fight cloud-init", but the dhcpcd branch passed only -q, which is quiet
rather than one-shot. Every Ubuntu image takes that branch, so a dhcpcd
daemon survived on the data NIC holding the control socket.

cloud-init's Ec2 datasource then runs its own ephemeral dhcpcd on the same
NIC. Instead of spawning, dhcpcd forwards the invocation to the resident
daemon as a control command and exits 0, and --dumplease returns the
resident daemon's lease, so discovery looks successful. Cleanup then waits
for a pid file at /run/dhcpcd/<iface>-4.pid that no process ever wrote,
spinning its full 30000 x 10ms budget: init-local stalls for exactly 300s
on every system-instance boot.

Adding -1 exits once the interface is configured and -p keeps the address
and routes across that exit. Both are needed; -1 alone tears the link back
down. The test now models the real clients — the stub stays resident unless
given the flag that makes its client exit, and the dhcpcd branch is forced
by dropping udhcpc from PATH rather than left to whether the host happens
to have /usr/share/udhcpc/default.script.

* fix(images): serve without a CUDA toolkit and stop vllm-serve crash-looping

vLLM picks FlashInfer for top-k/top-p sampling, and FlashInfer JIT-compiles
that kernel on first use. The serving image ships the driver and the
container toolkit but no nvcc, so the sampler warmup died with "Could not
find nvcc and default cuda_home='/usr/local/cuda' doesn't exist" minutes
into a GPU cold start, after the model had already loaded.

VLLM_USE_FLASHINFER_SAMPLER=0 selects vLLM's native top-k/top-p sampler,
which is the same operation with no CUDA toolchain at runtime. Everything
else on the serving path is already precompiled in the wheels: the attention
backend resolves to FLASH_ATTN and CUDA graph capture completes, both
without nvcc. Baking FlashInfer's AOT modules is the alternative if the
fused kernel is ever wanted, and it keeps the toolchain in the builder
rather than the appliance.

The unit had Restart=on-failure with no bound, so an unfixable start failure
restarted forever and buried its own first traceback under identical copies
on the serial console — the only view an operator has of a headless guest.
StartLimitIntervalSec/StartLimitBurst make it report instead.

The postinst asserts the installed unit carries the sampler setting, for the
same reason the datasource pin is asserted there: the alternative is finding
out at runtime, on a GPU, five minutes in.

Also corrects the shared datasource pin's header, which still claimed it
installs as 90-spinifex-ec2-imds.cfg. It is installed at 99- by the vllm
profile precisely because a 90- name loses the reverse-sorted, first-source-
wins cloud.cfg.d merge to cloud-init's own 90_dpkg.cfg.

* fix(bedrock): raise the serving-endpoint readiness bound to 15 minutes

The 5 minute bound was chosen before any cold start had been measured, and
its own comment asked to be corrected once a model actually ran on wattle.
It now has: Llama 3.2 1B on an RTX A1000 reached "Application startup
complete" 257.6s after launch, leaving ~42s of margin on the smallest model
this platform can serve, with a cold torch.compile accounting for 26.3s of
it. A bigger card and a bigger model are unmeasured.

The two failure modes are not symmetric. Too low tears down an endpoint that
was about to come up and reports it as a failed launch; too high only makes
a genuinely stuck launch take longer to give up. So the bound is set well
clear of the single data point rather than close to it.

The CLI's own wait moves to 20 minutes to stay above it. The daemon starts
its clock when the VM is launched, up to a minute after the CLI starts its
own, so leaving both at 15 would expire the client first and report a bare
"timed out" in place of the daemon's abort, which carries the reason.

* fix: give the Ochre daemon a routed port in the Bedrock system VPC

The readiness probe and every InvokeModel call dial a serving VM's
system-VPC address from inside the daemon, but that subnet lives in OVN
on br-int and the host has no route to it, so a healthy VM never reached
READY.

Each daemon now owns an ENI in the system VPC's private subnet, bound to
an OVS internal port on br-int by iface-id, exactly as ovn-controller
binds a guest tap. The ENI address is installed at the subnet's prefix
length, which is what supplies the connected route. BaseURL is unchanged:
the address it already carried is now reachable.

Describe-or-create keyed on a per-node description, since ENIs carry no
tag filter, so a restart or a second endpoint reuses one address instead
of leaking one per launch. The port is ensured before anything else the
launch creates: a VM the daemon cannot dial holds a GPU for the whole
readiness window before unwinding.
2026-08-06 15:26:05 +10:00

429 lines
19 KiB
Bash
Executable File

#!/usr/bin/env bash
# mkosi-build.sh — run an mkosi system-image build inside the pinned builder
# container.
#
# The runner needs only Docker: the toolchain is baked into the image (see
# scripts/mkosi-builder.Dockerfile), so nothing is installed on the host and
# the build cannot drift between runners. The same command runs identically on
# a developer's box.
#
# Usage:
# scripts/mkosi-build.sh --image <name> [--shell] [-- <mkosi options>]
# scripts/mkosi-build.sh --profile <name> [--profile <name>...] [--shell] [-- <mkosi options>]
#
# scripts/mkosi-build.sh --image spinifex-eks-node-gpu
# MKOSI_VERB=clean scripts/mkosi-build.sh
#
# --image is the interface to prefer: it names an output and expands to the
# right ordered profile list. --profile is the escape hatch for ad-hoc builds
# and puts the ordering rule below on the caller.
#
# --image also names the artefact: the build writes <image>.raw, so what a
# composition produced is legible from the output rather than inferred from
# which build ran last. Profile-mode builds keep the base config's ImageId.
#
# Builds are always forced; see the --force note further down for why mkosi's
# own skip logic is not a cache worth having here.
#
# Args after `--` are mkosi OPTIONS only; the verb comes from MKOSI_VERB.
#
# Env:
# MKOSI_IMAGE_DIR directory holding mkosi.conf (default: images/)
# MKOSI_OUTPUT_DIR where artefacts land (default: <image dir>/output)
# MKOSI_VERB mkosi verb to run (default: build)
# BUILDER_TAG builder image tag (default: spinifex-mkosi-builder)
set -euo pipefail
# shellcheck source=scripts/mkosi-common.sh
source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/mkosi-common.sh"
IMAGE_DIR="${MKOSI_IMAGE_DIR:-${MKOSI_REPO_ROOT}/images}"
OUTPUT_DIR="${MKOSI_OUTPUT_DIR:-${IMAGE_DIR}/output}"
# Named image compositions. Profiles are composition units, not outputs, so an
# image is an ordered list of them.
#
# The ORDER IS LOAD-BEARING and is why this table exists rather than callers
# passing profiles by hand: mkosi runs each profile's postinst in the order the
# profiles are given (verified — it is argument order, not alphabetical), so a
# profile whose postinst uses a tool another profile installs must come after
# it. docker's `nvidia-ctk runtime configure` needs gpu-nvidia's toolkit, and
# getting that backwards is the kind of mistake that ships a working-looking
# image whose GPU wiring is simply absent.
image_profiles() {
case "$1" in
ubuntu-gpu-nvidia) echo "gpu-nvidia docker" ;;
ubuntu-gpu-amd) echo "gpu-amd docker" ;;
# docker is deliberately absent: the serving VM runs vLLM as a systemd
# service and never touches a Docker daemon. vllm comes second because
# it stacks onto the GPU driver gpu-nvidia installs.
ubuntu-vllm-serving) echo "gpu-nvidia vllm" ;;
spinifex-eks-node-gpu) echo "gpu-nvidia eks-common eks-agent" ;;
spinifex-ecs-node-gpu) echo "gpu-nvidia ecs" ;;
# The CPU variant is the GPU one minus gpu-nvidia. ecs pulls nothing
# from that profile: it adds no repositories, needs no network at build
# time, and every package it installs is in main/universe, which the
# base already enables.
spinifex-ecs-node) echo "ecs" ;;
# One image for both EKS roles: eks-server and eks-agent are both
# present and the first-boot selector starts whichever the launch asked
# for. eks-common comes first because it installs the k3s binary the
# other two build on.
spinifex-eks-node) echo "eks-common eks-server eks-agent" ;;
*) return 1 ;;
esac
}
# Go binaries a profile ships, as "<go package>:<path in image>".
#
# These are built on the host rather than in the builder container, which
# carries no Go on purpose: go.mod is the single pin for the toolchain version
# (CI honours it via setup-go's go-version-file), and a second pin in the
# Dockerfile could disagree with it silently. GOWORK=off because the sub-repo
# builds standalone and the workspace lives in the parent monorepo, which is
# not present in every checkout.
profile_binaries() {
case "$1" in
eks-agent)
echo "./cmd/ecr-credential-provider:usr/local/bin/ecr-credential-provider"
;;
eks-server)
# konnectivity-server is NOT here — it is not an in-repo ./cmd/...
# package, so it gets its own clone-and-build step in
# stage_profile_binaries() below.
echo "./cmd/eks-token-webhook:usr/local/bin/eks-token-webhook \
./cmd/eks-gateway-publish:usr/local/bin/eks-gateway-publish \
./cmd/eks-gateway-fetch:usr/local/bin/eks-gateway-fetch \
./cmd/eks-webhook-cert:usr/local/bin/eks-webhook-cert \
./cmd/eks-konnectivity-cert:usr/local/bin/eks-konnectivity-cert"
;;
ecs) echo "./cmd/ecs-agent:usr/local/bin/ecs-agent" ;;
*) echo "" ;;
esac
}
# apiserver-network-proxy version powering konnectivity-server. Pinned as
# a named constant, not resolved at build time: its GitHub releases ship only
# container images (no downloadable binary), and its own go.mod carries local
# replace directives, which `go install pkg@version` cannot resolve for a
# module outside its own checkout — a plain clone-and-build is the only route.
readonly KONNECTIVITY_SERVER_VERSION="v0.30.3"
# Clone and build konnectivity-server (apiserver-network-proxy's ./cmd/server)
# into the given staging dir, on the host, alongside stage_profile_binaries()'s
# in-repo binaries below. Kept as its own function because it is not an
# in-repo ./cmd/... package, so profile_binaries() cannot express it.
stage_konnectivity_server() {
local staging="$1" clone_dir
command -v git >/dev/null || {
echo "mkosi-build: git not found — eks-server needs it to build konnectivity-server" >&2
exit 1
}
command -v go >/dev/null || {
echo "mkosi-build: go not found — eks-server needs it to build konnectivity-server" >&2
exit 1
}
clone_dir="$(mktemp -d)"
echo "[mkosi-build] cloning apiserver-network-proxy ${KONNECTIVITY_SERVER_VERSION}"
git clone --quiet --depth 1 --branch "${KONNECTIVITY_SERVER_VERSION}" \
https://github.com/kubernetes-sigs/apiserver-network-proxy "${clone_dir}"
mkdir -p "${staging}/usr/local/bin"
echo "[mkosi-build] building konnectivity-server -> staging/eks-server/usr/local/bin/konnectivity-server"
# Same flags as the in-repo binaries below: a static, FIPS-pinned, stripped
# binary. Built from within its own clone, so no GOWORK=off is needed —
# nothing in a bare /tmp checkout can discover the monorepo's go.work.
( cd "${clone_dir}" && CGO_ENABLED=0 GOFIPS140=v1.0.0 \
go build -ldflags "-s -w" -o "${staging}/usr/local/bin/konnectivity-server" ./cmd/server )
chmod 0755 "${staging}/usr/local/bin/konnectivity-server"
rm -rf "${clone_dir}"
}
# Compile a profile's binaries into images/staging/<profile>/, which the
# profile picks up via ExtraTrees=. Staging is rebuilt from scratch each run:
# a stale binary from a previous build silently shipping is the exact failure
# this whole containerised, pinned path exists to avoid.
stage_profile_binaries() {
local profile="$1" spec pkg dest staging
spec="$(profile_binaries "${profile}")"
# eks-server always has in-repo binaries too (profile_binaries() above), so
# this never leaves konnectivity-server staged alone with spec empty and
# the rm -rf below wiping it out.
[[ -z "${spec}" ]] && return 0
staging="${IMAGE_DIR}/staging/${profile}"
rm -rf "${staging}"
command -v go >/dev/null || {
echo "mkosi-build: go not found — profile ${profile} ships Go binaries" >&2
exit 1
}
for entry in ${spec}; do
pkg="${entry%%:*}"
dest="${entry#*:}"
mkdir -p "${staging}/$(dirname "${dest}")"
echo "[mkosi-build] building ${pkg} -> staging/${profile}/${dest}"
# Flags match the pre-mkosi manifests: a static, FIPS-pinned binary.
( cd "${MKOSI_REPO_ROOT}" && CGO_ENABLED=0 GOFIPS140=v1.0.0 GOWORK=off \
go build -ldflags "-s -w" -o "${staging}/${dest}" "${pkg}" )
chmod 0755 "${staging}/${dest}"
done
if [[ "${profile}" == "eks-server" ]]; then
stage_konnectivity_server "${staging}"
fi
}
# Reject an unescaped $VAR/${VAR} in any Exec*= line of a profile's units.
#
# systemd expands those from the UNIT's environment before the command runs and
# substitutes an empty string for a name it does not know, so a unit that wraps
# a shell to source a runtime-written env file must write $$ to defer expansion
# to that shell. A single $ is not a syntax error anywhere — it silently drops
# the argument's value, and the service dies on flag parsing with no useful log.
# This bites exactly on the OpenRC->systemd port, where the same ${VAR} text was
# correct because the shell was the only thing reading it.
#
# Runs on the host before the container starts, alongside the binary staging
# above, so a bad unit fails the build in seconds instead of at first boot.
lint_unit_expansions() {
local profile="$1" extra
extra="${IMAGE_DIR}/mkosi.profiles/${profile}/mkosi.extra"
[[ -d "${extra}" ]] || return 0
# Line continuations are joined first: the offending reference is typically
# mid-way through a wrapped multi-line ExecStart, not on the Exec*= line.
local bad
bad="$(find "${extra}" -type f \( -name '*.service' -o -name '*.socket' \
-o -name '*.timer' -o -name '*.mount' \) -print0 |
xargs -0 --no-run-if-empty awk '
FNR == 1 { pending = "" }
{ line = pending $0 }
line ~ /\\$/ { pending = substr(line, 1, length(line) - 1); next }
{ pending = "" }
line !~ /^[[:space:]]*Exec(Start|Stop|Reload|Condition)/ { next }
# Strip $$ first so an escaped reference cannot match as a bare one.
{ probe = line; gsub(/\$\$/, "", probe) }
probe ~ /\$\{?[A-Za-z_]/ { print FILENAME ": " line }
')"
if [[ -n "${bad}" ]]; then
echo "mkosi-build: unescaped systemd variable reference in Exec* line(s):" >&2
echo "${bad}" >&2
echo "mkosi-build: systemd expands these to empty; write \$\$ to defer to the shell" >&2
exit 1
fi
}
IMAGE=""
PROFILES=()
WANT_SHELL=0
MKOSI_ARGS=()
while [[ $# -gt 0 ]]; do
case "$1" in
--image) IMAGE="$2"; shift 2 ;;
--profile) PROFILES+=("$2"); shift 2 ;;
--shell) WANT_SHELL=1; shift ;;
--) shift; MKOSI_ARGS=("$@"); break ;;
*) echo "mkosi-build: unknown arg: $1" >&2; exit 2 ;;
esac
done
if [[ -n "${IMAGE}" ]]; then
if [[ "${#PROFILES[@]}" -gt 0 ]]; then
echo "mkosi-build: pass --image or --profile, not both" >&2
exit 2
fi
if ! expanded="$(image_profiles "${IMAGE}")"; then
echo "mkosi-build: unknown image: ${IMAGE}" >&2
echo "mkosi-build: known images: ubuntu-gpu-nvidia ubuntu-gpu-amd ubuntu-vllm-serving spinifex-eks-node-gpu spinifex-ecs-node-gpu spinifex-ecs-node spinifex-eks-node" >&2
exit 2
fi
read -r -a PROFILES <<< "${expanded}"
echo "[mkosi-build] image ${IMAGE} = ${PROFILES[*]}"
fi
require_docker
if [[ ! -d "${IMAGE_DIR}" ]]; then
echo "mkosi-build: no image dir at ${IMAGE_DIR} (set MKOSI_IMAGE_DIR)" >&2
exit 1
fi
build_builder_image
# Before the container starts: the repo is mounted read-only, so anything a
# profile needs built has to exist by now.
for p in "${PROFILES[@]+"${PROFILES[@]}"}"; do
lint_unit_expansions "${p}"
stage_profile_binaries "${p}"
done
mkdir -p "${OUTPUT_DIR}"
# mkosi builds in a user namespace, and two of Docker's default confinements
# block that. Neither is a privilege grant: capabilities stay dropped and no
# devices are exposed, unlike --privileged.
#
# seccomp=unconfined the default profile masks CLONE_NEWUSER out of clone,
# so the namespace cannot be created at all.
# apparmor=unconfined the docker-default profile separately blocks unsharing
# the MOUNT namespace. This one is easy to miss: with
# seccomp alone `unshare -U` succeeds and only
# `unshare -U -m` fails.
DOCKER_ARGS=(
--rm
--security-opt seccomp=unconfined
--security-opt apparmor=unconfined
--volume "${OUTPUT_DIR}:/work/output"
)
# The whole repo is mounted, not just images/, because profiles pull build
# inputs from outside the image tree via ExtraTrees= — the eks helpers live in
# scripts/images/eks-node/ and are shared with the Alpine build that still
# consumes them. Referencing them where they are keeps one copy: duplicating
# them under mkosi.extra/ would reintroduce exactly the drift Stage A deleted a
# hand-copied helper to remove.
#
# Read-only: every build input is either checked in or staged before the
# container starts, and the only thing that legitimately gets written is the
# output directory, mounted separately above. A build that tries to mutate the
# repo is a bug, so let it fail here rather than silently succeed.
#
# An image dir outside the repo (ad-hoc/testing) keeps the old standalone
# mount and simply has no repo to reference.
if [[ "${IMAGE_DIR}" == "${MKOSI_REPO_ROOT}"/* ]]; then
DOCKER_ARGS+=(--volume "${MKOSI_REPO_ROOT}:/work/repo:ro")
CONTAINER_IMAGE_DIR="/work/repo/${IMAGE_DIR#"${MKOSI_REPO_ROOT}"/}"
else
DOCKER_ARGS+=(--volume "${IMAGE_DIR}:/work/images:ro")
CONTAINER_IMAGE_DIR="/work/images"
fi
DOCKER_ARGS+=(--workdir "${CONTAINER_IMAGE_DIR}")
[[ -t 0 ]] && DOCKER_ARGS+=(--interactive --tty)
# Persist the package cache across runs so a rebuild does not re-download the
# whole target distribution every time. Only the downloaded packages live here:
# see the workspace note below for why the build area deliberately does not.
CACHE_VOL="${BUILDER_TAG}-cache"
docker volume create "${CACHE_VOL}" >/dev/null
DOCKER_ARGS+=(--volume "${CACHE_VOL}:/home/builder/.cache")
# The workspace must sit on the same mount as the output directory. mkosi
# assembles the image in the workspace and moves the result to the output, and
# a rename across mounts fails EXDEV — Docker's named volume and the output
# bind mount are separate mounts even on one filesystem. mkosi then falls back
# to copying, which needs the finished image to exist twice at once. That is
# invisible on a small image and fatal on a real one: a ~4G GPU image needs ~8G
# to land. Keeping both on one mount makes the move a rename again.
WORKSPACE_DIR="${OUTPUT_DIR}/.mkosi-workspace"
mkdir -p "${WORKSPACE_DIR}"
if [[ "${WANT_SHELL}" -eq 1 ]]; then
exec docker run "${DOCKER_ARGS[@]}" "${BUILDER_TAG}" bash
fi
# mkosi takes its options BEFORE the verb and silently discards any that follow
# it — `mkosi build --force` skips the rebuild, prints "Use --force to rebuild"
# and still exits 0. So the verb is always appended last, and a verb passed in
# via `--` is rejected rather than positioned wrong: passing one would push the
# real options after it and no-op them exactly the same silent way.
VERB="${MKOSI_VERB:-build}"
for arg in "${MKOSI_ARGS[@]+"${MKOSI_ARGS[@]}"}"; do
case "${arg}" in
build|clean|shell|boot|vm|qemu|sandbox|serve|burn|dependencies)
echo "mkosi-build: pass the verb as MKOSI_VERB=${arg}, not after --" >&2
echo "mkosi-build: (mkosi ignores options that follow a verb, silently)" >&2
exit 2
;;
esac
done
CMD=(mkosi --output-dir /work/output --workspace-dir /work/output/.mkosi-workspace)
for p in "${PROFILES[@]+"${PROFILES[@]}"}"; do
CMD+=(--profile "${p}")
done
# What the caller already asked for, so neither default below overrides an
# explicit choice.
CALLER_SET_IMAGE_ID=0
CALLER_SET_FORCE=0
for arg in "${MKOSI_ARGS[@]+"${MKOSI_ARGS[@]}"}"; do
case "${arg}" in
--image-id|--image-id=*) CALLER_SET_IMAGE_ID=1 ;;
--force|-f) CALLER_SET_FORCE=1 ;;
esac
done
# Name the output after the image, not after the base config's ImageId. Every
# composition otherwise lands on the same spinifex.raw, so the artefact carries
# no record of which profiles produced it and the only thing distinguishing an
# EKS image from an ECS one is which build ran last. Publishing then picks a
# name by hand, which is how the wrong image gets shipped under the right one.
if [[ -n "${IMAGE}" && "${CALLER_SET_IMAGE_ID}" -eq 0 ]]; then
CMD+=(--image-id "${IMAGE}")
fi
# Always rebuild. mkosi skips the build whenever the output path merely exists
# — it does not compare inputs — so its "cache" never notices an edited
# mkosi.conf or a restaged binary, and it exits 0 while doing nothing. That
# makes a stale image indistinguishable from a fresh one, and the next step
# imports and boot-tests last week's build. The cache worth keeping is the
# package download volume, which --force does not touch.
if [[ "${CALLER_SET_FORCE}" -eq 0 ]]; then
CMD+=(--force)
fi
CMD+=("${MKOSI_ARGS[@]+"${MKOSI_ARGS[@]}"}" "${VERB}")
BUILD_STARTED_AT="$(date +%s)"
echo "[mkosi-build] ${CMD[*]}"
docker run "${DOCKER_ARGS[@]}" "${BUILDER_TAG}" "${CMD[@]}"
# Belt and braces on the above: prove this run actually wrote the image rather
# than trusting the exit status, which mkosi reports as 0 for a build it
# declined to do. Only checked when the output name is ours to predict.
if [[ "${VERB}" == "build" && "${CALLER_SET_IMAGE_ID}" -eq 0 ]]; then
EXPECTED_RAW="${OUTPUT_DIR}/${IMAGE:-spinifex}.raw"
if [[ ! -f "${EXPECTED_RAW}" ]]; then
echo "mkosi-build: build reported success but ${EXPECTED_RAW} does not exist" >&2
exit 1
fi
if [[ "$(stat -c %Y "${EXPECTED_RAW}")" -lt "${BUILD_STARTED_AT}" ]]; then
echo "mkosi-build: ${EXPECTED_RAW} predates this run — the build was skipped, not performed" >&2
exit 1
fi
echo "[mkosi-build] built ${EXPECTED_RAW}"
# mkosi only emits the raw. A qcow2 next to it came from a previous run's
# hand conversion, and nothing about its name says which build it belongs
# to — so it survives a rebuild and silently ships the old image to a node,
# while the raw beside it carries the new one. Same failure the freshness
# check above exists to prevent, one file over.
#
# Refresh rather than delete: the qcow2 is what gets copied to a node and
# imported, so removing it just pushes the manual conversion (and this
# trap) back onto the caller. Conversion is a couple of seconds.
EXPECTED_QCOW2="${OUTPUT_DIR}/${IMAGE:-spinifex}.qcow2"
if [[ -f "${EXPECTED_QCOW2}" && "$(stat -c %Y "${EXPECTED_QCOW2}")" -lt "${BUILD_STARTED_AT}" ]]; then
if command -v qemu-img >/dev/null 2>&1; then
echo "[mkosi-build] refreshing stale ${EXPECTED_QCOW2}"
# Convert to a temp name and rename, so an interrupted run leaves
# the old qcow2 rather than a half-written one that looks current.
qemu-img convert -f raw -O qcow2 "${EXPECTED_RAW}" "${EXPECTED_QCOW2}.tmp"
mv "${EXPECTED_QCOW2}.tmp" "${EXPECTED_QCOW2}"
else
echo "[mkosi-build] removing stale ${EXPECTED_QCOW2} (no qemu-img to refresh it)" >&2
rm -f "${EXPECTED_QCOW2}"
fi
fi
fi
echo "[mkosi-build] artefacts in ${OUTPUT_DIR}:"
ls -la "${OUTPUT_DIR}"