Files
spinifex/scripts/build-system-image.sh
T
Josh Siviour 496424c41c test(eks): EKS control-plane E2E suite (Sprint 6g) (#304)
* test(eks): EKS control-plane e2e suite (Phase 1, API-level)

Add tests/e2e/eks/eks_test.go driving the EKS lifecycle against awsgw:
CreateCluster -> ACTIVE (endpoint + CA asserted), kubeconfig artifact
assembly, AccessEntry CRUD, get-token presign decode, DeleteCluster -> gone.

Harness: EKS SDK client on AWSClient + WaitForEKSCluster{Active,Deleted}
pollers. Wire e2e-eks into the Makefile and the single-node CI suite list.

* test(eks): Phase 2 kubectl get-nodes data-plane gate

Add the reachability shim (harness/eks_mgmt.go) that reads the control-plane
VM's br-mgmt IP from the cluster KV meta over NATS, plus a kubectl wrapper
(harness/kubectl.go) that skips when kubectl is absent. KubectlGetNodes points
kubectl at https://<mgmtIP>:6443 (insecure-skip-tls-verify; cert SANs the NLB
DNS, not the mgmt IP) with an aws eks get-token exec block and polls until the
control-plane node is Ready — the get-token webhook auth regression gate.

IRSA pod + the real NLB/Route53 passthrough are deferred to a follow-up.

* test(eks): tolerate doubled NotFound code + dial loopback NATS

DeleteCluster detection matched the SDK constant ResourceNotFoundException,
but awsgw emits ResourceNotFoundExceptionException on the wire — match the
NotFound code by substring so deletion is detected immediately instead of
timing out. The mgmt-IP shim dialed NATS.Host verbatim (0.0.0.0:4222), which
the serving cert does not SAN; rewrite the wildcard bind to loopback.

Validated live: all 6 subtests green, kubectl get nodes shows the
control-plane node Ready via the get-token webhook auth path.

* feat: build system images with libguestfs (no host block devices)

Replace the qemu-nbd + sudo mount + chroot + /dev-bind flow in
build-system-image.sh with libguestfs (virt-customize / virt-resize /
guestfish). Every operation runs in an isolated userspace appliance VM with
its own kernel, so the build touches no host block device, no host mount, and
needs no sudo — removing the failure class that wiped the host's /dev on shared
CI runners (a leaked /dev bind traversed by rm -rf).

Applies to all distros: alpine resizes via guestfish e2fsck+resize2fs, ubuntu
via virt-resize; customization (packages, binaries, files, setup script,
service enable, cache cleanup) is one ordered virt-customize invocation.
LIBGUESTFS_BACKEND=direct (no libvirt on runners).

Wire the eks-node AMI into the single-node e2e job: the runner builds the
device-free raw, ships it to the VM, and the VM imports it (predastore/NATS are
VM-only) so the eks suite's CreateCluster has an AMI to boot.

* feat(eks): bootstrap + state-report via AWS gateway broker

Converge the EKS control-plane → host channel onto the ELBv2 lb-agent model:
the K3s server VM publishes its bootstrap envelopes and periodic state reports
via SigV4-signed HTTPS POST to the AWS gateway, instead of dialing core NATS
directly. A new gateway action (PublishInternal, POST
/clusters/{name}/internal-publish) relays the payload verbatim onto the
existing eks.bus.* / eks.state.* subjects, so the daemon-side subscriber, KV
persistence/encryption, and reconciler are unchanged.

This removes the VM's need to reach NATS at all, so NATS can stay
cluster-internal behind its bind/firewall/mTLS controls — the prior direct
path broke when NATS was not VM-reachable (e2e CreateCluster timeout) and was
DDIL-fragile (fire-and-forget nats pub, no ack/retry).

- gateway/eks: PublishInternal broker + route; closed-set channel/kind validation
- cmd/eks-gateway-publish: one-shot SigV4 publisher (retry-until-ack) baked into
  the eks-node AMI; replaces the nats-cli publisher in k3s-first-boot.sh and
  mulga-eks-state-report.sh
