Compare commits

...

424 Commits

Author SHA1 Message Date
Julian Sommer a7f7610176 release: update go.mod for v1.5.0 2026-05-26 10:04:09 +10:00
dependabot[bot] e34b536b97 chore: bump the go-dependencies group across 1 directory with 3 updates (#252) 2026-05-26 09:10:38 +10:00
dependabot[bot] ef097bf4ab chore: bump actions/setup-go from 5 to 6 in the github-actions group (#251) 2026-05-26 09:09:24 +10:00
Julian Sommer 3b5e3e21c0 chore: tidy 2026-05-26 09:04:46 +10:00
Julian Sommer 71350033f7 chore: bump golang.org/x/net 2026-05-26 09:02:55 +10:00
joshsiv-mulga 80ab3fb37a ci: prune rootless docker + trap container leaks in microvm build
Self-hosted runner accumulated 18GB in overlay2 from leaked container
layers. Two causes: build-microvm-image.sh only removed the alpine
container on the success path, and no workflow step pruned docker
between runs.

- scripts/build-microvm-image.sh: container id now removed via the
  existing EXIT trap, so failures, signals, and runner kills no longer
  leak the writable layer.
- .github/workflows/e2e-go.yml: prune containers / dangling images /
  buildx cache before each Build Install Artifacts step, in all three
  e2e jobs (single, multinode, reboot).
2026-05-22 16:52:38 +10:00
Julian Sommer 756a5bb31f feat: media-sanitization doc 2026-05-22 15:38:10 +10:00
Julian Sommer a7f3debd6d fix: log and retry on Describe failure in ensureDefaultVPCInfrastructureFor 2026-05-22 15:22:30 +10:00
Julian Sommer 0362bc9baa fix: only subscribe to elbv2 operations if gateway url configured 2026-05-22 15:16:16 +10:00
Julian Sommer 0f7ff5e574 fix: bootstrap IGW for new tenants on iam.account.created 2026-05-22 14:05:57 +10:00
Julian Sommer edeb53b9b8 fix: fail RunInstances when AttachEni fails 2026-05-22 13:57:25 +10:00
Julian Sommer 0aa714c2bf feat: neutralise potentially-committed NAT rule on rollback (#249) 2026-05-22 13:48:14 +10:00
Julian Sommer b3d8a7d262 fix: trim sleep times 2026-05-22 11:34:20 +10:00
Julian Sommer d323facf65 fix: pseudo multi node, old behaviour asserted wrong 2026-05-22 11:26:54 +10:00
Julian Sommer 54cd1d789c fix(vm): poll for nbd + qmp readiness instead of fixed sleeps (#248) 2026-05-22 11:17:41 +10:00
Julian Sommer d0c6ad9f69 fix: map Manager.Start not-found to InvalidInstanceID.NotFound 2026-05-22 10:45:10 +10:00
Julian Sommer 25b29fadec fix: test race 2026-05-22 10:34:45 +10:00
Julian Sommer 2b6c61b95c fix: StopAll persists state 2026-05-22 10:21:31 +10:00
Julian Sommer bc5dc6f63d fix: allocate TOCTOU race 2026-05-22 09:31:26 +10:00
joshsiv-mulga 38202206bc fix(e2e/single): TestClusterStatsCLI back to sequential pre-launch
TestClusterStatsCLI asserts baseline cluster state ("0/" CPU used, "No VMs found") which doesn't hold once the singleton VM is up. Moving it into bucket #1 parallel (siv-128) ran it AFTER all sequential VM launches, so the spx top nodes output reported 4/12 CPU + 1 VM and the assertion failed.

Move it back into the sequential block, ahead of TestInstanceLaunch, so the baseline assertion sees the cluster pre-launch.
2026-05-21 23:14:30 +10:00
Josh Siviour 40f8c4da94 refactor(e2e/single): drop phase numbers + parallel bucket #1 (#247)
- Rename 36 phaseN_X / phaseIAM_N funcs to runX.
- Drop numeric suffix from 7 TestIAMN_X wrappers (TestIAM1_UserCRUD -> TestIAMUserCRUD).
- Rewrite harness.Phase log strings "Phase N - X" -> "Single - X" to mirror multinode convention.
- Add t.Parallel() to bucket #1: 10 read-only / own-everything top-level Tests (Environment, ClusterStatsCLI, Discovery, SerialConsoleAccess, KeyPairs, Image, TagManagement, AccountScoping, VPCSubnetE2E, RouteTableValidation). Sequential bucket retains source-order singleton lifecycle (InstanceLaunch through FinalClusterStats).
2026-05-21 22:47:26 +10:00
Josh Siviour c89a24ab0c refactor(gateway): consolidate SigV4 verification via predastore/auth (#246)
* refactor: switch to aws sdk for sigv4 via predastore/auth

* refactor: properly verify against gateway region

* fix(gateway): revert gw.Region binding, surface SigMismatchError details

The gw.Region pin from 65c8444b causes every signed EC2/IAM/etc. request
to fail verification in CI: "auth: signature mismatch" on every health
probe, daemon never reports ready. Restoring sig.Region (pre-refactor
behaviour) unblocks the path while we identify whether nodeConfig.Region
diverges from the AWS CLI profile region, or whether the SDK signer's
canonical-request building differs from the client's.

Add SigMismatchError unwrap to the auth-failure log so operators see the
expected vs provided Authorization headers and can pinpoint the
divergence (credential scope, signed headers, signature) instead of just
the generic mismatch sentinel.

The supportedServices pre-Verify allowlist check stays — it's a
structural gate that doesn't depend on cross-config consistency.

* fix(gateway): re-enable gw.Region binding in SigV4 Verify

With predastore ab6ec8b stripping transport-added headers before
re-signing, the SigV4 verification path is no longer over-sensitive
to wire-vs-signed header divergence. The earlier "auth: signature
mismatch" CI failure was solely from Accept-Encoding/Content-Length
inflation of SignedHeaders — not from a region mismatch — so gw.Region
can be reinstated safely.

The diagnostic SigMismatchError unwrap stays as a permanent
operability improvement; future auth failures will still surface
expected vs provided Authorization headers in the warn log.

* fix: change predastore node ips to 127.0.0.1 (#244)

---------

Co-authored-by: brynmailer-mdc <bdm@mulgadc.com>
Co-authored-by: Julian Sommer <115516918+juliansommer@users.noreply.github.com>
2026-05-21 22:41:24 +10:00
Josh Siviour 92fa467c90 refactor(e2e/multinode): flatten spread + parallel bucket + drop phase numbers (#245)
* refactor(e2e/multinode): flatten phase 11 + parallelize bucket #1 + drop phase numbers

Split monolithic phase11_PlacementAndNATGateway (~430 lines, 8 inline
sub-steps) into 6 top-level Tests, each owning its own VPC graph via
t.Cleanup and layered setup helpers (spreadSetupVPC, spreadSetupBastion,
spreadSetupPrivateTrio, spreadSetupNATGW). The cleanup-ordering test
flips *bool latches as each explicit teardown step succeeds so the
LIFO unwind is a no-op on the success path.

Add t.Parallel() to read-only / independent Tests: ClusterHealth,
InstanceDistribution, GuestSSH, VPCNetworking. Deviates from the
mulga-siv-127 bead's bucket-#1 listing by keeping CrossNodeGateway
sequential (asserts a snapshot equality across gateways that the
parallel VPC test would break by churning instance count mid-assert)
and CrossNodeOps sequential (stops/starts trio[0], would race
GuestSSH). Both deviations documented in tests_test.go + plan doc.

Drop the bash phase numbering (phaseN_X funcs + "Phase N" log strings)
from the rest of the suite — 10 funcs renamed to runX. Phase numbers
were a port-time crutch; the Test names now describe each scenario and
phase numbers ageing into noise as bash is retired in Stage H.

* refactor(e2e/multinode): collapse spread Tests + control execution order

Two changes wedged together since they touch the same files:

1. Collapse the 6 top-level TestMultinodeSpread* Tests into a single
   TestMultinodeSpread with 6 t.Run sub-tests sharing one VPC + bastion +
   private trio + NAT GW setup chain. The 6-test split paid setup cost
   6× — bastion launch + private trio launch + NAT GW provision per Test
   pushed phase-11 wall-clock near 60min vs ~15min monolithic. Shared-
   setup layout keeps JUnit granularity (one row per sub-test) while
   restoring the monolithic budget. Setup helpers register t.Cleanup
   against the outer test so teardown runs once at the end (LIFO:
   NAT GW → trio → bastion → VPC).

2. Move the TestMultinodeSpread wrapper into tests_test.go so file-source
   order controls execution position. Previously the func lived in
   placement_nat_test.go which sorts before preflight_test.go and
   tests_test.go alphabetically — Spread ran FIRST in the sequential
   bucket, defeating Preflight's fail-fast on a sick cluster. Wrapper now
   runs after NodeRecovery so the cluster has been fully degrade-tested
   before Spread's 4-VM + NAT GW + custom-VPC graph spins up.
2026-05-21 20:53:59 +10:00
Josh Siviour bedc35344f feat(e2e/multinode) (#242)
* feat(e2e/harness): multinode cluster wait helpers + AWSClientForGateway (Stage A)

Stage A of mulga-siv-90. Compile-only; no tests wired yet.

multinode_cluster.go:
- Cluster.WaitNATSPeers polls /routez per node, dedupes by remote_id.
  want=2 on healthy 3-node, =1 after a node-down event.
- Cluster.WaitPredastoreHealthy / WaitGatewayHealthy probe HTTPS per node
  via insecureHTTPClient (multinode certs are self-signed across peers).
- Cluster.WaitDaemonReady runs DescribeInstanceTypes through every
  node's gateway, not just env.ServiceIPs[0] — catches a daemon that's
  only registered on one node.

multinode_aws_via.go:
- AWSClientForGateway clones *Env, swaps ServiceIPs to a single node's
  Addr, returns a fresh AWSClient. Lets phases 6/7 issue describe/
  stop/start across every node in turn (bash aws_via equivalent).

Init/Join helpers deferred to the pseudo-multinode bead (mulga-siv-91)
where they belong — real-multinode bootstrap is owned by tofu +
bootstrap-install.sh before the Go suite starts.

* feat(e2e/multinode): TestMultinodePreflight + TestMultinodeClusterHealth (Stage B)

Stage B of mulga-siv-90. First runnable Test* against a 3-node cluster.

multinode/main_test.go:
- Package-scoped singleton fixture, mirrors single/main_test.go.
- requireMultiNodeFixture skips when SPINIFEX_E2E unset or mode != multinode.
- Fixture = {Env, AWS, Harness, Cluster, Artifacts, TmpDir} — adds Cluster
  to the single-node shape via harness.ClusterFromEnv (reads SPINIFEX_NODES
  + SPINIFEX_SSH_USER/KEY).
- TestMain drains harness.Fixture.Close + removes TmpDir at process exit.

multinode/helpers_test.go:
- needAZ / needInstanceTypeArch / needAMI shims (memoized via harness).
- readyNodeCount substring counter — matches bash `grep -c "Ready"`.

multinode/tests_test.go:
- Top-level Test* wrappers (TestMultinodePreflight, TestMultinodeClusterHealth).

multinode/preflight_test.go (phase 1):
- /dev/kvm writable check (no /dev/kvm = test environment is wrong, fail fast).
- peer_ssh hostname on every remote node. Skips node[0] to match bash —
  the runner's loopback SSHd is not part of the contract.

multinode/cluster_health_test.go (phase 2):
- Cluster.WaitNATSPeers >= 2 per node.
- Cluster.WaitPredastoreHealthy / WaitGatewayHealthy across nodes.
- Cluster.WaitDaemonReady (DescribeInstanceTypes per gateway).
- spx get nodes shows N Ready, where N = cluster size.
- spx get vms returns (length only — content may be empty).

* feat(e2e/multinode): TestMultinodeInstanceDistribution + GuestSSH (Stage C)

Stage C of mulga-siv-90. Phase 3 (distribution) + phase 4 (guest SSH).

harness/multinode_distribution.go:
- InstanceHostingNode: SSH each peer, ps grep qemu-system | <id>.
  Reuses NewPeerSSH; returns nil on no match (caller decides fatality).
- CountInstancesPerNode + AssertSpreadAcrossNodes: fan-out + minNodes
  gate. Bash phase 3 warns-on-stacking; Go matches by accepting
  minNodes=1 + caller-side log.

harness/multinode_guest_ssh.go:
- GuestSSHPort polls hosting node's qemu cmdline for hostfwd=tcp:...
  reusing the existing hostfwdRE from ec2helpers.go (single regex, two
  capture groups: host + port).
- GuestSSHEndpoint prefers PublicIpAddress, falls back to hosting-node
  + hostfwd. Mirrors the phase 4 conditional.
- GuestSSHReady polls `echo ready` via SSH with bash-equivalent -o flags.

multinode/helpers_test.go:
- needKeyPair (EnsureKeyPair shim).
- needInstanceTrio: pkg-level sync.Once launches 3 nano instances on
  default VPC, registers terminate-on-process-exit. Phase 4+ reuse the
  same trio without re-launch.

multinode/distribution_test.go (phase 3):
- launch trio + assert >= 1 node hosting (log warn on stacking).
- spx get vms contains every trio ID.

multinode/guest_ssh_test.go (phase 4):
- For each trio instance: resolve endpoint, poll SSH ready, assert id
  reports ec2-user, log lsblk line count.

multinode/tests_test.go: TestMultinodeInstanceDistribution + TestMultinodeGuestSSH.

* feat(e2e/multinode): TestMultinode{Volume,CrossNodeGateway,CrossNodeOps} (Stage D)

Stage D of mulga-siv-90. Phases 5/6/7 — pure AWS API exercise (no
node-state churn).

volume_test.go (phase 5):
- Create 10GiB volume in discovered AZ, attach to trio[0] at /dev/sdf,
  detach, delete. Inline lifecycle — DeleteVolume in-test would race a
  registered Ensure* cleanup, surface as InvalidVolume.NotFound.

gateway_test.go (phase 6):
- Baseline DescribeInstances via the local gateway, then fan out
  AWSClientForGateway per non-local node. Require.Equal across all —
  cross-node fan-out regression surfaces immediately.

crossnode_ops_test.go (phase 7):
- Find trio[0]'s hosting node, stop via a different gateway, start via
  a third (or same other on 2-node fallback). Returns to running so
  later Test*s (phase 4 SSH, etc.) don't cascade-fail. otherTwoGateways
  mirrors the bash fallback chain.

* feat(e2e/multinode): TestMultinodeNode{Failure,Recovery} (Stage E)

Stage E of mulga-siv-90. Phase 8 (failure) + phase 9 (recovery) +
diagnostic primitives.

harness/multinode_node_lifecycle.go:
- StopNode SSHes `sudo systemctl stop spinifex.target`. Non-fatal on
  exit error (bash matches — cluster shutdown races the SSH conn).
- StartNode mirrors with sudo systemctl start.
- WaitNodeServiceReady polls a single node's HTTPS gateway. Distinct
  from cluster-wide WaitGatewayHealthy so a recovering node can be
  tracked without re-checking peers.

harness/multinode_diag.go:
- DumpAllNodeLogs fans out journalctl -u 'spinifex-*' per node into
  artifactsDir/journal-<node>.log. Tolerates per-node SSH failure.
- SpxGetNodesAcrossCluster strips trailing whitespace off SpxGetNodes.

multinode/node_failure_test.go (phase 8):
- Stop node2, register t.Cleanup restart safety net.
- WaitNATSPeers want=1 (degraded), DescribeInstanceTypes via node1+node3,
  DescribeInstances still returns the trio.

multinode/node_recovery_test.go (phase 9):
- StartNode node2 (idempotent — phase 9 in isolation still works).
- WaitNATSPeers want=2, WaitNodeServiceReady, spx get nodes 3 Ready,
  DescribeInstanceTypes via the recovered gateway.

* feat(e2e/multinode): TestMultinodeVPCNetworking (Stage F)

Ports phase 10 from run-multinode-e2e.sh. Stands up a fresh non-default
VPC + subnet (10.200.0.0/16 + 10.200.1.0/24), launches 3 instances into
the subnet, then asserts:

  - PrivateIpAddress is non-empty and matches 10.200.1.0/24
  - All 3 IPs are unique
  - SubnetId / VpcId on the instance match the freshly-created ids
  - IPs persist across stop -> start cycles

Independent of the package-level trio (which uses the default VPC) —
this phase needs full control over subnet allocation so resources are
created and cleaned up inline via t.Cleanup (LIFO: instances ->
subnet -> VPC).

* feat(e2e/multinode): TestMultinodePlacementAndNAT (Stage G)

Ports phase 11 from run-multinode-e2e.sh — the largest single phase
(~340 bash lines covering spread placement + NAT Gateway egress). Eight
sub-steps preserved with matching harness.Step markers so the Go run
cross-references cleanly with the bash log:

  Step 1: VPC + pub/priv subnets + IGW + main RT route + 2 SGs
          (bastion: tcp/22 anywhere; private: tcp/22 from bastion-sg,
           icmp from VPC)
  Step 2: bastion launch, wait SSH, scp key
  Step 3: spread placement group + 3 private instances via the group
  Step 4: validate spread (>=2 unique hosting nodes; >=3 = full PASS)
  Step 5: pre-NAT baseline — confirm NO internet from private VMs
  Step 6: allocate EIP + create NAT GW + private RT + 0.0.0.0/0 -> NGW
  Step 7: post-NAT — every private VM reaches 8.8.8.8 via NAT GW
  Step 8: inline teardown of NAT GW + private RT + EIP

Each resource is owned by the test (no Ensure* memoization) so a failure
mid-flight tears the full VPC graph down via t.Cleanup in LIFO order,
matching the bash teardown sequence. NAT GW state polling is duplicated
locally (waitForNATGatewayStateFatal/Best); the equivalents in
single/natgw_test.go are unexported. Promote to harness/poll.go once a
third consumer lands.

* ci(e2e-go): add multi-node Go E2E job + cert/lb on 3-node cluster (Stage H)

Adds e2e_go_multinode as a parallel job alongside e2e_go_single on the
e2e-go workflow. The single-node job is untouched apart from extending
its SUITES filter to drop the multinode suite (which only runs on the
ci-multi cluster).

What the new job does:

  - Provisions a 3-node tofu cluster via the same build-install-artifacts
    + bootstrap-install path as e2e_real_multi (bash).
  - Builds multinode.test / cert.test / lb.test on the runner, ships
    them to node1.
  - Runs each suite with SPINIFEX_MODE=multinode and the cluster csv
    in SPINIFEX_NODES / SPINIFEX_NODE_IPS. cert / lb branch internally
    on env.Mode so the same binaries cover both single + multi.
  - Pulls per-node journals (spinifex-journal-node1..N.log) for the
    e2e-analyze action so a node2/3 failure isn't blind.
  - Same JUnit + analyze + artifact-upload + status pipeline as the
    single-node job.

Other moves:

  - Default SUITES now includes e2e-multinode so push triggers fan
    out across single + multi automatically. e2e_go_reboot stays
    explicit-opt-in (unchanged).
  - tests/e2e/Makefile: e2e-multinode now sets MODE=multinode so
    `make e2e-multinode` doesn't fall back to single (which would
    skip every TestMultinode* on the SPINIFEX_MODE check).
  - tests/e2e/TEST_COVERAGE.md: cross-reference the Go port at the
    head of the Real Multi-Node section. The bash driver stays in
    place as executable spec until the Go run is soak-tested.
  - e2e_go_status now needs e2e_go_multinode; multi=skipped counts
    as success so a single-only dispatch still posts green.

* fix(e2e/multinode): NATS routez via peer_ssh + StartNode timeout + spx best-effort

CI run 26161792146 surfaced three regressions in the freshly-landed
multinode suite:

  1. WaitNATSPeers dialled node.Addr:8222 directly. NATS monitor binds
     127.0.0.1:8222 only (cmd/spinifex/cmd/templates/nats.conf:65), so
     every routez poll returned "connection refused". Switched to
     peer_ssh + curl http://127.0.0.1:8222/routez per node — matches
     the bash run-multinode-e2e.sh phase 2 pattern.

  2. StartNode / StopNode capped ssh at a 30s context. spinifex.target
     has 6+ dependent units (predastore, NATS, awsgw, vpcd, daemon, ui)
     and a clean systemctl start runs 60-90s; the 30s budget was
     killing ssh mid-command ("signal: killed"). Bump StartNode to 5min
     and StopNode to 3min.

  3. SpxGetVMs is best-effort in bash (`spx get vms --timeout 5s
     2>/dev/null` with no exit-code check) because the CLI's NATS dial
     can race the cluster join shortly after bootstrap. The Go port
     fatal'd on non-zero exit and broke TestMultinodeClusterHealth +
     TestMultinodeInstanceDistribution downstream. Added
     harness.SpxRunBestEffort and rewired both call sites.

* fix(e2e/multinode): spx CLI lenience + trio capacity retry + diag

Run 26163994815 cascaded from two issues that bash silently tolerated.
The Go port hard-failed where bash counts the failure and continues, so
3+ tests cascaded off transient first-failures.

- spx get nodes: cluster_health + node_recovery now downgrade <Ready
  count to WARN with SpxRunBestEffort, matching bash phase 2
  fail_test counter and phase 9 `|| echo ""`. Gateway+daemon health
  checks above already prove end-to-end NATS routing.
- needInstanceTrio: retry RunInstances up to 6× on
  InsufficientInstanceCapacity (10s backoff). Daemon resourceMgr can
  briefly report 0 capacity right after node bootstrap before its
  first inventory scan completes — AWS itself surfaces this
  transiently. Stagger 2s between launches to match bash phase 3.
- CI workflow: new "Pre-flight diagnostics on node1" step dumps
  groups/perms/spx output/systemctl status/journal tail to
  preflight-diag.log so next run's artifacts surface what tf-user
  sees before the test binary starts.

* fix(e2e): strip SPINIFEX_* from spx child env

Root cause of `spx get nodes` returning "nats: no servers available for
connection" in the multinode Go suite (mulga-siv-90 runs 26163994815,
26195522455): spx CLI calls viper.AutomaticEnv() with prefix "SPINIFEX"
inside config.LoadConfig. The CI workflow exports SPINIFEX_NODES (csv
of cluster WAN IPs) so harness.ClusterFromEnv can build the *Cluster.
viper picks SPINIFEX_NODES up as the `nodes` config key and silently
clobbers the loaded `[nodes.*]` map → nodeConfig.NATS.Host = "" →
nats.Connect("") → ErrNoServers.

Bash never set SPINIFEX_NODES so the bash port was unaffected. Direct
ssh `spx get nodes` from the runner also worked because the env was
clean. Only the test-binary-spawned spx hit the collision.

Fix: SpxRun / SpxRunBestEffort build a sanitized child env that
preserves the small SPINIFEX_* set spx's root command explicitly
BindEnvs (config path, AWS keys, host, base-dir, NATS dial overrides)
and drops everything else.

* fix(vpcd): make handleAddNAT idempotent (mulga-siv-124)

PR #232 (commit 5887927) added a second vpc.add-nat publish in
daemon/vm_adapters.go:onInstanceUpHook to cover instance recovery.
The publish fires on every instance up event including fresh launches,
racing the original publish from handlers/ec2/instance/service_impl.go.

Both publishes are queue-grouped to "vpcd-workers" so two cluster vpcd
instances handle them ~13s apart. handleAddNAT's existing
DeleteAllNATsByExternalIP -> AddNAT sequence is not idempotent under
concurrent writers: the second invocation deletes the rule the first
installed (after ovn-northd compiled SB flows and chassis installed
them), creating a flow-install gap during which the EIP is unreachable.

Symptom: TestMultinodePlacementAndNAT fails with ssh: Connection timed
out to the bastion EIP for 150s (mulga-siv-90 CI runs 26195522455,
26197248656). Bash port last green on e2e-baremetal run 26008057481
before #232 merged.

Fix: before delete+add, look up the existing rule via
FindNATByExternalIP. If it exists and its LogicalIP + spinifex:vpc_id
ExternalID (plus ExternalMAC + LogicalPort under distributed-NAT mode)
already match the target, no-op. Falls through to the existing
delete-then-add for the IP-reuse-across-VPCs case the original code
already handled.

Adds TestTopologyHandler_AddNAT_Idempotent covering the UUID-witness +
flow-barrier counter (two adds with identical state must not recreate
the row or re-arm the barrier).

* test(e2e/multinode): bump bastion SSH wait + ICMP probe

Run 26198221557 confirmed the vpcd dnat_and_snat race (mulga-siv-124)
was fixed — no "cleaned up stale NAT" lines for the bastion EIP, only
one writer + an idempotent skip. But TestMultinodePlacementAndNAT
still timed out at exactly 150s on SSH to the bastion's public IP,
suggesting a separate OVN flow-install lag on the freshly-created
VPC's external switch.

- Bump waitForBastionSSH budget 150s -> 5min. Default-VPC GuestSSH
  reaches sshd in ~70s; a brand-new VPC needs longer for ovn-northd
  to compile SB + every chassis to install the external ARP
  responder + dnat_and_snat flow. 5min covers cloud-init + OVN warm.
- Add an ICMP probe per iteration. Pure diagnostic — ping failure
  does not fail the step, but the next iteration's SSH error
  carries ping_output so the log splits "datapath down" (ping fail)
  from "sshd not up" (ping ok, ssh refused/timeout). Matches the
  RCA agent's fix candidate 3.

* test(e2e/multinode): dump cluster datapath state on phase 11 failure

waitForBastionSSH currently fails opaquely when the EIP datapath is
broken — the log shows "ping=fail" but doesn't pin which node owns the
gw chassis or whether ARP / OVN NAT rules / chassis encap IPs are sane.

Register a t.Cleanup hook (after bastionPubIP is known) that fans out
arp / ip route / ovs-vsctl / ovn-sbctl / ovn-nbctl probes to every
cluster node and writes results to <Artifacts>/diag-<node>-<probe>.log
on failure only. Plus a local-side ping / traceroute / ip route block
so a future post-mortem can distinguish runner-network breakage from
cluster datapath breakage.

Best-effort: per-node SSH errors log but never re-fail the test.

* test(e2e/multinode): use IpPermissions form for SG authorize calls

Two AuthorizeSecurityGroupIngress calls in phase 11 used the legacy flat
form (top-level IpProtocol/FromPort/ToPort/CidrIp) instead of the
IpPermissions array. The Spinifex VPC handler at
spinifex/handlers/ec2/vpc/security_group.go:667 reads only
input.IpPermissions and silently drops the flat fields — newRules=0,
no error returned.

Run 26200626265 journal:
  groupId=sg-922de57bc74b85c9c (natgw-bastion) newRules=0
  groupId=sg-758b05b4a5e7161fd (natgw-private) newRules=1

Bastion SG had zero ingress rules; TCP SYN to bastion :22 was dropped
by ACL, causing waitForBastionSSH to time out after 5min.

Bash version passes because AWS CLI v2 normalizes --protocol/--port/--cidr
shorthand into IpPermissions.N.* on the wire.

Fix: re-shape both calls (bastion tcp/22, private icmp) to use
IpPermissions, matching the third authorize call in the same function
(line 163-175) that already works.

Server-side fix to honor flat form follows in a separate bead.
2026-05-21 15:40:00 +10:00
Julian Sommer 6d650f6c3a fix: actually fix predastore integration test 2026-05-21 15:13:14 +10:00
Julian Sommer f374ab41c5 fix: predastore integration test 2026-05-21 14:28:27 +10:00
Julian Sommer 0ff911fdbc fix: change predastore node ips to 127.0.0.1 (#244) 2026-05-21 13:46:02 +10:00
Julian Sommer a33b91553d feat: TLS 1.3 minimum + PQ encryption (#243) 2026-05-21 11:52:41 +10:00
Josh Siviour 050ac8f861 feat(e2e): Go E2E harness + single-node migration to Ensure*/Discover* + reboot suite (#241)
* feat(e2e): add Go test harness + cert scenario, port run-cert-e2e.sh

Lands step 1 of docs/development/improvements/e2e-go-harness.md:
shared harness primitives (env, wait, artifacts, cert, tls) plus the
TestCertIssuance scenario that supersedes run-cert-e2e.sh. All files
gated by //go:build e2e so default builds and `make preflight` are
unaffected. Bash script stays in place for one CI cycle before deletion.

* feat(e2e): port run-lb-e2e.sh to TestLoadBalancer Go scenario

Step 3 of docs/development/improvements/e2e-go-harness.md. Replaces 1246
lines of bash with a typed Go scenario exercising all 4 LB variants
(ALB/NLB × internet-facing/internal) against a shared VPC + app instance
pool. Internet-facing phase auto-skips when no peer node is configured;
internal phase always runs.

Adds shared harness primitives:
- harness/aws.go: AWS SDK client factory wired to Spinifex AWS gateway
- harness/lb.go: WaitForLBActive, WaitForTargetsHealthy, WaitForENICleanup,
  WaitForInstanceRunning/Terminated, InstanceENI
- harness/traffic.go: HTTPRoundRobin, TCPRoundRobin, distribution asserts
- harness/ssh.go: minimal peer SSH wrapper shelling out to /usr/bin/ssh
  (replaced by typed x/crypto/ssh client hoisted from DDIL in mulga-siv-74)

User-data scripts embedded via //go:embed under lb/testdata/.

Bash script stays in place for one CI cycle before deletion.

* fix(e2e): log client launch params + dump daemon log on InvalidInstanceType

Adds diagnostics for the LB internal client launch path. Logs the exact
InstanceType, AMI, subnet, key name, and user-data size right before
RunInstances, and registers a daemon-log dump on failure so we can
correlate the 400 InvalidInstanceType with what spinifex saw.

* refactor(e2e): serialise LB suites + share probe client across internal tests

Splits TestLoadBalancer into 4 sequential subtests (ALB/NLB ×
internet-facing/internal). Each suite creates its own TG + LB + listener,
runs its traffic test, and tears down before the next starts. Replaces
the per-suite throwaway client VM with a single long-lived probe server
launched at fixture setup; per-suite internal traffic tests GET
/probe?ip=...&proto=... against it instead of cold-booting a fresh client.

Drops peak instance count from 5 to 4 (2 app + 1 LB + 1 client), so the
internal phase passes on capacity-constrained dev nodes that previously
hit InvalidInstanceType (see mulga-siv-76, mulga-siv-77 for the underlying
spinifex bugs).

* feat(daemon): make hostReserve env-configurable for dev capacity squeeze

Adds SPINIFEX_RESERVED_VCPU and SPINIFEX_RESERVED_MEM_GB env-var overrides
read at daemon startup. Invalid values are logged and ignored so a typo
doesn't silently widen the reserve. Unblocks the LB E2E on capacity-tight
dev hosts (8 vCPU box: default 2-vCPU reserve + 3x t3.nano test load
leaves 0 free for the sys.micro LB VM). Lays groundwork for the planned
operator-tunable [capacity] config.

bead: mulga-siv-78

* fix(e2e): revert LB to per-suite client + fail-fast on terminal LB state

Reverts the shared probe-server VM that wouldn't come up reliably during
the InternalFacing suites — back to the proven bash pattern of launching
a fresh client VM per suite (one of ALB_IP / NLB_IP populated based on
the suite kind). Adds renderClientUserData + a conditional template so
each client probes only its target LB.

WaitForLBActive now bails immediately on terminal LB states (failed,
provisioning_failed) and emits a "still waiting" log line every 30s so
hangs are visible instead of silently burning the full 5-minute timeout.

* ci(e2e): add e2e-go.yml workflow + GOTESTSUM Makefile opt-in

Standalone workflow runs the new Go-based E2E harness on the existing
ci-single self-hosted runner — provisions a tofu VM, bootstraps spinifex,
installs gotestsum, runs each requested suite via the e2e Makefile, and
uploads per-test artifacts + JUnit XML + JSON streams as a build artifact.
Test summary parses the JUnit and emits ::error file=...:: annotations so
failures surface in the PR diff.

Gated to the feat/e2e-go-harness branch + workflow_dispatch so it doesn't
interfere with the existing e2e.yml while the Go conversion lands.

tests/e2e/Makefile gains a GOTESTSUM=1 opt-in: when set, the e2e target
runs gotestsum with --junitfile and --jsonfile rooted in ARTIFACT_DIR
under a suite-tagged filename derived from the first PKG component.

* feat(e2e): share probe client across both internal LB suites + SG fixes

- Replaces per-suite probe client with a single shared VM launched in the
  fixture. Saves ~60s by avoiding a cold boot between the ALB and NLB
  internal suites.
- Client cloud-init exposes a JSON trigger endpoint on :9090 in addition
  to the http.server on :80 (results dir). Each suite POSTs
  {proto, ip, count, outfile, http_port, tcp_port} to run a probe loop
  against the LB private IP and writes the result file under /tmp/httpd
  for the driver to fetch back.
- configureDefaultSG now uses the structured IpPermissions form (top-level
  shortcut is silently ignored by vpcd — tracked separately) and opens
  port 9090 in addition to 80 + 9000.
- deleteLB captures the underlying sys.micro VM id via the LB ENI before
  delete, then polls DescribeLoadBalancers until LoadBalancerNotFound so
  the next suite doesn't race the capacity reclaim.
- Internal_NLB sleeps 15s up front to let the ALB sys.micro deallocate
  fully before its own createLB fires. DescribeLoadBalancers can report
  the ALB gone before the vCPU/memory allocation is actually released on
  capacity-tight dev hosts; without this settle, NLB's createLB trips the
  sys.micro reserve and lands in terminal state=failed. Still cheaper
  than cold-booting a per-suite client.

* ci(e2e): install Go on tofu VM before running Go E2E suites

The tofu-provisioned VM ships with the spx binary and bash test scripts
but no go toolchain, so the prior workflow's "Install gotestsum on VM"
step failed with `bash: line 2: go: command not found`. Add a curl-based
Go 1.26.3 install (matching go.mod) ahead of the gotestsum install and
update the test step's PATH so make e2e-* runs the freshly-installed
go binary.

* ci(e2e): bump gotestsum to @latest for Go 1.26 compatibility

gotestsum v1.12.0 pins golang.org/x/tools v0.19.0 which fails to compile
under Go 1.26 with:
  invalid array length -delta * delta (constant -256 of type int64)
Switching to @latest pulls a newer x/tools that builds clean.

* ci(e2e): ship spinifex source to VM so make -C tests/e2e can run there

Single-node tofu VM only has the spx binary + bash test bundle at
~/e2e-tests; the Go e2e workflow was running 'make -C tests/e2e e2e-cert'
from $HOME/Development/mulga/spinifex which doesn't exist, producing:
  make: *** No rule to make target 'e2e-cert'.  Stop.

Add a checkout of the spinifex repo on the runner and rsync it (minus
.git, build/, bin/, node_modules) to the VM as
~/Development/mulga/spinifex before installing Go + gotestsum.

Interim solution; mulga-siv-80 tracks replacing the rsync with shipping
pre-built test binaries from the runner.

* ci(e2e-go): replace rsync with tar-over-ssh; VM lacks rsync

bootstrap-install.sh ships a minimal binary VM with no rsync; the
ship-source and collect-artifacts steps failed with
'rsync: command not found'. Stream a tarball over ssh instead — same
exclude list, no extra dependency on the VM.

* fix(e2e): discover single-node IPs + awsgw bind in LoadEnv

run-cert-e2e.sh single mode probes awsgw.host from spinifex.toml and
adds every non-loopback global IPv4 to the node list. The Go harness
defaulted to 127.0.0.1 only, so CI on a tofu VM (awsgw bound to the
LAN IP, not loopback) saw 'connection refused' on every TLS check.

Port the same discovery: ModeSingle now walks net.InterfaceAddrs for
NodeIPs and grep-parses spinifex.toml for the awsgw bind. Both stay
overridable via SPINIFEX_NODE_IPS / SPINIFEX_SERVICE_IPS.

* ci(e2e-go): pre-build test binaries on runner; drop go toolchain from VM

Replaces the source-tree + go + gotestsum payload that the e2e-go.yml
workflow previously shipped to the tofu CI VM with self-contained
test binaries built on the runner via `go test -c`. testdata for the
LB suite is already //go:embed'd into the binaries, so the bundle on
the VM is just `<suite>.test` files (cert: 14M, lb: 17M uncompressed).

Workflow changes
- actions/setup-go on runner.
- New step builds binaries via `make e2e-build SUITES_BUILD=...`.
- Install go-junit-report on runner; convert each VM-side test log to
  junit-<suite>.xml after artifact pull so "Publish test summary" still
  parses JUnit XML.
- Drop "Install Go + gotestsum on VM" entirely (saves ~60s + network).
- Drop source-shipping rsync/tar (saves ~30s + a lot of bandwidth).
- VM step runs `./<suite>.test -test.v -test.timeout=30m` directly.

Makefile changes
- New `e2e-build` target compiles each `SUITES_BUILD` entry into
  `BIN_DIR/<suite>.test` via `go test -c -tags=e2e`. `clean` also
  removes BIN_DIR.

Closes mulga-siv-80.

* ci(e2e-go): disable setup-go cache; upload dominates self-hosted run time

* refactor(e2e): hoist generic DDIL harness primitives to shared tests/e2e/harness

Step 1.5 of docs/development/improvements/e2e-go-harness.md. Promotes
Cluster/Node/SSH/DaemonClient/LocalStateRevision/ResetAllNodes/Witness
into the shared package so the LB, multinode, and BM scenarios in
steps 4-5 can build on the same primitives instead of duplicating SSH
+ cluster plumbing.

Moves
- ddil/harness/{cluster,ssh,daemon_client,state,reset,witness}.go
  -> harness/ (plus the witness testdata embed dir).
- ddil/harness/{fault,netem,profiles,snapshot}.go -> ddil/fault/ as a
  new package; fault.PartitionNode/HealNode/KillNATS/StartNATS/
  RestartDaemonOnly/ApplyNetem/ClearNetem/LinkProfile*/TakeSnapshot
  now take harness.Cluster / harness.Node / harness.SSH explicitly.
- ddil/harness/retry.go::Run retry-with-reset extracted as
  harness.RetryWithReset in harness/wait.go; ddil/harness/quarantine.go
  keeps the DDIL-only quarantine wrapper that calls into it.
- WaitForMode moved from fault.go to harness/daemon_client.go since
  it polls daemon state and has no fault-injection concern.
- The pre-hoist harness/ssh.go (PeerSSH, subprocess ssh) is preserved
  as harness/peer_ssh.go so the lb_test suite keeps working unchanged;
  the typed x/crypto/ssh client from DDIL now owns harness/ssh.go.
- New harness/util.go exports ShellQuote (was unexported shellQuote in
  netem.go) so both ddil/fault and reset.go/witness.go can use it.

Env var rename
- SPINIFEX_NODES / SPINIFEX_SSH_USER / SPINIFEX_SSH_KEY replace
  DDIL_NODES / DDIL_SSH_USER / DDIL_SSH_KEY. Old names still honoured
  with a stderr deprecation note (envWithLegacy) so the DDIL CI keeps
  passing until .github/workflows/e2e-ddil.yml drops the old names.
  DDIL_QUARANTINED stays DDIL-specific.

DDIL parity preserved — scenarios still call ddilh.Run for quarantine
semantics; the underlying retry-with-reset is the same code path,
just shared.

Closes mulga-siv-74.

* fix(e2e): open default SG :22 ingress via structured form before witness launch

The daemon currently drops the top-level shortcut AuthorizeSecurityGroupIngress
form silently (mulga-siv-79), so the default SG starts with no inbound :22 and
the witness EIP awaitBaseline tunnel times out under DDIL CI. Mirror the
workaround already present in lb_test by issuing the structured IpPermissions
form before LaunchWitnessVM. Idempotent on InvalidPermission.Duplicate so reruns
in the same cluster are safe.

* ci: render JUnit into job summary + collapsible groups in noisy steps

Add a reusable composite action (.github/actions/junit-report) that parses
a glob of JUnit XML files, writes a totals table + collapsible per-failure
details into $GITHUB_STEP_SUMMARY, and emits one ::error annotation per
failed/errored test case so failures surface in the run header instead of
being buried in step logs.

Apply the action to both e2e-go.yml and e2e-ddil.yml (e2e-ddil gotestsum
now also emits --junitfile so the action has input).

Same workflows also wrap noisy phases (tofu apply/destroy, scrub-env,
tar-over-ssh, per-suite test runs, cluster-log collection) in
::group::/::endgroup:: markers so the Actions UI collapses the chatter
by default and the human can expand only the failing phase. Tofu output
still tees to $TOFU_LOG so the artifact bundle is unchanged.

Future workflows (multinode, baremetal) can adopt the same pattern via
the README in .github/actions/junit-report.

* test(ddil): quarantine Scenario C until predastore §2 lands

CI run 26016840819 surfaced that ScenarioC_CleanPartition silently
depends on predastore-ddil-hardening §2 (write-quorum with deferred
parity repair). Root cause from node3 daemon log:

  predastore: write udp ...->192.168.0.22:9991: sendmsg: operation not permitted
  predastore: putObjectViaQUIC: put failed
  spx QMP: BLOCK_IO_ERROR instanceId=<witness>
  spx: StopOrTerminateInstance: Terminating
  spx: Instance state transition running → shutting-down

The 'sendmsg: operation not permitted' is the EPERM Linux returns when
iptables drops an outbound packet — exactly the OUTPUT DROP rules
PartitionNode installs. The witness VM's block I/O is backed by
distributed predastore, so the same partition that severs node3↔peer
NATS traffic also severs the witness disk's cross-shard QUIC writes.
QEMU power-downs the guest after BLOCK_IO_ERROR, the daemon's
reconciler terminates the instance, and the next AssertProgressed dials
a sshd that's already gone (hence the 'handshake failed: EOF' the test
reports).

Quarantine by collapsing C to the same scenarioSkip stub D/E/F already
use, citing predastore-ddil-hardening §2 as the dependency. Flip C
back to ENABLED in the same PR that lands §2 — that's when the witness
disk can survive a peer partition without QEMU halting.

TEST_COVERAGE.md row C flipped ENABLED → SKIPPED; added a quarantine
note explaining the predastore dependency.

* ci: colored phase banners + quiet-on-success tofu output

Pretty up the live Actions log without flooding it. Three changes:

1. .github/scripts/ci-fmt.sh — sourceable bash helpers (banner, ok, bad,
   warn, note, quiet_run) using ANSI colors that the Actions UI renders.
2. e2e-go.yml + e2e-ddil.yml — tofu phases switch back to quiet redirect
   into $TOFU_LOG (the previous tee'd version regressed live noise vs
   the original silent redirect). Each phase now prints one colored
   ok/✗ line; on failure the tail of $TOFU_LOG dumps inside a single
   collapsed group, so the live log stays scannable but the failing tail
   is one click away.
3. junit-report render.py — compact summary: short status icons (✅/❌)
   in a single column, no separate err column, single-line failure list
   (truncated to 120 chars) instead of <details> blocks that wrap badly
   on narrow screens.

Per-suite test runs still wrap in ::group:: with an inline cyan banner
so pass output collapses and the suite name is obvious when expanded.

* test(e2e): add harness.Phase / Step / Detail for human-readable test output

Test fixtures were emitting flat walls of t.Logf with every line prefixed
by the same `lb_test.go:61:` (file:line of the call site that the
t.Helper() chain bubbles up to) and forced 6-space indent. Hard to skim,
hard to find where one phase ends and the next begins.

Add three helpers in tests/e2e/harness/log.go:

  Phase(t, fmt, ...)   — flush-left colored banner via fmt.Println.
                         Bypasses t.Logf so the line lands without the
                         testing framework's indent/file:line prefix.
                         Pure visual structure; the diagnostic detail
                         stays on t.Logf.
  Step(t, fmt, ...)    — single-line sub-phase marker via t.Logf.
                         Keeps file:line attribution so a triager can
                         find the source of a progress message.
  Detail(t, k, v, ...) — key=value resource IDs via t.Logf. Same
                         attribution rationale as Step — IDs are
                         diagnostic and want to land in JUnit XML
                         bucketed with the owning test.

Why the split: Phase is purely a banner; losing JUnit attribution costs
nothing because banners aren't diagnostic. Step/Detail carry triage
context (instance IDs, AMI, etc.) and a failing test's JUnit testcase
node should still contain them, so t.Logf attribution is kept.

ANSI colors are auto-disabled when stdout isn't a TTY and GITHUB_ACTIONS
isn't set, so piped local `go test -v` stays clean.

Apply to lb_test setupSharedFixture — five Phase banners replace the
implicit run of helper logs ("Discovering cluster capacity",
"Ensuring SSH key pair", "Creating shared VPC topology",
"Launching app instances", "Launching probe client") so the live CI log
reads as a story rather than a flat resource dump.

* ci(e2e-go): strip indent + file:line on display, propagate color env over SSH

Two display bugs spotted on the previous run:

1. Phase banners came out white instead of bold-cyan because the test
   binary runs over SSH on the tofu VM, where neither GITHUB_ACTIONS
   nor a TTY is present, so harness.log.go init() disables ANSI escapes.
   Fix: export GITHUB_ACTIONS=true and FORCE_COLOR=1 in the remote env
   so the helper keeps emitting color codes.

2. Every t.Logf line still rendered as
       lb_test.go:61: instance type: t3.nano
   (4-space indent + `<file>:<line>:` prefix) because t.Helper()
   bubbles file:line to the call site. Fix: pipe the test binary's
   stdout through `sed -E 's/^[[:space:]]+([a-zA-Z0-9_]+_test\.go:[0-9]+: )?//'`
   so the display strips both. Tee to the artifact log happens BEFORE
   the sed so raw output is preserved for triage; only the live UI
   sees the cleaned form.

* feat(e2e-harness): extend harness for single-node Go suite port

Stage A of mulga-siv-89 — add the primitives the single-node run-e2e.sh
port consumes:

- AWSClient gains IAM + STS service handles, plus NewAWSClientWithCreds
  for per-tenant account-scoping in Phase 8.
- awserror.go: ErrorCodeIs, AssertAWSError, ExpectError — Go equivalent
  of the bash expect_error helper used throughout the negative phases.
- poll.go: WaitForInstance/Volume/Snapshot/Image state to any target,
  with per-resource default timeouts tuned from the bash sleep loops.
- spxcli.go: thin shellouts for spx get nodes/vms, spx top nodes,
  spx admin account create with output parsing for tenant-id/access-key.
- rawhttp.go: signed raw POST against the AWS gateway for the Phase 8
  InvalidAction probe (gateway rejects on auth before action parsing).
- ovn.go: OvnNbctl/OvnSbctl wrappers + SkipIfNoOVN for Phase 8b-e
  datapath checks; WaitForPortGroupMember handles propagation polls.
- ec2helpers.go: InstancePublicSSHHost (public-IP-or-qemu-hostfwd),
  LsblkRootGiB, serial-console-access trio, DiscoverDefaultVPC,
  idempotent AuthorizeSSHIngress.
- account.go: AccountCarousel maps tenant name -> scoped AWSClient,
  built atop NewAWSClientWithCreds for the Phase 8 isolation tests.

Pure groundwork — no new tests, no production behaviour change. The
cert + lb scenarios still compile against the extended harness.

* ci(e2e-go): auto-cancel + reap orphans to prevent runner wedge

A cancelled run on the single-runner ci-single host has been leaving
orphan tofu/terraform procs (and stale tfstate locks) behind, which
caused the next push's tofu destroy to hang for 45m+ and saturate the
runner host until sshd refused new connections.

- Add `concurrency: e2e-go-${ref}, cancel-in-progress: true` so a new
  push to feat/e2e-go-harness cancels the in-flight run instead of
  stacking on the same self-hosted runner.
- New `Reap Orphan Procs` step at job start: SIGTERM then SIGKILL any
  lingering `tofu`/`terraform` from the runner user, and unlink any
  stale `.terraform.tfstate.lock.info`.
- Harden `Teardown`: drop `set -e` (scrub-env must always run as the
  libvirt-level fallback), reap orphans before destroy, and wrap
  `tofu destroy` in `timeout 600` so a wedged provider can't pin the
  job to its 45m timeout.

* ci(e2e-go): drop push trigger, manual dispatch only

Pull the on.push branch trigger so the workflow only fires from
workflow_dispatch — matches the e2e-ddil.yml Phase-1 pattern. Push
trigger lifts again once the single-node Go port (mulga-siv-89) is
green end-to-end and run-e2e.sh is retired.

* test(e2e-single): port run-e2e.sh phases 1-4 to Go

Stage B of mulga-siv-89. Lands the TestSingleNode orchestrator + Fixture
that threads state between sequential phase subtests, plus phases:

- Phase 1: KVM check, AWS gateway TLS reachability, daemon NATS
  readiness polling, region/AZ smoke.
- Phase 1b: spx get nodes / top nodes / get vms (single-node only).
- Phase 2: nano instance-type + arch discovery.
- Phase 2b: serial-console-access default/enable/disable round-trips.
- Phase 3: create-key-pair + import-key-pair lifecycle; test-key-1 PEM
  kept for Phase 5 instance launch (cleanup deferred to teardown phase
  in stage G).
- Phase 4: architecture-aware AMI discovery via name filter
  ami-ubuntu-24.04-{x86_64,arm64}, falls back from spx import output.

Phase ordering is contractual (Phase 5+ depend on AMI, key pair, AZ).
detectPoolMode reads [network] external_mode from spinifex.toml so
stages F/G can skip pool-only phases on dev_networking fixtures.

No production code changed. cert + lb scenarios continue to compile.

* test(e2e-single): port run-e2e.sh phases 5-5e to Go

Stage C of mulga-siv-89 — the instance/volume/snapshot/image lifecycle
that everything else hangs off:

- Phase 5: run-instances (default VPC + default SG with tcp/22 opened
  via idempotent harness.AuthorizeSSHIngress), wait running, resolve
  root volume from BlockDeviceMappings.
- Phase 5a-pre: spx get vms now contains the running instance.
- Phase 5a: describe-instances metadata round-trip (type/key/AMI/BDMs).
- Phase 5a-ii: TCP wait, ssh id, lsblk cross-check vs API root volume
  size (±1 GiB tolerance — lsblk rounds down), soft hostname check.
- Phase 5a-iii: get-console-output round-trip on InstanceId.
- Phase 5b: create-volume (10 GiB) → modify-volume (20 GiB) →
  attach-volume → detach-volume → delete-volume, polling each state
  transition through harness.WaitForVolumeState.
- Phase 5b-ii: describe-volume-status on the root volume.
- Phase 5c: create-snapshot → wait completed → copy-snapshot → both
  visible → delete original → copy survives → delete copy.
- Phase 5d: AMI snapshot-backed launch verification via
  DescribeImages.BlockDeviceMappings[].Ebs.SnapshotId. Predastore-side
  config.json reads are TODO (no S3 client in harness yet).
- Phase 5e: create-image --no-reboot, wait available, capture backing
  snapshot ID for Phase 9 cleanup.

Phase 7a (attach to stopped → error) stubbed in volume_test.go for
Stage D to fill once the lifecycle stop/start lands.

* test(e2e-single): port run-e2e.sh phases 5f-8 to Go

Stage D of the single-node Go port (mulga-siv-89). Adds four files
under tests/e2e/single/ covering Phase 5f (SG egress ACL enforcement),
Phase 6 (tag CRUD), Phases 7 / 7a / 7b / 7c-pre / 7c (stop, start,
attach-to-stopped negative, ModifyInstanceAttribute, reboot, multi-count
RunInstances), and Phase 8 negatives (18 sub-tests asserting AWS error
codes via harness.ExpectError / AssertAWSError / PostAWSAction).

Wired into TestSingleNode in main_test.go; phase ordering preserved
because Phase 7 chain mutates the shared instance state (stopped at end
of 7, .small after 7b, running after 7c-pre, untouched by 7c siblings)
and Phase 8 negatives need a running instance with the AMI/key created
earlier.

Removes the placeholder phase7a_AttachToStoppedError stub from
volume_test.go (real impl now in lifecycle_test.go).

run-e2e.sh stays in tree until step-7 cleanup per the parent plan.

* ci(e2e-go): wire e2e-single target + add single to build set

Lets the in-progress single-node Go port (mulga-siv-89, Stages A-D
landed) be exercised in CI via explicit `suites: e2e-single` dispatch
input ahead of Stage G flipping the workflow default.

Workflow default suites unchanged until Phase 9 teardown lands.

* test(e2e-single): port run-e2e.sh IAM 1-7 + Phase 8 account scoping to Go

Stage E of the single-node Go port (mulga-siv-89). Adds two files
under tests/e2e/single/:

- iam_test.go (903 lines): IAM Phases 1-7 — User CRUD, Access Key
  Lifecycle, User Authentication (via harness.NewAWSClientWithCreds),
  Policy CRUD (+ CreatePolicyVersion / SetDefaultPolicyVersion /
  DeletePolicyVersion which bash lacks), Policy Attachment &
  Enforcement (allow / deny / wildcard / root bypass), Policy Lifecycle
  (DeleteConflict while attached), and idempotent Cleanup.

- account_test.go (1186 lines): Phase 8 EC2 Account Scoping — 11 Steps
  (Account Setup, Instance / Volume / KeyPair / Snapshot / VPC-Subnet /
  IGW-EIGW Scoping, Account Settings, Global Resources, Edge Cases,
  Cleanup). Uses harness.AccountCarousel for the multi-profile
  credential map and SpxAdminAccountCreate for admin account
  bootstrap.

Fixture extended with IAM cross-phase state (IAMAdminAccount,
IAM{Alice,Bob,Charlie}{KeyID,Secret}) so Phase 7 cleanup is defensive
even when an intermediate IAM phase short-circuits. All 11 Phase 8
Steps share local state in the outer function; t.Cleanup mirrors
Step 11 LIFO so a mid-Step failure still tears down accounts.

Wired into TestSingleNode. run-e2e.sh stays in tree until step-7
cleanup per parent plan.

* fix(e2e-harness): use kebab-case is-default filter for DescribeVpcs

AWS EC2 DescribeVpcs filter names are kebab-case (is-default), and the
daemon's filter allowlist enforces that. The harness sent the camelCase
SDK field name (isDefault), so the daemon returned InvalidParameterValue
400 and DiscoverDefaultVPC fataled — leaving Phase 5 InstanceID empty
and cascading 16 downstream Single-Node E2E subtests to fail.

* test(e2e-single): port run-e2e.sh phases 8b-8e to Go

Stage F of the single-node Go port (mulga-siv-89). Adds three files
under tests/e2e/single/:

- vpc_test.go (553 lines): Phase 8b (pool-mode VPC public/private
  subnet E2E — CreateVpc, dual subnets with MapPublicIpOnLaunch
  assertions, IGW attach, custom route table + 0.0.0.0/0 route,
  instance launch + SSH + external connectivity probe) and Phase 8c
  (route table validation — main RT association, local + IGW route
  presence, custom RT CRUD + association/disassociation, negative
  delete-local + delete-main assertions).
- natgw_test.go (467 lines): Phase 8d (pool-mode NAT GW E2E —
  CreateNatGateway with EIP, private subnet route via NAT, bastion-
  hopped SSH into private VM, baseline egress drop / post-NAT egress
  succeed / post-teardown egress lost). Inline waitForNATGatewayState
  helper bounds the pending->available->deleted state machine via
  harness.EventuallyErr.
- datapath_test.go (326 lines): Phase 8e (OVN-gated SG-to-SG datapath
  — two SGs with UserIdGroupPair ingress rule, OVN port-group
  membership + SB address-set wait loops, allowed traffic curl,
  denied traffic nc, revoke->drop, re-add->restore).

Pool-mode gating via fix.PoolMode for 8b/8d; OVN gating via
harness.SkipIfNoOVN for 8e. Per-phase t.Cleanup LIFO ensures
teardown unwinds even on partial failure. Wired into TestSingleNode.

* test(e2e-single): port run-e2e.sh phases 9, 9a, 9b to Go

Stage G of the single-node Go port (mulga-siv-89) — final teardown +
post-teardown verification. Adds tests/e2e/single/teardown_test.go
(226 lines):

- phase9_Teardown: DeleteSnapshot(CustomAMISnapID) -> DeregisterImage
  (CustomAMIID) -> TerminateInstances(InstanceID) + wait terminated ->
  DeleteKeyPair(KeyName). Each call swallows *NotFound via
  harness.ErrorCodeIs; unexpected errors logged as warnings so 9a/9b
  always run.
- phase9a_VerifyTeardown: DescribeInstances must show terminated or
  NotFound; DescribeImages must NOT include the deregistered AMI;
  DescribeKeyPairs must NOT include the deleted key. Clears the
  consumed Fixture fields.
- phase9b_FinalClusterStats: harness.SpxGetVMs must NOT list the
  terminated InstanceID; orphan i- rows surface as soft Logf warnings
  to avoid flakes when parallel suites share the node.

Wired into TestSingleNode. With this, mulga-siv-89's single-node Go
port (Stages A-G) is feature-complete; run-e2e.sh remains in tree
until a separate cleanup bead deletes it after CI burn-in.

* ci(e2e-go): unbuffer sed in test pipeline for live log streaming

GNU sed defaults to block-buffered stdout when stdout is a pipe
(GitHub Actions runner). Accumulates 4-64KB before flushing, so
test output appeared in sporadic batches unaligned to test
boundaries. -u flag forces line-buffered output so each go test -v
line surfaces in the Actions UI as it is produced.

Side effect: none. No process count change, no SSH session change,
no test execution change. Pure output flushing on the runner host.

* fix(e2e-single): persist key PEM across phases and skip unimplemented IAM ops

Three bugs blocked the Single-Node Go E2E suite after the DescribeVpcs
filter fix:

1. Phase 3 wrote the SSH PEM into `t.TempDir()`, which Go cleans up when
   the Phase 3 subtest exits. Phases 5a-ii / 5a-iii / 7b / 7c-pre then
   saw "Permission denied (publickey)" / "Identity file not accessible"
   and the cascade left the instance in a state that broke 7c-pre's
   `start-instances`. Move the PEM under a TestSingleNode-scoped
   `fix.TmpDir` so it survives until the whole test exits.

2. IAM Phase 2's `require.NotEmpty(t, fix.IAMAdminAccount, "")` was
   intended as a no-op (Phase 4 populates the field), but require.NotEmpty
   fails on empty regardless of message. Removed the bogus check.

3. The daemon does not yet implement `UpdateUser` (IAM Phase 1) or the
   policy-version family (IAM Phase 4): `CreatePolicyVersion`,
   `ListPolicyVersions`, `SetDefaultPolicyVersion`, `DeletePolicyVersion`.
   They return `InvalidAction` and abort the phase. Wrap each block in a
   t.Run sub-test with t.Skip so the surrounding phase still runs and
   the skips show up explicitly in the test report.

* test(e2e): parallelise hot phases + tune fast-path polls

Squash merge of feat/e2e-parallel-quickwins (mulga-siv-96). Four
quick wins from decomposing run-e2e.sh into Go phase subtests:

- Phase 8 negatives (single/negative_test.go): t.Parallel() inside
  each of the 18 read-only error-path sub-tests. ~5-10x speedup.
- IAM Phase 4 policies (single/iam_test.go): EC2ReadOnly stays
  seeded sequentially (populates fix.IAMAdminAccount); the other
  four policies fan out as siblings under a wrapping
  create_policies_parallel t.Run. ~5x on the Phase 4 sub-step.
- Volume + snapshot fast paths (single/{volume,snapshot}_test.go):
  pass harness.WithPoll(500ms) on the five state transitions that
  finish in <1s on local QEMU. Halves Phase 5b / 5c wall-clock.
- Cert per-IP loop (cert/cert_test.go): t.Parallel() inside the 4
  inner t.Run(ip, ...) blocks. Cuts cert phase by len(nodes).

No harness changes, no Fixture changes, no test-execution-order
changes. mulga-siv-96.

* test(e2e-lb): resolve peer availability before shared fixture setup

setupSharedFixture builds a VPC + 2 app VMs + probe client (minutes
on capacity-tight dev hosts) and previously ran BEFORE the peer
pickup + ssh.Ping. On single-node, the InternetFacing_* subtests
would correctly t.Skip on peer == "" — but only after the fixture
work and once subtests started, so the wait appeared to be attached
to the first subtest name gotestsum printed.

Reorder: pickPeer + ssh.Ping first, log "internet-facing will skip"
immediately, then build the fixture for the internal subtests.
No behavior change for multi-node + peer-reachable; on single-node
the skip rationale surfaces before the fixture wait.

* test(e2e-single): extend SSH wait budget to 3m on slow boot paths

CI run 26034322018 timed out Phase5a_ii_SSH (60s budget) and
Phase8b_VPCSubnetE2E (2m budget) on the TCP-port-open poll while
sshd was still finishing pam/cloud-init. Bump all four wait sites
to 3m as a stop-gap:

- single/instance_test.go phase5aii_SSHProbe
- single/lifecycle_test.go waitForSSHReady (Phase 7b/7c/7c-pre)
- single/vpc_test.go phase8b_VPCSubnetE2E
- single/natgw_test.go phase8d bastion SSH wait

Proper fix tracked under mulga-siv-101 (hoist a harness.WaitForSSH
that probes the actual handshake, not just tcp/22 reachability).

* test(e2e-single): skip-gate IAM3/IAM5/IAM6 until daemon handlers land

CI run 26034322018 surfaced three IAM daemon gaps:

- IAM3_UserAuthentication: alice's freshly-created access key returns
  403 from DescribeInstances — daemon doesn't honour scoped IAM key
  signatures yet.
- IAM5_PolicyAttachmentEnforcement: cascades from IAM3 (alice can't
  sign).
- IAM6_PolicyLifecycle: DetachUserPolicy returns 404 even on
  attachments Phase 5 confirmed — daemon attachment ledger gap.

Skip-gate each phase with t.Skip + a pointer to mulga-siv-100 so the
remaining IAM coverage (IAM1/IAM2/IAM4/IAM7) keeps surfacing
regressions, and the suite as a whole doesn't fail on known gaps.
Flip to real assertions once the daemon handlers land.

* test(e2e-single): probe full SSH handshake (not just TCP port open)

CI run 26036025293 hit "Connection timed out during banner exchange"
on Phase5a-ii / 8b / 8d even with the 3m TCP-port-open budget: tcp/22
opens immediately, but sshd is still finishing pam/cloud-init, so the
first real runSSH call afterwards races the half-open daemon.

Switch waitForSSHReady to issue an actual ssh BatchMode + ConnectTimeout
handshake against the target, retrying until the daemon completes the
banner exchange. Drop the four TCP-only Eventually loops in favour of
the upgraded helper:

- instance_test.go phase5aii_SSHProbe
- lifecycle_test.go waitForSSHReady (Phase 7b / 7c-pre / 7c)
- vpc_test.go phase8b_VPCSubnetE2E
- natgw_test.go phase8d_NATGateway (bastion)

Signature: waitForSSHReady(t, host, port, keyPath). Backwards-compat
alias waitForSSHHandshake exposed for the instance_test caller. Proper
harness hoist tracked under mulga-siv-101.

* test(e2e-harness): dual-emit Step + Detail for live CI streaming

go test's testing framework buffers t.Logf output per-test and releases
it only on PASS/FAIL. Long subtests (Phase8Acct_AccountScoping ~115s,
Internal_NLB ~60s, Phase8b_VPCSubnetE2E 2-3m, Phase8d_NATGateway ~90s)
therefore went silent in CI for the entire subtest duration, even with
sed -u line-buffering in the workflow pipeline.

harness.Phase already wrote to os.Stdout directly for this reason.
Extend the pattern: have Step and Detail dual-emit — write the same
line to os.Stdout (streams live) AND t.Logf (keeps JUnit per-test
attribution). The duplicate on a failure is acceptable; both copies
carry identical content.

mulga-siv-103 (note: numbering skipped past siv-102 which was already
filed in the parent branch).

* test(e2e-harness): drop t.Logf duplicate from Step + Detail

The dual-emit (Fprintln + t.Logf) caused every Step/Detail line to
appear twice after long subtests: once live via stdout, once again
when the testing framework released its per-test buffer at
PASS/FAIL. Most visible after lb subtests (Internal_NLB ~60s) where
the bulk release dumped ~dozens of duplicated lines at once.

Drop the t.Logf copy. go-junit-report parses stdout between
`=== RUN` and `--- PASS/FAIL` markers, so per-test attribution in
JUnit XML is preserved without the duplicate.

Refs: mulga-siv-103

* test(e2e-harness): stream lb-waiter progress live in CI

WaitForLBActive, WaitForTargetsHealthy, WaitForENICleanup, and
WaitForInstanceRunning emitted progress via t.Logf, which the
testing framework buffers per-test. After the previous commit
dropped the dual-emit, that meant every poll loop went silent
until subtest end — visible in CI as 'nlb is active' hanging on
screen for the whole duration of the subsequent traffic check.

Route progress through harness.Step (direct stdout). Also emit a
'waiting for X' Step at the start of each helper so observers
know what the silent stretches actually represent.

Refs: mulga-siv-103

* test(e2e): dump VPC/IGW datapath diagnostics on Phase8b SSH timeout

Phase8b's 3min SSH timeout on a fresh-VPC VM (CI run 26037892507) is
the customer-visible surface of mulga-siv-105 — vpcd's handleIGWAttach
returns before ovn-northd compiles + the gateway chassis installs
flows. Two other cleanup-only bugs (siv-106 missing spinifex:cidr,
siv-107 dnat_and_snat double-delete) showed up in the same triage.

Keep the test hard-failing so the regression stays visible, but dump a
triage bundle on timeout so each occurrence is actionable:

- guest console output (last 4KB — boot + cloud-init state)
- ovn-nbctl show + ovn-sbctl show
- Logical_Router external_ids (confirms siv-106 reproduction)
- NAT rules (confirms siv-105 + siv-107 surface)
- Port_Binding chassis,up (confirms gateway port up state)

Diagnostics go straight to stdout (Step/Detail rationale) so they
stream live in CI rather than dumping at PASS/FAIL.

Refs: mulga-siv-105, mulga-siv-106, mulga-siv-107

* ci(junit-report): drop parent t.Run containers from tally + list skips with reasons

GitHub Actions test summary was counting Go's top-level Test* parent
plus its failing subtest as two distinct failures (the parent is just
a rollup of its children). The summary now de-dupes by dropping any
test case whose name is a strict prefix of another's, so the per-file
counts and the Failures block both match the actual leaf-test
outcome. CI run 26037892507 was reporting '2 failed' for single when
it should have been 1.

Also adds a collapsible Skipped section listing each skipped leaf test
with its t.Skipf call-site reason (the .go:<line>: line from the
CDATA). ANSI escapes from harness.Phase banners are stripped so the
reason is plain text. Reasons are useful triage signal — current run
has 9 skips spread across daemon gaps (mulga-siv-100) and pool-mode
gates (InternetFacing_*).

Refs: mulga-siv-89

* ci(e2e-go): propagate cancel to remote .test procs + nuke VM before destroy

Two related leaks made cancelled runs strand QEMU work on the libvirt host:

1. The long `ssh tf-user@VM "for SUITE in …; do ./$SUITE.test"` ran
   without -tt, so when the runner cancelled the step the local ssh
   died but sshd never sent SIGHUP through (no PTY). The remote
   `.test` binary orphaned and kept running until its own 30m
   `-test.timeout`, holding tmp dirs, NATS connections, and qcow2 file
   handles.
2. Teardown went straight to `tofu destroy` without first killing any
   stragglers on the VM, so destroy raced the .test's open file handles
   on the root disk — sometimes successfully, sometimes leaving the
   libvirt host with a half-released qcow2.

Fix:
 - Allocate a PTY on the run-suites ssh (`-tt`) so sshd HUPs children
   when the local ssh dies.
 - Add a trap (EXIT INT TERM) in the run-suites step that ssh's back
   to the VM and pkill's `/spinifex-e2e-bin/*.test` belt-and-braces.
 - Mirror the same pkill at the top of Teardown so destroy runs against
   a quiesced VM.

Both cleanups are scoped to the per-run wan_ip — safe to land while
multiple ci-single runner agents run concurrent jobs against different
tofu envs on the same host (we deliberately keep that parallelism).

Refs: runner host wedged 2026-05-19 (post-cancel orphans suspected;
no journal access yet to confirm).

* ci(e2e-go): scope orphan reap to GITHUB_WORKSPACE so it spares sibling runners

Reap Orphan Procs (job start) and the in-Teardown pkill block were using
`pkill -u $UID_SELF -x tofu/terraform` plus `find $HOME -maxdepth 6 -name
.terraform.tfstate.lock.info`. The single host runs 5+ self-hosted runners
all as `tf-user`, so every e2e-go run was SIGKILLing in-flight tofu from
sibling workflows (e2e.yml, e2e-ddil.yml, e2e-baremetal.yml, ...) and
deleting their state-lock files — exactly the kind of cross-runner
collateral the reap was meant to recover from.

Scope by `/proc/$pid/cwd` starting with $GITHUB_WORKSPACE; each runner's
workspace dir is unique on the host, so only this run's own tofu procs
match. Find for stale lockfiles is likewise scoped to $GITHUB_WORKSPACE.

* ci(e2e-go): bump setup-go v5->v6, upload-artifact v4->v7

GitHub now warns Node 20 actions are deprecated. e2e-go.yml lagged the
rest of the repo; bump to match versions already in use:

- actions/setup-go@v5 -> @v6   (matches spinifex.yml)
- actions/upload-artifact@v4 -> @v7  (matches release.yml, e2e-ddil.yml)

checkout@v6 and create-github-app-token@v3 are already current across
all workflows. The local junit-report action is `using: composite`
(shell-only), so the Node runtime change does not apply.

* fix(vpcd): add flows-ready barrier after IGW attach (mulga-siv-105)

handleIGWAttach wrote the external switch + gateway port + LRP + default
route to OVN NB and returned immediately, before ovn-northd had compiled
NB -> SB and before ovn-controller on the gateway chassis had installed
the resulting flows. A subsequent RunInstances -> vpc.add-nat could
complete while the datapath was still dark; the launched VM was then
unreachable on its public IP for whatever flow-install latency the
chassis happened to incur. Customer-visible as 'I just created a VPC,
launched a VM, and can't SSH in' for ~minutes.

Reproduced deterministically in CI run 26037892507 Phase8b: a VM in
a fresh VPC was unreachable at 192.168.0.202:22 for 3+ minutes, while
a VM at the same public IP in the warm default VPC (Phase8d) was
reachable in ~40 seconds.

Block at the end of handleIGWAttach on `ovn-nbctl --wait=hv sync`,
which bumps NB_Global.nb_cfg and waits for every connected chassis to
acknowledge the new sequence number — i.e. flows installed. Bounded by
--timeout=30; overruns log a WARN and continue (a 30s OVN stall is
already a deeper problem than failing VPC create would surface).

waitForFlowsHV is a package var so tests can stub it; in environments
without ovn-nbctl on PATH the shell-out fails fast and the WARN path
returns nil, so existing IGW tests stay green without a live OVN.

Refs: mulga-siv-105

* fix(vpcd): store spinifex:cidr on VPC create (mulga-siv-106)

handleVPCCreate built the OVN logical router with only spinifex:vpc_id
and spinifex:vni in ExternalIDs. Cleanup paths (handleIGWDetach + NAT
sweeps) read external_ids['spinifex:cidr'] to scope SNAT removal to
the VPC's own CIDR; when unset, they fell back to an overbroad
10.0.0.0/8 and emitted a WARN.

reconcileVPC already stored the same key at startup; the live-create
path drifted. Add the field to handleVPCCreate, log the cidr alongside
vpc_id, extend TestTopologyHandler_VPCCreate to assert the external_id
is set.

Refs: mulga-siv-106

* fix(vpcd): make handleDeleteNAT idempotent on 'already absent' (mulga-siv-107)

Instance termination fans out through multiple cleanup paths
(instanceCleanerAdapter.ReleasePublicIP AND
VPCServiceImpl.DeleteNetworkInterface), both of which publish
vpc.delete-nat for the same (vpc, logical_ip). The publish is
fire-and-forget (utils.PublishNATEvent uses PublishEvent for
vpc.delete-nat, not RequestEvent), so callers never branch on the
response. The second publisher then triggered a spurious
'ERROR vpcd: failed to delete dnat_and_snat rule ... not found' log
line ms after the first delete succeeded — confusing triage signal
with no actionable consequence.

Add ErrNATNotFound sentinel to the OVN client interface and wrap the
not-found return paths in both LiveOVNClient and MockOVNClient.
handleDeleteNAT matches errors.Is(err, ErrNATNotFound) and treats
that as success (logs INFO 'already absent (idempotent)').

Existing TestTopologyHandler_DeleteNAT_NotFound asserted the opposite
contract; the comment claimed EIP disassociation needed the error
signal, but DisassociateAddress also fire-and-forgets vpc.delete-nat,
so the contract never had a real consumer. Test renamed to
_Idempotent with assertSuccess.

Other failure modes (router missing, OVN unreachable, transact error)
still surface as ERROR + reply with the error string.

Refs: mulga-siv-107

* ci(e2e-go): cluster JUnit failures + name likely root cause (#230)

* ci(e2e-go): cluster JUnit failures + name likely root cause

New composite action `.github/actions/e2e-analyze` runs after
`go-junit-report` converts each suite log. For every `<failure>` it
strips ANSI, normalises dynamic noise (EC2 IDs, UUIDs, IPs, timestamps,
file:line) to a stable signature, picks the earliest non-cascade
failure per suite as the likely root cause, and buckets the rest by
signature. Output is appended to `$GITHUB_STEP_SUMMARY` and written to
`<log-dir>/analysis.md` so it travels with the artifact bundle.

Cascade markers (`must populate fix.`, `Should NOT be empty`,
`Expected value not to be nil`) collapse the typical "one upstream
phase fails, ten downstream `require.NotEmpty` assertions trip" pattern
into a single grouped entry under the real first failure.

The analyser is advisory — it always exits 0. The existing
"Fail job if any suite failed" gate keeps owning exit-code semantics.

Stage 1 of `docs/development/improvements/e2e-go-failure-analysis.md`
(mulga-siv-102). Stage 2 (per-failure log slicing) and Stage 3
(rule-based hints via `tests/e2e/.failure-rules.yaml`, placeholder
seeded here) are independent follow-ups.

CI wiring:
- e2e-go.yml: new "Analyze e2e failures" step between "Publish test
  summary" and the artifact upload, always-runs.
- spinifex.yml: new `make test-actions` step so the golden-file tests
  under `.github/actions/e2e-analyze/testdata/` run on every PR.
- Makefile: `test-actions` target; kept out of `test-cover` so CI-only
  tooling doesn't dilute coverage %.

Golden fixtures: a synthetic cascade-vpcs case (one Phase 5
InvalidParameterValue root + 15 downstream cascades), one real
single-failure case captured from run 26037892507, and a clean
no-failures case.

* ci(e2e-go): per-failure log bundles with time-window slicing

Stage 2 of the e2e failure analyzer. For each failure surfaced by
Stage 1, materialise <log-dir>/analysis/NN-<TestName>/ containing:

  failure.txt — summary (test, start, duration, file-site, error)
  journal.log — spinifex-journal.log sliced to
                [StartAt - 5s, StartAt + Duration + 5s]
  test.log    — test-<suite>.log block between `=== RUN <name>`
                and the matching `--- FAIL` marker
  <copies>    — any files the harness already dropped at
                <log-dir>/<TestName_with_/_replaced>/

Report links each entry to its bundle so a reader on the Actions
page goes straight to the relevant slice.

Workflow now invokes journalctl with --output=short-iso so the
analyzer can parse wall-clock timestamps for the journal window.
Lines without a parseable timestamp inherit the previous line's
classification (keeps multi-line records and stack traces
contiguous).

Render output is unchanged when BundlePath is empty, so the Stage 1
golden snapshots still match.

* ci(e2e-go): restore GOTOOLCHAIN=auto after setup-go v6 bump

setup-go@v6 flipped the GOTOOLCHAIN default from "auto" to "local",
which makes Go refuse to auto-download the toolchain version named
in go.mod. The self-hosted ci-single runner ships /usr/local/go
older than spinifex/go.mod's `go 1.26.3`, and build-install-artifacts.sh
prepends that path so the system Go wins the resolution race. Under
v5 ("auto"), Go silently fetched 1.26.3 on demand; under v6
("local"), the Build Install Artifacts step errors out:

  go: spinifex/go.mod requires go >= 1.26.3 (running go 1.26.1; GOTOOLCHAIN=local)

Re-set the env to "auto" so behaviour matches pre-v6 runs.

* ci(e2e-go): re-export GOTOOLCHAIN=auto after setup-go

Workflow-level env doesn't help: setup-go@v6 writes GOTOOLCHAIN=local
to $GITHUB_ENV, which overrides workflow env in every later step.
Add a dedicated step right after setup-go that appends
GOTOOLCHAIN=auto to $GITHUB_ENV so the Build Install Artifacts step
(which uses /usr/local/go from a stale runner path) can self-heal by
fetching the toolchain named in go.mod, matching pre-v6 behaviour.

* fix(vpcd): add flows-ready barrier after dnat_and_snat add

handleAddNAT wrote the NAT rule to OVN NB and returned immediately,
mirroring the gap siv-105 closed for handleIGWAttach but on the NAT
add path. ec2d/RunInstances then returned success while ovn-northd
was still compiling NB->SB and ovn-controller hadn't installed the
DNAT/SNAT flows on the gateway chassis, so the freshly launched VM
was unreachable on its public IP for whatever flow-install latency
the chassis happened to incur.

Reproduced in CI run 26072432957 Phase8b_VPCSubnetE2E: VM at
192.168.0.212 timed out at 3min while diagnostics showed
Port_Binding up=true for both the VM eni and the chassis-redirect
gateway. SB-side convergence was complete; the missing piece was the
same `ovn-nbctl --wait=hv sync` barrier siv-105 added to IGW attach,
applied at the end of handleAddNAT as well.

Reuses the existing package-var `waitForFlowsHV` so tests can stub
it; no new infra. Parallel unit test asserts the call site fires
exactly once per add-nat.

* ci(e2e-analyze): real-artifact fixes for journal slicing

Three bugs surfaced after Stage 2 ran against real CI artifacts in
run 26072432957:

1. Regex `[+-]\d{4}` rejected the colon-offset form (e.g. `+10:00`)
   that systemd's `journalctl --output=short-iso` actually emits on
   most versions. Added a parser that tries RFC3339 first and falls
   back to the no-colon `-0700` layout. Result: 0-line journal slices
   become populated.

2. go-junit-report's `timestamp` attr on <testsuite> records the
   moment the XML was produced (post-suite), not when the tests
   started. Computing StartAt as `that + cumulative-priors` placed
   every failure AFTER the journal collection ended, so windows
   always missed. Workflow now writes `${ARTIFACT_DIR}/test-<suite>.start`
   right before running each suite; analyzer prefers that file over
   the junit timestamp.

3. Persisted `OffsetFromSuiteStart` on each Failure so the
   suite-start override can recompute StartAt without re-parsing the
   XML.

Unit tests added for the colon-offset journal format. End-to-end
test (TestWriteBundles_EndToEnd) still passes with the new layout.

* ci(e2e-analyze): skip parent testcases when summing suite offsets

go-junit-report emits one <testcase> for the parent Go test AND each
subtest, with the parent's time = sum of its children. ParseFile was
summing every testcase into the suite-cumulative-offset counter, so
every leaf's offset was inflated by ~the parent's total duration.
Visible on run 26073817490 as Phase8b's StartAt landing 13 minutes
after the suite finished collecting logs — beyond the journal window,
so the per-failure journal slice was empty.

Pre-compute the leaf set per suite (a name is a leaf iff no other
testcase name starts with `<name>/`) and only accumulate cumul on
leaves. Golden snapshots updated; the offset for the only failure in
each fixture now matches the actual leaf-sum start time.

* test(e2e-harness): capture IP-targeted datapath state on Phase8b SSH fail

Phase 1 diagnostics for mulga-siv-111. The Phase8b SSH timeout
reproduced on the e2e-failure-analysis branch with siv-105's IGW
barrier AND the new add-nat barrier BOTH firing cleanly, so the
residual gap is downstream of `--wait=hv sync` — most likely stale
OVS conntrack, stale upstream-host ARP, or an OF flow-install miss.

DumpVPCFlowDiagnostics now takes a VPCDiagnosticsOpts carrying the
external IP, logical IP, and per-test artifact dir. When set, the
helper greps `ovs-ofctl dump-flows br-int`, `conntrack -L`, and
`ip -4 neigh show` for the external IP / logical IP and snapshots
`ovn-nbctl find NAT external_ip=…`. Each capture is both printed
inline (existing affordance) and written to a separate file under
fix.Artifacts so the Stage 2 analyzer bundler picks it up alongside
the journal + test-log slices, no analyzer changes needed.

Phase8b's caller now passes pubIP / privIP / fix.Artifacts so the
captures fire on the next failure; updates the t.Fatalf message to
point at siv-111 instead of siv-105 (which is the now-closed parent).

* fix(e2e-single): authorize SSH ingress on fresh-VPC default SG in Phase8b

Phase 8b stands up a non-default VPC and runs an instance there. Its
auto-created default SG denies all ingress, so the SSH probe times out
even when every datapath barrier converges — OF flow captures show the
SYN reaches OVN, gets DNAT'd at table 13, then drops at the ACL between
tables 13 and 17. The bash driver relies on admin init having pre-baked
this rule on the *default* VPC's default SG; a fresh VPC has not.

Add a DescribeSecurityGroups + AuthorizeSSHIngress step between
route-table setup and run-instances, using the existing harness helper.
Fix surfaced via mulga-siv-111's Phase 1 diagnostics (OF flow + ACL
drop counts), invalidating the H1/H2/H3 stale-state hypotheses in the
bead plan doc.

* test(e2e): dual-detect ubuntu image version (24.04/26.04) during gold-image transition

After mulga gold image bumped to v6-fastboot (stages ubuntu-26.04 / resolute
instead of ubuntu-24.04 / noble), all consumers that hardcoded 24.04 break on
the new image. Make them detect at runtime so v3 and v6 envs both pass.

- tests/e2e/run-e2e.sh: DescribeImages filter accepts both
  ami-ubuntu-{26,24}.04-<arch>, then derives IMAGE_NAME from the match.
- tests/e2e/run-pseudo-multinode-e2e.sh: prefer ~/images/ubuntu-26.04.img,
  fall back to ubuntu-24.04.img; pass detected version to spx import.
- tests/e2e/single/image_test.go: walk candidate catalog names (26.04 first,
  then 24.04), stop on the first one DescribeImages can resolve.
- .github/workflows/e2e-baremetal.yml: download resolute (26.04) into
  BM_ISO_CACHE; update BM_UBUNTU_IMAGE env var path.
- docs/terraform-workbooks/{bastion-private-subnet,s3-webapp,nginx-webserver}:
  AMI filter matches `*ubuntu-26.04*` OR `*ubuntu-24.04*` (AWS values=
  array is ORed).

Companion changes for mulga (bootstrap scripts, baremetal, iso pipeline)
land in scripts/tofu-cluster/* and scripts/{tofu-iso,baremetal,iso-builder}/*
under bead mulga-siv-114. Drop the 24.04 fallbacks once all envs are on the
v6 gold image and 24.04 entries are removed from spinifex/utils/images.go.

* feat(e2e): add service-interfaces.yaml manifest + validator

Source of truth for which spinifex services produce/consume which NATS
subjects, and which e2e suites cover which services. Consumed downstream
by the targeted suite selector (Bead 4) to derive subset E2E runs from a
PR diff so single-service changes stop paying the full 25 min suite cost.

- docs/service-interfaces.yaml: 7 services, 4 active suites (cert, lb,
  single, ddil). Subscribes / publishes / depends_on filled from grep
  of NATS subjects in source. spinifexui marked covers_no_suite so
  UI-only diffs skip e2e entirely. Future suites (multinode, reboot,
  tofu, baremetal) commented in until Go ports land.
- tests/e2e/manifest-check: schema + cross-reference validator (path
  existence, subject regex, duplicate detection, suite covers resolves
  to a real service, fixtures references resolve). 8 unit tests cover
  the failure modes plus a load-and-validate on the real manifest.
- Makefile: manifest-check target, hooked into preflight.
- docs/DESIGN.md: link to the manifest under "NATS Messaging".

mulga-siv-116. See docs/development/improvements/e2e-targeted-suite-selection.md.

* fix(e2e): drop catalog-download import path from phase4_Image

`spx admin images import --name ubuntu-26.04-x86_64` with wantErr=true
was meant to detect that bootstrap-install.sh already imported the AMI
and short-circuit on the duplicate exit code. After the v6 gold-image
bump preloaded `~/images/ubuntu-26.04.img` and bootstrap-install.sh
switched to `--file` for the import, two things broke at once:

  - The catalog `--name` path no longer hits a duplicate guard for the
    --file-imported AMI, so the import re-downloads ~857MB and either
    succeeds (failing the wantErr=true assertion) or wedges the job
    timeout while the catalog server crawls along at 16KB/s.
  - `--file` from the test won't work either: the test binary runs as
    `tf-user` over SSH, while bootstrap-install.sh placed the image at
    `/home/spinifex/images/...` (mode 0700 home dir).

bootstrap-install.sh is already responsible for staging the AMI. The
test's job is verifying DescribeImages round-trips by-name and by-ID —
not re-exercising import. Trust the bootstrap-staged AMI and stop
calling import.

Keeps the 26.04→24.04 dual-detect for the v3 gold image transition;
drop the 24.04 candidate when no envs remain on v3.

Refs mulga-siv-115 (caught by the EC2 cold-boot fast-path E2E run on
feat/ec2-coldboot-fastpath).

* ci(e2e-go): default to e2e-single + harden status-report SHA fallback

Two changes to .github/workflows/e2e-go.yml:

1. Default `suites` input now includes e2e-single so a no-input
   manual dispatch exercises the full single-node ladder. Single-node
   Go harness is greenable now (image_test fix at 10eb38a).

2. Report E2E (Go) Status job no longer gated on `inputs.sha` and
   falls back to `github.sha` when the input is unset. Previously a
   manual dispatch without a PR head SHA either skipped the status
   post entirely or hit `gh api repos/.../statuses/` with an empty
   path component (the 'invalid hash' error) — tests passed, the
   report step failed silently. Now status posts on every dispatch
   against the dispatched ref's commit when no explicit SHA given.

Closes mulga-siv-112.

* feat(e2e): add Ensure* fixture API with memoization + cleanup

Introduces tests/e2e/harness/fixtures.go: the memoized, cleanup-aware
provisioning helpers that Bead 3 (single-fixture-migration) will rewrite
phase tests around.

Each Ensure* helper:
- takes (t *testing.T, fx *Fixture, args...)
- memoizes by a key derived from args (sync.Mutex + singleflight)
- registers t.Cleanup on the Fixture's parent test so memoized resources
  survive child-subtest lifetimes
- bakes readiness polling into the helper (no sleeps in callers)

Exported helpers: EnsureKeyPair, EnsureAMI, EnsureDefaultVPC,
EnsureSubnet, EnsureSG, EnsureInstance, EnsureVolume, EnsureSnapshot,
EnsureNATGateway, EnsureLoadBalancer.

Fixture stores ec2iface.EC2API + elbv2iface.ELBV2API so unit tests can
inject fakes without building a real AWSClient (TLS + env). Compile-time
checks guard that the SDK concrete types satisfy the stored interfaces.

Manifest fixtures: block populated — Bead 4 selector will resolve test
coverage from AST-scanned Ensure* call sites via this map.

Makefile: new test-harness target runs the harness unit tests under
-tags=e2e (mocked, no infra). Hooked into preflight so signature drift
and memo/cleanup regressions surface locally.

Bead 2 of docs/development/improvements/e2e-targeted-suite-selection.md.
No existing tests modified — fixture API is net-new.

* feat(e2e): port run-reboot-e2e.sh to Go

Adds tests/e2e/reboot/reboot_test.go — Go port of the single-node host-reboot
resilience driver (cell #18). Runs from the GitHub Actions runner (not
shipped to the VM like cert/lb/single) because systemctl reboot kills any
in-VM process mid-step.

All AWS API traffic crosses the WAN to https://WAN_IP:9999 via the SDK;
SSH-level ops (reboot, ovn-nbctl, journalctl, HTTP burst) hop via
harness.PeerSSH. Trust path tries to SCP the cluster CA off the VM and
falls back to InsecureSkipVerify with a log message if SCP fails — the
cert suite already validates real trust, so the fallback is intentional.

Harness changes:
- env: WANHost field sourced from SPINIFEX_WAN_IP, distinct from
  ServiceIPs[0] (which is the awsgw bind IP, often loopback in-VM).
- aws: SPINIFEX_AWS_INSECURE=1 escape hatch when the runner has no CA on
  disk. Used as the fallback when CA fetch fails.
- cert: SPINIFEX_CA_CERT explicit path override so the reboot test can
  point ResolveCACert at the SCP'd tmp file.
- peer_ssh: IsReachable() probe wrapper for cleanup paths that must skip
  rather than fatal when the host is mid-reboot.

Workflow (.github/workflows/e2e-go.yml):
- New e2e_go_reboot job, opt-in via `e2e-reboot` in the SUITES dispatch
  input. needs: e2e_go_single to serialise on the ci-single self-hosted
  runner. Fetches AWS creds from the VM's ~/.aws/credentials over SSH
  (mirrors the cred hop already proven by e2e-ddil.yml).
- e2e_go_single filters 'reboot' out of its build + run loops so the
  in-VM driver doesn't try to ship reboot.test to the VM and reboot it
  from inside.
- e2e_go_status treats skipped reboot as success.

Refs mulga-siv-118. Plan: docs/development/improvements/e2e-go-reboot-port.md
(in the mulga parent repo).

* ci(e2e-go): run reboot job in parallel with single

Drop `needs: e2e_go_single` from e2e_go_reboot. Both jobs target the
same [self-hosted, ci-single] label, so GitHub Actions serialises on
runner allocation rather than blocking via job ordering — parallel-by-
spec, queued-by-runner. Lets a reboot-only dispatch start immediately
instead of waiting for an unrelated single-job pre-condition.

* fix(e2e/reboot): wait for both backends to serve before post-reboot burst

Phase 8.6 previously probed until ANY HTTP response from the ALB, then
fired a 20-req burst. Post-reboot, only one of the two backends may be
serving (the second is still bringing its responder online) while the
daemon's TG cache still shows pre-reboot 'healthy' for both — so
WaitForTargetsHealthy returns in ms instead of seconds, the ANY probe
matches the first-up backend, and the burst lands 20/0 instead of 10/10.

Probe until BOTH backends' instance_ids each appear at least once before
the round-robin burst — confirms recovery has reached both, not just one.

Underlying daemon-side TG cache staleness tracked separately as
mulga-siv-119. This wait is the test-side guard; the daemon fix will
make WaitForTargetsHealthy meaningful again post-reboot.

Refs mulga-siv-118.

* ci(e2e-go): skip single job when dispatch requests only reboot

Add if: gate to e2e_go_single that skips the job when the workflow_dispatch
suites input contains none of {e2e-single, e2e-cert, e2e-lb}. Push events
and the default suites list still trigger it, so plain PR runs are
unaffected. Avoids ~15min of wasted tofu provision+bootstrap+teardown
when only the runner-resident reboot suite is requested.

* fix(e2e/reboot): bump post-reboot LB recovery timeout to 5min

Pre-reboot the test creates the LB AFTER apps are already running, so
HAProxy starts probing against an already-serving responder — ~75s to
healthy. Post-reboot the daemon's recovery scan launches app VMs and
the ALB sys.micro VM simultaneously, so health checks start probing
within seconds of app boot beginning and both targets honestly report
unhealthy until the responder unit finishes starting. Cloud-init's
per-instance semaphore means no shortcut path on relaunch, so guest
second-boot can be slower than first boot.

Bash run-reboot-e2e.sh hid this behind stale 'healthy' state read from
the daemon's pre-reboot TG cache — fixed via ResetTargetHealthOnStartup
(mulga-siv-119), which made the test honestly wait on real lb-agent
reports. The 180s bash inherited turned out to be wishful thinking
backed by the cache bug, not measured recovery time.

5min matches the actual post-reboot eventually-consistent window
observed on run 26134156355. Refs mulga-siv-118.

* feat(e2e/reboot): ssh into guest VMs on failure for HTTP responder diag

Post-reboot failures bottom out at "0/2 healthy" with no insight into
whether the responder unit died, port 80 isn't bound, or the OVN path is
broken. Capture the keypair material from CreateKeyPair so OnFailure can
hop host -> qemu hostfwd:22 -> guest and dump systemctl status, port
listeners, curl localhost, and the responder journal per instance.

* fix(e2e/reboot): probe app instances via public IP (no QEMU hostfwd)

Previous hostfwd-SSH approach didn't apply: app VMs use pure OVN tap
interfaces (qemu argv has no hostfwd=tcp:*:*-:22), so the regex never
matched. Replace with host-side probes against the IGW-NAT'd public IP:
ping, /dev/tcp connect, and curl. Combined with the qemu ps check and
ovn-nbctl list nat dump, this localises post-reboot failures to one of:
VM not running, OVN DNAT missing, responder down, or sys.micro-only
reachability (path asymmetry).

* feat(e2e/reboot): probe ALB + dump host OVN/OVS state on failure

Last run showed apps unreachable from host post-reboot (ping 100% loss,
tcp refused) even though OVN dnat_snat rules looked correct. Need to
distinguish gateway/ext-bridge breakage from app-side breakage: add a
parallel ALB-public-IP probe (works pre-reboot, so any post-reboot
failure isolates the regression) plus a full host net-state dump (ip
route, ip neigh, ovn-nbctl show, ovs-vsctl show, br-int port list).

* feat(e2e/reboot): dump qemu serial console per app on failure

/run/spinifex/console-i-<id>.log captures cloud-init, kernel boot,
systemd, anything the guest emitted to ttyS0. Tail to 256KiB (~4x a
typical Linux boot) so the latest boot lands in full plus the tail of
the prior. Surfaces guest-side boot failures (kernel panic, fs
corruption, cloud-init OOM, etc.) that are invisible to host-side
network probes.

* feat(e2e/reboot): SSH into guest VMs via IGW for responder root-cause

Last run nailed the symptom: ALB returns 503, app public IPs answer TCP
with RST (port 80 not bound), so reboot-e2e-httpd.service isn't running
on relaunched VMs. To find out why (unit never installed? installed but
not enabled? failed to start?), open tcp/22 ingress on the shared SG and
add a guest-SSH diagnostic via the IGW NAT path. The diagnostic dumps
the unit state, port listeners, /var/lib/httpd, cloud-init result.json,
cloud-init logs, and the instance-id semaphore directory.

* test(e2e/reboot): always run guest SSH diag in phase 8

Last run passed after adding tcp/22 SG ingress. Two possibilities to
distinguish: (a) responder was running all along but blocked at the
OVN/SG layer, (b) responder wasn't running and HC was satisfied by some
other path. Capture in-guest unit + cloud-init state on every run so we
can confirm which without needing the test to fail.

* refactor(e2e/single): migrate VPC group to EnsureDefaultVPC [skip ci]

Bead 3a of mulga-siv-120 (e2e-single-fixture-migration). Replaces the
"discover default VPC + cache on Fixture" pattern in vpc_test.go,
natgw_test.go, securitygroup_test.go with harness.EnsureDefaultVPC.

- Adds Harness *harness.Fixture field on single.Fixture; constructed in
  TestSingleNode alongside AWSClient. Parent test for memoized Ensure*
  resources is TestSingleNode itself.
- Removes fix.DefaultVPCID / fix.DefaultSGID / fix.DefaultSubnetID reads
  in the three target files. Fields stay on the struct until 3e+3h
  migrate the instance / datapath / umbrella sites.
- Custom VPC graphs created by phase8b (10.99.0.0/16) and the bastion
  graph in phase8d remain inline — those are the resource lifecycle
  assertions, not prereqs.

CI gate skipped per dev request — full single-node suite verifies when
upstream main is unbroken.

* refactor(e2e/single): migrate Phase 3 key pair to EnsureKeyPair [skip ci]

Bead 3c of mulga-siv-120 (e2e-single-fixture-migration). Phase 3 now
calls harness.EnsureKeyPair for the primary key pair (Phase 5+ prereq).
The Ensure* path owns the create assertion (PEM shape), memoizes across
phases, and registers TestSingleNode-scoped cleanup — replacing the
former TODO(stage-G) deferred-cleanup marker.

- Drops hardcoded "test-key-1" name; EnsureKeyPair uses scratch-suffixed
  name so parallel CI runs don't collide on the namespace-flat AWS key
  pair name.
- fix.KeyName / fix.KeyPath still assigned for downstream Phase 5+
  readers. Per 3e migration plan, those reads move to in-test
  EnsureKeyPair calls and the Fixture fields drop with 3h.
- Import / describe / delete dance for test-key-2 stays inline — it is
  the lifecycle assertion for the import path, not a prereq.

* refactor(e2e/single): migrate Phase 4 + 5e AMI paths to EnsureAMI [skip ci]

Bead 3d of mulga-siv-120 (e2e-single-fixture-migration).

- Phase 4 (image_test.go): discover catalog AMI by canonical name, then
  route through harness.EnsureAMI({Existing}) so the state-available
  wait + memoization live with the fixture.
- Phase 5e (createimage_test.go): replace inline CreateImage block with
  harness.EnsureAMI({CreateFrom}). The custom AMI build now memoizes,
  registers DeregisterImage cleanup at TestSingleNode exit, and reuses
  the harness state poll.

Harness fix: extend AMICreateSpec with NoReboot bool — Phase 5e bakes
the AMI while leaving the source instance running for Phase 6/7
lifecycle assertions; Bead 2 missed this option.

fix.AMIID / fix.CustomAMIID / fix.CustomAMISnapID stay on Fixture for
downstream Phase 5/6/7 + Phase 9 cleanup. Those reads migrate with
3e/3h.

* refactor(e2e/single): migrate Phase 5 instance lifecycle to EnsureInstance [skip ci]

Bead 3e of e2e-single-fixture-migration.

- phase5_LaunchInstance: provision via EnsureInstance + re-describe for
  BDM/root-volume extraction; default VPC via EnsureDefaultVPC.
- phase8e_SGToSGDatapath: replace inline DiscoverDefaultVPC with
  EnsureDefaultVPC. createSG / runSGEInstance take vpcID/subnetID
  arguments so phase 8e no longer depends on fix.Default* fields.
- runSGEInstance stays direct RunInstances: phase 8e VMs are test
  subjects (need fresh ENI/private-IP per run), not memoized prereqs.
- harness.EnsureInstance memo key now includes InstanceType, SGID, and
  UserData hash — a second call with a different SG would have silently
  returned the first instance ID (Bead 2 bug).

* refactor(e2e/single): migrate Phase 5b/5c volume + snapshot to Ensure* [skip ci]

Bead 3f of e2e-single-fixture-migration.

- phase5b_VolumeLifecycle: create+wait via EnsureVolume; subsequent
  modify/attach/detach/delete stay inline (test subject lifecycle).
- phase5c_SnapshotLifecycle: create+wait via EnsureSnapshot. Drop the
  create-response State/Progress shape assertions — after EnsureSnapshot
  the snapshot is already "completed", and the describe block below
  re-asserts VolumeId / VolumeSize / Description.
- harness.EnsureSnapshot now takes SnapshotSpec{VolumeID, Description}
  so callers can pin a description (snapshot_test asserts on it). Memo
  key includes description hash so distinct-description calls do not
  silently collide on the volumeID prefix.

* refactor(e2e/single): isolate IAM stage E via parent-scoped cleanup [skip ci]

Bead 3g of e2e-single-fixture-migration.

- harness.Fixture: add RegisterCleanup helper that wraps fx.parent.Cleanup
  so test code can register parent-scoped teardown for resources created
  outside ensureOnce (IAM users / policies threaded across phase
  subtests). Registering on the calling subtest would tear down before
  the next dependent phase ran.
- phaseIAM1_UserCRUD: register parent-cleanup for alice/bob/charlie.
- phaseIAM4_PolicyCRUD: register parent-cleanup for the 5 test policies.
- IAM stage no longer relies solely on phaseIAM7 to clean up after a
  mid-stage failure; phase 7 still runs and now serves as an idempotent
  post-cleanup assertion.

No new Ensure* helper: IAM resources have no memoization story (users
and policies are test subjects, secrets only returned once on create).
IAM phases also have no EC2 prereqs, so EnsureDefaultVPC / EnsureInstance
are not applicable here.

* refactor(e2e/single): drop dead Fixture fields after Ensure* migration [skip ci]

First half of bead 3h. Removes per-phase Fixture fields with zero
remaining readers after bead 3a-3g:

- DefaultVPCID / DefaultSGID / DefaultSubnetID: superseded by
  harness.EnsureDefaultVPC (3a wired phase8b/8c/8d/5f; 3e wired phase5
  + phase8e).
- OVNAvailable: never assigned anywhere — leftover from the bash port.

Phase fields still in use (AMIID, InstanceID, IAM*, etc.) stay until
the umbrella TestSingleNode is replaced with top-level Test* + package-
level fixture singleton in the second half of 3h.

* refactor(e2e): add process-mode Fixture + TestMain singleton infra [skip ci]

Second half of bead 3h. Lands the infrastructure required to dismantle
the TestSingleNode umbrella in a follow-up bead, without changing the
umbrella body today.

harness.Fixture changes:
- NewProcessFixture(aws) builds a Fixture with no *testing.T parent.
  Cleanup callbacks queue on an internal LIFO drained by Close() —
  intended to be invoked from TestMain after m.Run(). Lets a singleton
  fixture span every top-level Test* in a package.
- Existing NewFixture(t, aws) unchanged; cleanups still route via
  t.Cleanup. registerCleanup() and logf() helpers fork on parent==nil
  so ensureOnce and RegisterCleanup work in either mode.
- TestProcessFixture_CloseRunsCleanupsLIFO covers Close ordering,
  one-shot semantics, and the no-op second-Close path.

single/main_test.go additions:
- TestMain owns process-level lifecycle: drains pkgFix.Harness.Close()
  and removes pkgFix.TmpDir after m.Run().
- requireSingleNodeFixture(t) returns the package-scoped singleton,
  building it lazily on first call. Skips when SPINIFEX_E2E is unset
  or mode != single; fails on construction error. Currently
  unreferenced — TestSingleNode keeps its own per-test Fixture for
  now. The helper exists so the follow-up bead (per-phase top-level
  Test* migration) has a stable API to call from day one.

Deferred to follow-up bead: extract Phase 4 AMI-name discovery + Phase
2 InstanceType/AZ selection into harness helpers so each phase function
can self-bootstrap, then promote phase subtests to top-level Test* and
delete the TestSingleNode umbrella.

* fix(e2e/harness): EnsureDefaultVPC filter name is-default not isDefault

Daemon rejects the camelCase `isDefault` form with 400
InvalidParameterValue. AWS CLI / DiscoverDefaultVPC use `is-default`.

Surfaced as Phase 5_LaunchInstance fatal in CI run 26140908505 —
cascade-failed every downstream phase that needed fix.InstanceID.

* fix(e2e/single): phase8 8j/8k probe fix.KeyName not hardcoded test-key-1

Bead 3c migrated phase 3 to harness.EnsureKeyPair which produces a
scratch-suffixed name (e2e-key-<random>), not the literal "test-key-1"
the bash port used. 8j_CreateKeyPairDuplicate / 8k_ImportKeyPairDuplicate
kept the old name and so created a fresh key pair instead of triggering
InvalidKeyPair.Duplicate — surfaced as the only two FAILs in CI run
26143227369 (everything else green after the EnsureDefaultVPC fix).

Read fix.KeyName (set by phase3_KeyPairs) so the probe hits the staged
primary key. Header comments updated to match.

make preflight diff-coverage against origin/dev fails at 57.9% due to
unrelated commits inherited from the recent rebase onto
feat/e2e-go-harness; against the fork base the diff is 0 instrumentable
lines (test-only change).

* refactor(e2e/single): dismantle TestSingleNode umbrella, per-phase self-bootstrap

mulga-siv-121. Each phase from run-e2e.sh now runs as a top-level Test*
in tests/e2e/single, runnable in isolation via `go test -run TestX`.

Highlights:
* TestSingleNode umbrella removed. Phase ordering dropped — each Test*
  self-bootstraps prerequisites through harness.Ensure*/harness.Discover*
  + the new package-local need*/iamEnsure* helpers.
* Fixture shrunk to {Env, AWS, Harness, Artifacts, TmpDir, PoolMode}.
  Every per-phase resource ID now lives on harness.Fixture via
  EnsureKeyPair/EnsureAMI/EnsureDefaultVPC/EnsureInstance/etc.
* TestMain owns the package singleton Fixture; harness.Fixture.Close
  drains every cleanup at process exit. phase9 + phase9a (in-line
  teardown) deleted; phase9b retained as TestFinalClusterStats.
* IAM Test* share state via sync.Once-gated helpers in
  iam_helpers_test.go (alice/bob/charlie users + keys + admin account).
* New harness/discover.go: DiscoverDefaultAZ, DiscoverNanoInstanceType,
  DiscoverUbuntuAMI — memoized on the Fixture and routed through
  EnsureAMI so any Test* can resolve them without phase coupling.
* New tests_test.go: descriptive top-level Test* wrappers
  (TestVPCSubnetE2E, TestKeyPairs, TestSnapshotLifecycle, …) that
  delegate to phaseN_X(t, requireSingleNodeFixture(t)).

* test(e2e/reboot): SSH as ec2-user, dump unit + network-online state

spinifex's cloud-init template hardcodes Username="ec2-user"
(handlers/ec2/instance/service_impl.go:1218) regardless of AMI,
so even on Ubuntu the SSH-injectable account is ec2-user, not
ubuntu. Switch the diag SSH-in to ec2-user so we actually get
in (was "Permission denied (publickey)").

Add diagnostics to distinguish the two leading post-reboot
hypotheses: (a) viperblock write loss = unit file gone vs
(b) network-online.target never fires = unit stuck on
After=network-online.target. New dumps: unit file content +
enable symlink, network-online.target status, networkctl,
ip addr / route, prior-boot journal grep for the responder.

* fix(e2e/single): resolve admin account ID before TestIAM7 deletes alice

iamEnsureAdminAccountID probes via GetUser(alice). TestIAM7_Cleanup
deleted alice/bob/charlie first, then called the helper at line 808 —
NoSuchEntity if IAM7 was the first IAM test to run (no memoized
account ID). Move the call above the deletion loop.
2026-05-20 21:04:13 +10:00
Julian Sommer b2dde0a66d feat: spx admin images remove (#239) 2026-05-20 16:20:09 +10:00
joshsiv-mulga 6142949a49 test(e2e/reboot): pad WAL > 4MB to force chunk upload (workaround for mulga-siv-122)
viperblock's WriteWALToChunk(false) is a no-op when the WAL is under
4MB (viperblock.go:1647-1657), so small user-data writes (the unit
file + index.html, ~300 bytes total) sit in the on-host WAL and are
lost when the host reboots before the WAL hits the threshold.

Workaround: write 8MB of zeros at end of user-data + sync to push WAL
over the threshold so the unit-file write definitely lands in
predastore before the host reboot. Drop when upstream fix lands.

Mirrors the same workaround in the Go reboot test
(tests/e2e/reboot/reboot_test.go on feat/e2e-go-harness).
2026-05-20 15:46:49 +10:00
joshsiv-mulga 560558dd12 fix(elbv2): reset target health to initial on daemon startup
Target HealthState is persisted in JetStream KV but the healthChecker's
per-target counters live only in process memory. After a daemon restart
(host reboot), the counters are gone but the stored 'healthy' claim
remains — DescribeTargetHealth returns 'healthy' immediately even though
zero post-restart health observations have been recorded and the LB VM's
lb-agent hasn't shipped a fresh report yet. Tests that gate on
WaitForTargetsHealthy advance prematurely, then ALB traffic lands on a
backend that isn't actually up (reboot E2E run 26133224853 saw 20/0 burst
instead of 10/10).

Add ELBv2ServiceImpl.ResetTargetHealthOnStartup: scans all TGs, rewrites
every non-draining target's HealthState back to 'initial' with the
matching HealthDesc. Draining is preserved — that state is driven by
DeregisterTargets and must not be clobbered. Wired from daemon
startCluster() right after elbv2 service init, before NATS subscriptions
go live, so first DescribeTargetHealth sees the reset state.

'initial' is the correct AWS-semantics representation of 'we don't know
yet' — the next lb-agent heartbeat drives state forward through the
existing evaluateHealth() path (single healthy probe transitions
initial→healthy, matching fast-start behaviour).

Multi-node caveat documented on the method: a sibling daemon's in-memory
counters would be out-of-sync with the persisted 'initial' state until
its next heartbeat re-converges. Acceptable — converges within one
health-check interval, and round-robin transiently excluding a backend
is safer than serving traffic to a dead one.

Closes mulga-siv-119. Paired with the e2e-side guard on
feat/e2e-go-harness that waits for both backends to actually serve
before the post-reboot burst.
2026-05-20 15:41:55 +10:00
Julian Sommer 739cf082a9 feat: rocky images + RHEL support (#238) 2026-05-20 14:36:41 +10:00
Julian Sommer bec7c382f1 feat: support different checksum format to support RHEL 2026-05-20 13:36:04 +10:00
Julian Sommer 20a8a39608 feat: flip images to UEFI default (#237) 2026-05-20 12:34:51 +10:00
Julian Sommer 78d179c89b feat: support uefi images (#236) 2026-05-20 11:26:28 +10:00
Julian Sommer 2032e1518f fix: golangci-lint config 2026-05-20 10:23:42 +10:00
Julian Sommer c5e65cae38 chore: bump golangci-lint 2026-05-20 10:18:58 +10:00
Julian Sommer 762381adfd chore: add BootMode field 2026-05-20 10:07:12 +10:00
Julian Sommer 85f248be39 chore: bump debian and ubuntu images 2026-05-20 09:48:26 +10:00
joshsiv-mulga 98ed8933d1 Add Ubuntu 26 filter 2026-05-20 09:26:06 +10:00
joshsiv-mulga dcf74b84e0 refactor: strip legacy lb-alpine AMI build path post-microvm-merge
ELBv2 LB instances now boot direct via microvm kernel+initramfs (the
lb-alpine-3.21.6 system AMI is no longer used). Remove the build/import
scaffolding:

- delete scripts/images/lb.conf + lb-setup.sh (system-image bake config)
- remove lb-alpine catalog entry from spinifex/utils/images.go
- drop LB_IMG download + BM_LB_ALPINE_IMAGE env from e2e-baremetal.yml

Tracks mulga-siv-81.
2026-05-19 22:02:00 +10:00
joshsiv-mulga f778508838 test(e2e): dual-detect ubuntu image version (24.04/26.04) during gold-image transition
After mulga gold image bumped to v6-fastboot (stages ubuntu-26.04 / resolute
instead of ubuntu-24.04 / noble), all consumers that hardcoded 24.04 break on
the new image. Make them detect at runtime so v3 and v6 envs both work.

- tests/e2e/run-e2e.sh: DescribeImages filter accepts both
  ami-ubuntu-{26,24}.04-<arch>, then derives IMAGE_NAME from the match.
- tests/e2e/run-pseudo-multinode-e2e.sh: prefer ~/images/ubuntu-26.04.img,
  fall back to ubuntu-24.04.img; pass detected version to spx import.
- .github/workflows/e2e-baremetal.yml: download resolute (26.04) into
  BM_ISO_CACHE; update BM_UBUNTU_IMAGE env var path.
- docs/terraform-workbooks/{bastion-private-subnet,s3-webapp,nginx-webserver}:
  AMI filter matches `*ubuntu-26.04*` OR `*ubuntu-24.04*` (AWS values=
  array is ORed).

Companion mulga changes (bootstrap-install.sh, bootstrap.sh, tofu-iso/*,
baremetal/*, iso-builder/spx-test-gpu.sh) land under bead mulga-siv-114.

Drop the 24.04 fallbacks once all envs are on the v6 gold image and 24.04
entries are removed from spinifex/utils/images.go.

The companion image_test.go fix (walk catalog name candidates) lives on
feat/e2e-go-harness — that test file doesn't exist on dev yet.
2026-05-19 21:42:29 +10:00
Julian Sommer e32b267f18 chore: docs debian 12 -> 13 2026-05-19 15:57:49 +10:00
Julian Sommer dae953e0dc fix: wait for unix socket on qmp client creation (#235) 2026-05-19 15:28:22 +10:00
Julian Sommer 726631e922 fix: regenerate fw_cfg blobs on ALB system VM relaunch (#234) 2026-05-19 14:26:41 +10:00
joshsiv-mulga fcb17db4ab fix(daemon): use numeric uid/gid when creating tap to avoid silent skip
user.Current() can return an error under hardened systemd units / static
builds, causing the user/group flags to be silently dropped on
`ip tuntap add` and leaving the tap root-owned. The child QEMU then
runs as spinifex-daemon, fails the tun_chr_open owner check, and exits
with "could not configure /dev/net/tun: Operation not permitted" before
writing its PID file — the LB microvm never boots and the load balancer
sticks in failed/provisioning state.

Reproduced in E2E nightly cell-17 (single/static/debian-13/tofu-examples)
run 26047315237 on the nginx-alb workbook at commit 4ae2a4a (the
microvm-elbv2 merge that introduced the prior user.Current() fix).

Pass os.Geteuid() and os.Getegid() numerically instead — always available,
no NSS lookup, no silent fallthrough.

Closes mulga-siv-110
2026-05-19 13:16:56 +10:00
Julian Sommer a7a1222495 chore: remove FIPS info log 2026-05-19 10:58:49 +10:00
tomnewton-mulga 86721357cd Merge branch 'dev' of github.com:mulgadc/spinifex into dev 2026-05-19 10:46:06 +10:00
tomnewton-mulga e8ca596083 fix: secret for CF URL endpoint 2026-05-19 10:45:37 +10:00
Julian Sommer cc60104306 chore(ui): deps + accessibility fixes 2026-05-19 10:44:29 +10:00
Julian Sommer 55ceeccf56 test(e2e/reboot): wait for ALB to serve HTTP before post-reboot burst (#233) 2026-05-19 10:06:40 +10:00
Julian Sommer 5887927e9b fix(daemon): republish vpc.add-nat on instance recovery (#232) 2026-05-19 10:05:51 +10:00
Julian Sommer 31cd10dc99 fix: system vm instance metadata (#231) 2026-05-19 10:04:32 +10:00
Julian Sommer 495587304b release: update go.mod for v1.4.0 2026-05-19 09:37:54 +10:00
tomnewton-mulga 01d9fa858f feat: iso release flow (#229) 2026-05-19 09:07:26 +10:00
Josh Siviour 4ae2a4ac53 feat(elbv2): QEMU microvm direct-boot for LB system instances (#228)
* feat(vm): direct-boot foundations + config/input extensions + image build pipeline

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

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

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

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

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

PC-machine path (DirectBoot=false) unchanged.

Closes mulga-siv-65

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

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

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

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

Closes mulga-siv-66

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

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

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

Closes mulga-siv-67

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Root causes:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Test covers all three branches.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Changes:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Refs: mulga-siv-72

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

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

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

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

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

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

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

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

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

* Fixed GPU passthrough and added test script

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

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

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

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

* installer grub image fixes

* made gpu-passthrough more user facing

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

* fixed vpcd br-wan promisc and veth pair

* ssh keygen fix

* Fix: AMD GPU edge cases

* script for running 8x instances on SMCI computer

* fix: increase disk space on instances

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

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

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

* fix: multi-gpu instance types

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

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

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

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

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

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

* fixed instance fleet launch script

* remove known hosts and stagger provisioning

* improvements to launch script

* more upgrades

* single instance gpu instance provisioning

* fixed single instance provision

* smci computer demo dashboard

* fixes to launch script

* updated smci dashboard_server

* fixed demo dashboard

* updated dashboard.py

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

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

* fixing image builder process

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

WitnessVM.SSHPort → WitnessVM.GuestIP. hostfwdPortRE removed.

Closes mulga-siv-57.

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

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

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

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

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

Continues mulga-siv-57.

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

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

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

Continues mulga-siv-57.

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

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

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

Continues mulga-siv-57.

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

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

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

Continues mulga-siv-57.

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

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

Continues mulga-siv-57.

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

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

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

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

Continues mulga-siv-57.

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

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

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

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

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

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

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

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

Closes mulga-siv-58.

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

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

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

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

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

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

Two changes restore the Tier 1 contract:

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

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

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

Mulga-siv-58 (reopened).

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix(daemon): drop ensureDefaultVPCInfrastructure from reconcileOnHeal

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Refs mulga-siv-21.

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

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

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

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

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

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

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

This reverts commit 5b890162a6.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Bead: mulga-siv-23

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

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

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

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

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

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

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

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

bead: mulga-siv-17 (closes)

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

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

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

bead: mulga-siv-19 (closes)

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

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

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

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

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

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

mulga-siv-44.

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

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

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

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

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

Two bugs in the v1 script:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* Fixed GPU passthrough and added test script

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

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

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

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

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

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

* fixed admin

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

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

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

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

* fixing gpu bining permissions

* working state of gpu passthrough on phyiscal box

* installer grub image fixes

* made gpu-passthrough more user facing

* fixed banner

* fixed banner

* banner fix

* gpu passthrough docs

* preflight tweak

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

* fixed vpcd br-wan promisc and veth pair

* ssh keygen fix

* fixes after running /review-branch

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

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

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

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

---------

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

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

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

- run-multinode-e2e.sh: wrap get_remote_ssh_port in `if !`, bump retry
  10 -> 30, add dump_guest_ssh_diagnostics on miss
- run-pseudo-multinode-e2e.sh, run-e2e.sh: wrap get_ssh_port in `if !`
- run-lb-e2e.sh: add `|| VAR=""` fallback on pick_peer
2026-05-04 13:44:49 +10:00
joshsiv-mulga 89aec5c709 docs: warn br-wan prerequisite for curl install
Add WAN bridge prerequisite callout to single-node and multi-node
install guides. Bootstrap installer assumes br-wan exists; running
on a host whose default route is on a bare NIC leaves install in a
non-working state. Auto-provisioning will land in next release.
2026-05-01 15:59:14 +10:00
Julian Sommer cbe335e903 chore: bump single node e2e timeout to 35 mins 2026-05-01 15:51:42 +10:00
Julian Sommer e9a335f433 chore: merge main into dev (ISO installer) 2026-05-01 15:22:41 +10:00
Ben Duncan 89318d8f6e ISO method for installer 2026-05-01 15:18:00 +10:00
Julian Sommer e1b3623599 refactor: introduce vm.Manager (phase 1 of lifecycle extraction) (#176) 2026-05-01 15:15:54 +10:00
Julian Sommer 57a11c0668 fix(vpc): validate SG rule CidrIp/SourceSG to block OVN ACL injection (#182) 2026-05-01 15:15:42 +10:00
Julian Sommer 050cc03ae2 fix: daemon_test.go allocation 2026-05-01 15:14:03 +10:00
Julian Sommer bab16c75cc fix(elbv2): validate HAProxy inputs (#181) 2026-05-01 12:17:42 +10:00
tomnewton-mulga 85184d45f9 Merge branch 'dev-test' into dev 2026-05-01 12:15:05 +10:00
tomnewton-mulga 9d665f9dde bringing local networking changes onto dev branch 2026-05-01 12:13:34 +10:00
joshsiv-mulga 97f57d3826 fix(vpcd,viperblockd): retry NATS connect for ISO firstboot resilience
Both services used one-shot utils.ConnectNATS in launchService. On ISO
firstboot, when nats.service is starting in parallel, the dial can race
ahead of NATS being ready and exit non-zero. systemd's
Restart=on-failure with default StartLimitBurst=5 in 10s burns through
the budget in ~25s and the unit enters failed permanently — DHCP
manager never starts, vpc.dhcp.acquire requests hit no-responders and
the install hangs nondeterministically.

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

* fix: remove unused import

* chore: bump predastore version

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: pin HashMAC first octet to 0x02

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

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

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

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

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

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

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

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

mulga-siv-29

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

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

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

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

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

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

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

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

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

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

No behavioural change.

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

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

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

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

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

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

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

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

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

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

---------

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* feat: add DDIL harness fault injection and link profiles

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

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

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

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

* feat: add DDIL harness witness VM helpers

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

* feat: add DDIL harness snapshot and state assertions

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

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

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

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

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

bd: mulga-siv-5.5

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

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

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

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

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

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

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

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

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

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

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

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

Closes mulga-siv-7.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Refs: mulga-siv-1

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* feat: add 4 attributes methods to ELBv2Service interface

* feat: implement ELBv2 attributes service methods and NATS wrappers

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

Add corresponding NATS request wrappers in service_nats.go.

* feat: add gateway handlers for ELBv2 attributes API endpoints

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

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

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

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

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

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

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

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

Closes mulga-910

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: release auto-assigned public IPs on DeleteNetworkInterface

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

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

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

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

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

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

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

* feat: add custom AMI for ALB to images list

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

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

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

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

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

* fix: spx path

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

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

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

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

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

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

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

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

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

* fix: lazy system AMI discovery for ALB VMs

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

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

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

* push interim changes

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

This reverts commit d4220d8806.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: detach then delete ENIs in DeleteLoadBalancer

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

* test: add internal ALB dataplane E2E test

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

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

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

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

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

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

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

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

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

* feat: add NLB listener and protocol compatibility unit tests

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

* feat: add TCP HAProxy config generation for NLB

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

* refactor: rename albagent to lbagent for NLB generalization

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

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

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

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

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

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

* chore: rename file

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* update go version

* add LB e2e tests

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

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

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

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

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

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

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

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

* fix: filter AMI discovery by name in E2E tests

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

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

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

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

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

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

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

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

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

Three fixes for CI E2E failures:

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

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

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

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

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

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

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

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

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

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

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

* add LB enable flag to e2e

* test: add elbv2 tests to meet diff coverage threshold

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

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

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

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

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

* fix: data race in getSystemAMI/getSystemInstanceType lazy caching

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

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

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

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

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

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

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

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

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

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

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

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

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

* refactor: deduplicate MAC hash function into utils.HashMAC

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

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

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

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

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

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

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

Same fix applied to getSystemInstanceType for consistency.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: add retry loop for peer NLB reachability test

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

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

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

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

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

When a public IP is returned to the external pool and quickly reallocated
(e.g. instance terminated, then LB created), the fire-and-forget
vpc.delete-nat may not have been processed yet. handleAddNAT now removes
any existing dnat_and_snat rule for the same external IP before creating
the new one, preventing duplicate/conflicting OVN NAT rules.

This was causing intermittent NLB connectivity failures in multi-node E2E
where NLB public IPs collided with recently-freed instance IPs.

* fix: clean up stale NAT rules across all VPC routers on IP reuse

The previous fix (0a93081) only removed stale dnat_and_snat rules from
the target VPC's router. When a public IP is freed from one VPC and
reallocated to a different VPC before the fire-and-forget vpc.delete-nat
is processed, the stale rule on the old VPC's router caused duplicate
OVN ARP responses — traffic was black-holed when the host resolved to
the wrong router's MAC.

DeleteAllNATsByExternalIP now searches every logical router for stale
NAT rules matching the external IP and removes them in one transaction.

Also adds host connectivity diagnostics (ARP, routing, OVN NAT state)
to run-lb-e2e.sh so the next CI failure surfaces the root cause of the
persistent NLB-from-host issue.

* fix: run LB inet tests from actual hosting node, not hardcoded node1

The host/peer connectivity tests were hardcoded: "host" = node1 (where
the test script runs), "peer" = node2. But LB VMs land on whichever
node picks up the NATS queue message, so the tests were randomly testing
local vs remote connectivity depending on placement luck.

Now the test:
1. Accepts --nodes <ip1> <ip2> ... (all cluster node IPs)
2. After LB creation, discovers which node hosts each LB VM by checking
   for the ENI's tap device
3. Runs "local" tests from the hosting node and "remote" tests from a
   different node

Also updates e2e.yml to pass --nodes instead of --peer.
2026-04-10 10:27:20 +10:00
tomnewton-mulga c5b6beb006 feat: multi-NIC WAN/LAN bridge network setup in ISO installer (mulga-897)
- Replace single network screen with separate WAN and LAN screens
- WAN/LAN IPs now live on Linux bridges (br-wan, br-lan) over the physical
  NICs, not on the NICs directly — makes OVS/OVN veth attachment SSH-safe
- Add DHCP/static toggle on both screens (DHCP is the default)
- Add DNS nameserver input for static mode (comma-separated, default hint
  1.1.1.1 / 8.8.8.8 shown as placeholder)
- Detect WiFi NICs via /sys/class/net/<name>/wireless; show SSID and
  password fields when a WiFi adapter is selected
- LAN screen shows WAN NIC greyed/unavailable; cursor skips over it
- LAN screen only shown when 2+ NICs are detected
- Geneve encap IP uses br-lan address when LAN NIC present, else br-wan
- Remove --macvlan/--wan-iface from firstboot; setup-ovn.sh auto-detects
  br-wan as a Linux bridge and uses veth mode
- Pin both WAN and LAN NIC names to MAC via a single udev rules file
- node.conf writes MANAGEMENT_IFACE=br-wan (bridge, not physical NIC)
2026-04-10 10:02:16 +10:00
tomnewton-mulga e8e05d6898 chore: merge main into feat/iso-installer, resolve Makefile .PHONY conflict 2026-04-10 09:40:16 +10:00
Julian Sommer b001c3929d Revert "feat: enforce nats authentication (#123)"
This reverts commit 553e7857df.
2026-04-09 16:06:03 +10:00
tomnewton-mulga 03601ca89b fix: banner service ordering, getty wait, motd append (mulga-866)
- banner runs After=spinifex.target so services are settled before banner writes
- switch systemctl restart → try-restart (safe if target not yet active)
- append banner to /etc/motd with sentinel instead of overwriting, preserving
  the Debian disclaimer; sentinel ensures re-runs replace cleanly
- remove /etc/issue write — getty shows it pre-login which is wrong
- add getty@.service.d drop-in so all getty instances wait for banner before
  showing login prompt; prevents service messages scrolling during login
- fix Makefile to prefer locally built spx/installer binaries over system PATH
2026-04-09 15:40:26 +10:00
Julian Sommer 1a2e490ae7 fix: policy matching case insensitive 2026-04-09 15:34:43 +10:00
Julian Sommer 553e7857df feat: enforce nats authentication (#123) 2026-04-09 15:27:00 +10:00
tomnewton-mulga cbb3e91ada feat: add spx admin banner --boot-check, replace bash banner script (mulga-866)
- add `spx admin banner --boot-check` subcommand to spx CLI
  - reads management IP from the NIC at runtime (not from node.conf)
  - if IP has changed, updates /etc/spinifex/node.conf and restarts spinifex.target
  - writes banner to /etc/issue (pre-login console) and /etc/motd (SSH MOTD)
  - banner instructs user to cat ~/.aws/credentials (standard AWS CLI location)
- update firstboot.go to use spx-based banner unit; remove writeBannerScript
  and old spinifex-banner.sh entirely
- new banner unit runs Before=spinifex.target so IP is current before services start
2026-04-09 14:39:56 +10:00
Julian Sommer 7449090c86 fix: enable more linters (#121) 2026-04-09 14:36:22 +10:00
tomnewton-mulga 10a2a8116f fix: resolve firstboot service failures and improve installer UX (mulga-859)
- chown /etc/spinifex and /var/lib/spinifex to spinifex:spinifex after
  spx admin init so services running as the spinifex user can read configs
- add Environment=HOME=/root to firstboot unit to suppress $HOME warning
- increase wait-for-nats.sh timeout from 30s to 90s for slow startup
- banner now writes to both /dev/tty1 and /etc/motd (visible over SSH)
- add systemd.show_status=1 to GRUB cmdline for per-service boot feedback
- add --removable to grub-install EFI so fallback path EFI/BOOT/BOOTX64.EFI
  is written (required for UTM and bare metal without NVRAM boot entries)
- add root password screen and manual NIC entry to installer TUI
- tee spx admin init output to /root/spinifex-credentials.txt with 0600 perms
2026-04-09 13:52:31 +10:00
Julian Sommer 26e590716e feat: add systemd hardening directives to all service units (#120) 2026-04-09 12:09:43 +10:00
Julian Sommer e27d909e8d feat: move NBD sockets to /run/spinifex/nbd/ and enable PrivateTmp (#119) 2026-04-09 11:11:54 +10:00
Julian Sommer ccb6ed287d fix: move bootstrap.json to different folder to tighten perms (#118) 2026-04-08 16:12:25 +10:00
Julian Sommer 9a7e914d28 feat: awsgw directory (#117) 2026-04-08 15:19:29 +10:00
Julian Sommer 6026d46ba1 fix: ovn logging 2026-04-08 13:00:06 +10:00
Julian Sommer f0eb28ffc2 fix: remove gateway csp 2026-04-08 12:45:36 +10:00
Julian Sommer d33e6f3d70 chore: go 1.26.2 2026-04-08 11:53:29 +10:00
Julian Sommer 45ea467fcc feat: webserver proxy (#116) 2026-04-08 11:46:43 +10:00
tomnewton-mulga 7c007ea6b5 feat: replace huh TUI with bubbletea + add branding package (mulga-859)
Replaces charmbracelet/huh with a custom bubbletea wizard for the
installer TUI. This gives full control over terminal I/O so the
installer attaches directly to the correct console device (ttyS0 for
serial, tty1 for VGA) as directed by the new spinifex-init boot script.

- branding/branding.go: centralised logo (ASCII placeholder), color
  constants, and publisher string ready for Mulga asset swap
- ui/ui.go: multi-screen bubbletea model (welcome → disk → network →
  identity → join → CA cert → confirm) styled with lipgloss
- main.go: reads SPINIFEX_CONSOLE from /proc/cmdline to open the right
  tty before launching tea.Program
- go.mod: bubbletea/lipgloss/bubbles promoted to direct deps; huh removed
2026-04-08 10:49:57 +10:00
Julian Sommer 820b605c0b fix: require sudo admin init 2026-04-08 09:32:25 +10:00
Julian Sommer a56a2baf1f feat: privilege separation (#114) 2026-04-07 16:22:19 +10:00
tomnewton-mulga 6dfb350be8 fix: use INSTALL_SPINIFEX_SKIP_APT/AWS env vars instead of non-existent --offline flag (mulga-859) 2026-04-07 16:01:17 +10:00
tomnewton-mulga 5d6f25a695 feat: add build-installer Makefile target (mulga-859) 2026-04-07 15:56:39 +10:00
tomnewton-mulga 1b39439cb8 feat: scaffold ISO installer TUI and install pipeline (mulga-859)
Adds cmd/installer with three packages:
- ui: huh-based TUI collecting disk, network, node, and cluster config
- install: sequential install steps (partition, debootstrap, chroot, bootloader)
- firstboot: oneshot systemd service that runs setup-ovn and spx admin init/join on first real boot

Supports both single-node init and multi-node join flows.
2026-04-07 15:50:40 +10:00
Julian Sommer 99f9949bdd fix: enable TLS on cluster manager API (#113) 2026-04-07 10:56:16 +10:00
dependabot[bot] 5598e523d5 chore: bump the npm-dependencies group (#112) 2026-04-07 08:57:04 +10:00
Julian Sommer dda4575888 fix: use binary install for single node e2e (#111) 2026-04-02 16:22:41 +11:00
Ben Duncan e5b6204875 Fix service helper sequence 2026-04-02 13:53:29 +10:00
Ben Duncan 21aa1296c9 systemd tweaks for binary install to correctly function 2026-04-02 13:35:52 +10:00
Julian Sommer ea6b5d770a fix: mask credentials in CLI override log output
Access key and secret key values were printed to stdout when using
--access-key and --secret-key flags. These are captured by systemd
journal and log aggregation, exposing secrets. Log that keys were
overridden without printing the actual values.

Addresses H-5 from security audit.
2026-04-02 12:25:27 +10:00
Julian Sommer f986cf9748 fix: ui cert path 2026-04-02 11:55:32 +10:00
Julian Sommer 11f7c99955 fix: ui cert path 2026-04-02 11:20:02 +10:00
Julian Sommer 1b45c7fc7d fix: nats ip in services 2026-04-02 10:58:39 +10:00
Julian Sommer 868a92bd55 fix: service 2026-04-02 10:35:00 +10:00
Julian Sommer af52d881b9 fix: chown 2026-04-02 10:26:56 +10:00
Julian Sommer c51ea0c29d fix: make download checksum verification mandatory 2026-04-02 09:56:57 +10:00
Julian Sommer 8cf0cc332f feat: remove pw login 2026-04-02 09:51:07 +10:00
Julian Sommer 1d6cefd9d2 feat: extract helpers (#109) 2026-04-02 10:25:01 +11:00
Julian Sommer 0c160d7f45 fix: add request body size limit 2026-04-01 15:36:06 +10:00
Julian Sommer 91e4efb0ef feat: extract tag helper 2026-04-01 15:27:43 +10:00
Julian Sommer f5ec8c3bae feat: formation server HTTPS (#108) 2026-04-01 16:20:28 +11:00
Julian Sommer 8795a7b926 fix: remove dev branch 2026-04-01 13:54:35 +10:00
Julian Sommer 39e9fa6fb8 feat: more filters (#106) 2026-04-01 14:23:04 +11:00
Julian Sommer 853b5bfd32 feat: filterutil migration (#105) 2026-04-01 14:05:21 +11:00
Julian Sommer d7c72e80af feat: filters (#104) 2026-04-01 12:20:56 +11:00
Julian Sommer 475e632404 chore: update COMMANDS.md 2026-03-31 15:53:02 +10:00
922 changed files with 121318 additions and 26021 deletions
+104
View File
@@ -0,0 +1,104 @@
# e2e-analyze
Post-test failure-clustering action for the Go E2E suites.
## What it does
Runs after `go-junit-report` has converted each suite's stdout into
`junit-<suite>.xml`. For every JUnit file matched by `junit-glob`:
1. Parses each `<testcase>` with a `<failure>` / `<error>` child.
2. Strips ANSI escapes, then normalises dynamic noise (UUIDs, EC2 IDs,
IPs, timestamps, request-ids, source line numbers) to produce a stable
_signature_ per failure.
3. Picks the **earliest non-cascade** failure per suite as the likely
root cause. "Cascade" = failure message matches a fixture-dependency
marker (`must populate fix.`, `Should NOT be empty`,
`Expected value not to be nil`). When every failure is a cascade
(whole suite tripped on one upstream gap), the earliest cascade is
the fallback root.
4. Buckets the remaining failures by signature and renders a markdown
report. The report is appended to `$GITHUB_STEP_SUMMARY` and also
written to `<log-dir>/analysis.md` so it travels with the uploaded
artifact bundle.
The action **never fails the job** — exit-code semantics stay with the
existing "Fail job if any suite failed" gate.
## Inputs
| Input | Required | Description |
|--------------|----------|-------------|
| `junit-glob` | yes | Shell glob for the JUnit XML files (e.g. `${ARTIFACT_DIR}/junit-*.xml`). |
| `log-dir` | yes | Directory the rendered report is written into (`analysis.md`). |
| `title` | no | Heading at the top of the summary section. Default `E2E failure analysis`. |
## Outputs
None. The summary is appended to `$GITHUB_STEP_SUMMARY`; the same
content lands at `<log-dir>/analysis.md`.
## Local invocation
Outside Actions, the analyser prints to stdout instead of appending to a
summary file:
```sh
cd spinifex/.github/actions/e2e-analyze
go run . -junit-glob "/tmp/artifacts/junit-*.xml" -log-dir /tmp/artifacts
```
## Tests
Golden-file tests live in `testdata/`. Each subdir holds a scenario:
| Scenario | Source |
|--------------------|-------------------------------------------|
| `cascade-vpcs` | Synthetic: one Phase 5 root + 15 cascades |
| `single-failure` | Real artifact from run `26037892507` |
| `no-failures` | Synthetic: clean run |
Run normally:
```sh
go test ./.github/actions/e2e-analyze/
```
When the report format changes intentionally, regenerate the snapshots:
```sh
go test ./.github/actions/e2e-analyze/ -update
```
## Per-failure bundles (Stage 2)
After parsing, the action materialises a bundle directory per failure
under `<log-dir>/analysis/NN-<TestName>/`. The report links to it from
each entry so a reader on the Actions page can click straight through
to the relevant log slice.
Each bundle contains:
| File | Source |
|-------------------|-----------------------------------------------------------------------------------|
| `failure.txt` | Synthesised summary: test, start, duration, file-site, error line. |
| `journal.log` | `spinifex-journal.log` sliced to `[StartAt − 5s, StartAt + Duration + 5s]`. |
| `test.log` | `test-<suite>.log` block between `=== RUN <name>` and the matching `--- FAIL`. |
| `<other files>` | Anything the harness already dropped into `<log-dir>/<TestName>/` at run-time. |
The journal slice relies on the workflow running
`journalctl --output=short-iso` so timestamps are parseable. Lines
without a parseable timestamp inherit the previous line's
in-window/out-of-window classification (so multi-line records and stack
traces stay contiguous).
## Roadmap
- **Stage 1 (shipped):** first-failure surfacing + cascade clustering.
- **Stage 2 (this update):** time-window correlation — per-failure bundle
with sliced journal + test-log + any per-test artifacts.
- **Stage 3:** rule-based root-cause classifier driven by
`spinifex/tests/e2e/.failure-rules.yaml`.
See `docs/development/improvements/e2e-go-failure-analysis.md` in the
parent mulga repo.
+38
View File
@@ -0,0 +1,38 @@
name: E2E Failure Analyzer
description: >-
Post-test analyzer for the Go E2E suites. Parses junit-*.xml emitted by
go-junit-report, clusters failures by signature, picks the earliest
non-cascade failure per suite as the likely root cause, and appends a
human-readable report to $GITHUB_STEP_SUMMARY. Always exits 0 — the
existing "Fail job if any suite failed" gate keeps owning exit-code
semantics.
inputs:
junit-glob:
description: Shell glob matching JUnit XML files to analyze.
required: true
log-dir:
description: >-
Artifact directory. The rendered report is also written to
$log-dir/analysis.md so it travels with the uploaded artifact.
required: true
title:
description: Heading rendered at the top of the summary section.
required: false
default: "E2E failure analysis"
runs:
using: composite
steps:
- id: analyze
shell: bash
working-directory: ${{ github.action_path }}
env:
ANALYZE_JUNIT_GLOB: ${{ inputs.junit-glob }}
ANALYZE_LOG_DIR: ${{ inputs.log-dir }}
ANALYZE_TITLE: ${{ inputs.title }}
run: |
go run . \
-junit-glob "$ANALYZE_JUNIT_GLOB" \
-log-dir "$ANALYZE_LOG_DIR" \
-title "$ANALYZE_TITLE"
+552
View File
@@ -0,0 +1,552 @@
// Package main implements the e2e-analyze GitHub Action.
//
// Stage 1 of docs/development/improvements/e2e-go-failure-analysis.md:
// cluster failures from go-junit-report's junit-*.xml by error signature,
// pick the earliest non-cascade failure per suite as the likely root cause,
// and render a human-readable report.
//
// This file contains the pure logic (parsing, signature extraction,
// clustering, rendering). Side-effect handling lives in main.go.
package main
import (
"encoding/xml"
"fmt"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
)
// JUnit XML schema produced by go-junit-report v2. Stable across versions
// we care about; only fields used downstream are decoded.
type junitSuites struct {
XMLName xml.Name `xml:"testsuites"`
Suites []junitSuite `xml:"testsuite"`
}
type junitSuite struct {
Name string `xml:"name,attr"`
Tests int `xml:"tests,attr"`
Failures int `xml:"failures,attr"`
Errors int `xml:"errors,attr"`
Skipped int `xml:"skipped,attr"`
Timestamp string `xml:"timestamp,attr"`
Time float64 `xml:"time,attr"`
Cases []junitTC `xml:"testcase"`
}
type junitTC struct {
Name string `xml:"name,attr"`
Classname string `xml:"classname,attr"`
Time float64 `xml:"time,attr"`
Failure *junitFailure `xml:"failure"`
Error *junitFailure `xml:"error"`
Skipped *junitSkipped `xml:"skipped"`
SystemOut string `xml:"system-out"`
}
type junitFailure struct {
Message string `xml:"message,attr"`
Type string `xml:"type,attr"`
Body string `xml:",chardata"`
}
type junitSkipped struct {
Message string `xml:"message,attr"`
}
// Failure is the analyzer's normalized view of one failed testcase.
type Failure struct {
SuiteFile string // e.g. "single" (derived from junit-single.xml)
Name string // e.g. "TestSingleNode/Phase5_LaunchInstance"
Duration float64 // seconds
Order int // position within the suite (0-based)
StartAt time.Time // estimated wall-clock start: suite.timestamp + sum(prior durations)
Error string // last meaningful error line, ANSI-stripped
FileHint string // e.g. "vpc_test.go:227", parsed from failure body
Cascade bool // signature matched a known downstream pattern
Signature string // normalized error key for bucketing
// OffsetFromSuiteStart is sum(prior testcase durations) at the time
// this test started. Persisted separately from StartAt so a caller
// that has a more authoritative suite wall-clock origin (e.g. a
// `test-<suite>.start` file written by the workflow before the suite
// runs) can recompute StartAt without re-parsing the XML.
OffsetFromSuiteStart time.Duration
// BundlePath is the relative path (from log-dir) to the per-failure
// bundle written by Stage 2's WriteBundles. Empty when bundles were
// not generated (e.g. unit tests that call Render directly).
BundlePath string
}
// SuiteReport summarises one junit-*.xml file.
type SuiteReport struct {
File string // basename, e.g. "junit-single.xml"
Label string // short name, e.g. "single"
Total int
FailCount int
Root *Failure // nil if no failures
Cascades []Failure // everything else that failed in this suite
Buckets [][]Failure // failures grouped by signature (root's bucket first)
}
// Report is the full analysis across every junit file.
type Report struct {
Title string
Suites []SuiteReport
}
// ansiSeq matches ANSI CSI escapes the harness sprinkles through log
// output (colours, bold). Three forms in the wild:
// - real ESC: `\x1b[36m…`
// - U+FFFD replacement (XML serialiser dropped the ESC byte): `�[36m…`
// - bare CSI with the leading byte gone entirely: `[36m…`
//
// All three are stripped so colour state doesn't fork the signature bucket.
var ansiSeq = regexp.MustCompile(
"\x1b\\[[0-9;]*[a-zA-Z]" + // real ESC CSI
"|�\\[[0-9;]*[a-zA-Z]" + // replacement-char CSI
"|\\[[0-9]{1,2}(?:;[0-9]{1,2})*m", // bare colour code, "mode" form only
)
// cascadeMarkers are message fragments that only appear in tests that
// depend on a fixture field populated by an earlier phase. Their presence
// means the failure is downstream of some other failure that happened
// first; the report collapses them under that root cause.
var cascadeMarkers = []string{
"must populate fix.",
"Should NOT be empty",
"Expected value not to be nil",
}
// noiseReplacers normalise dynamic identifiers so that "ten failures, same
// bug" bucket together. Pattern ordering matters: longer/more-specific
// patterns must precede the catch-alls.
var noiseReplacers = []struct {
re *regexp.Regexp
repl string
}{
{regexp.MustCompile(`\b(vpc|subnet|sg|rtb|igw|i|ami|vol|snap|rtbassoc|key)-[0-9a-f]{8,}\b`), `${1}-<id>`},
{regexp.MustCompile(`\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b`), `<uuid>`},
{regexp.MustCompile(`\b[0-9a-f]{32,}\b`), `<hex>`},
{regexp.MustCompile(`\b\d{1,3}(\.\d{1,3}){3}(:\d+)?\b`), `<ip>`},
{regexp.MustCompile(`\b\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?\b`), `<ts>`},
{regexp.MustCompile(`request-id:\s*\S+`), `request-id: <id>`},
{regexp.MustCompile(`(_test\.go):\d+`), `${1}:<line>`},
{regexp.MustCompile(`\s+`), ` `},
}
// fileHintRe pulls the last "foo_test.go:NNN" we see in a failure body so
// the report can point a reader at the source line. Test files dominate the
// hint set; harness/helper file matches go through goFileLineRe below.
var fileHintRe = regexp.MustCompile(`([A-Za-z0-9_]+_test\.go:\d+)`)
// goFileLineRe matches any "foo.go:NNN:" prefix (test or non-test). Used to
// surface harness/helper-level error text like
// `ec2helpers.go:164: describe-vpcs: InvalidParameterValue …` which Go test
// emits when a non-test file calls t.Errorf.
var goFileLineRe = regexp.MustCompile(`^[A-Za-z0-9_]+\.go:\d+:\s*(.+)$`)
// testifyErrorPlaceholder is testify's "Error:" boilerplate when the
// assertion failed because of a wrapped error. The next non-blank line in
// the trace holds the actual error text.
const testifyErrorPlaceholder = "Received unexpected error:"
// stripANSI removes CSI escapes; leaves everything else intact.
func stripANSI(s string) string {
return ansiSeq.ReplaceAllString(s, "")
}
// extractErrorLine finds the most useful single-line summary in a failure
// body. Priority order, highest first:
// 1. Testify "Messages:" line — user-supplied context, the most signal-rich.
// 2. Testify "Error:" content (with one-line lookahead when the placeholder
// "Received unexpected error:" defers to the next line).
// 3. Leading "foo.go:NNN: text" content line — t.Errorf from non-test code.
// 4. Last `_test.go:NNN: text` line — bare t.Fatalf/Errorf from a test file.
// 5. First non-empty line of the body.
func extractErrorLine(body string) string {
body = stripANSI(body)
lines := strings.Split(body, "\n")
var (
msgLine string
errLine string
goFileLine string
lastTestLine string
)
for i, raw := range lines {
l := strings.TrimSpace(raw)
if l == "" {
continue
}
if strings.HasPrefix(l, "Messages:") {
msgLine = strings.TrimSpace(strings.TrimPrefix(l, "Messages:"))
continue
}
if strings.HasPrefix(l, "Error:") && errLine == "" {
content := strings.TrimSpace(strings.TrimPrefix(l, "Error:"))
if content == testifyErrorPlaceholder {
// Look ahead for the wrapped error text on the next non-blank line.
for j := i + 1; j < len(lines); j++ {
nxt := strings.TrimSpace(lines[j])
if nxt == "" {
continue
}
if strings.HasPrefix(nxt, "Test:") || strings.HasPrefix(nxt, "Messages:") {
break
}
content = content + " " + nxt
break
}
}
errLine = content
continue
}
if m := goFileLineRe.FindStringSubmatch(l); m != nil {
// Keep the LAST occurrence: t.Log / harness traces emit
// progress lines from the same file:line pattern before the
// failing assertion finally fires.
goFileLine = strings.TrimSpace(m[1])
continue
}
if fileHintRe.MatchString(l) {
lastTestLine = l
}
}
switch {
case msgLine != "":
return msgLine
case errLine != "":
return errLine
case goFileLine != "":
return goFileLine
case lastTestLine != "":
// "vpc_test.go:227: Eventually: condition not met …" → trim the file prefix.
if i := strings.Index(lastTestLine, ": "); i > 0 {
return strings.TrimSpace(lastTestLine[i+2:])
}
return lastTestLine
}
// Last resort: first non-empty line of the body.
for _, raw := range lines {
l := strings.TrimSpace(raw)
if l != "" {
return l
}
}
return ""
}
// errorTraceRe extracts the path from testify's "Error Trace:" line — e.g.
//
// Error Trace: tests/e2e/harness/ec2helpers.go:164
//
// — which points at the assertion site even when it lives in helper code
// outside *_test.go.
var errorTraceRe = regexp.MustCompile(`Error Trace:\s*(\S+\.go:\d+)`)
// extractFileHint returns the most precise pointer at the assertion site
// we can pull from the failure body: testify Error Trace wins, otherwise
// the last `*_test.go:NNN` line in the body.
func extractFileHint(body string) string {
body = stripANSI(body)
if m := errorTraceRe.FindStringSubmatch(body); m != nil {
return m[1]
}
all := fileHintRe.FindAllString(body, -1)
if len(all) == 0 {
return ""
}
return all[len(all)-1]
}
// signature normalises an error line so different runs of the same root
// cause bucket together. Empty signatures (e.g. parent-test placeholders
// where the failure body is empty) bucket together under "".
func signature(errLine string) string {
s := strings.ToLower(errLine)
for _, r := range noiseReplacers {
s = r.re.ReplaceAllString(s, r.repl)
}
return strings.TrimSpace(s)
}
// isCascade reports whether the error line contains any of the well-known
// fixture-propagation markers — i.e. "this test failed because something
// upstream of it failed first".
func isCascade(errLine string) bool {
for _, m := range cascadeMarkers {
if strings.Contains(errLine, m) {
return true
}
}
return false
}
// computeLeafSet returns the testcase names that are leaves — i.e. no
// other testcase name in `cases` starts with `<name>/`. go-junit-report
// emits one <testcase> for the parent Go test AND each subtest, with the
// parent's time = sum of its children, so summing every entry into the
// suite's cumulative offset double-counts. Restrict accumulation to
// leaves only.
func computeLeafSet(cases []junitTC) map[string]bool {
leaf := make(map[string]bool, len(cases))
for _, tc := range cases {
leaf[tc.Name] = true
}
for _, tc := range cases {
// Strip the deepest path segment; any ancestor of this test is
// not a leaf.
name := tc.Name
for {
i := strings.LastIndex(name, "/")
if i <= 0 {
break
}
name = name[:i]
if _, ok := leaf[name]; ok {
leaf[name] = false
}
}
}
return leaf
}
// parseStartTime turns a junit timestamp attr (RFC3339) into a time.Time;
// zero value if unparseable.
func parseStartTime(s string) time.Time {
if s == "" {
return time.Time{}
}
t, err := time.Parse(time.RFC3339, s)
if err != nil {
return time.Time{}
}
return t
}
// suiteLabel turns "junit-single.xml" into "single".
func suiteLabel(path string) string {
base := filepath.Base(path)
base = strings.TrimSuffix(base, ".xml")
base = strings.TrimPrefix(base, "junit-")
if base == "" {
return filepath.Base(path)
}
return base
}
// ParseFile decodes one junit-*.xml file into our normalized failure list.
// Returns the suite label, total testcase count, and the (ordered) failures.
func ParseFile(path string, data []byte) (SuiteReport, error) {
var doc junitSuites
if err := xml.Unmarshal(data, &doc); err != nil {
return SuiteReport{}, fmt.Errorf("parse %s: %w", path, err)
}
rep := SuiteReport{
File: filepath.Base(path),
Label: suiteLabel(path),
}
order := 0
for _, suite := range doc.Suites {
start := parseStartTime(suite.Timestamp)
// go-junit-report emits a testcase for both the parent Go test
// (e.g. `TestSingleNode`) AND each subtest (`TestSingleNode/PhaseX`).
// The parent's `time` attribute is the aggregate of its children, so
// summing every testcase double-counts. Pre-compute the leaf set so
// only leaves contribute to the suite's running offset.
isLeaf := computeLeafSet(suite.Cases)
cumul := 0.0
for _, tc := range suite.Cases {
rep.Total++
leaf := isLeaf[tc.Name]
var body string
switch {
case tc.Failure != nil:
body = tc.Failure.Body
case tc.Error != nil:
body = tc.Error.Body
default:
if leaf {
cumul += tc.Time
}
continue
}
// Skip the synthetic parent-test "Failed" stub that go-junit-report
// emits for any Go test whose subtests failed. It has an empty body
// and would otherwise outrank real failures by XML order.
trimmed := strings.TrimSpace(stripANSI(body))
if trimmed == "" {
if leaf {
cumul += tc.Time
}
continue
}
errLine := extractErrorLine(body)
offset := time.Duration(cumul * float64(time.Second))
f := Failure{
SuiteFile: rep.Label,
Name: tc.Name,
Duration: tc.Time,
Order: order,
Error: errLine,
FileHint: extractFileHint(body),
Cascade: isCascade(errLine),
Signature: signature(errLine),
OffsetFromSuiteStart: offset,
}
if !start.IsZero() {
f.StartAt = start.Add(offset)
}
rep.FailCount++
rep.Cascades = append(rep.Cascades, f) // temp staging; split below
order++
if leaf {
cumul += tc.Time
}
}
}
if rep.FailCount == 0 {
rep.Cascades = nil
return rep, nil
}
all := rep.Cascades
rep.Cascades = nil
// Root = earliest non-cascade. If every failure is a cascade (the whole
// suite tripped on the same upstream gap), fall back to earliest overall.
rootIdx := -1
for i, f := range all {
if !f.Cascade {
rootIdx = i
break
}
}
if rootIdx == -1 {
rootIdx = 0
}
root := all[rootIdx]
rep.Root = &root
// Bucket the remainder by signature so the report can collapse the
// "ten failures, same bug" pattern.
bucketKeys := []string{}
buckets := map[string][]Failure{}
for i, f := range all {
if i == rootIdx {
continue
}
if _, seen := buckets[f.Signature]; !seen {
bucketKeys = append(bucketKeys, f.Signature)
}
buckets[f.Signature] = append(buckets[f.Signature], f)
rep.Cascades = append(rep.Cascades, f)
}
// Largest bucket first; ties broken by first-seen order so output is
// deterministic across runs.
sort.SliceStable(bucketKeys, func(i, j int) bool {
return len(buckets[bucketKeys[i]]) > len(buckets[bucketKeys[j]])
})
for _, k := range bucketKeys {
rep.Buckets = append(rep.Buckets, buckets[k])
}
return rep, nil
}
// Render writes the markdown report. Format is documented in
// docs/development/improvements/e2e-go-failure-analysis.md (Stage 1).
func Render(r Report) string {
var b strings.Builder
fmt.Fprintf(&b, "## %s\n\n", r.Title)
totalFail := 0
for _, s := range r.Suites {
totalFail += s.FailCount
}
if totalFail == 0 {
b.WriteString("✅ No failures across any suite.\n")
return b.String()
}
for _, s := range r.Suites {
if s.FailCount == 0 {
fmt.Fprintf(&b, "### Suite `%s`: ✅ pass (%d tests)\n\n", s.Label, s.Total)
continue
}
fmt.Fprintf(&b, "### Suite `%s`: %d failed, 1 root cause likely\n\n", s.Label, s.FailCount)
if s.Root != nil {
b.WriteString("**Root cause (earliest non-cascade)**\n\n")
fmt.Fprintf(&b, "- Test: `%s`\n", s.Root.Name)
if !s.Root.StartAt.IsZero() {
fmt.Fprintf(&b, "- Start: %s (duration %.1fs)\n",
s.Root.StartAt.Format("15:04:05"), s.Root.Duration)
} else {
fmt.Fprintf(&b, "- Duration: %.1fs\n", s.Root.Duration)
}
if s.Root.Error != "" {
fmt.Fprintf(&b, "- Error: %s\n", oneLine(s.Root.Error))
}
if s.Root.FileHint != "" {
fmt.Fprintf(&b, "- File: `%s`\n", s.Root.FileHint)
}
if s.Root.BundlePath != "" {
fmt.Fprintf(&b, "- Bundle: `%s/`\n", s.Root.BundlePath)
}
b.WriteString("\n")
}
if len(s.Buckets) > 0 {
fmt.Fprintf(&b, "**Cascaded failures (%d) — grouped by signature**\n\n",
len(s.Cascades))
for _, bucket := range s.Buckets {
sample := bucket[0]
label := oneLine(sample.Error)
if label == "" {
label = "(no error message)"
}
fmt.Fprintf(&b, "- %d× %s\n", len(bucket), label)
// List up to 5 test names per bucket; collapse the rest.
const cap = 5
for i, f := range bucket {
if i == cap && len(bucket) > cap {
fmt.Fprintf(&b, " - … and %d more\n", len(bucket)-cap)
break
}
if f.BundlePath != "" {
fmt.Fprintf(&b, " - `%s` → `%s/`\n", f.Name, f.BundlePath)
} else {
fmt.Fprintf(&b, " - `%s`\n", f.Name)
}
}
}
b.WriteString("\n")
}
}
return b.String()
}
// oneLine collapses whitespace and caps length so a runaway assertion
// message doesn't blow out the summary card width.
func oneLine(s string) string {
s = strings.TrimSpace(s)
s = strings.ReplaceAll(s, "\n", " ")
s = strings.ReplaceAll(s, "\r", " ")
s = regexp.MustCompile(`\s+`).ReplaceAllString(s, " ")
const max = 160
if len(s) > max {
s = s[:max-1] + "…"
}
return s
}
+191
View File
@@ -0,0 +1,191 @@
package main
import (
"flag"
"os"
"path/filepath"
"sort"
"strings"
"testing"
)
// updateGolden lets `go test ./.github/actions/e2e-analyze/ -update` rewrite
// the expected analysis.md files when the rendering format changes
// intentionally. Day-to-day, leave it unset: tests diff against the on-disk
// snapshot to catch unintended drift in signature extraction or layout.
var updateGolden = flag.Bool("update", false, "rewrite testdata/*/analysis.md from current output")
// TestGoldenFixtures runs the full ParseFile + Render pipeline against each
// fixture directory under testdata/ and asserts the output matches the
// adjacent analysis.md snapshot. Each subdir is one named scenario; the
// fixture XMLs match the same junit-*.xml glob the production action uses
// so behaviour stays representative of CI.
func TestGoldenFixtures(t *testing.T) {
entries, err := os.ReadDir("testdata")
if err != nil {
t.Fatalf("read testdata: %v", err)
}
for _, e := range entries {
if !e.IsDir() {
continue
}
name := e.Name()
t.Run(name, func(t *testing.T) {
dir := filepath.Join("testdata", name)
matches, err := filepath.Glob(filepath.Join(dir, "junit-*.xml"))
if err != nil {
t.Fatalf("glob: %v", err)
}
sort.Strings(matches)
if len(matches) == 0 {
t.Fatalf("no junit-*.xml fixtures in %s", dir)
}
rep := Report{Title: "E2E failure analysis"}
for _, p := range matches {
data, err := os.ReadFile(p)
if err != nil {
t.Fatalf("read %s: %v", p, err)
}
sr, err := ParseFile(p, data)
if err != nil {
t.Fatalf("parse %s: %v", p, err)
}
rep.Suites = append(rep.Suites, sr)
}
got := Render(rep)
goldenPath := filepath.Join(dir, "analysis.md")
if *updateGolden {
if err := os.WriteFile(goldenPath, []byte(got), 0o644); err != nil {
t.Fatalf("write golden: %v", err)
}
return
}
want, err := os.ReadFile(goldenPath)
if err != nil {
t.Fatalf("read golden (run with -update to seed): %v", err)
}
if string(want) != got {
t.Errorf("rendered output differs from %s\n--- want ---\n%s\n--- got ---\n%s",
goldenPath, string(want), got)
}
})
}
}
func TestSignatureCollapsesNoise(t *testing.T) {
a := signature("describe-vpcs: InvalidParameterValue request-id: abc-123 vpc-deadbeefcafe1234")
b := signature("describe-vpcs: InvalidParameterValue request-id: xyz-999 vpc-1111222233334444")
if a != b {
t.Errorf("signatures should collapse to same bucket:\n a=%q\n b=%q", a, b)
}
if !strings.Contains(a, "<id>") || !strings.Contains(a, "vpc-<id>") {
t.Errorf("expected normalised placeholders in signature, got %q", a)
}
}
func TestCascadeDetection(t *testing.T) {
cases := []struct {
in string
want bool
}{
{"Phase 5 must populate fix.InstanceID", true},
{"Should NOT be empty", true},
{"Expected value not to be nil", true},
{"describe-vpcs: InvalidParameterValue", false},
{"Eventually: condition not met within 3m0s", false},
}
for _, c := range cases {
if got := isCascade(c.in); got != c.want {
t.Errorf("isCascade(%q) = %v, want %v", c.in, got, c.want)
}
}
}
func TestExtractErrorLine_MessagesWinsOverError(t *testing.T) {
body := ` lifecycle_test.go:28:
Error Trace: tests/e2e/single/lifecycle_test.go:28
Error: Should NOT be empty
Messages: Phase 5 must populate fix.InstanceID
Test: TestSingleNode/Phase5a_pre_ClusterStats`
got := extractErrorLine(body)
want := "Phase 5 must populate fix.InstanceID"
if got != want {
t.Errorf("extractErrorLine = %q, want %q", got, want)
}
}
func TestExtractErrorLine_FallsBackToLastTestLine(t *testing.T) {
body := " vpc_test.go:227: Eventually: condition not met within 3m0s: [SSH handshake never completed]"
got := extractErrorLine(body)
if !strings.HasPrefix(got, "Eventually:") {
t.Errorf("expected fallback to test-line content, got %q", got)
}
}
func TestExtractFileHint_PicksLastMatch(t *testing.T) {
body := "ec2helpers.go:50: setup\n vpc_test.go:227: Eventually: condition not met"
got := extractFileHint(body)
want := "vpc_test.go:227"
if got != want {
t.Errorf("extractFileHint = %q, want %q", got, want)
}
}
func TestParseFile_SkipsEmptyParentFailure(t *testing.T) {
// The synthetic "TestSingleNode" parent gets a <failure message="Failed"/>
// with no body whenever any subtest fails. The analyzer must skip it so
// it doesn't outrank the real first failure on XML order.
xml := []byte(`<?xml version="1.0"?>
<testsuites><testsuite name="" tests="2" failures="2">
<testcase name="TestX" time="1.0"><failure message="Failed"></failure></testcase>
<testcase name="TestX/RealFailure" time="0.5"><failure message="Failed"><![CDATA[
foo_test.go:10:
Messages: real assertion message
]]></failure></testcase>
</testsuite></testsuites>`)
sr, err := ParseFile("junit-x.xml", xml)
if err != nil {
t.Fatalf("ParseFile: %v", err)
}
if sr.FailCount != 1 {
t.Fatalf("FailCount = %d, want 1 (empty parent should be skipped)", sr.FailCount)
}
if sr.Root == nil || sr.Root.Name != "TestX/RealFailure" {
t.Fatalf("expected real failure as root, got %+v", sr.Root)
}
}
func TestParseFile_CascadeFallbackWhenAllCascades(t *testing.T) {
// If every failure is a cascade marker, the earliest one becomes root
// so the report isn't blank.
xml := []byte(`<?xml version="1.0"?>
<testsuites><testsuite name="" tests="2" failures="2">
<testcase name="TestX/A" time="1.0"><failure message="Failed"><![CDATA[
Messages: Phase 5 must populate fix.InstanceID
]]></failure></testcase>
<testcase name="TestX/B" time="1.0"><failure message="Failed"><![CDATA[
Messages: Phase 5 must populate fix.InstanceID
]]></failure></testcase>
</testsuite></testsuites>`)
sr, err := ParseFile("junit-x.xml", xml)
if err != nil {
t.Fatalf("ParseFile: %v", err)
}
if sr.Root == nil || sr.Root.Name != "TestX/A" {
t.Fatalf("expected TestX/A as fallback root, got %+v", sr.Root)
}
}
func TestRender_NoFailuresIsClean(t *testing.T) {
out := Render(Report{
Title: "E2E failure analysis",
Suites: []SuiteReport{
{File: "junit-single.xml", Label: "single", Total: 3, FailCount: 0},
},
})
if !strings.Contains(out, "No failures") {
t.Errorf("expected zero-failure banner, got:\n%s", out)
}
}
+208
View File
@@ -0,0 +1,208 @@
package main
// Stage 2 of docs/development/improvements/e2e-go-failure-analysis.md:
// for each failure surfaced by Stage 1, materialise a per-failure bundle
// directory containing the failure summary, a time-window slice of the
// daemon journal, a testcase-window slice of the suite's stdout log, and
// any per-test artifacts the harness already dumped at run-time.
import (
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"regexp"
"strings"
"time"
)
// bundlePad is the per-failure window padding either side of the
// [StartAt, StartAt+Duration] testcase span. Five seconds matches the
// plan doc: long enough to catch the daemon's pre-test setup and
// post-test cleanup chatter, short enough to keep the slice focused.
const bundlePad = 5 * time.Second
// nameSanitiseRe replaces characters unsafe in directory names with `_`.
// Tracks tests/e2e/harness/artifacts.go which uses the same convention
// when the test's own dump dir is created, so we can pick its contents
// up by path without a second translation table.
var nameSanitiseRe = regexp.MustCompile(`[^A-Za-z0-9._-]+`)
func sanitiseTestName(s string) string {
return nameSanitiseRe.ReplaceAllString(s, "_")
}
// ApplySuiteStartFiles overrides each failure's StartAt by reading
// <logDir>/test-<suiteLabel>.start (RFC3339 timestamp written by the
// workflow before the suite runs). Required because go-junit-report's
// `timestamp` attribute on <testsuite> records the time the XML was
// produced — i.e. after the suite has finished — so it can't be used
// as the suite wall-clock origin for journal slicing.
//
// Missing or unparseable files are silently ignored: the analyzer
// falls back to the junit timestamp and the bundle still gets created,
// it just may have an empty (or off-window) journal slice.
func ApplySuiteStartFiles(rep *Report, logDir string) {
if logDir == "" {
return
}
for si := range rep.Suites {
s := &rep.Suites[si]
startPath := filepath.Join(logDir, fmt.Sprintf("test-%s.start", s.Label))
data, err := os.ReadFile(startPath)
if err != nil {
continue
}
ts, err := time.Parse(time.RFC3339, strings.TrimSpace(string(data)))
if err != nil {
fmt.Fprintf(os.Stderr, "::warning::e2e-analyze: parse %s: %v\n", startPath, err)
continue
}
if s.Root != nil {
s.Root.StartAt = ts.Add(s.Root.OffsetFromSuiteStart)
}
for ci := range s.Cascades {
s.Cascades[ci].StartAt = ts.Add(s.Cascades[ci].OffsetFromSuiteStart)
}
for bi := range s.Buckets {
for fi := range s.Buckets[bi] {
f := &s.Buckets[bi][fi]
f.StartAt = ts.Add(f.OffsetFromSuiteStart)
}
}
}
}
// WriteBundles materialises per-failure bundles under <logDir>/analysis/.
// Mutates rep in-place so each Failure.BundlePath holds the relative
// path the renderer will link from the summary.
//
// Failures with no parseable wall-clock start still get a bundle dir
// (and the testcase-window test.log slice), but no journal slice — the
// time window is unknown.
//
// Errors are non-fatal: a bundle that partially fails still gets linked
// from the report so a reader sees what's available, and the analyzer
// keeps printing warnings so CI logs surface the gap.
func WriteBundles(rep *Report, logDir string) {
if logDir == "" {
return
}
base := filepath.Join(logDir, "analysis")
if err := os.MkdirAll(base, 0o755); err != nil {
fmt.Fprintf(os.Stderr, "::warning::e2e-analyze: mkdir %s: %v\n", base, err)
return
}
for si := range rep.Suites {
s := &rep.Suites[si]
if s.Root != nil {
writeOneBundle(logDir, base, s.Label, s.Root)
}
for ci := range s.Cascades {
writeOneBundle(logDir, base, s.Label, &s.Cascades[ci])
}
// Bucket entries share storage with Cascades but live in a
// separate slice in SuiteReport; mirror the bundle path so the
// renderer's bucket loop sees it too.
for bi := range s.Buckets {
for fi := range s.Buckets[bi] {
f := &s.Buckets[bi][fi]
if bp := findBundlePath(s, f.Name); bp != "" {
f.BundlePath = bp
}
}
}
}
}
// findBundlePath looks up a Failure's already-written BundlePath by test
// name within a suite. Buckets reference the same logical failure as
// Cascades but are independent slice values, so we copy the path across
// rather than re-deriving it.
func findBundlePath(s *SuiteReport, testName string) string {
if s.Root != nil && s.Root.Name == testName {
return s.Root.BundlePath
}
for _, c := range s.Cascades {
if c.Name == testName {
return c.BundlePath
}
}
return ""
}
func writeOneBundle(logDir, base, suiteLabel string, f *Failure) {
name := fmt.Sprintf("%02d-%s", f.Order+1, sanitiseTestName(f.Name))
dir := filepath.Join(base, name)
if err := os.MkdirAll(dir, 0o755); err != nil {
fmt.Fprintf(os.Stderr, "::warning::e2e-analyze: mkdir %s: %v\n", dir, err)
return
}
f.BundlePath = filepath.Join("analysis", name)
writeFailureSummary(dir, suiteLabel, f)
if !f.StartAt.IsZero() {
start := f.StartAt.Add(-bundlePad)
end := f.StartAt.Add(time.Duration(f.Duration * float64(time.Second))).Add(bundlePad)
journalSrc := filepath.Join(logDir, "spinifex-journal.log")
if _, err := SliceJournal(journalSrc, start, end, filepath.Join(dir, "journal.log")); err != nil {
fmt.Fprintf(os.Stderr, "::warning::e2e-analyze: slice journal for %s: %v\n", f.Name, err)
}
}
testLogSrc := filepath.Join(logDir, fmt.Sprintf("test-%s.log", suiteLabel))
if _, err := SliceTestLog(testLogSrc, f.Name, filepath.Join(dir, "test.log")); err != nil {
fmt.Fprintf(os.Stderr, "::warning::e2e-analyze: slice test log for %s: %v\n", f.Name, err)
}
// Per-test artifact subdir written by harness.ArtifactDir lives at
// <logDir>/<TestName with / replaced by _>/. Copy any files into
// the bundle so qemu/ovs/etc. dumps already collected by the test
// travel alongside the analysis.
perTestSrc := filepath.Join(logDir, strings.ReplaceAll(f.Name, "/", "_"))
if entries, err := os.ReadDir(perTestSrc); err == nil {
for _, e := range entries {
if e.IsDir() {
continue
}
_ = copyFile(filepath.Join(perTestSrc, e.Name()), filepath.Join(dir, e.Name()))
}
} else if !os.IsNotExist(err) && !errorsIsNotDir(err) {
fmt.Fprintf(os.Stderr, "::warning::e2e-analyze: read per-test dir %s: %v\n", perTestSrc, err)
}
}
// errorsIsNotDir handles the case where the harness wrote a file at the
// per-test name instead of a directory — best-effort skip.
func errorsIsNotDir(err error) bool {
var pe *fs.PathError
if !errors.As(err, &pe) {
return false
}
return pe.Err != nil && strings.Contains(pe.Err.Error(), "not a directory")
}
func writeFailureSummary(dir, suiteLabel string, f *Failure) {
var b strings.Builder
fmt.Fprintf(&b, "Suite: %s\n", suiteLabel)
fmt.Fprintf(&b, "Test: %s\n", f.Name)
if !f.StartAt.IsZero() {
fmt.Fprintf(&b, "Start: %s\n", f.StartAt.Format(time.RFC3339))
}
fmt.Fprintf(&b, "Duration: %.3fs\n", f.Duration)
if f.FileHint != "" {
fmt.Fprintf(&b, "Site: %s\n", f.FileHint)
}
if f.Cascade {
b.WriteString("Cascade: yes (downstream of another failure)\n")
}
if f.Error != "" {
b.WriteString("\nError:\n")
b.WriteString(oneLine(f.Error))
b.WriteString("\n")
}
_ = os.WriteFile(filepath.Join(dir, "failure.txt"), []byte(b.String()), 0o644)
}
+229
View File
@@ -0,0 +1,229 @@
package main
import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestSliceJournal_KeepsOnlyWindow(t *testing.T) {
src := filepath.Join(t.TempDir(), "journal.log")
dst := filepath.Join(t.TempDir(), "out.log")
body := strings.Join([]string{
"2026-05-19T12:32:30+1000 host spinifex-daemon[1]: pre",
" continuation of pre",
"2026-05-19T12:32:35+1000 host spinifex-daemon[1]: IN window",
" multiline inside window",
"2026-05-19T12:32:45+1000 host spinifex-daemon[1]: post",
"",
}, "\n")
if err := os.WriteFile(src, []byte(body), 0o644); err != nil {
t.Fatalf("write src: %v", err)
}
start, _ := time.Parse(time.RFC3339, "2026-05-19T12:32:33+10:00")
end, _ := time.Parse(time.RFC3339, "2026-05-19T12:32:40+10:00")
n, err := SliceJournal(src, start, end, dst)
if err != nil {
t.Fatalf("SliceJournal: %v", err)
}
if n != 2 {
t.Fatalf("written = %d, want 2", n)
}
got, _ := os.ReadFile(dst)
if !strings.Contains(string(got), "IN window") || !strings.Contains(string(got), "multiline inside window") {
t.Errorf("missing in-window content:\n%s", got)
}
if strings.Contains(string(got), "pre") || strings.Contains(string(got), "post") {
t.Errorf("kept out-of-window content:\n%s", got)
}
}
func TestSliceJournal_AcceptsRFC3339ColonOffset(t *testing.T) {
// systemd's `journalctl --output=short-iso` ships RFC3339 timestamps
// (offset includes a colon, e.g. "+10:00") on most versions. Earlier
// regex only matched "+1000" — make sure both forms work.
src := filepath.Join(t.TempDir(), "journal.log")
dst := filepath.Join(t.TempDir(), "out.log")
body := strings.Join([]string{
"2026-05-19T12:32:30+10:00 host spinifex-daemon[1]: pre",
"2026-05-19T12:32:35+10:00 host spinifex-daemon[1]: IN window",
"2026-05-19T12:32:50+10:00 host spinifex-daemon[1]: post",
"",
}, "\n")
if err := os.WriteFile(src, []byte(body), 0o644); err != nil {
t.Fatalf("write src: %v", err)
}
start, _ := time.Parse(time.RFC3339, "2026-05-19T12:32:33+10:00")
end, _ := time.Parse(time.RFC3339, "2026-05-19T12:32:40+10:00")
n, err := SliceJournal(src, start, end, dst)
if err != nil {
t.Fatalf("SliceJournal: %v", err)
}
if n != 1 {
t.Fatalf("written = %d, want 1", n)
}
got, _ := os.ReadFile(dst)
if !strings.Contains(string(got), "IN window") {
t.Errorf("missing in-window content:\n%s", got)
}
}
func TestSliceJournal_MissingSrcIsNotError(t *testing.T) {
dst := filepath.Join(t.TempDir(), "out.log")
n, err := SliceJournal("/no/such/file", time.Now(), time.Now(), dst)
if err != nil {
t.Fatalf("unexpected err: %v", err)
}
if n != 0 {
t.Errorf("written = %d, want 0", n)
}
}
func TestSliceTestLog_PicksOneTestcase(t *testing.T) {
src := filepath.Join(t.TempDir(), "test.log")
dst := filepath.Join(t.TempDir(), "out.log")
body := strings.Join([]string{
"=== RUN TestSingleNode",
" before subtests",
"=== RUN TestSingleNode/Phase5_LaunchInstance",
" runinstances",
" ec2helpers.go:164: describe-vpcs: InvalidParameterValue",
"--- FAIL: TestSingleNode/Phase5_LaunchInstance (2.50s)",
"=== RUN TestSingleNode/Phase6_Volumes",
" other content",
"--- FAIL: TestSingleNode/Phase6_Volumes (0.10s)",
"--- FAIL: TestSingleNode (10.0s)",
"",
}, "\n")
if err := os.WriteFile(src, []byte(body), 0o644); err != nil {
t.Fatalf("write src: %v", err)
}
n, err := SliceTestLog(src, "TestSingleNode/Phase5_LaunchInstance", dst)
if err != nil {
t.Fatalf("SliceTestLog: %v", err)
}
if n != 4 {
t.Fatalf("written = %d, want 4 (RUN + 2 inner + FAIL)", n)
}
got, _ := os.ReadFile(dst)
s := string(got)
if !strings.Contains(s, "describe-vpcs: InvalidParameterValue") {
t.Errorf("missing failure line:\n%s", s)
}
if strings.Contains(s, "Phase6_Volumes") || strings.Contains(s, "before subtests") {
t.Errorf("leaked sibling test content:\n%s", s)
}
}
func TestSliceTestLog_UnknownTestcaseIsEmpty(t *testing.T) {
src := filepath.Join(t.TempDir(), "test.log")
dst := filepath.Join(t.TempDir(), "out.log")
if err := os.WriteFile(src, []byte("=== RUN TestX\n--- PASS: TestX (0.0s)\n"), 0o644); err != nil {
t.Fatalf("write src: %v", err)
}
n, err := SliceTestLog(src, "TestY", dst)
if err != nil {
t.Fatalf("SliceTestLog: %v", err)
}
if n != 0 {
t.Errorf("written = %d, want 0", n)
}
if _, err := os.Stat(dst); err != nil {
t.Errorf("dst not created: %v", err)
}
}
// TestWriteBundles_EndToEnd builds a fake artifact dir with a junit XML,
// a journal log, a test log, and a per-test artifact subdir, then runs
// the full ParseFile → WriteBundles pipeline and asserts the bundle dir
// contains everything the report links to.
func TestWriteBundles_EndToEnd(t *testing.T) {
dir := t.TempDir()
junit := `<?xml version="1.0"?>
<testsuites><testsuite name="" tests="2" failures="1" timestamp="2026-05-19T12:32:30+10:00" time="3.0">
<testcase name="TestSingleNode/Phase5_LaunchInstance" time="2.500">
<failure message="Failed"><![CDATA[
ec2helpers.go:164: describe-vpcs: InvalidParameterValue (status 400)
Error Trace: tests/e2e/harness/ec2helpers.go:164
Test: TestSingleNode/Phase5_LaunchInstance]]></failure>
</testcase>
</testsuite></testsuites>`
if err := os.WriteFile(filepath.Join(dir, "junit-single.xml"), []byte(junit), 0o644); err != nil {
t.Fatalf("write junit: %v", err)
}
journal := strings.Join([]string{
"2026-05-19T12:32:20+1000 host spinifex-daemon[1]: too-early",
"2026-05-19T12:32:30+1000 host spinifex-daemon[1]: in-window-start",
"2026-05-19T12:32:32+1000 host spinifex-daemon[1]: describe-vpcs filter=is_default",
"2026-05-19T12:32:50+1000 host spinifex-daemon[1]: too-late",
"",
}, "\n")
if err := os.WriteFile(filepath.Join(dir, "spinifex-journal.log"), []byte(journal), 0o644); err != nil {
t.Fatalf("write journal: %v", err)
}
testlog := strings.Join([]string{
"=== RUN TestSingleNode/Phase5_LaunchInstance",
" ec2helpers.go:164: describe-vpcs: InvalidParameterValue",
"--- FAIL: TestSingleNode/Phase5_LaunchInstance (2.50s)",
"",
}, "\n")
if err := os.WriteFile(filepath.Join(dir, "test-single.log"), []byte(testlog), 0o644); err != nil {
t.Fatalf("write test log: %v", err)
}
// Per-test artifact subdir (matches harness.ArtifactDir convention).
perTest := filepath.Join(dir, "TestSingleNode_Phase5_LaunchInstance")
if err := os.MkdirAll(perTest, 0o755); err != nil {
t.Fatalf("mkdir per-test: %v", err)
}
if err := os.WriteFile(filepath.Join(perTest, "qemu-console.log"), []byte("[qemu] boot\n"), 0o644); err != nil {
t.Fatalf("write per-test file: %v", err)
}
data, _ := os.ReadFile(filepath.Join(dir, "junit-single.xml"))
sr, err := ParseFile("junit-single.xml", data)
if err != nil {
t.Fatalf("ParseFile: %v", err)
}
rep := Report{Title: "E2E failure analysis", Suites: []SuiteReport{sr}}
WriteBundles(&rep, dir)
if rep.Suites[0].Root == nil {
t.Fatal("no root")
}
bp := rep.Suites[0].Root.BundlePath
if bp == "" {
t.Fatal("BundlePath not set")
}
bundle := filepath.Join(dir, bp)
for _, want := range []string{"failure.txt", "journal.log", "test.log", "qemu-console.log"} {
if _, err := os.Stat(filepath.Join(bundle, want)); err != nil {
t.Errorf("missing %s in bundle %s: %v", want, bp, err)
}
}
jl, _ := os.ReadFile(filepath.Join(bundle, "journal.log"))
if !strings.Contains(string(jl), "describe-vpcs filter=is_default") {
t.Errorf("journal.log lacks in-window line:\n%s", jl)
}
if strings.Contains(string(jl), "too-early") || strings.Contains(string(jl), "too-late") {
t.Errorf("journal.log includes out-of-window content:\n%s", jl)
}
tl, _ := os.ReadFile(filepath.Join(bundle, "test.log"))
if !strings.Contains(string(tl), "--- FAIL: TestSingleNode/Phase5_LaunchInstance") {
t.Errorf("test.log missing FAIL marker:\n%s", tl)
}
out := Render(rep)
wantLink := fmt.Sprintf("- Bundle: `%s/`", bp)
if !strings.Contains(out, wantLink) {
t.Errorf("render missing bundle link %q:\n%s", wantLink, out)
}
}
+78
View File
@@ -0,0 +1,78 @@
package main
import (
"flag"
"fmt"
"os"
"path/filepath"
"sort"
)
func main() {
var junitGlob, logDir, title string
flag.StringVar(&junitGlob, "junit-glob", os.Getenv("ANALYZE_JUNIT_GLOB"), "glob matching junit-*.xml files")
flag.StringVar(&logDir, "log-dir", os.Getenv("ANALYZE_LOG_DIR"), "artifact dir to write analysis.md into")
flag.StringVar(&title, "title", os.Getenv("ANALYZE_TITLE"), "report heading")
flag.Parse()
if title == "" {
title = "E2E failure analysis"
}
if junitGlob == "" {
fmt.Fprintln(os.Stderr, "::error::e2e-analyze: -junit-glob is required")
os.Exit(0) // analyzer is advisory only; existing gate owns exit code
}
paths, err := filepath.Glob(junitGlob)
if err != nil {
fmt.Fprintf(os.Stderr, "::warning::e2e-analyze: bad glob %q: %v\n", junitGlob, err)
os.Exit(0)
}
sort.Strings(paths)
report := Report{Title: title}
for _, p := range paths {
data, err := os.ReadFile(p)
if err != nil {
fmt.Fprintf(os.Stderr, "::warning::e2e-analyze: read %s: %v\n", p, err)
continue
}
sr, err := ParseFile(p, data)
if err != nil {
fmt.Fprintf(os.Stderr, "::warning::e2e-analyze: %v\n", err)
continue
}
report.Suites = append(report.Suites, sr)
}
// Stage 2: prefer authoritative per-suite start times written by the
// workflow over junit-report's conversion-time `timestamp` attribute,
// then materialise per-failure bundles so Render can link them.
if logDir != "" {
ApplySuiteStartFiles(&report, logDir)
WriteBundles(&report, logDir)
}
out := Render(report)
if logDir != "" {
if err := os.MkdirAll(logDir, 0o755); err == nil {
if err := os.WriteFile(filepath.Join(logDir, "analysis.md"), []byte(out), 0o644); err != nil {
fmt.Fprintf(os.Stderr, "::warning::e2e-analyze: write analysis.md: %v\n", err)
}
}
}
if summaryPath := os.Getenv("GITHUB_STEP_SUMMARY"); summaryPath != "" {
f, err := os.OpenFile(summaryPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err == nil {
defer f.Close()
if _, werr := f.WriteString(out); werr != nil {
fmt.Fprintf(os.Stderr, "::warning::e2e-analyze: append summary: %v\n", werr)
}
}
} else {
// Local invocation (developer running the tool by hand). Mirror to stdout.
fmt.Print(out)
}
}
+176
View File
@@ -0,0 +1,176 @@
package main
// Stage 2 of docs/development/improvements/e2e-go-failure-analysis.md:
// per-failure log slicers used by bundle.go to materialise time-window /
// testcase-window log excerpts next to each named failure.
import (
"bufio"
"io"
"os"
"regexp"
"strings"
"time"
)
// shortIsoTSRe matches the timestamp prefix produced by
// `journalctl --output=short-iso`. The offset format varies across
// systemd versions: most ship "+10:00" (RFC3339), some ship "+1000"
// (no colon). Match either — the parser below picks the right layout.
var shortIsoTSRe = regexp.MustCompile(`^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:[+-]\d{2}:?\d{2}|Z))\s`)
// shortIsoLayouts are tried in order; the first parse that succeeds wins.
var shortIsoLayouts = []string{
time.RFC3339, // 2026-05-19T12:32:30+10:00
"2006-01-02T15:04:05-0700", // 2026-05-19T12:32:30+1000
}
func parseShortIso(s string) (time.Time, error) {
var lastErr error
for _, layout := range shortIsoLayouts {
t, err := time.Parse(layout, s)
if err == nil {
return t, nil
}
lastErr = err
}
return time.Time{}, lastErr
}
// SliceJournal copies lines from src whose `short-iso` timestamp falls in
// [start, end] to dst. Lines without a parseable timestamp inherit the
// previous line's classification — that keeps multi-line journal records
// (stack traces, panics) intact rather than orphaning their continuation
// lines. Lines that precede the first parseable timestamp are dropped.
//
// Returns the number of lines written; missing src is not an error (the
// journal may be empty if collection failed).
func SliceJournal(src string, start, end time.Time, dst string) (int, error) {
f, err := os.Open(src)
if err != nil {
if os.IsNotExist(err) {
return 0, nil
}
return 0, err
}
defer f.Close()
out, err := os.Create(dst)
if err != nil {
return 0, err
}
defer out.Close()
bw := bufio.NewWriter(out)
defer bw.Flush()
sc := bufio.NewScanner(f)
// Journal lines can be long (panic dumps); bump scanner buffer.
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
keep := false
written := 0
for sc.Scan() {
line := sc.Text()
if m := shortIsoTSRe.FindStringSubmatch(line); m != nil {
ts, err := parseShortIso(m[1])
if err != nil {
continue
}
keep = !ts.Before(start) && !ts.After(end)
}
if keep {
if _, err := bw.WriteString(line); err != nil {
return written, err
}
if err := bw.WriteByte('\n'); err != nil {
return written, err
}
written++
}
}
return written, sc.Err()
}
// runRePrefix and finishRePrefix bracket a single testcase's output in
// `go test -v` stdout. Subtests have their own `=== RUN` / `--- FAIL`
// markers, so slicing on the exact testcase name yields just that test's
// lines (including any nested phase logs).
var (
runRe = regexp.MustCompile(`^=== RUN\s+(\S+)$`)
finishRe = regexp.MustCompile(`^\s*---\s+(PASS|FAIL|SKIP):\s+(\S+)\s+\(`)
)
// SliceTestLog copies lines belonging to the named testcase from src to
// dst. The block starts at `=== RUN <name>` and ends at the matching
// `--- PASS|FAIL|SKIP: <name> (…)` line (inclusive). If the testcase is
// never seen the destination file is created empty so the bundle has a
// stable shape.
//
// Returns the number of lines written; missing src is not an error.
func SliceTestLog(src, testName, dst string) (int, error) {
f, err := os.Open(src)
if err != nil {
if os.IsNotExist(err) {
return 0, nil
}
return 0, err
}
defer f.Close()
out, err := os.Create(dst)
if err != nil {
return 0, err
}
defer out.Close()
bw := bufio.NewWriter(out)
defer bw.Flush()
sc := bufio.NewScanner(f)
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
in := false
written := 0
for sc.Scan() {
line := sc.Text()
if !in {
if m := runRe.FindStringSubmatch(strings.TrimRight(line, "\r")); m != nil && m[1] == testName {
in = true
} else {
continue
}
}
if _, err := bw.WriteString(line); err != nil {
return written, err
}
if err := bw.WriteByte('\n'); err != nil {
return written, err
}
written++
if m := finishRe.FindStringSubmatch(line); m != nil && m[2] == testName {
return written, nil
}
}
return written, sc.Err()
}
// copyFile is a small helper used when bundling pre-collected per-test
// artifacts. Best-effort: missing src is silently skipped.
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
defer in.Close()
out, err := os.Create(dst)
if err != nil {
return err
}
defer out.Close()
_, err = io.Copy(out, in)
return err
}
@@ -0,0 +1,23 @@
## E2E failure analysis
### Suite `single`: 16 failed, 1 root cause likely
**Root cause (earliest non-cascade)**
- Test: `TestSingleNode/Phase5_LaunchInstance`
- Start: 12:30:00 (duration 2.5s)
- Error: Received unexpected error: InvalidParameterValue (HTTP 400)
- File: `tests/e2e/harness/ec2helpers.go:164`
**Cascaded failures (15) — grouped by signature**
- 14× Phase 5 must populate fix.InstanceID
- `TestSingleNode/Phase5a_pre_ClusterStats`
- `TestSingleNode/Phase5a_Metadata`
- `TestSingleNode/Phase5b_DescribeInstances`
- `TestSingleNode/Phase5c_StopStart`
- `TestSingleNode/Phase6_Volumes`
- … and 9 more
- 1× Phase 2 must populate fix.AZName
- `TestSingleNode/Phase8_NatGW`
@@ -0,0 +1,143 @@
<?xml version="1.0" encoding="UTF-8"?>
<testsuites tests="20" failures="17" skipped="0">
<testsuite name="" tests="20" failures="17" errors="0" id="0" hostname="banksia" skipped="0" time="600.000" timestamp="2026-04-12T12:30:00+10:00">
<testcase name="TestSingleNode" classname="" time="600.000">
<failure message="Failed"></failure>
</testcase>
<testcase name="TestSingleNode/Phase1_Environment" classname="" time="0.300">
<system-out><![CDATA[
&#x2501;&#x2501; Phase 1 &#x2501;&#x2501;]]></system-out>
</testcase>
<testcase name="TestSingleNode/Phase2_Discovery" classname="" time="0.010"/>
<testcase name="TestSingleNode/Phase3_KeyPairs" classname="" time="0.540"/>
<testcase name="TestSingleNode/Phase4_Image" classname="" time="0.060"/>
<testcase name="TestSingleNode/Phase5_LaunchInstance" classname="" time="2.500">
<failure message="Failed"><![CDATA[
ec2helpers.go:164: describe-vpcs: InvalidParameterValue: Filter 'is_default' is invalid (status 400)
Error Trace: tests/e2e/harness/ec2helpers.go:164
Error: Received unexpected error:
InvalidParameterValue (HTTP 400)
Test: TestSingleNode/Phase5_LaunchInstance]]></failure>
</testcase>
<testcase name="TestSingleNode/Phase5a_pre_ClusterStats" classname="" time="0.020">
<failure message="Failed"><![CDATA[
lifecycle_test.go:28:
Error Trace: tests/e2e/single/lifecycle_test.go:28
Error: Should NOT be empty
Messages: Phase 5 must populate fix.InstanceID
Test: TestSingleNode/Phase5a_pre_ClusterStats]]></failure>
</testcase>
<testcase name="TestSingleNode/Phase5a_Metadata" classname="" time="0.020">
<failure message="Failed"><![CDATA[
lifecycle_test.go:57:
Error Trace: tests/e2e/single/lifecycle_test.go:57
Error: Should NOT be empty
Messages: Phase 5 must populate fix.InstanceID
Test: TestSingleNode/Phase5a_Metadata]]></failure>
</testcase>
<testcase name="TestSingleNode/Phase5b_DescribeInstances" classname="" time="0.020">
<failure message="Failed"><![CDATA[
lifecycle_test.go:109:
Error Trace: tests/e2e/single/lifecycle_test.go:109
Error: Should NOT be empty
Messages: Phase 5 must populate fix.InstanceID
Test: TestSingleNode/Phase5b_DescribeInstances]]></failure>
</testcase>
<testcase name="TestSingleNode/Phase5c_StopStart" classname="" time="0.020">
<failure message="Failed"><![CDATA[
lifecycle_test.go:222:
Error Trace: tests/e2e/single/lifecycle_test.go:222
Error: Should NOT be empty
Messages: Phase 5 must populate fix.InstanceID
Test: TestSingleNode/Phase5c_StopStart]]></failure>
</testcase>
<testcase name="TestSingleNode/Phase6_Volumes" classname="" time="0.020">
<failure message="Failed"><![CDATA[
volume_test.go:23:
Error Trace: tests/e2e/single/volume_test.go:23
Error: Should NOT be empty
Messages: Phase 5 must populate fix.InstanceID
Test: TestSingleNode/Phase6_Volumes]]></failure>
</testcase>
<testcase name="TestSingleNode/Phase6b_AttachVolume" classname="" time="0.020">
<failure message="Failed"><![CDATA[
volume_test.go:24:
Error Trace: tests/e2e/single/volume_test.go:24
Error: Should NOT be empty
Messages: Phase 5 must populate fix.InstanceID
Test: TestSingleNode/Phase6b_AttachVolume]]></failure>
</testcase>
<testcase name="TestSingleNode/Phase7_Tags" classname="" time="0.020">
<failure message="Failed"><![CDATA[
tags_test.go:23:
Error Trace: tests/e2e/single/tags_test.go:23
Error: Should NOT be empty
Messages: Phase 5 must populate fix.InstanceID
Test: TestSingleNode/Phase7_Tags]]></failure>
</testcase>
<testcase name="TestSingleNode/Phase7a_Snapshots" classname="" time="0.020">
<failure message="Failed"><![CDATA[
snapshot_test.go:23:
Error Trace: tests/e2e/single/snapshot_test.go:23
Error: Should NOT be empty
Messages: Phase 5 must populate fix.InstanceID
Test: TestSingleNode/Phase7a_Snapshots]]></failure>
</testcase>
<testcase name="TestSingleNode/Phase7b_DescribeInstanceAttribute" classname="" time="0.020">
<failure message="Failed"><![CDATA[
instance_test.go:81:
Error Trace: tests/e2e/single/instance_test.go:81
Error: Should NOT be empty
Messages: Phase 5 must populate fix.InstanceID
Test: TestSingleNode/Phase7b_DescribeInstanceAttribute]]></failure>
</testcase>
<testcase name="TestSingleNode/Phase7c_ModifyInstanceAttribute" classname="" time="0.020">
<failure message="Failed"><![CDATA[
instance_test.go:92:
Error Trace: tests/e2e/single/instance_test.go:92
Error: Should NOT be empty
Messages: Phase 5 must populate fix.InstanceID
Test: TestSingleNode/Phase7c_ModifyInstanceAttribute]]></failure>
</testcase>
<testcase name="TestSingleNode/Phase7d_Console" classname="" time="0.020">
<failure message="Failed"><![CDATA[
instance_test.go:121:
Error Trace: tests/e2e/single/instance_test.go:121
Error: Should NOT be empty
Messages: Phase 5 must populate fix.InstanceID
Test: TestSingleNode/Phase7d_Console]]></failure>
</testcase>
<testcase name="TestSingleNode/Phase7e_Negative" classname="" time="0.020">
<failure message="Failed"><![CDATA[
negative_test.go:43:
Error Trace: tests/e2e/single/negative_test.go:43
Error: Should NOT be empty
Messages: Phase 5 must populate fix.InstanceID
Test: TestSingleNode/Phase7e_Negative]]></failure>
</testcase>
<testcase name="TestSingleNode/Phase8_NatGW" classname="" time="0.020">
<failure message="Failed"><![CDATA[
natgw_test.go:39:
Error Trace: tests/e2e/single/natgw_test.go:39
Error: Should NOT be empty
Messages: Phase 2 must populate fix.AZName
Test: TestSingleNode/Phase8_NatGW]]></failure>
</testcase>
<testcase name="TestSingleNode/Phase8a_DataPath" classname="" time="0.020">
<failure message="Failed"><![CDATA[
datapath_test.go:48:
Error Trace: tests/e2e/single/datapath_test.go:48
Error: Should NOT be empty
Messages: Phase 5 must populate fix.InstanceID
Test: TestSingleNode/Phase8a_DataPath]]></failure>
</testcase>
<testcase name="TestSingleNode/Phase9_SecurityGroups" classname="" time="0.020">
<failure message="Failed"><![CDATA[
securitygroup_test.go:35:
Error Trace: tests/e2e/single/securitygroup_test.go:35
Error: Should NOT be empty
Messages: Phase 5 must populate fix.InstanceID
Test: TestSingleNode/Phase9_SecurityGroups]]></failure>
</testcase>
</testsuite>
</testsuites>
@@ -0,0 +1,3 @@
## E2E failure analysis
✅ No failures across any suite.
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<testsuites tests="3" failures="0" skipped="0">
<testsuite name="" tests="3" failures="0" errors="0" id="0" hostname="banksia" skipped="0" time="1.500" timestamp="2026-04-12T12:30:00+10:00">
<testcase name="TestSingleNode" classname="" time="1.500"/>
<testcase name="TestSingleNode/Phase1_Environment" classname="" time="0.300"/>
<testcase name="TestSingleNode/Phase2_Discovery" classname="" time="1.200"/>
</testsuite>
</testsuites>
@@ -0,0 +1,11 @@
## E2E failure analysis
### Suite `single`: 1 failed, 1 root cause likely
**Root cause (earliest non-cascade)**
- Test: `TestSingleNode/Phase8b_VPCSubnetE2E`
- Start: 00:26:18 (duration 200.5s)
- Error: Eventually: condition not met within 3m0s: [SSH handshake 192.168.0.202:22 never completed]
- File: `vpc_test.go:227`
@@ -0,0 +1,993 @@
<?xml version="1.0" encoding="UTF-8"?>
<testsuites tests="79" failures="2" skipped="6">
<testsuite name="" tests="79" failures="2" errors="0" id="0" hostname="banksia" skipped="6" time="1708.140" timestamp="2026-05-19T00:19:45+10:00">
<testcase name="TestSingleNode" classname="" time="800.320">
<failure message="Failed"></failure>
</testcase>
<testcase name="TestSingleNode/Phase1_Environment" classname="" time="0.300">
<system-out><![CDATA[
�[1m�[36m━━ Phase 1 — Environment ━━�[0m
�[2m· checking /dev/kvm�[0m
discovery_test.go:29: �[2m· checking /dev/kvm�[0m
�[2mkvm�[0m=writable
discovery_test.go:38: �[2mkvm�[0m=writable
�[2m· waiting for AWS gateway�[0m
discovery_test.go:45: �[2m· waiting for AWS gateway�[0m
�[2mgateway�[0m=https://192.168.0.10:9999/
discovery_test.go:55: �[2mgateway�[0m=https://192.168.0.10:9999/
�[2m· waiting for daemon NATS subscriptions�[0m
discovery_test.go:60: �[2m· waiting for daemon NATS subscriptions�[0m
�[2m· describe-regions / describe-availability-zones�[0m
discovery_test.go:73: �[2m· describe-regions / describe-availability-zones�[0m
�[2maz�[0m=ap-southeast-2a �[2mregion�[0m=ap-southeast-2
discovery_test.go:84: �[2maz�[0m=ap-southeast-2a �[2mregion�[0m=ap-southeast-2]]></system-out>
</testcase>
<testcase name="TestSingleNode/Phase1b_ClusterStats" classname="" time="9.200">
<system-out><![CDATA[
�[1m�[36m━━ Phase 1b — Cluster Stats CLI ━━�[0m
�[2m· spx get nodes�[0m
discovery_test.go:101: �[2m· spx get nodes�[0m
�[2m· spx top nodes�[0m
discovery_test.go:105: �[2m· spx top nodes�[0m
�[2m· spx get vms (empty before any launches)�[0m
discovery_test.go:112: �[2m· spx get vms (empty before any launches)�[0m]]></system-out>
</testcase>
<testcase name="TestSingleNode/Phase2_Discovery" classname="" time="0.010">
<system-out><![CDATA[
�[1m�[36m━━ Phase 2 — Discovery & Metadata ━━�[0m
�[2m· discovering nano instance type�[0m
discovery_test.go:133: �[2m· discovering nano instance type�[0m
�[2minstance_type�[0m=t3.nano �[2march�[0m=x86_64 �[2maz�[0m=ap-southeast-2a
discovery_test.go:159: �[2minstance_type�[0m=t3.nano �[2march�[0m=x86_64 �[2maz�[0m=ap-southeast-2a]]></system-out>
</testcase>
<testcase name="TestSingleNode/Phase2b_SerialConsole" classname="" time="0.020">
<system-out><![CDATA[
�[1m�[36m━━ Phase 2b — Serial Console Access ━━�[0m
�[2m· default state should be disabled�[0m
discovery_test.go:168: �[2m· default state should be disabled�[0m
�[2m· enable�[0m
discovery_test.go:173: �[2m· enable�[0m
�[2mstate�[0m=enabled
discovery_test.go:180: �[2mstate�[0m=enabled
�[2m· disable�[0m
discovery_test.go:182: �[2m· disable�[0m
�[2mstate�[0m=disabled
discovery_test.go:189: �[2mstate�[0m=disabled]]></system-out>
</testcase>
<testcase name="TestSingleNode/Phase3_KeyPairs" classname="" time="0.540">
<system-out><![CDATA[
�[1m�[36m━━ Phase 3 — SSH Key Management ━━�[0m
�[2m· create-key-pair "test-key-1"�[0m
keypair_test.go:37: �[2m· create-key-pair "test-key-1"�[0m
�[2mkey�[0m=test-key-1 �[2mpem�[0m=/tmp/TestSingleNode1289015487/001/test-key-1.pem
keypair_test.go:51: �[2mkey�[0m=test-key-1 �[2mpem�[0m=/tmp/TestSingleNode1289015487/001/test-key-1.pem
�[2m· import-key-pair "test-key-2" (local-generated RSA)�[0m
keypair_test.go:58: �[2m· import-key-pair "test-key-2" (local-generated RSA)�[0m
�[2mimported�[0m=test-key-2
keypair_test.go:65: �[2mimported�[0m=test-key-2
�[2m· describe-key-pairs (both present)�[0m
keypair_test.go:67: �[2m· describe-key-pairs (both present)�[0m
�[2m· delete "test-key-2"�[0m
keypair_test.go:72: �[2m· delete "test-key-2"�[0m
�[2m· describe-key-pairs (only test-key-1 remains)�[0m
keypair_test.go:76: �[2m· describe-key-pairs (only test-key-1 remains)�[0m]]></system-out>
</testcase>
<testcase name="TestSingleNode/Phase4_Image" classname="" time="0.060">
<system-out><![CDATA[
�[1m�[36m━━ Phase 4 — Image Management ━━�[0m
�[2mimage�[0m=ubuntu-24.04-x86_64 �[2march�[0m=x86_64
image_test.go:33: �[2mimage�[0m=ubuntu-24.04-x86_64 �[2march�[0m=x86_64
�[2m· spx admin images import --name ubuntu-24.04-x86_64�[0m
image_test.go:35: �[2m· spx admin images import --name ubuntu-24.04-x86_64�[0m
�[2m· spx import did not yield AMI ID; falling back to describe-images�[0m
image_test.go:48: �[2m· spx import did not yield AMI ID; falling back to describe-images�[0m
�[2m· describe-images by AMI ID (verify exactly one match)�[0m
image_test.go:78: �[2m· describe-images by AMI ID (verify exactly one match)�[0m
�[2mami�[0m=ami-6f60f2b3bf04b5453 �[2mname�[0m=ami-ubuntu-24.04-x86_64
image_test.go:86: �[2mami�[0m=ami-6f60f2b3bf04b5453 �[2mname�[0m=ami-ubuntu-24.04-x86_64]]></system-out>
</testcase>
<testcase name="TestSingleNode/Phase5_LaunchInstance" classname="" time="10.830">
<system-out><![CDATA[
�[1m�[36m━━ Phase 5 — Instance Lifecycle ━━�[0m
�[2mvpc�[0m=vpc-531252db3b4e1fc08 �[2msg�[0m=sg-764a7fa2a39577204 �[2msubnet�[0m=subnet-cd62e4c1c3cb50c63
instance_test.go:31: �[2mvpc�[0m=vpc-531252db3b4e1fc08 �[2msg�[0m=sg-764a7fa2a39577204 �[2msubnet�[0m=subnet-cd62e4c1c3cb50c63
�[2m· run-instances ami=ami-6f60f2b3bf04b5453 type=t3.nano key=test-key-1�[0m
instance_test.go:35: �[2m· run-instances ami=ami-6f60f2b3bf04b5453 type=t3.nano key=test-key-1�[0m
�[2minstance�[0m=i-0c615e5dd74050f53
instance_test.go:48: �[2minstance�[0m=i-0c615e5dd74050f53
instance_test.go:50: instance i-0c615e5dd74050f53 reached state running
�[2mroot_volume�[0m=vol-2230138124fb51ee2 �[2mroot_device�[0m=
instance_test.go:70: �[2mroot_volume�[0m=vol-2230138124fb51ee2 �[2mroot_device�[0m=]]></system-out>
</testcase>
<testcase name="TestSingleNode/Phase5a_pre_ClusterStats" classname="" time="3.040">
<system-out><![CDATA[
�[1m�[36m━━ Phase 5a-pre — Cluster Stats CLI (with running VM) ━━�[0m]]></system-out>
</testcase>
<testcase name="TestSingleNode/Phase5a_Metadata" classname="" time="0.510">
<system-out><![CDATA[
�[1m�[36m━━ Phase 5a — Instance Metadata Validation ━━�[0m
�[2mtype�[0m=t3.nano �[2mkey�[0m=test-key-1 �[2mimage�[0m=ami-6f60f2b3bf04b5453 �[2mbdms�[0m=1
instance_test.go:108: �[2mtype�[0m=t3.nano �[2mkey�[0m=test-key-1 �[2mimage�[0m=ami-6f60f2b3bf04b5453 �[2mbdms�[0m=1]]></system-out>
</testcase>
<testcase name="TestSingleNode/Phase5a_ii_SSH" classname="" time="49.450">
<system-out><![CDATA[
�[1m�[36m━━ Phase 5a-ii — SSH Connectivity & Volume Verification ━━�[0m
�[2mssh_host�[0m=192.168.0.201 �[2mssh_port�[0m=22
instance_test.go:127: �[2mssh_host�[0m=192.168.0.201 �[2mssh_port�[0m=22
�[2m· waiting for SSH handshake 192.168.0.201:22�[0m
instance_test.go:130: �[2m· waiting for SSH handshake 192.168.0.201:22�[0m
�[2m· waiting for SSH handshake 192.168.0.201:22�[0m
lifecycle_test.go:384: �[2m· waiting for SSH handshake 192.168.0.201:22�[0m
�[2m· ssh id�[0m
instance_test.go:136: �[2m· ssh id�[0m
�[2m· lsblk root-volume cross-check vs API�[0m
instance_test.go:140: �[2m· lsblk root-volume cross-check vs API�[0m
�[2mguest_gib�[0m=4 �[2mapi_gib�[0m=4
instance_test.go:159: �[2mguest_gib�[0m=4 �[2mapi_gib�[0m=4
�[2m· ssh hostname�[0m
instance_test.go:161: �[2m· ssh hostname�[0m
�[2mhostname�[0m=spinifex-vm-0c615e5d �[2mmatches_short_id�[0m=0c615e5d
instance_test.go:172: �[2mhostname�[0m=spinifex-vm-0c615e5d �[2mmatches_short_id�[0m=0c615e5d]]></system-out>
</testcase>
<testcase name="TestSingleNode/Phase5a_iii_Console" classname="" time="0.010">
<system-out><![CDATA[
�[1m�[36m━━ Phase 5a-iii — Console Output ━━�[0m
�[2minstance�[0m=i-0c615e5dd74050f53 �[2mhas_output�[0m=true
instance_test.go:191: �[2minstance�[0m=i-0c615e5dd74050f53 �[2mhas_output�[0m=true]]></system-out>
</testcase>
<testcase name="TestSingleNode/Phase5b_Volume" classname="" time="4.540">
<system-out><![CDATA[
�[1m�[36m━━ Phase 5b — Volume Lifecycle (Attach/Detach) ━━�[0m
�[2m· create-volume size=10 az=ap-southeast-2a�[0m
volume_test.go:26: �[2m· create-volume size=10 az=ap-southeast-2a�[0m
�[2mvolume�[0m=vol-a885f79173e0a8030
volume_test.go:34: �[2mvolume�[0m=vol-a885f79173e0a8030
volume_test.go:39: volume vol-a885f79173e0a8030 reached state available
�[2m· modify-volume size=20�[0m
volume_test.go:42: �[2m· modify-volume size=20�[0m
�[2mresized_gib�[0m=20
volume_test.go:66: �[2mresized_gib�[0m=20
�[2m· attach-volume vol-a885f79173e0a8030 -> i-0c615e5dd74050f53 as /dev/sdf�[0m
volume_test.go:68: �[2m· attach-volume vol-a885f79173e0a8030 -> i-0c615e5dd74050f53 as /dev/sdf�[0m
volume_test.go:76: volume vol-a885f79173e0a8030 reached state in-use
�[2m· detach-volume vol-a885f79173e0a8030�[0m
volume_test.go:90: �[2m· detach-volume vol-a885f79173e0a8030�[0m
volume_test.go:96: volume vol-a885f79173e0a8030 reached state available
�[2m· delete-volume vol-a885f79173e0a8030�[0m
volume_test.go:98: �[2m· delete-volume vol-a885f79173e0a8030�[0m]]></system-out>
</testcase>
<testcase name="TestSingleNode/Phase5b_ii_VolumeStatus" classname="" time="0.000">
<system-out><![CDATA[
�[1m�[36m━━ Phase 5b-ii — DescribeVolumeStatus ━━�[0m
�[2mvolume�[0m=vol-2230138124fb51ee2 �[2mstatus�[0m=ok
volume_test.go:158: �[2mvolume�[0m=vol-2230138124fb51ee2 �[2mstatus�[0m=ok]]></system-out>
</testcase>
<testcase name="TestSingleNode/Phase5c_Snapshot" classname="" time="0.190">
<system-out><![CDATA[
�[1m�[36m━━ Phase 5c — Snapshot Lifecycle ━━�[0m
�[2m· create-snapshot volume=vol-2230138124fb51ee2�[0m
snapshot_test.go:34: �[2m· create-snapshot volume=vol-2230138124fb51ee2�[0m
�[2msnapshot�[0m=snap-1188f92980e6470aa �[2msize�[0m=4 �[2mstate�[0m=completed �[2mprogress�[0m=100%
snapshot_test.go:50: �[2msnapshot�[0m=snap-1188f92980e6470aa �[2msize�[0m=4 �[2mstate�[0m=completed �[2mprogress�[0m=100%
snapshot_test.go:59: snapshot snap-1188f92980e6470aa reached state completed
�[2m· describe-snapshots snap-1188f92980e6470aa�[0m
snapshot_test.go:61: �[2m· describe-snapshots snap-1188f92980e6470aa�[0m
�[2m· copy-snapshot src=snap-1188f92980e6470aa region=ap-southeast-2�[0m
snapshot_test.go:76: �[2m· copy-snapshot src=snap-1188f92980e6470aa region=ap-southeast-2�[0m
�[2mcopy_snapshot�[0m=snap-cde6b5ddf467e2c0f
snapshot_test.go:86: �[2mcopy_snapshot�[0m=snap-cde6b5ddf467e2c0f
snapshot_test.go:88: snapshot snap-cde6b5ddf467e2c0f reached state completed
�[2m· describe-snapshots snap-1188f92980e6470aa,snap-cde6b5ddf467e2c0f (both visible)�[0m
snapshot_test.go:90: �[2m· describe-snapshots snap-1188f92980e6470aa,snap-cde6b5ddf467e2c0f (both visible)�[0m
�[2m· delete-snapshot snap-1188f92980e6470aa (original)�[0m
snapshot_test.go:107: �[2m· delete-snapshot snap-1188f92980e6470aa (original)�[0m
�[2m· verify copy snap-cde6b5ddf467e2c0f still completed�[0m
snapshot_test.go:135: �[2m· verify copy snap-cde6b5ddf467e2c0f still completed�[0m
�[2m· delete-snapshot snap-cde6b5ddf467e2c0f (copy)�[0m
snapshot_test.go:144: �[2m· delete-snapshot snap-cde6b5ddf467e2c0f (copy)�[0m
�[2mdeleted_original�[0m=snap-1188f92980e6470aa
snapshot_test.go:151: �[2mdeleted_original�[0m=snap-1188f92980e6470aa]]></system-out>
</testcase>
<testcase name="TestSingleNode/Phase5d_SnapshotBackedLaunch" classname="" time="0.010">
<system-out><![CDATA[
�[1m�[36m━━ Phase 5d — Snapshot-Backed Instance Launch ━━�[0m
�[2mami�[0m=ami-6f60f2b3bf04b5453 �[2msnapshot�[0m=snap-ami-6f60f2b3bf04b5453
snapshot_test.go:182: �[2mami�[0m=ami-6f60f2b3bf04b5453 �[2msnapshot�[0m=snap-ami-6f60f2b3bf04b5453]]></system-out>
</testcase>
<testcase name="TestSingleNode/Phase5e_CreateImage" classname="" time="0.660">
<system-out><![CDATA[
�[1m�[36m━━ Phase 5e — CreateImage Lifecycle ━━�[0m
�[2m· create-image instance=i-0c615e5dd74050f53 name=e2e-custom-ami (no-reboot)�[0m
createimage_test.go:26: �[2m· create-image instance=i-0c615e5dd74050f53 name=e2e-custom-ami (no-reboot)�[0m
�[2mcustom_ami�[0m=ami-7dcf263a412dd14c6
createimage_test.go:36: �[2mcustom_ami�[0m=ami-7dcf263a412dd14c6
createimage_test.go:38: image ami-7dcf263a412dd14c6 reached state available
�[2m· describe-images ami-7dcf263a412dd14c6�[0m
createimage_test.go:40: �[2m· describe-images ami-7dcf263a412dd14c6�[0m
�[2mcustom_ami_snapshot�[0m=snap-dde89b9ea2ef2e05a
createimage_test.go:67: �[2mcustom_ami_snapshot�[0m=snap-dde89b9ea2ef2e05a]]></system-out>
</testcase>
<testcase name="TestSingleNode/Phase5f_SecurityGroupEgress" classname="" time="4.880">
<skipped message="Skipped"><![CDATA[
�[1m�[36m━━ Phase 5f — Security Group Enforcement (egress ACL) ━━�[0m
�[2m· discover default gateway inside VM�[0m
securitygroup_test.go:53: �[2m· discover default gateway inside VM�[0m
�[2mprobe_gateway�[0m=172.31.0.1
securitygroup_test.go:66: �[2mprobe_gateway�[0m=172.31.0.1
�[2m· 5f-1 baseline egress (allow-all)�[0m
securitygroup_test.go:77: �[2m· 5f-1 baseline egress (allow-all)�[0m
securitygroup_test.go:80: Phase 5f: baseline ICMP did not succeed; env may block ICMP regardless of SG
Output:
PING 172.31.0.1 (172.31.0.1) 56(84) bytes of data.
--- 172.31.0.1 ping statistics ---
3 packets transmitted, 0 received, 100% packet loss, time 2044ms
]]></skipped>
</testcase>
<testcase name="TestSingleNode/Phase6_TagManagement" classname="" time="0.380">
<system-out><![CDATA[
�[1m�[36m━━ Phase 6 — Tag Management ━━�[0m
�[2m· 6a create-tags instance=i-0c615e5dd74050f53 (Name, Environment, DeleteMe)�[0m
tags_test.go:41: �[2m· 6a create-tags instance=i-0c615e5dd74050f53 (Name, Environment, DeleteMe)�[0m
�[2m· 6b describe-tags resource-id=i-0c615e5dd74050f53 want=3�[0m
tags_test.go:53: �[2m· 6b describe-tags resource-id=i-0c615e5dd74050f53 want=3�[0m
�[2m· 6c create-tags volume=vol-2230138124fb51ee2 (Name, Environment)�[0m
tags_test.go:65: �[2m· 6c create-tags volume=vol-2230138124fb51ee2 (Name, Environment)�[0m
�[2m· 6d describe-tags key=Environment want=2�[0m
tags_test.go:76: �[2m· 6d describe-tags key=Environment want=2�[0m
�[2m· 6e describe-tags resource-type=instance want=3�[0m
tags_test.go:85: �[2m· 6e describe-tags resource-type=instance want=3�[0m
�[2m· 6f overwrite instance Name -> e2e-test-updated�[0m
tags_test.go:95: �[2m· 6f overwrite instance Name -> e2e-test-updated�[0m
�[2m· 6g delete-tags Key=DeleteMe (no value)�[0m
tags_test.go:114: �[2m· 6g delete-tags Key=DeleteMe (no value)�[0m
�[2m· 6h delete-tags Key=Environment,Value=production (no-op)�[0m
tags_test.go:130: �[2m· 6h delete-tags Key=Environment,Value=production (no-op)�[0m
�[2m· 6i delete-tags Key=Environment,Value=testing (instance only)�[0m
tags_test.go:148: �[2m· 6i delete-tags Key=Environment,Value=testing (instance only)�[0m
�[2m· 6j final describe-tags resource-id=i-0c615e5dd74050f53 want=1�[0m
tags_test.go:171: �[2m· 6j final describe-tags resource-id=i-0c615e5dd74050f53 want=1�[0m]]></system-out>
</testcase>
<testcase name="TestSingleNode/Phase7_StopStart" classname="" time="8.090">
<system-out><![CDATA[
�[1m�[36m━━ Phase 7 — Instance State Transitions (Stop) ━━�[0m
�[2m· stop-instances i-0c615e5dd74050f53�[0m
lifecycle_test.go:30: �[2m· stop-instances i-0c615e5dd74050f53�[0m
lifecycle_test.go:36: instance i-0c615e5dd74050f53 reached state stopped
�[2m· reboot-instances on stopped instance (should fail)�[0m
lifecycle_test.go:40: �[2m· reboot-instances on stopped instance (should fail)�[0m
�[2mstopped_reject�[0m=IncorrectInstanceState
lifecycle_test.go:47: �[2mstopped_reject�[0m=IncorrectInstanceState]]></system-out>
</testcase>
<testcase name="TestSingleNode/Phase7a_AttachToStoppedError" classname="" time="0.300">
<system-out><![CDATA[
�[1m�[36m━━ Phase 7a — Attach Volume to Stopped Instance (Error Path) ━━�[0m
�[2m· create-volume size=10 az=ap-southeast-2a�[0m
lifecycle_test.go:60: �[2m· create-volume size=10 az=ap-southeast-2a�[0m
�[2mtest_volume�[0m=vol-8d3e1335938a378cd
lifecycle_test.go:68: �[2mtest_volume�[0m=vol-8d3e1335938a378cd
lifecycle_test.go:82: volume vol-8d3e1335938a378cd reached state available
�[2m· attach-volume vol-8d3e1335938a378cd -> i-0c615e5dd74050f53 (should fail)�[0m
lifecycle_test.go:84: �[2m· attach-volume vol-8d3e1335938a378cd -> i-0c615e5dd74050f53 (should fail)�[0m
�[2m· delete-volume vol-8d3e1335938a378cd�[0m
lifecycle_test.go:94: �[2m· delete-volume vol-8d3e1335938a378cd�[0m]]></system-out>
</testcase>
<testcase name="TestSingleNode/Phase7b_ModifyInstanceAttribute" classname="" time="55.620">
<system-out><![CDATA[
�[1m�[36m━━ Phase 7b — ModifyInstanceAttribute ━━�[0m
�[2mfrom_type�[0m=t3.nano �[2mto_type�[0m=t3.small
lifecycle_test.go:120: �[2mfrom_type�[0m=t3.nano �[2mto_type�[0m=t3.small
�[2mexpected_vcpus�[0m=2 �[2mexpected_mem_mib�[0m=2048
lifecycle_test.go:142: �[2mexpected_vcpus�[0m=2 �[2mexpected_mem_mib�[0m=2048
�[2m· modify-instance-attribute i-0c615e5dd74050f53 type=t3.small�[0m
lifecycle_test.go:144: �[2m· modify-instance-attribute i-0c615e5dd74050f53 type=t3.small�[0m
�[2m· start-instances i-0c615e5dd74050f53�[0m
lifecycle_test.go:165: �[2m· start-instances i-0c615e5dd74050f53�[0m
lifecycle_test.go:171: instance i-0c615e5dd74050f53 reached state running
�[2mssh_host�[0m=192.168.0.201 �[2mssh_port�[0m=22
lifecycle_test.go:177: �[2mssh_host�[0m=192.168.0.201 �[2mssh_port�[0m=22
�[2m· waiting for SSH handshake 192.168.0.201:22�[0m
lifecycle_test.go:179: �[2m· waiting for SSH handshake 192.168.0.201:22�[0m
�[2m· ssh nproc�[0m
lifecycle_test.go:182: �[2m· ssh nproc�[0m
�[2mvm_vcpus�[0m=2
lifecycle_test.go:188: �[2mvm_vcpus�[0m=2
�[2m· ssh MemTotal�[0m
lifecycle_test.go:190: �[2m· ssh MemTotal�[0m
�[2mvm_mem_mib�[0m=1967 �[2mthreshold_mib�[0m=1740
lifecycle_test.go:200: �[2mvm_mem_mib�[0m=1967 �[2mthreshold_mib�[0m=1740
�[2m· stop-instances i-0c615e5dd74050f53 (end of 7b)�[0m
lifecycle_test.go:206: �[2m· stop-instances i-0c615e5dd74050f53 (end of 7b)�[0m
lifecycle_test.go:211: instance i-0c615e5dd74050f53 reached state stopped]]></system-out>
</testcase>
<testcase name="TestSingleNode/Phase7c_pre_Reboot" classname="" time="83.480">
<system-out><![CDATA[
�[1m�[36m━━ Phase 7c-pre — Reboot Running Instance ━━�[0m
�[2m· start-instances i-0c615e5dd74050f53�[0m
lifecycle_test.go:226: �[2m· start-instances i-0c615e5dd74050f53�[0m
lifecycle_test.go:231: instance i-0c615e5dd74050f53 reached state running
�[2m· waiting for SSH handshake 192.168.0.201:22�[0m
lifecycle_test.go:236: �[2m· waiting for SSH handshake 192.168.0.201:22�[0m
�[2mpre_reboot_private_ip�[0m=172.31.0.4
lifecycle_test.go:246: �[2mpre_reboot_private_ip�[0m=172.31.0.4
�[2m· reboot-instances i-0c615e5dd74050f53�[0m
lifecycle_test.go:248: �[2m· reboot-instances i-0c615e5dd74050f53�[0m
�[2m· waiting for SSH handshake 192.168.0.201:22�[0m
lifecycle_test.go:279: �[2m· waiting for SSH handshake 192.168.0.201:22�[0m
�[2m· ssh uptime�[0m
lifecycle_test.go:282: �[2m· ssh uptime�[0m
�[2muptime_secs�[0m=25
lifecycle_test.go:288: �[2muptime_secs�[0m=25]]></system-out>
</testcase>
<testcase name="TestSingleNode/Phase7c_RunInstancesMultiCount" classname="" time="47.560">
<system-out><![CDATA[
�[1m�[36m━━ Phase 7c — RunInstances with MinCount/MaxCount > 1 ━━�[0m
�[2m· run-instances ami=ami-6f60f2b3bf04b5453 type=t3.small count=2�[0m
lifecycle_test.go:309: �[2m· run-instances ami=ami-6f60f2b3bf04b5453 type=t3.small count=2�[0m
�[2msibling_1�[0m=i-9bfadd8ebd378f846 �[2msibling_2�[0m=i-448a9e2bf45c58658
lifecycle_test.go:327: �[2msibling_1�[0m=i-9bfadd8ebd378f846 �[2msibling_2�[0m=i-448a9e2bf45c58658
lifecycle_test.go:342: instance i-9bfadd8ebd378f846 reached state running
lifecycle_test.go:342: instance i-448a9e2bf45c58658 reached state running
�[2m· terminate-instances i-9bfadd8ebd378f846 i-448a9e2bf45c58658�[0m
lifecycle_test.go:345: �[2m· terminate-instances i-9bfadd8ebd378f846 i-448a9e2bf45c58658�[0m
lifecycle_test.go:352: instance i-9bfadd8ebd378f846 reached state terminated
lifecycle_test.go:352: instance i-448a9e2bf45c58658 reached state terminated]]></system-out>
</testcase>
<testcase name="TestSingleNode/Phase8_NegativeErrorPaths" classname="" time="0.000">
<system-out><![CDATA[
�[1m�[36m━━ Phase 8 — Negative / Error Path Tests ━━�[0m]]></system-out>
</testcase>
<testcase name="TestSingleNode/Phase8_NegativeErrorPaths/8a_InvalidAMIIDMalformed" classname="" time="0.010">
<system-out><![CDATA[�[2m· run-instances image-id=notanami�[0m
negative_test.go:55: �[2m· run-instances image-id=notanami�[0m]]></system-out>
</testcase>
<testcase name="TestSingleNode/Phase8_NegativeErrorPaths/8b_InvalidInstanceType" classname="" time="0.210">
<system-out><![CDATA[�[2m· run-instances instance-type=x99.superlarge�[0m
negative_test.go:70: �[2m· run-instances instance-type=x99.superlarge�[0m]]></system-out>
</testcase>
<testcase name="TestSingleNode/Phase8_NegativeErrorPaths/8c_VolumeInUse" classname="" time="0.010">
<system-out><![CDATA[�[2m· attach-volume vol-2230138124fb51ee2 (root, in-use)�[0m
negative_test.go:85: �[2m· attach-volume vol-2230138124fb51ee2 (root, in-use)�[0m]]></system-out>
</testcase>
<testcase name="TestSingleNode/Phase8_NegativeErrorPaths/8d_DetachRootForbidden" classname="" time="0.010">
<system-out><![CDATA[�[2m· detach-volume vol-2230138124fb51ee2 (root)�[0m
negative_test.go:97: �[2m· detach-volume vol-2230138124fb51ee2 (root)�[0m]]></system-out>
</testcase>
<testcase name="TestSingleNode/Phase8_NegativeErrorPaths/8e_DeleteSnapshotNotFound" classname="" time="0.030">
<system-out><![CDATA[�[2m· delete-snapshot snap-nonexistent000000�[0m
negative_test.go:108: �[2m· delete-snapshot snap-nonexistent000000�[0m
�[2m· describe-volumes vol-0000000000000dead�[0m]]></system-out>
</testcase>
<testcase name="TestSingleNode/Phase8_NegativeErrorPaths/8f_InvalidActionRawHTTP" classname="" time="0.040">
<system-out><![CDATA[�[2m· POST Action=DescribeFakeThings�[0m
negative_test.go:122: �[2m· POST Action=DescribeFakeThings�[0m
negative_test.go:125: �[2mstatus�[0m=400 �[2mcode�[0m=InvalidAction]]></system-out>
</testcase>
<testcase name="TestSingleNode/Phase8_NegativeErrorPaths/8g_InvalidAMIIDNotFound" classname="" time="0.240">
<system-out><![CDATA[�[2m· reboot-instances i-nonexistent�[0m
�[2m· run-instances image-id=ami-0000000000000dead�[0m
negative_test.go:138: �[2m· run-instances image-id=ami-0000000000000dead�[0m]]></system-out>
</testcase>
<testcase name="TestSingleNode/Phase8_NegativeErrorPaths/8h_InvalidKeyPairNotFound" classname="" time="0.260">
<system-out><![CDATA[�[2m· run-instances key-name=nonexistent-key-xyz�[0m
negative_test.go:153: �[2m· run-instances key-name=nonexistent-key-xyz�[0m]]></system-out>
</testcase>
<testcase name="TestSingleNode/Phase8_NegativeErrorPaths/8i_DeleteVolumeNotFound" classname="" time="0.040">
<system-out><![CDATA[�[2m· delete-volume vol-0000000000000dead�[0m
negative_test.go:168: �[2m· delete-volume vol-0000000000000dead�[0m]]></system-out>
</testcase>
<testcase name="TestSingleNode/Phase8_NegativeErrorPaths/8j_CreateKeyPairDuplicate" classname="" time="0.040">
<system-out><![CDATA[ negative_test.go:178: �[2m· create-key-pair test-key-1 (duplicate)�[0m]]></system-out>
</testcase>
<testcase name="TestSingleNode/Phase8_NegativeErrorPaths/8k_ImportKeyPairDuplicate" classname="" time="0.040">
<system-out><![CDATA[�[2m· import-key-pair test-key-1 (duplicate)�[0m
negative_test.go:198: �[2m· import-key-pair test-key-1 (duplicate)�[0m]]></system-out>
</testcase>
<testcase name="TestSingleNode/Phase8_NegativeErrorPaths/8l_InvalidKeyFormat" classname="" time="0.040">
<system-out><![CDATA[ negative_test.go:210: �[2m· import-key-pair bad-format-key (bad material)�[0m]]></system-out>
</testcase>
<testcase name="TestSingleNode/Phase8_NegativeErrorPaths/8m_DescribeVolumesNotFound" classname="" time="0.040">
<system-out><![CDATA[ negative_test.go:230: �[2m· describe-volumes vol-0000000000000dead�[0m]]></system-out>
</testcase>
<testcase name="TestSingleNode/Phase8_NegativeErrorPaths/8n_DescribeImagesNotFound" classname="" time="0.040">
<system-out><![CDATA[�[2m· describe-images ami-0000000000000dead�[0m
negative_test.go:240: �[2m· describe-images ami-0000000000000dead�[0m]]></system-out>
</testcase>
<testcase name="TestSingleNode/Phase8_NegativeErrorPaths/8o_CreateImageDuplicateName" classname="" time="0.560">
<system-out><![CDATA[�[2m· create-key-pair test-key-1 (duplicate)�[0m
�[2m· create-image name=e2e-custom-ami (duplicate)�[0m
�[2m· import-key-pair bad-format-key (bad material)�[0m
negative_test.go:252: �[2m· create-image name=e2e-custom-ami (duplicate)�[0m]]></system-out>
</testcase>
<testcase name="TestSingleNode/Phase8_NegativeErrorPaths/8p_DeleteKeyPairIdempotent" classname="" time="0.020">
<system-out><![CDATA[�[2m· delete-key-pair nonexistent-key-99999 (idempotent)�[0m
negative_test.go:276: �[2m· delete-key-pair nonexistent-key-99999 (idempotent)�[0m
�[2mstatus�[0m=400 �[2mcode�[0m=InvalidAction]]></system-out>
</testcase>
<testcase name="TestSingleNode/Phase8_NegativeErrorPaths/8q_ModifyAttributeOnRunning" classname="" time="0.010">
<system-out><![CDATA[�[2m· modify-instance-attribute i-0c615e5dd74050f53 (running)�[0m
negative_test.go:289: �[2m· modify-instance-attribute i-0c615e5dd74050f53 (running)�[0m]]></system-out>
</testcase>
<testcase name="TestSingleNode/Phase8_NegativeErrorPaths/8r_RebootInstanceNotFound" classname="" time="0.040">
<system-out><![CDATA[ negative_test.go:302: �[2m· reboot-instances i-nonexistent�[0m]]></system-out>
</testcase>
<testcase name="TestSingleNode/IAM1_UserCRUD" classname="" time="0.070">
<system-out><![CDATA[
�[1m�[36m━━ IAM Phase 1 — User CRUD ━━�[0m
�[2m· list-users (root auth sanity)�[0m
iam_test.go:109: �[2m· list-users (root auth sanity)�[0m
�[2m· create-user "alice"�[0m
iam_test.go:114: �[2m· create-user "alice"�[0m
�[2muser�[0m=alice �[2marn�[0m=arn:aws:iam::000000000001:user/alice
iam_test.go:120: �[2muser�[0m=alice �[2marn�[0m=arn:aws:iam::000000000001:user/alice
�[2m· create-user "bob" path="/engineering/"�[0m
iam_test.go:122: �[2m· create-user "bob" path="/engineering/"�[0m
�[2m· create-user "alice" again (expect EntityAlreadyExists)�[0m
iam_test.go:131: �[2m· create-user "alice" again (expect EntityAlreadyExists)�[0m
�[2m· get-user "alice"�[0m
iam_test.go:137: �[2m· get-user "alice"�[0m
�[2m· get-user nonexistent (expect NoSuchEntity)�[0m
iam_test.go:142: �[2m· get-user nonexistent (expect NoSuchEntity)�[0m
�[2m· list-users (>=3 root+alice+bob)�[0m
iam_test.go:148: �[2m· list-users (>=3 root+alice+bob)�[0m
�[2m· list-users --path-prefix "/engineering/" (expect 1)�[0m
iam_test.go:154: �[2m· list-users --path-prefix "/engineering/" (expect 1)�[0m
iam_test.go:193: �[2m· create-user "iam-phase1-ephemeral" (delete idempotency probe)�[0m
�[2m· delete-user "iam-phase1-ephemeral"�[0m
iam_test.go:197: �[2m· delete-user "iam-phase1-ephemeral"�[0m
�[2m· delete-user "iam-phase1-ephemeral" again (expect NoSuchEntity)�[0m
iam_test.go:201: �[2m· delete-user "iam-phase1-ephemeral" again (expect NoSuchEntity)�[0m]]></system-out>
</testcase>
<testcase name="TestSingleNode/IAM1_UserCRUD/UpdateUser" classname="" time="0.000">
<skipped message="Skipped"><![CDATA[ iam_test.go:165: daemon UpdateUser handler not implemented — InvalidAction
�[2m· create-user "iam-phase1-ephemeral" (delete idempotency probe)�[0m]]></skipped>
</testcase>
<testcase name="TestSingleNode/IAM2_AccessKeyLifecycle" classname="" time="0.040">
<system-out><![CDATA[
�[1m�[36m━━ IAM Phase 2 — Access Key Lifecycle ━━�[0m
�[2m· create-access-key user=alice (key 1)�[0m
iam_test.go:218: �[2m· create-access-key user=alice (key 1)�[0m
�[2mkey1�[0m=AKIA4984AE57CF8D721A879A
iam_test.go:227: �[2mkey1�[0m=AKIA4984AE57CF8D721A879A
�[2m· create-access-key user=alice (key 2)�[0m
iam_test.go:229: �[2m· create-access-key user=alice (key 2)�[0m
�[2mkey2�[0m=AKIAA1CD38D4F0E573216E11
iam_test.go:236: �[2mkey2�[0m=AKIAA1CD38D4F0E573216E11
�[2m· create-access-key user=alice key 3 (expect LimitExceeded)�[0m
iam_test.go:238: �[2m· create-access-key user=alice key 3 (expect LimitExceeded)�[0m
�[2m· create-access-key user=ghost (expect NoSuchEntity)�[0m
iam_test.go:246: �[2m· create-access-key user=ghost (expect NoSuchEntity)�[0m
�[2m· list-access-keys user=alice (expect 2)�[0m
iam_test.go:254: �[2m· list-access-keys user=alice (expect 2)�[0m
�[2m· list-access-keys user=bob (expect 0)�[0m
iam_test.go:261: �[2m· list-access-keys user=bob (expect 0)�[0m
�[2m· update-access-key AKIA4984AE57CF8D721A879A -> Inactive�[0m
iam_test.go:268: �[2m· update-access-key AKIA4984AE57CF8D721A879A -> Inactive�[0m
�[2m· update-access-key AKIA4984AE57CF8D721A879A -> Active�[0m
iam_test.go:279: �[2m· update-access-key AKIA4984AE57CF8D721A879A -> Active�[0m
�[2m· delete-access-key AKIAA1CD38D4F0E573216E11 (key 2)�[0m
iam_test.go:287: �[2m· delete-access-key AKIAA1CD38D4F0E573216E11 (key 2)�[0m]]></system-out>
</testcase>
<testcase name="TestSingleNode/IAM3_UserAuthentication" classname="" time="0.000">
<skipped message="Skipped"><![CDATA[
�[1m�[36m━━ IAM Phase 3 — User Authentication ━━�[0m
iam_test.go:311: daemon IAM signature/principal lookup not implemented — mulga-siv-100]]></skipped>
</testcase>
<testcase name="TestSingleNode/IAM4_PolicyCRUD" classname="" time="0.040">
<system-out><![CDATA[
�[1m�[36m━━ IAM Phase 4 — Policy CRUD ━━�[0m
�[2m· create-policy EC2ReadOnly�[0m
iam_test.go:384: �[2m· create-policy EC2ReadOnly�[0m
�[2mpolicy�[0m=EC2ReadOnly �[2marn�[0m=arn:aws:iam::000000000001:policy/EC2ReadOnly �[2maccount�[0m=000000000001
iam_test.go:393: �[2mpolicy�[0m=EC2ReadOnly �[2marn�[0m=arn:aws:iam::000000000001:policy/EC2ReadOnly �[2maccount�[0m=000000000001
iam_test.go:440: �[2m· create-policy duplicate (expect EntityAlreadyExists)�[0m
�[2m· create-policy malformed (expect MalformedPolicyDocument)�[0m
iam_test.go:449: �[2m· create-policy malformed (expect MalformedPolicyDocument)�[0m
�[2m· get-policy EC2ReadOnly�[0m
iam_test.go:458: �[2m· get-policy EC2ReadOnly�[0m
�[2m· get-policy nonexistent (expect NoSuchEntity)�[0m
iam_test.go:463: �[2m· get-policy nonexistent (expect NoSuchEntity)�[0m
�[2m· get-policy-version EC2ReadOnly v1�[0m
iam_test.go:470: �[2m· get-policy-version EC2ReadOnly v1�[0m
iam_test.go:519: �[2m· list-policies (>=5)�[0m]]></system-out>
</testcase>
<testcase name="TestSingleNode/IAM4_PolicyCRUD/create_policies_parallel" classname="" time="0.000"></testcase>
<testcase name="TestSingleNode/IAM4_PolicyCRUD/create_policies_parallel/FullAdmin" classname="" time="0.010">
<system-out><![CDATA[�[2m· create-policy FullAdmin path=/admin/�[0m
iam_test.go:402: �[2m· create-policy FullAdmin path=/admin/�[0m]]></system-out>
</testcase>
<testcase name="TestSingleNode/IAM4_PolicyCRUD/create_policies_parallel/DenyTerminate" classname="" time="0.010">
<system-out><![CDATA[�[2m· create-policy DenyTerminate (mixed Allow+Deny)�[0m
iam_test.go:413: �[2m· create-policy DenyTerminate (mixed Allow+Deny)�[0m]]></system-out>
</testcase>
<testcase name="TestSingleNode/IAM4_PolicyCRUD/create_policies_parallel/IAMReadOnly" classname="" time="0.020">
<system-out><![CDATA[�[2m· create-policy IAMReadOnly�[0m
iam_test.go:422: �[2m· create-policy IAMReadOnly�[0m]]></system-out>
</testcase>
<testcase name="TestSingleNode/IAM4_PolicyCRUD/create_policies_parallel/EC2DescribeAll" classname="" time="0.020">
<system-out><![CDATA[�[2m· create-policy EC2DescribeAll (wildcard)�[0m
iam_test.go:431: �[2m· create-policy EC2DescribeAll (wildcard)�[0m
�[2m· create-policy duplicate (expect EntityAlreadyExists)�[0m]]></system-out>
</testcase>
<testcase name="TestSingleNode/IAM4_PolicyCRUD/PolicyVersionLifecycle" classname="" time="0.000">
<skipped message="Skipped"><![CDATA[ iam_test.go:484: daemon CreatePolicyVersion handler not implemented — InvalidAction
�[2m· list-policies (>=5)�[0m]]></skipped>
</testcase>
<testcase name="TestSingleNode/IAM5_PolicyAttachmentEnforcement" classname="" time="0.000">
<skipped message="Skipped"><![CDATA[
�[1m�[36m━━ IAM Phase 5 — Policy Attachment & Enforcement ━━�[0m
iam_test.go:535: daemon IAM scoped-credential enforcement not implemented — mulga-siv-100]]></skipped>
</testcase>
<testcase name="TestSingleNode/IAM6_PolicyLifecycle" classname="" time="0.000">
<skipped message="Skipped"><![CDATA[
�[1m�[36m━━ IAM Phase 6 — Policy Lifecycle (Detach & Delete) ━━�[0m
iam_test.go:718: daemon DetachUserPolicy not implemented — mulga-siv-100]]></skipped>
</testcase>
<testcase name="TestSingleNode/IAM7_Cleanup" classname="" time="0.050">
<system-out><![CDATA[
�[1m�[36m━━ IAM Phase 7 — Cleanup ━━�[0m
�[2m· cleanup user alice�[0m
iam_test.go:770: �[2m· cleanup user alice�[0m
�[2m· cleanup user bob�[0m
iam_test.go:770: �[2m· cleanup user bob�[0m
�[2m· cleanup user charlie�[0m
iam_test.go:770: �[2m· cleanup user charlie�[0m
�[2mremaining_users�[0m=1 �[2mnames�[0m=[admin]
iam_test.go:779: �[2mremaining_users�[0m=1 �[2mnames�[0m=[admin]
�[2m· cleanup policy arn:aws:iam::000000000001:policy/EC2ReadOnly�[0m
iam_test.go:806: �[2m· cleanup policy arn:aws:iam::000000000001:policy/EC2ReadOnly�[0m
�[2m· cleanup policy arn:aws:iam::000000000001:policy/admin/FullAdmin�[0m
iam_test.go:806: �[2m· cleanup policy arn:aws:iam::000000000001:policy/admin/FullAdmin�[0m
�[2m· cleanup policy arn:aws:iam::000000000001:policy/IAMReadOnly�[0m
iam_test.go:806: �[2m· cleanup policy arn:aws:iam::000000000001:policy/IAMReadOnly�[0m
�[2m· cleanup policy arn:aws:iam::000000000001:policy/EC2DescribeAll�[0m
iam_test.go:806: �[2m· cleanup policy arn:aws:iam::000000000001:policy/EC2DescribeAll�[0m
�[2m· cleanup policy arn:aws:iam::000000000001:policy/DenyTerminate�[0m
iam_test.go:806: �[2m· cleanup policy arn:aws:iam::000000000001:policy/DenyTerminate�[0m
�[2mremaining_policies�[0m=1 �[2mnames�[0m=[AdministratorAccess]
iam_test.go:812: �[2mremaining_policies�[0m=1 �[2mnames�[0m=[AdministratorAccess]]]></system-out>
</testcase>
<testcase name="TestSingleNode/Phase8Acct_AccountScoping" classname="" time="114.890">
<system-out><![CDATA[
�[1m�[36m━━ Phase 8 (acct) — EC2 Account Scoping ━━�[0m
account_test.go:75: �[2m· Phase 8 acct cleanup (deferred)�[0m]]></system-out>
</testcase>
<testcase name="TestSingleNode/Phase8Acct_AccountScoping/Step1_AccountSetup" classname="" time="1.220">
<system-out><![CDATA[�[2m· create account 'Team Alpha'�[0m
account_test.go:206: �[2m· create account 'Team Alpha'�[0m
�[2malpha_account�[0m=000000000002 �[2malpha_key�[0m=AKIA374CBBB2A15D3E2EE822
account_test.go:211: �[2malpha_account�[0m=000000000002 �[2malpha_key�[0m=AKIA374CBBB2A15D3E2EE822
�[2m· create account 'Team Beta'�[0m
account_test.go:213: �[2m· create account 'Team Beta'�[0m
�[2mbeta_account�[0m=000000000003 �[2mbeta_key�[0m=AKIA0AA0B2845E3BD5A26D01
account_test.go:218: �[2mbeta_account�[0m=000000000003 �[2mbeta_key�[0m=AKIA0AA0B2845E3BD5A26D01]]></system-out>
</testcase>
<testcase name="TestSingleNode/Phase8Acct_AccountScoping/Step2_InstanceScoping" classname="" time="52.380">
<system-out><![CDATA[�[2m· create per-account key pairs�[0m
account_test.go:242: �[2m· create per-account key pairs�[0m
�[2m· alpha run-instances�[0m
account_test.go:250: �[2m· alpha run-instances�[0m
�[2malpha_instance�[0m=i-a3072c2387630b8ee
account_test.go:262: �[2malpha_instance�[0m=i-a3072c2387630b8ee
�[2m· beta run-instances�[0m
account_test.go:264: �[2m· beta run-instances�[0m
�[2mbeta_instance�[0m=i-ad32cbb0afaee5eee
account_test.go:276: �[2mbeta_instance�[0m=i-ad32cbb0afaee5eee
account_test.go:278: instance i-a3072c2387630b8ee reached state running
account_test.go:279: instance i-ad32cbb0afaee5eee reached state running
�[2m· alpha sees only own instances�[0m
account_test.go:281: �[2m· alpha sees only own instances�[0m
�[2m· beta sees only own instances�[0m
account_test.go:285: �[2m· beta sees only own instances�[0m
�[2m· alpha OwnerId matches alpha account�[0m
account_test.go:289: �[2m· alpha OwnerId matches alpha account�[0m
�[2m· cross-account stop alpha->beta blocked�[0m
account_test.go:296: �[2m· cross-account stop alpha->beta blocked�[0m
�[2m· cross-account terminate beta->alpha blocked�[0m
account_test.go:304: �[2m· cross-account terminate beta->alpha blocked�[0m
�[2m· cross-account reboot alpha->beta blocked�[0m
account_test.go:312: �[2m· cross-account reboot alpha->beta blocked�[0m
�[2m· stop alpha instance for cross-account start/modify/console tests�[0m
account_test.go:321: �[2m· stop alpha instance for cross-account start/modify/console tests�[0m
account_test.go:326: instance i-a3072c2387630b8ee reached state stopped
�[2m· cross-account start beta->alpha blocked�[0m
account_test.go:328: �[2m· cross-account start beta->alpha blocked�[0m
�[2m· cross-account modify-instance-attribute beta->alpha blocked�[0m
account_test.go:336: �[2m· cross-account modify-instance-attribute beta->alpha blocked�[0m
�[2m· cross-account get-console-output beta->alpha blocked�[0m
account_test.go:345: �[2m· cross-account get-console-output beta->alpha blocked�[0m
�[2m· restart alpha instance�[0m
account_test.go:354: �[2m· restart alpha instance�[0m
account_test.go:359: instance i-a3072c2387630b8ee reached state running]]></system-out>
</testcase>
<testcase name="TestSingleNode/Phase8Acct_AccountScoping/Step3_VolumeScoping" classname="" time="24.140">
<system-out><![CDATA[�[2m· alpha create-volume�[0m
account_test.go:366: �[2m· alpha create-volume�[0m
�[2malpha_vol�[0m=vol-ceef82652369780af
account_test.go:375: �[2malpha_vol�[0m=vol-ceef82652369780af
�[2m· beta create-volume�[0m
account_test.go:377: �[2m· beta create-volume�[0m
�[2mbeta_vol�[0m=vol-08024f32d23d9e18c
account_test.go:386: �[2mbeta_vol�[0m=vol-08024f32d23d9e18c
account_test.go:388: volume vol-ceef82652369780af reached state available
account_test.go:389: volume vol-08024f32d23d9e18c reached state available
�[2m· alpha sees only own volumes�[0m
account_test.go:391: �[2m· alpha sees only own volumes�[0m
�[2m· cross-account describe-volume by id blocked�[0m
account_test.go:395: �[2m· cross-account describe-volume by id blocked�[0m
�[2m· cross-account delete-volume blocked�[0m
account_test.go:403: �[2m· cross-account delete-volume blocked�[0m
�[2m· cross-account attach-volume (beta attaches alpha's vol) blocked�[0m
account_test.go:411: �[2m· cross-account attach-volume (beta attaches alpha's vol) blocked�[0m
�[2m· alpha attach-volume to own instance�[0m
account_test.go:422: �[2m· alpha attach-volume to own instance�[0m
account_test.go:429: volume vol-ceef82652369780af reached state in-use
�[2m· cross-account detach-volume blocked�[0m
account_test.go:431: �[2m· cross-account detach-volume blocked�[0m
�[2m· cross-account modify-volume blocked�[0m
account_test.go:439: �[2m· cross-account modify-volume blocked�[0m
�[2m· alpha detach own volume�[0m
account_test.go:449: �[2m· alpha detach own volume�[0m
account_test.go:454: volume vol-ceef82652369780af reached state available]]></system-out>
</testcase>
<testcase name="TestSingleNode/Phase8Acct_AccountScoping/Step4_KeyPairScoping" classname="" time="0.370">
<system-out><![CDATA[�[2m· alpha create-key-pair alpha-key�[0m
account_test.go:466: �[2m· alpha create-key-pair alpha-key�[0m
�[2m· beta create-key-pair beta-key�[0m
account_test.go:473: �[2m· beta create-key-pair beta-key�[0m
�[2m· alpha sees only own keys�[0m
account_test.go:478: �[2m· alpha sees only own keys�[0m
�[2m· namespace isolation: shared-name in both accounts�[0m
account_test.go:483: �[2m· namespace isolation: shared-name in both accounts�[0m
�[2m· beta deletes alpha-key (idempotent, no cross-account effect)�[0m
account_test.go:497: �[2m· beta deletes alpha-key (idempotent, no cross-account effect)�[0m
�[2m· alpha import-key-pair imported-key�[0m
account_test.go:503: �[2m· alpha import-key-pair imported-key�[0m]]></system-out>
</testcase>
<testcase name="TestSingleNode/Phase8Acct_AccountScoping/Step5_SnapshotScoping" classname="" time="0.060">
<system-out><![CDATA[�[2m· alpha create-snapshot�[0m
account_test.go:522: �[2m· alpha create-snapshot�[0m
�[2malpha_snap�[0m=snap-2a47896f4304d805c
account_test.go:530: �[2malpha_snap�[0m=snap-2a47896f4304d805c
�[2m· beta create-snapshot�[0m
account_test.go:532: �[2m· beta create-snapshot�[0m
�[2mbeta_snap�[0m=snap-bf5744e78bd7c6908
account_test.go:540: �[2mbeta_snap�[0m=snap-bf5744e78bd7c6908
�[2m· alpha sees only own snapshots (owner-ids=self)�[0m
account_test.go:542: �[2m· alpha sees only own snapshots (owner-ids=self)�[0m
�[2m· cross-account delete-snapshot blocked (UnauthorizedOperation)�[0m
account_test.go:560: �[2m· cross-account delete-snapshot blocked (UnauthorizedOperation)�[0m
�[2m· cross-account create-snapshot of alpha's volume blocked�[0m
account_test.go:568: �[2m· cross-account create-snapshot of alpha's volume blocked�[0m]]></system-out>
</testcase>
<testcase name="TestSingleNode/Phase8Acct_AccountScoping/Step6_VPCSubnetScoping" classname="" time="0.030">
<system-out><![CDATA[�[2m· alpha create-vpc 10.0.0.0/16�[0m
account_test.go:582: �[2m· alpha create-vpc 10.0.0.0/16�[0m
�[2malpha_vpc�[0m=vpc-7f60df5e87183fb59
account_test.go:589: �[2malpha_vpc�[0m=vpc-7f60df5e87183fb59
�[2m· beta create-vpc 10.0.0.0/16 (same CIDR, no conflict)�[0m
account_test.go:591: �[2m· beta create-vpc 10.0.0.0/16 (same CIDR, no conflict)�[0m
�[2mbeta_vpc�[0m=vpc-7e8c859d3985c8218
account_test.go:598: �[2mbeta_vpc�[0m=vpc-7e8c859d3985c8218
�[2m· alpha describe-vpcs isolation�[0m
account_test.go:600: �[2m· alpha describe-vpcs isolation�[0m
�[2m· cross-account describe-vpc by id blocked�[0m
account_test.go:605: �[2m· cross-account describe-vpc by id blocked�[0m
�[2m· cross-account delete-vpc blocked�[0m
account_test.go:613: �[2m· cross-account delete-vpc blocked�[0m
�[2m· alpha create-subnet 10.0.1.0/24�[0m
account_test.go:621: �[2m· alpha create-subnet 10.0.1.0/24�[0m
�[2m· beta create-subnet 10.0.1.0/24�[0m
account_test.go:630: �[2m· beta create-subnet 10.0.1.0/24�[0m
�[2m· alpha describe-subnets isolation�[0m
account_test.go:639: �[2m· alpha describe-subnets isolation�[0m
�[2m· cross-account create-subnet in other VPC blocked�[0m
account_test.go:648: �[2m· cross-account create-subnet in other VPC blocked�[0m
�[2m· cross-account delete-subnet blocked�[0m
account_test.go:657: �[2m· cross-account delete-subnet blocked�[0m]]></system-out>
</testcase>
<testcase name="TestSingleNode/Phase8Acct_AccountScoping/Step7_IGWEIGWScoping" classname="" time="0.030">
<system-out><![CDATA[�[2m· alpha create-internet-gateway�[0m
account_test.go:673: �[2m· alpha create-internet-gateway�[0m
�[2malpha_igw�[0m=igw-ee966e3e923caf630
account_test.go:678: �[2malpha_igw�[0m=igw-ee966e3e923caf630
�[2m· beta create-internet-gateway�[0m
account_test.go:680: �[2m· beta create-internet-gateway�[0m
�[2mbeta_igw�[0m=igw-b46e66f2065889bb9
account_test.go:685: �[2mbeta_igw�[0m=igw-b46e66f2065889bb9
�[2m· alpha describe-internet-gateways isolation�[0m
account_test.go:687: �[2m· alpha describe-internet-gateways isolation�[0m
�[2m· cross-account describe IGW by id blocked�[0m
account_test.go:696: �[2m· cross-account describe IGW by id blocked�[0m
�[2m· cross-account delete IGW blocked�[0m
account_test.go:704: �[2m· cross-account delete IGW blocked�[0m
�[2m· cross-account attach IGW (alpha attaches beta's IGW) blocked�[0m
account_test.go:712: �[2m· cross-account attach IGW (alpha attaches beta's IGW) blocked�[0m
�[2m· alpha attach own IGW to own VPC�[0m
account_test.go:722: �[2m· alpha attach own IGW to own VPC�[0m
�[2m· cross-account detach IGW blocked�[0m
account_test.go:729: �[2m· cross-account detach IGW blocked�[0m
�[2m· alpha create-egress-only-internet-gateway�[0m
account_test.go:739: �[2m· alpha create-egress-only-internet-gateway�[0m
�[2malpha_eigw�[0m=eigw-51ba82da927e49393
account_test.go:746: �[2malpha_eigw�[0m=eigw-51ba82da927e49393
�[2m· beta create-egress-only-internet-gateway�[0m
account_test.go:748: �[2m· beta create-egress-only-internet-gateway�[0m
�[2mbeta_eigw�[0m=eigw-e88d642e78ab4fe5a
account_test.go:755: �[2mbeta_eigw�[0m=eigw-e88d642e78ab4fe5a
�[2m· alpha describe-eigws isolation�[0m
account_test.go:757: �[2m· alpha describe-eigws isolation�[0m]]></system-out>
</testcase>
<testcase name="TestSingleNode/Phase8Acct_AccountScoping/Step8_AccountSettings" classname="" time="0.010">
<system-out><![CDATA[�[2m· alpha enable-ebs-encryption-by-default�[0m
account_test.go:794: �[2m· alpha enable-ebs-encryption-by-default�[0m]]></system-out>
</testcase>
<testcase name="TestSingleNode/Phase8Acct_AccountScoping/Step9_GlobalResources" classname="" time="0.010">
<system-out><![CDATA[�[2m· describe-regions identical across accounts�[0m
account_test.go:829: �[2m· describe-regions identical across accounts�[0m
�[2m· describe-availability-zones identical across accounts�[0m
account_test.go:834: �[2m· describe-availability-zones identical across accounts�[0m
�[2m· describe-instance-types identical across accounts�[0m
account_test.go:839: �[2m· describe-instance-types identical across accounts�[0m]]></system-out>
</testcase>
<testcase name="TestSingleNode/Phase8Acct_AccountScoping/Step10_EdgeCases" classname="" time="1.200">
<system-out><![CDATA[�[2m· create empty Gamma account�[0m
account_test.go:849: �[2m· create empty Gamma account�[0m
�[2mgamma_account�[0m=000000000004
account_test.go:852: �[2mgamma_account�[0m=000000000004
�[2m· root create-key-pair root-scoping-key�[0m
account_test.go:879: �[2m· root create-key-pair root-scoping-key�[0m
�[2m· non-existent volume id -> InvalidVolume.NotFound�[0m
account_test.go:895: �[2m· non-existent volume id -> InvalidVolume.NotFound�[0m
�[2m· non-existent snapshot id -> InvalidSnapshot.NotFound�[0m
account_test.go:903: �[2m· non-existent snapshot id -> InvalidSnapshot.NotFound�[0m]]></system-out>
</testcase>
<testcase name="TestSingleNode/Phase8Acct_AccountScoping/Step11_Cleanup" classname="" time="32.440">
<system-out><![CDATA[�[2m· terminate alpha instance�[0m
account_test.go:920: �[2m· terminate alpha instance�[0m
�[2m· terminate beta instance�[0m
account_test.go:926: �[2m· terminate beta instance�[0m
�[2m· delete snapshots�[0m
account_test.go:939: �[2m· delete snapshots�[0m
�[2m· delete volumes�[0m
account_test.go:955: �[2m· delete volumes�[0m
�[2m· delete key pairs�[0m
account_test.go:969: �[2m· delete key pairs�[0m
�[2m· delete EIGWs�[0m
account_test.go:979: �[2m· delete EIGWs�[0m
�[2m· detach + delete IGWs�[0m
account_test.go:993: �[2m· detach + delete IGWs�[0m
�[2m· delete subnets�[0m
account_test.go:1013: �[2m· delete subnets�[0m
�[2m· delete VPCs�[0m
account_test.go:1027: �[2m· delete VPCs�[0m
�[2m· Phase 8 acct cleanup (deferred)�[0m]]></system-out>
</testcase>
<testcase name="TestSingleNode/Phase8b_VPCSubnetE2E" classname="" time="200.480">
<failure message="Failed"><![CDATA[
�[1m�[36m━━ Phase 8b — VPC Public/Private Subnet E2E ━━�[0m
�[2m· create-vpc 10.99.0.0/16�[0m
vpc_test.go:56: �[2m· create-vpc 10.99.0.0/16�[0m
�[2mvpc�[0m=vpc-39eff985799c43069 �[2mcidr�[0m=10.99.0.0/16
vpc_test.go:67: �[2mvpc�[0m=vpc-39eff985799c43069 �[2mcidr�[0m=10.99.0.0/16
�[2m· create-subnet 10.99.1.0/24 (public)�[0m
vpc_test.go:70: �[2m· create-subnet 10.99.1.0/24 (public)�[0m
�[2m· modify-subnet-attribute MapPublicIpOnLaunch=true subnet-22aba054e10588254�[0m
vpc_test.go:87: �[2m· modify-subnet-attribute MapPublicIpOnLaunch=true subnet-22aba054e10588254�[0m
�[2mpub_subnet�[0m=subnet-22aba054e10588254 �[2mmap_public_ip�[0m=true
vpc_test.go:93: �[2mpub_subnet�[0m=subnet-22aba054e10588254 �[2mmap_public_ip�[0m=true
�[2m· create-subnet 10.99.2.0/24 (private)�[0m
vpc_test.go:96: �[2m· create-subnet 10.99.2.0/24 (private)�[0m
�[2mpriv_subnet�[0m=subnet-f53aeddb73c134696 �[2mmap_public_ip�[0m=false
vpc_test.go:111: �[2mpriv_subnet�[0m=subnet-f53aeddb73c134696 �[2mmap_public_ip�[0m=false
�[2m· create-internet-gateway�[0m
vpc_test.go:114: �[2m· create-internet-gateway�[0m
�[2m· attach-internet-gateway igw-1520b5a34480da982 -> vpc-39eff985799c43069�[0m
vpc_test.go:133: �[2m· attach-internet-gateway igw-1520b5a34480da982 -> vpc-39eff985799c43069�[0m
�[2migw�[0m=igw-1520b5a34480da982
vpc_test.go:139: �[2migw�[0m=igw-1520b5a34480da982
�[2m· create-route-table (vpc=vpc-39eff985799c43069)�[0m
vpc_test.go:142: �[2m· create-route-table (vpc=vpc-39eff985799c43069)�[0m
�[2m· associate-route-table rtb-662df5e424cbdb60c <- subnet-22aba054e10588254�[0m
vpc_test.go:159: �[2m· associate-route-table rtb-662df5e424cbdb60c <- subnet-22aba054e10588254�[0m
�[2m· create-route 0.0.0.0/0 -> igw-1520b5a34480da982�[0m
vpc_test.go:176: �[2m· create-route 0.0.0.0/0 -> igw-1520b5a34480da982�[0m
�[2mrtb�[0m=rtb-662df5e424cbdb60c �[2massoc�[0m=rtbassoc-5510bf1dc6924eb0b �[2mroute�[0m=0.0.0.0/0->igw-1520b5a34480da982
vpc_test.go:192: �[2mrtb�[0m=rtb-662df5e424cbdb60c �[2massoc�[0m=rtbassoc-5510bf1dc6924eb0b �[2mroute�[0m=0.0.0.0/0->igw-1520b5a34480da982
�[2m· run-instances ami=ami-6f60f2b3bf04b5453 subnet=subnet-22aba054e10588254�[0m
vpc_test.go:195: �[2m· run-instances ami=ami-6f60f2b3bf04b5453 subnet=subnet-22aba054e10588254�[0m
�[2minstance�[0m=i-3177888cba3796d42
vpc_test.go:216: �[2minstance�[0m=i-3177888cba3796d42
vpc_test.go:218: instance i-3177888cba3796d42 reached state running
�[2mpublic_ip�[0m=192.168.0.202 �[2mprivate_ip�[0m=10.99.1.4
vpc_test.go:224: �[2mpublic_ip�[0m=192.168.0.202 �[2mprivate_ip�[0m=10.99.1.4
�[2m· waiting for SSH handshake 192.168.0.202:22�[0m
vpc_test.go:227: �[2m· waiting for SSH handshake 192.168.0.202:22�[0m
vpc_test.go:227: Eventually: condition not met within 3m0s: [SSH handshake 192.168.0.202:22 never completed]]]></failure>
</testcase>
<testcase name="TestSingleNode/Phase8c_RouteTableValidation" classname="" time="0.030">
<system-out><![CDATA[
�[1m�[36m━━ Phase 8c — Route Table Validation ━━�[0m
�[2mvpc�[0m=vpc-531252db3b4e1fc08 �[2msubnet�[0m=subnet-cd62e4c1c3cb50c63
vpc_test.go:292: �[2mvpc�[0m=vpc-531252db3b4e1fc08 �[2msubnet�[0m=subnet-cd62e4c1c3cb50c63]]></system-out>
</testcase>
<testcase name="TestSingleNode/Phase8c_RouteTableValidation/Step1_DefaultMainRouteTable" classname="" time="0.000">
<system-out><![CDATA[�[2m· describe-route-tables filter=vpc-id,association.main�[0m
vpc_test.go:297: �[2m· describe-route-tables filter=vpc-id,association.main�[0m
�[2mmain_rtb�[0m=rtb-e42012569f92f7184
vpc_test.go:309: �[2mmain_rtb�[0m=rtb-e42012569f92f7184
�[2mlocal_route�[0m=172.31.0.0/16
vpc_test.go:332: �[2mlocal_route�[0m=172.31.0.0/16
�[2mdefault_route�[0m=0.0.0.0/0->igw-79c2e643660b4b7e3
vpc_test.go:350: �[2mdefault_route�[0m=0.0.0.0/0->igw-79c2e643660b4b7e3]]></system-out>
</testcase>
<testcase name="TestSingleNode/Phase8c_RouteTableValidation/Step2_CustomRouteTableLifecycle" classname="" time="0.020">
<system-out><![CDATA[�[2m· create-route-table (vpc=vpc-531252db3b4e1fc08)�[0m
vpc_test.go:363: �[2m· create-route-table (vpc=vpc-531252db3b4e1fc08)�[0m
�[2mcustom_rtb�[0m=rtb-5e6c46936f3e44167
vpc_test.go:379: �[2mcustom_rtb�[0m=rtb-5e6c46936f3e44167
�[2mcustom_local_route�[0m=172.31.0.0/16
vpc_test.go:396: �[2mcustom_local_route�[0m=172.31.0.0/16
�[2mvpc_igw�[0m=igw-79c2e643660b4b7e3
vpc_test.go:410: �[2mvpc_igw�[0m=igw-79c2e643660b4b7e3
�[2m· create-route 0.0.0.0/0 -> igw-79c2e643660b4b7e3�[0m
vpc_test.go:415: �[2m· create-route 0.0.0.0/0 -> igw-79c2e643660b4b7e3�[0m
�[2m· associate-route-table rtb-5e6c46936f3e44167 <- subnet-cd62e4c1c3cb50c63�[0m
vpc_test.go:434: �[2m· associate-route-table rtb-5e6c46936f3e44167 <- subnet-cd62e4c1c3cb50c63�[0m
�[2mcustom_assoc�[0m=rtbassoc-da6377d37847d3d4a
vpc_test.go:450: �[2mcustom_assoc�[0m=rtbassoc-da6377d37847d3d4a
�[2m· disassociate-route-table rtbassoc-da6377d37847d3d4a�[0m
vpc_test.go:452: �[2m· disassociate-route-table rtbassoc-da6377d37847d3d4a�[0m
�[2m· delete-route 0.0.0.0/0�[0m
vpc_test.go:460: �[2m· delete-route 0.0.0.0/0�[0m
�[2m· delete-route-table rtb-5e6c46936f3e44167�[0m
vpc_test.go:468: �[2m· delete-route-table rtb-5e6c46936f3e44167�[0m]]></system-out>
</testcase>
<testcase name="TestSingleNode/Phase8c_RouteTableValidation/Step3_ErrorPaths" classname="" time="0.010">
<system-out><![CDATA[�[2m· create-route-table (scratch for error paths)�[0m
vpc_test.go:481: �[2m· create-route-table (scratch for error paths)�[0m
�[2m· delete-route local=172.31.0.0/16 (must fail)�[0m
vpc_test.go:517: �[2m· delete-route local=172.31.0.0/16 (must fail)�[0m
�[2mdelete_local_err�[0m=InvalidParameterValue
vpc_test.go:529: �[2mdelete_local_err�[0m=InvalidParameterValue
�[2m· delete-route-table rtb-e42012569f92f7184 (main, must fail)�[0m
vpc_test.go:533: �[2m· delete-route-table rtb-e42012569f92f7184 (main, must fail)�[0m
�[2mdelete_main_err�[0m=DependencyViolation
vpc_test.go:540: �[2mdelete_main_err�[0m=DependencyViolation]]></system-out>
</testcase>
<testcase name="TestSingleNode/Phase8d_NATGateway" classname="" time="106.010">
<system-out><![CDATA[
�[1m�[36m━━ Phase 8d — NAT Gateway E2E ━━�[0m
�[2mvpc�[0m=vpc-531252db3b4e1fc08 �[2mpub_subnet�[0m=subnet-cd62e4c1c3cb50c63 �[2msg�[0m=sg-764a7fa2a39577204
natgw_test.go:54: �[2mvpc�[0m=vpc-531252db3b4e1fc08 �[2mpub_subnet�[0m=subnet-cd62e4c1c3cb50c63 �[2msg�[0m=sg-764a7fa2a39577204
�[2m· create-subnet 172.31.16.0/20 (private)�[0m
natgw_test.go:58: �[2m· create-subnet 172.31.16.0/20 (private)�[0m
�[2mpriv_subnet�[0m=subnet-2360a40e28371e214
natgw_test.go:71: �[2mpriv_subnet�[0m=subnet-2360a40e28371e214
�[2m· run-instances bastion (default subnet)�[0m
natgw_test.go:74: �[2m· run-instances bastion (default subnet)�[0m
�[2m· run-instances private (private subnet)�[0m
natgw_test.go:98: �[2m· run-instances private (private subnet)�[0m
natgw_test.go:118: instance i-268402f0e28b5e477 reached state running
natgw_test.go:119: instance i-db7f3ed6e4cdaa5aa reached state running
�[2mbastion�[0m=i-268402f0e28b5e477 �[2mbastion_ip�[0m=192.168.0.202 �[2mprivate�[0m=i-db7f3ed6e4cdaa5aa �[2mprivate_ip�[0m=172.31.16.4
natgw_test.go:128: �[2mbastion�[0m=i-268402f0e28b5e477 �[2mbastion_ip�[0m=192.168.0.202 �[2mprivate�[0m=i-db7f3ed6e4cdaa5aa �[2mprivate_ip�[0m=172.31.16.4
�[2m· waiting for SSH handshake 192.168.0.202:22�[0m
natgw_test.go:133: �[2m· waiting for SSH handshake 192.168.0.202:22�[0m
�[2m· scp keypair -> bastion:/tmp/key.pem�[0m
natgw_test.go:139: �[2m· scp keypair -> bastion:/tmp/key.pem�[0m
�[2m· wait for private SSH via bastion�[0m
natgw_test.go:151: �[2m· wait for private SSH via bastion�[0m
�[2m· baseline: private VM has no internet�[0m
natgw_test.go:172: �[2m· baseline: private VM has no internet�[0m
�[2m· allocate-address (NAT EIP)�[0m
natgw_test.go:178: �[2m· allocate-address (NAT EIP)�[0m
�[2meip�[0m=192.168.0.203 �[2malloc�[0m=eipalloc-2d81753157cd62a50
natgw_test.go:195: �[2meip�[0m=192.168.0.203 �[2malloc�[0m=eipalloc-2d81753157cd62a50
�[2m· create-nat-gateway in subnet-cd62e4c1c3cb50c63�[0m
natgw_test.go:197: �[2m· create-nat-gateway in subnet-cd62e4c1c3cb50c63�[0m
�[2mnat_gw�[0m=nat-812216dd5105180c5
natgw_test.go:216: �[2mnat_gw�[0m=nat-812216dd5105180c5
natgw_test.go:218: nat gateway nat-812216dd5105180c5 reached state available
�[2m· create-route-table�[0m
natgw_test.go:223: �[2m· create-route-table�[0m
�[2m· associate-route-table rtb-8d6a4abd5f666ec13 <- subnet-2360a40e28371e214�[0m
natgw_test.go:241: �[2m· associate-route-table rtb-8d6a4abd5f666ec13 <- subnet-2360a40e28371e214�[0m
�[2m· create-route 0.0.0.0/0 -> nat-812216dd5105180c5�[0m
natgw_test.go:259: �[2m· create-route 0.0.0.0/0 -> nat-812216dd5105180c5�[0m
�[2m· verify private VM reaches 8.8.8.8 via NAT GW�[0m
natgw_test.go:279: �[2m· verify private VM reaches 8.8.8.8 via NAT GW�[0m
�[2m· delete-nat-gateway nat-812216dd5105180c5�[0m
natgw_test.go:293: �[2m· delete-nat-gateway nat-812216dd5105180c5�[0m
natgw_test.go:318: nat gateway nat-812216dd5105180c5 reached state deleted
�[2m· verify private VM lost internet after NAT GW teardown�[0m
natgw_test.go:329: �[2m· verify private VM lost internet after NAT GW teardown�[0m]]></system-out>
</testcase>
<testcase name="TestSingleNode/Phase8e_SGToSGDatapath" classname="" time="80.940">
<system-out><![CDATA[
�[1m�[36m━━ Phase 8e — Security Group SG-to-SG Datapath (OVN) ━━�[0m
�[2m· 8e-1 create sge-client + sge-target security groups�[0m
datapath_test.go:61: �[2m· 8e-1 create sge-client + sge-target security groups�[0m
�[2mclient_sg�[0m=sg-56e73c764a649f7ff �[2mtarget_sg�[0m=sg-67ad5efaa8be83b79
datapath_test.go:83: �[2mclient_sg�[0m=sg-56e73c764a649f7ff �[2mtarget_sg�[0m=sg-67ad5efaa8be83b79
�[2m· 8e-1 authorize target-sg ingress tcp/8080 from sg-56e73c764a649f7ff�[0m
datapath_test.go:91: �[2m· 8e-1 authorize target-sg ingress tcp/8080 from sg-56e73c764a649f7ff�[0m
�[2m· 8e-2 launch client-vm + target-vm�[0m
datapath_test.go:109: �[2m· 8e-2 launch client-vm + target-vm�[0m
datapath_test.go:132: instance i-eae6263d9b9fda069 reached state running
datapath_test.go:133: instance i-9d9411a83549d34e3 reached state running
�[2mclient_priv�[0m=172.31.0.5 �[2mclient_eni�[0m=eni-0f878da0143028915 �[2mtarget_priv�[0m=172.31.0.6 �[2mtarget_eni�[0m=eni-3bd355e19c7fdd45c
datapath_test.go:142: �[2mclient_priv�[0m=172.31.0.5 �[2mclient_eni�[0m=eni-0f878da0143028915 �[2mtarget_priv�[0m=172.31.0.6 �[2mtarget_eni�[0m=eni-3bd355e19c7fdd45c
�[2m· 8e-3 ovn-nbctl port_group membership�[0m
datapath_test.go:151: �[2m· 8e-3 ovn-nbctl port_group membership�[0m
�[2mclient_pg�[0m=sg_56e73c764a649f7ff �[2mtarget_pg�[0m=sg_67ad5efaa8be83b79
datapath_test.go:163: �[2mclient_pg�[0m=sg_56e73c764a649f7ff �[2mtarget_pg�[0m=sg_67ad5efaa8be83b79
�[2m· 8e-3 ovn-sbctl address_set sg_56e73c764a649f7ff_ip4 contains 172.31.0.5�[0m
datapath_test.go:170: �[2m· 8e-3 ovn-sbctl address_set sg_56e73c764a649f7ff_ip4 contains 172.31.0.5�[0m
�[2m· 8e-3 wait for client-vm SSH at 192.168.0.202:22�[0m
datapath_test.go:188: �[2m· 8e-3 wait for client-vm SSH at 192.168.0.202:22�[0m
�[2m· 8e-4 allowed traffic client -> target:172.31.0.6:8080�[0m
datapath_test.go:199: �[2m· 8e-4 allowed traffic client -> target:172.31.0.6:8080�[0m
�[2mstep4�[0m=allowed_traffic_ok
datapath_test.go:208: �[2mstep4�[0m=allowed_traffic_ok
�[2m· 8e-5 denied traffic client -> target:22 (no SSH ingress)�[0m
datapath_test.go:213: �[2m· 8e-5 denied traffic client -> target:22 (no SSH ingress)�[0m
�[2mstep5�[0m=denied_traffic_ok
datapath_test.go:217: �[2mstep5�[0m=denied_traffic_ok
�[2m· 8e-6 revoke target-sg ingress, retest (sync RequestEvent contract)�[0m
datapath_test.go:222: �[2m· 8e-6 revoke target-sg ingress, retest (sync RequestEvent contract)�[0m
�[2m· 8e-6 verify client -> target:8080 now blocked�[0m
datapath_test.go:240: �[2m· 8e-6 verify client -> target:8080 now blocked�[0m
�[2mstep6�[0m=revoke_blocked_ok
datapath_test.go:244: �[2mstep6�[0m=revoke_blocked_ok
�[2m· 8e-7 re-authorize target-sg ingress, verify traffic restored�[0m
datapath_test.go:250: �[2m· 8e-7 re-authorize target-sg ingress, verify traffic restored�[0m
�[2mstep7�[0m=restore_ok
datapath_test.go:271: �[2mstep7�[0m=restore_ok
datapath_test.go:127: instance i-eae6263d9b9fda069 reached state terminated
datapath_test.go:127: instance i-9d9411a83549d34e3 reached state terminated]]></system-out>
</testcase>
<testcase name="TestSingleNode/Phase9_Teardown" classname="" time="8.300">
<system-out><![CDATA[
�[1m�[36m━━ Phase 9 — Teardown ━━�[0m
�[2m· delete-snapshot snap-dde89b9ea2ef2e05a (custom AMI backing)�[0m
teardown_test.go:33: �[2m· delete-snapshot snap-dde89b9ea2ef2e05a (custom AMI backing)�[0m
�[2mdeleted_snapshot�[0m=snap-dde89b9ea2ef2e05a
teardown_test.go:39: �[2mdeleted_snapshot�[0m=snap-dde89b9ea2ef2e05a
�[2m· deregister-image ami-7dcf263a412dd14c6�[0m
teardown_test.go:51: �[2m· deregister-image ami-7dcf263a412dd14c6�[0m
�[2mderegistered_ami�[0m=ami-7dcf263a412dd14c6
teardown_test.go:57: �[2mderegistered_ami�[0m=ami-7dcf263a412dd14c6
�[2m· terminate-instances i-0c615e5dd74050f53�[0m
teardown_test.go:74: �[2m· terminate-instances i-0c615e5dd74050f53�[0m
�[2mterminated_instance�[0m=i-0c615e5dd74050f53
teardown_test.go:81: �[2mterminated_instance�[0m=i-0c615e5dd74050f53
�[2m· delete-key-pair test-key-1�[0m
teardown_test.go:94: �[2m· delete-key-pair test-key-1�[0m
�[2mdeleted_keypair�[0m=test-key-1
teardown_test.go:100: �[2mdeleted_keypair�[0m=test-key-1]]></system-out>
</testcase>
<testcase name="TestSingleNode/Phase9a_VerifyTeardown" classname="" time="0.560">
<system-out><![CDATA[
�[1m�[36m━━ Phase 9a — Verify Teardown ━━�[0m
�[2m· describe-instances i-0c615e5dd74050f53�[0m
teardown_test.go:123: �[2m· describe-instances i-0c615e5dd74050f53�[0m
�[2minstance_state�[0m=terminated
teardown_test.go:135: �[2minstance_state�[0m=terminated
�[2m· describe-images ami-7dcf263a412dd14c6�[0m
teardown_test.go:147: �[2m· describe-images ami-7dcf263a412dd14c6�[0m
�[2mami_reaped�[0m=ami-7dcf263a412dd14c6
teardown_test.go:159: �[2mami_reaped�[0m=ami-7dcf263a412dd14c6
�[2m· describe-key-pairs (must not contain test-key-1)�[0m
teardown_test.go:167: �[2m· describe-key-pairs (must not contain test-key-1)�[0m
�[2mkeypairs_listed�[0m=1
teardown_test.go:176: �[2mkeypairs_listed�[0m=1]]></system-out>
</testcase>
<testcase name="TestSingleNode/Phase9b_FinalClusterStats" classname="" time="3.070">
<system-out><![CDATA[
�[1m�[36m━━ Phase 9b — Final Cluster Stats ━━�[0m
�[2mspx_get_vms_bytes�[0m=118
teardown_test.go:208: �[2mspx_get_vms_bytes�[0m=118]]></system-out>
</testcase>
</testsuite>
</testsuites>
+1
View File
@@ -0,0 +1 @@
__pycache__/
+46
View File
@@ -0,0 +1,46 @@
# junit-report
Composite action that renders a glob of JUnit XML files into the GitHub
Actions job summary and emits one `::error` annotation per failed test case.
## Usage
```yaml
- name: Publish test summary
if: always()
uses: ./spinifex/.github/actions/junit-report # or your own checkout path
with:
junit-glob: ${{ env.ARTIFACT_DIR }}/junit-*.xml
title: "Go E2E results"
# fail-on-empty: "true" # opt-in: fail the step if the glob matches nothing
```
Outputs:
- `failures` — total `<failure>` + `<error>` counts across all matched files.
- `total` — total `<testcase>` count across all matched files.
The action expects standard JUnit XML (e.g. emitted by
`gotestsum --junitfile`, `go-junit-report`, or pytest's `--junitxml`). Both
single `<testsuite>` and wrapping `<testsuites>` roots are accepted.
## Companion convention: `::group::` for noisy steps
Long-running shell phases (tofu apply, package installs, tar-over-ssh) should
wrap their output in `::group::name` / `::endgroup::` markers so the Actions
UI collapses them by default. Pattern used in the e2e-go / e2e-ddil workflows:
```bash
run() {
local label="$1"; shift
echo "::group::${label}"
"$@" 2>&1 | tee -a "$TOFU_LOG"
local rc=${PIPESTATUS[0]}
echo "::endgroup::"
return "$rc"
}
run "tofu apply" tofu apply -var-file=... -auto-approve -input=false
```
Combined: groups make the live UI readable; this action makes the run header
land on the failures without scrolling the log.
+41
View File
@@ -0,0 +1,41 @@
name: JUnit Report
description: >-
Render JUnit XML files into the GitHub Actions job summary and emit
inline ::error annotations for each failed test case. Designed to be
called once per job after the test step (with `if: always()`) so summary
+ annotations appear on both passing and failing runs.
inputs:
junit-glob:
description: Shell glob matching JUnit XML files to render.
required: true
title:
description: Heading rendered at the top of the job summary section.
required: false
default: "Test results"
fail-on-empty:
description: >-
When "true", the action fails if the glob matches no files. Useful
for catching silently-skipped test phases.
required: false
default: "false"
outputs:
failures:
description: Total failed + errored test cases across all matched files.
value: ${{ steps.render.outputs.failures }}
total:
description: Total test cases across all matched files.
value: ${{ steps.render.outputs.total }}
runs:
using: composite
steps:
- id: render
shell: bash
env:
JUNIT_GLOB: ${{ inputs.junit-glob }}
JUNIT_TITLE: ${{ inputs.title }}
JUNIT_FAIL_ON_EMPTY: ${{ inputs.fail-on-empty }}
run: |
python3 "${{ github.action_path }}/render.py"
+281
View File
@@ -0,0 +1,281 @@
#!/usr/bin/env python3
"""Render JUnit XML files into GitHub Actions job summary + annotations.
Inputs (env):
JUNIT_GLOB Glob matching JUnit XML files.
JUNIT_TITLE Heading text for the summary section.
JUNIT_FAIL_ON_EMPTY "true" to exit non-zero when glob matches nothing.
GitHub-Actions outputs (via $GITHUB_OUTPUT):
failures Sum of <failures> + <errors> across all suites.
total Sum of <tests> across all suites.
Side effects:
Appends a markdown section to $GITHUB_STEP_SUMMARY with a per-file
totals table and a collapsible details block per failed test.
Emits one `::error` workflow command per failed/errored test case so
they surface as annotations in the run UI.
"""
from __future__ import annotations
import glob
import os
import re
import sys
import xml.etree.ElementTree as ET
from dataclasses import dataclass
# Strip ANSI SGR escapes from skipped/failure CDATA so the rendered reason is
# plain text (CDATA preserves the colour codes Go emits via harness.Phase).
_ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
def _strip_ansi(s: str) -> str:
return _ANSI_RE.sub("", s)
# Last `<file>.go:<line>: <reason>` line in a CDATA block is the t.Skipf /
# t.Fatal call site — that's the actionable signal. Phase banners and probe
# logs above it are noise for the summary view.
_SKIP_REASON_RE = re.compile(r"([A-Za-z0-9_.\-/]+\.go:\d+:\s*.+)$")
def _extract_skip_reason(cdata: str) -> str:
cdata = _strip_ansi(cdata).strip()
if not cdata:
return ""
for line in reversed(cdata.splitlines()):
line = line.strip()
m = _SKIP_REASON_RE.search(line)
if m:
return m.group(1).strip()
return cdata.splitlines()[-1].strip()
# Workflow commands carry their payload on one line; literal newlines must
# be percent-escaped per GitHub's workflow-command spec.
def _esc(s: str) -> str:
return (
s.replace("%", "%25")
.replace("\r", "%0D")
.replace("\n", "%0A")
)
@dataclass
class Case:
suite: str
classname: str
name: str
time: float
status: str # "pass" | "fail" | "error" | "skip"
message: str
detail: str
@dataclass
class FileTotals:
path: str
tests: int
failures: int
errors: int
skipped: int
time: float
cases: list[Case]
def _parse_file(path: str) -> FileTotals:
root = ET.parse(path).getroot()
# JUnit XML has two shapes: <testsuite> as root, or <testsuites> wrapping
# multiple <testsuite> children. Iterating with .iter() handles both.
suites = list(root.iter("testsuite"))
tests = sum(int(s.get("tests", 0) or 0) for s in suites)
failures = sum(int(s.get("failures", 0) or 0) for s in suites)
errors = sum(int(s.get("errors", 0) or 0) for s in suites)
skipped = sum(int(s.get("skipped", 0) or 0) for s in suites)
time = sum(float(s.get("time", 0) or 0) for s in suites)
cases: list[Case] = []
for s in suites:
suite_name = s.get("name", "")
for tc in s.findall("testcase"):
status = "pass"
message = ""
detail = ""
for tag in ("failure", "error"):
node = tc.find(tag)
if node is not None:
status = "fail" if tag == "failure" else "error"
message = (node.get("message") or "").strip()
detail = (node.text or "").strip()
break
skip_node = tc.find("skipped")
if skip_node is not None and status == "pass":
status = "skip"
# Skipped reason: prefer the CDATA call-site line, fall back
# to the @message attribute if CDATA is empty.
cdata = (skip_node.text or "").strip()
message = _extract_skip_reason(cdata) or (
skip_node.get("message") or ""
).strip()
cases.append(
Case(
suite=suite_name,
classname=tc.get("classname", ""),
name=tc.get("name", ""),
time=float(tc.get("time", 0) or 0),
status=status,
message=message,
detail=detail,
)
)
# Drop container test cases: any case whose name is a strict prefix of
# another case's name (with "/" separator) is a parent t.Run wrapper.
# Go reports the parent's status as a rollup of its children, so counting
# both double-tallies the same outcome (e.g. TestSingleNode failing
# because Phase8b_VPCSubnetE2E failed would show as 2 failures, not 1).
names = {c.name for c in cases}
prefixes = {n.rsplit("/", 1)[0] for n in names if "/" in n}
# A name is a container if some other name starts with "<name>/".
# Use the prefixes set rather than O(n^2) scanning.
def _is_container(name: str) -> bool:
return name in prefixes
leaf_cases = [c for c in cases if not _is_container(c.name)]
# Recompute totals from leaf cases so the per-file counts in the summary
# table match the de-duplicated failure list below it.
leaf_tests = len(leaf_cases)
leaf_failures = sum(1 for c in leaf_cases if c.status == "fail")
leaf_errors = sum(1 for c in leaf_cases if c.status == "error")
leaf_skipped = sum(1 for c in leaf_cases if c.status == "skip")
return FileTotals(
path=path,
tests=leaf_tests,
failures=leaf_failures,
errors=leaf_errors,
skipped=leaf_skipped,
time=time,
cases=leaf_cases,
)
def _file_label(path: str) -> str:
base = os.path.basename(path)
if base.endswith(".xml"):
base = base[:-4]
if base.startswith("junit-"):
base = base[len("junit-"):]
return base or path
def main() -> int:
pattern = os.environ.get("JUNIT_GLOB", "").strip()
title = os.environ.get("JUNIT_TITLE", "Test results").strip() or "Test results"
fail_on_empty = os.environ.get("JUNIT_FAIL_ON_EMPTY", "").strip().lower() == "true"
if not pattern:
print("::error::junit-report: junit-glob input is empty", file=sys.stderr)
return 1
paths = sorted(glob.glob(pattern))
summary_path = os.environ.get("GITHUB_STEP_SUMMARY", "/dev/null")
output_path = os.environ.get("GITHUB_OUTPUT", "/dev/null")
with open(summary_path, "a", encoding="utf-8") as summary, \
open(output_path, "a", encoding="utf-8") as outputs:
summary.write(f"## {title}\n\n")
if not paths:
summary.write(f"_No JUnit files matched `{pattern}`._\n\n")
outputs.write("failures=0\n")
outputs.write("total=0\n")
if fail_on_empty:
print(f"::error::junit-report: no files matched {pattern}", file=sys.stderr)
return 1
return 0
totals_by_file = [_parse_file(p) for p in paths]
grand_total = sum(f.tests for f in totals_by_file)
grand_fail = sum(f.failures + f.errors for f in totals_by_file)
grand_skip = sum(f.skipped for f in totals_by_file)
grand_pass = grand_total - grand_fail - grand_skip
grand_time = sum(f.time for f in totals_by_file)
if grand_fail == 0:
verdict = f"✅ **pass** — {grand_pass} / {grand_total}"
else:
verdict = f"❌ **{grand_fail} failed** — {grand_pass} / {grand_total} pass"
skip_part = f", {grand_skip} skipped" if grand_skip else ""
summary.write(f"{verdict}{skip_part} &nbsp; · &nbsp; {grand_time:.1f}s\n\n")
# Compact table — single status column, shorter headers. Drops
# separate err column (errors fold into fail count) so narrow
# screens don't wrap.
summary.write("| | Suite | Pass | Fail | Skip | Time |\n")
summary.write("|:-:|---|---:|---:|---:|---:|\n")
for f in totals_by_file:
fails = f.failures + f.errors
passed = f.tests - fails - f.skipped
icon = "✅" if fails == 0 else "❌"
summary.write(
f"| {icon} | `{_file_label(f.path)}` | {passed} | {fails} | "
f"{f.skipped} | {f.time:.1f}s |\n"
)
summary.write("\n")
failed_cases = [
c for f in totals_by_file for c in f.cases if c.status in ("fail", "error")
]
if failed_cases:
summary.write("---\n\n**Failures**\n\n")
for c in failed_cases:
header = f"{c.classname}.{c.name}" if c.classname else c.name
# One line per failure: name + truncated message. Full
# detail lives in the test artifact log; deep nesting and
# long messages wrap badly on narrow screens.
msg_short = (c.message or "").splitlines()[0] if c.message else ""
if len(msg_short) > 120:
msg_short = msg_short[:117] + "…"
line = f"- ❌ `{header}`"
if msg_short:
line += f" — {msg_short}"
summary.write(line + "\n")
# Workflow annotation — surfaces in run header + Files-changed view.
# classname for Go tests is the package path; map to a file when we can.
file_hint = c.classname.replace(".", "/")
msg = c.message or f"{c.status}: {header}"
print(f"::error file={file_hint},title={header}::{_esc(msg)}")
summary.write("\n")
skipped_cases = [
c for f in totals_by_file for c in f.cases if c.status == "skip"
]
if skipped_cases:
summary.write("---\n\n<details><summary><b>Skipped</b> ")
summary.write(f"({len(skipped_cases)})</summary>\n\n")
for c in skipped_cases:
header = f"{c.classname}.{c.name}" if c.classname else c.name
reason = (c.message or "").splitlines()[0] if c.message else ""
if len(reason) > 160:
reason = reason[:157] + "…"
line = f"- ⏭️ `{header}`"
if reason:
line += f" — {reason}"
summary.write(line + "\n")
summary.write("\n</details>\n\n")
outputs.write(f"failures={grand_fail}\n")
outputs.write(f"total={grand_total}\n")
return 0
if __name__ == "__main__":
sys.exit(main())
+225
View File
@@ -0,0 +1,225 @@
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="620" viewBox="0 0 900 620">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#0a0a0a"/>
<stop offset="100%" style="stop-color:#0d1117"/>
</linearGradient>
<linearGradient id="bar-grad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#161b22"/>
<stop offset="45%" style="stop-color:#1f2028"/>
<stop offset="100%" style="stop-color:#161b22"/>
</linearGradient>
<linearGradient id="client-grad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#3fb95022"/>
<stop offset="100%" style="stop-color:#3fb95008"/>
</linearGradient>
<linearGradient id="gw-grad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#DC262633"/>
<stop offset="100%" style="stop-color:#7F1D1D14"/>
</linearGradient>
<linearGradient id="nats-grad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#F87171"/>
<stop offset="50%" style="stop-color:#DC2626"/>
<stop offset="100%" style="stop-color:#7F1D1D"/>
</linearGradient>
</defs>
<rect width="900" height="620" fill="url(#bg)" rx="12"/>
<!-- Title bar -->
<text x="24" y="30" font-family="'Segoe UI', system-ui, sans-serif" font-size="11" fill="#a1a1aa" letter-spacing="2.5" font-weight="600">SPINIFEX ARCHITECTURE</text>
<text x="24" y="52" font-family="'Segoe UI', system-ui, sans-serif" font-size="16" fill="#f5f5f5" font-weight="500">AWS SDK → Gateway → NATS → Service daemons</text>
<text x="876" y="30" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#71717a" text-anchor="end">stateless · message-driven · horizontally scalable</text>
<!-- ═══ CLIENT LAYER ════════════════════════════════════ -->
<rect x="16" y="72" width="868" height="78" rx="8" fill="url(#client-grad)" stroke="#3fb950" stroke-width="1" stroke-opacity="0.5"/>
<text x="32" y="93" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#3fb950" font-weight="700" letter-spacing="1">CLIENTS</text>
<text x="32" y="108" font-family="'Segoe UI', sans-serif" font-size="10" fill="#a1a1aa">Standard AWS tooling — unmodified, no shims, no custom SDK</text>
<g transform="translate(32, 120)" font-family="'SF Mono', ui-monospace, monospace" font-size="10">
<rect x="0" y="0" width="86" height="22" rx="4" fill="#3fb950" fill-opacity="0.12" stroke="#3fb950" stroke-opacity="0.5" stroke-width="0.8"/>
<text x="43" y="15" fill="#7ee787" text-anchor="middle">aws-cli</text>
<rect x="94" y="0" width="96" height="22" rx="4" fill="#3fb950" fill-opacity="0.12" stroke="#3fb950" stroke-opacity="0.5" stroke-width="0.8"/>
<text x="142" y="15" fill="#7ee787" text-anchor="middle">Terraform</text>
<rect x="198" y="0" width="76" height="22" rx="4" fill="#3fb950" fill-opacity="0.12" stroke="#3fb950" stroke-opacity="0.5" stroke-width="0.8"/>
<text x="236" y="15" fill="#7ee787" text-anchor="middle">boto3</text>
<rect x="282" y="0" width="98" height="22" rx="4" fill="#3fb950" fill-opacity="0.12" stroke="#3fb950" stroke-opacity="0.5" stroke-width="0.8"/>
<text x="331" y="15" fill="#7ee787" text-anchor="middle">aws-sdk-go</text>
<rect x="388" y="0" width="86" height="22" rx="4" fill="#3fb950" fill-opacity="0.12" stroke="#3fb950" stroke-opacity="0.5" stroke-width="0.8"/>
<text x="431" y="15" fill="#7ee787" text-anchor="middle">kubectl</text>
<rect x="482" y="0" width="86" height="22" rx="4" fill="#3fb950" fill-opacity="0.12" stroke="#3fb950" stroke-opacity="0.5" stroke-width="0.8"/>
<text x="525" y="15" fill="#7ee787" text-anchor="middle">Packer</text>
<rect x="576" y="0" width="120" height="22" rx="4" fill="#3fb950" fill-opacity="0.12" stroke="#3fb950" stroke-opacity="0.5" stroke-width="0.8"/>
<text x="636" y="15" fill="#7ee787" text-anchor="middle">your application</text>
</g>
<!-- ═══ HTTPS + SigV4 connector ═══════════════════════════ -->
<line x1="450" y1="150" x2="450" y2="178" stroke="#DC2626" stroke-width="1.2" stroke-dasharray="3,3" opacity="0.7"/>
<rect x="390" y="154" width="120" height="18" rx="9" fill="#0a0a0a" stroke="#DC2626" stroke-width="0.8" stroke-opacity="0.5"/>
<text x="450" y="166" font-family="'SF Mono', ui-monospace, monospace" font-size="9" fill="#F87171" text-anchor="middle">HTTPS · SigV4 · :9999</text>
<!-- ═══ AWS GATEWAY ════════════════════════════════════ -->
<rect x="16" y="178" width="868" height="68" rx="8" fill="url(#gw-grad)" stroke="#DC2626" stroke-width="1.3" stroke-opacity="0.75"/>
<text x="32" y="200" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#F87171" font-weight="700" letter-spacing="1">AWS GATEWAY</text>
<text x="32" y="215" font-family="'Segoe UI', sans-serif" font-size="10" fill="#a1a1aa">spinifex/services/awsgw · TLS termination · SigV4 verification · IAM policy eval · NATS routing</text>
<g transform="translate(32, 224)" font-family="'SF Mono', ui-monospace, monospace" font-size="9">
<rect x="0" y="0" width="78" height="16" rx="3" fill="#7F1D1D" fill-opacity="0.3" stroke="#DC2626" stroke-opacity="0.5" stroke-width="0.6"/>
<text x="39" y="11" fill="#FCA5A5" text-anchor="middle">TLS 1.3</text>
<rect x="86" y="0" width="78" height="16" rx="3" fill="#7F1D1D" fill-opacity="0.3" stroke="#DC2626" stroke-opacity="0.5" stroke-width="0.6"/>
<text x="125" y="11" fill="#FCA5A5" text-anchor="middle">SigV4 auth</text>
<rect x="172" y="0" width="98" height="16" rx="3" fill="#7F1D1D" fill-opacity="0.3" stroke="#DC2626" stroke-opacity="0.5" stroke-width="0.6"/>
<text x="221" y="11" fill="#FCA5A5" text-anchor="middle">policy eval</text>
<rect x="278" y="0" width="98" height="16" rx="3" fill="#7F1D1D" fill-opacity="0.3" stroke="#DC2626" stroke-opacity="0.5" stroke-width="0.6"/>
<text x="327" y="11" fill="#FCA5A5" text-anchor="middle">req routing</text>
<rect x="384" y="0" width="88" height="16" rx="3" fill="#7F1D1D" fill-opacity="0.3" stroke="#DC2626" stroke-opacity="0.5" stroke-width="0.6"/>
<text x="428" y="11" fill="#FCA5A5" text-anchor="middle">rate-limit</text>
<rect x="480" y="0" width="98" height="16" rx="3" fill="#7F1D1D" fill-opacity="0.3" stroke="#DC2626" stroke-opacity="0.5" stroke-width="0.6"/>
<text x="529" y="11" fill="#FCA5A5" text-anchor="middle">audit log</text>
</g>
<!-- ═══ NATS BUS ═══════════════════════════════════════ -->
<line x1="450" y1="246" x2="450" y2="272" stroke="#DC2626" stroke-width="1.2" stroke-dasharray="3,3" opacity="0.7"/>
<rect x="16" y="272" width="868" height="34" rx="6" fill="url(#nats-grad)" opacity="0.92"/>
<text x="32" y="293" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#f5f5f5" font-weight="700" letter-spacing="1.5">NATS BUS</text>
<text x="140" y="293" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#fee2e2" opacity="0.95">ec2.* ebs.* s3.* vpc.* iam.*</text>
<text x="876" y="293" font-family="'SF Mono', ui-monospace, monospace" font-size="9" fill="#fee2e2" text-anchor="end" opacity="0.9">queue groups · fan-out · request/reply</text>
<!-- ═══ DAEMON FANOUT CONNECTORS ═══════════════════════ -->
<g stroke="#DC2626" stroke-width="0.9" stroke-dasharray="3,3" opacity="0.55" fill="none">
<line x1="100" y1="306" x2="100" y2="328"/>
<line x1="275" y1="306" x2="275" y2="328"/>
<line x1="450" y1="306" x2="450" y2="328"/>
<line x1="625" y1="306" x2="625" y2="328"/>
<line x1="800" y1="306" x2="800" y2="328"/>
</g>
<!-- ═══ SERVICE DAEMONS ROW ═══════════════════════════ -->
<!-- EC2 -->
<g transform="translate(28, 328)">
<rect width="144" height="82" rx="7" fill="#7F1D1D" fill-opacity="0.14" stroke="#DC2626" stroke-width="1.2" stroke-opacity="0.7"/>
<text x="72" y="22" font-family="'SF Mono', ui-monospace, monospace" font-size="13" fill="#F87171" text-anchor="middle" font-weight="700">ec2.* daemon</text>
<line x1="16" y1="30" x2="128" y2="30" stroke="#DC2626" stroke-opacity="0.25" stroke-width="0.5"/>
<text x="72" y="48" font-family="'Segoe UI', sans-serif" font-size="10" fill="#f5f5f5" text-anchor="middle">RunInstances</text>
<text x="72" y="62" font-family="'Segoe UI', sans-serif" font-size="10" fill="#f5f5f5" text-anchor="middle">TerminateInstances</text>
<text x="72" y="76" font-family="'Segoe UI', sans-serif" font-size="9" fill="#a1a1aa" text-anchor="middle">QMP · cloud-init</text>
</g>
<!-- EBS -->
<g transform="translate(204, 328)">
<rect width="144" height="82" rx="7" fill="#7F1D1D" fill-opacity="0.14" stroke="#DC2626" stroke-width="1.2" stroke-opacity="0.7"/>
<text x="72" y="22" font-family="'SF Mono', ui-monospace, monospace" font-size="13" fill="#F87171" text-anchor="middle" font-weight="700">ebs.* daemon</text>
<line x1="16" y1="30" x2="128" y2="30" stroke="#DC2626" stroke-opacity="0.25" stroke-width="0.5"/>
<text x="72" y="48" font-family="'Segoe UI', sans-serif" font-size="10" fill="#f5f5f5" text-anchor="middle">CreateVolume</text>
<text x="72" y="62" font-family="'Segoe UI', sans-serif" font-size="10" fill="#f5f5f5" text-anchor="middle">AttachVolume</text>
<text x="72" y="76" font-family="'Segoe UI', sans-serif" font-size="9" fill="#a1a1aa" text-anchor="middle">snapshots · WAL</text>
</g>
<!-- S3 -->
<g transform="translate(380, 328)">
<rect width="144" height="82" rx="7" fill="#7F1D1D" fill-opacity="0.14" stroke="#DC2626" stroke-width="1.2" stroke-opacity="0.7"/>
<text x="72" y="22" font-family="'SF Mono', ui-monospace, monospace" font-size="13" fill="#F87171" text-anchor="middle" font-weight="700">s3.* daemon</text>
<line x1="16" y1="30" x2="128" y2="30" stroke="#DC2626" stroke-opacity="0.25" stroke-width="0.5"/>
<text x="72" y="48" font-family="'Segoe UI', sans-serif" font-size="10" fill="#f5f5f5" text-anchor="middle">PutObject · GetObject</text>
<text x="72" y="62" font-family="'Segoe UI', sans-serif" font-size="10" fill="#f5f5f5" text-anchor="middle">Multipart · presigned</text>
<text x="72" y="76" font-family="'Segoe UI', sans-serif" font-size="9" fill="#a1a1aa" text-anchor="middle">erasure-coded</text>
</g>
<!-- VPC -->
<g transform="translate(556, 328)">
<rect width="144" height="82" rx="7" fill="#7F1D1D" fill-opacity="0.14" stroke="#DC2626" stroke-width="1.2" stroke-opacity="0.7"/>
<text x="72" y="22" font-family="'SF Mono', ui-monospace, monospace" font-size="13" fill="#F87171" text-anchor="middle" font-weight="700">vpc.* daemon</text>
<line x1="16" y1="30" x2="128" y2="30" stroke="#DC2626" stroke-opacity="0.25" stroke-width="0.5"/>
<text x="72" y="48" font-family="'Segoe UI', sans-serif" font-size="10" fill="#f5f5f5" text-anchor="middle">Subnets · SGs · RTs</text>
<text x="72" y="62" font-family="'Segoe UI', sans-serif" font-size="10" fill="#f5f5f5" text-anchor="middle">ALB · NAT · IGW</text>
<text x="72" y="76" font-family="'Segoe UI', sans-serif" font-size="9" fill="#a1a1aa" text-anchor="middle">OVN · OVS</text>
</g>
<!-- IAM -->
<g transform="translate(732, 328)">
<rect width="144" height="82" rx="7" fill="#7F1D1D" fill-opacity="0.14" stroke="#DC2626" stroke-width="1.2" stroke-opacity="0.7"/>
<text x="72" y="22" font-family="'SF Mono', ui-monospace, monospace" font-size="13" fill="#F87171" text-anchor="middle" font-weight="700">iam.* daemon</text>
<line x1="16" y1="30" x2="128" y2="30" stroke="#DC2626" stroke-opacity="0.25" stroke-width="0.5"/>
<text x="72" y="48" font-family="'Segoe UI', sans-serif" font-size="10" fill="#f5f5f5" text-anchor="middle">Users · Roles · Keys</text>
<text x="72" y="62" font-family="'Segoe UI', sans-serif" font-size="10" fill="#f5f5f5" text-anchor="middle">Policy evaluation</text>
<text x="72" y="76" font-family="'Segoe UI', sans-serif" font-size="9" fill="#a1a1aa" text-anchor="middle">audit trail</text>
</g>
<!-- ═══ DAEMON → BACKEND CONNECTORS ═══════════════════ -->
<g stroke="#71717a" stroke-width="0.8" stroke-dasharray="2,3" opacity="0.55" fill="none">
<line x1="100" y1="410" x2="100" y2="440"/>
<line x1="276" y1="410" x2="276" y2="440"/>
<line x1="452" y1="410" x2="452" y2="440"/>
<line x1="628" y1="410" x2="628" y2="440"/>
<line x1="804" y1="410" x2="804" y2="440"/>
</g>
<!-- ═══ BACKEND RESOURCES BAR ═════════════════════════ -->
<rect x="16" y="440" width="868" height="26" rx="5" fill="url(#bar-grad)"/>
<text x="450" y="458" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#a1a1aa" text-anchor="middle" letter-spacing="1.5">BACKEND RESOURCES · local hardware · no cloud dependency</text>
<!-- ═══ BACKEND ROW ═══════════════════════════════════ -->
<!-- QEMU/KVM -->
<g transform="translate(28, 474)">
<rect width="144" height="62" rx="7" fill="#27272a" fill-opacity="0.7" stroke="#71717a" stroke-width="1" stroke-opacity="0.6"/>
<text x="72" y="22" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#e4e4e7" text-anchor="middle" font-weight="600">QEMU / KVM</text>
<text x="72" y="40" font-family="'Segoe UI', sans-serif" font-size="10" fill="#a1a1aa" text-anchor="middle">hypervisor</text>
<text x="72" y="54" font-family="'Segoe UI', sans-serif" font-size="9" fill="#71717a" text-anchor="middle">host kernel · virtio</text>
</g>
<!-- Viperblock -->
<g transform="translate(204, 474)">
<rect width="144" height="62" rx="7" fill="#27272a" fill-opacity="0.7" stroke="#71717a" stroke-width="1" stroke-opacity="0.6"/>
<text x="72" y="22" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#e4e4e7" text-anchor="middle" font-weight="600">Viperblock</text>
<text x="72" y="40" font-family="'Segoe UI', sans-serif" font-size="10" fill="#a1a1aa" text-anchor="middle">block storage</text>
<text x="72" y="54" font-family="'Segoe UI', sans-serif" font-size="9" fill="#71717a" text-anchor="middle">NVMe WAL · NBD</text>
</g>
<!-- Predastore -->
<g transform="translate(380, 474)">
<rect width="144" height="62" rx="7" fill="#27272a" fill-opacity="0.7" stroke="#71717a" stroke-width="1" stroke-opacity="0.6"/>
<text x="72" y="22" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#e4e4e7" text-anchor="middle" font-weight="600">Predastore</text>
<text x="72" y="40" font-family="'Segoe UI', sans-serif" font-size="10" fill="#a1a1aa" text-anchor="middle">object storage</text>
<text x="72" y="54" font-family="'Segoe UI', sans-serif" font-size="9" fill="#71717a" text-anchor="middle">Reed–Solomon</text>
</g>
<!-- OVN/OVS -->
<g transform="translate(556, 474)">
<rect width="144" height="62" rx="7" fill="#27272a" fill-opacity="0.7" stroke="#71717a" stroke-width="1" stroke-opacity="0.6"/>
<text x="72" y="22" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#e4e4e7" text-anchor="middle" font-weight="600">OVN / OVS</text>
<text x="72" y="40" font-family="'Segoe UI', sans-serif" font-size="10" fill="#a1a1aa" text-anchor="middle">SDN fabric</text>
<text x="72" y="54" font-family="'Segoe UI', sans-serif" font-size="9" fill="#71717a" text-anchor="middle">overlays · flow rules</text>
</g>
<!-- IAM store -->
<g transform="translate(732, 474)">
<rect width="144" height="62" rx="7" fill="#27272a" fill-opacity="0.7" stroke="#71717a" stroke-width="1" stroke-opacity="0.6"/>
<text x="72" y="22" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#e4e4e7" text-anchor="middle" font-weight="600">Identity store</text>
<text x="72" y="40" font-family="'Segoe UI', sans-serif" font-size="10" fill="#a1a1aa" text-anchor="middle">users · keys · rules</text>
<text x="72" y="54" font-family="'Segoe UI', sans-serif" font-size="9" fill="#71717a" text-anchor="middle">local · audit-logged</text>
</g>
<!-- ═══ FOOTER NOTES ══════════════════════════════════ -->
<g transform="translate(16, 558)">
<rect width="868" height="48" rx="6" fill="#0a0a0a" stroke="#27272a" stroke-width="0.8"/>
<text x="24" y="20" font-family="'Segoe UI', sans-serif" font-size="10" fill="#a1a1aa">
<tspan fill="#F87171" font-weight="600">Queue groups</tspan> load-balance ops across daemon replicas.
<tspan fill="#F87171" font-weight="600"> Fan-out</tspan> (no queue group) dispatches describe calls to every node.
</text>
<text x="24" y="38" font-family="'Segoe UI', sans-serif" font-size="10" fill="#a1a1aa">
Daemons are <tspan fill="#F87171" font-weight="600">stateless</tspan> — scale horizontally by starting more.
No etcd, no Kubernetes, no external control plane. Systemd units, a NATS cluster, and your hardware.
</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 17 KiB

+154
View File
@@ -0,0 +1,154 @@
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="300" viewBox="0 0 900 300">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#0a0a0a;stop-opacity:1" />
<stop offset="50%" style="stop-color:#0d1117;stop-opacity:1" />
<stop offset="100%" style="stop-color:#0a0a0a;stop-opacity:1" />
</linearGradient>
<radialGradient id="vignette" cx="50%" cy="50%" r="70%">
<stop offset="0%" style="stop-color:#7F1D1D;stop-opacity:0.08"/>
<stop offset="100%" style="stop-color:#7F1D1D;stop-opacity:0"/>
</radialGradient>
<linearGradient id="mulga-grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#DC2626;stop-opacity:0.95" />
<stop offset="100%" style="stop-color:#7F1D1D;stop-opacity:0.95" />
</linearGradient>
<linearGradient id="rule" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#DC2626;stop-opacity:0" />
<stop offset="50%" style="stop-color:#F87171;stop-opacity:0.45" />
<stop offset="100%" style="stop-color:#7F1D1D;stop-opacity:0" />
</linearGradient>
<filter id="node-glow">
<feGaussianBlur in="SourceGraphic" stdDeviation="3" />
</filter>
</defs>
<!-- Background -->
<rect width="900" height="300" fill="url(#bg)" rx="12"/>
<rect width="900" height="300" fill="url(#vignette)" rx="12"/>
<!-- Mesh network background (mulgadc style) -->
<g opacity="0.35">
<!-- Connections (drawn first, under nodes) -->
<g stroke="#7F1D1D" stroke-width="0.6" fill="none">
<line x1="80" y1="60" x2="200" y2="40"/>
<line x1="80" y1="60" x2="150" y2="140"/>
<line x1="200" y1="40" x2="330" y2="90"/>
<line x1="150" y1="140" x2="260" y2="180"/>
<line x1="150" y1="140" x2="60" y2="220"/>
<line x1="330" y1="90" x2="260" y2="180"/>
<line x1="260" y1="180" x2="120" y2="260"/>
<line x1="60" y1="220" x2="120" y2="260"/>
<line x1="620" y1="50" x2="760" y2="90"/>
<line x1="620" y1="50" x2="700" y2="160"/>
<line x1="760" y1="90" x2="850" y2="160"/>
<line x1="700" y1="160" x2="820" y2="220"/>
<line x1="850" y1="160" x2="820" y2="220"/>
<line x1="700" y1="160" x2="600" y2="240"/>
<line x1="600" y1="240" x2="820" y2="220"/>
<line x1="760" y1="90" x2="700" y2="160"/>
</g>
<!-- Nodes -->
<g fill="#DC2626">
<circle cx="80" cy="60" r="2.5"/>
<circle cx="200" cy="40" r="2"/>
<circle cx="330" cy="90" r="2.5"/>
<circle cx="150" cy="140" r="3"/>
<circle cx="260" cy="180" r="2"/>
<circle cx="60" cy="220" r="2"/>
<circle cx="120" cy="260" r="2.5"/>
<circle cx="620" cy="50" r="2"/>
<circle cx="760" cy="90" r="3"/>
<circle cx="700" cy="160" r="2.5"/>
<circle cx="850" cy="160" r="2"/>
<circle cx="820" cy="220" r="2.5"/>
<circle cx="600" cy="240" r="2"/>
</g>
<!-- Pulse rings (static "about-to-pulse" look) -->
<g fill="none" stroke="#F87171" stroke-width="0.8" opacity="0.5">
<circle cx="150" cy="140" r="6"/>
<circle cx="760" cy="90" r="6"/>
<circle cx="700" cy="160" r="8"/>
</g>
</g>
<!-- Main emblem: cluster-node stack with NATS bus -->
<g transform="translate(200, 150)">
<!-- Outer glow ring -->
<circle cx="0" cy="0" r="64" fill="none" stroke="url(#mulga-grad)" stroke-width="1.5" opacity="0.35" filter="url(#node-glow)"/>
<!-- Top node -->
<rect x="-24" y="-52" width="48" height="22" rx="3" fill="none" stroke="#DC2626" stroke-width="1.8"/>
<circle cx="-16" cy="-41" r="1.6" fill="#F87171"/>
<circle cx="-8" cy="-41" r="1.6" fill="#F87171"/>
<line x1="0" y1="-41" x2="16" y2="-41" stroke="#F87171" stroke-width="0.9" opacity="0.6"/>
<!-- Middle node (active) -->
<rect x="-30" y="-14" width="60" height="28" rx="3" fill="url(#mulga-grad)" opacity="0.22" stroke="#DC2626" stroke-width="2"/>
<circle cx="-20" cy="0" r="2" fill="#F87171"/>
<circle cx="-10" cy="0" r="2" fill="#F87171"/>
<line x1="-2" y1="0" x2="22" y2="0" stroke="#F87171" stroke-width="1.2" opacity="0.8"/>
<!-- Bottom node -->
<rect x="-24" y="30" width="48" height="22" rx="3" fill="none" stroke="#7F1D1D" stroke-width="1.8"/>
<circle cx="-16" cy="41" r="1.6" fill="#DC2626"/>
<circle cx="-8" cy="41" r="1.6" fill="#DC2626"/>
<line x1="0" y1="41" x2="16" y2="41" stroke="#DC2626" stroke-width="0.9" opacity="0.6"/>
<!-- Radiating mesh ports -->
<g stroke-width="0.9" opacity="0.55">
<line x1="-30" y1="0" x2="-58" y2="-26" stroke="#DC2626"/>
<line x1="-30" y1="0" x2="-64" y2="0" stroke="#DC2626"/>
<line x1="-30" y1="0" x2="-58" y2="26" stroke="#DC2626"/>
<line x1="30" y1="0" x2="58" y2="-26" stroke="#7F1D1D"/>
<line x1="30" y1="0" x2="64" y2="0" stroke="#7F1D1D"/>
<line x1="30" y1="0" x2="58" y2="26" stroke="#7F1D1D"/>
</g>
<g fill="#F87171">
<circle cx="-58" cy="-26" r="2.5"/>
<circle cx="-64" cy="0" r="2.5"/>
<circle cx="-58" cy="26" r="2.5"/>
</g>
<g fill="#DC2626">
<circle cx="58" cy="-26" r="2.5"/>
<circle cx="64" cy="0" r="2.5"/>
<circle cx="58" cy="26" r="2.5"/>
</g>
</g>
<!-- Title -->
<text x="340" y="120" font-family="'Segoe UI', system-ui, -apple-system, sans-serif" font-size="56" font-weight="700" fill="#f5f5f5" letter-spacing="-1.5">Spinifex</text>
<!-- Subtitle -->
<text x="340" y="155" font-family="'Segoe UI', system-ui, -apple-system, sans-serif" font-size="17" fill="#a1a1aa" letter-spacing="0.3">Open-source AWS-compatible platform</text>
<text x="340" y="180" font-family="'Segoe UI', system-ui, -apple-system, sans-serif" font-size="17" fill="#a1a1aa" letter-spacing="0.3">for secure edge deployments</text>
<!-- Rule -->
<rect x="340" y="200" width="440" height="1" fill="url(#rule)" rx="1"/>
<!-- Service pills -->
<g font-family="'Segoe UI', system-ui, -apple-system, sans-serif" font-size="11" font-weight="500">
<rect x="340" y="215" width="50" height="24" rx="12" fill="#7F1D1D" fill-opacity="0.25" stroke="#DC2626" stroke-width="0.5" stroke-opacity="0.55"/>
<text x="365" y="231" fill="#F87171" text-anchor="middle">EC2</text>
<rect x="398" y="215" width="50" height="24" rx="12" fill="#7F1D1D" fill-opacity="0.25" stroke="#DC2626" stroke-width="0.5" stroke-opacity="0.55"/>
<text x="423" y="231" fill="#F87171" text-anchor="middle">EBS</text>
<rect x="456" y="215" width="45" height="24" rx="12" fill="#7F1D1D" fill-opacity="0.25" stroke="#DC2626" stroke-width="0.5" stroke-opacity="0.55"/>
<text x="478" y="231" fill="#F87171" text-anchor="middle">S3</text>
<rect x="509" y="215" width="55" height="24" rx="12" fill="#7F1D1D" fill-opacity="0.25" stroke="#DC2626" stroke-width="0.5" stroke-opacity="0.55"/>
<text x="536" y="231" fill="#F87171" text-anchor="middle">VPC</text>
<rect x="572" y="215" width="50" height="24" rx="12" fill="#7F1D1D" fill-opacity="0.25" stroke="#DC2626" stroke-width="0.5" stroke-opacity="0.55"/>
<text x="597" y="231" fill="#F87171" text-anchor="middle">IAM</text>
<rect x="630" y="215" width="78" height="24" rx="12" fill="#18181b" stroke="#52525b" stroke-width="0.5" stroke-opacity="0.8"/>
<text x="669" y="231" fill="#a1a1aa" text-anchor="middle">Air-Gapped</text>
</g>
<!-- Tagline -->
<text x="340" y="265" font-family="'Segoe UI', system-ui, -apple-system, sans-serif" font-size="11" fill="#71717a" letter-spacing="1.8">YOUR INFRASTRUCTURE · YOUR HARDWARE · YOUR CLOUD</text>
</svg>

After

Width:  |  Height:  |  Size: 7.5 KiB

+175
View File
@@ -0,0 +1,175 @@
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="720" viewBox="0 0 900 720">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#0a0a0a"/>
<stop offset="100%" style="stop-color:#0d1117"/>
</linearGradient>
<linearGradient id="bar-grad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#161b22"/>
<stop offset="45%" style="stop-color:#1f2028"/>
<stop offset="100%" style="stop-color:#161b22"/>
</linearGradient>
<linearGradient id="client-grad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#3fb95022"/>
<stop offset="100%" style="stop-color:#3fb95008"/>
</linearGradient>
<linearGradient id="gw-grad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#DC262633"/>
<stop offset="100%" style="stop-color:#7F1D1D14"/>
</linearGradient>
<linearGradient id="nats-grad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#F87171"/>
<stop offset="50%" style="stop-color:#DC2626"/>
<stop offset="100%" style="stop-color:#7F1D1D"/>
</linearGradient>
<linearGradient id="daemon-grad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#DC262622"/>
<stop offset="100%" style="stop-color:#7F1D1D0a"/>
</linearGradient>
</defs>
<rect width="900" height="720" fill="url(#bg)" rx="12"/>
<!-- Title -->
<text x="24" y="30" font-family="'Segoe UI', system-ui, sans-serif" font-size="11" fill="#a1a1aa" letter-spacing="2.5" font-weight="600">HIGH-LEVEL ARCHITECTURE</text>
<text x="876" y="30" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#71717a" text-anchor="end">SDK → Gateway → NATS → Daemons → VMs</text>
<!-- ═══ AWS SDK / CLI ══════════════════════════════════ -->
<rect x="16" y="50" width="868" height="76" rx="8" fill="url(#client-grad)" stroke="#3fb950" stroke-width="1" stroke-opacity="0.5"/>
<text x="32" y="72" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#3fb950" font-weight="700" letter-spacing="1">AWS SDK / CLI</text>
<text x="32" y="90" font-family="'Segoe UI', sans-serif" font-size="10" fill="#a1a1aa">Users interact with Spinifex using standard AWS SDKs and CLIs against a custom endpoint URL</text>
<g font-family="'SF Mono', ui-monospace, monospace" font-size="10.5">
<rect x="32" y="98" width="480" height="20" rx="4" fill="#0a0a0a" stroke="#3fb950" stroke-width="0.6" stroke-opacity="0.4"/>
<text x="42" y="112" fill="#7ee787">$ aws ec2 run-instances --endpoint-url https://localhost:9999 ...</text>
</g>
<!-- Connector: HTTPS + SigV4 -->
<line x1="450" y1="126" x2="450" y2="154" stroke="#DC2626" stroke-width="1.2" stroke-dasharray="3,3" opacity="0.7"/>
<rect x="380" y="128" width="140" height="20" rx="10" fill="#0a0a0a" stroke="#DC2626" stroke-width="0.8" stroke-opacity="0.5"/>
<text x="450" y="142" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#F87171" text-anchor="middle">HTTPS · SigV4 Auth</text>
<!-- ═══ AWS GATEWAY ════════════════════════════════════ -->
<rect x="16" y="154" width="868" height="104" rx="8" fill="url(#gw-grad)" stroke="#DC2626" stroke-width="1.3" stroke-opacity="0.75"/>
<text x="32" y="176" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#F87171" font-weight="700" letter-spacing="1">AWS GATEWAY · port 9999</text>
<text x="876" y="176" font-family="'SF Mono', ui-monospace, monospace" font-size="9" fill="#71717a" text-anchor="end">spinifex/services/awsgw/awsgw.go</text>
<!-- Two-column feature bullets -->
<g font-family="'Segoe UI', sans-serif" font-size="10.5" fill="#f5f5f5">
<text x="40" y="200">● TLS termination</text>
<text x="40" y="220">● EC2 Query Protocol parsing</text>
<text x="40" y="240">● XML response generation</text>
<text x="460" y="200">● AWS SigV4 authentication</text>
<text x="460" y="220">● Routes to service handlers</text>
<text x="460" y="240">● NATS client connection</text>
</g>
<!-- Connector: JSON over NATS -->
<line x1="450" y1="258" x2="450" y2="286" stroke="#DC2626" stroke-width="1.2" stroke-dasharray="3,3" opacity="0.7"/>
<rect x="380" y="260" width="140" height="20" rx="10" fill="#0a0a0a" stroke="#DC2626" stroke-width="0.8" stroke-opacity="0.5"/>
<text x="450" y="274" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#F87171" text-anchor="middle">JSON over NATS</text>
<!-- ═══ NATS BROKER ═══════════════════════════════════ -->
<rect x="16" y="286" width="868" height="124" rx="8" fill="#1a0a0a" stroke="#DC2626" stroke-width="1.2" stroke-opacity="0.65"/>
<rect x="16" y="286" width="868" height="28" rx="8" fill="url(#nats-grad)" opacity="0.9"/>
<text x="32" y="305" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#f5f5f5" font-weight="700" letter-spacing="1.5">NATS MESSAGE BROKER</text>
<text x="876" y="305" font-family="'SF Mono', ui-monospace, monospace" font-size="9" fill="#fee2e2" text-anchor="end" opacity="0.9">request/reply · queue groups · fan-out</text>
<!-- Two-column topics/queue groups -->
<g font-family="'SF Mono', ui-monospace, monospace" font-size="10">
<text x="40" y="332" fill="#F87171" font-weight="600">Topics</text>
<text x="40" y="350" fill="#f5f5f5">ec2.RunInstances</text>
<text x="40" y="368" fill="#f5f5f5">ec2.DescribeInstances</text>
<text x="40" y="386" fill="#f5f5f5">ec2.cmd.{instanceID}</text>
<text x="40" y="404" fill="#f5f5f5">ebs.mount · ebs.unmount</text>
<text x="460" y="332" fill="#F87171" font-weight="600">Delivery mode</text>
<text x="460" y="350" fill="#a1a1aa">spinifex-workers <tspan fill="#71717a">(load-balanced)</tspan></text>
<text x="460" y="368" fill="#a1a1aa">no queue group <tspan fill="#71717a">(fan-out to all nodes)</tspan></text>
<text x="460" y="386" fill="#a1a1aa">per-instance <tspan fill="#71717a">(start / stop / terminate)</tspan></text>
<text x="460" y="404" fill="#a1a1aa">Viperblock request/reply</text>
</g>
<!-- Fan-out connectors to 3 daemons -->
<g stroke="#DC2626" stroke-width="1" stroke-dasharray="3,3" opacity="0.6" fill="none">
<line x1="450" y1="410" x2="450" y2="430"/>
<line x1="450" y1="430" x2="155" y2="452"/>
<line x1="450" y1="430" x2="450" y2="452"/>
<line x1="450" y1="430" x2="745" y2="452"/>
</g>
<!-- ═══ DAEMON ROW ════════════════════════════════════ -->
<!-- Daemon 1 -->
<g transform="translate(32, 452)">
<rect width="246" height="124" rx="8" fill="url(#daemon-grad)" stroke="#DC2626" stroke-width="1.2" stroke-opacity="0.7"/>
<text x="20" y="24" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#F87171" font-weight="700">Spinifex Daemon</text>
<text x="226" y="24" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#a1a1aa" text-anchor="end">Node 1</text>
<line x1="16" y1="32" x2="230" y2="32" stroke="#DC2626" stroke-opacity="0.25" stroke-width="0.5"/>
<g font-family="'Segoe UI', sans-serif" font-size="10.5" fill="#f5f5f5">
<text x="20" y="52">● NATS subscriber</text>
<text x="20" y="70">● Resource manager</text>
<text x="20" y="88">● QEMU/KVM launcher</text>
<text x="20" y="106">● QMP monitoring</text>
</g>
<text x="20" y="119" font-family="'SF Mono', ui-monospace, monospace" font-size="9" fill="#71717a">daemon.go</text>
</g>
<!-- Daemon 2 -->
<g transform="translate(327, 452)">
<rect width="246" height="124" rx="8" fill="url(#daemon-grad)" stroke="#DC2626" stroke-width="1.2" stroke-opacity="0.7"/>
<text x="20" y="24" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#F87171" font-weight="700">Spinifex Daemon</text>
<text x="226" y="24" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#a1a1aa" text-anchor="end">Node 2</text>
<line x1="16" y1="32" x2="230" y2="32" stroke="#DC2626" stroke-opacity="0.25" stroke-width="0.5"/>
<g font-family="'Segoe UI', sans-serif" font-size="10.5" fill="#f5f5f5">
<text x="20" y="52">● NATS subscriber</text>
<text x="20" y="70">● Resource manager</text>
<text x="20" y="88">● QEMU/KVM launcher</text>
<text x="20" y="106">● QMP monitoring</text>
</g>
<text x="20" y="119" font-family="'SF Mono', ui-monospace, monospace" font-size="9" fill="#71717a">daemon.go</text>
</g>
<!-- Daemon N -->
<g transform="translate(622, 452)">
<rect width="246" height="124" rx="8" fill="url(#daemon-grad)" stroke="#DC2626" stroke-width="1.2" stroke-opacity="0.7"/>
<text x="20" y="24" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#F87171" font-weight="700">Spinifex Daemon</text>
<text x="226" y="24" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#a1a1aa" text-anchor="end">Node N</text>
<line x1="16" y1="32" x2="230" y2="32" stroke="#DC2626" stroke-opacity="0.25" stroke-width="0.5"/>
<g font-family="'Segoe UI', sans-serif" font-size="10.5" fill="#f5f5f5">
<text x="20" y="52">● NATS subscriber</text>
<text x="20" y="70">● Resource manager</text>
<text x="20" y="88">● QEMU/KVM launcher</text>
<text x="20" y="106">● QMP monitoring</text>
</g>
<text x="20" y="119" font-family="'SF Mono', ui-monospace, monospace" font-size="9" fill="#71717a">daemon.go</text>
</g>
<!-- Daemon → VM connectors -->
<g stroke="#71717a" stroke-width="0.8" stroke-dasharray="2,3" opacity="0.55" fill="none">
<line x1="155" y1="576" x2="155" y2="610"/>
<line x1="450" y1="576" x2="450" y2="610"/>
<line x1="745" y1="576" x2="745" y2="610"/>
</g>
<!-- ═══ VM BAR + ROW ═══════════════════════════════════ -->
<rect x="16" y="610" width="868" height="22" rx="5" fill="url(#bar-grad)"/>
<text x="450" y="625" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#a1a1aa" text-anchor="middle" letter-spacing="1.5">WORKLOADS · QEMU/KVM virtual machines per node</text>
<!-- VM boxes -->
<g font-family="'SF Mono', ui-monospace, monospace" font-size="11">
<rect x="32" y="642" width="246" height="60" rx="7" fill="#27272a" fill-opacity="0.7" stroke="#71717a" stroke-width="1" stroke-opacity="0.6"/>
<text x="155" y="665" fill="#e4e4e7" text-anchor="middle" font-weight="600">QEMU / KVM VMs</text>
<text x="155" y="683" fill="#a1a1aa" text-anchor="middle" font-size="9.5">virtio-blk · virtio-net · cloud-init</text>
<text x="155" y="695" fill="#71717a" text-anchor="middle" font-size="9">Node 1 workloads</text>
<rect x="327" y="642" width="246" height="60" rx="7" fill="#27272a" fill-opacity="0.7" stroke="#71717a" stroke-width="1" stroke-opacity="0.6"/>
<text x="450" y="665" fill="#e4e4e7" text-anchor="middle" font-weight="600">QEMU / KVM VMs</text>
<text x="450" y="683" fill="#a1a1aa" text-anchor="middle" font-size="9.5">virtio-blk · virtio-net · cloud-init</text>
<text x="450" y="695" fill="#71717a" text-anchor="middle" font-size="9">Node 2 workloads</text>
<rect x="622" y="642" width="246" height="60" rx="7" fill="#27272a" fill-opacity="0.7" stroke="#71717a" stroke-width="1" stroke-opacity="0.6"/>
<text x="745" y="665" fill="#e4e4e7" text-anchor="middle" font-weight="600">QEMU / KVM VMs</text>
<text x="745" y="683" fill="#a1a1aa" text-anchor="middle" font-size="9.5">virtio-blk · virtio-net · cloud-init</text>
<text x="745" y="695" fill="#71717a" text-anchor="middle" font-size="9">Node N workloads</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 12 KiB

+46
View File
@@ -0,0 +1,46 @@
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="180" viewBox="0 0 900 180">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#0a0a0a"/>
<stop offset="100%" style="stop-color:#0d1117"/>
</linearGradient>
</defs>
<rect width="900" height="180" fill="url(#bg)" rx="10"/>
<text x="24" y="28" font-family="'Segoe UI', system-ui, sans-serif" font-size="11" fill="#a1a1aa" letter-spacing="2.5" font-weight="600">BASTION · only entry point into a private subnet</text>
<!-- WAN -->
<g transform="translate(40, 56)">
<rect width="200" height="60" rx="8" fill="#3fb9501a" stroke="#3fb950" stroke-width="1.2" stroke-opacity="0.55"/>
<text x="100" y="28" font-family="'SF Mono', ui-monospace, monospace" font-size="13" fill="#3fb950" text-anchor="middle" font-weight="700">WAN</text>
<text x="100" y="46" font-family="'Segoe UI', sans-serif" font-size="10" fill="#a1a1aa" text-anchor="middle">operator / laptop</text>
</g>
<!-- Arrow: SSH -->
<line x1="240" y1="86" x2="320" y2="86" stroke="#58a6ff" stroke-width="1.4" stroke-dasharray="4,3" opacity="0.75"/>
<polygon points="316,82 328,86 316,90" fill="#58a6ff" opacity="0.8"/>
<rect x="244" y="62" width="80" height="18" rx="9" fill="#0a0a0a" stroke="#58a6ff" stroke-width="0.6" stroke-opacity="0.55"/>
<text x="284" y="75" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#8ab4f8" text-anchor="middle">SSH</text>
<!-- Bastion -->
<g transform="translate(338, 56)">
<rect width="220" height="60" rx="8" fill="#3fb9501a" stroke="#3fb950" stroke-width="1.3" stroke-opacity="0.6"/>
<text x="110" y="28" font-family="'SF Mono', ui-monospace, monospace" font-size="13" fill="#3fb950" text-anchor="middle" font-weight="700">Bastion</text>
<text x="110" y="46" font-family="'Segoe UI', sans-serif" font-size="10" fill="#7ee787" text-anchor="middle">public subnet</text>
</g>
<!-- Arrow: SSH (private IP) -->
<line x1="558" y1="86" x2="638" y2="86" stroke="#a855f7" stroke-width="1.4" stroke-dasharray="4,3" opacity="0.75"/>
<polygon points="634,82 646,86 634,90" fill="#a855f7" opacity="0.8"/>
<rect x="552" y="62" width="140" height="18" rx="9" fill="#0a0a0a" stroke="#a855f7" stroke-width="0.6" stroke-opacity="0.55"/>
<text x="622" y="75" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#c084fc" text-anchor="middle">SSH (private IP)</text>
<!-- App server -->
<g transform="translate(656, 56)">
<rect width="220" height="60" rx="8" fill="#a855f71a" stroke="#a855f7" stroke-width="1.3" stroke-opacity="0.6"/>
<text x="110" y="28" font-family="'SF Mono', ui-monospace, monospace" font-size="13" fill="#c084fc" text-anchor="middle" font-weight="700">App Server</text>
<text x="110" y="46" font-family="'Segoe UI', sans-serif" font-size="10" fill="#d8b4fe" text-anchor="middle">private subnet · no internet</text>
</g>
<text x="450" y="156" font-family="'Segoe UI', sans-serif" font-size="10" fill="#71717a" text-anchor="middle" font-style="italic">App server has no route to the internet — all access hops through the bastion.</text>
</svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

+98
View File
@@ -0,0 +1,98 @@
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="480" viewBox="0 0 900 480">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#0a0a0a"/>
<stop offset="100%" style="stop-color:#0d1117"/>
</linearGradient>
<linearGradient id="inet-grad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#3fb95022"/>
<stop offset="100%" style="stop-color:#3fb95008"/>
</linearGradient>
<linearGradient id="pub-grad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#3fb9501f"/>
<stop offset="100%" style="stop-color:#3fb95008"/>
</linearGradient>
<linearGradient id="priv-grad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#a855f71f"/>
<stop offset="100%" style="stop-color:#a855f708"/>
</linearGradient>
</defs>
<rect width="900" height="480" fill="url(#bg)" rx="12"/>
<text x="24" y="30" font-family="'Segoe UI', system-ui, sans-serif" font-size="11" fill="#a1a1aa" letter-spacing="2.5" font-weight="600">NGINX + ALB · VPC WITH PUBLIC AND PRIVATE SUBNETS</text>
<text x="876" y="30" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#71717a" text-anchor="end">10.20.0.0/16 · multi-AZ</text>
<!-- Internet -->
<rect x="350" y="54" width="200" height="40" rx="8" fill="url(#inet-grad)" stroke="#3fb950" stroke-width="1.1" stroke-opacity="0.55"/>
<text x="450" y="78" font-family="'SF Mono', ui-monospace, monospace" font-size="13" fill="#3fb950" text-anchor="middle" font-weight="700">Internet</text>
<!-- connector to IGW -->
<line x1="450" y1="94" x2="450" y2="116" stroke="#71717a" stroke-width="1" opacity="0.5"/>
<polygon points="446,112 454,112 450,120" fill="#71717a" opacity="0.7"/>
<!-- IGW -->
<rect x="380" y="122" width="140" height="44" rx="7" fill="#DC26261a" stroke="#DC2626" stroke-width="1.2" stroke-opacity="0.65"/>
<text x="450" y="142" font-family="'SF Mono', ui-monospace, monospace" font-size="13" fill="#F87171" text-anchor="middle" font-weight="700">IGW</text>
<text x="450" y="158" font-family="'Segoe UI', sans-serif" font-size="10" fill="#FCA5A5" text-anchor="middle">nginx-alb-igw</text>
<!-- Fan-out to two public subnets -->
<line x1="450" y1="166" x2="450" y2="184" stroke="#DC2626" stroke-width="1" stroke-dasharray="3,3" opacity="0.6"/>
<line x1="230" y1="184" x2="670" y2="184" stroke="#DC2626" stroke-width="1" stroke-dasharray="3,3" opacity="0.6"/>
<line x1="230" y1="184" x2="230" y2="202" stroke="#DC2626" stroke-width="1" stroke-dasharray="3,3" opacity="0.6"/>
<line x1="670" y1="184" x2="670" y2="202" stroke="#DC2626" stroke-width="1" stroke-dasharray="3,3" opacity="0.6"/>
<!-- public_a -->
<g transform="translate(108, 202)">
<rect width="244" height="104" rx="8" fill="url(#pub-grad)" stroke="#3fb950" stroke-width="1.2" stroke-opacity="0.55"/>
<text x="20" y="24" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#3fb950" font-weight="700">public_a</text>
<text x="224" y="24" font-family="'Segoe UI', sans-serif" font-size="9.5" fill="#7ee787" text-anchor="end">az-a</text>
<line x1="16" y1="32" x2="228" y2="32" stroke="#3fb950" stroke-opacity="0.3" stroke-width="0.5"/>
<rect x="20" y="42" width="200" height="22" rx="4" fill="#3fb9501a" stroke="#3fb950" stroke-opacity="0.5" stroke-width="0.6"/>
<text x="120" y="57" font-family="'SF Mono', ui-monospace, monospace" font-size="10.5" fill="#7ee787" text-anchor="middle" font-weight="600">ALB ENI</text>
<rect x="20" y="70" width="200" height="22" rx="4" fill="#3fb9501a" stroke="#3fb950" stroke-opacity="0.5" stroke-width="0.6"/>
<text x="120" y="85" font-family="'SF Mono', ui-monospace, monospace" font-size="10.5" fill="#7ee787" text-anchor="middle" font-weight="600">NAT GW</text>
</g>
<!-- public_b -->
<g transform="translate(548, 202)">
<rect width="244" height="104" rx="8" fill="url(#pub-grad)" stroke="#3fb950" stroke-width="1.2" stroke-opacity="0.55"/>
<text x="20" y="24" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#3fb950" font-weight="700">public_b</text>
<text x="224" y="24" font-family="'Segoe UI', sans-serif" font-size="9.5" fill="#7ee787" text-anchor="end">az-b</text>
<line x1="16" y1="32" x2="228" y2="32" stroke="#3fb950" stroke-opacity="0.3" stroke-width="0.5"/>
<rect x="20" y="56" width="200" height="22" rx="4" fill="#3fb9501a" stroke="#3fb950" stroke-opacity="0.5" stroke-width="0.6"/>
<text x="120" y="71" font-family="'SF Mono', ui-monospace, monospace" font-size="10.5" fill="#7ee787" text-anchor="middle" font-weight="600">ALB ENI</text>
</g>
<!-- SNAT arrow from public_a NAT → private row -->
<line x1="230" y1="306" x2="230" y2="340" stroke="#F87171" stroke-width="1.2" stroke-dasharray="3,3" opacity="0.65"/>
<polygon points="226,336 234,336 230,344" fill="#F87171" opacity="0.8"/>
<rect x="256" y="314" width="240" height="20" rx="10" fill="#0a0a0a" stroke="#F87171" stroke-width="0.6" stroke-opacity="0.55"/>
<text x="376" y="328" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#FCA5A5" text-anchor="middle">SNAT for private subnets (egress)</text>
<!-- private_a -->
<g transform="translate(108, 350)">
<rect width="244" height="104" rx="8" fill="url(#priv-grad)" stroke="#a855f7" stroke-width="1.2" stroke-opacity="0.55"/>
<text x="20" y="24" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#c084fc" font-weight="700">private_a</text>
<text x="224" y="24" font-family="'Segoe UI', sans-serif" font-size="9.5" fill="#d8b4fe" text-anchor="end">az-a</text>
<line x1="16" y1="32" x2="228" y2="32" stroke="#a855f7" stroke-opacity="0.3" stroke-width="0.5"/>
<rect x="20" y="44" width="200" height="48" rx="5" fill="#a855f71a" stroke="#a855f7" stroke-opacity="0.5" stroke-width="0.6"/>
<text x="120" y="64" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#d8b4fe" text-anchor="middle" font-weight="700">nginx_1</text>
<text x="120" y="80" font-family="'Segoe UI', sans-serif" font-size="9.5" fill="#a1a1aa" text-anchor="middle">Debian 12 · cloud-init</text>
</g>
<!-- private_b -->
<g transform="translate(548, 350)">
<rect width="244" height="104" rx="8" fill="url(#priv-grad)" stroke="#a855f7" stroke-width="1.2" stroke-opacity="0.55"/>
<text x="20" y="24" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#c084fc" font-weight="700">private_b</text>
<text x="224" y="24" font-family="'Segoe UI', sans-serif" font-size="9.5" fill="#d8b4fe" text-anchor="end">az-b</text>
<line x1="16" y1="32" x2="228" y2="32" stroke="#a855f7" stroke-opacity="0.3" stroke-width="0.5"/>
<rect x="20" y="44" width="200" height="48" rx="5" fill="#a855f71a" stroke="#a855f7" stroke-opacity="0.5" stroke-width="0.6"/>
<text x="120" y="64" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#d8b4fe" text-anchor="middle" font-weight="700">nginx_2</text>
<text x="120" y="80" font-family="'Segoe UI', sans-serif" font-size="9.5" fill="#a1a1aa" text-anchor="middle">Debian 12 · cloud-init</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 7.1 KiB

+44
View File
@@ -0,0 +1,44 @@
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="180" viewBox="0 0 900 180">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#0a0a0a"/>
<stop offset="100%" style="stop-color:#0d1117"/>
</linearGradient>
</defs>
<rect width="900" height="180" fill="url(#bg)" rx="10"/>
<text x="24" y="28" font-family="'Segoe UI', system-ui, sans-serif" font-size="11" fill="#a1a1aa" letter-spacing="2.5" font-weight="600">S3 WEBAPP · Flask on EC2 with Predastore backend</text>
<!-- Browser -->
<g transform="translate(40, 56)">
<rect width="200" height="60" rx="8" fill="#3fb9501a" stroke="#3fb950" stroke-width="1.2" stroke-opacity="0.55"/>
<text x="100" y="28" font-family="'SF Mono', ui-monospace, monospace" font-size="13" fill="#3fb950" text-anchor="middle" font-weight="700">Browser</text>
<text x="100" y="46" font-family="'Segoe UI', sans-serif" font-size="10" fill="#a1a1aa" text-anchor="middle">end user</text>
</g>
<!-- Arrow: HTTP -->
<line x1="240" y1="86" x2="320" y2="86" stroke="#58a6ff" stroke-width="1.4" stroke-dasharray="4,3" opacity="0.75"/>
<polygon points="316,82 328,86 316,90" fill="#58a6ff" opacity="0.8"/>
<rect x="244" y="62" width="80" height="18" rx="9" fill="#0a0a0a" stroke="#58a6ff" stroke-width="0.6" stroke-opacity="0.55"/>
<text x="284" y="75" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#8ab4f8" text-anchor="middle">HTTP</text>
<!-- EC2 Instance -->
<g transform="translate(338, 56)">
<rect width="240" height="60" rx="8" fill="#DC26261a" stroke="#DC2626" stroke-width="1.3" stroke-opacity="0.65"/>
<text x="120" y="28" font-family="'SF Mono', ui-monospace, monospace" font-size="13" fill="#F87171" text-anchor="middle" font-weight="700">EC2 Instance</text>
<text x="120" y="46" font-family="'Segoe UI', sans-serif" font-size="10" fill="#FCA5A5" text-anchor="middle">Flask webapp · port 80</text>
</g>
<!-- Arrow: S3 API (boto3) -->
<line x1="578" y1="86" x2="658" y2="86" stroke="#a855f7" stroke-width="1.4" stroke-dasharray="4,3" opacity="0.75"/>
<polygon points="654,82 666,86 654,90" fill="#a855f7" opacity="0.8"/>
<rect x="570" y="62" width="144" height="18" rx="9" fill="#0a0a0a" stroke="#a855f7" stroke-width="0.6" stroke-opacity="0.55"/>
<text x="642" y="75" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#c084fc" text-anchor="middle">S3 API (boto3)</text>
<!-- Predastore -->
<g transform="translate(676, 56)">
<rect width="200" height="60" rx="8" fill="#a855f71a" stroke="#a855f7" stroke-width="1.3" stroke-opacity="0.6"/>
<text x="100" y="28" font-family="'SF Mono', ui-monospace, monospace" font-size="13" fill="#c084fc" text-anchor="middle" font-weight="700">Predastore</text>
<text x="100" y="46" font-family="'Segoe UI', sans-serif" font-size="10" fill="#d8b4fe" text-anchor="middle">S3 store · port 8443</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

@@ -0,0 +1,78 @@
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="330" viewBox="0 0 900 330">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#0a0a0a"/>
<stop offset="100%" style="stop-color:#0d1117"/>
</linearGradient>
<linearGradient id="cluster-grad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#3fb95022"/>
<stop offset="100%" style="stop-color:#3fb95008"/>
</linearGradient>
<linearGradient id="pool-grad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#DC262622"/>
<stop offset="100%" style="stop-color:#7F1D1D08"/>
</linearGradient>
<linearGradient id="node-grad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#58a6ff22"/>
<stop offset="100%" style="stop-color:#58a6ff08"/>
</linearGradient>
</defs>
<rect width="900" height="330" fill="url(#bg)" rx="12"/>
<text x="24" y="30" font-family="'Segoe UI', system-ui, sans-serif" font-size="11" fill="#a1a1aa" letter-spacing="2.5" font-weight="600">CONFIGURATION LEVELS · spinifex.toml</text>
<text x="876" y="30" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#71717a" text-anchor="end">three scopes: cluster · pool · node</text>
<!-- File badge -->
<rect x="24" y="50" width="140" height="24" rx="6" fill="#161b22" stroke="#52525b" stroke-width="0.6"/>
<text x="94" y="67" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#e4e4e7" text-anchor="middle">spinifex.toml</text>
<!-- spine -->
<line x1="52" y1="74" x2="52" y2="298" stroke="#52525b" stroke-width="1" opacity="0.6"/>
<!-- ═══ Level 1: [network] cluster-wide ═══════════════════ -->
<line x1="52" y1="108" x2="92" y2="108" stroke="#52525b" stroke-width="1" opacity="0.6"/>
<rect x="92" y="86" width="764" height="56" rx="8" fill="url(#cluster-grad)" stroke="#3fb950" stroke-width="1.2" stroke-opacity="0.55"/>
<text x="108" y="108" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#3fb950" font-weight="700">[network]</text>
<text x="220" y="108" font-family="'Segoe UI', sans-serif" font-size="11" fill="#f5f5f5">Cluster-wide · which mode?</text>
<g font-family="'SF Mono', ui-monospace, monospace" font-size="10">
<text x="108" y="128" fill="#a1a1aa">└─</text>
<text x="128" y="128" fill="#7ee787">external_mode</text>
<text x="224" y="128" fill="#a1a1aa">=</text>
<text x="238" y="128" fill="#f5f5f5">"pool"</text>
</g>
<!-- ═══ Level 2: [[network.external_pools]] ═══════════════ -->
<line x1="52" y1="176" x2="92" y2="176" stroke="#52525b" stroke-width="1" opacity="0.6"/>
<rect x="92" y="154" width="764" height="78" rx="8" fill="url(#pool-grad)" stroke="#DC2626" stroke-width="1.2" stroke-opacity="0.65"/>
<text x="108" y="176" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#F87171" font-weight="700">[[network.external_pools]]</text>
<text x="328" y="176" font-family="'Segoe UI', sans-serif" font-size="11" fill="#f5f5f5">IP ranges · what IPs to use?</text>
<g font-family="'SF Mono', ui-monospace, monospace" font-size="10">
<text x="108" y="196" fill="#a1a1aa">├─</text>
<text x="128" y="196" fill="#FCA5A5">name</text>
<text x="158" y="196" fill="#71717a">·</text>
<text x="166" y="196" fill="#FCA5A5">range_start</text>
<text x="246" y="196" fill="#71717a">·</text>
<text x="254" y="196" fill="#FCA5A5">range_end</text>
<text x="108" y="212" fill="#a1a1aa">├─</text>
<text x="128" y="212" fill="#FCA5A5">gateway</text>
<text x="182" y="212" fill="#71717a">·</text>
<text x="190" y="212" fill="#FCA5A5">prefix_len</text>
<text x="108" y="228" fill="#a1a1aa">└─</text>
<text x="128" y="228" fill="#FCA5A5">region</text>
<text x="174" y="228" fill="#71717a">·</text>
<text x="182" y="228" fill="#FCA5A5">az</text>
<text x="202" y="228" fill="#71717a">(optional)</text>
</g>
<!-- ═══ Level 3: [nodes.NAME.vpcd] ════════════════════════ -->
<line x1="52" y1="268" x2="92" y2="268" stroke="#52525b" stroke-width="1" opacity="0.6"/>
<rect x="92" y="246" width="764" height="56" rx="8" fill="url(#node-grad)" stroke="#58a6ff" stroke-width="1.2" stroke-opacity="0.6"/>
<text x="108" y="268" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#58a6ff" font-weight="700">[nodes.NAME.vpcd]</text>
<text x="266" y="268" font-family="'Segoe UI', sans-serif" font-size="11" fill="#f5f5f5">Per-node · which NIC?</text>
<g font-family="'SF Mono', ui-monospace, monospace" font-size="10">
<text x="108" y="288" fill="#a1a1aa">└─</text>
<text x="128" y="288" fill="#8ab4f8">external_interface</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.8 KiB

@@ -0,0 +1,63 @@
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="110" viewBox="0 0 900 110">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#0a0a0a"/>
<stop offset="100%" style="stop-color:#0d1117"/>
</linearGradient>
</defs>
<rect width="900" height="110" fill="url(#bg)" rx="10"/>
<text x="24" y="22" font-family="'Segoe UI', system-ui, sans-serif" font-size="10" fill="#a1a1aa" letter-spacing="2" font-weight="600">DATA PATH · VM to WAN via OVN localnet</text>
<!-- VM TAP -->
<g transform="translate(24, 40)">
<rect width="118" height="48" rx="7" fill="#a855f71a" stroke="#a855f7" stroke-width="1.1" stroke-opacity="0.6"/>
<text x="59" y="22" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#c084fc" text-anchor="middle" font-weight="700">VM TAP</text>
<text x="59" y="38" font-family="'Segoe UI', sans-serif" font-size="9.5" fill="#a1a1aa" text-anchor="middle">guest interface</text>
</g>
<polygon points="148,60 160,64 148,68" fill="#71717a" opacity="0.7"/>
<line x1="142" y1="64" x2="148" y2="64" stroke="#71717a" stroke-width="1" opacity="0.5"/>
<!-- br-int -->
<g transform="translate(166, 40)">
<rect width="102" height="48" rx="7" fill="#58a6ff1a" stroke="#58a6ff" stroke-width="1.1" stroke-opacity="0.6"/>
<text x="51" y="22" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#58a6ff" text-anchor="middle" font-weight="700">br-int</text>
<text x="51" y="38" font-family="'Segoe UI', sans-serif" font-size="9.5" fill="#a1a1aa" text-anchor="middle">OVS bridge</text>
</g>
<polygon points="274,60 286,64 274,68" fill="#71717a" opacity="0.7"/>
<line x1="268" y1="64" x2="274" y2="64" stroke="#71717a" stroke-width="1" opacity="0.5"/>
<!-- OVN logical pipeline -->
<g transform="translate(292, 40)">
<rect width="192" height="48" rx="7" fill="#DC26261a" stroke="#DC2626" stroke-width="1.1" stroke-opacity="0.65"/>
<text x="96" y="22" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#F87171" text-anchor="middle" font-weight="700">OVN logical pipeline</text>
<text x="96" y="38" font-family="'Segoe UI', sans-serif" font-size="9.5" fill="#FCA5A5" text-anchor="middle">switch → router → SNAT/DNAT</text>
</g>
<polygon points="490,60 502,64 490,68" fill="#71717a" opacity="0.7"/>
<line x1="484" y1="64" x2="490" y2="64" stroke="#71717a" stroke-width="1" opacity="0.5"/>
<!-- localnet -->
<g transform="translate(508, 40)">
<rect width="120" height="48" rx="7" fill="#DC26260d" stroke="#DC2626" stroke-width="1" stroke-opacity="0.5" stroke-dasharray="4,3"/>
<text x="60" y="22" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#F87171" text-anchor="middle" font-weight="700">localnet</text>
<text x="60" y="38" font-family="'Segoe UI', sans-serif" font-size="9.5" fill="#FCA5A5" text-anchor="middle">OVN port type</text>
</g>
<polygon points="634,60 646,64 634,68" fill="#71717a" opacity="0.7"/>
<line x1="628" y1="64" x2="634" y2="64" stroke="#71717a" stroke-width="1" opacity="0.5"/>
<!-- br-wan -->
<g transform="translate(652, 40)">
<rect width="110" height="48" rx="7" fill="#58a6ff1a" stroke="#58a6ff" stroke-width="1.1" stroke-opacity="0.6"/>
<text x="55" y="22" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#58a6ff" text-anchor="middle" font-weight="700">br-wan</text>
<text x="55" y="38" font-family="'Segoe UI', sans-serif" font-size="9.5" fill="#a1a1aa" text-anchor="middle">OVS bridge</text>
</g>
<polygon points="768,60 780,64 768,68" fill="#71717a" opacity="0.7"/>
<line x1="762" y1="64" x2="768" y2="64" stroke="#71717a" stroke-width="1" opacity="0.5"/>
<!-- WAN -->
<g transform="translate(786, 40)">
<rect width="90" height="48" rx="7" fill="#3fb9501a" stroke="#3fb950" stroke-width="1.1" stroke-opacity="0.6"/>
<text x="45" y="22" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#3fb950" text-anchor="middle" font-weight="700">WAN</text>
<text x="45" y="38" font-family="'Segoe UI', sans-serif" font-size="9.5" fill="#a1a1aa" text-anchor="middle">uplink</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 4.1 KiB

@@ -0,0 +1,74 @@
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="360" viewBox="0 0 900 360">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#0a0a0a"/>
<stop offset="100%" style="stop-color:#0d1117"/>
</linearGradient>
<linearGradient id="host-grad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#58a6ff22"/>
<stop offset="100%" style="stop-color:#58a6ff08"/>
</linearGradient>
<linearGradient id="vm-grad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#3fb95022"/>
<stop offset="100%" style="stop-color:#3fb95008"/>
</linearGradient>
</defs>
<rect width="900" height="360" fill="url(#bg)" rx="12"/>
<text x="24" y="30" font-family="'Segoe UI', system-ui, sans-serif" font-size="11" fill="#a1a1aa" letter-spacing="2.5" font-weight="600">TWO DHCP CONVERSATIONS · completely independent</text>
<text x="876" y="30" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#71717a" text-anchor="end">pool mode · static or dhcp source</text>
<!-- ═══ HOST ↔ ROUTER DHCP ══════════════════════════════ -->
<rect x="24" y="54" width="852" height="138" rx="10" fill="url(#host-grad)" stroke="#58a6ff" stroke-width="1.2" stroke-opacity="0.6"/>
<text x="44" y="78" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#58a6ff" font-weight="700" letter-spacing="0.8">HOST ↔ ROUTER DHCP</text>
<text x="876" y="78" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#8ab4f8" text-anchor="end">on br-ext · veth pair → br-wan</text>
<line x1="24" y1="88" x2="876" y2="88" stroke="#58a6ff" stroke-opacity="0.25" stroke-width="0.5"/>
<g font-family="'SF Mono', ui-monospace, monospace" font-size="11">
<!-- step 1 -->
<circle cx="54" cy="108" r="9" fill="#58a6ff" fill-opacity="0.15" stroke="#58a6ff" stroke-width="1"/>
<text x="54" y="112" fill="#58a6ff" text-anchor="middle" font-weight="700" font-size="10">1</text>
<text x="74" y="112" fill="#f5f5f5">Spinifex asks router:</text>
<text x="228" y="112" fill="#a1a1aa">"give me a public IP for this VM"</text>
<!-- step 2 -->
<circle cx="54" cy="132" r="9" fill="#58a6ff" fill-opacity="0.15" stroke="#58a6ff" stroke-width="1"/>
<text x="54" y="136" fill="#58a6ff" text-anchor="middle" font-weight="700" font-size="10">2</text>
<text x="74" y="136" fill="#f5f5f5">Router responds:</text>
<text x="196" y="136" fill="#8ab4f8" font-weight="600">192.168.1.75</text>
<!-- step 3 -->
<circle cx="54" cy="156" r="9" fill="#58a6ff" fill-opacity="0.15" stroke="#58a6ff" stroke-width="1"/>
<text x="54" y="160" fill="#58a6ff" text-anchor="middle" font-weight="700" font-size="10">3</text>
<text x="74" y="160" fill="#f5f5f5">Spinifex stores IP, tells OVN:</text>
<text x="274" y="160" fill="#F87171" font-weight="600">dnat_and_snat(192.168.1.75 ↔ 172.31.0.4)</text>
</g>
<text x="44" y="181" font-family="'Segoe UI', sans-serif" font-size="10" fill="#71717a" font-style="italic">Only used in DHCP source mode. Static mode picks from range.</text>
<!-- separator -->
<line x1="24" y1="206" x2="876" y2="206" stroke="#27272a" stroke-width="0.8"/>
<text x="450" y="220" font-family="'SF Mono', ui-monospace, monospace" font-size="9" fill="#52525b" text-anchor="middle" letter-spacing="2">INDEPENDENT · no packets cross between</text>
<!-- ═══ VM ↔ OVN DHCP ═══════════════════════════════════ -->
<rect x="24" y="234" width="852" height="110" rx="10" fill="url(#vm-grad)" stroke="#3fb950" stroke-width="1.2" stroke-opacity="0.55"/>
<text x="44" y="258" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#3fb950" font-weight="700" letter-spacing="0.8">VM ↔ OVN DHCP</text>
<text x="876" y="258" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#7ee787" text-anchor="end">on tap → br-int → OVN logical switch</text>
<line x1="24" y1="268" x2="876" y2="268" stroke="#3fb950" stroke-opacity="0.25" stroke-width="0.5"/>
<g font-family="'SF Mono', ui-monospace, monospace" font-size="11">
<circle cx="54" cy="288" r="9" fill="#3fb950" fill-opacity="0.15" stroke="#3fb950" stroke-width="1"/>
<text x="54" y="292" fill="#3fb950" text-anchor="middle" font-weight="700" font-size="10">1</text>
<text x="74" y="292" fill="#f5f5f5">cloud-init asks:</text>
<text x="190" y="292" fill="#a1a1aa">"what's my IP?"</text>
<circle cx="54" cy="312" r="9" fill="#3fb950" fill-opacity="0.15" stroke="#3fb950" stroke-width="1"/>
<text x="54" y="316" fill="#3fb950" text-anchor="middle" font-weight="700" font-size="10">2</text>
<text x="74" y="316" fill="#f5f5f5">OVN DHCP responds:</text>
<text x="226" y="316" fill="#a1a1aa">"you are</text>
<text x="284" y="316" fill="#7ee787" font-weight="600">172.31.0.4</text>
<text x="356" y="316" fill="#a1a1aa">"</text>
</g>
<text x="44" y="336" font-family="'Segoe UI', sans-serif" font-size="10" fill="#71717a" font-style="italic">VM never sees 192.168.1.x or the router.</text>
</svg>

After

Width:  |  Height:  |  Size: 5.2 KiB

@@ -0,0 +1,44 @@
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="170" viewBox="0 0 900 170">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#0a0a0a"/>
<stop offset="100%" style="stop-color:#0d1117"/>
</linearGradient>
</defs>
<rect width="900" height="170" fill="url(#bg)" rx="10"/>
<text x="24" y="24" font-family="'Segoe UI', system-ui, sans-serif" font-size="10" fill="#a1a1aa" letter-spacing="2" font-weight="600">DIRECT BRIDGE MODE · dedicated WAN NIC</text>
<!-- Row 1: Management NIC (independent, no WAN bridge path) -->
<g transform="translate(24, 42)">
<rect width="852" height="44" rx="7" fill="#3fb9501a" stroke="#3fb950" stroke-width="1.1" stroke-opacity="0.55"/>
<text x="20" y="20" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#3fb950" font-weight="700">Management NIC</text>
<text x="20" y="36" font-family="'Segoe UI', sans-serif" font-size="10" fill="#a1a1aa">host IP · SSH · spinifex services · overlay (Geneve)</text>
<text x="840" y="28" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#7ee787" text-anchor="end" font-weight="600">independent path</text>
</g>
<!-- Row 2: WAN NIC → br-wan → localnet -->
<g transform="translate(24, 100)">
<rect width="160" height="44" rx="7" fill="#58a6ff1a" stroke="#58a6ff" stroke-width="1.1" stroke-opacity="0.6"/>
<text x="80" y="20" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#58a6ff" text-anchor="middle" font-weight="700">WAN NIC</text>
<text x="80" y="36" font-family="'Segoe UI', sans-serif" font-size="9.5" fill="#a1a1aa" text-anchor="middle">dedicated physical</text>
</g>
<polygon points="198,118 210,122 198,126" fill="#71717a" opacity="0.7"/>
<line x1="186" y1="122" x2="198" y2="122" stroke="#71717a" stroke-width="1" opacity="0.5"/>
<g transform="translate(214, 100)">
<rect width="220" height="44" rx="7" fill="#58a6ff1a" stroke="#58a6ff" stroke-width="1.1" stroke-opacity="0.6"/>
<text x="110" y="20" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#58a6ff" text-anchor="middle" font-weight="700">br-wan</text>
<text x="110" y="36" font-family="'Segoe UI', sans-serif" font-size="9.5" fill="#a1a1aa" text-anchor="middle">OVS port (NIC added directly)</text>
</g>
<polygon points="448,118 460,122 448,126" fill="#71717a" opacity="0.7"/>
<line x1="434" y1="122" x2="448" y2="122" stroke="#71717a" stroke-width="1" opacity="0.5"/>
<g transform="translate(464, 100)">
<rect width="180" height="44" rx="7" fill="#DC26261a" stroke="#DC2626" stroke-width="1.1" stroke-opacity="0.65"/>
<text x="90" y="20" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#F87171" text-anchor="middle" font-weight="700">OVN localnet</text>
<text x="90" y="36" font-family="'Segoe UI', sans-serif" font-size="9.5" fill="#FCA5A5" text-anchor="middle">external switch port</text>
</g>
<text x="670" y="130" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#F87171" font-weight="600">distributed NAT · any MAC · all frames</text>
</svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

+91
View File
@@ -0,0 +1,91 @@
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="440" viewBox="0 0 900 440">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#0a0a0a"/>
<stop offset="100%" style="stop-color:#0d1117"/>
</linearGradient>
<linearGradient id="host-grad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#27272a88"/>
<stop offset="100%" style="stop-color:#27272a22"/>
</linearGradient>
<linearGradient id="br-int-grad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#a855f71f"/>
<stop offset="100%" style="stop-color:#a855f708"/>
</linearGradient>
<linearGradient id="br-wan-grad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#58a6ff1f"/>
<stop offset="100%" style="stop-color:#58a6ff08"/>
</linearGradient>
</defs>
<rect width="900" height="440" fill="url(#bg)" rx="12"/>
<text x="24" y="30" font-family="'Segoe UI', system-ui, sans-serif" font-size="11" fill="#a1a1aa" letter-spacing="2.5" font-weight="600">NETWORK FLOW · bare-metal host</text>
<text x="876" y="30" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#71717a" text-anchor="end">br-int overlay · br-wan WAN uplink</text>
<!-- ═══ Bare-Metal Host container ═══════════════════════ -->
<rect x="40" y="60" width="820" height="284" rx="12" fill="url(#host-grad)" stroke="#71717a" stroke-width="1.2" stroke-opacity="0.7"/>
<text x="58" y="84" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#e4e4e7" font-weight="700" letter-spacing="1">BARE-METAL HOST</text>
<line x1="40" y1="96" x2="860" y2="96" stroke="#52525b" stroke-opacity="0.5" stroke-width="0.5"/>
<!-- br-int bridge (left) -->
<g transform="translate(80, 122)">
<rect width="260" height="176" rx="10" fill="url(#br-int-grad)" stroke="#a855f7" stroke-width="1.2" stroke-opacity="0.6"/>
<text x="20" y="24" font-family="'SF Mono', ui-monospace, monospace" font-size="13" fill="#c084fc" font-weight="700">br-int</text>
<text x="240" y="24" font-family="'Segoe UI', sans-serif" font-size="10" fill="#d8b4fe" text-anchor="end">(overlay)</text>
<line x1="20" y1="34" x2="240" y2="34" stroke="#a855f7" stroke-opacity="0.3" stroke-width="0.5"/>
<g font-family="'SF Mono', ui-monospace, monospace" font-size="11">
<rect x="30" y="52" width="200" height="28" rx="5" fill="#a855f71a" stroke="#a855f7" stroke-opacity="0.4" stroke-width="0.6"/>
<text x="130" y="70" fill="#d8b4fe" text-anchor="middle">tap-eni1</text>
<rect x="30" y="88" width="200" height="28" rx="5" fill="#a855f71a" stroke="#a855f7" stroke-opacity="0.4" stroke-width="0.6"/>
<text x="130" y="106" fill="#d8b4fe" text-anchor="middle">tap-eni2</text>
<rect x="30" y="124" width="200" height="28" rx="5" fill="#a855f71a" stroke="#a855f7" stroke-opacity="0.4" stroke-width="0.6"/>
<text x="130" y="142" fill="#d8b4fe" text-anchor="middle">tap-eni3</text>
</g>
</g>
<!-- OVN NAT pipeline arrow in middle -->
<g transform="translate(352, 200)">
<line x1="0" y1="10" x2="148" y2="10" stroke="#DC2626" stroke-width="1.4" stroke-dasharray="4,3" opacity="0.75"/>
<polygon points="148,5 160,10 148,15" fill="#DC2626" opacity="0.8"/>
<text x="78" y="0" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#F87171" text-anchor="middle" font-weight="700">OVN</text>
<text x="78" y="28" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#FCA5A5" text-anchor="middle">NAT pipeline</text>
</g>
<!-- br-wan bridge (right) -->
<g transform="translate(520, 122)">
<rect width="260" height="176" rx="10" fill="url(#br-wan-grad)" stroke="#58a6ff" stroke-width="1.2" stroke-opacity="0.6"/>
<text x="20" y="24" font-family="'SF Mono', ui-monospace, monospace" font-size="13" fill="#58a6ff" font-weight="700">br-wan</text>
<text x="240" y="24" font-family="'Segoe UI', sans-serif" font-size="10" fill="#8ab4f8" text-anchor="end">(WAN)</text>
<line x1="20" y1="34" x2="240" y2="34" stroke="#58a6ff" stroke-opacity="0.3" stroke-width="0.5"/>
<g font-family="'SF Mono', ui-monospace, monospace" font-size="11">
<rect x="30" y="52" width="200" height="60" rx="5" fill="#58a6ff1a" stroke="#58a6ff" stroke-opacity="0.45" stroke-width="0.8"/>
<text x="130" y="74" fill="#8ab4f8" text-anchor="middle" font-weight="600">spx-ext-{nic}</text>
<text x="130" y="92" fill="#a1a1aa" text-anchor="middle" font-size="10">(macvlan)</text>
</g>
</g>
<!-- macvlan bridge mode label -->
<text x="650" y="314" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#71717a" text-anchor="middle" font-style="italic">macvlan bridge mode</text>
<!-- WAN NIC inside host, bottom -->
<line x1="650" y1="298" x2="650" y2="320" stroke="#71717a" stroke-width="1" opacity="0.5"/>
<g transform="translate(500, 320)">
<rect width="300" height="20" rx="5" fill="#161b22" stroke="#52525b" stroke-width="0.6"/>
<text x="150" y="14" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#a1a1aa" text-anchor="middle">WAN NIC · host IP unchanged</text>
</g>
<!-- connector out -->
<line x1="650" y1="344" x2="650" y2="380" stroke="#71717a" stroke-width="1" opacity="0.5"/>
<polygon points="646,376 654,376 650,384" fill="#71717a" opacity="0.7"/>
<!-- Physical WAN -->
<g transform="translate(500, 388)">
<rect width="300" height="40" rx="7" fill="#3fb9501a" stroke="#3fb950" stroke-width="1.1" stroke-opacity="0.55"/>
<text x="150" y="25" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#3fb950" text-anchor="middle" font-weight="700">Physical WAN</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 5.7 KiB

+57
View File
@@ -0,0 +1,57 @@
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="320" viewBox="0 0 900 320">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#0a0a0a"/>
<stop offset="100%" style="stop-color:#0d1117"/>
</linearGradient>
</defs>
<rect width="900" height="320" fill="url(#bg)" rx="12"/>
<text x="24" y="30" font-family="'Segoe UI', system-ui, sans-serif" font-size="11" fill="#a1a1aa" letter-spacing="2.5" font-weight="600">MACVLAN FALLBACK · single-NIC host</text>
<text x="876" y="30" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#71717a" text-anchor="end">WAN NIC == SSH NIC · host IP preserved</text>
<!-- WAN NIC (parent) -->
<g transform="translate(280, 58)">
<rect width="340" height="54" rx="8" fill="#58a6ff1a" stroke="#58a6ff" stroke-width="1.3" stroke-opacity="0.65"/>
<text x="170" y="24" font-family="'SF Mono', ui-monospace, monospace" font-size="13" fill="#58a6ff" text-anchor="middle" font-weight="700">WAN NIC</text>
<text x="170" y="42" font-family="'Segoe UI', sans-serif" font-size="10" fill="#a1a1aa" text-anchor="middle">host IP unchanged · parent interface</text>
</g>
<!-- Split point -->
<line x1="450" y1="112" x2="450" y2="132" stroke="#71717a" stroke-width="1" opacity="0.5"/>
<line x1="260" y1="132" x2="640" y2="132" stroke="#71717a" stroke-width="1" opacity="0.5"/>
<line x1="260" y1="132" x2="260" y2="148" stroke="#71717a" stroke-width="1" opacity="0.5"/>
<line x1="640" y1="132" x2="640" y2="148" stroke="#71717a" stroke-width="1" opacity="0.5"/>
<!-- Child 1: host traffic -->
<g transform="translate(120, 148)">
<rect width="280" height="48" rx="7" fill="#3fb9501a" stroke="#3fb950" stroke-width="1.1" stroke-opacity="0.6"/>
<text x="140" y="22" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#3fb950" text-anchor="middle" font-weight="700">host traffic</text>
<text x="140" y="38" font-family="'Segoe UI', sans-serif" font-size="10" fill="#a1a1aa" text-anchor="middle">SSH · management · overlay</text>
</g>
<!-- Child 2: macvlan -->
<g transform="translate(500, 148)">
<rect width="280" height="48" rx="7" fill="#DC26261a" stroke="#DC2626" stroke-width="1.1" stroke-opacity="0.65"/>
<text x="140" y="22" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#F87171" text-anchor="middle" font-weight="700">spx-ext-{nic}</text>
<text x="140" y="38" font-family="'Segoe UI', sans-serif" font-size="10" fill="#FCA5A5" text-anchor="middle">macvlan · bridge mode · no IP</text>
</g>
<!-- br-wan -->
<line x1="640" y1="196" x2="640" y2="216" stroke="#71717a" stroke-width="1" opacity="0.5"/>
<polygon points="636,212 644,212 640,220" fill="#71717a" opacity="0.7"/>
<g transform="translate(500, 220)">
<rect width="280" height="40" rx="7" fill="#58a6ff1a" stroke="#58a6ff" stroke-width="1.1" stroke-opacity="0.6"/>
<text x="140" y="25" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#58a6ff" text-anchor="middle" font-weight="700">br-wan</text>
</g>
<!-- localnet -->
<line x1="640" y1="260" x2="640" y2="276" stroke="#71717a" stroke-width="1" opacity="0.5"/>
<polygon points="636,272 644,272 640,280" fill="#71717a" opacity="0.7"/>
<g transform="translate(500, 280)">
<rect width="280" height="30" rx="6" fill="#DC26260d" stroke="#DC2626" stroke-width="1" stroke-opacity="0.5" stroke-dasharray="4,3"/>
<text x="140" y="20" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#F87171" text-anchor="middle" font-weight="600">localnet → OVN ext switch</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.6 KiB

@@ -0,0 +1,131 @@
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="560" viewBox="0 0 900 560">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#0a0a0a"/>
<stop offset="100%" style="stop-color:#0d1117"/>
</linearGradient>
<linearGradient id="wan-grad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#3fb95022"/>
<stop offset="100%" style="stop-color:#3fb95008"/>
</linearGradient>
<linearGradient id="ovs-grad" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#58a6ff22"/>
<stop offset="100%" style="stop-color:#58a6ff0a"/>
</linearGradient>
<linearGradient id="router-grad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#DC262622"/>
<stop offset="100%" style="stop-color:#7F1D1D0a"/>
</linearGradient>
<linearGradient id="pub-grad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#3fb95022"/>
<stop offset="100%" style="stop-color:#3fb95008"/>
</linearGradient>
<linearGradient id="priv-grad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#a855f722"/>
<stop offset="100%" style="stop-color:#a855f708"/>
</linearGradient>
</defs>
<rect width="900" height="560" fill="url(#bg)" rx="12"/>
<!-- Title -->
<text x="24" y="30" font-family="'Segoe UI', system-ui, sans-serif" font-size="11" fill="#a1a1aa" letter-spacing="2.5" font-weight="600">NETWORK PATH</text>
<text x="876" y="30" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#71717a" text-anchor="end">OVN logical topology · bridge mapping · NAT</text>
<!-- WAN -->
<rect x="300" y="54" width="300" height="40" rx="8" fill="url(#wan-grad)" stroke="#3fb950" stroke-width="1" stroke-opacity="0.55"/>
<text x="450" y="78" font-family="'SF Mono', ui-monospace, monospace" font-size="13" fill="#3fb950" text-anchor="middle" font-weight="700">Physical Network (WAN)</text>
<!-- connector -->
<line x1="450" y1="94" x2="450" y2="112" stroke="#71717a" stroke-width="1" opacity="0.5"/>
<!-- br-wan bridge -->
<rect x="340" y="112" width="220" height="52" rx="8" fill="url(#ovs-grad)" stroke="#58a6ff" stroke-width="1.2" stroke-opacity="0.65"/>
<text x="450" y="134" font-family="'SF Mono', ui-monospace, monospace" font-size="13" fill="#58a6ff" text-anchor="middle" font-weight="700">br-wan</text>
<text x="450" y="152" font-family="'Segoe UI', sans-serif" font-size="10" fill="#a1a1aa" text-anchor="middle">OVS bridge · physical uplink</text>
<!-- connector with bridge-mappings label -->
<line x1="450" y1="164" x2="450" y2="186" stroke="#71717a" stroke-width="1" opacity="0.5"/>
<rect x="316" y="170" width="268" height="18" rx="9" fill="#0a0a0a" stroke="#58a6ff" stroke-width="0.6" stroke-opacity="0.5"/>
<text x="450" y="183" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#8ab4f8" text-anchor="middle">ovn-bridge-mappings: external:br-wan</text>
<!-- VPC Logical Router (the big box) -->
<rect x="60" y="208" width="780" height="196" rx="10" fill="url(#router-grad)" stroke="#DC2626" stroke-width="1.4" stroke-opacity="0.75"/>
<text x="78" y="232" font-family="'SF Mono', ui-monospace, monospace" font-size="13" fill="#F87171" font-weight="700" letter-spacing="1">VPC LOGICAL ROUTER</text>
<line x1="60" y1="242" x2="840" y2="242" stroke="#DC2626" stroke-opacity="0.25" stroke-width="0.5"/>
<!-- Router ports row -->
<g font-family="'SF Mono', ui-monospace, monospace" font-size="11">
<rect x="82" y="254" width="120" height="42" rx="5" fill="#0d1117" stroke="#F87171" stroke-opacity="0.6" stroke-width="0.8"/>
<text x="142" y="270" fill="#F87171" text-anchor="middle" font-weight="600">Gateway port</text>
<text x="142" y="286" fill="#a1a1aa" text-anchor="middle" font-size="9.5">public IP</text>
<text x="215" y="282" fill="#71717a">─────</text>
<rect x="260" y="254" width="120" height="42" rx="5" fill="#0d1117" stroke="#F87171" stroke-opacity="0.6" stroke-width="0.8"/>
<text x="320" y="270" fill="#F87171" text-anchor="middle" font-weight="600">ext switch</text>
<text x="320" y="286" fill="#a1a1aa" text-anchor="middle" font-size="9.5">OVN logical</text>
<text x="393" y="282" fill="#71717a">─────</text>
<rect x="438" y="254" width="120" height="42" rx="5" fill="#0d1117" stroke="#F87171" stroke-opacity="0.6" stroke-width="0.8"/>
<text x="498" y="270" fill="#F87171" text-anchor="middle" font-weight="600">localnet port</text>
<text x="498" y="286" fill="#a1a1aa" text-anchor="middle" font-size="9.5">→ br-wan</text>
</g>
<!-- NAT rules + Routes, two columns on right -->
<g font-family="'SF Mono', ui-monospace, monospace" font-size="10.5">
<text x="598" y="260" fill="#F87171" font-weight="600">NAT rules</text>
<text x="598" y="276" fill="#f5f5f5">SNAT:</text>
<text x="644" y="276" fill="#a1a1aa">VPC CIDR → gw IP</text>
<text x="598" y="292" fill="#f5f5f5">dnat_and_snat:</text>
<text x="704" y="292" fill="#a1a1aa">pub ↔ priv</text>
</g>
<g font-family="'SF Mono', ui-monospace, monospace" font-size="10.5">
<text x="82" y="330" fill="#F87171" font-weight="600">Routes</text>
<text x="82" y="346" fill="#f5f5f5">0.0.0.0/0</text>
<text x="154" y="346" fill="#a1a1aa">→ WAN gateway</text>
</g>
<!-- Subnet branches inside router -->
<line x1="150" y1="362" x2="150" y2="386" stroke="#DC2626" stroke-width="1" stroke-dasharray="3,3" opacity="0.6"/>
<line x1="150" y1="386" x2="305" y2="386" stroke="#DC2626" stroke-width="1" stroke-dasharray="3,3" opacity="0.6"/>
<line x1="305" y1="386" x2="305" y2="404" stroke="#DC2626" stroke-width="1" stroke-dasharray="3,3" opacity="0.6"/>
<line x1="150" y1="386" x2="515" y2="386" stroke="#DC2626" stroke-width="1" stroke-dasharray="3,3" opacity="0.6"/>
<line x1="515" y1="386" x2="515" y2="404" stroke="#DC2626" stroke-width="1" stroke-dasharray="3,3" opacity="0.6"/>
<!-- Subnet A (public) -->
<g transform="translate(215, 404)">
<rect width="180" height="124" rx="8" fill="url(#pub-grad)" stroke="#3fb950" stroke-width="1.2" stroke-opacity="0.6"/>
<text x="90" y="22" font-family="'SF Mono', ui-monospace, monospace" font-size="13" fill="#3fb950" text-anchor="middle" font-weight="700">subnet-a</text>
<text x="90" y="38" font-family="'Segoe UI', sans-serif" font-size="10" fill="#7ee787" text-anchor="middle">(public)</text>
<line x1="16" y1="46" x2="164" y2="46" stroke="#3fb950" stroke-opacity="0.3" stroke-width="0.5"/>
<text x="90" y="64" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#f5f5f5" text-anchor="middle">10.0.1.0/24</text>
<g font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#a1a1aa">
<circle cx="40" cy="88" r="2.5" fill="#3fb950"/>
<text x="52" y="92">eni-1</text>
<circle cx="40" cy="108" r="2.5" fill="#3fb950"/>
<text x="52" y="112">eni-2</text>
</g>
</g>
<!-- Subnet B (private) -->
<g transform="translate(425, 404)">
<rect width="180" height="124" rx="8" fill="url(#priv-grad)" stroke="#a855f7" stroke-width="1.2" stroke-opacity="0.6"/>
<text x="90" y="22" font-family="'SF Mono', ui-monospace, monospace" font-size="13" fill="#c084fc" text-anchor="middle" font-weight="700">subnet-b</text>
<text x="90" y="38" font-family="'Segoe UI', sans-serif" font-size="10" fill="#d8b4fe" text-anchor="middle">(private)</text>
<line x1="16" y1="46" x2="164" y2="46" stroke="#a855f7" stroke-opacity="0.3" stroke-width="0.5"/>
<text x="90" y="64" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#f5f5f5" text-anchor="middle">10.0.2.0/24</text>
<g font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#a1a1aa">
<circle cx="40" cy="88" r="2.5" fill="#a855f7"/>
<text x="52" y="92">eni-3</text>
<circle cx="40" cy="108" r="2.5" fill="#a855f7"/>
<text x="52" y="112">eni-4</text>
</g>
</g>
<!-- Footnote -->
<text x="24" y="548" font-family="'Segoe UI', sans-serif" font-size="10" fill="#71717a">Cross-host traffic uses Geneve tunnels (UDP 6081) over the overlay NIC · ovn-controller programs OpenFlow rules on br-int</text>
</svg>

After

Width:  |  Height:  |  Size: 8.2 KiB

@@ -0,0 +1,56 @@
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="110" viewBox="0 0 900 110">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#0a0a0a"/>
<stop offset="100%" style="stop-color:#0d1117"/>
</linearGradient>
</defs>
<rect width="900" height="110" fill="url(#bg)" rx="10"/>
<text x="24" y="24" font-family="'Segoe UI', system-ui, sans-serif" font-size="10" fill="#a1a1aa" letter-spacing="2" font-weight="600">PRIVATE SUBNET · no default route</text>
<!-- Instance -->
<g transform="translate(40, 42)">
<rect width="180" height="44" rx="7" fill="#a855f71a" stroke="#a855f7" stroke-width="1.2" stroke-opacity="0.6"/>
<text x="90" y="20" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#c084fc" text-anchor="middle" font-weight="700">Instance</text>
<text x="90" y="34" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#d8b4fe" text-anchor="middle">10.0.2.5</text>
</g>
<!-- arrow -->
<g stroke="#DC2626" stroke-width="1.2" opacity="0.6" fill="none">
<line x1="220" y1="64" x2="260" y2="64" stroke-dasharray="3,3"/>
</g>
<polygon points="260,60 268,64 260,68" fill="#DC2626" opacity="0.6"/>
<!-- Router -->
<g transform="translate(275, 42)">
<rect width="180" height="44" rx="7" fill="#DC26261a" stroke="#DC2626" stroke-width="1.2" stroke-opacity="0.65"/>
<text x="90" y="20" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#F87171" text-anchor="middle" font-weight="700">VPC Router</text>
<text x="90" y="34" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#FCA5A5" text-anchor="middle">lookup 0.0.0.0/0</text>
</g>
<!-- arrow -->
<g stroke="#DC2626" stroke-width="1.2" opacity="0.6" fill="none">
<line x1="455" y1="64" x2="495" y2="64" stroke-dasharray="3,3"/>
</g>
<polygon points="495,60 503,64 495,68" fill="#DC2626" opacity="0.6"/>
<!-- [no default route] -->
<g transform="translate(510, 42)">
<rect width="196" height="44" rx="7" fill="#0a0a0a" stroke="#71717a" stroke-width="1" stroke-opacity="0.7" stroke-dasharray="4,3"/>
<text x="98" y="20" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#a1a1aa" text-anchor="middle" font-weight="600">[no default route]</text>
<text x="98" y="34" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#71717a" text-anchor="middle">no IGW attached</text>
</g>
<!-- arrow to dropped -->
<g stroke="#71717a" stroke-width="1.2" opacity="0.6" fill="none">
<line x1="706" y1="64" x2="746" y2="64" stroke-dasharray="3,3"/>
</g>
<polygon points="746,60 754,64 746,68" fill="#71717a" opacity="0.6"/>
<!-- dropped -->
<g transform="translate(760, 42)">
<rect width="116" height="44" rx="7" fill="#27272a" stroke="#52525b" stroke-width="1" stroke-opacity="0.7"/>
<text x="58" y="27" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#a1a1aa" text-anchor="middle" font-weight="600">dropped</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

@@ -0,0 +1,74 @@
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="170" viewBox="0 0 900 170">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#0a0a0a"/>
<stop offset="100%" style="stop-color:#0d1117"/>
</linearGradient>
</defs>
<rect width="900" height="170" fill="url(#bg)" rx="10"/>
<text x="24" y="24" font-family="'Segoe UI', system-ui, sans-serif" font-size="10" fill="#a1a1aa" letter-spacing="2" font-weight="600">PUBLIC SUBNET · 1:1 NAT managed by OVN</text>
<!-- OUTBOUND row -->
<text x="24" y="52" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#3fb950" font-weight="700">Outbound</text>
<g transform="translate(112, 38)">
<rect width="180" height="40" rx="6" fill="#3fb9501a" stroke="#3fb950" stroke-width="1.1" stroke-opacity="0.6"/>
<text x="90" y="18" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#7ee787" text-anchor="middle" font-weight="700">Instance</text>
<text x="90" y="32" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#a1a1aa" text-anchor="middle">10.0.1.5</text>
</g>
<g stroke="#3fb950" stroke-width="1.1" opacity="0.6" fill="none">
<line x1="292" y1="58" x2="352" y2="58" stroke-dasharray="3,3"/>
</g>
<polygon points="352,54 360,58 352,62" fill="#3fb950" opacity="0.7"/>
<g transform="translate(368, 38)">
<rect width="130" height="40" rx="6" fill="#DC26261a" stroke="#DC2626" stroke-width="1.1" stroke-opacity="0.65"/>
<text x="65" y="25" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#F87171" text-anchor="middle" font-weight="700">SNAT</text>
</g>
<g stroke="#3fb950" stroke-width="1.1" opacity="0.6" fill="none">
<line x1="498" y1="58" x2="558" y2="58" stroke-dasharray="3,3"/>
</g>
<polygon points="558,54 566,58 558,62" fill="#3fb950" opacity="0.7"/>
<g transform="translate(574, 38)">
<rect width="272" height="40" rx="6" fill="#3fb9501a" stroke="#3fb950" stroke-width="1.1" stroke-opacity="0.6"/>
<text x="136" y="18" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#7ee787" text-anchor="middle" font-weight="700">exits as 203.0.113.10</text>
<text x="136" y="32" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#a1a1aa" text-anchor="middle">source IP rewritten</text>
</g>
<!-- INBOUND row -->
<text x="24" y="110" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#58a6ff" font-weight="700">Inbound</text>
<g transform="translate(112, 96)">
<rect width="180" height="40" rx="6" fill="#58a6ff1a" stroke="#58a6ff" stroke-width="1.1" stroke-opacity="0.6"/>
<text x="90" y="18" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#8ab4f8" text-anchor="middle" font-weight="700">WAN client</text>
<text x="90" y="32" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#a1a1aa" text-anchor="middle">→ 203.0.113.10</text>
</g>
<g stroke="#58a6ff" stroke-width="1.1" opacity="0.6" fill="none">
<line x1="292" y1="116" x2="352" y2="116" stroke-dasharray="3,3"/>
</g>
<polygon points="352,112 360,116 352,120" fill="#58a6ff" opacity="0.7"/>
<g transform="translate(368, 96)">
<rect width="130" height="40" rx="6" fill="#DC26261a" stroke="#DC2626" stroke-width="1.1" stroke-opacity="0.65"/>
<text x="65" y="25" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#F87171" text-anchor="middle" font-weight="700">DNAT</text>
</g>
<g stroke="#58a6ff" stroke-width="1.1" opacity="0.6" fill="none">
<line x1="498" y1="116" x2="558" y2="116" stroke-dasharray="3,3"/>
</g>
<polygon points="558,112 566,116 558,120" fill="#58a6ff" opacity="0.7"/>
<g transform="translate(574, 96)">
<rect width="272" height="40" rx="6" fill="#58a6ff1a" stroke="#58a6ff" stroke-width="1.1" stroke-opacity="0.6"/>
<text x="136" y="18" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#8ab4f8" text-anchor="middle" font-weight="700">arrives at 10.0.1.5</text>
<text x="136" y="32" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#a1a1aa" text-anchor="middle">dest IP rewritten</text>
</g>
<!-- Footnote -->
<text x="24" y="158" font-family="'Segoe UI', sans-serif" font-size="9" fill="#71717a">The instance OS only sees its private IP (10.0.1.5) — the public address is managed entirely by OVN's dnat_and_snat rule.</text>
</svg>

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 437 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 378 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 396 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 525 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 490 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 754 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 406 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 530 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 544 KiB

+144
View File
@@ -0,0 +1,144 @@
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="560" viewBox="0 0 900 560">
<defs>
<linearGradient id="pf-bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#0a0a0a"/>
<stop offset="100%" style="stop-color:#0d1117"/>
</linearGradient>
<linearGradient id="hw-grad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#52525b;stop-opacity:0.18"/>
<stop offset="100%" style="stop-color:#52525b;stop-opacity:0.06"/>
</linearGradient>
<linearGradient id="os-grad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#a855f7;stop-opacity:0.18"/>
<stop offset="100%" style="stop-color:#a855f7;stop-opacity:0.05"/>
</linearGradient>
<linearGradient id="spx-grad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#DC2626;stop-opacity:0.28"/>
<stop offset="100%" style="stop-color:#7F1D1D;stop-opacity:0.1"/>
</linearGradient>
<linearGradient id="app-grad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#3fb950;stop-opacity:0.18"/>
<stop offset="100%" style="stop-color:#3fb950;stop-opacity:0.05"/>
</linearGradient>
</defs>
<rect width="900" height="560" fill="url(#pf-bg)" rx="12"/>
<!-- Subtle mesh dots background -->
<g opacity="0.12" fill="#7F1D1D">
<circle cx="40" cy="40" r="1"/><circle cx="120" cy="30" r="1"/><circle cx="220" cy="50" r="1"/>
<circle cx="320" cy="25" r="1"/><circle cx="440" cy="45" r="1"/><circle cx="560" cy="30" r="1"/>
<circle cx="680" cy="50" r="1"/><circle cx="780" cy="30" r="1"/><circle cx="860" cy="45" r="1"/>
<circle cx="60" cy="530" r="1"/><circle cx="160" cy="520" r="1"/><circle cx="260" cy="540" r="1"/>
<circle cx="380" cy="525" r="1"/><circle cx="500" cy="540" r="1"/><circle cx="620" cy="520" r="1"/>
<circle cx="740" cy="540" r="1"/><circle cx="840" cy="525" r="1"/>
</g>
<!-- Title -->
<text x="450" y="32" font-family="'Segoe UI', system-ui, sans-serif" font-size="11" fill="#a1a1aa" text-anchor="middle" letter-spacing="2.8">THE MULGA PLATFORM</text>
<text x="450" y="58" font-family="'Segoe UI', system-ui, sans-serif" font-size="20" font-weight="600" fill="#f5f5f5" text-anchor="middle">AWS-style workflows, on your own stack</text>
<!-- Layer 4: Applications (top) -->
<g transform="translate(60, 95)">
<rect width="780" height="88" rx="10" fill="url(#app-grad)" stroke="#3fb950" stroke-width="1.2" stroke-opacity="0.55"/>
<text x="20" y="26" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#3fb950" letter-spacing="1.5" font-weight="600">LAYER 4 · APPLICATIONS &amp; TOOLING</text>
<text x="20" y="48" font-family="'Segoe UI', sans-serif" font-size="15" fill="#f5f5f5" font-weight="500">Your workloads — unmodified AWS tooling</text>
<g transform="translate(20, 60)" font-family="'Segoe UI', sans-serif" font-size="10.5">
<rect x="0" y="0" width="70" height="20" rx="10" fill="#3fb950" fill-opacity="0.14" stroke="#3fb950" stroke-opacity="0.45" stroke-width="0.5"/>
<text x="35" y="14" fill="#7ee787" text-anchor="middle">aws-cli</text>
<rect x="78" y="0" width="78" height="20" rx="10" fill="#3fb950" fill-opacity="0.14" stroke="#3fb950" stroke-opacity="0.45" stroke-width="0.5"/>
<text x="117" y="14" fill="#7ee787" text-anchor="middle">Terraform</text>
<rect x="164" y="0" width="88" height="20" rx="10" fill="#3fb950" fill-opacity="0.14" stroke="#3fb950" stroke-opacity="0.45" stroke-width="0.5"/>
<text x="208" y="14" fill="#7ee787" text-anchor="middle">AWS SDKs</text>
<rect x="260" y="0" width="88" height="20" rx="10" fill="#3fb950" fill-opacity="0.14" stroke="#3fb950" stroke-opacity="0.45" stroke-width="0.5"/>
<text x="304" y="14" fill="#7ee787" text-anchor="middle">Kubernetes</text>
<rect x="356" y="0" width="70" height="20" rx="10" fill="#3fb950" fill-opacity="0.14" stroke="#3fb950" stroke-opacity="0.45" stroke-width="0.5"/>
<text x="391" y="14" fill="#7ee787" text-anchor="middle">Packer</text>
<rect x="434" y="0" width="98" height="20" rx="10" fill="#3fb950" fill-opacity="0.14" stroke="#3fb950" stroke-opacity="0.45" stroke-width="0.5"/>
<text x="483" y="14" fill="#7ee787" text-anchor="middle">Your Services</text>
</g>
</g>
<!-- Down arrow -->
<g transform="translate(450, 188)" opacity="0.45">
<line x1="0" y1="0" x2="0" y2="14" stroke="#a1a1aa" stroke-width="1"/>
<polygon points="-4,12 4,12 0,18" fill="#a1a1aa"/>
</g>
<!-- Layer 3: Spinifex (highlighted) -->
<g transform="translate(60, 215)">
<rect width="780" height="140" rx="10" fill="url(#spx-grad)" stroke="#DC2626" stroke-width="1.5" stroke-opacity="0.75"/>
<text x="20" y="26" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#F87171" letter-spacing="1.5" font-weight="600">LAYER 3 · SPINIFEX · AWS ORCHESTRATION</text>
<text x="20" y="50" font-family="'Segoe UI', sans-serif" font-size="15" fill="#f5f5f5" font-weight="500">AWS Gateway → NATS → Service Daemons · SigV4, IAM, VPC-aware</text>
<g transform="translate(20, 66)" font-family="'Segoe UI', sans-serif">
<!-- EC2 -->
<rect x="0" y="0" width="118" height="58" rx="6" fill="#DC2626" fill-opacity="0.1" stroke="#DC2626" stroke-opacity="0.6" stroke-width="1"/>
<text x="59" y="18" font-size="13" fill="#F87171" text-anchor="middle" font-weight="600">EC2</text>
<text x="59" y="34" font-size="9.5" fill="#f5f5f5" text-anchor="middle">QEMU/KVM VMs</text>
<text x="59" y="48" font-size="9.5" fill="#a1a1aa" text-anchor="middle">cloud-init · AMIs</text>
<!-- EBS -->
<rect x="126" y="0" width="118" height="58" rx="6" fill="#DC2626" fill-opacity="0.1" stroke="#DC2626" stroke-opacity="0.6" stroke-width="1"/>
<text x="185" y="18" font-size="13" fill="#F87171" text-anchor="middle" font-weight="600">EBS</text>
<text x="185" y="34" font-size="9.5" fill="#f5f5f5" text-anchor="middle">Viperblock</text>
<text x="185" y="48" font-size="9.5" fill="#a1a1aa" text-anchor="middle">WAL · NBD · snapshots</text>
<!-- S3 -->
<rect x="252" y="0" width="118" height="58" rx="6" fill="#DC2626" fill-opacity="0.1" stroke="#DC2626" stroke-opacity="0.6" stroke-width="1"/>
<text x="311" y="18" font-size="13" fill="#F87171" text-anchor="middle" font-weight="600">S3</text>
<text x="311" y="34" font-size="9.5" fill="#f5f5f5" text-anchor="middle">Predastore</text>
<text x="311" y="48" font-size="9.5" fill="#a1a1aa" text-anchor="middle">Reed–Solomon EC</text>
<!-- VPC -->
<rect x="378" y="0" width="118" height="58" rx="6" fill="#7F1D1D" fill-opacity="0.22" stroke="#F87171" stroke-opacity="0.55" stroke-width="1"/>
<text x="437" y="18" font-size="13" fill="#FCA5A5" text-anchor="middle" font-weight="600">VPC</text>
<text x="437" y="34" font-size="9.5" fill="#f5f5f5" text-anchor="middle">OVN / OVS</text>
<text x="437" y="48" font-size="9.5" fill="#a1a1aa" text-anchor="middle">subnets · SG · ALB</text>
<!-- IAM -->
<rect x="504" y="0" width="118" height="58" rx="6" fill="#7F1D1D" fill-opacity="0.22" stroke="#F87171" stroke-opacity="0.55" stroke-width="1"/>
<text x="563" y="18" font-size="13" fill="#FCA5A5" text-anchor="middle" font-weight="600">IAM</text>
<text x="563" y="34" font-size="9.5" fill="#f5f5f5" text-anchor="middle">SigV4 · policies</text>
<text x="563" y="48" font-size="9.5" fill="#a1a1aa" text-anchor="middle">users · roles · keys</text>
<!-- Control -->
<rect x="630" y="0" width="110" height="58" rx="6" fill="#27272a" fill-opacity="0.8" stroke="#71717a" stroke-opacity="0.6" stroke-width="1"/>
<text x="685" y="18" font-size="13" fill="#e4e4e7" text-anchor="middle" font-weight="600">Control</text>
<text x="685" y="34" font-size="9.5" fill="#f5f5f5" text-anchor="middle">NATS bus</text>
<text x="685" y="48" font-size="9.5" fill="#a1a1aa" text-anchor="middle">spx · daemons</text>
</g>
</g>
<!-- Down arrow -->
<g transform="translate(450, 360)" opacity="0.45">
<line x1="0" y1="0" x2="0" y2="14" stroke="#a1a1aa" stroke-width="1"/>
<polygon points="-4,12 4,12 0,18" fill="#a1a1aa"/>
</g>
<!-- Layer 2: OS -->
<g transform="translate(60, 387)">
<rect width="780" height="62" rx="10" fill="url(#os-grad)" stroke="#a855f7" stroke-width="1.2" stroke-opacity="0.5"/>
<text x="20" y="22" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#c084fc" letter-spacing="1.5" font-weight="600">LAYER 2 · OPERATING SYSTEM</text>
<text x="20" y="44" font-family="'Segoe UI', sans-serif" font-size="14" fill="#f5f5f5">Debian / Ubuntu LTS · systemd · KVM · Linux bridge · headless autoinstall</text>
</g>
<!-- Down arrow -->
<g transform="translate(450, 454)" opacity="0.45">
<line x1="0" y1="0" x2="0" y2="14" stroke="#a1a1aa" stroke-width="1"/>
<polygon points="-4,12 4,12 0,18" fill="#a1a1aa"/>
</g>
<!-- Layer 1: Hardware -->
<g transform="translate(60, 481)">
<rect width="780" height="62" rx="10" fill="url(#hw-grad)" stroke="#71717a" stroke-width="1.2" stroke-opacity="0.6"/>
<text x="20" y="22" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#a1a1aa" letter-spacing="1.5" font-weight="600">LAYER 1 · HARDWARE</text>
<text x="20" y="44" font-family="'Segoe UI', sans-serif" font-size="14" fill="#f5f5f5">Single node · multi-node cluster · edge appliance · air-gapped rack · commodity x86 / ARM</text>
</g>
</svg>

After

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

+3 -3
View File
@@ -8,7 +8,7 @@ updates:
day: monday
timezone: Australia/Sydney
cooldown:
default-days: 1
default-days: 3
groups:
go-dependencies:
patterns:
@@ -22,7 +22,7 @@ updates:
day: monday
timezone: Australia/Sydney
cooldown:
default-days: 1
default-days: 3
ignore:
- dependency-name: "*"
update-types:
@@ -41,7 +41,7 @@ updates:
day: monday
timezone: Australia/Sydney
cooldown:
default-days: 1
default-days: 3
ignore:
- dependency-name: "@types/node"
update-types:
+63
View File
@@ -0,0 +1,63 @@
# shellcheck shell=bash
# Source from a workflow `run:` block:
# source "${GITHUB_WORKSPACE}/spinifex/.github/scripts/ci-fmt.sh"
#
# Provides colored banners + ::group::/::endgroup:: wrappers. ANSI escapes
# render in the GitHub Actions live log; the same lines also land in any
# artifact log (tee'd output) without breaking parsers because they only
# affect terminal display.
#
# Functions kept short and unindented on purpose — wide nested output wraps
# badly on narrow terminals.
if [[ -n "${GITHUB_ACTIONS:-}" ]] || [[ -t 1 ]]; then
_R=$'\033[0m'
_B=$'\033[1m' # bold
_D=$'\033[2m' # dim
_RED=$'\033[31m'
_GRN=$'\033[32m'
_YEL=$'\033[33m'
_BLU=$'\033[34m'
_CYA=$'\033[36m'
else
_R="" _B="" _D="" _RED="" _GRN="" _YEL="" _BLU="" _CYA=""
fi
# Single-line rule using box-drawing chars — narrow enough that GitHub's
# timestamp prefix + this rule still fit on an 80-col terminal.
# banner LABEL — bold cyan title with surrounding rule. Used at the start
# of an inherently multi-line phase (test run, artifact pull) so the live
# log has an obvious visual marker.
banner() {
printf '\n%s%s━━ %s ━━%s\n' "$_B" "$_CYA" "$*" "$_R"
}
# Status indicators. Single char prefix only — keeps width on narrow logs.
ok() { printf '%s✓%s %s\n' "$_GRN" "$_R" "$*"; }
bad() { printf '%s✗%s %s\n' "$_RED" "$_R" "$*"; }
warn() { printf '%s!%s %s\n' "$_YEL" "$_R" "$*"; }
note() { printf '%s·%s %s\n' "$_D" "$_R" "$*"; }
# group_open LABEL / group_close — thin wrapper around GitHub's collapse
# markers. No extra body printed; banner() is the visible inside-group
# heading when callers want one.
group_open() { echo "::group::$*"; }
group_close() { echo "::endgroup::"; }
# quiet_run LABEL LOG_FILE -- CMD ARGS …
# Run CMD redirected to LOG_FILE (append). Print one colored ok/✗ line.
# On failure, dump the last 80 lines of LOG_FILE inside a collapsed group
# so the failing tail lands in the UI without surfacing the whole apply.
quiet_run() {
local label="$1" log="$2"; shift 2
if "$@" >> "$log" 2>&1; then
ok "$label"
else
local rc=$?
bad "$label (rc=$rc) — last 80 lines of $(basename "$log"):"
group_open "tail $(basename "$log")"
tail -n 80 "$log"
group_close
return "$rc"
fi
}
+107
View File
@@ -0,0 +1,107 @@
name: E2E Bare-Metal
on:
workflow_dispatch:
permissions:
contents: read
statuses: write
env:
GIT_BRANCH: ${{ github.ref_name }}
jobs:
e2e_baremetal:
name: Bare-Metal Multi-Node E2E
runs-on: [ self-hosted, ci-baremetal ]
timeout-minutes: 120
concurrency:
group: e2e-baremetal-cluster
cancel-in-progress: false
env:
BM_ISO_CACHE: /var/cache/spinifex-iso-builder
steps:
- name: Clean Workspace
run: sudo rm -rf "$GITHUB_WORKSPACE"/*
- uses: actions/checkout@v6
- uses: actions/create-github-app-token@v3
id: app-token
with:
client-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
repositories: mulga
- uses: actions/checkout@v6
with:
repository: mulgadc/mulga
token: ${{ steps.app-token.outputs.token }}
sparse-checkout: |
scripts/iso-builder
scripts/baremetal
path: mulga
- name: Install SSH Key
run: |
echo "${{ secrets.BM_SSH_PRIVATE_KEY }}" > "${RUNNER_TEMP}/bm-ci"
chmod 0600 "${RUNNER_TEMP}/bm-ci"
ssh-keygen -y -f "${RUNNER_TEMP}/bm-ci" > "${RUNNER_TEMP}/bm-ci.pub"
- name: Build Artifacts
run: |
mulga/scripts/baremetal/bm-build-artifacts.sh \
"$GITHUB_WORKSPACE" \
"${RUNNER_TEMP}/spinifex-tests.tar" \
"${RUNNER_TEMP}/bm-bins"
- name: Build PXE Artifacts
run: |
sudo make -C mulga/scripts/iso-builder \
build-rootfs build-squashfs build-initramfs \
CACHE="${BM_ISO_CACHE}" \
SPINIFEX_BIN="${RUNNER_TEMP}/bm-bins/spx" \
INSTALLER_BIN="${RUNNER_TEMP}/bm-bins/spinifex-installer" \
SPINIFEX_SRC="${GITHUB_WORKSPACE}" \
VIPERBLOCK_SRC="${RUNNER_TEMP}/bm-bins/viperblock" \
VERSION="$(cd "$GITHUB_WORKSPACE" && git describe --tags --always 2>/dev/null || echo dev)"
- name: Write ePDU credentials
run: |
install -m 0600 /dev/null "${RUNNER_TEMP}/epdu.env"
cat >> "${RUNNER_TEMP}/epdu.env" <<EOF
EPDU_HOST=192.168.1.63
EPDU_SNMP_USER=ci-bmpower
EPDU_SNMP_SEC_LEVEL=authPriv
EPDU_SNMP_AUTH_PROTO=MD5
EPDU_SNMP_AUTH_PASS=${{ secrets.BM_EPDU_AUTH_PASS }}
EPDU_SNMP_PRIV_PROTO=DES
EPDU_SNMP_PRIV_PASS=${{ secrets.BM_EPDU_PRIV_PASS }}
EOF
- name: Cache VM images
run: |
UBUNTU_IMG="${BM_ISO_CACHE}/ubuntu-26.04-cloudimg-amd64.img"
if [ ! -f "$UBUNTU_IMG" ]; then
echo "Downloading Ubuntu 26.04 cloud image..."
sudo curl -fsSL -o "$UBUNTU_IMG" \
"https://cloud-images.ubuntu.com/resolute/current/resolute-server-cloudimg-amd64.img"
echo " Downloaded $(du -h "$UBUNTU_IMG" | cut -f1)"
else
echo "Ubuntu 26.04 cloud image cached ($(du -h "$UBUNTU_IMG" | cut -f1))"
fi
- name: Run Bare-Metal E2E
env:
BM_CI_PASSWORD: ${{ secrets.BM_CI_PASSWORD }}
BM_SSH_KEY: ${{ runner.temp }}/bm-ci
BM_EPDU_ENV: ${{ runner.temp }}/epdu.env
BM_UBUNTU_IMAGE: ${{ env.BM_ISO_CACHE }}/ubuntu-26.04-cloudimg-amd64.img
SPX_EXTERNAL_POOL: ${{ env.SPINIFEX_CI_POOL }}
run: |
sudo -E mulga/scripts/baremetal/run-baremetal-e2e.sh \
--vmlinuz "${BM_ISO_CACHE}/vmlinuz" \
--initrd "${BM_ISO_CACHE}/initrd.img" \
--squashfs "${BM_ISO_CACHE}/spinifex.squashfs" \
--tests-tar "${RUNNER_TEMP}/spinifex-tests.tar"
+229
View File
@@ -0,0 +1,229 @@
name: E2E DDIL
# Phase 1 trigger: workflow_dispatch only. Nightly cron + on-main-merge
# land in mulga-siv-5.8 once ≥4 scenarios are asserting. See
# docs/development/improvements/ddil-e2e-test-harness.md §4 / §Dependencies.
on:
workflow_dispatch:
inputs:
quarantined:
description: "Comma-separated scenario letters to quarantine (A..F)"
required: false
default: ""
permissions:
contents: read
env:
GIT_BRANCH: ${{ github.ref_name }}
jobs:
ddil:
name: DDIL Real Multi-Node
runs-on: [ self-hosted, ci-multi ]
# Budget: ~45m go test + tofu provision/bootstrap/teardown on ci-multi.
# In Phase 1 the scenarios all t.Skip, so the real-world runtime is
# dominated by provisioning; the 60m ceiling leaves headroom for
# Phase 2/3 when assertions come online.
timeout-minutes: 60
env:
TOFU_LOG: /tmp/e2e-tofu-ddil-${{ github.run_id }}.log
steps:
- name: Clean Workspace
run: sudo rm -rf "$GITHUB_WORKSPACE"/*
- uses: actions/create-github-app-token@v3
id: app-token
with:
client-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
repositories: mulga
# Full spinifex checkout: the Go harness must run on the runner
# (outside the cluster) so its SSH path survives scenario partitions
# that drop peer-to-peer cluster traffic. Running inside the cluster
# would self-sever when Scenario C isolates a node.
- uses: actions/checkout@v6
name: Checkout Spinifex
with:
path: spinifex
- uses: actions/checkout@v6
name: Checkout Mulga (tofu-cluster)
with:
repository: mulgadc/mulga
token: ${{ steps.app-token.outputs.token }}
sparse-checkout: scripts/tofu-cluster
path: mulga
- name: Install gotestsum
env:
GOTESTSUM_VERSION: "1.12.0"
run: |
# Prebuilt tarball, not `go install`: gotestsum v1.12.0 pulls
# x/tools v0.19.0 whose tokeninternal static-assert pegs a
# narrow Go window (~1.22). The self-hosted runner's Go falls
# outside that window, so source-install fails with
# `invalid array length -delta * delta`. A prebuilt binary
# sidesteps toolchain coupling entirely.
BIN_DIR="${RUNNER_TEMP}/gotestsum-bin"
mkdir -p "${BIN_DIR}"
if [ ! -x "${BIN_DIR}/gotestsum" ]; then
curl -fsSL \
"https://github.com/gotestyourself/gotestsum/releases/download/v${GOTESTSUM_VERSION}/gotestsum_${GOTESTSUM_VERSION}_linux_amd64.tar.gz" \
| tar -xz -C "${BIN_DIR}" gotestsum
fi
echo "${BIN_DIR}" >> "${GITHUB_PATH}"
"${BIN_DIR}/gotestsum" --version
- name: Build Install Artifacts
working-directory: mulga/scripts/tofu-cluster
run: |
./build-install-artifacts.sh "${GIT_BRANCH}" \
"${RUNNER_TEMP}/spinifex-distro.tar.gz" \
"${RUNNER_TEMP}/setup.sh" \
"${RUNNER_TEMP}/spinifex-tests.tar"
- name: Provision VMs
working-directory: mulga/scripts/tofu-cluster
run: |
set -euo pipefail
# shellcheck source=/dev/null
source "${GITHUB_WORKSPACE}/spinifex/.github/scripts/ci-fmt.sh"
export TF_VAR_git_branch="${GIT_BRANCH}"
quiet_run "tofu init" "$TOFU_LOG" tofu init -input=false
quiet_run "tofu workspace select" "$TOFU_LOG" tofu workspace select -or-create "${SPINIFEX_CI_ENV}"
quiet_run "tofu destroy (pre-clean)" "$TOFU_LOG" tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false || true
quiet_run "scrub-env (pre-clean)" "$TOFU_LOG" ./scrub-env.sh "${SPINIFEX_CI_ENV}" || true
quiet_run "tofu apply" "$TOFU_LOG" tofu apply -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false
ok "VMs provisioned"
- name: Bootstrap via Binary Install
working-directory: mulga/scripts/tofu-cluster
run: |
SPX_EXTERNAL_POOL="${SPINIFEX_CI_POOL}" \
./bootstrap-install.sh "${SPINIFEX_CI_ENV}" \
"${RUNNER_TEMP}/setup.sh" \
"${RUNNER_TEMP}/spinifex-distro.tar.gz" \
--test-tar "${RUNNER_TEMP}/spinifex-tests.tar"
- name: Run DDIL Scenarios
working-directory: mulga/scripts/tofu-cluster
run: |
set -o pipefail
INVENTORY=$(tofu output -json inventory)
SSH_KEY=$(echo "$INVENTORY" | jq -r '.ssh_key_path')
SSH_KEY="${SSH_KEY/#\~/$HOME}"
SSH_USER=$(echo "$INVENTORY" | jq -r '.ssh_user')
REGION=$(echo "$INVENTORY" | jq -r '.region')
# DDIL_NODES must be the cluster-traffic IPs: PartitionNode
# installs iptables DROP rules for each peer, so the runner's
# SSH source IP must differ from these. On single-NIC
# tofu-cluster, wan_ip == cluster IP and the runner's outbound
# IP is distinct — see e2e-ddil-test-harness.md §3 Scenario C.
NODE_IPS=$(echo "$INVENTORY" | jq -r '[.nodes[].wan_ip] | join(",")')
PRIMARY_IP=$(echo "$INVENTORY" | jq -r '.nodes[0].wan_ip')
echo "DDIL_NODES=${NODE_IPS}"
# Witness VMs talk to the cluster's AWS gateway via aws-sdk-go,
# which resolves credentials from the standard chain. spx admin
# init writes [spinifex] to ${SSH_USER}'s ~/.aws/credentials on
# node1 — pull them over SSH and export as the SDK's env-var
# entries so the harness can launch instances without depending
# on a cluster-bootstrapped profile being present on the runner.
CREDS=$(ssh -i "${SSH_KEY}" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
"${SSH_USER}@${PRIMARY_IP}" 'cat ~/.aws/credentials')
AWS_ACCESS_KEY_ID=$(echo "$CREDS" | awk -F '= *' '/^aws_access_key_id/ {print $2; exit}')
AWS_SECRET_ACCESS_KEY=$(echo "$CREDS" | awk -F '= *' '/^aws_secret_access_key/ {print $2; exit}')
if [ -z "$AWS_ACCESS_KEY_ID" ] || [ -z "$AWS_SECRET_ACCESS_KEY" ]; then
echo "::error::Failed to extract spinifex AWS credentials from ${PRIMARY_IP}"
exit 1
fi
# Guest SSH key: bootstrap-install.sh imports spinifex-key into EC2;
# the matching private material was generated on the primary node at
# ~/.ssh/spinifex-key. Pull it to the runner so the harness can SSH
# the witness via its MapPublicIpOnLaunch-assigned EIP.
GUEST_KEY="${RUNNER_TEMP}/spinifex-key"
scp -i "${SSH_KEY}" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
"${SSH_USER}@${PRIMARY_IP}:~/.ssh/spinifex-key" "${GUEST_KEY}"
chmod 600 "${GUEST_KEY}"
mkdir -p "${RUNNER_TEMP}/ddil-results"
cd "${GITHUB_WORKSPACE}/spinifex"
# gotestsum: human-readable PASS/FAIL/SKIP lines in Actions stdout,
# plus machine-readable jsonfile + JUnit XML artifacts. The JUnit
# file feeds the junit-report composite action below so failures
# become summary table rows + workflow annotations.
# shellcheck source=/dev/null
source "${GITHUB_WORKSPACE}/spinifex/.github/scripts/ci-fmt.sh"
banner "DDIL scenarios"
DDIL_NODES="${NODE_IPS}" \
DDIL_SSH_USER="${SSH_USER}" \
DDIL_SSH_KEY="${SSH_KEY}" \
DDIL_GUEST_SSH_KEY="${GUEST_KEY}" \
DDIL_QUARANTINED="${{ inputs.quarantined }}" \
AWS_REGION="${REGION}" \
AWS_ACCESS_KEY_ID="${AWS_ACCESS_KEY_ID}" \
AWS_SECRET_ACCESS_KEY="${AWS_SECRET_ACCESS_KEY}" \
gotestsum --jsonfile "${RUNNER_TEMP}/ddil-results/results.json" \
--junitfile "${RUNNER_TEMP}/ddil-results/junit-ddil.xml" \
--format testname \
-- -tags=e2e -timeout 45m ./tests/e2e/ddil/scenarios/...
rc=$?
if [[ $rc -eq 0 ]]; then ok "all scenarios passed"; else bad "scenarios failed (rc=$rc)"; fi
exit $rc
- name: Publish test summary
if: always()
uses: ./spinifex/.github/actions/junit-report
with:
junit-glob: ${{ runner.temp }}/ddil-results/junit-*.xml
title: "DDIL scenario results"
- name: Collect Cluster Logs
if: failure()
working-directory: mulga/scripts/tofu-cluster
run: |
# Pull spinifex-* journals + QEMU process listings from every
# node into the artifact bundle so failures can be triaged
# without re-provisioning the cluster. Best-effort — a missing
# node should not mask the original test failure.
set +e
INVENTORY=$(tofu output -json inventory)
SSH_KEY=$(echo "$INVENTORY" | jq -r '.ssh_key_path')
SSH_KEY="${SSH_KEY/#\~/$HOME}"
SSH_USER=$(echo "$INVENTORY" | jq -r '.ssh_user')
LOG_DIR="${RUNNER_TEMP}/ddil-results/cluster-logs"
mkdir -p "${LOG_DIR}"
# shellcheck source=/dev/null
source "${GITHUB_WORKSPACE}/spinifex/.github/scripts/ci-fmt.sh"
mapfile -t NODE_IPS < <(echo "$INVENTORY" | jq -r '.nodes[].wan_ip')
for ip in "${NODE_IPS[@]}"; do
SSH="ssh -i ${SSH_KEY} -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ${SSH_USER}@${ip}"
${SSH} 'sudo journalctl -u "spinifex-*" --no-pager -n 5000' > "${LOG_DIR}/${ip}-spinifex.log" 2>&1
${SSH} 'ps auxw | grep -E "qemu-system|spinifex" | grep -v grep' > "${LOG_DIR}/${ip}-procs.txt" 2>&1
${SSH} 'ls -la /var/lib/spinifex 2>/dev/null; ls -la /var/log/spinifex 2>/dev/null' > "${LOG_DIR}/${ip}-state.txt" 2>&1
ok "$ip: $(wc -l < "${LOG_DIR}/${ip}-spinifex.log") journal lines, $(wc -l < "${LOG_DIR}/${ip}-procs.txt") procs"
done
- name: Upload DDIL Results
if: always()
uses: actions/upload-artifact@v7
with:
name: ddil-results-${{ github.run_id }}
path: ${{ runner.temp }}/ddil-results
retention-days: 30
- name: Teardown
if: always()
working-directory: mulga/scripts/tofu-cluster
run: |
set -euo pipefail
# shellcheck source=/dev/null
source "${GITHUB_WORKSPACE}/spinifex/.github/scripts/ci-fmt.sh"
quiet_run "tofu workspace select" "$TOFU_LOG" tofu workspace select -or-create "${SPINIFEX_CI_ENV}"
quiet_run "tofu destroy" "$TOFU_LOG" tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false
quiet_run "scrub-env" "$TOFU_LOG" ./scrub-env.sh "${SPINIFEX_CI_ENV}" || true
ok "teardown complete"
File diff suppressed because it is too large Load Diff
+218
View File
@@ -0,0 +1,218 @@
name: E2E Nightly
on:
schedule:
- cron: "0 14 * * 0-4" # 00:00 Mon-Fri AEST = 14:00 Sun-Thu UTC
workflow_dispatch:
inputs:
cells:
description: "Comma-separated cell numbers to run (blank = all)"
required: false
permissions:
contents: read
# Scheduled runs are loaded from the default branch (main), so the schedule
# trigger must stay in sync on main for cron to fire.
env:
GIT_BRANCH: ${{ github.event_name == 'schedule' && 'dev' || github.ref_name }}
UBUNTU_GOLD_IMAGE: /mnt/disk1/gold-images/spinifex-image-builder-ubuntu-24.04.qcow2
jobs:
matrix:
name: "cell-${{ matrix.cell }} (${{ matrix.topo }}/${{ matrix.net }}/${{
matrix.host }}${{ matrix.extra && format('/{0}', matrix.extra) || '' }})"
strategy:
fail-fast: false
max-parallel: 3
matrix:
include:
- { cell: 1, install: binary, topo: single, net: dhcp, host: debian-13, arch: x86_64, runner: ci-single, extra: "" }
- { cell: 2, install: binary, topo: single, net: static, host: ubuntu-24.04, arch: x86_64, runner: ci-single, extra: "" }
- { cell: 3, install: binary, topo: single, net: dhcp, host: ubuntu-24.04, arch: x86_64, runner: ci-single, extra: "" }
- { cell: 8, install: binary, topo: real-multi, net: dhcp, host: debian-13, arch: x86_64, runner: ci-multi, extra: "" }
- { cell: 9, install: binary, topo: real-multi, net: static, host: ubuntu-24.04, arch: x86_64, runner: ci-multi, extra: "" }
- { cell: 10, install: binary, topo: real-multi, net: dhcp, host: ubuntu-24.04, arch: x86_64, runner: ci-multi, extra: "" }
- { cell: 17, install: binary, topo: single, net: static, host: debian-13, arch: x86_64, runner: ci-pseudo, extra: "tofu-examples" }
- { cell: 18, install: binary, topo: single, net: static, host: debian-13, arch: x86_64, runner: ci-single, extra: "reboot-single" }
runs-on: [ self-hosted, "${{ matrix.runner }}" ]
timeout-minutes: 35
env:
TOFU_LOG: /tmp/e2e-nightly-cell-${{ matrix.cell }}-${{ github.run_id }}.log
steps:
# blank or scheduled run = full matrix.
# Input must be comma-separated with no whitespace (e.g. '1,2,17', not '1, 2').
- name: Gate on Dispatch Input
id: gate
env:
CELL: ${{ matrix.cell }}
CELLS: ${{ github.event_name == 'workflow_dispatch' && inputs.cells || '' }}
run: |
if [ -z "$CELLS" ] || [[ ",${CELLS}," == *",${CELL},"* ]]; then
echo "selected=true" >> "$GITHUB_OUTPUT"
echo "cell ${CELL} selected"
else
echo "selected=false" >> "$GITHUB_OUTPUT"
echo "cell ${CELL} not in '${CELLS}' — subsequent steps will skip"
fi
- name: Clean Workspace
if: steps.gate.outputs.selected == 'true'
run: sudo rm -rf "$GITHUB_WORKSPACE"/*
- uses: actions/create-github-app-token@v3
if: steps.gate.outputs.selected == 'true'
id: app-token
with:
client-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
repositories: mulga
- uses: actions/checkout@v6
if: steps.gate.outputs.selected == 'true'
with:
repository: mulgadc/mulga
token: ${{ steps.app-token.outputs.token }}
sparse-checkout: scripts/tofu-cluster
path: mulga
- name: Build Install Artifacts
if: steps.gate.outputs.selected == 'true'
working-directory: mulga/scripts/tofu-cluster
run: |
./build-install-artifacts.sh "${GIT_BRANCH}" \
"${RUNNER_TEMP}/spinifex-distro.tar.gz" \
"${RUNNER_TEMP}/setup.sh" \
"${RUNNER_TEMP}/spinifex-tests.tar"
- name: Provision VMs
if: steps.gate.outputs.selected == 'true'
working-directory: mulga/scripts/tofu-cluster
env:
MATRIX_HOST: ${{ matrix.host }}
run: |
export TF_VAR_git_branch="${GIT_BRANCH}"
TOFU_VAR_ARGS=""
if [ "$MATRIX_HOST" = "ubuntu-24.04" ]; then
TOFU_VAR_ARGS="-var=host_image_override=${UBUNTU_GOLD_IMAGE}"
fi
tofu init -input=false >> "$TOFU_LOG" 2>&1
tofu workspace select -or-create "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1
tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars ${TOFU_VAR_ARGS} -auto-approve -input=false >> "$TOFU_LOG" 2>&1 || true
./scrub-env.sh "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1 || true
tofu apply -var-file=envs/${SPINIFEX_CI_ENV}.tfvars ${TOFU_VAR_ARGS} -auto-approve -input=false >> "$TOFU_LOG" 2>&1
- name: Bootstrap via Binary Install
if: steps.gate.outputs.selected == 'true'
working-directory: mulga/scripts/tofu-cluster
env:
MATRIX_NET: ${{ matrix.net }}
run: |
if [ "$MATRIX_NET" = "dhcp" ]; then
unset SPX_EXTERNAL_POOL
export SPX_EXTERNAL_SOURCE=dhcp
else
export SPX_EXTERNAL_POOL="${SPINIFEX_CI_POOL}"
fi
./bootstrap-install.sh "${SPINIFEX_CI_ENV}" \
"${RUNNER_TEMP}/setup.sh" \
"${RUNNER_TEMP}/spinifex-distro.tar.gz" \
--test-tar "${RUNNER_TEMP}/spinifex-tests.tar"
- name: Run Single Node E2E
if: steps.gate.outputs.selected == 'true' && matrix.topo == 'single' && matrix.extra == ''
working-directory: mulga/scripts/tofu-cluster
run: |
INVENTORY=$(tofu output -json inventory)
WAN_IP=$(echo "$INVENTORY" | jq -r '.nodes[0].wan_ip')
SSH_KEY=$(echo "$INVENTORY" | jq -r '.ssh_key_path')
SSH_KEY="${SSH_KEY/#\~/$HOME}"
ssh -o StrictHostKeyChecking=no -i "$SSH_KEY" tf-user@${WAN_IP} \
"cd ~/e2e-tests && ./run-e2e.sh && ./run-cert-e2e.sh && ENDPOINT=https://${WAN_IP}:9999 ./run-lb-e2e.sh"
- name: Run Multi Node E2E
if: steps.gate.outputs.selected == 'true' && matrix.topo == 'real-multi'
working-directory: mulga/scripts/tofu-cluster
run: |
INVENTORY=$(tofu output -json inventory)
NODE1_IP=$(echo "$INVENTORY" | jq -r '.nodes[0].wan_ip')
SSH_KEY=$(echo "$INVENTORY" | jq -r '.ssh_key_path')
SSH_KEY="${SSH_KEY/#\~/$HOME}"
NODE_IPS=""
NODE_COUNT=$(echo "$INVENTORY" | jq '.nodes | length')
for i in $(seq 0 $((NODE_COUNT - 1))); do
NODE_IPS="${NODE_IPS} $(echo "$INVENTORY" | jq -r ".nodes[$i].wan_ip")"
done
ssh -o StrictHostKeyChecking=no -i "$SSH_KEY" tf-user@${NODE1_IP} \
"cd ~/e2e-tests && ./run-multinode-e2e.sh ${NODE_IPS} && ./run-cert-e2e.sh --multinode ${NODE_IPS} && ENDPOINT=https://${NODE1_IP}:9999 ./run-lb-e2e.sh --nodes ${NODE_IPS}"
- name: Run Tofu Workbooks
if: steps.gate.outputs.selected == 'true' && matrix.extra == 'tofu-examples'
working-directory: mulga/scripts/tofu-cluster
run: |
INVENTORY=$(tofu output -json inventory)
WAN_IP=$(echo "$INVENTORY" | jq -r '.nodes[0].wan_ip')
SSH_KEY=$(echo "$INVENTORY" | jq -r '.ssh_key_path')
SSH_KEY="${SSH_KEY/#\~/$HOME}"
ssh -o StrictHostKeyChecking=no -i "$SSH_KEY" tf-user@${WAN_IP} \
"cd ~/e2e-tests && ./run-tofu-examples-e2e.sh"
- name: Run Reboot Resilience E2E
if: steps.gate.outputs.selected == 'true' && matrix.extra == 'reboot-single'
working-directory: mulga/scripts/tofu-cluster
# Driver runs from the runner (NOT via ssh-and-run) so it survives the
# systemctl-reboot-induced ssh disconnect mid-run.
run: |
INVENTORY=$(tofu output -json inventory)
WAN_IP=$(echo "$INVENTORY" | jq -r '.nodes[0].wan_ip')
SSH_KEY=$(echo "$INVENTORY" | jq -r '.ssh_key_path')
SSH_KEY="${SSH_KEY/#\~/$HOME}"
REBOOT_DIR="${RUNNER_TEMP}/reboot-e2e"
mkdir -p "$REBOOT_DIR"
tar xf "${RUNNER_TEMP}/spinifex-tests.tar" -C "$REBOOT_DIR"
chmod +x "$REBOOT_DIR/run-reboot-e2e.sh"
WAN_IP="$WAN_IP" SSH_KEY="$SSH_KEY" "$REBOOT_DIR/run-reboot-e2e.sh"
- name: Dump Cluster Journals on Failure
if: failure() && steps.gate.outputs.selected == 'true'
working-directory: mulga/scripts/tofu-cluster
run: |
INVENTORY=$(tofu output -json inventory 2>/dev/null) || { echo "no inventory; skipping"; exit 0; }
SSH_KEY=$(echo "$INVENTORY" | jq -r '.ssh_key_path')
SSH_KEY="${SSH_KEY/#\~/$HOME}"
NODE_COUNT=$(echo "$INVENTORY" | jq '.nodes | length')
for i in $(seq 0 $((NODE_COUNT - 1))); do
IP=$(echo "$INVENTORY" | jq -r ".nodes[$i].wan_ip")
echo "===== node$((i+1)) ($IP) ====="
for svc in spinifex-nats spinifex-predastore spinifex-viperblock spinifex-daemon spinifex-awsgw spinifex-vpcd dhcpcd; do
echo "--- $svc ---"
ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5 -i "$SSH_KEY" tf-user@${IP} \
"sudo journalctl -u $svc --no-pager -n 200 2>/dev/null || echo '(not found)'" 2>&1 || echo "(node unreachable)"
done
echo "--- DHCP client state ---"
ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5 -i "$SSH_KEY" tf-user@${IP} \
"systemctl is-enabled dhcpcd.service 2>/dev/null; systemctl is-active dhcpcd.service 2>/dev/null; sudo ss -lupn 'sport = 68' 2>/dev/null; pgrep -laf dhcpcd 2>/dev/null" 2>&1 || echo "(node unreachable)"
done
- name: Teardown
if: always()
working-directory: mulga/scripts/tofu-cluster
env:
MATRIX_HOST: ${{ matrix.host }}
run: |
TOFU_VAR_ARGS=""
if [ "$MATRIX_HOST" = "ubuntu-24.04" ]; then
TOFU_VAR_ARGS="-var=host_image_override=${UBUNTU_GOLD_IMAGE}"
fi
tofu workspace select -or-create "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1
tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars ${TOFU_VAR_ARGS} -auto-approve -input=false >> "$TOFU_LOG" 2>&1
./scrub-env.sh "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1 || true
+104 -57
View File
@@ -1,4 +1,4 @@
name: Spinifex E2E Tests
name: E2E
on:
push:
@@ -13,6 +13,14 @@ on:
- tests/e2e/**
- .github/workflows/e2e.yml
workflow_dispatch:
inputs:
sha:
description: "PR head SHA to report status on"
required: false
permissions:
contents: read
statuses: write
env:
GIT_BRANCH: ${{ github.ref_name }}
@@ -20,18 +28,18 @@ env:
jobs:
e2e_single:
name: Single-Node E2E
runs-on: [self-hosted, ci-single]
timeout-minutes: 25
runs-on: [ self-hosted, ci-single ]
timeout-minutes: 35
env:
TOFU_LOG: /tmp/e2e-tofu-single-${{ github.run_id }}.log
steps:
- name: Clean workspace
run: rm -rf "$GITHUB_WORKSPACE"/*
- name: Clean Workspace
run: sudo rm -rf "$GITHUB_WORKSPACE"/*
- uses: actions/create-github-app-token@v3
id: app-token
with:
app-id: ${{ secrets.APP_ID }}
client-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
repositories: mulga
@@ -42,27 +50,32 @@ jobs:
sparse-checkout: scripts/tofu-cluster
path: mulga
- name: Clone and build
- name: Build Install Artifacts
working-directory: mulga/scripts/tofu-cluster
run: ./build-artifacts.sh "${GIT_BRANCH}" "${RUNNER_TEMP}/spinifex-artifacts.tar"
run: |
./build-install-artifacts.sh "${GIT_BRANCH}" \
"${RUNNER_TEMP}/spinifex-distro.tar.gz" \
"${RUNNER_TEMP}/setup.sh" \
"${RUNNER_TEMP}/spinifex-tests.tar"
- name: Provision VM
working-directory: mulga/scripts/tofu-cluster
run: |
export TF_VAR_git_branch="${GIT_BRANCH}"
tofu init -input=false >> "$TOFU_LOG" 2>&1
tofu workspace select -or-create env3 >> "$TOFU_LOG" 2>&1
tofu destroy -var-file=envs/env3.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1 || true
./scrub-env.sh env3 >> "$TOFU_LOG" 2>&1 || true
tofu apply -var-file=envs/env3.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1
tofu workspace select -or-create "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1
tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1 || true
./scrub-env.sh "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1 || true
tofu apply -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1
- name: Bootstrap Cluster
- name: Bootstrap via Binary Install
working-directory: mulga/scripts/tofu-cluster
run: |
SPX_PREBUILT=true \
SPX_ARTIFACT_TAR="${RUNNER_TEMP}/spinifex-artifacts.tar" \
SPX_EXTERNAL_POOL="192.168.1.230-192.168.1.240" \
./bootstrap.sh env3 >> "$TOFU_LOG" 2>&1
SPX_EXTERNAL_POOL="${SPINIFEX_CI_POOL}" \
./bootstrap-install.sh "${SPINIFEX_CI_ENV}" \
"${RUNNER_TEMP}/setup.sh" \
"${RUNNER_TEMP}/spinifex-distro.tar.gz" \
--test-tar "${RUNNER_TEMP}/spinifex-tests.tar"
- name: Run E2E Tests
working-directory: mulga/scripts/tofu-cluster
@@ -72,30 +85,30 @@ jobs:
SSH_KEY=$(echo "$INVENTORY" | jq -r '.ssh_key_path')
SSH_KEY="${SSH_KEY/#\~/$HOME}"
ssh -o StrictHostKeyChecking=no -i "$SSH_KEY" tf-user@${WAN_IP} \
'cd ~/Development/mulga/spinifex && ./tests/e2e/run-e2e.sh && ./tests/e2e/run-cert-e2e.sh'
"cd ~/e2e-tests && ./run-e2e.sh && ./run-cert-e2e.sh && ENDPOINT=https://${WAN_IP}:9999 ./run-lb-e2e.sh"
- name: Teardown
if: always()
working-directory: mulga/scripts/tofu-cluster
run: |
tofu workspace select -or-create env3 >> "$TOFU_LOG" 2>&1
tofu destroy -var-file=envs/env3.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1
./scrub-env.sh env3 >> "$TOFU_LOG" 2>&1 || true
tofu workspace select -or-create "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1
tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1
./scrub-env.sh "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1 || true
e2e_pseudo_multi:
name: Pseudo Multi-Node E2E
runs-on: [self-hosted, ci-pseudo]
timeout-minutes: 30
runs-on: [ self-hosted, ci-pseudo ]
timeout-minutes: 25
env:
TOFU_LOG: /tmp/e2e-tofu-pseudo-${{ github.run_id }}.log
steps:
- name: Clean workspace
run: rm -rf "$GITHUB_WORKSPACE"/*
- name: Clean Workspace
run: sudo rm -rf "$GITHUB_WORKSPACE"/*
- uses: actions/create-github-app-token@v3
id: app-token
with:
app-id: ${{ secrets.APP_ID }}
client-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
repositories: mulga
@@ -106,26 +119,27 @@ jobs:
sparse-checkout: scripts/tofu-cluster
path: mulga
- name: Clone and build
- name: Clone and Build
working-directory: mulga/scripts/tofu-cluster
run: ./build-artifacts.sh "${GIT_BRANCH}" "${RUNNER_TEMP}/spinifex-artifacts.tar"
run: ./build-artifacts.sh "${GIT_BRANCH}"
"${RUNNER_TEMP}/spinifex-artifacts.tar"
- name: Provision VM
working-directory: mulga/scripts/tofu-cluster
run: |
export TF_VAR_git_branch="${GIT_BRANCH}"
tofu init -input=false >> "$TOFU_LOG" 2>&1
tofu workspace select -or-create env4 >> "$TOFU_LOG" 2>&1
tofu destroy -var-file=envs/env4.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1 || true
./scrub-env.sh env4 >> "$TOFU_LOG" 2>&1 || true
tofu apply -var-file=envs/env4.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1
tofu workspace select -or-create "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1
tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1 || true
./scrub-env.sh "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1 || true
tofu apply -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1
- name: Bootstrap Cluster
working-directory: mulga/scripts/tofu-cluster
run: |
SPX_PREBUILT=true \
SPX_ARTIFACT_TAR="${RUNNER_TEMP}/spinifex-artifacts.tar" \
./bootstrap.sh env4 >> "$TOFU_LOG" 2>&1
./bootstrap.sh "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1
- name: Run E2E Tests
working-directory: mulga/scripts/tofu-cluster
@@ -135,30 +149,30 @@ jobs:
SSH_KEY=$(echo "$INVENTORY" | jq -r '.ssh_key_path')
SSH_KEY="${SSH_KEY/#\~/$HOME}"
ssh -o StrictHostKeyChecking=no -i "$SSH_KEY" tf-user@${WAN_IP} \
'cd ~/Development/mulga/spinifex && SPX_EXTERNAL_POOL="192.168.1.220-192.168.1.229" ./tests/e2e/run-pseudo-multinode-e2e.sh'
"cd ~/Development/mulga/spinifex && SPX_EXTERNAL_POOL=\"${SPINIFEX_CI_POOL}\" ./tests/e2e/run-pseudo-multinode-e2e.sh"
- name: Teardown
if: always()
working-directory: mulga/scripts/tofu-cluster
run: |
tofu workspace select -or-create env4 >> "$TOFU_LOG" 2>&1
tofu destroy -var-file=envs/env4.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1
./scrub-env.sh env4 >> "$TOFU_LOG" 2>&1 || true
tofu workspace select -or-create "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1
tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1
./scrub-env.sh "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1 || true
e2e_real_multi:
name: Real Multi-Node E2E
runs-on: [self-hosted, ci-multi]
timeout-minutes: 25
runs-on: [ self-hosted, ci-multi ]
timeout-minutes: 30
env:
TOFU_LOG: /tmp/e2e-tofu-multi-${{ github.run_id }}.log
steps:
- name: Clean workspace
run: rm -rf "$GITHUB_WORKSPACE"/*
- name: Clean Workspace
run: sudo rm -rf "$GITHUB_WORKSPACE"/*
- uses: actions/create-github-app-token@v3
id: app-token
with:
app-id: ${{ secrets.APP_ID }}
client-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
repositories: mulga
@@ -169,27 +183,32 @@ jobs:
sparse-checkout: scripts/tofu-cluster
path: mulga
- name: Clone and build
- name: Build Install Artifacts
working-directory: mulga/scripts/tofu-cluster
run: ./build-artifacts.sh "${GIT_BRANCH}" "${RUNNER_TEMP}/spinifex-artifacts.tar"
run: |
./build-install-artifacts.sh "${GIT_BRANCH}" \
"${RUNNER_TEMP}/spinifex-distro.tar.gz" \
"${RUNNER_TEMP}/setup.sh" \
"${RUNNER_TEMP}/spinifex-tests.tar"
- name: Provision VMs
working-directory: mulga/scripts/tofu-cluster
run: |
export TF_VAR_git_branch="${GIT_BRANCH}"
tofu init -input=false >> "$TOFU_LOG" 2>&1
tofu workspace select -or-create env2 >> "$TOFU_LOG" 2>&1
tofu destroy -var-file=envs/env2.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1 || true
./scrub-env.sh env2 >> "$TOFU_LOG" 2>&1 || true
tofu apply -var-file=envs/env2.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1
tofu workspace select -or-create "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1
tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1 || true
./scrub-env.sh "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1 || true
tofu apply -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1
- name: Bootstrap Cluster
- name: Bootstrap via Binary Install
working-directory: mulga/scripts/tofu-cluster
run: |
SPX_PREBUILT=true \
SPX_ARTIFACT_TAR="${RUNNER_TEMP}/spinifex-artifacts.tar" \
SPX_EXTERNAL_POOL="192.168.1.240-192.168.1.250" \
./bootstrap.sh env2 >> "$TOFU_LOG" 2>&1
SPX_EXTERNAL_POOL="${SPINIFEX_CI_POOL}" \
./bootstrap-install.sh "${SPINIFEX_CI_ENV}" \
"${RUNNER_TEMP}/setup.sh" \
"${RUNNER_TEMP}/spinifex-distro.tar.gz" \
--test-tar "${RUNNER_TEMP}/spinifex-tests.tar"
- name: Run E2E Tests
working-directory: mulga/scripts/tofu-cluster
@@ -206,13 +225,41 @@ jobs:
NODE_IPS="${NODE_IPS} $(echo "$INVENTORY" | jq -r ".nodes[$i].wan_ip")"
done
NODE2_IP=$(echo "$INVENTORY" | jq -r '.nodes[1].wan_ip')
ssh -o StrictHostKeyChecking=no -i "$SSH_KEY" tf-user@${NODE1_IP} \
"cd ~/Development/mulga/spinifex && ./tests/e2e/run-multinode-e2e.sh ${NODE_IPS} && ./tests/e2e/run-cert-e2e.sh --multinode ${NODE_IPS}"
"cd ~/e2e-tests && ./run-multinode-e2e.sh ${NODE_IPS} && ./run-cert-e2e.sh --multinode ${NODE_IPS} && ENDPOINT=https://${NODE1_IP}:9999 ./run-lb-e2e.sh --nodes ${NODE_IPS}"
- name: Teardown
if: always()
working-directory: mulga/scripts/tofu-cluster
run: |
tofu workspace select -or-create env2 >> "$TOFU_LOG" 2>&1
tofu destroy -var-file=envs/env2.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1
./scrub-env.sh env2 >> "$TOFU_LOG" 2>&1 || true
tofu workspace select -or-create "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1
tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1
./scrub-env.sh "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1 || true
e2e_status:
name: Report E2E Status
if: always() && inputs.sha
needs: [ e2e_single, e2e_pseudo_multi, e2e_real_multi ]
runs-on: [ self-hosted, ci-single ]
timeout-minutes: 5
steps:
- name: Report Status to PR
env:
GH_TOKEN: ${{ github.token }}
run: |
if [[ "${{ needs.e2e_single.result }}" == "success" && \
"${{ needs.e2e_pseudo_multi.result }}" == "success" && \
"${{ needs.e2e_real_multi.result }}" == "success" ]]; then
STATE="success"
DESC="All E2E suites passed"
else
STATE="failure"
DESC="E2E failed: single=${{ needs.e2e_single.result }} pseudo=${{ needs.e2e_pseudo_multi.result }} multi=${{ needs.e2e_real_multi.result }}"
fi
gh api "repos/${{ github.repository }}/statuses/${{ inputs.sha }}" \
-f state="$STATE" \
-f context=e2e \
-f description="$DESC" \
-f target_url="${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}"
+176 -30
View File
@@ -2,7 +2,7 @@ name: Release
on:
push:
tags: ["v*"]
tags: [ "v*" ]
permissions:
contents: write
@@ -31,6 +31,14 @@ jobs:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version: '1.26.3'
- name: Build microVM artifacts (lb-agent + kernel + initramfs)
run: cd spinifex && make build-microvm-image
- name: Build amd64 Tarball
env:
VERSION: ${{ github.ref_name }}
@@ -82,7 +90,7 @@ jobs:
spinifex/dist/spinifex-${{ github.ref_name }}-linux-arm64.tar.gz.sha256
release:
needs: [distro-amd64, distro-arm64]
needs: [ distro-amd64, distro-arm64 ]
runs-on: ubuntu-24.04
steps:
- name: Download amd64 Artifacts
@@ -98,7 +106,7 @@ jobs:
path: artifacts
- name: Create Draft GitHub Release
uses: softprops/action-gh-release@v2
uses: softprops/action-gh-release@v3
with:
draft: true
files: |
@@ -115,7 +123,7 @@ jobs:
verify:
needs: release
runs-on: [self-hosted, ci-single]
runs-on: [ self-hosted, ci-single ]
timeout-minutes: 25
env:
TOFU_LOG: /tmp/release-verify-${{ github.run_id }}.log
@@ -128,7 +136,7 @@ jobs:
- uses: actions/create-github-app-token@v3
id: app-token
with:
app-id: ${{ secrets.APP_ID }}
client-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
repositories: mulga
@@ -149,40 +157,30 @@ jobs:
working-directory: mulga/scripts/tofu-cluster
run: |
tofu init -input=false >> "$TOFU_LOG" 2>&1
tofu workspace select -or-create env3 >> "$TOFU_LOG" 2>&1
tofu destroy -var-file=envs/env3.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1 || true
./scrub-env.sh env3 >> "$TOFU_LOG" 2>&1 || true
tofu apply -var-file=envs/env3.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1
tofu workspace select -or-create "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1
tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1 || true
./scrub-env.sh "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1 || true
tofu apply -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1
- name: Bootstrap via Binary Install
working-directory: mulga/scripts/tofu-cluster
run: |
SETUP_SH=$(find "${GITHUB_WORKSPACE}/release-assets" -name "setup.sh" | head -1)
TARBALL=$(find "${GITHUB_WORKSPACE}/release-assets" -name "*-linux-amd64.tar.gz" | head -1)
./bootstrap-install.sh env3 "$SETUP_SH" "$TARBALL"
- name: Run Smoke Test
working-directory: mulga/scripts/tofu-cluster
run: |
INVENTORY=$(tofu output -json inventory)
WAN_IP=$(echo "$INVENTORY" | jq -r '.nodes[0].wan_ip')
SSH_KEY=$(echo "$INVENTORY" | jq -r '.ssh_key_path')
SSH_KEY="${SSH_KEY/#\~/$HOME}"
scp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR \
-i "$SSH_KEY" \
smoke-test-install.sh \
"tf-user@${WAN_IP}:/tmp/smoke-test.sh"
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR \
-i "$SSH_KEY" "tf-user@${WAN_IP}" \
'bash /tmp/smoke-test.sh'
./bootstrap-install.sh "${SPINIFEX_CI_ENV}" "$SETUP_SH" "$TARBALL"
- name: Promote Release
if: success()
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release edit "$TAG" --repo "$REPO" --draft=false
echo "Release ${TAG} promoted to latest"
if [[ "$TAG" =~ -(dev|rc|alpha|beta) ]]; then
gh release edit "$TAG" --repo "$REPO" --draft=false --prerelease
echo "Release ${TAG} promoted as prerelease"
else
gh release edit "$TAG" --repo "$REPO" --draft=false
echo "Release ${TAG} promoted to latest"
fi
- name: Mark Release Failed
if: failure()
@@ -199,6 +197,154 @@ jobs:
if: always()
working-directory: mulga/scripts/tofu-cluster
run: |
tofu workspace select -or-create env3 >> "$TOFU_LOG" 2>&1
tofu destroy -var-file=envs/env3.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1
./scrub-env.sh env3 >> "$TOFU_LOG" 2>&1 || true
tofu workspace select -or-create "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1
tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1
./scrub-env.sh "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1 || true
iso:
needs: release
runs-on: [ self-hosted, ci-single ]
timeout-minutes: 90
steps:
- name: Clean Workspace
run: |
ROOTFS="${GITHUB_WORKSPACE}/mulga/scripts/iso-builder/.cache/rootfs"
# Mirror make clean's umount sequence unconditionally — lazy unmount
# detaches immediately even if the mount is busy, so rm -rf is safe.
sudo umount -l -R "${ROOTFS}" 2>/dev/null || true
sudo umount -l "${ROOTFS}/dev/pts" 2>/dev/null || true
sudo umount -l "${ROOTFS}/dev" 2>/dev/null || true
sudo umount -l "${ROOTFS}/sys" 2>/dev/null || true
sudo umount -l "${ROOTFS}/proc" 2>/dev/null || true
sudo rm -rf "$GITHUB_WORKSPACE"/*
- uses: actions/create-github-app-token@v3
id: app-token
with:
client-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
repositories: mulga
- name: Checkout Spinifex
uses: actions/checkout@v6
with:
path: spinifex
ref: ${{ github.ref }}
- name: Checkout Predastore
uses: actions/checkout@v6
with:
repository: mulgadc/predastore
path: predastore
- name: Checkout Viperblock
uses: actions/checkout@v6
with:
repository: mulgadc/viperblock
path: viperblock
- name: Checkout Mulga iso-builder
uses: actions/checkout@v6
with:
repository: mulgadc/mulga
token: ${{ steps.app-token.outputs.token }}
sparse-checkout: scripts/iso-builder
path: mulga
- name: Clean ISO builder cache
run: sudo make -C mulga/scripts/iso-builder clean
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version-file: spinifex/go.mod
cache: false
- name: Configure Go workspace
run: go work init ./spinifex ./predastore ./viperblock
- name: Install ISO builder dependencies
run: |
sudo apt-get update -qq
sudo apt-get install -y \
debootstrap squashfs-tools xorriso grub-pc-bin grub-efi-amd64-bin \
grub-common mtools busybox-static ovmf zstd \
nbdkit nbdkit-plugin-dev pkg-config gcc
- name: Build spinifex binaries
run: cd spinifex && make build
- name: Build viperblock
run: cd viperblock && make build
- name: Build release ISO
id: build-iso
env:
VERSION: ${{ github.ref_name }}
run: |
sudo make -C mulga/scripts/iso-builder release \
VERSION="${VERSION}" \
SPINIFEX_SRC="${GITHUB_WORKSPACE}/spinifex" \
VIPERBLOCK_SRC="${GITHUB_WORKSPACE}/viperblock" \
SPINIFEX_BIN="${GITHUB_WORKSPACE}/spinifex/bin/spx" \
INSTALLER_BIN="${GITHUB_WORKSPACE}/spinifex/bin/spinifex-installer"
- name: Wait for verify
id: wait-verify
if: steps.build-iso.outcome == 'success'
continue-on-error: true
env:
GH_TOKEN: ${{ github.token }}
run: |
echo "Waiting for verify job to complete..."
while true; do
CONCLUSION=$(gh api \
"repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/jobs" \
--jq '.jobs[] | select(.name == "verify") | .conclusion // empty')
if [ -n "$CONCLUSION" ]; then
echo "Verify conclusion: $CONCLUSION"
[ "$CONCLUSION" = "success" ] && exit 0
exit 1
fi
echo "Verify still running, retrying in 30s..."
sleep 30
done
- name: Ensure AWS CLI is installed
if: steps.build-iso.outcome == 'success' && steps.wait-verify.outcome == 'success'
run: |
if ! command -v aws &>/dev/null; then
curl -fsSL "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o /tmp/awscliv2.zip
unzip -q /tmp/awscliv2.zip -d /tmp/aws-install
sudo /tmp/aws-install/aws/install
rm -rf /tmp/awscliv2.zip /tmp/aws-install
fi
- name: Upload ISO to Cloudflare R2
id: upload
if: steps.build-iso.outcome == 'success' && steps.wait-verify.outcome == 'success'
env:
AWS_ACCESS_KEY_ID: ${{ secrets.CF_R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.CF_R2_SECRET_ACCESS_KEY }}
CF_R2_ENDPOINT_URL: ${{ secrets.CF_R2_ENDPOINT_URL }}
VERSION: ${{ github.ref_name }}
run: |
aws --endpoint-url="${CF_R2_ENDPOINT_URL}" \
s3 cp "mulga/scripts/iso-builder/dist/spinifex-${VERSION}.iso" \
"s3://spinifex-iso/spinifex-${VERSION}.iso"
aws --endpoint-url="${CF_R2_ENDPOINT_URL}" \
s3 cp "mulga/scripts/iso-builder/dist/spinifex-${VERSION}.iso.sha256" \
"s3://spinifex-iso/spinifex-${VERSION}.iso.sha256"
- name: Mark ISO failed
if: always() && (steps.build-iso.outcome == 'failure' || steps.upload.outcome == 'failure')
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ github.ref_name }}
REPO: ${{ github.repository }}
run: |
EXISTING_BODY=$(gh release view "$TAG" --repo "$REPO" --json body -q .body)
NOTES_FILE=$(mktemp)
printf 'ISO build/upload failed — see workflow run for details.\n\n%s' "$EXISTING_BODY" > "$NOTES_FILE"
gh release edit "$TAG" --repo "$REPO" --notes-file "$NOTES_FILE"
rm -f "$NOTES_FILE"
+17 -5
View File
@@ -1,4 +1,4 @@
name: Spinifex Build and Test
name: Build and Test
on:
push:
@@ -17,9 +17,12 @@ on:
- dev
workflow_dispatch:
permissions:
contents: read
jobs:
build_and_test:
name: Spinifex Build and Test
name: Build and Test
runs-on: ubuntu-24.04
steps:
@@ -33,12 +36,14 @@ jobs:
uses: actions/checkout@v6
with:
repository: mulgadc/viperblock
ref: dev
path: viperblock
- name: Checkout Predastore
uses: actions/checkout@v6
with:
repository: mulgadc/predastore
ref: dev
path: predastore
- name: Setup Go
@@ -56,11 +61,14 @@ jobs:
- name: Run Tests with Coverage
run: make -C spinifex test-cover
- name: Run Action Tests
run: make -C spinifex test-actions
- name: Check Diff Coverage
run: cd spinifex && scripts/diff-coverage.sh coverage.out --base HEAD~1
race_detection:
name: Spinifex Race Detection
name: Race Detection
runs-on: ubuntu-24.04
steps:
@@ -73,12 +81,14 @@ jobs:
uses: actions/checkout@v6
with:
repository: mulgadc/viperblock
ref: dev
path: viperblock
- name: Checkout Predastore
uses: actions/checkout@v6
with:
repository: mulgadc/predastore
ref: dev
path: predastore
- name: Setup Go
@@ -94,7 +104,7 @@ jobs:
run: make -C spinifex test-race
lint_and_security:
name: Spinifex Lint and Security
name: Lint and Security
runs-on: ubuntu-24.04
steps:
@@ -107,12 +117,14 @@ jobs:
uses: actions/checkout@v6
with:
repository: mulgadc/viperblock
ref: dev
path: viperblock
- name: Checkout Predastore
uses: actions/checkout@v6
with:
repository: mulgadc/predastore
ref: dev
path: predastore
- name: Setup Go
@@ -127,7 +139,7 @@ jobs:
- name: Lint
uses: golangci/golangci-lint-action@v9
with:
version: v2.11.3
version: v2.12.2
working-directory: spinifex
- name: Govulncheck
+6 -3
View File
@@ -17,6 +17,9 @@ on:
- .github/workflows/ui.yml
workflow_dispatch:
permissions:
contents: read
jobs:
ui_build_and_test:
name: UI Build and Test
@@ -32,7 +35,7 @@ jobs:
sparse-checkout: spinifex/services/spinifexui/frontend
- name: Setup pnpm
uses: pnpm/action-setup@v5
uses: pnpm/action-setup@v6
with:
package_json_file: spinifex/services/spinifexui/frontend/package.json
@@ -46,8 +49,8 @@ jobs:
- name: Install Dependencies
run: pnpm install --frozen-lockfile
- name: Run Check
run: pnpm check
- name: Run Lint
run: pnpm lint
- name: Run Tests with Coverage
run: pnpm test:coverage
+6
View File
@@ -7,6 +7,9 @@
!example.tfvars
scripts/iac/proxmox/clusters/
# Generated SSH private keys from terraform examples (e.g. local_file.nginx_pem)
*.pem
# Go workspace
go.work
go.work.sum
@@ -14,6 +17,8 @@ go.work.sum
# Build outputs
bin/
dist/
build/microvm/vmlinuz
build/microvm/initramfs.cpio.gz
!spinifex/services/spinifexui/frontend/dist/
tmp/
*.test
@@ -28,6 +33,7 @@ Thumbs.db
# Environment
.env
.venv/
# Test reports
*-report.txt
+10 -2
View File
@@ -22,6 +22,7 @@ linters:
- errname # checks that sentinel errors are prefixed with Err, error types suffixed with Error
- exptostd # detects golang.org/x/exp/ functions replaceable by stdlib
- fatcontext # detects nested contexts in loops
- forcetypeassert # flags unchecked type assertions that can panic
- gocheckcompilerdirectives # validates go compiler directive comments (//go:)
- goprintffuncname # checks that printf-like functions are named with f at the end
- gosec # inspects source code for security problems
@@ -29,11 +30,14 @@ linters:
- ineffassign # detects assignments to existing variables that are not used
- makezero # finds slice declarations with non-zero initial length followed by append
- mirror # catches wrong mirror patterns in bytes/strings usage
- musttag # checks structs passed to marshal/unmarshal have proper tags
- modernize # modernizes code to use newer Go idioms (go fix analyzers)
- nakedret # finds naked returns in functions
- nilerr # catches code that returns nil even after checking err != nil
- nilnesserr # catches returning a different nil error variable after err != nil check
- nolintlint # reports ill-formed or insufficient nolint directives
- nosprintfhostport # flags fmt.Sprintf host:port — use net.JoinHostPort for IPv6 safety
- perfsprint # detects fmt.Sprintf calls replaceable with faster alternatives
- predeclared # catches shadowing of Go builtins (len, cap, error, etc.)
- reassign # checks that package variables are not reassigned
- recvcheck # checks for receiver type consistency (pointer vs value)
@@ -91,6 +95,12 @@ linters:
- newexpr # don't replace aws.String() with new() — AWS SDK idiom
- stringsbuilder # don't replace small string concatenations with strings.Builder
perfsprint:
concat-loop: false
err-error: false # don't flag err.Error()
errorf: false # don't convert fmt.Errorf to errors.New
strconcat: false
nakedret:
max-func-lines: 0
@@ -124,5 +134,3 @@ linters:
linters: [gosec] # subprocess execution — legitimate in VM orchestration
- text: "G304"
linters: [gosec] # file path from variable — necessary for config loading
- text: "G402"
linters: [gosec] # TLS config — we handle TLS ourselves
+106 -15
View File
@@ -34,6 +34,9 @@ else
endif
# Quiet-mode filters (active when QUIET=1, set by preflight via recursive make)
# Note: grep pipelines use PIPESTATUS[0] so the exit status of `go test`
# propagates through the filter — otherwise a test failure is swallowed by
# grep's own (success) exit code and preflight prints "passed" on red.
ifdef QUIET
_Q = @
_COVQ = 2>&1 | { grep -Ev '^\s*(ok|PASS|\?|=== RUN|--- PASS:)\s' | grep -v 'coverage: 0\.0%' || true; }; exit $${PIPESTATUS[0]}
@@ -46,8 +49,7 @@ else
_SECQ = 2>&1 | tee
endif
build:
$(MAKE) go_build
build: go_build build-installer build-lb-agent
# Build spinifex-ui frontend (requires pnpm)
build-ui:
@@ -61,20 +63,41 @@ LDFLAGS := -s -w -X github.com/mulgadc/spinifex/cmd/spinifex/cmd.Version=$(VERSI
go_build:
@echo -e "\n....Building $(GO_PROJECT_NAME)"
go build $(GO_BUILD_MOD) -ldflags "$(LDFLAGS)" -o ./bin/$(GO_PROJECT_NAME) cmd/spinifex/main.go
GOFIPS140=v1.0.0 go build $(GO_BUILD_MOD) -ldflags "$(LDFLAGS)" -o ./bin/$(GO_PROJECT_NAME) cmd/spinifex/main.go
build-alb-agent:
@echo -e "\n....Building alb-agent (static)"
CGO_ENABLED=0 go build -ldflags "-s -w" -o ./bin/alb-agent cmd/alb-agent/main.go
build-installer:
@echo -e "\n....Building spinifex-installer"
GOFIPS140=v1.0.0 go build -ldflags "-s -w" -o ./bin/spinifex-installer cmd/installer/main.go
build-system-image: ## Build a system image from manifest (use IMAGE=alb)
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)
ifndef IMAGE
$(error IMAGE is required. Usage: make build-system-image IMAGE=alb)
$(error IMAGE is required. Usage: make build-system-image IMAGE=lb)
endif
./scripts/build-system-image.sh scripts/images/$(IMAGE).conf
build-alb-image: ## Build ALB Alpine image
$(MAKE) build-system-image IMAGE=alb
MICROVM_OUT_DIR := build/microvm
MICROVM_ARTIFACTS := $(MICROVM_OUT_DIR)/vmlinuz $(MICROVM_OUT_DIR)/initramfs.cpio.gz
MICROVM_INPUTS := scripts/build-microvm-image.sh $(MICROVM_OUT_DIR)/init.sh $(MICROVM_OUT_DIR)/inittab bin/lb-agent
# Grouped target — script writes both files in one run.
$(MICROVM_ARTIFACTS) &: $(MICROVM_INPUTS)
./scripts/build-microvm-image.sh
# Only triggers when bin/lb-agent is missing; preserves the artifact's mtime so
# build-microvm-image stays correctly stale-aware.
bin/lb-agent:
$(MAKE) build-lb-agent
build-microvm-image: $(MICROVM_ARTIFACTS) ## Build microVM kernel + initramfs (incremental — skips when up to date)
install-microvm: $(MICROVM_ARTIFACTS) ## Install microVM artifacts to /usr/share/spinifex/microvm/
sudo install -d /usr/share/spinifex/microvm
sudo install -m 0644 $(MICROVM_OUT_DIR)/vmlinuz /usr/share/spinifex/microvm/vmlinuz
sudo install -m 0644 $(MICROVM_OUT_DIR)/initramfs.cpio.gz /usr/share/spinifex/microvm/initramfs.cpio.gz
go_run:
@echo -e "\n....Running $(GO_PROJECT_NAME)...."
@@ -83,9 +106,23 @@ go_run:
# Preflight — runs the same checks as GitHub Actions (lint + vuln + tests).
# Use this before committing to catch CI failures locally.
preflight:
@$(MAKE) --no-print-directory QUIET=1 lint govulncheck test-cover diff-coverage test-race
@$(MAKE) --no-print-directory QUIET=1 manifest-check lint govulncheck test-cover diff-coverage test-race test-harness
@echo -e "\n ✅ Preflight passed — safe to commit."
# E2E harness unit tests. Build-tagged `e2e` so they're skipped by the
# default `go test ./spinifex/...`. Runs with mocked AWS clients — no
# infrastructure required, safe to run in CI without a cluster.
test-harness:
@echo -e "\n....Running e2e harness unit tests...."
$(_Q)LOG_IGNORE=1 go test -tags=e2e -timeout 60s ./tests/e2e/harness/... $(_RACEQ)
# Validate docs/service-interfaces.yaml. Schema check + cross-reference
# of services/suites/fixtures + on-disk path existence. Subject content
# vs source is enforced separately in Bead 5 drift lint.
manifest-check:
@echo -e "\n....Checking service-interfaces.yaml...."
@go run ./tests/e2e/manifest-check/cmd/manifest-check -repo-root . -manifest docs/service-interfaces.yaml
# Run unit tests
test:
@echo -e "\n....Running tests for $(GO_PROJECT_NAME)...."
@@ -103,6 +140,12 @@ test-race:
@echo -e "\n....Running tests with race detector for $(GO_PROJECT_NAME)...."
$(_Q)LOG_IGNORE=1 go test -race -timeout 300s ./spinifex/... $(_RACEQ)
# Unit tests for in-repo GitHub Actions (e.g. .github/actions/e2e-analyze).
# Kept out of `test-cover` so coverage % isn't diluted by CI-only tooling.
test-actions:
@echo -e "\n....Running action tests...."
LOG_IGNORE=1 go test -timeout 60s ./.github/actions/...
# Check that new/changed code meets coverage threshold (runs tests first)
diff-coverage: test-cover
@QUIET=$(QUIET) scripts/diff-coverage.sh $(COVERPROFILE)
@@ -116,6 +159,26 @@ run:
$(MAKE) go_build
$(MAKE) go_run
# Fast iteration: build + install binary + restart all services.
# Microvm artifacts are reinstalled when they already exist on disk — the rule's
# input timestamps drive a rebuild only if anything actually changed. On a fresh
# checkout (no build/microvm/vmlinuz yet) the install step is skipped; run
# `make install-microvm` explicitly the first time.
deploy: build
sudo install -m 755 bin/spx /usr/local/bin/spx
@if [ -f $(MICROVM_OUT_DIR)/vmlinuz ]; then \
$(MAKE) install-microvm; \
else \
echo "[deploy] microvm artifacts absent — run 'make install-microvm' for first-time setup"; \
fi
sudo systemctl daemon-reload
sudo systemctl restart spinifex.target
# Re-run setup.sh after changing systemd units, helper scripts, or logrotate config.
# Not needed for code-only changes — use deploy for those.
reinstall:
scripts/dev-install.sh
clean:
rm -f ./bin/$(GO_PROJECT_NAME)
rm -rf spinifex/services/spinifexui/frontend/dist
@@ -127,14 +190,15 @@ install-system:
-o Dpkg::Options::="--force-confdef" \
-o Dpkg::Options::="--force-confold" \
nbdkit nbdkit-plugin-dev pkg-config $(QEMU_PACKAGES) qemu-utils qemu-kvm \
ovmf qemu-efi-aarch64 \
libvirt-daemon-system libvirt-clients libvirt-dev make gcc jq curl \
iproute2 netcat-openbsd openssh-client wget git unzip sudo xz-utils file \
ovn-central ovn-host openvswitch-switch dhcpcd-base
install-go:
@echo -e "\n....Installing Go 1.26.1 for $(ARCH) ($(GO_ARCH))...."
@echo -e "\n....Installing Go 1.26.3 for $(ARCH) ($(GO_ARCH))...."
@if [ ! -d "/usr/local/go" ]; then \
curl -L https://go.dev/dl/go1.26.1.linux-$(GO_ARCH).tar.gz | tar -C /usr/local -xz; \
curl -L https://go.dev/dl/go1.26.3.linux-$(GO_ARCH).tar.gz | tar -C /usr/local -xz; \
else \
echo "Go already installed in /usr/local/go"; \
fi
@@ -189,6 +253,14 @@ distro-amd64:
-f build/Dockerfile.distro \
--output type=local,dest=dist/amd64/ \
../
@if [ -f $(MICROVM_OUT_DIR)/vmlinuz ] && [ -f $(MICROVM_OUT_DIR)/initramfs.cpio.gz ]; then \
echo "[distro-amd64] staging microvm artifacts into tarball"; \
mkdir -p dist/amd64/microvm; \
cp $(MICROVM_OUT_DIR)/vmlinuz $(MICROVM_OUT_DIR)/initramfs.cpio.gz dist/amd64/microvm/; \
else \
echo "[distro-amd64] WARNING: microvm artifacts missing — tarball will not include them"; \
echo "[distro-amd64] run 'make build-microvm-image' before 'make distro-amd64'"; \
fi
tar -czf dist/spinifex-$(VERSION)-linux-amd64.tar.gz -C dist/amd64 .
sha256sum dist/spinifex-$(VERSION)-linux-amd64.tar.gz > dist/spinifex-$(VERSION)-linux-amd64.tar.gz.sha256
@@ -207,7 +279,26 @@ distro-arm64:
distro-clean:
rm -rf dist/
.PHONY: build build-ui build-alb-agent build-system-image build-alb-image go_build go_run preflight test test-cover test-race diff-coverage bench run clean \
# Ansible dev lifecycle (experimental, parallel to dev-*.sh / reset-dev-env.sh).
# See scripts/ansible/README.md and docs/development/improvements/ansible-dev-lifecycle.md.
ansible-dev-preflight:
cd scripts/ansible && ansible-playbook playbooks/dev-preflight.yml
ansible-dev-teardown:
cd scripts/ansible && ansible-playbook playbooks/dev-teardown.yml
ansible-dev-install:
cd scripts/ansible && ansible-playbook playbooks/dev-install.yml
ansible-dev-reset:
cd scripts/ansible && ansible-playbook playbooks/dev-reset.yml
ansible-dev-deploy:
cd scripts/ansible && ansible-playbook playbooks/dev-deploy.yml
.PHONY: build build-ui build-installer build-lb-agent build-system-image build-microvm-image install-microvm go_build go_run preflight test test-cover test-race 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
distro distro-amd64 distro-arm64 distro-clean \
ansible-dev-preflight ansible-dev-teardown ansible-dev-install ansible-dev-reset ansible-dev-deploy
+84 -12
View File
@@ -1,25 +1,54 @@
<p align="center">
<img src=".github/assets/banner.svg" alt="Spinifex — AWS-Compatible Infrastructure Stack" width="900">
</p>
<p align="center">
<a href="https://go.dev"><img src="https://img.shields.io/badge/Go-1.26+-00ADD8?style=flat-square&logo=go&logoColor=white" alt="Go"></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/License-AGPL--3.0-3fb950?style=flat-square" alt="License"></a>
<a href="https://docs.mulgadc.com"><img src="https://img.shields.io/badge/Docs-docs.mulgadc.com-DC2626?style=flat-square" alt="Docs"></a>
</p>
<p align="center">
<a href="#what-is-spinifex">What is Spinifex?</a> ·
<a href="#the-platform">Platform</a> ·
<a href="#core-components">Components</a> ·
<a href="#architecture-at-a-glance">Architecture</a> ·
<a href="#installation">Installation</a> ·
<a href="https://docs.mulgadc.com">Docs</a>
</p>
---
# Spinifex: An Open Source AWS-Compatible Stack for Bare-Metal, Edge, and On-Prem Deployments
(Developer Preview) - Spinifex developed by [Mulga Defense Corporation](https://mulgadc.com/) is an open source infrastructure platform that brings the core services of AWS—like EC2, VPC, EBS, and S3—to environments where running in the cloud isn't an option. Whether you're deploying to edge sites, private data-centers, or need to operate in low-connectivity or highly contested environments, Spinifex gives you AWS-style workflows on your own hardware.
Spinifex developed by [Mulga Defense Corporation](https://mulgadc.com) is an open source infrastructure platform that brings the core services of AWS—like EC2, VPC, EBS, and S3—to environments where running in the cloud isn't an option. Whether you're deploying to edge sites, private data-centers, or need to operate in low-connectivity or highly contested environments, Spinifex gives you AWS-style workflows on your own hardware.
## What is Spinifex?
Spinifex replicates essential AWS primitives—virtual machines, block volumes, and object storage—using lightweight, self-contained components that are easy to deploy and integrate.
It’s designed for developers and operators who need:
It's designed for developers and operators who need:
- Cloud-like infrastructure without the cloud
- Minimal dependencies, full control
- Drop-in compatibility with tools like the AWS CLI, SDKs, and Terraform
- A secure cloud environment you control and own the entire hardware, network and software stack
You can run Spinifex on a few servers in a rack, a field site, or anywhere centralized cloud services aren’t feasible.
You can run Spinifex on a few servers in a rack, a field site, or anywhere centralized cloud services aren't feasible.
## The Platform
<p align="center">
<img src=".github/assets/platform.svg" alt="The Mulga Platform — Hardware to Applications" width="900">
</p>
From commodity hardware up to unmodified AWS tooling, every layer is replaceable and yours to own.
## Core Components
### Spinifex (Compute Service – EC2 Alternative)
Spinifex is a minimal VM orchestration layer built on top of QEMU, exposing APIs similar to EC2. It manages lifecycle operations like start, stop, and terminate, using QEMU’s QMP interface. Designed to be straightforward and scriptable, Spinifex lets you launch VMs using the AWS CLI, SDKs, or Terraform—without needing Kubernetes or heavyweight orchestrators. Keep in mind, you can also setup a Kubernetes environment using Spinifex with underlying instances.
Spinifex is a minimal VM orchestration layer built on top of QEMU, exposing APIs similar to EC2. It manages lifecycle operations like start, stop, and terminate, using QEMU's QMP interface. Designed to be straightforward and scriptable, Spinifex lets you launch VMs using the AWS CLI, SDKs, or Terraform—without needing Kubernetes or heavyweight orchestrators. Keep in mind, you can also setup a Kubernetes environment using Spinifex with underlying instances.
- EC2-like VM management on bare metal
- Launches with cloud-init metadata support
@@ -27,7 +56,7 @@ Spinifex is a minimal VM orchestration layer built on top of QEMU, exposing APIs
### Viperblock (Block Storage – EBS Alternative)
[Viperblock](https://github.com/mulgadc/viperblock) is a high-performance, WAL-backed block storage service that replicates volumes across multiple nodes. It’s built for reliability and speed, with support for snapshots, recovery, and direct connection to QEMU instances using NBD or virtio-blk.
[Viperblock](https://github.com/mulgadc/viperblock) is a high-performance, WAL-backed block storage service that replicates volumes across multiple nodes. It's built for reliability and speed, with support for snapshots, recovery, and direct connection to QEMU instances using NBD or virtio-blk.
- Fast, durable virtual disks
- Replication for resilience
@@ -43,29 +72,52 @@ Spinifex is a minimal VM orchestration layer built on top of QEMU, exposing APIs
- Multipart uploads, streaming reads/writes
- Data redundancy with Reed-Solomon encoding
## Architecture at a Glance
<p align="center">
<img src=".github/assets/architecture.svg" alt="Spinifex message-driven architecture — AWS Gateway, NATS bus, service daemons" width="900">
</p>
Every AWS API call is authenticated at the gateway, published to a NATS subject, and answered by whichever daemon claims it. Daemons are stateless — scale horizontally by starting more. No etcd, no Kubernetes, no external control plane. Deep dive: **[`docs/DESIGN.md`](docs/DESIGN.md)**.
## Key Features
AWS-Compatible Interfaces – Provision infrastructure with awscli, Terraform, or SDKs you already use.
- Designed for Control – Run on your own terms, whether that’s in a datacenter, remote site, or sensitive environment.
- Designed for Control – Run on your own terms, whether that's in a datacenter, remote site, or sensitive environment.
- Minimal Dependencies – Each service is standalone and avoids complex orchestration layers.
- Works Offline – No reliance on centralized cloud services or external networks.
- Open Source – Licensed under AGPL 3.0. Fork it, extend it, or deploy it as-is.
## Installation
Installation requires an Ubuntu / Debian system. See the detailed documentation at [https://docs.mulgadc.com](https://docs.mulgadc.com) for maintaining and installing Spinifex.
Installation requires an Ubuntu / Debian system. See the detailed documentation at [docs.mulgadc.com](https://docs.mulgadc.com) for maintaining and installing Spinifex.
### Bare Metal ISO
The recommended installation is a [bootable x86 installer](https://iso.mulgadc.com/spinifex.iso) for bare-metal hardware.
```bash
curl -fLO https://iso.mulgadc.com/spinifex.iso
```
Follow the [USB install guide](https://docs.mulgadc.com/docs/install-usb) to write the ISO to USB and install on your hardware. The install guide walks through the full process.
### Single Node Install
The installation is straightforward to set up and running on a single node for testing purposes.
The installation is straightforward to set up and running on a single node for testing purposes. Debian 13 is currently supported, additional Linux distributions are on the immediate roadmap.
>*Prerequisite:* Linux bridge for networking.
Spinifex requires a Linux bridge configured on the host for VM networking. See the [single-node install guide](https://docs.mulgadc.com/docs/install#prerequisites) prerequisites for setup details.
```bash
curl https://install.mulgadc.com | bash
curl -fsSL https://install.mulgadc.com | bash
sudo /usr/local/share/spinifex/setup-ovn.sh --management
sudo spx admin init
sudo spx admin init --node node1 --nodes 1
sudo systemctl start spinifex.target
@@ -76,7 +128,7 @@ aws ec2 describe-instance-types
### Development Setup
For a complete development environment see the [Source Install](https://docs.mulgadc.com/docs/source-install/) documentation
For a complete development environment see the [Source Install](https://docs.mulgadc.com/docs/install-source) documentation
### Component Repositories
@@ -87,6 +139,26 @@ Spinifex coordinates these independent components:
Each component can be developed independently. See component-specific documentation for focused development guides.
## Spinifex UI
Spinifex ships with a built-in web console — an optional alternative to the AWS CLI, SDKs, and Terraform. If you're familiar with the AWS Management Console, the Spinifex UI fills the same role: a browser-based view of your instances, volumes, buckets, VPCs, and IAM resources, without leaving your own network.
<p align="center">
<img src=".github/assets/spinifex-ui.jpg" alt="Spinifex web console — dashboard view" width="900">
</p>
The console is served by each node on port `3000` over TLS, and becomes available as soon as `spinifex.target` is up:
```bash
open https://YOUR_NODE_IP:3000
```
- **Same API, different surface.** Every action in the UI is the same AWS SigV4 call the CLI makes — so RBAC, audit trails, and IAM policies apply uniformly.
- **Single sign-on against your AWS credentials.** Log in with the access keys from `~/.aws/credentials` on the node where Spinifex is installed — no separate user database.
- **Self-hosted, works offline.** The UI is embedded in the Spinifex binary and served from the node itself. No external CDN, no analytics calls, no cloud dependency.
For the full walkthrough — first-time TLS certificate trust, login, and feature tour — see [**Launching the Web UI**](https://docs.mulgadc.com/docs/setting-up-your-cluster#7-launching-the-web-ui) in the cluster setup guide.
## Development Philosophy
### Built by Engineers, For Engineers
@@ -122,4 +194,4 @@ This hybrid approach ensures Spinifex benefits from both proven engineering expe
## License
Spinifex is open source under the [GNU Affero General Public License v3.0](LICENSE). You're free to use, modify, and deploy it—anywhere you need reliable infrastructure without depending on centralized cloud platforms.
Spinifex is open source under the [GNU Affero General Public License v3.0](LICENSE). You're free to use, modify, and deploy it anywhere you need reliable infrastructure without depending on centralized cloud platforms.
+3 -2
View File
@@ -30,18 +30,19 @@ COPY viperblock/ ./viperblock/
# Build spinifex binary (spx)
RUN COMMIT=$(cd spinifex && git rev-parse --short HEAD 2>/dev/null || echo "unknown") && \
cd spinifex && go build -ldflags "-s -w \
cd spinifex && GOFIPS140=v1.0.0 go build -ldflags "-s -w \
-X github.com/mulgadc/spinifex/cmd/spinifex/cmd.Version=${VERSION} \
-X github.com/mulgadc/spinifex/cmd/spinifex/cmd.Commit=${COMMIT}" \
-o /out/spx cmd/spinifex/main.go
# Build nbdkit viperblock plugin (.so)
RUN cd viperblock && go build -o /out/nbdkit-viperblock-plugin.so -buildmode=c-shared nbd/viperblock.go
RUN cd viperblock && GOFIPS140=v1.0.0 go build -o /out/nbdkit-viperblock-plugin.so -buildmode=c-shared nbd/viperblock.go
# Copy setup scripts and systemd units
RUN cp spinifex/scripts/setup.sh /out/setup.sh 2>/dev/null || true
RUN cp spinifex/scripts/setup-ovn.sh /out/setup-ovn.sh
RUN mkdir -p /out/systemd && cp spinifex/build/systemd/* /out/systemd/
RUN mkdir -p /out/scripts && cp spinifex/build/scripts/* /out/scripts/
RUN cp spinifex/build/logrotate/spinifex /out/logrotate-spinifex
FROM scratch AS export
+56
View File
@@ -0,0 +1,56 @@
#!/bin/sh
mount -t proc proc /proc
mount -t sysfs sys /sys
mount -t devtmpfs dev /dev
# virtio_mmio is built-in (CONFIG_VIRTIO_MMIO=y) so virtio-net devices are
# already enumerated by the time PID 1 runs — no modprobe needed.
# qemu_fw_cfg and virtio_net are =m in stock Alpine linux-virt, so load them.
modprobe qemu_fw_cfg 2>/dev/null || modprobe fw_cfg_sysfs 2>/dev/null || true
modprobe virtio_net 2>/dev/null || true
for i in 1 2 3 4 5; do
[ -f /sys/firmware/qemu_fw_cfg/by_name/opt/spinifex/netcfg/raw ] && break
sleep 0.02
done
if [ ! -f /sys/firmware/qemu_fw_cfg/by_name/opt/spinifex/netcfg/raw ]; then
echo "[init] FATAL: fw_cfg netcfg not available" >&2
exit 1
fi
. /sys/firmware/qemu_fw_cfg/by_name/opt/spinifex/netcfg/raw
for n in 0 1 2 3 4 5; do
eval mac=\$NIC${n}_MAC
[ -z "$mac" ] && continue
iface=""
for attempt in 1 2 3 4 5; do
iface=$(ip -o link | awk -v m="$mac" 'tolower($0) ~ tolower(m) {print $2}' | tr -d :)
[ -n "$iface" ] && break
sleep 0.02
done
if [ -z "$iface" ]; then
echo "[init] WARNING: no interface found for MAC $mac (NIC$n), skipping" >&2
continue
fi
eval cidr=\$NIC${n}_CIDR
eval gw=\$NIC${n}_GW
eval default=\$NIC${n}_DEFAULT
eval rdst=\$NIC${n}_ROUTE_DST
eval rvia=\$NIC${n}_ROUTE_VIA
ip link set "$iface" up
[ -n "$cidr" ] && ip addr add "$cidr" dev "$iface"
[ "$default" = "1" ] && [ -n "$gw" ] && ip route add default via "$gw" dev "$iface"
[ -n "$rdst" ] && [ -n "$rvia" ] && ip route add "$rdst" via "$rvia" dev "$iface"
done
cp /sys/firmware/qemu_fw_cfg/by_name/opt/spinifex/lb-agent-env/raw /etc/conf.d/lb-agent
mkdir -p /etc/ssl/certs
cp /sys/firmware/qemu_fw_cfg/by_name/opt/spinifex/ca-cert/raw /etc/ssl/certs/ca-certificates.crt
exec /sbin/init
+4
View File
@@ -0,0 +1,4 @@
::sysinit:/etc/rc.local
::respawn:/bin/sh -c 'set -a; . /etc/conf.d/lb-agent; set +a; exec /usr/local/bin/lb-agent'
::ctrlaltdel:/sbin/reboot
::shutdown:/bin/umount -a -r
+30
View File
@@ -0,0 +1,30 @@
#!/bin/sh
# Helper script for spinifex-predastore.service
# Detects host, port, backend, and node-id from spinifex.toml
# then execs spx. Falls back to single-node defaults if no config found.
CONF=/etc/spinifex/spinifex.toml
BIND="0.0.0.0:8443"
# -1 = co-located mode (all [[db]] peers in one process). Single-node spinifex
# templates list 3 DB peers on 127.0.0.1; only -1 can serve that topology.
# Multi-node spinifex.toml emits node_id = N (N >= 1) which overrides below.
# predastore rejects NODE_ID=0 since d3a02f0 (strconv.Atoi-of-garbage guard).
NODE_ID="-1"
if [ -f "$CONF" ]; then
H=$(awk -F'"' '/\[nodes\..*\.predastore\]/{f=1} f&&/^host/{print $2;exit}' "$CONF")
if [ -n "$H" ]; then
BIND="$H"
fi
N=$(awk -F'= *' '/node_id/{gsub(/ /,"",$2);print $2;exit}' "$CONF")
if [ -n "$N" ]; then
NODE_ID="$N"
fi
fi
export SPINIFEX_PREDASTORE_HOST="${BIND%%:*}"
export SPINIFEX_PREDASTORE_PORT="${BIND##*:}"
export SPINIFEX_PREDASTORE_NODE_ID="$NODE_ID"
exec /usr/local/bin/spx service predastore start
+37
View File
@@ -0,0 +1,37 @@
#!/bin/sh
# Wait for NATS to be reachable before starting a dependent service.
# Reads the NATS address from spinifex.toml [nodes.*.nats] section,
# falls back to nats.conf, then to 127.0.0.1:4222.
CONF=/etc/spinifex/spinifex.toml
NATS_CONF=/etc/spinifex/nats/nats.conf
NATS_HOST=""
NATS_PORT="4222"
TIMEOUT=30
# Try spinifex.toml first
if [ -f "$CONF" ]; then
NATS_BIND=$(awk -F'"' '/\[nodes\..*\.nats\]/{f=1} f&&/^host/{print $2;exit}' "$CONF")
if [ -n "$NATS_BIND" ]; then
NATS_HOST="${NATS_BIND%%:*}"
NATS_PORT="${NATS_BIND##*:}"
fi
fi
# Fall back to nats.conf
if [ -z "$NATS_HOST" ] && [ -f "$NATS_CONF" ]; then
NATS_HOST=$(grep "^listen:" "$NATS_CONF" | sed 's/listen: *//;s/:.*//')
fi
# Final fallback
NATS_HOST="${NATS_HOST:-127.0.0.1}"
for i in $(seq 1 "$TIMEOUT"); do
if nc -z "$NATS_HOST" "$NATS_PORT" 2>/dev/null; then
exit 0
fi
sleep 1
done
echo "Timed out waiting for NATS at $NATS_HOST:$NATS_PORT" >&2
exit 1
@@ -0,0 +1,12 @@
[Unit]
Description=Regenerate SSH host keys
ConditionPathExists=!/etc/ssh/ssh_host_ed25519_key
Before=ssh.service
[Service]
Type=oneshot
ExecStart=/usr/bin/ssh-keygen -A
RemainAfterExit=yes
[Install]
WantedBy=multi-user.target
+36 -4
View File
@@ -2,21 +2,53 @@
Description=Spinifex AWS Gateway
PartOf=spinifex.target
After=spinifex-nats.service spinifex-daemon.service
Requires=spinifex-nats.service
Wants=spinifex-nats.service
[Service]
Type=simple
User=spinifex
User=spinifex-gw
Group=spinifex
ExecStartPre=/bin/sh -c 'for i in $(seq 1 30); do nc -z 127.0.0.1 4222 && exit 0; sleep 1; done; exit 1'
ExecStartPre=/var/lib/spinifex/wait-for-nats.sh
ExecStart=/usr/local/bin/spx service awsgw start
Restart=on-failure
RestartSec=5
OOMScoreAdjust=-500
Environment=SPINIFEX_CONFIG_PATH=/etc/spinifex/spinifex.toml
Environment=SPINIFEX_BASE_DIR=/var/lib/spinifex/
Environment=SPINIFEX_BASE_DIR=/var/lib/spinifex/awsgw/
Environment=SPINIFEX_AWSGW_TLS_CERT=/etc/spinifex/server.pem
Environment=SPINIFEX_AWSGW_TLS_KEY=/etc/spinifex/server.key
# Filesystem access
NoNewPrivileges=yes
ProtectSystem=strict
PrivateDevices=yes
ReadOnlyPaths=/etc/spinifex/spinifex.toml /etc/spinifex/master.key /etc/spinifex/server.pem /etc/spinifex/server.key /etc/spinifex/ca.pem
ReadWritePaths=/var/lib/spinifex/awsgw /var/log/spinifex
# Hardening
ProtectHome=yes
PrivateTmp=yes
RestrictSUIDSGID=yes
LockPersonality=yes
RestrictRealtime=yes
RestrictNamespaces=yes
ProtectClock=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectProc=invisible
SystemCallArchitectures=native
MemoryDenyWriteExecute=yes
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
CapabilityBoundingSet=
ProtectHostname=yes
KeyringMode=private
RemoveIPC=yes
LimitCORE=0
LimitNOFILE=65536
LimitNPROC=4096
[Install]
WantedBy=spinifex.target
+81 -4
View File
@@ -1,21 +1,98 @@
[Unit]
Description=Spinifex Daemon (VM Orchestration)
PartOf=spinifex.target
After=spinifex-nats.service spinifex-viperblock.service
Requires=spinifex-nats.service
After=spinifex-nats.service spinifex-viperblock.service openvswitch-switch.service ovn-controller.service
# No Wants=spinifex-nats.service: Wants= reactivates a stopped unit during
# `systemctl restart spinifex-daemon`, which would mask the standalone-startup
# path the Tier 1 DDIL Scenario B exercises. spinifex.target Wants= covers
# cold boot.
[Service]
Type=simple
User=spinifex
User=spinifex-daemon
Group=spinifex
ExecStartPre=/bin/sh -c 'for i in $(seq 1 30); do nc -z 127.0.0.1 4222 && exit 0; sleep 1; done; exit 1'
# DDIL Tier 1 (siv-17): no ExecStartPre wait-for-nats. The daemon starts in
# standalone mode and reconnects to NATS in the background — process-exiting
# on a missing NATS would kill the local VM management plane.
#
# Opt-in strict-startup (§1d-strict, siv-44): set SPINIFEX_REQUIRE_NATS=1 to
# restore pre-DDIL fail-fast UX. First NATS connect uses a ~30s bounded retry
# and the daemon aborts on timeout. Intended for dev/test/single-node deploys
# where silent NATS misconfig is more confusing than helpful. Do NOT set this
# in the shipped unit — it re-introduces the SPOF that Tier 1 removed.
ExecStart=/usr/local/bin/spx service spinifex start
Restart=on-failure
RestartSec=5
# DDIL Tier 1 (mulga-siv-58): KillMode=process so systemd only signals the
# daemon's main PID, not the cgroup. QEMU children stay alive across
# `systemctl restart spinifex-daemon` and the new daemon reattaches to them
# via the persisted local state file + pidfile + QMP socket. With the
# default control-group mode, restart would terminate every guest VM —
# defeating Scenario B's "daemon restart preserves running instances".
KillMode=process
# 15s upper bound on the SIGTERM grace window. The new SIGTERM handler
# skips VM teardown (it now just persists state and exits), so 15s is
# plenty of slack for the cluster-manager + NATS unsubscribe path.
TimeoutStopSec=15
OOMScoreAdjust=-500
Environment=SPINIFEX_CONFIG_PATH=/etc/spinifex/spinifex.toml
Environment=SPINIFEX_BASE_DIR=/var/lib/spinifex/spinifex/
Environment=SPINIFEX_WAL_DIR=/var/lib/spinifex/spinifex/
# DDIL Tier 1 (mulga-siv-58): QEMU pidfiles, QMP sockets, and the
# shared qemu.log all resolve via utils.pidPath() — which falls back
# to os.TempDir() (=/tmp) when $HOME/spinifex doesn't exist. With
# PrivateTmp=yes, each daemon start gets a fresh /tmp namespace, so
# pidfiles written by QEMU (running in the OLD daemon's namespace)
# are invisible to the new daemon. The /local/instances handler then
# reports PID=0 and Scenario B fails its "live QEMU PID" assertion.
# Pin XDG_RUNTIME_DIR to the host-wide tmpfs at /run/spinifex (created
# by tmpfiles.d, already in ReadWritePaths) so pidPath() returns a
# stable, namespace-independent path that both daemon incarnations and
# QEMU children resolve identically.
Environment=XDG_RUNTIME_DIR=/run/spinifex
# Filesystem access — NoNewPrivileges omitted (daemon uses sudo for ip, ovs-vsctl, dhcpcd)
# CAP_SYS_ADMIN: required for GPU passthrough sysfs operations (vfio group management).
# CAP_DAC_OVERRIDE: sysfs driver bind/unbind paths (/sys/bus/pci/drivers/vfio-pci/bind,
# unbind) are root:root 0200; non-root processes need CAP_DAC_OVERRIDE to open them.
AmbientCapabilities=CAP_SYS_ADMIN CAP_DAC_OVERRIDE
ProtectSystem=full
PrivateDevices=no
DevicePolicy=closed
DeviceAllow=/dev/kvm rw
DeviceAllow=/dev/net/tun rw
DeviceAllow=char-vfio rw
DeviceAllow=/dev/vfio/vfio rw
ReadOnlyPaths=/etc/spinifex/spinifex.toml /etc/spinifex/server.pem /etc/spinifex/server.key /etc/spinifex/ca.pem
ReadWritePaths=/var/lib/spinifex/spinifex /var/log/spinifex /run/spinifex /run/spinifex/nbd
# Hardening
ProtectHome=yes
PrivateTmp=yes
RestrictSUIDSGID=yes
LockPersonality=yes
RestrictRealtime=yes
RestrictNamespaces=yes
ProtectClock=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectProc=invisible
SystemCallArchitectures=native
MemoryDenyWriteExecute=yes
# DHCP lease acquisition moved to spinifex-vpcd (see dhcp-allocate-error.md);
# the daemon no longer needs AF_PACKET or the softened kernel protections.
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
ProtectHostname=yes
KeyringMode=private
RemoveIPC=yes
LimitCORE=0
LimitNOFILE=65536
LimitNPROC=4096
# VFIO GPU passthrough requires locking all guest RAM into physical memory.
# Without this, QEMU fails with RLIMIT_MEMLOCK exceeded when pinning guest pages.
LimitMEMLOCK=infinity
[Install]
WantedBy=spinifex.target
+32 -1
View File
@@ -5,7 +5,7 @@ Before=spinifex-predastore.service spinifex-daemon.service spinifex-awsgw.servic
[Service]
Type=simple
User=spinifex
User=spinifex-nats
Group=spinifex
ExecStart=/usr/local/bin/spx service nats start
Restart=on-failure
@@ -13,5 +13,36 @@ RestartSec=5
OOMScoreAdjust=-500
Environment=SPINIFEX_CONFIG_PATH=/etc/spinifex/nats/nats.conf
# Filesystem access
NoNewPrivileges=yes
ProtectSystem=strict
PrivateDevices=yes
ReadOnlyPaths=/etc/spinifex/nats /etc/spinifex/server.pem /etc/spinifex/server.key /etc/spinifex/ca.pem
ReadWritePaths=/var/lib/spinifex/nats /var/log/spinifex
# Hardening
ProtectHome=yes
PrivateTmp=yes
RestrictSUIDSGID=yes
LockPersonality=yes
RestrictRealtime=yes
RestrictNamespaces=yes
ProtectClock=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectProc=invisible
SystemCallArchitectures=native
MemoryDenyWriteExecute=yes
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
CapabilityBoundingSet=
ProtectHostname=yes
KeyringMode=private
RemoveIPC=yes
LimitCORE=0
LimitNOFILE=65536
LimitNPROC=4096
[Install]
WantedBy=spinifex.target
+36 -4
View File
@@ -2,14 +2,14 @@
Description=Spinifex Predastore (S3-compatible Object Storage)
PartOf=spinifex.target
After=spinifex-nats.service
Requires=spinifex-nats.service
Wants=spinifex-nats.service
[Service]
Type=simple
User=spinifex
User=spinifex-storage
Group=spinifex
ExecStartPre=/bin/sh -c 'for i in $(seq 1 30); do nc -z 127.0.0.1 4222 && exit 0; sleep 1; done; exit 1'
ExecStart=/usr/local/bin/spx service predastore start
ExecStartPre=/var/lib/spinifex/wait-for-nats.sh
ExecStart=/var/lib/spinifex/predastore-start.sh
Restart=on-failure
RestartSec=5
OOMScoreAdjust=-500
@@ -17,6 +17,38 @@ Environment=SPINIFEX_PREDASTORE_CONFIG_PATH=/etc/spinifex/predastore/predastore.
Environment=SPINIFEX_PREDASTORE_BASE_PATH=/var/lib/spinifex/predastore/
Environment=SPINIFEX_PREDASTORE_TLS_CERT=/etc/spinifex/server.pem
Environment=SPINIFEX_PREDASTORE_TLS_KEY=/etc/spinifex/server.key
Environment=SPINIFEX_PREDASTORE_ENCRYPTION_KEY_FILE=/etc/spinifex/predastore/encryption.key
# Filesystem access
NoNewPrivileges=yes
ProtectSystem=strict
PrivateDevices=yes
ReadOnlyPaths=/etc/spinifex/predastore /etc/spinifex/spinifex.toml /etc/spinifex/master.key /etc/spinifex/server.pem /etc/spinifex/server.key /etc/spinifex/ca.pem
ReadWritePaths=/var/lib/spinifex/predastore /var/log/spinifex
# Hardening
ProtectHome=yes
PrivateTmp=yes
RestrictSUIDSGID=yes
LockPersonality=yes
RestrictRealtime=yes
RestrictNamespaces=yes
ProtectClock=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectProc=invisible
SystemCallArchitectures=native
MemoryDenyWriteExecute=yes
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
CapabilityBoundingSet=
ProtectHostname=yes
KeyringMode=private
RemoveIPC=yes
LimitCORE=0
LimitNOFILE=65536
LimitNPROC=4096
[Install]
WantedBy=spinifex.target
+34 -1
View File
@@ -5,12 +5,45 @@ After=spinifex-awsgw.service
[Service]
Type=simple
User=spinifex
User=spinifex-ui
Group=spinifex
ExecStart=/usr/local/bin/spx service spinifex-ui start
Restart=on-failure
RestartSec=5
OOMScoreAdjust=-500
Environment=SPINIFEX_UI_TLS_CERT=/etc/spinifex/server.pem
Environment=SPINIFEX_UI_TLS_KEY=/etc/spinifex/server.key
# Filesystem access
NoNewPrivileges=yes
ProtectSystem=strict
PrivateDevices=yes
ReadOnlyPaths=/etc/spinifex/server.pem /etc/spinifex/server.key
ReadWritePaths=/var/log/spinifex
# Hardening
ProtectHome=yes
PrivateTmp=yes
RestrictSUIDSGID=yes
LockPersonality=yes
RestrictRealtime=yes
RestrictNamespaces=yes
ProtectClock=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectProc=invisible
SystemCallArchitectures=native
MemoryDenyWriteExecute=yes
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
CapabilityBoundingSet=
ProtectHostname=yes
KeyringMode=private
RemoveIPC=yes
LimitCORE=0
LimitNOFILE=65536
LimitNPROC=4096
[Install]
WantedBy=spinifex.target
+34 -3
View File
@@ -2,13 +2,13 @@
Description=Spinifex Viperblock (Block Storage)
PartOf=spinifex.target
After=spinifex-nats.service spinifex-predastore.service
Requires=spinifex-nats.service
Wants=spinifex-nats.service
[Service]
Type=simple
User=spinifex
User=spinifex-viperblock
Group=spinifex
ExecStartPre=/bin/sh -c 'for i in $(seq 1 30); do nc -z 127.0.0.1 4222 && exit 0; sleep 1; done; exit 1'
ExecStartPre=/var/lib/spinifex/wait-for-nats.sh
ExecStart=/usr/local/bin/spx service viperblock start
Restart=on-failure
RestartSec=5
@@ -17,5 +17,36 @@ EnvironmentFile=/etc/spinifex/systemd.env
Environment=SPINIFEX_CONFIG_PATH=/etc/spinifex/spinifex.toml
Environment=SPINIFEX_BASE_DIR=/var/lib/spinifex/viperblock/
# Filesystem access
NoNewPrivileges=yes
ProtectSystem=strict
PrivateDevices=yes
ReadOnlyPaths=/etc/spinifex/spinifex.toml /etc/spinifex/systemd.env /etc/spinifex/ca.pem
ReadWritePaths=/var/lib/spinifex/viperblock /var/log/spinifex /run/spinifex/nbd
# Hardening
ProtectHome=yes
PrivateTmp=yes
RestrictSUIDSGID=yes
LockPersonality=yes
RestrictRealtime=yes
RestrictNamespaces=yes
ProtectClock=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectProc=invisible
SystemCallArchitectures=native
MemoryDenyWriteExecute=yes
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
CapabilityBoundingSet=
ProtectHostname=yes
KeyringMode=private
RemoveIPC=yes
LimitCORE=0
LimitNOFILE=65536
LimitNPROC=4096
[Install]
WantedBy=spinifex.target
+44 -4
View File
@@ -1,19 +1,59 @@
[Unit]
Description=Spinifex VPC Daemon (OVN Networking)
PartOf=spinifex.target
After=spinifex-nats.service spinifex-daemon.service
Requires=spinifex-nats.service
After=spinifex-nats.service spinifex-daemon.service systemd-networkd.service openvswitch-switch.service ovn-controller.service
Wants=spinifex-nats.service ovn-controller.service
[Service]
Type=simple
User=spinifex
User=spinifex-vpcd
Group=spinifex
ExecStartPre=/bin/sh -c 'for i in $(seq 1 30); do nc -z 127.0.0.1 4222 && exit 0; sleep 1; done; exit 1'
ExecStartPre=/var/lib/spinifex/wait-for-nats.sh
ExecStart=/usr/local/bin/spx service vpcd start
Restart=on-failure
RestartSec=5
OOMScoreAdjust=-500
Environment=SPINIFEX_CONFIG_PATH=/etc/spinifex/spinifex.toml
Environment=SPINIFEX_BASE_DIR=/var/lib/spinifex/vpcd/
# Filesystem access — NoNewPrivileges omitted (vpcd uses sudo for ovs-vsctl, ovn-nbctl, systemctl)
ProtectSystem=full
PrivateDevices=no
ReadOnlyPaths=/etc/spinifex/spinifex.toml /etc/spinifex/ca.pem
ReadWritePaths=/var/lib/spinifex/vpcd /var/log/spinifex
# Hardening
ProtectHome=yes
PrivateTmp=yes
RestrictSUIDSGID=no
LockPersonality=yes
RestrictRealtime=yes
RestrictNamespaces=yes
ProtectClock=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectProc=invisible
SystemCallArchitectures=native
MemoryDenyWriteExecute=no
# AF_PACKET: required by the in-process DHCP client (github.com/insomniacslk/dhcp
# / nclient4) for raw L2 BOOTP on br-wan before an IP is configured on the
# interface. See docs/development/bugs/dhcp-allocate-error.md.
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK AF_PACKET
# CAP_NET_RAW: open AF_PACKET sockets.
# CAP_NET_BIND_SERVICE: covers unicast RENEW to UDP :68.
# CAP_NET_ADMIN: already used for ovs-vsctl / ip link wiring on the bridge.
# Note, disabled for now, sudo issue with ovs
AmbientCapabilities=CAP_NET_ADMIN CAP_NET_RAW CAP_NET_BIND_SERVICE CAP_SETUID CAP_SETGID
CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_RAW CAP_NET_BIND_SERVICE CAP_SETUID CAP_SETGID
NoNewPrivileges=no
ProtectHostname=yes
KeyringMode=private
RemoveIPC=yes
LimitCORE=0
LimitNOFILE=65536
LimitNPROC=4096
[Install]
WantedBy=spinifex.target
+1 -1
View File
@@ -2,7 +2,7 @@
Description=Spinifex Infrastructure Platform
After=network-online.target
Wants=network-online.target
Wants=spinifex-nats.service spinifex-predastore.service spinifex-viperblock.service spinifex-daemon.service spinifex-awsgw.service spinifex-vpcd.service spinifex-ui.service
Wants=spinifex-nats.service spinifex-predastore.service spinifex-viperblock.service spinifex-daemon.service spinifex-awsgw.service spinifex-vpcd.service spinifex-ui.service openvswitch-switch.service ovn-controller.service
[Install]
WantedBy=multi-user.target
+5
View File
@@ -0,0 +1,5 @@
# Allow spinifex group to access VFIO devices for GPU passthrough.
# QEMU (launched by spinifex-daemon, group spinifex) must open /dev/vfio/N
# to use vfio-pci passthrough devices. The kernel creates these nodes as
# root:root 0600 by default; this rule grants group-level read-write access.
SUBSYSTEM=="vfio", GROUP="spinifex", MODE="0660"
-62
View File
@@ -1,62 +0,0 @@
// alb-agent runs inside ALB VMs and manages HAProxy configuration.
//
// It connects to NATS, subscribes to config updates for its load balancer,
// writes HAProxy configs to disk, and reloads HAProxy on changes. It also
// responds to health pings so the daemon can verify the agent is alive.
//
// Usage:
//
// alb-agent --lb-id=lb-xxxxx --nats=nats://10.0.0.1:4222
//
// The LB ID defaults to the hostname (set by cloud-init to the LB ID).
// The NATS URL defaults to nats://127.0.0.1:4222.
package main
import (
"flag"
"log/slog"
"os"
"os/signal"
"syscall"
"github.com/mulgadc/spinifex/spinifex/albagent"
)
func main() {
var (
lbID string
natsURL string
)
hostname, _ := os.Hostname()
flag.StringVar(&lbID, "lb-id", hostname, "Load balancer ID (defaults to hostname)")
flag.StringVar(&natsURL, "nats", "nats://127.0.0.1:4222", "NATS server URL")
flag.Parse()
if lbID == "" {
slog.Error("--lb-id is required (or set hostname to the LB ID)")
os.Exit(1)
}
slog.Info("Starting ALB agent", "lbId", lbID, "nats", natsURL)
agent, err := albagent.New(lbID, natsURL)
if err != nil {
slog.Error("Failed to create agent", "err", err)
os.Exit(1)
}
if err := agent.Start(); err != nil {
slog.Error("Failed to start agent", "err", err)
os.Exit(1)
}
// Wait for shutdown signal
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT)
sig := <-sigCh
slog.Info("Received signal, shutting down", "signal", sig)
agent.Stop()
}
+172
View File
@@ -0,0 +1,172 @@
// dhcptest is a diagnostic tool that runs a DHCP DORA handshake using the
// same nclient4 library and options that spinifex-vpcd uses for gateway pool
// leases. Run it on a node to verify DHCP works at the library level before
// debugging the full spinifex stack.
//
// Usage (must run as root — needs AF_PACKET):
//
// sudo dhcptest --iface=br-wan
// sudo dhcptest --iface=br-wan --mac=02:00:00:7c:b7:b9 --promisc
// sudo dhcptest --iface=br-wan --spinifex-id=gateway-wan1
package main
import (
"context"
"flag"
"fmt"
"log"
"net"
"os"
"os/exec"
"strings"
"time"
"github.com/insomniacslk/dhcp/dhcpv4"
"github.com/insomniacslk/dhcp/dhcpv4/nclient4"
)
func main() {
iface := flag.String("iface", "br-wan", "Interface/bridge to bind the DHCP client to")
macStr := flag.String("mac", "", "Hardware address for DORA (chaddr + AF_PACKET filter). If empty, uses the real interface MAC.")
spinifexID := flag.String("spinifex-id", "", "Generate a spinifex virtual MAC from this resource ID (e.g. 'gateway-wan1'). Overrides --mac.")
clientID := flag.String("client-id", "", "DHCP option 61 client identifier. Defaults to --spinifex-id or --mac value.")
hostname := flag.String("hostname", "", "DHCP option 12 hostname")
vendorClass := flag.String("vendor-class", "mulga-spinifex-gw", "DHCP option 60 vendor class identifier")
promisc := flag.Bool("promisc", false, "Enable promiscuous mode on the interface before the test (mirrors vpcd's setBridgePromisc)")
timeout := flag.Duration("timeout", 10*time.Second, "Per-attempt DHCP timeout")
retries := flag.Int("retries", 3, "Number of DORA attempts before giving up")
verbose := flag.Bool("verbose", false, "Print full DHCP packet contents")
flag.Parse()
// Resolve the hardware address to use.
var hwAddr net.HardwareAddr
var err error
switch {
case *spinifexID != "":
hwAddr, err = net.ParseMAC(generateMAC(*spinifexID))
if err != nil {
log.Fatalf("generateMAC(%q): %v", *spinifexID, err)
}
fmt.Printf("spinifex virtual MAC for %q: %s\n", *spinifexID, hwAddr)
case *macStr != "":
hwAddr, err = net.ParseMAC(*macStr)
if err != nil {
log.Fatalf("parse --mac %q: %v", *macStr, err)
}
default:
hwAddr, err = ifaceMAC(*iface)
if err != nil {
log.Fatalf("get MAC for %s: %v", *iface, err)
}
fmt.Printf("using real interface MAC: %s\n", hwAddr)
}
// Resolve client-id: default to spinifex-id, then MAC string.
cid := *clientID
if cid == "" {
if *spinifexID != "" {
cid = *spinifexID
} else {
cid = hwAddr.String()
}
}
fmt.Printf("iface: %s\n", *iface)
fmt.Printf("hw-addr: %s\n", hwAddr)
fmt.Printf("client-id: %s\n", cid)
fmt.Printf("hostname: %s\n", *hostname)
fmt.Printf("vendor-class: %s\n", *vendorClass)
fmt.Printf("promisc: %v\n", *promisc)
fmt.Printf("timeout: %s retries: %d\n", *timeout, *retries)
fmt.Println()
if *promisc {
if err := setPromisc(*iface, true); err != nil {
log.Printf("WARNING: failed to set promisc on %s: %v", *iface, err)
} else {
fmt.Printf("set %s promisc ON\n\n", *iface)
}
}
// Build nclient4 options — identical to spinifex/services/vpcd/dhcp/nclient4.go.
opts := []nclient4.ClientOpt{
nclient4.WithHWAddr(hwAddr),
nclient4.WithTimeout(*timeout),
nclient4.WithRetry(*retries),
}
if *verbose {
opts = append(opts, nclient4.WithLogger(nclient4.ShortSummaryLogger{Printfer: log.New(os.Stderr, "[nclient4] ", 0)}))
}
client, err := nclient4.New(*iface, opts...)
if err != nil {
log.Fatalf("nclient4.New(%s): %v", *iface, err)
}
defer client.Close()
// Identity modifiers — identical to identityModifiers() in vpcd dhcp package.
var mods []dhcpv4.Modifier
if cid != "" {
mods = append(mods, dhcpv4.WithOption(dhcpv4.OptClientIdentifier([]byte(cid))))
}
if *hostname != "" {
mods = append(mods, dhcpv4.WithOption(dhcpv4.OptHostName(*hostname)))
}
if *vendorClass != "" {
mods = append(mods, dhcpv4.WithOption(dhcpv4.OptClassIdentifier(*vendorClass)))
}
fmt.Println("Sending DHCP Discover...")
start := time.Now()
ctx, cancel := context.WithTimeout(context.Background(), *timeout*time.Duration(*retries+1))
defer cancel()
lease, err := client.Request(ctx, mods...)
if err != nil {
fmt.Printf("\nFAILED after %s: %v\n", time.Since(start).Round(time.Millisecond), err)
os.Exit(1)
}
fmt.Printf("SUCCESS in %s\n\n", time.Since(start).Round(time.Millisecond))
ack := lease.ACK
fmt.Printf(" Your IP: %s\n", ack.YourIPAddr)
fmt.Printf(" Subnet: %s\n", ack.SubnetMask())
fmt.Printf(" Gateway: %v\n", ack.Router())
fmt.Printf(" DNS: %v\n", ack.DNS())
fmt.Printf(" Server ID: %s\n", ack.ServerIdentifier())
fmt.Printf(" Lease time: %s\n", ack.IPAddressLeaseTime(24*time.Hour))
if *verbose {
fmt.Printf("\n--- Full ACK ---\n%s\n", ack.Summary())
}
}
// generateMAC is the same deterministic hash used by vpcd's topology.go.
// It produces a locally-administered MAC (02:00:00:xx:xx:xx) from a resource ID.
func generateMAC(resourceID string) string {
h := uint32(0)
for _, c := range resourceID {
h = h*31 + uint32(c)
}
return fmt.Sprintf("02:00:00:%02x:%02x:%02x", (h>>16)&0xff, (h>>8)&0xff, h&0xff)
}
func ifaceMAC(name string) (net.HardwareAddr, error) {
ifc, err := net.InterfaceByName(name)
if err != nil {
return nil, err
}
return ifc.HardwareAddr, nil
}
func setPromisc(iface string, on bool) error {
state := "on"
if !on {
state = "off"
}
out, err := exec.Command("ip", "link", "set", iface, "promisc", state).CombinedOutput()
if err != nil {
return fmt.Errorf("%w: %s", err, strings.TrimSpace(string(out)))
}
return nil
}
+355
View File
@@ -0,0 +1,355 @@
/*
Copyright © 2026 Mulga Defense Corporation
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// Package autoinstall reads headless install parameters from environment
// variables exported by spinifex-init.sh from the kernel cmdline.
// Parameters are set in the GRUB "Headless" menu entry in grub.cfg.
package autoinstall
import (
"fmt"
"log/slog"
"net"
"os"
"os/exec"
"sort"
"strconv"
"strings"
"syscall"
"time"
"github.com/mulgadc/spinifex/cmd/installer/install"
)
// Load returns an install.Config built from SPINIFEX_* environment variables,
// or nil if SPINIFEX_AUTO is not set to "1" (interactive mode).
func Load() (*install.Config, error) {
if os.Getenv("SPINIFEX_AUTO") != "1" {
return nil, nil
}
slog.Info("autoinstall: headless mode enabled via kernel cmdline")
cfg, err := buildConfig()
if err != nil {
return nil, fmt.Errorf("autoinstall config: %w", err)
}
return cfg, nil
}
// EjectAndReboot ejects the boot device (best-effort) then reboots.
func EjectAndReboot() {
srcDev, _ := os.ReadFile("/run/iso-dev")
if dev := strings.TrimSpace(string(srcDev)); dev != "" {
slog.Info("autoinstall: ejecting boot device", "device", dev)
_ = exec.Command("eject", dev).Run()
}
fmt.Println()
fmt.Println("Installation complete.")
fmt.Println("Remove the USB drive now if it was not ejected automatically.")
fmt.Println("Rebooting in 10 seconds...")
time.Sleep(10 * time.Second)
// Use the kernel syscall directly — the live environment runs spinifex-init
// as PID 1 (not systemd), so exec("reboot") fails trying to reach D-Bus.
if err := syscall.Reboot(syscall.LINUX_REBOOT_CMD_RESTART); err != nil {
slog.Error("autoinstall: reboot syscall failed", "err", err)
}
}
func buildConfig() (*install.Config, error) {
password := os.Getenv("SPINIFEX_PASSWORD")
if password == "" {
return nil, fmt.Errorf("SPINIFEX_PASSWORD is required")
}
hostname := os.Getenv("SPINIFEX_HOSTNAME")
if hostname == "" {
hostname = "spinifex-node"
}
disk, err := resolveDisk(os.Getenv("SPINIFEX_DISK"))
if err != nil {
return nil, fmt.Errorf("disk: %w", err)
}
wanIface, err := resolveNIC(os.Getenv("SPINIFEX_WAN_IFACE"), "")
if err != nil {
return nil, fmt.Errorf("WAN NIC: %w", err)
}
cfg := &install.Config{
Disk: disk,
Hostname: hostname,
RootPassword: password,
// Optional on the headless path — empty means no --email passed to
// spx admin init, which omits the operator identity from telemetry.
Email: strings.TrimSpace(os.Getenv("SPINIFEX_EMAIL")),
WANInterface: wanIface,
WANDHCPMode: strings.ToLower(os.Getenv("SPINIFEX_WAN_MODE")) != "static",
}
if !cfg.WANDHCPMode {
ip := os.Getenv("SPINIFEX_WAN_IP")
mask := os.Getenv("SPINIFEX_WAN_MASK")
gw := os.Getenv("SPINIFEX_WAN_GW")
if ip == "" || mask == "" || gw == "" {
return nil, fmt.Errorf("SPINIFEX_WAN_IP, SPINIFEX_WAN_MASK, SPINIFEX_WAN_GW required for static mode")
}
cfg.WANAddress = ip
cfg.WANMask = mask
cfg.WANGateway = gw
if dns := os.Getenv("SPINIFEX_WAN_DNS"); dns != "" {
cfg.WANDNS = strings.Split(dns, ",")
}
}
if lanIface := os.Getenv("SPINIFEX_LAN_IFACE"); lanIface != "" {
lan, err := resolveNIC(lanIface, wanIface)
if err != nil {
return nil, fmt.Errorf("LAN NIC: %w", err)
}
cfg.LANInterface = lan
cfg.LANDHCPMode = strings.ToLower(os.Getenv("SPINIFEX_LAN_MODE")) != "static"
if !cfg.LANDHCPMode {
cfg.LANAddress = os.Getenv("SPINIFEX_LAN_IP")
cfg.LANMask = os.Getenv("SPINIFEX_LAN_MASK")
if dns := os.Getenv("SPINIFEX_LAN_DNS"); dns != "" {
cfg.LANDNS = strings.Split(dns, ",")
}
}
}
if os.Getenv("SPINIFEX_GPU_PASSTHROUGH") == "1" {
cfg.GPUPassthrough = true
}
role := strings.ToLower(os.Getenv("SPINIFEX_ROLE"))
if role == "" {
role = "init"
}
cfg.ClusterRole = role
skipFormation := os.Getenv("SPINIFEX_SKIP_FORMATION") == "1"
if skipFormation {
cfg.SkipFormation = true
}
if role == "join" && !skipFormation {
joinAddr := os.Getenv("SPINIFEX_JOIN_ADDR")
if joinAddr == "" {
return nil, fmt.Errorf("SPINIFEX_JOIN_ADDR required when SPINIFEX_ROLE=join")
}
cfg.JoinAddr = joinAddr
}
return cfg, nil
}
// resolveDisk maps the spx_disk value from grub.cfg to a block device path.
//
// Supported values:
//
// auto — use the only non-removable disk; fail if multiple found
// largest — largest non-removable disk (explicit opt-in)
// smallest — smallest non-removable disk (typical OS-on-SSD pattern)
// nvme — the only NVMe disk; fail if multiple found
// /dev/sda (etc.) — exact device path; fail if not present
func resolveDisk(target string) (string, error) {
switch strings.ToLower(target) {
case "", "auto":
return singleDisk()
case "largest":
return diskBySize(true)
case "smallest":
return diskBySize(false)
case "nvme":
return nvmeDisk()
default:
if _, err := os.Stat(target); err != nil {
return "", fmt.Errorf("%q not found", target)
}
return target, nil
}
}
type diskCandidate struct {
dev string
bytes int64
}
// nonRemovableDisks returns all non-removable, non-virtual block devices.
func nonRemovableDisks() ([]diskCandidate, error) {
entries, err := os.ReadDir("/sys/block")
if err != nil {
return nil, err
}
var out []diskCandidate
for _, e := range entries {
dev := e.Name()
switch {
case strings.HasPrefix(dev, "loop"),
strings.HasPrefix(dev, "ram"),
strings.HasPrefix(dev, "dm-"),
strings.HasPrefix(dev, "sr"):
continue
}
removable, _ := os.ReadFile("/sys/block/" + dev + "/removable")
if strings.TrimSpace(string(removable)) == "1" {
continue
}
sizeRaw, _ := os.ReadFile("/sys/block/" + dev + "/size")
sectors, parseErr := strconv.ParseInt(strings.TrimSpace(string(sizeRaw)), 10, 64)
if parseErr != nil || sectors == 0 {
continue
}
out = append(out, diskCandidate{dev: dev, bytes: sectors * 512})
}
return out, nil
}
// diskList returns a human-readable list of candidates for error messages.
func diskList(disks []diskCandidate) string {
lines := make([]string, len(disks))
for i, d := range disks {
lines[i] = fmt.Sprintf(" /dev/%s (%dG)", d.dev, d.bytes>>30)
}
return strings.Join(lines, "\n")
}
// singleDisk selects the only non-removable disk, or fails listing all found.
func singleDisk() (string, error) {
disks, err := nonRemovableDisks()
if err != nil {
return "", err
}
switch len(disks) {
case 0:
return "", fmt.Errorf("no non-removable disks found")
case 1:
slog.Info("autoinstall: single disk selected", "disk", disks[0].dev, "size_gb", disks[0].bytes>>30)
return "/dev/" + disks[0].dev, nil
default:
return "", fmt.Errorf(
"multiple disks found — set SPINIFEX_DISK to one of:\n%s\n"+
" largest (largest disk)\n"+
" smallest (smallest disk)\n"+
" nvme (NVMe only)\n"+
" /dev/sdX (exact path)",
diskList(disks),
)
}
}
// diskBySize selects the largest (largest=true) or smallest non-removable disk.
func diskBySize(largest bool) (string, error) {
disks, err := nonRemovableDisks()
if err != nil {
return "", err
}
if len(disks) == 0 {
return "", fmt.Errorf("no non-removable disks found")
}
sort.Slice(disks, func(i, j int) bool {
if largest {
return disks[i].bytes > disks[j].bytes
}
return disks[i].bytes < disks[j].bytes
})
label := "smallest"
if largest {
label = "largest"
}
slog.Info("autoinstall: disk selected by size", "mode", label, "disk", disks[0].dev, "size_gb", disks[0].bytes>>30)
return "/dev/" + disks[0].dev, nil
}
// nvmeDisk selects the only NVMe disk, or fails listing all NVMe disks found.
func nvmeDisk() (string, error) {
disks, err := nonRemovableDisks()
if err != nil {
return "", err
}
var nvmes []diskCandidate
for _, d := range disks {
if strings.HasPrefix(d.dev, "nvme") {
nvmes = append(nvmes, d)
}
}
switch len(nvmes) {
case 0:
return "", fmt.Errorf("no NVMe disks found")
case 1:
slog.Info("autoinstall: NVMe disk selected", "disk", nvmes[0].dev, "size_gb", nvmes[0].bytes>>30)
return "/dev/" + nvmes[0].dev, nil
default:
return "", fmt.Errorf(
"multiple NVMe disks found — set SPINIFEX_DISK to one of:\n%s",
diskList(nvmes),
)
}
}
// virtualNICPrefixes lists interface name prefixes that identify non-physical
// interfaces (bridges, tunnels, container/OVS veth pairs, etc.). These are
// skipped when auto-selecting a NIC so we don't configure docker0 or
// ovs-system as the management interface on a machine that previously ran
// Docker or OVN.
var virtualNICPrefixes = []string{
"docker", "veth", "virbr", "br-", "ovs-", "vxlan", "genev", "tun", "tap",
}
// resolveNIC returns the interface name to use. "auto" or empty picks the
// first physical (non-loopback, non-virtual) interface that is not exclude.
func resolveNIC(name, exclude string) (string, error) {
if name != "" && name != "auto" {
return name, nil
}
ifaces, err := net.Interfaces()
if err != nil {
return "", err
}
for _, iface := range ifaces {
if iface.Flags&net.FlagLoopback != 0 {
continue
}
// Require broadcast capability — filters out point-to-point tunnels.
if iface.Flags&net.FlagBroadcast == 0 {
continue
}
// Require a MAC address — virtual/tunnel interfaces have none.
if len(iface.HardwareAddr) == 0 {
continue
}
if iface.Name == exclude {
continue
}
// Skip known virtual interface prefixes.
virtual := false
for _, pfx := range virtualNICPrefixes {
if strings.HasPrefix(iface.Name, pfx) {
virtual = true
break
}
}
if virtual {
continue
}
slog.Info("autoinstall: NIC resolved", "mode", "auto", "selected", iface.Name)
return iface.Name, nil
}
return "", fmt.Errorf("no suitable physical NIC found (non-loopback, broadcast-capable, with MAC)")
}
@@ -0,0 +1,17 @@
package autoinstall
import (
"os"
"testing"
)
func TestLoadReturnsNilWhenNotAuto(t *testing.T) {
os.Unsetenv("SPINIFEX_AUTO")
cfg, err := Load()
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if cfg != nil {
t.Fatal("expected nil config when SPINIFEX_AUTO is not set")
}
}
+56
View File
@@ -0,0 +1,56 @@
/*
Copyright © 2026 Mulga Defense Corporation
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// Package branding centralises Mulga/Spinifex visual identity constants used
// by the installer TUI.
//
// TODO: Replace placeholder values with official Mulga/Spinifex assets once
// the brand guide and logo files are located.
package branding
import "github.com/charmbracelet/lipgloss"
// Logo is the ASCII art rendered on the installer welcome screen.
// TODO: Replace with official Spinifex/Mulga ASCII logo.
const Logo = `
███████╗██████╗ ██╗███╗ ██╗██╗███████╗███████╗██╗ ██╗
██╔════╝██╔══██╗██║████╗ ██║██║██╔════╝██╔════╝╚██╗██╔╝
███████╗██████╔╝██║██╔██╗ ██║██║█████╗ █████╗ ╚███╔╝
╚════██║██╔═══╝ ██║██║╚██╗██║██║██╔══╝ ██╔══╝ ██╔██╗
███████║██║ ██║██║ ╚████║██║██║ ███████╗██╔╝ ██╗
╚══════╝╚═╝ ╚═╝╚═╝ ╚═══╝╚═╝╚═╝ ╚══════╝╚═╝ ╚═╝`
// Subtitle is displayed beneath the logo on the welcome screen.
const Subtitle = "Bare-Metal Node Provisioner"
// Publisher is shown in the welcome screen footer.
const Publisher = "Mulga Defense Corporation"
// Mulga color palette.
// TODO: Replace with official hex values from the Mulga brand guide.
const (
ColorPrimary = lipgloss.Color("#00A3E0") // Mulga blue (placeholder)
ColorAccent = lipgloss.Color("#FF6B00") // Mulga orange (placeholder)
ColorBackground = lipgloss.Color("#0D1117") // Dark background
ColorSurface = lipgloss.Color("#161B22") // Slightly lighter surface
ColorText = lipgloss.Color("#E6EDF3") // Light text
ColorMuted = lipgloss.Color("#8B949E") // Muted / secondary text
ColorSuccess = lipgloss.Color("#3FB950") // Green
ColorWarning = lipgloss.Color("#D29922") // Amber
ColorError = lipgloss.Color("#F85149") // Red
ColorBorder = lipgloss.Color("#30363D") // Border
)
+266
View File
@@ -0,0 +1,266 @@
/*
Copyright © 2026 Mulga Defense Corporation
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// Package firstboot writes the oneshot systemd service and configuration that
// completes Spinifex provisioning on the first real boot after installation.
package firstboot
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/mulgadc/spinifex/cmd/installer/systemd"
)
// Config holds the values the firstboot service needs to configure the node.
type Config struct {
Hostname string
// EncapIP is the Geneve tunnel IP for OVN. Set to the LAN bridge IP when a
// dedicated LAN NIC is present, otherwise the WAN bridge IP. Empty when DHCP
// is used — setup-ovn.sh auto-detects the IP from the default route in that case.
EncapIP string
// ClusterRole is "init" or "join".
ClusterRole string
// JoinAddr is host:port of the primary node, only used when ClusterRole is "join".
JoinAddr string
// Email is the operator email collected by the TUI or SPINIFEX_EMAIL on
// the headless path. Passed to `spx admin init --email=<value>` when set;
// omitted entirely when empty.
Email string
// GPUPassthrough enables VFIO GPU passthrough by passing --gpu-passthrough
// to `spx admin init`, which writes gpu_passthrough = true in the daemon config.
GPUPassthrough bool
// InstallCallback, when non-empty, is curled once at the end of firstboot
// after the success marker is written. Generic phone-home hook for
// provisioning controllers (PXE/MAAS-style flows). Best-effort: non-2xx
// and network failures are swallowed so the install is not gated on
// controller reachability.
InstallCallback string
// SkipFormation skips spx admin init/join. Used when a provisioning
// controller (e.g. bm-bootstrap.sh) owns cluster formation and will call
// spx admin init/join itself with the correct multi-node parameters.
SkipFormation bool
}
// Write drops the firstboot script and systemd unit into root, which should be
// the path of the installed system's root filesystem (e.g. /mnt/spinifex-install).
func Write(root string, cfg Config) error {
if err := writeScript(root, cfg); err != nil {
return fmt.Errorf("firstboot script: %w", err)
}
if err := systemd.WriteFirstbootUnit(root); err != nil {
return fmt.Errorf("firstboot unit: %w", err)
}
if err := systemd.EnableUnit(root, "spinifex-firstboot.service"); err != nil {
return err
}
// Banner unit is written but NOT enabled here. firstboot enables it
// (along with spinifex.target) at the end of its run, so on the first
// boot the only spinifex unit running is firstboot itself — no implicit
// pull of spinifex.target via the banner's Wants= relationship, which
// was the root cause of the boot-time race against firstboot.
if err := systemd.WriteBannerUnit(root); err != nil {
return fmt.Errorf("banner unit: %w", err)
}
return systemd.WriteGettyDropIn(root)
}
func writeScript(root string, cfg Config) error {
clusterCmd := buildClusterCmd(cfg)
// --encap-ip is optional: when DHCP is used the IP is unknown at install time
// and setup-ovn.sh auto-detects it from the default route at boot.
setupOVN := "/usr/local/bin/setup-ovn.sh --management"
if cfg.EncapIP != "" {
setupOVN += fmt.Sprintf(" --encap-ip=%s", cfg.EncapIP)
}
callbackBlock := ""
if cfg.InstallCallback != "" {
callbackBlock = fmt.Sprintf(
"\ncurl -fsS --max-time 10 --retry 2 --retry-delay 2 -o /dev/null %s || true\n",
shellEscapeSingle(cfg.InstallCallback),
)
}
script := fmt.Sprintf(`#!/bin/bash
# Spinifex firstboot — runs once after ISO installation. On success, writes
# /var/lib/spinifex/.firstboot-done and the systemd unit's
# ConditionPathExists=! prevents re-execution on subsequent boots. A partial
# run leaves no marker, so the next reboot retries from the top — safe
# because every step below is idempotent (hostnamectl, setup-ovn.sh, spx
# admin init are all "set if not set"; systemctl enable on an already-enabled
# unit is a no-op).
set -euo pipefail
DONE_MARKER=/var/lib/spinifex/.firstboot-done
# Idempotency: bail early if a previous run completed successfully. The unit
# also has ConditionPathExists=!$DONE_MARKER so we shouldn't get here on
# subsequent boots, but defend in depth in case the unit is re-triggered
# manually before the operator notices the marker exists.
if [ -f "$DONE_MARKER" ]; then
echo "[firstboot] already complete — skipping"
exit 0
fi
# Set hostname
hostnamectl set-hostname %s
# Pre-start OVS and OVN central so their databases are initialised before
# setup-ovn.sh runs. On physical hardware, first-boot DB initialisation takes
# longer than setup-ovn.sh's internal 15-second timeout allows. Starting them
# here and waiting until the NB DB is ready means setup-ovn.sh sees a live DB
# the moment it starts — no races, no timeout failures.
systemctl start openvswitch-switch
systemctl start ovn-central
echo "Waiting for OVN NB DB to initialise..."
for _i in $(seq 1 120); do
if ovn-nbctl --timeout=2 get-connection >/dev/null 2>&1; then
echo "OVN NB DB ready (${_i}s)"
break
fi
sleep 1
done
# Create default nameservers if DHCP server returns blank
printf "nameserver 1.1.1.1\nnameserver 8.8.8.8\n" > /etc/resolvconf/resolv.conf.d/base
resolvconf -u
# Configure OVN networking.
# br-wan (and br-lan if present) are Linux bridges managed by systemd-networkd
# (declared in /etc/systemd/network/ by the installer). setup-ovn.sh
# auto-detects br-wan as the default route device and wires it to OVS via a
# veth pair — non-destructive, SSH-safe.
%s
# Write the banner (first time, spinifex-banner service will do this on reboot)
/usr/local/bin/spx admin banner --boot-check
# Cluster formation — capture credentials to file for display on console.
%s 2>&1
# Fix ownership of files spx admin init wrote. spx runs as root under
# systemd, so /etc/spinifex/{spinifex.toml,master.key,ca.key,*.pem} and any
# per-service files written under /var/lib/spinifex/* land as root:root.
# Delegate to setup.sh's fix_file_ownership (single source of truth) — it
# knows the per-file modes (ca.key 0600, *.pem 0644, toml/key 0640) that a
# blunt recursive chmod would clobber.
SETUP_STAGES=fixown /usr/local/share/spinifex/setup.sh
# The /var/lib/spinifex/awsgw/config symlink was created at build time by
# setup.sh's create_directories — it resolves to /etc/spinifex, so
# {BaseDir}/config/master.key automatically points at /etc/spinifex/master.key.
# Bootstrap the spinifex user's SSH directory with a generated key pair so
# ~/.ssh exists and the operator can immediately use ssh-keygen / ssh-copy-id
# without hitting "No such file or directory" errors documented in the setup guide.
if [ ! -f /home/spinifex/.ssh/id_ed25519 ]; then
mkdir -p /home/spinifex/.ssh
chmod 700 /home/spinifex/.ssh
ssh-keygen -t ed25519 -f /home/spinifex/.ssh/id_ed25519 -N "" -C "spinifex@$(hostname)"
chown -R spinifex:spinifex /home/spinifex/.ssh
chmod 600 /home/spinifex/.ssh/id_ed25519
chmod 644 /home/spinifex/.ssh/id_ed25519.pub
fi
# Copy AWS credentials to the spinifex user's home directory.
# spx admin init runs with HOME=/root (set by the systemd unit), so credentials
# land in /root/.aws/. Copy them to the spinifex user's home so the operator
# can use the AWS CLI without sudo.
if [ -f /root/.aws/credentials ]; then
mkdir -p /home/spinifex/.aws
cp /root/.aws/credentials /home/spinifex/.aws/credentials
cp /root/.aws/config /home/spinifex/.aws/config 2>/dev/null || true
chown -R spinifex:spinifex /home/spinifex/.aws
chmod 700 /home/spinifex/.aws
chmod 600 /home/spinifex/.aws/credentials
[ -f /home/spinifex/.aws/config ] && chmod 600 /home/spinifex/.aws/config
fi
# Enable + activate spinifex.target and the banner now that all configs are
# in place. "enable --now" creates the multi-user.target.wants/ symlinks (so
# they start directly on every subsequent boot — no longer dependent on
# firstboot running each time, since firstboot is condition-skipped after
# this run via the marker) and activates them in this boot.
# NOTE: Moved to firstinstall to enable
#systemctl enable --now spinifex.target spinifex-banner.service
# Enable services to start, on reboot
systemctl enable spinifex.target spinifex-banner.service
# Mark complete only after every step above has succeeded. Until this point,
# any failure (set -e) leaves the marker absent and the next reboot retries
# firstboot from the top.
mkdir -p "$(dirname "$DONE_MARKER")"
touch "$DONE_MARKER"
%s
systemctl start spinifex.target
# Wait for the daemon to bring up external networking. When external_mode=pool
# is configured the daemon creates br-ext (the OVN external bridge) during
# startup; launching instances before it is ready results in no public IP being
# assigned. ip link requires no root and avoids parsing daemon logs.
if grep -q 'external_mode.*pool' /etc/spinifex/spinifex.toml 2>/dev/null; then
echo "[firstboot] waiting for external networking (br-ext)..."
for _i in $(seq 1 30); do
if ip link show br-ext >/dev/null 2>&1; then
echo "[firstboot] br-ext ready (${_i}s)"
break
fi
sleep 1
done
if ! ip link show br-ext >/dev/null 2>&1; then
echo "[firstboot] warning: br-ext not up after 30s — external networking may be delayed"
fi
fi
`, cfg.Hostname, setupOVN, clusterCmd, callbackBlock)
path := filepath.Join(root, "usr/local/bin/spinifex-firstboot.sh")
return os.WriteFile(path, []byte(script), 0o755)
}
func buildClusterCmd(cfg Config) string {
if cfg.SkipFormation {
return `echo "[firstboot] cluster formation skipped — provisioning controller will handle it"`
}
emailFlag := ""
if cfg.Email != "" {
// shellEscapeSingle keeps the email safe if it ever contains a
// character shell treats specially — belt-and-braces; the regex
// validator already rejects whitespace and @-chains.
emailFlag = " --email=" + shellEscapeSingle(cfg.Email)
}
gpuFlag := ""
if cfg.GPUPassthrough {
gpuFlag = " --gpu-passthrough"
}
switch cfg.ClusterRole {
case "join":
return fmt.Sprintf("spx admin join --node %s --host %s%s", cfg.Hostname, cfg.JoinAddr, emailFlag)
default:
return fmt.Sprintf("spx admin init --node %s --nodes 1%s%s", cfg.Hostname, emailFlag, gpuFlag)
}
}
// shellEscapeSingle wraps s in single quotes with any embedded single
// quotes escaped. Minimal — we only need this because the email value is
// interpolated into a shell script written by Write().
func shellEscapeSingle(s string) string {
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
}
+100
View File
@@ -0,0 +1,100 @@
package firstboot
import (
"os"
"path/filepath"
"strings"
"testing"
)
func makeRootDirs(t *testing.T, root string) {
t.Helper()
for _, d := range []string{
"usr/local/bin",
"etc/systemd/system",
"etc/systemd/system/multi-user.target.wants",
} {
if err := os.MkdirAll(filepath.Join(root, d), 0o755); err != nil {
t.Fatalf("mkdir %s: %v", d, err)
}
}
}
func TestWriteScriptNoCallbackWhenEmpty(t *testing.T) {
root := t.TempDir()
makeRootDirs(t, root)
cfg := Config{Hostname: "test-node", ClusterRole: "init"}
if err := Write(root, cfg); err != nil {
t.Fatalf("Write: %v", err)
}
script, err := os.ReadFile(filepath.Join(root, "usr/local/bin/spinifex-firstboot.sh"))
if err != nil {
t.Fatalf("read script: %v", err)
}
if strings.Contains(string(script), "curl") {
t.Error("script should not contain curl when InstallCallback is empty")
}
}
func TestWriteScriptEmbedsCurlWhenCallbackSet(t *testing.T) {
root := t.TempDir()
makeRootDirs(t, root)
const callbackURL = "http://192.168.1.12/boot/done?mac=aa:bb:cc:dd:ee:ff"
cfg := Config{
Hostname: "test-node",
ClusterRole: "init",
InstallCallback: callbackURL,
}
if err := Write(root, cfg); err != nil {
t.Fatalf("Write: %v", err)
}
script, err := os.ReadFile(filepath.Join(root, "usr/local/bin/spinifex-firstboot.sh"))
if err != nil {
t.Fatalf("read script: %v", err)
}
content := string(script)
if !strings.Contains(content, "curl") {
t.Error("script missing curl command")
}
if !strings.Contains(content, callbackURL) {
t.Errorf("script missing callback URL %q", callbackURL)
}
}
func TestWriteScriptCallbackAfterDoneMarker(t *testing.T) {
root := t.TempDir()
makeRootDirs(t, root)
const callbackURL = "http://192.168.1.12/boot/done?mac=aa:bb:cc:dd:ee:ff"
cfg := Config{
Hostname: "node1",
ClusterRole: "init",
InstallCallback: callbackURL,
}
if err := Write(root, cfg); err != nil {
t.Fatalf("Write: %v", err)
}
script, err := os.ReadFile(filepath.Join(root, "usr/local/bin/spinifex-firstboot.sh"))
if err != nil {
t.Fatalf("read script: %v", err)
}
content := string(script)
doneIdx := strings.Index(content, "touch \"$DONE_MARKER\"")
curlIdx := strings.Index(content, "curl")
if doneIdx < 0 {
t.Fatal("done marker not found in script")
}
if curlIdx < 0 {
t.Fatal("curl not found in script")
}
if curlIdx < doneIdx {
t.Error("curl must appear after done marker write")
}
}
+79
View File
@@ -0,0 +1,79 @@
/*
Copyright © 2026 Mulga Defense Corporation
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// Package install performs the disk installation steps after the UI has
// collected configuration.
package install
// Config holds all values collected by the installer UI.
type Config struct {
// Disk is the block device path to install onto (e.g. /dev/sda).
Disk string
// WAN interface — always present. The management IP lives on br-wan (a Linux
// bridge over the NIC), not on the physical NIC itself. This allows OVN/OVS
// to safely attach to the bridge without disrupting host connectivity.
WANInterface string
WANDHCPMode bool
WANAddress string // empty if WANDHCPMode
WANMask string // empty if WANDHCPMode
WANGateway string // empty if WANDHCPMode
WANDNS []string // empty if WANDHCPMode; e.g. ["1.1.1.1", "8.8.8.8"]
WANWiFiSSID string // non-empty if a WiFi NIC was selected
WANWiFiPass string // non-empty if a WiFi NIC was selected
// LAN interface — only set when 2+ NICs are present. The Geneve encap IP
// for OVN tunnels lives on br-lan. When empty, the WAN address is used.
LANInterface string
LANDHCPMode bool
LANAddress string
LANMask string
LANDNS []string
LANWiFiSSID string
LANWiFiPass string
// Node identity
Hostname string
// ClusterRole is "init" or "join".
ClusterRole string
// JoinAddr is the primary node address (host:port) when ClusterRole is "join".
JoinAddr string
// SkipFormation skips spx admin init/join in firstboot; the provisioning
// controller (e.g. bm-bootstrap.sh) owns cluster formation instead.
SkipFormation bool
// CA certificate (PEM), optional.
HasCACert bool
CACert string
// RootPassword is the password to set for the root account on the installed system.
RootPassword string
// Email is the operator's email address, used by the call-home telemetry
// endpoint to notify of important system updates and security advisories.
// Required on interactive installs; may be empty on headless/CI installs
// when SPINIFEX_EMAIL is not supplied on the kernel cmdline.
Email string
// GPUPassthrough enables VFIO GPU passthrough on this node.
// Set via SPINIFEX_GPU_PASSTHROUGH=1 on headless installs.
// Passes --gpu-passthrough to `spx admin init` during firstboot.
GPUPassthrough bool
}
+864
View File
@@ -0,0 +1,864 @@
/*
Copyright © 2026 Mulga Defense Corporation
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package install
import (
"bufio"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
"syscall"
"time"
"github.com/mulgadc/spinifex/cmd/installer/firstboot"
"github.com/mulgadc/spinifex/cmd/installer/systemd"
)
const (
mountRoot = "/mnt/spinifex-install"
efiPart = mountRoot + "/boot/efi"
)
// Run executes all installation steps in order. It is intentionally sequential
// and explicit — each step is visible in logs so failures are easy to diagnose.
func Run(cfg *Config) error {
// The live environment may not have /sbin or /usr/sbin in PATH. Set it
// explicitly so exec.Command's LookPath finds system binaries like grub-install.
_ = os.Setenv("PATH", "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin")
// Unmount unconditionally on exit so a failed step never leaves partitions
// mounted in the live environment, which would cause a retry to double-mount.
defer func() {
_ = run("umount", efiPart)
_ = run("umount", mountRoot)
}()
steps := []struct {
name string
fn func() error
}{
{"partition disk", func() error { return partitionDisk(cfg.Disk) }},
{"format partitions", func() error { return formatPartitions(cfg.Disk) }},
{"mount partitions", func() error { return mountPartitions(cfg.Disk) }},
{"copy rootfs", copyRootfs},
{"write fstab", func() error { return writeFstab(cfg.Disk) }},
{"install spinifex", func() error { return installSpinifex(cfg) }},
{"write network config", func() error { return writeNetworkConfig(cfg) }},
{"write firstboot service", func() error { return firstboot.Write(mountRoot, cfg.toFirstbootConfig()) }},
{"install bootloader", func() error { return installBootloader(cfg.Disk) }},
{"install CA cert", func() error { return installCACert(cfg) }},
}
for _, s := range steps {
slog.Info("installer", "step", s.name)
if err := s.fn(); err != nil {
return fmt.Errorf("step %q: %w", s.name, err)
}
}
slog.Info("installation complete")
fireInstallCallback()
promptRemoveUSB()
return reboot()
}
func partitionDisk(disk string) error {
// GPT table with three partitions:
// p1: 1MiB BIOS Boot Partition — required for grub-install i386-pc on GPT
// p2: 512MiB EFI System Partition
// p3: remainder as root (ext4)
if err := run("parted", "--script", disk,
"mklabel", "gpt",
"mkpart", "bios_boot", "1MiB", "2MiB",
"set", "1", "bios_grub", "on",
"mkpart", "ESP", "fat32", "2MiB", "514MiB",
"set", "2", "esp", "on",
"mkpart", "root", "ext4", "514MiB", "100%",
); err != nil {
return err
}
// Force the kernel to re-read the partition table and wait for udev to
// create the partition device nodes. Without this, mkfs.fat in the next
// step may race and fail with "No such file or directory" on /dev/sda2 —
// the kernel has accepted the new layout but /dev hasn't been populated
// yet. Trixie's udev seems slower at this than Bookworm's was.
return waitForPartitions(disk)
}
// waitForPartitions ensures the EFI and root partition device nodes exist
// after parted creates them. It runs partprobe (kernel re-read) and
// udevadm settle (wait for queued events), then polls /dev for the files.
func waitForPartitions(disk string) error {
// Best-effort: partprobe failure isn't fatal — udev may still pick up
// the change from the BLKRRPART ioctl that parted itself issued.
if err := run("partprobe", disk); err != nil {
slog.Warn("partprobe failed, continuing", "disk", disk, "err", err)
}
if err := run("udevadm", "settle", "--timeout=10"); err != nil {
slog.Warn("udevadm settle failed, continuing", "err", err)
}
efi, root := partitionPaths(disk)
deadline := time.Now().Add(15 * time.Second)
for _, part := range []string{efi, root} {
for {
if _, err := os.Stat(part); err == nil {
break
}
if time.Now().After(deadline) {
return fmt.Errorf("partition device %s did not appear within timeout — kernel/udev did not pick up new partition table", part)
}
time.Sleep(200 * time.Millisecond)
}
}
return nil
}
func formatPartitions(disk string) error {
efi, root := partitionPaths(disk)
if err := run("mkfs.fat", "-F32", efi); err != nil {
return err
}
return run("mkfs.ext4", "-F", root)
}
func mountPartitions(disk string) error {
_, root := partitionPaths(disk)
if err := os.MkdirAll(mountRoot, 0o755); err != nil {
return err
}
if err := run("mount", root, mountRoot); err != nil {
return err
}
if err := os.MkdirAll(efiPart, 0o755); err != nil {
return err
}
efi, _ := partitionPaths(disk)
return run("mount", efi, efiPart)
}
// copyRootfs copies the live squashfs environment onto the target disk using
// rsync. This is the air-gapped alternative to debootstrap — all packages are
// already embedded in the ISO so no network access is required.
func copyRootfs() error {
if err := run("rsync", "-aHAX", "--delete", "--info=progress2",
"--exclude=/proc",
"--exclude=/sys",
"--exclude=/dev",
"--exclude=/run",
"--exclude=/tmp",
"--exclude=/cdrom",
"--exclude=/mnt",
"--exclude=/etc/machine-id",
"--exclude=/var/lib/dbus/",
"--exclude=/etc/openvswitch/",
"--exclude=/var/lib/openvswitch/",
"--exclude=/var/lib/dhcpcd/",
"--exclude=/etc/ssh/ssh_host_*",
"--exclude=/lost+found",
"--exclude=/boot/efi",
"/", mountRoot+"/",
); err != nil {
return err
}
// Verify critical paths exist before proceeding. rsync exits 0 on ENOSPC
// for individual file writes on some filesystems, which would produce a
// partial rootfs that boots into a panic.
critical := []string{
filepath.Join(mountRoot, "bin/bash"),
filepath.Join(mountRoot, "lib/systemd/systemd"),
filepath.Join(mountRoot, "usr/local/bin/spx"),
}
for _, p := range critical {
if _, err := os.Stat(p); err != nil {
return fmt.Errorf("copyRootfs: critical path missing after rsync (%s): %w", p, err)
}
}
// rsync skips excluded paths entirely — recreate the virtual filesystem
// mount points that systemd expects to exist on the installed system.
mountPoints := []struct {
path string
mode os.FileMode
}{
{"proc", 0o555},
{"sys", 0o555},
{"dev", 0o755},
{"run", 0o755},
{"run/lock", 0o1777},
{"tmp", 0o1777},
{"mnt", 0o755},
}
for _, mp := range mountPoints {
if err := os.MkdirAll(filepath.Join(mountRoot, mp.path), mp.mode); err != nil {
return fmt.Errorf("create mountpoint /%s: %w", mp.path, err)
}
}
return nil
}
func installSpinifex(cfg *Config) error {
// The rootfs copy already contains spx and spinifex-installer at
// /usr/local/bin/ — no binary copy needed. Regenerate machine-specific
// identity files so each installed node is unique.
// Bind-mount /dev, /proc, /sys into the chroot so PAM (chpasswd) and
// other chroot commands work correctly. Trixie's PAM requires /proc and
// /dev/urandom for password hashing.
if err := bindChrootMounts(); err != nil {
return err
}
defer unbindChrootMounts()
// Generate a unique machine-id from the kernel CSPRNG. We do not use
// systemd-machine-id-setup in the chroot: it reads the SMBIOS UUID via
// the bind-mounted /sys, which is identical on cloned VMs or hosts with
// absent/zeroed DMI data, and falls back to non-unique sources (MAC,
// hostname) when that also fails — producing the same ID on every node.
// /proc/sys/kernel/random/uuid is always unique and requires no chroot.
machineIDPath := filepath.Join(mountRoot, "etc/machine-id")
rawUUID, err := os.ReadFile("/proc/sys/kernel/random/uuid")
if err != nil {
return fmt.Errorf("installSpinifex: read kernel uuid: %w", err)
}
machineID := strings.ReplaceAll(strings.TrimSpace(string(rawUUID)), "-", "") + "\n"
if err := os.WriteFile(machineIDPath, []byte(machineID), 0o444); err != nil {
return fmt.Errorf("installSpinifex: write machine-id: %w", err)
}
// dbus mirrors /etc/machine-id; remove it so it is re-created from the
// new ID on first boot rather than carrying a stale value.
_ = os.Remove(filepath.Join(mountRoot, "var/lib/dbus/machine-id"))
// Hostname.
hostnamePath := filepath.Join(mountRoot, "etc/hostname")
if err := os.WriteFile(hostnamePath, []byte(cfg.Hostname+"\n"), 0o644); err != nil {
return err
}
// /etc/hosts entry for the hostname.
hosts := fmt.Sprintf("127.0.0.1\tlocalhost\n127.0.1.1\t%s\n", cfg.Hostname)
if err := os.WriteFile(filepath.Join(mountRoot, "etc/hosts"), []byte(hosts), 0o644); err != nil {
return err
}
// Set root + spinifex passwords. We invoke chpasswd from the LIVE
// installer (not via `chroot`), passing the target root with -R and
// forcing -c YESCRYPT. This deliberately bypasses PAM:
// * `chroot ... chpasswd` uses /etc/pam.d/chpasswd → common-password →
// pam_unix.so with the "obscure" option, which can return
// "Authentication token manipulation error" inside a chroot for
// reasons that are awkward to diagnose (audit subsystem, locked
// shadow entries from useradd, etc.).
// * -c YESCRYPT tells chpasswd to hash locally with libcrypt and write
// directly to <root>/etc/shadow — no PAM stack involved. YESCRYPT
// matches Trixie's ENCRYPT_METHOD so subsequent logins use the same
// algorithm.
// * -R <root> opens the target's passwd/shadow directly; no bind
// mounts of /dev/urandom or /proc are needed for the password step.
if cfg.RootPassword != "" {
if err := setShadowPassword("root", cfg.RootPassword); err != nil {
return fmt.Errorf("set root password: %w", err)
}
// The spinifex account is the default interactive login on the
// node (console + SSH). Root SSH is disabled, so this is the sole
// remote entry point. The user itself is created at rootfs build
// time (build-rootfs.sh) — here we just set its password.
if err := setShadowPassword("spinifex", cfg.RootPassword); err != nil {
return fmt.Errorf("set spinifex password: %w", err)
}
}
// Write /etc/spinifex/node.conf — read at runtime by spx admin banner
// to look up the current IP dynamically (handles IP changes after install).
// MANAGEMENT_IFACE is the bridge (br-wan), not the physical NIC.
// MANAGEMENT_IP is empty for DHCP — banner's --boot-check fills it in at boot.
nodeConf := fmt.Sprintf("MANAGEMENT_IP=%s\nMANAGEMENT_IFACE=br-wan\nNODE_HOSTNAME=%s\n",
cfg.WANAddress, cfg.Hostname)
confDir := filepath.Join(mountRoot, "etc/spinifex")
if err := os.MkdirAll(confDir, 0o755); err != nil {
return err
}
if err := os.WriteFile(filepath.Join(confDir, "node.conf"), []byte(nodeConf), 0o644); err != nil {
return err
}
// dhcpcd-base is present on the installed system (used by setup-ovn.sh for
// macvlan mode). Mask the standalone dhcpcd.service so it never auto-starts
// and races with systemd-networkd's built-in DHCP client on br-wan.
if err := maskSystemdUnit(mountRoot, "dhcpcd.service"); err != nil {
slog.Warn("installSpinifex: failed to mask dhcpcd.service", "err", err)
}
return nil
}
func writeNetworkConfig(cfg *Config) error {
// IPs live on Linux bridges (br-wan, br-lan), not on the physical NICs.
// systemd-networkd owns the full lifecycle: it creates the bridge NetDevs,
// enslaves the physical NICs, and runs the DHCP client. This means the
// veth pair that setup-ovn.sh adds later (veth-wan-br) is a port of a
// networkd-known bridge, so a networkctl reload or reboot never orphans it.
netdDir := filepath.Join(mountRoot, "etc/systemd/network")
if err := os.MkdirAll(netdDir, 0o755); err != nil {
return err
}
if err := writeNetworkdBridge(netdDir, cfg.WANInterface, "br-wan", false,
cfg.WANDHCPMode, cfg.WANAddress, cfg.WANMask, cfg.WANGateway, cfg.WANDNS,
cfg.WANWiFiSSID, cfg.WANWiFiPass); err != nil {
return err
}
if cfg.LANInterface != "" {
if err := writeNetworkdBridge(netdDir, cfg.LANInterface, "br-lan", true,
cfg.LANDHCPMode, cfg.LANAddress, cfg.LANMask, "", cfg.LANDNS,
cfg.LANWiFiSSID, cfg.LANWiFiPass); err != nil {
return err
}
// br-lan timing is controlled by spinifex-lan-bridge.service, not
// networkd auto-activation — ActivationPolicy=manual in the .network
// file ensures networkd creates the bridge but does not bring it up.
if err := systemd.WriteLANBridgeUnit(mountRoot); err != nil {
return fmt.Errorf("lan bridge unit: %w", err)
}
}
// Unmask systemd-networkd-wait-online on the installed system and scope
// it to br-wan only. The live ISO masks this service (build-rootfs.sh) to
// avoid blocking the installer environment (which has no br-wan). The mask
// is copied to the installed system by copyRootfs and must be removed here
// so that network-online.target — and therefore spinifex-firstboot.service
// (After=network-online.target) — does not fire before br-wan has its
// DHCP lease and default route. Without this, setup-ovn.sh finds no default
// route and exits early, creating no veth pair.
//
// The --interface=br-wan scope means br-lan (ActivationPolicy=manual) never
// blocks the wait; --timeout=60 caps the delay on a cold switch.
waitOnlineMask := filepath.Join(mountRoot, "etc/systemd/system/systemd-networkd-wait-online.service")
_ = os.Remove(waitOnlineMask)
waitOnlineDir := filepath.Join(mountRoot, "etc/systemd/system/systemd-networkd-wait-online.service.d")
if err := os.MkdirAll(waitOnlineDir, 0o755); err != nil {
return err
}
waitOnlineConf := "[Service]\nExecStart=\nExecStart=/lib/systemd/systemd-networkd-wait-online --interface=br-wan --timeout=60\n"
if err := os.WriteFile(filepath.Join(waitOnlineDir, "spinifex-wan-only.conf"), []byte(waitOnlineConf), 0o644); err != nil {
return err
}
// Disable IPv6 via sysctl — belt-and-suspenders alongside IPv6AcceptRA=no
// in the networkd .network files.
bridges := []string{"br-wan"}
if cfg.LANInterface != "" {
bridges = append(bridges, "br-lan")
}
var sysctl strings.Builder
sysctl.WriteString("# Generated by Spinifex installer — IPv6 disabled on management bridges\n")
for _, br := range bridges {
fmt.Fprintf(&sysctl, "net.ipv6.conf.%s.disable_ipv6=1\n", br)
}
sysctlDir := filepath.Join(mountRoot, "etc/sysctl.d")
if err := os.MkdirAll(sysctlDir, 0o755); err != nil {
return err
}
if err := os.WriteFile(filepath.Join(sysctlDir, "99-spinifex-network.conf"), []byte(sysctl.String()), 0o644); err != nil {
return err
}
// Pin each NIC name to its MAC address via udev so the installed system
// always uses the same interface name regardless of probe order.
udevDir := filepath.Join(mountRoot, "etc/udev/rules.d")
if err := os.MkdirAll(udevDir, 0o755); err != nil {
return err
}
var udevRules strings.Builder
for _, iface := range []string{cfg.WANInterface, cfg.LANInterface} {
if iface == "" {
continue
}
mac, err := os.ReadFile("/sys/class/net/" + iface + "/address")
if err != nil {
slog.Warn("writeNetworkConfig: could not read NIC MAC, skipping udev pin", "iface", iface, "err", err)
continue
}
fmt.Fprintf(&udevRules, "SUBSYSTEM==\"net\", ACTION==\"add\", ATTR{address}==\"%s\", NAME=\"%s\"\n",
strings.TrimSpace(string(mac)), iface)
}
if udevRules.Len() > 0 {
return os.WriteFile(filepath.Join(udevDir, "70-spinifex-net.rules"), []byte(udevRules.String()), 0o644)
}
return nil
}
// writeNetworkdBridge writes the three systemd-networkd files that configure a
// physical NIC enslaved to a Linux bridge:
//
// - {prio}-spinifex-{suffix}-nic.network enslaves the NIC to the bridge
// - {prio+1}-spinifex-{suffix}.netdev declares the bridge device
// - {prio+1}-spinifex-{suffix}.network configures IP on the bridge
//
// manual=true sets ActivationPolicy=manual so networkd creates the bridge but
// does not auto-activate it — used for br-lan, which spinifex-lan-bridge.service
// activates after network-online.target.
func writeNetworkdBridge(dir, nicIface, bridgeName string, manual, dhcp bool,
addr, mask, gw string, dns []string, wifiSSID, wifiPass string) error {
suffix := strings.TrimPrefix(bridgeName, "br-")
nicPrio, brPrio := 10, 11
if manual {
nicPrio, brPrio = 12, 13
}
// NIC .network — enslaves the physical NIC to the bridge.
nicNet := fmt.Sprintf("[Match]\nName=%s\n\n[Network]\nBridge=%s\n", nicIface, bridgeName)
nicFile := fmt.Sprintf("%02d-spinifex-%s-nic.network", nicPrio, suffix)
if err := os.WriteFile(filepath.Join(dir, nicFile), []byte(nicNet), 0o644); err != nil {
return err
}
// Bridge .netdev — declares the bridge device.
brNetdev := fmt.Sprintf("[NetDev]\nName=%s\nKind=bridge\n\n[Bridge]\nSTP=no\n", bridgeName)
brNetdevFile := fmt.Sprintf("%02d-spinifex-%s.netdev", brPrio, suffix)
if err := os.WriteFile(filepath.Join(dir, brNetdevFile), []byte(brNetdev), 0o644); err != nil {
return err
}
// Bridge .network — IP configuration.
var b strings.Builder
fmt.Fprintf(&b, "[Match]\nName=%s\n\n", bridgeName)
if manual {
b.WriteString("[Link]\nActivationPolicy=manual\nRequiredForOnline=no\n\n")
}
b.WriteString("[Network]\n")
if dhcp {
b.WriteString("DHCP=ipv4\n")
} else {
cidr, err := addrCIDR(addr, mask)
if err != nil {
return fmt.Errorf("bridge %s: %w", bridgeName, err)
}
fmt.Fprintf(&b, "Address=%s\n", cidr)
if gw != "" {
fmt.Fprintf(&b, "Gateway=%s\n", gw)
}
for _, ns := range dns {
if ns = strings.TrimSpace(ns); ns != "" {
fmt.Fprintf(&b, "DNS=%s\n", ns)
}
}
}
b.WriteString("IPv6AcceptRA=no\nConfigureWithoutCarrier=yes\n")
if manual && dhcp {
// br-lan is non-critical; fail fast on DHCP so a missing LAN cable
// does not stall spinifex-lan-bridge.service indefinitely.
b.WriteString("\n[DHCP]\nTimeout=10\n")
}
brNetFile := fmt.Sprintf("%02d-spinifex-%s.network", brPrio, suffix)
if err := os.WriteFile(filepath.Join(dir, brNetFile), []byte(b.String()), 0o644); err != nil {
return err
}
if wifiSSID != "" {
if err := writeWPASupplicant(nicIface, wifiSSID, wifiPass); err != nil {
return err
}
}
return nil
}
// addrCIDR converts a dotted-decimal address + subnet mask to CIDR notation.
func addrCIDR(addr, dotMask string) (string, error) {
if net.ParseIP(addr) == nil {
return "", fmt.Errorf("invalid IP: %s", addr)
}
maskIP := net.ParseIP(dotMask)
if maskIP == nil {
return "", fmt.Errorf("invalid mask: %s", dotMask)
}
m := net.IPMask(maskIP.To4())
ones, bits := m.Size()
if bits == 0 {
return "", fmt.Errorf("non-contiguous or zero subnet mask: %s", dotMask)
}
return fmt.Sprintf("%s/%d", addr, ones), nil
}
// writeWPASupplicant writes a wpa_supplicant config for nicIface and enables
// the per-interface wpa_supplicant@{iface}.service so authentication completes
// before networkd enslaves the NIC to the bridge.
func writeWPASupplicant(nicIface, ssid, psk string) error {
conf := fmt.Sprintf(
"ctrl_interface=DIR=/run/wpa_supplicant GROUP=netdev\nupdate_config=1\n\nnetwork={\n\tssid=%q\n\tpsk=%q\n}\n",
ssid, psk,
)
wpaDir := filepath.Join(mountRoot, "etc/wpa_supplicant")
if err := os.MkdirAll(wpaDir, 0o755); err != nil {
return err
}
if err := os.WriteFile(filepath.Join(wpaDir, "wpa_supplicant-"+nicIface+".conf"), []byte(conf), 0o600); err != nil {
return err
}
// Enable via symlink into multi-user.target.wants pointing at the
// package-provided template unit.
wantsDir := filepath.Join(mountRoot, "etc/systemd/system/multi-user.target.wants")
if err := os.MkdirAll(wantsDir, 0o755); err != nil {
return err
}
link := filepath.Join(wantsDir, "wpa_supplicant@"+nicIface+".service")
_ = os.Remove(link)
return os.Symlink("/lib/systemd/system/wpa_supplicant@.service", link)
}
func installBootloader(disk string) error {
// grub-install runs in the live environment (not chroot) using the
// grub-pc-bin and grub-efi-amd64-bin packages already present on the ISO.
// --boot-directory points at the installed system's /boot.
bootDir := filepath.Join(mountRoot, "boot")
efiDir := filepath.Join(mountRoot, "boot", "efi")
efiErr := run("grub-install",
"--target=x86_64-efi",
"--efi-directory="+efiDir,
"--boot-directory="+bootDir,
"--bootloader-id=spinifex",
"--removable",
"--recheck",
)
if efiErr != nil {
slog.Warn("installBootloader: EFI install failed", "err", efiErr)
}
if biosErr := run("grub-install",
"--target=i386-pc",
"--boot-directory="+bootDir,
"--recheck",
disk,
); biosErr != nil {
if efiErr != nil {
// Both targets failed — the system will not boot.
return fmt.Errorf("both bootloader targets failed (EFI: %v; BIOS: %w)", efiErr, biosErr)
}
return biosErr
}
// Copy splash image and unicode font from the ISO (mounted at /cdrom) so the
// installed GRUB shows the same branded background as the installer GRUB.
// The font must be at /boot/grub/fonts/unicode.pf2 so update-grub finds it
// there and emits the same loadfont path as the ISO's grub.cfg — GRUB 2.12
// (trixie) needs the font in the boot partition, not just /usr/share/grub/.
copySplashImage(mountRoot)
copyGrubFont(mountRoot)
// Both consoles listed: tty0 stays primary for local install, ttyS0 mirrors
// kernel output to serial so headless installs (CI, racks with serial-only
// IPMI, remote-dev SSH-to-QEMU) see boot messages. The last `console=` on
// the cmdline becomes the system console, so ttyS0 wins on serial-only
// hardware while tty0 still receives output for local display.
grubDefault := `GRUB_DEFAULT=0
GRUB_TIMEOUT=5
GRUB_DISTRIBUTOR=Spinifex
GRUB_CMDLINE_LINUX_DEFAULT=""
GRUB_CMDLINE_LINUX="console=tty0 console=ttyS0,115200 systemd.show_status=1"
`
if err := os.WriteFile(filepath.Join(mountRoot, "etc/default/grub"), []byte(grubDefault), 0o644); err != nil {
return fmt.Errorf("write /etc/default/grub: %w", err)
}
// Mirror the ISO grub.cfg graphical block exactly so the installed GRUB menu
// looks identical to the installer menu. gfxterm MUST be activated before
// serial is appended — background_image silently does nothing in text mode.
// Using exec tail so update-grub includes everything from line 3 as raw GRUB config.
grubTheme := `#!/bin/sh
exec tail -n +3 $0
insmod all_video
insmod font
if loadfont /boot/grub/fonts/unicode.pf2; then
set gfxmode=auto
insmod gfxterm
terminal_output gfxterm
fi
insmod serial
if serial --unit=0 --speed=115200 --timeout=1; then
terminal_input --append serial
terminal_output --append serial
fi
# --- Branding ---
insmod png
set theme=/boot/grub/theme.txt
export theme
`
if err := os.WriteFile(filepath.Join(mountRoot, "etc/grub.d/06_spinifex"), []byte(grubTheme), 0o755); err != nil {
return fmt.Errorf("write /etc/grub.d/06_spinifex: %w", err)
}
if err := bindChrootMounts(); err != nil {
return err
}
defer unbindChrootMounts()
return run("chroot", mountRoot, "update-grub")
}
func installCACert(cfg *Config) error {
if !cfg.HasCACert || cfg.CACert == "" {
return nil
}
certPath := filepath.Join(mountRoot, "usr/local/share/ca-certificates/spinifex-ca.crt")
if err := os.WriteFile(certPath, []byte(cfg.CACert), 0o644); err != nil {
return err
}
if err := bindChrootMounts(); err != nil {
return err
}
defer unbindChrootMounts()
return run("chroot", mountRoot, "update-ca-certificates")
}
// promptRemoveUSB prints a removal reminder and waits up to 10 seconds for
// the user to press Enter before rebooting. Reading from os.Stdin works because
// spinifex-init redirects the installer's stdin from $CONSOLE_DEV.
func promptRemoveUSB() {
fmt.Println("\n\033[1mInstallation complete.\033[0m")
fmt.Println("Remove the USB drive, then press Enter to reboot (auto-rebooting in 10 seconds)...")
done := make(chan struct{})
go func() {
scanner := bufio.NewScanner(os.Stdin)
scanner.Scan()
close(done)
}()
select {
case <-done:
case <-time.After(10 * time.Second):
}
}
// fireInstallCallback notifies the boot controller that installation is done
// so it clears the PXE install flag before the node reboots. Without this,
// a PXE-first boot order causes the node to reinstall on every reboot until
// firstboot fires the callback — which it can never do if it never runs.
// No-op when SPINIFEX_INSTALL_CALLBACK is not set (ISO/USB installs).
func fireInstallCallback() {
url := strings.TrimSpace(os.Getenv("SPINIFEX_INSTALL_CALLBACK"))
if url == "" {
return
}
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Get(url) //nolint:noctx // installer has no context; best-effort fire-and-forget
if err != nil {
slog.Warn("fireInstallCallback: request failed", "url", url, "err", err)
return
}
resp.Body.Close()
slog.Info("fireInstallCallback: notified boot controller", "url", url, "status", resp.StatusCode)
}
func reboot() error {
// sync filesystems before reboot so nothing is lost.
_ = run("sync")
// Use the kernel syscall directly — the live environment runs spinifex-init
// as PID 1 (not systemd), so the reboot(8) utility fails trying to reach D-Bus.
return syscall.Reboot(syscall.LINUX_REBOOT_CMD_RESTART)
}
// toFirstbootConfig maps installer Config to the firstboot package's Config.
func (c *Config) toFirstbootConfig() firstboot.Config {
// Geneve encap IP: use LAN bridge IP when a dedicated LAN NIC is present,
// otherwise fall back to WAN bridge IP. Empty for DHCP — setup-ovn.sh
// auto-detects the IP from the default route at boot in that case.
encapIP := c.WANAddress
if c.LANInterface != "" && c.LANAddress != "" {
encapIP = c.LANAddress
}
return firstboot.Config{
Hostname: c.Hostname,
EncapIP: encapIP,
ClusterRole: c.ClusterRole,
JoinAddr: c.JoinAddr,
Email: c.Email,
InstallCallback: strings.TrimSpace(os.Getenv("SPINIFEX_INSTALL_CALLBACK")),
SkipFormation: c.SkipFormation,
}
}
// writeFstab writes /etc/fstab on the installed system using partition UUIDs so
// the root filesystem is mounted read-write at boot and the EFI partition is
// mounted at /boot/efi.
func writeFstab(disk string) error {
efi, root := partitionPaths(disk)
rootUUID, err := partUUID(root)
if err != nil {
return fmt.Errorf("get root UUID: %w", err)
}
efiUUID, err := partUUID(efi)
if err != nil {
return fmt.Errorf("get EFI UUID: %w", err)
}
fstab := fmt.Sprintf("# /etc/fstab — generated by Spinifex installer\nUUID=%s / ext4 errors=remount-ro 0 1\nUUID=%s /boot/efi vfat umask=0077 0 1\n",
rootUUID, efiUUID)
return os.WriteFile(filepath.Join(mountRoot, "etc/fstab"), []byte(fstab), 0o644)
}
func partUUID(dev string) (string, error) {
out, err := exec.Command("blkid", "-s", "UUID", "-o", "value", dev).Output()
if err != nil {
return "", fmt.Errorf("blkid %s: %w", dev, err)
}
uuid := strings.TrimSpace(string(out))
if uuid == "" {
return "", fmt.Errorf("blkid returned no UUID for %s — partition may not have a filesystem yet", dev)
}
return uuid, nil
}
// partitionPaths returns the EFI and root partition device paths for a given
// disk. p1 is the BIOS Boot Partition (no filesystem), p2 is EFI, p3 is root.
// Handles both /dev/sdX (→ /dev/sdX2, /dev/sdX3) and /dev/nvmeXnY
// (→ /dev/nvmeXnYp2, /dev/nvmeXnYp3).
func partitionPaths(disk string) (efi, root string) {
// NVMe devices use a 'p' separator before the partition number.
if len(disk) > 0 && disk[len(disk)-1] >= '0' && disk[len(disk)-1] <= '9' {
return disk + "p2", disk + "p3"
}
return disk + "2", disk + "3"
}
// copyGrubFont copies the unicode.pf2 GRUB font into the installed system's
// /boot/grub/fonts/ directory. This ensures update-grub finds the font at
// /boot/grub/fonts/unicode.pf2 — the same path the ISO's grub.cfg uses —
// so the generated grub.cfg enables gfxterm and the background image.
// Without this, grub-mkconfig falls back to /usr/share/grub/unicode.pf2,
// a path that GRUB 2.12 (trixie) may fail to resolve at boot time.
// Non-fatal — a missing source is logged and skipped.
func copyGrubFont(root string) {
candidates := []string{
"/cdrom/boot/grub/fonts/unicode.pf2", // ISO tree (preferred)
"/usr/share/grub/unicode.pf2", // live system grub-common fallback
}
for _, src := range candidates {
in, err := os.Open(src)
if err != nil {
continue
}
defer in.Close()
dstDir := filepath.Join(root, "boot/grub/fonts")
if err := os.MkdirAll(dstDir, 0o755); err != nil {
slog.Warn("copyGrubFont: cannot create fonts dir", "err", err)
return
}
out, err := os.OpenFile(filepath.Join(dstDir, "unicode.pf2"), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
if err != nil {
slog.Warn("copyGrubFont: cannot open destination", "err", err)
return
}
defer out.Close()
if _, err := io.Copy(out, in); err != nil {
slog.Warn("copyGrubFont: copy failed", "err", err)
}
return
}
slog.Warn("copyGrubFont: no unicode.pf2 found, splash may not display")
}
// copySplashImage copies the GRUB splash from the live ISO (/cdrom/boot/grub/splash.png)
// into the installed system so the post-install GRUB shows the same branded background
// as the installer. Non-fatal — a missing or unreadable source is logged and skipped.
func copySplashImage(root string) {
const src = "/usr/share/spinifex/grub-splash.png"
in, err := os.Open(src)
if err != nil {
slog.Warn("copySplashImage: splash not found, skipping", "path", src)
return
}
defer in.Close()
dstDir := filepath.Join(root, "boot/grub")
if err := os.MkdirAll(dstDir, 0o755); err != nil {
slog.Warn("copySplashImage: cannot create boot/grub dir", "err", err)
return
}
out, err := os.OpenFile(filepath.Join(dstDir, "splash.png"), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
if err != nil {
slog.Warn("copySplashImage: cannot open destination", "err", err)
return
}
defer out.Close()
if _, err := io.Copy(out, in); err != nil {
slog.Warn("copySplashImage: copy failed", "err", err)
}
}
// maskSystemdUnit creates a symlink to /dev/null for the given unit, which is
// the standard way to permanently disable a unit so systemd will never start it.
func maskSystemdUnit(root, unit string) error {
unitPath := filepath.Join(root, "etc/systemd/system", unit)
_ = os.Remove(unitPath)
return os.Symlink("/dev/null", unitPath)
}
// setShadowPassword sets a Unix password on the installed system without
// going through PAM. See the long comment in installSpinifex for the
// rationale.
func setShadowPassword(user, password string) error {
cmd := exec.Command("chpasswd", "-c", "YESCRYPT", "-R", mountRoot)
cmd.Stdin = strings.NewReader(user + ":" + password)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
// chrootMountPaths lists virtual filesystems to bind-mount into the chroot.
// Order matters: unbind in reverse.
var chrootMountPaths = []string{"dev", "proc", "sys"}
// bindChrootMounts bind-mounts /dev, /proc, and /sys into the installed rootfs
// so chroot commands (chpasswd, systemd-machine-id-setup, update-grub) can
// access hardware, process info, and entropy sources. Idempotent — already-
// mounted paths are skipped.
func bindChrootMounts() error {
for _, m := range chrootMountPaths {
dst := filepath.Join(mountRoot, m)
if err := os.MkdirAll(dst, 0o755); err != nil {
return fmt.Errorf("create chroot mountpoint /%s: %w", m, err)
}
if err := run("mount", "--bind", "/"+m, dst); err != nil {
return fmt.Errorf("bind-mount /%s into chroot: %w", m, err)
}
}
return nil
}
// unbindChrootMounts unmounts the virtual filesystems in reverse order.
// Errors are logged but not returned — this is best-effort cleanup.
func unbindChrootMounts() {
for _, v := range slices.Backward(chrootMountPaths) {
_ = run("umount", filepath.Join(mountRoot, v))
}
}
func run(name string, args ...string) error {
cmd := exec.Command(name, args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
return cmd.Run()
}
+27
View File
@@ -0,0 +1,27 @@
package install
import (
"os"
"testing"
)
func TestToFirstbootConfigIncludesInstallCallback(t *testing.T) {
const url = "http://192.168.1.12/boot/done?mac=aa:bb:cc:dd:ee:ff"
t.Setenv("SPINIFEX_INSTALL_CALLBACK", url)
cfg := &Config{Hostname: "node1", ClusterRole: "init"}
fb := cfg.toFirstbootConfig()
if fb.InstallCallback != url {
t.Errorf("InstallCallback = %q, want %q", fb.InstallCallback, url)
}
}
func TestToFirstbootConfigEmptyCallbackWhenEnvUnset(t *testing.T) {
os.Unsetenv("SPINIFEX_INSTALL_CALLBACK")
cfg := &Config{Hostname: "node1", ClusterRole: "init"}
fb := cfg.toFirstbootConfig()
if fb.InstallCallback != "" {
t.Errorf("InstallCallback = %q, want empty", fb.InstallCallback)
}
}
+87
View File
@@ -0,0 +1,87 @@
/*
Copyright © 2026 Mulga Defense Corporation
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package main
import (
"fmt"
"os"
"strings"
"github.com/mulgadc/spinifex/cmd/installer/autoinstall"
"github.com/mulgadc/spinifex/cmd/installer/install"
"github.com/mulgadc/spinifex/cmd/installer/ui"
_ "github.com/mulgadc/spinifex/internal/fipsboot"
)
func main() {
// Check for a headless autoinstall config on the boot media before
// launching the interactive TUI. If found and enabled, run without
// any user input then eject the USB and reboot.
if cfg, err := autoinstall.Load(); err != nil {
fmt.Fprintf(os.Stderr, "autoinstall: %v\n", err)
os.Exit(1)
} else if cfg != nil {
if err := install.Run(cfg); err != nil {
fmt.Fprintf(os.Stderr, "install failed: %v\n", err)
os.Exit(1)
}
autoinstall.EjectAndReboot()
return
}
// Normal interactive path.
ttyPath := detectTTY()
cfg, err := ui.Run(ttyPath)
if err != nil {
fmt.Fprintf(os.Stderr, "installer: %v\n", err)
os.Exit(1)
}
if err := install.Run(cfg); err != nil {
fmt.Fprintf(os.Stderr, "install failed: %v\n", err)
os.Exit(1)
}
}
// detectTTY returns the TTY device path the installer should attach to.
// It first checks the SPINIFEX_CONSOLE environment variable (set by
// spinifex-init.sh after console fallback logic), then falls back to parsing
// /proc/cmdline. spinifex-init.sh may override the GRUB-set value (e.g. when
// GRUB says tty1 but no framebuffer exists, it falls back to ttyS0 and
// exports the corrected value).
func detectTTY() string {
// Prefer the env var — spinifex-init.sh sets this after resolving the
// actual console (including headless/framebuffer fallback).
if val := os.Getenv("SPINIFEX_CONSOLE"); val != "" && val != "auto" {
return "/dev/" + val
}
// Fall back to kernel cmdline for direct invocation without spinifex-init.
data, err := os.ReadFile("/proc/cmdline")
if err == nil {
for param := range strings.FieldsSeq(string(data)) {
if after, ok := strings.CutPrefix(param, "SPINIFEX_CONSOLE="); ok {
val := after
if val != "" && val != "auto" {
return "/dev/" + val
}
}
}
}
return "/dev/tty1"
}
+182
View File
@@ -0,0 +1,182 @@
/*
Copyright © 2026 Mulga Defense Corporation
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// Package systemd writes systemd unit files and drop-ins into an installed
// system root during the Spinifex ISO installation process.
package systemd
import (
"fmt"
"os"
"path/filepath"
)
// WriteLANBridgeUnit installs a non-critical oneshot service that activates
// br-lan via networkctl *after* network-online.target. br-lan has
// ActivationPolicy=manual in its networkd .network file, so networkd creates
// the bridge at boot but does not bring it up automatically. This service
// triggers the activation once the WAN path is confirmed online, ensuring a
// missing LAN cable or DHCP timeout on the secondary bridge never stalls
// the management interface or firstboot.
func WriteLANBridgeUnit(root string) error {
unit := `[Unit]
Description=Spinifex LAN bridge (non-critical)
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/bin/networkctl up br-lan
RemainAfterExit=yes
# Failure is non-critical — cable unplugged or switch not ready at boot.
SuccessExitStatus=0 1
[Install]
WantedBy=multi-user.target
`
unitPath := filepath.Join(root, "etc/systemd/system/spinifex-lan-bridge.service")
if err := os.WriteFile(unitPath, []byte(unit), 0o644); err != nil {
return err
}
wantsDir := filepath.Join(root, "etc/systemd/system/multi-user.target.wants")
if err := os.MkdirAll(wantsDir, 0o755); err != nil {
return err
}
link := filepath.Join(wantsDir, "spinifex-lan-bridge.service")
if err := os.Remove(link); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("remove stale symlink %s: %w", link, err)
}
return os.Symlink("/etc/systemd/system/spinifex-lan-bridge.service", link)
}
// WriteFirstbootUnit writes the spinifex-firstboot.service oneshot unit that
// runs the firstboot provisioning script on the first real boot after
// installation. The script writes /var/lib/spinifex/.firstboot-done on
// success; ConditionPathExists=! on that marker condition-skips the unit on
// every subsequent boot so it never re-runs (replacing the previous
// "disable on EXIT" trap which couldn't distinguish success from failure).
func WriteFirstbootUnit(root string) error {
unit := `[Unit]
Description=Spinifex first-boot provisioning
After=network-online.target
Wants=network-online.target
ConditionPathExists=/usr/local/bin/spinifex-firstboot.sh
ConditionPathExists=!/var/lib/spinifex/.firstboot-done
[Service]
Type=oneshot
Environment=HOME=/root
ExecStart=/usr/local/bin/spinifex-firstboot.sh
RemainAfterExit=yes
# On non-zero exit, overwrite /etc/issue with a hint so the operator sees
# the failure on the console — the banner unit is never enabled in that
# case (firstboot didn't reach its enable --now line), so /etc/issue would
# otherwise stay at the Debian default with no signal that anything is wrong.
ExecStopPost=/bin/sh -c 'if [ "$EXIT_STATUS" != "0" ]; then printf "\n*** Spinifex firstboot FAILED ***\nLogin as spinifex, then run:\n sudo journalctl -u spinifex-firstboot.service\n\n" > /etc/issue; fi'
# Cap total firstboot runtime so a hang in setup-ovn.sh / spx admin init /
# ovn-central startup cannot wedge multi-user.target and keep getty from
# ever reaching the login prompt.
TimeoutStartSec=300s
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
`
path := filepath.Join(root, "etc/systemd/system/spinifex-firstboot.service")
return os.WriteFile(path, []byte(unit), 0o644)
}
// WriteBannerUnit writes the spinifex-banner.service unit that runs
// `spx admin banner --boot-check` on every boot after spinifex.target is up.
// Running After=spinifex.target ensures the banner reflects a settled system
// state and that the IP check/restart happens once services are already running.
//
// The unit file is written by the installer but NOT enabled at install time.
// firstboot enables it (along with spinifex.target) at the end of its first
// run via `systemctl enable --now`. On every subsequent boot both units are
// driven by the multi-user.target.wants/ symlinks firstboot installed.
//
// Note: only After=spinifex.target — no Wants=. A Wants= would implicitly
// pull spinifex.target up at boot, which is exactly the race this whole
// change is fixing. After= alone is a no-op when the target isn't started,
// which is correct on first boot before firstboot has provisioned configs.
func WriteBannerUnit(root string) error {
unit := `[Unit]
Description=Spinifex console banner and boot health check
After=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/bin/spx admin banner --boot-check
RemainAfterExit=yes
# Banner is oneshot; cap it so a stuck boot-check (IP detection, try-restart)
# cannot block getty via the spinifex-wait.conf drop-in.
TimeoutStartSec=30s
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
`
path := filepath.Join(root, "etc/systemd/system/spinifex-banner.service")
return os.WriteFile(path, []byte(unit), 0o644)
}
// WriteGettyDropIn holds the primary consoles (tty1 and ttyS0) until
// spinifex-banner.service completes (when banner is enabled), so the MOTD
// banner is visible before the login prompt appears. Drop-ins are scoped to
// named instances (getty@tty1, serial-getty@ttyS0) so tty2, tty3, etc.
// remain available as rescue terminals.
//
// Only After=, not Wants=. A Wants= would implicitly start the (un-enabled)
// banner on the very first boot, partially undoing the "only firstboot is
// enabled on first boot" guarantee. On first boot, banner is not enabled,
// so After= is a no-op and login appears immediately with the default
// /etc/issue. Once firstboot completes and enables the banner, subsequent
// boots have banner enabled at multi-user.target and After= correctly
// orders getty after the banner has rendered /etc/issue.
func WriteGettyDropIn(root string) error {
dropIn := `[Unit]
After=spinifex-firstboot.service
`
for _, svc := range []string{"getty@tty1", "serial-getty@ttyS0"} {
dropInDir := filepath.Join(root, "etc/systemd/system/"+svc+".service.d")
if err := os.MkdirAll(dropInDir, 0o755); err != nil {
return err
}
if err := os.WriteFile(filepath.Join(dropInDir, "spinifex-wait.conf"), []byte(dropIn), 0o644); err != nil {
return err
}
}
return nil
}
// EnableUnit creates the multi-user.target.wants symlink for serviceName,
// equivalent to `systemctl enable` for a unit that targets multi-user.target.
func EnableUnit(root, serviceName string) error {
wantsDir := filepath.Join(root, "etc/systemd/system/multi-user.target.wants")
if err := os.MkdirAll(wantsDir, 0o755); err != nil {
return err
}
link := filepath.Join(wantsDir, serviceName)
target := "/etc/systemd/system/" + serviceName
if err := os.Remove(link); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("remove stale symlink %s: %w", link, err)
}
return os.Symlink(target, link)
}
File diff suppressed because it is too large Load Diff
+92
View File
@@ -0,0 +1,92 @@
// lb-agent runs inside load balancer VMs and manages HAProxy configuration.
//
// It polls the AWS gateway for config updates and reports health via heartbeats.
// All communication uses SigV4-signed HTTP requests.
//
// Usage:
//
// lb-agent --lb-id=lb-xxxxx --gateway=https://192.168.1.33:9999 --access-key=AKIA... --secret-key=...
//
// Flags default to environment variables set by cloud-init:
// LB_LB_ID, LB_GATEWAY_URL, LB_ACCESS_KEY, LB_SECRET_KEY, LB_REGION.
package main
import (
"flag"
"log/slog"
"os"
"os/signal"
"syscall"
"github.com/mulgadc/spinifex/spinifex/lbagent"
_ "github.com/mulgadc/spinifex/internal/fipsboot"
)
func main() {
var (
lbID string
gatewayURL string
accessKey string
secretKey string
region string
)
hostname, _ := os.Hostname()
flag.StringVar(&lbID, "lb-id", envOrDefault("LB_LB_ID", hostname), "Load balancer ID")
flag.StringVar(&gatewayURL, "gateway", os.Getenv("LB_GATEWAY_URL"), "Gateway URL (e.g. https://192.168.1.33:9999)")
flag.StringVar(&accessKey, "access-key", os.Getenv("LB_ACCESS_KEY"), "AWS access key ID")
flag.StringVar(&secretKey, "secret-key", os.Getenv("LB_SECRET_KEY"), "AWS secret access key")
flag.StringVar(&region, "region", envOrDefault("LB_REGION", "us-east-1"), "AWS region for SigV4 signing")
flag.Parse()
if lbID == "" {
slog.Error("--lb-id is required (or set LB_LB_ID)")
os.Exit(1)
}
if gatewayURL == "" {
slog.Error("--gateway is required (or set LB_GATEWAY_URL)")
os.Exit(1)
}
if accessKey == "" || secretKey == "" {
slog.Error("--access-key and --secret-key are required (or set LB_ACCESS_KEY / LB_SECRET_KEY)")
os.Exit(1)
}
slog.Info("Starting LB agent", "lbId", lbID, "gateway", gatewayURL)
agent, err := lbagent.New(lbID, gatewayURL, accessKey, secretKey, region)
if err != nil {
slog.Error("Failed to create agent", "err", err)
os.Exit(1)
}
// Start poll loop in a goroutine — it blocks until Stop is called.
errCh := make(chan error, 1)
go func() {
if err := agent.Start(); err != nil {
errCh <- err
}
}()
// Wait for shutdown signal or fatal error.
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT)
select {
case sig := <-sigCh:
slog.Info("Received signal, shutting down", "signal", sig)
case err := <-errCh:
slog.Error("Agent error", "err", err)
}
agent.Stop()
}
func envOrDefault(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
+1022 -267
View File
File diff suppressed because it is too large Load Diff
+486
View File
@@ -0,0 +1,486 @@
package cmd
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/mulgadc/spinifex/spinifex/admin"
"github.com/mulgadc/spinifex/spinifex/config"
"github.com/mulgadc/spinifex/spinifex/gpu"
"github.com/mulgadc/spinifex/spinifex/types"
"github.com/spf13/cobra"
"github.com/spf13/viper"
)
var adminGpuCmd = &cobra.Command{
Use: "gpu",
Short: "Manage GPU passthrough for a node",
}
var adminGpuStatusCmd = &cobra.Command{
Use: "status",
Short: "Show GPU hardware and passthrough state",
Run: runAdminGpuStatus,
}
var adminGpuEnableCmd = &cobra.Command{
Use: "enable",
Short: "Enable GPU passthrough on a node",
Run: runAdminGpuEnable,
}
var adminGpuDisableCmd = &cobra.Command{
Use: "disable",
Short: "Disable GPU passthrough on a node (blocked while GPU instances are running)",
Run: runAdminGpuDisable,
}
var adminGpuSetupCmd = &cobra.Command{
Use: "setup",
Short: "Configure this host for GPU passthrough (IOMMU, vfio-pci, nouveau blacklist)",
Long: `Idempotent host setup for GPU passthrough. Run once before a reboot to configure
GRUB, vfio-pci early binding, and the nouveau blacklist; then run again after the
reboot to verify bindings and enable GPU passthrough in the daemon.
Must be run as root.`,
Run: runAdminGpuSetup,
}
func init() {
adminCmd.AddCommand(adminGpuCmd)
adminGpuCmd.AddCommand(adminGpuStatusCmd)
adminGpuCmd.AddCommand(adminGpuEnableCmd)
adminGpuCmd.AddCommand(adminGpuDisableCmd)
adminGpuCmd.AddCommand(adminGpuSetupCmd)
adminGpuStatusCmd.Flags().String("node", "", "Target node name (default: local node)")
// enable, disable, and setup write to the local spinifex.toml and signal the
// local daemon — they must be run directly on the target host.
}
// gpuNodeStatus queries NATS and returns the NodeStatusResponse for the target node.
func gpuNodeStatus(targetNode string) (*types.NodeStatusResponse, error) {
cfg, nc, err := loadConfigAndConnect()
if err != nil {
return nil, err
}
defer nc.Close()
if targetNode == "" {
targetNode = cfg.Node
}
responses, err := collectResponses(nc, "spinifex.node.status", 3*time.Second)
if err != nil {
return nil, fmt.Errorf("collect node status: %w", err)
}
for _, raw := range responses {
var resp types.NodeStatusResponse
if err := json.Unmarshal(raw, &resp); err != nil {
continue
}
if resp.Node == targetNode {
return &resp, nil
}
}
return nil, fmt.Errorf("node %q not found or not responding", targetNode)
}
func runAdminGpuStatus(cmd *cobra.Command, _ []string) {
targetNode, _ := cmd.Flags().GetString("node")
resp, err := gpuNodeStatus(targetNode)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
iommu := "unknown"
vfio := "unknown"
if resp.GPUCapable || len(resp.GPUModels) > 0 {
iommu = "active"
vfio = "loaded"
}
fmt.Printf("Node: %s\n", resp.Node)
if len(resp.GPUModels) > 0 {
fmt.Printf("GPU hardware: %s\n", strings.Join(resp.GPUModels, ", "))
} else {
fmt.Printf("GPU hardware: none detected\n")
}
fmt.Printf("IOMMU: %s\n", iommu)
fmt.Printf("vfio-pci: %s\n", vfio)
if resp.GPUPassthrough {
fmt.Printf("Passthrough: enabled\n")
fmt.Printf("GPU pool: %d/%d allocated\n", resp.AllocGPUs, resp.TotalGPUs)
var gpuTypes []string
for _, cap := range resp.InstanceTypes {
gpuTypes = append(gpuTypes, cap.Name)
}
if len(gpuTypes) > 0 {
fmt.Printf("Instance types: %s\n", strings.Join(gpuTypes, " "))
}
} else if resp.GPUCapable {
fmt.Printf("Passthrough: disabled\n")
fmt.Printf("Instance types: (none — run 'spx admin gpu enable' to activate)\n")
} else {
fmt.Printf("Passthrough: disabled\n")
fmt.Printf("Instance types: (prerequisites not met)\n")
}
}
// gpuToggle writes the TOML setting, sends SIGHUP, and polls until the daemon confirms the new state.
// enable/disable always operate on the local node; run the command directly on the target host.
func gpuToggle(_ *cobra.Command, enable bool) {
cfgPath := viper.GetString("config")
if cfgPath == "" {
cfgPath = DefaultConfigFile()
}
cfg, err := config.LoadConfig(cfgPath)
if err != nil {
fmt.Fprintf(os.Stderr, "Error loading config: %v\n", err)
os.Exit(1)
}
localNode := cfg.Node
// Check current state first.
resp, err := gpuNodeStatus(localNode)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
if enable {
if resp.GPUPassthrough {
fmt.Println("GPU passthrough is already enabled.")
return
}
if !resp.GPUCapable {
fmt.Fprintln(os.Stderr, "Error: prerequisites not met on this node.")
fmt.Fprintln(os.Stderr, " Run 'sudo spx admin gpu setup' to configure the host.")
os.Exit(1)
}
} else {
if !resp.GPUPassthrough {
fmt.Println("GPU passthrough is already disabled.")
return
}
if resp.AllocGPUs > 0 {
fmt.Fprintf(os.Stderr, "Error: %d GPU instance(s) are running. Terminate them first.\n", resp.AllocGPUs)
os.Exit(1)
}
}
// Write the TOML setting.
if err := admin.SetGPUPassthrough(cfgPath, localNode, enable); err != nil {
fmt.Fprintf(os.Stderr, "Error writing config: %v\n", err)
os.Exit(1)
}
// Signal the daemon to reload.
out, err := exec.Command("systemctl", "kill", "-s", "HUP", "spinifex-daemon").CombinedOutput()
if err != nil {
fmt.Fprintf(os.Stderr, "Error sending SIGHUP: %v\n%s\n", err, out)
os.Exit(1)
}
// Poll until the daemon confirms the new state.
action := "enable"
if !enable {
action = "disable"
}
fmt.Printf("Waiting for daemon to %s GPU passthrough", action)
deadline := time.Now().Add(30 * time.Second)
for time.Now().Before(deadline) {
time.Sleep(1 * time.Second)
fmt.Print(".")
r, err := gpuNodeStatus(localNode)
if err != nil {
continue
}
if r.GPUPassthrough == enable {
fmt.Println(" done.")
// Refresh the MOTD banner immediately so the login prompt
// reflects the new GPU passthrough state without requiring a reboot.
_ = exec.Command("spx", "admin", "banner", "--boot-check").Run()
runAdminGpuStatus(adminGpuStatusCmd, nil)
return
}
}
fmt.Println()
fmt.Fprintln(os.Stderr, "Timed out waiting for daemon. Check: journalctl -u spinifex-daemon -n 50")
os.Exit(1)
}
func runAdminGpuEnable(cmd *cobra.Command, _ []string) {
gpuToggle(cmd, true)
}
func runAdminGpuDisable(cmd *cobra.Command, _ []string) {
gpuToggle(cmd, false)
}
func runAdminGpuSetup(_ *cobra.Command, _ []string) {
if os.Getuid() != 0 {
fmt.Fprintln(os.Stderr, "Error: spx admin gpu setup must be run as root.")
os.Exit(1)
}
fmt.Println("==> Detecting GPU")
devices, err := gpu.Discover()
if err != nil {
fmt.Fprintf(os.Stderr, "GPU discovery failed: %v\n", err)
os.Exit(1)
}
if len(devices) == 0 {
fmt.Fprintln(os.Stderr, "No NVIDIA or AMD GPU found — is the card seated?")
os.Exit(1)
}
for _, d := range devices {
fmt.Printf(" %s - %s\n", d.PCIAddress, d.Model)
}
fmt.Println("==> Collecting PCI IDs for vfio-pci")
ids := gpuSetupCollectVFIOIDs(devices)
idsCSV := strings.Join(ids, ",")
fmt.Printf(" IDs: %s\n", idsCSV)
rebootNeeded := false
fmt.Println("==> Checking IOMMU")
iommuEntries, _ := os.ReadDir("/sys/kernel/iommu_groups/")
if len(iommuEntries) > 0 {
fmt.Println(" Active")
} else {
params := gpuSetupIOMMUParams()
changed, err := gpuSetupAddGRUBParams(params)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to update GRUB: %v\n", err)
os.Exit(1)
}
if changed {
fmt.Printf(" GRUB updated with: %s\n", params)
} else {
fmt.Println(" GRUB already has IOMMU params — refreshing")
}
ug := exec.Command("update-grub")
ug.Stdout, ug.Stderr = os.Stdout, os.Stderr
if err := ug.Run(); err != nil {
fmt.Fprintf(os.Stderr, "update-grub failed: %v\n", err)
os.Exit(1)
}
rebootNeeded = true
}
fmt.Println("==> Configuring vfio udev rule")
const vfioUdevRule = "/etc/udev/rules.d/99-spinifex-vfio.rules"
if _, err := os.Stat(vfioUdevRule); os.IsNotExist(err) {
rule := "SUBSYSTEM==\"vfio\", GROUP=\"spinifex\", MODE=\"0660\"\n"
if err := os.WriteFile(vfioUdevRule, []byte(rule), 0o644); err != nil {
fmt.Fprintf(os.Stderr, "Failed to write vfio udev rule: %v\n", err)
os.Exit(1)
}
fmt.Println(" Rule installed")
_ = exec.Command("udevadm", "control", "--reload-rules").Run()
_ = exec.Command("udevadm", "trigger", "--subsystem-match=vfio").Run()
} else {
fmt.Println(" Rule already present")
}
fmt.Println("==> Configuring nouveau blacklist")
const nouveauConf = "/etc/modprobe.d/blacklist-nouveau.conf"
if _, err := os.Stat(nouveauConf); os.IsNotExist(err) {
if err := os.WriteFile(nouveauConf, []byte("blacklist nouveau\noptions nouveau modeset=0\n"), 0o644); err != nil {
fmt.Fprintf(os.Stderr, "Failed to write nouveau blacklist: %v\n", err)
os.Exit(1)
}
fmt.Println(" nouveau blacklisted")
rebootNeeded = true
} else {
fmt.Println(" Already blacklisted")
}
fmt.Println("==> Configuring amdgpu blacklist")
const amdgpuConf = "/etc/modprobe.d/blacklist-amdgpu.conf"
hasAMD := false
for _, d := range devices {
if d.Vendor == gpu.VendorAMD {
hasAMD = true
break
}
}
if hasAMD {
if _, err := os.Stat(amdgpuConf); os.IsNotExist(err) {
if err := os.WriteFile(amdgpuConf, []byte("blacklist amdgpu\n"), 0o644); err != nil {
fmt.Fprintf(os.Stderr, "Failed to write amdgpu blacklist: %v\n", err)
os.Exit(1)
}
fmt.Println(" amdgpu blacklisted")
rebootNeeded = true
} else {
fmt.Println(" Already blacklisted")
}
} else {
fmt.Println(" No AMD GPUs — skipping")
}
fmt.Println("==> Configuring vfio-pci early binding")
const vfioPCIConf = "/etc/modprobe.d/vfio-pci.conf"
existing, _ := os.ReadFile(vfioPCIConf)
if !strings.Contains(string(existing), "ids="+idsCSV) {
if err := os.WriteFile(vfioPCIConf, []byte("options vfio-pci ids="+idsCSV+"\n"), 0o644); err != nil {
fmt.Fprintf(os.Stderr, "Failed to write vfio-pci.conf: %v\n", err)
os.Exit(1)
}
fmt.Printf(" vfio-pci.conf written (ids=%s)\n", idsCSV)
rebootNeeded = true
} else {
fmt.Printf(" Already configured for %s\n", idsCSV)
}
const initramfsModules = "/etc/initramfs-tools/modules"
modData, _ := os.ReadFile(initramfsModules)
if !strings.Contains(string(modData), "vfio_pci") {
f, err := os.OpenFile(initramfsModules, os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0o644)
if err != nil {
fmt.Fprintf(os.Stderr, "Failed to open initramfs modules: %v\n", err)
os.Exit(1)
}
_, werr := fmt.Fprint(f, "\n# vfio early binding for GPU passthrough\nvfio\nvfio_iommu_type1\nvfio_pci\n")
f.Close()
if werr != nil {
fmt.Fprintf(os.Stderr, "Failed to write initramfs modules: %v\n", werr)
os.Exit(1)
}
fmt.Println(" vfio modules added to initramfs")
rebootNeeded = true
} else {
fmt.Println(" vfio modules already in initramfs")
}
if rebootNeeded {
fmt.Println("==> Updating initramfs")
ui := exec.Command("update-initramfs", "-u")
ui.Stdout, ui.Stderr = os.Stdout, os.Stderr
if err := ui.Run(); err != nil {
fmt.Fprintf(os.Stderr, "update-initramfs failed: %v\n", err)
os.Exit(1)
}
fmt.Println("\nSetup complete — reboot required.")
fmt.Println(" sudo reboot")
fmt.Println(" Then run: sudo spx admin gpu enable")
return
}
fmt.Println("==> Verifying vfio-pci module")
if _, err := os.Stat("/sys/module/vfio_pci"); os.IsNotExist(err) {
fmt.Fprintln(os.Stderr, "vfio_pci not loaded — check: journalctl -b | grep vfio")
os.Exit(1)
}
fmt.Println(" Loaded")
fmt.Println("==> Verifying GPU driver binding")
for _, d := range devices {
current, err := gpu.CurrentDriver(d.PCIAddress)
if err != nil {
fmt.Fprintf(os.Stderr, "Cannot read driver for %s: %v\n", d.PCIAddress, err)
os.Exit(1)
}
if current == "vfio-pci" {
fmt.Printf(" %s → vfio-pci\n", d.PCIAddress)
continue
}
if current != "" {
// Bound to native driver — blacklist/initramfs changes may not have taken effect.
fmt.Fprintf(os.Stderr, "%s is bound to %q, not vfio-pci — reboot may be required\n", d.PCIAddress, current)
fmt.Fprintf(os.Stderr, " Check: ls -la /sys/bus/pci/devices/%s/driver\n", d.PCIAddress)
os.Exit(1)
}
// Device is unbound (ids= did not auto-claim it) — bind explicitly via driver_override.
fmt.Printf(" %s unbound — binding to vfio-pci\n", d.PCIAddress)
if _, err := gpu.BindVFIO(d.PCIAddress); err != nil {
fmt.Fprintf(os.Stderr, "Failed to bind %s to vfio-pci: %v\n", d.PCIAddress, err)
fmt.Fprintf(os.Stderr, " Check: ls -la /sys/bus/pci/devices/%s/driver\n", d.PCIAddress)
os.Exit(1)
}
fmt.Printf(" %s → vfio-pci\n", d.PCIAddress)
}
fmt.Println("==> Enabling GPU passthrough")
gpuToggle(nil, true)
}
// gpuSetupCollectVFIOIDs collects vendor:device PCI ID pairs for each GPU and
// all sibling PCI functions on the same device slot (e.g. GPU + HDMI audio).
// All siblings must bind to vfio-pci together for IOMMU group isolation.
func gpuSetupCollectVFIOIDs(devices []gpu.GPUDevice) []string {
seen := make(map[string]bool)
var ids []string
for _, d := range devices {
dot := strings.LastIndex(d.PCIAddress, ".")
if dot < 0 {
continue
}
prefix := d.PCIAddress[:dot]
matches, _ := filepath.Glob("/sys/bus/pci/devices/" + prefix + ".*")
for _, m := range matches {
vb, err1 := os.ReadFile(m + "/vendor")
db, err2 := os.ReadFile(m + "/device")
if err1 != nil || err2 != nil {
continue
}
v := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(string(vb))), "0x")
dv := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(string(db))), "0x")
id := v + ":" + dv
if !seen[id] {
seen[id] = true
ids = append(ids, id)
}
}
}
return ids
}
func gpuSetupIOMMUParams() string {
data, _ := os.ReadFile("/proc/cpuinfo")
if strings.Contains(string(data), "GenuineIntel") {
return "intel_iommu=on iommu=pt"
}
return "amd_iommu=on iommu=pt"
}
// gpuSetupAddGRUBParams appends iommu kernel params to GRUB_CMDLINE_LINUX_DEFAULT.
// Returns (true, nil) if the file was modified, (false, nil) if params were already present.
func gpuSetupAddGRUBParams(params string) (bool, error) {
const grubFile = "/etc/default/grub"
data, err := os.ReadFile(grubFile)
if err != nil {
return false, fmt.Errorf("read %s: %w", grubFile, err)
}
content := string(data)
if strings.Contains(content, "intel_iommu=on") || strings.Contains(content, "amd_iommu=on") {
return false, nil
}
re := regexp.MustCompile(`(GRUB_CMDLINE_LINUX_DEFAULT="[^"]*)"`)
updated := re.ReplaceAllStringFunc(content, func(match string) string {
groups := re.FindStringSubmatch(match)
if len(groups) < 2 {
return match
}
return groups[1] + " " + params + `"`
})
if updated == content {
return false, fmt.Errorf("GRUB_CMDLINE_LINUX_DEFAULT not found in %s", grubFile)
}
if err := os.WriteFile(grubFile, []byte(updated), 0o644); err != nil {
return false, fmt.Errorf("write %s: %w", grubFile, err)
}
return true, nil
}
+333
View File
@@ -0,0 +1,333 @@
package cmd
import (
"bytes"
"errors"
"fmt"
"os"
"path/filepath"
"testing"
"github.com/mulgadc/spinifex/spinifex/admin"
"github.com/mulgadc/spinifex/spinifex/formation"
"github.com/mulgadc/spinifex/spinifex/utils"
"github.com/spf13/viper"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// Regression guard: source URL must print for every error kind, not just
// mismatch, so a 404/non-HTTPS/size-cap failure still tells the operator
// which URL to investigate.
func TestPrintChecksumError(t *testing.T) {
image := utils.Images{Checksum: "https://example.com/SUMS", ChecksumType: "sha512"}
const imageFile = "/var/lib/img.tar.xz"
const imageName = "debian-13-x86_64"
errs := []error{
fmt.Errorf("%w: expected abc got def", utils.ErrChecksumMismatch),
fmt.Errorf("%w: 404", utils.ErrChecksumFetchFailed),
errors.New("open /x: no such file"),
}
for _, e := range errs {
var buf bytes.Buffer
printChecksumError(&buf, imageFile, imageName, image, e)
out := buf.String()
assert.Contains(t, out, image.Checksum, "source URL must print for: %v", e)
assert.Contains(t, out, imageFile)
assert.Contains(t, out, "spx admin images import --name "+imageName+" --force")
}
}
// buildRemoteNodes must prefer AdvertiseIP (off-host dial target) and fall
// back to BindIP when the peer pre-dates siv-8 and didn't send AdvertiseIP.
func TestBuildRemoteNodes_AdvertiseFallback(t *testing.T) {
nodes := map[string]formation.NodeInfo{
"node1": {Name: "node1", BindIP: "10.0.0.1", AdvertiseIP: "203.0.113.1"},
"node2": {Name: "node2", BindIP: "10.0.0.2"}, // legacy joiner
"node3": {Name: "node3", BindIP: "10.0.0.3", AdvertiseIP: "203.0.113.3"},
}
got := buildRemoteNodes(nodes, "node3")
if assert.Len(t, got, 2) {
assert.Equal(t, "node1", got[0].Name)
assert.Equal(t, "203.0.113.1", got[0].Host, "advertise wins when set")
assert.Equal(t, "node2", got[1].Name)
assert.Equal(t, "10.0.0.2", got[1].Host, "bind fallback when advertise empty")
}
}
func TestResolveAdvertiseIP(t *testing.T) {
wan := &admin.DetectedNetwork{WAN: &admin.DetectedInterface{IP: "192.168.1.21"}}
noWAN := &admin.DetectedNetwork{}
tests := []struct {
name string
bindIP string
advertise string
detected *admin.DetectedNetwork
want string
wantErr bool
}{
{"single-node default, WAN detected", "0.0.0.0", "", wan, "192.168.1.21", false},
{"single-node default, no WAN", "0.0.0.0", "", noWAN, "127.0.0.1", false},
{"single-node explicit advertise", "0.0.0.0", "203.0.113.5", wan, "203.0.113.5", false},
{"multi-node init, specific bind", "10.11.12.1", "", nil, "10.11.12.1", false},
{"multi-node dual-homed override", "10.11.12.1", "203.0.113.5", nil, "203.0.113.5", false},
{"loopback-only test", "127.0.0.1", "", noWAN, "127.0.0.1", false},
{"invalid advertise flag", "0.0.0.0", "not-an-ip", wan, "", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := resolveAdvertiseIP(tt.bindIP, tt.advertise, tt.detected)
if tt.wantErr {
assert.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, tt.want, got)
})
}
}
// Generated spinifex.toml must include both host (listen, BindIP) and
// advertise (off-host dial target, AdvertiseIP) for the local node.
func TestSpinifexTomlTemplate_AdvertiseField(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "spinifex.toml")
settings := admin.ConfigSettings{
Node: "node1",
Az: "ap-southeast-2a",
Port: "4432",
Region: "ap-southeast-2",
BindIP: "0.0.0.0",
AdvertiseIP: "192.168.1.21",
AccessKey: "AKIATEST",
SecretKey: "SECRET",
AccountID: "123456789012",
NatsToken: "token",
ConfigDir: dir,
OVNNBAddr: "tcp:127.0.0.1:6641",
OVNSBAddr: "tcp:127.0.0.1:6642",
}
require.NoError(t, admin.GenerateConfigFile(path, spinifexTomlTemplate, settings))
data, err := os.ReadFile(path)
require.NoError(t, err)
content := string(data)
assert.Contains(t, content, `host = "0.0.0.0"`, "listen address")
assert.Contains(t, content, `advertise = "192.168.1.21"`, "off-host dial target")
}
// Empty AdvertiseIP (e.g. loading an existing cluster pre-siv-8) must NOT
// render an empty advertise = "" line — downstream fallback to Host kicks in.
func TestSpinifexTomlTemplate_AdvertiseOmittedWhenEmpty(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "spinifex.toml")
settings := admin.ConfigSettings{
Node: "node1",
Az: "ap-southeast-2a",
Port: "4432",
Region: "ap-southeast-2",
BindIP: "10.11.12.1",
AccessKey: "AKIATEST",
SecretKey: "SECRET",
AccountID: "123456789012",
NatsToken: "token",
ConfigDir: dir,
OVNNBAddr: "tcp:127.0.0.1:6641",
OVNSBAddr: "tcp:127.0.0.1:6642",
}
require.NoError(t, admin.GenerateConfigFile(path, spinifexTomlTemplate, settings))
data, _ := os.ReadFile(path)
assert.NotContains(t, string(data), "advertise =")
}
// detectedDhcpBindBridge must return the default-route interface name verbatim
// when it's a bridge (Linux or OVS, br-* prefix). The old detectedWanBridge()
// returned hardcoded "br-ext" for Linux bridges — that value broke DHCP on
// consumer-router LANs because br-ext never sees LAN DHCP traffic (mulga-998).
func TestDetectedDhcpBindBridge(t *testing.T) {
tests := []struct {
name string
detected *admin.DetectedNetwork
want string
}{
{
name: "Linux bridge default route returns bridge name (not 'br-ext')",
detected: &admin.DetectedNetwork{WAN: &admin.DetectedInterface{Name: "br-wan"}},
want: "br-wan",
},
{
name: "OVS bridge default route returned verbatim",
detected: &admin.DetectedNetwork{WAN: &admin.DetectedInterface{Name: "br-ext"}},
want: "br-ext",
},
{
name: "physical NIC defaults to 'br-wan' (bridge the installer creates)",
detected: &admin.DetectedNetwork{WAN: &admin.DetectedInterface{Name: "enp0s3"}},
want: "br-wan",
},
{
name: "nil DetectedNetwork",
detected: nil,
want: "",
},
{
name: "DetectedNetwork with nil WAN",
detected: &admin.DetectedNetwork{},
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, detectedDhcpBindBridge(tt.detected))
})
}
}
// Legacy `wan_bridge` TOML key must fail-start vpcd with guidance, not silently
// alias (per mulga-998 D3). Prevents the footgun where operators inherited the
// old key pointing at 'br-ext' and got broken DHCP on veth-mode hosts.
func TestCheckLegacyWanBridgeKey_TOMLRejected(t *testing.T) {
dir := t.TempDir()
cfgPath := filepath.Join(dir, "spinifex.toml")
require.NoError(t, os.WriteFile(cfgPath, []byte(`
node = "node1"
[nodes.node1.vpcd]
ovn_nb_addr = "tcp:127.0.0.1:6641"
wan_bridge = "br-ext"
`), 0o644))
viper.Reset()
t.Cleanup(viper.Reset)
viper.SetConfigFile(cfgPath)
viper.SetConfigType("toml")
require.NoError(t, viper.ReadInConfig())
err := checkLegacyWanBridgeKey("node1", cfgPath)
require.Error(t, err)
assert.Contains(t, err.Error(), "dhcp_bind_bridge")
assert.Contains(t, err.Error(), "wan_bridge")
}
func TestCheckLegacyWanBridgeKey_EnvVarRejected(t *testing.T) {
viper.Reset()
t.Cleanup(viper.Reset)
t.Setenv("SPINIFEX_VPCD_WAN_BRIDGE", "br-ext")
err := checkLegacyWanBridgeKey("node1", "/tmp/unused.toml")
require.Error(t, err)
assert.Contains(t, err.Error(), "SPINIFEX_VPCD_WAN_BRIDGE")
assert.Contains(t, err.Error(), "dhcp_bind_bridge")
}
func TestCheckLegacyWanBridgeKey_CleanConfigPasses(t *testing.T) {
dir := t.TempDir()
cfgPath := filepath.Join(dir, "spinifex.toml")
require.NoError(t, os.WriteFile(cfgPath, []byte(`
node = "node1"
[nodes.node1.vpcd]
ovn_nb_addr = "tcp:127.0.0.1:6641"
dhcp_bind_bridge = "br-wan"
`), 0o644))
viper.Reset()
t.Cleanup(viper.Reset)
viper.SetConfigFile(cfgPath)
viper.SetConfigType("toml")
require.NoError(t, viper.ReadInConfig())
assert.NoError(t, checkLegacyWanBridgeKey("node1", cfgPath))
}
// iam.nats_url must not embed a bare 0.0.0.0 — the predastore process dials
// its own NATS via loopback when listening on wildcard.
func TestPredastoreMultinodeTemplate_NatsURLLoopbackShim(t *testing.T) {
nodes := []admin.PredastoreNodeConfig{
{ID: 1, Host: "10.0.0.1"},
{ID: 2, Host: "10.0.0.2"},
{ID: 3, Host: "10.0.0.3"},
}
content, err := admin.GenerateMultiNodePredastoreConfig(
predastoreMultiNodeTemplate, nodes, "AK", "SK", "ap-southeast-2", "token", "/tmp", "0.0.0.0",
)
require.NoError(t, err)
assert.Contains(t, content, `nats_url = "nats://localhost:4222"`)
assert.NotContains(t, content, `nats_url = "nats://0.0.0.0:4222"`)
// Specific bind IP → stays as-is.
content2, err := admin.GenerateMultiNodePredastoreConfig(
predastoreMultiNodeTemplate, nodes, "AK", "SK", "ap-southeast-2", "token", "/tmp", "10.11.12.1",
)
require.NoError(t, err)
assert.Contains(t, content2, `nats_url = "nats://10.11.12.1:4222"`)
}
// writePredastoreEncryptionKey is called from both `spx admin init` and `spx
// admin join`; both depend on it producing a 32-byte file at 0600 in the
// expected layout. Predastore's keyfile loader rejects anything that isn't
// exactly that, so a regression here would break service startup on every
// node.
func TestWritePredastoreEncryptionKey(t *testing.T) {
configDir := t.TempDir()
keyPath, err := writePredastoreEncryptionKey(configDir)
require.NoError(t, err)
assert.Equal(t, filepath.Join(configDir, "predastore", "encryption.key"), keyPath)
info, err := os.Stat(keyPath)
require.NoError(t, err)
assert.Equal(t, int64(32), info.Size(), "predastore master key must be exactly 32 bytes")
assert.Equal(t, os.FileMode(0600), info.Mode().Perm(), "predastore master key must be mode 0600")
// Per-node invariant: two successive calls (on what would be two
// different nodes) must produce different key material. If they ever
// returned the same bytes we'd have silently lost the per-node
// blast-radius property.
configDir2 := t.TempDir()
keyPath2, err := writePredastoreEncryptionKey(configDir2)
require.NoError(t, err)
key1, err := os.ReadFile(keyPath)
require.NoError(t, err)
key2, err := os.ReadFile(keyPath2)
require.NoError(t, err)
assert.NotEqual(t, key1, key2, "per-node keys must differ")
}
// Joiners run `spx admin join` against a configDir that doesn't yet contain
// a predastore/ subdir. The helper must create it (predastore subdir is
// owned by spinifex-storage in prod, but the helper just needs the dir to
// exist).
func TestWritePredastoreEncryptionKey_CreatesMissingDir(t *testing.T) {
configDir := t.TempDir()
// Don't pre-create the predastore subdir — simulate a fresh joiner.
keyPath, err := writePredastoreEncryptionKey(configDir)
require.NoError(t, err)
dirInfo, err := os.Stat(filepath.Dir(keyPath))
require.NoError(t, err)
assert.True(t, dirInfo.IsDir())
}
// TestImagesRemoveCmd_FlagSchema guards the public flag surface for
// `spx admin images remove`. --image-id is required so cobra rejects a bare
// invocation; --force/--yes default to false.
func TestImagesRemoveCmd_FlagSchema(t *testing.T) {
imageIDFlag := imagesRemoveCmd.Flags().Lookup("image-id")
require.NotNil(t, imageIDFlag, "--image-id must be defined")
assert.Equal(t, []string{"true"}, imageIDFlag.Annotations[cobraRequiredAnnotation],
"--image-id must be marked required")
forceFlag := imagesRemoveCmd.Flags().Lookup("force")
require.NotNil(t, forceFlag)
assert.Equal(t, "false", forceFlag.DefValue)
yesFlag := imagesRemoveCmd.Flags().Lookup("yes")
require.NotNil(t, yesFlag)
assert.Equal(t, "false", yesFlag.DefValue)
}
// cobraRequiredAnnotation is the annotation key cobra uses to mark required flags.
const cobraRequiredAnnotation = "cobra_annotation_bash_completion_one_required_flag"
+92
View File
@@ -0,0 +1,92 @@
package cmd
import (
"bufio"
"fmt"
"os"
"strings"
"github.com/mulgadc/spinifex/spinifex/migrate"
"github.com/spf13/cobra"
)
var upgradeCmd = &cobra.Command{
Use: "upgrade",
Short: "Apply pending config migrations",
Long: `Apply pending configuration file migrations for upgrades between Spinifex versions.
When run interactively (without --yes), shows pending migrations and prompts
for confirmation. When called from setup.sh with --yes, applies immediately.
Operators can skip migrations during install by setting INSTALL_SPINIFEX_SKIP_MIGRATE=1,
then run 'spx admin upgrade' manually to review and apply.`,
Run: runAdminUpgrade,
}
func runAdminUpgrade(cmd *cobra.Command, _ []string) {
configDir, _ := cmd.Root().Flags().GetString("config-dir")
dataDir, _ := cmd.Root().Flags().GetString("spinifex-dir")
yes, _ := cmd.Flags().GetBool("yes")
// Check that the installation exists.
spinifexToml := configDir + "/spinifex.toml"
if _, err := os.Stat(spinifexToml); os.IsNotExist(err) {
fmt.Fprintf(os.Stderr, "No Spinifex installation found at %s\nRun 'spx admin init' first.\n", configDir)
os.Exit(1)
}
// Show current versions.
versions, err := migrate.DefaultRegistry.ConfigVersions(configDir)
if err != nil {
fmt.Fprintf(os.Stderr, "Error reading config versions: %v\n", err)
os.Exit(1)
}
fmt.Println("Reading current config versions...")
for name, v := range versions {
if v == 0 {
fmt.Printf(" %-20s %d (no version marker)\n", name+":", v)
} else {
fmt.Printf(" %-20s %d\n", name+":", v)
}
}
fmt.Println()
// Check for pending migrations.
pending, err := migrate.DefaultRegistry.PendingConfig(configDir)
if err != nil {
fmt.Fprintf(os.Stderr, "Error checking pending migrations: %v\n", err)
os.Exit(1)
}
if len(pending) == 0 {
fmt.Println("No pending config migrations.")
return
}
fmt.Println("Pending config migrations:")
for _, p := range pending {
fmt.Printf(" [config] %s %d → %d: %s\n", p.Target, p.FromVersion, p.ToVersion, p.Description)
}
fmt.Println()
// Prompt for confirmation unless --yes.
if !yes {
fmt.Print("Apply? [y/N] ")
reader := bufio.NewReader(os.Stdin)
answer, _ := reader.ReadString('\n')
answer = strings.TrimSpace(strings.ToLower(answer))
if answer != "y" && answer != "yes" {
fmt.Println("Aborted.")
return
}
}
// Apply migrations.
if err := migrate.DefaultRegistry.RunAllConfig(configDir, dataDir); err != nil {
fmt.Fprintf(os.Stderr, "Migration failed: %v\n", err)
os.Exit(1)
}
fmt.Println("\nDone. Restart services to apply: sudo systemctl restart spinifex.target")
}
+1 -1
View File
@@ -98,7 +98,7 @@ func runClusterShutdown(cmd *cobra.Command, args []string) {
}
nc.Flush()
// Wait briefly for messages to propagate
time.Sleep(2 * time.Second)
time.Sleep(200 * time.Millisecond)
fmt.Printf("[INFRA] Complete\n")
break
}
+13 -5
View File
@@ -22,9 +22,11 @@ import (
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"github.com/mulgadc/spinifex/spinifex/admin"
"github.com/mulgadc/spinifex/spinifex/config"
"github.com/mulgadc/spinifex/spinifex/types"
"github.com/mulgadc/spinifex/spinifex/utils"
@@ -76,14 +78,20 @@ func loadConfigAndConnect() (*config.ClusterConfig, *nats.Conn, error) {
}
// Backfill BaseDir from config file path when not set in the config.
// Config lives at <baseDir>/config/spinifex.toml, so baseDir is two levels up.
if nodeConfig, ok := cfg.Nodes[cfg.Node]; ok && nodeConfig.BaseDir == "" {
nodeConfig.BaseDir = filepath.Dir(filepath.Dir(cfgPath))
if isProductionLayout() {
// Production: BaseDir is /var/lib/spinifex (data dir).
// /var/lib/spinifex/config is a symlink to /etc/spinifex.
nodeConfig.BaseDir = DefaultDataDir()
} else {
// Dev: config lives at <baseDir>/config/spinifex.toml, so baseDir is two levels up.
nodeConfig.BaseDir = filepath.Dir(filepath.Dir(cfgPath))
}
cfg.Nodes[cfg.Node] = nodeConfig
}
nodeConfig := cfg.Nodes[cfg.Node]
nc, err := utils.ConnectNATS(nodeConfig.NATS.Host, nodeConfig.NATS.ACL.Token)
nc, err := utils.ConnectNATS(admin.DialTarget(nodeConfig.NATS.Host), nodeConfig.NATS.ACL.Token, nodeConfig.NATS.CACert)
if err != nil {
return nil, nil, fmt.Errorf("failed to connect to NATS: %w", err)
}
@@ -215,7 +223,7 @@ func runGetNodes(cmd *cobra.Command, args []string) {
resp.Region,
resp.AZ,
formatUptime(resp.Uptime),
fmt.Sprintf("%d", resp.VMCount),
strconv.Itoa(resp.VMCount),
strings.Join(resp.Services, ","),
})
} else {
@@ -295,7 +303,7 @@ func runGetVMs(cmd *cobra.Command, args []string) {
v.InstanceID,
v.Status,
v.InstanceType,
fmt.Sprintf("%d", v.VCPU),
strconv.Itoa(v.VCPU),
formatMemGB(v.MemoryGB),
v.Node,
v.Host,
+19 -7
View File
@@ -7,7 +7,7 @@ import (
)
// DefaultConfigDir returns the default configuration directory.
// Production: /etc/spinifex (when running as root AND /etc/spinifex exists)
// Production: /etc/spinifex (when /etc/spinifex exists)
// Development: ~/spinifex/config
func DefaultConfigDir() string {
if isProductionLayout() {
@@ -26,6 +26,16 @@ func DefaultDataDir() string {
return filepath.Join(realUserHomeDir(), "spinifex")
}
// LogDirFor returns the log directory for a given data directory.
// Production: /var/log/spinifex (matches systemd ReadWritePaths)
// Development: <dataDir>/logs (supports custom per-node data dirs)
func LogDirFor(dataDir string) string {
if isProductionLayout() {
return "/var/log/spinifex"
}
return filepath.Join(dataDir, "logs")
}
// realUserHomeDir returns the home directory of the real (non-sudo) user.
// When running under sudo, SUDO_USER is set to the invoking user — resolve
// their home directory so config/data land in the right place.
@@ -44,14 +54,16 @@ func DefaultConfigFile() string {
return filepath.Join(DefaultConfigDir(), "spinifex.toml")
}
// productionMarkerPath identifies a production install by directory presence.
// Created by setup.sh during binary install. Overridable in tests.
var productionMarkerPath = "/etc/spinifex"
// isProductionLayout returns true when running in a production install.
// Detected by: running as root AND /etc/spinifex directory already exists
// (created by setup.sh during binary install).
// No root check — allows non-root users (e.g. tf-user) to run CLI commands
// like `spx get nodes` without sudo or --config flags.
func isProductionLayout() bool {
if os.Getuid() == 0 {
if info, err := os.Stat("/etc/spinifex"); err == nil && info.IsDir() {
return true
}
if info, err := os.Stat(productionMarkerPath); err == nil && info.IsDir() {
return true
}
return false
}
+62 -42
View File
@@ -1,57 +1,77 @@
package cmd
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
)
func TestDefaultConfigDir_Dev(t *testing.T) {
// Non-root user without /etc/spinifex should get dev paths
if os.Getuid() == 0 {
t.Skip("test requires non-root user")
}
homeDir, _ := os.UserHomeDir()
expected := filepath.Join(homeDir, "spinifex", "config")
got := DefaultConfigDir()
if got != expected {
t.Errorf("DefaultConfigDir() = %q, want %q", got, expected)
}
// withProductionMarker temporarily overrides the marker path used to detect a
// production install. Tests that pass an empty string force "dev" mode by
// pointing at a non-existent path; tests that pass t.TempDir() force
// "production" mode by pointing at a real directory.
func withProductionMarker(t *testing.T, path string) {
t.Helper()
orig := productionMarkerPath
productionMarkerPath = path
t.Cleanup(func() { productionMarkerPath = orig })
}
func TestDefaultDataDir_Dev(t *testing.T) {
if os.Getuid() == 0 {
t.Skip("test requires non-root user")
}
homeDir, _ := os.UserHomeDir()
expected := filepath.Join(homeDir, "spinifex")
got := DefaultDataDir()
if got != expected {
t.Errorf("DefaultDataDir() = %q, want %q", got, expected)
}
func TestIsProductionLayout(t *testing.T) {
t.Run("absent marker returns false", func(t *testing.T) {
withProductionMarker(t, filepath.Join(t.TempDir(), "does-not-exist"))
assert.False(t, isProductionLayout())
})
t.Run("present marker returns true", func(t *testing.T) {
withProductionMarker(t, t.TempDir())
assert.True(t, isProductionLayout())
})
}
func TestDefaultConfigFile(t *testing.T) {
if os.Getuid() == 0 {
t.Skip("test requires non-root user")
func TestDefaultPaths(t *testing.T) {
homeDir := t.TempDir()
t.Setenv("HOME", homeDir)
t.Setenv("SUDO_USER", "")
tests := []struct {
name string
productionMode bool
wantConfigDir string
wantDataDir string
wantConfigFile string
wantLogDir string // for LogDirFor("/data/dir")
}{
{
name: "dev layout",
productionMode: false,
wantConfigDir: filepath.Join(homeDir, "spinifex", "config"),
wantDataDir: filepath.Join(homeDir, "spinifex"),
wantConfigFile: filepath.Join(homeDir, "spinifex", "config", "spinifex.toml"),
wantLogDir: "/data/dir/logs",
},
{
name: "production layout",
productionMode: true,
wantConfigDir: "/etc/spinifex",
wantDataDir: "/var/lib/spinifex",
wantConfigFile: "/etc/spinifex/spinifex.toml",
wantLogDir: "/var/log/spinifex",
},
}
got := DefaultConfigFile()
homeDir, _ := os.UserHomeDir()
expected := filepath.Join(homeDir, "spinifex", "config", "spinifex.toml")
if got != expected {
t.Errorf("DefaultConfigFile() = %q, want %q", got, expected)
}
}
func TestIsProductionLayout_NonRoot(t *testing.T) {
if os.Getuid() == 0 {
t.Skip("test requires non-root user")
}
if isProductionLayout() {
t.Error("isProductionLayout() = true for non-root user, want false")
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.productionMode {
withProductionMarker(t, t.TempDir())
} else {
withProductionMarker(t, filepath.Join(t.TempDir(), "absent"))
}
assert.Equal(t, tt.wantConfigDir, DefaultConfigDir())
assert.Equal(t, tt.wantDataDir, DefaultDataDir())
assert.Equal(t, tt.wantConfigFile, DefaultConfigFile())
assert.Equal(t, tt.wantLogDir, LogDirFor("/data/dir"))
})
}
}
+74 -34
View File
@@ -22,7 +22,6 @@ import (
"os"
"strings"
"github.com/mulgadc/predastore/s3"
"github.com/mulgadc/spinifex/spinifex/config"
"github.com/mulgadc/spinifex/spinifex/service"
"github.com/mulgadc/spinifex/spinifex/services/awsgw"
@@ -116,13 +115,11 @@ var predastoreStartCmd = &cobra.Command{
return
}
backendType := viper.GetString("backend")
var backend s3.BackendType
encryptionKeyFile := viper.GetString("encryption-key-file")
if backendType == "distributed" {
backend = s3.BackendDistributed
} else {
backend = s3.BackendFilesystem
if encryptionKeyFile == "" {
fmt.Println("Encryption key file is not set")
return
}
nodeID := viper.GetInt("node-id")
@@ -138,8 +135,9 @@ var predastoreStartCmd = &cobra.Command{
TlsCert: tlsCert,
TlsKey: tlsKey,
Backend: backend,
NodeID: nodeID,
EncryptionKeyFile: encryptionKeyFile,
NodeID: nodeID,
PprofEnabled: pprofEnabled,
PprofOutputPath: pprofOutput,
@@ -245,13 +243,13 @@ var viperblockStartCmd = &cobra.Command{
accessKey := viper.GetString("access-key")
if accessKey != "" {
fmt.Println("Overwriting access-key:", accessKey)
fmt.Println("Overwriting access-key: ****")
nodeConfig.Predastore.AccessKey = accessKey
}
secretKey := viper.GetString("secret-key")
if secretKey != "" {
fmt.Println("Overwriting secret-key:", secretKey)
fmt.Println("Overwriting secret-key: ****")
nodeConfig.Predastore.SecretKey = secretKey
}
@@ -287,6 +285,8 @@ var viperblockStartCmd = &cobra.Command{
service, err := service.New("viperblock", &viperblockd.Config{
NatsHost: nodeConfig.NATS.Host,
NatsToken: nodeConfig.NATS.ACL.Token,
NatsCACert: nodeConfig.NATS.CACert,
PluginPath: pluginPath,
S3Host: nodeConfig.Predastore.Host,
Bucket: nodeConfig.Predastore.Bucket,
@@ -310,7 +310,7 @@ var viperblockStartCmd = &cobra.Command{
return
}
fmt.Println("Viperblock service started", service)
fmt.Println("Viperblock service started")
},
}
@@ -677,6 +677,32 @@ var spinifexUIStatusCmd = &cobra.Command{
},
}
// checkLegacyWanBridgeKey fails vpcd startup if the deprecated `wan_bridge`
// TOML key or `SPINIFEX_VPCD_WAN_BRIDGE` env-var is present. The key was
// renamed to `dhcp_bind_bridge` (see mulga-998.a) because the old name misled
// operators into pointing at the OVN-side bridge ("br-ext"), which never sees
// LAN DHCP traffic. Per D3: no silent alias, no auto-rewrite — operator must
// rename the key before vpcd will start.
func checkLegacyWanBridgeKey(node, cfgFile string) error {
legacyInTOML := viper.IsSet("nodes." + node + ".vpcd.wan_bridge")
legacyInEnv := os.Getenv("SPINIFEX_VPCD_WAN_BRIDGE") != ""
if !legacyInTOML && !legacyInEnv {
return nil
}
source := cfgFile
if legacyInEnv {
source = "env SPINIFEX_VPCD_WAN_BRIDGE"
}
return fmt.Errorf(
"vpcd: deprecated 'wan_bridge' key found in %s. "+
"Rename 'wan_bridge' to 'dhcp_bind_bridge'. The value may also be wrong — verify it is "+
"the Linux bridge holding your WAN NIC (veth mode) or the OVS bridge holding your WAN NIC (direct mode); "+
"never 'br-ext'. Typical value on a consumer-router LAN: 'br-wan'. "+
"Then: sudo systemctl restart spinifex-vpcd",
source,
)
}
var vpcdStartCmd = &cobra.Command{
Use: "start",
Short: "Start the vpcd service",
@@ -695,32 +721,33 @@ var vpcdStartCmd = &cobra.Command{
return
}
if err := checkLegacyWanBridgeKey(clusterConfig.Node, cfgFile); err != nil {
slog.Error(err.Error())
os.Exit(1)
}
nodeConfig := clusterConfig.Nodes[clusterConfig.Node]
// Map cluster-wide external pools to vpcd config
var extPools []vpcd.ExternalPoolConfig
for _, p := range clusterConfig.Network.ExternalPools {
extPools = append(extPools, vpcd.ExternalPoolConfig{
Name: p.Name,
RangeStart: p.RangeStart,
RangeEnd: p.RangeEnd,
Gateway: p.Gateway,
GatewayIP: p.GatewayIP,
PrefixLen: p.PrefixLen,
DNSServers: p.DNSServers,
Region: p.Region,
AZ: p.AZ,
Name: p.Name,
Source: p.Source,
RangeStart: p.RangeStart,
RangeEnd: p.RangeEnd,
Gateway: p.Gateway,
GatewayIP: p.GatewayIP,
PrefixLen: p.PrefixLen,
DNSServers: p.DNSServers,
Region: p.Region,
AZ: p.AZ,
DhcpBindBridge: nodeConfig.VPCD.DhcpBindBridge,
GwLrpRangeStart: p.GwLrpRangeStart,
GwLrpRangeEnd: p.GwLrpRangeEnd,
})
}
// Fallback chassis names derived from config node names. vpcd prefers
// discovering chassis from the OVN Southbound DB at startup; these are
// only used if that discovery fails.
var chassisNames []string
for nodeName := range clusterConfig.Nodes {
chassisNames = append(chassisNames, "chassis-"+nodeName)
}
var bootstrap *vpcd.BootstrapVPC
if clusterConfig.Bootstrap.VpcId != "" {
bootstrap = &vpcd.BootstrapVPC{
@@ -733,19 +760,25 @@ var vpcdStartCmd = &cobra.Command{
}
}
baseDir := viper.GetString("base-dir")
if baseDir != "" {
fmt.Println("Overwriting vpcd base-dir to:", baseDir)
nodeConfig.BaseDir = baseDir
}
svc, err := service.New("vpcd", &vpcd.Config{
NatsHost: nodeConfig.NATS.Host,
NatsToken: nodeConfig.NATS.ACL.Token,
NatsCACert: nodeConfig.NATS.CACert,
OVNNBAddr: nodeConfig.VPCD.OVNNBAddr,
OVNSBAddr: nodeConfig.VPCD.OVNSBAddr,
BaseDir: nodeConfig.BaseDir,
Debug: false,
ExternalMode: clusterConfig.Network.ExternalMode,
ExternalPools: extPools,
ChassisNames: chassisNames,
Bootstrap: bootstrap,
ExternalInterface: nodeConfig.VPCD.ExternalInterface,
WanBridge: nodeConfig.VPCD.WanBridge,
DhcpBindBridge: nodeConfig.VPCD.DhcpBindBridge,
BridgeMode: nodeConfig.VPCD.BridgeMode,
})
if err != nil {
@@ -834,13 +867,20 @@ func init() {
viper.BindEnv("tls-key", "SPINIFEX_PREDASTORE_TLS_KEY")
viper.BindPFlag("tls-key", predastoreCmd.PersistentFlags().Lookup("tls-key"))
// Predastore at-rest encryption master key (per node; mode 0600)
predastoreCmd.PersistentFlags().String("encryption-key-file", "", "Path to this node's 32-byte AES-256 master key file (required)")
viper.BindEnv("encryption-key-file", "SPINIFEX_PREDASTORE_ENCRYPTION_KEY_FILE")
viper.BindPFlag("encryption-key-file", predastoreCmd.PersistentFlags().Lookup("encryption-key-file"))
// Predastore Backend
predastoreCmd.PersistentFlags().String("backend", "distributed", "Predastore (S3) backend")
viper.BindEnv("backend", "SPINIFEX_PREDASTORE_BACKEND")
viper.BindPFlag("backend", predastoreCmd.PersistentFlags().Lookup("backend"))
// Predastore Node ID
predastoreCmd.PersistentFlags().Int("node-id", 0, "Predastore (S3) node ID")
// Predastore Node ID. Default -1 is dev mode (launch every configured
// QUIC node in-process). Production deployments set this to the node's
// real ID (>= 1) via SPINIFEX_PREDASTORE_NODE_ID or --node-id.
predastoreCmd.PersistentFlags().Int("node-id", -1, "Predastore (S3) node ID (-1 = dev mode, >= 1 = production)")
viper.BindEnv("node-id", "SPINIFEX_PREDASTORE_NODE_ID")
viper.BindPFlag("node-id", predastoreCmd.PersistentFlags().Lookup("node-id"))
@@ -860,7 +900,7 @@ func init() {
serviceCmd.AddCommand(viperblockCmd)
viperblockCmd.PersistentFlags().String("s3-host", "0.0.0.0:8443", "Predastore (S3) host URI")
viperblockCmd.PersistentFlags().String("s3-host", "", "Predastore (S3) host URI")
viper.BindEnv("s3-host", "SPINIFEX_VIPERBLOCK_S3_HOST")
viper.BindPFlag("s3-host", predastoreCmd.PersistentFlags().Lookup("s3-host"))

Some files were not shown because too many files have changed in this diff Show More