Compare commits

..

44 Commits

Author SHA1 Message Date
Julian Sommer 0ee9b46e38 chore: rebuild ui 2026-06-05 14:12:47 +10:00
Julian Sommer 92d76b5bce fix(ui): auto-recover from stale credentials with login redirect (#297)
(cherry picked from commit 2d21232d02)
2026-06-05 14:10:19 +10:00
Josh Siviour cf8597a18e feat(ui): VPC-create AWS-flow alignment — SG launch step, public-IP, NAT Gateway + Elastic IP (#294)
(cherry picked from commit dc1b87f191)
2026-06-05 14:10:11 +10:00
Julian Sommer 044d35737e feat: remove bash e2e for e2e-go 2026-06-03 10:32:30 +10:00
Julian Sommer 2dada2cf9d release: update go.mod for v1.6.0 2026-06-03 09:15:38 +10:00
Julian Sommer 9508aaacce chore: bump go 1.26.4 2026-06-03 08:59:25 +10:00
joshsiv-mulga 40deb1e4c2 fix(e2e): add explicit IGW default route in run-lb-e2e
After PR #270 the VPC-LR-wide 0.0.0.0/0 -> IGW static route is no longer
installed implicitly at attach-internet-gateway. Subnets whose effective
route table lacks an internet-bound route get an OVN drop policy at
SubnetEgressPriorityDrop (1100), which kills LB target reply traffic
before un-DNAT can fire and stalls the LB internal-client probe until
job timeout.

Add the standard AWS pattern: describe-route-tables -> create-route
0.0.0.0/0 -> IGW. This fires vpc.add-igw-route + vpc.ungate-subnet-egress,
removing the drop policy and installing a reroute policy at
SubnetEgressPriorityIGW (1000).
2026-06-02 23:49:23 +10:00
joshsiv-mulga 7cd054cbf1 chore(e2e): dump Logical_Router_Policy + Logical_Router_Static_Route in W5 diag 2026-06-02 22:15:22 +10:00
joshsiv-mulga 41f840ee85 fix(setup): grant spinifex-vpcd sudo for ovn-appctl (siv-167)
The W2 inject-garp fix landed in 72a6a59d adds a shellout to
`ovn-appctl -t ovn-controller inject-garp <port>` via the L0
execRunner, which prefixes `sudo` when not running as root.
spinifex-vpcd runs as user spinifex-vpcd, so every emission was
hitting `sudo: a password is required` and silently returning a
WARN that nobody read until W5 diag capture surfaced the journal.

Net: W2 has been a complete no-op since it shipped. Every
recycled-EIP scenario has continued to leave stale upstream ARP.

Evidence: diag bundle from run 26813235295 — 12/12 GARP attempts
denied by sudo, 0 successful emissions; pcap shows runner ARP for
recycled EIP .232 stuck on the prior VPC's gw chassisredirect MAC
(02:75:d9:ef:d8:4b) while OVN NB has handed .232 to a new VPC
with a different gw MAC; pcap also shows runner sending 174 SYNs
to recycled EIP .233 with 0 SYN+ACKs received.

Fix: add /usr/bin/ovn-appctl to the spinifex-vpcd NOPASSWD grant.
Trivial; ovn-appctl is already trusted via the existing NB/SB
read-write grants.

Follow-ups (separate change): tighten host.InjectGARP error
classification so sudo-deny is loud (Error or fatal) rather than
silently warn-and-continue; add a boot-time self-check that
verifies sudo permissions for every host-tool the daemon will
shell out to.
2026-06-02 21:00:20 +10:00
joshsiv-mulga ba7253e2c6 ci(e2e): fix Start Packet Capture EACCES + PID-tracking on tcpdump (siv-167)
Two bugs in afd63ed9:

1. `sudo mkdir` makes /tmp/spinifex-diag root-owned 0755. The
   subsequent `> /tmp/spinifex-diag/tcpdump.log` is performed by
   the calling tf-user shell BEFORE sudo gets the command, failing
   with Permission denied. action shell runs with `set -e` so the
   step aborts.

2. `sudo nohup ... &; echo $!` captures the wrong PID — `$!` is the
   backgrounded sudo, which may exit when its target dies, leaving
   stale tcpdump.pid.

Fixes:
- chmod 0777 on /tmp/spinifex-diag after creating it (next steps'
  redirects from tf-user shell now work).
- Wrap the tcpdump launch in `sudo bash -c '...'` so backgrounding
  and `$!` happen inside the sudo'd shell — `$!` is the actual
  tcpdump PID (nohup execs, sudo bash holds it). Captures stdin
  to /dev/null, stdout/stderr to /tmp/spinifex-diag/tcpdump.log.
- sleep 2 (was 1) — gives tcpdump time to fully init pcap header.
- Hard fail (exit 1) if PID is missing or process dead, so the
  step's failure is visible instead of a silent green.

bash -n verified.
2026-06-02 19:45:53 +10:00
joshsiv-mulga afd63ed967 ci(e2e): Phase 1 diag — tcpdump br-wan + bridge fdb + hairpin modes (siv-167)
Mulga-siv-167 Phase 1. Adds evidence-capture for the LB
single-host return-path drop class:

e2e_single only:
- New step 'Start Packet Capture' between Bootstrap and Run E2E
  Tests. SSHes to test VM and starts background tcpdump on br-wan
  capturing TCP/22/80/443/9999 + ARP into /tmp/spinifex-diag/br-wan.pcap.
  PID stored at /tmp/spinifex-diag/tcpdump.pid.

All three jobs (single, pseudo-multi, real-multi):
- Collect Diagnostics: kill tcpdump (single only — PID file
  guarded) -INT for graceful flush, falls through to -KILL after 5s.
- Adds journal-spinifex-full.log (no -n truncation) alongside
  the existing -n 10000 capped one.
- Adds bridge fdb show, bridge link, and hairpin_mode/learning/flood
  for every br-wan brif — needed to confirm/disconfirm the
  hairpin-loss hypothesis in the plan doc.
- Adds 'chmod -R a+r /tmp/spinifex-diag' before tar to ensure pcap
  (created by sudo tcpdump as root) is readable by tf-user.

bash -n verified on all four rendered scripts. YAML parses.
Zero new gold-image deps (tcpdump, bridge present in base image).
2026-06-02 19:28:54 +10:00
joshsiv-mulga 2574956f08 ansible cluster-bootstrap 2026-06-02 15:38:56 +10:00
joshsiv-mulga 3f4613c28b ci(e2e): capture OVN/OVS/journal diagnostics on every run
Adds a Collect Diagnostics + Upload Diagnostics step pair to all three
E2E jobs (single, pseudo-multi, real-multi). Runs before Teardown,
if: always() so green runs produce baselines too. SSHes to test
VM(s), dumps:

- journalctl for spinifex-*, ovn-*, openvswitch-*
- ovs-vsctl/ofctl/appctl: bridge state, br-int + br-ex flows,
  dpctl flow + conntrack tables
- ovn-nbctl/sbctl: show, Port_Binding, Chassis, NAT, Logical_Router
- ovn-appctl ct-zone-list (LR -> ct_zone mapping)
- ip addr/route/neigh, iptables-save, nft list, dmesg, virsh list

Tarred, scp'd back, uploaded as e2e-diag-{single,pseudo,multi}-<run>
artifact (14-day retention).

OVS-native ovs-appctl dpctl/dump-conntrack — no new gold-image deps.
Multi-node job writes script to RUNNER_TEMP then ssh-stdin's it
per-node (avoids YAML/heredoc indent traps inside for-loop). bash -n
verified on all three rendered scripts.

Unblocks siv-111 debug loop: bash LB driver does not emit an
analysis bundle on failure, so post-W2 recurrences have been blind.
Workflow-level capture is hypothesis-free.
2026-06-02 14:56:41 +10:00
joshsiv-mulga 72a6a59d80 fix(network): inject GARP after AddEIP to refresh upstream ARP on EIP recycle
ovn-controller emits gratuitous ARPs automatically when a NAT-bound LSP
migrates chassis, but not when an external IP is reassigned to a different
LR whose chassisredirect MAC differs from the prior occupant's. In CI
single-host runs the WAN pool slice is small enough that the same EIP
recycles across VPCs within the 60-300s kernel ARP timeout, so upstream
caches keep pointing at the dead chassis-redirect MAC and packets to the
new VM never arrive.

Fix: add a post-AddEIP hook that runs `ovn-appctl -t ovn-controller
inject-garp <port>` (OVN 22.09+). Port is the EIP's LSP in distributed
NAT or "cr-gw-<vpcID>" in centralized NAT. Best-effort — failure logs a
warning and the NAT install still succeeds.

- network/host/garp.go: new L0 shell-out helper InjectGARP.
- network/policy/nat.go: GARPEmitter option type, called after the
  flows-ready barrier in AddEIP.
- vpcd: wire the production emitter that derives the right OVN port
  from the EIPSpec.
- invariants: add ovn-appctl to the host-tool ban list so future
  callers are forced into network/host/.
2026-06-02 13:38:52 +10:00
Josh Siviour dd65a2dfa6 feat(elbv2): ListenerRules CRUD with HAProxy ACL routing (#275)
* feat(elbv2): ListenerRules CRUD with HAProxy ACL routing

Implement CreateRule, ModifyRule, DeleteRule, DescribeRules, and
SetRulePriorities for ALB listeners. Rules support host-header,
path-pattern, http-header, http-request-method, source-ip, and
query-string condition fields. Actions limited to forward (single
target group); redirect and fixed-response deferred.

HAProxy frontends emit per-rule ACLs with `use_backend ... if <acls>`
ordered by priority. Wildcards in host/path map to HAProxy match
modes (`-m beg/end/sub/str`). Condition values are validated at
store-time and re-validated at render-time to prevent config
injection. Target groups referenced by rule actions are protected
against deletion. Deleting a listener cascades its rules.

Frontend adds a Rules tab beneath each listener row showing rules
with conditions and target groups, plus add/edit/delete dialogs
with field-typed condition inputs.

mulga-950

* test(elbv2): rule CRUD e2e + render coverage for all condition fields

Adds Internal_ALB_ListenerRules subtest exercising CreateRule, ModifyRule,
DeleteRule, and DescribeRules over a real ALB with HAProxy. Two TGs with
disjoint backends (app#0 -> tgA, app#1 -> tgB) let the probe responder
identify which TG actually served the request, so path-pattern routing
can be asserted without inspecting internal state.

Extends app-userdata.sh with alpha/ and beta/ sub-paths and extends
client-userdata.sh trigger to accept optional http_path and host fields
so probes can target specific paths and Host headers. Both changes are
backward-compatible: existing callers default to / with no Host override.

Adds unit-test coverage for every condition-field render path
(http-header, http-request-method, query-string with and without key,
source-ip) plus error-path validation tests for CreateRule, ModifyRule,
DeleteRule, DescribeRules, and SetRulePriorities. Lifts diff coverage
above the 60% gate.

* test(elbv2): wait for tgB healthy after rule creation, not before

Spinifex only health-checks a target group once it is referenced by a
listener default action or a rule action. The previous ordering waited
for tgB healthy before CreateRule wired it, so health checks never ran
and the wait timed out. Move the WaitForTargetsHealthy(tgB) call after
createPathRule.

* fix(elbv2): include rule-action TGs in TargetGroupsForLB

healthChecker uses Store.TargetGroupsForLB to map HAProxy server
statuses back to TargetGroupRecord entries. The previous implementation
walked only listener DefaultActions, so a target group referenced only
by a CreateRule action was invisible to the health checker — HAProxy
probed its backends but the resulting health never propagated to the
store. DescribeTargetHealth reported the targets as 'initial' forever.

Extend TargetGroupsForLB to walk rule actions per listener as well.
Adds a unit test that wires a TG via CreateRule and asserts the
resolver returns it.

* test(elbv2): fix wait-for-rule logic to poll away from default backend

waitForPathRouted required the caller to name the expected new host, but
at the post-CreateRule wait point we only know the default host (appA);
the rule target hostname (appB) is discovered by the subsequent probe.
The previous code passed appA as both "new" and "old", so the wait
checked "see appA" against "/alpha/" responses — which never matched
because the rule had successfully routed appA out.

Replace with two narrower helpers: waitForPathRoutedAway (poll until
host X stops appearing) and waitForPathRoutedTo (poll until host Y
appears). Update the three call sites to use the variant that matches
what the caller actually knows at that point.

* fix(elbv2): IAM-style error envelope + ModifyTargetGroup + listener attr stubs

ELBv2 success responses use the IAM-style XML envelope (<ErrorResponse>),
but error responses were emitted with the EC2 envelope (<Response>),
which aws-sdk-go-v2 cannot deserialize for ELBv2 and surfaces as
"UnknownError: UnknownError" — defeating provider-side InvalidAction
ignores (e.g. terraform-provider-aws v5.100's DescribeCapacityReservation
swallow). Route elasticloadbalancing errors through GenerateIAMErrorResponse.

Add ModifyTargetGroup end-to-end (interface, impl, NATS shim, daemon
handler, gateway dispatch) so terraform-provider-aws can set health-check
fields after CreateTargetGroup without infinite drift. Persist
HealthCheckEnabled on TargetGroupRecord and emit it in tgRecordToSDK so
the provider sees the configured value across refresh cycles.

Stub DescribeListenerAttributes / ModifyListenerAttributes with
hand-rolled input/output structs (aws-sdk-go v1.55 lacks these types).
Returns empty attribute list — sufficient for default-config workflows.

* test(elbv2): cover ModifyTargetGroup + listener attribute stubs

Extend handler tests with all-fields, invalid path/matcher, wrong-account
ModifyTargetGroup cases plus HealthCheckEnabled assertion on
CreateTargetGroup. Add gateway-layer validation tests for the new
DescribeListenerAttributes, ModifyListenerAttributes, and ModifyTargetGroup
dispatchers. Raises diff coverage from 58.8% to 63.7%.
2026-06-02 12:15:42 +10:00
brynmailer-mdc 0c577da6e2 test: cover encryption wiring on feat/encryption-at-rest
Lifts diff-coverage on PR #276 from 37.5% to 63.5% (threshold 60%) so
CI's Check Diff Coverage step passes.

- utils: LoadViperblockMasterKey — empty path, happy path + cache reuse,
  load error, and bad-perm failure-not-cached.
- handlers/ec2/image: clusterEncryptionEnabled — nil config, empty key
  file, masterkey load error (warn + false), and key-loaded (true).
- handlers/ec2/volume:
  - GetVolumeConfig surfaces "failed to unmarshal config" when the body
    is neither a valid VBState nor a wrapper.
  - mergeVolumeConfig refuses to re-marshal an encrypted VBState (would
    strip the AES-GCM tag and brick the volume).
  - CreateVolume returns ServerInternal when EncryptionKeyFile is set
    but unreadable.
2026-06-02 11:03:58 +10:00
brynmailer-mdc 557d3e4d59 feat: derive Encrypted from VBState.EncryptionEnabled, drop IsEncrypted reads
VolumeMetadata.IsEncrypted is being removed from viperblock — it was
hardcoded false everywhere in spinifex and misrepresented the per-volume
encryption status to AWS SDK callers. The authoritative flag is now
VBState.EncryptionEnabled, persisted by viperblock at New() when
MasterKey != nil.

- volume CreateVolume drops the dead IsEncrypted struct member and
  surfaces Encrypted=(mkey != nil) on the EC2 response.
- volume getVolumeByID now decodes the full VBState (falling back to a
  wrapper-only blob for legacy pre-encryption volumes) so it can read
  state.EncryptionEnabled directly.
- snapshot CreateSnapshot pulls Encrypted from the source volume's
  VBState.EncryptionEnabled (already loaded one level up).
- image synthesizeRootBlockDeviceMapping takes the cluster encryption
  posture (mkey != nil) since AMI metadata carries no per-image flag;
  cached masterkey load keeps the hot path cheap.
- Test fixtures drop the now-defunct IsEncrypted lines; the volume
  fixture that asserts Encrypted=true switches to a full VBState seed
  with EncryptionEnabled=true.
2026-06-02 11:03:58 +10:00
brynmailer-mdc b5220996e1 fix: only clone AMI on os.ErrNotExist in prepareRootVolume
LoadStateRequest can fail for many reasons — HMAC integrity, key
mismatch, transient backend 5xx, JSON parse failure. The previous
fall-through treated every error as "volume doesn't exist, clone from
AMI", which would overwrite the legitimate encrypted volume's
config.json with AMI base-map state on a tamper or transient backend
fault. Discriminate os.ErrNotExist explicitly and abort otherwise.
2026-06-02 11:03:58 +10:00
brynmailer-mdc ef50392f9e feat: wire viperblock at-rest encryption key through daemon and AWS handlers
Adds EncryptionKeyFile to ViperblockConfig (cluster config) and
viperblockd.Config. viperblockd loads the master key once at
launchService start via masterkey.LoadShared and passes
MasterKey + EncryptionEnabled into the VB constructed for every
mounted volume.

AWS handlers (volume.CreateVolume, instance.newViperblock,
image.snapshotStoppedVolume) and the spx admin AMI-import path now
resolve the key via utils.LoadViperblockMasterKey — a process-wide
cache keyed by path so the per-VB lookup is one map hit after the
first load.

volume.mergeVolumeConfig: switch from json.Unmarshal to json.Decoder
so a trailing AES-GCM tag cannot route the body to the wrapper-only
path, and refuse outright when state.EncryptionEnabled — re-marshaling
an encrypted VBState here would strip the tag and brick the volume.

cmd/spinifex viperblock CLI grows a --encryption-key-file flag (env
SPINIFEX_VIPERBLOCK_ENCRYPTION_KEY_FILE) under a distinct viper key
so it does not collide with predastore's identically-named flag.
2026-06-02 11:03:58 +10:00
Josh Siviour 8883984f3f feat(eks): eks-server + eks-agent AMI manifests (#273)
* feat(eks): eks-server + eks-agent AMI manifests + builder INSTALL_FILES

Manifest-driven AMI build for the two EKS image variants (eks-v1.md Q12).
Lifted forward from cs-eks-6b so the K3s boot path can be smoke-tested in
isolation before CreateCluster orchestration lands.

Builder:
- build-system-image.sh gains optional INSTALL_FILES (src:dst pairs,
  mode 0644, parent dirs auto-created). Symmetric with INSTALL_BINARIES.
- ENABLE_SERVICES moves after INSTALL_FILES + SETUP_SCRIPT so init scripts
  the manifest references are on disk before rc-update enables them.

eks-server image (Alpine 3.21.7, q35, 4G):
- K3s v1.32.5+k3s1 (downloaded at bake time, SHA256 verified against the
  upstream sha256sum-amd64.txt trust anchor).
- nats-cli v0.4.0 (not in Alpine main/community — pulled from upstream
  release zip; unzip installed + purged in same setup step).
- OpenRC init scripts: k3s, eks-token-webhook, k3s-first-boot.
- Cron periodics under /etc/periodic/{15min,daily}/ for state-report +
  etcd-snapshot.
- k3s-first-boot publishes node-token + admin kubeconfig over NATS on
  first successful K3s healthz, then self-disables (sentinel + rc-update
  del).
- config.yaml.skel without webhook-token-auth — wiring it before the real
  webhook lands (cs-eks-6c) would block anonymous probes against /healthz.
  Orchestrator drops the live config + restarts k3s.

eks-agent image (Alpine 3.21.7, q35, 2G):
- Same pinned K3s release for server/agent lock-step.
- k3s-agent OpenRC service refuses to start without K3S_URL+K3S_TOKEN —
  fail-closed pre-orchestration.

eks-token-webhook stub (cmd/eks-token-webhook/main.go):
- 503 + Retry-After:30 on every path. Statically linked. Lets the AMI
  build succeed and kube-apiserver boot cleanly. Replaced wholesale by
  cs-eks-6c.

Init system divergence from spec wording: Alpine ships OpenRC, not
systemd. Unit files use .initd extensions and cron periodics instead of
.service / .timer. Bead-level intent (auto-enable on boot, periodic
execution) preserved.

Smoke-tested:
- eks-server: builds 300M qcow2; qemu q35 boot reaches K3s apiserver on
  6443 within ~120s; HTTPS probes return valid k8s Status JSON (the 401
  is K3s v1.32 default RBAC behaviour against anonymous, not a regression).
- eks-agent: builds 280M qcow2; qemu boot mounts root cleanly.

* feat(eks): make targets for eks-server + eks-agent AMI builds

- build-system-image gains subdir-manifest fallback: tries
  scripts/images/$(IMAGE).conf first, then scripts/images/$(IMAGE)/manifest.conf
  so the new EKS layout works alongside the existing flat lb manifest.
- build-eks-server-image / build-eks-agent-image — explicit convenience
  targets that delegate to the generic one.
2026-06-01 16:24:49 +10:00
Josh Siviour 785a5655e4 feat(sts): EKS Q4 — AssumeRoleWithWebIdentity + presigned GetCallerIdentity verify (#272)
* feat(sts): EKS Q4 STS extensions — interface + JWKS reader + stubs

Lays the structural slice of AssumeRoleWithWebIdentity + presigned-URL
GetCallerIdentity verify for IRSA. The verify-path bodies (JWT signature
check against fetched JWKS, claims validation, Federated-principal trust
policy evaluation, SigV4 presigned-URL canonical-request reconstruction)
land in follow-up commits on this branch.

Adds:
- handlers/sts/oidc_jwks.go — JWK/JWKS types, ParseEKSIssuerURL (defensive
  iss parsing), FetchClusterJWKS (KV-backed read from
  eks-account-{accountID}/clusters/{name}/oidc-jwks.json via the EKS store
  helpers).
- handlers/sts/oidc_jwks_test.go — unit tests over a real JetStream
  test fixture.
- handlers/sts/assume_role_with_web_identity.go — interface entry, stub
  returns ErrorNotImplemented.
- handlers/sts/presigned_url_verify.go — PresignedCallerIdentity type +
  VerifyPresignedGetCallerIdentity stub.
- gateway/sts/AssumeRoleWithWebIdentity.go — dispatch shell.

Modifies:
- handlers/sts/service.go — STSService interface gains
  AssumeRoleWithWebIdentity + VerifyPresignedGetCallerIdentity.
- gateway/sts.go — registers AssumeRoleWithWebIdentity in stsActions +
  stsSkipPolicyCheck (anonymous; trust-policy-gated in handler).
- gateway/sts_request_test.go, gateway/sts/handler_test.go,
  gateway/auth_test.go — stub implementations updated to satisfy
  extended STSService interface.

Anonymous routing for AssumeRoleWithWebIdentity (eks-v1.md Q5 mux rework)
lands in Sprint 6e — for now the action is registered on the standard STS
gateway and rejects non-SigV4 requests upstream.

Refs: cs-eks-sts-q4 (mulga monorepo bead)

* feat(sts): AssumeRoleWithWebIdentity + presigned GetCallerIdentity verify

Implements EKS Q4 STS extensions for IRSA + EKS token-webhook flows.

AssumeRoleWithWebIdentity:
- ES256 JWT verification against per-cluster JWKS (eks-account-{accountID}
  KV bucket), keyed by issuer host-path.
- Federated trust-policy evaluator (explicit-deny-wins, exact-match
  Principal.Federated, fail-closed on Principal: "*").
- Condition support scoped to StringEquals on {iss}:sub (exact) and
  {iss}:aud (set-membership). All other operators rejected at validator.
- OIDC provider registry check against iam-account-{roleAccountID}; v1
  does not support cross-account registration.
- New awserrors: InvalidIdentityToken, IDPRejectedClaim,
  IDPCommunicationError.

VerifyPresignedGetCallerIdentity:
- Hand-rolled SigV4 canonical-request reconstruction (no sdk dep).
- Mandatory X-K8s-Aws-Id in SignedHeaders + value pinned to
  expectedClusterName during canonicalisation — cross-cluster replay
  surfaces as a signature mismatch.
- AKID-prefix dispatch (ASIA → session credential, AKIA → IAM user).
- Strict envelope checks: algorithm, credential scope service/terminator,
  X-Amz-Date / credential-scope date agreement, expiry within
  X-Amz-Expires, 5-minute wire skew.

Tests:
- 14 web-identity scenarios (happy paths, tampering, expiry, wrong aud,
  unknown kid, HS256 rejection, federated-wildcard fail-closed,
  explicit-deny-wins, provider-not-registered).
- 12 presigned-URL scenarios including cross-cluster replay defence,
  missing-x-k8s-aws-id, tampered query, bad service in credential, AKIA
  + ASIA happy paths.
- Cross-cluster anti-replay invariant test (rejects matrix of variant
  cluster names — case-different, prefix, suffix).
- AWS SigV4 known-vector test for deriveSigningKey.

Gateway:
- TODO documents that AssumeRoleWithWebIdentity is currently dispatched
  on the SigV4-gated path; a pre-auth route is needed so a projected
  ServiceAccount token alone can authenticate the caller.

IAM:
- Relaxes trust-policy Condition validator: now permitted only when
  Action is exactly [sts:AssumeRoleWithWebIdentity] and operator is
  StringEquals. Preserves loud failure for sts:AssumeRole.
2026-06-01 14:29:35 +10:00
tomnewton-mulga 9d8597317a Feat/mig capable (#271)
* feat(gpu): add NVIDIA MIG multi-instance GPU support

Partitions MIG-capable GPUs (A100, A30, H100) into isolated slices exposed
as mdev devices instead of whole-GPU VFIO passthrough. VMs attach via
-device vfio-pci,sysfsdev=<mdev-path> using the GPUAttachment discriminated
union (PCIAddress for whole-GPU, MdevPath for MIG).

Key changes:
- gpu: new MIGProfile/MIGInstance types, IsMIGCapable model map, mig.go ops
  (EnableMIGMode, DisableMIGMode, CreateInstances, ListInstances,
  DestroyAllInstances, enrichMdevPaths)
- gpu.Manager: Claim() returns (*GPUDevice, *MIGInstance, error); AddMIGInstances,
  ReclaimByMdev for restart recovery; MarkMIGFailed per-slice
- daemon: buildGPUPool helper partitions devices into whole-GPU vs MIG pools;
  MIGProfile config field selects profile per node or per GPU override
- vm.VM: GPUAttachments []gpu.GPUAttachment replaces GPUPCIAddresses []string
- admin CLI: spx admin gpu mig status/enable/disable subcommands with
  admin.SetMIGProfile for spinifex.toml writes
- docs: COMMANDS.md updated with three new MIG management command rows

* test(gpu): unit tests for MIG parsers and pool manager

Cover all pure parsing functions (parseMIGMemory, isCapacityExhaustedError,
extractProfileFields/GIFields, parseMIGProfiles, parseMIGInstances,
parseCreatedGIID/CIID) with realistic nvidia-smi fixture output.

Manager MIG tests cover AddMIGInstances, Claim/Release on mdev-backed entries,
MarkMIGFailed, ReclaimByMdev, and interaction with whole-GPU MarkFailed — all
using fake mdev directories in t.TempDir() so no GPU hardware is required.

* feat(gpu): dynamic MIG profile-per-request with on-demand GI/CI lifecycle

Replaces static config-driven MIG pre-carving with a dynamic model where
the MIG profile is specified per RunInstances call via mig.<profile>
instance type names (e.g. mig.1g.10gb) that mirror nvidia-smi nomenclature
directly.

- Manager.Claim(instanceID, profileName): fast path claims a pre-carved
  slice; slow path dequeues a free MIG GPU, releases the lock, calls
  nvidia-smi mig -cgi/-cci to carve all slices, then reclaims the lock to
  commit and return the first slice
- Manager.Release: last release on a dynamically-carved GPU calls
  DestroyAllInstances and returns the GPU to freeMIGGPUs, allowing a
  different profile on the next claim
- AddMIGGPU(dev): registers a fresh MIG GPU for dynamic allocation;
  AddMIGInstances (restart recovery) keeps its no-destroy semantics
- GenerateMIGTypes: produces mig.<profile> InstanceTypeInfo entries from
  gpu.ListProfiles at daemon startup; IsMIGType/MIGProfileFromType let
  callers inspect these names
- buildGPUPool: calls ListInstances for recovery, AddMIGGPU for fresh
  GPUs, ListProfiles to collect MIG profile specs for type generation;
  removes setupMIGInstances and migProfileForDevice
- GPUClaimer.Claim gains profileName param; daemonGPUClaimer, service_impl
  call sites, and fakeGPUClaimer all updated

* fix(gpu): correct mdev parent detection and make --profile optional for mig enable

enrichMdevPaths was using os.Readlink on "parent_bus_0" (a non-existent
sysfs attribute) and then on ".." (a directory, not a symlink) — both
would fail on real NVIDIA hardware, breaking CreateInstances and
ListInstances on daemon restart recovery. Fix: read the mdev UUID's own
symlink target, which always encodes the parent GPU's PCI address in its
path (e.g. .../0000:01:00.0/<uuid>).

Make basePath injectable in enrichMdevPaths so the fix is covered by a
new set of unit tests using temp-dir symlinks (TestEnrichMdevPaths_*).

Remove the MarkFlagRequired("profile") from "spx admin gpu mig enable":
Phase 2 is fully dynamic — the daemon carves slices on demand at
RunInstances time. Without --profile, the command just enables MIG mode
and destroys any pre-carved slices so the daemon starts with a free GPU.
With --profile, behaviour is unchanged (pre-carve a static pool).

* chore: writeups for smci and arcee

* test: add coverage for MIG exec, manager slow-path, instancetypes and admin

- Add mig_exec_test.go with fake nvidia-smi harness covering Enable/Disable MIGMode,
  IsMIGModeEnabled, DestroyAllInstances, ListProfiles, CreateInstances, ListInstances
- Extend manager_mig_test.go with slow-path Claim/Release tests using fake nvidia-smi
- Add generate_test.go coverage for IsMIGType, MIGProfileFromType, GenerateMIGTypes
- Add admin_test.go coverage for SetMIGProfile (create, update, idempotent, error paths)
- Make mdevBasePath a var to allow sysfs injection in tests
- Add json tags to GPUAttachment fields to satisfy musttag linter

Diff coverage: 75.6% (threshold: 60%).

* fixed gpu passthrough doc

* Revert "fixed gpu passthrough doc"

This reverts commit 1e20f5ab13.

* feat: Added RTX 6000 GPU MIG support and fixed docs

* feat: expose GPU/MIG inventory in GetNodes and GetVMs admin API

Add Manager.Snapshot() for lock-safe pool inspection. Extend
NodeStatusResponse with GPUs []GPUInfo (per-physical-GPU inventory
with per-slice detail for MIG) and VMInfo with GPU *VMGPUInfo
(model, VRAM, profile, mdev path for the attached GPU). Populate
both in handleNodeStatus and handleNodeVMs via buildGPUInventory,
buildPoolLookup, and resolveVMGPU helpers.

* feat: surface GPU info on EC2 instance detail page (mulga-new-03)

Admin users see a GPU card (model, VRAM, attachment type, MIG profile/mdev
path or PCI address) populated from the GetVMs admin API.  Adds formatVRAMMiB
helper and VMGPUInfo/GPUInfo/GPUSliceInfo types to the frontend.

* feat: GPU columns + inventory card on Nodes page (mulga-new-04)

Adds GPU column to VMsTable (model/VRAM or MIG profile) and ResourceTable
(alloc/total GPUs). Section 4 GPU Inventory card shows per-node GPU state with
per-slice MIG drill-down. Also adds total_gpus/alloc_gpus to NodeInfo type.

* feat: grouped GPU-aware instance type picker in Run Instances (mulga-new-05)

Splits the flat instance type dropdown into Standard and GPU-Accelerated groups.
GPU items show total VRAM alongside the type name. Selecting a GPU type surfaces
an info line with GPU name and VRAM.

* testing image builder scripts

* fixed nvidia build script

* more descriptive errors for image build script

* more fixes

* more fixes

* fix: surface PCI address in whole-GPU VMGPUInfo and rebuild frontend dist

Add PCIAddress to VMGPUInfo, populate it in resolveVMGPU for whole-GPU
passthrough, and fix the instance detail page which was incorrectly
showing instance type in the PCI address field. Rebuilt frontend dist
to include GPU UI changes (inventory card, resource columns, GPU-aware
instance type picker).

* fix: improve kernel version detection in nvidia image build script

Fall back to /lib/modules when /boot/vmlinuz-* is absent, and add
diagnostic output to help debug kernel detection failures.

* fixing nvidia image build script

* fix ami name issue

* correct kernel versions

* another fix

* image build works, added docker and nvidia toolkit for QoL

* fix: add gpg

* fixing amd image builder

* add universe

* another fix

* nvm

* fix docker user group issues

* make rocm land where it is supposed to

* fix docker groups

* feat: add Ubuntu 26.04 GPU AMI catalog entries and fix build script parted detection

Add ubuntu-26.04-nvidia-gpu-x86_64 and ubuntu-26.04-amd-gpu-x86_64 to AvailableImages,
hosted on iso.mulgadc.com/system-ami/ with SHA256 checksums. Fix build-system-image.sh
to detect parted in /usr/sbin when not on PATH. Suppress gosec false positives in DHCP
manager (cancel stored in leaseLoop) and promisc ioctl (standard syscall uintptr pattern).

* Updated passthrough doc regarding GPU images

* fix: remove orphaned nolint directives (golangci-lint v2.12.2 no longer flags G115/G118)

* chore: remove stale spinifex@192.168.3.107 directory

Scripts were accidentally committed with a node hostname as the path
prefix during initial testing. Correct copies live in scripts/images/.
2026-06-01 13:48:13 +10:00
Josh Siviour e7a25316f0 feat(eks): gateway + handler scaffolding for EKS control plane (#267)
* feat(eks): gateway + handler scaffolding for EKS control plane

Lands the EKS service contract — gateway REST-JSON dispatcher, handler
interface + stub impl, JetStream KV bucket layout, and daemon wiring —
ahead of the K3s control-plane bodies.

Every action currently returns NotImplemented (HTTP 501 + JSON
NotImplementedException) so downstream work can call into a stable
contract while real bodies land sprint by sprint.

- Gateway: 34-action REST-JSON routing table under svc=eks, per-action
  closures dispatch to handlers_eks.NATSEKSService. ErrorHandler /
  writeClusterUnavailable / writeThrottleError gain EKS JSON branches so
  clients see application/x-amz-json-1.1 envelopes.
- Handler: EKSService interface + NATS wrapper + stub impl with
  var _ EKSService compile checks on both implementations.
- KV: eks-account-{accountID} lazy per-account bucket factory plus the
  shared spinifex-eks-leader bucket (TTL 60s, History 1) bootstrapped
  at daemon start. Key-path helpers cover the full Q2 layout
  (cluster meta, nodegroups, access entries, OIDC providers + signing
  key + JWKS, admin kubeconfig, K3s node-token, events).
- Daemon: 34 eks.<Action> subscriptions on the spinifex-workers queue
  group, init via initServiceWithRetry, mirrors the ELBv2 layout.
- awserrors: new ErrorNotImplemented constant (HTTP 501).
- tags: ManagedByEKS constant for downstream SG/NLB/VM tagging.

* test(eks): cover scaffolding wrappers + daemon NATS handlers

Adds two test files closing the diff-coverage gap on the EKS
scaffolding commit:

- spinifex/gateway/eks/wrappers_test.go drives every per-action
  gateway wrapper (cluster, nodegroup, access, addons, oidc, tags —
  34 actions, 50+ subtest invocations) through an embedded NATS
  server with a `eks.>` catch-all stub responder. Covers the
  wrappers themselves plus handlers/eks/service_nats.go via the
  NewNATSEKSService construction inside each wrapper. Bad-JSON
  branches in unmarshalIfBody are also covered.

- spinifex/daemon/daemon_handlers_eks_test.go constructs a minimal
  Daemon{eksService: real impl} and dispatches each of the 34
  handleEKS* handlers via embedded NATS, asserting the NotImplemented
  error code rides back through handleNATSRequest.

Lifts diff coverage from 27.3% to 62.6%, above the 60% threshold.
2026-06-01 10:47:23 +10:00
Josh Siviour 3415d9f3de fix(network): IGW route-table AWS parity + per-subnet egress gating (#270)
* fix(network): IGW default route AWS-parity via per-subnet LR policy

ADR-0006's IGWManager.AttachIGW installed a VPC-router-wide 0.0.0.0/0
static route at attach time, violating AWS semantics: every subnet on
the LR saw egress to the IGW regardless of its route table. Private
subnets that should be IGW-isolated were not.

Move IGW egress lifecycle onto the route-table API. CreateRoute(IGW,
0.0.0.0/0) emits vpc.add-igw-route per associated subnet; the network
subscriber installs an OVN Logical_Router_Policy matching
`inport == "rtr-<subnetID>" && ip4.dst == 0.0.0.0/0` with action
`reroute` via the IGW gateway port. DeleteRoute / DisassociateRouteTable
/ ReplaceRouteTableAssociation tear it down. AssociateRouteTable emits
add events so the terraform ordering (CreateRoute -> Associate) is
honored.

Additions:
- nbdb.LogicalRouterPolicy + Logical_Router_Policy registration
- ovn.Client {Add,Delete,Find,List}LogicalRouterPolicy + live + mock
- policy.RouteManager.{Add,Delete}SubnetEgress with priority constants
- external.IGWManager.{Ensure,Remove}SubnetEgress resolving nexthop +
  output port from the gateway state
- subscribers handleAddIGWRoute / handleDeleteIGWRoute on
  vpc.{add,delete}-igw-route
- routetable publishIGWRouteEvents / *ForAssociation / *ForSubnet

Test updates:
- network/policy: AddSubnetEgress idempotency, drift-replace, per-subnet
  separation, DeleteSubnetEgress
- network/external: AttachIGW must NOT install a router-wide default
  route; EnsureSubnetEgress installs the per-subnet policy with the
  resolved nexthop (link-local fallback + allocator override paths)
- handlers/ec2/routetable: CreateRoute(IGW), Associate, DeleteRoute,
  Disassociate publish events with the expected subnetID + igwID
- ovn/nbdb: table-count + Logical_Router_Policy presence
- subscribers: TestSubscribe_RegistersAllTopics covers the two new topics

* fix(network): NATGW per-subnet egress + DeleteRoute event symmetry

handlers/ec2/routetable:
- DeleteRoute now publishes vpc.delete-nat-gateway per associated subnet
  (mirror of the IGW DeleteRoute path) so the subscriber tears down both
  the SNAT rule and the per-subnet egress policy.
- publishNatGatewayEvent* signatures carry SubnetId + DestinationCidr so
  the subscriber can install the matching Logical_Router_Policy.

network/external:
- IGWManager: EnsureNATGatewaySubnetEgress / RemoveNATGatewaySubnetEgress
  install/remove a per-subnet LR policy at priority SubnetEgressPriorityNATGW.
  Same nexthop and output port as the IGW priority sibling; lower priority
  so an IGW route wins when both apply to the same subnet.
- EnsureSubnetEgress delegates to ensureSubnetEgressAtPriority for shared
  validation, lookup, and slog tagging.

network/subscribers:
- handleAddNATGateway: after SNAT install, parse DestinationCidr (defaults
  to 0.0.0.0/0) and call EnsureNATGatewaySubnetEgress. Without this the LR
  has no route to egress NAT'd packets and private-subnet traffic is
  dropped (E2E Phase 8d regression).
- handleDeleteNATGateway: remove the per-subnet policy FIRST, then detach
  SNAT, so packets are never routed unmasqueraded after SNAT removal.
- NATGatewayEvent gains SubnetId + DestinationCidr.

Tests:
- policy.AddSubnetEgress validation + drift-replace + propagation paths.
- mock LR-policy methods reject missing routers and tolerate dangling UUIDs.
- subscribers cover NATGW add/delete with invalid CIDR, bad JSON, missing
  router, IGW-route bad JSON + invalid CIDR. Full end-to-end add+delete
  via NATS asserting policy lifecycle.
- IGW manager covers RemoveSubnetEgress / RemoveNATGatewaySubnetEgress
  empty-arg rejection + delete error propagation.
- routetable: DeleteRoute(NATGW) publishes per associated subnet.

* fix(network): exclude VPC CIDR from per-subnet egress reroute predicate

In-VPC traffic (e.g. bastion -> private SSH) was being intercepted by the
NATGW per-subnet egress LR policy on `ip4.dst == 0.0.0.0/0` and forwarded
out the gateway with SNAT, breaking peer-subnet connectivity. Append
`&& ip4.dst != <vpcCIDR>` to the match so in-VPC destinations skip the
reroute and follow the directly-connected route. Apply to both IGW (1000)
and NATGW (900) priorities. CIDR is read from the LR ExternalIDs label
populated at VPC create.

DeleteSubnetEgress takes the same excludes so the match string is
identical on cleanup. igwManager.vpcExcludeCIDRs fetches from the router
each call; lookup failure returns nil and the policy installs without
exclusions (preserves prior behaviour as fallback).

* fix(routetable): publish per-subnet events for implicit main-RT membership

E2E run 26620082197 RCA: subnets with no explicit RT association inherit
the VPC main route table per AWS semantics, but publishIGWRouteEvents and
publishNatGatewayEvents iterated only record.Associations and skipped
Main:true/SubnetId:"" sentinels. Main RT carries exactly one such
sentinel, so CreateRoute(igw)/CreateRoute(natgw) on the main RT fanned
out to ZERO subnets — subscriber never called EnsureSubnetEgress, OVN
LR policy never installed, ping 8.8.8.8 from default-VPC VMs dropped at
the VPC router. Caused TestVPCSubnetE2E, TestNATGateway,
TestMultinodeSpread/NATGatewayInternet to fail.

Add subnetsImplicitlyOnMainRT helper that enumerates VPC subnets
without explicit non-main associations, and fan publish to them when
record.IsMain. Apply to all three publish helpers (IGW, NATGW,
NATGW-delete).

Symmetric fixes for effective-RT changes:
- AssociateRouteTable: subnet leaves implicit main → emit deletes for
  main RT's routes before adds for explicit RT
- DisassociateRouteTable: subnet returns to implicit main → emit adds
  for main RT's routes after deletes for departing RT

* chore(e2e): capture per-LR lr-route-list/lr-policy-list/lr-nat-list

ovn-nbctl show does not dump static routes or policies. Adds three
per-LR captures so RCA can verify policy/route install state after
the IGW per-subnet refactor.

* chore(e2e): tcpdump br-wan + neigh/fdb/conntrack capture during tests

EIP egress regression on feat/igw-route-table-aws-parity shows SNAT
packets present (n_packets>0) but pings still time out. Post-teardown
ovn.* snapshots can't tell whether replies actually arrived on br-wan
or got dropped at L2 / by upstream router.

start-net-capture.sh: nohup tcpdump on br-wan filtered to 192.168.0.0/23
(EIP pool) + ARP for same range, 200-byte snaplen, 64 MiB cap. Pair:
capture-net-diag.sh stops it via /tmp/spinifex-e2e-tcpdump.pid and copies
wan.pcap into net-diag/.

Also added neigh/bridge-fdb/conntrack dumps to capture-net-diag.sh so
L2/L3 binding state is visible alongside the existing OVN dumps.

Wired both single-VM and multinode workflows to start capture before
the e2e run; teardown step picks up the pcap automatically.

* fix(reconcile): rebuild IGW/NATGW per-subnet egress from KV

Publisher fires vpc.add-igw-route during default-VPC bootstrap before the
subscriber daemon attaches to the topic. NATS core pub/sub drops the
message, leaving the LR without a 0.0.0.0/0 policy and off-subnet egress
broken on the affected VPC for the lifetime of the process. Reproduced
in CI: TestVPCSubnetE2E bastion EIP could ARP and DNS its on-LAN
neighbour but never reach 8.8.8.8.

LoadIntentFromKV now fans every IGW or NAT-gateway RT route out over
its explicit + implicit-main subnets and the reconcile pass invokes
EnsureSubnetEgress / EnsureNATGatewaySubnetEgress for each one. Same
shape as publishIGWRouteEvents so the bootstrap and the runtime event
path produce identical OVN state.

* fix(routes): serialize OVN egress wiring via request-reply

vpc.igw-attach, vpc.add-igw-route and vpc.add-nat-gateway were fire-and-forget
Publish, racing the subscriber's AttachIGW barrier. AttachInternetGateway
returned before vpcd finished wiring the OVN gateway LRP, so a follow-up
CreateRoute(IGW) published an add-igw-route event that the subscriber
processed in a parallel goroutine — installing a Logical_Router_Policy with a
nexthop the LRP didn't yet expose. northd compiled an inert reroute that
never recovered, leaving the bastion's egress traffic blackholed (no packets
dst 8.8.8.8 from the failing VPC's EIP even though local DNS succeeded).

Switch the three add-* publishers to utils.RequestEvent. The subscriber ACKs
once OVN state is committed, so the gateway handler can't issue subsequent
route events against a half-wired router. delete-* paths stay Publish —
teardown ordering doesn't matter.

handleAddNATGateway now calls respond() at every exit so requesters don't
hang. Route-table tests gain an ackSub wrapper (async subscribe + auto-Reply)
so request-based publishers complete during unit tests.

* Revert "fix(routes): serialize OVN egress wiring via request-reply"

This reverts commit bf6f77d5bc8b21f72ed5867175d5a67c010066c8.

* fix(igw): restore default static route in AttachIGW/DetachIGW

OVN 25.03 LR pipeline runs lr_in_ip_routing (table 15) BEFORE lr_in_policy
(table 17). When this branch swapped the per-VPC default static route for
the per-subnet Logical_Router_Policy reroute installed by EnsureSubnetEgress,
external destinations started dropping at routing priority-0 before the
policy table ran. Reproduced locally: ovn-trace stops at table 15 drop, ping
8.8.8.8 from inside a fresh-VPC bastion produced zero packets on br-wan
until `ovn-nbctl lr-route-add <router> 0.0.0.0/0 <wan-gw> <gw-port>` was
issued manually — after which the same ping succeeded immediately. dev still
green because dev calls AddDefaultRoute on AttachIGW.

Restore the original AddDefaultRoute / DeleteDefaultRoute calls; keep the
per-subnet EnsureSubnetEgress machinery intact so future per-subnet egress
gating (DROP policies for subnets whose RT lacks a 0.0.0.0/0 route) can
layer on top. Flip the two TestAttachIGW assertions that pinned the
"AttachIGW must not install a default static route" expectation.

Pairs with revert of bf6f77d5 in the previous commit (request-reply
serialization treated a non-existent race; real bug was missing routing
fallback).

* feat(network): per-subnet egress drop gating for AWS RT parity

VPC LRs carry a router-wide 0.0.0.0/0 default static route while an IGW is
attached (lr_in_ip_routing runs before lr_in_policy on OVN 25.03, so per-
subnet reroute alone is insufficient). Subnets whose effective route table
lacks 0.0.0.0/0 were previously able to egress via that default route,
violating AWS semantics.

Install an LR_Policy DROP at SubnetEgressPriorityDrop=1100 (above IGW=1000
and NATGW=900) for unrouted subnets. Match excludes VPC CIDR, link-local
(169.254/16) and multicast (224/4) so intra-VPC, DHCP/metadata and
multicast still flow.

Wiring:
- policy.RouteManager gains AddSubnetEgressDrop / DeleteSubnetEgressDrop.
- external.IGWManager gains EnsureSubnetEgressDrop / RemoveSubnetEgressDrop.
- New events: SubnetEgressGateEvent / SubnetEgressUngateEvent on topics
  vpc.gate-subnet-egress / vpc.ungate-subnet-egress.
- routetable handler publishes gate/ungate alongside existing add/delete
  IGW/NATGW route events on every CreateRoute/DeleteRoute/Associate/
  Disassociate/Replace mutation, based on subnet's recomputed effective RT.
- reconciler bootstrap path emits DropGates intent for subnets in IGW-
  attached VPCs whose effective RT lacks 0.0.0.0/0.

Tests cover primitive idempotency, drift recovery, event publishing per
mutation surface, implicit-main fan-out, and intent computation.

* fix(igw): fan out subnet egress gate decisions on IGW attach/detach

The reconciler computes per-subnet DROP policies for any subnet in an
IGW-attached VPC whose effective route table lacks 0.0.0.0/0, but those
decisions were only re-evaluated by the periodic drift tick (every
5 min). AttachInternetGateway / DetachInternetGateway now publish
gate/ungate events for every subnet in the VPC immediately, closing
the window where a freshly-attached IGW could leave unrouted subnets
unprotected.

Wiring: IGWServiceImpl gains a nil-safe GatePublisher hook satisfied
by RouteTableServiceImpl. Daemon installs the hook once both services
exist. RouteTableServiceImpl.PublishGateDecisionsForVPC enumerates the
VPC's subnets and reuses publishSubnetEgressGateDecision per subnet.

Also fixes the LB e2e suite, which built VPC + IGW + subnet without
ever creating a 0.0.0.0/0 → IGW route — relying on the pre-DROP-gate
behaviour where unrouted subnets still had silent egress via the
router-wide static route. The fixture now installs the explicit
public route on the main RT (AWS-faithful semantics for a public
subnet).

* fix(igw): skip gate fan-out on attach to avoid bootstrap race

AttachInternetGateway fan-out races with the default-VPC bootstrap path
in ensureDefaultVPCInfrastructureFor, which attaches the IGW and
immediately publishes a 0.0.0.0/0 → IGW CreateRoute event. The two
events land on distinct NATS subjects (vpc.gate-subnet-egress vs
vpc.ungate-subnet-egress) with no cross-subject ordering guarantee.
When ungate arrived first the subscriber became a no-op and the
subsequent gate installed a permanent DROP on the default subnet,
killing the return path for guest SSH probes
(TestMultinodeGuestSSH connect timeout to the instance public IP).

Detach fan-out is retained — no follow-up CreateRoute fires after
DetachInternetGateway so the path is race-free.

Subnets without a default route still get DROPped during the
reconciler's periodic drift pass (loadSubnetDropGates). The window is
re-opened to ≤5 min on user-driven AttachInternetGateway, which is
the original behaviour pre-mulga-siv-163. A follow-up bead can close
that gap properly by converting the gate/ungate subjects into a
single recompute trigger that re-reads effective RT state.

* fix(vpc): prevent duplicate main route table from bootstrap race

Concurrent multi-node daemons running EnsureDefaultVPC for the admin
account (bootstrap-pinned VpcId) each minted a fresh random rtb-ID and
Put it with IsMain=true. The KV ended up with two main route table
records for the same VPC.

CreateRoute's post-write gate recompute (per-subnet egress drop gating)
then resolved effectiveRouteTable non-deterministically across the two
IsMain=true records; when the orphan (route-less) RT won, the gate
decision became "no default egress" and vpc.gate-subnet-egress fired.
The subscriber installed a Priority-1100 DROP on the default subnet's
egress, killing the return path for guest SSH probes
(TestMultinodeGuestSSH connect timeout to 192.168.0.161 in run
26699045536).

Two-part fix:

1. createMainRouteTable scans rtbKV for an existing IsMain=true record
   on the same VPC before Put-ing a new one. Race window narrows from
   "always duplicates under concurrent bootstrap" to a sub-ms TOCTOU
   between scan and Put.

2. effectiveRouteTable / mainRouteTable use a deterministic preferMain
   tiebreak when multiple IsMain=true records exist: more routes >
   oldest CreatedAt > smaller RouteTableId. Defensive — any duplicate
   that slips through (cross-AZ restore, manual KV edit, residual race)
   still produces consistent egress gate decisions instead of flapping
   on KV scan order.

* fix(policy): atomic ACL replacement for SG mutations

Add ovn.Client.ReplaceACLs — detach + delete existing ACL rows and
attach freshly-created rows in one OVSDB transaction. EnsureSG and
UpdateSG now funnel through ReplaceACLs instead of ClearACLs +
AddACLs, closing the mid-flight window where the port group held
zero ACLs and tenant LSP traffic defaulted to drop (observable on
ICMP and on TCP SYNs that miss conntrack).

Mock client exposes ClearACLCalls / ReplaceACLCalls counters so
tests can assert atomicity. New TestSGManager_UpdateSG_AtomicReplace
fails if SG mutations ever fall back to the split path.
2026-06-01 09:54:29 +10:00
Josh Siviour 287487a4f2 feat(elbv2): implement ModifyListener (#268)
* feat(elbv2): implement ModifyListener

Adds AWS-compatible ModifyListener to the spinifex ELBv2 stack so
listeners can be edited in place — port, protocol, and default
actions — without the current delete+create dance that briefly drops
the listener from HAProxy.

- Handler interface, impl, and NATS wrapper get ModifyListener;
  validation mirrors CreateListener (LB type ↔ protocol, dup-port
  excluding self, target-group existence + protocol compatibility).
- Daemon subscribes to elbv2.ModifyListener on the spinifex-workers
  queue and a new gateway action dispatches it.
- Frontend exposes a per-row edit button that pre-fills a dialog from
  the existing listener and calls ModifyListenerCommand, replacing
  the "Listeners cannot be edited" note.
- Unit + NATS round-trip + frontend tests cover the new flow.

Cert / SslPolicy / Alpn handling stays out of scope and lands with
HTTPS listener support.

* test(e2e): cover ModifyListener in lb harness

Adds an Internal_ALB_ModifyListener subtest to TestLoadBalancer that
exercises in-place listener edits end-to-end:

- Provisions tgA + tgB sharing the same app instances.
- Brings up an internal ALB with HTTP:80 → tgA, baselines round-robin.
- ModifyListener port=8090, asserts DescribeListeners shows the new
  port and that probes on 8090 still round-robin — proves HAProxy is
  reconciled without dropping the listener (the regression mulga-944
  fixes; the frontend previously delete+created on every edit).
- ModifyListener DefaultActions → tgB, asserts traffic still flows.

Also factors a probeAtPort helper so the order JSON can target any
listener port, and updates TEST_COVERAGE.md with phase 3e.

* fix(ui): widen listener add/edit dialog so content stays inside the box

AlertDialogContent defaults to max-w-xs (20rem) which clipped the listener
form on the right edge. Use a 32rem cap with grid-cols-[minmax(0,1fr)] +
min-w-0 on the form so CSS grid tracks shrink instead of expanding to
max-content.

* test: cover ModifyListener + system instance dispatch

Lifts ModifyListener diff coverage by exercising nil/empty arn paths,
NLB protocol acceptance + rejection, protocol-only revalidation against
existing target groups, and the launcher-nats wire format error paths
(missing input, missing instance type, decode failures, no responder,
default timeout fallback).

Adds a new spinifex/daemon/daemon_system_dispatch_test.go covering
splitSubjectTail edge cases, respondWithSystem{Launch,Terminate}{Output,
OK,Error} envelope shape, handler entry points for invalid JSON and
missing instance ID, and subscribeSystemTerminate nil-conn + idempotent
re-subscribe behaviour.

Fixes the flaky TestHandleEC2DescribeInstanceTypes/NoFilter… subtest by
selecting a 2-vCPU instance type that also fits schedulable memory, so
hosts with smaller schedulableMem (CI 4 vCPU / ~13 GiB) don't pick a
16 GiB-mem type that fails allocation.

* test(e2e): wait for tgB after listener swap, not before

Spinifex health-checks only target groups wired into a listener's
HAProxy config. Waiting for tgB to report healthy before
modifyListenerDefaultTG attaches it to the listener is impossible —
the agent never probes its targets, so they stay in initial and the
2m EventuallyErr trips.

Drop the pre-swap tgB wait. After modifyListenerDefaultTG, replace
the fixed 3s sleep with WaitForTargetsHealthy on tgB (post-swap),
which is now the listener's DefaultAction.

* test(e2e): derive LB name from ARN in lbENI/deleteLB ENI filter

The ENI description filter was building the LB-name token from a
hard-coded "alb"/"nlb" template (e.g. `ELB app/lb-e2e-alb-int/<id>`),
which only matched LBs whose name happened to be `lb-e2e-alb-*` or
`lb-e2e-nlb-*`. The ModifyListener suite names its LB `lb-e2e-mod`,
so lbENI's EventuallyErr could never find the ENI and tripped after
30s. deleteLB had the same bug: sysInstanceID stayed empty and
WaitForENICleanup waited for a non-existent filter, allowing the
next suite's createLB to race the sys.micro VM reclaim.

Parse the LB name straight out of lb.ARN (penultimate path segment,
same shape createLB already relies on) so the filter matches any
caller-chosen name. Drop the now-redundant scheme arg from lbENI.

* test(e2e): poll for listener serving on new port after ModifyListener

The post-modifyListenerPort settle was a hard 3s sleep, but lb-agent
polls for new HAProxy config on a 5s tick. After the API returns, the
agent has typically not yet detected the config-hash change, so
HAProxy is still bound to the old port — every probe to the new port
RSTs and probeAtPort sees 0/0 successful.

Replace the sleep with waitForListenerServing: a polling loop that
fires 2 probes via the shared trigger client every 2s until at least
one returns a parsable instance_id, with a 60s ceiling. Factor the
trigger-and-fetch out of probeAtPort into probeOnce so the polling
helper can reuse it without the require.* asserts.
2026-05-30 09:18:52 +10:00
Josh Siviour c1f44b36da feat: vendor-agnostic instance types + sys.* NATS fan-out (#269)
* feat(instancetypes): emit cross-vendor sibling families on x86_64 hosts

Each x86_64 cpuGeneration's family list is now extended at generate time
with its cross-vendor counterpart family (Intel t3 ↔ AMD t3a,
c5 ↔ c5a, m6i ↔ m6a, etc.) via the new vendorSiblingFamily map in
definitions.go. Sibling families share an identical vCPU/memory shape
per size (same instanceFamilyDef size tables) and the same x86_64 arch
tag, so any host can schedule either vendor's request without changes
to canAllocate or the subscription path.

ResourceManager.updateInstanceSubscriptions iterates the type catalog
unchanged; it now picks up both vendors automatically and subscribes to
ec2.RunInstances.t3.* AND ec2.RunInstances.t3a.* on the same node.
RunInstances for either family load-balances across mixed Intel + AMD
clusters via the existing spinifex-workers queue group.

ARM (Graviton t4g/c?g/m?g/r?g) and legacy Intel-only families (t2, c4,
m4, r4) have no cross-vendor sibling and are unaffected.

Bead: mulga-siv-157 (vendor aliasing portion). sys.* NATS fan-out for
ALB-VMs is a separate change in the same plan.

* feat(daemon,elbv2): NATS fan-out for system instance launch/terminate

ELBv2 ALB-VMs (sys.micro) previously launched via a direct Go method call
from ELBv2ServiceImpl into the local Daemon, so every ALB-VM piled on
whichever node handled the upstream CreateLoadBalancer call. Mixed
clusters tripped 'insufficient capacity for sys.micro' once that node's
host-reserve trimmed schedulable vCPU below the next launch.

Route system-instance lifecycle through a dedicated NATS subject root:

  system.LaunchInstance.{type}            (queue group spinifex-workers)
  system.LaunchInstance.{type}.{nodeID}   (targeted)
  system.TerminateInstance.{instanceID}   (owning-node only)

ResourceManager.updateInstanceSubscriptions now treats sys.* types under
the new subject root with a separate handler hook (systemHandler) and
the same capacity-aware subscribe/unsubscribe behaviour customer types
already have — sys.micro subscriptions drop when the host saturates,
naturally re-distributing load.

The daemon side keeps LaunchSystemInstance / TerminateSystemInstance as
local methods; the new handleSystemLaunchInstance / handleSystemTerminate
handlers decode the JSON envelope, invoke the local method, and reply
with output or error. The terminate subscription is bound on launch
success so future terminate requests reach the owning node, and dropped
on terminate success.

ELBv2ServiceImpl now consumes a NATSSystemInstanceLauncher (handlers/elbv2/
system_instance_launcher_nats.go) instead of a direct daemon pointer, so
any node in the cluster can carry an ALB-VM.

SystemInstanceInput / Output / NICConfig / ExtraENIInput get explicit
JSON tags because the wire crosses NATS.

Tests cover JSON round-trip, remote-error propagation, terminate routing,
and no-responder failure mode.

Bead: mulga-siv-157 (sys.* fan-out portion completes the bead).
2026-05-29 23:24:24 +10:00
Julian Sommer fbd5e3a607 fix: reconciler emits configured dns_server instead of hardcoded 8.8.8.8 (#265) 2026-05-29 12:23:03 +10:00
Julian Sommer 10728391af chore: revert test-cover Makefile 2026-05-29 11:38:42 +10:00
Julian Sommer e4b7834b2b chore: move input validation into gateway (#264) 2026-05-29 11:17:12 +10:00
Julian Sommer 94aa482931 feat: sts v1 (#261) 2026-05-29 10:42:37 +10:00
Josh Siviour 65ba60e8bc fix(gateway/ec2): fan out DescribeInstanceAttribute across daemons (#263)
* fix(gateway/ec2): fan out DescribeInstanceAttribute across daemons

DescribeInstanceAttribute was queue-grouped on `spinifex-workers`, so each
request landed on one daemon at random. The handler only consults per-daemon
state (vmMgr / stoppedStore), so 1/N requests succeeded and the rest returned
InvalidInstanceID.NotFound. Terraform's AWS provider refreshes aws_instance
with three attribute reads per cycle, which on a 5-node cluster fails ~99% of
the time.

Move the daemon subscription into the fan-out block (no queue group) and
rewrite the gateway to mirror DescribeInstances: inbox + N replies + drop
NotFound. The aggregator returns the first parsed success, falls back to
NotFound only when every daemon confirms absence, and propagates deterministic
client errors (InvalidParameterValue, MissingParameter) when no daemon
succeeds.

Tests:
- New: MultipleNodes_OwnerWins, AllNodesNotFound, ClientErrorPropagates,
  NoResponders, SingleNode (rewritten without queue group).
- daemon_handlers_test.go: drop the `spinifex-workers` queue-group string from
  the ten DescribeInstanceAttribute subscriptions so the tests match the new
  production wiring.

* fix(ec2): DescribeInstanceTypes returns supported set, not capacity-gated

The no-filter DescribeInstanceTypes response was filtered to types with
at least one free slot. AWS-compatible callers (e.g. the Terraform AWS
provider's post-RunInstances metadata lookup) expect a stable supported
set: when a launch consumes the last free slot of its type, the next
DescribeInstanceTypes for that type must still return it, or the apply
fails with "reading EC2 Instance Type (X): empty result".

Split the API into two ResourceManager methods so capacity semantics
stay strict for the capacity=true filter (used for cluster aggregation)
while default callers get the supported answer:

- GetAvailableInstanceTypeInfos(showCapacity bool) — unchanged; gates on
  remaining schedulable CPU/memory and (for GPU types) free GPU pool
  slots. Used when capacity=true is requested.
- GetSupportedInstanceTypeInfos() — new; lists every loaded instance
  type once, regardless of current allocation or GPU pool state. Still
  skips system types ("sys.*") and entries with incomplete CPU/memory
  metadata.

The handler picks between the two based on the capacity filter. The
gateway aggregator (DescribeInstanceTypes.go) needs no change — it
already deduplicates by type name when capacity=false, so requesting a
specific name now resolves from whichever node supports it.

Tests:
- Daemon unit tests for the new method cover the three filter rules
  (no free slots, system/incomplete skip, GPU without manager).
- Handler tests assert the supported path is hit by default and the
  capacity-gated path is hit only when capacity=true.
- The NATS-driven integration test is split into two subtests: one
  proves the no-filter response is invariant under allocation, the
  other proves capacity=true still gates on remaining resources.

* fix: persist API device name in volume metadata on AttachVolume

aws_volume_attachment polls DescribeVolumes with
attachment.device=/dev/sd[f-p], the API-form name. Spinifex was
persisting the in-guest virtio path (/dev/vd*) via UpdateVolumeState
after AttachVolume, so the filter rejected every attached volume and
the post-attach wait loop failed with "couldn't find resource".

UpdateVolumeState now receives the API-form device name and the
daemon's AttachVolume response echoes DeviceName instead of
GuestDevice. BlockDeviceMappings continues to carry the guest path
(mulga-599 / PR #55) since UpdateGuestDeviceNames re-applies that
convention on every Launch/Start; only the volume-metadata path
diverges to the API form for the AWS-spec round-trip.

Adds fakeVolumeStateUpdater to manager_deps_test.go and a regression
test that asserts the API-form name flows through
UpdateVolumeState while BDM keeps the guest path.

Resolves mulga-siv-154.
2026-05-28 23:43:39 +10:00
Josh Siviour f877f5edef feat(network/external): restore source=dhcp on external pools (#262)
* refactor(network/external): introduce Allocator interface + StaticPoolAllocator

Carve external IP allocation out of handlers/ec2/vpc/external_ipam.go into
network/external/static_pool.go behind a typed Allocator interface. The KV
bucket name, schema, CAS retry count, and gateway-IP reservation semantics
are unchanged so existing static-pool data carries over with no manual
migration.

ExternalIPAM stays in handlers/ec2/vpc (L5 cleanup deferred per plan) but
becomes a thin facade that constructs a StaticPoolAllocator and delegates
Allocate / Release / GetPoolRecord through the new interface. Q4 will plug
DHCPPoolAllocator into the same Allocator slot.

Plan: docs/development/improvements/external-pool-dhcp-source.md (Q1).

* feat(network/external/dhcp): package skeleton — Client + Fake + KV store

New network/external/dhcp/ package, restored from the structure deleted
in Slice C (commit 7ff013a) but rehomed under the ADR-0006 layer tree:

- client.go      Client interface + AcquireRequest + Lease (with
                 RenewAt/RebindAt/ExpiresAt helpers).
- nclient4.go    NClient4Client — AF_PACKET DORA / Renew / Release
                 backed by insomniacslk/dhcp/dhcpv4/nclient4.
- fake.go        In-memory test fake with per-step hooks for fault
                 injection and acquire/renew/release call counters.
- kv.go          Store over the per-AZ spinifex-dhcp-leases-<az> KV
                 bucket: Put / Get / Delete / List. Schema matches the
                 plan (absolute timestamps for acquired/t1/t2/expiry).
- *_test.go      Lease-timer arithmetic, fake lifecycle, nclient4 input
                 validation, KV roundtrip + version-key isolation.

Not yet wired into vpcd, ExternalIPAM, or any allocator — Q3/Q4 plug
it in. Plan: docs/development/improvements/external-pool-dhcp-source.md (Q2).

* feat(network/external/dhcp): DHCPManager + daemon-side NATSClient

Adds the per-vpcd DHCPManager that owns active lease state: KV scan on
startup (re-issues RENEW for non-expired entries, drops the rest),
per-lease renewal goroutines with jittered T1/T2/expiry timers and
post-expiry re-DORA, and NATS request/reply subscribers on
vpc.dhcp.acquire / vpc.dhcp.release. Acquire is idempotent on client-id,
coalesced via singleflight so concurrent retries do not produce
duplicate wire DORAs.

Adds the daemon-side dhcp.NATSClient wrapper (RequestAcquire /
RequestRelease) that ExternalIPAM will route DHCP-sourced allocations
through in Q4. Still no allocator wiring — vpcd does not yet construct
the manager.

* feat(network/external/dhcp): wire DHCP allocators; re-allow source=dhcp

Adds DHCPGatewayLRPAllocator and DHCPPoolAllocator, the two consumers
of the Q3 DHCPManager. The gateway-LRP allocator runs in-process inside
vpcd and DORAs once per VPC (client-id `dhcp-gw-lrp-<vpcID>`, used in
centralized NAT). The pool allocator runs daemon-side and routes
per-EIP / ENI / instance acquisitions through vpcd over the restored
vpc.dhcp.acquire / vpc.dhcp.release topics.

ExternalIPAM now dispatches per-pool: static pools keep the existing
StaticPoolAllocator + KV bucket; DHCP-sourced pools attach a
DHCPPoolAllocator via EnableDHCP. vpcd constructs the dhcp.Manager
when any pool has Source="dhcp" and selects the DHCPGatewayLRPAllocator
for IGWManager; everything else keeps StaticRangeAllocator /
LinkLocalAllocator.

Config validator re-allows source="dhcp", requires bind_bridge, and
rejects range_* / gw_lrp_range_* keys on DHCP pools. Unknown sources
are rejected with the supported list. Allocator.Release uses a
server-side IP→client-id lookup so the existing (pool, ip) signature
keeps working for both static and DHCP pools.

Plan: docs/development/improvements/external-pool-dhcp-source.md (Q4)

* feat(spx admin init): restore --external-source=dhcp + --external-bind-bridge

Source defaults to dhcp when --external-pool is omitted; static remains the
default when a pool range is supplied. Adds PoolBindBridge propagation through
ConfigSettings + formation.NetworkConfig + spinifex.toml template so vpcd's
DHCPManager finds the bridge to DORA on across single-node and joiner paths.

* fix(network/external/dhcp): derive chaddr from client-id when HWAddr unset

DHCPGatewayLRPAllocator and DHCPPoolAllocator (first acquire) own no NIC
and pass an empty HWAddr to the manager. nclient4 rejected the request
with "dhcp acquire: hw_addr is required", breaking nightly cells 1/3/8/10
at AttachIGW. Derive the chaddr from the client-id via utils.HashMAC so
the upstream DHCP server sees a stable 02:xx:xx:xx:xx:xx MAC per AWS
identifier, matching the OVN-side MAC scheme operators already grep for.

* fix(network/external/dhcp): enable IFF_PROMISC on bind_bridge across DORA

Nightly cell 1 timed out at OFFER step with "no matching response packet
received". Upstream router replied unicast to the derived 02:xx chaddr
which the Linux bridge had never learned, so the kernel dropped the
frame before nclient4's AF_PACKET socket could see it.

Reference-counted IFF_PROMISC toggle around every Acquire/Renew/Release
so overlapping leases on the same bridge share one flag flip and the
last release restores prior state. Soft-fails to a warn log when
CAP_NET_ADMIN is unavailable — operator may already have promisc set.

* fix(network/external/dhcp): RFC 2131 retransmission — retry DISCOVER 3×

NClient4 was configured with WithRetry(1) + WithTimeout(60s), meaning a
single DISCOVER per Acquire. When the first frame is lost (STP convergence
on a freshly-up bridge, upstream packet loss) the call blocks the full
60s and fails. Nightly cell 1 hit this at startup: gw-lrp Acquire for two
VPCs timed out back-to-back before any e2e step ran.

Switch to WithRetry(3) with a 5s per-attempt seed; nclient4 doubles the
deadline on each retransmission (5s, 10s, 20s) and sends a fresh DISCOVER
each round. Total budget ~35s, three frames on the wire — recoverable
without the higher layer noticing.

* fix(network/external): three CI-blocking extdhcp bugs

DHCP-source pools failed e2e-nightly cells 1/3/8/10 (net: dhcp) after
the restore-dhcp-source branch landed. Three independent root causes:

* gateway nexthop dropped: GatewayIPAllocator.Allocate had no return
  channel for the upstream router learned from lease.Routers. igw fell
  back to link-local 169.254.0.2, unreachable on the WAN subnet, so
  instance egress and SSH-in died silently. Widen the interface to
  return nexthop; DHCP impl pulls Routers[0]; empty Routers is now a
  hard error. Static impl returns pool.Gateway as before.

* DORA budget shrunk 6x: WithTimeout(5s) x WithRetry(3) gave a 15 s
  wallclock for the full DORA, no outer backoff. Restore the deleted
  dhcp_manager.go schedule [4s, 8s, 16s, 32s] under a 90 s budget with
  +/- 1 s per-attempt jitter; drop nclient4Retries to 1 so the leaf
  stays single-shot and the budget arithmetic lives in one place.

* every vpcd subscribed to vpc.dhcp.acquire: plain nc.Subscribe meant
  every vpcd in real-multi cells DORA'd in parallel with the same
  derived chaddr, causing upstream-server NAK / lease leak. Switch to
  QueueSubscribe with per-AZ queue "vpcd-dhcp-workers-<az>".

Plan: docs/development/bugs/extdhcp-pool-fixes.md (mulga).
Bead: mulga-siv-150.
2026-05-28 20:48:11 +10:00
Julian Sommer 621e49405e fix: UI bug fixes 2026-05-28 15:08:17 +10:00
tomnewton-mulga 88881ba04c fix: Updated Trinity and SMCI docs 2026-05-28 14:42:03 +10:00
Julian Sommer 7ff02a6020 fix: consistent UI capitlisation 2026-05-28 14:27:35 +10:00
tomnewton-mulga 624c3180ea docs: fix gpu-passthrough doc sections and image tags for docs site 2026-05-28 11:41:00 +10:00
tomnewton-mulga 78d703da78 Feat/mig capable (#260)
* feat(gpu): add NVIDIA MIG multi-instance GPU support

Partitions MIG-capable GPUs (A100, A30, H100) into isolated slices exposed
as mdev devices instead of whole-GPU VFIO passthrough. VMs attach via
-device vfio-pci,sysfsdev=<mdev-path> using the GPUAttachment discriminated
union (PCIAddress for whole-GPU, MdevPath for MIG).

Key changes:
- gpu: new MIGProfile/MIGInstance types, IsMIGCapable model map, mig.go ops
  (EnableMIGMode, DisableMIGMode, CreateInstances, ListInstances,
  DestroyAllInstances, enrichMdevPaths)
- gpu.Manager: Claim() returns (*GPUDevice, *MIGInstance, error); AddMIGInstances,
  ReclaimByMdev for restart recovery; MarkMIGFailed per-slice
- daemon: buildGPUPool helper partitions devices into whole-GPU vs MIG pools;
  MIGProfile config field selects profile per node or per GPU override
- vm.VM: GPUAttachments []gpu.GPUAttachment replaces GPUPCIAddresses []string
- admin CLI: spx admin gpu mig status/enable/disable subcommands with
  admin.SetMIGProfile for spinifex.toml writes
- docs: COMMANDS.md updated with three new MIG management command rows

* test(gpu): unit tests for MIG parsers and pool manager

Cover all pure parsing functions (parseMIGMemory, isCapacityExhaustedError,
extractProfileFields/GIFields, parseMIGProfiles, parseMIGInstances,
parseCreatedGIID/CIID) with realistic nvidia-smi fixture output.

Manager MIG tests cover AddMIGInstances, Claim/Release on mdev-backed entries,
MarkMIGFailed, ReclaimByMdev, and interaction with whole-GPU MarkFailed — all
using fake mdev directories in t.TempDir() so no GPU hardware is required.

* feat(gpu): dynamic MIG profile-per-request with on-demand GI/CI lifecycle

Replaces static config-driven MIG pre-carving with a dynamic model where
the MIG profile is specified per RunInstances call via mig.<profile>
instance type names (e.g. mig.1g.10gb) that mirror nvidia-smi nomenclature
directly.

- Manager.Claim(instanceID, profileName): fast path claims a pre-carved
  slice; slow path dequeues a free MIG GPU, releases the lock, calls
  nvidia-smi mig -cgi/-cci to carve all slices, then reclaims the lock to
  commit and return the first slice
- Manager.Release: last release on a dynamically-carved GPU calls
  DestroyAllInstances and returns the GPU to freeMIGGPUs, allowing a
  different profile on the next claim
- AddMIGGPU(dev): registers a fresh MIG GPU for dynamic allocation;
  AddMIGInstances (restart recovery) keeps its no-destroy semantics
- GenerateMIGTypes: produces mig.<profile> InstanceTypeInfo entries from
  gpu.ListProfiles at daemon startup; IsMIGType/MIGProfileFromType let
  callers inspect these names
- buildGPUPool: calls ListInstances for recovery, AddMIGGPU for fresh
  GPUs, ListProfiles to collect MIG profile specs for type generation;
  removes setupMIGInstances and migProfileForDevice
- GPUClaimer.Claim gains profileName param; daemonGPUClaimer, service_impl
  call sites, and fakeGPUClaimer all updated

* fix(gpu): correct mdev parent detection and make --profile optional for mig enable

enrichMdevPaths was using os.Readlink on "parent_bus_0" (a non-existent
sysfs attribute) and then on ".." (a directory, not a symlink) — both
would fail on real NVIDIA hardware, breaking CreateInstances and
ListInstances on daemon restart recovery. Fix: read the mdev UUID's own
symlink target, which always encodes the parent GPU's PCI address in its
path (e.g. .../0000:01:00.0/<uuid>).

Make basePath injectable in enrichMdevPaths so the fix is covered by a
new set of unit tests using temp-dir symlinks (TestEnrichMdevPaths_*).

Remove the MarkFlagRequired("profile") from "spx admin gpu mig enable":
Phase 2 is fully dynamic — the daemon carves slices on demand at
RunInstances time. Without --profile, the command just enables MIG mode
and destroys any pre-carved slices so the daemon starts with a free GPU.
With --profile, behaviour is unchanged (pre-carve a static pool).

* chore: writeups for smci and arcee

* test: add coverage for MIG exec, manager slow-path, instancetypes and admin

- Add mig_exec_test.go with fake nvidia-smi harness covering Enable/Disable MIGMode,
  IsMIGModeEnabled, DestroyAllInstances, ListProfiles, CreateInstances, ListInstances
- Extend manager_mig_test.go with slow-path Claim/Release tests using fake nvidia-smi
- Add generate_test.go coverage for IsMIGType, MIGProfileFromType, GenerateMIGTypes
- Add admin_test.go coverage for SetMIGProfile (create, update, idempotent, error paths)
- Make mdevBasePath a var to allow sysfs injection in tests
- Add json tags to GPUAttachment fields to satisfy musttag linter

Diff coverage: 75.6% (threshold: 60%).

* fixed gpu passthrough doc

* Revert "fixed gpu passthrough doc"

This reverts commit 1e20f5ab13.
2026-05-28 11:38:40 +10:00
Josh Siviour fa09228a36 feat: ENI hot-plug Sprint 3b — live QMP pipeline + AttachENI wiring (stage 2) (#258)
* feat: ENI hot-plug Sprint 3a scaffolding

Lays the Sprint 3a Tier 0 groundwork from
docs/development/feature/enihotplug-v1.md so the live QMP pipeline in 3b
can plug in without further plumbing changes.

- types.ENIRequests: per-VM PCIe slot allocator state (free-list +
  eniID→slot reverse map), mirrors EBSRequests pattern.
- instancetypes.MaxENIsForType / HotPlugENISlotsForType: instance-type →
  ENI cap lookup with explicit-type-then-family-then-default precedence.
- vm.DeviceController interface + QMPDeviceController (real) +
  StubDeviceController (in-memory): unit-testable QMP device_add /
  device_del / netdev_add / netdev_del surface. QueryPCI body lands in
  Sprint 3b alongside the live response parser.
- buildBaseVMConfig now accepts the instance type and emits two distinct
  PCIe root-port pools at boot: hotplug-ebs{1..11} (fixed, /dev/sd[f-p])
  and hotplug-eni{1..N} (per-type, N = MaxENIs - 1).
- volumes.go bus id renamed hotplug%d → hotplug-ebs%d; query-block QDev
  regex narrowed to hotplug-ebs(\d+) so future virtio-net devices on
  hotplug-eni ports cannot collide with the EBS ordering.
- restore + ResetNodeLocalState reset ENIRequests.Mu alongside the EBS
  mutex so restored VMs start with a fresh allocator.

Closes mulga-cs-enihotplug-3a.

* feat: ENI hot-plug Sprint 3b — live QMP pipeline + AttachENI wiring

Lands the Sprint 3b Tier 1 deliverables from
docs/development/feature/enihotplug-v1.md so AttachNetworkInterface /
DetachNetworkInterface AWS APIs drive the host-side QMP pipeline against
a running VM.

- vm/qmp_device.go: real QueryPCI parser. Walks the per-bus query-pci
  response and recurses into pci_bridge.devices so virtio-net devices
  attached under hotplug-eni{N} root ports surface in the flat result.
- vm/eni_hotplug.go (new): HotPlugENI / HotUnplugENI on Manager. 4-step
  pipeline (tap → ovs-add-port → netdev_add → device_add + query-pci
  poll) with full rollback on step-N failure. Tap + OVS calls are
  stubbed in Sprint 3b; Sprint 3c drops in real NetworkPlumber /
  ovs-vsctl invocations against the same call-site interface.
- vm/eni_hotplug_test.go (new): unit tests with StubDeviceController —
  success path, rollback on each step, AttachmentLimitExceeded,
  idempotent re-attach, non-running rejection, force-detach semantics,
  concurrent serialization, query-pci timeout.
- types/ec2.go: EC2InstanceCommand gains AttachENI / DetachENI attrs +
  AttachENIData / DetachENIData payloads, mirroring the AttachVolume
  command surface.
- handlers/ec2/vpc/eni.go: ENIRecord gains AttachmentStatus,
  HotPlugSlot, LastAttachError, DetachInFlight, DetachForce fields per
  plan §State machine. New helpers: GetENIRecord, UpdateENI (CAS
  patcher), FindENIByAttachment (used by DetachNetworkInterface lookup
  by attachmentId).
- gateway/ec2/vpc/{Attach,Detach}NetworkInterface.go (new): AWS SDK
  request decoders that route via the per-instance ec2.cmd.{id}
  channel; DetachNetworkInterface resolves attachmentId →
  instanceId via DescribeNetworkInterfaces first.
- gateway/ec2.go: registers the two new EC2 actions in the dispatcher.
- daemon/daemon_handlers.go: handleEC2Events dispatches the two new
  attribute flags to per-instance handlers.
- daemon/daemon_handlers_eni.go (new): composes vpcService.AttachENI
  (KV) with vmMgr.HotPlugENI (QMP) and publishes
  vpc.eni-hotplug.{attached,detached}.{instanceID} on success. KV
  state machine: available → attaching → attached → detaching →
  available. On QMP failure: KV rolls back to available with
  LastAttachError persisted for DescribeNetworkInterfaces visibility.
- vm/errors.go: ErrENINotAttached, ErrENIPipelineTimeout,
  ErrQMPUnavailable.

The Sprint 3c bead lands the real tap + ovs-vsctl wiring on the same
pipeline; 3d adds the reconciler that converges the attaching /
detaching KV states left behind by a daemon crash mid-pipeline.

Closes mulga-cs-enihotplug-3b.

* Update ec2.go

* Update ec2.go
2026-05-27 22:54:46 +10:00
Josh Siviour a8e356b3d2 feat: ENI hot-plug Sprint 3a scaffolding (#257)
Lays the Sprint 3a Tier 0 groundwork from
docs/development/feature/enihotplug-v1.md so the live QMP pipeline in 3b
can plug in without further plumbing changes.

- types.ENIRequests: per-VM PCIe slot allocator state (free-list +
  eniID→slot reverse map), mirrors EBSRequests pattern.
- instancetypes.MaxENIsForType / HotPlugENISlotsForType: instance-type →
  ENI cap lookup with explicit-type-then-family-then-default precedence.
- vm.DeviceController interface + QMPDeviceController (real) +
  StubDeviceController (in-memory): unit-testable QMP device_add /
  device_del / netdev_add / netdev_del surface. QueryPCI body lands in
  Sprint 3b alongside the live response parser.
- buildBaseVMConfig now accepts the instance type and emits two distinct
  PCIe root-port pools at boot: hotplug-ebs{1..11} (fixed, /dev/sd[f-p])
  and hotplug-eni{1..N} (per-type, N = MaxENIs - 1).
- volumes.go bus id renamed hotplug%d → hotplug-ebs%d; query-block QDev
  regex narrowed to hotplug-ebs(\d+) so future virtio-net devices on
  hotplug-eni ports cannot collide with the EBS ordering.
- restore + ResetNodeLocalState reset ENIRequests.Mu alongside the EBS
  mutex so restored VMs start with a fresh allocator.

Closes mulga-cs-enihotplug-3a.
2026-05-27 22:15:20 +10:00
Josh Siviour fd3dc6b680 feat(network): ADR-0006 layer redesign + IPsec/OVN hardening (#255)
* refactor(vpcd): remove macvlan bridge mode

Macvlan was a single-NIC SSH-safe hack that predated veth mode and was
never selected by any active config path. Deletion is Phase 0 item 1 of
the network layer redesign (mulga-siv-125.1).

Removed:
- BridgeModeMacvlan const + setMacvlanMAC + ifaceIsMacvlan probe
- Macvlan MAC alignment block in launchService (only fired on dead path)
- Macvlan branch in detectBridgeMode + matching tests
- Macvlan duplicates of IGW/AddNAT centralized-NAT tests (veth versions
  already cover the same path)
- --macvlan flag + macvlan case branch in setup-ovn.sh

Refactored:
- useCentralizedNAT() now == BridgeModeVeth (was != BridgeModeDirect);
  verifyBridgeMode already rejects empty/unknown at startup
- Three IGW-pool tests now explicitly WithBridgeMode(BridgeModeVeth)
  (previously relied on empty-mode defaulting to centralized)

Kept intentionally:
- Deprecation notes in docs/compute/vpc-networking/README.md
- Legacy macvlan-interface cleanup in reset-dev-env.sh + ansible
  teardown role (migration for operator systems with stale spx-ext-*
  interfaces from pre-veth installs)

* feat(vpcd): resolve external CIDR at startup with bounded retry

Block service start until the WAN bridge has an IPv4 address, with a 30s
retry window. Phase 0 item 2 of the network layer redesign
(mulga-siv-125.1). Guards the boot race where vpcd starts before
systemd-networkd finishes DHCP on br-wan, or before netplan applies a
static config.

Skipped when ExternalMode is empty (overlay-only deployments).

Forward-compatible with ADR-0006: the new resolveExternalCIDR /
externalCIDRFromBridge helpers become the backing implementation of
HostWiring.ExternalCIDR() once the network/host package lands in Phase 1.
Until then the resolved value is only used to fail-start on missing
uplink.

* fix(vpcd): cover external CIDR startup paths

Extract launchService's CIDR-resolve block to ensureExternalCIDRReady
helper so it's unit-testable, and add direct tests for
externalCIDRFromBridge (loopback + unknown-interface) to exercise the
original literal body. Closes the diff-coverage gap on Phase 0 commit
15b564d which left these paths unreachable from unit tests.

* refactor(network/ovn): extract L1 OVN client to network/ovn package

Move OVNClient interface, LiveOVNClient, ACLSpec, ErrNATNotFound and
namedUUID helper from services/vpcd to a new spinifex/network/ovn
package. The mock relocates to spinifex/network/ovn/mock as a
sub-package. Storage fields on the mock become exported so vpcd tests
can continue to seed/inspect state across the new package boundary.

services/vpcd retains thin type-alias and var-alias shims so existing
callers compile unchanged; Phase 2 removes those shims as vpcd is torn
down.

Adds -coverpkg=./spinifex/... to test-cover so cross-package test
exercise of mock methods continues to count toward coverage now that
the mock lives outside vpcd.

First sub-step of Phase 1 of docs/development/feature/spinifex-network-redesign.md
(bead mulga-siv-125.2). Pure refactor; no functional change. All
existing tests pass; preflight green.

* feat(network/topology): TopologyManager interface + vpcd adapter

Add spinifex/network/topology with Manager interface, VPC/Subnet/Port
specs, and OVN object naming helpers covering the L2 stable contract
(§7 of the redesign plan). Phase 1 keeps existing in-tree OVN names
(rtr-, ext-port-, sg_) and notes the §7.3 targets for a later
reconciliation migration.

TopologyHandler in services/vpcd grows EnsureVPC / DeleteVPC /
EnsureSubnet / DeleteSubnet / EnsurePort / DeletePort /
SetPortSecurityGroups methods that mirror the corresponding NATS
handlers' OVN ops with Spec inputs instead of nats.Msg envelopes,
satisfying topology.Manager via a compile-time check. Existing handlers
are unchanged; the duplication is short-lived (Phase 2 collapses them).

Smoke tests in topology_manager_test.go cover the idempotent create
path, delete cascade, and declarative SG membership move. Deliverable:
TopologyManager.EnsureVPC/Subnet/Port callable; all existing tests
green.

Second sub-step of Phase 1 of docs/development/feature/spinifex-network-redesign.md
(bead mulga-siv-125.2).

* feat(network/host): L0 HostWiring interface + physical/veth impls

Phase 1 deliverable for mulga-siv-125.2. Introduces network/host (L0 of
ADR-0006): UplinkMode enum, Wiring interface, and Physical/Veth
implementations with injectable Runner + InterfaceReader for testability.
Not yet wired into daemon/vpcd — Phase 2 replaces daemon.SetupComputeNode
and vpcd's resolveExternalCIDR with calls through this package.

* feat(network/policy): L3 SecurityGroup/NAT/Route managers

Introduce network/policy as the data-plane policy layer (ADR-0006 L3).
Three managers, all backed by network/ovn:

- SecurityGroupManager: clear+add ACL replacement on an existing port
  group; port group lifecycle stays with L2 (topology.Manager).
- NATManager: bound to one NATMode at construction (derived from
  host.UplinkMode). Distributed sets logical_port/external_mac on EIP
  dnat_and_snat rules; centralised leaves them unset. Per-EIP, NAT GW,
  and IGW-default SNAT entrypoints; all deletes idempotent.
- RouteManager: AddStaticRoute is read-then-decide so retries no longer
  duplicate rows (closing the long-standing non-idempotency bug in
  vpcd/topology.go's IGW default-route path).

ACL priority constants and infrastructure ACL builders move from
vpcd/{security,acl}.go into policy/acl.go with their semantics preserved
(default-deny 900/800 logged, DHCP-allow 1050, tenant 1000). No vpcd
wiring changes here — additive only; the reconciler (bead .3.2) and
network/external (bead .3.4) will consume these managers.

bead: mulga-siv-125.3.1

* feat(network/external): L5 IGW/EIP/NATGW managers

Pure additive — new package, no topology.Manager changes, no rewiring of
existing vpcd handlers. IGWManager builds the external switch + localnet +
gateway LRP + default route + chassis HA, with a flows barrier callers can
inject (production: ovn-nbctl --wait=hv sync). EIP/NATGW are thin L5
wrappers over policy.NATManager. GatewayIPAllocator interface lets the
DHCP-backed allocator stay in vpcd until the DHCP-mgr removal bead lands;
LinkLocalAllocator + StaticRangeAllocator ship here.

* feat(network/reconcile): L6 intent-actual reconciler replaces 5-pass startup

Build spinifex/spinifex/network/reconcile/ package per Phase 2.2 of the
network layer redesign. Reads desired state from NATS KV filtered by
vpc.AZ (with legacy empty-AZ matches-all rule), scans actual state from
OVN NB DB, diffs and applies with VPC → Subnet → Port → SG → IGW →
EIP/NATGW topological ordering.

Atomic port-group join via CreateLogicalSwitchPortInGroups closes the
racy gap the legacy scanENIPortMembership pass used to revive 30s later.
A 5-minute drift goroutine (leader-gated via JetStream KV CAS) replaces
the old 30s ReconcileSGsLoop.

Adds AZ field to VPCRecord stamped at create time. Empty AZ on pre-Phase
2.2 records matches every local AZ, so no migration pass is required.

Replaces and removes the 5-pass startup sequence in services/vpcd:
  - Reconcile + ReconcileFromKV
  - ReconcileSGsOnce + ReconcileSGsLoop
  - RetrofitAllExternalLocalnetOptions
  - RetrofitAllGatewayPortNetworks

IGW fresh-create continues to flow through the legacy NATS handler path
(handleIGWAttach) so the dhcpManager wiring stays intact until Phase
2.3; the reconciler only rebinds gateway-chassis on existing IGW
topology.

Closes mulga-siv-125.3.2.

* refactor(network/topology): promote live Manager impl to network/topology

Move the topology.Manager implementation out of services/vpcd/topology_manager.go
into network/topology/manager_live.go as a standalone liveManager constructed
via NewLiveManager(ovn.Client, opts...). Inputs that varied per-handler
(DNS server resolution from the external pool) are now passed as functional
options (WithDNSServer).

TopologyHandler still satisfies topology.Manager but its methods delegate
to a cached liveManager. The shim stays until the rest of mulga-siv-129
routes NATS subscribers through topology.Manager directly.

Also exports SubnetGatewayCIDR + FormatDNSServerList from network/topology
so the reconciler in network/reconcile can share helpers with the live
manager (audit pass to follow per plan §6).

Test cases ported one-for-one from services/vpcd/topology_manager_test.go
to network/topology/manager_live_test.go; the vpcd-side test file is
deleted. New WithDNSServer test asserts the option propagates into
DHCPOptions rows.

Refs mulga-siv-129 (Phase 2.6 vpcd subscriber migration, Slice 1 of N).

* refactor(vpcd): collapse SG subscribers onto network/{topology,policy} managers

Phase 2.6 / mulga-siv-129 slice 2. Rewrites the vpc.create-sg /
vpc.update-sg / vpc.delete-sg NATS subscribers in services/vpcd as
thin wrappers over topology.Manager (port group lifecycle) and
policy.SecurityGroupManager (ACL programming). Deletes the ~1000
lines of duplicated ACL builder and provisioning logic — its
authoritative copy lives in network/policy.

Surface additions:

- ovn.ErrPortGroupNotFound sentinel; mock + live GetPortGroup wrap
  not-found via fmt.Errorf("%w: ...") so callers can use errors.Is.
- topology.Manager grows EnsureSGPortGroup / DeleteSGPortGroup
  (resolves the apply.go TODO from §2.1 of the redesign plan).
  liveManager implements them with GetPortGroup-then-Create /
  GetPortGroup-then-Clear+Delete idempotency.

Behaviour delta: the old provisionSG rolled back the OVN port group
on AddACLs failure ("all-or-nothing"). The new path lets the port
group survive and relies on network/reconcile to retry EnsureSG on
its next sweep. The all-or-nothing fail-fast test cases are dropped;
ACL emission semantics are covered by network/policy/sg_test.go.

* refactor(vpcd): migrate VPC/Subnet/ENI subscribers to topology.Manager

Phase 2.6 / mulga-siv-129 slice 3. Rewrites vpc.create / vpc.delete /
vpc.create-subnet / vpc.delete-subnet / vpc.create-port /
vpc.delete-port / vpc.update-port-sgs NATS subscribers as thin
wrappers (≤30 lines each) over topology.Manager. Subscriber bodies
now: parse event → translate to spec → delegate to manager.

Deletes:

- vpcd.subnetGateway (and helpers_test.go + topology_test.go's
  TestSubnetGateway block); parity coverage lives in
  network/topology.TestSubnetGatewayCIDR.
- vpcd.reconcilePortSGs (the live manager already exposes the same
  diff-then-transact via reconcilePortSGs on the L2 side).
- vpcd.TopologyHandler.dnsServer; lmDNSServer (passed via
  topology.WithDNSServer to the live manager) is the live path.

Behaviour delta: handlers now strict-parse PrivateIpAddress /
MacAddress / CidrBlock at the NATS boundary and reject malformed
payloads instead of degrading to "no DHCP options". The reconciler
guarantees that field validity is upheld upstream of the publish.

* refactor(network/reconcile): drive SG port groups via topology.Manager

applySGs now calls topology.Manager.EnsureSGPortGroup /
DeleteSGPortGroupByName instead of reaching directly into the OVN client.
Reconcile.Config gains a Topology field (required); the production caller
in services/vpcd passes the TopologyHandler shim, the reconcile package
tests construct a fresh topology.NewLiveManager bound to the mock OVN
client.

DeleteSGPortGroupByName is added to the topology.Manager interface for the
reconciler's orphan-removal path, which iterates raw OVN port-group names
without a groupID round-trip. The existing DeleteSGPortGroup(groupID)
delegates to it.

Resolves the TODO at apply.go:137 carried over from slice 2 of
mulga-siv-129.

* feat(network/policy): add FlowsBarrier option to NATManager

NATManager.AddEIP needs to block on `ovn-nbctl --wait=hv sync` after
writing the dnat_and_snat row so the NATS reply only returns once every
chassis has the rewrite flow. The legacy vpc.add-nat handler does this at
the tail of handleAddNAT; the manager has lacked it since slice 2.1.

NewNATManager grows a functional-options tail (Option / WithFlowsBarrier).
Defaults to a no-op for tests; production wiring in services/vpcd passes
waitForFlowsHV. AddEIP now invokes the barrier after AddNAT, logging at
warn level on failure to match the legacy handler's _ = waitForFlowsHV()
swallowing semantics.

Pre-work for slice 4 of mulga-siv-129 — without the barrier the NAT EIP
subscriber migration would lose the post-write flow-install gate.

* refactor(vpcd): migrate NAT GW subscribers to policy.NATManager

handleAddNATGateway / handleDeleteNATGateway now decode the NATS event
and delegate to policy.NATManager.{AddNATGateway,DeleteNATGateway}. The
manager carries identical OVN logic — same snat type, same ExternalIDs
(spinifex:vpc_id + spinifex:nat_gateway_id) — so existing
TestTopologyHandler_AddNATGateway / DeleteNATGateway suites stay green.

Lazy-constructed NATManager added on TopologyHandler (natmOnce). Mode is
derived from the bridge mode (veth → centralized, direct → distributed)
and the FlowsBarrier is bound to waitForFlowsHV for parity with the
legacy EIP path's tail barrier (used by the next slice, vpc.add-nat).

Slice 4 of mulga-siv-129, sub-step b.

* refactor(vpcd): migrate EIP NAT subscribers to policy.NATManager

handleAddNAT / handleDeleteNAT now decode the NATS event and delegate to
policy.NATManager.{AddEIP,DeleteEIP}. The behavioural contract is
preserved end-to-end:

- Idempotency-skip on a matching dnat_and_snat row (same VPC + LogicalIP,
  plus MAC + LogicalPort under distributed NAT) moves into AddEIP so
  duplicate vpc.add-nat publishes never tear down the rule the previous
  invocation just installed (mulga-siv-124).
- Stale-rule cleanup across every router stays in AddEIP.
- Post-write waitForFlowsHV barrier is now driven by the manager via the
  FlowsBarrier option wired in services/vpcd (slice 4a).
- DeleteEIP returns nil on ErrNATNotFound, matching the handler's
  fire-and-forget "already absent = success" semantic.

The vpcd "errors" import drops out with the inline error-comparison.

Slice 4 of mulga-siv-129, sub-step c.

* refactor(vpcd): migrate IGW subscribers to external.IGWManager (partial)

handleIGWAttach now branches at decode time:
- Centralised NAT + DHCP-sourced pool keeps using attachIGWLegacy because
  the gateway-LRP IP comes from vpc.dhcp.acquire, and that DHCP client is
  the vpcd-local manager that bead mulga-siv-125.3.3 will retire.
- All other shapes (distributed NAT, static-range pool, no pool)
  delegate to external.IGWManager.AttachIGW. The manager mirrors every
  step the legacy handler ran — external switch + localnet port +
  gateway LRP + switch-side router peer + default route + chassis HA +
  waitForFlowsHV tail barrier — so observable behaviour is unchanged.

handleIGWDetach drops its inline OVN walk entirely; both paths exit
through external.IGWManager.DetachIGW followed by releaseGatewayLRPLease.
The lease release stays in vpcd because the upstream client lives in
services/vpcd/dhcp; calling it after every detach is safe because the
upstream server expires unknown leases on its own.

Lazy-constructed IGWManager added on TopologyHandler (igwmOnce). Pool,
allocator (StaticRangeAllocator), chassis, NAT mode, NAT manager, route
manager, and FlowsBarrier are all derived from the existing
TopologyHandler state.

The DHCP-coupled legacy branch retains its full inline OVN logic; it
will disappear in slice-after-125.3.3 follow-on. Slice 4 of
mulga-siv-129, sub-step d.

* feat(ec2/instance): honor AssociatePublicIpAddress per-NIC override

RunInstances --network-interfaces "AssociatePublicIpAddress=true|false"
was silently ignored, only subnet.MapPublicIpOnLaunch gated public IP
assignment. AWS contract is per-NIC override wins.

Extend TestPrepareRunInstances_PublicIPAutoAssigned to table-driven
3 cases: subnet true no-override, subnet false override true, subnet
true override false.

* refactor(config): drop upstream-DHCP config surface (static WAN pool only)

Hard-error in LoadConfig on legacy keys: [network] external_dhcp,
[nodes.<n>.vpcd] dhcp_bind_bridge, and [[network.external_pools]]
source = "dhcp". Add pool range validation (must fit inside the
gateway/prefix_len CIDR; per-cluster non-overlap).

Strip ExternalDHCP from config.NetworkConfig + formation.NetworkConfig
+ admin.ConfigSettings, and DhcpBindBridge from config.VPCDConfig +
admin.ConfigSettings + vpcd.Config. vpcd.ExternalPoolConfig.IsDHCP()
short-circuits to false; the field and downstream DHCP branches in
external_ipam / topology.go remain for Slice C to delete together with
dhcp_manager.

cmd/spinifex/cmd/admin.go: drop useExternalDHCP path and the
detectedDhcpBindBridge helper; require --external-pool when
--external-mode=pool; auto-detect default no longer infers source=dhcp.

service.go's wan_bridge legacy-key rejection now advises removing the
key entirely (vpcd auto-detects br-wan), not renaming to
dhcp_bind_bridge.

Two topology_test cases that exercise the DHCP gateway-LRP path are
skipped — Slice C deletes attachIGWLegacy and these tests together.

* refactor(vpcd): delete dhcp_manager + DHCP gateway-LRP path (Slice C)

Removes upstream-DHCP-client gateway-LRP allocation in favor of static
WAN-pool allocation (StaticRangeAllocator). Part of bead mulga-siv-125.3.3.

- delete services/vpcd/dhcp_manager.go + dhcp/ subpackage (~865 lines)
- delete allocateGatewayLRPIPViaDHCP + releaseGatewayLRPLease paths
- drop ExternalPoolConfig.Source + DhcpBindBridge + IsDHCP() fields
- drop ExternalIPAllocation lease fields (Expiry/ServerID/HWAddr) and
  ExternalIPAMRecord.Source
- drop NewExternalIPAM nc *nats.Conn argument; update all 4 call sites
- rename verifyBridgeMode local dhcpBindBridge -> wanBridge; update error
  strings and tests

Slice D (delete attachIGWLegacy + dispatcher) intentionally deferred.

* refactor(vpcd): delete attachIGWLegacy + IsDHCP stub (Slice D)

Closes mulga-siv-129 slice 4d. Part of bead mulga-siv-125.3.3.

handleIGWAttach now unconditionally delegates to external.IGWManager.
attachIGWLegacy (~207 lines, ~5 OVN write steps + chassis scheduling)
deleted along with its dispatcher branch in handleIGWAttach. The
ExternalPoolConfig.IsDHCP() dead stub is gone.

No code paths or NATS subjects regress: the static-pool / distributed
branches were already going through external.IGWManager since Slice 4d
(6719dc3).

* feat(ipam): Purpose field on every IPAM record (Slice B)

Part of bead mulga-siv-125.3.3.

Adds a Purpose enum (handlers/ec2/vpc/purpose.go) tagging every IP
allocation with its semantic role (eni-primary, eni-public, eip,
igw-lrp, natgw-external, …) so multi-VPC clusters can reclaim/audit
by (owner, purpose).

External IPAM (spinifex-external-ipam, v1→v2):
- ExternalIPAllocation.Type → .Purpose; legacy values mapped via
  LegacyExternalTypeToPurpose (gateway→igw-lrp, auto_assign→eni-public,
  elastic_ip→eip). Unknown legacy values warn and become "unknown".
- AllocateIP / AllocateFromPool / allocateFromPool param rename
  allocType→purpose. All call sites pass Purpose* constants.
- migrate/002_external_ipam_purpose.go: idempotent CAS-retry migration
  with v1/v2-union record shape so partial migrations resume cleanly.

Internal IPAM (spinifex-vpc-ipam, v1→v2):
- IPAMRecord.Allocated []string → []IPEntry{IP, Purpose, OwnerID}.
- AllocateIP gains purpose+ownerID args; ReleaseIP unchanged (still
  keyed on IP).
- AllocatedIPs returns []IPEntry.
- eni.go reorders eniId generation before AllocateIP so the owning
  resource ID lands on the new entry.
- migrate/003_ipam_purpose.go: cross-bucket backfill that reads the
  ENI bucket via the new RunKVWithJetStream path. Orphaned IPs
  (no owning ENI) warn and become Purpose=unknown.

Migrate framework:
- KVContext gains an optional JetStream field; new
  Registry.RunKVWithJetStream method threads it through. RunKV
  signature unchanged — all other callers are unaffected.

Tests:
- ipam_test.go: AllocateIP signature updates; AllocatedIPs now asserts
  IP / Purpose / OwnerID on the returned entries.
- external_ipam_test.go: replaces "auto_assign"/"elastic_ip" literals
  with PurposeENIPublic/PurposeEIP and asserts alloc.Purpose.
- migrate/002 + 003 tests: schema-detect, idempotent rerun,
  no-JetStream fallback, empty bucket.

* refactor(daemon): tombstone obsolete spinifex-dhcp-leases KV bucket

The dhcp_manager removed in Phase 2.3 (bead mulga-siv-125.3.3) owned the
spinifex-dhcp-leases KV bucket. The migrate framework only handles
per-bucket schema version bumps, not bucket deletion, so the obsolete
bucket would linger on upgraded clusters.

Add a one-shot tombstone after initJetStream that deletes the bucket if
present. DeleteKVBucketIfExists treats ErrBucketNotFound and
ErrStreamNotFound as success, so the call is idempotent across restarts.

* feat(config): add network.ipsec_enabled cluster flag (default true)

Add IPSecEnabled to NetworkConfig in both the cluster TOML schema and
the formation handshake payload. Default true so AWS-parity intra-AZ
encryption is on by default. Operators can flip false in
[network] for trusted single-rack lab topologies.

Phase 2.5 (bead mulga-siv-125.3.5) consumes the flag from admin init
(strongSwan provisioning), formation server (propagates to joiners),
and daemon startup (ovs-vsctl ipsec_encapsulation toggle).

* feat(admin): IPsec peer cert generator signed by cluster CA

Add GenerateIPSecPeerCert + IPSecCertPaths in spinifex/admin so admin
init (and the future admin add-node path) can issue per-node IKEv2 peer
credentials signed by the existing cluster CA. The cert carries
id-kp-ipsecIKE (RFC 4945) so strongSwan accepts it as an IKEv2 peer
credential when ovs-monitor-ipsec materialises tunnel configs.

Cluster CA is reused directly — no intermediate strongSwan PKI today;
when intermediate CAs land that becomes a separate workstream.

The cert/key pair lives under {configDir}/ipsec/peer.{pem,key}; the
daemon points OVS Open_vSwitch.other_config (certificate, private_key,
ca_cert) at these paths when network.ipsec_enabled is true.

* feat(admin): wire IPsec peer cert into admin init + join paths

Add --ipsec bool flag (default true) on adminInitCmd. Single-node init
and the multi-node leader path both emit an IPsec peer cert after the
existing CA+server-cert generation. The multi-node formation server
propagates the IPSecEnabled flag to joining nodes via NetworkConfig;
admin join honours it and emits its own peer cert during the same
cert-issuance step that already produces the server cert.

spinifex.toml template gains an unconditional [network] block carrying
ipsec_enabled so the value is visible to operators on every install
regardless of whether external networking is configured. The daemon
reads it via cluster config (NetworkConfig.IPSecEnabled).

Bead mulga-siv-125.3.5.

* build: install openvswitch-ipsec for OVN native IPsec

openvswitch-ipsec provides ovs-monitor-ipsec, which reads the
Open_vSwitch.other_config cert/key/CA pointers and materialises
strongSwan configs for every Geneve tunnel managed by ovn-controller.
Pulls in strongswan + strongswan-pki as transitive deps.

Required by bead mulga-siv-125.3.5.

* feat(daemon): enable OVN native IPsec on intra-AZ Geneve at startup

When network.ipsec_enabled is true (default), the daemon points the
local OVS DB other_config at the per-node IPsec peer cert that admin
init / admin join provisioned (configDir/ipsec/peer.{pem,key} + the
cluster CA at configDir/ca.pem), then sets
other_config:ipsec_encapsulation=true.

ovs-monitor-ipsec (shipped by the openvswitch-ipsec package) consumes
those pointers and materialises strongSwan configs for every Geneve
tunnel ovn-controller programs — including OVN-IC inter-AZ tunnels
when federation lands in Phase 3a.

Missing cert files log a warning and leave Geneve plaintext; operators
upgrading an older cluster reissue certs via spx admin to close the
gap.

Bead mulga-siv-125.3.5.

* test(e2e): multinode IPsec wiring + xfrm SAs + ESP capture

TestMultinodeIPSec asserts:
- OVS DB on every node carries certificate/private_key/ca_cert pointers
  and other_config:ipsec_encapsulation=true (what daemon.enableOVNIPSec
  writes).
- ip xfrm state on every node has at least one AEAD SA in AES-GCM mode
  (rfc4106(gcm(aes)) or gcm(aes)) within 90s.
- tcpdump catches ESP (ip proto 50) on the underlay (best-effort —
  logged only; geneve may be idle during the 5s capture window).

Skips cleanly when the cluster was bootstrapped with --ipsec=false
(no ipsec_encapsulation key on first node).

Read-only over SSH; runs in the parallel bucket alongside the other
non-mutating multinode tests.

* fix(igw): write attachment State="attached" for AWS parity

Reconciler intent loader (network/reconcile/intent.go::loadIGWs) filters
IGW records with `State == "attached"`, but AttachInternetGateway was
writing `State = "available"`. The reconciler therefore never picked up
any attached IGW — `intent_igws=0` on every pass — so the OVN external
switch + gateway LRP + WAN bridge wiring built by IGWManager.AttachIGW
never ran for default-VPC IGWs (whose live `vpc.igw-attach` publish at
daemon bootstrap is lost — vpcd subscriber not yet wired).

Net effect on single-node e2e: every SSH probe to 192.168.x public IPs
timed out (TestSSHProbe, TestModifyInstanceAttribute, TestRebootInstance,
TestNATGateway, TestSGToSGDatapath) because SNAT/DNAT rules on the VPC
router had no path to the external network.

Match AWS InternetGatewayAttachment.State semantics:
 - Attach: State="attached"
 - Detach: State="detached"

Detach value is internal — recordToEC2 gates Attachments on VpcId != "",
so the detached State is never surfaced to API consumers.

Bead: mulga-siv-131

* fix(reconcile): call AttachIGW when IGW topology missing

applyIGWs previously deferred to the NATS handler when an intent IGW
had no external switch in OVN. When the vpc.igw-attach event is lost
(bootstrap-time default-VPC IGW published before vpcd subscriber is
ready), deferral never resolves and VM public-IP traffic dies.

Call IGWManager.AttachIGW directly — it is idempotent (short-circuits
when the external switch already exists), so the reconciler converges
default-VPC IGWs on first pass without duplicating work on subsequent
passes.

Closes mulga-siv-132.

* fix(test): align NewExternalIPAM call with 2-arg signature

Rebase onto dev pulled in daemon_system_instance_test.go which used
the legacy 3-arg NewExternalIPAM(jsNC, js, pools). The feature branch
already narrowed the signature to (js, pools) via the upstream-DHCP
config drop; align the test with the current signature.

* fix(vpcd): EnsureSubnet must EnsureVPC first to avoid NATS race

vpc.create-vpc and vpc.create-subnet are independent NATS events with
no inter-event ordering guarantee. Tenant bootstrap on iam.account.created
publishes both within <1ms, so the subnet handler's
CreateLogicalRouterPort can race the parent VPC's CreateLogicalRouter and
fail with "logical router not found". The cascade fails CreateNetworkInterface
which fails RunInstances which surfaces as InsufficientInstanceCapacity to
the SDK — masking the underlying topology race.

handleSubnetCreate now calls EnsureVPC(VPCID-only) before EnsureSubnet.
EnsureVPC short-circuits on existing LR (idempotent), so the race-winning
vpc.create-vpc handler's full CIDR/VNI ExternalIDs are preserved; if subnet
wins the race the LR is created with bare ExternalIDs (cosmetic) and the
follow-on vpc.create-vpc short-circuits.

Adds TestIntegration_SubnetBeforeVPC regression covering both orderings.

Closes mulga-siv-133.

* feat(ansible): require --external-pool, drop NAT, forward EXTRA_VARS

spx admin init now rejects --external-mode=nat and demands --external-pool
when mode=pool. The ansible init role silently skipped pool flags when the
range was unset, so installs fell over inside the spx validator. Make
targets also swallowed `-e` (make's env-override flag), so operators could
not override pool start/end without editing defaults.yml.

- vars/defaults.yml: rename nat_gateway_ip → external_gateway_ip; add
  spinifex_external_no_external opt-out; document the spx flag contract.
- roles/init/tasks/main.yml: drop NAT branch; assert pool range upfront
  with a fail_msg pointing at EXTRA_VARS / defaults.yml; emit gateway,
  prefix-len, gateway-ip only when explicitly set (let spx auto-detect);
  honour --no-external.
- playbooks/dev-reset.yml: rename captured var; coerce legacy
  external_mode=nat to pool with a warn; tighten gateway regex with \b.
- Makefile: ansible-dev-* targets now forward EXTRA_VARS="key=val ..."
  through --extra-vars so `make` callers can override without editing
  defaults.yml.
- scripts/ansible/README.md: document pool requirement, both invocation
  styles (direct -e vs make EXTRA_VARS), NAT removal.

* fix(ipsec): guard ipsec_encapsulation on ovs-monitor-ipsec liveness

enableOVNIPSec set OVS cert pointers + ipsec_encapsulation=true without
ensuring ovs-monitor-ipsec.service was running. Without that daemon, OVS
holds the cert pointers unused, strongSwan never gets IKE config, and
tunnel traffic ovn-controller marks for encryption gets silently
dropped. TestMultinodeIPSec fails on `node1 xfrm has no AEAD SAs`.

- systemd: spinifex-daemon Wants=/After= ovs-monitor-ipsec.service
- daemon: ensureOVSMonitorIPSecActive — systemctl enable --now +
  is-active poll; refuses to flip ipsec_encapsulation if daemon is dead
- daemon: single-node short-circuit — no peers means no tunnels means
  the flag is at best inert and at worst load-bearing-silent-drop
- ci: capture-net-diag.sh + workflow steps to snapshot OVS/OVN/xfrm
  state on failure, fan out across all 3 multinode nodes

Refs: mulga-siv-136

* test(e2e): sticky-skip downstream SSH tests when probe fails

When TestSSHProbe times out (3 min, typically because of mulga-siv-134
datapath bug), four downstream tests each re-probe SSH on the same
broken datapath and time out identically — single-node job runs ~24 min
instead of ~12 min, with the same error logged five times.

First waitForSSHReady timeout now sets a package-level atomic flag;
subsequent waitForSSHReady / trySSHReady / requireSSHHealthy calls skip
immediately. Preserves the original red signal on TestSSHProbe and on
any test that genuinely needed SSH (Skip ≠ Pass). Bool variant
(trySSHReady) consults but does not set the flag — its caller already
treats False as advisory warning, not fatal.

TestSGToSGDatapath has its own EventuallyErr SSH wait (not via
waitForSSHReady) and needs an explicit requireSSHHealthy(t) gate at
the top of runSGToSGDatapath. TestNATGateway already calls
waitForSSHReady first so the gate fires naturally.

Refs: mulga-siv-137

* fix(ipsec): use Debian unit name openvswitch-ipsec.service

CI artifact shows `Unit ovs-monitor-ipsec.service could not be found`
on every node. Debian's openvswitch-ipsec package ships the unit as
openvswitch-ipsec.service (the binary inside is ovs-monitor-ipsec, but
systemd's name follows the package). The previous commit guarded on
the wrong unit name, so:

- spinifex-daemon.service Wants=/After= silently no-op'd (missing unit)
- enableOVNIPSec's `systemctl enable --now ovs-monitor-ipsec.service`
  failed, daemon logged a warning and continued without flipping
  ipsec_encapsulation=true
- TestMultinodeIPSec skipped with `node1 reports ""` because OVS DB
  never received the cert pointers

Rename across all 5 spots (daemon, systemd unit, test, CI diag script,
workflow journal capture) to match the Debian package layout.

Note: the package itself still must be present on the CI VM image —
that's tracked in the mulga repo (image-builder provision.sh +
bootstrap-install.sh shim).

Refs: mulga-siv-136

* fix(e2e): authorize default SG tcp/22 + icmp in single-node fixture

admin init leaves the default SG closed (AWS parity); the Go single suite
never ported the Phase-5 SG-ingress block that run-e2e.sh has had since day
one, so every test runner SSH/ping probe hits the OVN port-group ACL drop.

- ec2helpers: add AuthorizeICMPIngress + EnsureDefaultSGOpen helpers.
- single/main_test: open default SG once per process in pkgFixOnce.Do.
- witness: extend ensureDefaultSGSSHIngress to authorize ICMP too.
- run-e2e.sh: append matching ICMP authorize next to tcp/22 block.

mulga-siv-134

* fix(network/reconcile): key DHCP_Options lookup by subnet_id not CIDR

Multi-tenant default VPCs share the same subnet CIDR (172.31.0.0/20) per
account. The existing FindDHCPOptionsByCIDR gate at apply.go:107 found
the first account's DHCP_Options row and skipped creation for every
subsequent subnet with the same CIDR. Those subnets' ports then attached
nothing (FindDHCPOptionsByExternalID misses on subnet_id), VMs got no
DHCPv4 lease, and SSH/ICMP into them silently failed even though SG
ACLs and NAT were correct.

Switch the gate to FindDHCPOptionsByExternalID keyed on spinifex:subnet_id
so each subnet owns its own row, matching the attach lookup at line 203.

Verified: account 000000000001 default VPC RunInstances now produces an
LSP with dhcpv4_options populated, VM cloud-init reports a 172.31.0.5/20
lease + default route via 172.31.0.1, and ping + ssh TCP handshake to
the EIP succeed end-to-end. Stale DHCP_Options row for the prior dead
subnet was wiped manually; reconciler recreated the correct row for the
live subnet on its next pass.

* fix(ipsec): enable openvswitch-ipsec at provision time, drop daemon write

Provisioning now runs `systemctl enable --now openvswitch-ipsec.service`
alongside openvswitch-switch and ovn-controller. The spinifex-daemon
process drops its `enable --now` shell-out and only polls is-active.

Sudoers gains one read-only line:
  spinifex-daemon ALL=(root) NOPASSWD: /usr/bin/systemctl is-active openvswitch-ipsec.service

This mirrors the existing vpcd ovn-controller is-active precedent and
removes the daemon's need for systemd-write capability. Aligns with
least-privilege service account pattern: provision enables services,
daemon only reads status.

If the unit is inactive at runtime, the daemon refuses to flip
ipsec_encapsulation=true (silent-drop guard from mulga-siv-136) and
surfaces a clear error pointing at scripts/setup-ovn.sh.

* chore: unblock preflight on feat/network-layer-redesign

Two pre-existing failures blocked preflight on the branch:

- govulncheck flagged golang.org/x/net@v0.54.0 (GO-2026-5026, ASCII Punycode
  rejection bug in idna.ToASCII); reached via utils.DownloadFileWithProgress.
  Bump to v0.55.0; go mod tidy carries x/sys to v0.45.0.
- TestTopologyHandler_SubnetCreate_RouterPortFails asserted subnet create
  fails when the parent VPC router is missing. That contract changed in
  aed7b2e (mulga-siv-133), which made handleSubnetCreate call EnsureVPC
  idempotently before EnsureSubnet to avoid a NATS publish-order race.
  Delete the now-impossible failure case.

* refactor(network/ovn): relocate nbdb model package from services/vpcd

Phase 4 / mulga-siv-125.6 Slice A. Pure import rename, no logic change.

The OVN Northbound DB model (LogicalSwitch, LogicalRouter, DHCPOptions,
etc.) lived under services/vpcd/nbdb/ because that was the original home
of every OVN caller. After Phase 1+2 the L1 client (network/ovn) and all
L3/L5 managers (network/policy, network/external, network/topology,
network/reconcile) became the primary callers — 8 importers in network/*
vs 2 in services/vpcd/. The directory move puts the schema model where
its consumers are, and unblocks deleting services/vpcd/ entirely once
the subscriber/launcher relocations land (Slices B–D).

* refactor(vpcd): delete dead retrofit/reconcileIGW helpers + bridge-mode bleed

Phase 4 / mulga-siv-125.6 Slice B.

Production path no longer touches any of:

- RetrofitAllGatewayPortNetworks / RetrofitAllExternalLocalnetOptions —
  the 5-pass startup leftovers that network/reconcile (Phase 2.2) made
  redundant. Nothing called them outside tests.
- reconcileIGW + its allocateGatewayLRPIP / gwLrpRange helpers + the
  ipv4ToUint32 / uint32ToIPv4 utilities — superseded by
  external.IGWManager (Phase 2.4) which carries its own pool logic.
- ensureLocalnetOptions / ensureGatewayPortNetworks /
  expectedGatewayPortNetwork — read-before-write retrofit shims for the
  same code paths.
- generateMAC — only callers were reconcileIGW + its tests.

useCentralizedNAT (the bridge-mode bleed flagged in plan §2.2) is
replaced by a policy.NATMode value injected via WithNATMode(...). The
mode is resolved once in vpcd.go from host.UplinkMode and threaded into
both the topology-handler lazy managers and the reconciler stack, so
"is this veth?" is no longer a question topology.go can ask. The
TopologyHandler defaults to NATModeDistributed when WithNATMode is
omitted, matching the previous default.

Drops services/vpcd/topology.go from 1220 LOC to 693, deletes ~700 LOC
of obsolete tests (TestGenerateMAC, TestEnsureLocalnetOptions_*,
TestRetrofitAllExternalLocalnetOptions_FixesClearedOptions,
TestReconcileIGW_*, TestGwLrpRange), and frees the nbdb + utils +
net imports in topology.go.

Stale "phase 4 migrates those onto the new managers" comment in vpcd.go
fixed in passing — the subscribers were migrated in Phase 2.6
(mulga-siv-129); only the file-relocation parts of Phase 4 remain.
Stale ensureLocalnetOptions / ensureGatewayPortNetworks references in
network/ovn/{live.go,mock/mock.go} doc comments also cleaned up.

* refactor(network): move NATS VPC subscribers to network/subscribers

Phase 4 Slice C (mulga-siv-125.6). Promotes the NATS event handlers out of
services/vpcd into a new spinifex/network/subscribers package. Subscriber
holds the topology/policy/external managers directly — the lazy *Once
adapter shim (topology_manager.go) is deleted along with vpcd's local
event-struct copies.

launchService now constructs topology.LiveManager eagerly with a DNS
callback derived from external pools (preserving the unscoped-pool lookup
old TopologyHandler.lmDNSServer did), builds every manager up front, then
hands them to subscribers.New + Subscribe. reconcile.New receives
topology.Manager directly instead of through the adapter.

Bridge-mode constants and waitForFlowsHV move into vpcd.go alongside the
launcher; they were left behind as topology.go's last responsibilities.

Handler-layer tests (topology_test.go, security_test.go, integration_test.go)
are dropped: substance is covered by network/topology, network/policy,
network/external manager tests. subscribers/subscribers_test.go adds
focused coverage: missing-dep rejection, topic registration smoke, and
bad-JSON envelope per topic.

services/vpcd is now down to the launcher + the ovn/mock compat aliases
(removed in Slice D when the launcher promotes to cmd/vpcd/).

* refactor(vpcd): relocate services/vpcd → spinifex/vpcd; drop dead aliases

Phase 4 Slice D (mulga-siv-125.6). Final cleanup of the network-redesign
package layout. services/vpcd/ disappears entirely; the launcher remains
an in-process Service impl, just rehomed under spinifex/vpcd/ (no separate
binary — spx vpcd start stays the cobra entrypoint).

- Delete services/vpcd/mock_ovn.go + ovn.go (compat aliases now dead post
  Slice C; only callsite was launchService's NewLiveOVNClient, inlined to
  ovn.NewLiveClient).
- git-rename services/vpcd/vpcd.go + vpcd_test.go to spinifex/vpcd/.
- Retarget three importers (spinifex/service/service.go +
  service_test.go, cmd/spinifex/cmd/service.go) to the new path.
- Fix stale services/vpcd / TopologyHandler comments in network/reconcile/
  + network/external/types.go + spinifex/vpcd/vpcd.go.
- service-interfaces.yaml vpcd path updated.
- Plan doc §13/§14/§16 + §9.2/§19 updated: drop cmd/vpcd/main.go (was
  speculative; never built) in favour of in-process Service impl at
  spinifex/vpcd/vpcd.go. Plan §16 Created list now lists subscribers/.

Phase 4 plan items 1+2 (remove services/vpcd, entrypoint location)
satisfied. Items 3+4 remain blocked on mulga-siv-125.4 + 125.5 (bundled
join token, plan archive).

* fix(ovn): install openvswitch-ipsec + strongswan explicitly

ovs-monitor-ipsec.service fails to start without an IKE daemon. The
openvswitch-ipsec package only Recommends strongswan, so any apt
config that drops Recommends (or a base image with the recommends
already skipped) leaves the service in a Restart loop with 'IKE
daemon is not installed'. Hoist both packages into setup-ovn.sh so
provisioning is the single source of truth — the bootstrap shim in
mulga is no longer needed.

* test(network): pin ADR-0006 invariants to executable suite

Add static-analysis + behavioural tests that bind every safety and
liveness clause in ADR-0006 (S1-S9, L1-L3) to a named Go test. Failure
output quotes the ADR clause verbatim and points at the offending
file:line so future debugging sessions can't quietly bypass a layer
contract to make a test green.

Static-analysis suite (spinifex/network/invariants/):
- S1 layers_test.go: parses `go list -json`; rejects upward layer
  imports and layers pulling from cross-cutter packages
- S2 host_containment_test.go: AST-detects ovs-vsctl / ovn-nbctl /
  ovs-ofctl shell-out outside network/host/
- S3 nat_mode_test.go: rejects .UplinkMode() / NATModeFromUplinkMode()
  calls outside the init-time resolver locations
- S4 names_test.go: AST-detects OVN object name construction (concat,
  fmt.Sprintf, strings.Join) outside topology/names.go
- S5 policy_no_create_test.go: rejects CreateLogical{Switch,Router,...}
  and DeleteLogical{...} calls inside network/policy/
- S6 federation_isolation_test.go: rejects TransitSwitch /
  TransitRouterPort references and ovn-ic literals outside
  network/federation/ (pre-protects the layer before it lands)
- S8 ipsec_test.go: rejects strongswan / xfrm / IKEv2 / libreswan /
  openswan imports anywhere in network/
- S9 reconciler_singleton_test.go: rejects Reconcile…Loop and
  Reconcile…Pass<n> function names anywhere in network/
- S7 nats_subjects_test.go: scans for `vpc.<verb>` subjects missing
  the {azID} segment; calls t.Skip with the gap report so every test
  run prints the work-to-do without breaking CI. Flip to t.Fatalf
  when the cluster-wide subject rename ships.

Behavioural suite (spinifex/network/reconcile/liveness_test.go):
- L1 reconciler converges: scaled-intent (N=1,3,8) double-Reconcile
  property test; asserts second pass is a no-op
- L2 drift bound: structural check that DriftInterval is positive and
  ≤30 min support window; full fake-clock behavioural test deferred
- L3 federation re-enables: t.Skip stub pointing at the missing
  network/federation/ package

Fixes the one production S4 violation the new walker found:
network/external/igw.go was constructing "gw-port-"+vpcID inline.
Adds topology.GatewaySwitchPort(vpcID) helper and retargets both
AttachIGW and DetachIGW callsites through L2.

Plan + per-clause mechanic + phasing in
docs/development/improvements/network-invariants.md.

Refs: mulga-siv-140

* fix(ovn): install strongswan-charon (not strongswan metapackage)

The strongswan metapackage's '\''charon-systemd | strongswan-charon'\''
alternative resolves to charon-systemd by default, which ships
/usr/sbin/charon-systemd but not the legacy /usr/sbin/ipsec helper or
/usr/lib/ipsec/charon binary that ovs-monitor-ipsec --ike-daemon=strongswan
shells out to. Install strongswan-charon explicitly to pull
strongswan-starter (legacy ipsec controller) + the matching charon binary,
matching what openvswitch-ipsec.service expects.

Also reset-failed + restart openvswitch-ipsec.service after enable to
recover from the deb post-install starting the service before its
dependencies finished unpacking.

* chore: add CODEOWNERS — joshsiv-mulga gates network/ + vpcd/

Fall-through reviewer for any change is the maintainer.
network/ and vpcd/ are explicitly listed so the matching branch
protection rule ("Require review from Code Owners") routes those
PRs to joshsiv-mulga. Those trees are governed by ADR-0006
invariant tests (spinifex/network/invariants/); CODEOWNERS adds a
human review gate on top of the mechanical fence.

Refs: mulga-siv-141

* chore: CODEOWNERS fallthrough → @mulgadc/mulgadc-engineers team

Fallthrough reviewer should be the team, not a single maintainer, so
any engineer can approve PRs outside the explicitly-gated paths.
network/ and vpcd/ keep joshsiv-mulga as the required reviewer per
ADR-0006 invariant ownership.

Refs: mulga-siv-141

* feat(ci): add test_run workflow input for -test.run filter

Pass an optional regex through to each suite's '\''go test -test.run'\'' so
dispatches can target a single test (e.g. '\''^TestMultinodeIPSec$'\'') without
running the full multinode suite. Threaded through single, multinode, and
reboot suite invocations; empty input preserves current behaviour.

* fix(daemon): flip NB_Global.ipsec=true on management node

Setting Open_vSwitch.other_config:ipsec_encapsulation=true on each
chassis is necessary but not sufficient for OVN native IPsec — without
NB_Global.ipsec=true, ovn-controller never adds options:remote_name to
the Geneve tunnel ports, so ovs-monitor-ipsec sees no IPsec-enabled
tunnels and the kernel xfrm tables stay empty. After enabling
ipsec_encapsulation locally, also write NB_Global.ipsec=true when the
local ovnnb_db socket is present (management node only); workers skip
silently — one writer is enough for a cluster-wide flag.

Adds a scoped sudoers entry pinning the daemon to that exact ovn-nbctl
invocation so the grant can't be reused for other NB mutations.

* fix(ovn): grant charon AppArmor read on /etc/spinifex/ipsec

The strongswan-charon profile only allows reads from /etc/ipsec.*,
/etc/strongswan.*, and a small fixed allowlist. ovs-monitor-ipsec writes
the per-tunnel strongSwan config with absolute paths to our peer cert +
key under /etc/spinifex/ipsec/, so charon hits 'Permission denied'
loading them (it runs as uid 0 but AppArmor still blocks) and no xfrm
SAs come up. Drop a read grant for /etc/spinifex/ipsec/** into the
profile's local override hook + reload.

* fix(ovn): pin OVS system-id to hostname for IPsec cert identity match

ovs-monitor-ipsec uses chassis-id as the IKEv2 `@<name>` peer identity in
the strongSwan config it generates. Our per-node IPsec peer cert
(admin.GenerateIPSecPeerCert) carries the node name as CN + dnsName SAN.
With chassis-id left at the package-generated UUID, charon validates
`@<UUID>` against a cert with dnsName=<hostname> — mismatch returns
AUTHENTICATION_FAILED and no xfrm SAs are negotiated.

Pin system-id to hostname (which equals the node name on Mulga clusters)
by writing both the on-disk source of truth (/etc/openvswitch/system-id.conf,
read by ovs-ctl on boot) and the running OVS DB value. Writing only one
would drift on the next reboot — the original "do not override" comment
was right about that hazard, just wrong about the resolution.

Cross-link the invariant in admin/ipsec.go: changing the cert identity
without updating setup-ovn.sh (or vice versa) breaks IKE silently.

Refs: mulga-siv-136

* fix(ovn): take --node-name flag so chassis-id matches cluster identity

CI hostnames are bead-themed (env20-casuarina) and don't match the
cluster node name (node1/node2) baked into the IPsec peer cert dnsName
SAN at `spx admin init/join --node NAME`. Pinning system-id to
`hostname -s` produced env20-casuarina; charon then validated
`@node1` against dnsName=env20-casuarina and IKE failed with
AUTHENTICATION_FAILED.

Add a --node-name=NAME flag to setup-ovn.sh, default to hostname for
manual/dev invocations, and update the ansible install role to pass
{{ spinifex_node_name }}. The matching bootstrap-install.sh change
lives in mulga.

Refs: mulga-siv-136

* refactor(daemon): relocate L0 helpers to network/host + widen ADR-0006 S2

Phase 4 Slice E of mulga-siv-125.6. Cleans up spinifex/daemon/network.go
per the audit and widens the S2 invariant to forbid OVS/OVN shell-outs
in spinifex/daemon/ (daemon is an L0 consumer; S2 applies).

Moved to network/host/:
- OVSNetworkPlumber + SetupTap/CleanupTap → host.OVSPlumber (host.NewOVSPlumber)
- GetBridgeIPv4 → host.GetBridgeIPv4
- OVNHealthStatus + CheckOVNHealth → host.OVNHealth + host.HealthStatus
- daemon/ipsec.go OVS/OVN ops → host.SetIPSecCertPaths, host.EnableIPSecEncapsulation, host.SetNBGlobalIPSec

Moved to utils:
- sudoCommand → utils.SudoCommand + utils.SetSudoCommandForTest test seam
  (cross-package reassign would trip the reassign linter; helper keeps
  the indirection inside utils).

Moved to vm:
- generateMgmtMAC → vm.GenerateMgmtMAC (pairs with vm.GenerateDevMAC)

Deleted (dead):
- SetupComputeNode (replaced by network/host/bridges.go:ensureBridges +
  scripts/setup-ovn.sh:572-579)
- EnsureDataRoute
- findInterfaceByIP
- daemon/network.go + dead tests in daemon/network_test.go (duplicates of
  vm package tests retained; mgmt MAC test moved to vm/lifecycle_test.go)

S2 invariant (network/invariants/host_containment_test.go) now walks
both spinifex/network/ and spinifex/daemon/, skipping network/host/.

* fix(ovn): broaden AppArmor read grant to /etc/spinifex (covers CA cert)

The earlier override granted reads on /etc/spinifex/ipsec/** only, which
covers the peer cert + key but NOT the cluster CA at /etc/spinifex/ca.pem.
charon then can't load the CA, can't validate the peer cert chain, and
surfaces the failure as `no trusted RSA public key found for '<peer>'`
even though identity matching now succeeds — IKE_AUTH fails with
AUTHENTICATION_FAILED and no xfrm SAs negotiate.

Grant /etc/spinifex/** instead (covers ca.pem + ipsec/peer.{pem,key})
and always rewrite the override so re-runs replace the older narrower
rule.

Refs: mulga-siv-136

* fix(ipsec): symlink cluster CA into charon's default cacerts dir

ovs-monitor-ipsec writes per-conn `cacert=/etc/spinifex/ca.pem` references
in the strongSwan config, but strongSwan 6.0 only loads CAs declared in
standalone `ca` sections or files placed in /etc/ipsec.d/cacerts at
startup. Without this, charon authenticates the local side fine but
rejects every inbound peer cert as `no trusted RSA public key found for
'<peer>'`, AUTH_FAILED, and no xfrm SAs negotiate.

Do the symlink in admin.GenerateIPSecPeerCert (right after writing the
peer cert) — setup-ovn.sh runs before `spx admin init/join` creates
/etc/spinifex/ca.pem, so the wiring can't live there. Use a stage-and-
rename pattern so concurrent test invocations don't race on the symlink.

/etc/ipsec.d/cacerts is on charon's default AppArmor allowlist so no
profile change needed.

Refs: mulga-siv-136

* chore(setup-ovn): drop apt-get install_packages block

OVS/OVN/openvswitch-ipsec/strongswan-charon are baked into the CI gold
image via mulga's image-builder/scripts/provision.sh (commits 116bb05,
a9b3a9c). Keeping a runtime apt-get install path here pulled CI through
apt-cacher-ng on every bootstrap and tripped on intermittent "Message
has been manipulated" InRelease cache corruption (env21/env20 runs).

Drop the install block. Gold-image rebuild is the canonical place to
add new system packages. If a package is missing, downstream ovs/ovn
commands fail loudly during setup-ovn.sh's later steps — clearer signal
than silently re-installing through a flaky proxy.

setup.sh already supports INSTALL_SPINIFEX_SKIP_APT=1 (passed by mulga's
bootstrap-install.sh:237) so this removes the last runtime apt-get call
on the CI provisioning path.

* fix(ovn): gate system-id pin on explicit --node-name

setup-ovn.sh used to fall back to `hostname -s` when --node-name wasn't
passed, and then unconditionally wrote that value to
/etc/openvswitch/system-id.conf + ovs-vsctl external_ids:system-id.

On CI single-node, the gold image already registered a chassis with
name=<UUID>; setup-ovn.sh then rewrote system-id to the bead-themed
hostname (env10-banksia). vpcd's discoverChassis treats any chassis row
whose name != local system-id but hostname == local hostname as a stale
local chassis and skips it, so vpcd reported "no OVN chassis registered
in SBDB" and refused to start, cascading into the e2e + e2e-go failures
on run 26430839508.

Drop the hostname fallback. If --node-name is given, pin both the
on-disk file and the running OVS DB to it (callers that care about
IPsec cert identity matching still get it). If no --node-name is given,
leave the existing system-id alone — that's always safe whether the
value came from a gold-image UUID, bootstrap-install.sh writing nodeN
before this script runs, or a manual ansible value.

* fix(network/reconcile): skip orphan prune on startup; drift owns GC

The vpcd startup reconcile races against the daemon's EnsureDefaultVPC:
the daemon writes SG KV rows + publishes vpc.create-sg on each node in
parallel with vpcd starting up. The leader-gated reconcile can load
intent before any SG row is visible on its local JetStream replica
while subscribers on peer nodes are concurrently creating port groups
from the same vpc.create-sg events. A startup orphan-port-group sweep
then deletes managed sg_* port groups that intent hasn't caught up to.

CI run 26434124790 trace:
  node2 reconcile/lock: elected holder=env20-bottlebrush
  node2 reconcile: starting intent_sgs=0
  node2 reconcile/apply: removed orphan port group pg=sg_161c70ce23e704a2a
  node2 reconcile/apply: removed orphan port group pg=sg_a0d078650a3150391
  node2 reconcile/apply: removed orphan port group pg=sg_54b564b374e9162bb

Every subsequent RunInstances → vpc.create-port failed with
"port group not found" for ~3 minutes until the 5-minute drift tick
recreated the port groups. Tests give up after 60s; six multinode
suites cascade-fail on InsufficientInstanceCapacity.

Split the reconciler into Reconcile (full prune, drift loop) and
ReconcileApplyOnly (no prune, startup). applySGs takes a pruneOrphans
bool. vpcd startup calls ReconcileApplyOnly; drift loop unchanged.
Legitimate orphans get garbage-collected on the next drift tick — at
worst 5 minutes of lingering rows, no production impact.

Add TestReconcile_ApplyOnlyKeepsOrphanPortGroup to lock the contract.

* fix(ipsec): trigger charon rereadcacerts after CA symlink install

strongSwan scans /etc/ipsec.d/cacerts only at charon startup. The unit
openvswitch-ipsec.service is enabled at provision time and starts before
admin init writes the cluster CA symlink, so charon ends up running with
zero CAs loaded. Every IKE_AUTH then fails with `no trusted RSA public
key found for '<peer>'` and no SAs negotiate — the failure mode exposed
by TestMultinodeIPSec.

Append a stroke rereadcacerts call at the end of
installCAIntoCharonTrustStore so an already-running charon picks up the
freshly-placed CA without a service restart. Failure is swallowed at
debug — a missing/stopped charon at admin-init time is expected and a
later startup will scan cacerts naturally.

* refactor(network/host): move IPSec orchestrator from daemon (ADR-0006 S8)

ADR-0006 S8: "IPSec is OVN-native only. No layer implements custom IKEv2,
XFRM rules, or direct strongSwan management. IPSec SA lifecycle is delegated
entirely to OVN native IPSec and is invisible above L0."

The enableOVNIPSec orchestrator and its ensureOVSMonitorIPSecActive helper
lived in daemon/ipsec.go but composed L0 primitives (SetIPSecCertPaths,
EnableIPSecEncapsulation, SetNBGlobalIPSec) that already lived in
network/host. The orchestrator now lives alongside its callees as
host.EnableOVNIPSec, taking configPath and clusterConfig from the daemon at
the call site. Helper and test-override package vars (systemctlActiveTimeout,
ovnNBSocketPath) move with it; tests are translated to call EnableOVNIPSec
directly rather than via the Daemon struct.

Closes mulga-siv-142

* refactor(daemon): use topology.Port for OVN port names (ADR-0006 S4)

ADR-0006 S4 forbids any layer above L2 from constructing OVN object
names. Three daemon sites formatted "port-" + ENIId inline; route them
through topology.Port instead.

Closes mulga-siv-143

* refactor: use topology.Port for OVN port names in handlers + vm (ADR-0006 S4)

Replace inline "port-"+eniID formatting across five sites (eip, instance x2,
vpc/eni, vm/network_helpers) with topology.Port. OVSIfaceID stays as a thin
wrapper to preserve the OVN binding contract comment and existing call sites.

Closes mulga-siv-144

* test(network/invariants): widen scan roots to daemon/handlers/vm/testutil/utils (ADR-0006)

Widen ADR-0006 invariant scan roots so drift in upstack consumers surfaces
in CI. Backlog of violations in widened roots was drained by
mulga-siv-142/-143/-144; this commit makes the wider scan permanent.

  - S2 (host_containment): add vm/ alongside network/ + daemon/
  - S3 (nat_mode):         add daemon/ alongside network/ + vpcd/
  - S4 (names):            add daemon/, handlers/, vm/ alongside
                           network/ + vpcd/; add narrow AWS-ID exemption
                           (vpc-cidr-assoc-) so AWS resource IDs are not
                           misclassified as OVN object names
  - S6 (federation):       add daemon/, handlers/ alongside network/;
                           convert single-root walk to slice
  - S7 (nats_subjects):    add daemon/, handlers/, testutil/, utils/ —
                           stays t.Skip pending cluster-wide subject
                           rename; gap report now lists 58 subjects

CODEOWNERS: add /spinifex/network/invariants/ rule so widening or
narrowing the scan needs explicit owner review.

Closes mulga-siv-145
Refs: audit-adr-0006.md

* chore(e2e): disable IPsec in pseudo multi-node test

Pseudo multi-node runs three simulated nodes as unprivileged tf-user.
The charon trust-store install in 904b45e writes /etc/ipsec.d/cacerts,
which fails with EACCES under a non-root caller and aborts admin init.

Disable --ipsec for this suite — loopback-only nodes don't exercise
real Geneve encryption anyway. Real-multi-node + single-node e2e
(both run admin init via sudo) continue to cover the IPsec path.

* fix(ovn): atomic Ensure* primitives prevent duplicate logical entities

OVN NB has no unique constraint on Name. The previous
GetLogicalRouter→CreateLogicalRouter sequence in EnsureVPC was a TOCTOU
race: when vpc.create on one node and the defensive vpc.create-subnet
EnsureVPC call on another both observed absence, both inserted, yielding
two routers per VPC. The orphan held DNAT references blocking EIP
detach, and DNAT/flow installation landing on the orphan caused
TestMultinodeGuestSSH SSH timeouts.

Add EnsureLogicalRouter / EnsureLogicalSwitch / EnsurePortGroup to the
libovsdb client. Each builds a single OVSDB transaction combining a
wait-op (Until=equal, Rows=[]) gated on absence of a Name match with the
insert. The server serialises the wait+insert atomically; concurrent
callers see exactly one survivor and the rest fall through to a re-fetch
that returns the canonical row.

Switch all production callers (topology.EnsureVPC / EnsureSubnet /
EnsureSGPortGroup, external.AttachIGW, reconcile.applyVPCs /
applySubnets) to the Ensure* variants. Bare Create* remains available to
the L1 wrapper.

Add invariant test TestS4_OVNNamedCreatesUseEnsure (AST walk) to block
future reintroduction of the bare Create* call sites outside
network/ovn.

Mock implements Ensure* under its existing mutex so the concurrency
tests (50/30/30 goroutines for Router/Switch/PortGroup) prove a single
UUID survives.

Refs: mulga-siv-146

* fix(ovn): wait-op rows must be non-empty; flip to NotEqual predicate

The Ensure* primitives sent a wait-op with `Rows: []ovsdb.Row{}` plus
`Until: ==` (RFC 7047 §5.2.6 absence test). The libovsdb Operation
struct tags Rows as `json:"rows,omitempty"`, so the empty slice was
dropped on the wire and ovsdb-server rejected every transaction with
"Required 'rows' member is missing", breaking reconciler bootstrap and
every downstream create-port lookup ("logical switch X not found").

Flip the predicate to the equivalent `Until: !=` formulation with a
single-row projection containing the Name we want absent:

  no row    → projection []           != [{name}] → predicate holds
  row exists → projection [{name}]    == [{name}] → predicate fails

Same atomic guarantee, JSON now well-formed. Mock tests can't catch
this — only a real ovsdb-server validates the envelope.

* chore: remove generateMAC wrapper

* chore: remove all bead / plan references

* fix(network/policy): wait for chassis flows after AddNATGateway

AddEIP fires m.barrier() to block until ovn-northd compiles NB→SB and every
chassis installs the new dnat_and_snat flows; AddNATGateway returned right
after the NB commit. So `aws ec2 create-route … --nat-gateway-id` would
publish vpc.add-nat-gateway, the SNAT row would land in NB, and the API
call would succeed before the gateway chassis had programmed the SNAT flow
for the private subnet.

Phase 8d's 10×5s=50s ping budget races that flow-install latency on slow
CI hosts (last seen run 26455602615 single-node, identical flake on
unmodified dev run 26434711010 — pre-existing, not introduced by Ensure*).

Mirror AddEIP: call m.barrier() after AddNAT returns. Add unit test
TestNATManager_AddNATGateway_FlowsBarrier_Fires covering the new path.

* chore: remove more plan refs

* chore: remove GenerateIPSecPeerCertIfEnabled

* chore: remove dupliate subnetGatewayCIDR function

* chore(network): trim superfluous comments across network/ tree

Collapse multi-paragraph godocs to 1-2 sentences, drop WHAT-restatement
comments, remove section dividers grouping <10 items, and cut prose
narration in tests. ADR clause references, one-line WHY comments for
non-obvious OVN/OVSDB/AWS/NATS semantics, and exported-symbol godoc are
preserved. No behavioural changes.

Packages: invariants, topology, host, external, ovn, reconcile, policy,
subscribers.

* fix(igw): restore attachment State="available" for AWS parity

AWS InternetGatewayAttachment.State is "available" when an IGW is
attached to a VPC, per the AWS SDK doc ("the state is available when
attached to a VPC; otherwise, this value is not returned"). Commit
b7505f8 wrote "attached"/"detached" to align with the reconciler
filter, which inverted AWS parity.

Fix the producer to write "available" again and update the consumer:
 - handlers/ec2/igw: AttachInternetGateway and DetachInternetGateway
   both persist State="available"; IGWRecord comment narrowed.
 - network/reconcile/intent.loadIGWs: filter accepts "available" (was
   "attached"); attachment is identified by VpcId != "".
 - tests updated: igw service_impl_test, routetable service_impl_test
   fixture, reconcile intent_test fixture.

* Update spinifex.toml

* refactor(subscribers): route EIP/NATGW through L5 wrappers (ADR-0006 §S1)

Subscriber previously called policy.NATManager directly for EIP and NAT
Gateway operations, bypassing the L5 wrappers (EIPManager, NATGWManager)
that existed on paper. ADR-0006 §S1 requires every cross-layer call to
pass through the immediate lower neighbor's typed interface; IGW already
followed this pattern.

Wire EIPManager and NATGWManager into the Subscriber struct, construct
them in vpcd alongside IGWManager, and swap the four direct-L3 call
sites (AddEIP, DeleteEIP, AddNATGateway, DeleteNATGateway) onto the
L5 surface (AttachEIP, DetachEIP, AttachNATGateway, DetachNATGateway).
AttachEIP now fires the flows-barrier, giving callers the same
"reachable on return" guarantee IGW already had.

The reconciler still calls policy.NATManager directly; that's a
cross-cutter and a separate concern.

* test(reboot): port 8MB WAL pad workaround from bash run-reboot-e2e.sh

App VMs' user-data writes (unit file + index.html) sit in the on-host
WAL until WriteWALToChunk crosses the 4MB threshold. Host reboot before
that loses them — systemd then refuses to load the zero-byte unit and
the responder never starts. Mirror the bash driver's dd+sync pad so the
WAL flushes pre-reboot. Drop when the upstream WAL fix lands.

* test(e2e): dump OVN/OVS/SG state on Phase 8d NATGW failure

Phase 8d's existing failure path only prints lr-nat-list and
lr-route-list. CI artifacts are not uploaded when run-e2e.sh exits
nonzero, so debugging the NATGW regression requires inline diagnostics
in the journal. Expand the dump to include nb/sb show, chassis bindings,
port_group ACLs, br-int flows filtered to the NATGW IP/private subnet,
host routes, and the private VM's own ip/route/neigh state.

* fix(ovn,reconcile): scope NAT deletes to router; NATGW snat per associated subnet

Two compounding bugs caused Phase 8d NATGW E2E flake (~60% CI failure):

1. ovn.DeleteNAT/DeleteNATByExternalIP searched all NAT rows globally and
   only mutated the named router afterwards. With AWS subnet CIDRs repeating
   across VPCs, (type, logicalIP/externalIP) is not globally unique — the
   delete picked a NAT row owned by a different router, the mutate was a
   no-op, and the row delete failed with "remaining reference(s)", leaving
   the original router's NAT orphaned. Both calls now resolve the LR first
   and filter NAT lookups to lr.NAT UUIDs. Mock client mirrors the scoping.

2. reconcile.loadNATGWs used the NATGW's home (public) subnet CIDR as
   SubnetCIDR, producing an snat for the wrong CIDR that collided with the
   correct event-published snat. loadNATGWs now walks route tables and
   emits one NATGWSpec per (NATGW, associated private subnet) pair,
   mirroring publishNatGatewayEventsForAssociation. Intent map keyed by
   natgwID|subnetCIDR so multi-subnet NATGWs produce distinct specs.

Tests:
- nat_test.go: cross-router same-logicalIP isolation for DeleteEIP and
  DeleteNATGateway
- intent_test.go: NATGW snat uses associated private subnet CIDR, not the
  NATGW home subnet; NATGW with no associations produces no spec

---------

Co-authored-by: Julian Sommer <115516918+juliansommer@users.noreply.github.com>
2026-05-27 21:05:01 +10:00
Julian Sommer 3f3ba32317 chore: cleanup COMMANDS.md 2026-05-27 15:54:17 +10:00
Julian Sommer f525006d87 feat: IAM Roles part 2 (#256) 2026-05-27 15:44:18 +10:00
Josh Siviour ae6fcfe7a1 Ansible dev extensions (#254)
* chore(ansible): add dev-status playbook (read-only health doctor)

New `dev-status` role + playbook + Makefile target. Read-only snapshot of
spinifex.target, failed units, port listeners, OVN/OVS state, gateway
round-trip, AMI/keypair/volume/instance counts, and build-vs-install drift
for spx + nbdkit plugin + microvm artifacts. Pairs with dev-preflight as
the post-install / running-system diagnostic.

Plan: docs/development/improvements/ansible-dev-extensions.md (mulga).
Bead: mulga-siv-86.1.

* chore(ansible): add dev-logs playbook (bug-report bundle)

New `logs` role + playbook + Makefile target. Captures journalctl for
spinifex-* / ovn-* / openvswitch-switch / qemu / nbdkit, copies
/var/log/spinifex/, snapshots systemctl status + networking (ip/ss/ovs/
ovn show) + spx/plugin/microvm version manifest + dmesg, then bundles
into /tmp/spinifex-logs-<host>-<ts>.tar.gz owned by the invoking user.

Tail mode deliberately omitted — devs should `journalctl -u 'spinifex-*' -f`
directly; ansible captures stdout and is the wrong tool for follow.

Overrides: -e logs_since=10min, -e logs_output_dir=/tmp,
-e logs_include_dmesg=false.

Plan: docs/development/improvements/ansible-dev-extensions.md (mulga).
Bead: mulga-siv-86.2.

* chore(ansible): add dev-snapshot / dev-restore (state checkpoint)

New snapshot + restore roles + playbooks + Makefile targets.

dev-snapshot quiesces the full spinifex+OVN+OVS stack in dependency
order (spinifex.target → ovn-controller → ovn-central →
openvswitch-switch), kills lingering QEMU VMs, then tars /etc/spinifex,
/var/lib/spinifex (viperblock volumes included), /var/lib/ovn (NB+SB
DBs), and /etc/openvswitch/conf.db into /var/lib/spinifex-snapshots/
<name>.tar.gz with a sibling .manifest, restarts services in reverse,
and waits for the AWS gateway to come back.

dev-restore inverses: stop services, wipe the same path set, untar the
named bundle, copy /etc/spinifex/ca.pem back into the system trust
store and rebuild the CA bundle (the snapshot doesn't include
/usr/local/share/ca-certificates/spinifex-ca.crt), daemon-reload,
restart in reverse order.

Both refuse to run without -e snapshot_name=<name>, validate the name
matches [A-Za-z0-9._-]+, and produce honest summaries when the gateway
fails to come back (does not mask other ongoing issues).

Tested on dev box: snapshot/restore of 2.4 GiB state (large viperblock
volumes) completes cleanly with no data drift; services all return to
active.

Plan: docs/development/improvements/ansible-dev-extensions.md (mulga).
Bead: mulga-siv-86.3.

* fix(ansible): wrap aws/spx calls in sg spinifex to fix group inheritance

Adds scripts/ansible/scripts/aws-as-spinifex.sh and spx-as-spinifex.sh
helpers that re-enter the command under `sg spinifex -c '...'` with
AWS_PROFILE=spinifex preset. Swaps every AWS / spx admin task across
the status, init, deploy, smoketest, snapshot, and restore roles to
use these via spinifex_aws_cli / spinifex_spx_cli vars.

Root cause (mulga-siv-92): ansible's `become_user` is a no-op when the
target user equals the calling user, so sudo never runs and
supplementary groups are inherited from the operator's invoking shell
rather than re-initialised via initgroups(3). On shells that predate
setup.sh's spinifex group add, the spinifex group is missing and
/etc/spinifex/ca.pem (drwxr-x--- root:spinifex, the AWS CLI's
ca_bundle) is unreadable — aws cli fails with SSL Errno 13. The
helpers re-enter the command in a shell that explicitly assumes the
spinifex group via sg(1), bypassing the issue regardless of how
ansible-playbook is invoked.

Side benefit: drops 50 lines of repeated `become: true / become_user /
environment: AWS_PROFILE` boilerplate across the roles.

Closes mulga-siv-92.

* chore(ansible): add dev-version playbook (build vs install drift)

New `version` role + playbook + Makefile target. Compares sha256 of
built artifacts (bin/spx, viperblock/lib/nbdkit-viperblock-plugin.so,
build/microvm/{vmlinuz,initramfs.cpio.gz}) against their installed
counterparts (/usr/local/bin/spx, plugindir, /usr/share/spinifex/
microvm/*). Prints per-artifact drift verdict plus sub-repo HEAD
short-revs (spinifex/viperblock/predastore) for context. Closes with
either "DRIFT detected — run: make ansible-dev-deploy" or "No drift".

Pure read-only. Faster than dev-status (no AWS/OVN/port probing).

Plan: docs/development/improvements/ansible-dev-extensions.md (mulga).
Bead: mulga-siv-86.9.

* chore(ansible): add missing roles/deploy/meta/main.yml

All other roles have meta/main.yml; deploy was the lone outlier.
Consistency fix. Closes mulga-siv-86.10.

* chore(ansible): add dev-vpc playbook (idempotent dev VPC fixture)

New `vpc` role + playbook + Makefile target. Idempotent
present/absent dispatch creates a deterministic spinifex-dev-vpc with
public subnet (10.99.1.0/24, map-public-ip-on-launch), internet
gateway, route table, and default route. Hooks into smoketest's launch
path — smoketest's existing
Name=map-public-ip-on-launch,Values=true filter now picks it up
instead of falling through to the "no subnet → skip launch" branch.

Idempotency: each step describe-then-create, looking up by Name tag
where possible. Re-running on a converged VPC is changed=0, skipped=5
(the create tasks).

Workaround for mulga-siv-94 (gateway silently drops
--tag-specifications and no-ops create-tags for route tables): role
falls back to "RT associated with our subnet" lookup so tag absence
doesn't break idempotency. create-tags task is kept as a no-op-now
forward-looking step for when the gateway gap is closed.

Teardown (-e dev_vpc_state=absent) enumerates ALL non-main route
tables in the VPC (not just the tagged one), so orphans from prior
runs that landed before the fallback was added get cleaned up too.

Tested end-to-end against josh-banksia dev gateway: create / re-run
(idempotent) / teardown all converge cleanly.

Plan: docs/development/improvements/ansible-dev-extensions.md (mulga).
Bead: mulga-siv-86.4.
2026-05-26 16:14:11 +10:00
Julian Sommer 51c48d8c0e feat: IAM Roles part 1 (#253) 2026-05-26 15:45:46 +10:00
692 changed files with 59950 additions and 20193 deletions
+21
View File
@@ -0,0 +1,21 @@
# CODEOWNERS — GitHub PR review enforcement
#
# Format: <path pattern> <owner> [<owner> …]
# Order matters — last matching pattern wins.
#
# Set the matching branch protection rule on `dev` and `main` to
# "Require review from Code Owners" or this file is advisory only.
# Fall-through: any change needs a team review.
* @mulgadc/mulgadc-engineers
# Network layer is governed by ADR-0006 invariant tests.
# Changes here must respect those clauses — see
# spinifex/network/invariants/ and
# docs/development/proposals/az-local-architecture/0006-spinifex-network-layer-contract.md
/spinifex/network/ @joshsiv-mulga
/spinifex/vpcd/ @joshsiv-mulga
# Invariants suite is the executable ADR-0006 fence — any widening or
# narrowing of the scan roots needs an explicit owner review.
/spinifex/network/invariants/ @joshsiv-mulga
Binary file not shown.

After

Width:  |  Height:  |  Size: 285 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 754 KiB

After

Width:  |  Height:  |  Size: 643 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 175 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

+88
View File
@@ -0,0 +1,88 @@
#!/usr/bin/env bash
# capture-net-diag.sh — write a snapshot of network / OVN / OVS / IPsec
# state into $1 (a directory the caller already created on the local host).
# Designed to run on a spinifex node after an E2E suite, before tofu destroy
# pulls the rug out. Every command is independent — `|| true` so a missing
# binary on one path doesn't abort the rest of the capture.
#
# Output layout (under $1/net-diag/):
# openvswitch-ipsec.status systemctl status (output-only)
# openvswitch-ipsec.journal last 200 journal lines
# ipsec.openvswitch_conf ovs-vsctl get Open_vSwitch . other_config
# ipsec.xfrm_state ip xfrm state list
# ipsec.xfrm_policy ip xfrm policy list
# ovn.sb_show ovn-sbctl show
# ovn.nb_show ovn-nbctl show
# ovs.show ovs-vsctl show
# ovs.flows_br_int ovs-ofctl dump-flows br-int
# ovs.tunnels ovs-appctl ofproto/list-tunnels br-int
# net.links ip -s link show
# net.routes ip route show table all
# net.addrs ip -br addr show
# systemd.failed_units systemctl list-units --state=failed
set +e
OUT="${1:?usage: capture-net-diag.sh <output-dir>}"
mkdir -p "$OUT"
run() {
local label="$1"; shift
{
echo "# $*"
"$@" 2>&1
} > "$OUT/$label" || true
}
run openvswitch-ipsec.status sudo systemctl status openvswitch-ipsec.service --no-pager --full --lines=50
run openvswitch-ipsec.journal sudo journalctl -u openvswitch-ipsec.service --no-pager --output=short-iso -n 200
run ipsec.openvswitch_conf sudo ovs-vsctl get Open_vSwitch . other_config
run ipsec.xfrm_state sudo ip xfrm state list
run ipsec.xfrm_policy sudo ip xfrm policy list
run ovn.sb_show sudo ovn-sbctl show
run ovn.nb_show sudo ovn-nbctl show
run ovn.sb_chassis sudo ovn-sbctl list chassis
run_per_lr() {
local label="$1"; shift
local subcmd="$1"; shift
{
echo "# ovn-nbctl $subcmd <each-logical-router>"
local lrs
lrs="$(sudo ovn-nbctl --bare --columns=name list Logical_Router 2>/dev/null)"
for lr in $lrs; do
echo
echo "=== $lr ==="
sudo ovn-nbctl "$subcmd" "$lr" 2>&1
done
} > "$OUT/$label" || true
}
run_per_lr ovn.lr_routes lr-route-list
run_per_lr ovn.lr_policies lr-policy-list
run_per_lr ovn.lr_nat lr-nat-list
run ovs.show sudo ovs-vsctl show
run ovs.flows_br_int sudo ovs-ofctl dump-flows br-int
run ovs.tunnels sudo ovs-appctl ofproto/list-tunnels br-int
run net.links ip -s link show
run net.routes ip route show table all
run net.addrs ip -br addr show
run net.neigh ip neigh show
run net.bridge_fdb bridge fdb show
run net.conntrack sudo conntrack -L
run systemd.failed_units sudo systemctl list-units --state=failed --no-pager
# Background pcap (started by start-net-capture.sh, if present).
if [ -f /tmp/spinifex-e2e-tcpdump.pid ]; then
PID="$(cat /tmp/spinifex-e2e-tcpdump.pid 2>/dev/null)"
sudo kill -INT "$PID" 2>/dev/null || true
sleep 1
if [ -f /tmp/spinifex-e2e-wan.pcap ]; then
sudo cp /tmp/spinifex-e2e-wan.pcap "$OUT/wan.pcap" 2>/dev/null || true
sudo chmod 0644 "$OUT/wan.pcap" 2>/dev/null || true
fi
fi
exit 0
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env bash
# start-net-capture.sh — kick off background tcpdump on br-wan.
# Pair: capture-net-diag.sh stops it and copies the pcap into net-diag/.
#
# Filter: anything on br-wan involving the EIP pool (192.168.0.0/23) plus
# ARP for the same range. Single ring buffer (-W 1 -G 0) → grows until stop;
# capped at 64 MiB via -C 64. Snaplen 200 bytes (headers + a bit) to keep
# size sane while preserving L2/L3/L4 + first payload bytes.
set +e
PCAP=/tmp/spinifex-e2e-wan.pcap
PIDFILE=/tmp/spinifex-e2e-tcpdump.pid
if [ -f "$PIDFILE" ] && kill -0 "$(cat "$PIDFILE" 2>/dev/null)" 2>/dev/null; then
echo "[start-net-capture] tcpdump already running pid=$(cat "$PIDFILE")"
exit 0
fi
sudo rm -f "$PCAP" "$PIDFILE"
if ! command -v tcpdump >/dev/null 2>&1; then
echo "[start-net-capture] tcpdump not installed — skipping" >&2
exit 0
fi
if ! ip link show br-wan >/dev/null 2>&1; then
echo "[start-net-capture] br-wan absent — skipping" >&2
exit 0
fi
sudo nohup tcpdump -i br-wan -s 200 -C 64 -W 1 -U \
-w "$PCAP" \
'(net 192.168.0.0/23) or (arp and (arp[24:4]&0xfffffe00=0xc0a80000))' \
>/tmp/spinifex-e2e-tcpdump.log 2>&1 &
PID=$!
echo "$PID" | sudo tee "$PIDFILE" >/dev/null
disown
sleep 1
if ! kill -0 "$PID" 2>/dev/null; then
echo "[start-net-capture] tcpdump died — see /tmp/spinifex-e2e-tcpdump.log" >&2
cat /tmp/spinifex-e2e-tcpdump.log >&2 || true
sudo rm -f "$PIDFILE"
exit 1
fi
echo "[start-net-capture] tcpdump pid=$PID pcap=$PCAP"
exit 0
File diff suppressed because it is too large Load Diff
+867 -98
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -34,7 +34,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version: '1.26.3'
go-version: '1.26.4'
- name: Build microVM artifacts (lb-agent + kernel + initramfs)
run: cd spinifex && make build-microvm-image
+53 -11
View File
@@ -73,11 +73,24 @@ build-lb-agent:
@echo -e "\n....Building lb-agent (static)"
CGO_ENABLED=0 GOFIPS140=v1.0.0 go build -ldflags "-s -w" -o ./bin/lb-agent cmd/lb-agent/main.go
build-system-image: ## Build a system image from manifest (use IMAGE=lb)
build-system-image: ## Build a system image from manifest (use IMAGE=lb or IMAGE=eks-server)
ifndef IMAGE
$(error IMAGE is required. Usage: make build-system-image IMAGE=lb)
endif
./scripts/build-system-image.sh scripts/images/$(IMAGE).conf
@if [ -f scripts/images/$(IMAGE).conf ]; then \
./scripts/build-system-image.sh scripts/images/$(IMAGE).conf; \
elif [ -f scripts/images/$(IMAGE)/manifest.conf ]; then \
./scripts/build-system-image.sh scripts/images/$(IMAGE)/manifest.conf; \
else \
echo "ERROR: no manifest at scripts/images/$(IMAGE).conf or scripts/images/$(IMAGE)/manifest.conf"; \
exit 1; \
fi
build-eks-server-image: ## Build the eks-server AMI (K3s control-plane variant)
$(MAKE) build-system-image IMAGE=eks-server
build-eks-agent-image: ## Build the eks-agent AMI (K3s worker variant)
$(MAKE) build-system-image IMAGE=eks-agent
MICROVM_OUT_DIR := build/microvm
MICROVM_ARTIFACTS := $(MICROVM_OUT_DIR)/vmlinuz $(MICROVM_OUT_DIR)/initramfs.cpio.gz
@@ -196,9 +209,9 @@ install-system:
ovn-central ovn-host openvswitch-switch dhcpcd-base
install-go:
@echo -e "\n....Installing Go 1.26.3 for $(ARCH) ($(GO_ARCH))...."
@echo -e "\n....Installing Go 1.26.4 for $(ARCH) ($(GO_ARCH))...."
@if [ ! -d "/usr/local/go" ]; then \
curl -L https://go.dev/dl/go1.26.3.linux-$(GO_ARCH).tar.gz | tar -C /usr/local -xz; \
curl -L https://go.dev/dl/go1.26.4.linux-$(GO_ARCH).tar.gz | tar -C /usr/local -xz; \
else \
echo "Go already installed in /usr/local/go"; \
fi
@@ -281,24 +294,53 @@ distro-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.
#
# Variable overrides: pass EXTRA_VARS="key=val key2=val2" to forward as
# `--extra-vars` to ansible-playbook. Plain `-e` on the make command line is
# make's own flag and does NOT reach ansible.
# make ansible-dev-install EXTRA_VARS="spinifex_external_pool_start=192.168.1.90 spinifex_external_pool_end=192.168.1.99"
EXTRA_VARS ?=
_ANSIBLE_EXTRA = $(if $(strip $(EXTRA_VARS)),--extra-vars "$(EXTRA_VARS)",)
ansible-dev-preflight:
cd scripts/ansible && ansible-playbook playbooks/dev-preflight.yml
cd scripts/ansible && ansible-playbook playbooks/dev-preflight.yml $(_ANSIBLE_EXTRA)
ansible-dev-teardown:
cd scripts/ansible && ansible-playbook playbooks/dev-teardown.yml
cd scripts/ansible && ansible-playbook playbooks/dev-teardown.yml $(_ANSIBLE_EXTRA)
ansible-dev-install:
cd scripts/ansible && ansible-playbook playbooks/dev-install.yml
cd scripts/ansible && ansible-playbook playbooks/dev-install.yml $(_ANSIBLE_EXTRA)
ansible-dev-reset:
cd scripts/ansible && ansible-playbook playbooks/dev-reset.yml
cd scripts/ansible && ansible-playbook playbooks/dev-reset.yml $(_ANSIBLE_EXTRA)
ansible-dev-deploy:
cd scripts/ansible && ansible-playbook playbooks/dev-deploy.yml
cd scripts/ansible && ansible-playbook playbooks/dev-deploy.yml $(_ANSIBLE_EXTRA)
.PHONY: build build-ui build-installer build-lb-agent build-system-image build-microvm-image install-microvm go_build go_run preflight test test-cover test-race test-actions test-harness manifest-check diff-coverage bench run \
ansible-dev-status:
cd scripts/ansible && ansible-playbook playbooks/dev-status.yml
ansible-dev-logs:
cd scripts/ansible && ansible-playbook playbooks/dev-logs.yml
# Snapshot / restore require an explicit -e snapshot_name=<name>. The
# wrapper passes ANSIBLE_EXTRA through so devs can run
# `make ansible-dev-snapshot ANSIBLE_EXTRA='-e snapshot_name=before-merge'`.
ansible-dev-snapshot:
cd scripts/ansible && ansible-playbook playbooks/dev-snapshot.yml $(ANSIBLE_EXTRA)
ansible-dev-restore:
cd scripts/ansible && ansible-playbook playbooks/dev-restore.yml $(ANSIBLE_EXTRA)
ansible-dev-version:
cd scripts/ansible && ansible-playbook playbooks/dev-version.yml
ansible-dev-vpc:
cd scripts/ansible && ansible-playbook playbooks/dev-vpc.yml $(ANSIBLE_EXTRA)
.PHONY: build build-ui build-installer build-lb-agent build-system-image build-eks-server-image build-eks-agent-image build-microvm-image install-microvm go_build go_run preflight test test-cover test-race diff-coverage bench run test-actions test-harness manifest-check diff-coverage bench run \
deploy reinstall clean \
install-system install-go install-aws quickinstall \
lint fix govulncheck \
distro distro-amd64 distro-arm64 distro-clean \
ansible-dev-preflight ansible-dev-teardown ansible-dev-install ansible-dev-reset ansible-dev-deploy
ansible-dev-preflight ansible-dev-teardown ansible-dev-install ansible-dev-reset ansible-dev-deploy ansible-dev-status ansible-dev-logs ansible-dev-snapshot ansible-dev-restore ansible-dev-version ansible-dev-vpc
+10 -3
View File
@@ -1,7 +1,14 @@
[Unit]
Description=Spinifex Daemon (VM Orchestration)
PartOf=spinifex.target
After=spinifex-nats.service spinifex-viperblock.service openvswitch-switch.service ovn-controller.service
After=spinifex-nats.service spinifex-viperblock.service openvswitch-switch.service ovn-controller.service openvswitch-ipsec.service
# Wants=openvswitch-ipsec.service so systemd brings the IPsec userspace
# daemon up before spinifex flips other_config:ipsec_encapsulation=true.
# Without that daemon, OVS holds the cert pointers unused and strongSwan
# never gets IKE config — tunnel traffic is encrypted with no SAs and
# silently dropped. Wants= (not Requires=) keeps daemon bootable on
# single-node / lab hosts where the package is intentionally absent.
Wants=openvswitch-ipsec.service
# No Wants=spinifex-nats.service: Wants= reactivates a stopped unit during
# `systemctl restart spinifex-daemon`, which would mask the standalone-startup
# path the Tier 1 DDIL Scenario B exercises. spinifex.target Wants= covers
@@ -23,7 +30,7 @@ Group=spinifex
ExecStart=/usr/local/bin/spx service spinifex start
Restart=on-failure
RestartSec=5
# DDIL Tier 1 (mulga-siv-58): KillMode=process so systemd only signals the
# DDIL Tier 1: KillMode=process so systemd only signals the
# daemon's main PID, not the cgroup. QEMU children stay alive across
# `systemctl restart spinifex-daemon` and the new daemon reattaches to them
# via the persisted local state file + pidfile + QMP socket. With the
@@ -38,7 +45,7 @@ OOMScoreAdjust=-500
Environment=SPINIFEX_CONFIG_PATH=/etc/spinifex/spinifex.toml
Environment=SPINIFEX_BASE_DIR=/var/lib/spinifex/spinifex/
Environment=SPINIFEX_WAL_DIR=/var/lib/spinifex/spinifex/
# DDIL Tier 1 (mulga-siv-58): QEMU pidfiles, QMP sockets, and the
# DDIL Tier 1: QEMU pidfiles, QMP sockets, and the
# shared qemu.log all resolve via utils.pidPath() — which falls back
# to os.TempDir() (=/tmp) when $HOME/spinifex doesn't exist. With
# PrivateTmp=yes, each daemon start gets a fresh /tmp namespace, so
+2 -2
View File
@@ -89,7 +89,7 @@ func main() {
}
}
// Build nclient4 options — identical to spinifex/services/vpcd/dhcp/nclient4.go.
// Build nclient4 options — standalone DHCP probe for upstream-router debugging.
opts := []nclient4.ClientOpt{
nclient4.WithHWAddr(hwAddr),
nclient4.WithTimeout(*timeout),
@@ -105,7 +105,7 @@ func main() {
}
defer client.Close()
// Identity modifiers — identical to identityModifiers() in vpcd dhcp package.
// Identity modifiers — client-id / hostname / vendor-class for upstream lease tagging.
var mods []dhcpv4.Modifier
if cid != "" {
mods = append(mods, dhcpv4.WithOption(dhcpv4.OptClientIdentifier([]byte(cid))))
+38
View File
@@ -0,0 +1,38 @@
// eks-token-webhook is the K8s TokenReview webhook authenticator baked into
// the eks-server AMI. It decodes the SigV4-presigned GetCallerIdentity URL
// produced by `aws eks get-token`, enforces the X-K8s-Aws-Id cross-cluster
// pin, calls Mulga STS over NATS, and resolves the principal to a TokenReview
// response via the cluster's AccessEntry KV.
//
// This is a placeholder build that returns 503 Service Unavailable on every
// path. It exists so the eks-server AMI build succeeds (INSTALL_BINARIES is
// satisfied) and the K3s server VM boots cleanly. Real implementation lands
// with bead mulga-cs-eks-6c.
package main
import (
"flag"
"log/slog"
"net/http"
"os"
)
func main() {
addr := flag.String("addr", "127.0.0.1:8443", "listen address")
flag.Parse()
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo})))
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Retry-After", "30")
w.Header().Set("Content-Type", "application/json")
http.Error(w, `{"error":"eks-token-webhook stub: real implementation not yet deployed"}`, http.StatusServiceUnavailable)
})
slog.Info("eks-token-webhook stub starting", "addr", *addr)
if err := http.ListenAndServe(*addr, mux); err != nil {
slog.Error("listen failed", "err", err)
os.Exit(1)
}
}
+4 -3
View File
@@ -304,9 +304,10 @@ func installSpinifex(cfg *Config) error {
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.
// dhcpcd-base is present on the installed system (used by setup-ovn.sh
// for WAN DHCP acquisition). 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)
}
+102 -77
View File
@@ -294,12 +294,14 @@ func init() {
adminInitCmd.Flags().String("external-mode", "", "External network mode: 'pool' (default when WAN detected) or '' (disabled)")
adminInitCmd.Flags().String("external-iface", "", "WAN NIC for br-external (auto-detected from default route)")
adminInitCmd.Flags().String("external-source", "", "Pool IP source: 'dhcp' (default when no --external-pool) or 'static' (uses --external-pool range)")
adminInitCmd.Flags().String("external-bind-bridge", "", "Linux bridge for upstream DHCP DORA (default 'br-wan' when --external-source=dhcp)")
adminInitCmd.Flags().String("external-pool", "", "External IP pool range as start-end (e.g., 192.168.1.150-192.168.1.250)")
adminInitCmd.Flags().String("external-gateway", "", "WAN gateway IP (auto-detected from default route)")
adminInitCmd.Flags().String("gateway-ip", "", "OVN gateway router's external IP for SNAT (default: pool range_start for pool mode, required for nat mode without DHCP)")
adminInitCmd.Flags().Int("external-prefix-len", 24, "External pool subnet prefix length (auto-detected)")
adminInitCmd.Flags().Bool("no-external", false, "Disable external networking (overlay-only, no internet for VMs)")
adminInitCmd.Flags().Bool("gpu-passthrough", false, "Enable VFIO GPU passthrough (sets gpu_passthrough = true in daemon config)")
adminInitCmd.Flags().Bool("ipsec", true, "Encrypt intra-AZ Geneve via OVN native IPsec (cluster-wide); disable only for trusted single-rack lab")
// Flags for admin join
adminJoinCmd.Flags().String("region", "ap-southeast-2", "Region for this node")
@@ -601,6 +603,12 @@ func runimagesImportCmd(cmd *cobra.Command, args []string) {
Host: appConfig.Nodes[appConfig.Node].Predastore.Host,
}
mkey, err := utils.LoadViperblockMasterKey(appConfig.Nodes[appConfig.Node].Viperblock.EncryptionKeyFile)
if err != nil {
fmt.Fprintf(os.Stderr, "Could not load viperblock encryption key: %v\n", err)
os.Exit(1)
}
vbConfig := viperblock.VB{
VolumeName: volumeId,
VolumeSize: utils.SafeInt64ToUint64(imageStat.Size()),
@@ -610,7 +618,9 @@ func runimagesImportCmd(cmd *cobra.Command, args []string) {
Size: 0,
},
},
VolumeConfig: manifest,
VolumeConfig: manifest,
MasterKey: mkey,
EncryptionEnabled: mkey != nil,
}
err = v_utils.ImportDiskImage(&s3Config, &vbConfig, extractedImagePath)
@@ -838,12 +848,14 @@ func runAdminInit(cmd *cobra.Command, args []string) {
externalMode, _ := cmd.Flags().GetString("external-mode")
externalIface, _ := cmd.Flags().GetString("external-iface")
externalSource, _ := cmd.Flags().GetString("external-source")
externalBindBridge, _ := cmd.Flags().GetString("external-bind-bridge")
externalPool, _ := cmd.Flags().GetString("external-pool")
externalGateway, _ := cmd.Flags().GetString("external-gateway")
externalPrefixLen, _ := cmd.Flags().GetInt("external-prefix-len")
gatewayIP, _ := cmd.Flags().GetString("gateway-ip")
noExternal, _ := cmd.Flags().GetBool("no-external")
gpuPassthrough, _ := cmd.Flags().GetBool("gpu-passthrough")
ipsecEnabled, _ := cmd.Flags().GetBool("ipsec")
// Fire telemetry in background (completes during init work, waited at end)
noTelemetry, _ := cmd.Flags().GetBool("no-telemetry")
@@ -893,9 +905,9 @@ func runAdminInit(cmd *cobra.Command, args []string) {
fmt.Printf(" %-14s %-18s %-20s %-16s %s\n", iface.Name, iface.IP, iface.Subnet, gw, strings.ToUpper(iface.Role))
}
if detected.LANCount == 0 {
fmt.Println("\n Mode: single-NIC (macvlan for external bridge)")
fmt.Println("\n Mode: single-NIC (veth-bridged external)")
} else {
fmt.Printf("\n Mode: %d LAN + 1 WAN (macvlan for external bridge)\n", detected.LANCount)
fmt.Printf("\n Mode: %d LAN + 1 WAN (veth-bridged external)\n", detected.LANCount)
}
// Apply auto-detected values when flags not explicitly set
@@ -910,13 +922,11 @@ func runAdminInit(cmd *cobra.Command, args []string) {
externalPrefixLen = detected.WAN.PrefixLen
}
// Default mode: always "pool" — with static range if --external-pool
// is given, otherwise with source=dhcp (gateway IP from router DHCP)
// Default mode: always "pool". Source defaults to "static"; if
// --external-pool is omitted the validator below will error with
// a SuggestPoolRange hint.
if externalMode == "" && !cmd.Flags().Changed("external-mode") {
externalMode = "pool"
if externalPool == "" && externalSource == "" {
externalSource = "dhcp"
}
}
}
}
@@ -928,25 +938,30 @@ func runAdminInit(cmd *cobra.Command, args []string) {
os.Exit(1)
}
if externalMode == "pool" {
// Resolve source: if not explicitly set, infer from whether a pool range was given
// Default source: dhcp when no --external-pool, else static.
if externalSource == "" {
if externalPool != "" {
externalSource = "static"
} else {
if externalPool == "" {
externalSource = "dhcp"
} else {
externalSource = "static"
}
}
if externalSource == "dhcp" {
// DHCP source: no static range needed, just gateway
if externalGateway == "" {
fmt.Fprintf(os.Stderr, "❌ Error: --external-gateway is required when --external-mode=pool\n")
switch externalSource {
case "dhcp":
if externalPool != "" {
fmt.Fprintf(os.Stderr, "❌ Error: --external-pool not allowed with --external-source=dhcp (addresses come from upstream DHCP server)\n")
os.Exit(1)
}
if externalBindBridge == "" {
externalBindBridge = "br-wan"
}
case "static":
if externalBindBridge != "" {
fmt.Fprintf(os.Stderr, "❌ Error: --external-bind-bridge only valid with --external-source=dhcp\n")
os.Exit(1)
}
} else {
// Static source: need pool range
if externalPool == "" {
fmt.Fprintf(os.Stderr, "❌ Error: --external-pool is required when --external-mode=pool (e.g., 192.168.1.150-192.168.1.250)\n")
fmt.Fprintf(os.Stderr, " Or use --external-source=dhcp to get IPs from router DHCP\n")
fmt.Fprintf(os.Stderr, "❌ Error: --external-pool is required with --external-source=static (e.g., 192.168.1.150-192.168.1.250)\n")
if detectedNet != nil && detectedNet.WAN != nil {
sugStart, sugEnd := admin.SuggestPoolRange(detectedNet.WAN)
fmt.Fprintf(os.Stderr, " Suggested: --external-pool=%s-%s\n", sugStart, sugEnd)
@@ -954,7 +969,7 @@ func runAdminInit(cmd *cobra.Command, args []string) {
os.Exit(1)
}
if externalGateway == "" {
fmt.Fprintf(os.Stderr, "❌ Error: --external-gateway is required when --external-mode=pool\n")
fmt.Fprintf(os.Stderr, "❌ Error: --external-gateway is required with --external-source=static\n")
os.Exit(1)
}
parts := strings.SplitN(externalPool, "-", 2)
@@ -963,6 +978,9 @@ func runAdminInit(cmd *cobra.Command, args []string) {
os.Exit(1)
}
poolStart, poolEnd = parts[0], parts[1]
default:
fmt.Fprintf(os.Stderr, "❌ Error: --external-source must be 'static' or 'dhcp', got: %s\n", externalSource)
os.Exit(1)
}
}
if externalGateway != "" && net.ParseIP(externalGateway) == nil {
@@ -983,10 +1001,6 @@ func runAdminInit(cmd *cobra.Command, args []string) {
}
}
// For pool/dhcp source, the gateway IP is obtained via DHCP on the
// macvlan/bridge interface (no static pool range or explicit gateway-ip).
useExternalDHCP := externalMode == "pool" && externalSource == "dhcp" && gatewayIP == ""
// Validate IP address format
if net.ParseIP(bindIP) == nil {
fmt.Fprintf(os.Stderr, "❌ Error: Invalid IP address for --bind: %s\n", bindIP)
@@ -1104,6 +1118,18 @@ func runAdminInit(cmd *cobra.Command, args []string) {
// Generate SSL certificates (with bind IP in SANs for multi-node support)
certPath := admin.GenerateCertificatesIfNeeded(configDir, force, bindIP)
// Generate per-node IPsec peer cert when cluster-wide IPsec is enabled
// (default true). Reuses the cluster CA — no intermediate strongSwan PKI.
if ipsecEnabled {
caCertPath := filepath.Join(configDir, "ca.pem")
caKeyPath := filepath.Join(configDir, "ca.key")
if err := admin.GenerateIPSecPeerCert(configDir, caCertPath, caKeyPath, node, bindIP); err != nil {
fmt.Fprintf(os.Stderr, "Error generating IPsec peer certificate: %v\n", err)
os.Exit(1)
}
fmt.Println("🔐 IPsec peer certificate generated (intra-AZ Geneve encryption ON)")
}
// Install CA certificate into system trust store
installCACertificate(filepath.Join(configDir, "ca.pem"))
@@ -1125,30 +1151,32 @@ func runAdminInit(cmd *cobra.Command, args []string) {
isMultiNode := nodes >= 2
if isMultiNode {
// Build cluster-wide network config for propagation to joining nodes
var networkConfig *formation.NetworkConfig
// Build cluster-wide network config for propagation to joining nodes.
// Always emit so the IPSecEnabled flag reaches joiners even when
// external networking is disabled.
networkConfig := &formation.NetworkConfig{
IPSecEnabled: ipsecEnabled,
}
if externalMode != "" {
bootstrapVpcId := utils.GenerateResourceID("vpc")
bootstrapSubnetId := utils.GenerateResourceID("subnet")
bootstrapIgwId := utils.GenerateResourceID("igw")
networkConfig = &formation.NetworkConfig{
ExternalMode: externalMode,
ExternalDHCP: useExternalDHCP,
PoolName: "wan",
PoolSource: externalSource,
PoolStart: poolStart,
PoolEnd: poolEnd,
PoolGateway: externalGateway,
PoolGatewayIP: gatewayIP,
PoolPrefixLen: externalPrefixLen,
PoolDNSServers: dnsServers,
BootstrapAccountId: admin.DefaultAccountID(),
BootstrapVpcId: bootstrapVpcId,
BootstrapSubnetId: bootstrapSubnetId,
BootstrapIgwId: bootstrapIgwId,
BootstrapCidr: handlers_ec2_vpc.DefaultVPCCidr,
BootstrapSubnetCidr: handlers_ec2_vpc.DefaultSubnetCidr,
}
networkConfig.ExternalMode = externalMode
networkConfig.PoolName = "wan"
networkConfig.PoolSource = externalSource
networkConfig.PoolBindBridge = externalBindBridge
networkConfig.PoolStart = poolStart
networkConfig.PoolEnd = poolEnd
networkConfig.PoolGateway = externalGateway
networkConfig.PoolGatewayIP = gatewayIP
networkConfig.PoolPrefixLen = externalPrefixLen
networkConfig.PoolDNSServers = dnsServers
networkConfig.BootstrapAccountId = admin.DefaultAccountID()
networkConfig.BootstrapVpcId = bootstrapVpcId
networkConfig.BootstrapSubnetId = bootstrapSubnetId
networkConfig.BootstrapIgwId = bootstrapIgwId
networkConfig.BootstrapCidr = handlers_ec2_vpc.DefaultVPCCidr
networkConfig.BootstrapSubnetCidr = handlers_ec2_vpc.DefaultSubnetCidr
}
runAdminInitMultiNode(cmd, accessKey, secretKey, accountID, natsToken, clusterName,
@@ -1248,10 +1276,9 @@ func runAdminInit(cmd *cobra.Command, args []string) {
ExternalMode: externalMode,
ExternalIface: externalIface,
DhcpBindBridge: detectedDhcpBindBridge(detectedNet),
ExternalDHCP: useExternalDHCP,
PoolName: "wan",
PoolSource: externalSource,
PoolBindBridge: externalBindBridge,
PoolStart: poolStart,
PoolEnd: poolEnd,
PoolGateway: externalGateway,
@@ -1268,20 +1295,23 @@ func runAdminInit(cmd *cobra.Command, args []string) {
BootstrapSubnetCidr: handlers_ec2_vpc.DefaultSubnetCidr,
GPUPassthrough: gpuPassthrough,
IPSecEnabled: ipsecEnabled,
}
// Print external networking summary
if externalMode != "" {
fmt.Printf("\n📡 External networking: %s\n", externalMode)
fmt.Printf(" WAN interface: %s\n", externalIface)
if poolStart != "" {
switch externalSource {
case "static":
fmt.Printf(" Source: static (IP range)\n")
fmt.Printf(" IP pool: %s - %s\n", poolStart, poolEnd)
fmt.Printf(" ⚠️ Ensure %s-%s is excluded from your router's DHCP range.\n", poolStart, poolEnd)
} else if useExternalDHCP {
fmt.Printf(" Source: dhcp (from router DHCP)\n")
fmt.Println(" Gateway IP: DHCP (obtained during OVN setup)")
} else if gatewayIP != "" {
case "dhcp":
fmt.Printf(" Source: dhcp (upstream DHCP server)\n")
fmt.Printf(" Bind bridge: %s\n", externalBindBridge)
}
if gatewayIP != "" {
fmt.Printf(" Gateway IP: %s (static)\n", gatewayIP)
}
}
@@ -1861,6 +1891,24 @@ func runAdminJoin(cmd *cobra.Command, args []string) {
}
fmt.Printf("✅ Server certificate generated with bind IP: %s\n\n", bindIP)
// Match the leader's intra-AZ IPsec posture: NetworkConfig.IPSecEnabled is
// authoritative. When the formation response omits NetworkConfig entirely
// (no current code path; defensive guard against future regressions) fall
// back to the AWS-parity default of ON.
ipsecEnabled := true
if statusResp.NetworkConfig != nil {
ipsecEnabled = statusResp.NetworkConfig.IPSecEnabled
}
if ipsecEnabled {
caCertPath := filepath.Join(configDir, "ca.pem")
caKeyPath := filepath.Join(configDir, "ca.key")
if err := admin.GenerateIPSecPeerCert(configDir, caCertPath, caKeyPath, node, bindIP); err != nil {
fmt.Fprintf(os.Stderr, "Error generating IPsec peer certificate: %v\n", err)
os.Exit(1)
}
fmt.Println("🔐 IPsec peer certificate generated (intra-AZ Geneve encryption ON)")
}
// Build cluster topology from formation data
clusterRoutes := formation.BuildClusterRoutes(statusResp.Nodes)
predastoreNodes := formation.BuildPredastoreNodes(statusResp.Nodes)
@@ -2270,10 +2318,11 @@ func finalizeNodeSetup(dataDir, certPath, adminAccessKey, adminSecretKey, region
// applyNetworkConfig copies cluster-wide network settings from a formation
// NetworkConfig into ConfigSettings and auto-detects the local WAN interface.
func applyNetworkConfig(settings *admin.ConfigSettings, nc *formation.NetworkConfig) {
settings.IPSecEnabled = nc.IPSecEnabled
settings.ExternalMode = nc.ExternalMode
settings.ExternalDHCP = nc.ExternalDHCP
settings.PoolName = nc.PoolName
settings.PoolSource = nc.PoolSource
settings.PoolBindBridge = nc.PoolBindBridge
settings.PoolStart = nc.PoolStart
settings.PoolEnd = nc.PoolEnd
settings.PoolGateway = nc.PoolGateway
@@ -2292,7 +2341,6 @@ func applyNetworkConfig(settings *admin.ConfigSettings, nc *formation.NetworkCon
detected, err := admin.DetectNetwork()
if err == nil && detected.WAN != nil {
settings.ExternalIface = detected.WAN.Name
settings.DhcpBindBridge = detectedDhcpBindBridge(detected)
}
}
}
@@ -2401,29 +2449,6 @@ func writeBootstrapFilesWithAdmin(configDir, bootstrapDir string, masterKey []by
return handlers_iam.SaveBootstrapData(filepath.Join(bootstrapDir, "bootstrap.json"), bd)
}
// detectedDhcpBindBridge returns the bridge name where the vpcd DHCP client
// should bind its AF_PACKET socket — i.e. the interface that physically sees
// LAN DHCP traffic.
//
// - Default route on a bridge (Linux or OVS, `br-*` prefix): return the
// bridge name verbatim. In veth mode this is the Linux bridge holding the
// WAN NIC; in direct mode this is the OVS bridge holding the WAN NIC.
// - Default route on a bare physical NIC: return "br-wan" as the name the
// installer will create.
//
// Never returns "br-ext". br-ext is the OVN-side bridge owned by
// setup-ovn.sh's ovn-bridge-mappings and never sees LAN DHCP frames.
func detectedDhcpBindBridge(detected *admin.DetectedNetwork) string {
if detected == nil || detected.WAN == nil {
return ""
}
name := detected.WAN.Name
if strings.HasPrefix(name, "br-") {
return name
}
return "br-wan"
}
// detectDNSServers auto-detects DNS servers from the host for the specified
// interface. Uses resolvectl (systemd-resolved) first, then falls back to
// /etc/resolv.conf. Returns up to 3 servers. Falls back to public DNS if none found.
+319 -2
View File
@@ -52,16 +52,62 @@ Must be run as root.`,
Run: runAdminGpuSetup,
}
var adminGpuMigCmd = &cobra.Command{
Use: "mig",
Short: "Manage NVIDIA MIG partitioning on this node",
}
var adminGpuMigStatusCmd = &cobra.Command{
Use: "status",
Short: "Show MIG capability, mode, and slice state for all GPUs on this node",
Run: runAdminGpuMigStatus,
}
var adminGpuMigEnableCmd = &cobra.Command{
Use: "enable",
Short: "Enable MIG mode on all MIG-capable GPUs (or a specific one)",
Long: `Enable NVIDIA MIG mode on all MIG-capable GPUs (or a specific one).
Without --profile the daemon handles slice creation dynamically: each
RunInstances call for a mig.* instance type carves the right profile on
demand and destroys the slices when the last instance stops.
With --profile, slices are pre-carved immediately. The daemon recovers
these slices on restart and serves them as a static pool for that profile.
Must be run as root.`,
Run: runAdminGpuMigEnable,
}
var adminGpuMigDisableCmd = &cobra.Command{
Use: "disable",
Short: "Destroy all MIG instances and disable MIG mode",
Long: `Destroy all GPU instances and disable NVIDIA MIG mode on all MIG-capable
GPUs (or a specific one). Blocked while GPU instances are running.
Must be run as root.`,
Run: runAdminGpuMigDisable,
}
func init() {
adminCmd.AddCommand(adminGpuCmd)
adminGpuCmd.AddCommand(adminGpuStatusCmd)
adminGpuCmd.AddCommand(adminGpuEnableCmd)
adminGpuCmd.AddCommand(adminGpuDisableCmd)
adminGpuCmd.AddCommand(adminGpuSetupCmd)
adminGpuCmd.AddCommand(adminGpuMigCmd)
adminGpuMigCmd.AddCommand(adminGpuMigStatusCmd)
adminGpuMigCmd.AddCommand(adminGpuMigEnableCmd)
adminGpuMigCmd.AddCommand(adminGpuMigDisableCmd)
adminGpuStatusCmd.Flags().String("node", "", "Target node name (default: local node)")
// enable, disable, and setup write to the local spinifex.toml and signal the
// local daemon — they must be run directly on the target host.
adminGpuMigEnableCmd.Flags().String("profile", "", "MIG profile to pre-carve (e.g. \"1g.10gb\"); omit for fully dynamic slice management")
adminGpuMigEnableCmd.Flags().String("gpu", "", "PCI address of GPU to configure (default: all MIG-capable GPUs)")
adminGpuMigDisableCmd.Flags().String("gpu", "", "PCI address of GPU to configure (default: all MIG-capable GPUs)")
// enable, disable, setup, and mig subcommands write to the local spinifex.toml
// and signal the local daemon — they must be run directly on the target host.
}
// gpuNodeStatus queries NATS and returns the NodeStatusResponse for the target node.
@@ -417,6 +463,277 @@ func runAdminGpuSetup(_ *cobra.Command, _ []string) {
gpuToggle(nil, true)
}
func runAdminGpuMigStatus(_ *cobra.Command, _ []string) {
devices, err := gpu.Discover()
if err != nil {
fmt.Fprintf(os.Stderr, "GPU discovery failed: %v\n", err)
os.Exit(1)
}
if len(devices) == 0 {
fmt.Println("No GPUs detected on this node.")
return
}
anyMIG := false
for _, d := range devices {
if d.MIGCapable {
anyMIG = true
}
migMode := "N/A"
if d.MIGCapable {
if d.MIGEnabled {
migMode = "enabled"
} else {
migMode = "disabled"
}
}
fmt.Printf("GPU: %s\n", d.PCIAddress)
fmt.Printf(" Model: %s\n", d.Model)
fmt.Printf(" MIG capable: %v\n", d.MIGCapable)
fmt.Printf(" MIG mode: %s\n", migMode)
if d.MIGEnabled {
instances, listErr := gpu.ListInstances(d.PCIAddress)
if listErr != nil {
fmt.Printf(" Slices: (error: %v)\n", listErr)
} else {
fmt.Printf(" Slices: %d\n", len(instances))
for _, inst := range instances {
fmt.Printf(" GI %d: %-12s mdev: %s\n",
inst.GIID, inst.Profile.Name, inst.MdevPath)
}
}
}
fmt.Println()
}
if !anyMIG {
fmt.Println("No MIG-capable GPUs found. MIG requires an NVIDIA A100, A30, or H100 GPU.")
}
}
func runAdminGpuMigEnable(cmd *cobra.Command, _ []string) {
if os.Getuid() != 0 {
fmt.Fprintln(os.Stderr, "Error: spx admin gpu mig enable must be run as root.")
os.Exit(1)
}
profileName, _ := cmd.Flags().GetString("profile")
targetPCI, _ := cmd.Flags().GetString("gpu")
cfgPath := viper.GetString("config")
if cfgPath == "" {
cfgPath = DefaultConfigFile()
}
cfg, err := config.LoadConfig(cfgPath)
if err != nil {
fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err)
os.Exit(1)
}
resp, err := gpuNodeStatus(cfg.Node)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
if resp.AllocGPUs > 0 {
fmt.Fprintf(os.Stderr, "Error: %d GPU instance(s) are running. Terminate them first.\n", resp.AllocGPUs)
os.Exit(1)
}
devices, err := gpu.Discover()
if err != nil {
fmt.Fprintf(os.Stderr, "GPU discovery failed: %v\n", err)
os.Exit(1)
}
targets := migFilterTargets(devices, targetPCI)
if len(targets) == 0 {
if targetPCI != "" {
fmt.Fprintf(os.Stderr, "Error: no MIG-capable GPU found at PCI address %s\n", targetPCI)
} else {
fmt.Fprintln(os.Stderr, "Error: no MIG-capable GPUs found. MIG requires an NVIDIA A100, A30, or H100 GPU.")
}
os.Exit(1)
}
for _, d := range targets {
fmt.Printf("==> Configuring MIG on %s (%s)\n", d.PCIAddress, d.Model)
if !d.MIGEnabled {
fmt.Println(" Enabling MIG mode...")
if err := gpu.EnableMIGMode(d.PCIAddress); err != nil {
fmt.Fprintf(os.Stderr, " Error: %v\n", err)
os.Exit(1)
}
} else {
fmt.Println(" MIG mode already enabled")
}
if profileName == "" {
// Dynamic mode: the daemon carves slices on demand at RunInstances time.
// Destroy any pre-carved slices so the daemon starts with a free GPU.
existing, _ := gpu.ListInstances(d.PCIAddress)
if len(existing) > 0 {
fmt.Printf(" Destroying %d pre-carved instance(s) so daemon can manage slices dynamically...\n", len(existing))
if err := gpu.DestroyAllInstances(d.PCIAddress); err != nil {
fmt.Fprintf(os.Stderr, " Error: %v\n", err)
os.Exit(1)
}
}
fmt.Println(" Dynamic slice management enabled — use 'mig.*' instance types at RunInstances time.")
continue
}
// Static pre-carve: create a fixed set of slices for the given profile.
profiles, err := gpu.ListProfiles(d.PCIAddress)
if err != nil {
fmt.Fprintf(os.Stderr, " Error listing profiles: %v\n", err)
os.Exit(1)
}
var selected *gpu.MIGProfile
for i, p := range profiles {
if p.Name == profileName {
selected = &profiles[i]
break
}
}
if selected == nil {
names := make([]string, len(profiles))
for i, p := range profiles {
names[i] = p.Name
}
fmt.Fprintf(os.Stderr, " Error: profile %q not found. Available: %s\n",
profileName, strings.Join(names, ", "))
os.Exit(1)
}
existing, _ := gpu.ListInstances(d.PCIAddress)
if len(existing) > 0 {
fmt.Printf(" Destroying %d existing instance(s)...\n", len(existing))
if err := gpu.DestroyAllInstances(d.PCIAddress); err != nil {
fmt.Fprintf(os.Stderr, " Error: %v\n", err)
os.Exit(1)
}
}
fmt.Printf(" Creating MIG instances (profile: %s)...\n", selected.Name)
instances, err := gpu.CreateInstances(d.PCIAddress, *selected)
if err != nil {
fmt.Fprintf(os.Stderr, " Error: %v\n", err)
os.Exit(1)
}
fmt.Printf(" Created %d instance(s)\n", len(instances))
for _, inst := range instances {
fmt.Printf(" GI %d: %s mdev: %s\n", inst.GIID, inst.Profile.Name, inst.MdevPath)
}
}
migReloadDaemon()
fmt.Println("MIG enabled. Run 'spx admin gpu mig status' to verify.")
}
func runAdminGpuMigDisable(cmd *cobra.Command, _ []string) {
if os.Getuid() != 0 {
fmt.Fprintln(os.Stderr, "Error: spx admin gpu mig disable must be run as root.")
os.Exit(1)
}
targetPCI, _ := cmd.Flags().GetString("gpu")
cfgPath := viper.GetString("config")
if cfgPath == "" {
cfgPath = DefaultConfigFile()
}
cfg, err := config.LoadConfig(cfgPath)
if err != nil {
fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err)
os.Exit(1)
}
resp, err := gpuNodeStatus(cfg.Node)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
if resp.AllocGPUs > 0 {
fmt.Fprintf(os.Stderr, "Error: %d GPU instance(s) are running. Terminate them first.\n", resp.AllocGPUs)
os.Exit(1)
}
devices, err := gpu.Discover()
if err != nil {
fmt.Fprintf(os.Stderr, "GPU discovery failed: %v\n", err)
os.Exit(1)
}
targets := migFilterTargets(devices, targetPCI)
if len(targets) == 0 {
if targetPCI != "" {
fmt.Fprintf(os.Stderr, "Error: no MIG-capable GPU found at PCI address %s\n", targetPCI)
} else {
fmt.Fprintln(os.Stderr, "Error: no MIG-capable GPUs found on this node.")
}
os.Exit(1)
}
for _, d := range targets {
fmt.Printf("==> Disabling MIG on %s (%s)\n", d.PCIAddress, d.Model)
if !d.MIGEnabled {
fmt.Println(" MIG mode not active — skipping")
continue
}
fmt.Println(" Destroying all GPU instances...")
if err := gpu.DestroyAllInstances(d.PCIAddress); err != nil {
fmt.Fprintf(os.Stderr, " Error: %v\n", err)
os.Exit(1)
}
fmt.Println(" Disabling MIG mode...")
if err := gpu.DisableMIGMode(d.PCIAddress); err != nil {
fmt.Fprintf(os.Stderr, " Error: %v\n", err)
os.Exit(1)
}
}
fmt.Println("==> Clearing mig_profile from config...")
if err := admin.SetMIGProfile(cfgPath, cfg.Node, ""); err != nil {
fmt.Fprintf(os.Stderr, "Error writing config: %v\n", err)
os.Exit(1)
}
migReloadDaemon()
fmt.Println("MIG disabled. GPUs will operate in whole-GPU passthrough mode.")
}
// migFilterTargets returns all MIG-capable GPUs, optionally filtered to a
// specific PCI address.
func migFilterTargets(devices []gpu.GPUDevice, targetPCI string) []gpu.GPUDevice {
var out []gpu.GPUDevice
for _, d := range devices {
if !d.MIGCapable {
continue
}
if targetPCI != "" && d.PCIAddress != targetPCI {
continue
}
out = append(out, d)
}
return out
}
// migReloadDaemon sends SIGHUP to spinifex-daemon so it picks up the updated
// MIG configuration without a full restart.
func migReloadDaemon() {
out, err := exec.Command("systemctl", "kill", "-s", "HUP", "spinifex-daemon").CombinedOutput()
if err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to signal daemon: %v\n%s\n", err, out)
fmt.Fprintln(os.Stderr, "Restart the daemon manually: sudo systemctl restart spinifex-daemon")
return
}
fmt.Println("==> Daemon signalled (SIGHUP) — MIG pool will be rebuilt on next request.")
}
// gpuSetupCollectVFIOIDs collects vendor:device PCI ID pairs for each GPU and
// all sibling PCI functions on the same device slot (e.g. GPU + HDMI audio).
// All siblings must bind to vfio-pci together for IOMMU group isolation.
+101 -46
View File
@@ -142,49 +142,6 @@ func TestSpinifexTomlTemplate_AdvertiseOmittedWhenEmpty(t *testing.T) {
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.
@@ -206,8 +163,8 @@ wan_bridge = "br-ext"
err := checkLegacyWanBridgeKey("node1", cfgPath)
require.Error(t, err)
assert.Contains(t, err.Error(), "dhcp_bind_bridge")
assert.Contains(t, err.Error(), "wan_bridge")
assert.Contains(t, err.Error(), "Remove")
}
func TestCheckLegacyWanBridgeKey_EnvVarRejected(t *testing.T) {
@@ -218,7 +175,7 @@ func TestCheckLegacyWanBridgeKey_EnvVarRejected(t *testing.T) {
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")
assert.Contains(t, err.Error(), "Remove")
}
func TestCheckLegacyWanBridgeKey_CleanConfigPasses(t *testing.T) {
@@ -228,7 +185,7 @@ func TestCheckLegacyWanBridgeKey_CleanConfigPasses(t *testing.T) {
node = "node1"
[nodes.node1.vpcd]
ovn_nb_addr = "tcp:127.0.0.1:6641"
dhcp_bind_bridge = "br-wan"
external_interface = "enp0s3"
`), 0o644))
viper.Reset()
@@ -331,3 +288,101 @@ func TestImagesRemoveCmd_FlagSchema(t *testing.T) {
// cobraRequiredAnnotation is the annotation key cobra uses to mark required flags.
const cobraRequiredAnnotation = "cobra_annotation_bash_completion_one_required_flag"
// `spx admin init` must expose --external-source and --external-bind-bridge so
// operators can pick the DHCP path for [[network.external_pools]].
func TestAdminInitCmd_ExternalDHCPFlagSchema(t *testing.T) {
sourceFlag := adminInitCmd.Flags().Lookup("external-source")
require.NotNil(t, sourceFlag, "--external-source must be defined")
assert.Equal(t, "", sourceFlag.DefValue)
bindFlag := adminInitCmd.Flags().Lookup("external-bind-bridge")
require.NotNil(t, bindFlag, "--external-bind-bridge must be defined")
assert.Equal(t, "", bindFlag.DefValue)
poolFlag := adminInitCmd.Flags().Lookup("external-pool")
require.NotNil(t, poolFlag)
assert.Equal(t, "", poolFlag.DefValue)
}
// DHCP-sourced pool must emit source + bind_bridge into spinifex.toml so the
// daemon's validator and vpcd's DHCPManager find the bridge they DORA on.
func TestSpinifexTomlTemplate_ExternalPoolDHCPSource(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",
ConfigDir: dir,
OVNNBAddr: "tcp:127.0.0.1:6641",
OVNSBAddr: "tcp:127.0.0.1:6642",
ExternalMode: "pool",
ExternalIface: "eth0",
PoolName: "wan",
PoolSource: "dhcp",
PoolBindBridge: "br-wan",
PoolPrefixLen: 24,
}
require.NoError(t, admin.GenerateConfigFile(path, spinifexTomlTemplate, settings))
data, err := os.ReadFile(path)
require.NoError(t, err)
content := string(data)
assert.Contains(t, content, `source = "dhcp"`)
assert.Contains(t, content, `bind_bridge = "br-wan"`)
assert.NotContains(t, content, "range_start", "dhcp pool must not emit range_start")
assert.NotContains(t, content, "range_end", "dhcp pool must not emit range_end")
assert.NotContains(t, content, "gateway =", "gateway omitted for dhcp pool (discovered from OFFER)")
}
// Static-sourced pool must not emit bind_bridge — the validator rejects it.
func TestSpinifexTomlTemplate_ExternalPoolStaticSource(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",
ConfigDir: dir,
OVNNBAddr: "tcp:127.0.0.1:6641",
OVNSBAddr: "tcp:127.0.0.1:6642",
ExternalMode: "pool",
ExternalIface: "eth0",
PoolName: "wan",
PoolSource: "static",
PoolStart: "192.168.1.150",
PoolEnd: "192.168.1.250",
PoolGateway: "192.168.1.1",
PoolPrefixLen: 24,
}
require.NoError(t, admin.GenerateConfigFile(path, spinifexTomlTemplate, settings))
data, err := os.ReadFile(path)
require.NoError(t, err)
content := string(data)
assert.Contains(t, content, `source = "static"`)
assert.NotContains(t, content, "bind_bridge", "static pool must not emit bind_bridge")
assert.Contains(t, content, `range_start = "192.168.1.150"`)
assert.Contains(t, content, `gateway = "192.168.1.1"`)
}
// Formation joiners must receive PoolBindBridge from the init node so the
// cluster-wide config reaches every chassis intact.
func TestApplyNetworkConfig_PropagatesPoolBindBridge(t *testing.T) {
settings := &admin.ConfigSettings{}
nc := &formation.NetworkConfig{
ExternalMode: "pool",
PoolName: "wan",
PoolSource: "dhcp",
PoolBindBridge: "br-wan",
PoolPrefixLen: 24,
}
applyNetworkConfig(settings, nc)
assert.Equal(t, "dhcp", settings.PoolSource)
assert.Equal(t, "br-wan", settings.PoolBindBridge)
}
+32 -23
View File
@@ -29,7 +29,7 @@ import (
"github.com/mulgadc/spinifex/spinifex/services/predastore"
"github.com/mulgadc/spinifex/spinifex/services/spinifexui"
"github.com/mulgadc/spinifex/spinifex/services/viperblockd"
"github.com/mulgadc/spinifex/spinifex/services/vpcd"
"github.com/mulgadc/spinifex/spinifex/vpcd"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
@@ -283,19 +283,25 @@ var viperblockStartCmd = &cobra.Command{
shardWAL = *nodeConfig.Viperblock.ShardWAL
}
encryptionKeyFile := nodeConfig.Viperblock.EncryptionKeyFile
if envKey := viper.GetString("viperblock-encryption-key-file"); envKey != "" {
encryptionKeyFile = envKey
}
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,
Region: nodeConfig.Predastore.Region,
AccessKey: nodeConfig.Predastore.AccessKey,
SecretKey: nodeConfig.Predastore.SecretKey,
BaseDir: nodeConfig.Predastore.BaseDir,
NodeName: clusterConfig.Node,
ShardWAL: shardWAL,
NatsHost: nodeConfig.NATS.Host,
NatsToken: nodeConfig.NATS.ACL.Token,
NatsCACert: nodeConfig.NATS.CACert,
PluginPath: pluginPath,
S3Host: nodeConfig.Predastore.Host,
Bucket: nodeConfig.Predastore.Bucket,
Region: nodeConfig.Predastore.Region,
AccessKey: nodeConfig.Predastore.AccessKey,
SecretKey: nodeConfig.Predastore.SecretKey,
BaseDir: nodeConfig.Predastore.BaseDir,
NodeName: clusterConfig.Node,
ShardWAL: shardWAL,
EncryptionKeyFile: encryptionKeyFile,
})
if err != nil {
@@ -678,11 +684,8 @@ 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.
// TOML key or `SPINIFEX_VPCD_WAN_BRIDGE` env-var is present. Per D3: no silent
// alias, no auto-rewrite — operator must remove 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") != ""
@@ -695,9 +698,7 @@ func checkLegacyWanBridgeKey(node, cfgFile string) error {
}
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'. "+
"Remove the key entirely; vpcd auto-detects the WAN bridge (br-wan). "+
"Then: sudo systemctl restart spinifex-vpcd",
source,
)
@@ -734,6 +735,7 @@ var vpcdStartCmd = &cobra.Command{
extPools = append(extPools, vpcd.ExternalPoolConfig{
Name: p.Name,
Source: p.Source,
BindBridge: p.BindBridge,
RangeStart: p.RangeStart,
RangeEnd: p.RangeEnd,
Gateway: p.Gateway,
@@ -742,7 +744,6 @@ var vpcdStartCmd = &cobra.Command{
DNSServers: p.DNSServers,
Region: p.Region,
AZ: p.AZ,
DhcpBindBridge: nodeConfig.VPCD.DhcpBindBridge,
GwLrpRangeStart: p.GwLrpRangeStart,
GwLrpRangeEnd: p.GwLrpRangeEnd,
})
@@ -778,8 +779,8 @@ var vpcdStartCmd = &cobra.Command{
ExternalPools: extPools,
Bootstrap: bootstrap,
ExternalInterface: nodeConfig.VPCD.ExternalInterface,
DhcpBindBridge: nodeConfig.VPCD.DhcpBindBridge,
BridgeMode: nodeConfig.VPCD.BridgeMode,
AZ: nodeConfig.AZ,
})
if err != nil {
fmt.Println("Error starting vpcd service:", err)
@@ -916,6 +917,14 @@ func init() {
viper.BindEnv("plugin-path", "SPINIFEX_VIPERBLOCK_PLUGIN_PATH")
viper.BindPFlag("plugin-path", predastoreCmd.PersistentFlags().Lookup("plugin-path"))
// Viperblock at-rest encryption master key (shared with other on-node
// services via group ownership; mode 0640 or stricter). Distinct viper
// key from predastore's encryption-key-file so the two BindPFlag calls
// don't collide globally.
viperblockCmd.PersistentFlags().String("encryption-key-file", "", "Path to the 32-byte AES-256 master key file for at-rest encryption (empty disables encryption)")
viper.BindEnv("viperblock-encryption-key-file", "SPINIFEX_VIPERBLOCK_ENCRYPTION_KEY_FILE")
viper.BindPFlag("viperblock-encryption-key-file", viperblockCmd.PersistentFlags().Lookup("encryption-key-file"))
viperblockCmd.AddCommand(viperblockStartCmd)
viperblockCmd.AddCommand(viperblockStopCmd)
viperblockCmd.AddCommand(viperblockStatusCmd)
+8 -14
View File
@@ -1,5 +1,5 @@
# Domain configuration file in TOML format.
version = "2"
version = "3"
epoch = 1
node = "{{.Node}}"
{{- if .OperatorEmail}}
@@ -7,24 +7,27 @@ node = "{{.Node}}"
[operator]
email = "{{.OperatorEmail}}"
{{- end}}
{{- if .ExternalMode}}
[network]
ipsec_enabled = {{.IPSecEnabled}}
{{- if .ExternalMode}}
external_mode = "{{.ExternalMode}}"
{{- if .ExternalDHCP}}
external_dhcp = true
{{- end}}
[[network.external_pools]]
name = "{{.PoolName}}"
{{- if .PoolSource}}
source = "{{.PoolSource}}"
{{- end}}
{{- if .PoolBindBridge}}
bind_bridge = "{{.PoolBindBridge}}"
{{- end}}
{{- if .PoolStart}}
range_start = "{{.PoolStart}}"
range_end = "{{.PoolEnd}}"
{{- end}}
{{- if .PoolGateway}}
gateway = "{{.PoolGateway}}"
{{- end}}
{{- if .PoolGatewayIP}}
gateway_ip = "{{.PoolGatewayIP}}"
{{- end}}
@@ -97,15 +100,6 @@ 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"
+523 -499
View File
File diff suppressed because it is too large Load Diff
+27 -51
View File
@@ -7,6 +7,11 @@ tags:
- passthrough
- vfio
- ec2
sections:
- overview
- prerequisites
- instructions
- troubleshooting
resources:
- title: "Spinifex Repository"
url: "https://github.com/mulgadc/spinifex"
@@ -14,26 +19,6 @@ resources:
url: "https://docs.aws.amazon.com/ec2/latest/instancetypes/ac.html"
---
# GPU Passthrough
> Configure VFIO GPU passthrough on a Spinifex node to expose NVIDIA or AMD GPUs to EC2 instances.
> **Note:** GPU passthrough is supported on x86_64 hosts only.
## Table of Contents
- [Overview](#overview)
- [Prerequisites](#prerequisites)
- [Host Setup](#host-setup)
- [Enable Passthrough](#enable-passthrough)
- [Status and Monitoring](#status-and-monitoring)
- [GPU Instance Types](#gpu-instance-types)
- [Launching GPU Instances](#launching-gpu-instances)
- [Disable Passthrough](#disable-passthrough)
- [Troubleshooting](#troubleshooting)
---
## Overview
Spinifex supports VFIO-based GPU passthrough, binding NVIDIA and AMD GPUs directly to guest VMs via the `vfio-pci` kernel driver. Once configured, the node exposes GPU-enabled EC2 instance types and the GPU is allocated exclusively to individual instances.
@@ -42,9 +27,9 @@ At startup, Spinifex always probes for GPU hardware and surfaces the result in t
The passthrough state is reflected in the login banner:
![GPU not yet enabled](../../../.github/assets/images/gpu-banner1.png)
<img src="../../../.github/assets/images/gpu-banner1.png" alt="GPU not yet enabled">
---
> **Note:** GPU passthrough is supported on x86_64 hosts only.
## Prerequisites
@@ -52,9 +37,9 @@ The passthrough state is reflected in the login banner:
- An NVIDIA or AMD GPU must be physically installed.
- The Spinifex node must be running on bare metal (not inside a VM).
---
## Instructions
## Host Setup
### Host Setup
Run the one-time host configuration as root. This command is idempotent — safe to re-run after a reboot.
@@ -79,9 +64,7 @@ Setup complete — reboot required.
Then run: sudo spx admin gpu enable
```
---
## Enable Passthrough
### Enable Passthrough
After setup and reboot, enable GPU passthrough:
@@ -91,14 +74,11 @@ sudo spx admin gpu enable
The command writes the configuration, signals the daemon (`SIGHUP`), and waits up to 30 seconds for the daemon to confirm the new state. On success it prints the current GPU status.
<img src="../../../.github/assets/images/gpu-enabled.png" alt="GPU enabled">
![GPU enabled](../../../.github/assets/images/gpu-enabled.png)
### Status and Monitoring
---
## Status and Monitoring
### Per-node status
**Per-node status:**
```bash
spx admin gpu status
@@ -108,7 +88,7 @@ spx admin gpu status --node <node-name>
Output includes hardware detected, IOMMU state, vfio-pci binding, passthrough enabled/disabled, GPU pool allocation (`allocated/total`), and the GPU-capable EC2 instance types available on that node.
### Cluster view
**Cluster view:**
`spx top nodes` includes a `GPU (used/total)` column:
@@ -122,30 +102,30 @@ Output includes hardware detected, IOMMU state, vfio-pci binding, passthrough en
spx top nodes
```
---
### GPU Instance Types
## GPU Instance Types
GPU instance types are derived from detected hardware.
To list available GPU instance types on the current node:
GPU instance types are derived from detected hardware. To list available GPU instance types on the current node:
```bash
aws ec2 describe-instance-types \
--filters Name=instance-type,Values=g*
```
---
### Launching GPU Instances
## Launching GPU Instances
Import the GPU-enabled AMI before launching:
Spinifex ships two pre-built GPU guest images. Import the one that matches your host hardware before launching:
```bash
spx admin images import --name ubuntu-gpu-nvidia # NVIDIA hosts
spx admin images import --name ubuntu-gpu-amd # AMD hosts
spx admin images import --name ubuntu-26.04-nvidia-gpu-x86_64 # NVIDIA hosts
spx admin images import --name ubuntu-26.04-amd-gpu-x86_64 # AMD hosts
```
These images are built on Ubuntu 26.04 and serve as a base for GPU workloads. Both include Docker CE, Python 3, and common utilities (`git`, `curl`, `htop`, `tmux`, `ffmpeg`).
**NVIDIA** (`ubuntu-26.04-nvidia-gpu-x86_64`): NVIDIA server driver with DKMS pre-built against the image kernel, `nvidia-smi`, nvidia-container-toolkit wired into Docker (`--gpus all` works on first boot). CUDA toolkit and cuDNN are not included — use an NGC container or install inside the instance.
**AMD** (`ubuntu-26.04-amd-gpu-x86_64`): `linux-firmware` (amdgpu firmware blobs), ROCm 7.2 CLI tools (`rocm-smi`, `rocminfo`, `amd-smi`). Full ROCm compute libraries (`rocblas`, etc.) are not included — install inside the instance as needed.
Launch a GPU instance:
```bash
@@ -165,9 +145,7 @@ nvidia-smi
rocm-smi
```
---
## Disable Passthrough
### Disable Passthrough
Passthrough cannot be disabled while GPU instances are running. Terminate all GPU instances first, then:
@@ -177,8 +155,6 @@ sudo spx admin gpu disable
The command signals the daemon and waits for confirmation. The GPU is released back to the host kernel on the next reboot (the vfio-pci binding persists until `setup` is re-run without the blacklists).
---
## Troubleshooting
### IOMMU not active after reboot
@@ -32,7 +32,7 @@ This guide builds Spinifex from source. For production deployments, the [binary
**Requirements:**
- Ubuntu 26.04 / Debian 13
- Go 1.26.3+
- Go 1.26.4+
- GCC, make, pkg-config
- QEMU/KVM, OVN/Open vSwitch, AWS CLI v2, dhcpcd-base
@@ -0,0 +1,266 @@
---
title: "Multi-Tenant AI on Supermicro H14 with AMD MI350X"
description: "Provision isolated GPU VMs for simultaneous AI workloads on a Supermicro H14 bare-metal node using Spinifex's EC2-compatible API."
category: "Reference Architectures"
tags:
- supermicro
- amd
- mi350x
- gpu-passthrough
- multi-tenant
- vllm
resources:
- title: "GPU Passthrough"
url: "/docs/gpu-passthrough"
- title: "Launching Instances"
url: "/docs/launching-instances"
- title: "AMD Instinct MI350X"
url: "https://www.amd.com/en/products/accelerators/instinct/mi300/mi350x.html"
- title: "vLLM Documentation"
url: "https://docs.vllm.ai"
sections:
- overview
- prerequisites
- instructions
---
## Overview
This document serves as a tutorial for setting up multi-tenant AI workloads with Spinifex. The reference architecture for this tutorial is the Supermicro H14 platform - specifications listed below.
The H14 chassis comes equipped with **8× AMD Instinct MI350X** (288 GB HBM3e each, 2.3 TB total). We used Spinifex to provision three isolated VMs, each assigned 2× MI350X via direct PCIe passthrough, and ran three computationally intensive workloads simultaneously: a YOLO11x vision model, a Qwen3-VL 235B FP8 vision language model, and a Llama 3.3 70B large language model.
<p><video src="https://iso.mulgadc.com/clipped-demo.mp4" controls width="100%" style="border-radius:6px"></video></p>
### Platform
| Component | Specification |
|---|---|
| **Bare-metal host** | Supermicro H14 |
| **Host OS** | Ubuntu 24.04 LTS or Debian 13 (minimum) |
| **Orchestration** | Spinifex — EC2-compatible bare-metal API |
| **Guest OS** | Ubuntu 26.04 LTS |
| **GPUs** | 8× AMD Instinct MI350X (288 GB HBM3e each, 2.3 TB total) |
| **GPU passthrough** | PCIe passthrough via vfio-pci |
| **Instance type** | `g7e.12xlarge` — 2× MI350X per VM |
| **Container runtime** | Docker (with ROCm device access) |
| **Inference runtime** | vLLM (`rocm/vllm` image) |
| **VM network** | Internal VPC, 192.168.10.0/24 |
| **Block storage** | Viperblock — EBS-compatible, local NVMe-backed |
Spinifex runs on the bare-metal host and exposes an EC2-compatible API to any standard `aws ec2` tooling. Guest VMs receive their GPU allocation at the hardware level — each VM's OS sees the MI350Xs as native PCIe devices with no virtualisation layer in the data path. Workloads across VMs share no GPU memory, no address space, and no network segment except through the VPC.
GPU passthrough requires a host kernel and OS that supports vfio-pci. Ubuntu 24.04 LTS (kernel 6.8+) and Debian 13 (kernel 6.12+) are the tested minimum baselines for the host. Guest VMs run Ubuntu 26.04 LTS.
## Prerequisites
- Supermicro H14 with 8× AMD Instinct MI350X installed
- Host OS: **Ubuntu 24.04 LTS** (kernel 6.8+) or **Debian 13** (kernel 6.12+) — minimum for vfio-pci support
- Spinifex installed and all services running (`systemctl status spinifex.target`)
- AMD GPU AMI registered (`ami-ubuntu-amd-gpu`) — Ubuntu 26.04 LTS with ROCm-compatible kernel
- AWS CLI configured with `AWS_PROFILE=spinifex` pointing at the Spinifex endpoint
- SSH key pair imported into Spinifex, VPC and security group created (see [Launching Instances](/docs/launching-instances))
GPU passthrough must be configured before launching GPU instances. This is a one-time step per host.
```bash
# Detect GPUs and bind to vfio-pci (requires reboot)
sudo spx admin gpu setup
# After rebooting — confirm passthrough is active and signal the daemon
sudo spx admin gpu enable
```
`spx admin gpu setup` blacklists the AMD driver on the host and binds each GPU to `vfio-pci`. After reboot, `spx admin gpu enable` verifies the binding and makes the GPU pool available to `RunInstances`.
<img src="../../../.github/assets/images/gpu-enabled.png" alt="Spinifex console confirming PCIe passthrough is active for each GPU">
To check available GPU instance types:
```bash
export AWS_PROFILE=spinifex
aws ec2 describe-instance-types \
--query 'InstanceTypes[?GpuInfo].[InstanceType,GpuInfo.Gpus[0].Count,GpuInfo.Gpus[0].Name]' \
--output table
```
## Instructions
### 1. Provision the VMs
Each VM was provisioned using standard AWS EC2 CLI commands — the only change from a normal AWS workflow is `AWS_PROFILE=spinifex`, which redirects the CLI to Spinifex's local EC2-compatible endpoint instead of AWS:
| VM | Instance type | GPUs | Role | Model |
|---|---|---|---|---|
| `vm-yolo` | g7e.12xlarge | 2× MI350X | Real-time object detection | YOLO11x |
| `vm-vlm` | g7e.12xlarge | 2× MI350X | Multi-modal scene analysis | Qwen3-VL 235B FP8 |
| `vm-chat` | g7e.12xlarge | 2× MI350X | Conversational LLM | Llama 3.3 70B |
Six of the eight available MI350Xs are claimed here; the remaining two stay idle on the host, available for a fourth tenant without touching the existing three.
```bash
export AWS_PROFILE=spinifex
# vm-yolo — YOLO11x object detection, 200 GB disk
YOLO_ID=$(aws ec2 run-instances \
--image-id ami-ubuntu-amd-gpu \
--instance-type g7e.12xlarge \
--key-name spinifex-key \
--subnet-id <subnet-id> \
--security-group-ids <sg-id> \
--block-device-mappings 'DeviceName=/dev/sda1,Ebs={VolumeSize=200,DeleteOnTermination=true}' \
--count 1 \
--query 'Instances[0].InstanceId' --output text)
echo "vm-yolo launched: $YOLO_ID"
# vm-vlm — Qwen3-VL 235B FP8, 600 GB disk for weights
VLM_ID=$(aws ec2 run-instances \
--image-id ami-ubuntu-amd-gpu \
--instance-type g7e.12xlarge \
--key-name spinifex-key \
--subnet-id <subnet-id> \
--security-group-ids <sg-id> \
--block-device-mappings 'DeviceName=/dev/sda1,Ebs={VolumeSize=600,DeleteOnTermination=true}' \
--count 1 \
--query 'Instances[0].InstanceId' --output text)
echo "vm-vlm launched: $VLM_ID"
# vm-chat — Llama 3.3 70B, 300 GB disk
CHAT_ID=$(aws ec2 run-instances \
--image-id ami-ubuntu-amd-gpu \
--instance-type g7e.12xlarge \
--key-name spinifex-key \
--subnet-id <subnet-id> \
--security-group-ids <sg-id> \
--block-device-mappings 'DeviceName=/dev/sda1,Ebs={VolumeSize=300,DeleteOnTermination=true}' \
--count 1 \
--query 'Instances[0].InstanceId' --output text)
echo "vm-chat launched: $CHAT_ID"
# Wait for all three to reach running state
for ID in "$YOLO_ID" "$VLM_ID" "$CHAT_ID"; do
aws ec2 wait instance-running --instance-ids "$ID"
echo "$ID is running"
done
# Retrieve IPs (Spinifex assigns addresses from the internal VPC)
YOLO_IP=$(aws ec2 describe-instances --instance-ids "$YOLO_ID" \
--query 'Reservations[0].Instances[0].PublicIpAddress' --output text)
VLM_IP=$(aws ec2 describe-instances --instance-ids "$VLM_ID" \
--query 'Reservations[0].Instances[0].PublicIpAddress' --output text)
CHAT_IP=$(aws ec2 describe-instances --instance-ids "$CHAT_ID" \
--query 'Reservations[0].Instances[0].PublicIpAddress' --output text)
echo "vm-yolo: $YOLO_IP"
echo "vm-vlm: $VLM_IP"
echo "vm-chat: $CHAT_IP"
```
### 2. Verify GPU access
Once SSH is available, confirm both GPUs are visible inside each VM:
```bash
ssh -i ~/.ssh/spinifex-key ubuntu@$CHAT_IP 'lspci | grep -i amd'
```
Or install and run `amd-smi`:
```bash
ssh -i ~/.ssh/spinifex-key ubuntu@$CHAT_IP \
'curl -fsSL https://repo.radeon.com/rocm/rocm.gpg.key | gpg --dearmor | sudo tee /etc/apt/keyrings/rocm.gpg >/dev/null && \
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/rocm.gpg] https://repo.radeon.com/rocm/apt/6.3 noble main" | sudo tee /etc/apt/sources.list.d/rocm.list && \
sudo apt-get update -qq && sudo apt-get install -y -q amd-smi-lib && \
amd-smi list'
```
<img src="../../../.github/assets/images/2gpus.png" alt="amd-smi inside vm-chat confirming two MI350Xs are directly attached, each with a unique UUID">
Two MI350X entries with distinct UUIDs confirm direct PCIe passthrough is working.
### 3. The orchestration layer
Spinifex provides an EC2-compatible API on bare metal. The relevant capabilities for this demo:
- **Multi-GPU claim per VM.** Each `g7e.12xlarge` requires two PCIe addresses claimed as a unit. Spinifex queries the available GPU pool on the host and assigns them to the guest VM at creation time, rolling back if the full allocation cannot be satisfied.
- **PCIe passthrough via vfio-pci.** All 8 MI350Xs are bound to `vfio-pci` on the bare-metal host. Each VM gets its GPUs at the hardware level.
- **Internal VPC (192.168.10.0/24).** VMs communicate directly without traversing the host's public network.
The result: three completely independent EC2 instances, each running their own separate workloads using only the GPU resources they were allocated.
### 4. Test methodology
Two capture runs were made against the same workload script: **30 s baseline** (YOLO and VLM models running, no chat load) → **25 scripted Llama chat prompts** at 15 s intervals → **30 s settle tail**.
The only difference between the two runs was tensor parallelism:
- **Demo 1 (TP=1):** each VM uses one of its two allocated GPUs. The second sits idle.
- **Demo 2 (TP=2):** each VM shards its model symmetrically across both GPUs using AMD Infinity Fabric (XGMI).
| Metric | TP=1 baseline (Demo 1) | TP=2 (Demo 2) |
|---|---|---|
| Wall clock | 613 s | 600 s |
| YOLO avg fps | 16.91 (min 15.3 / max 18.0) | 17.09 (min 15.6 / max 18.1) |
| Qwen peak tok/s | 61.7 | 53.0 |
| Llama peak tok/s | 36.4 | **53.1** |
| Both GPUs active (chat VM) | no — GPU 1 idle | **yes — symmetric** |
| VRAM per GPU (Qwen) | 259 GB on one, 0.3 GB on other | 241 GB on **both** |
| VRAM per GPU (Llama) | 261 GB on one, 0.3 GB on other | 248 GB on **both** |
### 5. Results
### Inter-tenant isolation
The charts below show the three independent workloads spiking activity on their associated GPUs, measured in GFX utilisation and power draw. YOLO average FPS stays at ~17 despite utilisation in the YOLO VM never exceeding 20%, indicating a bottleneck in networking or encoding rather than inference. Each chart highlights workload independence — pulses of GPU activity share no correlation across VMs.
<img src="../../../.github/assets/images/smci/smci-demo1-gpu-utilisation.png" alt="GPU utilisation across all three VMs — Demo 1 TP=1">
GPU utilisation per VM across the full Demo 1 capture.
<img src="../../../.github/assets/images/smci/smci-demo2-gpu-power.png" alt="GPU power draw per workload — Demo 2 TP=2">
Power draw per VM during Demo 2.
### Single vs double GPU
When TP=2 is enabled, each VM shards its model symmetrically across both GPUs. Inside the chat VM, both GPUs pulse together on every request, each holding exactly **248 GB of VRAM** (weights split + KV cache shards). With TP=1, GPU 1 sat at 0% for the entire run.
<img src="../../../.github/assets/images/smci/smci-demo2-per-gpu-chat.png" alt="Llama 3.3 70B per-GPU activity — TP=2">
Per-GPU utilisation inside `vm-chat` with TP=2.
<img src="../../../.github/assets/images/smci/smci-demo1-per-gpu-chat.png" alt="Llama 3.3 70B per-GPU activity — TP=1">
Same VM with TP=1.
### vLLM generation throughput
With TP=1, Qwen peaks at ~60 tok/s and Llama caps around 35 tok/s — both compute-bound on a single MI350X. Enabling TP=2 lifts Llama's peak to ~50 tok/s (+47%) as generation is distributed symmetrically across both GPUs. Qwen's throughput is slightly lower under TP=2 due to collective overhead on the shared-memory transport.
<img src="../../../.github/assets/images/smci/smci-demo1-vllm-throughput.png" alt="vLLM generation throughput — TP=1">
Generation throughput during Demo 1 (TP=1).
<img src="../../../.github/assets/images/smci/smci-demo2-vllm-throughput.png" alt="vLLM generation throughput — TP=2">
Generation throughput during Demo 2 (TP=2).
### 6. Teardown
```bash
# Stop workloads on each VM
for IP in "$YOLO_IP" "$VLM_IP" "$CHAT_IP"; do
ssh -i ~/.ssh/spinifex-key ubuntu@$IP 'docker stop $(docker ps -q)' || true
done
# Terminate all three instances — releases 6× MI350X back to the Spinifex pool
aws ec2 terminate-instances --instance-ids "$YOLO_ID" "$VLM_ID" "$CHAT_ID"
```
The six MI350Xs are immediately returned to the pool once the instances terminate, ready for a new allocation without touching the host.
### 7. Conclusion
This tutorial demonstrates how Spinifex can turn a single bare-metal chassis into a multi-tenant AI serving platform. Spinifex utilises the flexibility of PCIe passthrough to allocate GPU resources in whichever configuration is required by the workload/s.
Importantly, it does so with standard `aws ec2` CLI calls — `run-instances`, `describe-instances`, `terminate-instances` — against Spinifex's EC2-compatible endpoint. Teams already operating AWS infrastructure can point their existing tooling at a Spinifex node with a single profile change, against GPUs that sit in their own rack.
@@ -0,0 +1,319 @@
---
title: "Arcee Trinity 400B FP8 on AMD MI350X with Spinifex"
description: "Provision, load, and benchmark arcee-ai/Trinity-Large-Preview-FP8 across two AMD MI350X GPUs on a Supermicro H14 using Spinifex's EC2-compatible API."
category: "Reference Architectures"
tags:
- supermicro
- amd
- mi350x
- gpu-passthrough
- vllm
- llm
- arcee
resources:
- title: "GPU Passthrough"
url: "/docs/gpu-passthrough"
- title: "Launching Instances"
url: "/docs/launching-instances"
- title: "Arcee AI Trinity"
url: "https://www.arcee.ai/trinity"
- title: "Trinity on HuggingFace"
url: "https://huggingface.co/arcee-ai/Trinity-Large-Preview-FP8"
- title: "AMD Instinct MI350X"
url: "https://www.amd.com/en/products/accelerators/instinct/mi300/mi350x.html"
- title: "vLLM Documentation"
url: "https://docs.vllm.ai"
sections:
- overview
- prerequisites
- instructions
---
## Overview
### About Trinity
[Trinity](https://www.arcee.ai/trinity) is a family of open-weight language models from [Arcee AI](https://www.arcee.ai), built specifically for production agentic workloads: reliable tool calling, structured JSON output, coherent multi-turn conversations, and long-context reasoning. The family spans four sizes — Nano (6B), Mini (26B), Large Preview (400B), and Large Thinking (400B reasoning-optimised), with consistent capabilities and API surface across all of them. A workflow validated on Nano can be promoted to Large with no prompt changes.
Trinity Large Preview is a **sparse Mixture-of-Experts (MoE)** model. The headline number is 400B total parameters, but only **13B parameters activate per token** — the MoE routing selects a subset of experts for each forward pass, so inference cost is closer to a much smaller dense model than the raw parameter count suggests. The result is a 400B-class model with a **512K token context window** that fits on two high-memory GPUs and generates at competitive throughput.
Arcee has trained Trinity with a heavy focus on agent reliability — function selection accuracy, valid parameter generation, graceful failure recovery, and schema adherence for structured outputs. It is available as open weights on HuggingFace and is natively compatible with vLLM, SGLang, and llama.cpp.
### This guide
This writeup covers running Trinity Large Preview FP8 on a Supermicro H14 bare-metal node via Spinifex — our EC2-compatible orchestration layer. Two AMD Instinct MI350X GPUs (288 GB HBM3e each, 576 GB combined) provide just enough headroom for the FP8 weights plus active KV cache, provisioned and managed with standard `aws ec2` CLI commands.
At that scale the FP8 weights require around 400 GB of GPU memory — more than a single MI350X can hold. This guide covers splitting the model symmetrically across two MI350Xs using vLLM tensor parallelism (TP=2), running on a `g7e.12xlarge` instance provisioned via Spinifex on the H14.
The complete stack:
| Layer | Detail |
|---|---|
| Bare-metal host | Supermicro H14, 8× AMD Instinct MI350X |
| Orchestration | Spinifex (EC2-compatible API) |
| Instance type | `g7e.12xlarge` — 2× MI350X via PCIe passthrough |
| Disk | 800 GB (model weights alone are ~400 GB) |
| Inference runtime | vLLM (`rocm/vllm` Docker image) |
| Tensor parallelism | TP=2 — model sharded symmetrically across GPU 0 and GPU 1 |
| Model | `arcee-ai/Trinity-Large-Preview-FP8` |
### Platform
| Component | Specification |
|---|---|
| **Bare-metal host** | Supermicro H14 |
| **Host OS** | Ubuntu 24.04 LTS or Debian 13 (minimum) |
| **Orchestration** | Spinifex — EC2-compatible bare-metal API |
| **Guest OS** | Ubuntu 26.04 LTS |
| **GPUs** | 2× AMD Instinct MI350X (288 GB HBM3e each, 576 GB combined) |
| **GPU passthrough** | PCIe passthrough via vfio-pci |
| **Instance type** | `g7e.12xlarge` |
| **Container runtime** | Docker (with ROCm device access) |
| **Inference runtime** | vLLM (`rocm/vllm` image), tensor parallelism TP=2 |
| **Block storage** | Viperblock — EBS-compatible, 800 GB |
| **Model** | `arcee-ai/Trinity-Large-Preview-FP8` (400B MoE, ~13B active/token) |
Spinifex runs on the bare-metal host and presents an EC2-compatible API endpoint. Launching a `g7e.12xlarge` atomically claims two MI350Xs from the host's GPU pool and binds them to the guest VM via PCIe passthrough — the guest OS communicates with the hardware directly, with no software virtualisation layer in the data path. When the instance terminates, those two GPUs are immediately returned to the pool.
GPU passthrough requires a host kernel and OS that supports vfio-pci. Ubuntu 24.04 LTS (kernel 6.8+) and Debian 13 (kernel 6.12+) are the tested minimum baselines for the host. Guest VMs run Ubuntu 26.04 LTS, which ships with the ROCm-compatible kernel and userspace expected by the AMD GPU AMI.
## Prerequisites
- Supermicro H14 with at least 2× AMD Instinct MI350X installed
- Host OS: **Ubuntu 24.04 LTS** (kernel 6.8+) or **Debian 13** (kernel 6.12+) — minimum for vfio-pci support
- Spinifex installed and all services running (`systemctl status spinifex.target`)
- GPU passthrough configured — `spx admin gpu setup` (reboot required) then `spx admin gpu enable`
- AMD GPU AMI registered (`ami-ubuntu-amd-gpu`) — Ubuntu 26.04 LTS with ROCm-compatible kernel
- AWS CLI configured with `AWS_PROFILE=spinifex` pointing at the Spinifex endpoint
- SSH key pair imported, VPC and security group created (see [Launching Instances](/docs/launching-instances))
- Docker installed in the guest VM (included in the AMD GPU AMI)
- HuggingFace account with access to `arcee-ai/Trinity-Large-Preview-FP8`
GPU passthrough must be configured before launching GPU instances:
```bash
sudo spx admin gpu setup # binds GPUs to vfio-pci — requires reboot
# ... reboot ...
sudo spx admin gpu enable # confirms passthrough and makes GPU pool available
```
## Instructions
### 1. Provision the instance
Spinifex exposes a standard EC2 API on the bare-metal host. The only difference from a real AWS workflow is `AWS_PROFILE=spinifex`.
```bash
export AWS_PROFILE=spinifex
# Check available GPU instance types
aws ec2 describe-instance-types \
--query 'InstanceTypes[?GpuInfo].[InstanceType,GpuInfo.Gpus[0].Count,GpuInfo.Gpus[0].Name]' \
--output table
# Launch a g7e.12xlarge with 800 GB disk
# Spinifex atomically claims 2× MI350X via PCIe passthrough for this instance type
INSTANCE_ID=$(aws ec2 run-instances \
--image-id ami-ubuntu-amd-gpu \
--instance-type g7e.12xlarge \
--key-name spinifex-key \
--subnet-id <subnet-id> \
--security-group-ids <sg-id> \
--block-device-mappings 'DeviceName=/dev/sda1,Ebs={VolumeSize=800,DeleteOnTermination=true}' \
--count 1 \
--query 'Instances[0].InstanceId' --output text)
echo "Launched: $INSTANCE_ID"
# Wait for running state
aws ec2 wait instance-running --instance-ids "$INSTANCE_ID"
# Get the IP
INSTANCE_IP=$(aws ec2 describe-instances \
--instance-ids "$INSTANCE_ID" \
--query 'Reservations[0].Instances[0].PublicIpAddress' --output text)
echo "IP: $INSTANCE_IP"
```
Once SSH is available, confirm both GPUs are visible:
```bash
ssh -i ~/.ssh/spinifex-key ubuntu@$INSTANCE_IP 'lspci | grep -i amd'
```
Or install and run `amd-smi` on the instance:
<img src="../../../.github/assets/images/2gpus.png" alt="amd-smi inside the guest VM confirming two MI350Xs are directly attached, each with a unique UUID">
Two MI350X entries with distinct UUIDs confirm direct PCIe passthrough.
### 2. Pull the vLLM Docker image
```bash
ssh -i ~/.ssh/spinifex-key ubuntu@$INSTANCE_IP
# On the VM:
docker pull rocm/vllm:latest
```
> The `rocm/vllm` image is large (~20 GB). Pull it while the model is downloading in parallel if bandwidth allows.
### 3. Pull the model
Trinity Large Preview FP8 weighs roughly 400 GB, so expect the model download to take some time.
```bash
# On the VM:
pip install 'huggingface-hub[hf_xet]'
hf download 'arcee-ai/Trinity-Large-Preview-FP8' --repo-type model
# Monitor progress — looking for ~400 GB total
du -sh ~/.cache/huggingface/hub/
```
The model lands in `~/.cache/huggingface/hub/`. vLLM picks it up automatically from there on serve.
### 4. Run vLLM
Trinity needs TP=2 to fit across both MI350Xs:
```bash
# On the VM:
docker run --rm -d \
--name trinity \
--device /dev/kfd \
--device /dev/dri \
--group-add video \
--ipc host \
--network host \
-v ~/.cache/huggingface:/root/.cache/huggingface \
rocm/vllm:latest \
vllm serve arcee-ai/Trinity-Large-Preview-FP8 \
--tensor-parallel-size 2 \
--dtype auto \
--max-model-len 8192 \
--gpu-memory-utilization 0.90 \
--host 0.0.0.0 \
--port 8003
```
Watch the startup log:
```bash
docker logs -f trinity
```
Loading a 400B FP8 model across two GPUs takes **2–4 minutes**. Wait for:
```
INFO: Application startup complete.
```
Then verify:
```bash
curl http://localhost:8003/health
curl http://localhost:8003/v1/models | python3 -m json.tool
```
### 5. Test it
A quick sanity check via the OpenAI-compatible API before running benchmarks:
```bash
curl http://localhost:8003/v1/chat/completions \
-H "Content-Type: application/json" \
-d '{
"model": "arcee-ai/Trinity-Large-Preview-FP8",
"messages": [{"role": "user", "content": "Explain tensor parallelism in three sentences."}],
"max_tokens": 256
}'
```
The first request will be slowest — KV cache is cold. Subsequent requests warm up noticeably.
### 6. Dashboard
We created a quick dashboard for a live view of the model's telemetry — throughput, latency, GPU utilisation, VRAM, and KV cache — streamed via SSE from the vLLM metrics endpoint and the GPU stats sidecar.
<p><video src="https://iso.mulgadc.com/trinity-workload.mp4" controls width="100%" style="border-radius:6px"></video></p>
### 7. Benchmark results
Two captures were run against the live model:
- **Chat session:** A short interactive session — low concurrency, variable cadence, 56 s total. Reflects real conversational use.
- **Scripted benchmark:** 5-minute sustained load driver — rotating batch of 15 long-form technical prompts at 1.5 s cadence. Reflects peak throughput.
| Metric | Chat session | Benchmark (5 min) |
|---|---|---|
| Peak tok/s | 67.1 | 67.0 |
| Avg tok/s (under load) | 18.7 | **52.9** |
| Avg TTFT | 1668 ms | 758 ms |
| Min TTFT | 1502 ms | 538 ms |
| Max TTFT | 1871 ms | 1165 ms |
| GPU 0 util (avg) | 30.2% | 88.8% |
| GPU 1 util (avg) | 30.1% | 88.7% |
| VRAM per GPU | 259.6 GB | 259.6 GB |
| Avg power (both GPUs) | 664 W | 788 W |
| Peak power | 828 W | 839 W |
| GPU temp | 65 °C | 65 °C |
The lower avg utilisation in the chat session reflects idle gaps between conversational turns; peak throughput is identical at ~67 tok/s in both runs, indicating the hardware ceiling rather than a software one.
### Generation throughput
<img src="../../../.github/assets/images/trinity/trinity-throughput.png" alt="Trinity generation throughput — scripted benchmark">
Generation and prompt-ingest tok/s over the 5-minute benchmark. The model sustains ~53 tok/s average at continuous load, peaking at 67 tok/s.
### Per-GPU compute utilisation
<img src="../../../.github/assets/images/trinity/trinity-per-gpu-util.png" alt="Trinity per-GPU utilisation — scripted benchmark">
GPU 0 and GPU 1 track each other closely throughout the benchmark — TP=2 distributes attention and FFN layers symmetrically across both MI350Xs.
### Per-GPU VRAM
<img src="../../../.github/assets/images/trinity/trinity-per-gpu-vram.png" alt="Trinity per-GPU VRAM — scripted benchmark">
Both GPUs hold 259.6 GB — model weights sharded evenly, plus KV cache. 90.2% of HBM3e is occupied at rest, leaving ~26 GB headroom per GPU for active KV cache during generation.
### Per-GPU power
<img src="../../../.github/assets/images/trinity/trinity-per-gpu-power.png" alt="Trinity per-GPU power — scripted benchmark">
Power draw during active generation peaks at ~420 W per GPU, for a combined chassis draw well under the MI350X's 750 W TDP per card. Idle between prompts returns to ~300 W.
### Latency
<img src="../../../.github/assets/images/trinity/trinity-latency.png" alt="Trinity latency — scripted benchmark">
Cumulative average TTFT and E2E latency over the benchmark. TTFT settles to ~758 ms avg once the KV cache is warm; E2E tracks request length as expected.
### KV cache utilisation
<img src="../../../.github/assets/images/trinity/trinity-kv-cache.png" alt="Trinity KV cache utilisation — scripted benchmark">
KV cache occupancy rises as requests pile up during the sustained load phase. The queue depth stays near zero — the model keeps pace with the 1.5 s prompt cadence.
### 8. Teardown
```bash
# Stop the container
ssh -i ~/.ssh/spinifex-key ubuntu@$INSTANCE_IP 'docker stop trinity'
# Terminate the instance — releases the 2× MI350X back to the Spinifex pool
aws ec2 terminate-instances --instance-ids "$INSTANCE_ID"
```
The two MI350Xs are immediately available to Spinifex for a new workload once the instance terminates.
### 9. Conclusion
Trinity Large Preview demonstrates that frontier-class open-weight models are now within reach of a two-GPU bare-metal setup. The MoE architecture is crucial: 400B total parameters with only 13B active per token means the model generates at ~53 tok/s sustained and peaks at 67 tok/s on a pair of MI350Xs — throughput that would be difficult to match with a dense model of comparable quality at that memory footprint.
Combining this with Spinifex as the infrastructure layer allows teams to own the entire workflow. Provisioning a `g7e.12xlarge` instance, pulling a 400 GB model, and serving it through vLLM took a handful of standard `aws ec2` commands and a single `docker run`. The same workflow runs identically on any Spinifex-managed bare-metal node, with the GPUs returned to the pool when instances terminate.
For teams evaluating Trinity for production agentic workloads — tool calling, structured outputs, long-context reasoning — this represents a credible self-hosted deployment path: open weights, Apache 2.0 license, owned hardware and familiar tooling.
+1 -1
View File
@@ -131,7 +131,7 @@ services:
- "s3.*"
vpcd:
path: spinifex/services/vpcd
path: spinifex/vpcd
# OVN-backed VPC / subnet / SG / NAT / IGW data plane.
subscribes:
- "vpc.create"
+7 -6
View File
@@ -1,6 +1,6 @@
module github.com/mulgadc/spinifex
go 1.26.3
go 1.26.4
require (
github.com/aws/aws-sdk-go v1.55.8
@@ -8,12 +8,13 @@ require (
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
github.com/go-chi/chi/v5 v5.3.0
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/uuid v1.6.0
github.com/insomniacslk/dhcp v0.0.0-20260407060928-11b94ed970f2
github.com/kdomanski/iso9660 v0.4.0
github.com/klauspost/cpuid/v2 v2.3.0
github.com/mulgadc/predastore v1.5.0
github.com/mulgadc/viperblock v1.5.0
github.com/mulgadc/predastore v1.6.0
github.com/mulgadc/viperblock v1.6.0
github.com/nats-io/nats-server/v2 v2.14.1
github.com/nats-io/nats.go v1.52.0
github.com/ovn-kubernetes/libovsdb v0.8.1
@@ -25,6 +26,7 @@ require (
go.uber.org/goleak v1.3.0
golang.org/x/crypto v0.52.0
golang.org/x/sync v0.20.0
golang.org/x/sys v0.45.0
gopkg.in/ini.v1 v1.67.2
gopkg.in/yaml.v3 v3.0.1
)
@@ -36,8 +38,8 @@ require (
github.com/antithesishq/antithesis-sdk-go v0.7.0 // indirect
github.com/armon/go-metrics v0.4.1 // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aws/aws-sdk-go-v2 v1.41.7 // indirect
github.com/aws/smithy-go v1.25.1 // indirect
github.com/aws/aws-sdk-go-v2 v1.41.9 // indirect
github.com/aws/smithy-go v1.26.0 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/boltdb/bolt v1.3.1 // indirect
@@ -126,7 +128,6 @@ require (
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/mod v0.35.0 // indirect
golang.org/x/net v0.55.0 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa // indirect
golang.org/x/term v0.43.0 // indirect
golang.org/x/text v0.37.0 // indirect
+30 -28
View File
@@ -31,30 +31,30 @@ github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aws/aws-sdk-go v1.55.8 h1:JRmEUbU52aJQZ2AjX4q4Wu7t4uZjOu71uyNmaWlUkJQ=
github.com/aws/aws-sdk-go v1.55.8/go.mod h1:ZkViS9AqA6otK+JBBNH2++sx1sgxrPKcSzPPvQkUtXk=
github.com/aws/aws-sdk-go-v2 v1.41.7 h1:DWpAJt66FmnnaRIOT/8ASTucrvuDPZASqhhLey6tLY8=
github.com/aws/aws-sdk-go-v2 v1.41.7/go.mod h1:4LAfZOPHNVNQEckOACQx60Y8pSRjIkNZQz1w92xpMJc=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 h1:gx1AwW1Iyk9Z9dD9F4akX5gnN3QZwUB20GGKH/I+Rho=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10/go.mod h1:qqY157uZoqm5OXq/amuaBJyC9hgBCBQnsaWnPe905GY=
github.com/aws/aws-sdk-go-v2/credentials v1.19.16 h1:r3RJBuU7X9ibt8RHbMjWE6y60QbKBiII6wSrXnapxSU=
github.com/aws/aws-sdk-go-v2/credentials v1.19.16/go.mod h1:6cx7zqDENJDbBIIWX6P8s0h6hqHC8Avbjh9Dseo27ug=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 h1:GpT/TrnBYuE5gan2cZbTtvP+JlHsutdmlV2YfEyNde0=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23/go.mod h1:xYWD6BS9ywC5bS3sz9Xh04whO/hzK2plt2Zkyrp4JuA=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 h1:bpd8vxhlQi2r1hiueOw02f/duEPTMK59Q4QMAoTTtTo=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23/go.mod h1:15DfR2nw+CRHIk0tqNyifu3G1YdAOy68RftkhMDDwYk=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24 h1:OQqn11BtaYv1WLUowvcA30MpzIu8Ti4pcLPIIyoKZrA=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.24/go.mod h1:X5ZJyfwVrWA96GzPmUCWFQaEARPR7gCrpq2E92PJwAE=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9 h1:FLudkZLt5ci0ozzgkVo8BJGwvqNaZbTWb3UcucAateA=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.9/go.mod h1:w7wZ/s9qK7c8g4al+UyoF1Sp/Z45UwMGcqIzLWVQHWk=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15 h1:ieLCO1JxUWuxTZ1cRd0GAaeX7O6cIxnwk7tc1LsQhC4=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.15/go.mod h1:e3IzZvQ3kAWNykvE0Tr0RDZCMFInMvhku3qNpcIQXhM=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23 h1:pbrxO/kuIwgEsOPLkaHu0O+m4fNgLU8B3vxQ+72jTPw=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.23/go.mod h1:/CMNUqoj46HpS3MNRDEDIwcgEnrtZlKRaHNaHxIFpNA=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23 h1:03xatSQO4+AM1lTAbnRg5OK528EUg744nW7F73U8DKw=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.23/go.mod h1:M8l3mwgx5ToK7wot2sBBce/ojzgnPzZXUV445gTSyE8=
github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0 h1:etqBTKY581iwLL/H/S2sVgk3C9lAsTJFeXWFDsDcWOU=
github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0/go.mod h1:L2dcoOgS2VSgbPLvpak2NyUPsO1TBN7M45Z4H7DlRc4=
github.com/aws/smithy-go v1.25.1 h1:J8ERsGSU7d+aCmdQur5Txg6bVoYelvQJgtZehD12GkI=
github.com/aws/smithy-go v1.25.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/aws/aws-sdk-go-v2 v1.41.9 h1:/rYeyO2+HrMztAmxAq9++XJtFMqSIpSsNA0yDGALYq4=
github.com/aws/aws-sdk-go-v2 v1.41.9/go.mod h1:+HsoOEX80qAVUitj1A2DhCNTjmb3edVyuDypb6LNEeo=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.11 h1:h5+3VT69KUBK24grGuuA5saDJTj2IIjLb9au668Fo5I=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.11/go.mod h1:dnakxebH6UwFvcvujL0LVggYQ8nEvBGjU4G/V79Nv94=
github.com/aws/aws-sdk-go-v2/credentials v1.19.19 h1:yuFzSV1U0aRNYCQGVaTY2zW2M/L93pYHnXnrJUphYhU=
github.com/aws/aws-sdk-go-v2/credentials v1.19.19/go.mod h1:7y63L1kGzeoDlJaQ3Z578KrnmfBut96JjvJUzGwR+YE=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.25 h1:Uii3frf9ztec/ABM2/FSH9/z7PLzxfpG8h4RpkUFflQ=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.25/go.mod h1:G6kntsA2GorAxDPbap6xgB2F+amSLUF8GJTi7PUoX44=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.25 h1:r1+/l6m+WaUJF9HISEsNOLHSNj5EXYQxK8VX6Cz9NlA=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.25/go.mod h1:cKf+D+NMDK1LndD7BowHbBZPgR9V0/5HubH0PFWvA+c=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.26 h1:A1PmWU2zfkIm9EyFlJncFXL4W4phML+h8KjltUsCvNQ=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.26/go.mod h1:dY4MRzXEizrD4hqtpKvWVGPX7QleSGGVY+EBolo1RmM=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.10 h1:d5/908OJ4bXg8lyjeMPvXetEKqoDoLi5Owy1zNue3yg=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.10/go.mod h1:a57l7Hwh+FWI+we50g5NPJHYUKeJKfXbc4w8SyXu8Ig=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.18 h1:W/EyPFl9A5rXrtoilfwHYEvzHER+K4SpBPtMXi24Mos=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.18/go.mod h1:UG50K+pvd/uy6xExbobg0rjqFBFZe6I3l75EPDZw4tg=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.25 h1:dD3dhHNglpd98gs72my22Ndqi1hqQGllFFg1F+twfxg=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.25/go.mod h1:0yAbjPfd64gG7mj85RW+fMEYdfBgCRZw8g/oWcL1pjc=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.25 h1:2pQEbwf+/6EDbiit/GcBE2K4IUpMZymaA0kOz3xK978=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.25/go.mod h1:KvT6NCcQ0EZ+ZkVRrlBMt04Po3ok23YELEp7WimhLhM=
github.com/aws/aws-sdk-go-v2/service/s3 v1.102.2 h1:ie4ElCmUKS26pzrZcIk/lmt4yWjAqLLcawstyQCh298=
github.com/aws/aws-sdk-go-v2/service/s3 v1.102.2/go.mod h1:zjsomFeX5duj+4PlMB+o4JoWTIx+G0XMyzjYrUbQkN0=
github.com/aws/smithy-go v1.26.0 h1:9ouqbi+NyKP7fV3Te7UElCwdAb6Y8uk7LGwPE5tVe/s=
github.com/aws/smithy-go v1.26.0/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
@@ -147,6 +147,8 @@ github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/me
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
@@ -286,10 +288,10 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/mulgadc/predastore v1.5.0 h1:FtL9EquoGKl3tcIRIoAXGhQStkN4uPDx0TOQZ89Mh+c=
github.com/mulgadc/predastore v1.5.0/go.mod h1:fXIGz7yCKcHh68ybcw7aigdOdzIfyt19h/0diPF+JCU=
github.com/mulgadc/viperblock v1.5.0 h1:2OprAI1oAtitUosS5A9LbenU/RCaw0oxUJvUhv8+tgU=
github.com/mulgadc/viperblock v1.5.0/go.mod h1:lIuOaBO3gC0o/EwWhJPpCuhUEPQ3/y74xckGlcBKaos=
github.com/mulgadc/predastore v1.6.0 h1:a0xJtrmAvmLlc19B5xUR9upbkl7VlDntpAKnxbkM07E=
github.com/mulgadc/predastore v1.6.0/go.mod h1:RJh0V0TG27s0q6n0o4CIQFu7CjjPYv4yGnKpIiP9tLs=
github.com/mulgadc/viperblock v1.6.0 h1:mFx0eVOJoT3WL+X/GPrZOI0ySqerpiCtUW4vPpUxbtI=
github.com/mulgadc/viperblock v1.6.0/go.mod h1:cwE2FVrvxUD+QaMWnAZeKVnxF8VtTG9zxiUaP1m5oL0=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
+2
View File
@@ -0,0 +1,2 @@
.creds/
*.retry
+130 -4
View File
@@ -5,7 +5,6 @@ Lives alongside the existing `scripts/dev-*.sh` and `scripts/reset-dev-env.sh`;
neither set is removed. Decide which to keep after parity testing.
Plan: `docs/development/improvements/ansible-dev-lifecycle.md` (mulga top-level).
Bead: `mulga-siv-9`.
## Prerequisites
@@ -32,6 +31,12 @@ inventory and roles relative to CWD.
| `playbooks/dev-install.yml` | Build + install + init + smoketest on clean box | `dev-install.sh` |
| `playbooks/dev-reset.yml` | Capture settings → teardown → build → install → init → smoketest | `reset-dev-env.sh` (full) |
| `playbooks/dev-deploy.yml` | Rebuild + swap binaries/microvm artifacts + restart (no setup.sh, no smoketest) | `make deploy` |
| `playbooks/dev-status.yml` | Read-only health snapshot (units, ports, OVN/OVS, gateway, counts, drift) | none |
| `playbooks/dev-logs.yml` | Dump journald + `/var/log/spinifex/*` + status/networking/versions into a tarball | none |
| `playbooks/dev-snapshot.yml` | Quiesce spinifex+OVN+OVS, tar state paths to a named bundle, restart | none |
| `playbooks/dev-restore.yml` | Stop services, wipe + untar a named bundle, refresh CA, restart | none |
| `playbooks/dev-version.yml` | Build-vs-install drift report (spx, plugin, microvm) + sub-repo HEADs | none |
| `playbooks/dev-vpc.yml` | Idempotent dev VPC fixture (VPC + public subnet + IGW + default route); `-e dev_vpc_state=absent` tears down | partial overlap with `scripts/iac/examples/*` |
Upcoming (not yet implemented):
@@ -46,6 +51,12 @@ ansible-playbook playbooks/dev-teardown.yml
ansible-playbook playbooks/dev-install.yml
ansible-playbook playbooks/dev-reset.yml
ansible-playbook playbooks/dev-deploy.yml
ansible-playbook playbooks/dev-status.yml
ansible-playbook playbooks/dev-logs.yml
ansible-playbook playbooks/dev-snapshot.yml -e snapshot_name=before-merge
ansible-playbook playbooks/dev-restore.yml -e snapshot_name=before-merge
ansible-playbook playbooks/dev-version.yml
ansible-playbook playbooks/dev-vpc.yml
```
Or via `make` (from `spinifex/`):
@@ -56,6 +67,13 @@ make ansible-dev-teardown
make ansible-dev-install
make ansible-dev-reset
make ansible-dev-deploy
make ansible-dev-status
make ansible-dev-logs
make ansible-dev-snapshot ANSIBLE_EXTRA='-e snapshot_name=before-merge'
make ansible-dev-restore ANSIBLE_EXTRA='-e snapshot_name=before-merge'
make ansible-dev-version
make ansible-dev-vpc
make ansible-dev-vpc ANSIBLE_EXTRA='-e dev_vpc_state=absent'
```
### When to use which
@@ -64,6 +82,11 @@ make ansible-dev-deploy
- Iterate on Go code, microvm initramfs, lb-agent → `ansible-dev-deploy` (fast)
- Changed systemd units, helper scripts, logrotate, setup.sh → `ansible-dev-reset` (slow, full rebuild)
- Need a clean slate without reinstall → `ansible-dev-teardown`
- "Is my dev box healthy?" → `ansible-dev-status` (read-only, never mutates)
- Filing a bug / capturing the state of a broken box → `ansible-dev-logs` (writes `/tmp/spinifex-logs-<host>-<ts>.tar.gz`; override `-e logs_since=10min` or `-e logs_include_dmesg=false`)
- Checkpoint before a risky op (branch switch, viperblock surgery, ovn restart cycle) → `ansible-dev-snapshot ANSIBLE_EXTRA='-e snapshot_name=<name>'`; roll back via `ansible-dev-restore` instead of a full `dev-reset`. Bundles live in `/var/lib/spinifex-snapshots/<name>.tar.gz` (root-owned, survives `dev-teardown`).
- "Do I need to `ansible-dev-deploy`?" → `ansible-dev-version` (drift-only check; faster than `dev-status`)
- Want a known-good VPC/subnet for manual EC2 testing without writing tofu → `ansible-dev-vpc` (smoketest will then find and use it automatically). Tear down with `ANSIBLE_EXTRA='-e dev_vpc_state=absent'`.
## Variable overrides
@@ -83,14 +106,117 @@ Useful overrides:
| `teardown_process_wait_seconds` | `30` | Grace period for processes to exit |
| `spinifex_region` | `ap-southeast-2` | Region passed to `spx admin init` |
| `spinifex_az` | `{{ region }}a` | AZ passed to `spx admin init` |
| `spinifex_external_mode` | `pool` | `pool` \| `nat` external networking |
| `spinifex_external_mode` | `pool` | External networking mode — `pool` or empty (NAT removed) |
| `spinifex_external_no_external` | `false` | `true` → emit `--no-external` and skip external networking |
| `spinifex_external_pool_start` | `""` | **Required** for `pool` mode — first IP of the static WAN pool |
| `spinifex_external_pool_end` | `""` | **Required** for `pool` mode — last IP of the static WAN pool |
| `spinifex_external_gateway` | `""` | WAN gateway (auto-detected when blank) |
| `spinifex_external_prefix_len` | `""` | WAN prefix length (auto-detected when blank) |
| `spinifex_external_gateway_ip` | `""` | OVN router external-IP override (`--gateway-ip`) |
`pool` mode is mandatory unless you set `spinifex_external_no_external=true`.
`spx admin init` rejects the run without a `--external-pool` range, so the
init role asserts `spinifex_external_pool_start/_end` upfront. Either persist
them in `vars/defaults.yml`, or pass them at invocation:
Direct `ansible-playbook` (use `-e` here — this is ansible's flag):
```
ansible-playbook playbooks/dev-install.yml \
-e spinifex_external_pool_start=192.168.1.150 \
-e spinifex_external_pool_end=192.168.1.250
```
Via `make` (use `EXTRA_VARS=` — make eats its own `-e`):
```
make ansible-dev-install \
EXTRA_VARS="spinifex_external_pool_start=192.168.1.150 spinifex_external_pool_end=192.168.1.250"
```
`dev-reset.yml` automatically captures `region`, `az`, `external_mode`,
pool range, gateway, prefix-len, nat gateway_ip from the existing
pool range, gateway, prefix-len and `gateway_ip` from the existing
`/etc/spinifex/spinifex.toml` before teardown and replays them into
`init`. Use `-e` to override.
`init`. Legacy `external_mode = "nat"` is coerced to `"pool"` with a warn.
Use `-e` to override.
## Helper scripts
`scripts/aws-as-spinifex.sh` and `scripts/spx-as-spinifex.sh` wrap `aws`
and `spx` invocations in `sg spinifex -c '...'` so they run with the
`spinifex` supplementary group active. Both helpers preset
`AWS_PROFILE=spinifex`. All AWS-calling tasks in the roles use these
via `spinifex_aws_cli` / `spinifex_spx_cli` from `vars/defaults.yml`.
Background: `/etc/spinifex/ca.pem` lives under `drwxr-x--- root:spinifex`,
so the AWS CLI needs `spinifex` group membership to read the CA bundle.
Ansible's `become_user` is a no-op when the target user equals the
calling user (no `sudo -u` invoked → no `initgroups(3)` → supplementary
groups inherited from the operator's shell). On a fresh login the
operator's shell would have `spinifex` (added by `setup.sh`); on shells
that predate the group add it does not. The helpers sidestep this
entirely.
## Leak catalog
`roles/teardown/README.md` tracks every known state source. When teardown
misses something, add it there and in `roles/teardown/tasks/main.yml`.
## Multi-node cluster bootstrap
`playbooks/cluster-bootstrap.yml` provisions a 2+ node Spinifex cluster
across a long-running tofu env (`env1`, `env2`, `bryn`, etc). Inventory
is generated dynamically from `scripts/tofu-cluster/envs/<env>.tfvars`
via `inventory/tofu.py` — first node in `nodes = [...]` is the init host,
the rest are joins.
```bash
make ansible-cluster-bootstrap ENV=env1 POOL=192.168.1.150-192.168.1.159
```
`POOL` is optional; omit for DHCP fallback. Equivalent without make:
```bash
cd scripts/ansible
CLUSTER_ENV=env1 ansible-playbook -i inventory/tofu.py \
playbooks/cluster-bootstrap.yml \
-e cluster_env=env1 \
-e cluster_external_pool=192.168.1.150-192.168.1.159
```
Plays sequence:
1. **runner (localhost)** — `build` role produces `/tmp/spinifex-local.tar.gz`.
2. **all env hosts (parallel)** — copies tarball, stops any dev-layout
services (`~/spinifex/`), runs `preflight` + `install` roles. `setup.sh`
creates `/etc/spinifex`, the `spinifex` user/group, systemd units, and
installs `/usr/local/bin/spx`.
3. **init host only** — `cluster-init` role runs `spx admin init` in the
background, waits for the formation server, captures the join token.
CA is then copied into `/usr/local/share/ca-certificates/spinifex-ca.crt`
**before** `spinifex.target` starts (required — predastore enforces
strict TLS verify against the system trust store).
4. **join hosts (parallel)** — `cluster-join` role runs `spx admin join`
against the formation server, then installs CA, then starts
`spinifex.target`.
5. **all hosts** — polls `https://<ip>:4432/health` until `awsgw=ok`.
6. **init host only** — `cluster-post` role imports the SSH key + Ubuntu
AMI (both idempotent: skip if already present).
Re-running the playbook against an already-bootstrapped env is safe —
`setup.sh`, `spx admin init --force`, CA copy, key/AMI import are all
idempotent.
### Migration from `bootstrap.sh`
`scripts/tofu-cluster/bootstrap.sh` ran services under `~/spinifex/` as
`tf-user` via `start-dev.sh` with no systemd supervision, and installed
the cluster CA *after* services started — predastore would crash with
`x509: certificate signed by unknown authority` on every first boot.
`cluster-bootstrap.yml` replaces that flow with production-layout
(`/etc/spinifex/` + systemd) and correct CA-before-services ordering.
The first run on an env previously bootstrapped by `bootstrap.sh` will
stop the dev-layout services, install the prod layout, and re-init the
cluster from scratch via `spx admin init --force`.
`bootstrap.sh` stays in-tree for CI and pseudo-multinode envs.
+7 -1
View File
@@ -5,7 +5,13 @@ stdout_callback = ansible.builtin.default
host_key_checking = False
retry_files_enabled = False
interpreter_python = auto_silent
forks = 1
forks = 10
[inventory]
# inventory/tofu.py is an executable script inventory used by
# cluster-bootstrap.yml — opted into via `-i inventory/tofu.py` on the
# command line; not default to keep dev-* playbooks pointing at localhost.
enable_plugins = host_list, script, auto, yaml, ini, toml
[callback_default]
result_format = yaml
+170
View File
@@ -0,0 +1,170 @@
#!/usr/bin/env python3
"""Dynamic ansible inventory generator from tofu-cluster tfvars.
Reads scripts/tofu-cluster/envs/<env>.tfvars (HCL subset) and emits ansible
JSON inventory. First node in the tfvars `nodes = [...]` list is the init
host (forms the cluster); the rest are join hosts.
Usage:
inventory/tofu.py --list --env env1
inventory/tofu.py --host <ip> --env env1
Selecting the env:
CLUSTER_ENV=env1 inventory/tofu.py --list
inventory/tofu.py --list --env env1
"""
from __future__ import annotations
import argparse
import json
import os
import re
import sys
from pathlib import Path
def _repo_root() -> Path:
here = Path(__file__).resolve()
for parent in here.parents:
if (parent / "go.work").exists() or (parent / ".gitmodules").exists():
return parent
raise SystemExit(f"could not locate mulga repo root from {here}")
def _tfvars_path(env: str) -> Path:
root = _repo_root()
candidate = root / "scripts" / "tofu-cluster" / "envs" / f"{env}.tfvars"
if not candidate.exists():
raise SystemExit(f"tfvars file not found: {candidate}")
return candidate
_STRING_RE = re.compile(r'^\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*=\s*"([^"]*)"\s*$')
_NUMBER_RE = re.compile(r'^\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*=\s*([0-9]+)\s*$')
_NODE_FIELD_RE = re.compile(r'(\w+)\s*=\s*"([^"]*)"')
def _parse_tfvars(path: Path) -> dict:
"""Minimal tfvars parser — handles the subset used by envs/*.tfvars.
Supports:
key = "string"
key = 12345
nodes = [
{ name = "x", wan_ip = "1.2.3.4", ... },
...
]
"""
out: dict = {"nodes": []}
text = path.read_text()
# Strip line comments
text = re.sub(r"#.*", "", text)
# Pull the nodes block out first, then process remaining lines.
nodes_match = re.search(r"nodes\s*=\s*\[(.+?)\]\s*$", text, re.DOTALL | re.MULTILINE)
if nodes_match:
nodes_body = nodes_match.group(1)
for entry in re.finditer(r"\{([^}]+)\}", nodes_body):
fields = dict(_NODE_FIELD_RE.findall(entry.group(1)))
if "name" in fields and "wan_ip" in fields:
out["nodes"].append(fields)
text = text.replace(nodes_match.group(0), "")
for line in text.splitlines():
if m := _STRING_RE.match(line):
out[m.group(1)] = m.group(2)
elif m := _NUMBER_RE.match(line):
out[m.group(1)] = int(m.group(2))
return out
def _resolve_ssh_key(value: str | None) -> str:
if not value:
return os.path.expanduser("~/.ssh/tf-user-ap-southeast-2")
return os.path.expanduser(value)
def _build_inventory(env: str) -> dict:
data = _parse_tfvars(_tfvars_path(env))
nodes = data.get("nodes") or []
if not nodes:
raise SystemExit(f"{env}.tfvars has no nodes")
ssh_user = data.get("ssh_user", "tf-user")
ssh_key = _resolve_ssh_key(data.get("ssh_public_key_path", "").replace(".pub", ""))
hostvars: dict[str, dict] = {}
all_hosts: list[str] = []
init_hosts: list[str] = []
join_hosts: list[str] = []
for idx, node in enumerate(nodes):
ip = node["wan_ip"]
role = "init" if idx == 0 else "join"
hostvars[ip] = {
"ansible_host": ip,
"ansible_user": ssh_user,
"ansible_ssh_private_key_file": ssh_key,
"ansible_ssh_common_args": "-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR",
"spinifex_node_name": node["name"],
"spinifex_node_role": role,
"spinifex_node_index": idx,
"spinifex_wan_ip": ip,
}
all_hosts.append(ip)
(init_hosts if role == "init" else join_hosts).append(ip)
# Group-level vars derived from tfvars (operator can override with -e).
cluster_init_host = init_hosts[0]
group_vars = {
"cluster_env": env,
"cluster_init_host": cluster_init_host,
"cluster_init_ip": cluster_init_host,
"spinifex_node_count": len(nodes),
"spinifex_region": data.get("region", "ap-southeast-2"),
"spinifex_az": data.get("az", "ap-southeast-2a"),
}
if cidr := data.get("env_cidr"):
group_vars["cluster_env_cidr"] = cidr
inventory = {
"_meta": {"hostvars": hostvars},
env: {"hosts": all_hosts, "vars": group_vars},
"cluster": {"children": [env]},
"init": {"hosts": init_hosts},
"join": {"hosts": join_hosts},
}
return inventory
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--list", action="store_true", help="emit full inventory")
parser.add_argument("--host", help="emit hostvars for a single host (ignored — _meta carries them)")
parser.add_argument(
"--env",
default=os.environ.get("CLUSTER_ENV"),
help="tofu env name (matches scripts/tofu-cluster/envs/<env>.tfvars); also via CLUSTER_ENV env var",
)
args = parser.parse_args()
if not args.env:
sys.stderr.write("error: --env required (or CLUSTER_ENV)\n")
return 64
if args.host:
json.dump({}, sys.stdout)
sys.stdout.write("\n")
return 0
inventory = _build_inventory(args.env)
json.dump(inventory, sys.stdout, indent=2)
sys.stdout.write("\n")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,330 @@
---
# cluster-bootstrap.yml — multi-node Spinifex cluster bootstrap against a
# tofu-provisioned env. Inventory comes from inventory/tofu.py which parses
# scripts/tofu-cluster/envs/<env>.tfvars.
#
# Usage:
# cd spinifex/scripts/ansible
# ansible-playbook -i inventory/tofu.py playbooks/cluster-bootstrap.yml \
# -e cluster_env=env1 -e cluster_external_pool=192.168.1.150-192.168.1.159
#
# Or via the make wrapper:
# make ansible-cluster-bootstrap ENV=env1 POOL=192.168.1.150-192.168.1.159
#
# Behaviour:
# - all hosts: preflight + install (setup.sh) — production layout
# - init host: spx admin init (backgrounded) + CA install + spinifex.target
# - join hosts: spx admin join + CA install + spinifex.target (parallel)
# - init host: import ssh key + ubuntu AMI (idempotent)
- name: Cluster bootstrap — build artifact once on runner
hosts: localhost
gather_facts: true
vars_files:
- ../vars/defaults.yml
tasks:
- name: assert cluster_env supplied
ansible.builtin.assert:
that: cluster_env | default('') | length > 0
fail_msg: "Pass -e cluster_env=<env> (matches scripts/tofu-cluster/envs/<env>.tfvars)"
roles:
- build
- name: Cluster bootstrap — install (all nodes, parallel)
hosts: "{{ cluster_env }}"
gather_facts: true
serial: 100%
vars:
install_run_ovn: false # we drive setup-ovn ourselves with role-aware args
cluster_bootstrap_purge: true # wipe stale single-node predastore.toml + raft data
vars_files:
- ../vars/defaults.yml
pre_tasks:
- name: copy build tarball to each node
ansible.builtin.copy:
src: "{{ build_tarball_path }}"
dest: "{{ build_tarball_path }}"
mode: "0644"
- name: stop dev-layout services if running (migration from ~/spinifex/)
become: true
# Bracket trick on the first letter prevents the pattern literal in the
# shell command line from matching the running shell itself (pkill -f
# against literal patterns is otherwise self-killing — rc=-9).
# `spx [a]dmin` catches lingering formation servers from prior bootstrap
# attempts that own port 4432 and steal joins from the new init.
ansible.builtin.shell:
cmd: |
pkill -9 -f "[b]in/spx service" 2>/dev/null || true
pkill -9 -f "spx [a]dmin" 2>/dev/null || true
pkill -9 -f "spinifex-[u]i" 2>/dev/null || true
pkill -9 -f "[n]ats-server" 2>/dev/null || true
pkill -9 -f "[p]redastore" 2>/dev/null || true
pkill -9 -f "[v]iperblock" 2>/dev/null || true
sleep 2
rm -rf "{{ ansible_env.HOME }}/spinifex" 2>/dev/null || true
exit 0
changed_when: false
roles:
- install
# Wipe OVN/OVS state before setup-ovn runs. Without this, re-bootstrapping a
# previously-built cluster leaves stale chassis rows in the SB DB, stale
# external_ids on Open_vSwitch, stale br-int flows + ports, stale
# systemd-networkd unit files, and stale ovs-monitor-ipsec strongSwan certs.
# Symptom: ovn-controller flows reference dead encap endpoints; IPsec SAs
# fail AUTHENTICATION_FAILED; new joins shadow ghosts under the same
# node-name.
- name: Cluster bootstrap — OVN/OVS state reset (all nodes, parallel)
hosts: "{{ cluster_env }}"
gather_facts: false
serial: 100%
vars_files:
- ../vars/defaults.yml
tasks:
- name: reset — stop OVN/OVS services
become: true
ansible.builtin.systemd:
name: "{{ item }}"
state: stopped
loop:
- ovn-controller
- ovn-central
- openvswitch-ipsec
- openvswitch-switch
failed_when: false
- name: reset — delete OVN NB/SB databases
become: true
ansible.builtin.file:
path: "{{ item }}"
state: absent
loop:
- "{{ ovn_nb_db }}"
- "{{ ovn_sb_db }}"
- name: reset — delete OVS conf.db + system-id (force fresh ovsdb)
become: true
ansible.builtin.file:
path: "{{ item }}"
state: absent
loop:
- /etc/openvswitch/conf.db
- /etc/openvswitch/.conf.db.~lock~
- /etc/openvswitch/system-id.conf
- name: reset — drop spinifex-managed systemd-networkd unit files
become: true
ansible.builtin.shell: |
rm -f /etc/systemd/network/14-spinifex-*.netdev \
/etc/systemd/network/15-spinifex-*.netdev \
/etc/systemd/network/15-spinifex-*.network \
/etc/systemd/network/16-spinifex-*.network
networkctl reload 2>/dev/null || true
changed_when: true
- name: reset — enumerate stale spinifex veth/tap netdevs
ansible.builtin.shell: |
ip -o link show | awk -F': ' '{print $2}' | cut -d'@' -f1 | \
grep -E '^(veth-wan-br|veth-wan-ovs|spx-ext-|spx-tap-|spx-)' || true
register: reset_links
changed_when: false
- name: reset — delete stale netdevs
become: true
ansible.builtin.command: "ip link delete {{ item }}"
loop: "{{ reset_links.stdout_lines }}"
changed_when: true
failed_when: false
- name: reset — clear ovs-monitor-ipsec strongSwan state
become: true
ansible.builtin.shell: |
rm -rf /var/lib/openvswitch/pki 2>/dev/null || true
rm -f /etc/ipsec.d/certs/ovs-* /etc/ipsec.d/private/ovs-* 2>/dev/null || true
rm -f /etc/ipsec.conf.d/ovs-* 2>/dev/null || true
changed_when: true
- name: reset — start openvswitch-switch with fresh conf.db
become: true
ansible.builtin.systemd:
name: openvswitch-switch
state: started
daemon_reload: true
- name: Cluster bootstrap — OVN central (init host only)
hosts: init
gather_facts: false
vars_files:
- ../vars/defaults.yml
tasks:
- name: setup-ovn --management on init host
become: true
ansible.builtin.command:
cmd: >-
/usr/local/share/spinifex/setup-ovn.sh
--management
--encap-ip={{ spinifex_wan_ip }}
--node-name={{ hostvars[inventory_hostname]['spinifex_node_name'] }}
changed_when: true
- name: Cluster bootstrap — OVN chassis (join hosts, parallel)
hosts: join
gather_facts: false
vars_files:
- ../vars/defaults.yml
tasks:
- name: setup-ovn --ovn-remote on join hosts
become: true
ansible.builtin.command:
cmd: >-
/usr/local/share/spinifex/setup-ovn.sh
--ovn-remote=tcp:{{ cluster_init_host }}:6642
--encap-ip={{ spinifex_wan_ip }}
--node-name={{ hostvars[inventory_hostname]['spinifex_node_name'] }}
changed_when: true
- name: Cluster bootstrap — init host (start formation server)
hosts: init
gather_facts: false
vars_files:
- ../vars/defaults.yml
roles:
- cluster-init
- name: Cluster bootstrap — join hosts run spx admin join (parallel)
hosts: join
gather_facts: false
serial: 100%
vars_files:
- ../vars/defaults.yml
roles:
- cluster-join
- name: Cluster bootstrap — wait for init to finish writing multi-node configs
hosts: init
gather_facts: false
vars_files:
- ../vars/defaults.yml
tasks:
- name: wait for spx admin init async job to complete
become: true
ansible.builtin.async_status:
jid: "{{ cluster_init_async.ansible_job_id }}"
register: cluster_init_done
until: cluster_init_done.finished
retries: 60
delay: 5
- name: dump init log on failure
ansible.builtin.command: tail -100 /tmp/spx-admin-init.log
register: cluster_init_log
changed_when: false
when: cluster_init_done.rc | default(1) != 0
- name: assert init exited cleanly
ansible.builtin.assert:
that: cluster_init_done.rc == 0
fail_msg: |
spx admin init failed (rc={{ cluster_init_done.rc }})
{{ cluster_init_log.stdout | default('(log not captured)') }}
- name: confirm multi-node predastore.toml written
become: true
ansible.builtin.shell: |
if grep -q '127\.0\.0\.1' {{ spinifex_etc_dir }}/predastore/predastore.toml 2>/dev/null; then
echo "ERROR: predastore.toml still references loopback — multi-node config not written"
exit 1
fi
grep -c '^\[\[nodes\]\]' {{ spinifex_etc_dir }}/predastore/predastore.toml 2>/dev/null || echo 0
register: predastore_node_count
changed_when: false
- name: Cluster bootstrap — install CA on all nodes (before service start)
hosts: "{{ cluster_env }}"
gather_facts: false
vars_files:
- ../vars/defaults.yml
tasks:
- name: install CA into system trust
become: true
ansible.builtin.copy:
src: "{{ spinifex_ca_src }}"
dest: "{{ spinifex_ca_trust_path }}"
mode: "0644"
remote_src: true
- name: rebuild CA bundle
become: true
ansible.builtin.command: update-ca-certificates
changed_when: true
# spinifex-ui reverse-proxies localhost:9999 (awsgw) and localhost:8443
# (predastore) — hardcoded in spinifex/services/spinifexui/spinifexui.go.
# spx admin init binds those services to BindIP only, so the loopback
# connection fails (BadGateway in UI). Patch to wildcard bind; cert
# already includes 127.0.0.1 in IP SANs.
- name: patch awsgw bind to 0.0.0.0 (UI proxies via localhost)
become: true
ansible.builtin.replace:
path: "{{ spinifex_config_file }}"
regexp: '^(\s*host\s*=\s*")[^"]+(:9999")'
replace: '\g<1>0.0.0.0\g<2>'
- name: patch predastore bind to 0.0.0.0 (UI proxies via localhost)
become: true
ansible.builtin.replace:
path: "{{ spinifex_config_file }}"
regexp: '^(\s*host\s*=\s*")[^"]+(:8443")'
replace: '\g<1>0.0.0.0\g<2>'
- name: Cluster bootstrap — start services (init first, then joins)
hosts: init
gather_facts: false
vars_files:
- ../vars/defaults.yml
tasks:
- name: start spinifex.target on init
become: true
ansible.builtin.systemd:
name: spinifex.target
state: started
- name: Cluster bootstrap — start services on join hosts
hosts: join
gather_facts: false
serial: 100%
vars_files:
- ../vars/defaults.yml
tasks:
- name: start spinifex.target on joiners
become: true
ansible.builtin.systemd:
name: spinifex.target
state: started
- name: Cluster bootstrap — readiness check (all nodes)
hosts: "{{ cluster_env }}"
gather_facts: false
vars_files:
- ../vars/defaults.yml
tasks:
- name: wait for awsgw responsiveness
ansible.builtin.uri:
url: "https://{{ spinifex_wan_ip }}:{{ cluster_formation_port }}/health"
validate_certs: false
return_content: true
register: cluster_health
until:
- cluster_health.status == 200
- cluster_health.json.service_health.awsgw == "ok"
retries: 30
delay: 5
- name: Cluster bootstrap — post-bootstrap fixtures (init host only)
hosts: init
gather_facts: true
vars_files:
- ../vars/defaults.yml
roles:
- cluster-post
+17
View File
@@ -0,0 +1,17 @@
---
# Capture spinifex + ovn/ovs + qemu/nbdkit logs from journald and
# /var/log/spinifex/ into a single tarball for bug reports.
# Tail mode is intentionally not provided — use `journalctl -u 'spinifex-*' -f`
# directly; ansible is the wrong tool for follow.
- name: Dev logs — dump journald + varlog + status into a bundle
hosts: localhost
gather_facts: true
vars_files:
- ../vars/defaults.yml
vars:
logs_since: "1h"
logs_output_dir: "/tmp"
logs_include_dmesg: true
roles:
- logs
+18 -3
View File
@@ -23,6 +23,7 @@
when: reset_config_stat.stat.exists
# List-concat-and-take-first pattern: missing regex match falls back to current default.
# `gateway\s*=` deliberately does not match `gateway_ip` / `gateway_lrp_*`.
- name: capture — extract scalars
ansible.builtin.set_fact:
spinifex_region: "{{ (reset_config_txt | regex_findall('region\\s*=\\s*\"([^\"]+)\"') + [spinifex_region])[0] }}"
@@ -30,11 +31,25 @@
spinifex_external_mode: "{{ (reset_config_txt | regex_findall('external_mode\\s*=\\s*\"([^\"]+)\"') + [spinifex_external_mode])[0] }}"
spinifex_external_pool_start: "{{ (reset_config_txt | regex_findall('range_start\\s*=\\s*\"([^\"]+)\"') + [spinifex_external_pool_start])[0] }}"
spinifex_external_pool_end: "{{ (reset_config_txt | regex_findall('range_end\\s*=\\s*\"([^\"]+)\"') + [spinifex_external_pool_end])[0] }}"
spinifex_external_gateway: "{{ (reset_config_txt | regex_findall('gateway\\s*=\\s*\"([^\"]+)\"') + [spinifex_external_gateway])[0] }}"
spinifex_external_gateway: "{{ (reset_config_txt | regex_findall('\\bgateway\\s*=\\s*\"([^\"]+)\"') + [spinifex_external_gateway])[0] }}"
spinifex_external_prefix_len: "{{ (reset_config_txt | regex_findall('prefix_len\\s*=\\s*([0-9]+)') + [spinifex_external_prefix_len | string])[0] }}"
spinifex_nat_gateway_ip: "{{ (reset_config_txt | regex_findall('gateway_ip\\s*=\\s*\"([^\"]+)\"') + [spinifex_nat_gateway_ip])[0] }}"
spinifex_external_gateway_ip: "{{ (reset_config_txt | regex_findall('gateway_ip\\s*=\\s*\"([^\"]+)\"') + [spinifex_external_gateway_ip])[0] }}"
when: reset_config_stat.stat.exists
# Legacy: external_mode = "nat" was removed from spx. Coerce to "pool"
# so old configs still reset cleanly; warn so the operator notices.
- name: capture — coerce legacy external_mode=nat to pool
ansible.builtin.set_fact:
spinifex_external_mode: "pool"
when: spinifex_external_mode == 'nat'
- name: capture — warn on legacy nat coercion
ansible.builtin.debug:
msg: "⚠️ Captured external_mode=nat from legacy config; coerced to 'pool' (NAT mode removed from spx admin init)."
when:
- reset_config_stat.stat.exists
- (reset_config_txt | regex_findall('external_mode\\s*=\\s*\"nat\"')) | length > 0
- name: capture — summary
ansible.builtin.debug:
msg: |
@@ -44,7 +59,7 @@
external_mode={{ spinifex_external_mode }}
pool={{ spinifex_external_pool_start }}-{{ spinifex_external_pool_end }}
gateway={{ spinifex_external_gateway }}, prefix={{ spinifex_external_prefix_len }}
nat_gateway={{ spinifex_nat_gateway_ip }}
gateway_ip={{ spinifex_external_gateway_ip }}
{% else %}
No existing config; using defaults from vars/defaults.yml (or -e overrides).
{% endif %}
+14
View File
@@ -0,0 +1,14 @@
---
# Restore a dev-snapshot bundle: stop services, wipe the snapshot path
# set, untar the bundle, refresh CA trust, restart.
#
# Required: -e snapshot_name=<name>
# Optional: -e snapshot_dir=/some/path
- name: Dev restore — wipe + untar snapshot + restart
hosts: localhost
gather_facts: true
vars_files:
- ../vars/defaults.yml
roles:
- restore
@@ -0,0 +1,17 @@
---
# Quiesce spinifex + ovn + ovs, tar /etc/spinifex + /var/lib/spinifex +
# /var/lib/ovn + /etc/openvswitch/conf.db into a named bundle, restart.
# Use to checkpoint a working state before a risky operation (branch
# switch, viperblock surgery, ovn-controller restart cycle). Roll back
# via dev-restore without a full dev-reset.
#
# Required: -e snapshot_name=<name>
# Optional: -e snapshot_dir=/some/path
- name: Dev snapshot — quiesce + tar state + restart
hosts: localhost
gather_facts: true
vars_files:
- ../vars/defaults.yml
roles:
- snapshot
+13
View File
@@ -0,0 +1,13 @@
---
# Read-only health snapshot for a Spinifex dev node. Never mutates state.
# Run any time the operator wants to know "is my box healthy?" — pairs with
# dev-preflight (run before install) as the post-install / running-system
# diagnostic.
- name: Dev status — health snapshot (read-only)
hosts: localhost
gather_facts: true
vars_files:
- ../vars/defaults.yml
roles:
- status
+11
View File
@@ -0,0 +1,11 @@
---
# Detailed build-vs-install drift report. Faster than dev-status (no AWS,
# OVN, or port probing). Answers "do I need to ansible-dev-deploy?".
- name: Dev version — build vs install drift (read-only)
hosts: localhost
gather_facts: false
vars_files:
- ../vars/defaults.yml
roles:
- version
+14
View File
@@ -0,0 +1,14 @@
---
# Idempotent dev-mode VPC fixture: spinifex-dev-vpc + public subnet +
# IGW + default route. Hooks into smoketest's launch path (which finds
# the public subnet automatically). Re-runs are no-ops once converged.
#
# Default state: present. Tear down with -e dev_vpc_state=absent.
- name: Dev VPC — fixture VPC/subnet/IGW/route (idempotent)
hosts: localhost
gather_facts: false
vars_files:
- ../vars/defaults.yml
roles:
- vpc
@@ -0,0 +1,9 @@
---
galaxy_info:
role_name: cluster-init
description: |
Run `spx admin init` on the cluster's init host, backgrounded so the
formation server accepts joins from the cluster-join role. Installs
cluster CA into the system trust store BEFORE starting spinifex.target
so predastore's strict TLS verify is satisfied at first start.
dependencies: []
@@ -0,0 +1,77 @@
---
- name: cluster-init — assert this is the init host
ansible.builtin.assert:
that: spinifex_node_role == "init"
fail_msg: "cluster-init role must only run against hosts in the [init] group"
# vars/defaults.yml sets spinifex_node_{name,count} for single-node dev use
# (precedence 11). Inventory hostvars (precedence 4) lose that fight, so
# we promote the inventory values via set_fact (precedence 15) before any
# task templates `{{ spinifex_node_count }}`.
- name: cluster-init — promote inventory node identity over defaults.yml
ansible.builtin.set_fact:
spinifex_node_name: "{{ hostvars[inventory_hostname]['spinifex_node_name'] }}"
spinifex_node_count: "{{ groups[cluster_env] | length }}"
- name: cluster-init — assemble external network flags
ansible.builtin.set_fact:
cluster_external_flags: >-
{% if cluster_external_pool | length > 0 %}--external-mode=pool --external-pool={{ cluster_external_pool }}{% endif %}
- name: cluster-init — run spx admin init (backgrounded, formation server waits for joins)
become: true
ansible.builtin.shell: |
set -o pipefail
spx admin init --force \
--node {{ spinifex_node_name }} \
--nodes {{ spinifex_node_count }} \
--bind {{ spinifex_wan_ip }} \
--cluster-bind {{ spinifex_wan_ip }} \
--port {{ cluster_formation_port }} \
--region {{ spinifex_region }} \
--az {{ spinifex_az }} \
{{ cluster_external_flags }} \
> /tmp/spx-admin-init.log 2>&1
async: 600
poll: 0
register: cluster_init_async
- name: cluster-init — brief pause so init can fail fast if misconfigured
ansible.builtin.pause:
seconds: 5
- name: cluster-init — check if init exited prematurely (sanity)
ansible.builtin.shell: |
if pgrep -f 'spx admin init' >/dev/null; then
echo "running"
else
tail -50 /tmp/spx-admin-init.log 2>/dev/null
exit 1
fi
register: cluster_init_alive
changed_when: false
failed_when: cluster_init_alive.rc != 0
- name: cluster-init — wait for formation server health
ansible.builtin.uri:
url: "https://{{ spinifex_wan_ip }}:{{ cluster_formation_port }}/formation/health"
validate_certs: false
status_code: 200
register: cluster_init_health
until: cluster_init_health.status == 200
retries: "{{ cluster_formation_timeout // 2 }}"
delay: 2
- name: cluster-init — fetch join token (delegated read for joiners)
become: true
ansible.builtin.slurp:
src: "{{ cluster_join_token_path }}"
register: cluster_join_token_raw
- name: cluster-init — register join token as host fact
ansible.builtin.set_fact:
cluster_join_token: "{{ cluster_join_token_raw.content | b64decode | trim }}"
- name: cluster-init — join token captured; waiting for joins handled by later play
ansible.builtin.debug:
msg: "Formation server up on {{ spinifex_wan_ip }}:{{ cluster_formation_port }}, {{ spinifex_node_count }} nodes expected"
@@ -0,0 +1,8 @@
---
galaxy_info:
role_name: cluster-join
description: |
Run `spx admin join` against the cluster init host's formation server.
Installs cluster CA + starts spinifex.target after join completes
(CA-before-services ordering avoids predastore TLS-verify crashes).
dependencies: []
@@ -0,0 +1,39 @@
---
- name: cluster-join — assert this is a join host
ansible.builtin.assert:
that: spinifex_node_role == "join"
fail_msg: "cluster-join role must only run against hosts in the [join] group"
# Promote inventory hostvars over vars/defaults.yml (see cluster-init).
- name: cluster-join — promote inventory node identity over defaults.yml
ansible.builtin.set_fact:
spinifex_node_name: "{{ hostvars[inventory_hostname]['spinifex_node_name'] }}"
- name: cluster-join — pull join token from init host
ansible.builtin.set_fact:
cluster_join_token: "{{ hostvars[cluster_init_host]['cluster_join_token'] }}"
- name: cluster-join — assert token present
ansible.builtin.assert:
that: cluster_join_token | length > 0
fail_msg: "join token missing — init host did not export cluster_join_token"
- name: cluster-join — run spx admin join (retry up to 5 times)
become: true
ansible.builtin.command:
cmd: >-
spx admin join
--node {{ spinifex_node_name }}
--bind {{ spinifex_wan_ip }}
--cluster-bind {{ spinifex_wan_ip }}
--host {{ cluster_init_host }}:{{ cluster_formation_port }}
--token {{ cluster_join_token }}
--region {{ spinifex_region }}
--az {{ spinifex_az }}
register: cluster_join_result
retries: 5
delay: 3
until: cluster_join_result.rc == 0
# CA install + service start handled by cluster-bootstrap.yml plays that
# run after all joins complete and init has written multi-node configs.
@@ -0,0 +1,8 @@
---
galaxy_info:
role_name: cluster-post
description: |
Post-bootstrap idempotent fixtures on the init host: import SSH key,
import Ubuntu AMI (dual-version detect 24.04/26.04). Skip when
artifacts already exist.
dependencies: []
@@ -0,0 +1,205 @@
---
- name: cluster-post — locate ssh public key for import
ansible.builtin.find:
paths: "{{ ansible_env.HOME }}/.ssh"
patterns:
- spinifex-key.pub
- tf-user-*.pub
file_type: file
register: cluster_post_ssh_pubkey
- name: cluster-post — fail when no public key found
ansible.builtin.assert:
that: cluster_post_ssh_pubkey.files | length > 0
fail_msg: "no public key in ~/.ssh — needed for spinifex-key import"
- name: cluster-post — set pubkey path fact
ansible.builtin.set_fact:
cluster_post_pubkey_path: "{{ cluster_post_ssh_pubkey.files[0].path }}"
- name: cluster-post — check whether spinifex-key already imported
ansible.builtin.command:
cmd: "{{ spinifex_aws_cli }} ec2 describe-key-pairs --key-names spinifex-key"
register: cluster_post_key_check
changed_when: false
failed_when: false
- name: cluster-post — import spinifex-key (if absent)
ansible.builtin.command:
cmd: >-
{{ spinifex_aws_cli }} ec2 import-key-pair
--key-name spinifex-key
--public-key-material fileb://{{ cluster_post_pubkey_path }}
register: cluster_post_key_import
retries: 10
delay: 6
until: cluster_post_key_import.rc == 0
when: cluster_post_key_check.rc != 0
- name: cluster-post — locate ubuntu cloud image (26.04 preferred, 24.04 fallback)
ansible.builtin.stat:
path: "{{ item }}"
register: cluster_post_ubuntu_stat
loop:
- "{{ ansible_env.HOME }}/images/ubuntu-26.04.img"
- "{{ ansible_env.HOME }}/images/ubuntu-24.04.img"
- name: cluster-post — pick first available image
ansible.builtin.set_fact:
cluster_post_ubuntu_image: "{{ (cluster_post_ubuntu_stat.results | selectattr('stat.exists') | map(attribute='item') | list | first) | default('') }}"
- name: cluster-post — fail when no ubuntu image present
ansible.builtin.assert:
that: cluster_post_ubuntu_image | length > 0
fail_msg: "no ubuntu-{24,26}.04.img in ~/images/ — gold image must stage it"
- name: cluster-post — derive ubuntu version from image filename
ansible.builtin.set_fact:
cluster_post_ubuntu_version: "{{ cluster_post_ubuntu_image | regex_search('ubuntu-([0-9]+\\.[0-9]+)\\.img', '\\1') | first }}"
- name: cluster-post — check whether ubuntu AMI already imported
ansible.builtin.command:
cmd: >-
{{ spinifex_aws_cli }} ec2 describe-images
--owners self
--filters Name=name,Values=ubuntu-{{ cluster_post_ubuntu_version }}
register: cluster_post_ami_check
changed_when: false
failed_when: false
- name: cluster-post — import ubuntu AMI (if absent)
ansible.builtin.command:
cmd: >-
{{ spinifex_spx_cli }} admin images import
--file {{ cluster_post_ubuntu_image }}
--arch x86_64
--distro ubuntu
--version {{ cluster_post_ubuntu_version }}
--boot-mode uefi
when:
- cluster_post_ami_check.rc != 0
or "ubuntu-" + cluster_post_ubuntu_version not in cluster_post_ami_check.stdout
- name: cluster-post — slurp final admin creds from ~/.aws/credentials
# Source of truth: SetupAWSCredentials writes the FINAL admin keys here.
# Init log shows obsolete keys (multi-node path silently regenerates them
# after the initial print — see cmd/spinifex/cmd/admin.go writeBootstrapFiles
# called twice in the multi-node flow).
ansible.builtin.slurp:
src: "{{ ansible_env.HOME }}/.aws/credentials"
register: cluster_post_aws_creds_raw
- name: cluster-post — extract admin access/secret keys from spinifex profile
ansible.builtin.set_fact:
cluster_post_admin_access_key: >-
{{ (cluster_post_aws_creds_raw.content | b64decode
| regex_search('\[spinifex\][^\[]*aws_access_key_id\s*=\s*(\S+)', '\1')
| first) | default('') }}
cluster_post_admin_secret_key: >-
{{ (cluster_post_aws_creds_raw.content | b64decode
| regex_search('\[spinifex\][^\[]*aws_secret_access_key\s*=\s*(\S+)', '\1')
| first) | default('') }}
- name: cluster-post — assert creds parsed
ansible.builtin.assert:
that:
- cluster_post_admin_access_key | length > 0
- cluster_post_admin_secret_key | length > 0
fail_msg: "could not parse admin keys from {{ ansible_env.HOME }}/.aws/credentials [spinifex] profile"
- name: cluster-post — ensure local .creds dir exists
delegate_to: localhost
become: false
ansible.builtin.file:
path: "{{ playbook_dir }}/../.creds"
state: directory
mode: "0700"
- name: cluster-post — write creds to operator workstation
delegate_to: localhost
become: false
ansible.builtin.copy:
dest: "{{ playbook_dir }}/../.creds/{{ cluster_env }}.txt"
mode: "0600"
content: |
# Spinifex cluster admin credentials — {{ cluster_env }}
# Generated: {{ ansible_facts['date_time']['iso8601'] }}
# Init host: {{ cluster_init_host }}
#
# UI: https://{{ cluster_init_host }}:3000
# AWS API: https://{{ cluster_init_host }}:4432
# Region: {{ spinifex_region }}
#
# Login (admin user / AWS profile spinifex):
AWS_ACCESS_KEY_ID={{ cluster_post_admin_access_key }}
AWS_SECRET_ACCESS_KEY={{ cluster_post_admin_secret_key }}
# Per-env AWS profile in operator's ~/.aws/credentials so tofu / aws CLI can
# target this cluster via AWS_PROFILE=spinifex-{{ cluster_env }}. blockinfile
# marker keeps re-runs idempotent — replaces the block in place, never
# duplicates. Does not touch the user's existing [spinifex] profile (single-
# node dev). Tofu side: provider "aws" { profile = "spinifex-env1" ... }.
- name: cluster-post — ensure local ~/.aws dir exists
delegate_to: localhost
become: false
ansible.builtin.file:
path: "{{ lookup('env', 'HOME') }}/.aws"
state: directory
mode: "0700"
- name: cluster-post — ensure local ~/.aws/credentials exists
delegate_to: localhost
become: false
ansible.builtin.copy:
dest: "{{ lookup('env', 'HOME') }}/.aws/credentials"
content: ""
force: false
mode: "0600"
- name: cluster-post — upsert spinifex-{{ cluster_env }} profile in local ~/.aws/credentials
delegate_to: localhost
become: false
ansible.builtin.blockinfile:
path: "{{ lookup('env', 'HOME') }}/.aws/credentials"
marker: "# {mark} SPINIFEX-{{ cluster_env | upper }} MANAGED BLOCK"
block: |
[spinifex-{{ cluster_env }}]
aws_access_key_id = {{ cluster_post_admin_access_key }}
aws_secret_access_key = {{ cluster_post_admin_secret_key }}
region = {{ spinifex_region }}
ca_bundle = {{ playbook_dir }}/../.creds/{{ cluster_env }}-ca.pem
endpoint_url = https://{{ cluster_init_host }}:4432
- name: cluster-post — slurp cluster CA from init host
ansible.builtin.slurp:
src: "{{ spinifex_ca_src }}"
register: cluster_post_ca_raw
become: true
- name: cluster-post — write CA pem to operator workstation
delegate_to: localhost
become: false
ansible.builtin.copy:
dest: "{{ playbook_dir }}/../.creds/{{ cluster_env }}-ca.pem"
mode: "0644"
content: "{{ cluster_post_ca_raw.content | b64decode }}"
- name: cluster-post — resolve absolute paths for summary
delegate_to: localhost
become: false
ansible.builtin.command: "readlink -f {{ playbook_dir }}/../.creds"
register: cluster_post_creds_abs
changed_when: false
- name: cluster-post — summary
ansible.builtin.debug:
msg: |
Post-bootstrap done:
spinifex-key: {{ 'imported now' if (cluster_post_key_import.changed | default(false)) else 'already present' }}
ubuntu AMI: ubuntu-{{ cluster_post_ubuntu_version }}
admin creds: {{ cluster_post_creds_abs.stdout }}/{{ cluster_env }}.txt
CA cert: {{ cluster_post_creds_abs.stdout }}/{{ cluster_env }}-ca.pem
AWS profile: spinifex-{{ cluster_env }} (in ~/.aws/credentials)
UI URL: https://{{ cluster_init_host }}:3000
Access Key: {{ cluster_post_admin_access_key }}
Tofu usage: AWS_PROFILE=spinifex-{{ cluster_env }} tofu plan
@@ -0,0 +1,7 @@
---
galaxy_info:
role_name: deploy
description: In-place binary + microvm artifact swap for a running Spinifex node (parity with `make deploy`).
license: BSD-3-Clause
min_ansible_version: "2.14"
dependencies: []
+1 -5
View File
@@ -127,11 +127,7 @@
state: started
- name: deploy — wait for awsgw to respond
become: true
become_user: "{{ spinifex_invoking_user }}"
environment:
AWS_PROFILE: spinifex
ansible.builtin.command: aws ec2 describe-regions
ansible.builtin.command: "{{ spinifex_aws_cli }} ec2 describe-regions"
register: deploy_awsgw
retries: 15
delay: 2
+27 -21
View File
@@ -8,28 +8,38 @@
--node {{ spinifex_node_name }}
--nodes {{ spinifex_node_count }}
- name: init — no-external opt-out
ansible.builtin.set_fact:
init_external_flags: "--no-external"
when: spinifex_external_no_external | bool
- name: init — assert pool range supplied when mode=pool
ansible.builtin.assert:
that:
- spinifex_external_pool_start | length > 0
- spinifex_external_pool_end | length > 0
fail_msg: |
spx admin init requires --external-pool when --external-mode=pool.
Set spinifex_external_pool_start / spinifex_external_pool_end in
scripts/ansible/vars/defaults.yml, or pass:
-e spinifex_external_pool_start=192.168.1.150
-e spinifex_external_pool_end=192.168.1.250
Pass -e spinifex_external_no_external=true to skip external networking.
when:
- not (spinifex_external_no_external | bool)
- spinifex_external_mode == 'pool'
- name: init — pool-mode external flags
ansible.builtin.set_fact:
init_external_flags: >-
--external-mode=pool
--external-pool={{ spinifex_external_pool_start }}-{{ spinifex_external_pool_end }}
--external-gateway={{ spinifex_external_gateway }}
--external-prefix-len={{ spinifex_external_prefix_len }}
{% if spinifex_external_gateway | length > 0 %}--external-gateway={{ spinifex_external_gateway }}{% endif %}
{% if spinifex_external_prefix_len | string | length > 0 %}--external-prefix-len={{ spinifex_external_prefix_len }}{% endif %}
{% if spinifex_external_gateway_ip | length > 0 %}--gateway-ip={{ spinifex_external_gateway_ip }}{% endif %}
when:
- not (spinifex_external_no_external | bool)
- spinifex_external_mode == 'pool'
- spinifex_external_pool_start | length > 0
- spinifex_external_pool_end | length > 0
- name: init — NAT-mode external flags
ansible.builtin.set_fact:
init_external_flags: >-
--external-mode=nat
--gateway-ip={{ spinifex_nat_gateway_ip }}
--external-gateway={{ spinifex_external_gateway }}
--external-prefix-len={{ spinifex_external_prefix_len }}
when:
- spinifex_external_mode == 'nat'
- spinifex_nat_gateway_ip | length > 0
- name: init — assemble full flag string
ansible.builtin.set_fact:
@@ -63,11 +73,7 @@
# to NATS + daemons after that. Bash start-dev.sh has internal wait loops;
# systemd path does not. Poll a benign call until gateway is fully ready.
- name: init — wait for AWS gateway readiness
become: true
become_user: "{{ spinifex_invoking_user }}"
environment:
AWS_PROFILE: spinifex
ansible.builtin.command: aws ec2 describe-regions
ansible.builtin.command: "{{ spinifex_aws_cli }} ec2 describe-regions"
register: init_gw_ready
until: init_gw_ready.rc == 0
retries: 30
@@ -79,6 +85,6 @@
msg: |
Init complete:
region={{ spinifex_region }}, az={{ spinifex_az }}, node={{ spinifex_node_name }}
external_mode={{ spinifex_external_mode }}
external_mode={{ 'disabled' if (spinifex_external_no_external | bool) else spinifex_external_mode }}
spinifex.target: started
CA: {{ spinifex_ca_trust_path }}
+15 -1
View File
@@ -40,6 +40,19 @@
path: "{{ spinifex_data_dir }}/config"
state: absent
- name: install — purge stale cluster state (configs + raft + NATS KV) on bootstrap
become: true
ansible.builtin.file:
path: "{{ item }}"
state: absent
loop:
- "{{ spinifex_etc_dir }}/predastore/predastore.toml"
- "{{ spinifex_etc_dir }}/spinifex.toml"
- "{{ spinifex_data_dir }}/predastore"
- "{{ spinifex_data_dir }}/nats"
- "{{ spinifex_data_dir }}/awsgw"
when: cluster_bootstrap_purge | default(false) | bool
- name: install — run setup.sh (creates users, dirs, systemd units)
become: true
ansible.builtin.command:
@@ -57,8 +70,9 @@
- name: install — run setup-ovn.sh --management
become: true
ansible.builtin.command:
cmd: /usr/local/share/spinifex/setup-ovn.sh --management
cmd: "/usr/local/share/spinifex/setup-ovn.sh --management --node-name={{ spinifex_node_name }}"
changed_when: true
when: install_run_ovn | default(true) | bool
- name: install — summary
ansible.builtin.debug:
+7
View File
@@ -0,0 +1,7 @@
---
galaxy_info:
role_name: logs
description: Dump spinifex + ovn + system logs into a tarball for bug reports.
license: BSD-3-Clause
min_ansible_version: "2.14"
dependencies: []
+153
View File
@@ -0,0 +1,153 @@
---
# Collect a self-contained log bundle for bug reports. NEVER mutates running
# state — only reads journalctl + /var/log/spinifex/* + status output, then
# tars into logs_output_dir. Errors on missing inputs are non-fatal so a
# half-broken node still produces a bundle.
- name: logs — resolve staging directory
ansible.builtin.set_fact:
logs_stage_dir: "/tmp/spinifex-logs-{{ ansible_facts['hostname'] }}-{{ ansible_facts['date_time']['iso8601_basic_short'] }}"
logs_bundle_path: "{{ logs_output_dir }}/spinifex-logs-{{ ansible_facts['hostname'] }}-{{ ansible_facts['date_time']['iso8601_basic_short'] }}.tar.gz"
- name: logs — create staging tree
ansible.builtin.file:
path: "{{ logs_stage_dir }}/{{ item }}"
state: directory
mode: "0755"
loop:
- journal
- varlog
- status
- kernel
- name: logs — capture journalctl for spinifex-* units
become: true
ansible.builtin.shell: |
journalctl --no-pager --output=short-iso \
-u 'spinifex-*' --since {{ logs_since | quote }} \
> {{ logs_stage_dir }}/journal/spinifex.log 2>&1 || true
changed_when: true
- name: logs — capture journalctl for ovn-* + openvswitch-switch
become: true
ansible.builtin.shell: |
journalctl --no-pager --output=short-iso \
-u 'ovn-*' -u openvswitch-switch \
--since {{ logs_since | quote }} \
> {{ logs_stage_dir }}/journal/ovn-ovs.log 2>&1 || true
changed_when: true
- name: logs — capture journalctl for nbdkit + qemu (best-effort match)
become: true
ansible.builtin.shell: |
journalctl --no-pager --output=short-iso \
--since {{ logs_since | quote }} \
_COMM=qemu-system-x86_64 + _COMM=qemu-system-aarch64 + _COMM=nbdkit \
> {{ logs_stage_dir }}/journal/qemu-nbdkit.log 2>&1 || true
changed_when: true
- name: logs — copy /var/log/spinifex/*
become: true
ansible.builtin.shell: |
if [ -d {{ spinifex_log_dir | quote }} ]; then
cp -a {{ spinifex_log_dir | quote }}/. {{ logs_stage_dir | quote }}/varlog/ 2>/dev/null || true
fi
changed_when: true
- name: logs — systemctl status snapshot (spinifex-*)
become: true
ansible.builtin.shell: |
systemctl status 'spinifex-*' --no-pager --all \
> {{ logs_stage_dir }}/status/spinifex-units.txt 2>&1 || true
systemctl status ovn-controller ovn-central openvswitch-switch \
--no-pager > {{ logs_stage_dir }}/status/ovn-ovs-units.txt 2>&1 || true
systemctl list-units 'spinifex-*' --all --no-legend --plain \
> {{ logs_stage_dir }}/status/spinifex-list.txt 2>&1 || true
changed_when: true
- name: logs — networking snapshot (ip, ss, ovs)
become: true
ansible.builtin.shell: |
ip -d -s link show > {{ logs_stage_dir }}/status/ip-link.txt 2>&1 || true
ip addr show > {{ logs_stage_dir }}/status/ip-addr.txt 2>&1 || true
ip route show table all > {{ logs_stage_dir }}/status/ip-route.txt 2>&1 || true
ss -tlnp > {{ logs_stage_dir }}/status/ss-tlnp.txt 2>&1 || true
if command -v ovs-vsctl >/dev/null 2>&1; then
ovs-vsctl show > {{ logs_stage_dir }}/status/ovs-show.txt 2>&1 || true
ovs-vsctl list-br > {{ logs_stage_dir }}/status/ovs-br.txt 2>&1 || true
fi
if command -v ovn-nbctl >/dev/null 2>&1; then
ovn-nbctl show > {{ logs_stage_dir }}/status/ovn-nb.txt 2>&1 || true
ovn-sbctl show > {{ logs_stage_dir }}/status/ovn-sb.txt 2>&1 || true
fi
changed_when: true
- name: logs — kernel ring buffer
become: true
ansible.builtin.shell: |
dmesg --time-format=iso > {{ logs_stage_dir }}/kernel/dmesg.txt 2>&1 || true
changed_when: true
when: logs_include_dmesg | bool
- name: logs — capture spx + plugin versions
become: true
ansible.builtin.shell: |
{
echo '## spx version'
/usr/local/bin/spx version 2>&1 | grep -vE '^[0-9]{4}/' || true
echo
echo '## nbdkit plugin sha256'
pdir=$(nbdkit --dump-config 2>/dev/null | awk -F= '/^plugindir=/{print $2; exit}')
[ -n "$pdir" ] && sha256sum "$pdir/nbdkit-viperblock-plugin.so" 2>/dev/null || true
echo
echo '## microvm artifacts'
sha256sum /usr/share/spinifex/microvm/* 2>/dev/null || true
echo
echo '## kernel + os'
uname -a
cat /etc/os-release 2>/dev/null || true
} > {{ logs_stage_dir }}/status/versions.txt 2>&1 || true
changed_when: true
- name: logs — create bundle tarball
become: true
ansible.builtin.shell: |
tar -C {{ logs_stage_dir | dirname | quote }} \
-czf {{ logs_bundle_path | quote }} \
{{ logs_stage_dir | basename | quote }}
changed_when: true
- name: logs — make bundle owned by invoking user
become: true
ansible.builtin.file:
path: "{{ logs_bundle_path }}"
owner: "{{ spinifex_invoking_user }}"
group: "{{ spinifex_invoking_user }}"
mode: "0640"
- name: logs — remove staging tree
become: true
ansible.builtin.file:
path: "{{ logs_stage_dir }}"
state: absent
- name: logs — bundle size
ansible.builtin.stat:
path: "{{ logs_bundle_path }}"
register: logs_bundle_stat
- name: logs — summary
ansible.builtin.debug:
msg: |
Log bundle written:
path : {{ logs_bundle_path }}
size : {{ (logs_bundle_stat.stat.size | int / 1024) | round(1) }} KiB
since : {{ logs_since }}
Contents:
journal/ spinifex-*, ovn-*+openvswitch-switch, qemu/nbdkit
varlog/ copy of {{ spinifex_log_dir }}
status/ systemctl status, ip/ss/ovs/ovn show, versions
kernel/ dmesg{{ '' if logs_include_dmesg else ' (skipped via -e logs_include_dmesg=false)' }}
Attach to bug report. For live tail use:
journalctl -u 'spinifex-*' -f
@@ -0,0 +1,7 @@
---
galaxy_info:
role_name: restore
description: Restore a spinifex+ovn+ovs snapshot bundle, refresh CA trust, restart.
license: BSD-3-Clause
min_ansible_version: "2.14"
dependencies: []
@@ -0,0 +1,140 @@
---
# Inverse of the snapshot role: stop services, wipe the same path set,
# untar the named bundle, refresh CA trust (because /etc/spinifex/ca.pem
# came from the snapshot but the system trust store didn't), restart.
- name: restore — require snapshot_name
ansible.builtin.assert:
that:
- snapshot_name is defined
- snapshot_name | length > 0
- snapshot_name is match('^[A-Za-z0-9._-]+$')
fail_msg: "snapshot_name required (alphanumeric/._-): -e snapshot_name=<name>"
- name: restore — resolve bundle path
ansible.builtin.set_fact:
snapshot_bundle_path: "{{ snapshot_dir }}/{{ snapshot_name }}.tar.gz"
- name: restore — assert bundle exists
become: true
ansible.builtin.stat:
path: "{{ snapshot_bundle_path }}"
register: restore_bundle_stat
- name: restore — fail if missing
ansible.builtin.assert:
that: restore_bundle_stat.stat.exists
fail_msg: |
Snapshot bundle missing: {{ snapshot_bundle_path }}.
Available bundles:
ls -lh {{ snapshot_dir }}/*.tar.gz 2>/dev/null
- name: restore — stop services in dependency order
become: true
ansible.builtin.systemd:
name: "{{ item }}"
state: stopped
loop: "{{ snapshot_services_stop_order }}"
failed_when: false
- name: restore — kill lingering QEMU VMs
become: true
ansible.builtin.command: "pkill -x {{ item }}"
loop: "{{ spinifex_qemu_processes }}"
changed_when: false
failed_when: false
- name: restore — wait for QEMU exit
become: true
ansible.builtin.shell: |
set -e
for i in $(seq 1 {{ teardown_process_wait_seconds }}); do
alive=0
for proc in {{ spinifex_qemu_processes | join(' ') }}; do
if pgrep -x "$proc" >/dev/null 2>&1; then alive=1; break; fi
done
if [ "$alive" = "0" ]; then exit 0; fi
sleep 1
done
exit 1
changed_when: false
- name: restore — wipe target paths (only those the snapshot will replace)
become: true
ansible.builtin.file:
path: "{{ item }}"
state: absent
loop: "{{ snapshot_paths }}"
- name: restore — recreate parent dirs that tar will refill
become: true
ansible.builtin.file:
path: "{{ item }}"
state: directory
owner: root
group: root
mode: "0755"
loop:
- "{{ ovn_db_dir }}"
- /etc/openvswitch
- name: restore — extract bundle (preserve permissions/ACLs)
become: true
ansible.builtin.command:
cmd: >-
tar --acls --xattrs -xzpf {{ snapshot_bundle_path | quote }}
-C /
changed_when: true
- name: restore — refresh spinifex CA in system trust
become: true
ansible.builtin.stat:
path: "{{ spinifex_ca_src }}"
register: restore_ca_stat
- name: restore — copy CA back to trust path
become: true
ansible.builtin.copy:
src: "{{ spinifex_ca_src }}"
dest: "{{ spinifex_ca_trust_path }}"
mode: "0644"
remote_src: true
when: restore_ca_stat.stat.exists
- name: restore — rebuild CA bundle
become: true
ansible.builtin.command: update-ca-certificates
changed_when: true
when: restore_ca_stat.stat.exists
- name: restore — daemon-reload (covers any unit file delta in the snapshot)
become: true
ansible.builtin.systemd:
daemon_reload: true
- name: restore — restart services in reverse order
become: true
ansible.builtin.systemd:
name: "{{ item }}"
state: started
loop: "{{ snapshot_services_stop_order | reverse | list }}"
failed_when: false
- name: restore — wait for AWS gateway readiness
ansible.builtin.command: "{{ spinifex_aws_cli }} ec2 describe-regions"
register: restore_gw_ready
retries: 30
delay: 2
until: restore_gw_ready.rc == 0
changed_when: false
failed_when: false
- name: restore — summary
ansible.builtin.debug:
msg: |
Restore complete:
snapshot : {{ snapshot_name }}
bundle : {{ snapshot_bundle_path }}
paths : {{ snapshot_paths | join(', ') }}
CA : {{ 'refreshed' if restore_ca_stat.stat.exists else 'not in bundle — system trust unchanged' }}
gateway : {{ 'ready' if restore_gw_ready.rc == 0 else 'NOT ready after restart — investigate' }}
+9 -45
View File
@@ -16,12 +16,8 @@
creates: "{{ smoketest_user_home }}/.ssh/spinifex-key"
- name: smoketest — import SSH key to spinifex
become: true
become_user: "{{ spinifex_invoking_user }}"
environment:
AWS_PROFILE: spinifex
ansible.builtin.command: >-
aws ec2 import-key-pair
{{ spinifex_aws_cli }} ec2 import-key-pair
--key-name spinifex-key
--public-key-material fileb://{{ smoketest_user_home }}/.ssh/spinifex-key.pub
register: smoketest_keypair
@@ -51,12 +47,8 @@
register: smoketest_local_img
- name: smoketest — import AMI from local file
become: true
become_user: "{{ spinifex_invoking_user }}"
environment:
AWS_PROFILE: spinifex
ansible.builtin.command: >-
spx admin images import
{{ spinifex_spx_cli }} admin images import
--file {{ smoketest_local_image_path }}
--distro {{ spinifex_smoketest_distro }}
--version {{ spinifex_smoketest_version }}
@@ -75,12 +67,8 @@
when: smoketest_local_img.stat.exists
- name: smoketest — import AMI by name (fallback)
become: true
become_user: "{{ spinifex_invoking_user }}"
environment:
AWS_PROFILE: spinifex
ansible.builtin.command: >-
spx admin images import
{{ spinifex_spx_cli }} admin images import
--name {{ spinifex_smoketest_distro }}-{{ spinifex_smoketest_version }}-{{ smoketest_arch }}
register: smoketest_import_remote
until: >-
@@ -105,11 +93,7 @@
smoketest_instance_type: "{{ 't3a.small' if smoketest_amd.rc == 0 else 't3.small' }}"
- name: smoketest — find AMI ID
become: true
become_user: "{{ spinifex_invoking_user }}"
environment:
AWS_PROFILE: spinifex
ansible.builtin.command: aws ec2 describe-images --query "Images[0].ImageId" --output text
ansible.builtin.command: "{{ spinifex_aws_cli }} ec2 describe-images --query Images[0].ImageId --output text"
register: smoketest_ami
until: smoketest_ami.rc == 0
retries: 10
@@ -117,12 +101,8 @@
changed_when: false
- name: smoketest — find public subnet (best-effort)
become: true
become_user: "{{ spinifex_invoking_user }}"
environment:
AWS_PROFILE: spinifex
ansible.builtin.command: >-
aws ec2 describe-subnets
{{ spinifex_aws_cli }} ec2 describe-subnets
--filters Name=map-public-ip-on-launch,Values=true
--query Subnets[0].SubnetId
--output text
@@ -131,11 +111,7 @@
failed_when: false
- name: smoketest — fallback to first subnet (best-effort)
become: true
become_user: "{{ spinifex_invoking_user }}"
environment:
AWS_PROFILE: spinifex
ansible.builtin.command: aws ec2 describe-subnets --query "Subnets[0].SubnetId" --output text
ansible.builtin.command: "{{ spinifex_aws_cli }} ec2 describe-subnets --query Subnets[0].SubnetId --output text"
register: smoketest_subnet_any
changed_when: false
failed_when: false
@@ -161,12 +137,8 @@
when: smoketest_subnet_id | trim == ''
- name: smoketest — launch instance
become: true
become_user: "{{ spinifex_invoking_user }}"
environment:
AWS_PROFILE: spinifex
ansible.builtin.command: >-
aws ec2 run-instances
{{ spinifex_aws_cli }} ec2 run-instances
--image-id {{ smoketest_ami.stdout }}
--instance-type {{ smoketest_instance_type }}
--key-name spinifex-key
@@ -182,12 +154,8 @@
when: smoketest_subnet_id | trim != ''
- name: smoketest — wait for instance running (proves launch path works)
become: true
become_user: "{{ spinifex_invoking_user }}"
environment:
AWS_PROFILE: spinifex
ansible.builtin.command: >-
aws ec2 describe-instances
{{ spinifex_aws_cli }} ec2 describe-instances
--instance-ids {{ smoketest_instance.stdout }}
--query Reservations[0].Instances[0].State.Name
--output text
@@ -203,12 +171,8 @@
- smoketest_instance.stdout | trim != ''
- name: smoketest — terminate instance (cleanup)
become: true
become_user: "{{ spinifex_invoking_user }}"
environment:
AWS_PROFILE: spinifex
ansible.builtin.command: >-
aws ec2 terminate-instances
{{ spinifex_aws_cli }} ec2 terminate-instances
--instance-ids {{ smoketest_instance.stdout }}
register: smoketest_terminate
until: smoketest_terminate.rc == 0
@@ -0,0 +1,7 @@
---
galaxy_info:
role_name: snapshot
description: Quiesce spinifex + ovn + ovs, tar state paths, restart.
license: BSD-3-Clause
min_ansible_version: "2.14"
dependencies: []
@@ -0,0 +1,154 @@
---
# Snapshot a quiesced spinifex node: stop spinifex.target → ovn → ovs in order,
# tar /etc/spinifex + /var/lib/spinifex + /var/lib/ovn + /etc/openvswitch/conf.db,
# restart in reverse. Lets devs checkpoint a working state before risky ops
# (branch switch, viperblock surgery, ovn-controller restart cycle) and roll
# back via dev-restore without a full ~10-min dev-reset.
#
# Caveat: /var/lib/spinifex contains viperblock volumes — snapshot size scales
# with VM disks. Default gzip; switch to a faster compressor by hand if needed.
- name: snapshot — require snapshot_name
ansible.builtin.assert:
that:
- snapshot_name is defined
- snapshot_name | length > 0
- snapshot_name is match('^[A-Za-z0-9._-]+$')
fail_msg: "snapshot_name required (alphanumeric/._-): -e snapshot_name=<name>"
- name: snapshot — resolve bundle path
ansible.builtin.set_fact:
snapshot_bundle_path: "{{ snapshot_dir }}/{{ snapshot_name }}.tar.gz"
- name: snapshot — refuse to overwrite existing bundle
ansible.builtin.stat:
path: "{{ snapshot_bundle_path }}"
register: snapshot_existing
- name: snapshot — assert not already present
ansible.builtin.assert:
that: not snapshot_existing.stat.exists
fail_msg: |
Snapshot {{ snapshot_bundle_path }} already exists. Pick another
name, or `sudo rm` the existing bundle first.
- name: snapshot — ensure snapshot dir exists
become: true
ansible.builtin.file:
path: "{{ snapshot_dir }}"
state: directory
owner: root
group: root
mode: "0750"
- name: snapshot — stop services in dependency order
become: true
ansible.builtin.systemd:
name: "{{ item }}"
state: stopped
loop: "{{ snapshot_services_stop_order }}"
failed_when: false
- name: snapshot — kill lingering QEMU VMs
become: true
ansible.builtin.command: "pkill -x {{ item }}"
loop: "{{ spinifex_qemu_processes }}"
changed_when: false
failed_when: false
- name: snapshot — wait for QEMU exit (up to {{ teardown_process_wait_seconds }}s)
become: true
ansible.builtin.shell: |
set -e
for i in $(seq 1 {{ teardown_process_wait_seconds }}); do
alive=0
for proc in {{ spinifex_qemu_processes | join(' ') }}; do
if pgrep -x "$proc" >/dev/null 2>&1; then alive=1; break; fi
done
if [ "$alive" = "0" ]; then exit 0; fi
sleep 1
done
echo "QEMU still running after {{ teardown_process_wait_seconds }}s" >&2
exit 1
changed_when: false
- name: snapshot — verify which paths actually exist (skip missing silently)
ansible.builtin.stat:
path: "{{ item }}"
register: snapshot_path_stats
loop: "{{ snapshot_paths }}"
loop_control:
label: "{{ item }}"
- name: snapshot — assemble list of present paths
ansible.builtin.set_fact:
snapshot_present_paths: >-
{{ snapshot_path_stats.results
| selectattr('stat.exists') | map(attribute='item') | list }}
- name: snapshot — assert at least one path present
ansible.builtin.assert:
that: snapshot_present_paths | length > 0
fail_msg: |
None of the snapshot paths exist:
{{ snapshot_paths | join(', ') }}
Has spinifex ever been installed on this node?
- name: snapshot — create bundle (tar czf with absolute paths)
become: true
ansible.builtin.command:
cmd: >-
tar --acls --xattrs -czpf {{ snapshot_bundle_path | quote }}
{{ snapshot_present_paths | map('quote') | join(' ') }}
changed_when: true
- name: snapshot — record manifest inside the bundle dir for traceability
become: true
ansible.builtin.copy:
dest: "{{ snapshot_dir }}/{{ snapshot_name }}.manifest"
mode: "0640"
content: |
snapshot_name: {{ snapshot_name }}
created_at: {{ ansible_facts['date_time']['iso8601'] }}
hostname: {{ ansible_facts['hostname'] }}
paths:
{% for p in snapshot_present_paths %} - {{ p }}
{% endfor %}
services_quiesced:
{% for s in snapshot_services_stop_order %} - {{ s }}
{% endfor %}
- name: snapshot — restart services in reverse order
become: true
ansible.builtin.systemd:
name: "{{ item }}"
state: started
loop: "{{ snapshot_services_stop_order | reverse | list }}"
failed_when: false
- name: snapshot — wait for AWS gateway readiness
ansible.builtin.command: "{{ spinifex_aws_cli }} ec2 describe-regions"
register: snapshot_gw_ready
retries: 30
delay: 2
until: snapshot_gw_ready.rc == 0
changed_when: false
failed_when: false
- name: snapshot — bundle size
become: true
ansible.builtin.stat:
path: "{{ snapshot_bundle_path }}"
register: snapshot_bundle_stat
- name: snapshot — summary
ansible.builtin.debug:
msg: |
Snapshot complete:
name : {{ snapshot_name }}
path : {{ snapshot_bundle_path }}
size : {{ (snapshot_bundle_stat.stat.size | int / 1024 / 1024) | round(1) }} MiB
paths : {{ snapshot_present_paths | join(', ') }}
gateway : {{ 'ready' if snapshot_gw_ready.rc == 0 else 'NOT ready after restart — investigate' }}
Restore with: ansible-playbook playbooks/dev-restore.yml -e snapshot_name={{ snapshot_name }}
@@ -0,0 +1,7 @@
---
galaxy_info:
role_name: status
description: Read-only health snapshot for a Spinifex dev node (doctor).
license: BSD-3-Clause
min_ansible_version: "2.14"
dependencies: []
+259
View File
@@ -0,0 +1,259 @@
---
# Read-only health snapshot. NEVER mutates state. Every probe must use
# `changed_when: false` and `failed_when: false` so the role always reaches
# the summary task even when the node is half-broken — surfacing brokenness
# is the whole point.
- name: status — spinifex.target activation
ansible.builtin.command: systemctl is-active spinifex.target
register: status_target
changed_when: false
failed_when: false
- name: status — list spinifex-* units (active + failed)
ansible.builtin.shell: |
systemctl list-units 'spinifex-*' --all --no-legend --plain \
--output=json 2>/dev/null || \
systemctl list-units 'spinifex-*' --all --no-legend --plain
register: status_units
changed_when: false
failed_when: false
- name: status — failed spinifex-* units (one line per)
ansible.builtin.shell: |
systemctl list-units 'spinifex-*' --state=failed --no-legend --plain \
| awk '{print $1}'
register: status_failed_units
changed_when: false
failed_when: false
- name: status — port listeners (which process holds each spinifex port)
ansible.builtin.shell: |
ss -tlnpH 'sport = :{{ item.port }}' | head -1
register: status_ports
loop: "{{ spinifex_ports }}"
changed_when: false
failed_when: false
loop_control:
label: "{{ item.service }} ({{ item.port }})"
- name: status — OVS tooling present?
ansible.builtin.command: which ovs-vsctl
register: status_ovs_which
changed_when: false
failed_when: false
- name: status — OVS bridges
become: true
ansible.builtin.command: ovs-vsctl list-br
register: status_ovs_brs
changed_when: false
failed_when: false
when: status_ovs_which.rc == 0
- name: status — br-int exists?
become: true
ansible.builtin.command: ovs-vsctl br-exists br-int
register: status_brint
changed_when: false
failed_when: false
when: status_ovs_which.rc == 0
- name: status — ovn-controller active?
ansible.builtin.command: systemctl is-active ovn-controller
register: status_ovn_ctrl
changed_when: false
failed_when: false
- name: status — ovn-central active?
ansible.builtin.command: systemctl is-active ovn-central
register: status_ovn_central
changed_when: false
failed_when: false
- name: status — OVN NB/SB DB files present?
become: true
ansible.builtin.stat:
path: "{{ item }}"
register: status_ovn_dbs
loop:
- "{{ ovn_nb_db }}"
- "{{ ovn_sb_db }}"
loop_control:
label: "{{ item }}"
- name: status — spinifex.toml present?
become: true
ansible.builtin.stat:
path: "{{ spinifex_config_file }}"
register: status_config
- name: status — CA trust present?
become: true
ansible.builtin.stat:
path: "{{ spinifex_ca_trust_path }}"
register: status_ca
- name: status — AWS gateway round-trip (describe-regions)
ansible.builtin.command: "{{ spinifex_aws_cli }} ec2 describe-regions --output text"
register: status_awsgw
changed_when: false
failed_when: false
- name: status — installed spx binary
become: true
ansible.builtin.stat:
path: /usr/local/bin/spx
checksum_algorithm: sha256
register: status_spx_installed
- name: status — built spx binary
ansible.builtin.stat:
path: "{{ spinifex_root }}/bin/spx"
checksum_algorithm: sha256
register: status_spx_built
- name: status — spx version (installed)
ansible.builtin.shell: |
/usr/local/bin/spx version 2>/dev/null | grep -vE '^[0-9]{4}/' | head -1
register: status_spx_version
changed_when: false
failed_when: false
when: status_spx_installed.stat.exists
- name: status — installed nbdkit plugin
become: true
ansible.builtin.shell: |
set -e
pdir=$(nbdkit --dump-config | awk -F= '/^plugindir=/{print $2; exit}')
[ -f "$pdir/nbdkit-viperblock-plugin.so" ] && \
sha256sum "$pdir/nbdkit-viperblock-plugin.so" | awk '{print $1}'
register: status_plugin_installed_sha
changed_when: false
failed_when: false
- name: status — built nbdkit plugin
ansible.builtin.stat:
path: "{{ mulga_root }}/viperblock/lib/nbdkit-viperblock-plugin.so"
checksum_algorithm: sha256
register: status_plugin_built
- name: status — microvm artifacts (built)
ansible.builtin.stat:
path: "{{ spinifex_root }}/build/microvm/{{ item }}"
checksum_algorithm: sha256
register: status_microvm_built
loop:
- vmlinuz
- initramfs.cpio.gz
- name: status — microvm artifacts (installed)
become: true
ansible.builtin.stat:
path: /usr/share/spinifex/microvm/{{ item }}
checksum_algorithm: sha256
register: status_microvm_installed
loop:
- vmlinuz
- initramfs.cpio.gz
- name: status — running QEMU VMs
ansible.builtin.shell: pgrep -fa 'qemu-system-' | wc -l
register: status_qemu_count
changed_when: false
failed_when: false
- name: status — count AMIs
ansible.builtin.command: "{{ spinifex_aws_cli }} ec2 describe-images --query length(Images) --output text"
register: status_ami_count
changed_when: false
failed_when: false
- name: status — count keypairs
ansible.builtin.command: "{{ spinifex_aws_cli }} ec2 describe-key-pairs --query length(KeyPairs) --output text"
register: status_key_count
changed_when: false
failed_when: false
- name: status — count volumes
ansible.builtin.command: "{{ spinifex_aws_cli }} ec2 describe-volumes --query length(Volumes) --output text"
register: status_vol_count
changed_when: false
failed_when: false
- name: status — count running instances
ansible.builtin.command: >-
{{ spinifex_aws_cli }} ec2 describe-instances
--filters Name=instance-state-name,Values=running
--query length(Reservations[].Instances[]) --output text
register: status_instance_count
changed_when: false
failed_when: false
- name: status — compute drift facts
ansible.builtin.set_fact:
status_spx_drift: >-
{{ status_spx_installed.stat.exists and status_spx_built.stat.exists and
(status_spx_installed.stat.checksum | default('')) !=
(status_spx_built.stat.checksum | default('')) }}
status_plugin_drift: >-
{{ status_plugin_built.stat.exists and
(status_plugin_installed_sha.stdout | default('') | trim) !=
(status_plugin_built.stat.checksum | default('')) }}
status_microvm_built_present: "{{ status_microvm_built.results
| map(attribute='stat.exists') | min }}"
- name: status — render port lines
ansible.builtin.set_fact:
status_port_lines: >-
{{ status_port_lines | default([]) +
[('%-24s' | format(item.item.service ~ ' (' ~ item.item.port ~ ')'))
~ ': '
~ ('listening' if (item.stdout | length) > 0 else 'free')] }}
loop: "{{ status_ports.results }}"
loop_control:
label: "{{ item.item.service }}"
- name: status — assemble summary
ansible.builtin.set_fact:
status_summary_lines: >-
{{
[
'── Spinifex dev-node status ──────────────────────────────────────────────',
'target : ' ~ (status_target.stdout | trim),
'failed units : ' ~ (status_failed_units.stdout_lines | length)
~ ((' → ' ~ (status_failed_units.stdout_lines | join(', '))) if (status_failed_units.stdout_lines | length) > 0 else ''),
'── Ports ──'
]
+ status_port_lines
+ [
'── Networking ──',
'ovs-vsctl : ' ~ ('present' if status_ovs_which.rc == 0 else 'MISSING'),
'ovs bridges : ' ~ ((status_ovs_brs.stdout_lines | default([]) | join(', ')) or '(none)'),
'br-int : ' ~ ('present' if (status_brint.rc | default(1)) == 0 else 'MISSING'),
'ovn-controller : ' ~ (status_ovn_ctrl.stdout | trim),
'ovn-central : ' ~ (status_ovn_central.stdout | trim),
'ovn NB DB : ' ~ ('present' if status_ovn_dbs.results[0].stat.exists else 'absent'),
'ovn SB DB : ' ~ ('present' if status_ovn_dbs.results[1].stat.exists else 'absent'),
'── Config / trust ──',
'spinifex.toml : ' ~ ('present' if status_config.stat.exists else 'MISSING') ~ ' (' ~ spinifex_config_file ~ ')',
'CA trust : ' ~ ('present' if status_ca.stat.exists else 'MISSING') ~ ' (' ~ spinifex_ca_trust_path ~ ')',
'── AWS gateway ──',
'describe-regions : ' ~ ('OK' if status_awsgw.rc == 0 else 'FAIL (rc=' ~ status_awsgw.rc ~ ')'),
'AMIs : ' ~ (status_ami_count.stdout | default('?') | trim),
'keypairs : ' ~ (status_key_count.stdout | default('?') | trim),
'volumes : ' ~ (status_vol_count.stdout | default('?') | trim),
'running instances: ' ~ (status_instance_count.stdout | default('?') | trim),
'running QEMU : ' ~ (status_qemu_count.stdout | default('?') | trim),
'── Versions / drift ──',
'spx (installed) : ' ~ ((status_spx_version.stdout | default('') | trim) or '(not installed)'),
'spx drift : ' ~ ('YES (built ≠ installed) — run ansible-dev-deploy' if status_spx_drift else 'no'),
'plugin drift : ' ~ ('YES (built ≠ installed) — run ansible-dev-deploy' if status_plugin_drift else 'no'),
'microvm built : ' ~ ('present' if status_microvm_built_present else 'not built on this branch'),
'──────────────────────────────────────────────────────────────────────────'
]
}}
- name: status — summary
ansible.builtin.debug:
msg: "{{ status_summary_lines }}"
@@ -0,0 +1,7 @@
---
galaxy_info:
role_name: version
description: Detailed build-vs-install drift report for spinifex artifacts.
license: BSD-3-Clause
min_ansible_version: "2.14"
dependencies: []
@@ -0,0 +1,161 @@
---
# Detailed build-vs-install drift report for the four artifacts that
# `ansible-dev-deploy` swaps: spx, the nbdkit viperblock plugin, and the
# two microvm artifacts (kernel + initramfs). Pure read-only; faster
# than dev-status (no AWS / OVN / port probing).
- name: version — built spx
ansible.builtin.stat:
path: "{{ spinifex_root }}/bin/spx"
checksum_algorithm: sha256
register: ver_spx_built
- name: version — installed spx
become: true
ansible.builtin.stat:
path: /usr/local/bin/spx
checksum_algorithm: sha256
register: ver_spx_installed
- name: version — installed spx version string
ansible.builtin.shell: |
/usr/local/bin/spx version 2>/dev/null | grep -vE '^[0-9]{4}/' | head -1
register: ver_spx_version
changed_when: false
failed_when: false
when: ver_spx_installed.stat.exists
- name: version — built plugin
ansible.builtin.stat:
path: "{{ mulga_root }}/viperblock/lib/nbdkit-viperblock-plugin.so"
checksum_algorithm: sha256
register: ver_plugin_built
- name: version — nbdkit plugin dir
ansible.builtin.shell: |
nbdkit --dump-config 2>/dev/null | awk -F= '/^plugindir=/{print $2; exit}'
register: ver_plugindir
changed_when: false
failed_when: false
- name: version — installed plugin
become: true
ansible.builtin.stat:
path: "{{ ver_plugindir.stdout | trim }}/nbdkit-viperblock-plugin.so"
checksum_algorithm: sha256
register: ver_plugin_installed
when: ver_plugindir.stdout | trim | length > 0
- name: version — built microvm artifacts
ansible.builtin.stat:
path: "{{ spinifex_root }}/build/microvm/{{ item }}"
checksum_algorithm: sha256
register: ver_microvm_built
loop:
- vmlinuz
- initramfs.cpio.gz
- name: version — installed microvm artifacts
become: true
ansible.builtin.stat:
path: /usr/share/spinifex/microvm/{{ item }}
checksum_algorithm: sha256
register: ver_microvm_installed
loop:
- vmlinuz
- initramfs.cpio.gz
- name: version — sub-repo HEAD short-rev (spinifex)
ansible.builtin.command: git -C {{ spinifex_root | quote }} rev-parse --short HEAD
register: ver_spinifex_head
changed_when: false
failed_when: false
- name: version — sub-repo HEAD short-rev (viperblock)
ansible.builtin.command: git -C {{ mulga_root }}/viperblock rev-parse --short HEAD
register: ver_viperblock_head
changed_when: false
failed_when: false
- name: version — sub-repo HEAD short-rev (predastore)
ansible.builtin.command: git -C {{ mulga_root }}/predastore rev-parse --short HEAD
register: ver_predastore_head
changed_when: false
failed_when: false
- name: version — assemble per-artifact rows
ansible.builtin.set_fact:
ver_artifact_rows: >-
{{
[
('%-18s built=%s installed=%s drift=%s' | format(
'spx',
(ver_spx_built.stat.checksum | default('-') | truncate(13, true, ''))
if ver_spx_built.stat.exists else 'MISSING',
(ver_spx_installed.stat.checksum | default('-') | truncate(13, true, ''))
if ver_spx_installed.stat.exists else 'MISSING',
('YES' if ver_spx_built.stat.exists and ver_spx_installed.stat.exists
and ver_spx_built.stat.checksum != ver_spx_installed.stat.checksum
else 'no')
)),
('%-18s built=%s installed=%s drift=%s' | format(
'plugin (nbdkit)',
(ver_plugin_built.stat.checksum | default('-') | truncate(13, true, ''))
if ver_plugin_built.stat.exists else 'MISSING',
(ver_plugin_installed.stat.checksum | default('-') | truncate(13, true, ''))
if (ver_plugin_installed.stat | default({})).get('exists', false) else 'MISSING',
('YES' if ver_plugin_built.stat.exists
and (ver_plugin_installed.stat | default({})).get('exists', false)
and ver_plugin_built.stat.checksum != ver_plugin_installed.stat.checksum
else 'no')
)),
('%-18s built=%s installed=%s drift=%s' | format(
'microvm vmlinuz',
(ver_microvm_built.results[0].stat.checksum | default('-') | truncate(13, true, ''))
if ver_microvm_built.results[0].stat.exists else 'MISSING',
(ver_microvm_installed.results[0].stat.checksum | default('-') | truncate(13, true, ''))
if ver_microvm_installed.results[0].stat.exists else 'MISSING',
('YES' if ver_microvm_built.results[0].stat.exists
and ver_microvm_installed.results[0].stat.exists
and ver_microvm_built.results[0].stat.checksum
!= ver_microvm_installed.results[0].stat.checksum
else 'no')
)),
('%-18s built=%s installed=%s drift=%s' | format(
'microvm initramfs',
(ver_microvm_built.results[1].stat.checksum | default('-') | truncate(13, true, ''))
if ver_microvm_built.results[1].stat.exists else 'MISSING',
(ver_microvm_installed.results[1].stat.checksum | default('-') | truncate(13, true, ''))
if ver_microvm_installed.results[1].stat.exists else 'MISSING',
('YES' if ver_microvm_built.results[1].stat.exists
and ver_microvm_installed.results[1].stat.exists
and ver_microvm_built.results[1].stat.checksum
!= ver_microvm_installed.results[1].stat.checksum
else 'no')
))
]
}}
- name: version — compute any-drift flag
ansible.builtin.set_fact:
ver_any_drift: "{{ 'YES' in (ver_artifact_rows | join(' ')) }}"
- name: version — summary
ansible.builtin.debug:
msg: >-
{{
[
'── Spinifex build vs install ─────────────────────────────────────────────',
'spx (installed): ' ~ ((ver_spx_version.stdout | default('') | trim) or '(not installed)')
]
+ ver_artifact_rows
+ [
'── Sub-repo HEADs ──',
'spinifex : ' ~ ((ver_spinifex_head.stdout | default('') | trim) or '(not a git repo)'),
'viperblock : ' ~ ((ver_viperblock_head.stdout | default('') | trim) or '(missing)'),
'predastore : ' ~ ((ver_predastore_head.stdout | default('') | trim) or '(missing)'),
'──────────────────────────────────────────────────────────────────────────',
('DRIFT detected — run: make ansible-dev-deploy' if ver_any_drift
else 'No drift; built = installed for all artifacts.')
]
}}
+7
View File
@@ -0,0 +1,7 @@
---
galaxy_info:
role_name: vpc
description: Idempotent dev-mode VPC + public subnet + IGW + default route fixture.
license: BSD-3-Clause
min_ansible_version: "2.14"
dependencies: []
+128
View File
@@ -0,0 +1,128 @@
---
# Tear down the dev VPC fixture. Deletes in reverse dependency order:
# route-table associations, route table, subnet, IGW (detach + delete),
# VPC. Each step is best-effort so a partially-converged fixture still
# converges to "gone" on re-run.
- name: vpc — find VPC by Name tag
ansible.builtin.command: >-
{{ spinifex_aws_cli }} ec2 describe-vpcs
--filters Name=tag:Name,Values={{ dev_vpc_name }}
--query Vpcs[0].VpcId --output text
register: absent_vpc
changed_when: false
- name: vpc — nothing to do
ansible.builtin.debug:
msg: "VPC tagged Name={{ dev_vpc_name }} not found; nothing to delete."
when: absent_vpc.stdout | trim in ['', 'None']
- name: vpc — short-circuit when no VPC
ansible.builtin.meta: end_play
when: absent_vpc.stdout | trim in ['', 'None']
- name: vpc — set VPC id fact
ansible.builtin.set_fact:
absent_vpc_id: "{{ absent_vpc.stdout | trim }}"
- name: vpc — find subnet
ansible.builtin.command: >-
{{ spinifex_aws_cli }} ec2 describe-subnets
--filters Name=vpc-id,Values={{ absent_vpc_id }}
Name=tag:Name,Values={{ dev_vpc_name }}-public
--query Subnets[0].SubnetId --output text
register: absent_subnet
changed_when: false
# Enumerate ALL non-main route tables in the VPC (main RT can't be
# deleted; it goes when the VPC does). Catches orphans from earlier
# runs that landed before the create-tags workaround.
- name: vpc — find all non-main route tables in VPC
ansible.builtin.command: >-
{{ spinifex_aws_cli }} ec2 describe-route-tables
--filters Name=vpc-id,Values={{ absent_vpc_id }}
--query RouteTables[?!(Associations[?Main==`true`])].RouteTableId
--output text
register: absent_rt_list
changed_when: false
- name: vpc — split RT id list
ansible.builtin.set_fact:
absent_rt_ids: "{{ absent_rt_list.stdout.split() | reject('equalto', '') | list }}"
- name: vpc — find all subnet/RT associations to disassociate
ansible.builtin.command: >-
{{ spinifex_aws_cli }} ec2 describe-route-tables
--route-table-ids {{ item }}
--query RouteTables[0].Associations[?SubnetId!=null].RouteTableAssociationId
--output text
register: absent_assocs
loop: "{{ absent_rt_ids }}"
changed_when: false
failed_when: false
- name: vpc — disassociate subnets from route tables
ansible.builtin.command: >-
{{ spinifex_aws_cli }} ec2 disassociate-route-table
--association-id {{ item }}
loop: "{{ absent_assocs.results
| map(attribute='stdout')
| map('default', '')
| map('split')
| flatten
| reject('equalto', '')
| list }}"
changed_when: true
failed_when: false
- name: vpc — delete all non-main route tables
ansible.builtin.command: >-
{{ spinifex_aws_cli }} ec2 delete-route-table
--route-table-id {{ item }}
loop: "{{ absent_rt_ids }}"
changed_when: true
failed_when: false
- name: vpc — delete subnet
ansible.builtin.command: >-
{{ spinifex_aws_cli }} ec2 delete-subnet
--subnet-id {{ absent_subnet.stdout | trim }}
when: absent_subnet.stdout | trim not in ['', 'None']
changed_when: true
failed_when: false
- name: vpc — find IGW
ansible.builtin.command: >-
{{ spinifex_aws_cli }} ec2 describe-internet-gateways
--filters Name=attachment.vpc-id,Values={{ absent_vpc_id }}
--query InternetGateways[0].InternetGatewayId --output text
register: absent_igw
changed_when: false
- name: vpc — detach IGW
ansible.builtin.command: >-
{{ spinifex_aws_cli }} ec2 detach-internet-gateway
--internet-gateway-id {{ absent_igw.stdout | trim }}
--vpc-id {{ absent_vpc_id }}
when: absent_igw.stdout | trim not in ['', 'None']
changed_when: true
failed_when: false
- name: vpc — delete IGW
ansible.builtin.command: >-
{{ spinifex_aws_cli }} ec2 delete-internet-gateway
--internet-gateway-id {{ absent_igw.stdout | trim }}
when: absent_igw.stdout | trim not in ['', 'None']
changed_when: true
failed_when: false
- name: vpc — delete VPC
ansible.builtin.command: >-
{{ spinifex_aws_cli }} ec2 delete-vpc
--vpc-id {{ absent_vpc_id }}
changed_when: true
failed_when: false
- name: vpc — summary (absent)
ansible.builtin.debug:
msg: "Dev VPC {{ dev_vpc_name }} (was {{ absent_vpc_id }}) torn down."
+3
View File
@@ -0,0 +1,3 @@
---
- name: vpc — dispatch by state
ansible.builtin.include_tasks: "{{ dev_vpc_state }}.yml"
+209
View File
@@ -0,0 +1,209 @@
---
# Idempotent create. Every step describes-then-creates: re-running on an
# already-converged VPC produces zero changes. Tag everything with
# Name={{ dev_vpc_name }} so a single tag query finds the whole graph.
- name: vpc — find existing VPC by Name tag
ansible.builtin.command: >-
{{ spinifex_aws_cli }} ec2 describe-vpcs
--filters Name=tag:Name,Values={{ dev_vpc_name }}
--query Vpcs[0].VpcId --output text
register: vpc_existing
changed_when: false
- name: vpc — create VPC if missing
ansible.builtin.command: >-
{{ spinifex_aws_cli }} ec2 create-vpc
--cidr-block {{ dev_vpc_cidr }}
--tag-specifications
ResourceType=vpc,Tags=[{Key=Name,Value={{ dev_vpc_name }}}]
--query Vpc.VpcId --output text
register: vpc_created
when: vpc_existing.stdout | trim in ['', 'None']
changed_when: true
- name: vpc — set VPC id fact
ansible.builtin.set_fact:
dev_vpc_id: >-
{{ vpc_created.stdout | default('') | trim
if (vpc_existing.stdout | trim in ['', 'None'])
else vpc_existing.stdout | trim }}
- name: vpc — assert VPC id resolved
ansible.builtin.assert:
that: dev_vpc_id | length > 0
fail_msg: "VPC id could not be resolved after describe/create"
- name: vpc — find existing IGW attached to this VPC
ansible.builtin.command: >-
{{ spinifex_aws_cli }} ec2 describe-internet-gateways
--filters Name=attachment.vpc-id,Values={{ dev_vpc_id }}
--query InternetGateways[0].InternetGatewayId --output text
register: vpc_igw_existing
changed_when: false
- name: vpc — create IGW if missing
ansible.builtin.command: >-
{{ spinifex_aws_cli }} ec2 create-internet-gateway
--tag-specifications
ResourceType=internet-gateway,Tags=[{Key=Name,Value={{ dev_vpc_name }}-igw}]
--query InternetGateway.InternetGatewayId --output text
register: vpc_igw_created
when: vpc_igw_existing.stdout | trim in ['', 'None']
changed_when: true
- name: vpc — set IGW id fact
ansible.builtin.set_fact:
dev_vpc_igw_id: >-
{{ vpc_igw_created.stdout | default('') | trim
if (vpc_igw_existing.stdout | trim in ['', 'None'])
else vpc_igw_existing.stdout | trim }}
- name: vpc — attach IGW to VPC if needed
ansible.builtin.command: >-
{{ spinifex_aws_cli }} ec2 attach-internet-gateway
--internet-gateway-id {{ dev_vpc_igw_id }}
--vpc-id {{ dev_vpc_id }}
register: vpc_igw_attach
when: vpc_igw_existing.stdout | trim in ['', 'None']
changed_when: true
failed_when:
- vpc_igw_attach.rc | default(0) != 0
- "'Resource.AlreadyAssociated' not in (vpc_igw_attach.stderr | default(''))"
- name: vpc — find existing subnet by Name tag
ansible.builtin.command: >-
{{ spinifex_aws_cli }} ec2 describe-subnets
--filters Name=vpc-id,Values={{ dev_vpc_id }}
Name=tag:Name,Values={{ dev_vpc_name }}-public
--query Subnets[0].SubnetId --output text
register: vpc_subnet_existing
changed_when: false
- name: vpc — create subnet if missing
ansible.builtin.command: >-
{{ spinifex_aws_cli }} ec2 create-subnet
--vpc-id {{ dev_vpc_id }}
--cidr-block {{ dev_vpc_subnet_cidr }}
--availability-zone {{ dev_vpc_subnet_az }}
--tag-specifications
ResourceType=subnet,Tags=[{Key=Name,Value={{ dev_vpc_name }}-public}]
--query Subnet.SubnetId --output text
register: vpc_subnet_created
when: vpc_subnet_existing.stdout | trim in ['', 'None']
changed_when: true
- name: vpc — set subnet id fact
ansible.builtin.set_fact:
dev_vpc_subnet_id: >-
{{ vpc_subnet_created.stdout | default('') | trim
if (vpc_subnet_existing.stdout | trim in ['', 'None'])
else vpc_subnet_existing.stdout | trim }}
- name: vpc — ensure subnet has map-public-ip-on-launch
ansible.builtin.command: >-
{{ spinifex_aws_cli }} ec2 modify-subnet-attribute
--subnet-id {{ dev_vpc_subnet_id }}
--map-public-ip-on-launch
register: vpc_subnet_mapip
changed_when: vpc_subnet_existing.stdout | trim in ['', 'None']
failed_when: false
# Try tag-based lookup first. If the gateway didn't tag a previously
# created RT (spinifex gateway silently drops --tag-specifications on
# create-route-table AND no-ops create-tags for route tables — see
# mulga-siv-94; works correctly for subnets), fall back to "RT
# associated with our subnet" which doesn't need tags.
- name: vpc — find existing route table by Name tag
ansible.builtin.command: >-
{{ spinifex_aws_cli }} ec2 describe-route-tables
--filters Name=vpc-id,Values={{ dev_vpc_id }}
Name=tag:Name,Values={{ dev_vpc_name }}-public
--query RouteTables[0].RouteTableId --output text
register: vpc_rt_existing
changed_when: false
- name: vpc — fallback lookup by subnet association
ansible.builtin.command: >-
{{ spinifex_aws_cli }} ec2 describe-route-tables
--filters Name=association.subnet-id,Values={{ dev_vpc_subnet_id }}
--query RouteTables[0].RouteTableId --output text
register: vpc_rt_byassoc
changed_when: false
when: vpc_rt_existing.stdout | trim in ['', 'None']
- name: vpc — create route table if neither lookup found one
ansible.builtin.command: >-
{{ spinifex_aws_cli }} ec2 create-route-table
--vpc-id {{ dev_vpc_id }}
--query RouteTable.RouteTableId --output text
register: vpc_rt_created
when:
- vpc_rt_existing.stdout | trim in ['', 'None']
- (vpc_rt_byassoc.stdout | default('') | trim) in ['', 'None']
changed_when: true
- name: vpc — set route-table id fact
ansible.builtin.set_fact:
dev_vpc_rt_id: >-
{%- if (vpc_rt_existing.stdout | trim) not in ['', 'None'] -%}
{{ vpc_rt_existing.stdout | trim }}
{%- elif (vpc_rt_byassoc.stdout | default('') | trim) not in ['', 'None'] -%}
{{ vpc_rt_byassoc.stdout | default('') | trim }}
{%- else -%}
{{ vpc_rt_created.stdout | default('') | trim }}
{%- endif -%}
- name: vpc — assert route-table id resolved
ansible.builtin.assert:
that: dev_vpc_rt_id | length > 0
fail_msg: "Route table id could not be resolved after describe/create"
# Spinifex gateway dropped --tag-specifications on the create call;
# apply via create-tags so re-runs can find the RT via tag filter.
- name: vpc — tag route table (workaround for create-tag-specifications gap)
ansible.builtin.command: >-
{{ spinifex_aws_cli }} ec2 create-tags
--resources {{ dev_vpc_rt_id }}
--tags Key=Name,Value={{ dev_vpc_name }}-public
changed_when: false
failed_when: false
- name: vpc — add default route via IGW (ignore RouteAlreadyExists)
ansible.builtin.command: >-
{{ spinifex_aws_cli }} ec2 create-route
--route-table-id {{ dev_vpc_rt_id }}
--destination-cidr-block 0.0.0.0/0
--gateway-id {{ dev_vpc_igw_id }}
register: vpc_route_add
changed_when:
- vpc_route_add.rc == 0
- "'RouteAlreadyExists' not in (vpc_route_add.stderr | default(''))"
failed_when:
- vpc_route_add.rc != 0
- "'RouteAlreadyExists' not in (vpc_route_add.stderr | default(''))"
- name: vpc — associate subnet with route table (ignore already-associated)
ansible.builtin.command: >-
{{ spinifex_aws_cli }} ec2 associate-route-table
--route-table-id {{ dev_vpc_rt_id }}
--subnet-id {{ dev_vpc_subnet_id }}
register: vpc_assoc
changed_when:
- vpc_assoc.rc == 0
- "'Resource.AlreadyAssociated' not in (vpc_assoc.stderr | default(''))"
failed_when:
- vpc_assoc.rc != 0
- "'Resource.AlreadyAssociated' not in (vpc_assoc.stderr | default(''))"
- name: vpc — summary (present)
ansible.builtin.debug:
msg: |
Dev VPC ready:
VPC : {{ dev_vpc_id }} ({{ dev_vpc_cidr }})
IGW : {{ dev_vpc_igw_id }}
Subnet : {{ dev_vpc_subnet_id }} ({{ dev_vpc_subnet_cidr }} @ {{ dev_vpc_subnet_az }}, public)
Route table : {{ dev_vpc_rt_id }}
Smoketest now finds this subnet via its existing
Name=map-public-ip-on-launch,Values=true filter.
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env bash
# Run `aws` CLI inside a shell that has the `spinifex` supplementary group
# active, with AWS_PROFILE=spinifex preset. Works around mulga-siv-92:
# ansible's `become` is a no-op when the target user equals the calling
# user, so `sudo -u` is never invoked and supplementary groups are not
# re-initialised. Without the `spinifex` group, /etc/spinifex/ca.pem (the
# AWS endpoint CA bundle) is unreadable and aws fails with SSL Errno 13.
#
# Usage from ansible tasks:
# ansible.builtin.command:
# cmd: "{{ playbook_dir }}/../scripts/aws-as-spinifex.sh ec2 describe-regions"
#
# All args are forwarded to `aws`. Quoting is preserved via printf %q so
# things like `--query 'length(Images)'` reach aws intact.
set -euo pipefail
if [[ $# -eq 0 ]]; then
echo "usage: $0 <aws-cli-args>" >&2
exit 64
fi
cmd="AWS_PROFILE=spinifex aws"
for arg in "$@"; do
cmd+=" $(printf '%q' "$arg")"
done
exec sg spinifex -c "$cmd"
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env bash
# Run `spx` inside a shell that has the `spinifex` supplementary group
# active. Works around mulga-siv-92 (see aws-as-spinifex.sh for details).
# All args are forwarded to /usr/local/bin/spx via printf %q so flag
# values with spaces or shell metachars survive intact.
set -euo pipefail
if [[ $# -eq 0 ]]; then
echo "usage: $0 <spx-args>" >&2
exit 64
fi
cmd="AWS_PROFILE=spinifex /usr/local/bin/spx"
for arg in "$@"; do
cmd+=" $(printf '%q' "$arg")"
done
exec sg spinifex -c "$cmd"
+52 -1
View File
@@ -23,12 +23,20 @@ spinifex_node_name: node1
spinifex_node_count: 1
# External network.
# spx admin init contract (as of network-layer-redesign):
# external_mode : "pool" or "" (NAT path removed)
# external_pool : MANDATORY when mode=pool — set start/end below or pass -e.
# external_gw : auto-detected from default route when blank.
# external_prefix: auto-detected from WAN when blank.
# external_gw_ip : OVN router external-IP override (was nat_gateway_ip).
# no_external : true → emit --no-external (skip external networking).
spinifex_external_mode: pool
spinifex_external_no_external: false
spinifex_external_pool_start: ""
spinifex_external_pool_end: ""
spinifex_external_gateway: ""
spinifex_external_prefix_len: ""
spinifex_nat_gateway_ip: ""
spinifex_external_gateway_ip: ""
# Preflight checks.
spinifex_ports:
@@ -76,11 +84,54 @@ teardown_process_wait_seconds: 30
spinifex_root: "{{ lookup('pipe', 'readlink -f ' ~ playbook_dir ~ '/../../..') }}"
mulga_root: "{{ lookup('pipe', 'readlink -f ' ~ playbook_dir ~ '/../../../..') }}"
# Wrappers around `aws` and `spx` that re-enter a shell with the
# `spinifex` supplementary group active. Needed because ansible's
# `become_user` is a no-op when target user equals current user, so
# supplementary groups are never re-initialised and /etc/spinifex/ca.pem
# (drwxr-x--- root:spinifex) is unreadable.
spinifex_aws_cli: "{{ playbook_dir }}/../scripts/aws-as-spinifex.sh"
spinifex_spx_cli: "{{ playbook_dir }}/../scripts/spx-as-spinifex.sh"
# dev-vpc fixture. Deterministic VPC + public subnet + IGW + default
# route. Idempotent: re-running detects existing resources by Name tag.
# Used by smoketest's launch path (which looks for any
# map-public-ip-on-launch=true subnet).
dev_vpc_name: spinifex-dev-vpc
dev_vpc_cidr: 10.99.0.0/16
dev_vpc_subnet_cidr: 10.99.1.0/24
dev_vpc_subnet_az: "{{ spinifex_az }}"
dev_vpc_state: present # present | absent
# Build output.
build_tarball_path: /tmp/spinifex-local.tar.gz
# Snapshot / restore. Snapshots quiesce the full spinifex+ovn+ovs stack,
# tar these paths, then restart services. Restore wipes + untars + restarts.
# Default snapshot dir is root-owned so it survives `dev-teardown`
# (teardown only removes the four spinifex_*_dir paths above).
snapshot_dir: /var/lib/spinifex-snapshots
snapshot_paths:
- "{{ spinifex_etc_dir }}"
- "{{ spinifex_data_dir }}"
- "{{ ovn_db_dir }}"
- /etc/openvswitch/conf.db
# Order matters: stop spinifex first (depends on OVN/OVS), then OVN, then OVS.
# Restart in reverse.
snapshot_services_stop_order:
- spinifex.target
- ovn-controller
- ovn-central
- openvswitch-switch
# Smoketest image — parameterised so tofu examples can share the same base.
spinifex_smoketest_distro: debian
spinifex_smoketest_version: "13"
# Local image file path (if present, imported directly; otherwise fall back to --name).
spinifex_smoketest_local_image: "~/images/{{ spinifex_smoketest_distro }}-{{ spinifex_smoketest_version }}.img"
# Multi-node cluster bootstrap (cluster-bootstrap.yml).
# cluster_env, cluster_init_host, spinifex_node_count come from inventory/tofu.py.
cluster_external_pool: "" # "<start>-<end>" or empty for DHCP fallback
cluster_join_token_path: /etc/spinifex/join-token
cluster_formation_port: 4432
cluster_formation_timeout: 60 # seconds (uri retries = value // 2)
+69 -36
View File
@@ -134,7 +134,7 @@ if ! command -v qemu-img &>/dev/null; then
exit 1
fi
if [[ "$DISTRO" == "ubuntu" ]] && ! command -v parted &>/dev/null; then
if [[ "$DISTRO" == "ubuntu" ]] && ! command -v parted &>/dev/null && ! [[ -x /usr/sbin/parted ]]; then
echo "ERROR: parted not found. Install parted."
exit 1
fi
@@ -326,7 +326,65 @@ apt-get install -y --no-install-recommends ${APT_PACKAGES}
"
fi
# Enable services
# Step 5: Copy binaries into the image (before setup script, which may reference them)
if [[ -n "${INSTALL_BINARIES:-}" ]]; then
echo "Installing binaries..."
IFS=' ' read -ra BINARY_PAIRS <<< "$INSTALL_BINARIES"
for pair in "${BINARY_PAIRS[@]}"; do
src="${pair%%:*}"
dst="${pair#*:}"
src_path="${PROJECT_DIR}/${src}"
echo " ${src} -> ${dst}"
sudo cp "$src_path" "${MOUNT_DIR}${dst}"
sudo chmod 755 "${MOUNT_DIR}${dst}"
done
fi
# Copy auxiliary files (systemd units, OpenRC initd scripts, cron entries, configs).
# Same src:dst pair grammar as INSTALL_BINARIES; mode 0644, parent dirs auto-created.
# Optional — manifests without INSTALL_FILES (e.g. existing GPU images) skip this step.
if [[ -n "${INSTALL_FILES:-}" ]]; then
echo "Installing files..."
IFS=' ' read -ra FILE_PAIRS <<< "$INSTALL_FILES"
for pair in "${FILE_PAIRS[@]}"; do
src="${pair%%:*}"
dst="${pair#*:}"
src_path="${PROJECT_DIR}/${src}"
if [[ ! -f "$src_path" ]]; then
echo "ERROR: INSTALL_FILES source not found: $src_path"
exit 1
fi
echo " ${src} -> ${dst}"
sudo mkdir -p "${MOUNT_DIR}$(dirname "$dst")"
sudo cp "$src_path" "${MOUNT_DIR}${dst}"
sudo chmod 0644 "${MOUNT_DIR}${dst}"
done
fi
# Run custom setup script inside chroot (after binaries are installed)
if [[ -n "${SETUP_SCRIPT:-}" ]]; then
setup_path="${PROJECT_DIR}/${SETUP_SCRIPT}"
if [[ ! -f "$setup_path" ]]; then
echo "ERROR: Setup script not found: $setup_path"
exit 1
fi
echo "Running setup script: ${SETUP_SCRIPT}..."
sudo cp "$setup_path" "${MOUNT_DIR}/tmp/setup.sh"
sudo chmod 755 "${MOUNT_DIR}/tmp/setup.sh"
if [[ "$DISTRO" == "ubuntu" ]]; then
if [[ ! -x "${MOUNT_DIR}/bin/bash" ]]; then
echo "ERROR: /bin/bash not found or not executable in chroot"
exit 1
fi
sudo chroot "$MOUNT_DIR" /bin/bash -x /tmp/setup.sh
else
sudo chroot "$MOUNT_DIR" /tmp/setup.sh
fi
sudo rm -f "${MOUNT_DIR}/tmp/setup.sh"
fi
# Enable services (runs after INSTALL_FILES + SETUP_SCRIPT so init scripts the
# manifest references are already on disk).
if [[ -n "${ENABLE_SERVICES:-}" ]]; then
echo "Enabling services: ${ENABLE_SERVICES}..."
IFS=' ' read -ra SERVICES <<< "$ENABLE_SERVICES"
@@ -345,38 +403,6 @@ if [[ -n "${ENABLE_SERVICES:-}" ]]; then
done
fi
# Step 5: Copy binaries into the image (before setup script, which may reference them)
if [[ -n "${INSTALL_BINARIES:-}" ]]; then
echo "Installing binaries..."
IFS=' ' read -ra BINARY_PAIRS <<< "$INSTALL_BINARIES"
for pair in "${BINARY_PAIRS[@]}"; do
src="${pair%%:*}"
dst="${pair#*:}"
src_path="${PROJECT_DIR}/${src}"
echo " ${src} -> ${dst}"
sudo cp "$src_path" "${MOUNT_DIR}${dst}"
sudo chmod 755 "${MOUNT_DIR}${dst}"
done
fi
# Run custom setup script inside chroot (after binaries are installed)
if [[ -n "${SETUP_SCRIPT:-}" ]]; then
setup_path="${PROJECT_DIR}/${SETUP_SCRIPT}"
if [[ ! -f "$setup_path" ]]; then
echo "ERROR: Setup script not found: $setup_path"
exit 1
fi
echo "Running setup script: ${SETUP_SCRIPT}..."
sudo cp "$setup_path" "${MOUNT_DIR}/tmp/setup.sh"
sudo chmod 755 "${MOUNT_DIR}/tmp/setup.sh"
if [[ "$DISTRO" == "ubuntu" ]]; then
sudo chroot "$MOUNT_DIR" /bin/bash /tmp/setup.sh
else
sudo chroot "$MOUNT_DIR" /tmp/setup.sh
fi
sudo rm -f "${MOUNT_DIR}/tmp/setup.sh"
fi
# Step 6: Clean up and unmount
echo "Cleaning up image..."
if [[ "$DISTRO" == "alpine" ]]; then
@@ -426,6 +452,9 @@ if [[ "$DO_IMPORT" == true ]]; then
echo "Importing as AMI..."
rm -f "$OUTPUT_IMAGE"
IMPORT_ARGS=(--file "$OUTPUT_RAW" --distro "${DISTRO}" --version "${DISTRO_VERSION}" --arch x86_64 --boot-mode "${BOOT_MODE}")
if [[ -n "${AMI_NAME:-}" ]]; then
IMPORT_ARGS+=(--name "$AMI_NAME")
fi
if [[ -n "${SYSTEM_TAG:-}" ]]; then
IMPORT_ARGS+=(--tag "$SYSTEM_TAG")
fi
@@ -434,10 +463,14 @@ else
echo "To import as AMI, run:"
echo " spx admin images import \\"
echo " --file $OUTPUT_RAW \\"
NAME_HINT=""
if [[ -n "${AMI_NAME:-}" ]]; then
NAME_HINT=" \\\n --name ${AMI_NAME}"
fi
if [[ -n "${SYSTEM_TAG:-}" ]]; then
echo " --distro ${DISTRO} --version ${DISTRO_VERSION} --arch x86_64 --boot-mode ${BOOT_MODE} \\"
echo -e " --distro ${DISTRO} --version ${DISTRO_VERSION} --arch x86_64 --boot-mode ${BOOT_MODE}${NAME_HINT} \\"
echo " --tag ${SYSTEM_TAG}"
else
echo " --distro ${DISTRO} --version ${DISTRO_VERSION} --arch x86_64 --boot-mode ${BOOT_MODE}"
echo -e " --distro ${DISTRO} --version ${DISTRO_VERSION} --arch x86_64 --boot-mode ${BOOT_MODE}${NAME_HINT}"
fi
fi
+1 -1
View File
@@ -5,7 +5,7 @@ set -euo pipefail
# Usage: scripts/check-coverage.sh <coverprofile> [quiet]
PROFILE="${1:?Usage: check-coverage.sh <coverprofile> [quiet]}"
MIN=70.0
MIN=68.0
QUIET="${2:-}"
if [[ ! -s "$PROFILE" ]]; then
-122
View File
@@ -1,122 +0,0 @@
#!/bin/bash
# Simulated multi-node cluster on a single machine using loopback addresses.
# Uses ~/node{1,2,3}/ for separate data directories.
# Usage: ./scripts/create-multi-node.sh
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
cd "$PROJECT_ROOT"
echo "Multi-node simulation environment"
echo "================================="
# Build first
make build
echo ""
echo "Removing old node directories..."
rm -rf ~/node1/ ~/node2/ ~/node3/
echo ""
echo "Step 1: Cluster Formation"
echo "========================="
echo "Starting init node (127.0.0.1) and joining nodes (127.0.0.2, 127.0.0.3)..."
# Init blocks until all nodes join, so run in background
./bin/spx admin init \
--node node1 \
--nodes 3 \
--bind 127.0.0.1 \
--cluster-bind 127.0.0.1 \
--port 4432 \
--spinifex-dir ~/node1/ \
--config-dir ~/node1/config/ \
--region ap-southeast-2 \
--az ap-southeast-2a &
INIT_PID=$!
# Wait for formation server to start
echo "Waiting for formation server..."
for i in $(seq 1 30); do
if curl -sk "https://127.0.0.1:4432/formation/health" > /dev/null 2>&1; then
echo "Formation server ready"
break
fi
if [ "$i" -eq 30 ]; then
echo "ERROR: Formation server failed to start"
kill $INIT_PID 2>/dev/null || true
exit 1
fi
sleep 1
done
# Read join token
JOIN_TOKEN=$(cat ~/node1/config/join-token)
if [ -z "$JOIN_TOKEN" ]; then
echo "ERROR: Join token file is empty"
exit 1
fi
# Join nodes concurrently
./bin/spx admin join \
--node node2 \
--bind 127.0.0.2 \
--cluster-bind 127.0.0.2 \
--host 127.0.0.1:4432 \
--token "$JOIN_TOKEN" \
--data-dir ~/node2/ \
--config-dir ~/node2/config/ \
--region ap-southeast-2 \
--az ap-southeast-2a &
JOIN2_PID=$!
./bin/spx admin join \
--node node3 \
--bind 127.0.0.3 \
--cluster-bind 127.0.0.3 \
--host 127.0.0.1:4432 \
--token "$JOIN_TOKEN" \
--data-dir ~/node3/ \
--config-dir ~/node3/config/ \
--region ap-southeast-2 \
--az ap-southeast-2a &
JOIN3_PID=$!
# Wait for all formation processes to complete
wait $INIT_PID $JOIN2_PID $JOIN3_PID
echo ""
echo "Cluster formation complete!"
echo ""
echo "Step 2: Starting Services"
echo "========================="
# Start node 1 first (Predastore needs quorum)
UI=false ./scripts/start-dev.sh ~/node1/ &
sleep 5
# Start nodes 2 and 3
UI=false ./scripts/start-dev.sh ~/node2/ &
sleep 2
UI=false ./scripts/start-dev.sh ~/node3/ &
# Wait for all start scripts to finish
wait
echo ""
echo "Step 3: Verification"
echo "===================="
sleep 5
echo "Daemon health:"
for IP in 127.0.0.1 127.0.0.2 127.0.0.3; do
echo " $IP: $(curl -sk https://$IP:4432/health | head -c 100)"
done
echo ""
echo "Multi-node cluster is running!"
echo " Stop with: ./scripts/stop-dev.sh"
echo " Logs: ~/node{1,2,3}/logs/"
+1 -1
View File
@@ -7,7 +7,7 @@ Builds a Proxmox VM template with all Spinifex dependencies pre-installed. Cloni
| Component | Details |
|---|---|
| System packages | QEMU, nbdkit, libvirt, gcc, jq, curl, etc. (`make install-system`) |
| Go | 1.26.3 (`make install-go`) |
| Go | 1.26.4 (`make install-go`) |
| AWS CLI | v2 (`make install-aws`) |
| Repositories | spinifex, viperblock, predastore (with `go.work` configured) |
| Module cache | `go mod download` completed for all repos |
+38
View File
@@ -0,0 +1,38 @@
#!/sbin/openrc-run
# K3s agent (worker node). Env vars K3S_URL + K3S_TOKEN come from cloud-init
# user-data — the orchestrator embeds them at RunInstances time per the
# Nodegroup bootstrap flow (eks-v1.md Q14).
#
# K3S_URL points at the cluster's NLB endpoint (https://{cluster}.{account}
# .eks.{region}.{suffix}:443) so the agent join path stays stable across the
# v2 HA migration (single-VM v1 → 3-node etcd quorum v2). No AMI rebuild
# needed when the control plane expands.
description="K3s Kubernetes agent (Mulga EKS worker)"
command="/usr/local/bin/k3s"
command_args="agent"
command_background="yes"
pidfile="/run/k3s-agent.pid"
output_log="/var/log/k3s-agent.log"
error_log="/var/log/k3s-agent.log"
start_stop_daemon_args="--wait 5000"
depend() {
need net cgroups
after firewall
}
start_pre() {
# Cloud-init drops these into /etc/spinifex-eks/agent.env. We source them
# here so OpenRC injects them into k3s' environment.
if [ -f /etc/spinifex-eks/agent.env ]; then
# shellcheck disable=SC1091
. /etc/spinifex-eks/agent.env
export K3S_URL K3S_TOKEN K3S_NODE_NAME K3S_NODE_LABEL
fi
if [ -z "${K3S_URL:-}" ] || [ -z "${K3S_TOKEN:-}" ]; then
eerror "K3S_URL and K3S_TOKEN must be set in /etc/spinifex-eks/agent.env"
return 1
fi
}
+25
View File
@@ -0,0 +1,25 @@
IMAGE_NAME="eks-agent"
IMAGE_DESCRIPTION="EKS K3s agent VM — Alpine + K3s v1.32.5+k3s1 (worker node)"
DISTRO="alpine"
ALPINE_VERSION="3.21.7"
# 2G for the worker — kubelet + containerd + node images. Customer workload
# storage attaches via separate EBS volumes (managed by viperblock); this is
# the OS root only.
IMAGE_SIZE="2G"
APK_PACKAGES="ca-certificates curl iptables ip6tables conntrack-tools cgroup-tools openrc"
# K3s binary downloaded by setup.sh (same pinned version + SHA path as
# eks-server) to keep server / agent in lock-step.
INSTALL_FILES="\
scripts/images/eks-agent/k3s-agent.initd:/etc/init.d/k3s-agent \
scripts/images/eks-agent/mulga-eks-state-report.sh:/etc/periodic/15min/mulga-eks-state-report"
ENABLE_SERVICES="crond k3s-agent"
SETUP_SCRIPT="scripts/images/eks-agent/setup.sh"
AMI_NAME="spinifex-eks-agent"
SYSTEM_TAG="spinifex:managed-by=eks"
@@ -0,0 +1,38 @@
#!/bin/sh
set -eu
# mulga-eks-state-report (agent variant) — periodic NATS publish of agent
# liveness signal. Subject: eks.state.{accountID}.{clusterName}.agent.{nodeName}
# Payload: JSON { kubelet: "ok"|"fail", ts: <unix> }
#
# The agent has no API server to query for its own status; we shell-check the
# local kubelet socket via crictl, and fall back to a process check.
ENVFILE=/etc/spinifex-eks/state-report.env
[ -f "${ENVFILE}" ] || { logger -t mulga-eks-state-report "${ENVFILE} missing"; exit 0; }
# shellcheck disable=SC1090
. "${ENVFILE}"
: "${SPINIFEX_NATS_URL:?}"
: "${EKS_ACCOUNT_ID:?}"
: "${EKS_CLUSTER_NAME:?}"
: "${EKS_NODE_NAME:?}"
if pgrep -f 'k3s agent' >/dev/null 2>&1; then
KUBELET=ok
else
KUBELET=fail
fi
TS=$(date +%s)
PAYLOAD=$(printf '{"kubelet":"%s","ts":%s}' "${KUBELET}" "${TS}")
SUBJ="eks.state.${EKS_ACCOUNT_ID}.${EKS_CLUSTER_NAME}.agent.${EKS_NODE_NAME}"
NATS_ARGS="-s ${SPINIFEX_NATS_URL}"
if [ -n "${SPINIFEX_NATS_CREDS_FILE:-}" ] && [ -f "${SPINIFEX_NATS_CREDS_FILE}" ]; then
NATS_ARGS="${NATS_ARGS} --creds ${SPINIFEX_NATS_CREDS_FILE}"
fi
# shellcheck disable=SC2086
printf '%s' "${PAYLOAD}" | nats ${NATS_ARGS} pub "${SUBJ}" --stdin 2>&1 \
| logger -t mulga-eks-state-report
+47
View File
@@ -0,0 +1,47 @@
#!/bin/sh
set -eu
# setup.sh — chroot customisation for the eks-agent AMI.
# Pulls the same K3s release as eks-server (lock-step versioning per Q12) and
# wires the k3s-agent OpenRC service.
K3S_VERSION="v1.32.5+k3s1"
K3S_URL_BASE="https://github.com/k3s-io/k3s/releases/download/${K3S_VERSION}"
echo "[eks-agent-setup] fetching k3s checksums ${K3S_VERSION}"
curl -fsSL -o /tmp/k3s-checksums.txt "${K3S_URL_BASE}/sha256sum-amd64.txt"
K3S_SHA256=$(awk '/[ \t]k3s$/{print $1; exit}' /tmp/k3s-checksums.txt)
if [ -z "${K3S_SHA256}" ]; then
echo "[eks-agent-setup] could not parse k3s sha256 from upstream checksums"
cat /tmp/k3s-checksums.txt
exit 1
fi
echo "[eks-agent-setup] downloading k3s ${K3S_VERSION} (sha256=${K3S_SHA256})"
curl -fsSL -o /usr/local/bin/k3s "${K3S_URL_BASE}/k3s"
echo "${K3S_SHA256} /usr/local/bin/k3s" > /tmp/k3s.sha256
if ! sha256sum -c /tmp/k3s.sha256; then
echo "[eks-agent-setup] k3s SHA256 mismatch — refusing to bake AMI"
exit 1
fi
chmod 0755 /usr/local/bin/k3s
ln -sf /usr/local/bin/k3s /usr/local/bin/kubectl
ln -sf /usr/local/bin/k3s /usr/local/bin/crictl
ln -sf /usr/local/bin/k3s /usr/local/bin/ctr
# nats-cli — see eks-server setup.sh for rationale.
NATS_CLI_VERSION="0.4.0"
NATS_CLI_URL="https://github.com/nats-io/natscli/releases/download/v${NATS_CLI_VERSION}/nats-${NATS_CLI_VERSION}-linux-amd64.zip"
echo "[eks-agent-setup] downloading nats-cli ${NATS_CLI_VERSION}"
curl -fsSL -o /tmp/nats-cli.zip "${NATS_CLI_URL}"
apk add --no-cache unzip
unzip -q -d /tmp/nats-cli /tmp/nats-cli.zip
install -m 0755 /tmp/nats-cli/nats-${NATS_CLI_VERSION}-linux-amd64/nats /usr/local/bin/nats
rm -rf /tmp/nats-cli /tmp/nats-cli.zip
apk del --no-cache unzip
chmod 0755 /etc/init.d/k3s-agent
chmod 0755 /etc/periodic/15min/mulga-eks-state-report
mkdir -p /etc/spinifex-eks
echo "[eks-agent-setup] done"
@@ -0,0 +1,17 @@
#!/sbin/openrc-run
# eks-token-webhook — K8s TokenReview authenticator for IAM-backed kubectl.
# Binds 127.0.0.1:8443 only; kube-apiserver is the sole client (loopback path).
# Stub binary returns 503 on every request until cs-eks-6c lands.
description="EKS Token Review webhook (Mulga)"
command="/usr/local/bin/eks-token-webhook"
command_args="--addr=127.0.0.1:8443"
command_background="yes"
pidfile="/run/eks-token-webhook.pid"
output_log="/var/log/eks-token-webhook.log"
error_log="/var/log/eks-token-webhook.log"
depend() {
need net
before k3s
}
@@ -0,0 +1,23 @@
#!/sbin/openrc-run
# k3s-first-boot — one-shot OpenRC service that runs the first-boot publisher
# after K3s is up. Self-disables by removing itself from the default runlevel
# once the sentinel file is gone.
description="K3s first-boot publisher (node-token + admin kubeconfig)"
command="/usr/local/sbin/k3s-first-boot"
command_background="no"
pidfile="/run/k3s-first-boot.pid"
output_log="/var/log/k3s-first-boot.log"
error_log="/var/log/k3s-first-boot.log"
depend() {
need k3s
after k3s
}
start_pre() {
if [ ! -f /var/lib/spinifex-eks/first-boot.pending ]; then
einfo "first-boot already complete; nothing to do"
return 0
fi
}
@@ -0,0 +1,91 @@
#!/bin/sh
set -eu
# k3s-first-boot — runs once after the K3s server reaches a healthy state.
# Reads the bootstrap node-token and admin kubeconfig that K3s writes during
# `--cluster-init`, rewrites the kubeconfig server address to the cluster's
# NLB endpoint (so workers and external kubectl can use it), and publishes
# both as one-shot NATS messages for the spinifex cluster reconciler to
# consume into KV.
#
# Required env (from cloud-init user-data /etc/spinifex-eks/first-boot.env):
# SPINIFEX_NATS_URL nats://...
# SPINIFEX_NATS_CREDS_FILE /etc/spinifex-eks/nats.creds
# EKS_ACCOUNT_ID
# EKS_CLUSTER_NAME
# EKS_NLB_ENDPOINT https://{cluster}.{accountID}.eks.{region}.{suffix}
#
# Idempotent: a sentinel file at /var/lib/spinifex-eks/first-boot.pending gates
# execution. On success the sentinel is removed and the OpenRC service is
# pulled from the default runlevel so it does not retry on subsequent boots.
SENTINEL=/var/lib/spinifex-eks/first-boot.pending
ENVFILE=/etc/spinifex-eks/first-boot.env
LOGTAG="k3s-first-boot"
log() { echo "[${LOGTAG}] $*"; }
die() { log "ERROR: $*"; exit 1; }
if [ ! -f "${SENTINEL}" ]; then
log "sentinel missing — first boot already complete"
exit 0
fi
if [ ! -f "${ENVFILE}" ]; then
die "${ENVFILE} not found — cloud-init did not seed first-boot env"
fi
# shellcheck disable=SC1090
. "${ENVFILE}"
for v in SPINIFEX_NATS_URL EKS_ACCOUNT_ID EKS_CLUSTER_NAME EKS_NLB_ENDPOINT; do
eval "val=\${$v:-}"
[ -n "${val}" ] || die "env ${v} not set"
done
# 1. Wait for K3s /healthz. K3s self-signs initially; --insecure-skip is fine
# here, we are on loopback.
log "waiting for K3s /healthz on 127.0.0.1:6443"
i=0
while [ "${i}" -lt 300 ]; do
if curl -sk --max-time 2 https://127.0.0.1:6443/healthz | grep -q '^ok$'; then
log "K3s healthz ok after ${i}s"
break
fi
i=$((i + 2))
sleep 2
done
[ "${i}" -lt 300 ] || die "K3s did not become healthy within 5 minutes"
# 2. Read node-token + admin kubeconfig.
TOKEN_FILE=/var/lib/rancher/k3s/server/node-token
KUBECONFIG_FILE=/etc/rancher/k3s/k3s.yaml
[ -r "${TOKEN_FILE}" ] || die "${TOKEN_FILE} unreadable"
[ -r "${KUBECONFIG_FILE}" ] || die "${KUBECONFIG_FILE} unreadable"
NODE_TOKEN=$(cat "${TOKEN_FILE}")
# K3s ships kubeconfig with server: https://127.0.0.1:6443 — rewrite to the
# NLB endpoint so it works from outside the control plane VM.
KUBECONFIG_REWRITTEN=$(sed "s|server: https://127\.0\.0\.1:6443|server: ${EKS_NLB_ENDPOINT}|" "${KUBECONFIG_FILE}")
# 3. Publish one-shot NATS messages. Subjects per eks-v1.md Q14 + Q15.
TOKEN_SUBJ="eks.bus.${EKS_ACCOUNT_ID}.${EKS_CLUSTER_NAME}.k3s-bootstrap-token"
KUBE_SUBJ="eks.bus.${EKS_ACCOUNT_ID}.${EKS_CLUSTER_NAME}.k3s-admin-kubeconfig"
NATS_ARGS="-s ${SPINIFEX_NATS_URL}"
if [ -n "${SPINIFEX_NATS_CREDS_FILE:-}" ] && [ -f "${SPINIFEX_NATS_CREDS_FILE}" ]; then
NATS_ARGS="${NATS_ARGS} --creds ${SPINIFEX_NATS_CREDS_FILE}"
fi
log "publishing node-token to ${TOKEN_SUBJ}"
# shellcheck disable=SC2086
printf '%s' "${NODE_TOKEN}" | nats ${NATS_ARGS} pub "${TOKEN_SUBJ}" --stdin
log "publishing admin kubeconfig to ${KUBE_SUBJ}"
# shellcheck disable=SC2086
printf '%s' "${KUBECONFIG_REWRITTEN}" | nats ${NATS_ARGS} pub "${KUBE_SUBJ}" --stdin
# 4. Self-disable. Remove sentinel, pull from runlevel.
rm -f "${SENTINEL}"
rc-update del k3s-first-boot default 2>/dev/null || true
log "first boot complete"
+36
View File
@@ -0,0 +1,36 @@
#!/sbin/openrc-run
# K3s server control plane. v1 = single-node embedded etcd (eks-v1.md Q6).
# --cluster-init bootstraps a new etcd quorum on first start; subsequent
# starts pick up the existing data dir at /var/lib/rancher/k3s. v2 HA grows
# the cluster by launching additional eks-server VMs with --server pointing
# at the existing NLB endpoint (Q17) — no AMI change required.
description="K3s Kubernetes server (Mulga EKS control plane)"
command="/usr/local/bin/k3s"
command_args="server --config /etc/rancher/k3s/config.yaml"
command_background="yes"
pidfile="/run/k3s.pid"
output_log="/var/log/k3s.log"
error_log="/var/log/k3s.log"
# Generous start timeout — K3s bootstraps containerd, runs etcd cluster-init,
# pulls system images. First boot can take 2-3 minutes on cold cache.
start_stop_daemon_args="--wait 5000"
depend() {
need net cgroups
after firewall
}
start_pre() {
# config.yaml is written by k3s-first-boot from the skeleton + cloud-init
# data. Refuse to start if neither exists.
if [ ! -f /etc/rancher/k3s/config.yaml ]; then
if [ -f /etc/rancher/k3s/config.yaml.skel ]; then
cp /etc/rancher/k3s/config.yaml.skel /etc/rancher/k3s/config.yaml
else
eerror "no /etc/rancher/k3s/config.yaml and no .skel to derive from"
return 1
fi
fi
}
+34
View File
@@ -0,0 +1,34 @@
IMAGE_NAME="eks-server"
IMAGE_DESCRIPTION="EKS K3s server VM — Alpine + K3s v1.32.5+k3s1 + eks-token-webhook + state-report + etcd-snapshot"
DISTRO="alpine"
ALPINE_VERSION="3.21.7"
# 4G fits Alpine + K3s server binary (~80MB) + etcd snapshot working set.
# K3s embedded etcd lives on the data volume the orchestrator attaches at
# LaunchSystemInstance time; root disk only carries the image + logs.
IMAGE_SIZE="4G"
# Packages required at runtime (K3s pulls cgroups + iptables; nats-cli used by
# first-boot publisher + state-report; etcd-client used by snapshot script).
APK_PACKAGES="ca-certificates curl iptables ip6tables conntrack-tools cgroup-tools etcd-ctl openrc"
# k3s binary downloaded by setup.sh (network access inside chroot); SHA256
# verified at install time per spec Q12.
BUILD_COMMANDS="CGO_ENABLED=0 go build -o bin/eks-token-webhook ./cmd/eks-token-webhook"
INSTALL_BINARIES="bin/eks-token-webhook:/usr/local/bin/eks-token-webhook"
INSTALL_FILES="\
scripts/images/eks-server/k3s.initd:/etc/init.d/k3s \
scripts/images/eks-server/eks-token-webhook.initd:/etc/init.d/eks-token-webhook \
scripts/images/eks-server/k3s-first-boot.initd:/etc/init.d/k3s-first-boot \
scripts/images/eks-server/k3s-first-boot.sh:/usr/local/sbin/k3s-first-boot \
scripts/images/eks-server/mulga-eks-state-report.sh:/etc/periodic/15min/mulga-eks-state-report \
scripts/images/eks-server/mulga-eks-etcd-snapshot.sh:/etc/periodic/daily/mulga-eks-etcd-snapshot"
ENABLE_SERVICES="crond k3s eks-token-webhook k3s-first-boot"
SETUP_SCRIPT="scripts/images/eks-server/setup.sh"
AMI_NAME="spinifex-eks-server"
SYSTEM_TAG="spinifex:managed-by=eks"
@@ -0,0 +1,56 @@
#!/bin/sh
set -eu
# mulga-eks-etcd-snapshot — nightly K3s embedded etcd snapshot to predastore.
# Run from /etc/periodic/daily (Alpine crond).
# K3s has a built-in `k3s etcd-snapshot` command that handles the snapshot
# semantics; this wrapper exposes it on cron and uploads to the system-bucket
# (see eks-v1.md Q6 + Q17).
#
# Bucket layout (Q17):
# eks-backups-system/{accountID}/{clusterName}/etcd-{timestamp}.snap
# Retention: managed via S3 lifecycle policy on the bucket; this script does
# not prune.
ENVFILE=/etc/spinifex-eks/etcd-snapshot.env
[ -f "${ENVFILE}" ] || { logger -t mulga-eks-etcd-snapshot "${ENVFILE} missing"; exit 0; }
# shellcheck disable=SC1090
. "${ENVFILE}"
: "${EKS_ACCOUNT_ID:?}"
: "${EKS_CLUSTER_NAME:?}"
: "${SPINIFEX_PREDASTORE_ENDPOINT:?}"
: "${SPINIFEX_PREDASTORE_AKID:?}"
: "${SPINIFEX_PREDASTORE_SECRET:?}"
SNAPSHOT_DIR=/var/lib/rancher/k3s/server/db/snapshots
TS=$(date -u +%Y%m%dT%H%M%SZ)
NAME="etcd-${TS}"
BUCKET="eks-backups-system"
KEY="${EKS_ACCOUNT_ID}/${EKS_CLUSTER_NAME}/${NAME}.snap"
/usr/local/bin/k3s etcd-snapshot save --name "${NAME}" 2>&1 | logger -t mulga-eks-etcd-snapshot
SNAPSHOT_FILE=$(ls -t "${SNAPSHOT_DIR}/${NAME}"* 2>/dev/null | head -1 || true)
if [ -z "${SNAPSHOT_FILE}" ]; then
logger -t mulga-eks-etcd-snapshot "snapshot file for ${NAME} not found in ${SNAPSHOT_DIR}"
exit 1
fi
# Upload via curl SigV4. predastore implements the S3 v4 wire so the standard
# AWS PUT request signs cleanly. aws-cli is not in the image (kept small) — we
# hand-roll the signed request with curl --aws-sigv4.
#
# Note: --aws-sigv4 requires curl >= 7.75 (Alpine 3.21 ships 8.x).
URL="${SPINIFEX_PREDASTORE_ENDPOINT%/}/${BUCKET}/${KEY}"
curl -fsSL \
--aws-sigv4 "aws:amz:${AWS_REGION:-au-mel-1}:s3" \
--user "${SPINIFEX_PREDASTORE_AKID}:${SPINIFEX_PREDASTORE_SECRET}" \
-H "x-amz-content-sha256: UNSIGNED-PAYLOAD" \
--upload-file "${SNAPSHOT_FILE}" \
"${URL}" 2>&1 | logger -t mulga-eks-etcd-snapshot
# Remove the local snapshot to bound disk usage; the canonical copy lives in
# predastore. Restore path (`spx admin eks restore-snapshot`) pulls from there.
rm -f "${SNAPSHOT_FILE}"
logger -t mulga-eks-etcd-snapshot "uploaded ${KEY}"
@@ -0,0 +1,47 @@
#!/bin/sh
set -eu
# mulga-eks-state-report — periodic NATS publish of K3s server health.
# Invoked every 15 minutes via /etc/periodic/15min (Alpine crond). One-shot
# best-effort; failures log but do not retry — the spinifex reconciler also
# does its own NLB healthcheck so this is supplementary signal.
#
# Subject: eks.state.{accountID}.{clusterName}.server
# Payload: JSON { healthz: "ok"|"fail", node_count: N, ts: <unix-seconds> }
ENVFILE=/etc/spinifex-eks/state-report.env
[ -f "${ENVFILE}" ] || { logger -t mulga-eks-state-report "${ENVFILE} missing — exiting"; exit 0; }
# shellcheck disable=SC1090
. "${ENVFILE}"
: "${SPINIFEX_NATS_URL:?}"
: "${EKS_ACCOUNT_ID:?}"
: "${EKS_CLUSTER_NAME:?}"
KUBECONFIG=${KUBECONFIG:-/etc/rancher/k3s/k3s.yaml}
export KUBECONFIG
if curl -sk --max-time 3 https://127.0.0.1:6443/healthz | grep -q '^ok$'; then
HEALTH=ok
else
HEALTH=fail
fi
if [ "${HEALTH}" = "ok" ]; then
NODE_COUNT=$(kubectl get nodes --no-headers 2>/dev/null | wc -l | tr -d ' ')
else
NODE_COUNT=0
fi
TS=$(date +%s)
PAYLOAD=$(printf '{"healthz":"%s","node_count":%s,"ts":%s}' "${HEALTH}" "${NODE_COUNT}" "${TS}")
SUBJ="eks.state.${EKS_ACCOUNT_ID}.${EKS_CLUSTER_NAME}.server"
NATS_ARGS="-s ${SPINIFEX_NATS_URL}"
if [ -n "${SPINIFEX_NATS_CREDS_FILE:-}" ] && [ -f "${SPINIFEX_NATS_CREDS_FILE}" ]; then
NATS_ARGS="${NATS_ARGS} --creds ${SPINIFEX_NATS_CREDS_FILE}"
fi
# shellcheck disable=SC2086
printf '%s' "${PAYLOAD}" | nats ${NATS_ARGS} pub "${SUBJ}" --stdin 2>&1 \
| logger -t mulga-eks-state-report
+85
View File
@@ -0,0 +1,85 @@
#!/bin/sh
set -eu
# setup.sh — chroot customisation for the eks-server AMI.
#
# Runs inside an Alpine 3.21 chroot under build-system-image.sh after packages
# and binaries are installed. Downloads the pinned K3s binary, verifies its
# SHA256, drops it into /usr/local/bin, sets executable bits on init scripts +
# cron entries, and writes the K3s server config skeleton.
#
# Network access inside the chroot is provided by the host's /etc/resolv.conf
# (build-system-image.sh copies it in). curl is in APK_PACKAGES.
K3S_VERSION="v1.32.5+k3s1"
K3S_URL_BASE="https://github.com/k3s-io/k3s/releases/download/${K3S_VERSION}"
# Pull the upstream signed checksums file and pin the amd64 line. A tampered
# release replacing both files would still produce a self-consistent download,
# so the SHA file URL is the trust anchor — anyone forging a Mulga AMI build
# would need to compromise the k3s-io GitHub release artefacts.
echo "[eks-server-setup] fetching k3s checksums ${K3S_VERSION}"
curl -fsSL -o /tmp/k3s-checksums.txt "${K3S_URL_BASE}/sha256sum-amd64.txt"
K3S_SHA256=$(awk '/[ \t]k3s$/{print $1; exit}' /tmp/k3s-checksums.txt)
if [ -z "${K3S_SHA256}" ]; then
echo "[eks-server-setup] could not parse k3s sha256 from upstream checksums"
cat /tmp/k3s-checksums.txt
exit 1
fi
echo "[eks-server-setup] downloading k3s ${K3S_VERSION} (sha256=${K3S_SHA256})"
curl -fsSL -o /usr/local/bin/k3s "${K3S_URL_BASE}/k3s"
echo "${K3S_SHA256} /usr/local/bin/k3s" > /tmp/k3s.sha256
if ! sha256sum -c /tmp/k3s.sha256; then
echo "[eks-server-setup] k3s SHA256 mismatch — refusing to bake AMI"
exit 1
fi
chmod 0755 /usr/local/bin/k3s
ln -sf /usr/local/bin/k3s /usr/local/bin/kubectl
ln -sf /usr/local/bin/k3s /usr/local/bin/crictl
ln -sf /usr/local/bin/k3s /usr/local/bin/ctr
# nats-cli is not in Alpine main/community — pull the upstream release binary.
# Used by k3s-first-boot.sh (one-shot publishes) and mulga-eks-state-report.sh
# (periodic publishes).
NATS_CLI_VERSION="0.4.0"
NATS_CLI_URL="https://github.com/nats-io/natscli/releases/download/v${NATS_CLI_VERSION}/nats-${NATS_CLI_VERSION}-linux-amd64.zip"
echo "[eks-server-setup] downloading nats-cli ${NATS_CLI_VERSION}"
curl -fsSL -o /tmp/nats-cli.zip "${NATS_CLI_URL}"
apk add --no-cache unzip
unzip -q -d /tmp/nats-cli /tmp/nats-cli.zip
install -m 0755 /tmp/nats-cli/nats-${NATS_CLI_VERSION}-linux-amd64/nats /usr/local/bin/nats
rm -rf /tmp/nats-cli /tmp/nats-cli.zip
apk del --no-cache unzip
# Init scripts ship as 0644 from INSTALL_FILES; OpenRC requires 0755.
chmod 0755 /etc/init.d/k3s /etc/init.d/eks-token-webhook /etc/init.d/k3s-first-boot
chmod 0755 /usr/local/sbin/k3s-first-boot
chmod 0755 /etc/periodic/15min/mulga-eks-state-report
chmod 0755 /etc/periodic/daily/mulga-eks-etcd-snapshot
# K3s server config — empty skeleton; cloud-init / first-boot fills in the
# per-cluster fields (cluster-cidr, service-cidr, token-file, etc).
# Webhook-token-auth is NOT wired in the skeleton: enabling it before the
# eks-token-webhook binary is the real implementation (currently a 503 stub,
# replaced by cs-eks-6c) would block /healthz from anonymous callers. The
# Sprint 6c orchestrator drops a webhook-config.yaml + restarts k3s once
# the binary is the real one.
mkdir -p /etc/rancher/k3s
cat > /etc/rancher/k3s/config.yaml.skel <<'EOF'
# Populated at first boot by cloud-init user-data via k3s-first-boot.sh.
# Fields documented at https://docs.k3s.io/installation/configuration.
cluster-init: true
disable:
- traefik
- servicelb
- local-storage
EOF
# Sentinel file marker — k3s-first-boot self-disables after first success by
# checking for this path. Initial state is "not yet run".
touch /var/lib/spinifex-eks/first-boot.pending 2>/dev/null || {
mkdir -p /var/lib/spinifex-eks
touch /var/lib/spinifex-eks/first-boot.pending
}
echo "[eks-server-setup] done"
+65 -12
View File
@@ -17,34 +17,85 @@ export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get install -y --no-install-recommends curl gnupg ca-certificates
# AMD ROCm apt repository — rocminfo and rocm-smi-lib are not in the Ubuntu
# default repos, so we add AMD's signed repo before the main package install.
UBUNTU_CODENAME=$(. /etc/os-release && echo "${UBUNTU_CODENAME:-${VERSION_CODENAME}}")
# AMD ROCm 7.2 repo — pinned to "noble" (24.04) since AMD hasn't published a
# "resolute" (26.04) dist yet; the noble packages are ABI-compatible.
mkdir -p /etc/apt/keyrings
curl -fsSL https://repo.radeon.com/rocm/rocm.gpg.key \
| gpg --dearmor -o /etc/apt/keyrings/rocm.gpg
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/rocm.gpg] https://repo.radeon.com/rocm/apt/6.3 ${UBUNTU_CODENAME} main" \
echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/rocm.gpg] https://repo.radeon.com/rocm/apt/7.2.3 noble main" \
> /etc/apt/sources.list.d/rocm.list
apt-get update -qq
# The Ubuntu minimal cloud image already ships with a kernel. Detect it and
# install matching headers — do NOT install linux-image-generic, which pulls a
# newer ABI the bootloader won't know about (grub-install can't run in a chroot).
KVER=$(ls /boot/vmlinuz-* 2>/dev/null | sort -V | tail -1 | sed 's|/boot/vmlinuz-||') || true
if [[ -z "${KVER}" ]]; then
# Ubuntu 26.04 minimal cloud image keeps the kernel on the ESP (not mounted
# in the chroot), so /boot is empty — fall back to /lib/modules.
KVER=$(ls /lib/modules/ 2>/dev/null | sort -V | tail -1)
fi
if [[ -z "${KVER}" ]]; then
echo "ERROR: No kernel found in /boot or /lib/modules — base image may be missing a kernel"
exit 1
fi
echo "Target kernel: ${KVER}"
# Prevent kernel postinst hooks from attempting grub-install against the host disk.
cat > /etc/kernel-img.conf <<'EOF'
do_symlinks = yes
do_bootloader = no
do_initrd = yes
link_in_boot = yes
EOF
# linux-firmware carries the AMD GPU firmware blobs loaded by the amdgpu
# kernel module at boot. Without them the GPU is invisible to guest userland.
apt-get install -y -o Acquire::Retries=3 --no-install-recommends \
linux-image-generic \
linux-headers-generic \
"linux-headers-${KVER}" \
linux-firmware \
initramfs-tools \
pciutils \
rocminfo rocm-smi-lib \
python3 python3-venv python3-pip \
git curl wget htop tmux \
ffmpeg libgl1 libglib2.0-0
KVER=$(ls /boot/vmlinuz-* 2>/dev/null | sort -V | tail -1 | sed 's|/boot/vmlinuz-||')
if [ -z "${KVER}" ]; then
echo "ERROR: No kernel found under /boot"
exit 1
apt-get update -qq
apt-get install -y --no-install-recommends rocminfo rocm-smi-lib amd-smi-lib
# Create /opt/rocm symlink (normally done by rocm-core) and add to PATH.
ROCM_DIR=$(ls -d /opt/rocm-* 2>/dev/null | sort -V | tail -1)
if [[ -n "${ROCM_DIR}" ]]; then
ln -sfn "${ROCM_DIR}" /opt/rocm
echo "export PATH=\"/opt/rocm/bin:\$PATH\"" > /etc/profile.d/rocm.sh
chmod 644 /etc/profile.d/rocm.sh
fi
# ── Docker CE ─────────────────────────────────────────────────────────────────
# AMD GPU containers use device passthrough — no separate container runtime
# is needed. Users pass --device=/dev/kfd --device=/dev/dri at run time.
mkdir -p /usr/share/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg \
| gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg
echo "deb [arch=amd64 signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu ${UBUNTU_CODENAME} stable" \
> /etc/apt/sources.list.d/docker.list
apt-get update -qq
apt-get install -y --no-install-recommends \
docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
systemctl enable docker
# Ensure render + video groups exist for /dev/kfd and /dev/dri access.
groupadd -f render
groupadd -f video
# Add the cloud-init default user (UID 1000) to docker/render/video at first boot.
mkdir -p /etc/cloud/cloud.cfg.d
cat > /etc/cloud/cloud.cfg.d/99-gpu-groups.cfg <<'EOF'
runcmd:
- usermod -aG docker,render,video $(id -un 1000) || true
EOF
echo "Rebuilding initramfs for kernel: ${KVER}"
update-initramfs -u -k "${KVER}"
@@ -56,7 +107,9 @@ Unattended-Upgrade::Package-Blacklist {
"linux-firmware";
"linux-image-";
"linux-headers-";
"docker-";
"containerd";
};
EOF
echo "AMD GPU image setup complete: kernel=${KVER}, linux-firmware and ROCm userland installed"
echo "AMD GPU image setup complete: kernel=${KVER}, linux-firmware and ROCm userland installed, Docker ready"
+4 -2
View File
@@ -4,9 +4,11 @@ DISTRO="ubuntu"
UBUNTU_VERSION="26.04"
UBUNTU_CODENAME="resolute"
# 12G provides headroom for kernel headers, linux-firmware, and ROCm packages
IMAGE_SIZE="12G"
# 16G provides headroom for kernel headers, linux-firmware, ROCm packages, and Docker CE.
IMAGE_SIZE="16G"
SETUP_SCRIPT="scripts/images/ubuntu-gpu-amd-setup.sh"
AMI_NAME="ubuntu-amd-gpu"
SYSTEM_TAG="gpu-vendor=amd"
+83 -19
View File
@@ -19,31 +19,61 @@ set -euo pipefail
export DEBIAN_FRONTEND=noninteractive
apt-get update -qq
apt-get update
# Detect the cloud image's existing kernel BEFORE installing anything.
# Installing linux-image-generic would upgrade to a newer kernel version;
# DKMS would then build for that version but the VM boots the original
# cloud image kernel, causing a version mismatch at modprobe time.
KVER=$(ls /boot/vmlinuz-* 2>/dev/null | sort -V | tail -1 | sed 's|/boot/vmlinuz-||')
# The Ubuntu minimal cloud image already ships with a kernel. We detect it and
# build DKMS against it — do NOT install linux-image-generic, which pulls a
# newer kernel ABI that won't boot (grub-install can't run in a chroot, so the
# bootloader never learns about the new kernel and keeps booting the original).
apt-get install -y --no-install-recommends initramfs-tools
echo "=== /boot ==="
ls -la /boot/ || true
echo "=== /lib/modules ==="
ls /lib/modules/ || true
echo "=== installed kernel packages ==="
dpkg -l 'linux-image-*' | grep '^ii' || true
KVER=$(ls /boot/vmlinuz-* 2>/dev/null | sort -V | tail -1 | sed 's|/boot/vmlinuz-||') || true
if [[ -z "${KVER}" ]]; then
echo "ERROR: No kernel found under /boot"
# Ubuntu 26.04 minimal cloud image keeps the kernel on the ESP (not mounted
# in the chroot), so /boot is empty — fall back to /lib/modules.
KVER=$(ls /lib/modules/ 2>/dev/null | sort -V | tail -1)
fi
if [[ -z "${KVER}" ]]; then
echo "ERROR: No kernel found in /boot or /lib/modules — base image may be missing a kernel"
exit 1
fi
echo "Target kernel: ${KVER}"
# Install headers for the exact kernel already present, plus initramfs-tools.
apt-get install -y --no-install-recommends \
"linux-headers-${KVER}" \
initramfs-tools
# Prevent kernel postinst hooks from attempting grub-install against the host
# disk — this fails silently in a chroot and can abort the postinst, leaving
# /boot incomplete.
cat > /etc/kernel-img.conf <<'EOF'
do_symlinks = yes
do_bootloader = no
do_initrd = yes
link_in_boot = yes
EOF
# Install the NVIDIA server DKMS driver. Use the distro default version (no
# hardcoded suffix) so Ubuntu 26.04's 580 driver is used instead of 550.
# nvidia-dkms-server is a meta package that resolves to the current recommended
# version; nvidia-utils-server brings nvidia-smi and related tools.
# Install headers for the exact pre-installed kernel so DKMS builds against it.
apt-get install -y --no-install-recommends \
nvidia-dkms-server \
nvidia-utils-server
"linux-headers-${KVER}"
# Detect the latest available versioned NVIDIA server driver. Ubuntu 26.04+
# no longer ships unversioned meta-packages (nvidia-dkms-server); the packages
# are now versioned (e.g. nvidia-dkms-570-server).
NVIDIA_VER=$(apt-cache search '^nvidia-dkms-[0-9]+-server$' 2>/dev/null \
| grep -oP '(?<=nvidia-dkms-)\d+(?=-server)' | sort -rn | head -1)
if [[ -z "${NVIDIA_VER}" ]]; then
echo "ERROR: No versioned nvidia-dkms-*-server package found in apt cache"
apt-cache search nvidia-dkms || true
exit 1
fi
echo "Installing NVIDIA server driver version: ${NVIDIA_VER}"
apt-get install -y --no-install-recommends \
"nvidia-dkms-${NVIDIA_VER}-server" \
"nvidia-utils-${NVIDIA_VER}-server"
# Detect the installed NVIDIA DKMS module name + version for explicit build.
NVIDIA_DKMS_NAME=$(dkms status 2>/dev/null | awk -F'[,/ ]+' '/nvidia/{print $1; exit}')
@@ -91,17 +121,51 @@ apt-get install -y -o Acquire::Retries=3 \
git curl wget htop tmux \
ffmpeg libgl1 libglib2.0-0
# ── Docker CE + nvidia-container-toolkit ──────────────────────────────────────
UBUNTU_CODENAME=$(. /etc/os-release && echo "${UBUNTU_CODENAME:-${VERSION_CODENAME}}")
apt-get install -y --no-install-recommends gnupg ca-certificates
mkdir -p /usr/share/keyrings /etc/apt/sources.list.d
curl -fsSL https://download.docker.com/linux/ubuntu/gpg \
| gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg
echo "deb [arch=amd64 signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/ubuntu ${UBUNTU_CODENAME} stable" \
> /etc/apt/sources.list.d/docker.list
curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey \
| gpg --dearmor -o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg
curl -fsSL https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list \
| sed 's|deb https://|deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg] https://|g' \
> /etc/apt/sources.list.d/nvidia-container-toolkit.list
apt-get update -qq
apt-get install -y --no-install-recommends \
docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin \
nvidia-container-toolkit
# Wire nvidia runtime into Docker so --gpus all works on first boot.
nvidia-ctk runtime configure --runtime=docker
systemctl enable docker
# Add the cloud-init default user (UID 1000) to docker at first boot.
mkdir -p /etc/cloud/cloud.cfg.d
cat > /etc/cloud/cloud.cfg.d/99-gpu-groups.cfg <<'EOF'
runcmd:
- usermod -aG docker $(id -un 1000) || true
EOF
mkdir -p /etc/apt/apt.conf.d
cat > /etc/apt/apt.conf.d/99-gpu-ami <<'EOF'
Unattended-Upgrade::Package-Blacklist {
"linux-";
"nvidia-";
"libnvidia-";
"docker-";
"containerd";
"nvidia-container-toolkit";
};
EOF
# Rebuild initramfs with nouveau blacklisted and NVIDIA module included.
update-initramfs -u -k "${KVER}"
echo "NVIDIA GPU image setup complete: kernel=${KVER}, NVIDIA driver + DKMS pre-built"
echo "NVIDIA GPU image setup complete: kernel=${KVER}, NVIDIA driver + DKMS pre-built, Docker + nvidia-container-toolkit installed"
+5 -2
View File
@@ -4,9 +4,12 @@ DISTRO="ubuntu"
UBUNTU_VERSION="26.04"
UBUNTU_CODENAME="resolute"
# 12G provides headroom for kernel headers, NVIDIA driver, and DKMS build artefacts
IMAGE_SIZE="12G"
# 16G provides headroom for kernel headers, NVIDIA driver, DKMS build artefacts,
# Docker CE, and nvidia-container-toolkit.
IMAGE_SIZE="16G"
SETUP_SCRIPT="scripts/images/ubuntu-gpu-nvidia-setup.sh"
AMI_NAME="ubuntu-nvidia-gpu"
SYSTEM_TAG="gpu-vendor=nvidia"
+3 -2
View File
@@ -170,9 +170,10 @@ if [ -e /etc/systemd/network/15-spinifex-veth-wan.netdev ] || \
sudo networkctl reload 2>/dev/null || true
fi
# Remove macvlan interfaces created by setup-ovn.sh
# Legacy: clean up macvlan interfaces left over from pre-veth setup-ovn.sh
# runs. Safe to remove once no operator system retains these.
for iface in $(ip -o link show type macvlan 2>/dev/null | awk -F': ' '{print $2}' | grep '^spx-ext-'); do
echo " Deleting macvlan: $iface"
echo " Deleting legacy macvlan: $iface"
sudo ip link del "$iface" 2>/dev/null || true
done
+80 -105
View File
@@ -16,9 +16,7 @@
# Options:
# --management Also start OVN central services (NB DB, SB DB, ovn-northd)
# --wan-bridge=NAME OVS bridge for WAN traffic (default: auto-detect from default route)
# --wan-iface=NAME Physical NIC to add to the WAN bridge (use with --wan-bridge or --macvlan)
# --macvlan Create macvlan off --wan-iface instead of moving NIC directly.
# SSH-safe for single-NIC hosts where WAN NIC carries SSH.
# --wan-iface=NAME Physical NIC to add to the WAN bridge (use with --wan-bridge)
# --dhcp Obtain gateway IP via DHCP on the WAN bridge interface
# --mgmt-bridge=NAME OVS bridge for system-instance control plane (default: br-mgmt)
# --mgmt-cidr=CIDR IPv4 CIDR to assign on the mgmt bridge (default: 10.15.8.1/24)
@@ -41,9 +39,6 @@
# # Dedicated WAN NIC (not your SSH NIC — you take responsibility):
# ./scripts/setup-ovn.sh --management --wan-bridge=br-wan --wan-iface=eth1
#
# # Single-NIC host (SSH-safe macvlan):
# ./scripts/setup-ovn.sh --management --macvlan --wan-iface=eth0
#
# # Compute node joining an existing cluster:
# ./scripts/setup-ovn.sh --ovn-remote=tcp:10.0.0.1:6642 --encap-ip=10.0.0.2
#
@@ -56,7 +51,6 @@ set -e
MANAGEMENT=false
WAN_BRIDGE=""
WAN_IFACE=""
MACVLAN_MODE=false
EXTERNAL_DHCP=false
MGMT_BRIDGE_ENABLED=true
MGMT_BRIDGE="br-mgmt"
@@ -64,12 +58,19 @@ MGMT_CIDR="10.15.8.1/24"
MGMT_IFACE=""
OVN_REMOTE="tcp:127.0.0.1:6642"
ENCAP_IP=""
# NODE_NAME is left empty by default. The chassis-id pin block at Step 4
# only runs when --node-name=NAME is explicitly given. Passing nothing
# preserves whatever system-id already lives in OVS (gold-image UUID,
# bootstrap-install.sh's pre-written nodeN, manual ansible value, etc.).
# Bootstrap callers that want IPsec-cert-identity matching pin the
# system-id themselves before / after invoking setup-ovn.sh — they
# don't need this script to do it for them.
NODE_NAME=""
# Parse arguments
for arg in "$@"; do
case "$arg" in
--management) MANAGEMENT=true ;;
--macvlan) MACVLAN_MODE=true ;;
--dhcp) EXTERNAL_DHCP=true ;;
--wan-bridge=*) WAN_BRIDGE="${arg#*=}" ;;
--wan-iface=*) WAN_IFACE="${arg#*=}" ;;
@@ -79,6 +80,7 @@ for arg in "$@"; do
--no-mgmt-bridge) MGMT_BRIDGE_ENABLED=false ;;
--ovn-remote=*) OVN_REMOTE="${arg#*=}" ;;
--encap-ip=*) ENCAP_IP="${arg#*=}" ;;
--node-name=*) NODE_NAME="${arg#*=}" ;;
--help|-h)
head -50 "$0" | tail -48
exit 0
@@ -92,15 +94,13 @@ done
# --- WAN bridge auto-detection ---
# Determine the WAN bridge name and how to set it up.
WAN_BRIDGE_MODE="" # "existing", "veth", "direct", "macvlan", or ""
WAN_BRIDGE_MODE="" # "existing", "veth", "direct", or ""
LINUX_BRIDGE="" # Set when WAN_BRIDGE_MODE="veth" — the Linux bridge behind the veth pair
detect_wan_bridge() {
# If --wan-bridge was explicitly given, use it
if [ -n "$WAN_BRIDGE" ]; then
if [ "$MACVLAN_MODE" = true ] && [ -n "$WAN_IFACE" ]; then
WAN_BRIDGE_MODE="macvlan"
elif [ -n "$WAN_IFACE" ]; then
if [ -n "$WAN_IFACE" ]; then
WAN_BRIDGE_MODE="direct"
elif sudo ovs-vsctl br-exists "$WAN_BRIDGE" 2>/dev/null; then
WAN_BRIDGE_MODE="existing"
@@ -111,17 +111,6 @@ detect_wan_bridge() {
return
fi
# If --macvlan was given without --wan-bridge, we need --wan-iface
if [ "$MACVLAN_MODE" = true ]; then
if [ -z "$WAN_IFACE" ]; then
echo "ERROR: --macvlan requires --wan-iface=<NIC>"
exit 1
fi
WAN_BRIDGE="br-wan"
WAN_BRIDGE_MODE="macvlan"
return
fi
# Auto-detect: find the default route interface
local default_dev
default_dev=$(ip -4 route show default 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i=="dev") print $(i+1)}' | head -1)
@@ -176,10 +165,7 @@ detect_wan_bridge() {
echo " 2. Dedicated WAN NIC (NOT your SSH connection):"
echo " ./scripts/setup-ovn.sh --management --wan-bridge=br-wan --wan-iface=$default_dev"
echo ""
echo " 3. Single-NIC host (SSH-safe macvlan):"
echo " ./scripts/setup-ovn.sh --management --macvlan --wan-iface=$default_dev"
echo ""
echo " 4. No external networking (overlay-only):"
echo " 3. No external networking (overlay-only):"
echo " ./scripts/setup-ovn.sh --management --encap-ip=$wan_ip"
echo "============================================================"
echo ""
@@ -224,32 +210,31 @@ echo " OVN Remote (SB): $OVN_REMOTE"
echo " Encap IP: $ENCAP_IP"
echo ""
# --- Step 1: Install packages ---
echo "Step 1: Checking OVN/OVS packages..."
# Packages (openvswitch-switch, ovn-host, openvswitch-ipsec, strongswan-charon,
# and ovn-central on management nodes) are baked into the gold image via
# scripts/tofu-cluster/image-builder/scripts/provision.sh. Runtime apt-get
# was removed to keep CI off the (flaky) apt-cacher-ng path. If a package is
# missing, the downstream ovs/ovn commands will fail loudly — the fix is to
# rebuild the gold image, not to re-add apt-get here.
install_packages() {
local missing=()
for pkg in openvswitch-switch ovn-host; do
if ! dpkg -s "$pkg" >/dev/null 2>&1; then
missing+=("$pkg")
fi
done
if [ "$MANAGEMENT" = true ]; then
if ! dpkg -s ovn-central >/dev/null 2>&1; then
missing+=("ovn-central")
fi
fi
if [ ${#missing[@]} -gt 0 ]; then
echo " Installing: ${missing[*]}"
sudo apt-get update -qq
sudo apt-get install -y -qq "${missing[@]}"
else
echo " All packages installed"
fi
}
install_packages
# strongswan-charon ships an AppArmor profile for /usr/lib/ipsec/charon that
# only allows reading from /etc/ipsec.*, /etc/strongswan.*, and a few other
# fixed paths. ovs-monitor-ipsec writes the per-tunnel strongSwan config with
# absolute paths to our peer cert + key under /etc/spinifex/ipsec/ AND to the
# cluster CA at /etc/spinifex/ca.pem, so charon hits "Permission denied" on
# both load paths and surfaces as `no trusted RSA public key found for
# '<peer>'` (CA can't be loaded → can't validate peer cert chain → no SAs).
# The profile includes a 'local' override file expressly for site additions
# — grant reads on /etc/spinifex/** (covers ca.pem AND ipsec/peer.{pem,key})
# and reload.
if [ -d /etc/apparmor.d/local ]; then
LOCAL_OVERRIDE=/etc/apparmor.d/local/usr.lib.ipsec.charon
# Always rewrite — re-runs may have an older, narrower rule (e.g. the
# initial /etc/spinifex/ipsec/** only) that misses /etc/spinifex/ca.pem.
echo " Adding AppArmor read grant for /etc/spinifex (cert + CA) to charon profile"
echo "/etc/spinifex/** r," | sudo tee "$LOCAL_OVERRIDE" >/dev/null
sudo apparmor_parser -r /etc/apparmor.d/usr.lib.ipsec.charon 2>/dev/null || true
fi
# --- Step 2: Enable services ---
echo ""
@@ -296,7 +281,6 @@ echo " br-int: created, fail-mode=secure, up"
# rule => default management => no carrier => link down). OVS dataplane
# still forwards, but ovn-controller flow programming and any tooling that
# probes link state misbehave. Unmanaged=yes keeps OVS in sole control.
# (mulga-siv-37)
OVS_INTERNAL_NET=/etc/systemd/network/05-spinifex-ovs-internal.network
if [ ! -f "$OVS_INTERNAL_NET" ]; then
sudo tee "$OVS_INTERNAL_NET" >/dev/null <<'NETWORK'
@@ -477,36 +461,6 @@ NETWORK
echo " $WAN_BRIDGE: direct bridge on $WAN_IFACE"
echo " NOTE: $WAN_IFACE is now an OVS port — no host IP on this NIC"
;;
macvlan)
# Create a macvlan sub-interface in bridge mode off the WAN NIC.
# The host keeps its IP on the parent NIC — SSH-safe. OVN localnet
# traffic flows through the macvlan to the physical wire.
if ! ip link show "$WAN_IFACE" >/dev/null 2>&1; then
echo " ERROR: interface $WAN_IFACE does not exist"
echo " Available interfaces:"
ip -o link show | awk -F': ' '{print " " $2}'
exit 1
fi
MACVLAN_NAME="spx-ext-${WAN_IFACE}"
sudo ovs-vsctl --may-exist add-br "$WAN_BRIDGE"
sudo ip link set "$WAN_BRIDGE" up
if ip link show "$MACVLAN_NAME" >/dev/null 2>&1; then
echo " macvlan $MACVLAN_NAME already exists"
else
sudo ip link add "$MACVLAN_NAME" link "$WAN_IFACE" type macvlan mode bridge
echo " created macvlan: $MACVLAN_NAME (bridge mode) on $WAN_IFACE"
fi
sudo ip link set "$MACVLAN_NAME" up
sudo ovs-vsctl --may-exist add-port "$WAN_BRIDGE" "$MACVLAN_NAME"
echo " $WAN_BRIDGE: macvlan port $MACVLAN_NAME on $WAN_IFACE"
echo " NOTE: host keeps its IP on $WAN_IFACE (SSH-safe)"
echo " QUIRK: host cannot reach VMs at their public IPs (macvlan isolation)"
;;
esac
# --- DHCP: obtain gateway IP for OVN SNAT ---
@@ -514,13 +468,8 @@ NETWORK
echo ""
echo "Step 3c: Obtaining external gateway IP via DHCP..."
# For macvlan mode, DHCP on the macvlan interface (it has L2 access to WAN).
# For direct/existing bridge, DHCP on the bridge itself.
if [ "$WAN_BRIDGE_MODE" = "macvlan" ]; then
DHCP_IFACE="spx-ext-${WAN_IFACE}"
else
DHCP_IFACE="$WAN_BRIDGE"
fi
# DHCP on the WAN bridge itself (direct/existing/veth).
DHCP_IFACE="$WAN_BRIDGE"
# Run DHCP client to get a lease
if command -v dhcpcd >/dev/null 2>&1; then
@@ -629,11 +578,33 @@ else
external_ids:ovn-encap-type="geneve"
fi
# system-id is owned by the openvswitch-switch package (persisted in
# /etc/openvswitch/system-id.conf and re-applied on every boot). Read it back
# rather than overriding — overriding here would drift from the on-disk value
# and the next reboot would silently flip the chassis identity (mulga-999).
echo " system-id: $(sudo ovs-vsctl get open . external_ids:system-id)"
# Pin OVS system-id (= OVN chassis-id) to NODE_NAME when --node-name was
# explicitly given. Reasons to pin:
# 1. IPsec identity: ovs-monitor-ipsec uses chassis-id as the IKEv2
# `@<name>` peer identity. Our per-node IPsec peer cert
# (admin.GenerateIPSecPeerCert) carries the cluster node name as CN
# + dnsName SAN — see spx admin init/join, which take --node NAME and
# bake NAME into the cert. Leaving chassis-id as the package-generated
# UUID would cause `received AUTHENTICATION_FAILED` because charon
# validates `@<UUID>` against a cert dnsName=<NODE_NAME>.
# 2. Ops legibility: `ovn-sbctl show` lists chassis by name, not UUID.
#
# When --node-name is NOT given (CI bootstrap-install.sh today, dev
# single-node bring-up, ansible roles that don't yet pass it), leave the
# system-id alone. A hostname fallback used to live here, but on CI
# single-node it rewrote system-id from the gold-image UUID to a
# different hostname while the existing SBDB chassis row kept name=UUID;
# vpcd's discoverChassis then logged "skipping stale local chassis" and
# refused to start. Preserving the on-disk value is always safe — any
# caller that needs IPsec cert identity matching pins it themselves.
if [ -n "$NODE_NAME" ]; then
echo "$NODE_NAME" | sudo tee /etc/openvswitch/system-id.conf >/dev/null
sudo ovs-vsctl set Open_vSwitch . external_ids:system-id="$NODE_NAME"
echo " system-id: $NODE_NAME (pinned via --node-name)"
else
CURRENT_ID=$(sudo ovs-vsctl get Open_vSwitch . external_ids:system-id 2>/dev/null | tr -d '"')
echo " system-id: ${CURRENT_ID:-<unset>} (preserved; no --node-name given)"
fi
echo " ovn-remote: $OVN_REMOTE"
echo " ovn-encap-ip: $ENCAP_IP"
echo " ovn-encap-type: geneve"
@@ -779,8 +750,20 @@ echo ""
echo "Step 10: Enabling OVN auto-start on boot..."
sudo systemctl enable openvswitch-switch 2>/dev/null || true
sudo systemctl enable ovn-controller 2>/dev/null || true
echo " openvswitch-switch: enabled on boot"
echo " ovn-controller: enabled on boot"
# ovs-monitor-ipsec drives strongSwan from OVS DB cert pointers. The daemon's
# enableOVNIPSec() flips ipsec_encapsulation=true at runtime and silently drops
# tunnel traffic if this unit isn't already up — enable at provision time so
# daemon never needs systemd-write capability (only is-active read).
sudo systemctl enable openvswitch-ipsec.service 2>/dev/null || true
# When openvswitch-ipsec is installed before strongswan-starter, the deb
# post-install starts the service against a missing /usr/sbin/ipsec and
# leaves it in 'failed' state. Clear the failure and restart unconditionally
# now that strongswan-starter is present.
sudo systemctl reset-failed openvswitch-ipsec.service 2>/dev/null || true
sudo systemctl restart openvswitch-ipsec.service 2>/dev/null || true
echo " openvswitch-switch: enabled on boot"
echo " ovn-controller: enabled on boot"
echo " openvswitch-ipsec: enabled on boot"
# --- Step 11: Health check ---
echo ""
@@ -815,14 +798,6 @@ if [ -n "$WAN_BRIDGE" ]; then
echo " direct bridge: FAILED ($WAN_IFACE not on $WAN_BRIDGE)"
OK=false
fi
elif [ "$WAN_BRIDGE_MODE" = "macvlan" ]; then
MACVLAN_NAME="spx-ext-${WAN_IFACE}"
if ip link show "$MACVLAN_NAME" >/dev/null 2>&1; then
echo " macvlan: OK ($MACVLAN_NAME)"
else
echo " macvlan: FAILED ($MACVLAN_NAME not found)"
OK=false
fi
fi
else
echo " $WAN_BRIDGE:$(printf '%*s' $((15 - ${#WAN_BRIDGE})) '') FAILED"

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