feat(eks): publish eks-node AMI to R2 + spx catalogue entry (#313)

* feat(eks): publish eks-node AMI to R2 + spx catalogue entry

Add a spinifex-eks-node catalogue entry so operators install the EKS
node image with a single command:

  spx admin images import --name spinifex-eks-node

The entry pulls the Alpine+K3s image from iso.mulgadc.com, verifies its
sha256, and stamps spinifex:managed-by=eks — the tag the control-plane
and node-group launchers resolve the boot AMI by, so no extra config is
needed.

Add scripts/publish-system-image.sh (+ make publish-eks-node-image) to
convert the built raw to a compressed qcow2, emit a checksum sidecar,
and upload both to the R2 bucket backing iso.mulgadc.com/system-ami via
the S3 API.

* ci(eks): add manual workflow to build + publish eks-node AMI to R2

Build the eks-node AMI on the runner (libguestfs) and publish via
publish-system-image.sh using the existing CF_R2_* secrets. Manual
workflow_dispatch with a dry-run input. Fix the publish script's default
bucket to spinifex-iso (the bucket iso.mulgadc.com is served from).
This commit is contained in:
Josh Siviour
2026-06-08 09:48:59 +10:00
committed by GitHub
parent 5cf5cba7ac
commit 467e4de715
5 changed files with 283 additions and 1 deletions
+92
View File
@@ -0,0 +1,92 @@
name: Publish EKS node AMI
# Builds the eks-node system AMI (libguestfs, on the runner) and publishes the
# compressed qcow2 + .sha256 sidecar to the Cloudflare R2 bucket that backs
# iso.mulgadc.com/system-ami/. Operators then install it on any cluster with:
# spx admin images import --name spinifex-eks-node
#
# Manual-only: publishing overwrites the mirror artifact, so it is a deliberate
# action, not something every push should trigger.
on:
workflow_dispatch:
inputs:
dry_run:
description: "Build + convert only; skip the R2 upload"
type: boolean
default: false
permissions:
contents: read
concurrency:
group: publish-eks-ami
cancel-in-progress: false
jobs:
publish:
name: Build + publish eks-node AMI
runs-on: ubuntu-24.04
steps:
- name: Checkout Spinifex
uses: actions/checkout@v6
with:
path: spinifex
- name: Checkout Viperblock
uses: actions/checkout@v6
with:
repository: mulgadc/viperblock
ref: dev
path: viperblock
- name: Checkout Predastore
uses: actions/checkout@v6
with:
repository: mulgadc/predastore
ref: dev
path: predastore
- name: Setup Go
uses: actions/setup-go@v6
with:
cache-dependency-path: "spinifex/go.sum"
go-version-file: "spinifex/go.mod"
- name: Setup Go Workspace
run: go work init ./spinifex ./viperblock ./predastore
- name: Install build + upload tooling
run: |
set -euo pipefail
sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends libguestfs-tools qemu-utils
if ! command -v aws &>/dev/null; then
curl -fsSL "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o /tmp/awscliv2.zip
unzip -q /tmp/awscliv2.zip -d /tmp/aws-install
sudo /tmp/aws-install/aws/install
rm -rf /tmp/awscliv2.zip /tmp/aws-install
fi
- name: Build + publish to R2
working-directory: spinifex
env:
# Map the repo's R2 secrets onto the names publish-system-image.sh reads.
AWS_ACCESS_KEY_ID: ${{ secrets.CF_R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.CF_R2_SECRET_ACCESS_KEY }}
R2_ENDPOINT: ${{ secrets.CF_R2_ENDPOINT_URL }}
R2_BUCKET: spinifex-iso
DRY_RUN: ${{ inputs.dry_run }}
run: |
set -euo pipefail
# libguestfs needs to read a host kernel; Ubuntu ships it mode 0600.
# direct backend = no libvirt on the runner.
export LIBGUESTFS_BACKEND=direct
sudo chmod 0644 /boot/vmlinuz-* || true
FLAGS=(--build)
if [[ "${DRY_RUN}" == "true" ]]; then
FLAGS+=(--dry-run)
fi
./scripts/publish-system-image.sh \
scripts/images/eks-node/manifest.conf "${FLAGS[@]}"
+4 -1
View File
@@ -92,6 +92,9 @@ build-eks-node-image: ## Build the unified eks-node AMI (K3s server+agent; role
import-eks-node-image: ## Build + register the eks-node AMI (requires a running cluster)
$(MAKE) build-system-image IMAGE=eks-node IMPORT=1
publish-eks-node-image: ## Build + publish the eks-node AMI to Cloudflare R2 (needs R2_ENDPOINT + AWS_* env)
./scripts/publish-system-image.sh scripts/images/eks-node/manifest.conf --build
MICROVM_OUT_DIR := build/microvm
MICROVM_ARTIFACTS := $(MICROVM_OUT_DIR)/vmlinuz $(MICROVM_OUT_DIR)/initramfs.cpio.gz
MICROVM_INPUTS := scripts/build-microvm-image.sh $(MICROVM_OUT_DIR)/init.sh $(MICROVM_OUT_DIR)/inittab bin/lb-agent
@@ -350,7 +353,7 @@ ansible-cluster-bootstrap:
$(if $(POOL),-e cluster_external_pool=$(POOL),) \
$(_ANSIBLE_EXTRA)
.PHONY: build build-ui build-installer build-lb-agent build-system-image build-eks-node-image import-eks-node-image build-microvm-image install-microvm go_build go_run preflight test test-cover test-race diff-coverage bench run test-actions test-harness manifest-check diff-coverage bench run \
.PHONY: build build-ui build-installer build-lb-agent build-system-image build-eks-node-image import-eks-node-image publish-eks-node-image build-microvm-image install-microvm go_build go_run preflight test test-cover test-race diff-coverage bench run test-actions test-harness manifest-check diff-coverage bench run \
deploy reinstall clean \
install-system install-go install-aws quickinstall \
lint fix govulncheck \
@@ -82,6 +82,25 @@ spx admin images import --name debian-13-arm64
Catalog imports verify the image against the catalog-declared SHA-256/SHA-512 digest before extraction. Use `--file` to import operator-supplied media (verification skipped — operator is responsible for integrity), or `--force` to re-download after a checksum mismatch.
### EKS node image
To run EKS, import the prebuilt node image from the catalog:
```bash
spx admin images import --name spinifex-eks-node
```
This pulls the Alpine + K3s node AMI from `iso.mulgadc.com`, verifies its
checksum, and registers it tagged `spinifex:managed-by=eks`. `eks create-cluster`
and `eks create-nodegroup` resolve the boot AMI by that tag, so no further
configuration is needed.
Operators publishing a freshly built node image to the mirror run the
**Publish EKS node AMI** GitHub Actions workflow (manual dispatch; uses the
`CF_R2_*` repo secrets) or, locally, `make publish-eks-node-image` (see
`scripts/publish-system-image.sh`; requires `R2_ENDPOINT` + R2-scoped
`AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY`).
## Cluster Shutdown
+147
View File
@@ -0,0 +1,147 @@
#!/bin/bash
set -euo pipefail
# publish-system-image.sh — Publish a built system AMI to Cloudflare R2
#
# Converts the raw image produced by build-system-image.sh into a compressed
# qcow2, writes a .sha256 sidecar (coreutils format, matched by the spx catalog
# checksum verifier), and uploads both to the R2 bucket that backs
# iso.mulgadc.com/system-ami/. The matching catalog entry in
# spinifex/spinifex/utils/images.go then lets operators run:
#
# spx admin images import --name <catalog-key>
#
# Object name is <AMI_NAME or IMAGE_NAME>-<arch>.qcow2 — this MUST match the
# basename of the catalog entry's URL.
#
# Requirements: qemu-img, sha256sum, aws (CLI; R2 speaks the S3 API)
# Usage: ./scripts/publish-system-image.sh <manifest.conf> [--build] [--dry-run]
# --build Run build-system-image.sh first (otherwise reuse the cached raw)
# --dry-run Print the convert/checksum/upload steps without uploading
#
# Env (required unless --dry-run):
# R2_ENDPOINT https://<account>.r2.cloudflarestorage.com
# R2_BUCKET defaults to "spinifex-iso" (backs iso.mulgadc.com)
# AWS_ACCESS_KEY_ID R2 token access key
# AWS_SECRET_ACCESS_KEY R2 token secret
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
usage() {
echo "Usage: $0 <manifest.conf> [--build] [--dry-run]"
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_BUILD=false
DRY_RUN=false
for arg in "$@"; do
case "$arg" in
--build) DO_BUILD=true ;;
--dry-run) DRY_RUN=true ;;
*) echo "ERROR: unknown flag: $arg"; usage ;;
esac
done
# shellcheck source=/dev/null
source "$MANIFEST"
DISTRO="${DISTRO:-alpine}"
ARCH="x86_64" # build-system-image.sh builds x86_64 only today
R2_BUCKET="${R2_BUCKET:-spinifex-iso}" # iso.mulgadc.com is served from this bucket
if [[ -z "${IMAGE_NAME:-}" ]]; then
echo "ERROR: Manifest missing required field: IMAGE_NAME"
exit 1
fi
# Mirror build-system-image.sh's derived paths so we reuse its cached raw.
BUILD_DIR="/tmp/${IMAGE_NAME}-image-build"
RAW_IMAGE="${BUILD_DIR}/${IMAGE_NAME}-${DISTRO}.raw"
OBJECT_BASE="${AMI_NAME:-$IMAGE_NAME}-${ARCH}"
QCOW2_OUT="${BUILD_DIR}/${OBJECT_BASE}.qcow2"
SHA_OUT="${QCOW2_OUT}.sha256"
OBJECT_QCOW2="${OBJECT_BASE}.qcow2"
OBJECT_SHA="${OBJECT_BASE}.qcow2.sha256"
for tool in qemu-img sha256sum; do
if ! command -v "$tool" &>/dev/null; then
echo "ERROR: $tool not found"
exit 1
fi
done
if [[ "$DRY_RUN" == false ]] && ! command -v aws &>/dev/null; then
echo "ERROR: aws CLI not found (required for R2 upload; use --dry-run to skip)"
exit 1
fi
if [[ "$DO_BUILD" == true ]]; then
echo "=== Building ${IMAGE_NAME} ==="
"${SCRIPT_DIR}/build-system-image.sh" "$MANIFEST"
fi
if [[ ! -f "$RAW_IMAGE" ]]; then
echo "ERROR: Raw image not found: $RAW_IMAGE"
echo " Run with --build, or 'make build-system-image IMAGE=...' first."
exit 1
fi
if [[ "$DRY_RUN" == false ]]; then
for var in R2_ENDPOINT AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY; do
if [[ -z "${!var:-}" ]]; then
echo "ERROR: $var must be set (or use --dry-run)"
exit 1
fi
done
fi
echo "=== Publish System Image ==="
echo " Manifest: ${MANIFEST}"
echo " Raw: ${RAW_IMAGE}"
echo " Object: s3://${R2_BUCKET}/system-ami/${OBJECT_QCOW2}"
echo ""
run() {
echo "+ $*"
if [[ "$DRY_RUN" == false ]]; then
"$@"
fi
}
# Compressed qcow2 keeps the transfer small (consistent with the GPU images).
run qemu-img convert -c -f raw -O qcow2 "$RAW_IMAGE" "$QCOW2_OUT"
# Sidecar in coreutils format: "<hex> <basename>". The spx catalog verifier
# matches the entry by the downloaded file's basename, so write the basename
# the catalog URL resolves to, not the local build path.
if [[ "$DRY_RUN" == false ]]; then
( cd "$(dirname "$QCOW2_OUT")" && sha256sum "$(basename "$QCOW2_OUT")" > "$(basename "$SHA_OUT")" )
echo " sha256: $(cut -d' ' -f1 "$SHA_OUT")"
else
echo "+ sha256sum ${OBJECT_QCOW2} > ${OBJECT_SHA}"
fi
S3_PREFIX="s3://${R2_BUCKET}/system-ami"
run aws s3 cp --endpoint-url "${R2_ENDPOINT:-<R2_ENDPOINT>}" "$QCOW2_OUT" "${S3_PREFIX}/${OBJECT_QCOW2}"
run aws s3 cp --endpoint-url "${R2_ENDPOINT:-<R2_ENDPOINT>}" "$SHA_OUT" "${S3_PREFIX}/${OBJECT_SHA}"
echo ""
if [[ "$DRY_RUN" == true ]]; then
echo "=== Dry run complete (no upload) ==="
else
echo "=== Published ==="
echo " https://iso.mulgadc.com/system-ami/${OBJECT_QCOW2}"
echo " https://iso.mulgadc.com/system-ami/${OBJECT_SHA}"
fi
+21
View File
@@ -462,6 +462,27 @@ var AvailableImages = map[string]Images{
BootMode: "uefi",
Tags: map[string]string{"gpu-vendor": "amd"},
},
// EKS node system AMI. The control-plane + node-group launchers resolve the
// boot AMI by the spinifex:managed-by=eks tag (not by name), so importing
// this catalog entry — which copies the tag onto the AMI — is enough for
// CreateCluster/CreateNodegroup to find it. Built from
// scripts/images/eks-node/manifest.conf (Alpine bios base); published to R2
// with scripts/publish-system-image.sh.
"spinifex-eks-node": {
Name: "spinifex-eks-node",
Description: "Mulga EKS node image — Alpine 3.21.7 + K3s v1.32.5 + eks-token-webhook (server|agent role selected at first boot)",
Distro: "alpine",
Version: "3.21.7",
Arch: "x86_64",
Platform: "Linux/UNIX",
CreatedAt: time.Date(2026, 6, 8, 0, 0, 0, 0, time.UTC),
URL: "https://iso.mulgadc.com/system-ami/spinifex-eks-node-x86_64.qcow2",
Checksum: "https://iso.mulgadc.com/system-ami/spinifex-eks-node-x86_64.qcow2.sha256",
ChecksumType: "sha256",
BootMode: "bios",
Tags: map[string]string{"spinifex:managed-by": "eks"},
},
}
// AMI / image extraction utils