Compare commits

...

64 Commits

Author SHA1 Message Date
Julian Sommer 30871edc05 release: update go.mod for v1.8.0 2026-06-16 09:32:18 +10:00
Julian Sommer 5cb73d83db chore: rebuild frontend with latest deps 2026-06-16 09:06:50 +10:00
dependabot[bot] 5f166b8197 chore: bump the npm-dependencies group (#360) 2026-06-16 08:58:38 +10:00
dependabot[bot] 4bc788ca51 chore: bump the go-dependencies group with 4 updates (#359) 2026-06-16 08:57:51 +10:00
Josh Siviour 129bfc0fb5 feat(eks): join nodegroup workers via cluster NLB endpoint + private endpoint access (#362)
* feat: join nodegroup workers via cluster NLB endpoint

Under the Set-B managed-CP-VPC topology the control plane runs in the
system-account CP VPC while nodegroup workers launch in the customer
VPC. The agent user-data set K3S_URL to the in-VPC control-plane ENI IP
(:6443), which sits in a different, unpeered VPC and is unreachable, so
the join path was dead.

Point K3S_URL at the published cluster NLB endpoint (meta.Endpoint,
https://<frontend-ip>:443, the :443->:6443 listener kubectl already
uses). Guard CreateNodegroup on a non-empty endpoint.

mulga-siv-298

* feat: gate nodegroup ACTIVE on workers registering Ready

launchNodegroupInfra flipped the nodegroup to ACTIVE the moment
RunInstances returned, so a worker that booted but never joined left the
nodegroup falsely ACTIVE with no usable capacity.

Gate ACTIVE on the cluster's Ready-node count (meta.NodeCount, refreshed
by the cluster reconciler from the CP NATS state report — Ready nodes
only) rising by the desired worker count over the create-time baseline.
On timeout the nodegroup is marked CREATE_FAILED with its instance IDs
retained for the reclaim path. Mirrors the CP reconciler's
healthy-observe gating. Bounded by injectable timeout/poll knobs.

mulga-siv-299

* feat(eks): private endpoint access via cross-account Set A ENI

Make EndpointPrivateAccess real: in-VPC workers and kubectl reach the
control plane through a customer-VPC (Set A) resident ENI on the cluster
NLB's LB VM, removing the public-NLB hairpin and the NAT-GW egress path.

Teardown: the Set A ENI is an extra cross-account NIC on the LB VM, not
its primary, so the instance-terminate cascade never reclaims it. Detach
(store-clear) the stale attachment before deleting, so private clusters
delete cleanly instead of looping on InvalidNetworkInterface.InUse.

Refs: mulga-siv-301

* fix(vpc): honor SecurityGroupRuleIds in RevokeSecurityGroup ingress/egress

Modern Terraform (aws_vpc_security_group_ingress_rule) revokes per-rule
resources by SecurityGroupRuleId with empty IpPermissions. The handlers
only read IpPermissions, so they returned success while removing nothing;
the surviving SG-reference rule then pinned both SGs undeletable
(DependencyViolation), deadlocking destroy of mutually-referencing SGs.

Resolve SecurityGroupRuleIds to their stored rules and remove them
(malformed -> InvalidSecurityGroupRuleId.Malformed, absent -> NotFound).

Refs: mulga-siv-321

* fix(vpc): reap the main route table on DeleteVpc

CreateVpc auto-creates a main route table and DeleteRouteTable refuses to
delete a main RT (AWS-faithful), so nothing reclaimed it — every deleted
VPC leaked one orphaned main RT in the rtb KV bucket. DeleteVpc now reaps
the VPC's main route table before deleting the VPC record.

Refs: mulga-siv-322

* fix: make AttachRolePolicy and AssociateRouteTable atomic via KV CAS

Concurrent writes to a single role / route table record used a blind
read-modify-Put with no optimistic-concurrency guard, so parallel writers
racing from the same revision clobbered each other and lost updates.
Verified on env19: three parallel policy attachments to one node role kept
only one; two parallel subnet associations to one route table kept only
one (the provider then timed out waiting for state 'associated').

Route the mutating paths (AttachRolePolicy/DetachRolePolicy,
AssociateRouteTable/DisassociateRouteTable) through a revision-guarded
Update + bounded retry, matching the existing accountCounterBucket CAS
idiom. Add concurrent regression tests covering both lost updates.

* docs: add EKS Terraform workbooks (quickstart, addons-access, https-ingress)

Three OpenTofu workbooks demonstrating EKS on Spinifex, each ending in a
browsable load-balanced demo app:
- eks-quickstart: minimal single-node cluster + NodePort demo
- eks-addons-access: CoreDNS managed addon + access-entry RBAC
- eks-https-ingress: 3-node cluster behind an HTTPS ALB terminating TLS
  with an ACM-imported self-signed certificate

Each covers the Spinifex-specific endpoints, the auto-managed worker SG
NodePort pattern, and the omitted addon_version constraint.
2026-06-16 00:59:36 +10:00
Josh Siviour 1873f45d26 Resilient resource lifecycle (ADR-0001..0006): EKS multi-node teardown, idempotent deletes, GC backstops (#357)
* fix: idempotent Delete* across VPC family + volume (RLC1)

Delete handlers now return empty success when the resource is already
absent (nats.ErrKeyNotFound), instead of *NotFound. Transient read
errors still map to ServerInternal. Covers DeleteVpc, DeleteSubnet,
DeleteSecurityGroup, DeleteNetworkInterface, DeleteInternetGateway,
DeleteNatGateway, DeleteRouteTable and DeleteVolume.

Makes destroy retries converge instead of failing mid-graph, removing a
source of orphaned/zombie resources between deploys. Adds a
TestRLC1_*DeleteIdempotentOnAbsent invariant per package and updates the
daemon delegation table; supersedes the old absent->NotFound tests.

* feat: prune orphan ENI LSPs in reconcile applyPorts (ADR-0003 §4)

applyPorts was create-or-update only, so a guest LSP whose
spinifex:eni_id no longer maps to an intent ENI survived instance
terminate and VM-host reinstall — the orphan-LSP capacity-drift root
cause. It now takes pruneOrphans (mirroring applySGs): on a full
reconcile it lists LSPs and tears down each managed ENI port absent from
intent via the composed topology.DeletePort cascade (clears port-group
memberships, then deletes the LSP). The apply-only startup path does not
prune, preserving in-flight ports until subscribers converge.

Adds ListLogicalSwitchPorts to the OVN client (live + mock). Invariant
TestRLC5_OrphanLSPPrunedAfterReconcile guards ADR-0003 §4; a startup-race
test guards the apply-only path.

* feat: idempotent + tracked instance terminate (ADR-0003 §1/§2)

Make terminate idempotent across all three layers and break the
un-terminable-ENI deadlock, and record per-dependency teardown so the
GC backstop can later tell a fully-reaped instance from a dangling one.

- vm.Manager.Terminate: absent / already-terminated / shutting-down now
  return nil instead of NotFound / invalid-transition (ADR-0003 §2).
- VM.Teardown map + teardown.go marks: sync steps stamped Done/Failed via
  cleaner error returns; fire-and-forget OVN LSP and NAT rule stamped
  Pending for the reconciler / GC reaper (ADR-0003 §1).
- InstanceCleaner: DeleteVolumes / ReleasePublicIP / DetachAndDeleteENI /
  RemoveFromPlacementGroup / ReleaseGPU now return error.
- vpc ForceDeleteInstanceENI bypasses the in-use guard for the owning
  instance, breaking the failed-detach deadlock; plain delete still
  rejects an in-use ENI held by another instance.
- gateway TerminateInstances: KV-health-gated idempotent absence (a failed
  terminated-bucket query does not fabricate success, ADR-0003 §3).
- Invariants: TestRLC1_TerminateIdempotentOnAbsent, TestRLC4 teardown-map
  stamping + failed-dependency, TestRLC_ENINonDeadlock.

* feat: reconciler GC backstop framework + terminated-teardown reaper (ADR-0003 §3)

Add the shared reality->desired garbage-collection framework (inverse of
Restore) and its first reaper, closing the gap 295.3 deferred: nothing
retried pending/failed teardown or purged the terminated record when its
dependents reached done.

- vm/gc.go: GarbageCollector + Reaper interface (Class/Scope/Sweep).
  KV-health gated — holds and alarms when the desired-state cannot be
  trusted, never reaps against it. ClusterWide reapers run only on the
  elected leader (optional elector); NodeLocal run on every node. Start
  sweeps immediately then every GCInterval.
- vm/teardown_reaper.go: TerminatedTeardownReaper (node-local). Scans this
  node's terminated records, re-drives each not-done dependent through the
  idempotent cleaner, and purges the record once all reach done. Records
  already complete on arrival are left to the bucket's 1h TTL.
- StateStore gains DeleteTerminatedInstance (JetStream impl already existed).
- daemon: wires the GC after the pending watchdog, gated on a cheap
  JetStream AccountInfo health probe.
- Cluster OVN-LSP arm already lands via the reconciler drift prune (295.2).
- Invariants: TestRLC5_GCRetriesPendingTeardownToDoneThenPurges,
  TestRLC5_GCNeverPurgesIncompleteRecord, TestRLC5_GCHoldsWhenKVDegraded,
  TestRLC5_GCSkipsOtherNodesRecords.

* feat: VPC-family dependency guards (ADR-0004 rule #3)

DeleteSubnet blocks while a live ENI attachment (a resident instance)
remains; available/orphan ENIs do not pin it. DeleteNetworkInterface
guards on the structured attachment (eniIsLiveAttachment), not a bare
in-use string. DeleteNatGateway blocks while a live route table forwards
to it and disassociates its EIP keeping the allocation (AWS parity);
CreateNatGateway now marks the EIP associated so the disassociate is
real and a second NAT GW cannot reuse the allocation.

Binding invariants: TestRLC3 dependency-guard completeness across the
subnet/ENI/NAT-gateway handlers.

* feat: EBS volume data-safety GC override (ADR-0005)

The shared GC backstop reaps actual-desired for every other class; for
volumes that default is forbidden because they carry customer data. Add
VolumeLeakReaper (node-local, rides the 295.5 GC): it marks a volume left
attached to a definitively-gone instance as orphaned (a non-destructive
tag) and alarms, but NEVER issues a delete. The daemon feeds it only this
node's terminated instances whose volumes-teardown failed, so the shared
predastore scan can never false-mark another node's live-instance volume.

DeleteVolume idempotency + the detach/snapshot guards + the
DeleteOnTermination teardown retry already landed (295.1/295.3/295.5).

Binding invariants: TestRLC5 (GC marks+alarms, never deletes data),
TestRLC3 (detach-before-delete).

* test(elbv2): lock ADR-0002 §3 live-only TG in-use guard (mulga-siv-295.4)

* feat(eks): idempotent DeleteCluster on absent + ADR-0006 lifecycle invariants (mulga-siv-295.8)

DeleteCluster returns empty success when the cluster meta is already swept
(Common Resource Lifecycle Contract #1), so tofu destroy retries converge.
Adds TestRLC1 (idempotent delete), TestRLC2 (billable-before-KV-sweep ordering)
and TestRLC3 (NLB target-group no-orphan, riding ADR-0002/172).

* fix(eks): GC reaper terminates orphan control-plane VM after cluster delete (mulga-siv-294)

A normal DeleteCluster can leave a running control-plane VM behind (terminate
completes async; a daemon restart re-launches it from saved running-state) after
the cluster meta is swept — a billable orphan with no owner. Add EKSBillableReaper
on the 0003 GC backstop: a running managed-by=eks CP VM whose cluster meta is
definitively gone is terminated. The CP ENI now carries the customer-account tag
so a system-account VM can be mapped back to its cluster meta; the reaper never
reaps on uncertainty (ENI gone/untagged or meta present).

* feat: reclaim orphan EKS NLB + NAT-GW EIP in billable reaper

The billable GC reaper terminated an orphan control-plane VM but left the
cluster's NAT-gateway EIP and NLB front-end NAT behind when the meta was
gone. After a successful terminate, run the name/tag-driven DeleteClusterNLB
+ DeleteClusterCPVPC teardown (idempotent, meta-independent) so the billable
NAT-GW EIP and NLB NAT are reclaimed too. ADR-0006 §5.

* fix: persist NAT-GW & route-table tags and reorder CP-VPC teardown to reclaim EKS NAT-GW EIP

CreateNatGateway and CreateRouteTable both dropped input.TagSpecifications,
so the tag-driven DeleteClusterCPVPC teardown could not discover the cluster
NAT gateway or its private route table. The explicit DeleteNatGateway +
ReleaseAddress were skipped and the billable NAT-GW EIP leaked under the
system account on every clean DeleteCluster.

Three fixes:
- CreateNatGateway persists ExtractTags(TagSpecifications, natgateway).
- CreateRouteTable persists ExtractTags(TagSpecifications, route-table).
- DeleteClusterCPVPC tears down route tables before the NAT gateway, so the
  rule #3 route-ref guard (DependencyViolation / InvalidAddress.Locked) is
  not tripped and the EIP releases.

Adds tag-persistence regression tests for both create paths and a teardown
ordering invariant (TestRLC5). Validated live: NAT-GW EIP released cleanly,
no DependencyViolation/Locked.

* fix: route EKS control-plane VM terminate cross-node + DELETING teardown backstop

Multi-node EKS cluster delete wedged in DELETING and stranded the billable
NAT-GW EIP. Two root causes, both invisible on a single-node box:

Cross-node terminate was never routed. TerminateSystemInstance was node-local,
so terminating a CP VM owned by another node returned NotFound (treated as
idempotent) and the teardown deleted a still-attached ENI
(InvalidNetworkInterface.InUse). That error short-circuited purgeClusterInfra
before the managed CP-VPC teardown that releases the NAT-GW EIP. Split
TerminateSystemInstance into a local body plus a routing wrapper that issues
system.TerminateInstance.<id> to the owning node. Bind that subscription for
every system-managed VM (elbv2 + eks) via the OnInstanceUp funnel, so CP VMs
placed on the coordinator's own node (and recovered after a daemon restart) are
reachable too, not only remotely-launched ones.

No DELETING backstop. A failed synchronous DeleteCluster left the cluster
DELETING with nothing to re-drive it. Add EKSDeletingReaper (vm.Reaper, class
eks-deleting) that re-runs purgeClusterInfra for clusters wedged past a min-age,
gated by the per-cluster reconciler leader lease. Stamp ClusterMeta.DeletingSince
on the transition into DELETING.

TerminateK3sServerVM additionally waits out any residual ENI-InUse with a bounded
budget as defense-in-depth.

Validated live on env19 3-node: first delete-cluster converges in 29s, both
remote CP VMs terminated cross-node, NAT-GW EIP released, backstop never fires.

* fix(network): gate gateway reconcile on real datapath reachability

Post-reboot the external OVN datapath can stay dead while every
control-plane signal is green: the chassisredirect SB binding is claimed
(up=true), NAT rows are present and the host neigh is primed, yet
ovn-controller leaves stale gateway/localnet flows so EIP north-south and
the VM->gateway heartbeat forward nothing. ensureGatewayClaimed only
nudges recompute when the binding is unclaimed, so a claimed-but-dead
datapath is never detected or repaired.

Add ensureGatewayDatapath: after the SB claim converges, probe the
gateway LRP IP (OVN answers ICMP natively, no guest or security-group
dependency), and on a miss force one ovn-controller recompute and
re-probe until a short deadline. Reuses the existing recompute nudge and
no-ops when no verifier is wired or no gateway IP resolves.

* fix: self-heal external uplink after reboot boot-race

Post-reboot, ovs-vswitchd/ovn-controller order after the passive
network.target rather than network-online.target, so they start
concurrently with systemd-networkd. Under a heavy boot the WAN-glue veth
(veth-wan-ovs) can come up admin-down before networkd brings it up,
leaving br-wan with no carrier and the external datapath dead while every
control-plane signal reconciles green.

Broaden the datapath health-gate remediation from recompute-only to
RepairDatapath: re-assert the veth admin state (covers the admin-down
link, which a flow recompute cannot revive) then recompute (covers a
stale gateway ofport). Idempotent; the veth ops are skipped in
physical-uplink mode where the device is absent.

Also capture ip -d link (veth admin/carrier) and ovs-ofctl show br-ext
(ofport) in the reboot diag bundle to disambiguate the two failure shapes
from CI artifacts.

* fix: restore AWS-faithful Delete* NotFound; move idempotency to teardown callers (mulga-siv-306)

RLC1 (f9218982) made EC2/VPC/EBS Delete* handlers return success on an absent
resource, breaking AWS compatibility and the cross-account isolation signal (a
cross-account delete reads as absent -> success instead of *.NotFound).

Restore the AWS contract at the API while keeping lifecycle convergence:
- Handlers return canonical *.NotFound on absent: DeleteVpc, DeleteSubnet,
  DeleteSecurityGroup, DeleteInternetGateway, DeleteNatGateway, DeleteRouteTable,
  DeleteVolume, and the public DeleteNetworkInterface (ForceDeleteInstanceENI
  keeps its idempotent force path, ADR-0003 section 2).
- Add awserrors.IsNotFound; destroy orchestration (instance terminate, EKS
  CP-VPC teardown, EKS IGW/SG cleanup, k3s rollback, ELBv2 ENI/SG teardown)
  tolerates NotFound so re-issued/double-targeted deletes converge.
- Flip the RLC1 unit invariants to assert *.NotFound on absent; revert the
  daemon round-trip cases; add IsNotFound + CP-VPC convergence tests.

ELBv2 deletes and TerminateInstances stay idempotent (AWS itself is).

* fix(network): gate ingress EIP datapath on guest SB Port_Binding convergence

TestMultinodeGuestSSH intermittently times out (Connection timed out, not
#355's reset-by-peer) connecting to a fresh trio instance's public IP after
the suite churns instances across all three chassis. The instance is running
and AddEIP has installed the DNAT row, flushed/primed the host neigh, and
barriered the gateway-chassis flow — but none of that proves the
gatewayLRP->guest geneve hop: until the guest ENI's southbound Port_Binding is
up on its hosting chassis the cross-node flow is missing and the DNAT-translated
packet blackholes.

Add ensureGuestPortDatapath, the guest-side analog of ensureGatewayDatapath:
after AddEIP, probe the guest LSP's SB Port_Binding (ovn-sbctl up column); on a
miss force one ovn-controller recompute (binds the port / installs the remote
flow) and re-probe until a short deadline. Recompute, not RepairDatapath: this
is flow/binding convergence, not a WAN-uplink fault. No-op when no verifier is
wired or the EIP carries no guest port. spec.PortName is populated for every
associated EIP regardless of NAT mode.

Extends GatewayClaimVerifier with GuestPortUp and wires the gate into
applyEIPs so it runs in the continuous reconcile loop on every node.

* Revert "fix(network): gate ingress EIP datapath on guest SB Port_Binding convergence"

This reverts commit 728b1a9cec6eaf5c7ef5e9eda4469f239b4d8fc0.

* fix(network): key EIP DNAT delete on external IP, not logical IP

A dnat_and_snat row's identity on the VPC gateway router is its external
IP. The logical (private) IP is not a stable key: the EIP pool round-robins
and instance private IPs are reused as instances recycle, so the same
logical IP is held by different EIPs over time.

DeleteEIP deleted the row by logical IP, so a stale or retried delete for a
freed EIP clobbered whichever live EIP now held that recycled private IP --
the next owner's row vanished with no re-add and its public IP went dark
(SSH SYN never left br-wan: Connection timed out). Lifecycle-resilience
teardown/GC retries amplified this by firing vpc.delete-nat repeatedly.

Key the delete on the external IP via DeleteNATByExternalIP and skip when
no external IP is supplied (no EIP means no dnat_and_snat row). A stale
delete now targets the freed EIP's own external IP (ErrNATNotFound no-op)
and can no longer touch the live row.

* refactor: relax DeleteNatGateway to AWS-faithful

Remove the route-reference dependency guard (checkNatGatewayRouteRefs) so a
NAT gateway deletes even while route tables still forward to it, matching AWS
(the route blackholes). The reconciler's loadNATGWs drops the SNAT once the
record leaves the active bucket, so a forwarding route is not a delete-blocking
dependent. EIP is still disassociated on delete (no leak).

* test: invert NAT-GW route-ref invariant to AWS-faithful

TestRLC3_NatGatewayBlocksWhileRouted asserted DependencyViolation while a
route forwarded to the NAT gateway, which is AWS-infaithful. Replace with
TestRLC3_NatGatewayDeletesWhileRouted: delete must succeed whether or not a
live route still targets it (the route blackholes; the reconciler drops the
SNAT). Per ADR-0004 §2.

* fix: tear down NAT-GW SNAT on delete (restore delete-time fan-out)

The guard commit (53ed55a9) dropped publishDeleteEventsForNatGateway on the
assumption a routed NAT GW could never be deleted. With the route-ref guard
removed, deleting a NAT GW while routes still target it left the SNAT in
place (applyNATGWs is create-only, so the reconciler never prunes it) and
egress kept working — TestNATGateway's post-teardown 'lost internet' check
failed. Restore the fan-out: on delete, scan route tables and publish
vpc.delete-nat-gateway per associated subnet so egress stops, while the
route entry survives as a blackhole (AWS-faithful).

* fix: probe EIP datapath in reboot self-heal, not the gateway LRP IP

The post-reboot gateway datapath self-heal probed the gateway LRP IP for
liveness, which OVN answers natively at the router — so it stayed green even
when the EIP NAT pipeline was stranded (ofproto/trace dropped EIP traffic at
table 0). RepairDatapath never fired and TestRebootResilience timed out, with
the drift loop re-probing the same false-green every cycle.

Prefer probing an associated EIP: force a fresh ARP of the dnat_and_snat
external IP (flush neigh, ping to trigger, read the resolved MAC). OVN answers
ARP for the external IP from the gateway chassis independent of the guest, so a
resolved MAC proves the WAN uplink forwards and the NAT flows are installed —
the signal the LRP IP cannot give. Fall back to the LRP IP for VPCs with no
EIP. The existing drift-loop cadence now re-heals a later flow strand.

* fix: lb-agent heartbeats over mgmt port, not the WAN/EIP datapath

The lb-agent heartbeat to awsgw is control-plane and must survive a host
reboot that strands the OVN/EIP north-south data plane. Customer
(internet-facing) lb-agents heartbeated over the WAN GatewayURL, riding the
EIP datapath, so after a reboot the ALB never reported healthy until the
datapath self-healed. System LBs already heartbeated over the on-link
br-mgmt NIC; this extends that path to every lb-agent.

buildLBAgentEnv now always uses mgmtGatewayURL (on-link MgmtBridgeIP, the
mgmt NIC the lb-agent VM already carries), falling back to the WAN URL only
when no mgmt bridge exists. Drops the account-id branch.

* fix: re-heal guest ENI SB Port_Binding post-reboot so EIP ingress converges

Post-reboot every EIP in a VPC goes dark north-south: the DNAT row, host
neigh and gateway-chassis flow are all in place (ARP to the EIP resolves)
but the gatewayLRP->guest flow is missing until ovn-controller binds the
guest ENI's SB Port_Binding, so the DNAT-translated packet blackholes and
the public IP is unreachable on an otherwise-running instance.

Restore the guest-port convergence gate in applyEIPs: probe the guest LSP's
SB Port_Binding up column; on a miss force an ovn-controller recompute and
re-probe until a deadline. Unlike the earlier reverted form, recompute on
every miss, not once: after a host reboot the guest tap is replumbed several
seconds after the reconcile runs, so a single early nudge fires before the
port exists and never binds it. The window is widened to span guest boot.

This is the southbound analog of the gateway datapath self-heal; the EIP
ARP probe cannot detect it because the gateway chassis answers ARP for the
external IP even while the guest delivery flow is absent.

* fix(network): force post-reboot EIP recompute on guest-port bind, startup-only

The reboot suite passed only by accident: the pre-#351 gateway-claim verifier
polled the always-chassis-less gateway LRP, so it fired an ovn-controller
inc-engine/recompute on every reconcile. That spurious recompute was load-bearing
- it reinstalled the centralised gateway DNAT flows that ovn-controller's
incremental engine misses on a cold (post-reboot) start. #351 correctly switched
the check to the chassisredirect port, eliminating the every-cycle recompute, and
the EIP datapath went dark post-reboot with every control-plane signal green.

Restore the kick, scoped to the post-reboot startup path: applyEIPs now gates a
single recompute on the guest ENI's SB Port_Binding chassis claim - the reliable
signal the tap is back, unlike the up column which stays stale-true across a
reboot. Drift never recomputes, preserving the #351 fix for freshly attached
EIPs. Probe renamed GuestPortUp -> GuestPortBound (chassis, not up).

* Revert "fix(network): force post-reboot EIP recompute on guest-port bind, startup-only"

This reverts commit 792e318211ef03efbe24febeb470749d88c0b03c.

* test(e2e/reboot): capture dataplane diag on ALB serve failure

The post-reboot ALB-serve assertion (8.6) failed with no flow/ARP evidence:
the OVN/OVS dataplane dumps only ran on the 8.5 targets-unhealthy path, but
8.5 passes on the daemon's own health view while the north-south EIP datapath
is dark, so the serve failure (8.6) left nothing to localise the drop.

On a serve failure, capture dumpALBProbe/dumpOVNDataplane/dumpOVNClaimState/
dumpHeartbeatDiagnostics/dumpAppDiagnostics before failing. Add to the OVN
dataplane dump: dump-flows br-ext and fdb/show br-ext (north-south bridge,
previously only br-int), and a real ARP probe that flushes the neigh entry
first so primeReachability's pre-seeded reachable entry can no longer mask an
unanswered ARP. Pure diagnostics; no production change.

* test(e2e/reboot): decisive datapath diag — untruncate br-int, real-ingress ofproto/trace, conntrack + tap capture

* test(e2e/reboot): add south->north return-path ofproto/trace to localize egress drop

* test(e2e/reboot): derive LRP MAC from realized flow + remediation experiment (recompute vs ovn-controller restart)

* test(e2e/reboot): dump persisted SB MAC_Binding + external MACs; flush experiments (conntrack vs MAC_Binding) to isolate recompute-resistant return-path drop

* test(e2e/reboot): untruncate Port_Binding + enumerate DGP/chassisredirect claim state (does cr-gw row exist + resident chassis)

* test(e2e/reboot): dump NB source of return-path drop (lflow stage, NAT external_mac/options, LR policy, LSP port_security)

* fix: exempt public-IP instances from subnet egress drop gate (mulga-siv-311)

Post-reboot reconcile installs the priority-1100 subnet egress drop gate for an
IGW-attached subnet that has no 0.0.0.0/0 route, but nothing punched the subnet's
public-IP instances through it. lr_in_policy runs before lr_out un-DNAT/SNAT, so an
inbound connection's reply still carries its private source when it hits the gate and
is dropped. The ALB/EIP datapath goes dark south->north post-reboot while NAT, ARP,
chassis and guest-port binding all read green; the pre-reboot path survives only
because the event-driven gate never evaluates a subnet created after IGW attach.

Install the existing priority-1200 /32 reroute (above the gate) for every public-IP
instance during reconcile. Reroute only: the instance's dnat_and_snat already supplies
SNAT, unlike EnsureSystemInstanceEgress which also adds a plain snat. Public IPs come
from two disjoint sources -- auto-assigned/ELB addresses on the ENI record and user
EIPs in the EIP bucket -- so drive the exemption from both intent.Ports (new
PortSpec.PublicIP) and intent.EIPs.

* refactor: scope public-IP drop-gate exemption to gated subnets

Only install the priority-1200 reroute when the instance's subnet actually carries
a drop gate. Routed subnets egress via their priority-1000 reroute and need no
exemption, so bounding on gate presence keeps the change inert for IGW-routed and
NATGW-routed subnets (no redundant or overriding policy). Adds a negative test.

* refactor: drop unused RemoveEIPInstanceEgress

No production caller; the exemption is install-only in reconcile. Removed the
interface method and impl rather than leave dead API surface.

* test: dump guest console + OVN/datapath diag on SG-to-SG client SSH timeout

TestSGToSGDatapath waited for client-vm SSH via EventuallyErr and reported
only 'exit status 255' on timeout — no guest-side or datapath state. When the
fresh public-IP VM is unreachable for the full window the failure was
undiagnosable from CI artifacts.

Swap the wait to the non-fatal trySSHReady probe and, on timeout, call
DumpVPCFlowDiagnostics (guest console tail, ovn-nbctl/sbctl show, NAT rows,
br-int flows / conntrack / host ARP keyed on the public + private IP) before
Fatal, mirroring vpc_test/baseline_test. Captures persist to the artifact dir
for the Stage 2 analyzer bundle.

* fix: ownership-scope public-IP teardown to survive IP recycling

A live instance's dnat_and_snat rule, host ARP, and IPAM lease were torn down
by the delayed garbage-collection teardown of a different, long-dead instance
that previously held the same recycled public IP. The 2-min vm/gc
instance-teardown reaper re-runs ReleasePublicIP on a stale terminated record;
because the external IPAM round-robins a small pool, the IP had already been
reassigned to a live instance, and both teardown primitives acted on the IP by
value with no ownership check:

- policy.DeleteEIP deleted the dnat_and_snat row by external IP alone,
  discarding the logical IP it receives.
- StaticPoolAllocator.Release freed the pool entry by IP value.

Scope both to the current owner so a stale or duplicated teardown for a
recycled IP is a no-op:

- DeleteEIP looks up the row by external IP and skips the delete (and the ARP
  flush) when its logical IP no longer matches, mirroring AddEIP's idempotency
  lookup. Defeats both external- and logical-IP reuse races.
- Allocator.Release / ExternalIPAM.ReleaseIP take an ownerENIID; the static
  allocator no-ops a release whose ENI differs from the live allocation, and
  treats an already-freed owner-scoped release as idempotent. Teardown and
  rollback callers pass their ENI; user-driven EIP release passes none.

Proven via E2E single TestSGToSGDatapath (run 27515293563): client-vm SSH
black-holed after its NAT was deleted by the dead instance's GC sweep.

* fix(eks): single-flight DeleteCluster teardown per cluster

SDK retries of one DeleteCluster get fanned across nodes by the worker
queue group, so purgeClusterInfra raced the same cluster on multiple
nodes: duplicate ENI/NLB teardown, duplicate TargetGroupNotFound
deregisters, and a real double-release risk on the billable NAT-GW EIP.
Idempotency kept it correct but noisy.

Add a dedicated per-cluster teardown lease (teardownLeaderKey =
reconcilerLeaderKey + "/teardown"), distinct from the reconciler lease
so a delete never contends with the still-live reconciler that holds the
reconciler lease until it observes DELETING and exits. DeleteCluster
acquires it after the NotFound-idempotency check: the loser returns the
cluster as DELETING (AWS-async semantics) without running the teardown;
the winner drives purgeClusterInfra and releases. The DELETING backstop
reaper shares the same lease, so at most one purgeClusterInfra runs per
cluster cluster-wide.

A nil leaderKV (single-node) skips gating, so local behavior is
unchanged.

* fix: env-driven SSH-ready budget for slow baremetal guests

Add SPINIFEX_SSH_READY_TIMEOUT override for the single-node SSH-handshake
probes (default unchanged at 3m). Baremetal boots q35 guests slower (OVMF +
NBD-backed disks) than CI loop devices and intermittently misses the 3m
window; the harness can now widen it to 5m without affecting shared-runner
e2e. Applied at all single-node probe sites; failure messages now report the
actual budget.

* fix: shared external switch to stop per-VPC localnet ARP collision

Per-VPC external switches each attached an untagged localnet port to the same
physical network (br-ext). OVN cannot keep multiple untagged localnets on one
physical network L2-separate, so with two or more concurrent public-IP VPCs ARP
for a public IP landed on the wrong VPC's switch and the owning gateway router's
ARP responder never fired, leaving the public IP unreachable.

Move to a single shared external switch (provider-network pattern): one switch
with one localnet on network_name=external. Each VPC's gateway LRP attaches via
its own router-type switch port; per-VPC NAT isolation is unchanged. AttachIGW
ensures the shared switch idempotently and is migration-safe; DetachIGW removes
only the per-VPC port + LRP and preserves the shared switch. DeleteVPC removes
the per-VPC gateway switch port from the shared switch so it does not dangle.
Reconcile detects attachment via the gateway LRP since the shared switch carries
no vpc_id.

* fix: owner-scope DeleteEIP by ENI port to survive identical pair recycling

A stale or GC-re-emitted vpc.delete-nat for a terminated instance tore down a
live instance's dnat_and_snat rule (and flushed the host ARP) when the public
IP and private IP both recycled, together, to that live instance in the same
VPC. The (external_ip, logical_ip) pair-key matches under identical recycling,
so the existing logical-IP guard fell through.

Stamp the owning ENI port (spinifex:logical_port) on the dnat_and_snat row at
AddEIP, thread the port through handleDeleteNAT -> DetachEIP -> DeleteEIP, and
skip the delete when the row's stamped port differs from the incoming one. The
IPAM release was already owner-scoped; this mirrors that guard at the NAT layer
so any stale delete for a recycled pair is a no-op. Empty port preserves the
prior pair-key behaviour.
2026-06-15 23:27:39 +10:00
Josh Siviour 465fa1e6c7 fix: complete at-rest encryption launch + volume lifecycle (#361)
* chore: bump viperblock to encrypted-snapshot VolumeUUID nonce fix

Pull viperblock 1044c2a (feat/snapshot-uuid-nonce-fix) so E2E builds against
the fix for the encrypted AMI snapshot metadata tag-verify failure
(cipher: message authentication failed) on instance launch. Pairs with
viperblock PR #22.

* fix: forward viperblock master key to nbdkit mount path

viperblockd loaded the master key but never passed it to the nbdkit
plugin it spawns for volume mounts, so encrypted boot volumes opened
in cleartext and failed LoadState with an encryption-state mismatch.
Forward cfg.EncryptionKeyFile into NBDKitConfig and emit the plugin's
encryption_key_file= argument when set.

* chore: bump viperblock to fresh-volume VolumeUUID mint fix

* chore: bump viperblock to snapshot-link LoadState ordering fix

* fix: route encrypted-volume config writes through viperblock keyholder

config.json is a sealed VBState whose AES-GCM tag and StateSeqNum-derived
nonce can only be advanced by the master-key holder that owns the volume.
The EC2 volume handler wrote it directly to S3, so mergeVolumeConfig had to
refuse encrypted volumes (rewriting would strip the tag) — bricking
UpdateVolumeState (attach in-use / detach available) and ModifyVolume.

Route encrypted-volume config updates through viperblockd, mirroring the
ebs.snapshot idiom: per-volume ebs.config.{volumeID} hits the owning node's
live VB (the single StateSeqNum writer); on ErrNoResponders the ebs.config
queue group opens the detached volume exclusively and reseals. Cleartext
volumes keep the direct-S3 fast path. No crypto leaves viperblockd.

mulga-siv-323

* fix: unwrap encryption envelope when snapshot handler reads config.json

CreateSnapshot and snapshotInUseByVolumes decoded config.json straight into
a VBState. For encrypted volumes config.json is an at-rest envelope
{payload, authtag}; a raw decode yields a zero VBState (SizeGiB==0), so
CreateSnapshot 500s on the size guard and the in-use scan silently drops
encrypted volumes' snapshots. Unwrap via viperblock.StateBody first,
mirroring the volume handler's mergeVolumeConfig path.

mulga-siv-324

* test: cover viperblockd ebs.config handlers

Unit/integration coverage for applyConfigUpdate, makeConfigUpdateHandler
(ebs.config.{volumeID}), and the ebs.config queue-group fallback: grow-only
size reconcile, live-VB apply, invalid JSON, apply errors, and the
detached-volume open path. Clears the diff-coverage gate (68.8%).

* chore: pin viperblock to dev tip (#22 merged)
2026-06-15 22:43:56 +10:00
Josh Siviour 17c83cb3ad fix: build microVM rootfs via buildx to stop overlay2 export leak (#358)
docker export of a container leaks its overlay mount on Docker's
overlay2 driver — the mount survives docker rm, so the layer dirs are
never reaped except by a daemon restart. The lb-agent microVM build ran
docker export on every e2e job across ~9 runners, accumulating ~69G of
orphaned overlay2 layers on the CI host (disk hit 94%).

Build the rootfs with `docker buildx --output type=local` from a small
generated Dockerfile instead, which writes the image filesystem to a
directory and releases all mounts — nothing leaks. Keep the podman path
(its store is unaffected). Tighten the e2e cleanup image prune to
dangling-only so the alpine base stays cached instead of re-pulling.
2026-06-15 10:50:10 +10:00
joshsiv-mulga 3835f39d35 fix: bump predastore + viperblock pins to dev-tip pseudo-versions
CI e2e-build compiles the spinifex module with GOWORK=off, resolving the
go.mod pins instead of the go.work local submodule redirect. v1.7.0 of both
modules predates symbols spinifex now calls (viperblock.StateBody,
predastore auth.ParseRoleARN), so the build failed with undefined-symbol
errors that go.work masked locally. Pin both to current dev-branch
pseudo-versions until a coordinated release is cut.
2026-06-15 10:46:56 +10:00
tomnewton-mulga eed57ee084 Merge branch 'dev' of github.com:mulgadc/spinifex into dev 2026-06-15 10:29:11 +10:00
tomnewton-mulga 69276f9a7a fix: Added better intro to Reference Architecture docs 2026-06-15 10:28:53 +10:00
tomnewton-mulga 1f22c96b3d fix: Added better intro to Reference Architecture docs 2026-06-15 10:26:51 +10:00
Bryn Mailer f6f3956952 feat: enable viperblock at-rest encryption by default on fresh installs (#323)
* feat: enable viperblock at-rest encryption by default on fresh installs

Fresh installs left nodes.<node>.viperblock.encryption_key_file empty, which
viperblockd treats as legacy cleartext mode. The encryption machinery existed
but the install path never generated a key or set the field.

Provision a cluster-wide viperblock master key in all three fresh-install
flows and render its path into spinifex.toml:

- admin init (single node): generate the key locally.
- admin init --multi-node (leader): generate once, save locally, and
  distribute via the formation server (mirrors IAM master key distribution).
- admin join (joiner): receive the shared key from the leader rather than
  minting its own, so volumes sealed on any node open everywhere. Lenient on
  absence — an older leader returns no key, so the joiner warns and stays
  cleartext instead of failing the join.

The key file is omitted from the template when unset, so upgrades with no
existing key keep running cleartext.

* fix: make viperblock encryption key group-readable for both readers

The key is loaded by two services per node — viperblockd (spinifex-viperblock)
and the awsgw EC2 handlers (spinifex-gw), both in the spinifex group — so it
must be group-readable, not owner-only like the per-node predastore key.

The previous commit generated the key but never registered its path with the
deployment ownership machinery, leaving /etc/spinifex/viperblock as root:root
0700 (non-traversable) and the key as root:root 0600. The daemon failed at
startup with "stat ... permission denied".

Mirror master.key: setup.sh creates /etc/spinifex/viperblock (0750), and
SetServiceOwnership chowns the dir to spinifex-viperblock and promotes the key
to root:spinifex 0640.

* fix: read viperblock config.json through StateBody for encrypted volumes

The metadata layer parsed config.json with json.Unmarshal/NewDecoder directly,
which fails on encrypted volumes/AMIs now that viperblock wraps VBState in a
valid-JSON envelope ({"v":1,"payload":...,"authtag":...}). DescribeImages
silently skipped encrypted AMIs (count=0 → "no AMIs available"), bricking the
whole launch path.

Route every config.json read through viperblock.StateBody, which unwraps the
envelope (no key needed — the metadata is authenticated, not encrypted) and is
a no-op for unencrypted volumes:

- image: DescribeImages, getVolumeConfig, amiNameExists, GetAMIConfig
- volume: getVolumeConfigAndEncryption, and mergeVolumeConfig (so the
  refuse-to-merge-encrypted guard sees the real VBState instead of clobbering
  the envelope with a tag-less blob)
- admin: readAMIConfig, readVolumeConfig
2026-06-15 09:31:23 +10:00
Josh Siviour b23fa3bf60 feat(eks): managed control-plane VPC (Set B) + control-plane lifecycle hardening (#356)
* feat(eks): VM-side managed-addon delivery transport

Stage CreateAddon manifests in KV host-side; the control-plane VM pulls the
staged set through the AWS gateway (signed GET /clusters/{name}/internal-addons/
{accountId}), renders the baked bundles into the K3s auto-deploy dir, and
reports per-addon delivery status on a dedicated eks.addon.*.status channel that
the cluster reconciler CASes onto AWS-visible AddonStatus (CREATING->ACTIVE).

- gateway: ListInternalAddons route + ListStagedAddonManifests NATS action
- handlers: addon status reconciler (nextAddonStatus), staging installer
- eks-node AMI: eks-gateway-fetch binary + mulga-eks-addon-sync agent
- agent renders idempotently (cmp before replace) so K3s auto-deploy does not
  re-apply every tick and clear the Addon CR .status.gvks readiness signal
- agent mirrors per-tick delivery outcomes to the serial console for host-side
  observability

* fix(eks): provision cluster NLB synchronously so the endpoint carries the real front-end IP

The cluster NLB front-end IP is assigned by the async LB-VM launch
(launchLBVMAsync), but EnsureClusterNLB read it back synchronously right after
CreateLoadBalancer returned — losing the race and falling back to the synthetic
<lb>.elb.spinifex.local DNS name. That name is unresolvable (no Route53) and is
not in the apiserver serving-cert SAN, so kubectl against the cluster never
connects.

Split the elbv2 create: the public CreateLoadBalancer stays async (267.4 — an
inline multi-minute VM boot head-of-line-blocks the gateway responder), and a
new CreateLoadBalancerSync drives the data-plane launch inline and returns the
LB carrying its front-end address. The EKS cluster-create worker runs off the
gateway responder, so it uses the sync entry and reads the IP from the return
value instead of racing persisted state. No polling.

- elbv2: factor provisionLBDataPlane out of launchLBVMAsync; add CreateLoadBalancerSync
- eks: ensureClusterLB uses the sync entry, sets FrontendIP from the result,
  drops the read-back; EnsureClusterNLB fails loud (ErrClusterNLBFrontendIPUnavailable)
  when no front-end IP is allocated rather than shipping the DNS-name fallback
- eks: drop the DNS-name endpoint fallback; the endpoint is always the front-end IP

* feat: auto-wire IGW for internet-facing EKS cluster endpoint

An internet-facing cluster NLB allocates an external front-end IP and the
OVN dnat_and_snat for it, but the external switch + localnet + gateway LRP
that make that IP answerable on the wire are built only on IGW attach. The
control-plane NLB is a system instance, so ensure an IGW on cluster build
and reclaim it on teardown.

EnsureClusterIGW (idempotent, reuses a customer IGW untouched) runs before
EnsureClusterNLB when the endpoint is public; DeleteClusterIGW (ownership-
scoped) removes only the cluster-owned IGW in purgeClusterInfra, before the
VPC delete. New IGW dep wired from the daemon.

* fix(eks): tear down infra on failed cluster launch to stop LB EIP leak

A CreateCluster that fails during its background launch flipped the meta
to FAILED but performed no teardown, relying on a later same-name create
to reclaim and purge the infra. EKS cluster names are unique per create,
so that reclaim never fires: the internet-facing load-balancer VM keeps
running with its allocated+associated dnat_and_snat EIP indefinitely.
Over repeated create/delete cycles the EIP pool fills with allocations
bound to terminated instances, so a later allocation's advertised
endpoint and its live OVN NAT diverge onto different pool addresses.

- failClusterLaunch now runs a best-effort purgeClusterInfra on the
  in-memory meta (freshest refs even when the failing step was the meta
  write itself) before MarkClusterFailed. purgeClusterInfra is
  idempotent, so a later reclaim or DeleteCluster re-purge is a no-op;
  the FAILED meta is retained for observability.
- Daemon releaseSystemInstanceEIP extracted and called from both
  TerminateSystemInstance and cleanupFailedSystemInstance, so a launch
  that fails after AssociateAddress releases the eipService allocation
  instead of leaking it (MarkFailed's ReleasePublicIP only knows the
  externalIPAM path).

* feat: EKS managed control-plane VPC (AWS Set B) composed from EC2 APIs

CreateCluster now auto-builds the managed control-plane VPC under the
system account, the spinifex analogue of AWS EKS's hidden managed-account
VPC: a public subnet for the internet-facing NLB, private subnet(s) for the
k3s control-plane VM(s), and a NAT gateway giving the private CP egress for
image pulls. It is composed from the real EC2 VPC-family APIs
(CreateVpc/Subnet, IGW, RouteTable/Route, NatGateway), so per-subnet egress
gating is wired by the existing topology subscribers off the route-table
events those APIs publish — no direct OVN mutation.

This resolves the mulga-siv-292 root cause: the internet-facing NLB and the
private CP were crammed into one customer subnet with no IGW route, so both
were treated as private and dropped by the lr_in_policy 1100 rule, killing
the LB-VM return leg and lb-agent heartbeat. The CP only survived via a
per-instance 1200 reroute bandaid, now removed — the topology expresses
egress instead.

Placement is uniform single/multi-node (CP always in the private subnet).
The system LB VM heartbeats over the mgmt bridge (SystemGatewayURL) rather
than the WAN. Managed-VPC teardown reclaims NAT GW + EIP, route tables,
subnets, IGW, and the VPC in dependency order. Customer subnetIds (Set A /
worker side) remain consumed-not-created and are deferred to the worker
round.

* fix(eks): bake cluster-owner account into CP-VM EKS_ACCOUNT_ID

The managed control-plane VPC (Set B) launches the K3s control-plane VMs and
their ENIs under the system (infra) account, but the on-VM agents — bootstrap
publish, state report, token-review, and add-on fetch — must key on the
cluster-OWNER (customer) account that owns the cluster meta, namespaces, and
AccessEntry KV. Add ClusterAccountID to K3sServerInput and bake it into the
VM's EKS_ACCOUNT_ID, decoupling it from the infra AccountID the VM launches
under. Without this the VM reported state and fetched add-ons against the
system account, so the cluster never observed a healthy control plane (stuck
CREATING, null CA) and kubectl/add-on delivery timed out.

Also refresh the e2e suite doc comment to describe the Set B topology and the
data-plane subtests (kubectl + managed-addon delivery) that now run.

* fix(eks): explicitly delete addon objects on unstage

K3s does not delete the applied resources when an auto-deploy manifest file is
removed from the server manifests dir (documented behaviour). The addon-sync
GC pass relied on that, so after DeleteAddon the rendered manifest was dropped
but its Namespace/ConfigMap stayed standing — the e2e AddonDelivery GC wait
timed out. Copy the manifest aside, drop the original so K3s stops tracking it,
then kubectl delete the objects (--wait=false to avoid finalizer stalls).
2026-06-12 22:29:24 +10:00
Julian Sommer a57ebd0ad9 chore: remove references to pseudo multi node 2026-06-12 15:28:23 +10:00
joshsiv-mulga 022c6cfd2a Update Makefile
Signed-off-by: joshsiv-mulga <mrk@mulgadc.com>
2026-06-12 15:26:11 +10:00
Julian Sommer 19472c377a chore: delete legacy scripts 2026-06-12 15:17:48 +10:00
Julian Sommer 5238e29f48 fix: admin init message 2026-06-12 15:02:54 +10:00
Julian Sommer 4adecc0794 chore: delete old scripts 2026-06-12 14:52:18 +10:00
Julian Sommer ef86d09a42 chore: delete legacy scripts/iac 2026-06-12 14:51:38 +10:00
Julian Sommer ad4e6e8896 chore: delete empty INSTALL.md 2026-06-12 14:41:52 +10:00
Julian Sommer 642fdacdb7 chore: delete dead function 2026-06-12 14:37:29 +10:00
Julian Sommer 2c6f65b2e9 fix(network): restore centralised EIP gateway-port neigh prime (#355) 2026-06-12 14:23:01 +10:00
Josh Siviour a2ede7c476 EKS AWS-compat + nodegroup lifecycle hardening (env19) (#353)
* fix(eks): surface CREATING block reason at Info, not Debug

A cluster stuck in CREATING logged its per-tick bootstrap/health block
reason at Debug, invisible at the default level, so a create that ran to
the 15m timeout FAILED gave no diagnosable cause. Bump both reasons to
Info; they fire only during the CREATING window.

* feat(iam): round-trip AWS-managed policy ARNs for stock EKS tooling

Stock terraform-aws-eks / eksctl attach AWS-managed policies
(AmazonEKSClusterPolicy, AmazonEKSWorkerNodePolicy, AmazonEKS_CNI_Policy,
...) that Spinifex does not provision. AttachRolePolicy rejected them
NoSuchEntity and ListAttachedRolePolicies dropped them, breaking the
node/cluster role round-trip DescribeNodegroup relies on. Accept and
store managed ARNs opaquely (no backing document), deriving the policy
name from the ARN on read.

* feat(eks): echo cluster tags + store-only Tag/Untag/ListTags

DescribeCluster never returned create-time tags and ClusterMeta did not
store them, so a stock terraform-aws provider reconciling default_tags
saw perpetual drift and issued TagResource on every apply — which itself
returned NotImplemented. Store tags on the cluster record, echo them from
DescribeCluster, and back the three tag ops store-only against the record
(cluster ARNs only) so the default_tags round-trip is clean.

* fix(eks): persist nodegroup workers incrementally + boot reclaim sweep

launchWorkers only wrote the worker IDs to the record after the whole
launch loop, so a daemon crash mid-loop stranded already-launched workers
under a record that still showed empty InstanceIDs — nothing could reclaim
them. Persist each worker before launching the next, and add a boot-time
reclaim sweep (SpawnRegisteredReconcilers) that terminates recorded
workers for any nodegroup left CREATING by a crashed launcher, or
CREATE_FAILED still holding workers, settling it to CREATE_FAILED with
InstanceIDs cleared.

* fix: plumb query params into EKS gateway dispatcher

EKS_Request never passed the query string to per-action handlers, so
UntagResource (DELETE /tags/{arn}?tagKeys=k1&tagKeys=k2) saw an empty
TagKeys and silently no-op'd. Fold url.Values into the JSON body when
the body is empty so the handler unmarshal sees the keys.

* feat(iam): resolve AWS-managed EKS policy grants for authorization

Assumed-role/user authorization (checkPolicy -> Get{Role,User}Policies ->
EvaluateAccess) could not honour AWS-managed policy grants: Spinifex stores
no document for managed ARNs, so stock EKS roles whose permissions come
entirely from managed policies (node role: AmazonEKSWorkerNodePolicy +
AmazonEC2ContainerRegistryReadOnly + AmazonEKS_CNI_Policy) evaluated to an
empty grant set and were denied every call.

Add a builtin registry of well-known AWS-managed policy documents, resolved
via a shared resolveAttachedPolicy helper used by both GetRolePolicies and
GetUserPolicies. Unmodeled managed ARNs resolve to no grant (fail-closed
deny) rather than erroring the whole request; customer-managed ARNs still
fail closed when unresolvable.
2026-06-12 13:54:11 +10:00
Julian Sommer 50b5495d26 chore: more comment cleanup 2026-06-12 12:38:31 +10:00
Julian Sommer 53e8c3f60f chore: trim comments 2026-06-12 12:06:57 +10:00
Julian Sommer 58842e5e8c chore: fix stale comments 2026-06-12 11:42:19 +10:00
Julian Sommer 1dda4cb37c feat(gateway): enforce assumed-role session policies (#354) 2026-06-12 11:34:53 +10:00
Julian Sommer c6c26c04e2 chore: trim workflow comments 2026-06-12 11:04:46 +10:00
Julian Sommer 2df35d3822 chore: remove reboot e2e 2026-06-12 10:46:14 +10:00
Josh Siviour d076a434d4 Feat/e2e targeted suites (#352)
* test(e2e): add targeted suite selector + shadow-mode CI step

select-suites maps a diff to the e2e suites it must run: changed files →
services (manifest path/additional_paths) → consumer closure over
depends_on vs publishes/subscribes → suites whose covers intersect.
Special cases force-all (infra/harness/workflow, ≥3 services, submodule
bumps) or a lone suite dir; empty diffs fall back to default suites. An
AST scan of each suite's harness.Ensure* call graph derives per-test
service coverage and emits an optional -test.run subset (RUN_PATTERN).

Wired into e2e.yml as a non-gating 'Select Suites (shadow)' job that
logs the computed selection to the run summary so predicted vs actual
can be compared before the selector drives SUITES.

* ci(e2e): add dispatch-only per-suite job blocks grouped by env

Restructure the looped e2e flow into env-grouped provision -> matrix
suites -> teardown blocks so each suite surfaces as its own node in the
Actions graph instead of a single green tick:

  single_provision -> single_suite [single,cert,lb,eks] -> single_teardown
  multi_provision  -> multi_suite  [multinode]          -> multi_teardown

Provision resolves the VM/cluster and ships test binaries, emitting WAN
IP + ssh key path as job outputs; each matrix leg runs one suite over
SSH and uploads its own junit/analysis artifacts; teardown (if:always)
reuses the persisted tofu workspace on the same runner to destroy.

workflow_dispatch only -- runs alongside the gating e2e.yml for
validation and posts no commit status.

* test(e2e): add manifest drift lint (fixture-create + NATS subject)

Bead 5 of the targeted-suite-selection plan. Two ratcheted drift guards in
tests/e2e/manifest-lint, wired into make preflight and the spinifex.yml PR job:

- Fixture lint: AST-flags direct resource-create AWS calls
  (c.EC2.RunInstances, c.ELBv2.CreateLoadBalancer, ...) in suite test files,
  steering them through harness.Ensure* fixtures. ~104 pre-existing call sites
  are captured in a checked-in baseline; the lint fails only on NEW creates.
  Intentional create-path tests opt out with an inline // e2e:allow-create.

- Subject lint: extracts NATS subject literals from the natsSub{} registry and
  literal Subscribe/Publish/Request args, cross-referenced wildcard-aware
  against service-interfaces.yaml. Code subjects missing from the manifest fail
  (ratcheted); manifest subjects with no static reference warn only (many are
  produced dynamically). Fixed real drift surfaced on first run: declared the
  five spinifex.cluster.shutdown.* phase subjects under spinifex_daemon.

manifest-check is now CI-enforced alongside manifest-lint (previously
preflight/local-only).

* feat: run-scoped e2e teardown sweep

Tag every Ensure* fixture resource with e2e:run=<run-id> when
SPINIFEX_E2E_RUN_ID is set, and add a teardown.test suite that sweeps
everything carrying that tag at end of run — reclaiming resources leaked
by a crashed suite, an interrupted run, or a persistent cluster.

- harness/sweep.go: tagRunResources (EC2 CreateTags) + tagRunELB (ELBv2
  AddTags) + SweepRunResources in dependency order (LB, instances, NAT,
  snapshots, volumes, images, SGs, subnets, key pairs). Blank run id
  no-ops both tagging and sweeping, so the sweep can never touch an
  untagged or foreign resource.
- harness/fixtures.go: stamp the run tag on each created resource.
- tests/e2e/teardown: thin suite that runs the sweep; skips without a
  cluster or run id, logs the report, never fails the run.
- e2e-suites.yml: build + ship teardown.test, pass the run id into suite
  legs, sweep on the VM in both teardown jobs before tofu destroy.
2026-06-11 21:09:50 +10:00
Julian Sommer 0b3e950abd fix(network): verify gateway claim on chassisredirect port, not distributed LRP (#351)
The gateway-claim verifier polled the SB Port_Binding for the distributed
gateway port LRP (gw-<vpc>), which is always chassis-less by OVN design — the
chassis claim lands on the chassisredirect port cr-gw-<vpc>. So
ensureGatewayClaimed always read "unclaimed", fired ovn-appctl
inc-engine/recompute every reconcile cycle, and logged a spurious "gateway SB
chassis claim did not converge" error after its 30s budget. The repeated forced
recomputes rebuilt the entire OVN flow table every cycle, intermittently
darkening freshly attached EIP datapaths and flaking the single-node e2e suite
(observed: every br-int EIP flow at duration~10s with n_packets=0).

Check the chassisredirect Port_Binding instead. The nudge now fires only on a
genuinely unclaimed redirect — the real reboot race #339 targets — rather than
on every healthy reconcile.

- topology: add GatewayChassisRedirectPort(vpcID) -> "cr-gw-<vpc>"
- reconcile/apply: pass the cr- port to ensureGatewayClaimed
- reconcile/host: doc the verifier checks the chassisredirect binding
- test: capture the queried port; assert the cr- port is checked, no nudge
2026-06-11 20:35:34 +10:00
Julian Sommer c4926d423f feat: IMDS public-keys (#350) 2026-06-11 15:19:01 +10:00
Julian Sommer b38c888b4d chore: dedupe auth helpers (#349) 2026-06-11 14:03:13 +10:00
joshsiv-mulga a5b6a74a0b fix(e2e): serialise TestMultinodeGuestSSH after cluster restabilises
TestMultinodeGuestSSH ran with t.Parallel(), so it resumed immediately
after the sequential bucket and probed trio[0] — which CrossNodeOps
stop/starts but only waits to reach state "running", not for sshd to
answer. The guest was often still booting, producing
kex_exchange_identification resets and 2m timeouts that flaked CI red.

Drop t.Parallel() and declare the test last in the sequential bucket so
it runs after CrossNodeOps + NodeFailure/Recovery + Spread have
restabilised the cluster, giving every trio member time to settle and
removing concurrent VM churn.
2026-06-11 13:00:35 +10:00
Julian Sommer 19de3b0994 chore: update COMMANDS.md 2026-06-11 12:50:34 +10:00
Julian Sommer 620b96f9c6 feat: Terraform S3 Webapp using IMDS (#348) 2026-06-11 12:35:15 +10:00
Julian Sommer 05e66bd6a0 feat: ListPolicyVersions 2026-06-11 12:25:12 +10:00
Josh Siviour db8d5a7265 fix(elbv2): build lb-agent data-plane config on LB-ready heartbeat (#347)
CreateLoadBalancer is async: InstanceID is only set when the LB VM's QEMU
process starts, ~600ms after the create burst. The reactive
updateStoredConfig calls from CreateListener/RegisterTargets all no-op
during that window (InstanceID empty), and nothing rebuilds config once
the VM launches. The lb-agent then heartbeats, gets an empty ConfigHash,
never sees it change, and probes zero backends -> e2e 0/2 healthy.

Build the data-plane config on the provisioning->active heartbeat
transition instead: that is the one point where InstanceID is guaranteed
set and the agent is alive to consume config. The InstanceID guard in
updateStoredConfig stays as a cheap no-data-plane skip, no longer the
primary gate.
2026-06-11 11:56:42 +10:00
Julian Sommer 3a9a1564eb chore: delete orphaned bash e2e scirpts 2026-06-11 10:34:58 +10:00
Josh Siviour c7f337229f feat(vpcd): drain held DHCP leases on teardown (#346)
DHCP-source external-pool leases are DHCPRELEASEd only on explicit
instance/EIP terminate. Manager.Stop() preserves leases for
adopt-on-reboot, so env teardown/reset kills vpcd and destroys the
per-AZ lease KV without releasing — stranding every live lease
(instance ENIs, EIP allocations, per-VPC LRP gateways) on the upstream
DHCP server until TTL. Across CI churn with fresh client-ids the
upstream scope fills and new launches get no public IP.

Add Manager.DrainAll + a vpc.dhcp.drain RPC and a standalone
'spx admin cluster drain-dhcp' command that releases every held lease.
Wired into both teardown paths (single-node teardown role and
multi-node cluster-teardown.yml) before services stop. Stop() is
unchanged, so normal restarts still adopt leases on reboot.
2026-06-11 10:11:23 +10:00
Josh Siviour e8bd99386e fix(ec2): fail RunInstances when public-IP auto-assign fails (mulga-siv-275) (#345)
* fix(ec2): fail RunInstances when public-IP auto-assign fails

PrepareRunInstances swallowed an AllocateIP failure for a
MapPublicIpOnLaunch subnet (logged WARN, fell through), so the instance
booted running with no public IP. With no qemu-hostfwd fallback such an
instance is unreachable. Mirror the adjacent AddNAT-failure path: detach
and delete the auto-created ENI, deallocate capacity, drop the instance,
and surface InsufficientAddressCapacity.

* test(daemon): deterministically pick a fitting 2-vCPU type in capacity filter test

CapacityFilterRespectsAllocation selected the first 2-vCPU type by Go
map-iteration order, intermittently picking a type near the
schedulable-memory boundary the allocator then rejected (~3/5 failures in
isolation on small hosts). Select the smallest-memory 2-vCPU type from the
capacity-aware set so it always fits.
2026-06-11 09:47:40 +10:00
Josh Siviour 5069a085c6 fix: roll back ELBv2 partial create-load-balancer/listener infra (#344)
CreateLoadBalancer's PutLoadBalancer-failure path only released the name
claim, orphaning the ENIs and managed front-end SG already created. Extract
the ENI+SG+claim teardown into rollbackLBInfra and call it from both the
ENI-create-failure and PutLoadBalancer-failure paths.

CreateListener persisted the listener record before authorizing the NLB
managed-SG port and reloading config; a failure in either left an orphan
listener record (and, after a config failure, a dangling SG authorization).
Add rollbackListener to revoke the port (when already opened) and
cascade-delete the listener on every post-persist failure.

Also wait for the async worker launch in the two CreateNodegroup disk-size
tests so they observe the launch under the record-before-launch contract.
2026-06-10 23:20:20 +10:00
Josh Siviour 8ab5441103 fix: async platform create handlers to stop responder starvation (mulga-siv-267.4) (#343)
* fix: make platform create handlers async to stop responder starvation

EKS CreateCluster/CreateNodegroup and ELBv2 CreateLoadBalancer ran their
full VM/control-plane launch synchronously inside the NATS subscription
callback on the shared spinifex-workers queue. A multi-minute create
head-of-line blocked the worker, so system.LaunchInstance/TerminateInstance
found no responder within the gateway timeout and failed with
'nats: no responders available' (mulga-siv-267.4).

Each create now runs validation + the atomic name claim synchronously,
snapshots the CREATING/provisioning output, then spawns the slow launch on
a per-impl launchWG and returns immediately. Launch failures mark the
record FAILED/CREATE_FAILED so 267.3 reclaim makes a retry idempotent. The
service interfaces are shared by the gateway NATS clients and the daemon
impls, so the async split lives inside the impl method and leaves the
interface, handleNATSRequest, and all daemon handlers unchanged.

The host-pinned system.LaunchInstance/TerminateInstance responders now
dispatch each request on its own goroutine (systemDispatchWg) so a slow
boot, which must respond after it completes, no longer blocks the subject.

ELBv2 keeps ENI creation synchronous (cheap VPC calls that yield the
AZ/VpcId the create response needs); only the LB-VM boot goes async,
folding InstanceID/IPs back into the record for a later Describe.

* fix: keep node-targeted launch subscription live under capacity pressure

The node-targeted (unicast) system.LaunchInstance.{type}.{node} subject was
torn down whenever the node filled, same as the queue (anycast) subject. For
the queue subject that is correct — NATS reroutes the launch to a node with
room. For the node-targeted subject there is no other node: a committed spread
reservation (EKS HA control-plane leg) that targets the now-unsubscribed node
gets 'nats: no responders available' instead of a capacity decision.

Keep the node-targeted subscription live for any type the node can host;
enforce capacity at launch time via ResourceManager.allocate, which already
rejects an over-capacity launch atomically. A reserved launch always reaches a
responder and, when the slot is still good, succeeds.
2026-06-10 22:41:43 +10:00
joshsiv-mulga e8cbd99158 fix(eks): propagate nodegroup DiskSize to worker root volume
launchWorkers built RunInstancesInput with no BlockDeviceMappings, so
parseVolumeParams applied the 4GB root-volume default and the nodegroup
DiskSize was silently ignored. Wire rec.DiskSize into a BlockDeviceMapping
when set.
2026-06-10 22:36:42 +10:00
Josh Siviour 22d316f76e test(reboot): capture OVN chassis-identity churn + ovn-controller claim/connection state on post-reboot failure (#342)
Run 27254808154 showed TestRebootResilience fail with ovn-controller on
the live chassis claiming NO local Port_Bindings post-reboot: ENIs stuck
on a dead chassis UUID, cr-gw chassis empty, NAT external_mac empty —
while NB intent was correct and taps carried the right iface-id. The SB
Chassis _uuid churned across the reboot (old UUID gone, new one created)
while the SB DB persisted, orphaning every claim.

The current artifacts cannot pin WHY the new chassis won't reclaim: the
ovn-controller vlog lives under /var/log/ovn, not journald, so it is
absent from the systemd-journal capture.

Add:
- phase5Snapshot: capture chassis identity PRE-reboot (system-id, SB
  Chassis/Chassis_Private, get-connection) to ovn-pre-chassis.txt as the
  churn baseline.
- dumpOVNClaimState: at post-reboot failure, capture the same chassis
  state POST-reboot plus ovn-controller connection-status/debug, the
  gw/eni Port_Binding claim detail, ovn-central-vs-ovn-controller boot
  ordering, and tails of ovn-controller.log / ovn-northd.log →
  diag-ovn-claim.txt.

Disambiguates chassis-identity churn from an SB/NB startup-ordering race
on the next reboot-suite run. Diagnostic only; no behavior change.
2026-06-10 16:19:34 +10:00
Josh Siviour 0b79697c15 feat: atomic create idempotency across EKS, ELBv2 and EC2 ClientToken (267.3) (#340)
* feat: atomic create idempotency across EKS, ELBv2 and EC2 ClientToken

Every create handler took a non-atomic Get-check then a blind Put, so a
gateway/NATS retry that spawned a second concurrent handler could pass the
check in both and double-launch + leak VMs (267 failure-mode C).

Make each create claim its identity key atomically before any launching work:

- EKS CreateCluster: claim the cluster meta via kv.Create; a prior FAILED
  attempt is reclaimed via a CREATING CAS so exactly one concurrent retry wins.
  purgeClusterInfra gains a deleteMeta flag so reclaim purges infra while
  keeping its claim key.
- EKS CreateNodegroup: claim the nodegroup record before SG provisioning or
  worker launch; duplicates return ResourceInUse without provisioning.
- ELBv2 CreateLoadBalancer: account-scoped name claim (lbname.<acct>.<name>)
  taken before ENI/VM work, released on every failure path and in
  DeleteLoadBalancer; a name claim whose owner lbID has no live record is a
  crashed-create orphan and is reclaimed.
- EC2 RunInstances: ClientToken dedup over a dedicated TTL bucket with a
  two-phase claim (in-flight -> done) and reservation replay; a token reused
  with different params returns IdempotentParameterMismatch.

Race tests under -race assert exactly one owner and a single launch for
concurrent same-name creates on all paths.

* test: cover ClientToken idempotency; extract orchestration for testability

Extract the claim->replay->launch->finalize/abort flow out of RunInstances
into runInstancesWithClientToken(store, ..., launch func) so the idempotency
path is unit-testable without a live gateway/NATS. RunInstances now just
resolves the store, hashes params, and passes a launch closure.

Make the in-flight poll timeout/step package vars so a duplicate-caller
wait-timeout is deterministically testable.

Adds tests: replay-skips-launch, owner-launches-once-then-replay,
launch-failure-aborts, param-mismatch maps to AWS error, getClientTokenStore
binds once, and in-flight wait-timeout.
2026-06-10 15:43:37 +10:00
joshsiv-mulga 908005df74 feat(iam): add ListRolePolicies handler returning empty inline list
Satisfies the AWS provider's aws_iam_role read-back. Spinifex only
attaches managed policies, never inline, so this always returns empty.
2026-06-10 15:20:21 +10:00
Josh Siviour 19c53b0794 fix: verify SB gateway-port chassis claim after rebind, nudge ovn-controller (#339)
After a host reboot the Northbound gateway_chassis intent recovers correctly
(SetGatewayChassis re-asserts it idempotently), but ovn-controller can leave the
Southbound Port_Binding for the gateway router port unclaimed (chassis == []).
With no claimed binding it installs no logical->physical flows for the centralised
gateway redirect, so every floating IP on the VPC is dark: ofproto/trace drops the
FIP ingress at br-int table 0, host->FIP is 100% packet loss, and the ALB lb-agent
heartbeat times out until the 5-min health window expires (TestRebootResilience).

rebindGatewayChassis only asserted NB intent. Now, after SetGatewayChassis, the
reconciler verifies the SB Port_Binding has a non-empty chassis and, if not, nudges
the local ovn-controller to recompute (inc-engine/recompute), polling until the
claim converges or a bounded deadline elapses — logging an error rather than
blocking reconcile. SB read + recompute are exec-only (ovn-sbctl/ovn-appctl) and
live in network/host behind a reconcile.GatewayClaimVerifier interface, wired in
vpcd; reconcile stays NB-pure and mockable. No NB object create/delete (ADR-0006 S5).

Verified by run 27251769281's diag-ovn-flows.txt (SB chassis empty + table-0 drop).
Validation completes on the next reboot-suite nightly (single-node self-heals too
fast to reproduce locally).
2026-06-10 15:02:31 +10:00
Josh Siviour f8482ad05a test(reboot): heartbeat-path diagnostics on post-reboot health timeout (#337)
* test(reboot): capture heartbeat-path diagnostics on post-reboot health timeout

When post-reboot ALB targets fail to go healthy, the lb-agent's heartbeat to the
gateway times out with 'context deadline exceeded while awaiting headers' — a
symptom that alone cannot distinguish a network-level drop from an app/gateway
stall. Replace the fatal WaitForTargetsHealthy with a non-fatal poll that, on
timeout, tcpdumps br-wan for the heartbeat 5-tuple + ARP for the ALB floating IP
and pulls the lb-agent console + awsgw journal, then fails. Disambiguates the
layer before any fix (mulga-siv-265).

* test(reboot): re-aim heartbeat capture + add OVN dataplane flow-dump

Run 27250238163 proved the post-reboot failure is network-level (all 3 floating
IPs 100% packet-loss from host) but NOT neigh/NAT priming — neigh is primed and
NAT rows are present, only br-int forwarding is dead. The prior capture watched
br-wan for the ALB floating IP, while the real lb-agent heartbeat posts to the
gateway port on the host's advertise interface, so the L4 classifier could never
see the heartbeat and defaulted regardless.

- heartbeat tcpdump now watches the gateway port on `any` (real path) plus a
  secondary br-wan floating-IP capture for north-south; classifier keys off
  gateway-port L4 presence.
- new dumpOVNDataplane captures ovs-vsctl show, ovs-ofctl dump-flows br-int,
  ovn-sbctl chassis + Port_Binding, dnat_and_snat NAT rows, host neigh, and a
  host->floating-IP ping + ofproto/trace, to pin the flow-restore step.
- failure path dumps diag-ovn-flows.txt alongside diag-heartbeat.txt.

Test-only, build-tagged e2e; no production code.
2026-06-10 14:56:34 +10:00
Josh Siviour 11e58b200d fix: persist EKS control-plane VM refs before egress alloc to prevent leak (#338)
CreateCluster set the control-plane VM/ENI/spread-group refs into the cluster
meta but first persisted them to KV only after allocateClusterEgressIP. A
failure between placement and that persist left the live sys.medium CP VMs
unrecorded, so neither DeleteCluster nor the FAILED-cluster reclaim could reach
them and they leaked, reserving host capacity.

Persist the meta immediately after placeControlPlane returns. The reclaim path
keys off the persisted ControlPlaneNodes + ControlPlaneSpreadGroup, so this also
frees reserved spread capacity on teardown.
2026-06-10 13:37:44 +10:00
Josh Siviour 4457b813ae fix: make EKS DeleteCluster egress-EIP release idempotent (#336)
A retried DeleteCluster (or the egress-delete cascade) can release the
hidden-pool allocation before the release step runs, so ReleaseAddress
returns InvalidAllocationID.NotFound. That was treated as a fatal teardown
error, so the cluster wedged in DELETING forever and its KV meta plus
downstream resources leaked. Swallow the NotFound variants as idempotent
success — mirroring the 165.17 ENI-teardown fix — so the SG + KV sweep
proceeds. A genuine release failure still wedges DELETING for retry.
2026-06-10 13:37:40 +10:00
Josh Siviour 74b91b5cc5 feat(eks): HA control plane — 3-node etcd quorum spread across distinct hosts (#335)
* feat(eks): node-targetable control-plane VM (HA spread transport prereq)

Groundwork for HA control-plane placement (mulga-siv-231.7.1): make the k3s
control-plane VM placeable on a specific Spinifex host.

- Add a sys.medium instance type (2 vCPU / 4 GB) and boot the control-plane VM
  as it instead of t3.medium. A sys.* type keeps the CP VM out of customer
  DescribeInstanceTypes and makes the daemon register the node-targeted
  system.LaunchInstance.{type}.{nodeID} subject (only sys.* types get it).
- Add K3sServerInput.TargetNodeID and a LaunchSystemInstanceOnNode method on
  the launcher: empty/local node launches in-process (unchanged single-CP
  path); any other node dispatches over the node-targeted subject, where the
  receiving daemon runs the launch and owns the VM.

The spread orchestrator (reserve N hosts, launch per host, verify, rollback,
<3-host fallback) lands separately; CreateCluster still launches locally
(TargetNodeID empty), so behaviour is unchanged.

* feat(eks): HA control-plane spread orchestrator

Place N=3 k3s server VMs across distinct Spinifex hosts via a per-cluster
spread placement group, with all-or-nothing rollback and single-CP fallback.

- placeControlPlane: capacity fan-out (HostScheduler) -> >=3-host gate ->
  ensure spread group (system account) -> ReserveSpreadNodes(Min=Max=3) ->
  parallel per-node LaunchK3sServerVM(TargetNodeID) -> node-vms placement
  verify -> FinalizeSpreadInstances. Any launch/verify/finalize failure
  terminates launched VMs, releases the reservation, drops the group.
- <3 schedulable hosts or a lost reservation race falls back to a single
  local control-plane VM (prior behaviour).
- ClusterMeta gains ControlPlaneNodes[] + ControlPlaneSpreadGroup; [0] is
  mirrored into the scalar ControlPlane* fields for back-compat. DeleteCluster
  tears down every CP VM and the spread group.
- Wire PlacementGroup + Scheduler into EKSServiceDeps + orchestration-deps gate.

* fix(eks): size HA control-plane spread on raw host headroom

SchedulableHosts matched the CP type by name in node.status's per-type
capacity list, but that list omits sys.* types (system types are hidden
from customers). The CP VM is sys.medium, so the match never hit and
SchedulableHosts returned 0 on every node — placeControlPlane always fell
back to a single CP, making the >=3-host spread unreachable in production.

System VMs consume host vCPU/memory like any guest, so size the fit against
each node's raw schedulable headroom (Total - Reserved - Alloc) and the
type's footprint instead. Adds instancetypes.SpecForSystemType for the
sys.* vCPU/memory spec and a pure nodeFitsSystemInstance helper.

* feat(eks): k3s server-join role for HA control-plane quorum

Servers 2..N now boot in join mode: server: https://<first-server>:6443 +
a shared cluster token, without cluster-init, so they join the first server's
embedded-etcd quorum instead of each cluster-initing an independent datastore.

- K3sServerInput gains JoinToken (shared cluster secret, set on every server)
  and ServerURL (set => join server, no cluster-init, server-join role).
- buildK3sUserData branches cluster-init vs server:, emits token:, and selects
  SPINIFEX_K3S_ROLE server vs server-join.
- launchControlPlaneSpread launches nodes[0] first (cluster-init), derives the
  join URL from its ENI IP, then launches the rest in parallel as join servers;
  a first-server failure skips the joins and rolls back.
- CreateCluster mints a per-cluster crypto-random token (GenerateK3sClusterToken)
  threaded into the placement template.
- eks-node-role.sh adds a server-join case: token-webhook + k3s + state-report,
  but not k3s-first-boot (the first server already publishes the bootstrap
  artifacts; a join re-publish only races the bootstrap bus).

* fix: open etcd-peer/kubelet ingress on EKS control-plane SG for HA join

Join servers (231.7.2) register over the supervisor port 6443 but their
embedded etcd could not peer with the quorum: the control-plane SG only
admitted 6443 from the VPC CIDR, omitting etcd client+peer 2379-2380 and
kubelet 10250 between control-plane nodes. A single cluster-init server never
exercises those ports, so the gap stayed hidden until multi-server HA — the
join registered but its etcd never replicated the raft log, leaving the node
perpetually NotReady.

Add EnsureControlPlaneHAIngress with self-referencing (cpSG->cpSG) rules for
etcd 2379-2380 and kubelet 10250, called from CreateCluster after
EnsureControlPlaneIngress. Verified live on a 3-node cluster: join servers
reach Ready and form a 3-member etcd quorum that survives loss of the original
first server.

* feat: register all control-plane ENI IPs as EKS cluster NLB targets

Register every HA control-plane server ENI IP in the cluster NLB target
group, not just the primary. The TG already health-checks :6443, so a
dead member drains and kubectl/apiserver traffic fails over to surviving
etcd-quorum members. Add RegisterClusterTargets batch helper; the single
RegisterClusterTarget now delegates to it.
2026-06-10 12:25:47 +10:00
Josh Siviour a6a60b5ba6 fix: persist EIP MAC so distributed NAT survives host reboot (#333)
Associated Elastic IPs lost external connectivity after a host reboot:
vpcd's reconcile re-applied the dnat_and_snat rule without the ENI MAC,
collapsing it to the centralised shape (external_mac/logical_port unset),
and the daemon's relaunch republish only fired for auto-assigned public
IPs, never for store-backed EIPs.

- EIPRecord gains MacAddress, captured at AssociateAddress and cleared at
  DisassociateAddress, so reconcile can re-apply the distributed shape
  without re-querying the ENI.
- loadEIPs feeds rec.MacAddress into the reconcile spec; policy.AddEIP
  self-heals a stale centralised row when given a MAC.
- AssociatedPublicIPForInstance lets the relaunch hook resolve an
  associated EIP from the store and republish vpc.add-nat with the MAC.
2026-06-10 10:11:14 +10:00
Josh Siviour 69b62f3ca0 feat(eks): HA control-plane spread placement (mulga-siv-231.7.1) (#332)
* feat(eks): node-targetable control-plane VM (HA spread transport prereq)

Groundwork for HA control-plane placement (mulga-siv-231.7.1): make the k3s
control-plane VM placeable on a specific Spinifex host.

- Add a sys.medium instance type (2 vCPU / 4 GB) and boot the control-plane VM
  as it instead of t3.medium. A sys.* type keeps the CP VM out of customer
  DescribeInstanceTypes and makes the daemon register the node-targeted
  system.LaunchInstance.{type}.{nodeID} subject (only sys.* types get it).
- Add K3sServerInput.TargetNodeID and a LaunchSystemInstanceOnNode method on
  the launcher: empty/local node launches in-process (unchanged single-CP
  path); any other node dispatches over the node-targeted subject, where the
  receiving daemon runs the launch and owns the VM.

The spread orchestrator (reserve N hosts, launch per host, verify, rollback,
<3-host fallback) lands separately; CreateCluster still launches locally
(TargetNodeID empty), so behaviour is unchanged.

* feat(eks): HA control-plane spread orchestrator

Place N=3 k3s server VMs across distinct Spinifex hosts via a per-cluster
spread placement group, with all-or-nothing rollback and single-CP fallback.

- placeControlPlane: capacity fan-out (HostScheduler) -> >=3-host gate ->
  ensure spread group (system account) -> ReserveSpreadNodes(Min=Max=3) ->
  parallel per-node LaunchK3sServerVM(TargetNodeID) -> node-vms placement
  verify -> FinalizeSpreadInstances. Any launch/verify/finalize failure
  terminates launched VMs, releases the reservation, drops the group.
- <3 schedulable hosts or a lost reservation race falls back to a single
  local control-plane VM (prior behaviour).
- ClusterMeta gains ControlPlaneNodes[] + ControlPlaneSpreadGroup; [0] is
  mirrored into the scalar ControlPlane* fields for back-compat. DeleteCluster
  tears down every CP VM and the spread group.
- Wire PlacementGroup + Scheduler into EKSServiceDeps + orchestration-deps gate.

* fix(eks): size HA control-plane spread on raw host headroom

SchedulableHosts matched the CP type by name in node.status's per-type
capacity list, but that list omits sys.* types (system types are hidden
from customers). The CP VM is sys.medium, so the match never hit and
SchedulableHosts returned 0 on every node — placeControlPlane always fell
back to a single CP, making the >=3-host spread unreachable in production.

System VMs consume host vCPU/memory like any guest, so size the fit against
each node's raw schedulable headroom (Total - Reserved - Alloc) and the
type's footprint instead. Adds instancetypes.SpecForSystemType for the
sys.* vCPU/memory spec and a pure nodeFitsSystemInstance helper.
2026-06-10 08:58:58 +10:00
Josh Siviour 686e374758 fix: strip duplicate lb- prefix from haproxy socket and cert paths (#331)
* fix: strip duplicate lb- prefix from haproxy socket and cert paths

LoadBalancerID, ListenerID and friends already carry their type prefix
(lb-/lst-), so the stats-socket, frontend cert, and HAProxyManager
config/pid filename templates double-prefixed: lb-lb-<id>.sock,
lb-lb-<id>-<lst>.pem, alb-lb-<id>.cfg.

The socket path is computed independently on both sides (gateway render
in buildHAProxyConfig, agent poll in lbagent.New); any divergence in the
lb- prefix convention between the two leaves the agent polling a socket
HAProxy never bound, spamming 'HAProxy stats unavailable: no such file'
while the data plane serves fine. Drop the redundant literal prefix so
both sides resolve to <id>.sock and the alb-/nginx cert paths match the
delivered config.

* refactor: remove unused HAProxyManager

HAProxyManager was never constructed (NewHAProxyManager had no callers).
The live ALB data plane runs in the lb-agent via reloadHAProxy with the
config generated daemon-side and shipped; this per-daemon process manager
was dead code. Remove the type, its methods, tests, and now-unused
imports (log/slog, os/exec, sync, syscall).
2026-06-10 08:55:34 +10:00
Josh Siviour 90369371ee Feat/nightly e2e fix (#330)
* ci(e2e-nightly): run Go harness suites instead of bash run-*.sh

Swap nightly cells 1-3 (single), 8-10 (real-multi) and 18 (reboot) to the
compiled <suite>.test binaries, mirroring e2e.yml: SPINIFEX_E2E=1 with
SPINIFEX_MODE single|multinode, per-suite logs to ARTIFACT_DIR, then
go-junit-report + e2e-analyze + upload-artifact. Adds a spinifex checkout for
the composite actions and pulls /run/spinifex/console-*.log so LB microVM
(lb-agent) state is captured on failure. Cells 17 (tofu) and 20 (baremetal)
stay on bash — no Go suite exists.

* ci(e2e-nightly): cell-17 RCA analytics + cell-20 cosmetic alignment

run-tofu-examples-e2e.sh now emits go test -v markers per workbook so the
cell-17 step's tee'd test-tofu.log flows through the same go-junit-report ->
e2e-analyze -> upload-artifact chain as the Go suites, producing junit-tofu.xml
and an analysis bundle for RCA. Align e2e-baremetal.yml (cell 20) ARTIFACT_DIR,
analyze title, and artifact name with the matrix cells so CI-RCA discovers its
bundle uniformly.

* ci(e2e-nightly): wire host journal + VM consoles into RCA bundles

Collect now emits the canonical spinifex-journal.log (concat of all nodes)
that the e2e-analyze bundler slices into each failure's journal.log, so
elbv2-service/vpcd/daemon lines in the failure window travel with the bundle
(previously the journal uploaded as spinifex-journal-node*.log, a name the
bundler never reads). Rename the console capture to vm-console-node* and note
it also carries guest cloud-init/sshd consoles, covering the cell-3 internal-LB,
cell-18 reboot-datapath, and cell-9/10 guest-ssh blind spots.

* ci(e2e): self-heal runner /dev + --one-file-system on workspace clean

Clean Workspace now detaches any leaked iso-builder rootfs mount, recreates
/dev/null if a prior build clobbered it, and uses rm --one-file-system so the
clean can't descend into a still-mounted devtmpfs and delete host device nodes.
Guards both the matrix cells and the bare-metal job against the host-wide
/dev/null wipe that fails go build with 'open /dev/null: no such file or
directory'.
2026-06-10 07:52:09 +10:00
Josh Siviour 6483c6f732 fix(elbv2): reap LB floating-IP dnat_and_snat on DeleteLoadBalancer (#329)
The LB VM's external-pool floating-IP NAT (dnat_and_snat external_ip -> LB
VPC IP) was reaped only conditionally on teardown: DeleteNetworkInterface
skips EIP-owned addresses (defers to the EIP lifecycle), and that lifecycle's
reap runs in the async TerminateSystemInstance goroutine gated on the EIP
association. So repeated create/delete cycles accumulated stale rules that
collapsed onto the reused LB VIP, making OVN SNAT/un-DNAT ambiguous and
dropping the lb-agent heartbeat return path.

DeleteLoadBalancer now publishes vpc.delete-nat deterministically from the LB
record (external=AvailZones public IP, logical=VPCIP). DeleteEIP matches on
external+logical and is idempotent, so redundant reaps from other paths are
harmless. Internal LBs (no floating IP) are a no-op.
2026-06-09 23:52:55 +10:00
Josh Siviour 06a583cb58 fix(network): re-prime EIP reachability on idempotent skip; drop dead inject-garp (#328)
* fix: re-prime EIP reachability on AddEIP idempotent-skip path

AddEIP early-returned on the idempotent-skip path (OVN DNAT row already
matches) before emitting GARP and priming the host neighbour. Stop->start
and daemon reboot-recovery re-attach the same EIP with an unchanged row, so
the host neigh entry / OVS datapath never got re-primed and the EIP/ALB-VIP
stayed dark until the kernel ARP entry timed out (60-300s). inject-garp is
unsupported on some chassis, making neighPrime the only working remediation
— exactly the step the skip bypassed.

Extract garp + neighPrime/flush into primeReachability and run it on the
skip path as well as after a fresh AddNAT. The FlowsBarrier still fires only
on row churn. Resolves cell-9/10 TestMultinodeGuestSSH and cell-18
TestRebootResilience nightly failures.

* refactor: remove dead inject-garp GARP emitter from EIP path

ovn-appctl inject-garp is not a valid command on the OVN build Spinifex
deploys and tests against — every AddEIP GARP attempt failed with
'inject-garp is not a valid command' and fell through to the host neigh
prime/flush, which is the only remediation that works. The GARP hook was a
guaranteed-failing shell-out plus a misleading WARN on every EIP attach.

Delete host.InjectGARP, the GARPEmitter type, WithGARPEmitter option, the
garp field + its m.garp() call in primeReachability, and the vpcd
injectGARPEmitter wiring. host.ReplaceNeigh (distributed) and host.FlushNeigh
(centralised) already cover both NAT shapes and become the sole EIP
reachability path. Reword the neigh hook docs that framed themselves as an
inject-garp-independent fallback. Prune GARP-only tests; move the shared
recordingRunner helper into neigh_test.go.
2026-06-09 23:20:07 +10:00
Josh Siviour 1654f98e18 feat(resource-governance): RG-3/4/6/8/9/11 — admission charge, OOM ladder, tier confinement (#327)
* feat(daemon): live MemAvailable admission gate + fixed-services reserve scope

Admission previously charged only guest -m against a flat reserve, blind to the
co-located storage VMs, nbdkit, and daemon stack — so it greenlit launches that
overcommit real RAM and OOM-kill guests on memory-tight hosts.

Add a second admission gate that clamps the accounting-derived launch count by
live /proc/meminfo MemAvailable (which already nets reclaimable cache and sees
every untracked consumer): admitted = min(accounting, (MemAvailable-reserve)/
guestMem). Reads live per admission, fails open on a /proc read error so a
transient fault can't wedge launches, and is disabled by SPINIFEX_ADMISSION_LIVE_MEM=0.

Re-scope the host reserve to the fixed infrastructure tier only; per-instance
nbdkit is no longer expected to fit inside it (explicit per-instance charge is a
follow-up; the live gate covers it implicitly in the meantime).

Pure helpers (liveMemCount, parseMemAvailableKB, liveMemAdmissionEnabled) are
unit-tested; liveMemGate covered via an injected reader.

* feat(daemon): charge nbdkit per-instance in admission accounting

An instance's admission memory charge is now its guest -m plus the nbdkit
processes backing its volumes (one per attached volume), not guest -m alone.
The static reserve no longer has to absorb per-instance nbdkit, which was
unsizable and the root of the host-memory-overcommit OOM.

Per-volume charge is measured per class (main 768 MiB: 128 cache + plugin
working set + in-flight I/O; aux 96 MiB: process floor, 0 cache), env-tunable
via SPINIFEX_NBDKIT_MAIN_MIB / SPINIFEX_NBDKIT_AUX_MIB. Default layout is one
main + two aux volumes (root + cloudinit + efi) ~= 0.94 GB/instance. Applied at
every accounting site through instanceMemChargeMiB so charge and release never
drift; the live MemAvailable gate backstops block-device-mapping volumes the
static count misses.

* feat(vm): three-tier guest OOM ladder + spinifex-guests.slice

Replace the flat oom_score_adj=500 on every guest QEMU with a ManagedBy-derived
ladder: user guests (ManagedBy=='') stay +500 and are reaped first under host
memory pressure; system instances (ELBv2 LB-VMs, EKS control-plane nodes,
ManagedBy!='') score 0, ranking above user guests but below the infra tier
(-500). Killing a system instance degrades the data path or cluster control
plane for many user VMs, so it must outlive them.

Add spinifex-guests.slice as a sibling of spinifex-system under spinifex.slice —
the parent tier for the per-instance leaves that land with RG-7. Guest QEMU
placement into the slice is deferred to the per-instance work.

* refactor(systemd): lean unit comments to RG-n tags + RG-9/RG-11 invariants

Strip the multi-paragraph rationale novels from spinifex-daemon.service (48->7
comment lines), spinifex-vpcd.service (29->8) and spinifex.slice (15->5),
replacing them with settings + terse # RG-n clause tags. Remove plan-doc, bead
and CI-run references from comments (project policy). Directives are byte-for-byte
unchanged — comment-only.

Add RG-9/RG-11 invariant tests asserting per-tier confinement (locked-down
storage/control tier drops all caps + NoNewPrivileges=yes; daemon keeps exactly
CAP_SYS_ADMIN+CAP_DAC_OVERRIDE and the four-entry DeviceAllow; vpcd is the
documented network-tier exception) and the lean-comment budget + no-plan-ref rule.
2026-06-09 22:56:46 +10:00
Josh Siviour f9b14e5a12 fix(ec2): make errored instances renderable and restartable (#325)
Project internal StateError onto AWS stopped (code 80) in DescribeInstances
so SDK/UI clients no longer break on the non-AWS 'error' state label.

Route start-instances through the per-instance ec2.cmd topic first so a
crash-pinned StateError instance is restarted by its live owner node, whose
StartInstance handler now accepts StateError (Manager.Start transitions
error->pending before relaunch). Fall back to the ec2.start queue-group path
on ErrNoResponders for genuinely stopped instances in the shared KV.
2026-06-09 16:59:32 +10:00
Josh Siviour 7bcd365549 ci(eks-ami): build on self-hosted ci-single runner (#326)
* ci(eks-ami): build on self-hosted ci-single-1 runner

GitHub-hosted ubuntu-24.04 cannot run the libguestfs appliance network:
passt fails (PID file denied under its AppArmor confine / privilege drop)
and the SLIRP fallback has no outbound, so virt-customize --network dies.
The self-hosted ci-single-1 runner has /dev/kvm and real networking, so
the existing libguestfs build works unmodified.

* fix(build-system-image): add --no-cache, force fresh build on publish

The <10min cached-raw skip avoids duplicate work for concurrent e2e
jobs, but on the persistent publish runner it could republish a stale
image from a prior or concurrent run. Add --no-cache to bypass the skip
and have publish-system-image.sh pass it on every build.

* ci(eks-ami): target ci-single label (no ci-single-1 label exists)

The self-hosted runners are named ci-single/-2/-3 but all share the
single label 'ci-single'; runs-on matches labels, not names, so
ci-single-1 never schedules.

* ci(eks-ami): disable Go cache on self-hosted runner

setup-go's cache-save (Post Setup Go) re-uploaded the Go cache to the
Actions backend on every run, taking ~2.5min vs the 36s build. The
self-hosted runner already keeps the cache on local disk, so cache:false.
2026-06-09 16:58:04 +10:00
Julian Sommer 773341248d feat: use STS tokens on frontend (#322) 2026-06-09 15:40:35 +10:00
Josh Siviour 2ea3e22db6 feat(systemd): cgroup-confine spinifex to a resource-limited slice + host swap (#324)
* feat(systemd): bound spinifex to a resource-limited slice

Add a spinifex.slice parent ceiling (MemoryHigh=80%, MemoryMax=90%,
MemorySwapMax=0) with a spinifex-system.slice child holding the seven
control/infra services, so a runaway hits the slice instead of the host.
A system.slice drop-in (MemoryMin=1G, CPUWeight=300) reserves RAM and CPU
for sshd/journald/the operator, keeping the box reachable when spinifex is
maxed.

The drop-in is generated in setup.sh install_systemd() rather than shipped
as a staged file, since the packaging globs flatten the systemd/ dir; this
mirrors the existing sshd-keygen drop-in pattern. The two .slice files flow
through every staging path unchanged.

Within the single effective cgroup, a slice MemoryMax OOM picks the highest
oom_score victim — guest QEMU (+500) before any service (-500) — so a slice
OOM degrades by killing one guest while the daemon, storage tier, NATS, and
the host all survive.

* feat(setup): provision 8G host swapfile + vm.swappiness=10

Add a setup_swap stage that creates an 8G /swapfile, enables it, persists it
to fstab, and writes vm.swappiness=10 so the kernel only swaps under real
pressure. Backs spinifex.slice MemorySwapMax so the slice can spill to swap
for graceful degradation instead of OOMing a guest. Idempotent; skipped under
ISO_BUILD (firstboot provisions swap on ISO hosts). Reverses the prior swap=0
assumption.
2026-06-09 15:21:13 +10:00
700 changed files with 27265 additions and 25273 deletions
+7 -12
View File
@@ -27,16 +27,16 @@ jobs:
cancel-in-progress: false
env:
BM_ISO_CACHE: /var/cache/spinifex-iso-builder
ARTIFACT_DIR: /tmp/spinifex-e2e-baremetal-artifacts-${{ github.run_id }}
ARTIFACT_DIR: /tmp/spinifex-e2e-nightly-cell-20-${{ github.run_id }}
steps:
- name: Clean Workspace
run: sudo rm -rf "$GITHUB_WORKSPACE"/*
run: |
sudo umount -R -l "${BM_ISO_CACHE}/rootfs" 2>/dev/null || true
if [ ! -c /dev/null ]; then sudo rm -f /dev/null; sudo mknod -m 666 /dev/null c 1 3; fi
sudo rm -rf --one-file-system "$GITHUB_WORKSPACE"/*
- uses: actions/checkout@v6
with:
# When called (nightly/release) the caller picks the ref (dev on
# schedule, the tag on release); standalone dispatch defaults to the
# triggering ref.
ref: ${{ inputs.ref || github.ref }}
- uses: actions/create-github-app-token@v3
@@ -104,7 +104,6 @@ jobs:
echo "Ubuntu 26.04 cloud image cached ($(du -h "$UBUNTU_IMG" | cut -f1))"
fi
- name: Install go-junit-report on runner
run: go install github.com/jstemmer/go-junit-report/v2@latest
@@ -117,10 +116,6 @@ jobs:
SPX_EXTERNAL_POOL: ${{ env.SPINIFEX_CI_POOL }}
run: |
mkdir -p "${ARTIFACT_DIR}"
# Capture combined bootstrap + e2e output so failures produce a
# downloadable, analyzable artifact (the suites run go test -v on the
# init node and stream back over SSH). pipefail keeps the script's
# real exit code through the tee.
set -o pipefail
sudo -E mulga/scripts/baremetal/run-baremetal-e2e.sh \
--vmlinuz "${BM_ISO_CACHE}/vmlinuz" \
@@ -142,12 +137,12 @@ jobs:
with:
junit-glob: ${{ env.ARTIFACT_DIR }}/junit-*.xml
log-dir: ${{ env.ARTIFACT_DIR }}
title: "Bare-Metal E2E failure analysis"
title: "E2E failure analysis (cell 20)"
- uses: actions/upload-artifact@v7
if: always()
with:
name: e2e-baremetal-artifacts-${{ github.run_id }}
name: e2e-artifacts-cell-20-${{ github.run_id }}
path: ${{ env.ARTIFACT_DIR }}
if-no-files-found: warn
retention-days: 14
-24
View File
@@ -21,10 +21,6 @@ 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
@@ -39,10 +35,6 @@ jobs:
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:
@@ -88,7 +80,6 @@ jobs:
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
@@ -140,10 +131,6 @@ jobs:
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}"
@@ -151,11 +138,6 @@ jobs:
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}" \
@@ -185,10 +167,6 @@ jobs:
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')
@@ -197,7 +175,6 @@ jobs:
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
@@ -221,7 +198,6 @@ jobs:
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
+124 -27
View File
@@ -40,6 +40,7 @@ jobs:
timeout-minutes: 35
env:
TOFU_LOG: /tmp/e2e-nightly-cell-${{ matrix.cell }}-${{ github.run_id }}.log
ARTIFACT_DIR: /tmp/spinifex-e2e-nightly-cell-${{ matrix.cell }}-${{ github.run_id }}
steps:
# blank or scheduled run = full matrix.
@@ -60,7 +61,10 @@ jobs:
- name: Clean Workspace
if: steps.gate.outputs.selected == 'true'
run: sudo rm -rf "$GITHUB_WORKSPACE"/*
run: |
sudo umount -R -l /var/cache/spinifex-iso-builder/rootfs 2>/dev/null || true
if [ ! -c /dev/null ]; then sudo rm -f /dev/null; sudo mknod -m 666 /dev/null c 1 3; fi
sudo rm -rf --one-file-system "$GITHUB_WORKSPACE"/*
- uses: actions/create-github-app-token@v3
if: steps.gate.outputs.selected == 'true'
@@ -78,6 +82,11 @@ jobs:
sparse-checkout: scripts/tofu-cluster
path: mulga
- uses: actions/checkout@v6
if: steps.gate.outputs.selected == 'true'
with:
path: spinifex
- name: Build Install Artifacts
if: steps.gate.outputs.selected == 'true'
working-directory: mulga/scripts/tofu-cluster
@@ -132,8 +141,18 @@ jobs:
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"
ssh -tt -o StrictHostKeyChecking=no -i "$SSH_KEY" tf-user@${WAN_IP} \
"set -u; mkdir -p '${ARTIFACT_DIR}'; cd \$HOME/e2e-tests/_bin; FAIL=0; \
for SUITE in single cert lb; do \
echo \"::group::suite \$SUITE\"; \
date -u +%Y-%m-%dT%H:%M:%SZ > '${ARTIFACT_DIR}/test-'\$SUITE'.start'; \
GITHUB_ACTIONS=true FORCE_COLOR=1 SPINIFEX_E2E=1 SPINIFEX_MODE=single \
ARTIFACT_DIR='${ARTIFACT_DIR}' \
./\$SUITE.test -test.v -test.timeout=30m \
2>&1 | tee '${ARTIFACT_DIR}/test-'\$SUITE'.log'; \
rc=\${PIPESTATUS[0]}; echo \"::endgroup::\"; \
if [ \$rc -ne 0 ]; then echo \"\$SUITE: FAILED\" >> '${ARTIFACT_DIR}/suite-status.txt'; FAIL=1; fi; \
done; exit \$FAIL"
- name: Run Multi Node E2E
if: steps.gate.outputs.selected == 'true' && matrix.topo == 'real-multi'
@@ -144,32 +163,57 @@ jobs:
SSH_KEY=$(echo "$INVENTORY" | jq -r '.ssh_key_path')
SSH_KEY="${SSH_KEY/#\~/$HOME}"
NODE_IPS=""
NODES_CSV=""
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")"
ip=$(echo "$INVENTORY" | jq -r ".nodes[$i].wan_ip")
NODES_CSV="${NODES_CSV:+$NODES_CSV,}$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}"
ssh -tt -o StrictHostKeyChecking=no -i "$SSH_KEY" tf-user@${NODE1_IP} \
"set -u; mkdir -p '${ARTIFACT_DIR}'; cd \$HOME/e2e-tests/_bin; FAIL=0; \
for SUITE in multinode cert lb; do \
echo \"::group::suite \$SUITE\"; \
date -u +%Y-%m-%dT%H:%M:%SZ > '${ARTIFACT_DIR}/test-'\$SUITE'.start'; \
GITHUB_ACTIONS=true FORCE_COLOR=1 SPINIFEX_E2E=1 SPINIFEX_MODE=multinode \
SPINIFEX_NODES='${NODES_CSV}' SPINIFEX_NODE_IPS='${NODES_CSV}' \
SPINIFEX_SSH_USER=tf-user SPINIFEX_SSH_KEY=\$HOME/.ssh/tf-user-ap-southeast-2 \
ARTIFACT_DIR='${ARTIFACT_DIR}' \
./\$SUITE.test -test.v -test.timeout=30m \
2>&1 | tee '${ARTIFACT_DIR}/test-'\$SUITE'.log'; \
rc=\${PIPESTATUS[0]}; echo \"::endgroup::\"; \
if [ \$rc -ne 0 ]; then echo \"\$SUITE: FAILED\" >> '${ARTIFACT_DIR}/suite-status.txt'; FAIL=1; fi; \
done; exit \$FAIL"
- name: Run Tofu Workbooks
if: steps.gate.outputs.selected == 'true' && matrix.extra == 'tofu-examples'
working-directory: mulga/scripts/tofu-cluster
# The driver emits go test -v markers per workbook; teeing to
# test-tofu.log feeds the same go-junit-report -> e2e-analyze -> upload
# chain the Go suites use, so cell 17 produces an RCA bundle too.
run: |
set +e
mkdir -p "${ARTIFACT_DIR}"
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}"
date -u +%Y-%m-%dT%H:%M:%SZ > "${ARTIFACT_DIR}/test-tofu.start"
ssh -o StrictHostKeyChecking=no -i "$SSH_KEY" tf-user@${WAN_IP} \
"cd ~/e2e-tests && ./run-tofu-examples-e2e.sh"
"cd ~/e2e-tests && ./run-tofu-examples-e2e.sh" \
2>&1 | tee "${ARTIFACT_DIR}/test-tofu.log"
rc=${PIPESTATUS[0]}
if [ $rc -ne 0 ]; then echo "tofu: FAILED" >> "${ARTIFACT_DIR}/suite-status.txt"; fi
exit $rc
- 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
# reboot.test runs from the runner (NOT via ssh-and-run) so it survives the
# systemctl-reboot-induced ssh disconnect mid-run.
run: |
set +e
mkdir -p "${ARTIFACT_DIR}"
INVENTORY=$(tofu output -json inventory)
WAN_IP=$(echo "$INVENTORY" | jq -r '.nodes[0].wan_ip')
SSH_KEY=$(echo "$INVENTORY" | jq -r '.ssh_key_path')
@@ -178,31 +222,85 @@ jobs:
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"
chmod +x "$REBOOT_DIR/_bin/reboot.test"
WAN_IP="$WAN_IP" SSH_KEY="$SSH_KEY" "$REBOOT_DIR/run-reboot-e2e.sh"
SSH_OPTS=(-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i "$SSH_KEY")
CREDS=$(ssh "${SSH_OPTS[@]}" tf-user@${WAN_IP} 'cat ~/.aws/credentials')
AWS_AK=$(echo "$CREDS" | awk -F '= *' '/^aws_access_key_id/ {print $2; exit}')
AWS_SK=$(echo "$CREDS" | awk -F '= *' '/^aws_secret_access_key/ {print $2; exit}')
echo "::add-mask::${AWS_AK}"; echo "::add-mask::${AWS_SK}"
- name: Dump Cluster Journals on Failure
if: failure() && steps.gate.outputs.selected == 'true'
date -u +%Y-%m-%dT%H:%M:%SZ > "${ARTIFACT_DIR}/test-reboot.start"
GITHUB_ACTIONS=true FORCE_COLOR=1 SPINIFEX_E2E=1 SPINIFEX_MODE=single \
SPINIFEX_WAN_IP="${WAN_IP}" SPINIFEX_SSH_KEY="${SSH_KEY}" \
SPINIFEX_AWS_ENDPOINT="https://${WAN_IP}:9999" \
SPINIFEX_AWS_ACCESS_KEY_ID="${AWS_AK}" SPINIFEX_AWS_SECRET_ACCESS_KEY="${AWS_SK}" \
ARTIFACT_DIR="${ARTIFACT_DIR}" \
"$REBOOT_DIR/_bin/reboot.test" -test.v -test.timeout=30m \
2>&1 | tee "${ARTIFACT_DIR}/test-reboot.log"
rc=${PIPESTATUS[0]}
if [ $rc -ne 0 ]; then echo "reboot: FAILED" >> "${ARTIFACT_DIR}/suite-status.txt"; fi
exit $rc
- name: Collect e2e artifacts
if: always() && steps.gate.outputs.selected == 'true'
working-directory: mulga/scripts/tofu-cluster
run: |
set +e
mkdir -p "${ARTIFACT_DIR}"
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}"
SSH_KEY=$(echo "$INVENTORY" | jq -r '.ssh_key_path'); SSH_KEY="${SSH_KEY/#\~/$HOME}"
NODE_COUNT=$(echo "$INVENTORY" | jq '.nodes | length')
NODE1_IP=$(echo "$INVENTORY" | jq -r '.nodes[0].wan_ip')
SSH_OPTS=(-o StrictHostKeyChecking=no -o ConnectTimeout=5 -i "$SSH_KEY")
ssh "${SSH_OPTS[@]}" tf-user@${NODE1_IP} "tar -C '${ARTIFACT_DIR}' -cf - . 2>/dev/null || true" \
| tar -C "${ARTIFACT_DIR}" -xf - 2>/dev/null
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)"
IP=$(echo "$INVENTORY" | jq -r ".nodes[$i].wan_ip"); idx=$((i + 1))
mkdir -p "${ARTIFACT_DIR}/vm-console-node${idx}"
ssh "${SSH_OPTS[@]}" tf-user@${IP} 'sudo sh -c "cd /run/spinifex 2>/dev/null && tar -cf - console-*.log 2>/dev/null"' \
| tar -C "${ARTIFACT_DIR}/vm-console-node${idx}" -xf - 2>/dev/null
ssh "${SSH_OPTS[@]}" tf-user@${IP} 'sudo journalctl -u "spinifex-*" --no-pager --output=short-iso --since "60 min ago"' \
> "${ARTIFACT_DIR}/spinifex-journal-node${idx}.log" 2>/dev/null
done
cat "${ARTIFACT_DIR}"/spinifex-journal-node*.log \
> "${ARTIFACT_DIR}/spinifex-journal.log" 2>/dev/null || true
echo "collected: $(ls "${ARTIFACT_DIR}" 2>/dev/null | wc -l) entries"
- name: Install go-junit-report
if: always() && steps.gate.outputs.selected == 'true'
run: |
go install github.com/jstemmer/go-junit-report/v2@latest
echo "$HOME/go/bin" >> "$GITHUB_PATH"
- name: Convert test logs to JUnit XML
if: always() && steps.gate.outputs.selected == 'true'
run: |
shopt -s nullglob
for LOG in "${ARTIFACT_DIR}"/test-*.log; do
BASE=$(basename "$LOG" .log); SUITE=${BASE#test-}
go-junit-report -in "$LOG" -out "${ARTIFACT_DIR}/junit-${SUITE}.xml" || true
done
- name: Analyze e2e failures
if: always() && steps.gate.outputs.selected == 'true'
uses: ./spinifex/.github/actions/e2e-analyze
with:
junit-glob: ${{ env.ARTIFACT_DIR }}/junit-*.xml
log-dir: ${{ env.ARTIFACT_DIR }}
title: "E2E failure analysis (cell ${{ matrix.cell }})"
- uses: actions/upload-artifact@v7
if: always() && steps.gate.outputs.selected == 'true'
with:
name: e2e-artifacts-cell-${{ matrix.cell }}-${{ github.run_id }}
path: ${{ env.ARTIFACT_DIR }}
if-no-files-found: warn
retention-days: 14
- name: Teardown
if: always()
working-directory: mulga/scripts/tofu-cluster
@@ -217,8 +315,7 @@ jobs:
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
# Bare-metal single-node suite (PXE-reimaged physical hardware). Doesn't fit
# the tofu matrix shape (different runner/provisioning/120m timeout), so it
# Bare-metal single-node suite. Doesn't fit the tofu matrix shape, so it
# runs as a sibling job via the reusable e2e-baremetal workflow. Gated like a
# matrix cell ("cell 20"): every weeknight cron, or dispatch with 20 selected
# (blank cells = all). Tests `dev` on schedule to match GIT_BRANCH above.
+799
View File
@@ -0,0 +1,799 @@
# E2E (per-suite blocks) — EXPERIMENTAL, workflow_dispatch only.
#
# Restructures the looped e2e.yml into env-grouped job blocks so each suite
# surfaces as its own node in the Actions graph (mulga-siv-283):
#
# single_provision ─┬─ single_suite (matrix: single, cert, lb, eks)
# └─ single_teardown (if: always)
# multi_provision ─┬─ multi_suite (matrix: multinode)
# └─ multi_teardown (if: always)
#
# Constraint: tofu state is runner-filesystem-local, so *_provision and
# *_teardown must execute on the same self-hosted runner (one runner per label
# today). Teardown reuses the persisted mulga/ tofu-cluster workspace and does
# NOT re-checkout mulga (that would wipe local state). Suite-matrix legs reach
# the already-provisioned VM over its WAN IP using the key path the provision
# job emits; they share the runner home dir, so the key file persists.
#
# This file does NOT post the gating `e2e` commit status — it runs alongside
# e2e.yml for validation. Once green across a few manual runs, fold the
# triggers from e2e.yml here and retire the loop.
name: E2E (suite blocks)
on:
workflow_dispatch:
inputs:
single_suites:
description: "Single-node suites to run as matrix legs"
required: false
default: "single cert lb eks"
run_multinode:
description: "Also run the multinode env block"
type: boolean
required: false
default: true
test_run:
description: "Optional -test.run regex (empty = all tests in suite)"
required: false
default: ""
permissions:
contents: read
env:
GIT_BRANCH: ${{ github.ref_name }}
TEST_RUN: ${{ inputs.test_run || '' }}
jobs:
# ---------------------------------------------------------------------------
# Single-node env
# ---------------------------------------------------------------------------
single_provision:
name: Single env · provision
runs-on: [ self-hosted, ci-single ]
timeout-minutes: 40
outputs:
wan_ip: ${{ steps.vm.outputs.wan_ip }}
ssh_key: ${{ steps.vm.outputs.ssh_key }}
suites: ${{ steps.suites.outputs.list }}
matrix: ${{ steps.suites.outputs.matrix }}
env:
TOFU_LOG: /tmp/e2e-tofu-single-${{ github.run_id }}.log
steps:
- name: Reap Orphan Procs
run: |
set +e
UID_SELF=$(id -u)
WS="${GITHUB_WORKSPACE}"
reap() {
local sig="$1"
for pid in $(pgrep -u "$UID_SELF" -x tofu) $(pgrep -u "$UID_SELF" -x terraform); do
cwd=$(readlink "/proc/$pid/cwd" 2>/dev/null) || continue
case "$cwd" in
"$WS"|"$WS"/*) echo "reap $sig pid=$pid cwd=$cwd"; kill "-$sig" "$pid" 2>/dev/null ;;
esac
done
}
reap TERM; sleep 3; reap KILL
find "$WS" -name '.terraform.tfstate.lock.info' -delete 2>/dev/null
true
- name: Clean Workspace
run: |
awk -v w="$GITHUB_WORKSPACE" '$2 ~ "^"w {print $2}' /proc/mounts \
| sort -r | xargs -r -I{} sudo umount -l -R {} || true
sudo rm -rf --one-file-system "$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
- uses: actions/checkout@v6
with:
repository: mulgadc/mulga
token: ${{ steps.app-token.outputs.token }}
sparse-checkout: scripts/tofu-cluster
path: mulga
- uses: actions/checkout@v6
with:
path: spinifex
- uses: actions/setup-go@v6
with:
go-version-file: spinifex/go.mod
cache: false
- name: Re-enable Go toolchain auto-download
run: echo "GOTOOLCHAIN=auto" >> "$GITHUB_ENV"
- name: Compute suite matrix
id: suites
run: |
LIST="${{ inputs.single_suites || 'single cert lb eks' }}"
echo "list=${LIST}" >> "$GITHUB_OUTPUT"
JSON=$(printf '%s\n' $LIST | jq -R . | jq -cs .)
echo "matrix=${JSON}" >> "$GITHUB_OUTPUT"
- name: Reclaim docker build cache
run: |
docker container prune -f || true
docker image prune -f || true
docker buildx prune -af --filter "until=24h" || true
docker system df || true
- 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 VM
working-directory: mulga/scripts/tofu-cluster
run: |
set -euo pipefail
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 "VM 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: Resolve VM SSH details
id: vm
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}"
echo "wan_ip=${WAN_IP}" >> "$GITHUB_OUTPUT"
echo "ssh_key=${SSH_KEY}" >> "$GITHUB_OUTPUT"
- name: Build eks-node AMI (libguestfs, runner)
if: contains(steps.suites.outputs.list, 'eks')
working-directory: spinifex
run: |
set -euo pipefail
source "${GITHUB_WORKSPACE}/spinifex/.github/scripts/ci-fmt.sh"
banner "build eks-node AMI (libguestfs — no host devices)"
sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends libguestfs-tools qemu-utils
export LIBGUESTFS_BACKEND=direct
sudo chmod 0644 /boot/vmlinuz-* || true
group_open "make build-eks-node-image"
make build-eks-node-image
group_close
ok "raw: $(du -h /tmp/eks-node-image-build/eks-node-alpine.raw | cut -f1)"
- name: Import eks-node AMI on VM
if: contains(steps.suites.outputs.list, 'eks')
run: |
set -euo pipefail
source "${GITHUB_WORKSPACE}/spinifex/.github/scripts/ci-fmt.sh"
eval "$(grep -E '^(ALPINE_VERSION|AMI_NAME|SYSTEM_TAG)=' \
spinifex/scripts/images/eks-node/manifest.conf)"
SSH_OPTS=(-o StrictHostKeyChecking=no -i "${{ steps.vm.outputs.ssh_key }}")
VM=tf-user@${{ steps.vm.outputs.wan_ip }}
banner "import eks-node AMI to VM image store"
scp "${SSH_OPTS[@]}" \
/tmp/eks-node-image-build/eks-node-alpine.raw \
"$VM":/tmp/eks-node-alpine.raw
ssh "${SSH_OPTS[@]}" "$VM" \
"spx admin images import --file /tmp/eks-node-alpine.raw \
--distro alpine --version ${ALPINE_VERSION} --arch x86_64 \
--boot-mode bios --ami-name ${AMI_NAME} --tag ${SYSTEM_TAG}" >/dev/null
ok "eks-node AMI imported"
- name: Install kubectl on VM
if: contains(steps.suites.outputs.list, 'eks')
run: |
set -euo pipefail
source "${GITHUB_WORKSPACE}/spinifex/.github/scripts/ci-fmt.sh"
SSH_OPTS=(-o StrictHostKeyChecking=no -i "${{ steps.vm.outputs.ssh_key }}")
VM=tf-user@${{ steps.vm.outputs.wan_ip }}
banner "install kubectl on VM (eks data-plane subtest)"
ssh "${SSH_OPTS[@]}" "$VM" 'bash -s' <<'EOF'
set -euo pipefail
KVER=v1.32.5
case "$(uname -m)" in
x86_64) ARCH=amd64 ;;
aarch64) ARCH=arm64 ;;
*) echo "unsupported arch $(uname -m)" >&2; exit 1 ;;
esac
cd /tmp
curl -fsSLO "https://dl.k8s.io/release/${KVER}/bin/linux/${ARCH}/kubectl"
curl -fsSLO "https://dl.k8s.io/release/${KVER}/bin/linux/${ARCH}/kubectl.sha256"
echo "$(cat kubectl.sha256) kubectl" | sha256sum -c -
sudo install -m 0755 kubectl /usr/local/bin/kubectl
kubectl version --client
EOF
ok "kubectl installed on VM"
- name: Build E2E test binaries on runner
working-directory: spinifex/tests/e2e
run: |
set -euo pipefail
source "${GITHUB_WORKSPACE}/spinifex/.github/scripts/ci-fmt.sh"
# teardown always builds: the run-scoped sweep binary runs in the
# teardown job, it is never a selectable suite leg.
SUITES_BUILD="${{ steps.suites.outputs.list }} teardown"
banner "build ${SUITES_BUILD// /, }"
group_open "go test -c"
make e2e-build SUITES_BUILD="${SUITES_BUILD}"
group_close
for f in _bin/*.test; do
ok "$(basename "$f") $(stat -c '%s' "$f" | numfmt --to=iec)"
done
- name: Ship test binaries to VM
run: |
set -euo pipefail
source "${GITHUB_WORKSPACE}/spinifex/.github/scripts/ci-fmt.sh"
SSH_OPTS=(-o StrictHostKeyChecking=no -i "${{ steps.vm.outputs.ssh_key }}")
VM=tf-user@${{ steps.vm.outputs.wan_ip }}
ssh "${SSH_OPTS[@]}" "$VM" "rm -rf \$HOME/spinifex-e2e-bin && mkdir -p \$HOME/spinifex-e2e-bin"
tar -C spinifex/tests/e2e/_bin -cf - . | ssh "${SSH_OPTS[@]}" "$VM" "tar -xf - -C \$HOME/spinifex-e2e-bin"
ok "shipped binaries to $VM"
- name: Start packet capture on VM br-wan
run: |
set +e
SSH_OPTS=(-o StrictHostKeyChecking=no -i "${{ steps.vm.outputs.ssh_key }}")
VM=tf-user@${{ steps.vm.outputs.wan_ip }}
scp "${SSH_OPTS[@]}" \
"${GITHUB_WORKSPACE}/spinifex/.github/scripts/start-net-capture.sh" \
"$VM":/tmp/start-net-capture.sh
ssh "${SSH_OPTS[@]}" "$VM" "bash /tmp/start-net-capture.sh" || true
single_suite:
name: Single env · suite
needs: single_provision
runs-on: [ self-hosted, ci-single ]
timeout-minutes: 45
strategy:
fail-fast: false
matrix:
suite: ${{ fromJSON(needs.single_provision.outputs.matrix) }}
env:
WAN_IP: ${{ needs.single_provision.outputs.wan_ip }}
SSH_KEY: ${{ needs.single_provision.outputs.ssh_key }}
ARTIFACT_DIR: /tmp/spinifex-e2e-single-${{ github.run_id }}-${{ matrix.suite }}
steps:
- uses: actions/checkout@v6
with:
path: spinifex
- uses: actions/setup-go@v6
with:
go-version-file: spinifex/go.mod
cache: false
- name: Install go-junit-report
run: |
go install github.com/jstemmer/go-junit-report/v2@latest
echo "$HOME/go/bin" >> "$GITHUB_PATH"
- name: Run ${{ matrix.suite }}
id: e2e
run: |
set +e
SUITE="${{ matrix.suite }}"
VM_HOST="tf-user@${WAN_IP}"
TEST_RUN_FLAG=""
[ -n "${TEST_RUN}" ] && TEST_RUN_FLAG="-test.run=${TEST_RUN}"
cleanup_remote() {
ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5 -i "$SSH_KEY" "$VM_HOST" \
"pkill -u tf-user -TERM -f \"/spinifex-e2e-bin/${SUITE}\.test( |\$)\" 2>/dev/null; sleep 1; pkill -u tf-user -KILL -f \"/spinifex-e2e-bin/${SUITE}\.test( |\$)\" 2>/dev/null; true" \
2>/dev/null || true
}
trap cleanup_remote EXIT INT TERM
ssh -tt -o StrictHostKeyChecking=no -i "$SSH_KEY" "$VM_HOST" \
"set -u; mkdir -p '${ARTIFACT_DIR}'; cd \$HOME/spinifex-e2e-bin; \
R=\$'\033[0m' B=\$'\033[1m' C=\$'\033[36m' G=\$'\033[32m' RED=\$'\033[31m'; \
printf '\n%s%s━━ suite %s ━━%s\n' \"\$B\" \"\$C\" \"${SUITE}\" \"\$R\"; \
date -u +%Y-%m-%dT%H:%M:%SZ > '${ARTIFACT_DIR}/test-${SUITE}.start'; \
GITHUB_ACTIONS=true FORCE_COLOR=1 SPINIFEX_E2E=1 SPINIFEX_MODE=single \
SPINIFEX_E2E_RUN_ID='${{ github.run_id }}' \
ARTIFACT_DIR='${ARTIFACT_DIR}' \
./${SUITE}.test -test.v -test.timeout=30m ${TEST_RUN_FLAG} \
2>&1 | tee '${ARTIFACT_DIR}/test-${SUITE}.log' \
| sed -uE 's/^[[:space:]]+([a-zA-Z0-9_]+_test\.go:[0-9]+: )?//'; \
exit \${PIPESTATUS[0]}"
echo "rc=$?" >> "$GITHUB_OUTPUT"
exit 0
- name: Collect suite artifacts from VM
if: always()
run: |
set +e
mkdir -p "${ARTIFACT_DIR}"
SSH_OPTS=(-o StrictHostKeyChecking=no -i "${SSH_KEY}")
VM=tf-user@${WAN_IP}
ssh "${SSH_OPTS[@]}" "$VM" "tar -C '${ARTIFACT_DIR}' -cf - . 2>/dev/null || true" \
| tar -C "${ARTIFACT_DIR}" -xf - 2>/dev/null
true
- name: Convert test log to JUnit XML
if: always()
run: |
shopt -s nullglob
for LOG in "${ARTIFACT_DIR}"/test-*.log; do
BASE=$(basename "$LOG" .log); SUITE=${BASE#test-}
go-junit-report -in "$LOG" -out "${ARTIFACT_DIR}/junit-${SUITE}.xml" || true
done
- name: Publish test summary
if: always()
uses: ./spinifex/.github/actions/junit-report
with:
junit-glob: ${{ env.ARTIFACT_DIR }}/junit-*.xml
title: "E2E ${{ matrix.suite }} results"
- name: Analyze e2e failures
if: always()
uses: ./spinifex/.github/actions/e2e-analyze
with:
junit-glob: ${{ env.ARTIFACT_DIR }}/junit-*.xml
log-dir: ${{ env.ARTIFACT_DIR }}
title: "E2E ${{ matrix.suite }} failure analysis"
- uses: actions/upload-artifact@v7
if: always()
with:
name: e2e-single-${{ matrix.suite }}-${{ github.run_id }}
path: ${{ env.ARTIFACT_DIR }}
if-no-files-found: warn
retention-days: 14
- name: Fail job if suite failed
if: always()
run: |
if [ "${{ steps.e2e.outputs.rc }}" != "0" ]; then
echo "::error::suite ${{ matrix.suite }} failed (rc=${{ steps.e2e.outputs.rc }})"
exit 1
fi
single_teardown:
name: Single env · teardown
needs: [ single_provision, single_suite ]
if: always()
runs-on: [ self-hosted, ci-single ]
timeout-minutes: 20
env:
TOFU_LOG: /tmp/e2e-tofu-single-td-${{ github.run_id }}.log
ARTIFACT_DIR: /tmp/spinifex-e2e-single-diag-${{ github.run_id }}
WAN_IP: ${{ needs.single_provision.outputs.wan_ip }}
SSH_KEY: ${{ needs.single_provision.outputs.ssh_key }}
steps:
# Reuse the persisted mulga/ tofu-cluster workspace from single_provision.
# Re-checkout only spinifex (for ci-fmt + diag scripts); checking out mulga
# again would wipe the local tofu state and orphan the VM.
- uses: actions/checkout@v6
with:
path: spinifex
- name: Capture network/IPsec diagnostics on VM
if: ${{ needs.single_provision.result == 'success' && env.WAN_IP != '' }}
run: |
set +e
source "${GITHUB_WORKSPACE}/spinifex/.github/scripts/ci-fmt.sh"
mkdir -p "${ARTIFACT_DIR}"
SSH_OPTS=(-o StrictHostKeyChecking=no -o ConnectTimeout=5 -i "${SSH_KEY}")
VM=tf-user@${WAN_IP}
scp "${SSH_OPTS[@]}" \
"${GITHUB_WORKSPACE}/spinifex/.github/scripts/capture-net-diag.sh" \
"$VM":/tmp/capture-net-diag.sh 2>/dev/null
ssh "${SSH_OPTS[@]}" "$VM" "bash /tmp/capture-net-diag.sh '${ARTIFACT_DIR}/net-diag'" 2>/dev/null
ssh "${SSH_OPTS[@]}" "$VM" 'sudo journalctl -u "spinifex-*" --no-pager --output=short-iso --since "90 min ago"' \
> "${ARTIFACT_DIR}/spinifex-journal.log" 2>/dev/null
mkdir -p "${ARTIFACT_DIR}/lb-console"
ssh "${SSH_OPTS[@]}" "$VM" 'sudo sh -c "cd /run/spinifex 2>/dev/null && tar -cf - console-*.log 2>/dev/null"' \
| tar -C "${ARTIFACT_DIR}/lb-console" -xf - 2>/dev/null
true
- uses: actions/upload-artifact@v7
if: always()
with:
name: e2e-single-diag-${{ github.run_id }}
path: ${{ env.ARTIFACT_DIR }}
if-no-files-found: ignore
retention-days: 14
# Reclaim any resource leaked by a crashed/interrupted suite before the VM
# is destroyed. Scoped to this run's tag, best-effort, never fails the job.
- name: Sweep leaked run resources on VM
if: ${{ needs.single_provision.result == 'success' && env.WAN_IP != '' }}
run: |
set +e
source "${GITHUB_WORKSPACE}/spinifex/.github/scripts/ci-fmt.sh"
SSH_OPTS=(-o StrictHostKeyChecking=no -o ConnectTimeout=5 -i "${SSH_KEY}")
VM=tf-user@${WAN_IP}
banner "sweep run-tagged resources (run ${{ github.run_id }})"
ssh "${SSH_OPTS[@]}" "$VM" \
"cd \$HOME/spinifex-e2e-bin 2>/dev/null && \
SPINIFEX_E2E=1 SPINIFEX_MODE=single \
SPINIFEX_E2E_RUN_ID='${{ github.run_id }}' \
./teardown.test -test.v -test.run TestSweepRunResources -test.timeout=10m" \
2>&1 || true
true
- name: Teardown VM
if: always()
working-directory: mulga/scripts/tofu-cluster
run: |
set +e
source "${GITHUB_WORKSPACE}/spinifex/.github/scripts/ci-fmt.sh"
if [ -n "${WAN_IP}" ] && [ -n "${SSH_KEY}" ]; then
ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5 -i "${SSH_KEY}" \
tf-user@${WAN_IP} \
'pkill -u tf-user -TERM -f "/spinifex-e2e-bin/.*\.test( |$)" 2>/dev/null; sleep 1; pkill -u tf-user -KILL -f "/spinifex-e2e-bin/.*\.test( |$)" 2>/dev/null; true' \
2>/dev/null || true
fi
UID_SELF=$(id -u); WS="${GITHUB_WORKSPACE}"
reap() {
local sig="$1"
for pid in $(pgrep -u "$UID_SELF" -x tofu) $(pgrep -u "$UID_SELF" -x terraform); do
cwd=$(readlink "/proc/$pid/cwd" 2>/dev/null) || continue
case "$cwd" in "$WS"|"$WS"/*) kill "-$sig" "$pid" 2>/dev/null ;; esac
done
}
reap TERM; sleep 2; reap KILL
find "$WS" -name '.terraform.tfstate.lock.info' -delete 2>/dev/null
quiet_run "tofu workspace select" "$TOFU_LOG" tofu workspace select -or-create "${SPINIFEX_CI_ENV}"
quiet_run "tofu destroy" "$TOFU_LOG" timeout 600 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}"
ok "teardown complete"
# ---------------------------------------------------------------------------
# Multinode env
# ---------------------------------------------------------------------------
multi_provision:
name: Multi env · provision
if: ${{ inputs.run_multinode }}
runs-on: [ self-hosted, ci-multi ]
timeout-minutes: 45
outputs:
node1_ip: ${{ steps.cluster.outputs.node1_ip }}
ssh_key: ${{ steps.cluster.outputs.ssh_key }}
nodes_csv: ${{ steps.cluster.outputs.nodes_csv }}
env:
TOFU_LOG: /tmp/e2e-tofu-multi-${{ github.run_id }}.log
steps:
- name: Reap Orphan Procs
run: |
set +e
UID_SELF=$(id -u); WS="${GITHUB_WORKSPACE}"
reap() {
local sig="$1"
for pid in $(pgrep -u "$UID_SELF" -x tofu) $(pgrep -u "$UID_SELF" -x terraform); do
cwd=$(readlink "/proc/$pid/cwd" 2>/dev/null) || continue
case "$cwd" in "$WS"|"$WS"/*) echo "reap $sig pid=$pid"; kill "-$sig" "$pid" 2>/dev/null ;; esac
done
}
reap TERM; sleep 3; reap KILL
find "$WS" -name '.terraform.tfstate.lock.info' -delete 2>/dev/null
true
- name: Clean Workspace
run: |
awk -v w="$GITHUB_WORKSPACE" '$2 ~ "^"w {print $2}' /proc/mounts \
| sort -r | xargs -r -I{} sudo umount -l -R {} || true
sudo rm -rf --one-file-system "$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
- uses: actions/checkout@v6
with:
repository: mulgadc/mulga
token: ${{ steps.app-token.outputs.token }}
sparse-checkout: scripts/tofu-cluster
path: mulga
- uses: actions/checkout@v6
with:
path: spinifex
- uses: actions/setup-go@v6
with:
go-version-file: spinifex/go.mod
cache: false
- name: Re-enable Go toolchain auto-download
run: echo "GOTOOLCHAIN=auto" >> "$GITHUB_ENV"
- name: Reclaim docker build cache
run: |
docker container prune -f || true
docker image prune -f || true
docker buildx prune -af --filter "until=24h" || true
docker system df || true
- 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
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 "3-node cluster 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: Resolve cluster SSH details
id: cluster
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_COUNT=$(echo "$INVENTORY" | jq '.nodes | length')
NODES_CSV=""
for i in $(seq 0 $((NODE_COUNT - 1))); do
ip=$(echo "$INVENTORY" | jq -r ".nodes[$i].wan_ip")
if [ -z "$NODES_CSV" ]; then NODES_CSV="$ip"; else NODES_CSV="$NODES_CSV,$ip"; fi
done
echo "node1_ip=${NODE1_IP}" >> "$GITHUB_OUTPUT"
echo "ssh_key=${SSH_KEY}" >> "$GITHUB_OUTPUT"
echo "nodes_csv=${NODES_CSV}" >> "$GITHUB_OUTPUT"
- name: Build multinode binary on runner
working-directory: spinifex/tests/e2e
run: |
set -euo pipefail
source "${GITHUB_WORKSPACE}/spinifex/.github/scripts/ci-fmt.sh"
banner "build multinode, teardown"
group_open "go test -c"
make e2e-build SUITES_BUILD="multinode teardown"
group_close
ok "$(basename _bin/multinode.test) $(stat -c '%s' _bin/multinode.test | numfmt --to=iec)"
- name: Ship test binaries to node1
run: |
set -euo pipefail
source "${GITHUB_WORKSPACE}/spinifex/.github/scripts/ci-fmt.sh"
SSH_OPTS=(-o StrictHostKeyChecking=no -i "${{ steps.cluster.outputs.ssh_key }}")
VM=tf-user@${{ steps.cluster.outputs.node1_ip }}
ssh "${SSH_OPTS[@]}" "$VM" "rm -rf \$HOME/spinifex-e2e-bin && mkdir -p \$HOME/spinifex-e2e-bin"
tar -C spinifex/tests/e2e/_bin -cf - . | ssh "${SSH_OPTS[@]}" "$VM" "tar -xf - -C \$HOME/spinifex-e2e-bin"
ok "shipped binaries to $VM"
- name: Start packet capture on all nodes
run: |
set +e
SSH_OPTS=(-o StrictHostKeyChecking=no -i "${{ steps.cluster.outputs.ssh_key }}")
IFS=',' read -ra NODE_LIST <<< "${{ steps.cluster.outputs.nodes_csv }}"
for ip in "${NODE_LIST[@]}"; do
[ -z "$ip" ] && continue
scp "${SSH_OPTS[@]}" \
"${GITHUB_WORKSPACE}/spinifex/.github/scripts/start-net-capture.sh" \
tf-user@"$ip":/tmp/start-net-capture.sh
ssh "${SSH_OPTS[@]}" tf-user@"$ip" "bash /tmp/start-net-capture.sh" || true
done
multi_suite:
name: Multi env · suite
needs: multi_provision
runs-on: [ self-hosted, ci-multi ]
timeout-minutes: 60
strategy:
fail-fast: false
matrix:
suite: [ multinode ]
env:
NODE1_IP: ${{ needs.multi_provision.outputs.node1_ip }}
SSH_KEY: ${{ needs.multi_provision.outputs.ssh_key }}
NODES_CSV: ${{ needs.multi_provision.outputs.nodes_csv }}
ARTIFACT_DIR: /tmp/spinifex-e2e-multi-${{ github.run_id }}-${{ matrix.suite }}
steps:
- uses: actions/checkout@v6
with:
path: spinifex
- uses: actions/setup-go@v6
with:
go-version-file: spinifex/go.mod
cache: false
- name: Install go-junit-report
run: |
go install github.com/jstemmer/go-junit-report/v2@latest
echo "$HOME/go/bin" >> "$GITHUB_PATH"
- name: Run ${{ matrix.suite }}
id: e2e
run: |
set +e
SUITE="${{ matrix.suite }}"
VM_HOST="tf-user@${NODE1_IP}"
TEST_RUN_FLAG=""
[ -n "${TEST_RUN}" ] && TEST_RUN_FLAG="-test.run=${TEST_RUN}"
cleanup_remote() {
ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5 -i "$SSH_KEY" "$VM_HOST" \
"pkill -u tf-user -TERM -f \"/spinifex-e2e-bin/${SUITE}\.test( |\$)\" 2>/dev/null; sleep 1; pkill -u tf-user -KILL -f \"/spinifex-e2e-bin/${SUITE}\.test( |\$)\" 2>/dev/null; true" \
2>/dev/null || true
}
trap cleanup_remote EXIT INT TERM
ssh -tt -o StrictHostKeyChecking=no -i "$SSH_KEY" "$VM_HOST" \
"set -u; mkdir -p '${ARTIFACT_DIR}'; cd \$HOME/spinifex-e2e-bin; \
printf '\n━━ suite %s ━━\n' \"${SUITE}\"; \
date -u +%Y-%m-%dT%H:%M:%SZ > '${ARTIFACT_DIR}/test-${SUITE}.start'; \
GITHUB_ACTIONS=true FORCE_COLOR=1 SPINIFEX_E2E=1 SPINIFEX_MODE=multinode \
SPINIFEX_E2E_RUN_ID='${{ github.run_id }}' \
SPINIFEX_NODES='${NODES_CSV}' SPINIFEX_NODE_IPS='${NODES_CSV}' \
SPINIFEX_SSH_USER=tf-user SPINIFEX_SSH_KEY=\$HOME/.ssh/tf-user-ap-southeast-2 \
ARTIFACT_DIR='${ARTIFACT_DIR}' \
./${SUITE}.test -test.v -test.timeout=30m ${TEST_RUN_FLAG} \
2>&1 | tee '${ARTIFACT_DIR}/test-${SUITE}.log' \
| sed -uE 's/^[[:space:]]+([a-zA-Z0-9_]+_test\.go:[0-9]+: )?//'; \
exit \${PIPESTATUS[0]}"
echo "rc=$?" >> "$GITHUB_OUTPUT"
exit 0
- name: Collect suite artifacts from node1
if: always()
run: |
set +e
mkdir -p "${ARTIFACT_DIR}"
SSH_OPTS=(-o StrictHostKeyChecking=no -i "${SSH_KEY}")
ssh "${SSH_OPTS[@]}" tf-user@${NODE1_IP} "tar -C '${ARTIFACT_DIR}' -cf - . 2>/dev/null || true" \
| tar -C "${ARTIFACT_DIR}" -xf - 2>/dev/null
true
- name: Convert test log to JUnit XML
if: always()
run: |
shopt -s nullglob
for LOG in "${ARTIFACT_DIR}"/test-*.log; do
BASE=$(basename "$LOG" .log); SUITE=${BASE#test-}
go-junit-report -in "$LOG" -out "${ARTIFACT_DIR}/junit-${SUITE}.xml" || true
done
- name: Publish test summary
if: always()
uses: ./spinifex/.github/actions/junit-report
with:
junit-glob: ${{ env.ARTIFACT_DIR }}/junit-*.xml
title: "Multi-Node E2E ${{ matrix.suite }} results"
- name: Analyze e2e failures
if: always()
uses: ./spinifex/.github/actions/e2e-analyze
with:
junit-glob: ${{ env.ARTIFACT_DIR }}/junit-*.xml
log-dir: ${{ env.ARTIFACT_DIR }}
title: "Multi-Node E2E ${{ matrix.suite }} failure analysis"
- uses: actions/upload-artifact@v7
if: always()
with:
name: e2e-multi-${{ matrix.suite }}-${{ github.run_id }}
path: ${{ env.ARTIFACT_DIR }}
if-no-files-found: warn
retention-days: 14
- name: Fail job if suite failed
if: always()
run: |
if [ "${{ steps.e2e.outputs.rc }}" != "0" ]; then
echo "::error::suite ${{ matrix.suite }} failed (rc=${{ steps.e2e.outputs.rc }})"
exit 1
fi
multi_teardown:
name: Multi env · teardown
needs: [ multi_provision, multi_suite ]
if: always() && inputs.run_multinode
runs-on: [ self-hosted, ci-multi ]
timeout-minutes: 20
env:
TOFU_LOG: /tmp/e2e-tofu-multi-td-${{ github.run_id }}.log
SSH_KEY: ${{ needs.multi_provision.outputs.ssh_key }}
NODES_CSV: ${{ needs.multi_provision.outputs.nodes_csv }}
steps:
- uses: actions/checkout@v6
with:
path: spinifex
# Reclaim leaked run-tagged resources before the cluster is destroyed.
- name: Sweep leaked run resources on node1
if: ${{ needs.multi_provision.result == 'success' && needs.multi_provision.outputs.node1_ip != '' }}
run: |
set +e
source "${GITHUB_WORKSPACE}/spinifex/.github/scripts/ci-fmt.sh"
SSH_OPTS=(-o StrictHostKeyChecking=no -o ConnectTimeout=5 -i "${SSH_KEY}")
VM=tf-user@${{ needs.multi_provision.outputs.node1_ip }}
banner "sweep run-tagged resources (run ${{ github.run_id }})"
ssh "${SSH_OPTS[@]}" "$VM" \
"cd \$HOME/spinifex-e2e-bin 2>/dev/null && \
SPINIFEX_E2E=1 SPINIFEX_MODE=multinode \
SPINIFEX_E2E_RUN_ID='${{ github.run_id }}' \
./teardown.test -test.v -test.run TestSweepRunResources -test.timeout=10m" \
2>&1 || true
true
- name: Teardown cluster
if: always()
working-directory: mulga/scripts/tofu-cluster
run: |
set +e
source "${GITHUB_WORKSPACE}/spinifex/.github/scripts/ci-fmt.sh"
if [ -n "${NODES_CSV}" ] && [ -n "${SSH_KEY}" ]; then
IFS=',' read -ra NODE_LIST <<< "${NODES_CSV}"
for ip in "${NODE_LIST[@]}"; do
ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5 -i "${SSH_KEY}" \
tf-user@"$ip" \
'pkill -u tf-user -TERM -f "/spinifex-e2e-bin/.*\.test( |$)" 2>/dev/null; sleep 1; pkill -u tf-user -KILL -f "/spinifex-e2e-bin/.*\.test( |$)" 2>/dev/null; true' \
2>/dev/null || true
done
fi
UID_SELF=$(id -u); WS="${GITHUB_WORKSPACE}"
reap() {
local sig="$1"
for pid in $(pgrep -u "$UID_SELF" -x tofu) $(pgrep -u "$UID_SELF" -x terraform); do
cwd=$(readlink "/proc/$pid/cwd" 2>/dev/null) || continue
case "$cwd" in "$WS"|"$WS"/*) kill "-$sig" "$pid" 2>/dev/null ;; esac
done
}
reap TERM; sleep 2; reap KILL
find "$WS" -name '.terraform.tfstate.lock.info' -delete 2>/dev/null
quiet_run "tofu workspace select" "$TOFU_LOG" tofu workspace select -or-create "${SPINIFEX_CI_ENV}"
quiet_run "tofu destroy" "$TOFU_LOG" timeout 600 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}"
ok "teardown complete"
e2e_suites_status:
name: E2E suite blocks · summary
if: always()
needs: [ single_suite, multi_suite ]
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Summarize
run: |
echo "## E2E suite-block results" >> "$GITHUB_STEP_SUMMARY"
echo "single_suite: ${{ needs.single_suite.result }}" >> "$GITHUB_STEP_SUMMARY"
echo "multi_suite: ${{ needs.multi_suite.result }}" >> "$GITHUB_STEP_SUMMARY"
case "${{ needs.single_suite.result }}" in success|skipped) ;; *) exit 1 ;; esac
case "${{ needs.multi_suite.result }}" in success|skipped) ;; *) exit 1 ;; esac
+51 -292
View File
@@ -36,14 +36,50 @@ env:
TEST_RUN: ${{ inputs.test_run || '' }}
jobs:
# Shadow-mode suite selector. Computes which suites the diff *would* run and logs it to the job summary.
# Does NOT gate or override the real SUITES env yet — it runs alongside the full matrix so we
# can compare predicted vs actual before flipping it on.
select_suites:
name: Select Suites (shadow)
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: false
- name: Compute targeted suite selection
run: |
set -euo pipefail
BASE="${{ github.event.before }}"
HEAD="${{ github.sha }}"
# First push to a branch reports an all-zero before-SHA; fall back to
# the first parent so the diff is still meaningful.
if [ -z "$BASE" ] || [ "$BASE" = "0000000000000000000000000000000000000000" ]; then
BASE="${HEAD}~1"
fi
echo "## Targeted suite selection (shadow)" >> "$GITHUB_STEP_SUMMARY"
echo '```' >> "$GITHUB_STEP_SUMMARY"
echo "base=$BASE head=$HEAD" >> "$GITHUB_STEP_SUMMARY"
go run ./tests/e2e/select-suites/cmd/select-suites \
--manifest docs/service-interfaces.yaml \
--base-sha "$BASE" --head-sha "$HEAD" --repo-root . \
2>>"$GITHUB_STEP_SUMMARY" \
| tee -a "$GITHUB_STEP_SUMMARY"
echo '```' >> "$GITHUB_STEP_SUMMARY"
echo "Shadow only — actual run uses SUITES='${SUITES}'." >> "$GITHUB_STEP_SUMMARY"
e2e_single:
name: Single-Node E2E
# Skip when the dispatch input requests no in-VM suites (e.g.
# suites='e2e-reboot' only). Push triggers and the default SUITES list
# suites='e2e-multinode' only). Push triggers and the default SUITES list
# both include single/cert/lb so the inputs.suites null-check keeps the
# job running on plain PR pushes.
if: github.event_name != 'workflow_dispatch' || contains(github.event.inputs.suites, 'e2e-single') || contains(github.event.inputs.suites, 'e2e-cert') || contains(github.event.inputs.suites, 'e2e-lb') || contains(github.event.inputs.suites, 'e2e-eks')
runs-on: [ self-hosted, ci-single ]
runs-on: [self-hosted, ci-single]
timeout-minutes: 45
env:
TOFU_LOG: /tmp/e2e-tofu-single-${{ github.run_id }}.log
@@ -71,11 +107,6 @@ jobs:
- name: Clean Workspace
run: |
# Detach any leaked mounts under the workspace before deleting. A
# killed image build can leave a `mount --bind /dev` under its build
# dir; if `rm -rf` traverses it as root it wipes the host's /dev
# (deletes /dev/null and wedges the box). umount deepest-first, then
# forbid rm from crossing any mount boundary as a hard backstop.
awk -v w="$GITHUB_WORKSPACE" '$2 ~ "^"w {print $2}' /proc/mounts \
| sort -r | xargs -r -I{} sudo umount -l -R {} || true
sudo rm -rf --one-file-system "$GITHUB_WORKSPACE"/*
@@ -106,10 +137,10 @@ jobs:
- name: Re-enable Go toolchain auto-download
run: echo "GOTOOLCHAIN=auto" >> "$GITHUB_ENV"
- name: Prune rootless docker cache
- name: Reclaim docker build cache
run: |
docker container prune -f || true
docker image prune -af || true
docker image prune -f || true
docker buildx prune -af --filter "until=24h" || true
docker system df || true
@@ -184,7 +215,6 @@ jobs:
run: |
set -euo pipefail
source "${GITHUB_WORKSPACE}/spinifex/.github/scripts/ci-fmt.sh"
# Pull import args straight from the manifest so they never drift.
eval "$(grep -E '^(ALPINE_VERSION|AMI_NAME|SYSTEM_TAG)=' \
spinifex/scripts/images/eks-node/manifest.conf)"
SSH_OPTS=(-o StrictHostKeyChecking=no -i "${{ steps.vm.outputs.ssh_key }}")
@@ -238,7 +268,7 @@ jobs:
run: |
set -euo pipefail
source "${GITHUB_WORKSPACE}/spinifex/.github/scripts/ci-fmt.sh"
SUITES_BUILD=$(echo "${SUITES}" | sed 's/e2e-//g' | tr ' ' '\n' | grep -vE '^(reboot|multinode)$' | tr '\n' ' ')
SUITES_BUILD=$(echo "${SUITES}" | sed 's/e2e-//g' | tr ' ' '\n' | grep -vE '^(multinode)$' | tr '\n' ' ')
if [ -z "$(echo "${SUITES_BUILD}" | tr -d ' ')" ]; then
echo "no in-VM suites requested (SUITES=${SUITES}); nothing to build"
exit 0
@@ -280,7 +310,7 @@ jobs:
id: e2e
run: |
set +e
SUITES_RUN=$(echo "${SUITES}" | sed 's/e2e-//g' | tr ' ' '\n' | grep -vE '^(reboot|multinode)$' | tr '\n' ' ')
SUITES_RUN=$(echo "${SUITES}" | sed 's/e2e-//g' | tr ' ' '\n' | grep -vE '^(multinode)$' | tr '\n' ' ')
if [ -z "$(echo "${SUITES_RUN}" | tr -d ' ')" ]; then
echo "no in-VM suites requested (SUITES=${SUITES}); skipping"
echo "rc=0" >> "$GITHUB_OUTPUT"
@@ -445,7 +475,7 @@ jobs:
e2e_real_multi:
name: Real Multi-Node E2E
if: github.event_name != 'workflow_dispatch' || contains(github.event.inputs.suites, 'e2e-multinode')
runs-on: [ self-hosted, ci-multi ]
runs-on: [self-hosted, ci-multi]
timeout-minutes: 60
env:
TOFU_LOG: /tmp/e2e-tofu-multi-${{ github.run_id }}.log
@@ -473,11 +503,6 @@ jobs:
- name: Clean Workspace
run: |
# Detach any leaked mounts under the workspace before deleting. A
# killed image build can leave a `mount --bind /dev` under its build
# dir; if `rm -rf` traverses it as root it wipes the host's /dev
# (deletes /dev/null and wedges the box). umount deepest-first, then
# forbid rm from crossing any mount boundary as a hard backstop.
awk -v w="$GITHUB_WORKSPACE" '$2 ~ "^"w {print $2}' /proc/mounts \
| sort -r | xargs -r -I{} sudo umount -l -R {} || true
sudo rm -rf --one-file-system "$GITHUB_WORKSPACE"/*
@@ -508,10 +533,10 @@ jobs:
- name: Re-enable Go toolchain auto-download
run: echo "GOTOOLCHAIN=auto" >> "$GITHUB_ENV"
- name: Prune rootless docker cache
- name: Reclaim docker build cache
run: |
docker container prune -f || true
docker image prune -af || true
docker image prune -f || true
docker buildx prune -af --filter "until=24h" || true
docker system df || true
@@ -573,7 +598,7 @@ jobs:
# JetStream clustering isn't solid yet (mulga-omq/z2z/5yg). EKS is
# validated single-node only for now; re-enable here once clustered
# JetStream + multinode EKS are a deliberate target.
SUITES_BUILD=$(echo "${SUITES}" | sed 's/e2e-//g' | tr ' ' '\n' | grep -vE '^(reboot|single|eks)$' | tr '\n' ' ')
SUITES_BUILD=$(echo "${SUITES}" | sed 's/e2e-//g' | tr ' ' '\n' | grep -vE '^(single|eks)$' | tr '\n' ' ')
if [ -z "$(echo "${SUITES_BUILD}" | tr -d ' ')" ]; then
echo "no multinode-eligible suites requested (SUITES=${SUITES}); nothing to build"
exit 0
@@ -656,7 +681,7 @@ jobs:
id: e2e
run: |
set +e
SUITES_RUN=$(echo "${SUITES}" | sed 's/e2e-//g' | tr ' ' '\n' | grep -vE '^(reboot|single|eks)$' | tr '\n' ' ')
SUITES_RUN=$(echo "${SUITES}" | sed 's/e2e-//g' | tr ' ' '\n' | grep -vE '^(single|eks)$' | tr '\n' ' ')
if [ -z "$(echo "${SUITES_RUN}" | tr -d ' ')" ]; then
echo "no multinode-eligible suites requested (SUITES=${SUITES}); skipping"
echo "rc=0" >> "$GITHUB_OUTPUT"
@@ -857,275 +882,10 @@ jobs:
exit 1
fi
e2e_reboot:
name: Reboot Resilience E2E
# Opt-in: only fires when the SUITES dispatch input lists e2e-reboot.
# The reboot test reboots the spinifex VM, so it must run on the runner
# (not shipped to the VM like the cert/lb/single suites). Runs in
# parallel with e2e_single — both target [self-hosted, ci-single]
# and GitHub Actions serialises on the underlying runner allocation,
# so no explicit needs: ordering is required.
if: contains(github.event.inputs.suites, 'e2e-reboot')
runs-on: [ self-hosted, ci-single ]
timeout-minutes: 60
env:
TOFU_LOG: /tmp/e2e-tofu-reboot-${{ github.run_id }}.log
ARTIFACT_DIR: /tmp/spinifex-e2e-reboot-artifacts-${{ github.run_id }}
steps:
- name: Reap Orphan Procs
run: |
set +e
UID_SELF=$(id -u)
WS="${GITHUB_WORKSPACE}"
reap() {
local sig="$1"
for pid in $(pgrep -u "$UID_SELF" -x tofu) $(pgrep -u "$UID_SELF" -x terraform); do
cwd=$(readlink "/proc/$pid/cwd" 2>/dev/null) || continue
case "$cwd" in
"$WS"|"$WS"/*) echo "reap $sig pid=$pid cwd=$cwd"; kill "-$sig" "$pid" 2>/dev/null ;;
esac
done
}
reap TERM
sleep 3
reap KILL
find "$WS" -name '.terraform.tfstate.lock.info' -delete 2>/dev/null
true
- name: Clean Workspace
run: |
# Detach any leaked mounts under the workspace before deleting. A
# killed image build can leave a `mount --bind /dev` under its build
# dir; if `rm -rf` traverses it as root it wipes the host's /dev
# (deletes /dev/null and wedges the box). umount deepest-first, then
# forbid rm from crossing any mount boundary as a hard backstop.
awk -v w="$GITHUB_WORKSPACE" '$2 ~ "^"w {print $2}' /proc/mounts \
| sort -r | xargs -r -I{} sudo umount -l -R {} || true
sudo rm -rf --one-file-system "$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
- uses: actions/checkout@v6
with:
repository: mulgadc/mulga
token: ${{ steps.app-token.outputs.token }}
sparse-checkout: scripts/tofu-cluster
path: mulga
- uses: actions/checkout@v6
with:
path: spinifex
- uses: actions/setup-go@v6
with:
go-version-file: spinifex/go.mod
cache: false
- name: Re-enable Go toolchain auto-download
run: echo "GOTOOLCHAIN=auto" >> "$GITHUB_ENV"
- name: Prune rootless docker cache
run: |
docker container prune -f || true
docker image prune -af || true
docker buildx prune -af --filter "until=24h" || true
docker system df || true
- 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 VM
working-directory: mulga/scripts/tofu-cluster
run: |
set -euo pipefail
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 "VM 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: Resolve VM SSH details
id: vm
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}"
echo "wan_ip=${WAN_IP}" >> "$GITHUB_OUTPUT"
echo "ssh_key=${SSH_KEY}" >> "$GITHUB_OUTPUT"
- name: Build reboot.test on runner
working-directory: spinifex/tests/e2e
run: |
set -euo pipefail
source "${GITHUB_WORKSPACE}/spinifex/.github/scripts/ci-fmt.sh"
banner "build reboot"
group_open "go test -c"
make e2e-build SUITES_BUILD="reboot"
group_close
ok "$(basename _bin/reboot.test) $(stat -c '%s' _bin/reboot.test | numfmt --to=iec)"
- name: Fetch AWS credentials from VM
id: creds
run: |
set -euo pipefail
SSH_OPTS=(-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
-i "${{ steps.vm.outputs.ssh_key }}")
VM=tf-user@${{ steps.vm.outputs.wan_ip }}
CREDS=$(ssh "${SSH_OPTS[@]}" "$VM" 'cat ~/.aws/credentials')
AWS_AK=$(echo "$CREDS" | awk -F '= *' '/^aws_access_key_id/ {print $2; exit}')
AWS_SK=$(echo "$CREDS" | awk -F '= *' '/^aws_secret_access_key/ {print $2; exit}')
if [ -z "$AWS_AK" ] || [ -z "$AWS_SK" ]; then
echo "::error::Failed to extract spinifex AWS credentials from ${{ steps.vm.outputs.wan_ip }}"
exit 1
fi
echo "::add-mask::${AWS_AK}"
echo "::add-mask::${AWS_SK}"
echo "ak=${AWS_AK}" >> "$GITHUB_OUTPUT"
echo "sk=${AWS_SK}" >> "$GITHUB_OUTPUT"
- name: Run reboot suite on runner
id: e2e
run: |
set +e
mkdir -p "${ARTIFACT_DIR}"
date -u +%Y-%m-%dT%H:%M:%SZ > "${ARTIFACT_DIR}/test-reboot.start"
R=$'\033[0m'; B=$'\033[1m'; C=$'\033[36m'
printf '\n%s%s━━ suite reboot ━━%s\n' "$B" "$C" "$R"
if [ -n "${TEST_RUN}" ]; then
TEST_RUN_FLAG="-test.run=${TEST_RUN}"
else
TEST_RUN_FLAG=""
fi
GITHUB_ACTIONS=true FORCE_COLOR=1 \
SPINIFEX_E2E=1 SPINIFEX_MODE=single \
SPINIFEX_WAN_IP="${{ steps.vm.outputs.wan_ip }}" \
SPINIFEX_SSH_KEY="${{ steps.vm.outputs.ssh_key }}" \
SPINIFEX_AWS_ENDPOINT="https://${{ steps.vm.outputs.wan_ip }}:9999" \
SPINIFEX_AWS_ACCESS_KEY_ID="${{ steps.creds.outputs.ak }}" \
SPINIFEX_AWS_SECRET_ACCESS_KEY="${{ steps.creds.outputs.sk }}" \
ARTIFACT_DIR="${ARTIFACT_DIR}" \
./spinifex/tests/e2e/_bin/reboot.test -test.v -test.timeout=30m ${TEST_RUN_FLAG} \
2>&1 | tee "${ARTIFACT_DIR}/test-reboot.log" \
| sed -uE 's/^[[:space:]]+([a-zA-Z0-9_]+_test\.go:[0-9]+: )?//'
rc=${PIPESTATUS[0]}
echo "rc=${rc}" >> "$GITHUB_OUTPUT"
exit 0
- name: Collect VM journals
if: always()
run: |
set +e
source "${GITHUB_WORKSPACE}/spinifex/.github/scripts/ci-fmt.sh"
mkdir -p "${ARTIFACT_DIR}"
SSH_OPTS=(-o StrictHostKeyChecking=no -o ConnectTimeout=5 \
-i "${{ steps.vm.outputs.ssh_key }}")
VM=tf-user@${{ steps.vm.outputs.wan_ip }}
ssh "${SSH_OPTS[@]}" "$VM" 'sudo journalctl -u "spinifex-*" --no-pager --output=short-iso --since "60 min ago"' \
> "${ARTIFACT_DIR}/spinifex-journal.log" 2>/dev/null || true
ok "journal: $(wc -l < "${ARTIFACT_DIR}/spinifex-journal.log" 2>/dev/null || echo 0) lines"
- name: Install go-junit-report
if: always()
run: |
go install github.com/jstemmer/go-junit-report/v2@latest
echo "$HOME/go/bin" >> "$GITHUB_PATH"
- name: Convert test log to JUnit XML
if: always()
run: |
shopt -s nullglob
for LOG in "${ARTIFACT_DIR}"/test-*.log; do
BASE=$(basename "$LOG" .log)
SUITE=${BASE#test-}
go-junit-report -in "$LOG" -out "${ARTIFACT_DIR}/junit-${SUITE}.xml" || true
done
- name: Publish test summary
if: always()
uses: ./spinifex/.github/actions/junit-report
with:
junit-glob: ${{ env.ARTIFACT_DIR }}/junit-*.xml
title: "E2E reboot results"
- name: Analyze e2e failures
if: always()
uses: ./spinifex/.github/actions/e2e-analyze
with:
junit-glob: ${{ env.ARTIFACT_DIR }}/junit-*.xml
log-dir: ${{ env.ARTIFACT_DIR }}
title: "Reboot E2E failure analysis"
- uses: actions/upload-artifact@v7
if: always()
with:
name: e2e-reboot-artifacts-${{ github.run_id }}
path: ${{ env.ARTIFACT_DIR }}
if-no-files-found: warn
retention-days: 14
- name: Teardown
if: always()
working-directory: mulga/scripts/tofu-cluster
run: |
set +e
source "${GITHUB_WORKSPACE}/spinifex/.github/scripts/ci-fmt.sh"
UID_SELF=$(id -u)
WS="${GITHUB_WORKSPACE}"
reap() {
local sig="$1"
for pid in $(pgrep -u "$UID_SELF" -x tofu) $(pgrep -u "$UID_SELF" -x terraform); do
cwd=$(readlink "/proc/$pid/cwd" 2>/dev/null) || continue
case "$cwd" in
"$WS"|"$WS"/*) kill "-$sig" "$pid" 2>/dev/null ;;
esac
done
}
reap TERM
sleep 2
reap KILL
find "$WS" -name '.terraform.tfstate.lock.info' -delete 2>/dev/null
quiet_run "tofu workspace select" "$TOFU_LOG" tofu workspace select -or-create "${SPINIFEX_CI_ENV}"
quiet_run "tofu destroy" "$TOFU_LOG" timeout 600 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}"
ok "teardown complete"
- name: Fail job if reboot suite failed
if: always()
run: |
if [ "${{ steps.e2e.outputs.rc }}" != "0" ]; then
echo "::error::Reboot suite failed (rc=${{ steps.e2e.outputs.rc }})"
exit 1
fi
e2e_status:
name: Report E2E Status
if: always()
needs: [ e2e_single, e2e_real_multi, e2e_reboot ]
needs: [e2e_single, e2e_real_multi]
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
@@ -1138,16 +898,15 @@ jobs:
echo "::error::no SHA available (inputs.sha and github.sha both empty)"
exit 1
fi
single_ok=true; multi_ok=true; reboot_ok=true
single_ok=true; multi_ok=true
case "${{ needs.e2e_single.result }}" in success|skipped) ;; *) single_ok=false ;; esac
case "${{ needs.e2e_real_multi.result }}" in success|skipped) ;; *) multi_ok=false ;; esac
case "${{ needs.e2e_reboot.result }}" in success|skipped) ;; *) reboot_ok=false ;; esac
if $single_ok && $multi_ok && $reboot_ok; then
if $single_ok && $multi_ok; then
STATE="success"
DESC="E2E suites passed"
else
STATE="failure"
DESC="E2E failed: single=${{ needs.e2e_single.result }} multi=${{ needs.e2e_real_multi.result }} reboot=${{ needs.e2e_reboot.result }}"
DESC="E2E failed: single=${{ needs.e2e_single.result }} multi=${{ needs.e2e_real_multi.result }}"
fi
gh api "repos/${{ github.repository }}/statuses/${REPORT_SHA}" \
-f state="$STATE" \
+6 -2
View File
@@ -26,7 +26,7 @@ concurrency:
jobs:
publish:
name: Build + publish eks-node AMI
runs-on: ubuntu-24.04
runs-on: [ self-hosted, ci-single ]
steps:
- name: Checkout Spinifex
@@ -51,8 +51,12 @@ jobs:
- name: Setup Go
uses: actions/setup-go@v6
with:
cache-dependency-path: "spinifex/go.sum"
go-version-file: "spinifex/go.mod"
# Self-hosted runner: the Go module + build cache already persists on
# local disk between runs. setup-go's cache-save ("Post Setup Go")
# otherwise re-uploads hundreds of MB to the Actions cache backend,
# taking longer than the build itself.
cache: false
- name: Setup Go Workspace
run: go work init ./spinifex ./viperblock ./predastore
+1 -18
View File
@@ -34,7 +34,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v6
with:
go-version: '1.26.4'
go-version: "1.26.4"
- name: Build microVM artifacts (lb-agent + kernel + initramfs)
run: cd spinifex && make build-microvm-image
@@ -132,12 +132,6 @@ jobs:
steps:
- name: Clean Workspace
run: |
# This runner (ci-single) is shared with the iso job, which mounts a
# kernel-global devtmpfs at <workspace>/mulga/scripts/iso-builder/.cache/rootfs/dev.
# If that job is killed its EXIT-trap unmount never runs and the bind
# leaks; a plain rm -rf then traverses it and deletes the host's /dev
# (wipes /dev/null, wedges the box). Detach any leaked mounts under the
# workspace first, then forbid rm from crossing a mount boundary.
awk -v w="$GITHUB_WORKSPACE" '$2 ~ "^"w {print $2}' /proc/mounts \
| sort -r | xargs -r -I{} sudo umount -l -R {} || true
sudo rm -rf --one-file-system "$GITHUB_WORKSPACE"/*
@@ -186,10 +180,6 @@ jobs:
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
# Bare-metal single-node suite on the real ISO/squashfs + PXE-boot path —
# the only release check that exercises hardware install. Runs in parallel
# with `verify` (both need: release). Promotion and ISO upload both hard-gate
# on this passing.
baremetal:
needs: release
permissions:
@@ -198,8 +188,6 @@ jobs:
uses: ./.github/workflows/e2e-baremetal.yml
secrets: inherit
# Promote the draft only after BOTH the tofu install (`verify`) and the
# bare-metal hardware boot (`baremetal`) pass — runs only if both succeed.
promote:
needs: [ verify, baremetal ]
runs-on: ubuntu-24.04
@@ -248,8 +236,6 @@ jobs:
- 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
@@ -335,9 +321,6 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
run: |
# Gate the R2 upload on BOTH the tofu install (`verify`) and the
# real-hardware boot (`baremetal`, a reusable workflow whose job shows
# as "baremetal / ..."). Upload only if both conclude success.
echo "Waiting for verify + baremetal jobs to complete..."
while true; do
JOBS=$(gh api \
+3
View File
@@ -58,6 +58,9 @@ jobs:
- name: Build Spinifex
run: make -C spinifex build
- name: Manifest check + drift lint
run: make -C spinifex manifest-check manifest-lint
- name: Run Tests with Coverage
run: make -C spinifex test-cover
-3
View File
@@ -1,3 +0,0 @@
# Spinifex Installation
For documentation on using and installing Spinifex see [https://docs.mulgadc.com](https://docs.mulgadc.com/)
+14 -80
View File
@@ -6,17 +6,12 @@ ARCH := $(shell uname -m)
ifeq ($(ARCH),x86_64)
GO_ARCH := amd64
AWS_ARCH := x86_64
# On x86, we can run ARM VMs via emulation
QEMU_PACKAGES := qemu-system-x86 qemu-system-arm
else ifeq ($(ARCH),aarch64)
GO_ARCH := arm64
AWS_ARCH := aarch64
# On ARM, we can run x86 VMs via emulation
QEMU_PACKAGES := qemu-system-aarch64 qemu-system-x86
else ifeq ($(ARCH),arm64)
GO_ARCH := arm64
AWS_ARCH := aarch64
QEMU_PACKAGES := qemu-system-aarch64 qemu-system-x86
else
$(error Unsupported architecture: $(ARCH). Only x86_64 and aarch64/arm64 are supported.)
endif
@@ -115,14 +110,10 @@ install-microvm: $(MICROVM_ARTIFACTS) ## Install microVM artifacts to /usr/share
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)...."
$(GOPATH)/bin/$(GO_PROJECT_NAME)
# 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 manifest-check lint govulncheck test-cover diff-coverage test-race test-harness
@$(MAKE) --no-print-directory QUIET=1 manifest-check manifest-lint 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
@@ -139,6 +130,17 @@ 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
# Drift guards (Bead 5): direct-create fixture lint + NATS subject lint,
# ratcheted against tests/e2e/manifest-lint/baseline.txt. Fails only on NEW
# drift beyond the baseline.
manifest-lint:
@echo -e "\n....Linting manifest drift (fixtures + subjects)...."
@go run ./tests/e2e/manifest-lint/cmd/manifest-lint -repo-root .
# Accept current drift into the baseline. Run after an intentional change.
manifest-lint-update:
@go run ./tests/e2e/manifest-lint/cmd/manifest-lint -repo-root . -update
# Run unit tests
test:
@echo -e "\n....Running tests for $(GO_PROJECT_NAME)...."
@@ -168,13 +170,8 @@ diff-coverage: test-cover
bench:
@echo -e "\n....Running benchmarks for $(GO_PROJECT_NAME)...."
$(MAKE) easyjson
LOG_IGNORE=1 go test -benchmem -run=. -bench=. ./...
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
@@ -201,11 +198,10 @@ clean:
install-system:
@echo -e "\n....Installing system dependencies for $(ARCH)...."
@echo "QEMU packages: $(QEMU_PACKAGES)"
sudo apt-get update && sudo DEBIAN_FRONTEND=noninteractive apt-get install -y \
-o Dpkg::Options::="--force-confdef" \
-o Dpkg::Options::="--force-confold" \
nbdkit nbdkit-plugin-dev pkg-config $(QEMU_PACKAGES) qemu-utils qemu-kvm \
nbdkit nbdkit-plugin-dev pkg-config qemu-system-x86 qemu-system-arm 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 \
@@ -234,19 +230,15 @@ install-aws:
quickinstall: install-system install-go install-aws
@echo -e "\n✅ Quickinstall complete for $(ARCH)."
@echo " Please ensure /usr/local/go/bin is in your PATH."
@echo " Installed: Go ($(GO_ARCH)), AWS CLI ($(AWS_ARCH)), QEMU ($(QEMU_PACKAGES))"
# Lint all Go code via golangci-lint (replaces check-format, vet, gosec, staticcheck)
lint:
@echo "Running golangci-lint..."
$(_Q)golangci-lint run ./...
@echo " golangci-lint ok"
# Auto-fix all linter issues that have fixers
fix:
golangci-lint run --fix ./...
# Govulncheck — dependency vulnerability scanning (not covered by golangci-lint)
govulncheck:
@echo "Running govulncheck..."
$(_Q)go tool govulncheck ./...
@@ -295,65 +287,7 @@ distro-arm64:
distro-clean:
rm -rf dist/
# Ansible dev lifecycle (experimental, parallel to dev-*.sh / reset-dev-env.sh).
# See scripts/ansible/README.md and docs/development/improvements/ansible-dev-lifecycle.md.
#
# Variable overrides: pass EXTRA_VARS="key=val key2=val2" to forward as
# `--extra-vars` to ansible-playbook. Plain `-e` on the make command line is
# make's own flag and does NOT reach ansible.
# make ansible-dev-install EXTRA_VARS="spinifex_external_pool_start=192.168.1.90 spinifex_external_pool_end=192.168.1.99"
EXTRA_VARS ?=
_ANSIBLE_EXTRA = $(if $(strip $(EXTRA_VARS)),--extra-vars "$(EXTRA_VARS)",)
ansible-dev-preflight:
cd scripts/ansible && ansible-playbook playbooks/dev-preflight.yml $(_ANSIBLE_EXTRA)
ansible-dev-teardown:
cd scripts/ansible && ansible-playbook playbooks/dev-teardown.yml $(_ANSIBLE_EXTRA)
ansible-dev-install:
cd scripts/ansible && ansible-playbook playbooks/dev-install.yml $(_ANSIBLE_EXTRA)
ansible-dev-reset:
cd scripts/ansible && ansible-playbook playbooks/dev-reset.yml $(_ANSIBLE_EXTRA)
ansible-dev-deploy:
cd scripts/ansible && ansible-playbook playbooks/dev-deploy.yml $(_ANSIBLE_EXTRA)
ansible-dev-status:
cd scripts/ansible && ansible-playbook playbooks/dev-status.yml
ansible-dev-logs:
cd scripts/ansible && ansible-playbook playbooks/dev-logs.yml
# Snapshot / restore require an explicit -e snapshot_name=<name>. The
# wrapper passes ANSIBLE_EXTRA through so devs can run
# `make ansible-dev-snapshot ANSIBLE_EXTRA='-e snapshot_name=before-merge'`.
ansible-dev-snapshot:
cd scripts/ansible && ansible-playbook playbooks/dev-snapshot.yml $(ANSIBLE_EXTRA)
ansible-dev-restore:
cd scripts/ansible && ansible-playbook playbooks/dev-restore.yml $(ANSIBLE_EXTRA)
ansible-dev-version:
cd scripts/ansible && ansible-playbook playbooks/dev-version.yml
ansible-dev-vpc:
cd scripts/ansible && ansible-playbook playbooks/dev-vpc.yml $(ANSIBLE_EXTRA)
# Multi-node cluster bootstrap against a tofu env (env1, env2, bryn, etc).
# Inventory is generated from scripts/tofu-cluster/envs/<env>.tfvars.
# make ansible-cluster-bootstrap ENV=env1 POOL=192.168.1.150-192.168.1.159
ENV ?=
POOL ?=
ansible-cluster-bootstrap:
@test -n "$(ENV)" || { echo "ENV=<env> required (e.g. env1, env2)"; exit 64; }
cd scripts/ansible && CLUSTER_ENV=$(ENV) ansible-playbook -i inventory/tofu.py playbooks/cluster-bootstrap.yml \
-e cluster_env=$(ENV) \
$(if $(POOL),-e cluster_external_pool=$(POOL),) \
$(_ANSIBLE_EXTRA)
.PHONY: build build-ui build-installer build-lb-agent build-system-image build-eks-node-image import-eks-node-image publish-eks-node-image build-microvm-image install-microvm go_build go_run preflight test test-cover test-race diff-coverage bench run test-actions test-harness manifest-check diff-coverage bench run \
.PHONY: build build-ui build-installer build-lb-agent build-system-image build-eks-node-image import-eks-node-image publish-eks-node-image build-microvm-image install-microvm go_build preflight test test-cover test-race diff-coverage bench test-actions test-harness manifest-check manifest-lint manifest-lint-update \
deploy reinstall clean \
install-system install-go install-aws quickinstall \
lint fix govulncheck \
+1
View File
@@ -6,6 +6,7 @@ Wants=spinifex-nats.service
[Service]
Type=simple
Slice=spinifex-system.slice
User=spinifex-gw
Group=spinifex
ExecStartPre=/var/lib/spinifex/wait-for-nats.sh
+8 -48
View File
@@ -2,66 +2,29 @@
Description=Spinifex Daemon (VM Orchestration)
PartOf=spinifex.target
After=spinifex-nats.service spinifex-viperblock.service openvswitch-switch.service ovn-controller.service openvswitch-ipsec.service
# Wants=openvswitch-ipsec.service so systemd brings the IPsec userspace
# daemon up before spinifex flips other_config:ipsec_encapsulation=true.
# Without that daemon, OVS holds the cert pointers unused and strongSwan
# never gets IKE config — tunnel traffic is encrypted with no SAs and
# silently dropped. Wants= (not Requires=) keeps daemon bootable on
# single-node / lab hosts where the package is intentionally absent.
# Wants (not Requires): stay bootable when openvswitch-ipsec is absent
Wants=openvswitch-ipsec.service
# No Wants=spinifex-nats.service: Wants= reactivates a stopped unit during
# `systemctl restart spinifex-daemon`, which would mask the standalone-startup
# path the Tier 1 DDIL Scenario B exercises. spinifex.target Wants= covers
# cold boot.
[Service]
Type=simple
Slice=spinifex-system.slice
User=spinifex-daemon
Group=spinifex
# 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: 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: guest QEMU survives daemon restart; the new daemon reattaches (DDIL)
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
# RG-4: infra tier — reaped only after every guest
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: 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.
# Pin to host tmpfs: QEMU pidfiles/QMP sockets must survive PrivateTmp namespace churn (DDIL)
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.
# RG-9: daemon tier — privileged by necessity (GPU vfio). NoNewPrivileges omitted (RG-10: sudo ip/ovs-vsctl/dhcpcd)
AmbientCapabilities=CAP_SYS_ADMIN CAP_DAC_OVERRIDE
ProtectSystem=full
PrivateDevices=no
@@ -73,7 +36,7 @@ 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
# RG-9: hardening baseline
ProtectHome=yes
PrivateTmp=yes
RestrictSUIDSGID=yes
@@ -88,8 +51,6 @@ 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
@@ -97,8 +58,7 @@ 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: VFIO pins all guest RAM
LimitMEMLOCK=infinity
[Install]
+8
View File
@@ -0,0 +1,8 @@
[Unit]
Description=Spinifex guest VM tier
Before=slices.target
[Slice]
# RG-3: child of spinifex.slice (spinifex-guests -> spinifex), sibling of
# spinifex-system. Parent for the per-instance spinifex-instance-<id>.slice
# leaves (0002 RG-7). Inherits the spinifex.slice ceiling; no extra limit here.
+1
View File
@@ -5,6 +5,7 @@ Before=spinifex-predastore.service spinifex-daemon.service spinifex-awsgw.servic
[Service]
Type=simple
Slice=spinifex-system.slice
User=spinifex-nats
Group=spinifex
ExecStart=/usr/local/bin/spx service nats start
@@ -6,6 +6,7 @@ Wants=spinifex-nats.service
[Service]
Type=simple
Slice=spinifex-system.slice
User=spinifex-storage
Group=spinifex
ExecStartPre=/var/lib/spinifex/wait-for-nats.sh
+9
View File
@@ -0,0 +1,9 @@
[Unit]
Description=Spinifex control/infra tier (NATS, predastore, viperblock, daemon, awsgw, vpcd, ui)
Before=slices.target
[Slice]
# RG-3: child of spinifex.slice (spinifex-system -> spinifex), inherits the
# parent ceiling, no extra limit. Holds the 7 control/infra services. Guest QEMU
# moves to the sibling spinifex-guests.slice (per-instance placement lands in
# 0002 RG-7); until then the daemon-forked guests stay under spinifex-daemon.service.
+1
View File
@@ -5,6 +5,7 @@ After=spinifex-awsgw.service
[Service]
Type=simple
Slice=spinifex-system.slice
User=spinifex-ui
Group=spinifex
ExecStart=/usr/local/bin/spx service spinifex-ui start
@@ -6,6 +6,7 @@ Wants=spinifex-nats.service
[Service]
Type=simple
Slice=spinifex-system.slice
User=spinifex-viperblock
Group=spinifex
ExecStartPre=/var/lib/spinifex/wait-for-nats.sh
+9 -30
View File
@@ -6,6 +6,7 @@ Wants=spinifex-nats.service ovn-controller.service
[Service]
Type=simple
Slice=spinifex-system.slice
User=spinifex-vpcd
Group=spinifex
ExecStartPre=/var/lib/spinifex/wait-for-nats.sh
@@ -16,45 +17,23 @@ OOMScoreAdjust=-500
Environment=SPINIFEX_CONFIG_PATH=/etc/spinifex/spinifex.toml
Environment=SPINIFEX_BASE_DIR=/var/lib/spinifex/vpcd/
# vpcd MUST share the HOST mount namespace. IMDS binds one listener per VPC inside
# a per-VPC netns entered in-process via setns(CLONE_NEWNET); the /run/netns/<vpc>
# bind-mount that `ip netns add` creates must be host-visible (for diagnostics) and
# must survive a vpcd restart (so the listener can rebind). ANY filesystem-sandbox
# directive — ProtectSystem / ProtectHome / PrivateTmp / ProtectKernel* /
# ProtectControlGroups / ProtectProc / Read{Only,Write}Paths — forks a PRIVATE
# mount namespace that traps that bind-mount inside vpcd, leaving the host a stale
# handle that fails setns(2) with EINVAL (CI runs 26920490454, 26922587471). Those
# directives are therefore omitted by design, and so is the MountFlags=shared /
# spinifex-netns.service propagation workaround they required.
#
# Boundary that REMAINS: DAC (User=spinifex-vpcd) plus the seccomp / capability /
# namespace / address-family restrictions below. The durable fix that restores
# filesystem sandboxing AND drops CAP_SYS_ADMIN is the process-per-netns model
# (`ip netns exec` helper instead of in-process setns) — see
# docs/development/improvements/imds-l2-datapath.md Phase 5.
# RG-9: network tier. vpcd MUST share the host mount namespace — IMDS enters
# per-VPC netns via setns(), and any filesystem-sandbox directive (ProtectSystem/
# Home, PrivateTmp, ProtectKernel*, ProtectControlGroups/Proc, Read*Paths) forks a
# private mount ns that traps the /run/netns bind-mount and breaks setns(2). Those,
# plus MemoryDenyWriteExecute, are omitted by design — not regressions.
PrivateDevices=no
# Hardening that does NOT fork a mount namespace (kept)
RestrictSUIDSGID=no
LockPersonality=yes
RestrictRealtime=yes
ProtectClock=yes
# IMDS enters the per-VPC netns via setns(CLONE_NEWNET); the `ip -n <netns>` /
# `ip netns exec` plumbing also unshares a mount namespace (CLONE_NEWNS, to give
# the netns its own /sys), so `mnt` must be allowlisted alongside `net` or the
# first in-netns command fails with EPERM.
# mnt alongside net: in-netns `ip` unshares a mount ns for its own /sys
RestrictNamespaces=net mnt
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.
# AF_PACKET: raw L2 BOOTP for the in-process DHCP client before the iface has an IP
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.
# CAP_SYS_ADMIN: setns(CLONE_NEWNET) into per-VPC IMDS netns to bind the listener.
# Note, disabled for now, sudo issue with ovs
# Caps: NET_ADMIN (ovs/ip), NET_RAW (AF_PACKET), NET_BIND_SERVICE (:68), SETUID/SETGID, SYS_ADMIN (setns into VPC netns)
AmbientCapabilities=CAP_NET_ADMIN CAP_NET_RAW CAP_NET_BIND_SERVICE CAP_SETUID CAP_SETGID CAP_SYS_ADMIN
CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_RAW CAP_NET_BIND_SERVICE CAP_SETUID CAP_SETGID CAP_SYS_ADMIN
NoNewPrivileges=no
+13
View File
@@ -0,0 +1,13 @@
[Unit]
Description=Spinifex resource-bounded slice (host ceiling)
Before=slices.target
[Slice]
# RG-1: no CPUWeight — slice roots are siblings; capping below the default would
# let user.slice out-prioritize the VM fleet. Operator CPU priority lives on
# system.slice (system.slice.d/spinifex-reserve.conf).
# RG-1/RG-5: throttle+reclaim at High, hard wall at Max (% of physical RAM).
MemoryHigh=80%
MemoryMax=90%
# RG-5: spill to the full swap device before the MemoryMax wall; matched to swap size.
MemorySwapMax=8G
+139
View File
@@ -0,0 +1,139 @@
// eks-gateway-fetch runs inside the EKS K3s control-plane VM and fetches a
// control-plane resource from the host through the AWS gateway, instead of
// dialing core NATS directly. It is the read-side companion to
// eks-gateway-publish.
//
// It SigV4-signs (service "eks") an HTTPS GET to the gateway, retrying with
// backoff until the gateway returns 2xx or the attempt budget is exhausted (so
// a degraded link surfaces as a non-zero exit), then emits the staged add-ons
// as tab-separated lines the on-VM addon-sync shell agent consumes without a
// JSON parser:
//
// <addonName>\t<addonVersion>\t<serviceAccountRoleArn>\t<base64(configurationValues)>
//
// The configuration values are base64-encoded so embedded tabs/newlines cannot
// break the line framing.
//
// Usage:
//
// eks-gateway-fetch -resource addons # GET /clusters/{cluster}/internal-addons/{accountId}
//
// Flags default to environment variables seeded by cloud-init:
// EKS_GATEWAY_URL, EKS_GATEWAY_CA, EKS_ACCESS_KEY, EKS_SECRET_KEY, EKS_REGION,
// EKS_ACCOUNT_ID, EKS_CLUSTER_NAME.
package main
import (
"bufio"
"encoding/base64"
"encoding/json"
"flag"
"fmt"
"io"
"log/slog"
"os"
"time"
"github.com/mulgadc/spinifex/internal/eksgw"
_ "github.com/mulgadc/spinifex/internal/fipsboot"
)
// stagedAddon mirrors the gateway's StagedAddonManifest wire shape; duplicated
// here to keep this tiny VM binary free of the handler package's dependency
// graph.
type stagedAddon struct {
AddonName string `json:"addonName"`
AddonVersion string `json:"addonVersion"`
ServiceAccountRoleArn string `json:"serviceAccountRoleArn"`
ConfigurationValues string `json:"configurationValues"`
}
type internalAddonsResponse struct {
Addons []stagedAddon `json:"addons"`
}
const (
maxAttempts = 30
retryDelay = 5 * time.Second
)
func main() {
var (
gatewayURL string
gatewayCA string
accessKey string
secretKey string
region string
accountID string
clusterName string
resource string
)
flag.StringVar(&gatewayURL, "gateway", os.Getenv("EKS_GATEWAY_URL"), "Gateway URL (e.g. https://10.15.8.1:9999)")
flag.StringVar(&gatewayCA, "gateway-ca", os.Getenv("EKS_GATEWAY_CA"), "Path to gateway TLS CA PEM (optional; falls back to system trust)")
flag.StringVar(&accessKey, "access-key", os.Getenv("EKS_ACCESS_KEY"), "AWS access key ID")
flag.StringVar(&secretKey, "secret-key", os.Getenv("EKS_SECRET_KEY"), "AWS secret access key")
flag.StringVar(&region, "region", os.Getenv("EKS_REGION"), "AWS region for SigV4 signing")
flag.StringVar(&accountID, "account-id", os.Getenv("EKS_ACCOUNT_ID"), "Cluster account ID")
flag.StringVar(&clusterName, "cluster", os.Getenv("EKS_CLUSTER_NAME"), "Cluster name")
flag.StringVar(&resource, "resource", "addons", "Resource to fetch: addons")
flag.Parse()
switch {
case accountID == "":
fatal("--account-id is required (or set EKS_ACCOUNT_ID)")
case clusterName == "":
fatal("--cluster is required (or set EKS_CLUSTER_NAME)")
case resource != "addons":
fatal("--resource must be addons")
}
client, err := eksgw.New(gatewayURL, gatewayCA, accessKey, secretKey, region)
if err != nil {
fatal(fmt.Sprintf("build gateway client: %v", err))
}
path := "/clusters/" + clusterName + "/internal-addons/" + accountID
var lastErr error
for attempt := 1; attempt <= maxAttempts; attempt++ {
body, err := client.Get(path)
if err != nil {
lastErr = err
slog.Warn("eks-gateway-fetch: attempt failed", "resource", resource, "attempt", attempt, "err", err)
if attempt < maxAttempts {
time.Sleep(retryDelay)
}
continue
}
if werr := emitAddonsTSV(os.Stdout, body); werr != nil {
fatal(fmt.Sprintf("render response: %v", werr))
}
return
}
fatal(fmt.Sprintf("fetch failed after %d attempts: %v", maxAttempts, lastErr))
}
// emitAddonsTSV decodes the internal-addons response and writes one TSV line per
// staged add-on to w. An empty add-on set writes nothing (the agent then GCs
// every locally-rendered manifest), which is the correct steady state for a
// cluster with no managed add-ons.
func emitAddonsTSV(out io.Writer, body []byte) error {
var resp internalAddonsResponse
if err := json.Unmarshal(body, &resp); err != nil {
return fmt.Errorf("unmarshal internal-addons response: %w", err)
}
w := bufio.NewWriter(out)
for _, a := range resp.Addons {
cfg := base64.StdEncoding.EncodeToString([]byte(a.ConfigurationValues))
if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", a.AddonName, a.AddonVersion, a.ServiceAccountRoleArn, cfg); err != nil {
return err
}
}
return w.Flush()
}
func fatal(msg string) {
slog.Error("eks-gateway-fetch: " + msg)
os.Exit(1)
}
+44
View File
@@ -0,0 +1,44 @@
package main
import (
"bytes"
"encoding/base64"
"testing"
)
func TestEmitAddonsTSV(t *testing.T) {
body := []byte(`{"addons":[
{"addonName":"spinifex-noop","addonVersion":"0.1.0"},
{"addonName":"aws-load-balancer-controller","addonVersion":"2.8.1",
"serviceAccountRoleArn":"arn:aws:iam::111122223333:role/alb",
"configurationValues":"{\"replicaCount\":2}"}
]}`)
var buf bytes.Buffer
if err := emitAddonsTSV(&buf, body); err != nil {
t.Fatalf("emitAddonsTSV: %v", err)
}
cfg := base64.StdEncoding.EncodeToString([]byte(`{"replicaCount":2}`))
want := "spinifex-noop\t0.1.0\t\t\n" +
"aws-load-balancer-controller\t2.8.1\tarn:aws:iam::111122223333:role/alb\t" + cfg + "\n"
if buf.String() != want {
t.Fatalf("TSV mismatch:\n got: %q\nwant: %q", buf.String(), want)
}
}
func TestEmitAddonsTSV_Empty(t *testing.T) {
var buf bytes.Buffer
if err := emitAddonsTSV(&buf, []byte(`{"addons":[]}`)); err != nil {
t.Fatalf("emitAddonsTSV: %v", err)
}
if buf.Len() != 0 {
t.Fatalf("empty addon set must emit nothing, got %q", buf.String())
}
}
func TestEmitAddonsTSV_BadJSON(t *testing.T) {
var buf bytes.Buffer
if err := emitAddonsTSV(&buf, []byte(`{not-json`)); err == nil {
t.Fatal("expected error on malformed JSON")
}
}
+3 -3
View File
@@ -69,7 +69,7 @@ func main() {
flag.StringVar(&region, "region", os.Getenv("EKS_REGION"), "AWS region for SigV4 signing")
flag.StringVar(&accountID, "account-id", os.Getenv("EKS_ACCOUNT_ID"), "Cluster account ID")
flag.StringVar(&clusterName, "cluster", os.Getenv("EKS_CLUSTER_NAME"), "Cluster name")
flag.StringVar(&channel, "channel", "", "Publish channel: bootstrap|state")
flag.StringVar(&channel, "channel", "", "Publish channel: bootstrap|state|addon")
flag.StringVar(&kind, "kind", "", "Bootstrap subject kind (bootstrap channel only)")
flag.Parse()
@@ -78,8 +78,8 @@ func main() {
fatal("--account-id is required (or set EKS_ACCOUNT_ID)")
case clusterName == "":
fatal("--cluster is required (or set EKS_CLUSTER_NAME)")
case channel != "bootstrap" && channel != "state":
fatal("--channel must be bootstrap or state")
case channel != "bootstrap" && channel != "state" && channel != "addon":
fatal("--channel must be bootstrap, state or addon")
case channel == "bootstrap" && kind == "":
fatal("--kind is required for the bootstrap channel")
}
+112 -2
View File
@@ -115,6 +115,16 @@ Each phase waits for all nodes to ACK before proceeding to the next.`,
Run: runClusterShutdown,
}
var clusterDrainDHCPCmd = &cobra.Command{
Use: "drain-dhcp",
Short: "Release all upstream DHCP leases held by vpcd",
Long: `Ask each vpcd to DHCPRELEASE every external-pool DHCP lease it currently
holds, returning them to the upstream DHCP server. Run this on teardown before
stopping services: an env reset otherwise strands held leases on the upstream
server until their TTL expires, eventually exhausting the upstream scope.`,
Run: runClusterDrainDHCP,
}
var adminInitCmd = &cobra.Command{
Use: "init",
Short: "Initialize Spinifex platform configuration",
@@ -245,6 +255,9 @@ func init() {
clusterShutdownCmd.Flags().Duration("timeout", 120*time.Second, "Maximum time to wait per phase")
clusterShutdownCmd.Flags().Bool("dry-run", false, "Print phase plan without executing")
clusterCmd.AddCommand(clusterDrainDHCPCmd)
clusterDrainDHCPCmd.Flags().Duration("timeout", 30*time.Second, "Reply-collection window for vpcd drain responders")
adminCmd.AddCommand(imagesCmd)
imagesCmd.AddCommand(imagesImportCmd)
imagesCmd.AddCommand(imagesListCmd)
@@ -1114,6 +1127,17 @@ func runAdminInit(cmd *cobra.Command, args []string) {
fmt.Println("\n🔐 Generated predastore encryption key (per-node, never transmitted)")
fmt.Printf(" Key: %s\n", predastoreKeyPath)
// Viperblock at-rest encryption key is cluster-wide; on a single-node init
// there are no joiners, so generate it locally and enable encryption by
// default for all volumes created on this install.
viperblockKeyPath, err := writeViperblockEncryptionKey(configDir)
if err != nil {
fmt.Fprintf(os.Stderr, "Error generating viperblock encryption key: %v\n", err)
os.Exit(1)
}
fmt.Println("\n🔐 Generated viperblock at-rest encryption key")
fmt.Printf(" Key: %s\n", viperblockKeyPath)
fmt.Printf("\n🔑 Generated admin credentials (save these — they won't be shown again):\n")
fmt.Printf(" Access Key: %s\n", bootstrapResult.AdminAccessKey)
fmt.Printf(" Secret Key: %s\n", bootstrapResult.AdminSecretKey)
@@ -1301,6 +1325,8 @@ func runAdminInit(cmd *cobra.Command, args []string) {
GPUPassthrough: gpuPassthrough,
IPSecEnabled: ipsecEnabled,
EncryptionKeyFile: viperblockKeyPath,
}
// Print external networking summary
@@ -1409,6 +1435,22 @@ func runAdminInitMultiNode(cmd *cobra.Command, accessKey, secretKey, accountID,
fmt.Println("\n🔐 Generated predastore encryption key (per-node, never transmitted)")
fmt.Printf(" Key: %s\n", predastoreKeyPath)
// Viperblock at-rest encryption key is cluster-wide and shared: the leader
// generates it once and distributes it to joiners via the formation server
// so a volume sealed on any node can be opened on any other.
viperblockKey, err := handlers_iam.GenerateMasterKey()
if err != nil {
fmt.Fprintf(os.Stderr, "❌ Error generating viperblock encryption key: %v\n", err)
os.Exit(1)
}
viperblockKeyPath, err := saveViperblockEncryptionKey(configDir, viperblockKey)
if err != nil {
fmt.Fprintf(os.Stderr, "❌ Error saving viperblock encryption key: %v\n", err)
os.Exit(1)
}
fmt.Println("\n🔐 Generated viperblock at-rest encryption key (cluster-wide, shared with joiners)")
fmt.Printf(" Key: %s\n", viperblockKeyPath)
// Read CA cert/key for distribution to joining nodes
caCertData, err := os.ReadFile(filepath.Join(configDir, "ca.pem"))
if err != nil {
@@ -1437,6 +1479,9 @@ func runAdminInitMultiNode(cmd *cobra.Command, accessKey, secretKey, accountID,
// Include master key in formation server for distribution to joining nodes
fs.SetMasterKey(base64.StdEncoding.EncodeToString(masterKey))
// Distribute the cluster-wide viperblock encryption key to joiners
fs.SetViperblockKey(base64.StdEncoding.EncodeToString(viperblockKey))
// Register self (init node) as the first node
selfNode := formation.NodeInfo{
Name: node,
@@ -1544,6 +1589,8 @@ func runAdminInitMultiNode(cmd *cobra.Command, accessKey, secretKey, accountID,
OperatorEmail: email,
EncryptionKeyFile: viperblockKeyPath,
// Init node runs ovn-central locally
OVNNBAddr: "tcp:127.0.0.1:6641",
OVNSBAddr: "tcp:127.0.0.1:6642",
@@ -1585,7 +1632,7 @@ func runAdminInitMultiNode(cmd *cobra.Command, accessKey, secretKey, accountID,
}
fmt.Println("\n📋 Next steps:")
fmt.Println(" 1. Start services on ALL nodes:")
fmt.Println(" ./scripts/start-dev.sh")
fmt.Println(" sudo systemctl start spinifex.target")
fmt.Println()
}
@@ -1889,6 +1936,28 @@ func runAdminJoin(cmd *cobra.Command, args []string) {
}
fmt.Printf("✅ Predastore encryption key generated: %s\n", predastoreKeyPath)
// Viperblock at-rest encryption key is cluster-wide: receive it from the
// leader rather than generating one, so this node can open volumes sealed
// elsewhere. Lenient on absence — an older leader that predates key
// distribution returns nothing; fall back to cleartext rather than fail
// the join.
var viperblockKeyPath string
if statusResp.ViperblockKey == "" {
fmt.Println("⚠️ Leader did not provide a viperblock encryption key; at-rest encryption disabled on this node")
} else {
viperblockKeyBytes, err := base64.StdEncoding.DecodeString(statusResp.ViperblockKey)
if err != nil {
fmt.Fprintf(os.Stderr, "❌ Error decoding viperblock encryption key: %v\n", err)
os.Exit(1)
}
viperblockKeyPath, err = saveViperblockEncryptionKey(configDir, viperblockKeyBytes)
if err != nil {
fmt.Fprintf(os.Stderr, "❌ Error saving viperblock encryption key: %v\n", err)
os.Exit(1)
}
fmt.Printf("✅ Viperblock encryption key received from leader: %s\n", viperblockKeyPath)
}
// Generate server cert signed by CA with this node's bind IP
if err := admin.GenerateServerCertOnly(configDir, bindIP); err != nil {
fmt.Fprintf(os.Stderr, "Error generating server certificate: %v\n", err)
@@ -1980,6 +2049,8 @@ func runAdminJoin(cmd *cobra.Command, args []string) {
OperatorEmail: email,
EncryptionKeyFile: viperblockKeyPath,
// Joining nodes connect to the init node's OVN NB/SB DB
OVNNBAddr: fmt.Sprintf("tcp:%s:6641", leaderIP),
OVNSBAddr: fmt.Sprintf("tcp:%s:6642", leaderIP),
@@ -2265,6 +2336,7 @@ func runCertRenew(cmd *cobra.Command, _ []string) {
type configDirs struct {
AWSGW string
Predastore string
Viperblock string
NATS string
Spinifex string
}
@@ -2274,10 +2346,11 @@ func createConfigSubdirs(configDir string) (configDirs, error) {
dirs := configDirs{
AWSGW: filepath.Join(configDir, "awsgw"),
Predastore: filepath.Join(configDir, "predastore"),
Viperblock: filepath.Join(configDir, "viperblock"),
NATS: filepath.Join(configDir, "nats"),
Spinifex: filepath.Join(configDir, "spinifex"),
}
for _, dir := range []string{dirs.AWSGW, dirs.Predastore, dirs.NATS, dirs.Spinifex} {
for _, dir := range []string{dirs.AWSGW, dirs.Predastore, dirs.Viperblock, dirs.NATS, dirs.Spinifex} {
if err := os.MkdirAll(dir, 0700); err != nil {
return configDirs{}, fmt.Errorf("create directory %s: %w", dir, err)
}
@@ -2399,6 +2472,43 @@ func writePredastoreEncryptionKey(configDir string) (string, error) {
return keyPath, nil
}
// writeViperblockEncryptionKey generates a fresh 32-byte AES-256 master key for
// viperblock at-rest encryption and writes it to
// <configDir>/viperblock/encryption.key with mode 0600. Unlike the predastore
// key, this key is cluster-wide and shared: the leader generates it once at init
// and distributes it to joiners via the formation server, so a volume sealed on
// any node can be opened on any other. Loaded at startup via masterkey.LoadShared.
func writeViperblockEncryptionKey(configDir string) (string, error) {
viperblockDir := filepath.Join(configDir, "viperblock")
if err := os.MkdirAll(viperblockDir, 0750); err != nil {
return "", fmt.Errorf("create viperblock config dir: %w", err)
}
key, err := handlers_iam.GenerateMasterKey()
if err != nil {
return "", fmt.Errorf("generate viperblock encryption key: %w", err)
}
keyPath := filepath.Join(viperblockDir, "encryption.key")
if err := handlers_iam.SaveMasterKey(keyPath, key); err != nil {
return "", fmt.Errorf("save viperblock encryption key: %w", err)
}
return keyPath, nil
}
// saveViperblockEncryptionKey writes an already-generated 32-byte viperblock
// master key (received from the formation leader) to
// <configDir>/viperblock/encryption.key with mode 0600.
func saveViperblockEncryptionKey(configDir string, key []byte) (string, error) {
viperblockDir := filepath.Join(configDir, "viperblock")
if err := os.MkdirAll(viperblockDir, 0750); err != nil {
return "", fmt.Errorf("create viperblock config dir: %w", err)
}
keyPath := filepath.Join(viperblockDir, "encryption.key")
if err := handlers_iam.SaveMasterKey(keyPath, key); err != nil {
return "", fmt.Errorf("save viperblock encryption key: %w", err)
}
return keyPath, nil
}
// writeSystemCredentials writes the system access key to a plaintext JSON file.
// The daemon reads this at startup to inject credentials into ALB VM cloud-init
// for SigV4-authenticated communication with the AWS gateway.
+68
View File
@@ -252,6 +252,74 @@ func TestWritePredastoreEncryptionKey(t *testing.T) {
assert.NotEqual(t, key1, key2, "per-node keys must differ")
}
// The viperblock key gates at-rest encryption for every volume on a fresh
// install. masterkey.LoadShared rejects anything that isn't exactly 32 bytes at
// 0640-or-stricter, so a regression in size/mode would silently fall back to
// cleartext (empty path) or fail service startup.
func TestWriteViperblockEncryptionKey(t *testing.T) {
configDir := t.TempDir()
keyPath, err := writeViperblockEncryptionKey(configDir)
require.NoError(t, err)
assert.Equal(t, filepath.Join(configDir, "viperblock", "encryption.key"), keyPath)
info, err := os.Stat(keyPath)
require.NoError(t, err)
assert.Equal(t, int64(32), info.Size(), "viperblock master key must be exactly 32 bytes")
assert.Equal(t, os.FileMode(0600), info.Mode().Perm(), "viperblock master key must be mode 0600")
}
// Joiners persist the leader's shared key via saveViperblockEncryptionKey
// against a configDir with no viperblock/ subdir yet. The bytes round-trip
// verbatim — the whole cluster shares one key, so any mutation would orphan
// volumes sealed on other nodes.
func TestSaveViperblockEncryptionKey_RoundTrip(t *testing.T) {
configDir := t.TempDir()
key := make([]byte, 32)
for i := range key {
key[i] = byte(i)
}
keyPath, err := saveViperblockEncryptionKey(configDir, key)
require.NoError(t, err)
assert.Equal(t, filepath.Join(configDir, "viperblock", "encryption.key"), keyPath)
got, err := os.ReadFile(keyPath)
require.NoError(t, err)
assert.Equal(t, key, got, "shared key must be written verbatim")
}
// A fresh install must render encryption_key_file so viperblockd enables
// at-rest encryption; an empty field (upgrade / legacy) must omit the line
// entirely so the volume stays in cleartext legacy mode.
func TestSpinifexTomlTemplate_EncryptionKeyFile(t *testing.T) {
base := admin.ConfigSettings{
Node: "node1", Az: "ap-southeast-2a", Port: "4432", Region: "ap-southeast-2",
BindIP: "0.0.0.0", AccessKey: "AKIATEST", SecretKey: "SECRET",
AccountID: "123456789012", NatsToken: "token",
OVNNBAddr: "tcp:127.0.0.1:6641", OVNSBAddr: "tcp:127.0.0.1:6642",
}
dir := t.TempDir()
withKey := base
withKey.ConfigDir = dir
withKey.EncryptionKeyFile = "/etc/spinifex/viperblock/encryption.key"
path := filepath.Join(dir, "spinifex.toml")
require.NoError(t, admin.GenerateConfigFile(path, spinifexTomlTemplate, withKey))
data, err := os.ReadFile(path)
require.NoError(t, err)
assert.Contains(t, string(data), `encryption_key_file = "/etc/spinifex/viperblock/encryption.key"`)
dir2 := t.TempDir()
noKey := base
noKey.ConfigDir = dir2
path2 := filepath.Join(dir2, "spinifex.toml")
require.NoError(t, admin.GenerateConfigFile(path2, spinifexTomlTemplate, noKey))
data2, err := os.ReadFile(path2)
require.NoError(t, err)
assert.NotContains(t, string(data2), "encryption_key_file", "blank key must omit the field")
}
// 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
+74
View File
@@ -8,6 +8,7 @@ import (
"time"
"github.com/mulgadc/spinifex/spinifex/daemon"
"github.com/mulgadc/spinifex/spinifex/network/external/dhcp"
"github.com/nats-io/nats.go"
"github.com/spf13/cobra"
)
@@ -160,6 +161,79 @@ func runClusterShutdown(cmd *cobra.Command, args []string) {
fmt.Printf("Cluster shutdown complete (%s)\n", time.Since(start).Round(time.Millisecond))
}
// runClusterDrainDHCP asks every vpcd to DHCPRELEASE all external-pool leases
// it currently holds, returning them to the upstream DHCP server. Intended for
// the teardown path: an env reset otherwise strands held leases until TTL
// because vpcd's Stop() preserves them for adopt-on-reboot. Best-effort — if
// the cluster is already partly down it warns and exits 0 so teardown proceeds.
func runClusterDrainDHCP(cmd *cobra.Command, args []string) {
timeout, _ := cmd.Flags().GetDuration("timeout")
if timeout <= 0 {
timeout = 30 * time.Second
}
_, nc, err := loadConfigAndConnect()
if err != nil {
fmt.Fprintf(os.Stderr, "Warning: drain-dhcp could not connect (cluster may be down): %v\n", err)
return
}
defer nc.Close()
total, responders := drainDHCPLeases(nc, timeout)
fmt.Printf("DHCP drain complete: released %d lease(s) from %d vpcd responder(s)\n", total, responders)
}
// drainDHCPLeases publishes a drain request to vpc.dhcp.drain and collects
// replies until timeout — one responder per AZ (vpcd uses a per-AZ queue
// group). Returns the summed released count and the number of responders.
func drainDHCPLeases(nc *nats.Conn, timeout time.Duration) (released, responders int) {
reqData, err := json.Marshal(struct{}{})
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
return 0, 0
}
inbox := nats.NewInbox()
sub, err := nc.SubscribeSync(inbox)
if err != nil {
fmt.Fprintf(os.Stderr, "Warning: drain-dhcp subscribe failed: %v\n", err)
return 0, 0
}
defer sub.Unsubscribe()
if err := nc.PublishRequest(dhcp.TopicDrain, inbox, reqData); err != nil {
fmt.Fprintf(os.Stderr, "Warning: drain-dhcp publish failed: %v\n", err)
return 0, 0
}
nc.Flush()
deadline := time.Now().Add(timeout)
for {
remaining := time.Until(deadline)
if remaining <= 0 {
break
}
msg, err := sub.NextMsg(remaining)
if err != nil {
break
}
var reply struct {
Released int `json:"released"`
Error string `json:"error,omitempty"`
}
if err := json.Unmarshal(msg.Data, &reply); err != nil {
continue
}
responders++
if reply.Error != "" {
fmt.Fprintf(os.Stderr, " vpcd drain error: %s\n", reply.Error)
continue
}
released += reply.Released
}
return released, responders
}
// collectShutdownACKs publishes a shutdown request and collects ACKs from nodes.
func collectShutdownACKs(nc *nats.Conn, topic string, reqData []byte, nodeCount int, timeout time.Duration) ([]daemon.ShutdownACK, error) {
inbox := nats.NewInbox()
+58
View File
@@ -0,0 +1,58 @@
package cmd
import (
"encoding/json"
"testing"
"time"
"github.com/mulgadc/spinifex/spinifex/network/external/dhcp"
"github.com/mulgadc/spinifex/spinifex/testutil"
"github.com/nats-io/nats.go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// fakeDrainResponder subscribes a stand-in vpcd to vpc.dhcp.drain that
// replies with the given released count (or error), mirroring
// Manager.handleDrainMsg without a real lease store.
func fakeDrainResponder(t *testing.T, nc *nats.Conn, released int, errMsg string) {
t.Helper()
sub, err := nc.Subscribe(dhcp.TopicDrain, func(msg *nats.Msg) {
body, _ := json.Marshal(struct {
Released int `json:"released"`
Error string `json:"error,omitempty"`
}{Released: released, Error: errMsg})
_ = msg.Respond(body)
})
require.NoError(t, err)
t.Cleanup(func() { _ = sub.Unsubscribe() })
}
func TestDrainDHCPLeasesSumsReleased(t *testing.T) {
_, nc, _ := testutil.StartTestJetStream(t)
fakeDrainResponder(t, nc, 7, "")
released, responders := drainDHCPLeases(nc, 2*time.Second)
assert.Equal(t, 7, released)
assert.Equal(t, 1, responders)
}
func TestDrainDHCPLeasesReportsResponderError(t *testing.T) {
_, nc, _ := testutil.StartTestJetStream(t)
fakeDrainResponder(t, nc, 0, "list leases for drain: boom")
// An erroring responder still counts as a responder but contributes
// zero released leases.
released, responders := drainDHCPLeases(nc, 2*time.Second)
assert.Equal(t, 0, released)
assert.Equal(t, 1, responders)
}
func TestDrainDHCPLeasesNoResponders(t *testing.T) {
_, nc, _ := testutil.StartTestJetStream(t)
// No vpcd subscribed: the collection window elapses with zero replies.
released, responders := drainDHCPLeases(nc, 200*time.Millisecond)
assert.Equal(t, 0, released)
assert.Equal(t, 0, responders)
}
+3
View File
@@ -93,6 +93,9 @@ token = "{{.NatsToken}}"
[nodes.{{.Node}}.viperblock]
shardwal = false
{{- if .EncryptionKeyFile}}
encryption_key_file = "{{.EncryptionKeyFile}}"
{{- end}}
[nodes.{{.Node}}.vpcd]
ovn_nb_addr = "{{.OVNNBAddr}}"
+99 -16
View File
@@ -65,6 +65,7 @@ Operational commands for inspecting cluster state. These fan out NATS requests t
| Command | Flags | Description |
|---------|-------|-------------|
| `spx admin cluster shutdown` | `--force` (shutdown even if nodes don't respond), `--timeout` (max wait per phase, default 120s), `--dry-run` (print phase plan without executing) | Performs coordinated, phased shutdown of entire cluster. Phases execute in order: GATE (stop API/UI) → DRAIN (stop VMs) → STORAGE (stop viperblock) → PERSIST (stop predastore) → INFRA (stop NATS/daemon). Each phase waits for all nodes to ACK before proceeding. Uses JetStream state tracking. |
| `spx admin cluster drain-dhcp` | `--timeout` (reply-collection window, default 30s) | Asks each vpcd to DHCPRELEASE every external-pool DHCP lease it currently holds, returning them to the upstream DHCP server. Run on teardown before stopping services — an env reset otherwise strands held leases upstream until their TTL expires, eventually exhausting the upstream scope. Best-effort: warns and exits 0 if the cluster is already down. |
### Certificate Management
@@ -183,7 +184,7 @@ input but treat it as `uefi` at launch.
| `attach-volume` | `--volume-id`, `--instance-id`, `--device` (auto-assigns `/dev/sd[f-p]`) | `--dry-run` | **DONE** |
| `detach-volume` | `--volume-id`, `--instance-id` (optional), `--device`, `--force` | `--dry-run` | **DONE** |
| `describe-volume-status` | `--volume-ids`, `--filters` (volume-id, volume-status.status, availability-zone) | `--max-results`, `--next-token`, `--dry-run` | **DONE** |
| `describe-volumes-modifications` | — | `--volume-ids`, `--filters`, `--max-results` | **NOT STARTED** |
| `describe-volumes-modifications` | `--volume-ids`, `--filters` (modification-state, original-iops, original-size, original-volume-type, start-time, target-iops, target-size, target-volume-type, volume-id) | `--max-results`, `--next-token`, `--dry-run` | **DONE** |
### EC2 — Snapshots
@@ -488,6 +489,7 @@ bypasses policy evaluation entirely.
| `create-policy` | `--policy-name`, `--policy-document`, `--path`, `--description` | `--tags` | **DONE** |
| `get-policy` | `--policy-arn` | — | **DONE** |
| `get-policy-version` | `--policy-arn`, `--version-id` | — | **DONE** |
| `list-policy-versions` | `--policy-arn` | `--max-items`, `--marker` | **DONE** |
| `list-policies` | — | `--scope`, `--only-attached`, `--path-prefix`, `--max-items`, `--marker` | **DONE** |
| `delete-policy` | `--policy-arn` | — | **DONE** |
| `attach-user-policy` | `--user-name`, `--policy-arn` | — | **DONE** |
@@ -507,6 +509,8 @@ bypasses policy evaluation entirely.
| `attach-role-policy` | `--role-name`, `--policy-arn` | — | **DONE** |
| `detach-role-policy` | `--role-name`, `--policy-arn` | — | **DONE** |
| `list-attached-role-policies` | `--role-name`, `--path-prefix` | `--max-items`, `--marker` | **DONE** |
| `list-role-policies` | `--role-name` | `--max-items`, `--marker` | **DONE** (gateway stub — inline policies unsupported, always returns empty) |
| `get-role-policy` / `put-role-policy` / `delete-role-policy` | — | inline role policies | **NOT STARTED** |
### IAM — Instance Profiles
@@ -520,6 +524,19 @@ bypasses policy evaluation entirely.
| `add-role-to-instance-profile` | `--instance-profile-name`, `--role-name` | — | **DONE** |
| `remove-role-from-instance-profile` | `--instance-profile-name`, `--role-name` | — | **DONE** |
### IAM — OIDC Providers
| Command | Implemented Flags | Missing Flags | Status |
|---------|-------------------|---------------|--------|
| `create-open-id-connect-provider` | `--url`, `--client-id-list`, `--thumbprint-list`, `--tags` | — | **DONE** |
| `get-open-id-connect-provider` | `--open-id-connect-provider-arn` | — | **DONE** |
| `list-open-id-connect-providers` | — | — | **DONE** |
| `delete-open-id-connect-provider` | `--open-id-connect-provider-arn` | — | **DONE** |
| `add-client-id-to-open-id-connect-provider` | — | `--open-id-connect-provider-arn`, `--client-id` | **NOT STARTED** |
| `remove-client-id-from-open-id-connect-provider` | — | `--open-id-connect-provider-arn`, `--client-id` | **NOT STARTED** |
| `update-open-id-connect-provider-thumbprint` | — | `--open-id-connect-provider-arn`, `--thumbprint-list` | **NOT STARTED** |
| `tag-open-id-connect-provider` / `untag-open-id-connect-provider` | — | `--open-id-connect-provider-arn`, `--tags`/`--tag-keys` | **NOT STARTED** |
### IAM — Groups
| Command | Implemented Flags | Missing Flags | Status |
@@ -544,16 +561,13 @@ bypasses policy evaluation entirely.
| `get-caller-identity` | — | — | **DONE** |
| `assume-role` | `--role-arn`, `--role-session-name`, `--duration-seconds` (900–min(role MaxSessionDuration, 43200)) | `--policy`, `--policy-arns` (→ `PackedPolicyTooLarge`); `--tags`, `--transitive-tag-keys` (→ `InvalidParameterValue`); `--serial-number`, `--token-code` (→ `InvalidParameterValue`); `--external-id`, `--source-identity` (accepted and logged, **not enforced** — no Condition evaluator in v1) | **DONE** |
| `get-session-token` | `--duration-seconds` (900–129600, default 43200 = 12h; clamped, not rejected) | `--serial-number`, `--token-code` (MFA → `InvalidParameterValue`) | **DONE** |
| `assume-role-with-web-identity` | — | `--role-arn`, `--role-session-name`, `--web-identity-token`, `--provider-id`, `--policy`, `--policy-arns`, `--duration-seconds` | **NOT STARTED** |
| `assume-role-with-web-identity` | `--role-arn`, `--role-session-name`, `--web-identity-token`, `--duration-seconds` (900–43200, default 3600) | `--provider-id`; `--policy`, `--policy-arns` (→ `PackedPolicyTooLarge`) | **DONE** |
| `assume-role-with-saml` | — | `--role-arn`, `--principal-arn`, `--saml-assertion`, `--policy`, `--policy-arns`, `--duration-seconds` | **NOT STARTED** |
| `get-access-key-info` | — | `--access-key-id` | **NOT STARTED** |
| `get-federation-token` | — | `--name`, `--policy`, `--policy-arns`, `--duration-seconds`, `--tags` | **NOT STARTED** |
| `decode-authorization-message` | — | `--encoded-message` | **NOT STARTED** |
Trust policies stored on roles (`AssumeRolePolicyDocument`) reject `Condition`,
`NotPrincipal`, `NotAction`, empty-string `Action` elements, and empty
`Principal` blocks at write time (`MalformedPolicyDocument`) — v1 has no
Condition evaluator so accepting them would silently allow.
Trust policies stored on roles (`AssumeRolePolicyDocument`) reject `Condition`, `NotPrincipal`, `NotAction`, empty-string `Action` elements, and empty `Principal` blocks at write time (`MalformedPolicyDocument`) — v1 has no Condition evaluator so accepting them would silently allow.
---
@@ -593,10 +607,21 @@ curl -i http://169.254.169.254/latest/meta-data/instance-id
| `/latest/meta-data/iam/info` | GET | `{InstanceProfileArn, InstanceProfileId}`; 404 if no profile | **DONE** |
| `/latest/meta-data/iam/security-credentials/` | GET | Role name(s) under the profile, one per line; empty body if none | **DONE** |
| `/latest/meta-data/iam/security-credentials/<role>` | GET | STS `AssumeRoleForInstance` → ASIA-prefixed temporary credential JSON | **DONE** |
| `/latest/meta-data/public-keys/` | GET | `0=<keyName>` from the launch key pair; 404 if none | **DONE** |
| `/latest/meta-data/public-keys/0/` | GET | `openssh-key` (format list for index 0) | **DONE** |
| `/latest/meta-data/public-keys/0/openssh-key` | GET | Launch SSH public key, live-fetched from the key store; 404 if the key was deleted, 500 on backend fault | **DONE** |
| `/latest/user-data` | GET | `vm.UserData`; 404 if none | **DONE** |
| `/latest/dynamic/instance-identity/document` | GET | Needs a per-cluster signing key | **NOT STARTED** (404; lands with EKS IRSA) |
| `/latest/meta-data/network/interfaces/...` | GET | Multi-ENI rendering | **NOT STARTED** (404) |
| `/latest/meta-data/tags/...` | GET | Instance-tag metadata | **NOT STARTED** (404) |
| `/latest/dynamic/instance-identity/document` (+ `signature`, `pkcs7`, `rsa2048`) | GET | Needs a per-cluster signing key | **NOT STARTED** (404; lands with EKS IRSA) |
| `/latest/meta-data/network/interfaces/macs/<mac>/...` | GET | Multi-ENI rendering (subnet-id, vpc-id, ipv4s, security-group-ids, …) | **NOT STARTED** (404) |
| `/latest/meta-data/tags/instance/<key>` | GET | Instance-tag metadata; gated on `InstanceMetadataTags` enablement | **NOT STARTED** (404) |
| `/latest/meta-data/public-hostname` | GET | No public DNS today; would mirror `public-ipv4` | **NOT STARTED** (404) |
| `/latest/meta-data/reservation-id` | GET | Available on the stored `Reservation`; cheap, not yet surfaced | **NOT STARTED** (404) |
| `/latest/meta-data/ami-launch-index` | GET | `0` for single-instance launches; trivial once multi-count is exercised | **NOT STARTED** (404) |
| `/latest/meta-data/block-device-mapping/...` | GET | `ami`/`root`/`ebsN`/`ephemeralN` device map | **NOT STARTED** (404) |
| `/latest/meta-data/placement/{group-name,partition-number,availability-zone-id,host-id}` | GET | Placement extras beyond `availability-zone`/`region` | **NOT STARTED** (404) |
| `/latest/meta-data/instance-life-cycle`, `/instance-action` | GET | `on-demand`/`spot`; `none` unless interruptible instances ship | **NOT STARTED** (404) |
| `/latest/meta-data/services/{domain,partition}` | GET | Static per-deployment values | **NOT STARTED** (404) |
| `/latest/meta-data/spot/{instance-action,termination-time}` | GET | Only meaningful once Spot is modelled | **NOT STARTED** (404) |
---
@@ -632,10 +657,54 @@ The data plane uses a system-managed LB VM, launched automatically during `creat
| Command | Implemented Flags | Missing Flags | Status |
|---------|-------------------|---------------|--------|
| `create-listener` | `--load-balancer-arn`, `--default-actions` (Type=forward, TargetGroupArn), `--protocol` (HTTP), `--port` | `--ssl-policy`, `--certificates`, `--alpn-policy`, `--mutual-authentication`, `--dry-run` | **DONE** |
| `create-listener` | `--load-balancer-arn`, `--default-actions` (Type=forward, TargetGroupArn), `--protocol` (HTTP/HTTPS/TLS), `--port`, `--certificates` (HTTPS/TLS), `--ssl-policy` | `--alpn-policy`, `--mutual-authentication`, `--dry-run` | **DONE** |
| `modify-listener` | `--listener-arn`, `--protocol`, `--port`, `--default-actions`, `--certificates`, `--ssl-policy` | `--alpn-policy`, `--mutual-authentication`, `--dry-run` | **DONE** |
| `delete-listener` | `--listener-arn` | `--dry-run` | **DONE** |
| `describe-listeners` | `--load-balancer-arn`, `--listener-arns` | `--page-size`, `--marker`, `--dry-run` | **DONE** |
### ELBv2 — Listener Rules
| Command | Implemented Flags | Missing Flags | Status |
|---------|-------------------|---------------|--------|
| `create-rule` | `--listener-arn`, `--priority` (1–50000), `--conditions` (host-header, path-pattern, http-header, http-request-method, query-string, source-ip), `--actions` (forward, redirect, fixed-response), `--tags` | `--dry-run` | **DONE** |
| `modify-rule` | `--rule-arn`, `--conditions`, `--actions` | `--dry-run` | **DONE** |
| `delete-rule` | `--rule-arn` | `--dry-run` | **DONE** |
| `describe-rules` | `--listener-arn`, `--rule-arns` | `--page-size`, `--marker` (parsed, not enforced) | **DONE** |
| `set-rule-priorities` | `--rule-priorities` (RuleArn, Priority) | — | **DONE** |
A synthetic `default` rule is derived from the listener's `DefaultActions`.
### ELBv2 — Listener Certificates & SSL Policies
| Command | Implemented Flags | Missing Flags | Status |
|---------|-------------------|---------------|--------|
| `add-listener-certificates` | `--listener-arn`, `--certificates` | `--dry-run` | **DONE** |
| `remove-listener-certificates` | `--listener-arn`, `--certificates` | `--dry-run` | **DONE** |
| `describe-listener-certificates` | `--listener-arn` | `--page-size`, `--marker` | **DONE** |
| `describe-ssl-policies` | `--names` | `--load-balancer-type`, `--page-size`, `--marker` | **DONE** (static catalog: `ELBSecurityPolicy-FS-1-2-Res-2019-08`, `ELBSecurityPolicy-TLS13-1-2-2021-06` — metadata only, no in-platform TLS termination) |
The default certificate cannot be added/removed via these calls — set it on the listener.
### ELBv2 — Attributes & Modify
| Command | Implemented Flags | Missing Flags | Status |
|---------|-------------------|---------------|--------|
| `describe-load-balancer-attributes` | `--load-balancer-arn` | — | **DONE** (stored values over per-type defaults) |
| `modify-load-balancer-attributes` | `--load-balancer-arn`, `--attributes` | — | **DONE** (unknown keys → `ValidationError`) |
| `describe-target-group-attributes` | `--target-group-arn` | — | **DONE** |
| `modify-target-group-attributes` | `--target-group-arn`, `--attributes` (incl. `deregistration_delay.timeout_seconds`, `stickiness.*`) | — | **DONE** |
| `modify-target-group` | `--target-group-arn`, `--health-check-*`, `--matcher` | `--target-type`/`--protocol`/`--vpc-id` (immutable) | **DONE** |
| `describe-listener-attributes` | `--listener-arn` | — | **DONE** (stub — returns empty; not persisted) |
| `modify-listener-attributes` | `--listener-arn`, `--attributes` | — | **DONE** (stub — echoes input; not persisted) |
### ELBv2 — Network & Security
| Command | Implemented Flags | Missing Flags | Status |
|---------|-------------------|---------------|--------|
| `set-security-groups` | `--load-balancer-arn`, `--security-groups` | `--enforce-security-group-inbound-rules-on-private-link-traffic` | **DONE** (ALB only — NLB → `InvalidConfigurationRequest`) |
| `set-subnets` | `--load-balancer-arn`, `--subnets`, `--subnet-mappings` | `--ip-address-type` | **DONE** (live ENI add/remove with rollback) |
| `set-ip-address-type` | `--load-balancer-arn`, `--ip-address-type` (ipv4) | dualstack/IPv6 (rejected) | **DONE** |
### ELBv2 — Tags
| Command | Implemented Flags | Missing Flags | Status |
@@ -646,18 +715,32 @@ The data plane uses a system-managed LB VM, launched automatically during `creat
| Feature | Priority | Status |
|---------|----------|--------|
| Listener rules (CreateRule, DeleteRule, DescribeRules, ModifyRule) | High | **NOT STARTED** |
| HTTPS/TLS termination (SSL certificates, policies, ALPN) | High | **NOT STARTED** |
| Modify operations (`ModifyLoadBalancerAttributes`, `ModifyTargetGroup`, `ModifyTargetGroupAttributes`, `ModifyListener`) | Medium | **NOT STARTED** |
| Connection draining (deregistration delay) | Medium | **NOT STARTED** |
| Stickiness / session affinity | Medium | **NOT STARTED** |
| Active health checking (API-driven, vs. HAProxy-only today) | Medium | **NOT STARTED** |
| In-platform HTTPS/TLS termination (cert + SSL-policy APIs exist; data-plane TLS not terminated) | High | **NOT STARTED** |
| ALPN policy, mutual TLS (mTLS) | Medium | **NOT STARTED** |
| Listener attribute persistence (`Describe/ModifyListenerAttributes` are stubs) | Medium | **NOT STARTED** |
| Active health checking (API-driven, vs. HAProxy/nginx-only today) | Medium | **NOT STARTED** |
| IP and Lambda target types | Low | **NOT STARTED** |
| S3 access log delivery | Low | **NOT STARTED** |
| WAF integration | Low | **NOT STARTED** |
---
## ACM (AWS Certificate Manager)
Import-only — Spinifex stores externally-issued certificates for ELBv2 listener references; it does not issue certificates or validate domains (`RequestCertificate`). Certs are account-scoped; `describe`/`delete` enforce ownership.
| Command | Implemented Flags | Missing Flags | Status |
|---------|-------------------|---------------|--------|
| `import-certificate` | `--certificate`, `--private-key`, `--certificate-chain`, `--certificate-arn` (re-import) | `--tags` | **DONE** |
| `describe-certificate` | `--certificate-arn` | — | **DONE** |
| `list-certificates` | — | `--certificate-statuses`, `--includes`, `--max-items`, `--next-token` | **DONE** |
| `delete-certificate` | `--certificate-arn` | — | **DONE** |
| `request-certificate` | — | `--domain-name`, `--validation-method`, `--subject-alternative-names`, `--tags` | **NOT STARTED** |
| `add-tags-to-certificate` / `list-tags-for-certificate` / `remove-tags-from-certificate` | — | `--certificate-arn`, `--tags`/`--tag-keys` | **NOT STARTED** |
| `export-certificate` | — | `--certificate-arn`, `--passphrase` | **NOT STARTED** |
---
## CloudWatch (Basic Monitoring)
| Command | Implemented Flags | Missing Flags | Status |
@@ -26,6 +26,8 @@ sections:
## Overview
Spinifex is an open-source infrastructure platform that brings core AWS services including EC2, EBS and S3 to bare-metal, edge, and on-prem environments. It exposes an EC2-compatible API, so any tooling that works against AWS (the `aws` CLI, Terraform, SDKs) works against a Spinifex node unchanged, with a single profile swap.
This document serves as a tutorial for setting up multi-tenant AI workloads with Spinifex. The reference architecture for this tutorial is the Supermicro H14 platform - specifications listed below.
The H14 chassis comes equipped with **8× AMD Instinct MI350X** (288 GB HBM3e each, 2.3 TB total). We used Spinifex to provision three isolated VMs, each assigned 2× MI350X via direct PCIe passthrough, and ran three computationally intensive workloads simultaneously: a YOLO11x vision model, a Qwen3-VL 235B FP8 vision language model, and a Llama 3.3 70B large language model.
@@ -31,6 +31,8 @@ sections:
## Overview
Spinifex is an open-source infrastructure platform that brings core AWS services including EC2, EBS and S3 to bare-metal, edge, and on-prem environments. It exposes an EC2-compatible API, so any tooling that works against AWS (the `aws` CLI, Terraform, SDKs) works against a Spinifex node unchanged, with a single profile swap.
### About Trinity
[Trinity](https://www.arcee.ai/trinity) is a family of open-weight language models from [Arcee AI](https://www.arcee.ai), built specifically for production agentic workloads: reliable tool calling, structured JSON output, coherent multi-turn conversations, and long-context reasoning. The family spans four sizes — Nano (6B), Mini (26B), Large Preview (400B), and Large Thinking (400B reasoning-optimised), with consistent capabilities and API surface across all of them. A workflow validated on Nano can be promoted to Large with no prompt changes.
+10
View File
@@ -84,6 +84,12 @@ services:
- "spinifex.node.vms"
- "spinifex.nodes.discover"
- "spinifex.cluster.shutdown.progress"
# Coordinated cluster-shutdown phases (fan-out, no queue group).
- "spinifex.cluster.shutdown.gate"
- "spinifex.cluster.shutdown.drain"
- "spinifex.cluster.shutdown.storage"
- "spinifex.cluster.shutdown.persist"
- "spinifex.cluster.shutdown.infra"
- "spinifex.storage.config"
# ELBv2 control plane (handlers/elbv2/service_nats.go topic map).
- "elbv2.CreateLoadBalancer"
@@ -133,6 +139,8 @@ services:
- "ebs.delete"
- "ebs.sync"
- "ebs.snapshot.*"
- "ebs.config"
- "ebs.config.*"
# → predastore (S3 / AMI / keypair store)
- "s3.*"
# IAM lifecycle event
@@ -181,6 +189,8 @@ services:
- "ebs.delete"
- "ebs.sync"
- "ebs.snapshot.*"
- "ebs.config"
- "ebs.config.*"
publishes:
- "ebs.mount.response"
- "ebs.unmount.response"
@@ -0,0 +1,181 @@
---
title: "EKS with Addons + Access-Entry RBAC"
description: "Extend a single-node EKS cluster with a managed CoreDNS addon, access-entry RBAC, and a browsable load-balanced demo app, using Terraform on Spinifex."
category: "Terraform Workbooks"
tags:
- terraform
- eks
- kubernetes
- addons
- rbac
- iam
- workbook
resources:
- title: "Terraform AWS Provider"
url: "https://registry.terraform.io/providers/hashicorp/aws/latest"
- title: "Terraform Kubernetes Provider"
url: "https://registry.terraform.io/providers/hashicorp/kubernetes/latest"
- title: "Spinifex Repository"
url: "https://github.com/mulgadc/spinifex"
- title: "OpenTofu"
url: "https://opentofu.org/"
---
# Terraform: EKS with Addons + Access-Entry RBAC
> Builds on the quickstart by installing a managed addon (CoreDNS) and granting a second IAM principal read-only cluster access — then deploys the same browsable, load-balanced demo app so you can see the result.
## Table of Contents
- [Overview](#overview)
- [Instructions](#instructions)
- [Troubleshooting](#troubleshooting)
---
## Overview
This workbook takes the same single-node cluster (and the same browsable demo app) from [EKS Quickstart](../eks-quickstart) and adds the two things you reach for on almost any real cluster:
1. **A managed addon.** CoreDNS is installed through the EKS API (`aws_eks_addon`) rather than `kubectl apply`, so its lifecycle is managed by the cluster. The demo app is deployed *after* CoreDNS, so a healthy page also confirms in-cluster DNS is working.
2. **Access-entry RBAC.** A second IAM role is granted read-only access via an `aws_eks_access_entry` plus an `aws_eks_access_policy_association` bound to the AWS-managed `AmazonEKSViewPolicy`. This is the modern access-entry path (`authentication_mode = "API"`) — no `aws-auth` ConfigMap.
When `apply` finishes, open the **`demo_url`** output and refresh — the `nginxdemos/hello` page reports which pod served each request.
**What you'll learn:**
- Installing a managed EKS addon with `aws_eks_addon`
- Registering an IAM principal with `aws_eks_access_entry` and scoping it with `aws_eks_access_policy_association`
- The difference between the cluster creator's admin access and a scoped viewer
- Deploying a workload with the Kubernetes provider once the addon is healthy
**What gets created**
| Resource | Name | Purpose |
|---|---|---|
| VPC | `eks-addons-vpc` | Isolated network (10.31.0.0/16) |
| Subnets | `eks-addons-subnet-a/-b` | Public subnets for the cluster and worker |
| Internet Gateway | `eks-addons-igw` | Egress so the worker can pull the demo image |
| IAM Roles | `eks-addons-cluster-role`, `eks-addons-node-role` | Control-plane and worker roles |
| IAM Role | `eks-addons-viewer` | Stand-in principal granted read-only access |
| EKS Cluster | `eks-addons` | Public endpoint, API auth mode |
| Node Group | `default` | One `t3.medium` worker |
| Addon | `coredns` | Cluster DNS, managed via the EKS API |
| Access Entry + Assoc. | `eks-addons-viewer` → `AmazonEKSViewPolicy` | Read-only, cluster scope |
| SG Ingress Rule | `eks-addons-demo-nodeport` | Opens the demo NodePort on the worker SG |
| K8s Deployment + Service | `hello` | `nginxdemos/hello`, 2 replicas, NodePort |
**Spinifex specifics**
- Addon catalog is limited. Spinifex currently serves `coredns`, `aws-load-balancer-controller` and `spinifex-noop`. There is no `vpc-cni`, `kube-proxy`, `ebs-csi` or `metrics-server`.
- **Leave `addon_version` unset.** Spinifex defaults an unset version to its catalog default (`coredns` 1.11.1). Pinning is a dead end: the AWS provider rejects a bare `1.11.1` (it requires the `v`-prefixed `v1.11.1-eksbuild.N` form), while Spinifex's catalog only accepts the bare `1.11.1` — so no single string satisfies both.
- Cluster access policies are the four AWS-managed `cluster-access-policy/Amazon EKS{ClusterAdmin,Admin,Edit,View}Policy` ARNs.
- The worker SG is auto-managed and the worker AMI is always the `eks-node` image; the NodePort is exposed by adding one rule to the looked-up worker SG.
**Prerequisites:**
- Spinifex installed and running
- The Spinifex `eks-node` image available on the cluster
- OpenTofu or Terraform, plus `kubectl` and the AWS CLI
## Instructions
### Step 1. Get the Template
```bash
git clone --depth 1 --filter=blob:none --sparse https://github.com/mulgadc/spinifex.git spinifex-tf
cd spinifex-tf
git sparse-checkout set docs/terraform-workbooks
cd docs/terraform-workbooks/eks-addons-access
```
Or create a `main.tf` file and paste the full configuration below.
<!-- INCLUDE: main.tf lang:hcl -->
### Step 2. Deploy
```bash
export AWS_PROFILE=spinifex
tofu init
tofu apply
```
Allow a few minutes for the cluster to reach `ACTIVE` and the worker to register. CoreDNS installs onto the worker, then Terraform deploys the demo app.
> Keep `AWS_PROFILE=spinifex` exported for the whole `apply` — the Kubernetes provider runs `aws eks get-token` against the Spinifex STS endpoint.
### Step 3. Open the Demo
```bash
tofu output demo_url
```
Open it in a browser and refresh — the `nginxdemos/hello` page shows the pod that answered, alternating between the two replicas.
### Step 4. Verify the Addon and Access Entry
```bash
aws eks list-addons --cluster-name eks-addons
aws eks describe-addon --cluster-name eks-addons --addon-name coredns --query 'addon.status'
aws eks list-access-entries --cluster-name eks-addons
aws eks list-associated-access-policies \
--cluster-name eks-addons \
--principal-arn "$(tofu output -raw viewer_principal_arn)"
```
The viewer principal is bound to `AmazonEKSViewPolicy` at cluster scope — it can read across the cluster but cannot mutate anything. The principal that ran `apply` keeps full cluster-admin from `bootstrap_cluster_creator_admin_permissions`.
### Cleanup
```bash
tofu destroy
```
## Troubleshooting
### Demo URL Doesn't Load
Same chain as the quickstart — check the worker's public IP, that the demo pods are `Running`, and that the NodePort rule landed on the worker SG:
```bash
kubectl get pods -o wide
aws ec2 describe-security-groups --filters "Name=group-name,Values=eks-cluster-eks-addons-nodegroup-sg" \
--query 'SecurityGroups[0].IpPermissions'
```
If the demo pods are stuck `ContainerCreating` with DNS errors, confirm CoreDNS is healthy first:
```bash
kubectl -n kube-system get deploy coredns
```
### Addon Stuck in CREATING / DEGRADED
CoreDNS needs a schedulable worker. Confirm the node group is `ACTIVE` and the node is `Ready`:
```bash
aws eks describe-nodegroup --cluster-name eks-addons --nodegroup-name default --query 'nodegroup.status'
kubectl get nodes
```
### Addon Version Rejected
This workbook leaves `addon_version` unset so Spinifex picks its catalog default. If you add an explicit `addon_version`, expect a wall: the AWS provider requires the `v`-prefixed `v1.11.1-eksbuild.N` form, but Spinifex's catalog only knows the bare `1.11.1` — a value that passes one is rejected by the other. Prefer omitting the field.
### Access Entry Not Taking Effect
Assume the viewer role and confirm it can read but not write:
```bash
kubectl auth can-i list pods --all-namespaces # yes
kubectl auth can-i create deployments # no
```
### Provider Connection Refused
```bash
sudo systemctl status spinifex.target
curl -k https://localhost:9999/
```
@@ -0,0 +1,468 @@
# Example: EKS with an Addon + Access-Entry RBAC (with a visible demo app)
#
# Builds on eks-quickstart: same single-worker cluster and browsable demo app,
# but adds two things you reach for on a real cluster:
#
# * a managed addon (CoreDNS) installed through the EKS API, and
# * a second IAM principal granted scoped, read-only access via an EKS access
# entry + access-policy association (authentication_mode = "API").
#
# After `apply`, open the demo_url output — the page reports the pod that
# served it, so a refresh shows requests landing on different replicas. CoreDNS
# being healthy is what lets in-cluster name resolution work.
#
# Usage:
# cd spinifex/docs/terraform-workbooks/eks-addons-access
# export AWS_PROFILE=spinifex
# tofu init && tofu apply
# # then open the demo_url output
terraform {
required_version = ">= 1.6.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.40, < 6.0"
}
kubernetes = {
source = "hashicorp/kubernetes"
version = ">= 2.20"
}
}
}
# ---------------------------------------------------------------------------
# Variables
# ---------------------------------------------------------------------------
variable "region" {
type = string
default = "ap-southeast-2"
}
variable "cluster_name" {
type = string
default = "eks-addons"
}
variable "k8s_version" {
type = string
default = "1.32"
}
variable "instance_type" {
type = string
default = "t3.medium"
}
variable "node_port" {
type = number
default = 30080
}
variable "replicas" {
type = number
default = 2
}
variable "browse_cidr" {
type = string
default = "0.0.0.0/0"
description = "CIDR allowed to reach the demo NodePort; tighten to your own IP in production"
}
variable "spinifex_endpoint" {
type = string
default = "https://127.0.0.1:9999"
}
# ---------------------------------------------------------------------------
# Providers
# ---------------------------------------------------------------------------
provider "aws" {
region = var.region
endpoints {
ec2 = var.spinifex_endpoint
iam = var.spinifex_endpoint
sts = var.spinifex_endpoint
eks = var.spinifex_endpoint
}
skip_credentials_validation = true
skip_metadata_api_check = true
skip_requesting_account_id = true
skip_region_validation = true
}
provider "kubernetes" {
host = aws_eks_cluster.this.endpoint
cluster_ca_certificate = base64decode(aws_eks_cluster.this.certificate_authority[0].data)
exec {
api_version = "client.authentication.k8s.io/v1beta1"
command = "aws"
args = ["eks", "get-token", "--cluster-name", aws_eks_cluster.this.name, "--region", var.region]
}
}
data "aws_availability_zones" "available" {
state = "available"
}
# ---------------------------------------------------------------------------
# VPC + two public subnets
# ---------------------------------------------------------------------------
resource "aws_vpc" "main" {
cidr_block = "10.31.0.0/16"
enable_dns_hostnames = true
enable_dns_support = true
tags = {
Name = "${var.cluster_name}-vpc"
}
}
resource "aws_internet_gateway" "igw" {
vpc_id = aws_vpc.main.id
tags = {
Name = "${var.cluster_name}-igw"
}
}
resource "aws_subnet" "a" {
vpc_id = aws_vpc.main.id
cidr_block = "10.31.1.0/24"
availability_zone = data.aws_availability_zones.available.names[0]
map_public_ip_on_launch = true
tags = {
Name = "${var.cluster_name}-subnet-a"
}
}
resource "aws_subnet" "b" {
vpc_id = aws_vpc.main.id
cidr_block = "10.31.2.0/24"
availability_zone = data.aws_availability_zones.available.names[0]
map_public_ip_on_launch = true
tags = {
Name = "${var.cluster_name}-subnet-b"
}
}
resource "aws_route_table" "public" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.igw.id
}
tags = {
Name = "${var.cluster_name}-public-rt"
}
}
resource "aws_route_table_association" "a" {
subnet_id = aws_subnet.a.id
route_table_id = aws_route_table.public.id
}
resource "aws_route_table_association" "b" {
subnet_id = aws_subnet.b.id
route_table_id = aws_route_table.public.id
}
# ---------------------------------------------------------------------------
# IAM — cluster role
# ---------------------------------------------------------------------------
resource "aws_iam_role" "cluster" {
name = "${var.cluster_name}-cluster-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = "sts:AssumeRole"
Principal = { Service = "eks.amazonaws.com" }
}]
})
}
resource "aws_iam_role_policy_attachment" "cluster" {
role = aws_iam_role.cluster.name
policy_arn = "arn:aws:iam::aws:policy/AmazonEKSClusterPolicy"
}
# ---------------------------------------------------------------------------
# IAM — node role
# ---------------------------------------------------------------------------
resource "aws_iam_role" "node" {
name = "${var.cluster_name}-node-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = "sts:AssumeRole"
Principal = { Service = "ec2.amazonaws.com" }
}]
})
}
resource "aws_iam_role_policy_attachment" "node_worker" {
role = aws_iam_role.node.name
policy_arn = "arn:aws:iam::aws:policy/AmazonEKSWorkerNodePolicy"
}
resource "aws_iam_role_policy_attachment" "node_cni" {
role = aws_iam_role.node.name
policy_arn = "arn:aws:iam::aws:policy/AmazonEKS_CNI_Policy"
}
resource "aws_iam_role_policy_attachment" "node_ecr" {
role = aws_iam_role.node.name
policy_arn = "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly"
}
# ---------------------------------------------------------------------------
# IAM — a second principal to grant read-only cluster access to
# ---------------------------------------------------------------------------
resource "aws_iam_role" "viewer" {
name = "${var.cluster_name}-viewer"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = "sts:AssumeRole"
Principal = { Service = "ec2.amazonaws.com" }
}]
})
}
# ---------------------------------------------------------------------------
# EKS cluster
# ---------------------------------------------------------------------------
resource "aws_eks_cluster" "this" {
name = var.cluster_name
role_arn = aws_iam_role.cluster.arn
version = var.k8s_version
vpc_config {
subnet_ids = [aws_subnet.a.id, aws_subnet.b.id]
endpoint_public_access = true
endpoint_private_access = false
}
access_config {
authentication_mode = "API"
bootstrap_cluster_creator_admin_permissions = true
}
depends_on = [aws_iam_role_policy_attachment.cluster]
tags = {
Name = var.cluster_name
}
}
# ---------------------------------------------------------------------------
# Node group
# ---------------------------------------------------------------------------
resource "aws_eks_node_group" "default" {
cluster_name = aws_eks_cluster.this.name
node_group_name = "default"
node_role_arn = aws_iam_role.node.arn
subnet_ids = [aws_subnet.a.id, aws_subnet.b.id]
scaling_config {
desired_size = 1
min_size = 1
max_size = 2
}
instance_types = [var.instance_type]
ami_type = "AL2_x86_64"
depends_on = [
aws_iam_role_policy_attachment.node_worker,
aws_iam_role_policy_attachment.node_cni,
aws_iam_role_policy_attachment.node_ecr,
]
tags = {
Name = "${var.cluster_name}-default"
}
}
# ---------------------------------------------------------------------------
# Addon — CoreDNS
# ---------------------------------------------------------------------------
resource "aws_eks_addon" "coredns" {
cluster_name = aws_eks_cluster.this.name
addon_name = "coredns"
resolve_conflicts_on_create = "OVERWRITE"
# addon_version omitted on purpose. Spinifex defaults an unset version to its
# catalog default (coredns 1.11.1). The AWS provider rejects a bare "1.11.1"
# (it demands a v-prefixed form) which Spinifex's catalog would in turn
# reject — so pinning is a dead end here; let the server choose.
depends_on = [aws_eks_node_group.default]
tags = {
Name = "${var.cluster_name}-coredns"
}
}
# ---------------------------------------------------------------------------
# Access entry — grant the viewer principal read-only access
# ---------------------------------------------------------------------------
resource "aws_eks_access_entry" "viewer" {
cluster_name = aws_eks_cluster.this.name
principal_arn = aws_iam_role.viewer.arn
type = "STANDARD"
}
resource "aws_eks_access_policy_association" "viewer" {
cluster_name = aws_eks_cluster.this.name
principal_arn = aws_iam_role.viewer.arn
policy_arn = "arn:aws:eks::aws:cluster-access-policy/AmazonEKSViewPolicy"
access_scope {
type = "cluster"
}
depends_on = [aws_eks_access_entry.viewer]
}
# ---------------------------------------------------------------------------
# Open the NodePort on the auto-managed nodegroup SG (see eks-quickstart)
# ---------------------------------------------------------------------------
data "aws_security_group" "nodegroup" {
filter {
name = "group-name"
values = ["eks-cluster-${var.cluster_name}-nodegroup-sg"]
}
filter {
name = "vpc-id"
values = [aws_vpc.main.id]
}
depends_on = [aws_eks_node_group.default]
}
resource "aws_vpc_security_group_ingress_rule" "nodeport" {
security_group_id = data.aws_security_group.nodegroup.id
cidr_ipv4 = var.browse_cidr
from_port = var.node_port
to_port = var.node_port
ip_protocol = "tcp"
tags = {
Name = "${var.cluster_name}-demo-nodeport"
}
}
# ---------------------------------------------------------------------------
# Demo workload — deployed after CoreDNS so name resolution is up
# ---------------------------------------------------------------------------
resource "kubernetes_deployment_v1" "hello" {
metadata {
name = "hello"
namespace = "default"
labels = { app = "hello" }
}
spec {
replicas = var.replicas
selector {
match_labels = { app = "hello" }
}
template {
metadata {
labels = { app = "hello" }
}
spec {
container {
name = "hello"
image = "nginxdemos/hello"
port {
container_port = 80
}
}
}
}
}
depends_on = [aws_eks_addon.coredns]
}
resource "kubernetes_service_v1" "hello" {
metadata {
name = "hello"
namespace = "default"
}
spec {
selector = { app = "hello" }
type = "NodePort"
port {
port = 80
target_port = 80
node_port = var.node_port
}
}
depends_on = [kubernetes_deployment_v1.hello]
}
data "aws_instances" "workers" {
instance_tags = {
"spinifex:eks-cluster" = aws_eks_cluster.this.name
}
depends_on = [aws_eks_node_group.default]
}
# ---------------------------------------------------------------------------
# Outputs
# ---------------------------------------------------------------------------
output "cluster_name" {
value = aws_eks_cluster.this.name
}
output "viewer_principal_arn" {
value = aws_iam_role.viewer.arn
}
output "demo_url" {
value = "http://${data.aws_instances.workers.public_ips[0]}:${var.node_port}"
description = "Open in a browser; refresh to see different pods answer"
}
output "update_kubeconfig" {
value = "aws eks update-kubeconfig --name ${aws_eks_cluster.this.name} --region ${var.region}"
}
@@ -0,0 +1,227 @@
---
title: "3-Node EKS behind an HTTPS ALB (ACM TLS)"
description: "Provision an HA-shaped EKS cluster with public + private API endpoints and three workers, deploy a demo app, and publish it through an internet-facing ALB that terminates TLS with an ACM-imported certificate, using Terraform on Spinifex."
category: "Terraform Workbooks"
tags:
- terraform
- eks
- kubernetes
- acm
- tls
- alb
- elbv2
- vpc
- workbook
resources:
- title: "Terraform AWS Provider"
url: "https://registry.terraform.io/providers/hashicorp/aws/latest"
- title: "Terraform Kubernetes Provider"
url: "https://registry.terraform.io/providers/hashicorp/kubernetes/latest"
- title: "Spinifex Repository"
url: "https://github.com/mulgadc/spinifex"
- title: "OpenTofu"
url: "https://opentofu.org/"
---
# Terraform: 3-Node EKS behind an HTTPS ALB
> The largest EKS workbook, and the clearest demo. An HA-shaped cluster with public + private API endpoints and three workers, running a demo app published over HTTPS through an internet-facing ALB that terminates TLS with an ACM-imported certificate.
## Table of Contents
- [Overview](#overview)
- [Instructions](#instructions)
- [Troubleshooting](#troubleshooting)
---
## Overview
This workbook is sized for a **3-node Spinifex deployment** and is built to show off. It brings together the pieces from the smaller EKS examples, deploys a demo app across three workers, and fronts it with a TLS-terminating load balancer. When `apply` finishes, fetch the ALB's public IP and open `https://<ip>` — the `nginxdemos/hello` page reports which pod answered, and refreshing moves between the three replicas spread across the cluster. That single page demonstrates a multi-node Kubernetes cluster load-balancing behind HTTPS.
- **Public + private API endpoints.** `endpoint_public_access` serves `kubectl` and the Kubernetes provider from your host (CIDR-restricted); `endpoint_private_access` exposes an in-VPC private endpoint the workers use to join.
- **Private workers + NAT.** The three workers sit in private subnets. They *join* the cluster over the in-VPC private endpoint, and a NAT gateway gives them outbound access to pull the demo container image.
- **CoreDNS** installed as a managed addon.
- **TLS via ACM.** A self-signed certificate is generated with the `tls` provider and **imported into ACM** (Spinifex ACM supports `ImportCertificate`, not `RequestCertificate`). The ALB's HTTPS listener references the ACM ARN and forwards to the workers' NodePort.
- **The app, deployed by Terraform.** The Kubernetes provider deploys the Deployment + NodePort Service, and one security-group rule lets the ALB reach the NodePort.
<p align="center">
<img src="../../../.github/assets/diagrams/tf-eks-https-ingress.svg" alt="3-node EKS — public+private API endpoints, ALB in public subnets terminating TLS, three workers in private subnets behind a NAT gateway" width="900">
</p>
**What you'll learn:**
- Running an EKS cluster with both public and private API endpoints
- Placing workers in private subnets that join over the private endpoint and egress via NAT
- Importing a certificate into ACM and terminating TLS on an ALB HTTPS listener
- Wiring an ALB target group to managed node-group workers via a NodePort, including the SG rule that allows it
- Deploying a load-balanced workload with the Kubernetes provider
**What gets created**
| Resource | Name | Purpose |
|---|---|---|
| VPC | `eks-https-vpc` | Isolated network (10.32.0.0/16) |
| Public Subnets | `eks-https-public-a/-b` | Host the ALB and NAT gateway |
| Private Subnets | `eks-https-private-a/-b` | Host the workers |
| Internet Gateway | `eks-https-igw` | Internet for the public subnets |
| NAT Gateway (+ EIP) | `eks-https-nat` | Outbound for the private workers (image pulls) |
| Route Tables | `eks-https-public-rt`, `eks-https-private-rt` | Public via IGW; private via NAT |
| IAM Roles | `eks-https-cluster-role`, `eks-https-node-role` | Control-plane and worker roles |
| EKS Cluster | `eks-https` | Public (CIDR-restricted) + private endpoints |
| Node Group | `workers` | Three `t3.large` workers in the private subnets |
| Addon | `coredns` | Cluster DNS |
| ACM Cert | `eks-https-ingress` | Self-signed cert imported into ACM |
| ALB + HTTPS Listener | `eks-https-alb` | Internet-facing, terminates TLS on :443 |
| Target Group | `eks-https-tg` | Instance targets on the NodePort |
| SG Ingress Rule | `eks-https-alb-nodeport` | Lets the ALB SG reach the worker NodePort |
| K8s Deployment + Service | `hello` | `nginxdemos/hello`, 3 replicas, NodePort |
**Spinifex specifics**
- ACM supports `ImportCertificate`, `DescribeCertificate`, `ListCertificates`, `DeleteCertificate` — there is no `RequestCertificate`/DNS-validation flow, hence the self-signed import.
- The ALB HTTPS listener resolves the cert from ACM. Valid SSL policies are `ELBSecurityPolicy-2016-08` (default) and `ELBSecurityPolicy-TLS13-1-2-2021-06`.
- The worker SG is auto-managed (`vpc_config.security_group_ids` is ignored) and admits only intra-cluster traffic. The ALB→NodePort rule is added by looking the SG up by its deterministic name and admitting the ALB SG.
- Worker discovery uses the `spinifex:eks-cluster` tag Spinifex stamps on node-group instances; the target-group attachment count is the known `node_desired_size`.
- Leave `addon_version` unset (see the addons workbook for why pinning it can't satisfy both the provider and the catalog).
**Prerequisites:**
- A 3-node Spinifex deployment, installed and running
- The Spinifex `eks-node` image available on the cluster
- OpenTofu or Terraform, plus `kubectl` and the AWS CLI
## Instructions
### Step 1. Get the Template
```bash
git clone --depth 1 --filter=blob:none --sparse https://github.com/mulgadc/spinifex.git spinifex-tf
cd spinifex-tf
git sparse-checkout set docs/terraform-workbooks
cd docs/terraform-workbooks/eks-https-ingress
```
Or create a `main.tf` file and paste the full configuration below.
<!-- INCLUDE: main.tf lang:hcl -->
### Step 2. Deploy
```bash
export AWS_PROFILE=spinifex
tofu init
tofu apply
```
Expect several minutes: the control plane bootstraps, three workers launch and join over the private endpoint, the NAT gateway comes up, CoreDNS installs, the ALB is provisioned, and the Kubernetes provider rolls out the demo app.
> **Keep `AWS_PROFILE=spinifex` exported** for the whole `apply` — the Kubernetes provider authenticates with `aws eks get-token` against the Spinifex STS endpoint.
> **Tighten access in production.** `api_public_access_cidr` and `alb_ingress_cidr` both default to `0.0.0.0/0`. Set them to your own CIDR:
>
> ```bash
> export TF_VAR_api_public_access_cidr="203.0.113.10/32"
> export TF_VAR_alb_ingress_cidr="203.0.113.10/32"
> ```
### Step 3. Open the Demo over HTTPS
The ALB DNS name (`*.elb.spinifex.local`) won't resolve from your host, so fetch its public IP:
```bash
ALB_IP=$(aws elbv2 describe-load-balancers --names eks-https-alb \
--query 'LoadBalancers[0].AvailabilityZones[].LoadBalancerAddresses[].IpAddress' \
--output text)
```
The certificate is self-signed, so a browser will warn (proceed anyway) and `curl` needs `-k`:
```bash
curl -k https://$ALB_IP
curl -k https://$ALB_IP # refresh: the "Server name" changes between pods
```
Open `https://$ALB_IP` in a browser and refresh to watch requests land on different pods across the three nodes.
### Step 4. Inspect (optional)
```bash
aws eks update-kubeconfig --name eks-https --region ap-southeast-2
kubectl get nodes
kubectl get pods -o wide # three replicas, one per node
TG_ARN=$(aws elbv2 describe-target-groups --names eks-https-tg \
--query 'TargetGroups[0].TargetGroupArn' --output text)
aws elbv2 describe-target-health --target-group-arn "$TG_ARN"
```
### Cleanup
```bash
tofu destroy
```
## Troubleshooting
### Targets Unhealthy / ALB Returns 5xx
The ALB health-checks `/` on the NodePort. Targets turn healthy once the demo pods are running *and* the SG rule admits the ALB. Check, in order:
```bash
kubectl get pods -o wide # pods Running?
aws ec2 describe-security-groups \
--filters "Name=group-name,Values=eks-cluster-eks-https-nodegroup-sg" \
--query 'SecurityGroups[0].IpPermissions' # NodePort rule present?
aws elbv2 describe-target-health --target-group-arn "$TG_ARN"
```
If pods are `ImagePullBackOff`, the workers can't reach Docker Hub — confirm the NAT gateway is `available`:
```bash
aws ec2 describe-nat-gateways --query 'NatGateways[].[NatGatewayId,State]'
```
### Workers Never Join
The workers join over the private endpoint. Confirm it's enabled and the node group is progressing:
```bash
aws eks describe-cluster --name eks-https --query 'cluster.resourcesVpcConfig.endpointPrivateAccess'
aws eks describe-nodegroup --cluster-name eks-https --nodegroup-name workers --query 'nodegroup.status'
```
### Target-Group Attachment Errors at Apply
The attachment indexes the discovered worker IDs by `node_desired_size`. If `apply` errors with an index out of range, the workers weren't all tagged/running when the `aws_instances` lookup ran. Re-run `tofu apply` — the node group will be `ACTIVE` by then:
```bash
aws ec2 describe-instances --filters "Name=tag:spinifex:eks-cluster,Values=eks-https" \
--query 'Reservations[].Instances[].[InstanceId,State.Name]' --output text
```
### kubernetes provider: connection refused / Unauthorized
The provider runs `aws eks get-token` against the Spinifex STS endpoint. If the cluster was still `CREATING` when the provider first connected, re-run `tofu apply`. Otherwise confirm:
```bash
aws sts get-caller-identity
aws eks describe-cluster --name eks-https --query 'cluster.status'
```
### ACM Import / HTTPS Listener Errors
```bash
aws acm list-certificates
aws acm describe-certificate --certificate-arn "$(tofu output -raw certificate_arn)" \
--query 'Certificate.Status'
```
A `CertificateNotFound` on the listener means the ACM ARN didn't resolve — check the cert exists and belongs to the same account/profile.
### Provider Connection Refused
```bash
sudo systemctl status spinifex.target
curl -k https://localhost:9999/
```
@@ -0,0 +1,671 @@
# Example: 3-Node EKS behind an HTTPS ALB (ACM TLS) with a load-balanced app
#
# The largest EKS workbook, and the one with the clearest payoff. It stands up
# an HA-shaped cluster sized for a 3-node Spinifex deployment, deploys a demo
# web app across the workers, and publishes it through an internet-facing ALB
# that terminates TLS with a certificate held in ACM.
#
# After `apply`, fetch the ALB's public IP (see alb_public_ip_hint) and open
# https://<ip> in a browser. The page reports which pod answered; refresh and
# it moves between the three replicas spread across the cluster — a live picture
# of a multi-node Kubernetes cluster load-balancing behind HTTPS.
#
# What it builds:
# * VPC with two public subnets (ALB + NAT) and two private subnets (workers).
# * A NAT gateway so the private workers can pull the demo container image.
# * EKS cluster with BOTH a public endpoint (kubectl from your host) and a
# private endpoint (the in-VPC path the workers use to join).
# * A managed node group of three workers (t3.large) in the private subnets.
# * CoreDNS addon.
# * A self-signed cert imported into ACM (Spinifex ACM supports
# ImportCertificate, not RequestCertificate), wired to the ALB's HTTPS
# listener, which forwards to the workers' NodePort.
# * The demo Deployment + NodePort Service, deployed by the Kubernetes
# provider, plus the one SG rule that lets the ALB reach the NodePort.
#
# Usage:
# cd spinifex/docs/terraform-workbooks/eks-https-ingress
# export AWS_PROFILE=spinifex
# tofu init && tofu apply
terraform {
required_version = ">= 1.6.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.40, < 6.0"
}
tls = {
source = "hashicorp/tls"
version = ">= 4.0"
}
kubernetes = {
source = "hashicorp/kubernetes"
version = ">= 2.20"
}
}
}
# ---------------------------------------------------------------------------
# Variables
# ---------------------------------------------------------------------------
variable "region" {
type = string
default = "ap-southeast-2"
}
variable "cluster_name" {
type = string
default = "eks-https"
}
variable "k8s_version" {
type = string
default = "1.32"
}
variable "node_instance_type" {
type = string
default = "t3.large"
}
variable "node_desired_size" {
type = number
default = 3
description = "Worker count; spread across a 3-node Spinifex deployment for HA"
}
variable "node_port" {
type = number
default = 30080
description = "NodePort the ALB forwards to and the demo Service is published on"
}
variable "cert_common_name" {
type = string
default = "eks-https.spinifex.local"
}
variable "api_public_access_cidr" {
type = string
default = "0.0.0.0/0"
description = "CIDR allowed to reach the public Kubernetes API endpoint; tighten in production"
}
variable "alb_ingress_cidr" {
type = string
default = "0.0.0.0/0"
description = "CIDR allowed to reach the ALB HTTPS listener"
}
variable "spinifex_endpoint" {
type = string
default = "https://127.0.0.1:9999"
}
# ---------------------------------------------------------------------------
# Providers
# ---------------------------------------------------------------------------
provider "aws" {
region = var.region
endpoints {
ec2 = var.spinifex_endpoint
iam = var.spinifex_endpoint
sts = var.spinifex_endpoint
eks = var.spinifex_endpoint
acm = var.spinifex_endpoint
elasticloadbalancingv2 = var.spinifex_endpoint
}
skip_credentials_validation = true
skip_metadata_api_check = true
skip_requesting_account_id = true
skip_region_validation = true
}
provider "kubernetes" {
host = aws_eks_cluster.this.endpoint
cluster_ca_certificate = base64decode(aws_eks_cluster.this.certificate_authority[0].data)
exec {
api_version = "client.authentication.k8s.io/v1beta1"
command = "aws"
args = ["eks", "get-token", "--cluster-name", aws_eks_cluster.this.name, "--region", var.region]
}
}
data "aws_availability_zones" "available" {
state = "available"
}
# ---------------------------------------------------------------------------
# VPC — public subnets for the ALB + NAT, private subnets for the workers
# ---------------------------------------------------------------------------
resource "aws_vpc" "main" {
cidr_block = "10.32.0.0/16"
enable_dns_hostnames = true
enable_dns_support = true
tags = {
Name = "${var.cluster_name}-vpc"
}
}
resource "aws_internet_gateway" "igw" {
vpc_id = aws_vpc.main.id
tags = {
Name = "${var.cluster_name}-igw"
}
}
resource "aws_subnet" "public_a" {
vpc_id = aws_vpc.main.id
cidr_block = "10.32.1.0/24"
availability_zone = data.aws_availability_zones.available.names[0]
map_public_ip_on_launch = true
tags = {
Name = "${var.cluster_name}-public-a"
}
}
resource "aws_subnet" "public_b" {
vpc_id = aws_vpc.main.id
cidr_block = "10.32.2.0/24"
availability_zone = data.aws_availability_zones.available.names[0]
map_public_ip_on_launch = true
tags = {
Name = "${var.cluster_name}-public-b"
}
}
resource "aws_subnet" "private_a" {
vpc_id = aws_vpc.main.id
cidr_block = "10.32.11.0/24"
availability_zone = data.aws_availability_zones.available.names[0]
tags = {
Name = "${var.cluster_name}-private-a"
}
}
resource "aws_subnet" "private_b" {
vpc_id = aws_vpc.main.id
cidr_block = "10.32.12.0/24"
availability_zone = data.aws_availability_zones.available.names[0]
tags = {
Name = "${var.cluster_name}-private-b"
}
}
resource "aws_route_table" "public" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.igw.id
}
tags = {
Name = "${var.cluster_name}-public-rt"
}
}
resource "aws_route_table_association" "public_a" {
subnet_id = aws_subnet.public_a.id
route_table_id = aws_route_table.public.id
}
resource "aws_route_table_association" "public_b" {
subnet_id = aws_subnet.public_b.id
route_table_id = aws_route_table.public.id
}
# NAT gateway gives the private workers outbound internet to pull the demo
# container image. The workers still *join* the cluster over the in-VPC private
# endpoint, not through the NAT.
resource "aws_eip" "nat" {
domain = "vpc"
tags = {
Name = "${var.cluster_name}-nat-eip"
}
}
resource "aws_nat_gateway" "nat" {
allocation_id = aws_eip.nat.id
subnet_id = aws_subnet.public_a.id
depends_on = [aws_internet_gateway.igw]
tags = {
Name = "${var.cluster_name}-nat"
}
}
resource "aws_route_table" "private" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
nat_gateway_id = aws_nat_gateway.nat.id
}
tags = {
Name = "${var.cluster_name}-private-rt"
}
}
resource "aws_route_table_association" "private_a" {
subnet_id = aws_subnet.private_a.id
route_table_id = aws_route_table.private.id
}
resource "aws_route_table_association" "private_b" {
subnet_id = aws_subnet.private_b.id
route_table_id = aws_route_table.private.id
}
# ---------------------------------------------------------------------------
# IAM — cluster role
# ---------------------------------------------------------------------------
resource "aws_iam_role" "cluster" {
name = "${var.cluster_name}-cluster-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = "sts:AssumeRole"
Principal = { Service = "eks.amazonaws.com" }
}]
})
}
resource "aws_iam_role_policy_attachment" "cluster" {
role = aws_iam_role.cluster.name
policy_arn = "arn:aws:iam::aws:policy/AmazonEKSClusterPolicy"
}
# ---------------------------------------------------------------------------
# IAM — node role
# ---------------------------------------------------------------------------
resource "aws_iam_role" "node" {
name = "${var.cluster_name}-node-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = "sts:AssumeRole"
Principal = { Service = "ec2.amazonaws.com" }
}]
})
}
resource "aws_iam_role_policy_attachment" "node_worker" {
role = aws_iam_role.node.name
policy_arn = "arn:aws:iam::aws:policy/AmazonEKSWorkerNodePolicy"
}
resource "aws_iam_role_policy_attachment" "node_cni" {
role = aws_iam_role.node.name
policy_arn = "arn:aws:iam::aws:policy/AmazonEKS_CNI_Policy"
}
resource "aws_iam_role_policy_attachment" "node_ecr" {
role = aws_iam_role.node.name
policy_arn = "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly"
}
# ---------------------------------------------------------------------------
# EKS cluster — public + private endpoints
# ---------------------------------------------------------------------------
resource "aws_eks_cluster" "this" {
name = var.cluster_name
role_arn = aws_iam_role.cluster.arn
version = var.k8s_version
vpc_config {
subnet_ids = [aws_subnet.private_a.id, aws_subnet.private_b.id]
endpoint_public_access = true
endpoint_private_access = true
public_access_cidrs = [var.api_public_access_cidr]
}
access_config {
authentication_mode = "API"
bootstrap_cluster_creator_admin_permissions = true
}
depends_on = [aws_iam_role_policy_attachment.cluster]
tags = {
Name = var.cluster_name
}
}
# ---------------------------------------------------------------------------
# Managed node group — three workers in the private subnets
# ---------------------------------------------------------------------------
resource "aws_eks_node_group" "workers" {
cluster_name = aws_eks_cluster.this.name
node_group_name = "workers"
node_role_arn = aws_iam_role.node.arn
subnet_ids = [aws_subnet.private_a.id, aws_subnet.private_b.id]
scaling_config {
desired_size = var.node_desired_size
min_size = var.node_desired_size
max_size = var.node_desired_size * 2
}
instance_types = [var.node_instance_type]
ami_type = "AL2_x86_64"
depends_on = [
aws_iam_role_policy_attachment.node_worker,
aws_iam_role_policy_attachment.node_cni,
aws_iam_role_policy_attachment.node_ecr,
]
tags = {
Name = "${var.cluster_name}-workers"
}
}
# ---------------------------------------------------------------------------
# Addon — CoreDNS
# ---------------------------------------------------------------------------
resource "aws_eks_addon" "coredns" {
cluster_name = aws_eks_cluster.this.name
addon_name = "coredns"
resolve_conflicts_on_create = "OVERWRITE"
# addon_version omitted on purpose. Spinifex defaults an unset version to its
# catalog default (coredns 1.11.1). The AWS provider also rejects a bare
# "1.11.1" (it demands a v-prefixed form) which Spinifex's catalog would in
# turn reject — so pinning is a dead end here; let the server choose.
depends_on = [aws_eks_node_group.workers]
tags = {
Name = "${var.cluster_name}-coredns"
}
}
# ---------------------------------------------------------------------------
# TLS — self-signed certificate imported into ACM
# ---------------------------------------------------------------------------
resource "tls_private_key" "ingress" {
algorithm = "RSA"
rsa_bits = 2048
}
resource "tls_self_signed_cert" "ingress" {
private_key_pem = tls_private_key.ingress.private_key_pem
subject {
common_name = var.cert_common_name
organization = "Spinifex EKS Demo"
}
dns_names = [var.cert_common_name]
validity_period_hours = 8760
early_renewal_hours = 720
allowed_uses = [
"key_encipherment",
"digital_signature",
"server_auth",
]
}
resource "aws_acm_certificate" "ingress" {
private_key = tls_private_key.ingress.private_key_pem
certificate_body = tls_self_signed_cert.ingress.cert_pem
tags = {
Name = "${var.cluster_name}-ingress"
}
}
# ---------------------------------------------------------------------------
# ALB — internet-facing, HTTPS terminated with the ACM cert
# ---------------------------------------------------------------------------
resource "aws_security_group" "alb" {
name = "${var.cluster_name}-alb-sg"
description = "ALB HTTPS inbound, NodePort to workers"
vpc_id = aws_vpc.main.id
ingress {
description = "HTTPS"
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = [var.alb_ingress_cidr]
}
egress {
description = "All outbound"
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "${var.cluster_name}-alb-sg"
}
}
resource "aws_lb" "ingress" {
name = "${var.cluster_name}-alb"
internal = false
load_balancer_type = "application"
security_groups = [aws_security_group.alb.id]
subnets = [aws_subnet.public_a.id, aws_subnet.public_b.id]
tags = {
Name = "${var.cluster_name}-alb"
}
}
resource "aws_lb_target_group" "workers" {
name = "${var.cluster_name}-tg"
port = var.node_port
protocol = "HTTP"
target_type = "instance"
vpc_id = aws_vpc.main.id
health_check {
path = "/"
protocol = "HTTP"
matcher = "200-399"
healthy_threshold = 2
unhealthy_threshold = 3
timeout = 5
interval = 10
}
tags = {
Name = "${var.cluster_name}-tg"
}
}
# Discover the launched workers by their Spinifex EKS tag so the ALB can target
# them. depends_on the node group: the lookup must run after the workers exist.
data "aws_instances" "workers" {
instance_tags = {
"spinifex:eks-cluster" = aws_eks_cluster.this.name
}
depends_on = [aws_eks_node_group.workers]
}
# count is the known desired size, so it's resolvable at plan time even though
# the worker IDs themselves are only known after the node group launches.
resource "aws_lb_target_group_attachment" "workers" {
count = var.node_desired_size
target_group_arn = aws_lb_target_group.workers.arn
target_id = data.aws_instances.workers.ids[count.index]
port = var.node_port
}
resource "aws_lb_listener" "https" {
load_balancer_arn = aws_lb.ingress.arn
port = 443
protocol = "HTTPS"
ssl_policy = "ELBSecurityPolicy-TLS13-1-2-2021-06"
certificate_arn = aws_acm_certificate.ingress.arn
default_action {
type = "forward"
target_group_arn = aws_lb_target_group.workers.arn
}
}
# ---------------------------------------------------------------------------
# Let the ALB reach the workers' NodePort
#
# Spinifex auto-manages the nodegroup SG and admits only intra-cluster traffic.
# Look it up by its deterministic name and admit the ALB SG on the NodePort.
# ---------------------------------------------------------------------------
data "aws_security_group" "nodegroup" {
filter {
name = "group-name"
values = ["eks-cluster-${var.cluster_name}-nodegroup-sg"]
}
filter {
name = "vpc-id"
values = [aws_vpc.main.id]
}
depends_on = [aws_eks_node_group.workers]
}
resource "aws_vpc_security_group_ingress_rule" "nodeport_from_alb" {
security_group_id = data.aws_security_group.nodegroup.id
referenced_security_group_id = aws_security_group.alb.id
from_port = var.node_port
to_port = var.node_port
ip_protocol = "tcp"
tags = {
Name = "${var.cluster_name}-alb-nodeport"
}
}
# ---------------------------------------------------------------------------
# Demo workload — three replicas spread across the workers
# ---------------------------------------------------------------------------
resource "kubernetes_deployment_v1" "hello" {
metadata {
name = "hello"
namespace = "default"
labels = { app = "hello" }
}
spec {
replicas = var.node_desired_size
selector {
match_labels = { app = "hello" }
}
template {
metadata {
labels = { app = "hello" }
}
spec {
container {
name = "hello"
image = "nginxdemos/hello"
port {
container_port = 80
}
}
# Spread one replica per node so a refresh visibly hits different nodes.
topology_spread_constraint {
max_skew = 1
topology_key = "kubernetes.io/hostname"
when_unsatisfiable = "ScheduleAnyway"
label_selector {
match_labels = { app = "hello" }
}
}
}
}
}
depends_on = [aws_eks_addon.coredns]
}
resource "kubernetes_service_v1" "hello" {
metadata {
name = "hello"
namespace = "default"
}
spec {
selector = { app = "hello" }
type = "NodePort"
port {
port = 80
target_port = 80
node_port = var.node_port
}
}
depends_on = [kubernetes_deployment_v1.hello]
}
# ---------------------------------------------------------------------------
# Outputs
# ---------------------------------------------------------------------------
output "cluster_name" {
value = aws_eks_cluster.this.name
}
output "certificate_arn" {
value = aws_acm_certificate.ingress.arn
}
output "alb_dns_name" {
value = aws_lb.ingress.dns_name
}
output "alb_public_ip_hint" {
value = "aws elbv2 describe-load-balancers --names ${var.cluster_name}-alb --query 'LoadBalancers[0].AvailabilityZones[].LoadBalancerAddresses[].IpAddress' --output text"
}
output "demo_url_hint" {
value = "Fetch the ALB IP via alb_public_ip_hint, then open https://<that-ip> (self-signed cert: curl -k). Refresh to see different pods answer."
}
output "update_kubeconfig" {
value = "aws eks update-kubeconfig --name ${aws_eks_cluster.this.name} --region ${var.region}"
}
@@ -0,0 +1,173 @@
---
title: "EKS Quickstart"
description: "Stand up a minimal managed-Kubernetes cluster and a browsable demo app — VPC, IAM roles, an EKS cluster, a single-worker node group, and a load-balanced web page — using Terraform on Spinifex."
category: "Terraform Workbooks"
tags:
- terraform
- eks
- kubernetes
- iam
- vpc
- workbook
resources:
- title: "Terraform AWS Provider"
url: "https://registry.terraform.io/providers/hashicorp/aws/latest"
- title: "Terraform Kubernetes Provider"
url: "https://registry.terraform.io/providers/hashicorp/kubernetes/latest"
- title: "Spinifex Repository"
url: "https://github.com/mulgadc/spinifex"
- title: "OpenTofu"
url: "https://opentofu.org/"
---
# Terraform: EKS Quickstart
> The smallest end-to-end EKS example on Spinifex: a VPC, the two IAM roles EKS needs, a single-worker cluster, and a demo web app you can open in a browser.
## Table of Contents
- [Overview](#overview)
- [Instructions](#instructions)
- [Troubleshooting](#troubleshooting)
---
## Overview
Provision a managed Kubernetes cluster on Spinifex with Terraform/OpenTofu — and finish with something you can actually see. As well as the cluster, this workbook deploys a small demo app onto it and publishes it on the worker's public IP. When `apply` finishes, open the **`demo_url`** output: the page is served by `nginxdemos/hello` and prints the name of the pod that answered. Refresh it and the answering pod changes, so even a non-technical viewer can watch Kubernetes scheduling and load-balancing in real time.
Under the hood it keeps things minimal: a VPC with two public subnets, an IAM role for the control plane, an IAM role for the workers with the three managed policies AWS-managed node groups expect, a public-endpoint `aws_eks_cluster`, and a single-node `aws_eks_node_group`. Terraform then uses the **Kubernetes provider** (authenticating exactly like `kubectl` does, via `aws eks get-token`) to deploy the Deployment + NodePort Service.
**What you'll learn:**
- Configuring the AWS provider to target Spinifex's `eks`, `ec2`, `iam` and `sts` endpoints
- Creating the EKS cluster and worker IAM roles with faithful managed-policy attachments
- Provisioning an `aws_eks_cluster` and a managed `aws_eks_node_group`
- Pointing the Kubernetes provider at the new cluster and deploying a workload from the same `apply`
- Opening one rule on the auto-managed worker security group to expose a NodePort
**What gets created**
| Resource | Name | Purpose |
|---|---|---|
| VPC | `eks-quickstart-vpc` | Isolated network (10.30.0.0/16) |
| Subnets | `eks-quickstart-subnet-a/-b` | Public subnets for the cluster and worker |
| Internet Gateway | `eks-quickstart-igw` | Egress so the worker can pull the demo image |
| IAM Roles | `eks-quickstart-cluster-role`, `eks-quickstart-node-role` | Control-plane and worker roles |
| EKS Cluster | `eks-quickstart` | Public API endpoint, API auth mode, Kubernetes 1.32 |
| Node Group | `default` | One `t3.medium` worker |
| SG Ingress Rule | `eks-quickstart-demo-nodeport` | Opens the demo NodePort on the auto-managed worker SG |
| K8s Deployment | `hello` | `nginxdemos/hello`, 2 replicas |
| K8s Service | `hello` | NodePort publishing the demo on the worker |
**Spinifex specifics**
- The cluster's security groups are **auto-managed** — `vpc_config.security_group_ids` is ignored. To reach a NodePort, this workbook looks the worker SG up by its deterministic name (`eks-cluster-<name>-nodegroup-sg`) and adds a single ingress rule.
- The worker AMI is always Spinifex's `eks-node` image; `ami_type` is recorded but does **not** select the image.
- `authentication_mode` must be `"API"` (the `API_AND_CONFIG_MAP` mode is rejected).
- The workers need outbound internet (here via the IGW) to pull the demo container image.
**Prerequisites:**
- Spinifex installed and running (see [Installing Spinifex](/docs/install))
- The Spinifex `eks-node` image available on the cluster
- OpenTofu or Terraform, plus `kubectl` and the AWS CLI
## Instructions
### Step 1. Get the Template
```bash
git clone --depth 1 --filter=blob:none --sparse https://github.com/mulgadc/spinifex.git spinifex-tf
cd spinifex-tf
git sparse-checkout set docs/terraform-workbooks
cd docs/terraform-workbooks/eks-quickstart
```
Or create a `main.tf` file and paste the full configuration below.
<!-- INCLUDE: main.tf lang:hcl -->
### Step 2. Deploy
```bash
export AWS_PROFILE=spinifex
tofu init
tofu apply
```
This single `apply` does the whole job: it creates the cluster (which bootstraps a control-plane VM and brings up k3s — a few minutes in `CREATING`), launches the worker, opens the NodePort, then deploys the demo app and waits for the pods to roll out.
> **Same profile for the Kubernetes provider.** The Kubernetes provider authenticates by shelling out to `aws eks get-token`, which has to reach the Spinifex STS endpoint. Keep `AWS_PROFILE=spinifex` exported for the whole `apply`.
### Step 3. Open the Demo
```bash
tofu output demo_url
```
Open that URL in a browser. You'll see the `nginxdemos/hello` page showing the **Server name** (the pod that handled the request). Refresh a few times — with two replicas, the pod name alternates, demonstrating that the Service is load-balancing across the cluster.
### Step 4. Inspect with kubectl (optional)
```bash
aws eks update-kubeconfig --name eks-quickstart --region ap-southeast-2
kubectl get nodes
kubectl get pods -o wide
```
`kubectl get pods -o wide` shows the demo pods and which node each landed on.
### Cleanup
```bash
tofu destroy
```
## Troubleshooting
### Demo URL Doesn't Load
The page is served from a NodePort on the worker's public IP. Work through the chain:
```bash
# Is the worker running with a public IP?
aws ec2 describe-instances --filters "Name=tag:spinifex:eks-cluster,Values=eks-quickstart" \
--query 'Reservations[].Instances[].[InstanceId,State.Name,PublicIpAddress]' --output text
# Did the demo pods roll out?
kubectl get pods -o wide
# Is the NodePort rule on the worker SG?
aws ec2 describe-security-groups --filters "Name=group-name,Values=eks-cluster-eks-quickstart-nodegroup-sg" \
--query 'SecurityGroups[0].IpPermissions'
```
If the pods are `Pending`, the worker may not be `Ready` yet — give it a moment. If they're `ImagePullBackOff`, the worker can't reach Docker Hub; confirm the IGW route and the worker's public IP.
### kubernetes provider: connection refused / Unauthorized
The provider runs `aws eks get-token` against the Spinifex STS endpoint. Confirm the AWS CLI is still pointed at Spinifex and the cluster is `ACTIVE`:
```bash
aws sts get-caller-identity
aws eks describe-cluster --name eks-quickstart --query 'cluster.status'
```
If the cluster was still `CREATING` when the provider first tried to connect, just re-run `tofu apply`.
### Cluster Stuck in CREATING
Control-plane bootstrap takes a few minutes. Confirm the underlying VM came up:
```bash
aws eks describe-cluster --name eks-quickstart --query 'cluster.status'
aws ec2 describe-instances --profile spinifex
```
### Provider Connection Refused
```bash
sudo systemctl status spinifex.target
curl -k https://localhost:9999/
```
@@ -0,0 +1,428 @@
# Example: EKS Quickstart on Spinifex
#
# A minimal managed-Kubernetes cluster that ends in something you can see: a
# VPC, the two IAM roles EKS needs, a single-worker cluster, and a demo web app
# deployed onto it by Terraform and exposed on the worker's public IP.
#
# Once `apply` finishes, open the demo_url output in a browser. The page is
# served by the nginxdemos/hello image and prints the pod name that answered —
# refresh and it bounces between the app's replicas, showing the cluster is
# really scheduling and load-balancing pods.
#
# Demonstrates: VPC + subnets, EKS cluster + node IAM roles, an aws_eks_cluster
# and managed aws_eks_node_group, the Kubernetes provider authenticating to the
# cluster, and a Deployment + NodePort Service reachable from your browser.
#
# Usage:
# cd spinifex/docs/terraform-workbooks/eks-quickstart
# export AWS_PROFILE=spinifex
# tofu init && tofu apply
# # then open the demo_url output
terraform {
required_version = ">= 1.6.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.40, < 6.0"
}
kubernetes = {
source = "hashicorp/kubernetes"
version = ">= 2.20"
}
}
}
# ---------------------------------------------------------------------------
# Variables
# ---------------------------------------------------------------------------
variable "region" {
type = string
default = "ap-southeast-2"
}
variable "cluster_name" {
type = string
default = "eks-quickstart"
}
variable "k8s_version" {
type = string
default = "1.32"
description = "Kubernetes minor version for the control plane and workers"
}
variable "instance_type" {
type = string
default = "t3.medium"
}
variable "node_port" {
type = number
default = 30080
description = "NodePort the demo Service is published on"
}
variable "replicas" {
type = number
default = 2
description = "Demo app replicas; refresh the page to see requests land on different pods"
}
variable "browse_cidr" {
type = string
default = "0.0.0.0/0"
description = "CIDR allowed to reach the demo NodePort; tighten to your own IP in production"
}
variable "spinifex_endpoint" {
type = string
default = "https://127.0.0.1:9999"
description = "Spinifex AWS gateway endpoint"
}
# ---------------------------------------------------------------------------
# Providers
# ---------------------------------------------------------------------------
provider "aws" {
region = var.region
endpoints {
ec2 = var.spinifex_endpoint
iam = var.spinifex_endpoint
sts = var.spinifex_endpoint
eks = var.spinifex_endpoint
}
skip_credentials_validation = true
skip_metadata_api_check = true
skip_requesting_account_id = true
skip_region_validation = true
}
# Authenticates to the cluster with the same `aws eks get-token` exec flow the
# generated kubeconfig uses, so the Kubernetes provider can deploy the demo app.
provider "kubernetes" {
host = aws_eks_cluster.this.endpoint
cluster_ca_certificate = base64decode(aws_eks_cluster.this.certificate_authority[0].data)
exec {
api_version = "client.authentication.k8s.io/v1beta1"
command = "aws"
args = ["eks", "get-token", "--cluster-name", aws_eks_cluster.this.name, "--region", var.region]
}
}
# ---------------------------------------------------------------------------
# Data sources
# ---------------------------------------------------------------------------
data "aws_availability_zones" "available" {
state = "available"
}
# ---------------------------------------------------------------------------
# VPC + two public subnets (workers get public IPs to pull the demo image)
# ---------------------------------------------------------------------------
resource "aws_vpc" "main" {
cidr_block = "10.30.0.0/16"
enable_dns_hostnames = true
enable_dns_support = true
tags = {
Name = "${var.cluster_name}-vpc"
}
}
resource "aws_internet_gateway" "igw" {
vpc_id = aws_vpc.main.id
tags = {
Name = "${var.cluster_name}-igw"
}
}
resource "aws_subnet" "a" {
vpc_id = aws_vpc.main.id
cidr_block = "10.30.1.0/24"
availability_zone = data.aws_availability_zones.available.names[0]
map_public_ip_on_launch = true
tags = {
Name = "${var.cluster_name}-subnet-a"
}
}
resource "aws_subnet" "b" {
vpc_id = aws_vpc.main.id
cidr_block = "10.30.2.0/24"
availability_zone = data.aws_availability_zones.available.names[0]
map_public_ip_on_launch = true
tags = {
Name = "${var.cluster_name}-subnet-b"
}
}
resource "aws_route_table" "public" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.igw.id
}
tags = {
Name = "${var.cluster_name}-public-rt"
}
}
resource "aws_route_table_association" "a" {
subnet_id = aws_subnet.a.id
route_table_id = aws_route_table.public.id
}
resource "aws_route_table_association" "b" {
subnet_id = aws_subnet.b.id
route_table_id = aws_route_table.public.id
}
# ---------------------------------------------------------------------------
# IAM — EKS cluster role
# ---------------------------------------------------------------------------
resource "aws_iam_role" "cluster" {
name = "${var.cluster_name}-cluster-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = "sts:AssumeRole"
Principal = { Service = "eks.amazonaws.com" }
}]
})
}
resource "aws_iam_role_policy_attachment" "cluster" {
role = aws_iam_role.cluster.name
policy_arn = "arn:aws:iam::aws:policy/AmazonEKSClusterPolicy"
}
# ---------------------------------------------------------------------------
# IAM — worker node role
# ---------------------------------------------------------------------------
resource "aws_iam_role" "node" {
name = "${var.cluster_name}-node-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = "sts:AssumeRole"
Principal = { Service = "ec2.amazonaws.com" }
}]
})
}
resource "aws_iam_role_policy_attachment" "node_worker" {
role = aws_iam_role.node.name
policy_arn = "arn:aws:iam::aws:policy/AmazonEKSWorkerNodePolicy"
}
resource "aws_iam_role_policy_attachment" "node_cni" {
role = aws_iam_role.node.name
policy_arn = "arn:aws:iam::aws:policy/AmazonEKS_CNI_Policy"
}
resource "aws_iam_role_policy_attachment" "node_ecr" {
role = aws_iam_role.node.name
policy_arn = "arn:aws:iam::aws:policy/AmazonEC2ContainerRegistryReadOnly"
}
# ---------------------------------------------------------------------------
# EKS cluster — public API endpoint, API (access-entry) auth mode
# ---------------------------------------------------------------------------
resource "aws_eks_cluster" "this" {
name = var.cluster_name
role_arn = aws_iam_role.cluster.arn
version = var.k8s_version
vpc_config {
subnet_ids = [aws_subnet.a.id, aws_subnet.b.id]
endpoint_public_access = true
endpoint_private_access = false
}
access_config {
authentication_mode = "API"
bootstrap_cluster_creator_admin_permissions = true
}
depends_on = [aws_iam_role_policy_attachment.cluster]
tags = {
Name = var.cluster_name
}
}
# ---------------------------------------------------------------------------
# Managed node group — one worker
# ---------------------------------------------------------------------------
resource "aws_eks_node_group" "default" {
cluster_name = aws_eks_cluster.this.name
node_group_name = "default"
node_role_arn = aws_iam_role.node.arn
subnet_ids = [aws_subnet.a.id, aws_subnet.b.id]
scaling_config {
desired_size = 1
min_size = 1
max_size = 2
}
instance_types = [var.instance_type]
ami_type = "AL2_x86_64"
depends_on = [
aws_iam_role_policy_attachment.node_worker,
aws_iam_role_policy_attachment.node_cni,
aws_iam_role_policy_attachment.node_ecr,
]
tags = {
Name = "${var.cluster_name}-default"
}
}
# ---------------------------------------------------------------------------
# Open the NodePort on the auto-managed nodegroup SG
#
# Spinifex creates the worker SG itself (vpc_config.security_group_ids is
# ignored) and admits only intra-cluster traffic. To reach the demo NodePort
# from a browser, look the SG up by its deterministic name and add one rule.
# ---------------------------------------------------------------------------
data "aws_security_group" "nodegroup" {
filter {
name = "group-name"
values = ["eks-cluster-${var.cluster_name}-nodegroup-sg"]
}
filter {
name = "vpc-id"
values = [aws_vpc.main.id]
}
depends_on = [aws_eks_node_group.default]
}
resource "aws_vpc_security_group_ingress_rule" "nodeport" {
security_group_id = data.aws_security_group.nodegroup.id
cidr_ipv4 = var.browse_cidr
from_port = var.node_port
to_port = var.node_port
ip_protocol = "tcp"
tags = {
Name = "${var.cluster_name}-demo-nodeport"
}
}
# ---------------------------------------------------------------------------
# Demo workload — deployed onto the cluster by Terraform
#
# nginxdemos/hello renders a page showing which pod served the request; with
# multiple replicas, refreshing the demo_url alternates between them.
# ---------------------------------------------------------------------------
resource "kubernetes_deployment_v1" "hello" {
metadata {
name = "hello"
namespace = "default"
labels = { app = "hello" }
}
spec {
replicas = var.replicas
selector {
match_labels = { app = "hello" }
}
template {
metadata {
labels = { app = "hello" }
}
spec {
container {
name = "hello"
image = "nginxdemos/hello"
port {
container_port = 80
}
}
}
}
}
depends_on = [aws_eks_node_group.default]
}
resource "kubernetes_service_v1" "hello" {
metadata {
name = "hello"
namespace = "default"
}
spec {
selector = { app = "hello" }
type = "NodePort"
port {
port = 80
target_port = 80
node_port = var.node_port
}
}
depends_on = [kubernetes_deployment_v1.hello]
}
# ---------------------------------------------------------------------------
# Discover the worker's public IP for the demo URL
# ---------------------------------------------------------------------------
data "aws_instances" "workers" {
instance_tags = {
"spinifex:eks-cluster" = aws_eks_cluster.this.name
}
depends_on = [aws_eks_node_group.default]
}
# ---------------------------------------------------------------------------
# Outputs
# ---------------------------------------------------------------------------
output "cluster_name" {
value = aws_eks_cluster.this.name
}
output "demo_url" {
value = "http://${data.aws_instances.workers.public_ips[0]}:${var.node_port}"
description = "Open in a browser; refresh to see different pods answer"
}
output "update_kubeconfig" {
value = "aws eks update-kubeconfig --name ${aws_eks_cluster.this.name} --region ${var.region}"
}
+37 -7
View File
@@ -9,6 +9,9 @@ tags:
- flask
- webapp
- workbook
- imds
- sts
- instance-profile
resources:
- title: "Terraform AWS Provider"
url: "https://registry.terraform.io/providers/hashicorp/aws/latest"
@@ -36,18 +39,21 @@ resources:
Deploy an EC2 instance running a Flask file-sharing web application backed by S3 (Predastore). Users can upload files through a web form and browse uploaded content — demonstrating Terraform managing both compute and object storage together.
This workbook uses the idiomatic AWS credential model: the instance is launched with an **IAM instance profile** and the app pulls short-lived STS credentials from **IMDS** (`169.254.169.254`) through boto3's default credential chain. **No long-lived S3 keys are baked into the instance.** The Terraform run still uses the operator's admin credentials (to create the bucket, role, profile, and instance), but the running instance authenticates to S3 with credentials it fetches at runtime and which expire in ~1 hour.
**Architecture:**
<p align="center">
<img src="../../../.github/assets/diagrams/tf-s3-webapp.svg" alt="S3 webapp — browser to Flask EC2 instance to Predastore via S3 API" width="900">
<img src="../../../.github/assets/diagrams/tf-s3-webapp.svg" alt="S3 webapp — browser to Flask EC2 instance; the instance fetches short-lived STS credentials from IMDS (169.254.169.254) via its instance profile, then calls Predastore over the S3 API" width="900">
</p>
**What you'll learn:**
- Configuring the AWS provider with both Spinifex and Predastore endpoints
- Creating S3 buckets on Predastore via Terraform
- Deploying a Python webapp with cloud-init that talks to S3
- Passing credentials and configuration to instances via user-data
- Defining an IAM role, least-privilege managed policy, and instance profile, and passing the role to an instance (`iam:PassRole`)
- How the instance obtains short-lived credentials from IMDS via boto3's default credential chain — no static keys on the instance
- Deploying a Python webapp with cloud-init that signs S3 requests with the STS session token
**Prerequisites:**
@@ -55,6 +61,7 @@ Deploy an EC2 instance running a Flask file-sharing web application backed by S3
- Predastore running (S3 API on port 8443)
- An Ubuntu 26.04 AMI imported (see [Setting Up Your Cluster](/docs/setting-up-your-cluster))
- OpenTofu or Terraform installed
- The operator identity running the Terraform apply (`AWS_PROFILE=spinifex`) must be allowed to manage IAM and pass the role: `iam:CreateRole`, `iam:CreatePolicy`, `iam:AttachRolePolicy`, `iam:CreateInstanceProfile`, `iam:AddRoleToInstanceProfile`, and `iam:PassRole` on the new role. The bootstrap admin profile satisfies this.
- The EC2 instance must be able to reach Predastore — use the host's br-wan IP, not localhost
## Instructions
@@ -141,16 +148,39 @@ sudo systemctl status s3-webapp
sudo journalctl -u s3-webapp --no-pager -n 50
```
### Upload Fails or Files Not Appearing
### Upload Fails with `403 AccessDenied`
Check that the S3 credentials in `/opt/webapp/.env` are correct and that the bucket exists:
The instance reached S3 but was not authorized. The `.env` file deliberately holds **no** keys — credentials come from IMDS:
```bash
ssh -i s3-webapp-demo.pem ec2-user@<public_ip>
cat /opt/webapp/.env
/opt/webapp/venv/bin/python -c "import boto3; print('boto3 OK')"
cat /opt/webapp/.env # S3_ENDPOINT / S3_BUCKET / S3_REGION only — no keys
```
Check that:
- the managed policy is attached to the role (`s3:ListBucket` on the bucket, `s3:GetObject`/`s3:PutObject` on its objects), and the policy's bucket name matches `bucket_name`;
- the assumed-role session's account matches the bucket owner's account — Predastore enforces bucket ownership. Both are created by the same operator identity, so they align by construction; a mismatch surfaces as `403`.
### `NoCredentialsError` / No Credentials
boto3 could not obtain credentials, which means the IMDS credential chain did not resolve. From the instance:
```bash
ssh -i s3-webapp-demo.pem ec2-user@<public_ip>
# IMDSv2: fetch a token, then the role name behind the instance profile
TOKEN=$(curl -sX PUT http://169.254.169.254/latest/api/token \
-H 'X-aws-ec2-metadata-token-ttl-seconds: 60')
curl -s -H "X-aws-ec2-metadata-token: $TOKEN" \
http://169.254.169.254/latest/meta-data/iam/security-credentials/
# Confirm the resolved identity — expect assumed-role/s3-webapp-role/<instance-id>
aws sts get-caller-identity --endpoint-url https://<predastore_host>
```
If the role name is empty, the instance profile was not attached — confirm `iam_instance_profile` on the instance and that `iam:PassRole` is allowed for the operator.
### AMI Not Found
Ensure you have imported an Ubuntu 26.04 image:
+70 -8
View File
@@ -8,13 +8,25 @@
# Architecture:
#
# Browser ──HTTP──▶ EC2 Instance (Flask webapp, port 80)
# │
# ▼ S3 API (boto3)
# │ ▲ IMDS: short-lived STS creds (ASIA + token)
# │ └──── 169.254.169.254
# ▼ S3 API (boto3, SigV4 with session token)
# Predastore (port 8443)
#
# Credentials:
# The Terraform run uses the operator's admin keys (s3_access_key /
# s3_secret_key) to create the bucket, IAM role, instance profile, and
# instance. The INSTANCE receives no long-lived secret — boto3's default
# credential chain pulls short-lived STS credentials from IMDS
# (169.254.169.254), resolved via the attached instance profile -> role.
#
# Prerequisites:
# - Spinifex services running (gateway on port 9999)
# - Predastore running (S3 API on port 8443)
# - The operator identity (AWS_PROFILE=spinifex) must be allowed
# iam:CreateRole / iam:CreatePolicy / iam:AttachRolePolicy /
# iam:CreateInstanceProfile / iam:AddRoleToInstanceProfile and
# iam:PassRole on the new role.
# - The EC2 instance must be able to reach the Predastore endpoint.
# Set `predastore_host` to the IP reachable from inside the VPC
# (e.g. the host's br-wan IP, NOT localhost).
@@ -80,13 +92,13 @@ variable "predastore_host" {
variable "s3_access_key" {
type = string
description = "S3 access key for Predastore"
description = "Operator S3 access key for the Terraform run (creates the bucket on Predastore); the instance uses IMDS, not this key"
}
variable "s3_secret_key" {
type = string
sensitive = true
description = "S3 secret key for Predastore"
description = "Operator S3 secret key for the Terraform run; the instance uses IMDS, not this key"
}
variable "bucket_name" {
@@ -154,6 +166,55 @@ resource "aws_s3_bucket" "uploads" {
bucket = var.bucket_name
}
# ---------------------------------------------------------------------------
# IAM Instance Role — least-privilege S3 access fetched at runtime via IMDS
# ---------------------------------------------------------------------------
resource "aws_iam_role" "webapp" {
name = "s3-webapp-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Principal = { Service = "ec2.amazonaws.com" }
Action = "sts:AssumeRole"
}]
})
}
resource "aws_iam_policy" "webapp_s3" {
name = "s3-webapp-policy"
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "ListBucket"
Effect = "Allow"
Action = ["s3:ListBucket"]
Resource = ["arn:aws:s3:::${var.bucket_name}"]
},
{
Sid = "ObjectRW"
Effect = "Allow"
Action = ["s3:GetObject", "s3:PutObject"]
Resource = ["arn:aws:s3:::${var.bucket_name}/*"]
}
]
})
}
resource "aws_iam_role_policy_attachment" "webapp" {
role = aws_iam_role.webapp.name
policy_arn = aws_iam_policy.webapp_s3.arn
}
resource "aws_iam_instance_profile" "webapp" {
name = "s3-webapp-profile"
role = aws_iam_role.webapp.name
}
# ---------------------------------------------------------------------------
# SSH Key Pair
# ---------------------------------------------------------------------------
@@ -273,9 +334,14 @@ resource "aws_instance" "webapp" {
subnet_id = aws_subnet.public.id
vpc_security_group_ids = [aws_security_group.webapp.id]
key_name = aws_key_pair.webapp.key_name
iam_instance_profile = aws_iam_instance_profile.webapp.name
associate_public_ip_address = true
# The role's permissions must exist before the instance boots and makes its
# first IMDS-credentialed S3 call
depends_on = [aws_iam_role_policy_attachment.webapp]
user_data_base64 = base64encode(<<-USERDATA
#!/bin/bash
set -euo pipefail
@@ -293,8 +359,6 @@ resource "aws_instance" "webapp" {
cat > /opt/webapp/.env <<'ENVFILE'
S3_ENDPOINT=https://${var.predastore_host}
S3_BUCKET=${var.bucket_name}
S3_ACCESS_KEY=${var.s3_access_key}
S3_SECRET_KEY=${var.s3_secret_key}
S3_REGION=${var.region}
ENVFILE
@@ -321,8 +385,6 @@ resource "aws_instance" "webapp" {
s3 = boto3.client(
"s3",
endpoint_url=env["S3_ENDPOINT"],
aws_access_key_id=env["S3_ACCESS_KEY"],
aws_secret_access_key=env["S3_SECRET_KEY"],
region_name=env["S3_REGION"],
verify=False,
config=Config(s3={"addressing_style": "path"}),
@@ -2,6 +2,11 @@
#
# The predastore_host must be reachable from INSIDE the VPC guest — not
# localhost. Use the host's br-wan or LAN IP, e.g. "192.168.1.10:8443".
#
# s3_access_key / s3_secret_key are the operator credentials for the Terraform
# run only — they create the bucket on Predastore and the IAM role/profile.
# They are NOT passed to the instance; the instance pulls short-lived STS
# credentials from IMDS via its instance profile.
predastore_host = "192.168.1.10:8443"
s3_access_key = "AKIAIOSFODNN7EXAMPLE"
+14 -14
View File
@@ -13,8 +13,8 @@ require (
github.com/insomniacslk/dhcp v0.0.0-20260407060928-11b94ed970f2
github.com/kdomanski/iso9660 v0.4.0
github.com/klauspost/cpuid/v2 v2.3.0
github.com/mulgadc/predastore v1.7.0
github.com/mulgadc/viperblock v1.7.0
github.com/mulgadc/predastore v1.8.0
github.com/mulgadc/viperblock v1.8.0
github.com/nats-io/nats-server/v2 v2.14.2
github.com/nats-io/nats.go v1.52.0
github.com/ovn-kubernetes/libovsdb v0.8.1
@@ -24,10 +24,10 @@ require (
github.com/spf13/viper v1.21.0
github.com/stretchr/testify v1.11.1
go.uber.org/goleak v1.3.0
golang.org/x/crypto v0.52.0
golang.org/x/sync v0.20.0
golang.org/x/sys v0.45.0
gopkg.in/ini.v1 v1.67.2
golang.org/x/crypto v0.53.0
golang.org/x/sync v0.21.0
golang.org/x/sys v0.46.0
gopkg.in/ini.v1 v1.67.3
gopkg.in/yaml.v3 v3.0.1
)
@@ -38,7 +38,7 @@ require (
github.com/antithesishq/antithesis-sdk-go v0.7.0 // indirect
github.com/armon/go-metrics v0.4.1 // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aws/aws-sdk-go-v2 v1.41.12 // indirect
github.com/aws/aws-sdk-go-v2 v1.42.0 // indirect
github.com/aws/smithy-go v1.27.1 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/beorn7/perks v1.0.1 // indirect
@@ -57,7 +57,7 @@ require (
github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
github.com/containerd/console v1.0.5 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dgraph-io/badger/v4 v4.9.1 // indirect
github.com/dgraph-io/badger/v4 v4.9.2 // indirect
github.com/dgraph-io/ristretto/v2 v2.4.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
@@ -126,13 +126,13 @@ require (
go.opentelemetry.io/otel/trace v1.42.0 // indirect
go.yaml.in/yaml/v2 v2.4.4 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/mod v0.35.0 // indirect
golang.org/x/net v0.55.0 // indirect
golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa // indirect
golang.org/x/term v0.43.0 // indirect
golang.org/x/text v0.37.0 // indirect
golang.org/x/mod v0.36.0 // indirect
golang.org/x/net v0.56.0 // indirect
golang.org/x/telemetry v0.0.0-20260508192327-42602be52be6 // indirect
golang.org/x/term v0.44.0 // indirect
golang.org/x/text v0.38.0 // indirect
golang.org/x/time v0.15.0 // indirect
golang.org/x/tools v0.44.0 // indirect
golang.org/x/tools v0.45.0 // indirect
golang.org/x/vuln v1.1.4 // indirect
google.golang.org/protobuf v1.36.11 // indirect
)
+44 -44
View File
@@ -31,28 +31,28 @@ github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aws/aws-sdk-go v1.55.8 h1:JRmEUbU52aJQZ2AjX4q4Wu7t4uZjOu71uyNmaWlUkJQ=
github.com/aws/aws-sdk-go v1.55.8/go.mod h1:ZkViS9AqA6otK+JBBNH2++sx1sgxrPKcSzPPvQkUtXk=
github.com/aws/aws-sdk-go-v2 v1.41.12 h1:DIKX2c31ekm9RA2D9FBj1EWXx++9AdAqRw+e78Tq2Ck=
github.com/aws/aws-sdk-go-v2 v1.41.12/go.mod h1:27+ACypSLljLAEKsCYOmrjKh83vuTRkuAe9Uv/3A4bg=
github.com/aws/aws-sdk-go-v2 v1.42.0 h1:XvXMJTkFQtpBKIWZnmr9ZEOc2InWM2yldjXEJ/bymhA=
github.com/aws/aws-sdk-go-v2 v1.42.0/go.mod h1:27+ACypSLljLAEKsCYOmrjKh83vuTRkuAe9Uv/3A4bg=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.13 h1:p1BBrg/Hhp6uK7zpejeI8QFXHJeC/mynzi04Sl03k9g=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.13/go.mod h1:8cIfkE9MDhkRZGpQ22aV6/lkYeYSozpz16Smrs5x4Ls=
github.com/aws/aws-sdk-go-v2/credentials v1.19.22 h1:SHfH6wyPsEgG7fVsi5rQxWEt7tuIcN2PGhb1mTFv6tE=
github.com/aws/aws-sdk-go-v2/credentials v1.19.22/go.mod h1:54nO8lKD4aQPOntM/VTWjnR+DYzTwx0YkSMZMhAgewQ=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.28 h1:Xf2j7NdVcUKomlZ4iihOP4AZ3Fzlr8h4yKpXeP+OFPg=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.28/go.mod h1:O8cDo1dW63jU7ki//kRe1z+tLGcpnD1jrouitsQddDw=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.28 h1:KqIfN9kpkKkcBqBbNpNGTIrXO6ExTUvFKvXkC+YAzVo=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.28/go.mod h1:uxtQiKvLtNS4iXVsH2McVD/ls8FKN/uUhe1hGxPjrw0=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.29 h1:VkE9FuzTQVjBBrnj4+oCdxCLFIz7aqLYKUCjtvxVcOs=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.29/go.mod h1:H32Z2Qth9b+9LqjyBsCnozMQ8H2N7YBUDVXwbs0iggg=
github.com/aws/aws-sdk-go-v2/credentials v1.19.24 h1:2hQqYCV9yqyePQ9o6dCrZc/zO8U3TwPr9mIKlZnPu/I=
github.com/aws/aws-sdk-go-v2/credentials v1.19.24/go.mod h1:IDwpACtwqHLISdzfwUUNq4P9DsB/h5BLg4FwJPNfqFY=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29 h1:f3vKqSo13fhTYb+JEcXwXefZQE26I1FB5eTSniU67ko=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.29/go.mod h1:MzoLFUArKGpGD+ukmPiTPG1X5x4o6M2kq4v2dr1FiEc=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29 h1:RdwIf/CuUsvJX3RgJagbOyotl/cxoLY4xviKuE7p2GY=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.29/go.mod h1:71wt8W2EgswdZy9Mf9KNnzxZ3TiZlv4caKghPktDOkA=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30 h1:VTGy885W5DKBxWRUJbym9hytNaYzsyaPkCHGRRMAOhU=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.30/go.mod h1:AS0HycUvJRFvTt613AYDOgO2jzw+00cVSMny8XB3yMY=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12 h1:ZD2+BSw9vFsNlKYIasSNt3uDbjqqXIBcM13UJv/Lx2k=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.12/go.mod h1:Ms4zlcVBbXbiP7EVLhl+lgjvA/a7YphqQ3Ih3174EmI=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.21 h1:FsZxbPiVgEHYofziwfylouMki8b1Z7mI4CMU/7bhwBA=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.21/go.mod h1:Mmm30OV+JLXYQUcbSd84THnv3P5JtjhVDujLwMqRG0U=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.28 h1:axj4mEDletwKmTm/9jR+DkIMmCfcn5vE4jBMAAN+3Vg=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.28/go.mod h1:3Aaz69M0jqfSHLKqxgolgUBFT4hpwSNc7DzC95orEi8=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.28 h1:li8rTZAAb22g4UsxbjwMdaNVWbgVcDzPqI7nDTI+mF4=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.28/go.mod h1:/brXioSGIMEdcBFoubpSdmighSVp6poP+mma/wB7iHA=
github.com/aws/aws-sdk-go-v2/service/s3 v1.103.2 h1:b4ikkRk22T4xYkEgaWc3Voe+3xbt5YbbFhNehOWyUiY=
github.com/aws/aws-sdk-go-v2/service/s3 v1.103.2/go.mod h1:Gp7eHZ0NZ8ZK5RXpoIUp/C8OeAmJqpCgdwEK1D/QOek=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.22 h1:V51LGlOq/1VsDsHUdoklAQi7rMmx4qQubvFYAlP2254=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.22/go.mod h1:4Pzhyz8hJOm2bepgl+NjvRx8vlUFAIIvJnZ/MkcNPpU=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29 h1:DRebniUGZ2MqiiIVmQJ04vIXr918hubdHMnarSLEWyU=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.29/go.mod h1:LfRkPCD8YHDM2E5eTkos2UpwYeZnBcVarTa8L59bJHA=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.29 h1:hiME6pBzC7OTl9LMtlyTWBuEl1f4QBcUmFDKC7MLXtc=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.29/go.mod h1:G7RP+uhagpKtKhd1BM9N6JQqjCcGEU47K5lBVZQyRQw=
github.com/aws/aws-sdk-go-v2/service/s3 v1.103.3 h1:JRseEu/vIDMaWis4bSw0QbXL+cvIGc1XnX076H5ZXLE=
github.com/aws/aws-sdk-go-v2/service/s3 v1.103.3/go.mod h1:77ZAgynvx1txMvDG8gGWoWkO1augYDxkp9JElWFgjQU=
github.com/aws/smithy-go v1.27.1 h1:4T340VFndXtADGF52gYa1POyL7s9E4Z1OeZ1hCscIw8=
github.com/aws/smithy-go v1.27.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
@@ -103,8 +103,8 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6N
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgraph-io/badger/v4 v4.9.1 h1:DocZXZkg5JJHJPtUErA0ibyHxOVUDVoXLSCV6t8NC8w=
github.com/dgraph-io/badger/v4 v4.9.1/go.mod h1:5/MEx97uzdPUHR4KtkNt8asfI2T4JiEiQlV7kWUo8c0=
github.com/dgraph-io/badger/v4 v4.9.2 h1:Wb5qw8gElqwV1a8msHTeQKova9b1V10heFKMIiPd80E=
github.com/dgraph-io/badger/v4 v4.9.2/go.mod h1:nJjaJTUOSsQEBhsq209FmwCvMJzEA3e74RjZw6V2pQI=
github.com/dgraph-io/ristretto/v2 v2.4.0 h1:I/w09yLjhdcVD2QV192UJcq8dPBaAJb9pOuMyNy0XlU=
github.com/dgraph-io/ristretto/v2 v2.4.0/go.mod h1:0KsrXtXvnv0EqnzyowllbVJB8yBonswa2lTCK2gGo9E=
github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da h1:aIftn67I1fkbMa512G+w+Pxci9hJPB8oMnkcP3iZF38=
@@ -288,10 +288,10 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/mulgadc/predastore v1.7.0 h1:TuCU1oxs5EUJCqL3MybpyH1JR3WyxLHIj4Fa/sM9a5k=
github.com/mulgadc/predastore v1.7.0/go.mod h1:0334T4L4Dby54TCuugpumyOhtvHQX+WSxQ/REDNSk9o=
github.com/mulgadc/viperblock v1.7.0 h1:+SAWg/Dcyb03PC+5ZB5Ab4HWum9PFWJ5KfeVkcivowY=
github.com/mulgadc/viperblock v1.7.0/go.mod h1:F/LOVydipnyjX9QMkAuf/gjdWeYjaniGHU4emwJwq+U=
github.com/mulgadc/predastore v1.8.0 h1:Di/PlAP8nIVjXMmjVPuS9UtqQkqMjR41RrC5J21vHmw=
github.com/mulgadc/predastore v1.8.0/go.mod h1:TC6I4Z27RCe2gPqlxqUehJ2J+/jKggnH+4DyMq1H/ng=
github.com/mulgadc/viperblock v1.8.0 h1:kVyM5RxomokVa1uA2kbhtS02kZ9aDXKVj4MvGeqsKiM=
github.com/mulgadc/viperblock v1.8.0/go.mod h1:18zxN4szsp5h6w8nTqkZWP0SWmIrLU3NdpKXDYNanls=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
@@ -428,14 +428,14 @@ golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnf
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa h1:FRnLl4eNAQl8hwxVVC17teOw8kdjVDVAiFMtgUdTSRQ=
golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa/go.mod h1:zk2irFbV9DP96SEBUUAy67IdHUaZuSnrz1n472HUCLE=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20181114220301-adae6a3d119a/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -446,8 +446,8 @@ golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81R
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -456,8 +456,8 @@ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJ
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181116152217-5ac8a444bdc5/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -488,33 +488,33 @@ golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa h1:efT73AJZfAAUV7SOip6pWGkwJDzIGiKBZGVzHYa+ve4=
golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa/go.mod h1:kHjTxDEnAu6/Nl9lDkzjWpR+bmKfxeiRuSDlsMb70gE=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20260508192327-42602be52be6 h1:HjU6IWBiAgRIdAJ9/y1rwCn+UELEmwV+VsTLzj/W4sE=
golang.org/x/telemetry v0.0.0-20260508192327-42602be52be6/go.mod h1:Eqhaxk/wZsWEH8CRxLwj6xzEJbz7k1EFGqx7nyCoabE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE=
golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM=
golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY=
golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM=
@@ -538,8 +538,8 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/ini.v1 v1.67.2 h1:JtOSMb9OuaCZKr7h5D/h6iii14sK0hLbplTc6frx4Ss=
gopkg.in/ini.v1 v1.67.2/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss=
gopkg.in/ini.v1 v1.67.3 h1:iM9Lhz5MRSGhHVGGwCuzG9KO8PoirCXj/m/qTmOJJQw=
gopkg.in/ini.v1 v1.67.3/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
+20 -11
View File
@@ -1,8 +1,6 @@
// Package eksgw is the on-VM SigV4 client EKS control-plane helpers use to
// reach the AWS gateway, instead of dialing core NATS. Both eks-gateway-publish
// (bootstrap + state relay) and eks-token-webhook (TokenReview relay) sign with
// the system (Predastore) credentials for service "eks" and POST over HTTPS;
// the gateway authorizes via IAM and relays to the host-side NATS/STS/KV.
// Package eksgw is the on-VM SigV4 client used by EKS helpers (eks-gateway-publish,
// eks-token-webhook) to POST over HTTPS to the AWS gateway instead of NATS.
// Signs with Predastore credentials for service "eks".
package eksgw
import (
@@ -73,16 +71,27 @@ func New(baseURL, caPath, accessKey, secretKey, region string) (*Client, error)
}, nil
}
// Post SigV4-signs and sends a single POST of body to path (e.g.
// "/clusters/alpha/token-review"). It returns the response body on a 2xx, or an
// error (including the gateway status + body) otherwise. No retry — callers that
// need it wrap Post in their own loop.
// Post SigV4-signs and sends body to path, returning the response body on 2xx.
// Errors include the gateway status and body. No retry; callers wrap as needed.
func (c *Client) Post(path string, body []byte) ([]byte, error) {
req, err := http.NewRequest(http.MethodPost, c.baseURL+path, bytes.NewReader(body))
return c.do(http.MethodPost, path, body)
}
// Get SigV4-signs and sends a single GET to path (e.g.
// "/clusters/alpha/internal-addons?accountId=..."). Same contract as Post: 2xx
// body or an error carrying the gateway status. No retry.
func (c *Client) Get(path string) ([]byte, error) {
return c.do(http.MethodGet, path, nil)
}
func (c *Client) do(method, path string, body []byte) ([]byte, error) {
req, err := http.NewRequest(method, c.baseURL+path, bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
req.Header.Set("Content-Type", "application/x-amz-json-1.1")
if body != nil {
req.Header.Set("Content-Type", "application/x-amz-json-1.1")
}
sum := sha256.Sum256(body)
if err := auth.SignReq(req, c.accessKey, c.secretKey, hex.EncodeToString(sum[:]), "eks", c.region); err != nil {
+3 -9
View File
@@ -1,12 +1,6 @@
// Package fipsboot enforces FIPS 140-3 mode at process startup.
//
// Blank-import this package from every binary's main:
//
// import _ "github.com/mulgadc/spinifex/internal/fipsboot"
//
// Builds with GOFIPS140=v1.0.0 link the Go Cryptographic Module v1.0.0
// (NIST CMVP cert #5247). At runtime FIPS mode can still be turned off
// via GODEBUG=fips140=off; init() panics in that case to close the hole.
// Package fipsboot enforces FIPS 140-3 at startup via blank import in every binary.
// Requires GOFIPS140=v1.0.0 at build time; panics if GODEBUG=fips140=off disables
// the runtime FIPS mode.
package fipsboot
import (
+5 -26
View File
@@ -1,34 +1,13 @@
// Package tlsconfig pins the cluster-wide TLS 1.3 curve preference list.
//
// The list is hardcoded because mixed classification on a single cluster is
// not supported and the certified FIPS build is itself the per-deployment
// choice. A typo in a config string would silently downgrade where a missing
// constant cannot.
// Hardcoded to prevent silent downgrade via misconfiguration; the FIPS build
// is the per-deployment choice.
package tlsconfig
import "crypto/tls"
// Curves is the fixed TLS 1.3 curve allowlist:
//
// - X25519MLKEM768 — Cat 3 ML-KEM hybrid, wins under Go 1.26 stdlib filter
// - SecP384r1MLKEM1024 — Cat 5 ML-KEM hybrid, fallback for peers that prefer it
// - X25519 — classical TLS 1.3 fallback for SDKs without ML-KEM (aws CLI v2 today)
// - CurveP384 — classical TLS 1.3 fallback (Java/.NET enterprise SDKs)
//
// PQ-capable peers (browsers since 2024, Go aws-sdk v2 with Go 1.24+) always
// negotiate the hybrid first; classical fallback only triggers for peers that
// cannot do ML-KEM. Those connections still get TLS 1.3 + AES-256-GCM but
// remain HNDL-exposed — accepted trade-off until AWS CLI v2 / bundled
// OpenSSL ships ML-KEM (currently lagging upstream OpenSSL 3.5).
//
// Weak/niche entries from the Go stdlib default (SecP256r1MLKEM768, P-256,
// P-521) are intentionally excluded so they cannot be advertised.
//
// Go 1.26 stdlib treats tls.Config.CurvePreferences as an allowlist filter
// applied to the stdlib default order, not as a user-supplied order.
//
// Callers must not mutate this slice. Assign it directly to
// tls.Config.CurvePreferences; do not append to it.
// Curves is the fixed TLS 1.3 curve allowlist: two ML-KEM hybrids (PQ-capable
// peers always negotiate these) followed by two classical fallbacks for SDKs
// without ML-KEM. Weak Go defaults (SecP256r1MLKEM768, P-256, P-521) excluded.
var Curves = []tls.CurveID{
tls.X25519MLKEM768,
tls.SecP384r1MLKEM1024,
+5 -9
View File
@@ -46,11 +46,9 @@ func TestCurves_ExcludesWeakPrimitives(t *testing.T) {
}
}
// TestIntegration_NegotiatesPQHybrid spins up an httptest TLS server
// configured with Curves + TLS 1.3 minimum, dials it, and asserts a PQ
// hybrid is negotiated at TLS 1.3. Per Go 1.26 stdlib filter behavior the
// winner is X25519MLKEM768; the assertion checks for any approved hybrid
// to remain stable across stdlib reorderings of our 4 entries.
// TestIntegration_NegotiatesPQHybrid verifies that a Curves-configured server
// negotiates a PQ hybrid at TLS 1.3; checks for any approved hybrid to stay
// stable across Go stdlib reorderings.
func TestIntegration_NegotiatesPQHybrid(t *testing.T) {
cert := selfSignedCert(t)
srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
@@ -95,10 +93,8 @@ func TestIntegration_NegotiatesPQHybrid(t *testing.T) {
}
}
// TestIntegration_TLS12ClientRejected confirms MinVersion: tls.VersionTLS13
// is enforced — a TLS 1.2-pinned client gets a handshake error rather than
// silently downgrading. This is the negative test that closes the HNDL gap
// on external surfaces.
// TestIntegration_TLS12ClientRejected confirms MinVersion TLS 1.3 is enforced:
// a TLS 1.2-pinned client must get a handshake error, not a silent downgrade.
func TestIntegration_TLS12ClientRejected(t *testing.T) {
cert := selfSignedCert(t)
srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+31 -15
View File
@@ -81,30 +81,46 @@ EOF
--initdb \
$ALPINE_PACKAGES
# Strategy 2/3: container runtime (dev machines)
else
if command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then
CONTAINER_TOOL=docker
elif command -v podman >/dev/null 2>&1; then
CONTAINER_TOOL=podman
else
echo "ERROR: no rootfs build tool available." >&2
echo " Install one of: apk (Alpine), docker, podman" >&2
exit 1
fi
# Strategy 2: docker -> BuildKit local export.
# Do NOT use `docker export` here. On Docker's overlay2 driver it leaks the
# container's overlay mount (the mount survives `docker rm`), orphaning layers
# under /var/lib/docker that only a daemon restart reclaims; on a busy CI host
# this grows without bound. `buildx --output type=local` writes the image
# filesystem straight to a directory and releases every mount, so nothing leaks.
elif command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then
echo "[build-microvm-image] rootfs: docker buildx --output type=local (alpine:${ALPINE_VERSION})"
BUILD_CTX=$(mktemp -d)
cat > "$BUILD_CTX/Dockerfile" <<DOCKERFILE
FROM alpine:${ALPINE_VERSION}
RUN apk add --no-cache ${ALPINE_PACKAGES}
DOCKERFILE
docker buildx build \
--platform linux/amd64 \
--output "type=local,dest=${CHROOT_DIR}" \
"$BUILD_CTX"
rm -rf "$BUILD_CTX"
echo "[build-microvm-image] rootfs: $CONTAINER_TOOL export (alpine:${ALPINE_VERSION})"
CONTAINER_CID=$($CONTAINER_TOOL run -d \
# Strategy 3: podman -> run + export. Podman's store does not exhibit the docker
# overlay2 export-mount leak, and podman has no buildx.
elif command -v podman >/dev/null 2>&1; then
echo "[build-microvm-image] rootfs: podman export (alpine:${ALPINE_VERSION})"
CONTAINER_TOOL=podman
CONTAINER_CID=$(podman run -d \
"alpine:${ALPINE_VERSION}" \
sh -c "apk add --no-cache ${ALPINE_PACKAGES}")
exit_code=$($CONTAINER_TOOL wait "$CONTAINER_CID")
exit_code=$(podman wait "$CONTAINER_CID")
if [ "$exit_code" != "0" ]; then
echo "ERROR: package installation failed in container (exit $exit_code)" >&2
exit 1
fi
$CONTAINER_TOOL export "$CONTAINER_CID" | tar -x -C "$CHROOT_DIR"
podman export "$CONTAINER_CID" | tar -x -C "$CHROOT_DIR"
else
echo "ERROR: no rootfs build tool available." >&2
echo " Install one of: apk (Alpine), docker, podman" >&2
exit 1
fi
# --- /dev: empty mountpoint only ---
+9 -4
View File
@@ -26,10 +26,11 @@ PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
export LIBGUESTFS_BACKEND="${LIBGUESTFS_BACKEND:-direct}"
usage() {
echo "Usage: $0 <manifest.conf> [--import]"
echo "Usage: $0 <manifest.conf> [--import] [--no-cache]"
echo ""
echo " manifest.conf Path to image manifest (see scripts/images/ for examples)"
echo " --import Import the built image as an AMI"
echo " --no-cache Always rebuild; ignore a recently-built cached raw"
exit 1
}
@@ -47,10 +48,12 @@ fi
DO_IMPORT=false
QUIET=false
NO_CACHE=false
for arg in "$@"; do
case "$arg" in
--import) DO_IMPORT=true ;;
--quiet) QUIET=true ;;
--import) DO_IMPORT=true ;;
--quiet) QUIET=true ;;
--no-cache) NO_CACHE=true ;;
esac
done
@@ -184,7 +187,9 @@ echo "Lock acquired"
# If the raw image was built recently (< 10 min), skip the entire build.
# This avoids duplicate work when concurrent CI jobs build the same image.
if [[ -f "$OUTPUT_RAW" ]] && [[ $(( $(date +%s) - $(stat -c %Y "$OUTPUT_RAW") )) -lt 600 ]]; then
# --no-cache (e.g. the publish path) forces a fresh build so a stale raw from
# a prior/concurrent run on a persistent runner is never republished.
if [[ "$NO_CACHE" == false ]] && [[ -f "$OUTPUT_RAW" ]] && [[ $(( $(date +%s) - $(stat -c %Y "$OUTPUT_RAW") )) -lt 600 ]]; then
echo "=== Skipping build — $OUTPUT_RAW is fresh (< 10 min old) ==="
# Restore stdout if suppressed, then jump to import
+1 -1
View File
@@ -75,5 +75,5 @@ echo ""
echo "🎉 Development environment setup complete!"
echo ""
echo "Next step:"
echo "Run development setup to build services: ./scripts/dev-setup.sh"
echo "Run development setup to build, install, and start services: ./scripts/dev-install.sh"
echo ""
+3 -3
View File
@@ -44,9 +44,9 @@ sudo systemctl stop spinifex.target 2>/dev/null || true
sudo systemctl reset-failed 'spinifex-*' 2>/dev/null || true
# Remove stale files owned by the dev user in production paths.
# Previous dev-mode installs (admin init without sudo, start-dev.sh) leave
# files owned by tf-user that service users (spinifex-nats, etc.) can't read
# under systemd's ProtectSystem=strict sandboxing.
# A prior admin init run without sudo can leave files owned by tf-user that
# service users (spinifex-nats, etc.) can't read under systemd's
# ProtectSystem=strict sandboxing.
for dir in /var/lib/spinifex /var/log/spinifex /etc/spinifex; do
if [ -d "$dir" ]; then
# Remove PID files, stale logs, and the legacy ~/spinifex/config symlink
-180
View File
@@ -1,180 +0,0 @@
#!/bin/bash
# Spinifex Platform Development Environment Setup
# This script sets up a complete development environment for the Spinifex platform
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
MULGA_ROOT="$(cd "$PROJECT_ROOT/.." && pwd)"
# Default configuration
NATS_PORT=4222
PREDASTORE_PORT=8443
SPINIFEX_GATEWAY_PORT=9999
echo "🚀 Setting up Spinifex development environment..."
echo "Project root: $PROJECT_ROOT"
# Function to check if command exists
command_exists() {
command -v "$1" >/dev/null 2>&1
}
# Function to check if port is available
port_available() {
! nc -z localhost "$1" 2>/dev/null
}
# Check dependencies
echo "🔍 Checking dependencies..."
# Check for required commands
required_commands=("go" "make")
missing_required=false
for cmd in "${required_commands[@]}"; do
if command_exists "$cmd"; then
echo "✅ $cmd found"
else
echo "❌ $cmd not found. Please install $cmd"
missing_required=true
case "$cmd" in
"go")
echo " Install Go: https://golang.org/dl/"
;;
esac
fi
done
if [ "$missing_required" = true ]; then
echo "❌ Missing required dependencies. Cannot continue."
exit 1
fi
# Check optional commands
optional_commands=("air" "nbdkit")
for cmd in "${optional_commands[@]}"; do
if command_exists "$cmd"; then
echo "✅ $cmd found (optional)"
else
echo "⚠️ $cmd not found (optional)"
case "$cmd" in
"air")
echo " Install air for hot reloading: go install github.com/air-verse/air@latest"
;;
"nbdkit")
echo " Install nbdkit: sudo apt-get install nbdkit (Ubuntu/Debian)"
;;
esac
fi
done
# Check OVN/OVS commands (required for VPC networking)
echo ""
echo "🔍 Checking OVN/OVS dependencies..."
ovn_commands=("ovs-vsctl" "ovn-controller" "ovn-nbctl")
ovn_ready=true
for cmd in "${ovn_commands[@]}"; do
if command_exists "$cmd"; then
echo "✅ $cmd found"
else
echo "⚠️ $cmd not found (required for VPC networking)"
ovn_ready=false
fi
done
if [ "$ovn_ready" = false ]; then
echo " Install OVN: sudo apt-get install ovn-central ovn-host openvswitch-switch"
echo " Or run: make quickinstall"
fi
# Initialize br-int if OVS is available
if command_exists "ovs-vsctl"; then
echo ""
echo "🌐 Checking OVS br-int bridge..."
if ovs-vsctl br-exists br-int 2>/dev/null; then
echo "✅ br-int already exists"
else
echo "⚠️ br-int not found. Run ./scripts/setup-ovn.sh to initialize OVN networking"
fi
fi
# Check component repositories
echo ""
echo "📦 Checking component repositories..."
components=("viperblock" "predastore")
for component in "${components[@]}"; do
component_path="$MULGA_ROOT/$component"
if [[ -d "$component_path" ]]; then
echo "✅ $component found at $component_path"
if [[ -f "$component_path/go.mod" ]]; then
echo " Go module verified"
else
echo " ⚠️ No go.mod found in $component"
fi
else
echo "❌ $component not found at $component_path"
echo " Run: ./scripts/clone-deps.sh"
fi
done
# Check port availability
echo ""
echo "🔌 Checking port availability..."
ports=("$NATS_PORT:NATS" "$PREDASTORE_PORT:Predastore" "$SPINIFEX_GATEWAY_PORT:Spinifex Gateway")
for port_info in "${ports[@]}"; do
port="${port_info%:*}"
service="${port_info#*:}"
if port_available "$port"; then
echo "✅ Port $port available for $service"
else
echo "⚠️ Port $port already in use (needed for $service)"
fi
done
# Build Spinifex first (needed for admin init)
echo ""
echo "🔨 Building Spinifex..."
cd "$PROJECT_ROOT"
make build
echo "✅ Spinifex built successfully"
# Build components
echo ""
echo "🔨 Building components..."
# Build Viperblock
if [[ -d "$MULGA_ROOT/viperblock" ]]; then
echo "📦 Building Viperblock..."
cd "$MULGA_ROOT/viperblock"
make build
echo "✅ Viperblock built successfully"
else
echo "⚠️ Viperblock directory not found, skipping build"
fi
# Build Predastore
if [[ -d "$MULGA_ROOT/predastore" ]]; then
echo "📦 Building Predastore..."
cd "$MULGA_ROOT/predastore"
make build
echo "✅ Predastore built successfully"
else
echo "⚠️ Predastore directory not found, skipping build"
fi
echo ""
echo "Development environment setup complete!"
echo ""
echo "Recommended: Use scripts/dev-install.sh for a production-parity setup"
echo " (systemd, privilege separation, production paths)."
echo ""
echo " scripts/dev-install.sh # One-time: build, install, init, start via systemd"
echo " make deploy # Daily: rebuild + copy binary + restart all"
echo " make reinstall # Changed a .service file? Re-run full setup"
echo ""
echo "Legacy workflow (deprecated, retained for pseudo-multinode CI only):"
echo " ./scripts/setup-ovn.sh --management"
echo " sudo ./bin/spx admin init --region ap-southeast-2 --az ap-southeast-2a --node node1 --nodes 1"
echo " ./scripts/start-dev.sh"
-9
View File
@@ -1,9 +0,0 @@
# IAC
Collection of OpenTofu/Terraform templates for creating Spinifex instances.
| Template | Description |
|---|---|
| [proxmox/](proxmox/) | Provisions a 3-node Spinifex cluster on Proxmox VE bare-metal hypervisors |
| [aws/](aws/) | Provisions infrastructure using the Terraform AWS provider, with MulgaOS AWS-compatible API as the local endpoint |
| [proxmox-template/](proxmox-template/) | Builds a Proxmox VM template with all Spinifex dependencies pre-installed (QEMU, Go, repos, modules) |
-206
View File
@@ -1,206 +0,0 @@
# MulgaOS AWS Infrastructure as Code
This directory contains OpenTofu/Terraform configuration files for deploying infrastructure on MulgaOS using AWS-compatible APIs.
## Prerequisites
1. **OpenTofu or Terraform installed**
```bash
# Install OpenTofu (recommended)
# See: https://opentofu.org/docs/intro/install/
# Or use Terraform
# See: https://developer.hashicorp.com/terraform/install
```
2. **MulgaOS services running**
- `spinifex daemon` - Core daemon with NATS subscriptions
- `awsd` - AWS API gateway (default: http://localhost:8080)
- `predastore` - S3-compatible storage
- `viperblock` - EBS-compatible block storage
3. **AWS credentials configured**
```bash
# Option 1: Environment variables
export AWS_ACCESS_KEY_ID=your_access_key
export AWS_SECRET_ACCESS_KEY=your_secret_key
# Option 2: AWS CLI profile
aws configure --profile spinifex
export AWS_PROFILE=spinifex
```
4. **AMI available**
Before running, ensure you have a Debian 13 AMI imported:
```bash
# Import an AMI using the spinifex CLI or aws CLI
aws --endpoint-url http://localhost:8080 ec2 describe-images
```
## Configuration
### Environment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| `TF_VAR_mulgaos_endpoint` | `http://localhost:8080` | MulgaOS API endpoint |
| `TF_VAR_region` | `ap-southeast-2` | AWS region (for compatibility) |
| `AWS_ACCESS_KEY_ID` | - | MulgaOS access key |
| `AWS_SECRET_ACCESS_KEY` | - | MulgaOS secret key |
### terraform.tfvars (optional)
Create a `terraform.tfvars` file for custom settings:
```hcl
mulgaos_endpoint = "http://192.168.1.100:8080"
region = "us-west-2"
```
## Usage
### Initialize
```bash
cd iac/aws
tofu init
# or: terraform init
```
### Validate
```bash
tofu validate
# or: terraform validate
```
### Plan
```bash
tofu plan
# or: terraform plan
```
### Apply
```bash
tofu apply
# or: terraform apply
```
### Destroy
```bash
tofu destroy
# or: terraform destroy
```
## What Gets Created
The `main.tf` creates:
1. **VPC** (`10.1.1.0/16`) - Virtual Private Cloud
2. **Internet Gateway** - For public internet access
3. **Public Subnet** (`10.1.2.0/24`) - With auto-assign public IP
4. **Route Table** - Routes `0.0.0.0/0` through IGW
5. **Security Group** - Allows SSH (22), HTTP (80), HTTPS (443) inbound
6. **Placement Group** - Spread strategy for HA
7. **3 EC2 Instances** - t3.small Debian 13 VMs
8. **SSH Key Pair** - ED25519 key saved to `spinifex-test.pem`
## SSH Access
After `tofu apply`, SSH into instances:
```bash
# Using the generated key
ssh -i spinifex-test.pem admin@<public_ip>
# For local development with QEMU port forwarding
# Find the forwarded port from QEMU args (-netdev user,id=net0,hostfwd=tcp:127.0.0.1:PORT-:22)
ssh -p PORT -i spinifex-test.pem admin@127.0.0.1
```
## Troubleshooting
### "No valid credential sources found"
Ensure AWS credentials are set:
```bash
export AWS_ACCESS_KEY_ID=your_key
export AWS_SECRET_ACCESS_KEY=your_secret
```
### "Error: No AMI found"
Import a Debian 13 AMI first, or modify `main.tf` to use a different AMI filter.
### Connection refused
Ensure MulgaOS services are running:
```bash
# Check if awsd gateway is running
curl http://localhost:8080/health
# Check daemon status
spinifex daemon status
```
### "InvalidAction" errors
Some AWS operations may not be implemented yet. Check `TODO.md` for the current implementation status.
## Customization
### Using a different AMI
Modify the `data "aws_ami"` block in `main.tf`:
```hcl
data "aws_ami" "custom" {
most_recent = true
owners = ["self"] # Your imported AMIs
filter {
name = "name"
values = ["my-custom-ami-*"]
}
}
```
### Changing instance count
Modify the `count` parameter:
```hcl
resource "aws_instance" "vm" {
count = 5 # Change from 3 to 5
# ...
}
```
### Different instance type
```hcl
resource "aws_instance" "vm" {
instance_type = "t3.medium" # Change from t3.small
# ...
}
```
## Files
| File | Description |
|------|-------------|
| `main.tf` | Main Terraform/OpenTofu configuration |
| `spinifex-test.pem` | Generated SSH private key (after apply) |
| `.terraform/` | Provider plugins (after init) |
| `terraform.tfstate` | State file (after apply) |
| `terraform.tfvars` | Custom variables (optional) |
## Related Documentation
- [MulgaOS DEV.md](../../DEV.md) - Development guide
- [MulgaOS TODO.md](../../TODO.md) - Implementation status
- [OpenTofu Documentation](https://opentofu.org/docs/)
- [AWS Provider Documentation](https://registry.terraform.io/providers/hashicorp/aws/latest/docs)
-307
View File
@@ -1,307 +0,0 @@
# Spinifex IaC Compatibility Test — "basic" suite
#
# Exercises every AWS resource Spinifex currently supports via tofu apply,
# WITHOUT route tables, NACLs, or other unimplemented networking.
#
# Usage:
# cd spinifex/scripts/iac/aws/basic
# tofu init
# tofu plan
# tofu apply # full round-trip against a running Spinifex cluster
# tofu destroy # clean teardown
terraform {
required_version = ">= 1.6.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0"
}
tls = {
source = "hashicorp/tls"
version = ">= 4.0"
}
local = {
source = "hashicorp/local"
version = ">= 2.0"
}
}
}
# ---------------------------------------------------------------------------
# Variables
# ---------------------------------------------------------------------------
variable "region" {
type = string
default = "ap-southeast-2"
}
variable "mulgaos_endpoint" {
type = string
default = "https://localhost:9999"
description = "Spinifex AWS gateway endpoint"
}
# ---------------------------------------------------------------------------
# Provider
# ---------------------------------------------------------------------------
provider "aws" {
region = var.region
endpoints {
ec2 = var.mulgaos_endpoint
s3 = var.mulgaos_endpoint
iam = var.mulgaos_endpoint
sts = var.mulgaos_endpoint
}
skip_credentials_validation = true
skip_metadata_api_check = true
skip_requesting_account_id = true
skip_region_validation = true
}
# ---------------------------------------------------------------------------
# Data sources
# ---------------------------------------------------------------------------
data "aws_availability_zones" "available" {
state = "available"
}
data "aws_ami" "debian13" {
most_recent = true
owners = ["000000000000"]
filter {
name = "name"
values = ["*debian-13*"]
}
filter {
name = "virtualization-type"
values = ["hvm"]
}
filter {
name = "root-device-type"
values = ["ebs"]
}
}
# ---------------------------------------------------------------------------
# 1. VPC + Subnet (no route table needed)
# ---------------------------------------------------------------------------
resource "aws_vpc" "test" {
cidr_block = "10.99.0.0/16"
enable_dns_hostnames = true
enable_dns_support = true
tags = {
Name = "spx-basic-test-vpc"
}
}
resource "aws_subnet" "test" {
vpc_id = aws_vpc.test.id
cidr_block = "10.99.1.0/24"
availability_zone = data.aws_availability_zones.available.names[0]
map_public_ip_on_launch = true
tags = {
Name = "spx-basic-test-subnet"
}
}
# ---------------------------------------------------------------------------
# 2. Internet Gateway (attach to VPC, but no route table referencing it)
# ---------------------------------------------------------------------------
resource "aws_internet_gateway" "test" {
vpc_id = aws_vpc.test.id
tags = {
Name = "spx-basic-test-igw"
}
}
# ---------------------------------------------------------------------------
# 3. Security Group — inbound SSH + HTTP, all outbound
# ---------------------------------------------------------------------------
resource "aws_security_group" "test" {
name = "spx-basic-test-sg"
description = "Basic test: SSH + HTTP inbound, all outbound"
vpc_id = aws_vpc.test.id
ingress {
description = "SSH"
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
description = "HTTP"
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
description = "All outbound"
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "spx-basic-test-sg"
}
}
# ---------------------------------------------------------------------------
# 4. Key Pair
# ---------------------------------------------------------------------------
resource "tls_private_key" "test" {
algorithm = "ED25519"
}
resource "aws_key_pair" "test" {
key_name = "spx-basic-test-key"
public_key = tls_private_key.test.public_key_openssh
}
resource "local_file" "test_pem" {
filename = "${path.module}/spx-basic-test.pem"
content = tls_private_key.test.private_key_openssh
file_permission = "0600"
}
# ---------------------------------------------------------------------------
# 5. Placement Group
# ---------------------------------------------------------------------------
resource "aws_placement_group" "test" {
name = "spx-basic-test-spread"
strategy = "spread"
}
# ---------------------------------------------------------------------------
# 6. EC2 Instance
# ---------------------------------------------------------------------------
resource "aws_instance" "test" {
ami = data.aws_ami.debian13.id
instance_type = "t3.small"
subnet_id = aws_subnet.test.id
vpc_security_group_ids = [aws_security_group.test.id]
key_name = aws_key_pair.test.key_name
placement_group = aws_placement_group.test.name
associate_public_ip_address = true
tags = {
Name = "spx-basic-test-vm"
Environment = "test"
}
}
# ---------------------------------------------------------------------------
# 7. EBS Volume + Attachment
# ---------------------------------------------------------------------------
resource "aws_ebs_volume" "data" {
availability_zone = data.aws_availability_zones.available.names[0]
size = 10
type = "gp3"
tags = {
Name = "spx-basic-test-data-vol"
}
}
# ---------------------------------------------------------------------------
# 8. EBS Snapshot (of the standalone volume)
# ---------------------------------------------------------------------------
resource "aws_ebs_snapshot" "data" {
volume_id = aws_ebs_volume.data.id
tags = {
Name = "spx-basic-test-snapshot"
}
}
# ---------------------------------------------------------------------------
# 9. Elastic IP + Association
# Requires external IPAM (pool mode) — uncomment when available.
# ---------------------------------------------------------------------------
# resource "aws_eip" "test" {
# domain = "vpc"
#
# tags = {
# Name = "spx-basic-test-eip"
# }
# }
#
# resource "aws_eip_association" "test" {
# allocation_id = aws_eip.test.id
# instance_id = aws_instance.test.id
# }
# ---------------------------------------------------------------------------
# 10. Standalone Network Interface (not attached — just tests CRUD)
# ---------------------------------------------------------------------------
resource "aws_network_interface" "extra" {
subnet_id = aws_subnet.test.id
security_groups = [aws_security_group.test.id]
tags = {
Name = "spx-basic-test-eni"
}
}
# ---------------------------------------------------------------------------
# Outputs
# ---------------------------------------------------------------------------
output "vpc_id" {
value = aws_vpc.test.id
}
output "instance_id" {
value = aws_instance.test.id
}
output "instance_public_ip" {
value = aws_instance.test.public_ip
}
# output "eip_public_ip" {
# value = aws_eip.test.public_ip
# }
output "volume_id" {
value = aws_ebs_volume.data.id
}
output "snapshot_id" {
value = aws_ebs_snapshot.data.id
}
output "ssh_command" {
value = "ssh -i spx-basic-test.pem admin@${aws_instance.test.public_ip}"
}
-217
View File
@@ -1,217 +0,0 @@
terraform {
required_version = ">= 1.6.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0"
}
tls = {
source = "hashicorp/tls"
version = ">= 4.0"
}
local = {
source = "hashicorp/local"
version = ">= 2.0"
}
}
}
variable "region" {
type = string
default = "ap-southeast-2" # Sydney, change if you want
}
# MulgaOS AWS-compatible endpoints
# Set these environment variables or use terraform.tfvars:
# export AWS_ACCESS_KEY_ID=your_access_key
# export AWS_SECRET_ACCESS_KEY=your_secret_key
# export TF_VAR_mulgaos_endpoint=https://localhost:9999
variable "mulgaos_endpoint" {
type = string
default = "https://localhost:9999"
description = "MulgaOS API endpoint (awsd gateway)"
}
provider "aws" {
region = var.region
# MulgaOS custom endpoints
endpoints {
ec2 = var.mulgaos_endpoint
s3 = var.mulgaos_endpoint
iam = var.mulgaos_endpoint
sts = var.mulgaos_endpoint
}
# Skip AWS credential validation (MulgaOS handles auth)
skip_credentials_validation = true
skip_metadata_api_check = true
skip_requesting_account_id = true
# For local development, skip region validation
skip_region_validation = true
}
data "aws_availability_zones" "available" {
state = "available"
}
# Debian 13 AMI
# For MulgaOS, need to import a local AMI first
data "aws_ami" "debian13" {
most_recent = true
owners = ["000000000000"] # Spinifex system images (on AWS use "136693071363" for Debian official)
filter {
name = "name"
values = ["*debian-13*"]
}
filter {
name = "virtualization-type"
values = ["hvm"]
}
filter {
name = "root-device-type"
values = ["ebs"]
}
}
# Keypair: spinifex-test
resource "tls_private_key" "hive_test" {
algorithm = "ED25519"
}
resource "aws_key_pair" "hive_test" {
key_name = "spinifex-test"
public_key = tls_private_key.hive_test.public_key_openssh
}
# Save private key locally for SSH
resource "local_file" "hive_test_pem" {
filename = "${path.module}/spinifex-test.pem"
content = tls_private_key.hive_test.private_key_openssh
file_permission = "0600"
}
# 1: VPC 10.1.0.0/16
resource "aws_vpc" "main" {
cidr_block = "10.1.0.0/16"
enable_dns_hostnames = true
enable_dns_support = true
tags = {
Name = "spinifex-test-vpc"
}
}
# Internet access for a public subnet
resource "aws_internet_gateway" "igw" {
vpc_id = aws_vpc.main.id
tags = {
Name = "spinifex-test-igw"
}
}
# 2: Subnet 10.1.2.0/24 (public)
resource "aws_subnet" "public" {
vpc_id = aws_vpc.main.id
cidr_block = "10.1.2.0/24"
availability_zone = data.aws_availability_zones.available.names[0]
map_public_ip_on_launch = true
tags = {
Name = "spinifex-test-public-subnet"
}
}
resource "aws_route_table" "public" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.igw.id
}
tags = {
Name = "spinifex-test-public-rt"
}
}
resource "aws_route_table_association" "public" {
subnet_id = aws_subnet.public.id
route_table_id = aws_route_table.public.id
}
# 3: Security group
resource "aws_security_group" "web_ssh" {
name = "spinifex-test-sg"
description = "Allow inbound 22,80,443; allow all outbound"
vpc_id = aws_vpc.main.id
ingress {
description = "SSH"
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
description = "HTTP"
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
description = "HTTPS"
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
description = "All outbound"
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "spinifex-test-sg"
}
}
resource "aws_instance" "vm" {
count = 3
ami = data.aws_ami.debian13.id
instance_type = "t3.small"
subnet_id = aws_subnet.public.id
vpc_security_group_ids = [aws_security_group.web_ssh.id]
key_name = aws_key_pair.hive_test.key_name
associate_public_ip_address = true
tags = {
Name = "spinifex-test-vm-${count.index + 1}"
}
}
output "instance_public_ips" {
value = aws_instance.vm[*].public_ip
}
output "ssh_commands" {
value = [
for ip in aws_instance.vm[*].public_ip :
"ssh -i spinifex-test.pem admin@${ip}"
]
}
-204
View File
@@ -1,204 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# Configure a provisioned Spinifex cluster: clone repo, build, form cluster, start services.
# Usage: configure-cluster.sh <cluster_name> <state_file>
CLUSTER_NAME="${1:?Usage: configure-cluster.sh <cluster_name> <state_file>}"
STATE_FILE="${2:?Usage: configure-cluster.sh <cluster_name> <state_file>}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TOFU_DIR="$SCRIPT_DIR/proxmox"
SSH_OPTS="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=10"
REGION="ap-southeast-2"
AZ="ap-southeast-2a"
inventory=$(tofu -chdir="$TOFU_DIR" output -json -state="$STATE_FILE" inventory)
NODE_COUNT=$(echo "$inventory" | jq -r '.node_count')
SSH_KEY=$(eval echo "$(echo "$inventory" | jq -r '.ssh_key_path')")
SSH_USER=$(echo "$inventory" | jq -r '.ssh_user')
# Build arrays of node IPs
declare -a MGMT_IPS
for i in $(seq 0 $((NODE_COUNT - 1))); do
MGMT_IPS+=("$(echo "$inventory" | jq -r ".nodes[$i].management")")
done
remote() {
local ip="$1"
shift
ssh -i "$SSH_KEY" $SSH_OPTS "$SSH_USER@$ip" "$@"
}
remote_bg() {
local ip="$1"
shift
ssh -i "$SSH_KEY" $SSH_OPTS "$SSH_USER@$ip" "$@" &
}
echo "==> Cluster '$CLUSTER_NAME': $NODE_COUNT nodes"
for i in $(seq 0 $((NODE_COUNT - 1))); do
echo " node$((i + 1)): ${MGMT_IPS[$i]}"
done
# --- Step 1: Wait for cloud-init ---
echo ""
echo "==> Waiting for cloud-init to complete on all nodes..."
for i in $(seq 0 $((NODE_COUNT - 1))); do
ip="${MGMT_IPS[$i]}"
printf " node%d (%s): " "$((i + 1))" "$ip"
attempts=0
while ! remote "$ip" "test -f /tmp/vendor-cloud-init-done" 2>/dev/null; do
attempts=$((attempts + 1))
if [ $attempts -ge 60 ]; then
echo "TIMEOUT"
echo "Error: cloud-init did not complete on $ip after 5 minutes"
exit 1
fi
sleep 5
done
echo "done"
done
# --- Step 2: Clone repo and build on all nodes ---
echo ""
echo "==> Cloning Spinifex repo and building on all nodes..."
PIDS=()
for i in $(seq 0 $((NODE_COUNT - 1))); do
ip="${MGMT_IPS[$i]}"
(
echo " node$((i + 1)) ($ip): cloning..."
remote "$ip" "mkdir -p ~/Development/mulga && cd ~/Development/mulga && git clone https://github.com/mulgadc/spinifex.git && export PATH=\$PATH:/usr/local/go/bin && sudo make -C spinifex quickinstall"
echo " node$((i + 1)) ($ip): clone-deps + dev-setup..."
remote "$ip" "export PATH=\$PATH:/usr/local/go/bin && cd ~/Development/mulga/spinifex && ./scripts/clone-deps.sh && ./scripts/dev-setup.sh"
echo " node$((i + 1)) ($ip): build complete"
) &
PIDS+=($!)
done
for pid in "${PIDS[@]}"; do
wait "$pid"
done
echo " All nodes built successfully"
# --- Step 3: Form cluster ---
echo ""
echo "==> Forming cluster..."
# Build comma-separated IP lists
ALL_MGMT_IPS=$(IFS=,; echo "${MGMT_IPS[*]}")
NODE1_IP="${MGMT_IPS[0]}"
# Node 1: init
echo " Starting init on node1 ($NODE1_IP)..."
remote_bg "$NODE1_IP" "export PATH=\$PATH:/usr/local/go/bin && cd ~/Development/mulga/spinifex && ./bin/spx admin init \
--node node1 \
--bind $NODE1_IP \
--cluster-bind $NODE1_IP \
--cluster-routes $NODE1_IP:4248 \
--predastore-nodes $ALL_MGMT_IPS \
--port 4432 \
--nodes $NODE_COUNT \
--region $REGION \
--az $AZ \
--spinifex-dir ~/spinifex/ \
--config-dir ~/spinifex/config/"
INIT_PID=$!
# Wait for formation server to be ready
echo " Waiting for formation server on $NODE1_IP..."
attempts=0
while ! curl -sk --connect-timeout 3 "https://$NODE1_IP:4432/formation/health" >/dev/null 2>&1; do
attempts=$((attempts + 1))
if [ $attempts -ge 60 ]; then
echo "Error: Formation server did not start on $NODE1_IP after 5 minutes"
kill $INIT_PID 2>/dev/null || true
exit 1
fi
sleep 5
done
echo " Formation server ready"
# Read join token from init node
JOIN_TOKEN=$(remote "$NODE1_IP" "cat ~/spinifex/config/join-token")
if [ -z "$JOIN_TOKEN" ]; then
echo "Error: Failed to read join token from $NODE1_IP"
exit 1
fi
echo " Join token loaded from node1"
# Nodes 2..N: join
if [ "$NODE_COUNT" -gt 1 ]; then
JOIN_PIDS=()
for i in $(seq 1 $((NODE_COUNT - 1))); do
ip="${MGMT_IPS[$i]}"
node_num=$((i + 1))
echo " Starting join on node$node_num ($ip)..."
remote_bg "$ip" "export PATH=\$PATH:/usr/local/go/bin && cd ~/Development/mulga/spinifex && ./bin/spx admin join \
--node node$node_num \
--bind $ip \
--cluster-bind $ip \
--cluster-routes $NODE1_IP:4248 \
--host $NODE1_IP:4432 \
--token $JOIN_TOKEN \
--data-dir ~/spinifex/ \
--config-dir ~/spinifex/config/ \
--region $REGION \
--az $AZ"
JOIN_PIDS+=($!)
done
for pid in "${JOIN_PIDS[@]}"; do
wait "$pid"
done
fi
# Wait for init process to complete
wait $INIT_PID
echo " Cluster formation complete"
# --- Step 4: Start services ---
echo ""
echo "==> Starting services on all nodes..."
PIDS=()
for i in $(seq 0 $((NODE_COUNT - 1))); do
ip="${MGMT_IPS[$i]}"
(
echo " node$((i + 1)) ($ip): starting services..."
remote "$ip" "export PATH=\$PATH:/usr/local/go/bin && cd ~/Development/mulga/spinifex && ./scripts/start-dev.sh"
echo " node$((i + 1)) ($ip): services started"
) &
PIDS+=($!)
done
for pid in "${PIDS[@]}"; do
wait "$pid"
done
# --- Step 5: Health check ---
echo ""
echo "==> Waiting for all nodes to become healthy..."
for i in $(seq 0 $((NODE_COUNT - 1))); do
ip="${MGMT_IPS[$i]}"
printf " node%d (%s): " "$((i + 1))" "$ip"
attempts=0
while true; do
health=$(curl -sk --connect-timeout 3 "https://$ip:4432/health" 2>/dev/null || echo "")
if [ -n "$health" ]; then
echo "healthy"
break
fi
attempts=$((attempts + 1))
if [ $attempts -ge 30 ]; then
echo "TIMEOUT"
echo "Warning: node$((i + 1)) did not become healthy after 150 seconds"
break
fi
sleep 5
done
done
echo ""
echo "==> Cluster '$CLUSTER_NAME' configured successfully"
-60
View File
@@ -1,60 +0,0 @@
# Proxmox Template Builder — Environment Configuration
#
# Copy to .env and fill in your values:
# cp .env.example .env
#
# Then source it before running tofu:
# source .env && tofu plan
# --- Proxmox API ---
# API token for Proxmox VE (read by the provider directly)
export PROXMOX_VE_API_TOKEN="terraform@pve!provider=YOUR_TOKEN_HERE"
# Proxmox VE API endpoint URL
export TF_VAR_proxmox_endpoint="https://pve-host.example.com:8006/"
# --- Proxmox Node ---
# Build the template on ONE node. After creation, use vzdump to copy to others.
#
# name - Proxmox node name (as shown in the Proxmox UI)
# address - SSH-reachable hostname or IP of the Proxmox host
export TF_VAR_proxmox_node_name="node1"
export TF_VAR_proxmox_node_address="node1.example.com"
# --- SSH Access ---
# SSH username on Proxmox hosts (used by provider for file uploads + qm commands)
export TF_VAR_proxmox_ssh_username="terraform"
# Path to SSH private key for Proxmox host access
export TF_VAR_ssh_private_key_path="~/.ssh/proxmox-host-tf"
# Path to SSH private key for VM access (must be the pair of the public key below)
export TF_VAR_vm_ssh_private_key_path="~/.ssh/cloud-init"
# Path to SSH public key injected into VMs via cloud-init (for tf-user)
export TF_VAR_ssh_public_key_path="~/.ssh/cloud-init.pub"
# --- Template ---
# VM ID for the template (pick a high number to avoid conflicts)
export TF_VAR_template_vmid=9000
# Short tag for the base image (used in Proxmox template tags)
export TF_VAR_base_image_tag="debian-12"
# Storage and network for the build node
export TF_VAR_datastore_id="local-lvm"
export TF_VAR_bridge="vmbr0"
# --- Optional ---
# Base image — change this when testing a newer Debian/Ubuntu version:
# export TF_VAR_base_image="local:iso/debian-13-genericcloud-amd64.img"
# export TF_VAR_base_image_tag="debian-13"
# export TF_VAR_template_vmid=9010
# Clone a specific branch instead of main:
# export TF_VAR_git_branch="feature/my-branch"
-6
View File
@@ -1,6 +0,0 @@
.env
.terraform/
.terraform.lock.hcl
*.tfstate
*.tfstate.backup
*.tfplan
-124
View File
@@ -1,124 +0,0 @@
# Spinifex Node Template Builder
Builds a Proxmox VM template with all Spinifex dependencies pre-installed. Cloning from this template reduces cluster spin-up from ~15 minutes to ~1-2 minutes (`git pull` + `make build`).
## What's in the template
| Component | Details |
|---|---|
| System packages | QEMU, nbdkit, libvirt, gcc, jq, curl, etc. (`make install-system`) |
| Go | 1.26.4 (`make install-go`) |
| AWS CLI | v2 (`make install-aws`) |
| Repositories | spinifex, viperblock, predastore (with `go.work` configured) |
| Module cache | `go mod download` completed for all repos |
| Cloud images | Ubuntu 26.04 for nested VMs (`~/images/ubuntu-26.04.img`) |
| Tuning | sysctl (rmem_max/wmem_max), kvm group, ufw disabled |
| Generalized | Clean machine-id, SSH host keys, cloud-init — each clone boots fresh |
## Quick Start
```bash
cd scripts/iac/proxmox-template
# 1. Configure
cp .env.example .env
# Edit .env — set your Proxmox endpoint, API token, node, and SSH key paths.
# 2. Set API token (not stored in .env for security)
export PROXMOX_VE_API_TOKEN="terraform@pve!provider=YOUR_TOKEN"
# 3. Build
source .env
tofu init
tofu plan
tofu apply
```
Build takes ~10-15 minutes (apt packages, Go, cloud image download). When done, you'll have a template on the build node at the configured VMID (default 9000).
## Distribute to Other Nodes
After `tofu apply`, the output prints ready-to-run commands. Example for a 3-node cluster:
```bash
# Export from build node
ssh -i ~/.ssh/proxmox-host-tf terraform@<BUILD_NODE> \
'vzdump 9000 --dumpdir /tmp --mode stop --compress zstd'
# Copy to other nodes
scp -3 -i ~/.ssh/proxmox-host-tf \
terraform@<BUILD_NODE>:/tmp/vzdump-qemu-9000-*.vma.zst \
terraform@<TARGET_NODE>:/tmp/
# Restore on each target node (use a unique VMID and the node's datastore)
ssh -i ~/.ssh/proxmox-host-tf terraform@<TARGET_NODE> \
'qmrestore /tmp/vzdump-qemu-9000-*.vma.zst <NEW_VMID> --storage <DATASTORE> && qm template <NEW_VMID>'
```
After distributing, each node has its own copy of the template with a unique VMID.
## Using the Template
Update `scripts/iac/proxmox/vms.tf` to clone from the template instead of booting from a raw cloud image. Or clone manually from the Proxmox UI.
On each cloned VM after boot:
```bash
# Pull latest code
cd ~/Development/mulga/spinifex && git pull
cd ~/Development/mulga/viperblock && git pull
cd ~/Development/mulga/predastore && git pull
# Build (~30s with cached modules)
cd ~/Development/mulga/spinifex && export PATH=/usr/local/go/bin:$PATH && make build
```
## Testing a Different Base Image
Upload the new image to Proxmox, then build a separate template:
```bash
# Debian 13 example
export TF_VAR_base_image="local:iso/debian-13-genericcloud-amd64.img"
export TF_VAR_base_image_tag="debian-13"
export TF_VAR_template_vmid=9010 # Keep the debian-12 template at 9000
source .env
tofu apply
```
Templates are tagged in the Proxmox UI with the base image name (`debian-12`, `debian-13`, etc.) so you can tell them apart.
## Rebuilding
When dependencies change (new Go version, new system packages):
```bash
source .env
tofu destroy # Remove the old template
tofu apply # Build fresh
# Then re-distribute with vzdump
```
## SSH Key Setup
This template uses **two separate key pairs** (matching the `scripts/iac/proxmox/` setup):
| Key | Used for | Variable |
|---|---|---|
| Proxmox host key | SSH to Proxmox hosts (provider, `qm template` command) | `ssh_private_key_path` |
| VM / cloud-init key | SSH to VMs (cloud-init injection, provisioning) | `vm_ssh_private_key_path` + `ssh_public_key_path` |
If you use the same key for both, set all three variables to the same key pair.
## Files
| File | Description |
|------|-------------|
| `main.tf` | Proxmox provider configuration |
| `variables.tf` | All configurable variables with defaults |
| `template-builder.tf` | VM creation, provisioning, template conversion |
| `outputs.tf` | Template info + vzdump/restore commands |
| `scripts/provision.sh` | Install deps, clone repos, cache modules, generalize |
| `.env.example` | Environment variable template |
| `.gitignore` | Ignores .env, state files, provider plugins |
-27
View File
@@ -1,27 +0,0 @@
terraform {
required_version = ">= 1.6.0"
required_providers {
proxmox = {
source = "bpg/proxmox"
version = "0.52.0"
}
}
}
# API token is read from PROXMOX_VE_API_TOKEN env var automatically
provider "proxmox" {
endpoint = var.proxmox_endpoint
insecure = true
ssh {
agent = false
username = var.proxmox_ssh_username
private_key = file(pathexpand(var.ssh_private_key_path))
node {
name = var.proxmox_node_name
address = var.proxmox_node_address
}
}
}
-43
View File
@@ -1,43 +0,0 @@
output "template_vmid" {
description = "VM ID of the created template"
value = proxmox_virtual_environment_vm.builder.vm_id
}
output "template_node" {
description = "Proxmox node where the template lives"
value = var.proxmox_node_name
}
output "base_image_tag" {
description = "Base image tag for this template"
value = var.base_image_tag
}
# Ready-to-run command: export the template for distribution to other Proxmox nodes
output "vzdump_export_command" {
description = "Run this to export the template (vzdump) for copying to other nodes"
value = <<-EOT
ssh -i ${var.ssh_private_key_path} ${var.proxmox_ssh_username}@${var.proxmox_node_address} \
'vzdump ${var.template_vmid} --dumpdir /tmp --mode stop --compress zstd'
EOT
}
# After exporting, copy to each target node and restore
output "distribute_commands" {
description = "Copy and restore the template on other Proxmox nodes"
value = <<-EOT
# 1. Export from build node (${var.proxmox_node_name}):
ssh -i ${var.ssh_private_key_path} ${var.proxmox_ssh_username}@${var.proxmox_node_address} \
'vzdump ${var.template_vmid} --dumpdir /tmp --mode stop --compress zstd'
# 2. Copy to each target node:
scp -3 \
-i ${var.ssh_private_key_path} \
${var.proxmox_ssh_username}@${var.proxmox_node_address}:/tmp/vzdump-qemu-${var.template_vmid}-*.vma.zst \
${var.proxmox_ssh_username}@<TARGET_NODE_ADDRESS>:/tmp/
# 3. On each target node — restore and convert to template:
ssh -i ${var.ssh_private_key_path} ${var.proxmox_ssh_username}@<TARGET_NODE_ADDRESS> \
'qmrestore /tmp/vzdump-qemu-${var.template_vmid}-*.vma.zst <NEW_VMID> --storage <DATASTORE> && qm template <NEW_VMID>'
EOT
}
@@ -1,176 +0,0 @@
#!/bin/bash
# provision.sh — Installs all Spinifex dependencies, clones repos, caches Go modules,
# and generalizes the VM for use as a Proxmox template.
#
# Runs as tf-user via Terraform remote-exec. Uses sudo for system operations.
# Environment: GIT_BRANCH (default: dev)
set -euo pipefail
GIT_BRANCH="${GIT_BRANCH:-dev}"
WORK_DIR="$HOME/Development/mulga"
echo "========================================"
echo " Spinifex Template Provisioning"
echo " Branch: ${GIT_BRANCH}"
echo " Arch: $(uname -m)"
echo "========================================"
# ── 1. Wait for cloud-init ──────────────────────────────────────────────────
echo ""
echo "=== [1/8] Waiting for cloud-init to complete ==="
cloud-init status --wait
# ── 2. Clone repositories ───────────────────────────────────────────────────
echo ""
echo "=== [2/8] Cloning repositories ==="
mkdir -p "$WORK_DIR"
cd "$WORK_DIR"
for repo in spinifex viperblock predastore; do
if [ ! -d "$repo" ]; then
echo " Cloning $repo (branch: $GIT_BRANCH)..."
git clone -b "$GIT_BRANCH" "https://github.com/mulgadc/${repo}.git"
else
echo " $repo already exists, skipping"
fi
done
# ── 3. Install system dependencies via Makefile ─────────────────────────────
echo ""
echo "=== [3/8] Installing system dependencies ==="
cd "$WORK_DIR/spinifex"
sudo make quickinstall
# Ensure Go is in PATH for the rest of the script
export PATH="/usr/local/go/bin:$HOME/go/bin:$PATH"
echo " Go version: $(go version)"
echo " AWS CLI: $(aws --version)"
# ── 4. Set up go.work ───────────────────────────────────────────────────────
echo ""
echo "=== [4/8] Creating go.work ==="
# Detect Go version from go.mod
GO_VERSION=$(grep '^go ' "$WORK_DIR/spinifex/go.mod" | awk '{print $2}')
echo " Using Go version from go.mod: ${GO_VERSION}"
cat > "$WORK_DIR/spinifex/go.work" << GOWORK
go ${GO_VERSION}
use (
.
../viperblock
../viperblock/nbd
../viperblock/nbd/libguestfs.org/nbdkit
../predastore
)
GOWORK
echo " Created $WORK_DIR/spinifex/go.work"
# ── 5. Download Go modules ──────────────────────────────────────────────────
echo ""
echo "=== [5/8] Downloading Go modules ==="
cd "$WORK_DIR/spinifex" && go mod download && echo " spinifex: done"
cd "$WORK_DIR/viperblock" && go mod download && echo " viperblock: done"
cd "$WORK_DIR/predastore" && go mod download && echo " predastore: done"
# Sub-modules
if [ -f "$WORK_DIR/viperblock/nbd/go.mod" ]; then
cd "$WORK_DIR/viperblock/nbd" && go mod download && echo " viperblock/nbd: done"
fi
if [ -f "$WORK_DIR/viperblock/nbd/libguestfs.org/nbdkit/go.mod" ]; then
cd "$WORK_DIR/viperblock/nbd/libguestfs.org/nbdkit" && go mod download && echo " viperblock/nbd/nbdkit: done"
fi
# ── 6. Pre-download cloud images for nested VMs ─────────────────────────────
echo ""
echo "=== [6/8] Pre-downloading cloud images ==="
mkdir -p "$HOME/images"
ARCH=$(dpkg --print-architecture)
IMAGE_URL=""
if [ "$ARCH" = "amd64" ]; then
IMAGE_URL="https://cloud-images.ubuntu.com/resolute/current/resolute-server-cloudimg-amd64.img"
elif [ "$ARCH" = "arm64" ]; then
IMAGE_URL="https://cloud-images.ubuntu.com/resolute/current/resolute-server-cloudimg-arm64.img"
fi
if [ -n "$IMAGE_URL" ]; then
echo " Downloading Ubuntu 26.04 cloud image ($ARCH)..."
curl -L -o "$HOME/images/ubuntu-26.04.img" "$IMAGE_URL"
echo " Saved to ~/images/ubuntu-26.04.img"
else
echo " Unknown arch ($ARCH), skipping cloud image download"
fi
# ── 7. System tuning ────────────────────────────────────────────────────────
echo ""
echo "=== [7/8] Applying system tuning ==="
# Persistent sysctl for NATS / high-throughput networking
sudo tee /etc/sysctl.d/99-spinifex.conf > /dev/null << 'SYSCTL'
net.core.rmem_max=4194304
net.core.wmem_max=4194304
SYSCTL
echo " sysctl: net.core.rmem_max/wmem_max = 4MB"
# Add user to kvm group for QEMU access
sudo usermod -aG kvm "$(whoami)"
echo " Added $(whoami) to kvm group"
# Disable ufw if present
if command -v ufw >/dev/null 2>&1; then
sudo ufw disable 2>/dev/null || true
echo " ufw disabled"
fi
# ── 8. Generalize for templating ────────────────────────────────────────────
echo ""
echo "=== [8/8] Cleaning up for template ==="
# Apt caches
sudo apt-get clean
sudo rm -rf /var/lib/apt/lists/*
echo " Cleared apt cache"
# Logs
sudo find /var/log -type f -name "*.log" -exec truncate -s 0 {} \; 2>/dev/null || true
sudo rm -rf /var/log/journal/*
echo " Truncated logs"
# Temp files
sudo rm -rf /tmp/* /var/tmp/*
# Machine ID — systemd regenerates on next boot.
# This is critical: without this, all clones get the same DHCP client ID → IP conflicts.
sudo truncate -s 0 /etc/machine-id
sudo rm -f /var/lib/dbus/machine-id
echo " Cleared machine-id (will regenerate on boot)"
# SSH host keys — regenerated on boot by cloud-init or systemd
sudo rm -f /etc/ssh/ssh_host_*
echo " Removed SSH host keys (will regenerate on boot)"
# Cloud-init state — forces cloud-init to run again on next boot.
# This ensures each clone gets its own hostname, SSH keys, user config, etc.
sudo cloud-init clean
echo " Cleaned cloud-init state (will re-run on boot)"
# Shell history
history -c 2>/dev/null || true
cat /dev/null > "$HOME/.bash_history" 2>/dev/null || true
# Provisioning script itself
rm -f /tmp/provision.sh
# Sync filesystem
sync
echo ""
echo "========================================"
echo " Template provisioning complete!"
echo " VM is ready to be shut down and"
echo " converted to a Proxmox template."
echo "========================================"
@@ -1,130 +0,0 @@
data "local_file" "ssh_public_key" {
filename = pathexpand(var.ssh_public_key_path)
}
# Minimal cloud-init: just enough to bootstrap SSH + guest agent.
# Heavy provisioning is done via remote-exec after boot.
resource "proxmox_virtual_environment_file" "cloud_config" {
content_type = "snippets"
datastore_id = "local"
node_name = var.proxmox_node_name
source_raw {
# NOTE: #cloud-config MUST start at column 0 — cloud-init uses it as a magic header.
data = <<EOF
#cloud-config
package_update: true
packages:
- qemu-guest-agent
- git
- make
- curl
- sudo
- ca-certificates
runcmd:
- systemctl enable qemu-guest-agent
- systemctl start qemu-guest-agent
EOF
file_name = "hive-template-cloud-config.yaml"
}
}
resource "proxmox_virtual_environment_vm" "builder" {
name = "${var.template_name}-builder"
node_name = var.proxmox_node_name
vm_id = var.template_vmid
tags = sort(["terraform", "hive-template", var.base_image_tag])
initialization {
datastore_id = "local"
vendor_data_file_id = proxmox_virtual_environment_file.cloud_config.id
user_account {
username = var.vm_ssh_user
keys = [trimspace(data.local_file.ssh_public_key.content)]
}
ip_config {
ipv4 {
address = "dhcp"
}
}
}
cpu {
cores = var.cpu_cores
type = "host"
}
memory {
dedicated = var.memory_mb
}
agent {
enabled = true
}
network_device {
bridge = var.bridge
}
disk {
file_id = var.base_image
datastore_id = var.datastore_id
interface = "virtio0"
iothread = true
discard = "on"
size = var.disk_size_gb
}
# SSH connection to the VM for provisioning.
# Uses the VM key (vm_ssh_private_key_path), NOT the Proxmox host key.
# Waits for guest agent to report an IP, then connects.
connection {
type = "ssh"
user = var.vm_ssh_user
private_key = file(pathexpand(var.vm_ssh_private_key_path))
host = element([for addr in flatten(self.ipv4_addresses) : addr if !startswith(addr, "127.")], 0)
timeout = "10m"
}
# Copy provisioning script to VM
provisioner "file" {
source = "${path.module}/scripts/provision.sh"
destination = "/tmp/provision.sh"
}
# Run provisioning (installs deps, clones repos, caches modules, cleans up)
provisioner "remote-exec" {
inline = [
"chmod +x /tmp/provision.sh",
"GIT_BRANCH='${var.git_branch}' /tmp/provision.sh",
]
}
}
# After provisioning completes, shut down the VM and convert to a Proxmox template.
# This runs on the Proxmox host via SSH (not on the VM — it's being shut down).
resource "null_resource" "convert_template" {
depends_on = [proxmox_virtual_environment_vm.builder]
triggers = {
builder_id = proxmox_virtual_environment_vm.builder.vm_id
}
provisioner "local-exec" {
command = <<-EOT
ssh -i '${pathexpand(var.ssh_private_key_path)}' \
-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
'${var.proxmox_ssh_username}@${var.proxmox_node_address}' \
"sudo qm shutdown ${proxmox_virtual_environment_vm.builder.vm_id} --timeout 60 && \
echo 'Waiting for VM to stop...' && \
while sudo qm status ${proxmox_virtual_environment_vm.builder.vm_id} | grep -q running; do sleep 2; done && \
echo 'Converting to template...' && \
sudo qm template ${proxmox_virtual_environment_vm.builder.vm_id} && \
echo 'Template ${var.template_vmid} created successfully'"
EOT
}
}
-117
View File
@@ -1,117 +0,0 @@
# --- Proxmox Connection ---
variable "proxmox_endpoint" {
type = string
description = "Proxmox VE API endpoint URL (e.g. https://pve.example.com:8006/)"
}
variable "proxmox_node_name" {
type = string
description = "Proxmox node name to build the template on (as shown in the Proxmox UI)"
}
variable "proxmox_node_address" {
type = string
description = "SSH-reachable hostname or IP of the Proxmox node"
}
variable "proxmox_ssh_username" {
type = string
description = "SSH username for Proxmox host access"
default = "terraform"
}
# --- SSH Keys ---
# Proxmox host and VM typically use different key pairs.
# - ssh_private_key_path → authenticates to the Proxmox host (provider + qm commands)
# - vm_ssh_private_key_path → authenticates to the VM for provisioning (remote-exec)
# - ssh_public_key_path → injected into the VM via cloud-init
variable "ssh_private_key_path" {
type = string
description = "Path to SSH private key for Proxmox host access"
}
variable "vm_ssh_private_key_path" {
type = string
description = "Path to SSH private key for VM access (must match ssh_public_key_path)"
}
variable "ssh_public_key_path" {
type = string
description = "Path to SSH public key injected into the VM via cloud-init"
}
# --- Base Image ---
variable "base_image" {
type = string
description = "Proxmox image file ID for the base OS (e.g. local:iso/debian-12-genericcloud-amd64-20240211-1654.img)"
default = "local:iso/debian-12-genericcloud-amd64-20240211-1654.img"
}
variable "base_image_tag" {
type = string
description = "Short tag identifying the base image, used in template tags for tracking"
default = "debian-12"
}
# --- Template Settings ---
variable "template_name" {
type = string
description = "Name for the resulting Proxmox template"
default = "hive-node-template"
}
variable "template_vmid" {
type = number
description = "VM ID for the template (pick a high number to avoid conflicts, e.g. 9000)"
default = 9000
}
variable "vm_ssh_user" {
type = string
description = "Username created in the VM via cloud-init"
default = "tf-user"
}
# --- VM Resources ---
variable "cpu_cores" {
type = number
description = "CPU cores for the builder VM (more = faster builds)"
default = 4
}
variable "memory_mb" {
type = number
description = "Memory in MB for the builder VM"
default = 8192
}
variable "disk_size_gb" {
type = number
description = "Disk size in GB (needs room for OS + Go + modules + cloud images)"
default = 32
}
variable "datastore_id" {
type = string
description = "Proxmox storage for the VM disk"
default = "local-lvm"
}
variable "bridge" {
type = string
description = "Network bridge for the VM"
default = "vmbr0"
}
# --- Git ---
variable "git_branch" {
type = string
description = "Git branch to clone for spinifex, viperblock, and predastore"
default = "dev"
}
-68
View File
@@ -1,68 +0,0 @@
# Proxmox IaC Environment Configuration
# Copy this file to .env and fill in your values:
# cp .env.example .env
#
# Then source it before running tofu:
# source .env && tofu plan
# --- Proxmox API ---
# API token for Proxmox VE (read by the provider directly)
export PROXMOX_VE_API_TOKEN="terraform@pve!provider=YOUR_TOKEN_HERE"
# Proxmox VE API endpoint URL
export TF_VAR_proxmox_endpoint="https://pve-host.example.com:8006/"
# --- SSH Access ---
# SSH username on Proxmox hosts (used by provider for file uploads)
export TF_VAR_proxmox_ssh_username="terraform"
# Path to SSH private key for Proxmox host access
export TF_VAR_ssh_private_key_path="~/.ssh/proxmox-host-tf"
# Path to SSH public key injected into VMs via cloud-init (for tf-user)
export TF_VAR_ssh_public_key_path="~/.ssh/cloud-init.pub"
# --- Proxmox Nodes ---
# Physical Proxmox hosts where VMs are placed (round-robin).
# You can define 1 or more nodes — VMs are distributed across them.
#
# Each node object:
# name - Proxmox node name (as shown in the Proxmox UI)
# address - SSH-reachable hostname or IP of the Proxmox host
# bridge - Network bridge for VM interfaces (e.g. vmbr0, vmbr3)
# datastore_id - Proxmox storage ID for VM disks (e.g. local, local-lvm)
# template_vmid - (optional) VMID of a pre-built template on this node.
# Build with scripts/iac/proxmox-template/, then distribute
# with vzdump. When set, VMs clone from the template (fast).
# When omitted, VMs boot from os_image (slower).
export TF_VAR_nodes='[
{
"name": "node1",
"address": "node1.example.com",
"bridge": "vmbr0",
"datastore_id": "local"
},
{
"name": "node2",
"address": "node2.example.com",
"bridge": "vmbr0",
"datastore_id": "local"
},
{
"name": "node3",
"address": "node3.example.com",
"bridge": "vmbr0",
"datastore_id": "local"
}
]'
# Example with templates (VMIDs may differ per node after vzdump restore):
#
# export TF_VAR_nodes='[
# { "name": "node1", "address": "node1.example.com", "bridge": "vmbr0", "datastore_id": "local", "template_vmid": 9999 },
# { "name": "node2", "address": "node2.example.com", "bridge": "vmbr0", "datastore_id": "local-lvm", "template_vmid": 9997 },
# { "name": "node3", "address": "node3.example.com", "bridge": "vmbr0", "datastore_id": "ssd", "template_vmid": 9998 }
# ]'
-191
View File
@@ -1,191 +0,0 @@
# Proxmox IaC
OpenTofu/Terraform templates for provisioning Spinifex development clusters on Proxmox VE.
## Prerequisites
- [OpenTofu](https://opentofu.org/) (or Terraform >= 1.6.0)
- 1 or more Proxmox VE nodes with:
- A Terraform API token (`Datacenter > Permissions > API Tokens`)
- SSH access for a `terraform` user on each node
- Debian 13 cloud image uploaded to `local:iso/` on each node
- SSH keypair for cloud-init VM access
- A hardcoded Debian ISO is used, which needs to be pre-installed on each Proxmox host ("debian-13-genericcloud-amd64-20260518-2482.img") https://cloud.debian.org/images/cloud/trixie/20260518-2482/debian-13-genericcloud-amd64-20260518-2482.qcow2
## Quick Start (spinifex-test.sh)
The `spinifex-test.sh` wrapper script manages the full lifecycle: provision, configure, test, and destroy.
```sh
# Source environment
source scripts/iac/proxmox/.env
source ~/prox # Proxmox API token
# Provision a 3-node cluster
./scripts/iac/spinifex-test.sh up my-cluster
# Configure: clone repo, build, form cluster, start services
./scripts/iac/spinifex-test.sh configure my-cluster
# Run smoke tests
./scripts/iac/spinifex-test.sh test my-cluster
# Check cluster health
./scripts/iac/spinifex-test.sh status my-cluster
# SSH to a specific node
./scripts/iac/spinifex-test.sh ssh my-cluster 2
# Destroy when done
./scripts/iac/spinifex-test.sh down my-cluster
# Or run the full lifecycle (up → configure → test → down)
./scripts/iac/spinifex-test.sh full my-cluster
```
### Options
```
--node-count=N Number of VMs (default: 3)
--memory-mb=N Memory per VM in MB (default: 16384)
--cpu-cores=N CPU cores per VM (default: 4)
--disk-size-gb=N Disk size per VM in GB (default: 32)
```
Example with custom sizing:
```sh
./scripts/iac/spinifex-test.sh up my-cluster --node-count=5 --memory-mb=8192 --cpu-cores=2
```
Per-cluster state is stored in `scripts/iac/proxmox/clusters/<name>/terraform.tfstate`, allowing multiple clusters to coexist.
## Manual Setup
### 1. Configure environment
```sh
cp .env.example .env
# Edit .env with your Proxmox endpoint, SSH keys, and node configuration
```
### 2. Set API token
```sh
export PROXMOX_VE_API_TOKEN="terraform@pve!provider=YOUR_TOKEN_SECRET"
```
### 3. Deploy
```sh
source .env
tofu init
tofu plan -var="cluster_name=dev1"
tofu apply -var="cluster_name=dev1"
```
### Example `.env` file
```sh
# --- Proxmox API ---
# API token - set manually via: export PROXMOX_VE_API_TOKEN="terraform@pve!provider=..."
# Format: <user>@<realm>!<token-name>=<token-secret>
# Proxmox VE API endpoint (the URL you use to access the web UI)
export TF_VAR_proxmox_endpoint="https://pve1.lab.example.com:8006/"
# --- SSH Access ---
# SSH username on the Proxmox hosts (used by the provider for file uploads via SCP)
export TF_VAR_proxmox_ssh_username="terraform"
# Path to SSH private key that authenticates as the above user on each Proxmox host
export TF_VAR_ssh_private_key_path="~/.ssh/proxmox-tf"
# Path to SSH public key injected into VMs via cloud-init (the tf-user account)
export TF_VAR_ssh_public_key_path="~/.ssh/proxmox-tf-cloudinit.pub"
# --- Proxmox Nodes ---
#
# Physical Proxmox hosts where VMs are placed (round-robin).
# Each node object:
# name - Proxmox node name exactly as shown in the Proxmox UI sidebar
# address - SSH-reachable hostname or IP of the Proxmox host
# bridge - Linux bridge for VM network interfaces (check Proxmox UI > Node > Network)
# datastore_id - Proxmox storage pool for VM disks (check Proxmox UI > Node > Storage)
export TF_VAR_nodes='[
{
"name": "pve1",
"address": "pve1.lab.example.com",
"bridge": "vmbr0",
"datastore_id": "local-lvm"
},
{
"name": "pve2",
"address": "pve2.lab.example.com",
"bridge": "vmbr0",
"datastore_id": "local-lvm"
},
{
"name": "pve3",
"address": "pve3.lab.example.com",
"bridge": "vmbr0",
"datastore_id": "local-lvm"
}
]'
```
### Environment variable reference
| Variable | Required | Description |
|---|---|---|
| `PROXMOX_VE_API_TOKEN` | Yes | Proxmox API token (read by provider directly) |
| `TF_VAR_proxmox_endpoint` | Yes | Proxmox VE API URL (e.g. `https://host:8006/`) |
| `TF_VAR_proxmox_ssh_username` | No | SSH user on Proxmox hosts (default: `terraform`) |
| `TF_VAR_ssh_private_key_path` | Yes | SSH private key for Proxmox host access |
| `TF_VAR_ssh_public_key_path` | Yes | SSH public key injected into VMs via cloud-init |
| `TF_VAR_nodes` | Yes | JSON array of Proxmox node objects |
Each node object in `TF_VAR_nodes`:
| Field | Example | Description |
|---|---|---|
| `name` | `pve1` | Proxmox node name (as shown in UI sidebar) |
| `address` | `pve1.lab.example.com` | SSH-reachable hostname or IP |
| `bridge` | `vmbr0` | Network bridge for VM interfaces |
| `datastore_id` | `local-lvm` | Storage pool for VM disks |
### Cluster variables (passed via `-var` or `TF_VAR_`)
| Variable | Default | Description |
|---|---|---|
| `cluster_name` | (required) | Cluster name — used in VM names and tags |
| `node_count` | `3` | Number of VMs to create (1-10) |
| `cpu_cores` | `4` | CPU cores per VM |
| `memory_mb` | `16384` | Memory per VM in MB |
| `disk_size_gb` | `32` | Disk size per VM in GB |
| `os_image` | `local:iso/debian-12-...` | Proxmox image for boot disk |
## Destroy
```sh
source .env
tofu destroy -var="cluster_name=dev1"
```
## SSH access
Connect to provisioned VMs using the cloud-init public key:
```sh
ssh -i ~/.ssh/your-cloud-init-key tf-user@<VM_IP>
```
## Known issues
- `~/spinifex/config/spinifex.toml` - Does not add node1, node2, node3 from config automatically
- `~/spinifex/config/predastore/predastore.toml` - Uses previous static local node config, needs to use the IPs for each node in the cluster
- On multi-node deployments, NATS on the primary can timeout waiting for other nodes to start, causing a race condition where NATS fails and all dependent services fail
- When adding 3 nodes, `nats.conf` is not updated with the 3rd node. Each node's cluster name is hardcoded to `C1` instead of per-node names
-48
View File
@@ -1,48 +0,0 @@
data "local_file" "ssh_public_key" {
filename = pathexpand(var.ssh_public_key_path)
}
# When cloning from a template, packages are pre-installed — skip apt update/upgrade.
locals {
cloud_config_template = <<-EOF
#cloud-config
runcmd:
- timedatectl set-timezone America/Los_Angeles
- systemctl enable qemu-guest-agent
- systemctl start qemu-guest-agent
- echo "done" > /tmp/vendor-cloud-init-done
EOF
cloud_config_image = <<-EOF
#cloud-config
package_update: true
package_upgrade: true
packages:
- qemu-guest-agent
- net-tools
- git
- make
runcmd:
- timedatectl set-timezone America/Los_Angeles
- systemctl enable qemu-guest-agent
- systemctl start qemu-guest-agent
- ufw disable
- sysctl -w net.core.rmem_max=4194304
- sysctl -w net.core.wmem_max=4194304
- usermod -aG kvm tf-user
- echo "done" > /tmp/vendor-cloud-init-done
EOF
}
resource "proxmox_virtual_environment_file" "cloud_config" {
count = var.node_count
content_type = "snippets"
datastore_id = "local"
node_name = var.nodes[count.index % length(var.nodes)].name
source_raw {
data = var.nodes[count.index % length(var.nodes)].template_vmid != null ? local.cloud_config_template : local.cloud_config_image
file_name = "${var.cluster_name}-${count.index + 1}-cloud-config.yaml"
}
}
-17
View File
@@ -1,17 +0,0 @@
# Files already exist, skip
#resource "proxmox_virtual_environment_download_file" "debian_cloud_image_neon" {
# content_type = "iso"
# datastore_id = "local"
# node_name = "neon"
# url = "https://cdimage.debian.org/images/cloud/bookworm/20240211-1654/debian-12-genericcloud-amd64-20240211-1654.qcow2"
# file_name = "debian-12-genericcloud-amd64-20240211-1654.img"
#}
#resource "proxmox_virtual_environment_download_file" "debian_cloud_image_radon" {
# content_type = "iso"
# datastore_id = "local"
# node_name = "radon"
# url = "https://cdimage.debian.org/images/cloud/bookworm/20240211-1654/debian-12-genericcloud-amd64-20240211-1654.qcow2"
# file_name = "debian-12-genericcloud-amd64-20240211-1654.img"
#}
-28
View File
@@ -1,28 +0,0 @@
terraform {
required_providers {
proxmox = {
source = "bpg/proxmox"
version = "0.52.0"
}
}
}
# API token is read from PROXMOX_VE_API_TOKEN env var automatically
provider "proxmox" {
endpoint = var.proxmox_endpoint
insecure = true
ssh {
agent = false
username = var.proxmox_ssh_username
private_key = file(pathexpand(var.ssh_private_key_path))
dynamic "node" {
for_each = var.nodes
content {
name = node.value.name
address = node.value.address
}
}
}
}
-14
View File
@@ -1,14 +0,0 @@
output "inventory" {
value = {
cluster_name = var.cluster_name
node_count = var.node_count
nodes = [for i, vm in proxmox_virtual_environment_vm.hive_node : {
name = "${var.cluster_name}-${i + 1}"
index = i + 1
management = vm.ipv4_addresses[1][0]
data = vm.ipv4_addresses[2][0]
}]
ssh_user = "tf-user"
ssh_key_path = trimsuffix(var.ssh_public_key_path, ".pub")
}
}
-106
View File
@@ -1,106 +0,0 @@
variable "proxmox_endpoint" {
type = string
description = "Proxmox VE API endpoint URL (e.g. https://pve.example.com:8006/)"
validation {
condition = can(regex("^https?://", var.proxmox_endpoint))
error_message = "proxmox_endpoint must be a valid URL starting with http:// or https://"
}
}
variable "proxmox_ssh_username" {
type = string
description = "SSH username for Proxmox host access"
default = "terraform"
}
variable "ssh_private_key_path" {
type = string
description = "Path to SSH private key for Proxmox host access"
validation {
condition = length(var.ssh_private_key_path) > 0
error_message = "ssh_private_key_path must not be empty."
}
}
variable "ssh_public_key_path" {
type = string
description = "Path to SSH public key injected into VMs via cloud-init"
validation {
condition = length(var.ssh_public_key_path) > 0
error_message = "ssh_public_key_path must not be empty."
}
}
variable "nodes" {
type = list(object({
name = string
address = string
bridge = string
datastore_id = string
template_vmid = optional(number)
}))
description = "Proxmox hosts for VM placement (VMs are distributed round-robin). Set template_vmid to clone from a pre-built template instead of booting from os_image."
validation {
condition = length(var.nodes) > 0
error_message = "At least one Proxmox node must be defined."
}
validation {
condition = alltrue([for n in var.nodes : length(n.name) > 0 && length(n.address) > 0])
error_message = "Each node must have a non-empty name and address."
}
validation {
condition = length(distinct([for n in var.nodes : n.name])) == length(var.nodes)
error_message = "Each node must have a unique name."
}
}
variable "cluster_name" {
type = string
description = "Name for the Spinifex cluster (used in VM names and tags)"
validation {
condition = can(regex("^[a-z0-9-]+$", var.cluster_name))
error_message = "cluster_name must contain only lowercase letters, numbers, and hyphens."
}
}
variable "node_count" {
type = number
description = "Number of Spinifex VMs to create"
default = 3
validation {
condition = var.node_count >= 1 && var.node_count <= 10
error_message = "node_count must be between 1 and 10."
}
}
variable "cpu_cores" {
type = number
description = "Number of CPU cores per VM"
default = 4
}
variable "memory_mb" {
type = number
description = "Memory in MB per VM"
default = 16384
}
variable "disk_size_gb" {
type = number
description = "Disk size in GB per VM"
default = 32
}
variable "os_image" {
type = string
description = "Proxmox image file ID for VM boot disk (used when node has no template_vmid)"
default = "local:iso/debian-12-genericcloud-amd64-20240211-1654.img"
}
-74
View File
@@ -1,74 +0,0 @@
resource "proxmox_virtual_environment_vm" "hive_node" {
count = var.node_count
name = "${var.cluster_name}-${count.index + 1}"
description = "Managed by Terraform"
tags = ["terraform", "spinifex", var.cluster_name]
node_name = var.nodes[count.index % length(var.nodes)].name
initialization {
datastore_id = "local"
vendor_data_file_id = proxmox_virtual_environment_file.cloud_config[count.index].id
user_account {
username = "tf-user"
keys = [trimspace(data.local_file.ssh_public_key.content)]
}
# Management
ip_config {
ipv4 {
address = "dhcp"
}
}
# Data/VM
ip_config {
ipv4 {
address = "dhcp"
}
}
}
cpu {
cores = var.cpu_cores
type = "host"
}
memory {
dedicated = var.memory_mb
}
agent {
enabled = true
}
network_device {
bridge = var.nodes[count.index % length(var.nodes)].bridge
}
network_device {
bridge = var.nodes[count.index % length(var.nodes)].bridge
}
# Clone from template if the target node has template_vmid set, otherwise boot from cloud image.
dynamic "clone" {
for_each = var.nodes[count.index % length(var.nodes)].template_vmid != null ? [1] : []
content {
vm_id = var.nodes[count.index % length(var.nodes)].template_vmid
full = true
}
}
dynamic "disk" {
for_each = var.nodes[count.index % length(var.nodes)].template_vmid == null ? [1] : []
content {
file_id = var.os_image
datastore_id = var.nodes[count.index % length(var.nodes)].datastore_id
interface = "virtio0"
iothread = true
discard = "on"
size = var.disk_size_gb
}
}
}
-223
View File
@@ -1,223 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TOFU_DIR="$SCRIPT_DIR/proxmox"
CLUSTERS_DIR="$TOFU_DIR/clusters"
usage() {
cat <<'USAGE'
Usage: spinifex-test.sh <command> <cluster_name> [options]
Commands:
up Provision VMs with OpenTofu
configure Clone repo, build, form cluster, start services
test Run smoke tests against the cluster
down Destroy VMs and clean up state
full up → configure → test → down (preserves test exit code)
status Show cluster health and node IPs
ssh <N> SSH to node N (1-based)
Options:
--node-count=N Number of VMs (default: 3)
--memory-mb=N Memory per VM in MB (default: 16384)
--cpu-cores=N CPU cores per VM (default: 4)
--disk-size-gb=N Disk size per VM in GB (default: 32)
Examples:
./scripts/iac/spinifex-test.sh up spinifex-test1
./scripts/iac/spinifex-test.sh configure spinifex-test1
./scripts/iac/spinifex-test.sh test spinifex-test1
./scripts/iac/spinifex-test.sh status spinifex-test1
./scripts/iac/spinifex-test.sh ssh spinifex-test1 2
./scripts/iac/spinifex-test.sh down spinifex-test1
./scripts/iac/spinifex-test.sh full spinifex-test1 --node-count=3
USAGE
exit 1
}
[ $# -lt 2 ] && usage
COMMAND="$1"
CLUSTER_NAME="$2"
shift 2
# Parse options
NODE_COUNT=3
MEMORY_MB=16384
CPU_CORES=4
DISK_SIZE_GB=32
SSH_NODE=""
for arg in "$@"; do
case "$arg" in
--node-count=*) NODE_COUNT="${arg#*=}" ;;
--memory-mb=*) MEMORY_MB="${arg#*=}" ;;
--cpu-cores=*) CPU_CORES="${arg#*=}" ;;
--disk-size-gb=*) DISK_SIZE_GB="${arg#*=}" ;;
[0-9]*) SSH_NODE="$arg" ;;
*) echo "Unknown option: $arg"; usage ;;
esac
done
STATE_DIR="$CLUSTERS_DIR/$CLUSTER_NAME"
STATE_FILE="$STATE_DIR/terraform.tfstate"
tofu_cmd() {
tofu -chdir="$TOFU_DIR" "$@"
}
get_inventory() {
tofu_cmd output -json -state="$STATE_FILE" inventory 2>/dev/null
}
get_node_ip() {
local index=$1
get_inventory | jq -r ".nodes[$((index - 1))].management"
}
get_ssh_key() {
get_inventory | jq -r ".ssh_key_path"
}
get_node_count() {
get_inventory | jq -r ".node_count"
}
cmd_up() {
echo "==> Provisioning cluster '$CLUSTER_NAME' ($NODE_COUNT nodes, ${MEMORY_MB}MB RAM, ${CPU_CORES} cores)"
mkdir -p "$STATE_DIR"
tofu_cmd init
tofu_cmd apply \
-state="$STATE_FILE" \
-var="cluster_name=$CLUSTER_NAME" \
-var="node_count=$NODE_COUNT" \
-var="memory_mb=$MEMORY_MB" \
-var="cpu_cores=$CPU_CORES" \
-var="disk_size_gb=$DISK_SIZE_GB" \
-auto-approve
echo ""
echo "==> Cluster '$CLUSTER_NAME' provisioned"
echo ""
get_inventory | jq '.nodes[] | {name, management, data}'
}
cmd_configure() {
echo "==> Configuring cluster '$CLUSTER_NAME'"
if [ ! -f "$STATE_FILE" ]; then
echo "Error: No state file found at $STATE_FILE. Run 'up' first."
exit 1
fi
"$SCRIPT_DIR/configure-cluster.sh" "$CLUSTER_NAME" "$STATE_FILE"
}
cmd_test() {
echo "==> Testing cluster '$CLUSTER_NAME'"
if [ ! -f "$STATE_FILE" ]; then
echo "Error: No state file found at $STATE_FILE. Run 'up' first."
exit 1
fi
"$SCRIPT_DIR/test-cluster.sh" "$CLUSTER_NAME" "$STATE_FILE"
}
cmd_down() {
echo "==> Destroying cluster '$CLUSTER_NAME'"
if [ ! -f "$STATE_FILE" ]; then
echo "Error: No state file found at $STATE_FILE."
exit 1
fi
tofu_cmd destroy \
-state="$STATE_FILE" \
-var="cluster_name=$CLUSTER_NAME" \
-var="node_count=$NODE_COUNT" \
-var="memory_mb=$MEMORY_MB" \
-var="cpu_cores=$CPU_CORES" \
-var="disk_size_gb=$DISK_SIZE_GB" \
-auto-approve
rm -rf "$STATE_DIR"
echo "==> Cluster '$CLUSTER_NAME' destroyed"
}
cmd_full() {
cmd_up
cmd_configure
local test_exit=0
cmd_test || test_exit=$?
cmd_down
exit $test_exit
}
cmd_status() {
if [ ! -f "$STATE_FILE" ]; then
echo "Error: No state file found at $STATE_FILE."
exit 1
fi
local inventory
inventory=$(get_inventory)
local count
count=$(echo "$inventory" | jq -r '.node_count')
local ssh_key
ssh_key=$(echo "$inventory" | jq -r '.ssh_key_path')
echo "==> Cluster '$CLUSTER_NAME' status ($count nodes)"
echo ""
for i in $(seq 1 "$count"); do
local ip
ip=$(echo "$inventory" | jq -r ".nodes[$((i - 1))].management")
local data_ip
data_ip=$(echo "$inventory" | jq -r ".nodes[$((i - 1))].data")
local name
name=$(echo "$inventory" | jq -r ".nodes[$((i - 1))].name")
printf " %-20s mgmt=%-16s data=%-16s " "$name" "$ip" "$data_ip"
local health
if health=$(curl -sk --connect-timeout 3 "https://$ip:4432/health" 2>/dev/null); then
echo "health=$health"
else
echo "health=unreachable"
fi
done
}
cmd_ssh() {
if [ -z "$SSH_NODE" ]; then
echo "Usage: spinifex-test.sh ssh <cluster_name> <node_number>"
exit 1
fi
local ip
ip=$(get_node_ip "$SSH_NODE")
local ssh_key
ssh_key=$(get_ssh_key)
echo "==> SSH to $CLUSTER_NAME node $SSH_NODE ($ip)"
ssh -i "$(eval echo "$ssh_key")" \
-o StrictHostKeyChecking=no \
-o UserKnownHostsFile=/dev/null \
"tf-user@$ip"
}
case "$COMMAND" in
up) cmd_up ;;
configure) cmd_configure ;;
test) cmd_test ;;
down) cmd_down ;;
full) cmd_full ;;
status) cmd_status ;;
ssh) cmd_ssh ;;
*) echo "Unknown command: $COMMAND"; usage ;;
esac
-71
View File
@@ -1,71 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# Run smoke tests against a configured Spinifex cluster.
# Usage: test-cluster.sh <cluster_name> <state_file>
CLUSTER_NAME="${1:?Usage: test-cluster.sh <cluster_name> <state_file>}"
STATE_FILE="${2:?Usage: test-cluster.sh <cluster_name> <state_file>}"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
TOFU_DIR="$SCRIPT_DIR/proxmox"
inventory=$(tofu -chdir="$TOFU_DIR" output -json -state="$STATE_FILE" inventory)
NODE1_IP=$(echo "$inventory" | jq -r '.nodes[0].management')
AWS_ENDPOINT="https://$NODE1_IP:9999"
PASS=0
FAIL=0
run_test() {
local name="$1"
shift
printf " %-40s " "$name"
if output=$("$@" 2>&1); then
echo "PASS"
PASS=$((PASS + 1))
else
echo "FAIL"
echo " $output" | head -5
FAIL=$((FAIL + 1))
fi
}
echo "==> Smoke tests for cluster '$CLUSTER_NAME'"
echo " Endpoint: $AWS_ENDPOINT"
echo ""
# Wait for gateway to be reachable
echo "==> Waiting for AWS gateway..."
attempts=0
while ! curl -sk --connect-timeout 3 "$AWS_ENDPOINT" >/dev/null 2>&1; do
attempts=$((attempts + 1))
if [ $attempts -ge 30 ]; then
echo "Error: Gateway not reachable at $AWS_ENDPOINT after 150 seconds"
exit 1
fi
sleep 5
done
echo " Gateway reachable"
echo ""
echo "==> Running tests..."
run_test "describe-regions" \
aws --endpoint-url "$AWS_ENDPOINT" --no-verify-ssl --region ap-southeast-2 \
ec2 describe-regions
run_test "describe-instance-types" \
aws --endpoint-url "$AWS_ENDPOINT" --no-verify-ssl --region ap-southeast-2 \
ec2 describe-instance-types --query 'InstanceTypes[0].InstanceType'
run_test "describe-instances" \
aws --endpoint-url "$AWS_ENDPOINT" --no-verify-ssl --region ap-southeast-2 \
ec2 describe-instances
echo ""
echo "==> Results: $PASS passed, $FAIL failed"
if [ $FAIL -gt 0 ]; then
exit 1
fi
@@ -0,0 +1,20 @@
# spinifex-noop — managed-addon delivery-transport fixture. Two trivial,
# dependency-free objects the addon e2e asserts on to prove the stage → fetch →
# render → auto-deploy → ACTIVE → delete round-trip end-to-end, independent of
# the CSI or load-balancer-controller bundles. Not a real workload.
apiVersion: v1
kind: Namespace
metadata:
name: spinifex-noop
labels:
app.kubernetes.io/managed-by: spinifex-addon
---
apiVersion: v1
kind: ConfigMap
metadata:
name: spinifex-noop
namespace: spinifex-noop
labels:
app.kubernetes.io/managed-by: spinifex-addon
data:
marker: "spinifex-noop addon delivered"
+22 -2
View File
@@ -10,8 +10,13 @@ set -eu
# ROLE_FILE so later boots and the etcd-snapshot cron can branch without
# re-parsing, then removes itself from the runlevel so it runs exactly once.
#
# server → eks-token-webhook, k3s (server), k3s-first-boot (bootstrap publish)
# agent → k3s-agent
# server → eks-token-webhook, k3s (server), k3s-first-boot (bootstrap
# publish), mulga-eks-state-report
# server-join → eks-token-webhook, k3s (server, joins the first server's etcd
# quorum), mulga-eks-state-report. NO k3s-first-boot: the first
# server already publishes the cluster-identical bootstrap
# artifacts; a join re-publish only races the bootstrap bus.
# agent → k3s-agent
#
# Paths are overridable via env so the selection logic is unit-testable (bats)
# without root or a real /etc.
@@ -50,10 +55,25 @@ case "${ROLE}" in
rc-update add k3s default
rc-update add k3s-first-boot default
rc-update add mulga-eks-state-report default
# Managed-addon delivery runs only on the primary server: it renders
# staged addon bundles into the K3s auto-deploy dir, which a single
# writer owns. HA multi-server addon delivery is tracked separately
# (mulga-siv-231.7); server-join nodes do not run it.
rc-update add mulga-eks-addon-sync default
rc-service eks-token-webhook start
rc-service k3s start
rc-service k3s-first-boot start
rc-service mulga-eks-state-report start
rc-service mulga-eks-addon-sync start
;;
server-join)
log "configuring server-join role"
rc-update add eks-token-webhook default
rc-update add k3s default
rc-update add mulga-eks-state-report default
rc-service eks-token-webhook start
rc-service k3s start
rc-service mulga-eks-state-report start
;;
agent)
log "configuring agent role"
@@ -66,6 +66,20 @@ check "server: starts k3s" "yes" "$(grep -q 'rc-service k3s start' "${CALLS}" &&
check "server: no k3s-agent" "no" "$(grep -q 'k3s-agent' "${CALLS}" && echo yes || echo no)"
check "server: self-disables" "yes" "$(grep -q 'rc-update del eks-node-role default' "${CALLS}" && echo yes || echo no)"
# --- Case 1b: explicit server-join role ---
run_case server_join
printf 'SPINIFEX_K3S_ROLE=server-join\nEKS_CLUSTER_NAME=alpha\n' > "${EKS_NODE_ENVFILE}"
invoke
check "server-join: exit 0" 0 "${rc}"
check "server-join: role file" "server-join" "$(cat "${ROLE_FILE}")"
check "server-join: enables webhook" "yes" "$(grep -q 'rc-update add eks-token-webhook default' "${CALLS}" && echo yes || echo no)"
check "server-join: enables k3s" "yes" "$(grep -q 'rc-update add k3s default' "${CALLS}" && echo yes || echo no)"
check "server-join: enables state-report" "yes" "$(grep -q 'rc-update add mulga-eks-state-report default' "${CALLS}" && echo yes || echo no)"
# Join servers must NOT re-publish bootstrap; the first server already did.
check "server-join: no k3s-first-boot" "no" "$(grep -q 'k3s-first-boot' "${CALLS}" && echo yes || echo no)"
check "server-join: no k3s-agent" "no" "$(grep -q 'k3s-agent' "${CALLS}" && echo yes || echo no)"
check "server-join: self-disables" "yes" "$(grep -q 'rc-update del eks-node-role default' "${CALLS}" && echo yes || echo no)"
# --- Case 2: explicit agent role ---
run_case agent_explicit
printf 'SPINIFEX_K3S_ROLE=agent\n' > "${EKS_NODE_ENVFILE}"
+5 -2
View File
@@ -18,9 +18,9 @@ APK_PACKAGES="ca-certificates curl jq iptables ip6tables conntrack-tools cgroup-
# eks-gateway-publish relays the bootstrap envelopes + state reports to the AWS
# gateway over SigV4 HTTPS (server role only). The k3s binary is downloaded +
# SHA256-verified by setup.sh (network in chroot).
BUILD_COMMANDS="CGO_ENABLED=0 GOFIPS140=v1.0.0 go build -o bin/eks-token-webhook ./cmd/eks-token-webhook && CGO_ENABLED=0 GOFIPS140=v1.0.0 go build -o bin/eks-gateway-publish ./cmd/eks-gateway-publish"
BUILD_COMMANDS="CGO_ENABLED=0 GOFIPS140=v1.0.0 go build -o bin/eks-token-webhook ./cmd/eks-token-webhook && CGO_ENABLED=0 GOFIPS140=v1.0.0 go build -o bin/eks-gateway-publish ./cmd/eks-gateway-publish && CGO_ENABLED=0 GOFIPS140=v1.0.0 go build -o bin/eks-gateway-fetch ./cmd/eks-gateway-fetch"
INSTALL_BINARIES="bin/eks-token-webhook:/usr/local/bin/eks-token-webhook bin/eks-gateway-publish:/usr/local/bin/eks-gateway-publish"
INSTALL_BINARIES="bin/eks-token-webhook:/usr/local/bin/eks-token-webhook bin/eks-gateway-publish:/usr/local/bin/eks-gateway-publish bin/eks-gateway-fetch:/usr/local/bin/eks-gateway-fetch"
# All role services ship; none but the selector are enabled at bake time.
# eks-node-role picks server vs agent at first boot and enables the rest.
@@ -34,6 +34,9 @@ scripts/images/eks-node/k3s-first-boot.initd:/etc/init.d/k3s-first-boot \
scripts/images/eks-node/k3s-first-boot.sh:/usr/local/sbin/k3s-first-boot \
scripts/images/eks-node/mulga-eks-state-report.initd:/etc/init.d/mulga-eks-state-report \
scripts/images/eks-node/mulga-eks-state-report.sh:/usr/local/sbin/mulga-eks-state-report \
scripts/images/eks-node/mulga-eks-addon-sync.initd:/etc/init.d/mulga-eks-addon-sync \
scripts/images/eks-node/mulga-eks-addon-sync.sh:/usr/local/sbin/mulga-eks-addon-sync \
scripts/images/eks-node/addons/spinifex-noop/0.1.0/noop.yaml:/usr/share/spinifex-eks/addons/spinifex-noop/0.1.0/noop.yaml \
scripts/images/eks-node/mulga-eks-etcd-snapshot.sh:/etc/periodic/daily/mulga-eks-etcd-snapshot"
# Only crond (etcd snapshot) + the role selector at bake. The selector adds the
@@ -0,0 +1,30 @@
#!/sbin/openrc-run
# mulga-eks-addon-sync — server-role service that renders Spinifex managed-addon
# bundles into the K3s auto-deploy dir and reports per-addon delivery status to
# the spinifex control plane every ADDON_SYNC_INTERVAL seconds. The host-side
# daemon stages each addon in KV; the VM pulls the staged set through the AWS
# gateway (it cannot read NATS/KV directly) and renders the baked bundles.
# Enabled on the primary server role by eks-node-role.sh.
description="Mulga EKS managed-addon delivery agent"
command="/usr/local/sbin/mulga-eks-addon-sync"
command_background="yes"
pidfile="/run/mulga-eks-addon-sync.pid"
output_log="/var/log/mulga-eks-addon-sync.log"
error_log="/var/log/mulga-eks-addon-sync.log"
# Cadence the sync loop sleeps between pulls. 30s keeps CreateAddon→ACTIVE
# latency low without hammering the gateway.
export ADDON_SYNC_INTERVAL="${ADDON_SYNC_INTERVAL:-30}"
depend() {
need net
after k3s
}
start_pre() {
if [ ! -f /etc/spinifex-eks/first-boot.env ]; then
eerror "/etc/spinifex-eks/first-boot.env not present — cloud-init did not seed gateway creds"
return 1
fi
}
@@ -0,0 +1,199 @@
#!/bin/sh
set -eu
# mulga-eks-addon-sync — renders Spinifex managed-addon bundles into the K3s
# auto-deploy dir and reports per-addon delivery status back to the spinifex
# control plane. The host-side daemon stages each CreateAddon as a manifest
# descriptor in KV; the VM cannot read KV directly, so this agent pulls the
# staged set through the AWS gateway (eks-gateway-fetch, SigV4) and renders the
# baked bundle for each one. Status flows back via eks-gateway-publish on the
# "addon" channel, where the reconciler CASes the AddonRecord CREATING→ACTIVE.
#
# Pull (host→VM): GET /clusters/{cluster}/internal-addons/{accountId}
# Report (VM→host): eks.addon.{accountID}.{clusterName}.status
#
# Runs server-role only (it owns the single auto-deploy dir); enabled by
# eks-node-role.sh. Loops every ADDON_SYNC_INTERVAL seconds in service mode,
# one-shot otherwise.
ENVFILE=/etc/spinifex-eks/first-boot.env
[ -f "${ENVFILE}" ] || { logger -t mulga-eks-addon-sync "${ENVFILE} missing — exiting"; exit 0; }
# set -a so the sourced creds are exported to the eks-gateway-{fetch,publish}
# children, which read EKS_ACCOUNT_ID etc. from their environment.
set -a
# shellcheck disable=SC1090
. "${ENVFILE}"
set +a
: "${EKS_GATEWAY_URL:?}"
: "${EKS_ACCESS_KEY:?}"
: "${EKS_SECRET_KEY:?}"
: "${EKS_ACCOUNT_ID:?}"
: "${EKS_CLUSTER_NAME:?}"
KUBECONFIG=${KUBECONFIG:-/etc/rancher/k3s/k3s.yaml}
export KUBECONFIG
# Baked bundles live at BAKED_DIR/<addon>/<version>/*.yaml (air-gapped, shipped
# in the AMI). Rendered output lands in the K3s auto-deploy dir under a stable
# per-addon filename so GC can find orphans this agent owns.
BAKED_DIR=/usr/share/spinifex-eks/addons
DEPLOY_DIR=/var/lib/rancher/k3s/server/manifests
RENDER_PREFIX=spinifex-addon-
# log mirrors to syslog (on-VM /var/log) and, best-effort, to the serial
# console — the host captures ttyS0 to a per-instance log, so this is the only
# delivery diagnostics channel reachable from the host (the VM has no
# host-routable address). Keep messages terse; one line per sync outcome.
log() {
logger -t mulga-eks-addon-sync "$*"
echo "[mulga-eks-addon-sync] $*" > /dev/console 2>/dev/null || true
}
# report PHASE ADDON VERSION [MESSAGE] — publish one addon status report.
report() {
_phase=$1; _addon=$2; _version=$3; _msg=${4:-}
printf '{"addon":"%s","version":"%s","phase":"%s","message":"%s","ts":%s}' \
"${_addon}" "${_version}" "${_phase}" "${_msg}" "$(date +%s)" \
| eks-gateway-publish -channel addon 2>&1 | logger -t mulga-eks-addon-sync || true
}
# render_addon ADDON VERSION ROLE_ARN — render the baked bundle into the
# auto-deploy dir, substituting the IRSA role ARN. Returns non-zero (and reports
# failed) when the baked bundle for ADDON/VERSION is absent.
render_addon() {
_addon=$1; _version=$2; _role=$3
_src="${BAKED_DIR}/${_addon}/${_version}"
_dst="${DEPLOY_DIR}/${RENDER_PREFIX}${_addon}.yaml"
if [ ! -d "${_src}" ]; then
log "no baked bundle for ${_addon}/${_version} (looked in ${_src})"
report failed "${_addon}" "${_version}" "no baked bundle for version ${_version}"
return 1
fi
_tmp="${_dst}.tmp.$$"
: > "${_tmp}"
for _f in "${_src}"/*.yaml; do
[ -e "${_f}" ] || continue
sed -e "s|{{SERVICE_ACCOUNT_ROLE_ARN}}|${_role}|g" "${_f}" >> "${_tmp}"
printf '\n---\n' >> "${_tmp}"
done
# Idempotent: only replace the auto-deploy file when the rendered content
# actually changed. An unconditional mv bumps the file mtime every tick,
# which K3s' auto-deploy controller treats as a change and re-applies —
# clearing the Addon CR's .status.gvks each time, so the same-tick readiness
# check never observes a settled apply and the add-on stays CREATING forever.
if [ -e "${_dst}" ] && cmp -s "${_tmp}" "${_dst}"; then
rm -f "${_tmp}"
return 0
fi
mv -f "${_tmp}" "${_dst}"
return 0
}
# addon_gvks ADDON — echoes the GVKs K3s' auto-deploy controller applied for the
# rendered manifest, empty until it has applied the objects. A non-empty value
# means delivery succeeded. Addon-agnostic — confirms delivery, not deep per-pod
# health (a later hardening item).
#
# K3s (v1.32.x) records the applied GVKs in the addon.k3s.cattle.io/gvks
# *annotation* (e.g. "/v1, Kind=Namespace;/v1, Kind=ConfigMap"); the Addon CR
# carries no .status subresource. The CR is matched by .spec.source (the
# absolute path of the rendered file) rather than a guessed CR name, since K3s
# derives the name from the path with its own transform.
addon_gvks() {
_want="${DEPLOY_DIR}/${RENDER_PREFIX}$1.yaml"
kubectl -n kube-system get addon -o json 2>/dev/null \
| jq -r --arg src "${_want}" \
'.items[] | select(.spec.source==$src)
| .metadata.annotations["addon.k3s.cattle.io/gvks"] // ""' 2>/dev/null \
| grep -v '^$' | head -1 || true
}
# diag_addon ADDON — one-shot console dump of the live K3s state for a stuck
# add-on, so a delivery that never reaches ready is diagnosable from the
# host-captured serial console (the VM is otherwise unreachable). Fires once per
# process via a sentinel to avoid spamming every tick.
diag_addon() {
[ -f /tmp/.addon-diag-"$1" ] && return 0
: > /tmp/.addon-diag-"$1"
{
echo "----- addon-diag ${1} -----"
echo "[deploy dir]"; ls -l "${DEPLOY_DIR}" 2>&1
echo "[rendered file head]"; head -40 "${DEPLOY_DIR}/${RENDER_PREFIX}${1}.yaml" 2>&1
echo "[all addon CRs]"; kubectl get addon -A 2>&1
echo "[addon CR ${RENDER_PREFIX}${1} -o yaml]"; kubectl -n kube-system get addon "${RENDER_PREFIX}${1}" -o yaml 2>&1
echo "[namespace ${1}]"; kubectl get ns "${1}" 2>&1
echo "[configmaps in ${1}]"; kubectl -n "${1}" get cm 2>&1
echo "----- end addon-diag ${1} -----"
} > /dev/console 2>&1 || true
}
sync_once() {
_staged=$(mktemp)
_ferr=$(mktemp)
if ! eks-gateway-fetch -resource addons > "${_staged}" 2>"${_ferr}"; then
log "fetch staged addons failed — keeping current state: $(tr '\n' ' ' < "${_ferr}")"
rm -f "${_staged}" "${_ferr}"
return 0
fi
rm -f "${_ferr}"
# Names currently staged, for the GC pass below.
_names=$(cut -f1 "${_staged}")
_count=$(grep -c . "${_staged}" 2>/dev/null || echo 0)
log "fetch ok: ${_count} staged addon(s)"
# Render/refresh each staged addon and report its phase.
while IFS=$(printf '\t') read -r _addon _version _role _cfg_b64; do
[ -n "${_addon}" ] || continue
: "${_cfg_b64:-}" # config values reserved for per-addon templating (follow-up)
if render_addon "${_addon}" "${_version}" "${_role}"; then
_gvks=$(addon_gvks "${_addon}")
if [ -n "${_gvks}" ]; then
log "addon ${_addon}/${_version}: applied + ready (gvks=${_gvks})"
report ready "${_addon}" "${_version}"
else
log "addon ${_addon}/${_version}: applied, not ready yet (no K3s Addon CR for ${RENDER_PREFIX}${_addon}.yaml with populated .status.gvks)"
diag_addon "${_addon}"
report applied "${_addon}" "${_version}"
fi
fi
done < "${_staged}"
rm -f "${_staged}"
# GC: remove rendered manifests this agent owns whose addon is no longer
# staged (DeleteAddon removed its descriptor). K3s does NOT delete the applied
# objects when the manifest file disappears, so delete them explicitly.
for _file in "${DEPLOY_DIR}/${RENDER_PREFIX}"*.yaml; do
[ -e "${_file}" ] || continue
_base=$(basename "${_file}")
_name=${_base#"${RENDER_PREFIX}"}
_name=${_name%.yaml}
if ! printf '%s\n' "${_names}" | grep -qx "${_name}"; then
log "unstaging addon ${_name} (no longer staged)"
# Copy the manifest aside, drop the original so K3s stops tracking it
# (and never re-applies), then delete the objects it rendered.
# --wait=false so namespace finalizers don't stall the sync loop.
_gc=$(mktemp)
cp "${_file}" "${_gc}"
rm -f "${_file}"
kubectl delete -f "${_gc}" --ignore-not-found --wait=false 2>&1 \
| logger -t mulga-eks-addon-sync || true
rm -f "${_gc}"
fi
done
}
mkdir -p "${DEPLOY_DIR}"
if [ -n "${ADDON_SYNC_INTERVAL:-}" ] && [ "${ADDON_SYNC_INTERVAL}" -gt 0 ] 2>/dev/null; then
log "starting: cluster=${EKS_CLUSTER_NAME} account=${EKS_ACCOUNT_ID} interval=${ADDON_SYNC_INTERVAL}s gateway=${EKS_GATEWAY_URL}"
while true; do
sync_once
sleep "${ADDON_SYNC_INTERVAL}"
done
else
sync_once
fi
+3 -2
View File
@@ -43,9 +43,10 @@ ln -sf /usr/local/bin/k3s /usr/local/bin/ctr
# Init scripts ship as 0644 from INSTALL_FILES; OpenRC requires 0755. Every
# role's services are baked; the selector enables the right ones at first boot.
chmod 0755 /etc/init.d/eks-node-role /etc/init.d/k3s /etc/init.d/k3s-agent \
/etc/init.d/eks-token-webhook /etc/init.d/k3s-first-boot /etc/init.d/mulga-eks-state-report
/etc/init.d/eks-token-webhook /etc/init.d/k3s-first-boot /etc/init.d/mulga-eks-state-report \
/etc/init.d/mulga-eks-addon-sync
chmod 0755 /usr/local/sbin/eks-node-role /usr/local/sbin/k3s-first-boot \
/usr/local/sbin/mulga-eks-state-report
/usr/local/sbin/mulga-eks-state-report /usr/local/sbin/mulga-eks-addon-sync
chmod 0755 /etc/periodic/daily/mulga-eks-etcd-snapshot
# K3s server config — skeleton; cloud-init / first-boot fills in the
+3 -1
View File
@@ -89,7 +89,9 @@ fi
if [[ "$DO_BUILD" == true ]]; then
echo "=== Building ${IMAGE_NAME} ==="
"${SCRIPT_DIR}/build-system-image.sh" "$MANIFEST"
# --no-cache: publishing must never reuse a stale raw left by a prior or
# concurrent build on a persistent runner.
"${SCRIPT_DIR}/build-system-image.sh" "$MANIFEST" --no-cache
fi
if [[ ! -f "$RAW_IMAGE" ]]; then
-55
View File
@@ -1,55 +0,0 @@
#!/bin/sh
# Query images, get Ubuntu image
export AWS_PROFILE=spinifex
# Detect architecture
ARCH=$(uname -m)
if [ "$ARCH" = "x86_64" ]; then
IMAGE_NAME="ami-ubuntu-26.04-x86_64"
elif [ "$ARCH" = "aarch64" ] || [ "$ARCH" = "arm64" ]; then
IMAGE_NAME="ami-ubuntu-26.04-arm64"
else
echo "Warning: Unknown architecture $ARCH, defaulting to x86_64"
IMAGE_NAME="ami-ubuntu-26.04-x86_64"
fi
# Query available instance types from Spinifex and pick the first micro type
INSTANCE_TYPE=$(aws ec2 describe-instance-types --query "InstanceTypes[?contains(InstanceType, '.micro')].InstanceType | [0]" --output text)
if [ -z "$INSTANCE_TYPE" ] || [ "$INSTANCE_TYPE" = "None" ]; then
echo "Error: Could not find available micro instance type"
echo "Available instance types:"
aws ec2 describe-instance-types --query "InstanceTypes[*].InstanceType" --output table
exit 1
fi
echo "Using instance type: $INSTANCE_TYPE"
echo "Detected architecture: $ARCH"
echo "Looking for image with Name: $IMAGE_NAME"
# Query images and extract ImageId matching the IMAGE_NAME
SPINIFEX_AMI=$(aws ec2 describe-images --query "Images[?Name=='$IMAGE_NAME'].ImageId" --output text)
if [ -z "$SPINIFEX_AMI" ]; then
echo "Error: Could not find image with Name '$IMAGE_NAME'"
echo "Available images:"
aws ec2 describe-images --query "Images[*].[Name,ImageId]" --output table
exit 1
fi
export SPINIFEX_AMI=$SPINIFEX_AMI
echo "Found ImageId: $SPINIFEX_AMI"
export SPINIFEX_AMI
echo "Launching instance"
# Launch instance
aws ec2 run-instances \
--image-id "$SPINIFEX_AMI" \
--instance-type "$INSTANCE_TYPE" \
--key-name spinifex-key \
--security-group-ids sg-0123456789abcdef0 \
--subnet-id subnet-6e7f829e \
--count 1
+65
View File
@@ -416,6 +416,14 @@ TMPEOF
$SUDO chown "spinifex-gw:$SPINIFEX_GROUP" /etc/spinifex/awsgw
$SUDO chmod 0750 /etc/spinifex/awsgw
# Viperblock's at-rest encryption key dir. 0750 group-traversable; the key
# itself is set to root:spinifex 0640 by SetServiceOwnership because both
# viperblockd (spinifex-viperblock) and the awsgw handlers (spinifex-gw)
# load it.
$SUDO mkdir -p /etc/spinifex/viperblock
$SUDO chown "spinifex-viperblock:$SPINIFEX_GROUP" /etc/spinifex/viperblock
$SUDO chmod 0750 /etc/spinifex/viperblock
# Per-service data directories
$SUDO mkdir -p /var/lib/spinifex/nats
$SUDO chown "spinifex-nats:$SPINIFEX_GROUP" /var/lib/spinifex/nats
@@ -599,6 +607,20 @@ install_systemd() {
info " /etc/systemd/system/$(basename "$unit")"
done
# Reserve RAM + CPU priority for system.slice (sshd, journald, the operator)
# so a maxed spinifex.slice cannot starve them — the "stay sshable" guarantee.
# Generated here rather than shipped as a staged file because the packaging
# globs flatten the systemd/ dir and would skip a nested drop-in directory;
# this mirrors the sshd-keygen drop-in pattern in build-rootfs.sh. MemoryMin
# is a guaranteed-unreclaimable floor, not a cap.
$SUDO mkdir -p /etc/systemd/system/system.slice.d
$SUDO tee /etc/systemd/system/system.slice.d/spinifex-reserve.conf > /dev/null << 'EOF'
[Slice]
MemoryMin=1G
CPUWeight=300
EOF
info " /etc/systemd/system/system.slice.d/spinifex-reserve.conf"
# daemon-reload / enable require a running systemd — skip inside the ISO
# chroot. Unit files are still dropped into place; firstboot enables them.
if [ "${ISO_BUILD:-0}" = "1" ]; then
@@ -704,6 +726,48 @@ print_summary() {
echo ""
}
# --- Configure host swap (mulga-siv-251) ---
# Provisions an 8G swapfile and lowers vm.swappiness so spinifex.slice
# (MemorySwapMax=100%) has a backing device for graceful degradation under
# memory pressure. Reverses the historical swap=0 assumption. Idempotent.
setup_swap() {
stage "configuring host swap"
# ISO build runs in a chroot — cannot swapon, and baking an 8G file into the
# rootfs bloats the image. ISO hosts provision swap at firstboot instead.
if [ "${ISO_BUILD:-0}" = "1" ]; then
info "Swap setup skipped (ISO_BUILD=1; firstboot provisions swap)"
return
fi
local swapfile=/swapfile size_mb=8192
# Swap is a safety buffer, not a routine path: reclaim page cache first.
$SUDO tee /etc/sysctl.d/99-spinifex-swap.conf > /dev/null << 'EOF'
# Spinifex: minimise swapping; swap backs spinifex.slice graceful degradation.
vm.swappiness = 10
EOF
$SUDO chmod 0644 /etc/sysctl.d/99-spinifex-swap.conf
$SUDO sysctl -q -w vm.swappiness=10 || true
if swapon --show=NAME --noheadings 2>/dev/null | grep -qx "$swapfile"; then
info "Swap already active ($swapfile)"
return
fi
if [ ! -f "$swapfile" ]; then
info "Creating ${size_mb}MiB $swapfile"
$SUDO fallocate -l "${size_mb}M" "$swapfile" 2>/dev/null \
|| $SUDO dd if=/dev/zero of="$swapfile" bs=1M count="$size_mb" status=none
$SUDO chmod 0600 "$swapfile"
$SUDO mkswap "$swapfile" > /dev/null
fi
$SUDO swapon "$swapfile"
grep -q "^$swapfile " /etc/fstab 2>/dev/null \
|| echo "$swapfile none swap sw 0 0" | $SUDO tee -a /etc/fstab > /dev/null
info "Swap enabled: $swapfile (${size_mb}MiB), vm.swappiness=10"
}
# --- Main ---
main() {
info "Spinifex installer"
@@ -739,6 +803,7 @@ main() {
stage_enabled systemd && install_systemd
stage_enabled logrotate && install_logrotate
stage_enabled udev && install_udev
stage_enabled swap && setup_swap
# Migrations are only safe on a live system (need a running NATS and a
# persisted config file). Skip under ISO_BUILD and under any explicit
-514
View File
@@ -1,514 +0,0 @@
#!/bin/bash
# Start Spinifex development environment
# This script starts all required services in the correct order using Spinifex service commands
# Usage: ./scripts/start-dev.sh [--build] [data-dir]
# --build: Rebuild all binaries before starting (default: skip build)
# data-dir: Optional data directory path (default: ~/spinifex)
#
# Environment variables:
# UI=false Skip starting Spinifex UI (e.g., UI=false ./scripts/start-dev.sh)
set -euo pipefail
echo "WARNING: start-dev.sh is deprecated for local development." >&2
echo " Use 'make deploy' for daily iteration or 'scripts/dev-install.sh' for first-time setup." >&2
echo " start-dev.sh is retained only for pseudo-multinode CI (e2e_pseudo_multi)." >&2
echo "" >&2
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
MULGA_ROOT="$(cd "$PROJECT_ROOT/.." && pwd)"
# Parse arguments
BUILD=false
DATA_DIR="$HOME/spinifex"
for arg in "$@"; do
case "$arg" in
--build) BUILD=true ;;
*) DATA_DIR="$arg" ;;
esac
done
# Configuration paths
# Use CONFIG_DIR environment variable if set, otherwise derive from DATA_DIR
CONFIG_DIR="${CONFIG_DIR:-$DATA_DIR/config}"
echo "Using data directory: $DATA_DIR"
echo "Using configuration directory: $CONFIG_DIR"
LOGS_DIR="$DATA_DIR/logs"
WAL_DIR="$DATA_DIR/spinifex"
echo "⚠️ Dev mode: all services run as $(whoami) without privilege separation"
echo ""
echo "🚀 Starting Spinifex development environment..."
echo "Project root: $PROJECT_ROOT"
echo "Data directory: $DATA_DIR"
# Parse services from spinifex.toml — defaults to all if not set
parse_services() {
local config_file="$CONFIG_DIR/spinifex.toml"
if [ -f "$config_file" ]; then
local svc_line=$(grep -m1 '^services' "$config_file" | sed 's/.*\[//;s/\].*//;s/"//g;s/,/ /g')
if [ -n "$svc_line" ]; then
echo "$svc_line"
return
fi
fi
echo "nats predastore viperblock daemon awsgw vpcd ui"
}
SERVICES=$(parse_services)
has_service() {
local svc="$1"
echo "$SERVICES" | grep -qw "$svc"
}
echo "Services: $SERVICES"
# Detect multi-node cluster from config
is_multinode() {
local config_file="$CONFIG_DIR/spinifex.toml"
if [ -f "$config_file" ]; then
local node_count=$(grep -c '^\[nodes\.' "$config_file")
[ "$node_count" -gt 1 ]
else
return 1
fi
}
# Confirm configuration directory exists
if [ ! -d "$CONFIG_DIR" ]; then
echo "⚠️ Configuration directory $CONFIG_DIR does not exist."
echo "Please init the spinifex environment using the CLI."
echo "spx admin init"
exit 1
fi
if [ -d "/mnt/ramdisk" ]; then
# Check if /mnt/ramdisk is mounted, if not mount it as tmpfs
if ! mountpoint -q /mnt/ramdisk; then
echo "💾 Mounting /mnt/ramdisk as tmpfs"
sudo mount -t tmpfs -o size=8G tmpfs /mnt/ramdisk/
fi
# If /mnt/ramdisk is mounted, use it for the WAL directory (for development)
if mountpoint -q "/mnt/ramdisk"; then
WAL_DIR="/mnt/ramdisk/"
fi
else
echo "⚠️ /mnt/ramdisk not available, using $DATA_DIR/viperblock"
fi
# Change to project root for all commands
cd "$PROJECT_ROOT"
# Function to start service in background
start_service() {
local name="$1"
local command="$2"
local pidfile="$LOGS_DIR/$name.pid"
local logfile="$LOGS_DIR/$name.log"
echo "📡 Starting $name..."
echo " Command: $command"
# Start service and capture PID
nohup $command > "$logfile" 2>&1 &
local pid=$!
echo $pid > "$pidfile"
echo " PID: $pid, Log: $logfile"
# Brief pause to let service start, then verify it's still alive
sleep 1
if ! kill -0 "$pid" 2>/dev/null; then
echo " ❌ $name failed to start (exited immediately)"
echo " Check log: $logfile"
tail -5 "$logfile" 2>/dev/null || true
exit 1
fi
}
# Function to start service in foreground (for final daemon)
start_service_foreground() {
local name="$1"
local command="$2"
echo "📡 Starting $name in foreground..."
echo " Command: $command"
# Setup signal handler to stop background services when daemon stops
trap 'echo ""; echo "🛑 Stopping all services..."; ./scripts/stop-dev.sh; exit 0' INT TERM
# Start the service in foreground
$command
}
# Function to set OOM score for a service (Linux only)
# Protects infrastructure services from OOM killer (-500 = less likely to be killed)
set_oom_score() {
local name="$1"
local score="$2"
local pid_file="$LOGS_DIR/${name}.pid"
if [ -f "$pid_file" ]; then
local pid=$(cat "$pid_file")
if [ -d "/proc/$pid" ]; then
echo "$score" > "/proc/$pid/oom_score_adj" 2>/dev/null && \
echo " OOM score for $name (PID $pid): $score" || \
echo " Warning: Could not set OOM score for $name"
fi
fi
}
# Function to check if service is responsive
check_service() {
local name="$1"
local host="$2"
local port="$3"
local max_attempts="${4:-30}"
local attempt=1
echo "🔍 Checking $name connectivity on $host:$port..."
while [ $attempt -le $max_attempts ]; do
if nc -z "$host" "$port" 2>/dev/null; then
echo " ✅ $name is responding on $host:$port"
return 0
fi
echo " ⏳ Attempt $attempt/$max_attempts - waiting for $name..."
sleep 2
((attempt++))
done
echo " ❌ $name failed to start on $host:$port after $max_attempts attempts"
exit 1
}
# Pre-flight, compile latest (only with --build flag)
if [ "$BUILD" = "true" ]; then
echo "✈️ Pre-flight, compiling latest..."
echo " Building spinifex..."
make build
echo " Building predastore..."
cd "$MULGA_ROOT/predastore" && make build
cd "$PROJECT_ROOT"
echo " Building viperblock (nbdkit plugin)..."
cd "$MULGA_ROOT/viperblock" && make build
cd "$PROJECT_ROOT"
echo " ✅ Build complete"
else
echo "✈️ Skipping build (pass --build to rebuild)"
fi
# 0️⃣ Start OVN networking (required for VPC)
echo ""
echo "0️⃣ Starting OVN networking..."
if ! pidof systemd >/dev/null 2>&1; then
echo " ⚠️ No systemd (container?) — skipping OVN lifecycle management"
elif ! command -v ovs-vsctl >/dev/null 2>&1; then
echo " ❌ OVS not installed"
echo " Run: make quickinstall && ./scripts/setup-ovn.sh --management"
exit 1
else
# Start OVS + OVN services (may already be running)
sudo systemctl start openvswitch-switch
echo " ✅ openvswitch-switch started"
# Start ovn-central if this node has it installed and enabled
if systemctl list-unit-files ovn-central.service >/dev/null 2>&1 && systemctl is-enabled ovn-central >/dev/null 2>&1; then
sudo systemctl start ovn-central
echo " ✅ ovn-central started"
fi
sudo systemctl start ovn-controller
echo " ✅ ovn-controller started"
# Verify br-int exists (created by setup-ovn.sh)
if ! sudo ovs-vsctl br-exists br-int 2>/dev/null; then
echo " ❌ br-int not found — run ./scripts/setup-ovn.sh --management first"
exit 1
fi
echo " ✅ br-int exists"
fi
# 1️⃣ Start NATS server
echo ""
if has_service "nats"; then
echo "1️⃣ Starting NATS server..."
if [ -f "$CONFIG_DIR/nats/nats.conf" ]; then
export SPINIFEX_CONFIG_PATH=$CONFIG_DIR/nats/nats.conf
echo " Using NATS config file: $CONFIG_DIR/nats/nats.conf"
else
echo " ⚠️ NATS config file not found at $CONFIG_DIR/nats/nats.conf, using defaults"
export SPINIFEX_NATS_HOST=0.0.0.0
export SPINIFEX_NATS_PORT=4222
export SPINIFEX_NATS_DATA_DIR=$DATA_DIR/nats/
export SPINIFEX_NATS_JETSTREAM=false
fi
# Extract NATS listen address from config (multi-node binds to specific IP, not 127.0.0.1)
NATS_CHECK_HOST="127.0.0.1"
if [ -f "$CONFIG_DIR/nats/nats.conf" ]; then
NATS_LISTEN_IP=$(grep -oP '^listen:\s*\K[^:]+' "$CONFIG_DIR/nats/nats.conf" 2>/dev/null || true)
if [ -n "$NATS_LISTEN_IP" ] && [ "$NATS_LISTEN_IP" != "0.0.0.0" ]; then
NATS_CHECK_HOST="$NATS_LISTEN_IP"
fi
fi
NATS_CMD="./bin/spx service nats start"
start_service "nats" "$NATS_CMD"
set_oom_score "nats" "-500"
check_service "NATS" "$NATS_CHECK_HOST" "4222"
# Wait for NATS JetStream to be ready before Predastore starts.
# Predastore lazily opens IAM KV buckets — it starts with config-only auth
# and activates IAM auth once the spinifex daemon creates the KV buckets.
# Wait for NATS JetStream to be ready before Predastore starts.
# Predastore lazily opens IAM KV buckets — it starts with config-only auth
# and activates IAM auth once the spinifex daemon creates the KV buckets.
# Use the monitoring HTTP endpoint (plaintext nc probe doesn't work with TLS).
# Monitoring is only on the primary node; secondary nodes skip this check.
NATS_HAS_MONITORING=false
if [ -f "$CONFIG_DIR/nats/nats.conf" ] && grep -q '^http:' "$CONFIG_DIR/nats/nats.conf"; then
NATS_HAS_MONITORING=true
fi
if [ "$NATS_HAS_MONITORING" = "true" ]; then
echo "🔍 Waiting for NATS JetStream..."
NATS_JS_READY=false
for i in $(seq 1 15); do
if curl -sf "http://127.0.0.1:8222/jsz" > /dev/null 2>&1; then
echo " ✅ NATS JetStream is ready"
NATS_JS_READY=true
break
fi
sleep 1
done
if [ "$NATS_JS_READY" = "false" ]; then
echo " ⚠️ NATS JetStream not confirmed ready — Predastore may fall back to config-only auth"
fi
else
echo "🔍 NATS monitoring not configured — skipping JetStream readiness check"
sleep 2
fi
else
echo "1️⃣ Skipping NATS (not a local service)"
fi
# 2️⃣ Start Predastore
echo ""
if has_service "predastore"; then
echo "2️⃣ Starting Predastore..."
unset SPINIFEX_CONFIG_PATH
export SPINIFEX_PREDASTORE_BASE_PATH=$DATA_DIR/predastore/
export SPINIFEX_PREDASTORE_CONFIG_PATH=$CONFIG_DIR/predastore/predastore.toml
export SPINIFEX_PREDASTORE_TLS_CERT=$CONFIG_DIR/server.pem
export SPINIFEX_PREDASTORE_TLS_KEY=$CONFIG_DIR/server.key
# Per-node predastore encryption key. Self-heal for devs who skipped
# `spx admin init`: generate with umask 0177 so the file is 0600 from
# creation (predastore rejects group/other-readable keys outright).
ENCRYPTION_KEY="$CONFIG_DIR/predastore/encryption.key"
if [ ! -f "$ENCRYPTION_KEY" ]; then
mkdir -p "$CONFIG_DIR/predastore"
( umask 0177 && openssl rand -out "$ENCRYPTION_KEY" 32 )
echo " Generated predastore encryption key: $ENCRYPTION_KEY"
fi
export SPINIFEX_PREDASTORE_ENCRYPTION_KEY_FILE="$ENCRYPTION_KEY"
# Auto-detect Predastore host:port from spinifex.toml [nodes.<name>.predastore] section
PREDASTORE_BIND="0.0.0.0:8443"
if [ -f "$CONFIG_DIR/spinifex.toml" ]; then
DETECTED_PREDASTORE_HOST=$(awk -F'"' '/\[nodes\..*\.predastore\]/{found=1} found && /^host/{print $2; exit}' "$CONFIG_DIR/spinifex.toml")
if [ -n "$DETECTED_PREDASTORE_HOST" ]; then
PREDASTORE_BIND="$DETECTED_PREDASTORE_HOST"
echo " Auto-detected Predastore bind=$PREDASTORE_BIND from spinifex.toml"
fi
fi
export SPINIFEX_PREDASTORE_HOST="${PREDASTORE_BIND%%:*}"
export SPINIFEX_PREDASTORE_PORT="${PREDASTORE_BIND##*:}"
export SPINIFEX_PREDASTORE_BACKEND=distributed
# Auto-detect Predastore NODE_ID from spinifex.toml if not already set
if [ -z "${SPINIFEX_PREDASTORE_NODE_ID:-}" ]; then
if [ -f "$CONFIG_DIR/spinifex.toml" ]; then
DETECTED_NODE_ID=$(awk -F'= *' '/node_id/{gsub(/ /,"",$2); print $2; exit}' "$CONFIG_DIR/spinifex.toml")
if [ -n "$DETECTED_NODE_ID" ] && [ "$DETECTED_NODE_ID" != "0" ]; then
export SPINIFEX_PREDASTORE_NODE_ID="$DETECTED_NODE_ID"
echo " Auto-detected Predastore NODE_ID=$DETECTED_NODE_ID from spinifex.toml"
fi
fi
fi
export SPINIFEX_PREDASTORE_NODE_ID="${SPINIFEX_PREDASTORE_NODE_ID:-}"
PREDASTORE_CMD="./bin/spx service predastore start"
start_service "predastore" "$PREDASTORE_CMD"
set_oom_score "predastore" "-500"
if is_multinode; then
echo " ⏭️ Skipping Predastore connectivity check (multi-node: needs quorum from peer nodes)"
else
check_service "Predastore" "$SPINIFEX_PREDASTORE_HOST" "$SPINIFEX_PREDASTORE_PORT"
fi
else
echo "2️⃣ Skipping Predastore (not a local service)"
fi
# 3️⃣ Start Viperblock
echo ""
if has_service "viperblock"; then
echo "3️⃣ Starting Viperblock..."
# Determine base directory for Viperblock data, dev uses /mnt/ramdisk if available
if [ -d "/mnt/ramdisk" ] && [ -w "/mnt/ramdisk" ]; then
VB_BASE_DIR="/mnt/ramdisk/"
else
VB_BASE_DIR="$DATA_DIR/viperblock/"
fi
# Check if NBD plugin exists
NBD_PLUGIN_PATH="$MULGA_ROOT/viperblock/lib/nbdkit-viperblock-plugin.so"
if [ ! -f "$NBD_PLUGIN_PATH" ]; then
echo " ⚠️ NBD plugin not found at $NBD_PLUGIN_PATH"
echo " Building Viperblock NBD plugin..."
cd "$MULGA_ROOT/viperblock" && make build
cd "$PROJECT_ROOT"
fi
export SPINIFEX_CONFIG_PATH=$CONFIG_DIR/spinifex.toml
export SPINIFEX_VIPERBLOCK_PLUGIN_PATH=$NBD_PLUGIN_PATH
export SPINIFEX_BASE_DIR=$VB_BASE_DIR
VIPERBLOCK_CMD="./bin/spx service viperblock start"
start_service "viperblock" "$VIPERBLOCK_CMD"
set_oom_score "viperblock" "-500"
else
echo "3️⃣ Skipping Viperblock (not a local service)"
fi
# 4️⃣ Start Spinifex Gateway/Daemon
echo ""
if has_service "daemon"; then
echo "4️⃣. Starting Spinifex Gateway..."
export SPINIFEX_CONFIG_PATH=$CONFIG_DIR/spinifex.toml
export SPINIFEX_BASE_DIR=$DATA_DIR/spinifex/
export SPINIFEX_WAL_DIR=$WAL_DIR
SPINIFEX_CMD="./bin/spx service spinifex start"
start_service "spinifex" "$SPINIFEX_CMD"
set_oom_score "spinifex" "-500"
else
echo "4️⃣. Skipping Spinifex Gateway (not a local service)"
fi
# 5️⃣ Start AWS Gateway
echo ""
if has_service "awsgw"; then
echo "5️⃣. Starting AWS Gateway..."
unset SPINIFEX_NATS_HOST
unset SPINIFEX_PREDASTORE_HOST
export SPINIFEX_BASE_DIR=$DATA_DIR
export SPINIFEX_CONFIG_PATH=$CONFIG_DIR/spinifex.toml
export SPINIFEX_AWSGW_TLS_CERT=$CONFIG_DIR/server.pem
export SPINIFEX_AWSGW_TLS_KEY=$CONFIG_DIR/server.key
AWSGW_CMD="./bin/spx service awsgw start"
start_service "awsgw" "$AWSGW_CMD"
set_oom_score "awsgw" "-500"
else
echo "5️⃣. Skipping AWS Gateway (not a local service)"
fi
# 6️⃣ Start vpcd (VPC daemon)
echo ""
if has_service "vpcd"; then
echo "6️⃣. Starting vpcd (VPC daemon)..."
export SPINIFEX_CONFIG_PATH=$CONFIG_DIR/spinifex.toml
VPCD_CMD="./bin/spx service vpcd start"
start_service "vpcd" "$VPCD_CMD"
set_oom_score "vpcd" "-500"
else
echo "6️⃣. Skipping vpcd (not a local service)"
fi
# 7️⃣ Start Spinifex UI (skip with UI=false or if not a local service)
if [ "${UI:-}" != "false" ] && has_service "ui"; then
echo ""
echo "7️⃣. Starting Spinifex UI..."
SPXUI_CMD="./bin/spx service spinifex-ui start"
start_service "spinifex-ui" "$SPXUI_CMD"
set_oom_score "spinifex-ui" "-500"
else
echo ""
echo "7️⃣. Skipping Spinifex UI"
fi
echo ""
echo "🔗 Service endpoints will be:"
if [ "${UI:-}" != "false" ]; then
echo " - Spinifex UI: https://localhost:3000"
fi
echo " - NATS: nats://localhost:4222"
echo " - Predastore: https://localhost:8443"
echo " - AWS Gateway: https://localhost:9999"
echo ""
echo "📊 Monitor background service logs:"
echo " tail -f $LOGS_DIR/*.log"
echo ""
echo "🧪 Test with AWS CLI (once daemon is running):"
echo " export AWS_PROFILE=spinifex"
echo " aws ec2 describe-instances"
echo ""
# For multi-node clusters, check peer daemon health (best-effort)
if is_multinode; then
echo "Checking cluster peer health..."
# Extract peer daemon hosts from config (host = "ip:port" under [nodes.X.daemon])
peer_hosts=$(awk '
/^\[nodes\./ { node=1; daemon=0 }
node && /^\[.*\.daemon\]/ { daemon=1 }
daemon && /^host/ { gsub(/[" ]/, "", $3); print $3; daemon=0 }
' "$CONFIG_DIR/spinifex.toml")
for peer in $peer_hosts; do
attempts=0
max_attempts=5
while [ $attempts -lt $max_attempts ]; do
if curl -sk --connect-timeout 3 "https://$peer/health" > /dev/null 2>&1; then
echo " $peer: healthy"
break
fi
attempts=$((attempts + 1))
if [ $attempts -lt $max_attempts ]; then
sleep 2
fi
done
if [ $attempts -ge $max_attempts ]; then
echo " $peer: not responding (may still be starting)"
fi
done
echo ""
fi
# This will only be reached if air/daemon exits normally
#echo ""
#echo "🛑 Spinifex development environment stopped"
-57
View File
@@ -1,57 +0,0 @@
#!/bin/sh
# Launch instance then terminate it while still in `pending` state.
set -e
export AWS_PROFILE=spinifex
ARCH=$(uname -m)
case "$ARCH" in
x86_64) IMAGE_NAME="ami-ubuntu-26.04-x86_64" ;;
aarch64|arm64) IMAGE_NAME="ami-ubuntu-26.04-arm64" ;;
*) echo "Unknown arch $ARCH, default x86_64"; IMAGE_NAME="ami-ubuntu-26.04-x86_64" ;;
esac
INSTANCE_TYPE=$(aws ec2 describe-instance-types \
--query "InstanceTypes[?contains(InstanceType, '.micro')].InstanceType | [0]" \
--output text)
[ -z "$INSTANCE_TYPE" ] || [ "$INSTANCE_TYPE" = "None" ] && { echo "no micro type"; exit 1; }
AMI=$(aws ec2 describe-images --query "Images[?Name=='$IMAGE_NAME'].ImageId" --output text)
[ -z "$AMI" ] && { echo "no AMI $IMAGE_NAME"; exit 1; }
SUBNET=$(aws ec2 describe-subnets --query 'Subnets[0].SubnetId' --output text)
SG=$(aws ec2 describe-security-groups --query 'SecurityGroups[0].GroupId' --output text)
[ -z "$SUBNET" ] || [ "$SUBNET" = "None" ] && { echo "no subnet"; exit 1; }
[ -z "$SG" ] || [ "$SG" = "None" ] && { echo "no SG"; exit 1; }
echo "Launch: ami=$AMI type=$INSTANCE_TYPE subnet=$SUBNET sg=$SG"
INSTANCE_ID=$(aws ec2 run-instances \
--image-id "$AMI" \
--instance-type "$INSTANCE_TYPE" \
--key-name spinifex-key \
--security-group-ids "$SG" \
--subnet-id "$SUBNET" \
--count 1 \
--query 'Instances[0].InstanceId' \
--output text)
echo "Launched: $INSTANCE_ID — waiting for QEMU"
# Wait until QEMU process exists (LaunchInstance.StartInstance fired) but
# BEFORE TransitionState moves to running. Poll tight; terminate on first hit.
TIMEOUT=60
DEADLINE=$(( $(date +%s) + TIMEOUT ))
QEMU_PID=""
while [ "$(date +%s)" -lt "$DEADLINE" ]; do
QEMU_PID=$(pgrep -f "qemu.*$INSTANCE_ID" | head -1 || true)
[ -n "$QEMU_PID" ] && break
done
[ -z "$QEMU_PID" ] && { echo "ERR: QEMU never appeared"; exit 1; }
echo "QEMU pid=$QEMU_PID — terminate now"
aws ec2 terminate-instances --instance-ids "$INSTANCE_ID" \
--query 'TerminatingInstances[0].[InstanceId,PreviousState.Name,CurrentState.Name]' \
--output text
-201
View File
@@ -1,201 +0,0 @@
#!/bin/bash
# Stop Spinifex development environment
# This script stops all services started by start-dev.sh
# Usage: ./scripts/stop-dev.sh
# Note: Services are stopped using PID files, so data-dir is not required
set -uo pipefail
# Accept optional data directory argument
DATA_DIR="${1:-$HOME/spinifex}"
# Always use the logs directory for PID files — start-dev.sh writes shell PIDs
# there, and Go services write PIDs to their own data dirs. This ensures
# consistent stop behavior for both single-node and multi-node setups.
PID_DIR="$DATA_DIR/logs"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
LOGS_DIR="$PROJECT_ROOT/data/logs"
# Check if /mnt/ramdisk is mounted
#if mountpoint -q /mnt/ramdisk; then
#echo "🛑 Stopping Spinifex development environment..."
#sudo umount /mnt/ramdisk
#fi
echo "Stopping Spinifex development environment..."
# Parse services from spinifex.toml — defaults to all if not set
CONFIG_DIR="${CONFIG_DIR:-$DATA_DIR/config}"
parse_services() {
local config_file="$CONFIG_DIR/spinifex.toml"
if [ -f "$config_file" ]; then
local svc_line=$(grep -m1 '^services' "$config_file" | sed 's/.*\[//;s/\].*//;s/"//g;s/,/ /g')
if [ -n "$svc_line" ]; then
echo "$svc_line"
return
fi
fi
echo "nats predastore viperblock daemon awsgw vpcd ui"
}
SERVICES=$(parse_services)
has_service() {
local svc="$1"
case "$svc" in
spinifex) svc="daemon" ;;
spinifex-ui) svc="ui" ;;
esac
echo "$SERVICES" | grep -qw "$svc"
}
# Detect multi-node cluster from config
is_multinode() {
local config_file="$CONFIG_DIR/spinifex.toml"
if [ -f "$config_file" ]; then
# Count only top-level node sections [nodes.X], not subsections [nodes.X.Y]
local node_count=$(grep -cE '^\[nodes\.[^.]+\]' "$config_file")
[ "$node_count" -gt 1 ]
else
return 1
fi
}
# For multi-node clusters, delegate to coordinated shutdown via NATS.
# Only when called without arguments (default path). When a data-dir is
# explicitly provided (e.g., E2E per-node cleanup), use per-service stop.
if is_multinode && [ -z "${SPINIFEX_FORCE_LOCAL_STOP:-}" ] && [ -z "${1:-}" ]; then
echo "Multi-node cluster detected. Using coordinated shutdown..."
echo " (Set SPINIFEX_FORCE_LOCAL_STOP=1 to force per-service stop)"
exec $PROJECT_ROOT/bin/spx admin cluster shutdown
fi
# Function to stop a service. Sends SIGTERM and waits for the process to exit
# so resources (ports, DB locks) are fully released before returning.
stop_service() {
local name="$1"
local pidpath="$2"
echo "🛑 Stopping $name..."
local pid
pid=$(cat "$pidpath/$name.pid" 2>/dev/null) || { echo "⚠️ No PID file for $name"; return 1; }
kill -SIGTERM "$pid" 2>/dev/null || { rm -f "$pidpath/$name.pid"; echo "✅ $name already stopped"; echo ""; return 0; }
# Wait for process to fully exit (up to 15s) so ports/locks are released
local checks=0
while kill -0 "$pid" 2>/dev/null; do
sleep 1
checks=$((checks + 1))
if [ "$checks" -ge 15 ]; then
echo " Force killing $name..."
kill -9 "$pid" 2>/dev/null || true
sleep 1
break
fi
done
rm -f "$pidpath/$name.pid"
echo "✅ $name stopped"
echo ""
}
# Stop services in reverse order
echo "Stopping services..."
echo ""
# Stop Spinifex UI first (last to start)
has_service "spinifex-ui" && stop_service "spinifex-ui" "$PID_DIR"
# Stop Spinifex daemon/gateway (it will terminate running instances, unmount nbd devices)
has_service "spinifex" && stop_service "spinifex" "$PID_DIR"
# Wait for all QEMU instances to exit before stopping infrastructure services.
# The daemon sends system_powerdown to VMs during shutdown, but may be killed
# before VMs fully exit. We must wait here so viperblock/predastore aren't
# pulled out from under running VMs.
if pgrep -x qemu-system-x86_64 > /dev/null 2>&1; then
echo "⏳ Waiting for QEMU instances to exit..."
timeout=120
elapsed=0
while pgrep -x qemu-system-x86_64 > /dev/null 2>&1; do
if [ $elapsed -ge $timeout ]; then
echo "⚠️ Timeout waiting for QEMU processes, force killing..."
pkill -9 -x qemu-system-x86_64 2>/dev/null || true
sleep 1
break
fi
sleep 1
((elapsed++))
done
echo "✅ All QEMU instances exited"
fi
# Stop vpcd (VPC daemon)
has_service "vpcd" && stop_service "vpcd" "$PID_DIR"
# Stop AWSGW
has_service "awsgw" && stop_service "awsgw" "$PID_DIR"
# Stop Viperblock
has_service "viperblock" && stop_service "viperblock" "$PID_DIR"
# Stop Predastore
has_service "predastore" && stop_service "predastore" "$PID_DIR"
# Stop NATS
has_service "nats" && stop_service "nats" "$PID_DIR"
# Stop OVN networking (prevents idle CPU burn when spinifex isn't running)
# CRITICAL: Do NOT stop openvswitch-switch if OVS carries WAN traffic.
# This can happen when an OVS bridge has a WAN IP (direct bridge mode) or
# when a veth pair links an OVS bridge to a Linux WAN bridge (veth mode).
# Stopping OVS would destroy the bridge and break WAN connectivity.
# OVS is infrastructure — only stop ovn-controller and ovn-central.
if pidof systemd >/dev/null 2>&1; then
echo "🛑 Stopping OVN networking..."
sudo systemctl stop ovn-controller 2>/dev/null && echo "✅ ovn-controller stopped" || true
if systemctl is-active --quiet ovn-central 2>/dev/null; then
sudo systemctl stop ovn-central 2>/dev/null && echo "✅ ovn-central stopped" || true
fi
# Only stop OVS if tearing it down won't kill WAN connectivity.
# Two cases where OVS is load-bearing for WAN:
# 1. An OVS bridge has a WAN IP directly (direct bridge mode)
# 2. A veth pair links a Linux bridge to an OVS bridge (veth mode) —
# stopping OVS destroys the OVS bridge end, breaking the link
WAN_BRIDGE_HAS_IP=false
VETH_LINKS_WAN=false
for br in $(sudo ovs-vsctl list-br 2>/dev/null); do
if [ "$br" = "br-int" ]; then continue; fi
if ip -4 addr show "$br" 2>/dev/null | grep -q "inet "; then
WAN_BRIDGE_HAS_IP=true
break
fi
done
# Check if a veth pair links an OVS bridge to a Linux bridge with a WAN IP
if ip link show veth-wan-br >/dev/null 2>&1; then
VETH_LINKS_WAN=true
fi
if [ "$WAN_BRIDGE_HAS_IP" = true ]; then
echo "⚠️ Skipping openvswitch-switch stop ($br has WAN IP — stopping would kill connectivity)"
elif [ "$VETH_LINKS_WAN" = true ]; then
echo "⚠️ Skipping openvswitch-switch stop (veth pair links OVS to WAN bridge)"
else
sudo systemctl stop openvswitch-switch 2>/dev/null && echo "✅ openvswitch-switch stopped" || true
fi
fi
echo ""
echo "✅ Spinifex development environment stopped"
# Show any remaining spinifex-related processes
remaining=$(pgrep -af '(bin/spx|spinifex-ui|nats-server|predastore|viperblock|vpcd|qemu-system)' | grep -v "stop-dev.sh" || true)
if [[ -n "$remaining" ]]; then
echo ""
echo "⚠️ Some related processes may still be running:"
echo "$remaining"
fi
+31 -81
View File
@@ -44,13 +44,12 @@ type ConfigSettings struct {
LogDir string
ConfigDir string
// Add more fields as needed
Node string
Az string
Port string
BindIP string
// AdvertiseIP is the off-host dial target rendered into the local node's
// [nodes.X].advertise field. Empty → callers fall back to BindIP.
// AdvertiseIP is the off-host dial target rendered into [nodes.X].advertise.
// Empty → callers fall back to BindIP.
AdvertiseIP string
// Cluster settings
@@ -82,10 +81,8 @@ type ConfigSettings struct {
PoolPrefixLen int // Subnet prefix length (default 24)
PoolDNSServers []string // DNS servers for VM DHCP (auto-detected from host)
// OperatorEmail is the address collected at install time (TUI, SPINIFEX_EMAIL,
// or `spx admin init --email`). Written under [operator] in spinifex.toml so
// reset-dev-env.sh can preserve it across wipes. Empty means no operator
// identity was supplied.
// OperatorEmail is the address collected at install time. Written under [operator]
// in spinifex.toml so it survives wipes. Empty means no identity was supplied.
OperatorEmail string
// Other nodes in the cluster (for config source of truth)
@@ -104,10 +101,15 @@ type ConfigSettings struct {
// Sets gpu_passthrough = true under [nodes.<node>.daemon].
GPUPassthrough bool
// IPSecEnabled toggles cluster-wide OVN native IPsec on intra-AZ Geneve
// (default true). Surfaced under [network] in spinifex.toml so the value
// is visible/auditable; the daemon reads it via cluster config.
// IPSecEnabled toggles cluster-wide OVN native IPsec on intra-AZ Geneve.
// Written under [network] in spinifex.toml; daemon reads it via cluster config.
IPSecEnabled bool
// EncryptionKeyFile is the path to the cluster-wide viperblock at-rest
// encryption key, rendered into [nodes.X.viperblock].encryption_key_file.
// Empty means no key was provisioned and volumes are written cleartext
// (legacy mode); the template omits the field entirely in that case.
EncryptionKeyFile string
}
// PredastoreNodeConfig describes a single Predastore node for multi-node config generation.
@@ -133,22 +135,19 @@ func GenerateConfigFiles(configs []ConfigFile, configSettings ConfigSettings) er
return nil
}
// generateConfigFile creates a configuration file from a template
// GenerateConfigFile creates a configuration file from a template.
func GenerateConfigFile(configPath string, configTemplate string, configSettings ConfigSettings) error {
// Parse the embedded template
tmpl, err := template.New("config").Parse(configTemplate)
if err != nil {
return fmt.Errorf("failed to parse template: %w", err)
}
// Create file with secure permissions
f, err := os.OpenFile(configPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil {
return fmt.Errorf("failed to create config file: %w", err)
}
defer f.Close()
// Execute template
if err := tmpl.Execute(f, configSettings); err != nil {
return fmt.Errorf("failed to execute template: %w", err)
}
@@ -157,7 +156,6 @@ func GenerateConfigFile(configPath string, configTemplate string, configSettings
}
func GenerateCertificatesIfNeeded(configDir string, force bool, bindIP string) (caCertPath string) {
// Certificate paths
caCertPath = filepath.Join(configDir, "ca.pem")
caKeyPath := filepath.Join(configDir, "ca.key")
serverCertPath := filepath.Join(configDir, "server.pem")
@@ -171,7 +169,6 @@ func GenerateCertificatesIfNeeded(configDir string, force bool, bindIP string) (
if needsGeneration {
fmt.Println("\n🔐 Generating Certificate Authority and SSL certificates...")
// Step 1: Generate CA certificate
if err := GenerateCACert(caCertPath, caKeyPath); err != nil {
fmt.Fprintf(os.Stderr, "Error generating CA certificate: %v\n", err)
os.Exit(1)
@@ -180,7 +177,6 @@ func GenerateCertificatesIfNeeded(configDir string, force bool, bindIP string) (
fmt.Printf(" CA Certificate: %s\n", caCertPath)
fmt.Printf(" CA Key: %s\n", caKeyPath)
// Step 2: Generate server certificate signed by CA (with bind IP in SANs)
if err := GenerateSignedCert(serverCertPath, serverKeyPath, caCertPath, caKeyPath, bindIP); err != nil {
fmt.Fprintf(os.Stderr, "Error generating server certificate: %v\n", err)
os.Exit(1)
@@ -211,17 +207,14 @@ func GenerateServerCertOnly(configDir string, bindIP string) error {
serverCertPath := filepath.Join(configDir, "server.pem")
serverKeyPath := filepath.Join(configDir, "server.key")
// Verify CA files exist
if !FileExists(caCertPath) || !FileExists(caKeyPath) {
return fmt.Errorf("CA files not found in %s", configDir)
}
// Generate server cert signed by CA with this node's bind IP
return GenerateSignedCert(serverCertPath, serverKeyPath, caCertPath, caKeyPath, bindIP)
}
func CreateServiceDirectories(spxRoot string) {
// Create additional directories
dirs := []string{
filepath.Join(spxRoot, "images"),
filepath.Join(spxRoot, "amis"),
@@ -238,7 +231,6 @@ func CreateServiceDirectories(spxRoot string) {
fmt.Println("\n📁 Creating directory structure...")
for _, dir := range dirs {
// Check if directory exists
if _, err := os.Stat(dir); os.IsNotExist(err) {
if err := os.MkdirAll(dir, 0750); err != nil {
fmt.Fprintf(os.Stderr, "Warning: Could not create %s: %v\n", dir, err)
@@ -248,16 +240,12 @@ func CreateServiceDirectories(spxRoot string) {
fmt.Printf("✅ Directory structure created in %s\n", spxRoot)
}
// Helper functions
func FileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
// ChownRecursive changes ownership of a path and all its contents to the
// specified username. Used after init to hand production directories back
// to the service user when init runs as root via sudo.
// ChownRecursive changes ownership of path and its contents to username.
// Best-effort: errors are logged but do not halt the operation.
func ChownRecursive(path, username string) {
u, err := user.Lookup(username)
@@ -306,7 +294,6 @@ func ChownRecursive(path, username string) {
// SetServiceOwnership sets per-service ownership on data/config directories
// and shared config files to root:spinifex with correct modes.
// Keep in sync with setup.sh create_directories() and plan doc section 2.
func SetServiceOwnership() {
grp, err := user.LookupGroup("spinifex")
if err != nil {
@@ -327,6 +314,7 @@ func SetServiceOwnership() {
"/var/lib/spinifex/nats": "spinifex-nats",
"/etc/spinifex/predastore": "spinifex-storage",
"/var/lib/spinifex/predastore": "spinifex-storage",
"/etc/spinifex/viperblock": "spinifex-viperblock",
"/var/lib/spinifex/spinifex": "spinifex-daemon",
"/var/lib/spinifex/viperblock": "spinifex-viperblock",
"/var/lib/spinifex/vpcd": "spinifex-vpcd",
@@ -361,11 +349,12 @@ func SetServiceOwnership() {
// bootstrap.json lives in the awsgw data dir (not /etc/spinifex),
// so /etc/spinifex stays at 0750 (no group-write needed).
for path, mode := range map[string]os.FileMode{
"/etc/spinifex/spinifex.toml": 0640,
"/etc/spinifex/master.key": 0640,
"/etc/spinifex/server.pem": 0644,
"/etc/spinifex/server.key": 0640,
"/etc/spinifex/ca.pem": 0644,
"/etc/spinifex/spinifex.toml": 0640,
"/etc/spinifex/master.key": 0640,
"/etc/spinifex/viperblock/encryption.key": 0640,
"/etc/spinifex/server.pem": 0644,
"/etc/spinifex/server.key": 0640,
"/etc/spinifex/ca.pem": 0644,
} {
if _, err := os.Stat(path); err != nil {
continue
@@ -379,12 +368,11 @@ func SetServiceOwnership() {
}
}
// updateAWSINIFile updates or creates an AWS INI file section with given key-value pairs
// UpdateAWSINIFile updates or creates an AWS INI file section with the given key-value pairs.
func UpdateAWSINIFile(path, section string, values map[string]string) error {
var cfg *ini.File
var err error
// Load existing file or create new one
if FileExists(path) {
cfg, err = ini.Load(path)
if err != nil {
@@ -394,25 +382,21 @@ func UpdateAWSINIFile(path, section string, values map[string]string) error {
cfg = ini.Empty()
}
// Get or create section
sec, err := cfg.NewSection(section)
if err != nil {
// Section already exists, get it
// Section already exists, get it.
sec, err = cfg.GetSection(section)
if err != nil {
return fmt.Errorf("failed to get section: %w", err)
}
}
// Set key-value pairs
for key, value := range values {
sec.Key(key).SetValue(value)
}
// Write atomically with 0600. ini.SaveTo uses os.Create (0666 & ~umask,
// typically 0644), which leaves ~/.aws/credentials world-readable. Render
// to a sibling temp file (os.CreateTemp is 0600) and rename into place so
// the secrets are never exposed, even briefly.
// Write atomically: ini.SaveTo uses os.Create (world-readable); render to a
// sibling temp file (0600) and rename to avoid briefly exposing secrets.
dir := filepath.Dir(path)
tmp, err := os.CreateTemp(dir, ".aws-ini-*.tmp")
if err != nil {
@@ -433,8 +417,7 @@ func UpdateAWSINIFile(path, section string, values map[string]string) error {
return nil
}
// generateAWSAccessKey generates an AWS-style access key
// Format: AKIA + 16 random uppercase alphanumeric characters
// GenerateAWSAccessKey generates an AWS-style access key (AKIA + 16 random alphanumeric chars).
func GenerateAWSAccessKey() (string, error) {
const prefix = "AKIA"
const charset = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
@@ -452,8 +435,7 @@ func GenerateAWSAccessKey() (string, error) {
return prefix + string(result), nil
}
// generateAWSSecretKey generates an AWS-style secret key
// 40 character base64-encoded string
// GenerateAWSSecretKey generates a 40-character base64-encoded AWS-style secret key.
func GenerateAWSSecretKey() (string, error) {
bytes := make([]byte, 30) // 30 bytes = 40 chars in base64
if _, err := rand.Read(bytes); err != nil {
@@ -488,15 +470,13 @@ func GenerateNATSToken() (string, error) {
return "nats_" + base64.URLEncoding.EncodeToString(bytes)[:32], nil
}
// GenerateCACert generates a Certificate Authority certificate and key
// GenerateCACert generates a Certificate Authority certificate and key.
func GenerateCACert(caCertPath, caKeyPath string) error {
// Generate CA private key
caPrivateKey, err := rsa.GenerateKey(rand.Reader, 4096)
if err != nil {
return fmt.Errorf("failed to generate CA private key: %w", err)
}
// Create CA certificate template
notBefore := time.Now()
notAfter := notBefore.Add(3650 * 24 * time.Hour) // 10 years
@@ -519,13 +499,11 @@ func GenerateCACert(caCertPath, caKeyPath string) error {
MaxPathLen: 1,
}
// Self-sign the CA certificate
caDerBytes, err := x509.CreateCertificate(rand.Reader, &caTemplate, &caTemplate, &caPrivateKey.PublicKey, caPrivateKey)
if err != nil {
return fmt.Errorf("failed to create CA certificate: %w", err)
}
// Write CA certificate to file
caCertOut, err := os.Create(caCertPath)
if err != nil {
return fmt.Errorf("failed to create CA cert file: %w", err)
@@ -536,7 +514,6 @@ func GenerateCACert(caCertPath, caKeyPath string) error {
return fmt.Errorf("failed to write CA cert: %w", err)
}
// Write CA private key to file
caKeyOut, err := os.OpenFile(caKeyPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil {
return fmt.Errorf("failed to create CA key file: %w", err)
@@ -752,15 +729,13 @@ func GenerateSignedCertWithDNS(certPath, keyPath, caCertPath, caKeyPath string,
return nil
}
// generateSelfSignedCert generates a self-signed SSL certificate (legacy, kept for compatibility)
// GenerateSelfSignedCert generates a self-signed SSL certificate (legacy, kept for compatibility).
func GenerateSelfSignedCert(certPath, keyPath string) error {
// Generate private key
privateKey, err := rsa.GenerateKey(rand.Reader, 4096)
if err != nil {
return fmt.Errorf("failed to generate private key: %w", err)
}
// Create certificate template
notBefore := time.Now()
notAfter := notBefore.Add(3650 * 24 * time.Hour) // 10 years
@@ -784,13 +759,11 @@ func GenerateSelfSignedCert(certPath, keyPath string) error {
IPAddresses: []net.IP{net.ParseIP("127.0.0.1"), net.ParseIP("::1")},
}
// Create certificate
derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &privateKey.PublicKey, privateKey)
if err != nil {
return fmt.Errorf("failed to create certificate: %w", err)
}
// Write certificate to file
certOut, err := os.Create(certPath)
if err != nil {
return fmt.Errorf("failed to create cert file: %w", err)
@@ -801,7 +774,6 @@ func GenerateSelfSignedCert(certPath, keyPath string) error {
return fmt.Errorf("failed to write cert: %w", err)
}
// Write private key to file
keyOut, err := os.OpenFile(keyPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil {
return fmt.Errorf("failed to create key file: %w", err)
@@ -821,11 +793,6 @@ func GenerateSelfSignedCert(certPath, keyPath string) error {
}
// SetupAWSCredentials updates ~/.aws/credentials and ~/.aws/config.
// bindIP is the IP the AWS gateway listens on. If empty or "0.0.0.0", the
// operator's local ~/.aws/config endpoint_url falls back to "localhost" (this
// runs on the same box as the gateway). wanIP is reserved for a future
// --operator-endpoint flag that will let remote operators point their CLI at
// the host's WAN IP; today it is accepted but unused.
// When running under sudo, writes to SUDO_USER's home instead of root's.
func SetupAWSCredentials(accessKey, secretKey, region, certPath, bindIP, wanIP string) error {
_ = wanIP
@@ -834,8 +801,6 @@ func SetupAWSCredentials(accessKey, secretKey, region, certPath, bindIP, wanIP s
return err
}
// When running under sudo, write to the invoking user's home directory
// so the operator can use AWS_PROFILE=spinifex without sudo.
sudoUser := os.Getenv("SUDO_USER")
if os.Getuid() == 0 && sudoUser != "" {
if u, err := user.Lookup(sudoUser); err == nil {
@@ -851,21 +816,8 @@ func SetupAWSCredentials(accessKey, secretKey, region, certPath, bindIP, wanIP s
credPath := filepath.Join(awsDir, "credentials")
configPath := filepath.Join(awsDir, "config")
// Determine profile name
//profileName := "default"
// Use Spinifex as the default profile
profileName := "spinifex"
if FileExists(credPath) {
// Check if default profile already exists
cfg, err := ini.Load(credPath)
if err == nil && cfg.HasSection("default") {
profileName = "spinifex"
}
}
// Update credentials file
if err := UpdateAWSINIFile(credPath, profileName, map[string]string{
"aws_access_key_id": accessKey,
"aws_secret_access_key": secretKey,
@@ -873,7 +825,6 @@ func SetupAWSCredentials(accessKey, secretKey, region, certPath, bindIP, wanIP s
return err
}
// Update config file
configSection := profileName
if profileName != "default" {
configSection = "profile " + profileName
@@ -961,9 +912,8 @@ func ParsePredastoreNodeIDFromConfig(tomlContent string, ip string) int {
return FindNodeIDByIP(cfg.DB, ip)
}
// SetMIGProfile idempotently writes mig_profile = "<profile>" for the given
// node into spinifex.toml, preserving all other content and comments.
// An empty profile clears the setting (writes mig_profile = "").
// SetMIGProfile idempotently writes mig_profile = "<profile>" for the given node
// into spinifex.toml. An empty profile clears the setting.
func SetMIGProfile(tomlPath, node, profile string) error {
raw, err := os.ReadFile(tomlPath)
if err != nil {
+4 -13
View File
@@ -2,13 +2,8 @@ package admin
import "net"
// DialTarget returns a host:port suitable for an in-process client to dial,
// given a configured listen address. Inputs of the form "0.0.0.0:N" or "[::]:N"
// are rewritten to "127.0.0.1:N" or "[::1]:N" — a service listening on the
// wildcard is reachable on loopback, and loopback is the TLS SAN that the
// daemon's own cert always covers. Loopback, specific IPs, and bare hostnames
// pass through unchanged. Malformed input (no host:port) passes through so
// callers still see the original value in error messages.
// DialTarget returns a host:port for in-process dialing. Wildcard listeners
// ("0.0.0.0:N", "[::]:N") are rewritten to loopback; specific IPs pass through.
func DialTarget(listenAddr string) string {
host, port, err := net.SplitHostPort(listenAddr)
if err != nil {
@@ -23,12 +18,8 @@ func DialTarget(listenAddr string) string {
return listenAddr
}
// AdvertiseHost picks the host that off-host clients should dial for a service
// listening on listenAddr. If the listen host is a wildcard (0.0.0.0, ::, or
// empty), the caller-supplied advertiseIP is returned; otherwise the listen
// host itself is returned. Port is intentionally dropped — callers append the
// port appropriate for the protocol (some services listen and advertise on
// different ports).
// AdvertiseHost returns the host off-host clients should dial. Wildcard listeners
// return advertiseIP; specific listen hosts return themselves (port is dropped).
func AdvertiseHost(listenAddr, advertiseIP string) string {
host, _, err := net.SplitHostPort(listenAddr)
if err != nil {
+2 -6
View File
@@ -26,12 +26,8 @@ func ValidateEmail(addr string) error {
return nil
}
// ReadOperatorEmail extracts the [operator].email scalar from a spinifex.toml
// file, or returns "" if the file doesn't exist, can't be read, or has no
// [operator] section. Used by `spx admin init --force` (and reset flows) to
// preserve a previously-captured email across re-inits when --email isn't
// supplied on the new invocation. Minimal text scan — no TOML parser, since
// we only need one scalar from one section.
// ReadOperatorEmail extracts the [operator].email scalar from spinifex.toml,
// returning "" on any error or missing section. Uses a text scan, not a full TOML parser.
func ReadOperatorEmail(tomlPath string) string {
f, err := os.Open(tomlPath)
if err != nil {
+13 -35
View File
@@ -46,10 +46,8 @@ func (d Dependents) Empty() bool {
return len(d.Snapshots) == 0 && len(d.Volumes) == 0 && len(d.AMIs) == 0
}
// RemovePreview captures everything the CLI needs to render the confirmation
// prompt and decide whether to proceed: AMI metadata (or zero values when the
// config is missing/corrupt), backing-storage byte counts, and the dependent
// resource set. PreviewRemoveSystemImage performs no deletions.
// RemovePreview captures AMI metadata, byte counts, and dependents for the
// CLI confirmation prompt. PreviewRemoveSystemImage performs no deletions.
type RemovePreview struct {
ImageID string
Name string
@@ -74,11 +72,8 @@ func SnapPrefix(imageID string) string {
return "snap-" + imageID
}
// PreviewRemoveSystemImage walks the object store and gathers everything the
// caller needs to decide whether to remove an admin-imported AMI. It performs
// no mutations. Returned errors indicate unexpected I/O failures only —
// AMI-not-found, corrupt config, and account-owned AMIs are reflected in the
// preview fields, not as errors.
// PreviewRemoveSystemImage gathers AMI metadata and dependents without mutating
// any state. Not-found, corrupt config, and account-owned AMI are preview fields.
func PreviewRemoveSystemImage(store objectstore.ObjectStore, bucket, imageID string) (*RemovePreview, error) {
if !strings.HasPrefix(imageID, "ami-") {
return nil, errors.New(awserrors.ErrorInvalidAMIIDMalformed)
@@ -125,16 +120,8 @@ func PreviewRemoveSystemImage(store objectstore.ObjectStore, bucket, imageID str
return preview, nil
}
// FindAMIDependents walks the bucket to find every resource that transitively
// depends on the given admin-imported AMI:
//
// 1. snap-*/metadata.json whose VolumeID == imageID → derivedSnaps
// (these are CopyImage-created snapshots that point back at the system AMI)
// 2. vol-*/config.json whose VolumeMetadata.SnapshotID ∈ {snap-ami-<id>} ∪ derivedSnaps
// 3. ami-*/config.json whose AMIMetadata.SnapshotID ∈ derivedSnaps
//
// The walk terminates one hop deep. CopyImage of an account AMI uses its own
// VolumeID, not the system AMI's, so further recursion is not required today.
// FindAMIDependents returns snapshots, volumes, and AMIs that depend on the
// given system AMI. Walk terminates one hop deep.
func FindAMIDependents(store objectstore.ObjectStore, bucket, imageID string) (Dependents, error) {
var deps Dependents
@@ -220,16 +207,9 @@ func FindAMIDependents(store objectstore.ObjectStore, bucket, imageID string) (D
return deps, nil
}
// RemoveSystemImage deletes an admin-imported AMI and its backing storage
// after re-validating the preconditions:
//
// 1. ami-<id>/config.json exists and parses (skipped under --force = salvage)
// 2. AMI is system-owned, not account-owned (--force bypasses)
// 3. No volumes / snapshots / AMIs depend on it (--force bypasses)
//
// Deletion order is intentional: the config.json is deleted FIRST so the AMI
// disappears from DescribeImages immediately, preventing new RunInstances
// from racing the block cleanup.
// RemoveSystemImage deletes an admin-imported AMI after re-validating that it
// is system-owned and has no dependents (bypassed by --force). config.json is
// deleted first so the AMI vanishes from DescribeImages before block cleanup.
func RemoveSystemImage(store objectstore.ObjectStore, bucket string, opts RemoveImageOpts) (*RemoveImageResult, error) {
if !strings.HasPrefix(opts.ImageID, "ami-") {
return nil, errors.New(awserrors.ErrorInvalidAMIIDMalformed)
@@ -338,16 +318,14 @@ func readAMIConfig(store objectstore.ObjectStore, bucket, imageID string) (viper
}
var state viperblock.VBState
if err := json.Unmarshal(body, &state); err != nil {
if err := json.Unmarshal(viperblock.StateBody(body), &state); err != nil {
return viperblock.AMIMetadata{}, fmt.Errorf("%w: %s: %v", handlers_ec2_image.ErrCorruptAMIConfig, key, err)
}
return state.VolumeConfig.AMIMetadata, nil
}
// errCorruptVolumeConfig lets the dependency walk distinguish a vol-*/config.json
// that can't be parsed (recovery is operator-owned, walk continues) from a
// transient transport error (walk must fail closed — silently dropping a
// dependent could lead to deleting blocks underneath a live volume).
// errCorruptVolumeConfig distinguishes an unparse-able config (walk continues)
// from a transport error (walk fails closed to prevent deleting live blocks).
var errCorruptVolumeConfig = errors.New("corrupt volume config")
// readVolumeConfig reads vol-<id>/config.json into VolumeConfig.
@@ -372,7 +350,7 @@ func readVolumeConfig(store objectstore.ObjectStore, bucket, volumeID string) (*
}
var w volumeConfigWrapper
if err := json.Unmarshal(body, &w); err != nil {
if err := json.Unmarshal(viperblock.StateBody(body), &w); err != nil {
return nil, fmt.Errorf("%w: %s: %v", errCorruptVolumeConfig, key, err)
}
return &w.VolumeConfig, nil
+9 -39
View File
@@ -33,33 +33,16 @@ const (
// strongSwan config consumes them as-is.
var oidExtKeyUsageIPSecIKE = asn1.ObjectIdentifier{1, 3, 6, 1, 5, 5, 7, 3, 17}
// IPSecCertPaths returns the canonical paths for the per-node IPsec peer
// credential pair under configDir. Both paths live in
// {configDir}/ipsec/. The CA trust anchor is the existing cluster CA in
// configDir/ca.pem; ovs-monitor-ipsec is pointed at that path directly.
// IPSecCertPaths returns the canonical cert/key paths for the per-node IPsec peer
// credential pair under {configDir}/ipsec/.
func IPSecCertPaths(configDir string) (certPath, keyPath string) {
dir := filepath.Join(configDir, ipsecDirName)
return filepath.Join(dir, ipsecCertFileName), filepath.Join(dir, ipsecKeyFileName)
}
// GenerateIPSecPeerCert issues an IKEv2 peer certificate for this node,
// signed by the existing cluster CA. The cert carries the IPSec-IKE
// extended key usage so strongSwan accepts it as a peer credential.
//
// hostname is the node's primary hostname (used as Common Name + DNS SAN).
// nodeIP is the node's primary cluster IP (added as an IP SAN). Additional
// SANs discovered from the local machine's interfaces are not added here —
// IPSec peer identity should be the explicit cluster identity only, not
// every interface address, to keep peer-identity matching strict.
//
// Cross-cutting invariant: this dnsName SAN must equal the OVS chassis-id
// (Open_vSwitch.external_ids:system-id) on every node, because
// ovs-monitor-ipsec generates strongSwan configs with leftid=@<chassis-id>
// / rightid=@<chassis-id> and charon validates those names against the
// peer cert's dnsName SAN. setup-ovn.sh pins system-id to `hostname -s`
// for exactly this reason — see the comment in setup-ovn.sh near
// `system-id.conf`. Changing the cert identity here without updating
// setup-ovn.sh (or vice versa) breaks IKE with AUTHENTICATION_FAILED.
// GenerateIPSecPeerCert issues an IKEv2 peer certificate for this node signed
// by the existing cluster CA. The cert carries the IPSec-IKE EKU so strongSwan
// accepts it. The dnsName SAN must equal the OVS chassis-id (system-id).
func GenerateIPSecPeerCert(configDir, caCertPath, caKeyPath, hostname, nodeIP string) error {
if hostname == "" {
return fmt.Errorf("ipsec peer cert: hostname required")
@@ -127,14 +110,8 @@ func GenerateIPSecPeerCert(configDir, caCertPath, caKeyPath, hostname, nodeIP st
return fmt.Errorf("ipsec peer cert: write key: %w", err)
}
// Symlink the cluster CA into charon's default cacerts scan dir.
// ovs-monitor-ipsec writes per-conn `cacert=` references, but
// strongSwan 6.0 only loads CAs declared in standalone `ca` sections
// or files placed in /etc/ipsec.d/cacerts at startup. Without this,
// charon authenticates the local side OK but rejects every inbound
// peer cert as `no trusted RSA public key found for '<peer>'` and
// no SAs negotiate. The dir is on charon's default AppArmor allowlist
// so no profile change is needed (unlike /etc/spinifex/).
// strongSwan 6.0 only loads CAs placed in /etc/ipsec.d/cacerts at startup;
// per-conn cacert= references are insufficient. Install the cluster CA there.
if err := installCAIntoCharonTrustStore(caCertPath); err != nil {
return fmt.Errorf("ipsec peer cert: install CA into charon trust store: %w", err)
}
@@ -166,15 +143,8 @@ func installCAIntoCharonTrustStore(caCertPath string) error {
return fmt.Errorf("rename %s -> %s: %w", staging, link, err)
}
// strongSwan scans /etc/ipsec.d/cacerts only at charon startup. If
// openvswitch-ipsec.service started before this install (the common
// case — the unit is enabled at provision time), charon is running
// with zero CAs loaded and every IKE_AUTH ends in `no trusted RSA
// public key found for '<peer>'`. Trigger a stroke rereadcacerts so
// the symlink we just placed is loaded without bouncing charon.
// Silent failure is expected when charon isn't running yet (admin
// init can run before setup-ovn.sh enables the unit); a later charon
// start will scan cacerts naturally.
// Trigger rereadcacerts so charon picks up the symlink without restart.
// Silent failure is expected when charon isn't running yet.
if err := charonRereadCAs(); err != nil {
slog.Debug("ipsec: rereadcacerts skipped", "err", err)
}
+3 -9
View File
@@ -127,12 +127,8 @@ func TestIPSecCertPaths(t *testing.T) {
assert.Equal(t, "/etc/spinifex/ipsec/peer.key", keyPath)
}
// TestInstallCAIntoCharonTrustStore_TriggersRereadCAs locks the fix for the
// race where openvswitch-ipsec started before admin init's CA install: every
// IKE_AUTH failed with `no trusted RSA public key found for '<peer>'` because
// charon had scanned cacerts before the symlink was placed. The trust-store
// installer must trigger `ipsec rereadcacerts` so an already-running charon
// picks up the CA without a restart.
// TestInstallCAIntoCharonTrustStore_TriggersRereadCAs ensures the installer
// calls `ipsec rereadcacerts` so an already-running charon picks up the CA.
func TestInstallCAIntoCharonTrustStore_TriggersRereadCAs(t *testing.T) {
origTrustDir := charonCATrustDir
charonCATrustDir = t.TempDir()
@@ -160,9 +156,7 @@ func TestInstallCAIntoCharonTrustStore_TriggersRereadCAs(t *testing.T) {
}
// TestInstallCAIntoCharonTrustStore_SwallowsRereadError verifies that a failed
// rereadcacerts (e.g. charon not running, ipsec binary missing on a fresh
// install) does not break admin init. The CA symlink must still be in place so
// a later charon start picks it up via the normal cacerts scan.
// rereadcacerts (charon not running) doesn't abort admin init.
func TestInstallCAIntoCharonTrustStore_SwallowsRereadError(t *testing.T) {
origTrustDir := charonCATrustDir
charonCATrustDir = t.TempDir()
+2 -8
View File
@@ -54,7 +54,6 @@ func DetectNetwork() (*DetectedNetwork, error) {
return nil, fmt.Errorf("could not parse default route: %s", defaultRoute)
}
// Get all routes to find interfaces and their subnets
allRoutes, err := exec.Command("ip", "-4", "route", "show", "scope", "link").Output()
if err != nil {
return nil, fmt.Errorf("failed to get routes: %w", err)
@@ -149,7 +148,6 @@ func SuggestPoolRange(wan *DetectedInterface) (start, end string) {
return "", ""
}
// Calculate broadcast address
ip := ipNet.IP.To4()
mask := ipNet.Mask
broadcast := make(net.IP, 4)
@@ -182,13 +180,9 @@ func SuggestPoolRange(wan *DetectedInterface) (start, end string) {
return startIP.String(), endIP.String()
}
// isVirtualInterface returns true for interfaces that shouldn't be
// considered as physical NICs. The defaultRouteDev parameter is the
// interface carrying the default route — it's always considered physical
// even if its name matches a virtual prefix (e.g., br-wan).
// isVirtualInterface returns true for interfaces that shouldn't be considered
// physical NICs. defaultRouteDev is always treated as physical.
func isVirtualInterface(name string, defaultRouteDev string) bool {
// The default route interface is always considered physical — it's the
// WAN uplink, even if it's a bridge (br-wan, br-external, etc.).
if defaultRouteDev != "" && name == defaultRouteDev {
return false
}
+4 -29
View File
@@ -13,27 +13,6 @@ import (
// Callers should map this to AWS's MalformedQueryString error code.
var ErrSliceTooLarge = errors.New("list parameter exceeds maximum entries")
/*
Sample args
args["Action"] = "RunInstance"
args["ImageId"] = "ami-123456789"
args["MinCount"] = "1"
args["MaxCount"] = "1"
args["InstanceType"] = "t2.micro"
args["KeyName"] = "my-key-pair"
args["SecurityGroup.1"] = "sg-12345678"
args["SecurityGroup.2"] = "sg-87654321"
args["TagSpecification.1.ResourceType"] = "instance"
args["TagSpecification.1.Tag.1.Key"] = "Name"
args["TagSpecification.1.Tag.1.Value"] = "MyInstance"
args["TagSpecification.2.ResourceType"] = "volume"
args["TagSpecification.2.Tag.1.Key"] = "Environment"
args["TagSpecification.2.Tag.1.Value"] = "Production"
*/
func QueryParamsToStruct(params map[string]string, out any) error {
v := reflect.ValueOf(out)
@@ -67,10 +46,8 @@ func setStructFields(v reflect.Value, params map[string]string, prefix string) e
// Check for locationName tag (AWS SDK uses this for query parameter names)
locationName := fieldType.Tag.Get("locationName")
// Try the field name, locationName, and title-cased locationName.
// AWS query params use title case (e.g. "ResourceId") but some SDK
// structs use camelCase locationName (e.g. "resourceId" in DeleteTagsInput
// vs "ResourceId" in CreateTagsInput).
// Try field name, locationName, and title-cased locationName.
// AWS query params use title case but some SDK structs use camelCase locationName.
queryKeys := []string{prefix + fieldName}
if locationName != "" && locationName != fieldName {
queryKeys = append(queryKeys, prefix+locationName)
@@ -153,13 +130,12 @@ func setFieldValue(field reflect.Value, value string) error {
elem := field.Type().Elem()
// Special case: []byte
if elem.Kind() == reflect.Uint8 {
// First try base64 (what the AWS API expects for PublicKeyMaterial).
// Try base64 first (AWS API expectation); fall back to raw bytes.
trimmed := strings.TrimSpace(value)
if decoded, err := base64.StdEncoding.DecodeString(trimmed); err == nil {
field.SetBytes(decoded)
return nil
}
// Some clients may send raw text. Fall back to raw bytes.
field.SetBytes([]byte(value))
return nil
}
@@ -195,8 +171,7 @@ func setFieldValue(field reflect.Value, value string) error {
const maxSliceLen = 1024
func setSliceField(field reflect.Value, params map[string]string, prefix string) error {
// Find all indexed items for this slice.
// Supports both EC2-style (Prefix.N) and IAM/ELBv2-style (Prefix.member.N) formats.
// Collect indexed items; supports EC2-style (Prefix.N) and IAM/ELBv2-style (Prefix.member.N).
indices := make(map[int]bool)
useMember := false
for key := range params {
+13 -11
View File
@@ -221,9 +221,7 @@ var (
ErrorInvalidOptionConflict = "InvalidOption.Conflict"
ErrorInvalidPaginationToken = "InvalidPaginationToken"
ErrorInvalidParameter = "InvalidParameter"
// ACM (AWS JSON 1.1). Suffix-less; the JSON error envelope appends
// "Exception" on the wire (InvalidArnException), matching aws-sdk-go acm
// ErrCode* values. Not-found reuses the shared ErrorResourceNotFound.
// ACM error: the JSON envelope appends "Exception" on the wire; not-found reuses ErrorResourceNotFound.
ErrorACMInvalidArn = "InvalidArn"
ErrorInvalidParameterCombination = "InvalidParameterCombination"
ErrorInvalidParameterDependency = "InvalidParameterDependency"
@@ -372,9 +370,7 @@ var (
ErrorResourceCountExceeded = "ResourceCountExceeded"
ErrorResourceCountLimitExceeded = "ResourceCountLimitExceeded"
ErrorResourceLimitExceeded = "ResourceLimitExceeded"
// ErrorResourceNotFound ("ResourceNotFound") is the ACM not-found code. EKS
// must use ErrorEKSResourceNotFound ("ResourceNotFoundException", below) —
// the restjson code the aws eks client expects. Do not cross-wire the two.
// ErrorResourceNotFound is the ACM not-found code. EKS must use ErrorEKSResourceNotFound — do not cross-wire.
ErrorResourceNotFound = "ResourceNotFound"
ErrorEKSResourceInUse = "ResourceInUseException"
ErrorEKSResourceNotFound = "ResourceNotFoundException"
@@ -495,15 +491,21 @@ func ValidErrorCode(code string) string {
return ErrorServerInternal
}
// IsErrorCode reports whether err carries the named AWS error code. Service
// handlers return these codes as plain strings (errors.New(code)), commonly
// wrapped with %w by orchestration callers, so the match is a substring test
// against err.Error() rather than equality. Use it to classify a delete error
// as idempotent (already-gone) or retryable (still-in-use) in teardown paths.
// IsErrorCode reports whether err carries the named AWS error code.
// Uses substring matching because handlers return codes as plain strings, sometimes wrapped with %w.
func IsErrorCode(err error, code string) bool {
return err != nil && strings.Contains(err.Error(), code)
}
// IsNotFound reports whether err carries an AWS "*.NotFound" error code — the
// canonical "resource absent" signal. Destroy orchestration (teardown / GC /
// reaper) uses this to treat an already-gone resource as success, so the API
// handlers stay AWS-faithful (idempotency lives at the call site, not the
// handler). Substring match mirrors IsErrorCode for %w-wrapped errors.
func IsNotFound(err error) bool {
return err != nil && strings.Contains(err.Error(), ".NotFound")
}
var ErrorLookup = map[string]ErrorMessage{
ErrorAccountDisabled: {HTTPCode: 400, Message: "The functionality you have requested has been administratively disabled for this account."},
ErrorActiveVpcPeeringConnectionPerVpcLimitExceeded: {HTTPCode: 400, Message: "You've reached the limit on the number of active VPC peering connections you can have for the specified VPC."},
+40 -6
View File
@@ -1,12 +1,13 @@
package awserrors
import "testing"
import (
"errors"
"fmt"
"testing"
)
// TestErrorLookup_Structure asserts invariants of the ErrorLookup map without
// duplicating every entry. Catches wholesale-deletion accidents, invalid HTTP
// codes, empty messages, and regressions of SDK-surfaced codes — the failure
// modes that actually matter — without locking in a 1:1 mirror that has to be
// updated in lockstep with every new error code.
// TestErrorLookup_Structure asserts ErrorLookup invariants: minimum size, valid HTTP codes, non-empty messages.
// Avoids a 1:1 mirror that would need updating with every new error code.
func TestErrorLookup_Structure(t *testing.T) {
if len(ErrorLookup) < 400 {
t.Fatalf("ErrorLookup unexpectedly small: %d entries", len(ErrorLookup))
@@ -68,3 +69,36 @@ func TestValidErrorCode(t *testing.T) {
})
}
}
// TestIsNotFound covers the destroy-orchestration tolerance helper: it matches
// any AWS canonical InvalidX.NotFound (so teardown converges on already-gone
// resources) and only those — a nil error or a non-NotFound failure is not
// swallowed.
func TestIsNotFound(t *testing.T) {
tests := []struct {
name string
err error
want bool
}{
{"nil", nil, false},
{"vpc not found", errors.New(ErrorInvalidVpcIDNotFound), true},
{"subnet not found", errors.New(ErrorInvalidSubnetIDNotFound), true},
{"igw not found", errors.New(ErrorInvalidInternetGatewayIDNotFound), true},
{"natgw not found", errors.New(ErrorInvalidNatGatewayIDNotFound), true},
{"route table not found", errors.New(ErrorInvalidRouteTableIDNotFound), true},
{"eni not found", errors.New(ErrorInvalidNetworkInterfaceIDNotFound), true},
{"sg not found", errors.New(ErrorInvalidGroupNotFound), true},
{"volume not found", errors.New(ErrorInvalidVolumeNotFound), true},
{"wrapped not found", fmt.Errorf("delete cp vpc: %w", errors.New(ErrorInvalidVpcIDNotFound)), true},
{"dependency violation is not NotFound", errors.New(ErrorDependencyViolation), false},
{"in use is not NotFound", errors.New(ErrorInvalidNetworkInterfaceInUse), false},
{"server internal is not NotFound", errors.New(ErrorServerInternal), false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := IsNotFound(tt.err); got != tt.want {
t.Errorf("IsNotFound(%v) = %v, want %v", tt.err, got, tt.want)
}
})
}
}
+15 -38
View File
@@ -33,12 +33,8 @@ type ExternalPool struct {
DNSServers []string `mapstructure:"dns_servers"` // DNS servers for VM DHCP (auto-detected from host; fallback: 8.8.8.8, 1.1.1.1)
Region string `mapstructure:"region"` // Scope to region (optional — empty means any region)
AZ string `mapstructure:"az"` // Scope to AZ (optional — empty means any AZ in region)
// GwLrpRangeStart/End reserve a sub-range of the LAN for OVN gateway LRP IPs
// in centralized NAT mode (veth). Each VPC consumes one IP from this
// range so its gateway router port can ARP with a sender IP on the LAN
// subnet — link-local 169.254.0.1/30 makes upstream routers reject ARP per
// RFC 826. Must NOT overlap [RangeStart, RangeEnd] or IPAM and vpcd will
// fight over the same IP.
// GwLrpRangeStart/End reserve a sub-range for OVN gateway LRP IPs in centralized NAT mode.
// Must NOT overlap [RangeStart, RangeEnd] — link-local 169.254/16 is rejected by upstream routers.
GwLrpRangeStart string `mapstructure:"gw_lrp_range_start"`
GwLrpRangeEnd string `mapstructure:"gw_lrp_range_end"`
}
@@ -47,15 +43,12 @@ type ExternalPool struct {
type NetworkConfig struct {
ExternalMode string `mapstructure:"external_mode"` // "pool" or "" (disabled)
ExternalPools []ExternalPool `mapstructure:"external_pools"` // One or more IP pools
// IPSecEnabled toggles OVN native IPsec on every node for intra-AZ Geneve
// (AES-256-GCM via strongSwan). Default true. Operators can flip off for
// trusted single-rack lab topologies; production edge stays on.
// IPSecEnabled toggles OVN native IPsec (AES-256-GCM) on every node. Default true; disable only for trusted lab topologies.
IPSecEnabled bool `mapstructure:"ipsec_enabled"`
}
// BootstrapConfig holds the default VPC infrastructure IDs written by admin init.
// vpcd reads this on startup to ensure OVN topology exists for the bootstrap VPC,
// covering the case where admin init ran before services were started.
// vpcd reads this on startup to ensure OVN topology exists for the bootstrap VPC.
type BootstrapConfig struct {
AccountID string `mapstructure:"account_id"`
VpcId string `mapstructure:"vpc_id"`
@@ -70,8 +63,7 @@ type Config struct {
// Node config
Node string `json:"Node" mapstructure:"node"`
Host string `json:"Host" mapstructure:"host"` // Unique hostname or IP of this node
// AdvertiseIP is the off-host dial target for this node. Empty → callers
// fall back to Host for backward compat with pre-siv-8 cluster configs.
// AdvertiseIP is the off-host dial target. Empty falls back to Host for backward compat.
AdvertiseIP string `json:"AdvertiseIP" mapstructure:"advertise"`
Region string `json:"Region" mapstructure:"region"`
AZ string `json:"AZ" mapstructure:"az"`
@@ -102,12 +94,8 @@ type AWSGWConfig struct {
type ViperblockConfig struct {
ShardWAL *bool `json:"ShardWAL" mapstructure:"shardwal"` // Enable sharded WAL (default false when nil)
// EncryptionKeyFile is the path to the shared 32-byte AES-256 master key
// for viperblock at-rest encryption (chunks, WAL records, VBState/snapshot
// metadata). When empty, volumes are written in cleartext (legacy mode).
// When set, every VB constructed on this node — daemons and AWS handlers
// alike — must load it via masterkey.LoadShared so existing volumes can
// still be opened.
// EncryptionKeyFile is the path to the shared 32-byte AES-256 master key for viperblock at-rest encryption.
// Empty means cleartext. When set, all VB instances must load it via masterkey.LoadShared.
EncryptionKeyFile string `json:"EncryptionKeyFile" mapstructure:"encryption_key_file"`
}
@@ -139,11 +127,9 @@ type GPUModelOverride struct {
Manufacturer string `json:"Manufacturer" mapstructure:"manufacturer"`
Name string `json:"Name" mapstructure:"name"`
MemoryMiB int64 `json:"MemoryMiB" mapstructure:"memory_mib"`
// XVGAOff forces x-vga=off for this GPU in QEMU passthrough, overriding the
// default (on for consumer GPUs, off for known datacenter cards).
// XVGAOff forces x-vga=off in QEMU passthrough, overriding the per-GPU default.
XVGAOff bool `json:"XVGAOff" mapstructure:"xvga_off"`
// MIGProfile overrides the daemon-level MIGProfile for this specific GPU.
// Takes the same format as DaemonConfig.MIGProfile (e.g. "1g.10gb").
// MIGProfile overrides the daemon-level MIGProfile for this GPU (same format, e.g. "1g.10gb").
MIGProfile string `json:"MIGProfile" mapstructure:"mig_profile"`
}
@@ -156,9 +142,7 @@ type DaemonConfig struct {
MgmtBridge string `json:"MgmtBridge" mapstructure:"mgmt_bridge"` // Linux bridge for system instance control plane (default "br-mgmt")
GPUPassthrough bool `json:"GPUPassthrough" mapstructure:"gpu_passthrough"` // Enable VFIO GPU passthrough for g5.* instance types
GPUModelOverrides []GPUModelOverride `json:"GPUModelOverrides" mapstructure:"gpu_model_overrides"` // Dev/test GPU mappings not in the production model list
// MIGProfile enables NVIDIA MIG partitioning on all eligible GPUs on this node.
// Set to a profile name such as "1g.10gb" to activate; empty disables MIG.
// Individual GPUs can override this via GPUModelOverrides[].MIGProfile.
// MIGProfile enables NVIDIA MIG on all eligible GPUs (e.g. "1g.10gb"); empty disables. Per-GPU override via GPUModelOverrides[].MIGProfile.
MIGProfile string `json:"MIGProfile" mapstructure:"mig_profile"`
}
@@ -180,8 +164,7 @@ type NATSSub struct {
Subject string `json:"Subject" mapstructure:"subject"`
}
// NodeBaseDir returns the BaseDir for the current node, or "" if the config
// is nil, the node name is unset, or the node is not found in the Nodes map.
// NodeBaseDir returns the BaseDir for the current node, or "" if config is nil, node is unset, or not found.
func (cc *ClusterConfig) NodeBaseDir() string {
if cc == nil || cc.Node == "" {
slog.Warn("NodeBaseDir: no config or node name set, using global PID path")
@@ -201,8 +184,7 @@ func (cc *ClusterConfig) NodeBaseDir() string {
// AllServices is the default service list when Services is empty (backward compat).
var AllServices = []string{"nats", "predastore", "viperblock", "daemon", "awsgw", "vpcd", "ui"}
// HasService reports whether the node runs the named service.
// An empty Services list means all services (backward compat).
// HasService reports whether the node runs the named service (empty list means all services).
func (c Config) HasService(name string) bool {
services := c.Services
if len(services) == 0 {
@@ -225,8 +207,7 @@ func LoadConfig(configPath string) (*ClusterConfig, error) {
viper.SetEnvPrefix("SPINIFEX")
viper.AutomaticEnv()
// network.ipsec_enabled defaults to true (intra-AZ Geneve encrypted via OVN
// native IPsec). Operators must explicitly set false to disable.
// Default ipsec_enabled to true; operators must explicitly set false to disable.
viper.SetDefault("network.ipsec_enabled", true)
// Try to load config file if it exists
@@ -251,9 +232,7 @@ func LoadConfig(configPath string) (*ClusterConfig, error) {
return nil, fmt.Errorf("error unmarshaling config: %w", err)
}
// Normalize the local node's bind address: 0.0.0.0 means "listen on all
// interfaces" but is not a valid connect address. Only rewrite for the
// local node — remote nodes use real IPs that must not be changed.
// Rewrite 0.0.0.0 in Predastore.Host to 127.0.0.1 for the local node only (not a valid connect address).
if local, ok := config.Nodes[config.Node]; ok {
if strings.HasPrefix(local.Predastore.Host, "0.0.0.0") {
local.Predastore.Host = strings.Replace(local.Predastore.Host, "0.0.0.0", "127.0.0.1", 1)
@@ -268,9 +247,7 @@ func LoadConfig(configPath string) (*ClusterConfig, error) {
return &config, nil
}
// validateClusterConfig rejects legacy upstream-DHCP config and validates
// external pool ranges. The upstream-DHCP-client model was replaced by static
// WAN-pool allocation; legacy keys must be removed from the operator's TOML.
// validateClusterConfig rejects legacy DHCP config keys and validates external pool ranges.
func validateClusterConfig(cc *ClusterConfig) error {
if viper.IsSet("network.external_dhcp") {
return fmt.Errorf("config: [network] external_dhcp is no longer supported; remove the key (static WAN-pool allocation only)")
+229 -377
View File
File diff suppressed because it is too large Load Diff

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