- handlers/eks + daemon: hand the K3s server VM the mgmt-reachable gateway URL +
  system (Predastore) SigV4 creds + gateway CA instead of NATS URL/token/CA;
  new resolveSystemGatewayBaseURL (mgmt-bridge-first)
- eks-node image: build/install eks-gateway-publish, drop nats-cli download

* feat: migrate eks-token-webhook to the AWS gateway broker

The TokenReview webhook dialed core NATS directly (STS verify + AccessEntry
KV read). On a mgmt-only control-plane VM with NATS bound off-subnet that
fails, so the webhook exited at startup, the apiserver token-webhook
kubeconfig was never written, and the apiserver crash-looped — the cluster
never reached ACTIVE.

Converge it onto the same gateway-broker model as bootstrap/state-report and
ELBv2's lb-agent: the webhook SigV4-signs an HTTPS POST {accountId,token} to
/clusters/{name}/token-review, and the gateway runs the verify + lookup
host-side and returns the resolved identity. The VM now speaks no core NATS.

- internal/eksgw: shared on-VM SigV4 client (also adopted by
  eks-gateway-publish).
- handlers/eks/token_review.go: Authenticate decision core (moved from the
  webhook binary) + ResolveTokenReview host-side wiring.
- gateway/eks token-review route + WebhookTokenReview handler.
- webhook: drop NATS, relay via eksgw.Client; broker fault -> 503 (retryable)
  not a silent deny.
- initd: export EKS_GATEWAY_* / EKS_ACCESS_* creds, not SPINIFEX_NATS_*.

* fix: build eks-node helpers with GOFIPS140; cover token-review broker

eks-gateway-publish blank-imports internal/fipsboot, whose init() panics
unless the binary is built with GOFIPS140=v1.0.0. The eks-node AMI build
omitted the flag, so the binary paniced at first boot right after k3s came
up — k3s-first-boot (set -eu) died before publishing the bootstrap token and
the cluster never reached ACTIVE. Build both eks-node helpers with
GOFIPS140=v1.0.0, matching cmd/lb-agent.

Add NATS-backed unit tests for ResolveTokenReview and the WebhookTokenReview
gateway handler (verify responder + account KV) to cover the new broker path.

* fix: export first-boot env so eks-gateway-publish child sees it

k3s-first-boot.sh and mulga-eks-state-report.sh sourced /etc/spinifex-eks/
first-boot.env but never exported the vars, so the eks-gateway-publish child
process inherited an empty environment and exited '--account-id is required'.
The control-plane VM came up healthy but never published its bootstrap
envelopes, so the cluster never reached ACTIVE. Wrap the source in set -a /
set +a so the KEY=value lines export to child processes.

* fix: make awsgw start --host override actually bind

The 'awsgw start --host' override printed 'Overwriting awsgw host' but the
assignment was commented out, so AWSGW always bound the spinifex.toml host
(a specific WAN IP, e.g. 192.168.0.10). A system instance (EKS K3s control
plane, internal LB agent) has a mgmt-only NIC on-link to the br-mgmt IP with
no route to the WAN IP, so its SigV4 POSTs to the gateway were refused and the
cluster never bootstrapped. Honour --host (e2e passes 0.0.0.0:9999) so AWSGW
listens on all interfaces incl. br-mgmt; the serving cert already SANs every
local interface IP, and WAN clients still reach it.

* test(eks): install kubectl on e2e VM so KubectlGetNodes runs

The eks data-plane subtest (TestEKS/KubectlGetNodes) drives the full
aws eks get-token -> kubectl -> apiserver -> token-webhook -> gateway broker
-> STS verify path — the only end-to-end exercise of the token-review broker.
The e2e binaries run on the VM, where kubectl was absent, so
harness.NewKubectl t.Skip'd it and the path went unvalidated. Install a
checksum-verified kubectl (pinned to the k3s server version) on the VM,
gated on e2e-eks.
2026-06-07 20:42:25 +10:00

370 lines
13 KiB
Bash
Executable File

