Compare commits

...

126 Commits

Author SHA1 Message Date
Julian Sommer 495587304b release: update go.mod for v1.4.0 2026-05-19 09:37:54 +10:00
tomnewton-mulga 01d9fa858f feat: iso release flow (#229) 2026-05-19 09:07:26 +10:00
Josh Siviour 4ae2a4ac53 feat(elbv2): QEMU microvm direct-boot for LB system instances (#228)
* feat(vm): direct-boot foundations + config/input extensions + image build pipeline

vm package (mulga-siv-62):
- Add KernelImage, Initrd, KernelCmdline, FwCfg/FwCfgEntry to vm.Config
- Emit -kernel/-initrd/-append/-fw_cfg args when fields set
- Relax drive invariant: accept kernel image in place of drives
- Add devices.go: IsMMIO predicate, NetDevice/BlkDevice/RngDevice helpers
- Refactor lifecycle.go + network_helpers.go call sites to use helpers
- shutdown.go: os.Remove fw_cfg tmpfiles after QEMU exits
- Add devices_test.go, microvm_test.go; extend vm_test.go

config/input extensions (mulga-siv-63):
- Add MicrovmConfig{ELBv2Enabled, ImagePath} to DaemonConfig
- ImagePath falls back to /usr/share/spinifex/microvm when empty
- Add commented [microvm] section to spinifex.toml template
- Add DirectBoot bool + NICs []NICConfig to SystemInstanceInput

image build pipeline (mulga-siv-64):
- scripts/build-microvm-image.sh: Alpine chroot build producing vmlinuz + initramfs.cpio.gz
- build/microvm/init.sh: fw_cfg-driven PID 1 init (mounts, modprobe, NIC config, lb-agent-env, ca-cert)
- build/microvm/inittab: BusyBox init with lb-agent respawn
- make build-microvm-image target
- setup.sh: install microvm/* to /usr/share/spinifex/microvm/
- .gitignore: exclude built vmlinuz + initramfs.cpio.gz artifacts

* feat(daemon): direct-boot launch path for microvm system instances

When SystemInstanceInput.DirectBoot=true:
- Skip GetAMIConfig and GenerateVolumes
- Build vm.Config directly (no buildBaseVMConfig, no PCIe root ports)
- MachineType=microvm,x-option-roms=off,pic=off,pit=off,rtc=on,acpi=on,isa-serial=on
- Resolve kernel/initrd from Microvm.ImagePath config field
- Serialise NICs to three fw_cfg tmpfiles (netcfg, lb-agent-env, ca-cert)
- Validate exactly one NICConfig.IsDefault=true before writing netcfg blob
- Shutdown path cleans up tmpfiles via existing Config.FwCfg iteration

PC-machine path (DirectBoot=false) unchanged.

Closes mulga-siv-65

* feat(elbv2): wire microvm.elbv2_enabled flag into ELBv2 service

When ELBv2Enabled=true:
- Drop lbVMUserData cloud-config; emit KEY=value LBAgentEnv blob instead
- Set SystemInstanceInput.DirectBoot=true; omit ImageID
- Populate NICs []NICConfig (index 0=primary ENI IsDefault, 1=mgmt, 2+=extra ENIs)
- Validate exactly one IsDefault NIC at service layer

When ELBv2Enabled=false: existing PC-machine path unchanged.

Adds buildLBAgentEnv, buildMicrovmNICs, subnetCIDRForIP, subnetGatewayIP helpers.
Tests in arn_test.go cover both flag-on and flag-off paths.

Closes mulga-siv-66

* test(daemon,vm): add unit tests for direct-boot helpers to meet diff coverage threshold

* feat(daemon): Phase 1 perf instrumentation + e2e runner microvm flag

- Log t1→t2 boot timing via slog on the direct-boot path
- measureQEMURSSMiB helper reads /proc/<pid>/status (tested)
- Gate constants: phase1BootGateMs=500, phase1RSSGateMiB=25
- run-lb-e2e.sh: --microvm flag, dual-path CI annotation, density test note

Closes mulga-siv-67

* fix(build): remove busybox-initscripts — not in Alpine 3.20, not needed

* fix(microvm): end-to-end fixes for direct-boot LB path

Serial output was silent (x-option-roms=off suppresses BIOS serial init);
fixed by removing it from the machine string and using -serial chardev:console0
to attach to the machine-created ISA serial device instead of adding a
duplicate isa-serial device (which would create ttyS1 while kernel uses ttyS0).

virtio-net interfaces were invisible to the kernel because QEMU's
auto-kernel-cmdline does not append virtio_mmio.device= params when -append
is used. Fixed by generating explicit virtio_mmio.device=0x200@0xfebNNNNN:N
params for each NIC in KernelCmdline, and modprobing virtio_mmio + virtio_net
in init.sh.

sys.micro bumped to 512 MiB — 128 MiB caused kernel OOM during initramfs
decompression (~68 MiB uncompressed).

BusyBox init ::respawn: entries do not source or export env files; fixed
inittab to use set -a; . /etc/conf.d/lb-agent; set +a before exec.

wireLBAgentConfig was documented as reading the CA cert but never set
elbv2Service.CACert; lb-agent TLS handshakes to AWSGW failed with
x509: certificate signed by unknown authority. Fixed by reading
d.config.NATS.CACert (same local CA that signs the AWSGW server cert)
and assigning to elbv2Service.CACert so writeFwCfgBlobs writes a
non-empty cacert blob into fw_cfg.

Adds vm_test TestExecute_MicrovmFileChardev and updates lifecycle_test
to assert SerialSocket is intentionally empty on direct-boot VMs.

* fix(build): auto-detect docker/podman when apk unavailable for microvm image build

* fix(build): create /dev/console and /dev/null as proper device nodes in initramfs

tar extraction without root silently demotes device files to 0-byte regular
files (mknod requires CAP_MKNOD). The kernel opens /dev/console when wiring
init's stdio to the serial console; a regular file there causes all init output
to be silently discarded to RAM — the VM appears stuck at 'Run /init as init
process' with no subsequent console output and lb-agent never heartbeats.

Fix: after rootfs population, wipe /dev and recreate console (c 5 1) and
null (c 1 3) via sudo mknod. devtmpfs provides all other devices at runtime
once init.sh mounts it.

* fix(microvm): fix mgmt NIC routing for all LB schemes

Two bugs prevented lb-agent from reaching the AWSGW via the mgmt interface:

1. RouteDst/RouteVia swapped — resolveMgmtRoute returns (gateway, target) but
   buildMicrovmNICs assigned them as (Dst=gateway, Via=target), producing
   'ip route add 10.15.8.1 via 192.168.1.33' instead of the correct
   'ip route add 192.168.1.33 via 10.15.8.1'.

2. NIC[1] MAC and CIDR never filled — the daemon allocates mgmt IP/MAC
   after buildMicrovmNICs returns, but didn't inject them into input.NICs[1]
   before calling writeFwCfgBlobs, leaving NIC1_MAC= empty in the netcfg
   blob and skipping interface setup in init.sh entirely.

Also extends mgmt routing to internet-facing scheme in the microvm path:
VPC SNAT is not reliable across all VPC topologies for control-plane
heartbeats; mgmt is always a direct L2 path to the host.

* feat(microvm): Phase 2 kernel stripping + boot optimisation

- cpu: switch to host,-pmu (disable PMU — not needed in microvm guest)
- machine type: make isa-serial configurable via microvm.isa_serial config
  field; default true (on) preserves console log path
- build: strip unused kernel modules (USB, IDE, ATA, GPU, media, input,
  HID, bluetooth, staging, sound, wireless) from initramfs before cpio
- build: add Phase 2 artifact size gate — fail if vmlinuz + initramfs.cpio.gz
  exceeds MICROVM_ARTIFACT_MAX_MiB (default 50 MiB); override via env var
  during development
- perf: add phase2BootGateMs=300 and phase2ArtifactGateMiB=50 constants
  with canary tests to catch unintentional gate changes
- tests: cover buildMicrovmNICs nil-VPC and extra-ENI paths
- tests: cover microvmMachineType nil/true/false branches

* fix(build): use find-based virtio+fw_cfg module keep-list in initramfs strip

The directory-only keep-list (drivers/virtio, drivers/firmware) missed
virtio_net.ko which lives in drivers/net/, virtio_blk in drivers/block/,
and virtio-rng in drivers/char/hw_random/. Switch to find-based approach:
collect all virtio*.ko* and *fw_cfg*.ko* regardless of subdirectory, then
restore them into their original relative paths under kernel/.

Artifacts now: 42 MiB (vmlinuz 11 MiB + initramfs 31 MiB), gate passes.

* fix(build): keep failover modules in initramfs strip

virtio_net depends on net_failover/failover for live-migration support.
Stripping them left virtio_net with unresolved symbols
(net_failover_create/destroy), so the module failed to load and the
guest came up with only lo. init.sh then skipped every NIC ("no
interface found for MAC ..."), leaving lb-agent with no network path
to AWSGW and the LB stuck in provisioning.

Add *failover*.ko* to the keep-list. Image size unchanged at 42 MiB
(under the 50 MiB Phase 2 gate). ELBv2 e2e now reaches active and
2/2 targets healthy for both ALB and NLB internal LBs.

* fix(daemon): restore slog default after viperblock.New mutates it

viperblock.New unconditionally calls vb.SetDebug, which calls
slog.SetDefault with a TextHandler at LevelError — globally silencing
every slog.Info and slog.Warn in the caller process for the rest of
its life. Discovered while validating microvm Phase 2 (mulga-siv-68):
the daemon stopped emitting Info logs the moment the first customer
VM with volumes launched, hiding direct-boot timing,
LaunchSystemInstance completed, and CreateLoadBalancer completed
lines that we need for boot-time tuning.

Workaround in newViperblock: re-install an Info-level TextHandler on
os.Stderr after each viperblock.New call. Also drop the three explicit
vb.SetDebug(false) calls in prepareRootVolume / prepareEFIVolume /
prepareCloudInitVolume — they were no-ops (viperblock.New already
runs SetDebug internally) and each one re-triggered the global
mutation we are trying to undo.

Proper fix is in viperblock itself — tracked as mulga-siv-70: SetDebug
should not call slog.SetDefault. Until then, this restore call is the
shortest path to getting the daemon's observability back so we can
measure t1_to_t2_ms and tune Phase 2.

Preflight note: golangci-lint not available on this dev host, so the
project preflight target cannot run end-to-end. Verified with
go vet ./spinifex/handlers/ec2/instance/... (clean) and
go test ./spinifex/handlers/ec2/instance/... (pass).

* perf(microvm): hit Phase 2 boot/memory gates

t1→t2 hypervisor: 3224 ms → 264 ms (12×). 128 MiB guest now boots
cleanly without OOM, reverting the temporary 512 MiB bump.

Root causes:

1. build-microvm-image.sh staged the entire chroot into the cpio
   without dropping /boot, embedding a duplicate vmlinuz, Alpine's
   stock initramfs-virt, System.map, and a "boot → ." symlink loop
   (~25 MiB). Decompression peak exceeded 128 MiB. Strip /boot after
   the kernel is copied out, before the cpio. Initramfs drops from
   31 MiB → 11 MiB (53 MiB → 28 MiB uncompressed); total artifact
   42 MiB → 22 MiB.

2. vm/lifecycle.go burned 3 s of fixed sleep on every direct-boot
   launch: 2 s nbdkit pre-exec wait (no nbdkit on direct-boot) and
   1 s post-fork settle (QEMU binds QMP within ~10 ms when there is
   no firmware, ROM, or block layer). Both extracted to
   directBoot-aware helpers — nbdkitPreExecWait collapses to 0, and
   qemuStartSettleWait to 50 ms.

3. KernelCmdline only carried `console=ttyS0 virtio_mmio.device=…`.
   Added boot-time perf flags: quiet loglevel=3 (silence printk over
   115200-baud isa-serial), mitigations=off (KVM guest on trusted
   host, single-tenant LB workload), tsc=reliable, no_timer_check,
   reboot=t, i8042.{nopnp,noaux,nokbd} (microvm has no PS/2).

4. init.sh dropped modprobe virtio_mmio (built-in:
   CONFIG_VIRTIO_MMIO=y) and tightened polling: fw_cfg/iface waits go
   from 10 × 200 ms (up to 2 s) to 5 × 20 ms (up to 100 ms).

Phase 2 gates:
  - t1→t2 ≤ 300 ms       ✅ 264 ms / 281 ms
  - artifact ≤ 50 MiB    ✅ 22 MiB (gate already in build script)
  - ELBv2 e2e green      ✅ 32/33 (pre-existing client-VM failure,
                                   unrelated to microvm path)

* build(microvm): wire build into deploy + distro + release pipeline

Adds install-microvm Makefile target (with grouped-target staleness
detection on vmlinuz/initramfs.cpio.gz), wires it into dev-install.sh
and reset-dev-env.sh, makes 'make deploy' reinstall artifacts when
present, and makes 'make distro-amd64' stage build/microvm/* into the
release tarball staging dir. arm64 distro stays unchanged (no aarch64
microvm artifact yet — Phase 4).

Release workflow gains a setup-go step and a 'make build-microvm-image'
step before 'make distro-amd64' on the amd64 runner so the resulting
tarball includes the kernel + initramfs without a manual copy.

Precursor for the Phase 3 PC-machine LB removal (mulga-siv-69).
Closes mulga-siv-71.

* build(ansible): include microvm artifacts in build role tarball

Adds a build-microvm-image task to the ansible build role and stages
build/microvm/* into the role's release tarball so the install role's
setup.sh run picks them up via INSTALL_SPINIFEX_TARBALL. Skipped
gracefully when the build script is absent (older branches without
direct-boot LB wiring).

* feat(config): enable microvm direct-boot LB path by default

Uncomments the [nodes.<node>.daemon.microvm] section in the generated
spinifex.toml template and sets elbv2_enabled = true so all new
clusters bring up ELBv2 LBs via the direct-boot microvm path. Operators
can opt back to the legacy PC-machine path by flipping the flag to
false in the rendered config.

Required for end-to-end CI on this branch to exercise the direct-boot
path. Existing clusters' configs are unchanged (the template only
affects fresh 'spx admin init' renders).

* fix(microvm): bootstrap apk.static when no host rootfs tool exists

Self-hosted CI runners and the GitHub-hosted ubuntu-24.04 image often
have none of apk, docker, or podman installed. apk-tools is not in the
Debian 13 (trixie) repositories. Without one of these the rootfs build
fails up front with 'no rootfs build tool available'.

When all three are absent, fetch apk-tools-static from the configured
Alpine mirror, extract it to a tempdir, and prepend it to PATH so the
existing native-apk strategy below picks it up transparently. Requires
only curl + tar, both of which are present on every CI runner that can
already check out source.

No-op when apk, docker, or podman is already present.

Unblocks https://github.com/mulgadc/spinifex/actions/runs/25988830244

* revert: drop apk.static bootstrap in build-microvm-image

Reverts 0bd76b7. Self-hosted runners will get docker.io installed
out of band; existing Strategy 2 (docker run alpine:3.20) handles
the rootfs build without host sudo or unsigned package install.

* docs(workbook): drop lb-alpine import for microvm direct-boot LB

LBs now launch via direct-boot QEMU microvm using kernel + initramfs
bundled at /usr/share/spinifex/microvm/, so the legacy lb-alpine system
AMI import is no longer required.

* fix(daemon): own tap devices by daemon user so QEMU can attach without CAP_NET_ADMIN

When the daemon runs non-root (production: User=spinifex-daemon), child
QEMU processes inherit that uid but lack CAP_NET_ADMIN. The kernel's tun
attach check then requires the tap owner uid to match the caller's euid.
Without the `user` flag, `ip tuntap add` defaults to root:root and qemu's
TUNSETIFF fails with EPERM ("could not configure /dev/net/tun: Operation
not permitted").

Surfaced by the e2e-nightly cell-17 microvm LB launch on the ci-pseudo
runner where the kernel enforces strictly; legacy EC2 launches on
ci-single runners happened to attach successfully despite the same code
path. Adding `user $current group $gid` to `ip tuntap add` ensures the
tap is owned by whatever uid the daemon (and its qemu children) run as.
Root daemons skip the flag — they have CAP_NET_ADMIN ambient.

* fix(e2e): redirect lb probe chatter to stderr so stalls are visible

test_http_from / test_tcp_from echoed progress to stdout, which the
callers captured into *_OK vars via $(...). Result: 6+ minute retry
loops ran silently in CI, and the "$ok" comparison never matched
"true" because the captured value contained the full multi-line
chatter. Reachability pass/fail counters were also lost in the
subshell.

Redirect helper output to stderr, leave only the true/false flag on
stdout, and move pass/fail to the caller so they actually count.

* fix(microvm): don't /32 AdvertiseIP via mgmt for internet-facing single-node

buildMicrovmNICs overrode resolveMgmtRoute's deliberate empty return
for the single-node internet-facing case by falling back to
(MgmtBridgeIP, AdvertiseIP). That installed a /32 route to AdvertiseIP
via the mgmt NIC inside the LB microvm. On single-node where the
AWSGW host IP equals the WAN client IP, the /32 also catches reply
traffic for inbound HTTP/TCP to the LB's floating IP — replies leave
via the mgmt NIC with src=data-NIC IP, the host kernel drops the
asymmetric flow, and same-chassis ingress hangs.

resolveMgmtRoute already encodes the correct policy:
  - multi-node any scheme        → explicit MgmtRoute{Gateway,Target}
  - single-node internal         → fallback to (MgmtBridgeIP, AdvertiseIP)
  - single-node internet-facing  → empty (reach AWSGW via VPC SNAT)

Honor it. Matches the legacy lbVMUserData path, whose own comment
flagged the stolen-WAN-return-path symptom this introduced.

Test covers all three branches.

* feat(ansible): dev-deploy playbook for in-place artifact swap

Fills the gap between ansible-dev-install (skips when already installed)
and ansible-dev-reset (full teardown + reinstall + smoketest). Mirrors
Makefile `deploy` target: rebuild + replace binaries and microvm
artifacts on a running node, restart spinifex.target, wait for awsgw.

Skips setup.sh (no user/dir/systemd-unit changes) and smoketest so
iteration is fast. Use ansible-dev-reset when changing systemd units,
helper scripts, logrotate, or setup.sh itself.

Adds:
- roles/deploy/tasks/main.yml — install spx + nbdkit plugin + microvm
  artifacts, with plugindir auto-detection via `nbdkit --dump-config`.
- playbooks/dev-deploy.yml — chains build + deploy roles.
- Makefile ansible-dev-deploy target.
- README playbook table + "when to use which" guidance.

* chore(microvm): remove legacy LB Alpine image build pipeline

Phase 3 microvm removal — strip the PC-machine LB image build
artifacts that direct-boot no longer needs. The microvm initramfs
already carries lb-agent + HAProxy via build-microvm-image, so the
lb.conf manifest, lb-setup.sh chroot script, build-lb-image target,
dev-install / reset-dev-env import steps, and ansible LB-image tasks
all become dead.

build-system-image stays — other manifests (e.g. GPU images) still
use it.

* refactor(microvm): remove legacy PC-machine LB boot path

Phase 3 — direct-boot becomes the only ELBv2 LB launch path.

- Drop SystemInstanceInput.DirectBoot / ImageID / UserData (every LB
  caller set DirectBoot=true; legacy cloud-init userdata is gone).
- Drop ELBv2ServiceImpl.lbVMUserData, systemAMI* fields, SetSystemAMIFunc,
  getSystemAMI, and the lazy-AMI wiring in daemon.startCluster.
- Collapse CreateLoadBalancer to a single direct-boot launch input.
- Collapse daemon.LaunchSystemInstance: drop AMI validation, ImageID
  on RunInstancesInput, UserData base64 encode, GenerateVolumes branch.
  buildDirectBootConfig hardcodes /usr/share/spinifex/microvm and
  isa-serial=on (microvmMachineType no longer takes a flag).
- Drop MicrovmConfig from config.DaemonConfig and the
  [daemon.microvm] section from the spinifex.toml template.
- Prune dead tests (lbVMUserData_*, PCMachine_Unchanged, AMI resolver
  paths) and refresh stale comments that still mentioned lbVMUserData.

ARM hosts have no LB launch path until Phase 4 adds direct-boot on
virt — CreateLoadBalancer on aarch64 fails naturally at QEMU exec.

* docs: drop lb-alpine catalog entry from cluster setup guide

LB system instances no longer use an AMI — direct-boot microvm
artifacts ship in the spinifex package.

* feat(ui): remove LB image import banner + LB-AMI gating

Direct-boot microvm artifacts ship in the spinifex package, so the
LB system AMI no longer exists — the "Load balancer image not imported"
banner and the Create-button disable on the LB list / wizard pages
can never fire. Strip:

- lb-image-missing-banner.tsx component
- hasLbImage / isLbImage / LB_IMAGE_NAME / LB_MANAGED_BY_VALUE from
  system-managed.ts (isSystemManagedImage stays — still used by the
  EC2 Images pages to hide system AMIs)
- Banner gating + ec2ImagesQueryOptions imports from the LB list and
  wizard pages
- The corresponding tests that asserted the banner appears

* revert: restore lb.conf + lb-setup.sh for mulga install pipeline

Phase 3 microvm removal (commit 6313d74) deleted these too early — the
mulga install/iso artifact builders (scripts/tofu-cluster/build-install
-artifacts.sh, scripts/tofu-iso/build-iso-artifacts.sh) still invoke
scripts/build-system-image.sh scripts/images/lb.conf, breaking e2e with
"ERROR: Manifest not found: scripts/images/lb.conf".

Restore both files verbatim so the legacy pipeline keeps building until
the mulga-side migration to microvm-only artifacts lands post-merge.
2026-05-18 15:48:04 +10:00
joshsiv-mulga 35ad76096b fix(daemon): don't fall back to local start on ec2.start forward timeout
The 5s ec2.start.{lastNode} forward timeout was firing before the target
node finished StartStoppedInstance (cold start = volume rehydrate + QEMU
launch + QMP handshake, ~10-20s on a t3a.nano). The forwarding daemon
then fell back to starting the same instance locally, racing the
still-running launch on the original node. Both attempts derive the same
deterministic tap name from the ENI, so the second one fails with
TUNSETIFF: Device or resource busy.

Surfaced on the pseudo-multinode E2E (run 26006933491) where all three
daemons share one kernel and the collision is unavoidable; real
multi-node hides it because separate kernels can each hold the same tap
name without conflict (but the duplicate Run is still semantically
wrong).

Changes:

* Bump startStoppedForwardTimeout from 5s to 30s so it matches the
  awsgw upstream NATS request budget. A slow-but-alive target node
  finishes well within this window.
* Distinguish nats.ErrNoResponders (no active subscriber on
  ec2.start.{lastNode} — node genuinely down) from a timeout or other
  transport error (subscriber existed but didn't respond). Only the
  no-responders case justifies a local-fallback Run; everything else
  surfaces ServerInternal so the caller retries instead of double-
  launching.
* Add an info log on every forward attempt for easier diagnosis.
2026-05-18 12:32:07 +10:00
Josh Siviour 7eff142c34 feat(e2e): bare-metal multi-node cluster support + density-aware guards (#227)
* feat(predastore): support 2-node distributed mode with RS(1,1)

Lower minimum node threshold from 3 to 2. Emit data=1,parity=1 when
exactly 2 nodes configured — effectively mirroring. 3+ nodes continue
to use RS(2,1).

* fix(ec2): route StartInstances back to original node with node-down fallback

ec2.start used a plain queue group, so NATS could route a restart request to
any daemon — including a capacity-constrained NUC that can't fit a second
instance. This caused InsufficientInstanceCapacity in the Phase 10 stop/start
test on 2-node bare-metal clusters where one node has significantly fewer
resources.

Each daemon now also subscribes to ec2.start.{nodeId} (no queue group). The
queue-group handler peeks at the instance ID, looks up LastNode from the
stopped KV, and forwards to ec2.start.{lastNode}. If the original node does
not respond within 5 seconds (down or partitioned), the handler falls back to
starting locally on whichever node picked up the request — preserving the
existing behaviour for node-failure scenarios.

The direct handler (ec2.start.{nodeId}) always starts locally and never
forwards, preventing routing loops.

* fix(ec2): fall back to local start when original node returns InsufficientInstanceCapacity

The forwarding handler was relaying all responses from the targeted node
unconditionally, including InsufficientInstanceCapacity. This meant that if
the NUC (node2) was the LastNode but had no remaining capacity, the error
propagated to the caller rather than falling back to the local node which may
have room.

Now only success and non-capacity errors are relayed; an InsufficientInstance-
Capacity response from the targeted node triggers a local start attempt, giving
any other node in the cluster a chance to absorb the instance.

* test(e2e): make VPC stop/start restart non-fatal on InsufficientInstanceCapacity

Asymmetric hardware (NUC node2) may not have capacity to restart all
VPC instances. IP-persists-while-stopped is still verified and fails
hard; restart verification is skipped with a warning on capacity errors.

* test(e2e): density-aware capacity guard across bare-metal multi-node suite

Sub-3-node bare-metal clusters with asymmetric/undersized nodes (e.g. a
4-vCPU NUC alongside a 16-vCPU box) can fit only one t3.nano on the
smallest node after the daemon's host reserve. Phases that direct or
indirectly land >1 instance on that node hit InsufficientInstanceCapacity
and previously hard-failed the entire suite.

Add is_density_skip helper and skip_test counter, and wrap every
density-sensitive run-instances / start-instances / wait-for-running call
site (phases 3, 5, 7, 10, 11) so capacity exhaustion on sub-3-node
clusters records a SKIP rather than a FAIL. On 3+ node clusters the
guard short-circuits to false, preserving existing failure semantics.

Summary block now prints Skipped count + names so degraded coverage is
visible in CI logs.

Refs: mulga-siv-72

* test(e2e): parameterise lb SSH key + user via PEER_SSH_KEY / PEER_SSH_USER

Allow run-lb-e2e.sh to run against clusters that don't use tf-user with the
tofu-cluster SSH key (e.g. bare-metal nodes which use spinifex@ + bm-ci key).
Defaults preserve existing behaviour on the tofu-cluster CI path.

Refs: mulga-siv-73
2026-05-18 10:18:04 +10:00
joshsiv-mulga 5b3ced92f9 fix(e2e): increase SSH wait timeout and fix console log path for bm
- SSH wait: 60 → 120 attempts; NBD-backed VMs on bare metal take
  longer to boot than tofu-cluster VMs with local storage
- Diagnostic console log: add /run/spinifex/console-<id>.log which
  is where Spinifex QEMU actually writes serial output
2026-05-15 13:18:53 +10:00
joshsiv-mulga ee986fefe6 fix(e2e): remove hardcoded 3-node assumptions from bm multinode test
Replace all literal `2`/`3` node counts with `NODE_COUNT`-derived
expressions so the test runs correctly against any cluster size.

- NATS recovery peer threshold: `>= 2` → `>= NODE_COUNT - 1`
- spx get nodes after recovery: `>= 3` → `>= NODE_COUNT`
- Spread placement: `--count 3` / loop `0 1 2` → `NODE_COUNT`; thresholds updated
- NAT GW step label: static "3 nodes" → `$NODE_COUNT`
2026-05-15 12:48:26 +10:00
joshsiv-mulga 7b755227d5 fix(e2e): NATS peer count expects NODE_COUNT-1 routes not hardcoded 2
/routez reports outbound routes; with N nodes each node has N-1 routes.
A 2-node cluster has 1 route, not 2. Test was written for 3-node tofu
cluster and failed on 2-node baremetal.
2026-05-15 12:41:49 +10:00
joshsiv-mulga f667e2e0a6 fix(e2e): fix unbound variable on 2-node bare-metal cluster
Hardcoded 'for i in 1 2' loops assumed 3+ nodes, accessing NODE_IPS[2]
which doesn't exist on a 2-node cluster. With set -u this is a fatal
unbound variable error. Use seq 1 $((NODE_COUNT-1)) instead.
2026-05-15 12:25:51 +10:00
Julian Sommer de3ef1bb96 fix: extra logging 2026-05-15 11:56:18 +10:00
Julian Sommer 970b3bb58d fix: allow port 80 2026-05-15 11:28:35 +10:00
Julian Sommer d3290be6e7 fix(e2e): use custom security group for alb 2026-05-15 11:03:43 +10:00
joshsiv-mulga 08935ff7e7 fix(e2e): replace guest SSH uptime check with LaunchTime assertion
Phase 8.3 polled VPC instance IPs via two-hop SSH. In single-node macvlan
mode the host has no route to VPC subnets, so SSH always timed out (10 min
of dead retries) even when the VMs were healthy (ALB targets passing health
checks).

Replace with a LaunchTime comparison: the daemon resets LaunchTime to
time.Now() when it relaunches QEMU on recovery, so LaunchTime >= REBOOT_START
is a fast, network-topology-independent reboot confirmation.
2026-05-15 10:33:01 +10:00
Julian Sommer b7a932d6b0 ci(e2e-nightly): target dispatched branch on workflow_dispatch
GIT_BRANCH was hardcoded to dev, so workflow_dispatch runs ignored the
ref chosen in the UI. Use github.ref_name for dispatch (and any non-cron
trigger), keep dev as the explicit target for scheduled cron runs.
2026-05-15 10:00:11 +10:00
joshsiv-mulga beb84b4936 fix(daemon): detect OVN pid file location at runtime
Trixie OVN moved the pid/socket to /var/run/ovn/; older installs
use /var/run/openvswitch/. Probe which exists so the same binary
works on both layouts.
2026-05-15 09:55:34 +10:00
joshsiv-mulga 3037c349a1 fix(installer): skip SPINIFEX_JOIN_ADDR validation when SPINIFEX_SKIP_FORMATION=1 2026-05-15 09:34:55 +10:00
joshsiv-mulga 7042f25861 feat(e2e): add run-bm-multinode-e2e.sh for bare-metal N≥2 node clusters
Bare-metal variant of run-multinode-e2e.sh:
- Accepts 2+ node IPs (not hardcoded 3)
- SSH key/user via PEER_SSH_KEY / PEER_SSH_USER env vars (default: bm-ci / spinifex)
- NODE3_IP optional; node3 survival check skipped when absent

Workflow updated: job renamed to Multi-Node E2E, timeout 60→120 min.
2026-05-14 17:15:17 +10:00
joshsiv-mulga fba2103133 feat(firstboot): SkipFormation flag — bm-bootstrap owns cluster formation
Add SkipFormation bool to firstboot.Config. When set, the cluster
formation block (spx admin init/join) is replaced with a no-op echo,
leaving formation to the provisioning controller.

Wired through: autoinstall reads SPINIFEX_SKIP_FORMATION=1 from env,
install.go passes it to firstboot.Config. spinifex-init.sh parses the
kernel cmdline param and exports it. boot-handler.go hardcodes
SPINIFEX_SKIP_FORMATION=1 so all bare-metal CI nodes skip formation —
bm-bootstrap.sh always runs spx admin init/join with the correct
--nodes N from nodes.json, enabling proper multi-node formation.
2026-05-14 16:57:54 +10:00
joshsiv-mulga 9fd89ec1ad fix(ci): cache lb-alpine image on runner alongside ubuntu, pass to bm-bootstrap 2026-05-14 16:05:53 +10:00
joshsiv-mulga 58c2e18ad1 fix(ci): cache ubuntu-24.04 cloud image on runner, pass to bm-bootstrap
Add a step before the E2E run that ensures the Ubuntu 24.04 noble cloud
image is cached at BM_ISO_CACHE. Downloads from ubuntu.com if missing,
prints cached size if present. Passes path via BM_UBUNTU_IMAGE to
bm-bootstrap.sh which SCPs it to the node for --file import.
2026-05-14 16:02:15 +10:00
Julian Sommer 0dc0daf116 feat: use FIPS compliant cryptography (#225) 2026-05-14 15:42:53 +10:00
joshsiv-mulga 26c2c9fa49 fix(setup): replace /dev/stdin in tmpfiles.d write to work without /dev mount in chroot 2026-05-14 15:01:14 +10:00
Julian Sommer f7efc69344 feat: disable api termination (#224) 2026-05-14 14:35:00 +10:00
joshsiv-mulga 8841241541 fix(setup-ovn): del-port cleanup without bridge name to avoid error on first run 2026-05-14 14:30:40 +10:00
joshsiv-mulga 7859d9610b fix(ci): write bm-ci key to RUNNER_TEMP and derive .pub for bm-bootstrap 2026-05-14 14:01:22 +10:00
joshsiv-mulga 7d25b04532 fix(installer): fire install callback before reboot to clear PXE flag 2026-05-14 12:34:56 +10:00
joshsiv-mulga 7dd3af3848 revert(installer): undo grub-install changes that introduced PXE boot loop
The --no-nvram flag removed the UEFI NVRAM boot entry, causing firmware to
fall through to PXE on every reboot. Restore original --recheck behaviour
which was confirmed working in manual testing.
2026-05-14 12:34:56 +10:00
Julian Sommer 54a1c29ed5 feat: test cleanup (#223) 2026-05-14 11:40:09 +10:00
Julian Sommer 89ddd06846 chore: update COMMANDS.md 2026-05-14 10:46:45 +10:00
Julian Sommer 4bf7a73feb feat: DescribeSecurityGroupRules (#222) 2026-05-14 10:44:43 +10:00
joshsiv-mulga cc157e97f9 fix(installer): skip fs-probe and nvram in grub-install to prevent eMMC scan hang
grub-probe scans all block devices even without --recheck. On N100 mini-PCs
with eMMC + NVMe, scanning the eMMC causes grub-install to hang. Also skip
NVRAM writes (--no-nvram) since --removable installs to the default EFI path
without needing to register a boot entry in UEFI firmware variables.
2026-05-14 09:37:43 +10:00
joshsiv-mulga fa5919fc14 fix(installer): remove --recheck from grub-install to prevent device scan hang
--recheck triggers grub to rescan all block devices. On N100 mini-PCs with
eMMC + NVMe, scanning the eMMC causes grub-install to hang indefinitely.
Device maps are generated fresh on each install so --recheck is redundant.
2026-05-14 08:47:06 +10:00
joshsiv-mulga f8d025d07e fix(installer): disable os-prober to prevent update-grub hang on multi-disk hardware
On N100 mini-PCs with eMMC + NVMe, update-grub's os-prober probes all
block devices via the bind-mounted /dev, hanging indefinitely on the eMMC.
GRUB_DISABLE_OS_PROBER=true skips os-prober in the chroot.

Also apply modernize lint fixes to vm test files (atomic.Int32).
2026-05-14 08:10:01 +10:00
joshsiv-mulga 38373b2fc7 fix(ci): generate epdu.env from secrets instead of pre-placed file 2026-05-13 16:14:06 +10:00
joshsiv-mulga e615d6f9e6 fix(ci): remove BM_SKIP_EPDU now that ePDU SNMPv3 is provisioned 2026-05-13 16:05:35 +10:00
Julian Sommer 97701737cb feat: describe instance status (#221) 2026-05-13 15:55:20 +10:00
joshsiv-mulga a31fd8098c fix(ci): use sudo to clean workspace so root-owned rootfs cache is removed
The build-rootfs step creates root-owned files in the workspace; a plain
rm -rf fails with permission denied on the next run's Clean Workspace step.
2026-05-13 14:25:51 +10:00
Julian Sommer c19f823d1e fix: unblock DHCP under narrow-egress SGs 2026-05-13 13:40:50 +10:00
Julian Sommer 7c08c62e0d chore: increase tofu e2e logging 2026-05-13 12:28:11 +10:00
Julian Sommer a08d9b8b99 feat: UI bug fixes (#220) 2026-05-13 12:12:43 +10:00
Julian Sommer 8d1258e4b7 fix: tofu e2e 2026-05-13 11:54:58 +10:00
Julian Sommer e80f1579d7 chore: delete no op config migrations 2026-05-13 11:52:24 +10:00
Julian Sommer 64b636cf5a feat: daemon and vm tests (#218) 2026-05-13 11:46:20 +10:00
tomnewton-mulga 43fa6eae59 feat: Updated GPU-Passthrough - AMD support, GPU images, /demo folder (#217)
* feat: add GPU fields to NodeStatus and spx top nodes (mulga-996)

- Add TotalGPUs/AllocGPUs to NodeStatusResponse (zero on non-GPU nodes)
- Populate from gpuManager in handleNodeStatus
- Show GPU (used/total) column in spx top nodes; "-" when no GPUs present

* Fixed GPU passthrough and added test script

* feat: always-probe GPU at startup, surface capability in NodeStatusResponse

- Add gpuProbeResult struct and probeGPU() which runs at daemon startup
  regardless of gpu_passthrough config. Checks sysfs for GPUs, IOMMU
  groups, and vfio_pci module presence.
- Store gpuProbe on Daemon struct; split NewDaemon into probe (always)
  and activate (opt-in) phases.
- Populate GPUCapable, GPUPassthrough, GPUModels on NodeStatusResponse
  so operators can see hardware state before enabling passthrough.
- Update 'spx top' GPU column: '-' = no hardware, '0/N*' = capable but
  passthrough not active, 'used/total' = passthrough active.

* feat: SIGHUP hot-reload for GPU passthrough config (mulga-1001)

- Export GenerateGPUTypes for use by the reload path
- Add ResourceManager.reloadGPUTypes: mutates instanceTypes map in-place
  (preserves references held by instanceService) and updates NATS
  subscriptions without full daemon restart
- Separate SIGHUP from SIGTERM/SIGINT: setupReload() listens for SIGHUP
  in a context-aware loop; setupShutdown no longer intercepts SIGHUP
- Add reloadConfig() + applyGPUConfig(): re-reads spinifex.toml on signal,
  handles false→true (re-probe + activate) and true→false (refuse if
  instances running, otherwise deactivate) transitions

* installer grub image fixes

* made gpu-passthrough more user facing

* vpcd fixes to set br-wan to promisc for public IP DHCP processes

* fixed vpcd br-wan promisc and veth pair

* ssh keygen fix

* Fix: AMD GPU edge cases

* script for running 8x instances on SMCI computer

* fix: increase disk space on instances

* feat: multi-GPU passthrough support for g7e.12xlarge (2× MI350X)

- vm.VM: GPUPCIAddress string → GPUPCIAddresses []string for multi-GPU
- instancetypes: add g7e.12xlarge (2 GPUs), GPUCountForType(), AMDmi350x model
- instancetypes/generate: use GPUCountForType for GpuInfo.Count and TotalGpuMemoryInMiB
- gpu/manager: Release() loops all pool entries for instanceID (not just first)
- vm/lifecycle: loop over GPUPCIAddresses, emit gpu0/gpu1/... QEMU devices
- daemon/handlers: claim N GPUs in loop for both RunInstances and StartStoppedInstance
- daemon/vm_adapters: ReclaimByAddress loops GPUPCIAddresses; ReleaseGPU checks len > 0
- scripts/launch-gpu-fleet: fixed fleet — 6× g7e.4xlarge + 1× g7e.12xlarge

* feat: per-instance-type disk size in GPU fleet (300 GB single, 600 GB dual)

* fix: multi-gpu instance types

* fix: kill QEMU synchronously when QMP handshake fails to free VFIO device

When startQEMU succeeds but newQMPClientWithHandshake times out, the
instance has not yet been inserted into the manager map so vmMgr.Delete
in the rollback path is a no-op. QEMU keeps running and holds the VFIO
group open. The GPU pool entry gets freed, the next Claim returns the
same PCI address, and every subsequent QEMU fails with 'device or
resource busy'.

Fix: SIGKILL QEMU and wait for it to exit before returning the QMP
error, ensuring the VFIO device is released before the caller recycles
the GPU pool slot.

* fix: quarantine GPU after QEMU launch failure to prevent re-assignment cascade

When QEMU crashes during startup (e.g. pci_irq_handler assertion on a
specific GPU), the pool Release frees that address and the next NATS
request immediately re-claims the same broken device. Add MarkFailed()
to gpu.Manager which sets Available=false for a PCI address after a
launch failure, preventing re-assignment until the daemon restarts.

Call MarkFailed for each GPU address in both RunInstances and
StartStoppedInstance rollback paths after the Release succeeds.

* fixed instance fleet launch script

* remove known hosts and stagger provisioning

* improvements to launch script

* more upgrades

* single instance gpu instance provisioning

* fixed single instance provision

* smci computer demo dashboard

* fixes to launch script

* updated smci dashboard_server

* fixed demo dashboard

* updated dashboard.py

* feat: GPU Passthrough fixes to passthrough process - builds proper AMD or NVIDA ready images, generalised launch script

* feat: gpu-passthrough updating image builder to include gpu-ready ubuntu images

* fixing image builder process

* fixed image builder to remove /tmp/ file before import to free space

* fix: Updated COMMANDS.md to reflect new GPU management admin commands

* Update docs, update internal launch script to work with new SGs, test tweaks

* Updated Docs, fixed internal launch-gpu.sh script, test tweaks

* fxing CI race due to GPU load
2026-05-13 11:07:13 +10:00
Julian Sommer 352eb164c1 chore: add new tests 2026-05-13 11:02:03 +10:00
Julian Sommer 4e5d7b0a13 fix: pass security groups to load balancer 2026-05-13 10:24:38 +10:00
Julian Sommer 32ae9d190e fix: tolerate ipv6 security group revoke (#215) 2026-05-13 09:44:41 +10:00
Julian Sommer aaa208b594 fix: use dev for build and test 2026-05-13 09:02:30 +10:00
Julian Sommer 85ff73ba9d chore: fix diff-coverage 2026-05-13 09:00:17 +10:00
Julian Sommer 4567f258ae chore: enable more oxlint rules 2026-05-12 17:01:34 +10:00
Julian Sommer fe59017cbb release: update go.mod for v1.3.0 2026-05-12 16:12:38 +10:00
Bryn Mailer 50eea38066 feat(predastore): aes 256 encryption at rest
Sibling changes in Spinifex to support changes made to Predastore in this PR.
2026-05-12 16:06:16 +10:00
Josh Siviour 45555dde49 test(ddil): flip Scenarios A/B/C to real assertions (#205)
* test(ddil): flip Scenarios A/B/C to real assertions

Replaces the t.Skip stubs landed in #204 with the assertions described in
docs/development/improvements/ddil-e2e-test-harness.md §3:

- A NATSKill — KillNATS on node3, assert ModeStandalone within 30s,
  /local/instances still serves on the isolated node, majority /health
  on node1+node2 stays up, witness counters on all three nodes keep
  advancing, snapshot survives reconnect.
- B DaemonRestartWithoutNATS — KillNATS + RestartDaemonOnly, assert
  ModeStandalone within 30s of restart, recovered instances present
  with live QEMU PIDs, cluster rejoin after StartNATS. Strict pre/post
  PID equality dropped — see file comment; recovery + a non-zero PID
  give the same regression coverage without coupling to the daemon
  unit's KillMode.
- C CleanPartition — PartitionNode peer-IP DROP, majority daemons keep
  serving /health and /local/instances, isolated node3 reports
  ModeStandalone with /local/* responsive, witness counter on the
  partitioned side keeps advancing, heal converges and snapshot is
  preserved.

Supporting changes:
- harness/daemon_client.go — daemonPort 8443→4432; 8443 is predastore,
  the deployed daemon ClusterManager binds [nodes.<id>.daemon].host.
  The Phase-1 stub used a placeholder that would have blocked any
  /local/* assertion against the real cluster.
- harness/witness.go — export WitnessVM.Terminate so scenarios can
  clean up their counter VMs from t.Cleanup.
- scenarios/main_test.go — requireLiveCluster + launchWitnesses
  helpers to keep the three flipped scenarios tight.
- TEST_COVERAGE.md — A/B/C status flipped SKIPPED → ENABLED;
  TestCoverageDrift continues to enforce alignment with the package.

Validation: built with -tags=e2e, dry-run scenarios pass, coverage
drift passes, preflight clean. workflow_dispatch e2e-ddil.yml against
tofu cluster is the next step.

* fix(ddil): countDropRules — single iptables -S call + filter

Phase 1's countDropRules ran `iptables -S INPUT -S OUTPUT` to grep DROPs
across both chains, but iptables -S takes a single optional chain — the
second -S errors to stderr, the combined SSH buffer poisons the grep
input, and Atoi fails ("ip" is what we saw on tofu-cluster). Hit by
TestHarnessSmoke and every flipped scenario (A/B/C) via AssertCleanState.

Switch to one `iptables -S` (lists every chain in iptables-save format),
filter for INPUT/OUTPUT lines, count `-j DROP`. `2>/dev/null` silences
sudo/iptables stderr so a noisy MOTD or warning cannot leak into stdin.
Error path now also surfaces the raw SSH output for the next time this
breaks somewhere unexpected.

* ci(e2e-ddil): export spinifex AWS creds for witness SDK

The witness EC2 client (harness/witness.go NewWitness) calls
session.NewSession with no static credentials, so the AWS SDK falls
through its default chain — env vars → shared profile → IAM role —
and on the CI runner none of those exist. Result on the live cluster:
"NoCredentialProviders: no valid providers in chain. Deprecated."
launches every Scenario A/B/C attempt straight to t.Fatal at
launchWitnesses.

spx admin init writes [spinifex] aws_access_key_id /
aws_secret_access_key to the operator's ~/.aws/credentials on node1
during bootstrap. Pull them over SSH and export as
AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY so the SDK's env-var step
resolves before it gets to the empty profile/IAM checks. Hard-fail
the step if either field is missing — better than silently watching
every scenario t.Fatal on the same line.

* fix(ddil): CI green-up — instance-type discovery, gotestsum, EC2 envelope

Three fixes to take the DDIL E2E suite from "scenarios flipped" to actually
green on the tofu cluster. mulga-siv-56.

1. Witness auto-discovers the smallest registered instance type
   (DescribeInstanceTypes, sort by memory then vCPUs). The hard-coded
   t2.micro default returned InvalidInstanceType on every Scenario A/B/C
   launch because the cluster doesn't register that type. DDIL_WITNESS_-
   INSTANCE_TYPE stays as an operator override.

2. e2e-ddil.yml uses gotestsum so the Actions UI shows per-test PASS/FAIL/
   SKIP lines instead of a JSON wall. results.json artifact preserved for
   future flake-rate dashboarding.

3. GenerateEC2ErrorResponse emits the real EC2 query-API envelope
   <Response><Errors><Error>... instead of the IAM-shaped <ErrorResponse>.
   aws-sdk-go v1's ec2query handler rejects the latter with
   SerializationError and drops the code, leaving Go callers without an
   awserr.Code() to inspect. New SDK round-trip test in gateway_test.go
   guards the regression.

* ci(e2e-ddil): install gotestsum from prebuilt tarball

`go install gotest.tools/gotestsum@v1.12.0` failed on the self-hosted
runner because v1.12.0 pulls golang.org/x/tools v0.19.0, whose
tokeninternal static-assert pegs a narrow Go window (~1.22). Both older
and newer runner Go versions trip `invalid array length -delta * delta`.
A prebuilt linux_amd64 binary sidesteps toolchain coupling entirely
without changing the gotestsum version pin.

* ci(e2e-ddil): collect cluster logs on failure

Witness `findHost` is currently failing with "could not locate QEMU host
for i-XXX across 3 nodes" — daemon reports running but no QEMU fork is
visible on any node. Cluster gets destroyed during teardown, so logs
have to be pulled before that runs.

Adds a failure-only step that ssh's each node and bundles
journalctl spinifex-*, process listings, and state dirs into the
ddil-results artifact for offline triage.

* fix(ddil): support tap-mode QEMU networking in witness harness

findHost no longer extracts hostfwd port — production launches go through
auto-allocated ENI on the OVS bridge and emit `-netdev tap,...`, never
hostfwd. Witness was timing out across all 3 nodes.

Discover guest IP via DescribeInstances.PrivateIpAddress (populated by
PrepareRunInstances on ENI auto-create) and tunnel SSH through the host
node to guestIP:22. Host routes to the tap bridge subnet, so the relay
works without runner-side bridge routing.

WitnessVM.SSHPort → WitnessVM.GuestIP. hostfwdPortRE removed.

Closes mulga-siv-57.

* fix(ddil): SSH witness via public IP, drop unreachable tunnel path

tofu-cluster bootstraps with --external-mode=pool which forces
dev_networking=false → no QEMU hostfwd dev NIC and no host route to the
ENI's PrivateIpAddress (tap on OVS br-int, 172.31.0.0/16 invisible to
host kernel). Previous tunnel-via-host fix could find the host but
couldn't dial the guest.

Switch to the path shell e2e already uses: default subnet has
MapPublicIpOnLaunch=true, daemon auto-allocates an EIP from the external
pool, and the runner reaches that IP via the same WAN subnet as the
cluster nodes. No host relay required, survives Scenario C (partition
only DROPs peer cluster IPs, not the runner's source IP).

Workflow now scp's ~/.ssh/spinifex-key from the primary node (the
counterpart to the public material bootstrap-install.sh imports) and
exports DDIL_GUEST_SSH_KEY so the harness can auth as ubuntu against
cloud-init's authorized_keys.

WitnessVM.GuestIP → WitnessVM.PublicIP. hostSigner / dialHost removed.

Continues mulga-siv-57.

* fix(ddil): tunnel guest SSH through host node

EIP from MapPublicIpOnLaunch sits on the cluster's external pool subnet,
which is OVN-routed — reachable from any cluster node, not from the
off-cluster runner sitting on the DDIL_NODES WAN subnet.

Restore the host-jump pattern: SSH the hosting node (already known from
findHost), dial publicIP:22 over that connection, run a guest SSH
handshake over the tunnel. Symmetric with how shell e2e reaches guests
from inside a node.

Continues mulga-siv-57.

* fix(ddil): wait for witness sshd before baseline read

Previous run reached the guest EIP via the host tunnel but hit
'Connection refused' — sshd hadn't bound yet. waitForRunning only
checks EC2 state, which flips on QEMU start, not cloud-init completion.
The OVN dnat_and_snat rule for the auto-allocated EIP is also written
asynchronously, so the tunnel can land before either layer is ready.

awaitGuestSSH polls the host-tunnelled SSH handshake to publicIP:22 for
up to 90s, retrying on transient transport errors (refused/reset/EOF/
timeout/no-route) and failing fast on auth errors so a bad key surfaces
immediately. Called once after resolvePublicIP, before the baseline
counter read.

Continues mulga-siv-57.

* fix(ddil): case-insensitive transient SSH error match

awaitGuestSSH's retry matcher used lowercase substrings, but x/crypto/ssh
emits "ssh: rejected: connect failed (Connection refused)" with capital
C — strings.Contains returned false and the helper bailed on first
attempt instead of polling. Last run failed in 49s, not the 90s budget.

Lowercase the error before matching, and add "connect failed" as the
canonical signal for "jump host dial through tunnel failed".

Continues mulga-siv-57.

* fix(ddil): default guest SSH user to ec2-user

Shell E2E SSHes the same ami-ubuntu-* images as ec2-user
(tests/e2e/run-multinode-e2e.sh:650). DDIL harness defaulted to ubuntu
and hit 'unable to authenticate, attempted methods [none publickey]' —
authorized_keys is provisioned under the AMI default account, which is
ec2-user, not ubuntu.

Continues mulga-siv-57.

* fix(ddil): wait for witness-counter.service to seed /var/lib/counter

awaitGuestSSH passed at sshd-up, but cloud-init's runcmd that enables
witness-counter.service runs in the final cloud-init stage — after sshd
binds. Baseline read landed in the window where sshd accepts but
ExecStartPre hasn't created /var/lib/counter:

  cat: /var/lib/counter: No such file or directory

Collapse awaitGuestSSH + readCounter into awaitBaseline that polls the
full path (tunnel + SSH handshake + cat) and retries on the transport
errors plus "no such file or directory". Single 2-minute budget covers
cloud-init final stage on tofu-cluster hardware.

Continues mulga-siv-57.

* fix(systemd): drop Wants=spinifex-nats from daemon unit

Wants= reactivates a stopped unit on `systemctl restart`, so a deliberately
killed spinifex-nats was getting pulled back up the moment the daemon
restarted — masking the DDIL Tier 1 standalone-startup path and breaking
the Scenario B assertion (mulga-siv-58). Cold-boot coverage stays intact:
spinifex.target's Wants= activates every spinifex unit on boot. After=
remains for ordering and is non-blocking when NATS is absent.

scripts/test-ddil-phase1.sh hit the same trap and worked around it with a
runtime drop-in plus sentinel file; the fix here makes that workaround
unnecessary for any caller that restarts the daemon while NATS is stopped.

* feat(daemon): peer-health probe drives standalone mode on partition

Mode() is now derived from two atomic signals — natsConnected (NATS client
TCP link, flipped by the existing disconnect/reconnect callbacks) and
peersReachable (any peer daemon's /health responded in the most recent
probe cycle). Reports cluster iff both are up; standalone otherwise.

The peer-health probe is the missing signal for DDIL Scenario C: an iptables
DROP between peer IPs blocks NATS cluster-routing traffic but leaves each
node's local NATS client TCP connection healthy, so nats.Conn.IsConnected()
keeps returning true and onNATSDisconnect never fires. The probe dials each
peer's /health over the cluster network with a 2s timeout on a 2s ticker;
worst-case detection of total partition is ~6s on a 3-node cluster, well
inside the 30s Scenario C budget. Single-node clusters keep peersReachable
permanently true (nothing to probe) so the derived mode flips to cluster as
soon as the local NATS connect succeeds.

The probe runs from startLocal so the partition signal exists even before
(or in the absence of) a successful NATS connect. It does not touch NATS or
JetStream, so the §1e Tier 1 invariant asserted by
assertNoClusterServicesInitialised is unaffected.

Tests:
- TestMode_RequiresBothSignals — every nats×peers combination.
- TestProbePeersOnce_FlipsOnHealthChange — deterministic probe-only test.
- TestProbePeerHealth_RejectsBadAddr — fails closed on missing addresses.
- TestMonitorPeerReachability_EndToEnd — ticker-driven false→true.

Closes mulga-siv-58.

* fix(daemon): peer probe falls back to local daemon port

`spx admin init` only renders `[nodes.<self>.daemon]` in spinifex.toml;
remote-node blocks carry just `host = "<ip>"` with no daemon sub-table.
So `peer.Daemon.Host` was always empty in a deployed cluster and the
probe failed closed, leaving peersReachable permanently false.

Tofu-cluster run 25665158964 surfaced this as a Scenario A regression:
after StartNATS, natsConnected flipped true on reconnect but Mode()
stayed standalone because the derived signal required peersReachable.
`a_nats_kill_test.go:75: node3 did not return to cluster mode … (still
reporting another mode)`.

Add peerDaemonPort(p) that prefers an explicit peer Daemon.Host port
when present and otherwise reuses the local daemon's port (clusters are
symmetric — every node binds the same port the template renders). New
TestPeerDaemonPort_FallbackToSelf covers both branches plus the no-port-
available case; TestProbePeerHealth_RejectsBadAddr is updated to clear
self config so it still asserts the fail-closed path.

* fix(daemon): SIGTERM preserves VMs + KillMode=process on the unit

DDIL Scenario B asserts that `systemctl restart spinifex-daemon`
preserves every guest VM so the new daemon can reattach via the
persisted local state file. The previous setupShutdown SIGTERM handler
called vmMgr.StopAll(), gracefully QMP-powering-down every guest before
the daemon exited — ~90s wall-clock and zero surviving QEMU on the
other side. CI run 25666369604 surfaced this as
`recovered instance i-... has no live QEMU PID`.

Two changes restore the Tier 1 contract:

- setupShutdown no longer calls StopAll. It still flips d.shuttingDown
  so crash/restart handlers bail out, persists state, unsubscribes from
  NATS, and exits. VMs are only stopped when coordinated cluster
  shutdown drains them — handleShutdownDrain still calls StopAll and
  flips the flag before the SIGTERM path runs. Operator-visible
  behaviour change: `systemctl stop spinifex-daemon` leaves guest VMs
  running. To stop VMs, use the cluster-shutdown topics or terminate
  via the AWS API.

- The systemd unit gains `KillMode=process` so systemd only signals the
  daemon's main PID. With the default control-group mode, QEMU
  children were killed when systemd tore down the daemon's cgroup —
  defeating the SIGTERM change above. `TimeoutStopSec=15` bounds the
  new fast-exit grace window.

Also nil-guards natsConn.Close in the shutdown path: when a daemon
restarts while NATS is unreachable (Scenario B), natsConn is nil and
the close would panic.

Mulga-siv-58 (reopened).

* fix(vm): QEMU stdout/stderr to shared log file (SIGPIPE survives daemon restart)

QEMU stdout/stderr were piped to daemon-side scanner goroutines.
When the daemon exits (e.g. `systemctl restart spinifex-daemon`),
the pipe read ends close; QEMU's next write triggers SIGPIPE and
the guest dies — defeating DDIL Scenario B's "daemon restart
preserves running instances" guarantee.

Open `<runtimeDir>/qemu.log` and assign it to cmd.Stdout/cmd.Stderr
instead. The FD is dup'd into QEMU at fork, so closing it on the
daemon side after Start() leaves QEMU writing into the file
indefinitely. POSIX O_APPEND makes concurrent QEMU writes
interleave atomically per line, so a single shared log is safe and
avoids per-instance clutter in RuntimeDir.

Trade-off: QEMU lines no longer land in journalctl -u
spinifex-daemon. Operators inspect <runtimeDir>/qemu.log directly
(path logged at instance start under the qemu_log field).

mulga-siv-58 (DDIL Scenario B iteration 4)

* fix(daemon): pin XDG_RUNTIME_DIR=/run/spinifex; revert SIGPIPE workaround

Root cause of Scenario B's "no live QEMU PID" was not SIGPIPE — QEMU
ignores SIGPIPE (SIG_IGN set early in QEMU init). It was systemd's
`PrivateTmp=yes` on the daemon unit combined with `utils.pidPath()`
falling through to `os.TempDir()` (=/tmp):

- spinifex-daemon HOME=/var/lib/spinifex/spinifex, so
  `$HOME/spinifex` (=/var/lib/spinifex/spinifex/spinifex) does not
  exist and pidPath() returns /tmp.
- Every daemon start gets a fresh private /tmp namespace. QEMU stays
  in the OLD namespace after the daemon dies (mount namespaces aren't
  changed by reparenting). The new daemon's /tmp is empty.
- `/local/instances` reads `<pidPath>/<id>.pid`; in the new namespace
  the file doesn't exist, so PID=0.

Fix: set `Environment=XDG_RUNTIME_DIR=/run/spinifex` on the daemon
unit. /run/spinifex is host-wide tmpfs (declared via tmpfiles.d,
already in ReadWritePaths) and survives daemon restart. pidPath()
short-circuits on XDG_RUNTIME_DIR, so QEMU pidfiles, QMP sockets, and
RuntimeDir() all resolve to the stable path. Both daemon incarnations
and QEMU children see the same files.

Revert iteration 4's pipe→file QEMU log change (commit 8a9a6f9f).
SIGPIPE was never killing QEMU, and the file-redirect dropped QEMU
lines from `journalctl -u spinifex-daemon` for no gain.

mulga-siv-58 (DDIL Scenario B iteration 5)

* feat(daemon): siv-60 heal reconcile — phase 1 (probe-heal trigger) (#211)

* feat(daemon): wire reconcileOnHeal on peer-probe heal edge (siv-60 phase 1)

Scenario C heal triggered zero resync: the local NATS client stayed
connected to its loopback server throughout the partition, so
onNATSReconnect never fired. The peer-probe false→true transition was
the only heal signal but had no resync action wired to it.

Adds reconcileOnHeal, called from both edges and coalesced via a
reconciling atomic.Bool so the two callbacks landing on the same heal
don't double-push. For phase 1 the body is the existing WriteState
(re-asserts our per-node row in the instance-state KV). Phase 2 will
extend it to pull KV → merge → push for the stopped/terminated buckets
and re-fire per-node bootstrap.

Plan: docs/development/feature/ddil-heal-reconcile.md

* feat(daemon): siv-60 phase 2 — idempotent default-VPC bootstrap re-fire on heal

Calls ensureDefaultVPCInfrastructure() from reconcileOnHeal after the
WriteState push. Idempotent: each branch (default VPC lookup, IGW
attach, default-SG create, default route insert) checks for existing
infrastructure before creating. Covers the failure mode where
startCluster ran during partition and the initial creates returned
errors against an unreachable NATS — without a heal-time retry the
cluster would be stuck without a default VPC until the next daemon
restart.

Scope note: the bead's "pull KV → merge → push" assumed the daemon
cached peer-node running state, stopped instances, terminated
instances, and VPC/IGW/SG state locally. The codebase audit (see plan
doc) confirms it doesn't — every consumer reads its KV bucket fresh
per request, and vmMgr only tracks VMs running on this node. The
bootstrap re-fire is the one heal-time action that has a real
in-memory target. Watchdog sweep is unnecessary because
vm.Manager.CrashHandler is event-driven from cmd.Wait().

* fix(daemon): drop ensureDefaultVPCInfrastructure from reconcileOnHeal (siv-60 follow-up) (#212)

* fix(daemon): drop ensureDefaultVPCInfrastructure from reconcileOnHeal

DDIL Scenario C regressed at the witness-SSH step after siv-60
landed in #211. Root cause: the first peer-probe tick on multi-node
startup flips peersReachable false→true (its zero value is false), so
reconcileOnHeal fires concurrently with startCluster's own
ensureDefaultVPCInfrastructure call. The Describe→Create→Attach
sequence is not cluster-wide singleton, so multiple daemons + double
calls per daemon raced and produced duplicate IGW attachments,
corrupting default-VPC routing — which broke the witness VM's tunnel
SSH path once Scenario C's partition was installed.

Phase 1 (the heal trigger + coalescing) is the meaningful fix and
stays. Phase 2's bootstrap re-fire needs leader election or a
debounce against the startup-time path before being re-introduced;
leaving an inline NOTE so the next attempt has the context.

Failed run: actions/runs/25706585637 (Scenario C, attempts 1+2).
Last green DDIL run: 25671044714 (pre-merge, SHA 40e3d847).

* fix(daemon): init peersReachable=true to suppress startup heal edge

DDIL Scenario C kept flaking at attempt-1 even after dropping the
bootstrap re-fire. Remaining trigger: peersReachable starts as the
zero value (false) on multi-node clusters, and the first probePeersOnce
tick flips it false→true. That counts as a "heal" edge under siv-60
phase 1, so reconcileOnHeal fires at boot — concurrent with
startCluster's bootstrap — perturbing timing in a way that the
harness's Scenario C exposes (witness sshd handshake EOF mid-test).

Initialise peersReachable optimistically. Mode() also requires
natsConnected (starts false), so the optimistic peer init can never
falsely report cluster mode — but it does eliminate the spurious
startup edge. Real partition→heal transitions still fire the trigger
exactly as designed: true→false on partition, false→true on heal.

---------

Co-authored-by: Julian Sommer <115516918+juliansommer@users.noreply.github.com>
2026-05-12 15:56:17 +10:00
Julian Sommer 23c26d8202 feat: enable no floating promises (#214) 2026-05-12 15:40:25 +10:00
Julian Sommer 069e6d411a fix: continue using ubuntu 24 image in workbooks 2026-05-12 15:09:53 +10:00
Julian Sommer dc3b48f17e feat: security groups enforcement (#201) 2026-05-12 14:16:50 +10:00
Julian Sommer bb08335831 fix: explicit workflow permission 2026-05-12 11:57:03 +10:00
Julian Sommer f9dddd3c9c fix: min package age 3 days 2026-05-12 11:23:58 +10:00
Julian Sommer 8b17ce4060 fix: allow esbuild build 2026-05-12 09:33:56 +10:00
Julian Sommer f49923ef10 chore: update pnpm 2026-05-12 09:22:04 +10:00
dependabot[bot] dbb153bd42 chore: bump the go-dependencies group across 1 directory with 2 updates (#206) 2026-05-12 08:58:12 +10:00
dependabot[bot] 13cf15b746 chore: bump the npm-dependencies group (#207) 2026-05-12 08:57:52 +10:00
Josh Siviour 48d7ce8a45 feat(viperblockd): retry LoadState on transient backend errors (siv-25 medium-term) (#210)
* feat(viperblockd): retry LoadState on transient backend errors

ebs.mount used to call vb.LoadState once and treat any failure as fatal.
When predastore was still warming up after a coordinated daemon restart,
the backend Read came back with a network/timeout error, viperblock
folded it into "block size is 0 in both local and backend state", and
the daemon's recovery path then ran the cleanup branch — destroying
DeleteOnTermination=true volumes (mulga-siv-25).

Wrap LoadState in retryLoadState: 5 attempts, 200ms * 1.5^n backoff
(total worst-case ~3.7s, well inside the 30s ebs.mount NATS deadline).
Retry only on viperblock.ErrStateBackendUnavailable; ErrStateNotFound
and any unclassified error fail fast so genuine problems are still
visible.

Retry loop is parameterised over sleep and loadFn so the retry policy
itself is unit-tested without spinning up a real VB.

* chore: bump viperblock to v1.2.2-0.20260511064619-1aa54f0dc1f6

Pulls in the ErrStateNotFound / ErrStateBackendUnavailable sentinels
introduced by mulgadc/viperblock#11 (merged on dev). Required for the
retry wrapper added in the previous commit to compile against the
public viperblock module.
2026-05-11 17:10:48 +10:00
Josh Siviour f6ddd602b9 fix(daemon): preserve volumes when recovery fails on restart (siv-25 short-term) (#209)
Stops the P0 data-loss path where `systemctl restart spinifex-daemon`
destroys user volumes flagged `DeleteOnTermination=true`.

Previously, when daemon recovery couldn't relaunch (Run error) or
reconnect (QMP attach failure) to a previously-running instance, the
code called `MarkFailed` — which transitions to `terminated`, runs the
full destructive cleanup (`terminateCleanup`), and deletes volumes per
`DeleteOnTermination`. A benign operator action (daemon restart) was
treated identically to a user-initiated `ec2.TerminateInstances`.

New `MarkRecoveryFailed` transitions to `StateError` with a
`Server.RecoveryFailed` StateReason, runs only the non-destructive
`stopCleanup` chain (graceful unmount + tap teardown + GPU release +
resource deallocation), and persists the running set. Volumes, public
IPs, ENIs, and placement-group membership are preserved so the operator
can either retry recovery or issue an explicit terminate (which will
then honour DoT as normal). The instance stays in the local map and
`OnInstanceDown` is NOT fired, keeping `ec2.cmd.<id>` live so operator
commands can still reach this node.

`classifyRestoredInstances` now skips relaunch + resource re-allocation
for instances already in `StateError`, so a recovery-failed instance
stays put across subsequent daemon restarts until the operator acts.

Medium-term (viperblock "block size 0" hard-vs-soft classification +
mount retry-with-backoff) and long-term (audit of every non-user-
initiated cleanup caller) tiers remain open on the siv-25 plan.
2026-05-11 16:47:24 +10:00
Josh Siviour 13d9fb494a refactor(daemon): extract instance mutators to service (siv-21, 1b-pre phase 2) (#208)
* refactor(daemon): extract ModifyInstanceAttribute to service (1b-pre phase 2a)

Collapse handleEC2ModifyInstanceAttribute to a thin Pattern A wrapper over
InstanceServiceImpl.ModifyInstanceAttribute, mirroring the Describe extraction
in 719ac63 (phase 1). The InstanceService interface and StoppedInstanceStore
each gain one method (ModifyInstanceAttribute, WriteStoppedInstance); the
daemon's JetStreamManager already implements WriteStoppedInstance, so no
production code outside the daemon handler changes.

Logic-level tests move from daemon/daemon_handlers_test.go to
handlers/ec2/instance/service_impl_test.go (12 cases against an in-memory
fakeStoppedStore). The daemon keeps one NATS round-trip wrapper smoke test.
The error-injection cases in daemon_handlers_instance_test.go remain in place
but now require daemonWithFakeStateStore to rewire d.instanceService with
the fake store — the previous `d.stateStore = store` swap no longer reaches
the new code path.

No behaviour change: SourceDestCheck stays a state-agnostic no-op, the
ownership/visibility check still gates after the stopped-state gate, and the
success response payload remains `{}`.

* refactor(daemon): extract TerminateStoppedInstance to service (1b-pre phase 2b)

Collapses ~140 lines of daemon termination logic into a one-liner
handleNATSRequest wrapper plus an InstanceServiceImpl method, matching
the Pattern A precedent from phase 1 (Describes) and phase 2a
(ModifyInstanceAttribute). Adds VolumeDeleter, ENIDeleter, and
PublicIPReleaser dep interfaces (wired via SetTerminationDeps) so the
service can clean up volumes, ENI, and EIP without depending on daemon
internals.

Hoists publishNATEvent off the daemon into utils.PublishNATEvent so the
service can drive vpc.delete-nat directly. Removes the
terminateStoppedInstanceRequest daemon-local payload in favour of the
exported TerminateStoppedInstanceInput on the service interface.

Tests: 13 service-level cases against an in-memory fake (missing ID,
nil store, load error, not found, not stopped, cross-tenant,
happy path, terminated-write failure aborts, retry-once delete,
user volume deleted, no-deleter graceful skip, internal volumes via
NATS, public IP release, ENI delete). The five JetStream-backed
daemon Terminate tests collapse to one wrapper smoke
(WritesToTerminatedKV); the six error-injection tests in
daemon_handlers_instance_test.go continue to drive the wrapper through
fakeStateStore for wire-format coverage.

Refs mulga-siv-21.

* refactor(daemon): extract StartStoppedInstance to service (1b-pre phase 2c)

Daemon's handleEC2StartStoppedInstance collapses to a Pattern A handleNATSRequest
wrapper over InstanceServiceImpl.StartStoppedInstance. Promotes ResourceCapacityProvider
to InstanceTypeAllocator with Allocate/Deallocate/CanAllocate/InstanceTypes, and adds
a GPUClaimer interface returning (pciAddress, xvgaEnabled, err) so the instance package
stays free of the gpu package. Daemon ResourceManager gains exported wrappers around
its internal allocator methods; a daemonGPUClaimer adapter wires gpu.Manager + the
GPU model overrides through SetGPUClaimer.

No behaviour change. New service-level tests cover the moved logic; existing
daemon integration tests continue to validate the end-to-end path through the
wrapper.

* ci(e2e-ddil): export spinifex AWS creds for witness SDK

The witness EC2 client (harness/witness.go NewWitness) calls
session.NewSession with no static credentials, so the AWS SDK falls
through its default chain — env vars → shared profile → IAM role —
and on the CI runner none of those exist. Result on the live cluster:
"NoCredentialProviders: no valid providers in chain. Deprecated."
launches every Scenario A/B/C attempt straight to t.Fatal at
launchWitnesses.

spx admin init writes [spinifex] aws_access_key_id /
aws_secret_access_key to the operator's ~/.aws/credentials on node1
during bootstrap. Pull them over SSH and export as
AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY so the SDK's env-var step
resolves before it gets to the empty profile/IAM checks. Hard-fail
the step if either field is missing — better than silently watching
every scenario t.Fatal on the same line.

* Revert "ci(e2e-ddil): export spinifex AWS creds for witness SDK"

This reverts commit 5b890162a6.

* refactor(daemon): extract RunInstances + ec2.cmd handlers to service (1b-pre phase 2d)

Final sub-phase of mulga-siv-21. Moves the remaining instance mutators
from spinifex/daemon/daemon_handlers_instance.go into InstanceServiceImpl:

- PrepareRunInstances: input validation, capacity allocation, per-instance
  RunInstance + ENI auto-create + public IP assignment.
- LaunchRunInstances: volume preparation, GPU claim, vmMgr.Run loop.
- RunInstances: synchronous Prepare+Launch convenience wrapper used by
  the InstanceService interface for non-daemon callers.
- RebootInstance, StartInstance, StopOrTerminateInstance: extract the
  three ec2.cmd sub-handlers; daemon.handleEC2Events shrinks to a
  dispatcher that looks up the VM and calls the matching service method.

The daemon's RunInstances handler is now a thin orchestrator that splits
the call into Prepare and Launch around the early NATS respond so the
original respond-then-launch timing is preserved (AWS clients see the
reservation before volumes prepare and VMs launch).

New service deps wired via SetRunInstancesDeps:
- AMIMetaLoader (handlers/ec2/image.ImageServiceImpl)
- KeyPairValidator (handlers/ec2/key.KeyServiceImpl)
- ENICreator + SubnetInfo (new daemonENICreator adapter over VPCServiceImpl
  to avoid an instance↔vpc cyclic import)
- PublicIPAllocator (handlers/ec2/vpc.ExternalIPAM)

No behaviour change. Tests retarget the service directly for the new
error paths; existing daemon-side RunInstances handler tests continue to
pass via the wrapper.

* test(instance): add LaunchRunInstances + setter coverage for phase 2d
2026-05-11 13:50:55 +10:00
Josh Siviour 6c54a6790c feat(daemon): DDIL Tier 1 — daemon-local-autonomy phase 1 (#204)
* refactor(daemon): extract Describe instance handlers to service (1b-pre phase 1)

Collapse the Describe family in daemon_handlers_instance.go to thin Pattern A
wrappers over a transport-neutral InstanceServiceImpl. This brings the read
path in line with the rest of the codebase and is the prerequisite for 1b's
local HTTP API (mulga-siv-14, daemon-local-autonomy plan).

Extracted: DescribeInstances, DescribeInstanceTypes, DescribeInstanceAttribute,
DescribeStoppedInstances, DescribeTerminatedInstances. Filter helpers
(instanceMatchesFilters, matchTagKey/Value, DescribeInstancesValidFilters,
IsInstanceVisible) move to the instance package alongside the service methods.

InstanceService gains the new methods and two dep interfaces
(ResourceCapacityProvider, StoppedInstanceStore) so the daemon's ResourceManager
and JetStreamManager can plug in without leaking concrete types. NATSInstanceService
returns a clear error for the fan-out describes (DescribeInstances,
DescribeInstanceTypes) since cluster-wide aggregation lives in the gateway's
scatter-gather helpers, not a single-node NATS request.

Behaviour changes: malformed JSON on DescribeInstanceAttribute now responds
with ValidationError (matching every other Pattern A handler) instead of
ServerInternal. Test updated.

Mutators (RunInstances, ModifyInstanceAttribute, StartStoppedInstance,
TerminateStoppedInstance, sub-handlers) remain Pattern B for now; follow-up
phases of mulga-siv-14 will collapse them.

* feat(daemon): local-first instance state persistence (1a)

Make /var/lib/spinifex/state/instance-state.json the source of truth for a
node's VMs; JetStream KV becomes a best-effort cluster cache. The daemon now
survives KV unavailability without losing instance state.

- New local_state.go: atomic write (tmp+fsync+rename), schema_version gate,
  missing-file = fresh install, corrupt = refuse start.
- Daemon.WriteState writes the local file under Instances.Mu, then calls
  jsManager.WriteStateBestEffort with a 1s timeout. KV failures log and
  continue; clients never see "KV down" during partition.
- Daemon.LoadState reads from the local file only — no KV fallback (a fresh
  node has empty KV anyway, so silent fallback would mask bugs).
- restoreInstances now returns error and Start() aborts on corrupt local
  state rather than silently starting empty.
- jsManager.WriteState no longer self-locks (caller holds Mu); added
  WriteStateBestEffort and shared marshalInstanceState helper.

Reconnect-driven KV resync deferred to siv-16 (1c owns the callback wiring).

Bead: mulga-siv-15
Plan: docs/development/feature/daemon-local-state-persistence.md

* fix(daemon): root local state at DataDir not BaseDir

LocalStatePath was falling back to BaseDir when DataDir was empty, putting
the file at /var/lib/spinifex/spinifex/state/instance-state.json (under the
daemon's own subdir) instead of the documented
/var/lib/spinifex/state/instance-state.json (the platform-shared root).

Drop the BaseDir fallback. Empty DataDir resolves to DefaultDataDir
(/var/lib/spinifex). Tests now set DataDir alongside BaseDir on their config
fixtures.

* feat(daemon): graceful NATS disconnect/reconnect handling (1c)

Daemon now flips a `mode` field (cluster|standalone) on NATS
disconnect/reconnect via optional callback hooks added to
utils.ConnectNATS. Reconnect bumps natsRetryCount and triggers 1a's
full local-state push to KV. NATSRequest/NATSScatterGather fail fast
with ErrClusterUnavailable when the connection is down. Gateway
short-circuits NATS-bound requests with a 503 ServiceUnavailable
carrying "cluster unavailable: NATS disconnected — check daemon
/local/status" so AWS clients see the failure mode immediately
instead of hanging on per-call timeouts.

Plan: docs/development/feature/daemon-autonomy-1c-disconnect.md

* fix(gateway): SigV4 middleware short-circuits on NATS disconnect

The 1c gateway.Request() IsConnected check (gateway.go:234) was
unreachable for any authenticated path because SigV4 auth middleware
runs first and calls IAMService.LookupAccessKey over JetStream KV. With
NATS down the lookup hangs ~5s on context.DeadlineExceeded and the
gateway returns 500 InternalError. AWS SDK retries it twice for ~18s of
user-visible latency instead of the <100ms 503 promised by the 1c plan.

Mirror the IsConnected check at the top of SigV4AuthMiddleware (after
credential parse so malformed auth headers still get the right SigV4
error code, before the IAM lookup). The catch-all in gateway.Request()
stays as defence-in-depth for any future unauthed paths.

Coverage debt picked up alongside: 1c utils.NATSRequest /
NATSScatterGather fail-fast paths, daemon Mode/onNATSDisconnect/
onNATSReconnect/localStatePath/Write/LoadState branches, and 1b-pre
InstanceServiceImpl Describe* methods (partial siv-22).

Bead: mulga-siv-23

* feat(daemon): read-only /local/* HTTP API (1b)

Wires GET /local/instances, GET /local/instances/{id}, GET /local/status
onto the existing ClusterManager TLS listener so observers can read this
node's instance state and connectivity mode while NATS is unreachable.

Also fixes a pre-existing race in WriteState: json.Marshal walked shared
*vm.VM pointers from SnapshotMap while concurrent TransitionState calls
mutated VM fields under the manager lock. Marshal now runs inside
vmMgr.View; WriteLocalStateBytes / WriteStateBytesBestEffort accept the
pre-marshalled JSON and commit lock-free. TestTransitionState_Concurrent
Transitions is race-clean across 30 iterations.

Adds Daemon.stateRevision (atomic.Uint64) bumped on every successful
local-state write, surfaced as /local/status.revision so observers can
detect changes without diffing payloads.

* feat(daemon): two-phase startup — survive NATS down (1d)

Split daemon Start() into startLocal() (HTTPS, mgmt bridge, OVS plumber,
OOM, local state load — no NATS) and startCluster() (background, infinite
retry exp backoff cap 60s for NATS connect, then JetStream + services +
restoreInstances + subscribeAll + heartbeat). Daemon never process-exits
on NATS failure; instead it stays up in standalone mode and reconnects in
the background.

Drop ExecStartPre=wait-for-nats.sh from spinifex-daemon.service — local
bootstrap no longer requires NATS to be reachable.

ConnectNATSWithRetry now supports infinite retry (WithMaxWait(0)),
context cancellation (WithContext), per-attempt error callback for the
nats_retry_count operator signal on /local/status, and a configurable
backoff cap (WithMaxRetryDelay).

bead: mulga-siv-17 (closes)

* feat(daemon): Tier 1 invariant guard for cluster-scoped service init (1e-audit)

Make the DDIL §1e-audit invariant explicit: startLocal must not initialise any
NATS-dependent or JetStream-KV-backed handle. Adds assertNoClusterServicesInitialised
as a runtime guard at the end of startLocal that fails fast if a future edit
hoists a cluster-scoped service into Phase 1, plus comment blocks naming the
invariant on both startLocal and startCluster.

Code audit confirms the existing 1d split already keeps every cluster-scoped
bucket out of Phase 1; this change pins the property so it cannot regress
silently. Tier table for the 25 daemon-process KV buckets is finalised in
docs/development/feature/daemon-local-autonomy.md §1e-audit.

bead: mulga-siv-19 (closes)

* feat(daemon): NATS observability hardening (1d-obs)

Closes the residual visibility gap left by the 1d non-blocking startup:

- Extend /local/status with kv_sync_failures, last_kv_sync_at,
  last_kv_sync_error so an operator polling the local API sees prolonged
  divergence between the local file and JetStream KV.
- Add a KVSyncObserver interface on JetStreamManager so the daemon can
  count best-effort write outcomes without coupling the manager to
  daemon state.
- Escalate utils.ConnectNATSWithRetry per-attempt log to slog.Error
  (rate-limited to once per minute, with disconnected_for context) once
  attempt count crosses the natsRetryEscalateAttempt threshold (~30
  attempts ≈ ~30 min disconnected at the 60s backoff cap), so a stuck
  NATS config surfaces in error logs instead of routine warn noise.

Prometheus metrics export deferred until a system-wide telemetry plan
(likely OTel) lands; the structured /local/status fields plus the
escalated retry log cover the Tier 1 visibility gap.

* feat(daemon): opt-in SPINIFEX_REQUIRE_NATS strict-startup mode (1d-strict)

When SPINIFEX_REQUIRE_NATS=1, startCluster's first NATS connect uses a
bounded ~30s retry and the daemon aborts on timeout. Default behaviour
(env unset) keeps the Tier 1 infinite-retry path so prod / edge nodes
still survive a missing NATS at boot. Restores pre-DDIL fail-fast UX
for dev / test / single-node deploys without flipping the prod default.

mulga-siv-44.

* test(scripts): systemd-level smoke for DDIL phase 1

Bash harness exercising daemon-local-autonomy on a real `make deploy` node:
baseline cluster mode → kill spinifex-nats → assert standalone + retry
counter advance + /local/instances served → restart NATS → assert mode flip
+ KV resync stamp → SPINIFEX_REQUIRE_NATS=1 bounded-exit. Cleanup trap
restores both services. Complements existing unit coverage by validating
real systemd wiring on port 8443.

* fix(test-ddil): daemon ClusterManager binds 4432 not 8443

8443 is predastore. Daemon's HTTPS ClusterManager (where /local/* lives)
binds [nodes.node1.daemon].host = 0.0.0.0:4432 in the deployed config.
Plan doc daemon-local-autonomy.md:112 incorrectly said 8443; actual code
uses d.config.Daemon.Host.

* fix(test-ddil): correct retry-counter site + bypass NATS Wants= for strict test

Two bugs in the v1 script:

1) nats_retry_count assertion ran during disconnect — but the counter
   only bumps on disconnect→reconnect *cycles* (daemon.go:321,
   onNATSReconnect). Moved assertion to step 3 (restart NATS) where
   the reconnect callback fires.

2) Step 4 stopped spinifex-nats then ran 'systemctl restart
   spinifex-daemon' — but the daemon unit's Wants=spinifex-nats.service
   re-pulled NATS up via the Wants= chain, so the strict-mode 30s
   timeout never fired. systemctl mask refuses on /etc-installed
   units, and --runtime mask is shadowed by /etc. Block via a runtime
   drop-in with ConditionPathExists=! pointing at a sentinel file.

Verified: all 5 checks pass on a single-node make deploy box; daemon
exits 32s after restart with SPINIFEX_REQUIRE_NATS=1 + NATS blocked.

* fix(admin): render data_dir into spinifex.toml so non-root nodes write local state

Pseudo-multinode E2E (rootless tf-user, ~/nodeN data roots) hit
"mkdir /var/lib/spinifex/state: permission denied" on every RunInstances:
Config.DataDir was empty because the template never emitted a data_dir
key, and LocalStatePath fell back to DefaultDataDir (/var/lib/spinifex).

Surfaced after 8eb9c5fa dropped the BaseDir fallback in LocalStatePath —
previously SPINIFEX_BASE_DIR=$DATA_DIR/spinifex/ (set by start-dev.sh)
masked the missing data_dir.

ConfigSettings.DataDir is already populated to spxRoot at admin
init/join; render it under [nodes.X] so it actually reaches the daemon.
2026-05-10 10:08:12 +10:00
joshsiv-mulga 7493f087d1 Update Ansible smoketest playbook to use debian 13 AMI 2026-05-08 15:56:16 +10:00
Julian Sommer 51f7afab4c fix(ui): github workflow 2026-05-08 12:42:02 +10:00
Julian Sommer 0aa53f78ea chore(ui): enable no-console 2026-05-08 12:39:55 +10:00
Julian Sommer fc0cc85731 chore(ui): enable vitest lint rules 2026-05-08 12:36:33 +10:00
Julian Sommer 6fa82c470d fix(vpcd): self-heal partial IGW topology on reconcile
reconcileIGW was non-idempotent and gated on "external switch missing",
so once a prior pass left the ext switch + LRP in place without the
localnet LSP, every subsequent vpcd start silently skipped the function
and the partial topology persisted forever (br-ext never got its patch
port; external traffic failed).

Convert each step to Get-then-Create idempotent and drop the cascade
rollbacks — partial state is now safe to leave for the next pass to
heal. Add FindStaticRoute so the default route is deduped instead of
duplicated on every retry. Drop the ext-switch gate in Reconcile and
ReconcileFromKV; rename IGWsCreated to IGWsReconciled to reflect the
new "called" semantics.
2026-05-08 12:09:05 +10:00
Julian Sommer 1c3583651d fix(ui): enable more rules 2026-05-08 11:55:18 +10:00
Julian Sommer f8ce8de566 fix(ui): require await 2026-05-08 11:39:33 +10:00
Julian Sommer b26bb8d35d fix(vpcd): bounded-retry reconcile-leader instead of fail-open (#202) 2026-05-08 10:47:22 +10:00
Josh Siviour 62ab74088e refactor(daemon): unify VPC + mgmt tap setup behind SetupTap(TapSpec) (#195) 2026-05-08 10:40:53 +10:00
Julian Sommer 6f3e901b9f chore: use go 1.26.3 2026-05-08 09:21:12 +10:00
joshsiv-mulga f44db4d84e fix(vpcd): reconcile EIP dnat_and_snat rules on startup
NAT rules are purely event-driven — only AssociateAddress creates them via
vpc.add-nat. No startup reconcile path means any OVN NB DB state drift
silently breaks EIP inbound traffic after a node reboot.

Add Step 5 to ReconcileFromKV that reads associated EIPs from the
spinifex-vpc-elastic-ips KV bucket and ensures each has a dnat_and_snat rule
in OVN NB DB. Correct rules are skipped (idempotent). Stale rules (wrong
LogicalIP) are replaced. Missing rules are created.

Also adds FindNATByExternalIP to OVNClient interface + LiveOVNClient/Mock
implementations, and three new tests covering: missing rule creation,
idempotent skip on correct rule, and stale rule replacement.
2026-05-07 23:41:41 +10:00
joshsiv-mulga 5ea7df6e7c fix(e2e): revert to internet-facing ALB; keep LaunchTime reboot check
The internal-ALB approach was wrong on two counts:
- ALB_ENI_MAC is the OVN VPC ENI MAC (lives on br-int), never appears
  in br-mgmt neighbour table, so the mgmt IP lookup always fails.
- Switching to internal masked the real bug: OVN EIP DNAT not restoring
  properly after a host reboot, which is exactly what this test should catch.

Restore internet-facing ALB and EIP-based traffic bursts. The post-reboot
traffic failure (Phase 8.6) is the correct signal for the outstanding OVN
EIP DNAT persistence bug.

Keep the Phase 8.3 LaunchTime fix: two-hop SSH to VPC private IPs doesn't
work in single-node macvlan (host has no route to VPC subnets). LaunchTime
from describe-instances is fast and topology-independent.
2026-05-07 21:52:37 +10:00
joshsiv-mulga 94405effec fix(e2e): cell-18 internal ALB + LaunchTime reboot check
Two changes to fix cell-18 (single/static/debian-13/reboot-single):

1. Switch ALB to --scheme internal.
   Internet-facing ALBs work pre-reboot on single-node but post-reboot the
   OVN EIP DNAT (nat-addresses=router GARP) is not re-established in time,
   causing 100% curl timeouts. Internal ALB routes traffic via br-mgmt using
   the mgmt IP discovered from the ALB ENI MAC in the br-mgmt neighbour table,
   bypassing OVN external NAT entirely.

2. Replace Phase 8.3 guest SSH uptime check with LaunchTime assertion.
   Two-hop SSH to VPC private IPs fails in single-node macvlan: the host has
   no route to VPC subnets. LaunchTime from describe-instances is reset by the
   daemon on QEMU relaunch, so LaunchTime >= REBOOT_START reliably confirms
   recovery without network reachability to the guest.
2026-05-07 21:35:23 +10:00
joshsiv-mulga 9ca8b84bea ci(baremetal): skip ePDU power-cycle until credentials are wired in 2026-05-07 16:39:21 +10:00
joshsiv-mulga e9aec9e784 ci(baremetal): restore bm-ci SSH key from secret at run start 2026-05-07 16:07:30 +10:00
joshsiv-mulga 8c6d0747e0 fix(ci): pass SPINIFEX_SRC/VIPERBLOCK_SRC to iso-builder, build viperblock .so 2026-05-07 15:50:30 +10:00
joshsiv-mulga 374f2ed7a3 feat(ci): add e2e-baremetal workflow — workflow_dispatch only
Separate from normal e2e. Checks out spinifex, checks out mulga scripts
(sparse: iso-builder + baremetal), builds spx + test tar via
bm-build-artifacts.sh, builds PXE artifacts (squashfs/initrd/vmlinuz)
via iso-builder with persistent cache on banksia, then runs
run-baremetal-e2e.sh against the single bare-metal node.
2026-05-07 15:38:05 +10:00
Julian Sommer 46d7a875ab fix: single node e2e direct s3 access 2026-05-07 14:52:58 +10:00
Julian Sommer 82d1c7aeee fix: add account id to predastore test 2026-05-07 14:42:41 +10:00
Julian Sommer 9640cca480 chore: bump predastore version 2026-05-07 14:29:57 +10:00
Julian Sommer 29a192766c configs 2026-05-07 13:51:03 +10:00
joshsiv-mulga 5f8ee0b5a2 feat(installer): persist SPINIFEX_INSTALL_CALLBACK through firstboot reboot
In the PXE path SPINIFEX_INSTALL_CALLBACK arrives on the iPXE kernel
cmdline but was never threaded into the firstboot script, so firstboot
never curled the controller's /done endpoint. The flag stayed on disk,
the node re-entered the install loop on the next PXE boot.

Fix: read SPINIFEX_INSTALL_CALLBACK from the live-environment env in
toFirstbootConfig and embed a best-effort curl into the generated
firstboot script, fired once after the success marker is written.

Interactive and USB-stick installs are unaffected (env var absent
-> callbackBlock is empty, no curl injected).
2026-05-07 12:28:09 +10:00
Julian Sommer a753ff99dd fix(UI): make mulga logo change with theme 2026-05-07 09:59:21 +10:00
Julian Sommer 7c860c99ca feat(UI): run instances block device mapping field (#200) 2026-05-07 09:54:46 +10:00
Julian Sommer 5e70ebd63f feat(UI): terminate instance confirmation (#199) 2026-05-07 09:21:33 +10:00
Julian Sommer a8591380cb test(daemon): add stopped/terminated handler tests against fake StateStore (#198) 2026-05-07 09:17:41 +10:00
Julian Sommer fefe506042 test(vm): improve vm tests (#197) 2026-05-06 16:42:14 +10:00
joshsiv-mulga 7ee21e0f25 fix(viperblockd): treat NBDKit code=0 premature exit as mount failure
NBDKit exiting within 1s was only treated as an error if exit code != 0.
A clean exit (code=0) fell through to Mounted=true, leaving QEMU with a
dead NBD socket and VMs that never boot post-reboot.

Treat any premature exit as failure regardless of exit code.
2026-05-06 15:55:41 +10:00
Julian Sommer 3eff6ccff2 test(vm): remove tautologies and asserts-nothing tests
manager_deps_test.go: drop TestDeps_VolumeMounterSatisfiesInterface,
TestDeps_StateStoreSatisfiesInterface, TestDeps_StateStoreErrorPropagates,
TestSetDeps_OverridesDeps, TestManagerHooks_InitiallyNil — they assert a
fake recorded its own input, that a fake returns an error literally
constructed in the test, or Go zero-value/direct-assignment semantics.
Compile-time interface checks already prove satisfaction.

state_test.go: replace TestEC2StateCodes_AllStatesHaveMapping and
TestEC2StateCodes_CorrectValues — both compared the production map to
its own literal. Now assert against an independent AWS contract using
ec2.InstanceStateName* SDK enum constants paired with the
AWS-documented numeric codes; a typo in production no longer
replicates into the test.

watchdog_test.go: replace TestStartPendingWatchdog_CtxCancelStopsGoroutine's
time.Sleep(10ms) "assertion" with goleak.VerifyNone. The previous form
passed even if the goroutine never returned on cancel — the harness
reaped it on test exit.
2026-05-06 15:19:51 +10:00
Julian Sommer 5b9a35914e feat(tests): missing vm tests (#196) 2026-05-06 15:13:32 +10:00
joshsiv-mulga 14b8fc234a fix: counts: unbound variable in bash 5.2 + set -u + empty declare -A 2026-05-06 15:01:40 +10:00
Julian Sommer 9c8f49ed71 fix: mark restored instance as failed on failed launch 2026-05-06 13:55:32 +10:00
Julian Sommer 8f7733eb64 fix: slog.Warn on instance kv migration failure 2026-05-06 13:51:34 +10:00
Julian Sommer f0eea9867b fix: cleanup instanceUp subscription if getConsoleOutput subscription fails 2026-05-06 13:45:35 +10:00
Julian Sommer c416a645f2 refactor(vm,daemon): post-extraction cleanup groups 1+2 (#194) 2026-05-06 13:30:06 +10:00
Julian Sommer 3aca1c9763 feat: require host and x-amz-date in SigV4 SignedHeaders (#193) 2026-05-06 11:31:33 +10:00
tomnewton-mulga 28ca689130 feat: VFIO GPU passthrough (#188)
* feat: add GPU discovery package (mulga-992)

Adds spinifex/gpu package with sysfs-based GPU enumeration, IOMMU group
member lookup, and a PCI device ID model table. Vendor-specific enrichment
via nvidia-smi is attempted opportunistically and falls back to the built-in
table when unavailable. All discovery and IOMMU logic is covered by unit tests
using mock sysfs trees.

* feat: add VFIO bind/unbind and GPU pool manager (mulga-993)

Adds gpu.BindVFIO/UnbindVFIO using sysfs driver_override for per-device
driver switching, and gpu.Manager which owns the claim/release lifecycle
for the GPU pool including full IOMMU group binding, rollback on partial
failure, and marking entries unavailable when rebind fails after VM
termination.

* feat: add G5 instance type generator with GPU model support (mulga-994)

- Add GPUModel type, NVIDIAa10g constant, and GPUModelForVendorDevice lookup
- Add generateGPUTypes to emit g5.* types with GpuInfo populated
- Add IsGPUType helper for consumers filtering GPU-capable types
- Update DetectAndGenerate to accept gpuModels []GPUModel parameter

* feat: integrate GPU passthrough into daemon lifecycle (mulga-995)

- Add gpu_passthrough config flag; discover GPUs and build manager at startup
- Gate g5 instance types on GPU pool availability via canAllocateCount
- Claim GPU (VFIO bind) in LaunchInstance; inject vfio-pci QEMU device
- Release GPU (VFIO unbind, rebind to host driver) in stopInstance
- Re-claim GPU on daemon restart when QEMU process is still alive
- Add UseUEFI / OVMF support for x86_64 VMs (optional, falls back to SeaBIOS)
- Fix Claim to use stored OriginalDriver when device is already vfio-pci bound

* feat: add GPU fields to NodeStatus and spx top nodes (mulga-996)

- Add TotalGPUs/AllocGPUs to NodeStatusResponse (zero on non-GPU nodes)
- Populate from gpuManager in handleNodeStatus
- Show GPU (used/total) column in spx top nodes; "-" when no GPUs present

* feat: Ubuntu 24.04 GPU AMI build pipeline (mulga-997)

Extends build-system-image.sh to support Ubuntu alongside Alpine.
Key differences handled: partition table (parted resizepart p1),
bind mounts for DKMS (/proc /sys /dev), apt vs apk, systemctl vs
rc-update. DISTRO defaults to "alpine" for backward compatibility.

Adds ubuntu-gpu.conf manifest and ubuntu-gpu-setup.sh chroot script
that installs nvidia-headless-550-server with DKMS pre-built against
a pinned linux-image-generic kernel, blacklists nouveau, and prevents
unattended-upgrades from invalidating the pre-built module.

* Fixed GPU passthrough and added test script

* feat: always-probe GPU at startup, surface capability in NodeStatusResponse

- Add gpuProbeResult struct and probeGPU() which runs at daemon startup
  regardless of gpu_passthrough config. Checks sysfs for GPUs, IOMMU
  groups, and vfio_pci module presence.
- Store gpuProbe on Daemon struct; split NewDaemon into probe (always)
  and activate (opt-in) phases.
- Populate GPUCapable, GPUPassthrough, GPUModels on NodeStatusResponse
  so operators can see hardware state before enabling passthrough.
- Update 'spx top' GPU column: '-' = no hardware, '0/N*' = capable but
  passthrough not active, 'used/total' = passthrough active.

* feat: SIGHUP hot-reload for GPU passthrough config (mulga-1001)

- Export GenerateGPUTypes for use by the reload path
- Add ResourceManager.reloadGPUTypes: mutates instanceTypes map in-place
  (preserves references held by instanceService) and updates NATS
  subscriptions without full daemon restart
- Separate SIGHUP from SIGTERM/SIGINT: setupReload() listens for SIGHUP
  in a context-aware loop; setupShutdown no longer intercepts SIGHUP
- Add reloadConfig() + applyGPUConfig(): re-reads spinifex.toml on signal,
  handles false→true (re-probe + activate) and true→false (refuse if
  instances running, otherwise deactivate) transitions

* feat: spx admin gpu status/enable/disable commands (mulga-1000)

- admin.SetGPUPassthrough: Go implementation of the idempotent TOML write
  (replaces the inline Python heredoc in spx-test-gpu.sh)
- spx admin gpu status: shows GPU hardware, IOMMU/vfio state, passthrough
  activation, and advertised g5 instance types via NATS NodeStatusResponse
- spx admin gpu enable: checks prerequisites, writes TOML, sends SIGHUP,
  polls until daemon confirms GPUPassthrough=true
- spx admin gpu disable: refuses while GPU instances are running, otherwise
  writes TOML, sends SIGHUP, polls until confirmed
- spx-test-gpu.sh: replace Python TOML heredoc with spx admin gpu enable

* fixed admin

* fix: filter Intel iGPU from passthrough pool, use lspci for consumer GPU names

Discover() now skips non-NVIDIA/AMD vendors so Intel iGPUs never enter
the passthrough pool. enrichFromLspci() uses the system PCI ID database
via lspci -vmm to populate the model name for any card not in the
hardcoded table, without needing nvidia-smi or driver tools.

* fix: grant CAP_SYS_ADMIN and vfio device access to spinifex-daemon

GPU passthrough requires writing to /sys/bus/pci/drivers/vfio-pci/unbind
on instance stop. The kernel gates this on CAP_SYS_ADMIN regardless of
file permissions. QEMU subprocesses also need char-vfio rw access to
open /dev/vfio/N for the passed-through device.

* fixing gpu bining permissions

* working state of gpu passthrough on phyiscal box

* installer grub image fixes

* made gpu-passthrough more user facing

* fixed banner

* fixed banner

* banner fix

* gpu passthrough docs

* preflight tweak

* vpcd fixes to set br-wan to promisc for public IP DHCP processes

* fixed vpcd br-wan promisc and veth pair

* ssh keygen fix

* fixes after running /review-branch

* feat: re-wire GPU claim and device injection after vm.Manager refactor

GPU passthrough claim (VFIO bind) and QEMU device injection were lost
when dev's vm.Manager refactor (#185/#186) moved StartInstance/
CreateQMPClient into vm/lifecycle.go. Re-add both in their correct
new homes:

- daemon_handlers_instance.go: claim GPU before vmMgr.Run() in both
  RunInstances and StartStoppedInstance handlers; release on failure
- vm/lifecycle.go startQEMU(): inject vfio-pci device with x-vga flag
  derived from instance.GPUXVGAEnabled when GPUPCIAddress is set

* fix: Create spinifex user ~/.ssh and keys in firstboot

---------

Co-authored-by: Ben Duncan <5178243+benduncan@users.noreply.github.com>
Co-authored-by: Julian Sommer <115516918+juliansommer@users.noreply.github.com>
2026-05-06 09:52:23 +10:00
Julian Sommer 2c1c46b1f6 test(vm): migrate lifecycle coverage from daemon to vm package (#192) 2026-05-06 09:34:55 +10:00
Julian Sommer addf2bc86e refactor(vm): typed Status accessor, sentinel-aware Stop/Terminate (#191) 2026-05-05 16:37:21 +10:00
Julian Sommer 34c461be9c refactor(vm): move Restore/crash recovery/watchdog onto Manager (#190) 2026-05-05 16:13:20 +10:00
Julian Sommer c2be7f047c refactor(vm): move AttachVolume/DetachVolume/Reboot onto Manager (#189) 2026-05-05 14:27:59 +10:00
Julian Sommer cfe56c49d1 refactor(vm): move Stop/Terminate/MarkFailed onto Manager (#187) 2026-05-05 13:25:32 +10:00
Julian Sommer e3d2504b42 refactor(daemon): Move instance commands to vm manager (#186) 2026-05-05 11:38:53 +10:00
Julian Sommer d28ac40ce7 refactor(vm): introduce collaborator interfaces and daemon adapters (#185) 2026-05-05 10:48:38 +10:00
Julian Sommer 56cc83f723 chore: merge main into dev 2026-05-05 09:55:10 +10:00
Julian Sommer a216d6f596 chore(ci): point nightly e2e at dev branch
Scheduled runs were using github.ref_name, which equals the default branch
(main) — so the nightly was testing main. Hardcode GIT_BRANCH=dev so the
build-install-artifacts.sh + tofu provisioning target dev's HEAD instead.

GitHub always loads scheduled-workflow files from the default branch, so
this change must stay in sync on main for the schedule to use it.
2026-05-05 09:54:53 +10:00
Julian Sommer ce0bbde8dc fix(e2e): poll guest SSH after reboot in reboot-e2e cell
The post-reboot guest uptime check was a single-shot ssh with a 10s connect
timeout. After the host comes back, guest QEMU + cloud-init + sshd inside
each guest still need a few seconds to settle, so the check raced them.

Poll up to GUEST_SSH_SECS (default 180s) per guest, mirroring the host
TCP/22 poll. Run 25329248329 cell-18 reproduced this — ALB targets went
healthy ~2s after the second SSH attempt failed.
2026-05-05 09:54:47 +10:00
dependabot[bot] 78c3cce183 chore: bump the go-dependencies group with 3 updates (#183) 2026-05-05 09:38:50 +10:00
dependabot[bot] 968cc687a0 chore: bump the npm-dependencies group (#184) 2026-05-05 09:36:26 +10:00
Julian Sommer f3b00eb85a feat: ubuntu 26.04 image 2026-05-05 09:32:29 +10:00
Julian Sommer fa071795d0 fix: use debian 13 2026-05-05 09:10:59 +10:00
Julian Sommer 2489621831 chore: merge main into dev 2026-05-05 09:04:08 +10:00
joshsiv-mulga 9ed59aac79 fix(e2e): prevent silent set -e abort on var=$(func) patterns
When a function returning non-zero is captured via command substitution
under `set -euo pipefail`, the assignment exits the script before the
following `if [ -z "$var" ]` diagnostic can run. CI failures showed up
as a sudden jump to the EXIT trap with no error message.

- run-multinode-e2e.sh: wrap get_remote_ssh_port in `if !`, bump retry
  10 -> 30, add dump_guest_ssh_diagnostics on miss
- run-pseudo-multinode-e2e.sh, run-e2e.sh: wrap get_ssh_port in `if !`
- run-lb-e2e.sh: add `|| VAR=""` fallback on pick_peer
2026-05-04 13:44:49 +10:00
joshsiv-mulga 89aec5c709 docs: warn br-wan prerequisite for curl install
Add WAN bridge prerequisite callout to single-node and multi-node
install guides. Bootstrap installer assumes br-wan exists; running
on a host whose default route is on a bare NIC leaves install in a
non-working state. Auto-provisioning will land in next release.
2026-05-01 15:59:14 +10:00
Julian Sommer cbe335e903 chore: bump single node e2e timeout to 35 mins 2026-05-01 15:51:42 +10:00
Julian Sommer e9a335f433 chore: merge main into dev (ISO installer) 2026-05-01 15:22:41 +10:00
Ben Duncan 89318d8f6e ISO method for installer 2026-05-01 15:18:00 +10:00
Julian Sommer e1b3623599 refactor: introduce vm.Manager (phase 1 of lifecycle extraction) (#176) 2026-05-01 15:15:54 +10:00
Julian Sommer 57a11c0668 fix(vpc): validate SG rule CidrIp/SourceSG to block OVN ACL injection (#182) 2026-05-01 15:15:42 +10:00
Julian Sommer 050cc03ae2 fix: daemon_test.go allocation 2026-05-01 15:14:03 +10:00
491 changed files with 41955 additions and 13021 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 525 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 490 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 754 KiB

+3 -3
View File
@@ -8,7 +8,7 @@ updates:
day: monday
timezone: Australia/Sydney
cooldown:
default-days: 1
default-days: 3
groups:
go-dependencies:
patterns:
@@ -22,7 +22,7 @@ updates:
day: monday
timezone: Australia/Sydney
cooldown:
default-days: 1
default-days: 3
ignore:
- dependency-name: "*"
update-types:
@@ -41,7 +41,7 @@ updates:
day: monday
timezone: Australia/Sydney
cooldown:
default-days: 1
default-days: 3
ignore:
- dependency-name: "@types/node"
update-types:
+117
View File
@@ -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"
+82 -4
View File
@@ -11,6 +11,9 @@ on:
required: false
default: ""
permissions:
contents: read
env:
GIT_BRANCH: ${{ github.ref_name }}
@@ -27,7 +30,7 @@ jobs:
TOFU_LOG: /tmp/e2e-tofu-ddil-${{ 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
@@ -53,6 +56,26 @@ jobs:
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: |
@@ -95,18 +118,73 @@ jobs:
# 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}" \
go test -tags=e2e -timeout 45m -json \
./tests/e2e/ddil/scenarios/... \
| tee "${RUNNER_TEMP}/ddil-results/results.json"
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()
+4 -2
View File
@@ -12,8 +12,10 @@ on:
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.ref_name }}
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:
@@ -58,7 +60,7 @@ jobs:
- name: Clean Workspace
if: steps.gate.outputs.selected == 'true'
run: rm -rf "$GITHUB_WORKSPACE"/*
run: sudo rm -rf "$GITHUB_WORKSPACE"/*
- uses: actions/create-github-app-token@v3
if: steps.gate.outputs.selected == 'true'
+7 -6
View File
@@ -19,6 +19,7 @@ on:
required: false
permissions:
contents: read
statuses: write
env:
@@ -28,12 +29,12 @@ jobs:
e2e_single:
name: Single-Node E2E
runs-on: [ self-hosted, ci-single ]
timeout-minutes: 30
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
@@ -97,12 +98,12 @@ jobs:
e2e_pseudo_multi:
name: Pseudo Multi-Node E2E
runs-on: [ self-hosted, ci-pseudo ]
timeout-minutes: 30
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
@@ -161,12 +162,12 @@ jobs:
e2e_real_multi:
name: Real Multi-Node E2E
runs-on: [ self-hosted, ci-multi ]
timeout-minutes: 35
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
+155
View File
@@ -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 }}
@@ -192,3 +200,150 @@ jobs:
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"
+9
View File
@@ -17,6 +17,9 @@ on:
- dev
workflow_dispatch:
permissions:
contents: read
jobs:
build_and_test:
name: Build and Test
@@ -33,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
@@ -73,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
@@ -107,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
+5 -2
View File
@@ -17,6 +17,9 @@ on:
- .github/workflows/ui.yml
workflow_dispatch:
permissions:
contents: read
jobs:
ui_build_and_test:
name: UI Build and Test
@@ -46,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
+2
View File
@@ -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
+47 -10
View File
@@ -63,15 +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"
go build -ldflags "-s -w" -o ./bin/spinifex-installer cmd/installer/main.go
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
@@ -79,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)...."
@@ -122,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
@@ -149,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
@@ -206,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
@@ -238,9 +272,12 @@ ansible-dev-install:
ansible-dev-reset:
cd scripts/ansible && ansible-playbook playbooks/dev-reset.yml
.PHONY: build build-ui build-installer 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-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 \
ansible-dev-preflight ansible-dev-teardown ansible-dev-install ansible-dev-reset
ansible-dev-preflight ansible-dev-teardown ansible-dev-install ansible-dev-reset ansible-dev-deploy
+16 -1
View File
@@ -93,9 +93,24 @@ AWS-Compatible Interfaces – Provision infrastructure with awscli, Terraform, o
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
+2 -2
View File
@@ -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
+56
View File
@@ -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
+4
View File
@@ -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
+6 -3
View File
@@ -5,17 +5,20 @@
CONF=/etc/spinifex/spinifex.toml
BIND="0.0.0.0:8443"
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
@@ -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
+46 -3
View File
@@ -2,27 +2,67 @@
Description=Spinifex Daemon (VM Orchestration)
PartOf=spinifex.target
After=spinifex-nats.service spinifex-viperblock.service openvswitch-switch.service ovn-controller.service
Wants=spinifex-nats.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)
# 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
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
@@ -50,6 +90,9 @@ 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,6 +17,7 @@ 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
+5
View File
@@ -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"
+172
View File
@@ -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
}
+10 -1
View File
@@ -134,12 +134,21 @@ func buildConfig() (*install.Config, error) {
}
}
if os.Getenv("SPINIFEX_GPU_PASSTHROUGH") == "1" {
cfg.GPUPassthrough = true
}
role := strings.ToLower(os.Getenv("SPINIFEX_ROLE"))
if role == "" {
role = "init"
}
cfg.ClusterRole = role
if role == "join" {
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")
@@ -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")
}
}
+43 -3
View File
@@ -43,6 +43,19 @@ type Config struct {
// 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
@@ -78,6 +91,14 @@ func writeScript(root string, cfg Config) error {
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
@@ -146,6 +167,18 @@ SETUP_STAGES=fixown /usr/local/share/spinifex/setup.sh
# 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
@@ -176,7 +209,7 @@ systemctl enable spinifex.target spinifex-banner.service
# 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
@@ -196,13 +229,16 @@ if grep -q 'external_mode.*pool' /etc/spinifex/spinifex.toml 2>/dev/null; then
echo "[firstboot] warning: br-ext not up after 30s — external networking may be delayed"
fi
fi
`, cfg.Hostname, setupOVN, clusterCmd)
`, 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
@@ -210,11 +246,15 @@ func buildClusterCmd(cfg Config) string {
// 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", cfg.Hostname, emailFlag)
return fmt.Sprintf("spx admin init --node %s --nodes 1%s%s", cfg.Hostname, emailFlag, gpuFlag)
}
}
+100
View File
@@ -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")
}
}
+9
View File
@@ -55,6 +55,10 @@ type Config struct {
// 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
@@ -67,4 +71,9 @@ type Config struct {
// 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
}
+75 -36
View File
@@ -23,6 +23,7 @@ import (
"io"
"log/slog"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
@@ -77,6 +78,7 @@ func Run(cfg *Config) error {
}
slog.Info("installation complete")
fireInstallCallback()
promptRemoveUSB()
return reboot()
}
@@ -172,6 +174,7 @@ func copyRootfs() error {
"--exclude=/etc/openvswitch/",
"--exclude=/var/lib/openvswitch/",
"--exclude=/var/lib/dhcpcd/",
"--exclude=/etc/ssh/ssh_host_*",
"--exclude=/lost+found",
"--exclude=/boot/efi",
"/", mountRoot+"/",
@@ -555,6 +558,11 @@ func installBootloader(disk string) error {
}
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)
@@ -644,6 +652,26 @@ func promptRemoveUSB() {
}
}
// 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")
@@ -662,11 +690,13 @@ func (c *Config) toFirstbootConfig() firstboot.Config {
encapIP = c.LANAddress
}
return firstboot.Config{
Hostname: c.Hostname,
EncapIP: encapIP,
ClusterRole: c.ClusterRole,
JoinAddr: c.JoinAddr,
Email: c.Email,
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,
}
}
@@ -712,9 +742,46 @@ func partitionPaths(disk string) (efi, root string) {
return disk + "2", disk + "3"
}
// copySplashImage copies the GRUB splash (embedded in the squashfs at build time by
// inject-bins.sh) into the installed system so the post-install GRUB shows the same
// branded background as the installer GRUB. Non-fatal — missing source is logged and skipped.
// 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)
@@ -740,34 +807,6 @@ func copySplashImage(root string) {
}
}
// copyGrubFont copies the unicode font into the installed system's
// /boot/grub/fonts/ so the loadfont path in 05_spinifex resolves at boot.
// grub-install does not copy fonts; we mirror what build-iso.sh does.
func copyGrubFont(root string) {
const src = "/usr/share/grub/unicode.pf2"
in, err := os.Open(src)
if err != nil {
slog.Warn("copyGrubFont: font not found, graphical GRUB may not work", "path", src)
return
}
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)
}
}
// 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 {
+27
View File
@@ -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)
}
}
+1
View File
@@ -24,6 +24,7 @@ import (
"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() {
+2
View File
@@ -19,6 +19,8 @@ import (
"syscall"
"github.com/mulgadc/spinifex/spinifex/lbagent"
_ "github.com/mulgadc/spinifex/internal/fipsboot"
)
func main() {
+171 -17
View File
@@ -45,6 +45,7 @@ import (
"github.com/mulgadc/spinifex/spinifex/admin"
"github.com/mulgadc/spinifex/spinifex/config"
"github.com/mulgadc/spinifex/spinifex/formation"
"github.com/mulgadc/spinifex/spinifex/gpu"
handlers_ec2_vpc "github.com/mulgadc/spinifex/spinifex/handlers/ec2/vpc"
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
"github.com/mulgadc/spinifex/spinifex/utils"
@@ -199,13 +200,13 @@ spx admin images list
- fetches from remote endpoint for common/trusted images to bootstrap environment, or baked in from compile.
// If --name specified, download
spx admin images import --name debian-12-x86_64
spx admin images import --name debian-13-x86_64
// List available images
spx admin images list
// Manually import a path
spx admin images import --file /path/to/image --distro debian --version 12 --arch x86_64
spx admin images import --file /path/to/image --distro debian --version 13 --arch x86_64
-> x <-
*/
@@ -274,6 +275,7 @@ func init() {
adminInitCmd.Flags().String("gateway-ip", "", "OVN gateway router's external IP for SNAT (default: pool range_start for pool mode, required for nat mode without DHCP)")
adminInitCmd.Flags().Int("external-prefix-len", 24, "External pool subnet prefix length (auto-detected)")
adminInitCmd.Flags().Bool("no-external", false, "Disable external networking (overlay-only, no internet for VMs)")
adminInitCmd.Flags().Bool("gpu-passthrough", false, "Enable VFIO GPU passthrough (sets gpu_passthrough = true in daemon config)")
// Flags for admin join
adminJoinCmd.Flags().String("region", "ap-southeast-2", "Region for this node")
@@ -660,6 +662,7 @@ func runAdminInit(cmd *cobra.Command, args []string) {
externalPrefixLen, _ := cmd.Flags().GetInt("external-prefix-len")
gatewayIP, _ := cmd.Flags().GetString("gateway-ip")
noExternal, _ := cmd.Flags().GetBool("no-external")
gpuPassthrough, _ := cmd.Flags().GetBool("gpu-passthrough")
// Fire telemetry in background (completes during init work, waited at end)
noTelemetry, _ := cmd.Flags().GetBool("no-telemetry")
@@ -901,6 +904,16 @@ func runAdminInit(cmd *cobra.Command, args []string) {
fmt.Printf(" Bootstrap: %s\n", filepath.Join(bootstrapDir, "bootstrap.json"))
fmt.Printf(" System creds: %s\n", filepath.Join(configDir, "system-credentials.json"))
// Predastore encryption key is per-node and never transmitted; generate
// it locally now so the service has it on first start.
predastoreKeyPath, err := writePredastoreEncryptionKey(configDir)
if err != nil {
fmt.Fprintf(os.Stderr, "Error generating predastore encryption key: %v\n", err)
os.Exit(1)
}
fmt.Println("\n🔐 Generated predastore encryption key (per-node, never transmitted)")
fmt.Printf(" Key: %s\n", predastoreKeyPath)
fmt.Printf("\n🔑 Generated admin credentials (save these — they won't be shown again):\n")
fmt.Printf(" Access Key: %s\n", bootstrapResult.AdminAccessKey)
fmt.Printf(" Secret Key: %s\n", bootstrapResult.AdminSecretKey)
@@ -980,8 +993,8 @@ func runAdminInit(cmd *cobra.Command, args []string) {
var predastoreNodeID int
if predastoreNodesStr != "" {
ips := strings.Split(predastoreNodesStr, ",")
if len(ips) < 3 {
fmt.Fprintf(os.Stderr, "❌ Error: --predastore-nodes requires at least 3 IPs, got %d\n", len(ips))
if len(ips) < 2 {
fmt.Fprintf(os.Stderr, "❌ Error: --predastore-nodes requires at least 2 IPs, got %d\n", len(ips))
os.Exit(1)
}
@@ -1072,6 +1085,8 @@ func runAdminInit(cmd *cobra.Command, args []string) {
BootstrapIgwId: bootstrapIgwId,
BootstrapCidr: handlers_ec2_vpc.DefaultVPCCidr,
BootstrapSubnetCidr: handlers_ec2_vpc.DefaultSubnetCidr,
GPUPassthrough: gpuPassthrough,
}
// Print external networking summary
@@ -1098,6 +1113,20 @@ func runAdminInit(cmd *cobra.Command, args []string) {
finalizeNodeSetup(spxRoot, certPath, bootstrapResult.AdminAccessKey, bootstrapResult.AdminSecretKey, region, bindIP, advertiseIP)
// Write node.conf so spx admin banner works on source installs (not just ISO).
nodeHostname, _ := os.Hostname()
if nodeHostname == "" {
nodeHostname = node
}
nodeConfPath := filepath.Join(configDir, "node.conf")
if err := writeNodeConf(nodeConfPath, map[string]string{
"MANAGEMENT_IP": advertiseIP,
"MANAGEMENT_IFACE": "br-wan",
"NODE_HOSTNAME": nodeHostname,
}); err != nil {
fmt.Fprintf(os.Stderr, "⚠️ Warning: could not write %s: %v\n", nodeConfPath, err)
}
// Print success message
fmt.Println("\n🎉 Spinifex initialization complete!")
fmt.Println()
@@ -1153,6 +1182,17 @@ func runAdminInitMultiNode(cmd *cobra.Command, accessKey, secretKey, accountID,
fmt.Println("\n🔐 Generated IAM master key")
fmt.Printf(" Bootstrap: %s\n", filepath.Join(bootstrapDir, "bootstrap.json"))
// Predastore encryption key is per-node and never distributed via the
// formation server. Generate only the leader's own key here; each
// joiner generates its own during `spx admin join`.
predastoreKeyPath, err := writePredastoreEncryptionKey(configDir)
if err != nil {
fmt.Fprintf(os.Stderr, "❌ Error generating predastore encryption key: %v\n", err)
os.Exit(1)
}
fmt.Println("\n🔐 Generated predastore encryption key (per-node, never transmitted)")
fmt.Printf(" Key: %s\n", predastoreKeyPath)
// Read CA cert/key for distribution to joining nodes
caCertData, err := os.ReadFile(filepath.Join(configDir, "ca.pem"))
if err != nil {
@@ -1243,7 +1283,7 @@ func runAdminInitMultiNode(cmd *cobra.Command, accessKey, secretKey, accountID,
// Generate multi-node predastore config
var predastoreNodeID int
hasPredastoreConfig := len(predastoreNodes) >= 3
hasPredastoreConfig := len(predastoreNodes) >= 2
if hasPredastoreConfig {
predastoreContent, err := admin.GenerateMultiNodePredastoreConfig(predastoreMultiNodeTemplate, predastoreNodes, accessKey, secretKey, region, natsToken, configDir, bindIP)
if err != nil {
@@ -1623,6 +1663,16 @@ func runAdminJoin(cmd *cobra.Command, args []string) {
fmt.Println("✅ IAM master key received from leader")
fmt.Printf("✅ Bootstrap file written: %s\n", filepath.Join(bootstrapDir, "bootstrap.json"))
// Predastore encryption key is per-node: generate locally rather than
// receiving from the leader. Each node only opens fragments it sealed
// itself, so there is no cluster-wide predastore key to share.
predastoreKeyPath, err := writePredastoreEncryptionKey(configDir)
if err != nil {
fmt.Fprintf(os.Stderr, "❌ Error generating predastore encryption key: %v\n", err)
os.Exit(1)
}
fmt.Printf("✅ Predastore encryption key generated: %s\n", predastoreKeyPath)
// Generate server cert signed by CA with this node's bind IP
if err := admin.GenerateServerCertOnly(configDir, bindIP); err != nil {
fmt.Fprintf(os.Stderr, "Error generating server certificate: %v\n", err)
@@ -1646,7 +1696,7 @@ func runAdminJoin(cmd *cobra.Command, args []string) {
// Generate multi-node predastore config
var predastoreNodeID int
hasPredastoreConfig := len(predastoreNodes) >= 3
hasPredastoreConfig := len(predastoreNodes) >= 2
if hasPredastoreConfig {
predastoreContent, err := admin.GenerateMultiNodePredastoreConfig(predastoreMultiNodeTemplate, predastoreNodes, creds.AccessKey, creds.SecretKey, creds.Region, creds.NatsToken, configDir, bindIP)
@@ -2094,6 +2144,27 @@ func writeBootstrapFiles(configDir, bootstrapDir string, masterKey []byte, acces
}, nil
}
// writePredastoreEncryptionKey generates a fresh 32-byte AES-256 master key
// for this node's predastore data dir and writes it to
// <configDir>/predastore/encryption.key with mode 0600. The key is per-node:
// every node generates its own at init/join time and never transmits it,
// because predastore only opens fragments on the node that sealed them.
func writePredastoreEncryptionKey(configDir string) (string, error) {
predastoreDir := filepath.Join(configDir, "predastore")
if err := os.MkdirAll(predastoreDir, 0750); err != nil {
return "", fmt.Errorf("create predastore config dir: %w", err)
}
key, err := handlers_iam.GenerateMasterKey()
if err != nil {
return "", fmt.Errorf("generate predastore encryption key: %w", err)
}
keyPath := filepath.Join(predastoreDir, "encryption.key")
if err := handlers_iam.SaveMasterKey(keyPath, key); err != nil {
return "", fmt.Errorf("save predastore encryption key: %w", err)
}
return keyPath, nil
}
// writeSystemCredentials writes the system access key to a plaintext JSON file.
// The daemon reads this at startup to inject credentials into ALB VM cloud-init
// for SigV4-authenticated communication with the AWS gateway.
@@ -2304,6 +2375,12 @@ func runAdminBanner(cmd *cobra.Command, _ []string) {
}
// Resolve current IP from the management interface at runtime.
// If node.conf is absent or has no MANAGEMENT_IFACE (source installs before
// the first spx admin init writes node.conf), fall back to br-wan which
// setup-ovn.sh always creates as the management bridge.
if iface == "" {
iface = "br-wan"
}
currentIP := resolveIfaceIP(iface)
if currentIP == "" {
currentIP = recordedIP // fall back to value recorded at install time
@@ -2331,17 +2408,17 @@ func runAdminBanner(cmd *cobra.Command, _ []string) {
}
banner := fmt.Sprintf(`
+----------------------------------------------------+
| Spinifex — Mulga Defense Corporation |
+----------------------------------------------------+
| Node: %-39s|
| Login: %-39s|
| Dashboard: %-39s|
| API: %-39s|
| SSH: %-39s|
+----------------------------------------------------+
| AWS credentials: cat ~/.aws/credentials |
+----------------------------------------------------+
+--------------------------------------------------------------+
| Spinifex — Mulga Defense Corporation |
+--------------------------------------------------------------+
| Node: %-49s|
| Login: %-49s|
| Dashboard: %-49s|
| API: %-49s|
| SSH: %-49s|
+--------------------------------------------------------------+
| AWS credentials: cat ~/.aws/credentials |
+--------------------------------------------------------------+
`,
hostname,
@@ -2351,6 +2428,8 @@ func runAdminBanner(cmd *cobra.Command, _ []string) {
"spinifex@"+currentIP,
)
banner += gpuBannerSection()
// Write to /etc/issue — displayed on the console before the login prompt.
// Overwrite entirely; this is a purpose-built appliance so we own this file.
if err := os.WriteFile("/etc/issue", []byte(banner), 0o644); err != nil {
@@ -2390,6 +2469,81 @@ func appendBannerToMotd(banner string) error {
return os.WriteFile(motdPath, []byte(base+sentinel+banner), 0o644)
}
// gpuBannerSection returns an optional banner box section describing GPU state.
// Returns "" when no GPU hardware is detected. Safe to call at boot before the
// daemon starts — all checks are sysfs/file reads, no NATS required.
func gpuBannerSection() string {
devices, err := gpu.Discover()
if err != nil || len(devices) == 0 {
return ""
}
iommuEntries, _ := os.ReadDir("/sys/kernel/iommu_groups/")
iommuActive := len(iommuEntries) > 0
_, vfioErr := os.Stat("/sys/module/vfio_pci")
vfioPresent := vfioErr == nil
passthroughEnabled := false
cfgPath := DefaultConfigFile()
if cfg, err := config.LoadConfig(cfgPath); err == nil {
if nodeCfg, ok := cfg.Nodes[cfg.Node]; ok {
passthroughEnabled = nodeCfg.Daemon.GPUPassthrough
}
}
models := gpuModelSummary(devices)
var statusLine, hintLine string
switch {
case passthroughEnabled:
statusLine = "Passthrough enabled"
case iommuActive && vfioPresent:
statusLine = "Ready to enable"
hintLine = "sudo spx admin gpu enable"
default:
statusLine = "Setup required"
hintLine = "sudo spx admin gpu setup"
}
const (
sep = " +--------------------------------------------------------------+\n"
maxVal = 55
)
if len([]rune(models)) > maxVal {
models = string([]rune(models)[:maxVal-3]) + "..."
}
section := sep +
fmt.Sprintf(" | GPU: %-55s|\n", models) +
fmt.Sprintf(" | %-55s|\n", statusLine)
if hintLine != "" {
section += fmt.Sprintf(" | %-55s|\n", hintLine)
}
section += sep + "\n"
return section
}
func gpuModelSummary(devices []gpu.GPUDevice) string {
counts := make(map[string]int)
var order []string
for _, d := range devices {
if counts[d.Model] == 0 {
order = append(order, d.Model)
}
counts[d.Model]++
}
var parts []string
for _, m := range order {
if n := counts[m]; n > 1 {
parts = append(parts, fmt.Sprintf("%dx %s", n, m))
} else {
parts = append(parts, m)
}
}
return strings.Join(parts, ", ")
}
// parseNodeConf reads a KEY=VALUE shell-format file and returns a map.
// Lines starting with # and blank lines are ignored.
func parseNodeConf(path string) map[string]string {
+486
View File
@@ -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
}
+49 -1
View File
@@ -22,7 +22,7 @@ import (
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-12-x86_64"
const imageName = "debian-13-x86_64"
errs := []error{
fmt.Errorf("%w: expected abc got def", utils.ErrChecksumMismatch),
@@ -262,3 +262,51 @@ func TestPredastoreMultinodeTemplate_NatsURLLoopbackShim(t *testing.T) {
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())
}
+18 -2
View File
@@ -115,6 +115,13 @@ var predastoreStartCmd = &cobra.Command{
return
}
encryptionKeyFile := viper.GetString("encryption-key-file")
if encryptionKeyFile == "" {
fmt.Println("Encryption key file is not set")
return
}
nodeID := viper.GetInt("node-id")
pprofEnabled := viper.GetBool("pprof")
pprofOutput := viper.GetString("pprof-output")
@@ -128,6 +135,8 @@ var predastoreStartCmd = &cobra.Command{
TlsCert: tlsCert,
TlsKey: tlsKey,
EncryptionKeyFile: encryptionKeyFile,
NodeID: nodeID,
PprofEnabled: pprofEnabled,
@@ -858,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,6 @@
# Predastore S3-compatible storage configuration file in TOML format.
# Generated for multi-node cluster
version = "3"
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,10 +44,12 @@ 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"] },
]
+3 -1
View File
@@ -1,6 +1,6 @@
# Predastore S3-compatible storage configuration file in TOML format.
# MULTI NODE used for testing
version = "3"
version = "4"
region = "{{.Region}}"
# Optionally configure port and host, or specify via daemon CLI options
@@ -73,10 +73,12 @@ 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"] },
]
+4
View File
@@ -52,6 +52,7 @@ advertise = "{{.AdvertiseIP}}"
{{- end}}
region = "{{.Region}}"
az = "{{.Az}}"
data_dir = "{{.DataDir}}"
{{- if .Services}}
services = [{{range $i, $s := .Services}}{{if $i}}, {{end}}"{{$s}}"{{end}}]
{{- end}}
@@ -67,6 +68,9 @@ dev_networking = false
{{- else}}
dev_networking = true
{{- end}}
{{- if .GPUPassthrough}}
gpu_passthrough = true
{{- end}}
[nodes.{{.Node}}.awsgw]
host = "{{.BindIP}}:9999"
+9 -1
View File
@@ -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) {
"-",
"-",
"-",
"-",
})
}
}
+4 -1
View File
@@ -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()
+15 -4
View File
@@ -45,7 +45,7 @@ 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
@@ -123,6 +123,15 @@ 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
@@ -133,13 +142,14 @@ file, deferred to a later phase.
| `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** |
@@ -336,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** |
@@ -77,7 +77,7 @@ Prints per-node CPU/memory usage and cluster-wide instance type availability.
```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.
+223
View File
@@ -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:
![GPU not yet enabled](../../../.github/assets/images/gpu-banner1.png)
---
## 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.
![GPU enabled](../../../.github/assets/images/gpu-enabled.png)
---
## 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
```
+1 -1
View File
@@ -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
+48
View File
@@ -573,6 +573,54 @@ external_interface = "br-wan"
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)
```
@@ -74,8 +74,8 @@ Mirror the cloud image you intend to run as guest VMs:
```bash
mkdir -p images
curl -fsSL "https://cdimage.debian.org/cdimage/cloud/bookworm/latest/debian-12-genericcloud-amd64.qcow2" \
-o images/debian-12-amd64.qcow2
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
@@ -140,8 +140,8 @@ sudo systemctl start spinifex.target
Register the pre-staged image with Spinifex:
```bash
sudo spx admin images import --file /mnt/usb/images/debian-12-amd64.qcow2 \
--distro debian --version 12 --arch x86_64
sudo spx admin images import --file /mnt/usb/images/debian-13-amd64.qcow2 \
--distro debian --version 13 --arch x86_64
```
## Step 9. Verify
@@ -22,6 +22,7 @@ resources:
## Table of Contents
- [Overview](#overview)
- [Prerequisites](#prerequisites)
- [Instructions](#instructions)
- [Troubleshooting](#troubleshooting)
@@ -37,6 +38,22 @@ A Spinifex cluster distributes services across multiple servers for high availab
- 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
@@ -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
+19 -2
View File
@@ -22,6 +22,7 @@ resources:
## Table of Contents
- [Overview](#overview)
- [Prerequisites](#prerequisites)
- [Instructions](#instructions)
- [Troubleshooting](#troubleshooting)
@@ -35,8 +36,8 @@ This guide installs Spinifex on a single server using the binary installer. For
**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
@@ -58,23 +58,22 @@ 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
lb-alpine-3.21.6-x86_64 | alpine | 3.21.6 | x86_64 | bios
ubuntu-24.04-arm64 | ubuntu | 24.04 | arm64 | bios
ubuntu-24.04-x86_64 | ubuntu | 24.04 | x86_64 | bios
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:
@@ -173,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" \
+2 -2
View File
@@ -105,8 +105,8 @@ Spinifex nodes initiate a small, fixed set of outbound connections.
| Destination | Purpose | Protocol | Verification |
|-------------|---------|----------|--------------|
| `https://cdimage.debian.org/cdimage/cloud/bookworm/latest` | Debian 12 cloud image download | HTTPS | TLS + checksum verification |
| `https://cloud-images.ubuntu.com/noble/current` | Ubuntu 24.04 LTS cloud image download | HTTPS | TLS + checksum 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 |
@@ -56,7 +56,7 @@ 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))
- An Ubuntu 24.04 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
## Instructions
@@ -149,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 an Ubuntu 24.04 image:
Ensure you have imported an Ubuntu 26.04 image:
```bash
aws ec2 describe-images --owners 000000000000 --profile spinifex
+8 -14
View File
@@ -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` | Ubuntu 24.04 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,7 +66,7 @@ Deploy two Nginx web servers behind an internet-facing Application Load Balancer
**Prerequisites:**
- Spinifex installed and running (see [Installing Spinifex](/docs/install))
- An Ubuntu 24.04 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
## Instructions
@@ -86,21 +86,15 @@ 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
```
### Step 2.1 Install Debian AMI
Next, install the Debian 12 AMI which is used in the example to host the `nginx` webservers as an EC2 instance.
```bash
spx admin images import --name debian-12-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
@@ -184,7 +178,7 @@ tofu destroy
### AMI Not Found
Ensure you have imported an Ubuntu 24.04 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
@@ -193,7 +187,7 @@ aws ec2 describe-images --owners 000000000000 --profile spinifex
If missing import:
```bash
spx admin images import --name ubuntu-24.04-x86_64
spx admin images import --name ubuntu-26.04-x86_64
```
### Provider Connection Refused
@@ -43,7 +43,7 @@ Deploy a complete Nginx web server on Spinifex using Terraform/OpenTofu. This wo
**Prerequisites:**
- Spinifex installed and running (see [Installing Spinifex](/docs/install))
- An Ubuntu 24.04 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
## Instructions
@@ -96,7 +96,7 @@ tofu destroy
### AMI Not Found
Ensure you have imported an Ubuntu 24.04 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
@@ -220,7 +220,7 @@ resource "aws_security_group" "web" {
}
# ---------------------------------------------------------------------------
# EC2 Instance — Ubuntu 24.04 with Nginx installed via cloud-init
# EC2 Instance — Ubuntu 26.04 with Nginx installed via cloud-init
# ---------------------------------------------------------------------------
resource "aws_instance" "nginx" {
+2 -2
View File
@@ -53,7 +53,7 @@ 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)
- An Ubuntu 24.04 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
- The EC2 instance must be able to reach Predastore — use the host's br-wan IP, not localhost
@@ -153,7 +153,7 @@ cat /opt/webapp/.env
### AMI Not Found
Ensure you have imported an Ubuntu 24.04 image:
Ensure you have imported an Ubuntu 26.04 image:
```bash
aws ec2 describe-images --owners 000000000000 --profile spinifex
+19 -19
View File
@@ -1,6 +1,6 @@
module github.com/mulgadc/spinifex
go 1.26.2
go 1.26.3
require (
github.com/aws/aws-sdk-go v1.55.8
@@ -12,20 +12,19 @@ require (
github.com/insomniacslk/dhcp v0.0.0-20260407060928-11b94ed970f2
github.com/kdomanski/iso9660 v0.4.0
github.com/klauspost/cpuid/v2 v2.3.0
github.com/mdlayher/packet v1.1.2
github.com/mulgadc/predastore v1.2.0
github.com/mulgadc/viperblock v1.2.1
github.com/nats-io/nats-server/v2 v2.12.7
github.com/nats-io/nats.go v1.51.0
github.com/mulgadc/predastore v1.4.0
github.com/mulgadc/viperblock v1.4.0
github.com/nats-io/nats-server/v2 v2.14.0
github.com/nats-io/nats.go v1.52.0
github.com/ovn-kubernetes/libovsdb v0.8.1
github.com/pelletier/go-toml/v2 v2.3.0
github.com/pelletier/go-toml/v2 v2.3.1
github.com/pterm/pterm v0.12.83
github.com/spf13/cobra v1.10.2
github.com/spf13/viper v1.21.0
github.com/stretchr/testify v1.11.1
golang.org/x/crypto v0.50.0
golang.org/x/sys v0.43.0
gopkg.in/ini.v1 v1.67.1
go.uber.org/goleak v1.3.0
golang.org/x/crypto v0.51.0
gopkg.in/ini.v1 v1.67.2
)
require (
@@ -80,7 +79,7 @@ require (
github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/josharian/native v1.1.0 // indirect
github.com/klauspost/compress v1.18.5 // indirect
github.com/klauspost/reedsolomon v1.13.3 // indirect
github.com/klauspost/reedsolomon v1.14.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/lithammer/fuzzysearch v1.1.8 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
@@ -88,6 +87,7 @@ require (
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-localereader v0.0.1 // indirect
github.com/mattn/go-runewidth v0.0.21 // indirect
github.com/mdlayher/packet v1.1.2 // indirect
github.com/mdlayher/socket v0.4.1 // indirect
github.com/minio/crc64nvme v1.1.1 // indirect
github.com/minio/highwayhash v1.0.4 // indirect
@@ -104,7 +104,7 @@ require (
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.5 // indirect
github.com/prometheus/procfs v0.20.1 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect
github.com/quic-go/quic-go v0.59.1 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/sagikazarmark/locafero v0.12.0 // indirect
github.com/spf13/afero v1.15.0 // indirect
@@ -120,15 +120,15 @@ require (
go.opentelemetry.io/otel/trace v1.42.0 // indirect
go.yaml.in/yaml/v2 v2.4.4 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa // indirect
golang.org/x/mod v0.34.0 // indirect
golang.org/x/net v0.53.0 // indirect
golang.org/x/mod v0.35.0 // indirect
golang.org/x/net v0.54.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c // indirect
golang.org/x/term v0.42.0 // indirect
golang.org/x/text v0.36.0 // indirect
golang.org/x/sys v0.44.0 // indirect
golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa // indirect
golang.org/x/term v0.43.0 // indirect
golang.org/x/text v0.37.0 // indirect
golang.org/x/time v0.15.0 // indirect
golang.org/x/tools v0.43.0 // indirect
golang.org/x/tools v0.44.0 // indirect
golang.org/x/vuln v1.1.4 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
+58 -58
View File
@@ -31,30 +31,30 @@ github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aws/aws-sdk-go v1.55.8 h1:JRmEUbU52aJQZ2AjX4q4Wu7t4uZjOu71uyNmaWlUkJQ=
github.com/aws/aws-sdk-go v1.55.8/go.mod h1:ZkViS9AqA6otK+JBBNH2++sx1sgxrPKcSzPPvQkUtXk=
github.com/aws/aws-sdk-go-v2 v1.41.6 h1:1AX0AthnBQzMx1vbmir3Y4WsnJgiydmnJjiLu+LvXOg=
github.com/aws/aws-sdk-go-v2 v1.41.6/go.mod h1:dy0UzBIfwSeot4grGvY1AqFWN5zgziMmWGzysDnHFcQ=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.9 h1:adBsCIIpLbLmYnkQU+nAChU5yhVTvu5PerROm+/Kq2A=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.9/go.mod h1:uOYhgfgThm/ZyAuJGNQ5YgNyOlYfqnGpTHXvk3cpykg=
github.com/aws/aws-sdk-go-v2/credentials v1.19.15 h1:fyvgWTszojq8hEnMi8PPBTvZdTtEVmAVyo+NFLHBhH4=
github.com/aws/aws-sdk-go-v2/credentials v1.19.15/go.mod h1:gJiYyMOjNg8OEdRWOf3CrFQxM2a98qmrtjx1zuiQfB8=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.22 h1:GmLa5Kw1ESqtFpXsx5MmC84QWa/ZrLZvlJGa2y+4kcQ=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.22/go.mod h1:6sW9iWm9DK9YRpRGga/qzrzNLgKpT2cIxb7Vo2eNOp0=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22 h1:dY4kWZiSaXIzxnKlj17nHnBcXXBfac6UlsAx2qL6XrU=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22/go.mod h1:KIpEUx0JuRZLO7U6cbV204cWAEco2iC3l061IxlwLtI=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.23 h1:FPXsW9+gMuIeKmz7j6ENWcWtBGTe1kH8r9thNt5Uxx4=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.23/go.mod h1:7J8iGMdRKk6lw2C+cMIphgAnT8uTwBwNOsGkyOCm80U=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.8 h1:HtOTYcbVcGABLOVuPYaIihj6IlkqubBwFj10K5fxRek=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.8/go.mod h1:VsK9abqQeGlzPgUr+isNWzPlK2vKe9INMLWnY65f5Xs=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.14 h1:xnvDEnw+pnj5mctWiYuFbigrEzSm35x7k4KS/ZkCANg=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.14/go.mod h1:yS5rNogD8e0Wu9+l3MUwr6eENBzEeGejvINpN5PAYfY=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.22 h1:PUmZeJU6Y1Lbvt9WFuJ0ugUK2xn6hIWUBBbKuOWF30s=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.22/go.mod h1:nO6egFBoAaoXze24a2C0NjQCvdpk8OueRoYimvEB9jo=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.22 h1:SE+aQ4DEqG53RRCAIHlCf//B2ycxGH7jFkpnAh/kKPM=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.22/go.mod h1:ES3ynECd7fYeJIL6+oax+uIEljmfps0S70BaQzbMd/o=
github.com/aws/aws-sdk-go-v2/service/s3 v1.100.0 h1:7G26Sae6PMKn4kMcU5JzNfrm1YrKwyOhowXPYR2WiWY=
github.com/aws/aws-sdk-go-v2/service/s3 v1.100.0/go.mod h1:Fw9aqhJicIVee1VytBBjH+l+5ov6/PhbtIK/u3rt/ls=
github.com/aws/smithy-go v1.25.0 h1:Sz/XJ64rwuiKtB6j98nDIPyYrV1nVNJ4YU74gttcl5U=
github.com/aws/smithy-go v1.25.0/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8=
github.com/aws/aws-sdk-go-v2 v1.41.7/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 h1:gx1AwW1Iyk9Z9dD9F4akX5gnN3QZwUB20GGKH/I+Rho=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10/go.mod h1:qqY157uZoqm5OXq/amuaBJyC9hgBCBQnsaWnPe905GY=
github.com/aws/aws-sdk-go-v2/credentials v1.19.16 h1:r3RJBuU7X9ibt8RHbMjWE6y60QbKBiII6wSrXnapxSU=
github.com/aws/aws-sdk-go-v2/credentials v1.19.16/go.mod h1:6cx7zqDENJDbBIIWX6P8s0h6hqHC8Avbjh9Dseo27ug=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 h1:GpT/TrnBYuE5gan2cZbTtvP+JlHsutdmlV2YfEyNde0=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23/go.mod h1:xYWD6BS9ywC5bS3sz9Xh04whO/hzK2plt2Zkyrp4JuA=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 h1:bpd8vxhlQi2r1hiueOw02f/duEPTMK59Q4QMAoTTtTo=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23/go.mod h1:15DfR2nw+CRHIk0tqNyifu3G1YdAOy68RftkhMDDwYk=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 h1:OQqn11BtaYv1WLUowvcA30MpzIu8Ti4pcLPIIyoKZrA=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24/go.mod h1:X5ZJyfwVrWA96GzPmUCWFQaEARPR7gCrpq2E92PJwAE=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 h1:FLudkZLt5ci0ozzgkVo8BJGwvqNaZbTWb3UcucAateA=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9/go.mod h1:w7wZ/s9qK7c8g4al+UyoF1Sp/Z45UwMGcqIzLWVQHWk=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15 h1:ieLCO1JxUWuxTZ1cRd0GAaeX7O6cIxnwk7tc1LsQhC4=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15/go.mod h1:e3IzZvQ3kAWNykvE0Tr0RDZCMFInMvhku3qNpcIQXhM=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 h1:pbrxO/kuIwgEsOPLkaHu0O+m4fNgLU8B3vxQ+72jTPw=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23/go.mod h1:/CMNUqoj46HpS3MNRDEDIwcgEnrtZlKRaHNaHxIFpNA=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23 h1:03xatSQO4+AM1lTAbnRg5OK528EUg744nW7F73U8DKw=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23/go.mod h1:M8l3mwgx5ToK7wot2sBBce/ojzgnPzZXUV445gTSyE8=
github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0 h1:etqBTKY581iwLL/H/S2sVgk3C9lAsTJFeXWFDsDcWOU=
github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0/go.mod h1:L2dcoOgS2VSgbPLvpak2NyUPsO1TBN7M45Z4H7DlRc4=
github.com/aws/smithy-go v1.25.1 h1:J8ERsGSU7d+aCmdQur5Txg6bVoYelvQJgtZehD12GkI=
github.com/aws/smithy-go v1.25.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
@@ -236,8 +236,8 @@ github.com/klauspost/cpuid/v2 v2.0.10/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuOb
github.com/klauspost/cpuid/v2 v2.0.12/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/klauspost/reedsolomon v1.13.3 h1:01GwnO2xoCSaM0ShP4qwl+FsHg3csFShC6Tu/RS1ji0=
github.com/klauspost/reedsolomon v1.13.3/go.mod h1:yjqqjgMTQkBUHSG97/rm4zipffCNbCiZcB3kTqr++sQ=
github.com/klauspost/reedsolomon v1.14.0 h1:5YSZeclzSYg5nl349+GDG/agDtQ6MZiwUYXvVKN1Jx0=
github.com/klauspost/reedsolomon v1.14.0/go.mod h1:yjqqjgMTQkBUHSG97/rm4zipffCNbCiZcB3kTqr++sQ=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
@@ -286,20 +286,20 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/mulgadc/predastore v1.2.0 h1:E4+ehAm2YEi2n5t4DmqguURMUXwTSDrK7792zVzw7JM=
github.com/mulgadc/predastore v1.2.0/go.mod h1:+NWROaNCtpPE9f36fUxCHwskW8CuHHEIvjsDQrwOB00=
github.com/mulgadc/viperblock v1.2.1 h1:G3cSgh/yspZd7Wdg3C9QGqkqzYbEfYpusd6uMddSDmg=
github.com/mulgadc/viperblock v1.2.1/go.mod h1:CisHy13Uam0gUg5dxV0rKryJF2yGbgfDv09FYApRaNk=
github.com/mulgadc/predastore v1.4.0 h1:txPZCaVT1cDpBotx8zHPXHZD3Mz62+n207zXISkK4uU=
github.com/mulgadc/predastore v1.4.0/go.mod h1:fXIGz7yCKcHh68ybcw7aigdOdzIfyt19h/0diPF+JCU=
github.com/mulgadc/viperblock v1.4.0 h1:3Yr6lRDnFgxkdMOWu+Tq1b97qWHQDNwkPWmCp38CIy8=
github.com/mulgadc/viperblock v1.4.0/go.mod h1:hHdfo02iJgCdNktiPAq+x+VQE0ZNYOLJXw0abJ81LeE=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/nats-io/jwt/v2 v2.8.1 h1:V0xpGuD/N8Mi+fQNDynXohVvp7ZztevW5io8CUWlPmU=
github.com/nats-io/jwt/v2 v2.8.1/go.mod h1:nWnOEEiVMiKHQpnAy4eXlizVEtSfzacZ1Q43LIRavZg=
github.com/nats-io/nats-server/v2 v2.12.7 h1:prQ9cPiWHcnwfT81Wi5lU9LL8TLY+7pxDru6fQYLCQQ=
github.com/nats-io/nats-server/v2 v2.12.7/go.mod h1:dOnmkprKMluTmTF7/QHZioxlau3sKHUM/LBPy9AiBPw=
github.com/nats-io/nats.go v1.51.0 h1:ByW84XTz6W03GSSsygsZcA+xgKK8vPGaa/FCAAEHnAI=
github.com/nats-io/nats.go v1.51.0/go.mod h1:26HypzazeOkyO3/mqd1zZd53STJN0EjCYF9Uy2ZOBno=
github.com/nats-io/nats-server/v2 v2.14.0 h1:+8q0HrDFotwLLcGH/legOEOnowunhK+aZ4GYBIWpQlM=
github.com/nats-io/nats-server/v2 v2.14.0/go.mod h1:ImVUUDvfClJbb6cuJQRc1VmgDCXKM5ds0OoiG9MVOKo=
github.com/nats-io/nats.go v1.52.0 h1:n3avV4VBsCgsdwh71TppsTwtv+QdPs7ntSKM8qJLGsc=
github.com/nats-io/nats.go v1.52.0/go.mod h1:26HypzazeOkyO3/mqd1zZd53STJN0EjCYF9Uy2ZOBno=
github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4=
github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
@@ -308,8 +308,8 @@ github.com/ovn-kubernetes/libovsdb v0.8.1 h1:M2J8bcJt5mXCom0HqzfEtuHkT80CTSQRcYG
github.com/ovn-kubernetes/libovsdb v0.8.1/go.mod h1:ZlnHLzagmLOSvyd9qfxBIZp6wOSOw0IsRsc+6lNUGbU=
github.com/pascaldekloe/goe v0.1.0 h1:cBOtyMzM9HTpWjXfbbunk26uA6nG3a8n06Wieeh0MwY=
github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
github.com/pelletier/go-toml/v2 v2.3.0 h1:k59bC/lIZREW0/iVaQR8nDHxVq8OVlIzYCOJf421CaM=
github.com/pelletier/go-toml/v2 v2.3.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc=
github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pierrec/lz4/v4 v4.1.14 h1:+fL8AQEZtz/ijeNnpduH0bROTu0O3NZAlPjQxGn8LwE=
github.com/pierrec/lz4/v4 v4.1.14/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
@@ -351,8 +351,8 @@ github.com/pterm/pterm v0.12.36/go.mod h1:NjiL09hFhT/vWjQHSj1athJpx6H8cjpHXNAK5b
github.com/pterm/pterm v0.12.40/go.mod h1:ffwPLwlbXxP+rxT0GsgDTzS3y3rmpAO1NMjUkGTYf8s=
github.com/pterm/pterm v0.12.83 h1:ie+YmGmA727VuhxBlyGr74Ks+7McV6kT99IB8EU80aA=
github.com/pterm/pterm v0.12.83/go.mod h1:xlgc6bFWyJIMtmLJvGim+L7jhSReilOlOnodeIYe4Tk=
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic=
github.com/quic-go/quic-go v0.59.1/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
@@ -424,14 +424,14 @@ golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnf
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa h1:FRnLl4eNAQl8hwxVVC17teOw8kdjVDVAiFMtgUdTSRQ=
golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa/go.mod h1:zk2irFbV9DP96SEBUUAy67IdHUaZuSnrz1n472HUCLE=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -442,8 +442,8 @@ golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81R
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -484,33 +484,33 @@ golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c h1:6a8FdnNk6bTXBjR4AGKFgUKuo+7GnR3FX5L7CbveeZc=
golang.org/x/telemetry v0.0.0-20260311193753-579e4da9a98c/go.mod h1:TpUTTEp9frx7rTdLpC9gFG9kdI7zVLFTFFlqaH2Cncw=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa h1:efT73AJZfAAUV7SOip6pWGkwJDzIGiKBZGVzHYa+ve4=
golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa/go.mod h1:kHjTxDEnAu6/Nl9lDkzjWpR+bmKfxeiRuSDlsMb70gE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM=
golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY=
golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM=
@@ -534,8 +534,8 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/ini.v1 v1.67.1 h1:tVBILHy0R6e4wkYOn3XmiITt/hEVH4TFMYvAX2Ytz6k=
gopkg.in/ini.v1 v1.67.1/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss=
gopkg.in/ini.v1 v1.67.2 h1:JtOSMb9OuaCZKr7h5D/h6iii14sK0hLbplTc6frx4Ss=
gopkg.in/ini.v1 v1.67.2/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
@@ -548,5 +548,5 @@ gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk=
pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04=
pgregory.net/rapid v1.3.0 h1:vBvO0VSqti75J1jjYqpgPNBLKMd1+gxa9fYo7vk/Exc=
pgregory.net/rapid v1.3.0/go.mod h1:dPlE4OBBxgXPqkP79flB6sJL1dx5azpI7HQ9MY9Z7uk=
+26
View File
@@ -0,0 +1,26 @@
// Package fipsboot enforces FIPS 140-3 mode at process startup.
//
// Blank-import this package from every binary's main:
//
// import _ "github.com/mulgadc/spinifex/internal/fipsboot"
//
// Builds with GOFIPS140=v1.0.0 link the Go Cryptographic Module v1.0.0
// (NIST CMVP cert #5247). At runtime FIPS mode can still be turned off
// via GODEBUG=fips140=off; init() panics in that case to close the hole.
package fipsboot
import (
"crypto/fips140"
"log/slog"
)
func init() {
if !fips140.Enabled() {
panic("fipsboot: FIPS 140-3 mode is not enabled at runtime — refusing to start. " +
"Build with GOFIPS140=v1.0.0 and do not set GODEBUG=fips140=off.")
}
slog.Info("FIPS 140-3 mode enabled",
"module", "Go Cryptographic Module v1.0.0",
"cmvp_cert", "5247",
)
}
+10 -1
View File
@@ -31,6 +31,7 @@ inventory and roles relative to CWD.
| `playbooks/dev-teardown.yml` | Wipe state only (no reinstall) | `reset-dev-env.sh` §teardown |
| `playbooks/dev-install.yml` | Build + install + init + smoketest on clean box | `dev-install.sh` |
| `playbooks/dev-reset.yml` | Capture settings → teardown → build → install → init → smoketest | `reset-dev-env.sh` (full) |
| `playbooks/dev-deploy.yml` | Rebuild + swap binaries/microvm artifacts + restart (no setup.sh, no smoketest) | `make deploy` |
Upcoming (not yet implemented):
@@ -44,6 +45,7 @@ ansible-playbook playbooks/dev-preflight.yml
ansible-playbook playbooks/dev-teardown.yml
ansible-playbook playbooks/dev-install.yml
ansible-playbook playbooks/dev-reset.yml
ansible-playbook playbooks/dev-deploy.yml
```
Or via `make` (from `spinifex/`):
@@ -53,8 +55,16 @@ make ansible-dev-preflight
make ansible-dev-teardown
make ansible-dev-install
make ansible-dev-reset
make ansible-dev-deploy
```
### When to use which
- First-time / clean box → `ansible-dev-install`
- Iterate on Go code, microvm initramfs, lb-agent → `ansible-dev-deploy` (fast)
- Changed systemd units, helper scripts, logrotate, setup.sh → `ansible-dev-reset` (slow, full rebuild)
- Need a clean slate without reinstall → `ansible-dev-teardown`
## Variable overrides
Defaults in `vars/defaults.yml`. Override per run with `-e`:
@@ -74,7 +84,6 @@ Useful overrides:
| `spinifex_region` | `ap-southeast-2` | Region passed to `spx admin init` |
| `spinifex_az` | `{{ region }}a` | AZ passed to `spx admin init` |
| `spinifex_external_mode` | `pool` | `pool` \| `nat` external networking |
| `spinifex_build_lb_image` | `true` | Build + import LB system image after init |
`dev-reset.yml` automatically captures `region`, `az`, `external_mode`,
pool range, gateway, prefix-len, nat gateway_ip from the existing
+14
View File
@@ -0,0 +1,14 @@
---
# In-place artifact swap: rebuilds + replaces binaries/microvm artifacts on
# an already-installed node, then restarts spinifex.target. Use when iterating
# on code without altering systemd units, users, dirs, or running the full
# smoketest. For first-time or unit/script changes use dev-install / dev-reset.
- name: Dev deploy — build + swap binaries + restart (no setup.sh, no smoketest)
hosts: localhost
gather_facts: true
vars_files:
- ../vars/defaults.yml
roles:
- build
- deploy
+19 -1
View File
@@ -11,6 +11,18 @@
chdir: "{{ mulga_root }}/viperblock"
changed_when: true
- name: build — detect microVM build script (present only on direct-boot branches)
ansible.builtin.stat:
path: "{{ spinifex_root }}/scripts/build-microvm-image.sh"
register: build_microvm_script
- name: build — microVM kernel + initramfs (direct-boot LBs)
ansible.builtin.command:
cmd: make build-microvm-image
chdir: "{{ spinifex_root }}"
changed_when: true
when: build_microvm_script.stat.exists
- name: build — verify build artifacts present
ansible.builtin.stat:
path: "{{ item }}"
@@ -40,9 +52,15 @@
mkdir -p "$STAGING/scripts"
cp "{{ spinifex_root }}/build/scripts/"* "$STAGING/scripts/"
cp "{{ spinifex_root }}/build/logrotate/spinifex" "$STAGING/logrotate-spinifex"
# microVM artifacts ship only when the branch built them.
if [ -f "{{ spinifex_root }}/build/microvm/vmlinuz" ] && [ -f "{{ spinifex_root }}/build/microvm/initramfs.cpio.gz" ]; then
mkdir -p "$STAGING/microvm"
cp "{{ spinifex_root }}/build/microvm/vmlinuz" "$STAGING/microvm/"
cp "{{ spinifex_root }}/build/microvm/initramfs.cpio.gz" "$STAGING/microvm/"
fi
tar czf "{{ build_tarball_path }}" -C "$STAGING" .
changed_when: true
- name: build — summary
ansible.builtin.debug:
msg: "Built spx + nbdkit plugin, tarball at {{ build_tarball_path }}"
msg: "Built spx + nbdkit plugin{{ ' + microvm artifacts' if build_microvm_script.stat.exists else '' }}, tarball at {{ build_tarball_path }}"
+148
View File
@@ -0,0 +1,148 @@
---
# In-place artifact swap for an already-installed spinifex node. Mirrors
# `make deploy`: replace binaries + microvm artifacts, restart services.
# Does NOT run setup.sh (no user/dir/systemd-unit changes) or smoketest.
- name: deploy — fail fast if spinifex isn't already installed
ansible.builtin.stat:
path: "{{ spinifex_config_file }}"
register: deploy_installed_stat
failed_when: not deploy_installed_stat.stat.exists
- name: deploy — assert config present
ansible.builtin.assert:
that: deploy_installed_stat.stat.exists
fail_msg: |
{{ spinifex_config_file }} missing — this playbook only updates an
existing install. Run ansible-dev-install or ansible-dev-reset first.
- name: deploy — verify build artifacts present
ansible.builtin.stat:
path: "{{ item }}"
register: deploy_artifacts
loop:
- "{{ spinifex_root }}/bin/spx"
- "{{ mulga_root }}/viperblock/lib/nbdkit-viperblock-plugin.so"
- name: deploy — assert artifacts exist after build role
ansible.builtin.assert:
that: item.stat.exists
fail_msg: "Build artifact missing: {{ item.item }} — build role should have produced it"
loop: "{{ deploy_artifacts.results }}"
loop_control:
label: "{{ item.item }}"
- name: deploy — detect microvm artifacts (direct-boot branches only)
ansible.builtin.stat:
path: "{{ spinifex_root }}/build/microvm/{{ item }}"
register: deploy_microvm_stat
loop:
- vmlinuz
- initramfs.cpio.gz
- name: deploy — set microvm presence fact
ansible.builtin.set_fact:
deploy_microvm_present: "{{ deploy_microvm_stat.results | map(attribute='stat.exists') | min }}"
- name: deploy — detect nbdkit plugin directory
ansible.builtin.shell: |
set -e
nbdkit --dump-config | awk -F= '/^plugindir=/{print $2; exit}'
register: deploy_plugindir_cmd
changed_when: false
- name: deploy — set plugin path fact
ansible.builtin.set_fact:
deploy_plugindir: "{{ deploy_plugindir_cmd.stdout | trim }}"
- name: deploy — assert plugindir resolved
ansible.builtin.assert:
that: deploy_plugindir | length > 0
fail_msg: "Could not detect nbdkit plugin directory via `nbdkit --dump-config`"
- name: deploy — stop spinifex.target before binary swap
become: true
ansible.builtin.systemd:
name: spinifex.target
state: stopped
failed_when: false
- name: deploy — install spx binary
become: true
ansible.builtin.copy:
src: "{{ spinifex_root }}/bin/spx"
dest: /usr/local/bin/spx
mode: "0755"
owner: root
group: root
- name: deploy — install viperblock nbdkit plugin
become: true
ansible.builtin.copy:
src: "{{ mulga_root }}/viperblock/lib/nbdkit-viperblock-plugin.so"
dest: "{{ deploy_plugindir }}/nbdkit-viperblock-plugin.so"
mode: "0755"
owner: root
group: root
- name: deploy — ensure microvm dir
become: true
ansible.builtin.file:
path: /usr/share/spinifex/microvm
state: directory
mode: "0755"
owner: root
group: root
when: deploy_microvm_present
- name: deploy — install microvm kernel
become: true
ansible.builtin.copy:
src: "{{ spinifex_root }}/build/microvm/vmlinuz"
dest: /usr/share/spinifex/microvm/vmlinuz
mode: "0644"
owner: root
group: root
when: deploy_microvm_present
- name: deploy — install microvm initramfs
become: true
ansible.builtin.copy:
src: "{{ spinifex_root }}/build/microvm/initramfs.cpio.gz"
dest: /usr/share/spinifex/microvm/initramfs.cpio.gz
mode: "0644"
owner: root
group: root
when: deploy_microvm_present
- name: deploy — daemon-reload (covers any unit changes shipped via setup.sh later)
become: true
ansible.builtin.systemd:
daemon_reload: true
- name: deploy — start spinifex.target
become: true
ansible.builtin.systemd:
name: spinifex.target
state: started
- name: deploy — wait for awsgw to respond
become: true
become_user: "{{ spinifex_invoking_user }}"
environment:
AWS_PROFILE: spinifex
ansible.builtin.command: aws ec2 describe-regions
register: deploy_awsgw
retries: 15
delay: 2
until: deploy_awsgw.rc == 0
changed_when: false
- name: deploy — summary
ansible.builtin.debug:
msg: |
Deployed:
spx → /usr/local/bin/spx
nbdkit plugin → {{ deploy_plugindir }}/nbdkit-viperblock-plugin.so
microvm → {{ '/usr/share/spinifex/microvm/{vmlinuz,initramfs.cpio.gz}' if deploy_microvm_present else 'skipped (not built on this branch)' }}
spinifex.target restarted; awsgw responding.
-14
View File
@@ -74,19 +74,6 @@
delay: 2
changed_when: false
- name: init — build LB agent
ansible.builtin.command:
cmd: make build-lb-agent
chdir: "{{ spinifex_root }}"
changed_when: true
when: spinifex_build_lb_image | bool
- name: init — import LB system image
ansible.builtin.command:
cmd: "{{ spinifex_root }}/scripts/build-system-image.sh {{ spinifex_lb_image_conf }} --import --quiet"
changed_when: true
when: spinifex_build_lb_image | bool
- name: init — summary
ansible.builtin.debug:
msg: |
@@ -95,4 +82,3 @@
external_mode={{ spinifex_external_mode }}
spinifex.target: started
CA: {{ spinifex_ca_trust_path }}
LB image: {{ 'imported' if spinifex_build_lb_image else 'skipped' }}
+17 -21
View File
@@ -32,34 +32,34 @@ spinifex_nat_gateway_ip: ""
# Preflight checks.
spinifex_ports:
- { port: 4222, service: NATS }
- { port: 8443, service: Predastore }
- { port: 9999, service: "Spinifex Gateway" }
- { port: 4222, service: NATS }
- { port: 8443, service: Predastore }
- { port: 9999, service: "Spinifex Gateway" }
spinifex_required_commands:
- go
- make
- nbdkit
- go
- make
- nbdkit
spinifex_optional_commands:
- air
- air
spinifex_ovn_commands:
- ovs-vsctl
- ovn-controller
- ovn-nbctl
- ovs-vsctl
- ovn-controller
- ovn-nbctl
spinifex_components:
- viperblock
- predastore
- viperblock
- predastore
# Process patterns for teardown.
# Bracket trick ([b]in/spx etc.) prevents the pattern literal from matching
# our own pgrep/shell cmdline. Exclude filter drops benign log followers and
# ansible's own wrappers that also mention these service names.
spinifex_process_pattern: '([b]in/spx|spinifex-[u]i|nats-[s]erver|[p]redastore|[v]iperblock|[v]pcd)'
spinifex_process_exclude: '(journalctl|ansible|/bin/sh|pgrep|pkill|tail -[fF])'
spinifex_process_pattern: "([b]in/spx|spinifex-[u]i|nats-[s]erver|[p]redastore|[v]iperblock|[v]pcd)"
spinifex_process_exclude: "(journalctl|ansible|/bin/sh|pgrep|pkill|tail -[fF])"
spinifex_qemu_processes:
- qemu-system-x86_64
- qemu-system-aarch64
- qemu-system-x86_64
- qemu-system-aarch64
# Invoking user (when run under sudo).
spinifex_invoking_user: "{{ lookup('env','SUDO_USER') | default(ansible_facts['user_id'], true) }}"
@@ -79,12 +79,8 @@ mulga_root: "{{ lookup('pipe', 'readlink -f ' ~ playbook_dir ~ '/../../../..') }
# Build output.
build_tarball_path: /tmp/spinifex-local.tar.gz
# LB system image (built + imported at end of init).
spinifex_build_lb_image: true
spinifex_lb_image_conf: "{{ spinifex_root }}/scripts/images/lb.conf"
# Smoketest image — parameterised so tofu examples can share the same base.
spinifex_smoketest_distro: debian
spinifex_smoketest_version: "12"
spinifex_smoketest_version: "13"
# Local image file path (if present, imported directly; otherwise fall back to --name).
spinifex_smoketest_local_image: "~/images/{{ spinifex_smoketest_distro }}-{{ spinifex_smoketest_version }}.img"
+261
View File
@@ -0,0 +1,261 @@
#!/bin/bash
# build-microvm-image.sh — build a minimal Alpine-based microVM kernel + initramfs
# for use with QEMU microvm machine type and Spinifex lb-agent.
#
# Outputs:
# $REPO_ROOT/build/microvm/vmlinuz
# $REPO_ROOT/build/microvm/initramfs.cpio.gz
#
# Rootfs strategy (first match wins):
# 1. apk available on host → install directly into a temp chroot (Alpine CI/CD)
# 2. docker available → run alpine container, export filesystem
# 3. podman available → same via podman
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
BUILD_DIR="$REPO_ROOT/build/microvm"
INIT_SH="$BUILD_DIR/init.sh"
INITTAB="$BUILD_DIR/inittab"
ALPINE_VERSION="${ALPINE_VERSION:-3.20}"
ALPINE_MIRROR="${ALPINE_MIRROR:-https://dl-cdn.alpinelinux.org/alpine}"
ALPINE_PACKAGES="busybox linux-virt haproxy iproute2 ca-certificates"
echo "[build-microvm-image] repo root: $REPO_ROOT"
echo "[build-microvm-image] build dir: $BUILD_DIR"
mkdir -p "$BUILD_DIR"
# --- Verify non-negotiable tools ---
for tool in cpio gzip find; do
if ! command -v "$tool" >/dev/null 2>&1; then
echo "ERROR: required tool not found: $tool" >&2
exit 1
fi
done
# --- Create chroot ---
CHROOT_DIR=$(mktemp -d)
cleanup() {
echo "[build-microvm-image] cleaning up chroot: $CHROOT_DIR"
rm -rf "$CHROOT_DIR"
}
trap cleanup EXIT
# --- Populate rootfs ---
# Strategy 1: native apk (Alpine hosts, CI)
if command -v apk >/dev/null 2>&1; then
echo "[build-microvm-image] rootfs: native apk"
mkdir -p \
"$CHROOT_DIR/etc/apk" \
"$CHROOT_DIR/lib/apk/db" \
"$CHROOT_DIR/var/cache/apk"
cat > "$CHROOT_DIR/etc/apk/repositories" <<EOF
${ALPINE_MIRROR}/v${ALPINE_VERSION}/main
${ALPINE_MIRROR}/v${ALPINE_VERSION}/community
EOF
apk add \
--root "$CHROOT_DIR" \
--arch x86_64 \
--no-cache \
--allow-untrusted \
--initdb \
$ALPINE_PACKAGES
# Strategy 2/3: container runtime (dev machines)
else
CONTAINER_TOOL=""
if command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then
CONTAINER_TOOL=docker
elif command -v podman >/dev/null 2>&1; then
CONTAINER_TOOL=podman
else
echo "ERROR: no rootfs build tool available." >&2
echo " Install one of: apk (Alpine), docker, podman" >&2
exit 1
fi
echo "[build-microvm-image] rootfs: $CONTAINER_TOOL export (alpine:${ALPINE_VERSION})"
cid=$($CONTAINER_TOOL run -d \
"alpine:${ALPINE_VERSION}" \
sh -c "apk add --no-cache ${ALPINE_PACKAGES}")
exit_code=$($CONTAINER_TOOL wait "$cid")
if [ "$exit_code" != "0" ]; then
echo "ERROR: package installation failed in container (exit $exit_code)" >&2
$CONTAINER_TOOL rm "$cid" >/dev/null 2>&1 || true
exit 1
fi
$CONTAINER_TOOL export "$cid" | tar -x -C "$CHROOT_DIR"
$CONTAINER_TOOL rm "$cid" >/dev/null 2>&1
fi
# --- Fix /dev device nodes ---
# tar extraction without root silently creates 0-byte regular files for device
# nodes (mknod requires CAP_MKNOD). The kernel opens /dev/console to wire
# init's stdio to the serial console; a regular file there causes all init
# output to be silently discarded. Recreate the two nodes needed before
# devtmpfs mounts (which provides the rest at runtime).
rm -rf "$CHROOT_DIR/dev"
mkdir "$CHROOT_DIR/dev"
sudo mknod -m 600 "$CHROOT_DIR/dev/console" c 5 1
sudo mknod -m 666 "$CHROOT_DIR/dev/null" c 1 3
# --- Place init script ---
echo "[build-microvm-image] installing init.sh as /init..."
if [ ! -f "$INIT_SH" ]; then
echo "ERROR: $INIT_SH not found — cannot build initramfs" >&2
exit 1
fi
install -m 0755 "$INIT_SH" "$CHROOT_DIR/init"
# --- Place inittab ---
if [ -f "$INITTAB" ]; then
echo "[build-microvm-image] installing inittab..."
install -m 0644 "$INITTAB" "$CHROOT_DIR/etc/inittab"
fi
# --- Copy lb-agent binary ---
LB_AGENT_BIN="$REPO_ROOT/bin/lb-agent"
if [ -f "$LB_AGENT_BIN" ]; then
echo "[build-microvm-image] copying lb-agent binary..."
mkdir -p "$CHROOT_DIR/usr/local/bin"
install -m 0755 "$LB_AGENT_BIN" "$CHROOT_DIR/usr/local/bin/lb-agent"
else
echo "[build-microvm-image] WARNING: $LB_AGENT_BIN not found — lb-agent will be absent from initramfs" >&2
fi
# --- Create required directories for init ---
mkdir -p \
"$CHROOT_DIR/proc" \
"$CHROOT_DIR/sys" \
"$CHROOT_DIR/dev" \
"$CHROOT_DIR/etc/ssl/certs" \
"$CHROOT_DIR/etc/conf.d"
# --- Locate kernel ---
KERNEL_IMG=$(find "$CHROOT_DIR/boot" -name "vmlinuz*" | sort | tail -1)
if [ -z "$KERNEL_IMG" ]; then
echo "ERROR: no vmlinuz found in $CHROOT_DIR/boot" >&2
exit 1
fi
echo "[build-microvm-image] kernel image: $KERNEL_IMG"
KERNEL_VER=$(basename "$KERNEL_IMG" | sed 's/vmlinuz-//')
KERNEL_CONFIG="$CHROOT_DIR/boot/config-${KERNEL_VER}"
# --- Assert fw_cfg module ---
if ! find "$CHROOT_DIR/lib/modules" \
-name "qemu_fw_cfg.ko*" \
-o -name "fw_cfg_sysfs.ko*" \
| grep -q .; then
if ! grep -q "CONFIG_FW_CFG_SYSFS=y" "$KERNEL_CONFIG" 2>/dev/null; then
echo "ERROR: qemu_fw_cfg module missing from initramfs — fw_cfg reads will fail at boot" >&2
exit 1
fi
echo "[build-microvm-image] fw_cfg: built-in (CONFIG_FW_CFG_SYSFS=y)"
else
echo "[build-microvm-image] fw_cfg: module found in lib/modules"
fi
# --- Strip kernel modules (Phase 2 keep-list approach) ---
# Only modules needed by a virtio-mmio microvm guest are kept:
# virtio*.ko* — virtio bus, net, blk, rng, console, and helpers
# *fw_cfg*.ko* — QEMU fw_cfg sysfs driver (delivers boot blobs)
# *failover*.ko* — net_failover + failover (virtio_net deps for live migration)
# All other loadable modules are removed. Drivers compiled into the kernel
# (CONFIG_VIRTIO_MMIO=y etc.) are unaffected — they live in vmlinuz, not here.
echo "[build-microvm-image] stripping kernel modules to virtio+fw_cfg only..."
for kver_dir in "$CHROOT_DIR/lib/modules"/*/; do
kver="$(basename "$kver_dir")"
kernel_dir="$kver_dir/kernel"
[ -d "$kernel_dir" ] || continue
# Collect keeper modules and their relative paths from the kernel/ tree.
tmp_keep=$(mktemp -d)
find "$kernel_dir" -name "virtio*.ko.gz" -o -name "virtio*.ko" \
-o -name "*fw_cfg*.ko.gz" -o -name "*fw_cfg*.ko" \
-o -name "*failover*.ko.gz" -o -name "*failover*.ko" \
2>/dev/null | while read -r mod; do
rel="${mod#"$kernel_dir/"}"
dest_dir="$tmp_keep/$(dirname "$rel")"
mkdir -p "$dest_dir"
cp "$mod" "$dest_dir/"
done
# Replace kernel module tree with the keeper set.
rm -rf "$kernel_dir"
mkdir -p "$kernel_dir"
# Restore each kept file into the same relative path.
(cd "$tmp_keep" && find . -name "*.ko*" | while read -r f; do
dest_dir="$kernel_dir/$(dirname "${f#./}")"
mkdir -p "$dest_dir"
cp "$f" "$dest_dir/"
done)
rm -rf "$tmp_keep"
# Regenerate module dependency map from the surviving modules.
depmod -b "$CHROOT_DIR" "$kver" 2>/dev/null || true
done
# --- Strip package-manager artifacts (not needed at runtime) ---
echo "[build-microvm-image] stripping package manager artifacts..."
rm -rf \
"$CHROOT_DIR/lib/apk" \
"$CHROOT_DIR/var/cache/apk" \
"$CHROOT_DIR/etc/apk" \
"$CHROOT_DIR/usr/share/man" \
"$CHROOT_DIR/usr/share/doc" \
"$CHROOT_DIR/usr/share/apk" \
"$CHROOT_DIR/usr/include" \
"$CHROOT_DIR/usr/lib/pkgconfig"
# --- Strip debug symbols from binaries ---
echo "[build-microvm-image] stripping debug symbols from binaries..."
find "$CHROOT_DIR/usr/sbin" "$CHROOT_DIR/usr/bin" \
"$CHROOT_DIR/sbin" "$CHROOT_DIR/bin" \
"$CHROOT_DIR/usr/local/bin" \
-type f 2>/dev/null | while read -r bin; do
strip --strip-all "$bin" 2>/dev/null || true
done
# --- Copy vmlinuz out before stripping /boot from chroot ---
VMLINUZ_OUT="$BUILD_DIR/vmlinuz"
echo "[build-microvm-image] copying kernel: $VMLINUZ_OUT"
cp "$KERNEL_IMG" "$VMLINUZ_OUT"
# Drop /boot from the chroot — vmlinuz, Alpine's stock initramfs-virt, System.map
# and kernel config all live here (~25 MiB). None are needed at runtime: the
# guest already has the kernel loaded by QEMU's -kernel, and Alpine's initramfs
# is unused because we replace it with our own /init. Leaving /boot in the cpio
# nearly doubles uncompressed initramfs size and forces a 512 MiB guest just to
# survive decompression.
rm -rf "$CHROOT_DIR/boot"
# --- Build initramfs ---
INITRAMFS_OUT="$BUILD_DIR/initramfs.cpio.gz"
echo "[build-microvm-image] building initramfs: $INITRAMFS_OUT"
(
cd "$CHROOT_DIR"
find . | cpio --quiet -o -H newc | gzip -9 > "$INITRAMFS_OUT"
)
# --- Log artifact sizes ---
echo ""
echo "[build-microvm-image] artifacts:"
ls -lh "$VMLINUZ_OUT" "$INITRAMFS_OUT"
echo ""
# --- Phase 2 artifact size gate (50 MiB total) ---
ARTIFACT_MAX_MiB="${MICROVM_ARTIFACT_MAX_MiB:-50}"
total_bytes=$(( $(stat -c %s "$VMLINUZ_OUT") + $(stat -c %s "$INITRAMFS_OUT") ))
max_bytes=$(( ARTIFACT_MAX_MiB * 1024 * 1024 ))
total_mib=$(( (total_bytes + 1048575) / 1048576 ))
if [ "$total_bytes" -gt "$max_bytes" ]; then
echo "ERROR: artifact size ${total_mib} MiB exceeds ${ARTIFACT_MAX_MiB} MiB gate" >&2
echo " Set MICROVM_ARTIFACT_MAX_MiB=N to override during development." >&2
exit 1
fi
echo "[build-microvm-image] artifact size gate: PASS (${total_mib} MiB / ${ARTIFACT_MAX_MiB} MiB)"
echo ""
echo "[build-microvm-image] done."
+153 -48
View File
@@ -1,10 +1,11 @@
#!/bin/bash
set -euo pipefail
# build-system-image.sh — Build a minimal Alpine AMI from a manifest
# build-system-image.sh — Build a minimal system AMI from a manifest
#
# Creates a pre-baked Alpine Linux image with custom packages, binaries,
# and setup scripts installed, ready for import as a Spinifex AMI.
# Supports Alpine Linux and Ubuntu 24.04 as base distros.
# Creates a pre-baked image with custom packages, binaries, and setup scripts
# installed, ready for import as a Spinifex AMI.
#
# Requirements: qemu-nbd, qemu-img, sudo (for mount/chroot), curl
# Usage: ./scripts/build-system-image.sh <manifest.conf> [--import]
@@ -46,8 +47,11 @@ done
# shellcheck source=/dev/null
source "$MANIFEST"
# Default distro is alpine for backward compatibility
DISTRO="${DISTRO:-alpine}"
# Validate required manifest fields
for field in IMAGE_NAME ALPINE_VERSION IMAGE_SIZE; do
for field in IMAGE_NAME IMAGE_SIZE; do
if [[ -z "${!field:-}" ]]; then
echo "ERROR: Manifest missing required field: $field"
exit 1
@@ -56,16 +60,48 @@ done
# Derived paths
BUILD_DIR="/tmp/${IMAGE_NAME}-image-build"
ALPINE_IMAGE="generic_alpine-${ALPINE_VERSION}-x86_64-bios-cloudinit-r0.qcow2"
ALPINE_URL="https://dl-cdn.alpinelinux.org/alpine/v${ALPINE_VERSION%.*}/releases/cloud/${ALPINE_IMAGE}"
OUTPUT_IMAGE="${BUILD_DIR}/${IMAGE_NAME}-alpine.qcow2"
OUTPUT_RAW="${BUILD_DIR}/${IMAGE_NAME}-alpine.raw"
NBD_DEV="/dev/nbd0"
MOUNT_DIR="${BUILD_DIR}/mnt"
case "$DISTRO" in
alpine)
if [[ -z "${ALPINE_VERSION:-}" ]]; then
echo "ERROR: Manifest missing required field: ALPINE_VERSION"
exit 1
fi
SOURCE_IMAGE="generic_alpine-${ALPINE_VERSION}-x86_64-bios-cloudinit-r0.qcow2"
SOURCE_URL="https://dl-cdn.alpinelinux.org/alpine/v${ALPINE_VERSION%.*}/releases/cloud/${SOURCE_IMAGE}"
OUTPUT_IMAGE="${BUILD_DIR}/${IMAGE_NAME}-alpine.qcow2"
OUTPUT_RAW="${BUILD_DIR}/${IMAGE_NAME}-alpine.raw"
DISTRO_VERSION="${ALPINE_VERSION}"
ROOT_PART="${NBD_DEV}"
;;
ubuntu)
if [[ -z "${UBUNTU_VERSION:-}" ]]; then
echo "ERROR: Manifest missing required field: UBUNTU_VERSION"
exit 1
fi
SOURCE_IMAGE="ubuntu-${UBUNTU_VERSION}-minimal-cloudimg-amd64.img"
SOURCE_URL="https://cloud-images.ubuntu.com/minimal/releases/${UBUNTU_CODENAME:-noble}/release/${SOURCE_IMAGE}"
OUTPUT_IMAGE="${BUILD_DIR}/${IMAGE_NAME}-ubuntu.qcow2"
OUTPUT_RAW="${BUILD_DIR}/${IMAGE_NAME}-ubuntu.raw"
DISTRO_VERSION="${UBUNTU_VERSION}"
ROOT_PART="${NBD_DEV}p1"
;;
*)
echo "ERROR: Unknown DISTRO: $DISTRO (supported: alpine, ubuntu)"
exit 1
;;
esac
cleanup() {
echo "Cleaning up..."
sudo umount "${MOUNT_DIR}" 2>/dev/null || true
# Ubuntu chroot has bind mounts that must be released first
sudo umount "${MOUNT_DIR}/dev/pts" 2>/dev/null || true
sudo umount "${MOUNT_DIR}/dev" 2>/dev/null || true
sudo umount "${MOUNT_DIR}/sys" 2>/dev/null || true
sudo umount "${MOUNT_DIR}/proc" 2>/dev/null || true
sudo umount "${MOUNT_DIR}" 2>/dev/null || true
sudo qemu-nbd --disconnect "${NBD_DEV}" 2>/dev/null || true
exec 9>&- 2>/dev/null || true # release nbd lock if held
echo "Done."
@@ -80,7 +116,7 @@ fi
echo "=== System Image Builder ==="
echo "Image: ${IMAGE_NAME} — ${IMAGE_DESCRIPTION:-}"
echo "Alpine: ${ALPINE_VERSION}"
echo "Distro: ${DISTRO} ${DISTRO_VERSION}"
echo "Size: ${IMAGE_SIZE}"
echo "Build: ${BUILD_DIR}"
echo ""
@@ -96,6 +132,11 @@ if ! command -v qemu-img &>/dev/null; then
exit 1
fi
if [[ "$DISTRO" == "ubuntu" ]] && ! command -v parted &>/dev/null; then
echo "ERROR: parted not found. Install parted."
exit 1
fi
# Build binaries if BUILD_COMMANDS is set
if [[ -n "${BUILD_COMMANDS:-}" ]]; then
echo "Building binaries: ${BUILD_COMMANDS}"
@@ -105,7 +146,7 @@ if [[ -n "${BUILD_COMMANDS:-}" ]]; then
fi
fi
# Verify binaries exist and are statically linked
# Verify binaries exist; Alpine also requires static linking (uses musl)
if [[ -n "${INSTALL_BINARIES:-}" ]]; then
IFS=' ' read -ra BINARY_PAIRS <<< "$INSTALL_BINARIES"
for pair in "${BINARY_PAIRS[@]}"; do
@@ -115,7 +156,7 @@ if [[ -n "${INSTALL_BINARIES:-}" ]]; then
echo "ERROR: Binary not found: $src_path"
exit 1
fi
if ! file "$src_path" | grep -q "statically linked"; then
if [[ "$DISTRO" == "alpine" ]] && ! file "$src_path" | grep -q "statically linked"; then
echo "ERROR: $src_path is not statically linked (Alpine uses musl — dynamic glibc binaries will fail)"
echo " Rebuild with: CGO_ENABLED=0 go build ..."
exit 1
@@ -147,31 +188,32 @@ if [[ -f "$OUTPUT_RAW" ]] && [[ $(( $(date +%s) - $(stat -c %Y "$OUTPUT_RAW") ))
if [[ "$DO_IMPORT" == true ]]; then
echo "Importing as AMI..."
IMPORT_ARGS=(--file "$OUTPUT_RAW" --distro alpine --version "${ALPINE_VERSION}" --arch x86_64)
rm -f "$OUTPUT_IMAGE"
IMPORT_ARGS=(--file "$OUTPUT_RAW" --distro "${DISTRO}" --version "${DISTRO_VERSION}" --arch x86_64)
if [[ -n "${SYSTEM_TAG:-}" ]]; then
IMPORT_ARGS+=(--tag "$SYSTEM_TAG")
fi
(cd "$PROJECT_DIR" && ./bin/spx admin images import "${IMPORT_ARGS[@]}")
spx admin images import "${IMPORT_ARGS[@]}"
fi
exit 0
fi
mkdir -p "$BUILD_DIR" "$MOUNT_DIR"
# Step 1: Download Alpine cloud image
if [[ -f "${BUILD_DIR}/${ALPINE_IMAGE}" ]]; then
echo "Alpine image already downloaded."
# Step 1: Download base cloud image
if [[ -f "${BUILD_DIR}/${SOURCE_IMAGE}" ]]; then
echo "Base image already downloaded."
else
echo "Downloading Alpine ${ALPINE_VERSION} cloud image..."
if ! curl --fail -L -o "${BUILD_DIR}/${ALPINE_IMAGE}" "$ALPINE_URL"; then
rm -f "${BUILD_DIR}/${ALPINE_IMAGE}"
echo "ERROR: Failed to download Alpine image from $ALPINE_URL"
echo "Downloading ${DISTRO} ${DISTRO_VERSION} cloud image..."
if ! curl --fail -L -o "${BUILD_DIR}/${SOURCE_IMAGE}" "$SOURCE_URL"; then
rm -f "${BUILD_DIR}/${SOURCE_IMAGE}"
echo "ERROR: Failed to download image from $SOURCE_URL"
exit 1
fi
# Verify the download is a valid qcow2 image
if ! qemu-img info "${BUILD_DIR}/${ALPINE_IMAGE}" &>/dev/null; then
rm -f "${BUILD_DIR}/${ALPINE_IMAGE}"
echo "ERROR: Downloaded file is not a valid qcow2 image"
# Verify the download is a valid qcow2/img
if ! qemu-img info "${BUILD_DIR}/${SOURCE_IMAGE}" &>/dev/null; then
rm -f "${BUILD_DIR}/${SOURCE_IMAGE}"
echo "ERROR: Downloaded file is not a valid disk image"
exit 1
fi
fi
@@ -179,9 +221,9 @@ fi
# Step 2: Copy image for customization
rm -f "$OUTPUT_IMAGE"
echo "Copying image for customization..."
cp "${BUILD_DIR}/${ALPINE_IMAGE}" "$OUTPUT_IMAGE"
cp "${BUILD_DIR}/${SOURCE_IMAGE}" "$OUTPUT_IMAGE"
# Resize the image (Alpine cloud images are ~200MB, need room for packages)
# Resize the image to provide room for packages
qemu-img resize "$OUTPUT_IMAGE" "$IMAGE_SIZE"
# Step 3: Connect via qemu-nbd
@@ -194,7 +236,7 @@ fi
sudo qemu-nbd --disconnect "${NBD_DEV}" 2>/dev/null || true
sudo qemu-nbd --connect="${NBD_DEV}" "$OUTPUT_IMAGE"
# Wait for the nbd device to be ready (kernel needs time to scan the block device)
# Wait for the nbd device to be ready
for i in $(seq 1 10); do
if sudo blockdev --getsize64 "${NBD_DEV}" &>/dev/null; then
break
@@ -203,32 +245,61 @@ for i in $(seq 1 10); do
done
sleep 1
# Ubuntu images have a partition table; wait for partition devices then resize
# the partition to fill the expanded image before resizing the filesystem.
if [[ "$DISTRO" == "ubuntu" ]]; then
for i in $(seq 1 10); do
if [[ -e "${ROOT_PART}" ]]; then break; fi
sleep 1
done
echo "Resizing partition..."
# After qemu-img resize the GPT backup header sits at the old end of disk.
# Relocate it to the new end (sfdisk --relocate is util-linux built-in,
# no extra packages needed), then re-read the partition table before parted
# runs — without this, parted sees stale GPT geometry and fails with
# "Unable to satisfy all constraints on the partition."
sudo sgdisk --move-second-header "${NBD_DEV}"
sudo partprobe "${NBD_DEV}" 2>/dev/null || true
sleep 1
sudo parted --script "${NBD_DEV}" resizepart 1 100%
fi
# Alpine cloud images have ext4 directly on the block device (no partition table).
# Resize the filesystem to fill the resized image.
echo "Checking filesystem..."
sudo e2fsck -f -y "${NBD_DEV}" || {
sudo e2fsck -f -y "${ROOT_PART}" || {
ec=$?
if [[ $ec -gt 1 ]]; then
echo "ERROR: e2fsck failed with exit code $ec on ${NBD_DEV}"
echo "ERROR: e2fsck failed with exit code $ec on ${ROOT_PART}"
exit 1
fi
}
echo "Resizing filesystem..."
if ! sudo resize2fs "${NBD_DEV}"; then
echo "ERROR: resize2fs failed on ${NBD_DEV}"
if ! sudo resize2fs "${ROOT_PART}"; then
echo "ERROR: resize2fs failed on ${ROOT_PART}"
exit 1
fi
# Step 4: Mount and customize
echo "Mounting root filesystem..."
sudo mount "${NBD_DEV}" "$MOUNT_DIR"
sudo mount "${ROOT_PART}" "$MOUNT_DIR"
# Set up resolv.conf for DNS inside chroot
# Set up resolv.conf for DNS inside chroot.
# Ubuntu cloud images symlink /etc/resolv.conf → /run/systemd/resolve/stub-resolv.conf
# which doesn't exist yet. Remove the symlink and write a real file.
sudo rm -f "${MOUNT_DIR}/etc/resolv.conf"
sudo cp /etc/resolv.conf "${MOUNT_DIR}/etc/resolv.conf"
# Ubuntu chroot requires /proc /sys /dev bind mounts for systemd and DKMS
if [[ "$DISTRO" == "ubuntu" ]]; then
sudo mount --bind /proc "${MOUNT_DIR}/proc"
sudo mount --bind /sys "${MOUNT_DIR}/sys"
sudo mount --bind /dev "${MOUNT_DIR}/dev"
sudo mount --bind /dev/pts "${MOUNT_DIR}/dev/pts"
fi
# Install packages
if [[ -n "${APK_PACKAGES:-}" ]]; then
if [[ "$DISTRO" == "alpine" ]] && [[ -n "${APK_PACKAGES:-}" ]]; then
echo "Installing packages: ${APK_PACKAGES}..."
sudo chroot "$MOUNT_DIR" /bin/sh -c "
set -e
@@ -244,16 +315,30 @@ fi
apk update
apk add ${APK_PACKAGES}
"
elif [[ "$DISTRO" == "ubuntu" ]] && [[ -n "${APT_PACKAGES:-}" ]]; then
echo "Installing packages: ${APT_PACKAGES}..."
sudo chroot "$MOUNT_DIR" /bin/bash -c "
export DEBIAN_FRONTEND=noninteractive
apt-get update
apt-get install -y --no-install-recommends ${APT_PACKAGES}
"
fi
# Enable OpenRC services
# Enable services
if [[ -n "${ENABLE_SERVICES:-}" ]]; then
echo "Enabling services: ${ENABLE_SERVICES}..."
IFS=' ' read -ra SERVICES <<< "$ENABLE_SERVICES"
for svc in "${SERVICES[@]}"; do
if ! sudo chroot "$MOUNT_DIR" /bin/sh -c "rc-update add ${svc} default"; then
echo "ERROR: Failed to enable service '${svc}' — does it exist in the image?"
exit 1
if [[ "$DISTRO" == "alpine" ]]; then
if ! sudo chroot "$MOUNT_DIR" /bin/sh -c "rc-update add ${svc} default"; then
echo "ERROR: Failed to enable service '${svc}' — does it exist in the image?"
exit 1
fi
else
if ! sudo chroot "$MOUNT_DIR" /bin/bash -c "systemctl enable ${svc}"; then
echo "ERROR: Failed to enable service '${svc}'"
exit 1
fi
fi
done
fi
@@ -282,21 +367,40 @@ if [[ -n "${SETUP_SCRIPT:-}" ]]; then
echo "Running setup script: ${SETUP_SCRIPT}..."
sudo cp "$setup_path" "${MOUNT_DIR}/tmp/setup.sh"
sudo chmod 755 "${MOUNT_DIR}/tmp/setup.sh"
sudo chroot "$MOUNT_DIR" /tmp/setup.sh
if [[ "$DISTRO" == "ubuntu" ]]; then
sudo chroot "$MOUNT_DIR" /bin/bash /tmp/setup.sh
else
sudo chroot "$MOUNT_DIR" /tmp/setup.sh
fi
sudo rm -f "${MOUNT_DIR}/tmp/setup.sh"
fi
# Step 6: Clean up and unmount
echo "Cleaning up image..."
sudo chroot "$MOUNT_DIR" /bin/sh -c '
if [[ "$DISTRO" == "alpine" ]]; then
sudo chroot "$MOUNT_DIR" /bin/sh -c '
apk cache clean 2>/dev/null || true
rm -rf /var/cache/apk/* /tmp/*
'
else
sudo chroot "$MOUNT_DIR" /bin/bash -c '
export DEBIAN_FRONTEND=noninteractive
apt-get clean
rm -rf /var/lib/apt/lists/* /tmp/*
'
fi
# Restore original resolv.conf (cloud-init will set it on boot)
# Restore the systemd-resolved symlink (Ubuntu default); cloud-init sets DNS on boot.
sudo rm -f "${MOUNT_DIR}/etc/resolv.conf"
sudo ln -sf /run/systemd/resolve/stub-resolv.conf "${MOUNT_DIR}/etc/resolv.conf"
echo "Unmounting..."
if [[ "$DISTRO" == "ubuntu" ]]; then
sudo umount "${MOUNT_DIR}/dev/pts"
sudo umount "${MOUNT_DIR}/dev"
sudo umount "${MOUNT_DIR}/sys"
sudo umount "${MOUNT_DIR}/proc"
fi
sudo umount "$MOUNT_DIR"
sudo qemu-nbd --disconnect "${NBD_DEV}"
@@ -318,19 +422,20 @@ echo ""
if [[ "$DO_IMPORT" == true ]]; then
echo "Importing as AMI..."
IMPORT_ARGS=(--file "$OUTPUT_RAW" --distro alpine --version "${ALPINE_VERSION}" --arch x86_64)
rm -f "$OUTPUT_IMAGE"
IMPORT_ARGS=(--file "$OUTPUT_RAW" --distro "${DISTRO}" --version "${DISTRO_VERSION}" --arch x86_64)
if [[ -n "${SYSTEM_TAG:-}" ]]; then
IMPORT_ARGS+=(--tag "$SYSTEM_TAG")
fi
(cd "$PROJECT_DIR" && sudo -u spinifex-storage ./bin/spx admin images import "${IMPORT_ARGS[@]}")
spx admin images import "${IMPORT_ARGS[@]}"
else
echo "To import as AMI, run:"
echo " cd $PROJECT_DIR && ./bin/spx admin images import \\"
echo " spx admin images import \\"
echo " --file $OUTPUT_RAW \\"
if [[ -n "${SYSTEM_TAG:-}" ]]; then
echo " --distro alpine --version ${ALPINE_VERSION} --arch x86_64 \\"
echo " --distro ${DISTRO} --version ${DISTRO_VERSION} --arch x86_64 \\"
echo " --tag ${SYSTEM_TAG}"
else
echo " --distro alpine --version ${ALPINE_VERSION} --arch x86_64"
echo " --distro ${DISTRO} --version ${DISTRO_VERSION} --arch x86_64"
fi
fi
+1 -1
View File
@@ -5,7 +5,7 @@ set -euo pipefail
# Usage: scripts/check-coverage.sh <coverprofile> [quiet]
PROFILE="${1:?Usage: check-coverage.sh <coverprofile> [quiet]}"
MIN=65.0
MIN=70.0
QUIET="${2:-}"
if [[ ! -s "$PROFILE" ]]; then
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env python3
"""Dashboard server for the Spinifex GPU demo.
Proxies streams from the GPU instances and serves the web UI.
Environment variables:
YOLO_HOST Base URL of the YOLO instance, e.g. http://10.0.0.3:8080
OLLAMA_HOST Base URL of the Ollama instance, e.g. http://10.0.0.2:11434
OLLAMA_MODEL Model name to use for chat (default: llama3.1:70b)
PORT Server port (default: 8000)
"""
import asyncio
import json
import os
import httpx
import uvicorn
from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse, StreamingResponse
from fastapi.staticfiles import StaticFiles
YOLO_HOST = os.environ.get("YOLO_HOST", "http://localhost:8080")
OLLAMA_HOST = os.environ.get("OLLAMA_HOST", "http://localhost:11434")
OLLAMA_MODEL = os.environ.get("OLLAMA_MODEL", "llama3.1:70b")
PORT = int(os.environ.get("PORT", "8000"))
app = FastAPI()
app.mount("/static", StaticFiles(directory="static"), name="static")
@app.get("/", response_class=HTMLResponse)
async def index():
with open("static/index.html") as f:
return f.read()
@app.get("/proxy/video")
async def proxy_video():
async def stream():
while True:
try:
async with httpx.AsyncClient(timeout=None) as client:
async with client.stream("GET", f"{YOLO_HOST}/video") as resp:
async for chunk in resp.aiter_bytes(8192):
yield chunk
except Exception:
await asyncio.sleep(5)
return StreamingResponse(stream(), media_type="multipart/x-mixed-replace; boundary=frame")
@app.get("/proxy/descriptions")
async def proxy_descriptions():
async def stream():
while True:
try:
async with httpx.AsyncClient(timeout=httpx.Timeout(connect=10.0, read=None, write=None, pool=None)) as client:
async with client.stream("GET", f"{YOLO_HOST}/descriptions") as resp:
async for line in resp.aiter_lines():
yield line + "\n"
except Exception:
yield 'data: {"type":"error","text":"YOLO stream offline, retrying..."}\n\n'
await asyncio.sleep(5)
return StreamingResponse(stream(), media_type="text/event-stream")
@app.post("/chat")
async def chat(request: Request):
body = await request.json()
prompt = body.get("prompt", "").strip()
if not prompt:
return {"error": "empty prompt"}
async def stream():
async with httpx.AsyncClient(timeout=120) as client:
async with client.stream(
"POST",
f"{OLLAMA_HOST}/v1/chat/completions",
json={
"model": OLLAMA_MODEL,
"messages": [{"role": "user", "content": prompt}],
"stream": True,
},
) as resp:
async for line in resp.aiter_lines():
if not line.startswith("data: "):
continue
data_str = line[6:]
if data_str.strip() == "[DONE]":
yield f"data: {json.dumps({'token': '', 'done': True})}\n\n"
break
try:
data = json.loads(data_str)
token = data["choices"][0]["delta"].get("content", "")
yield f"data: {json.dumps({'token': token, 'done': False})}\n\n"
except (json.JSONDecodeError, KeyError, IndexError):
pass
return StreamingResponse(stream(), media_type="text/event-stream")
@app.get("/status")
async def status():
results: dict[str, str] = {}
async with httpx.AsyncClient(timeout=3) as client:
for name, url in [
("yolo", f"{YOLO_HOST}/health"),
("ollama", f"{OLLAMA_HOST}/v1/models"),
]:
try:
r = await client.get(url)
results[name] = "ok" if r.status_code < 500 else "error"
except Exception:
results[name] = "offline"
return results
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=PORT)
+116
View File
@@ -0,0 +1,116 @@
#!/bin/bash
# deploy.sh — Deploy and start the Spinifex GPU demo across four instances.
#
# Usage:
# DASHBOARD_IP=<ip> YOLO_IP=<ip> OLLAMA_IP=<ip> QWEN_IP=<ip> \
# VIDEO_PATH=/path/to/video.mp4 \
# scripts/demo/deploy.sh
#
# Required env vars:
# DASHBOARD_IP 4x-1: serves the web dashboard
# YOLO_IP 4x-3: runs YOLO inference
# OLLAMA_IP 4x-2: runs Ollama + llama3.1:70b
# QWEN_IP 12x: runs Ollama + qwen3-vl:235b
# VIDEO_PATH Local path to the .mp4 file to run YOLO on
#
# Optional:
# SSH_KEY (default: ~/.ssh/spinifex-key)
# SSH_USER (default: ec2-user)
set -euo pipefail
DASHBOARD_IP="${DASHBOARD_IP:?set DASHBOARD_IP}"
YOLO_IP="${YOLO_IP:?set YOLO_IP}"
OLLAMA_IP="${OLLAMA_IP:?set OLLAMA_IP}"
QWEN_IP="${QWEN_IP:?set QWEN_IP}"
VIDEO_PATH="${VIDEO_PATH:?set VIDEO_PATH}"
SSH_KEY="${SSH_KEY:-$HOME/.ssh/spinifex-key}"
SSH_USER="${SSH_USER:-ec2-user}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
_ssh() {
local ip="$1"; shift
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
-o ConnectTimeout=10 -o BatchMode=yes \
-i "$SSH_KEY" "${SSH_USER}@${ip}" "$@"
}
_scp() {
scp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
-i "$SSH_KEY" -r "$@"
}
# ── YOLO instance (4x-3) ────────────────────────────────────────────────────
echo "==> [YOLO] Deploying to $YOLO_IP..."
_scp "$SCRIPT_DIR/yolo_stream.py" "$SCRIPT_DIR/requirements-yolo.txt" \
"${SSH_USER}@${YOLO_IP}:~/"
_scp "$VIDEO_PATH" "${SSH_USER}@${YOLO_IP}:~/demo_video.mp4"
echo " Installing Python deps..."
_ssh "$YOLO_IP" '
. ~/yolo-rocm/bin/activate
pip install --quiet -r ~/requirements-yolo.txt
'
echo " Starting yolo_stream.py..."
_ssh "$YOLO_IP" "
pkill -f yolo_stream.py || true
sleep 1
. ~/yolo-rocm/bin/activate
nohup env \
VIDEO_PATH=~/demo_video.mp4 \
QWEN_HOST=http://${QWEN_IP}:11434 \
YOLO_DEVICE=0 \
PORT=8080 \
python ~/yolo_stream.py > ~/yolo_stream.log 2>&1 &
echo \" PID \$!\"
"
echo " YOLO stream: http://${YOLO_IP}:8080/video"
# ── Dashboard instance (4x-1) ───────────────────────────────────────────────
echo ""
echo "==> [Dashboard] Deploying to $DASHBOARD_IP..."
_ssh "$DASHBOARD_IP" 'mkdir -p ~/demo/static'
_scp "$SCRIPT_DIR/dashboard_server.py" "$SCRIPT_DIR/requirements-dashboard.txt" \
"${SSH_USER}@${DASHBOARD_IP}:~/demo/"
_scp "$SCRIPT_DIR/static/index.html" \
"${SSH_USER}@${DASHBOARD_IP}:~/demo/static/"
echo " Installing Python deps..."
_ssh "$DASHBOARD_IP" '
python3 -m venv ~/demo-venv 2>/dev/null || true
. ~/demo-venv/bin/activate
pip install --quiet -r ~/demo/requirements-dashboard.txt
'
echo " Starting dashboard_server.py..."
_ssh "$DASHBOARD_IP" "
pkill -f dashboard_server.py || true
sleep 1
. ~/demo-venv/bin/activate
cd ~/demo
nohup env \
YOLO_HOST=http://${YOLO_IP}:8080 \
OLLAMA_HOST=http://${OLLAMA_IP}:11434 \
OLLAMA_MODEL=llama3.1:70b \
PORT=8000 \
python dashboard_server.py > ~/dashboard.log 2>&1 &
echo \" PID \$!\"
"
echo ""
echo "✅ Demo deployed"
echo ""
echo " Dashboard: http://${DASHBOARD_IP}:8000"
echo " YOLO feed: http://${YOLO_IP}:8080/video"
echo " Ollama: http://${OLLAMA_IP}:11434"
echo " Qwen3-VL: http://${QWEN_IP}:11434"
echo ""
echo " Logs:"
echo " ssh -i ${SSH_KEY} ${SSH_USER}@${YOLO_IP} tail -f ~/yolo_stream.log"
echo " ssh -i ${SSH_KEY} ${SSH_USER}@${DASHBOARD_IP} tail -f ~/dashboard.log"
echo ""
echo " NOTE: Ollama and qwen3-vl must already be running on their instances."
echo " If not: _ssh \$IP 'sudo systemctl start ollama && ollama run <model>'"
+4
View File
@@ -0,0 +1,4 @@
fastapi
uvicorn[standard]
httpx
python-multipart
+5
View File
@@ -0,0 +1,5 @@
fastapi
uvicorn[standard]
httpx
opencv-python-headless
ultralytics
+498
View File
@@ -0,0 +1,498 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Spinifex — GPU Compute Demo</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--bg: #07070d;
--surface: #0e0e18;
--border: #1a1a2e;
--accent: #6366f1;
--accent2: #22d3ee;
--text: #d4d4d8;
--muted: #52525b;
--green: #22c55e;
--red: #ef4444;
}
body {
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* ── Header ── */
header {
background: var(--surface);
border-bottom: 1px solid var(--border);
padding: 0 24px;
height: 52px;
display: flex;
align-items: center;
gap: 20px;
flex-shrink: 0;
z-index: 10;
}
.logo {
font-size: 17px;
font-weight: 700;
letter-spacing: -0.3px;
color: #fff;
white-space: nowrap;
}
.logo em { color: var(--accent); font-style: normal; }
.divider { width: 1px; height: 24px; background: var(--border); }
.subtitle {
font-size: 12px;
color: var(--muted);
letter-spacing: 0.3px;
}
.instances {
margin-left: auto;
display: flex;
gap: 20px;
}
.inst {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
color: var(--muted);
}
.inst-dot {
width: 7px; height: 7px;
border-radius: 50%;
background: var(--green);
}
.inst-dot.offline { background: var(--red); }
.inst-dot.live {
background: var(--green);
box-shadow: 0 0 0 0 var(--green);
animation: ring 2s infinite;
}
@keyframes ring {
0% { box-shadow: 0 0 0 0 rgba(34,197,94,.6); }
70% { box-shadow: 0 0 0 6px rgba(34,197,94,0); }
100% { box-shadow: 0 0 0 0 rgba(34,197,94,0); }
}
/* ── Layout ── */
main {
flex: 1;
display: grid;
grid-template-columns: 1fr 400px;
grid-template-rows: 1fr 1fr;
gap: 10px;
padding: 10px;
overflow: hidden;
}
/* ── Panel ── */
.panel {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 0;
}
.panel-head {
padding: 9px 14px;
background: rgba(0,0,0,.3);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.panel-head h2 {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 1px;
color: var(--muted);
}
.chip {
margin-left: auto;
font-size: 10px;
font-weight: 500;
padding: 2px 8px;
border-radius: 99px;
border: 1px solid var(--border);
color: var(--muted);
white-space: nowrap;
}
.chip.accent { border-color: rgba(99,102,241,.4); color: #818cf8; }
.chip.cyan { border-color: rgba(34,211,238,.3); color: var(--accent2); }
/* ── Video panel ── */
.video-panel { grid-row: 1 / 3; position: relative; }
.video-wrap {
flex: 1;
background: #000;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
min-height: 0;
}
.video-wrap img {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
}
.video-footer {
padding: 8px 14px;
font-size: 10px;
color: var(--muted);
border-top: 1px solid var(--border);
flex-shrink: 0;
display: flex;
gap: 16px;
}
/* ── Description panel ── */
.desc-content {
flex: 1;
overflow-y: auto;
padding: 14px;
display: flex;
flex-direction: column;
gap: 14px;
scroll-behavior: smooth;
min-height: 0;
}
.desc-entry {
padding-bottom: 14px;
border-bottom: 1px solid var(--border);
}
.desc-entry:last-child { border-bottom: none; padding-bottom: 0; }
.desc-time {
font-size: 10px;
color: var(--muted);
margin-bottom: 5px;
font-variant-numeric: tabular-nums;
}
.desc-text {
font-size: 13px;
line-height: 1.65;
color: var(--text);
}
.cursor {
display: inline-block;
width: 2px; height: 13px;
background: var(--accent);
vertical-align: text-bottom;
margin-left: 1px;
animation: blink 1s step-end infinite;
}
@keyframes blink { 0%,100%{opacity:1} 50%{opacity:0} }
.desc-placeholder {
font-size: 12px;
color: var(--muted);
font-style: italic;
}
/* ── Chat panel ── */
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 14px;
display: flex;
flex-direction: column;
gap: 10px;
min-height: 0;
}
.msg {
max-width: 88%;
padding: 9px 13px;
border-radius: 10px;
font-size: 13px;
line-height: 1.55;
word-break: break-word;
}
.msg.user {
background: #1e1e42;
align-self: flex-end;
color: #c7d2fe;
}
.msg.assistant {
background: #111120;
align-self: flex-start;
color: var(--text);
border: 1px solid var(--border);
}
.chat-foot {
border-top: 1px solid var(--border);
padding: 10px;
display: flex;
gap: 8px;
flex-shrink: 0;
}
.chat-foot input {
flex: 1;
background: var(--bg);
border: 1px solid var(--border);
border-radius: 7px;
padding: 8px 12px;
color: var(--text);
font-size: 13px;
outline: none;
transition: border-color .15s;
}
.chat-foot input:focus { border-color: var(--accent); }
.chat-foot input::placeholder { color: var(--muted); }
.send-btn {
background: var(--accent);
color: #fff;
border: none;
border-radius: 7px;
padding: 8px 16px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: background .15s, opacity .15s;
white-space: nowrap;
}
.send-btn:hover { background: #4f52d1; }
.send-btn:disabled { opacity: .4; cursor: not-allowed; }
/* scrollbar */
::-webkit-scrollbar { width: 4px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
</style>
</head>
<body>
<header>
<div class="logo">Spini<em>fex</em></div>
<div class="divider"></div>
<div class="subtitle">GPU Compute Platform &mdash; Live Demo</div>
<div class="instances">
<div class="inst">
<div class="inst-dot live" id="dot-yolo"></div>
<span>YOLO&nbsp;&middot;&nbsp;g7e.4xlarge</span>
</div>
<div class="inst">
<div class="inst-dot live" id="dot-qwen"></div>
<span>Qwen3-VL 235B&nbsp;&middot;&nbsp;g7e.12xlarge</span>
</div>
<div class="inst">
<div class="inst-dot live" id="dot-llama"></div>
<span>Llama&nbsp;3.1 70B&nbsp;&middot;&nbsp;g7e.4xlarge</span>
</div>
</div>
</header>
<main>
<!-- Left: YOLO video, full height -->
<div class="panel video-panel">
<div class="panel-head">
<h2>Live Object Detection</h2>
<span class="chip accent">YOLO11 &middot; AMD MI350X</span>
</div>
<div class="video-wrap">
<img id="video-feed" src="/proxy/video" alt="YOLO stream loading...">
</div>
<div class="video-footer">
<span>Real-time inference &mdash; AMD MI350X GPU</span>
<span id="fps-counter"></span>
</div>
</div>
<!-- Top right: qwen3-vl scene analysis -->
<div class="panel">
<div class="panel-head">
<h2>Scene Analysis</h2>
<span class="chip cyan">Qwen3-VL 235B &middot; MI350X</span>
</div>
<div class="desc-content" id="desc-content">
<p class="desc-placeholder">Waiting for first frame analysis&hellip;</p>
</div>
</div>
<!-- Bottom right: Llama3.1 chat -->
<div class="panel">
<div class="panel-head">
<h2>Chat</h2>
<span class="chip accent">Llama 3.1 70B &middot; MI350X</span>
</div>
<div class="chat-messages" id="chat-messages">
<div class="msg assistant">
Hello! I&rsquo;m Llama&nbsp;3.1&nbsp;70B running on an AMD MI350X GPU, orchestrated by Spinifex. Ask me anything.
</div>
</div>
<div class="chat-foot">
<input type="text" id="chat-input" placeholder="Ask Llama 3.1…" autocomplete="off">
<button class="send-btn" id="send-btn" onclick="sendChat()">Send</button>
</div>
</div>
</main>
<script>
// ── Scene description stream ─────────────────────────────────────────────────
const descContent = document.getElementById('desc-content');
let activeEntry = null;
let activeText = '';
function initDescriptions() {
const es = new EventSource('/proxy/descriptions');
es.onmessage = (e) => {
let data;
try { data = JSON.parse(e.data); } catch { return; }
if (data.type === 'token' && data.text) {
if (!activeEntry) {
// Remove placeholder if present
descContent.querySelector('.desc-placeholder')?.remove();
activeEntry = document.createElement('div');
activeEntry.className = 'desc-entry';
activeEntry.innerHTML = `<div class="desc-time">${new Date().toLocaleTimeString()}</div><div class="desc-text"></div>`;
descContent.appendChild(activeEntry);
activeText = '';
}
activeText += data.text;
const textEl = activeEntry.querySelector('.desc-text');
textEl.innerHTML = escHtml(activeText) + '<span class="cursor"></span>';
descContent.scrollTop = descContent.scrollHeight;
}
if (data.type === 'end' && activeEntry) {
activeEntry.querySelector('.desc-text').textContent = activeText;
activeEntry = null;
activeText = '';
// Keep only the last 4 entries
const entries = descContent.querySelectorAll('.desc-entry');
entries.forEach((el, i) => { if (i < entries.length - 4) el.remove(); });
}
};
es.onerror = () => { setTimeout(initDescriptions, 3000); };
}
// ── Chat ─────────────────────────────────────────────────────────────────────
const chatMessages = document.getElementById('chat-messages');
const chatInput = document.getElementById('chat-input');
const sendBtn = document.getElementById('send-btn');
async function sendChat() {
const prompt = chatInput.value.trim();
if (!prompt || sendBtn.disabled) return;
chatInput.value = '';
sendBtn.disabled = true;
appendMsg('user', prompt);
const assistantEl = appendMsg('assistant', '');
let text = '';
try {
const resp = await fetch('/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt }),
});
const reader = resp.body.getReader();
const decoder = new TextDecoder();
let buf = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const lines = buf.split('\n');
buf = lines.pop();
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
let d;
try { d = JSON.parse(line.slice(6)); } catch { continue; }
text += d.token;
assistantEl.innerHTML = escHtml(text) + (d.done ? '' : '<span class="cursor"></span>');
chatMessages.scrollTop = chatMessages.scrollHeight;
}
}
} catch (err) {
assistantEl.textContent = '(connection error)';
}
sendBtn.disabled = false;
chatInput.focus();
}
function appendMsg(role, text) {
const el = document.createElement('div');
el.className = `msg ${role}`;
el.textContent = text;
chatMessages.appendChild(el);
chatMessages.scrollTop = chatMessages.scrollHeight;
return el;
}
chatInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') sendChat(); });
// ── Status polling ────────────────────────────────────────────────────────────
async function pollStatus() {
try {
const data = await fetch('/status').then(r => r.json());
setDot('dot-yolo', data.yolo === 'ok');
setDot('dot-llama', data.ollama === 'ok');
} catch {}
setTimeout(pollStatus, 5000);
}
function setDot(id, ok) {
const el = document.getElementById(id);
if (!el) return;
el.className = 'inst-dot' + (ok ? ' live' : ' offline');
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function escHtml(str) {
return str.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
// ── Boot ──────────────────────────────────────────────────────────────────────
initDescriptions();
pollStatus();
</script>
</body>
</html>
+159
View File
@@ -0,0 +1,159 @@
#!/usr/bin/env python3
"""YOLO inference server for the Spinifex demo.
Reads a video file, runs YOLO detection, and serves:
GET /video — MJPEG stream of annotated frames
GET /descriptions — SSE stream of qwen3-vl scene descriptions
GET /health — {"ok": true}
Environment variables:
VIDEO_PATH Path to input .mp4 (required)
QWEN_HOST Base URL of the qwen3-vl Ollama instance, e.g. http://10.0.0.5:11434
YOLO_MODEL Model name (default: yolo11n.pt)
YOLO_DEVICE GPU device index (default: 0)
DESCRIBE_INTERVAL Seconds between qwen3-vl calls (default: 5.0)
PORT Server port (default: 8080)
"""
import asyncio
import base64
import json
import os
import queue
import threading
import time
import cv2
import httpx
import uvicorn
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
from ultralytics import YOLO
VIDEO_PATH = os.environ["VIDEO_PATH"]
QWEN_HOST = os.environ.get("QWEN_HOST", "")
YOLO_MODEL = os.environ.get("YOLO_MODEL", "yolo11x.pt")
YOLO_DEVICE = os.environ.get("YOLO_DEVICE", "0")
DESCRIBE_INTERVAL = float(os.environ.get("DESCRIBE_INTERVAL", "5.0"))
PORT = int(os.environ.get("PORT", "8080"))
app = FastAPI()
_frame_lock = threading.Lock()
_latest_frame: bytes | None = None
_desc_queue: queue.Queue = queue.Queue(maxsize=100)
def _encode(frame, quality: int = 80) -> bytes:
_, buf = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, quality])
return buf.tobytes()
def _describe(img_b64: str) -> None:
if not QWEN_HOST:
return
try:
with httpx.Client(timeout=180) as client:
with client.stream(
"POST",
f"{QWEN_HOST}/api/generate",
json={
"model": "qwen3-vl:235b",
"prompt": "Briefly describe what is happening in this scene. Focus on the objects detected and any notable activity.",
"images": [img_b64],
"stream": True,
},
) as resp:
for line in resp.iter_lines():
if not line:
continue
try:
data = json.loads(line)
token = data.get("response", "")
if token and not _desc_queue.full():
_desc_queue.put(("token", token))
if data.get("done"):
_desc_queue.put(("end", ""))
except json.JSONDecodeError:
pass
except Exception as exc:
_desc_queue.put(("error", str(exc)))
def inference_loop() -> None:
model = YOLO(YOLO_MODEL)
cap = cv2.VideoCapture(VIDEO_PATH)
if not cap.isOpened():
raise RuntimeError(f"Cannot open video: {VIDEO_PATH}")
fps = cap.get(cv2.CAP_PROP_FPS) or 25.0
frame_delay = 1.0 / fps
last_describe = 0.0
while True:
ret, frame = cap.read()
if not ret:
cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
continue
results = model(frame, device=YOLO_DEVICE, verbose=False)
annotated = results[0].plot()
with _frame_lock:
global _latest_frame
_latest_frame = _encode(annotated)
now = time.monotonic()
if QWEN_HOST and now - last_describe >= DESCRIBE_INTERVAL:
last_describe = now
img_b64 = base64.b64encode(_encode(annotated, quality=60)).decode()
threading.Thread(target=_describe, args=(img_b64,), daemon=True).start()
time.sleep(frame_delay)
@app.on_event("startup")
async def startup() -> None:
threading.Thread(target=inference_loop, daemon=True).start()
@app.get("/health")
async def health():
return {"ok": True}
@app.get("/video")
async def video():
async def generate():
while True:
with _frame_lock:
frame = _latest_frame
if frame:
yield b"--frame\r\nContent-Type: image/jpeg\r\n\r\n" + frame + b"\r\n"
await asyncio.sleep(0.04)
return StreamingResponse(
generate(),
media_type="multipart/x-mixed-replace; boundary=frame",
)
@app.get("/descriptions")
async def descriptions():
async def generate():
yield 'data: {"type":"ready"}\n\n'
loop = asyncio.get_event_loop()
while True:
try:
kind, text = await loop.run_in_executor(
None, lambda: _desc_queue.get(timeout=30)
)
yield f"data: {json.dumps({'type': kind, 'text': text})}\n\n"
except queue.Empty:
yield 'data: {"type":"ping"}\n\n'
return StreamingResponse(generate(), media_type="text/event-stream")
if __name__ == "__main__":
uvicorn.run(app, host="0.0.0.0", port=PORT)
+4 -3
View File
@@ -33,6 +33,8 @@ mkdir -p "$STAGING/systemd"
cp "$PROJECT_ROOT/build/systemd/"* "$STAGING/systemd/"
mkdir -p "$STAGING/scripts"
cp "$PROJECT_ROOT/build/scripts/"* "$STAGING/scripts/"
mkdir -p "$STAGING/udev"
cp "$PROJECT_ROOT/build/udev/"* "$STAGING/udev/"
cp "$PROJECT_ROOT/build/logrotate/spinifex" "$STAGING/logrotate-spinifex"
tar czf /tmp/spinifex-local.tar.gz -C "$STAGING" .
@@ -89,9 +91,8 @@ if ! id -Gn 2>/dev/null | grep -qw spinifex; then
newgrp spinifex
fi
echo "=== Building and importing LB image ==="
cd "$PROJECT_ROOT" && make build-lb-agent
"$PROJECT_ROOT/scripts/build-system-image.sh" "$PROJECT_ROOT/scripts/images/lb.conf" --import --quiet
echo "=== Building and installing microVM artifacts ==="
cd "$PROJECT_ROOT" && make build-lb-agent install-microvm
echo "=== Done ==="
echo "Services: sudo systemctl status spinifex.target"
+1 -1
View File
@@ -13,7 +13,7 @@ set -euo pipefail
PROFILE=""
BASE_REF=""
THRESHOLD=65
THRESHOLD=60
QUIET="${QUIET:-}"
while [[ $# -gt 0 ]]; do
+3 -3
View File
@@ -31,7 +31,7 @@ This directory contains OpenTofu/Terraform configuration files for deploying inf
```
4. **AMI available**
Before running, ensure you have a Debian 12 AMI imported:
Before running, ensure you have a Debian 13 AMI imported:
```bash
# Import an AMI using the spinifex CLI or aws CLI
aws --endpoint-url http://localhost:8080 ec2 describe-images
@@ -105,7 +105,7 @@ The `main.tf` creates:
4. **Route Table** - Routes `0.0.0.0/0` through IGW
5. **Security Group** - Allows SSH (22), HTTP (80), HTTPS (443) inbound
6. **Placement Group** - Spread strategy for HA
7. **3 EC2 Instances** - t3.small Debian 12 VMs
7. **3 EC2 Instances** - t3.small Debian 13 VMs
8. **SSH Key Pair** - ED25519 key saved to `spinifex-test.pem`
## SSH Access
@@ -133,7 +133,7 @@ export AWS_SECRET_ACCESS_KEY=your_secret
### "Error: No AMI found"
Import a Debian 12 AMI first, or modify `main.tf` to use a different AMI filter.
Import a Debian 13 AMI first, or modify `main.tf` to use a different AMI filter.
### Connection refused
+3 -3
View File
@@ -72,13 +72,13 @@ data "aws_availability_zones" "available" {
state = "available"
}
data "aws_ami" "debian12" {
data "aws_ami" "debian13" {
most_recent = true
owners = ["000000000000"]
filter {
name = "name"
values = ["*debian-12*"]
values = ["*debian-13*"]
}
filter {
@@ -200,7 +200,7 @@ resource "aws_placement_group" "test" {
# ---------------------------------------------------------------------------
resource "aws_instance" "test" {
ami = data.aws_ami.debian12.id
ami = data.aws_ami.debian13.id
instance_type = "t3.small"
subnet_id = aws_subnet.test.id
+4 -4
View File
@@ -57,15 +57,15 @@ data "aws_availability_zones" "available" {
state = "available"
}
# Debian 12 AMI
# Debian 13 AMI
# For MulgaOS, need to import a local AMI first
data "aws_ami" "debian12" {
data "aws_ami" "debian13" {
most_recent = true
owners = ["000000000000"] # Spinifex system images (on AWS use "136693071363" for Debian official)
filter {
name = "name"
values = ["*debian-12*"]
values = ["*debian-13*"]
}
filter {
@@ -191,7 +191,7 @@ resource "aws_security_group" "web_ssh" {
resource "aws_instance" "vm" {
count = 3
ami = data.aws_ami.debian12.id
ami = data.aws_ami.debian13.id
instance_type = "t3.small"
subnet_id = aws_subnet.public.id
+2 -2
View File
@@ -7,11 +7,11 @@ Builds a Proxmox VM template with all Spinifex dependencies pre-installed. Cloni
| Component | Details |
|---|---|
| System packages | QEMU, nbdkit, libvirt, gcc, jq, curl, etc. (`make install-system`) |
| Go | 1.26.2 (`make install-go`) |
| Go | 1.26.3 (`make install-go`) |
| AWS CLI | v2 (`make install-aws`) |
| Repositories | spinifex, viperblock, predastore (with `go.work` configured) |
| Module cache | `go mod download` completed for all repos |
| Cloud images | Ubuntu 24.04 for nested VMs (`~/images/ubuntu-24.04.img`) |
| Cloud images | Ubuntu 26.04 for nested VMs (`~/images/ubuntu-26.04.img`) |
| Tuning | sysctl (rmem_max/wmem_max), kvm group, ufw disabled |
| Generalized | Clean machine-id, SSH host keys, cloud-init — each clone boots fresh |
@@ -92,15 +92,15 @@ mkdir -p "$HOME/images"
ARCH=$(dpkg --print-architecture)
IMAGE_URL=""
if [ "$ARCH" = "amd64" ]; then
IMAGE_URL="https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img"
IMAGE_URL="https://cloud-images.ubuntu.com/resolute/current/resolute-server-cloudimg-amd64.img"
elif [ "$ARCH" = "arm64" ]; then
IMAGE_URL="https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-arm64.img"
IMAGE_URL="https://cloud-images.ubuntu.com/resolute/current/resolute-server-cloudimg-arm64.img"
fi
if [ -n "$IMAGE_URL" ]; then
echo " Downloading Ubuntu 24.04 cloud image ($ARCH)..."
curl -L -o "$HOME/images/ubuntu-24.04.img" "$IMAGE_URL"
echo " Saved to ~/images/ubuntu-24.04.img"
echo " Downloading Ubuntu 26.04 cloud image ($ARCH)..."
curl -L -o "$HOME/images/ubuntu-26.04.img" "$IMAGE_URL"
echo " Saved to ~/images/ubuntu-26.04.img"
else
echo " Unknown arch ($ARCH), skipping cloud image download"
fi
+62
View File
@@ -0,0 +1,62 @@
#!/bin/bash
set -euo pipefail
# ubuntu-gpu-amd-setup.sh — Chroot setup for an AMD GPU-capable guest image.
#
# Installs linux-firmware (AMD firmware blobs required by the amdgpu kernel
# module), ROCm CLI tools, Python toolchain, and common utilities. Rebuilds
# initramfs so AMD firmware is available at early boot inside the guest.
#
# Host-side VFIO setup (driver blacklisting, IOMMU, initramfs) is handled
# by `spx admin gpu setup` — this script is for the guest VM image only.
#
# Must run inside an Ubuntu 26.04 chroot with /proc /sys /dev bind-mounted.
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y --no-install-recommends curl gnupg ca-certificates
# AMD ROCm apt repository — rocminfo and rocm-smi-lib are not in the Ubuntu
# default repos, so we add AMD's signed repo before the main package install.
UBUNTU_CODENAME=$(. /etc/os-release && echo "${UBUNTU_CODENAME:-${VERSION_CODENAME}}")
mkdir -p /etc/apt/keyrings
curl -fsSL https://repo.radeon.com/rocm/rocm.gpg.key \
| gpg --dearmor -o /etc/apt/keyrings/rocm.gpg
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/rocm.gpg] https://repo.radeon.com/rocm/apt/6.3 ${UBUNTU_CODENAME} main" \
> /etc/apt/sources.list.d/rocm.list
apt-get update -qq
# linux-firmware carries the AMD GPU firmware blobs loaded by the amdgpu
# kernel module at boot. Without them the GPU is invisible to guest userland.
apt-get install -y -o Acquire::Retries=3 --no-install-recommends \
linux-image-generic \
linux-headers-generic \
linux-firmware \
initramfs-tools \
pciutils \
rocminfo rocm-smi-lib \
python3 python3-venv python3-pip \
git curl wget htop tmux \
ffmpeg libgl1 libglib2.0-0
KVER=$(ls /boot/vmlinuz-* 2>/dev/null | sort -V | tail -1 | sed 's|/boot/vmlinuz-||')
if [ -z "${KVER}" ]; then
echo "ERROR: No kernel found under /boot"
exit 1
fi
echo "Rebuilding initramfs for kernel: ${KVER}"
update-initramfs -u -k "${KVER}"
# Prevent unattended-upgrades from replacing linux-firmware mid-lifecycle;
# a firmware change without a matching initramfs rebuild can make the GPU invisible.
mkdir -p /etc/apt/apt.conf.d
cat > /etc/apt/apt.conf.d/99-gpu-ami <<'EOF'
Unattended-Upgrade::Package-Blacklist {
"linux-firmware";
"linux-image-";
"linux-headers-";
};
EOF
echo "AMD GPU image setup complete: kernel=${KVER}, linux-firmware and ROCm userland installed"
+12
View File
@@ -0,0 +1,12 @@
IMAGE_NAME="ubuntu-amd-gpu"
IMAGE_DESCRIPTION="Ubuntu 26.04 AMD GPU guest image — linux-firmware, ROCm CLI, Python toolchain"
DISTRO="ubuntu"
UBUNTU_VERSION="26.04"
UBUNTU_CODENAME="resolute"
# 12G provides headroom for kernel headers, linux-firmware, and ROCm packages
IMAGE_SIZE="12G"
SETUP_SCRIPT="scripts/images/ubuntu-gpu-amd-setup.sh"
SYSTEM_TAG="gpu-vendor=amd"
+107
View File
@@ -0,0 +1,107 @@
#!/bin/bash
set -euo pipefail
# ubuntu-gpu-nvidia-setup.sh — Chroot setup for an NVIDIA GPU-capable guest image.
#
# Pre-builds the NVIDIA headless server driver via DKMS against a pinned kernel
# so guests can use the GPU immediately after launch. Also installs Python
# toolchain and common utilities.
#
# nouveau is blacklisted inside the guest image so the NVIDIA kernel module can
# bind — this is distinct from the host-side blacklist that `spx admin gpu setup`
# configures for VFIO passthrough.
#
# Host-side VFIO setup (IOMMU, vfio-pci binding, host initramfs) is handled
# by `spx admin gpu setup` — this script is for the guest VM image only.
#
# Must run inside an Ubuntu 26.04 chroot with /proc /sys /dev bind-mounted
# (required by DKMS during the nvidia package post-install hook).
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
# Detect the cloud image's existing kernel BEFORE installing anything.
# Installing linux-image-generic would upgrade to a newer kernel version;
# DKMS would then build for that version but the VM boots the original
# cloud image kernel, causing a version mismatch at modprobe time.
KVER=$(ls /boot/vmlinuz-* 2>/dev/null | sort -V | tail -1 | sed 's|/boot/vmlinuz-||')
if [[ -z "${KVER}" ]]; then
echo "ERROR: No kernel found under /boot"
exit 1
fi
echo "Target kernel: ${KVER}"
# Install headers for the exact kernel already present, plus initramfs-tools.
apt-get install -y --no-install-recommends \
"linux-headers-${KVER}" \
initramfs-tools
# Install the NVIDIA server DKMS driver. Use the distro default version (no
# hardcoded suffix) so Ubuntu 26.04's 580 driver is used instead of 550.
# nvidia-dkms-server is a meta package that resolves to the current recommended
# version; nvidia-utils-server brings nvidia-smi and related tools.
apt-get install -y --no-install-recommends \
nvidia-dkms-server \
nvidia-utils-server
# Detect the installed NVIDIA DKMS module name + version for explicit build.
NVIDIA_DKMS_NAME=$(dkms status 2>/dev/null | awk -F'[,/ ]+' '/nvidia/{print $1; exit}')
NVIDIA_DKMS_VER=$(dkms status 2>/dev/null | awk -F'[,/ ]+' '/nvidia/{print $2; exit}')
if [[ -n "${NVIDIA_DKMS_NAME}" && -n "${NVIDIA_DKMS_VER}" ]]; then
echo "Building NVIDIA DKMS module ${NVIDIA_DKMS_NAME}/${NVIDIA_DKMS_VER} for kernel ${KVER}..."
dkms install -m "${NVIDIA_DKMS_NAME}" -v "${NVIDIA_DKMS_VER}" --kernelver "${KVER}" --force 2>&1
else
echo "WARNING: nvidia DKMS module not found in dkms status — driver install may have failed"
fi
# Verify the module was actually built.
NVIDIA_KO=$(find "/lib/modules/${KVER}/updates" -name "nvidia.ko*" 2>/dev/null | head -1)
if [[ -z "${NVIDIA_KO}" ]]; then
echo "ERROR: nvidia.ko not found under /lib/modules/${KVER}/updates after DKMS build"
exit 1
fi
echo "NVIDIA kernel module: ${NVIDIA_KO}"
# Pin the exact kernel + headers so unattended-upgrades cannot replace the
# kernel the DKMS module was built for.
apt-mark hold \
"linux-image-${KVER}" \
"linux-headers-${KVER}"
NVIDIA_PKGS=$(dpkg --get-selections 'nvidia-*' 2>/dev/null | awk '/install$/{print $1}' || true)
LIBNVIDIA_PKGS=$(dpkg --get-selections 'libnvidia-*' 2>/dev/null | awk '/install$/{print $1}' || true)
# shellcheck disable=SC2086
[[ -n "${NVIDIA_PKGS}" ]] && apt-mark hold ${NVIDIA_PKGS}
# shellcheck disable=SC2086
[[ -n "${LIBNVIDIA_PKGS}" ]] && apt-mark hold ${LIBNVIDIA_PKGS}
# Blacklist nouveau inside the guest — nvidia.ko and nouveau conflict, and
# without this blacklist the NVIDIA driver will not bind after boot.
cat > /etc/modprobe.d/blacklist-nouveau.conf <<'EOF'
blacklist nouveau
options nouveau modeset=0
EOF
# Common tooling matching the AMD image.
apt-get install -y -o Acquire::Retries=3 \
pciutils \
python3 python3-venv python3-pip \
git curl wget htop tmux \
ffmpeg libgl1 libglib2.0-0
mkdir -p /etc/apt/apt.conf.d
cat > /etc/apt/apt.conf.d/99-gpu-ami <<'EOF'
Unattended-Upgrade::Package-Blacklist {
"linux-";
"nvidia-";
"libnvidia-";
};
EOF
# Rebuild initramfs with nouveau blacklisted and NVIDIA module included.
update-initramfs -u -k "${KVER}"
echo "NVIDIA GPU image setup complete: kernel=${KVER}, NVIDIA driver + DKMS pre-built"
+12
View File
@@ -0,0 +1,12 @@
IMAGE_NAME="ubuntu-nvidia-gpu"
IMAGE_DESCRIPTION="Ubuntu 26.04 NVIDIA GPU guest image — NVIDIA server driver (DKMS pre-built), Python toolchain"
DISTRO="ubuntu"
UBUNTU_VERSION="26.04"
UBUNTU_CODENAME="resolute"
# 12G provides headroom for kernel headers, NVIDIA driver, and DKMS build artefacts
IMAGE_SIZE="12G"
SETUP_SCRIPT="scripts/images/ubuntu-gpu-nvidia-setup.sh"
SYSTEM_TAG="gpu-vendor=nvidia"
+248
View File
@@ -0,0 +1,248 @@
#!/bin/bash
set -euo pipefail
export AWS_PROFILE=spinifex
# Resolve the actual user's home — $HOME is /root when run under sudo.
_USER_HOME=$(getent passwd "${SUDO_USER:-$(id -un)}" | cut -d: -f6)
SSH_KEY="${SSH_KEY:-${_USER_HOME}/.ssh/spinifex-key}"
SSH_USER="${SSH_USER:-ec2-user}"
SSH_TIMEOUT="${SSH_TIMEOUT:-300}"
usage() {
cat <<'EOF'
launch-gpu.sh — Launch a GPU instance from the Spinifex AMD or NVIDIA GPU AMI.
Detects the GPU brand from the specified instance type and selects the matching
pre-built AMI automatically. Use --amd or --nvidia to override.
Usage:
scripts/launch-gpu.sh --type <instance-type> [--disk GB] [--amd|--nvidia]
Options:
--type TYPE EC2 instance type to launch (required)
--disk GB Root disk size in GB (default: 50)
--ami NAME Override the AMI name to use (default: ubuntu-amd-gpu or ubuntu-nvidia-gpu)
--amd Force AMD GPU AMI (ubuntu-amd-gpu)
--nvidia Force NVIDIA GPU AMI (ubuntu-nvidia-gpu)
Env overrides:
SSH_KEY Path to SSH private key (default: ~/.ssh/spinifex-key)
SSH_USER SSH user inside guest (default: ec2-user)
SSH_TIMEOUT Seconds to wait for initial SSH (default: 300)
EOF
}
INSTANCE_TYPE=""
DISK_GB=50
GPU_BRAND=""
AMI_OVERRIDE=""
while [[ $# -gt 0 ]]; do
case "$1" in
--type) INSTANCE_TYPE="$2"; shift 2 ;;
--disk) DISK_GB="$2"; shift 2 ;;
--ami) AMI_OVERRIDE="$2"; shift 2 ;;
--amd) GPU_BRAND="amd"; shift ;;
--nvidia) GPU_BRAND="nvidia"; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "Unknown option: $1"; usage; exit 1 ;;
esac
done
list_gpu_types() {
aws ec2 describe-instance-types \
--query 'InstanceTypes[?GpuInfo].[InstanceType,GpuInfo.Gpus[0].Count,GpuInfo.Gpus[0].Manufacturer,GpuInfo.Gpus[0].Name]' \
--output table 2>/dev/null || true
}
if [ -z "$INSTANCE_TYPE" ]; then
echo "Error: --type is required."
echo ""
echo "Available GPU instance types on this node:"
list_gpu_types
exit 1
fi
# --- Validate instance type and fetch GPU info in one call ---
echo "==> Checking instance type: ${INSTANCE_TYPE}"
INSTANCE_INFO=$(aws ec2 describe-instance-types \
--query "InstanceTypes[?InstanceType=='${INSTANCE_TYPE}' && GpuInfo].[GpuInfo.Gpus[0].Count,GpuInfo.Gpus[0].Manufacturer,GpuInfo.Gpus[0].Name]" \
--output text 2>/dev/null || true)
if [ -z "$INSTANCE_INFO" ] || [ "$INSTANCE_INFO" = "None" ]; then
echo "'${INSTANCE_TYPE}' is not available or is not a GPU type on this node."
echo ""
echo "Available GPU instance types:"
list_gpu_types
exit 1
fi
IFS=$'\t' read -r GPU_COUNT GPU_MANUFACTURER GPU_NAME <<< "$INSTANCE_INFO"
echo " ${GPU_COUNT}× ${GPU_NAME} (${GPU_MANUFACTURER})"
# --- Resolve GPU brand ---
if [ -z "$GPU_BRAND" ]; then
case "$GPU_MANUFACTURER" in
AMD|amd) GPU_BRAND="amd" ;;
NVIDIA|nvidia) GPU_BRAND="nvidia" ;;
*)
echo "Cannot auto-detect GPU brand from manufacturer '${GPU_MANUFACTURER}'."
echo "Use --amd or --nvidia to specify explicitly."
exit 1
;;
esac
fi
case "$GPU_BRAND" in
amd) AMI_NAME="ubuntu-amd-gpu" ;;
nvidia) AMI_NAME="ubuntu-nvidia-gpu" ;;
esac
# --- Resolve AMI ---
AMI_LOOKUP="${AMI_OVERRIDE:-${AMI_NAME}}"
echo "==> Resolving AMI: ${AMI_LOOKUP}"
AMI_ID=$(aws ec2 describe-images \
--query "Images[?Name=='${AMI_LOOKUP}'].ImageId | [0]" --output text 2>/dev/null || true)
if [ -z "$AMI_ID" ] || [ "$AMI_ID" = "None" ]; then
echo "No AMI found with name '${AMI_LOOKUP}'."
echo "Build and import it first, or specify the AMI name with --ami <name>"
echo " scripts/build-system-image.sh scripts/images/ubuntu-gpu-${GPU_BRAND}.conf --import"
exit 1
fi
echo " $AMI_ID"
# --- Resolve subnet ---
echo "==> Resolving subnet"
SUBNET_ID=$(aws ec2 describe-subnets \
--query 'Subnets[?MapPublicIpOnLaunch==`true`].SubnetId | [0]' --output text 2>/dev/null || true)
if [ -z "$SUBNET_ID" ] || [ "$SUBNET_ID" = "None" ]; then
echo "No public subnet found"
exit 1
fi
echo " $SUBNET_ID"
VPC_ID=$(aws ec2 describe-subnets --subnet-ids "$SUBNET_ID" \
--query 'Subnets[0].VpcId' --output text 2>/dev/null || true)
if [ -z "$VPC_ID" ] || [ "$VPC_ID" = "None" ]; then
echo "Could not resolve VPC for subnet $SUBNET_ID"
exit 1
fi
# --- Ensure SSH security group ---
# The default VPC SG only allows intra-SG traffic; we need a group that
# permits port 22 from anywhere so the SSH wait below can connect.
SSH_SG_NAME="spinifex-gpu-ssh"
echo "==> Ensuring SSH security group (${SSH_SG_NAME})"
SG_ID=$(aws ec2 describe-security-groups \
--filters "Name=group-name,Values=${SSH_SG_NAME}" "Name=vpc-id,Values=${VPC_ID}" \
--query 'SecurityGroups[0].GroupId' --output text 2>/dev/null || true)
if [ -z "$SG_ID" ] || [ "$SG_ID" = "None" ]; then
SG_ID=$(aws ec2 create-security-group \
--group-name "$SSH_SG_NAME" \
--description "Allow SSH access to GPU instances" \
--vpc-id "$VPC_ID" \
--query 'GroupId' --output text)
aws ec2 authorize-security-group-ingress \
--group-id "$SG_ID" \
--protocol tcp \
--port 22 \
--cidr 0.0.0.0/0 >/dev/null
echo " Created $SG_ID"
else
echo " Reusing $SG_ID"
fi
# --- Ensure SSH key ---
if [ ! -f "$SSH_KEY" ]; then
echo "==> Generating SSH key"
sudo -u "${SUDO_USER:-$(id -un)}" mkdir -p "$(dirname "$SSH_KEY")"
sudo -u "${SUDO_USER:-$(id -un)}" ssh-keygen -t ed25519 -f "$SSH_KEY" -N ""
fi
# Delete before import — stale key pair material causes import to fail
# silently, leaving the instance unreachable via SSH.
echo "==> Importing SSH key"
aws ec2 delete-key-pair --key-name spinifex-key >/dev/null 2>&1 || true
aws ec2 import-key-pair --key-name spinifex-key \
--public-key-material "fileb://${SSH_KEY}.pub" >/dev/null 2>&1 || true
# --- Launch ---
echo "==> Launching ${INSTANCE_TYPE} (${DISK_GB} GB)..."
ID=$(aws ec2 run-instances \
--image-id "$AMI_ID" \
--instance-type "$INSTANCE_TYPE" \
--key-name spinifex-key \
--subnet-id "$SUBNET_ID" \
--security-group-ids "$SG_ID" \
--count 1 \
--block-device-mappings "[{\"DeviceName\":\"/dev/sda1\",\"Ebs\":{\"VolumeSize\":${DISK_GB},\"VolumeType\":\"gp3\",\"DeleteOnTermination\":true}}]" \
--query 'Instances[0].InstanceId' --output text)
if [ -z "$ID" ] || [ "$ID" = "None" ] || [ "$ID" = "null" ]; then
echo "run-instances returned no InstanceId"
exit 1
fi
echo " $ID"
# --- Wait for running ---
echo "==> Waiting for running state..."
COUNT=0; STATE="unknown"
while [ $COUNT -lt 120 ]; do
STATE=$(aws ec2 describe-instances --instance-ids "$ID" \
--query "Reservations[0].Instances[0].State.Name" --output text 2>/dev/null || echo "unknown")
[ "$STATE" = "running" ] && break
[ "$STATE" = "terminated" ] && { echo "Instance terminated unexpectedly"; exit 1; }
sleep 2; COUNT=$((COUNT + 2))
done
[ "$STATE" = "running" ] || { echo "Instance did not reach running state (last: $STATE)"; exit 1; }
# --- Wait for public IP ---
echo "==> Waiting for public IP..."
IP=""
for _i in $(seq 1 120); do
IP=$(aws ec2 describe-instances --instance-ids "$ID" \
--query 'Reservations[0].Instances[0].PublicIpAddress' --output text 2>/dev/null || true)
[ -n "$IP" ] && [ "$IP" != "None" ] && [ "$IP" != "null" ] && break
sleep 1
done
[ -n "$IP" ] && [ "$IP" != "None" ] || { echo "No public IP assigned after 120s"; exit 1; }
echo " $IP"
# Clear stale known_hosts entry — instances reuse IPs across runs.
KNOWN_HOSTS="$HOME/.ssh/known_hosts"
[ -f "$KNOWN_HOSTS" ] && ssh-keygen -f "$KNOWN_HOSTS" -R "$IP" >/dev/null 2>&1 || true
# --- Wait for SSH ---
echo "==> Waiting for SSH..."
_ssh() {
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
-o ConnectTimeout=5 -o BatchMode=yes \
-i "$SSH_KEY" "${SSH_USER}@${IP}" "$@"
}
elapsed=0
while [ "$elapsed" -lt "$SSH_TIMEOUT" ]; do
_ssh 'echo ready' >/dev/null 2>&1 && break
sleep 5; elapsed=$((elapsed + 5))
done
[ "$elapsed" -lt "$SSH_TIMEOUT" ] || { echo "SSH timeout after ${SSH_TIMEOUT}s"; exit 1; }
# --- GPU visibility check ---
echo "==> Checking GPU visibility..."
lspci_out=$(_ssh 'lspci -nn 2>/dev/null' || true)
if printf '%s\n' "$lspci_out" | grep -qiE "1002:|10de:|Instinct|Aqua|Processing accelerator|NVIDIA"; then
printf '%s\n' "$lspci_out" \
| grep -iE "1002:|10de:|Instinct|Aqua|Processing accelerator|NVIDIA" \
| sed 's/^/ ✓ /'
else
echo " WARNING: GPU not visible in lspci — check host-side passthrough assignment"
printf '%s\n' "$lspci_out" | head -20 | sed 's/^/ /'
fi
# --- Done ---
echo ""
echo "✅ ${INSTANCE_TYPE} ready — ${ID} (${IP})"
echo ""
echo " ssh -i ${SSH_KEY} -o StrictHostKeyChecking=no ${SSH_USER}@${IP}"
echo ""
echo " Terminate when done:"
echo " AWS_PROFILE=spinifex aws ec2 terminate-instances --instance-ids ${ID}"
+3 -4
View File
@@ -268,10 +268,9 @@ sudo systemctl start spinifex.target
# Wait for services to start
sleep 5
# --- Build + import LB image (needs services running) ---
echo "==> Building and importing LB image"
cd "$PROJECT_ROOT" && make build-lb-agent
"$PROJECT_ROOT/scripts/build-system-image.sh" "$PROJECT_ROOT/scripts/images/lb.conf" --import --quiet
# --- Build + install microVM artifacts (kernel + initramfs for direct-boot LBs) ---
echo "==> Building and installing microVM artifacts"
cd "$PROJECT_ROOT" && make build-lb-agent install-microvm
# --- Smoke test ---
sudo -u "$INVOKING_USER" "$SCRIPT_DIR/smoke-test.sh"
+3 -3
View File
@@ -12,11 +12,11 @@ if [ -z "$SPINIFEX_AMI" ]; then
echo "SPINIFEX_AMI not set, attempting to detect..."
ARCH=$(uname -m)
if [ "$ARCH" = "x86_64" ]; then
IMAGE_NAME="ami-ubuntu-24.04-x86_64"
IMAGE_NAME="ami-ubuntu-26.04-x86_64"
elif [ "$ARCH" = "aarch64" ] || [ "$ARCH" = "arm64" ]; then
IMAGE_NAME="ami-ubuntu-24.04-arm64"
IMAGE_NAME="ami-ubuntu-26.04-arm64"
else
IMAGE_NAME="ami-ubuntu-24.04-x86_64"
IMAGE_NAME="ami-ubuntu-26.04-x86_64"
fi
SPINIFEX_AMI=$(aws ec2 describe-images --query "Images[?Name=='$IMAGE_NAME'].ImageId" --output text)
if [ -z "$SPINIFEX_AMI" ]; then
+3 -3
View File
@@ -6,12 +6,12 @@ export AWS_PROFILE=spinifex
# Detect architecture
ARCH=$(uname -m)
if [ "$ARCH" = "x86_64" ]; then
IMAGE_NAME="ami-ubuntu-24.04-x86_64"
IMAGE_NAME="ami-ubuntu-26.04-x86_64"
elif [ "$ARCH" = "aarch64" ] || [ "$ARCH" = "arm64" ]; then
IMAGE_NAME="ami-ubuntu-24.04-arm64"
IMAGE_NAME="ami-ubuntu-26.04-arm64"
else
echo "Warning: Unknown architecture $ARCH, defaulting to x86_64"
IMAGE_NAME="ami-ubuntu-24.04-x86_64"
IMAGE_NAME="ami-ubuntu-26.04-x86_64"
fi
# Query available instance types from Spinifex and pick the first micro type
+1 -1
View File
@@ -326,7 +326,7 @@ if [ -n "$WAN_BRIDGE" ]; then
/etc/systemd/network/15-spinifex-veth-wan.network \
/etc/systemd/network/16-spinifex-veth-wan-ovs.network
sudo networkctl reload 2>/dev/null || true
sudo ovs-vsctl --if-exists del-port "$WAN_BRIDGE" veth-wan-ovs
sudo ovs-vsctl --if-exists del-port veth-wan-ovs 2>/dev/null || true
sudo ip link del veth-wan-br 2>/dev/null || true
fi
+37 -5
View File
@@ -15,7 +15,7 @@
# VERBOSE Set to 1 to echo "[setup] <stage>" before each top-level step.
# SETUP_STAGES Comma-separated subset of stages to run:
# deps, aws, users, sudoers, files, directories,
# env, systemd, logrotate, fixown, migrations
# env, systemd, logrotate, udev, fixown, migrations
# Unset = run every stage appropriate for the current mode.
set -e
@@ -192,6 +192,7 @@ spinifex-vpcd ALL=(root) NOPASSWD: /usr/sbin/dhcpcd
spinifex-vpcd ALL=(root) NOPASSWD: /usr/bin/ovs-vsctl, /usr/bin/ovs-appctl
spinifex-vpcd ALL=(root) NOPASSWD: /usr/bin/ovn-nbctl, /usr/bin/ovn-sbctl
spinifex-vpcd ALL=(root) NOPASSWD: /usr/bin/systemctl is-active --quiet ovn-controller
spinifex-vpcd ALL=(root) NOPASSWD: /sbin/ip, /usr/sbin/ip
SUDOERS
$SUDO chmod 0440 /etc/sudoers.d/spinifex-network
$SUDO visudo -cf /etc/sudoers.d/spinifex-network || fatal "Invalid sudoers syntax in spinifex-network"
@@ -213,8 +214,9 @@ install_apt_deps() {
DEBIAN_FRONTEND=noninteractive $SUDO apt-get install -y -qq \
nbdkit \
$QEMU_PACKAGES qemu-utils qemu-kvm less \
$QEMU_PACKAGES qemu-utils qemu-kvm ovmf less \
libvirt-daemon-system libvirt-clients \
pciutils \
jq curl iproute2 netcat-openbsd wget unzip xz-utils file \
ovn-central ovn-host openvswitch-switch dhcpcd-base \
> /dev/null
@@ -344,6 +346,13 @@ install_files() {
$SUDO install -m 0755 "$EXTRACT_DIR/setup.sh" /usr/local/share/spinifex/setup.sh
info " /usr/local/share/spinifex/setup.sh"
fi
# microVM kernel + initramfs
$SUDO install -d /usr/share/spinifex/microvm
if [ -d "$EXTRACT_DIR/microvm" ]; then
$SUDO cp "$EXTRACT_DIR/microvm/"* /usr/share/spinifex/microvm/
info " /usr/share/spinifex/microvm/*"
fi
}
# --- Create directories ---
@@ -378,11 +387,14 @@ create_directories() {
# /run is tmpfs — direct mkdir doesn't survive reboot, and units have
# ReadWritePaths= on these paths which fails namespace setup with ENOENT
# if the dirs are absent at service start.
$SUDO install -m 0644 /dev/stdin /etc/tmpfiles.d/spinifex.conf <<'TMPEOF'
_tmpf=$(mktemp)
cat > "$_tmpf" <<'TMPEOF'
# Type Path Mode User Group Age
d /run/spinifex 0770 root spinifex -
d /run/spinifex/nbd 0770 spinifex-viperblock spinifex-viperblock -
TMPEOF
$SUDO install -m 0644 "$_tmpf" /etc/tmpfiles.d/spinifex.conf
rm -f "$_tmpf"
if [ "${ISO_BUILD:-0}" != "1" ]; then
# Live systems: materialise the runtime dirs immediately so a re-run of
# setup.sh on an existing host has correct /run state without a reboot.
@@ -608,6 +620,24 @@ install_logrotate() {
info "Logrotate config installed"
}
# --- Install udev rules ---
install_udev() {
stage "installing udev rules"
if [ ! -d "$EXTRACT_DIR/udev" ]; then
return
fi
$SUDO install -d /etc/udev/rules.d
for rule in "$EXTRACT_DIR"/udev/*; do
$SUDO install -m 0644 "$rule" "/etc/udev/rules.d/$(basename "$rule")"
info " /etc/udev/rules.d/$(basename "$rule")"
done
if [ "${ISO_BUILD:-0}" != "1" ]; then
$SUDO udevadm control --reload-rules
$SUDO udevadm trigger --subsystem-match=vfio 2>/dev/null || true
fi
info "udev rules installed"
}
# --- Upgrade handling ---
handle_upgrade() {
if $SUDO systemctl is-active --quiet spinifex.target 2>/dev/null; then
@@ -688,10 +718,11 @@ main() {
handle_upgrade
fi
# Stages that need EXTRACT_DIR: files, directories, systemd, logrotate.
# Stages that need EXTRACT_DIR: files, directories, systemd, logrotate, udev.
# Only download/extract when at least one such stage is enabled.
if stage_enabled files || stage_enabled directories \
|| stage_enabled systemd || stage_enabled logrotate; then
|| stage_enabled systemd || stage_enabled logrotate \
|| stage_enabled udev; then
download_spinifex
fi
@@ -705,6 +736,7 @@ main() {
stage_enabled fixown && fix_file_ownership
stage_enabled systemd && install_systemd
stage_enabled logrotate && install_logrotate
stage_enabled udev && install_udev
# Migrations are only safe on a live system (need a running NATS and a
# persisted config file). Skip under ISO_BUILD and under any explicit
+98 -17
View File
@@ -6,10 +6,31 @@
#
# Assumes services are running and AWS_PROFILE=spinifex is configured
# by a prior 'spx admin init'.
#
# GPU passthrough test (opt-in):
# TEST_GPU=1 scripts/smoke-test.sh
# TEST_GPU=1 GPU_VENDOR_ID=10de GPU_DEVICE_ID=2236 scripts/smoke-test.sh
#
# GPU env vars:
# TEST_GPU=1 enable GPU passthrough checks
# GPU_VENDOR_ID PCI vendor ID (default: auto-detect first NVIDIA VGA/3D device)
# GPU_DEVICE_ID PCI device ID (default: auto-detect)
# GPU_FAMILY instance family to test (default: g5)
set -euo pipefail
TEST_GPU="${TEST_GPU:-0}"
GPU_VENDOR_ID="${GPU_VENDOR_ID:-}"
GPU_DEVICE_ID="${GPU_DEVICE_ID:-}"
GPU_FAMILY="${GPU_FAMILY:-g5}"
export AWS_PROFILE=spinifex
# AWS commands run as the invoking user so their ~/.aws/config (spinifex profile
# + CA bundle) is used when the script is called via sudo.
INVOKING_USER="${SUDO_USER:-$(id -un)}"
INVOKING_HOME=$(getent passwd "$INVOKING_USER" | cut -d: -f6)
aws_as_user() { sudo -u "$INVOKING_USER" env HOME="$INVOKING_HOME" AWS_PROFILE=spinifex aws "$@"; }
# --- Wait for EC2 daemon to subscribe to NATS ---
# Port 3000 (UI) becomes ready before the daemon finishes initialising.
# Poll describe-key-pairs until it doesn't return InternalError.
@@ -17,7 +38,7 @@ echo "==> Waiting for EC2 daemon to be ready"
DAEMON_TIMEOUT=60
DAEMON_ELAPSED=0
while [ $DAEMON_ELAPSED -lt $DAEMON_TIMEOUT ]; do
if aws ec2 describe-key-pairs --output text >/dev/null 2>&1; then
if aws_as_user ec2 describe-key-pairs --output text >/dev/null 2>&1; then
break
fi
sleep 2
@@ -38,29 +59,64 @@ if [ ! -f "$SSH_KEY.pub" ]; then
fi
echo "==> Importing SSH key"
! aws ec2 import-key-pair --key-name spinifex-key \
--public-key-material "fileb://$SSH_KEY.pub"
aws ec2 describe-key-pairs
aws_as_user ec2 import-key-pair --key-name spinifex-key \
--public-key-material "fileb://$SSH_KEY.pub" || true
aws_as_user ec2 describe-key-pairs
# --- GPU pre-flight (TEST_GPU=1 only) ---
if [[ "$TEST_GPU" == "1" ]]; then
echo "==> [GPU] Checking IOMMU"
if ! dmesg | grep -qi "DMAR.*IOMMU enabled\|iommu.*Translated\|Adding to iommu group\|AMD-Vi.*enabled"; then
echo "❌ IOMMU not detected — add intel_iommu=on (or amd_iommu=on) and iommu=pt to kernel cmdline, then reboot"
exit 1
fi
echo " IOMMU active"
echo "==> [GPU] Detecting NVIDIA GPU"
if [[ -z "$GPU_VENDOR_ID" || -z "$GPU_DEVICE_ID" ]]; then
GPU_LINE=$(lspci -nn | grep -i nvidia | grep -iE "VGA|3D" | head -1)
if [[ -z "$GPU_LINE" ]]; then
echo "❌ No NVIDIA GPU found via lspci — is the card seated and detectable?"
lspci | grep -i nvidia >&2 || true
exit 1
fi
echo " Detected: $GPU_LINE"
PCI_IDS=$(echo "$GPU_LINE" | grep -oP '\[\K[0-9a-f]{4}:[0-9a-f]{4}(?=\])')
GPU_VENDOR_ID=$(echo "$PCI_IDS" | cut -d: -f1)
GPU_DEVICE_ID=$(echo "$PCI_IDS" | cut -d: -f2)
fi
echo " GPU PCI ID: ${GPU_VENDOR_ID}:${GPU_DEVICE_ID}"
echo "==> [GPU] Verifying ${GPU_FAMILY} instance types advertised"
G5_TYPES=$(aws_as_user ec2 describe-instance-types \
--query "InstanceTypes[?starts_with(InstanceType, \`${GPU_FAMILY}\`)].InstanceType" \
--output text 2>/dev/null || true)
if [[ -z "$G5_TYPES" ]]; then
echo "❌ No ${GPU_FAMILY}.* instance types returned by describe-instance-types"
exit 1
fi
echo " ${GPU_FAMILY} types available: $G5_TYPES"
fi
# --- Import AMI ---
# --- Import AMI ---
echo "==> Importing AMI"
LOCAL_IMAGE="$HOME/images/ubuntu-24.04.img"
LOCAL_IMAGE="$HOME/images/ubuntu-26.04.img"
ARCH=$(uname -m)
case "$ARCH" in
x86_64) IMG_ARCH="x86_64"; IMAGE_NAME="ubuntu-24.04-x86_64" ;;
aarch64|arm64) IMG_ARCH="arm64"; IMAGE_NAME="ubuntu-24.04-arm64" ;;
x86_64) IMG_ARCH="x86_64"; IMAGE_NAME="ubuntu-26.04-x86_64" ;;
aarch64|arm64) IMG_ARCH="arm64"; IMAGE_NAME="ubuntu-26.04-arm64" ;;
*)
echo " Warning: unknown arch $ARCH, defaulting to x86_64"
IMG_ARCH="x86_64"; IMAGE_NAME="ubuntu-24.04-x86_64"
IMG_ARCH="x86_64"; IMAGE_NAME="ubuntu-26.04-x86_64"
;;
esac
AMI_NAME="ami-${IMAGE_NAME}"
EXISTING_AMI_ID=$(aws ec2 describe-images \
EXISTING_AMI_ID=$(aws_as_user ec2 describe-images \
--query "Images[?Name=='${AMI_NAME}'] | [0].ImageId" \
--output text)
@@ -70,7 +126,7 @@ else
if [ -f "$LOCAL_IMAGE" ]; then
echo " Using local image: $LOCAL_IMAGE"
sudo /usr/local/bin/spx admin images import \
--file "$LOCAL_IMAGE" --distro ubuntu --version 24.04 --arch "$IMG_ARCH"
--file "$LOCAL_IMAGE" --distro ubuntu --version 26.04 --arch "$IMG_ARCH"
else
echo " Downloading image: $IMAGE_NAME"
sudo /usr/local/bin/spx admin images import --name "$IMAGE_NAME"
@@ -79,13 +135,15 @@ fi
# --- Launch smoke-test instance ---
echo "==> Launching smoke-test instance"
if grep -q 'AuthenticAMD' /proc/cpuinfo; then
if [[ "$TEST_GPU" == "1" ]]; then
INSTANCE_TYPE="${GPU_FAMILY}.xlarge"
elif grep -q 'AuthenticAMD' /proc/cpuinfo; then
INSTANCE_TYPE="t3a.small"
else
INSTANCE_TYPE="t3.small"
fi
AMI_ID=$(aws ec2 describe-images \
AMI_ID=$(aws_as_user ec2 describe-images \
--query "Images[?Name=='${AMI_NAME}'] | [0].ImageId" \
--output text)
@@ -94,7 +152,7 @@ if [ -z "$AMI_ID" ] || [ "$AMI_ID" = "None" ]; then
exit 1
fi
SUBNET_ID=$(aws ec2 describe-subnets --query 'Subnets[?MapPublicIpOnLaunch==`true`].SubnetId | [0]' --output text)
SUBNET_ID=$(aws_as_user ec2 describe-subnets --query 'Subnets[?MapPublicIpOnLaunch==`true`].SubnetId | [0]' --output text)
if [ -z "$SUBNET_ID" ] || [ "$SUBNET_ID" = "None" ]; then
echo "❌ No subnet found"
exit 1
@@ -102,7 +160,7 @@ fi
echo " AMI: $AMI_ID type: $INSTANCE_TYPE subnet: $SUBNET_ID"
INSTANCE_ID=$(aws ec2 run-instances \
INSTANCE_ID=$(aws_as_user ec2 run-instances \
--image-id "$AMI_ID" \
--instance-type "$INSTANCE_TYPE" \
--key-name spinifex-key \
@@ -121,7 +179,7 @@ echo "==> Waiting for instance to reach running state"
COUNT=0
STATE="unknown"
while [ $COUNT -lt 60 ]; do
DESCRIBE=$(aws ec2 describe-instances --instance-ids "$INSTANCE_ID" 2>/dev/null) || {
DESCRIBE=$(aws_as_user ec2 describe-instances --instance-ids "$INSTANCE_ID" 2>/dev/null) || {
sleep 2; COUNT=$((COUNT + 1)); continue
}
STATE=$(echo "$DESCRIBE" | jq -r '.Reservations[0].Instances[0].State.Name // "not-found"')
@@ -144,7 +202,7 @@ echo "==> Waiting for public IP assignment (up to 300s)"
SSH_INST_PORT=22
SSH_INST_HOST=""
for _i in $(seq 1 300); do
_ip=$(aws ec2 describe-instances --instance-ids "$INSTANCE_ID" \
_ip=$(aws_as_user ec2 describe-instances --instance-ids "$INSTANCE_ID" \
--query 'Reservations[0].Instances[0].PublicIpAddress' --output text 2>/dev/null || true)
if [ -n "$_ip" ] && [ "$_ip" != "None" ] && [ "$_ip" != "null" ]; then
SSH_INST_HOST="$_ip"
@@ -195,4 +253,27 @@ if ! echo "$SSH_OUT" | grep -q "ec2-user"; then
exit 1
fi
echo "✅ Smoke test passed — instance $INSTANCE_ID launched, running, and SSH-verified"
# --- GPU guest verification (TEST_GPU=1 only) ---
if [[ "$TEST_GPU" == "1" ]]; then
echo "==> [GPU] Verifying GPU visible inside guest"
GPU_VISIBLE=0
for _i in $(seq 1 12); do
if ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
-o ConnectTimeout=5 -o BatchMode=yes \
-p "$SSH_INST_PORT" -i "$SSH_KEY" \
ec2-user@"$SSH_INST_HOST" \
'lspci 2>/dev/null | grep -iE "nvidia|amdgpu|instinct"' 2>/dev/null; then
GPU_VISIBLE=1
break
fi
sleep 5
done
if [[ $GPU_VISIBLE -eq 0 ]]; then
echo "❌ GPU not visible in guest via lspci"
exit 1
fi
echo " GPU visible in guest"
echo "✅ Smoke test passed (GPU passthrough) — instance $INSTANCE_ID launched with GPU and verified"
else
echo "✅ Smoke test passed — instance $INSTANCE_ID launched, running, and SSH-verified"
fi
+11
View File
@@ -314,6 +314,17 @@ if has_service "predastore"; then
export SPINIFEX_PREDASTORE_TLS_CERT=$CONFIG_DIR/server.pem
export SPINIFEX_PREDASTORE_TLS_KEY=$CONFIG_DIR/server.key
# Per-node predastore encryption key. Self-heal for devs who skipped
# `spx admin init`: generate with umask 0177 so the file is 0600 from
# creation (predastore rejects group/other-readable keys outright).
ENCRYPTION_KEY="$CONFIG_DIR/predastore/encryption.key"
if [ ! -f "$ENCRYPTION_KEY" ]; then
mkdir -p "$CONFIG_DIR/predastore"
( umask 0177 && openssl rand -out "$ENCRYPTION_KEY" 32 )
echo " Generated predastore encryption key: $ENCRYPTION_KEY"
fi
export SPINIFEX_PREDASTORE_ENCRYPTION_KEY_FILE="$ENCRYPTION_KEY"
# Auto-detect Predastore host:port from spinifex.toml [nodes.<name>.predastore] section
PREDASTORE_BIND="0.0.0.0:8443"
if [ -f "$CONFIG_DIR/spinifex.toml" ]; then
+3 -3
View File
@@ -7,9 +7,9 @@ export AWS_PROFILE=spinifex
ARCH=$(uname -m)
case "$ARCH" in
x86_64) IMAGE_NAME="ami-ubuntu-24.04-x86_64" ;;
aarch64|arm64) IMAGE_NAME="ami-ubuntu-24.04-arm64" ;;
*) echo "Unknown arch $ARCH, default x86_64"; IMAGE_NAME="ami-ubuntu-24.04-x86_64" ;;
x86_64) IMAGE_NAME="ami-ubuntu-26.04-x86_64" ;;
aarch64|arm64) IMAGE_NAME="ami-ubuntu-26.04-arm64" ;;
*) echo "Unknown arch $ARCH, default x86_64"; IMAGE_NAME="ami-ubuntu-26.04-x86_64" ;;
esac
INSTANCE_TYPE=$(aws ec2 describe-instance-types \
+121
View File
@@ -0,0 +1,121 @@
#!/bin/bash
# test-ddil-phase1.sh — systemd-level smoke test for daemon-local-autonomy
# Tier 1 (1a / 1b / 1c / 1d / 1d-strict). Run after `make deploy` on a single
# node where spinifex-daemon and spinifex-nats are managed by systemd.
#
# Usage: scripts/test-ddil-phase1.sh
# Requires: sudo, curl, jq
#
# Destructive on the local node — stops/starts spinifex-nats and restarts
# spinifex-daemon. Do NOT run against a host carrying workloads. The cleanup
# trap restores both services on exit.
set -euo pipefail
DAEMON_URL="${DAEMON_URL:-https://127.0.0.1:4432}"
DAEMON_SVC=spinifex-daemon
NATS_SVC=spinifex-nats
OVERRIDE=/etc/systemd/system/${DAEMON_SVC}.service.d/99-test-ddil.conf
NATS_BLOCK=/run/spinifex-nats-blocked
NATS_BLOCK_DROPIN=/run/systemd/system/${NATS_SVC}.service.d/99-test-block.conf
CURL=(curl -fsS -k --max-time 5)
ok() { printf '\033[32mPASS\033[0m %s\n' "$1"; }
fail() { printf '\033[31mFAIL\033[0m %s\n' "$1"; exit 1; }
info() { printf '\033[36m==>\033[0m %s\n' "$1"; }
cleanup() {
info "cleanup: removing nats block, override, restoring services"
sudo rm -f "$NATS_BLOCK" "$NATS_BLOCK_DROPIN" "$OVERRIDE"
sudo rmdir "$(dirname "$NATS_BLOCK_DROPIN")" 2>/dev/null || true
sudo systemctl daemon-reload
sudo systemctl start "$NATS_SVC" 2>/dev/null || true
sudo systemctl restart "$DAEMON_SVC" 2>/dev/null || true
}
trap cleanup EXIT
field() { "${CURL[@]}" "${DAEMON_URL}/local/status" | jq -r ".$1 // empty"; }
wait_field() {
local f=$1 want=$2 timeout=${3:-30} elapsed=0
while [ "$elapsed" -lt "$timeout" ]; do
[ "$(field "$f" 2>/dev/null || true)" = "$want" ] && return 0
sleep 1; elapsed=$((elapsed + 1))
done
return 1
}
# 0. baseline
info "baseline: daemon active + cluster mode"
sudo systemctl is-active --quiet "$DAEMON_SVC" || fail "daemon not running — run \`make deploy\` first"
sudo systemctl start "$NATS_SVC"
wait_field mode cluster 30 || fail "never reached mode=cluster (got: $(field mode))"
[ "$(field nats)" = connected ] || fail "nats not connected (got: $(field nats))"
ok "mode=cluster nats=connected"
# 1. kill NATS → standalone, daemon survives
info "1a/1c: kill NATS"
retry_before=$(field nats_retry_count)
sudo systemctl stop "$NATS_SVC"
wait_field mode standalone 30 || fail "never went standalone (got: $(field mode))"
wait_field nats disconnected 5 || fail "nats not disconnected (got: $(field nats))"
sudo systemctl is-active --quiet "$DAEMON_SVC" || fail "daemon exited — should stay up"
ok "standalone, nats=disconnected, daemon alive"
# 2. /local/instances reachable during partition
info "1b: /local/instances during partition"
"${CURL[@]}" "${DAEMON_URL}/local/instances" | jq -e . >/dev/null \
|| fail "/local/instances did not return JSON"
ok "/local/instances served while NATS down"
# 3. restart NATS → cluster mode + retry count advances + KV push observed
# nats_retry_count counts disconnect→reconnect *cycles* (bumped from
# onNATSReconnect, daemon.go:321). It stays flat during disconnect and
# advances by 1 here when the reconnect callback fires.
info "1c reconnect: restart NATS"
before_sync=$(field last_kv_sync_at)
sudo systemctl start "$NATS_SVC"
wait_field mode cluster 60 || fail "did not return to cluster mode"
wait_field nats connected 10 || fail "nats not reconnected"
sleep 5
retry_after=$(field nats_retry_count)
[ "$retry_after" -gt "$retry_before" ] \
|| fail "nats_retry_count did not advance on reconnect ($retry_before → $retry_after)"
after_sync=$(field last_kv_sync_at)
[ -n "$after_sync" ] && [ "$after_sync" != "$before_sync" ] \
|| fail "last_kv_sync_at did not advance ($before_sync → $after_sync)"
ok "cluster restored, retry $retry_before → $retry_after, KV resync at $after_sync"
# 4. SPINIFEX_REQUIRE_NATS=1 → daemon exits within ~30s when NATS down.
# Block spinifex-nats via a runtime drop-in with ConditionPathExists=!. This
# defeats the daemon unit's Wants=spinifex-nats.service auto-start, which
# would otherwise pull NATS back up on `systemctl restart spinifex-daemon`
# and let the daemon connect immediately. mask refuses (real unit lives in
# /etc) and --runtime mask is shadowed by /etc — the condition drop-in is
# the only mechanism that works against an /etc-installed unit.
info "1d-strict: SPINIFEX_REQUIRE_NATS=1 exits when NATS down"
sudo mkdir -p "$(dirname "$NATS_BLOCK_DROPIN")"
sudo tee "$NATS_BLOCK_DROPIN" >/dev/null <<EOF
[Unit]
ConditionPathExists=!$NATS_BLOCK
EOF
sudo touch "$NATS_BLOCK"
sudo systemctl daemon-reload
sudo systemctl stop "$NATS_SVC"
sudo mkdir -p "$(dirname "$OVERRIDE")"
sudo tee "$OVERRIDE" >/dev/null <<EOF
[Service]
Environment=SPINIFEX_REQUIRE_NATS=1
Restart=no
EOF
sudo systemctl daemon-reload
sudo systemctl restart "$DAEMON_SVC" || true
start=$(date +%s)
while sudo systemctl is-active --quiet "$DAEMON_SVC"; do
[ $(( $(date +%s) - start )) -gt 45 ] && fail "did not exit within 45s"
sleep 1
done
ok "exited after $(( $(date +%s) - start ))s"
echo
echo "DDIL Phase 1 systemd integration: ALL PASS"
+2 -2
View File
@@ -5,8 +5,8 @@
sudo apt update
sudo apt install -y make gcc git
wget https://go.dev/dl/go1.26.2.linux-amd64.tar.gz
sudo tar -C /usr/local -xzf go1.26.2.linux-amd64.tar.gz
wget https://go.dev/dl/go1.26.3.linux-amd64.tar.gz
sudo tar -C /usr/local -xzf go1.26.3.linux-amd64.tar.gz
echo "export PATH=$PATH:/usr/local/go/bin" >> $HOME/.bashrc
source $HOME/.bashrc
+62 -2
View File
@@ -15,6 +15,7 @@ import (
"os"
"os/user"
"path/filepath"
"regexp"
"strconv"
"strings"
"text/template"
@@ -99,6 +100,10 @@ type ConfigSettings struct {
BootstrapIgwId string
BootstrapCidr string
BootstrapSubnetCidr string
// GPUPassthrough enables VFIO GPU passthrough in the daemon config.
// Sets gpu_passthrough = true under [nodes.<node>.daemon].
GPUPassthrough bool
}
// PredastoreNodeConfig describes a single Predastore node for multi-node config generation.
@@ -881,8 +886,8 @@ func SetupAWSCredentials(accessKey, secretKey, region, certPath, bindIP, wanIP s
// multi-node Predastore cluster. Each node gets its own DB entry (port 6660)
// and shard entry (port 9991) on a distinct IP. Node ID 1 is the bootstrap leader.
func GenerateMultiNodePredastoreConfig(templateStr string, nodes []PredastoreNodeConfig, accessKey, secretKey, region, natsToken, configDir, bindIP string) (string, error) {
if len(nodes) < 3 {
return "", fmt.Errorf("multi-node predastore requires at least 3 nodes, got %d", len(nodes))
if len(nodes) < 2 {
return "", fmt.Errorf("multi-node predastore requires at least 2 nodes, got %d", len(nodes))
}
data := struct {
@@ -931,3 +936,58 @@ func ParsePredastoreNodeIDFromConfig(tomlContent string, ip string) int {
}
return FindNodeIDByIP(cfg.DB, ip)
}
// SetGPUPassthrough idempotently writes gpu_passthrough = <enabled> for the
// given node into spinifex.toml, preserving all other content and comments.
// Returns nil without touching the file if the setting is already correct.
func SetGPUPassthrough(tomlPath, node string, enabled bool) error {
raw, err := os.ReadFile(tomlPath)
if err != nil {
return fmt.Errorf("read %s: %w", tomlPath, err)
}
text := string(raw)
value := "false"
if enabled {
value = "true"
}
sectionHeader := "[nodes." + node + ".daemon]"
sectionStart := strings.Index(text, sectionHeader)
if sectionStart < 0 {
// No daemon section — append one.
text = strings.TrimRight(text, "\n") +
"\n\n[nodes." + node + ".daemon]\ngpu_passthrough = " + value + "\n"
return os.WriteFile(tomlPath, []byte(text), 0640) //nolint:gosec // spinifex.toml is root:spinifex 0640 so the daemon can read it
}
// Extract body of just this section (stops at the next section header).
bodyStart := sectionStart + len(sectionHeader)
rest := text[bodyStart:]
nextSection := strings.Index(rest, "\n[")
var body, suffix string
if nextSection < 0 {
body = rest
} else {
body = rest[:nextSection]
suffix = rest[nextSection:]
}
// Already correct — no-op.
if regexp.MustCompile(`gpu_passthrough\s*=\s*` + value).MatchString(body) {
return nil
}
// Key exists with wrong value — flip within this section only.
flipRe := regexp.MustCompile(`gpu_passthrough\s*=\s*(?:true|false)`)
var newBody string
if flipRe.MatchString(body) {
newBody = flipRe.ReplaceAllString(body, "gpu_passthrough = "+value)
} else {
// Key absent — insert right after section header.
newBody = "\ngpu_passthrough = " + value + body
}
text = text[:bodyStart] + newBody + suffix
return os.WriteFile(tomlPath, []byte(text), 0640) //nolint:gosec // spinifex.toml is root:spinifex 0640 so the daemon can read it
}
+70 -2
View File
@@ -738,10 +738,9 @@ func TestGenerateMultiNodePredastoreConfig_MinimumNodes(t *testing.T) {
_, err := GenerateMultiNodePredastoreConfig(tmpl, []PredastoreNodeConfig{
{ID: 1, Host: "10.0.0.1"},
{ID: 2, Host: "10.0.0.2"},
}, "AK", "SK", "us-east-1", "nats-token", "/config", "10.0.0.1")
assert.Error(t, err)
assert.Contains(t, err.Error(), "at least 3 nodes")
assert.Contains(t, err.Error(), "at least 2 nodes")
}
func TestGenerateMultiNodePredastoreConfig_InvalidTemplate(t *testing.T) {
@@ -862,3 +861,72 @@ func TestChownRecursive_NonExistentPath(t *testing.T) {
}
ChownRecursive("/tmp/nonexistent-path-12345", currentUser)
}
// --- SetGPUPassthrough ---
func writeToml(t *testing.T, content string) string {
t.Helper()
f, err := os.CreateTemp(t.TempDir(), "spinifex*.toml")
require.NoError(t, err)
_, err = f.WriteString(content)
require.NoError(t, err)
require.NoError(t, f.Close())
return f.Name()
}
func readToml(t *testing.T, path string) string {
t.Helper()
b, err := os.ReadFile(path)
require.NoError(t, err)
return string(b)
}
func TestSetGPUPassthrough_NoOp(t *testing.T) {
toml := "[nodes.node1.daemon]\ngpu_passthrough = true\n"
path := writeToml(t, toml)
require.NoError(t, SetGPUPassthrough(path, "node1", true))
assert.Equal(t, toml, readToml(t, path))
}
func TestSetGPUPassthrough_Flip(t *testing.T) {
path := writeToml(t, "[nodes.node1.daemon]\ngpu_passthrough = false\n")
require.NoError(t, SetGPUPassthrough(path, "node1", true))
assert.Contains(t, readToml(t, path), "gpu_passthrough = true")
}
func TestSetGPUPassthrough_AddKeyToSection(t *testing.T) {
path := writeToml(t, "[nodes.node1.daemon]\nsome_other = true\n")
require.NoError(t, SetGPUPassthrough(path, "node1", true))
got := readToml(t, path)
assert.Contains(t, got, "gpu_passthrough = true")
assert.Contains(t, got, "some_other = true")
}
func TestSetGPUPassthrough_AppendSection(t *testing.T) {
path := writeToml(t, "[nodes.node1.network]\ncidr = \"10.0.0.0/24\"\n")
require.NoError(t, SetGPUPassthrough(path, "node1", true))
got := readToml(t, path)
assert.Contains(t, got, "[nodes.node1.daemon]")
assert.Contains(t, got, "gpu_passthrough = true")
}
func TestSetGPUPassthrough_DisableNoOp(t *testing.T) {
toml := "[nodes.node1.daemon]\ngpu_passthrough = false\n"
path := writeToml(t, toml)
require.NoError(t, SetGPUPassthrough(path, "node1", false))
assert.Equal(t, toml, readToml(t, path))
}
func TestSetGPUPassthrough_ReadError(t *testing.T) {
err := SetGPUPassthrough("/nonexistent/path/spinifex.toml", "node1", true)
require.Error(t, err)
}
func TestSetGPUPassthrough_SectionBoundary(t *testing.T) {
// gpu_passthrough = true exists but in a DIFFERENT node's section; should still write.
path := writeToml(t, "[nodes.node2.daemon]\ngpu_passthrough = true\n[nodes.node1.daemon]\nsome_key = 1\n")
require.NoError(t, SetGPUPassthrough(path, "node1", true))
got := readToml(t, path)
// node1 section should now have the key
assert.Contains(t, got, "[nodes.node1.daemon]\ngpu_passthrough = true")
}
+35 -461
View File
@@ -2,473 +2,47 @@ package awserrors
import "testing"
func TestErrorLookup(t *testing.T) {
expected := []struct {
code string
http int
message string
}{
{code: "AccountDisabled", http: 400, message: "The functionality you have requested has been administratively disabled for this account."},
{code: "ActiveVpcPeeringConnectionPerVpcLimitExceeded", http: 400, message: "You've reached the limit on the number of active VPC peering connections you can have for the specified VPC."},
{code: "AddressLimitExceeded", http: 400, message: "You've reached the limit on the number of Elastic IP addresses that you can allocate. For more information, see Elastic IP address limit."},
{code: "AsnConflict", http: 400, message: "The Autonomous System Numbers (ASNs) of the specified customer gateway and the specified virtual private gateway are the same."},
{code: "AttachmentLimitExceeded", http: 400, message: "You've reached the limit on the number of Amazon EBS volumes or network interfaces that can be attached to a single instance. The number of Amazon EBS volumes that you can attach to an instance depends on the instance type. For more information, see Amazon EBS volume limits for Amazon EC2 instances in the Amazon EC2 User Guide."},
{code: "AuthFailure", http: 403, message: "The provided credentials could not be validated. You might not be authorized to carry out the request; for example, trying to associate an Elastic IP address that is not yours, or trying to use an AMI for which you do not have permissions. Ensure that your account is authorized to use Amazon EC2, that your credit card details are correct, and that you are using the correct credentials."},
{code: "BandwidthLimitExceeded", http: 500, message: "You've reached the limit on the network bandwidth that is available to an Amazon EC2 instance. For more information, see Amazon EC2 instance network bandwidth."},
{code: "Blocked", http: 403, message: "Your account is currently blocked. Contact Support if you have questions."},
{code: "BootForVolumeTypeUnsupported", http: 400, message: "The specified volume type cannot be used as a boot volume. For more information, see Amazon EBS volume types."},
{code: "BundlingInProgress", http: 400, message: "The specified instance already has a bundling task in progress."},
{code: "CannotDelete", http: 400, message: "You cannot delete the 'default' security group in your VPC, but you can change its rules. For more information, see Amazon EC2 security groups."},
{code: "CapacityBlockDescribeLimitExceeded", http: 400, message: "You've reached the limit for this account. The returned message provides details."},
{code: "ClientInvalidParameterValue", http: 400, message: "A parameter specified in a request is not valid, is unsupported, or cannot be used. The returned message provides an explanation of the error value. For example, if you are launching an instance, you can't specify a security group and subnet that are in different VPCs."},
{code: "ClientVpnAuthorizationRuleLimitExceeded", http: 400, message: "You've reached the limit on the number of authorization rules that can be added to a single Client VPN endpoint."},
{code: "ClientVpnCertificateRevocationListLimitExceeded", http: 400, message: "You've reached the limit on the number of client certificate revocation lists that can be added to a single Client VPN endpoint."},
{code: "ClientVpnEndpointAssociationExists", http: 409, message: "The specified target network is already associated with the Client VPN endpoint."},
{code: "ClientVpnEndpointLimitExceeded", http: 400, message: "You've reached the limit on the number of Client VPN endpoints that you can create."},
{code: "ClientVpnRouteLimitExceeded", http: 400, message: "You've reached the limit on the number of routes that can be added to a single Client VPN endpoint."},
{code: "ClientVpnTerminateConnectionsLimitExceeded", http: 400, message: "The number of client connections you're attempting to terminate exceeds the limit."},
{code: "ConcurrentCreateImageNoRebootLimitExceeded", http: 400, message: "The maximum number of concurrent CreateImage requests for the instance has been reached. Wait for the current CreateImage requests to complete, and then retry your request."},
{code: "ConcurrentSnapshotLimitExceeded", http: 409, message: "You've reached the limit on the number of concurrent snapshots you can create on the specified volume. Wait until the 'pending' requests have completed, and check that you do not have snapshots that are in an incomplete state, such as 'error', which count against your concurrent snapshot limit."},
{code: "ConcurrentTagAccess", http: 400, message: "You can't run simultaneous commands to modify a tag for a specific resource. Allow sufficient wait time for the previous request to complete, then retry your request."},
{code: "CreditSpecificationUpdateInProgress", http: 400, message: "The default credit specification for the instance family is currently being updated. It takes about five minutes to complete. For more information, see Set the default credit specification for the account."},
{code: "CustomerGatewayLimitExceeded", http: 400, message: "You've reached the limit on the number of customer gateways you can create for the AWS Region. For more information, see Amazon VPC quotas."},
{code: "CustomerKeyHasBeenRevoked", http: 400, message: "The KMS key cannot be accessed. For more information, see Amazon EBS encryption."},
{code: "DeclarativePoliciesAccessDeniedException", http: 404, message: "You do not have sufficient access to perform this action, or the specified TargetId does not exist, or the specified TargetId is not in your organization. To generate an account status report for declarative policies, the caller must be the management account or a delegated administrator for the organization and the specified TargetId must belong to your organization."},
{code: "DeclarativePoliciesNotEnabledException", http: 400, message: "Trusted access is not enabled. Trusted access must be enabled for the service for which the declarative policy will enforce a baseline configuration. The API uses the following service principal to identify the EC2 service: ec2.amazonaws.com. For more information on how to enable trusted access with the AWS CLI and AWS SDKs, see Using Organizations with other AWS services."},
{code: "DefaultSubnetAlreadyExistsInAvailabilityZone", http: 409, message: "A default subnet already exists in the specified Availability Zone. You can have only one default subnet per Availability Zone."},
{code: "DefaultVpcAlreadyExists", http: 409, message: "A default VPC already exists in the AWS Region. You can only have one default VPC per Region."},
{code: "DefaultVpcDoesNotExist", http: 400, message: "There is no default VPC in which to carry out the request. If you've deleted your default VPC, you can create a new one. For more information, see Create a default VPC."},
{code: "DeleteConversionTaskError", http: 400, message: "The conversion task cannot be canceled."},
{code: "DependencyViolation", http: 400, message: "The specified object has dependent resources. A number of resources in a VPC may have dependent resources, which prevent you from deleting or detaching them. Remove the dependencies first, then retry your request. For example, this error occurs if you try to delete a security group in a VPC that is in use by another security group."},
{code: "DiskImageSizeTooLarge", http: 400, message: "The disk image exceeds the allowed limit (for instance or volume import)."},
{code: "DryRunOperation", http: 412, message: "The user has the required permissions, so the request would have succeeded, but the DryRun parameter was used."},
{code: "DuplicateSubnetsInSameZone", http: 400, message: "For an interface VPC endpoint, you can specify only one subnet per Availability Zone."},
{code: "EncryptedVolumesNotSupported", http: 400, message: "Encrypted Amazon EBS volumes may only be attached to instances that support Amazon EBS encryption. For more information, see Amazon EBS encryption."},
{code: "ExistingVpcEndpointConnections", http: 400, message: "You cannot delete a VPC endpoint service configuration or change the load balancers for the endpoint service if there are endpoints attached to the service."},
{code: "FilterLimitExceeded", http: 400, message: "The request uses too many filters or too many filter values."},
{code: "FleetNotInModifiableState", http: 400, message: "The Spot Fleet request must be in the active state in order to modify it. For more information, see Spot Fleet request types."},
{code: "FlowLogAlreadyExists", http: 409, message: "A flow log with the specified configuration already exists."},
{code: "FlowLogsLimitExceeded", http: 400, message: "You've reached the limit on the number of flow logs you can create. For more information, see Amazon VPC quotas."},
{code: "Gateway.NotAttached", http: 400, message: "An internet gateway is not attached to a VPC. If you are trying to detach an internet gateway, ensure that you specify the correct VPC. If you are trying to associate an Elastic IP address with a network interface or an instance, ensure that an internet gateway is attached to the relevant VPC."},
{code: "HostAlreadyCoveredByReservation", http: 400, message: "The specified Dedicated Host is already covered by a reservation."},
{code: "HostLimitExceeded", http: 400, message: "You've reached the limit on the number of Dedicated Hosts that you can allocate. For more information, see Dedicated Hosts."},
{code: "IdempotentInstanceTerminated", http: 400, message: "The request to launch an instance uses the same client token as a previous request for which the instance has been terminated."},
{code: "IdempotentParameterMismatch", http: 400, message: "The request uses the same client token as a previous, but non-identical request. Do not reuse a client token with different requests, unless the requests are identical."},
{code: "InaccessibleStorageLocation", http: 400, message: "The specified Amazon S3 URL cannot be accessed. Check the access permissions for the URL."},
{code: "InaccessibleStorageLocationException", http: 400, message: "The specified Amazon S3 bucket can't be accessed. An S3 bucket must be available before generating an account status report for declarative policies (you can create a new one or use an existing one), you must own the bucket, it must be in the same Region in which the request was made, and it must have an appropriate bucket policy. For a sample S3 policy, see Sample Amazon S3 policy under Examples."},
{code: "IncompatibleHostRequirements", http: 400, message: "There are no available or compatible Dedicated Hosts available on which to launch or start the instance."},
{code: "IncompleteSignature", http: 400, message: "The request signature does not conform to AWS standards."},
{code: "IncorrectInstanceState", http: 409, message: "The instance is in an incorrect state for the requested action. For example, some instance attributes, such as user data, can only be modified if the instance is in a 'stopped' state. If you are associating an Elastic IP address with a network interface, ensure that the instance that the interface is attached to is not in the 'pending' state."},
{code: "IncorrectModificationState", http: 400, message: "A new modification action on an EBS Elastic Volume cannot occur because the volume is currently being modified."},
{code: "IncorrectSpotRequestState", http: 400, message: "The Spot Instance request is in an incorrect state for the request. Spot request status information can help you track your Amazon EC2 Spot Instance requests. For more information, see Spot request status."},
{code: "IncorrectState", http: 400, message: "The resource is in an incorrect state for the request. This error can occur if you are trying to attach a volume that is still being created or detach a volume that is not in the 'available' state. Verify that the volume is in the 'available' state. If you are creating a snapshot, ensure that the previous request to create a snapshot on the same volume has completed. If you are deleting a virtual private gateway, ensure that it's detached from the VPC."},
{code: "IncorrectStateException", http: 400, message: "The resource is in an incorrect state for the request. This error can occur if you're trying to cancel the generation of an account status report for declarative policies that already has the complete, cancelled, or error status. Cancelation is only possible for reports with a running status. This error can also occur when you're trying to get a summary for a report that has not yet reached the completed status. This error can also occur if you start report generation while another report is being generated. Only one report per organization can be generated at a time. For more information, see Generating the account status report for declarative policies."},
{code: "InstanceCreditSpecification.NotSupported", http: 400, message: "The specified instance does not use CPU credits for CPU usage; only T2 instances use CPU credits for CPU usage."},
{code: "InstanceEventStartTimeCannotChange", http: 400, message: "The specified scheduled event start time does not meet the requirements for rescheduling a scheduled event. For more information, see Limitations."},
{code: "InstanceLimitExceeded", http: 400, message: "You've reached the limit on the number of instances you can run concurrently. This error can occur if you are launching an instance or if you are creating a Capacity Reservation. Capacity Reservations count towards your On-Demand Instance limits. If your request fails due to limit constraints, increase your On-Demand Instance limit for the required instance type and try again. For more information, see EC2 On-Demand instance limits."},
{code: "InstanceTpmEkPubNotFound", http: 400, message: "The public Trusted Platform Module (TPM) Endorsement Key (EK) cannot be found."},
{code: "InsufficientAddressCapacity", http: 503, message: "Not enough available addresses to satisfy your minimum request. Reduce the number of addresses you are requesting or wait for additional capacity to become available."},
{code: "InsufficientCapacity", http: 503, message: "There is not enough capacity to fulfill your import instance request. You can wait for additional capacity to become available."},
{code: "InsufficientCapacityOnHost", http: 400, message: "There is not enough capacity on the Dedicated Host to launch or start the instance."},
{code: "InsufficientFreeAddressesInSubnet", http: 503, message: "The specified subnet does not contain enough free private IP addresses to fulfill your request. Use the DescribeSubnets request to view how many IP addresses are available (unused) in your subnet. IP addresses associated with stopped instances are considered unavailable."},
{code: "InsufficientHostCapacity", http: 503, message: "There is not enough capacity to fulfill your Dedicated Host request. Reduce the number of Dedicated Hosts in your request, or wait for additional capacity to become available."},
{code: "InsufficientInstanceCapacity", http: 400, message: "There is not enough capacity to fulfill your request. This error can occur if you launch a new instance, restart a stopped instance, create a new Capacity Reservation, or modify an existing Capacity Reservation. Reduce the number of instances in your request, or wait for additional capacity to become available. You can also try launching an instance by selecting different instance types (which you can resize at a later stage). The returned message might also give specific guidance about how to solve the problem."},
{code: "InsufficientReservedInstanceCapacity", http: 503, message: "Not enough available Reserved Instances to satisfy your minimum request. Reduce the number of Reserved Instances in your request or wait for additional capacity to become available."},
{code: "InsufficientReservedInstancesCapacity", http: 400, message: "There is insufficient capacity for the requested Reserved Instances."},
{code: "InsufficientVolumeCapacity", http: 503, message: "There is not enough capacity to fulfill your EBS volume provision request. You can try to provision a different volume type, EBS volume in a different availability zone, or you can wait for additional capacity to become available."},
{code: "InterfaceInUseByTrafficMirrorSession", http: 409, message: "The Traffic Mirror source that you are trying to create uses an interface that is already associated with a session. An interface can only be associated with a session, or with a target, but not both."},
{code: "InterfaceInUseByTrafficMirrorTarget", http: 409, message: "The Traffic Mirror source that you are trying to create uses an interface that is already associated with a target. An interface can only be associated with a session, or with a target, but not both. If the interface is associated with a target, it cannot be associated with another target."},
{code: "InternalError", http: 500, message: "An internal error has occurred. Retry your request, but if the problem persists, contact us with details by posting a message on AWS re:Post."},
{code: "InternalFailure", http: 404, message: "The request processing has failed because of an unknown error, exception, or failure."},
{code: "InternetGatewayLimitExceeded", http: 400, message: "You've reached the limit on the number of internet gateways that you can create. For more information, see Amazon VPC quotas."},
{code: "InvalidAMIAttributeItemValue", http: 400, message: "The value of an item added to, or removed from, an image attribute is not valid. If you are specifying a userId, check that it is in the form of an AWS account ID, without hyphens."},
{code: "InvalidAMIID.Malformed", http: 400, message: "The specified AMI ID is malformed. Ensure that you provide the full AMI ID, in the form ami-xxxxxxxxxxxxxxxxx."},
{code: "InvalidAMIID.NotFound", http: 400, message: "The specified AMI doesn't exist. Check the AMI ID, and ensure that you specify the AWS Region in which the AMI is located, if it's not in the default Region. This error might also occur if you specified an incorrect kernel ID when launching an instance. This error might also occur if the AMI doesn't meet your Allowed AMIs criteria. To use this AMI, you must change the Allowed AMIs criteria. For more information, see Control the discovery and use of AMIs in Amazon EC2 with Allowed AMIs."},
{code: "InvalidAMIID.Unavailable", http: 400, message: "The specified AMI has been deregistered and is no longer available, or is not in a state from which you can launch an instance or modify attributes."},
{code: "InvalidAMIName.Duplicate", http: 409, message: "The specified AMI name is already in use by another AMI. If you have recently deregistered an AMI with the same name, allow enough time for the change to propagate through the system, and retry your request."},
{code: "InvalidAMIName.Malformed", http: 400, message: "AMI names must be between 3 and 128 characters long, and may only contain letters, numbers, and the following special characters: '-', '_', '.', '/', '(', and ')'."},
{code: "InvalidAction", http: 400, message: "The action or operation requested is not valid. Verify that the action is typed correctly."},
{code: "InvalidAddress.Locked", http: 400, message: "The specified Elastic IP address cannot be released from your account. A reverse DNS record may be associated with the Elastic IP address. To unlock the address, contact Support."},
{code: "InvalidAddress.Malformed", http: 400, message: "The specified IP address is not valid. Ensure that you provide the address in the form xx.xx.xx.xx; for example, 55.123.45.67"},
{code: "InvalidAddress.NotFound", http: 400, message: "The specified Elastic IP address that you are describing cannot be found. Ensure that you specify the AWS Region in which the IP address is located, if it's not in the default Region."},
{code: "InvalidAddressID.NotFound", http: 400, message: "The specified allocation ID for the Elastic IP address you are trying to release cannot be found. Ensure that you specify the AWS Region in which the IP address is located, if it's not in the default Region."},
{code: "InvalidAffinity", http: 400, message: "The specified affinity value is not valid."},
{code: "InvalidAllocationID.NotFound", http: 404, message: "The specified allocation ID you are trying to describe or associate does not exist. Ensure that you specify the AWS Region in which the IP address is located, if it's not in the default Region."},
{code: "InvalidAssociationID.NotFound", http: 404, message: "The specified association ID (for an Elastic IP address, a route table, or network ACL) does not exist. Ensure that you specify the AWS Region in which the association ID is located, if it's not in the default Region."},
{code: "InvalidAttachment.NotFound", http: 400, message: "Indicates an attempt to detach a volume from an instance to which it is not attached."},
{code: "InvalidAttachmentID.NotFound", http: 404, message: "The specified network interface attachment does not exist."},
{code: "InvalidAutoPlacement", http: 400, message: "The specified value for auto-placement is not valid."},
{code: "InvalidAvailabilityZone", http: 400, message: "The specified Availability Zone is not valid."},
{code: "InvalidBlockDeviceMapping", http: 400, message: "A block device mapping parameter is not valid. The returned message indicates the incorrect value."},
{code: "InvalidBundleID.NotFound", http: 400, message: "The specified bundle task ID cannot be found. Ensure that you specify the AWS Region in which the bundle task is located, if it's not in the default Region."},
{code: "InvalidCapacityBlockOfferingIdExpired", http: 400, message: "The Capacity Block offering ID is no longer available."},
{code: "InvalidCapacityBlockOfferingIdMalformed", http: 400, message: "The Capacity Block offering ID is malformed."},
{code: "InvalidCapacityBlockOfferingIdNotFound", http: 400, message: "The Capacity Block offering ID cannot be found for this account."},
{code: "InvalidCapacityReservationId.Malformed", http: 400, message: "The ID for the Capacity Reservation is malformed. Ensure that you specify the Capacity Reservation ID in the form cr-xxxxxxxxxxxxxxxxx."},
{code: "InvalidCapacityReservationId.NotFound", http: 404, message: "The specified Capacity Reservation ID does not exist."},
{code: "InvalidCapacityReservationState.PendingActivation", http: 400, message: "Your Capacity Block is not active yet."},
{code: "InvalidCarrierGatewayID.NotFound", http: 400, message: "The specified carrier gateway ID cannot be found. Ensure that you specify the AWS Region in which the carrier gateway is located, if it's not in the default Region."},
{code: "InvalidCharacter", http: 400, message: "A specified character is invalid."},
{code: "InvalidCidr.InUse", http: 409, message: "The specified inside tunnel CIDR is already in use by another VPN tunnel for the virtual private gateway."},
{code: "InvalidClientToken", http: 400, message: "The specified client token is not valid. For more information, see Ensuring idempotency."},
{code: "InvalidClientTokenId", http: 403, message: "The X.509 certificate or credentials provided do not exist in our records."},
{code: "InvalidClientVpnActiveAssociationNotFound", http: 409, message: "You cannot perform this action on the Client VPN endpoint while it is in the pending-association state."},
{code: "InvalidClientVpnAssociationIdNotFound", http: 400, message: "The specified target network association cannot be found."},
{code: "InvalidClientVpnConnection.IdNotFound", http: 400, message: "The specified Client VPN endpoint cannot be found."},
{code: "InvalidClientVpnConnection.UserNotFound", http: 400, message: "The specified user does not have an active connection to the specified Client VPN endpoint."},
{code: "InvalidClientVpnDuplicateAssociationException", http: 400, message: "The specified target network has already been associated with the Client VPN endpoint."},
{code: "InvalidClientVpnDuplicateAuthorizationRule", http: 400, message: "The specified authorization has already been added to the Client VPN endpoint."},
{code: "InvalidClientVpnDuplicateRoute", http: 400, message: "The specified route has already been added to the Client VPN endpoint."},
{code: "InvalidClientVpnEndpointAuthorizationRuleNotFound", http: 400, message: "The specified authorization rule cannot be found."},
{code: "InvalidClientVpnEndpointId.NotFound", http: 400, message: "The specified Client VPN Endpoint cannot be found."},
{code: "InvalidClientVpnRouteNotFound", http: 400, message: "The specified route cannot be found."},
{code: "InvalidClientVpnSubnetId.DifferentAccount", http: 400, message: "The specified subnet belongs to a different account."},
{code: "InvalidClientVpnSubnetId.DuplicateAz", http: 409, message: "You have already associated a subnet from this Availability Zone with the Client VPN endpoint."},
{code: "InvalidClientVpnSubnetId.NotFound", http: 400, message: "The specified subnet cannot be found in the VPN with which the Client VPN endpoint is associated."},
{code: "InvalidClientVpnSubnetId.OverlappingCidr", http: 400, message: "The specified target network's CIDR range overlaps with the Client VPN endpoint's client CIDR range."},
{code: "InvalidConversionTaskId", http: 400, message: "The specified conversion task ID (for instance or volume import) is not valid."},
{code: "InvalidConversionTaskId.Malformed", http: 400, message: "The specified conversion task ID (for instance or volume import) is malformed. Ensure that you've specified the ID in the form import-i-xxxxxxxx."},
{code: "InvalidCpuCredits", http: 400, message: "The specified CpuCredit value is invalid. Valid values are standard and unlimited."},
{code: "InvalidCpuCredits.Malformed", http: 400, message: "The specified CpuCredit value is invalid. Valid values are standard and unlimited."},
{code: "InvalidCustomerGateway.DuplicateIpAddress", http: 409, message: "There is a conflict among the specified gateway IP addresses. Each VPN connection in an AWS Region must be created with a unique customer gateway IP address (across all AWS accounts). For more information, see Your customer gateway device in the AWS Site-to-Site VPN User Guide."},
{code: "InvalidCustomerGatewayID.NotFound", http: 400, message: "The specified customer gateway ID cannot be found. Ensure that you specify the AWS Region in which the customer gateway is located, if it's not in the default Region."},
{code: "InvalidCustomerGatewayId.Malformed", http: 400, message: "The specified customer gateway ID is malformed, or cannot be found. Specify the ID in the form cgw-xxxxxxxx, and ensure that you specify the AWS Region in which the customer gateway is located, if it's not in the default Region."},
{code: "InvalidCustomerGatewayState", http: 400, message: "The customer gateway is not in the available state, and therefore cannot be used."},
{code: "InvalidDeclarativePoliciesReportId.Malformed", http: 400, message: "The specified account status report ID for declarative policies is malformed. Ensure that you specify the ID in the form p-xxxxxxxxxx. For more information, see Generating the account status report for declarative policies."},
{code: "InvalidDhcpOptionID.NotFound", http: 404, message: "The specified DHCP options set does not exist. Ensure that you specify the AWS Region in which the DHCP options set is located, if it's not in the default Region."},
{code: "InvalidDhcpOptionsID.NotFound", http: 404, message: "The specified DHCP options set does not exist. Ensure that you specify the AWS Region in which the DHCP options set is located, if it's not in the default Region."},
{code: "InvalidDhcpOptionsId.Malformed", http: 400, message: "The specified DHCP options set ID is malformed. Ensure that you provide the full DHCP options set ID in the request, in the form dopt-xxxxxxxx."},
{code: "InvalidEgressOnlyInternetGatewayId.Malformed", http: 400, message: "The specified egress-only internet gateway ID is malformed. Ensure that you specify the ID in the form eigw-xxxxxxxxxxxxxxxxx."},
{code: "InvalidEgressOnlyInternetGatewayId.NotFound", http: 404, message: "The specified egress-only internet gateway does not exist."},
{code: "InvalidElasticGpuID.Malformed", http: 400, message: "The specified Elastic GPU ID is malformed. Ensure that you specify the ID in the form egpu-xxxxxxxxxxxxxxxxx."},
{code: "InvalidElasticGpuID.NotFound", http: 404, message: "The specified Elastic GPU does not exist."},
{code: "InvalidExportTaskID.Malformed", http: 400, message: "The specified export task ID cannot be found."},
{code: "InvalidExportTaskID.NotFound", http: 400, message: "The specified export task ID is malformed. Ensure that you specify the ID in the form export-ami-xxxxxxxxxxxxxxxxx."},
{code: "InvalidFilter", http: 400, message: "The specified filter is not valid."},
{code: "InvalidFlowLogId.NotFound", http: 404, message: "The specified flow log does not exist."},
{code: "InvalidFormat", http: 400, message: "The specified disk format (for the instance or volume import) is not valid."},
{code: "InvalidFpgaImageID.Malformed", http: 400, message: "The specified Amazon FPGA image (AFI) ID is malformed. Ensure that you provide the full AFI ID in the request, in the form afi-xxxxxxxxxxxxxxxxx."},
{code: "InvalidFpgaImageID.NotFound", http: 404, message: "The specified Amazon FPGA image (AFI) ID does not exist. Ensure that you specify the AWS Region in which the AFI is located, if it's not in the default Region."},
{code: "InvalidGatewayID.NotFound", http: 404, message: "The specified gateway does not exist."},
{code: "InvalidGroup.Duplicate", http: 400, message: "You cannot create a security group with the same name as an existing security group in the same VPC."},
{code: "InvalidGroup.InUse", http: 409, message: "The specified security group can't be deleted because it's in use by another security group. You can remove dependencies by modifying or deleting rules in the affected security groups."},
{code: "InvalidGroup.NotFound", http: 404, message: "The specified security group does not exist. This error can occur because the ID of a recently created security group has not propagated through the system. For more information, see Ensuring idempotency. You can't specify a security group that is in a different AWS Region or VPC than the request."},
{code: "InvalidGroup.Reserved", http: 400, message: "The name 'default' is reserved, and cannot be used to create a new security group."},
{code: "InvalidGroupId.Malformed", http: 400, message: "The specified security group ID is malformed. Ensure that you provide the full security group ID in the request, in the form sg-xxxxxxxxxxxxxxxxx."},
{code: "InvalidHostConfiguration", http: 400, message: "The specified Dedicated Host configuration is not supported."},
{code: "InvalidHostID.Malformed", http: 400, message: "The specified Dedicated Host ID is not formed correctly. Ensure that you provide the full ID in the form h-xxxxxxxxxxxxxxxxx."},
{code: "InvalidHostID.NotFound", http: 404, message: "The specified Dedicated Host ID does not exist. Ensure that you specify the AWS Region in which the Dedicated Host is located, if it's not in the default Region."},
{code: "InvalidHostId", http: 400, message: "The specified Dedicated Host ID is not valid."},
{code: "InvalidHostId.Malformed", http: 400, message: "The specified Dedicated Host ID is not formed correctly. Ensure that you provide the full ID in the form h-xxxxxxxxxxxxxxxxx."},
{code: "InvalidHostId.NotFound", http: 404, message: "The specified Dedicated Host ID does not exist. Ensure that you specify the region in which the Dedicated Host is located, if it's not in the default region."},
{code: "InvalidHostReservationId.Malformed", http: 400, message: "The specified Dedicated Host Reservation ID is not formed correctly. Ensure that you provide the full ID in the form hr-xxxxxxxxxxxxxxxxx."},
{code: "InvalidHostReservationOfferingId.Malformed", http: 400, message: "The specified Dedicated Host Reservation offering is not formed correctly. Ensure that you provide the full ID in the form hro-xxxxxxxxxxxxxxxxx."},
{code: "InvalidHostState", http: 400, message: "The Dedicated Host must be in the available state to complete the operation."},
{code: "InvalidID", http: 400, message: "The specified ID for the resource you are trying to tag is not valid. Ensure that you provide the full resource ID; for example, ami-2bb65342 for an AMI. If you're using the command line tools on a Windows system, you might need to use quotation marks for the key-value pair; for example, \"Name=TestTag\"."},
{code: "InvalidIPAddress.InUse", http: 409, message: "The specified IP address is already in use. If you are trying to release an address, you must first disassociate it from the instance."},
{code: "InvalidIamInstanceProfileArn.Malformed", http: 400, message: "The specified IAM instance profile ARN is not valid. For more information about valid ARN formats, see Amazon Resource Names (ARNs)."},
{code: "InvalidInput", http: 400, message: "An input parameter in the request is not valid. For example, you may have specified an incorrect Reserved Instance listing ID in the request or the Reserved Instance you tried to list cannot be sold in the Reserved Instances Marketplace (for example, if it has a scope of Region, or is a Convertible Reserved Instance)."},
{code: "InvalidInstanceAttributeValue", http: 400, message: "The specified instance attribute value is not valid. This error is most commonly encountered when trying to set the InstanceType/--instance-type attribute to an unrecognized value."},
{code: "InvalidInstanceConnectEndpointId.Malformed", http: 400, message: "The specified EC2 Instance Connect Endpoint ID is malformed. Ensure that you specify the ID in the form eice-xxxxxxxxxxxxxxxxx."},
{code: "InvalidInstanceConnectEndpointId.NotFound", http: 404, message: "The specified EC2 Instance Connect Endpoint does not exist."},
{code: "InvalidInstanceCreditSpecification", http: 409, message: "If you are modifying the credit option for CPU usage for T2 instances, the request may not contain duplicate instance IDs."},
{code: "InvalidInstanceCreditSpecification.DuplicateInstanceId", http: 409, message: "If you are modifying the credit option for CPU usage for T2 instances, the request may not contain duplicate instance IDs."},
{code: "InvalidInstanceEventIDNotFound", http: 400, message: "The specified ID of the event whose date and time you are modifying cannot be found. Verify the ID of the event and try your request again."},
{code: "InvalidInstanceEventStartTime", http: 400, message: "The specified scheduled event start time does not meet the requirements for rescheduling a scheduled event. For more information, see Limitations."},
{code: "InvalidInstanceFamily", http: 400, message: "The instance family is not supported for this request. For example, the instance family for the Dedicated Host Reservation offering is different from the instance family of the Dedicated Hosts. Or, you can only modify the default credit specification for burstable performance instance families (T2, T3, and T3a). For more information, see Set the default credit specification for the account."},
{code: "InvalidInstanceID", http: 400, message: "This error can occur when trying to perform an operation on an instance that has multiple network interfaces. A network interface can have individual attributes; therefore, you may need to specify the network interface ID as part of the request, or use a different request. For example, each network interface in an instance can have a source/destination check flag. To modify this attribute, modify the network interface attribute, and not the instance attribute. To create a route in a route table, provide a specific network interface ID as part of the request."},
{code: "InvalidInstanceID.Malformed", http: 400, message: "The specified instance ID is malformed. Ensure that you provide the full instance ID in the request, in the form i-xxxxxxxx or i-xxxxxxxxxxxxxxxxx."},
{code: "InvalidInstanceID.NotFound", http: 404, message: "The specified instance does not exist. This error might occur because the ID of a recently created instance has not propagated through the system. For more information, see Ensuring idempotency."},
{code: "InvalidInstanceID.NotLinkable", http: 400, message: "The specified instance cannot be linked to the specified VPC. This error may also occur if the instance was recently launched, and its ID has not yet propagated through the system. Wait a few minutes, or wait until the instance is in the running state, and then try again."},
{code: "InvalidInstanceState", http: 400, message: "The instance is not in an appropriate state to complete the request. If you're modifying the instance placement, the instance must be in the stopped state."},
{code: "InvalidInstanceType", http: 400, message: "The instance type is not supported for this request. For example, you can only bundle instance store-backed Windows instances."},
{code: "InvalidInterface.IpAddressLimitExceeded", http: 400, message: "The number of private IP addresses for a specified network interface exceeds the limit for the type of instance you are trying to launch. For more information, see IP addresses per network interface per instance type."},
{code: "InvalidInternetGatewayID.NotFound", http: 404, message: "The specified internet gateway does not exist. Ensure that you specify the AWS Region in which the internet gateway is located, if it's not in the default Region."},
{code: "InvalidInternetGatewayId.Malformed", http: 400, message: "The specified internet gateway ID is malformed. Ensure that you provide the full ID in the request, in the form igw-xxxxxxxxxxxxxxxxx."},
{code: "InvalidKernelId.Malformed", http: 400, message: "The specified kernel ID is not valid. Ensure that you specify the kernel ID in the form aki-xxxxxxxx."},
{code: "InvalidKey.Format", http: 400, message: "The key pair is not specified in a valid OpenSSH public key format."},
{code: "InvalidKeyPair.Duplicate", http: 409, message: "The key pair name already exists in that AWS Region. If you are creating or importing a key pair, ensure that you use a unique name."},
{code: "InvalidKeyPair.Format", http: 400, message: "The format of the public key you are attempting to import is not valid."},
{code: "InvalidKeyPair.NotFound", http: 404, message: "The specified key pair name does not exist. Ensure that you specify the AWS Region in which the key pair is located, if it's not in the default Region."},
{code: "InvalidLaunchTargets", http: 400, message: "One or more specified targets are invalid. Verify the capacity for the Capacity Reservation selected or verify the ID."},
{code: "InvalidLaunchTemplateId.Malformed", http: 400, message: "The ID for the launch template is malformed. Ensure that you specify the launch template ID in the form lt-xxxxxxxxxxxxxxxxx."},
{code: "InvalidLaunchTemplateId.NotFound", http: 404, message: "The specified launch template ID does not exist. Ensure that you specify the AWS Region in which the launch template is located."},
{code: "InvalidLaunchTemplateId.VersionNotFound", http: 404, message: "The specified launch template version does not exist."},
{code: "InvalidLaunchTemplateName.AlreadyExistsException", http: 409, message: "The specified launch template name is already in use."},
{code: "InvalidLaunchTemplateName.MalformedException", http: 400, message: "The specified launch template name is invalid. A launch template name must be between 3 and 128 characters, and may contain letters, numbers, and the following characters: '-', '_', '.', '/', '(', and ')'."},
{code: "InvalidLaunchTemplateName.NotFoundException", http: 404, message: "The specified launch template name does not exist. Check the spelling of the name and ensure that you specify the AWS Region in which the launch template is located. Launch template names are case-sensitive."},
{code: "InvalidManifest", http: 400, message: "The specified AMI has an unparsable manifest, or you may not have access to the location of the manifest file in Amazon S3."},
{code: "InvalidMaxResults", http: 400, message: "The specified value for MaxResults is not valid."},
{code: "InvalidNatGatewayID.NotFound", http: 404, message: "The specified NAT gateway ID does not exist. Ensure that you specify the AWS Region in which the NAT gateway is located, if it's not in the default Region."},
{code: "InvalidNetworkAclEntry.NotFound", http: 404, message: "The specified network ACL entry does not exist."},
{code: "InvalidNetworkAclID.NotFound", http: 404, message: "The specified network ACL does not exist. Ensure that you specify the AWS Region in which the network ACL is located, if it's not in the default Region."},
{code: "InvalidNetworkAclId.Malformed", http: 400, message: "The specified network ACL ID is malformed. Ensure that you provide the ID in the form acl-xxxxxxxxxxxxxxxxx."},
{code: "InvalidNetworkInterface.InUse", http: 409, message: "The specified interface is currently in use and cannot be deleted or attached to another instance. Ensure that you have detached the network interface first. If a network interface is in use, you may also receive the InvalidParameterValue error."},
{code: "InvalidNetworkInterface.NotFound", http: 404, message: "The specified network interface does not exist."},
{code: "InvalidNetworkInterfaceAttachmentId.Malformed", http: 400, message: "The ID for the network interface attachment is malformed. Ensure that you use the attachment ID rather than the network interface ID, in the form eni-attach-xxxxxxxxxxxxxxxxx."},
{code: "InvalidNetworkInterfaceID.NotFound", http: 404, message: "The specified network interface does not exist. Ensure that you specify the AWS Region in which the network interface is located, if it's not in the default Region."},
{code: "InvalidNetworkInterfaceId.Malformed", http: 400, message: "The specified network interface ID is malformed. Ensure that you specify the network interface ID in the form eni-xxxxxxxxxxxxxxxxx."},
{code: "InvalidNetworkLoadBalancerArn.Malformed", http: 400, message: "The specified Network Load Balancer ARN is malformed. Ensure that you specify the ARN in the form arn:aws:elasticloadbalancing:region:account-id:loadbalancer/net/load-balancer-name/load-balancer-id."},
{code: "InvalidNetworkLoadBalancerArn.NotFound", http: 404, message: "The specified Network Load Balancer ARN does not exist."},
{code: "InvalidNextToken", http: 400, message: "The specified NextToken is not valid."},
{code: "InvalidOption.Conflict", http: 409, message: "A VPN connection between the virtual private gateway and the customer gateway already exists."},
{code: "InvalidPaginationToken", http: 403, message: "The specified pagination token is not valid or is expired."},
{code: "InvalidParameter", http: 400, message: "A parameter specified in a request is not valid, is unsupported, or cannot be used. The returned message provides an explanation of the error value. For example, if you are launching an instance, you can't specify a security group and subnet that are in different VPCs."},
{code: "InvalidParameterCombination", http: 400, message: "Indicates an incorrect combination of parameters, or a missing parameter. For example, trying to terminate an instance without specifying the instance ID."},
{code: "InvalidParameterDependency", http: 400, message: "Indicates an incorrect combination of parameters, or a missing parameter. For example, trying to terminate an instance without specifying the instance ID."},
{code: "InvalidParameterValue", http: 400, message: "A value specified in a parameter is not valid, is unsupported, or cannot be used. Ensure that you specify a resource by using its full ID. The returned message provides an explanation of the error value."},
{code: "InvalidPermission.Duplicate", http: 409, message: "The specified inbound or outbound rule already exists for that security group."},
{code: "InvalidPermission.Malformed", http: 400, message: "The specified security group rule is malformed. If you are specifying an IP address range, ensure that you use CIDR notation; for example, 203.0.113.0/24."},
{code: "InvalidPermission.NotFound", http: 404, message: "The specified rule does not exist in this security group."},
{code: "InvalidPlacementGroup.Duplicate", http: 409, message: "The specified placement group already exists in that AWS Region."},
{code: "InvalidPlacementGroup.InUse", http: 409, message: "The specified placement group is in use. If you are trying to delete a placement group, ensure that its instances have been terminated."},
{code: "InvalidPlacementGroup.Unknown", http: 400, message: "The specified placement group cannot be found. Ensure that you specify the AWS Region in which the placement group is located, if it's not in the default Region."},
{code: "InvalidPlacementGroupId.Malformed", http: 400, message: "The specified placement group ID is malformed. Ensure that you specify the ID in the form pg-xxxxxxxxxxxxxxxxx."},
{code: "InvalidPolicyDocument", http: 400, message: "The specified policy document is not a valid JSON policy document."},
{code: "InvalidPrefixListID.NotFound", http: 404, message: "The specified prefix list ID does not exist."},
{code: "InvalidPrefixListId.Malformed", http: 400, message: "The specified prefix list ID is malformed. Ensure that you provide the ID in the form pl-xxxxxxxxxxxxxxxxx."},
{code: "InvalidProductInfo", http: 400, message: "(AWS Marketplace) The product code is not valid."},
{code: "InvalidPurchaseToken.Expired", http: 403, message: "The specified purchase token has expired."},
{code: "InvalidPurchaseToken.Malformed", http: 400, message: "The specified purchase token is not valid."},
{code: "InvalidQuantity", http: 400, message: "The specified quantity of Dedicated Hosts is not valid."},
{code: "InvalidQueryParameter", http: 400, message: "The AWS query string is malformed or does not adhere to AWS standards."},
{code: "InvalidRamDiskId.Malformed", http: 400, message: "The specified RAM disk ID is not valid. Ensure that you specify the RAM disk ID in the form ari-xxxxxxxx."},
{code: "InvalidRegion", http: 400, message: "The specified AWS Region is not valid. For copying a snapshot or image, specify the source Region using its Region code, for example, us-west-2."},
{code: "InvalidRequest", http: 400, message: "The request is not valid. The returned message provides details about the nature of the error."},
{code: "InvalidReservationID.Malformed", http: 400, message: "The specified reservation ID is not valid."},
{code: "InvalidReservationID.NotFound", http: 404, message: "The specified reservation does not exist."},
{code: "InvalidReservedInstancesId", http: 404, message: "The specified Reserved Instance does not exist."},
{code: "InvalidReservedInstancesOfferingId", http: 404, message: "The specified Reserved Instances offering does not exist."},
{code: "InvalidResourceConfigurationArn.Malformed", http: 400, message: "The specified resource configuration ARN is malformed."},
{code: "InvalidResourceConfigurationArn.NotFound", http: 404, message: "The specified resource configuration ARN does not exist."},
{code: "InvalidResourceType.Unknown", http: 400, message: "The specified resource type is not supported or is not valid. To view resource types that support longer IDs, use DescribeIdFormat."},
{code: "InvalidRoute.InvalidState", http: 400, message: "The specified route is not valid."},
{code: "InvalidRoute.Malformed", http: 400, message: "The specified route is not valid. If you are deleting a route in a VPN connection, ensure that you've entered the value for the CIDR block correctly."},
{code: "InvalidRoute.NotFound", http: 404, message: "The specified route does not exist in the specified route table. Ensure that you indicate the exact CIDR range for the route in the request. This error can also occur if you've specified a route table ID in the request that does not exist."},
{code: "InvalidRouteTableID.NotFound", http: 404, message: "The specified route table does not exist. Ensure that you specify the AWS Region in which the route table is located, if it's not in the default Region."},
{code: "InvalidRouteTableId.Malformed", http: 400, message: "The specified route table ID is malformed. Ensure that you specify the route table ID in the form rtb-xxxxxxxxxxxxxxxxx."},
{code: "InvalidScheduledInstance", http: 404, message: "The specified Scheduled Instance does not exist."},
{code: "InvalidSecurity.RequestHasExpired", http: 400, message: "The difference between the request timestamp and the AWS server time is greater than 5 minutes. Ensure that your system clock is accurate and configured to use the correct time zone."},
{code: "InvalidSecurityGroupID.NotFound", http: 404, message: "The specified security group does not exist."},
{code: "InvalidSecurityGroupId.Malformed", http: 400, message: "The specified security group ID is not valid. Ensure that you specify the security group ID in the form sg-xxxxxxxxxxxxxxxxx."},
{code: "InvalidSecurityGroupRuleId.Malformed", http: 400, message: "The specified security group rule ID is not valid. Ensure that you specify the security group rule ID in the form sgr-xxxxxxxxxxxxxxxxx."},
{code: "InvalidSecurityGroupRuleId.NotFound", http: 404, message: "The specified security group rule does not exist."},
{code: "InvalidServiceName", http: 400, message: "The name of the service is not valid. To get a list of available service names, use DescribeVpcEndpointServices."},
{code: "InvalidSnapshot.InUse", http: 409, message: "The snapshot that you are trying to delete is in use by one or more AMIs."},
{code: "InvalidSnapshot.NotFound", http: 404, message: "The specified snapshot does not exist. Ensure that you specify the AWS Region in which the snapshot is located, if it's not in the default Region."},
{code: "InvalidSnapshotID.Malformed", http: 400, message: "The snapshot ID is not valid."},
{code: "InvalidSpotDatafeed.NotFound", http: 400, message: "You have no data feed for Spot Instances."},
{code: "InvalidSpotFleetRequestConfig", http: 400, message: "The Spot Fleet request configuration is not valid. Ensure that you provide valid values for all of the configuration parameters; for example, a valid AMI ID. Limits apply on the target capacity and the number of launch specifications per Spot Fleet request. For more information, see Fleet quotas."},
{code: "InvalidSpotFleetRequestId.Malformed", http: 400, message: "The specified Spot Fleet request ID is malformed. Ensure that you specify the Spot Fleet request ID in the form sfr- followed by 36 characters, including hyphens; for example, sfr-123f8fc2-11aa-22bb-33cc-example12710."},
{code: "InvalidSpotFleetRequestId.NotFound", http: 404, message: "The specified Spot Fleet request ID does not exist. Ensure that you specify the AWS Region in which the Spot Fleet request is located, if it's not in the default Region."},
{code: "InvalidSpotInstanceRequestID.Malformed", http: 400, message: "The specified Spot Instance request ID is not valid. Ensure that you specify the Spot Instance request ID in the form sir-xxxxxxxx."},
{code: "InvalidSpotInstanceRequestID.NotFound", http: 404, message: "The specified Spot Instance request ID does not exist. Ensure that you specify the AWS Region in which the Spot Instance request is located, if it's not in the default Region."},
{code: "InvalidState", http: 400, message: "The specified resource is not in the correct state for the request; for example, if you are trying to enable monitoring on a recently terminated instance, or if you are trying to create a snapshot when a previous identical request has not yet completed."},
{code: "InvalidStateTransition", http: 400, message: "The specified VPC peering connection is not in the correct state for the request. For example, you may be trying to accept a VPC peering request that has failed, or that was rejected."},
{code: "InvalidSubnet", http: 404, message: "The specified subnet ID is not valid or does not exist."},
{code: "InvalidSubnet.Conflict", http: 409, message: "The specified CIDR block conflicts with that of another subnet in your VPC."},
{code: "InvalidSubnet.Range", http: 400, message: "The CIDR block you've specified for the subnet is not valid. The allowed block size is between a /28 netmask and /16 netmask."},
{code: "InvalidSubnetID.Malformed", http: 400, message: "The specified subnet ID is malformed. Ensure that you specify the ID in the form subnet-xxxxxxxxxxxxxxxxx"},
{code: "InvalidSubnetID.NotFound", http: 404, message: "or InvalidSubnetId.NotFound \tThe specified subnet does not exist."},
{code: "InvalidTagKey.Malformed", http: 400, message: "The specified tag key is not valid. Tag keys cannot be empty or null, and cannot start with aws:."},
{code: "InvalidTargetArn.Unknown", http: 404, message: "The specified ARN for the specified user or role is not valid or does not exist."},
{code: "InvalidTargetException", http: 404, message: "The specified TargetId is not valid, does not exist, or is in another organization. You can only generate an account status report for declarative policies in your own organization. Ensure that you specify the TargetId in one of the following forms: r-xxxx, ou-xxxx-xxxxxxxx, or a 12-digit account ID in the form xxxxxxxxxxxx."},
{code: "InvalidTenancy", http: 400, message: "The tenancy of the instance or VPC is not supported for the requested action. For example, you cannot modify the tenancy of an instance or VPC that has a tenancy attribute of default."},
{code: "InvalidTime", http: 400, message: "The specified timestamp is not valid."},
{code: "InvalidTrafficMirrorFilterNotFound", http: 404, message: "The specified Traffic Mirror filter does not exist."},
{code: "InvalidTrafficMirrorFilterRuleNotFound", http: 404, message: "The specified Traffic Mirror filter rule does not exist."},
{code: "InvalidTrafficMirrorSessionNotFound", http: 404, message: "The specified Traffic Mirror session does not exist."},
{code: "InvalidTrafficMirrorTargetNoFound", http: 404, message: "The specified Traffic Mirror target does not exist."},
{code: "InvalidUserID.Malformed", http: 400, message: "The specified user or owner is not valid. If you are performing a DescribeImages request, you must specify a valid value for the owner or executableBy parameters, such as an AWS account ID. If you are performing a DescribeSnapshots request, you must specify a valid value for the owner or restorableBy parameters."},
{code: "InvalidVolume.NotFound", http: 404, message: "The specified volume does not exist."},
{code: "InvalidVolume.ZoneMismatch", http: 400, message: "The specified volume is not in the same Availability Zone as the specified instance. You can only attach an Amazon EBS volume to an instance if they are in the same Availability Zone."},
{code: "InvalidVolumeID.Duplicate", http: 409, message: "The Amazon EBS volume already exists."},
{code: "InvalidVolumeID.Malformed", http: 400, message: "The specified volume ID is not valid. Check the letter-number combination carefully."},
{code: "InvalidVolumeID.ZoneMismatch", http: 400, message: "The specified volume and instance are in different Availability Zones."},
{code: "InvalidVpcEndpoint.NotFound", http: 404, message: "The specified VPC endpoint does not exist. If you are performing a bulk request that is partially successful or unsuccessful, the response includes a list of the unsuccessful items. If the request succeeds, the list is empty."},
{code: "InvalidVpcEndpointId.Malformed", http: 400, message: "The specified VPC endpoint ID is malformed. Use the full VPC endpoint ID in the request, in the form vpce-xxxxxxxxxxxxxxxxx."},
{code: "InvalidVpcEndpointId.NotFound", http: 404, message: "The specified VPC endpoint does not exist. If you are performing a bulk request that is partially successful or unsuccessful, the response includes a list of the unsuccessful items. If the request succeeds, the list is empty."},
{code: "InvalidVpcEndpointService.NotFound", http: 404, message: "The specified VPC endpoint service does not exist. If you are performing a bulk request that is partially successful or unsuccessful, the response includes a list of the unsuccessful items. If the request succeeds, the list is empty."},
{code: "InvalidVpcEndpointServiceId.NotFound", http: 404, message: "The specified VPC endpoint service does not exist. If you are performing a bulk request that is partially successful or unsuccessful, the response includes a list of the unsuccessful items. If the request succeeds, the list is empty."},
{code: "InvalidVpcEndpointServiceIdId.Malformed", http: 400, message: "The specified VPC endpoint service ID is malformed. Ensure that you specify the ID in the form vpc-svc-xxxxxxxxxxxxxxxxx."},
{code: "InvalidVpcEndpointType", http: 400, message: "The specified VPC endpoint type is not valid."},
{code: "InvalidVpcID.Malformed", http: 400, message: "The specified VPC ID is malformed. Ensure that you've specified the ID in the form vpc-xxxxxxxxxxxxxxxxx."},
{code: "InvalidVpcID.NotFound", http: 404, message: "The specified VPC does not exist."},
{code: "InvalidVpcPeeringConnectionID.NotFound", http: 404, message: "The specified VPC peering connection ID does not exist."},
{code: "InvalidVpcPeeringConnectionId.Malformed", http: 400, message: "The specified VPC peering connection ID is malformed. Ensure that you provide the ID in the form pcx-xxxxxxxxxxxxxxxxx."},
{code: "InvalidVpcPeeringConnectionState.DnsHostnamesDisabled", http: 400, message: "To enable DNS hostname resolution for the VPC peering connection, DNS hostname support must be enabled for the VPCs."},
{code: "InvalidVpcRange", http: 400, message: "The specified CIDR block range is not valid. The block range must be between a /28 netmask and /16 netmask. For more information, see VPC CIDR blocks."},
{code: "InvalidVpcState", http: 400, message: "The specified VPC already has a virtual private gateway attached to it."},
{code: "InvalidVpnConnection.InvalidState", http: 400, message: "The VPN connection must be in the available state to complete the request."},
{code: "InvalidVpnConnection.InvalidType", http: 400, message: "The specified VPN connection does not support static routes."},
{code: "InvalidVpnConnectionID", http: 400, message: "The specified VPN connection ID cannot be found."},
{code: "InvalidVpnConnectionID.NotFound", http: 404, message: "The specified VPN connection ID does not exist."},
{code: "InvalidVpnGatewayAttachment.NotFound", http: 404, message: "An attachment between the specified virtual private gateway and specified VPC does not exist. This error can also occur if you've specified an incorrect VPC ID in the request."},
{code: "InvalidVpnGatewayID.NotFound", http: 404, message: "The specified virtual private gateway does not exist."},
{code: "InvalidVpnGatewayState", http: 400, message: "The virtual private gateway is not in an available state."},
{code: "InvalidZone.NotFound", http: 404, message: "The specified Availability Zone does not exist, or is not available for you to use. Use the DescribeAvailabilityZones request to list the Availability Zones that are currently available to you. Specify the full name of the Availability Zone: for example, us-east-1a."},
{code: "KeyPairLimitExceeded", http: 400, message: "You've reached the limit on the number of key pairs that you can have in this AWS Region. For more information, see Amazon EC2 key pairs."},
{code: "LegacySecurityGroup", http: 400, message: "Any VPC created using an API version older than 2011-01-01 may have the 2009-07-15-default security group. You must delete this security group before you can attach an internet gateway to the VPC."},
{code: "LimitPriceExceeded", http: 400, message: "The cost of the total order is greater than the specified limit price (instance count * price)."},
{code: "LogDestinationNotFound", http: 404, message: "The specified Amazon S3 bucket does not exist. Ensure that you have specified the ARN for an existing Amazon S3 bucket, and that the ARN is in the correct format."},
{code: "LogDestinationPermissionIssue", http: 400, message: "You do not have sufficient permissions to publish flow logs to the specific Amazon S3 bucket."},
{code: "MalformedQueryString", http: 400, message: "The query string contains a syntax error."},
{code: "MaxConfigLimitExceededException", http: 400, message: "You\u2019ve exceeded your maximum allowed Spot placement configurations. You can retry configurations that you used within the last 24 hours, or wait for 24 hours before specifying a new configuration. For more information, see Spot placement score."},
{code: "MaxIOPSLimitExceeded", http: 400, message: "You've reached the limit on your IOPS usage for the AWS Region. For more information, see Quotas for Amazon EBS."},
{code: "MaxScheduledInstanceCapacityExceeded", http: 400, message: "You've attempted to launch more instances than you purchased."},
{code: "MaxSpotFleetRequestCountExceeded", http: 400, message: "You've reached one or both of these limits: the total number of Spot Fleet requests that you can make, or the total number of instances in all Spot Fleets for the AWS Region (the target capacity). For more information, see Fleet quotas."},
{code: "MaxSpotInstanceCountExceeded", http: 400, message: "You've reached the limit on the number of Spot Instances that you can launch. The limit depends on the instance type. For more information, see Spot Instance limits."},
{code: "MaxTemplateLimitExceeded", http: 400, message: "You've reached the limit on the number of launch templates you can create. For more information, see Launch template restrictions."},
{code: "MaxTemplateVersionLimitExceeded", http: 400, message: "You've reached the limit on the number of launch template versions that you can create. For more information, see Launch template restrictions."},
{code: "MissingAction", http: 400, message: "The request is missing an action or a required parameter."},
{code: "MissingAuthenticationToken", http: 403, message: "The request must contain valid credentials."},
{code: "MissingInput", http: 400, message: "An input parameter is missing."},
{code: "MissingParameter", http: 400, message: "The request is missing a required parameter. Ensure that you have supplied all the required parameters for the request; for example, the resource ID."},
{code: "NatGatewayLimitExceeded", http: 400, message: "You've reached the limit on the number of NAT gateways that you can create. For more information, see Amazon VPC quotas."},
{code: "NatGatewayMalformed", http: 400, message: "The specified NAT gateway ID is not formed correctly. Ensure that you specify the NAT gateway ID in the form nat-xxxxxxxxxxxxxxxxx."},
{code: "NatGatewayNotFound", http: 404, message: "The specified NAT gateway does not exist."},
{code: "NetworkAclEntryAlreadyExists", http: 409, message: "The specified rule number already exists in this network ACL."},
{code: "NetworkAclEntryLimitExceeded", http: 400, message: "You've reached the limit on the number of rules that you can add to the network ACL. For more information, see Amazon VPC quotas."},
{code: "NetworkAclLimitExceeded", http: 400, message: "You've reached the limit on the number of network ACLs that you can create for the specified VPC. For more information, see Amazon VPC quotas."},
{code: "NetworkInterfaceLimitExceeded", http: 400, message: "You've reached the limit on the number of network interfaces that you can create. For more information, see Amazon VPC quotas."},
{code: "NetworkInterfaceNotSupported", http: 400, message: "The network interface is not supported for Traffic Mirroring."},
{code: "NetworkLoadBalancerNotFoundException", http: 404, message: "The specified Network Load Balancer does not exist."},
{code: "NlbInUseByTrafficMirrorTargetException", http: 400, message: "The Network Load Balancer is already configured as a Traffic Mirror target."},
{code: "NoSuchVersion", http: 404, message: "The specified API version does not exist."},
{code: "NonEBSInstance", http: 400, message: "The specified instance does not support Amazon EBS. Restart the instance and try again, to ensure that the code is run on an instance with updated code."},
{code: "NotExportable", http: 400, message: "The specified instance cannot be exported. You can only export certain instances. For more information, see Considerations for instance export."},
{code: "OperationNotPermitted", http: 400, message: "The specified operation is not allowed. This error can occur for a number of reasons; for example, you might be trying to terminate an instance that has termination protection enabled, or trying to detach the primary network interface (eth0) from an instance."},
{code: "OptInRequired", http: 403, message: "You are not authorized to use the requested service. Ensure that you have subscribed to the service you are trying to use. If you are new to AWS, your account might take some time to be activated while your credit card details are being verified."},
{code: "OutstandingVpcPeeringConnectionLimitExceeded", http: 400, message: "You've reached the limit on the number of VPC peering connection requests that you can create for the specified VPC."},
{code: "PendingSnapshotLimitExceeded", http: 409, message: "You've reached the limit on the number of Amazon EBS snapshots that you can have in the pending state."},
{code: "PendingVerification", http: 409, message: "Your account is pending verification. Until the verification process is complete, you may not be able to carry out requests with this account. If you have questions, contact Support."},
{code: "PendingVpcPeeringConnectionLimitExceeded", http: 409, message: "You've reached the limit on the number of pending VPC peering connections that you can have."},
{code: "PlacementGroupLimitExceeded", http: 400, message: "You've reached the limit on the number of placement groups that you can have."},
{code: "PrivateIpAddressLimitExceeded", http: 400, message: "You've reached the limit on the number of private IP addresses that you can assign to the specified network interface for that type of instance. For more information, see IP addresses per network interface."},
{code: "RequestEntityTooLarge", http: 413, message: "Request body exceeds the maximum allowed size."},
{code: "RequestExpired", http: 403, message: "The request reached the service more than 15 minutes after the date stamp on the request or more than 15 minutes after the request expiration date (such as for presigned URLs), or the date stamp on the request is more than 15 minutes in the future. If you're using temporary security credentials, this error can also occur if the credentials have expired. For more information, see Temporary security credentials in the IAM User Guide."},
{code: "RequestLimitExceeded", http: 503, message: "The maximum request rate permitted by the Amazon EC2 APIs has been exceeded for your account. For best results, use an increasing or variable sleep interval between requests. For more information, see Query API request rate."},
{code: "RequestResourceCountExceeded", http: 409, message: "Details in your Spot request exceed the numbers allowed by the Spot service in one of the following ways, depending on the action that generated the error: \u2014If you get this error when you submitted a request for Spot Instances, check the number of Spot Instances specified in your request. The number shouldn't exceed the 3,000 maximum allowed per request. Resend your Spot Instance request and specify a number less than 3,000. If your account's regional Spot request limit is greater than 3,000 instances, you can access these instances by submitting multiple smaller requests. \u2014If you get this error when you sent Describe Spot Instance requests, check the number of requests for Spot Instance data, the amount of data you requested, and how often you sent the request. The frequency with which you requested the data combined with the amount of data exceeds the levels allowed by the Spot service. Try again and submit fewer large Describe requests over longer intervals."},
{code: "ReservationCapacityExceeded", http: 400, message: "The targeted Capacity Reservation does not enough available instance capacity to fulfill your request. Either increase the instance capacity for the targeted Capacity Reservation, or target a different Capacity Reservation."},
{code: "ReservedInstancesCountExceeded", http: 400, message: "You've reached the limit for the number of Reserved Instances."},
{code: "ReservedInstancesLimitExceeded", http: 400, message: "Your current quota does not allow you to purchase the required number of Reserved Instances."},
{code: "ReservedInstancesUnavailable", http: 400, message: "The requested Reserved Instances are not available."},
{code: "Resource.AlreadyAssigned", http: 400, message: "The specified private IP address is already assigned to a resource. Unassign the private IP first, or use a different private IP address."},
{code: "Resource.AlreadyAssociated", http: 409, message: "The specified resource is already in use. For example, in EC2-VPC, you cannot associate an Elastic IP address with an instance if it's already associated with another instance. You also cannot attach an internet gateway to more than one VPC at a time."},
{code: "ResourceCountExceeded", http: 400, message: "You have exceeded the number of resources allowed for this request; for example, if you try to launch more instances than AWS allows in a single request. This limit is separate from your individual resource limit. If you get this error, break up your request into smaller requests; for example, if you are launching 15 instances, try launching 5 instances in 3 separate requests."},
{code: "ResourceCountLimitExceeded", http: 400, message: "You have exceeded a resource limit for creating routes."},
{code: "ResourceLimitExceeded", http: 400, message: "You have exceeded an Amazon EC2 resource limit. For example, you might have too many snapshot copies in progress."},
{code: "RetryableError", http: 400, message: "A request submitted by an AWS service on your behalf could not be completed. The requesting service might automatically retry the request."},
{code: "RouteAlreadyExists", http: 409, message: "A route for the specified CIDR block already exists in this route table."},
{code: "RouteLimitExceeded", http: 400, message: "You've reached the limit on the number of routes that you can add to a route table."},
{code: "RouteTableLimitExceeded", http: 400, message: "You've reached the limit on the number of route tables that you can create for the specified VPC. For more information about route table limits, see Amazon VPC quotas."},
{code: "RulesPerSecurityGroupLimitExceeded", http: 400, message: "You've reached the limit on the number of rules that you can add to a security group. For more information, see Amazon VPC quotas."},
{code: "ScheduledInstanceLimitExceeded", http: 400, message: "You've reached the limit on the number of Scheduled Instances that you can purchase."},
{code: "ScheduledInstanceParameterMismatch", http: 400, message: "The launch specification does not match the details for the Scheduled Instance."},
{code: "ScheduledInstanceSlotNotOpen", http: 400, message: "You can launch a Scheduled Instance only during its scheduled time periods."},
{code: "ScheduledInstanceSlotUnavailable", http: 400, message: "The requested Scheduled Instance is no longer available during this scheduled time period."},
{code: "SecurityGroupLimitExceeded", http: 400, message: "You've reached the limit on the number of security groups that you can create, or that you can assign to an instance."},
{code: "SecurityGroupsPerInstanceLimitExceeded", http: 400, message: "You've reached the limit on the number of security groups that you can assign to an instance. For more information, see Amazon EC2 security groups."},
{code: "SecurityGroupsPerInterfaceLimitExceeded", http: 400, message: "You've reached the limit on the number of security groups you can associate with the specified network interface. For more information, see Amazon VPC quotas."},
{code: "SerialConsoleSessionUnavailable", http: 403, message: "The serial console access is not enabled for this account. Use EnableSerialConsoleAccess to enable access."},
{code: "ServerInternal", http: 500, message: "An internal error has occurred. Retry your request, but if the problem persists, contact us with details by posting a message on AWS re:Post."},
{code: "ServiceUnavailable", http: 503, message: "The request has failed due to a temporary failure of the server."},
{code: "SignatureDoesNotMatch", http: 403, message: "The request signature that Amazon has does not match the signature that you provided. Check your AWS credentials and signing method."},
{code: "SnapshotCopyUnsupported.InterRegion", http: 400, message: "Inter-region snapshot copy is not supported for this AWS Region."},
{code: "SnapshotCreationPerVolumeRateExceeded", http: 400, message: "The rate limit for creating concurrent snapshots of an EBS volume has been exceeded. Wait at least 15 seconds between concurrent volume snapshots."},
{code: "SnapshotLimitExceeded", http: 400, message: "You've reached the limit on the number of Amazon EBS snapshots that you can create."},
{code: "SpotMaxPriceTooLow", http: 400, message: "The request can't be fulfilled yet because your maximum price is below the Spot price. In this case, no instance is launched and your request remains open."},
{code: "SubnetLimitExceeded", http: 400, message: "You've reached the limit on the number of subnets that you can create for the specified VPC. For more information, see Amazon VPC quotas."},
{code: "TagLimitExceeded", http: 400, message: "You've reached the limit on the number of tags that you can assign to the specified resource. For more information, see Tag restrictions."},
{code: "TagPolicyViolation", http: 400, message: "You attempted to create or update a resource with tags that are not compliant with the tag policy requirements for this account. For more information, see Grant permission to tag resources during creation."},
{code: "TargetCapacityLimitExceededException", http: 400, message: "The value for targetCapacity exceeds your limit on the amount of Spot placement target capacity you can explore. Reduce the targetCapacity value, and try again. For more information, see Spot placement score."},
{code: "Throttling", http: 503, message: "Rate exceeded."},
{code: "TrafficMirrorFilterInUse", http: 400, message: "The Traffic Mirror filter cannot be deleted because a Traffic Mirror session is currently using it."},
{code: "TrafficMirrorFilterLimitExceeded", http: 400, message: "The maximum number of Traffic Mirror filters has been exceeded."},
{code: "TrafficMirrorFilterRuleAlreadyExists", http: 409, message: "The Traffic Mirror filter rule already exists."},
{code: "TrafficMirrorFilterRuleLimitExceeded", http: 400, message: "The maximum number of Traffic Mirror filter rules has been exceeded."},
{code: "TrafficMirrorSessionLimitExceeded", http: 400, message: "The maximum number of Traffic Mirror sessions has been exceeded."},
{code: "TrafficMirrorSessionsPerInterfaceLimitExceeded", http: 400, message: "The allowed number of Traffic Mirror sessions for the specified network interface has been exceeded."},
{code: "TrafficMirrorSessionsPerTargetLimitExceeded", http: 400, message: "The maximum number of Traffic Mirror sessions for the specified Traffic Mirror target has been exceeded."},
{code: "TrafficMirrorSourcesPerTargetLimitExceeded", http: 400, message: "The maximum number of Traffic Mirror sources for the specified Traffic Mirror target has been exceeded."},
{code: "TrafficMirrorTargetInUseException", http: 400, message: "The Traffic Mirror target cannot be deleted because a Traffic Mirror session is currently using it."},
{code: "TrafficMirrorTargetLimitExceeded", http: 400, message: "The maximum number of Traffic Mirror targets has been exceeded."},
{code: "UnauthorizedOperation", http: 403, message: "You are not authorized to perform this operation. Check your IAM policies, and ensure that you are using the correct credentials. For more information, see Identity and access management for Amazon EC2. If the returned message is encoded, you can decode it using the DecodeAuthorizationMessage action. For more information, see DecodeAuthorizationMessage in the AWS Security Token Service API Reference."},
{code: "Unavailable", http: 503, message: "The server is overloaded and can't handle the request."},
{code: "UnavailableHostRequirements", http: 400, message: "There are no valid Dedicated Hosts available on which you can launch an instance."},
{code: "UnfulfillableCapacity", http: 400, message: "At this time there isn't enough spare capacity to fulfill your request for Spot Instances. You can wait a few minutes to see whether capacity becomes available for your request. Alternatively, create a more flexible request. For example, include additional instance types, include additional Availability Zones, or use the capacity-optimized allocation strategy."},
{code: "UnknownParameter", http: 404, message: "An unknown or unrecognized parameter was supplied. Requests that could cause this error include supplying a misspelled parameter or a parameter that is not supported for the specified API version."},
{code: "UnknownPrincipalType.Unsupported", http: 400, message: "The principal type is not supported."},
{code: "UnknownVolumeType", http: 400, message: "The specified volume type is unsupported. The supported volume types are gp2, io1, st1, sc1, and standard."},
{code: "Unsupported", http: 400, message: "The specified request is unsupported. For example, you might be trying to launch an instance in an Availability Zone that currently has constraints on that instance type. The returned message provides details of the unsupported request."},
{code: "UnsupportedException", http: 400, message: "Capacity Reservations are not supported for this Region."},
{code: "UnsupportedHibernationConfiguration", http: 400, message: "The instance could not be launched because one or more parameter values do not meet the prerequisites for enabling hibernation. For more information, see Hibernation Prerequisites. Alternatively, the instance could not be hibernated because it is not enabled for hibernation."},
{code: "UnsupportedHostConfiguration", http: 400, message: "The specified Dedicated Host configuration is unsupported. For more information about supported configurations, see Dedicated Hosts."},
{code: "UnsupportedInstanceAttribute", http: 400, message: "The specified attribute cannot be modified."},
{code: "UnsupportedInstanceTypeOnHost", http: 400, message: "The instance type is not supported on the Dedicated Host. For more information about supported instance types, see Amazon EC2 Dedicated Hosts Pricing."},
{code: "UnsupportedOperation", http: 400, message: "The specified request includes an unsupported operation. For example, you can't stop an instance that's instance store-backed. Or you might be trying to launch an instance type that is not supported by the specified AMI. The returned message provides details of the unsupported operation."},
{code: "UnsupportedProtocol", http: 400, message: "SOAP has been deprecated and is no longer supported."},
{code: "UnsupportedTenancy", http: 400, message: "The specified tenancy is unsupported. You can change the tenancy of a VPC to default only."},
{code: "UpdateLimitExceeded", http: 400, message: "The default credit specification for an instance family can be modified only once in a rolling 5-minute period, and up to four times in a rolling 24-hour period. For more information, see Set the default credit specification for the account."},
{code: "VPCIdNotSpecified", http: 400, message: "You have no default VPC in which to carry out the request. Specify a VPC or subnet ID or, in the case of security groups, specify the ID and not the security group name. If you deleted your default VPC, you can create a new one. For more information, see Create a default VPC."},
{code: "VPCResourceNotSpecified", http: 400, message: "The specified resource can be used only in a VPC; for example, T2 instances. Ensure that you have a VPC in your account, and then specify a subnet ID or network interface ID in the request."},
{code: "ValidationError", http: 400, message: "The input fails to satisfy the constraints specified by an AWS service."},
{code: "VcpuLimitExceeded", http: 400, message: "You've reached the limit on the number of vCPUs (virtual processing units) assigned to the running instances in your account. You are limited to running one or more On-Demand instances in an AWS account, and Amazon EC2 measures usage towards each limit based on the total number of vCPUs that are assigned to the running On-Demand instances in your AWS account. If your request fails due to limit constraints, increase your On-Demand instance limits and try again. For more information, see EC2 On-Demand instance limits."},
{code: "VolumeIOPSLimit", http: 400, message: "The maximum IOPS limit for the volume has been reached. For more information, see Amazon EBS volume types."},
{code: "VolumeInUse", http: 400, message: "The specified Amazon EBS volume is attached to an instance. Ensure that the specified volume is in an \u2018available\u2019 state."},
{code: "VolumeLimitExceeded", http: 400, message: "You've reached the limit on your Amazon EBS volume storage. For more information, see Quotas for Amazon EBS."},
{code: "VolumeModificationSizeLimitExceeded", http: 400, message: "You've reached the limit on your Amazon EBS volume modification storage in this Region. For more information, see Quotas for Amazon EBS."},
{code: "VolumeTypeNotAvailableInZone", http: 400, message: "The specified Availability Zone does not support Provisioned IOPS SSD volumes. Try launching your instance in a different Availability Zone, or don't specify a zone in the request. If you're creating a volume, try specifying a different Availability Zone in the request."},
{code: "VpcEndpointLimitExceeded", http: 400, message: "You've reached the limit on the number of VPC endpoints that you can create in the AWS Region. For more information, see Amazon VPC quotas."},
{code: "VpcLimitExceeded", http: 400, message: "You've reached the limit on the number of VPCs that you can create in the AWS Region. For more information about VPC limits, see Amazon VPC quotas."},
{code: "VpcPeeringConnectionAlreadyExists", http: 409, message: "A VPC peering connection between the VPCs already exists."},
{code: "VpcPeeringConnectionsPerVpcLimitExceeded", http: 400, message: "You've reached the limit on the number of VPC peering connections that you can have per VPC. For more information, see Amazon VPC quotas."},
{code: "VpnConnectionLimitExceeded", http: 400, message: "You've reached the limit on the number of VPN connections that you can create. For more information, see Amazon VPC quotas."},
{code: "VpnGatewayAttachmentLimitExceeded", http: 400, message: "You've reached the limit on the number of VPCs that can be attached to the specified virtual private gateway."},
{code: "VpnGatewayLimitExceeded", http: 400, message: "You've reached the limit on the number of virtual private gateways that you can create. For more information about limits, see Amazon VPC quotas."},
{code: "ZonesMismatched", http: 400, message: "The Availability Zone for the instance does not match that of the Dedicated Host"},
// IAM error codes
{code: "NoSuchEntity", http: 404, message: "The request was rejected because it referenced a resource entity that does not exist."},
{code: "EntityAlreadyExists", http: 409, message: "The request was rejected because it attempted to create a resource that already exists."},
{code: "DeleteConflict", http: 409, message: "The request was rejected because it attempted to delete a resource that has attached subordinate entities."},
{code: "LimitExceeded", http: 409, message: "The request was rejected because it attempted to create resources beyond the current AWS account limits."},
{code: "MalformedPolicyDocument", http: 400, message: "The policy document is malformed."},
{code: "AccessDenied", http: 403, message: "User is not authorized to perform this action."},
// ELBv2 error codes
{code: "LoadBalancerNotFound", http: 400, message: "One or more load balancers not found."},
{code: "TargetGroupNotFound", http: 400, message: "One or more target groups not found."},
{code: "ListenerNotFound", http: 400, message: "One or more listeners not found."},
{code: "DuplicateLoadBalancerName", http: 400, message: "A load balancer with the same name already exists."},
{code: "DuplicateTargetGroupName", http: 400, message: "A target group with the same name already exists."},
{code: "DuplicateListener", http: 400, message: "A listener with the specified port already exists on this load balancer."},
{code: "TooManyLoadBalancers", http: 400, message: "You've reached the limit on the number of load balancers for your account."},
{code: "TooManyTargetGroups", http: 400, message: "You've reached the limit on the number of target groups for your account."},
{code: "TooManyListeners", http: 400, message: "You've reached the limit on the number of listeners per load balancer."},
{code: "TooManyRegistrationsForTargetId", http: 400, message: "You've reached the limit on the number of times a target can be registered with a target group."},
{code: "InvalidTarget", http: 400, message: "The specified target does not exist, is not in the same VPC as the target group, or has an unsupported instance type."},
{code: "ResourceInUse", http: 400, message: "The target group is currently in use by a listener or rule."},
{code: "InvalidSecurityGroup", http: 400, message: "The specified security group does not exist."},
{code: "InvalidScheme", http: 400, message: "The specified scheme is not valid. Specify 'internet-facing' or 'internal'."},
{code: "SubnetNotFound", http: 400, message: "The specified subnet does not exist."},
{code: "AvailabilityZoneNotSupported", http: 400, message: "The specified Availability Zone is not supported."},
{code: "InvalidConfigurationRequest", http: 400, message: "Security groups are not supported for load balancers with type 'network'."},
// TestErrorLookup_Structure asserts invariants of the ErrorLookup map without
// duplicating every entry. Catches wholesale-deletion accidents, invalid HTTP
// codes, empty messages, and regressions of SDK-surfaced codes — the failure
// modes that actually matter — without locking in a 1:1 mirror that has to be
// updated in lockstep with every new error code.
func TestErrorLookup_Structure(t *testing.T) {
if len(ErrorLookup) < 400 {
t.Fatalf("ErrorLookup unexpectedly small: %d entries", len(ErrorLookup))
}
if len(ErrorLookup) != len(expected) {
t.Fatalf("expected %d errors, got %d", len(expected), len(ErrorLookup))
validHTTP := map[int]bool{400: true, 403: true, 404: true, 409: true, 412: true, 413: true, 500: true, 503: true}
for code, msg := range ErrorLookup {
if !validHTTP[msg.HTTPCode] {
t.Errorf("%s has invalid HTTPCode %d", code, msg.HTTPCode)
}
if msg.Message == "" {
t.Errorf("%s has empty Message", code)
}
}
for _, tt := range expected {
msg, ok := ErrorLookup[tt.code]
// Spot-check business-critical codes that the AWS SDK surfaces by name.
critical := map[string]int{
ErrorAuthFailure: 403,
ErrorInvalidInstanceIDNotFound: 404,
ErrorInvalidAMIIDNotFound: 400,
ErrorInvalidKeyPairNotFound: 404,
ErrorInvalidVpcIDNotFound: 404,
ErrorInvalidGroupNotFound: 404,
ErrorInvalidSubnetIDNotFound: 404,
ErrorServerInternal: 500,
ErrorInsufficientInstanceCapacity: 400,
ErrorUnauthorizedOperation: 403,
}
for code, wantHTTP := range critical {
msg, ok := ErrorLookup[code]
if !ok {
t.Fatalf("missing error code %s", tt.code)
t.Errorf("critical code %q missing from ErrorLookup", code)
continue
}
if msg.HTTPCode != tt.http {
t.Fatalf("%s HTTPCode = %d, want %d", tt.code, msg.HTTPCode, tt.http)
}
if msg.Message != tt.message {
t.Fatalf("%s message = %q, want %q", tt.code, msg.Message, tt.message)
if msg.HTTPCode != wantHTTP {
t.Errorf("%s HTTPCode = %d, want %d", code, msg.HTTPCode, wantHTTP)
}
}
}

Some files were not shown because too many files have changed in this diff Show More