Compare commits

..

411 Commits

Author SHA1 Message Date
Julian Sommer bab16c75cc fix(elbv2): validate HAProxy inputs (#181) 2026-05-01 12:17:42 +10:00
tomnewton-mulga 85184d45f9 Merge branch 'dev-test' into dev 2026-05-01 12:15:05 +10:00
tomnewton-mulga 9d665f9dde bringing local networking changes onto dev branch 2026-05-01 12:13:34 +10:00
joshsiv-mulga 97f57d3826 fix(vpcd,viperblockd): retry NATS connect for ISO firstboot resilience
Both services used one-shot utils.ConnectNATS in launchService. On ISO
firstboot, when nats.service is starting in parallel, the dial can race
ahead of NATS being ready and exit non-zero. systemd's
Restart=on-failure with default StartLimitBurst=5 in 10s burns through
the budget in ~25s and the unit enters failed permanently — DHCP
manager never starts, vpc.dhcp.acquire requests hit no-responders and
the install hangs nondeterministically.

Match daemon/awsgw, which already use ConnectNATSWithRetry (5min
exponential backoff). The newDHCPManagerWithRetry guard (mulga-siv-40)
only helps after the dial succeeds.
2026-05-01 12:10:59 +10:00
Bryn Mailer 3c2f57101f refactor: lock free write path (#179)
* fix: remove predastore filesystem backend references

* fix: remove unused import

* chore: bump predastore version

* fix: create test buckets explicitly
2026-05-01 11:46:33 +10:00
Julian Sommer 4542b22e87 chore: decrease daemon resource pre allocation 2026-05-01 11:42:32 +10:00
Julian Sommer ca965e48d6 fix(daemon): deny cross tenant attach on empty tenant id volumes 2026-05-01 11:15:58 +10:00
tomnewton-mulga 09638a874d chore: merge fix/iso-public-ip-boot-race into dev-test
Conflict resolution:
- external_ipam_dhcp.go: take dev deletion (consolidated into dhcp package)
- external_ipam.go: take dev's dhcp.RequestAcquire call
- external_ipam_test.go: drop duplicate ObtainDHCPLease/ReleaseDHCPLease tests
- daemon.go: drop dead gwMAC block (GatewayMAC field not in dev's ExternalPoolConfig)
2026-05-01 10:59:57 +10:00
Josh Siviour b7dea45ee0 feat: Ansible dev lifecycle (parallel to bash scripts) (#178)
Playbooks for preflight / teardown / install / reset covering the same
ground as dev-install.sh, dev-setup.sh, reset-dev-env.sh. Existing bash
scripts untouched — decide which to keep after parity testing.

- roles/teardown + teardown catalog README track every known state
  source; add a task + row when a leak is reported.
- roles/init gates smoketest on awsgw readiness (describe-regions poll).
- roles/smoketest is dup-tolerant, retry-wrapped, auto-terminates the
  launched instance.
- dev-install.yml skips build/install/init when a healthy environment
  is already present (4-signal health check), always runs smoketest.
- dev-reset.yml captures region/az/external-mode scalars from the
  existing spinifex.toml and replays them into init.

Plan: docs/development/improvements/ansible-dev-lifecycle.md (mulga).
Bead: mulga-siv-9.
2026-05-01 10:04:37 +10:00
Josh Siviour 69404b38ce fix(vpcd): public-IP boot race + DHCP DORA hardening (siv-33/34/36/37/38/39) (#177)
* fix(installer): emit kernel cmdline to ttyS0 alongside tty0 (mulga-siv-33)

GRUB_CMDLINE_LINUX in /etc/default/grub only listed console=tty0, so the
installed kernel printed nothing on serial after the GRUB menu. Headless
installs (CI, racks with serial-only IPMI, remote-dev SSH-to-QEMU) saw a
black screen forever and had no path to debug boot failures.

Append console=ttyS0,115200 — the last `console=` on the cmdline becomes
the system console, so ttyS0 wins on serial-only hardware while tty0
still receives output on local displays.

Plan: docs/development/bugs/iso-public-ip-boot-race.md

* fix(ipam): widen vpc.dhcp.acquire retry budget to 60 s (mulga-siv-34)

ObtainDHCPLease retried only 5 attempts × 1 s before returning a hard
error. On a cold ISO boot, NATS comes up well before vpcd's reconcile
leader-election + dhcp.Manager subscribe (typical 5–30 s, pathological
~60 s during full reconcile), so daemon's NewExternalIPAM races vpcd's
dhcp.acquire subscription and fails permanently — d.externalIPAM stays
nil for the daemon's lifetime, daemon_handlers_instance.go silently
skips all auto-public-IP allocation, and instances launch with no
public IP, no dnat_and_snat, and no NAT event ever published.

Bump maxAttempts to 60 with 1 s spacing; expose both as package vars
so tests can shrink the budget for fast timeout-path coverage. Pure
widening of the existing retry loop — no behaviour change on the
success path, only the worst-case wallclock when vpcd never comes up.

The dominant cause of slow vpcd starts (mulga-siv-33: systemd-networkd
not enabled in the ISO → veth pair vanishes on reboot → vpcd
crashloops) is fixed alongside this. A 60 s budget fits comfortably
above all observed healthy-startup times.

Plan: docs/development/bugs/iso-public-ip-boot-race.md

* fix(setup-ovn): declare Linux WAN bridge as NetDev so networkd-managed veth resolves Bridge= on reboot (mulga-siv-33)

The veth-mode reboot persistence drop-ins added in mulga-998.b were
incomplete: setup-ovn.sh wrote `15-spinifex-veth-wan.network` with
`Bridge=br-wan`, but never declared br-wan itself to networkd.

br-wan is created by `networking.service` (ifupdown via
`/etc/network/interfaces`, generated by `install.go writeNetworkConfig`),
not networkd. networkd's `Bridge=` directive requires the target to be a
known NetDev — when it isn't, networkd silently logs
`br-wan NetDev could not be found, ignoring assignment` and the veth
ends up with no master. vpcd's bridge-mode sanity check then fails on
every reboot.

Fix: also write `14-spinifex-br-wan.netdev` declaring br-wan as
`Kind=bridge`. networkd matches the existing kernel bridge by name+kind
(the `Failed to create netdev: File exists` log is harmless), and
`Bridge=br-wan` resolves on next boot. ifupdown still owns DHCP and the
IP — networkd just needs the NetDev declaration to enslave the veth.

The write is gated to avoid clobbering operator-managed networkd config:
if any other `.netdev` under `/etc/systemd/network`,
`/run/systemd/network`, or `/usr/lib/systemd/network` already declares
the WAN bridge by name, the write is skipped with a log line pointing
at the operator file. Cleanup hook also removes the new file when
switching away from veth mode.

Verified end-to-end via QEMU repro: clean install + first boot + reboot,
veth-wan-br master persists across reboots, vpcd starts cleanly.

* fix(vpcd/dhcp): enable AF_PACKET promisc on bridge for DORA (mulga-siv-33)

Real-world DHCP servers commonly ignore the BOOTP BROADCAST flag and
reply with a unicast OFFER addressed to the synthetic per-tenant chaddr
(e.g. 02:f3:...). A Linux bridge floods unknown unicast to slave ports
but does NOT pass it up to the bridge netdev unless IFF_PROMISC is set,
so vpcd's AF_PACKET socket on br-wan misses the OFFER and DORA times
out at 15s ("unable to receive an offer: context deadline exceeded").

Dev/CI didn't hit this:
- CI uses spx_wan_mode=static — no DORA via vpcd.
- QEMU dev DHCP servers (SLIRP/dnsmasq) honor the BROADCAST flag, so
  their replies are L2 broadcast which the bridge always passes up.

Replace the three nclient4.New(iface, ...) call sites in Acquire/Renew/
Release with nclient4.NewWithConn(...) wrapping a custom AF_PACKET
DGRAM conn that calls SetPromiscuous(true). Promisc is per-socket
(per-DORA, ~15s) and refcounted by the kernel — auto-clears on close.

* revert(vpcd/dhcp): drop AF_PACKET promisc wrapper, restore nclient4.New

ecfbd4f swapped nclient4.New for openPromiscBridgeConn + NewWithConn to
catch unicast OFFER replies that bypass the bridge netdev. On dev hosts
where upstream honors the BOOTP BROADCAST flag, the wrapper regressed
DORA — public-IP allocation failed where main works. Reverting until
the promisc path is validated against both broadcast- and unicast-OFFER
servers.

* fix(setup-ovn): mark br-int/br-ext Unmanaged for systemd-networkd (mulga-siv-37)

Fresh ISO install on Trixie+systemd-networkd left the OVS internal netdevs
admin-down: networkd has no matching .network so it tags them as unmanaged
but never brings them up. OVS dataplane works but ovn-controller flow
programming and any tooling that probes link state misbehaves.

Drop /etc/systemd/network/05-spinifex-ovs-internal.network with
Unmanaged=yes so OVS itself owns the link state — `ovs-vsctl add-br`
already runs the netdev up at create time.

* fix(vpcd): allocate gateway LRP IP from pool in centralized NAT (mulga-siv-36)

In veth/macvlan modes the gateway LRP carries all egress, so it must hold
a WAN-subnet IP — otherwise the upstream router silently drops ARP for
the WAN nexthop (RFC 826) and the default route never resolves. The
hardcoded 169.254.0.1/30 was correct for distributed NAT but regressed
centralized NAT (introduced under mulga-siv-26).

Add gw_lrp_range_start/end to ExternalPool. In centralized mode at IGW
attach the gateway LRP gets a fresh IP from that range, persisted as
external_ids:spinifex:gateway_ip on the LRP for idempotent reconcile and
retrofit. Direct mode keeps link-local. Per-VM EIP allocator skips the
gw range; ValidatePoolConfig rejects overlap with the EIP range.

Adds TestTopologyHandler_IGWAttach_CentralizedNATAllocatesGwLrpIP and
extends RetrofitAllGatewayPortNetworks to migrate stale link-local LRPs
to allocated pool IPs at startup.

* fix(vpcd): drop link-local fallback, auto-derive gw_lrp_range (mulga-siv-36)

In centralized NAT (veth/macvlan) the gateway LRP must hold a WAN-subnet
IP — link-local cannot ARP-resolve the upstream nexthop, so the upstream
router silently drops egress. Hardware deployment confirmed the previous
fallback to 169.254.0.1/30 was always reached when gw_lrp_range was not
explicitly configured.

Auto-derive a 16-IP range at the top of the WAN subnet (broadcast - 16
.. broadcast - 1) when gw_lrp_range is unset; shift below RangeStart on
overlap with the per-VM EIP range; clamp inside the subnet; guard the
upstream gateway IP. IGW attach now errors out instead of falling back
to link-local when allocation fails — surfaces misconfiguration loudly.

Adds focused unit tests for gwLrpRange, ValidatePoolConfig and
nextAvailableExternalIP gw-lrp skip; updates the multi-VPC and
PoolWithGatewayIP regression tests.

* feat(vpcd): DHCP-acquire gateway LRP IPs (mulga-siv-38)

Per-VPC OVN gateway LRPs now obtain their WAN-subnet IP via vpc.dhcp.acquire
when the pool is source="dhcp", instead of carving from a static gw_lrp_range.
The upstream DHCP server becomes the single arbiter of LAN address space; a
cluster grows by N nodes without operator-side range carving in spinifex.toml.

- Consolidate ObtainDHCPLease/ReleaseDHCPLease into the dhcp package as
  RequestAcquire/RequestRelease so vpcd and the daemon-side ExternalIPAM
  share one helper. handlers/ec2/vpc keeps its existing ENI/EIP DHCP path.

- TopologyHandler dispatches by pool source: DHCP pools call DHCPManager
  via NATS (client-id "gw-lrp-<vpcId>", idempotent on retry); static pools
  keep the siv-36 gw_lrp_range / auto-derive allocator. Direct mode and
  no-pool deployments still use link-local.

- IGW detach issues vpc.dhcp.release so the upstream server reclaims the
  IP. Best-effort — server-side expiry is the safety net.

- Tests: TestTopologyHandler_IGWAttach_DHCPGwLrpIP (3 VPCs share one DHCP
  pool, distinct IPs, idempotent attach, release on detach),
  IGWAttach_DHCPAcquireFailureAborts (no link-local fallback when DHCP
  errors), TestPrefixFromMask. Existing static / auto-derive tests
  preserved.

* fix(vpcd): subscribe DHCPManager before reconcile to fix boot race (mulga-siv-38)

DHCPManager.Subscribe ran AFTER ReconcileFromKV / RetrofitAllGatewayPortNetworks,
so retrofit's vpc.dhcp.acquire requests for existing IGWs hit "no responders" and
burned the 60-attempt × 1 s cold-boot retry budget per VPC before failing. On a
dev box with one bootstrap VPC the symptom is a 60 s vpcd startup hang followed
by "allocate gw LRP IP failed", and the bootstrap IGW topology missing from OVN
until the next reconcile pass.

Move dhcpManager.Subscribe ahead of the two reconcile passes so the subscription
is live before any topology code can fan out an acquire.

* fix(vpcd): also subscribe DHCPManager before bootstrap reconcile pass

Previous fix moved DHCPManager.Subscribe ahead of ReconcileFromKV but
missed Pass 1 (the bootstrap-config Reconcile that runs even earlier).
On a dev box with a source="dhcp" pool and a bootstrap IGW, that earlier
pass still hit "no responders" and burned the 60-retry cold-boot budget
before the Manager came up. Move both reconcile passes after Subscribe.

* fix(vpcd): RFC 2131 §4.1 DORA exponential backoff (mulga-siv-39)

DHCPManager.Acquire previously did one DISCOVER and gave up after 15s
because the outer ctx.WithTimeout matched the per-attempt timeout —
nclient4's WithRetry(3) could never fire. Real DHCP servers (consumer
routers, ISC dhcpd, dnsmasq) silently drop OFFERs under load; the spec
mandates retransmission with exponential backoff, and a single lost
DISCOVER was killing legitimate boots.

Move retransmission into DHCPManager.acquireWithBackoff with a
4s/8s/16s/32s schedule (±1s jitter, ~60s worst case). NClient4Client
becomes single-attempt; manager owns retry policy. Default
acquireTimeout lifts from 15s to 90s (sum of schedule + slack).

* fix(vpcd): retry NewDHCPManager on JetStream-not-ready (mulga-siv-40)

NewDHCPManager creates the spinifex-dhcp-leases KV bucket via
utils.GetOrCreateKVBucket → js.CreateKeyValue with no retry. After the
mulga-siv-38 launchService reorder, this runs before any other JetStream
user, so on multi-node cold-boot vpcd hits "JetStream system temporarily
unavailable" and exits before cluster quorum forms. Pseudo-multinode CI
calls start_node_services sequentially, so node2's vpcd failure aborts
the script before node3 ever launches and quorum then never forms.

Wrap NewDHCPManager in newDHCPManagerWithRetry (500ms→10s, 5 min cap,
ctx-cancellable) mirroring daemon.initJetStream so vpcd waits as
patiently as the daemon for the cluster to come up.

* fix(daemon): idempotent DetachVolume QMP path (mulga-siv-41)

DetachVolume's QMP sequence was not retry-safe. First call: device_del
succeeds, blockdev-del fails with "Node nbd-vol-X is in use" because NBD
client teardown / in-flight I/O still hold the block node. Handler bails
ServerInternal "leaving volume state intact" but the guest device is
actually gone. AWS-CLI retries: device_del returns DeviceNotFound,
handler bails again. After 2 retries aws cli exits 254 — Single-Node
Phase 8 Step 3 reproducibly fails this way.

Phase 1: treat DeviceNotFound on device_del as already-detached, log and
continue. Detach is now idempotent across retries.

Phase 2: tryBlockdevDel polls blockdev-del up to 20×detachDelay on "in
use" errors so the I/O drain can complete instead of failing the whole
detach. Other QMP errors fall through immediately as before.
2026-05-01 09:48:52 +10:00
tomnewton-mulga 5c14f56428 Merge branch 'fix/iso-public-ip-boot-race' of github.com:mulgadc/spinifex into fix/iso-public-ip-boot-race 2026-05-01 09:11:59 +10:00
joshsiv-mulga 11b3f37d06 fix(daemon): idempotent DetachVolume QMP path (mulga-siv-41)
DetachVolume's QMP sequence was not retry-safe. First call: device_del
succeeds, blockdev-del fails with "Node nbd-vol-X is in use" because NBD
client teardown / in-flight I/O still hold the block node. Handler bails
ServerInternal "leaving volume state intact" but the guest device is
actually gone. AWS-CLI retries: device_del returns DeviceNotFound,
handler bails again. After 2 retries aws cli exits 254 — Single-Node
Phase 8 Step 3 reproducibly fails this way.

Phase 1: treat DeviceNotFound on device_del as already-detached, log and
continue. Detach is now idempotent across retries.

Phase 2: tryBlockdevDel polls blockdev-del up to 20×detachDelay on "in
use" errors so the I/O drain can complete instead of failing the whole
detach. Other QMP errors fall through immediately as before.
2026-05-01 08:52:49 +10:00
joshsiv-mulga c234cee60d fix(vpcd): retry NewDHCPManager on JetStream-not-ready (mulga-siv-40)
NewDHCPManager creates the spinifex-dhcp-leases KV bucket via
utils.GetOrCreateKVBucket → js.CreateKeyValue with no retry. After the
mulga-siv-38 launchService reorder, this runs before any other JetStream
user, so on multi-node cold-boot vpcd hits "JetStream system temporarily
unavailable" and exits before cluster quorum forms. Pseudo-multinode CI
calls start_node_services sequentially, so node2's vpcd failure aborts
the script before node3 ever launches and quorum then never forms.

Wrap NewDHCPManager in newDHCPManagerWithRetry (500ms→10s, 5 min cap,
ctx-cancellable) mirroring daemon.initJetStream so vpcd waits as
patiently as the daemon for the cluster to come up.
2026-05-01 08:52:41 +10:00
joshsiv-mulga efe054e4e4 fix(vpcd): RFC 2131 §4.1 DORA exponential backoff (mulga-siv-39)
DHCPManager.Acquire previously did one DISCOVER and gave up after 15s
because the outer ctx.WithTimeout matched the per-attempt timeout —
nclient4's WithRetry(3) could never fire. Real DHCP servers (consumer
routers, ISC dhcpd, dnsmasq) silently drop OFFERs under load; the spec
mandates retransmission with exponential backoff, and a single lost
DISCOVER was killing legitimate boots.

Move retransmission into DHCPManager.acquireWithBackoff with a
4s/8s/16s/32s schedule (±1s jitter, ~60s worst case). NClient4Client
becomes single-attempt; manager owns retry policy. Default
acquireTimeout lifts from 15s to 90s (sum of schedule + slack).
2026-04-30 23:01:05 +10:00
joshsiv-mulga 714898022e fix(vpcd): also subscribe DHCPManager before bootstrap reconcile pass
Previous fix moved DHCPManager.Subscribe ahead of ReconcileFromKV but
missed Pass 1 (the bootstrap-config Reconcile that runs even earlier).
On a dev box with a source="dhcp" pool and a bootstrap IGW, that earlier
pass still hit "no responders" and burned the 60-retry cold-boot budget
before the Manager came up. Move both reconcile passes after Subscribe.
2026-04-30 21:58:15 +10:00
joshsiv-mulga a6ef8e575b fix(vpcd): subscribe DHCPManager before reconcile to fix boot race (mulga-siv-38)
DHCPManager.Subscribe ran AFTER ReconcileFromKV / RetrofitAllGatewayPortNetworks,
so retrofit's vpc.dhcp.acquire requests for existing IGWs hit "no responders" and
burned the 60-attempt × 1 s cold-boot retry budget per VPC before failing. On a
dev box with one bootstrap VPC the symptom is a 60 s vpcd startup hang followed
by "allocate gw LRP IP failed", and the bootstrap IGW topology missing from OVN
until the next reconcile pass.

Move dhcpManager.Subscribe ahead of the two reconcile passes so the subscription
is live before any topology code can fan out an acquire.
2026-04-30 21:49:25 +10:00
joshsiv-mulga f5c9ad59cf feat(vpcd): DHCP-acquire gateway LRP IPs (mulga-siv-38)
Per-VPC OVN gateway LRPs now obtain their WAN-subnet IP via vpc.dhcp.acquire
when the pool is source="dhcp", instead of carving from a static gw_lrp_range.
The upstream DHCP server becomes the single arbiter of LAN address space; a
cluster grows by N nodes without operator-side range carving in spinifex.toml.

- Consolidate ObtainDHCPLease/ReleaseDHCPLease into the dhcp package as
  RequestAcquire/RequestRelease so vpcd and the daemon-side ExternalIPAM
  share one helper. handlers/ec2/vpc keeps its existing ENI/EIP DHCP path.

- TopologyHandler dispatches by pool source: DHCP pools call DHCPManager
  via NATS (client-id "gw-lrp-<vpcId>", idempotent on retry); static pools
  keep the siv-36 gw_lrp_range / auto-derive allocator. Direct mode and
  no-pool deployments still use link-local.

- IGW detach issues vpc.dhcp.release so the upstream server reclaims the
  IP. Best-effort — server-side expiry is the safety net.

- Tests: TestTopologyHandler_IGWAttach_DHCPGwLrpIP (3 VPCs share one DHCP
  pool, distinct IPs, idempotent attach, release on detach),
  IGWAttach_DHCPAcquireFailureAborts (no link-local fallback when DHCP
  errors), TestPrefixFromMask. Existing static / auto-derive tests
  preserved.
2026-04-30 21:26:06 +10:00
joshsiv-mulga c73e172fac fix(vpcd): drop link-local fallback, auto-derive gw_lrp_range (mulga-siv-36)
In centralized NAT (veth/macvlan) the gateway LRP must hold a WAN-subnet
IP — link-local cannot ARP-resolve the upstream nexthop, so the upstream
router silently drops egress. Hardware deployment confirmed the previous
fallback to 169.254.0.1/30 was always reached when gw_lrp_range was not
explicitly configured.

Auto-derive a 16-IP range at the top of the WAN subnet (broadcast - 16
.. broadcast - 1) when gw_lrp_range is unset; shift below RangeStart on
overlap with the per-VM EIP range; clamp inside the subnet; guard the
upstream gateway IP. IGW attach now errors out instead of falling back
to link-local when allocation fails — surfaces misconfiguration loudly.

Adds focused unit tests for gwLrpRange, ValidatePoolConfig and
nextAvailableExternalIP gw-lrp skip; updates the multi-VPC and
PoolWithGatewayIP regression tests.
2026-04-30 17:38:02 +10:00
tomnewton-mulga 2dae3f00bd Merge branch 'fix/iso-public-ip-boot-race' of github.com:mulgadc/spinifex into fix/iso-public-ip-boot-race 2026-04-30 16:42:48 +10:00
joshsiv-mulga 71ef7236ca fix(vpcd): allocate gateway LRP IP from pool in centralized NAT (mulga-siv-36)
In veth/macvlan modes the gateway LRP carries all egress, so it must hold
a WAN-subnet IP — otherwise the upstream router silently drops ARP for
the WAN nexthop (RFC 826) and the default route never resolves. The
hardcoded 169.254.0.1/30 was correct for distributed NAT but regressed
centralized NAT (introduced under mulga-siv-26).

Add gw_lrp_range_start/end to ExternalPool. In centralized mode at IGW
attach the gateway LRP gets a fresh IP from that range, persisted as
external_ids:spinifex:gateway_ip on the LRP for idempotent reconcile and
retrofit. Direct mode keeps link-local. Per-VM EIP allocator skips the
gw range; ValidatePoolConfig rejects overlap with the EIP range.

Adds TestTopologyHandler_IGWAttach_CentralizedNATAllocatesGwLrpIP and
extends RetrofitAllGatewayPortNetworks to migrate stale link-local LRPs
to allocated pool IPs at startup.
2026-04-30 16:04:10 +10:00
joshsiv-mulga 87dd7d242a fix(setup-ovn): mark br-int/br-ext Unmanaged for systemd-networkd (mulga-siv-37)
Fresh ISO install on Trixie+systemd-networkd left the OVS internal netdevs
admin-down: networkd has no matching .network so it tags them as unmanaged
but never brings them up. OVS dataplane works but ovn-controller flow
programming and any tooling that probes link state misbehaves.

Drop /etc/systemd/network/05-spinifex-ovs-internal.network with
Unmanaged=yes so OVS itself owns the link state — `ovs-vsctl add-br`
already runs the netdev up at create time.
2026-04-30 16:03:54 +10:00
Julian Sommer 6ec2f4bd08 fix: usb install doc formatting 2026-04-30 14:32:56 +10:00
joshsiv-mulga 82b0bdfd56 revert(vpcd/dhcp): drop AF_PACKET promisc wrapper, restore nclient4.New
ecfbd4f swapped nclient4.New for openPromiscBridgeConn + NewWithConn to
catch unicast OFFER replies that bypass the bridge netdev. On dev hosts
where upstream honors the BOOTP BROADCAST flag, the wrapper regressed
DORA — public-IP allocation failed where main works. Reverting until
the promisc path is validated against both broadcast- and unicast-OFFER
servers.
2026-04-30 14:26:43 +10:00
Julian Sommer 97977be286 fix: install-usb doc 2026-04-30 14:07:13 +10:00
tomnewton-mulga df2e652ca6 OVN GatewayMAC fixes 2026-04-30 12:17:17 +10:00
Julian Sommer 84278b794f chore: reintroduce dev branch in CI + scripts
- workflows: add 'dev' to push/PR triggers (spinifex.yml, ui.yml, e2e.yml)
- workflows: drop "Resolve Mulga Branch" fallback step; mulga is single-branch
  so checkout@v6 uses the default branch implicitly
- dependabot: route gomod/github-actions/npm PRs to dev
- diff-coverage.sh: pick origin/dev as base for feat branches, origin/main for dev
- iac/proxmox-template: default git_branch to dev for cluster builds

Refs docs/development/improvements/reintroduce-dev-branch.md
2026-04-30 11:50:13 +10:00
tomnewton-mulga 819a7a8cd3 Merge branch 'fix/iso-public-ip-boot-race' of github.com:mulgadc/spinifex into fix/iso-public-ip-boot-race 2026-04-30 11:14:10 +10:00
tomnewton-mulga 9f0b8c5d7d boradcast fixes 2026-04-30 11:14:03 +10:00
joshsiv-mulga ecfbd4fd19 fix(vpcd/dhcp): enable AF_PACKET promisc on bridge for DORA (mulga-siv-33)
Real-world DHCP servers commonly ignore the BOOTP BROADCAST flag and
reply with a unicast OFFER addressed to the synthetic per-tenant chaddr
(e.g. 02:f3:...). A Linux bridge floods unknown unicast to slave ports
but does NOT pass it up to the bridge netdev unless IFF_PROMISC is set,
so vpcd's AF_PACKET socket on br-wan misses the OFFER and DORA times
out at 15s ("unable to receive an offer: context deadline exceeded").

Dev/CI didn't hit this:
- CI uses spx_wan_mode=static — no DORA via vpcd.
- QEMU dev DHCP servers (SLIRP/dnsmasq) honor the BROADCAST flag, so
  their replies are L2 broadcast which the bridge always passes up.

Replace the three nclient4.New(iface, ...) call sites in Acquire/Renew/
Release with nclient4.NewWithConn(...) wrapping a custom AF_PACKET
DGRAM conn that calls SetPromiscuous(true). Promisc is per-socket
(per-DORA, ~15s) and refcounted by the kernel — auto-clears on close.
2026-04-30 10:53:59 +10:00
joshsiv-mulga 9bc15aa306 fix(setup-ovn): declare Linux WAN bridge as NetDev so networkd-managed veth resolves Bridge= on reboot (mulga-siv-33)
The veth-mode reboot persistence drop-ins added in mulga-998.b were
incomplete: setup-ovn.sh wrote `15-spinifex-veth-wan.network` with
`Bridge=br-wan`, but never declared br-wan itself to networkd.

br-wan is created by `networking.service` (ifupdown via
`/etc/network/interfaces`, generated by `install.go writeNetworkConfig`),
not networkd. networkd's `Bridge=` directive requires the target to be a
known NetDev — when it isn't, networkd silently logs
`br-wan NetDev could not be found, ignoring assignment` and the veth
ends up with no master. vpcd's bridge-mode sanity check then fails on
every reboot.

Fix: also write `14-spinifex-br-wan.netdev` declaring br-wan as
`Kind=bridge`. networkd matches the existing kernel bridge by name+kind
(the `Failed to create netdev: File exists` log is harmless), and
`Bridge=br-wan` resolves on next boot. ifupdown still owns DHCP and the
IP — networkd just needs the NetDev declaration to enslave the veth.

The write is gated to avoid clobbering operator-managed networkd config:
if any other `.netdev` under `/etc/systemd/network`,
`/run/systemd/network`, or `/usr/lib/systemd/network` already declares
the WAN bridge by name, the write is skipped with a log line pointing
at the operator file. Cleanup hook also removes the new file when
switching away from veth mode.

Verified end-to-end via QEMU repro: clean install + first boot + reboot,
veth-wan-br master persists across reboots, vpcd starts cleanly.
2026-04-29 23:35:42 +10:00
joshsiv-mulga 619f944d80 fix(ipam): widen vpc.dhcp.acquire retry budget to 60 s (mulga-siv-34)
ObtainDHCPLease retried only 5 attempts × 1 s before returning a hard
error. On a cold ISO boot, NATS comes up well before vpcd's reconcile
leader-election + dhcp.Manager subscribe (typical 5–30 s, pathological
~60 s during full reconcile), so daemon's NewExternalIPAM races vpcd's
dhcp.acquire subscription and fails permanently — d.externalIPAM stays
nil for the daemon's lifetime, daemon_handlers_instance.go silently
skips all auto-public-IP allocation, and instances launch with no
public IP, no dnat_and_snat, and no NAT event ever published.

Bump maxAttempts to 60 with 1 s spacing; expose both as package vars
so tests can shrink the budget for fast timeout-path coverage. Pure
widening of the existing retry loop — no behaviour change on the
success path, only the worst-case wallclock when vpcd never comes up.

The dominant cause of slow vpcd starts (mulga-siv-33: systemd-networkd
not enabled in the ISO → veth pair vanishes on reboot → vpcd
crashloops) is fixed alongside this. A 60 s budget fits comfortably
above all observed healthy-startup times.

Plan: docs/development/bugs/iso-public-ip-boot-race.md
2026-04-29 21:54:23 +10:00
joshsiv-mulga 7b8bb92905 fix(installer): emit kernel cmdline to ttyS0 alongside tty0 (mulga-siv-33)
GRUB_CMDLINE_LINUX in /etc/default/grub only listed console=tty0, so the
installed kernel printed nothing on serial after the GRUB menu. Headless
installs (CI, racks with serial-only IPMI, remote-dev SSH-to-QEMU) saw a
black screen forever and had no path to debug boot failures.

Append console=ttyS0,115200 — the last `console=` on the cmdline becomes
the system console, so ttyS0 wins on serial-only hardware while tty0
still receives output on local displays.

Plan: docs/development/bugs/iso-public-ip-boot-race.md
2026-04-29 21:54:07 +10:00
joshsiv-mulga 9b9bcc5b8f fix(setup): mask dhcpcd.service even when apt install skipped
bc33c72 added the mask but placed it after the SKIP_APT early-return.
CI bootstrap runs setup.sh with INSTALL_SPINIFEX_SKIP_APT=1 against
runners that already have dhcpcd-base, so the mask never ran. On
Debian-13 the auto-enabled dhcpcd.service kept binding br-wan and
racing vpcd's nclient4, reproducing the mulga-siv-32 DORA timeout
on the public-IP allocation path. Lift the disable+mask out of the
apt branch so it runs unconditionally.
2026-04-29 16:10:59 +10:00
Julian Sommer 6538a6cb69 feat: DescribeVolumeModifications (#174) 2026-04-29 15:54:43 +10:00
Josh Siviour 04f63ad8c6 feat(iso-installer): DHCP race fix + vpcd reconcile gate + ELB mgmt route + HashMAC + diagnostics (#172)
* preflight tweak

* fix: pin gateway LRP to link-local, end intra-cluster ARP collision

Every VPC's IGW gateway-LRP claimed pool.GatewayIP || pool.RangeStart on
attach. Multi-VPC clusters all GARP'd the same .160 with three different
deterministic MACs on the shared br-ext localnet, flapping the upstream
ARP cache and dropping 100% of inbound traffic to dnat_and_snat external
IPs (mulga-siv-26).

handleIGWAttach and reconcileIGW now unconditionally set the gateway
router port to 169.254.0.1/30. The default route's OutputPort already
pins the LRP, so the WAN nexthop need not be on-subnet. Pool IPs stay
allocated only for per-VM dnat_and_snat external addresses, which are
unique by construction.

Self-heal: RetrofitAllGatewayPortNetworks runs at startup and rewrites
stale Networks in place via the new UpdateLogicalRouterPort path. Needed
because Reconcile gates IGW work on "ext switch missing", so a cluster
shipped with the buggy CIDR never re-enters reconcileIGW.

Regression guards: TestTopologyHandler_IGWAttach_MultiVPC_NoIPCollision
and TestReconcileIGW_RewritesStaleGatewayPortNetworks. Existing tests
updated to assert link-local and OutputPort=&gwPortName. Log field
gateway_ip renamed to lrp_network to signal the semantic shift.

* fix: rewrite utils.HashMAC to SHA-256 / 46-bit output

The old 24-bit rolling hash with literal class prefix had birthday-paradox
collision probability of 1% at ~580 ids and 50% at 4096 — well within the
range of a single deployment's resource population (LRPs, ENIs, dev/mgmt
NICs). On a shared L2 segment, two clusters could trivially produce
duplicate MACs, breaking neighbour discovery and DHCP.

Switch HashMAC to SHA-256 across the entire input, taking the low 6 bytes
and forcing the IEEE 802 reserved bits (bit0=0 unicast, bit1=1 LAA) on the
first octet. Output entropy goes from 24 bits to 46 bits — 1% collision
boundary moves from ~580 to ~1.2M ids.

Drop the redundant prefix arg: it was no longer the literal output prefix
under the new scheme, just folded into the hash input. Class disambiguation
moves to the caller, where it's explicit. Three of four callers had nothing
to disambiguate (eni-, subnet-, gw-vpc- ids are already unique); dev and
mgmt NICs share instance.ID and now compose "dev:" / "mgmt:" tags.

Also consolidate vpcd.generateMAC, which was a local duplicate of the
broken algorithm rather than a HashMAC wrapper, into a HashMAC delegation.

Plan: docs/development/bugs/vpcd-mac-collisions-shared-l2.md
Bead: mulga-siv-27

* fix: pin HashMAC first octet to 0x02

Previous impl forced only the IEEE 802 reserved bits (bit0=0 unicast,
bit1=1 LAA) on the first octet, leaving the upper 6 bits hash-derived.
Technically valid LAA unicast, but produced MACs like 4a:.. and fe:..
that visually encroach on vendor OUI space and tripped CI/ops checks
expecting all generated MACs to start with 02:.

Pin byte 0 to literal 0x02; hash bytes 1-5 from sha256[0:5]. Output
entropy drops from 46 to 40 bits — birthday-paradox 1% collision at
~150k ids, still 256x the old 24-bit bound (~580).

* chore(e2e): dump cross-chassis OVN state on guest SSH failure

Adds chassis registrations, port_binding claims, gateway_chassis rows,
ovs-vsctl topology, Geneve tunnel state, and br-int tunnel flows to
dump_guest_ssh_diagnostics so we can pinpoint where the cross-chassis
dataplane stalls (cr-gw chassis vs remote chassis with the VM bound).

* fix: gate vpcd startup reconcile behind NATS KV leader election

N vpcds in a multi-node cluster all hit Get-then-Create on Logical_Router
with no atomicity, producing duplicate rows that ovn-nbctl rejects with
"Multiple logical routers named '...'. Use a UUID."

Add AcquireReconcileLeader using NATS KV CAS (kv.Create on a single key
in spinifex-vpcd-reconcile bucket). Only the elected vpcd runs Reconcile,
ReconcileFromKV, and the two retrofit passes — others Subscribe and rely
on the vpcd-workers queue group for runtime events. MaxAge=60s on the
bucket caps recovery time when a leader crashes mid-reconcile.

Falls through (elected=true) when KV is unavailable so first-boot does
not deadlock on a missing JetStream.

mulga-siv-29

* fix(elbv2): force mgmt host route for internal-scheme LBs (mulga-siv-30)

Single-node CI e2e Phase 3b ALB internal hangs in 'provisioning' for 270s
because the lb-agent inside the ALB VM has no return path to AWSGW.

The gateway-URL decision tree in daemon.wireLBAgentConfig picks advertiseIP
for single-node and deliberately skips adding the /32 mgmt host route — the
WAN-return-path concern in the comment is real but applies only to
internet-facing LBs, which have an EIP and an OVN dnat_and_snat pair that
gives reply traffic a way home. Internal LBs have no EIP / no SNAT, so when
the agent dials advertiseIP the reply targets the VM's private subnet IP from
the host's WAN with no route back.

Make lbVMUserData scheme-aware. New helper resolveMgmtRoute returns the
existing (MgmtRouteGateway, MgmtRouteTarget) when set (multi-node case) and
falls back to (MgmtBridgeIP, AdvertiseIP) when scheme is internal — only
internal LBs need the /32 route, so internet-facing single-node still gets
no bootcmd and the host-initiated WAN path stays clean.

Daemon now exposes MgmtBridgeIP and AdvertiseIP unconditionally so the
fallback has the inputs it needs.

* fix(daemon): stub sudoCommand in TestSetupComputeNode_ValidatesArgs (mulga-siv-31)

The test was calling real SetupComputeNode("chassis-test", ...) which shelled
out via sudoCommand to set external_ids:system-id=chassis-test on the host's
live OVS. On a dev box with OVS installed this overwrote the persistent UUID
(owned by openvswitch-switch and re-applied only on reboot), so vpcd's
discoverChassis filtered out the legitimate SBDB chassis row as 'stale local'
and crashlooped — taking out all VPC ops including ALB ENI provisioning.

Bind sudoCommand to a var so tests can swap it. Stub it in the test to return
exec.Command("/bin/false") and assert SetupComputeNode returns an error. The
test's intent (signature + error-path coverage) is preserved; nothing on the
host is touched.

* chore(vpcd): log DHCP Acquire concurrency + duration (mulga-siv-32)

Diagnostics-only change to surface whether nightly e2e cell-3 / cell-8
DORA timeouts correlate with concurrent Acquire calls on the same
bridge. Tracks per-bridge in-flight count and wallclock duration of each
Acquire; logs WARN on failure with both fields, DEBUG on success.

No behavioural change.

* ci(e2e-nightly): dump cluster journals on failure (mulga-siv-32)

run-multinode-e2e.sh dumps node journals via its own EXIT trap, but
run-cert-e2e.sh / run-lb-e2e.sh / reboot-e2e do not. When those scripts
fail the cluster is destroyed in Teardown without surfacing any daemon
logs, blocking root-cause analysis.

Add a workflow step that runs after the test scripts but before
Teardown when a previous step failed, ssh'ing to each node and pulling
the last 200 lines of every spinifex-* unit. Best-effort: skips
gracefully when inventory or nodes are unreachable.

Diagnostic-only; no behavioural change to the test path.

* fix(setup): mask dhcpcd.service to stop it racing vpcd's DHCP client (mulga-siv-32)

Debian 13 (Trixie) auto-enables a standalone dhcpcd.service when
dhcpcd-base is installed. It binds br-wan and races vpcd's in-process
nclient4 client for upstream OFFERs, draining the lab DHCP pool with
duplicate leases and causing intermittent DORA timeouts in the e2e
public-IP path (worst on cell-8 real-multi/dhcp/debian-13).

The ISO installer already masks the unit at install time
(cmd/installer/install/install.go:316). The binary install path used by
e2e nightly missed it, so debian hosts installed via setup.sh ran with
both DHCP clients live. Mirror the ISO mask here.

Ubuntu hosts are unaffected (dhcpcd-base does not auto-enable
dhcpcd.service on Noble).

* ci(e2e-nightly): also dump dhcpcd.service state on failure (mulga-siv-32)

Confirms or rules out the dhcpcd-vs-vpcd race hypothesis. Pulls the
dhcpcd.service journal alongside the spinifex-* units, plus
systemctl is-enabled / is-active and the DHCP client port (UDP 68)
listener so we can see directly whether dhcpcd is running and bound
to br-wan when an Acquire times out.

---------

Co-authored-by: Ben Duncan <5178243+benduncan@users.noreply.github.com>
2026-04-29 14:38:29 +10:00
Julian Sommer 3494ea4eb0 fix: don't log secret key 2026-04-29 14:31:39 +10:00
Julian Sommer 050054ad0f fix: enable alpine x86 image 2026-04-29 12:03:58 +10:00
Julian Sommer bc9f881034 feat: AWS input validation compliance (#171) 2026-04-29 11:19:44 +10:00
Julian Sommer 3dad452b09 feat: reserve compute for spinifex services (#170) 2026-04-28 16:11:49 +10:00
Julian Sommer 5911a878c6 fix: flaky daemon test 2026-04-28 14:40:37 +10:00
Julian Sommer 94735e7d19 chore: interface compliance comp time check 2026-04-28 14:27:33 +10:00
Julian Sommer b0d58ff0f6 chore: add missing tests 2026-04-28 14:18:56 +10:00
Julian Sommer a3d1d5f7b3 chore: delete zero-op tests (#169) 2026-04-28 14:10:46 +10:00
Julian Sommer a3994510e0 feat: e2e nightly reboot tests (#168) 2026-04-28 13:47:58 +10:00
Julian Sommer 3da6e3cfa9 docs: update DESIGN.md 2026-04-28 11:37:31 +10:00
Julian Sommer 138083f6c8 chore: only run e2e nightly mon to friday 2026-04-28 11:20:24 +10:00
Julian Sommer f2dcb2f33f fix: tofu nightly e2e 2026-04-28 10:58:05 +10:00
Julian Sommer 7128dc785c chore: dhcp e2e nightly 2026-04-28 10:54:19 +10:00
dependabot[bot] d663d143a4 chore: bump the npm-dependencies group across 1 directory with 22 updates (#164) 2026-04-28 10:37:24 +10:00
Josh Siviour dd30790d51 fix: reconcile external IPAM pool on config drift (mulga-siv-24) (#167)
initPool's first guard inverted: !errors.Is(err, ErrKeyNotFound) was
true when err==nil, so initPool returned immediately when a record
existed and never reached the drift-reconcile or already-initialized
branches. As a result, range_start/range_end/source changes in
spinifex.toml never propagated and nextAvailableExternalIP failed
with "invalid IP range:  - " on dhcp -> static transitions.

Restructure to: bail on hard errors, reconcile or no-op when record
exists, fall through to create when ErrKeyNotFound.

Verified on dev cluster: dhcp -> static toml change now reconciles
KV bounds on restart and RunInstances allocates from the new range.

Stale Allocated entries and stale gateway_ip on source change remain
out of scope for this hotfix; tracked under mulga-siv-24 follow-ups.
2026-04-28 10:20:51 +10:00
Julian Sommer a7f965526b fix: lint error 2026-04-28 09:59:28 +10:00
dependabot[bot] f3cfee3189 chore: bump actions/upload-artifact in the github-actions group (#163) 2026-04-28 09:16:05 +10:00
dependabot[bot] b2921dbad6 chore: bump golang.org/x/crypto (#162) 2026-04-28 09:11:29 +10:00
dependabot[bot] 1f5aee54d7 chore: bump postcss (#166) 2026-04-28 09:11:07 +10:00
Ben Duncan b18bad5b93 Merge branch 'feat/iso-installer' of github.com:mulgadc/spinifex into feat/iso-installer 2026-04-27 21:56:10 +10:00
Ben Duncan a2107bba8a Manual testing, spinifex.toml changes for dhcp/static, pool names, retry timeout for DHCP lease 2026-04-27 21:56:00 +10:00
joshsiv-mulga 05d5148611 Merge branch 'feat/iso-installer' of github.com:mulgadc/spinifex into feat/iso-installer 2026-04-27 21:18:40 +10:00
Ben Duncan 94600d7b84 OVN chassis issues, new reconsile function, reboot fixed (#165) 2026-04-27 15:48:47 +10:00
Ben Duncan a25bc6ef19 Merge branch 'feat/iso-installer' of github.com:mulgadc/spinifex into feat/iso-installer 2026-04-27 14:47:45 +10:00
joshsiv-mulga 8735275d84 Merge commit 'eeb7ebc5db77e06db11ed7a58b9a70cdcc4128fb' into feat/iso-installer 2026-04-27 13:09:51 +10:00
joshsiv-mulga 187b641726 fix: order spinifex-daemon after ovn-controller too
Symmetric to the openvswitch-switch ordering: ensures ovn-controller
shutdown waits for spinifex-daemon's instance teardown, the correct
general invariant for VM cleanup since logical port programming
flows through OVN. PR1a flagged this as optional follow-up.

Bead: mulga-siv-20.1
Plan: docs/development/bugs/orphan-tap-on-launch-race.md (PR1a)
2026-04-27 12:39:58 +10:00
joshsiv-mulga 660fe5227e fix: idempotent tap setup + race-free terminate cleanup
Three minimal edits to close the orphan-OVS-port failure mode that
survives PR1a's systemd ordering fix (host crash, OOM-kill of ovsdb,
operator manually stopping OVS on a live host) and the
terminate-during-pending race that leaks taps when a user aborts a
slow launch.

1. SetupTapDevice (network.go): unconditional `ovs-vsctl --if-exists
   del-port br-int <tap>` + separately-gated kernel `ip tuntap del`.
   The previous kernel-side-gated cleanup missed orphan OVS ports
   because OVS conf.db survives reboot while kernel taps don't.
   Rejected `--may-exist add-port`: it would silently keep stale
   external_ids:iface-id/attached-mac from a prior launch.

2. stopInstance mgmt-tap branch (daemon.go) + CleanupMgmtTapDevice
   (network.go): derive the mgmt tap name from instance.ID rather
   than reading instance.MgmtTap, since that field is set only after
   SetupMgmtTapDevice returns and a terminate racing mid-launch
   would skip cleanup. CleanupMgmtTapDevice gains a kernel-presence
   short-circuit so unconditional invocation doesn't log spurious
   "Device does not exist" errors for instances that never reached
   SetupMgmtTapDevice.

3. LaunchInstance (daemon.go): `launchStillValid` helper re-checks
   instance status before StartInstance and TransitionState, in
   addition to the entry guard. On detection of shutting-down /
   terminated, log Info and return nil — the concurrent terminate
   goroutine owns cleanup; this is an expected race outcome, not an
   error. Removes the spurious "Failed to transition instance to
   running" ERROR that surfaced on every legitimate
   terminate-during-pending. Plus the entry path now returns nil
   instead of an error for the same reason.

Also lands `scripts/start-then-terminate-pending.sh` (added during
investigation) which reproduces the runtime race for manual
verification.

Bead: mulga-siv-20.2
Plan: docs/development/bugs/orphan-tap-on-launch-race.md (PR1b)
2026-04-27 12:33:54 +10:00
joshsiv-mulga 58bdcd43f5 fix: order spinifex-daemon after openvswitch-switch on shutdown
systemd reverses After= on shutdown so daemon now runs CleanupTapDevice
while ovsdb-server still answers. Eliminates orphan OVS port left in
conf.db when host reboots — root trigger for "port already exists" on
post-reboot RunInstances. spinifex-vpcd.service already had this; the
daemon unit was missed.

Also pull openvswitch-switch + ovn-controller into spinifex.target Wants
so the platform brings them up explicitly rather than relying on
implicit ordering from spinifex-vpcd alone.

Bead: mulga-siv-20.1
Plan: docs/development/bugs/orphan-tap-on-launch-race.md (PR1a)
2026-04-27 12:19:40 +10:00
Julian Sommer eeb7ebc5db chore: enable nightly nginx-alb workbook (#161) 2026-04-24 17:14:57 +10:00
Ben Duncan e799af48e0 Merge branch 'feat/iso-installer' of github.com:mulgadc/spinifex into feat/iso-installer 2026-04-24 16:33:36 +10:00
joshsiv-mulga 1059412e28 fix: localSystemID + discoverChassis use Output() to skip sudo PAM noise
Follow-up to d509df7. The sudo PAM audit bug exists in two more vpcd call
sites beyond portToBr: localSystemID() and discoverChassis().

When vpcd.service's AmbientCapabilities trip sudo's PAM, stderr carries
"sudo: unable to send audit message: Operation not permitted". CombinedOutput
merges that into stdout and corrupts the parsed value:

  localSystemID → localID="sudo: ...\n\"add0104e-..."
  discoverChassis → tries to parse the stderr banner as chassis name/hostname

Downstream effect: discoverChassis skips the real local chassis as "stale",
vpcd falls back to cfg.ChassisNames (e.g. "chassis-josh-banksia") which no
chassis in SBDB actually uses, so every gateway_chassis row points at a
phantom chassis. cr-gw* chassisredirect ports never bind → no proxy-ARP
for EIPs → external ping fails with ARP timeout.

Switch both to Output() + exec.ExitError.Stderr, matching d509df7's pattern.

Found during mulga-998.b reboot drill while verifying external reachability:
chassis discovery log showed `localID="sudo: unable to send audit message...`,
`cr-gw-vpc-* chassis=[]`, and host ping to EIP returned Destination Host
Unreachable. With this fix applied, discoverChassis returns the real system-id
and (after stale gateway_chassis rows are cleared) cr-gw binds, proxy-ARP
responds, and EIP ping succeeds end-to-end.

Note: existing gateway_chassis rows written pre-fix still carry the phantom
chassis_name. Cleanup is one-shot — delete those rows and trigger an IGW
detach+attach, or run reset-dev-env.sh. vpcd does not reconcile existing
rows on startup (potential future retrofit, similar to Fix 3's
RetrofitAllExternalLocalnetOptions).
2026-04-24 16:31:19 +10:00
Ben Duncan 0434a823cc Merge branch 'feat/iso-installer' of github.com:mulgadc/spinifex into feat/iso-installer 2026-04-24 16:14:39 +10:00
Ben Duncan 40a42cbb73 grub branding tweaks 2026-04-24 16:14:30 +10:00
joshsiv-mulga d509df731a fix: portToBr uses Output() to skip sudo PAM audit noise
vpcd.service sets AmbientCapabilities which trips sudo's pam_limits into
emitting 'sudo: unable to send audit message: Operation not permitted'
on stderr. CombinedOutput merged that into stdout and poisoned the
port-to-br result, causing verifyBridgeMode to fail with a bogus bridge
name on service start after reinstall.

Switch to Output() (stdout only) + exec.ExitError.Stderr on error.
2026-04-24 16:10:55 +10:00
joshsiv-mulga c01207b342 merge: fix/dhcp-vpcd-issues (mulga-998.b Fixes 1+1b+2+3) into feat/iso-installer
Fixes DHCP/vpcd external-path issues discovered during reboot drill on josh-banksia:
- Fix 1: persist veth-wan pair via networkd .netdev/.network
- Fix 1b: admin-up veth-wan-ovs via second .network unit (peer stayed LOWERLAYERDOWN)
- Fix 2: loud detectBridgeMode + verifyBridgeMode sanity check
- Fix 3: retrofit nat-addresses=router + network_name=external on every vpcd startup via RetrofitAllExternalLocalnetOptions

Bead: mulga-siv-13 (closed). Plan: docs/development/bugs/dhcp-vpcd-issues.md.
Reboot-drill evidence: smoketest 9/9, Drill 2 retrofit verified post-reboot.

# Conflicts:
#	scripts/reset-dev-env.sh
#	spinifex/handlers/ec2/vpc/external_ipam.go
#	spinifex/services/vpcd/vpcd.go
2026-04-24 15:59:57 +10:00
joshsiv-mulga a00550a200 fix: retrofit localnet options on every vpcd startup (mulga-998.b Fix 3)
ensureLocalnetOptions was only invoked inside reconcileIGW, which is gated
on the external logical switch being absent — so a port whose options were
cleared or drifted from the current bridge mode would never be corrected
on restart. Drill 2 (stale-options recovery) exposed this: clearing
ext-port options and restarting vpcd left the port with options={}.

Changes:
- Extend ensureLocalnetOptions to also restore network_name=external (not
  just nat-addresses). A manual `ovn-nbctl clear` wipes both, and the port
  is non-functional without network_name.
- Add RetrofitAllExternalLocalnetOptions: walks every OVN logical switch
  tagged spinifex:role=external and calls ensureLocalnetOptions on its
  ext-port-<vpcId>. OVN is the source of truth; NATS KV records may be
  missing or stale.
- Call RetrofitAllExternalLocalnetOptions after ReconcileFromKV during
  vpcd startup.
- Tests: network_name restoration in both centralized and direct modes;
  RetrofitAll across multiple switches with mixed stale / correct / non-
  external state.
2026-04-24 15:31:42 +10:00
joshsiv-mulga 6b75470b0f fix: admin-up veth-wan-ovs via networkd (mulga-998.b Fix 1 follow-up)
Fix 1 persisted the veth pair across reboot via
15-spinifex-veth-wan.{netdev,network}, but only configured
veth-wan-br. Its peer veth-wan-ovs had no .network unit, so
networkd left it unmanaged and admin-DOWN after reboot. That
cascades: peer goes LOWERLAYERDOWN, br-wan loses carrier,
external DHCP/NAT path broken — despite vpcd correctly
detecting mode=veth and the sanity check passing.

OVS does not admin-up external (non-internal) ports — that's
networkd's job. Pre-reboot the setup-ovn.sh 'ip link set
veth-wan-ovs up' made it work, but that doesn't persist.

Write a second unit 16-spinifex-veth-wan-ovs.network with
ConfigureWithoutCarrier=yes so networkd flips admin state
without taking ownership of bridge/address assignment (OVS
retains that). Cleanup paths in the non-veth branch of
setup-ovn.sh and reset-dev-env.sh updated to remove the new
unit too.

Verified live via 'networkctl reload': veth-wan-ovs and
veth-wan-br both UP, br-wan LOWER_UP. Reboot drill per
docs/development/bugs/dhcp-vpcd-issues.md 'Live drill 1'
still required to confirm persistence.
2026-04-24 15:03:05 +10:00
joshsiv-mulga 825f7202b4 test: cover verifyBridgeMode empty-parameter branches
Add four branch tests for verifyBridgeMode that were missing from the
initial Fix 2 commit:
- direct mode rejects empty external_interface
- direct mode rejects empty dhcp_bind_bridge
- veth mode rejects empty dhcp_bind_bridge
- veth mode fails when veth-wan-br link missing

Lifts diff coverage from 59.5% to 65.6%, meeting the 65% gate.
2026-04-24 14:18:00 +10:00
joshsiv-mulga 853da4cc2a fix: loud detectBridgeMode + verifyBridgeMode sanity check (mulga-998.b Fix 2)
Make bridge-mode detection loud and fail-start when runtime state does
not match config. Closes D4/D12/D18 of dhcp-vpcd-issues.md.

Changes:
- detectBridgeMode: slog.Info at each decision, slog.Warn on fall-through
  (was silent Debug — let the veth persistence bug hide for weeks).
- Extract resolveBridgeConfig so mode/bind-bridge resolution is unit
  testable. Empty mode now stays empty so verifyBridgeMode rejects it
  with an explicit list of supported values.
- Add verifyBridgeMode: refuses to start when external iface missing
  (direct), when external iface not on the OVS bridge (direct), when
  veth-wan-ovs missing or attached to the wrong OVS bridge, or when
  veth-wan-br missing or on the wrong Linux bridge. References
  OvnExternalBridge (= "br-ext") so the sanity check tracks the contract,
  not a hardcoded string.
- Wire verifyBridgeMode into launchService. Error is loud and includes
  operator guidance ("re-run scripts/setup-ovn.sh").
- Injectable probes (ifaceIsMacvlan, ifaceExists, portToBr,
  readLinkMaster) so tests can exercise every branch without touching
  real interfaces.
- Tests: 16 new unit tests covering detectBridgeMode, resolveBridgeConfig,
  and all verifyBridgeMode branches.
2026-04-24 13:08:04 +10:00
joshsiv-mulga 904071099a fix: retrofit nat-addresses=router on mode transition (mulga-998.b Fix 3)
CreateLogicalSwitchPort only seeds options:nat-addresses at first
creation. If a VPC's localnet port was created during a vpcd run that
saw direct mode (e.g. pre-Fix 1 reboot without the veth pair), the port
keeps stale options forever — OVN never GARPs for the external IP and
centralized NAT stays broken.

Add TopologyHandler.ensureLocalnetOptions: read the LSP, diff
options["nat-addresses"] against useCentralizedNAT(), update only on
mismatch. Called from reconcileIGW after CreateLogicalSwitchPort so the
retrofit runs on every reconcile pass (no-op when already correct).

Mock tracks UpdateLogicalSwitchPortCalls so the idempotency test
asserts no write was issued.
2026-04-24 12:40:48 +10:00
joshsiv-mulga abd93098fc fix: persist veth-wan pair across reboot (mulga-998.b Fix 1)
setup-ovn.sh's veth branch creates veth-wan-br ↔ veth-wan-ovs but nothing
recreates the kernel interfaces on reboot. OVSDB remembers veth-wan-ovs
as a port on br-ext but the peer is gone — the external path silently
breaks and vpcd falls through to direct mode.

- setup-ovn.sh: write /etc/systemd/network/15-spinifex-veth-wan.{netdev,network}
  in the veth branch so systemd-networkd recreates the pair on boot. Add
  a single pre-case guard that rips down stale veth persistence + the OVS
  port + the kernel veth whenever WAN_BRIDGE_MODE != veth, so mode
  transitions are idempotent (D17).
- reset-dev-env.sh: delete the netdev/network units during cleanup so
  `make reset` gives a truly clean slate.
- spinifex-vpcd.service: After=systemd-networkd.service openvswitch-switch.service
  so vpcd's sanity checks see a populated kernel + OVSDB (D5).
2026-04-24 12:37:01 +10:00
Julian Sommer 5126a1df8d tweak: README.md link 2026-04-24 12:34:04 +10:00
joshsiv-mulga 112738198f fix: rename wan_bridge to dhcp_bind_bridge (mulga-998.a)
The old wan_bridge name misled operators into pointing the field at
the OVN-side bridge 'br-ext', which never sees LAN DHCP traffic. On
consumer-router LANs (WAN NIC enslaved to a Linux bridge) the vpcd
DHCP client's AF_PACKET bind failed silently, blocking the default
install path.

- Rename Go field WanBridge -> DhcpBindBridge and TOML key wan_bridge
  -> dhcp_bind_bridge across config, vpcd service, external IPAM,
  daemon pool wiring, admin settings, and spinifex.toml template.
- Rewrite detectedWanBridge() -> detectedDhcpBindBridge(): the
  Linux-bridge branch now returns the bridge name verbatim (e.g.
  "br-wan") instead of synthesising "br-ext". OVS-vs-Linux probe via
  ovs-vsctl is no longer needed for this field.
- Add checkLegacyWanBridgeKey() in cmd/service.go: vpcd fail-starts
  if the deprecated 'wan_bridge' TOML key or SPINIFEX_VPCD_WAN_BRIDGE
  env var is set. No silent alias, no auto-rewrite (per plan D3/D13).
- Template carries an operator-facing comment warning never to set
  the field to 'br-ext'.
- 7 new unit tests for the detector branches and fail-start paths.

Smoke on ben-bottlebrush: spx admin init --force writes
dhcp_bind_bridge matching the live br-wan Linux bridge; VM launched
with --map-public-ip-on-launch reached from LAN; restoring a
legacy-key backup causes vpcd to enter failed state with the
deprecated-key guidance log.

Plan: docs/development/bugs/dhcp-vpcd-issues.md (Fix 4)
Bead: mulga-998.a (unblocker for .b / .c)
2026-04-24 12:27:41 +10:00
Julian Sommer c7d4040dd3 feat: nightly e2e (#155) 2026-04-24 12:25:03 +10:00
Josh Siviour 068aef5589 feat: DDIL E2E test harness (mulga-siv-5) (#160)
* feat: scaffold DDIL E2E harness (cluster, SSH, daemon client)

Phase 1 of the DDIL E2E test harness (mulga-siv-5.1). Adds the
tests/e2e/ddil/ package tree with the three harness primitives required
by every sibling scenario/fault bead:

- harness/cluster.go — Cluster, Node, ClusterFromEnv, NodeFromEnv
- harness/ssh.go — typed SSH transport backed by golang.org/x/crypto/ssh,
  interface so dry-run and tests can stub it
- harness/daemon_client.go — thin HTTPS wrapper over /local/status,
  /local/instances (placeholder types until daemon-local-autonomy lands),
  plus /health for scaffolding validation

Also adds doc.go and README.md (run instructions + env-var contract). All
harness files carry //go:build e2e so default builds skip them.

Plan: docs/development/improvements/ddil-e2e-test-harness.md §0, §1.

* feat: add DDIL harness fault injection and link profiles

Phase 2 of the DDIL E2E test harness (mulga-siv-5.2). Adds the fault
injection and netem primitives consumed by Scenarios A–F:

- harness/fault.go — PartitionNode (per-peer iptables DROP with
  orchestrator-SSH sanity check), HealNode, KillNATS, StartNATS,
  RestartDaemonOnly, WaitForMode (polls /local/status for typed
  DaemonMode enum)
- harness/netem.go — ApplyNetem (tc qdisc replace, idempotent),
  ClearNetem (idempotent clear)
- harness/profiles.go — LinkProfile struct plus LAN, WAN, LTEDegraded,
  SATCOM, HFData, Flapping constants (unvalidated approximations per
  plan §2 table; validation tracking lives in TEST_COVERAGE.md)

All files carry //go:build e2e. Flapping profile is defined but
ApplyNetem rejects it — flap orchestration lands with the scenarios
that need it.

Plan: docs/development/improvements/ddil-e2e-test-harness.md §1, §2.

* feat: add DDIL harness witness VM helpers

LaunchWitnessVM launches a counter VM via the cluster's AWS gateway,
locates its QEMU host via ps, and captures a baseline counter reading.
ReadCounter/AssertProgressed tunnel SSH through the host's QEMU
hostfwd port to verify the workload progressed across a fault. README
documents the new DDIL_WITNESS_* and DDIL_GUEST_SSH_* env vars.

* feat: add DDIL harness snapshot and state assertions

TakeSnapshot / AssertPreserved compare /local/instances pre and post
fault, failing if IDs disappear or a running/pending instance regresses
to a terminal state. LocalStateRevision / AssertMonotonic read the
local state file revision from /local/status so scenarios can detect
the daemon rewinding its persisted state.

* feat: add DDIL harness reset, retry, and quarantine

ResetAllNodes brings every node back to a clean baseline (iptables -F,
tc netem clear on any interface carrying one, spinifex-nats and
spinifex-daemon started) then waits for cluster health — daemon /health
200 plus NATS routez peer count matching len(nodes)-1, mirroring the
shell suite's verify_nats_cluster predicate.

AssertCleanState is a pre-scenario baseline check that recovers a dirty
state via ResetAllNodes instead of failing outright, since a dirty
baseline usually means a previous scenario's cleanup was skipped.

Run wraps each scenario with quarantine handling (DDIL_QUARANTINED
skip) and a single reset-and-retry on failure. First-attempt failures
are not hidden; the retry is a diagnostic aid rather than a flake mask.

bd: mulga-siv-5.5

* feat: DDIL scenarios skeleton (6x t.Skip + TestMain + coverage drift)

Phase 1 of mulga-siv-5 (DDIL E2E harness): all six scenarios land as
TestScenario<L>_... stubs that t.Skipf on their hardening-epic dependency.
TestMain installs a SIGINT/SIGTERM trap that ResetAllNodes before
exiting; DDIL_DRY_RUN=1 short-circuits cluster setup and logs the planned
scenarios. TestCoverageDrift AST-parses sibling *_test.go files and
reconciles them against the DDIL table in TEST_COVERAGE.md so Phase 2/3
flips cannot silently diverge doc from code. TestHarnessSmoke covers the
AssertCleanState + ResetAllNodes round trip for the "full run on real
cluster" acceptance bullet.

* feat: add DDIL E2E CI workflow (workflow_dispatch only)

Phase 1 CI wiring for mulga-siv-5. Mirrors e2e_real_multi's
provision/bootstrap/teardown shape against the same ci-multi runner and
tofu-cluster, but runs the Go harness on the runner (outside the
cluster) so its SSH path survives the peer-traffic iptables rules that
Scenario C installs on a cluster node. Triggers are workflow_dispatch
only — nightly cron and on-main-merge come in mulga-siv-5.8 once ≥4
scenarios are asserting. results.json (go test -json) uploads as an
artifact so flake-rate visibility can be built on gh run view +
structured output, per the design doc.
2026-04-23 22:38:29 +10:00
Ben Duncan c908662f69 DHCP tweaks, inflight 2026-04-23 21:37:30 +10:00
Ben Duncan 6f0821d02b Latest merge 2026-04-23 15:01:28 +10:00
Josh Siviour 604693ea0c fix: wrap missed NATS/S3 dial sites with admin.DialTarget (#159)
siv-8.5 migrated daemon + awsgw but missed the sidecar services and
the spx CLI. vpcd and viperblockd read cfg.NatsHost / cfg.S3Host raw
from spinifex.toml; when BindIP=0.0.0.0 the TLS handshake fails SAN
verification because 0.0.0.0 is not a cert SAN. Same for the spx get
CLI path.

Call sites:
  spinifex/services/viperblockd/viperblockd.go:173  (NATS)
  spinifex/services/viperblockd/viperblockd.go:407  (S3 s3.S3Config)
  spinifex/services/viperblockd/viperblockd.go:533  (S3 nbd.NBDKitConfig)
  spinifex/services/vpcd/vpcd.go:277                (NATS)
  cmd/spinifex/cmd/get.go:93                        (CLI NATS)

admin.DialTarget rewrites 0.0.0.0:N to 127.0.0.1:N — loopback is always
a SAN and in-process sidecars share the host with NATS/predastore.
2026-04-23 14:42:13 +10:00
Ben Duncan 1d38311e07 main merge 2026-04-23 14:10:34 +10:00
Josh Siviour 75fb15be2b feat: spx admin init/join listen-vs-advertise split + br-mgmt OVS provisioning (#158)
* fix: provision br-mgmt as OVS bridge + loopback guard for LB gateway

Convert br-mgmt from an externally-provisioned Linux bridge to an OVS
bridge owned by scripts/setup-ovn.sh, giving every deployment type
(tofu-cluster, production single-node, production multi-node) a single
provisioning path.

setup-ovn.sh grows --mgmt-bridge / --mgmt-cidr / --mgmt-iface /
--no-mgmt-bridge flags. Default creates br-mgmt with fail-mode=standalone
(excluded from ovn-bridge-mappings so ovn-controller ignores it) and
assigns the mgmt IP via a systemd-networkd drop-in so it survives reboot.

SetupMgmtTapDevice / CleanupMgmtTapDevice switch from `ip link master`
to `ovs-vsctl add-port` / `del-port` to match the new bridge type.

Add a loopback guard to wireLBAgentConfig's fallback branch: on a
production single-node with awsgw bound to 127.0.0.1 and no mgmt bridge,
the fallback previously injected 127.0.0.1 into LB VM cloud-init —
silently unreachable because the VM's loopback is its own. The guard
leaves GatewayURL empty and the existing "LB agent gateway URL not
configured" error fires instead. Regression covered in
daemon_wire_lb_test.go.

* fix: auto-detect primary IP for admin init --bind default

Bare `spx admin init` used a 127.0.0.1 default that kept awsgw on loopback
only, so system VMs on br-mgmt could never reach it. Auto-detect the
default-route source IPv4 instead, which keeps TLS cert SANs specific
(still not a wildcard). Dev-install scripts now pass --bind 127.0.0.1
explicitly to preserve loopback-only behavior on laptops.

Closes mulga-siv-7.

* test: refresh wire lb loopback-bind comment for siv-7

* fix: dev-install scripts should let admin init auto-detect --bind

Pinning --bind to 127.0.0.1 on dev installs left awsgw loopback-only while
setup-ovn.sh still provisions br-mgmt, so LB VMs hit 10.15.8.1:9999 with
no listener. Drop the explicit --bind so auto-detect picks the primary IP
and the siv-6 mgmtRouteVia path routes LB traffic to a real listener.

* fix: bind awsgw and predastore on 0.0.0.0 in spinifex.toml template

PR #144 (nats TLS) made every service listen on --bind's specific IP. For
awsgw and predastore this broke localhost-reachability that the UI proxy
(spinifexui/spinifexui.go:193-194) and AWS CLI spinifex profile rely on —
latent since #144, surfaced by siv-7's auto-detect default on dev installs
(502 from the UI).

Hardcode awsgw and predastore host fields to 0.0.0.0 so both listen
wildcard; loopback clients (UI proxy, spinifex AWS CLI profile, same-host
daemon → predastore) keep working, LAN clients still reach them via the
LAN-IP SAN on the cert. daemon.host and nats.host stay as {{.BindIP}}
because peer-advertised values must not resolve to 0.0.0.0/loopback.
predastore.host normalization at config.go:207 rewrites 0.0.0.0 → 127.0.0.1
for local-node consumers, so existing callers see a usable connect address.

* revert: siv-7 --bind auto-detect + siv-6 loopback guard (prep mulga-siv-8)

Clears the deck before layering the listen-vs-advertise split
(mulga-siv-8). The plan restores --bind=0.0.0.0 as the single-node
default and introduces an explicit --advertise flag for off-host dial
targets; the reverted approach (auto-detect a specific IP at init,
hardcode 0.0.0.0 in the template, guard against loopback in
wireLBAgentConfig) becomes redundant once that lands.

Reverts:
- bb0ad3a fix: bind awsgw and predastore on 0.0.0.0 in spinifex.toml template
- f20a47b fix: dev-install scripts should let admin init auto-detect --bind
- 49b0b4e test: refresh wire lb loopback-bind comment for siv-7
- b88dfdf fix: auto-detect primary IP for admin init --bind default

Also drops the loopback guard from siv-6 (74b6e2d) wireLBAgentConfig
fallback and its regression test — subsumed by the GatewayURL
precedence rework in mulga-siv-8.5 (br-mgmt → AdvertiseIP → error,
no silent empty-string fallthrough). The br-mgmt OVS provisioning
from 74b6e2d is retained.

Plan: docs/development/bugs/spx-admin-init-bind.md

* feat: admin.DialTarget + AdvertiseHost helpers (mulga-siv-8.2)

Introduce the two small address-resolution helpers the listen-vs-
advertise split needs, with full unit coverage. No callers yet —
dial-site migration lands in mulga-siv-8.5.

- DialTarget(listenAddr): rewrites wildcard listen addresses
  (0.0.0.0:N, [::]:N, :N) to their loopback equivalents so in-process
  clients dial a target the daemon's cert already has in its SANs.
  Specific IPs, loopback, hostnames, and malformed input pass through.
- AdvertiseHost(listenAddr, advertiseIP): picks the host off-host
  clients should dial. Wildcard listen → advertiseIP; specific host →
  that host. Port is dropped so callers append the protocol-appropriate
  port.

Plan: docs/development/bugs/spx-admin-init-bind.md §Proposed Changes/1

* feat: plumb AdvertiseIP through config/formation (mulga-siv-8.3)

Pure data-model change. Add AdvertiseIP to:
- admin.ConfigSettings (local node, populated by init).
- config.Config (per-node; mapstructure "advertise").
- formation.NodeInfo (json "advertise_ip,omitempty") so the join handshake
  carries it between peers.

buildRemoteNodes now prefers n.AdvertiseIP and falls back to n.BindIP so
joined nodes from older clusters / pre-siv-8 joiners keep working.

No behaviour change yet — templates, CLI flag, and dial-site migration
land in siv-8.4 and siv-8.5.

* feat: --advertise flag + resolveAdvertiseIP + templates (mulga-siv-8.4)

Introduce the listen-vs-advertise split at the CLI boundary.

admin init/join:
- --bind default for init is 0.0.0.0 (was 127.0.0.1 under siv-7 revert).
- New --advertise <IP> flag on both init and join; optional, auto-detected
  from DetectNetwork().WAN.IP when --bind is wildcard or loopback.
- resolveAdvertiseIP(bindIP, advertiseFlag, detected) implements
  precedence: flag > specific bindIP > WAN auto-detect > 127.0.0.1 +
  warning.
- Join resolves advertise before building the JoinRequest so peers record
  a reachable dial target (not 0.0.0.0).
- isMultiNode: trigger on --nodes >= 2 regardless of bind literal; operator
  can now run "init --nodes 3" without typing a specific --bind.
- Init success message prints bind / advertise / loopback three-way split.

Templates:
- spinifex.toml: [nodes.X] gains advertise = "{{.AdvertiseIP}}" rendered
  only when AdvertiseIP is set.
- predastore-multinode.toml: iam.nats_url host wrapped in the same
  {{if eq .BindIP "0.0.0.0"}}localhost{{...}}{{end}} shim that
  predastore.toml already uses, so co-resident predastore dials its local
  NATS via loopback instead of 0.0.0.0.

Tests cover the resolveAdvertiseIP matrix, the template advertise field
(present + omitted), and the predastore nats_url loopback shim.

Dial-site migration and GatewayURL rework land in siv-8.5.

* feat: dial-site migration + GatewayURL AdvertiseIP fallback (mulga-siv-8.5)

Switch every in-process dial site to admin.DialTarget so the wildcard
listen address (0.0.0.0) is rewritten to loopback before Dial(): daemon
→ NATS and → predastore (boot, reconnect, status probe, S3 store, service
manifest write), awsgw → NATS. The TLS cert already SANs 127.0.0.1, so
loopback-dial a wildcard-listen service is always valid; the old
0.0.0.0 literal was tripping TLS SAN verification.

Rework wireLBAgentConfig GatewayURL precedence (spinifex/daemon/daemon.go):

  1. AWSGW on specific WAN IP + br-mgmt present → WAN IP (unchanged)
  2. br-mgmt present, AWSGW on 0.0.0.0     → br-mgmt IP (unchanged)
  3. d.config.AdvertiseIP (single-node default install) → AdvertiseIP (NEW)
  4. DevNetworking shim                    → 10.0.2.2 (unchanged)
  5. AWSGW on specific IP, no bridge       → that IP (unchanged)
  6. Else                                  → log error, skip assignment

Kills the pre-siv-8 silent empty-URL failure mode that made
CreateLoadBalancer fail with 'missing system credentials: gatewayURL='
on every fresh single-node install. Supersedes siv-6's loopback guard.

Extend SetupAWSCredentials with a wanIP parameter (reserved for a
future --operator-endpoint flag) and thread AdvertiseIP through
finalizeNodeSetup's three call sites (single-node init, multi-node
init, join). Print 'Bind / Advertise / Loopback' on multi-node init
and join completion to match the single-node summary.

Plan: docs/development/bugs/spx-admin-init-bind.md §2, §3
2026-04-23 14:02:12 +10:00
Julian Sommer 3c5e09a937 chore: enable frontend lint rules 2026-04-23 12:43:53 +10:00
Julian Sommer fd17c35330 chore: disable frontend lint rules 2026-04-23 12:23:55 +10:00
Ben Duncan d7c95bda5e feat: wire ExternalIPAM to NATS DHCP + move AF_PACKET to vpcd (mulga-991)
Replaces the sudo-dhcpcd shell-out in ObtainDHCPLease / ReleaseDHCPLease
with NATS request/reply against vpc.dhcp.acquire / vpc.dhcp.release.
The daemon-side IPAM passes its nats.Conn into NewExternalIPAM and
threads hostname (option 12) / vendor-class (option 60) through to
vpcd; ExternalIPAllocation now records LeaseExpiresUnix, DHCPServerID
and HWAddr on DHCP-sourced entries. allocateFromPool is restructured
to acquire the lease once up-front and rely on vpcd's idempotent
Manager for CAS-retry safety; a single release fires only if every
CAS attempt fails. Drops the stdout parser, the per-bridge mutex, and
the parse test.

Systemd: spinifex-daemon loses AF_PACKET, ProtectKernelTunables and
MemoryDenyWriteExecute are restored to =yes; spinifex-vpcd gains
AF_PACKET in RestrictAddressFamilies, AmbientCapabilities=CAP_NET_RAW,
and CAP_NET_ADMIN / CAP_NET_RAW / CAP_NET_BIND_SERVICE in the bounding
set. Net privilege footprint narrows.

See docs/development/bugs/dhcp-allocate-error.md.
2026-04-23 12:21:55 +10:00
tomnewton-mulga 265556dc4e updated ISO doc 2026-04-23 11:27:26 +10:00
Julian Sommer 0f89db1893 chore: extract validation logic from gateway function 2026-04-23 10:46:16 +10:00
Julian Sommer 3b0376c3a6 fix: Makefile 2026-04-23 10:04:11 +10:00
tomnewton-mulga cc3c77fdb7 Merge commit '0a7832440504442f685e85048d545848a8f2b8f6' into feat/iso-installer 2026-04-23 09:07:08 +10:00
Ben Duncan f73e151c77 feat: in-process DHCP client and Manager in vpcd (mulga-990)
Introduces the spinifex/services/vpcd/dhcp package with a Client
interface, typed Lease struct, nclient4-backed implementation, and a
Fake for unit tests. Adds DHCPManager in the vpcd package: subscribes
to vpc.dhcp.acquire / vpc.dhcp.release NATS requests, runs a renewal
goroutine per lease with ±10% jitter on T1/T2, persists leases in the
new spinifex-dhcp-leases KV bucket, and fans out vpc.dhcp.lease-expired
when a lease permanently fails to renew. Wires the Manager into
launchService so it starts on vpcd boot and rehydrates persisted leases
via Bootstrap.

Handler-side wiring (ExternalIPAM → NATS) and the systemd capability
move land separately in mulga-991.

See docs/development/bugs/dhcp-allocate-error.md.
2026-04-22 16:45:30 +10:00
Ben Duncan 889ef361eb fix: interim -T retrofit for dhcpcd EIP allocation
Stop ObtainDHCPLease from erroring on hosts where dhcpcd is already
running as a daemon managing br-wan (e.g. server 2). Add -T (test-only
mode) so the invocation does not collide with the host daemon's
control socket on the interface.

Softens ProtectKernelTunables and MemoryDenyWriteExecute in the daemon
unit to accommodate dhcpcd's runtime requirements; both revert when
the in-process Go DHCP client rewrite lands (mulga-989 / mulga-990 /
mulga-991, see docs/development/bugs/dhcp-allocate-error.md).

Interim: leaves the phantom-lease and release-socket-clobber risks
active until the rewrite replaces this path entirely.
2026-04-22 16:30:38 +10:00
tomnewton-mulga 7fd1019efa tweaks to smoke-test.sh 2026-04-21 17:14:14 +10:00
Julian Sommer 0a78324405 feat: missing image commands (#156) 2026-04-21 15:10:09 +10:00
tomnewton-mulga 7cc90f554c daemon race fix 2026-04-21 14:54:00 +10:00
tomnewton-mulga f0d48c4274 Merge branch 'feat/iso-installer' of github.com:mulgadc/spinifex into feat/iso-installer 2026-04-21 14:33:46 +10:00
Ben Duncan b79460f9ff Merge branch 'feat/iso-installer' of github.com:mulgadc/spinifex into feat/iso-installer 2026-04-21 14:31:54 +10:00
Ben Duncan 93a6704944 Merge remote-tracking branch 'origin/main' into feat/iso-installer 2026-04-21 14:29:16 +10:00
Josh Siviour 1e3d82f978 fix: route elbv2 ALB public IP through EIP service (#157)
Internet-facing ALBs allocated their public IP by calling
externalIPAM.AllocateIP() directly from LaunchSystemInstance, bypassing
the EIP service entirely. No EIPRecord landed in the eips KV bucket, the
tag was "auto_assign" (EIP service uses "elastic_ip"), and
DescribeAddresses never returned the IP — so any OpenTofu flow that
expects an AllocateAddress-observable record hung waiting.

LaunchSystemInstance now calls eipService.AllocateAddress +
AssociateAddress when the EIP service is wired (falls back to the old
direct IPAM path otherwise). TerminateSystemInstance releases through
the service so the KV record is cleaned up and vpc.delete-nat is
published. The generic stopInstance teardown is left untouched so
customer RunInstances flows behave identically.

Also refreshes the nginx-alb terraform example to use public subnets
with auto-assigned public IPs (the configuration that actually exercises
this path end-to-end) and tightens the awsgwBindIP loopback guard in
wireLBAgentConfig so the gateway URL is usable by LB agents.
2026-04-21 13:52:48 +10:00
Julian Sommer 1ca8621e16 fix: allow mulga feat branch 2026-04-21 11:14:29 +10:00
tomnewton-mulga 56a1ead706 extra pics for ISO docs 2026-04-21 11:05:38 +10:00
Julian Sommer acefd1cfd4 chore: make README have go 1.26 2026-04-21 10:19:04 +10:00
Julian Sommer 1bad55ce54 chore: update frontend build 2026-04-21 09:20:51 +10:00
dependabot[bot] 869184efb5 chore: bump the go-dependencies group with 2 updates (#153) 2026-04-21 09:01:49 +10:00
dependabot[bot] b46fe2531a chore: bump the npm-dependencies group (#154) 2026-04-21 09:01:29 +10:00
tomnewton-mulga c4b2b13c89 local tests for ISO 2026-04-20 17:11:18 +10:00
Ben Duncan 49acc12390 Merge branch 'feat/iso-installer' of github.com:mulgadc/spinifex into feat/iso-installer 2026-04-20 16:56:38 +10:00
Ben Duncan 3d1f8340be ISO tweaks, email prompt on installer, improved network detection 2026-04-20 16:56:29 +10:00
tomnewton-mulga 3a5d823fbd Merge branch 'feat/iso-installer' of github.com:mulgadc/spinifex into feat/iso-installer 2026-04-20 15:16:34 +10:00
Ben Duncan 27d7fdd12b Latest changes re ISO and binary install path 2026-04-20 15:14:32 +10:00
joshsiv-mulga d6863f3d5e Change LB ISO download to Cloudflare host 2026-04-20 14:18:22 +10:00
tomnewton-mulga 3c6077ef58 ISO install doc tweaks 2026-04-20 13:10:11 +10:00
Ben Duncan 728cdd972b Merge branch 'feat/iso-installer' of github.com:mulgadc/spinifex into feat/iso-installer 2026-04-20 12:13:25 +10:00
Ben Duncan c3242b6bc4 Improved reset-dev-env and validated 2026-04-20 12:09:40 +10:00
Josh Siviour f97ff772a3 fix: sentinel TLS errors + shared NATS retry (#152)
* fix: replace stringly-typed TLS error detection with sentinel errors

Export ErrCACertRead and ErrCACertParse from utils/nats.go so callers can
detect permanent TLS configuration failures with errors.Is instead of
substring matching on error messages. Update daemon.go and awsgw.go retry
loops accordingly, and update tests to use assert.ErrorIs.

Refs: mulga-siv-1

* refactor: extract shared NATS connect-with-retry into utils

daemon.connectNATS() and awsgw.connectNATS() were near-identical retry
loops. Extract to utils.ConnectNATSWithRetry() with functional options
for test configurability.
2026-04-20 12:04:40 +10:00
tomnewton-mulga f4e0b4a01b Merge branch 'feat/iso-installer' of github.com:mulgadc/spinifex into feat/iso-installer 2026-04-20 11:41:40 +10:00
tomnewton-mulga 6736722533 Merge branch 'feat/iso-installer' of github.com:mulgadc/spinifex into feat/iso-installer 2026-04-20 11:40:18 +10:00
tomnewton-mulga 9d51800f58 fixed duplicate IP bug between physical and VM nodes 2026-04-20 11:40:10 +10:00
Ben Duncan 36425a48eb refactor: rewrite reset-dev-env.sh against production layout
Reset now targets /etc/spinifex, /var/lib/spinifex, /var/log/spinifex,
/run/spinifex (the privilege-separated layout installed by setup.sh) and
drives re-install from a source build via dev-install.sh instead of the
legacy in-tree $HOME/spinifex + start-dev.sh flow.

Capture region, AZ, external_mode, pool range, gateway, and prefix from
the existing spinifex.toml before the wipe so the reset restores the
current topology rather than applying defaults. Fresh boxes fall back to
ap-southeast-2 and a pool derived from the WAN subnet.

Drop the macvlan branch entirely — bridge-only from here on. Operators
on single-NIC hosts must run reset from the console (documented in the
script header).

dev-install.sh gains DEV_INSTALL_SKIP_INIT so reset can own the post-
setup.sh steps (setup-ovn, spx admin init with preserved args, CA
trust, service start, LB image import). Also honors SPINIFEX_REGION /
SPINIFEX_AZ for non-reset callers, and scopes systemctl reset-failed
to spinifex-* so unrelated failed units aren't silently cleared.

Closes mulga-983.
2026-04-20 11:38:10 +10:00
Ben Duncan a209805ffc Merge branch 'feat/iso-installer' of github.com:mulgadc/spinifex into feat/iso-installer 2026-04-20 11:27:58 +10:00
Ben Duncan b591c04880 firstboot tweaks 2026-04-20 11:27:49 +10:00
tomnewton-mulga ad3762bdcc fixing duplicate IP issue 2026-04-20 10:29:50 +10:00
tomnewton-mulga 7b9fdeb60c new docs 2026-04-17 17:08:28 +10:00
tomnewton-mulga 9c98c023ce fixed GRUB splash and drafted ISO README 2026-04-17 16:20:25 +10:00
Julian Sommer f00a376d0b tweak: docs 2026-04-17 16:09:06 +10:00
Julian Sommer c469eecfa1 chore: doc tweaks 2026-04-17 15:27:14 +10:00
tomnewton-mulga aa1fc546bd Merge branch 'main' into feat/iso-installer 2026-04-17 14:48:37 +10:00
Julian Sommer 4f991095ee feat: physical security guide 2026-04-17 14:28:43 +10:00
Ben Duncan a64ee2b2da ISO tweaks 2026-04-17 14:22:34 +10:00
Julian Sommer 14efe44125 feat: flaw remediation policy doc (#151) 2026-04-17 14:15:44 +10:00
Julian Sommer e131caf9ec fix: doc links 2026-04-17 13:41:58 +10:00
Julian Sommer e46b427938 feat: network-connections docs (#150) 2026-04-17 12:44:48 +10:00
Julian Sommer 8d6f4df526 fix: setting up cluster image 2026-04-17 12:13:44 +10:00
Julian Sommer 1a41731ef5 chore: tweak malware-protection doc 2026-04-17 11:59:10 +10:00
Julian Sommer 6f58a3f190 feat(docs): malware protection guide (#149) 2026-04-17 11:17:16 +10:00
Julian Sommer 883fac980c fix: remove unused /join /config endpoints (#148) 2026-04-17 10:32:49 +10:00
tomnewton-mulga 72503cd7ae fix: prevented systemd blocking 2026-04-16 17:58:54 +10:00
Julian Sommer 084e93cfd0 feat: validate image checksums (#147) 2026-04-16 17:00:27 +10:00
Ben Duncan d8924a2ecc ISO tweaks and doc template 2026-04-16 15:38:56 +10:00
Julian Sommer 6f02f6bc22 feat(vpcd): log=true on default-deny ACLs (#145) 2026-04-16 14:45:21 +10:00
Julian Sommer 3986549783 fix: log failed attempts at warn level for compliance 2026-04-16 13:51:46 +10:00
Julian Sommer ff9f9485d2 feat: nats TLS (#144) 2026-04-16 13:46:12 +10:00
Julian Sommer 29ba511b38 feat: disable public nats monitoring (#143) 2026-04-16 11:31:28 +10:00
Julian Sommer 8e9aff3299 chore: update old doc 2026-04-16 10:15:10 +10:00
Julian Sommer 911b36a696 refactor: extract shared helpers from admin init/join (#142) 2026-04-16 10:00:22 +10:00
Ben Duncan 402a04e1b9 Merge branch 'feat/iso-installer' of github.com:mulgadc/spinifex into feat/iso-installer 2026-04-16 09:29:15 +10:00
Ben Duncan 25d02707d0 ISO tweaks 2026-04-16 09:29:00 +10:00
Julian Sommer bb9cd8ea23 feat: formation join token (#141) 2026-04-15 16:55:55 +10:00
tomnewton-mulga 0e51a6b06a fix: enable AWS CLI install in firstboot (remove SKIP_AWS flag) 2026-04-15 16:21:56 +10:00
tomnewton-mulga d3e21d3360 fix: added remove usb prompt 2026-04-15 16:21:56 +10:00
Julian Sommer 32effaa16e Merge branch 'main' into feat/iso-installer 2026-04-15 15:50:37 +10:00
tomnewton-mulga 054485e930 refactor: move systemd unit files into installer/systemd package
Extract all inline systemd unit and drop-in content from install.go and
firstboot.go into a dedicated cmd/installer/systemd package for editability.
Bash scripts remain inline in firstboot.go.

Also copies the GRUB splash image from the live ISO into the installed
system's /boot/grub/ and sets GRUB_BACKGROUND so the branded background
appears in the post-install GRUB, not just the installer GRUB.
2026-04-15 15:34:40 +10:00
Ben Duncan c8429f98ae Update ALB doc 2026-04-15 15:25:48 +10:00
Julian Sommer 68ae17dcf1 chore: rename app-id to client-id 2026-04-15 15:03:16 +10:00
Julian Sommer 5b39efb078 fix: make e2e use env vars 2026-04-15 13:40:22 +10:00
tomnewton-mulga 24429ee8b5 fix: simplify firstboot — delegate setup to setup.sh with embedded tarball
Remove the duplicated chown/ownership/symlink block from the firstboot
script. setup.sh (called with INSTALL_SPINIFEX_TARBALL) already handles
service users, directory ownership, sudoers, systemd unit installation,
and enabling spinifex.target — the same steps as the online curl|bash
install.

Firstboot now follows the documented install flow directly:
  1. setup.sh --tarball (air-gapped equivalent of curl|bash)
  2. setup-ovn.sh --management
  3. spx admin init
  4. systemctl start spinifex.target

Also reduce wait-for-nats.sh timeout from 90s to 30s per reviewer feedback.
2026-04-15 13:36:07 +10:00
Julian Sommer 7d6e0780b5 feat: action rate limiting (#140) 2026-04-15 12:19:30 +10:00
tomnewton-mulga 83f1766081 fix: fixed TUI breaking at the end 2026-04-15 11:54:57 +10:00
tomnewton-mulga 811a0230fc fix: fall back to tty1 when requested TTY unavailable instead of aborting 2026-04-15 11:17:58 +10:00
tomnewton-mulga 69c8b20b78 fix: harden ISO installer error handling and safety checks
- partUUID: error on empty blkid output instead of writing UUID= to fstab
- installBootloader: fail if both EFI and BIOS targets fail; track efiErr
- machine-id fallback: write at mode 0600 so systemd can commit on first boot
- Run: defer unmount so partitions are always cleaned up on failure
- copyRootfs: verify critical paths exist after rsync before continuing
- resolveNIC: skip virtual/bridge interfaces (docker0, virbr0, ovs-system)
- EjectAndReboot: use syscall.Reboot instead of exec(reboot) which needs D-Bus
- ui.Run: return error on TTY open failure instead of silent fallback
- firstboot script: trap EXIT to always self-disable, preventing retry loops
- enableUnit/enableBannerUnit: deduplicate into enableSystemdUnit; check Remove
- runAdminBanner: promote service restart failure to slog.Error
- parseNodeConf: log non-ENOENT read failures
2026-04-15 10:59:51 +10:00
Julian Sommer f0bb064237 Merge branch 'main' into feat/iso-installer 2026-04-15 09:57:15 +10:00
Julian Sommer 1ad03bb518 feat(gateway): per-IP auth failure rate limiting (#138) 2026-04-15 09:56:04 +10:00
tomnewton-mulga c75f4a0160 fix: eject iso boot device 2026-04-15 09:46:53 +10:00
tomnewton-mulga 01cd3b9ab1 Merge commit 'b7b639084b3c2a40b97c2f543b88ad498901b77d' into feat/iso-installer
# Conflicts:
#	Makefile
#	cmd/spinifex/cmd/admin.go
#	go.sum
2026-04-14 16:11:49 +10:00
Julian Sommer b7b639084b chore: use ts config files 2026-04-14 14:28:00 +10:00
Julian Sommer 7dcad2ba1d chore: remove unused e2e 2026-04-14 13:59:29 +10:00
Julian Sommer 7cf0783122 fix: set dev tags as prerelease 2026-04-14 13:29:05 +10:00
Josh Siviour 13eae4d40b fix(elbv2): tag-based LB AMI lookup with hard error on miss (#137)
Replace fragile image-name substring match (-lb-/-alb-) with a
DescribeImages tag filter on spinifex:managed-by=elbv2. Remove the
silent fallback that picks the first image in the store when no LB
image exists — CreateLoadBalancer now returns ServerInternal with a
remediation hint instead of persisting a record in StateFailed.

- daemon: tag filter + error return from the resolver closure
- service_impl: resolver signature is func() (string, error); caches
  only successful results so the next call retries after an error
- CreateLoadBalancer gate now requires launcher + networking; AMI is
  resolved inside the gate and failures short-circuit before persist
- admin images import: add --name+--file combo (catalog metadata with
  a local file source) and a --tag key=value flag for explicit tagging
  of file-only imports
- build-system-image.sh + lb.conf: propagate SYSTEM_TAG via --tag so
  locally built system AMIs get the right tag even when the catalog
  is bypassed

Closes mulga-969
2026-04-14 12:52:28 +10:00
Julian Sommer b32111102d fix: README.md 2026-04-14 12:06:33 +10:00
Ben Duncan 71d0f31997 Merge branch 'main' of github.com:mulgadc/spinifex 2026-04-14 11:45:46 +10:00
Ben Duncan f99eb42ca7 Doc tweaks and README for release 2026-04-14 11:45:20 +10:00
Julian Sommer 56cdc158a6 chore: bump viperblock 2026-04-14 11:17:48 +10:00
Julian Sommer a4ae813e38 fix: recursive chmod on shared data dirs so existing image files are group-readable
chmod 0770 without -R only affected the top directory; pre-existing files
(e.g. images imported while owned by another user at mode 0600) stayed
unreadable to the spinifex group, breaking image import on upgrade paths.
2026-04-14 11:05:01 +10:00
Julian Sommer e0118975d1 fix: setup.sh 2026-04-14 10:17:01 +10:00
Julian Sommer 36e3481ca3 fix: enable spinifex.target so services autostart on boot 2026-04-14 10:14:09 +10:00
Julian Sommer 515c313e07 fix: setup.sh newgrp 2026-04-14 10:07:59 +10:00
Julian Sommer 0547bdd1fe feat: loadbalancer frontend (#136) 2026-04-14 09:24:14 +10:00
Julian Sommer e75953f5e2 chore: build ui with latest deps 2026-04-14 09:07:59 +10:00
dependabot[bot] e5a6f3ea60 chore: bump the npm-dependencies group (#134) 2026-04-14 08:59:32 +10:00
dependabot[bot] 36da673ecd chore: bump the github-actions group with 2 updates (#133) 2026-04-14 08:59:01 +10:00
tomnewton-mulga ca63011f25 feat: GRUB env var headless install, disk selection modes, banner
- autoinstall: replace file scanner with env var reader (SPINIFEX_AUTO,
  SPINIFEX_HOSTNAME, etc.) set by spinifex-init.sh from kernel cmdline
- autoinstall: disk selection modes — auto (single or fail), largest,
  smallest, nvme, or exact path; clear error listing available disks
  when auto/nvme finds multiple candidates
- admin banner: add Login row showing spinifex account name
- admin banner: write to /etc/issue (pre-login console) as well as
  /etc/motd (post-login SSH); fix SSH line root@ → spinifex@
2026-04-13 15:37:16 +10:00
Ben Duncan 96b5ad5740 Minor doc tweaks and permissions 2026-04-13 15:23:18 +10:00
tomnewton-mulga e8cb102819 feat: headless autoinstall mode via autoinstall.toml on boot media
Scans the USB EFI partition at boot for autoinstall.toml. When
enabled = true, the installer runs fully unattended using values from
the config file then ejects the USB and reboots — no user interaction
needed.

- New autoinstall package: TOML config struct, boot-media scanner,
  install.Config converter, EjectAndReboot
- Scanner skips iso9660 mounts so the bundled reference copy of the
  template never accidentally triggers headless mode
- "auto" disk target selects the largest non-removable disk
- "auto" NIC target picks the first physical interface
- Validates all required fields before touching any disk
- main.go: check for autoinstall config before launching TUI
2026-04-13 13:19:05 +10:00
Ben Duncan ebd0a64fe5 chore: bump predastore to v1.1.5 (#135)
Picks up multipart upload wedge fixes from predastore v1.1.5:
- 76fdbf1 fix: pool cleanup must not reap connections with in-flight streams (Bug A)
- 7df9645 fix: add mutex to bufferedWALFile to prevent bufio race (Bug B)
- 9dcd3dd fix: drain shard body before taking wal.mu in handlePUTShard (Bug C)
- 43e20f6 fix: widen QUIC flow-control windows to mitigate multipart upload deadlock

plus regression tests for all three bugs (predastore PR #8).
2026-04-13 13:00:25 +10:00
tomnewton-mulga 02aba39942 feat: spinifex login user, credential handoff, networking hardening (ISO)
- Add spinifex login user password via chpasswd in install.go (same
  password as root, collected once in TUI)
- Copy /root/.aws/ to /home/spinifex/.aws/ in firstboot so the operator
  can use the AWS CLI without sudo after first boot
- Fix getty drop-ins to be instance-specific (tty1/ttyS0 only), leaving
  tty2+ immediately available as rescue terminals
- Remove autologin — node now requires password at login prompt
- Pre-start OVS/OVN in firstboot and poll NB DB up to 120s to eliminate
  setup-ovn.sh timeout races on slow physical hardware
- Tighten per-service ownership in firstboot (each service user owns only
  its own /var/lib/spinifex subdirectory)
- Add LAN bridge as a non-critical systemd unit so a missing LAN cable
  can never stall network-online.target or firstboot
- Write dhcpcd.conf with short br-lan timeout; add networking.service
  drop-in to treat exit 1 as success for optional interface failures
- Disable IPv6 on management bridges to silence dhcpcd journal noise
- Fix WAN/LAN enter-key and ESC navigation logic in TUI
- Fix LAN NIC cursor to skip WAN NIC correctly
2026-04-13 12:53:07 +10:00
Josh Siviour db9e93351e fix: nginx-alb + route table NAT GW SNAT event bugs
Bundle of ALB and NAT GW fixes surfaced by the nginx-alb terraform example.

- fix: expose ALB ENI private IPs in DescribeLoadBalancers (mulga-928)
- fix: attach every ENI to multi-subnet ALB VMs (mulga-929)
- fix: nginx-alb example workers reach apt via NAT Gateway (mulga-930)
- fix: accept every ALB attribute key terraform sends (mulga-931)
- fix: publish NAT GW SNAT events on route table (dis)association (mulga-932)
- fix: publish NAT GW SNAT events on route table replace (mulga-933)
- refactor: extract multi-ENI NIC setup into testable helpers

E2E: https://github.com/mulgadc/spinifex/actions/runs/24318441048 (all green)
2026-04-13 09:29:47 +10:00
Josh Siviour c8ca746bf0 feat: ELBv2 attributes API + DescribeTags (#128)
* feat: add attributes map to LB and TG records with defaults

Replace CrossZoneEnabled bool with Attributes map[string]string on
LoadBalancerRecord and TargetGroupRecord. Add DefaultLoadBalancerAttributes()
and DefaultTargetGroupAttributes() functions. ALBs seed cross-zone enabled
at create time.

* feat: add 4 attributes methods to ELBv2Service interface

* feat: implement ELBv2 attributes service methods and NATS wrappers

Add ModifyTargetGroupAttributes, DescribeTargetGroupAttributes,
ModifyLoadBalancerAttributes, and DescribeLoadBalancerAttributes
to service_impl.go with proper ARN validation and default merging.

Add corresponding NATS request wrappers in service_nats.go.

* feat: add gateway handlers for ELBv2 attributes API endpoints

Add ModifyTargetGroupAttributes, DescribeTargetGroupAttributes,
ModifyLoadBalancerAttributes, and DescribeLoadBalancerAttributes
gateway handlers with input validation and action map registration.

* feat: add daemon handlers & NATS subscriptions for elbv2 attributes

Add 4 handler functions in daemon_handlers_elbv2.go and 4 NATS
subscriptions in daemon.go for ModifyTargetGroupAttributes,
DescribeTargetGroupAttributes, ModifyLoadBalancerAttributes, and
DescribeLoadBalancerAttributes.

* fix: add missing accountID authorization check to ELBv2 attribute handlers

* fix: guard nil attributes and log skips in ELBv2 Modify*Attributes

ModifyTargetGroupAttributes and ModifyLoadBalancerAttributes silently
dropped attributes with nil Key or Value, and would panic on a nil
slice element when dereferencing attr.Key. Add an explicit nil-element
guard and log a warning whenever an attribute is skipped, so callers
and operators can see divergence between the submitted payload and the
persisted state.

* fix: sort ELBv2 attribute responses by key for deterministic output

DescribeTargetGroupAttributes and DescribeLoadBalancerAttributes iterated
a map and appended to a slice, producing random attribute order on each
call. Sort keys before building the response so Terraform plans stay
stable and snapshot tests can rely on consistent ordering.

Closes mulga-910

* feat: add ELBv2 DescribeTags handler (mulga-919)

Required by the Terraform AWS provider's post-create refresh on
aws_lb, aws_lb_target_group, and aws_lb_listener. Without it, the
gateway returns InvalidAction and apply fails after the resources
are already created.

Dispatches by ARN resource segment (loadbalancer/targetgroup/listener)
to the existing record stores. Enforces account scoping via per-type
not-found errors to avoid cross-account existence leaks. Listeners
return an empty Tags slice since ListenerRecord doesn't store tags yet
— that's the exact case the provider hits on every untagged listener
refresh. Output is sorted by key for deterministic responses.

* refactor: parameterize DefaultLoadBalancerAttributes by lbType (mulga-915)

Cross-zone default was split across three places: CreateLoadBalancer seeded
{load_balancing.cross_zone.enabled: true} for ALBs, DefaultLoadBalancerAttributes
returned false unconditionally, and tests asserted both sides. Make
DefaultLoadBalancerAttributes(lbType) the single source of truth: ALBs get
"true", NLBs (and any other type) get "false". CreateLoadBalancer no longer
seeds attributes — DescribeLoadBalancerAttributes derives per-type defaults
from lb.Type on read. Updated TestCreateLoadBalancer_CrossZoneAttributes to
assert via the Describe handler instead of poking stored record state.

* perf: direct KV lookup for ELBv2 *ByArn finders (mulga-913)

GetLoadBalancerByArn and GetTargetGroupByArn used to call ListLoadBalancers /
ListTargetGroups and linearly scan, doing one NATS round-trip per record in
the bucket. Terraform hits Describe*Attributes on every plan/refresh — a
20-ALB / 40-TG workspace was doing ~60 calls × ~60 records = ~3600 KV gets
per refresh where ~60 would suffice. The LB/TG ARN's final path segment IS
the KV key used by GetLoadBalancer(lbID)/GetTargetGroup(tgID), so parse the
short ID from the ARN and do an O(1) lookup, verifying the record's stored
ARN matches as defence-in-depth against any stale-key weirdness.

* fix: reject ELBv2 ModifyAttributes calls where every entry is invalid (mulga-911)

When every submitted attribute was rejected by the nil-guard (nil element,
nil Key, or nil Value), Modify{TargetGroup,LoadBalancer}Attributes still
called PutTargetGroup/PutLoadBalancer with an unchanged record and returned
a successful ModifyOutput with empty Attributes. Only a slog.Warn marked
the skip — the caller saw 200 OK and moved on. Return
ErrorInvalidParameterValue when len(input.Attributes) > 0 and every entry
was rejected, so the caller learns that nothing landed.

* fix: reject unknown ELBv2 ModifyAttributes keys with ValidationError (mulga-912)

ModifyTargetGroupAttributes and ModifyLoadBalancerAttributes had no whitelist
check — any caller-supplied key (typo like 'stickness.enabled', deprecated
keys, or cross-product keys sent to the wrong resource) was persisted verbatim
and merrily echoed back by Describe. AWS rejects unknown keys with
ValidationError; match that so Terraform surfaces the typo at plan time.
Validate each key against DefaultTargetGroupAttributes() /
DefaultLoadBalancerAttributes(lb.Type) and return ErrorValidationError on the
first unknown key, logging both the arn and the offending key name.

* perf: skip no-op Put in ELBv2 ModifyAttributes (mulga-914)

The unconditional PutTargetGroup/PutLoadBalancer fired on every Modify call,
even when the caller sent an empty attribute list or when every submitted
value already matched the stored value. Terraform re-applies the same
attribute set on every drift check, so the steady-state path was doing a
pointless NATS/KV round-trip per call and unnecessarily expanding the
read-modify-write race window.

Track a dirty flag per Modify call: compare each submitted key's value
against tg.Attributes/lb.Attributes and only write to the map (and allocate
the map lazily) when it actually differs. Skip the Put entirely if nothing
changed. Added regression tests that capture the KV entry revision before
and after a no-op modify to prove the write was skipped.

* test: assert exact ModifyAttributes echoed key/value pairs (mulga-917)

Both round-trip tests used assert.Len(t, modOut.Attributes, 2) and never
checked that the returned Attributes actually contained the submitted
keys/values. A regression that pointed at the wrong source, dropped Value,
or returned a copy of defaults would have passed the length check
unchallenged. Build a modMap from the echoed response and assert each
expected Key/Value pair explicitly.

* test: cover sequential ModifyAttributes merge and same-key overwrite (mulga-916)

All existing round-trip tests did one Modify then Describe. No test covered:
Modify {A}, Modify {B}, Describe, assert BOTH keys present; or Modify
overwriting the same key on a second call. A future refactor that replaced
tg.Attributes instead of mutating in place would pass every existing test
but silently wipe previous attributes on each Modify — the most likely
real-world ALB/TG bug. Add sequential-merge tests for both TG and LB
handlers that exercise multi-key merge, same-key overwrite, and
cross-survival of unrelated keys.
2026-04-10 21:31:43 +10:00
Josh Siviour 67bdc9d4af fix: nginx-alb terraform example uses private IPs for instances (#131)
The ALB reaches backends over the private VPC network, so the Nginx
instances no longer need public IPs. Drop associate_public_ip_address
and map_public_ip_on_launch, and swap the instance public-IP outputs
for private-IP ones.

The *.elb.spinifex.local DNS name does not resolve from the operator's
host, so document an AWS CLI one-liner (describe-load-balancers with
an AvailabilityZones[].LoadBalancerAddresses[].IpAddress query) that
retrieves the ALB's public IP for curl/browser testing.
2026-04-10 18:54:12 +10:00
Josh Siviour 8d2830481d update alb terraform example (#130) 2026-04-10 16:52:52 +10:00
Josh Siviour 6238988a8f feat: add Terraform nginx-alb example (#129)
Deploys two Nginx EC2 instances behind an Application Load Balancer
on Spinifex, exercising VPC, public subnets, IGW, security group,
key pair + cloud-init, ALB, target group, and HTTP listener end-to-end.

Also gitignore generated *.pem files from tofu local_file outputs.
2026-04-10 15:56:50 +10:00
Julian Sommer 4365fb0958 feat: update guide 2026-04-10 15:05:46 +10:00
Julian Sommer 48c90b795a feat: migration framework (#127) 2026-04-10 14:34:15 +10:00
Julian Sommer 18684d5c8b fix: remove InsecureSkipVerify and normalize 0.0.0.0 connect addresses (#125) 2026-04-10 13:30:13 +10:00
Julian Sommer 708bb1d340 feat: enforce nats authentication (again) (#126) 2026-04-10 12:18:46 +10:00
Julian Sommer 893fbbc243 fix: remediate trivial security audit findings (#124) 2026-04-10 11:42:43 +10:00
Julian Sommer 7723ad4a89 fix: enable perfsprint 2026-04-10 11:36:51 +10:00
Josh Siviour ab95566795 feat: initial ELBv2 implementation (#122)
* feat: add ALB target health reporting via HAProxy stats

Agent-side: periodically query HAProxy stats socket and publish
target health status to NATS (elbv2.alb.<id>.health topic).

Control-plane: healthChecker subscribes to health reports from all
ALB agents and updates target health state in the KV store. Wire
into ELBv2ServiceImpl lifecycle and DeregisterTargets cleanup.

E2E: remove health check skip — targets must now become healthy.

* feat: allocate public IP for internet-facing ALB VMs in daemon

Add Scheme field to SystemInstanceInput so the daemon knows whether
an ALB is internet-facing or internal. For internet-facing, allocate
a public IP via externalIPAM and publish vpc.add-nat for the OVN
dnat_and_snat rule. Internal ALBs skip public IP assignment entirely.

* fix: release auto-assigned public IPs on DeleteNetworkInterface

DeleteNetworkInterface previously leaked public IPs and NAT rules.
Now checks if the ENI has a public IP, verifies it's not EIP-owned,
and releases via externalIPAM + publishes vpc.delete-nat. Daemon
injects externalIPAM and EIP KV into VPC service at init time.

Tests cover: auto-assigned IP release with NAT event, EIP-owned IP
skipped, and no-op when externalIPAM is not configured.

* feat: enforce multi-AZ for internet-facing ALBs in pool mode (mulga-770)

In CreateLoadBalancer, when DevNetworking is false (pool mode), require
2+ subnets in different AZs for internet-facing scheme. Dev mode and
internal scheme skip the validation.

* feat: ALB scheme unit tests + DNS internal prefix + public IP in AZ response (mulga-771)

Add 9 unit tests covering scheme-dependent ALB behavior:
- DNS name uses internal- prefix for internal scheme (AWS-compatible)
- Internet-facing ALBs include public IP in AvailabilityZones response
- Internal ALBs have no public IP in AZ response
- Launcher receives correct scheme, delete cleans up VM + ENIs

Also implements the code changes these tests exercise:
- AvailZoneInfo.PublicIP field populated from launcher output
- lbRecordToSDK populates LoadBalancerAddresses for internet-facing

* feat: add custom AMI for ALB to images list

* feat: add internet-facing ALB data plane E2E test (mulga-772)

New E2E script that validates internet-facing ALBs get public IPs and are
reachable from a peer cluster node (external validation). Skips in dev mode.

* fix: set ENDPOINT to runner node IP in ALB E2E workflow

* fix: correct paths filter to reference alb-e2e.yml

* fix: alb ami name and workflow step to import the image

* fix: spx path

* fix: remove ALB AMI import from workflow and added to bootstrap.sh

* fix: use two subnets in different AZs for internet-facing ALB E2E

Multi-AZ enforcement (mulga-770) requires internet-facing ALBs to have
subnets in 2+ AZs in pool mode. Discover AZs and create a subnet in each.

* refactor: remove multi-AZ enforcement for internet-facing ALBs

Team decision: ALBs must work with single-AZ and single-node clusters.
Removes the multi-AZ validation, pool mode helper, associated tests,
and error constant. E2E script reverted to single subnet.

* fix: use correct account ID for ALB ENI public IP assignment

The ENI is created under the caller's account but LaunchSystemInstance
was looking it up with GlobalAccountID (system account), causing the
UpdateENIPublicIP call to fail with "ENI not found". Pass the caller's
account ID via SystemInstanceInput.AccountID.

* fix: add debug logging for ALB public IP assignment failure

* fix: expand debug logging to include peer node and more keywords

* fix: lazy system AMI discovery for ALB VMs

The daemon was caching the system AMI at startup, but the ALB image is
imported after services start (bootstrap step 9). Replace SetSystemAMI
with SetSystemAMIFunc so the AMI is resolved at request time when
CreateLoadBalancer actually needs it.

* fix: resolve ALB VM instance type dynamically from resource manager

The instance type was hardcoded to t3.nano which may not exist on all
clusters. Now the daemon provides a resolver that picks the smallest
available instance type from the resource manager.

* push interim changes

* refactor: rewrite ALB agent from NATS client to HTTP server

Replace NATS subscriptions with an HTTP server on :8405 exposing
POST /config, GET /ping, and GET /health. The daemon will push
config and poll health over HTTP through the VPC network, removing
the agent's direct NATS dependency.

- agent.go: net/http server replaces nats.Conn/Subscriptions
- health.go: remove push-based reportHealth; /health handler
  calls queryHAProxyStats synchronously on each request
- cmd/alb-agent: --nats flag replaced with --listen
- tests: rewritten with httptest instead of NATS test server
- health_checker_test: inline topic strings (HealthTopic removed)

* refactor: replace NATS with HTTP client for daemon-to-ALB agent comms

Daemon now pushes config via POST and polls ping via GET to the ALB
agent's HTTP server on the VPC IP, eliminating the agent's NATS
dependency from the daemon side.

- service_impl: pushConfig uses http.Post, waitForAgentReady uses
  http.Get, both target http://<vpcIP>:8405
- albVMUserData: drop natsURL param (agent no longer needs NATS)
- types: add VPCIP field to LoadBalancerRecord
- daemon: remove SetNATSURL call and 10.0.2.2 translation block
- arn_test: update user data tests for new signature

* refactor: rewrite ALB health checker from NATS subscription to HTTP polling

Health checker now polls each active ALB's GET /health endpoint on a
10s ticker instead of subscribing to a NATS wildcard topic. Removes
the NATS connection dependency from the health checker.

- health_checker: replace nc/sub with httpClient + stopCh polling loop
- newHealthChecker: drop nc param, add agentURLFn for testability
- service_impl: update newHealthChecker call site
- tests: rewrite with httptest, test pollAll/pollOne paths

* refactor: remove NATS wiring from ALB image and make hostfwd dev-only

- alb-setup.sh: remove --nats flag, use --listen=:8405; remove
  placeholder HAProxy frontend on 8405 (agent owns the port now);
  update description and comments
- service_impl: HostfwdPorts (80/443) now only set when DevNetworking
  is enabled — production ALBs are reached via VPC IP directly

* feat: system credentials file + daemon wiring for ALB agent SigV4 auth

Write config/system-credentials.json during admin init/join with the
system access key. Daemon reads it at startup, resolves the gateway URL
from external IPAM (or 10.0.2.2 in dev mode), and wires both into the
ELBv2 service. albVMUserData now injects ALB_GATEWAY_URL, ALB_ACCESS_KEY,
and ALB_SECRET_KEY into cloud-init for ALB VMs.

* feat: ALB agent gateway actions + daemon handlers (heartbeat, config)

Add ALBAgentHeartbeat and GetALBConfig actions to the gateway so the ALB
agent can communicate via the AWS gateway instead of direct HTTP.

- Add agent_types.go with input/output types for both actions
- Add ALBAgentHeartbeat: processes heartbeat, transitions provisioning→active
  on first contact, updates LastHeartbeat, processes health report
- Add GetALBConfig: returns pre-computed HAProxy config text and hash
- Replace pushConfig (HTTP POST to agent) with updateStoredConfig (stores
  config + SHA256 hash on LB record for agent pull)
- Remove waitForAgentReady polling goroutine (heartbeat handles transition)
- Remove HTTP client from ELBv2ServiceImpl (no longer needed)
- Strip health checker HTTP polling (pollLoop/pollAll/pollOne) — health
  reports now arrive directly via ALBAgentHeartbeat
- Add ConfigText, ConfigHash, LastHeartbeat fields to LoadBalancerRecord
- Wire NATS subscriptions + daemon handlers for both new actions
- Add gateway handler files and elbv2Actions map entries
- Comprehensive tests for heartbeat, config, and updateStoredConfig

* feat: rewrite ALB agent as AWS SDK HTTP client (Bead 3r)

Replace the HTTP server agent with a poll-based client that communicates
with the gateway using SigV4-signed requests. The agent now sends
ALBAgentHeartbeat every 5s with health reports and fetches config via
GetALBConfig when the config hash changes.

* feat: add VPC service route for ALB agent gateway access

ALB VMs need to reach the gateway for heartbeat/config polling, but
VPCs without an IGW have no external routing. Instead of requiring
an IGW, the daemon now publishes a vpc.ensure-service-route event
before launching ALB VMs. vpcd handles this by creating minimal
external connectivity (external switch, localnet port, gateway router
port, chassis bindings) if not already present, then adds a /32 static
route for the gateway IP.

Refactors handleIGWAttach to share the external connectivity setup via
ensureExternalConnectivity helper.

* Revert "feat: add VPC service route for ALB agent gateway access"

This reverts commit d4220d8806.

* feat: add management NIC infrastructure for system instance control plane

Add config, IP allocator, TAP helpers, and VM struct fields for the
management NIC that gives system instances (ALBs etc.) L3 access to the
host control plane via br-mgmt, independent of VPC routing.

- DaemonConfig.MgmtBridge (default "br-mgmt")
- MgmtIPAllocator: thread-safe static IP allocation (.10-.249), rebuild from VMs on restart
- generateMgmtMAC (02:a0:00 prefix), MgmtTapName, GetBridgeIPv4
- SetupMgmtTapDevice / CleanupMgmtTapDevice (plain Linux bridge TAP)
- VM struct: MgmtMAC, MgmtIP, MgmtTap fields

* feat: wire management NIC into QEMU launch and cloud-init config

Add QEMU NIC injection for mgmt TAP (virtio-net-pci on br-mgmt),
extend generateNetworkConfig to emit mgmt0 stanza with static IP,
and wire mgmt MAC/IP allocation into LaunchSystemInstance. Includes
mgmt TAP cleanup on instance stop and failed launch cleanup.

* test: add coverage for ELBv2 service lifecycle and setter methods

* fix: move br-mgmt detection before wireALBAgentConfig so gateway URL uses management bridge IP

* fix: generate per-interface cloud-init config when eniMAC present without devMAC

The guard in generateNetworkConfig required both eniMAC and devMAC to
produce per-interface config, falling back to wildcard DHCP otherwise.
System instances (ALBs) have eniMAC + mgmtMAC but no devMAC (DevNetworking
is independent), so the mgmt NIC stanza was never emitted. The wildcard
config does DHCP on all NICs, but br-mgmt has no DHCP server, leaving the
management NIC unconfigured and the agent unable to reach the gateway.

* fix: add wildcard support to ENI description filter + add ca-certificates to ALB image

- ENI DescribeNetworkInterfaces description filter did exact string match,
  but AWS supports * wildcards. E2E test uses "ELB *" which didn't match
  "ELB app/my-alb/lb-123". Added matchFilter helper for wildcard matching.
- ALB image was missing ca-certificates package, so cloud-init ca_certs
  module couldn't add the Spinifex CA to the system trust store. Agent
  TLS handshakes failed with "certificate signed by unknown authority".
- Fixed data race in TestDeleteLoadBalancer_TerminatesVM_WithPublicIP
  where async TerminateSystemInstance goroutine wrote to mock fields
  while the test read them.

* fix: detach ENI before delete on termination + defer ENI cleanup to stopInstance

DeleteLoadBalancer was trying to delete ENIs synchronously, but they were
still attached (in-use) to the running VM. The delete was rejected with
InvalidNetworkInterface.InUse.

Fix: DeleteLoadBalancer only deletes ENIs when no VM was launched (InstanceID
empty). When a VM exists, ENI cleanup is deferred to stopInstance which runs
after QEMU exits. stopInstance now calls DetachENI before DeleteNetworkInterface
to clear the in-use status.

E2E test now polls for ENI cleanup (up to 30s) since VM termination is async.

* fix: detach then delete ENIs in DeleteLoadBalancer

ENIs are attached (in-use) to the ALB VM, so DeleteNetworkInterface
rejects them. Fix: call DetachENI first to clear in-use status, then
delete. stopInstance tolerates already-deleted ENIs.

* test: add internal ALB dataplane E2E test

Add run-elbv2-dataplane-internal-e2e.sh replacing the generic dataplane
test. Internet-facing coverage is already provided by
run-elbv2-dataplane-internet-facing-e2e.sh.

The internal test validates:
- ALB reaches active via mgmt NIC heartbeat (no IGW dependency)
- Scheme=internal, no public IP, internal- DNS prefix
- Health checks pass via VPC L2 (OVN logical switch)
- Round-robin traffic from a VPC client to the ALB's private IP
- ENI cleanup on ALB deletion

Uses a client VM inside the VPC that curls the ALB private IP and
serves results via HTTP on its public IP for host-side verification.

* feat: add NLB constants and data model changes (mulga-838)

* feat: type-aware validation in CreateLoadBalancer (mulga-839)

Accept Type=network for NLB, reject SecurityGroups for NLB, set
CrossZoneEnabled defaults (false for NLB, true for ALB), and use
/net/ ARN path segment for network load balancers.

* feat: type-aware validation in CreateTargetGroup (mulga-840)

Accept TCP/UDP/TLS/TCP_UDP protocols and apply NLB health check
defaults (TCP protocol, no path/matcher, 10s timeout, 3/3 thresholds).
Reject invalid protocols with InvalidParameterValue.

* feat: type-aware validation in CreateListener (mulga-841)

* feat: add NLB listener and protocol compatibility unit tests

Cover the remaining Phase 1 test gaps: NLB CreateListener with all L4
protocols, protocol rejection for wrong LB type, listener-to-target-group
protocol compatibility validation, and default protocol rejection on NLB.

* feat: add TCP HAProxy config generation for NLB

Add TCP mode template and generateNLBConfig function for Network Load
Balancer support. GenerateHAProxyConfig now branches on LB type,
producing mode-tcp configs for NLBs with tcp-check or httpchk health
checks depending on the target group's health check protocol.

* refactor: rename albagent to lbagent for NLB generalization

Rename the ALB-specific agent package and binary to generic LB names
in preparation for NLB support. Both ALB and NLB VMs use the same
agent — it just applies whatever HAProxy config the control plane
generates.

- Rename albagent/ → lbagent/, cmd/alb-agent/ → cmd/lb-agent/
- Rename types: ALBAgentHeartbeat* → LBAgentHeartbeat*, GetALBConfig* → GetLBConfig*
- Rename NATS topics: elbv2.ALBAgentHeartbeat → elbv2.LBAgentHeartbeat
- Rename cloud-init user data: albVMUserData → lbVMUserData with LB_* env vars
- Rename Makefile targets: build-alb-agent → build-lb-agent
- Update gateway action map, daemon subscriptions, service interface
- Fix pre-existing unused matchFilter function lint error
- Fix awserrors test count for InvalidConfigurationRequest

* feat: add sys.micro system instance type for LB VMs (mulga-845)

Add sys.micro (1 vCPU, 128MB) as an internal-only instance type for
system VMs (load balancers, NAT gateways). Registered in the type map
for allocation but excluded from DescribeInstanceTypes, NATS
subscriptions, and resource stats. Daemon SetSystemInstanceTypeFunc
now returns sys.micro instead of scanning for smallest guest type.

* feat: LB image shrink and rename (mulga-846)

Rename alb.conf → lb.conf, alb-setup.sh → lb-setup.sh with IMAGE_SIZE
reduced from 512M to 192M. Update all references to use lb-agent binary
and LB_* env vars. Update COMMANDS.md for NLB support.

* chore: rename file

* feat: NLB E2E test and bug fixes (mulga-847)

Add full NLB data plane E2E test (33 assertions, 9 phases):
VPC/subnet setup, TCP target group with health checks, NLB
creation (type=network), TCP listener, agent heartbeat,
target health verification, TCP traffic round-robin, target
deregistration, and full cleanup verification.

Bug fixes found during E2E testing:
- Fix HAProxy stats socket path mismatch: config used alb-/nlb-
  prefix but agent expects lb- prefix, preventing health reports
- Fix system AMI lookup to match new -lb- image name, falling
  back to legacy -alb- name
- Bump LB image size from 192M to 256M (Alpine base is 200M)

* test: validate NLB internal scheme (mulga-857)

NLB with scheme=internal works out of the box — shared code paths
with ALB handle it correctly. Add unit test and E2E coverage:

- Unit test: NLB internal creates with no public IP, internal- DNS prefix
- E2E phases 10-14: internal NLB lifecycle with VPC client TCP traffic
- E2E passed 56/56 with perfect round-robin (20/20 requests)

* feat: consolidate LB e2e tests into run-lb-e2e.sh (mulga-854)

Replace 3 separate LB dataplane scripts with a single consolidated test
that covers all 4 variants (ALB/NLB × internet-facing/internal).

- Share one VPC, subnet, and 2 dual-purpose app instances across all suites
- ALB + NLB provision in parallel within each phase (internet-facing / internal)
- One client VM tests both internal LBs simultaneously
- Replace sleep 100 with LB health check gates
- Delete standalone alb-e2e.yml, integrate into e2e.yml for both single and multi-node

* chore: disable LB E2E tests in CI until dev env issue resolved

* fix: read LB agent credentials from spinifex.toml instead of system-credentials.json

The daemon couldn't read system-credentials.json due to systemd
hardening (file owned by root:root 0600, not in ReadOnlyPaths).
Use the same system credentials already loaded from spinifex.toml
via the predastore config section — these are identical keys written
by admin init.

* feat: automate LB image build and import (mulga-861)

- dev-install.sh: build lb-agent and run build-system-image.sh --import
  after services start so the LB image is available out of the box
- build-system-image.sh: add --quiet flag to suppress build output,
  remove hardcoded --config path (uses auto-detected default)
- images.go: rename alb-alpine → lb-alpine to match current naming

* update go version

* add LB e2e tests

* fix: clean LB image build dir and wait for nbd device readiness

Previous CI runs can leave stale state in /tmp/lb-image-build/ causing
e2fsck failures. Clean the build dir before each CI build. Also add a
readiness check after qemu-nbd connect to avoid race conditions.

* fix: serialize nbd access with flock for concurrent CI builds

Single-node and multi-node CI jobs share the same host and both build
the LB system image using /dev/nbd0. Use flock to serialize the nbd
connect/mount/customize/disconnect section so concurrent builds wait
rather than collide.

* fix: move flock to cover entire image build, not just nbd section

Concurrent CI jobs share BUILD_DIR and /dev/nbd0. The lock must be
acquired before downloading/copying the image, not just before nbd
connect, otherwise the second job can clobber the first job's files.

* fix: skip LB image build if output is fresh from concurrent CI job

When concurrent CI jobs build the same image, the second job now reuses
the first's output if it's less than 10 minutes old, avoiding a
duplicate download + build cycle.

* fix: filter AMI discovery by name in E2E tests

With the LB Alpine image now imported alongside Ubuntu, the unfiltered
describe-images call picked up the wrong AMI. Filter by the expected
image name so tests use the Ubuntu image for instance lifecycle.

* fix: filter AMI discovery by name in multi-node E2E tests

Same fix as run-e2e.sh — with the LB Alpine image now imported,
the unfiltered describe-images picks the wrong AMI.

* fix: use awsgw bind IP as LB agent gateway URL fallback

When br-mgmt is not configured and dev_networking is off (production
with external pool), the gateway URL was falling through to the
external IPAM pool's GatewayIP — the WAN router, not the Spinifex
node. LB VMs couldn't reach the awsgw, so heartbeats never arrived
and ALBs stayed stuck in provisioning.

Use the awsgw bind IP instead — LB VMs on the OVN overlay can reach
the host node's WAN IP where the gateway listens.

* fix: capture StateReason and daemon logs when LB app instances fail

Instances with user-data terminate immediately in CI. Add
describe-instances StateReason query and daemon log dump to diagnose
whether it's volume_preparation_failed or launch_failed.

* fix: prevent orphaned AMI from breaking LB E2E and avoid stuck provisioning

Three fixes for CI E2E failures:

1. run-e2e.sh: Remove custom AMI from predastore after deleting its
   backing snapshot. Previously the AMI remained discoverable but
   unusable, causing run-lb-e2e.sh to pick it and fail with NoSuchKey
   on the deleted snapshot.

2. run-lb-e2e.sh: Prefer ubuntu AMI by name for app instances instead
   of picking any non-alpine image. Also improve debug log dump to use
   journalctl (binary-install CI uses systemd, not log files).

3. CreateLoadBalancer: Only set provisioning state when a system VM
   was actually launched. Previously a failed launch still set
   provisioning, leaving the LB stuck forever with no agent to
   send heartbeats.

* fix: use AWSGW bind IP as LB agent gateway URL in multi-node

In multi-node deployments, the AWSGW binds to the WAN IP (e.g.
192.168.1.26:9999), but wireLBAgentConfig was using the br-mgmt IP as
the gateway URL. Since the AWSGW doesn't listen on br-mgmt, the LB
agent's heartbeat was refused, leaving internet-facing ALBs stuck in
provisioning.

Internet-facing LBs now use the AWSGW bind IP directly — the VM reaches
it via VPC/external networking. Internal LBs get a host route on the
mgmt NIC so they can reach the AWSGW bind IP through br-mgmt.

* fix: add mgmt route via bootcmd for multi-node internal LBs

Alpine cloud-init does not support v2 routes under ethernets — adding
a routes: block to the network-config breaks all networking in the VM.

Instead, add the host route via a bootcmd in the LB user data. This
runs early in cloud-init and adds `ip route add {awsgw}/32 via
{br-mgmt}` so internal LB agents can reach the AWSGW through the
management NIC when the gateway binds to a specific WAN IP.

Also moves the networking service from boot to default runlevel in the
LB Alpine image to prevent a race with cloud-init.

Verified locally: internal ALB transitions to active with AWSGW bound
to WAN IP (simulating multi-node).

* add LB enable flag to e2e

* test: add elbv2 tests to meet diff coverage threshold

Cover health checker edge cases (invalid JSON, empty servers, zero
thresholds, port-0 fallback) and service_impl paths (mgmt route
cloud-init, protocol validation, missing target group).

* fix: swallowed health report error + needless marshal round-trip in LBAgentHeartbeat

Add handleHealthReportDirect(report) to healthChecker so LBAgentHeartbeat
can pass the struct directly instead of marshaling to JSON and immediately
unmarshaling. Promote handleHealthReport unmarshal error from Debug to Warn
so failures are visible in logs.

* fix: validate credentials before launching LB VM + set failed state on launch error (mulga-867)

Guard lbVMUserData with empty-value check for gatewayURL, systemAccessKey,
and systemSecretKey — blank env vars previously caused agent auth-loop with
no daemon-side log. Set LB state to failed (not active) when VM launch fails
so the API reflects the broken data-plane instead of black-holing traffic.

* fix: data race in getSystemAMI/getSystemInstanceType lazy caching

Use sync.Once to guard lazy resolution of systemAMI and
systemInstanceType fields. Concurrent CreateLoadBalancer calls
previously raced on unsynchronized read+write of these cached values.
The setter methods reset the Once so re-resolution works when the
func is set after initial construction.

Also fix pre-existing nosprintfhostport lint in daemon.go.

* fix: use utils.PublishEvent for NAT events + return errors from isEIPOwned

publishNATEvent in eni.go and daemon was copy-pasted boilerplate — now
delegates to utils.PublishEvent (matching the EIP service pattern).

isEIPOwned previously swallowed all KV errors and returned false, which
could release an EIP-owned public IP if the store was temporarily
unavailable. Now returns (bool, error) so the caller skips release on
KV failure instead of assuming no EIP ownership.

* fix: narrow health checker to only scan target groups for reporting LB

handleHealthReportDirect was calling ListTargetGroups() on every heartbeat
(every 5s per LB agent), scanning all TGs. Now uses report.LBID to follow
LB → listeners → TG ARNs and fetch only the attached target groups via
direct key lookups. Falls back to full scan if LBID is missing.

* fix: pass region through to LB agent SigV4 signing (mulga-871)

LB agent hardcoded us-east-1 as the SigV4 signing region. Thread region
from ELBv2 service config → cloud-init LB_REGION env var → lb-agent
--region flag → lbagent.New() → signer.Sign() call.

* fix: add account-level authorization to LBAgentHeartbeat and GetLBConfig

Both endpoints accepted any accountID without verifying the LB belongs
to that account. Now verify accountID matches the LB's AccountID,
with an exception for the system account (000000000000) which the
ALB agent uses for heartbeats and config fetches.

* fix: upgrade silent failure paths from Debug to Warn/Error

- healthChecker target group list failure: Debug → Warn
- HAProxy stats socket error in agent tick(): Debug → Warn
- ENI delete EIP-owned skip: Debug → Info (operational visibility)
- wireLBAgentConfig: warn when no gateway host is resolved
- detectMgmtIP: warn when bridge exists but IP is empty
- stale mgmt TAP cleanup: log errors instead of discarding

* refactor: deduplicate MAC hash function into utils.HashMAC

Three identical hash-to-MAC implementations (generateDevMAC, generateMgmtMAC,
generateENIMac) now delegate to a shared utils.HashMAC(prefix, id) helper.

* fix: guard LBAgentHeartbeat KV writes to state transitions or stale timestamps

Previously the full LoadBalancerRecord (including ConfigText) was written
to JetStream KV on every heartbeat (every 5s). Now only persists on state
transitions (provisioning→active) or when LastHeartbeat is older than 60s.

* fix: fail ALB launch when public IP allocation fails for internet-facing scheme

Previously, IPAM allocation failure was logged as a warning and the ALB
VM continued launching without a public IP. The ALB reached active state
but was unreachable from the internet. Now the launch returns an error
and cleans up the partially-created instance, causing the ALB to enter
the failed state.

* fix: retry system AMI resolution when image not yet imported

sync.Once cached empty results permanently — if the daemon started
before the system image was imported, all CreateLoadBalancer calls
silently skipped VM creation. Replace sync.Once with a mutex that
only caches non-empty results, allowing retry on subsequent calls.

Same fix applied to getSystemInstanceType for consistency.

* fix: propagate updateStoredConfig errors to API callers (mulga-884)

updateStoredConfig and updateStoredConfigForTargetGroup now return
errors instead of silently swallowing them. Callers (CreateListener,
DeleteListener, RegisterTargets, DeregisterTargets) return
ErrorServerInternal when config persistence fails, preventing the API
from reporting success while HAProxy serves stale configuration.

* test: add unit tests for wireLBAgentConfig, isEIPOwned, toHealthReport (mulga-885)

* fix: parse HAProxy templates once at package level (mulga-875)

generateALBConfig and generateNLBConfig were calling template.New().Parse()
on every invocation. Move to package-level template.Must for one-time parsing.

* fix: fail launch for internal-scheme ALBs when mgmt NIC setup fails (mulga-886)

When management TAP or IP allocation fails, internal-scheme ALBs now
return an error instead of silently continuing without a management NIC.
Without the mgmt NIC, the LB agent cannot reach the gateway and the ALB
gets stuck in provisioning forever. Internet-facing ALBs retain the
existing degrade-and-continue behavior.

* refactor: extract lbArnPathSegment helper for ALB/NLB ARN construction (mulga-877)

The app/net path segment derivation was duplicated in buildLBArn,
buildListenerArn, and inline in CreateLoadBalancer. Extracted into a
single lbArnPathSegment helper.

* refactor: use aws.StringValue in toHealthReport (mulga-878)

Replace manual nil-check-then-deref with aws.StringValue for cleaner
pointer-to-value conversion in the heartbeat health report builder.

* refactor: use testutil.StartTestJetStream in health_checker_test.go (mulga-876)

Replace hand-rolled setupTestNATS (~20 lines) with the shared
testutil.StartTestJetStream helper. Simplify return to just *Store
since no caller needed the *nats.Conn.

* fix: address PR #122 review comments (mulga-887)

- Extract NATEvent named type from anonymous struct in publishNATEvent
- Remove unused build-lb-image.sh thin wrapper
- Remove SPX_ENABLE_LB env var from e2e workflow (unused)
- Remove no-op healthChecker start/stop methods
- Export simple ELBv2 fields, remove trivial setter methods
- Delete old e2e scripts replaced by consolidated run-lb-e2e.sh

* fix: check specific error type for ENI deletion on termination (mulga-888)

ENI deletion errors were all silently downgraded to Debug level,
masking genuine failures (KV issues, permissions, ENI stuck in-use)
that cause orphaned ENIs and leaked IPAM addresses. Now only
NotFound is logged at Debug; all other errors log at Error.

* fix: propagate non-ENODEV errors from GetBridgeIPv4 (mulga-890)

GetBridgeIPv4 returned ("", nil) for ALL InterfaceByName failures,
not just missing devices. Permission errors on /sys/class/net/ were
silently treated as "bridge absent". Now only "no such network
interface" returns nil; other errors propagate to the caller.

* fix: remove backend address from proxy error response (mulga-892)

The reverse proxy error handler included the internal backend
host:port in the XML error body sent to clients. The address is
already logged server-side; the client now sees a generic message.

* fix: release mutex before KV store I/O in health checker (mulga-893)

handleHealthReportDirect held hc.mu across PutTargetGroup calls
(JetStream KV Put — network I/O), serializing concurrent heartbeats
from independent LBs. Now collects changed TGs under the lock and
persists them after releasing it.

* fix: warn when system credentials missing in wireLBAgentConfig (mulga-894)

fix: log DetachENI errors instead of discarding with blank identifier (mulga-895)

* fix: detect veth bridge mode and use centralized NAT for it

The veth-pair bridge topology (Linux bridge ↔ veth ↔ OVS bridge) was
misclassified as direct bridge mode, causing OVN to use distributed NAT.
Distributed NAT hairpin routing fails through the Linux bridge intermediary,
breaking host-to-local-LB connectivity.

Add BridgeModeVeth constant, detect veth-wan-ovs interface in
detectBridgeMode(), and introduce useCentralizedNAT() to gate NAT mode
and nat-addresses=router for both macvlan and veth modes. isMacvlanMode()
is preserved for macvlan-specific behavior (MAC alignment).

* fix: add retry loop for peer NLB reachability test

With centralized NAT, ARP resolution for NAT external IPs relies on
gratuitous ARPs from the gateway chassis. These take time to propagate
to peer nodes. The peer→NLB test had a single 3s attempt while all
other connectivity tests use retry loops.

* fix: use request-reply for vpc.add-nat to prevent ARP propagation races

publishNATEvent was fire-and-forget for vpc.add-nat, meaning the daemon
continued before vpcd committed the OVN NAT rule. This caused intermittent
NLB connectivity failures: the LB was reported active before OVN had
the DNAT rule and sent GARPs for the public IP.

Switch vpc.add-nat to request-reply (RequestEvent) so the daemon waits
for OVN confirmation. Also increase nc timeout from 2-3s to 5s in E2E
tests for more reliable TCP probing during ARP resolution.

* fix: clean up stale NAT rules before adding new ones for reused IPs

When a public IP is returned to the external pool and quickly reallocated
(e.g. instance terminated, then LB created), the fire-and-forget
vpc.delete-nat may not have been processed yet. handleAddNAT now removes
any existing dnat_and_snat rule for the same external IP before creating
the new one, preventing duplicate/conflicting OVN NAT rules.

This was causing intermittent NLB connectivity failures in multi-node E2E
where NLB public IPs collided with recently-freed instance IPs.

* fix: clean up stale NAT rules across all VPC routers on IP reuse

The previous fix (0a93081) only removed stale dnat_and_snat rules from
the target VPC's router. When a public IP is freed from one VPC and
reallocated to a different VPC before the fire-and-forget vpc.delete-nat
is processed, the stale rule on the old VPC's router caused duplicate
OVN ARP responses — traffic was black-holed when the host resolved to
the wrong router's MAC.

DeleteAllNATsByExternalIP now searches every logical router for stale
NAT rules matching the external IP and removes them in one transaction.

Also adds host connectivity diagnostics (ARP, routing, OVN NAT state)
to run-lb-e2e.sh so the next CI failure surfaces the root cause of the
persistent NLB-from-host issue.

* fix: run LB inet tests from actual hosting node, not hardcoded node1

The host/peer connectivity tests were hardcoded: "host" = node1 (where
the test script runs), "peer" = node2. But LB VMs land on whichever
node picks up the NATS queue message, so the tests were randomly testing
local vs remote connectivity depending on placement luck.

Now the test:
1. Accepts --nodes <ip1> <ip2> ... (all cluster node IPs)
2. After LB creation, discovers which node hosts each LB VM by checking
   for the ENI's tap device
3. Runs "local" tests from the hosting node and "remote" tests from a
   different node

Also updates e2e.yml to pass --nodes instead of --peer.
2026-04-10 10:27:20 +10:00
tomnewton-mulga c5b6beb006 feat: multi-NIC WAN/LAN bridge network setup in ISO installer (mulga-897)
- Replace single network screen with separate WAN and LAN screens
- WAN/LAN IPs now live on Linux bridges (br-wan, br-lan) over the physical
  NICs, not on the NICs directly — makes OVS/OVN veth attachment SSH-safe
- Add DHCP/static toggle on both screens (DHCP is the default)
- Add DNS nameserver input for static mode (comma-separated, default hint
  1.1.1.1 / 8.8.8.8 shown as placeholder)
- Detect WiFi NICs via /sys/class/net/<name>/wireless; show SSID and
  password fields when a WiFi adapter is selected
- LAN screen shows WAN NIC greyed/unavailable; cursor skips over it
- LAN screen only shown when 2+ NICs are detected
- Geneve encap IP uses br-lan address when LAN NIC present, else br-wan
- Remove --macvlan/--wan-iface from firstboot; setup-ovn.sh auto-detects
  br-wan as a Linux bridge and uses veth mode
- Pin both WAN and LAN NIC names to MAC via a single udev rules file
- node.conf writes MANAGEMENT_IFACE=br-wan (bridge, not physical NIC)
2026-04-10 10:02:16 +10:00
tomnewton-mulga e8e05d6898 chore: merge main into feat/iso-installer, resolve Makefile .PHONY conflict 2026-04-10 09:40:16 +10:00
Julian Sommer b001c3929d Revert "feat: enforce nats authentication (#123)"
This reverts commit 553e7857df.
2026-04-09 16:06:03 +10:00
tomnewton-mulga 03601ca89b fix: banner service ordering, getty wait, motd append (mulga-866)
- banner runs After=spinifex.target so services are settled before banner writes
- switch systemctl restart → try-restart (safe if target not yet active)
- append banner to /etc/motd with sentinel instead of overwriting, preserving
  the Debian disclaimer; sentinel ensures re-runs replace cleanly
- remove /etc/issue write — getty shows it pre-login which is wrong
- add getty@.service.d drop-in so all getty instances wait for banner before
  showing login prompt; prevents service messages scrolling during login
- fix Makefile to prefer locally built spx/installer binaries over system PATH
2026-04-09 15:40:26 +10:00
Julian Sommer 1a2e490ae7 fix: policy matching case insensitive 2026-04-09 15:34:43 +10:00
Julian Sommer 553e7857df feat: enforce nats authentication (#123) 2026-04-09 15:27:00 +10:00
tomnewton-mulga cbb3e91ada feat: add spx admin banner --boot-check, replace bash banner script (mulga-866)
- add `spx admin banner --boot-check` subcommand to spx CLI
  - reads management IP from the NIC at runtime (not from node.conf)
  - if IP has changed, updates /etc/spinifex/node.conf and restarts spinifex.target
  - writes banner to /etc/issue (pre-login console) and /etc/motd (SSH MOTD)
  - banner instructs user to cat ~/.aws/credentials (standard AWS CLI location)
- update firstboot.go to use spx-based banner unit; remove writeBannerScript
  and old spinifex-banner.sh entirely
- new banner unit runs Before=spinifex.target so IP is current before services start
2026-04-09 14:39:56 +10:00
Julian Sommer 7449090c86 fix: enable more linters (#121) 2026-04-09 14:36:22 +10:00
tomnewton-mulga 10a2a8116f fix: resolve firstboot service failures and improve installer UX (mulga-859)
- chown /etc/spinifex and /var/lib/spinifex to spinifex:spinifex after
  spx admin init so services running as the spinifex user can read configs
- add Environment=HOME=/root to firstboot unit to suppress $HOME warning
- increase wait-for-nats.sh timeout from 30s to 90s for slow startup
- banner now writes to both /dev/tty1 and /etc/motd (visible over SSH)
- add systemd.show_status=1 to GRUB cmdline for per-service boot feedback
- add --removable to grub-install EFI so fallback path EFI/BOOT/BOOTX64.EFI
  is written (required for UTM and bare metal without NVRAM boot entries)
- add root password screen and manual NIC entry to installer TUI
- tee spx admin init output to /root/spinifex-credentials.txt with 0600 perms
2026-04-09 13:52:31 +10:00
Julian Sommer 26e590716e feat: add systemd hardening directives to all service units (#120) 2026-04-09 12:09:43 +10:00
Julian Sommer e27d909e8d feat: move NBD sockets to /run/spinifex/nbd/ and enable PrivateTmp (#119) 2026-04-09 11:11:54 +10:00
Julian Sommer ccb6ed287d fix: move bootstrap.json to different folder to tighten perms (#118) 2026-04-08 16:12:25 +10:00
Julian Sommer 9a7e914d28 feat: awsgw directory (#117) 2026-04-08 15:19:29 +10:00
Julian Sommer 6026d46ba1 fix: ovn logging 2026-04-08 13:00:06 +10:00
Julian Sommer f0eb28ffc2 fix: remove gateway csp 2026-04-08 12:45:36 +10:00
Julian Sommer d33e6f3d70 chore: go 1.26.2 2026-04-08 11:53:29 +10:00
Julian Sommer 45ea467fcc feat: webserver proxy (#116) 2026-04-08 11:46:43 +10:00
tomnewton-mulga 7c007ea6b5 feat: replace huh TUI with bubbletea + add branding package (mulga-859)
Replaces charmbracelet/huh with a custom bubbletea wizard for the
installer TUI. This gives full control over terminal I/O so the
installer attaches directly to the correct console device (ttyS0 for
serial, tty1 for VGA) as directed by the new spinifex-init boot script.

- branding/branding.go: centralised logo (ASCII placeholder), color
  constants, and publisher string ready for Mulga asset swap
- ui/ui.go: multi-screen bubbletea model (welcome → disk → network →
  identity → join → CA cert → confirm) styled with lipgloss
- main.go: reads SPINIFEX_CONSOLE from /proc/cmdline to open the right
  tty before launching tea.Program
- go.mod: bubbletea/lipgloss/bubbles promoted to direct deps; huh removed
2026-04-08 10:49:57 +10:00
Julian Sommer 820b605c0b fix: require sudo admin init 2026-04-08 09:32:25 +10:00
Julian Sommer a56a2baf1f feat: privilege separation (#114) 2026-04-07 16:22:19 +10:00
tomnewton-mulga 6dfb350be8 fix: use INSTALL_SPINIFEX_SKIP_APT/AWS env vars instead of non-existent --offline flag (mulga-859) 2026-04-07 16:01:17 +10:00
tomnewton-mulga 5d6f25a695 feat: add build-installer Makefile target (mulga-859) 2026-04-07 15:56:39 +10:00
tomnewton-mulga 1b39439cb8 feat: scaffold ISO installer TUI and install pipeline (mulga-859)
Adds cmd/installer with three packages:
- ui: huh-based TUI collecting disk, network, node, and cluster config
- install: sequential install steps (partition, debootstrap, chroot, bootloader)
- firstboot: oneshot systemd service that runs setup-ovn and spx admin init/join on first real boot

Supports both single-node init and multi-node join flows.
2026-04-07 15:50:40 +10:00
Julian Sommer 99f9949bdd fix: enable TLS on cluster manager API (#113) 2026-04-07 10:56:16 +10:00
dependabot[bot] 5598e523d5 chore: bump the npm-dependencies group (#112) 2026-04-07 08:57:04 +10:00
Julian Sommer dda4575888 fix: use binary install for single node e2e (#111) 2026-04-02 16:22:41 +11:00
Ben Duncan e5b6204875 Fix service helper sequence 2026-04-02 13:53:29 +10:00
Ben Duncan 21aa1296c9 systemd tweaks for binary install to correctly function 2026-04-02 13:35:52 +10:00
Julian Sommer ea6b5d770a fix: mask credentials in CLI override log output
Access key and secret key values were printed to stdout when using
--access-key and --secret-key flags. These are captured by systemd
journal and log aggregation, exposing secrets. Log that keys were
overridden without printing the actual values.

Addresses H-5 from security audit.
2026-04-02 12:25:27 +10:00
Julian Sommer f986cf9748 fix: ui cert path 2026-04-02 11:55:32 +10:00
Julian Sommer 11f7c99955 fix: ui cert path 2026-04-02 11:20:02 +10:00
Julian Sommer 1b45c7fc7d fix: nats ip in services 2026-04-02 10:58:39 +10:00
Julian Sommer 868a92bd55 fix: service 2026-04-02 10:35:00 +10:00
Julian Sommer af52d881b9 fix: chown 2026-04-02 10:26:56 +10:00
Julian Sommer c51ea0c29d fix: make download checksum verification mandatory 2026-04-02 09:56:57 +10:00
Julian Sommer 8cf0cc332f feat: remove pw login 2026-04-02 09:51:07 +10:00
Julian Sommer 1d6cefd9d2 feat: extract helpers (#109) 2026-04-02 10:25:01 +11:00
Julian Sommer 0c160d7f45 fix: add request body size limit 2026-04-01 15:36:06 +10:00
Julian Sommer 91e4efb0ef feat: extract tag helper 2026-04-01 15:27:43 +10:00
Julian Sommer f5ec8c3bae feat: formation server HTTPS (#108) 2026-04-01 16:20:28 +11:00
Julian Sommer 8795a7b926 fix: remove dev branch 2026-04-01 13:54:35 +10:00
Julian Sommer 39e9fa6fb8 feat: more filters (#106) 2026-04-01 14:23:04 +11:00
Julian Sommer 853b5bfd32 feat: filterutil migration (#105) 2026-04-01 14:05:21 +11:00
Julian Sommer d7c72e80af feat: filters (#104) 2026-04-01 12:20:56 +11:00
Julian Sommer 475e632404 chore: update COMMANDS.md 2026-03-31 15:53:02 +10:00
Julian Sommer 92e25a9653 chore: bump predastore 2026-03-31 14:11:47 +10:00
Julian Sommer ec525d3a6d add back -fsSL 2026-03-31 13:59:58 +10:00
Julian Sommer 94978f517b actual last 2026-03-31 13:55:48 +10:00
Julian Sommer 4d7239e645 last doc tweaks 2026-03-31 13:50:18 +10:00
Ben Duncan acc8dc1994 Remove macvlan 2026-03-31 13:36:42 +10:00
Julian Sommer a7ed93c6f3 docs: add prerequisite 2026-03-31 13:28:12 +10:00
Julian Sommer 67075c6600 docs: better urls 2026-03-31 13:25:23 +10:00
Ben Duncan ae223cdf15 Merge branch 'dev' of github.com:mulgadc/spinifex into dev 2026-03-31 13:19:48 +10:00
Ben Duncan b73a630f41 TF updates 2026-03-31 13:19:17 +10:00
Julian Sommer c283100fcf docs: rename source-install to source-installation 2026-03-31 13:16:42 +10:00
Julian Sommer 355decd406 docs: tweaks 2026-03-31 13:10:59 +10:00
Julian Sommer b76953bfb4 doc tweaks 2026-03-31 13:02:21 +10:00
Julian Sommer c9e83380df a 2026-03-31 11:59:07 +10:00
Julian Sommer e421840541 feat: placment group docs 2026-03-31 11:42:29 +10:00
Julian Sommer 406ba9431e fix 2026-03-31 11:39:10 +10:00
Julian Sommer 7882f77a36 add more docs 2026-03-31 11:36:09 +10:00
Julian Sommer f7b99f0c50 fix: COMMANDS.md 2026-03-31 11:29:28 +10:00
Julian Sommer f3ee50e9b8 fix: show deleted nat gateways 2026-03-31 10:49:36 +10:00
Julian Sommer 4f3b54abc2 fix: ModifyInstanceAttribute SourceDestCheck 2026-03-31 10:30:14 +10:00
Julian Sommer a7925d65f2 feat: DescribeAddressesAttribute (#103) 2026-03-31 11:14:27 +11:00
Julian Sommer 0d2e91da37 fix: downgrade node types to 24.12.0 2026-03-31 08:16:45 +10:00
Julian Sommer e2c7a64aff fix: set ui to use dark mode by default 2026-03-31 08:10:59 +10:00
dependabot[bot] 588141cf67 chore: bump the npm-dependencies group across 1 directory with 18 updates (#101) 2026-03-31 09:05:09 +11:00
dependabot[bot] 9e3076727e chore: bump github.com/nats-io/nats.go in the go-dependencies group (#99) 2026-03-31 09:03:19 +11:00
Ben Duncan c1df418c5c fix: parse NODE column correctly from pipe-delimited spx get vms output 2026-03-30 22:16:23 +10:00
Ben Duncan 5e0d819393 test: add frontend unit tests to meet 70% line coverage threshold
Add tests for: back-link, not-found, list-card, header,
delete-confirmation-dialog, use-initial-sidebar hook, admin queries,
and admin-dashboard-panel. Line coverage: 63.98% → 71.12%.
2026-03-30 22:09:14 +10:00
Ben Duncan 6d3b2f7e5d feat: add multi-node NAT GW + spread placement group E2E test
Phase 11 in run-multinode-e2e.sh:
- Create VPC with public + private subnets + IGW
- Launch bastion in public subnet, verify SSH
- Create spread placement group, launch 3 VMs in private subnet
- Validate spread distribution via spx get vms (3 nodes)
- Verify no internet pre-NAT (ping 8.8.8.8 fails from all 3)
- Create NAT GW + EIP + route, poll for OVN SNAT rule
- Verify internet via NAT GW from all 3 nodes (different physical hosts)
- Full cleanup: NAT GW, instances, placement group, VPC resources
2026-03-30 21:54:22 +10:00
Ben Duncan f3c5bce811 feat: admin dashboard, nodes page, S3 service metrics, sidebar branding
Admin Dashboard Panel:
- spinifex service namespace on AWS gateway (GetCallerIdentity,
  GetVersion, GetNodes, GetVMs, GetStorageStatus)
- Admin detection via SigV4-signed GetCallerIdentity
- Dashboard panel: version, license, cluster status, add-ons matrix
- Sidebar branding: Mulga logo + "Spinifex", SVG favicon
- Upgrade button (admin + open-source)
- Documentation link (admin only)

Nodes Page (/nodes, admin only):
- Cluster nodes with services, roles, region, uptime
- Elastic Compute Cloud (EC2) instances with age
- Resource usage per node + instance type capacity table

S3 Service Metrics (/s3/service-metrics, admin only):
- Daemon NATS handler parses predastore TOML (creds stripped)
- Gateway queries DB nodes /status + /health in parallel
- Predastore (S3) Overview: RS encoding, node counts, cluster health
- DB Consensus Nodes: Raft state, leader, term, commit/applied index
- Object Storage Shard Nodes: topology from config

Frontend infrastructure:
- signedFetch utility (SigV4 via @smithy/signature-v4)
- AdminContext + useAdmin hook
- React Query options for admin endpoints
- Full-width sidebar separator above sign out
2026-03-30 17:21:45 +10:00
Ben Duncan 9b8863a182 Revert "feat: add install call-home telemetry to admin init and join"
This reverts commit 6e807f88cf.
2026-03-30 16:35:47 +10:00
Ben Duncan 6e807f88cf feat: add install call-home telemetry to admin init and join
Sends anonymous telemetry to install.mulgadc.com/install during
spx admin init and join. Reports machine_id (/etc/machine-id),
region, az, node count, version, and arch. Fire-and-forget with
5-second timeout — never blocks the user.

Opt out with --no-telemetry flag or SPX_NO_TELEMETRY=1 env var.
2026-03-30 16:34:45 +10:00
Ben Duncan 32f5ec92c0 fix: exclude 127.0.0.1 from SERVICE_IPS when awsgw binds to specific IP 2026-03-30 11:14:49 +10:00
Ben Duncan 6a4b823de7 fix: only test TLS connections on awsgw bind IP, not all interfaces
In single-node mode, cert E2E discovered all host IPs (WAN, mgmt, VPC)
and tried openssl s_client on each. But awsgw only binds to the
configured host IP, not all interfaces. Management (10.11.x) and VPC
(10.x) IPs have no listener on port 9999, causing false failures.

Split NODE_IPS (all IPs for SAN validation) from SERVICE_IPS (IPs
where awsgw listens) parsed from spinifex.toml. Connection-based
tests (3, 4c, 5, 8) now use SERVICE_IPS.
2026-03-30 10:51:29 +10:00
Ben Duncan 91483d632e fix: replace fixed sleeps with retry loops in NAT GW E2E tests
The NAT GW E2E tests used 2-3 second sleeps before verifying SNAT
rule creation, outbound connectivity, and rule removal. This caused
flaky CI failures when OVN took longer to propagate flows to the
datapath (varies by host load and OVN version).

Replace with retry loops:
- SNAT rule check: poll OVN NB every 1s for up to 30s
- Outbound ping: retry up to 10 attempts with 5s backoff (~50s max)
- SNAT removal: poll up to 15 attempts with 2s backoff (~30s max)

Also dump OVN routes alongside NAT rules on failure for debugging.
2026-03-30 10:27:22 +10:00
Ben Duncan 5426dc1bb0 feat: improved certificate authority with auto-discovery, CA download, and cloud-init injection
Auto-discover all node IPs (WAN, mgmt, VPC) and hostname for server
cert SANs — fixes TLS failures when accessing Spinifex via non-localhost
IPs. Adds CA download endpoint (/api/ca.pem) with install instructions
on the login page. Injects CA cert into guest VM cloud-init so EC2
instances trust Spinifex services without --insecure. Adds 'spx admin
cert renew' command for cert regeneration after IP changes.

Includes certificate validation E2E test suite (run-cert-e2e.sh) with
system trust store, SAN, and in-guest TLS verification across single,
pseudo multi-node, and real multi-node modes.

Closes: mulga-777, mulga-778, mulga-779, mulga-780, mulga-781
Plan: docs/development/feature/cert-manager.md
2026-03-28 14:29:07 +10:00
Julian Sommer 15f88caee7 fix: latest frontend build 2026-03-27 15:57:23 +10:00
Julian Sommer 5d194098ca feat: security groups frontend (#97) 2026-03-27 16:56:00 +11:00
Julian Sommer 94a80a3be7 fix: lint issues 2026-03-27 15:13:55 +10:00
Julian Sommer 3f642524a8 feat: vpc create wizard (#96) 2026-03-27 16:07:20 +11:00
Julian Sommer de1340a331 fix: let reset-dev-env.sh auto-detect chassis ID from hostname
Previously hardcoded CHASSIS_ID="chassis-node1" which could mismatch
the OVS system-id from prior setup-ovn.sh runs, creating stale chassis
entries in the SBDB. Now defers to setup-ovn.sh's auto-detect
(chassis-$(hostname -s)) so the name is always consistent.
2026-03-27 12:44:01 +10:00
Julian Sommer ae094cb936 fix: set OutputPort on default route and filter stale chassis
Two stacked bugs prevented SSH to VPC instances:

1. The default route (0.0.0.0/0) was added without OutputPort. For
   DHCP-sourced pools the gateway router port uses link-local addressing
   (169.254.0.1/30) so OVN northd cannot resolve the nexthop (e.g.
   192.168.1.1) to an output port — it silently drops the route from
   the southbound DB.

2. discoverChassis returned all chassis from the SBDB including stale
   entries from previous OVN setups on the same host. When a stale
   chassis got highest gateway priority, the chassisredirect port
   stayed unbound (up: false), so no ARP/NAT traffic was processed.

Fix: add OutputPort to static routes in both handleIGWAttach and
reconcileIGW, and filter stale chassis by cross-referencing the local
OVS system-id with SBDB hostnames.
2026-03-27 12:40:52 +10:00
Julian Sommer c2ab63fdef feat: basic terraform templates 2026-03-27 12:09:49 +10:00
Julian Sommer 04bea48a39 fix: dhcp default behaviour 2026-03-27 11:53:48 +10:00
Julian Sommer a92ea13798 fixes 2026-03-27 11:30:39 +10:00
Julian Sommer 46670ee8af test 2026-03-27 10:54:02 +10:00
Julian Sommer cbc04a2437 fix: chassis names mismatch 2026-03-27 10:26:16 +10:00
Julian Sommer 216842cc1b fix: enable ovn on boot 2026-03-27 09:41:23 +10:00
Julian Sommer 8ea40a1973 fix: test coverage 2026-03-27 09:04:21 +10:00
Julian Sommer 5bfc680a68 fix: elbv2 error handling 2026-03-27 08:45:32 +10:00
Julian Sommer 98d5ec92ed fix: add pre commit hook 2026-03-27 08:28:16 +10:00
Julian Sommer 2244ba56b0 fix: race condition test 2026-03-27 08:09:26 +10:00
Ben Duncan 9e8e944faa fix: pass private IP in vpc.delete-nat event to clean up OVN rules (mulga-764)
Root cause: when terminating instances, the vpc.delete-nat event was
published with an empty logicalIP. vpcd's DeleteNAT looked for a
dnat_and_snat rule with logicalIP="" — found nothing — and the real
rule was left as a stale entry in OVN.

When the IPAM later recycled that public IP for an EIP/NAT Gateway,
OVN had conflicting rules: stale dnat_and_snat AND new snat, both
using the same external IP.

Fix: extract PrivateIpAddress from instance.Instance before publishing
vpc.delete-nat, in both running and stopped termination paths.

Also adds 14 unit tests for NAT Gateway service.
2026-03-27 07:43:53 +10:00
joshsiv-mulga 3129c9eb70 feat: generic system image builder + lint fixes (mulga-766)
Extract build-alb-image.sh into manifest-driven build-system-image.sh:
- alb.conf manifest + alb-setup.sh for ALB-specific config
- build-alb-image.sh kept as thin wrapper for backward compat
- Makefile targets: build-system-image, build-alb-image
- Hardened error handling: curl --fail, e2fsck/resize2fs checks,
  static-link enforcement, rc-update validation, nbd device guard

Also fixes 12 lint issues: errcheck, errchkjson, gosec, gofmt, modernize.
2026-03-26 22:30:19 +10:00
joshsiv-mulga f744aedede feat: ALB/ELBv2 service 2026-03-26 21:38:14 +10:00
Ben Duncan e5801aa6ba fix: NAT Gateway E2E order + CA cert in reset-dev-env (mulga-763)
E2E: associate subnet with route table BEFORE adding the NAT GW route.
CreateRoute publishes SNAT events for associated subnets — if the
association doesn't exist yet, no SNAT rule is created in OVN.

Validated locally: bastion SSH hop works, ping 8.8.8.8 passes with
NAT GW, fails without. All 5 steps confirmed.

reset-dev-env.sh: trust Spinifex CA cert before starting services,
fixing TLS cert verify failures on fresh resets.
2026-03-26 16:23:38 +10:00
Julian Sommer 77a4a40ca6 fix: tf 2026-03-26 15:46:09 +10:00
Julian Sommer ab84905297 fix: test coverage (#93) 2026-03-26 16:30:44 +11:00
Ben Duncan 2a3ada5e93 fix: NAT Gateway E2E — poll for private instance SSH readiness (150s)
The bastion SSH hop failed because the private instance's cloud-init
hadn't completed (SSH daemon not ready). The instance was "running"
from the hypervisor perspective but cloud-init takes ~30-40s.

- Replace 2-attempt retry with 30-attempt poll (5s intervals, 150s max)
- Hard failure with OVN debug output instead of silent skip
- Matches how wait_for_ssh works for public instances
2026-03-26 15:29:48 +10:00
Ben Duncan adfd20dee5 test: NAT Gateway E2E — bastion SSH + ping validation (mulga-763)
Adds Phase 8d to run-e2e.sh with 5-step NAT Gateway validation:

1. Baseline: bastion → private VM → ping 8.8.8.8 (FAIL, no NAT GW)
2. Create NAT GW + EIP + route table + route + associate with private subnet
3. With NAT GW: bastion → private VM → ping 8.8.8.8 (PASS, SNAT active)
4. Delete NAT GW + route + cleanup
5. After deletion: bastion → private VM → ping 8.8.8.8 (FAIL, confirms NAT GW)

Reuses public + private instances from Phase 8b (deferred cleanup).
Bastion SSH approach: CI → public VM (pub IP) → private VM (VPC priv IP).
OVN SNAT rule verification when ovn-nbctl is available.
2026-03-26 14:43:01 +10:00
Ben Duncan 4ffc7785a5 feat: NAT Gateway — CRUD + vpcd SNAT rules + route table integration (mulga-750)
Implements AWS-compatible NAT Gateways for private subnet outbound internet:

API (3 actions):
- CreateNatGateway: validates subnet exists, EIP allocated, stores in KV
- DeleteNatGateway: removes record, publishes delete events for SNAT cleanup
- DescribeNatGateways: filter by ID, vpc-id, state

vpcd SNAT rule management:
- handleAddNATGateway: creates OVN snat rule (subnet CIDR → NAT GW EIP)
- handleDeleteNATGateway: removes OVN snat rule
- Subscribes to vpc.add-nat-gateway / vpc.delete-nat-gateway topics

Route table integration:
- CreateRoute now accepts NatGatewayId target (in addition to GatewayId)
- Validates NAT GW exists and belongs to same VPC
- Publishes vpc.add-nat-gateway events for associated subnets

OVN mechanics:
- Single snat rule per private subnet CIDR (shared by all VMs)
- No policy-based routing needed — SNAT rewrites before route lookup
- Existing 0.0.0.0/0 default route handles all egress
- Connection tracking handles return traffic automatically
2026-03-26 14:43:01 +10:00
Julian Sommer e91b67a7c6 feat: ModifyNetworkInterfaceAttribute 2026-03-26 14:08:28 +10:00
Julian Sommer 051ecbd4ce fix test 2026-03-26 13:54:29 +10:00
Julian Sommer ef68c5bffe feat: DescribeInstanceCreditSpecifications 2026-03-26 13:46:46 +10:00
Julian Sommer 64c7305b37 feat: DescribeInstanceAttribute (#92) 2026-03-26 14:39:45 +11:00
Ben Duncan d025cab50e fix: harden route table error handling (silent-failure-hunter review)
- getRouteTable: distinguish ErrKeyNotFound (→ not found) from other
  errors (→ log + internal error) instead of mapping all to "not found"
- putRouteTable: log underlying error on marshal/write failures
- KV scan loops (allRouteTablesForVPC, DescribeRouteTables,
  DisassociateRouteTable, ReplaceRouteTableAssociation): fail on
  transient NATS errors instead of silently skipping entries. Only
  skip ErrKeyNotFound (deleted between Keys() and Get())
- ReplaceRouteTableAssociation: compensating write if second KV put
  fails — restores association to old table to prevent data loss
- createMainRouteTable: returns error instead of swallowing it
- ensureDefaultIGWRoute: log actual errors at Warn level, not Debug
- E2E test: fail on missing default VPC instead of skipping
2026-03-26 13:33:22 +10:00
Julian Sommer 1c194da915 fix: change "attached" to "available" in igw to match aws + add describe instance types filtering for TF 2026-03-26 12:34:32 +10:00
Julian Sommer 4ad5843ff5 feat: ModifyVpcAttribute + DescribeVpcAttribute (#91) 2026-03-26 13:28:09 +11:00
Ben Duncan 7ea0353d2f test: E2E route table validation in single-node test (mulga-761)
Adds Phase 8c to run-e2e.sh with three steps:

1. Verify default VPC main route table has local + IGW routes
2. Custom route table full lifecycle (create → add route → associate
   → disassociate → delete route → delete table)
3. Error paths: reject local route deletion, reject main table deletion
2026-03-26 12:26:52 +10:00
Ben Duncan f8fc2b2cbb feat: route tables V1 — CRUD, associations, VPC integration (mulga-760)
Implements AWS-compatible route tables for VPC subnets:

Route Table CRUD:
- CreateRouteTable, DeleteRouteTable, DescribeRouteTables
- Main route table auto-created with every VPC (CreateVpc + EnsureDefaultVPC)
- Cannot delete main table or tables with subnet associations

Route CRUD:
- CreateRoute (IGW targets), DeleteRoute, ReplaceRoute
- Immutable local route (VPC CIDR → local) cannot be deleted
- IGW ownership validation (must be attached to same VPC)
- Duplicate destination CIDR rejected

Association CRUD:
- AssociateRouteTable, DisassociateRouteTable, ReplaceRouteTableAssociation
- One explicit association per subnet
- Disassociating reverts subnet to main table

VPC Integration:
- CreateVpc auto-creates main route table with local route
- EnsureDefaultVPC creates main route table for default VPC
- ensureDefaultVPCInfrastructure adds 0.0.0.0/0 → IGW route

DescribeRouteTables filters: vpc-id, route-table-id, association.main,
association.subnet-id, route.destination-cidr-block, route.gateway-id

20 unit tests covering all CRUD operations and error paths.
2026-03-26 12:26:52 +10:00
Julian Sommer 74671a05a5 aws basic 2026-03-26 12:00:38 +10:00
Julian Sommer 90a3da2cc2 fix: daemon nil pointer 2026-03-26 11:59:44 +10:00
Julian Sommer 06a8da8fb5 fix: dependabot 2026-03-26 11:05:04 +10:00
Julian Sommer 7c6f03f0d0 chore: update action versions 2026-03-26 10:45:02 +10:00
Julian Sommer e6b48ce170 fix: docs 2026-03-26 10:29:48 +10:00
Julian Sommer fca9d018fc fix: install guide 2026-03-26 10:02:04 +10:00
Julian Sommer 4a2ae33dd9 fix: add missing assets 2026-03-26 09:56:38 +10:00
Julian Sommer 9e53443f7a fix: decrease shutdown wait time 2026-03-26 08:55:55 +10:00
Julian Sommer bf6b25842f feat: add dependabot auto updates 2026-03-26 08:36:50 +10:00
Ben Duncan 5b442e8eef chore: increase pseudo multi-node E2E timeout to 35m
External networking adds overhead (DHCP, tap setup/teardown) and the
shutdown drain phase needs more time with tap networking cleanup.
2026-03-25 20:54:46 +10:00
Ben Duncan a3c4819c1c fix: enable external networking in pseudo multi-node E2E
The pseudo multi-node test was not passing --external-mode/--external-pool
to spx admin init, so instances never got public IPs. SSH then failed
trying to use hostfwd which doesn't exist with tap networking.

- init_leader_node: pass SPX_EXTERNAL_POOL as --external-mode=pool flags
- e2e.yml: set SPX_EXTERNAL_POOL=192.168.1.220-229 for pseudo test run
2026-03-25 20:26:52 +10:00
Ben Duncan 70199f410a fix: pseudo multi-node E2E uses public IP + nbdkit for node detection
After enabling tap networking (public subnet), QEMU no longer uses
hostfwd bindings. The pseudo multi-node test was still grepping for
hostfwd to detect instance distribution and SSH endpoints.

- check_instance_distribution: count nbdkit root-volume processes per
  node IP instead of hostfwd-bound QEMU processes
- get_instance_node: match QEMU nbd socket → nbdkit host= IP to
  determine which simulated node hosts each instance
- count_instances_per_node: use get_instance_node instead of hostfwd
- SSH connectivity: check PublicIpAddress first (port 22), fall back
  to hostfwd — same pattern as single-node and multi-node E2E
2026-03-25 19:53:00 +10:00
Ben Duncan 7cdf630cf7 feat: propagate network config via formation server to joining nodes (mulga-759)
admin init builds a NetworkConfig from external networking flags and
passes it to the formation server. When joining nodes poll /formation/status,
they receive the cluster-wide [network] section (external mode, pools,
gateway, DNS, bootstrap VPC IDs). Each joining node auto-detects its
own external_interface and wan_bridge locally.

Fixes multi-node clusters having no [network] section in spinifex.toml
on secondary nodes, which caused no public IP assignment and broke
guest SSH in E2E tests.
2026-03-25 17:06:21 +10:00
Ben Duncan fb8c42e934 fix: poll for NAT rule cleanup instead of fixed sleep + hard fail
The dnat_and_snat rule is removed asynchronously via NATS after instance
termination. A 2s sleep was insufficient. Now polls for up to 30s and
downgrades to WARN if the rule persists (non-fatal — cleanup happens
when the VPC is deleted).
2026-03-25 16:35:15 +10:00
Ben Duncan cc46cf746e fix: multi-node E2E SSH via public IP when external networking enabled
Same fix as single-node: detect PublicIpAddress from describe-instances,
SSH via public IP on port 22. Falls back to QEMU hostfwd on the hosting
node when no public IP is available.
2026-03-25 16:05:54 +10:00
Ben Duncan e65f00c86a fix: set TenantID on root volume created by RunInstances (mulga-758)
GenerateVolumes did not set TenantID on the VolumeMetadata for root
volumes cloned from AMIs. After the pre-Phase4 tenant fallback was
removed (ae316db), describe-volumes filters volumes by exact tenantID
match — root volumes with empty TenantID became invisible to the
owning account, breaking E2E Phase 5b volume lifecycle tests.
2026-03-25 16:01:18 +10:00
Ben Duncan 85f51b8084 Merge branch 'vpc-public-subnet' into dev 2026-03-25 15:43:08 +10:00
Ben Duncan 4749a37725 fix: SSH via public IP instead of QEMU hostfwd when external networking enabled
When external_mode is set, dev_networking=false (no QEMU hostfwd NIC).
E2E tests now detect the instance's public IP from describe-instances
and SSH via port 22 on the public IP. Falls back to hostfwd only when
no public IP is available (overlay-only mode).

Applies to Phase 5a-ii, 7b, 7c-pre, and 8b SSH tests.
2026-03-25 15:34:33 +10:00
Ben Duncan 982a7b6b45 feat: SSH via public IP in E2E public subnet test
Verify full networking path: host → public IP → OVN DNAT → VM.
Test outbound internet from VM via OVN SNAT (ping 8.8.8.8).
Falls back to API-only verification if public IP unreachable.
2026-03-25 15:33:21 +10:00
Julian Sommer b81fdb5bc0 chore: frontend deps 2026-03-25 15:26:50 +10:00
Julian Sommer 71e82b22a7 chore: bump deps 2026-03-25 15:24:21 +10:00
Ben Duncan 21c4754336 feat: E2E tests for public/private subnet isolation + remove legacy nat mode
- Add Phase 8b to run-e2e.sh: public subnet instance gets PublicIpAddress
  and dnat_and_snat rule; private subnet instance gets neither; no blanket
  VPC CIDR SNAT (validates mulga-754 fix)
- Remove legacy external_mode="nat" — only "pool" with source=static or
  source=dhcp. Admin init defaults to pool/dhcp when WAN detected.
- Enable dev_networking always (was disabled with external mode, breaking
  E2E SSH tests)
- Update bootstrap.sh + e2e.yml: SPX_EXTERNAL_POOL env var configures
  static IP pool for CI (230-240 single-node, 240-250 multi-node)
- Fix run-vpc-e2e.sh SNAT assertion for mulga-754 (no blanket SNAT)
- Add unit tests for parseDHCPCDLeasedIP, ReconcileFromKV edge cases,
  DHCP pool config validation to meet 60% diff coverage threshold
2026-03-25 15:22:18 +10:00
Julian Sommer ec29cc2745 Revert "fix: remove InsecureSkipVerify (#87)"
This reverts commit a66b1a3d48.
2026-03-25 15:19:48 +10:00
Julian Sommer 5d31f05dba refactor: migrate ObjectStore from ListObjects V1 to V2
Replace all ListObjects (V1) usage with ListObjectsV2 across the
objectstore interface, both implementations, all 10 call sites, and
tests. V2 uses ContinuationToken instead of Marker for pagination
and adds KeyCount to responses. The frontend and Predastore already
speak V2 — this aligns the Go backend.
2026-03-25 14:44:13 +10:00
Julian Sommer 73e5905092 chore: bump viperblock version 2026-03-25 14:17:15 +10:00
Ben Duncan edcc53b947 Merge commit '820f620e34006699de1105859301146aaf491ca5' into vpc-public-subnet
# Conflicts:
#	cmd/spinifex/cmd/admin.go
#	docs/installing-spinifex/README.md
#	scripts/setup.sh
#	spinifex/admin/admin.go
#	spinifex/admin/network_detect.go
#	spinifex/handlers/iam/service_impl.go
2026-03-25 14:16:47 +10:00
Julian Sommer 820f620e34 feat: aws tf template 2026-03-25 13:54:24 +10:00
Ben Duncan 3d73dd180b fix: add dhcpcd-base to system dependencies for DHCP pool mode
DHCP pool mode requires dhcpcd-base (/usr/sbin/dhcpcd) to obtain
leases from the router. Added to Makefile install-system, setup.sh
binary installer, and source-install docs.

Closes: mulga-753
2026-03-25 12:05:21 +10:00
Ben Duncan 7bfb032f90 fix: remove blanket VPC CIDR SNAT from IGW — private subnet isolation (AWS parity)
handleIGWAttach and reconcileIGW created a blanket snat rule for the
entire VPC CIDR, allowing private subnet instances (no public IP) to
route outbound through the IGW. AWS behavior: only instances with
public IPs route through IGW via per-VM dnat_and_snat rules. Private
subnets require a NAT Gateway for outbound internet.

Also fixes pre-existing lint issues in external_ipam_dhcp.go (gofmt,
modernize, nolintlint).

Closes: mulga-754
2026-03-25 12:05:15 +10:00
Julian Sommer 15d6b0aa20 fix: multi node e2e decrease ssh timeout 2026-03-25 11:53:52 +10:00
Julian Sommer a66b1a3d48 fix: remove InsecureSkipVerify (#87) 2026-03-25 12:51:34 +11:00
Ben Duncan cbbd81bc76 feat: DHCP pool mode — public IPs from router DHCP instead of static range
Adds source="dhcp" option to external pool config. When set, ExternalIPAM
obtains public IPs from the upstream router's DHCP server (via dhcpcd on
br-ext) instead of allocating from a static range_start..range_end.

The VM behavior is identical to static pool mode: private VPC IP from OVN
DHCP, public IP via DNAT+SNAT, full DescribeInstances support. The only
difference is where the public IP comes from.

Two separate DHCP conversations:
- Host ↔ Router DHCP (br-ext): "give me a public IP for this VM's NAT"
- VM ↔ OVN DHCP (br-int): "give me my private VPC IP"

Tested: DHCP pool mode on single-node (ben-banksia). VM got public IP
192.168.1.77 from router DHCP, SSH works, outbound internet works.
2026-03-25 10:53:29 +10:00
Julian Sommer 8c4adf08a3 chore: bump predastore to v1.1.2 2026-03-25 10:52:08 +10:00
Julian Sommer c0b89e6d8c fix: path 2026-03-25 10:35:30 +10:00
Ben Duncan dd86805514 docs: add DHCP pool mode to vpc-networking guide
Explains static vs DHCP IP sources in pool mode, the two-DHCP-conversations
architecture, when to use each, and adds a DHCP homelab deployment example.
Also updates bridge setup section for veth pair approach.
2026-03-25 10:30:42 +10:00
Julian Sommer ad1339505d fix: dump vpcd log 2026-03-25 10:09:39 +10:00
Julian Sommer ba7e6a5c67 fix: redirect image import to dev null 2026-03-25 10:04:16 +10:00
Ben Duncan 4d56565c3f feat: veth pair bridge — link Linux bridge to OVS without conversion
setup-ovn.sh now detects whether the default route is on a Linux bridge
or OVS bridge. When it's a Linux bridge (e.g. br-wan from cloud-init),
instead of destructively converting it, the script creates an OVS bridge
(br-ext) and links them with a veth pair. The Linux bridge keeps its IP,
routes, and ports untouched — no WAN interruption.

Also fixes all pre-existing lint issues (gofmt, ineffassign, errchkjson,
gosec, modernize, whitespace) to bring golangci-lint to 0 issues.

Tested: pool mode with static IP range on single-node (ben-banksia).
VM gets public IP 192.168.1.201, SSH works, outbound internet works.
2026-03-25 10:01:37 +10:00
Julian Sommer ae316dba47 fix: remove pre-Phase4 volume tenant fallback
Simplify all 6 tenant-check sites in volume handlers from
`if accountID != "" && tenantID != "" && tenantID != accountID`
to `if tenantID != accountID`. No pre-Phase4 volumes with empty
TenantID exist in any environment — this code was unreachable.
Remove vol-legacy test fixture and empty-accountID-sees-all assertion.
2026-03-25 09:59:42 +10:00
Julian Sommer bdf0522895 feat: admin init auto install cert 2026-03-25 09:06:50 +10:00
dependabot[bot] e347f0a75e chore: bump github.com/nats-io/nats-server/v2 (#86) 2026-03-25 08:14:15 +10:00
Ben Duncan c2992ebe5e fix: reset-dev-env.sh — pass ADMIN_INIT_ARGS to second admin init --force
The second admin init --force was called without external networking
flags, overwriting the pool/nat config from the first call with
auto-detected NAT+DHCP defaults.
2026-03-24 17:09:48 +10:00
Ben Duncan ffca7330c5 fix: skip external IPAM init in NAT mode — only pool mode needs per-VM allocation
In NAT mode, all VMs share a single gateway IP for SNAT. No per-VM
public IPs are allocated. Previously, external IPAM was initialized
for any external_mode != "", causing noisy AllocateIP failures (empty
pool range) on every instance launch.

Now IPAM is only initialized when external_mode == "pool". In NAT mode,
d.externalIPAM stays nil, cleanly skipping the auto-assign public IP
path. SNAT topology is still created by vpcd via the pool's gateway_ip.

Part of mulga-737 (Phase 16).
2026-03-24 16:27:28 +10:00
Ben Duncan a21a275776 docs: update vpc-networking README for br-wan + new setup-ovn.sh flags
Replace all br-external references with br-wan. Update setup-ovn.sh
command examples to use --wan-bridge/--wan-iface/--macvlan instead of
--external-bridge/--external-iface/--direct.

Part of mulga-742.
2026-03-24 16:19:19 +10:00
Ben Duncan 78bc0d989d docs: update install guides + setup.sh for new setup-ovn.sh flags
- All guides: OVN setup before admin init (consistent ordering)
- Single-node + source-install: Replace --external-bridge/--external-iface
  with auto-detection + --wan-bridge/--wan-iface/--macvlan options
- Multi-node: Simplify examples (auto-detect WAN bridge), show override
  options in prose
- setup.sh: Update "next steps" to match single-node guide, add
  --wan-bridge/--macvlan guidance for physical NIC setups

Part of mulga-742.
2026-03-24 16:17:35 +10:00
Ben Duncan 8c6f66670a feat: add wan_bridge config field, update vpcd detection + network_detect
- config.go: Add WanBridge field to VPCDConfig (default "br-wan")
- spinifex.toml template: Add wan_bridge under [nodes.X.vpcd]
- admin.go: Auto-detect WAN bridge name from default route interface,
  add detectedWanBridge helper
- service.go: Wire WanBridge from cluster config to vpcd Config
- vpcd.go: Add WanBridge to Config, log it on startup, update comments
- network_detect.go: Fix isVirtualInterface to accept the default route
  device as a parameter instead of hardcoding br-external. Bridges like
  br-wan are now correctly treated as physical when they carry the
  default route.

Part of mulga-741.
2026-03-24 16:02:22 +10:00
Ben Duncan 1fca43c9f4 refactor: reset-dev-env.sh + stop-dev.sh — use new setup-ovn.sh flags
reset-dev-env.sh: Replace --external-bridge/--external-iface/--direct
with --wan-bridge/--wan-iface/--macvlan. Auto-detect when WAN interface
is already a bridge (tofu-cluster) vs physical NIC.

stop-dev.sh: Replace hardcoded br-external check with dynamic scan of
all OVS bridges — any non-br-int bridge with an IP means OVS is
infrastructure and should not be stopped.

Part of mulga-740.
2026-03-24 15:58:49 +10:00
Ben Duncan 34b50b11fb refactor: setup-ovn.sh — replace br-external with configurable WAN bridge
Replace --external-bridge/--external-iface/--direct with:
- --wan-bridge=NAME: OVS bridge for WAN traffic (auto-detected from
  default route if it's already a bridge)
- --wan-iface=NAME: physical NIC to add to the WAN bridge
- --macvlan: SSH-safe macvlan mode for single-NIC hosts

Auto-detection logic:
- If default route goes through a bridge (br-wan) → use it directly
- If default route is a physical NIC → stop with guidance (never
  auto-move a NIC into OVS — may drop SSH)

Bridge-mappings updated: external:$WAN_BRIDGE (was external:br-external).
OVN logical network name stays "external" (no topology.go change needed).

Encap IP now prefers br-vpc IP when available (dedicated VPC data plane).

Part of mulga-739. Blocks VPC E2E work.
2026-03-24 15:46:49 +10:00
Julian Sommer 8f81ac29a4 chore: update license references 2026-03-24 15:43:11 +10:00
Julian Sommer eefed70658 docs: add iam 2026-03-24 15:16:33 +10:00
Ben Duncan 216ca9e710 feat: Phase 12b + Phase 16 — KV reconcile on startup + NAT mode support
Phase 12b: vpcd now reconciles OVN topology from NATS KV on startup
(Pass 2). After subscribing to topics, it reads all VPCs, subnets,
IGW attachments, and ENIs from KV and ensures matching OVN objects
exist. Handles reboots, OVN DB loss, and missed events. All operations
are idempotent.

Phase 16 prep: Add --gateway-ip flag to admin init for NAT mode
(specifies the OVN gateway router's external IP without DHCP).
Update reset-dev-env.sh to support EXTERNAL_MODE=nat env var for
NAT/DHCP mode testing alongside the default pool mode.
2026-03-24 15:03:04 +10:00
Julian Sommer fefd5ed9e8 chore: update to typescript 6 2026-03-24 14:29:55 +10:00
Julian Sommer d47f2ab4fd fix: a11y 2026-03-24 14:02:26 +10:00
Julian Sommer 58a9d5741f fix: missing ui tests 2026-03-24 13:45:48 +10:00
Julian Sommer 625a78c529 feat: placement groups frontend 2026-03-24 13:39:04 +10:00
Ben Duncan 88baf5e202 feat: direct bridge mode — prefer over macvlan when WAN NIC != SSH NIC
Auto-detect bridge topology: if a dedicated WAN NIC exists (not used for
SSH), add it directly to br-external instead of creating a macvlan.
Direct bridge enables distributed NAT (DNAT on VM's own chassis) and
eliminates macvlan workarounds (MAC alignment, gratuitous ARPs,
centralized NAT bottleneck).

- topology.go: bridge-mode-aware NAT and localnet port options
- vpcd.go: auto-detect bridge mode, skip MAC alignment for direct bridge
- setup-ovn.sh: --direct flag for physical NIC on br-external
- reset-dev-env.sh: SSH NIC detection via SSH_CONNECTION
2026-03-24 01:57:54 +00:00
Julian Sommer 6379a2098a fix: lint errors 2026-03-24 11:55:42 +10:00
Julian Sommer 638828f636 feat: add leaders to get nodes command 2026-03-24 11:50:28 +10:00
Julian Sommer 0fbf4537c0 chore: bump viperblock 2026-03-24 11:24:40 +10:00
Julian Sommer 1b5108d379 chore: bump frontend deps 2026-03-24 10:45:01 +10:00
Julian Sommer bab7b1dea3 fix: enable last rules 2026-03-24 10:28:20 +10:00
Julian Sommer 30b08ca984 fix: use nullish coalescing 2026-03-24 10:20:11 +10:00
Julian Sommer 3467e0cf22 feat: oxlint type checked rules 2026-03-24 10:14:31 +10:00
Julian Sommer 35d0df5b56 feat: placement groups (#85) 2026-03-24 10:52:00 +11:00
Julian Sommer 648b36d672 fix: add agent browser 2026-03-24 09:25:48 +10:00
Ben Duncan d6062ed550 docs: document direct bridge vs macvlan modes for br-external
Direct bridge (WAN NIC added directly to OVS) is preferred when a
dedicated WAN NIC exists. Enables distributed NAT, no MAC workarounds,
host can reach VMs. Macvlan is fallback for single-NIC hosts only.
2026-03-23 11:36:15 +00:00
Ben Duncan 93aaf0f991 fix: single-node E2E — OVN SB cleanup, chassis naming, macvlan NAT, DNS
Three blockers fixed for VPC public subnet E2E on single-node:

1. OVN SB stale data: reset-dev-env.sh now deletes both NB+SB DB files
   instead of individual NB cleanup. Eliminates ovn-controller commit
   failure loop from stale chassis/port binding entries.

2. Chassis name mismatch: reset-dev-env.sh passes --chassis-id=chassis-node1
   to setup-ovn.sh, aligning OVS system-id with vpcd's chassis-{nodeName}.

3. Macvlan unicast filtering: switched to centralized NAT (no external_mac
   on DNAT rules), set nat-addresses=router on localnet ports, and vpcd
   aligns macvlan MAC with OVN gateway router MAC on startup. Macvlan only
   delivers unicast matching its own MAC — this ensures inbound traffic
   from the LAN reaches OVS/OVN.

Also fixed: DNS for VMs. admin init auto-detects host DNS servers via
resolvectl, stores them in spinifex.toml dns_servers field, and vpcd
propagates them to OVN DHCP options. Fallback: 8.8.8.8 + 1.1.1.1.

Validated E2E: inbound ping, SSH, outbound ping, DNS, curl, apt update.
2026-03-23 10:59:08 +00:00
Ben Duncan f34452883c refactor: move bootstrap config to admin init, add IGW ID, eliminate race
- admin init now pre-generates VPC/subnet/IGW IDs and writes [bootstrap]
  to spinifex.toml before services ever start. No race condition.
- EnsureDefaultVPC accepts optional BootstrapIDs to use pre-determined
  IDs instead of generating random ones.
- CreateInternetGatewayWithID allows daemon to use the bootstrap IGW ID.
- Removed writeBootstrapConfig from daemon (no longer needed).
- Fixed bootstrap account_id: uses admin account (000000000001) not
  system account (000000000000).
- reconcileIGW now sets spinifex:igw_id in OVN external IDs.
2026-03-23 06:57:25 +00:00
Ben Duncan c21f3dc188 feat: vpcd OVN reconciliation on startup + default subnet resolution
Phase 11b: RunInstances now auto-resolves the default subnet when no
SubnetId is specified, matching AWS behavior. Previously fell back to
SLIRP networking.

Phase 12: vpcd reconciles OVN topology from [bootstrap] config on startup.
The daemon writes a [bootstrap] section to spinifex.toml with the default
VPC/subnet IDs on first run. vpcd reads this on startup and creates any
missing OVN objects (router, switch, DHCP, IGW topology, SNAT, gateway
chassis). All operations are idempotent — safe on every restart.

This solves the bootstrap ordering problem where admin init creates VPC
records before vpcd is running, so NATS events are missed.

EnsureDefaultVPC now returns DefaultVPCInfo with VPC/subnet IDs.
2026-03-23 06:40:19 +00:00
Ben Duncan daae6ca6b8 refactor: remove single-nic bridge mode, standardize on macvlan
The single-nic IP migration path was fragile and caused connectivity
issues during setup. macvlan is now the only bridge strategy — always
creates spx-ext-{nic} on the WAN NIC. Removed --single-nic flag,
SingleNIC config/detection fields, and updated all docs.
2026-03-23 01:12:22 +00:00
Ben Duncan 32276c7cc7 feat: wire EIP + SG gateway actions, fix gateway chassis scheduling, default VPC infra
Phase 8a: Wire all 5 EIP gateway actions (AllocateAddress, ReleaseAddress,
AssociateAddress, DisassociateAddress, DescribeAddresses) end-to-end through
gateway → NATS → daemon → EIP service.

Phase 8b: Wire all 7 SG gateway actions (Create/Delete/Describe SecurityGroup,
Authorize/Revoke Ingress/Egress) through the same pipeline.

Critical fix: handleIGWAttach was not calling SetGatewayChassis on the gateway
router port. Without this, OVN has no chassis binding for external traffic —
SNAT/default route exist logically but packets have nowhere to go physically.
This was the root cause of outbound routing failure (can't ping 8.8.8.8).

Default VPC now matches AWS behavior: auto-creates IGW (attached),
MapPublicIpOnLaunch=true on default subnet, and a default security group
with intra-SG inbound allow + all outbound allow.

Comprehensive VPC networking docs rewrite covering modes, bridge setup,
configuration, security groups, EIPs, OVN reference, and troubleshooting.
2026-03-21 10:26:59 +00:00
Ben Duncan 30394b735b Removed legacy dir 2026-03-21 09:36:28 +00:00
Ben Duncan a25a4cec34 Merge commit '9b64c1911fc7e1a2442c376fb2cba0f5ece9a913' into vpc-public-subnet 2026-03-20 07:28:15 +00:00
Ben Duncan f6c6660f15 fix: create default VPC for admin account, not just system account
EnsureDefaultVPC was only called for GlobalAccountID (000000000000).
The admin account (000000000001) from admin init never got a default
VPC because seedAdminAccount didn't publish iam.account.created.

Fix: daemon startup now ensures default VPC for both system and admin
accounts. Also publish iam.account.created from seedAdminAccount so
any future account-creation listeners fire correctly.
2026-03-20 07:19:05 +00:00
Ben Duncan 704874c0c7 docs: restructure install guides + new common setup guide
All 3 install guides (binary, multi-node, source) now end at the same
verification point: aws ec2 describe-instance-types. Installation is
complete when that command returns instance types.

New docs/setting-up-your-cluster/README.md is the common guide all 3
install paths point to. Covers: AMI import, SSH key creation, VPC with
public subnet (IGW + modify-subnet-attribute), instance launch with
public IP, SSH via public IP, instance lifecycle management.

Removed SSH-via-hostfwd from all guides — public subnet is the default
connectivity path. Private subnet documented as an alternative.
2026-03-20 07:10:33 +00:00
Ben Duncan 665093343c fix: preserve DNS resolution after IP migration to br-external
When setup-ovn.sh migrates the WAN IP from a physical NIC to
br-external, systemd-resolved loses its DNS association (DNS servers
are per-interface). This breaks hostname resolution for everything
on the host — git, apt, curl, SSH all fail.

Now captures DNS config from the original interface before migration
and re-associates it with br-external via resolvectl. Falls back to
8.8.8.8 if no DNS was configured on the original interface.
2026-03-20 06:57:18 +00:00
Ben Duncan bb9e8db386 fix: stop-dev.sh must not stop OVS when br-external has WAN IP
When setup-ovn.sh migrates the host's WAN IP to br-external, stopping
openvswitch-switch destroys the bridge and the host loses all network
connectivity — unreachable via SSH, requires console reboot.

Now checks if br-external has an IP before stopping OVS. If it does,
skip the OVS stop and only stop ovn-controller + ovn-central. OVS is
infrastructure — bridges must survive service stop/start cycles.

Discovered during 3-node E2E testing: stop-dev.sh killed all 3 nodes'
WAN connectivity simultaneously.
2026-03-20 06:55:23 +00:00
Ben Duncan 0d4a70ef28 fix: remove verbose next-steps from admin init, disable dev_networking with external mode
Admin init output was duplicating install guide steps. Simplified to
just show completion message. Install guides already have all steps.

Set dev_networking=false when external_mode is configured — the hostfwd
NIC adds a confusing second interface inside VMs and is not needed when
public subnets provide WAN-routable IPs.
2026-03-20 06:45:11 +00:00
Ben Duncan 946429d792 feat: Phase 8c — daemon auto-assign public IP on RunInstances
Wire public IP auto-assignment end-to-end:
- RunInstances checks MapPublicIpOnLaunch on subnet, allocates external
  IP from ExternalIPAM, updates ENI record, publishes vpc.add-nat for
  dnat_and_snat rule, returns PublicIpAddress in response
- TerminateInstances releases public IP back to pool, publishes
  vpc.delete-nat to remove NAT rule before ENI deletion
- DescribeInstances populates PublicIpAddress from VM struct

Add ExternalIPAM initialization in daemon startup from cluster
NetworkConfig. Add GetSubnet and UpdateENIPublicIP helpers. Add
PublicIP/PublicIPPool fields to vm.VM struct. Fix network auto-detect
to recognize br-external as WAN interface.
2026-03-20 06:41:32 +00:00
Ben Duncan a554fe4ecf docs: rewrite single + multi-node install guides
Single-node:
- Add auto-detected network topology + external networking section
- Replace hostfwd SSH with VPC overlay private IP SSH
- Add setup-ovn.sh step with --dhcp flag
- Fix undefined variables ($SPINIFEX_AMI, $SPINIFEX_SUBNET)
- Use bash variable capture for VPC/subnet/AMI IDs
- Add three-tier external networking table
- Fix step headings (## → ###)

Multi-node:
- Move CA certificate trust to Step 5 (before starting services)
- Add --external-bridge --dhcp to all OVN setup commands
- Add AMI import step (was missing entirely)
- Add VPC + subnet creation step with variable capture
- Add SSH via private IP step
- Fix sudo inconsistency (all formation commands use sudo)
- Add external networking section with pool upgrade path
- Add DHCP troubleshooting section
- Add port requirements to overview
2026-03-20 05:58:38 +00:00
Ben Duncan 2af3bf5287 feat: auto-detect network topology + DHCP gateway in admin init
spx admin init now works with zero external networking flags:
- Auto-detects WAN interface, gateway, prefix from ip route
- Auto-detects single-NIC vs multi-NIC mode
- Defaults to nat mode with DHCP for gateway IP
- Prints detected topology with interface roles

setup-ovn.sh gains --dhcp flag: obtains a DHCP lease on the
macvlan/bridge interface for the OVN gateway SNAT address, then
writes gateway_ip into spinifex.toml. Tested on VM: macvlan gets
separate IP from router DHCP, host keeps its original IP.

Three tiers of external networking:
- Zero flags: auto nat+DHCP (VMs get outbound internet)
- --external-pool=start-end: pool mode (VMs get public IPs)
- --no-external: overlay only (no internet for VMs)
2026-03-20 05:45:30 +00:00
Ben Duncan 1f38213d4b feat: add external networking flags to spx admin init
Add --external-mode, --external-iface, --single-nic, --external-pool,
--external-gateway, --external-prefix-len flags to admin init. Config
is now generated with the correct [network] section and per-node vpcd
external_interface/single_nic — no manual spinifex.toml editing needed.

Single-node single-NIC:
  spx admin init --external-mode=pool --external-iface=eth0 --single-nic \
    --external-pool=192.168.1.150-192.168.1.250 --external-gateway=192.168.1.1

Single-node two-NIC:
  spx admin init --external-mode=pool --external-iface=eth1 \
    --external-pool=192.168.1.150-192.168.1.250 --external-gateway=192.168.1.1

Next steps output now shows the correct setup-ovn.sh command with
matching flags.
2026-03-20 05:24:06 +00:00
Ben Duncan 1ee7012ba9 feat: Phase 7 — single-NIC macvlan bridge strategy
Add --single-nic flag to setup-ovn.sh. When used with --external-bridge,
creates a macvlan sub-interface (spx-ext-{iface}) in bridge mode off the
host's NIC instead of adding the NIC directly to br-external. The host
keeps its IP — no migration, SSH-safe.

Strategy selection:
- Dedicated NIC: --external-iface=eth1 (add directly)
- Single NIC:    --external-iface=eth0 --single-nic (macvlan)
- IP migration:  --external-iface=eth0 (legacy, no --single-nic)

Add single_nic bool field to VPCDConfig for per-node config. Different
nodes in a cluster can use different strategies. Unit test verifies
single_nic parsing alongside external_interface.
2026-03-20 05:15:35 +00:00
Ben Duncan b6e99947e1 feat: Phase 5 — unit tests for ACL, security groups, public subnet, EIP
Add 18 unit tests covering:
- ACL match expression compiler (8 tests): TCP port/range, ICMP, UDP
  range, all-protocols, SG-ref source, egress, no-source filter
- Security group Port Group lifecycle (3 tests): create, delete, update
  with ACL generation via NATS events + MockOVNClient
- ModifySubnetAttribute + MapPublicIpOnLaunch (2 tests): default false,
  toggle to true via ModifySubnetAttribute
- Elastic IP lifecycle (5 tests): allocate, allocate from specific pool,
  release, release-while-associated error, describe
2026-03-20 04:35:52 +00:00
Ben Duncan 5481ef4361 feat: Phase 4 — security groups (OVN ACLs + Port Groups)
Add security group CRUD handlers: CreateSecurityGroup, DeleteSecurityGroup,
DescribeSecurityGroups, AuthorizeSecurityGroupIngress/Egress,
RevokeSecurityGroupIngress/Egress. SG records stored in NATS KV.

Add ACL match expression compiler (acl.go) translating AWS SG rules to
OVN ACL match strings for TCP/UDP port/ranges, ICMP, all-protocols, CIDR
filters, and intra-SG references.

Add vpcd security.go handlers for OVN Port Group lifecycle and ACL
management via vpc.create-sg/delete-sg/update-sg NATS topics. Extend
OVNClient interface with CreatePortGroup, DeletePortGroup,
SetPortGroupPorts, AddACL, ClearACLs. Add PortGroup and ACL to nbdb
model.
2026-03-20 04:24:49 +00:00
Ben Duncan ee06105c56 feat: Phase 3 — Elastic IPs (AllocateAddress, AssociateAddress)
Add EIP service with AllocateAddress, ReleaseAddress, AssociateAddress,
DisassociateAddress, and DescribeAddresses. Uses ExternalIPAM for IP
allocation from admin-defined pools. Publishes vpc.add-nat/delete-nat
events for OVN dnat_and_snat rules.

EIP records stored in spinifex-vpc-elastic-ips NATS KV bucket with
account-scoped keys. AssociateAddress replaces auto-assigned public IPs
when present.
2026-03-20 04:21:46 +00:00
Ben Duncan 150bf246a4 feat: Phase 2 — public subnets + auto-assign public IPs
Add MapPublicIpOnLaunch field to SubnetRecord with ModifySubnetAttribute
API handler. Add PublicIpAddress/PublicIpPool fields to ENIRecord for
tracking auto-assigned public IPs.

Add vpc.add-nat / vpc.delete-nat NATS topics and handlers in vpcd
topology for creating distributed dnat_and_snat NAT rules (with
LogicalPort + ExternalMAC for chassis-local NAT processing).

Wire ModifySubnetAttribute through gateway → daemon → VPC service.
2026-03-20 04:19:28 +00:00
Julian Sommer 9b64c1911f ci: add pull_request triggers to CI workflows
Enable PR-triggered CI so required status checks can gate merges.
Spinifex runs on all PRs (no path filter) to avoid blocking non-Go PRs.
UI workflow keeps path filters as it's not a required check.
2026-03-20 14:08:12 +10:00
Ben Duncan c1cdfc07c3 feat: Phase 1 completion — external IPAM, br-external setup, config template
Add external IPAM module (CAS-based IP allocation from admin-defined
pools in NATS KV). Supports multi-pool with region/AZ scoping, pool
fallback logic, gateway IP reservation, and concurrent allocation via
CAS retries. 12 unit tests covering allocation, release, exhaustion,
CAS conflict, multi-pool, fallback, validation, and idempotent init.

Add --external-bridge flag to setup-ovn.sh: creates OVS bridge
br-external, adds WAN NIC, migrates host IP to bridge, and sets
ovn-bridge-mappings=external:br-external for OVN localnet ports.

Add commented [network] section and external_interface to spinifex.toml
config template for discoverability.
2026-03-20 03:59:47 +00:00
Ben Duncan c6af189638 Merge commit '02931567ac207bd074d8b2efda6e523ab36540d9' into vpc-public-subnet 2026-03-20 03:47:50 +00:00
Julian Sommer 02931567ac release: update go.mod for v0.4.2 2026-03-20 13:09:48 +10:00
dependabot[bot] c4890c80d3 build(deps): bump fast-xml-parser (#83) 2026-03-20 12:48:53 +11:00
Julian Sommer d2d3e52522 feat: migrate to golangci-lint (#82) 2026-03-20 12:14:42 +11:00
Julian Sommer 42cb6dbed0 fix: add better error message 2026-03-20 11:12:32 +10:00
Julian Sommer 6cdeea3aa8 chore: more tests 2026-03-20 09:27:21 +10:00
Julian Sommer 4884b68b51 feat: version bootstrap file 2026-03-20 09:12:42 +10:00
Julian Sommer 726528bd9d feat: version jetstream buckets 2026-03-20 09:07:40 +10:00
Julian Sommer 593920ce7d feat: E2E test redistribution and dev-setup cleanup
Redistribute E2E tests between single-node and multi-node suites, slim
down pseudo multi-node suite, and remove directory creation from
dev-setup.sh so spx admin init is the single source of truth for
~/spinifex/ setup.
2026-03-20 08:24:15 +10:00
Ben Duncan 5e30d12f76 feat: Phase 1 — external network config + IGW gateway router fix
Add cluster-wide [network] config with external_mode and multi-pool
support (ExternalPool with region/AZ scoping). Per-node vpcd gets
external_interface for WAN NIC name.

Fix handleIGWAttach to use real external IPs from the configured pool
instead of link-local 169.254.0.1/30. Gateway router port now gets
the pool's gateway IP with correct prefix length, SNAT uses the real
external IP, and the default route points to the actual WAN gateway.

Includes pool selection logic (AZ-scoped → region-scoped → unscoped
fallback) and unit tests for config parsing, IGW with external pool,
explicit gateway IP, and pool selection.
2026-03-19 11:34:30 +00:00
796 changed files with 98355 additions and 14610 deletions
+17
View File
@@ -0,0 +1,17 @@
#!/usr/bin/env bash
set -e
branch=$(git rev-parse --abbrev-ref HEAD)
# Only enforce preflight on dev and main
if [ "$branch" != "dev" ] && [ "$branch" != "main" ]; then
exit 0
fi
# Only run preflight if Go files were staged
if git diff --cached --name-only | grep -q '\.go$'; then
echo "Go files staged on $branch — running make preflight..."
make preflight
else
echo "No Go files staged, skipping preflight."
fi
+225
View File
@@ -0,0 +1,225 @@
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="620" viewBox="0 0 900 620">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#0a0a0a"/>
<stop offset="100%" style="stop-color:#0d1117"/>
</linearGradient>
<linearGradient id="bar-grad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#161b22"/>
<stop offset="45%" style="stop-color:#1f2028"/>
<stop offset="100%" style="stop-color:#161b22"/>
</linearGradient>
<linearGradient id="client-grad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#3fb95022"/>
<stop offset="100%" style="stop-color:#3fb95008"/>
</linearGradient>
<linearGradient id="gw-grad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#DC262633"/>
<stop offset="100%" style="stop-color:#7F1D1D14"/>
</linearGradient>
<linearGradient id="nats-grad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#F87171"/>
<stop offset="50%" style="stop-color:#DC2626"/>
<stop offset="100%" style="stop-color:#7F1D1D"/>
</linearGradient>
</defs>
<rect width="900" height="620" fill="url(#bg)" rx="12"/>
<!-- Title bar -->
<text x="24" y="30" font-family="'Segoe UI', system-ui, sans-serif" font-size="11" fill="#a1a1aa" letter-spacing="2.5" font-weight="600">SPINIFEX ARCHITECTURE</text>
<text x="24" y="52" font-family="'Segoe UI', system-ui, sans-serif" font-size="16" fill="#f5f5f5" font-weight="500">AWS SDK → Gateway → NATS → Service daemons</text>
<text x="876" y="30" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#71717a" text-anchor="end">stateless · message-driven · horizontally scalable</text>
<!-- ═══ CLIENT LAYER ════════════════════════════════════ -->
<rect x="16" y="72" width="868" height="78" rx="8" fill="url(#client-grad)" stroke="#3fb950" stroke-width="1" stroke-opacity="0.5"/>
<text x="32" y="93" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#3fb950" font-weight="700" letter-spacing="1">CLIENTS</text>
<text x="32" y="108" font-family="'Segoe UI', sans-serif" font-size="10" fill="#a1a1aa">Standard AWS tooling — unmodified, no shims, no custom SDK</text>
<g transform="translate(32, 120)" font-family="'SF Mono', ui-monospace, monospace" font-size="10">
<rect x="0" y="0" width="86" height="22" rx="4" fill="#3fb950" fill-opacity="0.12" stroke="#3fb950" stroke-opacity="0.5" stroke-width="0.8"/>
<text x="43" y="15" fill="#7ee787" text-anchor="middle">aws-cli</text>
<rect x="94" y="0" width="96" height="22" rx="4" fill="#3fb950" fill-opacity="0.12" stroke="#3fb950" stroke-opacity="0.5" stroke-width="0.8"/>
<text x="142" y="15" fill="#7ee787" text-anchor="middle">Terraform</text>
<rect x="198" y="0" width="76" height="22" rx="4" fill="#3fb950" fill-opacity="0.12" stroke="#3fb950" stroke-opacity="0.5" stroke-width="0.8"/>
<text x="236" y="15" fill="#7ee787" text-anchor="middle">boto3</text>
<rect x="282" y="0" width="98" height="22" rx="4" fill="#3fb950" fill-opacity="0.12" stroke="#3fb950" stroke-opacity="0.5" stroke-width="0.8"/>
<text x="331" y="15" fill="#7ee787" text-anchor="middle">aws-sdk-go</text>
<rect x="388" y="0" width="86" height="22" rx="4" fill="#3fb950" fill-opacity="0.12" stroke="#3fb950" stroke-opacity="0.5" stroke-width="0.8"/>
<text x="431" y="15" fill="#7ee787" text-anchor="middle">kubectl</text>
<rect x="482" y="0" width="86" height="22" rx="4" fill="#3fb950" fill-opacity="0.12" stroke="#3fb950" stroke-opacity="0.5" stroke-width="0.8"/>
<text x="525" y="15" fill="#7ee787" text-anchor="middle">Packer</text>
<rect x="576" y="0" width="120" height="22" rx="4" fill="#3fb950" fill-opacity="0.12" stroke="#3fb950" stroke-opacity="0.5" stroke-width="0.8"/>
<text x="636" y="15" fill="#7ee787" text-anchor="middle">your application</text>
</g>
<!-- ═══ HTTPS + SigV4 connector ═══════════════════════════ -->
<line x1="450" y1="150" x2="450" y2="178" stroke="#DC2626" stroke-width="1.2" stroke-dasharray="3,3" opacity="0.7"/>
<rect x="390" y="154" width="120" height="18" rx="9" fill="#0a0a0a" stroke="#DC2626" stroke-width="0.8" stroke-opacity="0.5"/>
<text x="450" y="166" font-family="'SF Mono', ui-monospace, monospace" font-size="9" fill="#F87171" text-anchor="middle">HTTPS · SigV4 · :9999</text>
<!-- ═══ AWS GATEWAY ════════════════════════════════════ -->
<rect x="16" y="178" width="868" height="68" rx="8" fill="url(#gw-grad)" stroke="#DC2626" stroke-width="1.3" stroke-opacity="0.75"/>
<text x="32" y="200" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#F87171" font-weight="700" letter-spacing="1">AWS GATEWAY</text>
<text x="32" y="215" font-family="'Segoe UI', sans-serif" font-size="10" fill="#a1a1aa">spinifex/services/awsgw · TLS termination · SigV4 verification · IAM policy eval · NATS routing</text>
<g transform="translate(32, 224)" font-family="'SF Mono', ui-monospace, monospace" font-size="9">
<rect x="0" y="0" width="78" height="16" rx="3" fill="#7F1D1D" fill-opacity="0.3" stroke="#DC2626" stroke-opacity="0.5" stroke-width="0.6"/>
<text x="39" y="11" fill="#FCA5A5" text-anchor="middle">TLS 1.3</text>
<rect x="86" y="0" width="78" height="16" rx="3" fill="#7F1D1D" fill-opacity="0.3" stroke="#DC2626" stroke-opacity="0.5" stroke-width="0.6"/>
<text x="125" y="11" fill="#FCA5A5" text-anchor="middle">SigV4 auth</text>
<rect x="172" y="0" width="98" height="16" rx="3" fill="#7F1D1D" fill-opacity="0.3" stroke="#DC2626" stroke-opacity="0.5" stroke-width="0.6"/>
<text x="221" y="11" fill="#FCA5A5" text-anchor="middle">policy eval</text>
<rect x="278" y="0" width="98" height="16" rx="3" fill="#7F1D1D" fill-opacity="0.3" stroke="#DC2626" stroke-opacity="0.5" stroke-width="0.6"/>
<text x="327" y="11" fill="#FCA5A5" text-anchor="middle">req routing</text>
<rect x="384" y="0" width="88" height="16" rx="3" fill="#7F1D1D" fill-opacity="0.3" stroke="#DC2626" stroke-opacity="0.5" stroke-width="0.6"/>
<text x="428" y="11" fill="#FCA5A5" text-anchor="middle">rate-limit</text>
<rect x="480" y="0" width="98" height="16" rx="3" fill="#7F1D1D" fill-opacity="0.3" stroke="#DC2626" stroke-opacity="0.5" stroke-width="0.6"/>
<text x="529" y="11" fill="#FCA5A5" text-anchor="middle">audit log</text>
</g>
<!-- ═══ NATS BUS ═══════════════════════════════════════ -->
<line x1="450" y1="246" x2="450" y2="272" stroke="#DC2626" stroke-width="1.2" stroke-dasharray="3,3" opacity="0.7"/>
<rect x="16" y="272" width="868" height="34" rx="6" fill="url(#nats-grad)" opacity="0.92"/>
<text x="32" y="293" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#f5f5f5" font-weight="700" letter-spacing="1.5">NATS BUS</text>
<text x="140" y="293" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#fee2e2" opacity="0.95">ec2.* ebs.* s3.* vpc.* iam.*</text>
<text x="876" y="293" font-family="'SF Mono', ui-monospace, monospace" font-size="9" fill="#fee2e2" text-anchor="end" opacity="0.9">queue groups · fan-out · request/reply</text>
<!-- ═══ DAEMON FANOUT CONNECTORS ═══════════════════════ -->
<g stroke="#DC2626" stroke-width="0.9" stroke-dasharray="3,3" opacity="0.55" fill="none">
<line x1="100" y1="306" x2="100" y2="328"/>
<line x1="275" y1="306" x2="275" y2="328"/>
<line x1="450" y1="306" x2="450" y2="328"/>
<line x1="625" y1="306" x2="625" y2="328"/>
<line x1="800" y1="306" x2="800" y2="328"/>
</g>
<!-- ═══ SERVICE DAEMONS ROW ═══════════════════════════ -->
<!-- EC2 -->
<g transform="translate(28, 328)">
<rect width="144" height="82" rx="7" fill="#7F1D1D" fill-opacity="0.14" stroke="#DC2626" stroke-width="1.2" stroke-opacity="0.7"/>
<text x="72" y="22" font-family="'SF Mono', ui-monospace, monospace" font-size="13" fill="#F87171" text-anchor="middle" font-weight="700">ec2.* daemon</text>
<line x1="16" y1="30" x2="128" y2="30" stroke="#DC2626" stroke-opacity="0.25" stroke-width="0.5"/>
<text x="72" y="48" font-family="'Segoe UI', sans-serif" font-size="10" fill="#f5f5f5" text-anchor="middle">RunInstances</text>
<text x="72" y="62" font-family="'Segoe UI', sans-serif" font-size="10" fill="#f5f5f5" text-anchor="middle">TerminateInstances</text>
<text x="72" y="76" font-family="'Segoe UI', sans-serif" font-size="9" fill="#a1a1aa" text-anchor="middle">QMP · cloud-init</text>
</g>
<!-- EBS -->
<g transform="translate(204, 328)">
<rect width="144" height="82" rx="7" fill="#7F1D1D" fill-opacity="0.14" stroke="#DC2626" stroke-width="1.2" stroke-opacity="0.7"/>
<text x="72" y="22" font-family="'SF Mono', ui-monospace, monospace" font-size="13" fill="#F87171" text-anchor="middle" font-weight="700">ebs.* daemon</text>
<line x1="16" y1="30" x2="128" y2="30" stroke="#DC2626" stroke-opacity="0.25" stroke-width="0.5"/>
<text x="72" y="48" font-family="'Segoe UI', sans-serif" font-size="10" fill="#f5f5f5" text-anchor="middle">CreateVolume</text>
<text x="72" y="62" font-family="'Segoe UI', sans-serif" font-size="10" fill="#f5f5f5" text-anchor="middle">AttachVolume</text>
<text x="72" y="76" font-family="'Segoe UI', sans-serif" font-size="9" fill="#a1a1aa" text-anchor="middle">snapshots · WAL</text>
</g>
<!-- S3 -->
<g transform="translate(380, 328)">
<rect width="144" height="82" rx="7" fill="#7F1D1D" fill-opacity="0.14" stroke="#DC2626" stroke-width="1.2" stroke-opacity="0.7"/>
<text x="72" y="22" font-family="'SF Mono', ui-monospace, monospace" font-size="13" fill="#F87171" text-anchor="middle" font-weight="700">s3.* daemon</text>
<line x1="16" y1="30" x2="128" y2="30" stroke="#DC2626" stroke-opacity="0.25" stroke-width="0.5"/>
<text x="72" y="48" font-family="'Segoe UI', sans-serif" font-size="10" fill="#f5f5f5" text-anchor="middle">PutObject · GetObject</text>
<text x="72" y="62" font-family="'Segoe UI', sans-serif" font-size="10" fill="#f5f5f5" text-anchor="middle">Multipart · presigned</text>
<text x="72" y="76" font-family="'Segoe UI', sans-serif" font-size="9" fill="#a1a1aa" text-anchor="middle">erasure-coded</text>
</g>
<!-- VPC -->
<g transform="translate(556, 328)">
<rect width="144" height="82" rx="7" fill="#7F1D1D" fill-opacity="0.14" stroke="#DC2626" stroke-width="1.2" stroke-opacity="0.7"/>
<text x="72" y="22" font-family="'SF Mono', ui-monospace, monospace" font-size="13" fill="#F87171" text-anchor="middle" font-weight="700">vpc.* daemon</text>
<line x1="16" y1="30" x2="128" y2="30" stroke="#DC2626" stroke-opacity="0.25" stroke-width="0.5"/>
<text x="72" y="48" font-family="'Segoe UI', sans-serif" font-size="10" fill="#f5f5f5" text-anchor="middle">Subnets · SGs · RTs</text>
<text x="72" y="62" font-family="'Segoe UI', sans-serif" font-size="10" fill="#f5f5f5" text-anchor="middle">ALB · NAT · IGW</text>
<text x="72" y="76" font-family="'Segoe UI', sans-serif" font-size="9" fill="#a1a1aa" text-anchor="middle">OVN · OVS</text>
</g>
<!-- IAM -->
<g transform="translate(732, 328)">
<rect width="144" height="82" rx="7" fill="#7F1D1D" fill-opacity="0.14" stroke="#DC2626" stroke-width="1.2" stroke-opacity="0.7"/>
<text x="72" y="22" font-family="'SF Mono', ui-monospace, monospace" font-size="13" fill="#F87171" text-anchor="middle" font-weight="700">iam.* daemon</text>
<line x1="16" y1="30" x2="128" y2="30" stroke="#DC2626" stroke-opacity="0.25" stroke-width="0.5"/>
<text x="72" y="48" font-family="'Segoe UI', sans-serif" font-size="10" fill="#f5f5f5" text-anchor="middle">Users · Roles · Keys</text>
<text x="72" y="62" font-family="'Segoe UI', sans-serif" font-size="10" fill="#f5f5f5" text-anchor="middle">Policy evaluation</text>
<text x="72" y="76" font-family="'Segoe UI', sans-serif" font-size="9" fill="#a1a1aa" text-anchor="middle">audit trail</text>
</g>
<!-- ═══ DAEMON → BACKEND CONNECTORS ═══════════════════ -->
<g stroke="#71717a" stroke-width="0.8" stroke-dasharray="2,3" opacity="0.55" fill="none">
<line x1="100" y1="410" x2="100" y2="440"/>
<line x1="276" y1="410" x2="276" y2="440"/>
<line x1="452" y1="410" x2="452" y2="440"/>
<line x1="628" y1="410" x2="628" y2="440"/>
<line x1="804" y1="410" x2="804" y2="440"/>
</g>
<!-- ═══ BACKEND RESOURCES BAR ═════════════════════════ -->
<rect x="16" y="440" width="868" height="26" rx="5" fill="url(#bar-grad)"/>
<text x="450" y="458" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#a1a1aa" text-anchor="middle" letter-spacing="1.5">BACKEND RESOURCES · local hardware · no cloud dependency</text>
<!-- ═══ BACKEND ROW ═══════════════════════════════════ -->
<!-- QEMU/KVM -->
<g transform="translate(28, 474)">
<rect width="144" height="62" rx="7" fill="#27272a" fill-opacity="0.7" stroke="#71717a" stroke-width="1" stroke-opacity="0.6"/>
<text x="72" y="22" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#e4e4e7" text-anchor="middle" font-weight="600">QEMU / KVM</text>
<text x="72" y="40" font-family="'Segoe UI', sans-serif" font-size="10" fill="#a1a1aa" text-anchor="middle">hypervisor</text>
<text x="72" y="54" font-family="'Segoe UI', sans-serif" font-size="9" fill="#71717a" text-anchor="middle">host kernel · virtio</text>
</g>
<!-- Viperblock -->
<g transform="translate(204, 474)">
<rect width="144" height="62" rx="7" fill="#27272a" fill-opacity="0.7" stroke="#71717a" stroke-width="1" stroke-opacity="0.6"/>
<text x="72" y="22" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#e4e4e7" text-anchor="middle" font-weight="600">Viperblock</text>
<text x="72" y="40" font-family="'Segoe UI', sans-serif" font-size="10" fill="#a1a1aa" text-anchor="middle">block storage</text>
<text x="72" y="54" font-family="'Segoe UI', sans-serif" font-size="9" fill="#71717a" text-anchor="middle">NVMe WAL · NBD</text>
</g>
<!-- Predastore -->
<g transform="translate(380, 474)">
<rect width="144" height="62" rx="7" fill="#27272a" fill-opacity="0.7" stroke="#71717a" stroke-width="1" stroke-opacity="0.6"/>
<text x="72" y="22" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#e4e4e7" text-anchor="middle" font-weight="600">Predastore</text>
<text x="72" y="40" font-family="'Segoe UI', sans-serif" font-size="10" fill="#a1a1aa" text-anchor="middle">object storage</text>
<text x="72" y="54" font-family="'Segoe UI', sans-serif" font-size="9" fill="#71717a" text-anchor="middle">Reed–Solomon</text>
</g>
<!-- OVN/OVS -->
<g transform="translate(556, 474)">
<rect width="144" height="62" rx="7" fill="#27272a" fill-opacity="0.7" stroke="#71717a" stroke-width="1" stroke-opacity="0.6"/>
<text x="72" y="22" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#e4e4e7" text-anchor="middle" font-weight="600">OVN / OVS</text>
<text x="72" y="40" font-family="'Segoe UI', sans-serif" font-size="10" fill="#a1a1aa" text-anchor="middle">SDN fabric</text>
<text x="72" y="54" font-family="'Segoe UI', sans-serif" font-size="9" fill="#71717a" text-anchor="middle">overlays · flow rules</text>
</g>
<!-- IAM store -->
<g transform="translate(732, 474)">
<rect width="144" height="62" rx="7" fill="#27272a" fill-opacity="0.7" stroke="#71717a" stroke-width="1" stroke-opacity="0.6"/>
<text x="72" y="22" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#e4e4e7" text-anchor="middle" font-weight="600">Identity store</text>
<text x="72" y="40" font-family="'Segoe UI', sans-serif" font-size="10" fill="#a1a1aa" text-anchor="middle">users · keys · rules</text>
<text x="72" y="54" font-family="'Segoe UI', sans-serif" font-size="9" fill="#71717a" text-anchor="middle">local · audit-logged</text>
</g>
<!-- ═══ FOOTER NOTES ══════════════════════════════════ -->
<g transform="translate(16, 558)">
<rect width="868" height="48" rx="6" fill="#0a0a0a" stroke="#27272a" stroke-width="0.8"/>
<text x="24" y="20" font-family="'Segoe UI', sans-serif" font-size="10" fill="#a1a1aa">
<tspan fill="#F87171" font-weight="600">Queue groups</tspan> load-balance ops across daemon replicas.
<tspan fill="#F87171" font-weight="600"> Fan-out</tspan> (no queue group) dispatches describe calls to every node.
</text>
<text x="24" y="38" font-family="'Segoe UI', sans-serif" font-size="10" fill="#a1a1aa">
Daemons are <tspan fill="#F87171" font-weight="600">stateless</tspan> — scale horizontally by starting more.
No etcd, no Kubernetes, no external control plane. Systemd units, a NATS cluster, and your hardware.
</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 17 KiB

+154
View File
@@ -0,0 +1,154 @@
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="300" viewBox="0 0 900 300">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#0a0a0a;stop-opacity:1" />
<stop offset="50%" style="stop-color:#0d1117;stop-opacity:1" />
<stop offset="100%" style="stop-color:#0a0a0a;stop-opacity:1" />
</linearGradient>
<radialGradient id="vignette" cx="50%" cy="50%" r="70%">
<stop offset="0%" style="stop-color:#7F1D1D;stop-opacity:0.08"/>
<stop offset="100%" style="stop-color:#7F1D1D;stop-opacity:0"/>
</radialGradient>
<linearGradient id="mulga-grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#DC2626;stop-opacity:0.95" />
<stop offset="100%" style="stop-color:#7F1D1D;stop-opacity:0.95" />
</linearGradient>
<linearGradient id="rule" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#DC2626;stop-opacity:0" />
<stop offset="50%" style="stop-color:#F87171;stop-opacity:0.45" />
<stop offset="100%" style="stop-color:#7F1D1D;stop-opacity:0" />
</linearGradient>
<filter id="node-glow">
<feGaussianBlur in="SourceGraphic" stdDeviation="3" />
</filter>
</defs>
<!-- Background -->
<rect width="900" height="300" fill="url(#bg)" rx="12"/>
<rect width="900" height="300" fill="url(#vignette)" rx="12"/>
<!-- Mesh network background (mulgadc style) -->
<g opacity="0.35">
<!-- Connections (drawn first, under nodes) -->
<g stroke="#7F1D1D" stroke-width="0.6" fill="none">
<line x1="80" y1="60" x2="200" y2="40"/>
<line x1="80" y1="60" x2="150" y2="140"/>
<line x1="200" y1="40" x2="330" y2="90"/>
<line x1="150" y1="140" x2="260" y2="180"/>
<line x1="150" y1="140" x2="60" y2="220"/>
<line x1="330" y1="90" x2="260" y2="180"/>
<line x1="260" y1="180" x2="120" y2="260"/>
<line x1="60" y1="220" x2="120" y2="260"/>
<line x1="620" y1="50" x2="760" y2="90"/>
<line x1="620" y1="50" x2="700" y2="160"/>
<line x1="760" y1="90" x2="850" y2="160"/>
<line x1="700" y1="160" x2="820" y2="220"/>
<line x1="850" y1="160" x2="820" y2="220"/>
<line x1="700" y1="160" x2="600" y2="240"/>
<line x1="600" y1="240" x2="820" y2="220"/>
<line x1="760" y1="90" x2="700" y2="160"/>
</g>
<!-- Nodes -->
<g fill="#DC2626">
<circle cx="80" cy="60" r="2.5"/>
<circle cx="200" cy="40" r="2"/>
<circle cx="330" cy="90" r="2.5"/>
<circle cx="150" cy="140" r="3"/>
<circle cx="260" cy="180" r="2"/>
<circle cx="60" cy="220" r="2"/>
<circle cx="120" cy="260" r="2.5"/>
<circle cx="620" cy="50" r="2"/>
<circle cx="760" cy="90" r="3"/>
<circle cx="700" cy="160" r="2.5"/>
<circle cx="850" cy="160" r="2"/>
<circle cx="820" cy="220" r="2.5"/>
<circle cx="600" cy="240" r="2"/>
</g>
<!-- Pulse rings (static "about-to-pulse" look) -->
<g fill="none" stroke="#F87171" stroke-width="0.8" opacity="0.5">
<circle cx="150" cy="140" r="6"/>
<circle cx="760" cy="90" r="6"/>
<circle cx="700" cy="160" r="8"/>
</g>
</g>
<!-- Main emblem: cluster-node stack with NATS bus -->
<g transform="translate(200, 150)">
<!-- Outer glow ring -->
<circle cx="0" cy="0" r="64" fill="none" stroke="url(#mulga-grad)" stroke-width="1.5" opacity="0.35" filter="url(#node-glow)"/>
<!-- Top node -->
<rect x="-24" y="-52" width="48" height="22" rx="3" fill="none" stroke="#DC2626" stroke-width="1.8"/>
<circle cx="-16" cy="-41" r="1.6" fill="#F87171"/>
<circle cx="-8" cy="-41" r="1.6" fill="#F87171"/>
<line x1="0" y1="-41" x2="16" y2="-41" stroke="#F87171" stroke-width="0.9" opacity="0.6"/>
<!-- Middle node (active) -->
<rect x="-30" y="-14" width="60" height="28" rx="3" fill="url(#mulga-grad)" opacity="0.22" stroke="#DC2626" stroke-width="2"/>
<circle cx="-20" cy="0" r="2" fill="#F87171"/>
<circle cx="-10" cy="0" r="2" fill="#F87171"/>
<line x1="-2" y1="0" x2="22" y2="0" stroke="#F87171" stroke-width="1.2" opacity="0.8"/>
<!-- Bottom node -->
<rect x="-24" y="30" width="48" height="22" rx="3" fill="none" stroke="#7F1D1D" stroke-width="1.8"/>
<circle cx="-16" cy="41" r="1.6" fill="#DC2626"/>
<circle cx="-8" cy="41" r="1.6" fill="#DC2626"/>
<line x1="0" y1="41" x2="16" y2="41" stroke="#DC2626" stroke-width="0.9" opacity="0.6"/>
<!-- Radiating mesh ports -->
<g stroke-width="0.9" opacity="0.55">
<line x1="-30" y1="0" x2="-58" y2="-26" stroke="#DC2626"/>
<line x1="-30" y1="0" x2="-64" y2="0" stroke="#DC2626"/>
<line x1="-30" y1="0" x2="-58" y2="26" stroke="#DC2626"/>
<line x1="30" y1="0" x2="58" y2="-26" stroke="#7F1D1D"/>
<line x1="30" y1="0" x2="64" y2="0" stroke="#7F1D1D"/>
<line x1="30" y1="0" x2="58" y2="26" stroke="#7F1D1D"/>
</g>
<g fill="#F87171">
<circle cx="-58" cy="-26" r="2.5"/>
<circle cx="-64" cy="0" r="2.5"/>
<circle cx="-58" cy="26" r="2.5"/>
</g>
<g fill="#DC2626">
<circle cx="58" cy="-26" r="2.5"/>
<circle cx="64" cy="0" r="2.5"/>
<circle cx="58" cy="26" r="2.5"/>
</g>
</g>
<!-- Title -->
<text x="340" y="120" font-family="'Segoe UI', system-ui, -apple-system, sans-serif" font-size="56" font-weight="700" fill="#f5f5f5" letter-spacing="-1.5">Spinifex</text>
<!-- Subtitle -->
<text x="340" y="155" font-family="'Segoe UI', system-ui, -apple-system, sans-serif" font-size="17" fill="#a1a1aa" letter-spacing="0.3">Open-source AWS-compatible platform</text>
<text x="340" y="180" font-family="'Segoe UI', system-ui, -apple-system, sans-serif" font-size="17" fill="#a1a1aa" letter-spacing="0.3">for secure edge deployments</text>
<!-- Rule -->
<rect x="340" y="200" width="440" height="1" fill="url(#rule)" rx="1"/>
<!-- Service pills -->
<g font-family="'Segoe UI', system-ui, -apple-system, sans-serif" font-size="11" font-weight="500">
<rect x="340" y="215" width="50" height="24" rx="12" fill="#7F1D1D" fill-opacity="0.25" stroke="#DC2626" stroke-width="0.5" stroke-opacity="0.55"/>
<text x="365" y="231" fill="#F87171" text-anchor="middle">EC2</text>
<rect x="398" y="215" width="50" height="24" rx="12" fill="#7F1D1D" fill-opacity="0.25" stroke="#DC2626" stroke-width="0.5" stroke-opacity="0.55"/>
<text x="423" y="231" fill="#F87171" text-anchor="middle">EBS</text>
<rect x="456" y="215" width="45" height="24" rx="12" fill="#7F1D1D" fill-opacity="0.25" stroke="#DC2626" stroke-width="0.5" stroke-opacity="0.55"/>
<text x="478" y="231" fill="#F87171" text-anchor="middle">S3</text>
<rect x="509" y="215" width="55" height="24" rx="12" fill="#7F1D1D" fill-opacity="0.25" stroke="#DC2626" stroke-width="0.5" stroke-opacity="0.55"/>
<text x="536" y="231" fill="#F87171" text-anchor="middle">VPC</text>
<rect x="572" y="215" width="50" height="24" rx="12" fill="#7F1D1D" fill-opacity="0.25" stroke="#DC2626" stroke-width="0.5" stroke-opacity="0.55"/>
<text x="597" y="231" fill="#F87171" text-anchor="middle">IAM</text>
<rect x="630" y="215" width="78" height="24" rx="12" fill="#18181b" stroke="#52525b" stroke-width="0.5" stroke-opacity="0.8"/>
<text x="669" y="231" fill="#a1a1aa" text-anchor="middle">Air-Gapped</text>
</g>
<!-- Tagline -->
<text x="340" y="265" font-family="'Segoe UI', system-ui, -apple-system, sans-serif" font-size="11" fill="#71717a" letter-spacing="1.8">YOUR INFRASTRUCTURE · YOUR HARDWARE · YOUR CLOUD</text>
</svg>

After

Width:  |  Height:  |  Size: 7.5 KiB

+175
View File
@@ -0,0 +1,175 @@
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="720" viewBox="0 0 900 720">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#0a0a0a"/>
<stop offset="100%" style="stop-color:#0d1117"/>
</linearGradient>
<linearGradient id="bar-grad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#161b22"/>
<stop offset="45%" style="stop-color:#1f2028"/>
<stop offset="100%" style="stop-color:#161b22"/>
</linearGradient>
<linearGradient id="client-grad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#3fb95022"/>
<stop offset="100%" style="stop-color:#3fb95008"/>
</linearGradient>
<linearGradient id="gw-grad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#DC262633"/>
<stop offset="100%" style="stop-color:#7F1D1D14"/>
</linearGradient>
<linearGradient id="nats-grad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#F87171"/>
<stop offset="50%" style="stop-color:#DC2626"/>
<stop offset="100%" style="stop-color:#7F1D1D"/>
</linearGradient>
<linearGradient id="daemon-grad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#DC262622"/>
<stop offset="100%" style="stop-color:#7F1D1D0a"/>
</linearGradient>
</defs>
<rect width="900" height="720" fill="url(#bg)" rx="12"/>
<!-- Title -->
<text x="24" y="30" font-family="'Segoe UI', system-ui, sans-serif" font-size="11" fill="#a1a1aa" letter-spacing="2.5" font-weight="600">HIGH-LEVEL ARCHITECTURE</text>
<text x="876" y="30" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#71717a" text-anchor="end">SDK → Gateway → NATS → Daemons → VMs</text>
<!-- ═══ AWS SDK / CLI ══════════════════════════════════ -->
<rect x="16" y="50" width="868" height="76" rx="8" fill="url(#client-grad)" stroke="#3fb950" stroke-width="1" stroke-opacity="0.5"/>
<text x="32" y="72" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#3fb950" font-weight="700" letter-spacing="1">AWS SDK / CLI</text>
<text x="32" y="90" font-family="'Segoe UI', sans-serif" font-size="10" fill="#a1a1aa">Users interact with Spinifex using standard AWS SDKs and CLIs against a custom endpoint URL</text>
<g font-family="'SF Mono', ui-monospace, monospace" font-size="10.5">
<rect x="32" y="98" width="480" height="20" rx="4" fill="#0a0a0a" stroke="#3fb950" stroke-width="0.6" stroke-opacity="0.4"/>
<text x="42" y="112" fill="#7ee787">$ aws ec2 run-instances --endpoint-url https://localhost:9999 ...</text>
</g>
<!-- Connector: HTTPS + SigV4 -->
<line x1="450" y1="126" x2="450" y2="154" stroke="#DC2626" stroke-width="1.2" stroke-dasharray="3,3" opacity="0.7"/>
<rect x="380" y="128" width="140" height="20" rx="10" fill="#0a0a0a" stroke="#DC2626" stroke-width="0.8" stroke-opacity="0.5"/>
<text x="450" y="142" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#F87171" text-anchor="middle">HTTPS · SigV4 Auth</text>
<!-- ═══ AWS GATEWAY ════════════════════════════════════ -->
<rect x="16" y="154" width="868" height="104" rx="8" fill="url(#gw-grad)" stroke="#DC2626" stroke-width="1.3" stroke-opacity="0.75"/>
<text x="32" y="176" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#F87171" font-weight="700" letter-spacing="1">AWS GATEWAY · port 9999</text>
<text x="876" y="176" font-family="'SF Mono', ui-monospace, monospace" font-size="9" fill="#71717a" text-anchor="end">spinifex/services/awsgw/awsgw.go</text>
<!-- Two-column feature bullets -->
<g font-family="'Segoe UI', sans-serif" font-size="10.5" fill="#f5f5f5">
<text x="40" y="200">● TLS termination</text>
<text x="40" y="220">● EC2 Query Protocol parsing</text>
<text x="40" y="240">● XML response generation</text>
<text x="460" y="200">● AWS SigV4 authentication</text>
<text x="460" y="220">● Routes to service handlers</text>
<text x="460" y="240">● NATS client connection</text>
</g>
<!-- Connector: JSON over NATS -->
<line x1="450" y1="258" x2="450" y2="286" stroke="#DC2626" stroke-width="1.2" stroke-dasharray="3,3" opacity="0.7"/>
<rect x="380" y="260" width="140" height="20" rx="10" fill="#0a0a0a" stroke="#DC2626" stroke-width="0.8" stroke-opacity="0.5"/>
<text x="450" y="274" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#F87171" text-anchor="middle">JSON over NATS</text>
<!-- ═══ NATS BROKER ═══════════════════════════════════ -->
<rect x="16" y="286" width="868" height="124" rx="8" fill="#1a0a0a" stroke="#DC2626" stroke-width="1.2" stroke-opacity="0.65"/>
<rect x="16" y="286" width="868" height="28" rx="8" fill="url(#nats-grad)" opacity="0.9"/>
<text x="32" y="305" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#f5f5f5" font-weight="700" letter-spacing="1.5">NATS MESSAGE BROKER</text>
<text x="876" y="305" font-family="'SF Mono', ui-monospace, monospace" font-size="9" fill="#fee2e2" text-anchor="end" opacity="0.9">request/reply · queue groups · fan-out</text>
<!-- Two-column topics/queue groups -->
<g font-family="'SF Mono', ui-monospace, monospace" font-size="10">
<text x="40" y="332" fill="#F87171" font-weight="600">Topics</text>
<text x="40" y="350" fill="#f5f5f5">ec2.RunInstances</text>
<text x="40" y="368" fill="#f5f5f5">ec2.DescribeInstances</text>
<text x="40" y="386" fill="#f5f5f5">ec2.cmd.{instanceID}</text>
<text x="40" y="404" fill="#f5f5f5">ebs.mount · ebs.unmount</text>
<text x="460" y="332" fill="#F87171" font-weight="600">Delivery mode</text>
<text x="460" y="350" fill="#a1a1aa">spinifex-workers <tspan fill="#71717a">(load-balanced)</tspan></text>
<text x="460" y="368" fill="#a1a1aa">no queue group <tspan fill="#71717a">(fan-out to all nodes)</tspan></text>
<text x="460" y="386" fill="#a1a1aa">per-instance <tspan fill="#71717a">(start / stop / terminate)</tspan></text>
<text x="460" y="404" fill="#a1a1aa">Viperblock request/reply</text>
</g>
<!-- Fan-out connectors to 3 daemons -->
<g stroke="#DC2626" stroke-width="1" stroke-dasharray="3,3" opacity="0.6" fill="none">
<line x1="450" y1="410" x2="450" y2="430"/>
<line x1="450" y1="430" x2="155" y2="452"/>
<line x1="450" y1="430" x2="450" y2="452"/>
<line x1="450" y1="430" x2="745" y2="452"/>
</g>
<!-- ═══ DAEMON ROW ════════════════════════════════════ -->
<!-- Daemon 1 -->
<g transform="translate(32, 452)">
<rect width="246" height="124" rx="8" fill="url(#daemon-grad)" stroke="#DC2626" stroke-width="1.2" stroke-opacity="0.7"/>
<text x="20" y="24" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#F87171" font-weight="700">Spinifex Daemon</text>
<text x="226" y="24" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#a1a1aa" text-anchor="end">Node 1</text>
<line x1="16" y1="32" x2="230" y2="32" stroke="#DC2626" stroke-opacity="0.25" stroke-width="0.5"/>
<g font-family="'Segoe UI', sans-serif" font-size="10.5" fill="#f5f5f5">
<text x="20" y="52">● NATS subscriber</text>
<text x="20" y="70">● Resource manager</text>
<text x="20" y="88">● QEMU/KVM launcher</text>
<text x="20" y="106">● QMP monitoring</text>
</g>
<text x="20" y="119" font-family="'SF Mono', ui-monospace, monospace" font-size="9" fill="#71717a">daemon.go</text>
</g>
<!-- Daemon 2 -->
<g transform="translate(327, 452)">
<rect width="246" height="124" rx="8" fill="url(#daemon-grad)" stroke="#DC2626" stroke-width="1.2" stroke-opacity="0.7"/>
<text x="20" y="24" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#F87171" font-weight="700">Spinifex Daemon</text>
<text x="226" y="24" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#a1a1aa" text-anchor="end">Node 2</text>
<line x1="16" y1="32" x2="230" y2="32" stroke="#DC2626" stroke-opacity="0.25" stroke-width="0.5"/>
<g font-family="'Segoe UI', sans-serif" font-size="10.5" fill="#f5f5f5">
<text x="20" y="52">● NATS subscriber</text>
<text x="20" y="70">● Resource manager</text>
<text x="20" y="88">● QEMU/KVM launcher</text>
<text x="20" y="106">● QMP monitoring</text>
</g>
<text x="20" y="119" font-family="'SF Mono', ui-monospace, monospace" font-size="9" fill="#71717a">daemon.go</text>
</g>
<!-- Daemon N -->
<g transform="translate(622, 452)">
<rect width="246" height="124" rx="8" fill="url(#daemon-grad)" stroke="#DC2626" stroke-width="1.2" stroke-opacity="0.7"/>
<text x="20" y="24" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#F87171" font-weight="700">Spinifex Daemon</text>
<text x="226" y="24" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#a1a1aa" text-anchor="end">Node N</text>
<line x1="16" y1="32" x2="230" y2="32" stroke="#DC2626" stroke-opacity="0.25" stroke-width="0.5"/>
<g font-family="'Segoe UI', sans-serif" font-size="10.5" fill="#f5f5f5">
<text x="20" y="52">● NATS subscriber</text>
<text x="20" y="70">● Resource manager</text>
<text x="20" y="88">● QEMU/KVM launcher</text>
<text x="20" y="106">● QMP monitoring</text>
</g>
<text x="20" y="119" font-family="'SF Mono', ui-monospace, monospace" font-size="9" fill="#71717a">daemon.go</text>
</g>
<!-- Daemon → VM connectors -->
<g stroke="#71717a" stroke-width="0.8" stroke-dasharray="2,3" opacity="0.55" fill="none">
<line x1="155" y1="576" x2="155" y2="610"/>
<line x1="450" y1="576" x2="450" y2="610"/>
<line x1="745" y1="576" x2="745" y2="610"/>
</g>
<!-- ═══ VM BAR + ROW ═══════════════════════════════════ -->
<rect x="16" y="610" width="868" height="22" rx="5" fill="url(#bar-grad)"/>
<text x="450" y="625" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#a1a1aa" text-anchor="middle" letter-spacing="1.5">WORKLOADS · QEMU/KVM virtual machines per node</text>
<!-- VM boxes -->
<g font-family="'SF Mono', ui-monospace, monospace" font-size="11">
<rect x="32" y="642" width="246" height="60" rx="7" fill="#27272a" fill-opacity="0.7" stroke="#71717a" stroke-width="1" stroke-opacity="0.6"/>
<text x="155" y="665" fill="#e4e4e7" text-anchor="middle" font-weight="600">QEMU / KVM VMs</text>
<text x="155" y="683" fill="#a1a1aa" text-anchor="middle" font-size="9.5">virtio-blk · virtio-net · cloud-init</text>
<text x="155" y="695" fill="#71717a" text-anchor="middle" font-size="9">Node 1 workloads</text>
<rect x="327" y="642" width="246" height="60" rx="7" fill="#27272a" fill-opacity="0.7" stroke="#71717a" stroke-width="1" stroke-opacity="0.6"/>
<text x="450" y="665" fill="#e4e4e7" text-anchor="middle" font-weight="600">QEMU / KVM VMs</text>
<text x="450" y="683" fill="#a1a1aa" text-anchor="middle" font-size="9.5">virtio-blk · virtio-net · cloud-init</text>
<text x="450" y="695" fill="#71717a" text-anchor="middle" font-size="9">Node 2 workloads</text>
<rect x="622" y="642" width="246" height="60" rx="7" fill="#27272a" fill-opacity="0.7" stroke="#71717a" stroke-width="1" stroke-opacity="0.6"/>
<text x="745" y="665" fill="#e4e4e7" text-anchor="middle" font-weight="600">QEMU / KVM VMs</text>
<text x="745" y="683" fill="#a1a1aa" text-anchor="middle" font-size="9.5">virtio-blk · virtio-net · cloud-init</text>
<text x="745" y="695" fill="#71717a" text-anchor="middle" font-size="9">Node N workloads</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 12 KiB

+46
View File
@@ -0,0 +1,46 @@
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="180" viewBox="0 0 900 180">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#0a0a0a"/>
<stop offset="100%" style="stop-color:#0d1117"/>
</linearGradient>
</defs>
<rect width="900" height="180" fill="url(#bg)" rx="10"/>
<text x="24" y="28" font-family="'Segoe UI', system-ui, sans-serif" font-size="11" fill="#a1a1aa" letter-spacing="2.5" font-weight="600">BASTION · only entry point into a private subnet</text>
<!-- WAN -->
<g transform="translate(40, 56)">
<rect width="200" height="60" rx="8" fill="#3fb9501a" stroke="#3fb950" stroke-width="1.2" stroke-opacity="0.55"/>
<text x="100" y="28" font-family="'SF Mono', ui-monospace, monospace" font-size="13" fill="#3fb950" text-anchor="middle" font-weight="700">WAN</text>
<text x="100" y="46" font-family="'Segoe UI', sans-serif" font-size="10" fill="#a1a1aa" text-anchor="middle">operator / laptop</text>
</g>
<!-- Arrow: SSH -->
<line x1="240" y1="86" x2="320" y2="86" stroke="#58a6ff" stroke-width="1.4" stroke-dasharray="4,3" opacity="0.75"/>
<polygon points="316,82 328,86 316,90" fill="#58a6ff" opacity="0.8"/>
<rect x="244" y="62" width="80" height="18" rx="9" fill="#0a0a0a" stroke="#58a6ff" stroke-width="0.6" stroke-opacity="0.55"/>
<text x="284" y="75" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#8ab4f8" text-anchor="middle">SSH</text>
<!-- Bastion -->
<g transform="translate(338, 56)">
<rect width="220" height="60" rx="8" fill="#3fb9501a" stroke="#3fb950" stroke-width="1.3" stroke-opacity="0.6"/>
<text x="110" y="28" font-family="'SF Mono', ui-monospace, monospace" font-size="13" fill="#3fb950" text-anchor="middle" font-weight="700">Bastion</text>
<text x="110" y="46" font-family="'Segoe UI', sans-serif" font-size="10" fill="#7ee787" text-anchor="middle">public subnet</text>
</g>
<!-- Arrow: SSH (private IP) -->
<line x1="558" y1="86" x2="638" y2="86" stroke="#a855f7" stroke-width="1.4" stroke-dasharray="4,3" opacity="0.75"/>
<polygon points="634,82 646,86 634,90" fill="#a855f7" opacity="0.8"/>
<rect x="552" y="62" width="140" height="18" rx="9" fill="#0a0a0a" stroke="#a855f7" stroke-width="0.6" stroke-opacity="0.55"/>
<text x="622" y="75" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#c084fc" text-anchor="middle">SSH (private IP)</text>
<!-- App server -->
<g transform="translate(656, 56)">
<rect width="220" height="60" rx="8" fill="#a855f71a" stroke="#a855f7" stroke-width="1.3" stroke-opacity="0.6"/>
<text x="110" y="28" font-family="'SF Mono', ui-monospace, monospace" font-size="13" fill="#c084fc" text-anchor="middle" font-weight="700">App Server</text>
<text x="110" y="46" font-family="'Segoe UI', sans-serif" font-size="10" fill="#d8b4fe" text-anchor="middle">private subnet · no internet</text>
</g>
<text x="450" y="156" font-family="'Segoe UI', sans-serif" font-size="10" fill="#71717a" text-anchor="middle" font-style="italic">App server has no route to the internet — all access hops through the bastion.</text>
</svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

+98
View File
@@ -0,0 +1,98 @@
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="480" viewBox="0 0 900 480">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#0a0a0a"/>
<stop offset="100%" style="stop-color:#0d1117"/>
</linearGradient>
<linearGradient id="inet-grad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#3fb95022"/>
<stop offset="100%" style="stop-color:#3fb95008"/>
</linearGradient>
<linearGradient id="pub-grad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#3fb9501f"/>
<stop offset="100%" style="stop-color:#3fb95008"/>
</linearGradient>
<linearGradient id="priv-grad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#a855f71f"/>
<stop offset="100%" style="stop-color:#a855f708"/>
</linearGradient>
</defs>
<rect width="900" height="480" fill="url(#bg)" rx="12"/>
<text x="24" y="30" font-family="'Segoe UI', system-ui, sans-serif" font-size="11" fill="#a1a1aa" letter-spacing="2.5" font-weight="600">NGINX + ALB · VPC WITH PUBLIC AND PRIVATE SUBNETS</text>
<text x="876" y="30" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#71717a" text-anchor="end">10.20.0.0/16 · multi-AZ</text>
<!-- Internet -->
<rect x="350" y="54" width="200" height="40" rx="8" fill="url(#inet-grad)" stroke="#3fb950" stroke-width="1.1" stroke-opacity="0.55"/>
<text x="450" y="78" font-family="'SF Mono', ui-monospace, monospace" font-size="13" fill="#3fb950" text-anchor="middle" font-weight="700">Internet</text>
<!-- connector to IGW -->
<line x1="450" y1="94" x2="450" y2="116" stroke="#71717a" stroke-width="1" opacity="0.5"/>
<polygon points="446,112 454,112 450,120" fill="#71717a" opacity="0.7"/>
<!-- IGW -->
<rect x="380" y="122" width="140" height="44" rx="7" fill="#DC26261a" stroke="#DC2626" stroke-width="1.2" stroke-opacity="0.65"/>
<text x="450" y="142" font-family="'SF Mono', ui-monospace, monospace" font-size="13" fill="#F87171" text-anchor="middle" font-weight="700">IGW</text>
<text x="450" y="158" font-family="'Segoe UI', sans-serif" font-size="10" fill="#FCA5A5" text-anchor="middle">nginx-alb-igw</text>
<!-- Fan-out to two public subnets -->
<line x1="450" y1="166" x2="450" y2="184" stroke="#DC2626" stroke-width="1" stroke-dasharray="3,3" opacity="0.6"/>
<line x1="230" y1="184" x2="670" y2="184" stroke="#DC2626" stroke-width="1" stroke-dasharray="3,3" opacity="0.6"/>
<line x1="230" y1="184" x2="230" y2="202" stroke="#DC2626" stroke-width="1" stroke-dasharray="3,3" opacity="0.6"/>
<line x1="670" y1="184" x2="670" y2="202" stroke="#DC2626" stroke-width="1" stroke-dasharray="3,3" opacity="0.6"/>
<!-- public_a -->
<g transform="translate(108, 202)">
<rect width="244" height="104" rx="8" fill="url(#pub-grad)" stroke="#3fb950" stroke-width="1.2" stroke-opacity="0.55"/>
<text x="20" y="24" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#3fb950" font-weight="700">public_a</text>
<text x="224" y="24" font-family="'Segoe UI', sans-serif" font-size="9.5" fill="#7ee787" text-anchor="end">az-a</text>
<line x1="16" y1="32" x2="228" y2="32" stroke="#3fb950" stroke-opacity="0.3" stroke-width="0.5"/>
<rect x="20" y="42" width="200" height="22" rx="4" fill="#3fb9501a" stroke="#3fb950" stroke-opacity="0.5" stroke-width="0.6"/>
<text x="120" y="57" font-family="'SF Mono', ui-monospace, monospace" font-size="10.5" fill="#7ee787" text-anchor="middle" font-weight="600">ALB ENI</text>
<rect x="20" y="70" width="200" height="22" rx="4" fill="#3fb9501a" stroke="#3fb950" stroke-opacity="0.5" stroke-width="0.6"/>
<text x="120" y="85" font-family="'SF Mono', ui-monospace, monospace" font-size="10.5" fill="#7ee787" text-anchor="middle" font-weight="600">NAT GW</text>
</g>
<!-- public_b -->
<g transform="translate(548, 202)">
<rect width="244" height="104" rx="8" fill="url(#pub-grad)" stroke="#3fb950" stroke-width="1.2" stroke-opacity="0.55"/>
<text x="20" y="24" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#3fb950" font-weight="700">public_b</text>
<text x="224" y="24" font-family="'Segoe UI', sans-serif" font-size="9.5" fill="#7ee787" text-anchor="end">az-b</text>
<line x1="16" y1="32" x2="228" y2="32" stroke="#3fb950" stroke-opacity="0.3" stroke-width="0.5"/>
<rect x="20" y="56" width="200" height="22" rx="4" fill="#3fb9501a" stroke="#3fb950" stroke-opacity="0.5" stroke-width="0.6"/>
<text x="120" y="71" font-family="'SF Mono', ui-monospace, monospace" font-size="10.5" fill="#7ee787" text-anchor="middle" font-weight="600">ALB ENI</text>
</g>
<!-- SNAT arrow from public_a NAT → private row -->
<line x1="230" y1="306" x2="230" y2="340" stroke="#F87171" stroke-width="1.2" stroke-dasharray="3,3" opacity="0.65"/>
<polygon points="226,336 234,336 230,344" fill="#F87171" opacity="0.8"/>
<rect x="256" y="314" width="240" height="20" rx="10" fill="#0a0a0a" stroke="#F87171" stroke-width="0.6" stroke-opacity="0.55"/>
<text x="376" y="328" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#FCA5A5" text-anchor="middle">SNAT for private subnets (egress)</text>
<!-- private_a -->
<g transform="translate(108, 350)">
<rect width="244" height="104" rx="8" fill="url(#priv-grad)" stroke="#a855f7" stroke-width="1.2" stroke-opacity="0.55"/>
<text x="20" y="24" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#c084fc" font-weight="700">private_a</text>
<text x="224" y="24" font-family="'Segoe UI', sans-serif" font-size="9.5" fill="#d8b4fe" text-anchor="end">az-a</text>
<line x1="16" y1="32" x2="228" y2="32" stroke="#a855f7" stroke-opacity="0.3" stroke-width="0.5"/>
<rect x="20" y="44" width="200" height="48" rx="5" fill="#a855f71a" stroke="#a855f7" stroke-opacity="0.5" stroke-width="0.6"/>
<text x="120" y="64" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#d8b4fe" text-anchor="middle" font-weight="700">nginx_1</text>
<text x="120" y="80" font-family="'Segoe UI', sans-serif" font-size="9.5" fill="#a1a1aa" text-anchor="middle">Debian 12 · cloud-init</text>
</g>
<!-- private_b -->
<g transform="translate(548, 350)">
<rect width="244" height="104" rx="8" fill="url(#priv-grad)" stroke="#a855f7" stroke-width="1.2" stroke-opacity="0.55"/>
<text x="20" y="24" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#c084fc" font-weight="700">private_b</text>
<text x="224" y="24" font-family="'Segoe UI', sans-serif" font-size="9.5" fill="#d8b4fe" text-anchor="end">az-b</text>
<line x1="16" y1="32" x2="228" y2="32" stroke="#a855f7" stroke-opacity="0.3" stroke-width="0.5"/>
<rect x="20" y="44" width="200" height="48" rx="5" fill="#a855f71a" stroke="#a855f7" stroke-opacity="0.5" stroke-width="0.6"/>
<text x="120" y="64" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#d8b4fe" text-anchor="middle" font-weight="700">nginx_2</text>
<text x="120" y="80" font-family="'Segoe UI', sans-serif" font-size="9.5" fill="#a1a1aa" text-anchor="middle">Debian 12 · cloud-init</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 7.1 KiB

+44
View File
@@ -0,0 +1,44 @@
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="180" viewBox="0 0 900 180">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#0a0a0a"/>
<stop offset="100%" style="stop-color:#0d1117"/>
</linearGradient>
</defs>
<rect width="900" height="180" fill="url(#bg)" rx="10"/>
<text x="24" y="28" font-family="'Segoe UI', system-ui, sans-serif" font-size="11" fill="#a1a1aa" letter-spacing="2.5" font-weight="600">S3 WEBAPP · Flask on EC2 with Predastore backend</text>
<!-- Browser -->
<g transform="translate(40, 56)">
<rect width="200" height="60" rx="8" fill="#3fb9501a" stroke="#3fb950" stroke-width="1.2" stroke-opacity="0.55"/>
<text x="100" y="28" font-family="'SF Mono', ui-monospace, monospace" font-size="13" fill="#3fb950" text-anchor="middle" font-weight="700">Browser</text>
<text x="100" y="46" font-family="'Segoe UI', sans-serif" font-size="10" fill="#a1a1aa" text-anchor="middle">end user</text>
</g>
<!-- Arrow: HTTP -->
<line x1="240" y1="86" x2="320" y2="86" stroke="#58a6ff" stroke-width="1.4" stroke-dasharray="4,3" opacity="0.75"/>
<polygon points="316,82 328,86 316,90" fill="#58a6ff" opacity="0.8"/>
<rect x="244" y="62" width="80" height="18" rx="9" fill="#0a0a0a" stroke="#58a6ff" stroke-width="0.6" stroke-opacity="0.55"/>
<text x="284" y="75" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#8ab4f8" text-anchor="middle">HTTP</text>
<!-- EC2 Instance -->
<g transform="translate(338, 56)">
<rect width="240" height="60" rx="8" fill="#DC26261a" stroke="#DC2626" stroke-width="1.3" stroke-opacity="0.65"/>
<text x="120" y="28" font-family="'SF Mono', ui-monospace, monospace" font-size="13" fill="#F87171" text-anchor="middle" font-weight="700">EC2 Instance</text>
<text x="120" y="46" font-family="'Segoe UI', sans-serif" font-size="10" fill="#FCA5A5" text-anchor="middle">Flask webapp · port 80</text>
</g>
<!-- Arrow: S3 API (boto3) -->
<line x1="578" y1="86" x2="658" y2="86" stroke="#a855f7" stroke-width="1.4" stroke-dasharray="4,3" opacity="0.75"/>
<polygon points="654,82 666,86 654,90" fill="#a855f7" opacity="0.8"/>
<rect x="570" y="62" width="144" height="18" rx="9" fill="#0a0a0a" stroke="#a855f7" stroke-width="0.6" stroke-opacity="0.55"/>
<text x="642" y="75" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#c084fc" text-anchor="middle">S3 API (boto3)</text>
<!-- Predastore -->
<g transform="translate(676, 56)">
<rect width="200" height="60" rx="8" fill="#a855f71a" stroke="#a855f7" stroke-width="1.3" stroke-opacity="0.6"/>
<text x="100" y="28" font-family="'SF Mono', ui-monospace, monospace" font-size="13" fill="#c084fc" text-anchor="middle" font-weight="700">Predastore</text>
<text x="100" y="46" font-family="'Segoe UI', sans-serif" font-size="10" fill="#d8b4fe" text-anchor="middle">S3 store · port 8443</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

@@ -0,0 +1,78 @@
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="330" viewBox="0 0 900 330">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#0a0a0a"/>
<stop offset="100%" style="stop-color:#0d1117"/>
</linearGradient>
<linearGradient id="cluster-grad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#3fb95022"/>
<stop offset="100%" style="stop-color:#3fb95008"/>
</linearGradient>
<linearGradient id="pool-grad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#DC262622"/>
<stop offset="100%" style="stop-color:#7F1D1D08"/>
</linearGradient>
<linearGradient id="node-grad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#58a6ff22"/>
<stop offset="100%" style="stop-color:#58a6ff08"/>
</linearGradient>
</defs>
<rect width="900" height="330" fill="url(#bg)" rx="12"/>
<text x="24" y="30" font-family="'Segoe UI', system-ui, sans-serif" font-size="11" fill="#a1a1aa" letter-spacing="2.5" font-weight="600">CONFIGURATION LEVELS · spinifex.toml</text>
<text x="876" y="30" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#71717a" text-anchor="end">three scopes: cluster · pool · node</text>
<!-- File badge -->
<rect x="24" y="50" width="140" height="24" rx="6" fill="#161b22" stroke="#52525b" stroke-width="0.6"/>
<text x="94" y="67" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#e4e4e7" text-anchor="middle">spinifex.toml</text>
<!-- spine -->
<line x1="52" y1="74" x2="52" y2="298" stroke="#52525b" stroke-width="1" opacity="0.6"/>
<!-- ═══ Level 1: [network] cluster-wide ═══════════════════ -->
<line x1="52" y1="108" x2="92" y2="108" stroke="#52525b" stroke-width="1" opacity="0.6"/>
<rect x="92" y="86" width="764" height="56" rx="8" fill="url(#cluster-grad)" stroke="#3fb950" stroke-width="1.2" stroke-opacity="0.55"/>
<text x="108" y="108" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#3fb950" font-weight="700">[network]</text>
<text x="220" y="108" font-family="'Segoe UI', sans-serif" font-size="11" fill="#f5f5f5">Cluster-wide · which mode?</text>
<g font-family="'SF Mono', ui-monospace, monospace" font-size="10">
<text x="108" y="128" fill="#a1a1aa">└─</text>
<text x="128" y="128" fill="#7ee787">external_mode</text>
<text x="224" y="128" fill="#a1a1aa">=</text>
<text x="238" y="128" fill="#f5f5f5">"pool"</text>
</g>
<!-- ═══ Level 2: [[network.external_pools]] ═══════════════ -->
<line x1="52" y1="176" x2="92" y2="176" stroke="#52525b" stroke-width="1" opacity="0.6"/>
<rect x="92" y="154" width="764" height="78" rx="8" fill="url(#pool-grad)" stroke="#DC2626" stroke-width="1.2" stroke-opacity="0.65"/>
<text x="108" y="176" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#F87171" font-weight="700">[[network.external_pools]]</text>
<text x="328" y="176" font-family="'Segoe UI', sans-serif" font-size="11" fill="#f5f5f5">IP ranges · what IPs to use?</text>
<g font-family="'SF Mono', ui-monospace, monospace" font-size="10">
<text x="108" y="196" fill="#a1a1aa">├─</text>
<text x="128" y="196" fill="#FCA5A5">name</text>
<text x="158" y="196" fill="#71717a">·</text>
<text x="166" y="196" fill="#FCA5A5">range_start</text>
<text x="246" y="196" fill="#71717a">·</text>
<text x="254" y="196" fill="#FCA5A5">range_end</text>
<text x="108" y="212" fill="#a1a1aa">├─</text>
<text x="128" y="212" fill="#FCA5A5">gateway</text>
<text x="182" y="212" fill="#71717a">·</text>
<text x="190" y="212" fill="#FCA5A5">prefix_len</text>
<text x="108" y="228" fill="#a1a1aa">└─</text>
<text x="128" y="228" fill="#FCA5A5">region</text>
<text x="174" y="228" fill="#71717a">·</text>
<text x="182" y="228" fill="#FCA5A5">az</text>
<text x="202" y="228" fill="#71717a">(optional)</text>
</g>
<!-- ═══ Level 3: [nodes.NAME.vpcd] ════════════════════════ -->
<line x1="52" y1="268" x2="92" y2="268" stroke="#52525b" stroke-width="1" opacity="0.6"/>
<rect x="92" y="246" width="764" height="56" rx="8" fill="url(#node-grad)" stroke="#58a6ff" stroke-width="1.2" stroke-opacity="0.6"/>
<text x="108" y="268" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#58a6ff" font-weight="700">[nodes.NAME.vpcd]</text>
<text x="266" y="268" font-family="'Segoe UI', sans-serif" font-size="11" fill="#f5f5f5">Per-node · which NIC?</text>
<g font-family="'SF Mono', ui-monospace, monospace" font-size="10">
<text x="108" y="288" fill="#a1a1aa">└─</text>
<text x="128" y="288" fill="#8ab4f8">external_interface</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.8 KiB

@@ -0,0 +1,63 @@
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="110" viewBox="0 0 900 110">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#0a0a0a"/>
<stop offset="100%" style="stop-color:#0d1117"/>
</linearGradient>
</defs>
<rect width="900" height="110" fill="url(#bg)" rx="10"/>
<text x="24" y="22" font-family="'Segoe UI', system-ui, sans-serif" font-size="10" fill="#a1a1aa" letter-spacing="2" font-weight="600">DATA PATH · VM to WAN via OVN localnet</text>
<!-- VM TAP -->
<g transform="translate(24, 40)">
<rect width="118" height="48" rx="7" fill="#a855f71a" stroke="#a855f7" stroke-width="1.1" stroke-opacity="0.6"/>
<text x="59" y="22" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#c084fc" text-anchor="middle" font-weight="700">VM TAP</text>
<text x="59" y="38" font-family="'Segoe UI', sans-serif" font-size="9.5" fill="#a1a1aa" text-anchor="middle">guest interface</text>
</g>
<polygon points="148,60 160,64 148,68" fill="#71717a" opacity="0.7"/>
<line x1="142" y1="64" x2="148" y2="64" stroke="#71717a" stroke-width="1" opacity="0.5"/>
<!-- br-int -->
<g transform="translate(166, 40)">
<rect width="102" height="48" rx="7" fill="#58a6ff1a" stroke="#58a6ff" stroke-width="1.1" stroke-opacity="0.6"/>
<text x="51" y="22" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#58a6ff" text-anchor="middle" font-weight="700">br-int</text>
<text x="51" y="38" font-family="'Segoe UI', sans-serif" font-size="9.5" fill="#a1a1aa" text-anchor="middle">OVS bridge</text>
</g>
<polygon points="274,60 286,64 274,68" fill="#71717a" opacity="0.7"/>
<line x1="268" y1="64" x2="274" y2="64" stroke="#71717a" stroke-width="1" opacity="0.5"/>
<!-- OVN logical pipeline -->
<g transform="translate(292, 40)">
<rect width="192" height="48" rx="7" fill="#DC26261a" stroke="#DC2626" stroke-width="1.1" stroke-opacity="0.65"/>
<text x="96" y="22" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#F87171" text-anchor="middle" font-weight="700">OVN logical pipeline</text>
<text x="96" y="38" font-family="'Segoe UI', sans-serif" font-size="9.5" fill="#FCA5A5" text-anchor="middle">switch → router → SNAT/DNAT</text>
</g>
<polygon points="490,60 502,64 490,68" fill="#71717a" opacity="0.7"/>
<line x1="484" y1="64" x2="490" y2="64" stroke="#71717a" stroke-width="1" opacity="0.5"/>
<!-- localnet -->
<g transform="translate(508, 40)">
<rect width="120" height="48" rx="7" fill="#DC26260d" stroke="#DC2626" stroke-width="1" stroke-opacity="0.5" stroke-dasharray="4,3"/>
<text x="60" y="22" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#F87171" text-anchor="middle" font-weight="700">localnet</text>
<text x="60" y="38" font-family="'Segoe UI', sans-serif" font-size="9.5" fill="#FCA5A5" text-anchor="middle">OVN port type</text>
</g>
<polygon points="634,60 646,64 634,68" fill="#71717a" opacity="0.7"/>
<line x1="628" y1="64" x2="634" y2="64" stroke="#71717a" stroke-width="1" opacity="0.5"/>
<!-- br-wan -->
<g transform="translate(652, 40)">
<rect width="110" height="48" rx="7" fill="#58a6ff1a" stroke="#58a6ff" stroke-width="1.1" stroke-opacity="0.6"/>
<text x="55" y="22" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#58a6ff" text-anchor="middle" font-weight="700">br-wan</text>
<text x="55" y="38" font-family="'Segoe UI', sans-serif" font-size="9.5" fill="#a1a1aa" text-anchor="middle">OVS bridge</text>
</g>
<polygon points="768,60 780,64 768,68" fill="#71717a" opacity="0.7"/>
<line x1="762" y1="64" x2="768" y2="64" stroke="#71717a" stroke-width="1" opacity="0.5"/>
<!-- WAN -->
<g transform="translate(786, 40)">
<rect width="90" height="48" rx="7" fill="#3fb9501a" stroke="#3fb950" stroke-width="1.1" stroke-opacity="0.6"/>
<text x="45" y="22" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#3fb950" text-anchor="middle" font-weight="700">WAN</text>
<text x="45" y="38" font-family="'Segoe UI', sans-serif" font-size="9.5" fill="#a1a1aa" text-anchor="middle">uplink</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.1 KiB

@@ -0,0 +1,74 @@
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="360" viewBox="0 0 900 360">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#0a0a0a"/>
<stop offset="100%" style="stop-color:#0d1117"/>
</linearGradient>
<linearGradient id="host-grad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#58a6ff22"/>
<stop offset="100%" style="stop-color:#58a6ff08"/>
</linearGradient>
<linearGradient id="vm-grad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#3fb95022"/>
<stop offset="100%" style="stop-color:#3fb95008"/>
</linearGradient>
</defs>
<rect width="900" height="360" fill="url(#bg)" rx="12"/>
<text x="24" y="30" font-family="'Segoe UI', system-ui, sans-serif" font-size="11" fill="#a1a1aa" letter-spacing="2.5" font-weight="600">TWO DHCP CONVERSATIONS · completely independent</text>
<text x="876" y="30" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#71717a" text-anchor="end">pool mode · static or dhcp source</text>
<!-- ═══ HOST ↔ ROUTER DHCP ══════════════════════════════ -->
<rect x="24" y="54" width="852" height="138" rx="10" fill="url(#host-grad)" stroke="#58a6ff" stroke-width="1.2" stroke-opacity="0.6"/>
<text x="44" y="78" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#58a6ff" font-weight="700" letter-spacing="0.8">HOST ↔ ROUTER DHCP</text>
<text x="876" y="78" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#8ab4f8" text-anchor="end">on br-ext · veth pair → br-wan</text>
<line x1="24" y1="88" x2="876" y2="88" stroke="#58a6ff" stroke-opacity="0.25" stroke-width="0.5"/>
<g font-family="'SF Mono', ui-monospace, monospace" font-size="11">
<!-- step 1 -->
<circle cx="54" cy="108" r="9" fill="#58a6ff" fill-opacity="0.15" stroke="#58a6ff" stroke-width="1"/>
<text x="54" y="112" fill="#58a6ff" text-anchor="middle" font-weight="700" font-size="10">1</text>
<text x="74" y="112" fill="#f5f5f5">Spinifex asks router:</text>
<text x="228" y="112" fill="#a1a1aa">"give me a public IP for this VM"</text>
<!-- step 2 -->
<circle cx="54" cy="132" r="9" fill="#58a6ff" fill-opacity="0.15" stroke="#58a6ff" stroke-width="1"/>
<text x="54" y="136" fill="#58a6ff" text-anchor="middle" font-weight="700" font-size="10">2</text>
<text x="74" y="136" fill="#f5f5f5">Router responds:</text>
<text x="196" y="136" fill="#8ab4f8" font-weight="600">192.168.1.75</text>
<!-- step 3 -->
<circle cx="54" cy="156" r="9" fill="#58a6ff" fill-opacity="0.15" stroke="#58a6ff" stroke-width="1"/>
<text x="54" y="160" fill="#58a6ff" text-anchor="middle" font-weight="700" font-size="10">3</text>
<text x="74" y="160" fill="#f5f5f5">Spinifex stores IP, tells OVN:</text>
<text x="274" y="160" fill="#F87171" font-weight="600">dnat_and_snat(192.168.1.75 ↔ 172.31.0.4)</text>
</g>
<text x="44" y="181" font-family="'Segoe UI', sans-serif" font-size="10" fill="#71717a" font-style="italic">Only used in DHCP source mode. Static mode picks from range.</text>
<!-- separator -->
<line x1="24" y1="206" x2="876" y2="206" stroke="#27272a" stroke-width="0.8"/>
<text x="450" y="220" font-family="'SF Mono', ui-monospace, monospace" font-size="9" fill="#52525b" text-anchor="middle" letter-spacing="2">INDEPENDENT · no packets cross between</text>
<!-- ═══ VM ↔ OVN DHCP ═══════════════════════════════════ -->
<rect x="24" y="234" width="852" height="110" rx="10" fill="url(#vm-grad)" stroke="#3fb950" stroke-width="1.2" stroke-opacity="0.55"/>
<text x="44" y="258" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#3fb950" font-weight="700" letter-spacing="0.8">VM ↔ OVN DHCP</text>
<text x="876" y="258" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#7ee787" text-anchor="end">on tap → br-int → OVN logical switch</text>
<line x1="24" y1="268" x2="876" y2="268" stroke="#3fb950" stroke-opacity="0.25" stroke-width="0.5"/>
<g font-family="'SF Mono', ui-monospace, monospace" font-size="11">
<circle cx="54" cy="288" r="9" fill="#3fb950" fill-opacity="0.15" stroke="#3fb950" stroke-width="1"/>
<text x="54" y="292" fill="#3fb950" text-anchor="middle" font-weight="700" font-size="10">1</text>
<text x="74" y="292" fill="#f5f5f5">cloud-init asks:</text>
<text x="190" y="292" fill="#a1a1aa">"what's my IP?"</text>
<circle cx="54" cy="312" r="9" fill="#3fb950" fill-opacity="0.15" stroke="#3fb950" stroke-width="1"/>
<text x="54" y="316" fill="#3fb950" text-anchor="middle" font-weight="700" font-size="10">2</text>
<text x="74" y="316" fill="#f5f5f5">OVN DHCP responds:</text>
<text x="226" y="316" fill="#a1a1aa">"you are</text>
<text x="284" y="316" fill="#7ee787" font-weight="600">172.31.0.4</text>
<text x="356" y="316" fill="#a1a1aa">"</text>
</g>
<text x="44" y="336" font-family="'Segoe UI', sans-serif" font-size="10" fill="#71717a" font-style="italic">VM never sees 192.168.1.x or the router.</text>
</svg>

After

Width:  |  Height:  |  Size: 5.2 KiB

@@ -0,0 +1,44 @@
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="170" viewBox="0 0 900 170">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#0a0a0a"/>
<stop offset="100%" style="stop-color:#0d1117"/>
</linearGradient>
</defs>
<rect width="900" height="170" fill="url(#bg)" rx="10"/>
<text x="24" y="24" font-family="'Segoe UI', system-ui, sans-serif" font-size="10" fill="#a1a1aa" letter-spacing="2" font-weight="600">DIRECT BRIDGE MODE · dedicated WAN NIC</text>
<!-- Row 1: Management NIC (independent, no WAN bridge path) -->
<g transform="translate(24, 42)">
<rect width="852" height="44" rx="7" fill="#3fb9501a" stroke="#3fb950" stroke-width="1.1" stroke-opacity="0.55"/>
<text x="20" y="20" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#3fb950" font-weight="700">Management NIC</text>
<text x="20" y="36" font-family="'Segoe UI', sans-serif" font-size="10" fill="#a1a1aa">host IP · SSH · spinifex services · overlay (Geneve)</text>
<text x="840" y="28" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#7ee787" text-anchor="end" font-weight="600">independent path</text>
</g>
<!-- Row 2: WAN NIC → br-wan → localnet -->
<g transform="translate(24, 100)">
<rect width="160" height="44" rx="7" fill="#58a6ff1a" stroke="#58a6ff" stroke-width="1.1" stroke-opacity="0.6"/>
<text x="80" y="20" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#58a6ff" text-anchor="middle" font-weight="700">WAN NIC</text>
<text x="80" y="36" font-family="'Segoe UI', sans-serif" font-size="9.5" fill="#a1a1aa" text-anchor="middle">dedicated physical</text>
</g>
<polygon points="198,118 210,122 198,126" fill="#71717a" opacity="0.7"/>
<line x1="186" y1="122" x2="198" y2="122" stroke="#71717a" stroke-width="1" opacity="0.5"/>
<g transform="translate(214, 100)">
<rect width="220" height="44" rx="7" fill="#58a6ff1a" stroke="#58a6ff" stroke-width="1.1" stroke-opacity="0.6"/>
<text x="110" y="20" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#58a6ff" text-anchor="middle" font-weight="700">br-wan</text>
<text x="110" y="36" font-family="'Segoe UI', sans-serif" font-size="9.5" fill="#a1a1aa" text-anchor="middle">OVS port (NIC added directly)</text>
</g>
<polygon points="448,118 460,122 448,126" fill="#71717a" opacity="0.7"/>
<line x1="434" y1="122" x2="448" y2="122" stroke="#71717a" stroke-width="1" opacity="0.5"/>
<g transform="translate(464, 100)">
<rect width="180" height="44" rx="7" fill="#DC26261a" stroke="#DC2626" stroke-width="1.1" stroke-opacity="0.65"/>
<text x="90" y="20" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#F87171" text-anchor="middle" font-weight="700">OVN localnet</text>
<text x="90" y="36" font-family="'Segoe UI', sans-serif" font-size="9.5" fill="#FCA5A5" text-anchor="middle">external switch port</text>
</g>
<text x="670" y="130" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#F87171" font-weight="600">distributed NAT · any MAC · all frames</text>
</svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

+91
View File
@@ -0,0 +1,91 @@
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="440" viewBox="0 0 900 440">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#0a0a0a"/>
<stop offset="100%" style="stop-color:#0d1117"/>
</linearGradient>
<linearGradient id="host-grad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#27272a88"/>
<stop offset="100%" style="stop-color:#27272a22"/>
</linearGradient>
<linearGradient id="br-int-grad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#a855f71f"/>
<stop offset="100%" style="stop-color:#a855f708"/>
</linearGradient>
<linearGradient id="br-wan-grad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#58a6ff1f"/>
<stop offset="100%" style="stop-color:#58a6ff08"/>
</linearGradient>
</defs>
<rect width="900" height="440" fill="url(#bg)" rx="12"/>
<text x="24" y="30" font-family="'Segoe UI', system-ui, sans-serif" font-size="11" fill="#a1a1aa" letter-spacing="2.5" font-weight="600">NETWORK FLOW · bare-metal host</text>
<text x="876" y="30" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#71717a" text-anchor="end">br-int overlay · br-wan WAN uplink</text>
<!-- ═══ Bare-Metal Host container ═══════════════════════ -->
<rect x="40" y="60" width="820" height="284" rx="12" fill="url(#host-grad)" stroke="#71717a" stroke-width="1.2" stroke-opacity="0.7"/>
<text x="58" y="84" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#e4e4e7" font-weight="700" letter-spacing="1">BARE-METAL HOST</text>
<line x1="40" y1="96" x2="860" y2="96" stroke="#52525b" stroke-opacity="0.5" stroke-width="0.5"/>
<!-- br-int bridge (left) -->
<g transform="translate(80, 122)">
<rect width="260" height="176" rx="10" fill="url(#br-int-grad)" stroke="#a855f7" stroke-width="1.2" stroke-opacity="0.6"/>
<text x="20" y="24" font-family="'SF Mono', ui-monospace, monospace" font-size="13" fill="#c084fc" font-weight="700">br-int</text>
<text x="240" y="24" font-family="'Segoe UI', sans-serif" font-size="10" fill="#d8b4fe" text-anchor="end">(overlay)</text>
<line x1="20" y1="34" x2="240" y2="34" stroke="#a855f7" stroke-opacity="0.3" stroke-width="0.5"/>
<g font-family="'SF Mono', ui-monospace, monospace" font-size="11">
<rect x="30" y="52" width="200" height="28" rx="5" fill="#a855f71a" stroke="#a855f7" stroke-opacity="0.4" stroke-width="0.6"/>
<text x="130" y="70" fill="#d8b4fe" text-anchor="middle">tap-eni1</text>
<rect x="30" y="88" width="200" height="28" rx="5" fill="#a855f71a" stroke="#a855f7" stroke-opacity="0.4" stroke-width="0.6"/>
<text x="130" y="106" fill="#d8b4fe" text-anchor="middle">tap-eni2</text>
<rect x="30" y="124" width="200" height="28" rx="5" fill="#a855f71a" stroke="#a855f7" stroke-opacity="0.4" stroke-width="0.6"/>
<text x="130" y="142" fill="#d8b4fe" text-anchor="middle">tap-eni3</text>
</g>
</g>
<!-- OVN NAT pipeline arrow in middle -->
<g transform="translate(352, 200)">
<line x1="0" y1="10" x2="148" y2="10" stroke="#DC2626" stroke-width="1.4" stroke-dasharray="4,3" opacity="0.75"/>
<polygon points="148,5 160,10 148,15" fill="#DC2626" opacity="0.8"/>
<text x="78" y="0" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#F87171" text-anchor="middle" font-weight="700">OVN</text>
<text x="78" y="28" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#FCA5A5" text-anchor="middle">NAT pipeline</text>
</g>
<!-- br-wan bridge (right) -->
<g transform="translate(520, 122)">
<rect width="260" height="176" rx="10" fill="url(#br-wan-grad)" stroke="#58a6ff" stroke-width="1.2" stroke-opacity="0.6"/>
<text x="20" y="24" font-family="'SF Mono', ui-monospace, monospace" font-size="13" fill="#58a6ff" font-weight="700">br-wan</text>
<text x="240" y="24" font-family="'Segoe UI', sans-serif" font-size="10" fill="#8ab4f8" text-anchor="end">(WAN)</text>
<line x1="20" y1="34" x2="240" y2="34" stroke="#58a6ff" stroke-opacity="0.3" stroke-width="0.5"/>
<g font-family="'SF Mono', ui-monospace, monospace" font-size="11">
<rect x="30" y="52" width="200" height="60" rx="5" fill="#58a6ff1a" stroke="#58a6ff" stroke-opacity="0.45" stroke-width="0.8"/>
<text x="130" y="74" fill="#8ab4f8" text-anchor="middle" font-weight="600">spx-ext-{nic}</text>
<text x="130" y="92" fill="#a1a1aa" text-anchor="middle" font-size="10">(macvlan)</text>
</g>
</g>
<!-- macvlan bridge mode label -->
<text x="650" y="314" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#71717a" text-anchor="middle" font-style="italic">macvlan bridge mode</text>
<!-- WAN NIC inside host, bottom -->
<line x1="650" y1="298" x2="650" y2="320" stroke="#71717a" stroke-width="1" opacity="0.5"/>
<g transform="translate(500, 320)">
<rect width="300" height="20" rx="5" fill="#161b22" stroke="#52525b" stroke-width="0.6"/>
<text x="150" y="14" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#a1a1aa" text-anchor="middle">WAN NIC · host IP unchanged</text>
</g>
<!-- connector out -->
<line x1="650" y1="344" x2="650" y2="380" stroke="#71717a" stroke-width="1" opacity="0.5"/>
<polygon points="646,376 654,376 650,384" fill="#71717a" opacity="0.7"/>
<!-- Physical WAN -->
<g transform="translate(500, 388)">
<rect width="300" height="40" rx="7" fill="#3fb9501a" stroke="#3fb950" stroke-width="1.1" stroke-opacity="0.55"/>
<text x="150" y="25" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#3fb950" text-anchor="middle" font-weight="700">Physical WAN</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.7 KiB

+57
View File
@@ -0,0 +1,57 @@
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="320" viewBox="0 0 900 320">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#0a0a0a"/>
<stop offset="100%" style="stop-color:#0d1117"/>
</linearGradient>
</defs>
<rect width="900" height="320" fill="url(#bg)" rx="12"/>
<text x="24" y="30" font-family="'Segoe UI', system-ui, sans-serif" font-size="11" fill="#a1a1aa" letter-spacing="2.5" font-weight="600">MACVLAN FALLBACK · single-NIC host</text>
<text x="876" y="30" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#71717a" text-anchor="end">WAN NIC == SSH NIC · host IP preserved</text>
<!-- WAN NIC (parent) -->
<g transform="translate(280, 58)">
<rect width="340" height="54" rx="8" fill="#58a6ff1a" stroke="#58a6ff" stroke-width="1.3" stroke-opacity="0.65"/>
<text x="170" y="24" font-family="'SF Mono', ui-monospace, monospace" font-size="13" fill="#58a6ff" text-anchor="middle" font-weight="700">WAN NIC</text>
<text x="170" y="42" font-family="'Segoe UI', sans-serif" font-size="10" fill="#a1a1aa" text-anchor="middle">host IP unchanged · parent interface</text>
</g>
<!-- Split point -->
<line x1="450" y1="112" x2="450" y2="132" stroke="#71717a" stroke-width="1" opacity="0.5"/>
<line x1="260" y1="132" x2="640" y2="132" stroke="#71717a" stroke-width="1" opacity="0.5"/>
<line x1="260" y1="132" x2="260" y2="148" stroke="#71717a" stroke-width="1" opacity="0.5"/>
<line x1="640" y1="132" x2="640" y2="148" stroke="#71717a" stroke-width="1" opacity="0.5"/>
<!-- Child 1: host traffic -->
<g transform="translate(120, 148)">
<rect width="280" height="48" rx="7" fill="#3fb9501a" stroke="#3fb950" stroke-width="1.1" stroke-opacity="0.6"/>
<text x="140" y="22" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#3fb950" text-anchor="middle" font-weight="700">host traffic</text>
<text x="140" y="38" font-family="'Segoe UI', sans-serif" font-size="10" fill="#a1a1aa" text-anchor="middle">SSH · management · overlay</text>
</g>
<!-- Child 2: macvlan -->
<g transform="translate(500, 148)">
<rect width="280" height="48" rx="7" fill="#DC26261a" stroke="#DC2626" stroke-width="1.1" stroke-opacity="0.65"/>
<text x="140" y="22" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#F87171" text-anchor="middle" font-weight="700">spx-ext-{nic}</text>
<text x="140" y="38" font-family="'Segoe UI', sans-serif" font-size="10" fill="#FCA5A5" text-anchor="middle">macvlan · bridge mode · no IP</text>
</g>
<!-- br-wan -->
<line x1="640" y1="196" x2="640" y2="216" stroke="#71717a" stroke-width="1" opacity="0.5"/>
<polygon points="636,212 644,212 640,220" fill="#71717a" opacity="0.7"/>
<g transform="translate(500, 220)">
<rect width="280" height="40" rx="7" fill="#58a6ff1a" stroke="#58a6ff" stroke-width="1.1" stroke-opacity="0.6"/>
<text x="140" y="25" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#58a6ff" text-anchor="middle" font-weight="700">br-wan</text>
</g>
<!-- localnet -->
<line x1="640" y1="260" x2="640" y2="276" stroke="#71717a" stroke-width="1" opacity="0.5"/>
<polygon points="636,272 644,272 640,280" fill="#71717a" opacity="0.7"/>
<g transform="translate(500, 280)">
<rect width="280" height="30" rx="6" fill="#DC26260d" stroke="#DC2626" stroke-width="1" stroke-opacity="0.5" stroke-dasharray="4,3"/>
<text x="140" y="20" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#F87171" text-anchor="middle" font-weight="600">localnet → OVN ext switch</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.6 KiB

@@ -0,0 +1,131 @@
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="560" viewBox="0 0 900 560">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#0a0a0a"/>
<stop offset="100%" style="stop-color:#0d1117"/>
</linearGradient>
<linearGradient id="wan-grad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#3fb95022"/>
<stop offset="100%" style="stop-color:#3fb95008"/>
</linearGradient>
<linearGradient id="ovs-grad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#58a6ff22"/>
<stop offset="100%" style="stop-color:#58a6ff0a"/>
</linearGradient>
<linearGradient id="router-grad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#DC262622"/>
<stop offset="100%" style="stop-color:#7F1D1D0a"/>
</linearGradient>
<linearGradient id="pub-grad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#3fb95022"/>
<stop offset="100%" style="stop-color:#3fb95008"/>
</linearGradient>
<linearGradient id="priv-grad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#a855f722"/>
<stop offset="100%" style="stop-color:#a855f708"/>
</linearGradient>
</defs>
<rect width="900" height="560" fill="url(#bg)" rx="12"/>
<!-- Title -->
<text x="24" y="30" font-family="'Segoe UI', system-ui, sans-serif" font-size="11" fill="#a1a1aa" letter-spacing="2.5" font-weight="600">NETWORK PATH</text>
<text x="876" y="30" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#71717a" text-anchor="end">OVN logical topology · bridge mapping · NAT</text>
<!-- WAN -->
<rect x="300" y="54" width="300" height="40" rx="8" fill="url(#wan-grad)" stroke="#3fb950" stroke-width="1" stroke-opacity="0.55"/>
<text x="450" y="78" font-family="'SF Mono', ui-monospace, monospace" font-size="13" fill="#3fb950" text-anchor="middle" font-weight="700">Physical Network (WAN)</text>
<!-- connector -->
<line x1="450" y1="94" x2="450" y2="112" stroke="#71717a" stroke-width="1" opacity="0.5"/>
<!-- br-wan bridge -->
<rect x="340" y="112" width="220" height="52" rx="8" fill="url(#ovs-grad)" stroke="#58a6ff" stroke-width="1.2" stroke-opacity="0.65"/>
<text x="450" y="134" font-family="'SF Mono', ui-monospace, monospace" font-size="13" fill="#58a6ff" text-anchor="middle" font-weight="700">br-wan</text>
<text x="450" y="152" font-family="'Segoe UI', sans-serif" font-size="10" fill="#a1a1aa" text-anchor="middle">OVS bridge · physical uplink</text>
<!-- connector with bridge-mappings label -->
<line x1="450" y1="164" x2="450" y2="186" stroke="#71717a" stroke-width="1" opacity="0.5"/>
<rect x="316" y="170" width="268" height="18" rx="9" fill="#0a0a0a" stroke="#58a6ff" stroke-width="0.6" stroke-opacity="0.5"/>
<text x="450" y="183" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#8ab4f8" text-anchor="middle">ovn-bridge-mappings: external:br-wan</text>
<!-- VPC Logical Router (the big box) -->
<rect x="60" y="208" width="780" height="196" rx="10" fill="url(#router-grad)" stroke="#DC2626" stroke-width="1.4" stroke-opacity="0.75"/>
<text x="78" y="232" font-family="'SF Mono', ui-monospace, monospace" font-size="13" fill="#F87171" font-weight="700" letter-spacing="1">VPC LOGICAL ROUTER</text>
<line x1="60" y1="242" x2="840" y2="242" stroke="#DC2626" stroke-opacity="0.25" stroke-width="0.5"/>
<!-- Router ports row -->
<g font-family="'SF Mono', ui-monospace, monospace" font-size="11">
<rect x="82" y="254" width="120" height="42" rx="5" fill="#0d1117" stroke="#F87171" stroke-opacity="0.6" stroke-width="0.8"/>
<text x="142" y="270" fill="#F87171" text-anchor="middle" font-weight="600">Gateway port</text>
<text x="142" y="286" fill="#a1a1aa" text-anchor="middle" font-size="9.5">public IP</text>
<text x="215" y="282" fill="#71717a">─────</text>
<rect x="260" y="254" width="120" height="42" rx="5" fill="#0d1117" stroke="#F87171" stroke-opacity="0.6" stroke-width="0.8"/>
<text x="320" y="270" fill="#F87171" text-anchor="middle" font-weight="600">ext switch</text>
<text x="320" y="286" fill="#a1a1aa" text-anchor="middle" font-size="9.5">OVN logical</text>
<text x="393" y="282" fill="#71717a">─────</text>
<rect x="438" y="254" width="120" height="42" rx="5" fill="#0d1117" stroke="#F87171" stroke-opacity="0.6" stroke-width="0.8"/>
<text x="498" y="270" fill="#F87171" text-anchor="middle" font-weight="600">localnet port</text>
<text x="498" y="286" fill="#a1a1aa" text-anchor="middle" font-size="9.5">→ br-wan</text>
</g>
<!-- NAT rules + Routes, two columns on right -->
<g font-family="'SF Mono', ui-monospace, monospace" font-size="10.5">
<text x="598" y="260" fill="#F87171" font-weight="600">NAT rules</text>
<text x="598" y="276" fill="#f5f5f5">SNAT:</text>
<text x="644" y="276" fill="#a1a1aa">VPC CIDR → gw IP</text>
<text x="598" y="292" fill="#f5f5f5">dnat_and_snat:</text>
<text x="704" y="292" fill="#a1a1aa">pub ↔ priv</text>
</g>
<g font-family="'SF Mono', ui-monospace, monospace" font-size="10.5">
<text x="82" y="330" fill="#F87171" font-weight="600">Routes</text>
<text x="82" y="346" fill="#f5f5f5">0.0.0.0/0</text>
<text x="154" y="346" fill="#a1a1aa">→ WAN gateway</text>
</g>
<!-- Subnet branches inside router -->
<line x1="150" y1="362" x2="150" y2="386" stroke="#DC2626" stroke-width="1" stroke-dasharray="3,3" opacity="0.6"/>
<line x1="150" y1="386" x2="305" y2="386" stroke="#DC2626" stroke-width="1" stroke-dasharray="3,3" opacity="0.6"/>
<line x1="305" y1="386" x2="305" y2="404" stroke="#DC2626" stroke-width="1" stroke-dasharray="3,3" opacity="0.6"/>
<line x1="150" y1="386" x2="515" y2="386" stroke="#DC2626" stroke-width="1" stroke-dasharray="3,3" opacity="0.6"/>
<line x1="515" y1="386" x2="515" y2="404" stroke="#DC2626" stroke-width="1" stroke-dasharray="3,3" opacity="0.6"/>
<!-- Subnet A (public) -->
<g transform="translate(215, 404)">
<rect width="180" height="124" rx="8" fill="url(#pub-grad)" stroke="#3fb950" stroke-width="1.2" stroke-opacity="0.6"/>
<text x="90" y="22" font-family="'SF Mono', ui-monospace, monospace" font-size="13" fill="#3fb950" text-anchor="middle" font-weight="700">subnet-a</text>
<text x="90" y="38" font-family="'Segoe UI', sans-serif" font-size="10" fill="#7ee787" text-anchor="middle">(public)</text>
<line x1="16" y1="46" x2="164" y2="46" stroke="#3fb950" stroke-opacity="0.3" stroke-width="0.5"/>
<text x="90" y="64" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#f5f5f5" text-anchor="middle">10.0.1.0/24</text>
<g font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#a1a1aa">
<circle cx="40" cy="88" r="2.5" fill="#3fb950"/>
<text x="52" y="92">eni-1</text>
<circle cx="40" cy="108" r="2.5" fill="#3fb950"/>
<text x="52" y="112">eni-2</text>
</g>
</g>
<!-- Subnet B (private) -->
<g transform="translate(425, 404)">
<rect width="180" height="124" rx="8" fill="url(#priv-grad)" stroke="#a855f7" stroke-width="1.2" stroke-opacity="0.6"/>
<text x="90" y="22" font-family="'SF Mono', ui-monospace, monospace" font-size="13" fill="#c084fc" text-anchor="middle" font-weight="700">subnet-b</text>
<text x="90" y="38" font-family="'Segoe UI', sans-serif" font-size="10" fill="#d8b4fe" text-anchor="middle">(private)</text>
<line x1="16" y1="46" x2="164" y2="46" stroke="#a855f7" stroke-opacity="0.3" stroke-width="0.5"/>
<text x="90" y="64" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#f5f5f5" text-anchor="middle">10.0.2.0/24</text>
<g font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#a1a1aa">
<circle cx="40" cy="88" r="2.5" fill="#a855f7"/>
<text x="52" y="92">eni-3</text>
<circle cx="40" cy="108" r="2.5" fill="#a855f7"/>
<text x="52" y="112">eni-4</text>
</g>
</g>
<!-- Footnote -->
<text x="24" y="548" font-family="'Segoe UI', sans-serif" font-size="10" fill="#71717a">Cross-host traffic uses Geneve tunnels (UDP 6081) over the overlay NIC · ovn-controller programs OpenFlow rules on br-int</text>
</svg>

After

Width:  |  Height:  |  Size: 8.2 KiB

@@ -0,0 +1,56 @@
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="110" viewBox="0 0 900 110">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#0a0a0a"/>
<stop offset="100%" style="stop-color:#0d1117"/>
</linearGradient>
</defs>
<rect width="900" height="110" fill="url(#bg)" rx="10"/>
<text x="24" y="24" font-family="'Segoe UI', system-ui, sans-serif" font-size="10" fill="#a1a1aa" letter-spacing="2" font-weight="600">PRIVATE SUBNET · no default route</text>
<!-- Instance -->
<g transform="translate(40, 42)">
<rect width="180" height="44" rx="7" fill="#a855f71a" stroke="#a855f7" stroke-width="1.2" stroke-opacity="0.6"/>
<text x="90" y="20" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#c084fc" text-anchor="middle" font-weight="700">Instance</text>
<text x="90" y="34" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#d8b4fe" text-anchor="middle">10.0.2.5</text>
</g>
<!-- arrow -->
<g stroke="#DC2626" stroke-width="1.2" opacity="0.6" fill="none">
<line x1="220" y1="64" x2="260" y2="64" stroke-dasharray="3,3"/>
</g>
<polygon points="260,60 268,64 260,68" fill="#DC2626" opacity="0.6"/>
<!-- Router -->
<g transform="translate(275, 42)">
<rect width="180" height="44" rx="7" fill="#DC26261a" stroke="#DC2626" stroke-width="1.2" stroke-opacity="0.65"/>
<text x="90" y="20" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#F87171" text-anchor="middle" font-weight="700">VPC Router</text>
<text x="90" y="34" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#FCA5A5" text-anchor="middle">lookup 0.0.0.0/0</text>
</g>
<!-- arrow -->
<g stroke="#DC2626" stroke-width="1.2" opacity="0.6" fill="none">
<line x1="455" y1="64" x2="495" y2="64" stroke-dasharray="3,3"/>
</g>
<polygon points="495,60 503,64 495,68" fill="#DC2626" opacity="0.6"/>
<!-- [no default route] -->
<g transform="translate(510, 42)">
<rect width="196" height="44" rx="7" fill="#0a0a0a" stroke="#71717a" stroke-width="1" stroke-opacity="0.7" stroke-dasharray="4,3"/>
<text x="98" y="20" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#a1a1aa" text-anchor="middle" font-weight="600">[no default route]</text>
<text x="98" y="34" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#71717a" text-anchor="middle">no IGW attached</text>
</g>
<!-- arrow to dropped -->
<g stroke="#71717a" stroke-width="1.2" opacity="0.6" fill="none">
<line x1="706" y1="64" x2="746" y2="64" stroke-dasharray="3,3"/>
</g>
<polygon points="746,60 754,64 746,68" fill="#71717a" opacity="0.6"/>
<!-- dropped -->
<g transform="translate(760, 42)">
<rect width="116" height="44" rx="7" fill="#27272a" stroke="#52525b" stroke-width="1" stroke-opacity="0.7"/>
<text x="58" y="27" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#a1a1aa" text-anchor="middle" font-weight="600">dropped</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

@@ -0,0 +1,74 @@
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="170" viewBox="0 0 900 170">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#0a0a0a"/>
<stop offset="100%" style="stop-color:#0d1117"/>
</linearGradient>
</defs>
<rect width="900" height="170" fill="url(#bg)" rx="10"/>
<text x="24" y="24" font-family="'Segoe UI', system-ui, sans-serif" font-size="10" fill="#a1a1aa" letter-spacing="2" font-weight="600">PUBLIC SUBNET · 1:1 NAT managed by OVN</text>
<!-- OUTBOUND row -->
<text x="24" y="52" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#3fb950" font-weight="700">Outbound</text>
<g transform="translate(112, 38)">
<rect width="180" height="40" rx="6" fill="#3fb9501a" stroke="#3fb950" stroke-width="1.1" stroke-opacity="0.6"/>
<text x="90" y="18" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#7ee787" text-anchor="middle" font-weight="700">Instance</text>
<text x="90" y="32" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#a1a1aa" text-anchor="middle">10.0.1.5</text>
</g>
<g stroke="#3fb950" stroke-width="1.1" opacity="0.6" fill="none">
<line x1="292" y1="58" x2="352" y2="58" stroke-dasharray="3,3"/>
</g>
<polygon points="352,54 360,58 352,62" fill="#3fb950" opacity="0.7"/>
<g transform="translate(368, 38)">
<rect width="130" height="40" rx="6" fill="#DC26261a" stroke="#DC2626" stroke-width="1.1" stroke-opacity="0.65"/>
<text x="65" y="25" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#F87171" text-anchor="middle" font-weight="700">SNAT</text>
</g>
<g stroke="#3fb950" stroke-width="1.1" opacity="0.6" fill="none">
<line x1="498" y1="58" x2="558" y2="58" stroke-dasharray="3,3"/>
</g>
<polygon points="558,54 566,58 558,62" fill="#3fb950" opacity="0.7"/>
<g transform="translate(574, 38)">
<rect width="272" height="40" rx="6" fill="#3fb9501a" stroke="#3fb950" stroke-width="1.1" stroke-opacity="0.6"/>
<text x="136" y="18" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#7ee787" text-anchor="middle" font-weight="700">exits as 203.0.113.10</text>
<text x="136" y="32" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#a1a1aa" text-anchor="middle">source IP rewritten</text>
</g>
<!-- INBOUND row -->
<text x="24" y="110" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#58a6ff" font-weight="700">Inbound</text>
<g transform="translate(112, 96)">
<rect width="180" height="40" rx="6" fill="#58a6ff1a" stroke="#58a6ff" stroke-width="1.1" stroke-opacity="0.6"/>
<text x="90" y="18" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#8ab4f8" text-anchor="middle" font-weight="700">WAN client</text>
<text x="90" y="32" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#a1a1aa" text-anchor="middle">→ 203.0.113.10</text>
</g>
<g stroke="#58a6ff" stroke-width="1.1" opacity="0.6" fill="none">
<line x1="292" y1="116" x2="352" y2="116" stroke-dasharray="3,3"/>
</g>
<polygon points="352,112 360,116 352,120" fill="#58a6ff" opacity="0.7"/>
<g transform="translate(368, 96)">
<rect width="130" height="40" rx="6" fill="#DC26261a" stroke="#DC2626" stroke-width="1.1" stroke-opacity="0.65"/>
<text x="65" y="25" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#F87171" text-anchor="middle" font-weight="700">DNAT</text>
</g>
<g stroke="#58a6ff" stroke-width="1.1" opacity="0.6" fill="none">
<line x1="498" y1="116" x2="558" y2="116" stroke-dasharray="3,3"/>
</g>
<polygon points="558,112 566,116 558,120" fill="#58a6ff" opacity="0.7"/>
<g transform="translate(574, 96)">
<rect width="272" height="40" rx="6" fill="#58a6ff1a" stroke="#58a6ff" stroke-width="1.1" stroke-opacity="0.6"/>
<text x="136" y="18" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#8ab4f8" text-anchor="middle" font-weight="700">arrives at 10.0.1.5</text>
<text x="136" y="32" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#a1a1aa" text-anchor="middle">dest IP rewritten</text>
</g>
<!-- Footnote -->
<text x="24" y="158" font-family="'Segoe UI', sans-serif" font-size="9" fill="#71717a">The instance OS only sees its private IP (10.0.1.5) — the public address is managed entirely by OVN's dnat_and_snat rule.</text>
</svg>

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 437 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 378 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 396 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 530 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 544 KiB

+144
View File
@@ -0,0 +1,144 @@
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="560" viewBox="0 0 900 560">
<defs>
<linearGradient id="pf-bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#0a0a0a"/>
<stop offset="100%" style="stop-color:#0d1117"/>
</linearGradient>
<linearGradient id="hw-grad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#52525b;stop-opacity:0.18"/>
<stop offset="100%" style="stop-color:#52525b;stop-opacity:0.06"/>
</linearGradient>
<linearGradient id="os-grad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#a855f7;stop-opacity:0.18"/>
<stop offset="100%" style="stop-color:#a855f7;stop-opacity:0.05"/>
</linearGradient>
<linearGradient id="spx-grad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#DC2626;stop-opacity:0.28"/>
<stop offset="100%" style="stop-color:#7F1D1D;stop-opacity:0.1"/>
</linearGradient>
<linearGradient id="app-grad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#3fb950;stop-opacity:0.18"/>
<stop offset="100%" style="stop-color:#3fb950;stop-opacity:0.05"/>
</linearGradient>
</defs>
<rect width="900" height="560" fill="url(#pf-bg)" rx="12"/>
<!-- Subtle mesh dots background -->
<g opacity="0.12" fill="#7F1D1D">
<circle cx="40" cy="40" r="1"/><circle cx="120" cy="30" r="1"/><circle cx="220" cy="50" r="1"/>
<circle cx="320" cy="25" r="1"/><circle cx="440" cy="45" r="1"/><circle cx="560" cy="30" r="1"/>
<circle cx="680" cy="50" r="1"/><circle cx="780" cy="30" r="1"/><circle cx="860" cy="45" r="1"/>
<circle cx="60" cy="530" r="1"/><circle cx="160" cy="520" r="1"/><circle cx="260" cy="540" r="1"/>
<circle cx="380" cy="525" r="1"/><circle cx="500" cy="540" r="1"/><circle cx="620" cy="520" r="1"/>
<circle cx="740" cy="540" r="1"/><circle cx="840" cy="525" r="1"/>
</g>
<!-- Title -->
<text x="450" y="32" font-family="'Segoe UI', system-ui, sans-serif" font-size="11" fill="#a1a1aa" text-anchor="middle" letter-spacing="2.8">THE MULGA PLATFORM</text>
<text x="450" y="58" font-family="'Segoe UI', system-ui, sans-serif" font-size="20" font-weight="600" fill="#f5f5f5" text-anchor="middle">AWS-style workflows, on your own stack</text>
<!-- Layer 4: Applications (top) -->
<g transform="translate(60, 95)">
<rect width="780" height="88" rx="10" fill="url(#app-grad)" stroke="#3fb950" stroke-width="1.2" stroke-opacity="0.55"/>
<text x="20" y="26" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#3fb950" letter-spacing="1.5" font-weight="600">LAYER 4 · APPLICATIONS &amp; TOOLING</text>
<text x="20" y="48" font-family="'Segoe UI', sans-serif" font-size="15" fill="#f5f5f5" font-weight="500">Your workloads — unmodified AWS tooling</text>
<g transform="translate(20, 60)" font-family="'Segoe UI', sans-serif" font-size="10.5">
<rect x="0" y="0" width="70" height="20" rx="10" fill="#3fb950" fill-opacity="0.14" stroke="#3fb950" stroke-opacity="0.45" stroke-width="0.5"/>
<text x="35" y="14" fill="#7ee787" text-anchor="middle">aws-cli</text>
<rect x="78" y="0" width="78" height="20" rx="10" fill="#3fb950" fill-opacity="0.14" stroke="#3fb950" stroke-opacity="0.45" stroke-width="0.5"/>
<text x="117" y="14" fill="#7ee787" text-anchor="middle">Terraform</text>
<rect x="164" y="0" width="88" height="20" rx="10" fill="#3fb950" fill-opacity="0.14" stroke="#3fb950" stroke-opacity="0.45" stroke-width="0.5"/>
<text x="208" y="14" fill="#7ee787" text-anchor="middle">AWS SDKs</text>
<rect x="260" y="0" width="88" height="20" rx="10" fill="#3fb950" fill-opacity="0.14" stroke="#3fb950" stroke-opacity="0.45" stroke-width="0.5"/>
<text x="304" y="14" fill="#7ee787" text-anchor="middle">Kubernetes</text>
<rect x="356" y="0" width="70" height="20" rx="10" fill="#3fb950" fill-opacity="0.14" stroke="#3fb950" stroke-opacity="0.45" stroke-width="0.5"/>
<text x="391" y="14" fill="#7ee787" text-anchor="middle">Packer</text>
<rect x="434" y="0" width="98" height="20" rx="10" fill="#3fb950" fill-opacity="0.14" stroke="#3fb950" stroke-opacity="0.45" stroke-width="0.5"/>
<text x="483" y="14" fill="#7ee787" text-anchor="middle">Your Services</text>
</g>
</g>
<!-- Down arrow -->
<g transform="translate(450, 188)" opacity="0.45">
<line x1="0" y1="0" x2="0" y2="14" stroke="#a1a1aa" stroke-width="1"/>
<polygon points="-4,12 4,12 0,18" fill="#a1a1aa"/>
</g>
<!-- Layer 3: Spinifex (highlighted) -->
<g transform="translate(60, 215)">
<rect width="780" height="140" rx="10" fill="url(#spx-grad)" stroke="#DC2626" stroke-width="1.5" stroke-opacity="0.75"/>
<text x="20" y="26" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#F87171" letter-spacing="1.5" font-weight="600">LAYER 3 · SPINIFEX · AWS ORCHESTRATION</text>
<text x="20" y="50" font-family="'Segoe UI', sans-serif" font-size="15" fill="#f5f5f5" font-weight="500">AWS Gateway → NATS → Service Daemons · SigV4, IAM, VPC-aware</text>
<g transform="translate(20, 66)" font-family="'Segoe UI', sans-serif">
<!-- EC2 -->
<rect x="0" y="0" width="118" height="58" rx="6" fill="#DC2626" fill-opacity="0.1" stroke="#DC2626" stroke-opacity="0.6" stroke-width="1"/>
<text x="59" y="18" font-size="13" fill="#F87171" text-anchor="middle" font-weight="600">EC2</text>
<text x="59" y="34" font-size="9.5" fill="#f5f5f5" text-anchor="middle">QEMU/KVM VMs</text>
<text x="59" y="48" font-size="9.5" fill="#a1a1aa" text-anchor="middle">cloud-init · AMIs</text>
<!-- EBS -->
<rect x="126" y="0" width="118" height="58" rx="6" fill="#DC2626" fill-opacity="0.1" stroke="#DC2626" stroke-opacity="0.6" stroke-width="1"/>
<text x="185" y="18" font-size="13" fill="#F87171" text-anchor="middle" font-weight="600">EBS</text>
<text x="185" y="34" font-size="9.5" fill="#f5f5f5" text-anchor="middle">Viperblock</text>
<text x="185" y="48" font-size="9.5" fill="#a1a1aa" text-anchor="middle">WAL · NBD · snapshots</text>
<!-- S3 -->
<rect x="252" y="0" width="118" height="58" rx="6" fill="#DC2626" fill-opacity="0.1" stroke="#DC2626" stroke-opacity="0.6" stroke-width="1"/>
<text x="311" y="18" font-size="13" fill="#F87171" text-anchor="middle" font-weight="600">S3</text>
<text x="311" y="34" font-size="9.5" fill="#f5f5f5" text-anchor="middle">Predastore</text>
<text x="311" y="48" font-size="9.5" fill="#a1a1aa" text-anchor="middle">Reed–Solomon EC</text>
<!-- VPC -->
<rect x="378" y="0" width="118" height="58" rx="6" fill="#7F1D1D" fill-opacity="0.22" stroke="#F87171" stroke-opacity="0.55" stroke-width="1"/>
<text x="437" y="18" font-size="13" fill="#FCA5A5" text-anchor="middle" font-weight="600">VPC</text>
<text x="437" y="34" font-size="9.5" fill="#f5f5f5" text-anchor="middle">OVN / OVS</text>
<text x="437" y="48" font-size="9.5" fill="#a1a1aa" text-anchor="middle">subnets · SG · ALB</text>
<!-- IAM -->
<rect x="504" y="0" width="118" height="58" rx="6" fill="#7F1D1D" fill-opacity="0.22" stroke="#F87171" stroke-opacity="0.55" stroke-width="1"/>
<text x="563" y="18" font-size="13" fill="#FCA5A5" text-anchor="middle" font-weight="600">IAM</text>
<text x="563" y="34" font-size="9.5" fill="#f5f5f5" text-anchor="middle">SigV4 · policies</text>
<text x="563" y="48" font-size="9.5" fill="#a1a1aa" text-anchor="middle">users · roles · keys</text>
<!-- Control -->
<rect x="630" y="0" width="110" height="58" rx="6" fill="#27272a" fill-opacity="0.8" stroke="#71717a" stroke-opacity="0.6" stroke-width="1"/>
<text x="685" y="18" font-size="13" fill="#e4e4e7" text-anchor="middle" font-weight="600">Control</text>
<text x="685" y="34" font-size="9.5" fill="#f5f5f5" text-anchor="middle">NATS bus</text>
<text x="685" y="48" font-size="9.5" fill="#a1a1aa" text-anchor="middle">spx · daemons</text>
</g>
</g>
<!-- Down arrow -->
<g transform="translate(450, 360)" opacity="0.45">
<line x1="0" y1="0" x2="0" y2="14" stroke="#a1a1aa" stroke-width="1"/>
<polygon points="-4,12 4,12 0,18" fill="#a1a1aa"/>
</g>
<!-- Layer 2: OS -->
<g transform="translate(60, 387)">
<rect width="780" height="62" rx="10" fill="url(#os-grad)" stroke="#a855f7" stroke-width="1.2" stroke-opacity="0.5"/>
<text x="20" y="22" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#c084fc" letter-spacing="1.5" font-weight="600">LAYER 2 · OPERATING SYSTEM</text>
<text x="20" y="44" font-family="'Segoe UI', sans-serif" font-size="14" fill="#f5f5f5">Debian / Ubuntu LTS · systemd · KVM · Linux bridge · headless autoinstall</text>
</g>
<!-- Down arrow -->
<g transform="translate(450, 454)" opacity="0.45">
<line x1="0" y1="0" x2="0" y2="14" stroke="#a1a1aa" stroke-width="1"/>
<polygon points="-4,12 4,12 0,18" fill="#a1a1aa"/>
</g>
<!-- Layer 1: Hardware -->
<g transform="translate(60, 481)">
<rect width="780" height="62" rx="10" fill="url(#hw-grad)" stroke="#71717a" stroke-width="1.2" stroke-opacity="0.6"/>
<text x="20" y="22" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#a1a1aa" letter-spacing="1.5" font-weight="600">LAYER 1 · HARDWARE</text>
<text x="20" y="44" font-family="'Segoe UI', sans-serif" font-size="14" fill="#f5f5f5">Single node · multi-node cluster · edge appliance · air-gapped rack · commodity x86 / ARM</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

+52
View File
@@ -0,0 +1,52 @@
version: 2
updates:
- package-ecosystem: gomod
target-branch: dev
directory: /
schedule:
interval: weekly
day: monday
timezone: Australia/Sydney
cooldown:
default-days: 1
groups:
go-dependencies:
patterns:
- "*"
- package-ecosystem: github-actions
target-branch: dev
directory: /
schedule:
interval: weekly
day: monday
timezone: Australia/Sydney
cooldown:
default-days: 1
ignore:
- dependency-name: "*"
update-types:
- "version-update:semver-minor"
- "version-update:semver-patch"
groups:
github-actions:
patterns:
- "*"
- package-ecosystem: npm
target-branch: dev
directory: /spinifex/services/spinifexui/frontend
schedule:
interval: weekly
day: monday
timezone: Australia/Sydney
cooldown:
default-days: 1
ignore:
- dependency-name: "@types/node"
update-types:
- "version-update:semver-major"
groups:
npm-dependencies:
patterns:
- "*"
+125
View File
@@ -0,0 +1,125 @@
name: E2E DDIL
# Phase 1 trigger: workflow_dispatch only. Nightly cron + on-main-merge
# land in mulga-siv-5.8 once ≥4 scenarios are asserting. See
# docs/development/improvements/ddil-e2e-test-harness.md §4 / §Dependencies.
on:
workflow_dispatch:
inputs:
quarantined:
description: "Comma-separated scenario letters to quarantine (A..F)"
required: false
default: ""
env:
GIT_BRANCH: ${{ github.ref_name }}
jobs:
ddil:
name: DDIL Real Multi-Node
runs-on: [ self-hosted, ci-multi ]
# Budget: ~45m go test + tofu provision/bootstrap/teardown on ci-multi.
# In Phase 1 the scenarios all t.Skip, so the real-world runtime is
# dominated by provisioning; the 60m ceiling leaves headroom for
# Phase 2/3 when assertions come online.
timeout-minutes: 60
env:
TOFU_LOG: /tmp/e2e-tofu-ddil-${{ github.run_id }}.log
steps:
- name: Clean Workspace
run: rm -rf "$GITHUB_WORKSPACE"/*
- uses: actions/create-github-app-token@v3
id: app-token
with:
client-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
repositories: mulga
# Full spinifex checkout: the Go harness must run on the runner
# (outside the cluster) so its SSH path survives scenario partitions
# that drop peer-to-peer cluster traffic. Running inside the cluster
# would self-sever when Scenario C isolates a node.
- uses: actions/checkout@v6
name: Checkout Spinifex
with:
path: spinifex
- uses: actions/checkout@v6
name: Checkout Mulga (tofu-cluster)
with:
repository: mulgadc/mulga
token: ${{ steps.app-token.outputs.token }}
sparse-checkout: scripts/tofu-cluster
path: mulga
- name: Build Install Artifacts
working-directory: mulga/scripts/tofu-cluster
run: |
./build-install-artifacts.sh "${GIT_BRANCH}" \
"${RUNNER_TEMP}/spinifex-distro.tar.gz" \
"${RUNNER_TEMP}/setup.sh" \
"${RUNNER_TEMP}/spinifex-tests.tar"
- name: Provision VMs
working-directory: mulga/scripts/tofu-cluster
run: |
export TF_VAR_git_branch="${GIT_BRANCH}"
tofu init -input=false >> "$TOFU_LOG" 2>&1
tofu workspace select -or-create "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1
tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1 || true
./scrub-env.sh "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1 || true
tofu apply -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1
- name: Bootstrap via Binary Install
working-directory: mulga/scripts/tofu-cluster
run: |
SPX_EXTERNAL_POOL="${SPINIFEX_CI_POOL}" \
./bootstrap-install.sh "${SPINIFEX_CI_ENV}" \
"${RUNNER_TEMP}/setup.sh" \
"${RUNNER_TEMP}/spinifex-distro.tar.gz" \
--test-tar "${RUNNER_TEMP}/spinifex-tests.tar"
- name: Run DDIL Scenarios
working-directory: mulga/scripts/tofu-cluster
run: |
set -o pipefail
INVENTORY=$(tofu output -json inventory)
SSH_KEY=$(echo "$INVENTORY" | jq -r '.ssh_key_path')
SSH_KEY="${SSH_KEY/#\~/$HOME}"
SSH_USER=$(echo "$INVENTORY" | jq -r '.ssh_user')
REGION=$(echo "$INVENTORY" | jq -r '.region')
# DDIL_NODES must be the cluster-traffic IPs: PartitionNode
# installs iptables DROP rules for each peer, so the runner's
# SSH source IP must differ from these. On single-NIC
# tofu-cluster, wan_ip == cluster IP and the runner's outbound
# IP is distinct — see e2e-ddil-test-harness.md §3 Scenario C.
NODE_IPS=$(echo "$INVENTORY" | jq -r '[.nodes[].wan_ip] | join(",")')
echo "DDIL_NODES=${NODE_IPS}"
mkdir -p "${RUNNER_TEMP}/ddil-results"
cd "${GITHUB_WORKSPACE}/spinifex"
DDIL_NODES="${NODE_IPS}" \
DDIL_SSH_USER="${SSH_USER}" \
DDIL_SSH_KEY="${SSH_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"
- name: Upload DDIL Results
if: always()
uses: actions/upload-artifact@v7
with:
name: ddil-results-${{ github.run_id }}
path: ${{ runner.temp }}/ddil-results
retention-days: 30
- name: Teardown
if: always()
working-directory: mulga/scripts/tofu-cluster
run: |
tofu workspace select -or-create "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1
tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1
./scrub-env.sh "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1 || true
+216
View File
@@ -0,0 +1,216 @@
name: E2E Nightly
on:
schedule:
- cron: "0 14 * * 0-4" # 00:00 Mon-Fri AEST = 14:00 Sun-Thu UTC
workflow_dispatch:
inputs:
cells:
description: "Comma-separated cell numbers to run (blank = all)"
required: false
permissions:
contents: read
env:
GIT_BRANCH: ${{ github.ref_name }}
UBUNTU_GOLD_IMAGE: /mnt/disk1/gold-images/spinifex-image-builder-ubuntu-24.04.qcow2
jobs:
matrix:
name: "cell-${{ matrix.cell }} (${{ matrix.topo }}/${{ matrix.net }}/${{
matrix.host }}${{ matrix.extra && format('/{0}', matrix.extra) || '' }})"
strategy:
fail-fast: false
max-parallel: 3
matrix:
include:
- { cell: 1, install: binary, topo: single, net: dhcp, host: debian-13, arch: x86_64, runner: ci-single, extra: "" }
- { cell: 2, install: binary, topo: single, net: static, host: ubuntu-24.04, arch: x86_64, runner: ci-single, extra: "" }
- { cell: 3, install: binary, topo: single, net: dhcp, host: ubuntu-24.04, arch: x86_64, runner: ci-single, extra: "" }
- { cell: 8, install: binary, topo: real-multi, net: dhcp, host: debian-13, arch: x86_64, runner: ci-multi, extra: "" }
- { cell: 9, install: binary, topo: real-multi, net: static, host: ubuntu-24.04, arch: x86_64, runner: ci-multi, extra: "" }
- { cell: 10, install: binary, topo: real-multi, net: dhcp, host: ubuntu-24.04, arch: x86_64, runner: ci-multi, extra: "" }
- { cell: 17, install: binary, topo: single, net: static, host: debian-13, arch: x86_64, runner: ci-pseudo, extra: "tofu-examples" }
- { cell: 18, install: binary, topo: single, net: static, host: debian-13, arch: x86_64, runner: ci-single, extra: "reboot-single" }
runs-on: [ self-hosted, "${{ matrix.runner }}" ]
timeout-minutes: 35
env:
TOFU_LOG: /tmp/e2e-nightly-cell-${{ matrix.cell }}-${{ github.run_id }}.log
steps:
# blank or scheduled run = full matrix.
# Input must be comma-separated with no whitespace (e.g. '1,2,17', not '1, 2').
- name: Gate on Dispatch Input
id: gate
env:
CELL: ${{ matrix.cell }}
CELLS: ${{ github.event_name == 'workflow_dispatch' && inputs.cells || '' }}
run: |
if [ -z "$CELLS" ] || [[ ",${CELLS}," == *",${CELL},"* ]]; then
echo "selected=true" >> "$GITHUB_OUTPUT"
echo "cell ${CELL} selected"
else
echo "selected=false" >> "$GITHUB_OUTPUT"
echo "cell ${CELL} not in '${CELLS}' — subsequent steps will skip"
fi
- name: Clean Workspace
if: steps.gate.outputs.selected == 'true'
run: rm -rf "$GITHUB_WORKSPACE"/*
- uses: actions/create-github-app-token@v3
if: steps.gate.outputs.selected == 'true'
id: app-token
with:
client-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
repositories: mulga
- uses: actions/checkout@v6
if: steps.gate.outputs.selected == 'true'
with:
repository: mulgadc/mulga
token: ${{ steps.app-token.outputs.token }}
sparse-checkout: scripts/tofu-cluster
path: mulga
- name: Build Install Artifacts
if: steps.gate.outputs.selected == 'true'
working-directory: mulga/scripts/tofu-cluster
run: |
./build-install-artifacts.sh "${GIT_BRANCH}" \
"${RUNNER_TEMP}/spinifex-distro.tar.gz" \
"${RUNNER_TEMP}/setup.sh" \
"${RUNNER_TEMP}/spinifex-tests.tar"
- name: Provision VMs
if: steps.gate.outputs.selected == 'true'
working-directory: mulga/scripts/tofu-cluster
env:
MATRIX_HOST: ${{ matrix.host }}
run: |
export TF_VAR_git_branch="${GIT_BRANCH}"
TOFU_VAR_ARGS=""
if [ "$MATRIX_HOST" = "ubuntu-24.04" ]; then
TOFU_VAR_ARGS="-var=host_image_override=${UBUNTU_GOLD_IMAGE}"
fi
tofu init -input=false >> "$TOFU_LOG" 2>&1
tofu workspace select -or-create "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1
tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars ${TOFU_VAR_ARGS} -auto-approve -input=false >> "$TOFU_LOG" 2>&1 || true
./scrub-env.sh "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1 || true
tofu apply -var-file=envs/${SPINIFEX_CI_ENV}.tfvars ${TOFU_VAR_ARGS} -auto-approve -input=false >> "$TOFU_LOG" 2>&1
- name: Bootstrap via Binary Install
if: steps.gate.outputs.selected == 'true'
working-directory: mulga/scripts/tofu-cluster
env:
MATRIX_NET: ${{ matrix.net }}
run: |
if [ "$MATRIX_NET" = "dhcp" ]; then
unset SPX_EXTERNAL_POOL
export SPX_EXTERNAL_SOURCE=dhcp
else
export SPX_EXTERNAL_POOL="${SPINIFEX_CI_POOL}"
fi
./bootstrap-install.sh "${SPINIFEX_CI_ENV}" \
"${RUNNER_TEMP}/setup.sh" \
"${RUNNER_TEMP}/spinifex-distro.tar.gz" \
--test-tar "${RUNNER_TEMP}/spinifex-tests.tar"
- name: Run Single Node E2E
if: steps.gate.outputs.selected == 'true' && matrix.topo == 'single' && matrix.extra == ''
working-directory: mulga/scripts/tofu-cluster
run: |
INVENTORY=$(tofu output -json inventory)
WAN_IP=$(echo "$INVENTORY" | jq -r '.nodes[0].wan_ip')
SSH_KEY=$(echo "$INVENTORY" | jq -r '.ssh_key_path')
SSH_KEY="${SSH_KEY/#\~/$HOME}"
ssh -o StrictHostKeyChecking=no -i "$SSH_KEY" tf-user@${WAN_IP} \
"cd ~/e2e-tests && ./run-e2e.sh && ./run-cert-e2e.sh && ENDPOINT=https://${WAN_IP}:9999 ./run-lb-e2e.sh"
- name: Run Multi Node E2E
if: steps.gate.outputs.selected == 'true' && matrix.topo == 'real-multi'
working-directory: mulga/scripts/tofu-cluster
run: |
INVENTORY=$(tofu output -json inventory)
NODE1_IP=$(echo "$INVENTORY" | jq -r '.nodes[0].wan_ip')
SSH_KEY=$(echo "$INVENTORY" | jq -r '.ssh_key_path')
SSH_KEY="${SSH_KEY/#\~/$HOME}"
NODE_IPS=""
NODE_COUNT=$(echo "$INVENTORY" | jq '.nodes | length')
for i in $(seq 0 $((NODE_COUNT - 1))); do
NODE_IPS="${NODE_IPS} $(echo "$INVENTORY" | jq -r ".nodes[$i].wan_ip")"
done
ssh -o StrictHostKeyChecking=no -i "$SSH_KEY" tf-user@${NODE1_IP} \
"cd ~/e2e-tests && ./run-multinode-e2e.sh ${NODE_IPS} && ./run-cert-e2e.sh --multinode ${NODE_IPS} && ENDPOINT=https://${NODE1_IP}:9999 ./run-lb-e2e.sh --nodes ${NODE_IPS}"
- name: Run Tofu Workbooks
if: steps.gate.outputs.selected == 'true' && matrix.extra == 'tofu-examples'
working-directory: mulga/scripts/tofu-cluster
run: |
INVENTORY=$(tofu output -json inventory)
WAN_IP=$(echo "$INVENTORY" | jq -r '.nodes[0].wan_ip')
SSH_KEY=$(echo "$INVENTORY" | jq -r '.ssh_key_path')
SSH_KEY="${SSH_KEY/#\~/$HOME}"
ssh -o StrictHostKeyChecking=no -i "$SSH_KEY" tf-user@${WAN_IP} \
"cd ~/e2e-tests && ./run-tofu-examples-e2e.sh"
- name: Run Reboot Resilience E2E
if: steps.gate.outputs.selected == 'true' && matrix.extra == 'reboot-single'
working-directory: mulga/scripts/tofu-cluster
# Driver runs from the runner (NOT via ssh-and-run) so it survives the
# systemctl-reboot-induced ssh disconnect mid-run.
run: |
INVENTORY=$(tofu output -json inventory)
WAN_IP=$(echo "$INVENTORY" | jq -r '.nodes[0].wan_ip')
SSH_KEY=$(echo "$INVENTORY" | jq -r '.ssh_key_path')
SSH_KEY="${SSH_KEY/#\~/$HOME}"
REBOOT_DIR="${RUNNER_TEMP}/reboot-e2e"
mkdir -p "$REBOOT_DIR"
tar xf "${RUNNER_TEMP}/spinifex-tests.tar" -C "$REBOOT_DIR"
chmod +x "$REBOOT_DIR/run-reboot-e2e.sh"
WAN_IP="$WAN_IP" SSH_KEY="$SSH_KEY" "$REBOOT_DIR/run-reboot-e2e.sh"
- name: Dump Cluster Journals on Failure
if: failure() && steps.gate.outputs.selected == 'true'
working-directory: mulga/scripts/tofu-cluster
run: |
INVENTORY=$(tofu output -json inventory 2>/dev/null) || { echo "no inventory; skipping"; exit 0; }
SSH_KEY=$(echo "$INVENTORY" | jq -r '.ssh_key_path')
SSH_KEY="${SSH_KEY/#\~/$HOME}"
NODE_COUNT=$(echo "$INVENTORY" | jq '.nodes | length')
for i in $(seq 0 $((NODE_COUNT - 1))); do
IP=$(echo "$INVENTORY" | jq -r ".nodes[$i].wan_ip")
echo "===== node$((i+1)) ($IP) ====="
for svc in spinifex-nats spinifex-predastore spinifex-viperblock spinifex-daemon spinifex-awsgw spinifex-vpcd dhcpcd; do
echo "--- $svc ---"
ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5 -i "$SSH_KEY" tf-user@${IP} \
"sudo journalctl -u $svc --no-pager -n 200 2>/dev/null || echo '(not found)'" 2>&1 || echo "(node unreachable)"
done
echo "--- DHCP client state ---"
ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5 -i "$SSH_KEY" tf-user@${IP} \
"systemctl is-enabled dhcpcd.service 2>/dev/null; systemctl is-active dhcpcd.service 2>/dev/null; sudo ss -lupn 'sport = 68' 2>/dev/null; pgrep -laf dhcpcd 2>/dev/null" 2>&1 || echo "(node unreachable)"
done
- name: Teardown
if: always()
working-directory: mulga/scripts/tofu-cluster
env:
MATRIX_HOST: ${{ matrix.host }}
run: |
TOFU_VAR_ARGS=""
if [ "$MATRIX_HOST" = "ubuntu-24.04" ]; then
TOFU_VAR_ARGS="-var=host_image_override=${UBUNTU_GOLD_IMAGE}"
fi
tofu workspace select -or-create "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1
tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars ${TOFU_VAR_ARGS} -auto-approve -input=false >> "$TOFU_LOG" 2>&1
./scrub-env.sh "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1 || true
+100 -52
View File
@@ -1,4 +1,4 @@
name: Spinifex E2E Tests
name: E2E
on:
push:
@@ -13,6 +13,13 @@ on:
- tests/e2e/**
- .github/workflows/e2e.yml
workflow_dispatch:
inputs:
sha:
description: "PR head SHA to report status on"
required: false
permissions:
statuses: write
env:
GIT_BRANCH: ${{ github.ref_name }}
@@ -20,18 +27,18 @@ env:
jobs:
e2e_single:
name: Single-Node E2E
runs-on: [self-hosted, ci-single]
timeout-minutes: 25
runs-on: [ self-hosted, ci-single ]
timeout-minutes: 30
env:
TOFU_LOG: /tmp/e2e-tofu-single-${{ github.run_id }}.log
steps:
- name: Clean workspace
- name: Clean Workspace
run: rm -rf "$GITHUB_WORKSPACE"/*
- uses: actions/create-github-app-token@v3
id: app-token
with:
app-id: ${{ secrets.APP_ID }}
client-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
repositories: mulga
@@ -42,26 +49,32 @@ jobs:
sparse-checkout: scripts/tofu-cluster
path: mulga
- name: Clone and build
- name: Build Install Artifacts
working-directory: mulga/scripts/tofu-cluster
run: ./build-artifacts.sh "${GIT_BRANCH}" "${RUNNER_TEMP}/spinifex-artifacts.tar"
run: |
./build-install-artifacts.sh "${GIT_BRANCH}" \
"${RUNNER_TEMP}/spinifex-distro.tar.gz" \
"${RUNNER_TEMP}/setup.sh" \
"${RUNNER_TEMP}/spinifex-tests.tar"
- name: Provision VM
working-directory: mulga/scripts/tofu-cluster
run: |
export TF_VAR_git_branch="${GIT_BRANCH}"
tofu init -input=false >> "$TOFU_LOG" 2>&1
tofu workspace select -or-create env3 >> "$TOFU_LOG" 2>&1
tofu destroy -var-file=envs/env3.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1 || true
./scrub-env.sh env3 >> "$TOFU_LOG" 2>&1 || true
tofu apply -var-file=envs/env3.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1
tofu workspace select -or-create "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1
tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1 || true
./scrub-env.sh "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1 || true
tofu apply -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1
- name: Bootstrap Cluster
- name: Bootstrap via Binary Install
working-directory: mulga/scripts/tofu-cluster
run: |
SPX_PREBUILT=true \
SPX_ARTIFACT_TAR="${RUNNER_TEMP}/spinifex-artifacts.tar" \
./bootstrap.sh env3 >> "$TOFU_LOG" 2>&1
SPX_EXTERNAL_POOL="${SPINIFEX_CI_POOL}" \
./bootstrap-install.sh "${SPINIFEX_CI_ENV}" \
"${RUNNER_TEMP}/setup.sh" \
"${RUNNER_TEMP}/spinifex-distro.tar.gz" \
--test-tar "${RUNNER_TEMP}/spinifex-tests.tar"
- name: Run E2E Tests
working-directory: mulga/scripts/tofu-cluster
@@ -71,30 +84,30 @@ jobs:
SSH_KEY=$(echo "$INVENTORY" | jq -r '.ssh_key_path')
SSH_KEY="${SSH_KEY/#\~/$HOME}"
ssh -o StrictHostKeyChecking=no -i "$SSH_KEY" tf-user@${WAN_IP} \
'cd ~/Development/mulga/spinifex && ./tests/e2e/run-e2e.sh'
"cd ~/e2e-tests && ./run-e2e.sh && ./run-cert-e2e.sh && ENDPOINT=https://${WAN_IP}:9999 ./run-lb-e2e.sh"
- name: Teardown
if: always()
working-directory: mulga/scripts/tofu-cluster
run: |
tofu workspace select -or-create env3 >> "$TOFU_LOG" 2>&1
tofu destroy -var-file=envs/env3.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1
./scrub-env.sh env3 >> "$TOFU_LOG" 2>&1 || true
tofu workspace select -or-create "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1
tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1
./scrub-env.sh "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1 || true
e2e_pseudo_multi:
name: Pseudo Multi-Node E2E
runs-on: [self-hosted, ci-pseudo]
timeout-minutes: 25
runs-on: [ self-hosted, ci-pseudo ]
timeout-minutes: 30
env:
TOFU_LOG: /tmp/e2e-tofu-pseudo-${{ github.run_id }}.log
steps:
- name: Clean workspace
- name: Clean Workspace
run: rm -rf "$GITHUB_WORKSPACE"/*
- uses: actions/create-github-app-token@v3
id: app-token
with:
app-id: ${{ secrets.APP_ID }}
client-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
repositories: mulga
@@ -105,26 +118,27 @@ jobs:
sparse-checkout: scripts/tofu-cluster
path: mulga
- name: Clone and build
- name: Clone and Build
working-directory: mulga/scripts/tofu-cluster
run: ./build-artifacts.sh "${GIT_BRANCH}" "${RUNNER_TEMP}/spinifex-artifacts.tar"
run: ./build-artifacts.sh "${GIT_BRANCH}"
"${RUNNER_TEMP}/spinifex-artifacts.tar"
- name: Provision VM
working-directory: mulga/scripts/tofu-cluster
run: |
export TF_VAR_git_branch="${GIT_BRANCH}"
tofu init -input=false >> "$TOFU_LOG" 2>&1
tofu workspace select -or-create env4 >> "$TOFU_LOG" 2>&1
tofu destroy -var-file=envs/env4.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1 || true
./scrub-env.sh env4 >> "$TOFU_LOG" 2>&1 || true
tofu apply -var-file=envs/env4.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1
tofu workspace select -or-create "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1
tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1 || true
./scrub-env.sh "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1 || true
tofu apply -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1
- name: Bootstrap Cluster
working-directory: mulga/scripts/tofu-cluster
run: |
SPX_PREBUILT=true \
SPX_ARTIFACT_TAR="${RUNNER_TEMP}/spinifex-artifacts.tar" \
./bootstrap.sh env4 >> "$TOFU_LOG" 2>&1
./bootstrap.sh "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1
- name: Run E2E Tests
working-directory: mulga/scripts/tofu-cluster
@@ -134,30 +148,30 @@ jobs:
SSH_KEY=$(echo "$INVENTORY" | jq -r '.ssh_key_path')
SSH_KEY="${SSH_KEY/#\~/$HOME}"
ssh -o StrictHostKeyChecking=no -i "$SSH_KEY" tf-user@${WAN_IP} \
'cd ~/Development/mulga/spinifex && ./tests/e2e/run-pseudo-multinode-e2e.sh'
"cd ~/Development/mulga/spinifex && SPX_EXTERNAL_POOL=\"${SPINIFEX_CI_POOL}\" ./tests/e2e/run-pseudo-multinode-e2e.sh"
- name: Teardown
if: always()
working-directory: mulga/scripts/tofu-cluster
run: |
tofu workspace select -or-create env4 >> "$TOFU_LOG" 2>&1
tofu destroy -var-file=envs/env4.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1
./scrub-env.sh env4 >> "$TOFU_LOG" 2>&1 || true
tofu workspace select -or-create "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1
tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1
./scrub-env.sh "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1 || true
e2e_real_multi:
name: Real Multi-Node E2E
runs-on: [self-hosted, ci-multi]
timeout-minutes: 25
runs-on: [ self-hosted, ci-multi ]
timeout-minutes: 35
env:
TOFU_LOG: /tmp/e2e-tofu-multi-${{ github.run_id }}.log
steps:
- name: Clean workspace
- name: Clean Workspace
run: rm -rf "$GITHUB_WORKSPACE"/*
- uses: actions/create-github-app-token@v3
id: app-token
with:
app-id: ${{ secrets.APP_ID }}
client-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
repositories: mulga
@@ -168,26 +182,32 @@ jobs:
sparse-checkout: scripts/tofu-cluster
path: mulga
- name: Clone and build
- name: Build Install Artifacts
working-directory: mulga/scripts/tofu-cluster
run: ./build-artifacts.sh "${GIT_BRANCH}" "${RUNNER_TEMP}/spinifex-artifacts.tar"
run: |
./build-install-artifacts.sh "${GIT_BRANCH}" \
"${RUNNER_TEMP}/spinifex-distro.tar.gz" \
"${RUNNER_TEMP}/setup.sh" \
"${RUNNER_TEMP}/spinifex-tests.tar"
- name: Provision VMs
working-directory: mulga/scripts/tofu-cluster
run: |
export TF_VAR_git_branch="${GIT_BRANCH}"
tofu init -input=false >> "$TOFU_LOG" 2>&1
tofu workspace select -or-create env2 >> "$TOFU_LOG" 2>&1
tofu destroy -var-file=envs/env2.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1 || true
./scrub-env.sh env2 >> "$TOFU_LOG" 2>&1 || true
tofu apply -var-file=envs/env2.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1
tofu workspace select -or-create "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1
tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1 || true
./scrub-env.sh "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1 || true
tofu apply -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1
- name: Bootstrap Cluster
- name: Bootstrap via Binary Install
working-directory: mulga/scripts/tofu-cluster
run: |
SPX_PREBUILT=true \
SPX_ARTIFACT_TAR="${RUNNER_TEMP}/spinifex-artifacts.tar" \
./bootstrap.sh env2 >> "$TOFU_LOG" 2>&1
SPX_EXTERNAL_POOL="${SPINIFEX_CI_POOL}" \
./bootstrap-install.sh "${SPINIFEX_CI_ENV}" \
"${RUNNER_TEMP}/setup.sh" \
"${RUNNER_TEMP}/spinifex-distro.tar.gz" \
--test-tar "${RUNNER_TEMP}/spinifex-tests.tar"
- name: Run E2E Tests
working-directory: mulga/scripts/tofu-cluster
@@ -204,13 +224,41 @@ jobs:
NODE_IPS="${NODE_IPS} $(echo "$INVENTORY" | jq -r ".nodes[$i].wan_ip")"
done
NODE2_IP=$(echo "$INVENTORY" | jq -r '.nodes[1].wan_ip')
ssh -o StrictHostKeyChecking=no -i "$SSH_KEY" tf-user@${NODE1_IP} \
"cd ~/Development/mulga/spinifex && ./tests/e2e/run-multinode-e2e.sh ${NODE_IPS}"
"cd ~/e2e-tests && ./run-multinode-e2e.sh ${NODE_IPS} && ./run-cert-e2e.sh --multinode ${NODE_IPS} && ENDPOINT=https://${NODE1_IP}:9999 ./run-lb-e2e.sh --nodes ${NODE_IPS}"
- name: Teardown
if: always()
working-directory: mulga/scripts/tofu-cluster
run: |
tofu workspace select -or-create env2 >> "$TOFU_LOG" 2>&1
tofu destroy -var-file=envs/env2.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1
./scrub-env.sh env2 >> "$TOFU_LOG" 2>&1 || true
tofu workspace select -or-create "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1
tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1
./scrub-env.sh "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1 || true
e2e_status:
name: Report E2E Status
if: always() && inputs.sha
needs: [ e2e_single, e2e_pseudo_multi, e2e_real_multi ]
runs-on: [ self-hosted, ci-single ]
timeout-minutes: 5
steps:
- name: Report Status to PR
env:
GH_TOKEN: ${{ github.token }}
run: |
if [[ "${{ needs.e2e_single.result }}" == "success" && \
"${{ needs.e2e_pseudo_multi.result }}" == "success" && \
"${{ needs.e2e_real_multi.result }}" == "success" ]]; then
STATE="success"
DESC="All E2E suites passed"
else
STATE="failure"
DESC="E2E failed: single=${{ needs.e2e_single.result }} pseudo=${{ needs.e2e_pseudo_multi.result }} multi=${{ needs.e2e_real_multi.result }}"
fi
gh api "repos/${{ github.repository }}/statuses/${{ inputs.sha }}" \
-f state="$STATE" \
-f context=e2e \
-f description="$DESC" \
-f target_url="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
+26 -36
View File
@@ -2,7 +2,7 @@ name: Release
on:
push:
tags: ["v*"]
tags: [ "v*" ]
permissions:
contents: write
@@ -37,7 +37,7 @@ jobs:
run: cd spinifex && make distro-amd64 VERSION="$VERSION"
- name: Upload amd64 Artifacts
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: distro-amd64
path: |
@@ -74,7 +74,7 @@ jobs:
run: cd spinifex && make distro-arm64 VERSION="$VERSION"
- name: Upload arm64 Artifacts
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: distro-arm64
path: |
@@ -82,23 +82,23 @@ jobs:
spinifex/dist/spinifex-${{ github.ref_name }}-linux-arm64.tar.gz.sha256
release:
needs: [distro-amd64, distro-arm64]
needs: [ distro-amd64, distro-arm64 ]
runs-on: ubuntu-24.04
steps:
- name: Download amd64 Artifacts
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
name: distro-amd64
path: artifacts
- name: Download arm64 Artifacts
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
name: distro-arm64
path: artifacts
- name: Create Draft GitHub Release
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@v3
with:
draft: true
files: |
@@ -108,14 +108,14 @@ jobs:
generate_release_notes: true
- name: Upload Tarball for Verify Job
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v7
with:
name: release-assets
path: artifacts/
verify:
needs: release
runs-on: [self-hosted, ci-single]
runs-on: [ self-hosted, ci-single ]
timeout-minutes: 25
env:
TOFU_LOG: /tmp/release-verify-${{ github.run_id }}.log
@@ -128,7 +128,7 @@ jobs:
- uses: actions/create-github-app-token@v3
id: app-token
with:
app-id: ${{ secrets.APP_ID }}
client-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
repositories: mulga
@@ -140,7 +140,7 @@ jobs:
path: mulga
- name: Download Release Assets
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
name: release-assets
path: release-assets
@@ -149,40 +149,30 @@ jobs:
working-directory: mulga/scripts/tofu-cluster
run: |
tofu init -input=false >> "$TOFU_LOG" 2>&1
tofu workspace select -or-create env3 >> "$TOFU_LOG" 2>&1
tofu destroy -var-file=envs/env3.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1 || true
./scrub-env.sh env3 >> "$TOFU_LOG" 2>&1 || true
tofu apply -var-file=envs/env3.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1
tofu workspace select -or-create "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1
tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1 || true
./scrub-env.sh "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1 || true
tofu apply -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1
- name: Bootstrap via Binary Install
working-directory: mulga/scripts/tofu-cluster
run: |
SETUP_SH=$(find "${GITHUB_WORKSPACE}/release-assets" -name "setup.sh" | head -1)
TARBALL=$(find "${GITHUB_WORKSPACE}/release-assets" -name "*-linux-amd64.tar.gz" | head -1)
./bootstrap-install.sh env3 "$SETUP_SH" "$TARBALL"
- name: Run Smoke Test
working-directory: mulga/scripts/tofu-cluster
run: |
INVENTORY=$(tofu output -json inventory)
WAN_IP=$(echo "$INVENTORY" | jq -r '.nodes[0].wan_ip')
SSH_KEY=$(echo "$INVENTORY" | jq -r '.ssh_key_path')
SSH_KEY="${SSH_KEY/#\~/$HOME}"
scp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR \
-i "$SSH_KEY" \
smoke-test-install.sh \
"tf-user@${WAN_IP}:/tmp/smoke-test.sh"
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR \
-i "$SSH_KEY" "tf-user@${WAN_IP}" \
'bash /tmp/smoke-test.sh'
./bootstrap-install.sh "${SPINIFEX_CI_ENV}" "$SETUP_SH" "$TARBALL"
- name: Promote Release
if: success()
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release edit "$TAG" --repo "$REPO" --draft=false
echo "Release ${TAG} promoted to latest"
if [[ "$TAG" =~ -(dev|rc|alpha|beta) ]]; then
gh release edit "$TAG" --repo "$REPO" --draft=false --prerelease
echo "Release ${TAG} promoted as prerelease"
else
gh release edit "$TAG" --repo "$REPO" --draft=false
echo "Release ${TAG} promoted to latest"
fi
- name: Mark Release Failed
if: failure()
@@ -199,6 +189,6 @@ jobs:
if: always()
working-directory: mulga/scripts/tofu-cluster
run: |
tofu workspace select -or-create env3 >> "$TOFU_LOG" 2>&1
tofu destroy -var-file=envs/env3.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1
./scrub-env.sh env3 >> "$TOFU_LOG" 2>&1 || true
tofu workspace select -or-create "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1
tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1
./scrub-env.sh "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1 || true
+15 -14
View File
@@ -1,4 +1,4 @@
name: Spinifex Build and Test
name: Build and Test
on:
push:
@@ -11,11 +11,15 @@ on:
- go.sum
- Makefile
- .github/workflows/spinifex.yml
pull_request:
branches:
- main
- dev
workflow_dispatch:
jobs:
build_and_test:
name: Spinifex Build and Test
name: Build and Test
runs-on: ubuntu-24.04
steps:
@@ -56,7 +60,7 @@ jobs:
run: cd spinifex && scripts/diff-coverage.sh coverage.out --base HEAD~1
race_detection:
name: Spinifex Race Detection
name: Race Detection
runs-on: ubuntu-24.04
steps:
@@ -90,7 +94,7 @@ jobs:
run: make -C spinifex test-race
lint_and_security:
name: Spinifex Lint and Security
name: Lint and Security
runs-on: ubuntu-24.04
steps:
@@ -120,14 +124,11 @@ jobs:
- name: Setup Go Workspace
run: go work init ./spinifex ./viperblock ./predastore
- name: Check Format
run: make -C spinifex check-format
- name: Lint
uses: golangci/golangci-lint-action@v9
with:
version: v2.11.3
working-directory: spinifex
- name: Check Modernize
run: make -C spinifex check-modernize
- name: Go Vet
run: make -C spinifex vet
- name: Security Checks
run: make -C spinifex security-check
- name: Govulncheck
run: make -C spinifex govulncheck
+8 -1
View File
@@ -8,6 +8,13 @@ on:
paths:
- spinifex/services/spinifexui/frontend/**
- .github/workflows/ui.yml
pull_request:
branches:
- main
- dev
paths:
- spinifex/services/spinifexui/frontend/**
- .github/workflows/ui.yml
workflow_dispatch:
jobs:
@@ -25,7 +32,7 @@ jobs:
sparse-checkout: spinifex/services/spinifexui/frontend
- name: Setup pnpm
uses: pnpm/action-setup@v5
uses: pnpm/action-setup@v6
with:
package_json_file: spinifex/services/spinifexui/frontend/package.json
+6
View File
@@ -7,6 +7,9 @@
!example.tfvars
scripts/iac/proxmox/clusters/
# Generated SSH private keys from terraform examples (e.g. local_file.nginx_pem)
*.pem
# Go workspace
go.work
go.work.sum
@@ -14,6 +17,7 @@ go.work.sum
# Build outputs
bin/
dist/
!spinifex/services/spinifexui/frontend/dist/
tmp/
*.test
*.out
@@ -27,6 +31,7 @@ Thumbs.db
# Environment
.env
.venv/
# Test reports
*-report.txt
@@ -39,3 +44,4 @@ docs/PLAN.md
docs/TODO.md
docs/development/
docs/internal/
scripts/iac/aws/spinifex-test.pem
+136
View File
@@ -0,0 +1,136 @@
version: "2"
formatters:
enable:
- gofmt
settings:
gofmt:
simplify: false # modernize linter covers gofmt -s simplifications (Go 1.23+)
linters:
enable:
- asasalint # catches pass []any as any in variadic func(...any)
- asciicheck # checks that code does not contain non-ASCII identifiers
- bidichk # checks for dangerous unicode bidi character sequences
- bodyclose # checks whether HTTP response body is closed successfully
- canonicalheader # checks whether net/http.Header uses canonical header keys
- decorder # checks declaration order of types, consts, vars, and funcs
- durationcheck # checks for two durations multiplied together
- embeddedstructfieldcheck # checks embedded sync.Mutex/RWMutex in structs
- errcheck # checks for unchecked errors in type assertions and I/O
- errchkjson # checks types passed to json encoding functions, flags omittable error checks
- errname # checks that sentinel errors are prefixed with Err, error types suffixed with Error
- exptostd # detects golang.org/x/exp/ functions replaceable by stdlib
- fatcontext # detects nested contexts in loops
- forcetypeassert # flags unchecked type assertions that can panic
- gocheckcompilerdirectives # validates go compiler directive comments (//go:)
- goprintffuncname # checks that printf-like functions are named with f at the end
- gosec # inspects source code for security problems
- govet # reports suspicious constructs (Printf alignment, shadow, etc.)
- ineffassign # detects assignments to existing variables that are not used
- makezero # finds slice declarations with non-zero initial length followed by append
- mirror # catches wrong mirror patterns in bytes/strings usage
- musttag # checks structs passed to marshal/unmarshal have proper tags
- modernize # modernizes code to use newer Go idioms (go fix analyzers)
- nakedret # finds naked returns in functions
- nilerr # catches code that returns nil even after checking err != nil
- nilnesserr # catches returning a different nil error variable after err != nil check
- nolintlint # reports ill-formed or insufficient nolint directives
- nosprintfhostport # flags fmt.Sprintf host:port — use net.JoinHostPort for IPv6 safety
- perfsprint # detects fmt.Sprintf calls replaceable with faster alternatives
- predeclared # catches shadowing of Go builtins (len, cap, error, etc.)
- reassign # checks that package variables are not reassigned
- recvcheck # checks for receiver type consistency (pointer vs value)
- sloglint # enforces consistent log/slog usage (key-value pairs, no globals)
- staticcheck # extensive static analysis checks
- unconvert # removes unnecessary type conversions
- usestdlibvars # detects hardcoded values that should use stdlib constants
- usetesting # catches os.TempDir() etc. in tests — should use t.TempDir()
- wastedassign # finds assignments to variables that are never subsequently read
- whitespace # detects leading and trailing whitespace in functions
settings:
embeddedstructfieldcheck:
forbid-mutex: true
errcheck:
check-type-assertions: true
exclude-functions:
- (*github.com/nats-io/nats.go.Subscription).Unsubscribe # cleanup-only, errors not actionable
- (*github.com/kdomanski/iso9660.ImageWriter).Cleanup # deferred cleanup, errors not actionable
govet:
enable-all: true
disable:
- fieldalignment
- shadow # too noisy to retrofit — all 50 findings are err re-declarations
- unusedwrite # false positives on struct field assignments before marshal/send
staticcheck:
checks:
- all
- -ST1000 # package comment
- -ST1003 # naming conventions (Id→ID etc.) — too noisy to retrofit
- -ST1012 # error var naming — errname covers this + error type suffixes
- -ST1013 # HTTP status constants — usestdlibvars is broader
- -ST1016 # receiver name consistency (too noisy to retrofit)
- -ST1020 # godoc on exported types — not enforcing yet
- -ST1021 # godoc on exported type groups — not enforcing yet
- -ST1022 # godoc on exported vars/consts — not enforcing yet
- -SA1019 # deprecated usage (handle separately)
- -SA4006 # value overwritten — ineffassign + wastedassign cover this better
- -SA9005 # ineffective break (staticcheck false positives)
gosec:
excludes:
- G104 # unchecked errors — errcheck is stricter
- G116 # bidi chars — bidichk is more thorough
- G117 # use of net/http serve functions (we handle TLS separately)
- G703 # path traversal taint analysis — false positives on config file loading
- G705 # XSS taint analysis — false positives on internal error responses
- G706 # log injection taint analysis — false positives on structured slog usage
modernize:
disable:
- newexpr # don't replace aws.String() with new() — AWS SDK idiom
- stringsbuilder # don't replace small string concatenations with strings.Builder
perfsprint:
concat-loop: false
err-error: false # don't flag err.Error()
errorf: false # don't convert fmt.Errorf to errors.New
strconcat: false
nakedret:
max-func-lines: 0
nolintlint:
require-explanation: true
require-specific: true
reassign:
patterns:
- ".*"
exclusions:
presets:
- std-error-handling
- common-false-positives
rules:
- path: '_test\.go'
linters:
- bodyclose
- errcheck
- errchkjson
- gosec
# spinifex-specific: gosec exclusions
- path: "cmd/"
linters:
- errcheck # CLI glue code — viper/cobra/pterm errors not actionable
- gosec # matches current -exclude-dir=cmd
- text: "G204"
linters: [gosec] # subprocess execution — legitimate in VM orchestration
- text: "G304"
linters: [gosec] # file path from variable — necessary for config loading
+68 -60
View File
@@ -34,6 +34,9 @@ else
endif
# Quiet-mode filters (active when QUIET=1, set by preflight via recursive make)
# Note: grep pipelines use PIPESTATUS[0] so the exit status of `go test`
# propagates through the filter — otherwise a test failure is swallowed by
# grep's own (success) exit code and preflight prints "passed" on red.
ifdef QUIET
_Q = @
_COVQ = 2>&1 | { grep -Ev '^\s*(ok|PASS|\?|=== RUN|--- PASS:)\s' | grep -v 'coverage: 0\.0%' || true; }; exit $${PIPESTATUS[0]}
@@ -46,8 +49,7 @@ else
_SECQ = 2>&1 | tee
endif
build:
$(MAKE) go_build
build: go_build build-installer build-lb-agent
# Build spinifex-ui frontend (requires pnpm)
build-ui:
@@ -63,14 +65,31 @@ 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
build-installer:
@echo -e "\n....Building spinifex-installer"
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
build-system-image: ## Build a system image from manifest (use IMAGE=lb)
ifndef IMAGE
$(error IMAGE is required. Usage: make build-system-image IMAGE=lb)
endif
./scripts/build-system-image.sh scripts/images/$(IMAGE).conf
build-lb-image: ## Build LB Alpine image
$(MAKE) build-system-image IMAGE=lb
go_run:
@echo -e "\n....Running $(GO_PROJECT_NAME)...."
$(GOPATH)/bin/$(GO_PROJECT_NAME)
# Preflight — runs the same checks as GitHub Actions (format + lint + security + tests).
# Preflight — runs the same checks as GitHub Actions (lint + vuln + tests).
# Use this before committing to catch CI failures locally.
preflight:
@$(MAKE) --no-print-directory QUIET=1 check-format check-modernize vet security-check test-cover diff-coverage test-race
@$(MAKE) --no-print-directory QUIET=1 lint govulncheck test-cover diff-coverage test-race
@echo -e "\n ✅ Preflight passed — safe to commit."
# Run unit tests
@@ -103,6 +122,17 @@ run:
$(MAKE) go_build
$(MAKE) go_run
# Fast iteration: build + install binary + restart all services
deploy: build
sudo install -m 755 bin/spx /usr/local/bin/spx
sudo systemctl daemon-reload
sudo systemctl restart spinifex.target
# Re-run setup.sh after changing systemd units, helper scripts, or logrotate config.
# Not needed for code-only changes — use deploy for those.
reinstall:
scripts/dev-install.sh
clean:
rm -f ./bin/$(GO_PROJECT_NAME)
rm -rf spinifex/services/spinifexui/frontend/dist
@@ -116,12 +146,12 @@ install-system:
nbdkit nbdkit-plugin-dev pkg-config $(QEMU_PACKAGES) qemu-utils qemu-kvm \
libvirt-daemon-system libvirt-clients libvirt-dev make gcc jq curl \
iproute2 netcat-openbsd openssh-client wget git unzip sudo xz-utils file \
ovn-central ovn-host openvswitch-switch
ovn-central ovn-host openvswitch-switch dhcpcd-base
install-go:
@echo -e "\n....Installing Go 1.26.1 for $(ARCH) ($(GO_ARCH))...."
@echo -e "\n....Installing Go 1.26.2 for $(ARCH) ($(GO_ARCH))...."
@if [ ! -d "/usr/local/go" ]; then \
curl -L https://go.dev/dl/go1.26.1.linux-$(GO_ARCH).tar.gz | tar -C /usr/local -xz; \
curl -L https://go.dev/dl/go1.26.2.linux-$(GO_ARCH).tar.gz | tar -C /usr/local -xz; \
else \
echo "Go already installed in /usr/local/go"; \
fi
@@ -143,59 +173,21 @@ quickinstall: install-system install-go install-aws
@echo " Please ensure /usr/local/go/bin is in your PATH."
@echo " Installed: Go ($(GO_ARCH)), AWS CLI ($(AWS_ARCH)), QEMU ($(QEMU_PACKAGES))"
# Format all Go files in place
format:
gofmt -w .
# Lint all Go code via golangci-lint (replaces check-format, vet, gosec, staticcheck)
lint:
@echo "Running golangci-lint..."
$(_Q)golangci-lint run ./...
@echo " golangci-lint ok"
# Check that all Go files are formatted (CI-compatible, fails on diff)
check-format:
@echo "Checking gofmt..."
@UNFORMATTED=$$(gofmt -l .); \
if [ -n "$$UNFORMATTED" ]; then \
echo "Files not formatted:"; \
echo "$$UNFORMATTED"; \
echo "Run 'make format' to fix."; \
exit 1; \
fi
@echo " gofmt ok"
# Auto-fix all linter issues that have fixers
fix:
golangci-lint run --fix ./...
# Go vet (fails on issues, matches CI)
vet:
@echo "Running go vet..."
$(_Q)go vet ./...
@echo " go vet ok"
# Excluded: newexpr (replaces aws.String with new, not idiomatic for AWS SDK)
# Excluded: stringsbuilder (replaces string += in loops with strings.Builder, not worth the complexity for small loops)
GOFIX_EXCLUDE := -newexpr=false -stringsbuilder=false
# Apply go fix modernizations
modernize:
@echo "Applying go fix modernizations..."
go fix $(GOFIX_EXCLUDE) ./...
@echo " go fix applied"
# Check that code is modernized (CI-compatible, fails on diff)
check-modernize:
@echo "Checking go fix modernizations..."
@DIFF=$$(go fix $(GOFIX_EXCLUDE) -diff ./...); \
if [ -n "$$DIFF" ]; then \
echo "$$DIFF"; \
echo "Run 'make modernize' to fix."; \
exit 1; \
fi
@echo " go fix ok"
# Security checks — each tool fails the build on findings (matches CI).
# Reports are also saved to tests/ for review.
security-check:
@echo -e "\n....Running security checks for $(GO_PROJECT_NAME)...."
$(_Q)set -o pipefail && go tool govulncheck ./... $(_SECQ) tests/govulncheck-report.txt $(if $(QUIET),|| { cat tests/govulncheck-report.txt; exit 1; })
# Govulncheck — dependency vulnerability scanning (not covered by golangci-lint)
govulncheck:
@echo "Running govulncheck..."
$(_Q)go tool govulncheck ./...
@echo " govulncheck ok"
$(_Q)set -o pipefail && go tool gosec -quiet -exclude=G204,G304,G402,G117,G703,G705,G706 -exclude-generated -exclude-dir=cmd ./... $(_SECQ) tests/gosec-report.txt $(if $(QUIET),|| { cat tests/gosec-report.txt; exit 1; })
@echo " gosec ok"
$(_Q)set -o pipefail && go tool staticcheck -checks="all,-ST1000,-ST1003,-ST1016,-ST1020,-ST1021,-ST1022,-SA1019,-SA9005" ./... $(_SECQ) tests/staticcheck-report.txt $(if $(QUIET),|| { cat tests/staticcheck-report.txt; exit 1; })
@echo " staticcheck ok"
# Build release tarballs — use distro-ARCH for single arch, distro for both
distro: distro-amd64 distro-arm64
@@ -232,7 +224,23 @@ distro-arm64:
distro-clean:
rm -rf dist/
.PHONY: build build-ui go_build go_run preflight test test-cover test-race diff-coverage bench run clean \
# Ansible dev lifecycle (experimental, parallel to dev-*.sh / reset-dev-env.sh).
# See scripts/ansible/README.md and docs/development/improvements/ansible-dev-lifecycle.md.
ansible-dev-preflight:
cd scripts/ansible && ansible-playbook playbooks/dev-preflight.yml
ansible-dev-teardown:
cd scripts/ansible && ansible-playbook playbooks/dev-teardown.yml
ansible-dev-install:
cd scripts/ansible && ansible-playbook playbooks/dev-install.yml
ansible-dev-reset:
cd scripts/ansible && ansible-playbook playbooks/dev-reset.yml
.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 \
deploy reinstall clean \
install-system install-go install-aws quickinstall \
format check-format modernize check-modernize vet security-check \
distro distro-amd64 distro-arm64 distro-clean
lint fix govulncheck \
distro distro-amd64 distro-arm64 distro-clean \
ansible-dev-preflight ansible-dev-teardown ansible-dev-install ansible-dev-reset
+76 -18
View File
@@ -1,25 +1,54 @@
<p align="center">
<img src=".github/assets/banner.svg" alt="Spinifex — AWS-Compatible Infrastructure Stack" width="900">
</p>
<p align="center">
<a href="https://go.dev"><img src="https://img.shields.io/badge/Go-1.26+-00ADD8?style=flat-square&logo=go&logoColor=white" alt="Go"></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/License-AGPL--3.0-3fb950?style=flat-square" alt="License"></a>
<a href="https://docs.mulgadc.com"><img src="https://img.shields.io/badge/Docs-docs.mulgadc.com-DC2626?style=flat-square" alt="Docs"></a>
</p>
<p align="center">
<a href="#what-is-spinifex">What is Spinifex?</a> ·
<a href="#the-platform">Platform</a> ·
<a href="#core-components">Components</a> ·
<a href="#architecture-at-a-glance">Architecture</a> ·
<a href="#installation">Installation</a> ·
<a href="https://docs.mulgadc.com">Docs</a>
</p>
---
# Spinifex: An Open Source AWS-Compatible Stack for Bare-Metal, Edge, and On-Prem Deployments
(Developer Preview) - Spinifex developed by [Mulga Defense Corporation](https://mulgadc.com/) is an open source infrastructure platform that brings the core services of AWS—like EC2, VPC, EBS, and S3—to environments where running in the cloud isn't an option. Whether you're deploying to edge sites, private data-centers, or need to operate in low-connectivity or highly contested environments, Spinifex gives you AWS-style workflows on your own hardware.
Spinifex developed by [Mulga Defense Corporation](https://mulgadc.com) is an open source infrastructure platform that brings the core services of AWS—like EC2, VPC, EBS, and S3—to environments where running in the cloud isn't an option. Whether you're deploying to edge sites, private data-centers, or need to operate in low-connectivity or highly contested environments, Spinifex gives you AWS-style workflows on your own hardware.
## What is Spinifex?
Spinifex replicates essential AWS primitives—virtual machines, block volumes, and object storage—using lightweight, self-contained components that are easy to deploy and integrate.
It’s designed for developers and operators who need:
It's designed for developers and operators who need:
- Cloud-like infrastructure without the cloud
- Minimal dependencies, full control
- Drop-in compatibility with tools like the AWS CLI, SDKs, and Terraform
- A secure cloud environment you control and own the entire hardware, network and software stack
You can run Spinifex on a few servers in a rack, a field site, or anywhere centralized cloud services aren’t feasible.
You can run Spinifex on a few servers in a rack, a field site, or anywhere centralized cloud services aren't feasible.
## The Platform
<p align="center">
<img src=".github/assets/platform.svg" alt="The Mulga Platform — Hardware to Applications" width="900">
</p>
From commodity hardware up to unmodified AWS tooling, every layer is replaceable and yours to own.
## Core Components
### Spinifex (Compute Service – EC2 Alternative)
Spinifex is a minimal VM orchestration layer built on top of QEMU, exposing APIs similar to EC2. It manages lifecycle operations like start, stop, and terminate, using QEMU’s QMP interface. Designed to be straightforward and scriptable, Spinifex lets you launch VMs using the AWS CLI, SDKs, or Terraform—without needing Kubernetes or heavyweight orchestrators. Keep in mind, you can also setup a Kubernetes environment using Spinifex with underlying instances.
Spinifex is a minimal VM orchestration layer built on top of QEMU, exposing APIs similar to EC2. It manages lifecycle operations like start, stop, and terminate, using QEMU's QMP interface. Designed to be straightforward and scriptable, Spinifex lets you launch VMs using the AWS CLI, SDKs, or Terraform—without needing Kubernetes or heavyweight orchestrators. Keep in mind, you can also setup a Kubernetes environment using Spinifex with underlying instances.
- EC2-like VM management on bare metal
- Launches with cloud-init metadata support
@@ -27,7 +56,7 @@ Spinifex is a minimal VM orchestration layer built on top of QEMU, exposing APIs
### Viperblock (Block Storage – EBS Alternative)
[Viperblock](https://github.com/mulgadc/viperblock) is a high-performance, WAL-backed block storage service that replicates volumes across multiple nodes. It’s built for reliability and speed, with support for snapshots, recovery, and direct connection to QEMU instances using NBD or virtio-blk.
[Viperblock](https://github.com/mulgadc/viperblock) is a high-performance, WAL-backed block storage service that replicates volumes across multiple nodes. It's built for reliability and speed, with support for snapshots, recovery, and direct connection to QEMU instances using NBD or virtio-blk.
- Fast, durable virtual disks
- Replication for resilience
@@ -43,39 +72,48 @@ Spinifex is a minimal VM orchestration layer built on top of QEMU, exposing APIs
- Multipart uploads, streaming reads/writes
- Data redundancy with Reed-Solomon encoding
## Architecture at a Glance
<p align="center">
<img src=".github/assets/architecture.svg" alt="Spinifex message-driven architecture — AWS Gateway, NATS bus, service daemons" width="900">
</p>
Every AWS API call is authenticated at the gateway, published to a NATS subject, and answered by whichever daemon claims it. Daemons are stateless — scale horizontally by starting more. No etcd, no Kubernetes, no external control plane. Deep dive: **[`docs/DESIGN.md`](docs/DESIGN.md)**.
## Key Features
AWS-Compatible Interfaces – Provision infrastructure with awscli, Terraform, or SDKs you already use.
- Designed for Control – Run on your own terms, whether that’s in a datacenter, remote site, or sensitive environment.
- Designed for Control – Run on your own terms, whether that's in a datacenter, remote site, or sensitive environment.
- Minimal Dependencies – Each service is standalone and avoids complex orchestration layers.
- Works Offline – No reliance on centralized cloud services or external networks.
- Open Source – Licensed under Apache 2.0. Fork it, extend it, or deploy it as-is.
- Open Source – Licensed under AGPL 3.0. Fork it, extend it, or deploy it as-is.
## Installation
Installation requires an Ubuntu / Debian system. See the detailed documentation at [https://docs.mulgadc.com](https://docs.mulgadc.com/) for maintaining and installing Spinifex.
Installation requires an Ubuntu / Debian system. See the detailed documentation at [docs.mulgadc.com](https://docs.mulgadc.com) for maintaining and installing Spinifex.
### Single node install
### Single Node Install
The installation is straightforward to set up and running on a single node for testing purposes.
```bash
curl -fsSL https://install.mulgadc.com | bash
sudo /usr/local/share/spinifex/setup-ovn.sh --management
sudo spx admin init --node node1 --nodes 1
sudo systemctl start spinifex.target
export AWS_PROFILE=spinifex
export AWS_REGION=ap-southeast-2-sc
```
```bash
curl https://install.mulgadc.com/ | bash
spx admin init --region $AWS_REGION
aws ec2 describe-instance-types
```
### Development Setup
For a complete development environment see the [Source Install](https://docs.mulgadc.com/docs/source-install/) documentation
For a complete development environment see the [Source Install](https://docs.mulgadc.com/docs/install-source) documentation
### Component Repositories
@@ -86,6 +124,26 @@ Spinifex coordinates these independent components:
Each component can be developed independently. See component-specific documentation for focused development guides.
## Spinifex UI
Spinifex ships with a built-in web console — an optional alternative to the AWS CLI, SDKs, and Terraform. If you're familiar with the AWS Management Console, the Spinifex UI fills the same role: a browser-based view of your instances, volumes, buckets, VPCs, and IAM resources, without leaving your own network.
<p align="center">
<img src=".github/assets/spinifex-ui.jpg" alt="Spinifex web console — dashboard view" width="900">
</p>
The console is served by each node on port `3000` over TLS, and becomes available as soon as `spinifex.target` is up:
```bash
open https://YOUR_NODE_IP:3000
```
- **Same API, different surface.** Every action in the UI is the same AWS SigV4 call the CLI makes — so RBAC, audit trails, and IAM policies apply uniformly.
- **Single sign-on against your AWS credentials.** Log in with the access keys from `~/.aws/credentials` on the node where Spinifex is installed — no separate user database.
- **Self-hosted, works offline.** The UI is embedded in the Spinifex binary and served from the node itself. No external CDN, no analytics calls, no cloud dependency.
For the full walkthrough — first-time TLS certificate trust, login, and feature tour — see [**Launching the Web UI**](https://docs.mulgadc.com/docs/setting-up-your-cluster#7-launching-the-web-ui) in the cluster setup guide.
## Development Philosophy
### Built by Engineers, For Engineers
@@ -121,4 +179,4 @@ This hybrid approach ensures Spinifex benefits from both proven engineering expe
## License
Spinifex is open source under the Apache 2.0 License. You're free to use, modify, and deploy it—anywhere you need reliable infrastructure without depending on centralized cloud platforms.
Spinifex is open source under the [GNU Affero General Public License v3.0](LICENSE). You're free to use, modify, and deploy it anywhere you need reliable infrastructure without depending on centralized cloud platforms.
+1
View File
@@ -42,6 +42,7 @@ RUN cd viperblock && go build -o /out/nbdkit-viperblock-plugin.so -buildmode=c-s
RUN cp spinifex/scripts/setup.sh /out/setup.sh 2>/dev/null || true
RUN cp spinifex/scripts/setup-ovn.sh /out/setup-ovn.sh
RUN mkdir -p /out/systemd && cp spinifex/build/systemd/* /out/systemd/
RUN mkdir -p /out/scripts && cp spinifex/build/scripts/* /out/scripts/
RUN cp spinifex/build/logrotate/spinifex /out/logrotate-spinifex
FROM scratch AS export
+27
View File
@@ -0,0 +1,27 @@
#!/bin/sh
# Helper script for spinifex-predastore.service
# Detects host, port, backend, and node-id from spinifex.toml
# then execs spx. Falls back to single-node defaults if no config found.
CONF=/etc/spinifex/spinifex.toml
BIND="0.0.0.0:8443"
NODE_ID="0"
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
NODE_ID="$N"
fi
fi
export SPINIFEX_PREDASTORE_HOST="${BIND%%:*}"
export SPINIFEX_PREDASTORE_PORT="${BIND##*:}"
export SPINIFEX_PREDASTORE_NODE_ID="$NODE_ID"
exec /usr/local/bin/spx service predastore start
+37
View File
@@ -0,0 +1,37 @@
#!/bin/sh
# Wait for NATS to be reachable before starting a dependent service.
# Reads the NATS address from spinifex.toml [nodes.*.nats] section,
# falls back to nats.conf, then to 127.0.0.1:4222.
CONF=/etc/spinifex/spinifex.toml
NATS_CONF=/etc/spinifex/nats/nats.conf
NATS_HOST=""
NATS_PORT="4222"
TIMEOUT=30
# Try spinifex.toml first
if [ -f "$CONF" ]; then
NATS_BIND=$(awk -F'"' '/\[nodes\..*\.nats\]/{f=1} f&&/^host/{print $2;exit}' "$CONF")
if [ -n "$NATS_BIND" ]; then
NATS_HOST="${NATS_BIND%%:*}"
NATS_PORT="${NATS_BIND##*:}"
fi
fi
# Fall back to nats.conf
if [ -z "$NATS_HOST" ] && [ -f "$NATS_CONF" ]; then
NATS_HOST=$(grep "^listen:" "$NATS_CONF" | sed 's/listen: *//;s/:.*//')
fi
# Final fallback
NATS_HOST="${NATS_HOST:-127.0.0.1}"
for i in $(seq 1 "$TIMEOUT"); do
if nc -z "$NATS_HOST" "$NATS_PORT" 2>/dev/null; then
exit 0
fi
sleep 1
done
echo "Timed out waiting for NATS at $NATS_HOST:$NATS_PORT" >&2
exit 1
+36 -4
View File
@@ -2,21 +2,53 @@
Description=Spinifex AWS Gateway
PartOf=spinifex.target
After=spinifex-nats.service spinifex-daemon.service
Requires=spinifex-nats.service
Wants=spinifex-nats.service
[Service]
Type=simple
User=spinifex
User=spinifex-gw
Group=spinifex
ExecStartPre=/bin/sh -c 'for i in $(seq 1 30); do nc -z 127.0.0.1 4222 && exit 0; sleep 1; done; exit 1'
ExecStartPre=/var/lib/spinifex/wait-for-nats.sh
ExecStart=/usr/local/bin/spx service awsgw start
Restart=on-failure
RestartSec=5
OOMScoreAdjust=-500
Environment=SPINIFEX_CONFIG_PATH=/etc/spinifex/spinifex.toml
Environment=SPINIFEX_BASE_DIR=/var/lib/spinifex/
Environment=SPINIFEX_BASE_DIR=/var/lib/spinifex/awsgw/
Environment=SPINIFEX_AWSGW_TLS_CERT=/etc/spinifex/server.pem
Environment=SPINIFEX_AWSGW_TLS_KEY=/etc/spinifex/server.key
# Filesystem access
NoNewPrivileges=yes
ProtectSystem=strict
PrivateDevices=yes
ReadOnlyPaths=/etc/spinifex/spinifex.toml /etc/spinifex/master.key /etc/spinifex/server.pem /etc/spinifex/server.key /etc/spinifex/ca.pem
ReadWritePaths=/var/lib/spinifex/awsgw /var/log/spinifex
# Hardening
ProtectHome=yes
PrivateTmp=yes
RestrictSUIDSGID=yes
LockPersonality=yes
RestrictRealtime=yes
RestrictNamespaces=yes
ProtectClock=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectProc=invisible
SystemCallArchitectures=native
MemoryDenyWriteExecute=yes
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
CapabilityBoundingSet=
ProtectHostname=yes
KeyringMode=private
RemoveIPC=yes
LimitCORE=0
LimitNOFILE=65536
LimitNPROC=4096
[Install]
WantedBy=spinifex.target
+38 -4
View File
@@ -1,14 +1,14 @@
[Unit]
Description=Spinifex Daemon (VM Orchestration)
PartOf=spinifex.target
After=spinifex-nats.service spinifex-viperblock.service
Requires=spinifex-nats.service
After=spinifex-nats.service spinifex-viperblock.service openvswitch-switch.service ovn-controller.service
Wants=spinifex-nats.service
[Service]
Type=simple
User=spinifex
User=spinifex-daemon
Group=spinifex
ExecStartPre=/bin/sh -c 'for i in $(seq 1 30); do nc -z 127.0.0.1 4222 && exit 0; sleep 1; done; exit 1'
ExecStartPre=/var/lib/spinifex/wait-for-nats.sh
ExecStart=/usr/local/bin/spx service spinifex start
Restart=on-failure
RestartSec=5
@@ -17,5 +17,39 @@ Environment=SPINIFEX_CONFIG_PATH=/etc/spinifex/spinifex.toml
Environment=SPINIFEX_BASE_DIR=/var/lib/spinifex/spinifex/
Environment=SPINIFEX_WAL_DIR=/var/lib/spinifex/spinifex/
# Filesystem access — NoNewPrivileges omitted (daemon uses sudo for ip, ovs-vsctl)
ProtectSystem=full
PrivateDevices=no
DevicePolicy=closed
DeviceAllow=/dev/kvm rw
DeviceAllow=/dev/net/tun rw
ReadOnlyPaths=/etc/spinifex/spinifex.toml /etc/spinifex/server.pem /etc/spinifex/server.key /etc/spinifex/ca.pem
ReadWritePaths=/var/lib/spinifex/spinifex /var/log/spinifex /run/spinifex /run/spinifex/nbd
# Hardening
ProtectHome=yes
PrivateTmp=yes
RestrictSUIDSGID=yes
LockPersonality=yes
RestrictRealtime=yes
RestrictNamespaces=yes
ProtectClock=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectProc=invisible
SystemCallArchitectures=native
MemoryDenyWriteExecute=yes
# DHCP lease acquisition moved to spinifex-vpcd (see dhcp-allocate-error.md);
# the daemon no longer needs AF_PACKET or the softened kernel protections.
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
ProtectHostname=yes
KeyringMode=private
RemoveIPC=yes
LimitCORE=0
LimitNOFILE=65536
LimitNPROC=4096
[Install]
WantedBy=spinifex.target
+32 -1
View File
@@ -5,7 +5,7 @@ Before=spinifex-predastore.service spinifex-daemon.service spinifex-awsgw.servic
[Service]
Type=simple
User=spinifex
User=spinifex-nats
Group=spinifex
ExecStart=/usr/local/bin/spx service nats start
Restart=on-failure
@@ -13,5 +13,36 @@ RestartSec=5
OOMScoreAdjust=-500
Environment=SPINIFEX_CONFIG_PATH=/etc/spinifex/nats/nats.conf
# Filesystem access
NoNewPrivileges=yes
ProtectSystem=strict
PrivateDevices=yes
ReadOnlyPaths=/etc/spinifex/nats /etc/spinifex/server.pem /etc/spinifex/server.key /etc/spinifex/ca.pem
ReadWritePaths=/var/lib/spinifex/nats /var/log/spinifex
# Hardening
ProtectHome=yes
PrivateTmp=yes
RestrictSUIDSGID=yes
LockPersonality=yes
RestrictRealtime=yes
RestrictNamespaces=yes
ProtectClock=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectProc=invisible
SystemCallArchitectures=native
MemoryDenyWriteExecute=yes
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
CapabilityBoundingSet=
ProtectHostname=yes
KeyringMode=private
RemoveIPC=yes
LimitCORE=0
LimitNOFILE=65536
LimitNPROC=4096
[Install]
WantedBy=spinifex.target
+35 -4
View File
@@ -2,14 +2,14 @@
Description=Spinifex Predastore (S3-compatible Object Storage)
PartOf=spinifex.target
After=spinifex-nats.service
Requires=spinifex-nats.service
Wants=spinifex-nats.service
[Service]
Type=simple
User=spinifex
User=spinifex-storage
Group=spinifex
ExecStartPre=/bin/sh -c 'for i in $(seq 1 30); do nc -z 127.0.0.1 4222 && exit 0; sleep 1; done; exit 1'
ExecStart=/usr/local/bin/spx service predastore start
ExecStartPre=/var/lib/spinifex/wait-for-nats.sh
ExecStart=/var/lib/spinifex/predastore-start.sh
Restart=on-failure
RestartSec=5
OOMScoreAdjust=-500
@@ -18,5 +18,36 @@ 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
# Filesystem access
NoNewPrivileges=yes
ProtectSystem=strict
PrivateDevices=yes
ReadOnlyPaths=/etc/spinifex/predastore /etc/spinifex/spinifex.toml /etc/spinifex/master.key /etc/spinifex/server.pem /etc/spinifex/server.key /etc/spinifex/ca.pem
ReadWritePaths=/var/lib/spinifex/predastore /var/log/spinifex
# Hardening
ProtectHome=yes
PrivateTmp=yes
RestrictSUIDSGID=yes
LockPersonality=yes
RestrictRealtime=yes
RestrictNamespaces=yes
ProtectClock=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectProc=invisible
SystemCallArchitectures=native
MemoryDenyWriteExecute=yes
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
CapabilityBoundingSet=
ProtectHostname=yes
KeyringMode=private
RemoveIPC=yes
LimitCORE=0
LimitNOFILE=65536
LimitNPROC=4096
[Install]
WantedBy=spinifex.target
+34 -1
View File
@@ -5,12 +5,45 @@ After=spinifex-awsgw.service
[Service]
Type=simple
User=spinifex
User=spinifex-ui
Group=spinifex
ExecStart=/usr/local/bin/spx service spinifex-ui start
Restart=on-failure
RestartSec=5
OOMScoreAdjust=-500
Environment=SPINIFEX_UI_TLS_CERT=/etc/spinifex/server.pem
Environment=SPINIFEX_UI_TLS_KEY=/etc/spinifex/server.key
# Filesystem access
NoNewPrivileges=yes
ProtectSystem=strict
PrivateDevices=yes
ReadOnlyPaths=/etc/spinifex/server.pem /etc/spinifex/server.key
ReadWritePaths=/var/log/spinifex
# Hardening
ProtectHome=yes
PrivateTmp=yes
RestrictSUIDSGID=yes
LockPersonality=yes
RestrictRealtime=yes
RestrictNamespaces=yes
ProtectClock=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectProc=invisible
SystemCallArchitectures=native
MemoryDenyWriteExecute=yes
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
CapabilityBoundingSet=
ProtectHostname=yes
KeyringMode=private
RemoveIPC=yes
LimitCORE=0
LimitNOFILE=65536
LimitNPROC=4096
[Install]
WantedBy=spinifex.target
+34 -3
View File
@@ -2,13 +2,13 @@
Description=Spinifex Viperblock (Block Storage)
PartOf=spinifex.target
After=spinifex-nats.service spinifex-predastore.service
Requires=spinifex-nats.service
Wants=spinifex-nats.service
[Service]
Type=simple
User=spinifex
User=spinifex-viperblock
Group=spinifex
ExecStartPre=/bin/sh -c 'for i in $(seq 1 30); do nc -z 127.0.0.1 4222 && exit 0; sleep 1; done; exit 1'
ExecStartPre=/var/lib/spinifex/wait-for-nats.sh
ExecStart=/usr/local/bin/spx service viperblock start
Restart=on-failure
RestartSec=5
@@ -17,5 +17,36 @@ EnvironmentFile=/etc/spinifex/systemd.env
Environment=SPINIFEX_CONFIG_PATH=/etc/spinifex/spinifex.toml
Environment=SPINIFEX_BASE_DIR=/var/lib/spinifex/viperblock/
# Filesystem access
NoNewPrivileges=yes
ProtectSystem=strict
PrivateDevices=yes
ReadOnlyPaths=/etc/spinifex/spinifex.toml /etc/spinifex/systemd.env /etc/spinifex/ca.pem
ReadWritePaths=/var/lib/spinifex/viperblock /var/log/spinifex /run/spinifex/nbd
# Hardening
ProtectHome=yes
PrivateTmp=yes
RestrictSUIDSGID=yes
LockPersonality=yes
RestrictRealtime=yes
RestrictNamespaces=yes
ProtectClock=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectProc=invisible
SystemCallArchitectures=native
MemoryDenyWriteExecute=yes
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
CapabilityBoundingSet=
ProtectHostname=yes
KeyringMode=private
RemoveIPC=yes
LimitCORE=0
LimitNOFILE=65536
LimitNPROC=4096
[Install]
WantedBy=spinifex.target
+44 -4
View File
@@ -1,19 +1,59 @@
[Unit]
Description=Spinifex VPC Daemon (OVN Networking)
PartOf=spinifex.target
After=spinifex-nats.service spinifex-daemon.service
Requires=spinifex-nats.service
After=spinifex-nats.service spinifex-daemon.service systemd-networkd.service openvswitch-switch.service ovn-controller.service
Wants=spinifex-nats.service ovn-controller.service
[Service]
Type=simple
User=spinifex
User=spinifex-vpcd
Group=spinifex
ExecStartPre=/bin/sh -c 'for i in $(seq 1 30); do nc -z 127.0.0.1 4222 && exit 0; sleep 1; done; exit 1'
ExecStartPre=/var/lib/spinifex/wait-for-nats.sh
ExecStart=/usr/local/bin/spx service vpcd start
Restart=on-failure
RestartSec=5
OOMScoreAdjust=-500
Environment=SPINIFEX_CONFIG_PATH=/etc/spinifex/spinifex.toml
Environment=SPINIFEX_BASE_DIR=/var/lib/spinifex/vpcd/
# Filesystem access — NoNewPrivileges omitted (vpcd uses sudo for ovs-vsctl, ovn-nbctl, systemctl)
ProtectSystem=full
PrivateDevices=no
ReadOnlyPaths=/etc/spinifex/spinifex.toml /etc/spinifex/ca.pem
ReadWritePaths=/var/lib/spinifex/vpcd /var/log/spinifex
# Hardening
ProtectHome=yes
PrivateTmp=yes
RestrictSUIDSGID=no
LockPersonality=yes
RestrictRealtime=yes
RestrictNamespaces=yes
ProtectClock=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectProc=invisible
SystemCallArchitectures=native
MemoryDenyWriteExecute=no
# AF_PACKET: required by the in-process DHCP client (github.com/insomniacslk/dhcp
# / nclient4) for raw L2 BOOTP on br-wan before an IP is configured on the
# interface. See docs/development/bugs/dhcp-allocate-error.md.
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK AF_PACKET
# CAP_NET_RAW: open AF_PACKET sockets.
# CAP_NET_BIND_SERVICE: covers unicast RENEW to UDP :68.
# CAP_NET_ADMIN: already used for ovs-vsctl / ip link wiring on the bridge.
# Note, disabled for now, sudo issue with ovs
AmbientCapabilities=CAP_NET_ADMIN CAP_NET_RAW CAP_NET_BIND_SERVICE CAP_SETUID CAP_SETGID
CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_RAW CAP_NET_BIND_SERVICE CAP_SETUID CAP_SETGID
NoNewPrivileges=no
ProtectHostname=yes
KeyringMode=private
RemoveIPC=yes
LimitCORE=0
LimitNOFILE=65536
LimitNPROC=4096
[Install]
WantedBy=spinifex.target
+1 -1
View File
@@ -2,7 +2,7 @@
Description=Spinifex Infrastructure Platform
After=network-online.target
Wants=network-online.target
Wants=spinifex-nats.service spinifex-predastore.service spinifex-viperblock.service spinifex-daemon.service spinifex-awsgw.service spinifex-vpcd.service spinifex-ui.service
Wants=spinifex-nats.service spinifex-predastore.service spinifex-viperblock.service spinifex-daemon.service spinifex-awsgw.service spinifex-vpcd.service spinifex-ui.service openvswitch-switch.service ovn-controller.service
[Install]
WantedBy=multi-user.target
+346
View File
@@ -0,0 +1,346 @@
/*
Copyright © 2026 Mulga Defense Corporation
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// Package autoinstall reads headless install parameters from environment
// variables exported by spinifex-init.sh from the kernel cmdline.
// Parameters are set in the GRUB "Headless" menu entry in grub.cfg.
package autoinstall
import (
"fmt"
"log/slog"
"net"
"os"
"os/exec"
"sort"
"strconv"
"strings"
"syscall"
"time"
"github.com/mulgadc/spinifex/cmd/installer/install"
)
// Load returns an install.Config built from SPINIFEX_* environment variables,
// or nil if SPINIFEX_AUTO is not set to "1" (interactive mode).
func Load() (*install.Config, error) {
if os.Getenv("SPINIFEX_AUTO") != "1" {
return nil, nil
}
slog.Info("autoinstall: headless mode enabled via kernel cmdline")
cfg, err := buildConfig()
if err != nil {
return nil, fmt.Errorf("autoinstall config: %w", err)
}
return cfg, nil
}
// EjectAndReboot ejects the boot device (best-effort) then reboots.
func EjectAndReboot() {
srcDev, _ := os.ReadFile("/run/iso-dev")
if dev := strings.TrimSpace(string(srcDev)); dev != "" {
slog.Info("autoinstall: ejecting boot device", "device", dev)
_ = exec.Command("eject", dev).Run()
}
fmt.Println()
fmt.Println("Installation complete.")
fmt.Println("Remove the USB drive now if it was not ejected automatically.")
fmt.Println("Rebooting in 10 seconds...")
time.Sleep(10 * time.Second)
// Use the kernel syscall directly — the live environment runs spinifex-init
// as PID 1 (not systemd), so exec("reboot") fails trying to reach D-Bus.
if err := syscall.Reboot(syscall.LINUX_REBOOT_CMD_RESTART); err != nil {
slog.Error("autoinstall: reboot syscall failed", "err", err)
}
}
func buildConfig() (*install.Config, error) {
password := os.Getenv("SPINIFEX_PASSWORD")
if password == "" {
return nil, fmt.Errorf("SPINIFEX_PASSWORD is required")
}
hostname := os.Getenv("SPINIFEX_HOSTNAME")
if hostname == "" {
hostname = "spinifex-node"
}
disk, err := resolveDisk(os.Getenv("SPINIFEX_DISK"))
if err != nil {
return nil, fmt.Errorf("disk: %w", err)
}
wanIface, err := resolveNIC(os.Getenv("SPINIFEX_WAN_IFACE"), "")
if err != nil {
return nil, fmt.Errorf("WAN NIC: %w", err)
}
cfg := &install.Config{
Disk: disk,
Hostname: hostname,
RootPassword: password,
// Optional on the headless path — empty means no --email passed to
// spx admin init, which omits the operator identity from telemetry.
Email: strings.TrimSpace(os.Getenv("SPINIFEX_EMAIL")),
WANInterface: wanIface,
WANDHCPMode: strings.ToLower(os.Getenv("SPINIFEX_WAN_MODE")) != "static",
}
if !cfg.WANDHCPMode {
ip := os.Getenv("SPINIFEX_WAN_IP")
mask := os.Getenv("SPINIFEX_WAN_MASK")
gw := os.Getenv("SPINIFEX_WAN_GW")
if ip == "" || mask == "" || gw == "" {
return nil, fmt.Errorf("SPINIFEX_WAN_IP, SPINIFEX_WAN_MASK, SPINIFEX_WAN_GW required for static mode")
}
cfg.WANAddress = ip
cfg.WANMask = mask
cfg.WANGateway = gw
if dns := os.Getenv("SPINIFEX_WAN_DNS"); dns != "" {
cfg.WANDNS = strings.Split(dns, ",")
}
}
if lanIface := os.Getenv("SPINIFEX_LAN_IFACE"); lanIface != "" {
lan, err := resolveNIC(lanIface, wanIface)
if err != nil {
return nil, fmt.Errorf("LAN NIC: %w", err)
}
cfg.LANInterface = lan
cfg.LANDHCPMode = strings.ToLower(os.Getenv("SPINIFEX_LAN_MODE")) != "static"
if !cfg.LANDHCPMode {
cfg.LANAddress = os.Getenv("SPINIFEX_LAN_IP")
cfg.LANMask = os.Getenv("SPINIFEX_LAN_MASK")
if dns := os.Getenv("SPINIFEX_LAN_DNS"); dns != "" {
cfg.LANDNS = strings.Split(dns, ",")
}
}
}
role := strings.ToLower(os.Getenv("SPINIFEX_ROLE"))
if role == "" {
role = "init"
}
cfg.ClusterRole = role
if role == "join" {
joinAddr := os.Getenv("SPINIFEX_JOIN_ADDR")
if joinAddr == "" {
return nil, fmt.Errorf("SPINIFEX_JOIN_ADDR required when SPINIFEX_ROLE=join")
}
cfg.JoinAddr = joinAddr
}
return cfg, nil
}
// resolveDisk maps the spx_disk value from grub.cfg to a block device path.
//
// Supported values:
//
// auto — use the only non-removable disk; fail if multiple found
// largest — largest non-removable disk (explicit opt-in)
// smallest — smallest non-removable disk (typical OS-on-SSD pattern)
// nvme — the only NVMe disk; fail if multiple found
// /dev/sda (etc.) — exact device path; fail if not present
func resolveDisk(target string) (string, error) {
switch strings.ToLower(target) {
case "", "auto":
return singleDisk()
case "largest":
return diskBySize(true)
case "smallest":
return diskBySize(false)
case "nvme":
return nvmeDisk()
default:
if _, err := os.Stat(target); err != nil {
return "", fmt.Errorf("%q not found", target)
}
return target, nil
}
}
type diskCandidate struct {
dev string
bytes int64
}
// nonRemovableDisks returns all non-removable, non-virtual block devices.
func nonRemovableDisks() ([]diskCandidate, error) {
entries, err := os.ReadDir("/sys/block")
if err != nil {
return nil, err
}
var out []diskCandidate
for _, e := range entries {
dev := e.Name()
switch {
case strings.HasPrefix(dev, "loop"),
strings.HasPrefix(dev, "ram"),
strings.HasPrefix(dev, "dm-"),
strings.HasPrefix(dev, "sr"):
continue
}
removable, _ := os.ReadFile("/sys/block/" + dev + "/removable")
if strings.TrimSpace(string(removable)) == "1" {
continue
}
sizeRaw, _ := os.ReadFile("/sys/block/" + dev + "/size")
sectors, parseErr := strconv.ParseInt(strings.TrimSpace(string(sizeRaw)), 10, 64)
if parseErr != nil || sectors == 0 {
continue
}
out = append(out, diskCandidate{dev: dev, bytes: sectors * 512})
}
return out, nil
}
// diskList returns a human-readable list of candidates for error messages.
func diskList(disks []diskCandidate) string {
lines := make([]string, len(disks))
for i, d := range disks {
lines[i] = fmt.Sprintf(" /dev/%s (%dG)", d.dev, d.bytes>>30)
}
return strings.Join(lines, "\n")
}
// singleDisk selects the only non-removable disk, or fails listing all found.
func singleDisk() (string, error) {
disks, err := nonRemovableDisks()
if err != nil {
return "", err
}
switch len(disks) {
case 0:
return "", fmt.Errorf("no non-removable disks found")
case 1:
slog.Info("autoinstall: single disk selected", "disk", disks[0].dev, "size_gb", disks[0].bytes>>30)
return "/dev/" + disks[0].dev, nil
default:
return "", fmt.Errorf(
"multiple disks found — set SPINIFEX_DISK to one of:\n%s\n"+
" largest (largest disk)\n"+
" smallest (smallest disk)\n"+
" nvme (NVMe only)\n"+
" /dev/sdX (exact path)",
diskList(disks),
)
}
}
// diskBySize selects the largest (largest=true) or smallest non-removable disk.
func diskBySize(largest bool) (string, error) {
disks, err := nonRemovableDisks()
if err != nil {
return "", err
}
if len(disks) == 0 {
return "", fmt.Errorf("no non-removable disks found")
}
sort.Slice(disks, func(i, j int) bool {
if largest {
return disks[i].bytes > disks[j].bytes
}
return disks[i].bytes < disks[j].bytes
})
label := "smallest"
if largest {
label = "largest"
}
slog.Info("autoinstall: disk selected by size", "mode", label, "disk", disks[0].dev, "size_gb", disks[0].bytes>>30)
return "/dev/" + disks[0].dev, nil
}
// nvmeDisk selects the only NVMe disk, or fails listing all NVMe disks found.
func nvmeDisk() (string, error) {
disks, err := nonRemovableDisks()
if err != nil {
return "", err
}
var nvmes []diskCandidate
for _, d := range disks {
if strings.HasPrefix(d.dev, "nvme") {
nvmes = append(nvmes, d)
}
}
switch len(nvmes) {
case 0:
return "", fmt.Errorf("no NVMe disks found")
case 1:
slog.Info("autoinstall: NVMe disk selected", "disk", nvmes[0].dev, "size_gb", nvmes[0].bytes>>30)
return "/dev/" + nvmes[0].dev, nil
default:
return "", fmt.Errorf(
"multiple NVMe disks found — set SPINIFEX_DISK to one of:\n%s",
diskList(nvmes),
)
}
}
// virtualNICPrefixes lists interface name prefixes that identify non-physical
// interfaces (bridges, tunnels, container/OVS veth pairs, etc.). These are
// skipped when auto-selecting a NIC so we don't configure docker0 or
// ovs-system as the management interface on a machine that previously ran
// Docker or OVN.
var virtualNICPrefixes = []string{
"docker", "veth", "virbr", "br-", "ovs-", "vxlan", "genev", "tun", "tap",
}
// resolveNIC returns the interface name to use. "auto" or empty picks the
// first physical (non-loopback, non-virtual) interface that is not exclude.
func resolveNIC(name, exclude string) (string, error) {
if name != "" && name != "auto" {
return name, nil
}
ifaces, err := net.Interfaces()
if err != nil {
return "", err
}
for _, iface := range ifaces {
if iface.Flags&net.FlagLoopback != 0 {
continue
}
// Require broadcast capability — filters out point-to-point tunnels.
if iface.Flags&net.FlagBroadcast == 0 {
continue
}
// Require a MAC address — virtual/tunnel interfaces have none.
if len(iface.HardwareAddr) == 0 {
continue
}
if iface.Name == exclude {
continue
}
// Skip known virtual interface prefixes.
virtual := false
for _, pfx := range virtualNICPrefixes {
if strings.HasPrefix(iface.Name, pfx) {
virtual = true
break
}
}
if virtual {
continue
}
slog.Info("autoinstall: NIC resolved", "mode", "auto", "selected", iface.Name)
return iface.Name, nil
}
return "", fmt.Errorf("no suitable physical NIC found (non-loopback, broadcast-capable, with MAC)")
}
+56
View File
@@ -0,0 +1,56 @@
/*
Copyright © 2026 Mulga Defense Corporation
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// Package branding centralises Mulga/Spinifex visual identity constants used
// by the installer TUI.
//
// TODO: Replace placeholder values with official Mulga/Spinifex assets once
// the brand guide and logo files are located.
package branding
import "github.com/charmbracelet/lipgloss"
// Logo is the ASCII art rendered on the installer welcome screen.
// TODO: Replace with official Spinifex/Mulga ASCII logo.
const Logo = `
███████╗██████╗ ██╗███╗ ██╗██╗███████╗███████╗██╗ ██╗
██╔════╝██╔══██╗██║████╗ ██║██║██╔════╝██╔════╝╚██╗██╔╝
███████╗██████╔╝██║██╔██╗ ██║██║█████╗ █████╗ ╚███╔╝
╚════██║██╔═══╝ ██║██║╚██╗██║██║██╔══╝ ██╔══╝ ██╔██╗
███████║██║ ██║██║ ╚████║██║██║ ███████╗██╔╝ ██╗
╚══════╝╚═╝ ╚═╝╚═╝ ╚═══╝╚═╝╚═╝ ╚══════╝╚═╝ ╚═╝`
// Subtitle is displayed beneath the logo on the welcome screen.
const Subtitle = "Bare-Metal Node Provisioner"
// Publisher is shown in the welcome screen footer.
const Publisher = "Mulga Defense Corporation"
// Mulga color palette.
// TODO: Replace with official hex values from the Mulga brand guide.
const (
ColorPrimary = lipgloss.Color("#00A3E0") // Mulga blue (placeholder)
ColorAccent = lipgloss.Color("#FF6B00") // Mulga orange (placeholder)
ColorBackground = lipgloss.Color("#0D1117") // Dark background
ColorSurface = lipgloss.Color("#161B22") // Slightly lighter surface
ColorText = lipgloss.Color("#E6EDF3") // Light text
ColorMuted = lipgloss.Color("#8B949E") // Muted / secondary text
ColorSuccess = lipgloss.Color("#3FB950") // Green
ColorWarning = lipgloss.Color("#D29922") // Amber
ColorError = lipgloss.Color("#F85149") // Red
ColorBorder = lipgloss.Color("#30363D") // Border
)
+226
View File
@@ -0,0 +1,226 @@
/*
Copyright © 2026 Mulga Defense Corporation
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// Package firstboot writes the oneshot systemd service and configuration that
// completes Spinifex provisioning on the first real boot after installation.
package firstboot
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/mulgadc/spinifex/cmd/installer/systemd"
)
// Config holds the values the firstboot service needs to configure the node.
type Config struct {
Hostname string
// EncapIP is the Geneve tunnel IP for OVN. Set to the LAN bridge IP when a
// dedicated LAN NIC is present, otherwise the WAN bridge IP. Empty when DHCP
// is used — setup-ovn.sh auto-detects the IP from the default route in that case.
EncapIP string
// ClusterRole is "init" or "join".
ClusterRole string
// JoinAddr is host:port of the primary node, only used when ClusterRole is "join".
JoinAddr string
// Email is the operator email collected by the TUI or SPINIFEX_EMAIL on
// the headless path. Passed to `spx admin init --email=<value>` when set;
// omitted entirely when empty.
Email string
}
// Write drops the firstboot script and systemd unit into root, which should be
// the path of the installed system's root filesystem (e.g. /mnt/spinifex-install).
func Write(root string, cfg Config) error {
if err := writeScript(root, cfg); err != nil {
return fmt.Errorf("firstboot script: %w", err)
}
if err := systemd.WriteFirstbootUnit(root); err != nil {
return fmt.Errorf("firstboot unit: %w", err)
}
if err := systemd.EnableUnit(root, "spinifex-firstboot.service"); err != nil {
return err
}
// Banner unit is written but NOT enabled here. firstboot enables it
// (along with spinifex.target) at the end of its run, so on the first
// boot the only spinifex unit running is firstboot itself — no implicit
// pull of spinifex.target via the banner's Wants= relationship, which
// was the root cause of the boot-time race against firstboot.
if err := systemd.WriteBannerUnit(root); err != nil {
return fmt.Errorf("banner unit: %w", err)
}
return systemd.WriteGettyDropIn(root)
}
func writeScript(root string, cfg Config) error {
clusterCmd := buildClusterCmd(cfg)
// --encap-ip is optional: when DHCP is used the IP is unknown at install time
// and setup-ovn.sh auto-detects it from the default route at boot.
setupOVN := "/usr/local/bin/setup-ovn.sh --management"
if cfg.EncapIP != "" {
setupOVN += fmt.Sprintf(" --encap-ip=%s", cfg.EncapIP)
}
script := fmt.Sprintf(`#!/bin/bash
# Spinifex firstboot — runs once after ISO installation. On success, writes
# /var/lib/spinifex/.firstboot-done and the systemd unit's
# ConditionPathExists=! prevents re-execution on subsequent boots. A partial
# run leaves no marker, so the next reboot retries from the top — safe
# because every step below is idempotent (hostnamectl, setup-ovn.sh, spx
# admin init are all "set if not set"; systemctl enable on an already-enabled
# unit is a no-op).
set -euo pipefail
DONE_MARKER=/var/lib/spinifex/.firstboot-done
# Idempotency: bail early if a previous run completed successfully. The unit
# also has ConditionPathExists=!$DONE_MARKER so we shouldn't get here on
# subsequent boots, but defend in depth in case the unit is re-triggered
# manually before the operator notices the marker exists.
if [ -f "$DONE_MARKER" ]; then
echo "[firstboot] already complete — skipping"
exit 0
fi
# Set hostname
hostnamectl set-hostname %s
# Pre-start OVS and OVN central so their databases are initialised before
# setup-ovn.sh runs. On physical hardware, first-boot DB initialisation takes
# longer than setup-ovn.sh's internal 15-second timeout allows. Starting them
# here and waiting until the NB DB is ready means setup-ovn.sh sees a live DB
# the moment it starts — no races, no timeout failures.
systemctl start openvswitch-switch
systemctl start ovn-central
echo "Waiting for OVN NB DB to initialise..."
for _i in $(seq 1 120); do
if ovn-nbctl --timeout=2 get-connection >/dev/null 2>&1; then
echo "OVN NB DB ready (${_i}s)"
break
fi
sleep 1
done
# Create default nameservers if DHCP server returns blank
printf "nameserver 1.1.1.1\nnameserver 8.8.8.8\n" > /etc/resolvconf/resolv.conf.d/base
resolvconf -u
# Configure OVN networking.
# br-wan (and br-lan if present) are Linux bridges managed by systemd-networkd
# (declared in /etc/systemd/network/ by the installer). setup-ovn.sh
# auto-detects br-wan as the default route device and wires it to OVS via a
# veth pair — non-destructive, SSH-safe.
%s
# Write the banner (first time, spinifex-banner service will do this on reboot)
/usr/local/bin/spx admin banner --boot-check
# Cluster formation — capture credentials to file for display on console.
%s 2>&1
# Fix ownership of files spx admin init wrote. spx runs as root under
# systemd, so /etc/spinifex/{spinifex.toml,master.key,ca.key,*.pem} and any
# per-service files written under /var/lib/spinifex/* land as root:root.
# Delegate to setup.sh's fix_file_ownership (single source of truth) — it
# knows the per-file modes (ca.key 0600, *.pem 0644, toml/key 0640) that a
# blunt recursive chmod would clobber.
SETUP_STAGES=fixown /usr/local/share/spinifex/setup.sh
# The /var/lib/spinifex/awsgw/config symlink was created at build time by
# setup.sh's create_directories — it resolves to /etc/spinifex, so
# {BaseDir}/config/master.key automatically points at /etc/spinifex/master.key.
# Copy AWS credentials to the spinifex user's home directory.
# spx admin init runs with HOME=/root (set by the systemd unit), so credentials
# land in /root/.aws/. Copy them to the spinifex user's home so the operator
# can use the AWS CLI without sudo.
if [ -f /root/.aws/credentials ]; then
mkdir -p /home/spinifex/.aws
cp /root/.aws/credentials /home/spinifex/.aws/credentials
cp /root/.aws/config /home/spinifex/.aws/config 2>/dev/null || true
chown -R spinifex:spinifex /home/spinifex/.aws
chmod 700 /home/spinifex/.aws
chmod 600 /home/spinifex/.aws/credentials
[ -f /home/spinifex/.aws/config ] && chmod 600 /home/spinifex/.aws/config
fi
# Enable + activate spinifex.target and the banner now that all configs are
# in place. "enable --now" creates the multi-user.target.wants/ symlinks (so
# they start directly on every subsequent boot — no longer dependent on
# firstboot running each time, since firstboot is condition-skipped after
# this run via the marker) and activates them in this boot.
# NOTE: Moved to firstinstall to enable
#systemctl enable --now spinifex.target spinifex-banner.service
# Enable services to start, on reboot
systemctl enable spinifex.target spinifex-banner.service
# Mark complete only after every step above has succeeded. Until this point,
# any failure (set -e) leaves the marker absent and the next reboot retries
# firstboot from the top.
mkdir -p "$(dirname "$DONE_MARKER")"
touch "$DONE_MARKER"
systemctl start spinifex.target
# Wait for the daemon to bring up external networking. When external_mode=pool
# is configured the daemon creates br-ext (the OVN external bridge) during
# startup; launching instances before it is ready results in no public IP being
# assigned. ip link requires no root and avoids parsing daemon logs.
if grep -q 'external_mode.*pool' /etc/spinifex/spinifex.toml 2>/dev/null; then
echo "[firstboot] waiting for external networking (br-ext)..."
for _i in $(seq 1 30); do
if ip link show br-ext >/dev/null 2>&1; then
echo "[firstboot] br-ext ready (${_i}s)"
break
fi
sleep 1
done
if ! ip link show br-ext >/dev/null 2>&1; then
echo "[firstboot] warning: br-ext not up after 30s — external networking may be delayed"
fi
fi
`, cfg.Hostname, setupOVN, clusterCmd)
path := filepath.Join(root, "usr/local/bin/spinifex-firstboot.sh")
return os.WriteFile(path, []byte(script), 0o755)
}
func buildClusterCmd(cfg Config) string {
emailFlag := ""
if cfg.Email != "" {
// shellEscapeSingle keeps the email safe if it ever contains a
// character shell treats specially — belt-and-braces; the regex
// validator already rejects whitespace and @-chains.
emailFlag = " --email=" + shellEscapeSingle(cfg.Email)
}
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)
}
}
// shellEscapeSingle wraps s in single quotes with any embedded single
// quotes escaped. Minimal — we only need this because the email value is
// interpolated into a shell script written by Write().
func shellEscapeSingle(s string) string {
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
}
+70
View File
@@ -0,0 +1,70 @@
/*
Copyright © 2026 Mulga Defense Corporation
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// Package install performs the disk installation steps after the UI has
// collected configuration.
package install
// Config holds all values collected by the installer UI.
type Config struct {
// Disk is the block device path to install onto (e.g. /dev/sda).
Disk string
// WAN interface — always present. The management IP lives on br-wan (a Linux
// bridge over the NIC), not on the physical NIC itself. This allows OVN/OVS
// to safely attach to the bridge without disrupting host connectivity.
WANInterface string
WANDHCPMode bool
WANAddress string // empty if WANDHCPMode
WANMask string // empty if WANDHCPMode
WANGateway string // empty if WANDHCPMode
WANDNS []string // empty if WANDHCPMode; e.g. ["1.1.1.1", "8.8.8.8"]
WANWiFiSSID string // non-empty if a WiFi NIC was selected
WANWiFiPass string // non-empty if a WiFi NIC was selected
// LAN interface — only set when 2+ NICs are present. The Geneve encap IP
// for OVN tunnels lives on br-lan. When empty, the WAN address is used.
LANInterface string
LANDHCPMode bool
LANAddress string
LANMask string
LANDNS []string
LANWiFiSSID string
LANWiFiPass string
// Node identity
Hostname string
// ClusterRole is "init" or "join".
ClusterRole string
// JoinAddr is the primary node address (host:port) when ClusterRole is "join".
JoinAddr string
// CA certificate (PEM), optional.
HasCACert bool
CACert string
// RootPassword is the password to set for the root account on the installed system.
RootPassword string
// Email is the operator's email address, used by the call-home telemetry
// endpoint to notify of important system updates and security advisories.
// Required on interactive installs; may be empty on headless/CI installs
// when SPINIFEX_EMAIL is not supplied on the kernel cmdline.
Email string
}
+824
View File
@@ -0,0 +1,824 @@
/*
Copyright © 2026 Mulga Defense Corporation
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package install
import (
"bufio"
"fmt"
"io"
"log/slog"
"net"
"os"
"os/exec"
"path/filepath"
"strings"
"syscall"
"time"
"github.com/mulgadc/spinifex/cmd/installer/firstboot"
"github.com/mulgadc/spinifex/cmd/installer/systemd"
)
const (
mountRoot = "/mnt/spinifex-install"
efiPart = mountRoot + "/boot/efi"
)
// Run executes all installation steps in order. It is intentionally sequential
// and explicit — each step is visible in logs so failures are easy to diagnose.
func Run(cfg *Config) error {
// The live environment may not have /sbin or /usr/sbin in PATH. Set it
// explicitly so exec.Command's LookPath finds system binaries like grub-install.
_ = os.Setenv("PATH", "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin")
// Unmount unconditionally on exit so a failed step never leaves partitions
// mounted in the live environment, which would cause a retry to double-mount.
defer func() {
_ = run("umount", efiPart)
_ = run("umount", mountRoot)
}()
steps := []struct {
name string
fn func() error
}{
{"partition disk", func() error { return partitionDisk(cfg.Disk) }},
{"format partitions", func() error { return formatPartitions(cfg.Disk) }},
{"mount partitions", func() error { return mountPartitions(cfg.Disk) }},
{"copy rootfs", copyRootfs},
{"write fstab", func() error { return writeFstab(cfg.Disk) }},
{"install spinifex", func() error { return installSpinifex(cfg) }},
{"write network config", func() error { return writeNetworkConfig(cfg) }},
{"write firstboot service", func() error { return firstboot.Write(mountRoot, cfg.toFirstbootConfig()) }},
{"install bootloader", func() error { return installBootloader(cfg.Disk) }},
{"install CA cert", func() error { return installCACert(cfg) }},
}
for _, s := range steps {
slog.Info("installer", "step", s.name)
if err := s.fn(); err != nil {
return fmt.Errorf("step %q: %w", s.name, err)
}
}
slog.Info("installation complete")
promptRemoveUSB()
return reboot()
}
func partitionDisk(disk string) error {
// GPT table with three partitions:
// p1: 1MiB BIOS Boot Partition — required for grub-install i386-pc on GPT
// p2: 512MiB EFI System Partition
// p3: remainder as root (ext4)
if err := run("parted", "--script", disk,
"mklabel", "gpt",
"mkpart", "bios_boot", "1MiB", "2MiB",
"set", "1", "bios_grub", "on",
"mkpart", "ESP", "fat32", "2MiB", "514MiB",
"set", "2", "esp", "on",
"mkpart", "root", "ext4", "514MiB", "100%",
); err != nil {
return err
}
// Force the kernel to re-read the partition table and wait for udev to
// create the partition device nodes. Without this, mkfs.fat in the next
// step may race and fail with "No such file or directory" on /dev/sda2 —
// the kernel has accepted the new layout but /dev hasn't been populated
// yet. Trixie's udev seems slower at this than Bookworm's was.
return waitForPartitions(disk)
}
// waitForPartitions ensures the EFI and root partition device nodes exist
// after parted creates them. It runs partprobe (kernel re-read) and
// udevadm settle (wait for queued events), then polls /dev for the files.
func waitForPartitions(disk string) error {
// Best-effort: partprobe failure isn't fatal — udev may still pick up
// the change from the BLKRRPART ioctl that parted itself issued.
if err := run("partprobe", disk); err != nil {
slog.Warn("partprobe failed, continuing", "disk", disk, "err", err)
}
if err := run("udevadm", "settle", "--timeout=10"); err != nil {
slog.Warn("udevadm settle failed, continuing", "err", err)
}
efi, root := partitionPaths(disk)
deadline := time.Now().Add(15 * time.Second)
for _, part := range []string{efi, root} {
for {
if _, err := os.Stat(part); err == nil {
break
}
if time.Now().After(deadline) {
return fmt.Errorf("partition device %s did not appear within timeout — kernel/udev did not pick up new partition table", part)
}
time.Sleep(200 * time.Millisecond)
}
}
return nil
}
func formatPartitions(disk string) error {
efi, root := partitionPaths(disk)
if err := run("mkfs.fat", "-F32", efi); err != nil {
return err
}
return run("mkfs.ext4", "-F", root)
}
func mountPartitions(disk string) error {
_, root := partitionPaths(disk)
if err := os.MkdirAll(mountRoot, 0o755); err != nil {
return err
}
if err := run("mount", root, mountRoot); err != nil {
return err
}
if err := os.MkdirAll(efiPart, 0o755); err != nil {
return err
}
efi, _ := partitionPaths(disk)
return run("mount", efi, efiPart)
}
// copyRootfs copies the live squashfs environment onto the target disk using
// rsync. This is the air-gapped alternative to debootstrap — all packages are
// already embedded in the ISO so no network access is required.
func copyRootfs() error {
if err := run("rsync", "-aHAX", "--delete", "--info=progress2",
"--exclude=/proc",
"--exclude=/sys",
"--exclude=/dev",
"--exclude=/run",
"--exclude=/tmp",
"--exclude=/cdrom",
"--exclude=/mnt",
"--exclude=/etc/machine-id",
"--exclude=/var/lib/dbus/",
"--exclude=/etc/openvswitch/",
"--exclude=/var/lib/openvswitch/",
"--exclude=/var/lib/dhcpcd/",
"--exclude=/lost+found",
"--exclude=/boot/efi",
"/", mountRoot+"/",
); err != nil {
return err
}
// Verify critical paths exist before proceeding. rsync exits 0 on ENOSPC
// for individual file writes on some filesystems, which would produce a
// partial rootfs that boots into a panic.
critical := []string{
filepath.Join(mountRoot, "bin/bash"),
filepath.Join(mountRoot, "lib/systemd/systemd"),
filepath.Join(mountRoot, "usr/local/bin/spx"),
}
for _, p := range critical {
if _, err := os.Stat(p); err != nil {
return fmt.Errorf("copyRootfs: critical path missing after rsync (%s): %w", p, err)
}
}
// rsync skips excluded paths entirely — recreate the virtual filesystem
// mount points that systemd expects to exist on the installed system.
mountPoints := []struct {
path string
mode os.FileMode
}{
{"proc", 0o555},
{"sys", 0o555},
{"dev", 0o755},
{"run", 0o755},
{"run/lock", 0o1777},
{"tmp", 0o1777},
{"mnt", 0o755},
}
for _, mp := range mountPoints {
if err := os.MkdirAll(filepath.Join(mountRoot, mp.path), mp.mode); err != nil {
return fmt.Errorf("create mountpoint /%s: %w", mp.path, err)
}
}
return nil
}
func installSpinifex(cfg *Config) error {
// The rootfs copy already contains spx and spinifex-installer at
// /usr/local/bin/ — no binary copy needed. Regenerate machine-specific
// identity files so each installed node is unique.
// Bind-mount /dev, /proc, /sys into the chroot so PAM (chpasswd) and
// other chroot commands work correctly. Trixie's PAM requires /proc and
// /dev/urandom for password hashing.
if err := bindChrootMounts(); err != nil {
return err
}
defer unbindChrootMounts()
// Generate a unique machine-id from the kernel CSPRNG. We do not use
// systemd-machine-id-setup in the chroot: it reads the SMBIOS UUID via
// the bind-mounted /sys, which is identical on cloned VMs or hosts with
// absent/zeroed DMI data, and falls back to non-unique sources (MAC,
// hostname) when that also fails — producing the same ID on every node.
// /proc/sys/kernel/random/uuid is always unique and requires no chroot.
machineIDPath := filepath.Join(mountRoot, "etc/machine-id")
rawUUID, err := os.ReadFile("/proc/sys/kernel/random/uuid")
if err != nil {
return fmt.Errorf("installSpinifex: read kernel uuid: %w", err)
}
machineID := strings.ReplaceAll(strings.TrimSpace(string(rawUUID)), "-", "") + "\n"
if err := os.WriteFile(machineIDPath, []byte(machineID), 0o444); err != nil {
return fmt.Errorf("installSpinifex: write machine-id: %w", err)
}
// dbus mirrors /etc/machine-id; remove it so it is re-created from the
// new ID on first boot rather than carrying a stale value.
_ = os.Remove(filepath.Join(mountRoot, "var/lib/dbus/machine-id"))
// Hostname.
hostnamePath := filepath.Join(mountRoot, "etc/hostname")
if err := os.WriteFile(hostnamePath, []byte(cfg.Hostname+"\n"), 0o644); err != nil {
return err
}
// /etc/hosts entry for the hostname.
hosts := fmt.Sprintf("127.0.0.1\tlocalhost\n127.0.1.1\t%s\n", cfg.Hostname)
if err := os.WriteFile(filepath.Join(mountRoot, "etc/hosts"), []byte(hosts), 0o644); err != nil {
return err
}
// Set root + spinifex passwords. We invoke chpasswd from the LIVE
// installer (not via `chroot`), passing the target root with -R and
// forcing -c YESCRYPT. This deliberately bypasses PAM:
// * `chroot ... chpasswd` uses /etc/pam.d/chpasswd → common-password →
// pam_unix.so with the "obscure" option, which can return
// "Authentication token manipulation error" inside a chroot for
// reasons that are awkward to diagnose (audit subsystem, locked
// shadow entries from useradd, etc.).
// * -c YESCRYPT tells chpasswd to hash locally with libcrypt and write
// directly to <root>/etc/shadow — no PAM stack involved. YESCRYPT
// matches Trixie's ENCRYPT_METHOD so subsequent logins use the same
// algorithm.
// * -R <root> opens the target's passwd/shadow directly; no bind
// mounts of /dev/urandom or /proc are needed for the password step.
if cfg.RootPassword != "" {
if err := setShadowPassword("root", cfg.RootPassword); err != nil {
return fmt.Errorf("set root password: %w", err)
}
// The spinifex account is the default interactive login on the
// node (console + SSH). Root SSH is disabled, so this is the sole
// remote entry point. The user itself is created at rootfs build
// time (build-rootfs.sh) — here we just set its password.
if err := setShadowPassword("spinifex", cfg.RootPassword); err != nil {
return fmt.Errorf("set spinifex password: %w", err)
}
}
// Write /etc/spinifex/node.conf — read at runtime by spx admin banner
// to look up the current IP dynamically (handles IP changes after install).
// MANAGEMENT_IFACE is the bridge (br-wan), not the physical NIC.
// MANAGEMENT_IP is empty for DHCP — banner's --boot-check fills it in at boot.
nodeConf := fmt.Sprintf("MANAGEMENT_IP=%s\nMANAGEMENT_IFACE=br-wan\nNODE_HOSTNAME=%s\n",
cfg.WANAddress, cfg.Hostname)
confDir := filepath.Join(mountRoot, "etc/spinifex")
if err := os.MkdirAll(confDir, 0o755); err != nil {
return err
}
if err := os.WriteFile(filepath.Join(confDir, "node.conf"), []byte(nodeConf), 0o644); err != nil {
return err
}
// dhcpcd-base is present on the installed system (used by setup-ovn.sh for
// macvlan mode). Mask the standalone dhcpcd.service so it never auto-starts
// and races with systemd-networkd's built-in DHCP client on br-wan.
if err := maskSystemdUnit(mountRoot, "dhcpcd.service"); err != nil {
slog.Warn("installSpinifex: failed to mask dhcpcd.service", "err", err)
}
return nil
}
func writeNetworkConfig(cfg *Config) error {
// IPs live on Linux bridges (br-wan, br-lan), not on the physical NICs.
// systemd-networkd owns the full lifecycle: it creates the bridge NetDevs,
// enslaves the physical NICs, and runs the DHCP client. This means the
// veth pair that setup-ovn.sh adds later (veth-wan-br) is a port of a
// networkd-known bridge, so a networkctl reload or reboot never orphans it.
netdDir := filepath.Join(mountRoot, "etc/systemd/network")
if err := os.MkdirAll(netdDir, 0o755); err != nil {
return err
}
if err := writeNetworkdBridge(netdDir, cfg.WANInterface, "br-wan", false,
cfg.WANDHCPMode, cfg.WANAddress, cfg.WANMask, cfg.WANGateway, cfg.WANDNS,
cfg.WANWiFiSSID, cfg.WANWiFiPass); err != nil {
return err
}
if cfg.LANInterface != "" {
if err := writeNetworkdBridge(netdDir, cfg.LANInterface, "br-lan", true,
cfg.LANDHCPMode, cfg.LANAddress, cfg.LANMask, "", cfg.LANDNS,
cfg.LANWiFiSSID, cfg.LANWiFiPass); err != nil {
return err
}
// br-lan timing is controlled by spinifex-lan-bridge.service, not
// networkd auto-activation — ActivationPolicy=manual in the .network
// file ensures networkd creates the bridge but does not bring it up.
if err := systemd.WriteLANBridgeUnit(mountRoot); err != nil {
return fmt.Errorf("lan bridge unit: %w", err)
}
}
// Unmask systemd-networkd-wait-online on the installed system and scope
// it to br-wan only. The live ISO masks this service (build-rootfs.sh) to
// avoid blocking the installer environment (which has no br-wan). The mask
// is copied to the installed system by copyRootfs and must be removed here
// so that network-online.target — and therefore spinifex-firstboot.service
// (After=network-online.target) — does not fire before br-wan has its
// DHCP lease and default route. Without this, setup-ovn.sh finds no default
// route and exits early, creating no veth pair.
//
// The --interface=br-wan scope means br-lan (ActivationPolicy=manual) never
// blocks the wait; --timeout=60 caps the delay on a cold switch.
waitOnlineMask := filepath.Join(mountRoot, "etc/systemd/system/systemd-networkd-wait-online.service")
_ = os.Remove(waitOnlineMask)
waitOnlineDir := filepath.Join(mountRoot, "etc/systemd/system/systemd-networkd-wait-online.service.d")
if err := os.MkdirAll(waitOnlineDir, 0o755); err != nil {
return err
}
waitOnlineConf := "[Service]\nExecStart=\nExecStart=/lib/systemd/systemd-networkd-wait-online --interface=br-wan --timeout=60\n"
if err := os.WriteFile(filepath.Join(waitOnlineDir, "spinifex-wan-only.conf"), []byte(waitOnlineConf), 0o644); err != nil {
return err
}
// Disable IPv6 via sysctl — belt-and-suspenders alongside IPv6AcceptRA=no
// in the networkd .network files.
bridges := []string{"br-wan"}
if cfg.LANInterface != "" {
bridges = append(bridges, "br-lan")
}
var sysctl strings.Builder
sysctl.WriteString("# Generated by Spinifex installer — IPv6 disabled on management bridges\n")
for _, br := range bridges {
fmt.Fprintf(&sysctl, "net.ipv6.conf.%s.disable_ipv6=1\n", br)
}
sysctlDir := filepath.Join(mountRoot, "etc/sysctl.d")
if err := os.MkdirAll(sysctlDir, 0o755); err != nil {
return err
}
if err := os.WriteFile(filepath.Join(sysctlDir, "99-spinifex-network.conf"), []byte(sysctl.String()), 0o644); err != nil {
return err
}
// Pin each NIC name to its MAC address via udev so the installed system
// always uses the same interface name regardless of probe order.
udevDir := filepath.Join(mountRoot, "etc/udev/rules.d")
if err := os.MkdirAll(udevDir, 0o755); err != nil {
return err
}
var udevRules strings.Builder
for _, iface := range []string{cfg.WANInterface, cfg.LANInterface} {
if iface == "" {
continue
}
mac, err := os.ReadFile("/sys/class/net/" + iface + "/address")
if err != nil {
slog.Warn("writeNetworkConfig: could not read NIC MAC, skipping udev pin", "iface", iface, "err", err)
continue
}
fmt.Fprintf(&udevRules, "SUBSYSTEM==\"net\", ACTION==\"add\", ATTR{address}==\"%s\", NAME=\"%s\"\n",
strings.TrimSpace(string(mac)), iface)
}
if udevRules.Len() > 0 {
return os.WriteFile(filepath.Join(udevDir, "70-spinifex-net.rules"), []byte(udevRules.String()), 0o644)
}
return nil
}
// writeNetworkdBridge writes the three systemd-networkd files that configure a
// physical NIC enslaved to a Linux bridge:
//
// - {prio}-spinifex-{suffix}-nic.network enslaves the NIC to the bridge
// - {prio+1}-spinifex-{suffix}.netdev declares the bridge device
// - {prio+1}-spinifex-{suffix}.network configures IP on the bridge
//
// manual=true sets ActivationPolicy=manual so networkd creates the bridge but
// does not auto-activate it — used for br-lan, which spinifex-lan-bridge.service
// activates after network-online.target.
func writeNetworkdBridge(dir, nicIface, bridgeName string, manual, dhcp bool,
addr, mask, gw string, dns []string, wifiSSID, wifiPass string) error {
suffix := strings.TrimPrefix(bridgeName, "br-")
nicPrio, brPrio := 10, 11
if manual {
nicPrio, brPrio = 12, 13
}
// NIC .network — enslaves the physical NIC to the bridge.
nicNet := fmt.Sprintf("[Match]\nName=%s\n\n[Network]\nBridge=%s\n", nicIface, bridgeName)
nicFile := fmt.Sprintf("%02d-spinifex-%s-nic.network", nicPrio, suffix)
if err := os.WriteFile(filepath.Join(dir, nicFile), []byte(nicNet), 0o644); err != nil {
return err
}
// Bridge .netdev — declares the bridge device.
brNetdev := fmt.Sprintf("[NetDev]\nName=%s\nKind=bridge\n\n[Bridge]\nSTP=no\n", bridgeName)
brNetdevFile := fmt.Sprintf("%02d-spinifex-%s.netdev", brPrio, suffix)
if err := os.WriteFile(filepath.Join(dir, brNetdevFile), []byte(brNetdev), 0o644); err != nil {
return err
}
// Bridge .network — IP configuration.
var b strings.Builder
fmt.Fprintf(&b, "[Match]\nName=%s\n\n", bridgeName)
if manual {
b.WriteString("[Link]\nActivationPolicy=manual\nRequiredForOnline=no\n\n")
}
b.WriteString("[Network]\n")
if dhcp {
b.WriteString("DHCP=ipv4\n")
} else {
cidr, err := addrCIDR(addr, mask)
if err != nil {
return fmt.Errorf("bridge %s: %w", bridgeName, err)
}
fmt.Fprintf(&b, "Address=%s\n", cidr)
if gw != "" {
fmt.Fprintf(&b, "Gateway=%s\n", gw)
}
for _, ns := range dns {
if ns = strings.TrimSpace(ns); ns != "" {
fmt.Fprintf(&b, "DNS=%s\n", ns)
}
}
}
b.WriteString("IPv6AcceptRA=no\nConfigureWithoutCarrier=yes\n")
if manual && dhcp {
// br-lan is non-critical; fail fast on DHCP so a missing LAN cable
// does not stall spinifex-lan-bridge.service indefinitely.
b.WriteString("\n[DHCP]\nTimeout=10\n")
}
brNetFile := fmt.Sprintf("%02d-spinifex-%s.network", brPrio, suffix)
if err := os.WriteFile(filepath.Join(dir, brNetFile), []byte(b.String()), 0o644); err != nil {
return err
}
if wifiSSID != "" {
if err := writeWPASupplicant(nicIface, wifiSSID, wifiPass); err != nil {
return err
}
}
return nil
}
// addrCIDR converts a dotted-decimal address + subnet mask to CIDR notation.
func addrCIDR(addr, dotMask string) (string, error) {
if net.ParseIP(addr) == nil {
return "", fmt.Errorf("invalid IP: %s", addr)
}
maskIP := net.ParseIP(dotMask)
if maskIP == nil {
return "", fmt.Errorf("invalid mask: %s", dotMask)
}
m := net.IPMask(maskIP.To4())
ones, bits := m.Size()
if bits == 0 {
return "", fmt.Errorf("non-contiguous or zero subnet mask: %s", dotMask)
}
return fmt.Sprintf("%s/%d", addr, ones), nil
}
// writeWPASupplicant writes a wpa_supplicant config for nicIface and enables
// the per-interface wpa_supplicant@{iface}.service so authentication completes
// before networkd enslaves the NIC to the bridge.
func writeWPASupplicant(nicIface, ssid, psk string) error {
conf := fmt.Sprintf(
"ctrl_interface=DIR=/run/wpa_supplicant GROUP=netdev\nupdate_config=1\n\nnetwork={\n\tssid=%q\n\tpsk=%q\n}\n",
ssid, psk,
)
wpaDir := filepath.Join(mountRoot, "etc/wpa_supplicant")
if err := os.MkdirAll(wpaDir, 0o755); err != nil {
return err
}
if err := os.WriteFile(filepath.Join(wpaDir, "wpa_supplicant-"+nicIface+".conf"), []byte(conf), 0o600); err != nil {
return err
}
// Enable via symlink into multi-user.target.wants pointing at the
// package-provided template unit.
wantsDir := filepath.Join(mountRoot, "etc/systemd/system/multi-user.target.wants")
if err := os.MkdirAll(wantsDir, 0o755); err != nil {
return err
}
link := filepath.Join(wantsDir, "wpa_supplicant@"+nicIface+".service")
_ = os.Remove(link)
return os.Symlink("/lib/systemd/system/wpa_supplicant@.service", link)
}
func installBootloader(disk string) error {
// grub-install runs in the live environment (not chroot) using the
// grub-pc-bin and grub-efi-amd64-bin packages already present on the ISO.
// --boot-directory points at the installed system's /boot.
bootDir := filepath.Join(mountRoot, "boot")
efiDir := filepath.Join(mountRoot, "boot", "efi")
efiErr := run("grub-install",
"--target=x86_64-efi",
"--efi-directory="+efiDir,
"--boot-directory="+bootDir,
"--bootloader-id=spinifex",
"--removable",
"--recheck",
)
if efiErr != nil {
slog.Warn("installBootloader: EFI install failed", "err", efiErr)
}
if biosErr := run("grub-install",
"--target=i386-pc",
"--boot-directory="+bootDir,
"--recheck",
disk,
); biosErr != nil {
if efiErr != nil {
// Both targets failed — the system will not boot.
return fmt.Errorf("both bootloader targets failed (EFI: %v; BIOS: %w)", efiErr, biosErr)
}
return biosErr
}
copySplashImage(mountRoot)
copyGrubFont(mountRoot)
// Both consoles listed: tty0 stays primary for local install, ttyS0 mirrors
// kernel output to serial so headless installs (CI, racks with serial-only
// IPMI, remote-dev SSH-to-QEMU) see boot messages. The last `console=` on
// the cmdline becomes the system console, so ttyS0 wins on serial-only
// hardware while tty0 still receives output for local display.
grubDefault := `GRUB_DEFAULT=0
GRUB_TIMEOUT=5
GRUB_DISTRIBUTOR=Spinifex
GRUB_CMDLINE_LINUX_DEFAULT=""
GRUB_CMDLINE_LINUX="console=tty0 console=ttyS0,115200 systemd.show_status=1"
`
if err := os.WriteFile(filepath.Join(mountRoot, "etc/default/grub"), []byte(grubDefault), 0o644); err != nil {
return fmt.Errorf("write /etc/default/grub: %w", err)
}
// Mirror the ISO grub.cfg graphical block exactly so the installed GRUB menu
// looks identical to the installer menu. gfxterm MUST be activated before
// serial is appended — background_image silently does nothing in text mode.
// Using exec tail so update-grub includes everything from line 3 as raw GRUB config.
grubTheme := `#!/bin/sh
exec tail -n +3 $0
insmod all_video
insmod font
if loadfont /boot/grub/fonts/unicode.pf2; then
set gfxmode=auto
insmod gfxterm
terminal_output gfxterm
fi
insmod serial
if serial --unit=0 --speed=115200 --timeout=1; then
terminal_input --append serial
terminal_output --append serial
fi
# --- Branding ---
insmod png
set theme=/boot/grub/theme.txt
export theme
`
if err := os.WriteFile(filepath.Join(mountRoot, "etc/grub.d/06_spinifex"), []byte(grubTheme), 0o755); err != nil {
return fmt.Errorf("write /etc/grub.d/06_spinifex: %w", err)
}
if err := bindChrootMounts(); err != nil {
return err
}
defer unbindChrootMounts()
return run("chroot", mountRoot, "update-grub")
}
func installCACert(cfg *Config) error {
if !cfg.HasCACert || cfg.CACert == "" {
return nil
}
certPath := filepath.Join(mountRoot, "usr/local/share/ca-certificates/spinifex-ca.crt")
if err := os.WriteFile(certPath, []byte(cfg.CACert), 0o644); err != nil {
return err
}
if err := bindChrootMounts(); err != nil {
return err
}
defer unbindChrootMounts()
return run("chroot", mountRoot, "update-ca-certificates")
}
// promptRemoveUSB prints a removal reminder and waits up to 10 seconds for
// the user to press Enter before rebooting. Reading from os.Stdin works because
// spinifex-init redirects the installer's stdin from $CONSOLE_DEV.
func promptRemoveUSB() {
fmt.Println("\n\033[1mInstallation complete.\033[0m")
fmt.Println("Remove the USB drive, then press Enter to reboot (auto-rebooting in 10 seconds)...")
done := make(chan struct{})
go func() {
scanner := bufio.NewScanner(os.Stdin)
scanner.Scan()
close(done)
}()
select {
case <-done:
case <-time.After(10 * time.Second):
}
}
func reboot() error {
// sync filesystems before reboot so nothing is lost.
_ = run("sync")
// Use the kernel syscall directly — the live environment runs spinifex-init
// as PID 1 (not systemd), so the reboot(8) utility fails trying to reach D-Bus.
return syscall.Reboot(syscall.LINUX_REBOOT_CMD_RESTART)
}
// toFirstbootConfig maps installer Config to the firstboot package's Config.
func (c *Config) toFirstbootConfig() firstboot.Config {
// Geneve encap IP: use LAN bridge IP when a dedicated LAN NIC is present,
// otherwise fall back to WAN bridge IP. Empty for DHCP — setup-ovn.sh
// auto-detects the IP from the default route at boot in that case.
encapIP := c.WANAddress
if c.LANInterface != "" && c.LANAddress != "" {
encapIP = c.LANAddress
}
return firstboot.Config{
Hostname: c.Hostname,
EncapIP: encapIP,
ClusterRole: c.ClusterRole,
JoinAddr: c.JoinAddr,
Email: c.Email,
}
}
// writeFstab writes /etc/fstab on the installed system using partition UUIDs so
// the root filesystem is mounted read-write at boot and the EFI partition is
// mounted at /boot/efi.
func writeFstab(disk string) error {
efi, root := partitionPaths(disk)
rootUUID, err := partUUID(root)
if err != nil {
return fmt.Errorf("get root UUID: %w", err)
}
efiUUID, err := partUUID(efi)
if err != nil {
return fmt.Errorf("get EFI UUID: %w", err)
}
fstab := fmt.Sprintf("# /etc/fstab — generated by Spinifex installer\nUUID=%s / ext4 errors=remount-ro 0 1\nUUID=%s /boot/efi vfat umask=0077 0 1\n",
rootUUID, efiUUID)
return os.WriteFile(filepath.Join(mountRoot, "etc/fstab"), []byte(fstab), 0o644)
}
func partUUID(dev string) (string, error) {
out, err := exec.Command("blkid", "-s", "UUID", "-o", "value", dev).Output()
if err != nil {
return "", fmt.Errorf("blkid %s: %w", dev, err)
}
uuid := strings.TrimSpace(string(out))
if uuid == "" {
return "", fmt.Errorf("blkid returned no UUID for %s — partition may not have a filesystem yet", dev)
}
return uuid, nil
}
// partitionPaths returns the EFI and root partition device paths for a given
// disk. p1 is the BIOS Boot Partition (no filesystem), p2 is EFI, p3 is root.
// Handles both /dev/sdX (→ /dev/sdX2, /dev/sdX3) and /dev/nvmeXnY
// (→ /dev/nvmeXnYp2, /dev/nvmeXnYp3).
func partitionPaths(disk string) (efi, root string) {
// NVMe devices use a 'p' separator before the partition number.
if len(disk) > 0 && disk[len(disk)-1] >= '0' && disk[len(disk)-1] <= '9' {
return disk + "p2", disk + "p3"
}
return disk + "2", disk + "3"
}
// 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.
func copySplashImage(root string) {
const src = "/usr/share/spinifex/grub-splash.png"
in, err := os.Open(src)
if err != nil {
slog.Warn("copySplashImage: splash not found, skipping", "path", src)
return
}
defer in.Close()
dstDir := filepath.Join(root, "boot/grub")
if err := os.MkdirAll(dstDir, 0o755); err != nil {
slog.Warn("copySplashImage: cannot create boot/grub dir", "err", err)
return
}
out, err := os.OpenFile(filepath.Join(dstDir, "splash.png"), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
if err != nil {
slog.Warn("copySplashImage: cannot open destination", "err", err)
return
}
defer out.Close()
if _, err := io.Copy(out, in); err != nil {
slog.Warn("copySplashImage: copy failed", "err", err)
}
}
// 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 {
unitPath := filepath.Join(root, "etc/systemd/system", unit)
_ = os.Remove(unitPath)
return os.Symlink("/dev/null", unitPath)
}
// setShadowPassword sets a Unix password on the installed system without
// going through PAM. See the long comment in installSpinifex for the
// rationale.
func setShadowPassword(user, password string) error {
cmd := exec.Command("chpasswd", "-c", "YESCRYPT", "-R", mountRoot)
cmd.Stdin = strings.NewReader(user + ":" + password)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
// chrootMountPaths lists virtual filesystems to bind-mount into the chroot.
// Order matters: unbind in reverse.
var chrootMountPaths = []string{"dev", "proc", "sys"}
// bindChrootMounts bind-mounts /dev, /proc, and /sys into the installed rootfs
// so chroot commands (chpasswd, systemd-machine-id-setup, update-grub) can
// access hardware, process info, and entropy sources. Idempotent — already-
// mounted paths are skipped.
func bindChrootMounts() error {
for _, m := range chrootMountPaths {
dst := filepath.Join(mountRoot, m)
if err := os.MkdirAll(dst, 0o755); err != nil {
return fmt.Errorf("create chroot mountpoint /%s: %w", m, err)
}
if err := run("mount", "--bind", "/"+m, dst); err != nil {
return fmt.Errorf("bind-mount /%s into chroot: %w", m, err)
}
}
return nil
}
// unbindChrootMounts unmounts the virtual filesystems in reverse order.
// Errors are logged but not returned — this is best-effort cleanup.
func unbindChrootMounts() {
for i := len(chrootMountPaths) - 1; i >= 0; i-- {
_ = run("umount", filepath.Join(mountRoot, chrootMountPaths[i]))
}
}
func run(name string, args ...string) error {
cmd := exec.Command(name, args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
+86
View File
@@ -0,0 +1,86 @@
/*
Copyright © 2026 Mulga Defense Corporation
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package main
import (
"fmt"
"os"
"strings"
"github.com/mulgadc/spinifex/cmd/installer/autoinstall"
"github.com/mulgadc/spinifex/cmd/installer/install"
"github.com/mulgadc/spinifex/cmd/installer/ui"
)
func main() {
// Check for a headless autoinstall config on the boot media before
// launching the interactive TUI. If found and enabled, run without
// any user input then eject the USB and reboot.
if cfg, err := autoinstall.Load(); err != nil {
fmt.Fprintf(os.Stderr, "autoinstall: %v\n", err)
os.Exit(1)
} else if cfg != nil {
if err := install.Run(cfg); err != nil {
fmt.Fprintf(os.Stderr, "install failed: %v\n", err)
os.Exit(1)
}
autoinstall.EjectAndReboot()
return
}
// Normal interactive path.
ttyPath := detectTTY()
cfg, err := ui.Run(ttyPath)
if err != nil {
fmt.Fprintf(os.Stderr, "installer: %v\n", err)
os.Exit(1)
}
if err := install.Run(cfg); err != nil {
fmt.Fprintf(os.Stderr, "install failed: %v\n", err)
os.Exit(1)
}
}
// detectTTY returns the TTY device path the installer should attach to.
// It first checks the SPINIFEX_CONSOLE environment variable (set by
// spinifex-init.sh after console fallback logic), then falls back to parsing
// /proc/cmdline. spinifex-init.sh may override the GRUB-set value (e.g. when
// GRUB says tty1 but no framebuffer exists, it falls back to ttyS0 and
// exports the corrected value).
func detectTTY() string {
// Prefer the env var — spinifex-init.sh sets this after resolving the
// actual console (including headless/framebuffer fallback).
if val := os.Getenv("SPINIFEX_CONSOLE"); val != "" && val != "auto" {
return "/dev/" + val
}
// Fall back to kernel cmdline for direct invocation without spinifex-init.
data, err := os.ReadFile("/proc/cmdline")
if err == nil {
for param := range strings.FieldsSeq(string(data)) {
if after, ok := strings.CutPrefix(param, "SPINIFEX_CONSOLE="); ok {
val := after
if val != "" && val != "auto" {
return "/dev/" + val
}
}
}
}
return "/dev/tty1"
}
+182
View File
@@ -0,0 +1,182 @@
/*
Copyright © 2026 Mulga Defense Corporation
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// Package systemd writes systemd unit files and drop-ins into an installed
// system root during the Spinifex ISO installation process.
package systemd
import (
"fmt"
"os"
"path/filepath"
)
// WriteLANBridgeUnit installs a non-critical oneshot service that activates
// br-lan via networkctl *after* network-online.target. br-lan has
// ActivationPolicy=manual in its networkd .network file, so networkd creates
// the bridge at boot but does not bring it up automatically. This service
// triggers the activation once the WAN path is confirmed online, ensuring a
// missing LAN cable or DHCP timeout on the secondary bridge never stalls
// the management interface or firstboot.
func WriteLANBridgeUnit(root string) error {
unit := `[Unit]
Description=Spinifex LAN bridge (non-critical)
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/bin/networkctl up br-lan
RemainAfterExit=yes
# Failure is non-critical — cable unplugged or switch not ready at boot.
SuccessExitStatus=0 1
[Install]
WantedBy=multi-user.target
`
unitPath := filepath.Join(root, "etc/systemd/system/spinifex-lan-bridge.service")
if err := os.WriteFile(unitPath, []byte(unit), 0o644); err != nil {
return err
}
wantsDir := filepath.Join(root, "etc/systemd/system/multi-user.target.wants")
if err := os.MkdirAll(wantsDir, 0o755); err != nil {
return err
}
link := filepath.Join(wantsDir, "spinifex-lan-bridge.service")
if err := os.Remove(link); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("remove stale symlink %s: %w", link, err)
}
return os.Symlink("/etc/systemd/system/spinifex-lan-bridge.service", link)
}
// WriteFirstbootUnit writes the spinifex-firstboot.service oneshot unit that
// runs the firstboot provisioning script on the first real boot after
// installation. The script writes /var/lib/spinifex/.firstboot-done on
// success; ConditionPathExists=! on that marker condition-skips the unit on
// every subsequent boot so it never re-runs (replacing the previous
// "disable on EXIT" trap which couldn't distinguish success from failure).
func WriteFirstbootUnit(root string) error {
unit := `[Unit]
Description=Spinifex first-boot provisioning
After=network-online.target
Wants=network-online.target
ConditionPathExists=/usr/local/bin/spinifex-firstboot.sh
ConditionPathExists=!/var/lib/spinifex/.firstboot-done
[Service]
Type=oneshot
Environment=HOME=/root
ExecStart=/usr/local/bin/spinifex-firstboot.sh
RemainAfterExit=yes
# On non-zero exit, overwrite /etc/issue with a hint so the operator sees
# the failure on the console — the banner unit is never enabled in that
# case (firstboot didn't reach its enable --now line), so /etc/issue would
# otherwise stay at the Debian default with no signal that anything is wrong.
ExecStopPost=/bin/sh -c 'if [ "$EXIT_STATUS" != "0" ]; then printf "\n*** Spinifex firstboot FAILED ***\nLogin as spinifex, then run:\n sudo journalctl -u spinifex-firstboot.service\n\n" > /etc/issue; fi'
# Cap total firstboot runtime so a hang in setup-ovn.sh / spx admin init /
# ovn-central startup cannot wedge multi-user.target and keep getty from
# ever reaching the login prompt.
TimeoutStartSec=300s
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
`
path := filepath.Join(root, "etc/systemd/system/spinifex-firstboot.service")
return os.WriteFile(path, []byte(unit), 0o644)
}
// WriteBannerUnit writes the spinifex-banner.service unit that runs
// `spx admin banner --boot-check` on every boot after spinifex.target is up.
// Running After=spinifex.target ensures the banner reflects a settled system
// state and that the IP check/restart happens once services are already running.
//
// The unit file is written by the installer but NOT enabled at install time.
// firstboot enables it (along with spinifex.target) at the end of its first
// run via `systemctl enable --now`. On every subsequent boot both units are
// driven by the multi-user.target.wants/ symlinks firstboot installed.
//
// Note: only After=spinifex.target — no Wants=. A Wants= would implicitly
// pull spinifex.target up at boot, which is exactly the race this whole
// change is fixing. After= alone is a no-op when the target isn't started,
// which is correct on first boot before firstboot has provisioned configs.
func WriteBannerUnit(root string) error {
unit := `[Unit]
Description=Spinifex console banner and boot health check
After=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/bin/spx admin banner --boot-check
RemainAfterExit=yes
# Banner is oneshot; cap it so a stuck boot-check (IP detection, try-restart)
# cannot block getty via the spinifex-wait.conf drop-in.
TimeoutStartSec=30s
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
`
path := filepath.Join(root, "etc/systemd/system/spinifex-banner.service")
return os.WriteFile(path, []byte(unit), 0o644)
}
// WriteGettyDropIn holds the primary consoles (tty1 and ttyS0) until
// spinifex-banner.service completes (when banner is enabled), so the MOTD
// banner is visible before the login prompt appears. Drop-ins are scoped to
// named instances (getty@tty1, serial-getty@ttyS0) so tty2, tty3, etc.
// remain available as rescue terminals.
//
// Only After=, not Wants=. A Wants= would implicitly start the (un-enabled)
// banner on the very first boot, partially undoing the "only firstboot is
// enabled on first boot" guarantee. On first boot, banner is not enabled,
// so After= is a no-op and login appears immediately with the default
// /etc/issue. Once firstboot completes and enables the banner, subsequent
// boots have banner enabled at multi-user.target and After= correctly
// orders getty after the banner has rendered /etc/issue.
func WriteGettyDropIn(root string) error {
dropIn := `[Unit]
After=spinifex-firstboot.service
`
for _, svc := range []string{"getty@tty1", "serial-getty@ttyS0"} {
dropInDir := filepath.Join(root, "etc/systemd/system/"+svc+".service.d")
if err := os.MkdirAll(dropInDir, 0o755); err != nil {
return err
}
if err := os.WriteFile(filepath.Join(dropInDir, "spinifex-wait.conf"), []byte(dropIn), 0o644); err != nil {
return err
}
}
return nil
}
// EnableUnit creates the multi-user.target.wants symlink for serviceName,
// equivalent to `systemctl enable` for a unit that targets multi-user.target.
func EnableUnit(root, serviceName string) error {
wantsDir := filepath.Join(root, "etc/systemd/system/multi-user.target.wants")
if err := os.MkdirAll(wantsDir, 0o755); err != nil {
return err
}
link := filepath.Join(wantsDir, serviceName)
target := "/etc/systemd/system/" + serviceName
if err := os.Remove(link); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("remove stale symlink %s: %w", link, err)
}
return os.Symlink(target, link)
}
File diff suppressed because it is too large Load Diff
+90
View File
@@ -0,0 +1,90 @@
// lb-agent runs inside load balancer VMs and manages HAProxy configuration.
//
// It polls the AWS gateway for config updates and reports health via heartbeats.
// All communication uses SigV4-signed HTTP requests.
//
// Usage:
//
// lb-agent --lb-id=lb-xxxxx --gateway=https://192.168.1.33:9999 --access-key=AKIA... --secret-key=...
//
// Flags default to environment variables set by cloud-init:
// LB_LB_ID, LB_GATEWAY_URL, LB_ACCESS_KEY, LB_SECRET_KEY, LB_REGION.
package main
import (
"flag"
"log/slog"
"os"
"os/signal"
"syscall"
"github.com/mulgadc/spinifex/spinifex/lbagent"
)
func main() {
var (
lbID string
gatewayURL string
accessKey string
secretKey string
region string
)
hostname, _ := os.Hostname()
flag.StringVar(&lbID, "lb-id", envOrDefault("LB_LB_ID", hostname), "Load balancer ID")
flag.StringVar(&gatewayURL, "gateway", os.Getenv("LB_GATEWAY_URL"), "Gateway URL (e.g. https://192.168.1.33:9999)")
flag.StringVar(&accessKey, "access-key", os.Getenv("LB_ACCESS_KEY"), "AWS access key ID")
flag.StringVar(&secretKey, "secret-key", os.Getenv("LB_SECRET_KEY"), "AWS secret access key")
flag.StringVar(&region, "region", envOrDefault("LB_REGION", "us-east-1"), "AWS region for SigV4 signing")
flag.Parse()
if lbID == "" {
slog.Error("--lb-id is required (or set LB_LB_ID)")
os.Exit(1)
}
if gatewayURL == "" {
slog.Error("--gateway is required (or set LB_GATEWAY_URL)")
os.Exit(1)
}
if accessKey == "" || secretKey == "" {
slog.Error("--access-key and --secret-key are required (or set LB_ACCESS_KEY / LB_SECRET_KEY)")
os.Exit(1)
}
slog.Info("Starting LB agent", "lbId", lbID, "gateway", gatewayURL)
agent, err := lbagent.New(lbID, gatewayURL, accessKey, secretKey, region)
if err != nil {
slog.Error("Failed to create agent", "err", err)
os.Exit(1)
}
// Start poll loop in a goroutine — it blocks until Stop is called.
errCh := make(chan error, 1)
go func() {
if err := agent.Start(); err != nil {
errCh <- err
}
}()
// Wait for shutdown signal or fatal error.
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT)
select {
case sig := <-sigCh:
slog.Info("Received signal, shutting down", "signal", sig)
case err := <-errCh:
slog.Error("Agent error", "err", err)
}
agent.Stop()
}
func envOrDefault(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
+1137 -219
View File
File diff suppressed because it is too large Load Diff
+264
View File
@@ -0,0 +1,264 @@
package cmd
import (
"bytes"
"errors"
"fmt"
"os"
"path/filepath"
"testing"
"github.com/mulgadc/spinifex/spinifex/admin"
"github.com/mulgadc/spinifex/spinifex/formation"
"github.com/mulgadc/spinifex/spinifex/utils"
"github.com/spf13/viper"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// Regression guard: source URL must print for every error kind, not just
// mismatch, so a 404/non-HTTPS/size-cap failure still tells the operator
// which URL to investigate.
func TestPrintChecksumError(t *testing.T) {
image := utils.Images{Checksum: "https://example.com/SUMS", ChecksumType: "sha512"}
const imageFile = "/var/lib/img.tar.xz"
const imageName = "debian-12-x86_64"
errs := []error{
fmt.Errorf("%w: expected abc got def", utils.ErrChecksumMismatch),
fmt.Errorf("%w: 404", utils.ErrChecksumFetchFailed),
errors.New("open /x: no such file"),
}
for _, e := range errs {
var buf bytes.Buffer
printChecksumError(&buf, imageFile, imageName, image, e)
out := buf.String()
assert.Contains(t, out, image.Checksum, "source URL must print for: %v", e)
assert.Contains(t, out, imageFile)
assert.Contains(t, out, "spx admin images import --name "+imageName+" --force")
}
}
// buildRemoteNodes must prefer AdvertiseIP (off-host dial target) and fall
// back to BindIP when the peer pre-dates siv-8 and didn't send AdvertiseIP.
func TestBuildRemoteNodes_AdvertiseFallback(t *testing.T) {
nodes := map[string]formation.NodeInfo{
"node1": {Name: "node1", BindIP: "10.0.0.1", AdvertiseIP: "203.0.113.1"},
"node2": {Name: "node2", BindIP: "10.0.0.2"}, // legacy joiner
"node3": {Name: "node3", BindIP: "10.0.0.3", AdvertiseIP: "203.0.113.3"},
}
got := buildRemoteNodes(nodes, "node3")
if assert.Len(t, got, 2) {
assert.Equal(t, "node1", got[0].Name)
assert.Equal(t, "203.0.113.1", got[0].Host, "advertise wins when set")
assert.Equal(t, "node2", got[1].Name)
assert.Equal(t, "10.0.0.2", got[1].Host, "bind fallback when advertise empty")
}
}
func TestResolveAdvertiseIP(t *testing.T) {
wan := &admin.DetectedNetwork{WAN: &admin.DetectedInterface{IP: "192.168.1.21"}}
noWAN := &admin.DetectedNetwork{}
tests := []struct {
name string
bindIP string
advertise string
detected *admin.DetectedNetwork
want string
wantErr bool
}{
{"single-node default, WAN detected", "0.0.0.0", "", wan, "192.168.1.21", false},
{"single-node default, no WAN", "0.0.0.0", "", noWAN, "127.0.0.1", false},
{"single-node explicit advertise", "0.0.0.0", "203.0.113.5", wan, "203.0.113.5", false},
{"multi-node init, specific bind", "10.11.12.1", "", nil, "10.11.12.1", false},
{"multi-node dual-homed override", "10.11.12.1", "203.0.113.5", nil, "203.0.113.5", false},
{"loopback-only test", "127.0.0.1", "", noWAN, "127.0.0.1", false},
{"invalid advertise flag", "0.0.0.0", "not-an-ip", wan, "", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := resolveAdvertiseIP(tt.bindIP, tt.advertise, tt.detected)
if tt.wantErr {
assert.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, tt.want, got)
})
}
}
// Generated spinifex.toml must include both host (listen, BindIP) and
// advertise (off-host dial target, AdvertiseIP) for the local node.
func TestSpinifexTomlTemplate_AdvertiseField(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "spinifex.toml")
settings := admin.ConfigSettings{
Node: "node1",
Az: "ap-southeast-2a",
Port: "4432",
Region: "ap-southeast-2",
BindIP: "0.0.0.0",
AdvertiseIP: "192.168.1.21",
AccessKey: "AKIATEST",
SecretKey: "SECRET",
AccountID: "123456789012",
NatsToken: "token",
ConfigDir: dir,
OVNNBAddr: "tcp:127.0.0.1:6641",
OVNSBAddr: "tcp:127.0.0.1:6642",
}
require.NoError(t, admin.GenerateConfigFile(path, spinifexTomlTemplate, settings))
data, err := os.ReadFile(path)
require.NoError(t, err)
content := string(data)
assert.Contains(t, content, `host = "0.0.0.0"`, "listen address")
assert.Contains(t, content, `advertise = "192.168.1.21"`, "off-host dial target")
}
// Empty AdvertiseIP (e.g. loading an existing cluster pre-siv-8) must NOT
// render an empty advertise = "" line — downstream fallback to Host kicks in.
func TestSpinifexTomlTemplate_AdvertiseOmittedWhenEmpty(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "spinifex.toml")
settings := admin.ConfigSettings{
Node: "node1",
Az: "ap-southeast-2a",
Port: "4432",
Region: "ap-southeast-2",
BindIP: "10.11.12.1",
AccessKey: "AKIATEST",
SecretKey: "SECRET",
AccountID: "123456789012",
NatsToken: "token",
ConfigDir: dir,
OVNNBAddr: "tcp:127.0.0.1:6641",
OVNSBAddr: "tcp:127.0.0.1:6642",
}
require.NoError(t, admin.GenerateConfigFile(path, spinifexTomlTemplate, settings))
data, _ := os.ReadFile(path)
assert.NotContains(t, string(data), "advertise =")
}
// detectedDhcpBindBridge must return the default-route interface name verbatim
// when it's a bridge (Linux or OVS, br-* prefix). The old detectedWanBridge()
// returned hardcoded "br-ext" for Linux bridges — that value broke DHCP on
// consumer-router LANs because br-ext never sees LAN DHCP traffic (mulga-998).
func TestDetectedDhcpBindBridge(t *testing.T) {
tests := []struct {
name string
detected *admin.DetectedNetwork
want string
}{
{
name: "Linux bridge default route returns bridge name (not 'br-ext')",
detected: &admin.DetectedNetwork{WAN: &admin.DetectedInterface{Name: "br-wan"}},
want: "br-wan",
},
{
name: "OVS bridge default route returned verbatim",
detected: &admin.DetectedNetwork{WAN: &admin.DetectedInterface{Name: "br-ext"}},
want: "br-ext",
},
{
name: "physical NIC defaults to 'br-wan' (bridge the installer creates)",
detected: &admin.DetectedNetwork{WAN: &admin.DetectedInterface{Name: "enp0s3"}},
want: "br-wan",
},
{
name: "nil DetectedNetwork",
detected: nil,
want: "",
},
{
name: "DetectedNetwork with nil WAN",
detected: &admin.DetectedNetwork{},
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, detectedDhcpBindBridge(tt.detected))
})
}
}
// Legacy `wan_bridge` TOML key must fail-start vpcd with guidance, not silently
// alias (per mulga-998 D3). Prevents the footgun where operators inherited the
// old key pointing at 'br-ext' and got broken DHCP on veth-mode hosts.
func TestCheckLegacyWanBridgeKey_TOMLRejected(t *testing.T) {
dir := t.TempDir()
cfgPath := filepath.Join(dir, "spinifex.toml")
require.NoError(t, os.WriteFile(cfgPath, []byte(`
node = "node1"
[nodes.node1.vpcd]
ovn_nb_addr = "tcp:127.0.0.1:6641"
wan_bridge = "br-ext"
`), 0o644))
viper.Reset()
t.Cleanup(viper.Reset)
viper.SetConfigFile(cfgPath)
viper.SetConfigType("toml")
require.NoError(t, viper.ReadInConfig())
err := checkLegacyWanBridgeKey("node1", cfgPath)
require.Error(t, err)
assert.Contains(t, err.Error(), "dhcp_bind_bridge")
assert.Contains(t, err.Error(), "wan_bridge")
}
func TestCheckLegacyWanBridgeKey_EnvVarRejected(t *testing.T) {
viper.Reset()
t.Cleanup(viper.Reset)
t.Setenv("SPINIFEX_VPCD_WAN_BRIDGE", "br-ext")
err := checkLegacyWanBridgeKey("node1", "/tmp/unused.toml")
require.Error(t, err)
assert.Contains(t, err.Error(), "SPINIFEX_VPCD_WAN_BRIDGE")
assert.Contains(t, err.Error(), "dhcp_bind_bridge")
}
func TestCheckLegacyWanBridgeKey_CleanConfigPasses(t *testing.T) {
dir := t.TempDir()
cfgPath := filepath.Join(dir, "spinifex.toml")
require.NoError(t, os.WriteFile(cfgPath, []byte(`
node = "node1"
[nodes.node1.vpcd]
ovn_nb_addr = "tcp:127.0.0.1:6641"
dhcp_bind_bridge = "br-wan"
`), 0o644))
viper.Reset()
t.Cleanup(viper.Reset)
viper.SetConfigFile(cfgPath)
viper.SetConfigType("toml")
require.NoError(t, viper.ReadInConfig())
assert.NoError(t, checkLegacyWanBridgeKey("node1", cfgPath))
}
// iam.nats_url must not embed a bare 0.0.0.0 — the predastore process dials
// its own NATS via loopback when listening on wildcard.
func TestPredastoreMultinodeTemplate_NatsURLLoopbackShim(t *testing.T) {
nodes := []admin.PredastoreNodeConfig{
{ID: 1, Host: "10.0.0.1"},
{ID: 2, Host: "10.0.0.2"},
{ID: 3, Host: "10.0.0.3"},
}
content, err := admin.GenerateMultiNodePredastoreConfig(
predastoreMultiNodeTemplate, nodes, "AK", "SK", "ap-southeast-2", "token", "/tmp", "0.0.0.0",
)
require.NoError(t, err)
assert.Contains(t, content, `nats_url = "nats://localhost:4222"`)
assert.NotContains(t, content, `nats_url = "nats://0.0.0.0:4222"`)
// Specific bind IP → stays as-is.
content2, err := admin.GenerateMultiNodePredastoreConfig(
predastoreMultiNodeTemplate, nodes, "AK", "SK", "ap-southeast-2", "token", "/tmp", "10.11.12.1",
)
require.NoError(t, err)
assert.Contains(t, content2, `nats_url = "nats://10.11.12.1:4222"`)
}
+92
View File
@@ -0,0 +1,92 @@
package cmd
import (
"bufio"
"fmt"
"os"
"strings"
"github.com/mulgadc/spinifex/spinifex/migrate"
"github.com/spf13/cobra"
)
var upgradeCmd = &cobra.Command{
Use: "upgrade",
Short: "Apply pending config migrations",
Long: `Apply pending configuration file migrations for upgrades between Spinifex versions.
When run interactively (without --yes), shows pending migrations and prompts
for confirmation. When called from setup.sh with --yes, applies immediately.
Operators can skip migrations during install by setting INSTALL_SPINIFEX_SKIP_MIGRATE=1,
then run 'spx admin upgrade' manually to review and apply.`,
Run: runAdminUpgrade,
}
func runAdminUpgrade(cmd *cobra.Command, _ []string) {
configDir, _ := cmd.Root().Flags().GetString("config-dir")
dataDir, _ := cmd.Root().Flags().GetString("spinifex-dir")
yes, _ := cmd.Flags().GetBool("yes")
// Check that the installation exists.
spinifexToml := configDir + "/spinifex.toml"
if _, err := os.Stat(spinifexToml); os.IsNotExist(err) {
fmt.Fprintf(os.Stderr, "No Spinifex installation found at %s\nRun 'spx admin init' first.\n", configDir)
os.Exit(1)
}
// Show current versions.
versions, err := migrate.DefaultRegistry.ConfigVersions(configDir)
if err != nil {
fmt.Fprintf(os.Stderr, "Error reading config versions: %v\n", err)
os.Exit(1)
}
fmt.Println("Reading current config versions...")
for name, v := range versions {
if v == 0 {
fmt.Printf(" %-20s %d (no version marker)\n", name+":", v)
} else {
fmt.Printf(" %-20s %d\n", name+":", v)
}
}
fmt.Println()
// Check for pending migrations.
pending, err := migrate.DefaultRegistry.PendingConfig(configDir)
if err != nil {
fmt.Fprintf(os.Stderr, "Error checking pending migrations: %v\n", err)
os.Exit(1)
}
if len(pending) == 0 {
fmt.Println("No pending config migrations.")
return
}
fmt.Println("Pending config migrations:")
for _, p := range pending {
fmt.Printf(" [config] %s %d → %d: %s\n", p.Target, p.FromVersion, p.ToVersion, p.Description)
}
fmt.Println()
// Prompt for confirmation unless --yes.
if !yes {
fmt.Print("Apply? [y/N] ")
reader := bufio.NewReader(os.Stdin)
answer, _ := reader.ReadString('\n')
answer = strings.TrimSpace(strings.ToLower(answer))
if answer != "y" && answer != "yes" {
fmt.Println("Aborted.")
return
}
}
// Apply migrations.
if err := migrate.DefaultRegistry.RunAllConfig(configDir, dataDir); err != nil {
fmt.Fprintf(os.Stderr, "Migration failed: %v\n", err)
os.Exit(1)
}
fmt.Println("\nDone. Restart services to apply: sudo systemctl restart spinifex.target")
}
+42 -16
View File
@@ -1,17 +1,18 @@
/*
Copyright © 2025 Mulga Defense Corporation
Copyright © 2026 Mulga Defense Corporation
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
http://www.apache.org/licenses/LICENSE-2.0
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package cmd
@@ -21,9 +22,11 @@ import (
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"github.com/mulgadc/spinifex/spinifex/admin"
"github.com/mulgadc/spinifex/spinifex/config"
"github.com/mulgadc/spinifex/spinifex/types"
"github.com/mulgadc/spinifex/spinifex/utils"
@@ -75,14 +78,20 @@ func loadConfigAndConnect() (*config.ClusterConfig, *nats.Conn, error) {
}
// Backfill BaseDir from config file path when not set in the config.
// Config lives at <baseDir>/config/spinifex.toml, so baseDir is two levels up.
if nodeConfig, ok := cfg.Nodes[cfg.Node]; ok && nodeConfig.BaseDir == "" {
nodeConfig.BaseDir = filepath.Dir(filepath.Dir(cfgPath))
if isProductionLayout() {
// Production: BaseDir is /var/lib/spinifex (data dir).
// /var/lib/spinifex/config is a symlink to /etc/spinifex.
nodeConfig.BaseDir = DefaultDataDir()
} else {
// Dev: config lives at <baseDir>/config/spinifex.toml, so baseDir is two levels up.
nodeConfig.BaseDir = filepath.Dir(filepath.Dir(cfgPath))
}
cfg.Nodes[cfg.Node] = nodeConfig
}
nodeConfig := cfg.Nodes[cfg.Node]
nc, err := utils.ConnectNATS(nodeConfig.NATS.Host, nodeConfig.NATS.ACL.Token)
nc, err := utils.ConnectNATS(admin.DialTarget(nodeConfig.NATS.Host), nodeConfig.NATS.ACL.Token, nodeConfig.NATS.CACert)
if err != nil {
return nil, nil, fmt.Errorf("failed to connect to NATS: %w", err)
}
@@ -138,6 +147,20 @@ func formatUptime(seconds int64) string {
return fmt.Sprintf("%dm", mins)
}
func formatRoles(resp types.NodeStatusResponse) string {
var roles []string
if resp.NATSRole != "" {
roles = append(roles, "nats:"+resp.NATSRole)
}
if resp.PredastoreRole != "" {
roles = append(roles, "predastore:"+resp.PredastoreRole)
}
if len(roles) == 0 {
return "-"
}
return strings.Join(roles, ",")
}
func formatMemGB(gb float64) string {
if gb >= 1.0 {
return fmt.Sprintf("%.1fGi", gb)
@@ -172,7 +195,7 @@ func runGetNodes(cmd *cobra.Command, args []string) {
// Build table: union of config-known nodes + NATS responders
tableData := pterm.TableData{
{"NAME", "STATUS", "IP", "REGION", "AZ", "UPTIME", "VMs", "SERVICES"},
{"NAME", "STATUS", "ROLES", "IP", "REGION", "AZ", "UPTIME", "VMs", "SERVICES"},
}
// Collect all node names: config + responded (union)
@@ -195,17 +218,19 @@ func runGetNodes(cmd *cobra.Command, args []string) {
tableData = append(tableData, []string{
resp.Node,
resp.Status,
formatRoles(resp),
resp.Host,
resp.Region,
resp.AZ,
formatUptime(resp.Uptime),
fmt.Sprintf("%d", resp.VMCount),
strconv.Itoa(resp.VMCount),
strings.Join(resp.Services, ","),
})
} else {
tableData = append(tableData, []string{
name,
"NotReady",
"-",
nodeCfg.Host,
nodeCfg.Region,
nodeCfg.AZ,
@@ -236,6 +261,7 @@ func runGetVMs(cmd *cobra.Command, args []string) {
type vmRow struct {
types.VMInfo
Node string
Host string
}
@@ -277,7 +303,7 @@ func runGetVMs(cmd *cobra.Command, args []string) {
v.InstanceID,
v.Status,
v.InstanceType,
fmt.Sprintf("%d", v.VCPU),
strconv.Itoa(v.VCPU),
formatMemGB(v.MemoryGB),
v.Node,
v.Host,
+49
View File
@@ -0,0 +1,49 @@
package cmd
import (
"testing"
"github.com/mulgadc/spinifex/spinifex/types"
"github.com/stretchr/testify/assert"
)
func TestFormatRoles(t *testing.T) {
tests := []struct {
name string
resp types.NodeStatusResponse
want string
}{
{
name: "no roles",
resp: types.NodeStatusResponse{},
want: "-",
},
{
name: "nats leader only",
resp: types.NodeStatusResponse{NATSRole: "leader"},
want: "nats:leader",
},
{
name: "predastore follower only",
resp: types.NodeStatusResponse{PredastoreRole: "follower"},
want: "predastore:follower",
},
{
name: "both roles",
resp: types.NodeStatusResponse{NATSRole: "leader", PredastoreRole: "follower"},
want: "nats:leader,predastore:follower",
},
{
name: "both leaders",
resp: types.NodeStatusResponse{NATSRole: "leader", PredastoreRole: "leader"},
want: "nats:leader,predastore:leader",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := formatRoles(tt.resp)
assert.Equal(t, tt.want, got)
})
}
}
+34 -9
View File
@@ -2,18 +2,18 @@ package cmd
import (
"os"
"os/user"
"path/filepath"
)
// DefaultConfigDir returns the default configuration directory.
// Production: /etc/spinifex (when running as root or /etc/spinifex exists)
// Production: /etc/spinifex (when /etc/spinifex exists)
// Development: ~/spinifex/config
func DefaultConfigDir() string {
if isProductionLayout() {
return "/etc/spinifex"
}
homeDir, _ := os.UserHomeDir()
return filepath.Join(homeDir, "spinifex", "config")
return filepath.Join(realUserHomeDir(), "spinifex", "config")
}
// DefaultDataDir returns the default data directory.
@@ -23,8 +23,30 @@ func DefaultDataDir() string {
if isProductionLayout() {
return "/var/lib/spinifex"
}
return filepath.Join(realUserHomeDir(), "spinifex")
}
// LogDirFor returns the log directory for a given data directory.
// Production: /var/log/spinifex (matches systemd ReadWritePaths)
// Development: <dataDir>/logs (supports custom per-node data dirs)
func LogDirFor(dataDir string) string {
if isProductionLayout() {
return "/var/log/spinifex"
}
return filepath.Join(dataDir, "logs")
}
// realUserHomeDir returns the home directory of the real (non-sudo) user.
// When running under sudo, SUDO_USER is set to the invoking user — resolve
// their home directory so config/data land in the right place.
func realUserHomeDir() string {
if sudoUser := os.Getenv("SUDO_USER"); sudoUser != "" {
if u, err := user.Lookup(sudoUser); err == nil {
return u.HomeDir
}
}
homeDir, _ := os.UserHomeDir()
return filepath.Join(homeDir, "spinifex")
return homeDir
}
// DefaultConfigFile returns the default path to spinifex.toml.
@@ -32,13 +54,16 @@ func DefaultConfigFile() string {
return filepath.Join(DefaultConfigDir(), "spinifex.toml")
}
// productionMarkerPath identifies a production install by directory presence.
// Created by setup.sh during binary install. Overridable in tests.
var productionMarkerPath = "/etc/spinifex"
// isProductionLayout returns true when running in a production install.
// Detected by: running as root, or /etc/spinifex directory exists.
// No root check — allows non-root users (e.g. tf-user) to run CLI commands
// like `spx get nodes` without sudo or --config flags.
func isProductionLayout() bool {
if os.Getuid() == 0 {
if info, err := os.Stat("/etc/spinifex"); err == nil && info.IsDir() {
return true
}
if info, err := os.Stat(productionMarkerPath); err == nil && info.IsDir() {
return true
}
return false
}
+62 -42
View File
@@ -1,57 +1,77 @@
package cmd
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
)
func TestDefaultConfigDir_Dev(t *testing.T) {
// Non-root user without /etc/spinifex should get dev paths
if os.Getuid() == 0 {
t.Skip("test requires non-root user")
}
homeDir, _ := os.UserHomeDir()
expected := filepath.Join(homeDir, "spinifex", "config")
got := DefaultConfigDir()
if got != expected {
t.Errorf("DefaultConfigDir() = %q, want %q", got, expected)
}
// withProductionMarker temporarily overrides the marker path used to detect a
// production install. Tests that pass an empty string force "dev" mode by
// pointing at a non-existent path; tests that pass t.TempDir() force
// "production" mode by pointing at a real directory.
func withProductionMarker(t *testing.T, path string) {
t.Helper()
orig := productionMarkerPath
productionMarkerPath = path
t.Cleanup(func() { productionMarkerPath = orig })
}
func TestDefaultDataDir_Dev(t *testing.T) {
if os.Getuid() == 0 {
t.Skip("test requires non-root user")
}
homeDir, _ := os.UserHomeDir()
expected := filepath.Join(homeDir, "spinifex")
got := DefaultDataDir()
if got != expected {
t.Errorf("DefaultDataDir() = %q, want %q", got, expected)
}
func TestIsProductionLayout(t *testing.T) {
t.Run("absent marker returns false", func(t *testing.T) {
withProductionMarker(t, filepath.Join(t.TempDir(), "does-not-exist"))
assert.False(t, isProductionLayout())
})
t.Run("present marker returns true", func(t *testing.T) {
withProductionMarker(t, t.TempDir())
assert.True(t, isProductionLayout())
})
}
func TestDefaultConfigFile(t *testing.T) {
if os.Getuid() == 0 {
t.Skip("test requires non-root user")
func TestDefaultPaths(t *testing.T) {
homeDir := t.TempDir()
t.Setenv("HOME", homeDir)
t.Setenv("SUDO_USER", "")
tests := []struct {
name string
productionMode bool
wantConfigDir string
wantDataDir string
wantConfigFile string
wantLogDir string // for LogDirFor("/data/dir")
}{
{
name: "dev layout",
productionMode: false,
wantConfigDir: filepath.Join(homeDir, "spinifex", "config"),
wantDataDir: filepath.Join(homeDir, "spinifex"),
wantConfigFile: filepath.Join(homeDir, "spinifex", "config", "spinifex.toml"),
wantLogDir: "/data/dir/logs",
},
{
name: "production layout",
productionMode: true,
wantConfigDir: "/etc/spinifex",
wantDataDir: "/var/lib/spinifex",
wantConfigFile: "/etc/spinifex/spinifex.toml",
wantLogDir: "/var/log/spinifex",
},
}
got := DefaultConfigFile()
homeDir, _ := os.UserHomeDir()
expected := filepath.Join(homeDir, "spinifex", "config", "spinifex.toml")
if got != expected {
t.Errorf("DefaultConfigFile() = %q, want %q", got, expected)
}
}
func TestIsProductionLayout_NonRoot(t *testing.T) {
if os.Getuid() == 0 {
t.Skip("test requires non-root user")
}
if isProductionLayout() {
t.Error("isProductionLayout() = true for non-root user, want false")
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.productionMode {
withProductionMarker(t, t.TempDir())
} else {
withProductionMarker(t, filepath.Join(t.TempDir(), "absent"))
}
assert.Equal(t, tt.wantConfigDir, DefaultConfigDir())
assert.Equal(t, tt.wantDataDir, DefaultDataDir())
assert.Equal(t, tt.wantConfigFile, DefaultConfigFile())
assert.Equal(t, tt.wantLogDir, LogDirFor("/data/dir"))
})
}
}
+11 -11
View File
@@ -1,17 +1,18 @@
/*
Copyright © 2025 Mulga Defense Corporation
Copyright © 2026 Mulga Defense Corporation
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
http://www.apache.org/licenses/LICENSE-2.0
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package cmd
@@ -102,7 +103,6 @@ func init() {
//viper.BindPFlag("nats.host", rootCmd.PersistentFlags().Lookup("nats-host"))
//viper.BindPFlag("nats.acl.token", rootCmd.PersistentFlags().Lookup("nats-token"))
//viper.BindPFlag("nats.sub.subject", rootCmd.PersistentFlags().Lookup("nats-subject"))
}
// initConfig reads in config file and ENV variables if set.
+102 -34
View File
@@ -1,17 +1,18 @@
/*
Copyright © 2025 Mulga Defense Corporation
Copyright © 2026 Mulga Defense Corporation
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
http://www.apache.org/licenses/LICENSE-2.0
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package cmd
@@ -21,9 +22,9 @@ import (
"os"
"strings"
"github.com/mulgadc/predastore/s3"
"github.com/mulgadc/spinifex/spinifex/config"
"github.com/mulgadc/spinifex/spinifex/service"
"github.com/mulgadc/spinifex/spinifex/services/awsgw"
"github.com/mulgadc/spinifex/spinifex/services/nats"
"github.com/mulgadc/spinifex/spinifex/services/predastore"
"github.com/mulgadc/spinifex/spinifex/services/spinifexui"
@@ -114,15 +115,6 @@ var predastoreStartCmd = &cobra.Command{
return
}
backendType := viper.GetString("backend")
var backend s3.BackendType
if backendType == "distributed" {
backend = s3.BackendDistributed
} else {
backend = s3.BackendFilesystem
}
nodeID := viper.GetInt("node-id")
pprofEnabled := viper.GetBool("pprof")
pprofOutput := viper.GetString("pprof-output")
@@ -136,8 +128,7 @@ var predastoreStartCmd = &cobra.Command{
TlsCert: tlsCert,
TlsKey: tlsKey,
Backend: backend,
NodeID: nodeID,
NodeID: nodeID,
PprofEnabled: pprofEnabled,
PprofOutputPath: pprofOutput,
@@ -243,13 +234,13 @@ var viperblockStartCmd = &cobra.Command{
accessKey := viper.GetString("access-key")
if accessKey != "" {
fmt.Println("Overwriting access-key:", accessKey)
fmt.Println("Overwriting access-key: ****")
nodeConfig.Predastore.AccessKey = accessKey
}
secretKey := viper.GetString("secret-key")
if secretKey != "" {
fmt.Println("Overwriting secret-key:", secretKey)
fmt.Println("Overwriting secret-key: ****")
nodeConfig.Predastore.SecretKey = secretKey
}
@@ -285,6 +276,8 @@ var viperblockStartCmd = &cobra.Command{
service, err := service.New("viperblock", &viperblockd.Config{
NatsHost: nodeConfig.NATS.Host,
NatsToken: nodeConfig.NATS.ACL.Token,
NatsCACert: nodeConfig.NATS.CACert,
PluginPath: pluginPath,
S3Host: nodeConfig.Predastore.Host,
Bucket: nodeConfig.Predastore.Bucket,
@@ -308,7 +301,7 @@ var viperblockStartCmd = &cobra.Command{
return
}
fmt.Println("Viperblock service started", service)
fmt.Println("Viperblock service started")
},
}
@@ -556,6 +549,7 @@ var awsgwStartCmd = &cobra.Command{
// Apply changes back to cluster config
clusterConfig.Nodes[clusterConfig.Node] = nodeConfig
awsgw.SetBuildInfo(Version, Commit)
service, err := service.New("awsgw", clusterConfig)
if err != nil {
@@ -674,6 +668,32 @@ var spinifexUIStatusCmd = &cobra.Command{
},
}
// checkLegacyWanBridgeKey fails vpcd startup if the deprecated `wan_bridge`
// TOML key or `SPINIFEX_VPCD_WAN_BRIDGE` env-var is present. The key was
// renamed to `dhcp_bind_bridge` (see mulga-998.a) because the old name misled
// operators into pointing at the OVN-side bridge ("br-ext"), which never sees
// LAN DHCP traffic. Per D3: no silent alias, no auto-rewrite — operator must
// rename the key before vpcd will start.
func checkLegacyWanBridgeKey(node, cfgFile string) error {
legacyInTOML := viper.IsSet("nodes." + node + ".vpcd.wan_bridge")
legacyInEnv := os.Getenv("SPINIFEX_VPCD_WAN_BRIDGE") != ""
if !legacyInTOML && !legacyInEnv {
return nil
}
source := cfgFile
if legacyInEnv {
source = "env SPINIFEX_VPCD_WAN_BRIDGE"
}
return fmt.Errorf(
"vpcd: deprecated 'wan_bridge' key found in %s. "+
"Rename 'wan_bridge' to 'dhcp_bind_bridge'. The value may also be wrong — verify it is "+
"the Linux bridge holding your WAN NIC (veth mode) or the OVS bridge holding your WAN NIC (direct mode); "+
"never 'br-ext'. Typical value on a consumer-router LAN: 'br-wan'. "+
"Then: sudo systemctl restart spinifex-vpcd",
source,
)
}
var vpcdStartCmd = &cobra.Command{
Use: "start",
Short: "Start the vpcd service",
@@ -692,15 +712,65 @@ var vpcdStartCmd = &cobra.Command{
return
}
if err := checkLegacyWanBridgeKey(clusterConfig.Node, cfgFile); err != nil {
slog.Error(err.Error())
os.Exit(1)
}
nodeConfig := clusterConfig.Nodes[clusterConfig.Node]
// Map cluster-wide external pools to vpcd config
var extPools []vpcd.ExternalPoolConfig
for _, p := range clusterConfig.Network.ExternalPools {
extPools = append(extPools, vpcd.ExternalPoolConfig{
Name: p.Name,
Source: p.Source,
RangeStart: p.RangeStart,
RangeEnd: p.RangeEnd,
Gateway: p.Gateway,
GatewayIP: p.GatewayIP,
PrefixLen: p.PrefixLen,
DNSServers: p.DNSServers,
Region: p.Region,
AZ: p.AZ,
DhcpBindBridge: nodeConfig.VPCD.DhcpBindBridge,
GwLrpRangeStart: p.GwLrpRangeStart,
GwLrpRangeEnd: p.GwLrpRangeEnd,
})
}
var bootstrap *vpcd.BootstrapVPC
if clusterConfig.Bootstrap.VpcId != "" {
bootstrap = &vpcd.BootstrapVPC{
AccountID: clusterConfig.Bootstrap.AccountID,
VpcId: clusterConfig.Bootstrap.VpcId,
SubnetId: clusterConfig.Bootstrap.SubnetId,
IgwId: clusterConfig.Bootstrap.IgwId,
Cidr: clusterConfig.Bootstrap.Cidr,
SubnetCidr: clusterConfig.Bootstrap.SubnetCidr,
}
}
baseDir := viper.GetString("base-dir")
if baseDir != "" {
fmt.Println("Overwriting vpcd base-dir to:", baseDir)
nodeConfig.BaseDir = baseDir
}
svc, err := service.New("vpcd", &vpcd.Config{
NatsHost: nodeConfig.NATS.Host,
NatsToken: nodeConfig.NATS.ACL.Token,
OVNNBAddr: nodeConfig.VPCD.OVNNBAddr,
OVNSBAddr: nodeConfig.VPCD.OVNSBAddr,
BaseDir: nodeConfig.BaseDir,
Debug: false,
NatsHost: nodeConfig.NATS.Host,
NatsToken: nodeConfig.NATS.ACL.Token,
NatsCACert: nodeConfig.NATS.CACert,
OVNNBAddr: nodeConfig.VPCD.OVNNBAddr,
OVNSBAddr: nodeConfig.VPCD.OVNSBAddr,
BaseDir: nodeConfig.BaseDir,
Debug: false,
ExternalMode: clusterConfig.Network.ExternalMode,
ExternalPools: extPools,
Bootstrap: bootstrap,
ExternalInterface: nodeConfig.VPCD.ExternalInterface,
DhcpBindBridge: nodeConfig.VPCD.DhcpBindBridge,
BridgeMode: nodeConfig.VPCD.BridgeMode,
})
if err != nil {
fmt.Println("Error starting vpcd service:", err)
@@ -744,7 +814,6 @@ var vpcdStatusCmd = &cobra.Command{
}
func init() {
viper.SetEnvPrefix("SPINIFEX") // Prefix for environment variables
viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_"))
@@ -815,7 +884,7 @@ func init() {
serviceCmd.AddCommand(viperblockCmd)
viperblockCmd.PersistentFlags().String("s3-host", "0.0.0.0:8443", "Predastore (S3) host URI")
viperblockCmd.PersistentFlags().String("s3-host", "", "Predastore (S3) host URI")
viper.BindEnv("s3-host", "SPINIFEX_VIPERBLOCK_S3_HOST")
viper.BindPFlag("s3-host", predastoreCmd.PersistentFlags().Lookup("s3-host"))
@@ -928,5 +997,4 @@ func init() {
vpcdCmd.AddCommand(vpcdStartCmd)
vpcdCmd.AddCommand(vpcdStopCmd)
vpcdCmd.AddCommand(vpcdStatusCmd)
}
+23 -1
View File
@@ -1,6 +1,28 @@
# AWS Gateway configuration file in TOML format.
version = "1.0"
version = "2"
region = "{{.Region}}"
# Enable verbose debugging
debug = false
# API request throttling (per-account, per-action token bucket)
[ratelimit]
enabled = false
rate = 20 # Default sustained requests per second per account per action
burst = 100 # Default maximum burst capacity
[ratelimit.action.RunInstances]
rate = 2
burst = 40
[ratelimit.action.TerminateInstances]
rate = 2
burst = 40
[ratelimit.action.StartInstances]
rate = 2
burst = 40
[ratelimit.action.StopInstances]
rate = 2
burst = 40
+18 -8
View File
@@ -1,3 +1,4 @@
# spinifex-config-version: 3
# NATS Server Configuration
# Generated by Spinifex admin init
@@ -8,15 +9,28 @@ listen: {{.BindIP}}:4222
listen: 0.0.0.0:4222
{{- end }}
tls {
cert_file: "{{.ConfigDir}}/server.pem"
key_file: "{{.ConfigDir}}/server.key"
ca_file: "{{.ConfigDir}}/ca.pem"
}
{{- if and .ClusterBindIP .ClusterRoutes }}
cluster {
name: {{ .ClusterName }}
listen: {{.ClusterBindIP}}:4248
tls {
cert_file: "{{.ConfigDir}}/server.pem"
key_file: "{{.ConfigDir}}/server.key"
ca_file: "{{.ConfigDir}}/ca.pem"
verify: true
}
routes = [
{{- range .ClusterRoutes }}
"nats://{{ . }}",
"nats-route://{{$.NatsToken}}@{{ . }}",
{{- end }}
]
}
@@ -26,7 +40,7 @@ server_name: spinifex-nats-{{.Node}}
# Authorization
authorization {
# token: "{{.NatsToken}}"
token: "{{.NatsToken}}"
}
# JetStream for persistent messaging and KV store
@@ -40,7 +54,7 @@ jetstream {
debug: false
trace: false
logtime: true
log_file: "{{.DataDir}}/logs/nats.log"
log_file: "{{.LogDir}}/nats.log"
# Limits
max_connections: 1000
@@ -48,9 +62,5 @@ max_payload: 1MB
max_pending: 64MB
# Monitoring
{{- if .BindIP }}
http: {{.BindIP}}:8222
{{- else }}
http_port: 8222
{{- end }}
http: 127.0.0.1:8222
@@ -1,6 +1,6 @@
# Predastore S3-compatible storage configuration file in TOML format.
# Generated for multi-node cluster
version = "1.0"
version = "3"
region = "{{.Region}}"
host = "0.0.0.0"
@@ -47,9 +47,24 @@ policy = [
{ bucket = "predastore", actions = ["s3:ListBucket", "s3:GetObject", "s3:PutObject", "s3:DeleteObject", "s3:ListAllMyBuckets"] },
]
# API request throttling (per-account, per-action token bucket)
[ratelimit]
enabled = false
rate = 100 # Default sustained requests per second per account per action
burst = 500 # Default maximum burst capacity
[ratelimit.action."s3:PutObject"]
rate = 50
burst = 200
[ratelimit.action."s3:DeleteObject"]
rate = 50
burst = 200
# IAM authentication via NATS KV (enables multi-account S3 access)
[iam]
nats_url = "nats://{{.BindIP}}:4222"
nats_url = "nats://{{if eq .BindIP "0.0.0.0"}}localhost{{else}}{{.BindIP}}{{end}}:4222"
nats_token = "{{.NatsToken}}"
nats_ca_cert = "{{.ConfigDir}}/ca.pem"
master_key_path = "{{.ConfigDir}}/master.key"
access_keys_bucket = "spinifex-iam-access-keys"
+16 -1
View File
@@ -1,6 +1,6 @@
# Predastore S3-compatible storage configuration file in TOML format.
# MULTI NODE used for testing
version = "1.0"
version = "3"
region = "{{.Region}}"
# Optionally configure port and host, or specify via daemon CLI options
@@ -81,9 +81,24 @@ policy = [
{ bucket = "predastore", actions = ["s3:ListBucket", "s3:GetObject", "s3:PutObject", "s3:DeleteObject", "s3:ListAllMyBuckets"] },
]
# API request throttling (per-account, per-action token bucket)
[ratelimit]
enabled = false
rate = 100 # Default sustained requests per second per account per action
burst = 500 # Default maximum burst capacity
[ratelimit.action."s3:PutObject"]
rate = 50
burst = 200
[ratelimit.action."s3:DeleteObject"]
rate = 50
burst = 200
# IAM authentication via NATS KV (enables multi-account S3 access)
[iam]
nats_url = "nats://{{if eq .BindIP "0.0.0.0"}}localhost{{else}}{{.BindIP}}{{end}}:4222"
nats_token = "{{.NatsToken}}"
nats_ca_cert = "{{.ConfigDir}}/ca.pem"
master_key_path = "{{.ConfigDir}}/master.key"
access_keys_bucket = "spinifex-iam-access-keys"
+63 -2
View File
@@ -1,11 +1,55 @@
# Domain configuration file in TOML format.
version = "1.0"
version = "2"
epoch = 1
node = "{{.Node}}"
{{- if .OperatorEmail}}
[operator]
email = "{{.OperatorEmail}}"
{{- end}}
{{- if .ExternalMode}}
[network]
external_mode = "{{.ExternalMode}}"
{{- if .ExternalDHCP}}
external_dhcp = true
{{- end}}
[[network.external_pools]]
name = "{{.PoolName}}"
{{- if .PoolSource}}
source = "{{.PoolSource}}"
{{- end}}
{{- if .PoolStart}}
range_start = "{{.PoolStart}}"
range_end = "{{.PoolEnd}}"
{{- end}}
gateway = "{{.PoolGateway}}"
{{- if .PoolGatewayIP}}
gateway_ip = "{{.PoolGatewayIP}}"
{{- end}}
prefix_len = {{if .PoolPrefixLen}}{{.PoolPrefixLen}}{{else}}24{{end}}
{{- if .PoolDNSServers}}
dns_servers = [{{range $i, $s := .PoolDNSServers}}{{if $i}}, {{end}}"{{$s}}"{{end}}]
{{- end}}
{{- end}}
{{- if .BootstrapVpcId}}
[bootstrap]
account_id = "{{.BootstrapAccountId}}"
vpc_id = "{{.BootstrapVpcId}}"
subnet_id = "{{.BootstrapSubnetId}}"
igw_id = "{{.BootstrapIgwId}}"
cidr = "{{.BootstrapCidr}}"
subnet_cidr = "{{.BootstrapSubnetCidr}}"
{{- end}}
[nodes.{{.Node}}]
node = "{{.Node}}"
host = "{{.BindIP}}"
{{- if .AdvertiseIP}}
advertise = "{{.AdvertiseIP}}"
{{- end}}
region = "{{.Region}}"
az = "{{.Az}}"
{{- if .Services}}
@@ -18,7 +62,11 @@ account_id = "{{.AccountID}}"
host = "{{.BindIP}}:{{.Port}}"
tlskey = "config/server.key"
tlscert = "config/server.pem"
{{- if .ExternalMode}}
dev_networking = false
{{- else}}
dev_networking = true
{{- end}}
[nodes.{{.Node}}.awsgw]
host = "{{.BindIP}}:9999"
@@ -29,6 +77,7 @@ config = "awsgw/awsgw.toml"
[nodes.{{.Node}}.nats]
host = "{{.BindIP}}:4222"
cacert = "{{.ConfigDir}}/ca.pem"
[nodes.{{.Node}}.nats.acl]
token = "{{.NatsToken}}"
@@ -41,6 +90,18 @@ shardwal = false
[nodes.{{.Node}}.vpcd]
ovn_nb_addr = "{{.OVNNBAddr}}"
ovn_sb_addr = "{{.OVNSBAddr}}"
{{- if .ExternalIface}}
external_interface = "{{.ExternalIface}}"
{{- end}}
{{- if .DhcpBindBridge}}
# dhcp_bind_bridge: the bridge where vpcd's DHCP client binds its AF_PACKET
# socket. On Linux-bridge-WAN hosts (netplan default with the WAN NIC enslaved),
# this is the Linux bridge name (e.g. "br-wan"). On direct-OVS hosts, this is
# the OVS bridge holding the WAN NIC.
# NEVER set this to "br-ext" — that is the OVN-side bridge and never sees LAN
# DHCP traffic.
dhcp_bind_bridge = "{{.DhcpBindBridge}}"
{{- end}}
[nodes.{{.Node}}.predastore]
host = "{{.BindIP}}:8443"
@@ -61,4 +122,4 @@ az = "{{.AZ}}"
{{- if .Services}}
services = [{{range $i, $s := .Services}}{{if $i}}, {{end}}"{{$s}}"{{end}}]
{{- end}}
{{- end}}
{{- end}}
+15 -13
View File
@@ -1,17 +1,18 @@
/*
Copyright © 2025 Mulga Defense Corporation
Copyright © 2026 Mulga Defense Corporation
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
http://www.apache.org/licenses/LICENSE-2.0
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package cmd
@@ -20,6 +21,7 @@ import (
"fmt"
"os"
"sort"
"strconv"
"time"
"github.com/mulgadc/spinifex/spinifex/types"
@@ -98,7 +100,7 @@ func runTopNodes(cmd *cobra.Command, args []string) {
resp.Node,
fmt.Sprintf("%d/%d", resp.AllocVCPU, resp.TotalVCPU),
fmt.Sprintf("%s/%s", formatMemGB(resp.AllocMemGB), formatMemGB(resp.TotalMemGB)),
fmt.Sprintf("%d", resp.VMCount),
strconv.Itoa(resp.VMCount),
})
for _, cap := range resp.InstanceTypes {
@@ -145,8 +147,8 @@ func runTopNodes(cmd *cobra.Command, args []string) {
agg := capacityMap[name]
capTable = append(capTable, []string{
name,
fmt.Sprintf("%d", agg.Available),
fmt.Sprintf("%d", agg.VCPU),
strconv.Itoa(agg.Available),
strconv.Itoa(agg.VCPU),
formatMemGB(agg.MemoryGB),
})
}
-29
View File
@@ -1,29 +0,0 @@
package cmd
import (
"testing"
)
func TestVersionVarsExist(t *testing.T) {
// Verify the version variables are set (defaults when not injected via ldflags)
if Version == "" {
t.Error("Version should not be empty")
}
if Commit == "" {
t.Error("Commit should not be empty")
}
}
func TestVersionCommand(t *testing.T) {
// Verify the version command is registered
found := false
for _, cmd := range rootCmd.Commands() {
if cmd.Use == "version" {
found = true
break
}
}
if !found {
t.Error("version command not registered on rootCmd")
}
}
+11 -10
View File
@@ -1,17 +1,18 @@
/*
Copyright © 2025 Mulga Defense Corporation
Copyright © 2026 Mulga Defense Corporation
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
http://www.apache.org/licenses/LICENSE-2.0
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package main
-7
View File
@@ -1,7 +0,0 @@
#!/bin/sh
# Linux
#genisoimage -output seed.iso -volid cidata -joliet -rock user-data meta-data
# MacOS
mkisofs -output seed.iso -volid cidata -joliet -rock meta-data user-data
-5
View File
@@ -1,5 +0,0 @@
#!/bin/sh
# Example to launch nbdkit
./server/nbdkit -p 10810 --pidfile /tmp/vb-vol-1-efi.pid ./plugins/golang/examples/ramdisk/nbdkit-goramdisk-plugin.so -v -f size=67108864 volume=vol-50c968e8dbcc74bd2-efi bucket=predastore region=ap-southeast-2 access_key="AKIAIOSFODNN7EXAMPLE" secret_key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" base_dir="/tmp/vb/" host="https://127.0.0.1:8443" cache_size=1
-28
View File
@@ -1,28 +0,0 @@
#!/bin/sh
qemu-system-aarch64 \
-nographic \
-M virt,highmem=off \
-accel hvf \
-cpu host \
-smp 4 \
-m 3000 \
-drive file=/Users/benduncan/qemu/QEMU_EFI.img,if=pflash,format=raw \
-device virtio-blk-pci,drive=debian,bootindex=1 \
-drive if=none,media=disk,id=debian,format=raw,file=nbd://192.168.64.5:10809/default \
-drive format=raw,file=nbd://192.168.64.5:10810/default,if=pflash \
-drive file=nbd://192.168.64.5:10811/default,format=raw,if=virtio \
-device qemu-xhci \
-device usb-kbd \
-device usb-tablet \
-device intel-hda \
-device hda-duplex \
-netdev user,id=net0,hostfwd=tcp::2222-:22 -device virtio-net-device,netdev=net0 \
-device virtio-rng-pci
# ./server/nbdkit -p 10809 --pidfile /tmp/vb-vol-1.pid ./plugins/golang/examples/ramdisk/nbdkit-goramdisk-plugin.so -v -f size=4294967296 volume=vol-e660ff0065ad21f74 bucket=predastore region=ap-southeast-2 access_key="AKIAIOSFODNN7EXAMPLE" secret_key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" base_dir="/tmp/vb/" host="https://127.0.0.1:8443" cache_size=20
# ./server/nbdkit -p 10810 --pidfile /tmp/vb-vol-1-efi.pid ./plugins/golang/examples/ramdisk/nbdkit-goramdisk-plugin.so -v -f size=67108864 volume=vol-e660ff0065ad21f74-efi bucket=predastore region=ap-southeast-2 access_key="AKIAIOSFODNN7EXAMPLE" secret_key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" base_dir="/tmp/vb/" host="https://127.0.0.1:8443" cache_size=1
# ./server/nbdkit -p 10811 --pidfile /tmp/vb-vol-1-cloudinit.pid ./plugins/golang/examples/ramdisk/nbdkit-goramdisk-plugin.so -v -f size=1048576 volume=vol-e660ff0065ad21f74-cloudinit bucket=predastore region=ap-southeast-2 access_key="AKIAIOSFODNN7EXAMPLE" secret_key="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" base_dir="/tmp/vb/" host="https://127.0.0.1:8443" cache_size=1
+257 -67
View File
@@ -13,7 +13,7 @@ Service lifecycle commands for starting, stopping, and checking status of all Sp
| `spx service predastore start` | `--port` (default: 8443), `--host` (default: 0.0.0.0), `--base-path`, `--config-path`, `--debug`, `--tls-cert`, `--tls-key`, `--backend` (distributed/filesystem, default: distributed), `--node-id` (default: 0), `--pprof`, `--pprof-output` | TLS cert/key, base-path, config-path required | Creates predastore service instance with S3-compatible storage backend → starts service | **DONE** |
| `spx service predastore stop` | — | Predastore must be running | Stops the predastore service | **DONE** |
| `spx service predastore status` | — | None | Reports predastore service status | **DONE** |
| `spx service viperblock start` | `--s3-host` (default: 0.0.0.0:8443), `--s3-bucket` (default: predastore), `--s3-region` (default: ap-southeast-2), `--plugin-path` (default: /opt/spinifex/lib/nbdkit-viperblock-plugin.so) | `--config` required, plugin-path must exist on disk | Loads cluster config → connects to NATS and Predastore → starts viperblock block storage service with NBD plugin | **DONE** |
| `spx service viperblock start` | `--s3-host` (default: 0.0.0.0:8443), `--s3-bucket` (default: predastore), `--s3-region` (default: ap-southeast-2), `--plugin-path` (auto-detected via `nbdkit --dump-config plugindir`; typically `/usr/lib/x86_64-linux-gnu/nbdkit/plugins/nbdkit-viperblock-plugin.so` on amd64, overridable via `SPINIFEX_VIPERBLOCK_PLUGIN_PATH` in `/etc/spinifex/systemd.env`) | `--config` required, plugin-path must exist on disk | Loads cluster config → connects to NATS and Predastore → starts viperblock block storage service with NBD plugin | **DONE** |
| `spx service viperblock stop` | — | Viperblock must be running | Stops the viperblock service | **DONE** |
| `spx service viperblock status` | — | None | Reports viperblock service status | **DONE** |
| `spx service nats start` | `--port` (default: 4222), `--host` (default: 0.0.0.0), `--debug`, `--data-dir`, `--jetstream` | None | Starts embedded NATS server with optional JetStream | **DONE** |
@@ -51,14 +51,32 @@ Operational commands for inspecting cluster state. These fan out NATS requests t
| Command | Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------|---------------|-------------|------------|--------|
| `spx admin init` | `--nodes`, `--node`, `--bind`, `--port`, `--region`, `--az`, `--cluster-name`, `--cluster-bind`, `--cluster-routes`, `--predastore-nodes`, `--services`, `--formation-timeout`, `--force` | None (first-time setup) | Generates root IAM credentials (AKIA-prefixed access key + secret) → creates master.key (AES-256, 32 bytes, 0600) → writes bootstrap.json (consumed on first start) → generates CA + server TLS certificates → creates NATS config with auth token → writes spinifex.toml, awsgw.toml, predastore.toml → configures AWS CLI `spx` profile → creates directory structure under `~/spinifex/` | 1. Init creates all config files<br>2. Root credentials printed once<br>3. master.key is 32 bytes, mode 0600<br>4. bootstrap.json consumed on first start<br>5. `--force` re-initializes existing config<br>6. AWS CLI profile `spx` auto-configured | **DONE** |
| `spx admin join` | `--host` (required), `--node` (required), `--bind`, `--port`, `--region`, `--az`, `--cluster-bind`, `--cluster-routes`, `--data-dir`, `--services` | Leader node must be running | Connects to leader node → retrieves cluster configuration → configures local node to join cluster and participate in distributed operations | 1. Join existing cluster<br>2. Missing host (error)<br>3. Missing node name (error) | **DONE** |
| `spx admin init` | `--nodes`, `--node`, `--bind`, `--port`, `--region`, `--az`, `--cluster-name`, `--cluster-bind`, `--cluster-routes`, `--predastore-nodes`, `--services`, `--formation-timeout`, `--token-ttl`, `--force` | None (first-time setup) | Generates root IAM credentials (AKIA-prefixed access key + secret) → creates master.key (AES-256, 32 bytes, 0600) → writes bootstrap.json (consumed on first start) → generates CA + server TLS certificates → generates join token (written to `join-token` file, displayed in join command) → creates NATS config with auth token → writes spinifex.toml, awsgw.toml, predastore.toml → configures AWS CLI `spx` profile → creates directory structure under `~/spinifex/` | 1. Init creates all config files<br>2. Root credentials printed once<br>3. master.key is 32 bytes, mode 0600<br>4. bootstrap.json consumed on first start<br>5. `--force` re-initializes existing config<br>6. AWS CLI profile `spx` auto-configured<br>7. Multi-node init generates join token and writes to `<config-dir>/join-token` | **DONE** |
| `spx admin join` | `--host` (required), `--node` (required), `--token` (required), `--bind`, `--port`, `--region`, `--az`, `--cluster-bind`, `--cluster-routes`, `--data-dir`, `--services` | Leader node must be running | Connects to leader node with join token (Authorization: Bearer header) → retrieves cluster configuration → configures local node to join cluster and participate in distributed operations | 1. Join existing cluster<br>2. Missing host (error)<br>3. Missing node name (error)<br>4. Missing token (error)<br>5. Invalid token (401)<br>6. Expired token (401) | **DONE** |
### Version
| Command | Flags | Prerequisites | Basic Logic | Status |
|---------|-------|---------------|-------------|--------|
| `spx version` | — | None | Prints Spinifex version, commit hash, OS, and architecture (populated via build-time ldflags) | **DONE** |
### Cluster Operations
| Command | Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------|---------------|-------------|------------|--------|
| `spx admin cluster shutdown` | — | Cluster must be running | Gracefully shuts down the entire cluster | 1. Shutdown running cluster<br>2. All nodes stop cleanly | **DONE** |
| `spx admin cluster shutdown` | `--force` (shutdown even if nodes don't respond), `--timeout` (max wait per phase, default 120s), `--dry-run` (print phase plan without executing) | Cluster must be running | Performs coordinated, phased shutdown of entire cluster. Phases execute in order: GATE (stop API/UI) → DRAIN (stop VMs) → STORAGE (stop viperblock) → PERSIST (stop predastore) → INFRA (stop NATS/daemon). Each phase waits for all nodes to ACK before proceeding. Uses JetStream state tracking. | 1. Shutdown running cluster<br>2. All nodes stop cleanly<br>3. Force shutdown with unresponsive nodes<br>4. Dry-run prints plan | **DONE** |
### Certificate Management
| Command | Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------|---------------|-------------|------------|--------|
| `spx admin cert renew` | `--extra-ip` (additional IPs for SANs), `--extra-dns` (additional DNS names for SANs) | Existing CA from `spx admin init` | Reads existing CA → regenerates server certificate with all current network interface IPs and machine hostname in SANs → writes new cert. Use after adding a new network interface or changing IP addresses. | 1. Renew with auto-detected IPs<br>2. Renew with extra IPs<br>3. Renew with extra DNS names | **DONE** |
### Upgrade Management
| Command | Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------|---------------|-------------|------------|--------|
| `spx admin upgrade` | `--yes` (apply without prompting), `--config-dir` (persistent, default: `~/spinifex/config`), `--spinifex-dir` (persistent, default: `~/spinifex`) | Existing installation (`spinifex.toml` must exist in config dir) | Reads current config versions from registry → computes pending config migrations (from→to per target) → prints version summary and pending list → prompts for confirmation unless `--yes` → runs `migrate.DefaultRegistry.RunAllConfig()` to apply migrations to config files. Intended for upgrades between Spinifex versions. Operators can skip migrations during install by setting `INSTALL_SPINIFEX_SKIP_MIGRATE=1`, then run `spx admin upgrade` manually to review and apply. After completion, services must be restarted with `sudo systemctl restart spinifex.target`. Invoked non-interactively by `scripts/setup.sh` with `--yes`. | 1. No installation present (error, suggests `spx admin init`)<br>2. No pending migrations (prints "No pending config migrations")<br>3. Pending migrations with interactive confirmation (y/yes applies, anything else aborts)<br>4. Pending migrations with `--yes` (applies without prompt)<br>5. Migration failure surfaces error and exits non-zero | **DONE** |
### Account Management
@@ -71,24 +89,57 @@ Operational commands for inspecting cluster state. These fan out NATS requests t
| Command | Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------|---------------|-------------|------------|--------|
| `spx admin images import` | `--name` | Cluster must be running, image file must exist locally | Imports a local disk image (QCOW2) as an AMI → uploads to Predastore S3 → registers as available AMI | 1. Import valid QCOW2 image<br>2. Image appears in `describe-images` | **DONE** |
| `spx admin images import` | `--name`, `--file`, `--force`, `--skip-verify` | Cluster must be running; either `--name` (catalog download) or `--file` (operator-supplied media) | Catalog imports (`--name`) download the image, fetch the catalog `Checksum` URL, and verify the SHA-256/SHA-512 digest before extraction. Mismatch fails closed; the cached file is left on disk and `--force` re-downloads. `--file` imports skip verification — operator-supplied media is outside Spinifex's trust boundary, and the skip is logged at INFO for audit. `--skip-verify` bypasses verification for catalog imports and emits a WARN slog + stderr notice; use only for debugging or when upstream mirrors are confirmed-broken. | 1. Import valid catalog image (verifies checksum)<br>2. Tampered cache hit fails with `ErrChecksumMismatch`<br>3. `--force` recovers after a mismatch<br>4. `--file` import skips verification<br>5. `--skip-verify` bypasses checksum with WARN | **DONE** |
| `spx admin images list` | — | None | Lists available OS images that can be imported or downloaded | 1. List available images | **DONE** |
#### Image integrity verification (CMMC SI.L1-3.14.2)
Catalog imports (`spx admin images import --name <name>`) verify the image
against the catalog-declared SHA-256/SHA-512 digest before extraction. The sums
file is fetched from the catalog `Checksum` URL over HTTPS only (cross-scheme
redirects refused), and verification runs on both fresh downloads and cache
hits so a poisoned cache is caught on the next import.
On mismatch the import exits non-zero, the cached file is left on disk for
inspection, and the printed guidance is `spx admin images import --name <name>
--force` to re-download.
`--file` imports skip verification by design: operator-supplied media is
outside Spinifex's trust boundary and the operator is responsible for
integrity (e.g. `sha256sum` against a trusted upstream digest before import).
The skip is recorded as an INFO `slog` event with `reason=local-file-import`
so a CMMC assessor can audit the decision from journald.
`--skip-verify` bypasses the checksum step for catalog imports. The command
still downloads via the catalog URL but does not compare the image digest
against the sums file. Intended for narrow cases such as debugging upstream
mirror issues or running against a transiently-broken `latest/` path; the
skip is logged at WARN with `reason=skip-verify-flag` and printed to stderr
so operators and assessors see it. Prefer `--file` with an out-of-band
verified image over `--skip-verify` whenever possible.
**Limitation:** verification confirms the image matches the digest the mirror
served. A mirror compromise that swaps both image and sums file is not
detected; closing that gap requires GPG signature verification of the sums
file, deferred to a later phase.
## AWS Commands
### EC2 - Instance Management
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------------------|---------------|---------------|-------------|------------|--------|
| `run-instances` | `--image-id`, `--instance-type`, `--count` (Min/MaxCount), `--key-name`, `--user-data`, `--subnet-id` (auto-creates ENI, assigns private IP), `--block-device-mappings` (DeviceName, VolumeSize, VolumeType, Iops, DeleteOnTermination) | `--security-group-ids`, `--placement`, `--tag-specifications`, `--dry-run`, `--client-token`, `--disable-api-termination`, `--ebs-optimized`, `--iam-instance-profile`, `--network-interfaces`, `--private-ip-address`, `--monitoring`, `--credit-specification`, `--cpu-options`, `--metadata-options`, `--launch-template`, `--hibernate-options` | `describe-images` (AMI must exist), `create-key-pair` (optional), VPC/SG (optional) | Gateway parses AWS query → NATS `ec2.runinstances` → daemon creates QEMU/KVM VM with viperblock-backed root volume via NBD → if SubnetId provided, auto-creates ENI with private IP → cloud-init injects user-data/keys → returns reservation with instance ID | 1. Launch with valid AMI and key pair<br>2. Launch with invalid AMI ID (error)<br>3. Launch with block device mappings (custom volume size)<br>4. Launch multiple instances (MinCount/MaxCount)<br>5. Launch with subnet-id (auto-creates ENI)<br>6. Invalid instance type returns error | **DONE** |
| `describe-instances` | `--instance-ids` | `--filters`, `--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 | 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 | **DONE** |
| `run-instances` | `--image-id`, `--instance-type`, `--count` (Min/MaxCount), `--key-name`, `--user-data`, `--subnet-id` (auto-creates ENI, assigns private IP), `--block-device-mappings` (DeviceName, VolumeSize, VolumeType, Iops, DeleteOnTermination), `--placement` (GroupName only — routes via spread or cluster strategy) | `--security-group-ids`, `--tag-specifications`, `--dry-run`, `--client-token`, `--disable-api-termination`, `--ebs-optimized`, `--iam-instance-profile`, `--network-interfaces`, `--private-ip-address`, `--monitoring`, `--credit-specification`, `--cpu-options`, `--metadata-options`, `--launch-template`, `--hibernate-options` | `describe-images` (AMI must exist), `create-key-pair` (optional), VPC/SG (optional) | Gateway parses AWS query → if Placement.GroupName set, looks up strategy: spread → `distributeInstancesSpread()` (1 instance per node, atomic CAS reservation), cluster → `distributeInstancesCluster()` (pin all to single node); otherwise NATS `ec2.runinstances` → daemon creates QEMU/KVM VM with viperblock-backed root volume via NBD → if SubnetId provided, auto-creates ENI with private IP → cloud-init injects user-data/keys → on termination, removes instance from placement group → returns reservation with instance ID | 1. Launch with valid AMI and key pair<br>2. Launch with invalid AMI ID (error)<br>3. Launch with block device mappings (custom volume size)<br>4. Launch multiple instances (MinCount/MaxCount)<br>5. Launch with subnet-id (auto-creates ENI)<br>6. Invalid instance type returns error<br>7. Launch with spread placement group (1 per node)<br>8. Launch with cluster placement group (all on one node)<br>9. Insufficient capacity for placement group (error) | **DONE** |
| `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** |
| `reboot-instances` | — | `--instance-ids`, `--dry-run` | `run-instances` (instance must be running) | Gateway sends NATS to target node → daemon issues QMP `system_reset` → instance reboots without stopping | 1. Reboot running instance<br>2. Reboot stopped instance (error)<br>3. Verify instance stays in running state after reboot | **NOT STARTED** (parser test exists) |
| `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** |
| `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-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** |
| `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** |
@@ -97,7 +148,7 @@ Operational commands for inspecting cluster state. These fan out NATS requests t
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------------------|---------------|---------------|-------------|------------|--------|
| `create-key-pair` | `--key-name`, `--key-type` (rsa/ed25519) | `--key-format` (pem/ppk), `--tag-specifications`, `--dry-run` | None | NATS `ec2.CreateKeyPair` → daemon generates SSH keypair → stores public key in Predastore S3 (`/bucket/ec2/{name}.pub`) → returns private key material (one-time) | 1. Create RSA key pair<br>2. Create ED25519 key pair<br>3. Duplicate key name (error: InvalidKeyPair.Duplicate)<br>4. Verify key material returned only on creation | **DONE** |
| `describe-key-pairs` | `--key-names`, `--key-pair-ids` | `--filters`, `--max-results`, `--dry-run` | None | NATS `ec2.DescribeKeyPairs` → daemon lists public keys from Predastore S3 → returns key names and fingerprints | 1. List all key pairs<br>2. Filter by key name<br>3. Filter by key pair ID<br>4. Non-existent key returns empty | **DONE** |
| `describe-key-pairs` | `--key-names`, `--key-pair-ids`, `--filters` (key-pair-id, key-name, fingerprint, tag:\*) | `--max-results`, `--dry-run` | None | NATS `ec2.DescribeKeyPairs` → daemon lists public keys from Predastore S3 → applies filters → returns key names and fingerprints | 1. List all key pairs<br>2. Filter by key name<br>3. Filter by key pair ID<br>4. Non-existent key returns empty<br>5. Unknown filter returns InvalidParameterValue | **DONE** |
| `delete-key-pair` | `--key-name`, `--key-pair-id` | `--dry-run` | Key must exist | NATS `ec2.DeleteKeyPair` → daemon deletes public key from Predastore S3 → returns success | 1. Delete existing key pair<br>2. Delete non-existent key (idempotent, no error)<br>3. Verify key no longer in describe-key-pairs | **DONE** |
| `import-key-pair` | `--key-name`, `--public-key-material` | `--tag-specifications`, `--dry-run` | None | NATS `ec2.ImportKeyPair` → daemon stores provided public key in Predastore S3 → returns key name and fingerprint | 1. Import valid SSH public key<br>2. Import invalid key material (error)<br>3. Import duplicate name (error)<br>4. Verify imported key usable with run-instances | **DONE** |
@@ -105,27 +156,27 @@ Operational commands for inspecting cluster state. These fan out NATS requests t
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------------------|---------------|---------------|-------------|------------|--------|
| `describe-images` | `--image-ids` (format validation only), `--owners` (self, account ID, alias) | `--executable-users`, `--filters`, `--include-deprecated`, `--include-disabled`, `--max-results`, `--next-token`, `--dry-run` | None | NATS `ec2.DescribeImages` → daemon reads AMI metadata from Predastore S3 buckets (ami-*) → filters by ImageIds and Owners → returns image list with state, architecture, block device mappings | 1. List all images<br>2. Filter by image ID<br>3. Filter by owner (self/amazon)<br>4. Non-existent AMI returns empty<br>5. Verify metadata fields (architecture, state, rootDeviceName) | **DONE** |
| `describe-images` | `--image-ids` (format validation only), `--owners` (self, account ID, alias), `--filters` (name, state, architecture, image-id, is-public, owner-id, description, image-type, tag:\*) | `--executable-users`, `--include-deprecated`, `--include-disabled`, `--max-results`, `--next-token`, `--dry-run` | None | NATS `ec2.DescribeImages` → daemon reads AMI metadata from Predastore S3 buckets (ami-*) → filters by ImageIds, Owners, and Filters → returns image list with state, architecture, block device mappings | 1. List all images<br>2. Filter by image ID<br>3. Filter by owner (self/amazon)<br>4. Non-existent AMI returns empty<br>5. Verify metadata fields (architecture, state, rootDeviceName)<br>6. Filter by name wildcard<br>7. Unknown filter returns InvalidParameterValue | **DONE** |
| `create-image` | `--instance-id`, `--name`, `--description`, `--tag-specifications` | `--no-reboot`, `--block-device-mappings`, `--dry-run` | Instance must exist (running or stopped) | Gateway validates input → NATS `ec2.{instanceId}.CreateImage` (per-instance topic) → daemon extracts root volume → snapshots via `ebs.snapshot` NATS (running) or offline S3 copy (stopped) → creates AMI metadata in S3 (`{amiId}/config.json`) → stores tags → returns ami-ID. Duplicate AMI name validation enforced. | 1. Create image from running instance<br>2. Create image from stopped instance<br>3. Invalid instance ID (error)<br>4. Duplicate AMI name (error)<br>5. Verify new AMI appears in describe-images<br>6. Launch new instance from created AMI | **DONE** |
| `register-image` | — | `--name`, `--description`, `--architecture`, `--root-device-name`, `--virtualization-type`, `--block-device-mappings`, `--boot-mode`, `--ena-support`, `--sriov-net-support`, `--tpm-support`, `--imds-support` | Image data must exist in S3 | Create AMI metadata entry in Predastore → assign ami-ID → set state to available → return ami-ID | 1. Register with valid metadata<br>2. Missing required name (error)<br>3. Verify registered image in describe-images | **NOT STARTED** |
| `deregister-image` | — | `--image-id`, `--dry-run` | AMI must exist, no instances using it | Mark AMI metadata as deregistered in S3 → AMI no longer available for new launches → existing instances unaffected | 1. Deregister existing AMI<br>2. Deregister non-existent AMI (error)<br>3. Verify deregistered AMI not in describe-images<br>4. Existing instances from AMI still run | **NOT STARTED** |
| `copy-image` | — | `--source-image-id`, `--source-region`, `--name`, `--description`, `--encrypted`, `--kms-key-id`, `--client-token`, `--copy-image-tags`, `--tag-specifications` | Source AMI must exist | Copy AMI data between regions/nodes in Predastore → create new AMI metadata with new ami-ID → return new ami-ID | 1. Copy image within same region<br>2. Copy non-existent image (error)<br>3. Verify copied image independent of source | **NOT STARTED** |
| `register-image` | `--name`, `--description`, `--architecture` (x86_64/arm64/i386), `--root-device-name`, `--virtualization-type` (hvm only), `--block-device-mappings` (root with `Ebs.SnapshotId`+optional `VolumeSize`), `--tag-specifications` | `--billing-products`, `--uefi-data` | Backing snapshot must exist in Predastore (`{snapshotId}/metadata.json`); caller must own snapshot or it must be system-owned | Gateway validates name length (3–128), `snap-` prefix, architecture/virtualization values → NATS `ec2.RegisterImage` → daemon checks AMI name uniqueness, reads snapshot metadata, verifies snapshot ownership, builds `viperblock.AMIMetadata` (defaults: `Architecture=x86_64`, `Virtualization=hvm`, `PlatformDetails=Linux/UNIX`, `RootDeviceType=ebs`), writes `{amiId}/config.json`. Pointer-only — never touches block data. `BootMode`, `KernelId`, `RamdiskId`, `TpmSupport`, `ImdsSupport`, `EnaSupport`, `SriovNetSupport`, `ImageLocation`, `paravirtual` virtualization rejected with `InvalidParameterValue`. `VolumeSize` smaller than snapshot rejected. | 1. Register with valid snapshot<br>2. Missing name/snapshot (error)<br>3. Duplicate AMI name (`InvalidAMIName.Duplicate`)<br>4. Snapshot not found (`InvalidSnapshot.NotFound`)<br>5. Cross-account snapshot (`UnauthorizedOperation`)<br>6. Tags from `TagSpecifications` persisted<br>7. Verify registered image in describe-images | **DONE** |
| `deregister-image` | `--image-id` | `--dry-run` | AMI must exist; caller must own it (system AMIs immutable via this API) | Gateway validates `ami-` prefix → NATS `ec2.DeregisterImage` → daemon hard-deletes `{amiId}/config.json` from Predastore. Backing snapshot is left intact (matches AWS — operators run `delete-snapshot` separately to reclaim block storage). Cross-account/system-AMI mutations rejected with `UnauthorizedOperation`. Re-deregister returns `InvalidAMIID.NotFound` (no tombstone). | 1. Deregister existing AMI<br>2. Deregister non-existent AMI (`InvalidAMIID.NotFound`)<br>3. Re-deregister already-deleted AMI (`InvalidAMIID.NotFound`)<br>4. Cross-account AMI (`UnauthorizedOperation`)<br>5. System AMI (`UnauthorizedOperation`)<br>6. Verify deregistered AMI not in describe-images<br>7. Backing snapshot untouched | **DONE** |
| `copy-image` | `--source-image-id`, `--source-region` (must equal gateway region), `--name`, `--description`, `--client-token` (accepted, not honoured), `--copy-image-tags`, `--tag-specifications` (image only) | `--encrypted`, `--kms-key-id`, `--destination-outpost-arn`, `--dry-run` | Source AMI must exist and be owned by caller OR be a system AMI; backing snapshot must still exist | Gateway validates `ami-` prefix, name length (3–128), same-region — cross-region / `Encrypted` / `KmsKeyId` / `DestinationOutpostArn` rejected with `InvalidParameterValue`. NATS `ec2.CopyImage` → daemon checks name uniqueness, reads source `ami-xxx/config.json`, visibility-filters cross-account sources as `InvalidAMIID.NotFound`, reads source `{snap-xxx}/metadata.json` (missing or empty SnapshotID → `InvalidAMIID.NotFound`), writes new `snap-yyy/metadata.json` that inherits source `VolumeID` (no block copy), writes new `ami-yyy/config.json` owned by caller. `Description` inherits from source when unset. Tag merge: `CopyImageTags=true` seeds tags from source, explicit image-resource `TagSpecifications` override colliding keys and append new ones; non-image tag specs ignored. | 1. Same-region copy produces new `ami-`/`snap-` pair with distinct IDs<br>2. New snap shares source `VolumeID` (metadata-only)<br>3. New AMI owned by caller, source untouched<br>4. System AMI copied into caller's account<br>5. Cross-account source (`InvalidAMIID.NotFound`)<br>6. Missing source AMI (`InvalidAMIID.NotFound`)<br>7. Orphaned source (missing/empty `SnapshotID`) → `InvalidAMIID.NotFound`<br>8. Duplicate name (`InvalidAMIName.Duplicate`)<br>9. Cross-region / `Encrypted` / `KmsKeyId` rejected (`InvalidParameterValue`)<br>10. `CopyImageTags` true/false tag inheritance semantics | **DONE** |
| `import-image` | — | `--disk-containers` (Format, Url/S3Bucket+S3Key), `--description`, `--architecture`, `--platform` | S3 bucket with disk image | Download disk image from S3 → convert format (VMDK/VHD/RAW→QCOW2) → create viperblock volume → register as AMI | 1. Import QCOW2 image<br>2. Import RAW image<br>3. Invalid format (error)<br>4. Verify imported image launchable | **NOT STARTED** |
| `describe-image-attribute` | — | `--image-id`, `--attribute`, `--dry-run` | AMI must exist | Read specific attribute from AMI metadata in S3 → return attribute value | 1. Get description attribute<br>2. Get launch permission<br>3. Invalid attribute name (error) | **NOT STARTED** |
| `modify-image-attribute` | — | `--image-id`, `--launch-permission`, `--description`, `--operation-type`, `--user-ids`, `--user-groups`, `--organization-arns`, `--dry-run` | AMI must exist | Update specific attribute in AMI metadata → persist to S3 | 1. Add launch permission<br>2. Modify description<br>3. Invalid AMI (error) | **NOT STARTED** |
| `reset-image-attribute` | — | `--image-id`, `--attribute`, `--dry-run` | AMI must exist | Reset attribute to default value in AMI metadata | 1. Reset launch permission to owner-only<br>2. Invalid AMI (error) | **NOT STARTED** |
| `describe-image-attribute` | `--image-id`, `--attribute` (`description`, `blockDeviceMapping` only) | `--dry-run`, `--attribute` for `launchPermission`/`bootMode`/`kernel`/`ramdisk`/`sriovNetSupport`/`productCodes`/`tpmSupport`/`uefiData`/`imdsSupport`/`lastLaunchedTime`/`deregistrationProtection` | AMI must exist and be owned by caller OR be a system AMI | Gateway validates `ami-` prefix + allowlisted attribute → NATS `ec2.DescribeImageAttribute` → daemon reads `ami-xxx/config.json`, hides cross-account reads as `InvalidAMIID.NotFound` (matches `DescribeImages`), returns `description` from stored metadata or synthesises a single root `blockDeviceMapping` from `AMIMetadata` (same shape as `DescribeImages`). Unsupported attributes rejected with `InvalidParameterValue` — notably `launchPermission` is deferred (breaks Terraform's `aws_ami` refresh; multi-account AMI sharing needs a separate design). | 1. Read `description`<br>2. Read synthesised `blockDeviceMapping`<br>3. AMI not found (`InvalidAMIID.NotFound`)<br>4. Cross-account AMI hidden (`InvalidAMIID.NotFound`)<br>5. System AMI readable by any caller<br>6. Unsupported attributes (`launchPermission`, `bootMode`, …) rejected with `InvalidParameterValue` | **DONE** |
| `modify-image-attribute` | `--image-id`, `--description` (top-level `Value=…` form), `--attribute description --value …` (structured form) | `--launch-permission`, `--imds-support`, `--operation-type`, `--user-ids`, `--user-groups`, `--organization-arns`, `--product-codes`, `--dry-run`, `--attribute` for anything other than `description` | AMI must exist and be owned by caller (system AMIs immutable via this API) | Gateway validates `ami-` prefix, rejects `LaunchPermission`/`ImdsSupport`/`ProductCodes`/org+user-id flags with `InvalidParameterValue`, and normalises the overloaded input shape (top-level `Description` → `Attribute=description`+`Value=…`). Both forms set together → `InvalidParameterCombination`; neither → `MissingParameter`. NATS `ec2.ModifyImageAttribute` → daemon loads AMI, runs ownership check (cross-account → `UnauthorizedOperation`, system AMIs also rejected), writes `AMIMetadata.Description` back to S3. Empty `Value` clears the description. | 1. Modify description (top-level form)<br>2. Modify description (structured form)<br>3. Clear description with empty value<br>4. Both top-level + structured set (`InvalidParameterCombination`)<br>5. Cross-account / system AMI (`UnauthorizedOperation`)<br>6. AMI not found (`InvalidAMIID.NotFound`)<br>7. `LaunchPermission` / `ImdsSupport` / unsupported `Attribute` rejected (`InvalidParameterValue`)<br>8. Round-trips via `describe-image-attribute` | **DONE** |
| `reset-image-attribute` | `--image-id`, `--attribute description` | `--attribute launchPermission`, `--dry-run` | AMI must exist and be owned by caller | Gateway validates `ami-` prefix; only `description` is accepted (`launchPermission` — AWS's default reset target — is out of scope). NATS `ec2.ResetImageAttribute` → daemon loads AMI, ownership check, clears `AMIMetadata.Description` to empty string, persists. | 1. Reset description to empty<br>2. Cross-account / system AMI (`UnauthorizedOperation`)<br>3. AMI not found (`InvalidAMIID.NotFound`)<br>4. `launchPermission` / other attributes rejected (`InvalidParameterValue`)<br>5. Round-trips via `describe-image-attribute` | **DONE** |
### EC2 - Volume (EBS) Management
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------------------|---------------|---------------|-------------|------------|--------|
| `describe-volumes` | `--volume-ids` (fast-path lookup), `DeleteOnTermination` (from persisted VolumeMetadata) | `--filters`, `--max-results`, `--next-token`, `--dry-run` | None | NATS `ec2.DescribeVolumes` → daemon queries viperblock for volume metadata → returns volume list with state, size, attachments, type, DeleteOnTermination flag | 1. List all volumes<br>2. Filter by volume ID<br>3. Filter by attachment state<br>4. Non-existent volume returns empty<br>5. DeleteOnTermination reflects persisted value | **DONE** |
| `describe-volumes` | `--volume-ids` (fast-path lookup), `DeleteOnTermination` (from persisted VolumeMetadata), `--filters` (volume-id, status, size, volume-type, attachment.instance-id, attachment.status, attachment.device, availability-zone, tag:\*) | `--max-results`, `--next-token`, `--dry-run` | None | NATS `ec2.DescribeVolumes` → daemon queries viperblock for volume metadata → applies filters → returns volume list with state, size, attachments, type, DeleteOnTermination flag | 1. List all volumes<br>2. Filter by volume ID<br>3. Filter by attachment state<br>4. Non-existent volume returns empty<br>5. DeleteOnTermination reflects persisted value<br>6. Filter by status, size, volume-type<br>7. Unknown filter returns InvalidParameterValue | **DONE** |
| `modify-volume` | `--volume-id`, `--size`, `--volume-type`, `--iops` | `--throughput`, `--dry-run`, `--multi-attach-enabled` | Volume must exist | NATS `ec2.ModifyVolume` → daemon sends resize request to viperblock → NBD does not support live resize, instance must be stopped → returns modification state | 1. Increase volume size<br>2. Modify volume type<br>3. Decrease size (error - not supported)<br>4. Modify attached volume (requires stop/start) | **DONE** |
| `create-volume` | `--size`, `--availability-zone`, `--volume-type` (gp3 only), `--snapshot-id` (creates volume from snapshot) | `--iops` (hardcoded 3000), `--encrypted` (hardcoded false), `--throughput`, `--tag-specifications` | Valid AZ configured via `spinifex init` | Gateway validates input → NATS `ec2.CreateVolume` → daemon generates vol-ID via viperblock → creates volume (empty or from snapshot) of specified size → persists config.json to Predastore S3 → returns vol-ID with state=available | 1. Create 80GB gp3 volume<br>2. Boundary sizes (1 GiB min, 16384 GiB max)<br>3. Invalid AZ (error)<br>4. Verify volume in describe-volumes<br>5. Unsupported volume type (error - only gp3)<br>6. Size out of range (error)<br>7. Create from snapshot | **DONE** |
| `delete-volume` | `--volume-id` | `--dry-run` | Volume must exist and be detached (state=available) | Gateway validates vol- prefix → NATS `ec2.DeleteVolume` → daemon confirms state=available and no AttachedInstance → NATS `ebs.delete` to viperblockd (stops nbdkit/WAL) → deletes S3 objects under vol-id/, vol-id-efi/, vol-id-cloudinit/ → returns success | 1. Delete detached volume<br>2. Delete attached volume (error: VolumeInUse)<br>3. Delete non-existent volume (error: InvalidVolume.NotFound)<br>4. Verify volume gone from describe-volumes<br>5. Malformed volume ID (error: InvalidVolumeID.Malformed)<br>6. Double delete (idempotent NotFound) | **DONE** |
| `attach-volume` | `--volume-id`, `--instance-id`, `--device` (optional, auto-assigns `/dev/sd[f-p]`) | `--dry-run` | Volume must exist (available), instance must exist (running) | Gateway sends to `ec2.cmd.{instanceId}` → daemon validates volume (Predastore) → `ebs.mount` via NATS (viperblock starts NBD server) → QMP `blockdev-add` (nbd-{volId}) → QMP `device_add` (virtio-blk-pci, vdisk-{volId}) → three-phase rollback on failure → update EBSRequests + BlockDeviceMappings → persist to JetStream + Predastore → respond with VolumeAttachment | 1. Attach volume to running instance<br>2. Auto-assign device name<br>3. Attach already-attached volume (VolumeInUse)<br>4. Attach to non-existent instance (InvalidInstanceID.NotFound)<br>5. Attach to stopped instance (IncorrectInstanceState)<br>6. Volume not found (InvalidVolume.NotFound)<br>7. All device slots full (AttachmentLimitExceeded)<br>8. Volume persists across stop/start | **DONE** |
| `detach-volume` | `--volume-id`, `--instance-id` (optional, resolved via DescribeVolumes), `--device` (optional cross-check), `--force` | `--dry-run` | Volume must be attached, instance must be running | Gateway resolves InstanceId if omitted (via DescribeVolumes) → sends to `ec2.cmd.{instanceId}` → daemon validates (running, attached, not boot/EFI/CloudInit, device match) → three-phase hot-unplug: QMP `device_del` (force continues on failure) → QMP `blockdev-del` (abort if fails, preserves state to prevent double-attach) → `ebs.unmount` via NATS (best-effort) → remove from EBSRequests + BlockDeviceMappings → update volume metadata to available → persist state → respond with VolumeAttachment (state=detaching) | 1. Detach with explicit InstanceId<br>2. Detach without InstanceId (gateway resolution)<br>3. Detach with correct --device cross-check<br>4. Missing VolumeId (InvalidParameterValue)<br>5. Volume not attached (IncorrectState)<br>6. Nonexistent volume (InvalidVolume.NotFound)<br>7. Nonexistent instance (InvalidInstanceID.NotFound)<br>8. Instance not running (IncorrectInstanceState)<br>9. Device mismatch (InvalidParameterValue)<br>10. Boot volume protection (OperationNotPermitted)<br>11. Force flag (continues past device_del failure)<br>12. Volume reusability (re-attach after detach) | **DONE** |
| `describe-volume-status` | `--volume-ids` | `--filters`, `--max-results`, `--next-token`, `--dry-run` | None | Gateway validates vol- prefix → NATS `ec2.DescribeVolumeStatus` → daemon fetches VolumeConfig from Predastore S3 (parallel for specific IDs, sequential list-all for no IDs) → builds VolumeStatusItem per volume (status=ok, io-enabled=passed, io-performance=not-applicable) → returns InvalidVolume.NotFound for missing explicit IDs → skips internal sub-volumes (-efi, -cloudinit) | 1. List all volume statuses<br>2. Filter by specific volume IDs (fast path)<br>3. Non-existent volume ID returns InvalidVolume.NotFound<br>4. Invalid volume ID format (InvalidVolume.Malformed)<br>5. Internal sub-volumes excluded from listing<br>6. Nil/empty input defaults to all volumes | **DONE** |
| `describe-volume-status` | `--volume-ids`, `--filters` (volume-id, volume-status.status, availability-zone) | `--max-results`, `--next-token`, `--dry-run` | None | Gateway validates vol- prefix → NATS `ec2.DescribeVolumeStatus` → daemon fetches VolumeConfig from Predastore S3 (parallel for specific IDs, sequential list-all for no IDs) → applies filters → builds VolumeStatusItem per volume (status=ok, io-enabled=passed, io-performance=not-applicable) → returns InvalidVolume.NotFound for missing explicit IDs → skips internal sub-volumes (-efi, -cloudinit) | 1. List all volume statuses<br>2. Filter by specific volume IDs (fast path)<br>3. Non-existent volume ID returns InvalidVolume.NotFound<br>4. Invalid volume ID format (InvalidVolume.Malformed)<br>5. Internal sub-volumes excluded from listing<br>6. Nil/empty input defaults to all volumes<br>7. Unknown filter returns InvalidParameterValue | **DONE** |
| `describe-volumes-modifications` | — | `--volume-ids`, `--filters`, `--max-results` | None | Query pending/completed volume modifications → return modification state, progress, original/target size | 1. Check in-progress modification<br>2. Check completed modification<br>3. No modifications returns empty | **NOT STARTED** |
### EC2 - Snapshot Management
@@ -135,7 +186,7 @@ Operational commands for inspecting cluster state. These fan out NATS requests t
| `create-snapshot` | `--volume-id`, `--description`, `--tag-specifications` | `--dry-run` | Volume must exist in Predastore | Gateway validates vol- prefix → NATS `ec2.CreateSnapshot` → daemon reads VolumeConfig from Predastore → generates snap-ID via viperblock → stores SnapshotConfig (metadata-only, points to source volume) as completed → returns Snapshot with state=completed, progress=100% | 1. Snapshot from valid volume<br>2. Missing volume ID (InvalidParameterValue)<br>3. Volume not found (InvalidVolume.NotFound)<br>4. Invalid volume ID format (InvalidVolume.Malformed)<br>5. Snapshot with description<br>6. Snapshot with tag specifications | **DONE** |
| `create-snapshots` | — | `--instance-specification`, `--description`, `--tag-specifications` | Instance must exist, instance-volume attachment tracking | Create snapshots of all volumes attached to instance → return list of snapshot IDs. Blocked: requires instance-volume attachment tracking to discover which volumes to snapshot. | 1. Snapshot all volumes on instance<br>2. Instance with no volumes | **NOT STARTED** |
| `delete-snapshot` | `--snapshot-id` | `--dry-run` | Snapshot must exist | Gateway validates snap- prefix → NATS `ec2.DeleteSnapshot` → daemon verifies snapshot exists in Predastore → lists and deletes all objects under snapshot prefix → returns success | 1. Delete existing snapshot<br>2. Delete non-existent snapshot (InvalidSnapshot.NotFound)<br>3. Missing snapshot ID (InvalidParameterValue)<br>4. Invalid snapshot ID format (InvalidSnapshot.Malformed) | **DONE** |
| `describe-snapshots` | `--snapshot-ids` | `--owner-ids`, `--filters`, `--max-results`, `--dry-run` | None | Gateway validates snap- prefix on IDs → NATS `ec2.DescribeSnapshots` → daemon lists snap- prefixed objects in Predastore → reads SnapshotConfig for each → filters by snapshot ID if specified → returns snapshot list | 1. List all snapshots<br>2. Filter by snapshot ID<br>3. Empty snapshot list<br>4. Invalid snapshot ID format (InvalidSnapshot.Malformed) | **DONE** |
| `describe-snapshots` | `--snapshot-ids`, `--filters` (snapshot-id, status, volume-id, volume-size, owner-id, tag:\*) | `--owner-ids`, `--max-results`, `--dry-run` | None | Gateway validates snap- prefix on IDs → NATS `ec2.DescribeSnapshots` → daemon lists snap- prefixed objects in Predastore → reads SnapshotConfig for each → applies filters → returns snapshot list | 1. List all snapshots<br>2. Filter by snapshot ID<br>3. Empty snapshot list<br>4. Invalid snapshot ID format (InvalidSnapshot.Malformed)<br>5. Filter by status, volume-id, volume-size<br>6. Unknown filter returns InvalidParameterValue | **DONE** |
| `copy-snapshot` | `--source-snapshot-id`, `--source-region`, `--description` | `--encrypted`, `--dry-run` | Source snapshot must exist | Gateway validates snap- prefix + source region → NATS `ec2.CopySnapshot` → daemon reads source SnapshotConfig → generates new snap-ID → copies metadata (preserves tags, description override) → stores as completed → returns new snapshot ID | 1. Copy within same region<br>2. Copy non-existent snapshot (InvalidSnapshot.NotFound)<br>3. Missing source ID (InvalidParameterValue)<br>4. Missing source region (MissingParameter)<br>5. Copy preserves tags<br>6. Copy with description override | **DONE** |
### EC2 - Tags
@@ -188,15 +239,17 @@ Gateway routing and KV CRUD are wired up but no OVN/OVS integration exists. EIGW
|---------|-------------------|---------------|---------------|-------------|------------|--------|
| `create-egress-only-internet-gateway` | `--vpc-id`, `--tag-specifications` | `--client-token`, `--dry-run` | VPC must exist | Gateway validates VpcId → NATS `ec2.CreateEgressOnlyInternetGateway` → daemon generates eigw-ID → creates EgressOnlyIGWRecord in `spinifex-eigw` KV bucket with state=attached (always attached at creation, unlike IGW) → returns EgressOnlyInternetGateway with attachment | 1. Create with valid VpcId<br>2. Missing VpcId (error: MissingParameter)<br>3. Create with inline tags<br>4. Verify ID format eigw-{16hex} | **STARTED** (KV only, no OVN) |
| `delete-egress-only-internet-gateway` | `--egress-only-internet-gateway-id` | `--dry-run` | EIGW must exist | NATS `ec2.DeleteEgressOnlyInternetGateway` → daemon verifies EIGW exists in KV → deletes from `spinifex-eigw` KV → returns ReturnCode=true. No detach required (unlike IGW). | 1. Delete existing EIGW<br>2. Delete non-existent EIGW (error)<br>3. Missing ID (error: MissingParameter) | **STARTED** (KV only, no OVN) |
| `describe-egress-only-internet-gateways` | `--egress-only-internet-gateway-ids` | `--filters`, `--max-results`, `--next-token`, `--dry-run` | None | NATS `ec2.DescribeEgressOnlyInternetGateways` → daemon lists all keys from `spinifex-eigw` KV bucket → filters by ID if specified → always includes Attachments array → returns list | 1. Describe all EIGWs<br>2. Filter by specific ID<br>3. Non-existent ID returns empty list | **STARTED** (KV only, no OVN) |
| `describe-egress-only-internet-gateways` | `--egress-only-internet-gateway-ids`, `--filters` (egress-only-internet-gateway-id, tag:\*) | `--max-results`, `--next-token`, `--dry-run` | None | NATS `ec2.DescribeEgressOnlyInternetGateways` → daemon lists all keys from `spinifex-eigw` KV bucket → filters by ID if specified → applies filters → always includes Attachments array → returns list | 1. Describe all EIGWs<br>2. Filter by specific ID<br>3. Non-existent ID returns empty list<br>4. Unknown filter returns InvalidParameterValue | **STARTED** (KV only, no OVN) |
### EC2 - Placement Groups
Placement groups are stored in `spinifex-placement-groups` JetStream KV bucket, keyed by `{accountID}.{groupName}`. All mutations use CAS (compare-and-swap) with 5-retry limit for optimistic concurrency. Supported strategies: **spread** (strict 1 instance per node) and **cluster** (all instances pinned to single node). Partition strategy is rejected. Internal NATS operations (ReserveSpreadNodes, FinalizeSpreadInstances, ReleaseSpreadNodes, RemoveInstanceFromPlacementGroup, ReserveClusterNode, FinalizeClusterInstances) support atomic multi-node coordination for RunInstances and TerminateInstances.
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------------------|---------------|---------------|-------------|------------|--------|
| `create-placement-group` | — | `--group-name`, `--strategy` (cluster/spread/partition), `--partition-count`, `--spread-level`, `--tag-specifications` | None | NATS `ec2.CreatePlacementGroup` → daemon stores placement group in JetStream KV with strategy, state=available → return group metadata | 1. Create with cluster strategy<br>2. Create with spread strategy<br>3. Create with partition strategy + partition count<br>4. Duplicate name (error)<br>5. Invalid strategy (error) | **NOT STARTED** |
| `delete-placement-group` | — | `--group-name`, `--dry-run` | Group must exist, no instances in group | NATS `ec2.DeletePlacementGroup` → daemon verifies group exists and is empty → delete from KV → return success | 1. Delete empty group<br>2. Delete group with instances (error)<br>3. Delete non-existent group (error) | **NOT STARTED** |
| `describe-placement-groups` | — | `--group-names`, `--group-ids`, `--filters` | None | NATS `ec2.DescribePlacementGroups` → daemon lists groups from KV → return list with strategy and state | 1. List all groups<br>2. Filter by name<br>3. Filter by strategy | **NOT STARTED** |
| `create-placement-group` | `--group-name`, `--strategy` (spread/cluster) | `--partition-count`, `--spread-level` (hardcoded to "host"), `--tag-specifications`, `--dry-run` | None | Gateway validates GroupName + Strategy → NATS `ec2.CreatePlacementGroup` with `spinifex-workers` queue group → daemon generates pg-ID (pg-{16hex}) → stores PlacementGroupRecord in `spinifex-placement-groups` KV with state=available, SpreadLevel="host" for spread → rejects partition strategy (InvalidParameterValue) → rejects duplicate names (InvalidPlacementGroup.Duplicate) → returns PlacementGroup | 1. Create with spread strategy<br>2. Create with cluster strategy<br>3. Create with partition strategy (error: InvalidParameterValue)<br>4. Duplicate name (error: InvalidPlacementGroup.Duplicate)<br>5. Missing GroupName (error)<br>6. Missing Strategy (error) | **DONE** |
| `delete-placement-group` | `--group-name` | `--dry-run` | Group must exist, no instances in group | NATS `ec2.DeletePlacementGroup` → daemon verifies group exists (InvalidPlacementGroup.Unknown) → counts instances in NodeInstances map → rejects if non-empty (InvalidPlacementGroup.InUse) → deletes from KV → returns success | 1. Delete empty group<br>2. Delete group with instances (error: InvalidPlacementGroup.InUse)<br>3. Delete non-existent group (error: InvalidPlacementGroup.Unknown) | **DONE** |
| `describe-placement-groups` | `--group-names`, `--group-ids`, `--filters` (strategy, state, spread-level, group-name) | `--dry-run` | None | NATS `ec2.DescribePlacementGroups` → daemon lists all keys from `spinifex-placement-groups` KV → filters by GroupNames, GroupIds, or Filters → returns list with Strategy, State, SpreadLevel, GroupId | 1. List all groups<br>2. Filter by name<br>3. Filter by group ID<br>4. Filter by strategy<br>5. Filter by state<br>6. Empty result when no groups exist | **DONE** |
### EC2 - Dedicated Hosts
@@ -234,67 +287,76 @@ Gateway routing and KV CRUD are wired up but no OVN/OVS integration exists. EIGW
### EC2 - Elastic IP
EIP operations are conditionally available — only registered when external IPAM is configured (pool mode). EIPs are stored in `spinifex-eip-allocations` KV bucket. Association publishes `vpc.add-nat`/`vpc.delete-nat` events to vpcd for OVN NAT configuration.
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------------------|---------------|---------------|-------------|------------|--------|
| `allocate-address` | — | `--domain` (vpc), `--tag-specifications`, `--dry-run` | None | NATS `ec2.AllocateAddress` → daemon allocates IP from pool → stores in JetStream KV with eipalloc-ID → return AllocationId, PublicIp, Domain | 1. Allocate EIP<br>2. Verify in describe-addresses<br>3. Allocate without --domain uses default | **NOT STARTED** |
| `release-address` | — | `--allocation-id`, `--dry-run` | EIP must exist and be disassociated | NATS `ec2.ReleaseAddress` → daemon verifies not associated → release IP back to pool → delete from KV → return success | 1. Release disassociated EIP<br>2. Release associated EIP (error)<br>3. Release non-existent (error) | **NOT STARTED** |
| `associate-address` | — | `--allocation-id`, `--instance-id` or `--network-interface-id`, `--dry-run` | EIP and target must exist | NATS `ec2.AssociateAddress` → daemon links EIP to instance/ENI → configure OVS DNAT/SNAT rules → return eipassoc-ID | 1. Associate with instance<br>2. Re-associate (moves EIP)<br>3. Associate non-existent EIP (error) | **NOT STARTED** |
| `disassociate-address` | — | `--association-id`, `--dry-run` | Association must exist | NATS `ec2.DisassociateAddress` → daemon unlinks EIP from instance/ENI → remove OVS DNAT/SNAT rules → return success | 1. Disassociate existing<br>2. Disassociate non-existent (error) | **NOT STARTED** |
| `describe-addresses` | — | `--allocation-ids`, `--filters`, `--public-ips` | None | NATS `ec2.DescribeAddresses` → daemon lists EIPs from KV → return list with association info | 1. List all EIPs<br>2. Filter by allocation ID<br>3. Filter by public IP | **NOT STARTED** |
| `allocate-address` | `--public-ipv4-pool` (optional, specific IPAM pool), `--tag-specifications` | `--domain` (hardcoded vpc), `--dry-run` | EIP service must be initialized (external IPAM configured) | Gateway validates input → NATS `ec2.AllocateAddress` with `spinifex-workers` queue group → daemon allocates IP from external IPAM pool (named or best-matching) → creates EIPRecord with state=allocated in `spinifex-eip-allocations` KV → returns AllocationId, PublicIp, Domain=vpc, PublicIpv4Pool | 1. Allocate EIP from default pool<br>2. Allocate from specific pool<br>3. Pool exhausted (InsufficientAddressCapacity)<br>4. With tag specifications<br>5. Verify in describe-addresses | **DONE** |
| `release-address` | `--allocation-id` | `--dry-run` | EIP must exist and be disassociated | Gateway validates AllocationId non-empty → NATS `ec2.ReleaseAddress` → daemon retrieves EIP record from KV → checks state != associated (InvalidAddressLocked if still associated) → releases IP back to IPAM pool → deletes KV record → returns success | 1. Release disassociated EIP<br>2. Release associated EIP (error: InvalidAddressLocked)<br>3. Release non-existent (error: InvalidAllocationID.NotFound)<br>4. Missing AllocationId (error: MissingParameter) | **DONE** |
| `associate-address` | `--allocation-id`, `--network-interface-id` OR `--instance-id`, `--private-ip-address` (optional override) | `--dry-run`, `--allow-reassociation` | EIP and target ENI/instance must exist | Gateway validates AllocationId + one of NetworkInterfaceId/InstanceId required → NATS `ec2.AssociateAddress` → daemon retrieves EIP → resolves target ENI (by ID or instance lookup) → generates AssociationId → updates record state=associated with ENI/instance/VPC/private IP → publishes `vpc.add-nat` event to vpcd (OVN NAT configuration) → returns AssociationId | 1. Associate with instance<br>2. Associate with specific ENI<br>3. Associate with private IP override<br>4. Missing AllocationId (MissingParameter)<br>5. Missing target (MissingParameter)<br>6. Non-existent EIP (InvalidAllocationID.NotFound)<br>7. Non-existent ENI (InvalidNetworkInterfaceID.NotFound) | **DONE** |
| `disassociate-address` | `--association-id` | `--dry-run` | Association must exist | Gateway validates AssociationId non-empty → NATS `ec2.DisassociateAddress` → daemon scans KV to find EIP by AssociationId → publishes `vpc.delete-nat` event to vpcd → clears association fields → reverts state to allocated → updates KV → returns success | 1. Disassociate existing<br>2. Non-existent association (InvalidAssociationID.NotFound)<br>3. Missing AssociationId (MissingParameter) | **DONE** |
| `describe-addresses` | `--allocation-ids`, `--public-ips`, `--filters` (allocation-id, public-ip, instance-id, association-id, domain, tag:\*) | `--dry-run` | None | NATS `ec2.DescribeAddresses` → daemon scans all EIP records in account → filters by AllocationIds and/or PublicIps if specified → applies filters → returns Address list with allocation, association, PublicIp, PrivateIpAddress, NetworkInterfaceId, InstanceId, Domain | 1. List all EIPs<br>2. Filter by allocation ID<br>3. Filter by public IP<br>4. Non-existent AllocationId returns error<br>5. Empty result when no EIPs<br>6. Filter by instance-id, domain<br>7. Unknown filter returns InvalidParameterValue | **DONE** |
| `describe-addresses-attribute` | `--allocation-ids` (optional filter) | `--attribute`, `--dry-run`, `--max-results`, `--next-token` | None | Gateway validates input → NATS `ec2.DescribeAddressesAttribute` with `spinifex-workers` queue group → daemon looks up EIP records by AllocationIds (or scans all account EIPs if none specified) → returns AddressAttribute list with AllocationId and PublicIp per entry. Unlike DescribeAddresses, returns empty list (not error) for non-existent AllocationIds. PtrRecord always nil (reverse-DNS not supported). | 1. List all EIP attributes<br>2. Filter by allocation IDs<br>3. Non-existent AllocationId returns empty (not error)<br>4. Nil input returns error | **DONE** |
### EC2 - NAT Gateway
NAT gateways are stored in `spinifex-vpc-nat-gateways` KV bucket. Deleted gateways are moved to `spinifex-vpc-deleted-nat-gateways` with a 1-hour TTL for Terraform polling. SNAT rule coordination uses `vpc.add-nat-gateway` and `vpc.delete-nat-gateway` NATS events (published via route table service on CreateRoute and during DeleteNatGateway respectively).
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------------------|---------------|---------------|-------------|------------|--------|
| `create-nat-gateway` | — | `--subnet-id`, `--allocation-id`, `--connectivity-type`, `--tag-specifications` | Subnet must exist, EIP must be allocated (for public type) | NATS `ec2.CreateNatGateway` → daemon creates NAT gateway in JetStream KV with nat-ID → configure OVS SNAT rules → state=pending→available → return NatGateway | 1. Create public NAT gateway<br>2. Create private NAT gateway (no allocation-id)<br>3. Invalid subnet (error) | **NOT STARTED** |
| `delete-nat-gateway` | — | `--nat-gateway-id`, `--dry-run` | NAT gateway must exist | NATS `ec2.DeleteNatGateway` → daemon marks as deleting → remove OVS SNAT rules → delete from KV → return NatGatewayId | 1. Delete existing NAT gateway<br>2. Delete non-existent (error) | **NOT STARTED** |
| `describe-nat-gateways` | — | `--nat-gateway-ids`, `--filters`, `--max-results` | None | NATS `ec2.DescribeNatGateways` → daemon lists NAT gateways from KV → return list with state, subnet, addresses | 1. List all NAT gateways<br>2. Filter by ID<br>3. Filter by VPC ID | **NOT STARTED** |
| `create-nat-gateway` | `--subnet-id`, `--allocation-id` | `--connectivity-type` (public only), `--tag-specifications`, `--dry-run` | Subnet must exist, EIP must be allocated and unassociated | Gateway validates SubnetId + AllocationId (required) → NATS `ec2.CreateNatGateway` with `spinifex-workers` queue group → daemon verifies subnet exists in `spinifex-vpc-subnets` KV → verifies EIP exists in `spinifex-vpc-eips` KV and has no AssociationId (Resource.AlreadyAssociated if in use) → generates nat-ID → stores NatGatewayRecord in `spinifex-vpc-nat-gateways` KV with state=available → returns NatGateway with addresses | 1. Create NAT gateway (happy path)<br>2. Subnet not found (InvalidSubnetID.NotFound)<br>3. EIP not found (InvalidAllocationID.NotFound)<br>4. EIP already associated (Resource.AlreadyAssociated)<br>5. Missing SubnetId or AllocationId (MissingParameter) | **DONE** |
| `delete-nat-gateway` | `--nat-gateway-id` | `--dry-run` | NAT gateway must exist | Gateway validates NatGatewayId (required) → NATS `ec2.DeleteNatGateway` → daemon retrieves record from KV → scans all route tables in VPC for routes targeting this NAT gateway → publishes `vpc.delete-nat-gateway` event per affected subnet (SNAT rule cleanup) → sets state=deleted → stores in `spinifex-vpc-deleted-nat-gateways` with 1-hour TTL → deletes from active KV bucket | 1. Delete existing NAT gateway<br>2. Delete non-existent (InvalidNatGatewayID.NotFound)<br>3. Deleted gateway visible when queried by ID (state=deleted)<br>4. Deleted gateway excluded from general list queries (TTL auto-expire) | **DONE** |
| `describe-nat-gateways` | `--nat-gateway-ids`, `--filters` (vpc-id, state) | `--max-results`, `--next-token`, `--dry-run` | None | NATS `ec2.DescribeNatGateways` → daemon scans `spinifex-vpc-nat-gateways` KV → applies NatGatewayIds filter and/or vpc-id/state filters → for specific IDs not found in active bucket, checks deleted bucket → returns InvalidNatGatewayID.NotFound if ID never existed → returns list with state, addresses, subnet, VPC | 1. List all NAT gateways<br>2. Filter by specific IDs<br>3. Filter by state<br>4. Filter by vpc-id<br>5. Non-existent ID (InvalidNatGatewayID.NotFound)<br>6. Deleted gateway visible by ID query | **DONE** |
| `assign-private-nat-gateway-address` | — | `--nat-gateway-id`, `--private-ip-addresses` | NAT gateway must exist (private type) | NATS `ec2.AssignPrivateNatGatewayAddress` → daemon assigns private IPs to NAT gateway → return assigned addresses | 1. Assign address<br>2. Missing NAT gateway ID (error) | **NOT STARTED** |
| `associate-nat-gateway-address` | — | `--nat-gateway-id`, `--allocation-ids` | NAT gateway and EIPs must exist | NATS `ec2.AssociateNatGatewayAddress` → daemon associates EIPs with NAT gateway → return association info | 1. Associate EIP<br>2. Missing NAT gateway ID (error) | **NOT STARTED** |
### EC2 - VPC Networking (Core)
Requires OVN/OVS (`apt install openvswitch-switch ovn-host`). VPC/Subnet/ENI CRUD stores metadata in NATS KV and publishes events to vpcd (the network topology daemon) which translates to OVN logical switches, ports, and routers. Single AZ for Spinifex v1. IPAM uses CAS-based allocation from NATS KV (`spinifex-vpc-ipam` bucket).
Requires OVN/OVS (`apt install openvswitch-switch ovn-host`). VPC/Subnet/ENI/SG CRUD stores metadata in NATS KV and publishes events to vpcd (the network topology daemon) which translates to OVN logical switches, ports, routers, and ACLs. Single AZ for Spinifex v1. IPAM uses CAS-based allocation from NATS KV (`spinifex-vpc-ipam` bucket). Security groups stored in `spinifex-vpc-security-groups` KV bucket with optimistic locking on rule updates.
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------------------|---------------|---------------|-------------|------------|--------|
| `create-vpc` | `--cidr-block`, `--tag-specifications` | `--instance-tenancy`, `--dry-run` | None | Gateway validates CidrBlock (required, valid CIDR, /16–/28 prefix) → NATS `ec2.CreateVpc` → daemon allocates unique VNI from `spinifex-vpc-vni-counter` KV → generates vpc-ID → stores VPCRecord in `spinifex-vpc-vpcs` KV → publishes `vpc.create` event to vpcd (creates OVN logical switch) → returns VPC with state=available | 1. Create VPC with /16 CIDR<br>2. Invalid CIDR format (error: InvalidVpcRange)<br>3. CIDR prefix outside /16–/28 (error)<br>4. Verify in describe-vpcs<br>5. Tags applied at creation | **DONE** |
| `delete-vpc` | `--vpc-id` | `--dry-run` | VPC must be empty (no subnets) | Gateway validates VpcId → NATS `ec2.DeleteVpc` → daemon checks for dependent subnets in `spinifex-vpc-subnets` KV → returns DependencyViolation if subnets exist → deletes from `spinifex-vpc-vpcs` KV → publishes `vpc.delete` event to vpcd (removes OVN resources) | 1. Delete empty VPC<br>2. Delete VPC with subnets (DependencyViolation)<br>3. Delete non-existent VPC (error) | **DONE** |
| `describe-vpcs` | `--vpc-ids` | `--filters`, `--max-results`, `--dry-run` | None | NATS `ec2.DescribeVpcs` → daemon lists all keys from `spinifex-vpc-vpcs` KV → filters by VPC IDs if specified → returns error for non-existent requested IDs → returns VPC list with CIDR, state, CIDR associations | 1. List all VPCs<br>2. Filter by VPC ID<br>3. Non-existent VPC ID returns error | **DONE** |
| `modify-vpc-attribute` | — | `--vpc-id`, `--enable-dns-support`, `--enable-dns-hostnames` | VPC must exist | NATS `ec2.ModifyVpcAttribute` → daemon updates VPC attribute in KV → return success | 1. Enable DNS support<br>2. Enable DNS hostnames<br>3. Missing VPC ID (error) | **NOT STARTED** |
| `describe-vpcs` | `--vpc-ids`, `--filters` (vpc-id, state, cidr-block, is-default, owner-id, tag:\*) | `--max-results`, `--dry-run` | None | NATS `ec2.DescribeVpcs` → daemon lists all keys from `spinifex-vpc-vpcs` KV → filters by VPC IDs if specified → applies filters → returns error for non-existent requested IDs → returns VPC list with CIDR, state, CIDR associations | 1. List all VPCs<br>2. Filter by VPC ID<br>3. Non-existent VPC ID returns error<br>4. Filter by state, cidr-block, is-default<br>5. Unknown filter returns InvalidParameterValue | **DONE** |
| `modify-vpc-attribute` | `--vpc-id`, `--enable-dns-hostnames`, `--enable-dns-support`, `--enable-network-address-usage-metrics` | `--dry-run` | VPC must exist | Gateway validates VpcId non-empty → NATS `ec2.ModifyVpcAttribute` → daemon retrieves VPC record from `spinifex-vpc-vpcs` KV → updates boolean flags if provided → updates KV with optimistic locking → returns empty output | 1. Enable DNS hostnames<br>2. Enable DNS support<br>3. Enable network address usage metrics<br>4. Missing VPC ID (MissingParameter)<br>5. Non-existent VPC (InvalidVpcID.NotFound) | **DONE** |
| `describe-vpc-attribute` | `--vpc-id`, `--attribute` (enableDnsHostnames, enableDnsSupport, enableNetworkAddressUsageMetrics) | `--dry-run` | VPC must exist | Gateway validates VpcId + Attribute non-empty → NATS `ec2.DescribeVpcAttribute` → daemon retrieves VPC record → returns single attribute as AttributeBooleanValue (one attribute per call, matches AWS behavior) → validates attribute name | 1. Get enableDnsHostnames<br>2. Get enableDnsSupport<br>3. Get enableNetworkAddressUsageMetrics<br>4. Invalid attribute name (InvalidParameterValue)<br>5. Missing VPC ID (MissingParameter)<br>6. Non-existent VPC (InvalidVpcID.NotFound) | **DONE** |
| `associate-vpc-cidr-block` | — | `--vpc-id`, `--cidr-block` | VPC must exist | NATS `ec2.AssociateVpcCidrBlock` → daemon adds secondary CIDR to VPC metadata → return association | 1. Add secondary CIDR<br>2. Overlapping CIDR (error)<br>3. Max CIDR blocks exceeded (error) | **NOT STARTED** |
| `disassociate-vpc-cidr-block` | — | `--association-id` | Association must exist | NATS `ec2.DisassociateVpcCidrBlock` → daemon removes secondary CIDR from VPC → return success | 1. Remove secondary CIDR<br>2. Remove primary CIDR (error) | **NOT STARTED** |
| `create-subnet` | `--vpc-id`, `--cidr-block`, `--availability-zone`, `--tag-specifications` | `--dry-run` | VPC must exist | Gateway validates VpcId + CidrBlock (required, valid CIDR, /16–/28 prefix) → NATS `ec2.CreateSubnet` → daemon verifies parent VPC exists → validates subnet CIDR is within VPC CIDR range → checks for overlapping subnets in same VPC → calculates available IPs (total hosts - 5 AWS-reserved: .0/.1/.2/.3/.255) → generates subnet-ID → stores SubnetRecord in `spinifex-vpc-subnets` KV → publishes `vpc.create-subnet` event to vpcd → defaults AZ from config if omitted | 1. Create subnet within VPC CIDR<br>2. Subnet CIDR outside VPC range (error: InvalidSubnetRange)<br>3. Overlapping subnet CIDRs (error: InvalidSubnetConflict)<br>4. CIDR prefix outside /16–/28 (error)<br>5. Missing VPC or CIDR (error: MissingParameter) | **DONE** |
| `delete-subnet` | `--subnet-id` | `--dry-run` | Subnet must exist | NATS `ec2.DeleteSubnet` → daemon verifies subnet exists → deletes from `spinifex-vpc-subnets` KV → publishes `vpc.delete-subnet` event to vpcd | 1. Delete existing subnet<br>2. Delete non-existent subnet (error) | **DONE** |
| `describe-subnets` | `--subnet-ids`, `--filters` (vpc-id) | `--max-results`, `--dry-run` | None | NATS `ec2.DescribeSubnets` → daemon lists all keys from `spinifex-vpc-subnets` KV → applies vpc-id filter if present → filters by subnet IDs if specified → recalculates available IPs from CIDR → returns error for non-existent requested IDs | 1. List all subnets<br>2. Filter by VPC ID<br>3. Filter by subnet ID<br>4. Non-existent subnet returns error | **DONE** |
| `modify-subnet-attribute` | — | `--subnet-id`, `--map-public-ip-on-launch`, `--assign-ipv6-address-on-creation` | Subnet must exist | NATS `ec2.ModifySubnetAttribute` → daemon updates subnet attribute in KV → return success | 1. Enable auto-assign public IP<br>2. Missing subnet ID (error) | **NOT STARTED** |
| `describe-subnets` | `--subnet-ids`, `--filters` (vpc-id, subnet-id, availability-zone, cidr-block, state, default-for-az, tag:\*) | `--max-results`, `--dry-run` | None | NATS `ec2.DescribeSubnets` → daemon lists all keys from `spinifex-vpc-subnets` KV → applies filters → filters by subnet IDs if specified → recalculates available IPs from CIDR → returns error for non-existent requested IDs | 1. List all subnets<br>2. Filter by VPC ID<br>3. Filter by subnet ID<br>4. Non-existent subnet returns error<br>5. Filter by availability-zone, cidr-block, state<br>6. Unknown filter returns InvalidParameterValue | **DONE** |
| `modify-subnet-attribute` | `--subnet-id`, `--map-public-ip-on-launch` | `--assign-ipv6-address-on-creation`, `--dry-run` | Subnet must exist | Gateway validates SubnetId non-empty → NATS `ec2.ModifySubnetAttribute` → daemon retrieves Subnet record from `spinifex-vpc-subnets` KV → updates MapPublicIpOnLaunch flag if provided → updates KV with optimistic locking → returns empty output | 1. Enable auto-assign public IP<br>2. Disable auto-assign public IP<br>3. Missing SubnetId (MissingParameter)<br>4. Non-existent subnet (InvalidSubnetID.NotFound) | **DONE** |
| `associate-subnet-cidr-block` | — | `--subnet-id`, `--ipv6-cidr-block` | Subnet must exist | NATS `ec2.AssociateSubnetCidrBlock` → daemon adds IPv6 CIDR to subnet → return association | 1. Add IPv6 CIDR<br>2. Missing subnet ID (error) | **NOT STARTED** |
| `disassociate-subnet-cidr-block` | — | `--association-id` | Association must exist | NATS `ec2.DisassociateSubnetCidrBlock` → daemon removes IPv6 CIDR from subnet → return success | 1. Remove IPv6 CIDR<br>2. Invalid association (error) | **NOT STARTED** |
| `create-security-group` | — | `--group-name`, `--description`, `--vpc-id`, `--tag-specifications` | VPC must exist | Create security group metadata → create default OVS flow rules (deny all inbound, allow all outbound) → assign sg-ID | 1. Create SG in VPC<br>2. Duplicate name in same VPC (error)<br>3. Verify default rules | **NOT STARTED** |
| `delete-security-group` | — | `--group-id` | SG must not be in use by instances | Verify no instances reference SG → remove OVS flow rules → delete metadata | 1. Delete unused SG<br>2. Delete SG in use (error) | **NOT STARTED** |
| `describe-security-groups` | — | `--group-ids`, `--group-names`, `--filters`, `--max-results` | None | Query NATS KV for SG metadata → return SG list with rules | 1. List all SGs<br>2. Filter by VPC ID<br>3. Filter by group name | **NOT STARTED** |
| `authorize-security-group-ingress` | — | `--group-id`, `--protocol`, `--port`, `--cidr`, `--source-group` | SG must exist | Add inbound rule → create OVS OpenFlow rule → persist to metadata | 1. Allow SSH (port 22) from 0.0.0.0/0<br>2. Allow from specific CIDR<br>3. Duplicate rule (idempotent) | **NOT STARTED** |
| `authorize-security-group-egress` | — | `--group-id`, `--protocol`, `--port`, `--cidr` | SG must exist | Add outbound rule → create OVS OpenFlow rule → persist to metadata | 1. Allow HTTPS outbound<br>2. Restrict to specific CIDR | **NOT STARTED** |
| `revoke-security-group-ingress` | — | `--group-id`, `--protocol`, `--port`, `--cidr` | SG must exist, rule must exist | Remove inbound rule → delete OVS OpenFlow rule → update metadata | 1. Revoke existing rule<br>2. Revoke non-existent rule (error) | **NOT STARTED** |
| `revoke-security-group-egress` | — | `--group-id`, `--protocol`, `--port`, `--cidr` | SG must exist, rule must exist | Remove outbound rule → delete OVS OpenFlow rule → update metadata | 1. Revoke existing rule<br>2. Revoke non-existent rule (error) | **NOT STARTED** |
| `create-security-group` | `--group-name`, `--description`, `--vpc-id`, `--tag-specifications` | `--dry-run` | VPC must exist | Gateway validates GroupName + VpcId required → NATS `ec2.CreateSecurityGroup` → daemon verifies VPC exists → checks duplicate name in same VPC → generates sg-ID → creates SecurityGroupRecord in `spinifex-vpc-security-groups` KV with default egress rule (allow all outbound 0.0.0.0/0), no ingress rules → publishes `vpc.create-sg` event to vpcd (OVN ACL configuration) → returns GroupId | 1. Create SG in VPC<br>2. Duplicate name in same VPC (error: InvalidGroup.Duplicate)<br>3. Verify default egress rule (allow all)<br>4. Verify no default ingress rules<br>5. Missing GroupName (MissingParameter)<br>6. Non-existent VPC (InvalidVpcID.NotFound)<br>7. With tag specifications | **DONE** |
| `delete-security-group` | `--group-id` | `--dry-run` | SG must exist | Gateway validates GroupId non-empty → NATS `ec2.DeleteSecurityGroup` → daemon retrieves SG from KV → deletes from `spinifex-vpc-security-groups` KV → publishes `vpc.delete-sg` event to vpcd → returns success | 1. Delete existing SG<br>2. Delete non-existent SG (error: InvalidGroup.NotFound)<br>3. Missing GroupId (MissingParameter) | **DONE** |
| `describe-security-groups` | `--group-ids`, `--filters` (vpc-id, group-name, group-id, description, ip-permission.cidr, tag:\*) | `--group-names`, `--max-results`, `--dry-run` | None | NATS `ec2.DescribeSecurityGroups` → daemon scans all SG records in account → applies filters (AND logic across filter names, OR across values) → returns SecurityGroup list with ingress/egress rules → returns error for non-existent requested GroupIds | 1. List all SGs<br>2. Filter by VPC ID<br>3. Filter by group name<br>4. Filter by GroupId<br>5. Non-existent GroupId (InvalidGroup.NotFound)<br>6. Empty result when no SGs<br>7. Filter by description, ip-permission.cidr<br>8. Unknown filter returns InvalidParameterValue | **DONE** |
| `authorize-security-group-ingress` | `--group-id`, `--ip-permissions` (IpProtocol, FromPort, ToPort, IpRanges/CidrIp, UserIdGroupPairs/GroupId) | `--dry-run` | SG must exist | Gateway validates GroupId non-empty → NATS `ec2.AuthorizeSecurityGroupIngress` → daemon retrieves SG → converts IpPermissions to SGRule objects → appends to IngressRules → updates KV with optimistic locking (revision check) → publishes `vpc.update-sg` event to vpcd → returns true | 1. Allow SSH (port 22) from 0.0.0.0/0<br>2. Allow from specific CIDR<br>3. Allow from source security group<br>4. Missing GroupId (MissingParameter)<br>5. Non-existent SG (InvalidGroup.NotFound) | **DONE** |
| `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** |
| `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** |
| `delete-internet-gateway` | `--internet-gateway-id` | `--dry-run` | IGW must be detached | NATS `ec2.DeleteInternetGateway` → daemon validates IGW exists and VpcId is empty (DependencyViolation if attached) → deletes from `spinifex-igw` KV | 1. Delete detached IGW<br>2. Delete attached IGW (DependencyViolation)<br>3. Delete non-existent (error) | **DONE** |
| `describe-internet-gateways` | `--internet-gateway-ids` | `--filters`, `--max-results`, `--dry-run` | None | NATS `ec2.DescribeInternetGateways` → daemon lists all keys from `spinifex-igw` KV → filters by IGW IDs if specified → returns list with Attachments array (if attached) | 1. List all IGWs<br>2. Filter by IGW ID<br>3. Verify attachment info | **DONE** |
| `describe-internet-gateways` | `--internet-gateway-ids`, `--filters` (internet-gateway-id, attachment.vpc-id, attachment.state, tag:\*) | `--max-results`, `--dry-run` | None | NATS `ec2.DescribeInternetGateways` → daemon lists all keys from `spinifex-igw` KV → filters by IGW IDs if specified → applies filters → returns list with Attachments array (if attached) | 1. List all IGWs<br>2. Filter by IGW ID<br>3. Verify attachment info<br>4. Filter by attachment.vpc-id, attachment.state<br>5. Unknown filter returns InvalidParameterValue | **DONE** |
### EC2 - Route Tables
Route tables are stored in `spinifex-vpc-route-tables` JetStream KV bucket, keyed by `{accountID}.{routeTableId}`. Each route table has a Routes array (with local route pre-populated) and an Associations array. All operations validate VPC consistency. When a route targeting a NAT gateway is created, `vpc.add-nat-gateway` events are published per associated subnet for vpcd SNAT rule creation.
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------------------|---------------|---------------|-------------|------------|--------|
| `create-route-table` | — | `--vpc-id`, `--tag-specifications` | VPC must exist | NATS `ec2.CreateRouteTable` → daemon creates route table in KV with rtb-ID → add default local route for VPC CIDR → return route table | 1. Create route table<br>2. Verify default local route<br>3. Missing VPC ID (error) | **NOT STARTED** |
| `delete-route-table` | — | `--route-table-id`, `--dry-run` | Route table must exist and not be main route table | NATS `ec2.DeleteRouteTable` → daemon verifies not main table → delete from KV → return success | 1. Delete non-main route table<br>2. Delete main route table (error)<br>3. Delete non-existent (error) | **NOT STARTED** |
| `describe-route-tables` | — | `--route-table-ids`, `--filters`, `--max-results` | None | NATS `ec2.DescribeRouteTables` → daemon lists route tables from KV → return tables with routes | 1. List all route tables<br>2. Filter by VPC | **NOT STARTED** |
| `create-route` | — | `--route-table-id`, `--destination-cidr-block`, `--gateway-id` or `--nat-gateway-id` or `--egress-only-internet-gateway-id` | Route table and target must exist | NATS `ec2.CreateRoute` → daemon adds route entry → configure OVS flow for destination CIDR → update metadata | 1. Add internet route via IGW<br>2. Add route to NAT gateway<br>3. Add route to EIGW<br>4. Conflicting route (error) | **NOT STARTED** |
| `delete-route` | — | `--route-table-id`, `--destination-cidr-block` | Route must exist | NATS `ec2.DeleteRoute` → daemon removes route entry → remove OVS flow → update metadata | 1. Delete existing route<br>2. Delete non-existent route (error)<br>3. Delete local route (error) | **NOT STARTED** |
| `replace-route` | — | `--route-table-id`, `--destination-cidr-block`, `--gateway-id` or `--nat-gateway-id` | Route must exist | NATS `ec2.ReplaceRoute` → daemon replaces route target → update OVS flow → update metadata | 1. Replace IGW route with NAT<br>2. Replace non-existent route (error) | **NOT STARTED** |
| `associate-route-table` | — | `--route-table-id`, `--subnet-id` or `--gateway-id` | Route table and target must exist | NATS `ec2.AssociateRouteTable` → daemon links route table to subnet/gateway → return rtbassoc-ID | 1. Associate with subnet<br>2. Missing route table ID (error) | **NOT STARTED** |
| `disassociate-route-table` | — | `--association-id` | Association must exist, not main table association | NATS `ec2.DisassociateRouteTable` → daemon unlinks route table → return success | 1. Disassociate existing<br>2. Disassociate main table (error) | **NOT STARTED** |
| `create-route-table` | `--vpc-id` | `--tag-specifications`, `--dry-run` | VPC must exist | Gateway validates VpcId (required) → NATS `ec2.CreateRouteTable` with `spinifex-workers` queue group → daemon verifies VPC exists in `spinifex-vpc-vpcs` KV → retrieves VPC CIDR → generates rtb-ID → creates RouteTableRecord with IsMain=false, local route pre-populated (destination=VPC CIDR, gateway="local"), empty associations → stores in `spinifex-vpc-route-tables` KV → returns RouteTable | 1. Create route table with local route (happy path)<br>2. VPC not found (InvalidVpcID.NotFound)<br>3. Missing VpcId (MissingParameter) | **DONE** |
| `delete-route-table` | `--route-table-id` | `--dry-run` | Route table must exist, not main, no subnet associations | Gateway validates RouteTableId (required) → NATS `ec2.DeleteRouteTable` → daemon retrieves record → rejects if IsMain=true (DependencyViolation) → rejects if has non-main subnet associations (DependencyViolation) → deletes from KV → returns success | 1. Delete custom route table<br>2. Delete main route table (DependencyViolation)<br>3. Delete table with subnet associations (DependencyViolation)<br>4. Delete non-existent (InvalidRouteTableID.NotFound) | **DONE** |
| `describe-route-tables` | `--route-table-ids`, `--filters` (vpc-id, route-table-id, association.main, association.route-table-association-id, association.subnet-id, route.destination-cidr-block, route.gateway-id) | `--max-results`, `--next-token`, `--dry-run` | None | NATS `ec2.DescribeRouteTables` → daemon scans all keys in `spinifex-vpc-route-tables` KV → filters by RouteTableIds if specified → applies all provided filters (AND logic) → returns error for non-existent requested IDs → converts records to EC2 RouteTable objects | 1. List all route tables<br>2. Filter by vpc-id<br>3. Filter by association.main (true/false)<br>4. Non-existent ID (InvalidRouteTableID.NotFound)<br>5. Empty result when no route tables | **DONE** |
| `create-route` | `--route-table-id`, `--destination-cidr-block`, `--gateway-id`, `--nat-gateway-id` | `--egress-only-internet-gateway-id`, `--vpc-peering-connection-id`, `--dry-run` | Route table and target must exist in same VPC | Gateway validates RouteTableId + DestinationCidrBlock (required) → NATS `ec2.CreateRoute` → daemon retrieves route table → checks for duplicate destination (RouteAlreadyExists) → validates exactly one of GatewayId/NatGatewayId provided → if GatewayId: verifies IGW exists in `spinifex-igw` KV and is attached to same VPC → if NatGatewayId: verifies NAT GW exists in `spinifex-vpc-nat-gateways` KV and is in same VPC → for NAT GW routes, publishes `vpc.add-nat-gateway` event per associated subnet → appends RouteRecord (state=active, origin=CreateRoute) → updates KV → returns Return=true | 1. Add IGW route (happy path)<br>2. Duplicate destination (RouteAlreadyExists)<br>3. IGW not found (InvalidInternetGatewayID.NotFound)<br>4. NAT GW not found (InvalidNatGatewayID.NotFound)<br>5. Gateway not in same VPC (InvalidParameterValue)<br>6. Missing RouteTableId or DestinationCidrBlock (MissingParameter) | **DONE** |
| `delete-route` | `--route-table-id`, `--destination-cidr-block` | `--dry-run` | Route must exist | Gateway validates RouteTableId + DestinationCidrBlock (required) → NATS `ec2.DeleteRoute` → daemon retrieves route table → finds matching route → rejects if gateway is "local" (InvalidParameterValue — local routes cannot be deleted) → removes route from Routes array → updates KV → returns success | 1. Delete IGW route (happy path, local route remains)<br>2. Delete local route (InvalidParameterValue)<br>3. Route not found (InvalidRoute.NotFound)<br>4. Missing parameters (MissingParameter) | **DONE** |
| `replace-route` | `--route-table-id`, `--destination-cidr-block`, `--gateway-id` | `--nat-gateway-id`, `--dry-run` | Route must exist, new target must exist in same VPC | Gateway validates RouteTableId + DestinationCidrBlock + GatewayId (required) → NATS `ec2.ReplaceRoute` → daemon retrieves route table → finds matching route → rejects if local route → verifies IGW exists and is attached to same VPC → updates route GatewayId, clears NatGatewayId, sets state=active → updates KV → returns success | 1. Replace route target (IGW swap)<br>2. Replace local route (InvalidParameterValue)<br>3. Route not found (InvalidRoute.NotFound)<br>4. IGW not found (InvalidInternetGatewayID.NotFound) | **DONE** |
| `associate-route-table` | `--route-table-id`, `--subnet-id` | `--gateway-id`, `--dry-run` | Route table and subnet must exist in same VPC | Gateway validates RouteTableId + SubnetId (required) → NATS `ec2.AssociateRouteTable` → daemon retrieves route table → verifies subnet exists and is in same VPC → scans ALL route tables in VPC to check subnet isn't already explicitly associated (Resource.AlreadyAssociated) → generates rtbassoc-ID → appends AssociationRecord (Main=false) → updates KV → returns AssociationId with state=associated | 1. Associate subnet (happy path)<br>2. Duplicate subnet association (Resource.AlreadyAssociated)<br>3. Subnet not found (InvalidSubnetID.NotFound)<br>4. Subnet in different VPC (InvalidParameterValue)<br>5. Missing parameters (MissingParameter) | **DONE** |
| `disassociate-route-table` | `--association-id` | `--dry-run` | Association must exist, not main table association | Gateway validates AssociationId (required) → NATS `ec2.DisassociateRouteTable` → daemon scans ALL route tables in account to find matching AssociationId → rejects if Main=true (InvalidParameterValue — main associations are immutable) → removes association from Associations array → updates KV → returns success | 1. Disassociate subnet (happy path)<br>2. Disassociate main table (InvalidParameterValue)<br>3. Association not found (InvalidAssociationID.NotFound) | **DONE** |
| `replace-route-table-association` | `--association-id`, `--route-table-id` | `--dry-run` | Association and new route table must exist | Gateway validates AssociationId + RouteTableId (required) → NATS `ec2.ReplaceRouteTableAssociation` → daemon verifies new route table exists → scans all route tables to find matching AssociationId → if Main=true: atomically swaps IsMain flag between old and new table → two-phase commit: removes association from old table, creates new AssociationId on new table → compensating write on failure (logs CRITICAL if compensation fails) → returns new AssociationId with state=associated | 1. Move subnet between tables (happy path)<br>2. Replace main table association (swaps IsMain flags)<br>3. Association not found (InvalidAssociationID.NotFound)<br>4. Route table not found (InvalidRouteTableID.NotFound) | **DONE** |
### EC2 - VPC Peering
@@ -374,7 +436,7 @@ ENIs are managed via `spinifex-vpc-enis` KV bucket with CAS-based IPAM allocatio
| `describe-network-interfaces` | `--network-interface-ids`, `--filters` (subnet-id, vpc-id, attachment.instance-id) | `--max-results`, `--dry-run` | None | NATS `ec2.DescribeNetworkInterfaces` → daemon lists all keys from `spinifex-vpc-enis` KV → applies filters (subnet-id, vpc-id, attachment.instance-id) → filters by ENI IDs if specified → returns error for non-existent requested IDs | 1. List all ENIs<br>2. Filter by subnet-id<br>3. Filter by vpc-id<br>4. Filter by attachment.instance-id<br>5. Non-existent ENI returns error | **DONE** |
| `attach-network-interface` | — | `--network-interface-id`, `--instance-id`, `--device-index` | ENI and instance must exist | Internal operation used by `run-instances --subnet-id`. Marks ENI as "in-use", sets instance attachment, generates attachment ID. No standalone API endpoint yet. | 1. Attach ENI to instance<br>2. Already attached (error)<br>3. Missing device index (error) | **NOT STARTED** (internal only) |
| `detach-network-interface` | — | `--attachment-id`, `--force` | ENI must be attached | Internal operation used by terminate-instances. Marks ENI as "available", clears attachment info. No standalone API endpoint yet. | 1. Detach ENI<br>2. Force detach<br>3. Not attached (error) | **NOT STARTED** (internal only) |
| `modify-network-interface-attribute` | — | `--network-interface-id`, `--description`, `--source-dest-check`, `--groups` | ENI must exist | NATS `ec2.ModifyNetworkInterfaceAttribute` → daemon updates ENI attributes in KV → return success | 1. Modify description<br>2. Toggle source/dest check<br>3. Update security groups | **NOT STARTED** |
| `modify-network-interface-attribute` | — | `--network-interface-id`, `--description`, `--groups` | ENI must exist; at least one attribute required | NATS `ec2.ModifyNetworkInterfaceAttribute` → daemon validates ENI exists → updates description and/or security groups in `spinifex-vpc-enis` KV (optimistic locking) → return success | 1. Modify description<br>2. Update security groups<br>3. No attributes (InvalidParameterValue)<br>4. Non-existent ENI (error) | **DONE** |
| `assign-private-ip-addresses` | — | `--network-interface-id`, `--private-ip-addresses` or `--secondary-private-ip-address-count` | ENI must exist | NATS `ec2.AssignPrivateIpAddresses` → daemon allocates IPs from subnet pool → assign to ENI → return assigned IPs | 1. Assign specific IPs<br>2. Assign by count<br>3. Cannot specify both (error) | **NOT STARTED** |
| `unassign-private-ip-addresses` | — | `--network-interface-id`, `--private-ip-addresses` | IPs must be assigned to ENI | NATS `ec2.UnassignPrivateIpAddresses` → daemon releases IPs → return success | 1. Unassign secondary IP<br>2. Unassign primary IP (error) | **NOT STARTED** |
@@ -407,19 +469,6 @@ ENIs are managed via `spinifex-vpc-enis` KV bucket with CAS-based IPAM allocatio
| `list-snapshot-blocks` | — | `--snapshot-id`, `--max-results`, `--next-token` | Snapshot must exist | List all non-empty blocks in snapshot with tokens and sizes | 1. List blocks of populated snapshot<br>2. List blocks of empty snapshot | **NOT STARTED** |
| `list-changed-blocks` | — | `--second-snapshot-id`, `--first-snapshot-id`, `--max-results` | Snapshots must exist | Compare two snapshots → return list of differing block indexes | 1. Compare parent and child snapshots<br>2. Compare unrelated snapshots | **NOT STARTED** |
### S3 (via Predastore)
Currently S3 operations are handled directly by Predastore. Consider moving control/data plane to Spinifex format for AWS gateway integration.
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------------------|---------------|---------------|-------------|------------|--------|
| `s3 mb` (CreateBucket) | — | `s3://bucket-name` | None | NATS `s3.createbucket` → Predastore creates bucket → return success | 1. Create valid bucket<br>2. Duplicate bucket name (error)<br>3. Invalid bucket name (error) | **NOT STARTED** (Predastore handles directly) |
| `s3 rb` (DeleteBucket) | — | `s3://bucket-name`, `--force` | Bucket must exist | NATS `s3.deletebucket` → verify bucket empty (or force) → Predastore deletes bucket | 1. Delete empty bucket<br>2. Delete non-empty bucket (error)<br>3. Force delete non-empty bucket | **NOT STARTED** |
| `s3 ls` (ListBuckets/Objects) | — | `s3://bucket/prefix`, `--recursive` | None | NATS `s3.listobjects` → Predastore lists objects with prefix → return list | 1. List all buckets<br>2. List objects in bucket<br>3. List with prefix filter | **NOT STARTED** |
| `s3 cp` (PutObject/GetObject) | — | `source dest`, `--recursive`, `--acl` | Bucket must exist | NATS `s3.putobject`/`s3.getobject` → Predastore stores/retrieves with Reed-Solomon encoding | 1. Upload single file<br>2. Download single file<br>3. Recursive upload/download<br>4. Large file (multipart) | **NOT STARTED** |
| `s3 rm` (DeleteObject) | — | `s3://bucket/key`, `--recursive` | Object must exist | NATS `s3.deleteobject` → Predastore deletes object shards | 1. Delete single object<br>2. Recursive delete<br>3. Delete non-existent (idempotent) | **NOT STARTED** |
| `s3 sync` | — | `source dest`, `--delete`, `--exclude`, `--include` | Bucket must exist | Compare source and dest → upload changed/new files → optionally delete removed files | 1. Sync local dir to S3<br>2. Sync with --delete<br>3. Sync with exclude filter | **NOT STARTED** |
### IAM - User Management
All IAM operations are account-scoped. Users, policies, and access keys are namespaced per account. The gateway extracts the account ID from the authenticated access key via `c.Locals("sigv4.accountId")` and passes it to all IAM service methods. Root user (account `000000000000`) bypasses policy evaluation entirely.
@@ -475,5 +524,146 @@ The gateway evaluates IAM policies on every request (except root user bypass). P
| Explicit deny | Deny statements override Allow (deny wins) | **DONE** |
| Wildcard matching | `*` matches all, `ec2:*` matches all EC2, `ec2:Describe*` matches prefix | **DONE** |
| Account-scoped evaluation | Policies resolved from caller's account context | **DONE** |
| EC2 action mapping | 54 EC2 actions mapped to IAM policy strings | **DONE** |
| EC2 action mapping | All registered EC2 actions mapped to IAM policy strings | **DONE** |
| IAM action mapping | 16 IAM actions mapped to IAM policy strings | **DONE** |
### IAM - Roles
IAM roles are required for instance profiles, cross-account access, and service-linked permissions. Without roles, workloads must use static access keys. Roles should be stored in a `spinifex-iam-roles` KV bucket keyed by `{accountID}.{roleName}`. Trust policies (AssumeRolePolicyDocument) define who can assume the role.
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------------------|---------------|---------------|-------------|------------|--------|
| `create-role` | — | `--role-name`, `--assume-role-policy-document`, `--path`, `--description`, `--max-session-duration`, `--permissions-boundary`, `--tags` | None | IAMService.CreateRole(accountID, input) → validate RoleName + AssumeRolePolicyDocument (required) → validate trust policy JSON → generate AROA-prefixed RoleId → store RoleRecord in `spinifex-iam-roles` KV at `{accountID}.{roleName}` → ARN: `arn:aws:iam::{accountID}:role/{path}{roleName}` → return Role | 1. Create with valid trust policy<br>2. Malformed trust policy (MalformedPolicyDocument)<br>3. Duplicate name (EntityAlreadyExists)<br>4. Missing RoleName (InvalidInput)<br>5. With path and description | **NOT STARTED** |
| `get-role` | — | `--role-name` | Role must exist | IAMService.GetRole(accountID, input) → reads from KV at `{accountID}.{roleName}` → returns Role with ARN, Path, AssumeRolePolicyDocument, CreateDate | 1. Get existing role<br>2. Non-existent role (NoSuchEntity) | **NOT STARTED** |
| `list-roles` | — | `--path-prefix`, `--max-items`, `--marker` | None | IAMService.ListRoles(accountID, input) → prefix-scans KV for `{accountID}.*` → filters by path prefix → returns role list scoped to caller's account | 1. List all roles<br>2. List with path prefix<br>3. Empty list<br>4. Account isolation | **NOT STARTED** |
| `delete-role` | — | `--role-name` | Role must exist, no attached policies, no instance profiles | IAMService.DeleteRole(accountID, input) → verify no attached policies (DeleteConflict) → verify not in any instance profile (DeleteConflict) → delete from KV | 1. Delete role with no attachments<br>2. Delete role with policies (DeleteConflict)<br>3. Delete role in instance profile (DeleteConflict)<br>4. Non-existent role (NoSuchEntity) | **NOT STARTED** |
| `update-role` | — | `--role-name`, `--description`, `--max-session-duration` | Role must exist | IAMService.UpdateRole(accountID, input) → update description and/or session duration in KV → return success | 1. Update description<br>2. Update max session duration<br>3. Non-existent role (NoSuchEntity) | **NOT STARTED** |
| `update-assume-role-policy` | — | `--role-name`, `--policy-document` | Role must exist | IAMService.UpdateAssumeRolePolicy(accountID, input) → validate trust policy JSON → update AssumeRolePolicyDocument in KV → return success | 1. Update trust policy<br>2. Malformed policy (MalformedPolicyDocument)<br>3. Non-existent role (NoSuchEntity) | **NOT STARTED** |
| `attach-role-policy` | — | `--role-name`, `--policy-arn` | Role and policy must exist | IAMService.AttachRolePolicy(accountID, input) → verify policy exists → add ARN to role's AttachedPolicies → idempotent | 1. Attach policy<br>2. Already attached (idempotent)<br>3. Non-existent role (NoSuchEntity)<br>4. Non-existent policy (NoSuchEntity) | **NOT STARTED** |
| `detach-role-policy` | — | `--role-name`, `--policy-arn` | Policy must be attached | IAMService.DetachRolePolicy(accountID, input) → remove ARN from role's AttachedPolicies | 1. Detach attached policy<br>2. Not attached (NoSuchEntity)<br>3. Non-existent role (NoSuchEntity) | **NOT STARTED** |
| `list-attached-role-policies` | — | `--role-name`, `--path-prefix`, `--max-items`, `--marker` | Role must exist | IAMService.ListAttachedRolePolicies(accountID, input) → reads role's AttachedPolicies list → returns ARNs and names | 1. List attached policies<br>2. No policies (empty list)<br>3. Non-existent role (NoSuchEntity) | **NOT STARTED** |
### IAM - Instance Profiles
Instance profiles are containers for IAM roles that allow EC2 instances to assume a role and obtain temporary credentials. Required for `run-instances --iam-instance-profile`. Stored in `spinifex-iam-instance-profiles` KV bucket keyed by `{accountID}.{profileName}`.
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------------------|---------------|---------------|-------------|------------|--------|
| `create-instance-profile` | — | `--instance-profile-name`, `--path`, `--tags` | None | IAMService.CreateInstanceProfile(accountID, input) → validate name (required) → generate AIPA-prefixed InstanceProfileId → store in `spinifex-iam-instance-profiles` KV with empty Roles array → ARN: `arn:aws:iam::{accountID}:instance-profile/{path}{name}` → return InstanceProfile | 1. Create profile<br>2. Duplicate name (EntityAlreadyExists)<br>3. Missing name (InvalidInput) | **NOT STARTED** |
| `get-instance-profile` | — | `--instance-profile-name` | Profile must exist | IAMService.GetInstanceProfile(accountID, input) → reads from KV → returns InstanceProfile with Roles array | 1. Get existing profile<br>2. Non-existent (NoSuchEntity) | **NOT STARTED** |
| `list-instance-profiles` | — | `--path-prefix`, `--max-items`, `--marker` | None | IAMService.ListInstanceProfiles(accountID, input) → prefix-scans KV → returns list | 1. List all profiles<br>2. Empty list | **NOT STARTED** |
| `list-instance-profiles-for-role` | — | `--role-name`, `--max-items`, `--marker` | Role must exist | IAMService.ListInstanceProfilesForRole(accountID, input) → scan all profiles → filter where Roles contains role name → return list | 1. List profiles for role<br>2. Role not in any profile (empty) | **NOT STARTED** |
| `delete-instance-profile` | — | `--instance-profile-name` | Profile must exist, Roles must be empty | IAMService.DeleteInstanceProfile(accountID, input) → verify Roles empty (DeleteConflict if not) → delete from KV | 1. Delete empty profile<br>2. Delete profile with role (DeleteConflict)<br>3. Non-existent (NoSuchEntity) | **NOT STARTED** |
| `add-role-to-instance-profile` | — | `--instance-profile-name`, `--role-name` | Both must exist, max 1 role per profile | IAMService.AddRoleToInstanceProfile(accountID, input) → verify profile exists → verify role exists → check Roles array is empty (LimitExceeded if role already present) → append role → update KV | 1. Add role to empty profile<br>2. Profile already has role (LimitExceeded)<br>3. Non-existent profile (NoSuchEntity)<br>4. Non-existent role (NoSuchEntity) | **NOT STARTED** |
| `remove-role-from-instance-profile` | — | `--instance-profile-name`, `--role-name` | Role must be in profile | IAMService.RemoveRoleFromInstanceProfile(accountID, input) → remove role from Roles array → update KV | 1. Remove role<br>2. Role not in profile (NoSuchEntity) | **NOT STARTED** |
### IAM - Groups
Groups provide a way to attach policies to multiple users. Stored in `spinifex-iam-groups` KV bucket keyed by `{accountID}.{groupName}`.
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------------------|---------------|---------------|-------------|------------|--------|
| `create-group` | — | `--group-name`, `--path` | None | IAMService.CreateGroup(accountID, input) → validate GroupName (required) → generate AGPA-prefixed GroupId → store in KV → return Group | 1. Create group<br>2. Duplicate name (EntityAlreadyExists)<br>3. Missing name (InvalidInput) | **NOT STARTED** |
| `get-group` | — | `--group-name` | Group must exist | IAMService.GetGroup(accountID, input) → reads group from KV → returns Group with member Users list | 1. Get existing group<br>2. Non-existent (NoSuchEntity) | **NOT STARTED** |
| `list-groups` | — | `--path-prefix`, `--max-items`, `--marker` | None | IAMService.ListGroups(accountID, input) → prefix-scans KV → returns list | 1. List all groups<br>2. Empty list | **NOT STARTED** |
| `delete-group` | — | `--group-name` | Group must exist, no members, no attached policies | IAMService.DeleteGroup(accountID, input) → verify no members → verify no attached policies → delete from KV | 1. Delete empty group<br>2. Delete group with members (DeleteConflict)<br>3. Delete group with policies (DeleteConflict)<br>4. Non-existent (NoSuchEntity) | **NOT STARTED** |
| `add-user-to-group` | — | `--group-name`, `--user-name` | Both must exist | IAMService.AddUserToGroup(accountID, input) → verify group + user exist → add user to group's Members list → idempotent | 1. Add user<br>2. Already a member (idempotent)<br>3. Non-existent group (NoSuchEntity)<br>4. Non-existent user (NoSuchEntity) | **NOT STARTED** |
| `remove-user-from-group` | — | `--group-name`, `--user-name` | User must be in group | IAMService.RemoveUserFromGroup(accountID, input) → remove user from Members list → update KV | 1. Remove member<br>2. Not a member (NoSuchEntity) | **NOT STARTED** |
| `list-groups-for-user` | — | `--user-name`, `--max-items`, `--marker` | User must exist | IAMService.ListGroupsForUser(accountID, input) → scan all groups → filter where Members contains user → return list | 1. List groups for user<br>2. User in no groups (empty) | **NOT STARTED** |
| `attach-group-policy` | — | `--group-name`, `--policy-arn` | Group and policy must exist | IAMService.AttachGroupPolicy(accountID, input) → verify policy exists → add ARN to group's AttachedPolicies → idempotent | 1. Attach policy<br>2. Already attached (idempotent)<br>3. Non-existent group (NoSuchEntity) | **NOT STARTED** |
| `detach-group-policy` | — | `--group-name`, `--policy-arn` | Policy must be attached | IAMService.DetachGroupPolicy(accountID, input) → remove ARN from AttachedPolicies | 1. Detach policy<br>2. Not attached (NoSuchEntity) | **NOT STARTED** |
| `list-attached-group-policies` | — | `--group-name`, `--path-prefix`, `--max-items`, `--marker` | Group must exist | IAMService.ListAttachedGroupPolicies(accountID, input) → reads group's AttachedPolicies → returns ARNs and names | 1. List attached policies<br>2. No policies (empty) | **NOT STARTED** |
### STS (Security Token Service)
STS provides temporary credentials and identity verification. `GetCallerIdentity` is called by every AWS SDK/CLI invocation. `AssumeRole` is required once IAM roles are implemented. STS needs its own gateway service endpoint (`sts` service in the `supportedServices` map) with a dedicated action map.
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------------------|---------------|---------------|-------------|------------|--------|
| `get-caller-identity` | — | *(no flags)* | Valid SigV4 credentials | Gateway extracts accountID and username from authenticated request → returns Account, Arn (`arn:aws:iam::{accountID}:user/{userName}` or `arn:aws:sts::{accountID}:assumed-role/{roleName}/{sessionName}`), UserId. Note: a Spinifex-custom version exists at `spinifex.GetCallerIdentity` but needs a proper STS endpoint. | 1. Get identity for IAM user<br>2. Get identity for assumed role<br>3. Get identity for root user<br>4. Invalid credentials (AuthFailure) | **NOT STARTED** |
| `assume-role` | — | `--role-arn`, `--role-session-name`, `--duration-seconds`, `--policy`, `--external-id`, `--serial-number`, `--token-code`, `--tags`, `--transitive-tag-keys` | Role must exist, caller must be allowed by trust policy | Gateway validates RoleArn + RoleSessionName (required) → looks up role → evaluates trust policy (AssumeRolePolicyDocument) against caller → generates temporary credentials (AccessKeyId with ASIA prefix, SecretAccessKey, SessionToken) → credentials expire after DurationSeconds (default 3600, max 43200) → return Credentials + AssumedRoleUser (ARN, AssumedRoleId) | 1. Assume role with valid trust policy<br>2. Trust policy denies caller (AccessDenied)<br>3. Non-existent role (NoSuchEntity)<br>4. Expired session token rejected at SigV4<br>5. Custom duration<br>6. Session tags | **NOT STARTED** |
| `get-session-token` | — | `--duration-seconds`, `--serial-number`, `--token-code` | Valid IAM user credentials (not role) | Gateway validates caller is IAM user (not assumed role) → generates temporary credentials (ASIA prefix) → session inherits user's permissions → return Credentials | 1. Get token for IAM user<br>2. Duration within range<br>3. Reject if caller is assumed role | **NOT STARTED** |
### ELBv2 (Elastic Load Balancing v2 — Application & Network Load Balancer)
All ELBv2 resources are stored in the `spinifex-elbv2` JetStream KV bucket. Key format: `lb.{lbId}`, `tg.{tgId}`, `listener.{listenerId}`. The data plane uses a system-managed LB VM running HAProxy, launched automatically during CreateLoadBalancer. HAProxy configuration is pushed via NATS (`elbv2.lb.{lbId}.config` topic) whenever listeners or targets change. Agent readiness is polled via `elbv2.lb.{lbId}.ping`.
#### ELBv2 - Load Balancers
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------------------|---------------|---------------|-------------|------------|--------|
| `create-load-balancer` | `--name`, `--subnets`, `--security-groups`, `--scheme` (internet-facing/internal), `--tags`, `--ip-address-type` (ipv4 only) | `--type` (hardcoded application), `--customer-owned-ipv4-pool`, `--dry-run` | None (subnets optional) | Gateway validates Name (required) → NATS `elbv2.CreateLoadBalancer` with `spinifex-workers` queue group → daemon validates scheme (internet-facing or internal, InvalidScheme if other) → checks duplicate name in account (DuplicateLoadBalancerName) → generates LB ID → builds DNS name `{name}-{lbId}.{region}.elb.spinifex.local` → if subnets provided, creates one ENI per subnet with `spinifex:managed-by=elbv2` tag (rolls back on failure) → if system AMI available, launches sys.micro LB VM with cloud-init running `/usr/local/bin/lb-agent` → state=provisioning → background goroutine polls agent ping for ~5min → on agent ready: state=active, on timeout: state=failed → if no VM launcher: state=active immediately → persists to KV → returns LoadBalancer with ARN, DNSName, State | 1. Create with name (happy path)<br>2. Internal scheme<br>3. Invalid scheme (InvalidScheme)<br>4. Duplicate name (DuplicateLoadBalancerName)<br>5. Missing name (MissingParameter)<br>6. With tags<br>7. With subnets (ENI creation) | **DONE** |
| `delete-load-balancer` | `--load-balancer-arn` | `--dry-run` | LB must exist | Gateway validates LoadBalancerArn (required) → NATS `elbv2.DeleteLoadBalancer` → daemon verifies LB exists and belongs to account → terminates LB VM (async background) → deletes all listeners for this LB → deletes system-managed ENIs → deletes LB record from KV → returns success | 1. Delete existing LB<br>2. Not found (LoadBalancerNotFound)<br>3. Cascades listener cleanup<br>4. ENI cleanup | **DONE** |
| `describe-load-balancers` | `--load-balancer-arns`, `--names` | `--page-size`, `--marker`, `--dry-run` | None | NATS `elbv2.DescribeLoadBalancers` → daemon lists all LB records from KV → filters by account ID → applies ARN filter and/or Name filter → returns LoadBalancer list with State, DNSName, Scheme, VpcId, AvailabilityZones | 1. List all LBs<br>2. Filter by name<br>3. Account isolation | **DONE** |
#### ELBv2 - Target Groups
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------------------|---------------|---------------|-------------|------------|--------|
| `create-target-group` | `--name`, `--protocol` (HTTP), `--port` (default 80), `--vpc-id`, `--target-type` (instance only), `--health-check-protocol`, `--health-check-port`, `--health-check-path` (default /), `--health-check-interval-seconds` (default 30), `--health-check-timeout-seconds` (default 5), `--healthy-threshold-count` (default 5), `--unhealthy-threshold-count` (default 2), `--matcher` (HttpCode, default 200), `--tags` | `--health-check-enabled`, `--protocol-version`, `--ip-address-type`, `--dry-run` | None | Gateway validates Name (required) → NATS `elbv2.CreateTargetGroup` → daemon checks duplicate name in same VPC (DuplicateTargetGroupName) → generates TG ID → creates HealthCheckConfig with provided values or defaults → stores record with empty Targets array → returns TargetGroup with ARN | 1. Create with defaults<br>2. Custom health check<br>3. Duplicate name (DuplicateTargetGroupName)<br>4. Missing name (MissingParameter) | **DONE** |
| `delete-target-group` | `--target-group-arn` | `--dry-run` | TG must exist, not referenced by listener | Gateway validates TargetGroupArn (required) → NATS `elbv2.DeleteTargetGroup` → daemon verifies TG exists → scans all listeners to check if any DefaultActions reference this TG (TargetGroupInUse if referenced) → deletes from KV | 1. Delete TG<br>2. TG in use by listener (TargetGroupInUse)<br>3. Not found (TargetGroupNotFound) | **DONE** |
| `describe-target-groups` | `--target-group-arns`, `--names`, `--load-balancer-arn` | `--page-size`, `--marker`, `--dry-run` | None | NATS `elbv2.DescribeTargetGroups` → daemon lists all TG records → filters by account → applies ARN/Name filters → if LoadBalancerArn specified, lists listeners for that LB and includes only TGs referenced by those listeners → returns TargetGroup list | 1. List all TGs<br>2. Filter by LB ARN (via listener lookup)<br>3. Account isolation | **DONE** |
#### ELBv2 - Targets
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------------------|---------------|---------------|-------------|------------|--------|
| `register-targets` | `--target-group-arn`, `--targets` (Id=instance-id, Port=optional override) | `--dry-run` | TG must exist, instances should exist | Gateway validates TargetGroupArn + Targets (required) → NATS `elbv2.RegisterTargets` → daemon fetches TG → deduplicates against existing targets (key: `{id}:{port}`, idempotent) → resolves instance ID → private IP via VPC ENI lookup → adds target with state=initial → persists to KV → calls `pushConfigForTargetGroup()` to regenerate HAProxy config on all LBs using this TG → returns success | 1. Register targets<br>2. Idempotent re-registration<br>3. TG not found (TargetGroupNotFound)<br>4. Missing ARN or targets (MissingParameter) | **DONE** |
| `deregister-targets` | `--target-group-arn`, `--targets` (Id, Port) | `--dry-run` | TG must exist | Gateway validates TargetGroupArn + Targets (required) → NATS `elbv2.DeregisterTargets` → daemon fetches TG → builds removal set (key: `{id}:{port}`) → filters Targets array to keep only non-matching → persists to KV → calls `pushConfigForTargetGroup()` to update HAProxy config → returns success | 1. Deregister targets<br>2. TG not found (TargetGroupNotFound)<br>3. Missing parameters (MissingParameter) | **DONE** |
| `describe-target-health` | `--target-group-arn`, `--targets` (optional filter to specific targets) | `--include` | TG must exist | Gateway validates TargetGroupArn (required) → NATS `elbv2.DescribeTargetHealth` → daemon fetches TG → builds filter set from Targets param (if specified) → for each target, returns TargetHealthDescription with Target (Id, Port) and TargetHealth (State, Description). Note: health state is stored in records, not actively polled — HAProxy runs its own independent health checks. | 1. Describe all targets<br>2. Filter to specific targets<br>3. TG not found (TargetGroupNotFound) | **DONE** |
#### ELBv2 - Listeners
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------------------|---------------|---------------|-------------|------------|--------|
| `create-listener` | `--load-balancer-arn`, `--default-actions` (Type=forward, TargetGroupArn), `--protocol` (HTTP, default), `--port` (default 80) | `--ssl-policy`, `--certificates`, `--alpn-policy`, `--mutual-authentication`, `--dry-run` | LB must exist | Gateway validates LoadBalancerArn + DefaultActions (required) → NATS `elbv2.CreateListener` → daemon verifies LB exists → checks for duplicate port on same LB (DuplicateListener) → generates listener ID → builds ARN → stores ListenerRecord in KV → calls `pushConfig(lb)` to regenerate HAProxy config (creates frontend for listener port, backend for target group with health checks) → returns Listener with ARN, Protocol, Port, DefaultActions | 1. Create listener<br>2. Duplicate port (DuplicateListener)<br>3. LB not found (LoadBalancerNotFound)<br>4. Graceful handling when NATS unavailable | **DONE** |
| `delete-listener` | `--listener-arn` | `--dry-run` | Listener must exist | Gateway validates ListenerArn (required) → NATS `elbv2.DeleteListener` → daemon verifies listener exists and belongs to account → deletes from KV → fetches parent LB → calls `pushConfig(lb)` to regenerate HAProxy config without this listener → returns success | 1. Delete listener<br>2. Not found (ListenerNotFound) | **DONE** |
| `describe-listeners` | `--load-balancer-arn`, `--listener-arns` | `--page-size`, `--marker`, `--dry-run` | None | NATS `elbv2.DescribeListeners` → daemon lists listeners → if LoadBalancerArn specified, filters to that LB → applies ARN filter → account isolation → returns Listener list | 1. Filter by LB ARN<br>2. Account isolation | **DONE** |
#### ELBv2 - Tags
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------------------|---------------|---------------|-------------|------------|--------|
| `describe-tags` | `--resource-arns` (loadbalancer, targetgroup, listener) | — | Resource(s) must exist | Gateway validates non-empty ResourceArns → NATS `elbv2.DescribeTags` → daemon parses each ARN's resource segment to dispatch by type → looks up record (LB/TG/listener) → enforces account isolation (cross-account → not-found) → returns TagDescriptions in input order with sorted-by-key Tag list. Listener records don't store tags yet, so they always return an empty Tags slice (matches AWS behaviour for untagged resources). Required by Terraform AWS provider post-create refresh on `aws_lb`, `aws_lb_target_group`, and `aws_lb_listener`. | 1. LB tags round-trip<br>2. TG tags round-trip<br>3. Listener returns empty Tags (no error)<br>4. Multiple ARNs in one call<br>5. Unknown LB/TG/listener (per-type not-found)<br>6. Cross-account ARN (not-found, no leak)<br>7. Invalid/non-ELBv2 ARN (InvalidParameterValue)<br>8. Empty/nil ResourceArns (MissingParameter)<br>9. Untagged LB returns empty Tags | **DONE** |
#### ELBv2 - Not Implemented
| Feature | Description | Priority | Status |
|---------|-------------|----------|--------|
| Listener rules | Path-based and hostname-based routing (CreateRule, DeleteRule, DescribeRules, ModifyRule) | High | **NOT STARTED** |
| HTTPS/TLS termination | SSL certificates, SSL policies, ALPN | High | **NOT STARTED** |
| Modify operations | ModifyLoadBalancerAttributes, ModifyTargetGroup, ModifyTargetGroupAttributes, ModifyListener | Medium | **NOT STARTED** |
| Connection draining | Deregistration delay for graceful target removal | Medium | **NOT STARTED** |
| Stickiness | Session affinity / sticky sessions on target groups | Medium | **NOT STARTED** |
| Active health checking | API-driven health state updates (currently HAProxy-only) | Medium | **NOT STARTED** |
| Target types | IP and Lambda target types (only instance supported) | Low | **NOT STARTED** |
| Access logs | S3 access log delivery | Low | **NOT STARTED** |
| WAF integration | AWS WAF association for web application firewall | Low | **NOT STARTED** |
### CloudWatch (Basic Monitoring)
Basic CloudWatch metrics support is required for `monitor-instances`/`unmonitor-instances` to be meaningful. Daemon nodes should publish CPU, disk, and network metrics for running instances. Metrics stored in a time-series-friendly structure in JetStream KV or dedicated NATS subjects.
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------------------|---------------|---------------|-------------|------------|--------|
| `put-metric-data` | — | `--namespace`, `--metric-data` (MetricName, Value, Unit, Timestamp, Dimensions) | None | Gateway validates Namespace + MetricData (required) → NATS `cloudwatch.PutMetricData` → daemon stores metric data points in time-series KV (key: `{namespace}.{metricName}.{dimensionHash}`) → return success | 1. Put single metric<br>2. Put with dimensions (InstanceId)<br>3. Missing namespace (MissingParameter) | **NOT STARTED** |
| `get-metric-statistics` | — | `--namespace`, `--metric-name`, `--start-time`, `--end-time`, `--period`, `--statistics` (Average, Sum, Min, Max, SampleCount), `--dimensions` | None | Gateway validates required fields → NATS `cloudwatch.GetMetricStatistics` → daemon queries time-series data → aggregates by period → returns Datapoints with requested statistics | 1. Get CPU utilization over 1 hour<br>2. Get with dimensions (InstanceId)<br>3. No data returns empty | **NOT STARTED** |
| `list-metrics` | — | `--namespace`, `--metric-name`, `--dimensions`, `--recently-active` | None | NATS `cloudwatch.ListMetrics` → daemon scans metric namespaces → returns Metrics list with Namespace, MetricName, Dimensions | 1. List all metrics<br>2. Filter by namespace<br>3. Filter by metric name | **NOT STARTED** |
| `describe-alarms` | — | `--alarm-names`, `--alarm-name-prefix`, `--state-value`, `--action-prefix` | None | NATS `cloudwatch.DescribeAlarms` → daemon lists alarms from KV → return MetricAlarms with State, Threshold, ComparisonOperator | 1. List all alarms<br>2. Filter by state (OK, ALARM, INSUFFICIENT_DATA) | **NOT STARTED** |
| `put-metric-alarm` | — | `--alarm-name`, `--namespace`, `--metric-name`, `--statistic`, `--period`, `--evaluation-periods`, `--threshold`, `--comparison-operator`, `--alarm-actions`, `--dimensions` | None | NATS `cloudwatch.PutMetricAlarm` → daemon stores alarm config in KV → alarm evaluation runs on metric publish → state transitions trigger alarm actions | 1. Create alarm<br>2. Update existing alarm<br>3. Missing required fields | **NOT STARTED** |
| `delete-alarms` | — | `--alarm-names` | Alarms must exist | NATS `cloudwatch.DeleteAlarms` → daemon deletes alarm records from KV → return success | 1. Delete existing alarm<br>2. Non-existent alarm (ResourceNotFound) | **NOT STARTED** |
### Auto Scaling
Auto Scaling manages instance fleets based on scaling policies. Depends on Launch Templates (EC2) and optionally ELBv2 target groups for health-based scaling. Stored in `spinifex-autoscaling` KV bucket.
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------------------|---------------|---------------|-------------|------------|--------|
| `create-auto-scaling-group` | — | `--auto-scaling-group-name`, `--launch-template` (Id/Name, Version), `--min-size`, `--max-size`, `--desired-capacity`, `--vpc-zone-identifier` (subnet IDs), `--target-group-arns`, `--health-check-type` (EC2/ELB), `--health-check-grace-period`, `--tags` | Launch template must exist | NATS `autoscaling.CreateAutoScalingGroup` → daemon validates launch template → stores ASG record in KV → launches DesiredCapacity instances using template → registers with target groups if specified → starts health check loop → return success | 1. Create ASG with launch template<br>2. Scale to desired capacity<br>3. Missing launch template (ValidationError)<br>4. Min > Max (ValidationError) | **NOT STARTED** |
| `update-auto-scaling-group` | — | `--auto-scaling-group-name`, `--min-size`, `--max-size`, `--desired-capacity`, `--launch-template`, `--health-check-type`, `--health-check-grace-period` | ASG must exist | NATS `autoscaling.UpdateAutoScalingGroup` → daemon updates ASG config in KV → if DesiredCapacity changed, scale up/down accordingly → return success | 1. Update desired capacity<br>2. Update launch template<br>3. Non-existent ASG (ValidationError) | **NOT STARTED** |
| `delete-auto-scaling-group` | — | `--auto-scaling-group-name`, `--force-delete` | ASG must exist | NATS `autoscaling.DeleteAutoScalingGroup` → if force: terminate all instances → deregister from target groups → delete from KV → return success. Without force: reject if instances > 0 | 1. Delete empty ASG<br>2. Force delete with instances<br>3. Delete with instances (ResourceInUse) | **NOT STARTED** |
| `describe-auto-scaling-groups` | — | `--auto-scaling-group-names`, `--filters`, `--max-records` | None | NATS `autoscaling.DescribeAutoScalingGroups` → daemon lists ASG records → return list with Instances, DesiredCapacity, MinSize, MaxSize | 1. List all ASGs<br>2. Filter by name | **NOT STARTED** |
| `set-desired-capacity` | — | `--auto-scaling-group-name`, `--desired-capacity`, `--honor-cooldown` | ASG must exist | NATS `autoscaling.SetDesiredCapacity` → daemon updates desired count → scale up (launch) or scale down (terminate oldest) → return success | 1. Scale up<br>2. Scale down<br>3. Exceeds max (ValidationError) | **NOT STARTED** |
| `describe-auto-scaling-instances` | — | `--instance-ids`, `--max-records` | None | NATS `autoscaling.DescribeAutoScalingInstances` → daemon lists all ASG-managed instances → return list with ASG name, lifecycle state, health status | 1. List all ASG instances<br>2. Filter by instance ID | **NOT STARTED** |
| `put-scaling-policy` | — | `--auto-scaling-group-name`, `--policy-name`, `--policy-type` (TargetTrackingScaling, StepScaling, SimpleScaling), `--target-tracking-configuration`, `--scaling-adjustment`, `--cooldown` | ASG must exist | NATS `autoscaling.PutScalingPolicy` → daemon stores policy in KV → links to CloudWatch alarms for metric-based triggers → return PolicyARN | 1. Create target tracking policy<br>2. Create step scaling policy<br>3. Non-existent ASG (ValidationError) | **NOT STARTED** |
| `delete-scaling-policy` | — | `--auto-scaling-group-name`, `--policy-name` | Policy must exist | NATS `autoscaling.DeleteScalingPolicy` → daemon deletes policy → remove CloudWatch alarm links → return success | 1. Delete existing policy<br>2. Non-existent policy | **NOT STARTED** |
+123 -187
View File
@@ -4,181 +4,129 @@ Spinifex is an AWS-compatible infrastructure platform for bare-metal, edge, and
## High-Level Architecture
```
┌─────────────────────────────────────────────────────────────────────────────┐
│ AWS SDK / CLI │
│ (aws ec2 run-instances --endpoint-url ...) │
└─────────────────────────────────────────────────────────────────────────────┘
│
│ HTTPS (SigV4 Auth)
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ AWS Gateway (Port 9999) │
│ │
│ • TLS termination • AWS SigV4 authentication │
│ • EC2 Query Protocol parsing • Routes to service handlers │
│ • XML response generation • NATS client connection │
│ │
│ File: spinifex/services/awsgw/awsgw.go │
└─────────────────────────────────────────────────────────────────────────────┘
│
│ JSON over NATS
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ NATS Message Broker │
│ │
│ Topics: Queue Groups: │
│ • ec2.RunInstances • spinifex-workers (load balanced) │
│ • ec2.DescribeInstances • No queue (fan-out to all nodes) │
│ • ec2.cmd.{instanceID} (per-instance start/stop/terminate) │
│ • ebs.mount / ebs.unmount │
└─────────────────────────────────────────────────────────────────────────────┘
│
┌─────────────────┼─────────────────┐
▼ ▼ ▼
┌──────────────────────┐ ┌──────────────────────┐ ┌──────────────────────┐
│ Spinifex Daemon │ │ Spinifex Daemon │ │ Spinifex Daemon │
│ (Node 1) │ │ (Node 2) │ │ (Node N) │
│ │ │ │ │ │
│ • NATS subscriber │ │ • NATS subscriber │ │ • NATS subscriber │
│ • Resource manager │ │ • Resource manager │ │ • Resource manager │
│ • QEMU/KVM launcher │ │ • QEMU/KVM launcher │ │ • QEMU/KVM launcher │
│ • QMP monitoring │ │ • QMP monitoring │ │ • QMP monitoring │
│ │ │ │ │ │
│ File: daemon.go │ │ File: daemon.go │ │ File: daemon.go │
└──────────────────────┘ └──────────────────────┘ └──────────────────────┘
│ │ │
▼ ▼ ▼
┌──────────────────────┐ ┌──────────────────────┐ ┌──────────────────────┐
│ QEMU/KVM VMs │ │ QEMU/KVM VMs │ │ QEMU/KVM VMs │
└──────────────────────┘ └──────────────────────┘ └──────────────────────┘
```
<p align="center">
<img src="../.github/assets/design-architecture.svg" alt="Spinifex high-level architecture — AWS SDK, gateway, NATS broker, per-node daemons, QEMU/KVM VMs" width="900">
</p>
## Request Flow
### 1. AWS SDK Request
Users interact with Spinifex using standard AWS SDKs or the AWS CLI by pointing to a custom endpoint:
Users interact with Spinifex using standard AWS SDKs or the AWS CLI by using the spinifex profile.
```bash
aws --endpoint-url https://localhost:9999 --no-verify-ssl ec2 run-instances \
AWS_PROFILE=spinifex aws ec2 run-instances \
--image-id ami-debian12 \
--instance-type t3.micro \
--key-name my-keypair
```
The AWS SDK formats this as an HTTP POST with:
The AWS SDK formats this as an HTTPS POST with:
- AWS SigV4 authentication headers
- EC2 Query Protocol body (Action=RunInstances&ImageId=ami-debian12...)
- EC2 Query Protocol body (`Action=RunInstances&ImageId=ami-debian12&...`)
### 2. AWS Gateway
The gateway (`spinifex/services/awsgw/awsgw.go:57-107`) is the entry point:
The gateway (`spinifex/services/awsgw/awsgw.go`) is the entry point:
```go
// awsgw.go:74 - Connect to NATS
natsConn, err := nats.Connect(nodeConfig.NATS.Host, opts...)
// Connect to NATS (retries while the local broker comes up)
natsConn, err := utils.ConnectNATSWithRetry(...)
// awsgw.go:89-94 - Create gateway with NATS connection
// Load IAM (master key + JetStream KV) and create the gateway
gw := gateway.GatewayConfig{
NATSConn: natsConn,
Config: nodeConfig.AWSGW.Config,
NATSConn: natsConn,
Config: nodeConfig.AWSGW.Config,
IAMService: iamService,
// ...
}
// awsgw.go:103 - Start TLS listener
log.Fatal(app.ListenTLS(nodeConfig.AWSGW.Host, nodeConfig.AWSGW.TLSCert, nodeConfig.AWSGW.TLSKey))
// Serve over TLS
server.ListenAndServeTLS("", "")
```
Request routing (`spinifex/gateway/gateway.go:125-156`):
Request routing (`spinifex/gateway/gateway.go`):
1. **Authentication**: SigV4 middleware validates AWS credentials (`gateway.go:104`)
2. **Service Detection**: Extracts service name (ec2, iam, account) from Authorization header (`gateway.go:158-186`)
3. **Action Dispatch**: Routes to service-specific handler (`gateway.go:136-145`)
1. **Authentication**: SigV4 middleware validates AWS credentials and resolves the account ID
2. **Throttling**: Per-account+action token bucket rejects bursts post-auth
3. **Service Detection**: Extracts service name (ec2, iam, account, elasticloadbalancing, spinifex) from the Authorization header
4. **Action Dispatch**: Routes to service-specific handler
```go
// gateway.go:136-145
switch svc {
case "ec2":
err = gw.EC2_Request(ctx)
err = gw.EC2_Request(w, r)
case "account":
err = gw.Account_Request(ctx)
err = gw.Account_Request(w, r)
case "iam":
err = gw.IAM_Request(ctx)
err = gw.IAM_Request(w, r)
case "elasticloadbalancing":
err = gw.ELBv2_Request(w, r)
case "spinifex":
err = gw.Spinifex_Request(w, r)
}
```
### 3. EC2 Handler
The EC2 handler (`spinifex/gateway/ec2.go`) parses the Action parameter and delegates to specific handlers:
The EC2 handler (`spinifex/gateway/ec2.go`) parses the `Action` parameter and delegates to specific handlers via a generic `ec2Handler` wrapper. The account ID resolved by SigV4 auth is threaded into every handler so requests are scoped per IAM principal:
```go
// ec2.go - Action routing
switch action {
case "RunInstances":
output, err := gateway_ec2_instance.RunInstances(input, gw.NATSConn)
case "DescribeInstances":
output, err := gateway_ec2_instance.DescribeInstances(input, gw.NATSConn, expectedNodes)
// ... more actions
}
"RunInstances": ec2Handler(func(input *ec2.RunInstancesInput, gw *GatewayConfig, accountID string) (any, error) {
return gateway_ec2_instance.RunInstances(input, gw.NATSConn, accountID)
}),
"DescribeInstances": ec2Handler(func(input *ec2.DescribeInstancesInput, gw *GatewayConfig, accountID string) (any, error) {
return gateway_ec2_instance.DescribeInstances(input, gw.NATSConn, gw.DiscoverActiveNodes(), accountID)
}),
// ... + volumes, snapshots, VPCs, subnets, route tables, IGWs, NAT gateways,
// security groups, network interfaces, placement groups, key pairs, images, tags
```
### 4. NATS Messaging
The gateway communicates with daemons via NATS request/response (`spinifex/handlers/ec2/instance/service_nats.go:24-57`):
The gateway communicates with daemons via NATS request/response. Most calls go through `utils.NATSRequest`, which marshals the input, attaches the account ID as a NATS header, and unmarshals the typed response:
```go
// service_nats.go:24-57
func (s *NATSInstanceService) RunInstances(input *ec2.RunInstancesInput) (*ec2.Reservation, error) {
// Marshal input to JSON
jsonData, err := json.Marshal(input)
// Send request to daemon via NATS with 5 minute timeout
msg, err := s.natsConn.Request("ec2.RunInstances", jsonData, 5*time.Minute)
// Unmarshal response
var reservation ec2.Reservation
err = json.Unmarshal(msg.Data, &reservation)
return &reservation, nil
func (s *NATSInstanceService) RunInstances(input *ec2.RunInstancesInput, accountID string) (*ec2.Reservation, error) {
topic := fmt.Sprintf("ec2.RunInstances.%s", aws.StringValue(input.InstanceType))
return utils.NATSRequest[ec2.Reservation](s.natsConn, topic, input, 5*time.Minute, accountID)
}
```
`RunInstances` uses a per-instance-type subject so NATS only delivers the request to a node with spare capacity for that type — no application-level reject-and-retry.
### 5. Daemon Processing
Daemons (`spinifex/daemon/daemon.go`) subscribe to NATS topics and handle requests:
Daemons (`spinifex/daemon/daemon.go`) subscribe to NATS topics and handle requests. A table-driven `subscribeAll()` registers the static EC2/ELBv2 surface at startup:
```go
// daemon.go:319 - Subscribe with queue group for load balancing
d.natsSubscriptions["ec2.RunInstances"], err = d.natsConn.QueueSubscribe(
"ec2.RunInstances",
"spinifex-workers", // Queue group - only one daemon handles each request
d.handleEC2RunInstances,
)
// Queue group → load-balanced (one daemon handles each request)
{"ec2.CreateKeyPair", d.handleEC2CreateKeyPair, "spinifex-workers"},
// daemon.go:373 - Subscribe without queue group for fan-out
d.natsSubscriptions["ec2.DescribeInstances"], err = d.natsConn.Subscribe(
"ec2.DescribeInstances",
d.handleEC2DescribeInstances, // All daemons respond
)
// No queue group → fan-out (every daemon responds)
{"ec2.DescribeInstanceTypes", d.handleEC2DescribeInstanceTypes, ""},
```
**Queue Groups**: Topics subscribed with a queue group (`spinifex-workers`) are load-balanced - only one daemon handles each request. Topics without a queue group fan out to all daemons.
`ec2.RunInstances` is dynamic: `ResourceManager` subscribes to `ec2.RunInstances.{type}` (and `ec2.RunInstances.{type}.{nodeId}` for targeted dispatch) only while the node has capacity for that type, and unsubscribes when full. Per-instance commands flow over `ec2.cmd.{instanceID}`, subscribed only by the owning node.
**Queue Groups**: topics with the `spinifex-workers` queue group are load-balanced — only one daemon handles each request. Topics without a queue group fan out to all daemons.
### 6. VM Launch
When a daemon handles `RunInstances`:
1. **Resource Check**: Validates CPU/memory availability (`daemon.go:66-74`)
1. **Resource Check**: Validates CPU/memory availability via `ResourceManager`
2. **Volume Generation**: Creates boot, cloud-init, and EFI volumes via Viperblock
3. **Volume Mount**: Sends `ebs.mount` request to Viperblock, receives NBD URI
4. **QEMU Launch**: Builds and executes QEMU command with NBD-backed disks
5. **QMP Monitoring**: Establishes QEMU Machine Protocol connection for VM management
6. **Command subscription**: Subscribes to `ec2.cmd.{instanceID}` for subsequent commands on this VM
```go
// daemon.go - Volume mounting via NATS
// Volume mounting via NATS
msg, err := d.natsConn.Request("ebs.mount", ebsMountRequest, 10*time.Second)
// daemon.go - QEMU launch with NBD storage
// QEMU launch with NBD storage
cmd := exec.Command("qemu-system-x86_64",
"-drive", fmt.Sprintf("file=nbd:%s,format=raw", nbdURI),
// ... additional QEMU args
@@ -190,66 +138,56 @@ cmd := exec.Command("qemu-system-x86_64",
### Daemon Structure
```go
// daemon.go:76-102
type Daemon struct {
node string // Node identifier
clusterConfig *config.ClusterConfig // Cluster-wide configuration
config *config.Config // Node-specific configuration
natsConn *nats.Conn // NATS connection
resourceMgr *ResourceManager // CPU/Memory tracking
instanceService *InstanceServiceImpl // EC2 instance operations
keyService *KeyServiceImpl // SSH key operations
imageService *ImageServiceImpl // AMI operations
Instances vm.Instances // Local VMs
clusterApp *fiber.App // HTTP cluster manager
node string // Node identifier
clusterConfig *config.ClusterConfig // Cluster-wide configuration
config *config.Config // Node-specific configuration
natsConn *nats.Conn // NATS connection
resourceMgr *ResourceManager // CPU/Memory tracking + dynamic RunInstances subscriptions
Instances vm.Instances // Local VMs
// ... + one service struct per EC2/ELBv2 resource
// (instance, key, image, volume, snapshot, tags, vpc, subnet,
// igw, eigw, natgw, routetable, eip, placementgroup, elbv2, ...)
}
```
### Resource Manager
Tracks available and allocated CPU/memory to prevent overcommit:
Tracks available and allocated CPU/memory to prevent overcommit, and drives the dynamic `ec2.RunInstances.{type}` subscriptions:
```go
// daemon.go:66-74
type ResourceManager struct {
mu sync.RWMutex
availableVCPU int
availableMem float64
hostVCPU int // raw runtime.NumCPU
hostMemGB float64 // raw /proc/meminfo MemTotal
reservedVCPU int // held back for spinifex services
reservedMem float64 // held back for spinifex services
allocatedVCPU int
allocatedMem float64
instanceTypes map[string]InstanceType // t3.micro, t3.small, etc.
instanceTypes map[string]*ec2.InstanceTypeInfo // t3.micro, t3.small, etc.
}
```
### NATS Topics
| Topic | Queue Group | Purpose |
|-------|-------------|---------|
| `ec2.RunInstances` | `spinifex-workers` | Launch new instances |
| `ec2.DescribeInstances` | None (fan-out) | Query all nodes for instances |
| `ec2.cmd.{instanceID}` | None (owner only) | Per-instance commands (start/stop/terminate, attach/detach volume) |
| `ec2.CreateKeyPair` | `spinifex-workers` | Generate SSH keypair |
| `ec2.DescribeKeyPairs` | `spinifex-workers` | List SSH keypairs |
| `ec2.DescribeImages` | `spinifex-workers` | List AMIs |
| `ebs.mount` | - | Mount volume via Viperblock |
| `ebs.unmount` | - | Unmount volume |
| `spinifex.admin.{node}.health` | None | Node health checks |
### Multi-Node Aggregation
For operations that need data from all nodes (like `DescribeInstances`), the gateway uses inbox-based fan-out (`spinifex/gateway/ec2/instance/DescribeInstances.go`):
```go
// DescribeInstances.go:16-111
func DescribeInstances(...) {
// Create unique inbox for collecting responses
inbox := nats.NewInbox()
sub, err := natsConn.SubscribeSync(inbox)
sub, _ := natsConn.SubscribeSync(inbox)
// Publish to all nodes (no queue group = all daemons respond)
err = natsConn.PublishRequest("ec2.DescribeInstances", inbox, jsonData)
// Publish to all nodes (no queue group = all daemons respond).
// Account ID is propagated as a NATS header so each daemon scopes its response.
pubMsg := nats.NewMsg("ec2.DescribeInstances")
pubMsg.Reply = inbox
pubMsg.Data = jsonData
pubMsg.Header.Set(utils.AccountIDHeader, accountID)
_ = natsConn.PublishMsg(pubMsg)
// Collect responses from all nodes with 3-second timeout
// Collect responses, returning early once expectedNodes have replied
// or the 3s timeout is hit.
for {
msg, err := sub.NextMsg(3 * time.Second)
allReservations = append(allReservations, nodeOutput.Reservations...)
@@ -300,74 +238,72 @@ type ClusterConfig struct {
type Config struct {
Node, Host, Region, AZ string
DataDir string
Daemon DaemonConfig // Cluster manager port
NATS NATSConfig // NATS broker address
Predastore PredastoreConfig // S3 endpoint
AWSGW AWSGWConfig // Gateway port, TLS certs
AccessKey, SecretKey string
BaseDir, DataDir string
Daemon DaemonConfig
NATS NATSConfig // Broker address, JetStream, ACL token, CA cert
AWSGW AWSGWConfig // Gateway host, TLS certs, throttle config
Predastore PredastoreConfig // S3 endpoint + service credentials
Viperblock ViperblockConfig
VPCD VPCDConfig // OVN/OVS endpoints
Network NetworkConfig
}
```
Configs are generated by `spx admin init` (leader) or `spx admin join` (followers).
## File Structure
```
spinifex/
├── spinifex/
│ ├── services/
│ │ └── awsgw/
│ │ └── awsgw.go # AWS Gateway service entry point
│ ├── gateway/
│ │ ├── gateway.go # Request routing, auth middleware
│ │ ├── ec2.go # EC2 action dispatcher
│ │ └── ec2/
│ │ └── instance/
│ │ ├── RunInstances.go
│ │ └── DescribeInstances.go
│ ├── handlers/
│ │ └── ec2/
│ │ ├── instance/
│ │ │ ├── service_nats.go # NATS request/response
│ │ │ └── service_impl.go # Business logic
│ │ ├── key/
│ │ └── image/
│ ├── services/ # Long-running services (awsgw, spinifex, viperblockd,
│ │ # predastore, nats, vpcd, spinifexui)
│ ├── gateway/ # AWS API surface
│ │ ├── gateway.go # chi router, SigV4 auth, throttling
│ │ ├── auth.go # SigV4 verification + IAM lookup
│ │ ├── ec2.go # EC2 action dispatcher
│ │ ├── ec2/ # Per-resource handlers (instance, volume, vpc, ...)
│ │ ├── elbv2/, iam/, policy/
│ ├── handlers/ # NATS-side service implementations (ec2, elbv2, iam)
│ ├── daemon/
│ │ └── daemon.go # Daemon entry point, NATS subscriptions
│ ├── config/
│ │ └── config.go # Configuration structures
│ ├── vm/
│ │ └── instance.go # VM instance representation
│ └── qmp/
│ └── qmp.go # QEMU Machine Protocol client
│ │ ├── daemon.go # Daemon entry, NATS subscription table
│ │ └── daemon_handlers_*.go # One file per resource family
│ ├── admin/ # Cluster bootstrap (init/join, master.key, CA, tokens)
│ ├── config/ # Configuration structures
│ ├── instancetypes/ # t3.*, m5.*, sys.* definitions
│ ├── lbagent/ # In-VM agent for ELB target health
│ ├── vm/ # VM instance representation
│ └── qmp/ # QEMU Machine Protocol client
└── cmd/
└── spinifex/
└── main.go # CLI entry point
├── spinifex/ # spx CLI
├── lb-agent/ # ELB target agent binary
└── installer/ # Platform installer
```
## Development
### Running the Stack
1. Start NATS broker
2. Start Predastore (S3)
3. Start Viperblock (EBS)
4. Start Spinifex Gateway (`spx awsgw`)
5. Start Spinifex Daemon(s) (`spx daemon`)
```bash
sudo spx admin init --node node1 --nodes 1
sudo systemctl start spinifex.target
```
`spx admin init` generates the cluster CA, IAM credentials, TLS certs, and AWS CLI `spinifex` profile, then writes config to `/etc/spinifex/` and data to `/var/lib/spinifex/`. `spinifex.target` brings up all services (NATS, Predastore, Viperblock, vpcd, the daemon, awsgw, the UI) under systemd. Use `spx admin join` for additional nodes.
### Testing with AWS CLI
```bash
# Set endpoint
export AWS_ENDPOINT_URL=https://localhost:9999
export AWS_PROFILE=spinifex
# Run instance
aws --no-verify-ssl ec2 run-instances \
--image-id ami-debian12 \
--instance-type t3.micro
aws ec2 run-instances --image-id ami-debian12 --instance-type t3.micro
# List instances (queries all nodes)
aws --no-verify-ssl ec2 describe-instances
aws ec2 describe-instances
# Terminate instance
aws --no-verify-ssl ec2 terminate-instances --instance-ids i-xxxxx
aws ec2 terminate-instances --instance-ids i-xxxxx
```
For cluster-state inspection that bypasses the AWS API surface, use the `spx` CLI directly: `spx get nodes`, `spx get vms`, `spx top nodes`.
File diff suppressed because it is too large Load Diff
+66
View File
@@ -0,0 +1,66 @@
# Spinifex Tag Conventions
Spinifex uses a small set of reserved tag keys to mark resources that are
owned and managed by the platform itself (as opposed to resources created
directly by customer AWS API calls). The UI uses these tags to hide
system-owned resources from customer-facing listings so operators do not
mistake them for user workloads and accidentally terminate or modify
them.
## `spinifex:managed-by`
Identifies the Spinifex platform component that owns a resource.
| Value | Component | Tagged resource types |
|---------|---------------------------------|---------------------------------------|
| `elbv2` | ALB / HAProxy load balancers | LB VM (`instance`), LB ENI, LB AMI |
The tag is applied by the backend at create/launch time:
- **Instance** — set in
`spinifex/spinifex/daemon/daemon_system_instance.go` when
`LaunchSystemInstance` builds the `RunInstancesInput`, and mirrored onto
the stored `vm.VM.ManagedBy` field for fast lookup.
- **ENI** — set in
`spinifex/spinifex/handlers/elbv2/service_impl.go` when
`CreateLoadBalancer` calls `CreateNetworkInterface`.
- **AMI** — declared on the catalog entry in
`spinifex/spinifex/utils/images.go` and copied into
`AMIMetadata.Tags` by the image importer in
`spinifex/cmd/spinifex/cmd/admin.go`.
Tag keys and values are defined once in `spinifex/spinifex/tags/tags.go`;
new system components should register their value there and reuse the
constants rather than hardcoding strings.
## UI behaviour
The admin **Nodes** page and the **Images** (AMIs) page filter out
resources that carry `spinifex:managed-by`. Engineers who need to see
system resources for debugging can append `?system=1` to the URL — the
filter is bypassed and a hidden-count banner is replaced with the full
listing. Direct navigation to a system AMI detail page without
`?system=1` renders the "Image not found" fallback.
The EC2 **Instances** page does not need UI-side filtering: backend
SigV4 authentication scopes `DescribeInstances` by account ID, and
system instances live under the global system account
(`000000000000`), so customers never see them.
## `spinifex:lb-arn`
Applied to ELBv2-managed ENIs alongside `spinifex:managed-by=elbv2`.
Stores the ARN of the parent load balancer so reconciliation can map an
ENI back to its owner without a separate lookup. Customer-facing
filtering keys off `spinifex:managed-by`; `spinifex:lb-arn` is
informational.
## Adding a new system component
1. Add a new value constant next to `ManagedByELBv2` in
`spinifex/spinifex/tags/tags.go`.
2. Tag every resource the component creates (instance, ENI, AMI,
volume, …) with `spinifex:managed-by=<value>`.
3. Add the value to the table above so operators can recognise it.
4. No frontend changes are required — the UI filters on the key, not
the value.
@@ -32,19 +32,13 @@ Spinifex supports multi-tenant account isolation. Each account gets its own IAM
## Create Account
```bash
./bin/spx admin account create --name myteam
spx admin account create --name myteam
export AWS_PROFILE=spinifex-myteam
```
## Verify
```bash
aws sts get-caller-identity
```
## Troubleshooting
## Credentials not working
## Credentials Not Working
Verify the AWS CLI configuration files exist and contain the correct profile:
@@ -0,0 +1,538 @@
---
title: "IAM Users and Policies"
description: "Create IAM users, manage access keys, and control permissions with policies."
category: "Administration"
tags:
- iam
- users
- policies
- access keys
- security
resources:
- title: "Spinifex Repository"
url: "https://github.com/mulgadc/spinifex"
- title: "AWS IAM CLI Reference"
url: "https://docs.aws.amazon.com/cli/latest/reference/iam/"
- title: "IAM Policy Reference"
url: "https://docs.aws.amazon.com/IAM/latest/UserGuide/reference_policies_elements.html"
---
# IAM Users and Policies
> Create IAM users, manage access keys, and control permissions with policies.
## Table of Contents
- [Overview](#overview)
- [Prerequisites](#prerequisites)
- [Users](#users)
- [Access Keys](#access-keys)
- [Policies](#policies)
- [Attaching Policies](#attaching-policies)
- [Putting It All Together](#putting-it-all-together)
- [Command Reference](#command-reference)
- [Troubleshooting](#troubleshooting)
---
## Overview
Spinifex implements AWS-compatible IAM with 16 operations covering user management, access key lifecycle, policy CRUD, and policy attachment. All IAM resources are scoped to the account that creates them — users in one account cannot see or modify resources in another.
When you create an account with `spx admin account create`, Spinifex bootstraps a root user with an `AdministratorAccess` policy and writes the credentials to `~/.aws/credentials`. From there, you use the standard AWS CLI to manage additional users and permissions.
**How authentication works:** Every AWS CLI request is signed with SigV4 using an access key pair. The gateway verifies the signature, resolves the caller's account, and evaluates attached policies before routing the request. The root user (account `000000000000`) bypasses policy evaluation entirely.
## Instructions
## Prerequisites
- A running Spinifex cluster with the AWS gateway started
- An account created via `spx admin account create` (see [Creating Accounts](/docs/creating-accounts))
- AWS CLI configured with the account's profile:
```bash
export AWS_PROFILE=spinifex-myteam
```
## Users
### Create a User
```bash
aws iam create-user --user-name alice
```
The response includes the user's ARN, unique ID, and creation date:
```json
{
"User": {
"UserName": "alice",
"UserId": "AIDA1A2B3C4D5E6F7890",
"Arn": "arn:aws:iam::000000000001:user/alice",
"Path": "/",
"CreateDate": "2026-03-24T10:00:00Z"
}
}
```
Use `--path` to organise users into hierarchical groups:
```bash
aws iam create-user --user-name bob --path /developers/
```
### Get a User
```bash
aws iam get-user --user-name alice
```
### List Users
```bash
aws iam list-users
```
Filter by path prefix:
```bash
aws iam list-users --path-prefix /developers/
```
### Delete a User
Before deleting a user, remove all access keys and detach all policies:
```bash
# Remove access keys
aws iam list-access-keys --user-name alice
aws iam delete-access-key --user-name alice --access-key-id AKIA...
# Detach policies
aws iam list-attached-user-policies --user-name alice
aws iam detach-user-policy --user-name alice \
--policy-arn arn:aws:iam::000000000001:policy/MyPolicy
# Now delete
aws iam delete-user --user-name alice
```
## Access Keys
Access keys are how users authenticate with the AWS CLI. Each user can have up to **2 access keys** at a time, allowing key rotation without downtime.
### Create an Access Key
```bash
aws iam create-access-key --user-name alice
```
```json
{
"AccessKey": {
"UserName": "alice",
"AccessKeyId": "AKIA1A2B3C4D5E6F7890ABCD",
"Status": "Active",
"SecretAccessKey": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
"CreateDate": "2026-03-24T10:05:00Z"
}
}
```
> **The secret access key is only shown once.** Save it immediately. If lost, delete the key and create a new one.
Add the credentials to `~/.aws/credentials` under a new profile and set `AWS_PROFILE` to use them.
### List Access Keys
```bash
aws iam list-access-keys --user-name alice
```
### Rotate Access Keys
Create a second key, update your configuration, then delete the old one:
```bash
# 1. Create new key (while old key still works)
aws iam create-access-key --user-name alice
# 2. Update ~/.aws/credentials with the new key
# 3. Verify the new key works
AWS_PROFILE=spinifex-alice aws sts get-caller-identity
# 4. Delete the old key
aws iam delete-access-key --user-name alice --access-key-id AKIA_OLD_KEY_ID
```
### Deactivate an Access Key
Temporarily disable a key without deleting it:
```bash
aws iam update-access-key --user-name alice \
--access-key-id AKIA1A2B3C4D5E6F7890ABCD \
--status Inactive
```
Reactivate it later:
```bash
aws iam update-access-key --user-name alice \
--access-key-id AKIA1A2B3C4D5E6F7890ABCD \
--status Active
```
## Policies
Policies are JSON documents that define what actions a user can perform on which resources.
### Policy Document Format
```json
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["ec2:RunInstances", "ec2:DescribeInstances"],
"Resource": "*"
}
]
}
```
Each statement has:
- **Effect** — `Allow` or `Deny`
- **Action** — Service actions (e.g. `ec2:RunInstances`, `s3:GetObject`). Supports wildcards: `ec2:*`, `s3:Get*`, or `*` for all actions.
- **Resource** — Target resources. Use `*` for all resources.
**Evaluation order:** An explicit `Deny` always wins. If no statement matches, access is denied by default.
### Create a Policy
Save the policy document to a file, then create the policy:
```bash
cat > /tmp/ec2-readonly.json << 'EOF'
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"ec2:DescribeInstances",
"ec2:DescribeImages",
"ec2:DescribeKeyPairs",
"ec2:DescribeSecurityGroups",
"ec2:DescribeSubnets",
"ec2:DescribeVpcs"
],
"Resource": "*"
}
]
}
EOF
aws iam create-policy \
--policy-name EC2ReadOnly \
--policy-document file:///tmp/ec2-readonly.json
```
```json
{
"Policy": {
"PolicyName": "EC2ReadOnly",
"PolicyId": "ANPA1A2B3C4D5E6F7890",
"Arn": "arn:aws:iam::000000000001:policy/EC2ReadOnly",
"Path": "/",
"DefaultVersionId": "v1",
"CreateDate": "2026-03-24T10:10:00Z"
}
}
```
### Common Policy Examples
**Full administrator access:**
```json
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "*",
"Resource": "*"
}
]
}
```
**S3 read/write (for hybrid sync, backups):**
```json
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"s3:GetObject",
"s3:PutObject",
"s3:ListBucket",
"s3:DeleteObject"
],
"Resource": "*"
}
]
}
```
**EC2 operator (launch and manage instances, no VPC changes):**
```json
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"ec2:RunInstances",
"ec2:StartInstances",
"ec2:StopInstances",
"ec2:TerminateInstances",
"ec2:DescribeInstances",
"ec2:DescribeImages"
],
"Resource": "*"
}
]
}
```
**Deny terminate (attach alongside a broader Allow to prevent accidental termination):**
```json
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Deny",
"Action": "ec2:TerminateInstances",
"Resource": "*"
}
]
}
```
### Get a Policy
```bash
aws iam get-policy \
--policy-arn arn:aws:iam::000000000001:policy/EC2ReadOnly
```
### Get the Policy Document
Use `get-policy-version` with version `v1` to retrieve the actual JSON document:
```bash
aws iam get-policy-version \
--policy-arn arn:aws:iam::000000000001:policy/EC2ReadOnly \
--version-id v1
```
### List Policies
```bash
aws iam list-policies
```
### Delete a Policy
A policy must be detached from all users before it can be deleted:
```bash
aws iam delete-policy \
--policy-arn arn:aws:iam::000000000001:policy/EC2ReadOnly
```
## Attaching Policies
Policies have no effect until attached to a user.
### Attach a Policy to a User
```bash
aws iam attach-user-policy --user-name alice \
--policy-arn arn:aws:iam::000000000001:policy/EC2ReadOnly
```
### List a User's Policies
```bash
aws iam list-attached-user-policies --user-name alice
```
### Detach a Policy
```bash
aws iam detach-user-policy --user-name alice \
--policy-arn arn:aws:iam::000000000001:policy/EC2ReadOnly
```
## Putting It All Together
A complete workflow for onboarding a developer with scoped EC2 and S3 access:
```bash
# 1. Create the user
aws iam create-user --user-name dev-carol --path /developers/
# 2. Create an access key
aws iam create-access-key --user-name dev-carol
# Save the AccessKeyId and SecretAccessKey from the output
# 3. Create a scoped policy
cat > /tmp/dev-policy.json << 'EOF'
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "EC2Access",
"Effect": "Allow",
"Action": ["ec2:*"],
"Resource": "*"
},
{
"Sid": "S3ReadOnly",
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:ListBucket"],
"Resource": "*"
},
{
"Sid": "NoTerminate",
"Effect": "Deny",
"Action": "ec2:TerminateInstances",
"Resource": "*"
}
]
}
EOF
aws iam create-policy \
--policy-name DeveloperAccess \
--policy-document file:///tmp/dev-policy.json
# 4. Attach the policy
aws iam attach-user-policy --user-name dev-carol \
--policy-arn arn:aws:iam::000000000001:policy/DeveloperAccess
# 5. Configure the developer's AWS CLI
aws configure --profile spinifex-carol
# Enter the access key and secret from step 2
# 6. Verify
AWS_PROFILE=spinifex-carol aws ec2 describe-instances
AWS_PROFILE=spinifex-carol aws ec2 terminate-instances --instance-ids i-123
# ^ This will be denied by the NoTerminate statement
```
## Command Reference
| Command | Key Parameters | Notes |
| ----------------------------- | -------------------------------------------- | --------------------------------- |
| `create-user` | `--user-name`, `--path` | Path default: `/` |
| `get-user` | `--user-name` | |
| `list-users` | `--path-prefix` | |
| `delete-user` | `--user-name` | Must have no keys or policies |
| `create-access-key` | `--user-name` | Max 2 per user; secret shown once |
| `list-access-keys` | `--user-name` | |
| `delete-access-key` | `--user-name`, `--access-key-id` | |
| `update-access-key` | `--user-name`, `--access-key-id`, `--status` | `Active` or `Inactive` |
| `create-policy` | `--policy-name`, `--policy-document` | Max 6144 bytes |
| `get-policy` | `--policy-arn` | Metadata only |
| `get-policy-version` | `--policy-arn`, `--version-id` | Use `v1`; includes document |
| `list-policies` | | |
| `delete-policy` | `--policy-arn` | Must not be attached |
| `attach-user-policy` | `--user-name`, `--policy-arn` | Idempotent |
| `detach-user-policy` | `--user-name`, `--policy-arn` | |
| `list-attached-user-policies` | `--user-name` | |
## Troubleshooting
### AccessDenied on IAM Commands
The calling user must have IAM permissions. Attach a policy with `iam:*` actions, or use the root account profile:
```bash
export AWS_PROFILE=spinifex-myteam
aws iam list-users
```
### InvalidClientTokenId
The access key is either inactive or does not exist. Check the key status:
```bash
aws iam list-access-keys --user-name alice
```
If the key shows `Inactive`, reactivate it:
```bash
aws iam update-access-key --user-name alice \
--access-key-id AKIA... --status Active
```
If the key was deleted, create a new one.
### DeleteConflict When Deleting a User
The user still has access keys or attached policies. Remove them first:
```bash
# Check for access keys
aws iam list-access-keys --user-name alice
# Check for attached policies
aws iam list-attached-user-policies --user-name alice
```
### DeleteConflict When Deleting a Policy
The policy is still attached to one or more users. Detach it from all users before deleting:
```bash
# Find who has it attached, then detach
aws iam detach-user-policy --user-name alice \
--policy-arn arn:aws:iam::000000000001:policy/MyPolicy
aws iam delete-policy \
--policy-arn arn:aws:iam::000000000001:policy/MyPolicy
```
### LimitExceeded When Creating Access Keys
Each user can have at most 2 access keys. Delete an existing key before creating a new one:
```bash
aws iam list-access-keys --user-name alice
aws iam delete-access-key --user-name alice --access-key-id AKIA_OLD
aws iam create-access-key --user-name alice
```
### MalformedPolicyDocument
The policy JSON is invalid. Check that:
- `Version` is exactly `"2012-10-17"`
- At least one `Statement` exists
- Each statement has `Effect` (`Allow`/`Deny`), `Action`, and `Resource`
- The document is under 6144 bytes
- The JSON is well-formed (no trailing commas, correct quoting)
@@ -6,7 +6,6 @@ tags:
- cli
- admin
- reference
badge: cli
resources:
- title: "Spinifex Repository"
url: "https://github.com/mulgadc/spinifex"
@@ -28,7 +27,7 @@ resources:
The `spx` binary is the central administration tool for managing your Spinifex infrastructure. It provides commands for cluster initialization, account management, node operations, VM lifecycle, and service control.
**Binary location:** `~/Development/mulga/spinifex/bin/spx`
**Binary location:** `/usr/local/bin/spx` (binary install) or `~/Development/mulga/spinifex/bin/spx` (source install)
All services in the Spinifex platform are managed through this single binary.
@@ -36,15 +35,27 @@ All services in the Spinifex platform are managed through this single binary.
## Account Management
Create a new isolated account with an admin user and AdministratorAccess policy:
```bash
./bin/spx admin account create --name myteam
spx admin account create --name myteam
export AWS_PROFILE=spinifex-myteam
```
## Node Management
List all accounts:
```bash
./bin/spx get nodes
spx admin account list
```
## Node Management
List nodes in the cluster:
```bash
spx get nodes
```
```
@@ -57,41 +68,49 @@ node3 Ready 127.0.0.3 ap-southeast-2 ap-southeast-2a 2m 0
## Monitor Resources
```bash
./bin/spx top nodes
spx top nodes
```
Prints per-node CPU/memory usage and cluster-wide instance type availability.
## Image Management
```bash
./bin/spx admin images list
./bin/spx admin images import --name debian-12-arm64
spx admin images list
spx admin images import --name debian-12-arm64
```
Catalog imports verify the image against the catalog-declared SHA-256/SHA-512 digest before extraction. Use `--file` to import operator-supplied media (verification skipped — operator is responsible for integrity), or `--force` to re-download after a checksum mismatch.
## Cluster Shutdown
Coordinated, phased shutdown of the entire cluster (API/UI → VMs → viperblock → predastore → NATS/daemon):
```bash
./bin/spx admin cluster shutdown
spx admin cluster shutdown
```
## Troubleshooting
## Permission denied running spinifex
### Permission Denied Running Spinifex
The binary may not be executable. Fix permissions:
```bash
chmod +x ./bin/spx
chmod +x /usr/local/bin/spx
```
If you get permission errors during operations, ensure you're running with appropriate privileges. Some OVN and networking commands require `sudo`.
## Services fail to start
### Services Fail to Start
Check the daemon logs for specific errors:
Check the daemon logs for specific errors via `systemctl`/`journalctl`:
```bash
ls ~/spinifex/logs/
cat ~/spinifex/logs/spinifex.log
systemctl status 'spinifex-*'
journalctl -u spinifex-daemon -f
journalctl -u 'spinifex-*' -f
```
Common causes include port conflicts, missing OVN configuration, or untrusted CA certificates.
+173
View File
@@ -0,0 +1,173 @@
---
title: "Launching Instances"
description: "Launch, manage, and connect to EC2-compatible virtual machines on Spinifex."
category: "Compute"
tags:
- ec2
- instances
- vm
resources:
- title: "Spinifex Repository"
url: "https://github.com/mulgadc/spinifex"
- title: "AWS EC2 CLI"
url: "https://docs.aws.amazon.com/cli/latest/reference/ec2/"
---
# Launching Instances
> Launch, manage, and connect to EC2-compatible virtual machines on Spinifex.
## Table of Contents
- [Overview](#overview)
- [Launch](#launch)
- [Manage](#manage)
- [Reboot](#reboot)
- [Modify Instance Attributes](#modify-instance-attributes)
- [Console Output](#console-output)
- [Instance Types](#instance-types)
- [SSH (Development)](#ssh-development)
- [Troubleshooting](#troubleshooting)
- [Instance Fails to Boot](#instance-fails-to-boot)
- [Cannot SSH Into Instance](#cannot-ssh-into-instance)
---
## Overview
Spinifex provides EC2-compatible VM management built on QEMU/KVM. Instances support cloud-init, SSH key injection, VPC networking, and standard AWS lifecycle operations.
**Supported operations:**
- `run-instances` — Launch new VMs
- `describe-instances` — Query state
- `stop-instances` / `start-instances` — Lifecycle
- `reboot-instances` — In-place restart (QMP reset)
- `terminate-instances` — Permanent removal
- `modify-instance-attribute` — Change type, user data, or source/dest check
- `get-console-output` — Retrieve serial console log
- `describe-instance-types` — List available instance types
## Instructions
## Prerequisites
Ensure the AWS profile is set:
```bash
export AWS_PROFILE=spinifex
```
## Launch
```bash
aws ec2 run-instances \
--image-id $SPINIFEX_AMI \
--instance-type t3.small \
--key-name spinifex-key
export INSTANCE_ID="i-XXX"
```
## Manage
```bash
aws ec2 describe-instances --instance-ids $INSTANCE_ID
aws ec2 stop-instances --instance-ids $INSTANCE_ID
aws ec2 start-instances --instance-ids $INSTANCE_ID
aws ec2 terminate-instances --instance-ids $INSTANCE_ID
aws ec2 reboot-instances --instance-ids $INSTANCE_ID
```
## Modify Instance Attributes
Change instance type, user data, or source/dest check. Instance type and user data require the instance to be **stopped** first.
### Change Instance Type
```bash
aws ec2 stop-instances --instance-ids $INSTANCE_ID
aws ec2 modify-instance-attribute \
--instance-id $INSTANCE_ID \
--instance-type t3.medium
aws ec2 start-instances --instance-ids $INSTANCE_ID
```
## Console Output
Retrieve the serial console log for a running instance. Output is base64-encoded.
```bash
aws ec2 get-console-output --instance-id $INSTANCE_ID
```
Decode the output:
```bash
aws ec2 get-console-output --instance-id $INSTANCE_ID \
--query 'Output' --output text | base64 -d
```
## Instance Types
List instance types available on the current host. The catalog is generated from the host CPU (Intel, AMD, or ARM) and includes burstable (t-family), general purpose (m-family), compute optimised (c-family), and memory optimised (r-family) types.
```bash
aws ec2 describe-instance-types
```
Filter to a specific type:
```bash
aws ec2 describe-instance-types --instance-types t3.micro t3.small
```
Show capacity (how many of each type can still be launched):
```bash
aws ec2 describe-instance-types \
--filters Name=capacity,Values=true
```
## SSH (Development)
In development mode, find the QEMU port forward and connect via localhost:
```bash
ps auxw | grep $INSTANCE_ID
ssh -i ~/.ssh/spinifex-key ec2-user@127.0.0.1 -p <port>
```
For production SSH via public IPs, see [Setting Up Your Cluster](/docs/setting-up-your-cluster).
## Troubleshooting
### Instance Fails to Boot
Check QEMU logs for the instance and verify the AMI architecture matches your host:
```bash
journalctl -u spinifex-daemon -f
aws ec2 describe-images --image-ids $SPINIFEX_AMI
```
If the AMI is for a different architecture (e.g. arm64 on an x86_64 host), import the correct image:
```bash
spx admin images list
spx admin images import --name debian-12-x86_64
```
### Cannot SSH Into Instance
cloud-init needs time to configure the instance after boot. Wait 30-60 seconds and retry.
Verify the SSH key was specified correctly when launching:
```bash
aws ec2 describe-instances --instance-ids $INSTANCE_ID
```
Check the `KeyName` field matches the key you're using to connect.
+121
View File
@@ -0,0 +1,121 @@
---
title: "Placement Groups"
description: "Create and manage spread and cluster placement groups for hardware-level instance placement control."
category: "Compute"
tags:
- ec2
- placement
- spread
- cluster
resources:
- title: "Spinifex Repository"
url: "https://github.com/mulgadc/spinifex"
- title: "AWS Placement Groups"
url: "https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/placement-groups.html"
---
# Placement Groups
> Create and manage spread and cluster placement groups for hardware-level instance placement control.
## Table of Contents
- [Overview](#overview)
- [Create](#create)
- [Launch Into a Placement Group](#launch-into-a-placement-group)
- [Describe](#describe)
- [Delete](#delete)
- [Troubleshooting](#troubleshooting)
- [InsufficientInstanceCapacity Error](#insufficientinstancecapacity-error)
- [Cannot Delete Placement Group](#cannot-delete-placement-group)
---
## Overview
Placement groups control how Spinifex distributes instances across physical hosts. Two strategies are supported:
- **Spread** — One instance per physical host, maximising fault isolation
- **Cluster** — All instances on the same host, minimising latency
The `partition` strategy is not supported.
**Supported operations:**
- `create-placement-group` — Create a new group
- `describe-placement-groups` — Query groups with optional filters
- `delete-placement-group` — Remove an empty group
## Instructions
## Prerequisites
Ensure the AWS profile is set:
```bash
export AWS_PROFILE=spinifex
```
## Create
```bash
aws ec2 create-placement-group \
--group-name my-spread-group \
--strategy spread
```
```bash
aws ec2 create-placement-group \
--group-name my-cluster-group \
--strategy cluster
```
## Launch Into a Placement Group
```bash
aws ec2 run-instances \
--image-id $SPINIFEX_AMI \
--instance-type t3.small \
--key-name spinifex-key \
--placement GroupName=my-spread-group \
--count 3
```
## Describe
```bash
aws ec2 describe-placement-groups
aws ec2 describe-placement-groups --group-names my-spread-group
aws ec2 describe-placement-groups --group-ids pg-abc123
aws ec2 describe-placement-groups \
--filters Name=strategy,Values=spread
```
## Delete
The group must have no running instances before it can be deleted:
```bash
aws ec2 terminate-instances --instance-ids $INSTANCE_ID
aws ec2 delete-placement-group --group-name my-spread-group
```
## Troubleshooting
### InsufficientInstanceCapacity Error
Not enough distinct physical hosts for a spread launch. Reduce `--count` or terminate existing instances to free host slots:
```bash
spx admin nodes list
```
### Cannot Delete Placement Group
The group still has running instances. Terminate them first:
```bash
aws ec2 describe-instances \
--filters Name=placement-group-name,Values=my-spread-group
aws ec2 terminate-instances --instance-ids $INSTANCE_ID
```
File diff suppressed because it is too large Load Diff
-120
View File
@@ -1,120 +0,0 @@
---
title: "Creating an Environment"
description: "End-to-end guide: user accounts, SSH keys, VPCs, AMIs, and launching instances."
category: "Environments"
tags:
- environment
- vpc
- ec2
- ssh
resources:
- title: "Spinifex Repository"
url: "https://github.com/mulgadc/spinifex"
- title: "Debian Cloud Images"
url: "https://cloud.debian.org/images/cloud/bookworm/latest/"
---
# Creating an Environment
> End-to-end guide: user accounts, SSH keys, VPCs, AMIs, and launching instances.
## Table of Contents
- [Overview](#overview)
- [Instructions](#instructions)
- [Troubleshooting](#troubleshooting)
---
## Overview
This guide walks through the complete workflow for creating a production-ready Spinifex environment.
**Steps:**
1. Create a user account
2. Import or create SSH keys
3. Create a VPC and subnet
4. Select and import an AMI
5. Launch EC2 instances
6. Connect via SSH
7. Monitor instance health
## Instructions
## 1. Import SSH Key
```bash
aws ec2 import-key-pair --key-name "spinifex-key" --public-key-material fileb://~/.ssh/id_rsa.pub
```
## 2. Create VPC
```bash
aws ec2 create-vpc --cidr-block 10.200.0.0/16
export SPINIFEX_VPC="vpc-XXX"
aws ec2 create-subnet --vpc-id $SPINIFEX_VPC --cidr-block 10.200.1.0/24
export SPINIFEX_SUBNET="subnet-XXX"
```
## 3. Import AMI
```bash
./bin/spx admin images list
./bin/spx admin images import --name debian-12-x86_64
export SPINIFEX_AMI="ami-XXX"
```
## 4. Launch Instance
```bash
aws ec2 run-instances \
--image-id $SPINIFEX_AMI \
--instance-type t3.small \
--key-name spinifex-key \
--subnet-id $SPINIFEX_SUBNET \
--count 1
```
## 5. Connect via SSH
```bash
ps auxw | grep $INSTANCE_ID
# Look for: hostfwd=tcp:127.0.0.1:<port>-:22
ssh -i ~/.ssh/spinifex-key ec2-user@127.0.0.1 -p <port>
```
## Troubleshooting
## Instance stuck in pending
Check the Spinifex daemon and QEMU logs for boot errors:
```bash
ls ~/spinifex/logs/
cat ~/spinifex/logs/spinifex.log
```
Verify the AMI exists and architecture matches your host:
```bash
aws ec2 describe-images --image-ids $SPINIFEX_AMI
```
## SSH connection refused
cloud-init takes 30-60 seconds to configure networking and SSH after boot. Wait and retry.
Verify the SSH port forwarding is active:
```bash
ps auxw | grep hostfwd
```
Ensure you're using the correct key and port:
```bash
ssh -i ~/.ssh/spinifex-key ec2-user@127.0.0.1 -p <port>
```
@@ -0,0 +1,186 @@
---
title: "Air-Gapped Install"
description: "Deploy Spinifex in environments without internet connectivity. Covers using a pre-built release tarball, mirrored APT packages, and locally-staged cloud images."
category: "Getting Started"
tags:
- install
- air-gapped
- offline
resources:
- title: "Spinifex Repository"
url: "https://github.com/mulgadc/spinifex"
- title: "External Connection Inventory"
url: "/docs/security/network-connections"
---
# Air-Gapped Installation
> Deploy Spinifex in environments without internet connectivity.
## Table of Contents
- [Overview](#overview)
- [Instructions](#instructions)
- [Troubleshooting](#troubleshooting)
---
## Overview
In air-gapped or disconnected environments, Spinifex can be deployed without internet access. This guide covers preparing offline packages on a connected machine, creating USB deployment media, and installing on the target server with package verification.
## Instructions
## Step 1. Download the Release (on a connected machine)
Each Spinifex release publishes a self-contained tarball, the matching `setup.sh`, and a SHA-256 checksum to GitHub Releases. Resolve the latest tag and download the assets for your architecture:
```bash
ARCH=amd64 # or arm64
TAG=$(curl -fsSL https://api.github.com/repos/mulgadc/spinifex/releases/latest \
| grep '"tag_name"' | cut -d'"' -f4)
BASE="https://github.com/mulgadc/spinifex/releases/download/${TAG}"
curl -fsSLO "${BASE}/spinifex-${TAG}-linux-${ARCH}.tar.gz"
curl -fsSLO "${BASE}/spinifex-${TAG}-linux-${ARCH}.tar.gz.sha256"
curl -fsSLO "${BASE}/setup.sh"
sha256sum -c "spinifex-${TAG}-linux-${ARCH}.tar.gz.sha256"
```
## Step 2. Stage Dependencies (on the connected machine)
Pre-download the APT packages installed by the production setup. `apt install --download-only` writes `.deb` files to `/var/cache/apt/archives/` without installing them, so the connected machine isn't modified:
```bash
sudo apt update
sudo apt install --download-only -y \
nbdkit nbdkit-plugin-dev pkg-config \
qemu-system-x86 qemu-utils qemu-kvm \
libvirt-daemon-system libvirt-clients libvirt-dev \
ovn-central ovn-host openvswitch-switch \
dhcpcd-base make gcc jq curl iproute2 netcat-openbsd \
wget unzip xz-utils file
```
Download AWS CLI v2:
```bash
curl -fsSL "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" \
-o awscliv2.zip
```
Mirror the cloud image you intend to run as guest VMs:
```bash
mkdir -p images
curl -fsSL "https://cdimage.debian.org/cdimage/cloud/bookworm/latest/debian-12-genericcloud-amd64.qcow2" \
-o images/debian-12-amd64.qcow2
```
## Step 3. Assemble Transfer Media
```bash
mkdir -p /media/spinifex-deploy/{tarball,apt-packages,aws,images}
cp spinifex-${TAG}-linux-${ARCH}.tar.gz /media/spinifex-deploy/tarball/
cp setup.sh /media/spinifex-deploy/
cp /var/cache/apt/archives/*.deb /media/spinifex-deploy/apt-packages/
cp awscliv2.zip /media/spinifex-deploy/aws/
cp images/*.qcow2 /media/spinifex-deploy/images/
```
## Step 4. Install on the Air-Gapped Target
Mount the media, install APT dependencies, install AWS CLI, then run `setup.sh` with the local tarball:
```bash
sudo mount /dev/sdb1 /mnt/usb
sudo dpkg -i /mnt/usb/apt-packages/*.deb
sudo apt-get install -f --no-download # resolve any leftover deps from local cache
cd /tmp && unzip /mnt/usb/aws/awscliv2.zip && sudo ./aws/install
INSTALL_SPINIFEX_TARBALL=/mnt/usb/tarball/spinifex-*-linux-*.tar.gz \
INSTALL_SPINIFEX_SKIP_APT=1 \
INSTALL_SPINIFEX_SKIP_AWS=1 \
bash /mnt/usb/setup.sh
```
Run `setup.sh` without `sudo` — the script handles privilege escalation internally and ends by `exec`-ing into a `newgrp spinifex` subshell so your current shell picks up `spinifex` group membership. Without that membership, AWS CLI cannot traverse `/etc/spinifex/` (mode `0750`) to read `ca.pem` and Step 9 will fail with a TLS error. Type `exit` to leave the subshell when finished.
## Step 5. Setup OVN Networking
`spx admin init` requires OVN/OVS to be configured before the daemon can manage tenant networks. `setup-ovn.sh` ships in the tarball and runs purely against local commands (no network access required):
```bash
sudo /usr/local/share/spinifex/setup-ovn.sh --management
```
If your WAN interface is a physical NIC rather than a bridge, pass `--wan-bridge=br-wan --wan-iface=<nic>`.
## Step 6. Initialize Without Telemetry
```bash
sudo spx admin init --node node1 --nodes 1 --no-telemetry
```
This generates the cluster configuration, TLS certificates, installs the CA into the system trust store, and writes AWS CLI credentials. See [Single-Node Install](/docs/install) for what `spx admin init` does.
## Step 7. Start Services
`setup.sh` enables `spinifex.target` but does not start it on a fresh install. Start it now so predastore is online before you import images:
```bash
sudo systemctl start spinifex.target
```
## Step 8. Import Cloud Images
Register the pre-staged image with Spinifex:
```bash
sudo spx admin images import --file /mnt/usb/images/debian-12-amd64.qcow2 \
--distro debian --version 12 --arch x86_64
```
## Step 9. Verify
```bash
export AWS_PROFILE=spinifex
aws ec2 describe-instance-types
aws ec2 describe-images
```
If both calls return data, your air-gapped install is working. Continue to [Setting Up Your Cluster](/docs/setting-up-your-cluster) to launch your first instance.
## Troubleshooting
### Missing APT Dependencies
`dpkg -i` does not resolve transitive dependencies. After installing, run:
```bash
sudo apt-get install -f --no-download
```
The `--no-download` flag forces apt to use only what's in the local cache. If a dependency is genuinely missing, add it to the `apt --download-only` step on the connected machine and re-stage.
### Setup.sh Tries to Download Anyway
Confirm both skip flags are exported and that `INSTALL_SPINIFEX_TARBALL` points at a readable file:
```bash
sudo INSTALL_SPINIFEX_TARBALL=/mnt/usb/tarball/spinifex-...tar.gz \
INSTALL_SPINIFEX_SKIP_APT=1 \
INSTALL_SPINIFEX_SKIP_AWS=1 \
bash -x /mnt/usb/setup.sh
```
### Init Telemetry Attempted
`spx admin init` posts a one-shot record to `https://install.mulgadc.com/install`. Pass `--no-telemetry` on every `init` and `join` invocation, or export `SPX_NO_TELEMETRY=1` in the operator's shell profile.
### Image Import Fails
`spx admin images import --file` requires the distro/version/arch flags so the image registers in the catalogue. If the import succeeds but `aws ec2 describe-images` returns empty, check `journalctl -u spinifex-daemon -f` for predastore upload errors.
@@ -0,0 +1,190 @@
---
title: "Multi-Node Install"
description: "Deploy Spinifex across multiple servers to create an availability zone with high availability, data durability, and fault tolerance."
category: "Getting Started"
tags:
- install
- multi node
- cluster
resources:
- title: "Spinifex Repository"
url: "https://github.com/mulgadc/spinifex"
- title: "Predastore (S3)"
url: "https://github.com/mulgadc/predastore"
- title: "Viperblock (EBS)"
url: "https://github.com/mulgadc/viperblock"
---
# Multi-Node Installation
> Deploy Spinifex across multiple servers to create an availability zone.
## Table of Contents
- [Overview](#overview)
- [Instructions](#instructions)
- [Troubleshooting](#troubleshooting)
---
## Overview
A Spinifex cluster distributes services across multiple servers for high availability, data durability, and fault tolerance. Cluster formation is automatic — the init node waits for peers to join, then distributes credentials, CA certificates, and configuration.
**Network Requirements:**
- Minimum 1 NIC per server (2 recommended for production)
- UDP port 6081 open between hosts (Geneve tunnels)
- TCP ports 4222, 4248, 6641, 6642 open between hosts (NATS, OVN)
## Instructions
## Step 1. Install Spinifex on Each Server
```bash
curl -fsSL https://install.mulgadc.com | bash
```
## Step 2. Set Node IP Variables
On **each server**, export the management IPs for all nodes:
```bash
export SPINIFEX_NODE1=192.168.1.10
export SPINIFEX_NODE2=192.168.1.11
export SPINIFEX_NODE3=192.168.1.12
export AWS_REGION=us-east-1
export AWS_AZ=us-east-1a
```
## Step 3. Setup OVN Networking
Server 1 runs OVN central and must be set up first. If your WAN interface is already a bridge, setup-ovn.sh auto-detects it. Otherwise use `--wan-bridge=br-wan --wan-iface=eth1` (dedicated WAN NIC).
**Server 1:**
```bash
sudo /usr/local/share/spinifex/setup-ovn.sh \
--management \
--encap-ip=$SPINIFEX_NODE1
```
**Server 2** (after server 1 is ready):
```bash
sudo /usr/local/share/spinifex/setup-ovn.sh \
--ovn-remote=tcp:$SPINIFEX_NODE1:6642 \
--encap-ip=$SPINIFEX_NODE2
```
**Server 3** (after server 1 is ready):
```bash
sudo /usr/local/share/spinifex/setup-ovn.sh \
--ovn-remote=tcp:$SPINIFEX_NODE1:6642 \
--encap-ip=$SPINIFEX_NODE3
```
Verify all 3 chassis registered:
```bash
sudo ovn-sbctl show
```
## Step 4. Form the Cluster
Run init and join concurrently — init blocks until all nodes join.
**Server 1 — Initialize:**
```bash
sudo spx admin init \
--node node1 --nodes 3 \
--bind $SPINIFEX_NODE1 --cluster-bind $SPINIFEX_NODE1 \
--port 4432 --region $AWS_REGION --az $AWS_AZ
```
The init output displays the join command including the token:
```
📡 Formation server started on 10.0.0.1:4432
Waiting for 2 more node(s) to join...
Token expires in 30m0s
Other nodes should run:
sudo spx admin join --host 10.0.0.1:4432 --token spx_join_a8Bf3x9Kz2mN --node <name> --bind <ip>
```
**Server 2 — Join** (while init is running):
```bash
sudo spx admin join \
--node node2 --bind $SPINIFEX_NODE2 --cluster-bind $SPINIFEX_NODE2 \
--host $SPINIFEX_NODE1:4432 --token <token-from-init-output> \
--region $AWS_REGION --az $AWS_AZ
```
**Server 3 — Join** (while init is running):
```bash
sudo spx admin join \
--node node3 --bind $SPINIFEX_NODE3 --cluster-bind $SPINIFEX_NODE3 \
--host $SPINIFEX_NODE1:4432 --token <token-from-init-output> \
--region $AWS_REGION --az $AWS_AZ
```
**Note:** The join token expires 30 minutes after init by default. For larger deployments with slower provisioning, use `--token-ttl 2h`
## Step 5. Start Services
On **all servers**:
```bash
sudo systemctl start spinifex.target
```
## Step 6. Verify
From any node:
```bash
export AWS_PROFILE=spinifex
aws ec2 describe-instance-types
```
If this returns a list of available instance types, your cluster is working.
**Congratulations! Your Spinifex cluster is installed.**
Continue to [Setting Up Your Cluster](/docs/setting-up-your-cluster) to import an AMI, create a VPC, and launch your first instance.
## Troubleshooting
### Nodes Not Joining
The init command must still be running when join executes. If init exited, re-run with `--force`.
```bash
curl -sk https://$SPINIFEX_NODE1:4432/health
```
### OVN Chassis Not Registering
```bash
sudo ovn-sbctl show
sudo ss -tlnp | grep 6642
```
### CA Certificate Not Trusted
```bash
sudo cp /etc/spinifex/ca.pem /usr/local/share/ca-certificates/spinifex-ca.crt
sudo update-ca-certificates
```
### Cross-Host VMs Cannot Communicate
```bash
sudo ovs-vsctl show | grep -i geneve
sudo ss -ulnp | grep 6081
```
@@ -0,0 +1,91 @@
---
title: "Source Install"
description: "Build Spinifex from source for development, custom builds, or contributing."
category: "Getting Started"
tags:
- install
- source
- development
resources:
- title: "Spinifex Repository"
url: "https://github.com/mulgadc/spinifex"
- title: "Go Downloads"
url: "https://go.dev/dl/"
---
# Source Installation
> Build Spinifex from source for development, custom builds, or contributing.
## Table of Contents
- [Overview](#overview)
- [Instructions](#instructions)
- [Troubleshooting](#troubleshooting)
---
## Overview
This guide builds Spinifex from source. For production deployments, the [binary installer](/docs/install) is recommended.
**Requirements:**
- Ubuntu 22.04+ / Debian 12+
- Go 1.26.2+
- GCC, make, pkg-config
- QEMU/KVM, OVN/Open vSwitch, AWS CLI v2, dhcpcd-base
## Instructions
## Step 1. Install Dependencies
```bash
mkdir -p ~/Development/mulga && cd ~/Development/mulga
git clone https://github.com/mulgadc/spinifex.git
sudo make -C spinifex quickinstall
export PATH=$PATH:/usr/local/go/bin
```
## Step 2. Clone and Build
```bash
cd spinifex
./scripts/clone-deps.sh
```
## Step 3. Development Initialisation
```bash
./scripts/dev-install.sh
```
This bootstraps a single-node development environment: builds binaries, configures OVN, initialises the cluster, installs the CA certificate, and starts all services.
## Step 4. Verify Installation
```bash
export AWS_PROFILE=spinifex
aws ec2 describe-instance-types
```
If this returns a list of available instance types, your installation is working.
**Congratulations! Spinifex is installed from source.**
Continue to [Setting Up Your Cluster](/docs/setting-up-your-cluster) to import an AMI, create a VPC, and launch your first instance.
## Troubleshooting
### Go Not Found in PATH
```bash
export PATH=$PATH:/usr/local/go/bin
```
### CA Certificate Not Trusted
```bash
sudo cp /etc/spinifex/ca.pem /usr/local/share/ca-certificates/spinifex-ca.crt
sudo update-ca-certificates
```
+135
View File
@@ -0,0 +1,135 @@
---
title: "Bootable USB Install"
description: "Install Spinifex on bare-metal hardware by flashing the Spinifex ISO to a USB drive and booting the target device from it."
category: "Getting Started"
sections:
- overview
- troubleshooting
tags:
- install
- usb
- iso
- bare-metal
resources:
- title: "Spinifex ISO"
url: "https://iso.mulgadc.com/spinifex.iso"
- title: "Balena Etcher"
url: "https://etcher.balena.io"
- title: "Spinifex Repository"
url: "https://github.com/mulgadc/spinifex"
---
# Installing Spinifex from Bootable USB
> Install Spinifex on bare-metal hardware by flashing the Spinifex ISO to a USB drive and booting the target device from it.
## Overview
Spinifex is designed for bare-metal hardware, edge nodes and data-centre use. Follow this guide to install Spinifex from a bootable USB using the Spinifex ISO.
**Note:** this tutorial is for x86 architecture.
**Warning:** this procedure COMPLETELY WIPES the target disk. For systems with multiple disks, ensure the correct one is targeted.
### Booting Media
For this tutorial, a USB drive with at least 8GB of memory is required.
**Note:** flashing the Spinifex ISO onto the USB completely wipes the USB, so ensure no important data is stored on the USB used.
### Balena Etcher Installation
For this tutorial download Balena Etcher to simplify the ISO flash process.
- [Balena Etcher](https://etcher.balena.io)
### Download Spinifex ISO
Download the Spinifex ISO (x86)
- [Spinifex ISO](https://iso.mulgadc.com/spinifex.iso)
### Flash Media
Once installed, open Balena Etcher. Select "Flash From Image," then select the downloaded `spinifex.iso` file.
Next, click "Select target" and choose the USB drive to be used as the boot media. Then click "Flash!"
Balena Etcher will now flash the USB drive with the `spinifex.iso` file.
<img src="../../../.github/assets/images/balena-complete.png" alt="Balena complete">
You can now safely eject the USB drive if it was not ejected automatically.
### Boot From USB Drive
Insert your newly flashed USB drive into the target device and turn it on.
<img src="../../../.github/assets/images/asus-box-new.png" alt="ASUS Box">
As it boots, quickly press the correct key for your device to bring up the BIOS/UEFI menu (commonly F2, F10, F12, ESC or DEL) and change the boot order such that the flashed USB drive has first priority, then continue to boot.
If done successfully, the Spinifex ISO GRUB menu will appear.
<img src="../../../.github/assets/images/grub-new.png" alt="GRUB menu">
From this menu you can select which method of install is used (console recommended). Headless mode can be configured by mounting the USB on a host device after flashing and editing the `grub.cfg` file with the desired values.
### Setting Up the Spinifex Node
In console mode, follow the installation prompts to set the required networking values for the Spinifex node.
The installer will default to the most sensible disk to install on depending on system configuration — ensure this default is correct before installation, as the process will wipe the disk.
For network configuration, it is recommended to use automatic IP (DHCP), but this can also be configured manually.
Network interfaces will be automatically detected. In the event that none are detected, the user can manually input the name of the network interface. In the event that multiple network interfaces are detected, the installer will prompt for WAN selection first, followed by LAN.
A hostname (eg `node1`) and admin password must be set, followed by the "cluster role" — whether the node is the first in a new cluster, or joining an already existing one.
Once configuration is complete, a summary of the configuration will be shown.
<img src="../../../.github/assets/images/installer-complete.png" alt="Installer complete">
The installer will then complete the installation of Spinifex onto the target device. Once complete, remove the USB drive from the device before automatic reboot.
Once the USB drive is removed, press enter or wait for the auto-reboot.
### Log In
The device will reboot and spend ~2-5 minutes configuring the Spinifex node. The user will then be prompted to log in. Use the following credentials for login:
- Login: `spinifex`
- Password: Set by user during installation
Both before and after login, a banner will be printed specifying important information, such as details for SSH into the node and accessing the web dashboard for further configuration of the node.
<img src="../../../.github/assets/images/banner1.png" alt="banner">
### Setup Complete
**Congratulations! Spinifex is installed.**
Continue to [Setting Up Your Cluster](/docs/setting-up-your-cluster) to import an AMI, create a VPC, and launch your first instance.
## Troubleshooting
### Can't Access BIOS/UEFI to Change Boot Order
It can be difficult to get into the BIOS/UEFI — there is only a short window to press the correct key, and this key changes depending on the manufacturer. Search online for your device's BIOS/UEFI key, and press it rapidly as the device boots.
### GRUB Menu Not Appearing
Once in the BIOS/UEFI menu, ensure the correct boot order is set. First boot priority should be set to the USB drive flashed with the ISO — if the USB doesn't appear in the BIOS/UEFI menu, ensure it has been flashed with the ISO correctly. Take note of the name and storage capacity of the USB when flashing, as this should match what appears in the BIOS/UEFI.
### Networking Issues
For a Spinifex node to be properly provisioned, the target device must have at least one NIC.
Spinifex uses DHCP to assign instances within a node their public IP addresses. Instead of a static range, public IPs come from the upstream router's DHCP server. When a VM launches, Spinifex requests a DHCP lease from the router on behalf of the VM. When the VM terminates, the lease is released.
The VM itself never talks to the router's DHCP — it only sees its private VPC IP (from OVN's internal DHCP). The host-side DHCP conversation is invisible to the guest.
**Use when:** You don't control a static IP block but the router's DHCP server has enough leases. Homelabs where you don't want to carve out a range. Environments where IPs are managed centrally by the network team's DHCP.
For further troubleshooting suggestions, refer to the [VPC Networking](/docs/vpc-networking) guide.
+131
View File
@@ -0,0 +1,131 @@
---
title: "Single-Node Install"
description: "Install Spinifex on a single server using the binary installer."
category: "Getting Started"
tags:
- install
- single node
- quickstart
resources:
- title: "Spinifex Repository"
url: "https://github.com/mulgadc/spinifex"
- title: "Predastore (S3)"
url: "https://github.com/mulgadc/predastore"
- title: "Viperblock (EBS)"
url: "https://github.com/mulgadc/viperblock"
---
# Installing Spinifex
> Install Spinifex on a single server using the binary installer.
## Table of Contents
- [Overview](#overview)
- [Instructions](#instructions)
- [Troubleshooting](#troubleshooting)
---
## Overview
Spinifex is an open-source infrastructure platform that brings core AWS services to bare-metal, edge, and on-prem environments.
This guide installs Spinifex on a single server using the binary installer. For multi-server clusters, see [Multi-Node Install](/docs/install-multi-node). To build from source, see [Source Install](/docs/install-source).
**Supported Operating Systems:**
- Ubuntu 22.04 / 24.04 / 25.10
- Debian 12 / 13
**What Gets Installed:**
- Spinifex daemon and CLI
- QEMU/KVM for virtual machine management
- OVN/Open vSwitch for VPC networking
- Predastore (S3-compatible object storage)
- Viperblock (EBS-compatible block storage)
- AWS CLI v2
## Instructions
## Step 1. Install Spinifex
```bash
curl -fsSL https://install.mulgadc.com | bash
```
The installer downloads the Spinifex binary and bootstraps all dependencies (QEMU, OVN/OVS, AWS CLI).
## Step 2. Setup OVN Networking
If your WAN interface is already a bridge (e.g. `br-wan`), setup-ovn.sh auto-detects it:
```bash
sudo /usr/local/share/spinifex/setup-ovn.sh --management
```
If your WAN is a physical NIC:
```bash
sudo /usr/local/share/spinifex/setup-ovn.sh --management --wan-bridge=br-wan --wan-iface=eth1
```
**Separating VPC tunnel traffic from WAN:** If you want Geneve tunnel traffic (inter-VM east-west for VPC traffic) to use a dedicated interface instead of the WAN IP, add `--encap-ip=<IP>` to specify the tunnel endpoint address:
```bash
sudo /usr/local/share/spinifex/setup-ovn.sh --management --encap-ip=10.0.0.1
```
This is recommended for production and required in multi-node deployments. See [Multi-Node Install](/docs/install-multi-node) for details.
## Step 3. Initialize
```bash
sudo spx admin init --node node1 --nodes 1
```
This auto-detects your network topology, generates configuration and TLS certificates, installs the CA into the system trust store, and configures AWS CLI credentials (saved in `~/.aws/credentials`)
## Step 4. Start Services
```bash
sudo systemctl start spinifex.target
```
## Step 5. Verify
```bash
export AWS_PROFILE=spinifex
aws ec2 describe-instance-types
```
If this returns a list of available instance types, your installation is working.
**Congratulations! Spinifex is installed.**
Continue to [Setting Up Your Cluster](/docs/setting-up-your-cluster) to import an AMI, create a VPC, and launch your first instance.
## Troubleshooting
### spx Command Not Found
```bash
export PATH=$PATH:/usr/local/bin
```
### CA Certificate Not Trusted
`sudo spx admin init` installs the CA automatically. If you need to re-install it manually:
```bash
sudo cp /etc/spinifex/ca.pem /usr/local/share/ca-certificates/spinifex-ca.crt
sudo update-ca-certificates
```
### OVN Services Not Starting
```bash
sudo systemctl is-active ovn-controller
journalctl -u ovn-controller --no-pager -n 20
```
@@ -0,0 +1,392 @@
---
title: "Setting Up Your Cluster"
description: "Import an AMI, create a VPC with a public subnet, and launch your first EC2 instance on Spinifex."
category: "Getting Started"
tags:
- setup
- vpc
- ec2
- quickstart
resources:
- title: "Spinifex Repository"
url: "https://github.com/mulgadc/spinifex"
- title: "AWS EC2 CLI Reference"
url: "https://docs.aws.amazon.com/cli/latest/reference/ec2/"
---
# Setting Up Your Cluster
> Import an AMI, create a VPC with a public subnet, and launch your first instance.
## Table of Contents
- [1. Import an AMI](#1-import-an-ami)
- [2. Create an SSH Key](#2-create-an-ssh-key)
- [3. Create a VPC and Public Subnet](#3-create-a-vpc-and-public-subnet)
- [4. Launch an Instance](#4-launch-an-instance)
- [5. Connect via SSH](#5-connect-via-ssh)
- [6. Managing Instances](#6-managing-instances)
- [7. Launching the Web UI](#7-launching-the-web-ui)
- [Troubleshooting](#troubleshooting)
---
## Overview
This guide assumes Spinifex is already installed and running. If not, follow one of the installation guides first:
- [Single-Node Install)](/docs/install)
- [Multi-Node Install](/docs/install-multi-node)
- [Source Install](/docs/install-source)
## Instructions
## Prerequisites
Ensure the AWS profile is set:
```bash
export AWS_PROFILE=spinifex
```
## 1. Import an AMI
List available images and import one matching your architecture:
```bash
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
```
Import an image:
```bash
spx admin images import --name ubuntu-24.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
```
Verify the import and note the AMI ID:
```bash
aws ec2 describe-images
```
## 2. Create an SSH Key
### Option A: Import an existing key
```bash
aws ec2 import-key-pair \
--key-name "spinifex-key" \
--public-key-material fileb://~/.ssh/id_rsa.pub
```
### Option B: Create a new key pair
```bash
aws ec2 create-key-pair --key-name spinifex-key \
| jq -r '.KeyMaterial | rtrimstr("\n")' > ~/.ssh/spinifex-key
chmod 600 ~/.ssh/spinifex-key
ssh-keygen -y -f ~/.ssh/spinifex-key > ~/.ssh/spinifex-key.pub
```
Verify:
```bash
aws ec2 describe-key-pairs
```
## 3. Create a VPC and Public Subnet
### Create a VPC
```bash
VPC_ID=$(aws ec2 create-vpc --cidr-block 10.200.0.0/16 \
--query 'Vpc.VpcId' --output text)
echo "VPC: $VPC_ID"
```
### Create an Internet Gateway
An Internet Gateway enables instances in public subnets to reach the internet and be reachable from the LAN/WAN.
```bash
IGW_ID=$(aws ec2 create-internet-gateway \
--query 'InternetGateway.InternetGatewayId' --output text)
aws ec2 attach-internet-gateway \
--internet-gateway-id $IGW_ID \
--vpc-id $VPC_ID
echo "IGW: $IGW_ID (attached to $VPC_ID)"
```
### Create a Public Subnet
A public subnet auto-assigns a routable IP to each instance, making it directly reachable from your network.
```bash
SUBNET_ID=$(aws ec2 create-subnet \
--vpc-id $VPC_ID \
--cidr-block 10.200.1.0/24 \
--query 'Subnet.SubnetId' --output text)
# Enable auto-assign public IP
aws ec2 modify-subnet-attribute \
--subnet-id $SUBNET_ID \
--map-public-ip-on-launch
echo "Subnet: $SUBNET_ID (public)"
```
### Verify
```bash
aws ec2 describe-vpcs --vpc-ids $VPC_ID
aws ec2 describe-subnets --subnet-ids $SUBNET_ID
```
## 4. Launch an Instance
Query available instance types for your hardware:
```bash
aws ec2 describe-instance-types
```
Launch an instance in the public subnet, note this will select an instance type with 2 VCPU and 1024MB of memory.
> **Note:** Replace `AMI_NAME` with the previously imported image above with the `ami-` prefix.
```bash
AMI_NAME="ami-ubuntu-24.04-x86_64"
AMI_ID=$(aws ec2 describe-images \
--filters "Name=name,Values=$AMI_NAME" \
--query 'Images | sort_by(@, &CreationDate) | [-1].ImageId' \
--output text)
INSTANCE_TYPE=$(aws ec2 describe-instance-types \
--query "sort_by(InstanceTypes[?VCpuInfo.DefaultVCpus==\`2\` && MemoryInfo.SizeInMiB>=\`1024\`], &MemoryInfo.SizeInMiB)[0].InstanceType" \
--output text)
INSTANCE_ID=$(aws ec2 run-instances \
--image-id $AMI_ID \
--instance-type $INSTANCE_TYPE \
--key-name spinifex-key \
--subnet-id $SUBNET_ID \
--count 1 \
--query 'Instances[0].InstanceId' --output text)
echo "Instance: $INSTANCE_ID"
```
Wait for the instance to reach `running` state:
```bash
aws ec2 describe-instances --instance-ids $INSTANCE_ID \
--query 'Reservations[0].Instances[0].[State.Name, PrivateIpAddress, PublicIpAddress]' \
--output text
```
Expected output:
```
running 10.200.1.4 192.168.1.155
```
The instance has both a private IP (VPC overlay) and a public IP (from your external pool, routable on your network).
## 5. Connect via SSH
SSH directly to the instance's public IP:
```bash
PUBLIC_IP=$(aws ec2 describe-instances --instance-ids $INSTANCE_ID \
--query 'Reservations[0].Instances[0].PublicIpAddress' --output text)
ssh -i ~/.ssh/spinifex-key ec2-user@$PUBLIC_IP
```
> **Note:** cloud-init takes 30-60 seconds to configure the instance. If SSH is refused, wait and retry.
Once connected, verify the instance has internet access:
```bash
curl -s http://ifconfig.me
```
This should return the instance's public IP or the gateway's SNAT address.
## 6. Managing Instances
### Stop
```bash
aws ec2 stop-instances --instance-ids $INSTANCE_ID
```
### Start
```bash
aws ec2 start-instances --instance-ids $INSTANCE_ID
```
### Terminate
```bash
aws ec2 terminate-instances --instance-ids $INSTANCE_ID
```
### Console Output
View the instance's serial console log (useful for debugging boot issues):
```bash
aws ec2 get-console-output --instance-id $INSTANCE_ID \
--query 'Output' --output text
```
### Multi-Node: Check Instance Placement
On a multi-node cluster, instances are distributed across nodes:
```bash
spx get vms
```
## 7. Launching the Web UI
Spinifex ships with a built-in web console — an alternative to the AWS CLI, SDKs, and Terraform, analogous to the AWS Management Console. Every action in the UI is a standard AWS SigV4 API call, so the same IAM policies and audit behaviour apply.
<p align="center">
<img src="../../../.github/assets/spinifex-ui.jpg" alt="Spinifex Web UI" width="900">
</p>
### Open the Console
The UI is served by each node on port `3000` over TLS. Replace `YOUR_NODE_IP` with the address of the node you installed Spinifex on (or `localhost` if you're on the node itself):
```
https://YOUR_NODE_IP:3000
```
### Trust the Self-Signed Certificate (required)
On first load, your browser will show a TLS warning — Spinifex generates a self-signed certificate at install time. This is expected.
1. Accept the warning to reach the login page (exact wording varies by browser — e.g. Chrome: *Advanced → Proceed to ...*, Firefox: *Advanced → Accept the Risk and Continue*).
2. On the login page, click **Download Certificate** and save `spinifex-ca.pem` to your machine.
3. Install the certificate as a **trusted root** on your workstation:
- **macOS:** open `spinifex-ca.pem` in Keychain Access → *System* keychain → set *Trust* to **Always Trust**.
- **Linux:** `sudo cp spinifex-ca.pem /usr/local/share/ca-certificates/spinifex-ca.crt && sudo update-ca-certificates`
- **Windows:** double-click the file → *Install Certificate* → *Local Machine* → *Trusted Root Certification Authorities*.
- **Browser-only (Firefox):** *Settings → Privacy & Security → Certificates → View Certificates → Authorities → Import* and tick *Trust this CA to identify websites*.
4. Restart your browser and reload `https://YOUR_NODE_IP:3000`. The padlock should now show a valid certificate.
> **Why this is required:** the UI logs in by reading your AWS credentials through a trusted TLS channel. Browsers refuse to send credentials over an untrusted connection, so the certificate must be installed as trusted — temporary "Proceed anyway" exceptions won't work for login.
### Log In with AWS Credentials
The console authenticates against the AWS credentials in `~/.aws/credentials` on the node where Spinifex is installed:
```ini
[spinifex]
aws_access_key_id = AKIA...
aws_secret_access_key = ...
```
At the Spinifex login screen, paste the **Access Key ID** and **Secret Access Key** from the `[spinifex]` profile (or whichever profile maps to the IAM user or role you want to use). Additional users and policies can be managed through the UI or via `aws iam` commands — see [IAM Users and Policies](/docs/iam-users-and-policies).
Once logged in, you have browser-based access to every Spinifex feature: launch and manage instances, attach EBS volumes, browse S3 buckets, configure VPCs and security groups, and manage IAM users and keys — all backed by the same AWS-compatible control plane the CLI uses.
## Additional Options
### Private Subnets (No Public IP)
Create a subnet without `--map-public-ip-on-launch`:
```bash
PRIVATE_SUBNET=$(aws ec2 create-subnet \
--vpc-id $VPC_ID \
--cidr-block 10.200.2.0/24 \
--query 'Subnet.SubnetId' --output text)
```
Instances in private subnets get a private IP only. They can still reach the internet via SNAT through the VPC router, but are not directly reachable from your network.
### Multiple Accounts
Create isolated accounts with their own resources:
```bash
spx admin account create --name myteam
export AWS_PROFILE=spinifex-myteam
```
## Troubleshooting
### Instance Stuck in Pending
```bash
journalctl -u spinifex-daemon -f
aws ec2 describe-images
```
### SSH Connection Refused
cloud-init needs 30-60 seconds after boot. Check instance state:
```bash
aws ec2 describe-instances --instance-ids $INSTANCE_ID
```
### No Public IP Assigned
Verify the subnet has `MapPublicIpOnLaunch` enabled:
```bash
aws ec2 describe-subnets --subnet-ids $SUBNET_ID
```
If `MapPublicIpOnLaunch` is false:
```bash
aws ec2 modify-subnet-attribute --subnet-id $SUBNET_ID --map-public-ip-on-launch
```
Also verify an Internet Gateway is attached to the VPC:
```bash
aws ec2 describe-internet-gateways
```
### Instance Has No Internet Access
Check the VPC router's NAT rules (from the host):
```bash
sudo ovn-nbctl lr-nat-list $(sudo ovn-nbctl lr-list | awk '{print $2}' | head -1)
```
Verify the default route exists:
```bash
sudo ovn-nbctl lr-route-list $(sudo ovn-nbctl lr-list | awk '{print $2}' | head -1)
```
+124
View File
@@ -0,0 +1,124 @@
---
title: "Updating Spinifex"
description: "Upgrade an existing Spinifex installation to the latest release."
category: "Administration"
tags:
- update
- upgrade
- migrate
resources:
- title: "Spinifex Repository"
url: "https://github.com/mulgadc/spinifex"
- title: "Single-Node Install"
url: "/docs/install"
---
# Updating Spinifex
> Upgrade an existing Spinifex installation to the latest release.
## Table of Contents
- [Overview](#overview)
- [Instructions](#instructions)
- [Manual Upgrade](#manual-upgrade)
- [Troubleshooting](#troubleshooting)
---
## Overview
Updating Spinifex is the same command used to install it. The installer detects an existing installation, downloads the latest binary and runs any pending configuration migrations before restarting services.
For operators who want to review migrations before they are applied, a manual upgrade path is also supported.
## Instructions
## Step 1. Re-run the Installer
```bash
curl -fsSL https://install.mulgadc.com | bash
```
That's it. The installer will:
1. Download and install the latest Spinifex binary.
2. Reinstall systemd units so new services are picked up.
3. Run any pending configuration migrations automatically (equivalent to `spx admin upgrade --yes`).
4. Restart `spinifex.target` if the services were already running.
## Step 2. Verify
```bash
export AWS_PROFILE=spinifex
aws ec2 describe-instance-types
```
If this returns a list of instance types, your upgrade is complete.
## Manual Upgrade
If you prefer to review pending migrations before they are applied, Spinifex supports running `spx admin init` to allow you to verify config file migrations.
## Step 1. Install the New Binary Without Running Migrations
```bash
curl -fsSL https://install.mulgadc.com | INSTALL_SPINIFEX_SKIP_MIGRATE=1 bash
```
The installer will download the new binary and reinstall systemd units, but will **not** apply any configuration migrations.
## Step 2. Review Pending Migrations
```bash
sudo spx admin upgrade
```
The command prints the current version of each config file, the migrations that would be applied, and a `from → to` description for each. It then prompts for confirmation before making any changes. Answer `n` to abort without touching your config.
## Step 3. Apply Migrations
When you are ready, answer `y` at the prompt, or re-run with `--yes` to apply non-interactively:
```bash
sudo spx admin upgrade --yes
```
## Step 4. Restart Services
Migrations modify config files on disk but do not restart running services. Apply the new config with:
```bash
sudo systemctl restart spinifex.target
```
## Troubleshooting
### No Pending Config Migrations
```
No pending config migrations.
```
Your config is already at the latest version. Nothing to do.
### No Spinifex Installation Found
```
No Spinifex installation found at /etc/spinifex
Run 'spx admin init' first.
```
`spx admin upgrade` requires an initialized installation. If this is a fresh host, follow the [Single-Node Install](/docs/install) guide instead.
### Migration Failure
If a migration fails, the installer and `spx admin upgrade` exit non-zero and leave the config in its prior state where possible. Review the error output, then re-run `sudo spx admin upgrade` once the underlying issue is resolved.
### Services Did Not Pick Up New Config
Migrations edit config files on disk but the running daemons continue to use the config they loaded at start-up. Restart with:
```bash
sudo systemctl restart spinifex.target
```
+310
View File
@@ -0,0 +1,310 @@
# Installing Spinifex in a VM (MacOS)
For evaluation / quick POC use only.
Spinifex is designed for bare-metal hardware, edge nodes and data-centre use. However, follow this guide to install Spinifex on a MacOS VM to demonstrate the capabilities (dev/testing only).
Note this tutorial is for Apple Silicon (ARM64)
## UTM installation
For this tutorial download UTM.app on Mac to simplify the VM installation.
* UTM - [https://mac.getutm.app](https://mac.getutm.app)
## Download Debian ISO
Next, download the Debian ISO (ARM64, Apple Silicon)
* Debian 13.4 - [https://cdimage.debian.org/debian-cd/current/arm64/iso-cd/debian-13.4.0-arm64-netinst.iso](https://cdimage.debian.org/debian-cd/current/arm64/iso-cd/debian-13.4.0-arm64-netinst.iso)
## Login
Using the VM console, login using the `spinifex` user and your specified password. A few additional steps are required via the console, before you can SSH into the machine for easier administration.
## Install prerequisites
For a minimal Debian install additional dependencies are required prior to the Spinifex installation:
```
su root
apt update
apt install sudo curl bridge-utils
```
### Enable sudoers
Enable sudo for `spinifex` user as the root user:
```
su root
echo "spinifex ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/spinifex
```
## Networking
Spinifex requires a Linux bridge for networking to correctly function, given EC2 instances and resources can obtain an IP address from the upstream DHCP server or a static public IP address range you specify.
This is a requirement step for valid installation.
View the existing `/etc/network/interfaces` for the network configuration.
```sh
# This file describes the network interfaces available on your system
# and how to activate them. For more information, see interfaces(5).
source /etc/network/interfaces.d/*
# The loopback network interface
auto lo
iface lo inet loopback
# The primary network interface
allow-hotplug enp0s1
iface enp0s1 inet dhcp
# This is an autoconfigured IPv6 interface
iface enp0s1 inet6 auto
```
Edit `/etc/network/interfaces` note replace `enp0s1` with your network adapter.
```sh
# This file describes the network interfaces available on your system
# and how to activate them. For more information, see interfaces(5).
source /etc/network/interfaces.d/*
# The loopback network interface
auto lo
iface lo inet loopback
# The primary network interface - toggle to manual
allow-hotplug enp0s1
iface enp0s1 inet manual
# This is an autoconfigured IPv6 interface - toggle to manual
iface enp0s1 inet6 manual
# Spinifex Config
# WAN bridge (Spinifex EC2 instances connect here)
# VMs (EC2) / containers <-> br-wan <-> enp0s1 <-> external network
auto br-wan
iface br-wan inet dhcp
bridge_ports enp0s1
bridge_stp off
bridge_fd 0
bridge_maxwait 0
```
Walkthrough of changes:
* Adds eno3 into the bridge
* eno3 becomes a slave interface
* All traffic goes through br-wan
* Spinifex EC2 (VMs) in a public subnet, obtain an IP address from the WAN upstream
### Restart networking
Make sure you are logged into the console, not via SSH for these steps. Otherwise you will lose networking to the instance.
Replace `enp0s1` with your network adapter.
```bash
ifdown enp0s1
systemctl restart networking
```
Next, retrieve the IP address of the new `br-wan` bridge from your upstream DHCP server.
```bash
ip a show dev enp0s1
```
```bash
6: br-wan: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default qlen 1000
link/ether 12:c2:bc:f3:f6:7b brd ff:ff:ff:ff:ff:ff
inet 192.168.0.249/24 brd 192.168.0.255 scope global dynamic noprefixroute br-wan
valid_lft 86103sec preferred_lft 75303sec
inet6 fd0d:9d60:d09b:0:ee77:630d:3237:9a10/64 scope global dynamic mngtmpaddr noprefixroute
valid_lft 86282sec preferred_lft 86282sec
inet6 fe80::85c0:f2aa:f0bf:e951/64 scope link
valid_lft forever preferred_lft forever
```
Note the IP `192.168.0.249`
# Spinifex Installation
## Launch installer
Next, optionally SSH into your instance (using the IP above) or continue using the console.
Replace with your IP below.
```
ssh spinifex@<your-ip>
```
As the `spinifex` user launch the binary installer:
```bash
su spinifex
curl -fsSL https://install.mulgadc.com | bash
```
```bash
[INFO] Spinifex installer
[INFO] Detected OS: Debian GNU/Linux 13 (trixie)
[INFO] Detected architecture: aarch64 (arm64)
[INFO] Installing system dependencies...
[INFO] System dependencies installed
[INFO] Installing AWS CLI v2...
[INFO] AWS CLI installed: aws-cli/2.34.30 Python/3.14.4 Linux/6.12.74+deb13+1-arm64 exe/aarch64.debian.13
[INFO] Service users created (spinifex-{nats,gw,daemon,storage,viperblock,vpcd,ui})
/etc/sudoers.d/spinifex-network: parsed OK
[INFO] Scoped sudoers rules installed for spinifex-daemon and spinifex-vpcd
[INFO] Downloading Spinifex (latest channel, arm64)...
[INFO] Verifying checksum...
[INFO] Checksum verified
[INFO] Extracting...
[INFO] Installing files...
[INFO] /usr/local/bin/spx
[INFO] /usr/lib/aarch64-linux-gnu/nbdkit/plugins/nbdkit-viperblock-plugin.so
[INFO] /usr/local/share/spinifex/setup-ovn.sh
[INFO] Creating directories...
[INFO] Generated /etc/spinifex/systemd.env
[INFO] /var/lib/spinifex/predastore-start.sh
[INFO] /var/lib/spinifex/wait-for-nats.sh
[INFO] Fixing file ownership for privilege separation...
[INFO] File ownership updated
[INFO] Installing systemd units...
[INFO] /etc/systemd/system/spinifex-awsgw.service
[INFO] /etc/systemd/system/spinifex-daemon.service
[INFO] /etc/systemd/system/spinifex-nats.service
[INFO] /etc/systemd/system/spinifex-predastore.service
[INFO] /etc/systemd/system/spinifex.target
[INFO] /etc/systemd/system/spinifex-ui.service
[INFO] /etc/systemd/system/spinifex-viperblock.service
[INFO] /etc/systemd/system/spinifex-vpcd.service
Created symlink '/etc/systemd/system/multi-user.target.wants/spinifex.target' → '/etc/systemd/system/spinifex.target'.
[INFO] Systemd units installed and enabled (per-service users)
[INFO] Fresh install detected, skipping migrations
[INFO] Logrotate config installed
============================================
Spinifex installed successfully
============================================
Version: spinifex v1.1.0 (b7b6390) linux/arm64
Architecture: arm64
Service users: spinifex-{nats,gw,daemon,storage,viperblock,vpcd,ui}
Binary: /usr/local/bin/spx
Config: /etc/spinifex/
Data: /var/lib/spinifex/
Logs: /var/log/spinifex/
```
## Setup OVN networking
Since the WAN interface is already a bridge (e.g. br-wan) run the Openvswitch (OVN) install script:
```bash
sudo /usr/local/share/spinifex/setup-ovn.sh --management
```
Output:
```bash
Auto-detected Linux bridge: br-wan (default route)
Will create OVS bridge br-ext + veth pair to link them
Auto-detected encap IP: 192.168.0.249
=== Spinifex OVN Compute Node Setup ===
Management node: true
WAN bridge: br-ext (veth)
Linux bridge: br-wan (linked via veth pair)
OVN Remote (SB): tcp:127.0.0.1:6642
Encap IP: 192.168.0.249
...
system-id: "add0104e-9aec-43f0-af53-5fc89b4d91bf"
...
```
The `system-id` is the OVS-managed chassis UUID (read back from
`/etc/openvswitch/system-id.conf`); this is the same value that appears as
`Chassis.name` in `ovn-sbctl show`. Cross-reference with `Chassis.hostname`
when you need the human-readable host:
```bash
sudo ovn-sbctl --columns=name,hostname list Chassis
```
## Spinifex Initialize
```bash
sudo spx admin init --node node1 --nodes 1
```
## Start services
```bash
sudo systemctl start spinifex.target
```
## Verify installation
Connect to the local AWS gateway and issue a sample command using the AWS CLI tool, all running on your local infrastructure, no cloud required.
```bash
export AWS_PROFILE=spinifex
aws ec2 describe-instance-types
```
> ***Note:*** The system may require ~30 seconds to initalise prior to accepting connections for the command to complete above.
Output:
```json
{
"InstanceTypes": [
{
"InstanceType": "t4g.nano",
"CurrentGeneration": true,
"SupportedRootDeviceTypes": [
"ebs"
],
"SupportedVirtualizationTypes": [
"hvm"
],
"Hypervisor": "kvm",
"ProcessorInfo": {
"SupportedArchitectures": [
"arm64"
]
},
"VCpuInfo": {
"DefaultVCpus": 2
},
"MemoryInfo": {
"SizeInMiB": 512
},
"PlacementGroupInfo": {
"SupportedStrategies": [
"cluster",
"spread"
]
},
"BurstablePerformanceSupported": true
},
]
}
```
# Installation Complete
Congratulations! 🥳
Your installation of Spinifex is now complete on a VM running on a Mac.
# Next steps
@@ -1,132 +0,0 @@
---
title: "Air-Gapped Installation"
description: "Deploy Spinifex in environments without internet connectivity. Covers offline package caching, USB deployment, and package verification."
category: "Getting Started"
tags:
- install
- air-gapped
- offline
badge: advanced
resources:
- title: "Spinifex Repository"
url: "https://github.com/mulgadc/spinifex"
- title: "apt-cacher-ng"
url: "https://wiki.debian.org/AptCacherNg"
---
# Air-Gapped Installation
> Deploy Spinifex in environments without internet connectivity.
## Table of Contents
- [Overview](#overview)
- [Instructions](#instructions)
- [Troubleshooting](#troubleshooting)
---
## Overview
In air-gapped or disconnected environments, Spinifex can be deployed without internet access. This guide covers preparing offline packages on a connected machine, creating USB deployment media, and installing on the target server with package verification.
**Deployment methods:**
- **apt-cacher-ng** — Local APT cache for Debian/Ubuntu packages
- **Go module cache** — Pre-downloaded Go dependencies
- **USB deployment** — Portable installation media with all dependencies
- **GPG verification** — Cryptographic package integrity checks
## Instructions
## Step 1. Prepare packages (on a connected machine)
Set up a local APT cache to collect all required packages:
```bash
sudo apt install apt-cacher-ng
sudo systemctl enable --now apt-cacher-ng
```
Pre-download all Spinifex dependencies:
```bash
sudo apt install --download-only nbdkit nbdkit-plugin-dev pkg-config \
qemu-system qemu-utils qemu-kvm libvirt-daemon-system \
libvirt-clients libvirt-dev make gcc unzip xz-utils file \
ovn-central ovn-host openvswitch-switch
```
## Step 2. Create USB deployment media
```bash
mkdir -p /media/spinifex-deploy/{apt-packages,go-cache,spinifex-source}
cp /var/cache/apt/archives/*.deb /media/spinifex-deploy/apt-packages/
cp -r ~/go/pkg/mod/cache/ /media/spinifex-deploy/go-cache/
cp -r ~/Development/mulga/spinifex /media/spinifex-deploy/spinifex-source/
```
## Step 3. Verify package integrity
Sign and verify packages before transferring to the target:
```bash
gpg --import /media/spinifex-deploy/mulga-signing-key.asc
gpg --verify spinifex-v1.0.tar.gz.sig spinifex-v1.0.tar.gz
sha256sum -c /media/spinifex-deploy/checksums.sha256
```
## Step 4. Install on the target server
Mount the USB and install:
```bash
sudo mount /dev/sdb1 /mnt/usb
sudo dpkg -i /mnt/usb/apt-packages/*.deb
cp -r /mnt/usb/spinifex-source ~/Development/mulga/spinifex
cd ~/Development/mulga/spinifex && make build
```
Then follow the [Source Install](/docs/source-install) guide from Step 3 (Setup OVN) onwards.
## Troubleshooting
## Missing dependencies after dpkg install
Some packages may have unresolved dependencies. Fix with:
```bash
sudo dpkg -i /mnt/usb/apt-packages/*.deb
sudo apt-get install -f
```
The `-f` flag tells apt to fix broken dependencies using what's available locally.
## GPG verification fails
The signing key may not match or the download was corrupted. Re-import the key and verify:
```bash
gpg --import /media/spinifex-deploy/mulga-signing-key.asc
gpg --verify spinifex-v1.0.tar.gz.sig spinifex-v1.0.tar.gz
```
If verification still fails, re-download the package on the connected machine and compare checksums:
```bash
sha256sum spinifex-v1.0.tar.gz
```
## Go module cache errors
The Go module cache may have been incompletely copied. On the connected machine, re-export the full cache:
```bash
cp -r ~/go/pkg/mod/cache/ /media/spinifex-deploy/go-cache/
```
On the target, restore it:
```bash
mkdir -p ~/go/pkg/mod/
cp -r /mnt/usb/go-cache ~/go/pkg/mod/cache
```
@@ -1,288 +0,0 @@
---
title: "Multi-Node Installation"
description: "Deploy Spinifex across multiple servers to create an availability zone with high availability, data durability, and fault tolerance."
category: "Getting Started"
tags:
- install
- multi node
- cluster
badge: availability-zone
resources:
- title: "Spinifex Repository"
url: "https://github.com/mulgadc/spinifex"
- title: "Predastore (S3)"
url: "https://github.com/mulgadc/predastore"
- title: "Viperblock (EBS)"
url: "https://github.com/mulgadc/viperblock"
---
# Multi-Node Installation
> Deploy Spinifex across multiple servers to create an availability zone.
## Table of Contents
- [Overview](#overview)
- [Instructions](#instructions)
- [Troubleshooting](#troubleshooting)
---
## Overview
A Spinifex cluster operates as a fully distributed infrastructure region — similar to an AWS Region — where multiple nodes provide high availability, data durability, and fault tolerance.
**Services distributed across nodes:**
- **NATS** — Clustered message bus for request routing and JetStream replication
- **Predastore** — Raft consensus with Reed-Solomon erasure coding (RS 2+1)
- **Viperblock** — Block storage co-located with compute nodes
- **Spinifex Daemon** — Independent AWS API request serving per node
Cluster formation is automatic. When initializing with `--nodes 3`, the init node starts a formation server and waits for peers to join. Once all nodes register, each receives the full cluster topology — credentials, CA certificates, NATS routes, Predastore peer lists — no manual configuration synchronization required.
**Network requirements:**
- Minimum 1 NIC per server
- 2 NICs recommended for production (management + overlay)
- UDP port 6081 open between hosts (Geneve tunnels)
## Instructions
## Step 1. Install Spinifex on each server
Run the binary installer on **every server** in the cluster:
```bash
curl https://install.mulgadc.com | bash
```
## Step 2. Set node IP variables
On **each server**, export the IPs for all nodes (replace with your actual IPs):
```bash
export SPINIFEX_NODE1=192.168.1.10
export SPINIFEX_NODE2=192.168.1.11
export SPINIFEX_NODE3=192.168.1.12
```
To find your server's IP:
```bash
ip addr show | grep "inet "
```
Next, define the AWS region and AZ you are deploying locally.
```bash
export AWS_REGION=us-east-1
export AWS_AZ=us-east-1a
```
## Step 3. Setup OVN networking
OVN must be configured on every server before forming the cluster. Server 1 runs OVN central (NB and SB databases) and must be set up first.
**Server 1 — OVN central + compute (run first):**
```bash
sudo /usr/local/share/spinifex/setup-ovn.sh --management --encap-ip=$SPINIFEX_NODE1
```
Verify OVN central is ready before proceeding:
```bash
sudo ovn-sbctl show
```
**Server 2 — Compute node (after server 1 is ready):**
```bash
sudo /usr/local/share/spinifex/setup-ovn.sh --ovn-remote=tcp:$SPINIFEX_NODE1:6642 --encap-ip=$SPINIFEX_NODE2
```
**Server 3 — Compute node (after server 1 is ready):**
```bash
sudo /usr/local/share/spinifex/setup-ovn.sh --ovn-remote=tcp:$SPINIFEX_NODE1:6642 --encap-ip=$SPINIFEX_NODE3
```
Verify all chassis have registered (from server 1):
```bash
sudo ovn-sbctl show
```
You should see 3 chassis entries, one per server, each with a Geneve encap IP.
## Step 4. Form the cluster
The formation process requires running init and join commands concurrently. The init node blocks until all expected nodes have joined.
**Server 1 — Initialize the cluster:**
```bash
sudo spx admin init \
--node node1 \
--nodes 3 \
--bind $SPINIFEX_NODE1 \
--cluster-bind $SPINIFEX_NODE1 \
--port 4432 \
--region $AWS_REGION \
--az $AWS_AZ
```
**Server 2 — Join the cluster (while init is running):**
```bash
spx admin join \
--node node2 \
--bind $SPINIFEX_NODE2 \
--cluster-bind $SPINIFEX_NODE2 \
--host $SPINIFEX_NODE1:4432 \
--region $AWS_REGION \
--az $AWS_AZ
```
**Server 3 — Join the cluster (while init is running):**
```bash
spx admin join \
--node node3 \
--bind $SPINIFEX_NODE3 \
--cluster-bind $SPINIFEX_NODE3 \
--host $SPINIFEX_NODE1:4432 \
--region $AWS_REGION \
--az $AWS_AZ
```
All three processes will exit with a cluster summary once formation is complete.
## Step 5. Start services and verify
Start services on **all servers**:
```bash
sudo systemctl start spinifex.target
```
From any node, verify the cluster:
```bash
spx get nodes
```
```
NAME STATUS IP REGION AZ UPTIME VMs
node1 Ready 192.168.1.10 us-east-1 us-east-1a 2m 0
node2 Ready 192.168.1.11 us-east-1 us-east-1a 2m 0
node3 Ready 192.168.1.12 us-east-1 us-east-1a 2m 0
```
## Step 6. Trust the CA certificate
On **each server**, trust the CA generated during init:
```bash
sudo cp ~/spinifex/config/ca.pem /usr/local/share/ca-certificates/spinifex-ca.crt
sudo update-ca-certificates
export AWS_PROFILE=spinifex
```
## Step 7. Launch instances across the cluster
Connect to any node's AWS Gateway — all nodes serve the same cluster state:
```bash
aws ec2 run-instances \
--image-id $SPINIFEX_AMI \
--instance-type t3.small \
--key-name spinifex-key \
--subnet-id $SPINIFEX_SUBNET \
--count 3
```
Spinifex distributes instances across nodes based on available capacity. Check which node each instance landed on:
```bash
spx get vms
```
## Shutdown
A graceful shutdown coordinates all nodes — drains VMs, flushes storage, and stops services:
```bash
spx admin cluster shutdown
```
## Troubleshooting
## Nodes not joining the cluster
The init command must still be running when you execute join on other servers. If init has already exited, re-run it.
Check that all servers can reach the init node on the formation port:
```bash
curl -s http://$SPINIFEX_NODE1:4432/health
```
## OVN chassis not registering
Compute nodes need to reach OVN central on server 1. Verify from server 1:
```bash
sudo ovn-sbctl show
```
If chassis are missing, check firewall rules for port 6642:
```bash
sudo ss -tlnp | grep 6642
```
Re-run the OVN setup on the affected compute node.
## Cross-host VMs cannot communicate
This indicates a Geneve tunnel issue. Verify tunnels are established:
```bash
sudo ovs-vsctl show | grep -i geneve
```
Ensure UDP port 6081 is open between all hosts:
```bash
sudo ss -ulnp | grep 6081
```
If tunnels are missing, verify `--encap-ip` was set correctly during OVN setup.
## Health check fails on a node
Check if the daemon is running and listening on the correct IP:
```bash
curl -s http://$SPINIFEX_NODE1:4432/health
```
Review the daemon logs for errors:
```bash
cat ~/spinifex/logs/spinifex.log
```
Verify `--bind` was set to the correct IP during cluster formation.
## NATS cluster routing issues
Check NATS logs for connection errors between nodes:
```bash
grep -i "route\|cluster" ~/spinifex/logs/nats.log
```
Ensure `--cluster-bind` IPs are reachable between all servers.

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