mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-15 09:01:31 +00:00
merge dev into main
This commit is contained in:
+2
-8
@@ -6,16 +6,10 @@
|
||||
# Set the matching branch protection rule on `dev` and `main` to
|
||||
# "Require review from Code Owners" or this file is advisory only.
|
||||
|
||||
# Fall-through: any change needs a team review.
|
||||
* @mulgadc/mulgadc-engineers
|
||||
|
||||
# Network layer is governed by ADR-0006 invariant tests.
|
||||
# Changes here must respect those clauses — see
|
||||
# spinifex/network/invariants/ and
|
||||
# Changes here must respect those clauses
|
||||
# see spinifex/network/invariants/
|
||||
# docs/development/proposals/az-local-architecture/0006-spinifex-network-layer-contract.md
|
||||
/spinifex/network/ @joshsiv-mulga
|
||||
/spinifex/vpcd/ @joshsiv-mulga
|
||||
|
||||
# Invariants suite is the executable ADR-0006 fence — any widening or
|
||||
# narrowing of the scan roots needs an explicit owner review.
|
||||
/spinifex/network/invariants/ @joshsiv-mulga
|
||||
|
||||
@@ -2,6 +2,13 @@ name: E2E Bare-Metal
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
workflow_call:
|
||||
inputs:
|
||||
ref:
|
||||
description: "spinifex ref to checkout/test (default: triggering ref)"
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -12,7 +19,7 @@ env:
|
||||
|
||||
jobs:
|
||||
e2e_baremetal:
|
||||
name: Bare-Metal Multi-Node E2E
|
||||
name: Bare-Metal Single-Node E2E
|
||||
runs-on: [ self-hosted, ci-baremetal ]
|
||||
timeout-minutes: 120
|
||||
concurrency:
|
||||
@@ -20,11 +27,17 @@ jobs:
|
||||
cancel-in-progress: false
|
||||
env:
|
||||
BM_ISO_CACHE: /var/cache/spinifex-iso-builder
|
||||
ARTIFACT_DIR: /tmp/spinifex-e2e-baremetal-artifacts-${{ github.run_id }}
|
||||
steps:
|
||||
- name: Clean Workspace
|
||||
run: sudo rm -rf "$GITHUB_WORKSPACE"/*
|
||||
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
# When called (nightly/release) the caller picks the ref (dev on
|
||||
# schedule, the tag on release); standalone dispatch defaults to the
|
||||
# triggering ref.
|
||||
ref: ${{ inputs.ref || github.ref }}
|
||||
|
||||
- uses: actions/create-github-app-token@v3
|
||||
id: app-token
|
||||
@@ -92,6 +105,9 @@ jobs:
|
||||
fi
|
||||
|
||||
|
||||
- name: Install go-junit-report on runner
|
||||
run: go install github.com/jstemmer/go-junit-report/v2@latest
|
||||
|
||||
- name: Run Bare-Metal E2E
|
||||
env:
|
||||
BM_CI_PASSWORD: ${{ secrets.BM_CI_PASSWORD }}
|
||||
@@ -100,8 +116,38 @@ jobs:
|
||||
BM_UBUNTU_IMAGE: ${{ env.BM_ISO_CACHE }}/ubuntu-26.04-cloudimg-amd64.img
|
||||
SPX_EXTERNAL_POOL: ${{ env.SPINIFEX_CI_POOL }}
|
||||
run: |
|
||||
mkdir -p "${ARTIFACT_DIR}"
|
||||
# Capture combined bootstrap + e2e output so failures produce a
|
||||
# downloadable, analyzable artifact (the suites run go test -v on the
|
||||
# init node and stream back over SSH). pipefail keeps the script's
|
||||
# real exit code through the tee.
|
||||
set -o pipefail
|
||||
sudo -E mulga/scripts/baremetal/run-baremetal-e2e.sh \
|
||||
--vmlinuz "${BM_ISO_CACHE}/vmlinuz" \
|
||||
--initrd "${BM_ISO_CACHE}/initrd.img" \
|
||||
--squashfs "${BM_ISO_CACHE}/spinifex.squashfs" \
|
||||
--tests-tar "${RUNNER_TEMP}/spinifex-tests.tar"
|
||||
--tests-tar "${RUNNER_TEMP}/spinifex-tests.tar" \
|
||||
2>&1 | tee "${ARTIFACT_DIR}/test-baremetal.log"
|
||||
|
||||
- name: Generate JUnit report
|
||||
if: always()
|
||||
run: |
|
||||
LOG="${ARTIFACT_DIR}/test-baremetal.log"
|
||||
[ -f "$LOG" ] || exit 0
|
||||
go-junit-report -in "$LOG" -out "${ARTIFACT_DIR}/junit-baremetal.xml" || true
|
||||
|
||||
- name: Analyze e2e failures
|
||||
if: always()
|
||||
uses: ./.github/actions/e2e-analyze
|
||||
with:
|
||||
junit-glob: ${{ env.ARTIFACT_DIR }}/junit-*.xml
|
||||
log-dir: ${{ env.ARTIFACT_DIR }}
|
||||
title: "Bare-Metal E2E failure analysis"
|
||||
|
||||
- uses: actions/upload-artifact@v7
|
||||
if: always()
|
||||
with:
|
||||
name: e2e-baremetal-artifacts-${{ github.run_id }}
|
||||
path: ${{ env.ARTIFACT_DIR }}
|
||||
if-no-files-found: warn
|
||||
retention-days: 14
|
||||
|
||||
@@ -216,3 +216,22 @@ jobs:
|
||||
tofu workspace select -or-create "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1
|
||||
tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars ${TOFU_VAR_ARGS} -auto-approve -input=false >> "$TOFU_LOG" 2>&1
|
||||
./scrub-env.sh "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1 || true
|
||||
|
||||
# Bare-metal single-node suite (PXE-reimaged physical hardware). Doesn't fit
|
||||
# the tofu matrix shape (different runner/provisioning/120m timeout), so it
|
||||
# runs as a sibling job via the reusable e2e-baremetal workflow. Gated like a
|
||||
# matrix cell ("cell 20"): every weeknight cron, or dispatch with 20 selected
|
||||
# (blank cells = all). Tests `dev` on schedule to match GIT_BRANCH above.
|
||||
baremetal:
|
||||
name: cell-20 (baremetal/pxe/real-hw)
|
||||
if: >-
|
||||
github.event_name == 'schedule' ||
|
||||
inputs.cells == '' ||
|
||||
contains(format(',{0},', inputs.cells), ',20,')
|
||||
permissions:
|
||||
contents: read
|
||||
statuses: write
|
||||
uses: ./.github/workflows/e2e-baremetal.yml
|
||||
with:
|
||||
ref: ${{ github.event_name == 'schedule' && 'dev' || github.ref_name }}
|
||||
secrets: inherit
|
||||
|
||||
+137
-15
@@ -18,7 +18,7 @@ on:
|
||||
description: "PR head SHA to report status on"
|
||||
required: false
|
||||
suites:
|
||||
description: "Space-separated Make targets to run (e.g. 'e2e-single e2e-multinode e2e-cert e2e-lb')"
|
||||
description: "Space-separated Make targets to run (e.g. 'e2e-single e2e-multinode e2e-cert e2e-lb e2e-eks')"
|
||||
required: false
|
||||
default: "e2e-single e2e-multinode e2e-cert e2e-lb"
|
||||
test_run:
|
||||
@@ -30,13 +30,6 @@ permissions:
|
||||
contents: read
|
||||
statuses: write
|
||||
|
||||
# Pushes to the same ref stack on the single `ci-single` self-hosted runner,
|
||||
# and a cancelled run can leave orphan `tofu`/`go test -c` procs behind that
|
||||
# wedge the runner host. Auto-cancel keeps at most one in-flight run per ref.
|
||||
concurrency:
|
||||
group: e2e-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
GIT_BRANCH: ${{ github.ref_name }}
|
||||
SUITES: ${{ inputs.suites || 'e2e-single e2e-multinode e2e-cert e2e-lb' }}
|
||||
@@ -49,7 +42,7 @@ jobs:
|
||||
# suites='e2e-reboot' only). Push triggers and the default SUITES list
|
||||
# both include single/cert/lb so the inputs.suites null-check keeps the
|
||||
# job running on plain PR pushes.
|
||||
if: github.event_name != 'workflow_dispatch' || contains(github.event.inputs.suites, 'e2e-single') || contains(github.event.inputs.suites, 'e2e-cert') || contains(github.event.inputs.suites, 'e2e-lb')
|
||||
if: github.event_name != 'workflow_dispatch' || contains(github.event.inputs.suites, 'e2e-single') || contains(github.event.inputs.suites, 'e2e-cert') || contains(github.event.inputs.suites, 'e2e-lb') || contains(github.event.inputs.suites, 'e2e-eks')
|
||||
runs-on: [ self-hosted, ci-single ]
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
@@ -77,7 +70,15 @@ jobs:
|
||||
true
|
||||
|
||||
- name: Clean Workspace
|
||||
run: sudo rm -rf "$GITHUB_WORKSPACE"/*
|
||||
run: |
|
||||
# Detach any leaked mounts under the workspace before deleting. A
|
||||
# killed image build can leave a `mount --bind /dev` under its build
|
||||
# dir; if `rm -rf` traverses it as root it wipes the host's /dev
|
||||
# (deletes /dev/null and wedges the box). umount deepest-first, then
|
||||
# forbid rm from crossing any mount boundary as a hard backstop.
|
||||
awk -v w="$GITHUB_WORKSPACE" '$2 ~ "^"w {print $2}' /proc/mounts \
|
||||
| sort -r | xargs -r -I{} sudo umount -l -R {} || true
|
||||
sudo rm -rf --one-file-system "$GITHUB_WORKSPACE"/*
|
||||
|
||||
- uses: actions/create-github-app-token@v3
|
||||
id: app-token
|
||||
@@ -153,6 +154,85 @@ jobs:
|
||||
echo "wan_ip=${WAN_IP}" >> "$GITHUB_OUTPUT"
|
||||
echo "ssh_key=${SSH_KEY}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# The eks suite's CreateCluster boots a control-plane VM from the
|
||||
# eks-node AMI, so that AMI must exist in the VM's image store before the
|
||||
# suite runs. build-system-image.sh customizes the image with libguestfs
|
||||
# (an isolated userspace appliance) — it touches no host block device, so
|
||||
# it is safe on the shared runner. The build needs the spinifex source +
|
||||
# network (runner only); the import needs predastore/NATS (VM only), so
|
||||
# build on the runner then ship the raw and import on the VM.
|
||||
- name: Build eks-node AMI (libguestfs, runner)
|
||||
if: contains(env.SUITES, 'e2e-eks')
|
||||
working-directory: spinifex
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source "${GITHUB_WORKSPACE}/spinifex/.github/scripts/ci-fmt.sh"
|
||||
banner "build eks-node AMI (libguestfs — no host devices)"
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y --no-install-recommends libguestfs-tools qemu-utils
|
||||
# Debian/Ubuntu ship /boot/vmlinuz-* mode 0600; the libguestfs
|
||||
# appliance needs to read a host kernel. direct backend = no libvirt.
|
||||
export LIBGUESTFS_BACKEND=direct
|
||||
sudo chmod 0644 /boot/vmlinuz-* || true
|
||||
group_open "make build-eks-node-image"
|
||||
make build-eks-node-image
|
||||
group_close
|
||||
ok "raw: $(du -h /tmp/eks-node-image-build/eks-node-alpine.raw | cut -f1)"
|
||||
|
||||
- name: Import eks-node AMI on VM
|
||||
if: contains(env.SUITES, 'e2e-eks')
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source "${GITHUB_WORKSPACE}/spinifex/.github/scripts/ci-fmt.sh"
|
||||
# Pull import args straight from the manifest so they never drift.
|
||||
eval "$(grep -E '^(ALPINE_VERSION|AMI_NAME|SYSTEM_TAG)=' \
|
||||
spinifex/scripts/images/eks-node/manifest.conf)"
|
||||
SSH_OPTS=(-o StrictHostKeyChecking=no -i "${{ steps.vm.outputs.ssh_key }}")
|
||||
VM=tf-user@${{ steps.vm.outputs.wan_ip }}
|
||||
banner "import eks-node AMI to VM image store"
|
||||
scp "${SSH_OPTS[@]}" \
|
||||
/tmp/eks-node-image-build/eks-node-alpine.raw \
|
||||
"$VM":/tmp/eks-node-alpine.raw
|
||||
# Drop the import progress bar (pterm redraws to stdout — thousands of
|
||||
# lines over the SSH pipe). Errors go to stderr, so they survive; the
|
||||
# exit code still gates the step.
|
||||
ssh "${SSH_OPTS[@]}" "$VM" \
|
||||
"spx admin images import --file /tmp/eks-node-alpine.raw \
|
||||
--distro alpine --version ${ALPINE_VERSION} --arch x86_64 \
|
||||
--boot-mode bios --ami-name ${AMI_NAME} --tag ${SYSTEM_TAG}" >/dev/null
|
||||
ok "eks-node AMI imported"
|
||||
|
||||
# The eks data-plane subtest (TestEKS/KubectlGetNodes) drives the full
|
||||
# `aws eks get-token` → kubectl → apiserver → token-webhook → gateway
|
||||
# broker → STS verify path. The e2e binaries run ON the VM, so kubectl
|
||||
# must be on the VM's PATH or harness.NewKubectl t.Skips the subtest.
|
||||
# Pin to the k3s server version (setup.sh K3S_VERSION) — kubectl is
|
||||
# ±1-minor skew tolerant; matching is simplest. Checksum-verified.
|
||||
- name: Install kubectl on VM
|
||||
if: contains(env.SUITES, 'e2e-eks')
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source "${GITHUB_WORKSPACE}/spinifex/.github/scripts/ci-fmt.sh"
|
||||
SSH_OPTS=(-o StrictHostKeyChecking=no -i "${{ steps.vm.outputs.ssh_key }}")
|
||||
VM=tf-user@${{ steps.vm.outputs.wan_ip }}
|
||||
banner "install kubectl on VM (eks data-plane subtest)"
|
||||
ssh "${SSH_OPTS[@]}" "$VM" 'bash -s' <<'EOF'
|
||||
set -euo pipefail
|
||||
KVER=v1.32.5
|
||||
case "$(uname -m)" in
|
||||
x86_64) ARCH=amd64 ;;
|
||||
aarch64) ARCH=arm64 ;;
|
||||
*) echo "unsupported arch $(uname -m)" >&2; exit 1 ;;
|
||||
esac
|
||||
cd /tmp
|
||||
curl -fsSLO "https://dl.k8s.io/release/${KVER}/bin/linux/${ARCH}/kubectl"
|
||||
curl -fsSLO "https://dl.k8s.io/release/${KVER}/bin/linux/${ARCH}/kubectl.sha256"
|
||||
echo "$(cat kubectl.sha256) kubectl" | sha256sum -c -
|
||||
sudo install -m 0755 kubectl /usr/local/bin/kubectl
|
||||
kubectl version --client
|
||||
EOF
|
||||
ok "kubectl installed on VM"
|
||||
|
||||
- name: Build E2E test binaries on runner
|
||||
working-directory: spinifex/tests/e2e
|
||||
run: |
|
||||
@@ -277,6 +357,15 @@ jobs:
|
||||
ssh "${SSH_OPTS[@]}" "$VM" 'sudo journalctl -u "spinifex-*" --no-pager --output=short-iso --since "60 min ago"' \
|
||||
> "${ARTIFACT_DIR}/spinifex-journal.log" 2>/dev/null
|
||||
ok "journal: $(wc -l < "${ARTIFACT_DIR}/spinifex-journal.log" 2>/dev/null || echo 0) lines"
|
||||
# LB/system microVM serial consoles. The lb-agent logs its nginx (NLB)
|
||||
# engine activation and any `nginx -t`/reload error + active-probe
|
||||
# results to the guest serial console, which the host journal never
|
||||
# captures. These files persist in /run/spinifex past VM teardown, so
|
||||
# collecting them here makes an NLB "0/N healthy" timeout diagnosable.
|
||||
mkdir -p "${ARTIFACT_DIR}/lb-console"
|
||||
ssh "${SSH_OPTS[@]}" "$VM" 'sudo sh -c "cd /run/spinifex 2>/dev/null && tar -cf - console-*.log 2>/dev/null"' \
|
||||
| tar -C "${ARTIFACT_DIR}/lb-console" -xf - 2>/dev/null
|
||||
ok "lb-console: $(ls "${ARTIFACT_DIR}/lb-console" 2>/dev/null | wc -l) files"
|
||||
|
||||
- name: Convert test logs to JUnit XML
|
||||
if: always()
|
||||
@@ -383,7 +472,15 @@ jobs:
|
||||
true
|
||||
|
||||
- name: Clean Workspace
|
||||
run: sudo rm -rf "$GITHUB_WORKSPACE"/*
|
||||
run: |
|
||||
# Detach any leaked mounts under the workspace before deleting. A
|
||||
# killed image build can leave a `mount --bind /dev` under its build
|
||||
# dir; if `rm -rf` traverses it as root it wipes the host's /dev
|
||||
# (deletes /dev/null and wedges the box). umount deepest-first, then
|
||||
# forbid rm from crossing any mount boundary as a hard backstop.
|
||||
awk -v w="$GITHUB_WORKSPACE" '$2 ~ "^"w {print $2}' /proc/mounts \
|
||||
| sort -r | xargs -r -I{} sudo umount -l -R {} || true
|
||||
sudo rm -rf --one-file-system "$GITHUB_WORKSPACE"/*
|
||||
|
||||
- uses: actions/create-github-app-token@v3
|
||||
id: app-token
|
||||
@@ -471,7 +568,12 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
source "${GITHUB_WORKSPACE}/spinifex/.github/scripts/ci-fmt.sh"
|
||||
SUITES_BUILD=$(echo "${SUITES}" | sed 's/e2e-//g' | tr ' ' '\n' | grep -vE '^(reboot|single)$' | tr '\n' ' ')
|
||||
# eks is excluded on multinode: EKS KV is a clustered (Raft) JetStream
|
||||
# stream and create-cluster needs an elected leader, but multinode
|
||||
# JetStream clustering isn't solid yet (mulga-omq/z2z/5yg). EKS is
|
||||
# validated single-node only for now; re-enable here once clustered
|
||||
# JetStream + multinode EKS are a deliberate target.
|
||||
SUITES_BUILD=$(echo "${SUITES}" | sed 's/e2e-//g' | tr ' ' '\n' | grep -vE '^(reboot|single|eks)$' | tr '\n' ' ')
|
||||
if [ -z "$(echo "${SUITES_BUILD}" | tr -d ' ')" ]; then
|
||||
echo "no multinode-eligible suites requested (SUITES=${SUITES}); nothing to build"
|
||||
exit 0
|
||||
@@ -554,7 +656,7 @@ jobs:
|
||||
id: e2e
|
||||
run: |
|
||||
set +e
|
||||
SUITES_RUN=$(echo "${SUITES}" | sed 's/e2e-//g' | tr ' ' '\n' | grep -vE '^(reboot|single)$' | tr '\n' ' ')
|
||||
SUITES_RUN=$(echo "${SUITES}" | sed 's/e2e-//g' | tr ' ' '\n' | grep -vE '^(reboot|single|eks)$' | tr '\n' ' ')
|
||||
if [ -z "$(echo "${SUITES_RUN}" | tr -d ' ')" ]; then
|
||||
echo "no multinode-eligible suites requested (SUITES=${SUITES}); skipping"
|
||||
echo "rc=0" >> "$GITHUB_OUTPUT"
|
||||
@@ -664,6 +766,18 @@ jobs:
|
||||
ssh "${SSH_OPTS[@]}" tf-user@"$ip" 'sudo journalctl -u openvswitch-ipsec.service --no-pager --output=short-iso --since "60 min ago"' \
|
||||
> "${ARTIFACT_DIR}/openvswitch-ipsec-node${idx}.log" 2>/dev/null
|
||||
done
|
||||
# LB/system microVM serial consoles, per node. The lb-agent logs its
|
||||
# nginx (NLB) engine activation and any `nginx -t`/reload error +
|
||||
# active-probe results to the guest serial console (never in the host
|
||||
# journal). Files persist in /run/spinifex past VM teardown.
|
||||
for i in "${!NODE_LIST[@]}"; do
|
||||
idx=$((i + 1))
|
||||
ip="${NODE_LIST[$i]}"
|
||||
mkdir -p "${ARTIFACT_DIR}/lb-console/node${idx}"
|
||||
ssh "${SSH_OPTS[@]}" tf-user@"$ip" 'sudo sh -c "cd /run/spinifex 2>/dev/null && tar -cf - console-*.log 2>/dev/null"' \
|
||||
| tar -C "${ARTIFACT_DIR}/lb-console/node${idx}" -xf - 2>/dev/null
|
||||
ok "lb-console node${idx}: $(ls "${ARTIFACT_DIR}/lb-console/node${idx}" 2>/dev/null | wc -l) files"
|
||||
done
|
||||
|
||||
- name: Convert test logs to JUnit XML
|
||||
if: always()
|
||||
@@ -779,7 +893,15 @@ jobs:
|
||||
true
|
||||
|
||||
- name: Clean Workspace
|
||||
run: sudo rm -rf "$GITHUB_WORKSPACE"/*
|
||||
run: |
|
||||
# Detach any leaked mounts under the workspace before deleting. A
|
||||
# killed image build can leave a `mount --bind /dev` under its build
|
||||
# dir; if `rm -rf` traverses it as root it wipes the host's /dev
|
||||
# (deletes /dev/null and wedges the box). umount deepest-first, then
|
||||
# forbid rm from crossing any mount boundary as a hard backstop.
|
||||
awk -v w="$GITHUB_WORKSPACE" '$2 ~ "^"w {print $2}' /proc/mounts \
|
||||
| sort -r | xargs -r -I{} sudo umount -l -R {} || true
|
||||
sudo rm -rf --one-file-system "$GITHUB_WORKSPACE"/*
|
||||
|
||||
- uses: actions/create-github-app-token@v3
|
||||
id: app-token
|
||||
@@ -1004,7 +1126,7 @@ jobs:
|
||||
name: Report E2E Status
|
||||
if: always()
|
||||
needs: [ e2e_single, e2e_real_multi, e2e_reboot ]
|
||||
runs-on: [ self-hosted, ci-single ]
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Report Status to PR
|
||||
|
||||
@@ -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[@]}"
|
||||
@@ -131,7 +131,16 @@ jobs:
|
||||
REPO: ${{ github.repository }}
|
||||
steps:
|
||||
- name: Clean Workspace
|
||||
run: rm -rf "$GITHUB_WORKSPACE"/*
|
||||
run: |
|
||||
# This runner (ci-single) is shared with the iso job, which mounts a
|
||||
# kernel-global devtmpfs at <workspace>/mulga/scripts/iso-builder/.cache/rootfs/dev.
|
||||
# If that job is killed its EXIT-trap unmount never runs and the bind
|
||||
# leaks; a plain rm -rf then traverses it and deletes the host's /dev
|
||||
# (wipes /dev/null, wedges the box). Detach any leaked mounts under the
|
||||
# workspace first, then forbid rm from crossing a mount boundary.
|
||||
awk -v w="$GITHUB_WORKSPACE" '$2 ~ "^"w {print $2}' /proc/mounts \
|
||||
| sort -r | xargs -r -I{} sudo umount -l -R {} || true
|
||||
sudo rm -rf --one-file-system "$GITHUB_WORKSPACE"/*
|
||||
|
||||
- uses: actions/create-github-app-token@v3
|
||||
id: app-token
|
||||
@@ -169,8 +178,36 @@ jobs:
|
||||
TARBALL=$(find "${GITHUB_WORKSPACE}/release-assets" -name "*-linux-amd64.tar.gz" | head -1)
|
||||
./bootstrap-install.sh "${SPINIFEX_CI_ENV}" "$SETUP_SH" "$TARBALL"
|
||||
|
||||
- name: Teardown
|
||||
if: always()
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
run: |
|
||||
tofu workspace select -or-create "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1
|
||||
tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1
|
||||
./scrub-env.sh "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1 || true
|
||||
|
||||
# Bare-metal single-node suite on the real ISO/squashfs + PXE-boot path —
|
||||
# the only release check that exercises hardware install. Runs in parallel
|
||||
# with `verify` (both need: release). Promotion and ISO upload both hard-gate
|
||||
# on this passing.
|
||||
baremetal:
|
||||
needs: release
|
||||
permissions:
|
||||
contents: read
|
||||
statuses: write
|
||||
uses: ./.github/workflows/e2e-baremetal.yml
|
||||
secrets: inherit
|
||||
|
||||
# Promote the draft only after BOTH the tofu install (`verify`) and the
|
||||
# bare-metal hardware boot (`baremetal`) pass — runs only if both succeed.
|
||||
promote:
|
||||
needs: [ verify, baremetal ]
|
||||
runs-on: ubuntu-24.04
|
||||
env:
|
||||
TAG: ${{ github.ref_name }}
|
||||
REPO: ${{ github.repository }}
|
||||
steps:
|
||||
- name: Promote Release
|
||||
if: success()
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
@@ -182,29 +219,31 @@ jobs:
|
||||
echo "Release ${TAG} promoted to latest"
|
||||
fi
|
||||
|
||||
# Annotate the (still-draft) release if either gate failed.
|
||||
mark-failed:
|
||||
needs: [ verify, baremetal ]
|
||||
if: failure()
|
||||
runs-on: ubuntu-24.04
|
||||
env:
|
||||
TAG: ${{ github.ref_name }}
|
||||
REPO: ${{ github.repository }}
|
||||
steps:
|
||||
- name: Mark Release Failed
|
||||
if: failure()
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
EXISTING_BODY=$(gh release view "$TAG" --repo "$REPO" --json body -q .body)
|
||||
NOTES_FILE=$(mktemp)
|
||||
printf 'Verification failed — see workflow run for details.\n\n%s' "$EXISTING_BODY" > "$NOTES_FILE"
|
||||
printf 'Verification failed (tofu install or bare-metal boot) — see workflow run for details.\n\n%s' "$EXISTING_BODY" > "$NOTES_FILE"
|
||||
gh release edit "$TAG" --repo "$REPO" --notes-file "$NOTES_FILE"
|
||||
rm -f "$NOTES_FILE"
|
||||
|
||||
- name: Teardown
|
||||
if: always()
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
run: |
|
||||
tofu workspace select -or-create "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1
|
||||
tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1
|
||||
./scrub-env.sh "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1 || true
|
||||
|
||||
iso:
|
||||
needs: release
|
||||
runs-on: [ self-hosted, ci-single ]
|
||||
timeout-minutes: 90
|
||||
# ISO build (~60-90m) overlaps the parallel baremetal run (~120m); the
|
||||
# upload gate then waits for baremetal to finish, so the job must outlast it.
|
||||
timeout-minutes: 180
|
||||
steps:
|
||||
- name: Clean Workspace
|
||||
run: |
|
||||
@@ -216,7 +255,7 @@ jobs:
|
||||
sudo umount -l "${ROOTFS}/dev" 2>/dev/null || true
|
||||
sudo umount -l "${ROOTFS}/sys" 2>/dev/null || true
|
||||
sudo umount -l "${ROOTFS}/proc" 2>/dev/null || true
|
||||
sudo rm -rf "$GITHUB_WORKSPACE"/*
|
||||
sudo rm -rf --one-file-system "$GITHUB_WORKSPACE"/*
|
||||
|
||||
- uses: actions/create-github-app-token@v3
|
||||
id: app-token
|
||||
@@ -289,24 +328,28 @@ jobs:
|
||||
SPINIFEX_BIN="${GITHUB_WORKSPACE}/spinifex/bin/spx" \
|
||||
INSTALLER_BIN="${GITHUB_WORKSPACE}/spinifex/bin/spinifex-installer"
|
||||
|
||||
- name: Wait for verify
|
||||
- name: Wait for verify + baremetal
|
||||
id: wait-verify
|
||||
if: steps.build-iso.outcome == 'success'
|
||||
continue-on-error: true
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
echo "Waiting for verify job to complete..."
|
||||
# Gate the R2 upload on BOTH the tofu install (`verify`) and the
|
||||
# real-hardware boot (`baremetal`, a reusable workflow whose job shows
|
||||
# as "baremetal / ..."). Upload only if both conclude success.
|
||||
echo "Waiting for verify + baremetal jobs to complete..."
|
||||
while true; do
|
||||
CONCLUSION=$(gh api \
|
||||
"repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/jobs" \
|
||||
--jq '.jobs[] | select(.name == "verify") | .conclusion // empty')
|
||||
if [ -n "$CONCLUSION" ]; then
|
||||
echo "Verify conclusion: $CONCLUSION"
|
||||
[ "$CONCLUSION" = "success" ] && exit 0
|
||||
JOBS=$(gh api \
|
||||
"repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/jobs" --paginate)
|
||||
VERIFY=$(echo "$JOBS" | jq -r '.jobs[] | select(.name == "verify") | .conclusion // empty')
|
||||
BAREMETAL=$(echo "$JOBS" | jq -r '.jobs[] | select(.name | startswith("baremetal")) | .conclusion // empty')
|
||||
if [ -n "$VERIFY" ] && [ -n "$BAREMETAL" ]; then
|
||||
echo "verify=$VERIFY baremetal=$BAREMETAL"
|
||||
{ [ "$VERIFY" = "success" ] && [ "$BAREMETAL" = "success" ]; } && exit 0
|
||||
exit 1
|
||||
fi
|
||||
echo "Verify still running, retrying in 30s..."
|
||||
echo "Still waiting (verify=${VERIFY:-pending} baremetal=${BAREMETAL:-pending}), retry 30s..."
|
||||
sleep 30
|
||||
done
|
||||
|
||||
@@ -327,14 +370,13 @@ jobs:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.CF_R2_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.CF_R2_SECRET_ACCESS_KEY }}
|
||||
CF_R2_ENDPOINT_URL: ${{ secrets.CF_R2_ENDPOINT_URL }}
|
||||
VERSION: ${{ github.ref_name }}
|
||||
run: |
|
||||
aws --endpoint-url="${CF_R2_ENDPOINT_URL}" \
|
||||
s3 cp "mulga/scripts/iso-builder/dist/spinifex-${VERSION}.iso" \
|
||||
"s3://spinifex-iso/spinifex-${VERSION}.iso"
|
||||
s3 cp "mulga/scripts/iso-builder/dist/spinifex.iso" \
|
||||
"s3://spinifex-iso/spinifex.iso"
|
||||
aws --endpoint-url="${CF_R2_ENDPOINT_URL}" \
|
||||
s3 cp "mulga/scripts/iso-builder/dist/spinifex-${VERSION}.iso.sha256" \
|
||||
"s3://spinifex-iso/spinifex-${VERSION}.iso.sha256"
|
||||
s3 cp "mulga/scripts/iso-builder/dist/spinifex.iso.sha256" \
|
||||
"s3://spinifex-iso/spinifex.iso.sha256"
|
||||
|
||||
- name: Mark ISO failed
|
||||
if: always() && (steps.build-iso.outcome == 'failure' || steps.upload.outcome == 'failure')
|
||||
|
||||
@@ -73,24 +73,27 @@ build-lb-agent:
|
||||
@echo -e "\n....Building lb-agent (static)"
|
||||
CGO_ENABLED=0 GOFIPS140=v1.0.0 go build -ldflags "-s -w" -o ./bin/lb-agent cmd/lb-agent/main.go
|
||||
|
||||
build-system-image: ## Build a system image from manifest (use IMAGE=lb or IMAGE=eks-server)
|
||||
build-system-image: ## Build a system image from manifest (use IMAGE=lb or IMAGE=eks-node)
|
||||
ifndef IMAGE
|
||||
$(error IMAGE is required. Usage: make build-system-image IMAGE=lb)
|
||||
endif
|
||||
@if [ -f scripts/images/$(IMAGE).conf ]; then \
|
||||
./scripts/build-system-image.sh scripts/images/$(IMAGE).conf; \
|
||||
./scripts/build-system-image.sh scripts/images/$(IMAGE).conf $(if $(IMPORT),--import); \
|
||||
elif [ -f scripts/images/$(IMAGE)/manifest.conf ]; then \
|
||||
./scripts/build-system-image.sh scripts/images/$(IMAGE)/manifest.conf; \
|
||||
./scripts/build-system-image.sh scripts/images/$(IMAGE)/manifest.conf $(if $(IMPORT),--import); \
|
||||
else \
|
||||
echo "ERROR: no manifest at scripts/images/$(IMAGE).conf or scripts/images/$(IMAGE)/manifest.conf"; \
|
||||
exit 1; \
|
||||
fi
|
||||
|
||||
build-eks-server-image: ## Build the eks-server AMI (K3s control-plane variant)
|
||||
$(MAKE) build-system-image IMAGE=eks-server
|
||||
build-eks-node-image: ## Build the unified eks-node AMI (K3s server+agent; role at first boot; IMPORT=1 to register)
|
||||
$(MAKE) build-system-image IMAGE=eks-node
|
||||
|
||||
build-eks-agent-image: ## Build the eks-agent AMI (K3s worker variant)
|
||||
$(MAKE) build-system-image IMAGE=eks-agent
|
||||
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
|
||||
@@ -338,9 +341,20 @@ ansible-dev-version:
|
||||
ansible-dev-vpc:
|
||||
cd scripts/ansible && ansible-playbook playbooks/dev-vpc.yml $(ANSIBLE_EXTRA)
|
||||
|
||||
.PHONY: build build-ui build-installer build-lb-agent build-system-image build-eks-server-image build-eks-agent-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 \
|
||||
# Multi-node cluster bootstrap against a tofu env (env1, env2, bryn, etc).
|
||||
# Inventory is generated from scripts/tofu-cluster/envs/<env>.tfvars.
|
||||
# make ansible-cluster-bootstrap ENV=env1 POOL=192.168.1.150-192.168.1.159
|
||||
ENV ?=
|
||||
POOL ?=
|
||||
ansible-cluster-bootstrap:
|
||||
@test -n "$(ENV)" || { echo "ENV=<env> required (e.g. env1, env2)"; exit 64; }
|
||||
cd scripts/ansible && CLUSTER_ENV=$(ENV) ansible-playbook -i inventory/tofu.py playbooks/cluster-bootstrap.yml \
|
||||
-e cluster_env=$(ENV) \
|
||||
$(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 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 \
|
||||
distro distro-amd64 distro-arm64 distro-clean \
|
||||
ansible-dev-preflight ansible-dev-teardown ansible-dev-install ansible-dev-reset ansible-dev-deploy ansible-dev-status ansible-dev-logs ansible-dev-snapshot ansible-dev-restore ansible-dev-version ansible-dev-vpc
|
||||
distro distro-amd64 distro-arm64 distro-clean
|
||||
|
||||
+45
-7
@@ -6,20 +6,58 @@ mount -t devtmpfs dev /dev
|
||||
# virtio_mmio is built-in (CONFIG_VIRTIO_MMIO=y) so virtio-net devices are
|
||||
# already enumerated by the time PID 1 runs — no modprobe needed.
|
||||
# qemu_fw_cfg and virtio_net are =m in stock Alpine linux-virt, so load them.
|
||||
modprobe qemu_fw_cfg 2>/dev/null || modprobe fw_cfg_sysfs 2>/dev/null || true
|
||||
modprobe virtio_net 2>/dev/null || true
|
||||
#
|
||||
# modprobe needs a usable modules.dep; when the image is built on a host whose
|
||||
# depmod can't produce one, busybox modprobe silently no-ops and the module
|
||||
# never loads — fw_cfg then never appears and init panics. insmod by explicit
|
||||
# path needs no modules.dep, so fall back to it. Handles .ko and .ko.gz.
|
||||
load_mod() {
|
||||
modprobe "$1" 2>/dev/null && return 0
|
||||
for ko in $(find /lib/modules -name "$1.ko" -o -name "$1.ko.gz" 2>/dev/null); do
|
||||
insmod "$ko" 2>/dev/null && return 0
|
||||
case "$ko" in
|
||||
*.gz) gunzip -c "$ko" >/tmp/mod.ko 2>/dev/null \
|
||||
&& insmod /tmp/mod.ko 2>/dev/null && return 0 ;;
|
||||
esac
|
||||
done
|
||||
return 1
|
||||
}
|
||||
load_mod qemu_fw_cfg || load_mod fw_cfg_sysfs || true
|
||||
# virtio_net depends on net_failover, which depends on failover. The load_mod
|
||||
# insmod fallback loads a single .ko with no dependency resolution, and
|
||||
# modules.dep is absent when the image is built on a kmod-less host (depmod
|
||||
# skipped), so modprobe can't resolve the chain either. Load it explicitly
|
||||
# bottom-up so the NIC comes up regardless of how the image was built —
|
||||
# without this the agent has no network and the LB hangs in provisioning.
|
||||
load_mod failover || true
|
||||
load_mod net_failover || true
|
||||
load_mod virtio_net || true
|
||||
|
||||
for i in 1 2 3 4 5; do
|
||||
[ -f /sys/firmware/qemu_fw_cfg/by_name/opt/spinifex/netcfg/raw ] && break
|
||||
sleep 0.02
|
||||
NETCFG=/sys/firmware/qemu_fw_cfg/by_name/opt/spinifex/netcfg/raw
|
||||
i=0
|
||||
while [ "$i" -lt 25 ]; do
|
||||
[ -f "$NETCFG" ] && break
|
||||
sleep 0.04
|
||||
i=$((i + 1))
|
||||
done
|
||||
|
||||
if [ ! -f /sys/firmware/qemu_fw_cfg/by_name/opt/spinifex/netcfg/raw ]; then
|
||||
if [ ! -f "$NETCFG" ]; then
|
||||
# Dump host/guest fw_cfg state to the serial console before init exits so a
|
||||
# "netcfg not available" panic is diagnosable from the captured console log
|
||||
# (module-not-loaded vs qemu-not-exposing-by_name vs wrong machine type).
|
||||
echo "[init] FATAL: fw_cfg netcfg not available" >&2
|
||||
echo "[init] dmi: vendor=$(cat /sys/class/dmi/id/sys_vendor 2>/dev/null) product=$(cat /sys/class/dmi/id/product_name 2>/dev/null) bios=$(cat /sys/class/dmi/id/bios_version 2>/dev/null)" >&2
|
||||
echo "[init] qemu_fw_cfg dir present: $([ -d /sys/firmware/qemu_fw_cfg ] && echo yes || echo NO)" >&2
|
||||
echo "[init] modules: $(lsmod 2>/dev/null | awk 'NR>1{print $1}' | tr '\n' ',')" >&2
|
||||
echo "[init] by_name listing:" >&2
|
||||
ls -la /sys/firmware/qemu_fw_cfg/by_name/ 2>&1 | sed 's/^/[init] /' >&2
|
||||
ls -la /sys/firmware/qemu_fw_cfg/by_name/opt/ 2>&1 | sed 's/^/[init] /' >&2
|
||||
echo "[init] dmesg fw_cfg:" >&2
|
||||
dmesg 2>/dev/null | grep -i fw_cfg | sed 's/^/[init] /' >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
. /sys/firmware/qemu_fw_cfg/by_name/opt/spinifex/netcfg/raw
|
||||
. "$NETCFG"
|
||||
|
||||
for n in 0 1 2 3 4 5; do
|
||||
eval mac=\$NIC${n}_MAC
|
||||
|
||||
@@ -16,25 +16,34 @@ OOMScoreAdjust=-500
|
||||
Environment=SPINIFEX_CONFIG_PATH=/etc/spinifex/spinifex.toml
|
||||
Environment=SPINIFEX_BASE_DIR=/var/lib/spinifex/vpcd/
|
||||
|
||||
# Filesystem access — NoNewPrivileges omitted (vpcd uses sudo for ovs-vsctl, ovn-nbctl, systemctl)
|
||||
ProtectSystem=full
|
||||
# vpcd MUST share the HOST mount namespace. IMDS binds one listener per VPC inside
|
||||
# a per-VPC netns entered in-process via setns(CLONE_NEWNET); the /run/netns/<vpc>
|
||||
# bind-mount that `ip netns add` creates must be host-visible (for diagnostics) and
|
||||
# must survive a vpcd restart (so the listener can rebind). ANY filesystem-sandbox
|
||||
# directive — ProtectSystem / ProtectHome / PrivateTmp / ProtectKernel* /
|
||||
# ProtectControlGroups / ProtectProc / Read{Only,Write}Paths — forks a PRIVATE
|
||||
# mount namespace that traps that bind-mount inside vpcd, leaving the host a stale
|
||||
# handle that fails setns(2) with EINVAL (CI runs 26920490454, 26922587471). Those
|
||||
# directives are therefore omitted by design, and so is the MountFlags=shared /
|
||||
# spinifex-netns.service propagation workaround they required.
|
||||
#
|
||||
# Boundary that REMAINS: DAC (User=spinifex-vpcd) plus the seccomp / capability /
|
||||
# namespace / address-family restrictions below. The durable fix that restores
|
||||
# filesystem sandboxing AND drops CAP_SYS_ADMIN is the process-per-netns model
|
||||
# (`ip netns exec` helper instead of in-process setns) — see
|
||||
# docs/development/improvements/imds-l2-datapath.md Phase 5.
|
||||
PrivateDevices=no
|
||||
ReadOnlyPaths=/etc/spinifex/spinifex.toml /etc/spinifex/ca.pem
|
||||
ReadWritePaths=/var/lib/spinifex/vpcd /var/log/spinifex
|
||||
|
||||
# Hardening
|
||||
ProtectHome=yes
|
||||
PrivateTmp=yes
|
||||
# Hardening that does NOT fork a mount namespace (kept)
|
||||
RestrictSUIDSGID=no
|
||||
LockPersonality=yes
|
||||
RestrictRealtime=yes
|
||||
RestrictNamespaces=yes
|
||||
ProtectClock=yes
|
||||
ProtectKernelTunables=yes
|
||||
ProtectKernelModules=yes
|
||||
ProtectKernelLogs=yes
|
||||
ProtectControlGroups=yes
|
||||
ProtectProc=invisible
|
||||
# IMDS enters the per-VPC netns via setns(CLONE_NEWNET); the `ip -n <netns>` /
|
||||
# `ip netns exec` plumbing also unshares a mount namespace (CLONE_NEWNS, to give
|
||||
# the netns its own /sys), so `mnt` must be allowlisted alongside `net` or the
|
||||
# first in-netns command fails with EPERM.
|
||||
RestrictNamespaces=net mnt
|
||||
SystemCallArchitectures=native
|
||||
MemoryDenyWriteExecute=no
|
||||
# AF_PACKET: required by the in-process DHCP client (github.com/insomniacslk/dhcp
|
||||
@@ -44,9 +53,10 @@ RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK AF_PACKET
|
||||
# CAP_NET_RAW: open AF_PACKET sockets.
|
||||
# CAP_NET_BIND_SERVICE: covers unicast RENEW to UDP :68.
|
||||
# CAP_NET_ADMIN: already used for ovs-vsctl / ip link wiring on the bridge.
|
||||
# CAP_SYS_ADMIN: setns(CLONE_NEWNET) into per-VPC IMDS netns to bind the listener.
|
||||
# Note, disabled for now, sudo issue with ovs
|
||||
AmbientCapabilities=CAP_NET_ADMIN CAP_NET_RAW CAP_NET_BIND_SERVICE CAP_SETUID CAP_SETGID
|
||||
CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_RAW CAP_NET_BIND_SERVICE CAP_SETUID CAP_SETGID
|
||||
AmbientCapabilities=CAP_NET_ADMIN CAP_NET_RAW CAP_NET_BIND_SERVICE CAP_SETUID CAP_SETGID CAP_SYS_ADMIN
|
||||
CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_RAW CAP_NET_BIND_SERVICE CAP_SETUID CAP_SETGID CAP_SYS_ADMIN
|
||||
NoNewPrivileges=no
|
||||
ProtectHostname=yes
|
||||
KeyringMode=private
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
// eks-gateway-publish runs inside the EKS K3s control-plane VM and relays a
|
||||
// single publication to the host through the AWS gateway, instead of dialing
|
||||
// core NATS directly.
|
||||
//
|
||||
// It reads a JSON payload on stdin, wraps it as
|
||||
// {accountId, channel, kind, payload}, SigV4-signs (service "eks") an HTTPS
|
||||
// POST to {gateway}/clusters/{cluster}/internal-publish, and retries with
|
||||
// backoff until the gateway returns 2xx or the attempt budget is exhausted —
|
||||
// so a degraded link surfaces as a non-zero exit rather than a silently
|
||||
// dropped message (the failure mode of fire-and-forget `nats pub`).
|
||||
//
|
||||
// Usage:
|
||||
//
|
||||
// echo '{"token":"..."}' | eks-gateway-publish -channel bootstrap -kind k3s-bootstrap-token
|
||||
// kubectl ... | eks-gateway-publish -channel state
|
||||
//
|
||||
// Flags default to environment variables seeded by cloud-init:
|
||||
// EKS_GATEWAY_URL, EKS_GATEWAY_CA, EKS_ACCESS_KEY, EKS_SECRET_KEY, EKS_REGION,
|
||||
// EKS_ACCOUNT_ID, EKS_CLUSTER_NAME.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/mulgadc/spinifex/internal/eksgw"
|
||||
|
||||
_ "github.com/mulgadc/spinifex/internal/fipsboot"
|
||||
)
|
||||
|
||||
const (
|
||||
// Retry budget: the control plane reaches readiness before this runs, so a
|
||||
// failing POST means a degraded link, not a cold start. Bounded so a stuck
|
||||
// boot still terminates the OpenRC service.
|
||||
maxAttempts = 30
|
||||
retryDelay = 5 * time.Second
|
||||
)
|
||||
|
||||
type publishBody struct {
|
||||
AccountID string `json:"accountId"`
|
||||
Channel string `json:"channel"`
|
||||
Kind string `json:"kind"`
|
||||
Payload json.RawMessage `json:"payload"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
var (
|
||||
gatewayURL string
|
||||
gatewayCA string
|
||||
accessKey string
|
||||
secretKey string
|
||||
region string
|
||||
accountID string
|
||||
clusterName string
|
||||
channel string
|
||||
kind string
|
||||
)
|
||||
|
||||
flag.StringVar(&gatewayURL, "gateway", os.Getenv("EKS_GATEWAY_URL"), "Gateway URL (e.g. https://10.15.8.1:9999)")
|
||||
flag.StringVar(&gatewayCA, "gateway-ca", os.Getenv("EKS_GATEWAY_CA"), "Path to gateway TLS CA PEM (optional; falls back to system trust)")
|
||||
flag.StringVar(&accessKey, "access-key", os.Getenv("EKS_ACCESS_KEY"), "AWS access key ID")
|
||||
flag.StringVar(&secretKey, "secret-key", os.Getenv("EKS_SECRET_KEY"), "AWS secret access key")
|
||||
flag.StringVar(®ion, "region", os.Getenv("EKS_REGION"), "AWS region for SigV4 signing")
|
||||
flag.StringVar(&accountID, "account-id", os.Getenv("EKS_ACCOUNT_ID"), "Cluster account ID")
|
||||
flag.StringVar(&clusterName, "cluster", os.Getenv("EKS_CLUSTER_NAME"), "Cluster name")
|
||||
flag.StringVar(&channel, "channel", "", "Publish channel: bootstrap|state")
|
||||
flag.StringVar(&kind, "kind", "", "Bootstrap subject kind (bootstrap channel only)")
|
||||
flag.Parse()
|
||||
|
||||
switch {
|
||||
case accountID == "":
|
||||
fatal("--account-id is required (or set EKS_ACCOUNT_ID)")
|
||||
case clusterName == "":
|
||||
fatal("--cluster is required (or set EKS_CLUSTER_NAME)")
|
||||
case channel != "bootstrap" && channel != "state":
|
||||
fatal("--channel must be bootstrap or state")
|
||||
case channel == "bootstrap" && kind == "":
|
||||
fatal("--kind is required for the bootstrap channel")
|
||||
}
|
||||
|
||||
payload, err := io.ReadAll(os.Stdin)
|
||||
if err != nil {
|
||||
fatal(fmt.Sprintf("read stdin payload: %v", err))
|
||||
}
|
||||
if len(bytes.TrimSpace(payload)) == 0 {
|
||||
fatal("empty stdin payload")
|
||||
}
|
||||
if !json.Valid(payload) {
|
||||
fatal("stdin payload is not valid JSON")
|
||||
}
|
||||
|
||||
body, err := json.Marshal(publishBody{
|
||||
AccountID: accountID,
|
||||
Channel: channel,
|
||||
Kind: kind,
|
||||
Payload: json.RawMessage(payload),
|
||||
})
|
||||
if err != nil {
|
||||
fatal(fmt.Sprintf("marshal request body: %v", err))
|
||||
}
|
||||
|
||||
client, err := eksgw.New(gatewayURL, gatewayCA, accessKey, secretKey, region)
|
||||
if err != nil {
|
||||
fatal(fmt.Sprintf("build gateway client: %v", err))
|
||||
}
|
||||
path := "/clusters/" + clusterName + "/internal-publish"
|
||||
|
||||
var lastErr error
|
||||
for attempt := 1; attempt <= maxAttempts; attempt++ {
|
||||
if _, err := client.Post(path, body); err != nil {
|
||||
lastErr = err
|
||||
slog.Warn("eks-gateway-publish: attempt failed",
|
||||
"channel", channel, "kind", kind, "attempt", attempt, "err", err)
|
||||
if attempt < maxAttempts {
|
||||
time.Sleep(retryDelay)
|
||||
}
|
||||
continue
|
||||
}
|
||||
slog.Info("eks-gateway-publish: published", "channel", channel, "kind", kind)
|
||||
return
|
||||
}
|
||||
fatal(fmt.Sprintf("publish failed after %d attempts: %v", maxAttempts, lastErr))
|
||||
}
|
||||
|
||||
func fatal(msg string) {
|
||||
slog.Error("eks-gateway-publish: " + msg)
|
||||
os.Exit(1)
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/base64"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"net"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/mulgadc/spinifex/internal/tlsconfig"
|
||||
)
|
||||
|
||||
// tlsCertificate is the webhook's loopback serving certificate.
|
||||
type tlsCertificate struct {
|
||||
cert tls.Certificate
|
||||
}
|
||||
|
||||
func (c tlsCertificate) serverTLSConfig() *tls.Config {
|
||||
return &tls.Config{
|
||||
Certificates: []tls.Certificate{c.cert},
|
||||
MinVersion: tls.VersionTLS13,
|
||||
CurvePreferences: tlsconfig.Curves,
|
||||
}
|
||||
}
|
||||
|
||||
// ensureServingCert loads the persisted self-signed serving cert/key, or mints
|
||||
// a fresh ECDSA-P256 pair (SAN 127.0.0.1 / localhost) and persists it. Reusing
|
||||
// across restarts keeps the CA the apiserver loaded into its webhook kubeconfig
|
||||
// valid even if the webhook process is restarted. Returns the parsed cert and
|
||||
// the certificate PEM (for embedding as the kubeconfig CA bundle).
|
||||
func ensureServingCert(certPath, keyPath string) (tlsCertificate, []byte, error) {
|
||||
if certPEM, keyPEM, ok := readCertPair(certPath, keyPath); ok {
|
||||
cert, err := tls.X509KeyPair(certPEM, keyPEM)
|
||||
if err == nil {
|
||||
return tlsCertificate{cert: cert}, certPEM, nil
|
||||
}
|
||||
// Fall through to regenerate on a corrupt/incompatible persisted pair.
|
||||
}
|
||||
|
||||
certPEM, keyPEM, err := generateSelfSigned()
|
||||
if err != nil {
|
||||
return tlsCertificate{}, nil, err
|
||||
}
|
||||
if err := os.WriteFile(certPath, certPEM, 0o644); err != nil {
|
||||
return tlsCertificate{}, nil, fmt.Errorf("write cert %s: %w", certPath, err)
|
||||
}
|
||||
if err := os.WriteFile(keyPath, keyPEM, 0o600); err != nil {
|
||||
return tlsCertificate{}, nil, fmt.Errorf("write key %s: %w", keyPath, err)
|
||||
}
|
||||
cert, err := tls.X509KeyPair(certPEM, keyPEM)
|
||||
if err != nil {
|
||||
return tlsCertificate{}, nil, fmt.Errorf("parse minted keypair: %w", err)
|
||||
}
|
||||
return tlsCertificate{cert: cert}, certPEM, nil
|
||||
}
|
||||
|
||||
func readCertPair(certPath, keyPath string) (certPEM, keyPEM []byte, ok bool) {
|
||||
c, err := os.ReadFile(certPath)
|
||||
if err != nil {
|
||||
return nil, nil, false
|
||||
}
|
||||
k, err := os.ReadFile(keyPath)
|
||||
if err != nil {
|
||||
return nil, nil, false
|
||||
}
|
||||
return c, k, true
|
||||
}
|
||||
|
||||
func generateSelfSigned() (certPEM, keyPEM []byte, err error) {
|
||||
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("generate key: %w", err)
|
||||
}
|
||||
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("serial: %w", err)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
tmpl := x509.Certificate{
|
||||
SerialNumber: serial,
|
||||
Subject: pkix.Name{CommonName: "eks-token-webhook"},
|
||||
NotBefore: now.Add(-time.Hour),
|
||||
NotAfter: now.AddDate(10, 0, 0),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment | x509.KeyUsageCertSign,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
BasicConstraintsValid: true,
|
||||
IsCA: true,
|
||||
IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1), net.IPv6loopback},
|
||||
DNSNames: []string{"localhost"},
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &priv.PublicKey, priv)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("create cert: %w", err)
|
||||
}
|
||||
keyDER, err := x509.MarshalECPrivateKey(priv)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("marshal key: %w", err)
|
||||
}
|
||||
certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
|
||||
keyPEM = pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
|
||||
return certPEM, keyPEM, nil
|
||||
}
|
||||
|
||||
// writeAPIServerKubeconfig writes the kubeconfig kube-apiserver loads via
|
||||
// --authentication-token-webhook-config-file. It points the apiserver at the
|
||||
// loopback webhook and trusts its self-signed serving cert as the CA bundle.
|
||||
func writeAPIServerKubeconfig(path, addr string, certPEM []byte) error {
|
||||
caB64 := base64.StdEncoding.EncodeToString(certPEM)
|
||||
kubeconfig := fmt.Sprintf(`apiVersion: v1
|
||||
kind: Config
|
||||
clusters:
|
||||
- name: mulga-eks-token-webhook
|
||||
cluster:
|
||||
certificate-authority-data: %s
|
||||
server: https://%s/authenticate
|
||||
users:
|
||||
- name: kube-apiserver
|
||||
contexts:
|
||||
- name: webhook
|
||||
context:
|
||||
cluster: mulga-eks-token-webhook
|
||||
user: kube-apiserver
|
||||
current-context: webhook
|
||||
`, caB64, addr)
|
||||
if err := os.WriteFile(path, []byte(kubeconfig), 0o600); err != nil {
|
||||
return fmt.Errorf("write %s: %w", path, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
+207
-18
@@ -1,38 +1,227 @@
|
||||
// eks-token-webhook is the K8s TokenReview webhook authenticator baked into
|
||||
// the eks-server AMI. It decodes the SigV4-presigned GetCallerIdentity URL
|
||||
// produced by `aws eks get-token`, enforces the X-K8s-Aws-Id cross-cluster
|
||||
// pin, calls Mulga STS over NATS, and resolves the principal to a TokenReview
|
||||
// response via the cluster's AccessEntry KV.
|
||||
// the eks-server AMI. Per kubectl call the kube-apiserver POSTs the bearer
|
||||
// token (produced by `aws eks get-token`); the webhook relays it through the
|
||||
// AWS gateway broker (SigV4 HTTPS POST to /clusters/{name}/token-review), which
|
||||
// host-side runs the STS verify (cross-cluster x-k8s-aws-id pin) + AccessEntry
|
||||
// KV lookup and returns the resolved identity. The webhook never speaks core
|
||||
// NATS — same gateway-broker model as ELBv2's lb-agent and eks-gateway-publish.
|
||||
//
|
||||
// This is a placeholder build that returns 503 Service Unavailable on every
|
||||
// path. It exists so the eks-server AMI build succeeds (INSTALL_BINARIES is
|
||||
// satisfied) and the K3s server VM boots cleanly. Real implementation lands
|
||||
// with bead mulga-cs-eks-6c.
|
||||
// It binds 127.0.0.1 only; the kube-apiserver is the sole client (loopback).
|
||||
// On startup it writes the apiserver webhook kubeconfig (with its self-signed
|
||||
// serving CA) so k3s can be pointed at it via
|
||||
// --authentication-token-webhook-config-file.
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/mulgadc/spinifex/internal/eksgw"
|
||||
handlers_eks "github.com/mulgadc/spinifex/spinifex/handlers/eks"
|
||||
)
|
||||
|
||||
// config is the webhook's runtime configuration, sourced from the first-boot
|
||||
// env (/etc/spinifex-eks/first-boot.env) cloud-init seeds onto the VM.
|
||||
type config struct {
|
||||
addr string
|
||||
gatewayURL string
|
||||
gatewayCA string
|
||||
accessKey string
|
||||
secretKey string
|
||||
region string
|
||||
accountID string
|
||||
clusterName string
|
||||
certPath string
|
||||
keyPath string
|
||||
kubeconfig string
|
||||
}
|
||||
|
||||
func loadConfig() (config, error) {
|
||||
c := config{
|
||||
addr: flagAddr,
|
||||
gatewayURL: os.Getenv("EKS_GATEWAY_URL"),
|
||||
gatewayCA: os.Getenv("EKS_GATEWAY_CA"),
|
||||
accessKey: os.Getenv("EKS_ACCESS_KEY"),
|
||||
secretKey: os.Getenv("EKS_SECRET_KEY"),
|
||||
region: os.Getenv("EKS_REGION"),
|
||||
accountID: os.Getenv("EKS_ACCOUNT_ID"),
|
||||
clusterName: os.Getenv("EKS_CLUSTER_NAME"),
|
||||
certPath: envOr("EKS_WEBHOOK_CERT", "/etc/spinifex-eks/token-webhook.crt"),
|
||||
keyPath: envOr("EKS_WEBHOOK_KEY", "/etc/spinifex-eks/token-webhook.key"),
|
||||
kubeconfig: envOr("EKS_WEBHOOK_KUBECONFIG", "/etc/spinifex-eks/token-webhook.kubeconfig"),
|
||||
}
|
||||
switch {
|
||||
case c.gatewayURL == "":
|
||||
return c, errors.New("EKS_GATEWAY_URL not set")
|
||||
case c.accessKey == "" || c.secretKey == "":
|
||||
return c, errors.New("EKS_ACCESS_KEY / EKS_SECRET_KEY not set")
|
||||
case c.accountID == "":
|
||||
return c, errors.New("EKS_ACCOUNT_ID not set")
|
||||
case c.clusterName == "":
|
||||
return c, errors.New("EKS_CLUSTER_NAME not set")
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func envOr(key, def string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return def
|
||||
}
|
||||
|
||||
var flagAddr string
|
||||
|
||||
func main() {
|
||||
addr := flag.String("addr", "127.0.0.1:8443", "listen address")
|
||||
flag.StringVar(&flagAddr, "addr", "127.0.0.1:8443", "listen address (loopback only)")
|
||||
flag.Parse()
|
||||
|
||||
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo})))
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Retry-After", "30")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
http.Error(w, `{"error":"eks-token-webhook stub: real implementation not yet deployed"}`, http.StatusServiceUnavailable)
|
||||
})
|
||||
cfg, err := loadConfig()
|
||||
if err != nil {
|
||||
slog.Error("eks-token-webhook config error", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
slog.Info("eks-token-webhook stub starting", "addr", *addr)
|
||||
if err := http.ListenAndServe(*addr, mux); err != nil {
|
||||
slog.Error("listen failed", "err", err)
|
||||
if err := run(cfg); err != nil {
|
||||
slog.Error("eks-token-webhook fatal", "err", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(cfg config) error {
|
||||
// Serving cert + apiserver kubeconfig. Persisted and reused across restarts
|
||||
// so a webhook crash does not invalidate the CA the apiserver already loaded.
|
||||
tlsCert, certPEM, err := ensureServingCert(cfg.certPath, cfg.keyPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("serving cert: %w", err)
|
||||
}
|
||||
if err := writeAPIServerKubeconfig(cfg.kubeconfig, cfg.addr, certPEM); err != nil {
|
||||
return fmt.Errorf("write apiserver kubeconfig: %w", err)
|
||||
}
|
||||
|
||||
client, err := eksgw.New(cfg.gatewayURL, cfg.gatewayCA, cfg.accessKey, cfg.secretKey, cfg.region)
|
||||
if err != nil {
|
||||
return fmt.Errorf("build gateway client: %w", err)
|
||||
}
|
||||
|
||||
authr := &authenticator{
|
||||
accountID: cfg.accountID,
|
||||
clusterName: cfg.clusterName,
|
||||
review: gatewayReviewer(client, cfg.clusterName, cfg.accountID),
|
||||
}
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
})
|
||||
mux.HandleFunc("/authenticate", authr.handle)
|
||||
|
||||
server := newServer(cfg.addr, mux, tlsCert)
|
||||
slog.Info("eks-token-webhook listening", "addr", cfg.addr, "cluster", cfg.clusterName)
|
||||
if err := server.ListenAndServeTLS("", ""); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
return fmt.Errorf("serve: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// tokenReviewRequest is the body POSTed to /clusters/{name}/token-review. The
|
||||
// webhook holds system SigV4 creds (system account, not the cluster account),
|
||||
// so accountId names the cluster account explicitly — same as PublishInternal.
|
||||
type tokenReviewRequest struct {
|
||||
AccountID string `json:"accountId"`
|
||||
Token string `json:"token"`
|
||||
}
|
||||
|
||||
// gatewayReviewer returns the per-call relay: SigV4-POST {accountId,token} to
|
||||
// the gateway and decode the resolved identity. A non-2xx / transport error is
|
||||
// surfaced (handle() turns it into a 5xx so the apiserver retries) rather than a
|
||||
// silent deny, distinguishing "webhook broker down" from "token rejected".
|
||||
func gatewayReviewer(client *eksgw.Client, clusterName, accountID string) func(string) (handlers_eks.WebhookTokenReviewResult, error) {
|
||||
path := "/clusters/" + clusterName + "/token-review"
|
||||
return func(token string) (handlers_eks.WebhookTokenReviewResult, error) {
|
||||
body, err := json.Marshal(tokenReviewRequest{AccountID: accountID, Token: token})
|
||||
if err != nil {
|
||||
return handlers_eks.WebhookTokenReviewResult{}, fmt.Errorf("marshal request: %w", err)
|
||||
}
|
||||
respBody, err := client.Post(path, body)
|
||||
if err != nil {
|
||||
return handlers_eks.WebhookTokenReviewResult{}, err
|
||||
}
|
||||
var res handlers_eks.WebhookTokenReviewResult
|
||||
if err := json.Unmarshal(respBody, &res); err != nil {
|
||||
return handlers_eks.WebhookTokenReviewResult{}, fmt.Errorf("decode response: %w", err)
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
}
|
||||
|
||||
// authenticator resolves a TokenReview by relaying to the gateway broker.
|
||||
type authenticator struct {
|
||||
accountID string
|
||||
clusterName string
|
||||
review func(token string) (handlers_eks.WebhookTokenReviewResult, error)
|
||||
}
|
||||
|
||||
func (a *authenticator) handle(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var review tokenReview
|
||||
if err := json.NewDecoder(r.Body).Decode(&review); err != nil {
|
||||
http.Error(w, "bad request", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
res, err := a.review(review.Spec.Token)
|
||||
if err != nil {
|
||||
// Broker fault (gateway down, transport error): a 5xx tells the apiserver
|
||||
// to retry rather than treating a transient outage as a hard deny.
|
||||
slog.Error("TokenReview relay failed", "cluster", a.clusterName, "err", err)
|
||||
http.Error(w, "token review unavailable", http.StatusServiceUnavailable)
|
||||
return
|
||||
}
|
||||
|
||||
status := tokenReviewStatus{Authenticated: res.Authenticated}
|
||||
if res.Authenticated {
|
||||
status.User = userInfo{
|
||||
Username: res.Username,
|
||||
UID: res.UID,
|
||||
Groups: res.Groups,
|
||||
}
|
||||
// Confirm the token for the audiences the apiserver asked about; an empty
|
||||
// status.audiences is rejected when --api-audiences is configured.
|
||||
status.Audiences = review.Spec.Audiences
|
||||
}
|
||||
slog.Info("TokenReview decision", "authenticated", status.Authenticated, "username", status.User.Username, "audiences", status.Audiences)
|
||||
|
||||
resp := tokenReview{
|
||||
APIVersion: "authentication.k8s.io/v1",
|
||||
Kind: "TokenReview",
|
||||
Status: status,
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(resp); err != nil {
|
||||
slog.Error("encode TokenReview response", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// newServer builds the loopback TLS server. Split out so tests can construct it
|
||||
// without binding a port.
|
||||
func newServer(addr string, handler http.Handler, cert tlsCertificate) *http.Server {
|
||||
return &http.Server{
|
||||
Addr: addr,
|
||||
Handler: handler,
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
TLSConfig: cert.serverTLSConfig(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
handlers_eks "github.com/mulgadc/spinifex/spinifex/handlers/eks"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// postReview drives authenticator.handle with a TokenReview carrying token and
|
||||
// audiences, returning the decoded response status and HTTP code.
|
||||
func postReview(t *testing.T, a *authenticator, token string, audiences []string) (tokenReviewStatus, int) {
|
||||
t.Helper()
|
||||
body, err := json.Marshal(tokenReview{
|
||||
APIVersion: "authentication.k8s.io/v1",
|
||||
Kind: "TokenReview",
|
||||
Spec: tokenReviewSpec{Token: token, Audiences: audiences},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/authenticate", strings.NewReader(string(body)))
|
||||
w := httptest.NewRecorder()
|
||||
a.handle(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
return tokenReviewStatus{}, w.Code
|
||||
}
|
||||
var resp tokenReview
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
|
||||
return resp.Status, w.Code
|
||||
}
|
||||
|
||||
func TestHandle_GrantsAndStampsAudiences(t *testing.T) {
|
||||
a := &authenticator{
|
||||
accountID: "111122223333",
|
||||
clusterName: "alpha",
|
||||
review: func(string) (handlers_eks.WebhookTokenReviewResult, error) {
|
||||
return handlers_eks.WebhookTokenReviewResult{
|
||||
Authenticated: true,
|
||||
Username: "arn:aws:iam::111122223333:role/admin",
|
||||
UID: "AROAEXAMPLE:session",
|
||||
Groups: []string{"system:masters"},
|
||||
}, nil
|
||||
},
|
||||
}
|
||||
|
||||
status, code := postReview(t, a, "tok", []string{"https://kubernetes.default.svc"})
|
||||
require.Equal(t, http.StatusOK, code)
|
||||
require.True(t, status.Authenticated)
|
||||
assert.Equal(t, "arn:aws:iam::111122223333:role/admin", status.User.Username)
|
||||
assert.Equal(t, "AROAEXAMPLE:session", status.User.UID)
|
||||
assert.Equal(t, []string{"system:masters"}, status.User.Groups)
|
||||
assert.Equal(t, []string{"https://kubernetes.default.svc"}, status.Audiences)
|
||||
}
|
||||
|
||||
func TestHandle_DeniesWithoutStampingAudiences(t *testing.T) {
|
||||
a := &authenticator{
|
||||
clusterName: "alpha",
|
||||
review: func(string) (handlers_eks.WebhookTokenReviewResult, error) {
|
||||
return handlers_eks.WebhookTokenReviewResult{Authenticated: false}, nil
|
||||
},
|
||||
}
|
||||
|
||||
status, code := postReview(t, a, "tok", []string{"https://kubernetes.default.svc"})
|
||||
require.Equal(t, http.StatusOK, code)
|
||||
assert.False(t, status.Authenticated)
|
||||
assert.Empty(t, status.User.Username)
|
||||
assert.Empty(t, status.Audiences, "unauthenticated review must not stamp audiences")
|
||||
}
|
||||
|
||||
// A broker fault is a 5xx (retryable), not a silent deny — a transient gateway
|
||||
// outage must not look identical to a rejected token.
|
||||
func TestHandle_BrokerFaultReturns5xx(t *testing.T) {
|
||||
a := &authenticator{
|
||||
clusterName: "alpha",
|
||||
review: func(string) (handlers_eks.WebhookTokenReviewResult, error) {
|
||||
return handlers_eks.WebhookTokenReviewResult{}, errors.New("gateway returned 503")
|
||||
},
|
||||
}
|
||||
|
||||
_, code := postReview(t, a, "tok", nil)
|
||||
assert.Equal(t, http.StatusServiceUnavailable, code)
|
||||
}
|
||||
|
||||
func TestEnsureServingCert_PersistsAndReuses(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
certPath := filepath.Join(dir, "wh.crt")
|
||||
keyPath := filepath.Join(dir, "wh.key")
|
||||
|
||||
c1, pem1, err := ensureServingCert(certPath, keyPath)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, pem1)
|
||||
require.NotNil(t, c1.serverTLSConfig())
|
||||
|
||||
// Second call reuses the persisted pair (same cert bytes).
|
||||
_, pem2, err := ensureServingCert(certPath, keyPath)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, pem1, pem2)
|
||||
}
|
||||
|
||||
func TestWriteAPIServerKubeconfig(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "wh.kubeconfig")
|
||||
certPEM := []byte("-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----\n")
|
||||
|
||||
require.NoError(t, writeAPIServerKubeconfig(path, "127.0.0.1:8443", certPEM))
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
require.NoError(t, err)
|
||||
body := string(data)
|
||||
assert.Contains(t, body, "server: https://127.0.0.1:8443/authenticate")
|
||||
assert.Contains(t, body, base64.StdEncoding.EncodeToString(certPEM))
|
||||
assert.Contains(t, body, "current-context: webhook")
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package main
|
||||
|
||||
// Minimal authentication.k8s.io/v1 TokenReview wire types. Hand-rolled to avoid
|
||||
// pulling k8s.io/api into the spinifex module for these few fields; the shapes
|
||||
// match what kube-apiserver sends and expects on the webhook contract.
|
||||
|
||||
type tokenReview struct {
|
||||
APIVersion string `json:"apiVersion"`
|
||||
Kind string `json:"kind"`
|
||||
Spec tokenReviewSpec `json:"spec,omitzero"`
|
||||
Status tokenReviewStatus `json:"status"`
|
||||
}
|
||||
|
||||
type tokenReviewSpec struct {
|
||||
Token string `json:"token"`
|
||||
Audiences []string `json:"audiences,omitempty"`
|
||||
}
|
||||
|
||||
type tokenReviewStatus struct {
|
||||
Authenticated bool `json:"authenticated"`
|
||||
User userInfo `json:"user,omitzero"`
|
||||
// Audiences echoes the request's spec.audiences on success. When the
|
||||
// apiserver runs with --api-audiences, it sends spec.audiences and discards
|
||||
// an authenticated=true response that does not confirm a matching audience —
|
||||
// so an empty Audiences here surfaces as a 401 despite a valid principal.
|
||||
Audiences []string `json:"audiences,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
type userInfo struct {
|
||||
Username string `json:"username,omitempty"`
|
||||
UID string `json:"uid,omitempty"`
|
||||
Groups []string `json:"groups,omitempty"`
|
||||
}
|
||||
@@ -325,6 +325,7 @@ func init() {
|
||||
imagesImportCmd.Flags().String("tmp-dir", os.TempDir(), "Temporary directory for image import processing")
|
||||
|
||||
imagesImportCmd.Flags().String("name", "", "Import specified image by name")
|
||||
imagesImportCmd.Flags().String("ami-name", "", "Override the registered AMI name (DescribeImages name). Defaults to ami-{distro}-{version}-{arch}. Use for locally-built appliances (e.g. --ami-name spinifex-eks-node).")
|
||||
imagesImportCmd.Flags().String("file", "", "Import file from specified path (raw, qcow2, compressed)")
|
||||
imagesImportCmd.Flags().String("distro", "", "Specified distro name (e.g debian)")
|
||||
imagesImportCmd.Flags().String("version", "", "Specified distro version (e.g 12)")
|
||||
@@ -380,6 +381,7 @@ func runimagesImportCmd(cmd *cobra.Command, args []string) {
|
||||
// and the file is used directly (no download). When only --file is set,
|
||||
// metadata comes from flags and no catalog tags are applied.
|
||||
imageName, _ := cmd.Flags().GetString("name")
|
||||
amiNameOverride, _ := cmd.Flags().GetString("ami-name")
|
||||
localFile, _ := cmd.Flags().GetString("file")
|
||||
|
||||
if imageName == "" && localFile == "" {
|
||||
@@ -535,6 +537,9 @@ func runimagesImportCmd(cmd *cobra.Command, args []string) {
|
||||
// Calculate the size
|
||||
|
||||
manifest.AMIMetadata.Name = fmt.Sprintf("ami-%s-%s-%s", image.Distro, image.Version, image.Arch)
|
||||
if amiNameOverride != "" {
|
||||
manifest.AMIMetadata.Name = amiNameOverride
|
||||
}
|
||||
volumeId := utils.GenerateResourceID("ami")
|
||||
manifest.AMIMetadata.ImageID = volumeId
|
||||
|
||||
|
||||
@@ -538,7 +538,7 @@ var awsgwStartCmd = &cobra.Command{
|
||||
awsgwHost := viper.GetString("host")
|
||||
if awsgwHost != "" {
|
||||
fmt.Println("Overwriting awsgw host to:", awsgwHost)
|
||||
//nodeConfig.AWSGW.Host = awsgwHost
|
||||
nodeConfig.AWSGW.Host = awsgwHost
|
||||
}
|
||||
|
||||
awsgwTlsCert := viper.GetString("tls-cert")
|
||||
|
||||
+110
-159
@@ -8,162 +8,102 @@ Platform management commands not exposed via the AWS gateway API. These are CLI-
|
||||
|
||||
Service lifecycle commands for starting, stopping, and checking status of all Spinifex cluster services. Each service subcommand supports `start`, `stop`, and `status` operations.
|
||||
|
||||
| Command | Flags | Prerequisites | Basic Logic | Status |
|
||||
|---------|-------|---------------|-------------|--------|
|
||||
| `spx service predastore start` | `--port` (default: 8443), `--host` (default: 0.0.0.0), `--base-path`, `--config-path`, `--debug`, `--tls-cert`, `--tls-key`, `--backend` (distributed/filesystem, default: distributed), `--node-id` (default: 0), `--pprof`, `--pprof-output` | TLS cert/key, base-path, config-path required | Creates predastore service instance with S3-compatible storage backend → starts service | **DONE** |
|
||||
| `spx service predastore stop` | — | Predastore must be running | Stops the predastore service | **DONE** |
|
||||
| `spx service predastore status` | — | None | Reports predastore service status | **DONE** |
|
||||
| `spx service viperblock start` | `--s3-host` (default: 0.0.0.0:8443), `--s3-bucket` (default: predastore), `--s3-region` (default: ap-southeast-2), `--plugin-path` (auto-detected via `nbdkit --dump-config plugindir`; typically `/usr/lib/x86_64-linux-gnu/nbdkit/plugins/nbdkit-viperblock-plugin.so` on amd64, overridable via `SPINIFEX_VIPERBLOCK_PLUGIN_PATH` in `/etc/spinifex/systemd.env`) | `--config` required, plugin-path must exist on disk | Loads cluster config → connects to NATS and Predastore → starts viperblock block storage service with NBD plugin | **DONE** |
|
||||
| `spx service viperblock stop` | — | Viperblock must be running | Stops the viperblock service | **DONE** |
|
||||
| `spx service viperblock status` | — | None | Reports viperblock service status | **DONE** |
|
||||
| `spx service nats start` | `--port` (default: 4222), `--host` (default: 0.0.0.0), `--debug`, `--data-dir`, `--jetstream` | None | Starts embedded NATS server with optional JetStream | **DONE** |
|
||||
| `spx service nats stop` | — | NATS must be running | Stops the NATS service | **DONE** |
|
||||
| `spx service nats status` | — | None | Reports NATS service status | **DONE** |
|
||||
| `spx service spinifex start` | `--wal-dir` | `--config` required | Loads cluster config → starts spinifex daemon (VM orchestration, NATS subscriptions, health endpoint) | **DONE** |
|
||||
| `spx service spinifex stop` | — | Spinifex daemon must be running | Stops the spinifex daemon service | **DONE** |
|
||||
| `spx service spinifex status` | — | None | Reports spinifex daemon service status | **DONE** |
|
||||
| `spx service awsgw start` | `--host` (default: 0.0.0.0:9999), `--tls-cert`, `--tls-key`, `--debug` | `--config` required | Loads cluster config → starts AWS-compatible gateway with SigV4 auth, IAM policy enforcement, TLS | **DONE** |
|
||||
| `spx service awsgw stop` | — | AWS gateway must be running | Stops the AWS gateway service | **DONE** |
|
||||
| `spx service awsgw status` | — | None | Reports AWS gateway service status | **DONE** |
|
||||
| `spx service spinifex-ui start` | `--port` (default: 3000), `--host` (default: 0.0.0.0), `--tls-cert`, `--tls-key` | None | Starts embedded web UI server serving the React frontend. Aliases: `ui`, `spinifexui` | **DONE** |
|
||||
| `spx service spinifex-ui stop` | — | spinifex-ui must be running | Stops the spinifex-ui service | **DONE** |
|
||||
| `spx service spinifex-ui status` | — | None | Reports spinifex-ui service status | **DONE** |
|
||||
| `spx service vpcd start` | — | `--config` required, OVN/OVS installed | Loads cluster config → starts VPC daemon (subscribes to `vpc.*` NATS events, translates to OVN logical switches/ports/routers) | **DONE** |
|
||||
| `spx service vpcd stop` | — | vpcd must be running | Stops the vpcd service | **DONE** |
|
||||
| `spx service vpcd status` | — | None | Reports vpcd service status | **DONE** |
|
||||
| Command | Flags | Description |
|
||||
|---------|-------|-------------|
|
||||
| `spx service predastore start` | `--port` (default: 8443), `--host` (default: 0.0.0.0), `--base-path`, `--config-path`, `--debug`, `--tls-cert`, `--tls-key`, `--backend` (distributed/filesystem, default: distributed), `--node-id` (default: 0), `--pprof`, `--pprof-output` | Creates predastore service instance with S3-compatible storage backend → starts service |
|
||||
| `spx service predastore stop` | — | Stops the predastore service |
|
||||
| `spx service predastore status` | — | Reports predastore service status |
|
||||
| `spx service viperblock start` | `--s3-host` (default: 0.0.0.0:8443), `--s3-bucket` (default: predastore), `--s3-region` (default: ap-southeast-2), `--plugin-path` (auto-detected via `nbdkit --dump-config plugindir`; typically `/usr/lib/x86_64-linux-gnu/nbdkit/plugins/nbdkit-viperblock-plugin.so` on amd64, overridable via `SPINIFEX_VIPERBLOCK_PLUGIN_PATH` in `/etc/spinifex/systemd.env`) | Loads cluster config → connects to NATS and Predastore → starts viperblock block storage service with NBD plugin |
|
||||
| `spx service viperblock stop` | — | Stops the viperblock service |
|
||||
| `spx service viperblock status` | — | Reports viperblock service status |
|
||||
| `spx service nats start` | `--port` (default: 4222), `--host` (default: 0.0.0.0), `--debug`, `--data-dir`, `--jetstream` | Starts embedded NATS server with optional JetStream |
|
||||
| `spx service nats stop` | — | Stops the NATS service |
|
||||
| `spx service nats status` | — | Reports NATS service status |
|
||||
| `spx service spinifex start` | `--wal-dir` | Loads cluster config → starts spinifex daemon (VM orchestration, NATS subscriptions, health endpoint) |
|
||||
| `spx service spinifex stop` | — | Stops the spinifex daemon service |
|
||||
| `spx service spinifex status` | — | Reports spinifex daemon service status |
|
||||
| `spx service awsgw start` | `--host` (default: 0.0.0.0:9999), `--tls-cert`, `--tls-key`, `--debug` | Loads cluster config → starts AWS-compatible gateway with SigV4 auth, IAM policy enforcement, TLS |
|
||||
| `spx service awsgw stop` | — | Stops the AWS gateway service |
|
||||
| `spx service awsgw status` | — | Reports AWS gateway service status |
|
||||
| `spx service spinifex-ui start` | `--port` (default: 3000), `--host` (default: 0.0.0.0), `--tls-cert`, `--tls-key` | Starts embedded web UI server serving the React frontend. Aliases: `ui`, `spinifexui` |
|
||||
| `spx service spinifex-ui stop` | — | Stops the spinifex-ui service |
|
||||
| `spx service spinifex-ui status` | — | Reports spinifex-ui service status |
|
||||
| `spx service vpcd start` | — | Loads cluster config → starts VPC daemon (subscribes to `vpc.*` NATS events, translates to OVN logical switches/ports/routers) |
|
||||
| `spx service vpcd stop` | — | Stops the vpcd service |
|
||||
| `spx service vpcd status` | — | Reports vpcd service status |
|
||||
|
||||
### Cluster Inspection
|
||||
|
||||
Operational commands for inspecting cluster state. These fan out NATS requests to all nodes and aggregate responses.
|
||||
|
||||
| Command | Flags | Prerequisites | Basic Logic | Output Columns | Status |
|
||||
|---------|-------|---------------|-------------|----------------|--------|
|
||||
| `spx get nodes` | `--timeout` (default: 3s) | Cluster must be running (NATS) | Loads config → publishes to `spinifex.node.status` fan-out topic → collects responses within timeout → merges config-known nodes with NATS responders → nodes that don't respond shown as `NotReady` | NAME, STATUS, IP, REGION, AZ, UPTIME, VMs, SERVICES | **DONE** |
|
||||
| `spx get vms` | `--timeout` (default: 3s) | Cluster must be running (NATS) | Publishes to `spinifex.node.vms` fan-out topic → collects VM info from all nodes → sorts by node then instance ID → prints table. Alias: `spx get instances` | INSTANCE, STATUS, TYPE, VCPU, MEM, NODE, IP, AGE | **DONE** |
|
||||
| Command | Flags | Description |
|
||||
|---------|-------|-------------|
|
||||
| `spx get nodes` | `--timeout` (default: 3s) | Loads config → publishes to `spinifex.node.status` fan-out topic → collects responses within timeout → merges config-known nodes with NATS responders → nodes that don't respond shown as `NotReady` |
|
||||
| `spx get vms` | `--timeout` (default: 3s) | Publishes to `spinifex.node.vms` fan-out topic → collects VM info from all nodes → sorts by node then instance ID → prints table. Alias: `spx get instances` |
|
||||
|
||||
### Resource Monitoring
|
||||
|
||||
| Command | Flags | Prerequisites | Basic Logic | Output | Status |
|
||||
|---------|-------|---------------|-------------|--------|--------|
|
||||
| `spx top nodes` | `--timeout` (default: 3s) | Cluster must be running (NATS) | Publishes to `spinifex.node.status` fan-out topic → collects CPU/memory usage per node → aggregates instance type capacity across all nodes → prints two tables: per-node resource usage and cluster-wide instance type availability | Table 1: NAME, CPU (used/total), MEM (used/total), GPU (used/total), VMs — GPU column shows `allocated/total` when passthrough is enabled, `0/count*` when the node is GPU-capable but passthrough is disabled, or `-` when no GPU is present. Table 2: INSTANCE TYPE, AVAILABLE, VCPU, MEMORY | **DONE** |
|
||||
| Command | Flags | Description |
|
||||
|---------|-------|-------------|
|
||||
| `spx top nodes` | `--timeout` (default: 3s) | Publishes to `spinifex.node.status` fan-out topic → collects CPU/memory usage per node → aggregates instance type capacity across all nodes → prints two tables: per-node resource usage and cluster-wide instance type availability |
|
||||
|
||||
### Cluster Initialization
|
||||
|
||||
| Command | Flags | Prerequisites | Basic Logic | Test Cases | Status |
|
||||
|---------|-------|---------------|-------------|------------|--------|
|
||||
| `spx admin init` | `--nodes`, `--node`, `--bind`, `--port`, `--region`, `--az`, `--cluster-name`, `--cluster-bind`, `--cluster-routes`, `--predastore-nodes`, `--services`, `--formation-timeout`, `--token-ttl`, `--force` | None (first-time setup) | Generates root IAM credentials (AKIA-prefixed access key + secret) → creates master.key (AES-256, 32 bytes, 0600) → writes bootstrap.json (consumed on first start) → generates CA + server TLS certificates → generates join token (written to `join-token` file, displayed in join command) → creates NATS config with auth token → writes spinifex.toml, awsgw.toml, predastore.toml → configures AWS CLI `spx` profile → creates directory structure under `~/spinifex/` | 1. Init creates all config files<br>2. Root credentials printed once<br>3. master.key is 32 bytes, mode 0600<br>4. bootstrap.json consumed on first start<br>5. `--force` re-initializes existing config<br>6. AWS CLI profile `spx` auto-configured<br>7. Multi-node init generates join token and writes to `<config-dir>/join-token` | **DONE** |
|
||||
| `spx admin join` | `--host` (required), `--node` (required), `--token` (required), `--bind`, `--port`, `--region`, `--az`, `--cluster-bind`, `--cluster-routes`, `--data-dir`, `--services` | Leader node must be running | Connects to leader node with join token (Authorization: Bearer header) → retrieves cluster configuration → configures local node to join cluster and participate in distributed operations | 1. Join existing cluster<br>2. Missing host (error)<br>3. Missing node name (error)<br>4. Missing token (error)<br>5. Invalid token (401)<br>6. Expired token (401) | **DONE** |
|
||||
| Command | Flags | Description |
|
||||
|---------|-------|-------------|
|
||||
| `spx admin init` | `--nodes`, `--node`, `--bind`, `--port`, `--region`, `--az`, `--cluster-name`, `--cluster-bind`, `--cluster-routes`, `--predastore-nodes`, `--services`, `--formation-timeout`, `--token-ttl`, `--force` | Generates root IAM credentials (AKIA-prefixed access key + secret) → creates master.key (AES-256, 32 bytes, 0600) → writes bootstrap.json (consumed on first start) → generates CA + server TLS certificates → generates join token (written to `join-token` file, displayed in join command) → creates NATS config with auth token → writes spinifex.toml, awsgw.toml, predastore.toml → configures AWS CLI `spx` profile → creates directory structure under `~/spinifex/` |
|
||||
| `spx admin join` | `--host` (required), `--node` (required), `--token` (required), `--bind`, `--port`, `--region`, `--az`, `--cluster-bind`, `--cluster-routes`, `--data-dir`, `--services` | Connects to leader node with join token (Authorization: Bearer header) → retrieves cluster configuration → configures local node to join cluster and participate in distributed operations |
|
||||
|
||||
### Version
|
||||
|
||||
| Command | Flags | Prerequisites | Basic Logic | Status |
|
||||
|---------|-------|---------------|-------------|--------|
|
||||
| `spx version` | — | None | Prints Spinifex version, commit hash, OS, and architecture (populated via build-time ldflags) | **DONE** |
|
||||
| Command | Flags | Description |
|
||||
|---------|-------|-------------|
|
||||
| `spx version` | — | Prints Spinifex version, commit hash, OS, and architecture (populated via build-time ldflags) |
|
||||
|
||||
### Cluster Operations
|
||||
|
||||
| Command | Flags | Prerequisites | Basic Logic | Test Cases | Status |
|
||||
|---------|-------|---------------|-------------|------------|--------|
|
||||
| `spx admin cluster shutdown` | `--force` (shutdown even if nodes don't respond), `--timeout` (max wait per phase, default 120s), `--dry-run` (print phase plan without executing) | Cluster must be running | Performs coordinated, phased shutdown of entire cluster. Phases execute in order: GATE (stop API/UI) → DRAIN (stop VMs) → STORAGE (stop viperblock) → PERSIST (stop predastore) → INFRA (stop NATS/daemon). Each phase waits for all nodes to ACK before proceeding. Uses JetStream state tracking. | 1. Shutdown running cluster<br>2. All nodes stop cleanly<br>3. Force shutdown with unresponsive nodes<br>4. Dry-run prints plan | **DONE** |
|
||||
| Command | Flags | Description |
|
||||
|---------|-------|-------------|
|
||||
| `spx admin cluster shutdown` | `--force` (shutdown even if nodes don't respond), `--timeout` (max wait per phase, default 120s), `--dry-run` (print phase plan without executing) | Performs coordinated, phased shutdown of entire cluster. Phases execute in order: GATE (stop API/UI) → DRAIN (stop VMs) → STORAGE (stop viperblock) → PERSIST (stop predastore) → INFRA (stop NATS/daemon). Each phase waits for all nodes to ACK before proceeding. Uses JetStream state tracking. |
|
||||
|
||||
### Certificate Management
|
||||
|
||||
| Command | Flags | Prerequisites | Basic Logic | Test Cases | Status |
|
||||
|---------|-------|---------------|-------------|------------|--------|
|
||||
| `spx admin cert renew` | `--extra-ip` (additional IPs for SANs), `--extra-dns` (additional DNS names for SANs) | Existing CA from `spx admin init` | Reads existing CA → regenerates server certificate with all current network interface IPs and machine hostname in SANs → writes new cert. Use after adding a new network interface or changing IP addresses. | 1. Renew with auto-detected IPs<br>2. Renew with extra IPs<br>3. Renew with extra DNS names | **DONE** |
|
||||
| Command | Flags | Description |
|
||||
|---------|-------|-------------|
|
||||
| `spx admin cert renew` | `--extra-ip` (additional IPs for SANs), `--extra-dns` (additional DNS names for SANs) | Reads existing CA → regenerates server certificate with all current network interface IPs and machine hostname in SANs → writes new cert. Use after adding a new network interface or changing IP addresses. |
|
||||
|
||||
### Upgrade Management
|
||||
|
||||
| Command | Flags | Prerequisites | Basic Logic | Test Cases | Status |
|
||||
|---------|-------|---------------|-------------|------------|--------|
|
||||
| `spx admin upgrade` | `--yes` (apply without prompting), `--config-dir` (persistent, default: `~/spinifex/config`), `--spinifex-dir` (persistent, default: `~/spinifex`) | Existing installation (`spinifex.toml` must exist in config dir) | Reads current config versions from registry → computes pending config migrations (from→to per target) → prints version summary and pending list → prompts for confirmation unless `--yes` → runs `migrate.DefaultRegistry.RunAllConfig()` to apply migrations to config files. Intended for upgrades between Spinifex versions. Operators can skip migrations during install by setting `INSTALL_SPINIFEX_SKIP_MIGRATE=1`, then run `spx admin upgrade` manually to review and apply. After completion, services must be restarted with `sudo systemctl restart spinifex.target`. Invoked non-interactively by `scripts/setup.sh` with `--yes`. | 1. No installation present (error, suggests `spx admin init`)<br>2. No pending migrations (prints "No pending config migrations")<br>3. Pending migrations with interactive confirmation (y/yes applies, anything else aborts)<br>4. Pending migrations with `--yes` (applies without prompt)<br>5. Migration failure surfaces error and exits non-zero | **DONE** |
|
||||
| Command | Flags | Description |
|
||||
|---------|-------|-------------|
|
||||
| `spx admin upgrade` | `--yes` (apply without prompting), `--config-dir` (persistent, default: `~/spinifex/config`), `--spinifex-dir` (persistent, default: `~/spinifex`) | Reads current config versions from registry → computes pending config migrations (from→to per target) → prints version summary and pending list → prompts for confirmation unless `--yes` → runs `migrate.DefaultRegistry.RunAllConfig()` to apply migrations to config files. Intended for upgrades between Spinifex versions. Operators can skip migrations during install by setting `INSTALL_SPINIFEX_SKIP_MIGRATE=1`, then run `spx admin upgrade` manually to review and apply. After completion, services must be restarted with `sudo systemctl restart spinifex.target`. Invoked non-interactively by `scripts/setup.sh` with `--yes`. |
|
||||
|
||||
### Account Management
|
||||
|
||||
| Command | Flags | Prerequisites | Basic Logic | Test Cases | Status |
|
||||
|---------|-------|---------------|-------------|------------|--------|
|
||||
| `spx admin account create` | `--name` | Cluster must be running (NATS), master key must exist | Connects to NATS → CAS loop on `spinifex-account-counter:next_id` for sequential 12-digit ID → creates Account record in `spinifex-accounts` KV → creates `admin` user in new account → creates access key for admin → creates AdministratorAccess policy (Action:*, Resource:*) → attaches policy → prints credentials | 1. Create first account (ID 000000000001)<br>2. Sequential IDs (000000000002, etc.)<br>3. Admin user has full access<br>4. Credentials printed once | **DONE** |
|
||||
| `spx admin account list` | — | Cluster must be running (NATS), master key must exist | Connects to NATS → IAMService.ListAccounts() → prints table with Account ID, Name, Status, Created | 1. List all accounts<br>2. Empty list (only global account) | **DONE** |
|
||||
| Command | Flags | Description |
|
||||
|---------|-------|-------------|
|
||||
| `spx admin account create` | `--name` | Connects to NATS → CAS loop on `spinifex-account-counter:next_id` for sequential 12-digit ID → creates Account record in `spinifex-accounts` KV → creates `admin` user in new account → creates access key for admin → creates AdministratorAccess policy (Action:*, Resource:*) → attaches policy → prints credentials |
|
||||
| `spx admin account list` | — | Connects to NATS → IAMService.ListAccounts() → prints table with Account ID, Name, Status, Created |
|
||||
|
||||
### Image Management
|
||||
|
||||
| Command | Flags | Prerequisites | Basic Logic | Test Cases | Status |
|
||||
|---------|-------|---------------|-------------|------------|--------|
|
||||
| `spx admin images import` | `--name`, `--file`, `--distro`, `--version`, `--arch`, `--platform`, `--boot-mode` (bios/uefi/uefi-preferred), `--tag`, `--force`, `--skip-verify` | Cluster must be running; either `--name` (catalog download) or `--file` (operator-supplied media). `--file` mode additionally requires `--distro`, `--version`, `--arch`, `--boot-mode`. | Catalog imports (`--name`) download the image, fetch the catalog `Checksum` URL, verify the SHA-256/SHA-512 digest, and inherit `BootMode` from the catalog entry. `--boot-mode` overrides the catalog value when set. Mismatch fails closed; the cached file is left on disk and `--force` re-downloads. `--file` imports skip checksum verification (operator-supplied media is outside Spinifex's trust boundary, the skip is logged at INFO for audit) and require an explicit `--boot-mode` because there is no catalog metadata to inherit from. `--skip-verify` bypasses verification for catalog imports and emits a WARN slog + stderr notice; use only for debugging or when upstream mirrors are confirmed-broken. | 1. Import valid catalog image (verifies checksum)<br>2. Tampered cache hit fails with `ErrChecksumMismatch`<br>3. `--force` recovers after a mismatch<br>4. `--file` import skips verification<br>5. `--skip-verify` bypasses checksum with WARN<br>6. `--file` without `--boot-mode` rejected<br>7. `--boot-mode=invalid` rejected | **DONE** |
|
||||
| `spx admin images list` | — | None | Lists available OS images that can be imported or downloaded | 1. List available images | **DONE** |
|
||||
| `spx admin images remove` | `--image-id` (required), `--force`, `--yes` | Cluster must be running; predastore reachable. Targets AMIs imported via `spx admin images import` (non-account `ImageOwnerAlias`, e.g. `"system"`). | Loads `ami-<id>/config.json`, walks transitive dependents — copied snapshots whose `VolumeID == imageID`, volumes whose `SnapshotID` references the internal `snap-ami-<id>` or any derived snap, and account AMIs created via `CopyImage` whose `SnapshotID` is a derived snap — then prompts (skipped with `--yes`) before deleting `ami-<id>/config.json` (the DescribeImages barrier) followed by the rest of `ami-<id>/` and `snap-ami-<id>/`. Account-owned AMIs are refused with a hint pointing at `aws ec2 deregister-image` + `aws ec2 delete-snapshot`. `--force` bypasses the dependency, ownership and config-corrupt checks for salvage of orphaned blocks. | 1. Happy path: system AMI, no deps → deleted<br>2. Account-owned → refused with AWS-flow hint<br>3. Missing/corrupt config → `InvalidAMIID.NotFound` (salvageable with `--force`)<br>4. Dependent volume (direct or via copied snap) → refused<br>5. Dependent account AMI → refused<br>6. `--force` overrides dependents<br>7. Idempotent re-run after partial delete | **DONE** |
|
||||
|
||||
#### Image integrity verification (CMMC SI.L1-3.14.2)
|
||||
|
||||
Catalog imports (`spx admin images import --name <name>`) verify the image
|
||||
against the catalog-declared SHA-256/SHA-512 digest before extraction. The sums
|
||||
file is fetched from the catalog `Checksum` URL over HTTPS only (cross-scheme
|
||||
redirects refused), and verification runs on both fresh downloads and cache
|
||||
hits so a poisoned cache is caught on the next import.
|
||||
|
||||
On mismatch the import exits non-zero, the cached file is left on disk for
|
||||
inspection, and the printed guidance is `spx admin images import --name <name>
|
||||
--force` to re-download.
|
||||
|
||||
`--file` imports skip verification by design: operator-supplied media is
|
||||
outside Spinifex's trust boundary and the operator is responsible for
|
||||
integrity (e.g. `sha256sum` against a trusted upstream digest before import).
|
||||
The skip is recorded as an INFO `slog` event with `reason=local-file-import`
|
||||
so a CMMC assessor can audit the decision from journald.
|
||||
|
||||
`--skip-verify` bypasses the checksum step for catalog imports. The command
|
||||
still downloads via the catalog URL but does not compare the image digest
|
||||
against the sums file. Intended for narrow cases such as debugging upstream
|
||||
mirror issues or running against a transiently-broken `latest/` path; the
|
||||
skip is logged at WARN with `reason=skip-verify-flag` and printed to stderr
|
||||
so operators and assessors see it. Prefer `--file` with an out-of-band
|
||||
verified image over `--skip-verify` whenever possible.
|
||||
|
||||
**Limitation:** verification confirms the image matches the digest the mirror
|
||||
served. A mirror compromise that swaps both image and sums file is not
|
||||
detected; closing that gap requires GPG signature verification of the sums
|
||||
file, deferred to a later phase.
|
||||
|
||||
#### `spx admin images remove` caveats
|
||||
|
||||
Admin-imported AMIs (`ImageOwnerAlias = "system"`) live
|
||||
under the `ami-<id>/` S3 prefix and use a viperblock-internal snap checkpoint
|
||||
at `snap-ami-<id>/` — there is no `snap-<id>/metadata.json`. The AWS handlers
|
||||
(`DeregisterImage`, `DeleteSnapshot`) reject system owners with
|
||||
`UnauthorizedOperation`, which is the right behaviour for tenant API callers
|
||||
but leaves no AWS-flow path to reclaim space. `spx admin images remove` is
|
||||
the admin-trust-boundary counterpart that performs the dependency walk and
|
||||
hard-deletes the blocks directly against predastore.
|
||||
|
||||
`CopyImage` of a system AMI is metadata-only: it writes a fresh
|
||||
`snap-<acct>/metadata.json` whose `VolumeID` points at `ami-<sys>` and a new
|
||||
`ami-<acct>/config.json` referencing that snap. Volumes launched from the
|
||||
copied AMI read transitively from `ami-<sys>/chunks/...`. The remove command
|
||||
walks this transitive set and refuses if anything references the target.
|
||||
|
||||
**TOCTOU window:** between the safety scan and the `config.json` delete a
|
||||
concurrent `RunInstances` against the AMI could create a new dependent
|
||||
volume. The window is sub-second on a healthy cluster. The admin running
|
||||
this command is expected to know the fleet's operational state; if the race
|
||||
fires the result is a `vol-<id>` with deleted backing blocks, recovered by
|
||||
terminating the orphaned instance.
|
||||
|
||||
**`--force` bypasses every safety check** (dependents, ownership, missing /
|
||||
corrupt `config.json`). Use only for salvage of orphaned blocks. Running it
|
||||
against a live system AMI corrupts every dependent volume on the next disk
|
||||
read.
|
||||
| Command | Flags | Description |
|
||||
|---------|-------|-------------|
|
||||
| `spx admin images import` | `--name`, `--file`, `--distro`, `--version`, `--arch`, `--platform`, `--boot-mode` (bios/uefi/uefi-preferred), `--tag`, `--force`, `--skip-verify` | Catalog imports (`--name`) download the image, fetch the catalog `Checksum` URL, verify the SHA-256/SHA-512 digest, and inherit `BootMode` from the catalog entry. `--boot-mode` overrides the catalog value when set. Mismatch fails closed; the cached file is left on disk and `--force` re-downloads. `--file` imports skip checksum verification (operator-supplied media is outside Spinifex's trust boundary, the skip is logged at INFO for audit) and require an explicit `--boot-mode` because there is no catalog metadata to inherit from. `--skip-verify` bypasses verification for catalog imports and emits a WARN slog + stderr notice; use only for debugging or when upstream mirrors are confirmed-broken. |
|
||||
| `spx admin images list` | — | Lists available OS images that can be imported or downloaded |
|
||||
| `spx admin images remove` | `--image-id` (required), `--force`, `--yes` | Loads `ami-<id>/config.json`, walks transitive dependents — copied snapshots whose `VolumeID == imageID`, volumes whose `SnapshotID` references the internal `snap-ami-<id>` or any derived snap, and account AMIs created via `CopyImage` whose `SnapshotID` is a derived snap — then prompts (skipped with `--yes`) before deleting `ami-<id>/config.json` (the DescribeImages barrier) followed by the rest of `ami-<id>/` and `snap-ami-<id>/`. Account-owned AMIs are refused with a hint pointing at `aws ec2 deregister-image` + `aws ec2 delete-snapshot`. `--force` bypasses the dependency, ownership and config-corrupt checks for salvage of orphaned blocks. |
|
||||
|
||||
### GPU Management
|
||||
|
||||
| Command | Flags | Prerequisites | Basic Logic | Test Cases | Status |
|
||||
|---------|-------|---------------|-------------|------------|--------|
|
||||
| `spx admin gpu status` | `--node` (default: local node) | Cluster must be running (NATS) | Queries `spinifex.node.status` fan-out → finds the target node response → prints Node, GPU hardware (model list or "none detected"), IOMMU state, vfio-pci state, passthrough enabled/disabled, and GPU pool allocation (`allocated/total`). Also lists GPU-capable instance types when passthrough is active. | 1. Local node with passthrough enabled (shows pool and instance types)<br>2. Local node capable but passthrough disabled (shows "run enable")<br>3. Node without GPU hardware (shows "none detected")<br>4. `--node` targets a remote node<br>5. Node not found or not responding (error) | **DONE** |
|
||||
| `spx admin gpu enable` | — | Cluster must be running (NATS); node must be GPU-capable (IOMMU + vfio-pci configured) | Checks current passthrough state via NATS → errors if already enabled or prerequisites not met (directs to `setup`) → writes `gpu_passthrough = true` to `spinifex.toml` via `admin.SetGPUPassthrough` → sends SIGHUP to `spinifex-daemon` via `systemctl kill -s HUP` → polls node status for up to 30 s until daemon confirms new state → prints final `gpu status` output. Must be run directly on the target host. | 1. Enable on capable node<br>2. Already enabled (no-op)<br>3. Prerequisites not met (error, suggests `setup`)<br>4. Daemon does not confirm within 30 s (timeout error) | **DONE** |
|
||||
| `spx admin gpu disable` | — | Cluster must be running (NATS); no GPU instances may be running | Checks current passthrough state via NATS → errors if already disabled or if `AllocGPUs > 0` (must terminate GPU instances first) → writes `gpu_passthrough = false` → sends SIGHUP to `spinifex-daemon` → polls for up to 30 s → prints final `gpu status` output. Must be run directly on the target host. | 1. Disable with no GPU instances running<br>2. Already disabled (no-op)<br>3. GPU instances running (error with count)<br>4. Daemon does not confirm within 30 s (timeout error) | **DONE** |
|
||||
| `spx admin gpu setup` | — | Must be run as root; NVIDIA or AMD GPU must be present | Idempotent host configuration for GPU passthrough. Steps (skipped if already applied): detect GPUs via `gpu.Discover` → collect PCI IDs for all IOMMU-group siblings → check/enable IOMMU in GRUB (`intel_iommu=on iommu=pt` or `amd_iommu=on iommu=pt`) → write vfio udev rule (`/etc/udev/rules.d/99-spinifex-vfio.rules`) → blacklist nouveau (`/etc/modprobe.d/blacklist-nouveau.conf`) → blacklist amdgpu if AMD GPU present (`/etc/modprobe.d/blacklist-amdgpu.conf`) → write vfio-pci early binding config (`/etc/modprobe.d/vfio-pci.conf`) → add vfio modules to initramfs. If any change requires a reboot: runs `update-initramfs -u` and exits with reboot instructions. After reboot: verifies `vfio_pci` module is loaded → verifies each GPU is bound to `vfio-pci` (binds explicitly via `driver_override` if unbound) → calls `gpu enable` to activate passthrough. | 1. First run on unconfigured host (GRUB updated, reboot required)<br>2. Re-run after reboot (verifies bindings, enables passthrough)<br>3. Already fully configured (all steps skipped, enables passthrough)<br>4. AMD GPU present (blacklists amdgpu in addition to nouveau)<br>5. Not run as root (error)<br>6. No GPU hardware detected (error) | **DONE** |
|
||||
| `spx admin gpu mig status` | — (must be run on target host) | NVIDIA GPU with MIG support present | Runs `gpu.Discover()` locally → for each GPU prints: PCI address, model, MIG capability, MIG mode (enabled/disabled/N/A); for GPUs with MIG enabled lists active MIG slices with GI ID, profile name, and mdev path. | 1. Node with MIG enabled (shows slices)<br>2. MIG capable but not enabled (shows disabled)<br>3. No MIG-capable GPU (shows N/A for MIG fields) | **DONE** |
|
||||
| `spx admin gpu mig enable` | `--profile <name>` (required, e.g. `1g.10gb`), `--gpu <pci-addr>` (optional, default: all MIG-capable GPUs) | Must be run as root; MIG-capable GPU present; no GPU instances running | Checks no GPU instances running (NATS) → discovers MIG-capable GPUs via `gpu.Discover()` (filtered by `--gpu` if set) → enables MIG mode on each target (`gpu.EnableMIGMode`) → lists available profiles (`gpu.ListProfiles`) and validates requested profile name → destroys any existing instances (`gpu.DestroyAllInstances`) → creates new instances filling GPU capacity (`gpu.CreateInstances`) → writes `mig_profile` to `spinifex.toml` via `admin.SetMIGProfile` → sends SIGHUP to `spinifex-daemon`. Must be run directly on the target host. | 1. Enable MIG with valid profile<br>2. Invalid profile name (error with available profile list)<br>3. No MIG-capable GPU (error)<br>4. GPU instances running (error)<br>5. `--gpu` targets specific GPU<br>6. Not run as root (error) | **DONE** |
|
||||
| `spx admin gpu mig disable` | `--gpu <pci-addr>` (optional, default: all MIG-capable GPUs) | Must be run as root; no GPU instances running | Checks no GPU instances running (NATS) → discovers MIG-capable GPUs via `gpu.Discover()` (filtered by `--gpu` if set) → destroys all GPU instances (`gpu.DestroyAllInstances`) → disables MIG mode (`gpu.DisableMIGMode`) → clears `mig_profile` in `spinifex.toml` via `admin.SetMIGProfile` → sends SIGHUP to `spinifex-daemon`. Must be run directly on the target host. | 1. Disable with no running GPU instances<br>2. GPU instances running (error)<br>3. MIG not enabled on GPU (skips that GPU)<br>4. `--gpu` targets specific GPU<br>5. Not run as root (error) | **DONE** |
|
||||
| Command | Flags | Description |
|
||||
|---------|-------|-------------|
|
||||
| `spx admin gpu status` | `--node` (default: local node) | Queries `spinifex.node.status` fan-out → finds the target node response → prints Node, GPU hardware (model list or "none detected"), IOMMU state, vfio-pci state, passthrough enabled/disabled, and GPU pool allocation (`allocated/total`). Also lists GPU-capable instance types when passthrough is active. |
|
||||
| `spx admin gpu enable` | — | Checks current passthrough state via NATS → errors if already enabled or prerequisites not met (directs to `setup`) → writes `gpu_passthrough = true` to `spinifex.toml` via `admin.SetGPUPassthrough` → sends SIGHUP to `spinifex-daemon` via `systemctl kill -s HUP` → polls node status for up to 30 s until daemon confirms new state → prints final `gpu status` output. Must be run directly on the target host. |
|
||||
| `spx admin gpu disable` | — | Checks current passthrough state via NATS → errors if already disabled or if `AllocGPUs > 0` (must terminate GPU instances first) → writes `gpu_passthrough = false` → sends SIGHUP to `spinifex-daemon` → polls for up to 30 s → prints final `gpu status` output. Must be run directly on the target host. |
|
||||
| `spx admin gpu setup` | — | Idempotent host configuration for GPU passthrough. Steps (skipped if already applied): detect GPUs via `gpu.Discover` → collect PCI IDs for all IOMMU-group siblings → check/enable IOMMU in GRUB (`intel_iommu=on iommu=pt` or `amd_iommu=on iommu=pt`) → write vfio udev rule (`/etc/udev/rules.d/99-spinifex-vfio.rules`) → blacklist nouveau (`/etc/modprobe.d/blacklist-nouveau.conf`) → blacklist amdgpu if AMD GPU present (`/etc/modprobe.d/blacklist-amdgpu.conf`) → write vfio-pci early binding config (`/etc/modprobe.d/vfio-pci.conf`) → add vfio modules to initramfs. If any change requires a reboot: runs `update-initramfs -u` and exits with reboot instructions. After reboot: verifies `vfio_pci` module is loaded → verifies each GPU is bound to `vfio-pci` (binds explicitly via `driver_override` if unbound) → calls `gpu enable` to activate passthrough. |
|
||||
| `spx admin gpu mig status` | — (must be run on target host) | Runs `gpu.Discover()` locally → for each GPU prints: PCI address, model, MIG capability, MIG mode (enabled/disabled/N/A); for GPUs with MIG enabled lists active MIG slices with GI ID, profile name, and mdev path. |
|
||||
| `spx admin gpu mig enable` | `--profile <name>` (required, e.g. `1g.10gb`), `--gpu <pci-addr>` (optional, default: all MIG-capable GPUs) | Checks no GPU instances running (NATS) → discovers MIG-capable GPUs via `gpu.Discover()` (filtered by `--gpu` if set) → enables MIG mode on each target (`gpu.EnableMIGMode`) → lists available profiles (`gpu.ListProfiles`) and validates requested profile name → destroys any existing instances (`gpu.DestroyAllInstances`) → creates new instances filling GPU capacity (`gpu.CreateInstances`) → writes `mig_profile` to `spinifex.toml` via `admin.SetMIGProfile` → sends SIGHUP to `spinifex-daemon`. Must be run directly on the target host. |
|
||||
| `spx admin gpu mig disable` | `--gpu <pci-addr>` (optional, default: all MIG-capable GPUs) | Checks no GPU instances running (NATS) → discovers MIG-capable GPUs via `gpu.Discover()` (filtered by `--gpu` if set) → destroys all GPU instances (`gpu.DestroyAllInstances`) → disables MIG mode (`gpu.DisableMIGMode`) → clears `mig_profile` in `spinifex.toml` via `admin.SetMIGProfile` → sends SIGHUP to `spinifex-daemon`. Must be run directly on the target host. |
|
||||
|
||||
|
||||
## AWS-Compatible API
|
||||
@@ -295,9 +235,6 @@ by downstream services.
|
||||
|
||||
### EC2 — VPC Core
|
||||
|
||||
VPC/Subnet/ENI/SG CRUD stores metadata in NATS KV and publishes events to
|
||||
vpcd for OVN translation. Single AZ for Spinifex v1.
|
||||
|
||||
| Command | Implemented Flags | Missing Flags | Status |
|
||||
|---------|-------------------|---------------|--------|
|
||||
| `create-vpc` | `--cidr-block`, `--tag-specifications` | `--instance-tenancy`, `--dry-run` | **DONE** |
|
||||
@@ -393,9 +330,6 @@ EIP commands are only registered when an external IPAM pool is configured.
|
||||
|
||||
### EC2 — NAT Gateway
|
||||
|
||||
Deleted gateways move to a separate KV bucket with 1-hour TTL for Terraform
|
||||
polling.
|
||||
|
||||
| Command | Implemented Flags | Missing Flags | Status |
|
||||
|---------|-------------------|---------------|--------|
|
||||
| `create-nat-gateway` | `--subnet-id`, `--allocation-id` | `--connectivity-type`, `--tag-specifications`, `--dry-run` | **DONE** |
|
||||
@@ -406,9 +340,6 @@ polling.
|
||||
|
||||
### EC2 — Placement Groups
|
||||
|
||||
Strategies: `spread` (1 instance per node, strict) and `cluster` (all pinned
|
||||
to single node). `partition` rejected.
|
||||
|
||||
| Command | Implemented Flags | Missing Flags | Status |
|
||||
|---------|-------------------|---------------|--------|
|
||||
| `create-placement-group` | `--group-name`, `--strategy` (spread/cluster) | `--partition-count`, `--spread-level`, `--tag-specifications`, `--dry-run` | **DONE** |
|
||||
@@ -543,9 +474,6 @@ bypasses policy evaluation entirely.
|
||||
|
||||
### IAM — Access Keys
|
||||
|
||||
Max 2 keys per user. Secrets encrypted with AES-256-GCM using master key,
|
||||
returned plaintext only at creation.
|
||||
|
||||
| Command | Implemented Flags | Missing Flags | Status |
|
||||
|---------|-------------------|---------------|--------|
|
||||
| `create-access-key` | `--user-name` | — | **DONE** |
|
||||
@@ -555,10 +483,6 @@ returned plaintext only at creation.
|
||||
|
||||
### IAM — Policies
|
||||
|
||||
Policy documents require `Version: "2012-10-17"` and a valid Statement array.
|
||||
Wildcard action matching supported (`ec2:*`, `ec2:Describe*`, `*`). Evaluation
|
||||
order: explicit Deny > explicit Allow > implicit Deny.
|
||||
|
||||
| Command | Implemented Flags | Missing Flags | Status |
|
||||
|---------|-------------------|---------------|--------|
|
||||
| `create-policy` | `--policy-name`, `--policy-document`, `--path`, `--description` | `--tags` | **DONE** |
|
||||
@@ -570,19 +494,6 @@ order: explicit Deny > explicit Allow > implicit Deny.
|
||||
| `detach-user-policy` | `--user-name`, `--policy-arn` | — | **DONE** |
|
||||
| `list-attached-user-policies` | `--user-name` | `--path-prefix`, `--max-items`, `--marker` | **DONE** |
|
||||
|
||||
### IAM — Policy Evaluation (internal — not an AWS API)
|
||||
|
||||
| Feature | Status |
|
||||
|---------|--------|
|
||||
| Root user bypass | **DONE** |
|
||||
| Default deny | **DONE** |
|
||||
| Explicit allow | **DONE** |
|
||||
| Explicit deny | **DONE** |
|
||||
| Wildcard matching (`*`, `ec2:*`, `ec2:Describe*`) | **DONE** |
|
||||
| Account-scoped evaluation | **DONE** |
|
||||
| EC2 action mapping | **DONE** |
|
||||
| IAM action mapping (16 actions) | **DONE** |
|
||||
|
||||
### IAM — Roles
|
||||
|
||||
| Command | Implemented Flags | Missing Flags | Status |
|
||||
@@ -599,9 +510,6 @@ order: explicit Deny > explicit Allow > implicit Deny.
|
||||
|
||||
### IAM — Instance Profiles
|
||||
|
||||
Containers for IAM roles that allow EC2 instances to assume a role. Required
|
||||
for `run-instances --iam-instance-profile`. Max 1 role per profile.
|
||||
|
||||
| Command | Implemented Flags | Missing Flags | Status |
|
||||
|---------|-------------------|---------------|--------|
|
||||
| `create-instance-profile` | `--instance-profile-name`, `--path`, `--tags` | — | **DONE** |
|
||||
@@ -631,11 +539,11 @@ for `run-instances --iam-instance-profile`. Max 1 role per profile.
|
||||
|
||||
## STS
|
||||
|
||||
| Command | Implemented Flags | Missing Flags (rejected if supplied) | Status |
|
||||
| Command | Implemented Flags | Missing Flags | Status |
|
||||
|---------|-------------------|--------------------------------------|--------|
|
||||
| `get-caller-identity` | — | — | **DONE** |
|
||||
| `assume-role` | `--role-arn`, `--role-session-name`, `--duration-seconds` (900–min(role MaxSessionDuration, 43200)) | `--policy`, `--policy-arns` (→ `PackedPolicyTooLarge`); `--tags`, `--transitive-tag-keys` (→ `InvalidParameterValue`); `--serial-number`, `--token-code` (→ `InvalidParameterValue`); `--external-id`, `--source-identity` (accepted and logged, **not enforced** — no Condition evaluator in v1) | **DONE** |
|
||||
| `get-session-token` | — | `--duration-seconds`, `--serial-number`, `--token-code` | **NOT STARTED** |
|
||||
| `get-session-token` | `--duration-seconds` (900–129600, default 43200 = 12h; clamped, not rejected) | `--serial-number`, `--token-code` (MFA → `InvalidParameterValue`) | **DONE** |
|
||||
| `assume-role-with-web-identity` | — | `--role-arn`, `--role-session-name`, `--web-identity-token`, `--provider-id`, `--policy`, `--policy-arns`, `--duration-seconds` | **NOT STARTED** |
|
||||
| `assume-role-with-saml` | — | `--role-arn`, `--principal-arn`, `--saml-assertion`, `--policy`, `--policy-arns`, `--duration-seconds` | **NOT STARTED** |
|
||||
| `get-access-key-info` | — | `--access-key-id` | **NOT STARTED** |
|
||||
@@ -649,9 +557,52 @@ Condition evaluator so accepting them would silently allow.
|
||||
|
||||
---
|
||||
|
||||
## IMDS (Instance Metadata Service)
|
||||
|
||||
Available at `169.254.169.254` from inside every running guest VM, matching AWS. The endpoint is reached from within a guest over plain HTTP, exactly as on EC2, with no in-VM agent to install. DHCP and fully static guests reach it identically, with no in-guest route configuration.
|
||||
|
||||
**IMDSv2-only.** Every read requires a session token. A tokenless (v1-style) `GET` returns `401 Unauthorized` with an empty body. Obtain a token with a `PUT /latest/api/token` carrying `X-aws-ec2-metadata-token-ttl-seconds` (1–21600), then send it back in `X-aws-ec2-metadata-token` on every read.
|
||||
|
||||
```bash
|
||||
# Inside the guest VM:
|
||||
TOKEN=$(curl -s -X PUT "http://169.254.169.254/latest/api/token" \
|
||||
-H "X-aws-ec2-metadata-token-ttl-seconds: 60")
|
||||
curl -s -H "X-aws-ec2-metadata-token: $TOKEN" \
|
||||
http://169.254.169.254/latest/meta-data/instance-id
|
||||
|
||||
# Tokenless GET → 401 Unauthorized (empty body)
|
||||
curl -i http://169.254.169.254/latest/meta-data/instance-id
|
||||
```
|
||||
|
||||
### IMDS — Supported Paths
|
||||
|
||||
| Path | Method | Source | Status |
|
||||
|------|--------|--------|--------|
|
||||
| `/latest/api/token` | PUT | Issues an ENI-bound IMDSv2 token; `X-aws-ec2-metadata-token-ttl-seconds` ∈ [1, 21600] required | **DONE** |
|
||||
| `/latest/meta-data/` | GET | Directory listing of supported children | **DONE** |
|
||||
| `/latest/meta-data/instance-id` | GET | `vm.ID` | **DONE** |
|
||||
| `/latest/meta-data/instance-type` | GET | `vm.InstanceType` | **DONE** |
|
||||
| `/latest/meta-data/ami-id` | GET | launch `ImageId` | **DONE** |
|
||||
| `/latest/meta-data/local-ipv4` | GET | `ENIRecord.PrivateIpAddress` (== request source IP) | **DONE** |
|
||||
| `/latest/meta-data/public-ipv4` | GET | EIP, else instance public IP; empty body if none | **DONE** |
|
||||
| `/latest/meta-data/mac` | GET | `ENIRecord.MacAddress` | **DONE** |
|
||||
| `/latest/meta-data/security-groups` | GET | `ENIRecord.SecurityGroupIds`, newline-separated | **DONE** |
|
||||
| `/latest/meta-data/hostname`, `/local-hostname` | GET | Synthesised `ip-<dashed-ip>.<region>.compute.internal` | **DONE** |
|
||||
| `/latest/meta-data/placement/availability-zone` | GET | `ENIRecord.AvailabilityZone` | **DONE** |
|
||||
| `/latest/meta-data/placement/region` | GET | Derived from AZ (trailing letter stripped) | **DONE** |
|
||||
| `/latest/meta-data/iam/info` | GET | `{InstanceProfileArn, InstanceProfileId}`; 404 if no profile | **DONE** |
|
||||
| `/latest/meta-data/iam/security-credentials/` | GET | Role name(s) under the profile, one per line; empty body if none | **DONE** |
|
||||
| `/latest/meta-data/iam/security-credentials/<role>` | GET | STS `AssumeRoleForInstance` → ASIA-prefixed temporary credential JSON | **DONE** |
|
||||
| `/latest/user-data` | GET | `vm.UserData`; 404 if none | **DONE** |
|
||||
| `/latest/dynamic/instance-identity/document` | GET | Needs a per-cluster signing key | **NOT STARTED** (404; lands with EKS IRSA) |
|
||||
| `/latest/meta-data/network/interfaces/...` | GET | Multi-ENI rendering | **NOT STARTED** (404) |
|
||||
| `/latest/meta-data/tags/...` | GET | Instance-tag metadata | **NOT STARTED** (404) |
|
||||
|
||||
---
|
||||
|
||||
## ELBv2 (Application & Network Load Balancer)
|
||||
|
||||
The data plane uses a system-managed LB VM running HAProxy, launched automatically during `create-load-balancer`. HAProxy config is pushed via NATS on listener/target changes.
|
||||
The data plane uses a system-managed LB VM, launched automatically during `create-load-balancer`. Application Load Balancers run **HAProxy** (L7: rules, fixed-response, redirect, HTTP/HTTPS). Network Load Balancers run **nginx `stream`** (L4: TCP, UDP, TLS, TCP_UDP) — HAProxy cannot load-balance UDP. The agent selects the engine from the `Engine` field on the config-delivery response.
|
||||
|
||||
### ELBv2 — Load Balancers
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -164,6 +164,16 @@ spx admin images import --name debian-13-x86_64
|
||||
|
||||
cloud-init needs time to configure the instance after boot. Wait 30-60 seconds and retry.
|
||||
|
||||
If the connection **times out** rather than being refused, the security group is likely blocking port 22. The default security group denies all inbound traffic (matching AWS), so SSH must be explicitly allowed:
|
||||
|
||||
```bash
|
||||
# Allow SSH from anywhere on the instance's security group
|
||||
aws ec2 authorize-security-group-ingress \
|
||||
--group-id $SG_ID --protocol tcp --port 22 --cidr 0.0.0.0/0
|
||||
```
|
||||
|
||||
See [VPC Networking — Security Groups](/docs/vpc-networking#security-groups) for scoping rules to a trusted CIDR.
|
||||
|
||||
Verify the SSH key was specified correctly when launching:
|
||||
|
||||
```bash
|
||||
|
||||
@@ -48,8 +48,9 @@ NIC. Each host runs `ovn-controller` which programs OpenFlow rules on `br-int`
|
||||
|
||||
## Private vs Public Subnets
|
||||
|
||||
A subnet's behavior depends on two things: whether the VPC has an Internet
|
||||
Gateway, and whether the subnet has `MapPublicIpOnLaunch` enabled.
|
||||
A subnet's behavior depends on three things: whether the VPC has an Internet
|
||||
Gateway, whether the subnet's route table has a default route to that IGW, and
|
||||
whether the subnet has `MapPublicIpOnLaunch` enabled.
|
||||
|
||||
## Private Subnet (Default)
|
||||
|
||||
@@ -61,9 +62,10 @@ internet or be reached from the WAN.
|
||||
<img src="../../../.github/assets/diagrams/vpc-private-subnet-flow.svg" alt="Private subnet — instance hits router, no default route, packet dropped" width="900">
|
||||
</p>
|
||||
|
||||
If the VPC has an IGW attached, private subnet instances CAN reach the internet
|
||||
via the VPC router's SNAT rule (outbound only — they share the gateway IP). They
|
||||
still cannot be reached from the WAN because they have no public IP.
|
||||
Private subnet instances reach the internet only if their route table has a
|
||||
default route to the IGW (shared SNAT, outbound only — they share the gateway
|
||||
IP) or to a NAT gateway. With no default route, egress is dropped. Either way
|
||||
they cannot be reached from the WAN because they have no public IP.
|
||||
|
||||
## Public Subnet
|
||||
|
||||
@@ -77,8 +79,18 @@ managed by OVN — the instance OS only sees its private IP.
|
||||
**Requirements for a public subnet:**
|
||||
|
||||
1. VPC has an Internet Gateway attached
|
||||
2. Subnet has `MapPublicIpOnLaunch = true`
|
||||
3. External IP pool configured in `spinifex.toml`
|
||||
2. A route table associated with the subnet has a `0.0.0.0/0` route to the IGW
|
||||
3. Subnet has `MapPublicIpOnLaunch = true`
|
||||
4. External IP pool configured in `spinifex.toml`
|
||||
|
||||
Spinifex follows AWS route-table semantics: a subnet is only "public" if its
|
||||
effective route table carries a default route to the IGW. A new VPC's main
|
||||
route table has the local route only — Spinifex does **not** add the IGW route
|
||||
for you. Without it, the subnet's egress is gated with a drop policy, so
|
||||
instances cannot reach the internet (and inbound connections cannot complete
|
||||
because return traffic is dropped) even with a public IP and an attached IGW.
|
||||
Add the route explicitly — either to the main route table, or to a custom route
|
||||
table associated with the subnet (see [Quick Start](#3-create-vpc-with-public-subnet)).
|
||||
|
||||
## Comparison
|
||||
|
||||
@@ -86,7 +98,7 @@ managed by OVN — the instance OS only sees its private IP.
|
||||
| ------------------------ | --------------------------------- | ------------------------------ |
|
||||
| Private IP | Yes | Yes |
|
||||
| Public IP | No | Auto-assigned from pool |
|
||||
| Outbound internet | Only if VPC has IGW (shared SNAT) | Yes (own public IP via SNAT) |
|
||||
| Outbound internet | Only with a default route to IGW/NAT GW | Yes (own public IP via SNAT) |
|
||||
| Inbound from WAN | No | Yes (via 1:1 NAT to public IP) |
|
||||
| Instance sees public IP? | N/A | No — only sees private IP |
|
||||
| Elastic IP support | Only if explicitly associated | Yes |
|
||||
@@ -190,8 +202,7 @@ use `setup-ovn.sh --dhcp` to obtain one from the router. This is the router's
|
||||
DHCP — not Spinifex's internal OVN DHCP for VMs.
|
||||
|
||||
**Use when:** VMs only need outbound access (apt update, pulling images). Edge
|
||||
deployments behind ISP NAT. Single WAN IP available. Future: use with AWS-style
|
||||
NAT Gateway for private subnet internet access.
|
||||
deployments behind ISP NAT. Single WAN IP available.
|
||||
|
||||
```toml
|
||||
[network]
|
||||
@@ -935,17 +946,39 @@ spx admin init
|
||||
VPC=$(aws ec2 create-vpc --cidr-block 10.200.0.0/16 \
|
||||
--query Vpc.VpcId --output text)
|
||||
|
||||
SUBNET=$(aws ec2 create-subnet --vpc-id $VPC \
|
||||
--cidr-block 10.200.1.0/24 \
|
||||
--query Subnet.SubnetId --output text)
|
||||
|
||||
IGW=$(aws ec2 create-internet-gateway \
|
||||
--query InternetGateway.InternetGatewayId --output text)
|
||||
aws ec2 attach-internet-gateway \
|
||||
--internet-gateway-id $IGW --vpc-id $VPC
|
||||
|
||||
SUBNET=$(aws ec2 create-subnet --vpc-id $VPC \
|
||||
--cidr-block 10.200.1.0/24 \
|
||||
--query Subnet.SubnetId --output text)
|
||||
|
||||
# Route table — give the subnet a default route to the IGW. Spinifex does NOT
|
||||
# add this automatically; without it the subnet's egress is dropped.
|
||||
RT=$(aws ec2 create-route-table --vpc-id $VPC \
|
||||
--query RouteTable.RouteTableId --output text)
|
||||
|
||||
aws ec2 create-route --route-table-id $RT \
|
||||
--destination-cidr-block 0.0.0.0/0 --gateway-id $IGW
|
||||
|
||||
aws ec2 associate-route-table --route-table-id $RT --subnet-id $SUBNET
|
||||
|
||||
# Auto-assign a public IP to instances launched into the subnet
|
||||
aws ec2 modify-subnet-attribute \
|
||||
--subnet-id $SUBNET --map-public-ip-on-launch
|
||||
|
||||
# Allow SSH + ICMP on the VPC's default security group (blocks inbound by default)
|
||||
SG=$(aws ec2 describe-security-groups \
|
||||
--filters Name=vpc-id,Values=$VPC \
|
||||
--query 'SecurityGroups[0].GroupId' --output text)
|
||||
|
||||
aws ec2 authorize-security-group-ingress --group-id $SG \
|
||||
--protocol tcp --port 22 --cidr 0.0.0.0/0
|
||||
|
||||
aws ec2 authorize-security-group-ingress --group-id $SG \
|
||||
--protocol icmp --port -1 --cidr 0.0.0.0/0
|
||||
```
|
||||
|
||||
## 4. Launch Instance
|
||||
@@ -1135,6 +1168,37 @@ ip route show
|
||||
sudo ovn-nbctl lr-nat-list vpc-$VPC
|
||||
```
|
||||
|
||||
### Instance Has Public IP But No Internet
|
||||
|
||||
The instance shows a public IP in `describe-instances` but cannot reach the
|
||||
internet, and inbound connections hang. The usual cause is a missing default
|
||||
route: the subnet's effective route table has no `0.0.0.0/0` route to the IGW,
|
||||
so Spinifex gates the subnet with a drop policy.
|
||||
|
||||
```bash
|
||||
# Find the route table that applies to the subnet and check its routes
|
||||
aws ec2 describe-route-tables \
|
||||
--filters Name=association.subnet-id,Values=$SUBNET \
|
||||
--query 'RouteTables[0].Routes'
|
||||
# Expect a route with DestinationCidrBlock 0.0.0.0/0 and a GatewayId of igw-...
|
||||
```
|
||||
|
||||
If the subnet has no explicit association it falls back to the VPC's main route
|
||||
table — check that one too, then add the route to whichever table applies:
|
||||
|
||||
```bash
|
||||
aws ec2 create-route --route-table-id $RT \
|
||||
--destination-cidr-block 0.0.0.0/0 --gateway-id $IGW
|
||||
```
|
||||
|
||||
On the host, the drop policy installed when a subnet lacks an IGW route appears
|
||||
as a `Logical_Router_Policy` on the VPC router:
|
||||
|
||||
```bash
|
||||
sudo ovn-nbctl lr-policy-list vpc-$VPC
|
||||
# A drop policy matching the subnet CIDR means the subnet is gated.
|
||||
```
|
||||
|
||||
### Public IP Not Reachable from WAN
|
||||
|
||||
Work through these checks in order — each eliminates a class of issues.
|
||||
|
||||
@@ -34,7 +34,7 @@ resources:
|
||||
## Overview
|
||||
|
||||
This guide assumes Spinifex is already installed and running. If not, follow one of the installation guides first:
|
||||
- [Single-Node Install)](/docs/install)
|
||||
- [Single-Node Install](/docs/install)
|
||||
- [Multi-Node Install](/docs/install-multi-node)
|
||||
- [Source Install](/docs/install-source)
|
||||
|
||||
@@ -50,7 +50,9 @@ export AWS_PROFILE=spinifex
|
||||
|
||||
## 1. Import an AMI
|
||||
|
||||
List available images and import one matching your architecture:
|
||||
### Option A: Import a bundled image
|
||||
|
||||
List the bundled images and import one matching your architecture:
|
||||
|
||||
```bash
|
||||
spx admin images list
|
||||
@@ -64,13 +66,11 @@ ubuntu-26.04-arm64 | ubuntu | 26.04 | arm64 | uefi
|
||||
ubuntu-26.04-x86_64 | ubuntu | 26.04 | x86_64 | uefi
|
||||
```
|
||||
|
||||
Import an image:
|
||||
|
||||
```bash
|
||||
spx admin images import --name ubuntu-26.04-x86_64
|
||||
```
|
||||
|
||||
Or import a local image file:
|
||||
### Option B: Import a local image file
|
||||
|
||||
```bash
|
||||
spx admin images import --file ~/images/ubuntu-26.04.img --distro ubuntu --version 26.04 --arch x86_64 --boot-mode uefi
|
||||
@@ -79,7 +79,7 @@ spx admin images import --file ~/images/ubuntu-26.04.img --distro ubuntu --versi
|
||||
Verify the import and note the AMI ID:
|
||||
|
||||
```bash
|
||||
aws ec2 describe-images
|
||||
AMI_ID=$(aws ec2 describe-images --query 'Images[0].ImageId' --output text)
|
||||
```
|
||||
|
||||
## 2. Create an SSH Key
|
||||
@@ -115,8 +115,6 @@ aws ec2 describe-key-pairs
|
||||
```bash
|
||||
VPC_ID=$(aws ec2 create-vpc --cidr-block 10.200.0.0/16 \
|
||||
--query 'Vpc.VpcId' --output text)
|
||||
|
||||
echo "VPC: $VPC_ID"
|
||||
```
|
||||
|
||||
### Create an Internet Gateway
|
||||
@@ -130,68 +128,95 @@ IGW_ID=$(aws ec2 create-internet-gateway \
|
||||
aws ec2 attach-internet-gateway \
|
||||
--internet-gateway-id $IGW_ID \
|
||||
--vpc-id $VPC_ID
|
||||
|
||||
echo "IGW: $IGW_ID (attached to $VPC_ID)"
|
||||
```
|
||||
|
||||
### Create a Public Subnet
|
||||
### Create a Subnet
|
||||
|
||||
A public subnet auto-assigns a routable IP to each instance, making it directly reachable from your network.
|
||||
Create the subnet your instances will launch into. The routing and public-IP steps below are what make it a *public* subnet.
|
||||
|
||||
```bash
|
||||
SUBNET_ID=$(aws ec2 create-subnet \
|
||||
--vpc-id $VPC_ID \
|
||||
--cidr-block 10.200.1.0/24 \
|
||||
--query 'Subnet.SubnetId' --output text)
|
||||
```
|
||||
|
||||
# Enable auto-assign public IP
|
||||
### Create a Route Table
|
||||
|
||||
A subnet is only **public** if its route table has a default route to the Internet Gateway. Spinifex does **not** add this route automatically — a new VPC's route table only routes traffic within the VPC (matching AWS). Without a `0.0.0.0/0` route to the IGW, instances in the subnet cannot reach the internet (and inbound connections cannot complete) even with a public IP assigned.
|
||||
|
||||
```bash
|
||||
RT_ID=$(aws ec2 create-route-table \
|
||||
--vpc-id $VPC_ID \
|
||||
--query 'RouteTable.RouteTableId' --output text)
|
||||
|
||||
# Default route to the internet via the IGW
|
||||
aws ec2 create-route \
|
||||
--route-table-id $RT_ID \
|
||||
--destination-cidr-block 0.0.0.0/0 \
|
||||
--gateway-id $IGW_ID
|
||||
|
||||
# Associate the route table with the subnet
|
||||
aws ec2 associate-route-table \
|
||||
--route-table-id $RT_ID \
|
||||
--subnet-id $SUBNET_ID
|
||||
```
|
||||
|
||||
### Enable Auto-Assign Public IP
|
||||
|
||||
Give every instance launched into the subnet a routable public IP, making it directly reachable from your network.
|
||||
|
||||
```bash
|
||||
aws ec2 modify-subnet-attribute \
|
||||
--subnet-id $SUBNET_ID \
|
||||
--map-public-ip-on-launch
|
||||
|
||||
echo "Subnet: $SUBNET_ID (public)"
|
||||
```
|
||||
|
||||
### Allow SSH and ICMP
|
||||
|
||||
Every VPC gets a default security group that **blocks inbound traffic from outside the group** (matching AWS). Instances launched without an explicit security group use this default, so you must authorize ingress before you can SSH or ping the instance.
|
||||
|
||||
```bash
|
||||
SG_ID=$(aws ec2 describe-security-groups \
|
||||
--filters Name=vpc-id,Values=$VPC_ID \
|
||||
--query 'SecurityGroups[0].GroupId' --output text)
|
||||
|
||||
# Allow SSH from anywhere
|
||||
aws ec2 authorize-security-group-ingress \
|
||||
--group-id $SG_ID \
|
||||
--protocol tcp --port 22 --cidr 0.0.0.0/0
|
||||
|
||||
# Allow ICMP (ping) from anywhere
|
||||
aws ec2 authorize-security-group-ingress \
|
||||
--group-id $SG_ID \
|
||||
--protocol icmp --port -1 --cidr 0.0.0.0/0
|
||||
```
|
||||
|
||||
**Note:** `0.0.0.0/0` opens these ports to every source. For anything beyond a quick evaluation, scope the `--cidr` to a trusted range. Rule changes apply immediately — no instance restart needed.
|
||||
|
||||
### Verify
|
||||
|
||||
```bash
|
||||
aws ec2 describe-vpcs --vpc-ids $VPC_ID
|
||||
aws ec2 describe-subnets --subnet-ids $SUBNET_ID
|
||||
aws ec2 describe-route-tables --route-table-ids $RT_ID
|
||||
aws ec2 describe-security-groups --group-ids $SG_ID
|
||||
```
|
||||
|
||||
## 4. Launch an Instance
|
||||
|
||||
Query available instance types for your hardware:
|
||||
Launch an instance in the public subnet on a `t3.micro` (2 vCPU / 1 GiB).
|
||||
|
||||
> **Note:** On an **arm64** host, use `t4g.micro` instead of `t3.micro`.
|
||||
|
||||
```bash
|
||||
aws ec2 describe-instance-types
|
||||
```
|
||||
|
||||
Launch an instance in the public subnet, note this will select an instance type with 2 VCPU and 1024MB of memory.
|
||||
|
||||
> **Note:** Replace `AMI_NAME` with the previously imported image above with the `ami-` prefix.
|
||||
|
||||
```bash
|
||||
AMI_NAME="ami-ubuntu-26.04-x86_64"
|
||||
|
||||
AMI_ID=$(aws ec2 describe-images \
|
||||
--filters "Name=name,Values=$AMI_NAME" \
|
||||
--query 'Images | sort_by(@, &CreationDate) | [-1].ImageId' \
|
||||
--output text)
|
||||
|
||||
INSTANCE_TYPE=$(aws ec2 describe-instance-types \
|
||||
--query "sort_by(InstanceTypes[?VCpuInfo.DefaultVCpus==\`2\` && MemoryInfo.SizeInMiB>=\`1024\`], &MemoryInfo.SizeInMiB)[0].InstanceType" \
|
||||
--output text)
|
||||
|
||||
INSTANCE_ID=$(aws ec2 run-instances \
|
||||
--image-id $AMI_ID \
|
||||
--instance-type $INSTANCE_TYPE \
|
||||
--instance-type t3.micro \
|
||||
--key-name spinifex-key \
|
||||
--subnet-id $SUBNET_ID \
|
||||
--count 1 \
|
||||
--query 'Instances[0].InstanceId' --output text)
|
||||
|
||||
echo "Instance: $INSTANCE_ID"
|
||||
```
|
||||
|
||||
Wait for the instance to reach `running` state:
|
||||
@@ -328,7 +353,36 @@ PRIVATE_SUBNET=$(aws ec2 create-subnet \
|
||||
--query 'Subnet.SubnetId' --output text)
|
||||
```
|
||||
|
||||
Instances in private subnets get a private IP only. They can still reach the internet via SNAT through the VPC router, but are not directly reachable from your network.
|
||||
Instances in a private subnet get a private IP only and are not reachable from your network. By default they also have **no internet access**: with no `0.0.0.0/0` route in the subnet's route table, Spinifex gates egress with a drop policy — matching AWS, where a private subnet has no route off the VPC. They can still reach other instances in the same VPC.
|
||||
|
||||
To give a private subnet outbound-only internet access, deploy a NAT Gateway in a public subnet and point the private subnet's default route at it (the AWS pattern):
|
||||
|
||||
```bash
|
||||
# Allocate an Elastic IP for the NAT Gateway
|
||||
NAT_EIP=$(aws ec2 allocate-address --query AllocationId --output text)
|
||||
|
||||
# Create the NAT Gateway in the PUBLIC subnet (the one with the IGW route)
|
||||
NATGW_ID=$(aws ec2 create-nat-gateway \
|
||||
--subnet-id $SUBNET_ID \
|
||||
--allocation-id $NAT_EIP \
|
||||
--query 'NatGateway.NatGatewayId' --output text)
|
||||
|
||||
# Give the private subnet its own route table with a default route to the NAT Gateway
|
||||
PRIVATE_RT=$(aws ec2 create-route-table \
|
||||
--vpc-id $VPC_ID \
|
||||
--query 'RouteTable.RouteTableId' --output text)
|
||||
|
||||
aws ec2 create-route \
|
||||
--route-table-id $PRIVATE_RT \
|
||||
--destination-cidr-block 0.0.0.0/0 \
|
||||
--nat-gateway-id $NATGW_ID
|
||||
|
||||
aws ec2 associate-route-table \
|
||||
--route-table-id $PRIVATE_RT \
|
||||
--subnet-id $PRIVATE_SUBNET
|
||||
```
|
||||
|
||||
Instances in the private subnet now reach the internet outbound through the NAT Gateway's public IP, but remain unreachable from the WAN.
|
||||
|
||||
### Multiple Accounts
|
||||
|
||||
@@ -348,7 +402,7 @@ journalctl -u spinifex-daemon -f
|
||||
aws ec2 describe-images
|
||||
```
|
||||
|
||||
### SSH Connection Refused
|
||||
### SSH Connection Refused or Times Out
|
||||
|
||||
cloud-init needs 30-60 seconds after boot. Check instance state:
|
||||
|
||||
@@ -356,6 +410,20 @@ cloud-init needs 30-60 seconds after boot. Check instance state:
|
||||
aws ec2 describe-instances --instance-ids $INSTANCE_ID
|
||||
```
|
||||
|
||||
A connection that **times out** (rather than being refused) usually means the security group is blocking port 22. Confirm the default security group allows SSH ingress:
|
||||
|
||||
```bash
|
||||
aws ec2 describe-security-groups --group-ids $SG_ID \
|
||||
--query 'SecurityGroups[0].IpPermissions'
|
||||
```
|
||||
|
||||
If there are no rules for TCP 22, authorize it (see [Allow SSH and ICMP](#allow-ssh-and-icmp)):
|
||||
|
||||
```bash
|
||||
aws ec2 authorize-security-group-ingress \
|
||||
--group-id $SG_ID --protocol tcp --port 22 --cidr 0.0.0.0/0
|
||||
```
|
||||
|
||||
### No Public IP Assigned
|
||||
|
||||
Verify the subnet has `MapPublicIpOnLaunch` enabled:
|
||||
@@ -378,7 +446,23 @@ aws ec2 describe-internet-gateways
|
||||
|
||||
### Instance Has No Internet Access
|
||||
|
||||
Check the VPC router's NAT rules (from the host):
|
||||
First confirm the subnet's route table has a default route to the IGW. Spinifex gates a subnet's egress with a drop policy when this route is missing, so an instance with a public IP still cannot reach the internet:
|
||||
|
||||
```bash
|
||||
aws ec2 describe-route-tables \
|
||||
--filters Name=association.subnet-id,Values=$SUBNET_ID \
|
||||
--query 'RouteTables[0].Routes'
|
||||
# Expect a 0.0.0.0/0 route with a GatewayId of igw-...
|
||||
```
|
||||
|
||||
If the route is missing, add it (see [Create a Route Table](#create-a-route-table)):
|
||||
|
||||
```bash
|
||||
aws ec2 create-route --route-table-id $RT_ID \
|
||||
--destination-cidr-block 0.0.0.0/0 --gateway-id $IGW_ID
|
||||
```
|
||||
|
||||
If the route is present, check the VPC router's NAT rules (from the host):
|
||||
|
||||
```bash
|
||||
sudo ovn-nbctl lr-nat-list $(sudo ovn-nbctl lr-list | awk '{print $2}' | head -1)
|
||||
|
||||
@@ -12,8 +12,6 @@ For this tutorial download UTM.app on Mac to simplify the VM installation.
|
||||
|
||||
* UTM - [https://mac.getutm.app](https://mac.getutm.app)
|
||||
|
||||
|
||||
|
||||
## Download Debian ISO
|
||||
|
||||
Next, download the Debian ISO (ARM64, Apple Silicon)
|
||||
@@ -28,7 +26,7 @@ Using the VM console, login using the `spinifex` user and your specified passwor
|
||||
|
||||
For a minimal Debian install additional dependencies are required prior to the Spinifex installation:
|
||||
|
||||
```
|
||||
```bash
|
||||
su root
|
||||
apt update
|
||||
apt install sudo curl bridge-utils
|
||||
@@ -38,7 +36,7 @@ apt install sudo curl bridge-utils
|
||||
|
||||
Enable sudo for `spinifex` user as the root user:
|
||||
|
||||
```
|
||||
```bash
|
||||
su root
|
||||
echo "spinifex ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/spinifex
|
||||
```
|
||||
|
||||
@@ -30,10 +30,12 @@ services:
|
||||
publishes:
|
||||
- "ec2.*"
|
||||
- "elbv2.*"
|
||||
- "acm.*"
|
||||
- "iam.*"
|
||||
depends_on:
|
||||
- "ec2.*"
|
||||
- "elbv2.*"
|
||||
- "acm.*"
|
||||
- "iam.*"
|
||||
|
||||
spinifex_daemon:
|
||||
@@ -50,6 +52,7 @@ services:
|
||||
- spinifex/daemon
|
||||
- spinifex/handlers/ec2
|
||||
- spinifex/handlers/elbv2
|
||||
- spinifex/handlers/acm
|
||||
- spinifex/handlers/iam
|
||||
subscribes:
|
||||
# EC2 instance lifecycle.
|
||||
@@ -95,6 +98,15 @@ services:
|
||||
- "elbv2.CreateListener"
|
||||
- "elbv2.DeleteListener"
|
||||
- "elbv2.DescribeListeners"
|
||||
# ACM-alike certificate store (handlers/acm/service_nats.go topic map).
|
||||
- "acm.ImportCertificate"
|
||||
- "acm.DescribeCertificate"
|
||||
- "acm.ListCertificates"
|
||||
- "acm.DeleteCertificate"
|
||||
- "elbv2.AddListenerCertificates"
|
||||
- "elbv2.RemoveListenerCertificates"
|
||||
- "elbv2.DescribeListenerCertificates"
|
||||
- "elbv2.DescribeSSLPolicies"
|
||||
publishes:
|
||||
# → vpcd
|
||||
- "vpc.create"
|
||||
|
||||
@@ -142,7 +142,6 @@ The ALB is internet-facing, but the DNS name Spinifex returns (`*.elb.spinifex.l
|
||||
ALB_IP=$(aws elbv2 describe-load-balancers --names nginx-alb \
|
||||
--query 'LoadBalancers[0].AvailabilityZones[].LoadBalancerAddresses[].IpAddress' \
|
||||
--output text)
|
||||
echo "ALB public IP: $ALB_IP"
|
||||
```
|
||||
|
||||
Then hit the ALB — successive requests should alternate between Server 1 and Server 2:
|
||||
@@ -163,8 +162,6 @@ TG_ARN=$(aws elbv2 describe-target-groups \
|
||||
--query 'TargetGroups[0].TargetGroupArn' \
|
||||
--output text)
|
||||
|
||||
echo "Target Group ARN: $TG_ARN"
|
||||
|
||||
aws elbv2 describe-target-health --target-group-arn $TG_ARN
|
||||
```
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ require (
|
||||
github.com/klauspost/cpuid/v2 v2.3.0
|
||||
github.com/mulgadc/predastore v1.6.0
|
||||
github.com/mulgadc/viperblock v1.6.0
|
||||
github.com/nats-io/nats-server/v2 v2.14.1
|
||||
github.com/nats-io/nats-server/v2 v2.14.2
|
||||
github.com/nats-io/nats.go v1.52.0
|
||||
github.com/ovn-kubernetes/libovsdb v0.8.1
|
||||
github.com/pelletier/go-toml/v2 v2.3.1
|
||||
@@ -101,8 +101,8 @@ require (
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/nats-io/jwt/v2 v2.8.1 // indirect
|
||||
github.com/nats-io/nkeys v0.4.15 // indirect
|
||||
github.com/nats-io/jwt/v2 v2.8.2 // indirect
|
||||
github.com/nats-io/nkeys v0.4.16 // indirect
|
||||
github.com/nats-io/nuid v1.0.1 // indirect
|
||||
github.com/pierrec/lz4/v4 v4.1.14 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
|
||||
@@ -296,14 +296,14 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
|
||||
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
|
||||
github.com/nats-io/jwt/v2 v2.8.1 h1:V0xpGuD/N8Mi+fQNDynXohVvp7ZztevW5io8CUWlPmU=
|
||||
github.com/nats-io/jwt/v2 v2.8.1/go.mod h1:nWnOEEiVMiKHQpnAy4eXlizVEtSfzacZ1Q43LIRavZg=
|
||||
github.com/nats-io/nats-server/v2 v2.14.1 h1:wXs/a5fw9Hzm3CvuzLxGeIwpjPulSa7gMT3eSuhGkcg=
|
||||
github.com/nats-io/nats-server/v2 v2.14.1/go.mod h1:4N17zLpuS7WMbG8T9gsE2B7z9hC9PraPyulVBfpK6nU=
|
||||
github.com/nats-io/jwt/v2 v2.8.2 h1:XXRgB60MSTnqsRwejQurVDs/hcv2dkt+86GjI+I/bMc=
|
||||
github.com/nats-io/jwt/v2 v2.8.2/go.mod h1:Ag/56sq9OblL4JgdYufDd16Egb17Kr/8WwwuO/forVc=
|
||||
github.com/nats-io/nats-server/v2 v2.14.2 h1:Q7dRhCY03Y00rETFW3KV+KGaCIajlDfWgWUVgbMxyuk=
|
||||
github.com/nats-io/nats-server/v2 v2.14.2/go.mod h1:lWpb1bSpRELZfRdlMkdz8E7lbXKKyNe8RIn0vvepIHs=
|
||||
github.com/nats-io/nats.go v1.52.0 h1:n3avV4VBsCgsdwh71TppsTwtv+QdPs7ntSKM8qJLGsc=
|
||||
github.com/nats-io/nats.go v1.52.0/go.mod h1:26HypzazeOkyO3/mqd1zZd53STJN0EjCYF9Uy2ZOBno=
|
||||
github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4=
|
||||
github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs=
|
||||
github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg=
|
||||
github.com/nats-io/nkeys v0.4.16/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs=
|
||||
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
|
||||
github.com/ovn-kubernetes/libovsdb v0.8.1 h1:M2J8bcJt5mXCom0HqzfEtuHkT80CTSQRcYG7acT8gf4=
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
// Package eksgw is the on-VM SigV4 client EKS control-plane helpers use to
|
||||
// reach the AWS gateway, instead of dialing core NATS. Both eks-gateway-publish
|
||||
// (bootstrap + state relay) and eks-token-webhook (TokenReview relay) sign with
|
||||
// the system (Predastore) credentials for service "eks" and POST over HTTPS;
|
||||
// the gateway authorizes via IAM and relays to the host-side NATS/STS/KV.
|
||||
package eksgw
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mulgadc/predastore/auth"
|
||||
"github.com/mulgadc/spinifex/internal/tlsconfig"
|
||||
)
|
||||
|
||||
// Client posts SigV4-signed requests to the AWS gateway for the "eks" service.
|
||||
type Client struct {
|
||||
baseURL string
|
||||
accessKey string
|
||||
secretKey string
|
||||
region string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// New builds a client. caPath optionally pins the gateway TLS CA; when empty the
|
||||
// client relies on the system trust store. region defaults to us-east-1 when
|
||||
// empty (SigV4 requires a non-empty region).
|
||||
func New(baseURL, caPath, accessKey, secretKey, region string) (*Client, error) {
|
||||
if baseURL == "" {
|
||||
return nil, fmt.Errorf("eksgw: baseURL is required")
|
||||
}
|
||||
if accessKey == "" || secretKey == "" {
|
||||
return nil, fmt.Errorf("eksgw: access key and secret key are required")
|
||||
}
|
||||
if region == "" {
|
||||
region = "us-east-1"
|
||||
}
|
||||
|
||||
tlsCfg := &tls.Config{
|
||||
MinVersion: tls.VersionTLS13,
|
||||
CurvePreferences: tlsconfig.Curves,
|
||||
}
|
||||
if caPath != "" {
|
||||
pem, err := os.ReadFile(caPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("eksgw: read gateway CA %q: %w", caPath, err)
|
||||
}
|
||||
pool := x509.NewCertPool()
|
||||
if !pool.AppendCertsFromPEM(pem) {
|
||||
return nil, fmt.Errorf("eksgw: gateway CA %q has no usable certificates", caPath)
|
||||
}
|
||||
tlsCfg.RootCAs = pool
|
||||
}
|
||||
|
||||
return &Client{
|
||||
baseURL: strings.TrimRight(baseURL, "/"),
|
||||
accessKey: accessKey,
|
||||
secretKey: secretKey,
|
||||
region: region,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
Transport: &http.Transport{TLSClientConfig: tlsCfg, MaxIdleConns: 2, IdleConnTimeout: 30 * time.Second},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Post SigV4-signs and sends a single POST of body to path (e.g.
|
||||
// "/clusters/alpha/token-review"). It returns the response body on a 2xx, or an
|
||||
// error (including the gateway status + body) otherwise. No retry — callers that
|
||||
// need it wrap Post in their own loop.
|
||||
func (c *Client) Post(path string, body []byte) ([]byte, error) {
|
||||
req, err := http.NewRequest(http.MethodPost, c.baseURL+path, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-amz-json-1.1")
|
||||
|
||||
sum := sha256.Sum256(body)
|
||||
if err := auth.SignReq(req, c.accessKey, c.secretKey, hex.EncodeToString(sum[:]), "eks", c.region); err != nil {
|
||||
return nil, fmt.Errorf("sign request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("send request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
respBody, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return nil, fmt.Errorf("gateway returned %d: %s", resp.StatusCode, strings.TrimSpace(string(respBody)))
|
||||
}
|
||||
return respBody, nil
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
.creds/
|
||||
*.retry
|
||||
@@ -1,222 +0,0 @@
|
||||
# Ansible Dev Lifecycle (parallel to bash scripts)
|
||||
|
||||
Experimental Ansible-based lifecycle for a single-node Spinifex dev host.
|
||||
Lives alongside the existing `scripts/dev-*.sh` and `scripts/reset-dev-env.sh`;
|
||||
neither set is removed. Decide which to keep after parity testing.
|
||||
|
||||
Plan: `docs/development/improvements/ansible-dev-lifecycle.md` (mulga top-level).
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Venv lives at sub-repo root (`spinifex/.venv/`) — one venv per sub-repo,
|
||||
shared by any future python tooling in spinifex. Bootstrap from `spinifex/`:
|
||||
|
||||
```
|
||||
sudo apt install python3.13-venv
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install 'ansible>=9'
|
||||
ansible-galaxy collection install -r scripts/ansible/requirements.yml
|
||||
```
|
||||
|
||||
Then `cd scripts/ansible` to run playbooks — `ansible.cfg` resolves the
|
||||
inventory and roles relative to CWD.
|
||||
|
||||
## Playbooks
|
||||
|
||||
| Playbook | Purpose | Bash parity |
|
||||
|---|---|---|
|
||||
| `playbooks/dev-preflight.yml` | Dependency + port check | `dev-setup.sh` §checks |
|
||||
| `playbooks/dev-teardown.yml` | Wipe state only (no reinstall) | `reset-dev-env.sh` §teardown |
|
||||
| `playbooks/dev-install.yml` | Build + install + init + smoketest on clean box | `dev-install.sh` |
|
||||
| `playbooks/dev-reset.yml` | Capture settings → teardown → build → install → init → smoketest | `reset-dev-env.sh` (full) |
|
||||
| `playbooks/dev-deploy.yml` | Rebuild + swap binaries/microvm artifacts + restart (no setup.sh, no smoketest) | `make deploy` |
|
||||
| `playbooks/dev-status.yml` | Read-only health snapshot (units, ports, OVN/OVS, gateway, counts, drift) | none |
|
||||
| `playbooks/dev-logs.yml` | Dump journald + `/var/log/spinifex/*` + status/networking/versions into a tarball | none |
|
||||
| `playbooks/dev-snapshot.yml` | Quiesce spinifex+OVN+OVS, tar state paths to a named bundle, restart | none |
|
||||
| `playbooks/dev-restore.yml` | Stop services, wipe + untar a named bundle, refresh CA, restart | none |
|
||||
| `playbooks/dev-version.yml` | Build-vs-install drift report (spx, plugin, microvm) + sub-repo HEADs | none |
|
||||
| `playbooks/dev-vpc.yml` | Idempotent dev VPC fixture (VPC + public subnet + IGW + default route); `-e dev_vpc_state=absent` tears down | partial overlap with `scripts/iac/examples/*` |
|
||||
|
||||
Upcoming (not yet implemented):
|
||||
|
||||
- `dev-mode-start.yml` / `dev-mode-stop.yml` — parity with `start-dev.sh` / `stop-dev.sh`
|
||||
(lowest priority — deprecated bash scripts, CI-only callers).
|
||||
|
||||
## Invocation
|
||||
|
||||
```
|
||||
ansible-playbook playbooks/dev-preflight.yml
|
||||
ansible-playbook playbooks/dev-teardown.yml
|
||||
ansible-playbook playbooks/dev-install.yml
|
||||
ansible-playbook playbooks/dev-reset.yml
|
||||
ansible-playbook playbooks/dev-deploy.yml
|
||||
ansible-playbook playbooks/dev-status.yml
|
||||
ansible-playbook playbooks/dev-logs.yml
|
||||
ansible-playbook playbooks/dev-snapshot.yml -e snapshot_name=before-merge
|
||||
ansible-playbook playbooks/dev-restore.yml -e snapshot_name=before-merge
|
||||
ansible-playbook playbooks/dev-version.yml
|
||||
ansible-playbook playbooks/dev-vpc.yml
|
||||
```
|
||||
|
||||
Or via `make` (from `spinifex/`):
|
||||
|
||||
```
|
||||
make ansible-dev-preflight
|
||||
make ansible-dev-teardown
|
||||
make ansible-dev-install
|
||||
make ansible-dev-reset
|
||||
make ansible-dev-deploy
|
||||
make ansible-dev-status
|
||||
make ansible-dev-logs
|
||||
make ansible-dev-snapshot ANSIBLE_EXTRA='-e snapshot_name=before-merge'
|
||||
make ansible-dev-restore ANSIBLE_EXTRA='-e snapshot_name=before-merge'
|
||||
make ansible-dev-version
|
||||
make ansible-dev-vpc
|
||||
make ansible-dev-vpc ANSIBLE_EXTRA='-e dev_vpc_state=absent'
|
||||
```
|
||||
|
||||
### When to use which
|
||||
|
||||
- First-time / clean box → `ansible-dev-install`
|
||||
- Iterate on Go code, microvm initramfs, lb-agent → `ansible-dev-deploy` (fast)
|
||||
- Changed systemd units, helper scripts, logrotate, setup.sh → `ansible-dev-reset` (slow, full rebuild)
|
||||
- Need a clean slate without reinstall → `ansible-dev-teardown`
|
||||
- "Is my dev box healthy?" → `ansible-dev-status` (read-only, never mutates)
|
||||
- Filing a bug / capturing the state of a broken box → `ansible-dev-logs` (writes `/tmp/spinifex-logs-<host>-<ts>.tar.gz`; override `-e logs_since=10min` or `-e logs_include_dmesg=false`)
|
||||
- Checkpoint before a risky op (branch switch, viperblock surgery, ovn restart cycle) → `ansible-dev-snapshot ANSIBLE_EXTRA='-e snapshot_name=<name>'`; roll back via `ansible-dev-restore` instead of a full `dev-reset`. Bundles live in `/var/lib/spinifex-snapshots/<name>.tar.gz` (root-owned, survives `dev-teardown`).
|
||||
- "Do I need to `ansible-dev-deploy`?" → `ansible-dev-version` (drift-only check; faster than `dev-status`)
|
||||
- Want a known-good VPC/subnet for manual EC2 testing without writing tofu → `ansible-dev-vpc` (smoketest will then find and use it automatically). Tear down with `ANSIBLE_EXTRA='-e dev_vpc_state=absent'`.
|
||||
|
||||
## Variable overrides
|
||||
|
||||
Defaults in `vars/defaults.yml`. Override per run with `-e`:
|
||||
|
||||
```
|
||||
ansible-playbook playbooks/dev-teardown.yml -e spinifex_wipe_ssh_keys=true
|
||||
```
|
||||
|
||||
Useful overrides:
|
||||
|
||||
| Var | Default | What it does |
|
||||
|---|---|---|
|
||||
| `spinifex_wipe_legacy_home` | `true` | Remove `$HOME/spinifex` (legacy dev-mode path) |
|
||||
| `spinifex_wipe_ssh_keys` | `false` | Remove `~/.ssh/spinifex-key*` |
|
||||
| `spinifex_wipe_aws_creds` | `false` | Remove spinifex profile from `~/.aws/credentials` |
|
||||
| `teardown_process_wait_seconds` | `30` | Grace period for processes to exit |
|
||||
| `spinifex_region` | `ap-southeast-2` | Region passed to `spx admin init` |
|
||||
| `spinifex_az` | `{{ region }}a` | AZ passed to `spx admin init` |
|
||||
| `spinifex_external_mode` | `pool` | External networking mode — `pool` or empty (NAT removed) |
|
||||
| `spinifex_external_no_external` | `false` | `true` → emit `--no-external` and skip external networking |
|
||||
| `spinifex_external_pool_start` | `""` | **Required** for `pool` mode — first IP of the static WAN pool |
|
||||
| `spinifex_external_pool_end` | `""` | **Required** for `pool` mode — last IP of the static WAN pool |
|
||||
| `spinifex_external_gateway` | `""` | WAN gateway (auto-detected when blank) |
|
||||
| `spinifex_external_prefix_len` | `""` | WAN prefix length (auto-detected when blank) |
|
||||
| `spinifex_external_gateway_ip` | `""` | OVN router external-IP override (`--gateway-ip`) |
|
||||
|
||||
`pool` mode is mandatory unless you set `spinifex_external_no_external=true`.
|
||||
`spx admin init` rejects the run without a `--external-pool` range, so the
|
||||
init role asserts `spinifex_external_pool_start/_end` upfront. Either persist
|
||||
them in `vars/defaults.yml`, or pass them at invocation:
|
||||
|
||||
Direct `ansible-playbook` (use `-e` here — this is ansible's flag):
|
||||
|
||||
```
|
||||
ansible-playbook playbooks/dev-install.yml \
|
||||
-e spinifex_external_pool_start=192.168.1.150 \
|
||||
-e spinifex_external_pool_end=192.168.1.250
|
||||
```
|
||||
|
||||
Via `make` (use `EXTRA_VARS=` — make eats its own `-e`):
|
||||
|
||||
```
|
||||
make ansible-dev-install \
|
||||
EXTRA_VARS="spinifex_external_pool_start=192.168.1.150 spinifex_external_pool_end=192.168.1.250"
|
||||
```
|
||||
|
||||
`dev-reset.yml` automatically captures `region`, `az`, `external_mode`,
|
||||
pool range, gateway, prefix-len and `gateway_ip` from the existing
|
||||
`/etc/spinifex/spinifex.toml` before teardown and replays them into
|
||||
`init`. Legacy `external_mode = "nat"` is coerced to `"pool"` with a warn.
|
||||
Use `-e` to override.
|
||||
|
||||
## Helper scripts
|
||||
|
||||
`scripts/aws-as-spinifex.sh` and `scripts/spx-as-spinifex.sh` wrap `aws`
|
||||
and `spx` invocations in `sg spinifex -c '...'` so they run with the
|
||||
`spinifex` supplementary group active. Both helpers preset
|
||||
`AWS_PROFILE=spinifex`. All AWS-calling tasks in the roles use these
|
||||
via `spinifex_aws_cli` / `spinifex_spx_cli` from `vars/defaults.yml`.
|
||||
|
||||
Background: `/etc/spinifex/ca.pem` lives under `drwxr-x--- root:spinifex`,
|
||||
so the AWS CLI needs `spinifex` group membership to read the CA bundle.
|
||||
Ansible's `become_user` is a no-op when the target user equals the
|
||||
calling user (no `sudo -u` invoked → no `initgroups(3)` → supplementary
|
||||
groups inherited from the operator's shell). On a fresh login the
|
||||
operator's shell would have `spinifex` (added by `setup.sh`); on shells
|
||||
that predate the group add it does not. The helpers sidestep this
|
||||
entirely.
|
||||
|
||||
## Leak catalog
|
||||
|
||||
`roles/teardown/README.md` tracks every known state source. When teardown
|
||||
misses something, add it there and in `roles/teardown/tasks/main.yml`.
|
||||
|
||||
## Multi-node cluster bootstrap
|
||||
|
||||
`playbooks/cluster-bootstrap.yml` provisions a 2+ node Spinifex cluster
|
||||
across a long-running tofu env (`env1`, `env2`, `bryn`, etc). Inventory
|
||||
is generated dynamically from `scripts/tofu-cluster/envs/<env>.tfvars`
|
||||
via `inventory/tofu.py` — first node in `nodes = [...]` is the init host,
|
||||
the rest are joins.
|
||||
|
||||
```bash
|
||||
make ansible-cluster-bootstrap ENV=env1 POOL=192.168.1.150-192.168.1.159
|
||||
```
|
||||
|
||||
`POOL` is optional; omit for DHCP fallback. Equivalent without make:
|
||||
|
||||
```bash
|
||||
cd scripts/ansible
|
||||
CLUSTER_ENV=env1 ansible-playbook -i inventory/tofu.py \
|
||||
playbooks/cluster-bootstrap.yml \
|
||||
-e cluster_env=env1 \
|
||||
-e cluster_external_pool=192.168.1.150-192.168.1.159
|
||||
```
|
||||
|
||||
Plays sequence:
|
||||
|
||||
1. **runner (localhost)** — `build` role produces `/tmp/spinifex-local.tar.gz`.
|
||||
2. **all env hosts (parallel)** — copies tarball, stops any dev-layout
|
||||
services (`~/spinifex/`), runs `preflight` + `install` roles. `setup.sh`
|
||||
creates `/etc/spinifex`, the `spinifex` user/group, systemd units, and
|
||||
installs `/usr/local/bin/spx`.
|
||||
3. **init host only** — `cluster-init` role runs `spx admin init` in the
|
||||
background, waits for the formation server, captures the join token.
|
||||
CA is then copied into `/usr/local/share/ca-certificates/spinifex-ca.crt`
|
||||
**before** `spinifex.target` starts (required — predastore enforces
|
||||
strict TLS verify against the system trust store).
|
||||
4. **join hosts (parallel)** — `cluster-join` role runs `spx admin join`
|
||||
against the formation server, then installs CA, then starts
|
||||
`spinifex.target`.
|
||||
5. **all hosts** — polls `https://<ip>:4432/health` until `awsgw=ok`.
|
||||
6. **init host only** — `cluster-post` role imports the SSH key + Ubuntu
|
||||
AMI (both idempotent: skip if already present).
|
||||
|
||||
Re-running the playbook against an already-bootstrapped env is safe —
|
||||
`setup.sh`, `spx admin init --force`, CA copy, key/AMI import are all
|
||||
idempotent.
|
||||
|
||||
### Migration from `bootstrap.sh`
|
||||
|
||||
`scripts/tofu-cluster/bootstrap.sh` ran services under `~/spinifex/` as
|
||||
`tf-user` via `start-dev.sh` with no systemd supervision, and installed
|
||||
the cluster CA *after* services started — predastore would crash with
|
||||
`x509: certificate signed by unknown authority` on every first boot.
|
||||
`cluster-bootstrap.yml` replaces that flow with production-layout
|
||||
(`/etc/spinifex/` + systemd) and correct CA-before-services ordering.
|
||||
The first run on an env previously bootstrapped by `bootstrap.sh` will
|
||||
stop the dev-layout services, install the prod layout, and re-init the
|
||||
cluster from scratch via `spx admin init --force`.
|
||||
|
||||
`bootstrap.sh` stays in-tree for CI and pseudo-multinode envs.
|
||||
@@ -1,17 +0,0 @@
|
||||
[defaults]
|
||||
inventory = inventory/localhost.yml
|
||||
roles_path = roles
|
||||
stdout_callback = ansible.builtin.default
|
||||
host_key_checking = False
|
||||
retry_files_enabled = False
|
||||
interpreter_python = auto_silent
|
||||
forks = 10
|
||||
|
||||
[inventory]
|
||||
# inventory/tofu.py is an executable script inventory used by
|
||||
# cluster-bootstrap.yml — opted into via `-i inventory/tofu.py` on the
|
||||
# command line; not default to keep dev-* playbooks pointing at localhost.
|
||||
enable_plugins = host_list, script, auto, yaml, ini, toml
|
||||
|
||||
[callback_default]
|
||||
result_format = yaml
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
all:
|
||||
hosts:
|
||||
localhost:
|
||||
ansible_connection: local
|
||||
ansible_python_interpreter: "{{ ansible_playbook_python }}"
|
||||
@@ -1,170 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Dynamic ansible inventory generator from tofu-cluster tfvars.
|
||||
|
||||
Reads scripts/tofu-cluster/envs/<env>.tfvars (HCL subset) and emits ansible
|
||||
JSON inventory. First node in the tfvars `nodes = [...]` list is the init
|
||||
host (forms the cluster); the rest are join hosts.
|
||||
|
||||
Usage:
|
||||
inventory/tofu.py --list --env env1
|
||||
inventory/tofu.py --host <ip> --env env1
|
||||
|
||||
Selecting the env:
|
||||
CLUSTER_ENV=env1 inventory/tofu.py --list
|
||||
inventory/tofu.py --list --env env1
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _repo_root() -> Path:
|
||||
here = Path(__file__).resolve()
|
||||
for parent in here.parents:
|
||||
if (parent / "go.work").exists() or (parent / ".gitmodules").exists():
|
||||
return parent
|
||||
raise SystemExit(f"could not locate mulga repo root from {here}")
|
||||
|
||||
|
||||
def _tfvars_path(env: str) -> Path:
|
||||
root = _repo_root()
|
||||
candidate = root / "scripts" / "tofu-cluster" / "envs" / f"{env}.tfvars"
|
||||
if not candidate.exists():
|
||||
raise SystemExit(f"tfvars file not found: {candidate}")
|
||||
return candidate
|
||||
|
||||
|
||||
_STRING_RE = re.compile(r'^\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*=\s*"([^"]*)"\s*$')
|
||||
_NUMBER_RE = re.compile(r'^\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*=\s*([0-9]+)\s*$')
|
||||
_NODE_FIELD_RE = re.compile(r'(\w+)\s*=\s*"([^"]*)"')
|
||||
|
||||
|
||||
def _parse_tfvars(path: Path) -> dict:
|
||||
"""Minimal tfvars parser — handles the subset used by envs/*.tfvars.
|
||||
|
||||
Supports:
|
||||
key = "string"
|
||||
key = 12345
|
||||
nodes = [
|
||||
{ name = "x", wan_ip = "1.2.3.4", ... },
|
||||
...
|
||||
]
|
||||
"""
|
||||
out: dict = {"nodes": []}
|
||||
text = path.read_text()
|
||||
|
||||
# Strip line comments
|
||||
text = re.sub(r"#.*", "", text)
|
||||
|
||||
# Pull the nodes block out first, then process remaining lines.
|
||||
nodes_match = re.search(r"nodes\s*=\s*\[(.+?)\]\s*$", text, re.DOTALL | re.MULTILINE)
|
||||
if nodes_match:
|
||||
nodes_body = nodes_match.group(1)
|
||||
for entry in re.finditer(r"\{([^}]+)\}", nodes_body):
|
||||
fields = dict(_NODE_FIELD_RE.findall(entry.group(1)))
|
||||
if "name" in fields and "wan_ip" in fields:
|
||||
out["nodes"].append(fields)
|
||||
text = text.replace(nodes_match.group(0), "")
|
||||
|
||||
for line in text.splitlines():
|
||||
if m := _STRING_RE.match(line):
|
||||
out[m.group(1)] = m.group(2)
|
||||
elif m := _NUMBER_RE.match(line):
|
||||
out[m.group(1)] = int(m.group(2))
|
||||
|
||||
return out
|
||||
|
||||
|
||||
def _resolve_ssh_key(value: str | None) -> str:
|
||||
if not value:
|
||||
return os.path.expanduser("~/.ssh/tf-user-ap-southeast-2")
|
||||
return os.path.expanduser(value)
|
||||
|
||||
|
||||
def _build_inventory(env: str) -> dict:
|
||||
data = _parse_tfvars(_tfvars_path(env))
|
||||
nodes = data.get("nodes") or []
|
||||
if not nodes:
|
||||
raise SystemExit(f"{env}.tfvars has no nodes")
|
||||
|
||||
ssh_user = data.get("ssh_user", "tf-user")
|
||||
ssh_key = _resolve_ssh_key(data.get("ssh_public_key_path", "").replace(".pub", ""))
|
||||
|
||||
hostvars: dict[str, dict] = {}
|
||||
all_hosts: list[str] = []
|
||||
init_hosts: list[str] = []
|
||||
join_hosts: list[str] = []
|
||||
|
||||
for idx, node in enumerate(nodes):
|
||||
ip = node["wan_ip"]
|
||||
role = "init" if idx == 0 else "join"
|
||||
hostvars[ip] = {
|
||||
"ansible_host": ip,
|
||||
"ansible_user": ssh_user,
|
||||
"ansible_ssh_private_key_file": ssh_key,
|
||||
"ansible_ssh_common_args": "-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR",
|
||||
"spinifex_node_name": node["name"],
|
||||
"spinifex_node_role": role,
|
||||
"spinifex_node_index": idx,
|
||||
"spinifex_wan_ip": ip,
|
||||
}
|
||||
all_hosts.append(ip)
|
||||
(init_hosts if role == "init" else join_hosts).append(ip)
|
||||
|
||||
# Group-level vars derived from tfvars (operator can override with -e).
|
||||
cluster_init_host = init_hosts[0]
|
||||
group_vars = {
|
||||
"cluster_env": env,
|
||||
"cluster_init_host": cluster_init_host,
|
||||
"cluster_init_ip": cluster_init_host,
|
||||
"spinifex_node_count": len(nodes),
|
||||
"spinifex_region": data.get("region", "ap-southeast-2"),
|
||||
"spinifex_az": data.get("az", "ap-southeast-2a"),
|
||||
}
|
||||
if cidr := data.get("env_cidr"):
|
||||
group_vars["cluster_env_cidr"] = cidr
|
||||
|
||||
inventory = {
|
||||
"_meta": {"hostvars": hostvars},
|
||||
env: {"hosts": all_hosts, "vars": group_vars},
|
||||
"cluster": {"children": [env]},
|
||||
"init": {"hosts": init_hosts},
|
||||
"join": {"hosts": join_hosts},
|
||||
}
|
||||
return inventory
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--list", action="store_true", help="emit full inventory")
|
||||
parser.add_argument("--host", help="emit hostvars for a single host (ignored — _meta carries them)")
|
||||
parser.add_argument(
|
||||
"--env",
|
||||
default=os.environ.get("CLUSTER_ENV"),
|
||||
help="tofu env name (matches scripts/tofu-cluster/envs/<env>.tfvars); also via CLUSTER_ENV env var",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.env:
|
||||
sys.stderr.write("error: --env required (or CLUSTER_ENV)\n")
|
||||
return 64
|
||||
|
||||
if args.host:
|
||||
json.dump({}, sys.stdout)
|
||||
sys.stdout.write("\n")
|
||||
return 0
|
||||
|
||||
inventory = _build_inventory(args.env)
|
||||
json.dump(inventory, sys.stdout, indent=2)
|
||||
sys.stdout.write("\n")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,330 +0,0 @@
|
||||
---
|
||||
# cluster-bootstrap.yml — multi-node Spinifex cluster bootstrap against a
|
||||
# tofu-provisioned env. Inventory comes from inventory/tofu.py which parses
|
||||
# scripts/tofu-cluster/envs/<env>.tfvars.
|
||||
#
|
||||
# Usage:
|
||||
# cd spinifex/scripts/ansible
|
||||
# ansible-playbook -i inventory/tofu.py playbooks/cluster-bootstrap.yml \
|
||||
# -e cluster_env=env1 -e cluster_external_pool=192.168.1.150-192.168.1.159
|
||||
#
|
||||
# Or via the make wrapper:
|
||||
# make ansible-cluster-bootstrap ENV=env1 POOL=192.168.1.150-192.168.1.159
|
||||
#
|
||||
# Behaviour:
|
||||
# - all hosts: preflight + install (setup.sh) — production layout
|
||||
# - init host: spx admin init (backgrounded) + CA install + spinifex.target
|
||||
# - join hosts: spx admin join + CA install + spinifex.target (parallel)
|
||||
# - init host: import ssh key + ubuntu AMI (idempotent)
|
||||
|
||||
- name: Cluster bootstrap — build artifact once on runner
|
||||
hosts: localhost
|
||||
gather_facts: true
|
||||
vars_files:
|
||||
- ../vars/defaults.yml
|
||||
tasks:
|
||||
- name: assert cluster_env supplied
|
||||
ansible.builtin.assert:
|
||||
that: cluster_env | default('') | length > 0
|
||||
fail_msg: "Pass -e cluster_env=<env> (matches scripts/tofu-cluster/envs/<env>.tfvars)"
|
||||
roles:
|
||||
- build
|
||||
|
||||
- name: Cluster bootstrap — install (all nodes, parallel)
|
||||
hosts: "{{ cluster_env }}"
|
||||
gather_facts: true
|
||||
serial: 100%
|
||||
vars:
|
||||
install_run_ovn: false # we drive setup-ovn ourselves with role-aware args
|
||||
cluster_bootstrap_purge: true # wipe stale single-node predastore.toml + raft data
|
||||
vars_files:
|
||||
- ../vars/defaults.yml
|
||||
pre_tasks:
|
||||
- name: copy build tarball to each node
|
||||
ansible.builtin.copy:
|
||||
src: "{{ build_tarball_path }}"
|
||||
dest: "{{ build_tarball_path }}"
|
||||
mode: "0644"
|
||||
|
||||
- name: stop dev-layout services if running (migration from ~/spinifex/)
|
||||
become: true
|
||||
# Bracket trick on the first letter prevents the pattern literal in the
|
||||
# shell command line from matching the running shell itself (pkill -f
|
||||
# against literal patterns is otherwise self-killing — rc=-9).
|
||||
# `spx [a]dmin` catches lingering formation servers from prior bootstrap
|
||||
# attempts that own port 4432 and steal joins from the new init.
|
||||
ansible.builtin.shell:
|
||||
cmd: |
|
||||
pkill -9 -f "[b]in/spx service" 2>/dev/null || true
|
||||
pkill -9 -f "spx [a]dmin" 2>/dev/null || true
|
||||
pkill -9 -f "spinifex-[u]i" 2>/dev/null || true
|
||||
pkill -9 -f "[n]ats-server" 2>/dev/null || true
|
||||
pkill -9 -f "[p]redastore" 2>/dev/null || true
|
||||
pkill -9 -f "[v]iperblock" 2>/dev/null || true
|
||||
sleep 2
|
||||
rm -rf "{{ ansible_env.HOME }}/spinifex" 2>/dev/null || true
|
||||
exit 0
|
||||
changed_when: false
|
||||
roles:
|
||||
- install
|
||||
|
||||
# Wipe OVN/OVS state before setup-ovn runs. Without this, re-bootstrapping a
|
||||
# previously-built cluster leaves stale chassis rows in the SB DB, stale
|
||||
# external_ids on Open_vSwitch, stale br-int flows + ports, stale
|
||||
# systemd-networkd unit files, and stale ovs-monitor-ipsec strongSwan certs.
|
||||
# Symptom: ovn-controller flows reference dead encap endpoints; IPsec SAs
|
||||
# fail AUTHENTICATION_FAILED; new joins shadow ghosts under the same
|
||||
# node-name.
|
||||
- name: Cluster bootstrap — OVN/OVS state reset (all nodes, parallel)
|
||||
hosts: "{{ cluster_env }}"
|
||||
gather_facts: false
|
||||
serial: 100%
|
||||
vars_files:
|
||||
- ../vars/defaults.yml
|
||||
tasks:
|
||||
- name: reset — stop OVN/OVS services
|
||||
become: true
|
||||
ansible.builtin.systemd:
|
||||
name: "{{ item }}"
|
||||
state: stopped
|
||||
loop:
|
||||
- ovn-controller
|
||||
- ovn-central
|
||||
- openvswitch-ipsec
|
||||
- openvswitch-switch
|
||||
failed_when: false
|
||||
|
||||
- name: reset — delete OVN NB/SB databases
|
||||
become: true
|
||||
ansible.builtin.file:
|
||||
path: "{{ item }}"
|
||||
state: absent
|
||||
loop:
|
||||
- "{{ ovn_nb_db }}"
|
||||
- "{{ ovn_sb_db }}"
|
||||
|
||||
- name: reset — delete OVS conf.db + system-id (force fresh ovsdb)
|
||||
become: true
|
||||
ansible.builtin.file:
|
||||
path: "{{ item }}"
|
||||
state: absent
|
||||
loop:
|
||||
- /etc/openvswitch/conf.db
|
||||
- /etc/openvswitch/.conf.db.~lock~
|
||||
- /etc/openvswitch/system-id.conf
|
||||
|
||||
- name: reset — drop spinifex-managed systemd-networkd unit files
|
||||
become: true
|
||||
ansible.builtin.shell: |
|
||||
rm -f /etc/systemd/network/14-spinifex-*.netdev \
|
||||
/etc/systemd/network/15-spinifex-*.netdev \
|
||||
/etc/systemd/network/15-spinifex-*.network \
|
||||
/etc/systemd/network/16-spinifex-*.network
|
||||
networkctl reload 2>/dev/null || true
|
||||
changed_when: true
|
||||
|
||||
- name: reset — enumerate stale spinifex veth/tap netdevs
|
||||
ansible.builtin.shell: |
|
||||
ip -o link show | awk -F': ' '{print $2}' | cut -d'@' -f1 | \
|
||||
grep -E '^(veth-wan-br|veth-wan-ovs|spx-ext-|spx-tap-|spx-)' || true
|
||||
register: reset_links
|
||||
changed_when: false
|
||||
|
||||
- name: reset — delete stale netdevs
|
||||
become: true
|
||||
ansible.builtin.command: "ip link delete {{ item }}"
|
||||
loop: "{{ reset_links.stdout_lines }}"
|
||||
changed_when: true
|
||||
failed_when: false
|
||||
|
||||
- name: reset — clear ovs-monitor-ipsec strongSwan state
|
||||
become: true
|
||||
ansible.builtin.shell: |
|
||||
rm -rf /var/lib/openvswitch/pki 2>/dev/null || true
|
||||
rm -f /etc/ipsec.d/certs/ovs-* /etc/ipsec.d/private/ovs-* 2>/dev/null || true
|
||||
rm -f /etc/ipsec.conf.d/ovs-* 2>/dev/null || true
|
||||
changed_when: true
|
||||
|
||||
- name: reset — start openvswitch-switch with fresh conf.db
|
||||
become: true
|
||||
ansible.builtin.systemd:
|
||||
name: openvswitch-switch
|
||||
state: started
|
||||
daemon_reload: true
|
||||
|
||||
- name: Cluster bootstrap — OVN central (init host only)
|
||||
hosts: init
|
||||
gather_facts: false
|
||||
vars_files:
|
||||
- ../vars/defaults.yml
|
||||
tasks:
|
||||
- name: setup-ovn --management on init host
|
||||
become: true
|
||||
ansible.builtin.command:
|
||||
cmd: >-
|
||||
/usr/local/share/spinifex/setup-ovn.sh
|
||||
--management
|
||||
--encap-ip={{ spinifex_wan_ip }}
|
||||
--node-name={{ hostvars[inventory_hostname]['spinifex_node_name'] }}
|
||||
changed_when: true
|
||||
|
||||
- name: Cluster bootstrap — OVN chassis (join hosts, parallel)
|
||||
hosts: join
|
||||
gather_facts: false
|
||||
vars_files:
|
||||
- ../vars/defaults.yml
|
||||
tasks:
|
||||
- name: setup-ovn --ovn-remote on join hosts
|
||||
become: true
|
||||
ansible.builtin.command:
|
||||
cmd: >-
|
||||
/usr/local/share/spinifex/setup-ovn.sh
|
||||
--ovn-remote=tcp:{{ cluster_init_host }}:6642
|
||||
--encap-ip={{ spinifex_wan_ip }}
|
||||
--node-name={{ hostvars[inventory_hostname]['spinifex_node_name'] }}
|
||||
changed_when: true
|
||||
|
||||
- name: Cluster bootstrap — init host (start formation server)
|
||||
hosts: init
|
||||
gather_facts: false
|
||||
vars_files:
|
||||
- ../vars/defaults.yml
|
||||
roles:
|
||||
- cluster-init
|
||||
|
||||
- name: Cluster bootstrap — join hosts run spx admin join (parallel)
|
||||
hosts: join
|
||||
gather_facts: false
|
||||
serial: 100%
|
||||
vars_files:
|
||||
- ../vars/defaults.yml
|
||||
roles:
|
||||
- cluster-join
|
||||
|
||||
- name: Cluster bootstrap — wait for init to finish writing multi-node configs
|
||||
hosts: init
|
||||
gather_facts: false
|
||||
vars_files:
|
||||
- ../vars/defaults.yml
|
||||
tasks:
|
||||
- name: wait for spx admin init async job to complete
|
||||
become: true
|
||||
ansible.builtin.async_status:
|
||||
jid: "{{ cluster_init_async.ansible_job_id }}"
|
||||
register: cluster_init_done
|
||||
until: cluster_init_done.finished
|
||||
retries: 60
|
||||
delay: 5
|
||||
|
||||
- name: dump init log on failure
|
||||
ansible.builtin.command: tail -100 /tmp/spx-admin-init.log
|
||||
register: cluster_init_log
|
||||
changed_when: false
|
||||
when: cluster_init_done.rc | default(1) != 0
|
||||
|
||||
- name: assert init exited cleanly
|
||||
ansible.builtin.assert:
|
||||
that: cluster_init_done.rc == 0
|
||||
fail_msg: |
|
||||
spx admin init failed (rc={{ cluster_init_done.rc }})
|
||||
{{ cluster_init_log.stdout | default('(log not captured)') }}
|
||||
|
||||
- name: confirm multi-node predastore.toml written
|
||||
become: true
|
||||
ansible.builtin.shell: |
|
||||
if grep -q '127\.0\.0\.1' {{ spinifex_etc_dir }}/predastore/predastore.toml 2>/dev/null; then
|
||||
echo "ERROR: predastore.toml still references loopback — multi-node config not written"
|
||||
exit 1
|
||||
fi
|
||||
grep -c '^\[\[nodes\]\]' {{ spinifex_etc_dir }}/predastore/predastore.toml 2>/dev/null || echo 0
|
||||
register: predastore_node_count
|
||||
changed_when: false
|
||||
|
||||
- name: Cluster bootstrap — install CA on all nodes (before service start)
|
||||
hosts: "{{ cluster_env }}"
|
||||
gather_facts: false
|
||||
vars_files:
|
||||
- ../vars/defaults.yml
|
||||
tasks:
|
||||
- name: install CA into system trust
|
||||
become: true
|
||||
ansible.builtin.copy:
|
||||
src: "{{ spinifex_ca_src }}"
|
||||
dest: "{{ spinifex_ca_trust_path }}"
|
||||
mode: "0644"
|
||||
remote_src: true
|
||||
|
||||
- name: rebuild CA bundle
|
||||
become: true
|
||||
ansible.builtin.command: update-ca-certificates
|
||||
changed_when: true
|
||||
|
||||
# spinifex-ui reverse-proxies localhost:9999 (awsgw) and localhost:8443
|
||||
# (predastore) — hardcoded in spinifex/services/spinifexui/spinifexui.go.
|
||||
# spx admin init binds those services to BindIP only, so the loopback
|
||||
# connection fails (BadGateway in UI). Patch to wildcard bind; cert
|
||||
# already includes 127.0.0.1 in IP SANs.
|
||||
- name: patch awsgw bind to 0.0.0.0 (UI proxies via localhost)
|
||||
become: true
|
||||
ansible.builtin.replace:
|
||||
path: "{{ spinifex_config_file }}"
|
||||
regexp: '^(\s*host\s*=\s*")[^"]+(:9999")'
|
||||
replace: '\g<1>0.0.0.0\g<2>'
|
||||
|
||||
- name: patch predastore bind to 0.0.0.0 (UI proxies via localhost)
|
||||
become: true
|
||||
ansible.builtin.replace:
|
||||
path: "{{ spinifex_config_file }}"
|
||||
regexp: '^(\s*host\s*=\s*")[^"]+(:8443")'
|
||||
replace: '\g<1>0.0.0.0\g<2>'
|
||||
|
||||
- name: Cluster bootstrap — start services (init first, then joins)
|
||||
hosts: init
|
||||
gather_facts: false
|
||||
vars_files:
|
||||
- ../vars/defaults.yml
|
||||
tasks:
|
||||
- name: start spinifex.target on init
|
||||
become: true
|
||||
ansible.builtin.systemd:
|
||||
name: spinifex.target
|
||||
state: started
|
||||
|
||||
- name: Cluster bootstrap — start services on join hosts
|
||||
hosts: join
|
||||
gather_facts: false
|
||||
serial: 100%
|
||||
vars_files:
|
||||
- ../vars/defaults.yml
|
||||
tasks:
|
||||
- name: start spinifex.target on joiners
|
||||
become: true
|
||||
ansible.builtin.systemd:
|
||||
name: spinifex.target
|
||||
state: started
|
||||
|
||||
- name: Cluster bootstrap — readiness check (all nodes)
|
||||
hosts: "{{ cluster_env }}"
|
||||
gather_facts: false
|
||||
vars_files:
|
||||
- ../vars/defaults.yml
|
||||
tasks:
|
||||
- name: wait for awsgw responsiveness
|
||||
ansible.builtin.uri:
|
||||
url: "https://{{ spinifex_wan_ip }}:{{ cluster_formation_port }}/health"
|
||||
validate_certs: false
|
||||
return_content: true
|
||||
register: cluster_health
|
||||
until:
|
||||
- cluster_health.status == 200
|
||||
- cluster_health.json.service_health.awsgw == "ok"
|
||||
retries: 30
|
||||
delay: 5
|
||||
|
||||
- name: Cluster bootstrap — post-bootstrap fixtures (init host only)
|
||||
hosts: init
|
||||
gather_facts: true
|
||||
vars_files:
|
||||
- ../vars/defaults.yml
|
||||
roles:
|
||||
- cluster-post
|
||||
@@ -1,14 +0,0 @@
|
||||
---
|
||||
# In-place artifact swap: rebuilds + replaces binaries/microvm artifacts on
|
||||
# an already-installed node, then restarts spinifex.target. Use when iterating
|
||||
# on code without altering systemd units, users, dirs, or running the full
|
||||
# smoketest. For first-time or unit/script changes use dev-install / dev-reset.
|
||||
- name: Dev deploy — build + swap binaries + restart (no setup.sh, no smoketest)
|
||||
hosts: localhost
|
||||
gather_facts: true
|
||||
vars_files:
|
||||
- ../vars/defaults.yml
|
||||
|
||||
roles:
|
||||
- build
|
||||
- deploy
|
||||
@@ -1,67 +0,0 @@
|
||||
---
|
||||
- name: Dev install — build, install, init, smoketest (idempotent)
|
||||
hosts: localhost
|
||||
gather_facts: true
|
||||
vars_files:
|
||||
- ../vars/defaults.yml
|
||||
|
||||
pre_tasks:
|
||||
- name: preflight
|
||||
ansible.builtin.include_role:
|
||||
name: preflight
|
||||
|
||||
- name: health — config file present?
|
||||
ansible.builtin.stat:
|
||||
path: "{{ spinifex_config_file }}"
|
||||
register: health_config
|
||||
|
||||
- name: health — spinifex.target active?
|
||||
become: true
|
||||
ansible.builtin.command: systemctl is-active spinifex.target
|
||||
register: health_target
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: health — awsgw responds?
|
||||
become: true
|
||||
become_user: "{{ spinifex_invoking_user }}"
|
||||
environment:
|
||||
AWS_PROFILE: spinifex
|
||||
ansible.builtin.command: aws ec2 describe-regions
|
||||
register: health_gw
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: health — spx binary installed?
|
||||
ansible.builtin.stat:
|
||||
path: /usr/local/bin/spx
|
||||
register: health_spx
|
||||
|
||||
- name: health — set already_installed fact
|
||||
ansible.builtin.set_fact:
|
||||
spinifex_already_installed: >-
|
||||
{{ health_config.stat.exists
|
||||
and health_spx.stat.exists
|
||||
and (health_target.stdout | trim) == 'active'
|
||||
and health_gw.rc == 0 }}
|
||||
|
||||
- name: health — summary
|
||||
ansible.builtin.debug:
|
||||
msg: |
|
||||
Health:
|
||||
spx binary: {{ 'present' if health_spx.stat.exists else 'missing' }}
|
||||
config: {{ 'present' if health_config.stat.exists else 'missing' }}
|
||||
target: {{ health_target.stdout | trim }}
|
||||
awsgw: {{ 'ok' if health_gw.rc == 0 else 'not responding' }}
|
||||
Already installed: {{ spinifex_already_installed }}
|
||||
{% if spinifex_already_installed %}
|
||||
→ Skipping build/install/init. Smoketest only.
|
||||
{% else %}
|
||||
→ Running full build → install → init → smoketest.
|
||||
{% endif %}
|
||||
|
||||
roles:
|
||||
- { role: build, when: not (spinifex_already_installed | default(false)) }
|
||||
- { role: install, when: not (spinifex_already_installed | default(false)) }
|
||||
- { role: init, when: not (spinifex_already_installed | default(false)) }
|
||||
- smoketest
|
||||
@@ -1,17 +0,0 @@
|
||||
---
|
||||
# Capture spinifex + ovn/ovs + qemu/nbdkit logs from journald and
|
||||
# /var/log/spinifex/ into a single tarball for bug reports.
|
||||
# Tail mode is intentionally not provided — use `journalctl -u 'spinifex-*' -f`
|
||||
# directly; ansible is the wrong tool for follow.
|
||||
- name: Dev logs — dump journald + varlog + status into a bundle
|
||||
hosts: localhost
|
||||
gather_facts: true
|
||||
vars_files:
|
||||
- ../vars/defaults.yml
|
||||
vars:
|
||||
logs_since: "1h"
|
||||
logs_output_dir: "/tmp"
|
||||
logs_include_dmesg: true
|
||||
|
||||
roles:
|
||||
- logs
|
||||
@@ -1,8 +0,0 @@
|
||||
---
|
||||
- name: Dev preflight — validate host dependencies
|
||||
hosts: localhost
|
||||
gather_facts: true
|
||||
vars_files:
|
||||
- ../vars/defaults.yml
|
||||
roles:
|
||||
- preflight
|
||||
@@ -1,72 +0,0 @@
|
||||
---
|
||||
- name: Dev reset — capture settings, wipe, rebuild, reinstall, re-init, smoketest
|
||||
hosts: localhost
|
||||
gather_facts: true
|
||||
vars_files:
|
||||
- ../vars/defaults.yml
|
||||
|
||||
pre_tasks:
|
||||
- name: capture — check existing spinifex.toml
|
||||
ansible.builtin.stat:
|
||||
path: "{{ spinifex_config_file }}"
|
||||
register: reset_config_stat
|
||||
|
||||
- name: capture — slurp existing config
|
||||
ansible.builtin.slurp:
|
||||
src: "{{ spinifex_config_file }}"
|
||||
register: reset_config_raw
|
||||
when: reset_config_stat.stat.exists
|
||||
|
||||
- name: capture — decode config
|
||||
ansible.builtin.set_fact:
|
||||
reset_config_txt: "{{ reset_config_raw.content | b64decode }}"
|
||||
when: reset_config_stat.stat.exists
|
||||
|
||||
# List-concat-and-take-first pattern: missing regex match falls back to current default.
|
||||
# `gateway\s*=` deliberately does not match `gateway_ip` / `gateway_lrp_*`.
|
||||
- name: capture — extract scalars
|
||||
ansible.builtin.set_fact:
|
||||
spinifex_region: "{{ (reset_config_txt | regex_findall('region\\s*=\\s*\"([^\"]+)\"') + [spinifex_region])[0] }}"
|
||||
spinifex_az: "{{ (reset_config_txt | regex_findall('\\baz\\s*=\\s*\"([^\"]+)\"') + [spinifex_az])[0] }}"
|
||||
spinifex_external_mode: "{{ (reset_config_txt | regex_findall('external_mode\\s*=\\s*\"([^\"]+)\"') + [spinifex_external_mode])[0] }}"
|
||||
spinifex_external_pool_start: "{{ (reset_config_txt | regex_findall('range_start\\s*=\\s*\"([^\"]+)\"') + [spinifex_external_pool_start])[0] }}"
|
||||
spinifex_external_pool_end: "{{ (reset_config_txt | regex_findall('range_end\\s*=\\s*\"([^\"]+)\"') + [spinifex_external_pool_end])[0] }}"
|
||||
spinifex_external_gateway: "{{ (reset_config_txt | regex_findall('\\bgateway\\s*=\\s*\"([^\"]+)\"') + [spinifex_external_gateway])[0] }}"
|
||||
spinifex_external_prefix_len: "{{ (reset_config_txt | regex_findall('prefix_len\\s*=\\s*([0-9]+)') + [spinifex_external_prefix_len | string])[0] }}"
|
||||
spinifex_external_gateway_ip: "{{ (reset_config_txt | regex_findall('gateway_ip\\s*=\\s*\"([^\"]+)\"') + [spinifex_external_gateway_ip])[0] }}"
|
||||
when: reset_config_stat.stat.exists
|
||||
|
||||
# Legacy: external_mode = "nat" was removed from spx. Coerce to "pool"
|
||||
# so old configs still reset cleanly; warn so the operator notices.
|
||||
- name: capture — coerce legacy external_mode=nat to pool
|
||||
ansible.builtin.set_fact:
|
||||
spinifex_external_mode: "pool"
|
||||
when: spinifex_external_mode == 'nat'
|
||||
|
||||
- name: capture — warn on legacy nat coercion
|
||||
ansible.builtin.debug:
|
||||
msg: "⚠️ Captured external_mode=nat from legacy config; coerced to 'pool' (NAT mode removed from spx admin init)."
|
||||
when:
|
||||
- reset_config_stat.stat.exists
|
||||
- (reset_config_txt | regex_findall('external_mode\\s*=\\s*\"nat\"')) | length > 0
|
||||
|
||||
- name: capture — summary
|
||||
ansible.builtin.debug:
|
||||
msg: |
|
||||
{% if reset_config_stat.stat.exists %}
|
||||
Captured settings from {{ spinifex_config_file }}:
|
||||
region={{ spinifex_region }}, az={{ spinifex_az }}
|
||||
external_mode={{ spinifex_external_mode }}
|
||||
pool={{ spinifex_external_pool_start }}-{{ spinifex_external_pool_end }}
|
||||
gateway={{ spinifex_external_gateway }}, prefix={{ spinifex_external_prefix_len }}
|
||||
gateway_ip={{ spinifex_external_gateway_ip }}
|
||||
{% else %}
|
||||
No existing config; using defaults from vars/defaults.yml (or -e overrides).
|
||||
{% endif %}
|
||||
|
||||
roles:
|
||||
- teardown
|
||||
- build
|
||||
- install
|
||||
- init
|
||||
- smoketest
|
||||
@@ -1,14 +0,0 @@
|
||||
---
|
||||
# Restore a dev-snapshot bundle: stop services, wipe the snapshot path
|
||||
# set, untar the bundle, refresh CA trust, restart.
|
||||
#
|
||||
# Required: -e snapshot_name=<name>
|
||||
# Optional: -e snapshot_dir=/some/path
|
||||
- name: Dev restore — wipe + untar snapshot + restart
|
||||
hosts: localhost
|
||||
gather_facts: true
|
||||
vars_files:
|
||||
- ../vars/defaults.yml
|
||||
|
||||
roles:
|
||||
- restore
|
||||
@@ -1,17 +0,0 @@
|
||||
---
|
||||
# Quiesce spinifex + ovn + ovs, tar /etc/spinifex + /var/lib/spinifex +
|
||||
# /var/lib/ovn + /etc/openvswitch/conf.db into a named bundle, restart.
|
||||
# Use to checkpoint a working state before a risky operation (branch
|
||||
# switch, viperblock surgery, ovn-controller restart cycle). Roll back
|
||||
# via dev-restore without a full dev-reset.
|
||||
#
|
||||
# Required: -e snapshot_name=<name>
|
||||
# Optional: -e snapshot_dir=/some/path
|
||||
- name: Dev snapshot — quiesce + tar state + restart
|
||||
hosts: localhost
|
||||
gather_facts: true
|
||||
vars_files:
|
||||
- ../vars/defaults.yml
|
||||
|
||||
roles:
|
||||
- snapshot
|
||||
@@ -1,13 +0,0 @@
|
||||
---
|
||||
# Read-only health snapshot for a Spinifex dev node. Never mutates state.
|
||||
# Run any time the operator wants to know "is my box healthy?" — pairs with
|
||||
# dev-preflight (run before install) as the post-install / running-system
|
||||
# diagnostic.
|
||||
- name: Dev status — health snapshot (read-only)
|
||||
hosts: localhost
|
||||
gather_facts: true
|
||||
vars_files:
|
||||
- ../vars/defaults.yml
|
||||
|
||||
roles:
|
||||
- status
|
||||
@@ -1,8 +0,0 @@
|
||||
---
|
||||
- name: Dev teardown — wipe Spinifex state (no reinstall)
|
||||
hosts: localhost
|
||||
gather_facts: true
|
||||
vars_files:
|
||||
- ../vars/defaults.yml
|
||||
roles:
|
||||
- teardown
|
||||
@@ -1,11 +0,0 @@
|
||||
---
|
||||
# Detailed build-vs-install drift report. Faster than dev-status (no AWS,
|
||||
# OVN, or port probing). Answers "do I need to ansible-dev-deploy?".
|
||||
- name: Dev version — build vs install drift (read-only)
|
||||
hosts: localhost
|
||||
gather_facts: false
|
||||
vars_files:
|
||||
- ../vars/defaults.yml
|
||||
|
||||
roles:
|
||||
- version
|
||||
@@ -1,14 +0,0 @@
|
||||
---
|
||||
# Idempotent dev-mode VPC fixture: spinifex-dev-vpc + public subnet +
|
||||
# IGW + default route. Hooks into smoketest's launch path (which finds
|
||||
# the public subnet automatically). Re-runs are no-ops once converged.
|
||||
#
|
||||
# Default state: present. Tear down with -e dev_vpc_state=absent.
|
||||
- name: Dev VPC — fixture VPC/subnet/IGW/route (idempotent)
|
||||
hosts: localhost
|
||||
gather_facts: false
|
||||
vars_files:
|
||||
- ../vars/defaults.yml
|
||||
|
||||
roles:
|
||||
- vpc
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
collections:
|
||||
- name: community.general
|
||||
version: ">=8.0.0"
|
||||
- name: ansible.posix
|
||||
version: ">=1.5.0"
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
galaxy_info:
|
||||
role_name: build
|
||||
description: Build spinifex + viperblock-nbdkit, stage tarball for setup.sh consumption.
|
||||
license: BSD-3-Clause
|
||||
min_ansible_version: "2.14"
|
||||
dependencies: []
|
||||
@@ -1,66 +0,0 @@
|
||||
---
|
||||
- name: build — spinifex (make build)
|
||||
ansible.builtin.command:
|
||||
cmd: make build
|
||||
chdir: "{{ spinifex_root }}"
|
||||
changed_when: true
|
||||
|
||||
- name: build — viperblock nbdkit plugin (make go_build_nbd)
|
||||
ansible.builtin.command:
|
||||
cmd: make go_build_nbd
|
||||
chdir: "{{ mulga_root }}/viperblock"
|
||||
changed_when: true
|
||||
|
||||
- name: build — detect microVM build script (present only on direct-boot branches)
|
||||
ansible.builtin.stat:
|
||||
path: "{{ spinifex_root }}/scripts/build-microvm-image.sh"
|
||||
register: build_microvm_script
|
||||
|
||||
- name: build — microVM kernel + initramfs (direct-boot LBs)
|
||||
ansible.builtin.command:
|
||||
cmd: make build-microvm-image
|
||||
chdir: "{{ spinifex_root }}"
|
||||
changed_when: true
|
||||
when: build_microvm_script.stat.exists
|
||||
|
||||
- name: build — verify build artifacts present
|
||||
ansible.builtin.stat:
|
||||
path: "{{ item }}"
|
||||
register: build_artifacts
|
||||
loop:
|
||||
- "{{ spinifex_root }}/bin/spx"
|
||||
- "{{ mulga_root }}/viperblock/lib/nbdkit-viperblock-plugin.so"
|
||||
|
||||
- name: build — assert artifacts exist
|
||||
ansible.builtin.assert:
|
||||
that: item.stat.exists
|
||||
fail_msg: "Missing build artifact: {{ item.item }}"
|
||||
loop: "{{ build_artifacts.results }}"
|
||||
loop_control:
|
||||
label: "{{ item.item }}"
|
||||
|
||||
- name: build — stage tarball contents (mirrors dev-install.sh)
|
||||
ansible.builtin.shell: |
|
||||
set -e
|
||||
STAGING=$(mktemp -d)
|
||||
trap 'rm -rf "$STAGING"' EXIT
|
||||
cp "{{ spinifex_root }}/bin/spx" "$STAGING/spx"
|
||||
cp "{{ mulga_root }}/viperblock/lib/nbdkit-viperblock-plugin.so" "$STAGING/"
|
||||
cp "{{ spinifex_root }}/scripts/setup-ovn.sh" "$STAGING/"
|
||||
mkdir -p "$STAGING/systemd"
|
||||
cp "{{ spinifex_root }}/build/systemd/"* "$STAGING/systemd/"
|
||||
mkdir -p "$STAGING/scripts"
|
||||
cp "{{ spinifex_root }}/build/scripts/"* "$STAGING/scripts/"
|
||||
cp "{{ spinifex_root }}/build/logrotate/spinifex" "$STAGING/logrotate-spinifex"
|
||||
# microVM artifacts ship only when the branch built them.
|
||||
if [ -f "{{ spinifex_root }}/build/microvm/vmlinuz" ] && [ -f "{{ spinifex_root }}/build/microvm/initramfs.cpio.gz" ]; then
|
||||
mkdir -p "$STAGING/microvm"
|
||||
cp "{{ spinifex_root }}/build/microvm/vmlinuz" "$STAGING/microvm/"
|
||||
cp "{{ spinifex_root }}/build/microvm/initramfs.cpio.gz" "$STAGING/microvm/"
|
||||
fi
|
||||
tar czf "{{ build_tarball_path }}" -C "$STAGING" .
|
||||
changed_when: true
|
||||
|
||||
- name: build — summary
|
||||
ansible.builtin.debug:
|
||||
msg: "Built spx + nbdkit plugin{{ ' + microvm artifacts' if build_microvm_script.stat.exists else '' }}, tarball at {{ build_tarball_path }}"
|
||||
@@ -1,9 +0,0 @@
|
||||
---
|
||||
galaxy_info:
|
||||
role_name: cluster-init
|
||||
description: |
|
||||
Run `spx admin init` on the cluster's init host, backgrounded so the
|
||||
formation server accepts joins from the cluster-join role. Installs
|
||||
cluster CA into the system trust store BEFORE starting spinifex.target
|
||||
so predastore's strict TLS verify is satisfied at first start.
|
||||
dependencies: []
|
||||
@@ -1,77 +0,0 @@
|
||||
---
|
||||
- name: cluster-init — assert this is the init host
|
||||
ansible.builtin.assert:
|
||||
that: spinifex_node_role == "init"
|
||||
fail_msg: "cluster-init role must only run against hosts in the [init] group"
|
||||
|
||||
# vars/defaults.yml sets spinifex_node_{name,count} for single-node dev use
|
||||
# (precedence 11). Inventory hostvars (precedence 4) lose that fight, so
|
||||
# we promote the inventory values via set_fact (precedence 15) before any
|
||||
# task templates `{{ spinifex_node_count }}`.
|
||||
- name: cluster-init — promote inventory node identity over defaults.yml
|
||||
ansible.builtin.set_fact:
|
||||
spinifex_node_name: "{{ hostvars[inventory_hostname]['spinifex_node_name'] }}"
|
||||
spinifex_node_count: "{{ groups[cluster_env] | length }}"
|
||||
|
||||
- name: cluster-init — assemble external network flags
|
||||
ansible.builtin.set_fact:
|
||||
cluster_external_flags: >-
|
||||
{% if cluster_external_pool | length > 0 %}--external-mode=pool --external-pool={{ cluster_external_pool }}{% endif %}
|
||||
|
||||
- name: cluster-init — run spx admin init (backgrounded, formation server waits for joins)
|
||||
become: true
|
||||
ansible.builtin.shell: |
|
||||
set -o pipefail
|
||||
spx admin init --force \
|
||||
--node {{ spinifex_node_name }} \
|
||||
--nodes {{ spinifex_node_count }} \
|
||||
--bind {{ spinifex_wan_ip }} \
|
||||
--cluster-bind {{ spinifex_wan_ip }} \
|
||||
--port {{ cluster_formation_port }} \
|
||||
--region {{ spinifex_region }} \
|
||||
--az {{ spinifex_az }} \
|
||||
{{ cluster_external_flags }} \
|
||||
> /tmp/spx-admin-init.log 2>&1
|
||||
async: 600
|
||||
poll: 0
|
||||
register: cluster_init_async
|
||||
|
||||
- name: cluster-init — brief pause so init can fail fast if misconfigured
|
||||
ansible.builtin.pause:
|
||||
seconds: 5
|
||||
|
||||
- name: cluster-init — check if init exited prematurely (sanity)
|
||||
ansible.builtin.shell: |
|
||||
if pgrep -f 'spx admin init' >/dev/null; then
|
||||
echo "running"
|
||||
else
|
||||
tail -50 /tmp/spx-admin-init.log 2>/dev/null
|
||||
exit 1
|
||||
fi
|
||||
register: cluster_init_alive
|
||||
changed_when: false
|
||||
failed_when: cluster_init_alive.rc != 0
|
||||
|
||||
- name: cluster-init — wait for formation server health
|
||||
ansible.builtin.uri:
|
||||
url: "https://{{ spinifex_wan_ip }}:{{ cluster_formation_port }}/formation/health"
|
||||
validate_certs: false
|
||||
status_code: 200
|
||||
register: cluster_init_health
|
||||
until: cluster_init_health.status == 200
|
||||
retries: "{{ cluster_formation_timeout // 2 }}"
|
||||
delay: 2
|
||||
|
||||
- name: cluster-init — fetch join token (delegated read for joiners)
|
||||
become: true
|
||||
ansible.builtin.slurp:
|
||||
src: "{{ cluster_join_token_path }}"
|
||||
register: cluster_join_token_raw
|
||||
|
||||
- name: cluster-init — register join token as host fact
|
||||
ansible.builtin.set_fact:
|
||||
cluster_join_token: "{{ cluster_join_token_raw.content | b64decode | trim }}"
|
||||
|
||||
- name: cluster-init — join token captured; waiting for joins handled by later play
|
||||
ansible.builtin.debug:
|
||||
msg: "Formation server up on {{ spinifex_wan_ip }}:{{ cluster_formation_port }}, {{ spinifex_node_count }} nodes expected"
|
||||
@@ -1,8 +0,0 @@
|
||||
---
|
||||
galaxy_info:
|
||||
role_name: cluster-join
|
||||
description: |
|
||||
Run `spx admin join` against the cluster init host's formation server.
|
||||
Installs cluster CA + starts spinifex.target after join completes
|
||||
(CA-before-services ordering avoids predastore TLS-verify crashes).
|
||||
dependencies: []
|
||||
@@ -1,39 +0,0 @@
|
||||
---
|
||||
- name: cluster-join — assert this is a join host
|
||||
ansible.builtin.assert:
|
||||
that: spinifex_node_role == "join"
|
||||
fail_msg: "cluster-join role must only run against hosts in the [join] group"
|
||||
|
||||
# Promote inventory hostvars over vars/defaults.yml (see cluster-init).
|
||||
- name: cluster-join — promote inventory node identity over defaults.yml
|
||||
ansible.builtin.set_fact:
|
||||
spinifex_node_name: "{{ hostvars[inventory_hostname]['spinifex_node_name'] }}"
|
||||
|
||||
- name: cluster-join — pull join token from init host
|
||||
ansible.builtin.set_fact:
|
||||
cluster_join_token: "{{ hostvars[cluster_init_host]['cluster_join_token'] }}"
|
||||
|
||||
- name: cluster-join — assert token present
|
||||
ansible.builtin.assert:
|
||||
that: cluster_join_token | length > 0
|
||||
fail_msg: "join token missing — init host did not export cluster_join_token"
|
||||
|
||||
- name: cluster-join — run spx admin join (retry up to 5 times)
|
||||
become: true
|
||||
ansible.builtin.command:
|
||||
cmd: >-
|
||||
spx admin join
|
||||
--node {{ spinifex_node_name }}
|
||||
--bind {{ spinifex_wan_ip }}
|
||||
--cluster-bind {{ spinifex_wan_ip }}
|
||||
--host {{ cluster_init_host }}:{{ cluster_formation_port }}
|
||||
--token {{ cluster_join_token }}
|
||||
--region {{ spinifex_region }}
|
||||
--az {{ spinifex_az }}
|
||||
register: cluster_join_result
|
||||
retries: 5
|
||||
delay: 3
|
||||
until: cluster_join_result.rc == 0
|
||||
|
||||
# CA install + service start handled by cluster-bootstrap.yml plays that
|
||||
# run after all joins complete and init has written multi-node configs.
|
||||
@@ -1,8 +0,0 @@
|
||||
---
|
||||
galaxy_info:
|
||||
role_name: cluster-post
|
||||
description: |
|
||||
Post-bootstrap idempotent fixtures on the init host: import SSH key,
|
||||
import Ubuntu AMI (dual-version detect 24.04/26.04). Skip when
|
||||
artifacts already exist.
|
||||
dependencies: []
|
||||
@@ -1,205 +0,0 @@
|
||||
---
|
||||
- name: cluster-post — locate ssh public key for import
|
||||
ansible.builtin.find:
|
||||
paths: "{{ ansible_env.HOME }}/.ssh"
|
||||
patterns:
|
||||
- spinifex-key.pub
|
||||
- tf-user-*.pub
|
||||
file_type: file
|
||||
register: cluster_post_ssh_pubkey
|
||||
|
||||
- name: cluster-post — fail when no public key found
|
||||
ansible.builtin.assert:
|
||||
that: cluster_post_ssh_pubkey.files | length > 0
|
||||
fail_msg: "no public key in ~/.ssh — needed for spinifex-key import"
|
||||
|
||||
- name: cluster-post — set pubkey path fact
|
||||
ansible.builtin.set_fact:
|
||||
cluster_post_pubkey_path: "{{ cluster_post_ssh_pubkey.files[0].path }}"
|
||||
|
||||
- name: cluster-post — check whether spinifex-key already imported
|
||||
ansible.builtin.command:
|
||||
cmd: "{{ spinifex_aws_cli }} ec2 describe-key-pairs --key-names spinifex-key"
|
||||
register: cluster_post_key_check
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: cluster-post — import spinifex-key (if absent)
|
||||
ansible.builtin.command:
|
||||
cmd: >-
|
||||
{{ spinifex_aws_cli }} ec2 import-key-pair
|
||||
--key-name spinifex-key
|
||||
--public-key-material fileb://{{ cluster_post_pubkey_path }}
|
||||
register: cluster_post_key_import
|
||||
retries: 10
|
||||
delay: 6
|
||||
until: cluster_post_key_import.rc == 0
|
||||
when: cluster_post_key_check.rc != 0
|
||||
|
||||
- name: cluster-post — locate ubuntu cloud image (26.04 preferred, 24.04 fallback)
|
||||
ansible.builtin.stat:
|
||||
path: "{{ item }}"
|
||||
register: cluster_post_ubuntu_stat
|
||||
loop:
|
||||
- "{{ ansible_env.HOME }}/images/ubuntu-26.04.img"
|
||||
- "{{ ansible_env.HOME }}/images/ubuntu-24.04.img"
|
||||
|
||||
- name: cluster-post — pick first available image
|
||||
ansible.builtin.set_fact:
|
||||
cluster_post_ubuntu_image: "{{ (cluster_post_ubuntu_stat.results | selectattr('stat.exists') | map(attribute='item') | list | first) | default('') }}"
|
||||
|
||||
- name: cluster-post — fail when no ubuntu image present
|
||||
ansible.builtin.assert:
|
||||
that: cluster_post_ubuntu_image | length > 0
|
||||
fail_msg: "no ubuntu-{24,26}.04.img in ~/images/ — gold image must stage it"
|
||||
|
||||
- name: cluster-post — derive ubuntu version from image filename
|
||||
ansible.builtin.set_fact:
|
||||
cluster_post_ubuntu_version: "{{ cluster_post_ubuntu_image | regex_search('ubuntu-([0-9]+\\.[0-9]+)\\.img', '\\1') | first }}"
|
||||
|
||||
- name: cluster-post — check whether ubuntu AMI already imported
|
||||
ansible.builtin.command:
|
||||
cmd: >-
|
||||
{{ spinifex_aws_cli }} ec2 describe-images
|
||||
--owners self
|
||||
--filters Name=name,Values=ubuntu-{{ cluster_post_ubuntu_version }}
|
||||
register: cluster_post_ami_check
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: cluster-post — import ubuntu AMI (if absent)
|
||||
ansible.builtin.command:
|
||||
cmd: >-
|
||||
{{ spinifex_spx_cli }} admin images import
|
||||
--file {{ cluster_post_ubuntu_image }}
|
||||
--arch x86_64
|
||||
--distro ubuntu
|
||||
--version {{ cluster_post_ubuntu_version }}
|
||||
--boot-mode uefi
|
||||
when:
|
||||
- cluster_post_ami_check.rc != 0
|
||||
or "ubuntu-" + cluster_post_ubuntu_version not in cluster_post_ami_check.stdout
|
||||
|
||||
- name: cluster-post — slurp final admin creds from ~/.aws/credentials
|
||||
# Source of truth: SetupAWSCredentials writes the FINAL admin keys here.
|
||||
# Init log shows obsolete keys (multi-node path silently regenerates them
|
||||
# after the initial print — see cmd/spinifex/cmd/admin.go writeBootstrapFiles
|
||||
# called twice in the multi-node flow).
|
||||
ansible.builtin.slurp:
|
||||
src: "{{ ansible_env.HOME }}/.aws/credentials"
|
||||
register: cluster_post_aws_creds_raw
|
||||
|
||||
- name: cluster-post — extract admin access/secret keys from spinifex profile
|
||||
ansible.builtin.set_fact:
|
||||
cluster_post_admin_access_key: >-
|
||||
{{ (cluster_post_aws_creds_raw.content | b64decode
|
||||
| regex_search('\[spinifex\][^\[]*aws_access_key_id\s*=\s*(\S+)', '\1')
|
||||
| first) | default('') }}
|
||||
cluster_post_admin_secret_key: >-
|
||||
{{ (cluster_post_aws_creds_raw.content | b64decode
|
||||
| regex_search('\[spinifex\][^\[]*aws_secret_access_key\s*=\s*(\S+)', '\1')
|
||||
| first) | default('') }}
|
||||
|
||||
- name: cluster-post — assert creds parsed
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- cluster_post_admin_access_key | length > 0
|
||||
- cluster_post_admin_secret_key | length > 0
|
||||
fail_msg: "could not parse admin keys from {{ ansible_env.HOME }}/.aws/credentials [spinifex] profile"
|
||||
|
||||
- name: cluster-post — ensure local .creds dir exists
|
||||
delegate_to: localhost
|
||||
become: false
|
||||
ansible.builtin.file:
|
||||
path: "{{ playbook_dir }}/../.creds"
|
||||
state: directory
|
||||
mode: "0700"
|
||||
|
||||
- name: cluster-post — write creds to operator workstation
|
||||
delegate_to: localhost
|
||||
become: false
|
||||
ansible.builtin.copy:
|
||||
dest: "{{ playbook_dir }}/../.creds/{{ cluster_env }}.txt"
|
||||
mode: "0600"
|
||||
content: |
|
||||
# Spinifex cluster admin credentials — {{ cluster_env }}
|
||||
# Generated: {{ ansible_facts['date_time']['iso8601'] }}
|
||||
# Init host: {{ cluster_init_host }}
|
||||
#
|
||||
# UI: https://{{ cluster_init_host }}:3000
|
||||
# AWS API: https://{{ cluster_init_host }}:4432
|
||||
# Region: {{ spinifex_region }}
|
||||
#
|
||||
# Login (admin user / AWS profile spinifex):
|
||||
AWS_ACCESS_KEY_ID={{ cluster_post_admin_access_key }}
|
||||
AWS_SECRET_ACCESS_KEY={{ cluster_post_admin_secret_key }}
|
||||
|
||||
# Per-env AWS profile in operator's ~/.aws/credentials so tofu / aws CLI can
|
||||
# target this cluster via AWS_PROFILE=spinifex-{{ cluster_env }}. blockinfile
|
||||
# marker keeps re-runs idempotent — replaces the block in place, never
|
||||
# duplicates. Does not touch the user's existing [spinifex] profile (single-
|
||||
# node dev). Tofu side: provider "aws" { profile = "spinifex-env1" ... }.
|
||||
- name: cluster-post — ensure local ~/.aws dir exists
|
||||
delegate_to: localhost
|
||||
become: false
|
||||
ansible.builtin.file:
|
||||
path: "{{ lookup('env', 'HOME') }}/.aws"
|
||||
state: directory
|
||||
mode: "0700"
|
||||
|
||||
- name: cluster-post — ensure local ~/.aws/credentials exists
|
||||
delegate_to: localhost
|
||||
become: false
|
||||
ansible.builtin.copy:
|
||||
dest: "{{ lookup('env', 'HOME') }}/.aws/credentials"
|
||||
content: ""
|
||||
force: false
|
||||
mode: "0600"
|
||||
|
||||
- name: cluster-post — upsert spinifex-{{ cluster_env }} profile in local ~/.aws/credentials
|
||||
delegate_to: localhost
|
||||
become: false
|
||||
ansible.builtin.blockinfile:
|
||||
path: "{{ lookup('env', 'HOME') }}/.aws/credentials"
|
||||
marker: "# {mark} SPINIFEX-{{ cluster_env | upper }} MANAGED BLOCK"
|
||||
block: |
|
||||
[spinifex-{{ cluster_env }}]
|
||||
aws_access_key_id = {{ cluster_post_admin_access_key }}
|
||||
aws_secret_access_key = {{ cluster_post_admin_secret_key }}
|
||||
region = {{ spinifex_region }}
|
||||
ca_bundle = {{ playbook_dir }}/../.creds/{{ cluster_env }}-ca.pem
|
||||
endpoint_url = https://{{ cluster_init_host }}:4432
|
||||
|
||||
- name: cluster-post — slurp cluster CA from init host
|
||||
ansible.builtin.slurp:
|
||||
src: "{{ spinifex_ca_src }}"
|
||||
register: cluster_post_ca_raw
|
||||
become: true
|
||||
|
||||
- name: cluster-post — write CA pem to operator workstation
|
||||
delegate_to: localhost
|
||||
become: false
|
||||
ansible.builtin.copy:
|
||||
dest: "{{ playbook_dir }}/../.creds/{{ cluster_env }}-ca.pem"
|
||||
mode: "0644"
|
||||
content: "{{ cluster_post_ca_raw.content | b64decode }}"
|
||||
|
||||
- name: cluster-post — resolve absolute paths for summary
|
||||
delegate_to: localhost
|
||||
become: false
|
||||
ansible.builtin.command: "readlink -f {{ playbook_dir }}/../.creds"
|
||||
register: cluster_post_creds_abs
|
||||
changed_when: false
|
||||
|
||||
- name: cluster-post — summary
|
||||
ansible.builtin.debug:
|
||||
msg: |
|
||||
Post-bootstrap done:
|
||||
spinifex-key: {{ 'imported now' if (cluster_post_key_import.changed | default(false)) else 'already present' }}
|
||||
ubuntu AMI: ubuntu-{{ cluster_post_ubuntu_version }}
|
||||
admin creds: {{ cluster_post_creds_abs.stdout }}/{{ cluster_env }}.txt
|
||||
CA cert: {{ cluster_post_creds_abs.stdout }}/{{ cluster_env }}-ca.pem
|
||||
AWS profile: spinifex-{{ cluster_env }} (in ~/.aws/credentials)
|
||||
UI URL: https://{{ cluster_init_host }}:3000
|
||||
Access Key: {{ cluster_post_admin_access_key }}
|
||||
Tofu usage: AWS_PROFILE=spinifex-{{ cluster_env }} tofu plan
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
galaxy_info:
|
||||
role_name: deploy
|
||||
description: In-place binary + microvm artifact swap for a running Spinifex node (parity with `make deploy`).
|
||||
license: BSD-3-Clause
|
||||
min_ansible_version: "2.14"
|
||||
dependencies: []
|
||||
@@ -1,144 +0,0 @@
|
||||
---
|
||||
# In-place artifact swap for an already-installed spinifex node. Mirrors
|
||||
# `make deploy`: replace binaries + microvm artifacts, restart services.
|
||||
# Does NOT run setup.sh (no user/dir/systemd-unit changes) or smoketest.
|
||||
|
||||
- name: deploy — fail fast if spinifex isn't already installed
|
||||
ansible.builtin.stat:
|
||||
path: "{{ spinifex_config_file }}"
|
||||
register: deploy_installed_stat
|
||||
failed_when: not deploy_installed_stat.stat.exists
|
||||
|
||||
- name: deploy — assert config present
|
||||
ansible.builtin.assert:
|
||||
that: deploy_installed_stat.stat.exists
|
||||
fail_msg: |
|
||||
{{ spinifex_config_file }} missing — this playbook only updates an
|
||||
existing install. Run ansible-dev-install or ansible-dev-reset first.
|
||||
|
||||
- name: deploy — verify build artifacts present
|
||||
ansible.builtin.stat:
|
||||
path: "{{ item }}"
|
||||
register: deploy_artifacts
|
||||
loop:
|
||||
- "{{ spinifex_root }}/bin/spx"
|
||||
- "{{ mulga_root }}/viperblock/lib/nbdkit-viperblock-plugin.so"
|
||||
|
||||
- name: deploy — assert artifacts exist after build role
|
||||
ansible.builtin.assert:
|
||||
that: item.stat.exists
|
||||
fail_msg: "Build artifact missing: {{ item.item }} — build role should have produced it"
|
||||
loop: "{{ deploy_artifacts.results }}"
|
||||
loop_control:
|
||||
label: "{{ item.item }}"
|
||||
|
||||
- name: deploy — detect microvm artifacts (direct-boot branches only)
|
||||
ansible.builtin.stat:
|
||||
path: "{{ spinifex_root }}/build/microvm/{{ item }}"
|
||||
register: deploy_microvm_stat
|
||||
loop:
|
||||
- vmlinuz
|
||||
- initramfs.cpio.gz
|
||||
|
||||
- name: deploy — set microvm presence fact
|
||||
ansible.builtin.set_fact:
|
||||
deploy_microvm_present: "{{ deploy_microvm_stat.results | map(attribute='stat.exists') | min }}"
|
||||
|
||||
- name: deploy — detect nbdkit plugin directory
|
||||
ansible.builtin.shell: |
|
||||
set -e
|
||||
nbdkit --dump-config | awk -F= '/^plugindir=/{print $2; exit}'
|
||||
register: deploy_plugindir_cmd
|
||||
changed_when: false
|
||||
|
||||
- name: deploy — set plugin path fact
|
||||
ansible.builtin.set_fact:
|
||||
deploy_plugindir: "{{ deploy_plugindir_cmd.stdout | trim }}"
|
||||
|
||||
- name: deploy — assert plugindir resolved
|
||||
ansible.builtin.assert:
|
||||
that: deploy_plugindir | length > 0
|
||||
fail_msg: "Could not detect nbdkit plugin directory via `nbdkit --dump-config`"
|
||||
|
||||
- name: deploy — stop spinifex.target before binary swap
|
||||
become: true
|
||||
ansible.builtin.systemd:
|
||||
name: spinifex.target
|
||||
state: stopped
|
||||
failed_when: false
|
||||
|
||||
- name: deploy — install spx binary
|
||||
become: true
|
||||
ansible.builtin.copy:
|
||||
src: "{{ spinifex_root }}/bin/spx"
|
||||
dest: /usr/local/bin/spx
|
||||
mode: "0755"
|
||||
owner: root
|
||||
group: root
|
||||
|
||||
- name: deploy — install viperblock nbdkit plugin
|
||||
become: true
|
||||
ansible.builtin.copy:
|
||||
src: "{{ mulga_root }}/viperblock/lib/nbdkit-viperblock-plugin.so"
|
||||
dest: "{{ deploy_plugindir }}/nbdkit-viperblock-plugin.so"
|
||||
mode: "0755"
|
||||
owner: root
|
||||
group: root
|
||||
|
||||
- name: deploy — ensure microvm dir
|
||||
become: true
|
||||
ansible.builtin.file:
|
||||
path: /usr/share/spinifex/microvm
|
||||
state: directory
|
||||
mode: "0755"
|
||||
owner: root
|
||||
group: root
|
||||
when: deploy_microvm_present
|
||||
|
||||
- name: deploy — install microvm kernel
|
||||
become: true
|
||||
ansible.builtin.copy:
|
||||
src: "{{ spinifex_root }}/build/microvm/vmlinuz"
|
||||
dest: /usr/share/spinifex/microvm/vmlinuz
|
||||
mode: "0644"
|
||||
owner: root
|
||||
group: root
|
||||
when: deploy_microvm_present
|
||||
|
||||
- name: deploy — install microvm initramfs
|
||||
become: true
|
||||
ansible.builtin.copy:
|
||||
src: "{{ spinifex_root }}/build/microvm/initramfs.cpio.gz"
|
||||
dest: /usr/share/spinifex/microvm/initramfs.cpio.gz
|
||||
mode: "0644"
|
||||
owner: root
|
||||
group: root
|
||||
when: deploy_microvm_present
|
||||
|
||||
- name: deploy — daemon-reload (covers any unit changes shipped via setup.sh later)
|
||||
become: true
|
||||
ansible.builtin.systemd:
|
||||
daemon_reload: true
|
||||
|
||||
- name: deploy — start spinifex.target
|
||||
become: true
|
||||
ansible.builtin.systemd:
|
||||
name: spinifex.target
|
||||
state: started
|
||||
|
||||
- name: deploy — wait for awsgw to respond
|
||||
ansible.builtin.command: "{{ spinifex_aws_cli }} ec2 describe-regions"
|
||||
register: deploy_awsgw
|
||||
retries: 15
|
||||
delay: 2
|
||||
until: deploy_awsgw.rc == 0
|
||||
changed_when: false
|
||||
|
||||
- name: deploy — summary
|
||||
ansible.builtin.debug:
|
||||
msg: |
|
||||
Deployed:
|
||||
spx → /usr/local/bin/spx
|
||||
nbdkit plugin → {{ deploy_plugindir }}/nbdkit-viperblock-plugin.so
|
||||
microvm → {{ '/usr/share/spinifex/microvm/{vmlinuz,initramfs.cpio.gz}' if deploy_microvm_present else 'skipped (not built on this branch)' }}
|
||||
spinifex.target restarted; awsgw responding.
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
galaxy_info:
|
||||
role_name: init
|
||||
description: Run spx admin init, install CA trust, start services, import LB image.
|
||||
license: BSD-3-Clause
|
||||
min_ansible_version: "2.14"
|
||||
dependencies: []
|
||||
@@ -1,90 +0,0 @@
|
||||
---
|
||||
- name: init — base admin-init flags
|
||||
ansible.builtin.set_fact:
|
||||
init_base_flags: >-
|
||||
--force
|
||||
--region {{ spinifex_region }}
|
||||
--az {{ spinifex_az }}
|
||||
--node {{ spinifex_node_name }}
|
||||
--nodes {{ spinifex_node_count }}
|
||||
|
||||
- name: init — no-external opt-out
|
||||
ansible.builtin.set_fact:
|
||||
init_external_flags: "--no-external"
|
||||
when: spinifex_external_no_external | bool
|
||||
|
||||
- name: init — assert pool range supplied when mode=pool
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- spinifex_external_pool_start | length > 0
|
||||
- spinifex_external_pool_end | length > 0
|
||||
fail_msg: |
|
||||
spx admin init requires --external-pool when --external-mode=pool.
|
||||
Set spinifex_external_pool_start / spinifex_external_pool_end in
|
||||
scripts/ansible/vars/defaults.yml, or pass:
|
||||
-e spinifex_external_pool_start=192.168.1.150
|
||||
-e spinifex_external_pool_end=192.168.1.250
|
||||
Pass -e spinifex_external_no_external=true to skip external networking.
|
||||
when:
|
||||
- not (spinifex_external_no_external | bool)
|
||||
- spinifex_external_mode == 'pool'
|
||||
|
||||
- name: init — pool-mode external flags
|
||||
ansible.builtin.set_fact:
|
||||
init_external_flags: >-
|
||||
--external-mode=pool
|
||||
--external-pool={{ spinifex_external_pool_start }}-{{ spinifex_external_pool_end }}
|
||||
{% if spinifex_external_gateway | length > 0 %}--external-gateway={{ spinifex_external_gateway }}{% endif %}
|
||||
{% if spinifex_external_prefix_len | string | length > 0 %}--external-prefix-len={{ spinifex_external_prefix_len }}{% endif %}
|
||||
{% if spinifex_external_gateway_ip | length > 0 %}--gateway-ip={{ spinifex_external_gateway_ip }}{% endif %}
|
||||
when:
|
||||
- not (spinifex_external_no_external | bool)
|
||||
- spinifex_external_mode == 'pool'
|
||||
|
||||
- name: init — assemble full flag string
|
||||
ansible.builtin.set_fact:
|
||||
init_all_flags: "{{ init_base_flags }} {{ init_external_flags | default('') }}"
|
||||
|
||||
- name: init — run spx admin init
|
||||
become: true
|
||||
ansible.builtin.command: "spx admin init {{ init_all_flags }}"
|
||||
changed_when: true
|
||||
|
||||
- name: init — copy CA to system trust
|
||||
become: true
|
||||
ansible.builtin.copy:
|
||||
src: "{{ spinifex_ca_src }}"
|
||||
dest: "{{ spinifex_ca_trust_path }}"
|
||||
mode: "0644"
|
||||
remote_src: true
|
||||
|
||||
- name: init — rebuild CA bundle (not --fresh — preserve other CAs)
|
||||
become: true
|
||||
ansible.builtin.command: update-ca-certificates
|
||||
changed_when: true
|
||||
|
||||
- name: init — start spinifex.target
|
||||
become: true
|
||||
ansible.builtin.systemd:
|
||||
name: spinifex.target
|
||||
state: started
|
||||
|
||||
# systemctl start returns as soon as units activate; awsgw still wires up
|
||||
# to NATS + daemons after that. Bash start-dev.sh has internal wait loops;
|
||||
# systemd path does not. Poll a benign call until gateway is fully ready.
|
||||
- name: init — wait for AWS gateway readiness
|
||||
ansible.builtin.command: "{{ spinifex_aws_cli }} ec2 describe-regions"
|
||||
register: init_gw_ready
|
||||
until: init_gw_ready.rc == 0
|
||||
retries: 30
|
||||
delay: 2
|
||||
changed_when: false
|
||||
|
||||
- name: init — summary
|
||||
ansible.builtin.debug:
|
||||
msg: |
|
||||
Init complete:
|
||||
region={{ spinifex_region }}, az={{ spinifex_az }}, node={{ spinifex_node_name }}
|
||||
external_mode={{ 'disabled' if (spinifex_external_no_external | bool) else spinifex_external_mode }}
|
||||
spinifex.target: started
|
||||
CA: {{ spinifex_ca_trust_path }}
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
galaxy_info:
|
||||
role_name: install
|
||||
description: Invoke setup.sh and setup-ovn.sh to install Spinifex into production paths.
|
||||
license: BSD-3-Clause
|
||||
min_ansible_version: "2.14"
|
||||
dependencies: []
|
||||
@@ -1,79 +0,0 @@
|
||||
---
|
||||
- name: install — assert tarball exists
|
||||
ansible.builtin.stat:
|
||||
path: "{{ build_tarball_path }}"
|
||||
register: install_tarball
|
||||
|
||||
- name: install — fail if tarball missing
|
||||
ansible.builtin.assert:
|
||||
that: install_tarball.stat.exists
|
||||
fail_msg: "Tarball {{ build_tarball_path }} missing. Run the build role first."
|
||||
|
||||
- name: install — stop spinifex.target before reinstall
|
||||
become: true
|
||||
ansible.builtin.systemd:
|
||||
name: spinifex.target
|
||||
state: stopped
|
||||
failed_when: false
|
||||
|
||||
- name: install — reset failed state (spinifex-* scope)
|
||||
become: true
|
||||
ansible.builtin.command: systemctl reset-failed 'spinifex-*'
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: install — scrub stale pid/log files from prior dev-mode installs
|
||||
become: true
|
||||
ansible.builtin.shell: |
|
||||
for dir in {{ spinifex_data_dir }} {{ spinifex_log_dir }} {{ spinifex_etc_dir }}; do
|
||||
if [ -d "$dir" ]; then
|
||||
find "$dir" -name '*.pid' -delete 2>/dev/null || true
|
||||
find "$dir" -name '*.log' -delete 2>/dev/null || true
|
||||
find "$dir" -name '*.log.*' -delete 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
changed_when: false
|
||||
|
||||
- name: install — remove legacy /var/lib/spinifex/config
|
||||
become: true
|
||||
ansible.builtin.file:
|
||||
path: "{{ spinifex_data_dir }}/config"
|
||||
state: absent
|
||||
|
||||
- name: install — purge stale cluster state (configs + raft + NATS KV) on bootstrap
|
||||
become: true
|
||||
ansible.builtin.file:
|
||||
path: "{{ item }}"
|
||||
state: absent
|
||||
loop:
|
||||
- "{{ spinifex_etc_dir }}/predastore/predastore.toml"
|
||||
- "{{ spinifex_etc_dir }}/spinifex.toml"
|
||||
- "{{ spinifex_data_dir }}/predastore"
|
||||
- "{{ spinifex_data_dir }}/nats"
|
||||
- "{{ spinifex_data_dir }}/awsgw"
|
||||
when: cluster_bootstrap_purge | default(false) | bool
|
||||
|
||||
- name: install — run setup.sh (creates users, dirs, systemd units)
|
||||
become: true
|
||||
ansible.builtin.command:
|
||||
cmd: "bash {{ spinifex_root }}/scripts/setup.sh"
|
||||
environment:
|
||||
INSTALL_SPINIFEX_TARBALL: "{{ build_tarball_path }}"
|
||||
INSTALL_SPINIFEX_SKIP_NEWGRP: "1"
|
||||
changed_when: true
|
||||
|
||||
- name: install — cleanup tarball
|
||||
ansible.builtin.file:
|
||||
path: "{{ build_tarball_path }}"
|
||||
state: absent
|
||||
|
||||
- name: install — run setup-ovn.sh --management
|
||||
become: true
|
||||
ansible.builtin.command:
|
||||
cmd: "/usr/local/share/spinifex/setup-ovn.sh --management --node-name={{ spinifex_node_name }}"
|
||||
changed_when: true
|
||||
when: install_run_ovn | default(true) | bool
|
||||
|
||||
- name: install — summary
|
||||
ansible.builtin.debug:
|
||||
msg: "Install complete — setup.sh + setup-ovn.sh ran. Services NOT yet started (init role handles that)."
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
galaxy_info:
|
||||
role_name: logs
|
||||
description: Dump spinifex + ovn + system logs into a tarball for bug reports.
|
||||
license: BSD-3-Clause
|
||||
min_ansible_version: "2.14"
|
||||
dependencies: []
|
||||
@@ -1,153 +0,0 @@
|
||||
---
|
||||
# Collect a self-contained log bundle for bug reports. NEVER mutates running
|
||||
# state — only reads journalctl + /var/log/spinifex/* + status output, then
|
||||
# tars into logs_output_dir. Errors on missing inputs are non-fatal so a
|
||||
# half-broken node still produces a bundle.
|
||||
|
||||
- name: logs — resolve staging directory
|
||||
ansible.builtin.set_fact:
|
||||
logs_stage_dir: "/tmp/spinifex-logs-{{ ansible_facts['hostname'] }}-{{ ansible_facts['date_time']['iso8601_basic_short'] }}"
|
||||
logs_bundle_path: "{{ logs_output_dir }}/spinifex-logs-{{ ansible_facts['hostname'] }}-{{ ansible_facts['date_time']['iso8601_basic_short'] }}.tar.gz"
|
||||
|
||||
- name: logs — create staging tree
|
||||
ansible.builtin.file:
|
||||
path: "{{ logs_stage_dir }}/{{ item }}"
|
||||
state: directory
|
||||
mode: "0755"
|
||||
loop:
|
||||
- journal
|
||||
- varlog
|
||||
- status
|
||||
- kernel
|
||||
|
||||
- name: logs — capture journalctl for spinifex-* units
|
||||
become: true
|
||||
ansible.builtin.shell: |
|
||||
journalctl --no-pager --output=short-iso \
|
||||
-u 'spinifex-*' --since {{ logs_since | quote }} \
|
||||
> {{ logs_stage_dir }}/journal/spinifex.log 2>&1 || true
|
||||
changed_when: true
|
||||
|
||||
- name: logs — capture journalctl for ovn-* + openvswitch-switch
|
||||
become: true
|
||||
ansible.builtin.shell: |
|
||||
journalctl --no-pager --output=short-iso \
|
||||
-u 'ovn-*' -u openvswitch-switch \
|
||||
--since {{ logs_since | quote }} \
|
||||
> {{ logs_stage_dir }}/journal/ovn-ovs.log 2>&1 || true
|
||||
changed_when: true
|
||||
|
||||
- name: logs — capture journalctl for nbdkit + qemu (best-effort match)
|
||||
become: true
|
||||
ansible.builtin.shell: |
|
||||
journalctl --no-pager --output=short-iso \
|
||||
--since {{ logs_since | quote }} \
|
||||
_COMM=qemu-system-x86_64 + _COMM=qemu-system-aarch64 + _COMM=nbdkit \
|
||||
> {{ logs_stage_dir }}/journal/qemu-nbdkit.log 2>&1 || true
|
||||
changed_when: true
|
||||
|
||||
- name: logs — copy /var/log/spinifex/*
|
||||
become: true
|
||||
ansible.builtin.shell: |
|
||||
if [ -d {{ spinifex_log_dir | quote }} ]; then
|
||||
cp -a {{ spinifex_log_dir | quote }}/. {{ logs_stage_dir | quote }}/varlog/ 2>/dev/null || true
|
||||
fi
|
||||
changed_when: true
|
||||
|
||||
- name: logs — systemctl status snapshot (spinifex-*)
|
||||
become: true
|
||||
ansible.builtin.shell: |
|
||||
systemctl status 'spinifex-*' --no-pager --all \
|
||||
> {{ logs_stage_dir }}/status/spinifex-units.txt 2>&1 || true
|
||||
systemctl status ovn-controller ovn-central openvswitch-switch \
|
||||
--no-pager > {{ logs_stage_dir }}/status/ovn-ovs-units.txt 2>&1 || true
|
||||
systemctl list-units 'spinifex-*' --all --no-legend --plain \
|
||||
> {{ logs_stage_dir }}/status/spinifex-list.txt 2>&1 || true
|
||||
changed_when: true
|
||||
|
||||
- name: logs — networking snapshot (ip, ss, ovs)
|
||||
become: true
|
||||
ansible.builtin.shell: |
|
||||
ip -d -s link show > {{ logs_stage_dir }}/status/ip-link.txt 2>&1 || true
|
||||
ip addr show > {{ logs_stage_dir }}/status/ip-addr.txt 2>&1 || true
|
||||
ip route show table all > {{ logs_stage_dir }}/status/ip-route.txt 2>&1 || true
|
||||
ss -tlnp > {{ logs_stage_dir }}/status/ss-tlnp.txt 2>&1 || true
|
||||
if command -v ovs-vsctl >/dev/null 2>&1; then
|
||||
ovs-vsctl show > {{ logs_stage_dir }}/status/ovs-show.txt 2>&1 || true
|
||||
ovs-vsctl list-br > {{ logs_stage_dir }}/status/ovs-br.txt 2>&1 || true
|
||||
fi
|
||||
if command -v ovn-nbctl >/dev/null 2>&1; then
|
||||
ovn-nbctl show > {{ logs_stage_dir }}/status/ovn-nb.txt 2>&1 || true
|
||||
ovn-sbctl show > {{ logs_stage_dir }}/status/ovn-sb.txt 2>&1 || true
|
||||
fi
|
||||
changed_when: true
|
||||
|
||||
- name: logs — kernel ring buffer
|
||||
become: true
|
||||
ansible.builtin.shell: |
|
||||
dmesg --time-format=iso > {{ logs_stage_dir }}/kernel/dmesg.txt 2>&1 || true
|
||||
changed_when: true
|
||||
when: logs_include_dmesg | bool
|
||||
|
||||
- name: logs — capture spx + plugin versions
|
||||
become: true
|
||||
ansible.builtin.shell: |
|
||||
{
|
||||
echo '## spx version'
|
||||
/usr/local/bin/spx version 2>&1 | grep -vE '^[0-9]{4}/' || true
|
||||
echo
|
||||
echo '## nbdkit plugin sha256'
|
||||
pdir=$(nbdkit --dump-config 2>/dev/null | awk -F= '/^plugindir=/{print $2; exit}')
|
||||
[ -n "$pdir" ] && sha256sum "$pdir/nbdkit-viperblock-plugin.so" 2>/dev/null || true
|
||||
echo
|
||||
echo '## microvm artifacts'
|
||||
sha256sum /usr/share/spinifex/microvm/* 2>/dev/null || true
|
||||
echo
|
||||
echo '## kernel + os'
|
||||
uname -a
|
||||
cat /etc/os-release 2>/dev/null || true
|
||||
} > {{ logs_stage_dir }}/status/versions.txt 2>&1 || true
|
||||
changed_when: true
|
||||
|
||||
- name: logs — create bundle tarball
|
||||
become: true
|
||||
ansible.builtin.shell: |
|
||||
tar -C {{ logs_stage_dir | dirname | quote }} \
|
||||
-czf {{ logs_bundle_path | quote }} \
|
||||
{{ logs_stage_dir | basename | quote }}
|
||||
changed_when: true
|
||||
|
||||
- name: logs — make bundle owned by invoking user
|
||||
become: true
|
||||
ansible.builtin.file:
|
||||
path: "{{ logs_bundle_path }}"
|
||||
owner: "{{ spinifex_invoking_user }}"
|
||||
group: "{{ spinifex_invoking_user }}"
|
||||
mode: "0640"
|
||||
|
||||
- name: logs — remove staging tree
|
||||
become: true
|
||||
ansible.builtin.file:
|
||||
path: "{{ logs_stage_dir }}"
|
||||
state: absent
|
||||
|
||||
- name: logs — bundle size
|
||||
ansible.builtin.stat:
|
||||
path: "{{ logs_bundle_path }}"
|
||||
register: logs_bundle_stat
|
||||
|
||||
- name: logs — summary
|
||||
ansible.builtin.debug:
|
||||
msg: |
|
||||
Log bundle written:
|
||||
path : {{ logs_bundle_path }}
|
||||
size : {{ (logs_bundle_stat.stat.size | int / 1024) | round(1) }} KiB
|
||||
since : {{ logs_since }}
|
||||
Contents:
|
||||
journal/ spinifex-*, ovn-*+openvswitch-switch, qemu/nbdkit
|
||||
varlog/ copy of {{ spinifex_log_dir }}
|
||||
status/ systemctl status, ip/ss/ovs/ovn show, versions
|
||||
kernel/ dmesg{{ '' if logs_include_dmesg else ' (skipped via -e logs_include_dmesg=false)' }}
|
||||
|
||||
Attach to bug report. For live tail use:
|
||||
journalctl -u 'spinifex-*' -f
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
galaxy_info:
|
||||
role_name: preflight
|
||||
description: Validate dev-host dependencies for Spinifex (parity with dev-setup.sh).
|
||||
license: BSD-3-Clause
|
||||
min_ansible_version: "2.14"
|
||||
dependencies: []
|
||||
@@ -1,98 +0,0 @@
|
||||
---
|
||||
- name: Check required commands
|
||||
ansible.builtin.command: "which {{ item }}"
|
||||
register: preflight_required
|
||||
loop: "{{ spinifex_required_commands }}"
|
||||
changed_when: false
|
||||
failed_when: preflight_required.rc != 0
|
||||
loop_control:
|
||||
label: "{{ item }}"
|
||||
|
||||
- name: Check optional commands
|
||||
ansible.builtin.command: "which {{ item }}"
|
||||
register: preflight_optional
|
||||
loop: "{{ spinifex_optional_commands }}"
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
loop_control:
|
||||
label: "{{ item }}"
|
||||
|
||||
- name: Warn on missing optional commands
|
||||
ansible.builtin.debug:
|
||||
msg: "WARN: optional command '{{ item.item }}' not found"
|
||||
loop: "{{ preflight_optional.results }}"
|
||||
when: item.rc != 0
|
||||
loop_control:
|
||||
label: "{{ item.item }}"
|
||||
|
||||
- name: Check OVN/OVS commands
|
||||
ansible.builtin.command: "which {{ item }}"
|
||||
register: preflight_ovn
|
||||
loop: "{{ spinifex_ovn_commands }}"
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
loop_control:
|
||||
label: "{{ item }}"
|
||||
|
||||
- name: Warn on missing OVN/OVS tools
|
||||
ansible.builtin.debug:
|
||||
msg: "WARN: OVN/OVS tool '{{ item.item }}' not found (required for VPC networking). Install: sudo apt-get install ovn-central ovn-host openvswitch-switch"
|
||||
loop: "{{ preflight_ovn.results }}"
|
||||
when: item.rc != 0
|
||||
loop_control:
|
||||
label: "{{ item.item }}"
|
||||
|
||||
- name: Record OVN tool availability
|
||||
ansible.builtin.set_fact:
|
||||
preflight_ovs_present: "{{ (preflight_ovn.results | selectattr('item','eq','ovs-vsctl') | selectattr('rc','eq',0) | list | length) > 0 }}"
|
||||
|
||||
- name: Check br-int bridge
|
||||
ansible.builtin.command: ovs-vsctl br-exists br-int
|
||||
register: preflight_brint
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
when: preflight_ovs_present
|
||||
|
||||
- name: Warn if br-int missing
|
||||
ansible.builtin.debug:
|
||||
msg: "WARN: br-int missing. Run scripts/setup-ovn.sh --management to initialise OVN networking."
|
||||
when:
|
||||
- preflight_ovs_present
|
||||
- preflight_brint.rc | default(0) != 0
|
||||
|
||||
- name: Check sibling component directories
|
||||
ansible.builtin.stat:
|
||||
path: "{{ playbook_dir }}/../../../../{{ item }}/go.mod"
|
||||
register: preflight_components
|
||||
loop: "{{ spinifex_components }}"
|
||||
loop_control:
|
||||
label: "{{ item }}"
|
||||
|
||||
- name: Warn on missing component checkouts
|
||||
ansible.builtin.debug:
|
||||
msg: "WARN: component '{{ item.item }}' not found at expected sibling path (no go.mod). Run scripts/clone-deps.sh."
|
||||
loop: "{{ preflight_components.results }}"
|
||||
when: not item.stat.exists
|
||||
loop_control:
|
||||
label: "{{ item.item }}"
|
||||
|
||||
- name: Check port availability
|
||||
ansible.builtin.shell: "ss -tlnH 'sport = :{{ item.port }}' | head -1"
|
||||
register: preflight_ports
|
||||
loop: "{{ spinifex_ports }}"
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
loop_control:
|
||||
label: "{{ item.service }} ({{ item.port }})"
|
||||
|
||||
- name: Warn on occupied ports
|
||||
ansible.builtin.debug:
|
||||
msg: "WARN: port {{ item.item.port }} occupied (needed for {{ item.item.service }})"
|
||||
loop: "{{ preflight_ports.results }}"
|
||||
when: item.stdout | length > 0
|
||||
loop_control:
|
||||
label: "{{ item.item.service }}"
|
||||
|
||||
- name: Preflight summary
|
||||
ansible.builtin.debug:
|
||||
msg: "Preflight passed. Required: {{ spinifex_required_commands | join(', ') }}. Warnings above (if any) are non-fatal."
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
galaxy_info:
|
||||
role_name: restore
|
||||
description: Restore a spinifex+ovn+ovs snapshot bundle, refresh CA trust, restart.
|
||||
license: BSD-3-Clause
|
||||
min_ansible_version: "2.14"
|
||||
dependencies: []
|
||||
@@ -1,140 +0,0 @@
|
||||
---
|
||||
# Inverse of the snapshot role: stop services, wipe the same path set,
|
||||
# untar the named bundle, refresh CA trust (because /etc/spinifex/ca.pem
|
||||
# came from the snapshot but the system trust store didn't), restart.
|
||||
|
||||
- name: restore — require snapshot_name
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- snapshot_name is defined
|
||||
- snapshot_name | length > 0
|
||||
- snapshot_name is match('^[A-Za-z0-9._-]+$')
|
||||
fail_msg: "snapshot_name required (alphanumeric/._-): -e snapshot_name=<name>"
|
||||
|
||||
- name: restore — resolve bundle path
|
||||
ansible.builtin.set_fact:
|
||||
snapshot_bundle_path: "{{ snapshot_dir }}/{{ snapshot_name }}.tar.gz"
|
||||
|
||||
- name: restore — assert bundle exists
|
||||
become: true
|
||||
ansible.builtin.stat:
|
||||
path: "{{ snapshot_bundle_path }}"
|
||||
register: restore_bundle_stat
|
||||
|
||||
- name: restore — fail if missing
|
||||
ansible.builtin.assert:
|
||||
that: restore_bundle_stat.stat.exists
|
||||
fail_msg: |
|
||||
Snapshot bundle missing: {{ snapshot_bundle_path }}.
|
||||
Available bundles:
|
||||
ls -lh {{ snapshot_dir }}/*.tar.gz 2>/dev/null
|
||||
|
||||
- name: restore — stop services in dependency order
|
||||
become: true
|
||||
ansible.builtin.systemd:
|
||||
name: "{{ item }}"
|
||||
state: stopped
|
||||
loop: "{{ snapshot_services_stop_order }}"
|
||||
failed_when: false
|
||||
|
||||
- name: restore — kill lingering QEMU VMs
|
||||
become: true
|
||||
ansible.builtin.command: "pkill -x {{ item }}"
|
||||
loop: "{{ spinifex_qemu_processes }}"
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: restore — wait for QEMU exit
|
||||
become: true
|
||||
ansible.builtin.shell: |
|
||||
set -e
|
||||
for i in $(seq 1 {{ teardown_process_wait_seconds }}); do
|
||||
alive=0
|
||||
for proc in {{ spinifex_qemu_processes | join(' ') }}; do
|
||||
if pgrep -x "$proc" >/dev/null 2>&1; then alive=1; break; fi
|
||||
done
|
||||
if [ "$alive" = "0" ]; then exit 0; fi
|
||||
sleep 1
|
||||
done
|
||||
exit 1
|
||||
changed_when: false
|
||||
|
||||
- name: restore — wipe target paths (only those the snapshot will replace)
|
||||
become: true
|
||||
ansible.builtin.file:
|
||||
path: "{{ item }}"
|
||||
state: absent
|
||||
loop: "{{ snapshot_paths }}"
|
||||
|
||||
- name: restore — recreate parent dirs that tar will refill
|
||||
become: true
|
||||
ansible.builtin.file:
|
||||
path: "{{ item }}"
|
||||
state: directory
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0755"
|
||||
loop:
|
||||
- "{{ ovn_db_dir }}"
|
||||
- /etc/openvswitch
|
||||
|
||||
- name: restore — extract bundle (preserve permissions/ACLs)
|
||||
become: true
|
||||
ansible.builtin.command:
|
||||
cmd: >-
|
||||
tar --acls --xattrs -xzpf {{ snapshot_bundle_path | quote }}
|
||||
-C /
|
||||
changed_when: true
|
||||
|
||||
- name: restore — refresh spinifex CA in system trust
|
||||
become: true
|
||||
ansible.builtin.stat:
|
||||
path: "{{ spinifex_ca_src }}"
|
||||
register: restore_ca_stat
|
||||
|
||||
- name: restore — copy CA back to trust path
|
||||
become: true
|
||||
ansible.builtin.copy:
|
||||
src: "{{ spinifex_ca_src }}"
|
||||
dest: "{{ spinifex_ca_trust_path }}"
|
||||
mode: "0644"
|
||||
remote_src: true
|
||||
when: restore_ca_stat.stat.exists
|
||||
|
||||
- name: restore — rebuild CA bundle
|
||||
become: true
|
||||
ansible.builtin.command: update-ca-certificates
|
||||
changed_when: true
|
||||
when: restore_ca_stat.stat.exists
|
||||
|
||||
- name: restore — daemon-reload (covers any unit file delta in the snapshot)
|
||||
become: true
|
||||
ansible.builtin.systemd:
|
||||
daemon_reload: true
|
||||
|
||||
- name: restore — restart services in reverse order
|
||||
become: true
|
||||
ansible.builtin.systemd:
|
||||
name: "{{ item }}"
|
||||
state: started
|
||||
loop: "{{ snapshot_services_stop_order | reverse | list }}"
|
||||
failed_when: false
|
||||
|
||||
- name: restore — wait for AWS gateway readiness
|
||||
ansible.builtin.command: "{{ spinifex_aws_cli }} ec2 describe-regions"
|
||||
register: restore_gw_ready
|
||||
retries: 30
|
||||
delay: 2
|
||||
until: restore_gw_ready.rc == 0
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: restore — summary
|
||||
ansible.builtin.debug:
|
||||
msg: |
|
||||
Restore complete:
|
||||
snapshot : {{ snapshot_name }}
|
||||
bundle : {{ snapshot_bundle_path }}
|
||||
paths : {{ snapshot_paths | join(', ') }}
|
||||
CA : {{ 'refreshed' if restore_ca_stat.stat.exists else 'not in bundle — system trust unchanged' }}
|
||||
gateway : {{ 'ready' if restore_gw_ready.rc == 0 else 'NOT ready after restart — investigate' }}
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
galaxy_info:
|
||||
role_name: smoketest
|
||||
description: Import SSH key, import Ubuntu AMI, launch one smoke-test instance.
|
||||
license: BSD-3-Clause
|
||||
min_ansible_version: "2.14"
|
||||
dependencies: []
|
||||
@@ -1,197 +0,0 @@
|
||||
---
|
||||
- name: smoketest — resolve invoking user's home
|
||||
ansible.builtin.getent:
|
||||
database: passwd
|
||||
key: "{{ spinifex_invoking_user }}"
|
||||
|
||||
- name: smoketest — set user home fact
|
||||
ansible.builtin.set_fact:
|
||||
smoketest_user_home: "{{ ansible_facts['getent_passwd'][spinifex_invoking_user][4] }}"
|
||||
|
||||
- name: smoketest — generate SSH key if absent
|
||||
become: true
|
||||
become_user: "{{ spinifex_invoking_user }}"
|
||||
ansible.builtin.command:
|
||||
cmd: ssh-keygen -t ed25519 -f {{ smoketest_user_home }}/.ssh/spinifex-key -N ""
|
||||
creates: "{{ smoketest_user_home }}/.ssh/spinifex-key"
|
||||
|
||||
- name: smoketest — import SSH key to spinifex
|
||||
ansible.builtin.command: >-
|
||||
{{ spinifex_aws_cli }} ec2 import-key-pair
|
||||
--key-name spinifex-key
|
||||
--public-key-material fileb://{{ smoketest_user_home }}/.ssh/spinifex-key.pub
|
||||
register: smoketest_keypair
|
||||
until: >-
|
||||
smoketest_keypair.rc == 0 or
|
||||
'InvalidKeyPair.Duplicate' in (smoketest_keypair.stderr | default(''))
|
||||
retries: 10
|
||||
delay: 3
|
||||
changed_when: "'InvalidKeyPair.Duplicate' not in (smoketest_keypair.stderr | default(''))"
|
||||
failed_when:
|
||||
- smoketest_keypair.rc != 0
|
||||
- "'InvalidKeyPair.Duplicate' not in (smoketest_keypair.stderr | default(''))"
|
||||
|
||||
- name: smoketest — detect architecture
|
||||
ansible.builtin.set_fact:
|
||||
smoketest_arch: "{{ 'arm64' if ansible_facts['architecture'] in ['aarch64','arm64'] else 'x86_64' }}"
|
||||
|
||||
- name: smoketest — resolve local image path
|
||||
ansible.builtin.set_fact:
|
||||
smoketest_local_image_path: "{{ smoketest_local_image | expanduser }}"
|
||||
vars:
|
||||
smoketest_local_image: "{{ spinifex_smoketest_local_image | replace('~', smoketest_user_home) }}"
|
||||
|
||||
- name: smoketest — check for local image
|
||||
ansible.builtin.stat:
|
||||
path: "{{ smoketest_local_image_path }}"
|
||||
register: smoketest_local_img
|
||||
|
||||
- name: smoketest — import AMI from local file
|
||||
ansible.builtin.command: >-
|
||||
{{ spinifex_spx_cli }} admin images import
|
||||
--file {{ smoketest_local_image_path }}
|
||||
--distro {{ spinifex_smoketest_distro }}
|
||||
--version {{ spinifex_smoketest_version }}
|
||||
--arch {{ smoketest_arch }}
|
||||
--boot-mode uefi
|
||||
register: smoketest_import_local
|
||||
until: >-
|
||||
smoketest_import_local.rc == 0 or
|
||||
'already exists' in (smoketest_import_local.stderr | default(''))
|
||||
retries: 5
|
||||
delay: 3
|
||||
changed_when: smoketest_import_local.rc == 0
|
||||
failed_when:
|
||||
- smoketest_import_local.rc != 0
|
||||
- "'already exists' not in (smoketest_import_local.stderr | default(''))"
|
||||
when: smoketest_local_img.stat.exists
|
||||
|
||||
- name: smoketest — import AMI by name (fallback)
|
||||
ansible.builtin.command: >-
|
||||
{{ spinifex_spx_cli }} admin images import
|
||||
--name {{ spinifex_smoketest_distro }}-{{ spinifex_smoketest_version }}-{{ smoketest_arch }}
|
||||
register: smoketest_import_remote
|
||||
until: >-
|
||||
smoketest_import_remote.rc == 0 or
|
||||
'already exists' in (smoketest_import_remote.stderr | default(''))
|
||||
retries: 5
|
||||
delay: 3
|
||||
changed_when: smoketest_import_remote.rc == 0
|
||||
failed_when:
|
||||
- smoketest_import_remote.rc != 0
|
||||
- "'already exists' not in (smoketest_import_remote.stderr | default(''))"
|
||||
when: not smoketest_local_img.stat.exists
|
||||
|
||||
- name: smoketest — detect CPU vendor for instance type choice
|
||||
ansible.builtin.command: grep -q AuthenticAMD /proc/cpuinfo
|
||||
register: smoketest_amd
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: smoketest — pick instance type
|
||||
ansible.builtin.set_fact:
|
||||
smoketest_instance_type: "{{ 't3a.small' if smoketest_amd.rc == 0 else 't3.small' }}"
|
||||
|
||||
- name: smoketest — find AMI ID
|
||||
ansible.builtin.command: "{{ spinifex_aws_cli }} ec2 describe-images --query Images[0].ImageId --output text"
|
||||
register: smoketest_ami
|
||||
until: smoketest_ami.rc == 0
|
||||
retries: 10
|
||||
delay: 3
|
||||
changed_when: false
|
||||
|
||||
- name: smoketest — find public subnet (best-effort)
|
||||
ansible.builtin.command: >-
|
||||
{{ spinifex_aws_cli }} ec2 describe-subnets
|
||||
--filters Name=map-public-ip-on-launch,Values=true
|
||||
--query Subnets[0].SubnetId
|
||||
--output text
|
||||
register: smoketest_subnet_public
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: smoketest — fallback to first subnet (best-effort)
|
||||
ansible.builtin.command: "{{ spinifex_aws_cli }} ec2 describe-subnets --query Subnets[0].SubnetId --output text"
|
||||
register: smoketest_subnet_any
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
when:
|
||||
- smoketest_subnet_public.rc != 0 or smoketest_subnet_public.stdout in ['', 'None']
|
||||
|
||||
- name: smoketest — resolve subnet id (empty string if none)
|
||||
ansible.builtin.set_fact:
|
||||
smoketest_subnet_id: >-
|
||||
{%- if smoketest_subnet_public.rc == 0 and smoketest_subnet_public.stdout not in ['', 'None'] -%}
|
||||
{{ smoketest_subnet_public.stdout }}
|
||||
{%- elif (smoketest_subnet_any.rc | default(1)) == 0 and (smoketest_subnet_any.stdout | default('')) not in ['', 'None'] -%}
|
||||
{{ smoketest_subnet_any.stdout }}
|
||||
{%- else -%}{%- endif -%}
|
||||
|
||||
- name: smoketest — skip launch if no subnet (fresh install with no user VPC)
|
||||
ansible.builtin.debug:
|
||||
msg: |
|
||||
No subnet found — skipping instance launch.
|
||||
Services are up, CA is trusted, keypair imported, AMI imported.
|
||||
Create a VPC+subnet via tofu examples (or `aws ec2 create-vpc` /
|
||||
`create-subnet`) then re-run if you want a live instance.
|
||||
when: smoketest_subnet_id | trim == ''
|
||||
|
||||
- name: smoketest — launch instance
|
||||
ansible.builtin.command: >-
|
||||
{{ spinifex_aws_cli }} ec2 run-instances
|
||||
--image-id {{ smoketest_ami.stdout }}
|
||||
--instance-type {{ smoketest_instance_type }}
|
||||
--key-name spinifex-key
|
||||
--subnet-id {{ smoketest_subnet_id | trim }}
|
||||
--count 1
|
||||
--query Instances[0].InstanceId
|
||||
--output text
|
||||
register: smoketest_instance
|
||||
until: smoketest_instance.rc == 0
|
||||
retries: 5
|
||||
delay: 3
|
||||
changed_when: true
|
||||
when: smoketest_subnet_id | trim != ''
|
||||
|
||||
- name: smoketest — wait for instance running (proves launch path works)
|
||||
ansible.builtin.command: >-
|
||||
{{ spinifex_aws_cli }} ec2 describe-instances
|
||||
--instance-ids {{ smoketest_instance.stdout }}
|
||||
--query Reservations[0].Instances[0].State.Name
|
||||
--output text
|
||||
register: smoketest_instance_state
|
||||
until: smoketest_instance_state.stdout | trim == 'running'
|
||||
retries: 30
|
||||
delay: 2
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
when:
|
||||
- smoketest_subnet_id | trim != ''
|
||||
- smoketest_instance.stdout is defined
|
||||
- smoketest_instance.stdout | trim != ''
|
||||
|
||||
- name: smoketest — terminate instance (cleanup)
|
||||
ansible.builtin.command: >-
|
||||
{{ spinifex_aws_cli }} ec2 terminate-instances
|
||||
--instance-ids {{ smoketest_instance.stdout }}
|
||||
register: smoketest_terminate
|
||||
until: smoketest_terminate.rc == 0
|
||||
retries: 5
|
||||
delay: 3
|
||||
changed_when: true
|
||||
failed_when: false
|
||||
when:
|
||||
- smoketest_subnet_id | trim != ''
|
||||
- smoketest_instance.stdout is defined
|
||||
- smoketest_instance.stdout | trim != ''
|
||||
|
||||
- name: smoketest — summary
|
||||
ansible.builtin.debug:
|
||||
msg: |
|
||||
Smoketest complete:
|
||||
AMI: {{ smoketest_ami.stdout }}
|
||||
Subnet: {{ smoketest_subnet_id | trim | default('(none — launch skipped)', true) }}
|
||||
Instance type: {{ smoketest_instance_type }}
|
||||
Instance ID: {{ smoketest_instance.stdout | default('(skipped)', true) }}
|
||||
Final state: {{ smoketest_instance_state.stdout | default('(skipped)', true) | trim }}
|
||||
Terminated: {{ 'yes' if smoketest_terminate.rc | default(1) == 0 else 'no (launch skipped or terminate failed)' }}
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
galaxy_info:
|
||||
role_name: snapshot
|
||||
description: Quiesce spinifex + ovn + ovs, tar state paths, restart.
|
||||
license: BSD-3-Clause
|
||||
min_ansible_version: "2.14"
|
||||
dependencies: []
|
||||
@@ -1,154 +0,0 @@
|
||||
---
|
||||
# Snapshot a quiesced spinifex node: stop spinifex.target → ovn → ovs in order,
|
||||
# tar /etc/spinifex + /var/lib/spinifex + /var/lib/ovn + /etc/openvswitch/conf.db,
|
||||
# restart in reverse. Lets devs checkpoint a working state before risky ops
|
||||
# (branch switch, viperblock surgery, ovn-controller restart cycle) and roll
|
||||
# back via dev-restore without a full ~10-min dev-reset.
|
||||
#
|
||||
# Caveat: /var/lib/spinifex contains viperblock volumes — snapshot size scales
|
||||
# with VM disks. Default gzip; switch to a faster compressor by hand if needed.
|
||||
|
||||
- name: snapshot — require snapshot_name
|
||||
ansible.builtin.assert:
|
||||
that:
|
||||
- snapshot_name is defined
|
||||
- snapshot_name | length > 0
|
||||
- snapshot_name is match('^[A-Za-z0-9._-]+$')
|
||||
fail_msg: "snapshot_name required (alphanumeric/._-): -e snapshot_name=<name>"
|
||||
|
||||
- name: snapshot — resolve bundle path
|
||||
ansible.builtin.set_fact:
|
||||
snapshot_bundle_path: "{{ snapshot_dir }}/{{ snapshot_name }}.tar.gz"
|
||||
|
||||
- name: snapshot — refuse to overwrite existing bundle
|
||||
ansible.builtin.stat:
|
||||
path: "{{ snapshot_bundle_path }}"
|
||||
register: snapshot_existing
|
||||
|
||||
- name: snapshot — assert not already present
|
||||
ansible.builtin.assert:
|
||||
that: not snapshot_existing.stat.exists
|
||||
fail_msg: |
|
||||
Snapshot {{ snapshot_bundle_path }} already exists. Pick another
|
||||
name, or `sudo rm` the existing bundle first.
|
||||
|
||||
- name: snapshot — ensure snapshot dir exists
|
||||
become: true
|
||||
ansible.builtin.file:
|
||||
path: "{{ snapshot_dir }}"
|
||||
state: directory
|
||||
owner: root
|
||||
group: root
|
||||
mode: "0750"
|
||||
|
||||
- name: snapshot — stop services in dependency order
|
||||
become: true
|
||||
ansible.builtin.systemd:
|
||||
name: "{{ item }}"
|
||||
state: stopped
|
||||
loop: "{{ snapshot_services_stop_order }}"
|
||||
failed_when: false
|
||||
|
||||
- name: snapshot — kill lingering QEMU VMs
|
||||
become: true
|
||||
ansible.builtin.command: "pkill -x {{ item }}"
|
||||
loop: "{{ spinifex_qemu_processes }}"
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: snapshot — wait for QEMU exit (up to {{ teardown_process_wait_seconds }}s)
|
||||
become: true
|
||||
ansible.builtin.shell: |
|
||||
set -e
|
||||
for i in $(seq 1 {{ teardown_process_wait_seconds }}); do
|
||||
alive=0
|
||||
for proc in {{ spinifex_qemu_processes | join(' ') }}; do
|
||||
if pgrep -x "$proc" >/dev/null 2>&1; then alive=1; break; fi
|
||||
done
|
||||
if [ "$alive" = "0" ]; then exit 0; fi
|
||||
sleep 1
|
||||
done
|
||||
echo "QEMU still running after {{ teardown_process_wait_seconds }}s" >&2
|
||||
exit 1
|
||||
changed_when: false
|
||||
|
||||
- name: snapshot — verify which paths actually exist (skip missing silently)
|
||||
ansible.builtin.stat:
|
||||
path: "{{ item }}"
|
||||
register: snapshot_path_stats
|
||||
loop: "{{ snapshot_paths }}"
|
||||
loop_control:
|
||||
label: "{{ item }}"
|
||||
|
||||
- name: snapshot — assemble list of present paths
|
||||
ansible.builtin.set_fact:
|
||||
snapshot_present_paths: >-
|
||||
{{ snapshot_path_stats.results
|
||||
| selectattr('stat.exists') | map(attribute='item') | list }}
|
||||
|
||||
- name: snapshot — assert at least one path present
|
||||
ansible.builtin.assert:
|
||||
that: snapshot_present_paths | length > 0
|
||||
fail_msg: |
|
||||
None of the snapshot paths exist:
|
||||
{{ snapshot_paths | join(', ') }}
|
||||
Has spinifex ever been installed on this node?
|
||||
|
||||
- name: snapshot — create bundle (tar czf with absolute paths)
|
||||
become: true
|
||||
ansible.builtin.command:
|
||||
cmd: >-
|
||||
tar --acls --xattrs -czpf {{ snapshot_bundle_path | quote }}
|
||||
{{ snapshot_present_paths | map('quote') | join(' ') }}
|
||||
changed_when: true
|
||||
|
||||
- name: snapshot — record manifest inside the bundle dir for traceability
|
||||
become: true
|
||||
ansible.builtin.copy:
|
||||
dest: "{{ snapshot_dir }}/{{ snapshot_name }}.manifest"
|
||||
mode: "0640"
|
||||
content: |
|
||||
snapshot_name: {{ snapshot_name }}
|
||||
created_at: {{ ansible_facts['date_time']['iso8601'] }}
|
||||
hostname: {{ ansible_facts['hostname'] }}
|
||||
paths:
|
||||
{% for p in snapshot_present_paths %} - {{ p }}
|
||||
{% endfor %}
|
||||
services_quiesced:
|
||||
{% for s in snapshot_services_stop_order %} - {{ s }}
|
||||
{% endfor %}
|
||||
|
||||
- name: snapshot — restart services in reverse order
|
||||
become: true
|
||||
ansible.builtin.systemd:
|
||||
name: "{{ item }}"
|
||||
state: started
|
||||
loop: "{{ snapshot_services_stop_order | reverse | list }}"
|
||||
failed_when: false
|
||||
|
||||
- name: snapshot — wait for AWS gateway readiness
|
||||
ansible.builtin.command: "{{ spinifex_aws_cli }} ec2 describe-regions"
|
||||
register: snapshot_gw_ready
|
||||
retries: 30
|
||||
delay: 2
|
||||
until: snapshot_gw_ready.rc == 0
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: snapshot — bundle size
|
||||
become: true
|
||||
ansible.builtin.stat:
|
||||
path: "{{ snapshot_bundle_path }}"
|
||||
register: snapshot_bundle_stat
|
||||
|
||||
- name: snapshot — summary
|
||||
ansible.builtin.debug:
|
||||
msg: |
|
||||
Snapshot complete:
|
||||
name : {{ snapshot_name }}
|
||||
path : {{ snapshot_bundle_path }}
|
||||
size : {{ (snapshot_bundle_stat.stat.size | int / 1024 / 1024) | round(1) }} MiB
|
||||
paths : {{ snapshot_present_paths | join(', ') }}
|
||||
gateway : {{ 'ready' if snapshot_gw_ready.rc == 0 else 'NOT ready after restart — investigate' }}
|
||||
|
||||
Restore with: ansible-playbook playbooks/dev-restore.yml -e snapshot_name={{ snapshot_name }}
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
galaxy_info:
|
||||
role_name: status
|
||||
description: Read-only health snapshot for a Spinifex dev node (doctor).
|
||||
license: BSD-3-Clause
|
||||
min_ansible_version: "2.14"
|
||||
dependencies: []
|
||||
@@ -1,259 +0,0 @@
|
||||
---
|
||||
# Read-only health snapshot. NEVER mutates state. Every probe must use
|
||||
# `changed_when: false` and `failed_when: false` so the role always reaches
|
||||
# the summary task even when the node is half-broken — surfacing brokenness
|
||||
# is the whole point.
|
||||
|
||||
- name: status — spinifex.target activation
|
||||
ansible.builtin.command: systemctl is-active spinifex.target
|
||||
register: status_target
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: status — list spinifex-* units (active + failed)
|
||||
ansible.builtin.shell: |
|
||||
systemctl list-units 'spinifex-*' --all --no-legend --plain \
|
||||
--output=json 2>/dev/null || \
|
||||
systemctl list-units 'spinifex-*' --all --no-legend --plain
|
||||
register: status_units
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: status — failed spinifex-* units (one line per)
|
||||
ansible.builtin.shell: |
|
||||
systemctl list-units 'spinifex-*' --state=failed --no-legend --plain \
|
||||
| awk '{print $1}'
|
||||
register: status_failed_units
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: status — port listeners (which process holds each spinifex port)
|
||||
ansible.builtin.shell: |
|
||||
ss -tlnpH 'sport = :{{ item.port }}' | head -1
|
||||
register: status_ports
|
||||
loop: "{{ spinifex_ports }}"
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
loop_control:
|
||||
label: "{{ item.service }} ({{ item.port }})"
|
||||
|
||||
- name: status — OVS tooling present?
|
||||
ansible.builtin.command: which ovs-vsctl
|
||||
register: status_ovs_which
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: status — OVS bridges
|
||||
become: true
|
||||
ansible.builtin.command: ovs-vsctl list-br
|
||||
register: status_ovs_brs
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
when: status_ovs_which.rc == 0
|
||||
|
||||
- name: status — br-int exists?
|
||||
become: true
|
||||
ansible.builtin.command: ovs-vsctl br-exists br-int
|
||||
register: status_brint
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
when: status_ovs_which.rc == 0
|
||||
|
||||
- name: status — ovn-controller active?
|
||||
ansible.builtin.command: systemctl is-active ovn-controller
|
||||
register: status_ovn_ctrl
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: status — ovn-central active?
|
||||
ansible.builtin.command: systemctl is-active ovn-central
|
||||
register: status_ovn_central
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: status — OVN NB/SB DB files present?
|
||||
become: true
|
||||
ansible.builtin.stat:
|
||||
path: "{{ item }}"
|
||||
register: status_ovn_dbs
|
||||
loop:
|
||||
- "{{ ovn_nb_db }}"
|
||||
- "{{ ovn_sb_db }}"
|
||||
loop_control:
|
||||
label: "{{ item }}"
|
||||
|
||||
- name: status — spinifex.toml present?
|
||||
become: true
|
||||
ansible.builtin.stat:
|
||||
path: "{{ spinifex_config_file }}"
|
||||
register: status_config
|
||||
|
||||
- name: status — CA trust present?
|
||||
become: true
|
||||
ansible.builtin.stat:
|
||||
path: "{{ spinifex_ca_trust_path }}"
|
||||
register: status_ca
|
||||
|
||||
- name: status — AWS gateway round-trip (describe-regions)
|
||||
ansible.builtin.command: "{{ spinifex_aws_cli }} ec2 describe-regions --output text"
|
||||
register: status_awsgw
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: status — installed spx binary
|
||||
become: true
|
||||
ansible.builtin.stat:
|
||||
path: /usr/local/bin/spx
|
||||
checksum_algorithm: sha256
|
||||
register: status_spx_installed
|
||||
|
||||
- name: status — built spx binary
|
||||
ansible.builtin.stat:
|
||||
path: "{{ spinifex_root }}/bin/spx"
|
||||
checksum_algorithm: sha256
|
||||
register: status_spx_built
|
||||
|
||||
- name: status — spx version (installed)
|
||||
ansible.builtin.shell: |
|
||||
/usr/local/bin/spx version 2>/dev/null | grep -vE '^[0-9]{4}/' | head -1
|
||||
register: status_spx_version
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
when: status_spx_installed.stat.exists
|
||||
|
||||
- name: status — installed nbdkit plugin
|
||||
become: true
|
||||
ansible.builtin.shell: |
|
||||
set -e
|
||||
pdir=$(nbdkit --dump-config | awk -F= '/^plugindir=/{print $2; exit}')
|
||||
[ -f "$pdir/nbdkit-viperblock-plugin.so" ] && \
|
||||
sha256sum "$pdir/nbdkit-viperblock-plugin.so" | awk '{print $1}'
|
||||
register: status_plugin_installed_sha
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: status — built nbdkit plugin
|
||||
ansible.builtin.stat:
|
||||
path: "{{ mulga_root }}/viperblock/lib/nbdkit-viperblock-plugin.so"
|
||||
checksum_algorithm: sha256
|
||||
register: status_plugin_built
|
||||
|
||||
- name: status — microvm artifacts (built)
|
||||
ansible.builtin.stat:
|
||||
path: "{{ spinifex_root }}/build/microvm/{{ item }}"
|
||||
checksum_algorithm: sha256
|
||||
register: status_microvm_built
|
||||
loop:
|
||||
- vmlinuz
|
||||
- initramfs.cpio.gz
|
||||
|
||||
- name: status — microvm artifacts (installed)
|
||||
become: true
|
||||
ansible.builtin.stat:
|
||||
path: /usr/share/spinifex/microvm/{{ item }}
|
||||
checksum_algorithm: sha256
|
||||
register: status_microvm_installed
|
||||
loop:
|
||||
- vmlinuz
|
||||
- initramfs.cpio.gz
|
||||
|
||||
- name: status — running QEMU VMs
|
||||
ansible.builtin.shell: pgrep -fa 'qemu-system-' | wc -l
|
||||
register: status_qemu_count
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: status — count AMIs
|
||||
ansible.builtin.command: "{{ spinifex_aws_cli }} ec2 describe-images --query length(Images) --output text"
|
||||
register: status_ami_count
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: status — count keypairs
|
||||
ansible.builtin.command: "{{ spinifex_aws_cli }} ec2 describe-key-pairs --query length(KeyPairs) --output text"
|
||||
register: status_key_count
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: status — count volumes
|
||||
ansible.builtin.command: "{{ spinifex_aws_cli }} ec2 describe-volumes --query length(Volumes) --output text"
|
||||
register: status_vol_count
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: status — count running instances
|
||||
ansible.builtin.command: >-
|
||||
{{ spinifex_aws_cli }} ec2 describe-instances
|
||||
--filters Name=instance-state-name,Values=running
|
||||
--query length(Reservations[].Instances[]) --output text
|
||||
register: status_instance_count
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: status — compute drift facts
|
||||
ansible.builtin.set_fact:
|
||||
status_spx_drift: >-
|
||||
{{ status_spx_installed.stat.exists and status_spx_built.stat.exists and
|
||||
(status_spx_installed.stat.checksum | default('')) !=
|
||||
(status_spx_built.stat.checksum | default('')) }}
|
||||
status_plugin_drift: >-
|
||||
{{ status_plugin_built.stat.exists and
|
||||
(status_plugin_installed_sha.stdout | default('') | trim) !=
|
||||
(status_plugin_built.stat.checksum | default('')) }}
|
||||
status_microvm_built_present: "{{ status_microvm_built.results
|
||||
| map(attribute='stat.exists') | min }}"
|
||||
|
||||
- name: status — render port lines
|
||||
ansible.builtin.set_fact:
|
||||
status_port_lines: >-
|
||||
{{ status_port_lines | default([]) +
|
||||
[('%-24s' | format(item.item.service ~ ' (' ~ item.item.port ~ ')'))
|
||||
~ ': '
|
||||
~ ('listening' if (item.stdout | length) > 0 else 'free')] }}
|
||||
loop: "{{ status_ports.results }}"
|
||||
loop_control:
|
||||
label: "{{ item.item.service }}"
|
||||
|
||||
- name: status — assemble summary
|
||||
ansible.builtin.set_fact:
|
||||
status_summary_lines: >-
|
||||
{{
|
||||
[
|
||||
'── Spinifex dev-node status ──────────────────────────────────────────────',
|
||||
'target : ' ~ (status_target.stdout | trim),
|
||||
'failed units : ' ~ (status_failed_units.stdout_lines | length)
|
||||
~ ((' → ' ~ (status_failed_units.stdout_lines | join(', '))) if (status_failed_units.stdout_lines | length) > 0 else ''),
|
||||
'── Ports ──'
|
||||
]
|
||||
+ status_port_lines
|
||||
+ [
|
||||
'── Networking ──',
|
||||
'ovs-vsctl : ' ~ ('present' if status_ovs_which.rc == 0 else 'MISSING'),
|
||||
'ovs bridges : ' ~ ((status_ovs_brs.stdout_lines | default([]) | join(', ')) or '(none)'),
|
||||
'br-int : ' ~ ('present' if (status_brint.rc | default(1)) == 0 else 'MISSING'),
|
||||
'ovn-controller : ' ~ (status_ovn_ctrl.stdout | trim),
|
||||
'ovn-central : ' ~ (status_ovn_central.stdout | trim),
|
||||
'ovn NB DB : ' ~ ('present' if status_ovn_dbs.results[0].stat.exists else 'absent'),
|
||||
'ovn SB DB : ' ~ ('present' if status_ovn_dbs.results[1].stat.exists else 'absent'),
|
||||
'── Config / trust ──',
|
||||
'spinifex.toml : ' ~ ('present' if status_config.stat.exists else 'MISSING') ~ ' (' ~ spinifex_config_file ~ ')',
|
||||
'CA trust : ' ~ ('present' if status_ca.stat.exists else 'MISSING') ~ ' (' ~ spinifex_ca_trust_path ~ ')',
|
||||
'── AWS gateway ──',
|
||||
'describe-regions : ' ~ ('OK' if status_awsgw.rc == 0 else 'FAIL (rc=' ~ status_awsgw.rc ~ ')'),
|
||||
'AMIs : ' ~ (status_ami_count.stdout | default('?') | trim),
|
||||
'keypairs : ' ~ (status_key_count.stdout | default('?') | trim),
|
||||
'volumes : ' ~ (status_vol_count.stdout | default('?') | trim),
|
||||
'running instances: ' ~ (status_instance_count.stdout | default('?') | trim),
|
||||
'running QEMU : ' ~ (status_qemu_count.stdout | default('?') | trim),
|
||||
'── Versions / drift ──',
|
||||
'spx (installed) : ' ~ ((status_spx_version.stdout | default('') | trim) or '(not installed)'),
|
||||
'spx drift : ' ~ ('YES (built ≠ installed) — run ansible-dev-deploy' if status_spx_drift else 'no'),
|
||||
'plugin drift : ' ~ ('YES (built ≠ installed) — run ansible-dev-deploy' if status_plugin_drift else 'no'),
|
||||
'microvm built : ' ~ ('present' if status_microvm_built_present else 'not built on this branch'),
|
||||
'──────────────────────────────────────────────────────────────────────────'
|
||||
]
|
||||
}}
|
||||
|
||||
- name: status — summary
|
||||
ansible.builtin.debug:
|
||||
msg: "{{ status_summary_lines }}"
|
||||
@@ -1,54 +0,0 @@
|
||||
# teardown — Leak Catalog
|
||||
|
||||
Every task in this role corresponds to a known state source that survived a
|
||||
previous reset. When the operator reports "reset didn't clean X", add a new
|
||||
task and a new row here citing the symptom.
|
||||
|
||||
## Initial catalog (from reset-dev-env.sh + reset-dev-env.md target)
|
||||
|
||||
| State source | Cleaned by | Symptom if leaked |
|
||||
|---|---|---|
|
||||
| `spinifex.target` active | `systemctl stop` | Services keep running, file locks held |
|
||||
| `spinifex-*` units in failed state | `systemctl reset-failed 'spinifex-*'` | `systemctl status` shows red units, next start refuses |
|
||||
| Spinifex processes (spx, nats, predastore, etc.) | `pkill` pattern + wait loop | Stale listeners on 4222/8443/9999, start fails with "address in use" |
|
||||
| QEMU VMs | `pkill qemu-system-*` + wait loop | Zombie VMs hold tap interfaces and viperblock state |
|
||||
| `/etc/spinifex/` | `file: state=absent` | Stale TOML, stale CA, service users can't read fresh config |
|
||||
| `/var/lib/spinifex/` | `file: state=absent` | Stale WAL, stale volumes, viperblock mounts fail |
|
||||
| `/var/log/spinifex/` | `file: state=absent` | Logrotate confusion, permission drift after user changes |
|
||||
| `/run/spinifex/` | `file: state=absent` | Stale sockets, pidfiles from prior systemd run |
|
||||
| `$HOME/spinifex/` (legacy) | `file: state=absent` | Old dev-mode state shadowing new install |
|
||||
| OVS bridges (all) | `ovs-vsctl del-br` loop | `br-int` populated with stale ports → `ovn-controller` commit loop |
|
||||
| OVS `external_ids` | `ovs-vsctl clear Open_vSwitch .` | Stale chassis entries in SBDB |
|
||||
| `/var/lib/ovn/ovnnb_db.db`, `ovnsb_db.db` | `file: state=absent` | "OVNSB commit failed, force recompute" loop after reset |
|
||||
| `veth-wan-br` / `veth-wan-ovs` | `ip link delete` | Pair left dangling after setup-ovn.sh re-run |
|
||||
| `spx-ext-*` macvlan interfaces | `ip link delete` loop | Stale macvlan on WAN NIC, packet duplication |
|
||||
| Spinifex CA in trust store | remove + `update-ca-certificates` | TLS clients trust old CA, new cert fails verification |
|
||||
|
||||
## Not cleaned by default (opt-in)
|
||||
|
||||
| State source | Flag | Why opt-in |
|
||||
|---|---|---|
|
||||
| `~/.ssh/spinifex-key*` | `-e spinifex_wipe_ssh_keys=true` | Operator may want key to persist across resets |
|
||||
| `~/.aws/credentials` spinifex profile | `-e spinifex_wipe_aws_creds=true` | `spx admin init --force` rewrites this block anyway; wipe only on demand |
|
||||
|
||||
## Known traps
|
||||
|
||||
- **`pgrep -f` self-match.** A literal pattern that matches the pattern
|
||||
string itself causes `pgrep -f` to match our own shell. Mitigation:
|
||||
bracket trick (`[b]in/spx` matches `bin/spx` but not `[b]in/spx`).
|
||||
- **Log followers / ansible wrapper false positives.** `journalctl -f
|
||||
_SYSTEMD_UNIT=spinifex-*` and ansible's own module shell matches the
|
||||
service-name patterns in their cmdlines. Mitigation:
|
||||
`spinifex_process_exclude` filter drops `journalctl|ansible|/bin/sh|pgrep|pkill|tail -[fF]`.
|
||||
- **SIGTERM survivors.** `spx service spinifex start` (and potentially
|
||||
other service-supervisor processes) ignore or delay SIGTERM. Mitigation:
|
||||
kill task escalates SIGTERM → SIGKILL after 5s.
|
||||
|
||||
## Adding a new entry
|
||||
|
||||
1. Add a task to `tasks/main.yml` in the right section (services / state-dirs /
|
||||
ovn / ovs / netlink / trust / user-owned).
|
||||
2. Prefix the task `name:` with the state class (e.g. `netlink:`, `ovn:`).
|
||||
3. Add a row to the catalog above — symptom is what the operator described.
|
||||
4. If the leak class is general (e.g. "any interface matching spx-*"),
|
||||
generalise the task; do not pattern-match on a single instance name.
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
galaxy_info:
|
||||
role_name: teardown
|
||||
description: Wipe Spinifex dev state. Central leak-enumeration surface — every state location cleaned here is a known leak source. See README.md for catalog.
|
||||
license: BSD-3-Clause
|
||||
min_ansible_version: "2.14"
|
||||
dependencies: []
|
||||
@@ -1,242 +0,0 @@
|
||||
---
|
||||
- name: guard — detect node count in spinifex.toml
|
||||
ansible.builtin.shell: |
|
||||
set -e
|
||||
if [ -f {{ spinifex_config_file | quote }} ]; then
|
||||
grep -cE '^\[nodes\.[^.]+\]' {{ spinifex_config_file | quote }} || echo 0
|
||||
else
|
||||
echo 0
|
||||
fi
|
||||
register: teardown_node_count
|
||||
changed_when: false
|
||||
|
||||
- name: guard — refuse to run on multi-node cluster
|
||||
ansible.builtin.assert:
|
||||
that: teardown_node_count.stdout | int <= 1
|
||||
fail_msg: >-
|
||||
Multi-node cluster detected ({{ teardown_node_count.stdout }} nodes in
|
||||
{{ spinifex_config_file }}). Teardown supports single-node only;
|
||||
use 'spx admin cluster shutdown' per node.
|
||||
|
||||
- name: services — stop spinifex.target
|
||||
become: true
|
||||
ansible.builtin.systemd:
|
||||
name: spinifex.target
|
||||
state: stopped
|
||||
failed_when: false
|
||||
|
||||
- name: services — reset failed state (spinifex-* scope only)
|
||||
become: true
|
||||
ansible.builtin.command: systemctl reset-failed 'spinifex-*'
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: processes — kill spinifex processes (SIGTERM, escalate to SIGKILL after 5s)
|
||||
become: true
|
||||
ansible.builtin.shell: |
|
||||
pids=$(pgrep -af "{{ spinifex_process_pattern }}" \
|
||||
| grep -vE "{{ spinifex_process_exclude }}" \
|
||||
| awk '{print $1}')
|
||||
if [ -n "$pids" ]; then
|
||||
kill $pids 2>/dev/null || true
|
||||
sleep 5
|
||||
pids=$(pgrep -af "{{ spinifex_process_pattern }}" \
|
||||
| grep -vE "{{ spinifex_process_exclude }}" \
|
||||
| awk '{print $1}')
|
||||
if [ -n "$pids" ]; then
|
||||
kill -9 $pids 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: processes — wait for exit (up to {{ teardown_process_wait_seconds }}s)
|
||||
become: true
|
||||
ansible.builtin.shell: |
|
||||
for i in $(seq 1 {{ teardown_process_wait_seconds }}); do
|
||||
pids=$(pgrep -af "{{ spinifex_process_pattern }}" \
|
||||
| grep -vE "{{ spinifex_process_exclude }}" \
|
||||
| awk '{print $1}')
|
||||
if [ -z "$pids" ]; then
|
||||
exit 0
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
echo "still running after {{ teardown_process_wait_seconds }}s:" >&2
|
||||
pgrep -af "{{ spinifex_process_pattern }}" \
|
||||
| grep -vE "{{ spinifex_process_exclude }}" >&2 || true
|
||||
exit 1
|
||||
changed_when: false
|
||||
|
||||
- name: qemu — kill lingering VMs
|
||||
become: true
|
||||
ansible.builtin.command: "pkill -x {{ item }}"
|
||||
loop: "{{ spinifex_qemu_processes }}"
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: qemu — wait for exit (up to {{ teardown_process_wait_seconds }}s)
|
||||
become: true
|
||||
ansible.builtin.shell: |
|
||||
set -e
|
||||
for i in $(seq 1 {{ teardown_process_wait_seconds }}); do
|
||||
alive=0
|
||||
for proc in {{ spinifex_qemu_processes | join(' ') }}; do
|
||||
if pgrep -x "$proc" >/dev/null 2>&1; then alive=1; break; fi
|
||||
done
|
||||
if [ "$alive" = "0" ]; then exit 0; fi
|
||||
sleep 1
|
||||
done
|
||||
echo "QEMU still running after {{ teardown_process_wait_seconds }}s" >&2
|
||||
exit 1
|
||||
changed_when: false
|
||||
|
||||
- name: state-dirs — remove production dirs
|
||||
become: true
|
||||
ansible.builtin.file:
|
||||
path: "{{ item }}"
|
||||
state: absent
|
||||
loop:
|
||||
- "{{ spinifex_etc_dir }}"
|
||||
- "{{ spinifex_data_dir }}"
|
||||
- "{{ spinifex_log_dir }}"
|
||||
- "{{ spinifex_run_dir }}"
|
||||
|
||||
- name: state-dirs — remove legacy $HOME/spinifex (opt-in)
|
||||
become: true
|
||||
become_user: "{{ spinifex_invoking_user }}"
|
||||
ansible.builtin.file:
|
||||
path: "{{ spinifex_legacy_home_dir }}"
|
||||
state: absent
|
||||
when: spinifex_wipe_legacy_home | bool
|
||||
|
||||
- name: ovs — detect tooling
|
||||
ansible.builtin.command: which ovs-vsctl
|
||||
register: teardown_ovs_which
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: ovs — ensure openvswitch-switch is running for cleanup
|
||||
become: true
|
||||
ansible.builtin.systemd:
|
||||
name: openvswitch-switch
|
||||
state: started
|
||||
when: teardown_ovs_which.rc == 0
|
||||
failed_when: false
|
||||
|
||||
- name: ovs — list bridges
|
||||
become: true
|
||||
ansible.builtin.command: ovs-vsctl list-br
|
||||
register: teardown_ovs_brs
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
when: teardown_ovs_which.rc == 0
|
||||
|
||||
- name: ovs — delete all bridges
|
||||
become: true
|
||||
ansible.builtin.command: "ovs-vsctl --if-exists del-br {{ item }}"
|
||||
loop: "{{ teardown_ovs_brs.stdout_lines | default([]) }}"
|
||||
changed_when: true
|
||||
when: teardown_ovs_which.rc == 0
|
||||
|
||||
- name: ovs — clear external_ids
|
||||
become: true
|
||||
ansible.builtin.command: ovs-vsctl --if-exists clear Open_vSwitch . external_ids
|
||||
changed_when: true
|
||||
failed_when: false
|
||||
when: teardown_ovs_which.rc == 0
|
||||
|
||||
- name: ovs — stop openvswitch-switch after cleanup
|
||||
become: true
|
||||
ansible.builtin.systemd:
|
||||
name: openvswitch-switch
|
||||
state: stopped
|
||||
when: teardown_ovs_which.rc == 0
|
||||
failed_when: false
|
||||
|
||||
- name: ovn — stop ovn-central
|
||||
become: true
|
||||
ansible.builtin.systemd:
|
||||
name: ovn-central
|
||||
state: stopped
|
||||
failed_when: false
|
||||
|
||||
- name: ovn — stop ovn-controller
|
||||
become: true
|
||||
ansible.builtin.systemd:
|
||||
name: ovn-controller
|
||||
state: stopped
|
||||
failed_when: false
|
||||
|
||||
- name: ovn — delete NB/SB DB files
|
||||
become: true
|
||||
ansible.builtin.file:
|
||||
path: "{{ item }}"
|
||||
state: absent
|
||||
loop:
|
||||
- "{{ ovn_nb_db }}"
|
||||
- "{{ ovn_sb_db }}"
|
||||
|
||||
- name: netlink — enumerate spinifex-managed veth/tap
|
||||
ansible.builtin.shell: |
|
||||
ip -o link show | awk -F': ' '{print $2}' | cut -d'@' -f1 | \
|
||||
grep -E '^(veth-wan-br|veth-wan-ovs|spx-ext-|spx-tap-|spx-)' || true
|
||||
register: teardown_links
|
||||
changed_when: false
|
||||
|
||||
- name: netlink — delete enumerated interfaces
|
||||
become: true
|
||||
ansible.builtin.command: "ip link delete {{ item }}"
|
||||
loop: "{{ teardown_links.stdout_lines }}"
|
||||
changed_when: true
|
||||
failed_when: false
|
||||
|
||||
- name: trust — remove spinifex CA from system store
|
||||
become: true
|
||||
ansible.builtin.file:
|
||||
path: "{{ spinifex_ca_trust_path }}"
|
||||
state: absent
|
||||
register: teardown_ca_removed
|
||||
|
||||
- name: trust — rebuild CA bundle (without --fresh, preserve other CAs)
|
||||
become: true
|
||||
ansible.builtin.command: update-ca-certificates
|
||||
changed_when: true
|
||||
when: teardown_ca_removed.changed
|
||||
|
||||
- name: user-owned — remove spinifex profile from AWS credentials (opt-in)
|
||||
become: true
|
||||
become_user: "{{ spinifex_invoking_user }}"
|
||||
ansible.builtin.blockinfile:
|
||||
path: "~/.aws/credentials"
|
||||
marker: "# {mark} SPINIFEX MANAGED BLOCK"
|
||||
block: ""
|
||||
state: absent
|
||||
when: spinifex_wipe_aws_creds | bool
|
||||
failed_when: false
|
||||
|
||||
- name: user-owned — remove spinifex SSH key (opt-in)
|
||||
become: true
|
||||
become_user: "{{ spinifex_invoking_user }}"
|
||||
ansible.builtin.file:
|
||||
path: "~/.ssh/{{ item }}"
|
||||
state: absent
|
||||
loop:
|
||||
- spinifex-key
|
||||
- spinifex-key.pub
|
||||
when: spinifex_wipe_ssh_keys | bool
|
||||
|
||||
- name: summary
|
||||
ansible.builtin.debug:
|
||||
msg: |
|
||||
Teardown complete. Catalog: roles/teardown/README.md
|
||||
systemd : spinifex.target stopped, spinifex-* failed cleared
|
||||
procs : {{ spinifex_process_pattern }}
|
||||
qemu : {{ spinifex_qemu_processes | join(', ') }}
|
||||
dirs : {{ spinifex_etc_dir }}, {{ spinifex_data_dir }}, {{ spinifex_log_dir }}, {{ spinifex_run_dir }}{{ ', ' ~ spinifex_legacy_home_dir if spinifex_wipe_legacy_home else '' }}
|
||||
ovs : all bridges deleted, external_ids cleared
|
||||
ovn : NB/SB DB files removed
|
||||
netlink : {{ teardown_links.stdout_lines | length }} interface(s) removed
|
||||
trust : {{ spinifex_ca_trust_path }} removed{% if spinifex_wipe_aws_creds %}
|
||||
aws : credentials block removed{% endif %}{% if spinifex_wipe_ssh_keys %}
|
||||
ssh : spinifex-key pair removed{% endif %}
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
galaxy_info:
|
||||
role_name: version
|
||||
description: Detailed build-vs-install drift report for spinifex artifacts.
|
||||
license: BSD-3-Clause
|
||||
min_ansible_version: "2.14"
|
||||
dependencies: []
|
||||
@@ -1,161 +0,0 @@
|
||||
---
|
||||
# Detailed build-vs-install drift report for the four artifacts that
|
||||
# `ansible-dev-deploy` swaps: spx, the nbdkit viperblock plugin, and the
|
||||
# two microvm artifacts (kernel + initramfs). Pure read-only; faster
|
||||
# than dev-status (no AWS / OVN / port probing).
|
||||
|
||||
- name: version — built spx
|
||||
ansible.builtin.stat:
|
||||
path: "{{ spinifex_root }}/bin/spx"
|
||||
checksum_algorithm: sha256
|
||||
register: ver_spx_built
|
||||
|
||||
- name: version — installed spx
|
||||
become: true
|
||||
ansible.builtin.stat:
|
||||
path: /usr/local/bin/spx
|
||||
checksum_algorithm: sha256
|
||||
register: ver_spx_installed
|
||||
|
||||
- name: version — installed spx version string
|
||||
ansible.builtin.shell: |
|
||||
/usr/local/bin/spx version 2>/dev/null | grep -vE '^[0-9]{4}/' | head -1
|
||||
register: ver_spx_version
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
when: ver_spx_installed.stat.exists
|
||||
|
||||
- name: version — built plugin
|
||||
ansible.builtin.stat:
|
||||
path: "{{ mulga_root }}/viperblock/lib/nbdkit-viperblock-plugin.so"
|
||||
checksum_algorithm: sha256
|
||||
register: ver_plugin_built
|
||||
|
||||
- name: version — nbdkit plugin dir
|
||||
ansible.builtin.shell: |
|
||||
nbdkit --dump-config 2>/dev/null | awk -F= '/^plugindir=/{print $2; exit}'
|
||||
register: ver_plugindir
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: version — installed plugin
|
||||
become: true
|
||||
ansible.builtin.stat:
|
||||
path: "{{ ver_plugindir.stdout | trim }}/nbdkit-viperblock-plugin.so"
|
||||
checksum_algorithm: sha256
|
||||
register: ver_plugin_installed
|
||||
when: ver_plugindir.stdout | trim | length > 0
|
||||
|
||||
- name: version — built microvm artifacts
|
||||
ansible.builtin.stat:
|
||||
path: "{{ spinifex_root }}/build/microvm/{{ item }}"
|
||||
checksum_algorithm: sha256
|
||||
register: ver_microvm_built
|
||||
loop:
|
||||
- vmlinuz
|
||||
- initramfs.cpio.gz
|
||||
|
||||
- name: version — installed microvm artifacts
|
||||
become: true
|
||||
ansible.builtin.stat:
|
||||
path: /usr/share/spinifex/microvm/{{ item }}
|
||||
checksum_algorithm: sha256
|
||||
register: ver_microvm_installed
|
||||
loop:
|
||||
- vmlinuz
|
||||
- initramfs.cpio.gz
|
||||
|
||||
- name: version — sub-repo HEAD short-rev (spinifex)
|
||||
ansible.builtin.command: git -C {{ spinifex_root | quote }} rev-parse --short HEAD
|
||||
register: ver_spinifex_head
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: version — sub-repo HEAD short-rev (viperblock)
|
||||
ansible.builtin.command: git -C {{ mulga_root }}/viperblock rev-parse --short HEAD
|
||||
register: ver_viperblock_head
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: version — sub-repo HEAD short-rev (predastore)
|
||||
ansible.builtin.command: git -C {{ mulga_root }}/predastore rev-parse --short HEAD
|
||||
register: ver_predastore_head
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: version — assemble per-artifact rows
|
||||
ansible.builtin.set_fact:
|
||||
ver_artifact_rows: >-
|
||||
{{
|
||||
[
|
||||
('%-18s built=%s installed=%s drift=%s' | format(
|
||||
'spx',
|
||||
(ver_spx_built.stat.checksum | default('-') | truncate(13, true, ''))
|
||||
if ver_spx_built.stat.exists else 'MISSING',
|
||||
(ver_spx_installed.stat.checksum | default('-') | truncate(13, true, ''))
|
||||
if ver_spx_installed.stat.exists else 'MISSING',
|
||||
('YES' if ver_spx_built.stat.exists and ver_spx_installed.stat.exists
|
||||
and ver_spx_built.stat.checksum != ver_spx_installed.stat.checksum
|
||||
else 'no')
|
||||
)),
|
||||
('%-18s built=%s installed=%s drift=%s' | format(
|
||||
'plugin (nbdkit)',
|
||||
(ver_plugin_built.stat.checksum | default('-') | truncate(13, true, ''))
|
||||
if ver_plugin_built.stat.exists else 'MISSING',
|
||||
(ver_plugin_installed.stat.checksum | default('-') | truncate(13, true, ''))
|
||||
if (ver_plugin_installed.stat | default({})).get('exists', false) else 'MISSING',
|
||||
('YES' if ver_plugin_built.stat.exists
|
||||
and (ver_plugin_installed.stat | default({})).get('exists', false)
|
||||
and ver_plugin_built.stat.checksum != ver_plugin_installed.stat.checksum
|
||||
else 'no')
|
||||
)),
|
||||
('%-18s built=%s installed=%s drift=%s' | format(
|
||||
'microvm vmlinuz',
|
||||
(ver_microvm_built.results[0].stat.checksum | default('-') | truncate(13, true, ''))
|
||||
if ver_microvm_built.results[0].stat.exists else 'MISSING',
|
||||
(ver_microvm_installed.results[0].stat.checksum | default('-') | truncate(13, true, ''))
|
||||
if ver_microvm_installed.results[0].stat.exists else 'MISSING',
|
||||
('YES' if ver_microvm_built.results[0].stat.exists
|
||||
and ver_microvm_installed.results[0].stat.exists
|
||||
and ver_microvm_built.results[0].stat.checksum
|
||||
!= ver_microvm_installed.results[0].stat.checksum
|
||||
else 'no')
|
||||
)),
|
||||
('%-18s built=%s installed=%s drift=%s' | format(
|
||||
'microvm initramfs',
|
||||
(ver_microvm_built.results[1].stat.checksum | default('-') | truncate(13, true, ''))
|
||||
if ver_microvm_built.results[1].stat.exists else 'MISSING',
|
||||
(ver_microvm_installed.results[1].stat.checksum | default('-') | truncate(13, true, ''))
|
||||
if ver_microvm_installed.results[1].stat.exists else 'MISSING',
|
||||
('YES' if ver_microvm_built.results[1].stat.exists
|
||||
and ver_microvm_installed.results[1].stat.exists
|
||||
and ver_microvm_built.results[1].stat.checksum
|
||||
!= ver_microvm_installed.results[1].stat.checksum
|
||||
else 'no')
|
||||
))
|
||||
]
|
||||
}}
|
||||
|
||||
- name: version — compute any-drift flag
|
||||
ansible.builtin.set_fact:
|
||||
ver_any_drift: "{{ 'YES' in (ver_artifact_rows | join(' ')) }}"
|
||||
|
||||
- name: version — summary
|
||||
ansible.builtin.debug:
|
||||
msg: >-
|
||||
{{
|
||||
[
|
||||
'── Spinifex build vs install ─────────────────────────────────────────────',
|
||||
'spx (installed): ' ~ ((ver_spx_version.stdout | default('') | trim) or '(not installed)')
|
||||
]
|
||||
+ ver_artifact_rows
|
||||
+ [
|
||||
'── Sub-repo HEADs ──',
|
||||
'spinifex : ' ~ ((ver_spinifex_head.stdout | default('') | trim) or '(not a git repo)'),
|
||||
'viperblock : ' ~ ((ver_viperblock_head.stdout | default('') | trim) or '(missing)'),
|
||||
'predastore : ' ~ ((ver_predastore_head.stdout | default('') | trim) or '(missing)'),
|
||||
'──────────────────────────────────────────────────────────────────────────',
|
||||
('DRIFT detected — run: make ansible-dev-deploy' if ver_any_drift
|
||||
else 'No drift; built = installed for all artifacts.')
|
||||
]
|
||||
}}
|
||||
@@ -1,7 +0,0 @@
|
||||
---
|
||||
galaxy_info:
|
||||
role_name: vpc
|
||||
description: Idempotent dev-mode VPC + public subnet + IGW + default route fixture.
|
||||
license: BSD-3-Clause
|
||||
min_ansible_version: "2.14"
|
||||
dependencies: []
|
||||
@@ -1,128 +0,0 @@
|
||||
---
|
||||
# Tear down the dev VPC fixture. Deletes in reverse dependency order:
|
||||
# route-table associations, route table, subnet, IGW (detach + delete),
|
||||
# VPC. Each step is best-effort so a partially-converged fixture still
|
||||
# converges to "gone" on re-run.
|
||||
|
||||
- name: vpc — find VPC by Name tag
|
||||
ansible.builtin.command: >-
|
||||
{{ spinifex_aws_cli }} ec2 describe-vpcs
|
||||
--filters Name=tag:Name,Values={{ dev_vpc_name }}
|
||||
--query Vpcs[0].VpcId --output text
|
||||
register: absent_vpc
|
||||
changed_when: false
|
||||
|
||||
- name: vpc — nothing to do
|
||||
ansible.builtin.debug:
|
||||
msg: "VPC tagged Name={{ dev_vpc_name }} not found; nothing to delete."
|
||||
when: absent_vpc.stdout | trim in ['', 'None']
|
||||
|
||||
- name: vpc — short-circuit when no VPC
|
||||
ansible.builtin.meta: end_play
|
||||
when: absent_vpc.stdout | trim in ['', 'None']
|
||||
|
||||
- name: vpc — set VPC id fact
|
||||
ansible.builtin.set_fact:
|
||||
absent_vpc_id: "{{ absent_vpc.stdout | trim }}"
|
||||
|
||||
- name: vpc — find subnet
|
||||
ansible.builtin.command: >-
|
||||
{{ spinifex_aws_cli }} ec2 describe-subnets
|
||||
--filters Name=vpc-id,Values={{ absent_vpc_id }}
|
||||
Name=tag:Name,Values={{ dev_vpc_name }}-public
|
||||
--query Subnets[0].SubnetId --output text
|
||||
register: absent_subnet
|
||||
changed_when: false
|
||||
|
||||
# Enumerate ALL non-main route tables in the VPC (main RT can't be
|
||||
# deleted; it goes when the VPC does). Catches orphans from earlier
|
||||
# runs that landed before the create-tags workaround.
|
||||
- name: vpc — find all non-main route tables in VPC
|
||||
ansible.builtin.command: >-
|
||||
{{ spinifex_aws_cli }} ec2 describe-route-tables
|
||||
--filters Name=vpc-id,Values={{ absent_vpc_id }}
|
||||
--query RouteTables[?!(Associations[?Main==`true`])].RouteTableId
|
||||
--output text
|
||||
register: absent_rt_list
|
||||
changed_when: false
|
||||
|
||||
- name: vpc — split RT id list
|
||||
ansible.builtin.set_fact:
|
||||
absent_rt_ids: "{{ absent_rt_list.stdout.split() | reject('equalto', '') | list }}"
|
||||
|
||||
- name: vpc — find all subnet/RT associations to disassociate
|
||||
ansible.builtin.command: >-
|
||||
{{ spinifex_aws_cli }} ec2 describe-route-tables
|
||||
--route-table-ids {{ item }}
|
||||
--query RouteTables[0].Associations[?SubnetId!=null].RouteTableAssociationId
|
||||
--output text
|
||||
register: absent_assocs
|
||||
loop: "{{ absent_rt_ids }}"
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: vpc — disassociate subnets from route tables
|
||||
ansible.builtin.command: >-
|
||||
{{ spinifex_aws_cli }} ec2 disassociate-route-table
|
||||
--association-id {{ item }}
|
||||
loop: "{{ absent_assocs.results
|
||||
| map(attribute='stdout')
|
||||
| map('default', '')
|
||||
| map('split')
|
||||
| flatten
|
||||
| reject('equalto', '')
|
||||
| list }}"
|
||||
changed_when: true
|
||||
failed_when: false
|
||||
|
||||
- name: vpc — delete all non-main route tables
|
||||
ansible.builtin.command: >-
|
||||
{{ spinifex_aws_cli }} ec2 delete-route-table
|
||||
--route-table-id {{ item }}
|
||||
loop: "{{ absent_rt_ids }}"
|
||||
changed_when: true
|
||||
failed_when: false
|
||||
|
||||
- name: vpc — delete subnet
|
||||
ansible.builtin.command: >-
|
||||
{{ spinifex_aws_cli }} ec2 delete-subnet
|
||||
--subnet-id {{ absent_subnet.stdout | trim }}
|
||||
when: absent_subnet.stdout | trim not in ['', 'None']
|
||||
changed_when: true
|
||||
failed_when: false
|
||||
|
||||
- name: vpc — find IGW
|
||||
ansible.builtin.command: >-
|
||||
{{ spinifex_aws_cli }} ec2 describe-internet-gateways
|
||||
--filters Name=attachment.vpc-id,Values={{ absent_vpc_id }}
|
||||
--query InternetGateways[0].InternetGatewayId --output text
|
||||
register: absent_igw
|
||||
changed_when: false
|
||||
|
||||
- name: vpc — detach IGW
|
||||
ansible.builtin.command: >-
|
||||
{{ spinifex_aws_cli }} ec2 detach-internet-gateway
|
||||
--internet-gateway-id {{ absent_igw.stdout | trim }}
|
||||
--vpc-id {{ absent_vpc_id }}
|
||||
when: absent_igw.stdout | trim not in ['', 'None']
|
||||
changed_when: true
|
||||
failed_when: false
|
||||
|
||||
- name: vpc — delete IGW
|
||||
ansible.builtin.command: >-
|
||||
{{ spinifex_aws_cli }} ec2 delete-internet-gateway
|
||||
--internet-gateway-id {{ absent_igw.stdout | trim }}
|
||||
when: absent_igw.stdout | trim not in ['', 'None']
|
||||
changed_when: true
|
||||
failed_when: false
|
||||
|
||||
- name: vpc — delete VPC
|
||||
ansible.builtin.command: >-
|
||||
{{ spinifex_aws_cli }} ec2 delete-vpc
|
||||
--vpc-id {{ absent_vpc_id }}
|
||||
changed_when: true
|
||||
failed_when: false
|
||||
|
||||
- name: vpc — summary (absent)
|
||||
ansible.builtin.debug:
|
||||
msg: "Dev VPC {{ dev_vpc_name }} (was {{ absent_vpc_id }}) torn down."
|
||||
@@ -1,3 +0,0 @@
|
||||
---
|
||||
- name: vpc — dispatch by state
|
||||
ansible.builtin.include_tasks: "{{ dev_vpc_state }}.yml"
|
||||
@@ -1,209 +0,0 @@
|
||||
---
|
||||
# Idempotent create. Every step describes-then-creates: re-running on an
|
||||
# already-converged VPC produces zero changes. Tag everything with
|
||||
# Name={{ dev_vpc_name }} so a single tag query finds the whole graph.
|
||||
|
||||
- name: vpc — find existing VPC by Name tag
|
||||
ansible.builtin.command: >-
|
||||
{{ spinifex_aws_cli }} ec2 describe-vpcs
|
||||
--filters Name=tag:Name,Values={{ dev_vpc_name }}
|
||||
--query Vpcs[0].VpcId --output text
|
||||
register: vpc_existing
|
||||
changed_when: false
|
||||
|
||||
- name: vpc — create VPC if missing
|
||||
ansible.builtin.command: >-
|
||||
{{ spinifex_aws_cli }} ec2 create-vpc
|
||||
--cidr-block {{ dev_vpc_cidr }}
|
||||
--tag-specifications
|
||||
ResourceType=vpc,Tags=[{Key=Name,Value={{ dev_vpc_name }}}]
|
||||
--query Vpc.VpcId --output text
|
||||
register: vpc_created
|
||||
when: vpc_existing.stdout | trim in ['', 'None']
|
||||
changed_when: true
|
||||
|
||||
- name: vpc — set VPC id fact
|
||||
ansible.builtin.set_fact:
|
||||
dev_vpc_id: >-
|
||||
{{ vpc_created.stdout | default('') | trim
|
||||
if (vpc_existing.stdout | trim in ['', 'None'])
|
||||
else vpc_existing.stdout | trim }}
|
||||
|
||||
- name: vpc — assert VPC id resolved
|
||||
ansible.builtin.assert:
|
||||
that: dev_vpc_id | length > 0
|
||||
fail_msg: "VPC id could not be resolved after describe/create"
|
||||
|
||||
- name: vpc — find existing IGW attached to this VPC
|
||||
ansible.builtin.command: >-
|
||||
{{ spinifex_aws_cli }} ec2 describe-internet-gateways
|
||||
--filters Name=attachment.vpc-id,Values={{ dev_vpc_id }}
|
||||
--query InternetGateways[0].InternetGatewayId --output text
|
||||
register: vpc_igw_existing
|
||||
changed_when: false
|
||||
|
||||
- name: vpc — create IGW if missing
|
||||
ansible.builtin.command: >-
|
||||
{{ spinifex_aws_cli }} ec2 create-internet-gateway
|
||||
--tag-specifications
|
||||
ResourceType=internet-gateway,Tags=[{Key=Name,Value={{ dev_vpc_name }}-igw}]
|
||||
--query InternetGateway.InternetGatewayId --output text
|
||||
register: vpc_igw_created
|
||||
when: vpc_igw_existing.stdout | trim in ['', 'None']
|
||||
changed_when: true
|
||||
|
||||
- name: vpc — set IGW id fact
|
||||
ansible.builtin.set_fact:
|
||||
dev_vpc_igw_id: >-
|
||||
{{ vpc_igw_created.stdout | default('') | trim
|
||||
if (vpc_igw_existing.stdout | trim in ['', 'None'])
|
||||
else vpc_igw_existing.stdout | trim }}
|
||||
|
||||
- name: vpc — attach IGW to VPC if needed
|
||||
ansible.builtin.command: >-
|
||||
{{ spinifex_aws_cli }} ec2 attach-internet-gateway
|
||||
--internet-gateway-id {{ dev_vpc_igw_id }}
|
||||
--vpc-id {{ dev_vpc_id }}
|
||||
register: vpc_igw_attach
|
||||
when: vpc_igw_existing.stdout | trim in ['', 'None']
|
||||
changed_when: true
|
||||
failed_when:
|
||||
- vpc_igw_attach.rc | default(0) != 0
|
||||
- "'Resource.AlreadyAssociated' not in (vpc_igw_attach.stderr | default(''))"
|
||||
|
||||
- name: vpc — find existing subnet by Name tag
|
||||
ansible.builtin.command: >-
|
||||
{{ spinifex_aws_cli }} ec2 describe-subnets
|
||||
--filters Name=vpc-id,Values={{ dev_vpc_id }}
|
||||
Name=tag:Name,Values={{ dev_vpc_name }}-public
|
||||
--query Subnets[0].SubnetId --output text
|
||||
register: vpc_subnet_existing
|
||||
changed_when: false
|
||||
|
||||
- name: vpc — create subnet if missing
|
||||
ansible.builtin.command: >-
|
||||
{{ spinifex_aws_cli }} ec2 create-subnet
|
||||
--vpc-id {{ dev_vpc_id }}
|
||||
--cidr-block {{ dev_vpc_subnet_cidr }}
|
||||
--availability-zone {{ dev_vpc_subnet_az }}
|
||||
--tag-specifications
|
||||
ResourceType=subnet,Tags=[{Key=Name,Value={{ dev_vpc_name }}-public}]
|
||||
--query Subnet.SubnetId --output text
|
||||
register: vpc_subnet_created
|
||||
when: vpc_subnet_existing.stdout | trim in ['', 'None']
|
||||
changed_when: true
|
||||
|
||||
- name: vpc — set subnet id fact
|
||||
ansible.builtin.set_fact:
|
||||
dev_vpc_subnet_id: >-
|
||||
{{ vpc_subnet_created.stdout | default('') | trim
|
||||
if (vpc_subnet_existing.stdout | trim in ['', 'None'])
|
||||
else vpc_subnet_existing.stdout | trim }}
|
||||
|
||||
- name: vpc — ensure subnet has map-public-ip-on-launch
|
||||
ansible.builtin.command: >-
|
||||
{{ spinifex_aws_cli }} ec2 modify-subnet-attribute
|
||||
--subnet-id {{ dev_vpc_subnet_id }}
|
||||
--map-public-ip-on-launch
|
||||
register: vpc_subnet_mapip
|
||||
changed_when: vpc_subnet_existing.stdout | trim in ['', 'None']
|
||||
failed_when: false
|
||||
|
||||
# Try tag-based lookup first. If the gateway didn't tag a previously
|
||||
# created RT (spinifex gateway silently drops --tag-specifications on
|
||||
# create-route-table AND no-ops create-tags for route tables — see
|
||||
# mulga-siv-94; works correctly for subnets), fall back to "RT
|
||||
# associated with our subnet" which doesn't need tags.
|
||||
- name: vpc — find existing route table by Name tag
|
||||
ansible.builtin.command: >-
|
||||
{{ spinifex_aws_cli }} ec2 describe-route-tables
|
||||
--filters Name=vpc-id,Values={{ dev_vpc_id }}
|
||||
Name=tag:Name,Values={{ dev_vpc_name }}-public
|
||||
--query RouteTables[0].RouteTableId --output text
|
||||
register: vpc_rt_existing
|
||||
changed_when: false
|
||||
|
||||
- name: vpc — fallback lookup by subnet association
|
||||
ansible.builtin.command: >-
|
||||
{{ spinifex_aws_cli }} ec2 describe-route-tables
|
||||
--filters Name=association.subnet-id,Values={{ dev_vpc_subnet_id }}
|
||||
--query RouteTables[0].RouteTableId --output text
|
||||
register: vpc_rt_byassoc
|
||||
changed_when: false
|
||||
when: vpc_rt_existing.stdout | trim in ['', 'None']
|
||||
|
||||
- name: vpc — create route table if neither lookup found one
|
||||
ansible.builtin.command: >-
|
||||
{{ spinifex_aws_cli }} ec2 create-route-table
|
||||
--vpc-id {{ dev_vpc_id }}
|
||||
--query RouteTable.RouteTableId --output text
|
||||
register: vpc_rt_created
|
||||
when:
|
||||
- vpc_rt_existing.stdout | trim in ['', 'None']
|
||||
- (vpc_rt_byassoc.stdout | default('') | trim) in ['', 'None']
|
||||
changed_when: true
|
||||
|
||||
- name: vpc — set route-table id fact
|
||||
ansible.builtin.set_fact:
|
||||
dev_vpc_rt_id: >-
|
||||
{%- if (vpc_rt_existing.stdout | trim) not in ['', 'None'] -%}
|
||||
{{ vpc_rt_existing.stdout | trim }}
|
||||
{%- elif (vpc_rt_byassoc.stdout | default('') | trim) not in ['', 'None'] -%}
|
||||
{{ vpc_rt_byassoc.stdout | default('') | trim }}
|
||||
{%- else -%}
|
||||
{{ vpc_rt_created.stdout | default('') | trim }}
|
||||
{%- endif -%}
|
||||
|
||||
- name: vpc — assert route-table id resolved
|
||||
ansible.builtin.assert:
|
||||
that: dev_vpc_rt_id | length > 0
|
||||
fail_msg: "Route table id could not be resolved after describe/create"
|
||||
|
||||
# Spinifex gateway dropped --tag-specifications on the create call;
|
||||
# apply via create-tags so re-runs can find the RT via tag filter.
|
||||
- name: vpc — tag route table (workaround for create-tag-specifications gap)
|
||||
ansible.builtin.command: >-
|
||||
{{ spinifex_aws_cli }} ec2 create-tags
|
||||
--resources {{ dev_vpc_rt_id }}
|
||||
--tags Key=Name,Value={{ dev_vpc_name }}-public
|
||||
changed_when: false
|
||||
failed_when: false
|
||||
|
||||
- name: vpc — add default route via IGW (ignore RouteAlreadyExists)
|
||||
ansible.builtin.command: >-
|
||||
{{ spinifex_aws_cli }} ec2 create-route
|
||||
--route-table-id {{ dev_vpc_rt_id }}
|
||||
--destination-cidr-block 0.0.0.0/0
|
||||
--gateway-id {{ dev_vpc_igw_id }}
|
||||
register: vpc_route_add
|
||||
changed_when:
|
||||
- vpc_route_add.rc == 0
|
||||
- "'RouteAlreadyExists' not in (vpc_route_add.stderr | default(''))"
|
||||
failed_when:
|
||||
- vpc_route_add.rc != 0
|
||||
- "'RouteAlreadyExists' not in (vpc_route_add.stderr | default(''))"
|
||||
|
||||
- name: vpc — associate subnet with route table (ignore already-associated)
|
||||
ansible.builtin.command: >-
|
||||
{{ spinifex_aws_cli }} ec2 associate-route-table
|
||||
--route-table-id {{ dev_vpc_rt_id }}
|
||||
--subnet-id {{ dev_vpc_subnet_id }}
|
||||
register: vpc_assoc
|
||||
changed_when:
|
||||
- vpc_assoc.rc == 0
|
||||
- "'Resource.AlreadyAssociated' not in (vpc_assoc.stderr | default(''))"
|
||||
failed_when:
|
||||
- vpc_assoc.rc != 0
|
||||
- "'Resource.AlreadyAssociated' not in (vpc_assoc.stderr | default(''))"
|
||||
|
||||
- name: vpc — summary (present)
|
||||
ansible.builtin.debug:
|
||||
msg: |
|
||||
Dev VPC ready:
|
||||
VPC : {{ dev_vpc_id }} ({{ dev_vpc_cidr }})
|
||||
IGW : {{ dev_vpc_igw_id }}
|
||||
Subnet : {{ dev_vpc_subnet_id }} ({{ dev_vpc_subnet_cidr }} @ {{ dev_vpc_subnet_az }}, public)
|
||||
Route table : {{ dev_vpc_rt_id }}
|
||||
|
||||
Smoketest now finds this subnet via its existing
|
||||
Name=map-public-ip-on-launch,Values=true filter.
|
||||
@@ -1,27 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run `aws` CLI inside a shell that has the `spinifex` supplementary group
|
||||
# active, with AWS_PROFILE=spinifex preset. Works around mulga-siv-92:
|
||||
# ansible's `become` is a no-op when the target user equals the calling
|
||||
# user, so `sudo -u` is never invoked and supplementary groups are not
|
||||
# re-initialised. Without the `spinifex` group, /etc/spinifex/ca.pem (the
|
||||
# AWS endpoint CA bundle) is unreadable and aws fails with SSL Errno 13.
|
||||
#
|
||||
# Usage from ansible tasks:
|
||||
# ansible.builtin.command:
|
||||
# cmd: "{{ playbook_dir }}/../scripts/aws-as-spinifex.sh ec2 describe-regions"
|
||||
#
|
||||
# All args are forwarded to `aws`. Quoting is preserved via printf %q so
|
||||
# things like `--query 'length(Images)'` reach aws intact.
|
||||
set -euo pipefail
|
||||
|
||||
if [[ $# -eq 0 ]]; then
|
||||
echo "usage: $0 <aws-cli-args>" >&2
|
||||
exit 64
|
||||
fi
|
||||
|
||||
cmd="AWS_PROFILE=spinifex aws"
|
||||
for arg in "$@"; do
|
||||
cmd+=" $(printf '%q' "$arg")"
|
||||
done
|
||||
|
||||
exec sg spinifex -c "$cmd"
|
||||
@@ -1,18 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Run `spx` inside a shell that has the `spinifex` supplementary group
|
||||
# active. Works around mulga-siv-92 (see aws-as-spinifex.sh for details).
|
||||
# All args are forwarded to /usr/local/bin/spx via printf %q so flag
|
||||
# values with spaces or shell metachars survive intact.
|
||||
set -euo pipefail
|
||||
|
||||
if [[ $# -eq 0 ]]; then
|
||||
echo "usage: $0 <spx-args>" >&2
|
||||
exit 64
|
||||
fi
|
||||
|
||||
cmd="AWS_PROFILE=spinifex /usr/local/bin/spx"
|
||||
for arg in "$@"; do
|
||||
cmd+=" $(printf '%q' "$arg")"
|
||||
done
|
||||
|
||||
exec sg spinifex -c "$cmd"
|
||||
@@ -1,137 +0,0 @@
|
||||
---
|
||||
# Production layout (per spinifex/scripts/setup.sh).
|
||||
spinifex_etc_dir: /etc/spinifex
|
||||
spinifex_data_dir: /var/lib/spinifex
|
||||
spinifex_log_dir: /var/log/spinifex
|
||||
spinifex_run_dir: /run/spinifex
|
||||
spinifex_config_file: "{{ spinifex_etc_dir }}/spinifex.toml"
|
||||
spinifex_ca_trust_path: /usr/local/share/ca-certificates/spinifex-ca.crt
|
||||
spinifex_ca_src: "{{ spinifex_etc_dir }}/ca.pem"
|
||||
|
||||
# Legacy $HOME layout (still present on older dev boxes).
|
||||
spinifex_legacy_home_dir: "{{ lookup('env','HOME') }}/spinifex"
|
||||
|
||||
# OVN state.
|
||||
ovn_db_dir: /var/lib/ovn
|
||||
ovn_nb_db: "{{ ovn_db_dir }}/ovnnb_db.db"
|
||||
ovn_sb_db: "{{ ovn_db_dir }}/ovnsb_db.db"
|
||||
|
||||
# Region / init.
|
||||
spinifex_region: ap-southeast-2
|
||||
spinifex_az: "{{ spinifex_region }}a"
|
||||
spinifex_node_name: node1
|
||||
spinifex_node_count: 1
|
||||
|
||||
# External network.
|
||||
# spx admin init contract (as of network-layer-redesign):
|
||||
# external_mode : "pool" or "" (NAT path removed)
|
||||
# external_pool : MANDATORY when mode=pool — set start/end below or pass -e.
|
||||
# external_gw : auto-detected from default route when blank.
|
||||
# external_prefix: auto-detected from WAN when blank.
|
||||
# external_gw_ip : OVN router external-IP override (was nat_gateway_ip).
|
||||
# no_external : true → emit --no-external (skip external networking).
|
||||
spinifex_external_mode: pool
|
||||
spinifex_external_no_external: false
|
||||
spinifex_external_pool_start: ""
|
||||
spinifex_external_pool_end: ""
|
||||
spinifex_external_gateway: ""
|
||||
spinifex_external_prefix_len: ""
|
||||
spinifex_external_gateway_ip: ""
|
||||
|
||||
# Preflight checks.
|
||||
spinifex_ports:
|
||||
- { port: 4222, service: NATS }
|
||||
- { port: 8443, service: Predastore }
|
||||
- { port: 9999, service: "Spinifex Gateway" }
|
||||
|
||||
spinifex_required_commands:
|
||||
- go
|
||||
- make
|
||||
- nbdkit
|
||||
spinifex_optional_commands:
|
||||
- air
|
||||
spinifex_ovn_commands:
|
||||
- ovs-vsctl
|
||||
- ovn-controller
|
||||
- ovn-nbctl
|
||||
|
||||
spinifex_components:
|
||||
- viperblock
|
||||
- predastore
|
||||
|
||||
# Process patterns for teardown.
|
||||
# Bracket trick ([b]in/spx etc.) prevents the pattern literal from matching
|
||||
# our own pgrep/shell cmdline. Exclude filter drops benign log followers and
|
||||
# ansible's own wrappers that also mention these service names.
|
||||
spinifex_process_pattern: "([b]in/spx|spinifex-[u]i|nats-[s]erver|[p]redastore|[v]iperblock|[v]pcd)"
|
||||
spinifex_process_exclude: "(journalctl|ansible|/bin/sh|pgrep|pkill|tail -[fF])"
|
||||
spinifex_qemu_processes:
|
||||
- qemu-system-x86_64
|
||||
- qemu-system-aarch64
|
||||
|
||||
# Invoking user (when run under sudo).
|
||||
spinifex_invoking_user: "{{ lookup('env','SUDO_USER') | default(ansible_facts['user_id'], true) }}"
|
||||
|
||||
# Opt-in wipes.
|
||||
spinifex_wipe_ssh_keys: false
|
||||
spinifex_wipe_aws_creds: false
|
||||
spinifex_wipe_legacy_home: true
|
||||
|
||||
# Timeouts.
|
||||
teardown_process_wait_seconds: 30
|
||||
|
||||
# Path resolution — playbook_dir is scripts/ansible/playbooks/ at runtime.
|
||||
spinifex_root: "{{ lookup('pipe', 'readlink -f ' ~ playbook_dir ~ '/../../..') }}"
|
||||
mulga_root: "{{ lookup('pipe', 'readlink -f ' ~ playbook_dir ~ '/../../../..') }}"
|
||||
|
||||
# Wrappers around `aws` and `spx` that re-enter a shell with the
|
||||
# `spinifex` supplementary group active. Needed because ansible's
|
||||
# `become_user` is a no-op when target user equals current user, so
|
||||
# supplementary groups are never re-initialised and /etc/spinifex/ca.pem
|
||||
# (drwxr-x--- root:spinifex) is unreadable.
|
||||
spinifex_aws_cli: "{{ playbook_dir }}/../scripts/aws-as-spinifex.sh"
|
||||
spinifex_spx_cli: "{{ playbook_dir }}/../scripts/spx-as-spinifex.sh"
|
||||
|
||||
# dev-vpc fixture. Deterministic VPC + public subnet + IGW + default
|
||||
# route. Idempotent: re-running detects existing resources by Name tag.
|
||||
# Used by smoketest's launch path (which looks for any
|
||||
# map-public-ip-on-launch=true subnet).
|
||||
dev_vpc_name: spinifex-dev-vpc
|
||||
dev_vpc_cidr: 10.99.0.0/16
|
||||
dev_vpc_subnet_cidr: 10.99.1.0/24
|
||||
dev_vpc_subnet_az: "{{ spinifex_az }}"
|
||||
dev_vpc_state: present # present | absent
|
||||
|
||||
# Build output.
|
||||
build_tarball_path: /tmp/spinifex-local.tar.gz
|
||||
|
||||
# Snapshot / restore. Snapshots quiesce the full spinifex+ovn+ovs stack,
|
||||
# tar these paths, then restart services. Restore wipes + untars + restarts.
|
||||
# Default snapshot dir is root-owned so it survives `dev-teardown`
|
||||
# (teardown only removes the four spinifex_*_dir paths above).
|
||||
snapshot_dir: /var/lib/spinifex-snapshots
|
||||
snapshot_paths:
|
||||
- "{{ spinifex_etc_dir }}"
|
||||
- "{{ spinifex_data_dir }}"
|
||||
- "{{ ovn_db_dir }}"
|
||||
- /etc/openvswitch/conf.db
|
||||
# Order matters: stop spinifex first (depends on OVN/OVS), then OVN, then OVS.
|
||||
# Restart in reverse.
|
||||
snapshot_services_stop_order:
|
||||
- spinifex.target
|
||||
- ovn-controller
|
||||
- ovn-central
|
||||
- openvswitch-switch
|
||||
|
||||
# Smoketest image — parameterised so tofu examples can share the same base.
|
||||
spinifex_smoketest_distro: debian
|
||||
spinifex_smoketest_version: "13"
|
||||
# Local image file path (if present, imported directly; otherwise fall back to --name).
|
||||
spinifex_smoketest_local_image: "~/images/{{ spinifex_smoketest_distro }}-{{ spinifex_smoketest_version }}.img"
|
||||
|
||||
# Multi-node cluster bootstrap (cluster-bootstrap.yml).
|
||||
# cluster_env, cluster_init_host, spinifex_node_count come from inventory/tofu.py.
|
||||
cluster_external_pool: "" # "<start>-<end>" or empty for DHCP fallback
|
||||
cluster_join_token_path: /etc/spinifex/join-token
|
||||
cluster_formation_port: 4432
|
||||
cluster_formation_timeout: 60 # seconds (uri retries = value // 2)
|
||||
@@ -19,14 +19,21 @@ INITTAB="$BUILD_DIR/inittab"
|
||||
|
||||
ALPINE_VERSION="${ALPINE_VERSION:-3.20}"
|
||||
ALPINE_MIRROR="${ALPINE_MIRROR:-https://dl-cdn.alpinelinux.org/alpine}"
|
||||
ALPINE_PACKAGES="busybox linux-virt haproxy iproute2 ca-certificates"
|
||||
# haproxy: ALB (L7) data plane. nginx + nginx-mod-stream: NLB (L4) data plane —
|
||||
# the `stream` module (a separate Alpine package) load-balances TCP/UDP/TLS,
|
||||
# which haproxy cannot do for UDP.
|
||||
ALPINE_PACKAGES="busybox linux-virt haproxy nginx nginx-mod-stream iproute2 ca-certificates"
|
||||
|
||||
echo "[build-microvm-image] repo root: $REPO_ROOT"
|
||||
echo "[build-microvm-image] build dir: $BUILD_DIR"
|
||||
mkdir -p "$BUILD_DIR"
|
||||
|
||||
# --- Verify non-negotiable tools ---
|
||||
for tool in cpio gzip find; do
|
||||
# fakeroot is mandatory: device nodes are written into the cpio archive inside a
|
||||
# faked-root environment so no real device node is ever created on the host
|
||||
# filesystem (which previously required sudo mknod and risked clobbering host
|
||||
# /dev/null on cleanup).
|
||||
for tool in cpio gzip find fakeroot; do
|
||||
if ! command -v "$tool" >/dev/null 2>&1; then
|
||||
echo "ERROR: required tool not found: $tool" >&2
|
||||
exit 1
|
||||
@@ -35,6 +42,13 @@ done
|
||||
|
||||
# --- Create chroot ---
|
||||
CHROOT_DIR=$(mktemp -d)
|
||||
# Guard: every destructive op below targets "$CHROOT_DIR/...". An empty or
|
||||
# unexpected value would aim rm -rf at the host (e.g. /dev). Refuse anything that
|
||||
# is not a freshly-created temp dir.
|
||||
case "${CHROOT_DIR:?mktemp -d returned empty}" in
|
||||
/tmp/*|/var/tmp/*|"${TMPDIR:-/nonexistent}"/*) ;;
|
||||
*) echo "ERROR: refusing to use unexpected CHROOT_DIR: $CHROOT_DIR" >&2; exit 1 ;;
|
||||
esac
|
||||
CONTAINER_TOOL=""
|
||||
CONTAINER_CID=""
|
||||
cleanup() {
|
||||
@@ -93,16 +107,14 @@ else
|
||||
$CONTAINER_TOOL export "$CONTAINER_CID" | tar -x -C "$CHROOT_DIR"
|
||||
fi
|
||||
|
||||
# --- Fix /dev device nodes ---
|
||||
# --- /dev: empty mountpoint only ---
|
||||
# tar extraction without root silently creates 0-byte regular files for device
|
||||
# nodes (mknod requires CAP_MKNOD). The kernel opens /dev/console to wire
|
||||
# init's stdio to the serial console; a regular file there causes all init
|
||||
# output to be silently discarded. Recreate the two nodes needed before
|
||||
# devtmpfs mounts (which provides the rest at runtime).
|
||||
rm -rf "$CHROOT_DIR/dev"
|
||||
# nodes. Drop them and leave an empty /dev mountpoint. The two static nodes the
|
||||
# kernel needs (/dev/console to wire init's stdio, /dev/null) are written into
|
||||
# the cpio archive under fakeroot at pack time — NOT created on the host. init
|
||||
# mounts devtmpfs over /dev as its first action for everything else at runtime.
|
||||
rm -rf "${CHROOT_DIR:?}/dev"
|
||||
mkdir "$CHROOT_DIR/dev"
|
||||
sudo mknod -m 600 "$CHROOT_DIR/dev/console" c 5 1
|
||||
sudo mknod -m 666 "$CHROOT_DIR/dev/null" c 1 3
|
||||
|
||||
# --- Place init script ---
|
||||
echo "[build-microvm-image] installing init.sh as /init..."
|
||||
@@ -161,6 +173,21 @@ else
|
||||
echo "[build-microvm-image] fw_cfg: module found in lib/modules"
|
||||
fi
|
||||
|
||||
# --- Assert nginx + stream module (NLB L4 data plane) ---
|
||||
# The lb-agent runs `nginx -c` with a config that `load_module`s the stream
|
||||
# module; if either the binary or the .so is missing the agent never enters
|
||||
# nginx mode, never probes, and NLB targets stay at 0/N healthy. Fail the build
|
||||
# loudly here rather than discover it as an opaque e2e health timeout.
|
||||
if [ ! -x "$CHROOT_DIR/usr/sbin/nginx" ] && [ ! -x "$CHROOT_DIR/usr/bin/nginx" ]; then
|
||||
echo "ERROR: nginx binary missing from image — NLB data plane will not start" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! find "$CHROOT_DIR/usr/lib/nginx/modules" -name "ngx_stream_module.so" 2>/dev/null | grep -q .; then
|
||||
echo "ERROR: ngx_stream_module.so missing from image — NLB stream config fails to load" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "[build-microvm-image] nginx: binary + stream module present"
|
||||
|
||||
# --- Strip kernel modules (Phase 2 keep-list approach) ---
|
||||
# Only modules needed by a virtio-mmio microvm guest are kept:
|
||||
# virtio*.ko* — virtio bus, net, blk, rng, console, and helpers
|
||||
@@ -197,8 +224,34 @@ for kver_dir in "$CHROOT_DIR/lib/modules"/*/; do
|
||||
done)
|
||||
rm -rf "$tmp_keep"
|
||||
|
||||
# Regenerate module dependency map from the surviving modules.
|
||||
depmod -b "$CHROOT_DIR" "$kver" 2>/dev/null || true
|
||||
# Decompress kept modules to plain .ko. Alpine ships .ko.gz, but a host
|
||||
# kmod that lacks gzip support (e.g. Debian trixie's kmod 34) silently
|
||||
# produces an EMPTY modules.dep from compressed modules — the guest's
|
||||
# modprobe then no-ops and qemu_fw_cfg never loads. Plain .ko sidesteps the
|
||||
# host-kmod dependency entirely and the guest needs no decompressor either.
|
||||
find "$kernel_dir" -name "*.ko.gz" -exec gunzip -f {} +
|
||||
|
||||
# Regenerate the module dependency map when a host depmod is available. A
|
||||
# populated modules.dep lets the guest init's load_mod() resolve modules via
|
||||
# modprobe; without it, load_mod() falls back to insmod-by-path (see
|
||||
# build/microvm/init.sh), so modules.dep is an optimisation, not a boot
|
||||
# requirement. depmod (kmod) is absent on some self-hosted CI runners and
|
||||
# cannot be apt-installed there — skip rather than hard-fail. When depmod
|
||||
# IS present, still assert a non-empty result so the trixie-kmod-can't-read
|
||||
# -compressed-modules bug (empty modules.dep from .ko.gz) fails loudly.
|
||||
if command -v depmod >/dev/null 2>&1; then
|
||||
if ! depmod -b "$CHROOT_DIR" "$kver"; then
|
||||
echo "ERROR: depmod failed for $kver" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -s "$kver_dir/modules.dep" ]; then
|
||||
echo "ERROR: ${kver_dir}modules.dep missing or empty after depmod" >&2
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "[build-microvm-image] depmod not found; skipping modules.dep" \
|
||||
"(guest init load_mod() will insmod modules by path)" >&2
|
||||
fi
|
||||
done
|
||||
|
||||
# --- Strip package-manager artifacts (not needed at runtime) ---
|
||||
@@ -238,10 +291,15 @@ rm -rf "$CHROOT_DIR/boot"
|
||||
# --- Build initramfs ---
|
||||
INITRAMFS_OUT="$BUILD_DIR/initramfs.cpio.gz"
|
||||
echo "[build-microvm-image] building initramfs: $INITRAMFS_OUT"
|
||||
(
|
||||
cd "$CHROOT_DIR"
|
||||
find . | cpio --quiet -o -H newc | gzip -9 > "$INITRAMFS_OUT"
|
||||
)
|
||||
# Create the static device nodes and pack the archive inside a single fakeroot
|
||||
# session: mknod/cpio see faked device entries and write real char-device
|
||||
# records into the cpio, while the host filesystem never gets an actual node.
|
||||
fakeroot sh -c '
|
||||
cd "$1" || exit 1
|
||||
mknod -m 600 dev/console c 5 1
|
||||
mknod -m 666 dev/null c 1 3
|
||||
find . | cpio --quiet -o -H newc | gzip -9 > "$2"
|
||||
' _ "$CHROOT_DIR" "$INITRAMFS_OUT"
|
||||
|
||||
# --- Log artifact sizes ---
|
||||
echo ""
|
||||
|
||||
+102
-209
@@ -3,17 +3,28 @@ 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.
|
||||
# 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.
|
||||
#
|
||||
# Requirements: qemu-nbd, qemu-img, sudo (for mount/chroot), curl
|
||||
# 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 ""
|
||||
@@ -60,8 +71,6 @@ done
|
||||
|
||||
# Derived paths
|
||||
BUILD_DIR="/tmp/${IMAGE_NAME}-image-build"
|
||||
NBD_DEV="/dev/nbd0"
|
||||
MOUNT_DIR="${BUILD_DIR}/mnt"
|
||||
|
||||
case "$DISTRO" in
|
||||
alpine)
|
||||
@@ -74,7 +83,6 @@ case "$DISTRO" in
|
||||
OUTPUT_IMAGE="${BUILD_DIR}/${IMAGE_NAME}-alpine.qcow2"
|
||||
OUTPUT_RAW="${BUILD_DIR}/${IMAGE_NAME}-alpine.raw"
|
||||
DISTRO_VERSION="${ALPINE_VERSION}"
|
||||
ROOT_PART="${NBD_DEV}"
|
||||
BOOT_MODE="bios"
|
||||
;;
|
||||
ubuntu)
|
||||
@@ -87,7 +95,6 @@ case "$DISTRO" in
|
||||
OUTPUT_IMAGE="${BUILD_DIR}/${IMAGE_NAME}-ubuntu.qcow2"
|
||||
OUTPUT_RAW="${BUILD_DIR}/${IMAGE_NAME}-ubuntu.raw"
|
||||
DISTRO_VERSION="${UBUNTU_VERSION}"
|
||||
ROOT_PART="${NBD_DEV}p1"
|
||||
BOOT_MODE="uefi"
|
||||
;;
|
||||
*)
|
||||
@@ -96,19 +103,20 @@ case "$DISTRO" in
|
||||
;;
|
||||
esac
|
||||
|
||||
cleanup() {
|
||||
echo "Cleaning up..."
|
||||
# Ubuntu chroot has bind mounts that must be released first
|
||||
sudo umount "${MOUNT_DIR}/dev/pts" 2>/dev/null || true
|
||||
sudo umount "${MOUNT_DIR}/dev" 2>/dev/null || true
|
||||
sudo umount "${MOUNT_DIR}/sys" 2>/dev/null || true
|
||||
sudo umount "${MOUNT_DIR}/proc" 2>/dev/null || true
|
||||
sudo umount "${MOUNT_DIR}" 2>/dev/null || true
|
||||
sudo qemu-nbd --disconnect "${NBD_DEV}" 2>/dev/null || true
|
||||
exec 9>&- 2>/dev/null || true # release nbd lock if held
|
||||
echo "Done."
|
||||
# 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[@]}"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# In quiet mode, redirect build output to /dev/null (import output still shown)
|
||||
if [[ "$QUIET" == true ]]; then
|
||||
@@ -116,7 +124,7 @@ if [[ "$QUIET" == true ]]; then
|
||||
exec 1>/dev/null # suppress build output
|
||||
fi
|
||||
|
||||
echo "=== System Image Builder ==="
|
||||
echo "=== System Image Builder (libguestfs) ==="
|
||||
echo "Image: ${IMAGE_NAME} — ${IMAGE_DESCRIPTION:-}"
|
||||
echo "Distro: ${DISTRO} ${DISTRO_VERSION}"
|
||||
echo "Size: ${IMAGE_SIZE}"
|
||||
@@ -124,18 +132,18 @@ echo "Build: ${BUILD_DIR}"
|
||||
echo ""
|
||||
|
||||
# Step 0: Check prerequisites
|
||||
if ! command -v qemu-nbd &>/dev/null; then
|
||||
echo "ERROR: qemu-nbd not found. Install qemu-utils."
|
||||
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 ! command -v qemu-img &>/dev/null; then
|
||||
echo "ERROR: qemu-img not found. Install qemu-utils."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ "$DISTRO" == "ubuntu" ]] && ! command -v parted &>/dev/null && ! [[ -x /usr/sbin/parted ]]; then
|
||||
echo "ERROR: parted not found. Install parted."
|
||||
if [[ "$DISTRO" == "ubuntu" ]] && ! command -v virt-resize &>/dev/null; then
|
||||
echo "ERROR: virt-resize not found. Install libguestfs-tools."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -166,11 +174,11 @@ if [[ -n "${INSTALL_BINARIES:-}" ]]; then
|
||||
done
|
||||
fi
|
||||
|
||||
# Serialize the entire image build with flock — concurrent builds on the same
|
||||
# host (e.g. CI single-node + multi-node jobs) share /dev/nbd0 and BUILD_DIR.
|
||||
NBD_LOCK="/tmp/build-system-image.lock"
|
||||
# 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>"$NBD_LOCK"
|
||||
exec 9>"$BUILD_LOCK"
|
||||
flock 9
|
||||
echo "Lock acquired"
|
||||
|
||||
@@ -189,18 +197,12 @@ if [[ -f "$OUTPUT_RAW" ]] && [[ $(( $(date +%s) - $(stat -c %Y "$OUTPUT_RAW") ))
|
||||
echo " raw: $OUTPUT_RAW ($(du -h "$OUTPUT_RAW" | cut -f1))"
|
||||
|
||||
if [[ "$DO_IMPORT" == true ]]; then
|
||||
echo "Importing as AMI..."
|
||||
rm -f "$OUTPUT_IMAGE"
|
||||
IMPORT_ARGS=(--file "$OUTPUT_RAW" --distro "${DISTRO}" --version "${DISTRO_VERSION}" --arch x86_64 --boot-mode "${BOOT_MODE}")
|
||||
if [[ -n "${SYSTEM_TAG:-}" ]]; then
|
||||
IMPORT_ARGS+=(--tag "$SYSTEM_TAG")
|
||||
fi
|
||||
spx admin images import "${IMPORT_ARGS[@]}"
|
||||
import_ami
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
mkdir -p "$BUILD_DIR" "$MOUNT_DIR"
|
||||
mkdir -p "$BUILD_DIR"
|
||||
|
||||
# Step 1: Download base cloud image
|
||||
if [[ -f "${BUILD_DIR}/${SOURCE_IMAGE}" ]]; then
|
||||
@@ -225,126 +227,66 @@ rm -f "$OUTPUT_IMAGE"
|
||||
echo "Copying image for customization..."
|
||||
cp "${BUILD_DIR}/${SOURCE_IMAGE}" "$OUTPUT_IMAGE"
|
||||
|
||||
# Resize the image to provide room for packages
|
||||
qemu-img resize "$OUTPUT_IMAGE" "$IMAGE_SIZE"
|
||||
|
||||
# Step 3: Connect via qemu-nbd
|
||||
echo "Connecting image via qemu-nbd..."
|
||||
sudo modprobe nbd max_part=4 2>/dev/null || true
|
||||
if [[ ! -e "${NBD_DEV}" ]]; then
|
||||
echo "ERROR: ${NBD_DEV} does not exist. Is the nbd kernel module loaded? Try: sudo modprobe nbd"
|
||||
exit 1
|
||||
# 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
|
||||
sudo qemu-nbd --disconnect "${NBD_DEV}" 2>/dev/null || true
|
||||
sudo qemu-nbd --connect="${NBD_DEV}" "$OUTPUT_IMAGE"
|
||||
|
||||
# Wait for the nbd device to be ready
|
||||
for i in $(seq 1 10); do
|
||||
if sudo blockdev --getsize64 "${NBD_DEV}" &>/dev/null; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
sleep 1
|
||||
|
||||
# Ubuntu images have a partition table; wait for partition devices then resize
|
||||
# the partition to fill the expanded image before resizing the filesystem.
|
||||
# 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
|
||||
for i in $(seq 1 10); do
|
||||
if [[ -e "${ROOT_PART}" ]]; then break; fi
|
||||
sleep 1
|
||||
done
|
||||
echo "Resizing partition..."
|
||||
# After qemu-img resize the GPT backup header sits at the old end of disk.
|
||||
# Relocate it to the new end (sfdisk --relocate is util-linux built-in,
|
||||
# no extra packages needed), then re-read the partition table before parted
|
||||
# runs — without this, parted sees stale GPT geometry and fails with
|
||||
# "Unable to satisfy all constraints on the partition."
|
||||
sudo sgdisk --move-second-header "${NBD_DEV}"
|
||||
sudo partprobe "${NBD_DEV}" 2>/dev/null || true
|
||||
sleep 1
|
||||
sudo parted --script "${NBD_DEV}" resizepart 1 100%
|
||||
# DKMS driver bakes (GPU images) need real resources in the appliance.
|
||||
CUST+=(--memsize 6144 --smp 4)
|
||||
else
|
||||
CUST+=(--memsize 2048)
|
||||
fi
|
||||
|
||||
# Alpine cloud images have ext4 directly on the block device (no partition table).
|
||||
echo "Checking filesystem..."
|
||||
sudo e2fsck -f -y "${ROOT_PART}" || {
|
||||
ec=$?
|
||||
if [[ $ec -gt 1 ]]; then
|
||||
echo "ERROR: e2fsck failed with exit code $ec on ${ROOT_PART}"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
echo "Resizing filesystem..."
|
||||
if ! sudo resize2fs "${ROOT_PART}"; then
|
||||
echo "ERROR: resize2fs failed on ${ROOT_PART}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Step 4: Mount and customize
|
||||
echo "Mounting root filesystem..."
|
||||
sudo mount "${ROOT_PART}" "$MOUNT_DIR"
|
||||
|
||||
# Set up resolv.conf for DNS inside chroot.
|
||||
# Ubuntu cloud images symlink /etc/resolv.conf → /run/systemd/resolve/stub-resolv.conf
|
||||
# which doesn't exist yet. Remove the symlink and write a real file.
|
||||
sudo rm -f "${MOUNT_DIR}/etc/resolv.conf"
|
||||
sudo cp /etc/resolv.conf "${MOUNT_DIR}/etc/resolv.conf"
|
||||
|
||||
# Ubuntu chroot requires /proc /sys /dev bind mounts for systemd and DKMS
|
||||
if [[ "$DISTRO" == "ubuntu" ]]; then
|
||||
sudo mount --bind /proc "${MOUNT_DIR}/proc"
|
||||
sudo mount --bind /sys "${MOUNT_DIR}/sys"
|
||||
sudo mount --bind /dev "${MOUNT_DIR}/dev"
|
||||
sudo mount --bind /dev/pts "${MOUNT_DIR}/dev/pts"
|
||||
fi
|
||||
|
||||
# Install packages
|
||||
# Packages
|
||||
if [[ "$DISTRO" == "alpine" ]] && [[ -n "${APK_PACKAGES:-}" ]]; then
|
||||
echo "Installing packages: ${APK_PACKAGES}..."
|
||||
sudo chroot "$MOUNT_DIR" /bin/sh -c "
|
||||
set -e
|
||||
# Enable community repo
|
||||
sed -i 's|^#\(.*community\)|\1|' /etc/apk/repositories 2>/dev/null || true
|
||||
|
||||
# Ensure community repo is present
|
||||
if ! grep -q community /etc/apk/repositories; then
|
||||
MIRROR=\$(grep main /etc/apk/repositories | head -1 | sed 's|/main|/community|')
|
||||
echo \"\$MIRROR\" >> /etc/apk/repositories
|
||||
fi
|
||||
|
||||
apk update
|
||||
apk add ${APK_PACKAGES}
|
||||
"
|
||||
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 "Installing packages: ${APT_PACKAGES}..."
|
||||
sudo chroot "$MOUNT_DIR" /bin/bash -c "
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends ${APT_PACKAGES}
|
||||
"
|
||||
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
|
||||
|
||||
# Step 5: Copy binaries into the image (before setup script, which may reference them)
|
||||
# Binaries (mode 0755). --mkdir is idempotent (mkdir -p semantics).
|
||||
if [[ -n "${INSTALL_BINARIES:-}" ]]; then
|
||||
echo "Installing binaries..."
|
||||
IFS=' ' read -ra BINARY_PAIRS <<< "$INSTALL_BINARIES"
|
||||
for pair in "${BINARY_PAIRS[@]}"; do
|
||||
src="${pair%%:*}"
|
||||
dst="${pair#*:}"
|
||||
src_path="${PROJECT_DIR}/${src}"
|
||||
echo " ${src} -> ${dst}"
|
||||
sudo cp "$src_path" "${MOUNT_DIR}${dst}"
|
||||
sudo chmod 755 "${MOUNT_DIR}${dst}"
|
||||
CUST+=(--mkdir "$(dirname "$dst")")
|
||||
CUST+=(--upload "${src_path}:${dst}")
|
||||
CUST+=(--chmod "0755:${dst}")
|
||||
done
|
||||
fi
|
||||
|
||||
# Copy auxiliary files (systemd units, OpenRC initd scripts, cron entries, configs).
|
||||
# Same src:dst pair grammar as INSTALL_BINARIES; mode 0644, parent dirs auto-created.
|
||||
# Optional — manifests without INSTALL_FILES (e.g. existing GPU images) skip this step.
|
||||
# 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
|
||||
echo "Installing files..."
|
||||
IFS=' ' read -ra FILE_PAIRS <<< "$INSTALL_FILES"
|
||||
for pair in "${FILE_PAIRS[@]}"; do
|
||||
src="${pair%%:*}"
|
||||
@@ -354,85 +296,45 @@ if [[ -n "${INSTALL_FILES:-}" ]]; then
|
||||
echo "ERROR: INSTALL_FILES source not found: $src_path"
|
||||
exit 1
|
||||
fi
|
||||
echo " ${src} -> ${dst}"
|
||||
sudo mkdir -p "${MOUNT_DIR}$(dirname "$dst")"
|
||||
sudo cp "$src_path" "${MOUNT_DIR}${dst}"
|
||||
sudo chmod 0644 "${MOUNT_DIR}${dst}"
|
||||
CUST+=(--mkdir "$(dirname "$dst")")
|
||||
CUST+=(--upload "${src_path}:${dst}")
|
||||
CUST+=(--chmod "0644:${dst}")
|
||||
done
|
||||
fi
|
||||
|
||||
# Run custom setup script inside chroot (after binaries are installed)
|
||||
# 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
|
||||
echo "Running setup script: ${SETUP_SCRIPT}..."
|
||||
sudo cp "$setup_path" "${MOUNT_DIR}/tmp/setup.sh"
|
||||
sudo chmod 755 "${MOUNT_DIR}/tmp/setup.sh"
|
||||
if [[ "$DISTRO" == "ubuntu" ]]; then
|
||||
if [[ ! -x "${MOUNT_DIR}/bin/bash" ]]; then
|
||||
echo "ERROR: /bin/bash not found or not executable in chroot"
|
||||
exit 1
|
||||
fi
|
||||
sudo chroot "$MOUNT_DIR" /bin/bash -x /tmp/setup.sh
|
||||
else
|
||||
sudo chroot "$MOUNT_DIR" /tmp/setup.sh
|
||||
fi
|
||||
sudo rm -f "${MOUNT_DIR}/tmp/setup.sh"
|
||||
CUST+=(--run "$setup_path")
|
||||
fi
|
||||
|
||||
# Enable services (runs after INSTALL_FILES + SETUP_SCRIPT so init scripts the
|
||||
# manifest references are already on disk).
|
||||
# Enable services (after files + setup so referenced init scripts exist).
|
||||
if [[ -n "${ENABLE_SERVICES:-}" ]]; then
|
||||
echo "Enabling services: ${ENABLE_SERVICES}..."
|
||||
IFS=' ' read -ra SERVICES <<< "$ENABLE_SERVICES"
|
||||
for svc in "${SERVICES[@]}"; do
|
||||
if [[ "$DISTRO" == "alpine" ]]; then
|
||||
if ! sudo chroot "$MOUNT_DIR" /bin/sh -c "rc-update add ${svc} default"; then
|
||||
echo "ERROR: Failed to enable service '${svc}' — does it exist in the image?"
|
||||
exit 1
|
||||
fi
|
||||
CUST+=(--run-command "rc-update add ${svc} default")
|
||||
else
|
||||
if ! sudo chroot "$MOUNT_DIR" /bin/bash -c "systemctl enable ${svc}"; then
|
||||
echo "ERROR: Failed to enable service '${svc}'"
|
||||
exit 1
|
||||
fi
|
||||
CUST+=(--run-command "systemctl enable ${svc}")
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# Step 6: Clean up and unmount
|
||||
echo "Cleaning up image..."
|
||||
# Final cleanup of package caches + tmp.
|
||||
if [[ "$DISTRO" == "alpine" ]]; then
|
||||
sudo chroot "$MOUNT_DIR" /bin/sh -c '
|
||||
apk cache clean 2>/dev/null || true
|
||||
rm -rf /var/cache/apk/* /tmp/*
|
||||
'
|
||||
CUST+=(--run-command 'apk cache clean 2>/dev/null || true; rm -rf /var/cache/apk/* /tmp/* 2>/dev/null || true')
|
||||
else
|
||||
sudo chroot "$MOUNT_DIR" /bin/bash -c '
|
||||
export DEBIAN_FRONTEND=noninteractive
|
||||
apt-get clean
|
||||
rm -rf /var/lib/apt/lists/* /tmp/*
|
||||
'
|
||||
CUST+=(--run-command 'export DEBIAN_FRONTEND=noninteractive; apt-get clean; rm -rf /var/lib/apt/lists/* /tmp/* 2>/dev/null || true')
|
||||
fi
|
||||
|
||||
# Restore the systemd-resolved symlink (Ubuntu default); cloud-init sets DNS on boot.
|
||||
sudo rm -f "${MOUNT_DIR}/etc/resolv.conf"
|
||||
sudo ln -sf /run/systemd/resolve/stub-resolv.conf "${MOUNT_DIR}/etc/resolv.conf"
|
||||
echo "Customizing image (libguestfs appliance)..."
|
||||
"${CUST[@]}"
|
||||
|
||||
echo "Unmounting..."
|
||||
if [[ "$DISTRO" == "ubuntu" ]]; then
|
||||
sudo umount "${MOUNT_DIR}/dev/pts"
|
||||
sudo umount "${MOUNT_DIR}/dev"
|
||||
sudo umount "${MOUNT_DIR}/sys"
|
||||
sudo umount "${MOUNT_DIR}/proc"
|
||||
fi
|
||||
sudo umount "$MOUNT_DIR"
|
||||
sudo qemu-nbd --disconnect "${NBD_DEV}"
|
||||
|
||||
# Step 7: Convert to raw for import
|
||||
# Step 5: Convert to raw for import
|
||||
echo "Converting to raw format..."
|
||||
qemu-img convert -f qcow2 -O raw "$OUTPUT_IMAGE" "$OUTPUT_RAW"
|
||||
|
||||
@@ -449,23 +351,14 @@ echo " raw: $OUTPUT_RAW ($(du -h "$OUTPUT_RAW" | cut -f1))"
|
||||
echo ""
|
||||
|
||||
if [[ "$DO_IMPORT" == true ]]; then
|
||||
echo "Importing as AMI..."
|
||||
rm -f "$OUTPUT_IMAGE"
|
||||
IMPORT_ARGS=(--file "$OUTPUT_RAW" --distro "${DISTRO}" --version "${DISTRO_VERSION}" --arch x86_64 --boot-mode "${BOOT_MODE}")
|
||||
if [[ -n "${AMI_NAME:-}" ]]; then
|
||||
IMPORT_ARGS+=(--name "$AMI_NAME")
|
||||
fi
|
||||
if [[ -n "${SYSTEM_TAG:-}" ]]; then
|
||||
IMPORT_ARGS+=(--tag "$SYSTEM_TAG")
|
||||
fi
|
||||
spx admin images import "${IMPORT_ARGS[@]}"
|
||||
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 --name ${AMI_NAME}"
|
||||
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} \\"
|
||||
|
||||
@@ -90,6 +90,17 @@ fi
|
||||
echo "=== Building and installing microVM artifacts ==="
|
||||
cd "$PROJECT_ROOT" && make build-lb-agent install-microvm
|
||||
|
||||
# Unified EKS node AMI (server + agent; role chosen at first boot). Heavy (full
|
||||
# Alpine + K3s build, needs sudo + network), so it is opt-in: set
|
||||
# DEV_INSTALL_EKS=1 to build + register it. Without it, `aws eks create-cluster`
|
||||
# fails AMI resolution until the operator runs `make import-eks-node-image`.
|
||||
if [ "${DEV_INSTALL_EKS:-0}" = "1" ]; then
|
||||
echo "=== Building + importing eks-node AMI ==="
|
||||
cd "$PROJECT_ROOT" && make import-eks-node-image
|
||||
else
|
||||
echo "=== Skipping eks-node AMI (set DEV_INSTALL_EKS=1 to enable, or run 'make import-eks-node-image') ==="
|
||||
fi
|
||||
|
||||
echo "=== Done ==="
|
||||
echo "Services: sudo systemctl status spinifex.target"
|
||||
echo "Logs: journalctl -u 'spinifex-*' -f"
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
IMAGE_NAME="eks-agent"
|
||||
IMAGE_DESCRIPTION="EKS K3s agent VM — Alpine + K3s v1.32.5+k3s1 (worker node)"
|
||||
DISTRO="alpine"
|
||||
ALPINE_VERSION="3.21.7"
|
||||
|
||||
# 2G for the worker — kubelet + containerd + node images. Customer workload
|
||||
# storage attaches via separate EBS volumes (managed by viperblock); this is
|
||||
# the OS root only.
|
||||
IMAGE_SIZE="2G"
|
||||
|
||||
APK_PACKAGES="ca-certificates curl iptables ip6tables conntrack-tools cgroup-tools openrc"
|
||||
|
||||
# K3s binary downloaded by setup.sh (same pinned version + SHA path as
|
||||
# eks-server) to keep server / agent in lock-step.
|
||||
|
||||
INSTALL_FILES="\
|
||||
scripts/images/eks-agent/k3s-agent.initd:/etc/init.d/k3s-agent \
|
||||
scripts/images/eks-agent/mulga-eks-state-report.sh:/etc/periodic/15min/mulga-eks-state-report"
|
||||
|
||||
ENABLE_SERVICES="crond k3s-agent"
|
||||
|
||||
SETUP_SCRIPT="scripts/images/eks-agent/setup.sh"
|
||||
|
||||
AMI_NAME="spinifex-eks-agent"
|
||||
SYSTEM_TAG="spinifex:managed-by=eks"
|
||||
@@ -1,38 +0,0 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
# mulga-eks-state-report (agent variant) — periodic NATS publish of agent
|
||||
# liveness signal. Subject: eks.state.{accountID}.{clusterName}.agent.{nodeName}
|
||||
# Payload: JSON { kubelet: "ok"|"fail", ts: <unix> }
|
||||
#
|
||||
# The agent has no API server to query for its own status; we shell-check the
|
||||
# local kubelet socket via crictl, and fall back to a process check.
|
||||
|
||||
ENVFILE=/etc/spinifex-eks/state-report.env
|
||||
[ -f "${ENVFILE}" ] || { logger -t mulga-eks-state-report "${ENVFILE} missing"; exit 0; }
|
||||
# shellcheck disable=SC1090
|
||||
. "${ENVFILE}"
|
||||
|
||||
: "${SPINIFEX_NATS_URL:?}"
|
||||
: "${EKS_ACCOUNT_ID:?}"
|
||||
: "${EKS_CLUSTER_NAME:?}"
|
||||
: "${EKS_NODE_NAME:?}"
|
||||
|
||||
if pgrep -f 'k3s agent' >/dev/null 2>&1; then
|
||||
KUBELET=ok
|
||||
else
|
||||
KUBELET=fail
|
||||
fi
|
||||
|
||||
TS=$(date +%s)
|
||||
PAYLOAD=$(printf '{"kubelet":"%s","ts":%s}' "${KUBELET}" "${TS}")
|
||||
SUBJ="eks.state.${EKS_ACCOUNT_ID}.${EKS_CLUSTER_NAME}.agent.${EKS_NODE_NAME}"
|
||||
|
||||
NATS_ARGS="-s ${SPINIFEX_NATS_URL}"
|
||||
if [ -n "${SPINIFEX_NATS_CREDS_FILE:-}" ] && [ -f "${SPINIFEX_NATS_CREDS_FILE}" ]; then
|
||||
NATS_ARGS="${NATS_ARGS} --creds ${SPINIFEX_NATS_CREDS_FILE}"
|
||||
fi
|
||||
|
||||
# shellcheck disable=SC2086
|
||||
printf '%s' "${PAYLOAD}" | nats ${NATS_ARGS} pub "${SUBJ}" --stdin 2>&1 \
|
||||
| logger -t mulga-eks-state-report
|
||||
@@ -1,47 +0,0 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
# setup.sh — chroot customisation for the eks-agent AMI.
|
||||
# Pulls the same K3s release as eks-server (lock-step versioning per Q12) and
|
||||
# wires the k3s-agent OpenRC service.
|
||||
|
||||
K3S_VERSION="v1.32.5+k3s1"
|
||||
K3S_URL_BASE="https://github.com/k3s-io/k3s/releases/download/${K3S_VERSION}"
|
||||
|
||||
echo "[eks-agent-setup] fetching k3s checksums ${K3S_VERSION}"
|
||||
curl -fsSL -o /tmp/k3s-checksums.txt "${K3S_URL_BASE}/sha256sum-amd64.txt"
|
||||
K3S_SHA256=$(awk '/[ \t]k3s$/{print $1; exit}' /tmp/k3s-checksums.txt)
|
||||
if [ -z "${K3S_SHA256}" ]; then
|
||||
echo "[eks-agent-setup] could not parse k3s sha256 from upstream checksums"
|
||||
cat /tmp/k3s-checksums.txt
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "[eks-agent-setup] downloading k3s ${K3S_VERSION} (sha256=${K3S_SHA256})"
|
||||
curl -fsSL -o /usr/local/bin/k3s "${K3S_URL_BASE}/k3s"
|
||||
echo "${K3S_SHA256} /usr/local/bin/k3s" > /tmp/k3s.sha256
|
||||
if ! sha256sum -c /tmp/k3s.sha256; then
|
||||
echo "[eks-agent-setup] k3s SHA256 mismatch — refusing to bake AMI"
|
||||
exit 1
|
||||
fi
|
||||
chmod 0755 /usr/local/bin/k3s
|
||||
ln -sf /usr/local/bin/k3s /usr/local/bin/kubectl
|
||||
ln -sf /usr/local/bin/k3s /usr/local/bin/crictl
|
||||
ln -sf /usr/local/bin/k3s /usr/local/bin/ctr
|
||||
|
||||
# nats-cli — see eks-server setup.sh for rationale.
|
||||
NATS_CLI_VERSION="0.4.0"
|
||||
NATS_CLI_URL="https://github.com/nats-io/natscli/releases/download/v${NATS_CLI_VERSION}/nats-${NATS_CLI_VERSION}-linux-amd64.zip"
|
||||
echo "[eks-agent-setup] downloading nats-cli ${NATS_CLI_VERSION}"
|
||||
curl -fsSL -o /tmp/nats-cli.zip "${NATS_CLI_URL}"
|
||||
apk add --no-cache unzip
|
||||
unzip -q -d /tmp/nats-cli /tmp/nats-cli.zip
|
||||
install -m 0755 /tmp/nats-cli/nats-${NATS_CLI_VERSION}-linux-amd64/nats /usr/local/bin/nats
|
||||
rm -rf /tmp/nats-cli /tmp/nats-cli.zip
|
||||
apk del --no-cache unzip
|
||||
|
||||
chmod 0755 /etc/init.d/k3s-agent
|
||||
chmod 0755 /etc/periodic/15min/mulga-eks-state-report
|
||||
|
||||
mkdir -p /etc/spinifex-eks
|
||||
echo "[eks-agent-setup] done"
|
||||
Executable
+27
@@ -0,0 +1,27 @@
|
||||
#!/sbin/openrc-run
|
||||
# eks-node-role — one-shot first-boot service that selects the node's role
|
||||
# (server vs agent) from the cloud-init env and enables + starts the matching
|
||||
# K3s services. The unified eks-node AMI bakes every role's services but leaves
|
||||
# them out of the default runlevel; this selector adds the correct ones and then
|
||||
# removes itself so it runs exactly once.
|
||||
|
||||
description="EKS node role selector (server vs agent)"
|
||||
# Backgrounded: the selector calls `rc-service k3s start`, which blocks on k3s'
|
||||
# start-stop-daemon --wait window; backgrounding keeps it off the runlevel's
|
||||
# critical path. command_background is also required for output_log/error_log.
|
||||
command="/usr/local/sbin/eks-node-role"
|
||||
command_background="yes"
|
||||
pidfile="/run/eks-node-role.pid"
|
||||
# Route to the serial console so the role decision + any failure is visible in
|
||||
# the orchestrator's host-side console capture — the VM has no operator login.
|
||||
output_log="/dev/console"
|
||||
error_log="/dev/console"
|
||||
|
||||
depend() {
|
||||
# Needs the same substrate the role services do, since it starts them.
|
||||
need net cgroups
|
||||
# cloud-init write_files delivers /etc/spinifex-eks/first-boot.env (the role
|
||||
# source) in the cloud-config/cloud-final stages. Order after them so the
|
||||
# selector never races ahead and reads a missing env file on a fast boot.
|
||||
after firewall cloud-config cloud-final
|
||||
}
|
||||
Executable
+71
@@ -0,0 +1,71 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
# eks-node-role — first-boot role selector for the unified eks-node AMI.
|
||||
#
|
||||
# The same image boots either as a K3s control-plane server or a worker agent;
|
||||
# the role is chosen per-instance at first boot from SPINIFEX_K3S_ROLE in the
|
||||
# cloud-init env file. This script enables + starts the matching OpenRC services
|
||||
# (none are in the default runlevel at bake time), records the resolved role to
|
||||
# ROLE_FILE so later boots and the etcd-snapshot cron can branch without
|
||||
# re-parsing, then removes itself from the runlevel so it runs exactly once.
|
||||
#
|
||||
# server → eks-token-webhook, k3s (server), k3s-first-boot (bootstrap publish)
|
||||
# agent → k3s-agent
|
||||
#
|
||||
# Paths are overridable via env so the selection logic is unit-testable (bats)
|
||||
# without root or a real /etc.
|
||||
|
||||
ROLE_FILE="${EKS_NODE_ROLE_FILE:-/etc/spinifex-eks/role}"
|
||||
ENVFILE="${EKS_NODE_ENVFILE:-/etc/spinifex-eks/first-boot.env}"
|
||||
AGENT_ENVFILE="${EKS_NODE_AGENT_ENVFILE:-/etc/spinifex-eks/agent.env}"
|
||||
LOGTAG="eks-node-role"
|
||||
|
||||
log() { echo "[${LOGTAG}] $*"; }
|
||||
die() { log "ERROR: $*"; exit 1; }
|
||||
|
||||
# Already resolved on a prior boot — the role services are in the runlevel and
|
||||
# this service should have been pulled, but guard anyway so a stray re-run is a
|
||||
# no-op rather than a double-enable.
|
||||
if [ -f "${ROLE_FILE}" ]; then
|
||||
log "role already resolved ($(cat "${ROLE_FILE}")); nothing to do"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Resolve the role. Server seeds SPINIFEX_K3S_ROLE in first-boot.env; a worker
|
||||
# seed that predates the explicit var is inferred from the presence of agent.env
|
||||
# (K3S_URL/K3S_TOKEN live there).
|
||||
ROLE=""
|
||||
if [ -f "${ENVFILE}" ]; then
|
||||
ROLE=$(. "${ENVFILE}"; printf '%s' "${SPINIFEX_K3S_ROLE:-}")
|
||||
fi
|
||||
if [ -z "${ROLE}" ] && [ -f "${AGENT_ENVFILE}" ]; then
|
||||
ROLE="agent"
|
||||
fi
|
||||
|
||||
case "${ROLE}" in
|
||||
server)
|
||||
log "configuring server role"
|
||||
rc-update add eks-token-webhook default
|
||||
rc-update add k3s default
|
||||
rc-update add k3s-first-boot default
|
||||
rc-update add mulga-eks-state-report default
|
||||
rc-service eks-token-webhook start
|
||||
rc-service k3s start
|
||||
rc-service k3s-first-boot start
|
||||
rc-service mulga-eks-state-report start
|
||||
;;
|
||||
agent)
|
||||
log "configuring agent role"
|
||||
rc-update add k3s-agent default
|
||||
rc-service k3s-agent start
|
||||
;;
|
||||
*)
|
||||
die "SPINIFEX_K3S_ROLE missing or invalid: '${ROLE}'"
|
||||
;;
|
||||
esac
|
||||
|
||||
mkdir -p "$(dirname "${ROLE_FILE}")"
|
||||
printf '%s\n' "${ROLE}" > "${ROLE_FILE}"
|
||||
rc-update del eks-node-role default 2>/dev/null || true
|
||||
log "role '${ROLE}' configured"
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
#!/bin/sh
|
||||
# Self-contained POSIX test for eks-node-role.sh role selection. No bats/root
|
||||
# needed: rc-update / rc-service are stubbed on PATH and record their calls; the
|
||||
# script's file paths are redirected into a temp dir via its EKS_NODE_* env knobs.
|
||||
#
|
||||
# Run: sh scripts/images/eks-node/eks-node-role_test.sh
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
|
||||
SELECTOR="${SCRIPT_DIR}/eks-node-role.sh"
|
||||
WORK=$(mktemp -d)
|
||||
trap 'rm -rf "${WORK}"' EXIT
|
||||
|
||||
# Stub rc-update / rc-service: append "cmd args" to ${CALLS} when invoked.
|
||||
STUBBIN="${WORK}/bin"
|
||||
mkdir -p "${STUBBIN}"
|
||||
for tool in rc-update rc-service; do
|
||||
cat > "${STUBBIN}/${tool}" <<EOF
|
||||
#!/bin/sh
|
||||
echo "${tool} \$*" >> "\${CALLS}"
|
||||
exit 0
|
||||
EOF
|
||||
chmod +x "${STUBBIN}/${tool}"
|
||||
done
|
||||
|
||||
PASS=0
|
||||
FAIL=0
|
||||
check() { # check <desc> <expected> <actual>
|
||||
if [ "$2" = "$3" ]; then
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
FAIL=$((FAIL + 1))
|
||||
echo "FAIL: $1"
|
||||
echo " expected: [$2]"
|
||||
echo " actual: [$3]"
|
||||
fi
|
||||
}
|
||||
|
||||
# run_case <name>: resets a per-case dir, exports the knobs; caller seeds env
|
||||
# files then invokes the selector. Sets CASE (dir), CALLS (log), ROLE_FILE.
|
||||
run_case() {
|
||||
CASE="${WORK}/$1"
|
||||
mkdir -p "${CASE}"
|
||||
CALLS="${CASE}/calls"
|
||||
: > "${CALLS}"
|
||||
ROLE_FILE="${CASE}/role"
|
||||
export CALLS
|
||||
export EKS_NODE_ROLE_FILE="${ROLE_FILE}"
|
||||
export EKS_NODE_ENVFILE="${CASE}/first-boot.env"
|
||||
export EKS_NODE_AGENT_ENVFILE="${CASE}/agent.env"
|
||||
}
|
||||
|
||||
# Run the selector, capturing its exit code in ${rc} without tripping set -e.
|
||||
invoke() { rc=0; PATH="${STUBBIN}:${PATH}" sh "${SELECTOR}" >/dev/null 2>&1 || rc=$?; }
|
||||
|
||||
# --- Case 1: explicit server role ---
|
||||
run_case server
|
||||
printf 'SPINIFEX_K3S_ROLE=server\nEKS_CLUSTER_NAME=alpha\n' > "${EKS_NODE_ENVFILE}"
|
||||
invoke
|
||||
check "server: exit 0" 0 "${rc}"
|
||||
check "server: role file" "server" "$(cat "${ROLE_FILE}")"
|
||||
check "server: enables webhook" "yes" "$(grep -q 'rc-update add eks-token-webhook default' "${CALLS}" && echo yes || echo no)"
|
||||
check "server: enables k3s" "yes" "$(grep -q 'rc-update add k3s default' "${CALLS}" && echo yes || echo no)"
|
||||
check "server: enables k3s-first-boot" "yes" "$(grep -q 'rc-update add k3s-first-boot default' "${CALLS}" && echo yes || echo no)"
|
||||
check "server: starts k3s" "yes" "$(grep -q 'rc-service k3s start' "${CALLS}" && echo yes || echo no)"
|
||||
check "server: no k3s-agent" "no" "$(grep -q 'k3s-agent' "${CALLS}" && echo yes || echo no)"
|
||||
check "server: self-disables" "yes" "$(grep -q 'rc-update del eks-node-role default' "${CALLS}" && echo yes || echo no)"
|
||||
|
||||
# --- Case 2: explicit agent role ---
|
||||
run_case agent_explicit
|
||||
printf 'SPINIFEX_K3S_ROLE=agent\n' > "${EKS_NODE_ENVFILE}"
|
||||
invoke
|
||||
check "agent: exit 0" 0 "${rc}"
|
||||
check "agent: role file" "agent" "$(cat "${ROLE_FILE}")"
|
||||
check "agent: enables k3s-agent" "yes" "$(grep -q 'rc-update add k3s-agent default' "${CALLS}" && echo yes || echo no)"
|
||||
check "agent: no server services" "no" "$(grep -qE 'eks-token-webhook|k3s-first-boot|add k3s default' "${CALLS}" && echo yes || echo no)"
|
||||
|
||||
# --- Case 3: agent inferred from agent.env (no explicit role) ---
|
||||
run_case agent_inferred
|
||||
printf 'K3S_URL=https://nlb:443\nK3S_TOKEN=abc\n' > "${EKS_NODE_AGENT_ENVFILE}"
|
||||
invoke
|
||||
check "infer: exit 0" 0 "${rc}"
|
||||
check "infer: role file" "agent" "$(cat "${ROLE_FILE}")"
|
||||
check "infer: enables k3s-agent" "yes" "$(grep -q 'rc-update add k3s-agent default' "${CALLS}" && echo yes || echo no)"
|
||||
|
||||
# --- Case 4: missing role + no env files → fail, no role file, no services ---
|
||||
run_case missing
|
||||
invoke
|
||||
check "missing: nonzero exit" "1" "${rc}"
|
||||
check "missing: no role file" "absent" "$([ -f "${ROLE_FILE}" ] && echo present || echo absent)"
|
||||
check "missing: no rc calls" "" "$(cat "${CALLS}")"
|
||||
|
||||
# --- Case 5: already resolved (role file present) → no-op, exit 0 ---
|
||||
run_case resolved
|
||||
printf 'SPINIFEX_K3S_ROLE=server\n' > "${EKS_NODE_ENVFILE}"
|
||||
printf 'server\n' > "${ROLE_FILE}"
|
||||
invoke
|
||||
check "resolved: exit 0" 0 "${rc}"
|
||||
check "resolved: no rc calls" "" "$(cat "${CALLS}")"
|
||||
|
||||
echo "---"
|
||||
echo "PASS=${PASS} FAIL=${FAIL}"
|
||||
[ "${FAIL}" -eq 0 ]
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
#!/sbin/openrc-run
|
||||
# eks-token-webhook — K8s TokenReview authenticator for IAM-backed kubectl.
|
||||
# Binds 127.0.0.1:8443 only; kube-apiserver is the sole client (loopback path).
|
||||
# Relays `aws eks get-token` bearer tokens through the AWS gateway broker (SigV4
|
||||
# HTTPS), which host-side verifies via Mulga STS + resolves the principal to a
|
||||
# TokenReview via the cluster AccessEntry KV — the VM never speaks core NATS.
|
||||
# Ordered `before k3s` so its apiserver kubeconfig exists before k3s reads
|
||||
# --authentication-token-webhook-config-file.
|
||||
|
||||
description="EKS Token Review webhook (Mulga)"
|
||||
command="/usr/local/bin/eks-token-webhook"
|
||||
command_args="--addr=127.0.0.1:8443"
|
||||
command_background="yes"
|
||||
pidfile="/run/eks-token-webhook.pid"
|
||||
# Headless appliance: no SSH. Route the webhook's stdout/stderr to the serial
|
||||
# console so its startup + per-request decisions land in the host-captured
|
||||
# console log (the only off-VM window into webhook health).
|
||||
output_log="/dev/console"
|
||||
error_log="/dev/console"
|
||||
|
||||
# Gateway endpoint + SigV4 creds + cluster identity come from the cloud-init-
|
||||
# seeded first-boot env. Sourced here and exported so start-stop-daemon hands
|
||||
# them to the binary, which reads EKS_GATEWAY_* / EKS_ACCESS_KEY / EKS_SECRET_KEY
|
||||
# / EKS_REGION / EKS_ACCOUNT_ID / EKS_CLUSTER_NAME from its environment.
|
||||
if [ -f /etc/spinifex-eks/first-boot.env ]; then
|
||||
# shellcheck disable=SC1091
|
||||
. /etc/spinifex-eks/first-boot.env
|
||||
export EKS_GATEWAY_URL EKS_GATEWAY_CA EKS_ACCESS_KEY EKS_SECRET_KEY EKS_REGION EKS_ACCOUNT_ID EKS_CLUSTER_NAME
|
||||
fi
|
||||
|
||||
depend() {
|
||||
need net
|
||||
before k3s
|
||||
}
|
||||
|
||||
start_pre() {
|
||||
if [ ! -f /etc/spinifex-eks/first-boot.env ]; then
|
||||
eerror "/etc/spinifex-eks/first-boot.env not present — cloud-init did not seed gateway creds"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
Regular → Executable
Executable
+30
@@ -0,0 +1,30 @@
|
||||
#!/sbin/openrc-run
|
||||
# k3s-first-boot — one-shot OpenRC service that runs the first-boot publisher
|
||||
# after K3s is up. Self-disables by removing itself from the default runlevel
|
||||
# once the sentinel file is gone.
|
||||
|
||||
description="K3s first-boot publisher (node-token + admin kubeconfig)"
|
||||
# Backgrounded: the publisher waits up to 5min for K3s /healthz before it can
|
||||
# read the node-token + kubeconfig, so it must not block the default runlevel.
|
||||
# command_background is also required for output_log/error_log — start-stop-daemon
|
||||
# rejects --stdout/--stderr without --background ("Failed to start k3s-first-boot").
|
||||
command="/usr/local/sbin/k3s-first-boot"
|
||||
command_background="yes"
|
||||
pidfile="/run/k3s-first-boot.pid"
|
||||
# Route to the serial console so the bootstrap publisher's progress (healthz
|
||||
# wait, token/kubeconfig publish, errors) is visible in the orchestrator's
|
||||
# host-side console capture — the VM has no operator login. Low volume.
|
||||
output_log="/dev/console"
|
||||
error_log="/dev/console"
|
||||
|
||||
depend() {
|
||||
need k3s
|
||||
after k3s
|
||||
}
|
||||
|
||||
start_pre() {
|
||||
if [ ! -f /var/lib/spinifex-eks/first-boot.pending ]; then
|
||||
einfo "first-boot already complete; nothing to do"
|
||||
return 0
|
||||
fi
|
||||
}
|
||||
Executable
+139
@@ -0,0 +1,139 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
# k3s-first-boot — runs once after the K3s server reaches a healthy state.
|
||||
# Reads the bootstrap node-token and admin kubeconfig that K3s writes at
|
||||
# server startup, rewrites the kubeconfig server address to the cluster's
|
||||
# NLB endpoint (so workers and external kubectl can use it), and publishes
|
||||
# the bootstrap artifacts to the host via the AWS gateway (SigV4-signed HTTPS
|
||||
# POST — the eks-gateway-publish helper) for the spinifex cluster reconciler
|
||||
# to consume into KV. The gateway relays onto the eks.bus.* NATS subjects; the
|
||||
# VM never speaks NATS directly.
|
||||
#
|
||||
# Required env (from cloud-init user-data /etc/spinifex-eks/first-boot.env):
|
||||
# EKS_GATEWAY_URL https://{mgmt-host}:9999 (AWS gateway)
|
||||
# EKS_GATEWAY_CA /etc/spinifex-eks/gateway-ca.pem (TLS CA bundle)
|
||||
# EKS_ACCESS_KEY system SigV4 access key id
|
||||
# EKS_SECRET_KEY system SigV4 secret access key
|
||||
# EKS_REGION SigV4 signing region
|
||||
# EKS_ACCOUNT_ID
|
||||
# EKS_CLUSTER_NAME
|
||||
# EKS_NLB_ENDPOINT https://{cluster}.{accountID}.eks.{region}.{suffix}
|
||||
#
|
||||
# Idempotent: a sentinel file at /var/lib/spinifex-eks/first-boot.pending gates
|
||||
# execution. On success the sentinel is removed and the OpenRC service is
|
||||
# pulled from the default runlevel so it does not retry on subsequent boots.
|
||||
|
||||
SENTINEL=/var/lib/spinifex-eks/first-boot.pending
|
||||
ENVFILE=/etc/spinifex-eks/first-boot.env
|
||||
LOGTAG="k3s-first-boot"
|
||||
|
||||
log() { echo "[${LOGTAG}] $*"; }
|
||||
die() { log "ERROR: $*"; exit 1; }
|
||||
|
||||
if [ ! -f "${SENTINEL}" ]; then
|
||||
log "sentinel missing — first boot already complete"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ ! -f "${ENVFILE}" ]; then
|
||||
die "${ENVFILE} not found — cloud-init did not seed first-boot env"
|
||||
fi
|
||||
|
||||
# set -a so the sourced KEY=value lines are exported: the eks-gateway-publish
|
||||
# child reads its config from the environment (EKS_ACCOUNT_ID etc.). A bare
|
||||
# source leaves them unexported and the helper exits "--account-id is required".
|
||||
set -a
|
||||
# shellcheck disable=SC1090
|
||||
. "${ENVFILE}"
|
||||
set +a
|
||||
|
||||
for v in EKS_GATEWAY_URL EKS_ACCESS_KEY EKS_SECRET_KEY EKS_ACCOUNT_ID EKS_CLUSTER_NAME EKS_NLB_ENDPOINT; do
|
||||
eval "val=\${$v:-}"
|
||||
[ -n "${val}" ] || die "env ${v} not set"
|
||||
done
|
||||
|
||||
# Paths K3s writes during server startup: the bootstrap node-token and the
|
||||
# admin kubeconfig (used both to gate readiness and to publish downstream).
|
||||
TOKEN_FILE=/var/lib/rancher/k3s/server/node-token
|
||||
KUBECONFIG_FILE=/etc/rancher/k3s/k3s.yaml
|
||||
|
||||
# 1. Wait for the apiserver to be ready to serve, gating on /readyz. K3s runs
|
||||
# the apiserver with anonymous-auth=false, so an anonymous probe returns 401,
|
||||
# never "ok" — the probe must be authenticated. Use the node's admin
|
||||
# kubeconfig (kubectl get --raw), which k3s writes early in startup. kubectl
|
||||
# exits 0 only when /readyz returns 200; on a failing sub-check it exits
|
||||
# non-zero, so the body "ok" check plus exit status gates correctly. Every
|
||||
# 30s the failing /readyz checks are logged so a stuck boot is diagnosable
|
||||
# from the captured serial console.
|
||||
log "waiting for K3s apiserver readiness (/readyz) on 127.0.0.1:6443"
|
||||
i=0
|
||||
ready=0
|
||||
while [ "${i}" -lt 300 ]; do
|
||||
if [ -r "${KUBECONFIG_FILE}" ] && \
|
||||
[ "$(kubectl --kubeconfig "${KUBECONFIG_FILE}" get --raw='/readyz' 2>/dev/null)" = "ok" ]; then
|
||||
log "K3s apiserver ready after ${i}s"
|
||||
ready=1
|
||||
break
|
||||
fi
|
||||
if [ $((i % 30)) -eq 0 ]; then
|
||||
log "apiserver not ready after ${i}s:"
|
||||
if [ -r "${KUBECONFIG_FILE}" ]; then
|
||||
kubectl --kubeconfig "${KUBECONFIG_FILE}" get --raw='/readyz?verbose' 2>&1 \
|
||||
| grep -E '^\[-\]|failed$' | head -20 | while IFS= read -r l; do log " ${l}"; done
|
||||
else
|
||||
log " (admin kubeconfig ${KUBECONFIG_FILE} not written yet)"
|
||||
fi
|
||||
fi
|
||||
i=$((i + 5))
|
||||
sleep 5
|
||||
done
|
||||
if [ "${ready}" -ne 1 ]; then
|
||||
log "K3s apiserver not ready within 5 minutes; last /readyz body:"
|
||||
kubectl --kubeconfig "${KUBECONFIG_FILE}" get --raw='/readyz?verbose' 2>&1 \
|
||||
| head -40 | while IFS= read -r l; do log " ${l}"; done
|
||||
log "last 40 lines of /var/log/k3s.log follow:"
|
||||
tail -n 40 /var/log/k3s.log 2>/dev/null || log "(no /var/log/k3s.log)"
|
||||
die "K3s apiserver not ready within 5 minutes"
|
||||
fi
|
||||
|
||||
# 2. Read the four bootstrap artifacts K3s wrote at server startup.
|
||||
[ -r "${TOKEN_FILE}" ] || die "${TOKEN_FILE} unreadable"
|
||||
[ -r "${KUBECONFIG_FILE}" ] || die "${KUBECONFIG_FILE} unreadable"
|
||||
|
||||
NODE_TOKEN=$(cat "${TOKEN_FILE}")
|
||||
# K3s ships kubeconfig with server: https://127.0.0.1:6443 — rewrite to the
|
||||
# NLB endpoint so it works from outside the control plane VM.
|
||||
KUBECONFIG_REWRITTEN=$(sed "s|server: https://127\.0\.0\.1:6443|server: ${EKS_NLB_ENDPOINT}|" "${KUBECONFIG_FILE}")
|
||||
# The cluster CA the daemon records (base64 PEM) is exactly the
|
||||
# certificate-authority-data the admin kubeconfig already embeds.
|
||||
CA_B64=$(awk '/certificate-authority-data:/ {print $2; exit}' "${KUBECONFIG_FILE}")
|
||||
[ -n "${CA_B64}" ] || die "no certificate-authority-data in ${KUBECONFIG_FILE}"
|
||||
# The OIDC JWKS the apiserver serves from the signing key cloud-init seeded. The
|
||||
# daemon cross-checks its kid/kty against the controller-generated keypair.
|
||||
JWKS=$(kubectl --kubeconfig "${KUBECONFIG_FILE}" get --raw='/openid/v1/jwks' 2>/dev/null)
|
||||
[ -n "${JWKS}" ] || die "apiserver returned empty /openid/v1/jwks"
|
||||
|
||||
# 3. Publish the four bootstrap messages through the AWS gateway. Each is a
|
||||
# BootstrapEnvelope JSON document (handlers/eks/nats_bootstrap.go); jq encodes
|
||||
# the values so embedded newlines/quotes in the kubeconfig and JWKS stay valid
|
||||
# JSON. eks-gateway-publish SigV4-signs the POST and retries until the gateway
|
||||
# acks, then the gateway relays onto eks.bus.{account}.{cluster}.{kind}. It
|
||||
# reads EKS_GATEWAY_URL/EKS_GATEWAY_CA/EKS_ACCESS_KEY/EKS_SECRET_KEY/EKS_REGION/
|
||||
# EKS_ACCOUNT_ID/EKS_CLUSTER_NAME from the env file already sourced above.
|
||||
|
||||
# publish_envelope <kind-suffix>: reads the envelope JSON from stdin.
|
||||
publish_envelope() {
|
||||
log "publishing $1 via gateway"
|
||||
eks-gateway-publish -channel bootstrap -kind "$1"
|
||||
}
|
||||
|
||||
jq -n --arg t "${NODE_TOKEN}" '{token: $t}' | publish_envelope k3s-bootstrap-token
|
||||
jq -n --arg k "${KUBECONFIG_REWRITTEN}" '{adminKubeconfig: $k}' | publish_envelope k3s-admin-kubeconfig
|
||||
jq -n --arg j "${JWKS}" '{jwks: $j}' | publish_envelope k3s-oidc-jwks
|
||||
jq -n --arg c "${CA_B64}" '{certificateAuthority: $c}' | publish_envelope k3s-ca
|
||||
|
||||
# 4. Self-disable. Remove sentinel, pull from runlevel.
|
||||
rm -f "${SENTINEL}"
|
||||
rc-update del k3s-first-boot default 2>/dev/null || true
|
||||
log "first boot complete"
|
||||
Executable
+65
@@ -0,0 +1,65 @@
|
||||
#!/sbin/openrc-run
|
||||
# K3s server control plane. The per-cluster config.yaml sets cluster-init, so
|
||||
# k3s uses the embedded etcd datastore (required for multi-server HA). Data
|
||||
# lives at /var/lib/rancher/k3s; subsequent starts reuse it.
|
||||
|
||||
description="K3s Kubernetes server (Mulga EKS control plane)"
|
||||
command="/usr/local/bin/k3s"
|
||||
command_args="server --config /etc/rancher/k3s/config.yaml"
|
||||
pidfile="/run/k3s.pid"
|
||||
|
||||
# Supervise the apiserver. A transient k3s crash must self-heal: without a
|
||||
# supervisor a single exit leaves the control plane permanently dead, the NLB
|
||||
# target never recovers, and the cluster wedges ACTIVE-but-unreachable.
|
||||
supervisor="supervise-daemon"
|
||||
respawn_delay=5
|
||||
respawn_max=0
|
||||
output_log="/var/log/k3s.log"
|
||||
error_log="/var/log/k3s.log"
|
||||
|
||||
depend() {
|
||||
need net cgroups
|
||||
after firewall
|
||||
}
|
||||
|
||||
start_pre() {
|
||||
# config.yaml is written by k3s-first-boot from the skeleton + cloud-init
|
||||
# data. Refuse to start if neither exists.
|
||||
if [ ! -f /etc/rancher/k3s/config.yaml ]; then
|
||||
if [ -f /etc/rancher/k3s/config.yaml.skel ]; then
|
||||
cp /etc/rancher/k3s/config.yaml.skel /etc/rancher/k3s/config.yaml
|
||||
else
|
||||
eerror "no /etc/rancher/k3s/config.yaml and no .skel to derive from"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Deferred built-in ingress: when this cluster wants traefik
|
||||
# (EKS_DEFER_TRAEFIK=1) but the control plane has not yet stabilised (no
|
||||
# traefik-enabled sentinel), stage a deploy-controller .skip marker so k3s
|
||||
# writes traefik.yaml/traefik-crd.yaml but does NOT apply them. Staged here,
|
||||
# pre-start, so the marker exists before the deploy controller first scans —
|
||||
# racing it post-start could apply traefik (and crash etcd) anyway.
|
||||
# mulga-eks-state-report removes the marker once the apiserver is healthy.
|
||||
if [ -f /etc/spinifex-eks/first-boot.env ]; then
|
||||
defer=$(. /etc/spinifex-eks/first-boot.env 2>/dev/null; echo "${EKS_DEFER_TRAEFIK:-0}")
|
||||
if [ "${defer}" = "1" ] && [ ! -f /var/lib/spinifex-eks/traefik-enabled ]; then
|
||||
mkdir -p /var/lib/rancher/k3s/server/manifests
|
||||
: > /var/lib/rancher/k3s/server/manifests/traefik.yaml.skip
|
||||
: > /var/lib/rancher/k3s/server/manifests/traefik-crd.yaml.skip
|
||||
fi
|
||||
fi
|
||||
|
||||
# The apiserver is configured with --authentication-token-webhook-config-file
|
||||
# pointing at the kubeconfig eks-token-webhook writes at startup. `before k3s`
|
||||
# orders the webhook's start but it backgrounds, so wait (bounded) for the
|
||||
# kubeconfig to land before letting the apiserver load a missing file.
|
||||
i=0
|
||||
while [ ! -f /etc/spinifex-eks/token-webhook.kubeconfig ] && [ "${i}" -lt 30 ]; do
|
||||
sleep 1
|
||||
i=$((i + 1))
|
||||
done
|
||||
if [ ! -f /etc/spinifex-eks/token-webhook.kubeconfig ]; then
|
||||
ewarn "eks-token-webhook kubeconfig absent after ${i}s; IAM token auth will be unavailable until the webhook starts"
|
||||
fi
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
IMAGE_NAME="eks-node"
|
||||
IMAGE_DESCRIPTION="Unified EKS K3s node VM — Alpine + K3s v1.32.5+k3s1 + eks-token-webhook; role (server|agent) chosen at first boot"
|
||||
DISTRO="alpine"
|
||||
ALPINE_VERSION="3.21.7"
|
||||
|
||||
# 4G — server superset (K3s server binary + embedded-etcd datastore working set under
|
||||
# /var/lib/rancher/k3s on the root disk). Agents use the same image; the ~10MB
|
||||
# of unused server binary + webhook is dwarfed by the headroom for node images.
|
||||
IMAGE_SIZE="4G"
|
||||
|
||||
# Server superset of both package sets (etcd-ctl is ~5MB, not worth branching).
|
||||
# K3s pulls cgroups + iptables; jq builds the bootstrap-message JSON envelopes;
|
||||
# etcd-client used by the snapshot script (server role only). The control plane
|
||||
# reaches the host via the AWS gateway (eks-gateway-publish), not a NATS CLI.
|
||||
APK_PACKAGES="ca-certificates curl jq iptables ip6tables conntrack-tools cgroup-tools etcd-ctl openrc"
|
||||
|
||||
# eks-token-webhook binary always installed (agents never start it).
|
||||
# eks-gateway-publish relays the bootstrap envelopes + state reports to the AWS
|
||||
# gateway over SigV4 HTTPS (server role only). The k3s binary is downloaded +
|
||||
# SHA256-verified by setup.sh (network in chroot).
|
||||
BUILD_COMMANDS="CGO_ENABLED=0 GOFIPS140=v1.0.0 go build -o bin/eks-token-webhook ./cmd/eks-token-webhook && CGO_ENABLED=0 GOFIPS140=v1.0.0 go build -o bin/eks-gateway-publish ./cmd/eks-gateway-publish"
|
||||
|
||||
INSTALL_BINARIES="bin/eks-token-webhook:/usr/local/bin/eks-token-webhook bin/eks-gateway-publish:/usr/local/bin/eks-gateway-publish"
|
||||
|
||||
# All role services ship; none but the selector are enabled at bake time.
|
||||
# eks-node-role picks server vs agent at first boot and enables the rest.
|
||||
INSTALL_FILES="\
|
||||
scripts/images/eks-node/eks-node-role.initd:/etc/init.d/eks-node-role \
|
||||
scripts/images/eks-node/eks-node-role.sh:/usr/local/sbin/eks-node-role \
|
||||
scripts/images/eks-node/k3s.initd:/etc/init.d/k3s \
|
||||
scripts/images/eks-node/k3s-agent.initd:/etc/init.d/k3s-agent \
|
||||
scripts/images/eks-node/eks-token-webhook.initd:/etc/init.d/eks-token-webhook \
|
||||
scripts/images/eks-node/k3s-first-boot.initd:/etc/init.d/k3s-first-boot \
|
||||
scripts/images/eks-node/k3s-first-boot.sh:/usr/local/sbin/k3s-first-boot \
|
||||
scripts/images/eks-node/mulga-eks-state-report.initd:/etc/init.d/mulga-eks-state-report \
|
||||
scripts/images/eks-node/mulga-eks-state-report.sh:/usr/local/sbin/mulga-eks-state-report \
|
||||
scripts/images/eks-node/mulga-eks-etcd-snapshot.sh:/etc/periodic/daily/mulga-eks-etcd-snapshot"
|
||||
|
||||
# Only crond (etcd snapshot) + the role selector at bake. The selector adds the
|
||||
# role-specific services (k3s/eks-token-webhook/k3s-first-boot/state-report or
|
||||
# k3s-agent) to the default runlevel on first boot.
|
||||
ENABLE_SERVICES="crond eks-node-role"
|
||||
|
||||
SETUP_SCRIPT="scripts/images/eks-node/setup.sh"
|
||||
|
||||
AMI_NAME="spinifex-eks-node"
|
||||
SYSTEM_TAG="spinifex:managed-by=eks"
|
||||
Regular → Executable
+6
@@ -12,6 +12,12 @@ set -eu
|
||||
# Retention: managed via S3 lifecycle policy on the bucket; this script does
|
||||
# not prune.
|
||||
|
||||
# Worker nodes share the unified eks-node AMI but have no etcd/SQLite datastore
|
||||
# to snapshot. The role file is written once by eks-node-role at first boot.
|
||||
if [ "$(cat /etc/spinifex-eks/role 2>/dev/null)" = "agent" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
ENVFILE=/etc/spinifex-eks/etcd-snapshot.env
|
||||
[ -f "${ENVFILE}" ] || { logger -t mulga-eks-etcd-snapshot "${ENVFILE} missing"; exit 0; }
|
||||
# shellcheck disable=SC1090
|
||||
@@ -0,0 +1,29 @@
|
||||
#!/sbin/openrc-run
|
||||
# mulga-eks-state-report — server-role service that publishes K3s health +
|
||||
# node_count to the management NATS bus every STATE_REPORT_INTERVAL seconds. The
|
||||
# spinifex cluster reconciler consumes this to gate CREATING → ACTIVE and to
|
||||
# track nodegroup join/scale, since the apiserver (bound to the VPC node-ip) is
|
||||
# unreachable from the host. Enabled on the server role by eks-node-role.sh.
|
||||
|
||||
description="Mulga EKS control-plane state reporter"
|
||||
command="/usr/local/sbin/mulga-eks-state-report"
|
||||
command_background="yes"
|
||||
pidfile="/run/mulga-eks-state-report.pid"
|
||||
output_log="/var/log/mulga-eks-state-report.log"
|
||||
error_log="/var/log/mulga-eks-state-report.log"
|
||||
|
||||
# Cadence the report loop sleeps between publishes. 30s is well under the
|
||||
# reconciler's 90s stale window, so a missed tick does not flap health.
|
||||
export STATE_REPORT_INTERVAL="${STATE_REPORT_INTERVAL:-30}"
|
||||
|
||||
depend() {
|
||||
need net
|
||||
after k3s
|
||||
}
|
||||
|
||||
start_pre() {
|
||||
if [ ! -f /etc/spinifex-eks/first-boot.env ]; then
|
||||
eerror "/etc/spinifex-eks/first-boot.env not present — cloud-init did not seed NATS creds"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user