Compare commits
304 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 495587304b | |||
| 01d9fa858f | |||
| 4ae2a4ac53 | |||
| 35ad76096b | |||
| 7eff142c34 | |||
| 5b3ced92f9 | |||
| ee986fefe6 | |||
| 7b755227d5 | |||
| f667e2e0a6 | |||
| de3ef1bb96 | |||
| 970b3bb58d | |||
| d3290be6e7 | |||
| 08935ff7e7 | |||
| b7a932d6b0 | |||
| beb84b4936 | |||
| 3037c349a1 | |||
| 7042f25861 | |||
| fba2103133 | |||
| 9fd89ec1ad | |||
| 58c2e18ad1 | |||
| 0dc0daf116 | |||
| 26c2c9fa49 | |||
| f7efc69344 | |||
| 8841241541 | |||
| 7859d9610b | |||
| 7d25b04532 | |||
| 7dd3af3848 | |||
| 54a1c29ed5 | |||
| 89ddd06846 | |||
| 4bf7a73feb | |||
| cc157e97f9 | |||
| fa5919fc14 | |||
| f8d025d07e | |||
| 38373b2fc7 | |||
| e615d6f9e6 | |||
| 97701737cb | |||
| a31fd8098c | |||
| c19f823d1e | |||
| 7c08c62e0d | |||
| a08d9b8b99 | |||
| 8d1258e4b7 | |||
| e80f1579d7 | |||
| 64b636cf5a | |||
| 43fa6eae59 | |||
| 352eb164c1 | |||
| 4e5d7b0a13 | |||
| 32ae9d190e | |||
| aaa208b594 | |||
| 85ff73ba9d | |||
| 4567f258ae | |||
| fe59017cbb | |||
| 50eea38066 | |||
| 45555dde49 | |||
| 23c26d8202 | |||
| 069e6d411a | |||
| dc3b48f17e | |||
| bb08335831 | |||
| f9dddd3c9c | |||
| 8b17ce4060 | |||
| f49923ef10 | |||
| dbb153bd42 | |||
| 13cf15b746 | |||
| 48d7ce8a45 | |||
| f6ddd602b9 | |||
| 13d9fb494a | |||
| 6c54a6790c | |||
| 7493f087d1 | |||
| 51f7afab4c | |||
| 0aa53f78ea | |||
| fc0cc85731 | |||
| 6fa82c470d | |||
| 1c3583651d | |||
| f8ce8de566 | |||
| b26bb8d35d | |||
| 62ab74088e | |||
| 6f3e901b9f | |||
| f44db4d84e | |||
| 5ea7df6e7c | |||
| 94405effec | |||
| 9ca8b84bea | |||
| e9aec9e784 | |||
| 8c6d0747e0 | |||
| 374f2ed7a3 | |||
| 46d7a875ab | |||
| 82d1c7aeee | |||
| 9640cca480 | |||
| 29a192766c | |||
| 5f8ee0b5a2 | |||
| a753ff99dd | |||
| 7c860c99ca | |||
| 5e70ebd63f | |||
| a8591380cb | |||
| fefe506042 | |||
| 7ee21e0f25 | |||
| 3eff6ccff2 | |||
| 5b9a35914e | |||
| 14b8fc234a | |||
| 9c8f49ed71 | |||
| 8f7733eb64 | |||
| f0eea9867b | |||
| c416a645f2 | |||
| 3aca1c9763 | |||
| 28ca689130 | |||
| 2c1c46b1f6 | |||
| addf2bc86e | |||
| 34c461be9c | |||
| c2be7f047c | |||
| cfe56c49d1 | |||
| e3d2504b42 | |||
| d28ac40ce7 | |||
| 56cc83f723 | |||
| a216d6f596 | |||
| ce0bbde8dc | |||
| 78c3cce183 | |||
| 968cc687a0 | |||
| f3b00eb85a | |||
| fa071795d0 | |||
| 2489621831 | |||
| 9ed59aac79 | |||
| 89aec5c709 | |||
| cbe335e903 | |||
| e9a335f433 | |||
| 89318d8f6e | |||
| e1b3623599 | |||
| 57a11c0668 | |||
| 050cc03ae2 | |||
| bab16c75cc | |||
| 85184d45f9 | |||
| 9d665f9dde | |||
| 97f57d3826 | |||
| 3c2f57101f | |||
| 4542b22e87 | |||
| ca965e48d6 | |||
| 09638a874d | |||
| b7dea45ee0 | |||
| 69404b38ce | |||
| 5c14f56428 | |||
| 11b3f37d06 | |||
| c234cee60d | |||
| efe054e4e4 | |||
| 714898022e | |||
| a6ef8e575b | |||
| f5c9ad59cf | |||
| c73e172fac | |||
| 2dae3f00bd | |||
| 71ef7236ca | |||
| 87dd7d242a | |||
| 6ec2f4bd08 | |||
| 82b0bdfd56 | |||
| 97977be286 | |||
| df2e652ca6 | |||
| 84278b794f | |||
| 819a7a8cd3 | |||
| 9f0b8c5d7d | |||
| ecfbd4fd19 | |||
| 9bc15aa306 | |||
| 619f944d80 | |||
| 7b8bb92905 | |||
| 9b9bcc5b8f | |||
| 6538a6cb69 | |||
| 04f63ad8c6 | |||
| 3494ea4eb0 | |||
| 050054ad0f | |||
| bc9f881034 | |||
| 3dad452b09 | |||
| 5911a878c6 | |||
| 94735e7d19 | |||
| b0d58ff0f6 | |||
| a3d1d5f7b3 | |||
| a3994510e0 | |||
| 3da6e3cfa9 | |||
| 138083f6c8 | |||
| f2dcb2f33f | |||
| 7128dc785c | |||
| d663d143a4 | |||
| dd30790d51 | |||
| a7f965526b | |||
| f3cfee3189 | |||
| b2921dbad6 | |||
| 1f5aee54d7 | |||
| b18bad5b93 | |||
| a2107bba8a | |||
| 05d5148611 | |||
| 94600d7b84 | |||
| a25bc6ef19 | |||
| 8735275d84 | |||
| 187b641726 | |||
| 660fe5227e | |||
| 58bdcd43f5 | |||
| eeb7ebc5db | |||
| e799af48e0 | |||
| 1059412e28 | |||
| 0434a823cc | |||
| 40a42cbb73 | |||
| d509df731a | |||
| c01207b342 | |||
| a00550a200 | |||
| 6b75470b0f | |||
| 825f7202b4 | |||
| 853da4cc2a | |||
| 904071099a | |||
| abd93098fc | |||
| 5126a1df8d | |||
| 112738198f | |||
| c7d4040dd3 | |||
| 068aef5589 | |||
| c908662f69 | |||
| 6f0821d02b | |||
| 604693ea0c | |||
| 1d38311e07 | |||
| 75fb15be2b | |||
| 3c5e09a937 | |||
| fd17c35330 | |||
| d7c95bda5e | |||
| 265556dc4e | |||
| 0f89db1893 | |||
| 3b0376c3a6 | |||
| cc3c77fdb7 | |||
| f73e151c77 | |||
| 889ef361eb | |||
| 7fd1019efa | |||
| 0a78324405 | |||
| 7cc90f554c | |||
| f0d48c4274 | |||
| b79460f9ff | |||
| 93a6704944 | |||
| 1e3d82f978 | |||
| 1ca8621e16 | |||
| 56a1ead706 | |||
| acefd1cfd4 | |||
| 1bad55ce54 | |||
| 869184efb5 | |||
| b46fe2531a | |||
| c4b2b13c89 | |||
| 49acc12390 | |||
| 3d1f8340be | |||
| 3a5d823fbd | |||
| 27d7fdd12b | |||
| d6863f3d5e | |||
| 3c6077ef58 | |||
| 728cdd972b | |||
| c3242b6bc4 | |||
| f97ff772a3 | |||
| f4e0b4a01b | |||
| 6736722533 | |||
| 9d51800f58 | |||
| 36425a48eb | |||
| a209805ffc | |||
| b591c04880 | |||
| ad3762bdcc | |||
| 7b9fdeb60c | |||
| 9c98c023ce | |||
| f00a376d0b | |||
| c469eecfa1 | |||
| aa1fc546bd | |||
| 4f991095ee | |||
| a64ee2b2da | |||
| 14efe44125 | |||
| e131caf9ec | |||
| e46b427938 | |||
| 8d6f4df526 | |||
| 1a41731ef5 | |||
| 6f58a3f190 | |||
| 883fac980c | |||
| 72503cd7ae | |||
| 084e93cfd0 | |||
| d8924a2ecc | |||
| 6f02f6bc22 | |||
| 3986549783 | |||
| ff9f9485d2 | |||
| 29ba511b38 | |||
| 8e9aff3299 | |||
| 911b36a696 | |||
| 402a04e1b9 | |||
| 25d02707d0 | |||
| bb9cd8ea23 | |||
| 0e51a6b06a | |||
| d3e21d3360 | |||
| 32effaa16e | |||
| 054485e930 | |||
| c8429f98ae | |||
| 68ae17dcf1 | |||
| 5b39efb078 | |||
| 24429ee8b5 | |||
| 7d6e0780b5 | |||
| 83f1766081 | |||
| 811a0230fc | |||
| 69c8b20b78 | |||
| f0bb064237 | |||
| 1ad03bb518 | |||
| c75f4a0160 | |||
| 01cd3b9ab1 | |||
| ca63011f25 | |||
| e8cb102819 | |||
| 02aba39942 | |||
| c5b6beb006 | |||
| e8e05d6898 | |||
| 03601ca89b | |||
| cbb3e91ada | |||
| 10a2a8116f | |||
| 7c007ea6b5 | |||
| 6dfb350be8 | |||
| 5d6f25a695 | |||
| 1b39439cb8 |
|
After Width: | Height: | Size: 437 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 378 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 396 KiB |
|
After Width: | Height: | Size: 2.5 MiB |
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 525 KiB |
|
After Width: | Height: | Size: 490 KiB |
|
After Width: | Height: | Size: 754 KiB |
|
After Width: | Height: | Size: 406 KiB |
|
After Width: | Height: | Size: 530 KiB |
|
After Width: | Height: | Size: 544 KiB |
|
Before Width: | Height: | Size: 151 KiB After Width: | Height: | Size: 151 KiB |
@@ -1,28 +1,28 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: gomod
|
||||
target-branch: main
|
||||
target-branch: dev
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: monday
|
||||
timezone: Australia/Sydney
|
||||
cooldown:
|
||||
default-days: 1
|
||||
default-days: 3
|
||||
groups:
|
||||
go-dependencies:
|
||||
patterns:
|
||||
- "*"
|
||||
|
||||
- package-ecosystem: github-actions
|
||||
target-branch: main
|
||||
target-branch: dev
|
||||
directory: /
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: monday
|
||||
timezone: Australia/Sydney
|
||||
cooldown:
|
||||
default-days: 1
|
||||
default-days: 3
|
||||
ignore:
|
||||
- dependency-name: "*"
|
||||
update-types:
|
||||
@@ -34,14 +34,14 @@ updates:
|
||||
- "*"
|
||||
|
||||
- package-ecosystem: npm
|
||||
target-branch: main
|
||||
target-branch: dev
|
||||
directory: /spinifex/services/spinifexui/frontend
|
||||
schedule:
|
||||
interval: weekly
|
||||
day: monday
|
||||
timezone: Australia/Sydney
|
||||
cooldown:
|
||||
default-days: 1
|
||||
default-days: 3
|
||||
ignore:
|
||||
- dependency-name: "@types/node"
|
||||
update-types:
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
name: E2E Bare-Metal
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
statuses: write
|
||||
|
||||
env:
|
||||
GIT_BRANCH: ${{ github.ref_name }}
|
||||
|
||||
jobs:
|
||||
e2e_baremetal:
|
||||
name: Bare-Metal Multi-Node E2E
|
||||
runs-on: [ self-hosted, ci-baremetal ]
|
||||
timeout-minutes: 120
|
||||
concurrency:
|
||||
group: e2e-baremetal-cluster
|
||||
cancel-in-progress: false
|
||||
env:
|
||||
BM_ISO_CACHE: /var/cache/spinifex-iso-builder
|
||||
steps:
|
||||
- name: Clean Workspace
|
||||
run: sudo rm -rf "$GITHUB_WORKSPACE"/*
|
||||
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/create-github-app-token@v3
|
||||
id: app-token
|
||||
with:
|
||||
client-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
repositories: mulga
|
||||
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
repository: mulgadc/mulga
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
sparse-checkout: |
|
||||
scripts/iso-builder
|
||||
scripts/baremetal
|
||||
path: mulga
|
||||
|
||||
- name: Install SSH Key
|
||||
run: |
|
||||
echo "${{ secrets.BM_SSH_PRIVATE_KEY }}" > "${RUNNER_TEMP}/bm-ci"
|
||||
chmod 0600 "${RUNNER_TEMP}/bm-ci"
|
||||
ssh-keygen -y -f "${RUNNER_TEMP}/bm-ci" > "${RUNNER_TEMP}/bm-ci.pub"
|
||||
|
||||
- name: Build Artifacts
|
||||
run: |
|
||||
mulga/scripts/baremetal/bm-build-artifacts.sh \
|
||||
"$GITHUB_WORKSPACE" \
|
||||
"${RUNNER_TEMP}/spinifex-tests.tar" \
|
||||
"${RUNNER_TEMP}/bm-bins"
|
||||
|
||||
- name: Build PXE Artifacts
|
||||
run: |
|
||||
sudo make -C mulga/scripts/iso-builder \
|
||||
build-rootfs build-squashfs build-initramfs \
|
||||
CACHE="${BM_ISO_CACHE}" \
|
||||
SPINIFEX_BIN="${RUNNER_TEMP}/bm-bins/spx" \
|
||||
INSTALLER_BIN="${RUNNER_TEMP}/bm-bins/spinifex-installer" \
|
||||
SPINIFEX_SRC="${GITHUB_WORKSPACE}" \
|
||||
VIPERBLOCK_SRC="${RUNNER_TEMP}/bm-bins/viperblock" \
|
||||
VERSION="$(cd "$GITHUB_WORKSPACE" && git describe --tags --always 2>/dev/null || echo dev)"
|
||||
|
||||
- name: Write ePDU credentials
|
||||
run: |
|
||||
install -m 0600 /dev/null "${RUNNER_TEMP}/epdu.env"
|
||||
cat >> "${RUNNER_TEMP}/epdu.env" <<EOF
|
||||
EPDU_HOST=192.168.1.63
|
||||
EPDU_SNMP_USER=ci-bmpower
|
||||
EPDU_SNMP_SEC_LEVEL=authPriv
|
||||
EPDU_SNMP_AUTH_PROTO=MD5
|
||||
EPDU_SNMP_AUTH_PASS=${{ secrets.BM_EPDU_AUTH_PASS }}
|
||||
EPDU_SNMP_PRIV_PROTO=DES
|
||||
EPDU_SNMP_PRIV_PASS=${{ secrets.BM_EPDU_PRIV_PASS }}
|
||||
EOF
|
||||
|
||||
- name: Cache VM images
|
||||
run: |
|
||||
UBUNTU_IMG="${BM_ISO_CACHE}/ubuntu-24.04-cloudimg-amd64.img"
|
||||
if [ ! -f "$UBUNTU_IMG" ]; then
|
||||
echo "Downloading Ubuntu 24.04 cloud image..."
|
||||
sudo curl -fsSL -o "$UBUNTU_IMG" \
|
||||
"https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img"
|
||||
echo " Downloaded $(du -h "$UBUNTU_IMG" | cut -f1)"
|
||||
else
|
||||
echo "Ubuntu 24.04 cloud image cached ($(du -h "$UBUNTU_IMG" | cut -f1))"
|
||||
fi
|
||||
|
||||
LB_IMG="${BM_ISO_CACHE}/lb-alpine-3.21.6-x86_64.raw"
|
||||
if [ ! -f "$LB_IMG" ]; then
|
||||
echo "Downloading lb-alpine image..."
|
||||
sudo curl -fsSL -o "$LB_IMG" \
|
||||
"https://iso.mulgadc.com/system-ami/lb-alpine-3.21.6-x86_64.raw"
|
||||
echo " Downloaded $(du -h "$LB_IMG" | cut -f1)"
|
||||
else
|
||||
echo "lb-alpine image cached ($(du -h "$LB_IMG" | cut -f1))"
|
||||
fi
|
||||
|
||||
- name: Run Bare-Metal E2E
|
||||
env:
|
||||
BM_CI_PASSWORD: ${{ secrets.BM_CI_PASSWORD }}
|
||||
BM_SSH_KEY: ${{ runner.temp }}/bm-ci
|
||||
BM_EPDU_ENV: ${{ runner.temp }}/epdu.env
|
||||
BM_UBUNTU_IMAGE: ${{ env.BM_ISO_CACHE }}/ubuntu-24.04-cloudimg-amd64.img
|
||||
BM_LB_ALPINE_IMAGE: ${{ env.BM_ISO_CACHE }}/lb-alpine-3.21.6-x86_64.raw
|
||||
SPX_EXTERNAL_POOL: ${{ env.SPINIFEX_CI_POOL }}
|
||||
run: |
|
||||
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"
|
||||
@@ -0,0 +1,203 @@
|
||||
name: E2E DDIL
|
||||
|
||||
# Phase 1 trigger: workflow_dispatch only. Nightly cron + on-main-merge
|
||||
# land in mulga-siv-5.8 once ≥4 scenarios are asserting. See
|
||||
# docs/development/improvements/ddil-e2e-test-harness.md §4 / §Dependencies.
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
quarantined:
|
||||
description: "Comma-separated scenario letters to quarantine (A..F)"
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
GIT_BRANCH: ${{ github.ref_name }}
|
||||
|
||||
jobs:
|
||||
ddil:
|
||||
name: DDIL Real Multi-Node
|
||||
runs-on: [ self-hosted, ci-multi ]
|
||||
# Budget: ~45m go test + tofu provision/bootstrap/teardown on ci-multi.
|
||||
# In Phase 1 the scenarios all t.Skip, so the real-world runtime is
|
||||
# dominated by provisioning; the 60m ceiling leaves headroom for
|
||||
# Phase 2/3 when assertions come online.
|
||||
timeout-minutes: 60
|
||||
env:
|
||||
TOFU_LOG: /tmp/e2e-tofu-ddil-${{ github.run_id }}.log
|
||||
steps:
|
||||
- name: Clean Workspace
|
||||
run: sudo rm -rf "$GITHUB_WORKSPACE"/*
|
||||
|
||||
- uses: actions/create-github-app-token@v3
|
||||
id: app-token
|
||||
with:
|
||||
client-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
repositories: mulga
|
||||
|
||||
# Full spinifex checkout: the Go harness must run on the runner
|
||||
# (outside the cluster) so its SSH path survives scenario partitions
|
||||
# that drop peer-to-peer cluster traffic. Running inside the cluster
|
||||
# would self-sever when Scenario C isolates a node.
|
||||
- uses: actions/checkout@v6
|
||||
name: Checkout Spinifex
|
||||
with:
|
||||
path: spinifex
|
||||
|
||||
- uses: actions/checkout@v6
|
||||
name: Checkout Mulga (tofu-cluster)
|
||||
with:
|
||||
repository: mulgadc/mulga
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
sparse-checkout: scripts/tofu-cluster
|
||||
path: mulga
|
||||
|
||||
- name: Install gotestsum
|
||||
env:
|
||||
GOTESTSUM_VERSION: "1.12.0"
|
||||
run: |
|
||||
# Prebuilt tarball, not `go install`: gotestsum v1.12.0 pulls
|
||||
# x/tools v0.19.0 whose tokeninternal static-assert pegs a
|
||||
# narrow Go window (~1.22). The self-hosted runner's Go falls
|
||||
# outside that window, so source-install fails with
|
||||
# `invalid array length -delta * delta`. A prebuilt binary
|
||||
# sidesteps toolchain coupling entirely.
|
||||
BIN_DIR="${RUNNER_TEMP}/gotestsum-bin"
|
||||
mkdir -p "${BIN_DIR}"
|
||||
if [ ! -x "${BIN_DIR}/gotestsum" ]; then
|
||||
curl -fsSL \
|
||||
"https://github.com/gotestyourself/gotestsum/releases/download/v${GOTESTSUM_VERSION}/gotestsum_${GOTESTSUM_VERSION}_linux_amd64.tar.gz" \
|
||||
| tar -xz -C "${BIN_DIR}" gotestsum
|
||||
fi
|
||||
echo "${BIN_DIR}" >> "${GITHUB_PATH}"
|
||||
"${BIN_DIR}/gotestsum" --version
|
||||
|
||||
- name: Build Install Artifacts
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
run: |
|
||||
./build-install-artifacts.sh "${GIT_BRANCH}" \
|
||||
"${RUNNER_TEMP}/spinifex-distro.tar.gz" \
|
||||
"${RUNNER_TEMP}/setup.sh" \
|
||||
"${RUNNER_TEMP}/spinifex-tests.tar"
|
||||
|
||||
- name: Provision VMs
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
run: |
|
||||
export TF_VAR_git_branch="${GIT_BRANCH}"
|
||||
tofu init -input=false >> "$TOFU_LOG" 2>&1
|
||||
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 || true
|
||||
./scrub-env.sh "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1 || true
|
||||
tofu apply -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1
|
||||
|
||||
- name: Bootstrap via Binary Install
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
run: |
|
||||
SPX_EXTERNAL_POOL="${SPINIFEX_CI_POOL}" \
|
||||
./bootstrap-install.sh "${SPINIFEX_CI_ENV}" \
|
||||
"${RUNNER_TEMP}/setup.sh" \
|
||||
"${RUNNER_TEMP}/spinifex-distro.tar.gz" \
|
||||
--test-tar "${RUNNER_TEMP}/spinifex-tests.tar"
|
||||
|
||||
- name: Run DDIL Scenarios
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
run: |
|
||||
set -o pipefail
|
||||
INVENTORY=$(tofu output -json inventory)
|
||||
SSH_KEY=$(echo "$INVENTORY" | jq -r '.ssh_key_path')
|
||||
SSH_KEY="${SSH_KEY/#\~/$HOME}"
|
||||
SSH_USER=$(echo "$INVENTORY" | jq -r '.ssh_user')
|
||||
REGION=$(echo "$INVENTORY" | jq -r '.region')
|
||||
# DDIL_NODES must be the cluster-traffic IPs: PartitionNode
|
||||
# installs iptables DROP rules for each peer, so the runner's
|
||||
# SSH source IP must differ from these. On single-NIC
|
||||
# tofu-cluster, wan_ip == cluster IP and the runner's outbound
|
||||
# IP is distinct — see e2e-ddil-test-harness.md §3 Scenario C.
|
||||
NODE_IPS=$(echo "$INVENTORY" | jq -r '[.nodes[].wan_ip] | join(",")')
|
||||
PRIMARY_IP=$(echo "$INVENTORY" | jq -r '.nodes[0].wan_ip')
|
||||
echo "DDIL_NODES=${NODE_IPS}"
|
||||
|
||||
# Witness VMs talk to the cluster's AWS gateway via aws-sdk-go,
|
||||
# which resolves credentials from the standard chain. spx admin
|
||||
# init writes [spinifex] to ${SSH_USER}'s ~/.aws/credentials on
|
||||
# node1 — pull them over SSH and export as the SDK's env-var
|
||||
# entries so the harness can launch instances without depending
|
||||
# on a cluster-bootstrapped profile being present on the runner.
|
||||
CREDS=$(ssh -i "${SSH_KEY}" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
|
||||
"${SSH_USER}@${PRIMARY_IP}" 'cat ~/.aws/credentials')
|
||||
AWS_ACCESS_KEY_ID=$(echo "$CREDS" | awk -F '= *' '/^aws_access_key_id/ {print $2; exit}')
|
||||
AWS_SECRET_ACCESS_KEY=$(echo "$CREDS" | awk -F '= *' '/^aws_secret_access_key/ {print $2; exit}')
|
||||
if [ -z "$AWS_ACCESS_KEY_ID" ] || [ -z "$AWS_SECRET_ACCESS_KEY" ]; then
|
||||
echo "::error::Failed to extract spinifex AWS credentials from ${PRIMARY_IP}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Guest SSH key: bootstrap-install.sh imports spinifex-key into EC2;
|
||||
# the matching private material was generated on the primary node at
|
||||
# ~/.ssh/spinifex-key. Pull it to the runner so the harness can SSH
|
||||
# the witness via its MapPublicIpOnLaunch-assigned EIP.
|
||||
GUEST_KEY="${RUNNER_TEMP}/spinifex-key"
|
||||
scp -i "${SSH_KEY}" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
|
||||
"${SSH_USER}@${PRIMARY_IP}:~/.ssh/spinifex-key" "${GUEST_KEY}"
|
||||
chmod 600 "${GUEST_KEY}"
|
||||
|
||||
mkdir -p "${RUNNER_TEMP}/ddil-results"
|
||||
cd "${GITHUB_WORKSPACE}/spinifex"
|
||||
# gotestsum: human-readable PASS/FAIL/SKIP lines in Actions stdout
|
||||
# while still emitting the machine-readable jsonfile artifact.
|
||||
DDIL_NODES="${NODE_IPS}" \
|
||||
DDIL_SSH_USER="${SSH_USER}" \
|
||||
DDIL_SSH_KEY="${SSH_KEY}" \
|
||||
DDIL_GUEST_SSH_KEY="${GUEST_KEY}" \
|
||||
DDIL_QUARANTINED="${{ inputs.quarantined }}" \
|
||||
AWS_REGION="${REGION}" \
|
||||
AWS_ACCESS_KEY_ID="${AWS_ACCESS_KEY_ID}" \
|
||||
AWS_SECRET_ACCESS_KEY="${AWS_SECRET_ACCESS_KEY}" \
|
||||
gotestsum --jsonfile "${RUNNER_TEMP}/ddil-results/results.json" \
|
||||
--format testname \
|
||||
-- -tags=e2e -timeout 45m ./tests/e2e/ddil/scenarios/...
|
||||
|
||||
- name: Collect Cluster Logs
|
||||
if: failure()
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
run: |
|
||||
# Pull spinifex-* journals + QEMU process listings from every
|
||||
# node into the artifact bundle so failures can be triaged
|
||||
# without re-provisioning the cluster. Best-effort — a missing
|
||||
# node should not mask the original test failure.
|
||||
set +e
|
||||
INVENTORY=$(tofu output -json inventory)
|
||||
SSH_KEY=$(echo "$INVENTORY" | jq -r '.ssh_key_path')
|
||||
SSH_KEY="${SSH_KEY/#\~/$HOME}"
|
||||
SSH_USER=$(echo "$INVENTORY" | jq -r '.ssh_user')
|
||||
LOG_DIR="${RUNNER_TEMP}/ddil-results/cluster-logs"
|
||||
mkdir -p "${LOG_DIR}"
|
||||
|
||||
mapfile -t NODE_IPS < <(echo "$INVENTORY" | jq -r '.nodes[].wan_ip')
|
||||
for ip in "${NODE_IPS[@]}"; do
|
||||
echo "=== collecting ${ip} ==="
|
||||
SSH="ssh -i ${SSH_KEY} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ${SSH_USER}@${ip}"
|
||||
${SSH} 'sudo journalctl -u "spinifex-*" --no-pager -n 5000' > "${LOG_DIR}/${ip}-spinifex.log" 2>&1
|
||||
${SSH} 'ps auxw | grep -E "qemu-system|spinifex" | grep -v grep' > "${LOG_DIR}/${ip}-procs.txt" 2>&1
|
||||
${SSH} 'ls -la /var/lib/spinifex 2>/dev/null; ls -la /var/log/spinifex 2>/dev/null' > "${LOG_DIR}/${ip}-state.txt" 2>&1
|
||||
done
|
||||
|
||||
- name: Upload DDIL Results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ddil-results-${{ github.run_id }}
|
||||
path: ${{ runner.temp }}/ddil-results
|
||||
retention-days: 30
|
||||
|
||||
- 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
|
||||
@@ -0,0 +1,218 @@
|
||||
name: E2E Nightly
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 14 * * 0-4" # 00:00 Mon-Fri AEST = 14:00 Sun-Thu UTC
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
cells:
|
||||
description: "Comma-separated cell numbers to run (blank = all)"
|
||||
required: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Scheduled runs are loaded from the default branch (main), so the schedule
|
||||
# trigger must stay in sync on main for cron to fire.
|
||||
env:
|
||||
GIT_BRANCH: ${{ github.event_name == 'schedule' && 'dev' || github.ref_name }}
|
||||
UBUNTU_GOLD_IMAGE: /mnt/disk1/gold-images/spinifex-image-builder-ubuntu-24.04.qcow2
|
||||
|
||||
jobs:
|
||||
matrix:
|
||||
name: "cell-${{ matrix.cell }} (${{ matrix.topo }}/${{ matrix.net }}/${{
|
||||
matrix.host }}${{ matrix.extra && format('/{0}', matrix.extra) || '' }})"
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 3
|
||||
matrix:
|
||||
include:
|
||||
- { cell: 1, install: binary, topo: single, net: dhcp, host: debian-13, arch: x86_64, runner: ci-single, extra: "" }
|
||||
- { cell: 2, install: binary, topo: single, net: static, host: ubuntu-24.04, arch: x86_64, runner: ci-single, extra: "" }
|
||||
- { cell: 3, install: binary, topo: single, net: dhcp, host: ubuntu-24.04, arch: x86_64, runner: ci-single, extra: "" }
|
||||
- { cell: 8, install: binary, topo: real-multi, net: dhcp, host: debian-13, arch: x86_64, runner: ci-multi, extra: "" }
|
||||
- { cell: 9, install: binary, topo: real-multi, net: static, host: ubuntu-24.04, arch: x86_64, runner: ci-multi, extra: "" }
|
||||
- { cell: 10, install: binary, topo: real-multi, net: dhcp, host: ubuntu-24.04, arch: x86_64, runner: ci-multi, extra: "" }
|
||||
- { cell: 17, install: binary, topo: single, net: static, host: debian-13, arch: x86_64, runner: ci-pseudo, extra: "tofu-examples" }
|
||||
- { cell: 18, install: binary, topo: single, net: static, host: debian-13, arch: x86_64, runner: ci-single, extra: "reboot-single" }
|
||||
|
||||
runs-on: [ self-hosted, "${{ matrix.runner }}" ]
|
||||
timeout-minutes: 35
|
||||
env:
|
||||
TOFU_LOG: /tmp/e2e-nightly-cell-${{ matrix.cell }}-${{ github.run_id }}.log
|
||||
|
||||
steps:
|
||||
# blank or scheduled run = full matrix.
|
||||
# Input must be comma-separated with no whitespace (e.g. '1,2,17', not '1, 2').
|
||||
- name: Gate on Dispatch Input
|
||||
id: gate
|
||||
env:
|
||||
CELL: ${{ matrix.cell }}
|
||||
CELLS: ${{ github.event_name == 'workflow_dispatch' && inputs.cells || '' }}
|
||||
run: |
|
||||
if [ -z "$CELLS" ] || [[ ",${CELLS}," == *",${CELL},"* ]]; then
|
||||
echo "selected=true" >> "$GITHUB_OUTPUT"
|
||||
echo "cell ${CELL} selected"
|
||||
else
|
||||
echo "selected=false" >> "$GITHUB_OUTPUT"
|
||||
echo "cell ${CELL} not in '${CELLS}' — subsequent steps will skip"
|
||||
fi
|
||||
|
||||
- name: Clean Workspace
|
||||
if: steps.gate.outputs.selected == 'true'
|
||||
run: sudo rm -rf "$GITHUB_WORKSPACE"/*
|
||||
|
||||
- uses: actions/create-github-app-token@v3
|
||||
if: steps.gate.outputs.selected == 'true'
|
||||
id: app-token
|
||||
with:
|
||||
client-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
repositories: mulga
|
||||
|
||||
- uses: actions/checkout@v6
|
||||
if: steps.gate.outputs.selected == 'true'
|
||||
with:
|
||||
repository: mulgadc/mulga
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
sparse-checkout: scripts/tofu-cluster
|
||||
path: mulga
|
||||
|
||||
- name: Build Install Artifacts
|
||||
if: steps.gate.outputs.selected == 'true'
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
run: |
|
||||
./build-install-artifacts.sh "${GIT_BRANCH}" \
|
||||
"${RUNNER_TEMP}/spinifex-distro.tar.gz" \
|
||||
"${RUNNER_TEMP}/setup.sh" \
|
||||
"${RUNNER_TEMP}/spinifex-tests.tar"
|
||||
|
||||
- name: Provision VMs
|
||||
if: steps.gate.outputs.selected == 'true'
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
env:
|
||||
MATRIX_HOST: ${{ matrix.host }}
|
||||
run: |
|
||||
export TF_VAR_git_branch="${GIT_BRANCH}"
|
||||
|
||||
TOFU_VAR_ARGS=""
|
||||
if [ "$MATRIX_HOST" = "ubuntu-24.04" ]; then
|
||||
TOFU_VAR_ARGS="-var=host_image_override=${UBUNTU_GOLD_IMAGE}"
|
||||
fi
|
||||
|
||||
tofu init -input=false >> "$TOFU_LOG" 2>&1
|
||||
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 || true
|
||||
./scrub-env.sh "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1 || true
|
||||
tofu apply -var-file=envs/${SPINIFEX_CI_ENV}.tfvars ${TOFU_VAR_ARGS} -auto-approve -input=false >> "$TOFU_LOG" 2>&1
|
||||
|
||||
- name: Bootstrap via Binary Install
|
||||
if: steps.gate.outputs.selected == 'true'
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
env:
|
||||
MATRIX_NET: ${{ matrix.net }}
|
||||
run: |
|
||||
if [ "$MATRIX_NET" = "dhcp" ]; then
|
||||
unset SPX_EXTERNAL_POOL
|
||||
export SPX_EXTERNAL_SOURCE=dhcp
|
||||
else
|
||||
export SPX_EXTERNAL_POOL="${SPINIFEX_CI_POOL}"
|
||||
fi
|
||||
|
||||
./bootstrap-install.sh "${SPINIFEX_CI_ENV}" \
|
||||
"${RUNNER_TEMP}/setup.sh" \
|
||||
"${RUNNER_TEMP}/spinifex-distro.tar.gz" \
|
||||
--test-tar "${RUNNER_TEMP}/spinifex-tests.tar"
|
||||
|
||||
- name: Run Single Node E2E
|
||||
if: steps.gate.outputs.selected == 'true' && matrix.topo == 'single' && matrix.extra == ''
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
run: |
|
||||
INVENTORY=$(tofu output -json inventory)
|
||||
WAN_IP=$(echo "$INVENTORY" | jq -r '.nodes[0].wan_ip')
|
||||
SSH_KEY=$(echo "$INVENTORY" | jq -r '.ssh_key_path')
|
||||
SSH_KEY="${SSH_KEY/#\~/$HOME}"
|
||||
ssh -o StrictHostKeyChecking=no -i "$SSH_KEY" tf-user@${WAN_IP} \
|
||||
"cd ~/e2e-tests && ./run-e2e.sh && ./run-cert-e2e.sh && ENDPOINT=https://${WAN_IP}:9999 ./run-lb-e2e.sh"
|
||||
|
||||
- name: Run Multi Node E2E
|
||||
if: steps.gate.outputs.selected == 'true' && matrix.topo == 'real-multi'
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
run: |
|
||||
INVENTORY=$(tofu output -json inventory)
|
||||
NODE1_IP=$(echo "$INVENTORY" | jq -r '.nodes[0].wan_ip')
|
||||
SSH_KEY=$(echo "$INVENTORY" | jq -r '.ssh_key_path')
|
||||
SSH_KEY="${SSH_KEY/#\~/$HOME}"
|
||||
|
||||
NODE_IPS=""
|
||||
NODE_COUNT=$(echo "$INVENTORY" | jq '.nodes | length')
|
||||
for i in $(seq 0 $((NODE_COUNT - 1))); do
|
||||
NODE_IPS="${NODE_IPS} $(echo "$INVENTORY" | jq -r ".nodes[$i].wan_ip")"
|
||||
done
|
||||
|
||||
ssh -o StrictHostKeyChecking=no -i "$SSH_KEY" tf-user@${NODE1_IP} \
|
||||
"cd ~/e2e-tests && ./run-multinode-e2e.sh ${NODE_IPS} && ./run-cert-e2e.sh --multinode ${NODE_IPS} && ENDPOINT=https://${NODE1_IP}:9999 ./run-lb-e2e.sh --nodes ${NODE_IPS}"
|
||||
|
||||
- name: Run Tofu Workbooks
|
||||
if: steps.gate.outputs.selected == 'true' && matrix.extra == 'tofu-examples'
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
run: |
|
||||
INVENTORY=$(tofu output -json inventory)
|
||||
WAN_IP=$(echo "$INVENTORY" | jq -r '.nodes[0].wan_ip')
|
||||
SSH_KEY=$(echo "$INVENTORY" | jq -r '.ssh_key_path')
|
||||
SSH_KEY="${SSH_KEY/#\~/$HOME}"
|
||||
ssh -o StrictHostKeyChecking=no -i "$SSH_KEY" tf-user@${WAN_IP} \
|
||||
"cd ~/e2e-tests && ./run-tofu-examples-e2e.sh"
|
||||
|
||||
- name: Run Reboot Resilience E2E
|
||||
if: steps.gate.outputs.selected == 'true' && matrix.extra == 'reboot-single'
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
# Driver runs from the runner (NOT via ssh-and-run) so it survives the
|
||||
# systemctl-reboot-induced ssh disconnect mid-run.
|
||||
run: |
|
||||
INVENTORY=$(tofu output -json inventory)
|
||||
WAN_IP=$(echo "$INVENTORY" | jq -r '.nodes[0].wan_ip')
|
||||
SSH_KEY=$(echo "$INVENTORY" | jq -r '.ssh_key_path')
|
||||
SSH_KEY="${SSH_KEY/#\~/$HOME}"
|
||||
|
||||
REBOOT_DIR="${RUNNER_TEMP}/reboot-e2e"
|
||||
mkdir -p "$REBOOT_DIR"
|
||||
tar xf "${RUNNER_TEMP}/spinifex-tests.tar" -C "$REBOOT_DIR"
|
||||
chmod +x "$REBOOT_DIR/run-reboot-e2e.sh"
|
||||
|
||||
WAN_IP="$WAN_IP" SSH_KEY="$SSH_KEY" "$REBOOT_DIR/run-reboot-e2e.sh"
|
||||
|
||||
- name: Dump Cluster Journals on Failure
|
||||
if: failure() && steps.gate.outputs.selected == 'true'
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
run: |
|
||||
INVENTORY=$(tofu output -json inventory 2>/dev/null) || { echo "no inventory; skipping"; exit 0; }
|
||||
SSH_KEY=$(echo "$INVENTORY" | jq -r '.ssh_key_path')
|
||||
SSH_KEY="${SSH_KEY/#\~/$HOME}"
|
||||
NODE_COUNT=$(echo "$INVENTORY" | jq '.nodes | length')
|
||||
for i in $(seq 0 $((NODE_COUNT - 1))); do
|
||||
IP=$(echo "$INVENTORY" | jq -r ".nodes[$i].wan_ip")
|
||||
echo "===== node$((i+1)) ($IP) ====="
|
||||
for svc in spinifex-nats spinifex-predastore spinifex-viperblock spinifex-daemon spinifex-awsgw spinifex-vpcd dhcpcd; do
|
||||
echo "--- $svc ---"
|
||||
ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5 -i "$SSH_KEY" tf-user@${IP} \
|
||||
"sudo journalctl -u $svc --no-pager -n 200 2>/dev/null || echo '(not found)'" 2>&1 || echo "(node unreachable)"
|
||||
done
|
||||
echo "--- DHCP client state ---"
|
||||
ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5 -i "$SSH_KEY" tf-user@${IP} \
|
||||
"systemctl is-enabled dhcpcd.service 2>/dev/null; systemctl is-active dhcpcd.service 2>/dev/null; sudo ss -lupn 'sport = 68' 2>/dev/null; pgrep -laf dhcpcd 2>/dev/null" 2>&1 || echo "(node unreachable)"
|
||||
done
|
||||
|
||||
- name: Teardown
|
||||
if: always()
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
env:
|
||||
MATRIX_HOST: ${{ matrix.host }}
|
||||
run: |
|
||||
TOFU_VAR_ARGS=""
|
||||
if [ "$MATRIX_HOST" = "ubuntu-24.04" ]; then
|
||||
TOFU_VAR_ARGS="-var=host_image_override=${UBUNTU_GOLD_IMAGE}"
|
||||
fi
|
||||
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
|
||||
@@ -1,9 +1,10 @@
|
||||
name: Spinifex E2E Tests
|
||||
name: E2E
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
paths:
|
||||
- "**/*.go"
|
||||
- go.mod
|
||||
@@ -18,6 +19,7 @@ on:
|
||||
required: false
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
statuses: write
|
||||
|
||||
env:
|
||||
@@ -26,13 +28,13 @@ env:
|
||||
jobs:
|
||||
e2e_single:
|
||||
name: Single-Node E2E
|
||||
runs-on: [self-hosted, ci-single]
|
||||
timeout-minutes: 30
|
||||
runs-on: [ self-hosted, ci-single ]
|
||||
timeout-minutes: 35
|
||||
env:
|
||||
TOFU_LOG: /tmp/e2e-tofu-single-${{ github.run_id }}.log
|
||||
steps:
|
||||
- name: Clean Workspace
|
||||
run: rm -rf "$GITHUB_WORKSPACE"/*
|
||||
run: sudo rm -rf "$GITHUB_WORKSPACE"/*
|
||||
|
||||
- uses: actions/create-github-app-token@v3
|
||||
id: app-token
|
||||
@@ -48,7 +50,7 @@ jobs:
|
||||
sparse-checkout: scripts/tofu-cluster
|
||||
path: mulga
|
||||
|
||||
- name: Build install artifacts
|
||||
- name: Build Install Artifacts
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
run: |
|
||||
./build-install-artifacts.sh "${GIT_BRANCH}" \
|
||||
@@ -61,16 +63,16 @@ jobs:
|
||||
run: |
|
||||
export TF_VAR_git_branch="${GIT_BRANCH}"
|
||||
tofu init -input=false >> "$TOFU_LOG" 2>&1
|
||||
tofu workspace select -or-create env3 >> "$TOFU_LOG" 2>&1
|
||||
tofu destroy -var-file=envs/env3.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1 || true
|
||||
./scrub-env.sh env3 >> "$TOFU_LOG" 2>&1 || true
|
||||
tofu apply -var-file=envs/env3.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1
|
||||
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 || true
|
||||
./scrub-env.sh "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1 || true
|
||||
tofu apply -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1
|
||||
|
||||
- name: Bootstrap via Binary Install
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
run: |
|
||||
SPX_EXTERNAL_POOL="192.168.1.230-192.168.1.240" \
|
||||
./bootstrap-install.sh env3 \
|
||||
SPX_EXTERNAL_POOL="${SPINIFEX_CI_POOL}" \
|
||||
./bootstrap-install.sh "${SPINIFEX_CI_ENV}" \
|
||||
"${RUNNER_TEMP}/setup.sh" \
|
||||
"${RUNNER_TEMP}/spinifex-distro.tar.gz" \
|
||||
--test-tar "${RUNNER_TEMP}/spinifex-tests.tar"
|
||||
@@ -83,25 +85,25 @@ jobs:
|
||||
SSH_KEY=$(echo "$INVENTORY" | jq -r '.ssh_key_path')
|
||||
SSH_KEY="${SSH_KEY/#\~/$HOME}"
|
||||
ssh -o StrictHostKeyChecking=no -i "$SSH_KEY" tf-user@${WAN_IP} \
|
||||
'cd ~/e2e-tests && ./run-e2e.sh && ./run-cert-e2e.sh && ./run-lb-e2e.sh'
|
||||
"cd ~/e2e-tests && ./run-e2e.sh && ./run-cert-e2e.sh && ENDPOINT=https://${WAN_IP}:9999 ./run-lb-e2e.sh"
|
||||
|
||||
- name: Teardown
|
||||
if: always()
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
run: |
|
||||
tofu workspace select -or-create env3 >> "$TOFU_LOG" 2>&1
|
||||
tofu destroy -var-file=envs/env3.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1
|
||||
./scrub-env.sh env3 >> "$TOFU_LOG" 2>&1 || true
|
||||
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
|
||||
|
||||
e2e_pseudo_multi:
|
||||
name: Pseudo Multi-Node E2E
|
||||
runs-on: [self-hosted, ci-pseudo]
|
||||
timeout-minutes: 30
|
||||
runs-on: [ self-hosted, ci-pseudo ]
|
||||
timeout-minutes: 25
|
||||
env:
|
||||
TOFU_LOG: /tmp/e2e-tofu-pseudo-${{ github.run_id }}.log
|
||||
steps:
|
||||
- name: Clean Workspace
|
||||
run: rm -rf "$GITHUB_WORKSPACE"/*
|
||||
run: sudo rm -rf "$GITHUB_WORKSPACE"/*
|
||||
|
||||
- uses: actions/create-github-app-token@v3
|
||||
id: app-token
|
||||
@@ -117,26 +119,27 @@ jobs:
|
||||
sparse-checkout: scripts/tofu-cluster
|
||||
path: mulga
|
||||
|
||||
- name: Clone and build
|
||||
- name: Clone and Build
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
run: ./build-artifacts.sh "${GIT_BRANCH}" "${RUNNER_TEMP}/spinifex-artifacts.tar"
|
||||
run: ./build-artifacts.sh "${GIT_BRANCH}"
|
||||
"${RUNNER_TEMP}/spinifex-artifacts.tar"
|
||||
|
||||
- name: Provision VM
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
run: |
|
||||
export TF_VAR_git_branch="${GIT_BRANCH}"
|
||||
tofu init -input=false >> "$TOFU_LOG" 2>&1
|
||||
tofu workspace select -or-create env4 >> "$TOFU_LOG" 2>&1
|
||||
tofu destroy -var-file=envs/env4.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1 || true
|
||||
./scrub-env.sh env4 >> "$TOFU_LOG" 2>&1 || true
|
||||
tofu apply -var-file=envs/env4.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1
|
||||
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 || true
|
||||
./scrub-env.sh "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1 || true
|
||||
tofu apply -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1
|
||||
|
||||
- name: Bootstrap Cluster
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
run: |
|
||||
SPX_PREBUILT=true \
|
||||
SPX_ARTIFACT_TAR="${RUNNER_TEMP}/spinifex-artifacts.tar" \
|
||||
./bootstrap.sh env4 >> "$TOFU_LOG" 2>&1
|
||||
./bootstrap.sh "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1
|
||||
|
||||
- name: Run E2E Tests
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
@@ -146,25 +149,25 @@ jobs:
|
||||
SSH_KEY=$(echo "$INVENTORY" | jq -r '.ssh_key_path')
|
||||
SSH_KEY="${SSH_KEY/#\~/$HOME}"
|
||||
ssh -o StrictHostKeyChecking=no -i "$SSH_KEY" tf-user@${WAN_IP} \
|
||||
'cd ~/Development/mulga/spinifex && SPX_EXTERNAL_POOL="192.168.1.220-192.168.1.229" ./tests/e2e/run-pseudo-multinode-e2e.sh'
|
||||
"cd ~/Development/mulga/spinifex && SPX_EXTERNAL_POOL=\"${SPINIFEX_CI_POOL}\" ./tests/e2e/run-pseudo-multinode-e2e.sh"
|
||||
|
||||
- name: Teardown
|
||||
if: always()
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
run: |
|
||||
tofu workspace select -or-create env4 >> "$TOFU_LOG" 2>&1
|
||||
tofu destroy -var-file=envs/env4.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1
|
||||
./scrub-env.sh env4 >> "$TOFU_LOG" 2>&1 || true
|
||||
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
|
||||
|
||||
e2e_real_multi:
|
||||
name: Real Multi-Node E2E
|
||||
runs-on: [self-hosted, ci-multi]
|
||||
timeout-minutes: 35
|
||||
runs-on: [ self-hosted, ci-multi ]
|
||||
timeout-minutes: 30
|
||||
env:
|
||||
TOFU_LOG: /tmp/e2e-tofu-multi-${{ github.run_id }}.log
|
||||
steps:
|
||||
- name: Clean Workspace
|
||||
run: rm -rf "$GITHUB_WORKSPACE"/*
|
||||
run: sudo rm -rf "$GITHUB_WORKSPACE"/*
|
||||
|
||||
- uses: actions/create-github-app-token@v3
|
||||
id: app-token
|
||||
@@ -193,16 +196,16 @@ jobs:
|
||||
run: |
|
||||
export TF_VAR_git_branch="${GIT_BRANCH}"
|
||||
tofu init -input=false >> "$TOFU_LOG" 2>&1
|
||||
tofu workspace select -or-create env2 >> "$TOFU_LOG" 2>&1
|
||||
tofu destroy -var-file=envs/env2.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1 || true
|
||||
./scrub-env.sh env2 >> "$TOFU_LOG" 2>&1 || true
|
||||
tofu apply -var-file=envs/env2.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1
|
||||
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 || true
|
||||
./scrub-env.sh "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1 || true
|
||||
tofu apply -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1
|
||||
|
||||
- name: Bootstrap via Binary Install
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
run: |
|
||||
SPX_EXTERNAL_POOL="192.168.1.240-192.168.1.250" \
|
||||
./bootstrap-install.sh env2 \
|
||||
SPX_EXTERNAL_POOL="${SPINIFEX_CI_POOL}" \
|
||||
./bootstrap-install.sh "${SPINIFEX_CI_ENV}" \
|
||||
"${RUNNER_TEMP}/setup.sh" \
|
||||
"${RUNNER_TEMP}/spinifex-distro.tar.gz" \
|
||||
--test-tar "${RUNNER_TEMP}/spinifex-tests.tar"
|
||||
@@ -231,18 +234,18 @@ jobs:
|
||||
if: always()
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
run: |
|
||||
tofu workspace select -or-create env2 >> "$TOFU_LOG" 2>&1
|
||||
tofu destroy -var-file=envs/env2.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1
|
||||
./scrub-env.sh env2 >> "$TOFU_LOG" 2>&1 || true
|
||||
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
|
||||
|
||||
e2e_status:
|
||||
name: Report E2E Status
|
||||
if: always() && inputs.sha
|
||||
needs: [e2e_single, e2e_pseudo_multi, e2e_real_multi]
|
||||
runs-on: [self-hosted, ci-single]
|
||||
needs: [ e2e_single, e2e_pseudo_multi, e2e_real_multi ]
|
||||
runs-on: [ self-hosted, ci-single ]
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Report status to PR
|
||||
- name: Report Status to PR
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
|
||||
@@ -2,7 +2,7 @@ name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags: ["v*"]
|
||||
tags: [ "v*" ]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -31,6 +31,14 @@ jobs:
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.26.3'
|
||||
|
||||
- name: Build microVM artifacts (lb-agent + kernel + initramfs)
|
||||
run: cd spinifex && make build-microvm-image
|
||||
|
||||
- name: Build amd64 Tarball
|
||||
env:
|
||||
VERSION: ${{ github.ref_name }}
|
||||
@@ -82,7 +90,7 @@ jobs:
|
||||
spinifex/dist/spinifex-${{ github.ref_name }}-linux-arm64.tar.gz.sha256
|
||||
|
||||
release:
|
||||
needs: [distro-amd64, distro-arm64]
|
||||
needs: [ distro-amd64, distro-arm64 ]
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Download amd64 Artifacts
|
||||
@@ -115,7 +123,7 @@ jobs:
|
||||
|
||||
verify:
|
||||
needs: release
|
||||
runs-on: [self-hosted, ci-single]
|
||||
runs-on: [ self-hosted, ci-single ]
|
||||
timeout-minutes: 25
|
||||
env:
|
||||
TOFU_LOG: /tmp/release-verify-${{ github.run_id }}.log
|
||||
@@ -128,7 +136,7 @@ jobs:
|
||||
- uses: actions/create-github-app-token@v3
|
||||
id: app-token
|
||||
with:
|
||||
app-id: ${{ secrets.APP_ID }}
|
||||
client-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
repositories: mulga
|
||||
|
||||
@@ -149,17 +157,17 @@ jobs:
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
run: |
|
||||
tofu init -input=false >> "$TOFU_LOG" 2>&1
|
||||
tofu workspace select -or-create env3 >> "$TOFU_LOG" 2>&1
|
||||
tofu destroy -var-file=envs/env3.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1 || true
|
||||
./scrub-env.sh env3 >> "$TOFU_LOG" 2>&1 || true
|
||||
tofu apply -var-file=envs/env3.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1
|
||||
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 || true
|
||||
./scrub-env.sh "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1 || true
|
||||
tofu apply -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1
|
||||
|
||||
- name: Bootstrap via Binary Install
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
run: |
|
||||
SETUP_SH=$(find "${GITHUB_WORKSPACE}/release-assets" -name "setup.sh" | head -1)
|
||||
TARBALL=$(find "${GITHUB_WORKSPACE}/release-assets" -name "*-linux-amd64.tar.gz" | head -1)
|
||||
./bootstrap-install.sh env3 "$SETUP_SH" "$TARBALL"
|
||||
./bootstrap-install.sh "${SPINIFEX_CI_ENV}" "$SETUP_SH" "$TARBALL"
|
||||
|
||||
- name: Promote Release
|
||||
if: success()
|
||||
@@ -189,6 +197,153 @@ jobs:
|
||||
if: always()
|
||||
working-directory: mulga/scripts/tofu-cluster
|
||||
run: |
|
||||
tofu workspace select -or-create env3 >> "$TOFU_LOG" 2>&1
|
||||
tofu destroy -var-file=envs/env3.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1
|
||||
./scrub-env.sh env3 >> "$TOFU_LOG" 2>&1 || true
|
||||
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
|
||||
steps:
|
||||
- name: Clean Workspace
|
||||
run: |
|
||||
ROOTFS="${GITHUB_WORKSPACE}/mulga/scripts/iso-builder/.cache/rootfs"
|
||||
# Mirror make clean's umount sequence unconditionally — lazy unmount
|
||||
# detaches immediately even if the mount is busy, so rm -rf is safe.
|
||||
sudo umount -l -R "${ROOTFS}" 2>/dev/null || true
|
||||
sudo umount -l "${ROOTFS}/dev/pts" 2>/dev/null || true
|
||||
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"/*
|
||||
|
||||
- uses: actions/create-github-app-token@v3
|
||||
id: app-token
|
||||
with:
|
||||
client-id: ${{ secrets.APP_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
repositories: mulga
|
||||
|
||||
- name: Checkout Spinifex
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
path: spinifex
|
||||
ref: ${{ github.ref }}
|
||||
|
||||
- name: Checkout Predastore
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
repository: mulgadc/predastore
|
||||
path: predastore
|
||||
|
||||
- name: Checkout Viperblock
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
repository: mulgadc/viperblock
|
||||
path: viperblock
|
||||
|
||||
- name: Checkout Mulga iso-builder
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
repository: mulgadc/mulga
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
sparse-checkout: scripts/iso-builder
|
||||
path: mulga
|
||||
|
||||
- name: Clean ISO builder cache
|
||||
run: sudo make -C mulga/scripts/iso-builder clean
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: spinifex/go.mod
|
||||
cache: false
|
||||
|
||||
- name: Configure Go workspace
|
||||
run: go work init ./spinifex ./predastore ./viperblock
|
||||
|
||||
- name: Install ISO builder dependencies
|
||||
run: |
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y \
|
||||
debootstrap squashfs-tools xorriso grub-pc-bin grub-efi-amd64-bin \
|
||||
grub-common mtools busybox-static ovmf zstd \
|
||||
nbdkit nbdkit-plugin-dev pkg-config gcc
|
||||
|
||||
- name: Build spinifex binaries
|
||||
run: cd spinifex && make build
|
||||
|
||||
- name: Build viperblock
|
||||
run: cd viperblock && make build
|
||||
|
||||
- name: Build release ISO
|
||||
id: build-iso
|
||||
env:
|
||||
VERSION: ${{ github.ref_name }}
|
||||
run: |
|
||||
sudo make -C mulga/scripts/iso-builder release \
|
||||
VERSION="${VERSION}" \
|
||||
SPINIFEX_SRC="${GITHUB_WORKSPACE}/spinifex" \
|
||||
VIPERBLOCK_SRC="${GITHUB_WORKSPACE}/viperblock" \
|
||||
SPINIFEX_BIN="${GITHUB_WORKSPACE}/spinifex/bin/spx" \
|
||||
INSTALLER_BIN="${GITHUB_WORKSPACE}/spinifex/bin/spinifex-installer"
|
||||
|
||||
- name: Wait for verify
|
||||
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..."
|
||||
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
|
||||
exit 1
|
||||
fi
|
||||
echo "Verify still running, retrying in 30s..."
|
||||
sleep 30
|
||||
done
|
||||
|
||||
- name: Ensure AWS CLI is installed
|
||||
if: steps.build-iso.outcome == 'success' && steps.wait-verify.outcome == 'success'
|
||||
run: |
|
||||
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: Upload ISO to Cloudflare R2
|
||||
id: upload
|
||||
if: steps.build-iso.outcome == 'success' && steps.wait-verify.outcome == 'success'
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.CF_R2_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.CF_R2_SECRET_ACCESS_KEY }}
|
||||
VERSION: ${{ github.ref_name }}
|
||||
run: |
|
||||
aws --endpoint-url=https://e80e329f7dbcf75b80e019c1dce640f2.r2.cloudflarestorage.com \
|
||||
s3 cp "mulga/scripts/iso-builder/dist/spinifex-${VERSION}.iso" \
|
||||
"s3://spinifex-iso/spinifex-${VERSION}.iso"
|
||||
aws --endpoint-url=https://e80e329f7dbcf75b80e019c1dce640f2.r2.cloudflarestorage.com \
|
||||
s3 cp "mulga/scripts/iso-builder/dist/spinifex-${VERSION}.iso.sha256" \
|
||||
"s3://spinifex-iso/spinifex-${VERSION}.iso.sha256"
|
||||
|
||||
- name: Mark ISO failed
|
||||
if: always() && (steps.build-iso.outcome == 'failure' || steps.upload.outcome == 'failure')
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
REPO: ${{ github.repository }}
|
||||
run: |
|
||||
EXISTING_BODY=$(gh release view "$TAG" --repo "$REPO" --json body -q .body)
|
||||
NOTES_FILE=$(mktemp)
|
||||
printf 'ISO build/upload failed — 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"
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
name: Spinifex Build and Test
|
||||
name: Build and Test
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
paths:
|
||||
- "**/*.go"
|
||||
- go.mod
|
||||
@@ -13,11 +14,15 @@ on:
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build_and_test:
|
||||
name: Spinifex Build and Test
|
||||
name: Build and Test
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
steps:
|
||||
@@ -31,12 +36,14 @@ jobs:
|
||||
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
|
||||
@@ -58,7 +65,7 @@ jobs:
|
||||
run: cd spinifex && scripts/diff-coverage.sh coverage.out --base HEAD~1
|
||||
|
||||
race_detection:
|
||||
name: Spinifex Race Detection
|
||||
name: Race Detection
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
steps:
|
||||
@@ -71,12 +78,14 @@ jobs:
|
||||
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
|
||||
@@ -92,7 +101,7 @@ jobs:
|
||||
run: make -C spinifex test-race
|
||||
|
||||
lint_and_security:
|
||||
name: Spinifex Lint and Security
|
||||
name: Lint and Security
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
steps:
|
||||
@@ -105,12 +114,14 @@ jobs:
|
||||
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
|
||||
|
||||
@@ -4,17 +4,22 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
paths:
|
||||
- spinifex/services/spinifexui/frontend/**
|
||||
- .github/workflows/ui.yml
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
paths:
|
||||
- spinifex/services/spinifexui/frontend/**
|
||||
- .github/workflows/ui.yml
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
ui_build_and_test:
|
||||
name: UI Build and Test
|
||||
@@ -44,8 +49,8 @@ jobs:
|
||||
- name: Install Dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run Check
|
||||
run: pnpm check
|
||||
- name: Run Lint
|
||||
run: pnpm lint
|
||||
|
||||
- name: Run Tests with Coverage
|
||||
run: pnpm test:coverage
|
||||
|
||||
@@ -17,6 +17,8 @@ go.work.sum
|
||||
# Build outputs
|
||||
bin/
|
||||
dist/
|
||||
build/microvm/vmlinuz
|
||||
build/microvm/initramfs.cpio.gz
|
||||
!spinifex/services/spinifexui/frontend/dist/
|
||||
tmp/
|
||||
*.test
|
||||
@@ -31,6 +33,7 @@ Thumbs.db
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.venv/
|
||||
|
||||
# Test reports
|
||||
*-report.txt
|
||||
|
||||
@@ -34,6 +34,9 @@ else
|
||||
endif
|
||||
|
||||
# Quiet-mode filters (active when QUIET=1, set by preflight via recursive make)
|
||||
# Note: grep pipelines use PIPESTATUS[0] so the exit status of `go test`
|
||||
# propagates through the filter — otherwise a test failure is swallowed by
|
||||
# grep's own (success) exit code and preflight prints "passed" on red.
|
||||
ifdef QUIET
|
||||
_Q = @
|
||||
_COVQ = 2>&1 | { grep -Ev '^\s*(ok|PASS|\?|=== RUN|--- PASS:)\s' | grep -v 'coverage: 0\.0%' || true; }; exit $${PIPESTATUS[0]}
|
||||
@@ -46,8 +49,7 @@ else
|
||||
_SECQ = 2>&1 | tee
|
||||
endif
|
||||
|
||||
build:
|
||||
$(MAKE) go_build
|
||||
build: go_build build-installer build-lb-agent
|
||||
|
||||
# Build spinifex-ui frontend (requires pnpm)
|
||||
build-ui:
|
||||
@@ -61,11 +63,15 @@ LDFLAGS := -s -w -X github.com/mulgadc/spinifex/cmd/spinifex/cmd.Version=$(VERSI
|
||||
|
||||
go_build:
|
||||
@echo -e "\n....Building $(GO_PROJECT_NAME)"
|
||||
go build $(GO_BUILD_MOD) -ldflags "$(LDFLAGS)" -o ./bin/$(GO_PROJECT_NAME) cmd/spinifex/main.go
|
||||
GOFIPS140=v1.0.0 go build $(GO_BUILD_MOD) -ldflags "$(LDFLAGS)" -o ./bin/$(GO_PROJECT_NAME) cmd/spinifex/main.go
|
||||
|
||||
build-installer:
|
||||
@echo -e "\n....Building spinifex-installer"
|
||||
GOFIPS140=v1.0.0 go build -ldflags "-s -w" -o ./bin/spinifex-installer cmd/installer/main.go
|
||||
|
||||
build-lb-agent:
|
||||
@echo -e "\n....Building lb-agent (static)"
|
||||
CGO_ENABLED=0 go build -ldflags "-s -w" -o ./bin/lb-agent cmd/lb-agent/main.go
|
||||
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)
|
||||
ifndef IMAGE
|
||||
@@ -73,8 +79,25 @@ ifndef IMAGE
|
||||
endif
|
||||
./scripts/build-system-image.sh scripts/images/$(IMAGE).conf
|
||||
|
||||
build-lb-image: ## Build LB Alpine image
|
||||
$(MAKE) build-system-image IMAGE=lb
|
||||
MICROVM_OUT_DIR := build/microvm
|
||||
MICROVM_ARTIFACTS := $(MICROVM_OUT_DIR)/vmlinuz $(MICROVM_OUT_DIR)/initramfs.cpio.gz
|
||||
MICROVM_INPUTS := scripts/build-microvm-image.sh $(MICROVM_OUT_DIR)/init.sh $(MICROVM_OUT_DIR)/inittab bin/lb-agent
|
||||
|
||||
# Grouped target — script writes both files in one run.
|
||||
$(MICROVM_ARTIFACTS) &: $(MICROVM_INPUTS)
|
||||
./scripts/build-microvm-image.sh
|
||||
|
||||
# Only triggers when bin/lb-agent is missing; preserves the artifact's mtime so
|
||||
# build-microvm-image stays correctly stale-aware.
|
||||
bin/lb-agent:
|
||||
$(MAKE) build-lb-agent
|
||||
|
||||
build-microvm-image: $(MICROVM_ARTIFACTS) ## Build microVM kernel + initramfs (incremental — skips when up to date)
|
||||
|
||||
install-microvm: $(MICROVM_ARTIFACTS) ## Install microVM artifacts to /usr/share/spinifex/microvm/
|
||||
sudo install -d /usr/share/spinifex/microvm
|
||||
sudo install -m 0644 $(MICROVM_OUT_DIR)/vmlinuz /usr/share/spinifex/microvm/vmlinuz
|
||||
sudo install -m 0644 $(MICROVM_OUT_DIR)/initramfs.cpio.gz /usr/share/spinifex/microvm/initramfs.cpio.gz
|
||||
|
||||
go_run:
|
||||
@echo -e "\n....Running $(GO_PROJECT_NAME)...."
|
||||
@@ -116,9 +139,18 @@ run:
|
||||
$(MAKE) go_build
|
||||
$(MAKE) go_run
|
||||
|
||||
# Fast iteration: build + install binary + restart all services
|
||||
# Fast iteration: build + install binary + restart all services.
|
||||
# Microvm artifacts are reinstalled when they already exist on disk — the rule's
|
||||
# input timestamps drive a rebuild only if anything actually changed. On a fresh
|
||||
# checkout (no build/microvm/vmlinuz yet) the install step is skipped; run
|
||||
# `make install-microvm` explicitly the first time.
|
||||
deploy: build
|
||||
sudo install -m 755 bin/spx /usr/local/bin/spx
|
||||
@if [ -f $(MICROVM_OUT_DIR)/vmlinuz ]; then \
|
||||
$(MAKE) install-microvm; \
|
||||
else \
|
||||
echo "[deploy] microvm artifacts absent — run 'make install-microvm' for first-time setup"; \
|
||||
fi
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl restart spinifex.target
|
||||
|
||||
@@ -143,9 +175,9 @@ install-system:
|
||||
ovn-central ovn-host openvswitch-switch dhcpcd-base
|
||||
|
||||
install-go:
|
||||
@echo -e "\n....Installing Go 1.26.2 for $(ARCH) ($(GO_ARCH))...."
|
||||
@echo -e "\n....Installing Go 1.26.3 for $(ARCH) ($(GO_ARCH))...."
|
||||
@if [ ! -d "/usr/local/go" ]; then \
|
||||
curl -L https://go.dev/dl/go1.26.2.linux-$(GO_ARCH).tar.gz | tar -C /usr/local -xz; \
|
||||
curl -L https://go.dev/dl/go1.26.3.linux-$(GO_ARCH).tar.gz | tar -C /usr/local -xz; \
|
||||
else \
|
||||
echo "Go already installed in /usr/local/go"; \
|
||||
fi
|
||||
@@ -200,6 +232,14 @@ distro-amd64:
|
||||
-f build/Dockerfile.distro \
|
||||
--output type=local,dest=dist/amd64/ \
|
||||
../
|
||||
@if [ -f $(MICROVM_OUT_DIR)/vmlinuz ] && [ -f $(MICROVM_OUT_DIR)/initramfs.cpio.gz ]; then \
|
||||
echo "[distro-amd64] staging microvm artifacts into tarball"; \
|
||||
mkdir -p dist/amd64/microvm; \
|
||||
cp $(MICROVM_OUT_DIR)/vmlinuz $(MICROVM_OUT_DIR)/initramfs.cpio.gz dist/amd64/microvm/; \
|
||||
else \
|
||||
echo "[distro-amd64] WARNING: microvm artifacts missing — tarball will not include them"; \
|
||||
echo "[distro-amd64] run 'make build-microvm-image' before 'make distro-amd64'"; \
|
||||
fi
|
||||
tar -czf dist/spinifex-$(VERSION)-linux-amd64.tar.gz -C dist/amd64 .
|
||||
sha256sum dist/spinifex-$(VERSION)-linux-amd64.tar.gz > dist/spinifex-$(VERSION)-linux-amd64.tar.gz.sha256
|
||||
|
||||
@@ -218,8 +258,26 @@ distro-arm64:
|
||||
distro-clean:
|
||||
rm -rf dist/
|
||||
|
||||
.PHONY: build build-ui build-lb-agent build-system-image build-lb-image go_build go_run preflight test test-cover test-race diff-coverage bench run \
|
||||
# Ansible dev lifecycle (experimental, parallel to dev-*.sh / reset-dev-env.sh).
|
||||
# See scripts/ansible/README.md and docs/development/improvements/ansible-dev-lifecycle.md.
|
||||
ansible-dev-preflight:
|
||||
cd scripts/ansible && ansible-playbook playbooks/dev-preflight.yml
|
||||
|
||||
ansible-dev-teardown:
|
||||
cd scripts/ansible && ansible-playbook playbooks/dev-teardown.yml
|
||||
|
||||
ansible-dev-install:
|
||||
cd scripts/ansible && ansible-playbook playbooks/dev-install.yml
|
||||
|
||||
ansible-dev-reset:
|
||||
cd scripts/ansible && ansible-playbook playbooks/dev-reset.yml
|
||||
|
||||
ansible-dev-deploy:
|
||||
cd scripts/ansible && ansible-playbook playbooks/dev-deploy.yml
|
||||
|
||||
.PHONY: build build-ui build-installer build-lb-agent build-system-image build-microvm-image install-microvm go_build go_run preflight test test-cover test-race diff-coverage bench run \
|
||||
deploy reinstall clean \
|
||||
install-system install-go install-aws quickinstall \
|
||||
lint fix govulncheck \
|
||||
distro distro-amd64 distro-arm64 distro-clean
|
||||
distro distro-amd64 distro-arm64 distro-clean \
|
||||
ansible-dev-preflight ansible-dev-teardown ansible-dev-install ansible-dev-reset ansible-dev-deploy
|
||||
|
||||
@@ -3,10 +3,9 @@
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://go.dev/"><img src="https://img.shields.io/badge/Go-1.22+-00ADD8?style=flat-square&logo=go&logoColor=white" alt="Go"></a>
|
||||
<a href="https://go.dev"><img src="https://img.shields.io/badge/Go-1.26+-00ADD8?style=flat-square&logo=go&logoColor=white" alt="Go"></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/License-AGPL--3.0-3fb950?style=flat-square" alt="License"></a>
|
||||
<a href="https://docs.mulgadc.com"><img src="https://img.shields.io/badge/Docs-mulgadc.com-DC2626?style=flat-square" alt="Docs"></a>
|
||||
<a href="https://github.com/mulgadc/spinifex/actions"><img src="https://img.shields.io/badge/CI-e2e-58a6ff?style=flat-square" alt="CI"></a>
|
||||
<a href="https://docs.mulgadc.com"><img src="https://img.shields.io/badge/Docs-docs.mulgadc.com-DC2626?style=flat-square" alt="Docs"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -92,11 +91,26 @@ AWS-Compatible Interfaces – Provision infrastructure with awscli, Terraform, o
|
||||
|
||||
## Installation
|
||||
|
||||
Installation requires an Ubuntu / Debian system. See the detailed documentation at [https://docs.mulgadc.com](https://docs.mulgadc.com) for maintaining and installing Spinifex.
|
||||
Installation requires an Ubuntu / Debian system. See the detailed documentation at [docs.mulgadc.com](https://docs.mulgadc.com) for maintaining and installing Spinifex.
|
||||
|
||||
### Bare Metal ISO
|
||||
|
||||
The recommended installation is a [bootable x86 installer](https://iso.mulgadc.com/spinifex.iso) for bare-metal hardware.
|
||||
|
||||
```bash
|
||||
curl -fLO https://iso.mulgadc.com/spinifex.iso
|
||||
```
|
||||
|
||||
Follow the [USB install guide](https://docs.mulgadc.com/docs/install-usb) to write the ISO to USB and install on your hardware. The install guide walks through the full process.
|
||||
|
||||
|
||||
### Single Node Install
|
||||
|
||||
The installation is straightforward to set up and running on a single node for testing purposes.
|
||||
The installation is straightforward to set up and running on a single node for testing purposes. Debian 13 is currently supported, additional Linux distributions are on the immediate roadmap.
|
||||
|
||||
>*Prerequisite:* Linux bridge for networking.
|
||||
|
||||
Spinifex requires a Linux bridge configured on the host for VM networking. See the [single-node install guide](https://docs.mulgadc.com/docs/install#prerequisites) prerequisites for setup details.
|
||||
|
||||
```bash
|
||||
curl -fsSL https://install.mulgadc.com | bash
|
||||
@@ -130,7 +144,7 @@ Each component can be developed independently. See component-specific documentat
|
||||
Spinifex ships with a built-in web console — an optional alternative to the AWS CLI, SDKs, and Terraform. If you're familiar with the AWS Management Console, the Spinifex UI fills the same role: a browser-based view of your instances, volumes, buckets, VPCs, and IAM resources, without leaving your own network.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/spinifex-ui.jpg" alt="Spinifex web console — dashboard view" width="900">
|
||||
<img src=".github/assets/spinifex-ui.jpg" alt="Spinifex web console — dashboard view" width="900">
|
||||
</p>
|
||||
|
||||
The console is served by each node on port `3000` over TLS, and becomes available as soon as `spinifex.target` is up:
|
||||
@@ -180,4 +194,4 @@ This hybrid approach ensures Spinifex benefits from both proven engineering expe
|
||||
|
||||
## License
|
||||
|
||||
Spinifex is open source under the [GNU Affero General Public License v3.0](LICENSE). You're free to use, modify, and deploy it—anywhere you need reliable infrastructure without depending on centralized cloud platforms.
|
||||
Spinifex is open source under the [GNU Affero General Public License v3.0](LICENSE). You're free to use, modify, and deploy it anywhere you need reliable infrastructure without depending on centralized cloud platforms.
|
||||
|
||||
@@ -30,13 +30,13 @@ COPY viperblock/ ./viperblock/
|
||||
|
||||
# Build spinifex binary (spx)
|
||||
RUN COMMIT=$(cd spinifex && git rev-parse --short HEAD 2>/dev/null || echo "unknown") && \
|
||||
cd spinifex && go build -ldflags "-s -w \
|
||||
cd spinifex && GOFIPS140=v1.0.0 go build -ldflags "-s -w \
|
||||
-X github.com/mulgadc/spinifex/cmd/spinifex/cmd.Version=${VERSION} \
|
||||
-X github.com/mulgadc/spinifex/cmd/spinifex/cmd.Commit=${COMMIT}" \
|
||||
-o /out/spx cmd/spinifex/main.go
|
||||
|
||||
# Build nbdkit viperblock plugin (.so)
|
||||
RUN cd viperblock && go build -o /out/nbdkit-viperblock-plugin.so -buildmode=c-shared nbd/viperblock.go
|
||||
RUN cd viperblock && GOFIPS140=v1.0.0 go build -o /out/nbdkit-viperblock-plugin.so -buildmode=c-shared nbd/viperblock.go
|
||||
|
||||
# Copy setup scripts and systemd units
|
||||
RUN cp spinifex/scripts/setup.sh /out/setup.sh 2>/dev/null || true
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
#!/bin/sh
|
||||
mount -t proc proc /proc
|
||||
mount -t sysfs sys /sys
|
||||
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
|
||||
|
||||
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
|
||||
done
|
||||
|
||||
if [ ! -f /sys/firmware/qemu_fw_cfg/by_name/opt/spinifex/netcfg/raw ]; then
|
||||
echo "[init] FATAL: fw_cfg netcfg not available" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
. /sys/firmware/qemu_fw_cfg/by_name/opt/spinifex/netcfg/raw
|
||||
|
||||
for n in 0 1 2 3 4 5; do
|
||||
eval mac=\$NIC${n}_MAC
|
||||
[ -z "$mac" ] && continue
|
||||
|
||||
iface=""
|
||||
for attempt in 1 2 3 4 5; do
|
||||
iface=$(ip -o link | awk -v m="$mac" 'tolower($0) ~ tolower(m) {print $2}' | tr -d :)
|
||||
[ -n "$iface" ] && break
|
||||
sleep 0.02
|
||||
done
|
||||
|
||||
if [ -z "$iface" ]; then
|
||||
echo "[init] WARNING: no interface found for MAC $mac (NIC$n), skipping" >&2
|
||||
continue
|
||||
fi
|
||||
|
||||
eval cidr=\$NIC${n}_CIDR
|
||||
eval gw=\$NIC${n}_GW
|
||||
eval default=\$NIC${n}_DEFAULT
|
||||
eval rdst=\$NIC${n}_ROUTE_DST
|
||||
eval rvia=\$NIC${n}_ROUTE_VIA
|
||||
|
||||
ip link set "$iface" up
|
||||
[ -n "$cidr" ] && ip addr add "$cidr" dev "$iface"
|
||||
[ "$default" = "1" ] && [ -n "$gw" ] && ip route add default via "$gw" dev "$iface"
|
||||
[ -n "$rdst" ] && [ -n "$rvia" ] && ip route add "$rdst" via "$rvia" dev "$iface"
|
||||
done
|
||||
|
||||
cp /sys/firmware/qemu_fw_cfg/by_name/opt/spinifex/lb-agent-env/raw /etc/conf.d/lb-agent
|
||||
mkdir -p /etc/ssl/certs
|
||||
cp /sys/firmware/qemu_fw_cfg/by_name/opt/spinifex/ca-cert/raw /etc/ssl/certs/ca-certificates.crt
|
||||
|
||||
exec /sbin/init
|
||||
@@ -0,0 +1,4 @@
|
||||
::sysinit:/etc/rc.local
|
||||
::respawn:/bin/sh -c 'set -a; . /etc/conf.d/lb-agent; set +a; exec /usr/local/bin/lb-agent'
|
||||
::ctrlaltdel:/sbin/reboot
|
||||
::shutdown:/bin/umount -a -r
|
||||
@@ -5,25 +5,26 @@
|
||||
|
||||
CONF=/etc/spinifex/spinifex.toml
|
||||
BIND="0.0.0.0:8443"
|
||||
BACKEND="filesystem"
|
||||
NODE_ID="0"
|
||||
# -1 = co-located mode (all [[db]] peers in one process). Single-node spinifex
|
||||
# templates list 3 DB peers on 127.0.0.1; only -1 can serve that topology.
|
||||
# Multi-node spinifex.toml emits node_id = N (N >= 1) which overrides below.
|
||||
# predastore rejects NODE_ID=0 since d3a02f0 (strconv.Atoi-of-garbage guard).
|
||||
NODE_ID="-1"
|
||||
|
||||
if [ -f "$CONF" ]; then
|
||||
H=$(awk -F'"' '/\[nodes\..*\.predastore\]/{f=1} f&&/^host/{print $2;exit}' "$CONF")
|
||||
if [ -n "$H" ]; then
|
||||
BIND="$H"
|
||||
BACKEND="distributed"
|
||||
fi
|
||||
|
||||
N=$(awk -F'= *' '/node_id/{gsub(/ /,"",$2);print $2;exit}' "$CONF")
|
||||
if [ -n "$N" ] && [ "$N" != "0" ]; then
|
||||
if [ -n "$N" ]; then
|
||||
NODE_ID="$N"
|
||||
fi
|
||||
fi
|
||||
|
||||
export SPINIFEX_PREDASTORE_HOST="${BIND%%:*}"
|
||||
export SPINIFEX_PREDASTORE_PORT="${BIND##*:}"
|
||||
export SPINIFEX_PREDASTORE_BACKEND="$BACKEND"
|
||||
export SPINIFEX_PREDASTORE_NODE_ID="$NODE_ID"
|
||||
|
||||
exec /usr/local/bin/spx service predastore start
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
[Unit]
|
||||
Description=Regenerate SSH host keys
|
||||
ConditionPathExists=!/etc/ssh/ssh_host_ed25519_key
|
||||
Before=ssh.service
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/bin/ssh-keygen -A
|
||||
RemainAfterExit=yes
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
@@ -12,6 +12,7 @@ ExecStartPre=/var/lib/spinifex/wait-for-nats.sh
|
||||
ExecStart=/usr/local/bin/spx service awsgw start
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
|
||||
OOMScoreAdjust=-500
|
||||
Environment=SPINIFEX_CONFIG_PATH=/etc/spinifex/spinifex.toml
|
||||
Environment=SPINIFEX_BASE_DIR=/var/lib/spinifex/awsgw/
|
||||
@@ -22,7 +23,7 @@ Environment=SPINIFEX_AWSGW_TLS_KEY=/etc/spinifex/server.key
|
||||
NoNewPrivileges=yes
|
||||
ProtectSystem=strict
|
||||
PrivateDevices=yes
|
||||
ReadOnlyPaths=/etc/spinifex/spinifex.toml /etc/spinifex/master.key /etc/spinifex/server.pem /etc/spinifex/server.key
|
||||
ReadOnlyPaths=/etc/spinifex/spinifex.toml /etc/spinifex/master.key /etc/spinifex/server.pem /etc/spinifex/server.key /etc/spinifex/ca.pem
|
||||
ReadWritePaths=/var/lib/spinifex/awsgw /var/log/spinifex
|
||||
|
||||
# Hardening
|
||||
|
||||
@@ -1,29 +1,69 @@
|
||||
[Unit]
|
||||
Description=Spinifex Daemon (VM Orchestration)
|
||||
PartOf=spinifex.target
|
||||
After=spinifex-nats.service spinifex-viperblock.service
|
||||
Wants=spinifex-nats.service
|
||||
After=spinifex-nats.service spinifex-viperblock.service openvswitch-switch.service ovn-controller.service
|
||||
# No Wants=spinifex-nats.service: Wants= reactivates a stopped unit during
|
||||
# `systemctl restart spinifex-daemon`, which would mask the standalone-startup
|
||||
# path the Tier 1 DDIL Scenario B exercises. spinifex.target Wants= covers
|
||||
# cold boot.
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=spinifex-daemon
|
||||
Group=spinifex
|
||||
ExecStartPre=/var/lib/spinifex/wait-for-nats.sh
|
||||
# DDIL Tier 1 (siv-17): no ExecStartPre wait-for-nats. The daemon starts in
|
||||
# standalone mode and reconnects to NATS in the background — process-exiting
|
||||
# on a missing NATS would kill the local VM management plane.
|
||||
#
|
||||
# Opt-in strict-startup (§1d-strict, siv-44): set SPINIFEX_REQUIRE_NATS=1 to
|
||||
# restore pre-DDIL fail-fast UX. First NATS connect uses a ~30s bounded retry
|
||||
# and the daemon aborts on timeout. Intended for dev/test/single-node deploys
|
||||
# where silent NATS misconfig is more confusing than helpful. Do NOT set this
|
||||
# in the shipped unit — it re-introduces the SPOF that Tier 1 removed.
|
||||
ExecStart=/usr/local/bin/spx service spinifex start
|
||||
Restart=on-failure
|
||||
RestartSec=5
|
||||
# DDIL Tier 1 (mulga-siv-58): KillMode=process so systemd only signals the
|
||||
# daemon's main PID, not the cgroup. QEMU children stay alive across
|
||||
# `systemctl restart spinifex-daemon` and the new daemon reattaches to them
|
||||
# via the persisted local state file + pidfile + QMP socket. With the
|
||||
# default control-group mode, restart would terminate every guest VM —
|
||||
# defeating Scenario B's "daemon restart preserves running instances".
|
||||
KillMode=process
|
||||
# 15s upper bound on the SIGTERM grace window. The new SIGTERM handler
|
||||
# skips VM teardown (it now just persists state and exits), so 15s is
|
||||
# plenty of slack for the cluster-manager + NATS unsubscribe path.
|
||||
TimeoutStopSec=15
|
||||
OOMScoreAdjust=-500
|
||||
Environment=SPINIFEX_CONFIG_PATH=/etc/spinifex/spinifex.toml
|
||||
Environment=SPINIFEX_BASE_DIR=/var/lib/spinifex/spinifex/
|
||||
Environment=SPINIFEX_WAL_DIR=/var/lib/spinifex/spinifex/
|
||||
# DDIL Tier 1 (mulga-siv-58): QEMU pidfiles, QMP sockets, and the
|
||||
# shared qemu.log all resolve via utils.pidPath() — which falls back
|
||||
# to os.TempDir() (=/tmp) when $HOME/spinifex doesn't exist. With
|
||||
# PrivateTmp=yes, each daemon start gets a fresh /tmp namespace, so
|
||||
# pidfiles written by QEMU (running in the OLD daemon's namespace)
|
||||
# are invisible to the new daemon. The /local/instances handler then
|
||||
# reports PID=0 and Scenario B fails its "live QEMU PID" assertion.
|
||||
# Pin XDG_RUNTIME_DIR to the host-wide tmpfs at /run/spinifex (created
|
||||
# by tmpfiles.d, already in ReadWritePaths) so pidPath() returns a
|
||||
# stable, namespace-independent path that both daemon incarnations and
|
||||
# QEMU children resolve identically.
|
||||
Environment=XDG_RUNTIME_DIR=/run/spinifex
|
||||
|
||||
# Filesystem access — NoNewPrivileges omitted (daemon uses sudo for ip, ovs-vsctl, dhcpcd)
|
||||
# CAP_SYS_ADMIN: required for GPU passthrough sysfs operations (vfio group management).
|
||||
# CAP_DAC_OVERRIDE: sysfs driver bind/unbind paths (/sys/bus/pci/drivers/vfio-pci/bind,
|
||||
# unbind) are root:root 0200; non-root processes need CAP_DAC_OVERRIDE to open them.
|
||||
AmbientCapabilities=CAP_SYS_ADMIN CAP_DAC_OVERRIDE
|
||||
ProtectSystem=full
|
||||
PrivateDevices=no
|
||||
DevicePolicy=closed
|
||||
DeviceAllow=/dev/kvm rw
|
||||
DeviceAllow=/dev/net/tun rw
|
||||
ReadOnlyPaths=/etc/spinifex/spinifex.toml /etc/spinifex/server.pem /etc/spinifex/server.key
|
||||
DeviceAllow=char-vfio rw
|
||||
DeviceAllow=/dev/vfio/vfio rw
|
||||
ReadOnlyPaths=/etc/spinifex/spinifex.toml /etc/spinifex/server.pem /etc/spinifex/server.key /etc/spinifex/ca.pem
|
||||
ReadWritePaths=/var/lib/spinifex/spinifex /var/log/spinifex /run/spinifex /run/spinifex/nbd
|
||||
|
||||
# Hardening
|
||||
@@ -41,14 +81,18 @@ ProtectControlGroups=yes
|
||||
ProtectProc=invisible
|
||||
SystemCallArchitectures=native
|
||||
MemoryDenyWriteExecute=yes
|
||||
# AF_PACKET is required by dhcpcd (raw L2 BOOTP before the iface has an IP).
|
||||
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK AF_PACKET
|
||||
# DHCP lease acquisition moved to spinifex-vpcd (see dhcp-allocate-error.md);
|
||||
# the daemon no longer needs AF_PACKET or the softened kernel protections.
|
||||
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
|
||||
ProtectHostname=yes
|
||||
KeyringMode=private
|
||||
RemoveIPC=yes
|
||||
LimitCORE=0
|
||||
LimitNOFILE=65536
|
||||
LimitNPROC=4096
|
||||
# VFIO GPU passthrough requires locking all guest RAM into physical memory.
|
||||
# Without this, QEMU fails with RLIMIT_MEMLOCK exceeded when pinning guest pages.
|
||||
LimitMEMLOCK=infinity
|
||||
|
||||
[Install]
|
||||
WantedBy=spinifex.target
|
||||
|
||||
@@ -17,7 +17,7 @@ Environment=SPINIFEX_CONFIG_PATH=/etc/spinifex/nats/nats.conf
|
||||
NoNewPrivileges=yes
|
||||
ProtectSystem=strict
|
||||
PrivateDevices=yes
|
||||
ReadOnlyPaths=/etc/spinifex/nats
|
||||
ReadOnlyPaths=/etc/spinifex/nats /etc/spinifex/server.pem /etc/spinifex/server.key /etc/spinifex/ca.pem
|
||||
ReadWritePaths=/var/lib/spinifex/nats /var/log/spinifex
|
||||
|
||||
# Hardening
|
||||
|
||||
@@ -17,12 +17,13 @@ Environment=SPINIFEX_PREDASTORE_CONFIG_PATH=/etc/spinifex/predastore/predastore.
|
||||
Environment=SPINIFEX_PREDASTORE_BASE_PATH=/var/lib/spinifex/predastore/
|
||||
Environment=SPINIFEX_PREDASTORE_TLS_CERT=/etc/spinifex/server.pem
|
||||
Environment=SPINIFEX_PREDASTORE_TLS_KEY=/etc/spinifex/server.key
|
||||
Environment=SPINIFEX_PREDASTORE_ENCRYPTION_KEY_FILE=/etc/spinifex/predastore/encryption.key
|
||||
|
||||
# Filesystem access
|
||||
NoNewPrivileges=yes
|
||||
ProtectSystem=strict
|
||||
PrivateDevices=yes
|
||||
ReadOnlyPaths=/etc/spinifex/predastore /etc/spinifex/spinifex.toml /etc/spinifex/master.key /etc/spinifex/server.pem /etc/spinifex/server.key
|
||||
ReadOnlyPaths=/etc/spinifex/predastore /etc/spinifex/spinifex.toml /etc/spinifex/master.key /etc/spinifex/server.pem /etc/spinifex/server.key /etc/spinifex/ca.pem
|
||||
ReadWritePaths=/var/lib/spinifex/predastore /var/log/spinifex
|
||||
|
||||
# Hardening
|
||||
|
||||
@@ -21,7 +21,7 @@ Environment=SPINIFEX_BASE_DIR=/var/lib/spinifex/viperblock/
|
||||
NoNewPrivileges=yes
|
||||
ProtectSystem=strict
|
||||
PrivateDevices=yes
|
||||
ReadOnlyPaths=/etc/spinifex/spinifex.toml /etc/spinifex/systemd.env
|
||||
ReadOnlyPaths=/etc/spinifex/spinifex.toml /etc/spinifex/systemd.env /etc/spinifex/ca.pem
|
||||
ReadWritePaths=/var/lib/spinifex/viperblock /var/log/spinifex /run/spinifex/nbd
|
||||
|
||||
# Hardening
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
[Unit]
|
||||
Description=Spinifex VPC Daemon (OVN Networking)
|
||||
PartOf=spinifex.target
|
||||
After=spinifex-nats.service spinifex-daemon.service
|
||||
Wants=spinifex-nats.service
|
||||
After=spinifex-nats.service spinifex-daemon.service systemd-networkd.service openvswitch-switch.service ovn-controller.service
|
||||
Wants=spinifex-nats.service ovn-controller.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
@@ -18,14 +18,14 @@ Environment=SPINIFEX_BASE_DIR=/var/lib/spinifex/vpcd/
|
||||
|
||||
# Filesystem access — NoNewPrivileges omitted (vpcd uses sudo for ovs-vsctl, ovn-nbctl, systemctl)
|
||||
ProtectSystem=full
|
||||
PrivateDevices=yes
|
||||
ReadOnlyPaths=/etc/spinifex/spinifex.toml
|
||||
PrivateDevices=no
|
||||
ReadOnlyPaths=/etc/spinifex/spinifex.toml /etc/spinifex/ca.pem
|
||||
ReadWritePaths=/var/lib/spinifex/vpcd /var/log/spinifex
|
||||
|
||||
# Hardening
|
||||
ProtectHome=yes
|
||||
PrivateTmp=yes
|
||||
RestrictSUIDSGID=yes
|
||||
RestrictSUIDSGID=no
|
||||
LockPersonality=yes
|
||||
RestrictRealtime=yes
|
||||
RestrictNamespaces=yes
|
||||
@@ -36,8 +36,18 @@ ProtectKernelLogs=yes
|
||||
ProtectControlGroups=yes
|
||||
ProtectProc=invisible
|
||||
SystemCallArchitectures=native
|
||||
MemoryDenyWriteExecute=yes
|
||||
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
|
||||
MemoryDenyWriteExecute=no
|
||||
# AF_PACKET: required by the in-process DHCP client (github.com/insomniacslk/dhcp
|
||||
# / nclient4) for raw L2 BOOTP on br-wan before an IP is configured on the
|
||||
# interface. See docs/development/bugs/dhcp-allocate-error.md.
|
||||
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.
|
||||
# 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
|
||||
NoNewPrivileges=no
|
||||
ProtectHostname=yes
|
||||
KeyringMode=private
|
||||
RemoveIPC=yes
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
Description=Spinifex Infrastructure Platform
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
Wants=spinifex-nats.service spinifex-predastore.service spinifex-viperblock.service spinifex-daemon.service spinifex-awsgw.service spinifex-vpcd.service spinifex-ui.service
|
||||
Wants=spinifex-nats.service spinifex-predastore.service spinifex-viperblock.service spinifex-daemon.service spinifex-awsgw.service spinifex-vpcd.service spinifex-ui.service openvswitch-switch.service ovn-controller.service
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# Allow spinifex group to access VFIO devices for GPU passthrough.
|
||||
# QEMU (launched by spinifex-daemon, group spinifex) must open /dev/vfio/N
|
||||
# to use vfio-pci passthrough devices. The kernel creates these nodes as
|
||||
# root:root 0600 by default; this rule grants group-level read-write access.
|
||||
SUBSYSTEM=="vfio", GROUP="spinifex", MODE="0660"
|
||||
@@ -0,0 +1,172 @@
|
||||
// dhcptest is a diagnostic tool that runs a DHCP DORA handshake using the
|
||||
// same nclient4 library and options that spinifex-vpcd uses for gateway pool
|
||||
// leases. Run it on a node to verify DHCP works at the library level before
|
||||
// debugging the full spinifex stack.
|
||||
//
|
||||
// Usage (must run as root — needs AF_PACKET):
|
||||
//
|
||||
// sudo dhcptest --iface=br-wan
|
||||
// sudo dhcptest --iface=br-wan --mac=02:00:00:7c:b7:b9 --promisc
|
||||
// sudo dhcptest --iface=br-wan --spinifex-id=gateway-wan1
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/insomniacslk/dhcp/dhcpv4"
|
||||
"github.com/insomniacslk/dhcp/dhcpv4/nclient4"
|
||||
)
|
||||
|
||||
func main() {
|
||||
iface := flag.String("iface", "br-wan", "Interface/bridge to bind the DHCP client to")
|
||||
macStr := flag.String("mac", "", "Hardware address for DORA (chaddr + AF_PACKET filter). If empty, uses the real interface MAC.")
|
||||
spinifexID := flag.String("spinifex-id", "", "Generate a spinifex virtual MAC from this resource ID (e.g. 'gateway-wan1'). Overrides --mac.")
|
||||
clientID := flag.String("client-id", "", "DHCP option 61 client identifier. Defaults to --spinifex-id or --mac value.")
|
||||
hostname := flag.String("hostname", "", "DHCP option 12 hostname")
|
||||
vendorClass := flag.String("vendor-class", "mulga-spinifex-gw", "DHCP option 60 vendor class identifier")
|
||||
promisc := flag.Bool("promisc", false, "Enable promiscuous mode on the interface before the test (mirrors vpcd's setBridgePromisc)")
|
||||
timeout := flag.Duration("timeout", 10*time.Second, "Per-attempt DHCP timeout")
|
||||
retries := flag.Int("retries", 3, "Number of DORA attempts before giving up")
|
||||
verbose := flag.Bool("verbose", false, "Print full DHCP packet contents")
|
||||
flag.Parse()
|
||||
|
||||
// Resolve the hardware address to use.
|
||||
var hwAddr net.HardwareAddr
|
||||
var err error
|
||||
|
||||
switch {
|
||||
case *spinifexID != "":
|
||||
hwAddr, err = net.ParseMAC(generateMAC(*spinifexID))
|
||||
if err != nil {
|
||||
log.Fatalf("generateMAC(%q): %v", *spinifexID, err)
|
||||
}
|
||||
fmt.Printf("spinifex virtual MAC for %q: %s\n", *spinifexID, hwAddr)
|
||||
case *macStr != "":
|
||||
hwAddr, err = net.ParseMAC(*macStr)
|
||||
if err != nil {
|
||||
log.Fatalf("parse --mac %q: %v", *macStr, err)
|
||||
}
|
||||
default:
|
||||
hwAddr, err = ifaceMAC(*iface)
|
||||
if err != nil {
|
||||
log.Fatalf("get MAC for %s: %v", *iface, err)
|
||||
}
|
||||
fmt.Printf("using real interface MAC: %s\n", hwAddr)
|
||||
}
|
||||
|
||||
// Resolve client-id: default to spinifex-id, then MAC string.
|
||||
cid := *clientID
|
||||
if cid == "" {
|
||||
if *spinifexID != "" {
|
||||
cid = *spinifexID
|
||||
} else {
|
||||
cid = hwAddr.String()
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("iface: %s\n", *iface)
|
||||
fmt.Printf("hw-addr: %s\n", hwAddr)
|
||||
fmt.Printf("client-id: %s\n", cid)
|
||||
fmt.Printf("hostname: %s\n", *hostname)
|
||||
fmt.Printf("vendor-class: %s\n", *vendorClass)
|
||||
fmt.Printf("promisc: %v\n", *promisc)
|
||||
fmt.Printf("timeout: %s retries: %d\n", *timeout, *retries)
|
||||
fmt.Println()
|
||||
|
||||
if *promisc {
|
||||
if err := setPromisc(*iface, true); err != nil {
|
||||
log.Printf("WARNING: failed to set promisc on %s: %v", *iface, err)
|
||||
} else {
|
||||
fmt.Printf("set %s promisc ON\n\n", *iface)
|
||||
}
|
||||
}
|
||||
|
||||
// Build nclient4 options — identical to spinifex/services/vpcd/dhcp/nclient4.go.
|
||||
opts := []nclient4.ClientOpt{
|
||||
nclient4.WithHWAddr(hwAddr),
|
||||
nclient4.WithTimeout(*timeout),
|
||||
nclient4.WithRetry(*retries),
|
||||
}
|
||||
if *verbose {
|
||||
opts = append(opts, nclient4.WithLogger(nclient4.ShortSummaryLogger{Printfer: log.New(os.Stderr, "[nclient4] ", 0)}))
|
||||
}
|
||||
|
||||
client, err := nclient4.New(*iface, opts...)
|
||||
if err != nil {
|
||||
log.Fatalf("nclient4.New(%s): %v", *iface, err)
|
||||
}
|
||||
defer client.Close()
|
||||
|
||||
// Identity modifiers — identical to identityModifiers() in vpcd dhcp package.
|
||||
var mods []dhcpv4.Modifier
|
||||
if cid != "" {
|
||||
mods = append(mods, dhcpv4.WithOption(dhcpv4.OptClientIdentifier([]byte(cid))))
|
||||
}
|
||||
if *hostname != "" {
|
||||
mods = append(mods, dhcpv4.WithOption(dhcpv4.OptHostName(*hostname)))
|
||||
}
|
||||
if *vendorClass != "" {
|
||||
mods = append(mods, dhcpv4.WithOption(dhcpv4.OptClassIdentifier(*vendorClass)))
|
||||
}
|
||||
|
||||
fmt.Println("Sending DHCP Discover...")
|
||||
start := time.Now()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), *timeout*time.Duration(*retries+1))
|
||||
defer cancel()
|
||||
|
||||
lease, err := client.Request(ctx, mods...)
|
||||
if err != nil {
|
||||
fmt.Printf("\nFAILED after %s: %v\n", time.Since(start).Round(time.Millisecond), err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Printf("SUCCESS in %s\n\n", time.Since(start).Round(time.Millisecond))
|
||||
ack := lease.ACK
|
||||
fmt.Printf(" Your IP: %s\n", ack.YourIPAddr)
|
||||
fmt.Printf(" Subnet: %s\n", ack.SubnetMask())
|
||||
fmt.Printf(" Gateway: %v\n", ack.Router())
|
||||
fmt.Printf(" DNS: %v\n", ack.DNS())
|
||||
fmt.Printf(" Server ID: %s\n", ack.ServerIdentifier())
|
||||
fmt.Printf(" Lease time: %s\n", ack.IPAddressLeaseTime(24*time.Hour))
|
||||
if *verbose {
|
||||
fmt.Printf("\n--- Full ACK ---\n%s\n", ack.Summary())
|
||||
}
|
||||
}
|
||||
|
||||
// generateMAC is the same deterministic hash used by vpcd's topology.go.
|
||||
// It produces a locally-administered MAC (02:00:00:xx:xx:xx) from a resource ID.
|
||||
func generateMAC(resourceID string) string {
|
||||
h := uint32(0)
|
||||
for _, c := range resourceID {
|
||||
h = h*31 + uint32(c)
|
||||
}
|
||||
return fmt.Sprintf("02:00:00:%02x:%02x:%02x", (h>>16)&0xff, (h>>8)&0xff, h&0xff)
|
||||
}
|
||||
|
||||
func ifaceMAC(name string) (net.HardwareAddr, error) {
|
||||
ifc, err := net.InterfaceByName(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ifc.HardwareAddr, nil
|
||||
}
|
||||
|
||||
func setPromisc(iface string, on bool) error {
|
||||
state := "on"
|
||||
if !on {
|
||||
state = "off"
|
||||
}
|
||||
out, err := exec.Command("ip", "link", "set", iface, "promisc", state).CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %s", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
/*
|
||||
Copyright © 2026 Mulga Defense Corporation
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
// Package autoinstall reads headless install parameters from environment
|
||||
// variables exported by spinifex-init.sh from the kernel cmdline.
|
||||
// Parameters are set in the GRUB "Headless" menu entry in grub.cfg.
|
||||
package autoinstall
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/mulgadc/spinifex/cmd/installer/install"
|
||||
)
|
||||
|
||||
// Load returns an install.Config built from SPINIFEX_* environment variables,
|
||||
// or nil if SPINIFEX_AUTO is not set to "1" (interactive mode).
|
||||
func Load() (*install.Config, error) {
|
||||
if os.Getenv("SPINIFEX_AUTO") != "1" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
slog.Info("autoinstall: headless mode enabled via kernel cmdline")
|
||||
|
||||
cfg, err := buildConfig()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("autoinstall config: %w", err)
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// EjectAndReboot ejects the boot device (best-effort) then reboots.
|
||||
func EjectAndReboot() {
|
||||
srcDev, _ := os.ReadFile("/run/iso-dev")
|
||||
if dev := strings.TrimSpace(string(srcDev)); dev != "" {
|
||||
slog.Info("autoinstall: ejecting boot device", "device", dev)
|
||||
_ = exec.Command("eject", dev).Run()
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println("Installation complete.")
|
||||
fmt.Println("Remove the USB drive now if it was not ejected automatically.")
|
||||
fmt.Println("Rebooting in 10 seconds...")
|
||||
time.Sleep(10 * time.Second)
|
||||
// Use the kernel syscall directly — the live environment runs spinifex-init
|
||||
// as PID 1 (not systemd), so exec("reboot") fails trying to reach D-Bus.
|
||||
if err := syscall.Reboot(syscall.LINUX_REBOOT_CMD_RESTART); err != nil {
|
||||
slog.Error("autoinstall: reboot syscall failed", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
func buildConfig() (*install.Config, error) {
|
||||
password := os.Getenv("SPINIFEX_PASSWORD")
|
||||
if password == "" {
|
||||
return nil, fmt.Errorf("SPINIFEX_PASSWORD is required")
|
||||
}
|
||||
|
||||
hostname := os.Getenv("SPINIFEX_HOSTNAME")
|
||||
if hostname == "" {
|
||||
hostname = "spinifex-node"
|
||||
}
|
||||
|
||||
disk, err := resolveDisk(os.Getenv("SPINIFEX_DISK"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("disk: %w", err)
|
||||
}
|
||||
|
||||
wanIface, err := resolveNIC(os.Getenv("SPINIFEX_WAN_IFACE"), "")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("WAN NIC: %w", err)
|
||||
}
|
||||
|
||||
cfg := &install.Config{
|
||||
Disk: disk,
|
||||
Hostname: hostname,
|
||||
RootPassword: password,
|
||||
// Optional on the headless path — empty means no --email passed to
|
||||
// spx admin init, which omits the operator identity from telemetry.
|
||||
Email: strings.TrimSpace(os.Getenv("SPINIFEX_EMAIL")),
|
||||
WANInterface: wanIface,
|
||||
WANDHCPMode: strings.ToLower(os.Getenv("SPINIFEX_WAN_MODE")) != "static",
|
||||
}
|
||||
|
||||
if !cfg.WANDHCPMode {
|
||||
ip := os.Getenv("SPINIFEX_WAN_IP")
|
||||
mask := os.Getenv("SPINIFEX_WAN_MASK")
|
||||
gw := os.Getenv("SPINIFEX_WAN_GW")
|
||||
if ip == "" || mask == "" || gw == "" {
|
||||
return nil, fmt.Errorf("SPINIFEX_WAN_IP, SPINIFEX_WAN_MASK, SPINIFEX_WAN_GW required for static mode")
|
||||
}
|
||||
cfg.WANAddress = ip
|
||||
cfg.WANMask = mask
|
||||
cfg.WANGateway = gw
|
||||
if dns := os.Getenv("SPINIFEX_WAN_DNS"); dns != "" {
|
||||
cfg.WANDNS = strings.Split(dns, ",")
|
||||
}
|
||||
}
|
||||
|
||||
if lanIface := os.Getenv("SPINIFEX_LAN_IFACE"); lanIface != "" {
|
||||
lan, err := resolveNIC(lanIface, wanIface)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("LAN NIC: %w", err)
|
||||
}
|
||||
cfg.LANInterface = lan
|
||||
cfg.LANDHCPMode = strings.ToLower(os.Getenv("SPINIFEX_LAN_MODE")) != "static"
|
||||
if !cfg.LANDHCPMode {
|
||||
cfg.LANAddress = os.Getenv("SPINIFEX_LAN_IP")
|
||||
cfg.LANMask = os.Getenv("SPINIFEX_LAN_MASK")
|
||||
if dns := os.Getenv("SPINIFEX_LAN_DNS"); dns != "" {
|
||||
cfg.LANDNS = strings.Split(dns, ",")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if os.Getenv("SPINIFEX_GPU_PASSTHROUGH") == "1" {
|
||||
cfg.GPUPassthrough = true
|
||||
}
|
||||
|
||||
role := strings.ToLower(os.Getenv("SPINIFEX_ROLE"))
|
||||
if role == "" {
|
||||
role = "init"
|
||||
}
|
||||
cfg.ClusterRole = role
|
||||
skipFormation := os.Getenv("SPINIFEX_SKIP_FORMATION") == "1"
|
||||
if skipFormation {
|
||||
cfg.SkipFormation = true
|
||||
}
|
||||
|
||||
if role == "join" && !skipFormation {
|
||||
joinAddr := os.Getenv("SPINIFEX_JOIN_ADDR")
|
||||
if joinAddr == "" {
|
||||
return nil, fmt.Errorf("SPINIFEX_JOIN_ADDR required when SPINIFEX_ROLE=join")
|
||||
}
|
||||
cfg.JoinAddr = joinAddr
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// resolveDisk maps the spx_disk value from grub.cfg to a block device path.
|
||||
//
|
||||
// Supported values:
|
||||
//
|
||||
// auto — use the only non-removable disk; fail if multiple found
|
||||
// largest — largest non-removable disk (explicit opt-in)
|
||||
// smallest — smallest non-removable disk (typical OS-on-SSD pattern)
|
||||
// nvme — the only NVMe disk; fail if multiple found
|
||||
// /dev/sda (etc.) — exact device path; fail if not present
|
||||
func resolveDisk(target string) (string, error) {
|
||||
switch strings.ToLower(target) {
|
||||
case "", "auto":
|
||||
return singleDisk()
|
||||
case "largest":
|
||||
return diskBySize(true)
|
||||
case "smallest":
|
||||
return diskBySize(false)
|
||||
case "nvme":
|
||||
return nvmeDisk()
|
||||
default:
|
||||
if _, err := os.Stat(target); err != nil {
|
||||
return "", fmt.Errorf("%q not found", target)
|
||||
}
|
||||
return target, nil
|
||||
}
|
||||
}
|
||||
|
||||
type diskCandidate struct {
|
||||
dev string
|
||||
bytes int64
|
||||
}
|
||||
|
||||
// nonRemovableDisks returns all non-removable, non-virtual block devices.
|
||||
func nonRemovableDisks() ([]diskCandidate, error) {
|
||||
entries, err := os.ReadDir("/sys/block")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []diskCandidate
|
||||
for _, e := range entries {
|
||||
dev := e.Name()
|
||||
switch {
|
||||
case strings.HasPrefix(dev, "loop"),
|
||||
strings.HasPrefix(dev, "ram"),
|
||||
strings.HasPrefix(dev, "dm-"),
|
||||
strings.HasPrefix(dev, "sr"):
|
||||
continue
|
||||
}
|
||||
removable, _ := os.ReadFile("/sys/block/" + dev + "/removable")
|
||||
if strings.TrimSpace(string(removable)) == "1" {
|
||||
continue
|
||||
}
|
||||
sizeRaw, _ := os.ReadFile("/sys/block/" + dev + "/size")
|
||||
sectors, parseErr := strconv.ParseInt(strings.TrimSpace(string(sizeRaw)), 10, 64)
|
||||
if parseErr != nil || sectors == 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out, diskCandidate{dev: dev, bytes: sectors * 512})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// diskList returns a human-readable list of candidates for error messages.
|
||||
func diskList(disks []diskCandidate) string {
|
||||
lines := make([]string, len(disks))
|
||||
for i, d := range disks {
|
||||
lines[i] = fmt.Sprintf(" /dev/%s (%dG)", d.dev, d.bytes>>30)
|
||||
}
|
||||
return strings.Join(lines, "\n")
|
||||
}
|
||||
|
||||
// singleDisk selects the only non-removable disk, or fails listing all found.
|
||||
func singleDisk() (string, error) {
|
||||
disks, err := nonRemovableDisks()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
switch len(disks) {
|
||||
case 0:
|
||||
return "", fmt.Errorf("no non-removable disks found")
|
||||
case 1:
|
||||
slog.Info("autoinstall: single disk selected", "disk", disks[0].dev, "size_gb", disks[0].bytes>>30)
|
||||
return "/dev/" + disks[0].dev, nil
|
||||
default:
|
||||
return "", fmt.Errorf(
|
||||
"multiple disks found — set SPINIFEX_DISK to one of:\n%s\n"+
|
||||
" largest (largest disk)\n"+
|
||||
" smallest (smallest disk)\n"+
|
||||
" nvme (NVMe only)\n"+
|
||||
" /dev/sdX (exact path)",
|
||||
diskList(disks),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// diskBySize selects the largest (largest=true) or smallest non-removable disk.
|
||||
func diskBySize(largest bool) (string, error) {
|
||||
disks, err := nonRemovableDisks()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(disks) == 0 {
|
||||
return "", fmt.Errorf("no non-removable disks found")
|
||||
}
|
||||
sort.Slice(disks, func(i, j int) bool {
|
||||
if largest {
|
||||
return disks[i].bytes > disks[j].bytes
|
||||
}
|
||||
return disks[i].bytes < disks[j].bytes
|
||||
})
|
||||
label := "smallest"
|
||||
if largest {
|
||||
label = "largest"
|
||||
}
|
||||
slog.Info("autoinstall: disk selected by size", "mode", label, "disk", disks[0].dev, "size_gb", disks[0].bytes>>30)
|
||||
return "/dev/" + disks[0].dev, nil
|
||||
}
|
||||
|
||||
// nvmeDisk selects the only NVMe disk, or fails listing all NVMe disks found.
|
||||
func nvmeDisk() (string, error) {
|
||||
disks, err := nonRemovableDisks()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var nvmes []diskCandidate
|
||||
for _, d := range disks {
|
||||
if strings.HasPrefix(d.dev, "nvme") {
|
||||
nvmes = append(nvmes, d)
|
||||
}
|
||||
}
|
||||
switch len(nvmes) {
|
||||
case 0:
|
||||
return "", fmt.Errorf("no NVMe disks found")
|
||||
case 1:
|
||||
slog.Info("autoinstall: NVMe disk selected", "disk", nvmes[0].dev, "size_gb", nvmes[0].bytes>>30)
|
||||
return "/dev/" + nvmes[0].dev, nil
|
||||
default:
|
||||
return "", fmt.Errorf(
|
||||
"multiple NVMe disks found — set SPINIFEX_DISK to one of:\n%s",
|
||||
diskList(nvmes),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// virtualNICPrefixes lists interface name prefixes that identify non-physical
|
||||
// interfaces (bridges, tunnels, container/OVS veth pairs, etc.). These are
|
||||
// skipped when auto-selecting a NIC so we don't configure docker0 or
|
||||
// ovs-system as the management interface on a machine that previously ran
|
||||
// Docker or OVN.
|
||||
var virtualNICPrefixes = []string{
|
||||
"docker", "veth", "virbr", "br-", "ovs-", "vxlan", "genev", "tun", "tap",
|
||||
}
|
||||
|
||||
// resolveNIC returns the interface name to use. "auto" or empty picks the
|
||||
// first physical (non-loopback, non-virtual) interface that is not exclude.
|
||||
func resolveNIC(name, exclude string) (string, error) {
|
||||
if name != "" && name != "auto" {
|
||||
return name, nil
|
||||
}
|
||||
ifaces, err := net.Interfaces()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
for _, iface := range ifaces {
|
||||
if iface.Flags&net.FlagLoopback != 0 {
|
||||
continue
|
||||
}
|
||||
// Require broadcast capability — filters out point-to-point tunnels.
|
||||
if iface.Flags&net.FlagBroadcast == 0 {
|
||||
continue
|
||||
}
|
||||
// Require a MAC address — virtual/tunnel interfaces have none.
|
||||
if len(iface.HardwareAddr) == 0 {
|
||||
continue
|
||||
}
|
||||
if iface.Name == exclude {
|
||||
continue
|
||||
}
|
||||
// Skip known virtual interface prefixes.
|
||||
virtual := false
|
||||
for _, pfx := range virtualNICPrefixes {
|
||||
if strings.HasPrefix(iface.Name, pfx) {
|
||||
virtual = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if virtual {
|
||||
continue
|
||||
}
|
||||
slog.Info("autoinstall: NIC resolved", "mode", "auto", "selected", iface.Name)
|
||||
return iface.Name, nil
|
||||
}
|
||||
return "", fmt.Errorf("no suitable physical NIC found (non-loopback, broadcast-capable, with MAC)")
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package autoinstall
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadReturnsNilWhenNotAuto(t *testing.T) {
|
||||
os.Unsetenv("SPINIFEX_AUTO")
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if cfg != nil {
|
||||
t.Fatal("expected nil config when SPINIFEX_AUTO is not set")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
Copyright © 2026 Mulga Defense Corporation
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
// Package branding centralises Mulga/Spinifex visual identity constants used
|
||||
// by the installer TUI.
|
||||
//
|
||||
// TODO: Replace placeholder values with official Mulga/Spinifex assets once
|
||||
// the brand guide and logo files are located.
|
||||
package branding
|
||||
|
||||
import "github.com/charmbracelet/lipgloss"
|
||||
|
||||
// Logo is the ASCII art rendered on the installer welcome screen.
|
||||
// TODO: Replace with official Spinifex/Mulga ASCII logo.
|
||||
const Logo = `
|
||||
███████╗██████╗ ██╗███╗ ██╗██╗███████╗███████╗██╗ ██╗
|
||||
██╔════╝██╔══██╗██║████╗ ██║██║██╔════╝██╔════╝╚██╗██╔╝
|
||||
███████╗██████╔╝██║██╔██╗ ██║██║█████╗ █████╗ ╚███╔╝
|
||||
╚════██║██╔═══╝ ██║██║╚██╗██║██║██╔══╝ ██╔══╝ ██╔██╗
|
||||
███████║██║ ██║██║ ╚████║██║██║ ███████╗██╔╝ ██╗
|
||||
╚══════╝╚═╝ ╚═╝╚═╝ ╚═══╝╚═╝╚═╝ ╚══════╝╚═╝ ╚═╝`
|
||||
|
||||
// Subtitle is displayed beneath the logo on the welcome screen.
|
||||
const Subtitle = "Bare-Metal Node Provisioner"
|
||||
|
||||
// Publisher is shown in the welcome screen footer.
|
||||
const Publisher = "Mulga Defense Corporation"
|
||||
|
||||
// Mulga color palette.
|
||||
// TODO: Replace with official hex values from the Mulga brand guide.
|
||||
const (
|
||||
ColorPrimary = lipgloss.Color("#00A3E0") // Mulga blue (placeholder)
|
||||
ColorAccent = lipgloss.Color("#FF6B00") // Mulga orange (placeholder)
|
||||
ColorBackground = lipgloss.Color("#0D1117") // Dark background
|
||||
ColorSurface = lipgloss.Color("#161B22") // Slightly lighter surface
|
||||
ColorText = lipgloss.Color("#E6EDF3") // Light text
|
||||
ColorMuted = lipgloss.Color("#8B949E") // Muted / secondary text
|
||||
ColorSuccess = lipgloss.Color("#3FB950") // Green
|
||||
ColorWarning = lipgloss.Color("#D29922") // Amber
|
||||
ColorError = lipgloss.Color("#F85149") // Red
|
||||
ColorBorder = lipgloss.Color("#30363D") // Border
|
||||
)
|
||||
@@ -0,0 +1,266 @@
|
||||
/*
|
||||
Copyright © 2026 Mulga Defense Corporation
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
// Package firstboot writes the oneshot systemd service and configuration that
|
||||
// completes Spinifex provisioning on the first real boot after installation.
|
||||
package firstboot
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/mulgadc/spinifex/cmd/installer/systemd"
|
||||
)
|
||||
|
||||
// Config holds the values the firstboot service needs to configure the node.
|
||||
type Config struct {
|
||||
Hostname string
|
||||
// EncapIP is the Geneve tunnel IP for OVN. Set to the LAN bridge IP when a
|
||||
// dedicated LAN NIC is present, otherwise the WAN bridge IP. Empty when DHCP
|
||||
// is used — setup-ovn.sh auto-detects the IP from the default route in that case.
|
||||
EncapIP string
|
||||
// ClusterRole is "init" or "join".
|
||||
ClusterRole string
|
||||
// JoinAddr is host:port of the primary node, only used when ClusterRole is "join".
|
||||
JoinAddr string
|
||||
// Email is the operator email collected by the TUI or SPINIFEX_EMAIL on
|
||||
// the headless path. Passed to `spx admin init --email=<value>` when set;
|
||||
// omitted entirely when empty.
|
||||
Email string
|
||||
// GPUPassthrough enables VFIO GPU passthrough by passing --gpu-passthrough
|
||||
// to `spx admin init`, which writes gpu_passthrough = true in the daemon config.
|
||||
GPUPassthrough bool
|
||||
// InstallCallback, when non-empty, is curled once at the end of firstboot
|
||||
// after the success marker is written. Generic phone-home hook for
|
||||
// provisioning controllers (PXE/MAAS-style flows). Best-effort: non-2xx
|
||||
// and network failures are swallowed so the install is not gated on
|
||||
// controller reachability.
|
||||
InstallCallback string
|
||||
// SkipFormation skips spx admin init/join. Used when a provisioning
|
||||
// controller (e.g. bm-bootstrap.sh) owns cluster formation and will call
|
||||
// spx admin init/join itself with the correct multi-node parameters.
|
||||
SkipFormation bool
|
||||
}
|
||||
|
||||
// Write drops the firstboot script and systemd unit into root, which should be
|
||||
// the path of the installed system's root filesystem (e.g. /mnt/spinifex-install).
|
||||
func Write(root string, cfg Config) error {
|
||||
if err := writeScript(root, cfg); err != nil {
|
||||
return fmt.Errorf("firstboot script: %w", err)
|
||||
}
|
||||
if err := systemd.WriteFirstbootUnit(root); err != nil {
|
||||
return fmt.Errorf("firstboot unit: %w", err)
|
||||
}
|
||||
if err := systemd.EnableUnit(root, "spinifex-firstboot.service"); err != nil {
|
||||
return err
|
||||
}
|
||||
// Banner unit is written but NOT enabled here. firstboot enables it
|
||||
// (along with spinifex.target) at the end of its run, so on the first
|
||||
// boot the only spinifex unit running is firstboot itself — no implicit
|
||||
// pull of spinifex.target via the banner's Wants= relationship, which
|
||||
// was the root cause of the boot-time race against firstboot.
|
||||
if err := systemd.WriteBannerUnit(root); err != nil {
|
||||
return fmt.Errorf("banner unit: %w", err)
|
||||
}
|
||||
return systemd.WriteGettyDropIn(root)
|
||||
}
|
||||
|
||||
func writeScript(root string, cfg Config) error {
|
||||
clusterCmd := buildClusterCmd(cfg)
|
||||
|
||||
// --encap-ip is optional: when DHCP is used the IP is unknown at install time
|
||||
// and setup-ovn.sh auto-detects it from the default route at boot.
|
||||
setupOVN := "/usr/local/bin/setup-ovn.sh --management"
|
||||
if cfg.EncapIP != "" {
|
||||
setupOVN += fmt.Sprintf(" --encap-ip=%s", cfg.EncapIP)
|
||||
}
|
||||
|
||||
callbackBlock := ""
|
||||
if cfg.InstallCallback != "" {
|
||||
callbackBlock = fmt.Sprintf(
|
||||
"\ncurl -fsS --max-time 10 --retry 2 --retry-delay 2 -o /dev/null %s || true\n",
|
||||
shellEscapeSingle(cfg.InstallCallback),
|
||||
)
|
||||
}
|
||||
|
||||
script := fmt.Sprintf(`#!/bin/bash
|
||||
# Spinifex firstboot — runs once after ISO installation. On success, writes
|
||||
# /var/lib/spinifex/.firstboot-done and the systemd unit's
|
||||
# ConditionPathExists=! prevents re-execution on subsequent boots. A partial
|
||||
# run leaves no marker, so the next reboot retries from the top — safe
|
||||
# because every step below is idempotent (hostnamectl, setup-ovn.sh, spx
|
||||
# admin init are all "set if not set"; systemctl enable on an already-enabled
|
||||
# unit is a no-op).
|
||||
set -euo pipefail
|
||||
|
||||
DONE_MARKER=/var/lib/spinifex/.firstboot-done
|
||||
|
||||
# Idempotency: bail early if a previous run completed successfully. The unit
|
||||
# also has ConditionPathExists=!$DONE_MARKER so we shouldn't get here on
|
||||
# subsequent boots, but defend in depth in case the unit is re-triggered
|
||||
# manually before the operator notices the marker exists.
|
||||
if [ -f "$DONE_MARKER" ]; then
|
||||
echo "[firstboot] already complete — skipping"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Set hostname
|
||||
hostnamectl set-hostname %s
|
||||
|
||||
# Pre-start OVS and OVN central so their databases are initialised before
|
||||
# setup-ovn.sh runs. On physical hardware, first-boot DB initialisation takes
|
||||
# longer than setup-ovn.sh's internal 15-second timeout allows. Starting them
|
||||
# here and waiting until the NB DB is ready means setup-ovn.sh sees a live DB
|
||||
# the moment it starts — no races, no timeout failures.
|
||||
systemctl start openvswitch-switch
|
||||
systemctl start ovn-central
|
||||
echo "Waiting for OVN NB DB to initialise..."
|
||||
for _i in $(seq 1 120); do
|
||||
if ovn-nbctl --timeout=2 get-connection >/dev/null 2>&1; then
|
||||
echo "OVN NB DB ready (${_i}s)"
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
# Create default nameservers if DHCP server returns blank
|
||||
printf "nameserver 1.1.1.1\nnameserver 8.8.8.8\n" > /etc/resolvconf/resolv.conf.d/base
|
||||
resolvconf -u
|
||||
|
||||
# Configure OVN networking.
|
||||
# br-wan (and br-lan if present) are Linux bridges managed by systemd-networkd
|
||||
# (declared in /etc/systemd/network/ by the installer). setup-ovn.sh
|
||||
# auto-detects br-wan as the default route device and wires it to OVS via a
|
||||
# veth pair — non-destructive, SSH-safe.
|
||||
%s
|
||||
|
||||
# Write the banner (first time, spinifex-banner service will do this on reboot)
|
||||
/usr/local/bin/spx admin banner --boot-check
|
||||
|
||||
# Cluster formation — capture credentials to file for display on console.
|
||||
%s 2>&1
|
||||
|
||||
# Fix ownership of files spx admin init wrote. spx runs as root under
|
||||
# systemd, so /etc/spinifex/{spinifex.toml,master.key,ca.key,*.pem} and any
|
||||
# per-service files written under /var/lib/spinifex/* land as root:root.
|
||||
# Delegate to setup.sh's fix_file_ownership (single source of truth) — it
|
||||
# knows the per-file modes (ca.key 0600, *.pem 0644, toml/key 0640) that a
|
||||
# blunt recursive chmod would clobber.
|
||||
SETUP_STAGES=fixown /usr/local/share/spinifex/setup.sh
|
||||
# The /var/lib/spinifex/awsgw/config symlink was created at build time by
|
||||
# setup.sh's create_directories — it resolves to /etc/spinifex, so
|
||||
# {BaseDir}/config/master.key automatically points at /etc/spinifex/master.key.
|
||||
|
||||
# Bootstrap the spinifex user's SSH directory with a generated key pair so
|
||||
# ~/.ssh exists and the operator can immediately use ssh-keygen / ssh-copy-id
|
||||
# without hitting "No such file or directory" errors documented in the setup guide.
|
||||
if [ ! -f /home/spinifex/.ssh/id_ed25519 ]; then
|
||||
mkdir -p /home/spinifex/.ssh
|
||||
chmod 700 /home/spinifex/.ssh
|
||||
ssh-keygen -t ed25519 -f /home/spinifex/.ssh/id_ed25519 -N "" -C "spinifex@$(hostname)"
|
||||
chown -R spinifex:spinifex /home/spinifex/.ssh
|
||||
chmod 600 /home/spinifex/.ssh/id_ed25519
|
||||
chmod 644 /home/spinifex/.ssh/id_ed25519.pub
|
||||
fi
|
||||
|
||||
# Copy AWS credentials to the spinifex user's home directory.
|
||||
# spx admin init runs with HOME=/root (set by the systemd unit), so credentials
|
||||
# land in /root/.aws/. Copy them to the spinifex user's home so the operator
|
||||
# can use the AWS CLI without sudo.
|
||||
if [ -f /root/.aws/credentials ]; then
|
||||
mkdir -p /home/spinifex/.aws
|
||||
cp /root/.aws/credentials /home/spinifex/.aws/credentials
|
||||
cp /root/.aws/config /home/spinifex/.aws/config 2>/dev/null || true
|
||||
chown -R spinifex:spinifex /home/spinifex/.aws
|
||||
chmod 700 /home/spinifex/.aws
|
||||
chmod 600 /home/spinifex/.aws/credentials
|
||||
[ -f /home/spinifex/.aws/config ] && chmod 600 /home/spinifex/.aws/config
|
||||
fi
|
||||
|
||||
# Enable + activate spinifex.target and the banner now that all configs are
|
||||
# in place. "enable --now" creates the multi-user.target.wants/ symlinks (so
|
||||
# they start directly on every subsequent boot — no longer dependent on
|
||||
# firstboot running each time, since firstboot is condition-skipped after
|
||||
# this run via the marker) and activates them in this boot.
|
||||
# NOTE: Moved to firstinstall to enable
|
||||
#systemctl enable --now spinifex.target spinifex-banner.service
|
||||
|
||||
# Enable services to start, on reboot
|
||||
systemctl enable spinifex.target spinifex-banner.service
|
||||
|
||||
# Mark complete only after every step above has succeeded. Until this point,
|
||||
# any failure (set -e) leaves the marker absent and the next reboot retries
|
||||
# firstboot from the top.
|
||||
mkdir -p "$(dirname "$DONE_MARKER")"
|
||||
touch "$DONE_MARKER"
|
||||
%s
|
||||
systemctl start spinifex.target
|
||||
|
||||
# Wait for the daemon to bring up external networking. When external_mode=pool
|
||||
# is configured the daemon creates br-ext (the OVN external bridge) during
|
||||
# startup; launching instances before it is ready results in no public IP being
|
||||
# assigned. ip link requires no root and avoids parsing daemon logs.
|
||||
if grep -q 'external_mode.*pool' /etc/spinifex/spinifex.toml 2>/dev/null; then
|
||||
echo "[firstboot] waiting for external networking (br-ext)..."
|
||||
for _i in $(seq 1 30); do
|
||||
if ip link show br-ext >/dev/null 2>&1; then
|
||||
echo "[firstboot] br-ext ready (${_i}s)"
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
if ! ip link show br-ext >/dev/null 2>&1; then
|
||||
echo "[firstboot] warning: br-ext not up after 30s — external networking may be delayed"
|
||||
fi
|
||||
fi
|
||||
`, cfg.Hostname, setupOVN, clusterCmd, callbackBlock)
|
||||
|
||||
path := filepath.Join(root, "usr/local/bin/spinifex-firstboot.sh")
|
||||
return os.WriteFile(path, []byte(script), 0o755)
|
||||
}
|
||||
|
||||
func buildClusterCmd(cfg Config) string {
|
||||
if cfg.SkipFormation {
|
||||
return `echo "[firstboot] cluster formation skipped — provisioning controller will handle it"`
|
||||
}
|
||||
emailFlag := ""
|
||||
if cfg.Email != "" {
|
||||
// shellEscapeSingle keeps the email safe if it ever contains a
|
||||
// character shell treats specially — belt-and-braces; the regex
|
||||
// validator already rejects whitespace and @-chains.
|
||||
emailFlag = " --email=" + shellEscapeSingle(cfg.Email)
|
||||
}
|
||||
gpuFlag := ""
|
||||
if cfg.GPUPassthrough {
|
||||
gpuFlag = " --gpu-passthrough"
|
||||
}
|
||||
switch cfg.ClusterRole {
|
||||
case "join":
|
||||
return fmt.Sprintf("spx admin join --node %s --host %s%s", cfg.Hostname, cfg.JoinAddr, emailFlag)
|
||||
default:
|
||||
return fmt.Sprintf("spx admin init --node %s --nodes 1%s%s", cfg.Hostname, emailFlag, gpuFlag)
|
||||
}
|
||||
}
|
||||
|
||||
// shellEscapeSingle wraps s in single quotes with any embedded single
|
||||
// quotes escaped. Minimal — we only need this because the email value is
|
||||
// interpolated into a shell script written by Write().
|
||||
func shellEscapeSingle(s string) string {
|
||||
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package firstboot
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func makeRootDirs(t *testing.T, root string) {
|
||||
t.Helper()
|
||||
for _, d := range []string{
|
||||
"usr/local/bin",
|
||||
"etc/systemd/system",
|
||||
"etc/systemd/system/multi-user.target.wants",
|
||||
} {
|
||||
if err := os.MkdirAll(filepath.Join(root, d), 0o755); err != nil {
|
||||
t.Fatalf("mkdir %s: %v", d, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteScriptNoCallbackWhenEmpty(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
makeRootDirs(t, root)
|
||||
|
||||
cfg := Config{Hostname: "test-node", ClusterRole: "init"}
|
||||
if err := Write(root, cfg); err != nil {
|
||||
t.Fatalf("Write: %v", err)
|
||||
}
|
||||
|
||||
script, err := os.ReadFile(filepath.Join(root, "usr/local/bin/spinifex-firstboot.sh"))
|
||||
if err != nil {
|
||||
t.Fatalf("read script: %v", err)
|
||||
}
|
||||
if strings.Contains(string(script), "curl") {
|
||||
t.Error("script should not contain curl when InstallCallback is empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteScriptEmbedsCurlWhenCallbackSet(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
makeRootDirs(t, root)
|
||||
|
||||
const callbackURL = "http://192.168.1.12/boot/done?mac=aa:bb:cc:dd:ee:ff"
|
||||
cfg := Config{
|
||||
Hostname: "test-node",
|
||||
ClusterRole: "init",
|
||||
InstallCallback: callbackURL,
|
||||
}
|
||||
if err := Write(root, cfg); err != nil {
|
||||
t.Fatalf("Write: %v", err)
|
||||
}
|
||||
|
||||
script, err := os.ReadFile(filepath.Join(root, "usr/local/bin/spinifex-firstboot.sh"))
|
||||
if err != nil {
|
||||
t.Fatalf("read script: %v", err)
|
||||
}
|
||||
content := string(script)
|
||||
|
||||
if !strings.Contains(content, "curl") {
|
||||
t.Error("script missing curl command")
|
||||
}
|
||||
if !strings.Contains(content, callbackURL) {
|
||||
t.Errorf("script missing callback URL %q", callbackURL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteScriptCallbackAfterDoneMarker(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
makeRootDirs(t, root)
|
||||
|
||||
const callbackURL = "http://192.168.1.12/boot/done?mac=aa:bb:cc:dd:ee:ff"
|
||||
cfg := Config{
|
||||
Hostname: "node1",
|
||||
ClusterRole: "init",
|
||||
InstallCallback: callbackURL,
|
||||
}
|
||||
if err := Write(root, cfg); err != nil {
|
||||
t.Fatalf("Write: %v", err)
|
||||
}
|
||||
|
||||
script, err := os.ReadFile(filepath.Join(root, "usr/local/bin/spinifex-firstboot.sh"))
|
||||
if err != nil {
|
||||
t.Fatalf("read script: %v", err)
|
||||
}
|
||||
content := string(script)
|
||||
|
||||
doneIdx := strings.Index(content, "touch \"$DONE_MARKER\"")
|
||||
curlIdx := strings.Index(content, "curl")
|
||||
if doneIdx < 0 {
|
||||
t.Fatal("done marker not found in script")
|
||||
}
|
||||
if curlIdx < 0 {
|
||||
t.Fatal("curl not found in script")
|
||||
}
|
||||
if curlIdx < doneIdx {
|
||||
t.Error("curl must appear after done marker write")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
Copyright © 2026 Mulga Defense Corporation
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
// Package install performs the disk installation steps after the UI has
|
||||
// collected configuration.
|
||||
package install
|
||||
|
||||
// Config holds all values collected by the installer UI.
|
||||
type Config struct {
|
||||
// Disk is the block device path to install onto (e.g. /dev/sda).
|
||||
Disk string
|
||||
|
||||
// WAN interface — always present. The management IP lives on br-wan (a Linux
|
||||
// bridge over the NIC), not on the physical NIC itself. This allows OVN/OVS
|
||||
// to safely attach to the bridge without disrupting host connectivity.
|
||||
WANInterface string
|
||||
WANDHCPMode bool
|
||||
WANAddress string // empty if WANDHCPMode
|
||||
WANMask string // empty if WANDHCPMode
|
||||
WANGateway string // empty if WANDHCPMode
|
||||
WANDNS []string // empty if WANDHCPMode; e.g. ["1.1.1.1", "8.8.8.8"]
|
||||
WANWiFiSSID string // non-empty if a WiFi NIC was selected
|
||||
WANWiFiPass string // non-empty if a WiFi NIC was selected
|
||||
|
||||
// LAN interface — only set when 2+ NICs are present. The Geneve encap IP
|
||||
// for OVN tunnels lives on br-lan. When empty, the WAN address is used.
|
||||
LANInterface string
|
||||
LANDHCPMode bool
|
||||
LANAddress string
|
||||
LANMask string
|
||||
LANDNS []string
|
||||
LANWiFiSSID string
|
||||
LANWiFiPass string
|
||||
|
||||
// Node identity
|
||||
Hostname string
|
||||
|
||||
// ClusterRole is "init" or "join".
|
||||
ClusterRole string
|
||||
|
||||
// JoinAddr is the primary node address (host:port) when ClusterRole is "join".
|
||||
JoinAddr string
|
||||
|
||||
// SkipFormation skips spx admin init/join in firstboot; the provisioning
|
||||
// controller (e.g. bm-bootstrap.sh) owns cluster formation instead.
|
||||
SkipFormation bool
|
||||
|
||||
// CA certificate (PEM), optional.
|
||||
HasCACert bool
|
||||
CACert string
|
||||
|
||||
// RootPassword is the password to set for the root account on the installed system.
|
||||
RootPassword string
|
||||
|
||||
// Email is the operator's email address, used by the call-home telemetry
|
||||
// endpoint to notify of important system updates and security advisories.
|
||||
// Required on interactive installs; may be empty on headless/CI installs
|
||||
// when SPINIFEX_EMAIL is not supplied on the kernel cmdline.
|
||||
Email string
|
||||
|
||||
// GPUPassthrough enables VFIO GPU passthrough on this node.
|
||||
// Set via SPINIFEX_GPU_PASSTHROUGH=1 on headless installs.
|
||||
// Passes --gpu-passthrough to `spx admin init` during firstboot.
|
||||
GPUPassthrough bool
|
||||
}
|
||||
@@ -0,0 +1,863 @@
|
||||
/*
|
||||
Copyright © 2026 Mulga Defense Corporation
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
package install
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/mulgadc/spinifex/cmd/installer/firstboot"
|
||||
"github.com/mulgadc/spinifex/cmd/installer/systemd"
|
||||
)
|
||||
|
||||
const (
|
||||
mountRoot = "/mnt/spinifex-install"
|
||||
efiPart = mountRoot + "/boot/efi"
|
||||
)
|
||||
|
||||
// Run executes all installation steps in order. It is intentionally sequential
|
||||
// and explicit — each step is visible in logs so failures are easy to diagnose.
|
||||
func Run(cfg *Config) error {
|
||||
// The live environment may not have /sbin or /usr/sbin in PATH. Set it
|
||||
// explicitly so exec.Command's LookPath finds system binaries like grub-install.
|
||||
_ = os.Setenv("PATH", "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin")
|
||||
|
||||
// Unmount unconditionally on exit so a failed step never leaves partitions
|
||||
// mounted in the live environment, which would cause a retry to double-mount.
|
||||
defer func() {
|
||||
_ = run("umount", efiPart)
|
||||
_ = run("umount", mountRoot)
|
||||
}()
|
||||
|
||||
steps := []struct {
|
||||
name string
|
||||
fn func() error
|
||||
}{
|
||||
{"partition disk", func() error { return partitionDisk(cfg.Disk) }},
|
||||
{"format partitions", func() error { return formatPartitions(cfg.Disk) }},
|
||||
{"mount partitions", func() error { return mountPartitions(cfg.Disk) }},
|
||||
{"copy rootfs", copyRootfs},
|
||||
{"write fstab", func() error { return writeFstab(cfg.Disk) }},
|
||||
{"install spinifex", func() error { return installSpinifex(cfg) }},
|
||||
{"write network config", func() error { return writeNetworkConfig(cfg) }},
|
||||
{"write firstboot service", func() error { return firstboot.Write(mountRoot, cfg.toFirstbootConfig()) }},
|
||||
{"install bootloader", func() error { return installBootloader(cfg.Disk) }},
|
||||
{"install CA cert", func() error { return installCACert(cfg) }},
|
||||
}
|
||||
|
||||
for _, s := range steps {
|
||||
slog.Info("installer", "step", s.name)
|
||||
if err := s.fn(); err != nil {
|
||||
return fmt.Errorf("step %q: %w", s.name, err)
|
||||
}
|
||||
}
|
||||
|
||||
slog.Info("installation complete")
|
||||
fireInstallCallback()
|
||||
promptRemoveUSB()
|
||||
return reboot()
|
||||
}
|
||||
|
||||
func partitionDisk(disk string) error {
|
||||
// GPT table with three partitions:
|
||||
// p1: 1MiB BIOS Boot Partition — required for grub-install i386-pc on GPT
|
||||
// p2: 512MiB EFI System Partition
|
||||
// p3: remainder as root (ext4)
|
||||
if err := run("parted", "--script", disk,
|
||||
"mklabel", "gpt",
|
||||
"mkpart", "bios_boot", "1MiB", "2MiB",
|
||||
"set", "1", "bios_grub", "on",
|
||||
"mkpart", "ESP", "fat32", "2MiB", "514MiB",
|
||||
"set", "2", "esp", "on",
|
||||
"mkpart", "root", "ext4", "514MiB", "100%",
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
// Force the kernel to re-read the partition table and wait for udev to
|
||||
// create the partition device nodes. Without this, mkfs.fat in the next
|
||||
// step may race and fail with "No such file or directory" on /dev/sda2 —
|
||||
// the kernel has accepted the new layout but /dev hasn't been populated
|
||||
// yet. Trixie's udev seems slower at this than Bookworm's was.
|
||||
return waitForPartitions(disk)
|
||||
}
|
||||
|
||||
// waitForPartitions ensures the EFI and root partition device nodes exist
|
||||
// after parted creates them. It runs partprobe (kernel re-read) and
|
||||
// udevadm settle (wait for queued events), then polls /dev for the files.
|
||||
func waitForPartitions(disk string) error {
|
||||
// Best-effort: partprobe failure isn't fatal — udev may still pick up
|
||||
// the change from the BLKRRPART ioctl that parted itself issued.
|
||||
if err := run("partprobe", disk); err != nil {
|
||||
slog.Warn("partprobe failed, continuing", "disk", disk, "err", err)
|
||||
}
|
||||
if err := run("udevadm", "settle", "--timeout=10"); err != nil {
|
||||
slog.Warn("udevadm settle failed, continuing", "err", err)
|
||||
}
|
||||
efi, root := partitionPaths(disk)
|
||||
deadline := time.Now().Add(15 * time.Second)
|
||||
for _, part := range []string{efi, root} {
|
||||
for {
|
||||
if _, err := os.Stat(part); err == nil {
|
||||
break
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
return fmt.Errorf("partition device %s did not appear within timeout — kernel/udev did not pick up new partition table", part)
|
||||
}
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func formatPartitions(disk string) error {
|
||||
efi, root := partitionPaths(disk)
|
||||
if err := run("mkfs.fat", "-F32", efi); err != nil {
|
||||
return err
|
||||
}
|
||||
return run("mkfs.ext4", "-F", root)
|
||||
}
|
||||
|
||||
func mountPartitions(disk string) error {
|
||||
_, root := partitionPaths(disk)
|
||||
if err := os.MkdirAll(mountRoot, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := run("mount", root, mountRoot); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.MkdirAll(efiPart, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
efi, _ := partitionPaths(disk)
|
||||
return run("mount", efi, efiPart)
|
||||
}
|
||||
|
||||
// copyRootfs copies the live squashfs environment onto the target disk using
|
||||
// rsync. This is the air-gapped alternative to debootstrap — all packages are
|
||||
// already embedded in the ISO so no network access is required.
|
||||
func copyRootfs() error {
|
||||
if err := run("rsync", "-aHAX", "--delete", "--info=progress2",
|
||||
"--exclude=/proc",
|
||||
"--exclude=/sys",
|
||||
"--exclude=/dev",
|
||||
"--exclude=/run",
|
||||
"--exclude=/tmp",
|
||||
"--exclude=/cdrom",
|
||||
"--exclude=/mnt",
|
||||
"--exclude=/etc/machine-id",
|
||||
"--exclude=/var/lib/dbus/",
|
||||
"--exclude=/etc/openvswitch/",
|
||||
"--exclude=/var/lib/openvswitch/",
|
||||
"--exclude=/var/lib/dhcpcd/",
|
||||
"--exclude=/etc/ssh/ssh_host_*",
|
||||
"--exclude=/lost+found",
|
||||
"--exclude=/boot/efi",
|
||||
"/", mountRoot+"/",
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Verify critical paths exist before proceeding. rsync exits 0 on ENOSPC
|
||||
// for individual file writes on some filesystems, which would produce a
|
||||
// partial rootfs that boots into a panic.
|
||||
critical := []string{
|
||||
filepath.Join(mountRoot, "bin/bash"),
|
||||
filepath.Join(mountRoot, "lib/systemd/systemd"),
|
||||
filepath.Join(mountRoot, "usr/local/bin/spx"),
|
||||
}
|
||||
for _, p := range critical {
|
||||
if _, err := os.Stat(p); err != nil {
|
||||
return fmt.Errorf("copyRootfs: critical path missing after rsync (%s): %w", p, err)
|
||||
}
|
||||
}
|
||||
|
||||
// rsync skips excluded paths entirely — recreate the virtual filesystem
|
||||
// mount points that systemd expects to exist on the installed system.
|
||||
mountPoints := []struct {
|
||||
path string
|
||||
mode os.FileMode
|
||||
}{
|
||||
{"proc", 0o555},
|
||||
{"sys", 0o555},
|
||||
{"dev", 0o755},
|
||||
{"run", 0o755},
|
||||
{"run/lock", 0o1777},
|
||||
{"tmp", 0o1777},
|
||||
{"mnt", 0o755},
|
||||
}
|
||||
for _, mp := range mountPoints {
|
||||
if err := os.MkdirAll(filepath.Join(mountRoot, mp.path), mp.mode); err != nil {
|
||||
return fmt.Errorf("create mountpoint /%s: %w", mp.path, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func installSpinifex(cfg *Config) error {
|
||||
// The rootfs copy already contains spx and spinifex-installer at
|
||||
// /usr/local/bin/ — no binary copy needed. Regenerate machine-specific
|
||||
// identity files so each installed node is unique.
|
||||
|
||||
// Bind-mount /dev, /proc, /sys into the chroot so PAM (chpasswd) and
|
||||
// other chroot commands work correctly. Trixie's PAM requires /proc and
|
||||
// /dev/urandom for password hashing.
|
||||
if err := bindChrootMounts(); err != nil {
|
||||
return err
|
||||
}
|
||||
defer unbindChrootMounts()
|
||||
|
||||
// Generate a unique machine-id from the kernel CSPRNG. We do not use
|
||||
// systemd-machine-id-setup in the chroot: it reads the SMBIOS UUID via
|
||||
// the bind-mounted /sys, which is identical on cloned VMs or hosts with
|
||||
// absent/zeroed DMI data, and falls back to non-unique sources (MAC,
|
||||
// hostname) when that also fails — producing the same ID on every node.
|
||||
// /proc/sys/kernel/random/uuid is always unique and requires no chroot.
|
||||
machineIDPath := filepath.Join(mountRoot, "etc/machine-id")
|
||||
rawUUID, err := os.ReadFile("/proc/sys/kernel/random/uuid")
|
||||
if err != nil {
|
||||
return fmt.Errorf("installSpinifex: read kernel uuid: %w", err)
|
||||
}
|
||||
machineID := strings.ReplaceAll(strings.TrimSpace(string(rawUUID)), "-", "") + "\n"
|
||||
if err := os.WriteFile(machineIDPath, []byte(machineID), 0o444); err != nil {
|
||||
return fmt.Errorf("installSpinifex: write machine-id: %w", err)
|
||||
}
|
||||
// dbus mirrors /etc/machine-id; remove it so it is re-created from the
|
||||
// new ID on first boot rather than carrying a stale value.
|
||||
_ = os.Remove(filepath.Join(mountRoot, "var/lib/dbus/machine-id"))
|
||||
|
||||
// Hostname.
|
||||
hostnamePath := filepath.Join(mountRoot, "etc/hostname")
|
||||
if err := os.WriteFile(hostnamePath, []byte(cfg.Hostname+"\n"), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// /etc/hosts entry for the hostname.
|
||||
hosts := fmt.Sprintf("127.0.0.1\tlocalhost\n127.0.1.1\t%s\n", cfg.Hostname)
|
||||
if err := os.WriteFile(filepath.Join(mountRoot, "etc/hosts"), []byte(hosts), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Set root + spinifex passwords. We invoke chpasswd from the LIVE
|
||||
// installer (not via `chroot`), passing the target root with -R and
|
||||
// forcing -c YESCRYPT. This deliberately bypasses PAM:
|
||||
// * `chroot ... chpasswd` uses /etc/pam.d/chpasswd → common-password →
|
||||
// pam_unix.so with the "obscure" option, which can return
|
||||
// "Authentication token manipulation error" inside a chroot for
|
||||
// reasons that are awkward to diagnose (audit subsystem, locked
|
||||
// shadow entries from useradd, etc.).
|
||||
// * -c YESCRYPT tells chpasswd to hash locally with libcrypt and write
|
||||
// directly to <root>/etc/shadow — no PAM stack involved. YESCRYPT
|
||||
// matches Trixie's ENCRYPT_METHOD so subsequent logins use the same
|
||||
// algorithm.
|
||||
// * -R <root> opens the target's passwd/shadow directly; no bind
|
||||
// mounts of /dev/urandom or /proc are needed for the password step.
|
||||
if cfg.RootPassword != "" {
|
||||
if err := setShadowPassword("root", cfg.RootPassword); err != nil {
|
||||
return fmt.Errorf("set root password: %w", err)
|
||||
}
|
||||
// The spinifex account is the default interactive login on the
|
||||
// node (console + SSH). Root SSH is disabled, so this is the sole
|
||||
// remote entry point. The user itself is created at rootfs build
|
||||
// time (build-rootfs.sh) — here we just set its password.
|
||||
if err := setShadowPassword("spinifex", cfg.RootPassword); err != nil {
|
||||
return fmt.Errorf("set spinifex password: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Write /etc/spinifex/node.conf — read at runtime by spx admin banner
|
||||
// to look up the current IP dynamically (handles IP changes after install).
|
||||
// MANAGEMENT_IFACE is the bridge (br-wan), not the physical NIC.
|
||||
// MANAGEMENT_IP is empty for DHCP — banner's --boot-check fills it in at boot.
|
||||
nodeConf := fmt.Sprintf("MANAGEMENT_IP=%s\nMANAGEMENT_IFACE=br-wan\nNODE_HOSTNAME=%s\n",
|
||||
cfg.WANAddress, cfg.Hostname)
|
||||
confDir := filepath.Join(mountRoot, "etc/spinifex")
|
||||
if err := os.MkdirAll(confDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(confDir, "node.conf"), []byte(nodeConf), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// dhcpcd-base is present on the installed system (used by setup-ovn.sh for
|
||||
// macvlan mode). Mask the standalone dhcpcd.service so it never auto-starts
|
||||
// and races with systemd-networkd's built-in DHCP client on br-wan.
|
||||
if err := maskSystemdUnit(mountRoot, "dhcpcd.service"); err != nil {
|
||||
slog.Warn("installSpinifex: failed to mask dhcpcd.service", "err", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeNetworkConfig(cfg *Config) error {
|
||||
// IPs live on Linux bridges (br-wan, br-lan), not on the physical NICs.
|
||||
// systemd-networkd owns the full lifecycle: it creates the bridge NetDevs,
|
||||
// enslaves the physical NICs, and runs the DHCP client. This means the
|
||||
// veth pair that setup-ovn.sh adds later (veth-wan-br) is a port of a
|
||||
// networkd-known bridge, so a networkctl reload or reboot never orphans it.
|
||||
netdDir := filepath.Join(mountRoot, "etc/systemd/network")
|
||||
if err := os.MkdirAll(netdDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := writeNetworkdBridge(netdDir, cfg.WANInterface, "br-wan", false,
|
||||
cfg.WANDHCPMode, cfg.WANAddress, cfg.WANMask, cfg.WANGateway, cfg.WANDNS,
|
||||
cfg.WANWiFiSSID, cfg.WANWiFiPass); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if cfg.LANInterface != "" {
|
||||
if err := writeNetworkdBridge(netdDir, cfg.LANInterface, "br-lan", true,
|
||||
cfg.LANDHCPMode, cfg.LANAddress, cfg.LANMask, "", cfg.LANDNS,
|
||||
cfg.LANWiFiSSID, cfg.LANWiFiPass); err != nil {
|
||||
return err
|
||||
}
|
||||
// br-lan timing is controlled by spinifex-lan-bridge.service, not
|
||||
// networkd auto-activation — ActivationPolicy=manual in the .network
|
||||
// file ensures networkd creates the bridge but does not bring it up.
|
||||
if err := systemd.WriteLANBridgeUnit(mountRoot); err != nil {
|
||||
return fmt.Errorf("lan bridge unit: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Unmask systemd-networkd-wait-online on the installed system and scope
|
||||
// it to br-wan only. The live ISO masks this service (build-rootfs.sh) to
|
||||
// avoid blocking the installer environment (which has no br-wan). The mask
|
||||
// is copied to the installed system by copyRootfs and must be removed here
|
||||
// so that network-online.target — and therefore spinifex-firstboot.service
|
||||
// (After=network-online.target) — does not fire before br-wan has its
|
||||
// DHCP lease and default route. Without this, setup-ovn.sh finds no default
|
||||
// route and exits early, creating no veth pair.
|
||||
//
|
||||
// The --interface=br-wan scope means br-lan (ActivationPolicy=manual) never
|
||||
// blocks the wait; --timeout=60 caps the delay on a cold switch.
|
||||
waitOnlineMask := filepath.Join(mountRoot, "etc/systemd/system/systemd-networkd-wait-online.service")
|
||||
_ = os.Remove(waitOnlineMask)
|
||||
waitOnlineDir := filepath.Join(mountRoot, "etc/systemd/system/systemd-networkd-wait-online.service.d")
|
||||
if err := os.MkdirAll(waitOnlineDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
waitOnlineConf := "[Service]\nExecStart=\nExecStart=/lib/systemd/systemd-networkd-wait-online --interface=br-wan --timeout=60\n"
|
||||
if err := os.WriteFile(filepath.Join(waitOnlineDir, "spinifex-wan-only.conf"), []byte(waitOnlineConf), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Disable IPv6 via sysctl — belt-and-suspenders alongside IPv6AcceptRA=no
|
||||
// in the networkd .network files.
|
||||
bridges := []string{"br-wan"}
|
||||
if cfg.LANInterface != "" {
|
||||
bridges = append(bridges, "br-lan")
|
||||
}
|
||||
var sysctl strings.Builder
|
||||
sysctl.WriteString("# Generated by Spinifex installer — IPv6 disabled on management bridges\n")
|
||||
for _, br := range bridges {
|
||||
fmt.Fprintf(&sysctl, "net.ipv6.conf.%s.disable_ipv6=1\n", br)
|
||||
}
|
||||
sysctlDir := filepath.Join(mountRoot, "etc/sysctl.d")
|
||||
if err := os.MkdirAll(sysctlDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(sysctlDir, "99-spinifex-network.conf"), []byte(sysctl.String()), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Pin each NIC name to its MAC address via udev so the installed system
|
||||
// always uses the same interface name regardless of probe order.
|
||||
udevDir := filepath.Join(mountRoot, "etc/udev/rules.d")
|
||||
if err := os.MkdirAll(udevDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
var udevRules strings.Builder
|
||||
for _, iface := range []string{cfg.WANInterface, cfg.LANInterface} {
|
||||
if iface == "" {
|
||||
continue
|
||||
}
|
||||
mac, err := os.ReadFile("/sys/class/net/" + iface + "/address")
|
||||
if err != nil {
|
||||
slog.Warn("writeNetworkConfig: could not read NIC MAC, skipping udev pin", "iface", iface, "err", err)
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(&udevRules, "SUBSYSTEM==\"net\", ACTION==\"add\", ATTR{address}==\"%s\", NAME=\"%s\"\n",
|
||||
strings.TrimSpace(string(mac)), iface)
|
||||
}
|
||||
if udevRules.Len() > 0 {
|
||||
return os.WriteFile(filepath.Join(udevDir, "70-spinifex-net.rules"), []byte(udevRules.String()), 0o644)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeNetworkdBridge writes the three systemd-networkd files that configure a
|
||||
// physical NIC enslaved to a Linux bridge:
|
||||
//
|
||||
// - {prio}-spinifex-{suffix}-nic.network enslaves the NIC to the bridge
|
||||
// - {prio+1}-spinifex-{suffix}.netdev declares the bridge device
|
||||
// - {prio+1}-spinifex-{suffix}.network configures IP on the bridge
|
||||
//
|
||||
// manual=true sets ActivationPolicy=manual so networkd creates the bridge but
|
||||
// does not auto-activate it — used for br-lan, which spinifex-lan-bridge.service
|
||||
// activates after network-online.target.
|
||||
func writeNetworkdBridge(dir, nicIface, bridgeName string, manual, dhcp bool,
|
||||
addr, mask, gw string, dns []string, wifiSSID, wifiPass string) error {
|
||||
suffix := strings.TrimPrefix(bridgeName, "br-")
|
||||
nicPrio, brPrio := 10, 11
|
||||
if manual {
|
||||
nicPrio, brPrio = 12, 13
|
||||
}
|
||||
|
||||
// NIC .network — enslaves the physical NIC to the bridge.
|
||||
nicNet := fmt.Sprintf("[Match]\nName=%s\n\n[Network]\nBridge=%s\n", nicIface, bridgeName)
|
||||
nicFile := fmt.Sprintf("%02d-spinifex-%s-nic.network", nicPrio, suffix)
|
||||
if err := os.WriteFile(filepath.Join(dir, nicFile), []byte(nicNet), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Bridge .netdev — declares the bridge device.
|
||||
brNetdev := fmt.Sprintf("[NetDev]\nName=%s\nKind=bridge\n\n[Bridge]\nSTP=no\n", bridgeName)
|
||||
brNetdevFile := fmt.Sprintf("%02d-spinifex-%s.netdev", brPrio, suffix)
|
||||
if err := os.WriteFile(filepath.Join(dir, brNetdevFile), []byte(brNetdev), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Bridge .network — IP configuration.
|
||||
var b strings.Builder
|
||||
fmt.Fprintf(&b, "[Match]\nName=%s\n\n", bridgeName)
|
||||
if manual {
|
||||
b.WriteString("[Link]\nActivationPolicy=manual\nRequiredForOnline=no\n\n")
|
||||
}
|
||||
b.WriteString("[Network]\n")
|
||||
if dhcp {
|
||||
b.WriteString("DHCP=ipv4\n")
|
||||
} else {
|
||||
cidr, err := addrCIDR(addr, mask)
|
||||
if err != nil {
|
||||
return fmt.Errorf("bridge %s: %w", bridgeName, err)
|
||||
}
|
||||
fmt.Fprintf(&b, "Address=%s\n", cidr)
|
||||
if gw != "" {
|
||||
fmt.Fprintf(&b, "Gateway=%s\n", gw)
|
||||
}
|
||||
for _, ns := range dns {
|
||||
if ns = strings.TrimSpace(ns); ns != "" {
|
||||
fmt.Fprintf(&b, "DNS=%s\n", ns)
|
||||
}
|
||||
}
|
||||
}
|
||||
b.WriteString("IPv6AcceptRA=no\nConfigureWithoutCarrier=yes\n")
|
||||
if manual && dhcp {
|
||||
// br-lan is non-critical; fail fast on DHCP so a missing LAN cable
|
||||
// does not stall spinifex-lan-bridge.service indefinitely.
|
||||
b.WriteString("\n[DHCP]\nTimeout=10\n")
|
||||
}
|
||||
brNetFile := fmt.Sprintf("%02d-spinifex-%s.network", brPrio, suffix)
|
||||
if err := os.WriteFile(filepath.Join(dir, brNetFile), []byte(b.String()), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if wifiSSID != "" {
|
||||
if err := writeWPASupplicant(nicIface, wifiSSID, wifiPass); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// addrCIDR converts a dotted-decimal address + subnet mask to CIDR notation.
|
||||
func addrCIDR(addr, dotMask string) (string, error) {
|
||||
if net.ParseIP(addr) == nil {
|
||||
return "", fmt.Errorf("invalid IP: %s", addr)
|
||||
}
|
||||
maskIP := net.ParseIP(dotMask)
|
||||
if maskIP == nil {
|
||||
return "", fmt.Errorf("invalid mask: %s", dotMask)
|
||||
}
|
||||
m := net.IPMask(maskIP.To4())
|
||||
ones, bits := m.Size()
|
||||
if bits == 0 {
|
||||
return "", fmt.Errorf("non-contiguous or zero subnet mask: %s", dotMask)
|
||||
}
|
||||
return fmt.Sprintf("%s/%d", addr, ones), nil
|
||||
}
|
||||
|
||||
// writeWPASupplicant writes a wpa_supplicant config for nicIface and enables
|
||||
// the per-interface wpa_supplicant@{iface}.service so authentication completes
|
||||
// before networkd enslaves the NIC to the bridge.
|
||||
func writeWPASupplicant(nicIface, ssid, psk string) error {
|
||||
conf := fmt.Sprintf(
|
||||
"ctrl_interface=DIR=/run/wpa_supplicant GROUP=netdev\nupdate_config=1\n\nnetwork={\n\tssid=%q\n\tpsk=%q\n}\n",
|
||||
ssid, psk,
|
||||
)
|
||||
wpaDir := filepath.Join(mountRoot, "etc/wpa_supplicant")
|
||||
if err := os.MkdirAll(wpaDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(wpaDir, "wpa_supplicant-"+nicIface+".conf"), []byte(conf), 0o600); err != nil {
|
||||
return err
|
||||
}
|
||||
// Enable via symlink into multi-user.target.wants pointing at the
|
||||
// package-provided template unit.
|
||||
wantsDir := filepath.Join(mountRoot, "etc/systemd/system/multi-user.target.wants")
|
||||
if err := os.MkdirAll(wantsDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
link := filepath.Join(wantsDir, "wpa_supplicant@"+nicIface+".service")
|
||||
_ = os.Remove(link)
|
||||
return os.Symlink("/lib/systemd/system/wpa_supplicant@.service", link)
|
||||
}
|
||||
|
||||
func installBootloader(disk string) error {
|
||||
// grub-install runs in the live environment (not chroot) using the
|
||||
// grub-pc-bin and grub-efi-amd64-bin packages already present on the ISO.
|
||||
// --boot-directory points at the installed system's /boot.
|
||||
bootDir := filepath.Join(mountRoot, "boot")
|
||||
efiDir := filepath.Join(mountRoot, "boot", "efi")
|
||||
|
||||
efiErr := run("grub-install",
|
||||
"--target=x86_64-efi",
|
||||
"--efi-directory="+efiDir,
|
||||
"--boot-directory="+bootDir,
|
||||
"--bootloader-id=spinifex",
|
||||
"--removable",
|
||||
"--recheck",
|
||||
)
|
||||
if efiErr != nil {
|
||||
slog.Warn("installBootloader: EFI install failed", "err", efiErr)
|
||||
}
|
||||
if biosErr := run("grub-install",
|
||||
"--target=i386-pc",
|
||||
"--boot-directory="+bootDir,
|
||||
"--recheck",
|
||||
disk,
|
||||
); biosErr != nil {
|
||||
if efiErr != nil {
|
||||
// Both targets failed — the system will not boot.
|
||||
return fmt.Errorf("both bootloader targets failed (EFI: %v; BIOS: %w)", efiErr, biosErr)
|
||||
}
|
||||
return biosErr
|
||||
}
|
||||
// Copy splash image and unicode font from the ISO (mounted at /cdrom) so the
|
||||
// installed GRUB shows the same branded background as the installer GRUB.
|
||||
// The font must be at /boot/grub/fonts/unicode.pf2 so update-grub finds it
|
||||
// there and emits the same loadfont path as the ISO's grub.cfg — GRUB 2.12
|
||||
// (trixie) needs the font in the boot partition, not just /usr/share/grub/.
|
||||
copySplashImage(mountRoot)
|
||||
copyGrubFont(mountRoot)
|
||||
|
||||
// Both consoles listed: tty0 stays primary for local install, ttyS0 mirrors
|
||||
// kernel output to serial so headless installs (CI, racks with serial-only
|
||||
// IPMI, remote-dev SSH-to-QEMU) see boot messages. The last `console=` on
|
||||
// the cmdline becomes the system console, so ttyS0 wins on serial-only
|
||||
// hardware while tty0 still receives output for local display.
|
||||
grubDefault := `GRUB_DEFAULT=0
|
||||
GRUB_TIMEOUT=5
|
||||
GRUB_DISTRIBUTOR=Spinifex
|
||||
GRUB_CMDLINE_LINUX_DEFAULT=""
|
||||
GRUB_CMDLINE_LINUX="console=tty0 console=ttyS0,115200 systemd.show_status=1"
|
||||
`
|
||||
|
||||
if err := os.WriteFile(filepath.Join(mountRoot, "etc/default/grub"), []byte(grubDefault), 0o644); err != nil {
|
||||
return fmt.Errorf("write /etc/default/grub: %w", err)
|
||||
}
|
||||
|
||||
// Mirror the ISO grub.cfg graphical block exactly so the installed GRUB menu
|
||||
// looks identical to the installer menu. gfxterm MUST be activated before
|
||||
// serial is appended — background_image silently does nothing in text mode.
|
||||
// Using exec tail so update-grub includes everything from line 3 as raw GRUB config.
|
||||
grubTheme := `#!/bin/sh
|
||||
exec tail -n +3 $0
|
||||
insmod all_video
|
||||
insmod font
|
||||
if loadfont /boot/grub/fonts/unicode.pf2; then
|
||||
set gfxmode=auto
|
||||
insmod gfxterm
|
||||
terminal_output gfxterm
|
||||
fi
|
||||
insmod serial
|
||||
if serial --unit=0 --speed=115200 --timeout=1; then
|
||||
terminal_input --append serial
|
||||
terminal_output --append serial
|
||||
fi
|
||||
|
||||
# --- Branding ---
|
||||
insmod png
|
||||
set theme=/boot/grub/theme.txt
|
||||
export theme
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(mountRoot, "etc/grub.d/06_spinifex"), []byte(grubTheme), 0o755); err != nil {
|
||||
return fmt.Errorf("write /etc/grub.d/06_spinifex: %w", err)
|
||||
}
|
||||
|
||||
if err := bindChrootMounts(); err != nil {
|
||||
return err
|
||||
}
|
||||
defer unbindChrootMounts()
|
||||
return run("chroot", mountRoot, "update-grub")
|
||||
}
|
||||
|
||||
func installCACert(cfg *Config) error {
|
||||
if !cfg.HasCACert || cfg.CACert == "" {
|
||||
return nil
|
||||
}
|
||||
certPath := filepath.Join(mountRoot, "usr/local/share/ca-certificates/spinifex-ca.crt")
|
||||
if err := os.WriteFile(certPath, []byte(cfg.CACert), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := bindChrootMounts(); err != nil {
|
||||
return err
|
||||
}
|
||||
defer unbindChrootMounts()
|
||||
return run("chroot", mountRoot, "update-ca-certificates")
|
||||
}
|
||||
|
||||
// promptRemoveUSB prints a removal reminder and waits up to 10 seconds for
|
||||
// the user to press Enter before rebooting. Reading from os.Stdin works because
|
||||
// spinifex-init redirects the installer's stdin from $CONSOLE_DEV.
|
||||
func promptRemoveUSB() {
|
||||
fmt.Println("\n\033[1mInstallation complete.\033[0m")
|
||||
fmt.Println("Remove the USB drive, then press Enter to reboot (auto-rebooting in 10 seconds)...")
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
scanner := bufio.NewScanner(os.Stdin)
|
||||
scanner.Scan()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(10 * time.Second):
|
||||
}
|
||||
}
|
||||
|
||||
// fireInstallCallback notifies the boot controller that installation is done
|
||||
// so it clears the PXE install flag before the node reboots. Without this,
|
||||
// a PXE-first boot order causes the node to reinstall on every reboot until
|
||||
// firstboot fires the callback — which it can never do if it never runs.
|
||||
// No-op when SPINIFEX_INSTALL_CALLBACK is not set (ISO/USB installs).
|
||||
func fireInstallCallback() {
|
||||
url := strings.TrimSpace(os.Getenv("SPINIFEX_INSTALL_CALLBACK"))
|
||||
if url == "" {
|
||||
return
|
||||
}
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Get(url) //nolint:noctx // installer has no context; best-effort fire-and-forget
|
||||
if err != nil {
|
||||
slog.Warn("fireInstallCallback: request failed", "url", url, "err", err)
|
||||
return
|
||||
}
|
||||
resp.Body.Close()
|
||||
slog.Info("fireInstallCallback: notified boot controller", "url", url, "status", resp.StatusCode)
|
||||
}
|
||||
|
||||
func reboot() error {
|
||||
// sync filesystems before reboot so nothing is lost.
|
||||
_ = run("sync")
|
||||
// Use the kernel syscall directly — the live environment runs spinifex-init
|
||||
// as PID 1 (not systemd), so the reboot(8) utility fails trying to reach D-Bus.
|
||||
return syscall.Reboot(syscall.LINUX_REBOOT_CMD_RESTART)
|
||||
}
|
||||
|
||||
// toFirstbootConfig maps installer Config to the firstboot package's Config.
|
||||
func (c *Config) toFirstbootConfig() firstboot.Config {
|
||||
// Geneve encap IP: use LAN bridge IP when a dedicated LAN NIC is present,
|
||||
// otherwise fall back to WAN bridge IP. Empty for DHCP — setup-ovn.sh
|
||||
// auto-detects the IP from the default route at boot in that case.
|
||||
encapIP := c.WANAddress
|
||||
if c.LANInterface != "" && c.LANAddress != "" {
|
||||
encapIP = c.LANAddress
|
||||
}
|
||||
return firstboot.Config{
|
||||
Hostname: c.Hostname,
|
||||
EncapIP: encapIP,
|
||||
ClusterRole: c.ClusterRole,
|
||||
JoinAddr: c.JoinAddr,
|
||||
Email: c.Email,
|
||||
InstallCallback: strings.TrimSpace(os.Getenv("SPINIFEX_INSTALL_CALLBACK")),
|
||||
SkipFormation: c.SkipFormation,
|
||||
}
|
||||
}
|
||||
|
||||
// writeFstab writes /etc/fstab on the installed system using partition UUIDs so
|
||||
// the root filesystem is mounted read-write at boot and the EFI partition is
|
||||
// mounted at /boot/efi.
|
||||
func writeFstab(disk string) error {
|
||||
efi, root := partitionPaths(disk)
|
||||
rootUUID, err := partUUID(root)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get root UUID: %w", err)
|
||||
}
|
||||
efiUUID, err := partUUID(efi)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get EFI UUID: %w", err)
|
||||
}
|
||||
fstab := fmt.Sprintf("# /etc/fstab — generated by Spinifex installer\nUUID=%s / ext4 errors=remount-ro 0 1\nUUID=%s /boot/efi vfat umask=0077 0 1\n",
|
||||
rootUUID, efiUUID)
|
||||
return os.WriteFile(filepath.Join(mountRoot, "etc/fstab"), []byte(fstab), 0o644)
|
||||
}
|
||||
|
||||
func partUUID(dev string) (string, error) {
|
||||
out, err := exec.Command("blkid", "-s", "UUID", "-o", "value", dev).Output()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("blkid %s: %w", dev, err)
|
||||
}
|
||||
uuid := strings.TrimSpace(string(out))
|
||||
if uuid == "" {
|
||||
return "", fmt.Errorf("blkid returned no UUID for %s — partition may not have a filesystem yet", dev)
|
||||
}
|
||||
return uuid, nil
|
||||
}
|
||||
|
||||
// partitionPaths returns the EFI and root partition device paths for a given
|
||||
// disk. p1 is the BIOS Boot Partition (no filesystem), p2 is EFI, p3 is root.
|
||||
// Handles both /dev/sdX (→ /dev/sdX2, /dev/sdX3) and /dev/nvmeXnY
|
||||
// (→ /dev/nvmeXnYp2, /dev/nvmeXnYp3).
|
||||
func partitionPaths(disk string) (efi, root string) {
|
||||
// NVMe devices use a 'p' separator before the partition number.
|
||||
if len(disk) > 0 && disk[len(disk)-1] >= '0' && disk[len(disk)-1] <= '9' {
|
||||
return disk + "p2", disk + "p3"
|
||||
}
|
||||
return disk + "2", disk + "3"
|
||||
}
|
||||
|
||||
// copyGrubFont copies the unicode.pf2 GRUB font into the installed system's
|
||||
// /boot/grub/fonts/ directory. This ensures update-grub finds the font at
|
||||
// /boot/grub/fonts/unicode.pf2 — the same path the ISO's grub.cfg uses —
|
||||
// so the generated grub.cfg enables gfxterm and the background image.
|
||||
// Without this, grub-mkconfig falls back to /usr/share/grub/unicode.pf2,
|
||||
// a path that GRUB 2.12 (trixie) may fail to resolve at boot time.
|
||||
// Non-fatal — a missing source is logged and skipped.
|
||||
func copyGrubFont(root string) {
|
||||
candidates := []string{
|
||||
"/cdrom/boot/grub/fonts/unicode.pf2", // ISO tree (preferred)
|
||||
"/usr/share/grub/unicode.pf2", // live system grub-common fallback
|
||||
}
|
||||
for _, src := range candidates {
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
defer in.Close()
|
||||
dstDir := filepath.Join(root, "boot/grub/fonts")
|
||||
if err := os.MkdirAll(dstDir, 0o755); err != nil {
|
||||
slog.Warn("copyGrubFont: cannot create fonts dir", "err", err)
|
||||
return
|
||||
}
|
||||
out, err := os.OpenFile(filepath.Join(dstDir, "unicode.pf2"), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
|
||||
if err != nil {
|
||||
slog.Warn("copyGrubFont: cannot open destination", "err", err)
|
||||
return
|
||||
}
|
||||
defer out.Close()
|
||||
if _, err := io.Copy(out, in); err != nil {
|
||||
slog.Warn("copyGrubFont: copy failed", "err", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
slog.Warn("copyGrubFont: no unicode.pf2 found, splash may not display")
|
||||
}
|
||||
|
||||
// copySplashImage copies the GRUB splash from the live ISO (/cdrom/boot/grub/splash.png)
|
||||
// into the installed system so the post-install GRUB shows the same branded background
|
||||
// as the installer. Non-fatal — a missing or unreadable source is logged and skipped.
|
||||
func copySplashImage(root string) {
|
||||
const src = "/usr/share/spinifex/grub-splash.png"
|
||||
in, err := os.Open(src)
|
||||
if err != nil {
|
||||
slog.Warn("copySplashImage: splash not found, skipping", "path", src)
|
||||
return
|
||||
}
|
||||
defer in.Close()
|
||||
|
||||
dstDir := filepath.Join(root, "boot/grub")
|
||||
if err := os.MkdirAll(dstDir, 0o755); err != nil {
|
||||
slog.Warn("copySplashImage: cannot create boot/grub dir", "err", err)
|
||||
return
|
||||
}
|
||||
out, err := os.OpenFile(filepath.Join(dstDir, "splash.png"), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
|
||||
if err != nil {
|
||||
slog.Warn("copySplashImage: cannot open destination", "err", err)
|
||||
return
|
||||
}
|
||||
defer out.Close()
|
||||
if _, err := io.Copy(out, in); err != nil {
|
||||
slog.Warn("copySplashImage: copy failed", "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
// maskSystemdUnit creates a symlink to /dev/null for the given unit, which is
|
||||
// the standard way to permanently disable a unit so systemd will never start it.
|
||||
func maskSystemdUnit(root, unit string) error {
|
||||
unitPath := filepath.Join(root, "etc/systemd/system", unit)
|
||||
_ = os.Remove(unitPath)
|
||||
return os.Symlink("/dev/null", unitPath)
|
||||
}
|
||||
|
||||
// setShadowPassword sets a Unix password on the installed system without
|
||||
// going through PAM. See the long comment in installSpinifex for the
|
||||
// rationale.
|
||||
func setShadowPassword(user, password string) error {
|
||||
cmd := exec.Command("chpasswd", "-c", "YESCRYPT", "-R", mountRoot)
|
||||
cmd.Stdin = strings.NewReader(user + ":" + password)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
}
|
||||
|
||||
// chrootMountPaths lists virtual filesystems to bind-mount into the chroot.
|
||||
// Order matters: unbind in reverse.
|
||||
var chrootMountPaths = []string{"dev", "proc", "sys"}
|
||||
|
||||
// bindChrootMounts bind-mounts /dev, /proc, and /sys into the installed rootfs
|
||||
// so chroot commands (chpasswd, systemd-machine-id-setup, update-grub) can
|
||||
// access hardware, process info, and entropy sources. Idempotent — already-
|
||||
// mounted paths are skipped.
|
||||
func bindChrootMounts() error {
|
||||
for _, m := range chrootMountPaths {
|
||||
dst := filepath.Join(mountRoot, m)
|
||||
if err := os.MkdirAll(dst, 0o755); err != nil {
|
||||
return fmt.Errorf("create chroot mountpoint /%s: %w", m, err)
|
||||
}
|
||||
if err := run("mount", "--bind", "/"+m, dst); err != nil {
|
||||
return fmt.Errorf("bind-mount /%s into chroot: %w", m, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// unbindChrootMounts unmounts the virtual filesystems in reverse order.
|
||||
// Errors are logged but not returned — this is best-effort cleanup.
|
||||
func unbindChrootMounts() {
|
||||
for i := len(chrootMountPaths) - 1; i >= 0; i-- {
|
||||
_ = run("umount", filepath.Join(mountRoot, chrootMountPaths[i]))
|
||||
}
|
||||
}
|
||||
|
||||
func run(name string, args ...string) error {
|
||||
cmd := exec.Command(name, args...)
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd.Run()
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package install
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestToFirstbootConfigIncludesInstallCallback(t *testing.T) {
|
||||
const url = "http://192.168.1.12/boot/done?mac=aa:bb:cc:dd:ee:ff"
|
||||
t.Setenv("SPINIFEX_INSTALL_CALLBACK", url)
|
||||
|
||||
cfg := &Config{Hostname: "node1", ClusterRole: "init"}
|
||||
fb := cfg.toFirstbootConfig()
|
||||
if fb.InstallCallback != url {
|
||||
t.Errorf("InstallCallback = %q, want %q", fb.InstallCallback, url)
|
||||
}
|
||||
}
|
||||
|
||||
func TestToFirstbootConfigEmptyCallbackWhenEnvUnset(t *testing.T) {
|
||||
os.Unsetenv("SPINIFEX_INSTALL_CALLBACK")
|
||||
|
||||
cfg := &Config{Hostname: "node1", ClusterRole: "init"}
|
||||
fb := cfg.toFirstbootConfig()
|
||||
if fb.InstallCallback != "" {
|
||||
t.Errorf("InstallCallback = %q, want empty", fb.InstallCallback)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/*
|
||||
Copyright © 2026 Mulga Defense Corporation
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/mulgadc/spinifex/cmd/installer/autoinstall"
|
||||
"github.com/mulgadc/spinifex/cmd/installer/install"
|
||||
"github.com/mulgadc/spinifex/cmd/installer/ui"
|
||||
_ "github.com/mulgadc/spinifex/internal/fipsboot"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Check for a headless autoinstall config on the boot media before
|
||||
// launching the interactive TUI. If found and enabled, run without
|
||||
// any user input then eject the USB and reboot.
|
||||
if cfg, err := autoinstall.Load(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "autoinstall: %v\n", err)
|
||||
os.Exit(1)
|
||||
} else if cfg != nil {
|
||||
if err := install.Run(cfg); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "install failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
autoinstall.EjectAndReboot()
|
||||
return
|
||||
}
|
||||
|
||||
// Normal interactive path.
|
||||
ttyPath := detectTTY()
|
||||
|
||||
cfg, err := ui.Run(ttyPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "installer: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if err := install.Run(cfg); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "install failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// detectTTY returns the TTY device path the installer should attach to.
|
||||
// It first checks the SPINIFEX_CONSOLE environment variable (set by
|
||||
// spinifex-init.sh after console fallback logic), then falls back to parsing
|
||||
// /proc/cmdline. spinifex-init.sh may override the GRUB-set value (e.g. when
|
||||
// GRUB says tty1 but no framebuffer exists, it falls back to ttyS0 and
|
||||
// exports the corrected value).
|
||||
func detectTTY() string {
|
||||
// Prefer the env var — spinifex-init.sh sets this after resolving the
|
||||
// actual console (including headless/framebuffer fallback).
|
||||
if val := os.Getenv("SPINIFEX_CONSOLE"); val != "" && val != "auto" {
|
||||
return "/dev/" + val
|
||||
}
|
||||
|
||||
// Fall back to kernel cmdline for direct invocation without spinifex-init.
|
||||
data, err := os.ReadFile("/proc/cmdline")
|
||||
if err == nil {
|
||||
for param := range strings.FieldsSeq(string(data)) {
|
||||
if after, ok := strings.CutPrefix(param, "SPINIFEX_CONSOLE="); ok {
|
||||
val := after
|
||||
if val != "" && val != "auto" {
|
||||
return "/dev/" + val
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return "/dev/tty1"
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
/*
|
||||
Copyright © 2026 Mulga Defense Corporation
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
// Package systemd writes systemd unit files and drop-ins into an installed
|
||||
// system root during the Spinifex ISO installation process.
|
||||
package systemd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// WriteLANBridgeUnit installs a non-critical oneshot service that activates
|
||||
// br-lan via networkctl *after* network-online.target. br-lan has
|
||||
// ActivationPolicy=manual in its networkd .network file, so networkd creates
|
||||
// the bridge at boot but does not bring it up automatically. This service
|
||||
// triggers the activation once the WAN path is confirmed online, ensuring a
|
||||
// missing LAN cable or DHCP timeout on the secondary bridge never stalls
|
||||
// the management interface or firstboot.
|
||||
func WriteLANBridgeUnit(root string) error {
|
||||
unit := `[Unit]
|
||||
Description=Spinifex LAN bridge (non-critical)
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/bin/networkctl up br-lan
|
||||
RemainAfterExit=yes
|
||||
# Failure is non-critical — cable unplugged or switch not ready at boot.
|
||||
SuccessExitStatus=0 1
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
`
|
||||
unitPath := filepath.Join(root, "etc/systemd/system/spinifex-lan-bridge.service")
|
||||
if err := os.WriteFile(unitPath, []byte(unit), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
wantsDir := filepath.Join(root, "etc/systemd/system/multi-user.target.wants")
|
||||
if err := os.MkdirAll(wantsDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
link := filepath.Join(wantsDir, "spinifex-lan-bridge.service")
|
||||
if err := os.Remove(link); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("remove stale symlink %s: %w", link, err)
|
||||
}
|
||||
return os.Symlink("/etc/systemd/system/spinifex-lan-bridge.service", link)
|
||||
}
|
||||
|
||||
// WriteFirstbootUnit writes the spinifex-firstboot.service oneshot unit that
|
||||
// runs the firstboot provisioning script on the first real boot after
|
||||
// installation. The script writes /var/lib/spinifex/.firstboot-done on
|
||||
// success; ConditionPathExists=! on that marker condition-skips the unit on
|
||||
// every subsequent boot so it never re-runs (replacing the previous
|
||||
// "disable on EXIT" trap which couldn't distinguish success from failure).
|
||||
func WriteFirstbootUnit(root string) error {
|
||||
unit := `[Unit]
|
||||
Description=Spinifex first-boot provisioning
|
||||
After=network-online.target
|
||||
Wants=network-online.target
|
||||
ConditionPathExists=/usr/local/bin/spinifex-firstboot.sh
|
||||
ConditionPathExists=!/var/lib/spinifex/.firstboot-done
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
Environment=HOME=/root
|
||||
ExecStart=/usr/local/bin/spinifex-firstboot.sh
|
||||
RemainAfterExit=yes
|
||||
# On non-zero exit, overwrite /etc/issue with a hint so the operator sees
|
||||
# the failure on the console — the banner unit is never enabled in that
|
||||
# case (firstboot didn't reach its enable --now line), so /etc/issue would
|
||||
# otherwise stay at the Debian default with no signal that anything is wrong.
|
||||
ExecStopPost=/bin/sh -c 'if [ "$EXIT_STATUS" != "0" ]; then printf "\n*** Spinifex firstboot FAILED ***\nLogin as spinifex, then run:\n sudo journalctl -u spinifex-firstboot.service\n\n" > /etc/issue; fi'
|
||||
# Cap total firstboot runtime so a hang in setup-ovn.sh / spx admin init /
|
||||
# ovn-central startup cannot wedge multi-user.target and keep getty from
|
||||
# ever reaching the login prompt.
|
||||
TimeoutStartSec=300s
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
`
|
||||
path := filepath.Join(root, "etc/systemd/system/spinifex-firstboot.service")
|
||||
return os.WriteFile(path, []byte(unit), 0o644)
|
||||
}
|
||||
|
||||
// WriteBannerUnit writes the spinifex-banner.service unit that runs
|
||||
// `spx admin banner --boot-check` on every boot after spinifex.target is up.
|
||||
// Running After=spinifex.target ensures the banner reflects a settled system
|
||||
// state and that the IP check/restart happens once services are already running.
|
||||
//
|
||||
// The unit file is written by the installer but NOT enabled at install time.
|
||||
// firstboot enables it (along with spinifex.target) at the end of its first
|
||||
// run via `systemctl enable --now`. On every subsequent boot both units are
|
||||
// driven by the multi-user.target.wants/ symlinks firstboot installed.
|
||||
//
|
||||
// Note: only After=spinifex.target — no Wants=. A Wants= would implicitly
|
||||
// pull spinifex.target up at boot, which is exactly the race this whole
|
||||
// change is fixing. After= alone is a no-op when the target isn't started,
|
||||
// which is correct on first boot before firstboot has provisioned configs.
|
||||
func WriteBannerUnit(root string) error {
|
||||
unit := `[Unit]
|
||||
Description=Spinifex console banner and boot health check
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/local/bin/spx admin banner --boot-check
|
||||
RemainAfterExit=yes
|
||||
# Banner is oneshot; cap it so a stuck boot-check (IP detection, try-restart)
|
||||
# cannot block getty via the spinifex-wait.conf drop-in.
|
||||
TimeoutStartSec=30s
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
`
|
||||
path := filepath.Join(root, "etc/systemd/system/spinifex-banner.service")
|
||||
return os.WriteFile(path, []byte(unit), 0o644)
|
||||
}
|
||||
|
||||
// WriteGettyDropIn holds the primary consoles (tty1 and ttyS0) until
|
||||
// spinifex-banner.service completes (when banner is enabled), so the MOTD
|
||||
// banner is visible before the login prompt appears. Drop-ins are scoped to
|
||||
// named instances (getty@tty1, serial-getty@ttyS0) so tty2, tty3, etc.
|
||||
// remain available as rescue terminals.
|
||||
//
|
||||
// Only After=, not Wants=. A Wants= would implicitly start the (un-enabled)
|
||||
// banner on the very first boot, partially undoing the "only firstboot is
|
||||
// enabled on first boot" guarantee. On first boot, banner is not enabled,
|
||||
// so After= is a no-op and login appears immediately with the default
|
||||
// /etc/issue. Once firstboot completes and enables the banner, subsequent
|
||||
// boots have banner enabled at multi-user.target and After= correctly
|
||||
// orders getty after the banner has rendered /etc/issue.
|
||||
func WriteGettyDropIn(root string) error {
|
||||
dropIn := `[Unit]
|
||||
After=spinifex-firstboot.service
|
||||
`
|
||||
for _, svc := range []string{"getty@tty1", "serial-getty@ttyS0"} {
|
||||
dropInDir := filepath.Join(root, "etc/systemd/system/"+svc+".service.d")
|
||||
if err := os.MkdirAll(dropInDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dropInDir, "spinifex-wait.conf"), []byte(dropIn), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// EnableUnit creates the multi-user.target.wants symlink for serviceName,
|
||||
// equivalent to `systemctl enable` for a unit that targets multi-user.target.
|
||||
func EnableUnit(root, serviceName string) error {
|
||||
wantsDir := filepath.Join(root, "etc/systemd/system/multi-user.target.wants")
|
||||
if err := os.MkdirAll(wantsDir, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
link := filepath.Join(wantsDir, serviceName)
|
||||
target := "/etc/systemd/system/" + serviceName
|
||||
if err := os.Remove(link); err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("remove stale symlink %s: %w", link, err)
|
||||
}
|
||||
return os.Symlink(target, link)
|
||||
}
|
||||
@@ -19,6 +19,8 @@ import (
|
||||
"syscall"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/lbagent"
|
||||
|
||||
_ "github.com/mulgadc/spinifex/internal/fipsboot"
|
||||
)
|
||||
|
||||
func main() {
|
||||
|
||||
@@ -0,0 +1,486 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/admin"
|
||||
"github.com/mulgadc/spinifex/spinifex/config"
|
||||
"github.com/mulgadc/spinifex/spinifex/gpu"
|
||||
"github.com/mulgadc/spinifex/spinifex/types"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
var adminGpuCmd = &cobra.Command{
|
||||
Use: "gpu",
|
||||
Short: "Manage GPU passthrough for a node",
|
||||
}
|
||||
|
||||
var adminGpuStatusCmd = &cobra.Command{
|
||||
Use: "status",
|
||||
Short: "Show GPU hardware and passthrough state",
|
||||
Run: runAdminGpuStatus,
|
||||
}
|
||||
|
||||
var adminGpuEnableCmd = &cobra.Command{
|
||||
Use: "enable",
|
||||
Short: "Enable GPU passthrough on a node",
|
||||
Run: runAdminGpuEnable,
|
||||
}
|
||||
|
||||
var adminGpuDisableCmd = &cobra.Command{
|
||||
Use: "disable",
|
||||
Short: "Disable GPU passthrough on a node (blocked while GPU instances are running)",
|
||||
Run: runAdminGpuDisable,
|
||||
}
|
||||
|
||||
var adminGpuSetupCmd = &cobra.Command{
|
||||
Use: "setup",
|
||||
Short: "Configure this host for GPU passthrough (IOMMU, vfio-pci, nouveau blacklist)",
|
||||
Long: `Idempotent host setup for GPU passthrough. Run once before a reboot to configure
|
||||
GRUB, vfio-pci early binding, and the nouveau blacklist; then run again after the
|
||||
reboot to verify bindings and enable GPU passthrough in the daemon.
|
||||
|
||||
Must be run as root.`,
|
||||
Run: runAdminGpuSetup,
|
||||
}
|
||||
|
||||
func init() {
|
||||
adminCmd.AddCommand(adminGpuCmd)
|
||||
adminGpuCmd.AddCommand(adminGpuStatusCmd)
|
||||
adminGpuCmd.AddCommand(adminGpuEnableCmd)
|
||||
adminGpuCmd.AddCommand(adminGpuDisableCmd)
|
||||
adminGpuCmd.AddCommand(adminGpuSetupCmd)
|
||||
|
||||
adminGpuStatusCmd.Flags().String("node", "", "Target node name (default: local node)")
|
||||
// enable, disable, and setup write to the local spinifex.toml and signal the
|
||||
// local daemon — they must be run directly on the target host.
|
||||
}
|
||||
|
||||
// gpuNodeStatus queries NATS and returns the NodeStatusResponse for the target node.
|
||||
func gpuNodeStatus(targetNode string) (*types.NodeStatusResponse, error) {
|
||||
cfg, nc, err := loadConfigAndConnect()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer nc.Close()
|
||||
|
||||
if targetNode == "" {
|
||||
targetNode = cfg.Node
|
||||
}
|
||||
|
||||
responses, err := collectResponses(nc, "spinifex.node.status", 3*time.Second)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("collect node status: %w", err)
|
||||
}
|
||||
for _, raw := range responses {
|
||||
var resp types.NodeStatusResponse
|
||||
if err := json.Unmarshal(raw, &resp); err != nil {
|
||||
continue
|
||||
}
|
||||
if resp.Node == targetNode {
|
||||
return &resp, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("node %q not found or not responding", targetNode)
|
||||
}
|
||||
|
||||
func runAdminGpuStatus(cmd *cobra.Command, _ []string) {
|
||||
targetNode, _ := cmd.Flags().GetString("node")
|
||||
resp, err := gpuNodeStatus(targetNode)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
iommu := "unknown"
|
||||
vfio := "unknown"
|
||||
if resp.GPUCapable || len(resp.GPUModels) > 0 {
|
||||
iommu = "active"
|
||||
vfio = "loaded"
|
||||
}
|
||||
|
||||
fmt.Printf("Node: %s\n", resp.Node)
|
||||
if len(resp.GPUModels) > 0 {
|
||||
fmt.Printf("GPU hardware: %s\n", strings.Join(resp.GPUModels, ", "))
|
||||
} else {
|
||||
fmt.Printf("GPU hardware: none detected\n")
|
||||
}
|
||||
fmt.Printf("IOMMU: %s\n", iommu)
|
||||
fmt.Printf("vfio-pci: %s\n", vfio)
|
||||
|
||||
if resp.GPUPassthrough {
|
||||
fmt.Printf("Passthrough: enabled\n")
|
||||
fmt.Printf("GPU pool: %d/%d allocated\n", resp.AllocGPUs, resp.TotalGPUs)
|
||||
|
||||
var gpuTypes []string
|
||||
for _, cap := range resp.InstanceTypes {
|
||||
gpuTypes = append(gpuTypes, cap.Name)
|
||||
}
|
||||
if len(gpuTypes) > 0 {
|
||||
fmt.Printf("Instance types: %s\n", strings.Join(gpuTypes, " "))
|
||||
}
|
||||
} else if resp.GPUCapable {
|
||||
fmt.Printf("Passthrough: disabled\n")
|
||||
fmt.Printf("Instance types: (none — run 'spx admin gpu enable' to activate)\n")
|
||||
} else {
|
||||
fmt.Printf("Passthrough: disabled\n")
|
||||
fmt.Printf("Instance types: (prerequisites not met)\n")
|
||||
}
|
||||
}
|
||||
|
||||
// gpuToggle writes the TOML setting, sends SIGHUP, and polls until the daemon confirms the new state.
|
||||
// enable/disable always operate on the local node; run the command directly on the target host.
|
||||
func gpuToggle(_ *cobra.Command, enable bool) {
|
||||
cfgPath := viper.GetString("config")
|
||||
if cfgPath == "" {
|
||||
cfgPath = DefaultConfigFile()
|
||||
}
|
||||
cfg, err := config.LoadConfig(cfgPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
localNode := cfg.Node
|
||||
|
||||
// Check current state first.
|
||||
resp, err := gpuNodeStatus(localNode)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if enable {
|
||||
if resp.GPUPassthrough {
|
||||
fmt.Println("GPU passthrough is already enabled.")
|
||||
return
|
||||
}
|
||||
if !resp.GPUCapable {
|
||||
fmt.Fprintln(os.Stderr, "Error: prerequisites not met on this node.")
|
||||
fmt.Fprintln(os.Stderr, " Run 'sudo spx admin gpu setup' to configure the host.")
|
||||
os.Exit(1)
|
||||
}
|
||||
} else {
|
||||
if !resp.GPUPassthrough {
|
||||
fmt.Println("GPU passthrough is already disabled.")
|
||||
return
|
||||
}
|
||||
if resp.AllocGPUs > 0 {
|
||||
fmt.Fprintf(os.Stderr, "Error: %d GPU instance(s) are running. Terminate them first.\n", resp.AllocGPUs)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
// Write the TOML setting.
|
||||
if err := admin.SetGPUPassthrough(cfgPath, localNode, enable); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error writing config: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Signal the daemon to reload.
|
||||
out, err := exec.Command("systemctl", "kill", "-s", "HUP", "spinifex-daemon").CombinedOutput()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Error sending SIGHUP: %v\n%s\n", err, out)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Poll until the daemon confirms the new state.
|
||||
action := "enable"
|
||||
if !enable {
|
||||
action = "disable"
|
||||
}
|
||||
fmt.Printf("Waiting for daemon to %s GPU passthrough", action)
|
||||
deadline := time.Now().Add(30 * time.Second)
|
||||
for time.Now().Before(deadline) {
|
||||
time.Sleep(1 * time.Second)
|
||||
fmt.Print(".")
|
||||
r, err := gpuNodeStatus(localNode)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if r.GPUPassthrough == enable {
|
||||
fmt.Println(" done.")
|
||||
// Refresh the MOTD banner immediately so the login prompt
|
||||
// reflects the new GPU passthrough state without requiring a reboot.
|
||||
_ = exec.Command("spx", "admin", "banner", "--boot-check").Run()
|
||||
runAdminGpuStatus(adminGpuStatusCmd, nil)
|
||||
return
|
||||
}
|
||||
}
|
||||
fmt.Println()
|
||||
fmt.Fprintln(os.Stderr, "Timed out waiting for daemon. Check: journalctl -u spinifex-daemon -n 50")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
func runAdminGpuEnable(cmd *cobra.Command, _ []string) {
|
||||
gpuToggle(cmd, true)
|
||||
}
|
||||
|
||||
func runAdminGpuDisable(cmd *cobra.Command, _ []string) {
|
||||
gpuToggle(cmd, false)
|
||||
}
|
||||
|
||||
func runAdminGpuSetup(_ *cobra.Command, _ []string) {
|
||||
if os.Getuid() != 0 {
|
||||
fmt.Fprintln(os.Stderr, "Error: spx admin gpu setup must be run as root.")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
fmt.Println("==> Detecting GPU")
|
||||
devices, err := gpu.Discover()
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "GPU discovery failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if len(devices) == 0 {
|
||||
fmt.Fprintln(os.Stderr, "No NVIDIA or AMD GPU found — is the card seated?")
|
||||
os.Exit(1)
|
||||
}
|
||||
for _, d := range devices {
|
||||
fmt.Printf(" %s - %s\n", d.PCIAddress, d.Model)
|
||||
}
|
||||
|
||||
fmt.Println("==> Collecting PCI IDs for vfio-pci")
|
||||
ids := gpuSetupCollectVFIOIDs(devices)
|
||||
idsCSV := strings.Join(ids, ",")
|
||||
fmt.Printf(" IDs: %s\n", idsCSV)
|
||||
|
||||
rebootNeeded := false
|
||||
|
||||
fmt.Println("==> Checking IOMMU")
|
||||
iommuEntries, _ := os.ReadDir("/sys/kernel/iommu_groups/")
|
||||
if len(iommuEntries) > 0 {
|
||||
fmt.Println(" Active")
|
||||
} else {
|
||||
params := gpuSetupIOMMUParams()
|
||||
changed, err := gpuSetupAddGRUBParams(params)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Failed to update GRUB: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if changed {
|
||||
fmt.Printf(" GRUB updated with: %s\n", params)
|
||||
} else {
|
||||
fmt.Println(" GRUB already has IOMMU params — refreshing")
|
||||
}
|
||||
ug := exec.Command("update-grub")
|
||||
ug.Stdout, ug.Stderr = os.Stdout, os.Stderr
|
||||
if err := ug.Run(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "update-grub failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
rebootNeeded = true
|
||||
}
|
||||
|
||||
fmt.Println("==> Configuring vfio udev rule")
|
||||
const vfioUdevRule = "/etc/udev/rules.d/99-spinifex-vfio.rules"
|
||||
if _, err := os.Stat(vfioUdevRule); os.IsNotExist(err) {
|
||||
rule := "SUBSYSTEM==\"vfio\", GROUP=\"spinifex\", MODE=\"0660\"\n"
|
||||
if err := os.WriteFile(vfioUdevRule, []byte(rule), 0o644); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Failed to write vfio udev rule: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println(" Rule installed")
|
||||
_ = exec.Command("udevadm", "control", "--reload-rules").Run()
|
||||
_ = exec.Command("udevadm", "trigger", "--subsystem-match=vfio").Run()
|
||||
} else {
|
||||
fmt.Println(" Rule already present")
|
||||
}
|
||||
|
||||
fmt.Println("==> Configuring nouveau blacklist")
|
||||
const nouveauConf = "/etc/modprobe.d/blacklist-nouveau.conf"
|
||||
if _, err := os.Stat(nouveauConf); os.IsNotExist(err) {
|
||||
if err := os.WriteFile(nouveauConf, []byte("blacklist nouveau\noptions nouveau modeset=0\n"), 0o644); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Failed to write nouveau blacklist: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println(" nouveau blacklisted")
|
||||
rebootNeeded = true
|
||||
} else {
|
||||
fmt.Println(" Already blacklisted")
|
||||
}
|
||||
|
||||
fmt.Println("==> Configuring amdgpu blacklist")
|
||||
const amdgpuConf = "/etc/modprobe.d/blacklist-amdgpu.conf"
|
||||
hasAMD := false
|
||||
for _, d := range devices {
|
||||
if d.Vendor == gpu.VendorAMD {
|
||||
hasAMD = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if hasAMD {
|
||||
if _, err := os.Stat(amdgpuConf); os.IsNotExist(err) {
|
||||
if err := os.WriteFile(amdgpuConf, []byte("blacklist amdgpu\n"), 0o644); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Failed to write amdgpu blacklist: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println(" amdgpu blacklisted")
|
||||
rebootNeeded = true
|
||||
} else {
|
||||
fmt.Println(" Already blacklisted")
|
||||
}
|
||||
} else {
|
||||
fmt.Println(" No AMD GPUs — skipping")
|
||||
}
|
||||
|
||||
fmt.Println("==> Configuring vfio-pci early binding")
|
||||
const vfioPCIConf = "/etc/modprobe.d/vfio-pci.conf"
|
||||
existing, _ := os.ReadFile(vfioPCIConf)
|
||||
if !strings.Contains(string(existing), "ids="+idsCSV) {
|
||||
if err := os.WriteFile(vfioPCIConf, []byte("options vfio-pci ids="+idsCSV+"\n"), 0o644); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Failed to write vfio-pci.conf: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf(" vfio-pci.conf written (ids=%s)\n", idsCSV)
|
||||
rebootNeeded = true
|
||||
} else {
|
||||
fmt.Printf(" Already configured for %s\n", idsCSV)
|
||||
}
|
||||
|
||||
const initramfsModules = "/etc/initramfs-tools/modules"
|
||||
modData, _ := os.ReadFile(initramfsModules)
|
||||
if !strings.Contains(string(modData), "vfio_pci") {
|
||||
f, err := os.OpenFile(initramfsModules, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0o644)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Failed to open initramfs modules: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
_, werr := fmt.Fprint(f, "\n# vfio early binding for GPU passthrough\nvfio\nvfio_iommu_type1\nvfio_pci\n")
|
||||
f.Close()
|
||||
if werr != nil {
|
||||
fmt.Fprintf(os.Stderr, "Failed to write initramfs modules: %v\n", werr)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println(" vfio modules added to initramfs")
|
||||
rebootNeeded = true
|
||||
} else {
|
||||
fmt.Println(" vfio modules already in initramfs")
|
||||
}
|
||||
|
||||
if rebootNeeded {
|
||||
fmt.Println("==> Updating initramfs")
|
||||
ui := exec.Command("update-initramfs", "-u")
|
||||
ui.Stdout, ui.Stderr = os.Stdout, os.Stderr
|
||||
if err := ui.Run(); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "update-initramfs failed: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("\nSetup complete — reboot required.")
|
||||
fmt.Println(" sudo reboot")
|
||||
fmt.Println(" Then run: sudo spx admin gpu enable")
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Println("==> Verifying vfio-pci module")
|
||||
if _, err := os.Stat("/sys/module/vfio_pci"); os.IsNotExist(err) {
|
||||
fmt.Fprintln(os.Stderr, "vfio_pci not loaded — check: journalctl -b | grep vfio")
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println(" Loaded")
|
||||
|
||||
fmt.Println("==> Verifying GPU driver binding")
|
||||
for _, d := range devices {
|
||||
current, err := gpu.CurrentDriver(d.PCIAddress)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Cannot read driver for %s: %v\n", d.PCIAddress, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if current == "vfio-pci" {
|
||||
fmt.Printf(" %s → vfio-pci\n", d.PCIAddress)
|
||||
continue
|
||||
}
|
||||
if current != "" {
|
||||
// Bound to native driver — blacklist/initramfs changes may not have taken effect.
|
||||
fmt.Fprintf(os.Stderr, "%s is bound to %q, not vfio-pci — reboot may be required\n", d.PCIAddress, current)
|
||||
fmt.Fprintf(os.Stderr, " Check: ls -la /sys/bus/pci/devices/%s/driver\n", d.PCIAddress)
|
||||
os.Exit(1)
|
||||
}
|
||||
// Device is unbound (ids= did not auto-claim it) — bind explicitly via driver_override.
|
||||
fmt.Printf(" %s unbound — binding to vfio-pci\n", d.PCIAddress)
|
||||
if _, err := gpu.BindVFIO(d.PCIAddress); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "Failed to bind %s to vfio-pci: %v\n", d.PCIAddress, err)
|
||||
fmt.Fprintf(os.Stderr, " Check: ls -la /sys/bus/pci/devices/%s/driver\n", d.PCIAddress)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf(" %s → vfio-pci\n", d.PCIAddress)
|
||||
}
|
||||
|
||||
fmt.Println("==> Enabling GPU passthrough")
|
||||
gpuToggle(nil, true)
|
||||
}
|
||||
|
||||
// gpuSetupCollectVFIOIDs collects vendor:device PCI ID pairs for each GPU and
|
||||
// all sibling PCI functions on the same device slot (e.g. GPU + HDMI audio).
|
||||
// All siblings must bind to vfio-pci together for IOMMU group isolation.
|
||||
func gpuSetupCollectVFIOIDs(devices []gpu.GPUDevice) []string {
|
||||
seen := make(map[string]bool)
|
||||
var ids []string
|
||||
for _, d := range devices {
|
||||
dot := strings.LastIndex(d.PCIAddress, ".")
|
||||
if dot < 0 {
|
||||
continue
|
||||
}
|
||||
prefix := d.PCIAddress[:dot]
|
||||
matches, _ := filepath.Glob("/sys/bus/pci/devices/" + prefix + ".*")
|
||||
for _, m := range matches {
|
||||
vb, err1 := os.ReadFile(m + "/vendor")
|
||||
db, err2 := os.ReadFile(m + "/device")
|
||||
if err1 != nil || err2 != nil {
|
||||
continue
|
||||
}
|
||||
v := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(string(vb))), "0x")
|
||||
dv := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(string(db))), "0x")
|
||||
id := v + ":" + dv
|
||||
if !seen[id] {
|
||||
seen[id] = true
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
func gpuSetupIOMMUParams() string {
|
||||
data, _ := os.ReadFile("/proc/cpuinfo")
|
||||
if strings.Contains(string(data), "GenuineIntel") {
|
||||
return "intel_iommu=on iommu=pt"
|
||||
}
|
||||
return "amd_iommu=on iommu=pt"
|
||||
}
|
||||
|
||||
// gpuSetupAddGRUBParams appends iommu kernel params to GRUB_CMDLINE_LINUX_DEFAULT.
|
||||
// Returns (true, nil) if the file was modified, (false, nil) if params were already present.
|
||||
func gpuSetupAddGRUBParams(params string) (bool, error) {
|
||||
const grubFile = "/etc/default/grub"
|
||||
data, err := os.ReadFile(grubFile)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("read %s: %w", grubFile, err)
|
||||
}
|
||||
content := string(data)
|
||||
if strings.Contains(content, "intel_iommu=on") || strings.Contains(content, "amd_iommu=on") {
|
||||
return false, nil
|
||||
}
|
||||
re := regexp.MustCompile(`(GRUB_CMDLINE_LINUX_DEFAULT="[^"]*)"`)
|
||||
updated := re.ReplaceAllStringFunc(content, func(match string) string {
|
||||
groups := re.FindStringSubmatch(match)
|
||||
if len(groups) < 2 {
|
||||
return match
|
||||
}
|
||||
return groups[1] + " " + params + `"`
|
||||
})
|
||||
if updated == content {
|
||||
return false, fmt.Errorf("GRUB_CMDLINE_LINUX_DEFAULT not found in %s", grubFile)
|
||||
}
|
||||
if err := os.WriteFile(grubFile, []byte(updated), 0o644); err != nil {
|
||||
return false, fmt.Errorf("write %s: %w", grubFile, err)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/admin"
|
||||
"github.com/mulgadc/spinifex/spinifex/formation"
|
||||
"github.com/mulgadc/spinifex/spinifex/utils"
|
||||
"github.com/spf13/viper"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// Regression guard: source URL must print for every error kind, not just
|
||||
// mismatch, so a 404/non-HTTPS/size-cap failure still tells the operator
|
||||
// which URL to investigate.
|
||||
func TestPrintChecksumError(t *testing.T) {
|
||||
image := utils.Images{Checksum: "https://example.com/SUMS", ChecksumType: "sha512"}
|
||||
const imageFile = "/var/lib/img.tar.xz"
|
||||
const imageName = "debian-13-x86_64"
|
||||
|
||||
errs := []error{
|
||||
fmt.Errorf("%w: expected abc got def", utils.ErrChecksumMismatch),
|
||||
fmt.Errorf("%w: 404", utils.ErrChecksumFetchFailed),
|
||||
errors.New("open /x: no such file"),
|
||||
}
|
||||
for _, e := range errs {
|
||||
var buf bytes.Buffer
|
||||
printChecksumError(&buf, imageFile, imageName, image, e)
|
||||
out := buf.String()
|
||||
assert.Contains(t, out, image.Checksum, "source URL must print for: %v", e)
|
||||
assert.Contains(t, out, imageFile)
|
||||
assert.Contains(t, out, "spx admin images import --name "+imageName+" --force")
|
||||
}
|
||||
}
|
||||
|
||||
// buildRemoteNodes must prefer AdvertiseIP (off-host dial target) and fall
|
||||
// back to BindIP when the peer pre-dates siv-8 and didn't send AdvertiseIP.
|
||||
func TestBuildRemoteNodes_AdvertiseFallback(t *testing.T) {
|
||||
nodes := map[string]formation.NodeInfo{
|
||||
"node1": {Name: "node1", BindIP: "10.0.0.1", AdvertiseIP: "203.0.113.1"},
|
||||
"node2": {Name: "node2", BindIP: "10.0.0.2"}, // legacy joiner
|
||||
"node3": {Name: "node3", BindIP: "10.0.0.3", AdvertiseIP: "203.0.113.3"},
|
||||
}
|
||||
got := buildRemoteNodes(nodes, "node3")
|
||||
if assert.Len(t, got, 2) {
|
||||
assert.Equal(t, "node1", got[0].Name)
|
||||
assert.Equal(t, "203.0.113.1", got[0].Host, "advertise wins when set")
|
||||
assert.Equal(t, "node2", got[1].Name)
|
||||
assert.Equal(t, "10.0.0.2", got[1].Host, "bind fallback when advertise empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveAdvertiseIP(t *testing.T) {
|
||||
wan := &admin.DetectedNetwork{WAN: &admin.DetectedInterface{IP: "192.168.1.21"}}
|
||||
noWAN := &admin.DetectedNetwork{}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
bindIP string
|
||||
advertise string
|
||||
detected *admin.DetectedNetwork
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{"single-node default, WAN detected", "0.0.0.0", "", wan, "192.168.1.21", false},
|
||||
{"single-node default, no WAN", "0.0.0.0", "", noWAN, "127.0.0.1", false},
|
||||
{"single-node explicit advertise", "0.0.0.0", "203.0.113.5", wan, "203.0.113.5", false},
|
||||
{"multi-node init, specific bind", "10.11.12.1", "", nil, "10.11.12.1", false},
|
||||
{"multi-node dual-homed override", "10.11.12.1", "203.0.113.5", nil, "203.0.113.5", false},
|
||||
{"loopback-only test", "127.0.0.1", "", noWAN, "127.0.0.1", false},
|
||||
{"invalid advertise flag", "0.0.0.0", "not-an-ip", wan, "", true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := resolveAdvertiseIP(tt.bindIP, tt.advertise, tt.detected)
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Generated spinifex.toml must include both host (listen, BindIP) and
|
||||
// advertise (off-host dial target, AdvertiseIP) for the local node.
|
||||
func TestSpinifexTomlTemplate_AdvertiseField(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "spinifex.toml")
|
||||
settings := admin.ConfigSettings{
|
||||
Node: "node1",
|
||||
Az: "ap-southeast-2a",
|
||||
Port: "4432",
|
||||
Region: "ap-southeast-2",
|
||||
BindIP: "0.0.0.0",
|
||||
AdvertiseIP: "192.168.1.21",
|
||||
AccessKey: "AKIATEST",
|
||||
SecretKey: "SECRET",
|
||||
AccountID: "123456789012",
|
||||
NatsToken: "token",
|
||||
ConfigDir: dir,
|
||||
OVNNBAddr: "tcp:127.0.0.1:6641",
|
||||
OVNSBAddr: "tcp:127.0.0.1:6642",
|
||||
}
|
||||
require.NoError(t, admin.GenerateConfigFile(path, spinifexTomlTemplate, settings))
|
||||
|
||||
data, err := os.ReadFile(path)
|
||||
require.NoError(t, err)
|
||||
content := string(data)
|
||||
assert.Contains(t, content, `host = "0.0.0.0"`, "listen address")
|
||||
assert.Contains(t, content, `advertise = "192.168.1.21"`, "off-host dial target")
|
||||
}
|
||||
|
||||
// Empty AdvertiseIP (e.g. loading an existing cluster pre-siv-8) must NOT
|
||||
// render an empty advertise = "" line — downstream fallback to Host kicks in.
|
||||
func TestSpinifexTomlTemplate_AdvertiseOmittedWhenEmpty(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "spinifex.toml")
|
||||
settings := admin.ConfigSettings{
|
||||
Node: "node1",
|
||||
Az: "ap-southeast-2a",
|
||||
Port: "4432",
|
||||
Region: "ap-southeast-2",
|
||||
BindIP: "10.11.12.1",
|
||||
AccessKey: "AKIATEST",
|
||||
SecretKey: "SECRET",
|
||||
AccountID: "123456789012",
|
||||
NatsToken: "token",
|
||||
ConfigDir: dir,
|
||||
OVNNBAddr: "tcp:127.0.0.1:6641",
|
||||
OVNSBAddr: "tcp:127.0.0.1:6642",
|
||||
}
|
||||
require.NoError(t, admin.GenerateConfigFile(path, spinifexTomlTemplate, settings))
|
||||
data, _ := os.ReadFile(path)
|
||||
assert.NotContains(t, string(data), "advertise =")
|
||||
}
|
||||
|
||||
// detectedDhcpBindBridge must return the default-route interface name verbatim
|
||||
// when it's a bridge (Linux or OVS, br-* prefix). The old detectedWanBridge()
|
||||
// returned hardcoded "br-ext" for Linux bridges — that value broke DHCP on
|
||||
// consumer-router LANs because br-ext never sees LAN DHCP traffic (mulga-998).
|
||||
func TestDetectedDhcpBindBridge(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
detected *admin.DetectedNetwork
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "Linux bridge default route returns bridge name (not 'br-ext')",
|
||||
detected: &admin.DetectedNetwork{WAN: &admin.DetectedInterface{Name: "br-wan"}},
|
||||
want: "br-wan",
|
||||
},
|
||||
{
|
||||
name: "OVS bridge default route returned verbatim",
|
||||
detected: &admin.DetectedNetwork{WAN: &admin.DetectedInterface{Name: "br-ext"}},
|
||||
want: "br-ext",
|
||||
},
|
||||
{
|
||||
name: "physical NIC defaults to 'br-wan' (bridge the installer creates)",
|
||||
detected: &admin.DetectedNetwork{WAN: &admin.DetectedInterface{Name: "enp0s3"}},
|
||||
want: "br-wan",
|
||||
},
|
||||
{
|
||||
name: "nil DetectedNetwork",
|
||||
detected: nil,
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "DetectedNetwork with nil WAN",
|
||||
detected: &admin.DetectedNetwork{},
|
||||
want: "",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.want, detectedDhcpBindBridge(tt.detected))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy `wan_bridge` TOML key must fail-start vpcd with guidance, not silently
|
||||
// alias (per mulga-998 D3). Prevents the footgun where operators inherited the
|
||||
// old key pointing at 'br-ext' and got broken DHCP on veth-mode hosts.
|
||||
func TestCheckLegacyWanBridgeKey_TOMLRejected(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfgPath := filepath.Join(dir, "spinifex.toml")
|
||||
require.NoError(t, os.WriteFile(cfgPath, []byte(`
|
||||
node = "node1"
|
||||
[nodes.node1.vpcd]
|
||||
ovn_nb_addr = "tcp:127.0.0.1:6641"
|
||||
wan_bridge = "br-ext"
|
||||
`), 0o644))
|
||||
|
||||
viper.Reset()
|
||||
t.Cleanup(viper.Reset)
|
||||
viper.SetConfigFile(cfgPath)
|
||||
viper.SetConfigType("toml")
|
||||
require.NoError(t, viper.ReadInConfig())
|
||||
|
||||
err := checkLegacyWanBridgeKey("node1", cfgPath)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "dhcp_bind_bridge")
|
||||
assert.Contains(t, err.Error(), "wan_bridge")
|
||||
}
|
||||
|
||||
func TestCheckLegacyWanBridgeKey_EnvVarRejected(t *testing.T) {
|
||||
viper.Reset()
|
||||
t.Cleanup(viper.Reset)
|
||||
t.Setenv("SPINIFEX_VPCD_WAN_BRIDGE", "br-ext")
|
||||
|
||||
err := checkLegacyWanBridgeKey("node1", "/tmp/unused.toml")
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "SPINIFEX_VPCD_WAN_BRIDGE")
|
||||
assert.Contains(t, err.Error(), "dhcp_bind_bridge")
|
||||
}
|
||||
|
||||
func TestCheckLegacyWanBridgeKey_CleanConfigPasses(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
cfgPath := filepath.Join(dir, "spinifex.toml")
|
||||
require.NoError(t, os.WriteFile(cfgPath, []byte(`
|
||||
node = "node1"
|
||||
[nodes.node1.vpcd]
|
||||
ovn_nb_addr = "tcp:127.0.0.1:6641"
|
||||
dhcp_bind_bridge = "br-wan"
|
||||
`), 0o644))
|
||||
|
||||
viper.Reset()
|
||||
t.Cleanup(viper.Reset)
|
||||
viper.SetConfigFile(cfgPath)
|
||||
viper.SetConfigType("toml")
|
||||
require.NoError(t, viper.ReadInConfig())
|
||||
|
||||
assert.NoError(t, checkLegacyWanBridgeKey("node1", cfgPath))
|
||||
}
|
||||
|
||||
// iam.nats_url must not embed a bare 0.0.0.0 — the predastore process dials
|
||||
// its own NATS via loopback when listening on wildcard.
|
||||
func TestPredastoreMultinodeTemplate_NatsURLLoopbackShim(t *testing.T) {
|
||||
nodes := []admin.PredastoreNodeConfig{
|
||||
{ID: 1, Host: "10.0.0.1"},
|
||||
{ID: 2, Host: "10.0.0.2"},
|
||||
{ID: 3, Host: "10.0.0.3"},
|
||||
}
|
||||
content, err := admin.GenerateMultiNodePredastoreConfig(
|
||||
predastoreMultiNodeTemplate, nodes, "AK", "SK", "ap-southeast-2", "token", "/tmp", "0.0.0.0",
|
||||
)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, content, `nats_url = "nats://localhost:4222"`)
|
||||
assert.NotContains(t, content, `nats_url = "nats://0.0.0.0:4222"`)
|
||||
|
||||
// Specific bind IP → stays as-is.
|
||||
content2, err := admin.GenerateMultiNodePredastoreConfig(
|
||||
predastoreMultiNodeTemplate, nodes, "AK", "SK", "ap-southeast-2", "token", "/tmp", "10.11.12.1",
|
||||
)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, content2, `nats_url = "nats://10.11.12.1:4222"`)
|
||||
}
|
||||
|
||||
// writePredastoreEncryptionKey is called from both `spx admin init` and `spx
|
||||
// admin join`; both depend on it producing a 32-byte file at 0600 in the
|
||||
// expected layout. Predastore's keyfile loader rejects anything that isn't
|
||||
// exactly that, so a regression here would break service startup on every
|
||||
// node.
|
||||
func TestWritePredastoreEncryptionKey(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
|
||||
keyPath, err := writePredastoreEncryptionKey(configDir)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, filepath.Join(configDir, "predastore", "encryption.key"), keyPath)
|
||||
|
||||
info, err := os.Stat(keyPath)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(32), info.Size(), "predastore master key must be exactly 32 bytes")
|
||||
assert.Equal(t, os.FileMode(0600), info.Mode().Perm(), "predastore master key must be mode 0600")
|
||||
|
||||
// Per-node invariant: two successive calls (on what would be two
|
||||
// different nodes) must produce different key material. If they ever
|
||||
// returned the same bytes we'd have silently lost the per-node
|
||||
// blast-radius property.
|
||||
configDir2 := t.TempDir()
|
||||
keyPath2, err := writePredastoreEncryptionKey(configDir2)
|
||||
require.NoError(t, err)
|
||||
|
||||
key1, err := os.ReadFile(keyPath)
|
||||
require.NoError(t, err)
|
||||
key2, err := os.ReadFile(keyPath2)
|
||||
require.NoError(t, err)
|
||||
assert.NotEqual(t, key1, key2, "per-node keys must differ")
|
||||
}
|
||||
|
||||
// Joiners run `spx admin join` against a configDir that doesn't yet contain
|
||||
// a predastore/ subdir. The helper must create it (predastore subdir is
|
||||
// owned by spinifex-storage in prod, but the helper just needs the dir to
|
||||
// exist).
|
||||
func TestWritePredastoreEncryptionKey_CreatesMissingDir(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
// Don't pre-create the predastore subdir — simulate a fresh joiner.
|
||||
|
||||
keyPath, err := writePredastoreEncryptionKey(configDir)
|
||||
require.NoError(t, err)
|
||||
|
||||
dirInfo, err := os.Stat(filepath.Dir(keyPath))
|
||||
require.NoError(t, err)
|
||||
assert.True(t, dirInfo.IsDir())
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/admin"
|
||||
"github.com/mulgadc/spinifex/spinifex/config"
|
||||
"github.com/mulgadc/spinifex/spinifex/types"
|
||||
"github.com/mulgadc/spinifex/spinifex/utils"
|
||||
@@ -90,7 +91,7 @@ func loadConfigAndConnect() (*config.ClusterConfig, *nats.Conn, error) {
|
||||
}
|
||||
|
||||
nodeConfig := cfg.Nodes[cfg.Node]
|
||||
nc, err := utils.ConnectNATS(nodeConfig.NATS.Host, nodeConfig.NATS.ACL.Token)
|
||||
nc, err := utils.ConnectNATS(admin.DialTarget(nodeConfig.NATS.Host), nodeConfig.NATS.ACL.Token, nodeConfig.NATS.CACert)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to connect to NATS: %w", err)
|
||||
}
|
||||
|
||||
@@ -54,12 +54,15 @@ func DefaultConfigFile() string {
|
||||
return filepath.Join(DefaultConfigDir(), "spinifex.toml")
|
||||
}
|
||||
|
||||
// productionMarkerPath identifies a production install by directory presence.
|
||||
// Created by setup.sh during binary install. Overridable in tests.
|
||||
var productionMarkerPath = "/etc/spinifex"
|
||||
|
||||
// isProductionLayout returns true when running in a production install.
|
||||
// Detected by: /etc/spinifex directory exists (created by setup.sh during
|
||||
// binary install). No root check — allows non-root users (e.g. tf-user) to
|
||||
// run CLI commands like `spx get nodes` without sudo or --config flags.
|
||||
// No root check — allows non-root users (e.g. tf-user) to run CLI commands
|
||||
// like `spx get nodes` without sudo or --config flags.
|
||||
func isProductionLayout() bool {
|
||||
if info, err := os.Stat("/etc/spinifex"); err == nil && info.IsDir() {
|
||||
if info, err := os.Stat(productionMarkerPath); err == nil && info.IsDir() {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
|
||||
@@ -1,57 +1,77 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestDefaultConfigDir_Dev(t *testing.T) {
|
||||
// Without /etc/spinifex should get dev paths
|
||||
if _, err := os.Stat("/etc/spinifex"); err == nil {
|
||||
t.Skip("test requires /etc/spinifex to not exist")
|
||||
}
|
||||
|
||||
homeDir, _ := os.UserHomeDir()
|
||||
expected := filepath.Join(homeDir, "spinifex", "config")
|
||||
got := DefaultConfigDir()
|
||||
if got != expected {
|
||||
t.Errorf("DefaultConfigDir() = %q, want %q", got, expected)
|
||||
}
|
||||
// withProductionMarker temporarily overrides the marker path used to detect a
|
||||
// production install. Tests that pass an empty string force "dev" mode by
|
||||
// pointing at a non-existent path; tests that pass t.TempDir() force
|
||||
// "production" mode by pointing at a real directory.
|
||||
func withProductionMarker(t *testing.T, path string) {
|
||||
t.Helper()
|
||||
orig := productionMarkerPath
|
||||
productionMarkerPath = path
|
||||
t.Cleanup(func() { productionMarkerPath = orig })
|
||||
}
|
||||
|
||||
func TestDefaultDataDir_Dev(t *testing.T) {
|
||||
if _, err := os.Stat("/etc/spinifex"); err == nil {
|
||||
t.Skip("test requires /etc/spinifex to not exist")
|
||||
}
|
||||
|
||||
homeDir, _ := os.UserHomeDir()
|
||||
expected := filepath.Join(homeDir, "spinifex")
|
||||
got := DefaultDataDir()
|
||||
if got != expected {
|
||||
t.Errorf("DefaultDataDir() = %q, want %q", got, expected)
|
||||
}
|
||||
func TestIsProductionLayout(t *testing.T) {
|
||||
t.Run("absent marker returns false", func(t *testing.T) {
|
||||
withProductionMarker(t, filepath.Join(t.TempDir(), "does-not-exist"))
|
||||
assert.False(t, isProductionLayout())
|
||||
})
|
||||
t.Run("present marker returns true", func(t *testing.T) {
|
||||
withProductionMarker(t, t.TempDir())
|
||||
assert.True(t, isProductionLayout())
|
||||
})
|
||||
}
|
||||
|
||||
func TestDefaultConfigFile(t *testing.T) {
|
||||
if _, err := os.Stat("/etc/spinifex"); err == nil {
|
||||
t.Skip("test requires /etc/spinifex to not exist")
|
||||
func TestDefaultPaths(t *testing.T) {
|
||||
homeDir := t.TempDir()
|
||||
t.Setenv("HOME", homeDir)
|
||||
t.Setenv("SUDO_USER", "")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
productionMode bool
|
||||
wantConfigDir string
|
||||
wantDataDir string
|
||||
wantConfigFile string
|
||||
wantLogDir string // for LogDirFor("/data/dir")
|
||||
}{
|
||||
{
|
||||
name: "dev layout",
|
||||
productionMode: false,
|
||||
wantConfigDir: filepath.Join(homeDir, "spinifex", "config"),
|
||||
wantDataDir: filepath.Join(homeDir, "spinifex"),
|
||||
wantConfigFile: filepath.Join(homeDir, "spinifex", "config", "spinifex.toml"),
|
||||
wantLogDir: "/data/dir/logs",
|
||||
},
|
||||
{
|
||||
name: "production layout",
|
||||
productionMode: true,
|
||||
wantConfigDir: "/etc/spinifex",
|
||||
wantDataDir: "/var/lib/spinifex",
|
||||
wantConfigFile: "/etc/spinifex/spinifex.toml",
|
||||
wantLogDir: "/var/log/spinifex",
|
||||
},
|
||||
}
|
||||
|
||||
got := DefaultConfigFile()
|
||||
homeDir, _ := os.UserHomeDir()
|
||||
expected := filepath.Join(homeDir, "spinifex", "config", "spinifex.toml")
|
||||
if got != expected {
|
||||
t.Errorf("DefaultConfigFile() = %q, want %q", got, expected)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsProductionLayout_NoEtcSpinifex(t *testing.T) {
|
||||
if _, err := os.Stat("/etc/spinifex"); err == nil {
|
||||
t.Skip("test requires /etc/spinifex to not exist")
|
||||
}
|
||||
|
||||
if isProductionLayout() {
|
||||
t.Error("isProductionLayout() = true without /etc/spinifex, want false")
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.productionMode {
|
||||
withProductionMarker(t, t.TempDir())
|
||||
} else {
|
||||
withProductionMarker(t, filepath.Join(t.TempDir(), "absent"))
|
||||
}
|
||||
|
||||
assert.Equal(t, tt.wantConfigDir, DefaultConfigDir())
|
||||
assert.Equal(t, tt.wantDataDir, DefaultDataDir())
|
||||
assert.Equal(t, tt.wantConfigFile, DefaultConfigFile())
|
||||
assert.Equal(t, tt.wantLogDir, LogDirFor("/data/dir"))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,6 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/mulgadc/predastore/s3"
|
||||
"github.com/mulgadc/spinifex/spinifex/config"
|
||||
"github.com/mulgadc/spinifex/spinifex/service"
|
||||
"github.com/mulgadc/spinifex/spinifex/services/awsgw"
|
||||
@@ -116,13 +115,11 @@ var predastoreStartCmd = &cobra.Command{
|
||||
return
|
||||
}
|
||||
|
||||
backendType := viper.GetString("backend")
|
||||
var backend s3.BackendType
|
||||
encryptionKeyFile := viper.GetString("encryption-key-file")
|
||||
|
||||
if backendType == "distributed" {
|
||||
backend = s3.BackendDistributed
|
||||
} else {
|
||||
backend = s3.BackendFilesystem
|
||||
if encryptionKeyFile == "" {
|
||||
fmt.Println("Encryption key file is not set")
|
||||
return
|
||||
}
|
||||
|
||||
nodeID := viper.GetInt("node-id")
|
||||
@@ -138,8 +135,9 @@ var predastoreStartCmd = &cobra.Command{
|
||||
TlsCert: tlsCert,
|
||||
TlsKey: tlsKey,
|
||||
|
||||
Backend: backend,
|
||||
NodeID: nodeID,
|
||||
EncryptionKeyFile: encryptionKeyFile,
|
||||
|
||||
NodeID: nodeID,
|
||||
|
||||
PprofEnabled: pprofEnabled,
|
||||
PprofOutputPath: pprofOutput,
|
||||
@@ -288,6 +286,7 @@ var viperblockStartCmd = &cobra.Command{
|
||||
service, err := service.New("viperblock", &viperblockd.Config{
|
||||
NatsHost: nodeConfig.NATS.Host,
|
||||
NatsToken: nodeConfig.NATS.ACL.Token,
|
||||
NatsCACert: nodeConfig.NATS.CACert,
|
||||
PluginPath: pluginPath,
|
||||
S3Host: nodeConfig.Predastore.Host,
|
||||
Bucket: nodeConfig.Predastore.Bucket,
|
||||
@@ -311,7 +310,7 @@ var viperblockStartCmd = &cobra.Command{
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Println("Viperblock service started", service)
|
||||
fmt.Println("Viperblock service started")
|
||||
},
|
||||
}
|
||||
|
||||
@@ -678,6 +677,32 @@ var spinifexUIStatusCmd = &cobra.Command{
|
||||
},
|
||||
}
|
||||
|
||||
// checkLegacyWanBridgeKey fails vpcd startup if the deprecated `wan_bridge`
|
||||
// TOML key or `SPINIFEX_VPCD_WAN_BRIDGE` env-var is present. The key was
|
||||
// renamed to `dhcp_bind_bridge` (see mulga-998.a) because the old name misled
|
||||
// operators into pointing at the OVN-side bridge ("br-ext"), which never sees
|
||||
// LAN DHCP traffic. Per D3: no silent alias, no auto-rewrite — operator must
|
||||
// rename the key before vpcd will start.
|
||||
func checkLegacyWanBridgeKey(node, cfgFile string) error {
|
||||
legacyInTOML := viper.IsSet("nodes." + node + ".vpcd.wan_bridge")
|
||||
legacyInEnv := os.Getenv("SPINIFEX_VPCD_WAN_BRIDGE") != ""
|
||||
if !legacyInTOML && !legacyInEnv {
|
||||
return nil
|
||||
}
|
||||
source := cfgFile
|
||||
if legacyInEnv {
|
||||
source = "env SPINIFEX_VPCD_WAN_BRIDGE"
|
||||
}
|
||||
return fmt.Errorf(
|
||||
"vpcd: deprecated 'wan_bridge' key found in %s. "+
|
||||
"Rename 'wan_bridge' to 'dhcp_bind_bridge'. The value may also be wrong — verify it is "+
|
||||
"the Linux bridge holding your WAN NIC (veth mode) or the OVS bridge holding your WAN NIC (direct mode); "+
|
||||
"never 'br-ext'. Typical value on a consumer-router LAN: 'br-wan'. "+
|
||||
"Then: sudo systemctl restart spinifex-vpcd",
|
||||
source,
|
||||
)
|
||||
}
|
||||
|
||||
var vpcdStartCmd = &cobra.Command{
|
||||
Use: "start",
|
||||
Short: "Start the vpcd service",
|
||||
@@ -696,32 +721,33 @@ var vpcdStartCmd = &cobra.Command{
|
||||
return
|
||||
}
|
||||
|
||||
if err := checkLegacyWanBridgeKey(clusterConfig.Node, cfgFile); err != nil {
|
||||
slog.Error(err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
nodeConfig := clusterConfig.Nodes[clusterConfig.Node]
|
||||
|
||||
// Map cluster-wide external pools to vpcd config
|
||||
var extPools []vpcd.ExternalPoolConfig
|
||||
for _, p := range clusterConfig.Network.ExternalPools {
|
||||
extPools = append(extPools, vpcd.ExternalPoolConfig{
|
||||
Name: p.Name,
|
||||
RangeStart: p.RangeStart,
|
||||
RangeEnd: p.RangeEnd,
|
||||
Gateway: p.Gateway,
|
||||
GatewayIP: p.GatewayIP,
|
||||
PrefixLen: p.PrefixLen,
|
||||
DNSServers: p.DNSServers,
|
||||
Region: p.Region,
|
||||
AZ: p.AZ,
|
||||
Name: p.Name,
|
||||
Source: p.Source,
|
||||
RangeStart: p.RangeStart,
|
||||
RangeEnd: p.RangeEnd,
|
||||
Gateway: p.Gateway,
|
||||
GatewayIP: p.GatewayIP,
|
||||
PrefixLen: p.PrefixLen,
|
||||
DNSServers: p.DNSServers,
|
||||
Region: p.Region,
|
||||
AZ: p.AZ,
|
||||
DhcpBindBridge: nodeConfig.VPCD.DhcpBindBridge,
|
||||
GwLrpRangeStart: p.GwLrpRangeStart,
|
||||
GwLrpRangeEnd: p.GwLrpRangeEnd,
|
||||
})
|
||||
}
|
||||
|
||||
// Fallback chassis names derived from config node names. vpcd prefers
|
||||
// discovering chassis from the OVN Southbound DB at startup; these are
|
||||
// only used if that discovery fails.
|
||||
var chassisNames []string
|
||||
for nodeName := range clusterConfig.Nodes {
|
||||
chassisNames = append(chassisNames, "chassis-"+nodeName)
|
||||
}
|
||||
|
||||
var bootstrap *vpcd.BootstrapVPC
|
||||
if clusterConfig.Bootstrap.VpcId != "" {
|
||||
bootstrap = &vpcd.BootstrapVPC{
|
||||
@@ -743,16 +769,16 @@ var vpcdStartCmd = &cobra.Command{
|
||||
svc, err := service.New("vpcd", &vpcd.Config{
|
||||
NatsHost: nodeConfig.NATS.Host,
|
||||
NatsToken: nodeConfig.NATS.ACL.Token,
|
||||
NatsCACert: nodeConfig.NATS.CACert,
|
||||
OVNNBAddr: nodeConfig.VPCD.OVNNBAddr,
|
||||
OVNSBAddr: nodeConfig.VPCD.OVNSBAddr,
|
||||
BaseDir: nodeConfig.BaseDir,
|
||||
Debug: false,
|
||||
ExternalMode: clusterConfig.Network.ExternalMode,
|
||||
ExternalPools: extPools,
|
||||
ChassisNames: chassisNames,
|
||||
Bootstrap: bootstrap,
|
||||
ExternalInterface: nodeConfig.VPCD.ExternalInterface,
|
||||
WanBridge: nodeConfig.VPCD.WanBridge,
|
||||
DhcpBindBridge: nodeConfig.VPCD.DhcpBindBridge,
|
||||
BridgeMode: nodeConfig.VPCD.BridgeMode,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -841,13 +867,20 @@ func init() {
|
||||
viper.BindEnv("tls-key", "SPINIFEX_PREDASTORE_TLS_KEY")
|
||||
viper.BindPFlag("tls-key", predastoreCmd.PersistentFlags().Lookup("tls-key"))
|
||||
|
||||
// Predastore at-rest encryption master key (per node; mode 0600)
|
||||
predastoreCmd.PersistentFlags().String("encryption-key-file", "", "Path to this node's 32-byte AES-256 master key file (required)")
|
||||
viper.BindEnv("encryption-key-file", "SPINIFEX_PREDASTORE_ENCRYPTION_KEY_FILE")
|
||||
viper.BindPFlag("encryption-key-file", predastoreCmd.PersistentFlags().Lookup("encryption-key-file"))
|
||||
|
||||
// Predastore Backend
|
||||
predastoreCmd.PersistentFlags().String("backend", "distributed", "Predastore (S3) backend")
|
||||
viper.BindEnv("backend", "SPINIFEX_PREDASTORE_BACKEND")
|
||||
viper.BindPFlag("backend", predastoreCmd.PersistentFlags().Lookup("backend"))
|
||||
|
||||
// Predastore Node ID
|
||||
predastoreCmd.PersistentFlags().Int("node-id", 0, "Predastore (S3) node ID")
|
||||
// Predastore Node ID. Default -1 is dev mode (launch every configured
|
||||
// QUIC node in-process). Production deployments set this to the node's
|
||||
// real ID (>= 1) via SPINIFEX_PREDASTORE_NODE_ID or --node-id.
|
||||
predastoreCmd.PersistentFlags().Int("node-id", -1, "Predastore (S3) node ID (-1 = dev mode, >= 1 = production)")
|
||||
viper.BindEnv("node-id", "SPINIFEX_PREDASTORE_NODE_ID")
|
||||
viper.BindPFlag("node-id", predastoreCmd.PersistentFlags().Lookup("node-id"))
|
||||
|
||||
|
||||
@@ -1,6 +1,28 @@
|
||||
# AWS Gateway configuration file in TOML format.
|
||||
version = "1.0"
|
||||
version = "2"
|
||||
region = "{{.Region}}"
|
||||
|
||||
# Enable verbose debugging
|
||||
debug = false
|
||||
|
||||
# API request throttling (per-account, per-action token bucket)
|
||||
[ratelimit]
|
||||
enabled = false
|
||||
rate = 20 # Default sustained requests per second per account per action
|
||||
burst = 100 # Default maximum burst capacity
|
||||
|
||||
[ratelimit.action.RunInstances]
|
||||
rate = 2
|
||||
burst = 40
|
||||
|
||||
[ratelimit.action.TerminateInstances]
|
||||
rate = 2
|
||||
burst = 40
|
||||
|
||||
[ratelimit.action.StartInstances]
|
||||
rate = 2
|
||||
burst = 40
|
||||
|
||||
[ratelimit.action.StopInstances]
|
||||
rate = 2
|
||||
burst = 40
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# spinifex-config-version: 1
|
||||
# spinifex-config-version: 3
|
||||
# NATS Server Configuration
|
||||
# Generated by Spinifex admin init
|
||||
|
||||
@@ -9,12 +9,25 @@ listen: {{.BindIP}}:4222
|
||||
listen: 0.0.0.0:4222
|
||||
{{- end }}
|
||||
|
||||
tls {
|
||||
cert_file: "{{.ConfigDir}}/server.pem"
|
||||
key_file: "{{.ConfigDir}}/server.key"
|
||||
ca_file: "{{.ConfigDir}}/ca.pem"
|
||||
}
|
||||
|
||||
{{- if and .ClusterBindIP .ClusterRoutes }}
|
||||
|
||||
cluster {
|
||||
name: {{ .ClusterName }}
|
||||
listen: {{.ClusterBindIP}}:4248
|
||||
|
||||
tls {
|
||||
cert_file: "{{.ConfigDir}}/server.pem"
|
||||
key_file: "{{.ConfigDir}}/server.key"
|
||||
ca_file: "{{.ConfigDir}}/ca.pem"
|
||||
verify: true
|
||||
}
|
||||
|
||||
routes = [
|
||||
{{- range .ClusterRoutes }}
|
||||
"nats-route://{{$.NatsToken}}@{{ . }}",
|
||||
@@ -49,9 +62,5 @@ max_payload: 1MB
|
||||
max_pending: 64MB
|
||||
|
||||
# Monitoring
|
||||
{{- if .BindIP }}
|
||||
http: {{.BindIP}}:8222
|
||||
{{- else }}
|
||||
http_port: 8222
|
||||
{{- end }}
|
||||
http: 127.0.0.1:8222
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Predastore S3-compatible storage configuration file in TOML format.
|
||||
# Generated for multi-node cluster
|
||||
version = "1.0"
|
||||
version = "4"
|
||||
region = "{{.Region}}"
|
||||
|
||||
host = "0.0.0.0"
|
||||
@@ -9,8 +9,13 @@ port = 8443
|
||||
debug = false
|
||||
|
||||
[rs]
|
||||
{{- if eq (len .Nodes) 2}}
|
||||
data = 1
|
||||
parity = 1
|
||||
{{- else}}
|
||||
data = 2
|
||||
parity = 1
|
||||
{{- end}}
|
||||
{{- range .Nodes}}
|
||||
|
||||
[[db]]
|
||||
@@ -39,17 +44,34 @@ region = "{{.Region}}"
|
||||
type = "distributed"
|
||||
public = false
|
||||
encryption = ""
|
||||
account_id = "000000000000"
|
||||
|
||||
[[auth]]
|
||||
access_key_id = "{{.AccessKey}}"
|
||||
secret_access_key = "{{.SecretKey}}"
|
||||
account_id = "000000000000"
|
||||
policy = [
|
||||
{ bucket = "predastore", actions = ["s3:ListBucket", "s3:GetObject", "s3:PutObject", "s3:DeleteObject", "s3:ListAllMyBuckets"] },
|
||||
]
|
||||
|
||||
# API request throttling (per-account, per-action token bucket)
|
||||
[ratelimit]
|
||||
enabled = false
|
||||
rate = 100 # Default sustained requests per second per account per action
|
||||
burst = 500 # Default maximum burst capacity
|
||||
|
||||
[ratelimit.action."s3:PutObject"]
|
||||
rate = 50
|
||||
burst = 200
|
||||
|
||||
[ratelimit.action."s3:DeleteObject"]
|
||||
rate = 50
|
||||
burst = 200
|
||||
|
||||
# IAM authentication via NATS KV (enables multi-account S3 access)
|
||||
[iam]
|
||||
nats_url = "nats://{{.BindIP}}:4222"
|
||||
nats_url = "nats://{{if eq .BindIP "0.0.0.0"}}localhost{{else}}{{.BindIP}}{{end}}:4222"
|
||||
nats_token = "{{.NatsToken}}"
|
||||
nats_ca_cert = "{{.ConfigDir}}/ca.pem"
|
||||
master_key_path = "{{.ConfigDir}}/master.key"
|
||||
access_keys_bucket = "spinifex-iam-access-keys"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Predastore S3-compatible storage configuration file in TOML format.
|
||||
# MULTI NODE used for testing
|
||||
version = "1.0"
|
||||
version = "4"
|
||||
region = "{{.Region}}"
|
||||
|
||||
# Optionally configure port and host, or specify via daemon CLI options
|
||||
@@ -73,17 +73,34 @@ region = "ap-southeast-2"
|
||||
type = "distributed"
|
||||
public = false
|
||||
encryption = ""
|
||||
account_id = "000000000000"
|
||||
|
||||
[[auth]]
|
||||
access_key_id = "{{.AccessKey}}"
|
||||
secret_access_key = "{{.SecretKey}}"
|
||||
account_id = "000000000000"
|
||||
policy = [
|
||||
{ bucket = "predastore", actions = ["s3:ListBucket", "s3:GetObject", "s3:PutObject", "s3:DeleteObject", "s3:ListAllMyBuckets"] },
|
||||
]
|
||||
|
||||
# API request throttling (per-account, per-action token bucket)
|
||||
[ratelimit]
|
||||
enabled = false
|
||||
rate = 100 # Default sustained requests per second per account per action
|
||||
burst = 500 # Default maximum burst capacity
|
||||
|
||||
[ratelimit.action."s3:PutObject"]
|
||||
rate = 50
|
||||
burst = 200
|
||||
|
||||
[ratelimit.action."s3:DeleteObject"]
|
||||
rate = 50
|
||||
burst = 200
|
||||
|
||||
# IAM authentication via NATS KV (enables multi-account S3 access)
|
||||
[iam]
|
||||
nats_url = "nats://{{if eq .BindIP "0.0.0.0"}}localhost{{else}}{{.BindIP}}{{end}}:4222"
|
||||
nats_token = "{{.NatsToken}}"
|
||||
nats_ca_cert = "{{.ConfigDir}}/ca.pem"
|
||||
master_key_path = "{{.ConfigDir}}/master.key"
|
||||
access_keys_bucket = "spinifex-iam-access-keys"
|
||||
@@ -1,7 +1,12 @@
|
||||
# Domain configuration file in TOML format.
|
||||
version = "1.0"
|
||||
version = "2"
|
||||
epoch = 1
|
||||
node = "{{.Node}}"
|
||||
{{- if .OperatorEmail}}
|
||||
|
||||
[operator]
|
||||
email = "{{.OperatorEmail}}"
|
||||
{{- end}}
|
||||
{{- if .ExternalMode}}
|
||||
|
||||
[network]
|
||||
@@ -42,8 +47,12 @@ subnet_cidr = "{{.BootstrapSubnetCidr}}"
|
||||
[nodes.{{.Node}}]
|
||||
node = "{{.Node}}"
|
||||
host = "{{.BindIP}}"
|
||||
{{- if .AdvertiseIP}}
|
||||
advertise = "{{.AdvertiseIP}}"
|
||||
{{- end}}
|
||||
region = "{{.Region}}"
|
||||
az = "{{.Az}}"
|
||||
data_dir = "{{.DataDir}}"
|
||||
{{- if .Services}}
|
||||
services = [{{range $i, $s := .Services}}{{if $i}}, {{end}}"{{$s}}"{{end}}]
|
||||
{{- end}}
|
||||
@@ -59,6 +68,9 @@ dev_networking = false
|
||||
{{- else}}
|
||||
dev_networking = true
|
||||
{{- end}}
|
||||
{{- if .GPUPassthrough}}
|
||||
gpu_passthrough = true
|
||||
{{- end}}
|
||||
|
||||
[nodes.{{.Node}}.awsgw]
|
||||
host = "{{.BindIP}}:9999"
|
||||
@@ -69,6 +81,7 @@ config = "awsgw/awsgw.toml"
|
||||
|
||||
[nodes.{{.Node}}.nats]
|
||||
host = "{{.BindIP}}:4222"
|
||||
cacert = "{{.ConfigDir}}/ca.pem"
|
||||
|
||||
[nodes.{{.Node}}.nats.acl]
|
||||
token = "{{.NatsToken}}"
|
||||
@@ -84,8 +97,14 @@ ovn_sb_addr = "{{.OVNSBAddr}}"
|
||||
{{- if .ExternalIface}}
|
||||
external_interface = "{{.ExternalIface}}"
|
||||
{{- end}}
|
||||
{{- if .WanBridge}}
|
||||
wan_bridge = "{{.WanBridge}}"
|
||||
{{- if .DhcpBindBridge}}
|
||||
# dhcp_bind_bridge: the bridge where vpcd's DHCP client binds its AF_PACKET
|
||||
# socket. On Linux-bridge-WAN hosts (netplan default with the WAN NIC enslaved),
|
||||
# this is the Linux bridge name (e.g. "br-wan"). On direct-OVS hosts, this is
|
||||
# the OVS bridge holding the WAN NIC.
|
||||
# NEVER set this to "br-ext" — that is the OVN-side bridge and never sees LAN
|
||||
# DHCP traffic.
|
||||
dhcp_bind_bridge = "{{.DhcpBindBridge}}"
|
||||
{{- end}}
|
||||
|
||||
[nodes.{{.Node}}.predastore]
|
||||
|
||||
@@ -88,7 +88,7 @@ func runTopNodes(cmd *cobra.Command, args []string) {
|
||||
sort.Strings(nodeNames)
|
||||
|
||||
nodeTable := pterm.TableData{
|
||||
{"NAME", "CPU (used/total)", "MEM (used/total)", "VMs"},
|
||||
{"NAME", "CPU (used/total)", "MEM (used/total)", "GPU (used/total)", "VMs"},
|
||||
}
|
||||
|
||||
// Aggregate instance type capacity across all nodes
|
||||
@@ -96,10 +96,17 @@ func runTopNodes(cmd *cobra.Command, args []string) {
|
||||
|
||||
for _, name := range nodeNames {
|
||||
if resp, ok := respondedNodes[name]; ok {
|
||||
gpuCol := "-"
|
||||
if resp.GPUPassthrough {
|
||||
gpuCol = fmt.Sprintf("%d/%d", resp.AllocGPUs, resp.TotalGPUs)
|
||||
} else if resp.GPUCapable {
|
||||
gpuCol = fmt.Sprintf("0/%d*", len(resp.GPUModels))
|
||||
}
|
||||
nodeTable = append(nodeTable, []string{
|
||||
resp.Node,
|
||||
fmt.Sprintf("%d/%d", resp.AllocVCPU, resp.TotalVCPU),
|
||||
fmt.Sprintf("%s/%s", formatMemGB(resp.AllocMemGB), formatMemGB(resp.TotalMemGB)),
|
||||
gpuCol,
|
||||
strconv.Itoa(resp.VMCount),
|
||||
})
|
||||
|
||||
@@ -120,6 +127,7 @@ func runTopNodes(cmd *cobra.Command, args []string) {
|
||||
"-",
|
||||
"-",
|
||||
"-",
|
||||
"-",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestVersionVarsExist(t *testing.T) {
|
||||
// Verify the version variables are set (defaults when not injected via ldflags)
|
||||
if Version == "" {
|
||||
t.Error("Version should not be empty")
|
||||
}
|
||||
if Commit == "" {
|
||||
t.Error("Commit should not be empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVersionCommand(t *testing.T) {
|
||||
// Verify the version command is registered
|
||||
found := false
|
||||
for _, cmd := range rootCmd.Commands() {
|
||||
if cmd.Use == "version" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Error("version command not registered on rootCmd")
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,10 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
package main
|
||||
|
||||
import "github.com/mulgadc/spinifex/cmd/spinifex/cmd"
|
||||
import (
|
||||
"github.com/mulgadc/spinifex/cmd/spinifex/cmd"
|
||||
_ "github.com/mulgadc/spinifex/internal/fipsboot"
|
||||
)
|
||||
|
||||
func main() {
|
||||
cmd.Execute()
|
||||
|
||||
@@ -13,7 +13,7 @@ Service lifecycle commands for starting, stopping, and checking status of all Sp
|
||||
| `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` (default: /opt/spinifex/lib/nbdkit-viperblock-plugin.so) | `--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 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** |
|
||||
@@ -45,14 +45,14 @@ Operational commands for inspecting cluster state. These fan out NATS requests t
|
||||
|
||||
| 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), VMs. Table 2: INSTANCE TYPE, AVAILABLE, VCPU, MEMORY | **DONE** |
|
||||
| `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** |
|
||||
|
||||
### 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`, `--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 → 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 | **DONE** |
|
||||
| `spx admin join` | `--host` (required), `--node` (required), `--bind`, `--port`, `--region`, `--az`, `--cluster-bind`, `--cluster-routes`, `--data-dir`, `--services` | Leader node must be running | Connects to leader node → 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) | **DONE** |
|
||||
| `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** |
|
||||
|
||||
### Version
|
||||
|
||||
@@ -89,9 +89,49 @@ Operational commands for inspecting cluster state. These fan out NATS requests t
|
||||
|
||||
| Command | Flags | Prerequisites | Basic Logic | Test Cases | Status |
|
||||
|---------|-------|---------------|-------------|------------|--------|
|
||||
| `spx admin images import` | `--name` | Cluster must be running, image file must exist locally | Imports a local disk image (QCOW2) as an AMI → uploads to Predastore S3 → registers as available AMI | 1. Import valid QCOW2 image<br>2. Image appears in `describe-images` | **DONE** |
|
||||
| `spx admin images import` | `--name`, `--file`, `--force`, `--skip-verify` | Cluster must be running; either `--name` (catalog download) or `--file` (operator-supplied media) | Catalog imports (`--name`) download the image, fetch the catalog `Checksum` URL, and verify the SHA-256/SHA-512 digest before extraction. Mismatch fails closed; the cached file is left on disk and `--force` re-downloads. `--file` imports skip verification — operator-supplied media is outside Spinifex's trust boundary, and the skip is logged at INFO for audit. `--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 | **DONE** |
|
||||
| `spx admin images list` | — | None | Lists available OS images that can be imported or downloaded | 1. List available images | **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.
|
||||
|
||||
### 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** |
|
||||
|
||||
## AWS Commands
|
||||
|
||||
### EC2 - Instance Management
|
||||
@@ -102,13 +142,14 @@ Operational commands for inspecting cluster state. These fan out NATS requests t
|
||||
| `describe-instances` | `--instance-ids`, `--filters` (instance-state-name, instance-id, instance-type, vpc-id, subnet-id, tag:\*, tag-key, tag-value) | `--max-results`, `--next-token`, `--dry-run` | None | Gateway fans out NATS `ec2.DescribeInstances` to all nodes (no queue group) → each daemon returns local instances → gateway aggregates and returns combined list. Filters applied per-node before aggregation (reduces payload). Also applies to stopped/terminated instances via `describeInstancesFromKV()`. | 1. Describe all instances (no filter)<br>2. Describe by instance ID<br>3. Describe with filters (e.g. instance-state-name)<br>4. Instance not found returns empty set<br>5. Multi-node aggregation returns instances from all nodes<br>6. Filter by tag<br>7. Unknown filter returns InvalidParameterValue | **DONE** |
|
||||
| `start-instances` | `--instance-ids` | `--dry-run`, `--force` | `run-instances` (instance must exist in stopped state) | Gateway sends NATS `ec2.cmd.{instance-id}` → daemon restarts stopped QEMU process with same config → state transitions stopped→pending→running | 1. Start a stopped instance<br>2. Start already-running instance (error: IncorrectInstanceState)<br>3. Start with invalid instance ID<br>4. Verify volumes re-mount on start | **DONE** |
|
||||
| `stop-instances` | `--instance-ids` | `--force`, `--hibernate`, `--dry-run` | `run-instances` (instance must be running) | Gateway sends NATS to target node → daemon issues QMP `system_powerdown` for graceful shutdown → monitors heartbeat until QEMU exits → state transitions running→stopping→stopped | 1. Graceful stop of running instance<br>2. Force stop (kills QEMU process)<br>3. Stop already-stopped instance (error)<br>4. Verify ~30s heartbeat detection | **DONE** |
|
||||
| `terminate-instances` | `--instance-ids`, `DeleteOnTermination` (per-volume flag, default true) | `--dry-run` | `run-instances` (instance must exist) | Gateway sends NATS to target node → daemon kills QEMU process → cleans up NBD mounts → deletes volumes with `DeleteOnTermination=true` via `volumeService.DeleteVolume()` (S3 cleanup of vol/, vol-efi/, vol-cloudinit/) → internal volumes (EFI, cloud-init) always cleaned up via `ebs.delete` NATS → volumes with `DeleteOnTermination=false` left in available state → state→terminated | 1. Terminate running instance<br>2. Terminate stopped instance<br>3. Terminate with DeleteOnTermination=true deletes volumes<br>4. Terminate with DeleteOnTermination=false preserves volumes<br>5. Terminate already-terminated (idempotent)<br>6. Internal volumes (EFI, cloud-init) always cleaned up<br>7. Invalid instance ID | **DONE** |
|
||||
| `terminate-instances` | `--instance-ids`, `DeleteOnTermination` (per-volume flag, default true) | `--dry-run` | `run-instances` (instance must exist) | Gateway sends NATS to target node → daemon checks `DisableApiTermination` first and rejects with `OperationNotPermitted` if set (running or stopped, no side effects) → kills QEMU process → cleans up NBD mounts → deletes volumes with `DeleteOnTermination=true` via `volumeService.DeleteVolume()` (S3 cleanup of vol/, vol-efi/, vol-cloudinit/) → internal volumes (EFI, cloud-init) always cleaned up via `ebs.delete` NATS → volumes with `DeleteOnTermination=false` left in available state → state→terminated | 1. Terminate running instance<br>2. Terminate stopped instance<br>3. Terminate with DeleteOnTermination=true deletes volumes<br>4. Terminate with DeleteOnTermination=false preserves volumes<br>5. Terminate already-terminated (idempotent)<br>6. Internal volumes (EFI, cloud-init) always cleaned up<br>7. Invalid instance ID<br>8. Running protected instance rejected (OperationNotPermitted, state stays running)<br>9. Stopped protected instance rejected (resources intact) | **DONE** |
|
||||
| `reboot-instances` | `--instance-ids` | `--dry-run` | `run-instances` (instance must be running) | Gateway validates instance IDs → sends EC2InstanceCommand with `RebootInstance=true` via NATS `ec2.cmd.{instanceId}` → daemon validates instance is in StateRunning (returns IncorrectInstanceState if stopped) → issues QMP `system_reset` → instance reboots without stopping, stays in running state | 1. Reboot running instance<br>2. Reboot multiple instances<br>3. Reboot stopped instance (error: IncorrectInstanceState)<br>4. Instance not found (error: InvalidInstanceID.NotFound)<br>5. Verify instance stays in running state after reboot | **DONE** |
|
||||
| `describe-instance-types` | `--filters` (capacity filter only) | `--instance-types`, `--max-results`, `--next-token`, `--dry-run`, all other filters | None | Gateway fans out NATS `ec2.DescribeInstanceTypes` to all nodes → each daemon reports supported types (t3.micro/small/medium/large) with vCPU/memory specs → gateway deduplicates and returns | 1. List all instance types<br>2. Filter by specific type<br>3. Filter with `capacity=true` shows available slots<br>4. Verify vCPU/memory specs match hardware | **DONE** |
|
||||
| `modify-instance-attribute` | `--instance-id`, `--instance-type`, `--user-data` | `--disable-api-termination`, `--ebs-optimized`, `--source-dest-check`, `--instance-initiated-shutdown-behavior`, `--block-device-mappings`, `--groups`, `--ena-support`, `--sriov-net-support` | Instance must be stopped (in NATS KV) | Gateway validates input (exactly one attribute per call, instance ID format) → NATS `ec2.ModifyInstanceAttribute` with `spinifex-workers` queue group → daemon loads stopped instance from JetStream KV → applies attribute change → writes back to KV → returns `{}` on success. **InstanceType**: updates vm.InstanceType, Config, and Instance fields; clears StateReason (enables recovery from instance-type-missing bug). **UserData**: stores decoded content in vm.UserData and re-encodes to base64 for RunInstancesInput (cloud-init on next start). No instance type pre-validation (matches AWS — invalid types accepted, fail at StartInstances time). | 1. Change instance type while stopped<br>2. Change user data while stopped<br>3. Modify running instance (error: NotFound — running instances not in KV)<br>4. Instance not found (error: InvalidInstanceID.NotFound)<br>5. Instance not stopped (error: IncorrectInstanceState)<br>6. Invalid instance type accepted (fails on start with InsufficientInstanceCapacity)<br>7. StateReason cleared on type change (recovery from capacity-unavailable)<br>8. Missing/malformed instance ID (error: InvalidInstanceID.Malformed)<br>9. No attribute set (error: InvalidParameterValue)<br>10. Multiple attributes in one call (error: InvalidParameterValue) | **DONE** |
|
||||
| `modify-instance-attribute` | `--instance-id`, `--instance-type`, `--user-data`, `--disable-api-termination` | `--ebs-optimized`, `--source-dest-check`, `--instance-initiated-shutdown-behavior`, `--block-device-mappings`, `--groups`, `--ena-support`, `--sriov-net-support` | Instance must exist; `instance-type` and `user-data` require stopped state, `disable-api-termination` works on running or stopped | Gateway validates input (exactly one attribute per call, instance ID format) → NATS `ec2.ModifyInstanceAttribute` with `spinifex-workers` queue group → daemon dispatches by state: stopped instances loaded from JetStream KV, running instances updated via `vm.Manager.UpdateAndPersist` (atomic flag check + persist under one lock to prevent Modify/Terminate race) → returns `{}` on success. **InstanceType**: updates vm.InstanceType, Config, and Instance fields; clears StateReason (enables recovery from instance-type-missing bug). **UserData**: stores decoded content in vm.UserData and re-encodes to base64 for RunInstancesInput (cloud-init on next start). **DisableApiTermination**: stamps `RunInstancesInput.DisableApiTermination`; flag survives stop/start and daemon restarts. No instance type pre-validation (matches AWS — invalid types accepted, fail at StartInstances time). | 1. Change instance type while stopped<br>2. Change user data while stopped<br>3. Toggle disable-api-termination on running instance<br>4. Toggle disable-api-termination on stopped instance<br>5. Modify instance-type on running instance (error: NotFound — running instances not in KV)<br>6. Instance not found (error: InvalidInstanceID.NotFound)<br>7. Instance not stopped for type/user-data change (error: IncorrectInstanceState)<br>8. Invalid instance type accepted (fails on start with InsufficientInstanceCapacity)<br>9. StateReason cleared on type change (recovery from capacity-unavailable)<br>10. Missing/malformed instance ID (error: InvalidInstanceID.Malformed)<br>11. No attribute set (error: InvalidParameterValue)<br>12. Multiple attributes in one call (error: InvalidParameterValue) | **DONE** |
|
||||
| `get-console-output` | `--instance-id` | `--latest` (always returns latest), `--dry-run` | Instance must be running on a node | Gateway sends NATS `ec2.{instanceId}.GetConsoleOutput` (per-instance topic, routed to owning node) → daemon reads console log file from disk → returns last 64KB base64-encoded with timestamp. Always available regardless of serial console access setting (matches AWS behavior). | 1. Get output from running instance<br>2. Empty log file returns empty output<br>3. Instance not found (error: InvalidInstanceID.NotFound) | **DONE** |
|
||||
| `describe-instance-attribute` | `--instance-id`, `--attribute` (instanceType, userData, instanceInitiatedShutdownBehavior, disableApiTermination, disableApiStop, ebsOptimized, enaSupport, sourceDestCheck, rootDeviceName, kernel, ramdisk) | `--dry-run` | Instance must exist (running or stopped) | Gateway validates input → NATS `ec2.DescribeInstanceAttribute` with `spinifex-workers` queue group → daemon checks running instances first (`d.Instances.VMS`), then stopped instances in JetStream KV → returns single attribute per call (matches AWS behavior). Stored attributes (`instanceType`, `userData`) return real values; unstored attributes return AWS defaults (`instanceInitiatedShutdownBehavior`=stop, `disableApiTermination`=false, etc.) | 1. Get instanceType from running instance<br>2. Get userData from stopped instance<br>3. Get default disableApiTermination<br>4. Invalid attribute name (error)<br>5. Instance not found (error: InvalidInstanceID.NotFound) | **DONE** |
|
||||
| `describe-instance-attribute` | `--instance-id`, `--attribute` (instanceType, userData, instanceInitiatedShutdownBehavior, disableApiTermination, disableApiStop, ebsOptimized, enaSupport, sourceDestCheck, rootDeviceName, kernel, ramdisk) | `--dry-run` | Instance must exist (running or stopped) | Gateway validates input → NATS `ec2.DescribeInstanceAttribute` with `spinifex-workers` queue group → daemon checks running instances first (`d.Instances.VMS`), then stopped instances in JetStream KV → returns single attribute per call (matches AWS behavior). Stored attributes (`instanceType`, `userData`, `disableApiTermination`) return real values from `RunInstancesInput`; remaining attributes return AWS defaults (`instanceInitiatedShutdownBehavior`=stop, etc.) | 1. Get instanceType from running instance<br>2. Get userData from stopped instance<br>3. Get disableApiTermination (reflects RunInstances flag + Modify updates)<br>4. Invalid attribute name (error)<br>5. Instance not found (error: InvalidInstanceID.NotFound) | **DONE** |
|
||||
| `describe-instance-credit-specifications` | `--instance-ids` | `--filters`, `--max-results`, `--dry-run` | None | Gateway-only stub — returns `CpuCredits: "standard"` for each requested instance ID. No daemon round-trip. T-series credit mode is not persisted. | 1. Get credit spec for T-series instance<br>2. Multiple instance IDs | **DONE** |
|
||||
| `describe-instance-status` | `--instance-ids`, `--include-all-instances`, `--filters` (availability-zone, instance-state-code, instance-state-name, tag:\*) | `--max-results`, `--next-token`, `--dry-run` (silently ignored), `event.*` / `instance-status.*` / `system-status.*` filters (rejected with InvalidParameterValue) | None | Gateway fans out NATS `ec2.DescribeInstanceStatus` to all nodes (no queue group). Each daemon validates input (i- prefix, filter names), iterates local VMs and renders InstanceStatus: `Running`→ok/passed, everything-else-included→not-applicable. When `IncludeAllInstances=true`, gateway also queries `ec2.DescribeStoppedInstances` in parallel and transforms reservation entries into InstanceStatus with `not-applicable`. Aggregated results dedup'd by InstanceId (running > stopped). Terminated and error-state instances never returned. No real health probing — static values reflect Mulga's lifecycle state only. | 1. Status of running instance (ok/passed)<br>2. Filter by instance ID<br>3. Default excludes stopped instances<br>4. IncludeAllInstances=true includes stopped (not-applicable)<br>5. Cross-account instance excluded<br>6. Unknown filter returns InvalidParameterValue<br>7. Malformed instance ID rejected<br>8. Tag filter narrows correctly | **DONE** |
|
||||
| `monitor-instances` | — | `--instance-ids` | Instance must exist | Enable basic monitoring (CPU, disk, network) → store metrics in NATS KV → return monitoring state | 1. Enable monitoring<br>2. Verify monitoring state in describe-instances | **NOT STARTED** |
|
||||
| `unmonitor-instances` | — | `--instance-ids` | Instance must exist | Disable detailed monitoring → revert to basic monitoring → return monitoring state | 1. Disable monitoring<br>2. Verify monitoring state reverts in describe-instances | **NOT STARTED** |
|
||||
|
||||
@@ -127,13 +168,13 @@ Operational commands for inspecting cluster state. These fan out NATS requests t
|
||||
|---------|-------------------|---------------|---------------|-------------|------------|--------|
|
||||
| `describe-images` | `--image-ids` (format validation only), `--owners` (self, account ID, alias), `--filters` (name, state, architecture, image-id, is-public, owner-id, description, image-type, tag:\*) | `--executable-users`, `--include-deprecated`, `--include-disabled`, `--max-results`, `--next-token`, `--dry-run` | None | NATS `ec2.DescribeImages` → daemon reads AMI metadata from Predastore S3 buckets (ami-*) → filters by ImageIds, Owners, and Filters → returns image list with state, architecture, block device mappings | 1. List all images<br>2. Filter by image ID<br>3. Filter by owner (self/amazon)<br>4. Non-existent AMI returns empty<br>5. Verify metadata fields (architecture, state, rootDeviceName)<br>6. Filter by name wildcard<br>7. Unknown filter returns InvalidParameterValue | **DONE** |
|
||||
| `create-image` | `--instance-id`, `--name`, `--description`, `--tag-specifications` | `--no-reboot`, `--block-device-mappings`, `--dry-run` | Instance must exist (running or stopped) | Gateway validates input → NATS `ec2.{instanceId}.CreateImage` (per-instance topic) → daemon extracts root volume → snapshots via `ebs.snapshot` NATS (running) or offline S3 copy (stopped) → creates AMI metadata in S3 (`{amiId}/config.json`) → stores tags → returns ami-ID. Duplicate AMI name validation enforced. | 1. Create image from running instance<br>2. Create image from stopped instance<br>3. Invalid instance ID (error)<br>4. Duplicate AMI name (error)<br>5. Verify new AMI appears in describe-images<br>6. Launch new instance from created AMI | **DONE** |
|
||||
| `register-image` | — | `--name`, `--description`, `--architecture`, `--root-device-name`, `--virtualization-type`, `--block-device-mappings`, `--boot-mode`, `--ena-support`, `--sriov-net-support`, `--tpm-support`, `--imds-support` | Image data must exist in S3 | Create AMI metadata entry in Predastore → assign ami-ID → set state to available → return ami-ID | 1. Register with valid metadata<br>2. Missing required name (error)<br>3. Verify registered image in describe-images | **NOT STARTED** |
|
||||
| `deregister-image` | — | `--image-id`, `--dry-run` | AMI must exist, no instances using it | Mark AMI metadata as deregistered in S3 → AMI no longer available for new launches → existing instances unaffected | 1. Deregister existing AMI<br>2. Deregister non-existent AMI (error)<br>3. Verify deregistered AMI not in describe-images<br>4. Existing instances from AMI still run | **NOT STARTED** |
|
||||
| `copy-image` | — | `--source-image-id`, `--source-region`, `--name`, `--description`, `--encrypted`, `--kms-key-id`, `--client-token`, `--copy-image-tags`, `--tag-specifications` | Source AMI must exist | Copy AMI data between regions/nodes in Predastore → create new AMI metadata with new ami-ID → return new ami-ID | 1. Copy image within same region<br>2. Copy non-existent image (error)<br>3. Verify copied image independent of source | **NOT STARTED** |
|
||||
| `register-image` | `--name`, `--description`, `--architecture` (x86_64/arm64/i386), `--root-device-name`, `--virtualization-type` (hvm only), `--block-device-mappings` (root with `Ebs.SnapshotId`+optional `VolumeSize`), `--tag-specifications` | `--billing-products`, `--uefi-data` | Backing snapshot must exist in Predastore (`{snapshotId}/metadata.json`); caller must own snapshot or it must be system-owned | Gateway validates name length (3–128), `snap-` prefix, architecture/virtualization values → NATS `ec2.RegisterImage` → daemon checks AMI name uniqueness, reads snapshot metadata, verifies snapshot ownership, builds `viperblock.AMIMetadata` (defaults: `Architecture=x86_64`, `Virtualization=hvm`, `PlatformDetails=Linux/UNIX`, `RootDeviceType=ebs`), writes `{amiId}/config.json`. Pointer-only — never touches block data. `BootMode`, `KernelId`, `RamdiskId`, `TpmSupport`, `ImdsSupport`, `EnaSupport`, `SriovNetSupport`, `ImageLocation`, `paravirtual` virtualization rejected with `InvalidParameterValue`. `VolumeSize` smaller than snapshot rejected. | 1. Register with valid snapshot<br>2. Missing name/snapshot (error)<br>3. Duplicate AMI name (`InvalidAMIName.Duplicate`)<br>4. Snapshot not found (`InvalidSnapshot.NotFound`)<br>5. Cross-account snapshot (`UnauthorizedOperation`)<br>6. Tags from `TagSpecifications` persisted<br>7. Verify registered image in describe-images | **DONE** |
|
||||
| `deregister-image` | `--image-id` | `--dry-run` | AMI must exist; caller must own it (system AMIs immutable via this API) | Gateway validates `ami-` prefix → NATS `ec2.DeregisterImage` → daemon hard-deletes `{amiId}/config.json` from Predastore. Backing snapshot is left intact (matches AWS — operators run `delete-snapshot` separately to reclaim block storage). Cross-account/system-AMI mutations rejected with `UnauthorizedOperation`. Re-deregister returns `InvalidAMIID.NotFound` (no tombstone). | 1. Deregister existing AMI<br>2. Deregister non-existent AMI (`InvalidAMIID.NotFound`)<br>3. Re-deregister already-deleted AMI (`InvalidAMIID.NotFound`)<br>4. Cross-account AMI (`UnauthorizedOperation`)<br>5. System AMI (`UnauthorizedOperation`)<br>6. Verify deregistered AMI not in describe-images<br>7. Backing snapshot untouched | **DONE** |
|
||||
| `copy-image` | `--source-image-id`, `--source-region` (must equal gateway region), `--name`, `--description`, `--client-token` (accepted, not honoured), `--copy-image-tags`, `--tag-specifications` (image only) | `--encrypted`, `--kms-key-id`, `--destination-outpost-arn`, `--dry-run` | Source AMI must exist and be owned by caller OR be a system AMI; backing snapshot must still exist | Gateway validates `ami-` prefix, name length (3–128), same-region — cross-region / `Encrypted` / `KmsKeyId` / `DestinationOutpostArn` rejected with `InvalidParameterValue`. NATS `ec2.CopyImage` → daemon checks name uniqueness, reads source `ami-xxx/config.json`, visibility-filters cross-account sources as `InvalidAMIID.NotFound`, reads source `{snap-xxx}/metadata.json` (missing or empty SnapshotID → `InvalidAMIID.NotFound`), writes new `snap-yyy/metadata.json` that inherits source `VolumeID` (no block copy), writes new `ami-yyy/config.json` owned by caller. `Description` inherits from source when unset. Tag merge: `CopyImageTags=true` seeds tags from source, explicit image-resource `TagSpecifications` override colliding keys and append new ones; non-image tag specs ignored. | 1. Same-region copy produces new `ami-`/`snap-` pair with distinct IDs<br>2. New snap shares source `VolumeID` (metadata-only)<br>3. New AMI owned by caller, source untouched<br>4. System AMI copied into caller's account<br>5. Cross-account source (`InvalidAMIID.NotFound`)<br>6. Missing source AMI (`InvalidAMIID.NotFound`)<br>7. Orphaned source (missing/empty `SnapshotID`) → `InvalidAMIID.NotFound`<br>8. Duplicate name (`InvalidAMIName.Duplicate`)<br>9. Cross-region / `Encrypted` / `KmsKeyId` rejected (`InvalidParameterValue`)<br>10. `CopyImageTags` true/false tag inheritance semantics | **DONE** |
|
||||
| `import-image` | — | `--disk-containers` (Format, Url/S3Bucket+S3Key), `--description`, `--architecture`, `--platform` | S3 bucket with disk image | Download disk image from S3 → convert format (VMDK/VHD/RAW→QCOW2) → create viperblock volume → register as AMI | 1. Import QCOW2 image<br>2. Import RAW image<br>3. Invalid format (error)<br>4. Verify imported image launchable | **NOT STARTED** |
|
||||
| `describe-image-attribute` | — | `--image-id`, `--attribute`, `--dry-run` | AMI must exist | Read specific attribute from AMI metadata in S3 → return attribute value | 1. Get description attribute<br>2. Get launch permission<br>3. Invalid attribute name (error) | **NOT STARTED** |
|
||||
| `modify-image-attribute` | — | `--image-id`, `--launch-permission`, `--description`, `--operation-type`, `--user-ids`, `--user-groups`, `--organization-arns`, `--dry-run` | AMI must exist | Update specific attribute in AMI metadata → persist to S3 | 1. Add launch permission<br>2. Modify description<br>3. Invalid AMI (error) | **NOT STARTED** |
|
||||
| `reset-image-attribute` | — | `--image-id`, `--attribute`, `--dry-run` | AMI must exist | Reset attribute to default value in AMI metadata | 1. Reset launch permission to owner-only<br>2. Invalid AMI (error) | **NOT STARTED** |
|
||||
| `describe-image-attribute` | `--image-id`, `--attribute` (`description`, `blockDeviceMapping` only) | `--dry-run`, `--attribute` for `launchPermission`/`bootMode`/`kernel`/`ramdisk`/`sriovNetSupport`/`productCodes`/`tpmSupport`/`uefiData`/`imdsSupport`/`lastLaunchedTime`/`deregistrationProtection` | AMI must exist and be owned by caller OR be a system AMI | Gateway validates `ami-` prefix + allowlisted attribute → NATS `ec2.DescribeImageAttribute` → daemon reads `ami-xxx/config.json`, hides cross-account reads as `InvalidAMIID.NotFound` (matches `DescribeImages`), returns `description` from stored metadata or synthesises a single root `blockDeviceMapping` from `AMIMetadata` (same shape as `DescribeImages`). Unsupported attributes rejected with `InvalidParameterValue` — notably `launchPermission` is deferred (breaks Terraform's `aws_ami` refresh; multi-account AMI sharing needs a separate design). | 1. Read `description`<br>2. Read synthesised `blockDeviceMapping`<br>3. AMI not found (`InvalidAMIID.NotFound`)<br>4. Cross-account AMI hidden (`InvalidAMIID.NotFound`)<br>5. System AMI readable by any caller<br>6. Unsupported attributes (`launchPermission`, `bootMode`, …) rejected with `InvalidParameterValue` | **DONE** |
|
||||
| `modify-image-attribute` | `--image-id`, `--description` (top-level `Value=…` form), `--attribute description --value …` (structured form) | `--launch-permission`, `--imds-support`, `--operation-type`, `--user-ids`, `--user-groups`, `--organization-arns`, `--product-codes`, `--dry-run`, `--attribute` for anything other than `description` | AMI must exist and be owned by caller (system AMIs immutable via this API) | Gateway validates `ami-` prefix, rejects `LaunchPermission`/`ImdsSupport`/`ProductCodes`/org+user-id flags with `InvalidParameterValue`, and normalises the overloaded input shape (top-level `Description` → `Attribute=description`+`Value=…`). Both forms set together → `InvalidParameterCombination`; neither → `MissingParameter`. NATS `ec2.ModifyImageAttribute` → daemon loads AMI, runs ownership check (cross-account → `UnauthorizedOperation`, system AMIs also rejected), writes `AMIMetadata.Description` back to S3. Empty `Value` clears the description. | 1. Modify description (top-level form)<br>2. Modify description (structured form)<br>3. Clear description with empty value<br>4. Both top-level + structured set (`InvalidParameterCombination`)<br>5. Cross-account / system AMI (`UnauthorizedOperation`)<br>6. AMI not found (`InvalidAMIID.NotFound`)<br>7. `LaunchPermission` / `ImdsSupport` / unsupported `Attribute` rejected (`InvalidParameterValue`)<br>8. Round-trips via `describe-image-attribute` | **DONE** |
|
||||
| `reset-image-attribute` | `--image-id`, `--attribute description` | `--attribute launchPermission`, `--dry-run` | AMI must exist and be owned by caller | Gateway validates `ami-` prefix; only `description` is accepted (`launchPermission` — AWS's default reset target — is out of scope). NATS `ec2.ResetImageAttribute` → daemon loads AMI, ownership check, clears `AMIMetadata.Description` to empty string, persists. | 1. Reset description to empty<br>2. Cross-account / system AMI (`UnauthorizedOperation`)<br>3. AMI not found (`InvalidAMIID.NotFound`)<br>4. `launchPermission` / other attributes rejected (`InvalidParameterValue`)<br>5. Round-trips via `describe-image-attribute` | **DONE** |
|
||||
|
||||
### EC2 - Volume (EBS) Management
|
||||
|
||||
@@ -305,6 +346,7 @@ Requires OVN/OVS (`apt install openvswitch-switch ovn-host`). VPC/Subnet/ENI/SG
|
||||
| `authorize-security-group-egress` | `--group-id`, `--ip-permissions` | `--dry-run` | SG must exist | Gateway validates GroupId → NATS `ec2.AuthorizeSecurityGroupEgress` → daemon retrieves SG → appends rules to EgressRules → updates KV with optimistic locking → publishes `vpc.update-sg` event → returns true | 1. Allow HTTPS outbound<br>2. Restrict to specific CIDR<br>3. Missing GroupId (MissingParameter)<br>4. Non-existent SG (InvalidGroup.NotFound) | **DONE** |
|
||||
| `revoke-security-group-ingress` | `--group-id`, `--ip-permissions` (matching rules to remove) | `--dry-run` | SG must exist | Gateway validates GroupId → NATS `ec2.RevokeSecurityGroupIngress` → daemon retrieves SG → removes matching rules from IngressRules (set difference) → updates KV with optimistic locking → publishes `vpc.update-sg` event → returns true | 1. Revoke existing rule<br>2. Missing GroupId (MissingParameter)<br>3. Non-existent SG (InvalidGroup.NotFound) | **DONE** |
|
||||
| `revoke-security-group-egress` | `--group-id`, `--ip-permissions` (matching rules to remove) | `--dry-run` | SG must exist | Gateway validates GroupId → NATS `ec2.RevokeSecurityGroupEgress` → daemon retrieves SG → removes matching rules from EgressRules (set difference) → updates KV with optimistic locking → publishes `vpc.update-sg` event → returns true | 1. Revoke existing rule<br>2. Missing GroupId (MissingParameter)<br>3. Non-existent SG (InvalidGroup.NotFound) | **DONE** |
|
||||
| `describe-security-group-rules` | `--filters` (group-id, security-group-rule-id, tag:\*, tag-key), `--security-group-rule-ids` | `--max-results`, `--next-token`, `--dry-run` | None | Gateway validates `sgr-` format on SecurityGroupRuleIds (nil input = all rules in account) → NATS `ec2.DescribeSecurityGroupRules` → daemon iterates SG records in `spinifex-vpc-security-groups` KV under account → applies filters → emits one `SecurityGroupRule` per ingress (IsEgress=false) and egress (IsEgress=true) rule, each with stable `sgr-` ID, GroupOwnerId=accountID, ReferencedGroupInfo for SG-source rules → returns `InvalidSecurityGroupRuleId.NotFound` if any requested ID missing → v1→v2 KV migration backfills `sgr-` IDs on existing rules at daemon startup; MaxResults/NextToken accepted but ignored | 1. List all rules in account<br>2. Filter by group-id<br>3. Filter by security-group-rule-id<br>4. Non-existent rule ID (InvalidSecurityGroupRuleId.NotFound)<br>5. Malformed sgr- ID at gateway (InvalidParameterValue)<br>6. Tag filters match zero (Tags not yet populated on rules)<br>7. Unknown filter (InvalidParameterValue)<br>8. Migration backfills sgr- IDs on pre-v2 records | **DONE** |
|
||||
| `create-internet-gateway` | `--tag-specifications` | `--dry-run` | None | NATS `ec2.CreateInternetGateway` → daemon generates igw-ID → creates IGWRecord in `spinifex-igw` KV bucket with state=available (not attached) → returns InternetGateway with empty Attachments | 1. Create IGW<br>2. Verify in describe-internet-gateways<br>3. Tags applied at creation | **DONE** |
|
||||
| `attach-internet-gateway` | `--internet-gateway-id`, `--vpc-id` | `--dry-run` | IGW and VPC must exist | NATS `ec2.AttachInternetGateway` → daemon validates IGW exists and is not already attached (ResourceAlreadyAssociated) → sets VpcId on record → state=attached → publishes `vpc.igw-attach` event to vpcd (creates OVN external switch, gateway port, SNAT rules) | 1. Attach IGW to VPC<br>2. Attach already-attached IGW (ResourceAlreadyAssociated)<br>3. Non-existent IGW (error)<br>4. Missing params (MissingParameter) | **DONE** |
|
||||
| `detach-internet-gateway` | `--internet-gateway-id`, `--vpc-id` | `--dry-run` | IGW must be attached to specified VPC | NATS `ec2.DetachInternetGateway` → daemon validates IGW exists and is attached to specified VPC (GatewayNotAttached if wrong VPC) → clears VpcId → state=available → publishes `vpc.igw-detach` event to vpcd (removes OVN resources) | 1. Detach attached IGW<br>2. Detach from wrong VPC (GatewayNotAttached)<br>3. Detach unattached IGW (error) | **DONE** |
|
||||
|
||||
@@ -12,127 +12,121 @@ Spinifex is an AWS-compatible infrastructure platform for bare-metal, edge, and
|
||||
|
||||
### 1. AWS SDK Request
|
||||
|
||||
Users interact with Spinifex using standard AWS SDKs or the AWS CLI by pointing to a custom endpoint:
|
||||
Users interact with Spinifex using standard AWS SDKs or the AWS CLI by using the spinifex profile.
|
||||
|
||||
```bash
|
||||
aws --endpoint-url https://localhost:9999 --no-verify-ssl ec2 run-instances \
|
||||
AWS_PROFILE=spinifex aws ec2 run-instances \
|
||||
--image-id ami-debian12 \
|
||||
--instance-type t3.micro \
|
||||
--key-name my-keypair
|
||||
```
|
||||
|
||||
The AWS SDK formats this as an HTTP POST with:
|
||||
The AWS SDK formats this as an HTTPS POST with:
|
||||
- AWS SigV4 authentication headers
|
||||
- EC2 Query Protocol body (Action=RunInstances&ImageId=ami-debian12...)
|
||||
- EC2 Query Protocol body (`Action=RunInstances&ImageId=ami-debian12&...`)
|
||||
|
||||
### 2. AWS Gateway
|
||||
|
||||
The gateway (`spinifex/services/awsgw/awsgw.go:57-107`) is the entry point:
|
||||
The gateway (`spinifex/services/awsgw/awsgw.go`) is the entry point:
|
||||
|
||||
```go
|
||||
// awsgw.go:74 - Connect to NATS
|
||||
natsConn, err := nats.Connect(nodeConfig.NATS.Host, opts...)
|
||||
// Connect to NATS (retries while the local broker comes up)
|
||||
natsConn, err := utils.ConnectNATSWithRetry(...)
|
||||
|
||||
// awsgw.go:89-94 - Create gateway with NATS connection
|
||||
// Load IAM (master key + JetStream KV) and create the gateway
|
||||
gw := gateway.GatewayConfig{
|
||||
NATSConn: natsConn,
|
||||
Config: nodeConfig.AWSGW.Config,
|
||||
NATSConn: natsConn,
|
||||
Config: nodeConfig.AWSGW.Config,
|
||||
IAMService: iamService,
|
||||
// ...
|
||||
}
|
||||
|
||||
// awsgw.go:103 - Start TLS listener
|
||||
log.Fatal(app.ListenTLS(nodeConfig.AWSGW.Host, nodeConfig.AWSGW.TLSCert, nodeConfig.AWSGW.TLSKey))
|
||||
// Serve over TLS
|
||||
server.ListenAndServeTLS("", "")
|
||||
```
|
||||
|
||||
Request routing (`spinifex/gateway/gateway.go:125-156`):
|
||||
Request routing (`spinifex/gateway/gateway.go`):
|
||||
|
||||
1. **Authentication**: SigV4 middleware validates AWS credentials (`gateway.go:104`)
|
||||
2. **Service Detection**: Extracts service name (ec2, iam, account) from Authorization header (`gateway.go:158-186`)
|
||||
3. **Action Dispatch**: Routes to service-specific handler (`gateway.go:136-145`)
|
||||
1. **Authentication**: SigV4 middleware validates AWS credentials and resolves the account ID
|
||||
2. **Throttling**: Per-account+action token bucket rejects bursts post-auth
|
||||
3. **Service Detection**: Extracts service name (ec2, iam, account, elasticloadbalancing, spinifex) from the Authorization header
|
||||
4. **Action Dispatch**: Routes to service-specific handler
|
||||
|
||||
```go
|
||||
// gateway.go:136-145
|
||||
switch svc {
|
||||
case "ec2":
|
||||
err = gw.EC2_Request(ctx)
|
||||
err = gw.EC2_Request(w, r)
|
||||
case "account":
|
||||
err = gw.Account_Request(ctx)
|
||||
err = gw.Account_Request(w, r)
|
||||
case "iam":
|
||||
err = gw.IAM_Request(ctx)
|
||||
err = gw.IAM_Request(w, r)
|
||||
case "elasticloadbalancing":
|
||||
err = gw.ELBv2_Request(w, r)
|
||||
case "spinifex":
|
||||
err = gw.Spinifex_Request(w, r)
|
||||
}
|
||||
```
|
||||
|
||||
### 3. EC2 Handler
|
||||
|
||||
The EC2 handler (`spinifex/gateway/ec2.go`) parses the Action parameter and delegates to specific handlers:
|
||||
The EC2 handler (`spinifex/gateway/ec2.go`) parses the `Action` parameter and delegates to specific handlers via a generic `ec2Handler` wrapper. The account ID resolved by SigV4 auth is threaded into every handler so requests are scoped per IAM principal:
|
||||
|
||||
```go
|
||||
// ec2.go - Action routing
|
||||
switch action {
|
||||
case "RunInstances":
|
||||
output, err := gateway_ec2_instance.RunInstances(input, gw.NATSConn)
|
||||
case "DescribeInstances":
|
||||
output, err := gateway_ec2_instance.DescribeInstances(input, gw.NATSConn, expectedNodes)
|
||||
// ... more actions
|
||||
}
|
||||
"RunInstances": ec2Handler(func(input *ec2.RunInstancesInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_ec2_instance.RunInstances(input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DescribeInstances": ec2Handler(func(input *ec2.DescribeInstancesInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_ec2_instance.DescribeInstances(input, gw.NATSConn, gw.DiscoverActiveNodes(), accountID)
|
||||
}),
|
||||
// ... + volumes, snapshots, VPCs, subnets, route tables, IGWs, NAT gateways,
|
||||
// security groups, network interfaces, placement groups, key pairs, images, tags
|
||||
```
|
||||
|
||||
### 4. NATS Messaging
|
||||
|
||||
The gateway communicates with daemons via NATS request/response (`spinifex/handlers/ec2/instance/service_nats.go:24-57`):
|
||||
The gateway communicates with daemons via NATS request/response. Most calls go through `utils.NATSRequest`, which marshals the input, attaches the account ID as a NATS header, and unmarshals the typed response:
|
||||
|
||||
```go
|
||||
// service_nats.go:24-57
|
||||
func (s *NATSInstanceService) RunInstances(input *ec2.RunInstancesInput) (*ec2.Reservation, error) {
|
||||
// Marshal input to JSON
|
||||
jsonData, err := json.Marshal(input)
|
||||
|
||||
// Send request to daemon via NATS with 5 minute timeout
|
||||
msg, err := s.natsConn.Request("ec2.RunInstances", jsonData, 5*time.Minute)
|
||||
|
||||
// Unmarshal response
|
||||
var reservation ec2.Reservation
|
||||
err = json.Unmarshal(msg.Data, &reservation)
|
||||
|
||||
return &reservation, nil
|
||||
func (s *NATSInstanceService) RunInstances(input *ec2.RunInstancesInput, accountID string) (*ec2.Reservation, error) {
|
||||
topic := fmt.Sprintf("ec2.RunInstances.%s", aws.StringValue(input.InstanceType))
|
||||
return utils.NATSRequest[ec2.Reservation](s.natsConn, topic, input, 5*time.Minute, accountID)
|
||||
}
|
||||
```
|
||||
|
||||
`RunInstances` uses a per-instance-type subject so NATS only delivers the request to a node with spare capacity for that type — no application-level reject-and-retry.
|
||||
|
||||
### 5. Daemon Processing
|
||||
|
||||
Daemons (`spinifex/daemon/daemon.go`) subscribe to NATS topics and handle requests:
|
||||
Daemons (`spinifex/daemon/daemon.go`) subscribe to NATS topics and handle requests. A table-driven `subscribeAll()` registers the static EC2/ELBv2 surface at startup:
|
||||
|
||||
```go
|
||||
// daemon.go:319 - Subscribe with queue group for load balancing
|
||||
d.natsSubscriptions["ec2.RunInstances"], err = d.natsConn.QueueSubscribe(
|
||||
"ec2.RunInstances",
|
||||
"spinifex-workers", // Queue group - only one daemon handles each request
|
||||
d.handleEC2RunInstances,
|
||||
)
|
||||
// Queue group → load-balanced (one daemon handles each request)
|
||||
{"ec2.CreateKeyPair", d.handleEC2CreateKeyPair, "spinifex-workers"},
|
||||
|
||||
// daemon.go:373 - Subscribe without queue group for fan-out
|
||||
d.natsSubscriptions["ec2.DescribeInstances"], err = d.natsConn.Subscribe(
|
||||
"ec2.DescribeInstances",
|
||||
d.handleEC2DescribeInstances, // All daemons respond
|
||||
)
|
||||
// No queue group → fan-out (every daemon responds)
|
||||
{"ec2.DescribeInstanceTypes", d.handleEC2DescribeInstanceTypes, ""},
|
||||
```
|
||||
|
||||
**Queue Groups**: Topics subscribed with a queue group (`spinifex-workers`) are load-balanced - only one daemon handles each request. Topics without a queue group fan out to all daemons.
|
||||
`ec2.RunInstances` is dynamic: `ResourceManager` subscribes to `ec2.RunInstances.{type}` (and `ec2.RunInstances.{type}.{nodeId}` for targeted dispatch) only while the node has capacity for that type, and unsubscribes when full. Per-instance commands flow over `ec2.cmd.{instanceID}`, subscribed only by the owning node.
|
||||
|
||||
**Queue Groups**: topics with the `spinifex-workers` queue group are load-balanced — only one daemon handles each request. Topics without a queue group fan out to all daemons.
|
||||
|
||||
### 6. VM Launch
|
||||
|
||||
When a daemon handles `RunInstances`:
|
||||
|
||||
1. **Resource Check**: Validates CPU/memory availability (`daemon.go:66-74`)
|
||||
1. **Resource Check**: Validates CPU/memory availability via `ResourceManager`
|
||||
2. **Volume Generation**: Creates boot, cloud-init, and EFI volumes via Viperblock
|
||||
3. **Volume Mount**: Sends `ebs.mount` request to Viperblock, receives NBD URI
|
||||
4. **QEMU Launch**: Builds and executes QEMU command with NBD-backed disks
|
||||
5. **QMP Monitoring**: Establishes QEMU Machine Protocol connection for VM management
|
||||
6. **Command subscription**: Subscribes to `ec2.cmd.{instanceID}` for subsequent commands on this VM
|
||||
|
||||
```go
|
||||
// daemon.go - Volume mounting via NATS
|
||||
// Volume mounting via NATS
|
||||
msg, err := d.natsConn.Request("ebs.mount", ebsMountRequest, 10*time.Second)
|
||||
|
||||
// daemon.go - QEMU launch with NBD storage
|
||||
// QEMU launch with NBD storage
|
||||
cmd := exec.Command("qemu-system-x86_64",
|
||||
"-drive", fmt.Sprintf("file=nbd:%s,format=raw", nbdURI),
|
||||
// ... additional QEMU args
|
||||
@@ -144,66 +138,56 @@ cmd := exec.Command("qemu-system-x86_64",
|
||||
### Daemon Structure
|
||||
|
||||
```go
|
||||
// daemon.go:76-102
|
||||
type Daemon struct {
|
||||
node string // Node identifier
|
||||
clusterConfig *config.ClusterConfig // Cluster-wide configuration
|
||||
config *config.Config // Node-specific configuration
|
||||
natsConn *nats.Conn // NATS connection
|
||||
resourceMgr *ResourceManager // CPU/Memory tracking
|
||||
instanceService *InstanceServiceImpl // EC2 instance operations
|
||||
keyService *KeyServiceImpl // SSH key operations
|
||||
imageService *ImageServiceImpl // AMI operations
|
||||
Instances vm.Instances // Local VMs
|
||||
clusterApp *fiber.App // HTTP cluster manager
|
||||
node string // Node identifier
|
||||
clusterConfig *config.ClusterConfig // Cluster-wide configuration
|
||||
config *config.Config // Node-specific configuration
|
||||
natsConn *nats.Conn // NATS connection
|
||||
resourceMgr *ResourceManager // CPU/Memory tracking + dynamic RunInstances subscriptions
|
||||
Instances vm.Instances // Local VMs
|
||||
// ... + one service struct per EC2/ELBv2 resource
|
||||
// (instance, key, image, volume, snapshot, tags, vpc, subnet,
|
||||
// igw, eigw, natgw, routetable, eip, placementgroup, elbv2, ...)
|
||||
}
|
||||
```
|
||||
|
||||
### Resource Manager
|
||||
|
||||
Tracks available and allocated CPU/memory to prevent overcommit:
|
||||
Tracks available and allocated CPU/memory to prevent overcommit, and drives the dynamic `ec2.RunInstances.{type}` subscriptions:
|
||||
|
||||
```go
|
||||
// daemon.go:66-74
|
||||
type ResourceManager struct {
|
||||
mu sync.RWMutex
|
||||
availableVCPU int
|
||||
availableMem float64
|
||||
hostVCPU int // raw runtime.NumCPU
|
||||
hostMemGB float64 // raw /proc/meminfo MemTotal
|
||||
reservedVCPU int // held back for spinifex services
|
||||
reservedMem float64 // held back for spinifex services
|
||||
allocatedVCPU int
|
||||
allocatedMem float64
|
||||
instanceTypes map[string]InstanceType // t3.micro, t3.small, etc.
|
||||
instanceTypes map[string]*ec2.InstanceTypeInfo // t3.micro, t3.small, etc.
|
||||
}
|
||||
```
|
||||
|
||||
### NATS Topics
|
||||
|
||||
| Topic | Queue Group | Purpose |
|
||||
|-------|-------------|---------|
|
||||
| `ec2.RunInstances` | `spinifex-workers` | Launch new instances |
|
||||
| `ec2.DescribeInstances` | None (fan-out) | Query all nodes for instances |
|
||||
| `ec2.cmd.{instanceID}` | None (owner only) | Per-instance commands (start/stop/terminate, attach/detach volume) |
|
||||
| `ec2.CreateKeyPair` | `spinifex-workers` | Generate SSH keypair |
|
||||
| `ec2.DescribeKeyPairs` | `spinifex-workers` | List SSH keypairs |
|
||||
| `ec2.DescribeImages` | `spinifex-workers` | List AMIs |
|
||||
| `ebs.mount` | - | Mount volume via Viperblock |
|
||||
| `ebs.unmount` | - | Unmount volume |
|
||||
| `spinifex.admin.{node}.health` | None | Node health checks |
|
||||
|
||||
### Multi-Node Aggregation
|
||||
|
||||
For operations that need data from all nodes (like `DescribeInstances`), the gateway uses inbox-based fan-out (`spinifex/gateway/ec2/instance/DescribeInstances.go`):
|
||||
|
||||
```go
|
||||
// DescribeInstances.go:16-111
|
||||
func DescribeInstances(...) {
|
||||
// Create unique inbox for collecting responses
|
||||
inbox := nats.NewInbox()
|
||||
sub, err := natsConn.SubscribeSync(inbox)
|
||||
sub, _ := natsConn.SubscribeSync(inbox)
|
||||
|
||||
// Publish to all nodes (no queue group = all daemons respond)
|
||||
err = natsConn.PublishRequest("ec2.DescribeInstances", inbox, jsonData)
|
||||
// Publish to all nodes (no queue group = all daemons respond).
|
||||
// Account ID is propagated as a NATS header so each daemon scopes its response.
|
||||
pubMsg := nats.NewMsg("ec2.DescribeInstances")
|
||||
pubMsg.Reply = inbox
|
||||
pubMsg.Data = jsonData
|
||||
pubMsg.Header.Set(utils.AccountIDHeader, accountID)
|
||||
_ = natsConn.PublishMsg(pubMsg)
|
||||
|
||||
// Collect responses from all nodes with 3-second timeout
|
||||
// Collect responses, returning early once expectedNodes have replied
|
||||
// or the 3s timeout is hit.
|
||||
for {
|
||||
msg, err := sub.NextMsg(3 * time.Second)
|
||||
allReservations = append(allReservations, nodeOutput.Reservations...)
|
||||
@@ -254,74 +238,72 @@ type ClusterConfig struct {
|
||||
|
||||
type Config struct {
|
||||
Node, Host, Region, AZ string
|
||||
DataDir string
|
||||
Daemon DaemonConfig // Cluster manager port
|
||||
NATS NATSConfig // NATS broker address
|
||||
Predastore PredastoreConfig // S3 endpoint
|
||||
AWSGW AWSGWConfig // Gateway port, TLS certs
|
||||
AccessKey, SecretKey string
|
||||
BaseDir, DataDir string
|
||||
Daemon DaemonConfig
|
||||
NATS NATSConfig // Broker address, JetStream, ACL token, CA cert
|
||||
AWSGW AWSGWConfig // Gateway host, TLS certs, throttle config
|
||||
Predastore PredastoreConfig // S3 endpoint + service credentials
|
||||
Viperblock ViperblockConfig
|
||||
VPCD VPCDConfig // OVN/OVS endpoints
|
||||
Network NetworkConfig
|
||||
}
|
||||
```
|
||||
|
||||
Configs are generated by `spx admin init` (leader) or `spx admin join` (followers).
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
spinifex/
|
||||
├── spinifex/
|
||||
│ ├── services/
|
||||
│ │ └── awsgw/
|
||||
│ │ └── awsgw.go # AWS Gateway service entry point
|
||||
│ ├── gateway/
|
||||
│ │ ├── gateway.go # Request routing, auth middleware
|
||||
│ │ ├── ec2.go # EC2 action dispatcher
|
||||
│ │ └── ec2/
|
||||
│ │ └── instance/
|
||||
│ │ ├── RunInstances.go
|
||||
│ │ └── DescribeInstances.go
|
||||
│ ├── handlers/
|
||||
│ │ └── ec2/
|
||||
│ │ ├── instance/
|
||||
│ │ │ ├── service_nats.go # NATS request/response
|
||||
│ │ │ └── service_impl.go # Business logic
|
||||
│ │ ├── key/
|
||||
│ │ └── image/
|
||||
│ ├── services/ # Long-running services (awsgw, spinifex, viperblockd,
|
||||
│ │ # predastore, nats, vpcd, spinifexui)
|
||||
│ ├── gateway/ # AWS API surface
|
||||
│ │ ├── gateway.go # chi router, SigV4 auth, throttling
|
||||
│ │ ├── auth.go # SigV4 verification + IAM lookup
|
||||
│ │ ├── ec2.go # EC2 action dispatcher
|
||||
│ │ ├── ec2/ # Per-resource handlers (instance, volume, vpc, ...)
|
||||
│ │ ├── elbv2/, iam/, policy/
|
||||
│ ├── handlers/ # NATS-side service implementations (ec2, elbv2, iam)
|
||||
│ ├── daemon/
|
||||
│ │ └── daemon.go # Daemon entry point, NATS subscriptions
|
||||
│ ├── config/
|
||||
│ │ └── config.go # Configuration structures
|
||||
│ ├── vm/
|
||||
│ │ └── instance.go # VM instance representation
|
||||
│ └── qmp/
|
||||
│ └── qmp.go # QEMU Machine Protocol client
|
||||
│ │ ├── daemon.go # Daemon entry, NATS subscription table
|
||||
│ │ └── daemon_handlers_*.go # One file per resource family
|
||||
│ ├── admin/ # Cluster bootstrap (init/join, master.key, CA, tokens)
|
||||
│ ├── config/ # Configuration structures
|
||||
│ ├── instancetypes/ # t3.*, m5.*, sys.* definitions
|
||||
│ ├── lbagent/ # In-VM agent for ELB target health
|
||||
│ ├── vm/ # VM instance representation
|
||||
│ └── qmp/ # QEMU Machine Protocol client
|
||||
└── cmd/
|
||||
└── spinifex/
|
||||
└── main.go # CLI entry point
|
||||
├── spinifex/ # spx CLI
|
||||
├── lb-agent/ # ELB target agent binary
|
||||
└── installer/ # Platform installer
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
### Running the Stack
|
||||
|
||||
1. Start NATS broker
|
||||
2. Start Predastore (S3)
|
||||
3. Start Viperblock (EBS)
|
||||
4. Start Spinifex Gateway (`spx awsgw`)
|
||||
5. Start Spinifex Daemon(s) (`spx daemon`)
|
||||
```bash
|
||||
sudo spx admin init --node node1 --nodes 1
|
||||
sudo systemctl start spinifex.target
|
||||
```
|
||||
|
||||
`spx admin init` generates the cluster CA, IAM credentials, TLS certs, and AWS CLI `spinifex` profile, then writes config to `/etc/spinifex/` and data to `/var/lib/spinifex/`. `spinifex.target` brings up all services (NATS, Predastore, Viperblock, vpcd, the daemon, awsgw, the UI) under systemd. Use `spx admin join` for additional nodes.
|
||||
|
||||
### Testing with AWS CLI
|
||||
|
||||
```bash
|
||||
# Set endpoint
|
||||
export AWS_ENDPOINT_URL=https://localhost:9999
|
||||
export AWS_PROFILE=spinifex
|
||||
|
||||
# Run instance
|
||||
aws --no-verify-ssl ec2 run-instances \
|
||||
--image-id ami-debian12 \
|
||||
--instance-type t3.micro
|
||||
aws ec2 run-instances --image-id ami-debian12 --instance-type t3.micro
|
||||
|
||||
# List instances (queries all nodes)
|
||||
aws --no-verify-ssl ec2 describe-instances
|
||||
aws ec2 describe-instances
|
||||
|
||||
# Terminate instance
|
||||
aws --no-verify-ssl ec2 terminate-instances --instance-ids i-xxxxx
|
||||
aws ec2 terminate-instances --instance-ids i-xxxxx
|
||||
```
|
||||
|
||||
For cluster-state inspection that bypasses the AWS API surface, use the `spx` CLI directly: `spx get nodes`, `spx get vms`, `spx top nodes`.
|
||||
|
||||
@@ -35,13 +35,25 @@ All services in the Spinifex platform are managed through this single binary.
|
||||
|
||||
## Account Management
|
||||
|
||||
Create a new isolated account with an admin user and AdministratorAccess policy:
|
||||
|
||||
```bash
|
||||
spx admin account create --name myteam
|
||||
export AWS_PROFILE=spinifex-myteam
|
||||
```
|
||||
|
||||
List all accounts:
|
||||
|
||||
```bash
|
||||
spx admin account list
|
||||
```
|
||||
|
||||
|
||||
|
||||
## Node Management
|
||||
|
||||
List nodes in the cluster:
|
||||
|
||||
```bash
|
||||
spx get nodes
|
||||
```
|
||||
@@ -59,15 +71,22 @@ node3 Ready 127.0.0.3 ap-southeast-2 ap-southeast-2a 2m 0
|
||||
spx top nodes
|
||||
```
|
||||
|
||||
Prints per-node CPU/memory usage and cluster-wide instance type availability.
|
||||
|
||||
## Image Management
|
||||
|
||||
```bash
|
||||
spx admin images list
|
||||
spx admin images import --name debian-12-arm64
|
||||
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.
|
||||
|
||||
|
||||
## Cluster Shutdown
|
||||
|
||||
Coordinated, phased shutdown of the entire cluster (API/UI → VMs → viperblock → predastore → NATS/daemon):
|
||||
|
||||
```bash
|
||||
spx admin cluster shutdown
|
||||
```
|
||||
@@ -86,11 +105,12 @@ If you get permission errors during operations, ensure you're running with appro
|
||||
|
||||
### Services Fail to Start
|
||||
|
||||
Check the daemon logs for specific errors:
|
||||
Check the daemon logs for specific errors via `systemctl`/`journalctl`:
|
||||
|
||||
```bash
|
||||
ls ~/spinifex/logs/
|
||||
cat ~/spinifex/logs/spinifex.log
|
||||
systemctl status 'spinifex-*'
|
||||
journalctl -u spinifex-daemon -f
|
||||
journalctl -u 'spinifex-*' -f
|
||||
```
|
||||
|
||||
Common causes include port conflicts, missing OVN configuration, or untrusted CA certificates.
|
||||
@@ -0,0 +1,223 @@
|
||||
---
|
||||
title: "GPU Passthrough"
|
||||
description: "Configure VFIO GPU passthrough on a Spinifex node to expose NVIDIA or AMD GPUs to EC2 instances."
|
||||
category: "Compute"
|
||||
tags:
|
||||
- gpu
|
||||
- passthrough
|
||||
- vfio
|
||||
- ec2
|
||||
resources:
|
||||
- title: "Spinifex Repository"
|
||||
url: "https://github.com/mulgadc/spinifex"
|
||||
- title: "AWS EC2 Accelerated Computing Instance Types"
|
||||
url: "https://docs.aws.amazon.com/ec2/latest/instancetypes/ac.html"
|
||||
---
|
||||
|
||||
# GPU Passthrough
|
||||
|
||||
> Configure VFIO GPU passthrough on a Spinifex node to expose NVIDIA or AMD GPUs to EC2 instances.
|
||||
|
||||
> **Note:** GPU passthrough is supported on x86_64 hosts only.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [Host Setup](#host-setup)
|
||||
- [Enable Passthrough](#enable-passthrough)
|
||||
- [Status and Monitoring](#status-and-monitoring)
|
||||
- [GPU Instance Types](#gpu-instance-types)
|
||||
- [Launching GPU Instances](#launching-gpu-instances)
|
||||
- [Disable Passthrough](#disable-passthrough)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Spinifex supports VFIO-based GPU passthrough, binding NVIDIA and AMD GPUs directly to guest VMs via the `vfio-pci` kernel driver. Once configured, the node exposes GPU-enabled EC2 instance types and the GPU is allocated exclusively to individual instances.
|
||||
|
||||
At startup, Spinifex always probes for GPU hardware and surfaces the result in the node banner and in `spx top nodes` — regardless of whether passthrough is enabled. This lets operators verify hardware before activating the feature.
|
||||
|
||||
The passthrough state is reflected in the login banner:
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- IOMMU must be supported and enabled in the host BIOS/UEFI (`Intel VT-d` or `AMD-Vi`).
|
||||
- An NVIDIA or AMD GPU must be physically installed.
|
||||
- The Spinifex node must be running on bare metal (not inside a VM).
|
||||
|
||||
---
|
||||
|
||||
## Host Setup
|
||||
|
||||
Run the one-time host configuration as root. This command is idempotent — safe to re-run after a reboot.
|
||||
|
||||
```bash
|
||||
sudo spx admin gpu setup
|
||||
```
|
||||
|
||||
What it does:
|
||||
|
||||
1. Detects installed GPUs and their PCI IDs.
|
||||
2. Enables IOMMU in GRUB (`intel_iommu=on iommu=pt` or `amd_iommu=on iommu=pt`).
|
||||
3. Writes the vfio udev rule (`/etc/udev/rules.d/99-spinifex-vfio.rules`).
|
||||
4. Blacklists `nouveau` (NVIDIA) and `amdgpu` (AMD) so the host kernel does not claim the device.
|
||||
5. Configures `vfio-pci` early binding in `/etc/modprobe.d/vfio-pci.conf`.
|
||||
6. Adds vfio modules to initramfs.
|
||||
|
||||
If any change requires a reboot, the command prints instructions and exits:
|
||||
|
||||
```
|
||||
Setup complete — reboot required.
|
||||
sudo reboot
|
||||
Then run: sudo spx admin gpu enable
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Enable Passthrough
|
||||
|
||||
After setup and reboot, enable GPU passthrough:
|
||||
|
||||
```bash
|
||||
sudo spx admin gpu enable
|
||||
```
|
||||
|
||||
The command writes the configuration, signals the daemon (`SIGHUP`), and waits up to 30 seconds for the daemon to confirm the new state. On success it prints the current GPU status.
|
||||
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## Status and Monitoring
|
||||
|
||||
### Per-node status
|
||||
|
||||
```bash
|
||||
spx admin gpu status
|
||||
# or for a specific node in the cluster:
|
||||
spx admin gpu status --node <node-name>
|
||||
```
|
||||
|
||||
Output includes hardware detected, IOMMU state, vfio-pci binding, passthrough enabled/disabled, GPU pool allocation (`allocated/total`), and the GPU-capable EC2 instance types available on that node.
|
||||
|
||||
### Cluster view
|
||||
|
||||
`spx top nodes` includes a `GPU (used/total)` column:
|
||||
|
||||
| Value | Meaning |
|
||||
|-------|---------|
|
||||
| `1/2` | Passthrough active — 1 of 2 GPUs allocated |
|
||||
| `0/1*` | Node has a GPU but passthrough is not enabled |
|
||||
| `-` | No GPU hardware detected |
|
||||
|
||||
```bash
|
||||
spx top nodes
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## GPU Instance Types
|
||||
|
||||
GPU instance types are derived from detected hardware.
|
||||
|
||||
To list available GPU instance types on the current node:
|
||||
|
||||
```bash
|
||||
aws ec2 describe-instance-types \
|
||||
--filters Name=instance-type,Values=g*
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Launching GPU Instances
|
||||
|
||||
Import the GPU-enabled AMI before launching:
|
||||
|
||||
```bash
|
||||
spx admin images import --name ubuntu-gpu-nvidia # NVIDIA hosts
|
||||
spx admin images import --name ubuntu-gpu-amd # AMD hosts
|
||||
```
|
||||
|
||||
Launch a GPU instance:
|
||||
|
||||
```bash
|
||||
aws ec2 run-instances \
|
||||
--image-id $GPU_AMI \
|
||||
--instance-type $INSTANCE_TYPE \
|
||||
--key-name spinifex-key
|
||||
```
|
||||
|
||||
To verify the GPU is visible from inside the instance, SSH in and run:
|
||||
|
||||
```bash
|
||||
# NVIDIA
|
||||
nvidia-smi
|
||||
|
||||
# AMD
|
||||
rocm-smi
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Disable Passthrough
|
||||
|
||||
Passthrough cannot be disabled while GPU instances are running. Terminate all GPU instances first, then:
|
||||
|
||||
```bash
|
||||
sudo spx admin gpu disable
|
||||
```
|
||||
|
||||
The command signals the daemon and waits for confirmation. The GPU is released back to the host kernel on the next reboot (the vfio-pci binding persists until `setup` is re-run without the blacklists).
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### IOMMU not active after reboot
|
||||
|
||||
Verify IOMMU is enabled in BIOS/UEFI (`Intel VT-d` or `AMD-Vi`). Then check GRUB picked up the parameters:
|
||||
|
||||
```bash
|
||||
cat /proc/cmdline | grep iommu
|
||||
ls /sys/kernel/iommu_groups/
|
||||
```
|
||||
|
||||
If `/sys/kernel/iommu_groups/` is empty, IOMMU is not active. Re-run `sudo spx admin gpu setup` after enabling it in firmware.
|
||||
|
||||
### GPU bound to native driver instead of vfio-pci
|
||||
|
||||
```bash
|
||||
lspci -k | grep -A3 -i "vga\|3d\|display"
|
||||
```
|
||||
|
||||
If the driver is `amdgpu` or `nvidia` rather than `vfio-pci`, the blacklist or early binding did not take effect. Re-run setup and reboot:
|
||||
|
||||
```bash
|
||||
sudo spx admin gpu setup
|
||||
sudo reboot
|
||||
```
|
||||
|
||||
### `spx admin gpu enable` fails with "prerequisites not met"
|
||||
|
||||
Run `setup` first to configure the host, then retry `enable`:
|
||||
|
||||
```bash
|
||||
sudo spx admin gpu setup
|
||||
sudo spx admin gpu enable
|
||||
```
|
||||
|
||||
### Daemon does not confirm within 30 seconds
|
||||
|
||||
Check the daemon log for errors:
|
||||
|
||||
```bash
|
||||
journalctl -u spinifex-daemon -n 50
|
||||
```
|
||||
@@ -149,7 +149,7 @@ For production SSH via public IPs, see [Setting Up Your Cluster](/docs/setting-u
|
||||
Check QEMU logs for the instance and verify the AMI architecture matches your host:
|
||||
|
||||
```bash
|
||||
cat ~/spinifex/logs/spinifex.log
|
||||
journalctl -u spinifex-daemon -f
|
||||
aws ec2 describe-images --image-ids $SPINIFEX_AMI
|
||||
```
|
||||
|
||||
@@ -157,7 +157,7 @@ If the AMI is for a different architecture (e.g. arm64 on an x86_64 host), impor
|
||||
|
||||
```bash
|
||||
spx admin images list
|
||||
spx admin images import --name debian-12-x86_64
|
||||
spx admin images import --name debian-13-x86_64
|
||||
```
|
||||
|
||||
### Cannot SSH Into Instance
|
||||
@@ -39,7 +39,7 @@ Spinifex maps AWS VPC concepts directly to OVN constructs:
|
||||
## Network Path
|
||||
|
||||
<p align="center">
|
||||
<img src="../../.github/assets/diagrams/vpc-network-path.svg" alt="VPC logical topology — WAN, br-wan, VPC logical router, subnets, ENIs" width="900">
|
||||
<img src="../../../.github/assets/diagrams/vpc-network-path.svg" alt="VPC logical topology — WAN, br-wan, VPC logical router, subnets, ENIs" width="900">
|
||||
</p>
|
||||
|
||||
Cross-host traffic uses **Geneve tunnels** (UDP 6081) over the management/overlay
|
||||
@@ -58,7 +58,7 @@ same VPC (even across subnets and hosts via the overlay). They cannot reach the
|
||||
internet or be reached from the WAN.
|
||||
|
||||
<p align="center">
|
||||
<img src="../../.github/assets/diagrams/vpc-private-subnet-flow.svg" alt="Private subnet — instance hits router, no default route, packet dropped" width="900">
|
||||
<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
|
||||
@@ -71,7 +71,7 @@ Instances get both a private IP and a public IP. The public IP is a 1:1 NAT
|
||||
managed by OVN — the instance OS only sees its private IP.
|
||||
|
||||
<p align="center">
|
||||
<img src="../../.github/assets/diagrams/vpc-public-subnet-flow.svg" alt="Public subnet — outbound SNAT and inbound DNAT between private and public IPs" width="900">
|
||||
<img src="../../../.github/assets/diagrams/vpc-public-subnet-flow.svg" alt="Public subnet — outbound SNAT and inbound DNAT between private and public IPs" width="900">
|
||||
</p>
|
||||
|
||||
**Requirements for a public subnet:**
|
||||
@@ -162,7 +162,7 @@ Regardless of whether IPs come from a static range or DHCP, the OVN behavior
|
||||
is identical:
|
||||
|
||||
<p align="center">
|
||||
<img src="../../.github/assets/diagrams/vpc-dhcp-conversations.svg" alt="Two independent DHCP conversations — host-to-router and VM-to-OVN" width="900">
|
||||
<img src="../../../.github/assets/diagrams/vpc-dhcp-conversations.svg" alt="Two independent DHCP conversations — host-to-router and VM-to-OVN" width="900">
|
||||
</p>
|
||||
|
||||
### Choosing Static vs DHCP
|
||||
@@ -227,177 +227,171 @@ define a range (or use `source = "dhcp"`) — no data migration needed.
|
||||
|
||||
## Bridge Setup — Physical Network Wiring
|
||||
|
||||
OVN needs an OVS bridge for external connectivity. `setup-ovn.sh` auto-detects
|
||||
the best approach:
|
||||
The WAN NIC **must** be enslaved to a Linux bridge. This is a hard requirement —
|
||||
`setup-ovn.sh` will not attach a physical NIC directly to OVS, and macvlan is
|
||||
no longer supported. The Linux bridge owns the host IP, default route, and any
|
||||
DHCP lease, so SSH and management traffic stay up while OVS/OVN are configured
|
||||
underneath.
|
||||
|
||||
- **Linux bridge on default route** (e.g. `br-wan` from cloud-init): Creates
|
||||
OVS bridge `br-ext` and links them with a veth pair. The Linux bridge keeps
|
||||
its IP and routes — no interruption. Bridge-mapping: `external:br-ext`.
|
||||
- **OVS bridge on default route**: Uses it directly for bridge-mappings.
|
||||
- **Physical NIC**: Stops and prints guidance (cannot safely move the NIC).
|
||||
The full datapath chain looks like this:
|
||||
|
||||
## Two Bridges, Two Jobs
|
||||
|
||||
Every Spinifex node has two OVS bridges:
|
||||
|
||||
| Bridge | Purpose | Ports |
|
||||
| -------- | ------------------------------------ | ---------------------------- |
|
||||
| `br-int` | VM overlay traffic (Geneve tunnels) | VM TAP devices, tunnel ports |
|
||||
| `br-wan` | WAN uplink for public subnet traffic | macvlan on WAN NIC |
|
||||
|
||||
`br-int` is always created by `setup-ovn.sh`. `br-wan` is created only when
|
||||
a WAN bridge is configured (auto-detected or via `--wan-bridge`, required for public subnets / external connectivity).
|
||||
|
||||
The connection between them is logical, not physical — OVN's `localnet` port type
|
||||
maps the logical external switch to `br-wan` via `ovn-bridge-mappings`.
|
||||
|
||||
<p align="center">
|
||||
<img src="../../.github/assets/diagrams/vpc-datapath-localnet.svg" alt="Data path — VM TAP through br-int, OVN pipeline, localnet, br-wan, WAN" width="900">
|
||||
</p>
|
||||
|
||||
## Bridge Modes
|
||||
|
||||
Spinifex supports two ways to wire `br-wan` to the physical network.
|
||||
**Direct bridge** is preferred when the host has a dedicated WAN NIC. **Macvlan**
|
||||
is the fallback for single-NIC hosts where the WAN NIC is also the SSH NIC.
|
||||
|
||||
### Direct Bridge (Preferred)
|
||||
|
||||
The WAN NIC is added directly to `br-wan` as an OVS port. OVS sees all
|
||||
traffic on the wire — any MAC, any protocol. No filtering, no workarounds.
|
||||
|
||||
<p align="center">
|
||||
<img src="../../.github/assets/diagrams/vpc-direct-bridge.svg" alt="Direct bridge mode — dedicated WAN NIC added directly to br-wan" width="900">
|
||||
</p>
|
||||
|
||||
**Use when:** The host has a NIC dedicated to external/WAN traffic that is NOT
|
||||
used for SSH or management. Datacenter servers with separate management +
|
||||
public NICs. Homelab hosts with 2+ NICs.
|
||||
|
||||
**Benefits over macvlan:**
|
||||
|
||||
- **Distributed NAT**: DNAT processed on the VM's own chassis (no hairpin)
|
||||
- **No MAC workarounds**: OVS sees all frames, any MAC
|
||||
- **Host can reach VMs**: No macvlan parent↔child isolation
|
||||
- **Better multi-node performance**: External traffic stays local
|
||||
- **DHCP lease stability**: No MAC changes after lease
|
||||
|
||||
```bash
|
||||
sudo setup-ovn.sh --wan-bridge=br-wan --wan-iface=eth1
|
||||
```
|
||||
physical NIC (e.g. `wan`)
|
||||
└─ enslaved to ─▶ br-wan (Linux bridge — host IP, default route, DHCP)
|
||||
│
|
||||
└─ veth pair ─▶ br-ext (OVS bridge — OVN external uplink)
|
||||
│
|
||||
└─ localnet ─▶ br-int (OVS integration bridge)
|
||||
│
|
||||
└─▶ TAP devices (VM NICs)
|
||||
```
|
||||
|
||||
### Macvlan (Fallback for Single-NIC)
|
||||
`setup-ovn.sh` auto-detects the Linux bridge that owns the default route
|
||||
(typically `br-wan`, provisioned by cloud-init / netplan / systemd-networkd).
|
||||
You can override the detection with `--wan-bridge=<name>`. Once detected, the
|
||||
script creates the OVS bridge `br-ext` and links it to the WAN bridge with a
|
||||
veth pair. The Linux bridge keeps its IP and routes — no interruption.
|
||||
Bridge-mapping is set to `external:br-ext`.
|
||||
|
||||
A macvlan sub-interface is created in bridge mode off the WAN NIC, and that
|
||||
macvlan is added to br-wan. The host keeps its IP on the original NIC —
|
||||
SSH stays up.
|
||||
If the default route is on a bare physical NIC (no bridge), `setup-ovn.sh`
|
||||
stops and prints guidance on how to convert the NIC to a bridge before
|
||||
re-running.
|
||||
|
||||
<p align="center">
|
||||
<img src="../../.github/assets/diagrams/vpc-macvlan.svg" alt="Macvlan mode — WAN NIC shared between host traffic and macvlan child on br-wan" width="900">
|
||||
</p>
|
||||
### Example: Required `br-wan` State
|
||||
|
||||
**Use when:** The WAN NIC is the same NIC used for SSH. Single-NIC homelabs,
|
||||
edge deployments. Adding the NIC directly to OVS would break host connectivity.
|
||||
The host must have something resembling this before `setup-ovn.sh` is run:
|
||||
|
||||
**Limitations:**
|
||||
|
||||
- Centralized NAT only (macvlan filters unicast not matching its MAC)
|
||||
- Host cannot reach VMs at their public IPs (macvlan isolation)
|
||||
- vpcd must align macvlan MAC with OVN router MAC on startup
|
||||
- Multi-node: all external traffic hairpins through the gateway chassis
|
||||
|
||||
```bash
|
||||
sudo setup-ovn.sh --macvlan --wan-iface=eth0
|
||||
```
|
||||
7: br-wan: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default qlen 1000
|
||||
link/ether 26:df:3c:de:d0:c2 brd ff:ff:ff:ff:ff:ff
|
||||
inet 192.168.1.31/23 brd 192.168.1.255 scope global br-wan
|
||||
valid_lft forever preferred_lft forever
|
||||
inet6 fe80::24df:3cff:fede:d0c2/64 scope link proto kernel_ll
|
||||
valid_lft forever preferred_lft forever
|
||||
```
|
||||
|
||||
### Mode Comparison
|
||||
The physical NIC (e.g. `wan`, `eth0`, `eno1`) is enslaved to `br-wan` and has
|
||||
no IP of its own — all L3 state lives on the bridge.
|
||||
|
||||
| Capability | Direct bridge | Macvlan |
|
||||
| ------------------------ | ------------------------ | -------------------------------- |
|
||||
| NAT mode | Distributed (local DNAT) | Centralized (gateway hairpin) |
|
||||
| Multi-node external perf | Optimal | Hairpin through gateway chassis |
|
||||
| Host → VM public IP | Works | Blocked (kernel isolation) |
|
||||
| MAC alignment needed | No | Yes (fragile startup step) |
|
||||
| Gratuitous ARPs needed | No | Yes (`nat-addresses=router`) |
|
||||
| Works on single-NIC | No (breaks SSH) | Yes |
|
||||
| DHCP lease stability | Clean | Fragile (MAC changes post-lease) |
|
||||
Example netplan that produces this:
|
||||
|
||||
### How setup-ovn.sh Decides
|
||||
```yaml
|
||||
network:
|
||||
version: 2
|
||||
ethernets:
|
||||
wan:
|
||||
dhcp4: false
|
||||
bridges:
|
||||
br-wan:
|
||||
interfaces: [wan]
|
||||
dhcp4: true
|
||||
```
|
||||
|
||||
| Flags | Result |
|
||||
| -------------------------------------- | ----------------------------------------------- |
|
||||
| `--wan-bridge=br-wan --wan-iface=eth1` | Direct bridge: NIC added to br-wan |
|
||||
| `--macvlan --wan-iface=eth0` | Macvlan: sub-interface created, added to br-wan |
|
||||
| (no WAN bridge) | Only br-int created, no WAN connectivity |
|
||||
## Three Bridges, Three Jobs
|
||||
|
||||
### Setup (Macvlan Mode)
|
||||
Every Spinifex node has three bridges in the datapath:
|
||||
|
||||
| Bridge | Type | Created By | Purpose | Ports |
|
||||
| -------- | ------------ | --------------------------- | --------------------------------------------- | ---------------------------- |
|
||||
| `br-wan` | Linux bridge | Host (cloud-init / netplan) | Host WAN uplink — owns host IP and default route | Physical WAN NIC, veth peer |
|
||||
| `br-ext` | OVS bridge | `setup-ovn.sh` | OVN external uplink (`localnet`) | veth peer to `br-wan` |
|
||||
| `br-int` | OVS bridge | `setup-ovn.sh` | VM overlay traffic (Geneve tunnels) | VM TAP devices, tunnel ports |
|
||||
|
||||
`br-wan` is provisioned by your distro's network configuration (cloud-init,
|
||||
netplan, systemd-networkd, ifupdown). The name is configurable; `br-wan` is
|
||||
the convention. `br-int` and `br-ext` are always created by `setup-ovn.sh`.
|
||||
|
||||
The link between `br-ext` and the VM datapath is logical, not physical: OVN's
|
||||
`localnet` port type maps the logical external switch to `br-ext` via
|
||||
`ovn-bridge-mappings`. Frames egressing a VM travel TAP → `br-int` → OVN
|
||||
pipeline → `br-ext` → veth → `br-wan` → physical NIC → wire.
|
||||
|
||||
<p align="center">
|
||||
<img src="../../../.github/assets/diagrams/vpc-datapath-localnet.svg" alt="Data path — VM TAP through br-int, OVN pipeline, br-ext, veth pair, br-wan, physical NIC" width="900">
|
||||
</p>
|
||||
|
||||
## Running setup-ovn.sh
|
||||
|
||||
```bash
|
||||
# Auto-detect the WAN bridge (recommended)
|
||||
sudo setup-ovn.sh
|
||||
|
||||
# Explicitly specify the WAN bridge name
|
||||
sudo setup-ovn.sh --wan-bridge=br-wan
|
||||
```
|
||||
|
||||
In environments where the WAN IP comes from a router's DHCP server (homelab,
|
||||
small office), add `--dhcp` to obtain a gateway IP from the router automatically:
|
||||
small office), add `--dhcp` to obtain a gateway IP from the router
|
||||
automatically:
|
||||
|
||||
```bash
|
||||
sudo setup-ovn.sh --macvlan --wan-iface=eth0 --dhcp
|
||||
sudo setup-ovn.sh --dhcp
|
||||
```
|
||||
|
||||
This requests an IP from the **router's DHCP** (e.g., 192.168.1.1 serving
|
||||
addresses on the LAN). This is not Spinifex's internal OVN DHCP that assigns
|
||||
private IPs to VMs — it's your network's existing DHCP server.
|
||||
|
||||
### Macvlan Internals
|
||||
| Flags | Result |
|
||||
| ------------------------------ | --------------------------------------------------------------------- |
|
||||
| (no flags) | Auto-detect WAN bridge from default route, create `br-int` + `br-ext` |
|
||||
| `--wan-bridge=<name>` | Use the specified Linux bridge as the WAN uplink |
|
||||
| `--dhcp` | Obtain the OVN gateway IP from the router's DHCP |
|
||||
|
||||
macvlan in `bridge` mode creates a virtual interface that shares the parent NIC's
|
||||
physical wire but has its own MAC address. The Linux kernel blocks direct L2
|
||||
frames between a parent interface and its macvlan children.
|
||||
|
||||
| Path | Works? | Why |
|
||||
| ------------------------- | ------ | ------------------------------------------------- |
|
||||
| VM → internet | Yes | SNAT through OVN router → macvlan → WAN NIC → WAN |
|
||||
| LAN device → VM public IP | Yes | LAN → WAN NIC → macvlan → br-wan → OVN |
|
||||
| Host → VM public IP | No | macvlan isolation (kernel blocks parent↔child) |
|
||||
| Host → VM private IP | Yes | Overlay via br-int (unrelated to br-wan) |
|
||||
If no Linux bridge owns the default route, `setup-ovn.sh` exits with guidance
|
||||
rather than silently breaking host connectivity.
|
||||
|
||||
## Per-Node Configuration
|
||||
|
||||
Different nodes in a cluster can have different WAN NICs:
|
||||
Different nodes in a cluster can have different WAN bridges and NICs:
|
||||
|
||||
```toml
|
||||
[nodes.node1.vpcd]
|
||||
external_interface = "eth1"
|
||||
external_interface = "br-wan"
|
||||
|
||||
[nodes.node2.vpcd]
|
||||
external_interface = "eth0"
|
||||
external_interface = "br-public"
|
||||
|
||||
[nodes.node3.vpcd]
|
||||
external_interface = "bond0" # bonded NIC
|
||||
external_interface = "br-wan" # br-wan enslaving bond0
|
||||
```
|
||||
|
||||
Each node runs `setup-ovn.sh` with its own WAN bridge configuration. OVN doesn't
|
||||
care which NIC the macvlan is on — only that `ovn-bridge-mappings` is set.
|
||||
`external_interface` is the **Linux bridge** that owns the WAN uplink on this
|
||||
node — not the physical NIC. The physical NIC lives underneath the bridge.
|
||||
Each node runs `setup-ovn.sh` with its own WAN bridge name (or relies on
|
||||
auto-detection). OVN only requires `ovn-bridge-mappings` to point at `br-ext`.
|
||||
|
||||
## Bridge Verification
|
||||
|
||||
```bash
|
||||
# Check br-wan exists
|
||||
sudo ovs-vsctl br-exists br-wan && echo "OK" || echo "MISSING"
|
||||
# OVS bridges created by setup-ovn.sh
|
||||
sudo ovs-vsctl br-exists br-int && echo "br-int OK" || echo "br-int MISSING"
|
||||
sudo ovs-vsctl br-exists br-ext && echo "br-ext OK" || echo "br-ext MISSING"
|
||||
|
||||
# Check the physical port
|
||||
sudo ovs-vsctl list-ports br-wan
|
||||
# Multi-NIC: shows "eth1" (or your WAN NIC)
|
||||
# Single-NIC: shows "spx-ext-eth0" (macvlan name)
|
||||
# Linux WAN bridge owns the host IP and default route
|
||||
ip -br addr show br-wan
|
||||
ip route show default
|
||||
# Default route's dev should be br-wan (or your WAN bridge name)
|
||||
|
||||
# Check bridge mappings
|
||||
# br-ext should have one veth port linking it to br-wan
|
||||
sudo ovs-vsctl list-ports br-ext
|
||||
# Expect: a veth name (e.g. "veth-wan-ovs")
|
||||
|
||||
# Confirm the matching peer is enslaved to the Linux WAN bridge
|
||||
sudo bridge link show | grep br-wan
|
||||
|
||||
# Bridge mappings must point at br-ext
|
||||
sudo ovs-vsctl get Open_vSwitch . external-ids:ovn-bridge-mappings
|
||||
# Output: "external:br-wan"
|
||||
# Output: "external:br-ext"
|
||||
|
||||
# Check macvlan (single-NIC only)
|
||||
ip link show spx-ext-eth0
|
||||
# Shows: state UP, type macvlan mode bridge
|
||||
# Physical NIC is enslaved to br-wan (master should be br-wan)
|
||||
ip -d link show wan
|
||||
```
|
||||
|
||||
## Network Flow Diagram
|
||||
|
||||
<p align="center">
|
||||
<img src="../../.github/assets/diagrams/vpc-host-flow.svg" alt="Bare-metal host — br-int overlay, br-wan WAN bridge with macvlan, OVN NAT pipeline" width="900">
|
||||
<img src="../../../.github/assets/diagrams/vpc-host-flow.svg" alt="Bare-metal host — br-int overlay, br-ext OVS uplink, br-wan Linux bridge, physical NIC, OVN NAT pipeline" width="900">
|
||||
</p>
|
||||
|
||||
## Configuration Reference
|
||||
@@ -408,7 +402,7 @@ levels: cluster-wide mode, IP pool definitions, and per-node NIC settings.
|
||||
## Configuration Levels
|
||||
|
||||
<p align="center">
|
||||
<img src="../../.github/assets/diagrams/vpc-config-layers.svg" alt="spinifex.toml configuration layers — cluster mode, IP pools, per-node NIC" width="900">
|
||||
<img src="../../../.github/assets/diagrams/vpc-config-layers.svg" alt="spinifex.toml configuration layers — cluster mode, IP pools, per-node NIC" width="900">
|
||||
</p>
|
||||
|
||||
## Cluster-Wide: external_mode
|
||||
@@ -480,12 +474,12 @@ These are different things:
|
||||
[nodes.spx1.vpcd]
|
||||
ovn_nb_addr = "tcp:10.1.3.181:6641" # OVN Northbound DB
|
||||
ovn_sb_addr = "tcp:10.1.3.181:6642" # OVN Southbound DB
|
||||
external_interface = "eth1" # WAN NIC name
|
||||
external_interface = "br-wan" # WAN Linux bridge name
|
||||
```
|
||||
|
||||
| Field | Description |
|
||||
| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `external_interface` | Physical NIC for WAN traffic. A macvlan sub-interface is created on this NIC for br-wan. Different servers may have different names (eth1, eno2, enp3s0, bond0). |
|
||||
| Field | Description |
|
||||
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `external_interface` | Linux bridge that owns the WAN uplink on this node (e.g. `br-wan`, `br-public`). The physical NIC is enslaved to this bridge — not configured here. Different nodes may differ. |
|
||||
|
||||
## Pool Selection Logic
|
||||
|
||||
@@ -544,11 +538,13 @@ gateway = "192.168.1.1"
|
||||
prefix_len = 24
|
||||
|
||||
[nodes.homelab.vpcd]
|
||||
external_interface = "eth0"
|
||||
external_interface = "br-wan"
|
||||
```
|
||||
|
||||
**Setup:** Change your router's DHCP range to end at .149. Run
|
||||
`setup-ovn.sh --macvlan --wan-iface=eth0` (or auto-detected if WAN is already a bridge).
|
||||
**Setup:** Configure `br-wan` to enslave your physical WAN NIC (netplan,
|
||||
cloud-init, or systemd-networkd). Change your router's DHCP range to end
|
||||
at .149. Run `sudo setup-ovn.sh` — it auto-detects the WAN bridge from the
|
||||
default route, or specify it with `--wan-bridge=br-wan`.
|
||||
|
||||
## Homelab / Dev (DHCP Pool — No Range Reservation)
|
||||
|
||||
@@ -570,19 +566,67 @@ prefix_len = 24
|
||||
dns_servers = ["192.168.1.1", "8.8.8.8"]
|
||||
|
||||
[nodes.homelab.vpcd]
|
||||
external_interface = "eth0"
|
||||
external_interface = "br-wan"
|
||||
```
|
||||
|
||||
**Setup:** No router changes needed. Spinifex requests IPs from the router's
|
||||
DHCP server when VMs launch and releases them on terminate. Requires `dhclient`
|
||||
on the host (`apt install isc-dhcp-client`).
|
||||
|
||||
## Host-Local Subnet (No Upstream Router)
|
||||
|
||||
```
|
||||
Network: 192.168.10.0/24 — host-local, reachable from the host only
|
||||
Host WAN: 198.51.100.10/24 on br-wan (existing address — unchanged)
|
||||
Gateway: 192.168.10.1 — second address added to br-wan
|
||||
```
|
||||
|
||||
Add the VM pool gateway as a second address on `br-wan` alongside the existing WAN
|
||||
IP. The host acts as the gateway for the pool — no upstream router or DHCP server
|
||||
needed for this range.
|
||||
|
||||
```yaml
|
||||
# /etc/netplan/…
|
||||
bridges:
|
||||
br-wan:
|
||||
addresses:
|
||||
- 192.168.10.1/24 # VM pool gateway — host-local
|
||||
- 198.51.100.10/24 # existing WAN IP — unchanged
|
||||
routes:
|
||||
- to: default
|
||||
via: 198.51.100.1
|
||||
```
|
||||
|
||||
```toml
|
||||
[network]
|
||||
external_mode = "pool"
|
||||
|
||||
[[network.external_pools]]
|
||||
name = "wan"
|
||||
source = "static" # required — no upstream DHCP for this range
|
||||
range_start = "192.168.10.2"
|
||||
range_end = "192.168.10.100"
|
||||
gateway = "192.168.10.1" # second address on br-wan
|
||||
prefix_len = 24
|
||||
dns_servers = ["8.8.8.8"]
|
||||
```
|
||||
|
||||
**Setup:** Apply with `sudo netplan apply`. VMs are reachable from the host at
|
||||
`192.168.10.x`. For internet access through the host's WAN interface:
|
||||
|
||||
```bash
|
||||
sysctl -w net.ipv4.ip_forward=1
|
||||
iptables -t nat -A POSTROUTING -s 192.168.10.0/24 -o br-wan -j MASQUERADE
|
||||
```
|
||||
|
||||
Persist via `/etc/sysctl.d/99-ip-forward.conf` and `netfilter-persistent save`.
|
||||
|
||||
## Datacenter / Colo (ISP Block)
|
||||
|
||||
```
|
||||
ISP-assigned: 203.0.113.0/28 (14 usable IPs)
|
||||
ISP gateway: 203.0.113.1
|
||||
Servers: 3x with separate mgmt (eth0) and public (eth1) NICs
|
||||
Servers: 3x with separate mgmt NIC (eth0) and public NIC enslaved to br-wan
|
||||
```
|
||||
|
||||
```toml
|
||||
@@ -597,17 +641,21 @@ gateway = "203.0.113.1"
|
||||
prefix_len = 28
|
||||
|
||||
[nodes.dc1.vpcd]
|
||||
external_interface = "eth1"
|
||||
external_interface = "br-wan" # br-wan enslaves eth1
|
||||
|
||||
[nodes.dc2.vpcd]
|
||||
external_interface = "eth1"
|
||||
external_interface = "br-wan" # br-wan enslaves eth1
|
||||
|
||||
[nodes.dc3.vpcd]
|
||||
external_interface = "eno1"
|
||||
external_interface = "br-public" # br-public enslaves eno1
|
||||
```
|
||||
|
||||
## Enterprise On-Prem (VLAN)
|
||||
|
||||
The Linux WAN bridge enslaves a VLAN sub-interface (e.g. `eth1.200`) instead
|
||||
of a raw NIC. From OVN's perspective nothing changes — `external_interface`
|
||||
still points at the bridge.
|
||||
|
||||
```toml
|
||||
[network]
|
||||
external_mode = "pool"
|
||||
@@ -620,10 +668,10 @@ gateway = "172.16.0.1"
|
||||
prefix_len = 24
|
||||
|
||||
[nodes.srv1.vpcd]
|
||||
external_interface = "eth1.200" # VLAN-tagged sub-interface
|
||||
external_interface = "br-dmz" # br-dmz enslaves eth1.200
|
||||
|
||||
[nodes.srv2.vpcd]
|
||||
external_interface = "bond0.200" # Bonded NIC with VLAN tag
|
||||
external_interface = "br-dmz" # br-dmz enslaves bond0.200
|
||||
```
|
||||
|
||||
## Edge / Branch (Outbound Only)
|
||||
@@ -639,7 +687,7 @@ gateway_ip = "10.0.0.50"
|
||||
prefix_len = 24
|
||||
|
||||
[nodes.edge1.vpcd]
|
||||
external_interface = "eth0"
|
||||
external_interface = "br-wan"
|
||||
```
|
||||
|
||||
## Multi-Region (Multiple Pools)
|
||||
@@ -800,14 +848,15 @@ ovn-nbctl lr-route-add vpc-{vpcId} 0.0.0.0/0 192.168.1.1
|
||||
## Per-Instance Public IP
|
||||
|
||||
```bash
|
||||
# 1:1 NAT (centralized — processed on gateway chassis, uses router MAC)
|
||||
ovn-nbctl lr-nat-add vpc-{vpcId} dnat_and_snat {public_ip} {private_ip}
|
||||
# 1:1 NAT — distributed (DNAT processed on the VM's own chassis)
|
||||
ovn-nbctl lr-nat-add vpc-{vpcId} dnat_and_snat {public_ip} {private_ip} \
|
||||
port-{eniId} {vm_mac}
|
||||
```
|
||||
|
||||
> **Note:** Spinifex uses centralized NAT (no `external_mac` / `logical_port`)
|
||||
> because macvlan-based external bridges can only receive unicast traffic matching
|
||||
> the macvlan's own MAC. The router port MAC is set on the macvlan, and OVN uses
|
||||
> it for all ARP replies. For single-node, there is no performance difference.
|
||||
With the WAN NIC on a Linux bridge wired to OVS via veth, OVS sees every frame
|
||||
on the wire regardless of MAC, so OVN can use distributed NAT. The DNAT is
|
||||
processed on the chassis hosting the VM rather than hairpinning through a
|
||||
single gateway chassis.
|
||||
|
||||
## Security Group
|
||||
|
||||
@@ -863,8 +912,13 @@ sudo ovn-nbctl pg-get-ports sg-{groupId}
|
||||
|
||||
## 1. Set Up OVN Bridges
|
||||
|
||||
Make sure the WAN NIC is enslaved to a Linux bridge (e.g. `br-wan`) and that
|
||||
bridge owns the default route. Then run:
|
||||
|
||||
```bash
|
||||
sudo setup-ovn.sh --macvlan --wan-iface=eth0
|
||||
sudo setup-ovn.sh # auto-detect WAN bridge
|
||||
# or
|
||||
sudo setup-ovn.sh --wan-bridge=br-wan # explicit
|
||||
```
|
||||
|
||||
## 2. Configure External IP Pool
|
||||
@@ -937,7 +991,7 @@ sudo ovn-nbctl list Logical_Switch_Port port-eni-{eniId}
|
||||
# Gateway chassis assignment
|
||||
sudo ovn-nbctl list Logical_Router_Port gw-vpc-{vpcId}
|
||||
|
||||
# Localnet port options (check nat-addresses=router)
|
||||
# Localnet port options (network_name should be "external")
|
||||
sudo ovn-nbctl get Logical_Switch_Port ext-port-vpc-{vpcId} options
|
||||
```
|
||||
|
||||
@@ -987,24 +1041,30 @@ sudo ovs-vsctl get Open_vSwitch . external_ids
|
||||
### Network Interfaces
|
||||
|
||||
```bash
|
||||
# Check macvlan exists and has correct MAC
|
||||
ip -d link show spx-ext-{nic}
|
||||
# Physical NIC should be enslaved to br-wan ("master br-wan")
|
||||
ip -d link show {nic}
|
||||
|
||||
# Check macvlan is attached to OVS (look for "openvswitch_slave")
|
||||
ip -d link show spx-ext-{nic} | grep openvswitch
|
||||
# Linux WAN bridge — owns the host IP and default route
|
||||
ip -br addr show br-wan
|
||||
ip route show default
|
||||
|
||||
# OVS bridges and the veth linking br-ext to br-wan
|
||||
sudo ovs-vsctl list-ports br-ext
|
||||
bridge link show | grep br-wan
|
||||
|
||||
# Interface traffic stats (RX/TX counts, drops)
|
||||
ip -s link show spx-ext-{nic}
|
||||
ip -s link show {nic}
|
||||
ip -s link show br-wan
|
||||
```
|
||||
|
||||
### Packet Capture
|
||||
|
||||
```bash
|
||||
# Capture on the OVS bridge (sees traffic after OVS processing)
|
||||
sudo tcpdump -i br-wan -n -e arp
|
||||
# Capture on the OVS uplink bridge (frames between OVN and br-wan)
|
||||
sudo tcpdump -i br-ext -n -e arp
|
||||
|
||||
# Capture on the macvlan (sees what leaves/enters the wire)
|
||||
sudo tcpdump -i spx-ext-{nic} -n -e "host {public_ip}"
|
||||
# Capture on the Linux WAN bridge (frames between br-ext veth and the NIC)
|
||||
sudo tcpdump -i br-wan -n -e "host {public_ip}"
|
||||
|
||||
# Capture on the physical NIC (sees everything on the wire)
|
||||
sudo tcpdump -i {nic} -n "host {public_ip}"
|
||||
@@ -1014,13 +1074,13 @@ sudo tcpdump -i {nic} -n "host {public_ip}"
|
||||
|
||||
```bash
|
||||
# vpcd log (reconcile, NAT, topology operations)
|
||||
cat ~/spinifex/logs/vpcd.log
|
||||
journalctl -u spinifex-vpcd -f
|
||||
|
||||
# ovn-controller log (port binding, commit failures)
|
||||
sudo cat /var/log/ovn/ovn-controller.log | tail -50
|
||||
|
||||
# Daemon log (instance launch, network setup)
|
||||
cat ~/spinifex/logs/spinifex.log
|
||||
journalctl -u spinifex-daemon -f
|
||||
```
|
||||
|
||||
### VPC Creation Fails
|
||||
@@ -1029,7 +1089,7 @@ Check OVN services and vpcd daemon:
|
||||
|
||||
```bash
|
||||
sudo systemctl is-active ovn-controller
|
||||
cat ~/spinifex/logs/vpcd.log
|
||||
journalctl -u spinifex-vpcd -f
|
||||
```
|
||||
|
||||
### Instances Cannot Reach Each Other
|
||||
@@ -1082,12 +1142,18 @@ Work through these checks in order — each eliminates a class of issues.
|
||||
### 1. Verify OVS wiring
|
||||
|
||||
```bash
|
||||
# br-wan must exist with the macvlan port
|
||||
sudo ovs-vsctl show | grep -A5 br-wan
|
||||
# br-int and br-ext must exist
|
||||
sudo ovs-vsctl show | grep -E "Bridge (br-int|br-ext)"
|
||||
|
||||
# Bridge mappings must be set
|
||||
# br-ext should have a veth port linking it to br-wan
|
||||
sudo ovs-vsctl list-ports br-ext
|
||||
|
||||
# The Linux WAN bridge should have the physical NIC and the veth peer
|
||||
bridge link show | grep br-wan
|
||||
|
||||
# Bridge mappings must point at br-ext
|
||||
sudo ovs-vsctl get Open_vSwitch . external-ids:ovn-bridge-mappings
|
||||
# Expected: "external:br-wan"
|
||||
# Expected: "external:br-ext"
|
||||
```
|
||||
|
||||
### 2. Verify chassis and gateway scheduling
|
||||
@@ -1111,7 +1177,8 @@ If these don't match, see "Chassis name mismatch" below.
|
||||
```bash
|
||||
sudo ovn-nbctl lr-nat-list vpc-$VPC | grep dnat_and_snat
|
||||
# Must show the public IP → private IP mapping
|
||||
# Must NOT have external_mac or logical_port set (centralized NAT)
|
||||
# For distributed NAT, external_mac and logical_port should be set
|
||||
# (the VM's MAC and ENI port name)
|
||||
```
|
||||
|
||||
### 4. Verify ARP resolution
|
||||
@@ -1122,20 +1189,21 @@ From another host on the same LAN, check if OVN responds to ARP:
|
||||
# On the remote host:
|
||||
ping -c 1 {public_ip}
|
||||
ip neigh show {public_ip}
|
||||
# Should show the OVN router MAC (02:00:00:xx:xx:xx)
|
||||
# Should show the VM's ENI MAC (distributed NAT) or the OVN router MAC
|
||||
```
|
||||
|
||||
If ARP fails, check the macvlan MAC alignment:
|
||||
If ARP fails, confirm the WAN bridge is forwarding:
|
||||
|
||||
```bash
|
||||
# On the Spinifex host:
|
||||
# Gateway router MAC
|
||||
sudo ovn-nbctl get Logical_Router_Port gw-vpc-{vpcId} mac
|
||||
# Macvlan MAC
|
||||
ip link show spx-ext-{nic} | grep ether
|
||||
# Physical NIC must be enslaved to br-wan
|
||||
ip -d link show {nic} | grep "master br-wan"
|
||||
|
||||
# These MUST match. If they don't, vpcd failed to set the macvlan MAC.
|
||||
# Check vpcd.log for "macvlan MAC aligned" or errors.
|
||||
# br-wan must be UP and have an IP
|
||||
ip -br addr show br-wan
|
||||
|
||||
# br-ext must have a veth port whose peer is enslaved to br-wan
|
||||
sudo ovs-vsctl list-ports br-ext
|
||||
bridge link show | grep br-wan
|
||||
```
|
||||
|
||||
### 5. Verify packet flow with tcpdump
|
||||
@@ -1146,15 +1214,16 @@ Capture at each layer to find where packets stop:
|
||||
# Layer 1: Does the ARP/ICMP arrive on the physical NIC?
|
||||
sudo tcpdump -i {nic} -n "host {public_ip}"
|
||||
|
||||
# Layer 2: Does it reach br-wan?
|
||||
# Layer 2: Does it cross the Linux WAN bridge?
|
||||
sudo tcpdump -i br-wan -n "host {public_ip}"
|
||||
|
||||
# Layer 3: Does it reach the macvlan output?
|
||||
sudo tcpdump -i spx-ext-{nic} -n -e "host {public_ip}"
|
||||
# Layer 3: Does it reach the OVS uplink bridge?
|
||||
sudo tcpdump -i br-ext -n -e "host {public_ip}"
|
||||
```
|
||||
|
||||
If traffic arrives on the physical NIC but not br-wan, the macvlan is
|
||||
filtering it. Check MAC alignment (step 4).
|
||||
If traffic arrives on the physical NIC but not br-wan, the NIC is not
|
||||
enslaved to the bridge. If it reaches br-wan but not br-ext, the veth
|
||||
pair between them is missing or down — re-run `setup-ovn.sh`.
|
||||
|
||||
### 6. Use ovn-trace for pipeline debugging
|
||||
|
||||
@@ -1197,77 +1266,46 @@ sudo systemctl start ovn-central ovn-controller
|
||||
|
||||
### Chassis Name Mismatch
|
||||
|
||||
**Symptom:** Gateway chassis is set in NB but the SB has no matching chassis.
|
||||
`ovn-nbctl show` reports a gateway chassis name that doesn't appear in
|
||||
`ovn-sbctl show`. Gateway traffic (SNAT/DNAT) doesn't work.
|
||||
After mulga-999, vpcd uses the OVS-managed UUID (persisted at
|
||||
`/etc/openvswitch/system-id.conf` by the `openvswitch-switch` package and
|
||||
re-applied on every boot) as the canonical chassis identity. The chassis name
|
||||
across OVS `external_ids:system-id`, OVN SB `Chassis.name`, and NB
|
||||
`Gateway_Chassis.chassis_name` is always the same value, so the mismatch
|
||||
class that this section previously documented can no longer recur.
|
||||
|
||||
**Cause:** Three names must align:
|
||||
`ReconcileFromKV` runs a `reconcileGatewayChassis` pre-step on every vpcd
|
||||
startup that deletes any stale `Gateway_Chassis` row left over from earlier
|
||||
installs (where `setup-ovn.sh` fabricated `chassis-$(hostname -s)`) and
|
||||
re-binds every gateway LRP against the live SBDB chassis. Pre-mulga-999
|
||||
brokenness recovers automatically on the first vpcd restart after upgrade —
|
||||
no manual `ovn-nbctl destroy` required.
|
||||
|
||||
1. OVS `external_ids:system-id` → what ovn-controller registers as
|
||||
2. OVN SB `Chassis.name` → what actually exists in the SB DB
|
||||
3. vpcd's chassis name → `chassis-{nodeName}` from config
|
||||
### WAN NIC Not Enslaved to a Bridge
|
||||
|
||||
If `setup-ovn.sh` used `chassis-$(hostname -s)` but vpcd uses
|
||||
`chassis-{nodeName}`, they'll differ on any host where hostname ≠ node name.
|
||||
**Symptom:** `setup-ovn.sh` exits with an error like "default route is on a
|
||||
physical NIC, not a bridge" and refuses to continue.
|
||||
|
||||
**Diagnosis:**
|
||||
**Cause:** Spinifex requires the WAN NIC to be enslaved to a Linux bridge
|
||||
(typically `br-wan`). Macvlan is no longer supported, and attaching the NIC
|
||||
directly to OVS would break SSH and any other host services using the NIC.
|
||||
|
||||
```bash
|
||||
# What OVS has
|
||||
sudo ovs-vsctl get Open_vSwitch . external-ids:system-id
|
||||
# What SB registered
|
||||
sudo ovn-sbctl list Chassis | grep name
|
||||
# What vpcd expects
|
||||
grep node ~/spinifex/config/spinifex.toml | head -1
|
||||
**Fix:** Move the host IP and default route onto a Linux bridge. Example
|
||||
netplan:
|
||||
|
||||
```yaml
|
||||
network:
|
||||
version: 2
|
||||
ethernets:
|
||||
wan:
|
||||
dhcp4: false
|
||||
bridges:
|
||||
br-wan:
|
||||
interfaces: [wan]
|
||||
dhcp4: true
|
||||
```
|
||||
|
||||
**Fix:** `reset-dev-env.sh` passes `--chassis-id=chassis-node1` to
|
||||
`setup-ovn.sh`. For manual fix:
|
||||
|
||||
```bash
|
||||
sudo ovs-vsctl set Open_vSwitch . external_ids:system-id=chassis-node1
|
||||
sudo systemctl restart ovn-controller
|
||||
```
|
||||
|
||||
### Macvlan Unicast Filtering (Inbound Traffic Lost)
|
||||
|
||||
**Symptom:** VM gets DHCP, outbound works (ping 8.8.8.8 from VM), but inbound
|
||||
from the LAN to the VM's public IP fails. ARP replies go out but ICMP never
|
||||
arrives at br-wan.
|
||||
|
||||
**Cause:** macvlan in bridge mode only delivers unicast frames matching its own
|
||||
MAC. OVN announces the router MAC for external IPs, but the macvlan has a
|
||||
different (random) MAC. Broadcasts (ARP requests) pass through; unicast return
|
||||
traffic is filtered by the kernel macvlan subsystem.
|
||||
|
||||
**Diagnosis:**
|
||||
|
||||
```bash
|
||||
# Compare these two — they must match
|
||||
sudo ovn-nbctl get Logical_Router_Port gw-vpc-{vpcId} mac
|
||||
ip link show spx-ext-{nic} | grep ether
|
||||
|
||||
# If they differ, inbound unicast is being dropped
|
||||
```
|
||||
|
||||
**Fix:** vpcd automatically aligns the macvlan MAC on startup. If it failed:
|
||||
|
||||
```bash
|
||||
# Get the gateway MAC from OVN
|
||||
GW_MAC=$(sudo ovn-nbctl get Logical_Router_Port gw-vpc-{vpcId} mac | tr -d '"')
|
||||
|
||||
# Set it on the macvlan
|
||||
sudo ip link set spx-ext-{nic} down
|
||||
sudo ip link set spx-ext-{nic} address $GW_MAC
|
||||
sudo ip link set spx-ext-{nic} up
|
||||
```
|
||||
|
||||
Also ensure the DNAT rule uses centralized NAT (no `external_mac`):
|
||||
|
||||
```bash
|
||||
sudo ovn-nbctl lr-nat-list vpc-{vpcId}
|
||||
# The dnat_and_snat row should have EMPTY external_mac and logical_port columns
|
||||
```
|
||||
Apply (`sudo netplan apply`), confirm the host IP is now on `br-wan`
|
||||
(`ip -br addr show br-wan`), and re-run `setup-ovn.sh`.
|
||||
|
||||
### Stale ARP on Remote Hosts
|
||||
|
||||
@@ -1282,7 +1320,7 @@ sudo ip neigh flush dev {nic} {public_ip}
|
||||
ping {public_ip} # should work now
|
||||
```
|
||||
|
||||
OVN's `nat-addresses=router` option sends periodic gratuitous ARPs that will
|
||||
OVN sends periodic gratuitous ARPs from the chassis hosting the VM that will
|
||||
eventually update remote ARP caches, but flushing is faster for testing.
|
||||
|
||||
### Security Group Rules Not Taking Effect
|
||||
@@ -0,0 +1,186 @@
|
||||
---
|
||||
title: "Air-Gapped Install"
|
||||
description: "Deploy Spinifex in environments without internet connectivity. Covers using a pre-built release tarball, mirrored APT packages, and locally-staged cloud images."
|
||||
category: "Getting Started"
|
||||
tags:
|
||||
- install
|
||||
- air-gapped
|
||||
- offline
|
||||
resources:
|
||||
- title: "Spinifex Repository"
|
||||
url: "https://github.com/mulgadc/spinifex"
|
||||
- title: "External Connection Inventory"
|
||||
url: "/docs/security/network-connections"
|
||||
---
|
||||
|
||||
# Air-Gapped Installation
|
||||
|
||||
> Deploy Spinifex in environments without internet connectivity.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Instructions](#instructions)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
In air-gapped or disconnected environments, Spinifex can be deployed without internet access. This guide covers preparing offline packages on a connected machine, creating USB deployment media, and installing on the target server with package verification.
|
||||
|
||||
## Instructions
|
||||
|
||||
## Step 1. Download the Release (on a connected machine)
|
||||
|
||||
Each Spinifex release publishes a self-contained tarball, the matching `setup.sh`, and a SHA-256 checksum to GitHub Releases. Resolve the latest tag and download the assets for your architecture:
|
||||
|
||||
```bash
|
||||
ARCH=amd64 # or arm64
|
||||
TAG=$(curl -fsSL https://api.github.com/repos/mulgadc/spinifex/releases/latest \
|
||||
| grep '"tag_name"' | cut -d'"' -f4)
|
||||
|
||||
BASE="https://github.com/mulgadc/spinifex/releases/download/${TAG}"
|
||||
curl -fsSLO "${BASE}/spinifex-${TAG}-linux-${ARCH}.tar.gz"
|
||||
curl -fsSLO "${BASE}/spinifex-${TAG}-linux-${ARCH}.tar.gz.sha256"
|
||||
curl -fsSLO "${BASE}/setup.sh"
|
||||
|
||||
sha256sum -c "spinifex-${TAG}-linux-${ARCH}.tar.gz.sha256"
|
||||
```
|
||||
|
||||
## Step 2. Stage Dependencies (on the connected machine)
|
||||
|
||||
Pre-download the APT packages installed by the production setup. `apt install --download-only` writes `.deb` files to `/var/cache/apt/archives/` without installing them, so the connected machine isn't modified:
|
||||
|
||||
```bash
|
||||
sudo apt update
|
||||
sudo apt install --download-only -y \
|
||||
nbdkit nbdkit-plugin-dev pkg-config \
|
||||
qemu-system-x86 qemu-utils qemu-kvm \
|
||||
libvirt-daemon-system libvirt-clients libvirt-dev \
|
||||
ovn-central ovn-host openvswitch-switch \
|
||||
dhcpcd-base make gcc jq curl iproute2 netcat-openbsd \
|
||||
wget unzip xz-utils file
|
||||
```
|
||||
|
||||
Download AWS CLI v2:
|
||||
|
||||
```bash
|
||||
curl -fsSL "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" \
|
||||
-o awscliv2.zip
|
||||
```
|
||||
|
||||
Mirror the cloud image you intend to run as guest VMs:
|
||||
|
||||
```bash
|
||||
mkdir -p images
|
||||
curl -fsSL "https://cloud.debian.org/images/cloud/trixie/20260501-2465/debian-13-genericcloud-amd64-20260501-2465.qcow2" \
|
||||
-o images/debian-13-amd64.qcow2
|
||||
```
|
||||
|
||||
## Step 3. Assemble Transfer Media
|
||||
|
||||
```bash
|
||||
mkdir -p /media/spinifex-deploy/{tarball,apt-packages,aws,images}
|
||||
cp spinifex-${TAG}-linux-${ARCH}.tar.gz /media/spinifex-deploy/tarball/
|
||||
cp setup.sh /media/spinifex-deploy/
|
||||
cp /var/cache/apt/archives/*.deb /media/spinifex-deploy/apt-packages/
|
||||
cp awscliv2.zip /media/spinifex-deploy/aws/
|
||||
cp images/*.qcow2 /media/spinifex-deploy/images/
|
||||
```
|
||||
|
||||
## Step 4. Install on the Air-Gapped Target
|
||||
|
||||
Mount the media, install APT dependencies, install AWS CLI, then run `setup.sh` with the local tarball:
|
||||
|
||||
```bash
|
||||
sudo mount /dev/sdb1 /mnt/usb
|
||||
|
||||
sudo dpkg -i /mnt/usb/apt-packages/*.deb
|
||||
sudo apt-get install -f --no-download # resolve any leftover deps from local cache
|
||||
|
||||
cd /tmp && unzip /mnt/usb/aws/awscliv2.zip && sudo ./aws/install
|
||||
|
||||
INSTALL_SPINIFEX_TARBALL=/mnt/usb/tarball/spinifex-*-linux-*.tar.gz \
|
||||
INSTALL_SPINIFEX_SKIP_APT=1 \
|
||||
INSTALL_SPINIFEX_SKIP_AWS=1 \
|
||||
bash /mnt/usb/setup.sh
|
||||
```
|
||||
|
||||
Run `setup.sh` without `sudo` — the script handles privilege escalation internally and ends by `exec`-ing into a `newgrp spinifex` subshell so your current shell picks up `spinifex` group membership. Without that membership, AWS CLI cannot traverse `/etc/spinifex/` (mode `0750`) to read `ca.pem` and Step 9 will fail with a TLS error. Type `exit` to leave the subshell when finished.
|
||||
|
||||
## Step 5. Setup OVN Networking
|
||||
|
||||
`spx admin init` requires OVN/OVS to be configured before the daemon can manage tenant networks. `setup-ovn.sh` ships in the tarball and runs purely against local commands (no network access required):
|
||||
|
||||
```bash
|
||||
sudo /usr/local/share/spinifex/setup-ovn.sh --management
|
||||
```
|
||||
|
||||
If your WAN interface is a physical NIC rather than a bridge, pass `--wan-bridge=br-wan --wan-iface=<nic>`.
|
||||
|
||||
## Step 6. Initialize Without Telemetry
|
||||
|
||||
```bash
|
||||
sudo spx admin init --node node1 --nodes 1 --no-telemetry
|
||||
```
|
||||
|
||||
This generates the cluster configuration, TLS certificates, installs the CA into the system trust store, and writes AWS CLI credentials. See [Single-Node Install](/docs/install) for what `spx admin init` does.
|
||||
|
||||
## Step 7. Start Services
|
||||
|
||||
`setup.sh` enables `spinifex.target` but does not start it on a fresh install. Start it now so predastore is online before you import images:
|
||||
|
||||
```bash
|
||||
sudo systemctl start spinifex.target
|
||||
```
|
||||
|
||||
## Step 8. Import Cloud Images
|
||||
|
||||
Register the pre-staged image with Spinifex:
|
||||
|
||||
```bash
|
||||
sudo spx admin images import --file /mnt/usb/images/debian-13-amd64.qcow2 \
|
||||
--distro debian --version 13 --arch x86_64
|
||||
```
|
||||
|
||||
## Step 9. Verify
|
||||
|
||||
```bash
|
||||
export AWS_PROFILE=spinifex
|
||||
aws ec2 describe-instance-types
|
||||
aws ec2 describe-images
|
||||
```
|
||||
|
||||
If both calls return data, your air-gapped install is working. Continue to [Setting Up Your Cluster](/docs/setting-up-your-cluster) to launch your first instance.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Missing APT Dependencies
|
||||
|
||||
`dpkg -i` does not resolve transitive dependencies. After installing, run:
|
||||
|
||||
```bash
|
||||
sudo apt-get install -f --no-download
|
||||
```
|
||||
|
||||
The `--no-download` flag forces apt to use only what's in the local cache. If a dependency is genuinely missing, add it to the `apt --download-only` step on the connected machine and re-stage.
|
||||
|
||||
### Setup.sh Tries to Download Anyway
|
||||
|
||||
Confirm both skip flags are exported and that `INSTALL_SPINIFEX_TARBALL` points at a readable file:
|
||||
|
||||
```bash
|
||||
sudo INSTALL_SPINIFEX_TARBALL=/mnt/usb/tarball/spinifex-...tar.gz \
|
||||
INSTALL_SPINIFEX_SKIP_APT=1 \
|
||||
INSTALL_SPINIFEX_SKIP_AWS=1 \
|
||||
bash -x /mnt/usb/setup.sh
|
||||
```
|
||||
|
||||
### Init Telemetry Attempted
|
||||
|
||||
`spx admin init` posts a one-shot record to `https://install.mulgadc.com/install`. Pass `--no-telemetry` on every `init` and `join` invocation, or export `SPX_NO_TELEMETRY=1` in the operator's shell profile.
|
||||
|
||||
### Image Import Fails
|
||||
|
||||
`spx admin images import --file` requires the distro/version/arch flags so the image registers in the catalogue. If the import succeeds but `aws ec2 describe-images` returns empty, check `journalctl -u spinifex-daemon -f` for predastore upload errors.
|
||||
@@ -22,6 +22,7 @@ resources:
|
||||
## Table of Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [Instructions](#instructions)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
|
||||
@@ -31,12 +32,28 @@ resources:
|
||||
|
||||
A Spinifex cluster distributes services across multiple servers for high availability, data durability, and fault tolerance. Cluster formation is automatic — the init node waits for peers to join, then distributes credentials, CA certificates, and configuration.
|
||||
|
||||
**Network requirements:**
|
||||
**Network Requirements:**
|
||||
|
||||
- Minimum 1 NIC per server (2 recommended for production)
|
||||
- UDP port 6081 open between hosts (Geneve tunnels)
|
||||
- TCP ports 4222, 4248, 6641, 6642 open between hosts (NATS, OVN)
|
||||
|
||||
## Prerequisites
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Prerequisite — WAN bridge required on every node.**
|
||||
>
|
||||
> Before running the installer on any server, that server's WAN interface **must** already be enslaved to a Linux bridge named `br-wan`. The host IP, default route, and DHCP must all live on the bridge — not on the bare NIC.
|
||||
>
|
||||
> The bootstrap installer does **not** create this bridge for you yet. Running it on a host whose default route is still on a bare NIC will leave the install in a non-working state. Auto-provisioning of `br-wan` will land in the next release.
|
||||
>
|
||||
> **Verify on every node before continuing:**
|
||||
>
|
||||
> - `ip -br link show br-wan` — bridge exists and is `UP`
|
||||
> - `ip route` — default route's `dev` is `br-wan`
|
||||
>
|
||||
> **Setup references:** [VPC Networking → Bridge Setup](/docs/vpc-networking#bridge-setup-physical-network-wiring) for the topology.
|
||||
|
||||
## Instructions
|
||||
|
||||
## Step 1. Install Spinifex on Each Server
|
||||
@@ -59,7 +76,7 @@ export AWS_AZ=us-east-1a
|
||||
|
||||
## Step 3. Setup OVN Networking
|
||||
|
||||
Server 1 runs OVN central and must be set up first. If your WAN interface is already a bridge (e.g. `br-wan`), setup-ovn.sh auto-detects it. Otherwise use `--wan-bridge=br-wan --wan-iface=eth1` (dedicated WAN NIC) or `--macvlan --wan-iface=eth0` (single-NIC).
|
||||
Server 1 runs OVN central and must be set up first. If your WAN interface is already a bridge, setup-ovn.sh auto-detects it. Otherwise use `--wan-bridge=br-wan --wan-iface=eth1` (dedicated WAN NIC).
|
||||
|
||||
**Server 1:**
|
||||
|
||||
@@ -104,14 +121,24 @@ sudo spx admin init \
|
||||
--port 4432 --region $AWS_REGION --az $AWS_AZ
|
||||
```
|
||||
|
||||
Save the admin credentials — they will not be shown again.
|
||||
The init output displays the join command including the token:
|
||||
|
||||
```
|
||||
📡 Formation server started on 10.0.0.1:4432
|
||||
Waiting for 2 more node(s) to join...
|
||||
Token expires in 30m0s
|
||||
|
||||
Other nodes should run:
|
||||
sudo spx admin join --host 10.0.0.1:4432 --token spx_join_a8Bf3x9Kz2mN --node <name> --bind <ip>
|
||||
```
|
||||
|
||||
**Server 2 — Join** (while init is running):
|
||||
|
||||
```bash
|
||||
sudo spx admin join \
|
||||
--node node2 --bind $SPINIFEX_NODE2 --cluster-bind $SPINIFEX_NODE2 \
|
||||
--host $SPINIFEX_NODE1:4432 --region $AWS_REGION --az $AWS_AZ
|
||||
--host $SPINIFEX_NODE1:4432 --token <token-from-init-output> \
|
||||
--region $AWS_REGION --az $AWS_AZ
|
||||
```
|
||||
|
||||
**Server 3 — Join** (while init is running):
|
||||
@@ -119,9 +146,12 @@ sudo spx admin join \
|
||||
```bash
|
||||
sudo spx admin join \
|
||||
--node node3 --bind $SPINIFEX_NODE3 --cluster-bind $SPINIFEX_NODE3 \
|
||||
--host $SPINIFEX_NODE1:4432 --region $AWS_REGION --az $AWS_AZ
|
||||
--host $SPINIFEX_NODE1:4432 --token <token-from-init-output> \
|
||||
--region $AWS_REGION --az $AWS_AZ
|
||||
```
|
||||
|
||||
**Note:** The join token expires 30 minutes after init by default. For larger deployments with slower provisioning, use `--token-ttl 2h`
|
||||
|
||||
## Step 5. Start Services
|
||||
|
||||
On **all servers**:
|
||||
@@ -165,7 +195,7 @@ sudo ss -tlnp | grep 6642
|
||||
### CA Certificate Not Trusted
|
||||
|
||||
```bash
|
||||
sudo cp ~/spinifex/config/ca.pem /usr/local/share/ca-certificates/spinifex-ca.crt
|
||||
sudo cp /etc/spinifex/ca.pem /usr/local/share/ca-certificates/spinifex-ca.crt
|
||||
sudo update-ca-certificates
|
||||
```
|
||||
|
||||
@@ -31,8 +31,8 @@ This guide builds Spinifex from source. For production deployments, the [binary
|
||||
|
||||
**Requirements:**
|
||||
|
||||
- Ubuntu 22.04+ / Debian 12+
|
||||
- Go 1.26.2+
|
||||
- Ubuntu 26.04 / Debian 13
|
||||
- Go 1.26.3+
|
||||
- GCC, make, pkg-config
|
||||
- QEMU/KVM, OVN/Open vSwitch, AWS CLI v2, dhcpcd-base
|
||||
|
||||
@@ -52,46 +52,17 @@ export PATH=$PATH:/usr/local/go/bin
|
||||
```bash
|
||||
cd spinifex
|
||||
./scripts/clone-deps.sh
|
||||
./scripts/dev-setup.sh
|
||||
```
|
||||
|
||||
Confirm `./bin/spx` exists.
|
||||
|
||||
## Step 3. Setup OVN
|
||||
|
||||
If your WAN interface is already a bridge (e.g. `br-wan`), setup-ovn.sh auto-detects it:
|
||||
|
||||
```bash
|
||||
./scripts/setup-ovn.sh --management
|
||||
```
|
||||
|
||||
If your WAN is a physical NIC, choose one:
|
||||
|
||||
```bash
|
||||
# Dedicated WAN NIC (not your SSH connection):
|
||||
./scripts/setup-ovn.sh --management --wan-bridge=br-wan --wan-iface=eth1
|
||||
|
||||
# Single-NIC host (SSH-safe macvlan):
|
||||
./scripts/setup-ovn.sh --management --macvlan --wan-iface=enp0s3
|
||||
```
|
||||
|
||||
## Step 4. Development initialisation
|
||||
## Step 3. Development Initialisation
|
||||
|
||||
```bash
|
||||
./scripts/dev-install.sh
|
||||
```
|
||||
|
||||
Note, this will bootstrap a single node development environment
|
||||
This bootstraps a single-node development environment: builds binaries, configures OVN, initialises the cluster, installs the CA certificate, and starts all services.
|
||||
|
||||
## Step 5. Start services
|
||||
|
||||
```bash
|
||||
sudo systemctl status spinifex.target
|
||||
```
|
||||
|
||||
Start all services
|
||||
|
||||
## Step 6. Verify Installation
|
||||
## Step 4. Verify Installation
|
||||
|
||||
```bash
|
||||
export AWS_PROFILE=spinifex
|
||||
@@ -112,15 +83,9 @@ Continue to [Setting Up Your Cluster](/docs/setting-up-your-cluster) to import a
|
||||
export PATH=$PATH:/usr/local/go/bin
|
||||
```
|
||||
|
||||
### ./bin/spx Missing After Build
|
||||
|
||||
```bash
|
||||
./scripts/dev-setup.sh
|
||||
```
|
||||
|
||||
### CA Certificate Not Trusted
|
||||
|
||||
```bash
|
||||
sudo cp ~/spinifex/config/ca.pem /usr/local/share/ca-certificates/spinifex-ca.crt
|
||||
sudo cp /etc/spinifex/ca.pem /usr/local/share/ca-certificates/spinifex-ca.crt
|
||||
sudo update-ca-certificates
|
||||
```
|
||||
@@ -0,0 +1,135 @@
|
||||
---
|
||||
title: "Bootable USB Install"
|
||||
description: "Install Spinifex on bare-metal hardware by flashing the Spinifex ISO to a USB drive and booting the target device from it."
|
||||
category: "Getting Started"
|
||||
sections:
|
||||
- overview
|
||||
- troubleshooting
|
||||
tags:
|
||||
- install
|
||||
- usb
|
||||
- iso
|
||||
- bare-metal
|
||||
resources:
|
||||
- title: "Spinifex ISO"
|
||||
url: "https://iso.mulgadc.com/spinifex.iso"
|
||||
- title: "Balena Etcher"
|
||||
url: "https://etcher.balena.io"
|
||||
- title: "Spinifex Repository"
|
||||
url: "https://github.com/mulgadc/spinifex"
|
||||
---
|
||||
|
||||
# Installing Spinifex from Bootable USB
|
||||
|
||||
> Install Spinifex on bare-metal hardware by flashing the Spinifex ISO to a USB drive and booting the target device from it.
|
||||
|
||||
## Overview
|
||||
|
||||
Spinifex is designed for bare-metal hardware, edge nodes and data-centre use. Follow this guide to install Spinifex from a bootable USB using the Spinifex ISO.
|
||||
|
||||
**Note:** this tutorial is for x86 architecture.
|
||||
|
||||
**Warning:** this procedure COMPLETELY WIPES the target disk. For systems with multiple disks, ensure the correct one is targeted.
|
||||
|
||||
### Booting Media
|
||||
|
||||
For this tutorial, a USB drive with at least 8GB of memory is required.
|
||||
|
||||
**Note:** flashing the Spinifex ISO onto the USB completely wipes the USB, so ensure no important data is stored on the USB used.
|
||||
|
||||
### Balena Etcher Installation
|
||||
|
||||
For this tutorial download Balena Etcher to simplify the ISO flash process.
|
||||
|
||||
- [Balena Etcher](https://etcher.balena.io)
|
||||
|
||||
### Download Spinifex ISO
|
||||
|
||||
Download the Spinifex ISO (x86)
|
||||
|
||||
- [Spinifex ISO](https://iso.mulgadc.com/spinifex.iso)
|
||||
|
||||
### Flash Media
|
||||
|
||||
Once installed, open Balena Etcher. Select "Flash From Image," then select the downloaded `spinifex.iso` file.
|
||||
|
||||
Next, click "Select target" and choose the USB drive to be used as the boot media. Then click "Flash!"
|
||||
|
||||
Balena Etcher will now flash the USB drive with the `spinifex.iso` file.
|
||||
|
||||
<img src="../../../.github/assets/images/balena-complete.png" alt="Balena complete">
|
||||
|
||||
You can now safely eject the USB drive if it was not ejected automatically.
|
||||
|
||||
### Boot From USB Drive
|
||||
|
||||
Insert your newly flashed USB drive into the target device and turn it on.
|
||||
|
||||
<img src="../../../.github/assets/images/asus-box-new.png" alt="ASUS Box">
|
||||
|
||||
As it boots, quickly press the correct key for your device to bring up the BIOS/UEFI menu (commonly F2, F10, F12, ESC or DEL) and change the boot order such that the flashed USB drive has first priority, then continue to boot.
|
||||
|
||||
If done successfully, the Spinifex ISO GRUB menu will appear.
|
||||
|
||||
<img src="../../../.github/assets/images/grub-new.png" alt="GRUB menu">
|
||||
|
||||
From this menu you can select which method of install is used (console recommended). Headless mode can be configured by mounting the USB on a host device after flashing and editing the `grub.cfg` file with the desired values.
|
||||
|
||||
### Setting Up the Spinifex Node
|
||||
|
||||
In console mode, follow the installation prompts to set the required networking values for the Spinifex node.
|
||||
|
||||
The installer will default to the most sensible disk to install on depending on system configuration — ensure this default is correct before installation, as the process will wipe the disk.
|
||||
|
||||
For network configuration, it is recommended to use automatic IP (DHCP), but this can also be configured manually.
|
||||
|
||||
Network interfaces will be automatically detected. In the event that none are detected, the user can manually input the name of the network interface. In the event that multiple network interfaces are detected, the installer will prompt for WAN selection first, followed by LAN.
|
||||
|
||||
A hostname (eg `node1`) and admin password must be set, followed by the "cluster role" — whether the node is the first in a new cluster, or joining an already existing one.
|
||||
|
||||
Once configuration is complete, a summary of the configuration will be shown.
|
||||
|
||||
<img src="../../../.github/assets/images/installer-complete.png" alt="Installer complete">
|
||||
|
||||
The installer will then complete the installation of Spinifex onto the target device. Once complete, remove the USB drive from the device before automatic reboot.
|
||||
|
||||
Once the USB drive is removed, press enter or wait for the auto-reboot.
|
||||
|
||||
### Log In
|
||||
|
||||
The device will reboot and spend ~2-5 minutes configuring the Spinifex node. The user will then be prompted to log in. Use the following credentials for login:
|
||||
|
||||
- Login: `spinifex`
|
||||
- Password: Set by user during installation
|
||||
|
||||
Both before and after login, a banner will be printed specifying important information, such as details for SSH into the node and accessing the web dashboard for further configuration of the node.
|
||||
|
||||
<img src="../../../.github/assets/images/banner1.png" alt="banner">
|
||||
|
||||
### Setup Complete
|
||||
|
||||
**Congratulations! Spinifex is installed.**
|
||||
|
||||
Continue to [Setting Up Your Cluster](/docs/setting-up-your-cluster) to import an AMI, create a VPC, and launch your first instance.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Can't Access BIOS/UEFI to Change Boot Order
|
||||
|
||||
It can be difficult to get into the BIOS/UEFI — there is only a short window to press the correct key, and this key changes depending on the manufacturer. Search online for your device's BIOS/UEFI key, and press it rapidly as the device boots.
|
||||
|
||||
### GRUB Menu Not Appearing
|
||||
|
||||
Once in the BIOS/UEFI menu, ensure the correct boot order is set. First boot priority should be set to the USB drive flashed with the ISO — if the USB doesn't appear in the BIOS/UEFI menu, ensure it has been flashed with the ISO correctly. Take note of the name and storage capacity of the USB when flashing, as this should match what appears in the BIOS/UEFI.
|
||||
|
||||
### Networking Issues
|
||||
|
||||
For a Spinifex node to be properly provisioned, the target device must have at least one NIC.
|
||||
|
||||
Spinifex uses DHCP to assign instances within a node their public IP addresses. Instead of a static range, public IPs come from the upstream router's DHCP server. When a VM launches, Spinifex requests a DHCP lease from the router on behalf of the VM. When the VM terminates, the lease is released.
|
||||
|
||||
The VM itself never talks to the router's DHCP — it only sees its private VPC IP (from OVN's internal DHCP). The host-side DHCP conversation is invisible to the guest.
|
||||
|
||||
**Use when:** You don't control a static IP block but the router's DHCP server has enough leases. Homelabs where you don't want to carve out a range. Environments where IPs are managed centrally by the network team's DHCP.
|
||||
|
||||
For further troubleshooting suggestions, refer to the [VPC Networking](/docs/vpc-networking) guide.
|
||||
@@ -22,6 +22,7 @@ resources:
|
||||
## Table of Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [Instructions](#instructions)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
|
||||
@@ -29,14 +30,14 @@ resources:
|
||||
|
||||
## Overview
|
||||
|
||||
Spinifex is an open-source infrastructure platform that brings core AWS services — EC2, VPC, EBS, and S3 — to bare-metal, edge, and on-prem environments.
|
||||
Spinifex is an open-source infrastructure platform that brings core AWS services to bare-metal, edge, and on-prem environments.
|
||||
|
||||
This guide installs Spinifex on a single server using the binary installer. For multi-server clusters, see [Multi-Node Install](/docs/install-multi-node). To build from source, see [Source Install](/docs/install-source).
|
||||
|
||||
**Supported Operating Systems:**
|
||||
|
||||
- Ubuntu 22.04 / 24.04 / 25.10
|
||||
- Debian 12 / 13
|
||||
- Ubuntu 26.04
|
||||
- Debian 13
|
||||
|
||||
**What Gets Installed:**
|
||||
|
||||
@@ -47,6 +48,22 @@ This guide installs Spinifex on a single server using the binary installer. For
|
||||
- Viperblock (EBS-compatible block storage)
|
||||
- AWS CLI v2
|
||||
|
||||
## Prerequisites
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Prerequisite — WAN bridge required.**
|
||||
>
|
||||
> Before running the installer, the host's WAN interface **must** already be enslaved to a Linux bridge named `br-wan`. The host IP, default route, and DHCP must all live on the bridge — not on the bare NIC.
|
||||
>
|
||||
> The bootstrap installer does **not** create this bridge for you yet. Running it on a host whose default route is still on a bare NIC will leave the install in a non-working state. Auto-provisioning of `br-wan` will land in the next release.
|
||||
>
|
||||
> **Verify before continuing:**
|
||||
>
|
||||
> - `ip -br link show br-wan` — bridge exists and is `UP`
|
||||
> - `ip route` — default route's `dev` is `br-wan`
|
||||
>
|
||||
> **Setup references:** [VPC Networking → Bridge Setup](/docs/vpc-networking#bridge-setup-physical-network-wiring) for the topology.
|
||||
|
||||
## Instructions
|
||||
|
||||
## Step 1. Install Spinifex
|
||||
@@ -65,10 +82,9 @@ If your WAN interface is already a bridge (e.g. `br-wan`), setup-ovn.sh auto-det
|
||||
sudo /usr/local/share/spinifex/setup-ovn.sh --management
|
||||
```
|
||||
|
||||
If your WAN is a physical NIC, choose one:
|
||||
If your WAN is a physical NIC:
|
||||
|
||||
```bash
|
||||
# Dedicated WAN NIC (not your SSH connection):
|
||||
sudo /usr/local/share/spinifex/setup-ovn.sh --management --wan-bridge=br-wan --wan-iface=eth1
|
||||
```
|
||||
|
||||
@@ -86,7 +102,7 @@ This is recommended for production and required in multi-node deployments. See [
|
||||
sudo spx admin init --node node1 --nodes 1
|
||||
```
|
||||
|
||||
This auto-detects your network topology, generates configuration and TLS certificates, installs the CA into the system trust store, and configures AWS CLI credentials. Save the admin credentials printed during init — they will not be shown again.
|
||||
This auto-detects your network topology, generates configuration and TLS certificates, installs the CA into the system trust store, and configures AWS CLI credentials (saved in `~/.aws/credentials`)
|
||||
|
||||
## Step 4. Start Services
|
||||
|
||||
@@ -34,7 +34,7 @@ resources:
|
||||
## Overview
|
||||
|
||||
This guide assumes Spinifex is already installed and running. If not, follow one of the installation guides first:
|
||||
- [Binary Install (Single Node)](/docs/install)
|
||||
- [Single-Node Install)](/docs/install)
|
||||
- [Multi-Node Install](/docs/install-multi-node)
|
||||
- [Source Install](/docs/install-source)
|
||||
|
||||
@@ -57,23 +57,23 @@ spx admin images list
|
||||
```
|
||||
|
||||
```
|
||||
NAME | DISTRO | VERSION | ARCH | BOOT
|
||||
debian-12-arm64 | debian | 12 | arm64 | bios
|
||||
debian-12-x86_64 | debian | 12 | x86_64 | bios
|
||||
ubuntu-24.04-arm64 | ubuntu | 24.04 | arm64 | bios
|
||||
ubuntu-24.04-x86_64 | ubuntu | 24.04 | x86_64 | bios
|
||||
NAME | DISTRO | VERSION | ARCH | BOOT
|
||||
debian-13-arm64 | debian | 13 | arm64 | bios
|
||||
debian-13-x86_64 | debian | 13 | x86_64 | bios
|
||||
ubuntu-26.04-arm64 | ubuntu | 26.04 | arm64 | bios
|
||||
ubuntu-26.04-x86_64 | ubuntu | 26.04 | x86_64 | bios
|
||||
```
|
||||
|
||||
Import an image:
|
||||
|
||||
```bash
|
||||
spx admin images import --name ubuntu-24.04-x86_64
|
||||
spx admin images import --name ubuntu-26.04-x86_64
|
||||
```
|
||||
|
||||
Or import a local image file:
|
||||
|
||||
```bash
|
||||
spx admin images import --file ~/images/ubuntu-24.04.img --distro ubuntu --version 24.04 --arch x86_64
|
||||
spx admin images import --file ~/images/ubuntu-26.04.img --distro ubuntu --version 26.04 --arch x86_64
|
||||
```
|
||||
|
||||
Verify the import and note the AMI ID:
|
||||
@@ -172,7 +172,7 @@ Launch an instance in the public subnet, note this will select an instance type
|
||||
> **Note:** Replace `AMI_NAME` with the previously imported image above with the `ami-` prefix.
|
||||
|
||||
```bash
|
||||
AMI_NAME="ami-ubuntu-24.04-x86_64"
|
||||
AMI_NAME="ami-ubuntu-26.04-x86_64"
|
||||
|
||||
AMI_ID=$(aws ec2 describe-images \
|
||||
--filters "Name=name,Values=$AMI_NAME" \
|
||||
@@ -272,7 +272,9 @@ spx get vms
|
||||
|
||||
Spinifex ships with a built-in web console — an alternative to the AWS CLI, SDKs, and Terraform, analogous to the AWS Management Console. Every action in the UI is a standard AWS SigV4 API call, so the same IAM policies and audit behaviour apply.
|
||||
|
||||

|
||||
<p align="center">
|
||||
<img src="../../../.github/assets/spinifex-ui.jpg" alt="Spinifex Web UI" width="900">
|
||||
</p>
|
||||
|
||||
### Open the Console
|
||||
|
||||
@@ -342,7 +344,7 @@ export AWS_PROFILE=spinifex-myteam
|
||||
### Instance Stuck in Pending
|
||||
|
||||
```bash
|
||||
cat ~/spinifex/logs/spinifex.log
|
||||
journalctl -u spinifex-daemon -f
|
||||
aws ec2 describe-images
|
||||
```
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
title: "Updating Spinifex"
|
||||
description: "Upgrade an existing Spinifex installation to the latest release."
|
||||
category: "Getting Started"
|
||||
category: "Administration"
|
||||
tags:
|
||||
- update
|
||||
- upgrade
|
||||
@@ -10,7 +10,7 @@ resources:
|
||||
- title: "Spinifex Repository"
|
||||
url: "https://github.com/mulgadc/spinifex"
|
||||
- title: "Single-Node Install"
|
||||
url: "https://docs.mulgadc.com/docs/install"
|
||||
url: "/docs/install"
|
||||
---
|
||||
|
||||
# Updating Spinifex
|
||||
@@ -1,131 +0,0 @@
|
||||
---
|
||||
title: "Air-Gapped Install"
|
||||
description: "Deploy Spinifex in environments without internet connectivity. Covers offline package caching, USB deployment, and package verification."
|
||||
category: "Getting Started"
|
||||
tags:
|
||||
- install
|
||||
- air-gapped
|
||||
- offline
|
||||
resources:
|
||||
- title: "Spinifex Repository"
|
||||
url: "https://github.com/mulgadc/spinifex"
|
||||
- title: "apt-cacher-ng"
|
||||
url: "https://wiki.debian.org/AptCacherNg"
|
||||
---
|
||||
|
||||
# Air-Gapped Installation
|
||||
|
||||
> Deploy Spinifex in environments without internet connectivity.
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Instructions](#instructions)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
In air-gapped or disconnected environments, Spinifex can be deployed without internet access. This guide covers preparing offline packages on a connected machine, creating USB deployment media, and installing on the target server with package verification.
|
||||
|
||||
**Deployment methods:**
|
||||
- **apt-cacher-ng** — Local APT cache for Debian/Ubuntu packages
|
||||
- **Go module cache** — Pre-downloaded Go dependencies
|
||||
- **USB deployment** — Portable installation media with all dependencies
|
||||
- **GPG verification** — Cryptographic package integrity checks
|
||||
|
||||
## Instructions
|
||||
|
||||
## Step 1. Prepare Packages (on a connected machine)
|
||||
|
||||
Set up a local APT cache to collect all required packages:
|
||||
|
||||
```bash
|
||||
sudo apt install apt-cacher-ng
|
||||
sudo systemctl enable --now apt-cacher-ng
|
||||
```
|
||||
|
||||
Pre-download all Spinifex dependencies:
|
||||
|
||||
```bash
|
||||
sudo apt install --download-only nbdkit nbdkit-plugin-dev pkg-config \
|
||||
qemu-system qemu-utils qemu-kvm libvirt-daemon-system \
|
||||
libvirt-clients libvirt-dev make gcc unzip xz-utils file \
|
||||
ovn-central ovn-host openvswitch-switch
|
||||
```
|
||||
|
||||
## Step 2. Create USB Deployment Media
|
||||
|
||||
```bash
|
||||
mkdir -p /media/spinifex-deploy/{apt-packages,go-cache,spinifex-source}
|
||||
cp /var/cache/apt/archives/*.deb /media/spinifex-deploy/apt-packages/
|
||||
cp -r ~/go/pkg/mod/cache/ /media/spinifex-deploy/go-cache/
|
||||
cp -r ~/Development/mulga/spinifex /media/spinifex-deploy/spinifex-source/
|
||||
```
|
||||
|
||||
## Step 3. Verify Package Integrity
|
||||
|
||||
Sign and verify packages before transferring to the target:
|
||||
|
||||
```bash
|
||||
gpg --import /media/spinifex-deploy/mulga-signing-key.asc
|
||||
gpg --verify spinifex-v1.0.tar.gz.sig spinifex-v1.0.tar.gz
|
||||
sha256sum -c /media/spinifex-deploy/checksums.sha256
|
||||
```
|
||||
|
||||
## Step 4. Install on the Target Server
|
||||
|
||||
Mount the USB and install:
|
||||
|
||||
```bash
|
||||
sudo mount /dev/sdb1 /mnt/usb
|
||||
sudo dpkg -i /mnt/usb/apt-packages/*.deb
|
||||
cp -r /mnt/usb/spinifex-source ~/Development/mulga/spinifex
|
||||
cd ~/Development/mulga/spinifex && make build
|
||||
```
|
||||
|
||||
Then follow the [Source Install](/docs/install-source) guide from Step 3 (Setup OVN) onwards.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Missing Dependencies After dpkg Install
|
||||
|
||||
Some packages may have unresolved dependencies. Fix with:
|
||||
|
||||
```bash
|
||||
sudo dpkg -i /mnt/usb/apt-packages/*.deb
|
||||
sudo apt-get install -f
|
||||
```
|
||||
|
||||
The `-f` flag tells apt to fix broken dependencies using what's available locally.
|
||||
|
||||
### GPG Verification Fails
|
||||
|
||||
The signing key may not match or the download was corrupted. Re-import the key and verify:
|
||||
|
||||
```bash
|
||||
gpg --import /media/spinifex-deploy/mulga-signing-key.asc
|
||||
gpg --verify spinifex-v1.0.tar.gz.sig spinifex-v1.0.tar.gz
|
||||
```
|
||||
|
||||
If verification still fails, re-download the package on the connected machine and compare checksums:
|
||||
|
||||
```bash
|
||||
sha256sum spinifex-v1.0.tar.gz
|
||||
```
|
||||
|
||||
### Go Module Cache Errors
|
||||
|
||||
The Go module cache may have been incompletely copied. On the connected machine, re-export the full cache:
|
||||
|
||||
```bash
|
||||
cp -r ~/go/pkg/mod/cache/ /media/spinifex-deploy/go-cache/
|
||||
```
|
||||
|
||||
On the target, restore it:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/go/pkg/mod/
|
||||
cp -r /mnt/usb/go-cache ~/go/pkg/mod/cache
|
||||
```
|
||||
@@ -0,0 +1,310 @@
|
||||
# Installing Spinifex in a VM (MacOS)
|
||||
|
||||
For evaluation / quick POC use only.
|
||||
|
||||
Spinifex is designed for bare-metal hardware, edge nodes and data-centre use. However, follow this guide to install Spinifex on a MacOS VM to demonstrate the capabilities (dev/testing only).
|
||||
|
||||
Note this tutorial is for Apple Silicon (ARM64)
|
||||
|
||||
## UTM installation
|
||||
|
||||
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)
|
||||
|
||||
* Debian 13.4 - [https://cdimage.debian.org/debian-cd/current/arm64/iso-cd/debian-13.4.0-arm64-netinst.iso](https://cdimage.debian.org/debian-cd/current/arm64/iso-cd/debian-13.4.0-arm64-netinst.iso)
|
||||
|
||||
## Login
|
||||
|
||||
Using the VM console, login using the `spinifex` user and your specified password. A few additional steps are required via the console, before you can SSH into the machine for easier administration.
|
||||
|
||||
## Install prerequisites
|
||||
|
||||
For a minimal Debian install additional dependencies are required prior to the Spinifex installation:
|
||||
|
||||
```
|
||||
su root
|
||||
apt update
|
||||
apt install sudo curl bridge-utils
|
||||
```
|
||||
|
||||
### Enable sudoers
|
||||
|
||||
Enable sudo for `spinifex` user as the root user:
|
||||
|
||||
```
|
||||
su root
|
||||
echo "spinifex ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/spinifex
|
||||
```
|
||||
|
||||
## Networking
|
||||
|
||||
Spinifex requires a Linux bridge for networking to correctly function, given EC2 instances and resources can obtain an IP address from the upstream DHCP server or a static public IP address range you specify.
|
||||
|
||||
This is a requirement step for valid installation.
|
||||
|
||||
View the existing `/etc/network/interfaces` for the network configuration.
|
||||
|
||||
```sh
|
||||
# This file describes the network interfaces available on your system
|
||||
# and how to activate them. For more information, see interfaces(5).
|
||||
|
||||
source /etc/network/interfaces.d/*
|
||||
|
||||
# The loopback network interface
|
||||
auto lo
|
||||
iface lo inet loopback
|
||||
|
||||
# The primary network interface
|
||||
allow-hotplug enp0s1
|
||||
iface enp0s1 inet dhcp
|
||||
# This is an autoconfigured IPv6 interface
|
||||
iface enp0s1 inet6 auto
|
||||
```
|
||||
|
||||
Edit `/etc/network/interfaces` note replace `enp0s1` with your network adapter.
|
||||
|
||||
```sh
|
||||
# This file describes the network interfaces available on your system
|
||||
# and how to activate them. For more information, see interfaces(5).
|
||||
|
||||
source /etc/network/interfaces.d/*
|
||||
|
||||
# The loopback network interface
|
||||
auto lo
|
||||
iface lo inet loopback
|
||||
|
||||
# The primary network interface - toggle to manual
|
||||
allow-hotplug enp0s1
|
||||
iface enp0s1 inet manual
|
||||
# This is an autoconfigured IPv6 interface - toggle to manual
|
||||
iface enp0s1 inet6 manual
|
||||
|
||||
# Spinifex Config
|
||||
# WAN bridge (Spinifex EC2 instances connect here)
|
||||
# VMs (EC2) / containers <-> br-wan <-> enp0s1 <-> external network
|
||||
auto br-wan
|
||||
iface br-wan inet dhcp
|
||||
bridge_ports enp0s1
|
||||
bridge_stp off
|
||||
bridge_fd 0
|
||||
bridge_maxwait 0
|
||||
```
|
||||
|
||||
Walkthrough of changes:
|
||||
|
||||
* Adds eno3 into the bridge
|
||||
* eno3 becomes a slave interface
|
||||
* All traffic goes through br-wan
|
||||
* Spinifex EC2 (VMs) in a public subnet, obtain an IP address from the WAN upstream
|
||||
|
||||
### Restart networking
|
||||
|
||||
Make sure you are logged into the console, not via SSH for these steps. Otherwise you will lose networking to the instance.
|
||||
|
||||
Replace `enp0s1` with your network adapter.
|
||||
|
||||
```bash
|
||||
ifdown enp0s1
|
||||
systemctl restart networking
|
||||
```
|
||||
|
||||
Next, retrieve the IP address of the new `br-wan` bridge from your upstream DHCP server.
|
||||
|
||||
```bash
|
||||
ip a show dev enp0s1
|
||||
```
|
||||
|
||||
```bash
|
||||
6: br-wan: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default qlen 1000
|
||||
link/ether 12:c2:bc:f3:f6:7b brd ff:ff:ff:ff:ff:ff
|
||||
inet 192.168.0.249/24 brd 192.168.0.255 scope global dynamic noprefixroute br-wan
|
||||
valid_lft 86103sec preferred_lft 75303sec
|
||||
inet6 fd0d:9d60:d09b:0:ee77:630d:3237:9a10/64 scope global dynamic mngtmpaddr noprefixroute
|
||||
valid_lft 86282sec preferred_lft 86282sec
|
||||
inet6 fe80::85c0:f2aa:f0bf:e951/64 scope link
|
||||
valid_lft forever preferred_lft forever
|
||||
```
|
||||
|
||||
Note the IP `192.168.0.249`
|
||||
|
||||
# Spinifex Installation
|
||||
|
||||
## Launch installer
|
||||
|
||||
Next, optionally SSH into your instance (using the IP above) or continue using the console.
|
||||
|
||||
Replace with your IP below.
|
||||
|
||||
```
|
||||
ssh spinifex@<your-ip>
|
||||
```
|
||||
|
||||
As the `spinifex` user launch the binary installer:
|
||||
|
||||
```bash
|
||||
su spinifex
|
||||
curl -fsSL https://install.mulgadc.com | bash
|
||||
```
|
||||
|
||||
```bash
|
||||
[INFO] Spinifex installer
|
||||
|
||||
[INFO] Detected OS: Debian GNU/Linux 13 (trixie)
|
||||
[INFO] Detected architecture: aarch64 (arm64)
|
||||
[INFO] Installing system dependencies...
|
||||
[INFO] System dependencies installed
|
||||
[INFO] Installing AWS CLI v2...
|
||||
[INFO] AWS CLI installed: aws-cli/2.34.30 Python/3.14.4 Linux/6.12.74+deb13+1-arm64 exe/aarch64.debian.13
|
||||
[INFO] Service users created (spinifex-{nats,gw,daemon,storage,viperblock,vpcd,ui})
|
||||
/etc/sudoers.d/spinifex-network: parsed OK
|
||||
[INFO] Scoped sudoers rules installed for spinifex-daemon and spinifex-vpcd
|
||||
[INFO] Downloading Spinifex (latest channel, arm64)...
|
||||
[INFO] Verifying checksum...
|
||||
[INFO] Checksum verified
|
||||
[INFO] Extracting...
|
||||
[INFO] Installing files...
|
||||
[INFO] /usr/local/bin/spx
|
||||
[INFO] /usr/lib/aarch64-linux-gnu/nbdkit/plugins/nbdkit-viperblock-plugin.so
|
||||
[INFO] /usr/local/share/spinifex/setup-ovn.sh
|
||||
[INFO] Creating directories...
|
||||
[INFO] Generated /etc/spinifex/systemd.env
|
||||
[INFO] /var/lib/spinifex/predastore-start.sh
|
||||
[INFO] /var/lib/spinifex/wait-for-nats.sh
|
||||
[INFO] Fixing file ownership for privilege separation...
|
||||
[INFO] File ownership updated
|
||||
[INFO] Installing systemd units...
|
||||
[INFO] /etc/systemd/system/spinifex-awsgw.service
|
||||
[INFO] /etc/systemd/system/spinifex-daemon.service
|
||||
[INFO] /etc/systemd/system/spinifex-nats.service
|
||||
[INFO] /etc/systemd/system/spinifex-predastore.service
|
||||
[INFO] /etc/systemd/system/spinifex.target
|
||||
[INFO] /etc/systemd/system/spinifex-ui.service
|
||||
[INFO] /etc/systemd/system/spinifex-viperblock.service
|
||||
[INFO] /etc/systemd/system/spinifex-vpcd.service
|
||||
Created symlink '/etc/systemd/system/multi-user.target.wants/spinifex.target' → '/etc/systemd/system/spinifex.target'.
|
||||
[INFO] Systemd units installed and enabled (per-service users)
|
||||
[INFO] Fresh install detected, skipping migrations
|
||||
[INFO] Logrotate config installed
|
||||
|
||||
============================================
|
||||
Spinifex installed successfully
|
||||
============================================
|
||||
|
||||
Version: spinifex v1.1.0 (b7b6390) linux/arm64
|
||||
Architecture: arm64
|
||||
Service users: spinifex-{nats,gw,daemon,storage,viperblock,vpcd,ui}
|
||||
Binary: /usr/local/bin/spx
|
||||
Config: /etc/spinifex/
|
||||
Data: /var/lib/spinifex/
|
||||
Logs: /var/log/spinifex/
|
||||
```
|
||||
|
||||
## Setup OVN networking
|
||||
|
||||
Since the WAN interface is already a bridge (e.g. br-wan) run the Openvswitch (OVN) install script:
|
||||
|
||||
```bash
|
||||
sudo /usr/local/share/spinifex/setup-ovn.sh --management
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```bash
|
||||
Auto-detected Linux bridge: br-wan (default route)
|
||||
Will create OVS bridge br-ext + veth pair to link them
|
||||
Auto-detected encap IP: 192.168.0.249
|
||||
=== Spinifex OVN Compute Node Setup ===
|
||||
Management node: true
|
||||
WAN bridge: br-ext (veth)
|
||||
Linux bridge: br-wan (linked via veth pair)
|
||||
OVN Remote (SB): tcp:127.0.0.1:6642
|
||||
Encap IP: 192.168.0.249
|
||||
...
|
||||
system-id: "add0104e-9aec-43f0-af53-5fc89b4d91bf"
|
||||
...
|
||||
```
|
||||
|
||||
The `system-id` is the OVS-managed chassis UUID (read back from
|
||||
`/etc/openvswitch/system-id.conf`); this is the same value that appears as
|
||||
`Chassis.name` in `ovn-sbctl show`. Cross-reference with `Chassis.hostname`
|
||||
when you need the human-readable host:
|
||||
|
||||
```bash
|
||||
sudo ovn-sbctl --columns=name,hostname list Chassis
|
||||
```
|
||||
|
||||
## Spinifex Initialize
|
||||
|
||||
```bash
|
||||
sudo spx admin init --node node1 --nodes 1
|
||||
```
|
||||
|
||||
## Start services
|
||||
|
||||
```bash
|
||||
sudo systemctl start spinifex.target
|
||||
```
|
||||
|
||||
## Verify installation
|
||||
|
||||
Connect to the local AWS gateway and issue a sample command using the AWS CLI tool, all running on your local infrastructure, no cloud required.
|
||||
|
||||
```bash
|
||||
export AWS_PROFILE=spinifex
|
||||
aws ec2 describe-instance-types
|
||||
```
|
||||
|
||||
> ***Note:*** The system may require ~30 seconds to initalise prior to accepting connections for the command to complete above.
|
||||
|
||||
Output:
|
||||
|
||||
```json
|
||||
{
|
||||
"InstanceTypes": [
|
||||
{
|
||||
"InstanceType": "t4g.nano",
|
||||
"CurrentGeneration": true,
|
||||
"SupportedRootDeviceTypes": [
|
||||
"ebs"
|
||||
],
|
||||
"SupportedVirtualizationTypes": [
|
||||
"hvm"
|
||||
],
|
||||
"Hypervisor": "kvm",
|
||||
"ProcessorInfo": {
|
||||
"SupportedArchitectures": [
|
||||
"arm64"
|
||||
]
|
||||
},
|
||||
"VCpuInfo": {
|
||||
"DefaultVCpus": 2
|
||||
},
|
||||
"MemoryInfo": {
|
||||
"SizeInMiB": 512
|
||||
},
|
||||
"PlacementGroupInfo": {
|
||||
"SupportedStrategies": [
|
||||
"cluster",
|
||||
"spread"
|
||||
]
|
||||
},
|
||||
"BurstablePerformanceSupported": true
|
||||
},
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
# Installation Complete
|
||||
|
||||
Congratulations! 🥳
|
||||
|
||||
Your installation of Spinifex is now complete on a VM running on a Mac.
|
||||
|
||||
# Next steps
|
||||
@@ -0,0 +1,153 @@
|
||||
---
|
||||
title: "Flaw Remediation Policy"
|
||||
description: "CVSS-tiered SLAs for identifying, reporting, and correcting software flaws in Spinifex"
|
||||
category: "Security"
|
||||
sections:
|
||||
- overview
|
||||
tags:
|
||||
- security
|
||||
- compliance
|
||||
- cmmc
|
||||
- vulnerabilities
|
||||
- cvss
|
||||
- patching
|
||||
resources:
|
||||
- title: "NIST SP 800-171 Rev 3"
|
||||
url: "https://csrc.nist.gov/pubs/sp/800/171/r3/final"
|
||||
- title: "CMMC Level 1 Self-Assessment Guide v2.0"
|
||||
url: "https://dodcio.defense.gov/CMMC/Documentation"
|
||||
- title: "FIRST CVSS v3.1 Specification"
|
||||
url: "https://www.first.org/cvss/v3-1/specification-document"
|
||||
- title: "Go govulncheck"
|
||||
url: "https://pkg.go.dev/golang.org/x/vuln/cmd/govulncheck"
|
||||
- title: "GitHub Dependabot"
|
||||
url: "https://docs.github.com/en/code-security/dependabot"
|
||||
---
|
||||
|
||||
# Flaw Remediation Policy
|
||||
|
||||
> CVSS-tiered SLAs for identifying, reporting, and correcting software flaws in Spinifex
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [CMMC Practices Covered](#cmmc-practices-covered)
|
||||
- [Approach](#approach)
|
||||
- [1. Identification](#1-identification)
|
||||
- [2. Severity](#2-severity)
|
||||
- [3. Remediation SLAs](#3-remediation-slas)
|
||||
- [4. Reporting Workflow](#4-reporting-workflow)
|
||||
- [5. Operator Responsibilities](#5-operator-responsibilities)
|
||||
- [6. Evidence](#6-evidence)
|
||||
- [7. Operator Checklist](#7-operator-checklist)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
**Audience:** Spinifex maintainers who publish releases, and operators deploying Spinifex into CMMC Level 1 environments.
|
||||
|
||||
**Scope:** Flaws in Spinifex's own code and its direct dependencies (Go modules, UI npm packages, GitHub Actions). Operating system, kernel, OVN/OVS, and hypervisor CVEs are the operator's responsibility — see [§5](#5-operator-responsibilities).
|
||||
|
||||
## CMMC Practices Covered
|
||||
|
||||
| Practice | Title | Objective |
|
||||
|----------|-------|-----------|
|
||||
| SI.L1-3.14.1 | Flaw Remediation | [a] Time to identify flaws is specified. [b] Flaws identified in time. [c] Time to report flaws is specified. [d] Flaws reported in time. [e] Time to correct flaws is specified. [f] Flaws corrected in time. |
|
||||
|
||||
## Approach
|
||||
|
||||
Spinifex ships a short dependency chain and identification is already automated: Dependabot raises security PRs the moment GitHub links a CVE to a shipped dependency, `govulncheck` runs on every PR, and GitHub Security Advisories watch all three `mulgadc/*` repositories.
|
||||
|
||||
Sections map to the three scored objectives: [§1](#1-identification) names the identification sources (objectives [a]/[b]); [§3](#3-remediation-slas) and [§4](#4-reporting-workflow) set the report and correct timeframes (objectives [c]–[f]). [§5](#5-operator-responsibilities) delineates layers the operator owns, and [§6](#6-evidence) covers the records needed to demonstrate compliance at assessment.
|
||||
|
||||
## 1. Identification
|
||||
|
||||
| Source | Frequency | Coverage |
|
||||
|--------|-----------|----------|
|
||||
| Dependabot security updates | Immediate — raised as soon as GitHub links an advisory to a dependency | Go modules, GitHub Actions, UI npm packages |
|
||||
| Dependabot version updates | Weekly | Routine version bumps (non-security) |
|
||||
| `govulncheck` (CI `lint_and_security` job) | Every PR and push to `main` | Go stdlib + module CVEs reachable in the binary |
|
||||
| GitHub Security Advisories | Continuous | `mulgadc/spinifex`, `mulgadc/predastore`, `mulgadc/viperblock` |
|
||||
| Third-party reports (sensitive) | Ad hoc | [GitHub private vulnerability reporting](https://github.com/mulgadc/spinifex/security/advisories/new) |
|
||||
| Third-party reports (non-sensitive) | Ad hoc | Public GitHub issue, labelled `security` |
|
||||
| Internal discovery | Ad hoc | Review, audit, red team, incident response |
|
||||
|
||||
Dependabot security PRs are merged immediately once CI passes — for dependency CVEs this collapses identify, report, and correct into a single action. The separate weekly cadence applies only to non-security version bumps.
|
||||
|
||||
Reporters: use private vulnerability reporting for anything plausibly Critical or High; a public issue is fine for hardening suggestions and non-exploitable bugs. A flaw is **identified** once it appears in any channel above with enough detail to score CVSS.
|
||||
|
||||
## 2. Severity
|
||||
|
||||
CVSS v3.1 base score from the upstream advisory; otherwise scored by maintainers and the vector recorded on the tracking issue.
|
||||
|
||||
| Severity | CVSS v3.1 | Examples |
|
||||
|----------|-----------|----------|
|
||||
| Critical | 9.0 – 10.0 | Unauthenticated RCE reachable from tenant networks; `awsgw` auth bypass; master-key disclosure |
|
||||
| High | 7.0 – 8.9 | Authenticated RCE; intra-service privilege escalation; cluster-internal auth bypass; memory disclosure exposing credentials. |
|
||||
| Medium | 4.0 – 6.9 | Authenticated DoS; non-secret information disclosure; limited-blast-radius logic errors |
|
||||
| Low | 0.1 – 3.9 | Heavily mitigated issues — local-only, non-default config, negligible impact |
|
||||
|
||||
**Severity modifiers.** Bump one tier **up** if any of the following apply: the flaw is under active exploitation, reaches the master encryption key or cluster CA private key, or allows cross-tenant data access. Bump one tier **down** if the flaw is only reachable by a cluster-internal process already holding credentials equivalent to the attack's outcome.
|
||||
|
||||
## 3. Remediation SLAs
|
||||
|
||||
Clocks start at the moment of identification ([§1](#1-identification-sources)). All three objectives — identify [a], report [c], correct [e] — are time-bound.
|
||||
|
||||
| Severity | Identify | Report | Correct |
|
||||
|----------|----------|--------|---------|
|
||||
| Critical | 24 hours | 48 hours, with patch | 48 hours |
|
||||
| High | 48 hours | 7 days, with patch | 7 days |
|
||||
| Medium | 7 days | Next release changelog | 30 days |
|
||||
| Low | 30 days | Next release changelog | 90 days |
|
||||
|
||||
**Identify** - triaged into a tracking issue, CVSS scored, affected components and versions determined, severity label applied.
|
||||
|
||||
**Report** - GitHub Security Advisory is published (for Critical/High) or the fix is described in the release notes (for Medium/Low). Operators are the audience; the report must let them determine whether their deployment is affected.
|
||||
|
||||
**Correct** - tagged Spinifex release containing the fix is available for operators to pull. For dependency CVEs, this is a release that bumps the vulnerable module to a fixed version. For first-party code, it is a release containing the patch.
|
||||
|
||||
If an upstream embargo applies, the correct-SLA pauses until disclosure; the identify-SLA does not. Record the embargo on the tracking issue.
|
||||
|
||||
## 4. Reporting Workflow
|
||||
|
||||
1. **Intake** — External findings arrive via GitHub (private for sensitive, public issue otherwise). Maintainers mirror each finding into the internal tracker with CVSS score, affected components, and advisory link. Private disclosures stay private until the advisory is drafted.
|
||||
2. **Triage** — Validate severity within the identify-SLA, confirm reachability in Spinifex, and either close as not-applicable (with justification) or proceed.
|
||||
3. **Fix** — Patch lands on `main` via the normal PR + E2E workflow. Commit message references the CVE / GHSA ID.
|
||||
4. **Release** — Tagged release cut; release notes state affected versions, CVSS, and upgrade guidance. Critical/High get a published GHSA.
|
||||
5. **Notify** — GitHub release/advisory notifications reach subscribed operators automatically. Out-of-band email notification is triggered only for Critical.
|
||||
|
||||
Not-applicable findings (unreachable, already patched, scanner false positive) are still tracked to closure so every identification has an audit trail.
|
||||
|
||||
## 5. Operator Responsibilities
|
||||
|
||||
Operators own remediation for layers Spinifex does not ship:
|
||||
|
||||
| Layer | Responsibility |
|
||||
|-------|----------------|
|
||||
| Spinifex releases | Apply released patches within the correct-SLA above. |
|
||||
| Host OS + kernel | Track distribution advisories (Debian DSA, Ubuntu USN) at matching cadence. |
|
||||
| OVN / OVS | Patched via the operator's package channel alongside the OS. |
|
||||
| QEMU / KVM / libvirt | As above — hypervisor CVEs affect tenant isolation. |
|
||||
| Host AV / EDR agent | See [Malware Protection §2](/docs/security/malware-protection#2-update-requirements-sil1-3144). |
|
||||
|
||||
Operators should subscribe to the `mulgadc/spinifex` release feed (Watch → Custom → Releases + Security Advisories) so Critical/High advisories arrive via GitHub's notification channel rather than polling.
|
||||
|
||||
## 6. Evidence and Record Keeping
|
||||
|
||||
For CMMC assessment, retain the following for at least 12 months:
|
||||
|
||||
- Tracking record for every identified flaw (GitHub issue, GHSA, or internal tracker ID) with identify / triage / close timestamps.
|
||||
- Dependabot history and `govulncheck` CI logs — export before GitHub Actions retention expires if shorter.
|
||||
- Release notes and GHSAs for each corrected flaw.
|
||||
- Operator patch-apply records (change tickets, config-management runs) showing releases deployed within SLA.
|
||||
|
||||
These demonstrate objectives [b], [d], [f] — "within the specified time" — in practice.
|
||||
|
||||
## 7. Operator Checklist
|
||||
|
||||
- System security plan references this policy as the identify / report / correct timeframes for Spinifex.
|
||||
- Change-management records patch-apply timestamps for every Spinifex release.
|
||||
- Operator subscribes to `mulgadc/spinifex`, `mulgadc/predastore`, `mulgadc/viperblock` releases and advisories.
|
||||
- OS / kernel / hypervisor patching cadence documented and at least as strict as [§3](#3-remediation-slas).
|
||||
- Annual review confirms at least one patch cycle completed within SLA in the prior 12 months, with evidence per [§6](#6-evidence).
|
||||
@@ -0,0 +1,307 @@
|
||||
---
|
||||
title: "Malware Protection"
|
||||
description: "Operator guide for host-based malware protection and file integrity monitoring on Spinifex nodes"
|
||||
category: "Security"
|
||||
sections:
|
||||
- overview
|
||||
tags:
|
||||
- security
|
||||
- compliance
|
||||
- cmmc
|
||||
- malware
|
||||
- antivirus
|
||||
- file integrity
|
||||
resources:
|
||||
- title: "NIST SP 800-171 Rev 3"
|
||||
url: "https://csrc.nist.gov/pubs/sp/800/171/r3/final"
|
||||
- title: "CMMC Level 1 Self-Assessment Guide v2.0"
|
||||
url: "https://dodcio.defense.gov/CMMC/Documentation"
|
||||
- title: "ClamAV Documentation"
|
||||
url: "https://docs.clamav.net"
|
||||
- title: "AIDE"
|
||||
url: "https://aide.github.io"
|
||||
---
|
||||
|
||||
# Malware Protection
|
||||
|
||||
> Operator guide for host-based malware protection and file integrity monitoring on Spinifex nodes
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [CMMC Practices Covered](#cmmc-practices-covered)
|
||||
- [Approach](#approach)
|
||||
- [1. Designated Locations](#1-designated-locations-sil1-3142-a)
|
||||
- [2. Update Requirements](#2-update-requirements-sil1-3144)
|
||||
- [3. Scan Schedule](#3-scan-schedule-sil1-3145)
|
||||
- [4. Reference Configurations](#4-reference-configurations)
|
||||
- [5. Operator Checklist](#5-operator-checklist)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
**Audience:** Operators deploying Spinifex into environments subject to CMMC Level 1 (or organisations that otherwise require host-based malware protection).
|
||||
|
||||
**Scope:** The Spinifex nodes themselves — the Linux hosts running `spinifex-daemon`, `spinifex-awsgw`, `spinifex-nats`, `spinifex-predastore`, `spinifex-viperblock`, `spinifex-vpcd`, `spinifex-ui` and the OVN control plane. Guest VMs launched on the platform are the responsibility of the workload owner and are out of scope.
|
||||
|
||||
## CMMC Practices Covered
|
||||
|
||||
This guide captures the operator-side controls required to meet three CMMC Level 1 practices that Spinifex itself cannot enforce — they depend on host-based tooling chosen and operated by the deployment team.
|
||||
|
||||
| Practice | Title | Objective |
|
||||
|----------|-------|-----------|
|
||||
| SI.L1-3.14.2 | Malicious Code Protection | [a] Designated locations for malicious code protection are identified. [b] Protection from malicious code at designated locations is provided. |
|
||||
| SI.L1-3.14.4 | Update Malicious Code Protection | [a] Malicious code protection mechanisms are updated when new releases are available. |
|
||||
| SI.L1-3.14.5 | System & File Scanning | [a] Periodic scans of the information system are defined. [b] Periodic scans of the information system are performed. [c] Real-time scans of files from external sources are defined and performed as files are downloaded, opened, or executed. |
|
||||
|
||||
## Approach
|
||||
|
||||
Spinifex does not bundle or mandate a specific anti-malware product. Operators will typically have an existing endpoint security standard (CrowdStrike Falcon, Microsoft Defender for Linux, Wazuh, ClamAV, Sophos, ESET, etc.). This guide:
|
||||
|
||||
1. Names the Spinifex components and filesystem paths that must be included in scan and integrity-monitoring coverage (the "designated locations").
|
||||
2. Specifies update and scan cadence required to meet SI.L1-3.14.4 and SI.L1-3.14.5.
|
||||
3. Provides reference configurations for open-source stacks (ClamAV for AV, AIDE for file integrity, Wazuh for centralised monitoring) as a known-good baseline. Operators running a commercial EDR should map these locations and cadences into their existing product.
|
||||
|
||||
## 1. Designated Locations (SI.L1-3.14.2 [a])
|
||||
|
||||
All Spinifex nodes must include the following in their malware protection scope. Paths assume the production install layout (`/etc/spinifex` exists); development layouts under `$HOME/spinifex` are not supported for compliance deployments.
|
||||
|
||||
### 1.1 Executable Binaries
|
||||
|
||||
| Path | Contents |
|
||||
|------|----------|
|
||||
| `/usr/local/bin/spx` | Spinifex CLI and service entry point. All `spinifex-*.service` units invoke `spx service <name> start`, so every service binary is reached through this one path. |
|
||||
| `/usr/bin/ovn-*`, `/usr/bin/ovs-*` | OVN / OVS control-plane and datapath binaries (installed via `apt` from the `ovn-central`, `ovn-host`, `openvswitch-switch` packages). |
|
||||
| `/usr/lib/<arch>-linux-gnu/nbdkit/plugins/nbdkit-viperblock-plugin.so` | Viperblock NBD plugin loaded by `nbdkit` to expose EBS volumes as block devices. The exact path comes from `nbdkit --dump-config` (`plugindir=`); on Debian 12 amd64 it is `/usr/lib/x86_64-linux-gnu/nbdkit/plugins/`. |
|
||||
| `/usr/local/share/ca-certificates/spinifex-ca.crt` | Cluster CA cert installed into the system trust store |
|
||||
|
||||
### 1.2 Configuration and Secrets
|
||||
|
||||
| Path | Contents |
|
||||
|------|----------|
|
||||
| `/etc/spinifex/` | Cluster config directory: `spinifex.toml`, `predastore.toml`, `nats.conf`. |
|
||||
| `/etc/spinifex/ca.pem`, `/etc/spinifex/ca.key` | Cluster root CA certificate and private key (leader only). |
|
||||
| `/etc/spinifex/server.pem`, `/etc/spinifex/server.key` | Per-node TLS certificate and key. |
|
||||
| `/etc/spinifex/master.key` | Master encryption key that wraps per-volume DEKs and IAM secrets. **Highest-value artifact on the node.** |
|
||||
| `/etc/spinifex/systemd.env` | Service environment file (shared by all `spinifex-*.service` units). |
|
||||
|
||||
### 1.3 Service Units and Privilege Configuration
|
||||
|
||||
| Path | Contents |
|
||||
|------|----------|
|
||||
| `/etc/systemd/system/spinifex-*.service` | Unit files for daemon, awsgw, nats, predastore, viperblock, vpcd, ui. |
|
||||
| `/etc/sudoers`, `/etc/sudoers.d/` | Any drop-ins granting privileges to service accounts (OVN/OVS management, NBD mounts, etc.). |
|
||||
| `/etc/ssh/sshd_config`, `/etc/ssh/sshd_config.d/` | Host SSH daemon configuration. |
|
||||
|
||||
### 1.4 Runtime and Data Locations
|
||||
|
||||
| Path | Purpose | Notes |
|
||||
|------|---------|-------|
|
||||
| `/var/lib/spinifex/spinifex/` | Daemon state and local metadata. | Include in scan scope. |
|
||||
| `/var/lib/spinifex/awsgw/` | AWS gateway state. | Include in scan scope. |
|
||||
| `/var/lib/spinifex/vpcd/` | VPC daemon state. | Include in scan scope. |
|
||||
| `/var/lib/spinifex/predastore/` | Local Predastore S3 backing data (chunks, metadata). | See exclusion note below. |
|
||||
| `/var/lib/spinifex/viperblock/` | Local Viperblock WAL and chunk data for EBS volumes. | See exclusion note below. |
|
||||
| `/var/lib/spinifex/nats/` | NATS JetStream data. | See exclusion note below. |
|
||||
| `/run/spinifex/`, `/run/spinifex/nbd/` | Runtime sockets and PID files. | Integrity monitoring not meaningful (transient); exclude from AIDE. |
|
||||
| `/var/log/spinifex/` | Service logs. | Scan for malicious content indicators; do not block on writes. |
|
||||
|
||||
**Performance exclusions.** Viperblock WAL, Predastore chunks, and NATS JetStream files are large, high-churn, block-level storage that contains customer VM data. Real-time on-access AV scanning of these paths is both a significant performance cost and a confused-deputy problem (the daemon, not the operator, is the writer). Cover these paths with **periodic scheduled scans** (SI.L1-3.14.5 [b]) but exclude them from real-time on-access hooks. Record the exclusion and its justification in the system security plan.
|
||||
|
||||
### 1.5 External File Ingest — Real-time Scan Required (SI.L1-3.14.5 [c])
|
||||
|
||||
The following are "files from external sources" within the meaning of 3.14.5 [c] and **must** be scanned at ingest, before Spinifex consumes them:
|
||||
|
||||
| Ingest point | What lands here | How it reaches the node |
|
||||
|--------------|-----------------|-------------------------|
|
||||
| VM image catalogue downloads | Debian / Ubuntu / Alpine / Rocky cloud images extracted during `spx admin images import`. | Spinifex downloads over HTTPS and verifies the catalogue SHA checksum. Operators layer AV on top: scan the downloaded image file before it is registered. |
|
||||
| `ImportImage` / user-uploaded AMIs | Operator- or tenant-supplied VMDK/VHD/RAW images. | Uploaded to Predastore and registered via the EC2 API. |
|
||||
| Cloud-init user-data | Base64 payloads attached to instances. | Passed via `RunInstances`; written to the CIDATA seed ISO before boot. |
|
||||
| ISO / media attached to instances | Arbitrary ISO images attached as CD-ROM to VMs. | Uploaded to Predastore. |
|
||||
|
||||
Spinifex does not currently invoke an AV scanner on these ingest paths in-process. Until that integration exists, operators must either (a) use an on-access AV hook (ClamAV `clamonacc`, EDR file-write monitor) that sees Predastore writes, or (b) run a pre-ingest workflow that scans artifacts before they are uploaded to Spinifex.
|
||||
|
||||
## 2. Update Requirements (SI.L1-3.14.4)
|
||||
|
||||
Malicious code protection mechanisms must be updated when new releases are available. For signature-based AV this means signature feeds; for EDR products it means agent version pins. Apply to every node.
|
||||
|
||||
| Component | Cadence | Mechanism |
|
||||
|-----------|---------|-----------|
|
||||
| AV signatures (ClamAV `freshclam`, commercial EDR cloud feeds) | At least every 4 hours; real-time if the vendor supports it. | Systemd timer for `freshclam` (see [§4.1](#41-clamav-signature-based-av)); EDR agents typically self-update. |
|
||||
| AV engine / agent version | Within 7 days of vendor release for non-critical; within 48 hours for vendor-marked critical. | OS package manager or vendor deployment pipeline; align with the flaw-remediation SLAs. |
|
||||
| File-integrity baseline (AIDE) | Re-baseline on every Spinifex upgrade or config change. | Re-run `aide --init` as part of the upgrade runbook; commit the resulting hash to the change record. |
|
||||
|
||||
**Evidence:** the system security plan must record (a) which product is deployed, (b) how updates are delivered, and (c) a sample of update-success log entries from the last 30 days.
|
||||
|
||||
## 3. Scan Schedule (SI.L1-3.14.5)
|
||||
|
||||
### 3.1 Periodic Scans (objectives [a], [b])
|
||||
|
||||
| Target | Frequency | Type |
|
||||
|--------|-----------|------|
|
||||
| Designated locations [§1.1–§1.3](#11-executable-binaries) (binaries, config, secrets, unit files) | Daily | Full AV scan + AIDE integrity check. |
|
||||
| Runtime data under `/var/lib/spinifex/*` ([§1.4](#14-runtime-and-data-locations)) | Weekly | Full AV scan. |
|
||||
| Whole root filesystem | Monthly | Full AV scan. |
|
||||
|
||||
### 3.2 Real-time Scans (objective [c])
|
||||
|
||||
| Trigger | Action |
|
||||
|---------|--------|
|
||||
| Write to the external-ingest paths in [§1.5](#1-designated-locations-sil1-3142-a) | AV scan before the file is considered available. A positive detection must fail the ingest and emit a structured log line to `/var/log/spinifex/` and the operator's SIEM. |
|
||||
| Execution of a binary in `/usr/local/bin/`, `/usr/bin/ovn-*`, `/usr/bin/ovs-*`, or load of `nbdkit-viperblock-plugin.so` from the nbdkit plugin dir | On-access AV check (ClamAV `clamonacc` or EDR equivalent). |
|
||||
|
||||
### 3.3 Logging
|
||||
|
||||
All scan runs — scheduled and real-time — must produce a log entry that includes scanner name, version, signature version, target, outcome, and duration. Forward to the same log collection used for Spinifex service logs.
|
||||
|
||||
## 4. Reference Configurations
|
||||
|
||||
The configurations below are reference baselines. They are known to meet the CMMC L1 objectives on a standard Debian 12 Spinifex node; they are not the only valid implementation. Operators running a commercial EDR should treat [§1](#1-designated-locations-sil1-3142-a) and [§3](#3-scan-schedule-sil1-3145) as the contract and configure their chosen product to match.
|
||||
|
||||
### 4.1 ClamAV (signature-based AV)
|
||||
|
||||
Install:
|
||||
|
||||
```bash
|
||||
apt-get install -y clamav clamav-daemon clamav-freshclam clamonacc
|
||||
```
|
||||
|
||||
Signature updates — drop-in `/etc/systemd/system/clamav-freshclam.timer.d/spinifex.conf`:
|
||||
|
||||
```ini
|
||||
[Timer]
|
||||
OnCalendar=*-*-* 00,04,08,12,16,20:00:00
|
||||
Persistent=true
|
||||
```
|
||||
|
||||
Enable and start:
|
||||
|
||||
```bash
|
||||
systemctl enable --now clamav-freshclam.timer clamav-daemon.service clamav-clamonacc.service
|
||||
```
|
||||
|
||||
On-access scanning — `/etc/clamav/clamd.conf` additions:
|
||||
|
||||
```
|
||||
OnAccessIncludePath /usr/local/bin
|
||||
OnAccessIncludePath /usr/lib/x86_64-linux-gnu/nbdkit/plugins
|
||||
OnAccessIncludePath /etc/spinifex
|
||||
OnAccessIncludePath /etc/systemd/system
|
||||
OnAccessPreventionAction clamd-block
|
||||
OnAccessExcludePath /var/lib/spinifex/viperblock
|
||||
OnAccessExcludePath /var/lib/spinifex/predastore
|
||||
OnAccessExcludePath /var/lib/spinifex/nats
|
||||
```
|
||||
|
||||
Scheduled scans — `/etc/systemd/system/spinifex-clamav-scan.service`:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Spinifex scheduled AV scan
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=oneshot
|
||||
ExecStart=/usr/bin/clamdscan --fdpass --multiscan \
|
||||
/usr/local/bin /usr/lib/x86_64-linux-gnu/nbdkit/plugins \
|
||||
/etc/spinifex /etc/systemd/system \
|
||||
/var/lib/spinifex/awsgw /var/lib/spinifex/vpcd \
|
||||
/var/lib/spinifex/spinifex /var/log/spinifex
|
||||
StandardOutput=append:/var/log/spinifex/clamav-scheduled.log
|
||||
StandardError=append:/var/log/spinifex/clamav-scheduled.log
|
||||
```
|
||||
|
||||
Paired timer `spinifex-clamav-scan.timer`:
|
||||
|
||||
```ini
|
||||
[Unit]
|
||||
Description=Daily Spinifex AV scan
|
||||
|
||||
[Timer]
|
||||
OnCalendar=*-*-* 03:15:00
|
||||
Persistent=true
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
```
|
||||
|
||||
For the weekly `/var/lib/spinifex/*` data scan and the monthly root scan, add two additional timer/service pairs with `OnCalendar=weekly` / `OnCalendar=monthly` and the appropriate scan targets.
|
||||
|
||||
### 4.2 AIDE (file integrity monitoring)
|
||||
|
||||
Install:
|
||||
|
||||
```bash
|
||||
apt-get install -y aide aide-common
|
||||
```
|
||||
|
||||
Policy — `/etc/aide/aide.conf.d/90_spinifex`:
|
||||
|
||||
```
|
||||
# Binaries — any change is suspicious
|
||||
/usr/local/bin/spx f+p+u+g+s+m+c+sha256
|
||||
# Viperblock NBD plugin — adjust <arch> for the node (see `nbdkit --dump-config`)
|
||||
/usr/lib/x86_64-linux-gnu/nbdkit/plugins/nbdkit-viperblock-plugin.so f+p+u+g+s+m+c+sha256
|
||||
|
||||
# Config and secrets
|
||||
/etc/spinifex f+p+u+g+s+m+c+sha256
|
||||
/etc/systemd/system/spinifex-.*\.service$ f+p+u+g+s+m+c+sha256
|
||||
|
||||
# CA trust store
|
||||
/usr/local/share/ca-certificates/spinifex-ca.crt f+p+u+g+s+m+c+sha256
|
||||
|
||||
# Exclude high-churn runtime data (covered by scheduled AV instead)
|
||||
!/var/lib/spinifex/viperblock
|
||||
!/var/lib/spinifex/predastore
|
||||
!/var/lib/spinifex/nats
|
||||
!/var/log/spinifex
|
||||
!/run/spinifex
|
||||
```
|
||||
|
||||
Baseline:
|
||||
|
||||
```bash
|
||||
aideinit
|
||||
mv /var/lib/aide/aide.db.new /var/lib/aide/aide.db
|
||||
```
|
||||
|
||||
Daily check — a systemd timer invoking `aide --check`. Non-zero exit must alert. Re-baseline after every Spinifex upgrade (`make reinstall`, package upgrade, config change) and record the new hash.
|
||||
|
||||
### 4.3 Wazuh (centralised AV + FIM + alerting)
|
||||
|
||||
For multi-node clusters, a Wazuh agent on each Spinifex node feeding a central Wazuh manager is the simplest way to discharge 3.14.2 / 3.14.4 / 3.14.5 with consolidated evidence.
|
||||
|
||||
Key agent configuration (`/var/ossec/etc/ossec.conf` fragments):
|
||||
|
||||
```xml
|
||||
<syscheck>
|
||||
<frequency>43200</frequency> <!-- 12h FIM baseline -->
|
||||
<directories check_all="yes" realtime="yes">/etc/spinifex</directories>
|
||||
<directories check_all="yes" realtime="yes">/usr/local/bin</directories>
|
||||
<directories check_all="yes" realtime="yes">/etc/systemd/system</directories>
|
||||
<ignore>/var/lib/spinifex/viperblock</ignore>
|
||||
<ignore>/var/lib/spinifex/predastore</ignore>
|
||||
<ignore>/var/lib/spinifex/nats</ignore>
|
||||
<ignore>/run/spinifex</ignore>
|
||||
</syscheck>
|
||||
|
||||
<rootcheck>
|
||||
<frequency>3600</frequency>
|
||||
</rootcheck>
|
||||
```
|
||||
|
||||
Pair with the Wazuh ClamAV integration (or a commercial AV module) for signature-based detection, and use the Wazuh manager's rule engine to alert on signature updates failing (SI.L1-3.14.4 evidence) and on scheduled-scan failures (SI.L1-3.14.5 evidence).
|
||||
|
||||
## 5. Operator Checklist
|
||||
|
||||
Use this list to confirm a node meets the three CMMC practices before the node is admitted to a production cluster:
|
||||
|
||||
- Host AV product installed, running, and reporting to a central console or log sink.
|
||||
- Signature update timer/job running; last successful update within the cadence in [§2](#2-update-requirements-sil1-3144).
|
||||
- AV and file-integrity policy covers every path in [§1.1–§1.4](#11-executable-binaries), with documented exclusions for [§1.4](#14-runtime-and-data-locations) high-churn paths.
|
||||
- External-ingest paths in [§1.5](#1-designated-locations-sil1-3142-a) have real-time scanning in place (either at the Predastore write layer or in the operator's upload workflow).
|
||||
- Daily, weekly, and monthly scheduled scans defined per [§3.1](#31-periodic-scans-objectives-a-b) and visible in recent run history.
|
||||
- Scan and update logs forwarded to the same SIEM / log collector used for Spinifex service logs.
|
||||
- AIDE (or equivalent FIM) baseline exists; re-baseline step is part of the Spinifex upgrade runbook.
|
||||
- System security plan documents the deployed product, update mechanism, scan cadence, and exclusions.
|
||||
@@ -0,0 +1,177 @@
|
||||
---
|
||||
title: "External Connection Inventory"
|
||||
description: "Operator inventory of inbound listeners and outbound connections on Spinifex nodes"
|
||||
category: "Security"
|
||||
sections:
|
||||
- overview
|
||||
tags:
|
||||
- security
|
||||
- compliance
|
||||
- cmmc
|
||||
- network
|
||||
- connections
|
||||
- boundary
|
||||
resources:
|
||||
- title: "NIST SP 800-171 Rev 3"
|
||||
url: "https://csrc.nist.gov/pubs/sp/800/171/r3/final"
|
||||
- title: "CMMC Level 1 Self-Assessment Guide v2.0"
|
||||
url: "https://dodcio.defense.gov/CMMC/Documentation"
|
||||
- title: "NIST SP 800-41 Rev 1 — Guidelines on Firewalls and Firewall Policy"
|
||||
url: "https://csrc.nist.gov/pubs/sp/800/41/r1/final"
|
||||
---
|
||||
|
||||
# External Connection Inventory
|
||||
|
||||
> Operator inventory of inbound listeners and outbound connections on Spinifex nodes
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [CMMC Practices Covered](#cmmc-practices-covered)
|
||||
- [Approach](#approach)
|
||||
- [1. Inbound Listeners](#1-inbound-listeners)
|
||||
- [2. Outbound Connections](#2-outbound-connections)
|
||||
- [3. Cross-Node (Internal) Connections](#3-cross-node-internal-connections)
|
||||
- [4. Limiting Controls](#4-limiting-controls)
|
||||
- [5. Configuration Surface](#5-configuration-surface)
|
||||
- [6. Operator Checklist](#6-operator-checklist)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
**Audience:** Operators deploying Spinifex into environments subject to CMMC Level 1, or any site that requires a documented inventory of system connections.
|
||||
|
||||
**Scope:** Network connections originated by or terminated at the Spinifex nodes — the Linux hosts running `spinifex-daemon`, `spinifex-awsgw`, `spinifex-nats`, `spinifex-predastore`, `spinifex-viperblock`, `spinifex-vpcd`, `spinifex-ui`, and the OVN control plane. Guest VM traffic is the workload owner's responsibility and out of scope.
|
||||
|
||||
**Boundary definition.** For the purposes of this document:
|
||||
|
||||
- **External** means outside the Spinifex cluster's trusted network perimeter — the public internet, the operator's corporate network, tenant users of the AWS API, and guest VMs.
|
||||
- **Internal** means between Spinifex nodes inside the cluster subnet(s) defined in `spinifex.toml`.
|
||||
|
||||
AC.L1-3.1.20 applies specifically to **external** connections. Internal cluster connections are documented here as well so operators can build an accurate firewall policy.
|
||||
|
||||
## CMMC Practices Covered
|
||||
|
||||
This guide addresses AC.L1-3.1.20. The related boundary-protection practice SC.L1-3.13.1 is covered by OVN ACL and security-group enforcement in `vpcd` and is documented separately.
|
||||
|
||||
| Practice | Title | Objective |
|
||||
|----------|-------|-----------|
|
||||
| AC.L1-3.1.20 | External Connections | [a] Connections to external systems are identified. [b] The use of external systems is identified. [c] Connections to external systems are verified. [d] The use of external systems is verified. [e] Connections to external systems are controlled/limited. [f] The use of external systems is controlled/limited. |
|
||||
|
||||
## Approach
|
||||
|
||||
Spinifex has a small, enumerable set of network surfaces:
|
||||
|
||||
1. **Inbound listeners** — the TCP/UDP ports each node binds. These are the attack surface exposed to whoever can reach the node.
|
||||
2. **Outbound connections** — the destinations the node's services reach out to. Today this is a short list: peer Spinifex nodes, OS image mirrors, and install telemetry.
|
||||
3. **Cross-node connections** — inter-node control- and data-plane traffic inside the cluster subnet.
|
||||
|
||||
The inventory in [§1](#1-inbound-listeners)–[§2](#2-outbound-connections) satisfies objectives [a]/[b]. The **Auth / Verification** columns throughout satisfy [c]/[d]. [§4](#4-limiting-controls) and [§5](#5-configuration-surface) satisfy [e]/[f]. The default install meets [c]–[f] for every listed connection; the operator's remaining work is to record the inventory in the system security plan, apply host/network firewall rules per [§4](#4-limiting-controls), and audit [§5](#5-configuration-surface) on a recurring schedule.
|
||||
|
||||
## 1. Inbound Listeners
|
||||
|
||||
"Scope" classifies intended reach:
|
||||
|
||||
- **External** — reachable by tenant/operator networks. Authenticated and TLS-protected.
|
||||
- **Cluster** — reachable only from peer Spinifex nodes. Operator must restrict via host or network firewall.
|
||||
- **Localhost** — bound to `127.0.0.1`; not reachable off-node.
|
||||
|
||||
| Port | Service | Protocol | Scope | Purpose | Auth / Verification |
|
||||
|------|---------|----------|-------|---------|--------------------|
|
||||
| 9999 | spinifex-awsgw | HTTPS | External | AWS-compatible API (EC2, S3, ELBv2, IAM) — customer endpoint | AWS SigV4 + TLS (cluster CA) |
|
||||
| 3000 | spinifex-ui | HTTPS | External | Operator web dashboard | Session cookie + TLS |
|
||||
| 22 | OpenSSH | SSH | External | Operator administration | Key-based auth (operator-managed) |
|
||||
| 4432 | Formation server | HTTPS | External (bootstrap only) | Cluster join coordination; active only while a join token is valid. See *Formation port lifecycle* below. | Short-lived bearer token + TLS¹ |
|
||||
| 4222 | spinifex-nats (client) | NATS + TLS | Cluster | Internal service bus for EC2/EBS/VPC/S3 handlers | Token + mutual TLS (cluster CA) |
|
||||
| 4248 | spinifex-nats (cluster) | NATS + TLS | Cluster | Inter-node NATS federation | Token + mutual TLS (cluster CA) |
|
||||
| 8443 | spinifex-predastore | HTTPS | Cluster | S3-compatible object storage (AMIs, snapshots, user objects) | AWS SigV4 + TLS |
|
||||
| 6660–6662 | predastore (Raft) | TCP | Cluster | Metadata consensus (3 nodes) | Cluster network only |
|
||||
| 9991–9993 | predastore (data shards) | TCP | Cluster | Erasure-coded data shard transport | Cluster network only |
|
||||
| 6641 | OVN Northbound DB | OVSDB/TCP | Cluster | Logical network topology consumed by vpcd | Cluster network only; TLS planned |
|
||||
| 6642 | OVN Southbound DB | OVSDB/TCP | Cluster | Chassis / port / MAC binding state | Cluster network only; TLS planned |
|
||||
| 8222 | spinifex-nats (monitoring) | HTTP | Localhost | `varz`/`subsz` metrics consumed by the daemon | Loopback only |
|
||||
| socket / dynamic TCP | nbdkit (Viperblock) | NBD | Host-local / cluster | Block device transport for guest EBS volumes | Unix socket by default; TCP only in remote/DPU mode |
|
||||
|
||||
¹ **Formation port lifecycle.** 4432 opens during `spx admin init` / `spx admin join` while a bootstrap token is outstanding and closes once the cluster is formed (token TTL default 30 min, `--token-ttl`). The server presents an ephemeral self-signed cert that pre-dates trust bootstrap, so the joining node dials with `InsecureSkipVerify` — the only production dial that does. Authenticity rests on the operator supplying the leader address out-of-band plus possession of the bearer token. Document in the security plan so reviewers do not flag 4432 as a persistent open port.
|
||||
|
||||
**Development-only listeners.** When `dev_networking=true`, QEMU opens arbitrary host TCP ports for SSH port-forwarding into guest VMs. Production installs (the `/etc/spinifex` layout) do not enable this; it must not appear on compliance nodes.
|
||||
|
||||
## 2. Outbound Connections
|
||||
|
||||
Spinifex nodes initiate a small, fixed set of outbound connections.
|
||||
|
||||
**To external destinations:**
|
||||
|
||||
| Destination | Purpose | Protocol | Verification |
|
||||
|-------------|---------|----------|--------------|
|
||||
| `https://cloud.debian.org/images/cloud/trixie/20260501-2465` | Debian 13 cloud image download | HTTPS | TLS + checksum verification |
|
||||
| `https://cloud-images.ubuntu.com/resolute/current` | Ubuntu 26.04 LTS cloud image download | HTTPS | TLS + checksum verification |
|
||||
| `https://d2yp8ipz5jfqcw.cloudfront.net` | Alpine image for managed HAProxy load-balancer | HTTPS | TLS + checksum verification |
|
||||
| `https://install.mulgadc.com/install` | One-shot install telemetry POST on `spx admin init` / `join`. | HTTPS | TLS |
|
||||
|
||||
**To peer nodes (cluster-internal):** NATS federation (4248), Predastore S3 (8443), OVN NB/SB (6641/6642) — see [§3](#3-cross-node-internal-connections) for encryption and verification of each. The daemon also probes local Predastore Raft status at `<bind IP>:6660/status` (TLS, cluster CA) and local NATS monitoring at `127.0.0.1:8222/varz` (loopback HTTP).
|
||||
|
||||
**Update checks and metadata.** Spinifex does not check for updates and does not consume a cloud metadata service (`169.254.169.254` is served *by* the cluster to guest VMs). Node software updates come from the operator's OS package channel. The install-telemetry endpoint above is the only vendor-operated destination contacted by a node; closed-egress deployments should disable it and record the opt-out in the security plan.
|
||||
|
||||
**Air-gapped deployments.** The three image URLs are the only destinations needed for the standard image catalogue. Mirror them locally and use `spx admin images import --file` with pre-staged files. Telemetry must also be disabled. See [Air-Gapped Install](/docs/install-airgapped).
|
||||
|
||||
## 3. Cross-Node (Internal) Connections
|
||||
|
||||
Control-plane and data-plane traffic between Spinifex nodes, for completeness and firewall planning:
|
||||
|
||||
| Connection | Port(s) | Encryption / Auth | Notes |
|
||||
|-----------|---------|-------------------|-------|
|
||||
| NATS cluster routes | 4248 | Mutual TLS + cluster token | Full mesh between NATS servers |
|
||||
| Predastore S3 | 8443 | TLS + AWS SigV4 | Cross-node object reads/writes |
|
||||
| Predastore Raft | 6660–6662 | Cluster network only | Metadata consensus |
|
||||
| Predastore shards | 9991–9993 | Cluster network only | Erasure-coded data shards |
|
||||
| OVN NB/SB | 6641 / 6642 | Cluster network only (TLS planned) | Network control plane |
|
||||
| OVN tunnels (Geneve) | UDP 6081 | None | Tenant traffic overlay between chassis, inside the cluster subnet |
|
||||
|
||||
Nodes **must** sit on a network segment that is not routed to tenant/guest VMs or to the internet. Predastore Raft/shards and OVN DBs are cluster-internal and must not be reachable from anywhere else.
|
||||
|
||||
## 4. Limiting Controls
|
||||
|
||||
Default external surface is three listeners — **9999** (AWS API), **3000** (UI), **22** (SSH) — plus **4432** transiently during bootstrap. Every other listener is cluster-only and the operator must enforce this with a host firewall (`nftables`/`iptables`/`firewalld`) or an upstream network ACL. Minimal `nftables` reference:
|
||||
|
||||
```
|
||||
# External: from anywhere
|
||||
tcp dport { 22, 9999, 3000 } accept
|
||||
|
||||
# Cluster-only: replace 10.0.1.0/24 with your cluster CIDR
|
||||
ip saddr 10.0.1.0/24 tcp dport { 4222, 4248, 8443, 6641, 6642, 6660-6662, 9991-9993 } accept
|
||||
ip saddr 10.0.1.0/24 udp dport 6081 accept
|
||||
|
||||
# Default deny
|
||||
tcp dport 0-65535 drop
|
||||
udp dport 0-65535 drop
|
||||
```
|
||||
|
||||
Port 4432 must be closed outside the bootstrap window; `spx admin join` opens it transiently. Outbound egress can be limited to the image-catalogue hostnames in [§2](#2-outbound-connections) plus the operator's OS package repositories; on air-gapped nodes, block all outbound HTTPS and use `spx admin images import --file`.
|
||||
|
||||
## 5. Configuration Surface
|
||||
|
||||
Every listener and outbound destination is controlled by one of these files. Changes require a service restart.
|
||||
|
||||
| File | Keys | Controls |
|
||||
|------|------|----------|
|
||||
| `/etc/spinifex/spinifex.toml` | `nodes.<node>.{awsgw,nats,predastore,daemon}.host`, `nodes.<node>.vpcd.ovn_{nb,sb}_addr`, `nodes.<node>.daemon.dev_networking` | Per-service bind addresses/ports; dev-mode QEMU port forwarding. |
|
||||
| `/etc/spinifex/nats.conf` | `listen`, `cluster.listen`, `cluster.routes`, `http`, `tls`, `cluster.authorization` | NATS client/cluster/monitoring listeners, peer routes, TLS, cluster token. |
|
||||
| `/etc/spinifex/predastore.toml` | `host`, `port`, `[[db]].port`, `[[nodes]].port`, `tls.*` | Predastore S3 listener, Raft ports, shard ports, TLS certs. |
|
||||
| OVN packages (`ovn-central`, `ovn-host`) | `ovn-nb-db`, `ovn-sb-db` (via `ovs-vsctl set open_vswitch …`) | OVN DB bind addresses. |
|
||||
| Spinifex UI service | Built-in defaults: `host = "0.0.0.0"`, `port = 3000`. No `spinifex.toml` block today. | UI listener. |
|
||||
| `spx admin init` / `spx admin join` | `--port`, `--token-ttl`, `--no-telemetry` (or `SPX_NO_TELEMETRY=1`) | Formation port, token TTL, telemetry opt-out. |
|
||||
| `utils/images.go` `AvailableImages` | Image catalogue URLs | Outbound HTTPS destinations for image downloads. |
|
||||
|
||||
## 6. Operator Checklist
|
||||
|
||||
- Inventory recorded in the system security plan — inbound ([§1](#1-inbound-listeners)), outbound ([§2](#2-outbound-connections)), cross-node ([§3](#3-cross-node-internal-connections)) — matches what is observed on the node (`ss -tlnp`, `ss -unlp`).
|
||||
- Host firewall enforces the external/cluster/localhost split in [§4](#4-limiting-controls): external surface limited to 9999, 3000, 22 (and 4432 only during bootstrap).
|
||||
- Cluster subnet is isolated from tenant guest VM networks and from the public internet.
|
||||
- Formation port 4432 is closed on nodes not actively running a bootstrap token.
|
||||
- Outbound HTTPS restricted to the [§2](#2-outbound-connections) image-catalogue hosts, or replaced with air-gapped import.
|
||||
- Install telemetry (`install.mulgadc.com`) is either permitted and recorded in the security plan, or disabled via `SPX_NO_TELEMETRY=1` / `--no-telemetry`.
|
||||
- OVN NB/SB (6641/6642) exposure limited to the cluster subnet pending the L2 TLS work.
|
||||
- SSH (22) configured to operator-managed keys only; password auth disabled in `sshd_config`.
|
||||
- Periodic review (at least annually, and after any topology change) confirms this inventory still matches the deployed configuration.
|
||||
@@ -0,0 +1,205 @@
|
||||
---
|
||||
title: "Physical Security Operator Guide"
|
||||
description: "Operator guide for physical access controls, visitor handling, access logging, and access-device management at sites hosting Spinifex nodes"
|
||||
category: "Security"
|
||||
sections:
|
||||
- overview
|
||||
tags:
|
||||
- security
|
||||
- compliance
|
||||
- cmmc
|
||||
- physical
|
||||
- facilities
|
||||
- access control
|
||||
resources:
|
||||
- title: "NIST SP 800-171 Rev 3"
|
||||
url: "https://csrc.nist.gov/pubs/sp/800/171/r3/final"
|
||||
- title: "CMMC Level 1 Self-Assessment Guide v2.0"
|
||||
url: "https://dodcio.defense.gov/CMMC/Documentation"
|
||||
- title: "NIST SP 800-53 Rev 5 — PE Family"
|
||||
url: "https://csrc.nist.gov/pubs/sp/800/53/r5/upd1/final"
|
||||
- title: "NIST SP 800-116 Rev 1 — PIV for Physical Access"
|
||||
url: "https://csrc.nist.gov/pubs/sp/800/116/r1/final"
|
||||
---
|
||||
|
||||
# Physical Security Operator Guide
|
||||
|
||||
> Operator guide for physical access controls, visitor handling, access logging, and access-device management at sites hosting Spinifex nodes
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [CMMC Practices Covered](#cmmc-practices-covered)
|
||||
- [Approach](#approach)
|
||||
- [1. Protected Assets](#1-protected-assets)
|
||||
- [2. Limit Physical Access (PE.L1-3.10.1)](#2-limit-physical-access-pel1-3101)
|
||||
- [3. Escort and Monitor Visitors (PE.L1-3.10.3)](#3-escort-and-monitor-visitors-pel1-3103)
|
||||
- [4. Physical Access Logs (PE.L1-3.10.4)](#4-physical-access-logs-pel1-3104)
|
||||
- [5. Manage Physical Access Devices (PE.L1-3.10.5)](#5-manage-physical-access-devices-pel1-3105)
|
||||
- [6. Evidence and Record Keeping](#6-evidence-and-record-keeping)
|
||||
- [7. Operator Checklist](#7-operator-checklist)
|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
**Audience:** Operators deploying Spinifex into environments subject to CMMC Level 1, or any site that requires documented physical protection of compute and storage infrastructure.
|
||||
|
||||
**Scope:** The physical environment housing Spinifex nodes — the Linux hosts running `spinifex-daemon`, `spinifex-awsgw`, `spinifex-nats`, `spinifex-predastore`, `spinifex-viperblock`, `spinifex-vpcd`, `spinifex-ui`, and the OVN control plane — together with the network equipment, cabling, console/KVM access paths, and backup media that support them. Tenant workloads and operator endpoints (laptops, jump hosts) are out of scope.
|
||||
|
||||
## CMMC Practices Covered
|
||||
|
||||
PE.L1 objectives are discharged by the operator's facility, not by Spinifex. This guide names the assets, cadences, and evidence required.
|
||||
|
||||
| Practice | Title | Objective |
|
||||
|----------|-------|-----------|
|
||||
| PE.L1-3.10.1 | Limit Physical Access | [a] Authorized individuals allowed physical access are identified. [b] Physical access to organizational systems, equipment, and operating environments is limited to authorized individuals. |
|
||||
| PE.L1-3.10.3 | Escort Visitors | [a] Visitors are escorted. [b] Visitor activity is monitored. |
|
||||
| PE.L1-3.10.4 | Physical Access Logs | [a] Audit logs of physical access are maintained. |
|
||||
| PE.L1-3.10.5 | Manage Physical Access Devices | [a] Physical access devices are identified. [b] Physical access devices are controlled. [c] Physical access devices are managed. |
|
||||
|
||||
## Approach
|
||||
|
||||
Spinifex does not mandate a specific access-control product. Operators typically deploy into a facility that already has badge readers, CCTV, and a visitor-management system (Kisi, HID, Lenel, Genetec, Envoy, Traction Guest, paper logs, etc.). Manual procedures (locked cabinet, paper sign-in, tracked key list) are acceptable for small sites provided the evidence trail exists; multi-rack sites should feed an electronic access-control system into the same SIEM used for Spinifex service logs.
|
||||
|
||||
## 1. Protected Assets
|
||||
|
||||
The physical protection boundary must enclose every item in this table. An asset is "protected" when access to it requires passing through a controlled barrier (locked room, cage, or cabinet).
|
||||
|
||||
| Asset | Why it must be inside the boundary |
|
||||
|-------|-----------------------------------|
|
||||
| Spinifex node chassis | Host console, BMC, and disks hold `/etc/spinifex/master.key`, cluster CA key, per-node TLS keys, and all tenant volume data. |
|
||||
| Network switches and routers serving the cluster subnet | Physical access permits traffic capture, port mirroring, and control-plane tampering. |
|
||||
| Structured cabling (top-of-rack to host, host to storage) | Passive taps are trivial on unprotected cabling. |
|
||||
| Console / KVM / serial aggregators | Bypass host authentication; reach GRUB, single-user mode, BMC. |
|
||||
| Backup media (tapes, removable disks, off-site copies) | Carry the same data as primary storage; covered by MP.L1-3.8.3 for disposal. |
|
||||
| Facility power and HVAC cutoffs serving the rack | Unauthorised de-energise is a denial-of-service and a risk to in-flight writes. |
|
||||
|
||||
Nodes deployed in an unstaffed remote or edge location must be installed in a locked enclosure with tamper-evident seals and, where feasible, a sensor (door contact, accelerometer) feeding the central monitoring system. Record the enclosure location, seal serial, and sensor channel in the asset register.
|
||||
|
||||
## 2. Limit Physical Access (PE.L1-3.10.1)
|
||||
|
||||
### 2.1 Authorized Individuals ([a])
|
||||
|
||||
Maintain a written access list naming every individual authorized to enter each protected space. For each entry record:
|
||||
|
||||
- Full name and employing organization.
|
||||
- Role justifying access (e.g. "Spinifex operator", "facilities", "vendor: OEM field service").
|
||||
- Scope — which protected spaces, and whether escorted or unescorted.
|
||||
- Start date, scheduled review date, and end date when access is removed.
|
||||
- Approver (named individual, not a role mailbox).
|
||||
|
||||
Review the list at least quarterly and immediately on personnel change (role change, departure, contractor end-of-engagement). Revocations must be effective in the access-control system promptly per operator policy.
|
||||
|
||||
### 2.2 Enforcement ([b])
|
||||
|
||||
Access to protected spaces must require authentication at the barrier — a badge, PIN, key, biometric, or combination. Unaccompanied access by individuals not on the list in [§2.1](#21-authorized-individuals-a) must not be possible. Specifically:
|
||||
|
||||
- Doors and cages: electronic access control (badge/PIN) with door-forced and door-held alarms wired to the monitoring system. Mechanical-only locks are acceptable for lab/edge deployments provided key distribution is tracked under [§5](#5-manage-physical-access-devices-pel1-3105).
|
||||
- Racks and cabinets holding Spinifex nodes: locked at all times when unattended. Key or combination distribution tracked as an access device under [§5](#5-manage-physical-access-devices-pel1-3105).
|
||||
- Remote/edge enclosures: locked with tamper-evident seal; seal integrity checked on every site visit and recorded in the visit log.
|
||||
|
||||
Shared credentials (one badge used by several people, a rack key left in the cage) are not acceptable.
|
||||
|
||||
## 3. Escort and Monitor Visitors (PE.L1-3.10.3)
|
||||
|
||||
A visitor is any individual entering a protected space who is not on the authorized-access list in [§2.1](#21-authorized-individuals-a). This includes vendor field engineers, auditors, janitorial staff, and employees of the operator who do not hold Spinifex access.
|
||||
|
||||
### 3.1 Escort ([a])
|
||||
|
||||
- Every visitor must be signed in by a named authorized escort before entering the protected space.
|
||||
- The escort must remain physically present with the visitor for the duration of the visit. Passing a visitor between escorts is permitted; leaving a visitor unaccompanied is not.
|
||||
- Work inside a rack or at a node console requires one escort per visitor.
|
||||
|
||||
### 3.2 Monitoring ([b])
|
||||
|
||||
Visitor activity must be monitored by at least one of:
|
||||
|
||||
- Continuous physical presence of the escort in line-of-sight of the visitor. This is the minimum.
|
||||
- CCTV coverage of the protected space with recordings retained for at least 30 days. Cameras must cover rack fronts and rears, door entries, and any console/KVM positions.
|
||||
- For vendor maintenance involving system access (BMC, console, disk swap): a second operator witness on-site, or a recorded screen share if the work is performed from the console. Record the witness or session recording ID in the visitor log.
|
||||
|
||||
CCTV is strongly recommended for any facility hosting more than a single rack. It also serves as corroborating evidence for [§4](#4-physical-access-logs-pel1-3104) logs and is useful for incident response.
|
||||
|
||||
## 4. Physical Access Logs (PE.L1-3.10.4)
|
||||
|
||||
### 4.1 What to Log
|
||||
|
||||
Every entry into a protected space must produce a log entry capturing:
|
||||
|
||||
| Field | Source |
|
||||
|-------|--------|
|
||||
| Identity (name, badge ID, or visitor record ID) | Access control system or sign-in sheet. |
|
||||
| Timestamp in and timestamp out | Access control system; manual for paper logs. |
|
||||
| Protected space entered | Door/cage ID. |
|
||||
| Purpose | Free-text; required for visitors, recommended for authorized staff. |
|
||||
| Escort (for visitors) | Named individual from the authorized list. |
|
||||
| Associated change or ticket ID | When the visit is tied to a Spinifex change (upgrade, disk swap, node rebuild). |
|
||||
|
||||
### 4.2 Retention and Review
|
||||
|
||||
- **Retention:** at least 12 months. CCTV recordings associated with the same visit, where used to discharge [§3.2](#32-monitoring-b), should be retained for the same period.
|
||||
- **Review cadence:** monthly spot-check of 10% of entries, quarterly full review against the authorized-access list. Discrepancies (unknown badge ID, visitor with no matching sign-in, escort named who was off-site) must be investigated and the finding recorded.
|
||||
- **Alerting:** out-of-hours entries, repeated failed reads at a single door, and door-forced / door-held events must fire an alert to the on-call operator. Treat these as incidents until triaged.
|
||||
|
||||
### 4.3 Integration with Spinifex Logs
|
||||
|
||||
Forward physical-access events to the same SIEM or log collector used for Spinifex service logs (see [Malware Protection §3](/docs/security/malware-protection#3-scan-schedule-sil1-3145)) so a `master.key` access attempt can be correlated with a physical entry into the rack.
|
||||
|
||||
## 5. Manage Physical Access Devices (PE.L1-3.10.5)
|
||||
|
||||
Physical access devices are badges, key cards, PINs, mechanical keys, rack keys, safe combinations, and tamper seals. USB tokens or HSMs holding the cluster CA key or master encryption key also belong in the register.
|
||||
|
||||
### 5.1 Identification ([a])
|
||||
|
||||
Maintain a device register with one entry per issued device. Minimum fields:
|
||||
|
||||
| Field | Notes |
|
||||
|-------|-------|
|
||||
| Device ID | Badge serial, key stamp, seal serial, HSM serial. |
|
||||
| Device type | Badge / mechanical key / PIN / combination / seal / hardware token. |
|
||||
| Scope | Which barriers it opens, or which asset it protects. |
|
||||
| Holder | Named individual. Shared holders are not acceptable for badges or PINs. |
|
||||
| Issued date, issued by | Audit trail for [c]. |
|
||||
| Returned or revoked date, reason | Populated on personnel change. |
|
||||
|
||||
### 5.2 Control ([b])
|
||||
|
||||
- Issue only to individuals named in the [§2.1](#21-authorized-individuals-a) access list. Scope of the device must not exceed that individual's authorized scope.
|
||||
- Badges and PINs must be unique per holder. Mechanical keys that must be shared (e.g. a single rack key) are permitted only when distribution is tracked by check-out/check-in against the register.
|
||||
- Lost or compromised devices trigger immediate revocation: badges deactivated in the access-control system, PINs changed, affected mechanical locks re-keyed within 30 days, tamper seals re-applied on the next site visit. Record the event and remediation in the register.
|
||||
- Terminated or reassigned personnel surrender all devices on last day. Mark returned in the register.
|
||||
|
||||
### 5.3 Management ([c])
|
||||
|
||||
- **Inventory review:** at least quarterly, reconcile the device register against (a) the access-control system's badge database, (b) the physical key count, and (c) the authorized-access list in [§2.1](#21-authorized-individuals-a). Discrepancies must be resolved before the review is marked complete.
|
||||
- **Rotation / re-keying:** mechanical locks re-keyed on loss of any key, and at least every five years. Default combinations (safe, cabinet, BMC default passwords at install) must be changed before the device enters service.
|
||||
- **Spares and master keys:** held in a secured location (locked cabinet or safe inside a protected space), accessible only to named individuals, with any access itself logged under [§4](#4-physical-access-logs-pel1-3104).
|
||||
|
||||
## 6. Evidence and Record Keeping
|
||||
|
||||
For CMMC assessment, retain the following for at least 12 months (longer where facility or contract policy requires):
|
||||
|
||||
- **Authorized access list** ([§2.1](#21-authorized-individuals-a)) with review dates and approver names.
|
||||
- **Access-control system configuration** showing which badges are permitted at which readers, exported or screenshotted at each quarterly review.
|
||||
- **Physical access logs** ([§4](#4-physical-access-logs-pel1-3104)) and, where applicable, CCTV retention policy and sample recordings.
|
||||
- **Visitor logs** with escort names for every visit.
|
||||
- **Device register** ([§5.1](#51-identification-a)) with issue, return, and revocation history.
|
||||
- **Incident records** for door-forced/door-held events, lost devices, failed seal checks, and any access-review discrepancies, with remediation outcomes.
|
||||
- **Annual review attestation** from the facility or security owner confirming the controls above operated for the prior 12 months.
|
||||
|
||||
## 7. Operator Checklist
|
||||
|
||||
Use this list to confirm a site meets the four CMMC practices before admitting Spinifex nodes to a production cluster:
|
||||
|
||||
- Authorized-access list exists, names every protected space, is reviewed at least quarterly, and has an approver for every entry.
|
||||
- Every protected space enforces authentication at the barrier; shared credentials are not in use for badged/PIN'd spaces.
|
||||
- Remote/edge enclosures are locked, sealed, and listed in the asset register with seal serials recorded.
|
||||
- Visitor procedure is documented: sign-in, escort (one-per-visitor at equipment), sign-out.
|
||||
- CCTV or equivalent monitoring covers the rack fronts, rears, and console positions, with 30-day minimum retention.
|
||||
- Physical access logs are produced for every entry, retained 12 months, and reviewed on the cadence in [§4.2](#42-retention-and-review).
|
||||
- Door-forced, door-held, and out-of-hours events alert the on-call operator.
|
||||
- Physical access events forwarded to the SIEM used for Spinifex service logs (see [Malware Protection §3](/docs/security/malware-protection#3-scan-schedule-sil1-3145)).
|
||||
- Device register reconciles quarterly against the access-control system and physical key count; discrepancies are closed before sign-off.
|
||||
- Lost-device and termination procedures trigger immediate revocation per [§5.2](#52-control-b).
|
||||
- System security plan references this guide and records the facility, access-control product, CCTV retention, and log-forwarding destination.
|
||||
@@ -56,9 +56,8 @@ Deploy a VPC with public and private subnets where the private subnet has no int
|
||||
**Prerequisites:**
|
||||
|
||||
- Spinifex installed and running (see [Installing Spinifex](/docs/install))
|
||||
- A Debian 12 AMI imported (see [Setting Up Your Cluster](/docs/setting-up-your-cluster))
|
||||
- An Ubuntu 26.04 AMI imported (see [Setting Up Your Cluster](/docs/setting-up-your-cluster))
|
||||
- OpenTofu or Terraform installed
|
||||
- `AWS_PROFILE=spinifex` configured
|
||||
|
||||
## Instructions
|
||||
|
||||
@@ -79,6 +78,8 @@ Or create a `main.tf` file and paste the full configuration below.
|
||||
|
||||
### Step 2. Deploy
|
||||
|
||||
The workbook defaults to `t3.small` (2 vCPU, 2 GiB) for both bastion and private instance. On clusters without that type registered, override with `TF_VAR_instance_type` — query what's available with `aws ec2 describe-instance-types`.
|
||||
|
||||
```bash
|
||||
export AWS_PROFILE=spinifex
|
||||
tofu init
|
||||
@@ -148,7 +149,7 @@ The private route table should have no `0.0.0.0/0` route. Also verify the privat
|
||||
|
||||
### AMI Not Found
|
||||
|
||||
Ensure you have imported a Debian 12 image:
|
||||
Ensure you have imported an Ubuntu 26.04 image:
|
||||
|
||||
```bash
|
||||
aws ec2 describe-images --owners 000000000000 --profile spinifex
|
||||
@@ -52,9 +52,14 @@ variable "region" {
|
||||
default = "ap-southeast-2"
|
||||
}
|
||||
|
||||
variable "instance_type" {
|
||||
type = string
|
||||
default = "t3.small"
|
||||
}
|
||||
|
||||
variable "spinifex_endpoint" {
|
||||
type = string
|
||||
default = "https://localhost:9999"
|
||||
default = "https://127.0.0.1:9999"
|
||||
description = "Spinifex AWS gateway endpoint"
|
||||
}
|
||||
|
||||
@@ -85,13 +90,13 @@ data "aws_availability_zones" "available" {
|
||||
state = "available"
|
||||
}
|
||||
|
||||
data "aws_ami" "debian12" {
|
||||
data "aws_ami" "ubuntu" {
|
||||
most_recent = true
|
||||
owners = ["000000000000"]
|
||||
|
||||
filter {
|
||||
name = "name"
|
||||
values = ["*debian-12*"]
|
||||
values = ["*ubuntu-24.04*"]
|
||||
}
|
||||
|
||||
filter {
|
||||
@@ -275,8 +280,8 @@ resource "aws_security_group" "private" {
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
resource "aws_instance" "bastion" {
|
||||
ami = data.aws_ami.debian12.id
|
||||
instance_type = "t3.small"
|
||||
ami = data.aws_ami.ubuntu.id
|
||||
instance_type = var.instance_type
|
||||
|
||||
subnet_id = aws_subnet.public.id
|
||||
vpc_security_group_ids = [aws_security_group.bastion.id]
|
||||
@@ -307,8 +312,8 @@ resource "aws_instance" "bastion" {
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
resource "aws_instance" "private" {
|
||||
ami = data.aws_ami.debian12.id
|
||||
instance_type = "t3.small"
|
||||
ami = data.aws_ami.ubuntu.id
|
||||
instance_type = var.instance_type
|
||||
|
||||
subnet_id = aws_subnet.private.id
|
||||
vpc_security_group_ids = [aws_security_group.private.id]
|
||||
@@ -58,7 +58,7 @@ Deploy two Nginx web servers behind an internet-facing Application Load Balancer
|
||||
| Elastic IP | `nginx-alb-nat-eip` | Public address for the NAT Gateway |
|
||||
| NAT Gateway | `nginx-alb-nat` | Outbound internet for the private subnets (cloud-init apt bootstrap) |
|
||||
| Security Group | `nginx-alb-sg` | Allows SSH (22) and HTTP (80) inbound |
|
||||
| EC2 Instances | `nginx-alb-1`, `nginx-alb-2` | Debian 12 with Nginx via cloud-init (private subnets) |
|
||||
| EC2 Instances | `nginx-alb-1`, `nginx-alb-2` | Ubuntu 26.04 with Nginx via cloud-init (private subnets) |
|
||||
| ALB | `nginx-alb` | Internet-facing Application Load Balancer on port 80 |
|
||||
| Target Group | `nginx-alb-tg` | HTTP health-checked group for both instances |
|
||||
| Listener | HTTP :80 | Forwards traffic to the target group |
|
||||
@@ -66,9 +66,8 @@ Deploy two Nginx web servers behind an internet-facing Application Load Balancer
|
||||
**Prerequisites:**
|
||||
|
||||
- Spinifex installed and running (see [Installing Spinifex](/docs/install))
|
||||
- A Debian 12 AMI imported (see [Setting Up Your Cluster](/docs/setting-up-your-cluster))
|
||||
- An Ubuntu 26.04 AMI imported (see [Setting Up Your Cluster](/docs/setting-up-your-cluster))
|
||||
- OpenTofu or Terraform installed
|
||||
- `AWS_PROFILE=spinifex` configured
|
||||
|
||||
## Instructions
|
||||
|
||||
@@ -87,14 +86,16 @@ Or create a `main.tf` file and paste the full configuration below.
|
||||
|
||||
<!-- INCLUDE: main.tf lang:hcl -->
|
||||
|
||||
### Step 2. Install Load Balancer AMI
|
||||
### Step 2. Install Debian AMI
|
||||
|
||||
Next install the load balancer AMI images, which is used as the AMI disk image for launching the load-balancer and a requirement.
|
||||
Install the Debian 13 AMI which is used in the example to host the `nginx` webservers as an EC2 instance.
|
||||
|
||||
```bash
|
||||
spx admin images import --name lb-alpine-3.21.6-x86_64
|
||||
spx admin images import --name debian-13-x86_64
|
||||
```
|
||||
|
||||
> **Note:** The load balancer itself runs as a direct-boot QEMU microvm using the kernel + initramfs bundled with the Spinifex distribution (`/usr/share/spinifex/microvm/`), so no separate LB AMI import is required.
|
||||
|
||||
### Step 3. Deploy
|
||||
|
||||
```bash
|
||||
@@ -120,7 +121,7 @@ export TF_VAR_instance_type="t3.small"
|
||||
Or alternatively, using the AWS CLI tool query your instance for available types (e.g Intel, AMD, ARM) that support 2 vCPUs and 1 GB RAM.
|
||||
|
||||
```bash
|
||||
TF_VAR_instance_type=$(aws ec2 describe-instance-types \
|
||||
export TF_VAR_instance_type=$(aws ec2 describe-instance-types \
|
||||
--query "sort_by(InstanceTypes[?VCpuInfo.DefaultVCpus==\`2\` && MemoryInfo.SizeInMiB>=\`1024\`], &MemoryInfo.SizeInMiB)[0].InstanceType" \
|
||||
--output text)
|
||||
```
|
||||
@@ -177,7 +178,7 @@ tofu destroy
|
||||
|
||||
### AMI Not Found
|
||||
|
||||
Ensure you have imported a Debian 12 image. Check available AMIs:
|
||||
Ensure you have imported an Ubuntu 26.04 image. Check available AMIs:
|
||||
|
||||
```bash
|
||||
aws ec2 describe-images --owners 000000000000 --profile spinifex
|
||||
@@ -186,7 +187,7 @@ aws ec2 describe-images --owners 000000000000 --profile spinifex
|
||||
If missing import:
|
||||
|
||||
```bash
|
||||
spx admin images import --name debian-12-x86_64
|
||||
spx admin images import --name ubuntu-26.04-x86_64
|
||||
```
|
||||
|
||||
### Provider Connection Refused
|
||||
@@ -1,14 +1,18 @@
|
||||
# Example: Nginx Web Servers with ALB on Spinifex
|
||||
#
|
||||
# Deploys a VPC with two public subnets (ALB + NAT Gateway) and two private
|
||||
# subnets hosting Nginx EC2 instances. An internet-facing Application Load
|
||||
# Balancer distributes HTTP traffic between the private workers, and the
|
||||
# NAT Gateway gives them outbound internet access so cloud-init can install
|
||||
# Nginx from the Debian apt repository.
|
||||
# Deploys a VPC with two public subnets hosting Nginx EC2 instances and an
|
||||
# internet-facing Application Load Balancer. Workers sit in public subnets
|
||||
# with auto-assigned public IPs purely so cloud-init can apt-install nginx.
|
||||
# The ALB targets them by primary private IP (target_type=instance), so
|
||||
# load-balanced traffic stays on the private VPC network.
|
||||
#
|
||||
# Demonstrates: VPC, public and private subnets, internet gateway, NAT
|
||||
# Gateway with Elastic IP, route tables, security group, key pair, cloud-init
|
||||
# user-data, EC2 instances, ALB, target group, and listener.
|
||||
# In a production deployment, workers would be in private subnets with
|
||||
# nginx baked into a custom AMI (or installed via a private repository
|
||||
# mirror), and a NAT Gateway would be unnecessary for this workload.
|
||||
#
|
||||
# Demonstrates: VPC, public subnets, internet gateway, route tables,
|
||||
# security group, key pair, cloud-init user-data, EC2 instances with
|
||||
# auto-assigned public IPs, ALB, target group, and listener.
|
||||
#
|
||||
# Usage:
|
||||
# cd spinifex/docs/terraform/nginx-alb
|
||||
@@ -60,7 +64,7 @@ variable "instance_type" {
|
||||
|
||||
variable "spinifex_endpoint" {
|
||||
type = string
|
||||
default = "https://localhost:9999"
|
||||
default = "https://127.0.0.1:9999"
|
||||
description = "Spinifex AWS gateway endpoint"
|
||||
}
|
||||
|
||||
@@ -92,13 +96,13 @@ data "aws_availability_zones" "available" {
|
||||
state = "available"
|
||||
}
|
||||
|
||||
data "aws_ami" "debian12" {
|
||||
data "aws_ami" "ubuntu" {
|
||||
most_recent = true
|
||||
owners = ["000000000000"] # Spinifex system images
|
||||
|
||||
filter {
|
||||
name = "name"
|
||||
values = ["*debian-12*"]
|
||||
values = ["*ubuntu-24.04*"]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -152,9 +156,10 @@ resource "aws_internet_gateway" "igw" {
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
resource "aws_subnet" "public_a" {
|
||||
vpc_id = aws_vpc.main.id
|
||||
cidr_block = "10.20.1.0/24"
|
||||
availability_zone = data.aws_availability_zones.available.names[0]
|
||||
vpc_id = aws_vpc.main.id
|
||||
cidr_block = "10.20.1.0/24"
|
||||
availability_zone = data.aws_availability_zones.available.names[0]
|
||||
map_public_ip_on_launch = true
|
||||
|
||||
tags = {
|
||||
Name = "nginx-alb-public-a"
|
||||
@@ -162,9 +167,10 @@ resource "aws_subnet" "public_a" {
|
||||
}
|
||||
|
||||
resource "aws_subnet" "public_b" {
|
||||
vpc_id = aws_vpc.main.id
|
||||
cidr_block = "10.20.2.0/24"
|
||||
availability_zone = data.aws_availability_zones.available.names[0]
|
||||
vpc_id = aws_vpc.main.id
|
||||
cidr_block = "10.20.2.0/24"
|
||||
availability_zone = data.aws_availability_zones.available.names[0]
|
||||
map_public_ip_on_launch = true
|
||||
|
||||
tags = {
|
||||
Name = "nginx-alb-public-b"
|
||||
@@ -172,58 +178,7 @@ resource "aws_subnet" "public_b" {
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Private Subnets (two AZs for the Nginx workers)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
resource "aws_subnet" "private_a" {
|
||||
vpc_id = aws_vpc.main.id
|
||||
cidr_block = "10.20.11.0/24"
|
||||
availability_zone = data.aws_availability_zones.available.names[0]
|
||||
|
||||
tags = {
|
||||
Name = "nginx-alb-private-a"
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_subnet" "private_b" {
|
||||
vpc_id = aws_vpc.main.id
|
||||
cidr_block = "10.20.12.0/24"
|
||||
availability_zone = data.aws_availability_zones.available.names[0]
|
||||
|
||||
tags = {
|
||||
Name = "nginx-alb-private-b"
|
||||
}
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# NAT Gateway — outbound internet for the private subnets
|
||||
#
|
||||
# Background plumbing: the private-subnet workers need outbound connectivity
|
||||
# during cloud-init so apt-get can install Nginx. A single NAT Gateway in
|
||||
# public_a provides SNAT for both private subnets via the private route table.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
resource "aws_eip" "nat" {
|
||||
domain = "vpc"
|
||||
|
||||
tags = {
|
||||
Name = "nginx-alb-nat-eip"
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_nat_gateway" "main" {
|
||||
allocation_id = aws_eip.nat.id
|
||||
subnet_id = aws_subnet.public_a.id
|
||||
|
||||
tags = {
|
||||
Name = "nginx-alb-nat"
|
||||
}
|
||||
|
||||
depends_on = [aws_internet_gateway.igw]
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Route Tables — public subnets egress via IGW, private subnets via NAT GW
|
||||
# Route Table — public subnets egress via IGW
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
resource "aws_route_table" "public" {
|
||||
@@ -249,29 +204,6 @@ resource "aws_route_table_association" "public_b" {
|
||||
route_table_id = aws_route_table.public.id
|
||||
}
|
||||
|
||||
resource "aws_route_table" "private" {
|
||||
vpc_id = aws_vpc.main.id
|
||||
|
||||
route {
|
||||
cidr_block = "0.0.0.0/0"
|
||||
nat_gateway_id = aws_nat_gateway.main.id
|
||||
}
|
||||
|
||||
tags = {
|
||||
Name = "nginx-alb-private-rt"
|
||||
}
|
||||
}
|
||||
|
||||
resource "aws_route_table_association" "private_a" {
|
||||
subnet_id = aws_subnet.private_a.id
|
||||
route_table_id = aws_route_table.private.id
|
||||
}
|
||||
|
||||
resource "aws_route_table_association" "private_b" {
|
||||
subnet_id = aws_subnet.private_b.id
|
||||
route_table_id = aws_route_table.private.id
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Security Group — SSH + HTTP inbound, all outbound
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -315,17 +247,13 @@ resource "aws_security_group" "web" {
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
resource "aws_instance" "nginx_1" {
|
||||
ami = data.aws_ami.debian12.id
|
||||
ami = data.aws_ami.ubuntu.id
|
||||
instance_type = var.instance_type
|
||||
|
||||
subnet_id = aws_subnet.private_a.id
|
||||
subnet_id = aws_subnet.public_a.id
|
||||
vpc_security_group_ids = [aws_security_group.web.id]
|
||||
key_name = aws_key_pair.nginx.key_name
|
||||
|
||||
# Workers pull nginx from apt via the NAT Gateway — creation must wait
|
||||
# until the NAT Gateway is available so cloud-init can reach the repos.
|
||||
depends_on = [aws_nat_gateway.main]
|
||||
|
||||
user_data_base64 = base64encode(<<-USERDATA
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
@@ -359,17 +287,13 @@ resource "aws_instance" "nginx_1" {
|
||||
}
|
||||
|
||||
resource "aws_instance" "nginx_2" {
|
||||
ami = data.aws_ami.debian12.id
|
||||
ami = data.aws_ami.ubuntu.id
|
||||
instance_type = var.instance_type
|
||||
|
||||
subnet_id = aws_subnet.private_b.id
|
||||
subnet_id = aws_subnet.public_b.id
|
||||
vpc_security_group_ids = [aws_security_group.web.id]
|
||||
key_name = aws_key_pair.nginx.key_name
|
||||
|
||||
# Workers pull nginx from apt via the NAT Gateway — creation must wait
|
||||
# until the NAT Gateway is available so cloud-init can reach the repos.
|
||||
depends_on = [aws_nat_gateway.main]
|
||||
|
||||
user_data_base64 = base64encode(<<-USERDATA
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
@@ -43,9 +43,8 @@ Deploy a complete Nginx web server on Spinifex using Terraform/OpenTofu. This wo
|
||||
**Prerequisites:**
|
||||
|
||||
- Spinifex installed and running (see [Installing Spinifex](/docs/install))
|
||||
- A Debian 12 AMI imported (see [Setting Up Your Cluster](/docs/setting-up-your-cluster))
|
||||
- An Ubuntu 26.04 AMI imported (see [Setting Up Your Cluster](/docs/setting-up-your-cluster))
|
||||
- OpenTofu or Terraform installed
|
||||
- `AWS_PROFILE=spinifex` configured
|
||||
|
||||
## Instructions
|
||||
|
||||
@@ -66,6 +65,8 @@ Or create a `main.tf` file and paste the full configuration below.
|
||||
|
||||
### Step 2. Deploy
|
||||
|
||||
The workbook defaults to `t3.small` (2 vCPU, 2 GiB). On clusters without that type registered, override with `TF_VAR_instance_type` — query what's available with `aws ec2 describe-instance-types`.
|
||||
|
||||
```bash
|
||||
export AWS_PROFILE=spinifex
|
||||
tofu init
|
||||
@@ -95,7 +96,7 @@ tofu destroy
|
||||
|
||||
### AMI Not Found
|
||||
|
||||
Ensure you have imported a Debian 12 image. Check available AMIs:
|
||||
Ensure you have imported an Ubuntu 26.04 image. Check available AMIs:
|
||||
|
||||
```bash
|
||||
aws ec2 describe-images --owners 000000000000 --profile spinifex
|
||||
@@ -41,9 +41,14 @@ variable "region" {
|
||||
default = "ap-southeast-2"
|
||||
}
|
||||
|
||||
variable "instance_type" {
|
||||
type = string
|
||||
default = "t3.small"
|
||||
}
|
||||
|
||||
variable "spinifex_endpoint" {
|
||||
type = string
|
||||
default = "https://localhost:9999"
|
||||
default = "https://127.0.0.1:9999"
|
||||
description = "Spinifex AWS gateway endpoint"
|
||||
}
|
||||
|
||||
@@ -74,13 +79,13 @@ data "aws_availability_zones" "available" {
|
||||
state = "available"
|
||||
}
|
||||
|
||||
data "aws_ami" "debian12" {
|
||||
data "aws_ami" "ubuntu" {
|
||||
most_recent = true
|
||||
owners = ["000000000000"] # Spinifex system images
|
||||
|
||||
filter {
|
||||
name = "name"
|
||||
values = ["*debian-12*"]
|
||||
values = ["*ubuntu-24.04*"]
|
||||
}
|
||||
|
||||
filter {
|
||||
@@ -215,12 +220,12 @@ resource "aws_security_group" "web" {
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# EC2 Instance — Debian 12 with Nginx installed via cloud-init
|
||||
# EC2 Instance — Ubuntu 26.04 with Nginx installed via cloud-init
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
resource "aws_instance" "nginx" {
|
||||
ami = data.aws_ami.debian12.id
|
||||
instance_type = "t3.small"
|
||||
ami = data.aws_ami.ubuntu.id
|
||||
instance_type = var.instance_type
|
||||
|
||||
subnet_id = aws_subnet.public.id
|
||||
vpc_security_group_ids = [aws_security_group.web.id]
|
||||
@@ -53,9 +53,8 @@ Deploy an EC2 instance running a Flask file-sharing web application backed by S3
|
||||
|
||||
- Spinifex installed and running (see [Installing Spinifex](/docs/install))
|
||||
- Predastore running (S3 API on port 8443)
|
||||
- A Debian 12 AMI imported (see [Setting Up Your Cluster](/docs/setting-up-your-cluster))
|
||||
- An Ubuntu 26.04 AMI imported (see [Setting Up Your Cluster](/docs/setting-up-your-cluster))
|
||||
- OpenTofu or Terraform installed
|
||||
- `AWS_PROFILE=spinifex` configured
|
||||
- The EC2 instance must be able to reach Predastore — use the host's br-wan IP, not localhost
|
||||
|
||||
## Instructions
|
||||
@@ -85,6 +84,8 @@ Before deploying, create a `terraform.tfvars` with your Predastore credentials.
|
||||
|
||||
### Step 4. Deploy
|
||||
|
||||
The workbook defaults to `t3.small` (2 vCPU, 2 GiB). On clusters without that type registered, override with `TF_VAR_instance_type` — query what's available with `aws ec2 describe-instance-types`.
|
||||
|
||||
```bash
|
||||
export AWS_PROFILE=spinifex
|
||||
tofu init
|
||||
@@ -152,7 +153,7 @@ cat /opt/webapp/.env
|
||||
|
||||
### AMI Not Found
|
||||
|
||||
Ensure you have imported a Debian 12 image:
|
||||
Ensure you have imported an Ubuntu 26.04 image:
|
||||
|
||||
```bash
|
||||
aws ec2 describe-images --owners 000000000000 --profile spinifex
|
||||
@@ -56,15 +56,20 @@ variable "region" {
|
||||
default = "ap-southeast-2"
|
||||
}
|
||||
|
||||
variable "instance_type" {
|
||||
type = string
|
||||
default = "t3.small"
|
||||
}
|
||||
|
||||
variable "spinifex_endpoint" {
|
||||
type = string
|
||||
default = "https://localhost:9999"
|
||||
default = "https://127.0.0.1:9999"
|
||||
description = "Spinifex AWS gateway endpoint (EC2/IAM)"
|
||||
}
|
||||
|
||||
variable "predastore_endpoint" {
|
||||
type = string
|
||||
default = "https://localhost:8443"
|
||||
default = "https://127.0.0.1:8443"
|
||||
description = "Predastore S3 endpoint (for Terraform to create buckets)"
|
||||
}
|
||||
|
||||
@@ -121,13 +126,13 @@ data "aws_availability_zones" "available" {
|
||||
state = "available"
|
||||
}
|
||||
|
||||
data "aws_ami" "debian12" {
|
||||
data "aws_ami" "ubuntu" {
|
||||
most_recent = true
|
||||
owners = ["000000000000"]
|
||||
|
||||
filter {
|
||||
name = "name"
|
||||
values = ["*debian-12*"]
|
||||
values = ["*ubuntu-24.04*"]
|
||||
}
|
||||
|
||||
filter {
|
||||
@@ -262,8 +267,8 @@ resource "aws_security_group" "webapp" {
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
resource "aws_instance" "webapp" {
|
||||
ami = data.aws_ami.debian12.id
|
||||
instance_type = "t3.small"
|
||||
ami = data.aws_ami.ubuntu.id
|
||||
instance_type = var.instance_type
|
||||
|
||||
subnet_id = aws_subnet.public.id
|
||||
vpc_security_group_ids = [aws_security_group.webapp.id]
|
||||