#!/bin/bash
set -euo pipefail
# build-system-image.sh — Build a minimal system AMI from a manifest
#
# Supports Alpine Linux and Ubuntu 24.04+ as base distros. Creates a pre-baked
# image with custom packages, binaries, and setup scripts installed, ready for
# import as a Spinifex AMI.
#
# Customization runs entirely inside the libguestfs appliance (its own kernel +
# userspace), so the build touches NO host block device, NO host mount, and
# needs NO sudo. This deliberately replaces the previous qemu-nbd + `sudo mount`
# + chroot + /dev bind-mount flow, whose leaked binds could be traversed by a
# later `rm -rf` and wipe the host's /dev (it wedged CI runners). libguestfs
# isolates all of that in a throwaway VM.
#
# Requirements: virt-customize, guestfish, virt-resize (libguestfs-tools),
# qemu-img, curl
# Usage: ./scripts/build-system-image.sh <manifest.conf> [--import]
# --import Also import the image as an AMI via spx admin images import
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
# No libvirt on CI runners — drive qemu directly. Harmless when libvirt exists.
export LIBGUESTFS_BACKEND="${LIBGUESTFS_BACKEND:-direct}"
usage() {
echo "Usage: $0 <manifest.conf> [--import]"
echo ""
echo " manifest.conf Path to image manifest (see scripts/images/ for examples)"
echo " --import Import the built image as an AMI"
exit 1
}
if [[ $# -lt 1 || "$1" == "-h" || "$1" == "--help" ]]; then
usage
fi
MANIFEST="$1"
shift
if [[ ! -f "$MANIFEST" ]]; then
echo "ERROR: Manifest not found: $MANIFEST"
exit 1
fi
DO_IMPORT=false
QUIET=false
for arg in "$@"; do
case "$arg" in
--import) DO_IMPORT=true ;;
--quiet) QUIET=true ;;
esac
done
# Source the manifest
# shellcheck source=/dev/null
source "$MANIFEST"
# Default distro is alpine for backward compatibility
DISTRO="${DISTRO:-alpine}"
# Validate required manifest fields
for field in IMAGE_NAME IMAGE_SIZE; do
if [[ -z "${!field:-}" ]]; then
echo "ERROR: Manifest missing required field: $field"
exit 1
fi
done
# Derived paths
BUILD_DIR="/tmp/${IMAGE_NAME}-image-build"
case "$DISTRO" in
alpine)
if [[ -z "${ALPINE_VERSION:-}" ]]; then
echo "ERROR: Manifest missing required field: ALPINE_VERSION"
exit 1
fi
SOURCE_IMAGE="generic_alpine-${ALPINE_VERSION}-x86_64-bios-cloudinit-r0.qcow2"
SOURCE_URL="https://dl-cdn.alpinelinux.org/alpine/v${ALPINE_VERSION%.*}/releases/cloud/${SOURCE_IMAGE}"
OUTPUT_IMAGE="${BUILD_DIR}/${IMAGE_NAME}-alpine.qcow2"
OUTPUT_RAW="${BUILD_DIR}/${IMAGE_NAME}-alpine.raw"
DISTRO_VERSION="${ALPINE_VERSION}"
BOOT_MODE="bios"
;;
ubuntu)
if [[ -z "${UBUNTU_VERSION:-}" ]]; then
echo "ERROR: Manifest missing required field: UBUNTU_VERSION"
exit 1
fi
SOURCE_IMAGE="ubuntu-${UBUNTU_VERSION}-minimal-cloudimg-amd64.img"
SOURCE_URL="https://cloud-images.ubuntu.com/minimal/releases/${UBUNTU_CODENAME:-noble}/release/${SOURCE_IMAGE}"
OUTPUT_IMAGE="${BUILD_DIR}/${IMAGE_NAME}-ubuntu.qcow2"
OUTPUT_RAW="${BUILD_DIR}/${IMAGE_NAME}-ubuntu.raw"
DISTRO_VERSION="${UBUNTU_VERSION}"
BOOT_MODE="uefi"
;;
*)
echo "ERROR: Unknown DISTRO: $DISTRO (supported: alpine, ubuntu)"
exit 1
;;
esac
# import_ami registers the built raw image as a Spinifex AMI. Shared by the
# fresh-build and cached-build paths.
import_ami() {
echo "Importing as AMI..."
rm -f "$OUTPUT_IMAGE"
local args=(--file "$OUTPUT_RAW" --distro "${DISTRO}" --version "${DISTRO_VERSION}" --arch x86_64 --boot-mode "${BOOT_MODE}")
if [[ -n "${AMI_NAME:-}" ]]; then
args+=(--ami-name "$AMI_NAME")
fi
if [[ -n "${SYSTEM_TAG:-}" ]]; then
args+=(--tag "$SYSTEM_TAG")
fi
spx admin images import "${args[@]}"
}
# In quiet mode, redirect build output to /dev/null (import output still shown)
if [[ "$QUIET" == true ]]; then
exec 3>&1 # save original stdout
exec 1>/dev/null # suppress build output
fi
echo "=== System Image Builder (libguestfs) ==="
echo "Image: ${IMAGE_NAME} — ${IMAGE_DESCRIPTION:-}"
echo "Distro: ${DISTRO} ${DISTRO_VERSION}"
echo "Size: ${IMAGE_SIZE}"
echo "Build: ${BUILD_DIR}"
echo ""
# Step 0: Check prerequisites
for tool in virt-customize qemu-img; do
if ! command -v "$tool" &>/dev/null; then
echo "ERROR: $tool not found. Install libguestfs-tools + qemu-utils."
exit 1
fi
done
if [[ "$DISTRO" == "alpine" ]] && ! command -v guestfish &>/dev/null; then
echo "ERROR: guestfish not found. Install libguestfs-tools."
exit 1
fi
if [[ "$DISTRO" == "ubuntu" ]] && ! command -v virt-resize &>/dev/null; then
echo "ERROR: virt-resize not found. Install libguestfs-tools."
exit 1
fi
# Build binaries if BUILD_COMMANDS is set
if [[ -n "${BUILD_COMMANDS:-}" ]]; then
echo "Building binaries: ${BUILD_COMMANDS}"
if ! (cd "$PROJECT_DIR" && eval "$BUILD_COMMANDS"); then
echo "ERROR: BUILD_COMMANDS failed: ${BUILD_COMMANDS}"
exit 1
fi
fi
# Verify binaries exist; Alpine also requires static linking (uses musl)
if [[ -n "${INSTALL_BINARIES:-}" ]]; then
IFS=' ' read -ra BINARY_PAIRS <<< "$INSTALL_BINARIES"
for pair in "${BINARY_PAIRS[@]}"; do
src="${pair%%:*}"
src_path="${PROJECT_DIR}/${src}"
if [[ ! -f "$src_path" ]]; then
echo "ERROR: Binary not found: $src_path"
exit 1
fi
if [[ "$DISTRO" == "alpine" ]] && ! file "$src_path" | grep -q "statically linked"; then
echo "ERROR: $src_path is not statically linked (Alpine uses musl — dynamic glibc binaries will fail)"
echo " Rebuild with: CGO_ENABLED=0 go build ..."
exit 1
fi
done
fi
# Serialize the build with flock — concurrent builds on the same host (e.g. CI
# single-node + multi-node jobs) share BUILD_DIR + the cached raw image.
BUILD_LOCK="/tmp/build-system-image.lock"
echo "Acquiring build lock..."
exec 9>"$BUILD_LOCK"
flock 9
echo "Lock acquired"
# If the raw image was built recently (< 10 min), skip the entire build.
# This avoids duplicate work when concurrent CI jobs build the same image.
if [[ -f "$OUTPUT_RAW" ]] && [[ $(( $(date +%s) - $(stat -c %Y "$OUTPUT_RAW") )) -lt 600 ]]; then
echo "=== Skipping build — $OUTPUT_RAW is fresh (< 10 min old) ==="
# Restore stdout if suppressed, then jump to import
if [[ "$QUIET" == true ]]; then
exec 1>&3 3>&-
fi
echo ""
echo "=== Build complete (cached) ==="
echo " raw: $OUTPUT_RAW ($(du -h "$OUTPUT_RAW" | cut -f1))"
if [[ "$DO_IMPORT" == true ]]; then
import_ami
fi
exit 0
fi
mkdir -p "$BUILD_DIR"
# Step 1: Download base cloud image
if [[ -f "${BUILD_DIR}/${SOURCE_IMAGE}" ]]; then
echo "Base image already downloaded."
else
echo "Downloading ${DISTRO} ${DISTRO_VERSION} cloud image..."
if ! curl --fail -L -o "${BUILD_DIR}/${SOURCE_IMAGE}" "$SOURCE_URL"; then
rm -f "${BUILD_DIR}/${SOURCE_IMAGE}"
echo "ERROR: Failed to download image from $SOURCE_URL"
exit 1
fi
# Verify the download is a valid qcow2/img
if ! qemu-img info "${BUILD_DIR}/${SOURCE_IMAGE}" &>/dev/null; then
rm -f "${BUILD_DIR}/${SOURCE_IMAGE}"
echo "ERROR: Downloaded file is not a valid disk image"
exit 1
fi
fi
# Step 2: Copy image for customization
rm -f "$OUTPUT_IMAGE"
echo "Copying image for customization..."
cp "${BUILD_DIR}/${SOURCE_IMAGE}" "$OUTPUT_IMAGE"
# Step 3: Resize to provide room for packages. Both paths grow the root
# filesystem entirely inside the libguestfs appliance — no host loop/nbd device.
echo "Resizing image to ${IMAGE_SIZE}..."
if [[ "$DISTRO" == "alpine" ]]; then
# Alpine cloud images have ext4 directly on the whole disk (no partition
# table); grow the disk file then expand the fs from inside the appliance.
qemu-img resize "$OUTPUT_IMAGE" "$IMAGE_SIZE"
guestfish --rw -a "$OUTPUT_IMAGE" run \
: e2fsck /dev/sda forceall:true \
: resize2fs /dev/sda
else
# Ubuntu images use a GPT partition table with the root fs on /dev/sda1;
# virt-resize expands the partition + fs into a fresh IMAGE_SIZE disk.
RESIZED="${OUTPUT_IMAGE}.resized"
rm -f "$RESIZED"
qemu-img create -f qcow2 "$RESIZED" "$IMAGE_SIZE"
virt-resize --expand /dev/sda1 "$OUTPUT_IMAGE" "$RESIZED"
mv -f "$RESIZED" "$OUTPUT_IMAGE"
fi
# Step 4: Assemble the virt-customize operation list. virt-customize runs the
# operations in the order they appear on the command line, so this mirrors the
# old chroot sequence: packages → binaries → files → setup → services → clean.
CUST=(virt-customize -a "$OUTPUT_IMAGE" --network)
if [[ "$DISTRO" == "ubuntu" ]]; then
# DKMS driver bakes (GPU images) need real resources in the appliance.
CUST+=(--memsize 6144 --smp 4)
else
CUST+=(--memsize 2048)
fi
# Packages
if [[ "$DISTRO" == "alpine" ]] && [[ -n "${APK_PACKAGES:-}" ]]; then
echo "Will install packages: ${APK_PACKAGES}"
CUST+=(--run-command 'sed -i "s|^#\(.*community\)|\1|" /etc/apk/repositories 2>/dev/null || true')
CUST+=(--run-command 'grep -q community /etc/apk/repositories || grep main /etc/apk/repositories | head -1 | sed "s|/main|/community|" >> /etc/apk/repositories')
CUST+=(--run-command 'apk update')
CUST+=(--run-command "apk add ${APK_PACKAGES}")
elif [[ "$DISTRO" == "ubuntu" ]] && [[ -n "${APT_PACKAGES:-}" ]]; then
echo "Will install packages: ${APT_PACKAGES}"
CUST+=(--run-command 'export DEBIAN_FRONTEND=noninteractive; apt-get update')
CUST+=(--run-command "export DEBIAN_FRONTEND=noninteractive; apt-get install -y --no-install-recommends ${APT_PACKAGES}")
fi
# Binaries (mode 0755). --mkdir is idempotent (mkdir -p semantics).
if [[ -n "${INSTALL_BINARIES:-}" ]]; then
IFS=' ' read -ra BINARY_PAIRS <<< "$INSTALL_BINARIES"
for pair in "${BINARY_PAIRS[@]}"; do
src="${pair%%:*}"
dst="${pair#*:}"
src_path="${PROJECT_DIR}/${src}"
CUST+=(--mkdir "$(dirname "$dst")")
CUST+=(--upload "${src_path}:${dst}")
CUST+=(--chmod "0755:${dst}")
done
fi
# Auxiliary files (systemd units, OpenRC initd scripts, cron entries, configs).
# Mode 0644; setup scripts may chmod role-specific files to 0755 afterwards.
if [[ -n "${INSTALL_FILES:-}" ]]; then
IFS=' ' read -ra FILE_PAIRS <<< "$INSTALL_FILES"
for pair in "${FILE_PAIRS[@]}"; do
src="${pair%%:*}"
dst="${pair#*:}"
src_path="${PROJECT_DIR}/${src}"
if [[ ! -f "$src_path" ]]; then
echo "ERROR: INSTALL_FILES source not found: $src_path"
exit 1
fi
CUST+=(--mkdir "$(dirname "$dst")")
CUST+=(--upload "${src_path}:${dst}")
CUST+=(--chmod "0644:${dst}")
done
fi
# Custom setup script (uploaded + executed + removed by --run, inside the guest).
if [[ -n "${SETUP_SCRIPT:-}" ]]; then
setup_path="${PROJECT_DIR}/${SETUP_SCRIPT}"
if [[ ! -f "$setup_path" ]]; then
echo "ERROR: Setup script not found: $setup_path"
exit 1
fi
CUST+=(--run "$setup_path")
fi
# Enable services (after files + setup so referenced init scripts exist).
if [[ -n "${ENABLE_SERVICES:-}" ]]; then
IFS=' ' read -ra SERVICES <<< "$ENABLE_SERVICES"
for svc in "${SERVICES[@]}"; do
if [[ "$DISTRO" == "alpine" ]]; then
CUST+=(--run-command "rc-update add ${svc} default")
else
CUST+=(--run-command "systemctl enable ${svc}")
fi
done
fi
# Final cleanup of package caches + tmp.
if [[ "$DISTRO" == "alpine" ]]; then
CUST+=(--run-command 'apk cache clean 2>/dev/null || true; rm -rf /var/cache/apk/* /tmp/* 2>/dev/null || true')
else
CUST+=(--run-command 'export DEBIAN_FRONTEND=noninteractive; apt-get clean; rm -rf /var/lib/apt/lists/* /tmp/* 2>/dev/null || true')
fi
echo "Customizing image (libguestfs appliance)..."
"${CUST[@]}"
# Step 5: Convert to raw for import
echo "Converting to raw format..."
qemu-img convert -f qcow2 -O raw "$OUTPUT_IMAGE" "$OUTPUT_RAW"
# Restore stdout if suppressed
if [[ "$QUIET" == true ]]; then
exec 1>&3 3>&-
fi
echo ""
echo "=== Build complete ==="
echo " Image: ${IMAGE_NAME}"
echo " qcow2: $OUTPUT_IMAGE ($(du -h "$OUTPUT_IMAGE" | cut -f1))"
echo " raw: $OUTPUT_RAW ($(du -h "$OUTPUT_RAW" | cut -f1))"
echo ""
if [[ "$DO_IMPORT" == true ]]; then
import_ami
else
echo "To import as AMI, run:"
echo " spx admin images import \\"
echo " --file $OUTPUT_RAW \\"
NAME_HINT=""
if [[ -n "${AMI_NAME:-}" ]]; then
NAME_HINT=" \\\n --ami-name ${AMI_NAME}"
fi
if [[ -n "${SYSTEM_TAG:-}" ]]; then
echo -e " --distro ${DISTRO} --version ${DISTRO_VERSION} --arch x86_64 --boot-mode ${BOOT_MODE}${NAME_HINT} \\"
echo " --tag ${SYSTEM_TAG}"
else
echo -e " --distro ${DISTRO} --version ${DISTRO_VERSION} --arch x86_64 --boot-mode ${BOOT_MODE}${NAME_HINT}"
fi
fi