Compare commits

..

218 Commits

Author SHA1 Message Date
Julian Sommer caa1658663 release: update go.mod for v1.9.0 2026-06-23 16:22:08 +10:00
Bryn Mailer 15dede64ad feat: predastore compaction E2E (#407)
* feat: add predastore compaction E2E

Inject a short compaction interval into test-cluster predastore config via a
new --predastore-compaction-interval flag on admin init/join, gated so
production output stays byte-identical when unset. Add a harness helper that
drives write-then-delete churn against predastore S3, brackets it with
du-over-SSH usage, and asserts end-of-churn usage settles below 70% of the
churn peak; wire it into the multinode suite as TestPredastoreCompaction.

* fix: compaction E2E gate writes churn to its own bucket

The reclaim gate signed PutObject against the config-defined system
'predastore' bucket, which belongs to account 000000000000 with only a
static [[auth]] grant. The harness admin identity is a different IAM
account, so predastore returned 403 AccessDenied cross-account.

Create a per-run bucket the admin identity owns (AdministratorAccess)
and churn into it. Objects still erasure-code into distributed/nodes/
node-* so the du-based reclaim measurement is unchanged. The gateway
(:9999) does not proxy S3, so the client stays predastore-direct.

* test: run predastore compaction gate before destructive node tests

The compaction reclaim gate was declared last in the multinode suite, so it
measured shard-store usage after NodeFailure/NodeRecovery had torn down and
restarted node2. NodeRecovery only gates on NATS/gateway/EC2 readiness, not
predastore, so node2's shard store was still settling and the du baseline came
back empty. Move the test before the destructive node tests so it runs against
a pristine, fully-converged cluster primed by the volume tests.

* fix: race-free SSH output capture + empty-tolerant du probe

The compaction reclaim gate crashed with ParseInt("") when node1's du
probe returned empty combined output. Harden the probe and the SSH layer:

- ssh.Run used one bytes.Buffer for both session.Stdout and session.Stderr,
  which x/crypto/ssh copies in two concurrent goroutines — a data race on a
  non-concurrent-safe type. Switch to session.CombinedOutput, which guards the
  combined stream with a mutex-wrapped singleWriter.
- shardStoreUsageBytes now terminates the remote command with a sentinel echo.
  A missing sentinel is reported as output lost in SSH retrieval; a present
  sentinel with no number is treated as a 0 measurement (anomalous, logged)
  instead of crashing the gate.

The sentinel lets the next run distinguish a transport-layer drop from a
genuinely empty remote measurement.

* test(e2e): replace predastore du-reclaim gate with object lifecycle contract

The multinode compaction gate measured shard-store disk usage over SSH (du)
and asserted reclaim after churn. It could never pass: segments only seal at
the 4 GiB roll threshold, so test-scale churn produces no compactable segment,
and the du probe read 0 bytes before and after. Reclaim correctness is already
covered deterministically by predastore's store-level unit tests.

Delete AssertPredastoreCompaction and its du/churn/poll/sentinel machinery,
the harness unit tests that exercised them, and the multinode TestPredastore
Compaction entry. Add a single-node TestPredastoreObjectLifecycle that checks
the user-visible contract: an object is readable after PutObject, gone after
DeleteObject (GET returns NoSuchKey and ListObjectsV2 is empty), and the store
keeps serving fresh writes. newPredastoreS3 is retained, refactored to take a
host so it works outside the multinode cluster type.

* test(e2e): drop orphaned multinode TestPredastoreCompaction

Complete the du-reclaim -> object-lifecycle refactor: remove the
multinode TestPredastoreCompaction caller (and its now-unused context
and harness imports) that still referenced the deleted
harness.AssertPredastoreCompaction, which broke e2e-build. Wire the
single-node TestPredastoreObjectLifecycle entry that replaces it.
2026-06-23 16:19:10 +10:00
Josh Siviour 5025a37006 feat: Containers — internal-ECR pulls, DNS-free registry, EKS hardening + docs (#409)
* docs: add Containers section with EKS and ECR service pages

New docs/containers/{eks,ecr} pages covering each service via AWS CLI, the Spinifex console, and Terraform, plus the requisite VPC/IGW/NAT/SG/IAM setup. EKS page embeds console screenshots. Cross-links the EKS Terraform workbooks.

* docs(eks): rework Terraform workbooks into a capability ladder

Rework the three EKS workbooks into a coherent ladder and publish them to
the Containers section. All tiers are single- or three-node capable via
node_desired_size and serve a shared Spinifex-themed demo app from ECR,
replacing nginxdemos/hello.

- demo-app: new std-lib Go web app (themed page reporting pod/node/cluster/
  region + hit counter; optional /data persistence), distroless image.
- eks-quickstart: node_desired_size var, ECR repo + themed app over NodePort.
- eks-https-ingress (was eks-addons-access): HTTPS via the aws-load-balancer-
  controller addon + ACM, public/private VPC with NAT, managed-ingress=false.
- eks-gitops-argocd (was eks-https-ingress): Argo CD addon syncs the app from
  a git repo, aws-ebs-csi-driver PVC (Viperblock-backed) for state, access
  entry RBAC, HTTPS Ingress carried over.

Pending live e2e verification (image build/push, apply, ALB + PVC bind).

* fix(ecr): implement ListTagsForResource (empty tags) for client parity

The AWS provider calls ecr:ListTagsForResource on every repository read, which
returned 501 NotImplementedException and broke 'tofu apply' of any workbook that
creates an aws_ecr_repository. Implement it to return an empty tag set (resource
tagging stays a deferred feature) so reads succeed. Drop the tags block from the
three EKS workbooks' ECR repos so the provider doesn't call the still-stubbed
TagResource on create.

* feat(eks): pull from internal ECR via kubelet credential provider

Worker nodes could not pull custom images from the internal Spinifex ECR
registry: the eks-node had no DNS for the registry host, no containerd CA
trust, no port rewrite to :9999, and no pull credentials. Only public
images worked.

Add cmd/ecr-credential-provider, a kubelet exec credential provider that
fetches the node IAM-role credentials from IMDS, calls GetAuthorizationToken
against the gateway ECR endpoint, and returns the minted AWS:<jwt> as Basic
auth for the registry host. Thread the gateway URL, account, region, and
internal suffix into the agent userdata, which now writes the gateway CA,
a registries.yaml (CA trust + :9999 endpoint), the kubelet
credential-provider config and config.yaml.d drop-in, and an /etc/hosts
entry mapping the registry host to the gateway IP. Ship the provider binary
in the eks-node AMI.

Also remove a stray paragraph from the admin-cli README.

* fix: dial EKS ECR registry mirror by gateway IP

cloud-init manage_etc_hosts rewrites /etc/hosts from the alpine template on
every boot, wiping the registry-host mapping appended via bootcmd, so worker
nodes could not resolve the internal ECR host and pulls failed with no such
host. Point the k3s registries.yaml mirror endpoint and tls config at the
gateway IP instead (the gateway cert carries an IP SAN), keeping the pod image
ref port-less and dropping the DNS leg until northstar lands. Fall back to the
hostname endpoint when the gateway is not an IP.

* fix: attach node-role instance profile to EKS workers for ECR

EKS workers launched with no IAM instance profile, so IMDS served no role and
the ECR credential provider could not mint a registry token, leaving internal
ECR pulls failing with 401. Real EKS implicitly creates an instance profile for
the node role; do the same here. Wire a KV-backed IAM service into the EKS deps
and, at worker launch, find-or-create an instance profile named for the node
role, attach the role, and pass it to RunInstances so IMDS exposes the role to
the credential provider.

* feat: use mulga logo in EKS demo app

* feat: add ACM certificate tag support (List/Add/RemoveTags)

Implements ListTagsForCertificate, AddTagsToCertificate and RemoveTagsFromCertificate across the store, service, NATS, daemon and gateway layers. Import now persists tags. Fixes terraform aws_acm_certificate InvalidActionException on ListTagsForCertificate.

* test: cover ACM tag gateway and NATS wrapper paths

* feat: support EKS nodegroup tags (Tag/Untag/ListTags)

Extends EKS tag ops to back nodegroup ARNs alongside cluster ARNs. Nodegroup create-time tags persist on the record and echo via DescribeNodegroup, giving the terraform-aws provider a clean default_tags round-trip. Fixes aws_eks_node_group TagResource 501 NotImplementedException.

* fix: provision EKS ALB ingress via node credentials

The aws-load-balancer-controller addon could not provision an ALB. Three fixes:

- TLS: the controller's per-service ec2/elbv2/acm SDK clients install their own
  BaseEndpoint resolver and build an HTTP client that bypasses AWS_CA_BUNDLE,
  falling back to the Go system trust pool and rejecting the gateway cert as
  'unknown authority'. Add SSL_CERT_FILE so the gateway CA joins the system pool.
- Credentials: an empty AWS_ROLE_ARN beside the web-identity token file
  half-arms the SDK web-identity provider, which then signs anonymously
  (MissingAuthenticationToken). Inject the IRSA env only when a service-account
  role ARN is staged; otherwise omit it so the default chain falls back to the
  node instance-profile credentials served over IMDS.
- Authorization: the node role lacked the ec2/elbv2 actions the controller
  needs. The eks-https-ingress workbook now attaches an AWSLoadBalancerController
  managed policy to the node role (CreatePolicy + AttachRolePolicy).

* fix: add force_delete to EKS workbook demo ECR repos

tofu destroy of an EKS workbook left the demo ECR repository behind: it
still holds the pushed demo image, so DeleteRepository (force=false)
returns RepositoryNotEmptyException. Add force_delete = true to each
workbook's aws_ecr_repository.demo so teardown removes the repo with its
images.

* fix: force-delete EKS control-plane ENIs on teardown

TerminateK3sServerVM deleted the CP ENI with a plain force=false
DeleteNetworkInterface. eniIsLiveAttachment reports InUse whenever the
record's Status/InstanceId/AttachmentId are set, so when the K3s VM is
gone but the ENI record's attachment fields were never cleared the delete
returned InvalidNetworkInterface.InUse permanently and EKSDeletingReaper
re-drove the same non-idempotent call every 90s, wedging the cluster in
DELETING.

Teardown owns its CP ENIs and has already terminated the VM, so the
attachment is authoritatively dead. Replace the InUse-retry budget loop
with detach-then-delete (detachAndDeleteServerENI): DetachENI clears the
stale attachment fields, then DeleteNetworkInterface passes the in-use
guard. Both calls tolerate an already-gone ENI (NotFound) so a race with
the async instance-terminate cascade resolves to idempotent success.
purgeClusterInfra's private-endpoint ENI path reuses the same helper.

* fix: detach+delete worker ENIs on EKS nodegroup teardown

A terminated nodegroup worker could leave its primary ENI in
spinifex-vpc-enis as in-use/attached, and a dangling ENI throws
DependencyViolation on subnet/VPC delete, blocking tofu destroy. Two
gaps:

TerminateWorkerInstances did a local-only vmMgr.Get and silently skipped
any worker owned by another node, stranding both the VM and its ENI on
multi-node clusters. Route the terminate per-instance via ec2.cmd.<id>
(mirroring the gateway TerminateInstances path) so the owning daemon runs
the full teardown — detach + force-delete the primary ENI and clear its
record. No responder / NotFound payload is treated as already-gone so a
retried DeleteNodegroup stays idempotent.

deleteInstanceENI (stopped-instance terminate) issued a force=false
DeleteNetworkInterface with no prior detach: a stale in-use attachment
made it fail InvalidNetworkInterface.InUse, leaving the ENI record behind
after the instance was already written to the terminated bucket. Detach
first, then delete, tolerating NotFound.

* fix: heal volume State drift on detach/terminate teardown

A detach/terminate could leave a volume's State empty instead of
'available'. DeleteVolume gated on State=='available' exactly, so it
returned VolumeInUse for an unattached volume nothing was using, leaving
it undeletable and blocking stack teardown. DescribeVolumes cosmetically
rendered an empty State as 'available', masking the drift.

- DeleteVolume now treats an empty State with no attachment as deletable
  (an attached volume still returns VolumeInUse).
- DescribeVolumes derives an empty State from the attachment (attached ->
  in-use, unattached -> available) instead of blindly rendering
  available, so an empty-but-attached volume is no longer hidden.
- UpdateVolumeState normalizes an unattached volume with an empty state to
  available, so a detach writeback that omits the state cannot strand the
  volume in drift.

* fix: break all SG cross-references on EKS teardown

DeleteClusterSGs broke the cp<->nodegroup cycle too narrowly. It revoked
only ingress (IpPermissions), never egress (IpPermissionsEgress), so a
cross-reference via an egress rule kept the SG pinned on
DependencyViolation. It also resolved only the two named cluster SGs, so
any OTHER SG in the VPC referencing them — the default SG, the LBC/ALB SG,
the private-endpoint SG, user/TF SGs — was never revoked and pinned the
referenced SG on delete, leaving the VPC stuck.

Enumerate every non-default SG in the VPC and revoke BOTH ingress and
egress on each before deleting, breaking any cycle order-independently,
then delete only the cluster-owned SGs (referrer SGs are deleted by their
own teardown). The default SG is left untouched. Since DeleteClusterSGs
runs against both the customer VPC and the managed control-plane VPC, the
broadened sweep covers both teardown paths.

* feat: support TargetGroup ProtocolVersion in elbv2

CreateTargetGroup now stores ProtocolVersion (defaulting HTTP/HTTPS target
groups to HTTP1) and DescribeTargetGroups/Create output return it. Without
this the aws-load-balancer-controller sees a perpetual immutable-field
mismatch (desired HTTP1 vs empty) and recreates the target group every
reconcile, looping on DuplicateTargetGroupName once a shared ALB IngressGroup
reconciles more than one target group. NLB protocols (TCP/UDP/TLS) keep it
omitted to match AWS.

* fix: omit IRSA env from ebs-csi addon when no role staged

The ebs-csi controller manifest hardcoded AWS_ROLE_ARN={{SERVICE_ACCOUNT_ROLE_ARN}}
plus the web-identity token volume. With no service-account role staged the
placeholder rendered empty, half-arming the SDK web-identity provider, which
panics 'role ARN is not set' and CrashLoops the controller, leaving PVCs
Pending. Convert to the conditional {{IRSA_ENV}}/{{IRSA_VOLUME_MOUNT}}/{{IRSA_VOLUME}}
markers like the LBC bundle so the IRSA env and token volume are omitted when
no role is staged, falling back to node instance-profile credentials over IMDS.
The EBS-specific AWS_EC2_ENDPOINT/AWS_ENDPOINT_URL_EC2 stay in both modes.
Requires an eks-node AMI rebake to take effect.

* docs: route gitops-argocd workbook through a shared ALB and expose ArgoCD UI

Fold the demo app and ArgoCD ingresses onto one ALB via an LBC IngressGroup
with host routing (app./argocd. on a wildcard cert) instead of one ALB per
ingress, add a NodePort + HTTPS Ingress for the ArgoCD UI, and document the
access steps. Add the node-role LBC and EBS-CSI IAM policies the controllers
need (Spinifex wires controller creds at the node level, not IRSA) and an
ignore_changes block for the cluster's access_config and
vpc_config security_group_ids, which Spinifex DescribeCluster does not echo
back, so the provider stops trying to replace the cluster on every plan.

* fix: stop addon-sync substituting IRSA markers named in comments

The ebs-csi controller manifest's header comment spelled out the literal
{{IRSA_ENV}} / {{IRSA_VOLUME_MOUNT}} / {{IRSA_VOLUME}} marker tokens. addon-sync
matched each marker by substring, so the comment lines were replaced with the
env/mount blocks, injecting a bare YAML sequence between two documents. K3s'
auto-deploy then rejected the whole file (ParseManifestFailed: cannot unmarshal
array into unstructured), so the ebs-csi Addon CR never applied and the EKS
addon hung in CREATING. Reword the comment to not contain the literal tokens,
and anchor the marker substitution to lines that are exactly the marker
(whitespace aside) so a future comment mentioning a marker can't retrigger it.

* fix: disable K3s local-storage on EKS clusters in parity mode

In AWS LB Controller parity mode (BuiltinIngress=false) the K3s server
disabled traefik and servicelb but left local-storage enabled. Its local-path
provisioner ships a StorageClass marked default, which races the EBS CSI
ebs-gp3 default StorageClass — two defaults, so dynamic PVCs bind
nondeterministically. EKS has no local-path, so disable local-storage too and
let ebs-gp3 be the sole default. The built-in-ingress path is unchanged.

* fix: re-encrypt HTTPS target groups in haproxy ALB backends

An ALB target group with backend-protocol HTTPS (e.g. argocd-server, which
serves TLS on its own port) rendered a plaintext haproxy server line. HAProxy's
own check then spoke HTTP to a TLS port, failed, and marked the server DOWN,
returning 503 — even though the lb-agent's separate HTTPS health probe reported
the target healthy, so describe-target-health masked the fault.

Emit check-ssl ssl verify none on the server line for HTTPS target groups so
both the check and proxied traffic re-encrypt to the backend.

* fix: reconnect wedged QMP client on next command

A timed-out QMP decode under host contention left the cached client's
shared json.Decoder mid-message, wedging every later command and breaking
EBS AttachVolume/DetachVolume for the VM's lifetime. Mark the client Dead
on encode/decode error and redial the retained socket path (re-running the
qmp_capabilities handshake in place) on the next send.

* fix: round-trip security-group rule descriptions

SG ingress/egress rules dropped the per-rule Description on store and
describe. The AWS Load Balancer Controller tags the worker-node SG rules it
manages with a UserIdGroupPair description and identifies them by that value
to revoke them on teardown. Without the round-trip the controller cannot
match its rules, never revokes them, and the backend security group stays
referenced — DeleteSecurityGroup returns DependencyViolation forever,
hanging the ingress finalizer and tofu destroy with orphaned security groups.

Persist Description on SGRule (excluded from sgRuleKey so it is metadata, not
identity) and render it back through describe.

* fix: reclaim customer-VPC nodegroup SGs on managed-CP-VPC teardown

In the managed control-plane VPC topology, launchNodegroupInfra creates the
cluster control-plane and nodegroup security groups in the customer VPC for
worker<->control-plane networking, but purgeClusterInfra only reclaimed the
security groups in the managed CP VPC. The customer-VPC SGs orphaned,
cross-referenced each other, and pinned the customer VPC with
DependencyViolation, hanging tofu destroy of the VPC. Reclaim them in the
managed-CP-VPC branch too, matching the legacy customer-VPC branch.

* fix: reclaim ALB EIP when system VM already gone on terminate

TerminateSystemInstance only released an internet-facing ALB's Elastic IP on
the local path, where the VM is still present in the manager. When the VM was
already gone (health-reaped, crashed, or raced by ENI teardown), it took the
remote/no-owner path and returned idempotent success without reconciling the
EIP, leaking the wan-pool address still associated to a deleted ENI.

Add EIPServiceImpl.ReleaseAddressByInstanceID, an idempotent account-agnostic
scan that disassociates then releases any EIP recorded against an instance, and
call it as a backstop in TerminateSystemInstance for both branches.

* fix: cascade-prune demo PVC via Argo CD finalizer in eks-gitops workbook

The demo Application carried no resources finalizer, so tofu destroy removed
the Application CR without pruning the synced PVC. The PVC survived, the cluster
was destroyed, and the Delete-reclaim PV's EBS volume orphaned. Add the
resources-finalizer.argocd.argoproj.io finalizer so deleting the Application
cascades to the PVC and ebs-csi reaps the volume before cluster teardown.

* feat: raw-IP access + documented Argo CD creds in eks-gitops workbook

The shared ALB host-routed everything, so the raw ALB IP 404'd in a browser
without an /etc/hosts entry. Make the app the hostless catch-all on :443 and add
a hostless Argo CD listener on :8443, so https://<alb-ip>/ serves the app and
https://<alb-ip>:8443/ serves the UI with no DNS. Surface the admin-password
command as a tofu output and document the credential + IP URLs in the README.

* fix: reclaim ALB EIP on the owning-node terminate path

The EIP backstop lived only in TerminateSystemInstance, but the ALB
delete path goes serveSystemTerminateInstance -> terminateSystemInstanceLocal
directly, bypassing it, and releaseSystemInstanceEIP relied on the VM's
cached allocation which is often empty. Extract reclaimSystemInstanceEIP
(authoritative KV reconcile via ReleaseAddressByInstanceID) and call it
from terminateSystemInstanceLocal as well as the no-owner backstop, so an
internet-facing ALB's EIP is released on every terminate route.

* chore(eks): drop coredns from the managed addon catalog

Cluster DNS ships as the baked node-local coredns-mulga DaemonSet, not as
a managed addon — there is no bundled coredns manifest, so CreateAddon
coredns would stage but fail VM-side render. Remove the phantom catalog
entry so DescribeAddonVersions and CreateAddon no longer advertise it;
repoint the addon tests that used coredns as their sample to argocd.

* feat(ui): group EKS and ECR under a Containers nav section

Fold the separate EKS and ECR sidebar groups into one Containers group
with EKS (clusters) and ECR (repositories) entries.

* fix(ui): satisfy strict null checks in ECR component tests

Index mock.calls via optional chaining and narrow the getAllByRole result
before clicking, clearing the TS2532/TS2345 errors that failed pnpm fix.

* refactor(eks): hide internal fixture addons from the public catalog

Add an AddonSpec.Hidden flag and mark spinifex-noop hidden: it stays
creatable (lookupAddon finds it, so the delivery-transport e2e still drives
it) but no longer surfaces in the unfiltered DescribeAddonVersions listing.

* refactor(eks): remove K3s built-in ingress; LBC-only

Drop the BuiltinIngress option and its managed-ingress tag opt-in. traefik,
servicelb, and local-storage are now always disabled on EKS nodes (AWS
parity — Service type=LoadBalancer and Ingress are the Load Balancer
Controller's job). Removes the EKS_DEFER_TRAEFIK userdata flag, the k3s.initd
traefik .skip staging, and the maybe_enable_traefik deferred-enable loop in
the state reporter.

* fix(ui): clear lint debt in VPC-wizard mutations and CIDR validation

Parallelize the independent subnet-create and route-table-association loops
in the create-VPC mutation with Promise.all (each task records its resource
in the rollback list before any further call), and switch the CIDR-validation
regex to named capture groups. Clears the no-await-in-loop and
prefer-named-capture-group lint errors.

* fix: carry gateway port in ECR registry endpoints; rename mulga.internal -> spinifex.internal

The ECR registry is served on the gateway port (:9999), but proxyEndpoint and
repositoryUri were emitted port-less, so docker login/tag/push dialed :443 and
failed. Derive the port from the awsgw listen host and append it to the registry
host (omitted for 443). Also rename the default internal DNS suffix.

* docs: drop verbose ECR :9999 host note

* docs: fix literal-asterisk emphasis (single-* not rendered)

* docs: brace-wrap ${REGISTRY}:latest (zsh :l modifier ate the tag); document Docker CA trust

* feat: default ECR registry URIs to the gateway host (no-DNS interim)

The registry authenticates by token, not hostname, so the per-account
<acct>.dkr.ecr name is cosmetic. Advertise the gateway's reachable bind host:port
(already a cert SAN) in repositoryUri/proxyEndpoint so docker login/tag/push need
no DNS; the parity host still works server-side where DNS exists.

* fix: serve ECR registry on a DNS-free gateway host end to end

Advertise the gateway's AdvertiseIP as the ECR registry host when the AWSGW
bind is unspecified (0.0.0.0/::), so repository_url resolves to a reachable
host:9999 carried in the server cert SANs rather than the unresolvable
per-account parity name. Docker push then dials it directly with no DNS.

Mirror the same host on EKS workers: the registries.yaml mirror keys and the
credential provider matchImages now cover the port-less parity name, the
port-ful parity name, and the gateway IP:9999, all redirecting to the dialed
gateway endpoint. A port-ful pull from the API-returned repository_url matches
the mirror instead of bypassing it into a failed DNS lookup.

Net: docker push host, pod image host, and the API's repository_url are the
same value, with no DNS or /etc/hosts.

* docs: resolve eks-https Ingress ALB DNS name to its public IP (no DNS)

* docs: resolve eks-gitops Ingress ALB DNS name to its public IP (no DNS)

* docs: remind to run aws eks update-kubeconfig before kubectl in EKS workbooks
2026-06-23 15:29:14 +10:00
Julian Sommer 35eaca5dc4 fix: add detailed logging to e2e 2026-06-23 09:59:54 +10:00
Julian Sommer 413f9efa18 chore: use typescript native preview 2026-06-23 09:18:59 +10:00
Julian Sommer 3452746f47 fix: lint rules 2026-06-23 09:17:35 +10:00
dependabot[bot] c13a88537d chore: bump the npm-dependencies group (#406) 2026-06-23 09:12:38 +10:00
dependabot[bot] 1550f5c488 chore: bump github.com/pelletier/go-toml/v2 in the go-dependencies group (#404) 2026-06-23 09:11:20 +10:00
dependabot[bot] 79e881f1c8 chore: bump actions/checkout from 6 to 7 in the github-actions group (#405) 2026-06-23 09:11:02 +10:00
Josh Siviour bb2ea2fd2d feat(ecr): OperationNotSupportedException for image-scanning actions (#403)
* feat(ecr): return OperationNotSupportedException for image-scanning actions

The ECR image-scan control-plane actions resolved to the generic
NotImplemented 501 stub, indistinguishable from unbuilt features and
surfaced as a server fault. mulga runs no vulnerability scanner, so the
scan surface now returns OperationNotSupportedException (400):

- awserrors: add OperationNotSupportedException (400) ECR code.
- ecrapi: ScanningNotSupported handler; repoint the four image-scan
  actions and register the three registry-scanning actions at it.
- spinifexui: Scan tab on the repository detail page renders a static
  'scanning not supported' panel rather than a request that errors.

* refactor(config): default internal DNS suffix to spinifex.internal

Rename the AWS-parity internal DNS suffix from mulga.internal to
spinifex.internal across the config default, cert SAN builder, ECR
endpoint/audience construction, and their tests. Single runtime knob is
config.DefaultAWSInternalSuffix; no DNS backend exists yet, so this is a
naming change with no functional impact.
2026-06-22 09:31:01 +10:00
Josh Siviour 6165b9f517 feat(ecr): rotate JWT signing keys with previous-key retention (#401)
The ECR auth bridge minted ES256 tokens from a key that was created once and
never rotated. Add a background rotator (ecr-v1 Q3): mint a fresh key every 30
days, retain the previous keys for 24h so in-flight 12h tokens stay verifiable,
then prune. The active key is now the newest by KV creation time (not the
lexically-greatest kid) so a minted key takes over and all nodes converge. The
Issuer and Verifier became hot-swappable so the rotator updates the live
gateway as keys roll.
2026-06-22 07:59:40 +10:00
tomnewton-mulga 0ca350ef27 fix: MIG doc touch up 2026-06-19 16:30:47 +10:00
Julian Sommer 51706938e8 chore: use ubuntu-26.04 runners 2026-06-19 16:04:09 +10:00
tomnewton-mulga 879cfde07a fix: more docs tweaks 2026-06-19 15:06:31 +10:00
Josh Siviour 9d662f9889 feat: EKS EBS CSI driver (Viperblock) — egress, attach, node discovery, e2e (#402)
* feat: bake aws-ebs-csi-driver addon + mdev by-id bridge

Add the aws-ebs-csi-driver entry (v1.40.1) to the eks addon catalog and
ship its baked manifests (RBAC, IRSA-wired controller, node DaemonSet,
default gp3 StorageClass) under the eks-node image.

The node plugin locates EBS volumes via /dev/disk/by-id, which busybox
mdev does not populate. Add mulga-ebs-byid: an mdev rule + helper that
symlinks nvme-Amazon_Elastic_Block_Store_<serial> from the virtio-blk
serial, plus a boot oneshot that points the kernel hotplug helper at
mdev and coldplugs volumes present at boot.

* test: e2e EBS CSI volume — PVC bind + mount + reschedule survival

Add a TestEKS/EBSCSIVolume subtest that installs the aws-ebs-csi-driver
managed addon bound to an IRSA role, provisions a default gp3 PVC + pod,
and asserts a marker written by the first pod survives a reschedule onto a
fresh pod backed by the same PVC. Exercises the full data path:
CreateVolume(Viperblock)/AttachVolume virtio-blk hotplug with serial set to
the volume-id, mdev /dev/disk/by-id symlink, and ext4 mount.

* feat: eks-node provider-id + topology/instance-type labels for EBS CSI

mulga-eks-provider-id boot oneshot writes a K3s config.yaml.d drop-in
from IMDS before K3s starts: kubelet provider-id plus the region, zone
and instance-type node labels. The aws-ebs-csi-driver node plugin reads
this metadata for NodeGetInfo (instance id from providerID, region/zone
from topology labels, instance type for the volume-limit family lookup).
k3s runs no AWS cloud-provider so none of it is set otherwise, which left
the node plugin's metadata init failing, NodeGetInfo panicking in
IsNitroInstanceType, and the CSINode carrying no topology key. Wire the
service into the image manifest (INSTALL_FILES + ENABLE_SERVICES) and
chmod it in setup.

* test: e2e EBS CSI controller IRSA policy + failure diagnostics

awsgw enforces IAM on assumed-role EC2 calls, so the controller's
CreateVolume/AttachVolume need a permission policy on the IRSA role.
Attach a customer-managed EC2 policy (AWS-managed ARNs are opaque and
grant nothing) covering the EBS CSI action set; CreateVolume then
succeeds. Add an OnFailure dump of the CSI control path (provisioner and
plugin logs, pvc/pod describe, events, csinode) to the EBSCSIVolume
subtest so provisioning/attach failures are diagnosable post-teardown.

* feat: EBS CSI obeys control-plane taint; e2e worker nodegroup

Drop the CriticalAddonsOnly/NoExecute tolerations from the EBS CSI
controller + node manifests so the driver runs only on customer worker
nodes, never the system control-plane VM. The e2e creates a 1-node
worker nodegroup and the writer/reader pods drop their CP tolerations so
the customer-owned volume attaches in the customer space.

* fix: EBS CSI attach — decouple hotplug PCIe port from device name; iothread rollback

virtio-blk-pci hot-plug bus was derived from the AWS device-name letter
(/dev/sd[f-p]); the EBS CSI driver sends arbitrary names (/dev/xvdaa) that
map to no slot, so device_add fell back to pcie.0 — which rejects hot-plug —
and the attach hung. A failed attach also left the per-volume iothread object
behind, so every retry died on object-add 'duplicate property'.

nextHotplugEBSPort allocates the lowest free hotplug-ebs port from live
query-block state, independent of the device name. delIothreadBestEffort
removes the iothread on every attach rollback path.

* fix: EBS CSI node discovery — un-shadow mdev by-id rule, mint nvme link

Alpine eks-node runs busybox mdev (no eudev), which stops at the first
matching /etc/mdev.conf rule. setup.sh appended the mulga-ebs-byid rule after
the stock 'vd[a-z].* -> persistent-storage' line, so it never fired on
hot-attach: only /dev/disk/by-id/virtio-<serial> was created, while the
upstream aws-ebs-csi-driver resolves nvme-Amazon_Elastic_Block_Store_<volid>.

Swap the stock vd* rule in place to route through mulga-ebs-byid, which now
delegates to persistent-storage (preserving stock links) before minting the
nvme-style link the node plugin expects.

* test: EKS e2e — NAT Gateway egress for private worker nodegroup

The customer-VPC worker is private; AttachIGW programs no SNAT (mirrors AWS:
an IGW serves only public-IP instances), so an IGW-only fixture left the worker
unable to reach the public NLB endpoint or pull public CSI images — the
nodegroup never joined.

createWorkerEgress now builds the real topology: an IGW-fronted public subnet
hosting a NAT Gateway, with the worker subnet default-routed to it. Associating
the worker route table triggers the subnet-wide SNAT egress reroute. Teardown
drains the NAT Gateway before releasing the public subnet + EIP it pins.
2026-06-19 14:43:20 +10:00
tomnewton-mulga cf3f7f1222 docs fix 2026-06-19 13:12:40 +10:00
Josh Siviour 57737af09e feat(ecr): require repository to exist before push (#400)
The OCI /v2 registry auto-created a repository on first push (ensureRepo).
AWS ECR does not: a push or PutImage to a repository that was never created
with CreateRepository is rejected. Replace ensureRepo with requireRepo, which
returns OCI NAME_UNKNOWN on the data plane and RepositoryNotFoundException on
the JSON PutImage path. Enforcement sits at the single push choke point
(startUpload + StoreManifest).
2026-06-19 12:38:15 +10:00
Ben Duncan 2cde4ed21e Merge branch 'dev' of github.com:mulgadc/spinifex into dev 2026-06-19 12:31:19 +10:00
Ben Duncan 63f5520a89 Doc tweaks 2026-06-19 12:31:09 +10:00
Julian Sommer ad05544075 feat: EC2 capacity reservations — launch into reservation (#397) 2026-06-19 12:25:50 +10:00
Josh Siviour f9858d4e5d test(e2e): ECR end-to-end suite (control plane + docker/crane/skopeo) (#398)
* test(e2e): add ECR end-to-end suite (control plane + docker/crane/skopeo)

New tests/e2e/ecr/ suite plus harness support:
- harness: ECR SDK client, control-plane helpers (repo CRUD, get-login-password,
  describe-images), and docker/crane/skopeo CLI wrappers that skip when the
  binary or registry-host resolution is absent.
- suite: control-plane CRUD + policy + lifecycle round-trip; docker login/push/
  pull; crane append/pull and skopeo copy exercising the Bearer /v2/token flow.
- registered e2e-ecr in the Makefile + service manifest.

Predastore capacity/load leg is a skipped placeholder pending mulga-siv-359/356.

* test(e2e): carry awsgw port in ECR registry host for dev clusters

Dev clusters bind awsgw on a non-standard port (e.g. 9999); production uses
:443. ECRRegistryHost now appends SPINIFEX_ECR_PORT when set so docker/crane/
skopeo address the right endpoint, and RequireRegistryResolves strips the port
before DNS lookup.
2026-06-19 11:34:38 +10:00
Josh Siviour 188e7459e3 fix(ecr): negotiate manifest media type across all Accept headers (#399)
getManifest read only the first Accept header line via r.Header.Get, so
clients that send each acceptable manifest media type as a separate Accept
header (skopeo) never matched the stored type and got 406 MANIFEST_INVALID.
Join all Accept header values before negotiation.
2026-06-19 11:34:21 +10:00
Julian Sommer 003b2bc420 chore: switch to single default PR template 2026-06-19 11:22:36 +10:00
Julian Sommer b1b7d4196f chore: cleanup .gitignore 2026-06-19 11:13:16 +10:00
Julian Sommer 29ff386af2 chore: add PR Templates 2026-06-19 11:04:20 +10:00
Josh Siviour bd77ea8ee1 feat: ECR OCI /v2/token endpoint + Bearer challenge (#396)
Add the Docker Registry v2 token endpoint (GET /v2/token) and switch the
/v2 401 challenge from Basic to a Bearer realm pointing at it. The endpoint
verifies the caller's existing AWS:<jwt> credential and re-mints a fresh
full-TTL token, providing the token-refresh path for OCI clients (docker,
crane, skopeo). The auth bridge still accepts Basic AWS:<jwt> directly.
2026-06-19 10:32:32 +10:00
Josh Siviour 5d9e948b31 feat: set EBS volume-id as virtio-blk serial on attach (#395)
Stamp the dash-stripped volume-id (vol+17hex = 20 bytes, the virtio-blk
serial limit) as the hotplug disk serial so the EBS CSI node plugin can
locate the device via /dev/disk/by-id and match lsblk SERIAL.
2026-06-19 10:21:58 +10:00
Josh Siviour 1eda1ce88b feat: ECR background lifecycle expiry sweep (#394)
* feat: ECR background lifecycle expiry sweep

Add a gateway-side periodic sweep (LifecycleSweeper) that applies each repo's
stored lifecycle policy: it evaluates the shared rule engine against the repo's
current images and deletes the expired set through the existing registry GC
reclaim path (DeleteImage -> reclaimManifest). It runs in the gateway because
only the gateway holds a Registry (object store + meta); the daemon owns the KV
but cannot reclaim predastore bytes. Launched on the STS janitor's lifetime
context in awsgw, enumerating ACTIVE IAM accounts.

Per repo: skip when no policy; evaluate; then before deleting, re-read the
current tag set and skip any image whose tags changed since evaluation
(optimistic-concurrency guard against concurrent push/re-tag). Cross-repo blob
mount safety is provided by reclaimManifest/referencedDigests. Per-item errors
are logged and skipped so one bad repo never aborts the cycle.

* test: cover lifecycle sweeper Run loop and active-account filter

Add tests for LifecycleSweeper.Run ticker loop, NewLifecycleSweeper
interval fallback, and sweepRepo error branches. Extract accountLister
interface so activeAccountIDs is unit-testable. Raises diff coverage
on the sweep changes from 53.4% to 76.9%.
2026-06-19 10:03:21 +10:00
Julian Sommer e977683195 fix: imds e2e harness 2026-06-19 09:29:36 +10:00
Josh Siviour 2053b07878 feat: ECR lifecycle policies (CRUD + evaluation engine + preview) (#393)
* feat: ECR lifecycle policies (CRUD + evaluation engine + preview)

Implement PutLifecyclePolicy/GetLifecyclePolicy/DeleteLifecyclePolicy and
StartLifecyclePolicyPreview/GetLifecyclePolicyPreview, replacing the
NotImplemented stubs. A pure rule-evaluation engine parses the AWS
lifecycle-policy JSON and, given a repo's images, returns the ordered set that
would expire; each image is claimed by at most one rule (lowest rulePriority
first). Preview is served synchronously and deletes nothing.

Lifecycle-policy documents persist as a per-repo KV doc (repos/<name>/lifecycle)
over the ecr.lifecycle.* NATS subjects, mirroring the repo-policy storage stack,
and cascade on DeleteRepository. The console gains a Lifecycle tab with a policy
editor and a preview table over the saved policy.

The background expiry sweep that applies the engine and deletes images via GC is
deferred to mulga-siv-387.

* feat: client-side JSON validation in ECR policy editors

Swap the raw Textarea in the repository-policy and lifecycle-policy editors for
the shared JsonEditor (blur-time pretty-print) and gate Save on isValidJson,
showing an inline error for malformed JSON — matching the IAM/EKS editors. The
backend remains the source of truth for schema validation.
2026-06-19 08:42:28 +10:00
Josh Siviour a65843daa5 feat: ECR image tag mutability (immutable tags) (#392)
Honor, persist and echo imageTagMutability on CreateRepository and
DescribeRepositories instead of the hardcoded MUTABLE, and implement
PutImageTagMutability to flip a repository between MUTABLE and IMMUTABLE.

Enforce immutability at the shared StoreManifest chokepoint: on an
IMMUTABLE repo, re-tagging an existing tag onto a different digest is
rejected before any write (OCI 409 TAG_IMMUTABLE; JSON PutImage maps to
ImageTagAlreadyExistsException). Same-digest re-pushes, new tags and
digest references are still allowed.

Console: migrate the repository detail page to Summary/Images/Permissions
tabs and add a tag-immutability toggle on the Summary tab.
2026-06-19 00:19:16 +10:00
Josh Siviour 8cae94c715 feat(ui): ECR console — repositories, images, push commands, policy editor (#391)
Add a Repositories section to the Spinifex console backed by the ECR
control plane:

- getEcrClient() factory + @aws-sdk/client-ecr dependency
- queries/ecr.ts: repositories, per-repo images, repository policy
- mutations/ecr.ts: create/delete repository, batch-delete image,
  set/delete repository policy
- Routes under /ecr/list-repositories: list (table + create dialog +
  delete) and detail ($id: images table + delete-image + push-commands
  snippet + policy editor)
- ECR group + Repositories entry in the sidebar

Component tests cover the list, detail, create dialog, push commands and
policy editor.
2026-06-18 23:46:49 +10:00
Josh Siviour 590942b278 feat(ecr): reclaim manifest objects + GC orphaned blobs on digest delete (#390)
DELETE /v2/{name}/manifests/{digest} was a 202 no-op. Now delete-by-digest
removes the manifest record, deletes the manifest object, and sweeps the
account-wide blob pool: each child blob no longer referenced by any live
manifest is reclaimed, while shared blobs survive.

- DeleteImage (digest path) captures ChildDigests, deletes the manifest object,
  then reclaimManifest() drops orphaned blobs via referencedDigests() (a
  mark-and-sweep over the live manifest set across the account's repos).
- OCI deleteManifest by-digest wired to DeleteImage (real work + 202; 404 on
  unknown). Tag delete stays KV-only so untagged images persist (AWS-faithful).
- Predastore reclaim is best-effort: a failure leaks bytes but never undoes the
  logical delete.
2026-06-18 22:33:59 +10:00
Josh Siviour 2655178cb5 feat(ecr): implement image-management control plane (ListImages, DescribeImages, BatchGetImage, PutImage, BatchDeleteImage) (#389)
Serve the five image-management JSON 1.1 actions inline in ECR_Request,
reusing the gateway-side OCI Registry for manifest bytes:

- ListImages / DescribeImages enumerate images by tag + digest (KV meta).
- BatchGetImage returns verbatim manifest bytes with Q14 partial-failure
  semantics (cap 100, digest wins over tag, 200 + failures array).
- PutImage stores a manifest + tag via a shared StoreManifest core that the
  OCI manifest PUT now delegates to.
- BatchDeleteImage removes tag pointers / manifest meta (blob GC deferred).

Adds DeleteManifestMeta across the MetaStore relay chain and ECR image
error codes.
2026-06-18 22:14:57 +10:00
Josh Siviour 1608ffa465 feat(ecr): implement CreateRepository + DeleteRepository control plane (#388)
Add the two missing repository-lifecycle actions, served inline in
ECR_Request (like DescribeRepositories) since both return the repository
ARN/URI built from the gateway region + internal suffix.

CreateRepository validates the name, guards cross-account registryId,
rejects an existing repo (RepositoryAlreadyExistsException), and writes
the per-account KV meta (predastore bucket stays lazy on first push).

DeleteRepository rejects a non-empty repo without force
(RepositoryNotEmptyException) and cascades the KV records (meta, policy,
tags, manifests). Predastore blob GC is deferred to a separate pass.

Adds MetaStore DeleteRepo + ListManifests with the matching daemon-relayed
subjects, service methods, and NATS client, plus two new awserrors codes.
2026-06-18 21:38:30 +10:00
Josh Siviour 41dc53b7f8 fix(ci): prevent stale suite-status from false-failing e2e re-runs (#387)
The per-run ARTIFACT_DIR lives in /tmp, outside GITHUB_WORKSPACE, so a
re-run on the same self-hosted runner reused a prior attempt's append-only
suite-status.txt. The 'Fail job if any suite failed' gate's -s check then
failed the job even when the surviving attempt passed with rc=0.

Wipe ARTIFACT_DIR in Clean Workspace and gate solely on the per-attempt
steps.e2e.outputs.rc (fail-closed: an empty rc still fails).
2026-06-18 20:58:32 +10:00
Josh Siviour 56f960ebdc fix(ec2): mirror CreateTags/DeleteTags into AMI config (#386)
CreateTags/DeleteTags wrote only the standalone tag store, which
DescribeImages does not read (it reads AMI metadata), so tag mutations on
an AMI were invisible — DeleteTags appeared to be a silent no-op. Mirror
tag add/remove into the owning AMI config (ownership-checked; absent or
foreign AMIs skipped) so DescribeImages reflects them, matching the
existing subnet/vpc mirror.
2026-06-18 18:23:57 +10:00
Josh Siviour 2faad4b9e7 feat(ecr): repository policy passthrough + DescribeRepositories scoping (Sprint 2e) (#385)
Add Set/Get/DeleteRepositoryPolicy as JSON policy-doc passthrough stored at
KV repos/{name}/policy, and DescribeRepositories scoped to the caller account
with registryId override (cross-account denied pending registry-policy v2).

- MetaStore: PutRepoPolicy/GetRepoPolicy/DeleteRepoPolicy (KV + memory)
- NATS surface: ecr.policy.{put,get,delete} + daemon handlers
- gateway/ecrapi: repository-policy control-plane handlers
- gateway: handleDescribeRepositories dispatched inline for region/suffix
- awserrors: RepositoryNotFoundException, RepositoryPolicyNotFoundException
2026-06-18 18:23:22 +10:00
Josh Siviour a4a4288d7d feat(ecr): auth bridge + GetAuthorizationToken JWT (Sprint 2c) (#380)
* feat(ecr): auth bridge + GetAuthorizationToken JWT (Sprint 2c)

Mint a self-contained ES256 JWT from GetAuthorizationToken and verify it on
the /v2 OCI registry via a new ECRAuthBridgeMiddleware (Bearer | Basic AWS:jwt).
Signing keys live in the cluster-replicated awsgw-keys KV bucket under
jwt-signing/{kid}, encrypted with the IAM master key.

The registry is now scoped per-request to the token's account (replacing the
fixed bootstrap account); bucket-ensure is cached per account. Cross-account
token/host mismatch is rejected 403; unauthenticated /v2 access gets a 401
Bearer challenge.

* fix(ecr): use Basic auth challenge on /v2 (not Bearer token-endpoint flow)

ECR authenticates the OCI registry with Basic AWS:<token>; a Bearer realm
made docker chase an unimplemented /v2/token endpoint and fail login.
2026-06-18 17:29:41 +10:00
Julian Sommer 3f592b4a5e fix: alpine image ssh (#384) 2026-06-18 16:58:26 +10:00
Julian Sommer 038927d18e fix: stop NAT Gateway teardown leaking SNAT rule (#383) 2026-06-18 16:27:40 +10:00
Julian Sommer 83e538f2f2 feat: IMDS version discovery, instance-identity document, and cheap metadata routes (#382) 2026-06-18 15:26:14 +10:00
Josh Siviour 3e6dfde7dd feat(ui): inline EKS cluster IAM role creation in wizard (#379)
* feat(ui): inline EKS cluster IAM role creation in wizard

* feat(ui): show trust policy + attached managed policy in EKS role dialog

Render the trust policy with PolicyDocumentViewer (pretty-printed, safe
fallback) instead of a raw read-only textarea, matching the IAM detail
pages. Surface the attached AmazonEKSClusterPolicy ARN so users can see
exactly which permissions the cluster role grants.
2026-06-18 14:01:48 +10:00
Josh Siviour 833456ae09 fix(ui): EKS image banner shows spx admin images import command (#378) 2026-06-18 13:35:04 +10:00
Julian Sommer 941978a28c refactor: consolidate NATS fan-out onto a single utils.Gather (#381) 2026-06-18 13:33:43 +10:00
tomnewton-mulga b0810bd5a1 Merge branch 'dev' of github.com:mulgadc/spinifex into dev 2026-06-18 10:54:04 +10:00
tomnewton-mulga 116a80f0ca fixed up SMCI MIG doc 2026-06-18 10:53:56 +10:00
Josh Siviour 2acb37342d feat(eks): aws-load-balancer-controller addon — real ALB Ingress (L7, IRSA) (#376)
* fix(ec2): stamp subnet AvailabilityZoneId for single-AZ ALB resolution

DescribeSubnets omitted AvailabilityZoneId, so the AWS Load Balancer
Controller's DescribeAvailabilityZones-by-ZoneId lookup resolved no AZ
info ("cannot resolve AZ info for AZs: []") and never built the ALB
model. Stamp the single zone-id on every subnet and share it with
DescribeAvailabilityZones through one SingleZoneID constant so the two
responses can never drift.

* feat(eks): add aws-load-balancer-controller v2.8.1 addon bundle

Baked manifests for the LBC managed addon (CRDs, controller Deployment/
RBAC/webhooks/Service, IngressClass alb, IRSA render markers). Two
wirings make it work against the spinifex gateway:

- --aws-api-endpoints carries sts= alongside elasticloadbalancing/ec2/
  acm. The aws-sdk-go v1 credential bootstrap resolves the STS client
  from the session endpoint resolver, not AWS_ENDPOINT_URL_STS, so the
  web-identity AssumeRoleWithWebIdentity must be pointed at the gateway
  here or it falls back to real AWS STS.
- --feature-gates=ALBSingleSubnet=true drops the ALB minimum subnet
  count from two to one so an ALB provisions in a single-AZ deployment.

* feat(eks): render IRSA into managed addons + node-minted webhook cert

Extend the addon-sync renderer to inject the projected-token / role-arn /
gateway STS-endpoint + CA block into managed-addon pod specs via opt-in
markers, and substitute cluster-name/region/vpc-id/gateway-endpoint/CA
placeholders. Add cmd/eks-webhook-cert: the eks-node AMI has no openssl,
so this mints (then reuses) a self-signed ECDSA-P256 serving cert for the
admission webhook, cached on disk so the render is byte-stable across
ticks. Wire both into the image build (manifest.conf) plus INSTALL_FILES
for the LBC bundle.

* feat(eks): surface cluster VPC id as EKS_VPC_ID for the LB controller

Thread the control-plane VPC id through K3sServerInput.VpcID into the
first-boot EKS_VPC_ID env so the in-cluster aws-load-balancer-controller
can pass --aws-vpc-id to the gateway elbv2/ec2 handlers.

* fix(eks): point the LB controller at the customer VPC + customer-facing gateway

The aws-load-balancer-controller ran with two control-plane-scoped values
that customer worker pods cannot use:

- --aws-vpc-id was set from the managed control-plane VPC (cpRefs.VpcID),
  which the gateway's DescribeVpcs does not return for customer calls, so
  backend-SG auto-create failed with InvalidVpcID.NotFound. Source it from
  meta.ResourcesVpcConfig.VpcId, the customer cluster VPC where the workers
  and their subnets live and where the ALB is provisioned.

- The addon render baked the mgmt gateway endpoint (EKS_GATEWAY_URL /
  SystemGatewayURL) into pod specs, unreachable from worker pods. Add a
  distinct EKS_ADDON_GATEWAY_URL (the customer-facing GatewayBaseURL, same
  host as the OIDC issuer) and substitute it for {{GATEWAY_ENDPOINT}} in
  managed addon manifests. EKS_GATEWAY_URL stays mgmt for the CP's own
  bootstrap, state-report and token-webhook paths.

* fix(elbv2): resolve forward target group from ForwardConfig

listenerActionFromSDK read only the legacy flat Action.TargetGroupArn, so a
forward action carrying its target group via ForwardConfig.TargetGroups (the
shape the aws-load-balancer-controller emits) left the action target empty and
CreateRule/CreateListener rejected it with MissingParameter. Flatten the first
target group from ForwardConfig when the flat field is absent.

* fix(ec2): mirror CreateTags/DeleteTags into subnet/vpc records

The generic S3 tag store and the per-resource KV records are disjoint:
DescribeSubnets/DescribeVpcs read tags from their KV record, while
CreateTags only wrote the generic store. Subnet tags applied after
create were therefore invisible to tag-filtered describes, breaking
aws-load-balancer-controller subnet auto-discovery (kubernetes.io/role/elb).

Write through CreateTags/DeleteTags to the owning subnet/vpc record so
tag-filtered describes observe them. Resource types the VPC service does
not own are skipped; the generic store stays their record of truth.

* test(daemon): pin synthetic capacity in host-dependent allocate tests

TestCanAllocate_CountEdgeCases/Mixed_instance_types and
TestHandleEC2DescribeInstanceTypes/NoFilterReturnsSupportedRegardlessOfAllocation
called rm.allocate against live-detected host capacity. allocate gates on
readMemAvailableGB (live /proc MemAvailable), so a box already running VMs
fails the allocation spuriously, breaking the preflight gate locally.

Pin hostVCPU/hostMemGB/reserved and nil readMemAvailableGB to a synthetic
host, matching the sibling Capacity_decreases_after_allocation subtest.

* fix(eks): route apiserver->pod traffic via agent tunnel (egress-selector cluster)

In the managed-CP topology the apiserver VM sits in the system CP VPC with
no route to the worker pod network in the unpeered customer VPC. k3s default
egress-selector-mode=agent tunnels only apiserver->kubelet, so kubectl
logs/exec work but apiserver->pod traffic (admission webhooks, aggregated
APIs) is attempted directly and times out -- the aws-load-balancer-controller
webhooks fail with context deadline exceeded, blocking Service/Ingress
creation cluster-wide.

Set egress-selector-mode=cluster so apiserver->pod/service traffic routes
through the agent's outbound tunnel, selected by the node hosting the
destination, without requiring inbound CP->worker reachability.

* feat: bump aws-load-balancer-controller addon bundle to v2.11.0

v2.8.1 predates the ALBSingleSubnet feature gate, so single-AZ spinifex
clusters fail Ingress reconcile (subnet resolver dedups to 1 < required 2)
and never provision an ALB. v2.11.0 honors --feature-gates=ALBSingleSubnet=true.

Replace the baked bundle: new CRDs (ingressclassparams + targetgroupbindings),
controller manifest, and image tag v2.11.0, re-applying the spinifex deltas
(IRSA SA annotation, node-minted webhook serving-cert secret, gateway-CA
secret, gateway aws-api-endpoints, caBundle on every webhook clientConfig,
alb IngressClass). Pick up the new ClusterRole configmaps rule. Drop v2.8.1.

* fix: point LBC at the gateway via AWS_ENDPOINT_URL (sdk-v2)

LBC v2.11 uses aws-sdk-go-v2, which keys --aws-api-endpoints on SDK
service IDs (e.g. 'Elastic Load Balancing v2'), not the legacy lowercase
names the bundle passed. Those overrides were silently ignored, so every
non-STS call (ec2/elbv2/acm) went to real AWS and failed TLS against the
gateway CA -- no ALB could provision.

Inject AWS_ENDPOINT_URL in the shared IRSA env block (sdk-v2 global
base-endpoint override; AWS_ENDPOINT_URL_STS still takes precedence for
STS) and drop the dead --aws-api-endpoints flag from the controller args.

* fix: synthesize taggable ARN for default listener rule

Gateway DescribeRules returned the synthetic IsDefault rule with a null
RuleArn. LBC read the arn-less default rule after creating a listener and
issued DescribeTags with an empty resource ARN, which the gateway rejected
with 400 MissingParameter, aborting reconcile.

Set a deterministic RuleArn on the default rule, resolve it back to its
parent listener in DescribeTags (empty tags) and DescribeRules by-arn.
Also disable shield/waf/wafv2 in the LBC bundle to drop unsupported calls.

* feat: inject ELB subnets into alb IngressClassParams for single-AZ ALB

LBC's tag auto-discovery never threads the ALBSingleSubnet feature gate, so a
single-AZ cluster (subnets sharing one AZ) dedups to 1<2 subnets and aborts
Ingress reconcile. Only the explicit-subnet path honors the gate.

Resolve the cluster's ELB-eligible subnets, dedup to one per AZ, and surface
them as EKS_ELB_SUBNET_IDS. The addon sync renders them into the alb
IngressClassParams .spec.subnets.ids so every Ingress takes the explicit path.
An empty set falls back to auto-discovery, preserving the native UX.

* fix: route LBC service clients to gateway via --aws-api-endpoints, link IngressClassParams

Live verification showed two gaps in the LBC addon bundle:

LBC installs a per-client BaseEndpoint resolver, so the global AWS_ENDPOINT_URL
is ignored for the ec2/elbv2/acm clients (calls hit real AWS, failing TLS).
Redirect them with --aws-api-endpoints keyed on the aws-sdk-go-v2 service IDs
(EC2, "Elastic Load Balancing v2", ACM, STS) rather than the legacy lowercase
names the flag never matched. Drop the misleading AWS_ENDPOINT_URL env; the
web-identity STS client still resolves via AWS_ENDPOINT_URL_STS.

The injected IngressClassParams was orphaned: the alb IngressClass had no
.spec.parameters, so LBC fell back to subnet auto-discovery (which fails on a
single-AZ cluster). Link the IngressClass to the IngressClassParams so the
explicit-subnet path is taken.

* fix: stamp EKS-parity providerID on worker nodes for LBC ip targets

k3s registers workers with providerID k3s://<name>, which the AWS Load
Balancer Controller rejects (providerID invalid for EC2 instances) when
resolving nodes for ip-mode target registration. Resolve the instance-id and
AZ from IMDSv2 at agent boot and set --kubelet-arg=provider-id=aws:///<az>/<id>
so LBC can map the node to its EC2 instance. On any IMDS miss the agent keeps
the default providerID and still starts.

* fix(elbv2): honor SubnetMappings in CreateLoadBalancer

LBC supplies ALB subnets via SubnetMappings, not the plain Subnets list.
createLoadBalancer only read input.Subnets, so ENI allocation was skipped,
VpcId/AZ stayed empty, and the HAProxy data-plane VM never launched — the
ALB went active but could not serve traffic. Add flattenSubnetIDs to read
both sources and reuse it in createLoadBalancer and desiredSubnetSet.

* fix(vm): capture GCInterval before spawning GC goroutine

TestStart_SweepsImmediatelyThenStopsOnCtxCancel mutates the package global
GCInterval and restores it in a defer, racing the Start goroutine's read at
time.NewTicker(GCInterval) under -race. Read GCInterval on the caller's
goroutine into a local before spawning so the global access is ordered with
the test's defer; the goroutine touches only the captured local.
2026-06-18 10:53:05 +10:00
Josh Siviour 332087fccc feat: OCI Distribution v2 blob + manifest storage (#375)
* feat: implement OCI Distribution v2 blob and manifest storage

Replace the /v2/* 501 stub with a real path-parsing dispatcher and
content-addressable storage for the ECR registry surface:

- Blob upload (monolithic + chunked PATCH), HEAD/GET, cross-repo mount,
  and cancel, with server-side sha256 verification on finalize.
- Manifest PUT with type-aware shallow validation (image manifest blob
  existence; image index child-manifest existence + mediaType match),
  byte-for-byte GET with stored Content-Type, HEAD, and tag delete.
- Tag list and account-scoped catalog.

Blob and manifest bytes stream straight to predastore; repo, tag,
manifest and in-progress-upload metadata live in a per-account
JetStream KV bucket with optimistic-concurrency CAS on uploads. Chunks
are buffered to a temp upload object and reassembled on finalize, since
predastore exposes no append primitive through the shared object-store
interface. Adds HeadObject to the objectstore abstraction.

Refs: mulgadc/mulga#73

* refactor(ecr): route registry metadata through NATS daemon handlers

The gateway no longer talks JetStream KV directly. The daemon owns the
per-account KV via a MetaService bound to ecr.* request/reply subjects;
the gateway is a NATS client (NATSMetaStore). Blob and manifest bytes
still stream gateway-to-predastore directly.

Also fixes four registry review findings:
- finishUpload: clean up the temp object and upload record on a blob
  store failure so the uuid is reusable and no partial blob lingers.
- patchUpload: commit the appended bytes and advanced hash state under a
  single KV CAS keyed per write, so a racing PATCH is rejected (409)
  rather than corrupting the assembled bytes; the verified digest always
  matches the stored bytes.
- cancelUpload: delete both the temp bytes and the record, 204 on
  success, 404 on unknown uuid, no dead branch.
- getManifest: return MANIFEST_INVALID (not MANIFEST_UNKNOWN) for a 406
  Accept media-type mismatch.

Refs: mulgadc/mulga#73

* fix(ecr): provision account predastore bucket on demand

OCI blob/manifest writes target ecr-{accountID}, which nothing created,
so every push failed with a backend 403. Add idempotent EnsureBucket to
the object store (HeadBucket -> CreateBucket, swallowing already-owned)
and ensure it once per process at the top of the registry ServeHTTP, so
ECR storage appears transparently like AWS-managed ECR.

* fix(ecr): sanitize ':' from predastore blob/manifest object keys

predastore mis-canonicalizes SigV4 for object keys containing ':', so
writes/reads of content-addressable keys (sha256:<hex>) were rejected
403 (reproducible with the aws CLI too). Route BlobKey and ManifestKey
digests through DigestToken so the object key carries no ':' separator.
2026-06-18 10:51:26 +10:00
tomnewton-mulga aa46943e0a fixed up SMCI MIG doc 2026-06-18 10:49:43 +10:00
Julian Sommer 996eb370f3 feat: Capacity Reservations (#377) 2026-06-18 10:44:21 +10:00
Julian Sommer 28c6e8ccff chore: bump CI time 2026-06-18 10:44:01 +10:00
Josh Siviour 167c93b1c7 feat: ECR TLS SAN wildcard + hostmatch host-routing middleware (#374)
Implement the registry host-routing middleware so /v2/* requests addressed
at {accountID}.dkr.ecr.{region}.{suffix} carry the parsed target account and
region on the request context; non-registry hosts pass through unchanged.

Add cluster-wide AWS-parity config (aws.region, aws.internal_suffix) with
defaults, and append the AWS-parity ECR SANs (exact control-plane host plus
*.dkr wildcard) to the awsgw server certificate during cert generation.

Refs: mulgadc/mulga#78
2026-06-17 23:36:06 +10:00
Josh Siviour a1c307a128 feat: scaffold ECR control-plane + OCI registry gateway (501 stubs) (#373)
* feat: scaffold ECR control-plane + OCI registry gateway with 501 stubs

Add the ECR HTTP surface on awsgw as compiling skeletons that return
NotImplemented, so later work can call into the packages against a fixed
contract.

- gateway/ecrapi: ECR control plane (AWS JSON 1.1, X-Amz-Target). Full action
  namespace registered; every action resolves to a shared 501 stub.
- gateway/ecr: OCI Distribution v2 (/v2/*). Catch-all 501 with the OCI error
  envelope; GET /v2/ is live for the registry version-check probe.
- gateway/hostmatch: no-op host-routing middleware skeleton for the registry
  host group.
- handlers/ecr/storage: per-account bucket + object-key layout constants.
- gateway wiring: ecr in supportedServices, dispatch case, JSON 1.1 error
  branch, and /v2 mounted outside the SigV4 group.

* test: cover ECR scaffolding packages for diff-coverage gate

In-package tests for gateway_ecr, gateway_ecrapi, and handlers/ecr so the new
code reports coverage under its own package profile. Diff coverage 76.5%.
2026-06-17 20:47:38 +10:00
tomnewton-mulga 42db84184d Merge branch 'dev' of github.com:mulgadc/spinifex into dev 2026-06-17 16:45:25 +10:00
tomnewton-mulga a396e1bd30 updating SMCI docs 2026-06-17 16:45:04 +10:00
Julian Sommer d88783d5bf feat: ui json formatting 2026-06-17 16:02:48 +10:00
Julian Sommer eced3f733a feat: split IAM e2e (#372) 2026-06-17 15:06:16 +10:00
Julian Sommer b99b46c18c chore: consistent ui malformed json rejection 2026-06-17 14:37:56 +10:00
Julian Sommer 9288ecc7dc chore: delete no op tests + speedup e2e 2026-06-17 12:19:13 +10:00
Julian Sommer 5c0c9ed173 chore: fix favicon 2026-06-17 11:35:01 +10:00
Julian Sommer 058da3eff4 fix(viperblockd): seal volume block-map to predastore on detach + durability e2es (#371) 2026-06-17 11:24:03 +10:00
Julian Sommer 89ec47a69b chore: update COMMANDS.md 2026-06-17 10:20:31 +10:00
Julian Sommer 2e9501524b feat(e2e): more e2e coverage (#365) 2026-06-17 09:52:50 +10:00
Josh Siviour 67092d40be fix(workbooks): split EKS demo workloads into separate root module (#370)
The kubernetes provider in the eks-quickstart, eks-addons-access and
eks-https-ingress workbooks was configured from the managed cluster
resource (host = aws_eks_cluster.this.endpoint). On destroy the endpoint
resolved empty, the provider fell back to localhost:80, and refreshing
kubernetes_deployment_v1.hello / kubernetes_service_v1.hello failed with
'dial tcp [::1]:80: connect: connection refused'.

Move the kubernetes provider and the hello Deployment + Service into a
nested workloads/ root module with its own state. It reads cluster
identity from the parent via terraform_remote_state and the live
endpoint/CA from a data aws_eks_cluster source, so the provider is only
ever configured while the cluster exists. Apply parent then workloads;
destroy workloads then parent.
2026-06-16 21:48:56 +10:00
Josh Siviour da0f10015c fix(eks): stop leaking cluster SGs that pin the customer VPC on teardown (#369)
purgeClusterInfra stranded the cluster security groups two ways, leaving
the nodegroup/control-plane SGs (which live in the customer VPC) behind to
pin aws_vpc destroy on DependencyViolation:

  * an early return before the SG cleanup block skipped DeleteClusterSGs
    whenever any earlier best-effort step (NLB / ENI / VM / EIP) errored;
  * DeleteClusterSGs failures were warn-and-swallowed, so a nodegroup SG
    still referenced by a terminating worker's ENI leaked while the cluster
    completed deletion — no reaper retry ever fired.

Surface DeleteClusterSGs / DeleteClusterCPVPC / DeleteClusterIGW failures
into teardownErrs and gate the KV sweep on them, so a leaked VPC-pinning SG
keeps the cluster DELETING for a retry. Add a bounded DependencyViolation
retry to the SG delete (mirroring deleteENIAwaitingTerminate) to ride out
the worker-ENI-detach race in the common case.

Fixes the manual 'revoke rules + delete SGs before the VPC will destroy'
teardown step.
2026-06-16 21:20:05 +10:00
Josh Siviour bc9556d1a9 fix(eks): managed-CP worker data-plane — apiserver reachability, node-local CoreDNS, SG teardown (#368)
* fix: persist instance TagSpecifications in RunInstance

RunInstance built the ec2.Instance metadata but never read
input.TagSpecifications, so DescribeInstances returned untagged
instances and tag filters matched nothing. EKS node groups discover
their workers by the spinifex:eks-cluster / spinifex:eks-nodegroup
tags, so scale-up convergence and worker reconciliation depended on
tags that were silently dropped.

Apply instance-scoped TagSpecifications onto the built instance so the
tags surface through DescribeInstances and tag filters.

* fix: serialize nodegroup reconcile via CAS to prevent scale overshoot

UpdateNodegroupConfig is dispatched on a queue group, so two
overlapping requests (e.g. an SDK retry mid-flight) each read
current = len(InstanceIDs) and each launched desired-current workers,
overshooting the target (1 -> 5 for desired=3) and stranding untracked
VMs via last-writer-wins on the unconditional PutNodegroupRecord.

Make every durable nodegroup-record mutation a compare-and-swap on the
KV revision. Scale-up launches one worker at a time and CAS-appends it
only while the record is below the live ScalingDesired, terminating any
surplus VM launched into an already-full record. Scale-down terminates
before the CAS truncation so a crash or retry leaves reclaimable dead
IDs rather than orphaned VMs. A concurrent regression test asserts two
overlapping scale-ups converge to exactly desired under -race.

* fix: return securityGroupRuleSet from AuthorizeSecurityGroup ingress/egress

The AWS provider's aws_vpc_security_group_ingress_rule reads
SecurityGroupRules[0].SecurityGroupRuleId on create; an empty ruleset
panics the plugin (Plugin did not respond), though the rule applies
server-side. Echo the created rules via sgRuleToSecurityGroupRule, the
same shape DescribeSecurityGroupRules already returns.

* fix: surface InsufficientInstanceCapacity from nodegroup scale, not 500

launchWorkersCAS wrapped the bare awserror code via fmt.Errorf, so the
gateway ErrorHandler's exact-match against ErrorLookup missed and remapped
to InternalError (500). An out-of-capacity scale now surfaces the real
InsufficientInstanceCapacity code (400); opaque internal failures still
wrap and stay 500. Adds awserrors.HasErrorCode for the membership test.

* fix: cut EKS node boot ~50s — drop cloud-final deadlock + syslinux menu

Worker/server user-data runcmd ended with `[ rc-service, local, start ]`,
which runs inside cloud-final while the OpenRC `local` service is ordered
after it — a deadlock broken only by OpenRC's ~50s start timeout, stalling
the k3s join. Run the IMDS route script directly instead and keep
`rc-update add local default` for reboot persistence.

setup.sh: cut the SYSLINUX boot-menu countdown 10s->~1s (kept nonzero so the
menu stays interruptible over serial).

Validated live on env19 CP: boot menu 1s, no `waiting for cloud-final`
stall, apiserver ready ~66s (was ~109s+).

mulga-siv-332

* feat: add argocd managed addon + bake bundle into eks-node AMI

The addon catalog advertised coredns and aws-load-balancer-controller, but
the only baked manifest shipped in the eks-node AMI was the spinifex-noop
fixture. Installing any real addon failed with 'no baked bundle for version'
once the VM-side renderer found nothing on disk.

Add argocd v3.0.23 as a real, demonstrable managed addon: catalog entry (no
IRSA), a flattened namespaced install manifest baked into the AMI, and the
manifest.conf bake line. The eks-addons-access workbook switches from coredns
(redundant on K3s, never baked) to argocd.

The baked bundle is generated from upstream install.yaml via kubectl kustomize
with namespace: argocd plus a prepended Namespace, since upstream is
namespace-agnostic and K3s auto-deploy applies with no -n flag.

* fix(eks): run CoreDNS as a node-local DaemonSet for worker DNS

k3s ships CoreDNS as a single-replica Deployment that tolerates the CP taint
and strands itself on the control-plane node. In the managed-CP topology the
worker<->CP pod overlay cannot cross the VPC boundary, so worker pods cannot
reach a CP-resident CoreDNS and lose all cluster + external DNS.

Skip the bundled CoreDNS (coredns.yaml.skip, staged in k3s.initd like traefik)
and deploy a Mulga-owned variant: a DaemonSet (one pod per node) plus kube-dns
internalTrafficPolicy=Local, so every node resolves via its own CoreDNS. Immune
to the cross-VPC overlay gap and topology-agnostic (single-node and managed-CP
both work). Image-side only; no handler change.

* fix(eks): revoke cross-referencing SG rules before delete on teardown

DeleteClusterSGs deleted the control-plane and nodegroup SGs directly, but they
cross-reference each other (EnsureNodegroupSGRules), and AWS refuses to delete a
security group still referenced by another SG's rule. Both deletes failed with
DependencyViolation, the SGs leaked, and the VPC could not be deleted (tofu
destroy hung on DeleteVpc).

Resolve both SG IDs, revoke all ingress on each to break the cp<->ng cycle, then
delete both. Adds RevokeSecurityGroupIngress to sgProvisioner (already on
VPCServiceImpl) and revokeAllSGIngress, with a regression test that models AWS's
DependencyViolation.

* fix(eks): advertise NLB front-end so worker pods reach the apiserver ClusterIP

In the managed-control-plane topology the apiserver advertised its Set-B
node-ip into the in-cluster `kubernetes` Endpoints. That IP lives in the
unpeered CP VPC and is unreachable from worker pods, so every worker pod
using the apiserver ClusterIP (CoreDNS kubernetes-plugin sync, controllers,
argocd redis secret-init) failed.

- k3s_server_vm.go: set advertise-address to the worker-reachable NLB
  front-end (PrivateEndpointIP, else EndpointIP); both are already cert SANs.
- nlb.go: add a second :6443 listener on the cluster NLB (same CP target
  group) so PrivateEndpointIP:6443 — the advertised endpoint — is served.
- security_groups.go: admit :6443 on the private-endpoint SG.

Validated on a live managed-CP cluster: worker CoreDNS /ready 200, all argocd
pods Running, kubectl logs on worker pods working.
2026-06-16 20:27:21 +10:00
Josh Siviour cba1ecceaf fix: route EKS DisassociateAccessPolicy policyArn path segment (#367)
The DisassociateAccessPolicy route regex terminated at
/access-entries/{principalArn}/access-policies, but the AWS EKS REST API
sends the policy ARN as the final path segment:

  DELETE /clusters/{name}/access-entries/{principalArn}/access-policies/{policyArn}

The route never matched, so the gateway returned InvalidActionException
(seen as a 400 on 'tofu destroy' of aws_eks_access_policy_association).
The handler also read policyArn from the request body, which AWS leaves
empty for this DELETE.

Capture policyArn as a third path param and pass it through to the input,
dropping the body read. Update route and wrapper tests accordingly.
2026-06-16 16:37:44 +10:00
Josh Siviour c22a6f4ffc fix: install chrony on host via curl|bash setup path (#366)
The host clock needs an NTP daemon; without one it drifts and SigV4 request
signatures fall outside the skew window, kicking the web console back to /login.
Mirrors the ISO rootfs (iso-builder packages.list) so both host-install paths
ship the same software. Guest VMs track the host via kvm-clock.

Bug: mulga-siv-335
2026-06-16 16:03:34 +10:00
Ben Duncan 0dc5c97ba9 Draft doc feedback 2026-06-16 14:42:13 +10:00
tomnewton-mulga 362e1f42c1 Merge branch 'dev' of github.com:mulgadc/spinifex into dev 2026-06-16 12:17:52 +10:00
tomnewton-mulga e2782ede44 chore: first draft of SMCI MIG writeup 2026-06-16 12:17:45 +10:00
Josh Siviour 9a474220ce fix: preserve terminated-instance describe-visibility window in GC reaper (#363)
* fix: preserve terminated-instance describe-visibility window in GC reaper

The TerminatedTeardownReaper purged a terminated record the moment it drove
the instance's pending NAT/OVN teardown to done. Normal terminations always
arrive NAT/OVN-pending, so the first 2m GC sweep completed and purged them
immediately, removing the instance from DescribeInstances before clients could
observe state=terminated — an AWS-compat divergence that flaked e2e-single
TestRunInstancesMultiCount.

Stamp TerminatedAt on transition and purge a completed record only once it is
older than a 15m visibility window; fresher records are left to the terminated
bucket's 1h TTL, keeping the instance describable as terminated. The ADR-0003
§3 purge-on-completion invariants still hold (their seeded records predate the
window). Closes mulga-siv-328.

* test(e2e): poll intra-SG east-west ping until L2 converges

TestSameSGComms fired a single un-retried 'ping -c 3 -W 2' immediately after
the source instance's SSH became reachable, but the ICMP target is the
destination's east-west data plane. Two instances boot in parallel and the
destination guest NIC / OVN port flows come up several seconds after the EC2
running transition the launch waits on. When the probe wins that race the
source kernel reports 'Destination Host Unreachable' (ARP unresolved), not an
ACL drop, and the test flaked.

Replace the one-shot ping with pingConverged, a bounded poll that retries
until 0% packet loss or 45s. A genuine self-ingress/datapath break still
stays at 100% loss for the full window and fails, so coverage is preserved.
2026-06-16 11:38:05 +10:00
Julian Sommer 1f7adcc4dd chore: more e2e tests for IAM + STS + IMDS (#364) 2026-06-16 11:11:12 +10:00
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
Julian Sommer 97b2956186 release: update go.mod for v1.7.0 2026-06-09 12:50:53 +10:00
Julian Sommer 67ab37cb28 merge dev into main 2026-06-09 12:47:17 +10:00
Josh Siviour 4ef19fc488 feat(ui): EKS console (#310) 2026-06-09 10:57:50 +10:00
Julian Sommer 15cd1a1ad7 feat: sts get session token (#321) 2026-06-09 10:51:28 +10:00
Julian Sommer a96a34c0d3 chore: cleanup oxlint config 2026-06-09 09:49:45 +10:00
Julian Sommer 31260179a1 chore: frontend deps 2026-06-09 09:31:45 +10:00
dependabot[bot] a985dc34f2 chore: bump github.com/nats-io/nats-server/v2 (#316) 2026-06-09 08:53:46 +10:00
Josh Siviour 5ac8dc3fb9 fix(network): round-robin external pool cursor — stop instant EIP reuse (recycled EIP unreachable, EKS CI) (#320)
* Revert "fix(network): prime host neigh with gateway port MAC on centralised EIP attach"

This reverts commit f7446c69c6.

* fix(network): round-robin external pool cursor so a released IP is not reused instantly

The static external pool handed out the lowest free address and freed IPs
immediately on release, so a just-released EIP was reassigned within ~0.6s. A
recycled EIP front-end is unreachable from the same host: the kernel still
holds the prior owner's ARP binding (or a negative cache after the neigh
flush) and OVN does not answer the re-ARP in time, so the neighbour entry
settles to FAILED (EHOSTUNREACH). This surfaced as EKS TestEKS/KubectlGetNodes
failing in single-node CI once #315 routed kubectl through the cluster NLB's
external EIP. Dev never hit it — it uses DHCP-random addresses.

Add a persisted per-pool cursor: allocation resumes one past the last address
handed out and wraps at RangeEnd, so a freed IP is not reused until the cursor
cycles the whole range. Empty cursor (fresh pool / older KV record) starts at
RangeStart, preserving lowest-free order for the first cycle — backward
compatible, no migration.

* ci: drop e2e-eks from default e2e suites

EKS data-plane subtest (TestEKS/KubectlGetNodes) is a persistent flake on
single-node CI — a freshly-attached centralised EIP is intermittently
unreachable from the host (no route to host to the cluster endpoint), which
fails the whole E2E run and blocks unrelated suites. Remove e2e-eks from the
default SUITES so PR/push runs no longer gate on it. The e2e-eks target and
the workflow_dispatch suites input are unchanged, so EKS stays runnable on
demand and via make e2e-all.
2026-06-09 00:17:36 +10:00
joshsiv-mulga f7446c69c6 fix(network): prime host neigh with gateway port MAC on centralised EIP attach
Centralised-mode EIP attach only flushed the host neighbour entry and relied
on OVN re-answering the host's ARP. On OVN builds whose ovn-controller lacks
inject-garp, no node answers a same-chassis recycled IP's re-ARP, so the entry
decays to FAILED and the external IP is unreachable ("no route to host"). This
sank the EKS e2e suite: the cluster NLB front-end EIP never resolved on br-wan,
so kubectl could not reach the published apiserver endpoint.

Mirror the distributed-mode prime: resolve the VPC gateway router port MAC (the
L2 owner of a centralised EIP on the WAN segment) and program the host
neighbour entry directly, falling back to a flush only when no MAC resolves.
2026-06-08 21:04:08 +10:00
Josh Siviour 1a05386936 feat: EKS managed Addon API (state machine + staging installer) (#317)
* feat: un-stub EKS Addon API with managed-addon state machine

Wire the 6 EKS Addon ops (List/DescribeVersions/Create/Describe/Update/
Delete) to a per-cluster managed-addon state machine in the per-account KV.

- addon_catalog.go: static air-gapped catalog (aws-load-balancer-controller,
  coredns) with version validation.
- addon_state.go: AddonRecord + AddonStatus, KV CRUD + CAS helpers; list skips
  staged-manifest sub-keys; delete sweeps record + manifest.
- addon_installer.go: transport-agnostic AddonInstaller; stagingInstaller
  stages a manifest descriptor in KV for the (future) VM-side delivery slice.
- addons.go: op impls + AWS-shape converters; installer failure flips
  CREATE_FAILED. Status stays CREATING until delivery confirms ACTIVE (private
  apiserver is host-unreachable, so delivery must be VM-side).
- service_impl.go: drop notImpl stubs; add AddonInstaller dep (nil-defaults to
  staging installer).

DeleteClusterPrefix already sweeps clusters/{name}/ so addon keys clean up on
cluster delete.

* fix: route ListAddons at GET /clusters/{name}/addons

ListAddons was wired at GET /addons with no cluster param, so the AWS CLI's
GET /clusters/{name}/addons hit no route (InvalidActionException) and the
wrapper couldn't pass a cluster to the service. Match the real EKS path and
thread the cluster name through.
2026-06-08 13:56:41 +10:00
Josh Siviour 3d95664c80 fix(network): prime host neigh with external_mac on distributed EIP attach (#319)
Recycled WAN EIPs stayed unreachable from the host (kubectl to a cluster
endpoint: "no route to host", host neigh FAILED) on OVN builds whose
ovn-controller lacks the inject-garp appctl. OVN advertises a recycled
external IP's new MAC only via the GARP it emits on LSP binding-chassis
migration; a same-chassis rebind emits none, and the neigh flush added for
recycle reachability then waits on an ARP reply no node sends.

In distributed mode the external_mac is known at attach time, so program the
host neighbour entry directly (ip neigh replace <ip> lladdr <mac> nud
reachable) instead of flushing. Inbound traffic refreshes the entry from
there. Centralized attach (no external_mac) and all detaches keep the flush.

Adds host.ReplaceNeigh + policy.NeighPrimer hook wired in vpcd alongside the
existing flusher.
2026-06-08 13:53:41 +10:00
Josh Siviour 65e2a3335e feat(eks): default K3s built-in ingress ON until aws-lb-controller ships (#318)
Workloads need a working ingress/servicelb path before the
aws-load-balancer-controller add-on lands. Flip builtinIngressEnabled to
default ON; opt out per-cluster for AWS parity with the CreateCluster tag
spinifex.io/managed-ingress=false. Interim divergence — revert to
default-off when the controller add-on ships.
2026-06-08 13:31:39 +10:00
tomnewton-mulga 0a70334349 Merge branch 'dev' of github.com:mulgadc/spinifex into dev 2026-06-08 13:16:50 +10:00
tomnewton-mulga a34da2c89c fix: Added H200 GPU model 2026-06-08 13:15:23 +10:00
joshsiv-mulga 5d125cdb26 fix(network): flush host neigh on EIP attach/detach for recycle reachability
Recycled WAN external IPs stayed unreachable for the kernel ARP timeout
(60-300s) when ovn-controller lacks the inject-garp appctl: the GARP refresh
after AddEIP is then a no-op, leaving the host neighbour cache pointed at the
prior owner's MAC.

Add host.FlushNeigh (ip neigh flush) and a policy.NeighFlusher hook wired
into AddEIP and DeleteEIP, so a recycled IP re-resolves L2 against the current
owner independent of inject-garp. DeleteEIP now takes the external IP (the
vpc.delete-nat event already carries it) to flush on release.
2026-06-08 12:44:21 +10:00
Josh Siviour e44221d653 feat(eks): public/private endpoint access + publicAccessCidrs-driven NLB scheme & SG (#315)
* feat(eks): public/private endpoint access drives NLB scheme + IP:port reachability

Parse resourcesVpcConfig.endpointPublicAccess/endpointPrivateAccess/
publicAccessCidrs (defaults public=true, private=false, cidrs=0.0.0.0/0) and
reject the both-disabled combination. Public access provisions an
internet-facing NLB and publishes its external frontend IP as the cluster
endpoint; private access keeps the NLB internal and uses the VPC frontend IP.

Inject the resolved endpoint IP into the k3s apiserver tls-san so the
published IP:443 endpoint serves a cert valid for verified TLS, and surface
the access fields in DescribeCluster. The e2e suite now connects via the
verified endpoint with TLS verification on, dropping the insecure mgmt-IP
shim.

* fix(eks): authorize NLB->apiserver ingress on control-plane SG

The cluster control-plane security group was created with empty ingress, so
the NLB's backing LB VM could not reach the apiserver on 6443 from inside the
VPC. The target group stayed unhealthy and the published endpoint never
served. Authorize tcp/6443 on the control-plane SG from the VPC CIDR after the
SGs are created; public exposure remains gated at the NLB front-end by
publicAccessCidrs, so this only opens the in-VPC LB->apiserver hop.

Extend SubnetVPCResolver with GetVPCCIDR so the service can resolve the VPC
CIDR for the rule.

* feat(eks): thread publicAccessCidrs to NLB SetLoadBalancerIngressCIDRs

Wire the cluster's publicAccessCidrs (parsed from CreateCluster
ResourcesVpcConfig) through EnsureClusterNLB to the ELBv2
SetLoadBalancerIngressCIDRs override so a public cluster endpoint can be
narrowed below the wide-open scheme default. The override only fires for
an internet-facing NLB whose CIDRs restrict below 0.0.0.0/0; internal
NLBs and the default keep the LB's out-of-the-box ingress.
2026-06-08 11:27:48 +10:00
Josh Siviour bd279fffa3 fix(elbv2): open NLB listener ports on a dedicated managed security group (#314)
NLB ENIs fell back to the VPC default security group, whose intra-SG-only
ingress made the OVN ACL drop inbound listener traffic at the front-end VIP
even with a healthy target group. NLBs reject customer SGs, so mint an internal
managed SG per network LB instead: attach it to every LB ENI at create, open
each listener port on it from the resolved client CIDRs at CreateListener,
close ports on DeleteListener, and delete the SG on DeleteLoadBalancer.

Default ingress CIDRs are scheme-based (internet-facing: 0.0.0.0/0; internal:
the VPC CIDR). SetLoadBalancerIngressCIDRs overrides them and re-authorizes
existing listeners, providing the hook for EKS publicAccessCidrs. ALBs are
unchanged.
2026-06-08 10:33:23 +10:00
Josh Siviour 467e4de715 feat(eks): publish eks-node AMI to R2 + spx catalogue entry (#313)
* feat(eks): publish eks-node AMI to R2 + spx catalogue entry

Add a spinifex-eks-node catalogue entry so operators install the EKS
node image with a single command:

  spx admin images import --name spinifex-eks-node

The entry pulls the Alpine+K3s image from iso.mulgadc.com, verifies its
sha256, and stamps spinifex:managed-by=eks — the tag the control-plane
and node-group launchers resolve the boot AMI by, so no extra config is
needed.

Add scripts/publish-system-image.sh (+ make publish-eks-node-image) to
convert the built raw to a compressed qcow2, emit a checksum sidecar,
and upload both to the R2 bucket backing iso.mulgadc.com/system-ami via
the S3 API.

* ci(eks): add manual workflow to build + publish eks-node AMI to R2

Build the eks-node AMI on the runner (libguestfs) and publish via
publish-system-image.sh using the existing CF_R2_* secrets. Manual
workflow_dispatch with a dry-run input. Fix the publish script's default
bucket to spinifex-iso (the bucket iso.mulgadc.com is served from).
2026-06-08 09:48:59 +10:00
Josh Siviour 5cf5cba7ac fix(microvm): load virtio_net dependency chain explicitly in init (#311)
The LB system microVM stripped its module tree to virtio + fw_cfg +
failover, but init.sh only loaded qemu_fw_cfg and virtio_net. virtio_net
depends on net_failover -> failover; modprobe resolves that chain via
modules.dep, and load_mod()'s insmod-by-path fallback loads a single .ko
with no dependency resolution.

When the image is built on a kmod-less host (depmod skipped, no
modules.dep) the chain can't be resolved either way, so virtio_net fails
to load, no NIC appears ('no interface found for MAC'), the lb-agent has
no network, heartbeats never reach AWSGW and every LB hangs in
provisioning until the 5m e2e timeout. Baremetal runners ship kmod so
modules.dep is generated and the chain resolves — which is why the LB
suite passed there but failed on normal CI.

Load failover -> net_failover -> virtio_net explicitly bottom-up so the
NIC comes up regardless of whether modules.dep exists.
2026-06-08 08:44:57 +10:00
Josh Siviour a1f3fcc531 ci(e2e): exclude eks suite from multinode job (#312)
EKS create-cluster fails deterministically on the multinode job with
ServiceUnavailableException: the EKS KV is a clustered (Raft) JetStream
stream and create-cluster needs an elected leader, but multinode
JetStream clustering isn't reliably forming yet (see mulga-omq/z2z/5yg).
EKS is validated single-node only at this stage.

Drop eks from the multinode build+run suite filters (alongside the
existing reboot/single exclusions) so the multinode job reflects what's
actually supported. Single-node continues to run the full eks suite.
2026-06-08 08:06:44 +10:00
Josh Siviour 4418913fa1 feat(elbv2): SetSubnets + RGT GetResources + EKS ingress toggle (LB-controller gaps) (#309)
* feat(elbv2): SetSubnets — full add+remove LB subnet re-homing

Add the ELBv2 SetSubnets operation across the interface, NATS subject,
daemon handler/subscription, and gateway route/forwarder.

Re-homing is a full add+remove: subnets new to the LB get a fresh managed
ENI, dropped subnets have their ENI deleted, then the backing system VM is
relaunched on the new ENI set. The system-instance launcher exposes only
Launch/Terminate (no live ENI hotplug for system VMs), so applying new
subnets relaunches the LB VM — a brief data-plane interruption. The record
is mutated only after the new ENIs exist and the VM is relaunched.

Extract a shared launchLBVM helper from CreateLoadBalancer so create and
SetSubnets boot the LB VM identically. Idempotent on no-op; record-only
fallback when no VPC service is wired.

Completes the elbv2 mutation-API surface (SetSubnets was the last gap).

* feat(tagging): resourcegroupstaggingapi GetResources for controller discovery

Add the Resource Groups Tagging API as a new gateway service (SigV4 scope
\"tagging\", AWS JSON 1.1 / X-Amz-Target, mirroring ACM). GetResources is a
gateway-side aggregator over the existing elbv2 and ec2 tag stores — no new
NATS subject or daemon handler.

It lists the account's load balancers and target groups (attaching tags via
batched DescribeTags) plus EC2 resources from the tag store, then applies the
request's ResourceTypeFilters (service-wide or fully-qualified) and TagFilters
(AND across filters, OR within values). Results are ARN-sorted with opaque
offset pagination.

This is how aws-load-balancer-controller v2 discovers and adopts the elbv2
resources it manages (filtering on its cluster/stack tags); DescribeLoadBalancers
has no tag-filter field, so RGT GetResources is the required path.

* fix(eks): emit canonical exception code, no doubled suffix

GenerateEKSErrorResponse appended \"Exception\" unconditionally, so codes that
already carry it (e.g. awserrors.ErrorEKSResourceNotFound =
\"ResourceNotFoundException\") serialized as ResourceNotFoundExceptionException.
SDK clients comparing eks.ErrCodeResourceNotFoundException then fail to match.
Append the suffix idempotently. Also covers the many other awserrors codes that
already end in \"Exception\" and flow through the shared JSON error envelope
(EKS, ACM, tagging).

* refactor(tagging): split GetResources fetch from orchestration for testing

Introduce a resourceLister seam: GetResources wires a NATS-backed lister, while
getResources (parse + tag-filter + sort + paginate) and buildEC2Mappings (tag
grouping + ARN synthesis) are now pure and unit-tested without a live backend.
No behaviour change.

* feat(eks): per-cluster opt-in K3s built-in ingress + enforce parity default

The runtime-rendered /etc/rancher/k3s/config.yaml emitted no disable: block, so
traefik + servicelb actually ran on every cluster — the AMI config.yaml.skel
that disables them is a never-applied boot fallback (cloud-init overrides it).

Render the disable block by default so traefik + servicelb are off for AWS
parity (Service type=LoadBalancer / Ingress are the AWS Load Balancer
Controller's job). A cluster created with tag spinifex.io/managed-ingress=true
keeps the K3s built-ins for interim in-VPC app exposure before the controller
add-on lands. The flag is derived into ClusterMeta.BuiltinIngress at
CreateCluster and threaded onto K3sServerInput.

* feat(ui): subnet editor on load balancer detail page (SetSubnets)

Add a Subnets tab to the LB detail page backed by the elbv2 SetSubnets
API. The editor mirrors the security-groups editor (checkbox list of the
VPC's subnets, dirty-tracking, Reset/Save) and warns that applying a
change re-homes the load balancer by relaunching its system VM, causing
a brief data-plane interruption.

* fix(elbv2): SetSubnets must detach ENIs before delete/relaunch + roll back on terminate failure

TerminateSystemInstance tears down the LB VM but leaves its ENIs marked
in-use, so the relaunch path could neither delete removed-subnet ENIs
(InvalidNetworkInterface.InUse leak) nor re-attach retained ENIs to the
relaunched VM (attach failed InUse, dropping the retained subnet's data
plane). Detach the existing ENIs explicitly after terminate — mirroring
DeleteLoadBalancer — before deleting removed ones and relaunching.

Also roll back the freshly-created added-subnet ENIs when the terminate
step itself fails, so a failed relaunch no longer leaks ENIs (amplified
by SDK retries).

* fix(daemon): re-arm system-instance terminate subscription on recovery

system.TerminateInstance.{id} was bound only by the system.LaunchInstance
handler at first launch, so it was lost across a daemon restart: recovery
reconnects to the surviving QEMU (or relaunches) but never re-bound the
subject, leaving system.TerminateInstance.{id} with no responder. Any LB
that predated the restart could then never be torn down — SetSubnets
relaunch and DeleteLoadBalancer both failed with 'no responders'.

Re-arm it in onInstanceUpHook, which fires on both the relaunch and the
reconnect-to-surviving-QEMU recovery paths, for ManagedByELBv2 VMs; drop
it in onInstanceDownHook. Split subscribeSystemTerminate into a locked
helper so the hook (which already holds d.mu) can reuse it.
2026-06-07 22:26:56 +10:00
Josh Siviour a4817b1afc ci: run baremetal E2E nightly + hard-gate releases on it (#306)
Make e2e-baremetal.yml a reusable workflow (workflow_call + a ref input)
so the nightly and release pipelines can invoke the real PXE-reimaged
hardware suite, which previously only ran on manual dispatch.

Nightly: add a baremetal job (conceptual cell 20) that runs every
weeknight on the existing cron, or on dispatch when 20 is selected. It
tests dev on schedule, matching GIT_BRANCH. Sits outside the tofu matrix
(different runner, provisioning, 120m timeout) and shares the
e2e-baremetal-cluster concurrency group, so all baremetal runs serialise
on the single physical node.

Release: a release builds and uploads an ISO to R2 but never booted that
ISO/squashfs path on hardware. Add a baremetal job (needs: release) and
hard-gate on it: promotion moves to a dedicated promote job
needs:[verify, baremetal] (with a matching mark-failed job on failure),
and the iso job's upload gate now waits for both verify and baremetal to
conclude success before pushing to R2. Bump the iso job timeout to 180m
since it now waits on the parallel ~2h baremetal run.
2026-06-07 22:14:21 +10:00
Josh Siviour 44d0548fd4 ci: make microVM depmod best-effort, drop apt kmod install (#308)
build-microvm-image required depmod (kmod) as a mandatory tool to
generate modules.dep. Self-hosted CI runners don't ship kmod and can't
reliably apt-install it, so the inline 'apt-get install kmod' steps added
to e2e/e2e-nightly/e2e-ddil were unreliable.

modules.dep is an optimisation, not a boot requirement: the guest init's
load_mod() (build/microvm/init.sh) falls back to insmod-by-path when
modprobe can't resolve a module. So:

- Drop depmod from the mandatory-tool preflight.
- Run depmod only when present; still assert a non-empty modules.dep in
  that case to keep catching the trixie-kmod empty-dep bug.
- Warn and skip when depmod is absent (guest uses insmod fallback).
- Remove the apt-get kmod install steps from all three workflows.

Gold-image bake still installs kmod (provision.sh), so production images
get a proper modules.dep; only kmod-less CI runners take the fallback.
2026-06-07 22:10:56 +10:00
Josh Siviour 8e0edef7c2 ci: install kmod (depmod) before microVM image build (e2e + nightly + ddil) (#307)
* ci(e2e): install kmod (depmod) before microVM image build

#305 made depmod a hard requirement in build-microvm-image.sh, but the
self-hosted ci-single/ci-multi runners don't ship the kmod package, so the
Build Install Artifacts step aborts with 'required tool not found: depmod'
before any E2E suite runs. Add an idempotent kmod install ahead of each
Build Install Artifacts step (single/multi/eks jobs); the gold-image builder
bakes kmod in too, so this no-ops once runners pick up the new image.

* ci: install kmod (depmod) in nightly + ddil too

e2e-nightly and e2e-ddil call the same build-install-artifacts.sh (-> make
build-microvm-image) on the same self-hosted ci-single/ci-multi runners as
e2e.yml, so they hit the identical 'depmod not found' abort. Add the same
idempotent kmod guard before Build Install Artifacts in both.

release distro builds run on GitHub-hosted ubuntu (kmod present); e2e-baremetal
runs on ci-baremetal which already has it (green today) — neither needs the
guard.
2026-06-07 21:57:03 +10:00
Josh Siviour 527308c342 fix(baremetal): empty modules.dep + capacity-aware single-node e2e (#305)
* feat(elbv2): localize internal-LB heartbeat failures for baremetal debug

Add diagnostics so a stuck-in-provisioning internal LB can be pinned to
one of three points on the heartbeat path:

- lb-agent failure log now carries the gateway target + region, so the
  microVM serial console shows whether the heartbeat hit a transport
  error (never reached AWSGW) or an HTTP-level rejection.
- AWSGW LBAgentHeartbeat logs receipt (debug) and warns on the
  previously-silent not-found/account-mismatch reject path, so the
  journal distinguishes 'heartbeat arrived but cannot transition' from
  'heartbeat never arrived'.

* test(e2e/lb): fail fast on stuck internal LB + probe fw_cfg on microvm boot

- lb suite: gate the remaining internal subtests on Internal_ALB. The
  internal LBs share one mgmt-return datapath and fixture, so if the
  first never reaches active the rest time out identically — skip them
  instead of burning ~5m per subtest (~20m saved on a known-bad run).
- microvm init.sh: widen the fw_cfg netcfg poll window and, on the
  not-available path, dump DMI / loaded modules / qemu_fw_cfg by_name
  listing / dmesg fw_cfg to the serial console before init exits, so a
  boot panic is diagnosable from the captured console log.

* fix(microvm): insmod fallback for fw_cfg + fail build on bad modules.dep

Baremetal-built microVM images shipped an unusable modules.dep (the build
host's depmod errors were swallowed), so busybox modprobe in the guest init
silently no-oped, qemu_fw_cfg never loaded, /sys/firmware/qemu_fw_cfg was
absent, init could not read opt/spinifex/netcfg, and the LB microVM panicked
before lb-agent ran — leaving internal LBs stuck in provisioning.

init.sh: load_mod() tries modprobe, then falls back to insmod by explicit
path (.ko/.ko.gz) which needs no modules.dep, so the guest boots regardless
of which host built the image.

build-microvm-image.sh: require depmod, drop the error swallow, and assert
modules.dep is non-empty after depmod so a boot-broken image fails the build
loudly instead of silently.

* fix(microvm): decompress kernel modules before depmod

Debian trixie's kmod 34 depmod silently emits an empty modules.dep when fed
Alpine's gzip-compressed (.ko.gz) modules — reproduced: 928 modules in, 0-byte
modules.dep out. The guest's busybox modprobe then no-ops, qemu_fw_cfg never
loads, and internal LB microVMs panic before lb-agent runs (stuck provisioning
on bare metal; GitHub-hosted runners ship a kmod that reads .gz, so normal CI
was unaffected).

Decompress kept modules to plain .ko after the keep-list strip, before depmod.
Host depmod then produces a usable map regardless of its gzip support, and the
guest loads modules without a decompressor.

* feat(e2e): capacity-aware scheduling for dense single-node runs

Denominate daemon schedulable capacity in physical cores instead of
logical threads, and auto-serialise e2e launches when the cluster is at
capacity, so a single hyperthreaded node stops over-packing guests.

Daemon: NewResourceManager sizes hostVCPU from physicalCoreCount()
(distinct physical-id:core-id pairs in /proc/cpuinfo) rather than
runtime.NumCPU(). Falls back to logical CPUs when topology is absent
(non-Linux, container, ARM cpuinfo without core id). On a hyperthreaded
box this halves the over-pack: with the unchanged 2 vCPU reserve the
i5-12600H schedules 10 vCPU (12 cores - 2) instead of 14, keeping the
boot-spike CPU within real cores.

Harness: capacityRetryEC2 decorates the EC2 client and, on
InsufficientInstanceCapacity, waits and retries RunInstances instead of
failing — turning the daemon admission gate into back-pressure so
concurrent subtests queue. AWSClient.EC2 is now the wrapped
ec2iface.EC2API; EC2Conf keeps the raw *ec2.EC2 for .Config access.

Adds unit tests for parsePhysicalCores (hyperthreaded/hybrid/dual-socket/
fallback) and the retry decorator (retry-then-succeed, non-capacity
passthrough, deadline).

* fix(daemon): SPINIFEX_HOST_VCPU override so small CI hosts can start

Denominating capacity by physical cores starves tiny hosts: a 2-physical-
core CI runner leaves 0 schedulable after the default 2-vCPU reserve, so
NewResourceManager refuses to start and every daemon-constructing unit test
fails. The previous runtime.NumCPU() count (4 logical on the same runner)
incidentally cleared the reserve floor.

Add a SPINIFEX_HOST_VCPU escape hatch (symmetric with the existing
SPINIFEX_RESERVED_* overrides) that pins the detected core count for hosts
whose /proc/cpuinfo topology misreports, and pin it to 4 in the daemon
TestMain so unit tests are deterministic regardless of runner topology.
Reserve stays at the default 2.
2026-06-07 21:34:39 +10:00
Josh Siviour 496424c41c test(eks): EKS control-plane E2E suite (Sprint 6g) (#304)
* test(eks): EKS control-plane e2e suite (Phase 1, API-level)

Add tests/e2e/eks/eks_test.go driving the EKS lifecycle against awsgw:
CreateCluster -> ACTIVE (endpoint + CA asserted), kubeconfig artifact
assembly, AccessEntry CRUD, get-token presign decode, DeleteCluster -> gone.

Harness: EKS SDK client on AWSClient + WaitForEKSCluster{Active,Deleted}
pollers. Wire e2e-eks into the Makefile and the single-node CI suite list.

* test(eks): Phase 2 kubectl get-nodes data-plane gate

Add the reachability shim (harness/eks_mgmt.go) that reads the control-plane
VM's br-mgmt IP from the cluster KV meta over NATS, plus a kubectl wrapper
(harness/kubectl.go) that skips when kubectl is absent. KubectlGetNodes points
kubectl at https://<mgmtIP>:6443 (insecure-skip-tls-verify; cert SANs the NLB
DNS, not the mgmt IP) with an aws eks get-token exec block and polls until the
control-plane node is Ready — the get-token webhook auth regression gate.

IRSA pod + the real NLB/Route53 passthrough are deferred to a follow-up.

* test(eks): tolerate doubled NotFound code + dial loopback NATS

DeleteCluster detection matched the SDK constant ResourceNotFoundException,
but awsgw emits ResourceNotFoundExceptionException on the wire — match the
NotFound code by substring so deletion is detected immediately instead of
timing out. The mgmt-IP shim dialed NATS.Host verbatim (0.0.0.0:4222), which
the serving cert does not SAN; rewrite the wildcard bind to loopback.

Validated live: all 6 subtests green, kubectl get nodes shows the
control-plane node Ready via the get-token webhook auth path.

* feat: build system images with libguestfs (no host block devices)

Replace the qemu-nbd + sudo mount + chroot + /dev-bind flow in
build-system-image.sh with libguestfs (virt-customize / virt-resize /
guestfish). Every operation runs in an isolated userspace appliance VM with
its own kernel, so the build touches no host block device, no host mount, and
needs no sudo — removing the failure class that wiped the host's /dev on shared
CI runners (a leaked /dev bind traversed by rm -rf).

Applies to all distros: alpine resizes via guestfish e2fsck+resize2fs, ubuntu
via virt-resize; customization (packages, binaries, files, setup script,
service enable, cache cleanup) is one ordered virt-customize invocation.
LIBGUESTFS_BACKEND=direct (no libvirt on runners).

Wire the eks-node AMI into the single-node e2e job: the runner builds the
device-free raw, ships it to the VM, and the VM imports it (predastore/NATS are
VM-only) so the eks suite's CreateCluster has an AMI to boot.

* feat(eks): bootstrap + state-report via AWS gateway broker

Converge the EKS control-plane → host channel onto the ELBv2 lb-agent model:
the K3s server VM publishes its bootstrap envelopes and periodic state reports
via SigV4-signed HTTPS POST to the AWS gateway, instead of dialing core NATS
directly. A new gateway action (PublishInternal, POST
/clusters/{name}/internal-publish) relays the payload verbatim onto the
existing eks.bus.* / eks.state.* subjects, so the daemon-side subscriber, KV
persistence/encryption, and reconciler are unchanged.

This removes the VM's need to reach NATS at all, so NATS can stay
cluster-internal behind its bind/firewall/mTLS controls — the prior direct
path broke when NATS was not VM-reachable (e2e CreateCluster timeout) and was
DDIL-fragile (fire-and-forget nats pub, no ack/retry).

- gateway/eks: PublishInternal broker + route; closed-set channel/kind validation
- cmd/eks-gateway-publish: one-shot SigV4 publisher (retry-until-ack) baked into
  the eks-node AMI; replaces the nats-cli publisher in k3s-first-boot.sh and
  mulga-eks-state-report.sh
- handlers/eks + daemon: hand the K3s server VM the mgmt-reachable gateway URL +
  system (Predastore) SigV4 creds + gateway CA instead of NATS URL/token/CA;
  new resolveSystemGatewayBaseURL (mgmt-bridge-first)
- eks-node image: build/install eks-gateway-publish, drop nats-cli download

* feat: migrate eks-token-webhook to the AWS gateway broker

The TokenReview webhook dialed core NATS directly (STS verify + AccessEntry
KV read). On a mgmt-only control-plane VM with NATS bound off-subnet that
fails, so the webhook exited at startup, the apiserver token-webhook
kubeconfig was never written, and the apiserver crash-looped — the cluster
never reached ACTIVE.

Converge it onto the same gateway-broker model as bootstrap/state-report and
ELBv2's lb-agent: the webhook SigV4-signs an HTTPS POST {accountId,token} to
/clusters/{name}/token-review, and the gateway runs the verify + lookup
host-side and returns the resolved identity. The VM now speaks no core NATS.

- internal/eksgw: shared on-VM SigV4 client (also adopted by
  eks-gateway-publish).
- handlers/eks/token_review.go: Authenticate decision core (moved from the
  webhook binary) + ResolveTokenReview host-side wiring.
- gateway/eks token-review route + WebhookTokenReview handler.
- webhook: drop NATS, relay via eksgw.Client; broker fault -> 503 (retryable)
  not a silent deny.
- initd: export EKS_GATEWAY_* / EKS_ACCESS_* creds, not SPINIFEX_NATS_*.

* fix: build eks-node helpers with GOFIPS140; cover token-review broker

eks-gateway-publish blank-imports internal/fipsboot, whose init() panics
unless the binary is built with GOFIPS140=v1.0.0. The eks-node AMI build
omitted the flag, so the binary paniced at first boot right after k3s came
up — k3s-first-boot (set -eu) died before publishing the bootstrap token and
the cluster never reached ACTIVE. Build both eks-node helpers with
GOFIPS140=v1.0.0, matching cmd/lb-agent.

Add NATS-backed unit tests for ResolveTokenReview and the WebhookTokenReview
gateway handler (verify responder + account KV) to cover the new broker path.

* fix: export first-boot env so eks-gateway-publish child sees it

k3s-first-boot.sh and mulga-eks-state-report.sh sourced /etc/spinifex-eks/
first-boot.env but never exported the vars, so the eks-gateway-publish child
process inherited an empty environment and exited '--account-id is required'.
The control-plane VM came up healthy but never published its bootstrap
envelopes, so the cluster never reached ACTIVE. Wrap the source in set -a /
set +a so the KEY=value lines export to child processes.

* fix: make awsgw start --host override actually bind

The 'awsgw start --host' override printed 'Overwriting awsgw host' but the
assignment was commented out, so AWSGW always bound the spinifex.toml host
(a specific WAN IP, e.g. 192.168.0.10). A system instance (EKS K3s control
plane, internal LB agent) has a mgmt-only NIC on-link to the br-mgmt IP with
no route to the WAN IP, so its SigV4 POSTs to the gateway were refused and the
cluster never bootstrapped. Honour --host (e2e passes 0.0.0.0:9999) so AWSGW
listens on all interfaces incl. br-mgmt; the serving cert already SANs every
local interface IP, and WAN clients still reach it.

* test(eks): install kubectl on e2e VM so KubectlGetNodes runs

The eks data-plane subtest (TestEKS/KubectlGetNodes) drives the full
aws eks get-token -> kubectl -> apiserver -> token-webhook -> gateway broker
-> STS verify path — the only end-to-end exercise of the token-review broker.
The e2e binaries run on the VM, where kubectl was absent, so
harness.NewKubectl t.Skip'd it and the path went unvalidated. Install a
checksum-verified kubectl (pinned to the k3s server version) on the VM,
gated on e2e-eks.
2026-06-07 20:42:25 +10:00
joshsiv-mulga 609471c448 Revert "fix(elbv2): internal single-node LB agent dials br-mgmt IP"
This reverts commit 1ce9981c1a.
2026-06-07 09:22:11 +10:00
joshsiv-mulga 1ce9981c1a fix(elbv2): internal single-node LB agent dials br-mgmt IP
Internal-scheme LBs on single-node have no dedicated mgmt-bind IP on
br-mgmt, so resolveMgmtRoute falls back to routing the agent to the
advertised WAN IP via the mgmt NIC. That advertised IP lives on a
different bridge than the agent's mgmt NIC, and the cross-bridge return
path never delivers — the agent never heartbeats and the LB hangs in
provisioning (normal single-node dodges this entirely via dev_networking
slirp at 10.0.2.2; bare-metal external-mode is the first real exercise of
this path).

Point the agent at the br-mgmt host IP, which shares its mgmt L2 — no
cross-bridge hop — mirroring the multi-node datapath. Requires AWSGW to
listen on br-mgmt (bind 0.0.0.0); the server cert already SANs that IP.
2026-06-07 08:49:37 +10:00
joshsiv-mulga 63fd8b9f66 fix(elbv2): fail loud when internal LB has no mgmt return route
Internal-scheme LBs only leave provisioning once the lb-agent heartbeats
AWSGW, which requires the mgmt return route resolved by resolveMgmtRoute
(MgmtRoute{Gateway,Target}, or MgmtBridgeIP+AdvertiseIP on single-node).
When none resolve, the route was silently skipped and the LB hung in
provisioning until the client timed out, with no signal as to why.

Log which precondition is empty on the internal skip path, and mark the
LB failed instead of provisioning when no return route exists, so the
broken data-plane surfaces immediately.
2026-06-06 23:04:47 +10:00
joshsiv-mulga 92384cf7a7 fix(vpcd): create IMDS subnet-veth KV bucket at 1 replica
InitBuckets was passed the OVN chassis count as the replica factor. When the
chassis count exceeds the NATS node count (single-node bare-metal: 1 node but
2 SBDB chassis), CreateKeyValue requested more replicas than JetStream could
satisfy, the create-or-open fallback returned "bucket not found", and vpcd
crash-looped — cascading to RunInstances ServerInternal. Create at 1 replica
like STS/IAM; the daemon's deferred upgradeJetStreamReplicas raises all KV_*
streams to cluster size when the cluster grows.
2026-06-06 22:13:19 +10:00
Josh Siviour 5aaf8c23d2 fix(eks): make aws eks get-token → kubectl auth work end-to-end (Sprint 6f) (#303)
* feat(eks): webhook observability — console log sink + per-request TokenReview decision

* fix(eks): wire token-webhook authenticator into generated k3s apiserver config

The generated /etc/rancher/k3s/config.yaml overrides the AMI-baked
config.yaml.skel, but its kube-apiserver-arg list omitted the
authentication-token-webhook-config-file arg. The running apiserver
therefore never loaded the token webhook, so bearer tokens minted by
'aws eks get-token' 401'd without the webhook ever being invoked.

* fix(eks): echo TokenReview audiences so api-audiences apiserver accepts the principal

With --api-audiences set, kube-apiserver sends spec.audiences in the
TokenReview and discards an authenticated=true response that does not
confirm a matching audience. The webhook returned an empty
status.audiences, so a valid IAM principal surfaced as a 401. Echo the
request's audiences on success.

* fix(eks): pin token-webhook version v1 so apiserver accepts the response

kube-apiserver --authentication-token-webhook-version defaults to
v1beta1, but the eks-token-webhook responds with an
authentication.k8s.io/v1 TokenReview. The apiserver decodes the response
against the expected GVK and rejects the mismatch, surfacing a valid
principal as a 401. Pin v1 to match the webhook.
2026-06-06 22:11:50 +10:00
joshsiv-mulga d8aa31e699 ci(baremetal): capture combined log + junit + analyze bundle on failure
Bare-metal e2e produced no artifact, so RCA fell back to truncated
log-failed output. Tee bootstrap + e2e output to test-baremetal.log,
generate junit-baremetal.xml, run e2e-analyze to emit analysis.md, and
upload the bundle as e2e-baremetal-artifacts-<run_id>.
2026-06-06 13:01:32 +10:00
joshsiv-mulga f4ba115316 ci(baremetal): revert e2e to single-node 2026-06-06 12:33:47 +10:00
Josh Siviour 597b18b2a0 fix(eks): map transient JetStream errors in ListClusters to ServiceUnavailable (#302)
aws eks list-clusters returned InternalErrorException for a few seconds after a
daemon restart, while describe-cluster (by name) succeeded. During the
post-restart window the per-account KV stream's Raft group has no leader yet, so
kv.Keys() (an ordered consumer over the warming stream) fails with a transient
NATS error. ListClusters wrapped it as a fmt.Errorf string the daemon's
ValidErrorCode cannot map, so it sanitized to ServerInternal — a misleading 500
that tells AWS clients the request is non-retryable.

Classify transient NATS conditions (no-responders, timeout, no-stream-response,
connection-closed) and return a retryable ServiceUnavailable (503) instead;
genuine faults still pass through wrapped. describe-cluster survived only
because kv.Get on the same bucket is cheaper than the consumer kv.Keys spins up.
2026-06-06 12:03:51 +10:00
Josh Siviour ca2418ffa7 feat(eks): OIDC provider CRUD + IRSA discovery + anonymous STS (6e) (#301)
* feat(iam,awsgw): OIDC provider CRUD + IRSA discovery endpoints (6e)

Complete the AWS-SDK-facing half of IRSA:

- IAM Create/Get/List/DeleteOpenIDConnectProvider, backed by the per-account
  oidc-providers registry that STS AssumeRoleWithWebIdentity already reads to
  authorise an issuer. Create writes the key STS checks; the provider ARN is
  the scheme-stripped issuer per AWS convention.
- awsgw serves the per-cluster OIDC discovery document
  (/.well-known/openid-configuration) and JWKS (/keys) as unauthenticated
  public endpoints, mounted ahead of the SigV4 surface in their own route
  group. The discovery issuer is the cluster's stored OIDCIssuer, byte-exact
  with the apiserver iss claim; JWKS is served from the EKS account bucket.

Unit + route tests cover CRUD happy/idempotent/not-found/invalid-url, the
ARN<->issuer round-trip, discovery issuer/jwks_uri equality, JWKS bytes, 404s,
and the no-auth bypass. make preflight green.

* fix(awsgw): route anonymous AssumeRoleWithWebIdentity past SigV4 (6e)

AssumeRoleWithWebIdentity is the IRSA credential-bootstrap call: the AWS SDK
sends it unsigned because the caller holds no AWS credentials, only a projected
ServiceAccount JWT in the body. The gateway ran the SigV4 middleware first and
rejected it with MissingAuthenticationToken, so the true anonymous path never
reached the handler.

Add anonymousSTSInterceptor, a pre-auth middleware mounted ahead of the SigV4
surface. For an unsigned POST it body-peeks the Action and, for the
anonymousSTSActions set, dispatches straight to STS_Request; STS_Request now
skips SigV4-caller resolution for those actions (the handler authenticates the
JWT and gates on the role's web-identity trust policy). Signed requests carry an
Authorization header and pass through untouched, off the body-buffering path.

Surfaced by the 6e live exit gate: a real in-pod assume-role-with-web-identity
would otherwise have been rejected before reaching token validation.
2026-06-06 11:32:51 +10:00
Josh Siviour a22bc2f716 feat(eks): 6d nodegroup provisioning + NATS-gated cluster health (#300)
* feat(eks): nodegroup provisioning + K3s agent join (6d)

CreateNodegroup launches N customer-owned worker EC2 from the unified
eks-node AMI in agent role; each joins the cluster via K3S_URL + K3S_TOKEN
baked into cloud-init (first-boot.env SPINIFEX_K3S_ROLE=agent + agent.env).
Workers are customer-owned and DescribeInstances-visible (no ManagedBy,
no mgmt NIC) via the normal RunInstances path, matching real EKS.

K3S_URL targets the control-plane ENI's in-VPC private IP on :6443 — the
cluster NLB DNS endpoint is not resolvable in-VPC yet; switch to the NLB
endpoint on :443 once authoritative in-VPC DNS lands. K3S_TOKEN is the
decrypted cluster node-token persisted at server bootstrap.

Describe/List/Update(scaling)/Delete round out CRUD. Cluster<->nodegroup
SG ingress rules (apiserver 6443, flannel VXLAN 8472/udp, kubelet 10250,
intra-nodegroup) authorized idempotently. UpdateNodegroupVersion deferred.

* feat: gate EKS cluster health via NATS state reports

The K3s apiserver binds the VPC node-ip, unreachable from the host-side
spinifex daemon, so the reconciler cannot probe /healthz directly. Add a
control-plane state reporter that publishes {healthz, node_count} over the
management NATS bus the daemon already shares.

- eks-node server role runs mulga-eks-state-report as an OpenRC service,
  publishing eks.state.{account}.{cluster}.server every 30s.
- ClusterReconciler subscribes to that subject, gates CREATING -> ACTIVE on
  a healthy report, and records Ready node_count into ClusterMeta.
- node_count counts Ready nodes only; k3s retains terminated workers as
  NotReady until pruned, so a raw count never drops on scale-down.
2026-06-06 00:52:18 +10:00
joshsiv-mulga 3bb90c9fd3 fix(ci): guard release Clean Workspace against leaked iso-builder devtmpfs
The iso job mounts a kernel-global devtmpfs under the workspace
(.cache/rootfs/dev). A killed job skips its EXIT-trap unmount and leaks
the bind; the verify job (same ci-single runner) then ran a bare
'rm -rf $WORKSPACE/*' that could traverse it and wipe the host /dev.

Detach any mount under the workspace before deleting and add
--one-file-system so rm never crosses a mount boundary. Same backstop
added to the iso job's own cleanup.
2026-06-05 22:58:07 +10:00
joshsiv-mulga da26b1db69 fix(ci): stop image-build /dev bind from wiping host /dev
A killed ubuntu system-image build can leave `mount --bind /dev` under
its build dir. The e2e Clean Workspace step then ran
`sudo rm -rf "$GITHUB_WORKSPACE"/*` as root, traversed the leaked bind,
and deleted the host's /dev (incl. /dev/null), wedging the runner.

- e2e.yml: umount anything under the workspace (deepest-first) before
  deleting, and pass `rm --one-file-system` so rm can never cross a mount
  boundary into host /dev. Hard backstop, applied to all 3 Clean Workspace
  steps.
- build-system-image.sh: recursive lazy unmount in cleanup() detaches the
  chroot binds even when busy, and mark the build subtree rprivate so the
  /dev bind can't propagate into the host namespace.
2026-06-05 22:52:49 +10:00
Josh Siviour f9c08130ba fix(eks): embedded etcd datastore + Alpine IMDS-route fix + CP-VM visibility (#299)
* fix: switch EKS datastore to embedded etcd + fix Alpine cloud-init IMDS route

Flip the K3s control-plane datastore from SQLite/kine back to embedded etcd
(cluster-init: true, etcd-expose-metrics: true). The earlier abandonment
premise — that etcd's per-commit fsync stalls past the apiserver's 5s deadline
on the viperblock-backed root volume — was wrong: etcd inits fine on an
unchanged viperblock (apiserver ready 25-30s, clusters reach ACTIVE). The real
blocker was a network-config regression, fixed here.

Alpine's cloud-init eni renderer crashes (KeyError: 'gateway') on the
gateway-less IMDS on-link route, which failed the whole network-config render
so the static mgmt NIC never came up (no NATS bootstrap, no /healthz). Fixes:

- generateNetworkConfig: add emitIMDSRoute bool; selectNetworkConfigForFamily
  passes false for Alpine so the eni renderer never sees the route.
- Deliver the IMDS route out-of-band via a persistent /etc/local.d script +
  OpenRC local service. Resolve the VPC NIC via its default route at runtime
  (ip route show default), not a hardcoded name: the persistent-net rename to
  vpc0 doesn't apply on the Alpine AMI, so the NIC is eth0 at first boot.
- EKS path bakes the same route into buildK3sUserData's own write_files/runcmd;
  the generic template Alpine block fires only when no #cloud-config user-data,
  avoiding a duplicate write_files key (yaml.safe_load keeps last, silently
  dropping a block).
- eks-node image: config.yaml.skel adds cluster-init; comments updated.

* fix: surface CreateCluster step failures at ERROR + map subnet faults; add auth-mode/delete test cases

CreateCluster wrapped its step failures in bare fmt.Errorf that the request
surface collapses to an opaque ServerInternal ("contact AWS re:Post"), so the
real cause never reached the operator. Add logCreateErr to log each step's
precise cause at ERROR before returning, and map a subnet-resolve failure
(caller-supplied parameter) to InvalidParameterValue instead of a retryable
ServerInternal the SDK pointlessly retries. (mulga-siv-165.4)

Tests (mulga-siv-165.15): reject authenticationMode=API_AND_CONFIG_MAP (only
the CONFIG_MAP value was covered); DeleteCluster on a nonexistent cluster via
the full-deps path returns ResourceNotFound (the shim path short-circuits to
ServiceUnavailable before the meta lookup).

* fix: hide EKS control-plane VMs from customer DescribeInstances

EKS K3s control-plane VMs are owned by the customer account (their ENI
lives in the customer VPC for the NLB target), so unlike LB microVMs
(owned by the system account) they leaked into customer
ec2:DescribeInstances. The spinifex:managed-by tag only filtered the UI
Nodes page, not the EC2 API.

Add a caller-aware exclusion: skip any ManagedBy-tagged VM unless the
caller is root. Preserves operator visibility, matches LB behaviour, and
leaves future customer-owned worker nodes (no ManagedBy) visible.

* docs: correct stale SQLite datastore comments in eks-node image (now embedded etcd)
2026-06-05 17:55:31 +10:00
tomnewton-mulga 6be3ccbb23 fix: unify .iso and .iso.sha filenames 2026-06-05 17:07:26 +10:00
tomnewton-mulga 158b3a25c5 fix: updated release.yml for clean iso uploads 2026-06-05 16:48:40 +10:00
Julian Sommer 4f84586263 feat(ui): IAM Roles + Instance Profiles 2026-06-05 15:41:05 +10:00
Julian Sommer 958a9cb6c1 chore: fix docs 2026-06-05 14:35:02 +10:00
Julian Sommer 0ee9b46e38 chore: rebuild ui 2026-06-05 14:12:47 +10:00
Julian Sommer 92d76b5bce fix(ui): auto-recover from stale credentials with login redirect (#297)
(cherry picked from commit 2d21232d02)
2026-06-05 14:10:19 +10:00
Josh Siviour cf8597a18e feat(ui): VPC-create AWS-flow alignment — SG launch step, public-IP, NAT Gateway + Elastic IP (#294)
(cherry picked from commit dc1b87f191)
2026-06-05 14:10:11 +10:00
Julian Sommer 00d1142a02 chore: cleanup docs 2026-06-05 13:53:34 +10:00
tomnewton-mulga 412eb618b0 Adding GPU passthrough e2e tests - intended to be run on Wattle 2026-06-05 13:47:11 +10:00
Julian Sommer 77b3bbd7b7 fix: add route table to VPC docs 2026-06-05 11:39:33 +10:00
Julian Sommer 2d21232d02 fix(ui): auto-recover from stale credentials with login redirect (#297) 2026-06-05 11:25:15 +10:00
Julian Sommer 81c8d62859 fix: add SG guide to docs 2026-06-05 10:38:17 +10:00
joshsiv-mulga 0c76161a89 fix(elbv2): stop lb-agent hanging on nginx start (NLB targets stuck 0/N)
reloadNginx started nginx via cmd.CombinedOutput(). nginx daemonizes, and the
rendered NLB config uses `error_log stderr`, so the forked master and workers
inherit and hold this command's stderr open. CombinedOutput's Wait blocks on
stderr EOF, which the long-lived daemon never sends — so the call never returns,
wedging the agent's synchronous tick() before it applies the nginx engine or
reports health. NLB targets stayed at 0/N until the e2e health wait timed out,
while the data plane (nginx itself) ran fine — so manual smoke tests passed.

ALB is unaffected: haproxy -D redirects its stdio to /dev/null on daemonize, so
CombinedOutput sees EOF and returns.

Capture nginx start/reload stdio to a temp file and use cmd.Run(), so Wait
blocks only on the short-lived foreground process that exits once daemonized.
Surfaced via the LB microVM guest console now collected in CI.
2026-06-05 10:13:08 +10:00
Josh Siviour dc1b87f191 feat(ui): VPC-create AWS-flow alignment — SG launch step, public-IP, NAT Gateway + Elastic IP (#294) 2026-06-05 09:56:32 +10:00
Julian Sommer 70e9746e06 chore: tags cleanup 2026-06-05 09:33:22 +10:00
joshsiv-mulga af690b3299 test(elbv2): collect LB microVM guest console in CI + surface NLB probe failures
NLB target health stalls at 0/N in CI while the data plane works locally.
The lb-agent's nginx engine activation, any nginx -t/reload error, and its
active-probe results are logged to the LB microVM serial console — which the
host journal never captures and the GetConsoleOutput API path reports as
NotFound, so the failure is undiagnosable from CI artifacts.

- e2e.yml: pull /run/spinifex/console-*.log off each node in the single- and
  multi-node collect steps (files persist past VM teardown) into lb-console/.
- lbagent healthcheck: log probe failures at Warn (was Debug) so unreachable
  targets surface at the agent's default level on the captured console.
2026-06-05 09:30:24 +10:00
Julian Sommer dae0b521da fix: remove falltrhough code owners 2026-06-05 09:08:42 +10:00
Julian Sommer c666c8146d fix: e2e.yml 2026-06-05 09:06:55 +10:00
joshsiv-mulga 8e38faaede fix(elbv2): make NLB nginx health-check pathway robust in CI
E2E NLB suites timed out at "0/2 healthy" while ALB suites on the same
target instances went healthy in ~5s. The host daemon journal shows the
NLB agent reporting an empty server list (no probe results at all), which
happens when the lb-agent never enters nginx mode — i.e. `nginx -t`/start
fails closed inside the LB microVM, so the agent never probes and reports
nothing. The data plane itself is verified working; this is purely the
active-health-check pathway failing to converge in CI.

- lbagent: pre-create nginx runtime prefix dirs (pid dir + /var/lib/nginx,
  /var/lib/nginx/tmp, /var/log/nginx) before `nginx -t`, mirroring
  reloadHAProxy's socket-dir pre-create. A stripped Alpine initramfs may
  lack these, dead-ending the whole NLB health path.
- build-microvm-image.sh: assert the nginx binary and ngx_stream_module.so
  are present in the image and fail the build loudly, instead of surfacing
  as an opaque e2e health timeout.
- e2e: capture the LB VM serial console to the artifact bundle on NLB
  health-wait failure. The agent's engine activation and any `reload nginx:`
  error land on the guest console, which the host journal never sees.
2026-06-05 08:14:18 +10:00
Josh Siviour 554c5d96c7 feat(elbv2): NLB L4 data plane on nginx stream + UI parity (#295)
* feat(elbv2): NLB L4 data plane on nginx stream (mulga-siv-952)

Network load balancers render an nginx `stream` config (TCP/UDP/TLS/
TCP_UDP) instead of HAProxy, which cannot load-balance UDP in any
release. The gateway tags GetLBConfig with Engine=nginx; the LB agent
dispatches to the nginx engine (config/pid/cert paths + reload) and
skips HAProxy stats polling. ALB stays on HAProxy. nginx is added to
the microvm image.

* docs(elbv2): note NLB runs nginx stream, ALB runs HAProxy (mulga-siv-952)

* fix(elbv2): load nginx stream module for NLB data plane

Alpine's `nginx` apk is built without the stream module — it ships
separately as `nginx-mod-stream`. Without it, the generated NLB config
fails `nginx -t` with [emerg] unknown directive "stream", so the agent
never applies it. Add the package to the microvm image and emit a
`load_module .../ngx_stream_module.so;` directive at the top of the
generated config.

* fix(elbv2-ui): allow single-subnet LB + full NLB protocols in listener edit

Create-LB form required min 2 subnets while the CLI/backend accept 1;
relax to min(1) to match. Listener add/edit only offered HTTP/HTTPS —
add the NLB protocol set (TCP, UDP, TLS, TCP_UDP), driven by LB type,
with cert/ssl-policy fields gated on secure protocols (HTTPS/TLS).

* feat(elbv2): active target health checks for nginx NLBs

nginx stream has no active upstream probing, so NLB targets never
advanced past initial — DescribeTargetHealth stayed initial forever and
WaitForTargetsHealthy timed out for every NLB. GetLBConfig now delivers
per-target HealthTarget specs (daemon-computed server name, probe
address, protocol/path) for network LBs; the lb-agent probes each target
every tick (TCP dial or HTTP/HTTPS GET) and reports UP/DOWN via the
existing heartbeat path, so the daemon's threshold hysteresis advances
targets to healthy. ALBs are unchanged (HAProxy stats).

Adds an Internal_NLB_UDP e2e suite gating on WaitForTargetsHealthy plus a
UDP datagram round-trip through the VIP.
2026-06-05 00:11:51 +10:00
Josh Siviour d044a4c222 feat(vm): ENI hot-plug Sprint 3c — real OVS tap wiring (#296)
* test: cover Sprint 3b ENI hot-plug surface

Sprint 3b's gateway + daemon + KV-helper additions landed without unit
tests. dev's diff-coverage gate flipped to 28.5% as a result. This adds
the three test files needed to bring the diff coverage of the ENI
hot-plug surface back above the 60% threshold.

- handlers/ec2/vpc/eni_hotplug_helpers_test.go: GetENIRecord, UpdateENI,
  FindENIByAttachment — happy paths, not-found, account scoping,
  round-trip of every new ENIRecord field.
- gateway/ec2/vpc/network_interface_hotplug_test.go: Attach + Detach
  validation, no-responders, NATS roundtrip with command capture,
  daemon error / malformed responses, attachment-resolver branches.
- daemon/daemon_handlers_eni_test.go: eniHotplugErrorCode table,
  publishENIHotplugEvent (nil + publish), full handleAttach /
  handleDetach matrix (nil data, not-running, ENI/attachment not found,
  wrong owner, success with stub QMP + NATS event assertion, hot-plug
  failure + KV rollback assertion).

vm.SetHotPlugTestSeams is added as an exported test seam following the
same pattern as utils.SetSudoCommandForTest so daemon-package tests can
swap newDeviceController + eniPipelineSleep without reassigning vm-package
unexported vars.

* feat(vm): wire real OVS tap into ENI hot-plug pipeline

Sprint 3c: replace the stubTapAdd/stubOVSAddPort/stubTapDel/stubOVSDelPort
placeholders with the shared NetworkPlumber. SetupTap creates the kernel tap
and attaches it to br-int with the OVN iface-id binding in one atomic step;
CleanupTap reverses both on rollback and detach. Nil-plumber managers stay a
noop, mirroring setupExtraENINICs.

Adds fake-plumber coverage: tap-on-br-int spec assertion, tap-setup failure
aborts before any QMP call, and QMP/detach paths both invoke CleanupTap.
2026-06-04 23:42:31 +10:00
joshsiv-mulga eef337fb80 test(e2e): make egress baseline reproduce the real IGW gap (fresh VPC)
The previous new-subnet egress baseline used the default VPC, whose main
route table is prebaked with 0.0.0.0/0 -> IGW at bootstrap and reconciled by
the vpcd drift loop, so a subnet created in it is reachable regardless of the
create-time gap — the test passed and never flagged the bug.

Replace it with runNewVPCEgressBaseline: a brand-new VPC where the IGW route
is added to the MAIN route table BEFORE the public subnet is created. The
subnet then implicitly joins a main RT that already carries the route, but
CreateSubnet does not recompute per-subnet egress, so the subnet's OVN egress
LRP is never installed and the instance has no datapath to the IGW. With an
open SG, routing is the only barrier, so the instance is unreachable — red
until CreateSubnet triggers a per-subnet egress recompute.

PoolMode-gated like runVPCSubnetE2E (fresh VPC needs OVN IPAM for a public
IP). runVPCSubnetE2E doesn't catch this: it associates a custom RT then adds
the route, so the route mutation enumerates the subnet and installs the LRP.
2026-06-04 20:14:32 +10:00
joshsiv-mulga c9a4e904a8 test(e2e): fresh-install reachability baselines + drop qemu hostfwd fallback
Add baseline tests that confirm two fresh-install regressions the existing
suite masks (it authorizes tcp/22 on the default SG before every SSH probe):

  - default SG default-deny gates external reach of instances in the
    default subnet (SG, not routing)
  - a newly-created public subnet never gets its OVN IGW egress LRP, so it
    has no route to the VPC IGW despite MapPublicIpOnLaunch

Plus a positive test: instances sharing the default SG can communicate via
ICMP (same-host single-node, cross-host multinode).

Single-node baselines run after TestClusterStatsCLI (0-VM gate); multinode
after TestMultinodePreflight (fixture init). Instances are self-cleaning so
they don't perturb the singleton-VM ordering.

Remove the qemu hostfwd SSH fallback from the harness (InstancePublicSSHHost,
GuestSSHEndpoint) - it bypasses the OVN datapath and masks these regressions.
Endpoints now t.Fatal when no public IP is assigned.
2026-06-04 19:56:02 +10:00
Julian Sommer ff0dc42562 feat: IMDS subnet level (#292) 2026-06-04 16:33:45 +10:00
Josh Siviour 50bc38c8fd feat(ui): NLB support in create/edit load balancer + target group flows (#293)
* feat(ui): NLB support + protocol/type-dependent create-LB fields

The Create Load Balancer page was hardcoded to ALB. Add a load-balancer
Type selector (application/network) and switch the listener + target-group
form options on the selected type, mirroring the server's validation:

- ALB: HTTP/HTTPS listeners and target groups.
- NLB: TCP/UDP/TLS/TCP_UDP listeners and target groups; no security groups.
- Secure protocols (HTTPS/TLS) reveal a certificate selector; HTTPS also a
  security-policy selector. Cert is required for both; SSL policy is HTTPS-only.
- The default target-group protocol + health-check protocol stay in sync with
  the listener (TCP health checks omit HTTP path/matcher).
- The wizard mutation now sends the chosen Type and threads cert/SSL-policy
  through CreateListener (previously hardcoded Type=application and dropped
  certificates).

* feat(ui): NLB-aware target groups and LB detail

Standalone Create Target Group offers the full protocol set
(HTTP/HTTPS/TCP/UDP/TLS/TCP_UDP) and syncs the health check to TCP for L4
groups, dropping path/matcher from the request and CLI preview. Hide the
Security groups tab on the load balancer detail page for network LBs.
2026-06-04 15:37:48 +10:00
Josh Siviour 9fa596001f fix: EKS 6b create-cluster follow-ups (security + cleanup + tests) (#291)
* fix(admin): write ~/.aws credentials 0600 via atomic temp+rename

UpdateAWSINIFile ended with ini.SaveTo, which uses os.Create (0644 &
~umask) and left ~/.aws/credentials world-readable. Render to a 0600
temp file and rename into place so secrets are never exposed.

* fix(eks): purge OIDC signing key on zeroize; pin account bucket History=1

ZeroizeClusterOIDCKey used kv.Delete (a tombstone) which leaves prior
revisions of the encrypted PEM in KV history. Use kv.Purge to drop the
key's whole history. Decouple the account bucket's History from the
schema version (KVBucketEKSAccountHistory=1) so a version bump can't
silently widen history and resurrect ciphertext.

* fix(reconcile): get-or-create leader bucket in AcquireLeader

CreateKeyValue returns 'stream name already in use' once the bucket
exists, so every reconcile after the first hit the 60s deadline and
never elected a leader. Attach to the existing bucket first, create
only when genuinely absent.

* fix(eks): drop registry entry on lease-held instead of phantom holder

RunClusterReconciler returned (nil, nil) when another node held the
lease; Spawn recorded a holder with a nil release, so Has() stayed true
and a later Spawn no-op'd — the cluster was never re-reconciled until
daemon restart. Return ErrLeaseHeld so Spawn drops the entry and retries
after the holder's TTL lapses.

* refactor: tidy EKS cluster state, dedup, and not-found error wiring

- listClusterNames: O(n) map dedup instead of O(n^2) slices.Contains
- drop dead store *Store field + NewStore call from EKSServiceImpl
- ClusterMeta.LastHealthProbe: drop misleading omitzero tag (stdlib
  encoding/json ignores it; json/v2 only) and document why it always
  serializes
- clarify ErrorResourceNotFound is the ACM code; EKS uses
  ErrorEKSResourceNotFound (ResourceNotFoundException)

* test: EKS 6b pagination, restart-resume, and bootstrap bind hardening

- T7: ListClusters NextToken/MaxResults paging + out-of-range clamp
- T4: SpawnRegisteredReconcilers resumes CREATING/ACTIVE on restart,
  skips terminal clusters, no-ops when deps absent
- T-quality: replace sleep-based subscription binding with polling
  NumSubscriptions, poll KV for the one-shot drop, and assert Run
  tears down its subscriptions on return

T1 (restjson wire shape), T5 (JWKS kty mismatch), and T6
(resolveGatewayHost branches) were already covered.

* test(reconcile): cover AcquireLeader get-or-create election

First acquire creates the bucket and wins; a second acquire while the
lock is held attaches to the existing bucket and loses cleanly (the
get-or-create fix, not a CreateKeyValue dead-end); after release a later
acquire takes over.
2026-06-04 14:47:52 +10:00
Josh Siviour 216b9c7682 feat(ui): HTTPS listeners — ACM certificate + SSL policy (Phase C) (#290)
* feat(ui): HTTPS listeners with ACM certificate + SSL policy

Expose HTTPS in the load balancer listener UI. The ListenerForm gains a
HTTPS protocol option that reveals a certificate selector (backed by a new
ACM ListCertificates query), an inline certificate import dialog
(ImportCertificate), and an SSL security-policy selector (DescribeSSLPolicies).
CreateListener/ModifyListener now carry Certificates + SslPolicy for HTTPS;
the listeners table summarises the policy and certificate per HTTPS listener.

Adds an ACM client (getAcmClient) and @aws-sdk/client-acm. Server, gateway
and ACM endpoints already exist (epic phases A + B); this is frontend-only.

* fix: ACM JSON timestamps, react-compiler watch reactivity, scroll-chaining

- gateway/acm: serialise responses with jsonutil.BuildJSON so time.Time
  fields emit epoch seconds (AWS JSON 1.1 wire format). encoding/json
  emitted RFC3339 strings the JS SDK rejected with 'Expected real number',
  crashing the LB detail Listeners tab once any cert exists.
- frontend: replace watch() with useWatch across forms so the React
  Compiler tracks the subscription and re-renders on field change
  (HTTPS listener cert/policy fields now reveal). Add toggle regression
  test for listener-form.
- frontend: scope overscroll-none to html,body instead of *, restoring
  wheel scroll-chaining over nested scroll containers (sidebar, tables,
  dialogs).
2026-06-04 14:19:30 +10:00
Julian Sommer bcb49cd771 feat: IMDS v1 (#266) 2026-06-04 13:32:44 +10:00
Josh Siviour f14fb64562 feat(eks): unify eks-server + eks-agent into one eks-node AMI (#288)
* feat(eks): unify eks-server + eks-agent into one eks-node AMI

Collapse the two K3s AMIs into a single spinifex-eks-node image whose role
(server vs agent) is chosen per-instance at first boot, halving build time,
storage, and CVE-rotation surface.

- scripts/images/eks-node/: superset manifest + merged setup.sh; all role
  services baked but only crond + the new eks-node-role selector enabled.
- eks-node-role first-boot selector reads SPINIFEX_K3S_ROLE, rc-update adds +
  starts the matching services (server: k3s + eks-token-webhook + k3s-first-boot
  publisher; agent: k3s-agent), records /etc/spinifex-eks/role, self-disables.
  Paths are env-overridable; eks-node-role_test.sh covers all five cases.
- mulga-eks-etcd-snapshot.sh early-exits on agent nodes.
- k3s_server_vm.go seeds SPINIFEX_K3S_ROLE=server into first-boot.env.
- Makefile/dev-install: build-eks-node-image / import-eks-node-image replace
  the server+agent targets.
- Delete scripts/images/eks-server + eks-agent. The launcher resolves the AMI
  by the spinifex:managed-by=eks tag, so running clusters (which hold the
  resolved AMI ID) are unaffected.

AMI bake + cluster boot are operational integration steps, not yet run.

* fix(eks): order eks-node-role after cloud-config/cloud-final

The first-boot role selector reads /etc/spinifex-eks/first-boot.env, which
cloud-init delivers via write_files in the cloud-config/cloud-final stages.
The selector only depended on net+cgroups, so on a fast boot it could start
before write_files landed and read a missing role env. Add an after-ordering
on cloud-config + cloud-final so the env file is always present first.
2026-06-04 12:28:56 +10:00
Josh Siviour bfacca7789 feat(eks): 6c token webhook + AccessEntry (IAM auth → kubectl) (#284)
* feat(eks): 6c AccessEntry CRUD + access-policy association

Replace the nine notImpl() AccessEntry/AccessPolicy stubs with real
KV-backed handlers (Q9, API auth mode):

- AccessEntry CRUD persisted at clusters/{name}/access-entries/{hash};
  the principal ARN is hashed into the key (ARNs carry ':', illegal in
  NATS KV keys) while the record keeps the plaintext ARN.
- AssociateAccessPolicy/Disassociate/ListAssociated against the four
  supported AWS-managed policies; unsupported policy ARN or bad access
  scope -> InvalidParameterValue.
- ListAccessPolicies returns the static supported catalogue.
- Cluster-existence gate -> ResourceNotFoundException; duplicate create
  -> ResourceInUseException; node-type entries rejected until 6d.

Updates the daemon routing test for the now-implemented handlers.

* feat(eks): plumb caller principal ARN + bootstrap-creator-admin

Thread the caller's resolved IAM principal ARN from the gateway to the
EKS control plane via a new X-Principal-ARN NATS header, so CreateCluster
can mint the bootstrapClusterCreatorAdminPermissions AccessEntry (Q9):

- utils.NATSRequest takes optional extra headers; PrincipalARNHeader +
  PrincipalARNFromMsg added.
- Gateway resolves the caller ARN best-effort (buildCallerARN) and passes
  it down the EKS route table; only CreateCluster consumes it.
- CreateCluster signature gains callerPrincipalARN; default-true bootstrap
  flag mints a system:masters entry keyed by the caller's exact ARN (the
  token webhook resolves the same ARN from STS). '' degrades gracefully.
- Dedicated daemon handler reads both headers.

* feat(eks): IAM token webhook + STS verify-over-NATS + apiserver wiring

The eks-token-webhook (baked into the eks-server AMI) authenticates kubectl
requests that present an `aws eks get-token` bearer token: decode the presigned
STS GetCallerIdentity URL, verify it, resolve the principal ARN to its cluster
AccessEntry, and answer the apiserver TokenReview with the mapped username +
groups.

STS is hosted in-process in the awsgw service and is not otherwise on the bus,
so awsgw now serves an `eks.VerifyToken` NATS subject that wraps
VerifyPresignedGetCallerIdentity (the cluster name is forwarded so STS enforces
the x-k8s-aws-id anti-replay pin). The webhook reads the AccessEntry KV directly
over its own NATS connection.

Apiserver wiring: the k3s config skeleton sets
authentication-token-webhook-config-file; the webhook writes its own self-signed
serving cert + the apiserver kubeconfig at startup (ordered `before k3s`, with a
bounded wait in the k3s start_pre so the apiserver never loads a missing file).

* fix(eks): omit empty spec from token webhook TokenReview response

* fix(sts): presigned GetCallerIdentity must hash empty body, not UNSIGNED-PAYLOAD

VerifyPresignedGetCallerIdentity built the SigV4 canonical request with
"UNSIGNED-PAYLOAD" as the payload hash. That is an S3-only convention; botocore
and the aws-sdk presign non-S3 requests (sts:GetCallerIdentity, the shape `aws
eks get-token` produces) with the SHA256 of the empty string. Every real
get-token therefore failed signature verification with InvalidIdentityToken,
breaking the entire EKS IAM token-auth flow end to end.

Use the empty-string SHA256. The existing hand-rolled presign tests masked the
bug by signing with the same wrong constant; they now sign correctly, and a new
test presigns with the real aws-sdk-go v4 signer (the same canonicalisation the
CLI uses) so this can't regress silently.

Verified live: a real `aws eks get-token --cluster-name dev1` now resolves to
the caller principal over the awsgw eks.VerifyToken surface; an otherwise-valid
token presented for a different cluster is still rejected (anti-replay intact).

* fix(eks): match access-entry routes on escaped path so slashed principal ARNs resolve

IAM principal ARNs (user/admin, role/foo) embed a '/' that the AWS CLI
percent-encodes in the request path. The dispatcher matched r.URL.Path
(decoded), so %2F became a literal '/' and split the ARN across the
([^/]+) capture, dropping every describe/update/delete-access-entry and
associate/disassociate/list-access-policy call to InvalidActionException.

Match r.URL.EscapedPath() so the encoded ARN stays one segment, then
url.PathUnescape each captured param before it reaches the handler.

* test(eks): unit-cover access-entry scope validation, AWS converters, store guards
2026-06-04 11:25:56 +10:00
Josh Siviour b0227e7eeb chore(ansible): remove ansible tree, uplift to mulga/ansible/ (#287)
Ansible orchestrates the whole stack (spinifex + predastore + viperblock +
OVN + tofu hosts) and its spec/beads live in mulga. Moving it to top-level
mulga/ansible/ ends the per-change submodule-pointer-bump friction.

- git rm -r scripts/ansible/
- Makefile: drop ansible-dev-* / ansible-cluster-bootstrap targets + .PHONY

The tree lands at mulga/ansible/ (flat copy, Option B). History pivot:
tag feat/uplift-ansible-snapshot-pre-move marks the pre-move HEAD.

Plan: docs/development/improvements/ansible-uplift-to-mulga.md (in mulga).
2026-06-04 11:23:08 +10:00
Josh Siviour 61d73d1d4e fix: reject unknown ACM cert ARN at listener API boundary (#289)
An unresolvable certificate ARN was accepted into a listener record and
only failed later at config-render time, which aborted updateStoredConfig
wholesale and silently froze the load balancer's data-plane convergence.

Validate every certificate ARN against the ACM store (existence + account
ownership) in CreateListener, ModifyListener and AddListenerCertificates,
returning CertificateNotFound up front. Skipped when the ACM store is
unavailable, matching the nil-safe resolve path.
2026-06-04 11:20:52 +10:00
Josh Siviour 470954bbe8 feat(elbv2): HTTPS data plane — TLS termination via ssl crt + PEM delivery (#286)
Resolve listener certificate ARNs to PEM via the ACM store (shared
JetStream KV, no cross-service hop), render HAProxy 'ssl crt' for secure
frontends, and deliver the combined cert+chain+key PEM to the LB agent
alongside the config. ConfigHash folds in cert content so rotation
triggers a reload; the agent writes each cert 0600 (path-constrained to
its cert dir) before the config + reload.

Phase B of the ELBv2 HTTPS epic (mulga-siv-180).
2026-06-04 10:45:11 +10:00
Josh Siviour 38f5dd0d41 feat(acm): minimal ACM-alike certificate service (#285)
Stand up acm as its own AWS JSON 1.1 gateway service wiring the BYO-cert
import/store path ELBv2 HTTPS listeners need. ImportCertificate validates
tls.X509KeyPair, parses the leaf for domain/serial/expiry/key-algorithm,
mints arn:aws:acm:<region>:<acct>:certificate/<uuid>, and stores PEM (cert,
chain, key) in JetStream KV. DescribeCertificate/ListCertificates/
DeleteCertificate round-trip; access is account-scoped.

New gateway dispatch by X-Amz-Target (CertificateManager.<Action>); errors
share the AWS JSON 1.1 envelope with EKS. Wired through gateway/acm ops,
handlers/acm (service iface, store, NATS client), daemon handlers + acm.*
subscriptions, and service-interfaces.yaml.

Out of scope (future, same epic): RequestCertificate + DNS validation,
managed renewal, Get/ExportCertificate, tagging, ACM-PCA.
2026-06-04 10:13:10 +10:00
Josh Siviour e06d0c0865 feat(elbv2): HTTPS listener certificates + SSL policies (Phase A) (#283)
* feat(elbv2): HTTPS listener certificates + SSL policies

Add Certificates[]/SslPolicy to ListenerRecord. CreateListener and
ModifyListener validate secure protocols (HTTPS/TLS) require >=1
certificate with exactly one default, non-secure protocols reject
certs/policy, and default+validate the SSL policy. DescribeListeners
echoes certificates and SslPolicy.

New endpoints: AddListenerCertificates, RemoveListenerCertificates,
DescribeListenerCertificates, DescribeSSLPolicies (gateway ops, NATS
subjects, daemon handlers, service-interfaces.yaml). No TLS
termination yet.

* test(elbv2): cover listener-cert gateway ops + handler error paths
2026-06-04 09:51:10 +10:00
Josh Siviour bbff004b45 feat(eks): 6b create-cluster → ACTIVE (single-node SQLite + mgmt-IP NLB stop-gap) (#280)
* feat(eks): cluster_state KV helpers — ClusterMeta + status CAS + prefix sweep

Stage 1 of Sprint 6b CreateCluster lifecycle. Introduces the first
read/write helpers against the eks-account-{accountID} bucket so later
stages (oidc keypair, NLB wiring, cluster reconciler) can persist state
without re-inventing CAS/sweep logic.

- ClusterMeta struct holds the DescribeCluster source of truth (status,
  endpoint, OIDC issuer, CA, VPC config, control-plane VM + ENI, NLB ARN).
- ClusterStatus enum mirrors the AWS EKS string set verbatim.
- PutClusterMeta is unconditional (CreateCluster path); GetClusterMeta
  returns ErrClusterNotFound on key absence so the service boundary can
  map to ResourceNotFoundException.
- SetClusterStatus is revision-checked with bounded CAS retries so the
  reconciler and CreateCluster body cannot wedge each other.
- DeleteClusterPrefix sweeps every clusters/{name}/* key; called from
  DeleteCluster after the OIDC private key is zeroized.

Tests cover round-trip, missing/corrupt blob, idempotent status set,
DELETING transition from every prior state, concurrent revision bump
recovery, sibling-cluster preservation, empty-bucket no-op.

Plan: docs/development/feature/eks-6b-create-cluster.md

* feat(eks): per-cluster ECDSA-P256 OIDC keypair generation + zeroize

Stage 2 of Sprint 6b. Lays the at-rest primitives the CreateCluster body
will call before launching the K3s server VM. Pure crypto helpers — no
daemon, no launcher, no NATS bus.

- GenerateClusterOIDCKeypair mints an ECDSA-P256 keypair per cluster
  (matches eks-v1.md Q8), PEM-marshals the private as PKCS8,
  envelope-encrypts it via handlers_iam.EncryptSecret (AES-256-GCM,
  AwsGW masterKey), writes the ciphertext to OIDCSigningKeyKey, and
  publishes the public JWKS document to OIDCJWKSKey. Returns the JWKS
  bytes so CreateCluster can hand them straight to user-data without
  a follow-up KV read.
- JWKS shape mirrors what handlers_sts.JWKS already decodes for IRSA
  verification (RFC 7517 EC P-256, kid = base64url SHA-256 of SPKI DER,
  alg ES256, use sig). Types are defined locally to avoid a
  handlers_eks → handlers_sts → handlers_eks import cycle; JSON wire
  compat is the contract.
- LoadClusterOIDCPrivateKey is the recovery-path read for future
  stages (reconciler restart, restore-snapshot tooling). Validates
  PKCS8 parses to an ECDSA P-256 *PrivateKey and rejects anything else.
- ZeroizeClusterOIDCKey overwrites the encrypted blob with zeros of
  the same length, then deletes. DeleteCluster calls this *first* so a
  crash mid-teardown leaves no recoverable key material. Idempotent
  on missing key — supports retry semantics.

Tests cover JWKS shape (RFC 7517 EC P-256 with 32-byte SEC1
coordinates), per-cluster key uniqueness, round-trip private-key
decrypt matches JWKS public, wrong-master-key fails, missing-key
ErrClusterNotFound mapping, zeroize idempotency, JWKS survives
zeroize (carries no secret material), padCoord left-pad correctness.

Plan: docs/development/feature/eks-6b-create-cluster.md

* feat(eks): cluster SG + NLB ensure/delete helpers

Stage 3 of Sprint 6b CreateCluster lifecycle. Adds the customer-account
SG provisioning + per-cluster NLB plumbing the CreateCluster body will
call between OIDC keypair (Stage 2) and K3s VM launch (Stage 4). Pure
interface-call helpers — no daemon, no KV, no NATS bus.

- EnsureClusterSGs creates (or returns) eks-cluster-{name}-control-plane-sg
  and eks-cluster-{name}-nodegroup-sg in the customer VPC, tagged
  spinifex:managed-by=eks + spinifex:eks-cluster=<name> + spinifex:eks-role.
  Idempotent on group-name + vpc-id pair. DeleteClusterSGs is best-effort
  with continue-on-error sweep.
- EnsureClusterNLB provisions the eks-<name> NLB (internal scheme), the
  eks-<name>-cp target group (TCP, port 6443, target-type ip per eks-v1.md
  Q13), and the TCP :443 forward listener. Returns ClusterNLB
  {LoadBalancerArn, TargetGroupArn, ListenerArn, DNSName} so CreateCluster
  can persist every ARN in ClusterMeta. Idempotent on cluster name;
  resource-name length validated against the AWS 32-char cap so a too-long
  cluster name fails with a clear error instead of a generic
  InvalidParameterValue downstream.
- RegisterClusterTarget + DeregisterClusterTarget bind the K3s server VM
  ENI IP to the TG on :6443. Stage 4 calls Register once the VM has its
  ENI IP; DeleteCluster calls Deregister before VM termination.
- DeleteClusterNLB tears down NLB + TG. DeleteLoadBalancer already
  cascades listener cleanup inside the ELBv2 impl. LB sweep error takes
  precedence so DeleteCluster surfaces the more visible failure first
  while the TG sweep still runs.

Narrow sgProvisioner + nlbProvisioner interfaces in-package give tests
hand-rolled recorder fakes; the production VPCService + ELBv2Service
satisfy them structurally so no adapter is needed at Stage 6 wire-up.

Tests cover fresh-creates with right names/tags/ports, idempotent reuse,
mixed-existence (control-plane exists, nodegroup created), name-too-long
rejection, empty-inputs rejection, missing-resource no-op on delete, and
first-error-surfaced sweep continuation on delete.

Plan: docs/development/feature/eks-6b-create-cluster.md

* feat(ec2): RunInstances honors pre-created NetworkInterfaceId

Closes the AWS-parity gap where RunInstances always auto-created a
primary ENI even when the caller specified
NetworkInterfaces[0].NetworkInterfaceId. The EKS Sprint 6b CreateCluster
launcher (Stage 4) needs to pre-create the K3s server VM's ENI so the
ENI can be tagged/owned distinctly from the launcher's instance; the
existing path silently created a second ENI and left the caller's
unattached.

Changes:
- ENICreator interface gains GetENI(accountID, eniID) → *ENIInfo. ENIInfo
  is a translated struct (SubnetID, VpcID, PrivateIp, MAC, Status,
  SecurityGroupIDs) so handlers/ec2/instance stays free of an import on
  handlers/ec2/vpc.ENIRecord.
- daemon/vpc_adapter.go gains daemonENICreator.GetENI translating
  VPCServiceImpl.GetENIRecord into ENIInfo.
- PrepareRunInstances inspects NetworkInterfaces[0].NetworkInterfaceId
  after the existing subnet/SG lift block. If non-empty, the loop
  dispatches to a new attachPreCreatedENI helper:
    - Looks up the ENI under accountID.
    - Rejects with InvalidNetworkInterface.InUse if Status == "in-use".
    - Calls AttachENI to mark the ENI in-use and bind it to the new VM.
    - Populates instance.ENIId / ENIMac and ec2.Instance network fields
      (PrivateIp, SubnetId, VpcId, SecurityGroups, NetworkInterfaces[0]).
    - Skips the auto-create + public-IP-auto-assign paths; the caller is
      expected to manage public IP out-of-band.
- Auto-create path is left untouched for every other code path: callers
  that pass SubnetId without a NetworkInterfaceId still get a fresh ENI
  with the existing semantics.

Tests cover the four shapes: happy path (auto-create not called, attach
called once, instance populated), in-use ENI rejected with the canonical
AWS error code, lookup error surfaces verbatim, attach error surfaces
verbatim.

* feat(eks): K3s control-plane VM launcher with cloud-init user-data

LaunchK3sServerVM resolves the eks-server AMI by name, pre-creates the
customer-account control-plane ENI in the supplied subnet with the
control-plane SG, renders cloud-init user-data (first-boot env, OIDC
private key, K3s config.yaml with tls-san + service-account args), then
calls RunInstances with NetworkInterfaces[0].NetworkInterfaceId pointing
at the pre-created ENI. RunInstances failures roll back the ENI so the
caller does not leak a customer-account resource.

TerminateK3sServerVM tears the instance and ENI down for DeleteCluster;
missing identifiers are a no-op so retries stay safe.

ENI + VM both live in the customer account (deviation from plan §7 which
specified a SystemAccount-owned VM); cross-account ENI attach is deferred
to a follow-up bead.

* feat(eks): NATS bootstrap subscriber + cluster reconciler + leader lease

NATSBootstrap subscribes to the four single-shot subjects the K3s server VM
publishes (k3s-bootstrap-token, k3s-admin-kubeconfig, k3s-oidc-jwks, k3s-ca)
on eks.bus.{accountID}.{clusterName}.{kind}. Token and admin kubeconfig are
envelope-encrypted via handlers_iam.EncryptSecret and persisted to KV. The
published JWKS is cross-checked against the controller-generated keypair by
kid and rejected on mismatch. The CA lands on meta.CertificateAuthorityB64
via the new SetClusterCertificateAuthority CAS updater.

ClusterReconciler holds a leader lease on spinifex-eks-leader/{accountID}/
{clusterName} (60s TTL, 20s refresh) and drives:
  - CREATING -> ACTIVE once the four bootstrap KV artifacts are present and
    NLB /healthz responds 200.
  - ACTIVE -> probe /healthz on the configured interval, warn on failure.
  - DELETING / FAILED -> exit (DeleteCluster body owns teardown).
Loss of the leader lease (CAS conflict or different holderID) returns
ErrReconcilerLeaseLost so the caller can re-acquire from another node.

Tested with embedded JetStream + a stub HTTPDoer; race-clean.

* feat(eks): OIDC issuer URL builder + reconciler registry

ClusterOIDCIssuer renders the {gatewayBaseURL}/oidc/eks/{region}/{accountID}/
{clusterName} URL kube-apiserver advertises as service-account-issuer and
that STS / external IRSA validators dereference. Validates URL safety on the
path segments so a hostile cluster name cannot inject scheme/path fragments.

ReconcilerRegistry tracks per-(accountID, clusterName) reconciler goroutines
with idempotent Spawn (drop duplicate spawn calls), Stop (cancel one + wait
for release), and StopAll (graceful daemon shutdown). RunClusterReconciler
is the production SpawnReconcilerFn: constructs a ClusterReconciler,
acquires the leader lease, runs Run in a goroutine. Both helpers consumed
by the upcoming service_impl CreateCluster body.

* feat(eks): CreateCluster/DescribeCluster/ListClusters/DeleteCluster bodies

Wires the EKSServiceImpl orchestration deps (VPC SG/K3s/Subnet, NLB,
Instance launcher, AMI resolver, master key, gateway base URL, region,
holder ID, reconciler registry) and implements the four lifecycle
methods:

- CreateCluster: validate → CREATING meta → SGs → NLB → OIDC issuer +
  keypair → K3s server VM (cloud-init carries OIDC private PEM) →
  register ENI IP with NLB target group → spawn NATS bootstrap
  subscriber + per-cluster reconciler via the registry.
- DescribeCluster: KV meta read → eks.Cluster shape, with
  ErrClusterNotFound translated to ResourceNotFoundException at the
  service boundary.
- ListClusters: KV key-prefix scan + sorted maxResults/nextToken
  pagination.
- DeleteCluster: SetClusterStatus DELETING → zeroize OIDC key → stop
  reconciler via registry → deregister NLB target + delete NLB →
  terminate K3s VM → delete SGs → delete cluster KV prefix.

Daemon wiring:

- buildEKSServiceDeps loads MasterKey from BaseDir/config/master.key
  best-effort, resolves the gateway base URL via the same precedence the
  LB-agent block applies later, and passes VPCService + ELBv2Service +
  ImageService directly while adapting InstanceServiceImpl via
  daemonEKSInstanceLauncher (RunInstances passes through; Terminate-
  Instances loops vmMgr.Get + StopOrTerminateInstance in-process).
- SubnetVPCResolver is satisfied by daemonEKSSubnetResolver wrapping
  VPCService.GetSubnet.
- Startup spawns reconcilers for every persisted CREATING/ACTIVE cluster
  via SpawnRegisteredReconcilers; shutdown calls eksService.Shutdown so
  every per-cluster goroutine releases its lease before exit.

Test surface:

- service_impl_test: TestEKSServiceImpl_ClusterLifecycleShimMode covers
  the four lifecycle methods when constructed via the back-compat shim
  (CreateCluster/DeleteCluster → ServerInternal, DescribeCluster →
  ResourceNotFound on empty bucket, ListClusters → empty list).
- daemon_handlers_eks_test: drop the four lifecycle subjects from the
  NotImplemented routing loop; handler-side coverage now lives in
  handlers/eks.
- awserrors: add ResourceNotFound (404) for the DescribeCluster path.

* fix(eks): load master.key from config dir not service data dir

eks_deps read master.key from {BaseDir}/config/master.key, but the daemon
systemd unit sets SPINIFEX_BASE_DIR=/var/lib/spinifex/spinifex (a per-service
data dir). admin init writes master.key to the shared config dir, so the
daemon looked in a path that only resolved when a {BaseDir}/config symlink
existed — awsgw has one, the daemon's nested data dir does not. Result:
MasterKey=nil, depsReadyForOrchestration() false, CreateCluster/DeleteCluster
collapse to ServerInternal.

master.key is a config artifact written next to spinifex.toml. Read it from
filepath.Dir(d.configPath), matching how the daemon already resolves
predastore.toml and TLS certs. No symlink or provisioning change needed.

* fix(eks): register + resolve eks-server AMI via tag, add import --ami-name

CreateCluster's K3s launch failed 'eks: AMI eks-server not found' on every
clean install. Two defects:

1. spx admin images import had no way to name a locally-built appliance AMI:
   --name is a catalog lookup (rejects non-catalog values) and the AMI name
   was hardcoded to ami-{distro}-{version}-{arch}. build-system-image.sh ran
   'import --name $AMI_NAME' for eks-server/eks-agent/GPU images, which all
   failed 'Image name not found in available images'.

2. The launcher resolved DescribeImages by name=='eks-server', a name the
   pipeline could never produce.

Add --ami-name to override the registered AMI name on --file imports (fixes
eks-server, eks-agent, and GPU appliance imports). Resolve the EKS AMI by the
spinifex:managed-by=eks tag the pipeline already stamps, newest-wins on
multiple matches. Tag-based resolution survives the planned server+agent →
single EKS AMI unify untouched (role chosen per-instance at launch).

build-system-image.sh now passes --ami-name; Makefile gains
import-eks-server-image (IMPORT=1) and dev-install.sh imports the AMI when
DEV_INSTALL_EKS=1.

* fix(eks): give K3s server VM NATS token + CA so bootstrap can publish

The K3s control-plane VM was launched with NATSURL=ConnectedUrl() (loopback,
unreachable from the VM) and no auth material, while spinifex NATS requires a
shared token over TLS. The VM could not connect to NATS, so it never published
its four one-shot bootstrap messages and the cluster stayed in CREATING.

Hand the VM the daemon's shared NATS token + CA PEM and a VM-reachable NATS URL
(mirrors the ELBv2 LB-agent shared-credential pattern; spinifex has no nkeys
hierarchy to scope per-principal creds against).

- K3sServerInput: drop dead NATSCredsContent; add NATSToken + NATSCACert,
  required by validateK3sServerInput. user-data writes SPINIFEX_NATS_TOKEN,
  SPINIFEX_NATS_CA + the CA file; first-boot.env tightened to 0600.
- EKSServiceDeps + CreateCluster: pass NATSURL/NATSToken/NATSCACert.
- buildEKSServiceDeps: read CA from config.NATS.CACert; extract
  resolveGatewayHost(); add resolveNATSURL() (same reachable host, NATS port).
- k3s-first-boot.sh: build nats CLI args with --token + --tlsca.

* fix(eks): retry transient bootstrap persists, fail cluster on permanent error

The NATS bootstrap subscriber tore down on the first persist error and the
cluster sat in CREATING forever. Core-NATS delivers a publication once, so a
transient KV blip during persist was unrecoverable and a malformed payload
(bad JWKS kid, non-base64 CA, empty envelope) silently stranded the cluster.

- persistWithRetry rides out transient persist failures in-handler (3 attempts,
  200ms backoff) instead of unsubscribing and waiting for a re-publish that
  never comes.
- errBootstrapPermanent classifies validation failures (malformed envelope,
  empty fields, JWKS kid mismatch, non-base64 CA) as non-retryable so they
  return immediately.
- spawnBootstrap marks the cluster FAILED (with reason) when Run exits with a
  non-context error, so DescribeCluster surfaces the cause and DeleteCluster
  can reclaim resources.
- MarkClusterFailed transitions to FAILED+StatusReason only from CREATING, so a
  late bootstrap error cannot clobber a concurrent delete or healthy cluster.
- casUpdateMeta extracts the revision-checked RMW loop shared by the meta
  setters.

* fix(eks): resume NATS bootstrap for CREATING clusters on daemon restart

SpawnRegisteredReconcilers re-spawned only the reconciler on boot, never the
bootstrap subscriber. A daemon restart while a cluster was CREATING (K3s VM
still booting) lost the subscriptions; the VM published its four one-shot
core-NATS messages to nobody and the cluster hung in CREATING forever.

- BootstrapPendingKinds reports which artifacts (token, kubeconfig, CA) are
  still missing from KV. JWKS is excluded: it persists no artifact and
  re-waiting on a once-published subject would deadlock — its cross-check is
  best-effort across a restart.
- RunForKinds subscribes to a subject subset (nil = all four); Run delegates to
  it. subjectHandlers maps each kind to its handler and rejects unknown kinds.
- SpawnRegisteredReconcilers resumes bootstrap for CREATING clusters, waiting
  only on the missing artifacts, so a mid-CREATING restart still drives the
  cluster to ACTIVE.

* fix(eks): DeleteCluster must not wipe KV or report success on teardown failure

DeleteCluster ran zeroize, NLB teardown, and VM termination as warn-and-continue
then unconditionally swept the per-cluster KV prefix and returned success. A
failed NLB or VM teardown leaked billable resources with no owning record (the
meta holding their ARNs/IDs was erased) and told the operator the cluster was
gone. Zeroize — a stated security guarantee of no recoverable key material —
had its failure ignored too.

- Zeroize, NLB delete, and VM terminate are now blocking: their failures
  accumulate (errors.Join) and abort the call before the KV sweep, leaving the
  cluster DELETING and returning the error so a retry can still reach the ARNs.
- KV prefix sweep runs only after NLB + VM teardown confirm.
- SG cleanup stays best-effort (plan step 5) — a leaked SG strands no owning
  record — and target deregister stays best-effort since DeleteClusterNLB tears
  down the whole NLB + target group.

* fix(eks): persist NLB + control-plane ARNs mid-CreateCluster so failures don't leak

CreateCluster set meta.NLBArn / target-group / instance / ENI in memory and
persisted them only in the final PutClusterMeta, after LaunchK3sServerVM. Any
failure between EnsureClusterNLB and that persist left the NLB + target group +
listener + backing LB VM created but unrecorded; markFailed did no rollback and
DeleteCluster keys teardown off the persisted ARNs, so the resources leaked.

- Persist meta immediately after EnsureClusterNLB (records NLB/TG ARNs) and
  again after LaunchK3sServerVM (records instance + ENI), before the next
  fallible step.
- A failed create now leaves a FAILED meta carrying the resource references, so
  the DeleteCluster teardown path (165.9) reclaims every NLB/TG/listener/VM.
- Tests: NLB-arn-persisted-before-later-failure, failed-create-then-delete
  reclaims NLB, and a CreateCluster happy path. Shared eksServiceFixture
  extracted from the delete-cluster fixture.

* fix(sts): parse path-based EKS OIDC issuer so IRSA accepts 6b cluster tokens

ParseEKSIssuerURL required an `oidc.eks.` host prefix and an exactly-2-segment
path (AWS hosted-EKS shape), but ClusterOIDCIssuer emits — and awsgw serves —
https://{gateway-host}/oidc/eks/{region}/{accountID}/{clusterName}. Every 6b
cluster's iss claim was rejected with "issuer host must start with oidc.eks",
so AssumeRoleWithWebIdentity would fail all IRSA tokens once 6e serves them.

Reconcile on the path-based shape, which is what is actually reachable on
bare-metal/on-prem (no oidc.eks.* vhost or DNS exists). Host trust comes from
the IAM OIDC-provider registration (the full issuer string is the registry key)
and the JWKS signature check, not a host pattern — so the parser drops the host
prefix rule and instead pins accountID/clusterName at fixed positions in a
strict 5-segment /oidc/eks/{region}/{accountID}/{clusterName} path. A crafted
iss with extra or missing segments still fails closed.

Adds a cross-component round-trip test asserting a ClusterOIDCIssuer-built iss
parses back to the same (accountID, clusterName).

* fix(eks): reclaim FAILED clusters on retry + cluster-scoped exists/not-found errors

A failed CreateCluster left a FAILED ClusterMeta that poisoned retries: the
preflight returned ErrorELBv2TargetGroupInUse ("The target group is currently
in use...") for a cluster-exists condition, and because that maps to a
retryable code the SDK's auto-retry surfaced ResourceInUseException instead of
the original failure. FAILED clusters were also a dead state — visible to
neither a clean recreate nor a clear lifecycle.

- Add EKS-specific ResourceInUseException / ResourceNotFoundException codes with
  cluster-scoped messages; CreateCluster's exists check and DescribeCluster /
  DeleteCluster not-found now use them instead of the ELBv2 / EC2 codes.
- CreateCluster reclaims a FAILED cluster: it purges the prior attempt's
  recorded resources and clears KV before recreating, so a retry (including the
  SDK auto-retry) is idempotent rather than blocked. Teardown failure surfaces
  rather than recreating on top of orphans.
- Extract purgeClusterInfra, shared by DeleteCluster and the reclaim path
  (blocking zeroize/NLB/VM, best-effort SG, KV sweep only after teardown
  confirms).
- FAILED clusters remain observable via Describe (status=FAILED) and List — no
  state where create blocks but describe/list show nothing.

* fix(eks): fail CREATING clusters that exceed create timeout

A per-cluster reconciler that never observes a ready control plane (bootstrap
artifacts missing or healthz unreachable) previously stayed CREATING forever.
Add a configurable create timeout (default 15m): once meta.CreatedAt is older
than the deadline and the cluster is still not ready, the reconciler marks it
FAILED with a reason, exiting the loop. Within the deadline it stays CREATING.

* fix(eks): surface ACTIVE cluster health in describe-cluster

The reconciler only slog.Warn'd a failing /healthz probe for an ACTIVE
cluster, so describe-cluster kept reporting a dead control plane as healthy.
Add HealthIssue + LastHealthProbe to ClusterMeta, write them from the ACTIVE
reconcile branch (recording the failure reason, clearing on recovery), and map
a non-empty HealthIssue to a ClusterHealth ClusterUnreachable issue in
describe-cluster.

* test(eks): add plan-mandated CreateCluster/DeleteCluster service cases

service_impl_test only covered shim mode. Add the four cases the plan's Testing
section requires: CONFIG_MAP auth mode rejected (and API accepted), missing
subnetIds rejected, DescribeCluster 404 with full deps wired (past the shim
short-circuit), and the DeleteCluster zeroize-before-teardown ordering proven
by forcing a teardown failure and asserting the OIDC key is already wiped.

* refactor(eks): return OIDC private-key PEM from generator, drop re-read

CreateCluster discarded GenerateClusterOIDCKeypair's return then did a second
KV get + AES-GCM decrypt via loadOIDCPrivateKeyPEM to re-derive the same PEM the
generator already had in hand. Return the plaintext PEM (alongside the JWKS)
from the generator and use it directly; remove loadOIDCPrivateKeyPEM and the now
-unused crypto/x509 + encoding/pem imports in service_impl.

* fix(eks): make CreateCluster/DeleteCluster failures diagnosable

Unmet orchestration deps and a missing eks-server AMI both collapsed to a bare
ServerInternal with no request-time log, leaving the client a 'contact AWS
re:Post' message and zero cause. Enumerate the specific missing deps and log
them at ERROR, returning ServiceUnavailable (an operator/config fault, not an
unrecoverable internal one). Add an ErrEKSServerAMINotFound sentinel so a
missing control-plane AMI is logged and surfaced as ServiceUnavailable too.

* fix(eks): serialize REST-JSON responses via jsonutil so the AWS SDK can parse them

WriteJSONResponse marshaled aws-sdk-go *Output structs with encoding/json,
which ignores their locationName tags and emits Go PascalCase keys. EKS
clients parse restjson on the lowercase locationName, so every field of
list/describe/create/delete responses was silently dropped (empty endpoint,
null identity.oidc.issuer). Marshal via jsonutil.BuildJSON — the same
marshaler the SDK uses for restjson bodies — which honors locationName.

Adds wire-shape tests asserting lowercase keys present and PascalCase absent.

* feat(eks): launch K3s server VM via boot-agnostic system-instance path

The EKS K3s control-plane VM launched through the plain tenant RunInstances
path, so it had only its VPC ENI and no management-bridge NIC -- no route to
the daemon's NATS endpoint. It never published its bootstrap subjects and the
cluster stayed CREATING.

Generalize LaunchSystemInstance to a boot-agnostic contract supporting both
system-instance boot styles:

  - BootDirect: direct-boot microVM (fw_cfg blobs) -- ELBv2 LB agent
  - BootAMI:    AMI boot + cloud-init seed (q35)    -- EKS K3s server VM

The launch contract moves to a neutral handlers/sysinstance package so EKS can
launch system VMs without an eks->elbv2 dependency; handlers/elbv2 re-exports
the types as aliases. The BootAMI branch mirrors the daemon's
Prepare -> Insert -> Launch split and allocates the mgmt NIC on the VM record
between Insert and Launch, so the cloud-init network-config renders a static
mgmt0 interface. The EKS NATSURL is now sourced from the mgmt-bridge IP (the
on-link address the VM's mgmt NIC can reach), falling back to the gateway-host
resolution when no mgmt bridge exists.

Verified: K3s VM now boots from the eks-server AMI under q35 with a mgmt0 NIC
(10.15.8.x), reachable from the host over br-mgmt, with NATS on-link at the
bridge IP. No AMI rebuild required.

* fix(eks): make DeleteCluster K3s teardown idempotent so it can't wedge

TerminateK3sServerVM treated every DeleteNetworkInterface error -- and every
TerminateSystemInstance error -- as blocking. Because instance terminate is
async, the immediate ENI delete races it: the first attempt fails
InvalidNetworkInterface.InUse, and once the instance-terminate cascade releases
the ENI every retry fails InvalidNetworkInterfaceID.NotFound. Both joined into
purgeClusterInfra's teardownErrs gate, which short-circuits before the SG sweep
and KV prefix delete -- so once the ENI was gone the cluster was stuck DELETING
permanently, leaking its control-plane + nodegroup SGs and KV meta.

Classify the teardown errors instead of blanket-propagating:

  - ENI InvalidNetworkInterfaceID.NotFound / InvalidNetworkInterface.NotFound
    -> idempotent success (the cascade or a prior retry already deleted it);
    must not block the downstream SG + KV sweep.
  - ENI InvalidNetworkInterface.InUse (VM still terminating) and any other
    error -> retryable; surfaced so the cluster stays DELETING and the
    reconciler retries once the instance releases the ENI.
  - Instance already gone -> idempotent. TerminateSystemInstance now returns a
    sysinstance.ErrSystemInstanceNotFound sentinel that the EKS path tolerates
    via errors.Is, so a retried delete after the VM drained completes.

Add awserrors.IsErrorCode(err, code) as the shared delete-error classifier
(substring match through %w wrapping) and route the lone ad-hoc strings.Contains
guard in daemon/vm_adapters.go through it.

Verified: delete-cluster on three clusters left wedged DELETING / FAILED (ENI +
VM already gone) all complete -- clusters erased, SGs + ENIs reclaimed, no leak.

* fix(eks): gate ACTIVE on verified JWKS cross-check, not pre-seeded key

The controller pre-seeds OIDCJWKSKey at create time, so its presence
proved nothing about the running cluster's actual signing key. A cluster
could reach ACTIVE — and start signing IRSA tokens — before the K3s VM's
published JWKS was ever cross-checked against the generated keypair.

Add an OIDCJWKSVerifiedKey marker written by persistJWKS only after
assertJWKSMatch passes. The reconciler's ACTIVE gate and
BootstrapPendingKinds now key off the marker, not the pre-seeded JWKS.
assertJWKSMatch is map-based and also rejects kty mismatch.

* fix(eks): collapse duplicated gateway-host switch into resolveGatewayHost

startCluster reimplemented the 5-case gateway-host precedence inline,
duplicating resolveGatewayHost (used by the OIDC issuer and EKS NATS URL).
If one drifted, the OIDC issuer host embedded in cluster tokens would
diverge from the host LB VMs use to reach the daemon -> IRSA iss mismatch
and cluster-wide token rejection.

startCluster now calls resolveGatewayHost and derives the multi-node
mgmt host route (mgmtRouteVia) from the resolved host. resolveGatewayHost
carries the canonical case enumeration; a table test pins all five
branches plus the case-1 skip guards and the no-host fallthrough.

* fix(eks): make eks-server VM boot diagnosable + fix first-boot publisher crash

Three issues found while diagnosing why K3s clusters never leave CREATING:

1. setup.sh: the stock Alpine cloud image lists `console=tty0` LAST in
   default_kernel_opts, so Linux binds /dev/console to the framebuffer and the
   host-side serial capture sees only kernel dmesg — OpenRC, cloud-init, and
   k3s-first-boot output were invisible. Reorder so console=ttyS0 is last.

2. k3s-first-boot.initd: command_background was "no" while output_log/error_log
   were set; start-stop-daemon rejects --stdout/--stderr without --background
   ("Failed to start k3s-first-boot"), so the bootstrap publisher never ran and
   the cluster wedged in CREATING. Background it (the publisher waits up to 5min
   on K3s /healthz and must not block the runlevel anyway) and route its output
   to the serial console, which the orchestrator captures (the VM has no login).

3. k3s-first-boot.sh: on the /healthz timeout, dump the tail of /var/log/k3s.log
   to the console so the apiserver's own failure is visible host-side.

* fix(eks): seed K3s apiserver a public OIDC key, not the private key

buildK3sUserData pointed both kube-apiserver service-account args at the same
private-key file. --service-account-key-file requires a PUBLIC key — k8s
ParsePublicKeysPEM rejects a private-key PEM with "data does not contain any
valid RSA or ECDSA public keys" and the apiserver crash-loops, so K3s never
reaches /healthz, first-boot never publishes, and the cluster wedges in
CREATING.

Add PublicKeyPEMFromPrivate (PKIX public PEM from the PKCS#8 private key) and
seed it to a separate /etc/rancher/k3s/oidc-signing-key.pub.pem. Point
service-account-key-file at the public file; keep service-account-signing-key-file
on the private file. Runtime-verified: the apiserver crash is gone and K3s now
starts and schedules system pods.

* feat(eks): egress-only SNAT + DNS for K3s control-plane VM

The K3s server VM (a hidden system instance) could not pull system-pod
images: it had no internet egress and no working resolver, so
coredns/traefik/metrics-server never started and /healthz timed out.

Egress: give the single control-plane VM egress-only internet, confined
to its /32, with no inbound exposure and no customer-visible resource.

- policy/route.go: SystemInstanceEgressPriority (1200, above the drop
  gate) + AddSystemInstanceEgress/Delete — a reroute scoped to
  ip4.src == <vmIP>/32 so peers in a shared subnet are unaffected.
- policy/nat.go: AddSystemInstanceSNAT/Delete — a plain snat (not
  dnat_and_snat, so no inbound path), idempotent on its pool external IP.
- external/igw.go: L5 EnsureSystemInstanceEgress/Remove resolves the
  gateway nexthop and attaches the reroute + snat (mirrors the NAT-GW
  egress path; ADR-0006 S1/S4/S5 preserved).
- subscribers: vpc.add/delete-system-egress topics + handlers.
- handlers/eks: allocate a hidden (ManagedBy=eks) pool address on
  CreateCluster and publish add; release + publish delete on teardown.
  EIP dep wired through EKSServiceDeps + the daemon.

DNS: the eks-server AMI is Alpine/dhcpcd and /etc/resolv.conf is a
dangling symlink, so DHCP-supplied resolvers are never persisted. Write
a real /etc/resolv.conf from the K3s VM cloud-config via bootcmd (not
write_files — a write_files entry follows the dead symlink, fails, and
aborts the whole block, dropping first-boot.env + the k3s config).

* feat: EKS 6b single-node SQLite pivot + bootstrap-envelope fixes

Switch the K3s server VM from embedded etcd to embedded SQLite (kine)
for the single-node v1, so CreateCluster reaches a healthy control plane.
etcd's per-commit fsync stalls past the apiserver deadline on viperblock's
deferred-durability block path; SQLite tolerates it.

- k3s server: omit cluster-init (selects SQLite/kine), add anonymous-auth=true
  so the reconciler's anonymous /healthz NLB probe returns 200.
- first-boot readiness gate: anonymous /healthz (401 under CIS anonymous-auth
  =false) -> authenticated 'kubectl get --raw /readyz'.
- first-boot publish: emit a JSON BootstrapEnvelope for all four subjects
  (token, admin-kubeconfig, oidc-jwks, ca); jwks and ca were absent before.
- nats pub: --stdin -> --force-stdin (nats CLI 0.4.0).
- manifest: add jq (builds the bootstrap-message JSON envelopes).

* feat: EKS reconciler probes control-plane mgmt IP until DNS lands

The host-side cluster reconciler cannot resolve or route to the VPC-internal
NLB DNS endpoint (no authoritative DNS / Eclipso yet), so CreateCluster timed
out in CREATING and was marked FAILED even though the control plane was healthy.

Probe the control-plane VM's br-mgmt NIC address directly — the one apiserver
endpoint reachable from the daemon. Capture the mgmt IP from the launch result
(SystemInstanceOutput.MgmtIP -> K3sServerOutput.MgmtIP -> ClusterMeta.
ControlPlaneMgmtIP) and build the reconciler health URL as
https://<mgmtIP>:6443/healthz. The probe client already sets InsecureSkipVerify,
so the mgmt IP need not be in the apiserver cert SAN.

Workaround until authoritative DNS + a host-reachable NLB front-end land, at
which point spawnReconciler reverts to the NLB endpoint.
2026-06-03 22:29:31 +10:00
Josh Siviour 7b9eb9cc4e feat(elbv2): tag/SG/IP-type setters, ip targets, redirect actions, delete-cascade hardening (#282)
* fix(elbv2): make resource deletes idempotent on absent

DeleteLoadBalancer, DeleteTargetGroup, DeleteListener and DeleteRule
returned a NotFound error when the target was already gone. Real AWS
ELBv2 deletes are idempotent (success on absent), so terraform-provider-aws
fails a destroy step on retry. Return an empty success output instead,
mirroring the KV store which already tolerates absent keys.

Adds TestRLC1_ELBv2DeleteIdempotentOnAbsent enforcing the contract across
the ELBv2 delete surface; converts the three _NotFound delete tests to
assert idempotent success.

Refs mulga-siv-172 (idempotency clause; cascade-composition clause stays open).

* feat: ELBv2 AddTags/RemoveTags, SetSecurityGroups, SetIpAddressType

- O(1) GetListenerByArn via ARN-parse short-ID lookup + ARN equality check
- persist tags on listeners/rules; seed at create time
- AddTags/RemoveTags across LB, target group, listener, rule resources
- SetSecurityGroups (ALB only) reattaches SGs to each ENI via live port update
- SetIpAddressType (ipv4-only, idempotent)
- wire all four ops through gateway, NATS client, and daemon handlers
- frontend: editable Tags tab on LB/TG detail, Security groups tab on LB detail

* feat(elbv2): redirect listener/rule actions

- add redirect action type; parse/store/read-back RedirectActionConfig
- validate status code + render-safe fields (strip AWS #{...} placeholders)
- accept redirect and fixed-response on listener rules (not just defaults)
- HAProxy: http-request redirect scheme (HTTP->HTTPS fast path) or rebuilt
  location with placeholder->fetch translation; synthetic per-rule backends

* refactor: dedupe ELBv2 attribute handlers and harden store lookups

Collapse the four near-identical Modify/Describe attribute handlers (target
group and load balancer mirror pairs) onto generic helpers in both the service
impl and the gateway, and table-drive the mirror-pair tests over a TG/LB kind.

- service_impl_attributes.go: generic attrResource/modifyResourceAttributes/
  describeResourceAttributes plus sdkAttrsIn/sdkAttrsOut; folds in nil-input
  guards, explicit empty-accountID rejection, fresh-string output, and a
  no-op skip-persist for unchanged Modify calls.
- gateway/elbv2/attributes.go: generic attributeOp + shared validateAttrArn,
  replacing four per-endpoint files.
- types.go: default attribute sets are now package-level maps cloned on access
  instead of rebuilt per Describe; document the access_logs empty-string defaults.
- store.go: drop the stale 'linear scan' docstring and log ARN mismatches on
  GetLoadBalancerByArn/GetTargetGroupByArn instead of silently returning nil.

Adds cross-account isolation, wrong-account no-mutation, and empty-string-clear
coverage.

* feat: support ip target type in ELBv2 RegisterTargets

RegisterTargets resolved every target ID through an instance ENI lookup, so
ip-type target groups silently no-op'd with an empty PrivateIP and never
received traffic.

- types.go: add TargetTypeInstance/TargetTypeIP constants.
- CreateTargetGroup: default to instance; reject target types other than
  instance/ip with InvalidParameterValue.
- RegisterTargets: resolve PrivateIP per target type via resolveRegisteredTargetIP
  — ip targets use the supplied IP directly (validated with net.ParseIP),
  instance targets keep the ENI lookup and reject IP-shaped IDs.

* fix: cascade rule deletion in ELBv2 DeleteLoadBalancer

DeleteLoadBalancer deleted listeners via store.DeleteListener directly, which
bypasses the rule cascade in the DeleteListener handler. Orphan rules survived
LB teardown and pinned their target groups as ResourceInUse, leaving the TG
permanently undeletable and breaking tofu destroy on multinode.

Extract deleteListenerCascade (rules then listener record) and call it from both
DeleteListener and the DeleteLoadBalancer cleanup loop.

Regression: TestDeleteLoadBalancer_CascadesRuleDeletion.

* fix: harden ELBv2 delete lifecycle against orphaned cascades

- DeleteLoadBalancer: fail-fast on listener-list/cascade error and run the
  rule cascade before backing-artifact teardown, so a transient failure
  leaves the record intact for retry instead of orphaning rules.
- DeleteTargetGroup: in-use scan skips listeners/rules whose owning load
  balancer no longer exists, so a partial-teardown orphan can't pin a target
  group as ResourceInUse permanently.
- DeleteRule: propagate the listener lookup error instead of discarding it,
  so a failed HAProxy reload is no longer reported as success.
- Add lifecycle invariants for no-orphan completeness, no store-bypass
  cascade, and target-group deletability after LB teardown.
- Drop plan/bead references from comments.

* test: add NATS e2e round-trips for new ELBv2 ops

Cover the client→NATS→handler marshalling path that unit tests skip:
tags lifecycle (AddTags/DescribeTags/RemoveTags), target-group and
load-balancer attribute round-trips, redirect-action default listener,
ip target-type registration, and SetSecurityGroups/SetIpAddressType.

* fix(elbv2): correct misleading InvalidConfigurationRequest message

The error code is shared by five unrelated conditions (dualstack
rejection in SetIpAddressType, cross-listener rule moves, unsupported
rule action types, and the two genuine NLB security-group cases), but
its canonical message claimed security groups were unsupported on
network load balancers. Callers hitting it from SetIpAddressType or the
rule paths got a message about security groups and NLBs that did not
match their request.

Neutralize to a generic, condition-agnostic message. The AWS error code
is unchanged.

* style(ui): oxfmt security-groups-editor test
2026-06-03 21:49:25 +10:00
Josh Siviour b3f7251239 refactor: migrate lbagent signing to predastore/auth.SignReq (#281)
agent.go signed gateway requests with aws-sdk-go v1's v4.Signer. Switch to
predastore/auth.SignReq (a thin wrapper over aws-sdk-go-v2's v4.Signer) so the
monorepo runs a single signer version and drops the last direct dependency on
aws-sdk-go v1's signer subpackage.

- Replace the *v4.Signer field with the raw access/secret keys.
- signedPost computes the hex SHA-256 payload hash and calls auth.SignReq.
- Add TestSignedPost_ProducesVerifiableSignature: the signed request parses and
  verifies server-side via auth.ParseReq/Verify with a matching body hash.
2026-06-03 20:17:55 +10:00
Josh Siviour de5ee4339a fix(elbv2): render fixed-response listener defaults + DescribeTags listener-rules (#278)
Shared-ingress ALBs use a fixed-response default action (HTTP 404) with
host-header rules forwarding to per-app target groups. Two gaps broke this:

- The HAProxy generator read DefaultActions[0].TargetGroupArn unconditionally,
  so a fixed-response default (empty ARN) rendered `default_backend bk_`, a
  dangling reference that made HAProxy fail to start — the listener never bound
  :80 and the LB served no traffic. Store and render the fixed-response config
  as a synthetic backend (`http-request return status N ...`), with status,
  content-type, and body validated before interpolation.
- ListenerAction dropped the fixed-response config on create/modify, so
  DescribeListeners read back an empty action and tofu saw a perpetual diff.
  Preserve it through CreateListener/ModifyListener and the SDK conversion.
- DescribeTags returned InvalidParameterValue for listener-rule ARNs; resolve
  the rule and return an (untagged) descriptor instead.

Adds generator tests for the fixed-response default (with host-header rules)
and unsafe-body rejection, plus DescribeTags listener-rule coverage.
2026-06-03 15:06:35 +10:00
joshsiv-mulga 3de1d6d7c5 fix(microvm): build initramfs device nodes via fakeroot, drop sudo mknod
sudo mknod created real /dev/console and /dev/null char-device nodes on the
host under $CHROOT_DIR. Real device nodes on the host filesystem are a footgun:
a stale-image cleanup, tmp reaper, or an empty $CHROOT_DIR (which would redirect
the recursive /dev removal at the host root) could clobber host /dev/null and
force a reboot, breaking CI servers.

Write the two static nodes the kernel needs into the cpio archive inside a
fakeroot session instead. mknod/cpio see faked device entries and emit real
char-device records into the archive, while the host filesystem never gets an
actual node. init mounts devtmpfs over /dev at runtime for everything else.

Also guard $CHROOT_DIR against empty/unexpected values before any destructive
removal, and add fakeroot to the required-tools check.
2026-06-03 11:04:51 +10:00
Julian Sommer 044d35737e feat: remove bash e2e for e2e-go 2026-06-03 10:32:30 +10:00
1263 changed files with 133376 additions and 36047 deletions
+2 -8
View File
@@ -6,16 +6,10 @@
# Set the matching branch protection rule on `dev` and `main` to
# "Require review from Code Owners" or this file is advisory only.
# Fall-through: any change needs a team review.
* @mulgadc/mulgadc-engineers
# Network layer is governed by ADR-0006 invariant tests.
# Changes here must respect those clauses — see
# spinifex/network/invariants/ and
# Changes here must respect those clauses
# see spinifex/network/invariants/
# docs/development/proposals/az-local-architecture/0006-spinifex-network-layer-contract.md
/spinifex/network/ @joshsiv-mulga
/spinifex/vpcd/ @joshsiv-mulga
# Invariants suite is the executable ADR-0006 fence — any widening or
# narrowing of the scan roots needs an explicit owner review.
/spinifex/network/invariants/ @joshsiv-mulga
Binary file not shown.

After

Width:  |  Height:  |  Size: 189 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 274 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 352 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

+7
View File
@@ -0,0 +1,7 @@
## Summary
<!-- What does this PR do, and why? -->
## Changes
<!-- The notable changes, as bullets. -->
+46 -5
View File
@@ -2,6 +2,13 @@ name: E2E Bare-Metal
on:
workflow_dispatch:
workflow_call:
inputs:
ref:
description: "spinifex ref to checkout/test (default: triggering ref)"
required: false
type: string
default: ""
permissions:
contents: read
@@ -12,7 +19,7 @@ env:
jobs:
e2e_baremetal:
name: Bare-Metal Multi-Node E2E
name: Bare-Metal Single-Node E2E
runs-on: [ self-hosted, ci-baremetal ]
timeout-minutes: 120
concurrency:
@@ -20,11 +27,17 @@ jobs:
cancel-in-progress: false
env:
BM_ISO_CACHE: /var/cache/spinifex-iso-builder
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
- uses: actions/checkout@v7
with:
ref: ${{ inputs.ref || github.ref }}
- uses: actions/create-github-app-token@v3
id: app-token
@@ -33,7 +46,7 @@ jobs:
private-key: ${{ secrets.APP_PRIVATE_KEY }}
repositories: mulga
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
repository: mulgadc/mulga
token: ${{ steps.app-token.outputs.token }}
@@ -91,6 +104,8 @@ 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
- name: Run Bare-Metal E2E
env:
@@ -100,8 +115,34 @@ jobs:
BM_UBUNTU_IMAGE: ${{ env.BM_ISO_CACHE }}/ubuntu-26.04-cloudimg-amd64.img
SPX_EXTERNAL_POOL: ${{ env.SPINIFEX_CI_POOL }}
run: |
mkdir -p "${ARTIFACT_DIR}"
set -o pipefail
sudo -E mulga/scripts/baremetal/run-baremetal-e2e.sh \
--vmlinuz "${BM_ISO_CACHE}/vmlinuz" \
--initrd "${BM_ISO_CACHE}/initrd.img" \
--squashfs "${BM_ISO_CACHE}/spinifex.squashfs" \
--tests-tar "${RUNNER_TEMP}/spinifex-tests.tar"
--tests-tar "${RUNNER_TEMP}/spinifex-tests.tar" \
2>&1 | tee "${ARTIFACT_DIR}/test-baremetal.log"
- name: Generate JUnit report
if: always()
run: |
LOG="${ARTIFACT_DIR}/test-baremetal.log"
[ -f "$LOG" ] || exit 0
go-junit-report -in "$LOG" -out "${ARTIFACT_DIR}/junit-baremetal.xml" || true
- name: Analyze e2e failures
if: always()
uses: ./.github/actions/e2e-analyze
with:
junit-glob: ${{ env.ARTIFACT_DIR }}/junit-*.xml
log-dir: ${{ env.ARTIFACT_DIR }}
title: "E2E failure analysis (cell 20)"
- uses: actions/upload-artifact@v7
if: always()
with:
name: e2e-artifacts-cell-20-${{ github.run_id }}
path: ${{ env.ARTIFACT_DIR }}
if-no-files-found: warn
retention-days: 14
+2 -26
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,16 +35,12 @@ 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
- uses: actions/checkout@v7
name: Checkout Spinifex
with:
path: spinifex
- uses: actions/checkout@v6
- uses: actions/checkout@v7
name: Checkout Mulga (tofu-cluster)
with:
repository: mulgadc/mulga
@@ -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
File diff suppressed because it is too large Load Diff
+142 -26
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'
@@ -70,7 +74,7 @@ jobs:
private-key: ${{ secrets.APP_PRIVATE_KEY }}
repositories: mulga
- uses: actions/checkout@v6
- uses: actions/checkout@v7
if: steps.gate.outputs.selected == 'true'
with:
repository: mulgadc/mulga
@@ -78,6 +82,11 @@ jobs:
sparse-checkout: scripts/tofu-cluster
path: mulga
- uses: actions/checkout@v7
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
@@ -216,3 +314,21 @@ jobs:
tofu workspace select -or-create "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1
tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars ${TOFU_VAR_ARGS} -auto-approve -input=false >> "$TOFU_LOG" 2>&1
./scrub-env.sh "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1 || true
# 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.
baremetal:
name: cell-20 (baremetal/pxe/real-hw)
if: >-
github.event_name == 'schedule' ||
inputs.cells == '' ||
contains(format(',{0},', inputs.cells), ',20,')
permissions:
contents: read
statuses: write
uses: ./.github/workflows/e2e-baremetal.yml
with:
ref: ${{ github.event_name == 'schedule' && 'dev' || github.ref_name }}
secrets: inherit
+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, iam)
# └─ 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 iam"
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@v7
with:
repository: mulgadc/mulga
token: ${{ steps.app-token.outputs.token }}
sparse-checkout: scripts/tofu-cluster
path: mulga
- uses: actions/checkout@v7
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 iam' }}"
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@v7
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@v7
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@v7
with:
repository: mulgadc/mulga
token: ${{ steps.app-token.outputs.token }}
sparse-checkout: scripts/tofu-cluster
path: mulga
- uses: actions/checkout@v7
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@v7
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@v7
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
+762 -332
View File
File diff suppressed because it is too large Load Diff
+96
View File
@@ -0,0 +1,96 @@
name: Publish EKS node AMI
# Builds the eks-node system AMI (libguestfs, on the runner) and publishes the
# compressed qcow2 + .sha256 sidecar to the Cloudflare R2 bucket that backs
# iso.mulgadc.com/system-ami/. Operators then install it on any cluster with:
# spx admin images import --name spinifex-eks-node
#
# Manual-only: publishing overwrites the mirror artifact, so it is a deliberate
# action, not something every push should trigger.
on:
workflow_dispatch:
inputs:
dry_run:
description: "Build + convert only; skip the R2 upload"
type: boolean
default: false
permissions:
contents: read
concurrency:
group: publish-eks-ami
cancel-in-progress: false
jobs:
publish:
name: Build + publish eks-node AMI
runs-on: [ self-hosted, ci-single ]
steps:
- name: Checkout Spinifex
uses: actions/checkout@v7
with:
path: spinifex
- name: Checkout Viperblock
uses: actions/checkout@v7
with:
repository: mulgadc/viperblock
ref: dev
path: viperblock
- name: Checkout Predastore
uses: actions/checkout@v7
with:
repository: mulgadc/predastore
ref: dev
path: predastore
- name: Setup Go
uses: actions/setup-go@v6
with:
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
- name: Install build + upload tooling
run: |
set -euo pipefail
sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends libguestfs-tools qemu-utils
if ! command -v aws &>/dev/null; then
curl -fsSL "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o /tmp/awscliv2.zip
unzip -q /tmp/awscliv2.zip -d /tmp/aws-install
sudo /tmp/aws-install/aws/install
rm -rf /tmp/awscliv2.zip /tmp/aws-install
fi
- name: Build + publish to R2
working-directory: spinifex
env:
# Map the repo's R2 secrets onto the names publish-system-image.sh reads.
AWS_ACCESS_KEY_ID: ${{ secrets.CF_R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.CF_R2_SECRET_ACCESS_KEY }}
R2_ENDPOINT: ${{ secrets.CF_R2_ENDPOINT_URL }}
R2_BUCKET: spinifex-iso
DRY_RUN: ${{ inputs.dry_run }}
run: |
set -euo pipefail
# libguestfs needs to read a host kernel; Ubuntu ships it mode 0600.
# direct backend = no libvirt on the runner.
export LIBGUESTFS_BACKEND=direct
sudo chmod 0644 /boot/vmlinuz-* || true
FLAGS=(--build)
if [[ "${DRY_RUN}" == "true" ]]; then
FLAGS+=(--dry-run)
fi
./scripts/publish-system-image.sh \
scripts/images/eks-node/manifest.conf "${FLAGS[@]}"
+70 -45
View File
@@ -9,21 +9,21 @@ permissions:
jobs:
distro-amd64:
runs-on: ubuntu-24.04
runs-on: ubuntu-26.04
steps:
- name: Checkout Spinifex
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
path: spinifex
- name: Checkout Predastore
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
repository: mulgadc/predastore
path: predastore
- name: Checkout Viperblock
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
repository: mulgadc/viperblock
path: viperblock
@@ -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
@@ -54,21 +54,21 @@ jobs:
spinifex/scripts/setup.sh
distro-arm64:
runs-on: ubuntu-24.04-arm
runs-on: ubuntu-26.04-arm
steps:
- name: Checkout Spinifex
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
path: spinifex
- name: Checkout Predastore
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
repository: mulgadc/predastore
path: predastore
- name: Checkout Viperblock
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
repository: mulgadc/viperblock
path: viperblock
@@ -91,7 +91,7 @@ jobs:
release:
needs: [ distro-amd64, distro-arm64 ]
runs-on: ubuntu-24.04
runs-on: ubuntu-26.04
steps:
- name: Download amd64 Artifacts
uses: actions/download-artifact@v8
@@ -131,7 +131,10 @@ jobs:
REPO: ${{ github.repository }}
steps:
- name: Clean Workspace
run: rm -rf "$GITHUB_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
@@ -140,7 +143,7 @@ jobs:
private-key: ${{ secrets.APP_PRIVATE_KEY }}
repositories: mulga
- uses: actions/checkout@v6
- uses: actions/checkout@v7
with:
repository: mulgadc/mulga
token: ${{ steps.app-token.outputs.token }}
@@ -169,8 +172,30 @@ jobs:
TARBALL=$(find "${GITHUB_WORKSPACE}/release-assets" -name "*-linux-amd64.tar.gz" | head -1)
./bootstrap-install.sh "${SPINIFEX_CI_ENV}" "$SETUP_SH" "$TARBALL"
- name: Teardown
if: always()
working-directory: mulga/scripts/tofu-cluster
run: |
tofu workspace select -or-create "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1
tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1
./scrub-env.sh "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1 || true
baremetal:
needs: release
permissions:
contents: read
statuses: write
uses: ./.github/workflows/e2e-baremetal.yml
secrets: inherit
promote:
needs: [ verify, baremetal ]
runs-on: ubuntu-26.04
env:
TAG: ${{ github.ref_name }}
REPO: ${{ github.repository }}
steps:
- name: Promote Release
if: success()
env:
GH_TOKEN: ${{ github.token }}
run: |
@@ -182,41 +207,41 @@ jobs:
echo "Release ${TAG} promoted to latest"
fi
# Annotate the (still-draft) release if either gate failed.
mark-failed:
needs: [ verify, baremetal ]
if: failure()
runs-on: ubuntu-26.04
env:
TAG: ${{ github.ref_name }}
REPO: ${{ github.repository }}
steps:
- name: Mark Release Failed
if: failure()
env:
GH_TOKEN: ${{ github.token }}
run: |
EXISTING_BODY=$(gh release view "$TAG" --repo "$REPO" --json body -q .body)
NOTES_FILE=$(mktemp)
printf 'Verification failed — see workflow run for details.\n\n%s' "$EXISTING_BODY" > "$NOTES_FILE"
printf 'Verification failed (tofu install or bare-metal boot) — see workflow run for details.\n\n%s' "$EXISTING_BODY" > "$NOTES_FILE"
gh release edit "$TAG" --repo "$REPO" --notes-file "$NOTES_FILE"
rm -f "$NOTES_FILE"
- name: Teardown
if: always()
working-directory: mulga/scripts/tofu-cluster
run: |
tofu workspace select -or-create "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1
tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1
./scrub-env.sh "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1 || true
iso:
needs: release
runs-on: [ self-hosted, ci-single ]
timeout-minutes: 90
# ISO build (~60-90m) overlaps the parallel baremetal run (~120m); the
# upload gate then waits for baremetal to finish, so the job must outlast it.
timeout-minutes: 180
steps:
- name: Clean Workspace
run: |
ROOTFS="${GITHUB_WORKSPACE}/mulga/scripts/iso-builder/.cache/rootfs"
# Mirror make clean's umount sequence unconditionally — lazy unmount
# detaches immediately even if the mount is busy, so rm -rf is safe.
sudo umount -l -R "${ROOTFS}" 2>/dev/null || true
sudo umount -l "${ROOTFS}/dev/pts" 2>/dev/null || true
sudo umount -l "${ROOTFS}/dev" 2>/dev/null || true
sudo umount -l "${ROOTFS}/sys" 2>/dev/null || true
sudo umount -l "${ROOTFS}/proc" 2>/dev/null || true
sudo rm -rf "$GITHUB_WORKSPACE"/*
sudo rm -rf --one-file-system "$GITHUB_WORKSPACE"/*
- uses: actions/create-github-app-token@v3
id: app-token
@@ -226,25 +251,25 @@ jobs:
repositories: mulga
- name: Checkout Spinifex
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
path: spinifex
ref: ${{ github.ref }}
- name: Checkout Predastore
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
repository: mulgadc/predastore
path: predastore
- name: Checkout Viperblock
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
repository: mulgadc/viperblock
path: viperblock
- name: Checkout Mulga iso-builder
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
repository: mulgadc/mulga
token: ${{ steps.app-token.outputs.token }}
@@ -289,24 +314,25 @@ jobs:
SPINIFEX_BIN="${GITHUB_WORKSPACE}/spinifex/bin/spx" \
INSTALLER_BIN="${GITHUB_WORKSPACE}/spinifex/bin/spinifex-installer"
- name: Wait for verify
- name: Wait for verify + baremetal
id: wait-verify
if: steps.build-iso.outcome == 'success'
continue-on-error: true
env:
GH_TOKEN: ${{ github.token }}
run: |
echo "Waiting for verify job to complete..."
echo "Waiting for verify + baremetal jobs to complete..."
while true; do
CONCLUSION=$(gh api \
"repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/jobs" \
--jq '.jobs[] | select(.name == "verify") | .conclusion // empty')
if [ -n "$CONCLUSION" ]; then
echo "Verify conclusion: $CONCLUSION"
[ "$CONCLUSION" = "success" ] && exit 0
JOBS=$(gh api \
"repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/jobs" --paginate)
VERIFY=$(echo "$JOBS" | jq -r '.jobs[] | select(.name == "verify") | .conclusion // empty')
BAREMETAL=$(echo "$JOBS" | jq -r '.jobs[] | select(.name | startswith("baremetal")) | .conclusion // empty')
if [ -n "$VERIFY" ] && [ -n "$BAREMETAL" ]; then
echo "verify=$VERIFY baremetal=$BAREMETAL"
{ [ "$VERIFY" = "success" ] && [ "$BAREMETAL" = "success" ]; } && exit 0
exit 1
fi
echo "Verify still running, retrying in 30s..."
echo "Still waiting (verify=${VERIFY:-pending} baremetal=${BAREMETAL:-pending}), retry 30s..."
sleep 30
done
@@ -327,14 +353,13 @@ jobs:
AWS_ACCESS_KEY_ID: ${{ secrets.CF_R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.CF_R2_SECRET_ACCESS_KEY }}
CF_R2_ENDPOINT_URL: ${{ secrets.CF_R2_ENDPOINT_URL }}
VERSION: ${{ github.ref_name }}
run: |
aws --endpoint-url="${CF_R2_ENDPOINT_URL}" \
s3 cp "mulga/scripts/iso-builder/dist/spinifex-${VERSION}.iso" \
"s3://spinifex-iso/spinifex-${VERSION}.iso"
s3 cp "mulga/scripts/iso-builder/dist/spinifex.iso" \
"s3://spinifex-iso/spinifex.iso"
aws --endpoint-url="${CF_R2_ENDPOINT_URL}" \
s3 cp "mulga/scripts/iso-builder/dist/spinifex-${VERSION}.iso.sha256" \
"s3://spinifex-iso/spinifex-${VERSION}.iso.sha256"
s3 cp "mulga/scripts/iso-builder/dist/spinifex.iso.sha256" \
"s3://spinifex-iso/spinifex.iso.sha256"
- name: Mark ISO failed
if: always() && (steps.build-iso.outcome == 'failure' || steps.upload.outcome == 'failure')
+15 -12
View File
@@ -23,24 +23,24 @@ permissions:
jobs:
build_and_test:
name: Build and Test
runs-on: ubuntu-24.04
runs-on: ubuntu-26.04
steps:
- name: Checkout Spinifex
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
path: spinifex
fetch-depth: 2
- name: Checkout Viperblock
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
repository: mulgadc/viperblock
ref: dev
path: viperblock
- name: Checkout Predastore
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
repository: mulgadc/predastore
ref: dev
@@ -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
@@ -69,23 +72,23 @@ jobs:
race_detection:
name: Race Detection
runs-on: ubuntu-24.04
runs-on: ubuntu-26.04
steps:
- name: Checkout Spinifex
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
path: spinifex
- name: Checkout Viperblock
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
repository: mulgadc/viperblock
ref: dev
path: viperblock
- name: Checkout Predastore
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
repository: mulgadc/predastore
ref: dev
@@ -105,23 +108,23 @@ jobs:
lint_and_security:
name: Lint and Security
runs-on: ubuntu-24.04
runs-on: ubuntu-26.04
steps:
- name: Checkout Spinifex
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
path: spinifex
- name: Checkout Viperblock
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
repository: mulgadc/viperblock
ref: dev
path: viperblock
- name: Checkout Predastore
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
repository: mulgadc/predastore
ref: dev
+2 -2
View File
@@ -23,14 +23,14 @@ permissions:
jobs:
ui_build_and_test:
name: UI Build and Test
runs-on: ubuntu-24.04
runs-on: ubuntu-26.04
defaults:
run:
working-directory: spinifex/services/spinifexui/frontend
steps:
- name: Checkout Code
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
sparse-checkout: spinifex/services/spinifexui/frontend
-10
View File
@@ -37,13 +37,3 @@ Thumbs.db
# Test reports
*-report.txt
# Internal docs (moved to private mulga repo)
docs/INTERNAL.md
docs/PRIVATE.md
docs/DEVNOTES.md
docs/PLAN.md
docs/TODO.md
docs/development/
docs/internal/
scripts/iac/aws/spinifex-test.pem
-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/)
+25 -77
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
@@ -73,24 +68,27 @@ build-lb-agent:
@echo -e "\n....Building lb-agent (static)"
CGO_ENABLED=0 GOFIPS140=v1.0.0 go build -ldflags "-s -w" -o ./bin/lb-agent cmd/lb-agent/main.go
build-system-image: ## Build a system image from manifest (use IMAGE=lb or IMAGE=eks-server)
build-system-image: ## Build a system image from manifest (use IMAGE=lb or IMAGE=eks-node)
ifndef IMAGE
$(error IMAGE is required. Usage: make build-system-image IMAGE=lb)
endif
@if [ -f scripts/images/$(IMAGE).conf ]; then \
./scripts/build-system-image.sh scripts/images/$(IMAGE).conf; \
./scripts/build-system-image.sh scripts/images/$(IMAGE).conf $(if $(IMPORT),--import); \
elif [ -f scripts/images/$(IMAGE)/manifest.conf ]; then \
./scripts/build-system-image.sh scripts/images/$(IMAGE)/manifest.conf; \
./scripts/build-system-image.sh scripts/images/$(IMAGE)/manifest.conf $(if $(IMPORT),--import); \
else \
echo "ERROR: no manifest at scripts/images/$(IMAGE).conf or scripts/images/$(IMAGE)/manifest.conf"; \
exit 1; \
fi
build-eks-server-image: ## Build the eks-server AMI (K3s control-plane variant)
$(MAKE) build-system-image IMAGE=eks-server
build-eks-node-image: ## Build the unified eks-node AMI (K3s server+agent; role at first boot; IMPORT=1 to register)
$(MAKE) build-system-image IMAGE=eks-node
build-eks-agent-image: ## Build the eks-agent AMI (K3s worker variant)
$(MAKE) build-system-image IMAGE=eks-agent
import-eks-node-image: ## Build + register the eks-node AMI (requires a running cluster)
$(MAKE) build-system-image IMAGE=eks-node IMPORT=1
publish-eks-node-image: ## Build + publish the eks-node AMI to Cloudflare R2 (needs R2_ENDPOINT + AWS_* env)
./scripts/publish-system-image.sh scripts/images/eks-node/manifest.conf --build
MICROVM_OUT_DIR := build/microvm
MICROVM_ARTIFACTS := $(MICROVM_OUT_DIR)/vmlinuz $(MICROVM_OUT_DIR)/initramfs.cpio.gz
@@ -112,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
@@ -136,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)...."
@@ -165,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
@@ -198,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 \
@@ -231,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 ./...
@@ -292,55 +287,8 @@ 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)
.PHONY: build build-ui build-installer build-lb-agent build-system-image build-eks-server-image build-eks-agent-image build-microvm-image install-microvm go_build go_run preflight test test-cover test-race diff-coverage bench run test-actions test-harness manifest-check diff-coverage bench run \
.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 \
distro distro-amd64 distro-arm64 distro-clean \
ansible-dev-preflight ansible-dev-teardown ansible-dev-install ansible-dev-reset ansible-dev-deploy ansible-dev-status ansible-dev-logs ansible-dev-snapshot ansible-dev-restore ansible-dev-version ansible-dev-vpc
distro distro-amd64 distro-arm64 distro-clean
+45 -7
View File
@@ -6,20 +6,58 @@ mount -t devtmpfs dev /dev
# virtio_mmio is built-in (CONFIG_VIRTIO_MMIO=y) so virtio-net devices are
# already enumerated by the time PID 1 runs — no modprobe needed.
# qemu_fw_cfg and virtio_net are =m in stock Alpine linux-virt, so load them.
modprobe qemu_fw_cfg 2>/dev/null || modprobe fw_cfg_sysfs 2>/dev/null || true
modprobe virtio_net 2>/dev/null || true
#
# modprobe needs a usable modules.dep; when the image is built on a host whose
# depmod can't produce one, busybox modprobe silently no-ops and the module
# never loads — fw_cfg then never appears and init panics. insmod by explicit
# path needs no modules.dep, so fall back to it. Handles .ko and .ko.gz.
load_mod() {
modprobe "$1" 2>/dev/null && return 0
for ko in $(find /lib/modules -name "$1.ko" -o -name "$1.ko.gz" 2>/dev/null); do
insmod "$ko" 2>/dev/null && return 0
case "$ko" in
*.gz) gunzip -c "$ko" >/tmp/mod.ko 2>/dev/null \
&& insmod /tmp/mod.ko 2>/dev/null && return 0 ;;
esac
done
return 1
}
load_mod qemu_fw_cfg || load_mod fw_cfg_sysfs || true
# virtio_net depends on net_failover, which depends on failover. The load_mod
# insmod fallback loads a single .ko with no dependency resolution, and
# modules.dep is absent when the image is built on a kmod-less host (depmod
# skipped), so modprobe can't resolve the chain either. Load it explicitly
# bottom-up so the NIC comes up regardless of how the image was built —
# without this the agent has no network and the LB hangs in provisioning.
load_mod failover || true
load_mod net_failover || true
load_mod virtio_net || true
for i in 1 2 3 4 5; do
[ -f /sys/firmware/qemu_fw_cfg/by_name/opt/spinifex/netcfg/raw ] && break
sleep 0.02
NETCFG=/sys/firmware/qemu_fw_cfg/by_name/opt/spinifex/netcfg/raw
i=0
while [ "$i" -lt 25 ]; do
[ -f "$NETCFG" ] && break
sleep 0.04
i=$((i + 1))
done
if [ ! -f /sys/firmware/qemu_fw_cfg/by_name/opt/spinifex/netcfg/raw ]; then
if [ ! -f "$NETCFG" ]; then
# Dump host/guest fw_cfg state to the serial console before init exits so a
# "netcfg not available" panic is diagnosable from the captured console log
# (module-not-loaded vs qemu-not-exposing-by_name vs wrong machine type).
echo "[init] FATAL: fw_cfg netcfg not available" >&2
echo "[init] dmi: vendor=$(cat /sys/class/dmi/id/sys_vendor 2>/dev/null) product=$(cat /sys/class/dmi/id/product_name 2>/dev/null) bios=$(cat /sys/class/dmi/id/bios_version 2>/dev/null)" >&2
echo "[init] qemu_fw_cfg dir present: $([ -d /sys/firmware/qemu_fw_cfg ] && echo yes || echo NO)" >&2
echo "[init] modules: $(lsmod 2>/dev/null | awk 'NR>1{print $1}' | tr '\n' ',')" >&2
echo "[init] by_name listing:" >&2
ls -la /sys/firmware/qemu_fw_cfg/by_name/ 2>&1 | sed 's/^/[init] /' >&2
ls -la /sys/firmware/qemu_fw_cfg/by_name/opt/ 2>&1 | sed 's/^/[init] /' >&2
echo "[init] dmesg fw_cfg:" >&2
dmesg 2>/dev/null | grep -i fw_cfg | sed 's/^/[init] /' >&2
exit 1
fi
. /sys/firmware/qemu_fw_cfg/by_name/opt/spinifex/netcfg/raw
. "$NETCFG"
for n in 0 1 2 3 4 5; do
eval mac=\$NIC${n}_MAC
+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
+12 -23
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,37 +17,25 @@ OOMScoreAdjust=-500
Environment=SPINIFEX_CONFIG_PATH=/etc/spinifex/spinifex.toml
Environment=SPINIFEX_BASE_DIR=/var/lib/spinifex/vpcd/
# Filesystem access — NoNewPrivileges omitted (vpcd uses sudo for ovs-vsctl, ovn-nbctl, systemctl)
ProtectSystem=full
# 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
ReadOnlyPaths=/etc/spinifex/spinifex.toml /etc/spinifex/ca.pem
ReadWritePaths=/var/lib/spinifex/vpcd /var/log/spinifex
# Hardening
ProtectHome=yes
PrivateTmp=yes
RestrictSUIDSGID=no
LockPersonality=yes
RestrictRealtime=yes
RestrictNamespaces=yes
ProtectClock=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectProc=invisible
# 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.
# Note, disabled for now, sudo issue with ovs
AmbientCapabilities=CAP_NET_ADMIN CAP_NET_RAW CAP_NET_BIND_SERVICE CAP_SETUID CAP_SETGID
CapabilityBoundingSet=CAP_NET_ADMIN CAP_NET_RAW CAP_NET_BIND_SERVICE CAP_SETUID CAP_SETGID
# 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
ProtectHostname=yes
KeyringMode=private
+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
+368
View File
@@ -0,0 +1,368 @@
// ecr-credential-provider is a kubelet exec credential provider that lets a
// Spinifex EKS worker node pull images from the internal Spinifex ECR registry.
//
// kubelet invokes it per-pull, feeding a CredentialProviderRequest on STDIN and
// reading a CredentialProviderResponse on STDOUT. The provider fetches the
// node IAM-role credentials from IMDS, SigV4-signs GetAuthorizationToken against
// the AWS gateway's ECR endpoint, and returns the minted AWS:<jwt> as Basic auth
// for the requested registry host.
//
// Static node config (gateway URL, CA, region) is read from the cloud-init env
// file /etc/spinifex-eks/agent.env (KEY=value); real env vars override it.
package main
import (
"bufio"
"crypto/tls"
"crypto/x509"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"strings"
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/credentials"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/ecr"
"github.com/mulgadc/spinifex/internal/tlsconfig"
_ "github.com/mulgadc/spinifex/internal/fipsboot"
)
const (
credProviderAPIVersion = "credentialprovider.kubelet.k8s.io/v1"
defaultCacheDuration = "10h"
// expiryMargin trims the token's real TTL so kubelet re-invokes the provider
// before the JWT actually expires mid-run.
expiryMargin = 30 * time.Minute
defaultEnvFile = "/etc/spinifex-eks/agent.env"
defaultGatewayCA = "/etc/spinifex-eks/gateway-ca.pem"
defaultIMDSBase = "http://169.254.169.254/latest"
imdsTokenTTL = "21600"
)
// CredentialProviderRequest is the kubelet exec-provider request on STDIN.
type CredentialProviderRequest struct {
APIVersion string `json:"apiVersion"`
Kind string `json:"kind"`
Image string `json:"image"`
}
// AuthConfig is a single registry credential entry.
type AuthConfig struct {
Username string `json:"username"`
Password string `json:"password"`
}
// CredentialProviderResponse is the kubelet exec-provider response on STDOUT.
type CredentialProviderResponse struct {
APIVersion string `json:"apiVersion"`
Kind string `json:"kind"`
CacheKeyType string `json:"cacheKeyType"`
CacheDuration string `json:"cacheDuration"`
Auth map[string]AuthConfig `json:"auth"`
}
// config holds the static node settings the provider needs.
type config struct {
GatewayURL string
GatewayCA string
Region string
IMDSBase string
}
func main() {
cfg := loadConfig(defaultEnvFile)
httpClient, err := gatewayHTTPClient(cfg.GatewayCA)
if err != nil {
slog.Error("ecr-credential-provider: build gateway client", "err", err)
emitEmpty(os.Stdout)
os.Exit(1)
}
if err := run(os.Stdin, os.Stdout, cfg, httpClient); err != nil {
slog.Error("ecr-credential-provider: run failed", "err", err)
// Still emit a valid empty-auth response so kubelet has a parseable reply.
emitEmpty(os.Stdout)
os.Exit(1)
}
}
// run is the testable core: it reads a CredentialProviderRequest from in,
// resolves credentials via IMDS + the gateway, and writes a
// CredentialProviderResponse to out. The httpClient must trust the gateway CA.
func run(in io.Reader, out io.Writer, cfg config, httpClient *http.Client) error {
var req CredentialProviderRequest
if err := json.NewDecoder(in).Decode(&req); err != nil {
return fmt.Errorf("decode request: %w", err)
}
if req.Image == "" {
return fmt.Errorf("request image is empty")
}
imageHost := hostFromImage(req.Image)
creds, err := fetchIMDSCredentials(httpClient, cfg.IMDSBase)
if err != nil {
return fmt.Errorf("fetch IMDS credentials: %w", err)
}
username, password, proxyHost, expiresAt, err := getAuthToken(cfg, httpClient, creds)
if err != nil {
return fmt.Errorf("get authorization token: %w", err)
}
auth := AuthConfig{Username: username, Password: password}
authMap := map[string]AuthConfig{imageHost: auth}
if proxyHost != "" && proxyHost != imageHost {
authMap[proxyHost] = auth
}
resp := CredentialProviderResponse{
APIVersion: credProviderAPIVersion,
Kind: "CredentialProviderResponse",
CacheKeyType: "Registry",
CacheDuration: cacheDurationFrom(expiresAt),
Auth: authMap,
}
return json.NewEncoder(out).Encode(&resp)
}
// imdsCreds mirrors the IMDS security-credentials JSON shape.
type imdsCreds struct {
Code string `json:"Code"`
AccessKeyID string `json:"AccessKeyId"`
SecretAccessKey string `json:"SecretAccessKey"`
Token string `json:"Token"`
Expiration string `json:"Expiration"`
}
// fetchIMDSCredentials resolves the node role name then its credentials via
// IMDSv2 (PUT token, then GET with the token header).
func fetchIMDSCredentials(client *http.Client, base string) (imdsCreds, error) {
base = strings.TrimRight(base, "/")
token := imdsV2Token(client, base)
roleName, err := imdsGet(client, base+"/meta-data/iam/security-credentials/", token)
if err != nil {
return imdsCreds{}, fmt.Errorf("fetch role name: %w", err)
}
roleName = strings.TrimSpace(roleName)
if roleName == "" {
return imdsCreds{}, fmt.Errorf("IMDS returned empty role name")
}
body, err := imdsGet(client, base+"/meta-data/iam/security-credentials/"+roleName, token)
if err != nil {
return imdsCreds{}, fmt.Errorf("fetch role credentials: %w", err)
}
var creds imdsCreds
if err := json.Unmarshal([]byte(body), &creds); err != nil {
return imdsCreds{}, fmt.Errorf("decode role credentials: %w", err)
}
if creds.AccessKeyID == "" || creds.SecretAccessKey == "" {
return imdsCreds{}, fmt.Errorf("IMDS credentials missing access/secret key")
}
return creds, nil
}
// imdsV2Token requests an IMDSv2 session token; returns "" if the service does
// not enforce v2 (tokenless v1 then applies).
func imdsV2Token(client *http.Client, base string) string {
req, err := http.NewRequest(http.MethodPut, base+"/api/token", nil)
if err != nil {
return ""
}
req.Header.Set("X-Aws-Ec2-Metadata-Token-Ttl-Seconds", imdsTokenTTL)
resp, err := client.Do(req)
if err != nil {
return ""
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return ""
}
body, _ := io.ReadAll(resp.Body)
return strings.TrimSpace(string(body))
}
func imdsGet(client *http.Client, url, token string) (string, error) {
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return "", err
}
if token != "" {
req.Header.Set("X-Aws-Ec2-Metadata-Token", token)
}
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return "", fmt.Errorf("IMDS %s returned %d: %s", url, resp.StatusCode, strings.TrimSpace(string(body)))
}
return string(body), nil
}
// getAuthToken builds an aws-sdk ECR client pointed at the gateway and calls
// GetAuthorizationToken, decoding the AWS:<jwt> token into Basic-auth parts. It
// returns the username, password, the ProxyEndpoint host, and the token expiry.
func getAuthToken(cfg config, httpClient *http.Client, creds imdsCreds) (username, password, proxyHost string, expiresAt time.Time, err error) {
region := cfg.Region
if region == "" {
region = "us-east-1"
}
sess, serr := session.NewSession(&aws.Config{
Region: aws.String(region),
Endpoint: aws.String(cfg.GatewayURL),
Credentials: credentials.NewStaticCredentials(creds.AccessKeyID, creds.SecretAccessKey, creds.Token),
HTTPClient: httpClient,
DisableSSL: aws.Bool(false),
})
if serr != nil {
return "", "", "", time.Time{}, fmt.Errorf("new session: %w", serr)
}
out, gerr := ecr.New(sess).GetAuthorizationToken(&ecr.GetAuthorizationTokenInput{})
if gerr != nil {
return "", "", "", time.Time{}, fmt.Errorf("GetAuthorizationToken: %w", gerr)
}
if len(out.AuthorizationData) == 0 || out.AuthorizationData[0].AuthorizationToken == nil {
return "", "", "", time.Time{}, fmt.Errorf("GetAuthorizationToken returned no authorization data")
}
data := out.AuthorizationData[0]
raw, derr := base64.StdEncoding.DecodeString(aws.StringValue(data.AuthorizationToken))
if derr != nil {
return "", "", "", time.Time{}, fmt.Errorf("decode authorization token: %w", derr)
}
user, pass, ok := strings.Cut(string(raw), ":")
if !ok {
return "", "", "", time.Time{}, fmt.Errorf("authorization token not in user:password form")
}
if data.ExpiresAt != nil {
expiresAt = aws.TimeValue(data.ExpiresAt)
}
return user, pass, hostFromImage(aws.StringValue(data.ProxyEndpoint)), expiresAt, nil
}
// cacheDurationFrom derives a Go-duration string from the token expiry minus a
// safety margin, falling back to the default when expiry is unknown or too near.
func cacheDurationFrom(expiresAt time.Time) string {
if expiresAt.IsZero() {
return defaultCacheDuration
}
d := time.Until(expiresAt) - expiryMargin
if d <= 0 {
return defaultCacheDuration
}
return d.Round(time.Second).String()
}
// gatewayHTTPClient builds an HTTP client trusting the gateway CA at caPath.
// When caPath is empty it relies on the system trust store.
func gatewayHTTPClient(caPath string) (*http.Client, error) {
tlsCfg := &tls.Config{
MinVersion: tls.VersionTLS13,
CurvePreferences: tlsconfig.Curves,
}
if caPath != "" {
pem, err := os.ReadFile(caPath)
if err != nil {
return nil, fmt.Errorf("read gateway CA %q: %w", caPath, err)
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(pem) {
return nil, fmt.Errorf("gateway CA %q has no usable certificates", caPath)
}
tlsCfg.RootCAs = pool
}
return &http.Client{
Timeout: 15 * time.Second,
Transport: &http.Transport{TLSClientConfig: tlsCfg, MaxIdleConns: 2, IdleConnTimeout: 30 * time.Second},
}, nil
}
// loadConfig reads the cloud-init env file then lets real env vars override. The
// IMDS base is fixed (not in the env file) but overridable for tests.
func loadConfig(envFile string) config {
env := parseEnvFile(envFile)
get := func(key string) string {
if v := os.Getenv(key); v != "" {
return v
}
return env[key]
}
cfg := config{
GatewayURL: get("EKS_GATEWAY_URL"),
GatewayCA: get("EKS_GATEWAY_CA"),
Region: get("EKS_REGION"),
IMDSBase: get("EKS_IMDS_BASE"),
}
if cfg.GatewayCA == "" {
cfg.GatewayCA = defaultGatewayCA
}
if cfg.IMDSBase == "" {
cfg.IMDSBase = defaultIMDSBase
}
return cfg
}
// parseEnvFile reads a simple KEY=value file; missing files yield an empty map.
func parseEnvFile(path string) map[string]string {
out := map[string]string{}
f, err := os.Open(path)
if err != nil {
return out
}
defer f.Close()
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
key, val, ok := strings.Cut(line, "=")
if !ok {
continue
}
out[strings.TrimSpace(key)] = strings.TrimSpace(val)
}
return out
}
// hostFromImage returns the host[:port] portion of an image ref or URL. It strips
// any scheme and trims at the first path separator.
func hostFromImage(image string) string {
if i := strings.Index(image, "://"); i >= 0 {
image = image[i+3:]
}
if i := strings.IndexByte(image, '/'); i >= 0 {
image = image[:i]
}
return image
}
// emitEmpty writes a valid CredentialProviderResponse with no auth entries.
func emitEmpty(out io.Writer) {
resp := CredentialProviderResponse{
APIVersion: credProviderAPIVersion,
Kind: "CredentialProviderResponse",
CacheKeyType: "Registry",
CacheDuration: defaultCacheDuration,
Auth: map[string]AuthConfig{},
}
if err := json.NewEncoder(out).Encode(&resp); err != nil {
slog.Error("ecr-credential-provider: encode empty response", "err", err)
}
}
+138
View File
@@ -0,0 +1,138 @@
package main
import (
"bytes"
"crypto/tls"
"crypto/x509"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// fakeBackend serves both the IMDS role/credentials endpoints and the gateway
// ECR GetAuthorizationToken on one TLS server, routed by request path.
func fakeBackend(t *testing.T) *httptest.Server {
t.Helper()
mux := http.NewServeMux()
// IMDSv2 token.
mux.HandleFunc("/latest/api/token", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
_, _ = w.Write([]byte("imds-v2-token"))
})
// IMDS role name.
mux.HandleFunc("/latest/meta-data/iam/security-credentials/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/latest/meta-data/iam/security-credentials/" {
if r.Header.Get("X-Aws-Ec2-Metadata-Token") != "imds-v2-token" {
w.WriteHeader(http.StatusUnauthorized)
return
}
_, _ = w.Write([]byte("node-role"))
return
}
// Role credentials.
_ = json.NewEncoder(w).Encode(map[string]string{
"Code": "Success",
"Type": "AWS-HMAC",
"AccessKeyId": "AKIATEST",
"SecretAccessKey": "secrettest",
"Token": "sessiontoken",
"AccountId": "123456789012",
})
})
// Gateway ECR GetAuthorizationToken (X-Amz-Target dispatch).
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if !strings.Contains(r.Header.Get("X-Amz-Target"), "GetAuthorizationToken") {
w.WriteHeader(http.StatusBadRequest)
return
}
authToken := base64.StdEncoding.EncodeToString([]byte("AWS:testjwt"))
w.Header().Set("Content-Type", "application/x-amz-json-1.1")
_ = json.NewEncoder(w).Encode(map[string]any{
"authorizationData": []map[string]any{{
"authorizationToken": authToken,
"proxyEndpoint": "https://123456789012.dkr.ecr.us-east-1.spinifex.internal",
}},
})
})
srv := httptest.NewTLSServer(mux)
t.Cleanup(srv.Close)
return srv
}
func clientTrusting(srv *httptest.Server) *http.Client {
pool := x509.NewCertPool()
pool.AddCert(srv.Certificate())
return &http.Client{Transport: &http.Transport{TLSClientConfig: &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12}}}
}
func TestRun_ReturnsBasicAuth(t *testing.T) {
srv := fakeBackend(t)
client := clientTrusting(srv)
cfg := config{
GatewayURL: srv.URL,
Region: "us-east-1",
IMDSBase: srv.URL + "/latest",
}
const image = "123456789012.dkr.ecr.us-east-1.spinifex.internal/spinifex-demo:latest"
reqBody, err := json.Marshal(CredentialProviderRequest{
APIVersion: credProviderAPIVersion,
Kind: "CredentialProviderRequest",
Image: image,
})
if err != nil {
t.Fatalf("marshal request: %v", err)
}
var out bytes.Buffer
if err := run(bytes.NewReader(reqBody), &out, cfg, client); err != nil {
t.Fatalf("run: %v", err)
}
var resp CredentialProviderResponse
if err := json.Unmarshal(out.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal response: %v\n%s", err, out.String())
}
if resp.Kind != "CredentialProviderResponse" {
t.Errorf("Kind = %q, want CredentialProviderResponse", resp.Kind)
}
if resp.CacheKeyType != "Registry" {
t.Errorf("CacheKeyType = %q, want Registry", resp.CacheKeyType)
}
host := "123456789012.dkr.ecr.us-east-1.spinifex.internal"
auth, ok := resp.Auth[host]
if !ok {
t.Fatalf("auth map missing host %q; got %v", host, resp.Auth)
}
if auth.Username != "AWS" {
t.Errorf("username = %q, want AWS", auth.Username)
}
if auth.Password != "testjwt" {
t.Errorf("password = %q, want testjwt", auth.Password)
}
}
func TestHostFromImage(t *testing.T) {
cases := map[string]string{
"acct.dkr.ecr.us-east-1.spinifex.internal/demo:latest": "acct.dkr.ecr.us-east-1.spinifex.internal",
"https://acct.dkr.ecr.us-east-1.spinifex.internal": "acct.dkr.ecr.us-east-1.spinifex.internal",
"host:9999/repo:tag": "host:9999",
"plainhost": "plainhost",
}
for in, want := range cases {
if got := hostFromImage(in); got != want {
t.Errorf("hostFromImage(%q) = %q, want %q", in, got, want)
}
}
}
+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")
}
}
+134
View File
@@ -0,0 +1,134 @@
// eks-gateway-publish runs inside the EKS K3s control-plane VM and relays a
// single publication to the host through the AWS gateway, instead of dialing
// core NATS directly.
//
// It reads a JSON payload on stdin, wraps it as
// {accountId, channel, kind, payload}, SigV4-signs (service "eks") an HTTPS
// POST to {gateway}/clusters/{cluster}/internal-publish, and retries with
// backoff until the gateway returns 2xx or the attempt budget is exhausted —
// so a degraded link surfaces as a non-zero exit rather than a silently
// dropped message (the failure mode of fire-and-forget `nats pub`).
//
// Usage:
//
// echo '{"token":"..."}' | eks-gateway-publish -channel bootstrap -kind k3s-bootstrap-token
// kubectl ... | eks-gateway-publish -channel state
//
// 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 (
"bytes"
"encoding/json"
"flag"
"fmt"
"io"
"log/slog"
"os"
"time"
"github.com/mulgadc/spinifex/internal/eksgw"
_ "github.com/mulgadc/spinifex/internal/fipsboot"
)
const (
// Retry budget: the control plane reaches readiness before this runs, so a
// failing POST means a degraded link, not a cold start. Bounded so a stuck
// boot still terminates the OpenRC service.
maxAttempts = 30
retryDelay = 5 * time.Second
)
type publishBody struct {
AccountID string `json:"accountId"`
Channel string `json:"channel"`
Kind string `json:"kind"`
Payload json.RawMessage `json:"payload"`
}
func main() {
var (
gatewayURL string
gatewayCA string
accessKey string
secretKey string
region string
accountID string
clusterName string
channel string
kind 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(&channel, "channel", "", "Publish channel: bootstrap|state|addon")
flag.StringVar(&kind, "kind", "", "Bootstrap subject kind (bootstrap channel only)")
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 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")
}
payload, err := io.ReadAll(os.Stdin)
if err != nil {
fatal(fmt.Sprintf("read stdin payload: %v", err))
}
if len(bytes.TrimSpace(payload)) == 0 {
fatal("empty stdin payload")
}
if !json.Valid(payload) {
fatal("stdin payload is not valid JSON")
}
body, err := json.Marshal(publishBody{
AccountID: accountID,
Channel: channel,
Kind: kind,
Payload: json.RawMessage(payload),
})
if err != nil {
fatal(fmt.Sprintf("marshal request body: %v", err))
}
client, err := eksgw.New(gatewayURL, gatewayCA, accessKey, secretKey, region)
if err != nil {
fatal(fmt.Sprintf("build gateway client: %v", err))
}
path := "/clusters/" + clusterName + "/internal-publish"
var lastErr error
for attempt := 1; attempt <= maxAttempts; attempt++ {
if _, err := client.Post(path, body); err != nil {
lastErr = err
slog.Warn("eks-gateway-publish: attempt failed",
"channel", channel, "kind", kind, "attempt", attempt, "err", err)
if attempt < maxAttempts {
time.Sleep(retryDelay)
}
continue
}
slog.Info("eks-gateway-publish: published", "channel", channel, "kind", kind)
return
}
fatal(fmt.Sprintf("publish failed after %d attempts: %v", maxAttempts, lastErr))
}
func fatal(msg string) {
slog.Error("eks-gateway-publish: " + msg)
os.Exit(1)
}
+137
View File
@@ -0,0 +1,137 @@
package main
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/base64"
"encoding/pem"
"fmt"
"math/big"
"net"
"os"
"time"
"github.com/mulgadc/spinifex/internal/tlsconfig"
)
// tlsCertificate is the webhook's loopback serving certificate.
type tlsCertificate struct {
cert tls.Certificate
}
func (c tlsCertificate) serverTLSConfig() *tls.Config {
return &tls.Config{
Certificates: []tls.Certificate{c.cert},
MinVersion: tls.VersionTLS13,
CurvePreferences: tlsconfig.Curves,
}
}
// ensureServingCert loads the persisted self-signed serving cert/key, or mints
// a fresh ECDSA-P256 pair (SAN 127.0.0.1 / localhost) and persists it. Reusing
// across restarts keeps the CA the apiserver loaded into its webhook kubeconfig
// valid even if the webhook process is restarted. Returns the parsed cert and
// the certificate PEM (for embedding as the kubeconfig CA bundle).
func ensureServingCert(certPath, keyPath string) (tlsCertificate, []byte, error) {
if certPEM, keyPEM, ok := readCertPair(certPath, keyPath); ok {
cert, err := tls.X509KeyPair(certPEM, keyPEM)
if err == nil {
return tlsCertificate{cert: cert}, certPEM, nil
}
// Fall through to regenerate on a corrupt/incompatible persisted pair.
}
certPEM, keyPEM, err := generateSelfSigned()
if err != nil {
return tlsCertificate{}, nil, err
}
if err := os.WriteFile(certPath, certPEM, 0o644); err != nil {
return tlsCertificate{}, nil, fmt.Errorf("write cert %s: %w", certPath, err)
}
if err := os.WriteFile(keyPath, keyPEM, 0o600); err != nil {
return tlsCertificate{}, nil, fmt.Errorf("write key %s: %w", keyPath, err)
}
cert, err := tls.X509KeyPair(certPEM, keyPEM)
if err != nil {
return tlsCertificate{}, nil, fmt.Errorf("parse minted keypair: %w", err)
}
return tlsCertificate{cert: cert}, certPEM, nil
}
func readCertPair(certPath, keyPath string) (certPEM, keyPEM []byte, ok bool) {
c, err := os.ReadFile(certPath)
if err != nil {
return nil, nil, false
}
k, err := os.ReadFile(keyPath)
if err != nil {
return nil, nil, false
}
return c, k, true
}
func generateSelfSigned() (certPEM, keyPEM []byte, err error) {
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, nil, fmt.Errorf("generate key: %w", err)
}
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
if err != nil {
return nil, nil, fmt.Errorf("serial: %w", err)
}
now := time.Now().UTC()
tmpl := x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{CommonName: "eks-token-webhook"},
NotBefore: now.Add(-time.Hour),
NotAfter: now.AddDate(10, 0, 0),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment | x509.KeyUsageCertSign,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
IsCA: true,
IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1), net.IPv6loopback},
DNSNames: []string{"localhost"},
}
der, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &priv.PublicKey, priv)
if err != nil {
return nil, nil, fmt.Errorf("create cert: %w", err)
}
keyDER, err := x509.MarshalECPrivateKey(priv)
if err != nil {
return nil, nil, fmt.Errorf("marshal key: %w", err)
}
certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
keyPEM = pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
return certPEM, keyPEM, nil
}
// writeAPIServerKubeconfig writes the kubeconfig kube-apiserver loads via
// --authentication-token-webhook-config-file. It points the apiserver at the
// loopback webhook and trusts its self-signed serving cert as the CA bundle.
func writeAPIServerKubeconfig(path, addr string, certPEM []byte) error {
caB64 := base64.StdEncoding.EncodeToString(certPEM)
kubeconfig := fmt.Sprintf(`apiVersion: v1
kind: Config
clusters:
- name: mulga-eks-token-webhook
cluster:
certificate-authority-data: %s
server: https://%s/authenticate
users:
- name: kube-apiserver
contexts:
- name: webhook
context:
cluster: mulga-eks-token-webhook
user: kube-apiserver
current-context: webhook
`, caB64, addr)
if err := os.WriteFile(path, []byte(kubeconfig), 0o600); err != nil {
return fmt.Errorf("write %s: %w", path, err)
}
return nil
}
+207 -18
View File
@@ -1,38 +1,227 @@
// eks-token-webhook is the K8s TokenReview webhook authenticator baked into
// the eks-server AMI. It decodes the SigV4-presigned GetCallerIdentity URL
// produced by `aws eks get-token`, enforces the X-K8s-Aws-Id cross-cluster
// pin, calls Mulga STS over NATS, and resolves the principal to a TokenReview
// response via the cluster's AccessEntry KV.
// the eks-server AMI. Per kubectl call the kube-apiserver POSTs the bearer
// token (produced by `aws eks get-token`); the webhook relays it through the
// AWS gateway broker (SigV4 HTTPS POST to /clusters/{name}/token-review), which
// host-side runs the STS verify (cross-cluster x-k8s-aws-id pin) + AccessEntry
// KV lookup and returns the resolved identity. The webhook never speaks core
// NATS — same gateway-broker model as ELBv2's lb-agent and eks-gateway-publish.
//
// This is a placeholder build that returns 503 Service Unavailable on every
// path. It exists so the eks-server AMI build succeeds (INSTALL_BINARIES is
// satisfied) and the K3s server VM boots cleanly. Real implementation lands
// with bead mulga-cs-eks-6c.
// It binds 127.0.0.1 only; the kube-apiserver is the sole client (loopback).
// On startup it writes the apiserver webhook kubeconfig (with its self-signed
// serving CA) so k3s can be pointed at it via
// --authentication-token-webhook-config-file.
package main
import (
"encoding/json"
"errors"
"flag"
"fmt"
"log/slog"
"net/http"
"os"
"time"
"github.com/mulgadc/spinifex/internal/eksgw"
handlers_eks "github.com/mulgadc/spinifex/spinifex/handlers/eks"
)
// config is the webhook's runtime configuration, sourced from the first-boot
// env (/etc/spinifex-eks/first-boot.env) cloud-init seeds onto the VM.
type config struct {
addr string
gatewayURL string
gatewayCA string
accessKey string
secretKey string
region string
accountID string
clusterName string
certPath string
keyPath string
kubeconfig string
}
func loadConfig() (config, error) {
c := config{
addr: flagAddr,
gatewayURL: os.Getenv("EKS_GATEWAY_URL"),
gatewayCA: os.Getenv("EKS_GATEWAY_CA"),
accessKey: os.Getenv("EKS_ACCESS_KEY"),
secretKey: os.Getenv("EKS_SECRET_KEY"),
region: os.Getenv("EKS_REGION"),
accountID: os.Getenv("EKS_ACCOUNT_ID"),
clusterName: os.Getenv("EKS_CLUSTER_NAME"),
certPath: envOr("EKS_WEBHOOK_CERT", "/etc/spinifex-eks/token-webhook.crt"),
keyPath: envOr("EKS_WEBHOOK_KEY", "/etc/spinifex-eks/token-webhook.key"),
kubeconfig: envOr("EKS_WEBHOOK_KUBECONFIG", "/etc/spinifex-eks/token-webhook.kubeconfig"),
}
switch {
case c.gatewayURL == "":
return c, errors.New("EKS_GATEWAY_URL not set")
case c.accessKey == "" || c.secretKey == "":
return c, errors.New("EKS_ACCESS_KEY / EKS_SECRET_KEY not set")
case c.accountID == "":
return c, errors.New("EKS_ACCOUNT_ID not set")
case c.clusterName == "":
return c, errors.New("EKS_CLUSTER_NAME not set")
}
return c, nil
}
func envOr(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
var flagAddr string
func main() {
addr := flag.String("addr", "127.0.0.1:8443", "listen address")
flag.StringVar(&flagAddr, "addr", "127.0.0.1:8443", "listen address (loopback only)")
flag.Parse()
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo})))
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Retry-After", "30")
w.Header().Set("Content-Type", "application/json")
http.Error(w, `{"error":"eks-token-webhook stub: real implementation not yet deployed"}`, http.StatusServiceUnavailable)
})
cfg, err := loadConfig()
if err != nil {
slog.Error("eks-token-webhook config error", "err", err)
os.Exit(1)
}
slog.Info("eks-token-webhook stub starting", "addr", *addr)
if err := http.ListenAndServe(*addr, mux); err != nil {
slog.Error("listen failed", "err", err)
if err := run(cfg); err != nil {
slog.Error("eks-token-webhook fatal", "err", err)
os.Exit(1)
}
}
func run(cfg config) error {
// Serving cert + apiserver kubeconfig. Persisted and reused across restarts
// so a webhook crash does not invalidate the CA the apiserver already loaded.
tlsCert, certPEM, err := ensureServingCert(cfg.certPath, cfg.keyPath)
if err != nil {
return fmt.Errorf("serving cert: %w", err)
}
if err := writeAPIServerKubeconfig(cfg.kubeconfig, cfg.addr, certPEM); err != nil {
return fmt.Errorf("write apiserver kubeconfig: %w", err)
}
client, err := eksgw.New(cfg.gatewayURL, cfg.gatewayCA, cfg.accessKey, cfg.secretKey, cfg.region)
if err != nil {
return fmt.Errorf("build gateway client: %w", err)
}
authr := &authenticator{
accountID: cfg.accountID,
clusterName: cfg.clusterName,
review: gatewayReviewer(client, cfg.clusterName, cfg.accountID),
}
mux := http.NewServeMux()
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
})
mux.HandleFunc("/authenticate", authr.handle)
server := newServer(cfg.addr, mux, tlsCert)
slog.Info("eks-token-webhook listening", "addr", cfg.addr, "cluster", cfg.clusterName)
if err := server.ListenAndServeTLS("", ""); err != nil && !errors.Is(err, http.ErrServerClosed) {
return fmt.Errorf("serve: %w", err)
}
return nil
}
// tokenReviewRequest is the body POSTed to /clusters/{name}/token-review. The
// webhook holds system SigV4 creds (system account, not the cluster account),
// so accountId names the cluster account explicitly — same as PublishInternal.
type tokenReviewRequest struct {
AccountID string `json:"accountId"`
Token string `json:"token"`
}
// gatewayReviewer returns the per-call relay: SigV4-POST {accountId,token} to
// the gateway and decode the resolved identity. A non-2xx / transport error is
// surfaced (handle() turns it into a 5xx so the apiserver retries) rather than a
// silent deny, distinguishing "webhook broker down" from "token rejected".
func gatewayReviewer(client *eksgw.Client, clusterName, accountID string) func(string) (handlers_eks.WebhookTokenReviewResult, error) {
path := "/clusters/" + clusterName + "/token-review"
return func(token string) (handlers_eks.WebhookTokenReviewResult, error) {
body, err := json.Marshal(tokenReviewRequest{AccountID: accountID, Token: token})
if err != nil {
return handlers_eks.WebhookTokenReviewResult{}, fmt.Errorf("marshal request: %w", err)
}
respBody, err := client.Post(path, body)
if err != nil {
return handlers_eks.WebhookTokenReviewResult{}, err
}
var res handlers_eks.WebhookTokenReviewResult
if err := json.Unmarshal(respBody, &res); err != nil {
return handlers_eks.WebhookTokenReviewResult{}, fmt.Errorf("decode response: %w", err)
}
return res, nil
}
}
// authenticator resolves a TokenReview by relaying to the gateway broker.
type authenticator struct {
accountID string
clusterName string
review func(token string) (handlers_eks.WebhookTokenReviewResult, error)
}
func (a *authenticator) handle(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var review tokenReview
if err := json.NewDecoder(r.Body).Decode(&review); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
res, err := a.review(review.Spec.Token)
if err != nil {
// Broker fault (gateway down, transport error): a 5xx tells the apiserver
// to retry rather than treating a transient outage as a hard deny.
slog.Error("TokenReview relay failed", "cluster", a.clusterName, "err", err)
http.Error(w, "token review unavailable", http.StatusServiceUnavailable)
return
}
status := tokenReviewStatus{Authenticated: res.Authenticated}
if res.Authenticated {
status.User = userInfo{
Username: res.Username,
UID: res.UID,
Groups: res.Groups,
}
// Confirm the token for the audiences the apiserver asked about; an empty
// status.audiences is rejected when --api-audiences is configured.
status.Audiences = review.Spec.Audiences
}
slog.Info("TokenReview decision", "authenticated", status.Authenticated, "username", status.User.Username, "audiences", status.Audiences)
resp := tokenReview{
APIVersion: "authentication.k8s.io/v1",
Kind: "TokenReview",
Status: status,
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(resp); err != nil {
slog.Error("encode TokenReview response", "err", err)
}
}
// newServer builds the loopback TLS server. Split out so tests can construct it
// without binding a port.
func newServer(addr string, handler http.Handler, cert tlsCertificate) *http.Server {
return &http.Server{
Addr: addr,
Handler: handler,
ReadHeaderTimeout: 5 * time.Second,
TLSConfig: cert.serverTLSConfig(),
}
}
+123
View File
@@ -0,0 +1,123 @@
package main
import (
"encoding/base64"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
handlers_eks "github.com/mulgadc/spinifex/spinifex/handlers/eks"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// postReview drives authenticator.handle with a TokenReview carrying token and
// audiences, returning the decoded response status and HTTP code.
func postReview(t *testing.T, a *authenticator, token string, audiences []string) (tokenReviewStatus, int) {
t.Helper()
body, err := json.Marshal(tokenReview{
APIVersion: "authentication.k8s.io/v1",
Kind: "TokenReview",
Spec: tokenReviewSpec{Token: token, Audiences: audiences},
})
require.NoError(t, err)
req := httptest.NewRequest(http.MethodPost, "/authenticate", strings.NewReader(string(body)))
w := httptest.NewRecorder()
a.handle(w, req)
if w.Code != http.StatusOK {
return tokenReviewStatus{}, w.Code
}
var resp tokenReview
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
return resp.Status, w.Code
}
func TestHandle_GrantsAndStampsAudiences(t *testing.T) {
a := &authenticator{
accountID: "111122223333",
clusterName: "alpha",
review: func(string) (handlers_eks.WebhookTokenReviewResult, error) {
return handlers_eks.WebhookTokenReviewResult{
Authenticated: true,
Username: "arn:aws:iam::111122223333:role/admin",
UID: "AROAEXAMPLE:session",
Groups: []string{"system:masters"},
}, nil
},
}
status, code := postReview(t, a, "tok", []string{"https://kubernetes.default.svc"})
require.Equal(t, http.StatusOK, code)
require.True(t, status.Authenticated)
assert.Equal(t, "arn:aws:iam::111122223333:role/admin", status.User.Username)
assert.Equal(t, "AROAEXAMPLE:session", status.User.UID)
assert.Equal(t, []string{"system:masters"}, status.User.Groups)
assert.Equal(t, []string{"https://kubernetes.default.svc"}, status.Audiences)
}
func TestHandle_DeniesWithoutStampingAudiences(t *testing.T) {
a := &authenticator{
clusterName: "alpha",
review: func(string) (handlers_eks.WebhookTokenReviewResult, error) {
return handlers_eks.WebhookTokenReviewResult{Authenticated: false}, nil
},
}
status, code := postReview(t, a, "tok", []string{"https://kubernetes.default.svc"})
require.Equal(t, http.StatusOK, code)
assert.False(t, status.Authenticated)
assert.Empty(t, status.User.Username)
assert.Empty(t, status.Audiences, "unauthenticated review must not stamp audiences")
}
// A broker fault is a 5xx (retryable), not a silent deny — a transient gateway
// outage must not look identical to a rejected token.
func TestHandle_BrokerFaultReturns5xx(t *testing.T) {
a := &authenticator{
clusterName: "alpha",
review: func(string) (handlers_eks.WebhookTokenReviewResult, error) {
return handlers_eks.WebhookTokenReviewResult{}, errors.New("gateway returned 503")
},
}
_, code := postReview(t, a, "tok", nil)
assert.Equal(t, http.StatusServiceUnavailable, code)
}
func TestEnsureServingCert_PersistsAndReuses(t *testing.T) {
dir := t.TempDir()
certPath := filepath.Join(dir, "wh.crt")
keyPath := filepath.Join(dir, "wh.key")
c1, pem1, err := ensureServingCert(certPath, keyPath)
require.NoError(t, err)
require.NotEmpty(t, pem1)
require.NotNil(t, c1.serverTLSConfig())
// Second call reuses the persisted pair (same cert bytes).
_, pem2, err := ensureServingCert(certPath, keyPath)
require.NoError(t, err)
assert.Equal(t, pem1, pem2)
}
func TestWriteAPIServerKubeconfig(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "wh.kubeconfig")
certPEM := []byte("-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----\n")
require.NoError(t, writeAPIServerKubeconfig(path, "127.0.0.1:8443", certPEM))
data, err := os.ReadFile(path)
require.NoError(t, err)
body := string(data)
assert.Contains(t, body, "server: https://127.0.0.1:8443/authenticate")
assert.Contains(t, body, base64.StdEncoding.EncodeToString(certPEM))
assert.Contains(t, body, "current-context: webhook")
}
+34
View File
@@ -0,0 +1,34 @@
package main
// Minimal authentication.k8s.io/v1 TokenReview wire types. Hand-rolled to avoid
// pulling k8s.io/api into the spinifex module for these few fields; the shapes
// match what kube-apiserver sends and expects on the webhook contract.
type tokenReview struct {
APIVersion string `json:"apiVersion"`
Kind string `json:"kind"`
Spec tokenReviewSpec `json:"spec,omitzero"`
Status tokenReviewStatus `json:"status"`
}
type tokenReviewSpec struct {
Token string `json:"token"`
Audiences []string `json:"audiences,omitempty"`
}
type tokenReviewStatus struct {
Authenticated bool `json:"authenticated"`
User userInfo `json:"user,omitzero"`
// Audiences echoes the request's spec.audiences on success. When the
// apiserver runs with --api-audiences, it sends spec.audiences and discards
// an authenticated=true response that does not confirm a matching audience —
// so an empty Audiences here surfaces as a 401 despite a valid principal.
Audiences []string `json:"audiences,omitempty"`
Error string `json:"error,omitempty"`
}
type userInfo struct {
Username string `json:"username,omitempty"`
UID string `json:"uid,omitempty"`
Groups []string `json:"groups,omitempty"`
}
+147
View File
@@ -0,0 +1,147 @@
// Command eks-webhook-cert mints, then reuses, a self-signed ECDSA-P256 serving
// certificate for an in-cluster admission webhook and prints its base64 PEM so
// the addon renderer can embed it as the webhook Secret + each caBundle.
//
// The eks-node AMI carries no openssl, so this is the Go equivalent of the
// upstream Helm chart's install-time genSelfSignedCert: one cert per cluster,
// cached on disk under -dir, stable across addon-sync ticks. The single cert is
// its own CA (IsCA + ServerAuth), so the same PEM serves as both the TLS leaf
// and the caBundle the apiserver trusts.
//
// Usage:
//
// eks-webhook-cert -dir /var/lib/spinifex-eks/webhook-certs/<addon> \
// -cn <svc>.<ns>.svc -dns <svc>.<ns>.svc,<svc>.<ns>.svc.cluster.local
//
// Output (one line): <ca_b64>\t<tls_crt_b64>\t<tls_key_b64>
package main
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/base64"
"encoding/pem"
"flag"
"fmt"
"log/slog"
"math/big"
"os"
"path/filepath"
"strings"
"time"
)
func main() {
dir := flag.String("dir", "", "directory to cache tls.crt/tls.key in")
cn := flag.String("cn", "", "certificate common name")
dnsCSV := flag.String("dns", "", "comma-separated DNS SANs")
flag.Parse()
if *dir == "" || *cn == "" || *dnsCSV == "" {
fatal("eks-webhook-cert: -dir, -cn and -dns are required")
}
dnsNames := splitCSV(*dnsCSV)
if len(dnsNames) == 0 {
fatal("eks-webhook-cert: -dns lists no names")
}
certPEM, keyPEM, err := ensureCert(*dir, *cn, dnsNames)
if err != nil {
fatal("eks-webhook-cert: " + err.Error())
}
b := base64.StdEncoding.EncodeToString
fmt.Printf("%s\t%s\t%s\n", b(certPEM), b(certPEM), b(keyPEM))
}
// ensureCert returns the cached cert/key under dir, minting and persisting a
// fresh self-signed pair (for cn + dnsNames) when none is present or readable.
func ensureCert(dir, cn string, dnsNames []string) (certPEM, keyPEM []byte, err error) {
certPath := filepath.Join(dir, "tls.crt")
keyPath := filepath.Join(dir, "tls.key")
if c, cerr := os.ReadFile(certPath); cerr == nil {
if k, kerr := os.ReadFile(keyPath); kerr == nil {
if _, perr := x509.ParseCertificate(decodeFirst(c)); perr == nil && len(k) > 0 {
return c, k, nil
}
}
}
certPEM, keyPEM, err = generateSelfSigned(cn, dnsNames)
if err != nil {
return nil, nil, err
}
if err = os.MkdirAll(dir, 0o700); err != nil {
return nil, nil, fmt.Errorf("mkdir %s: %w", dir, err)
}
if err = os.WriteFile(certPath, certPEM, 0o644); err != nil {
return nil, nil, fmt.Errorf("write %s: %w", certPath, err)
}
if err = os.WriteFile(keyPath, keyPEM, 0o600); err != nil {
return nil, nil, fmt.Errorf("write %s: %w", keyPath, err)
}
return certPEM, keyPEM, nil
}
// generateSelfSigned mints an ECDSA-P256 self-signed cert valid as both a CA and
// a server cert, with the given common name and DNS SANs.
func generateSelfSigned(cn string, dnsNames []string) (certPEM, keyPEM []byte, err error) {
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, nil, fmt.Errorf("generate key: %w", err)
}
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
if err != nil {
return nil, nil, fmt.Errorf("serial: %w", err)
}
now := time.Now().UTC()
tmpl := x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{CommonName: cn},
NotBefore: now.Add(-time.Hour),
NotAfter: now.AddDate(10, 0, 0),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment | x509.KeyUsageCertSign,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
IsCA: true,
DNSNames: dnsNames,
}
der, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &priv.PublicKey, priv)
if err != nil {
return nil, nil, fmt.Errorf("create cert: %w", err)
}
keyDER, err := x509.MarshalECPrivateKey(priv)
if err != nil {
return nil, nil, fmt.Errorf("marshal key: %w", err)
}
certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
keyPEM = pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
return certPEM, keyPEM, nil
}
// decodeFirst returns the DER bytes of the first PEM block, or nil.
func decodeFirst(pemBytes []byte) []byte {
blk, _ := pem.Decode(pemBytes)
if blk == nil {
return nil
}
return blk.Bytes
}
func splitCSV(s string) []string {
var out []string
for p := range strings.SplitSeq(s, ",") {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
func fatal(msg string) {
slog.Error(msg)
os.Exit(1)
}
+81
View File
@@ -0,0 +1,81 @@
package main
import (
"crypto/x509"
"os"
"path/filepath"
"testing"
)
const (
testCN = "aws-load-balancer-webhook-service.kube-system.svc"
testDNS = "aws-load-balancer-webhook-service.kube-system.svc.cluster.local"
)
func parseLeaf(t *testing.T, certPEM []byte) *x509.Certificate {
t.Helper()
leaf, err := x509.ParseCertificate(decodeFirst(certPEM))
if err != nil {
t.Fatalf("parse cert: %v", err)
}
return leaf
}
func TestEnsureCertMintsServingCert(t *testing.T) {
dir := t.TempDir()
certPEM, keyPEM, err := ensureCert(dir, testCN, []string{testCN, testDNS})
if err != nil {
t.Fatalf("ensureCert: %v", err)
}
if len(keyPEM) == 0 {
t.Fatal("empty key PEM")
}
leaf := parseLeaf(t, certPEM)
// Both webhook service DNS names must be present, or the apiserver's TLS
// dial to the webhook fails SAN verification.
for _, want := range []string{testCN, testDNS} {
if err := leaf.VerifyHostname(want); err != nil {
t.Errorf("SAN %q missing: %v", want, err)
}
}
if !leaf.IsCA {
t.Error("cert must be a CA so the same PEM serves as the webhook caBundle")
}
// The single self-signed cert must verify against itself as a root — this is
// exactly what the apiserver does with caBundle == the serving cert.
roots := x509.NewCertPool()
roots.AddCert(leaf)
if _, err := leaf.Verify(x509.VerifyOptions{
Roots: roots,
DNSName: testCN,
KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
}); err != nil {
t.Errorf("self-signed cert does not verify as its own CA: %v", err)
}
}
func TestEnsureCertReusesCachedPair(t *testing.T) {
dir := t.TempDir()
cert1, key1, err := ensureCert(dir, testCN, []string{testCN})
if err != nil {
t.Fatalf("first ensureCert: %v", err)
}
cert2, key2, err := ensureCert(dir, testCN, []string{testCN})
if err != nil {
t.Fatalf("second ensureCert: %v", err)
}
if string(cert1) != string(cert2) || string(key1) != string(key2) {
t.Error("ensureCert regenerated instead of reusing the cached pair; a churning cert re-applies the addon every tick")
}
// The private key must not be world-readable on disk.
info, err := os.Stat(filepath.Join(dir, "tls.key"))
if err != nil {
t.Fatalf("stat key: %v", err)
}
if info.Mode().Perm()&0o077 != 0 {
t.Errorf("tls.key mode %o is group/other-accessible", info.Mode().Perm())
}
}
+139 -15
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)
@@ -285,6 +298,7 @@ func init() {
adminInitCmd.Flags().String("predastore-nodes", "", "Comma-separated IPs for multi-node Predastore cluster (e.g., 10.11.12.1,10.11.12.2,10.11.12.3). Requires >= 3 nodes.")
adminInitCmd.Flags().String("formation-timeout", "10m", "Timeout for cluster formation (e.g., 5m, 30s)")
adminInitCmd.Flags().String("token-ttl", "30m", "Join token validity duration (e.g. 30m, 1h, 2h)")
adminInitCmd.Flags().Int("predastore-compaction-interval", 0, "Predastore compactor interval in seconds (0 = unset, uses built-in default). Test clusters set a short interval.")
adminInitCmd.Flags().String("cluster-name", "spinifex", "NATS cluster name")
adminInitCmd.Flags().Bool("no-telemetry", false, "Disable telemetry metrics sent during init (default: enabled)")
adminInitCmd.Flags().String("email", "", "Operator email address (used for update and security notifications)")
@@ -318,6 +332,7 @@ func init() {
adminJoinCmd.Flags().StringSlice("services", nil, "Services this node runs (default: all)")
adminJoinCmd.Flags().Bool("no-telemetry", false, "Disable telemetry metrics sent during join (default: enabled)")
adminJoinCmd.Flags().String("email", "", "Operator email address (used for update and security notifications)")
adminJoinCmd.Flags().Int("predastore-compaction-interval", 0, "Predastore compactor interval in seconds (0 = unset, uses built-in default). Test clusters set a short interval.")
adminJoinCmd.MarkFlagRequired("node")
adminJoinCmd.MarkFlagRequired("host")
adminJoinCmd.MarkFlagRequired("token")
@@ -325,6 +340,7 @@ func init() {
imagesImportCmd.Flags().String("tmp-dir", os.TempDir(), "Temporary directory for image import processing")
imagesImportCmd.Flags().String("name", "", "Import specified image by name")
imagesImportCmd.Flags().String("ami-name", "", "Override the registered AMI name (DescribeImages name). Defaults to ami-{distro}-{version}-{arch}. Use for locally-built appliances (e.g. --ami-name spinifex-eks-node).")
imagesImportCmd.Flags().String("file", "", "Import file from specified path (raw, qcow2, compressed)")
imagesImportCmd.Flags().String("distro", "", "Specified distro name (e.g debian)")
imagesImportCmd.Flags().String("version", "", "Specified distro version (e.g 12)")
@@ -380,6 +396,7 @@ func runimagesImportCmd(cmd *cobra.Command, args []string) {
// and the file is used directly (no download). When only --file is set,
// metadata comes from flags and no catalog tags are applied.
imageName, _ := cmd.Flags().GetString("name")
amiNameOverride, _ := cmd.Flags().GetString("ami-name")
localFile, _ := cmd.Flags().GetString("file")
if imageName == "" && localFile == "" {
@@ -535,6 +552,9 @@ func runimagesImportCmd(cmd *cobra.Command, args []string) {
// Calculate the size
manifest.AMIMetadata.Name = fmt.Sprintf("ami-%s-%s-%s", image.Distro, image.Version, image.Arch)
if amiNameOverride != "" {
manifest.AMIMetadata.Name = amiNameOverride
}
volumeId := utils.GenerateResourceID("ami")
manifest.AMIMetadata.ImageID = volumeId
@@ -828,6 +848,7 @@ func runAdminInit(cmd *cobra.Command, args []string) {
predastoreNodesStr, _ := cmd.Flags().GetString("predastore-nodes")
formationTimeoutStr, _ := cmd.Flags().GetString("formation-timeout")
tokenTTLStr, _ := cmd.Flags().GetString("token-ttl")
compactionInterval, _ := cmd.Flags().GetInt("predastore-compaction-interval")
clusterName, _ := cmd.Flags().GetString("cluster-name")
services, _ := cmd.Flags().GetStringSlice("services")
@@ -1109,6 +1130,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)
@@ -1116,7 +1148,7 @@ func runAdminInit(cmd *cobra.Command, args []string) {
fmt.Printf(" AWS Profile: spinifex\n")
// Generate SSL certificates (with bind IP in SANs for multi-node support)
certPath := admin.GenerateCertificatesIfNeeded(configDir, force, bindIP)
certPath := admin.GenerateCertificatesIfNeeded(configDir, force, bindIP, region, config.DefaultAWSInternalSuffix)
// Generate per-node IPsec peer cert when cluster-wide IPsec is enabled
// (default true). Reuses the cluster CA — no intermediate strongSwan PKI.
@@ -1227,7 +1259,7 @@ func runAdminInit(cmd *cobra.Command, args []string) {
}
// Generate multi-node predastore.toml
predastoreContent, err := admin.GenerateMultiNodePredastoreConfig(predastoreMultiNodeTemplate, predastoreNodes, accessKey, secretKey, region, natsToken, configDir, bindIP)
predastoreContent, err := admin.GenerateMultiNodePredastoreConfig(predastoreMultiNodeTemplate, predastoreNodes, accessKey, secretKey, region, natsToken, configDir, bindIP, compactionInterval)
if err != nil {
fmt.Fprintf(os.Stderr, "Error generating multi-node predastore config: %v\n", err)
os.Exit(1)
@@ -1268,8 +1300,9 @@ func runAdminInit(cmd *cobra.Command, args []string) {
ClusterRoutes: clusterRoutes,
ClusterName: clusterName,
PredastoreNodeID: predastoreNodeID,
Services: services,
PredastoreNodeID: predastoreNodeID,
CompactionIntervalSeconds: compactionInterval,
Services: services,
OVNNBAddr: "tcp:127.0.0.1:6641",
OVNSBAddr: "tcp:127.0.0.1:6642",
@@ -1296,6 +1329,8 @@ func runAdminInit(cmd *cobra.Command, args []string) {
GPUPassthrough: gpuPassthrough,
IPSecEnabled: ipsecEnabled,
EncryptionKeyFile: viperblockKeyPath,
}
// Print external networking summary
@@ -1372,6 +1407,8 @@ func runAdminInitMultiNode(cmd *cobra.Command, accessKey, secretKey, accountID,
os.Exit(1)
}
compactionInterval, _ := cmd.Flags().GetInt("predastore-compaction-interval")
joinToken, err := formation.GenerateJoinToken()
if err != nil {
fmt.Fprintf(os.Stderr, "❌ Error generating join token: %v\n", err)
@@ -1404,6 +1441,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 {
@@ -1432,6 +1485,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,
@@ -1496,7 +1552,7 @@ func runAdminInitMultiNode(cmd *cobra.Command, accessKey, secretKey, accountID,
var predastoreNodeID int
hasPredastoreConfig := len(predastoreNodes) >= 2
if hasPredastoreConfig {
predastoreContent, err := admin.GenerateMultiNodePredastoreConfig(predastoreMultiNodeTemplate, predastoreNodes, accessKey, secretKey, region, natsToken, configDir, bindIP)
predastoreContent, err := admin.GenerateMultiNodePredastoreConfig(predastoreMultiNodeTemplate, predastoreNodes, accessKey, secretKey, region, natsToken, configDir, bindIP, compactionInterval)
if err != nil {
fmt.Fprintf(os.Stderr, "Error generating multi-node predastore config: %v\n", err)
os.Exit(1)
@@ -1533,12 +1589,15 @@ func runAdminInitMultiNode(cmd *cobra.Command, accessKey, secretKey, accountID,
ClusterRoutes: clusterRoutes,
ClusterName: clusterName,
PredastoreNodeID: predastoreNodeID,
Services: services,
RemoteNodes: buildRemoteNodes(allNodes, node),
PredastoreNodeID: predastoreNodeID,
CompactionIntervalSeconds: compactionInterval,
Services: services,
RemoteNodes: buildRemoteNodes(allNodes, node),
OperatorEmail: email,
EncryptionKeyFile: viperblockKeyPath,
// Init node runs ovn-central locally
OVNNBAddr: "tcp:127.0.0.1:6641",
OVNSBAddr: "tcp:127.0.0.1:6642",
@@ -1580,7 +1639,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()
}
@@ -1603,6 +1662,7 @@ func runAdminJoin(cmd *cobra.Command, args []string) {
configDir, _ := cmd.Flags().GetString("config-dir")
clusterBind, _ := cmd.Flags().GetString("cluster-bind")
services, _ := cmd.Flags().GetStringSlice("services")
compactionInterval, _ := cmd.Flags().GetInt("predastore-compaction-interval")
email, _ := cmd.Flags().GetString("email")
email = strings.TrimSpace(email)
@@ -1884,8 +1944,30 @@ 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 {
if err := admin.GenerateServerCertOnly(configDir, bindIP, region, config.DefaultAWSInternalSuffix); err != nil {
fmt.Fprintf(os.Stderr, "Error generating server certificate: %v\n", err)
os.Exit(1)
}
@@ -1928,7 +2010,7 @@ func runAdminJoin(cmd *cobra.Command, args []string) {
hasPredastoreConfig := len(predastoreNodes) >= 2
if hasPredastoreConfig {
predastoreContent, err := admin.GenerateMultiNodePredastoreConfig(predastoreMultiNodeTemplate, predastoreNodes, creds.AccessKey, creds.SecretKey, creds.Region, creds.NatsToken, configDir, bindIP)
predastoreContent, err := admin.GenerateMultiNodePredastoreConfig(predastoreMultiNodeTemplate, predastoreNodes, creds.AccessKey, creds.SecretKey, creds.Region, creds.NatsToken, configDir, bindIP, compactionInterval)
if err != nil {
fmt.Fprintf(os.Stderr, "Error generating multi-node predastore config: %v\n", err)
os.Exit(1)
@@ -1969,12 +2051,15 @@ func runAdminJoin(cmd *cobra.Command, args []string) {
ClusterRoutes: clusterRoutes,
ClusterName: creds.ClusterName,
PredastoreNodeID: predastoreNodeID,
Services: services,
RemoteNodes: buildRemoteNodes(statusResp.Nodes, node),
PredastoreNodeID: predastoreNodeID,
CompactionIntervalSeconds: compactionInterval,
Services: services,
RemoteNodes: buildRemoteNodes(statusResp.Nodes, node),
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),
@@ -2260,6 +2345,7 @@ func runCertRenew(cmd *cobra.Command, _ []string) {
type configDirs struct {
AWSGW string
Predastore string
Viperblock string
NATS string
Spinifex string
}
@@ -2269,10 +2355,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)
}
@@ -2394,6 +2481,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.
+152 -2
View File
@@ -6,11 +6,15 @@ import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
s3 "github.com/mulgadc/predastore/s3"
"github.com/mulgadc/spinifex/spinifex/admin"
"github.com/mulgadc/spinifex/spinifex/formation"
"github.com/mulgadc/spinifex/spinifex/utils"
toml "github.com/pelletier/go-toml/v2"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -206,7 +210,7 @@ func TestPredastoreMultinodeTemplate_NatsURLLoopbackShim(t *testing.T) {
{ID: 3, Host: "10.0.0.3"},
}
content, err := admin.GenerateMultiNodePredastoreConfig(
predastoreMultiNodeTemplate, nodes, "AK", "SK", "ap-southeast-2", "token", "/tmp", "0.0.0.0",
predastoreMultiNodeTemplate, nodes, "AK", "SK", "ap-southeast-2", "token", "/tmp", "0.0.0.0", 0,
)
require.NoError(t, err)
assert.Contains(t, content, `nats_url = "nats://localhost:4222"`)
@@ -214,7 +218,7 @@ func TestPredastoreMultinodeTemplate_NatsURLLoopbackShim(t *testing.T) {
// Specific bind IP → stays as-is.
content2, err := admin.GenerateMultiNodePredastoreConfig(
predastoreMultiNodeTemplate, nodes, "AK", "SK", "ap-southeast-2", "token", "/tmp", "10.11.12.1",
predastoreMultiNodeTemplate, nodes, "AK", "SK", "ap-southeast-2", "token", "/tmp", "10.11.12.1", 0,
)
require.NoError(t, err)
assert.Contains(t, content2, `nats_url = "nats://10.11.12.1:4222"`)
@@ -252,6 +256,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
@@ -386,3 +458,81 @@ func TestApplyNetworkConfig_PropagatesPoolBindBridge(t *testing.T) {
assert.Equal(t, "dhcp", settings.PoolSource)
assert.Equal(t, "br-wan", settings.PoolBindBridge)
}
func renderSingleNodePredastore(t *testing.T, settings admin.ConfigSettings) string {
t.Helper()
path := filepath.Join(t.TempDir(), "predastore.toml")
require.NoError(t, admin.GenerateConfigFile(path, predastoreTomlTemplate, settings))
b, err := os.ReadFile(path)
require.NoError(t, err)
return string(b)
}
func predastoreMultinodeNodes() []admin.PredastoreNodeConfig {
return []admin.PredastoreNodeConfig{
{ID: 1, Host: "10.0.0.1"}, {ID: 2, Host: "10.0.0.2"}, {ID: 3, Host: "10.0.0.3"},
}
}
// Unset knob must leave production config byte-identical: no [compaction] block
// and the original trailing bytes unchanged (single-node has no trailing
// newline, multinode keeps one).
func TestPredastoreTemplates_UnsetCompactionEmitsNoBlock(t *testing.T) {
single := renderSingleNodePredastore(t, admin.ConfigSettings{
Region: "ap-southeast-2", BindIP: "0.0.0.0", ConfigDir: "/cfg",
AccessKey: "AK", SecretKey: "SK", NatsToken: "tok",
})
assert.NotContains(t, single, "[compaction]")
assert.True(t, strings.HasSuffix(single, `access_keys_bucket = "spinifex-iam-access-keys"`),
"unset single-node tail changed: %q", single[len(single)-40:])
multi, err := admin.GenerateMultiNodePredastoreConfig(
predastoreMultiNodeTemplate, predastoreMultinodeNodes(), "AK", "SK", "ap-southeast-2", "tok", "/cfg", "10.0.0.1", 0,
)
require.NoError(t, err)
assert.NotContains(t, multi, "[compaction]")
assert.True(t, strings.HasSuffix(multi, "access_keys_bucket = \"spinifex-iam-access-keys\"\n"),
"unset multinode tail changed: %q", multi[len(multi)-40:])
}
func TestPredastoreTemplates_SetCompactionEmitsParseableBlock(t *testing.T) {
const interval = 30
single := renderSingleNodePredastore(t, admin.ConfigSettings{
Region: "ap-southeast-2", BindIP: "0.0.0.0", ConfigDir: "/cfg",
AccessKey: "AK", SecretKey: "SK", NatsToken: "tok", CompactionIntervalSeconds: interval,
})
assert.Contains(t, single, "[compaction]")
var singleCfg s3.Config
require.NoError(t, toml.Unmarshal([]byte(single), &singleCfg))
assert.Equal(t, interval, singleCfg.Compaction.IntervalSeconds)
multi, err := admin.GenerateMultiNodePredastoreConfig(
predastoreMultiNodeTemplate, predastoreMultinodeNodes(), "AK", "SK", "ap-southeast-2", "tok", "/cfg", "10.0.0.1", interval,
)
require.NoError(t, err)
var multiCfg s3.Config
require.NoError(t, toml.Unmarshal([]byte(multi), &multiCfg))
assert.Equal(t, interval, multiCfg.Compaction.IntervalSeconds)
}
// The init/join flag must register with an unset (0) default and flow into the
// rendered config via ConfigSettings.
func TestAdminInitFlag_CompactionIntervalReachesRenderedConfig(t *testing.T) {
for _, c := range []*cobra.Command{adminInitCmd, adminJoinCmd} {
f := c.Flags().Lookup("predastore-compaction-interval")
require.NotNil(t, f, "%s missing --predastore-compaction-interval", c.Name())
assert.Equal(t, "0", f.DefValue)
}
require.NoError(t, adminInitCmd.Flags().Set("predastore-compaction-interval", "45"))
defer func() { _ = adminInitCmd.Flags().Set("predastore-compaction-interval", "0") }()
v, _ := adminInitCmd.Flags().GetInt("predastore-compaction-interval")
require.Equal(t, 45, v)
out := renderSingleNodePredastore(t, admin.ConfigSettings{
Region: "ap-southeast-2", BindIP: "0.0.0.0", CompactionIntervalSeconds: v,
})
assert.Contains(t, out, "interval_seconds = 45")
}
+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)
}
+1 -1
View File
@@ -538,7 +538,7 @@ var awsgwStartCmd = &cobra.Command{
awsgwHost := viper.GetString("host")
if awsgwHost != "" {
fmt.Println("Overwriting awsgw host to:", awsgwHost)
//nodeConfig.AWSGW.Host = awsgwHost
nodeConfig.AWSGW.Host = awsgwHost
}
awsgwTlsCert := viper.GetString("tls-cert")
@@ -75,3 +75,8 @@ nats_token = "{{.NatsToken}}"
nats_ca_cert = "{{.ConfigDir}}/ca.pem"
master_key_path = "{{.ConfigDir}}/master.key"
access_keys_bucket = "spinifex-iam-access-keys"
{{- if .CompactionIntervalSeconds}}
[compaction]
interval_seconds = {{.CompactionIntervalSeconds}}
{{- end}}
+6 -1
View File
@@ -103,4 +103,9 @@ nats_url = "nats://{{if eq .BindIP "0.0.0.0"}}localhost{{else}}{{.BindIP}}{{end}
nats_token = "{{.NatsToken}}"
nats_ca_cert = "{{.ConfigDir}}/ca.pem"
master_key_path = "{{.ConfigDir}}/master.key"
access_keys_bucket = "spinifex-iam-access-keys"
access_keys_bucket = "spinifex-iam-access-keys"
{{- if .CompactionIntervalSeconds}}
[compaction]
interval_seconds = {{.CompactionIntervalSeconds}}
{{- end}}
+7
View File
@@ -8,6 +8,10 @@ node = "{{.Node}}"
email = "{{.OperatorEmail}}"
{{- end}}
[aws]
region = "{{.Region}}"
internal_suffix = "spinifex.internal"
[network]
ipsec_enabled = {{.IPSecEnabled}}
{{- if .ExternalMode}}
@@ -93,6 +97,9 @@ token = "{{.NatsToken}}"
[nodes.{{.Node}}.viperblock]
shardwal = false
{{- if .EncryptionKeyFile}}
encryption_key_file = "{{.EncryptionKeyFile}}"
{{- end}}
[nodes.{{.Node}}.vpcd]
ovn_nb_addr = "{{.OVNNBAddr}}"
+222 -176
View File
@@ -8,162 +8,103 @@ Platform management commands not exposed via the AWS gateway API. These are CLI-
Service lifecycle commands for starting, stopping, and checking status of all Spinifex cluster services. Each service subcommand supports `start`, `stop`, and `status` operations.
| Command | Flags | Prerequisites | Basic Logic | Status |
|---------|-------|---------------|-------------|--------|
| `spx service predastore start` | `--port` (default: 8443), `--host` (default: 0.0.0.0), `--base-path`, `--config-path`, `--debug`, `--tls-cert`, `--tls-key`, `--backend` (distributed/filesystem, default: distributed), `--node-id` (default: 0), `--pprof`, `--pprof-output` | TLS cert/key, base-path, config-path required | Creates predastore service instance with S3-compatible storage backend → starts service | **DONE** |
| `spx service predastore stop` | — | Predastore must be running | Stops the predastore service | **DONE** |
| `spx service predastore status` | — | None | Reports predastore service status | **DONE** |
| `spx service viperblock start` | `--s3-host` (default: 0.0.0.0:8443), `--s3-bucket` (default: predastore), `--s3-region` (default: ap-southeast-2), `--plugin-path` (auto-detected via `nbdkit --dump-config plugindir`; typically `/usr/lib/x86_64-linux-gnu/nbdkit/plugins/nbdkit-viperblock-plugin.so` on amd64, overridable via `SPINIFEX_VIPERBLOCK_PLUGIN_PATH` in `/etc/spinifex/systemd.env`) | `--config` required, plugin-path must exist on disk | Loads cluster config → connects to NATS and Predastore → starts viperblock block storage service with NBD plugin | **DONE** |
| `spx service viperblock stop` | — | Viperblock must be running | Stops the viperblock service | **DONE** |
| `spx service viperblock status` | — | None | Reports viperblock service status | **DONE** |
| `spx service nats start` | `--port` (default: 4222), `--host` (default: 0.0.0.0), `--debug`, `--data-dir`, `--jetstream` | None | Starts embedded NATS server with optional JetStream | **DONE** |
| `spx service nats stop` | — | NATS must be running | Stops the NATS service | **DONE** |
| `spx service nats status` | — | None | Reports NATS service status | **DONE** |
| `spx service spinifex start` | `--wal-dir` | `--config` required | Loads cluster config → starts spinifex daemon (VM orchestration, NATS subscriptions, health endpoint) | **DONE** |
| `spx service spinifex stop` | — | Spinifex daemon must be running | Stops the spinifex daemon service | **DONE** |
| `spx service spinifex status` | — | None | Reports spinifex daemon service status | **DONE** |
| `spx service awsgw start` | `--host` (default: 0.0.0.0:9999), `--tls-cert`, `--tls-key`, `--debug` | `--config` required | Loads cluster config → starts AWS-compatible gateway with SigV4 auth, IAM policy enforcement, TLS | **DONE** |
| `spx service awsgw stop` | — | AWS gateway must be running | Stops the AWS gateway service | **DONE** |
| `spx service awsgw status` | — | None | Reports AWS gateway service status | **DONE** |
| `spx service spinifex-ui start` | `--port` (default: 3000), `--host` (default: 0.0.0.0), `--tls-cert`, `--tls-key` | None | Starts embedded web UI server serving the React frontend. Aliases: `ui`, `spinifexui` | **DONE** |
| `spx service spinifex-ui stop` | — | spinifex-ui must be running | Stops the spinifex-ui service | **DONE** |
| `spx service spinifex-ui status` | — | None | Reports spinifex-ui service status | **DONE** |
| `spx service vpcd start` | — | `--config` required, OVN/OVS installed | Loads cluster config → starts VPC daemon (subscribes to `vpc.*` NATS events, translates to OVN logical switches/ports/routers) | **DONE** |
| `spx service vpcd stop` | — | vpcd must be running | Stops the vpcd service | **DONE** |
| `spx service vpcd status` | — | None | Reports vpcd service status | **DONE** |
| Command | Flags | Description |
|---------|-------|-------------|
| `spx service predastore start` | `--port` (default: 8443), `--host` (default: 0.0.0.0), `--base-path`, `--config-path`, `--debug`, `--tls-cert`, `--tls-key`, `--backend` (distributed/filesystem, default: distributed), `--node-id` (default: 0), `--pprof`, `--pprof-output` | Creates predastore service instance with S3-compatible storage backend → starts service |
| `spx service predastore stop` | — | Stops the predastore service |
| `spx service predastore status` | — | Reports predastore service status |
| `spx service viperblock start` | `--s3-host` (default: 0.0.0.0:8443), `--s3-bucket` (default: predastore), `--s3-region` (default: ap-southeast-2), `--plugin-path` (auto-detected via `nbdkit --dump-config plugindir`; typically `/usr/lib/x86_64-linux-gnu/nbdkit/plugins/nbdkit-viperblock-plugin.so` on amd64, overridable via `SPINIFEX_VIPERBLOCK_PLUGIN_PATH` in `/etc/spinifex/systemd.env`) | Loads cluster config → connects to NATS and Predastore → starts viperblock block storage service with NBD plugin |
| `spx service viperblock stop` | — | Stops the viperblock service |
| `spx service viperblock status` | — | Reports viperblock service status |
| `spx service nats start` | `--port` (default: 4222), `--host` (default: 0.0.0.0), `--debug`, `--data-dir`, `--jetstream` | Starts embedded NATS server with optional JetStream |
| `spx service nats stop` | — | Stops the NATS service |
| `spx service nats status` | — | Reports NATS service status |
| `spx service spinifex start` | `--wal-dir` | Loads cluster config → starts spinifex daemon (VM orchestration, NATS subscriptions, health endpoint) |
| `spx service spinifex stop` | — | Stops the spinifex daemon service |
| `spx service spinifex status` | — | Reports spinifex daemon service status |
| `spx service awsgw start` | `--host` (default: 0.0.0.0:9999), `--tls-cert`, `--tls-key`, `--debug` | Loads cluster config → starts AWS-compatible gateway with SigV4 auth, IAM policy enforcement, TLS |
| `spx service awsgw stop` | — | Stops the AWS gateway service |
| `spx service awsgw status` | — | Reports AWS gateway service status |
| `spx service spinifex-ui start` | `--port` (default: 3000), `--host` (default: 0.0.0.0), `--tls-cert`, `--tls-key` | Starts embedded web UI server serving the React frontend. Aliases: `ui`, `spinifexui` |
| `spx service spinifex-ui stop` | — | Stops the spinifex-ui service |
| `spx service spinifex-ui status` | — | Reports spinifex-ui service status |
| `spx service vpcd start` | — | Loads cluster config → starts VPC daemon (subscribes to `vpc.*` NATS events, translates to OVN logical switches/ports/routers) |
| `spx service vpcd stop` | — | Stops the vpcd service |
| `spx service vpcd status` | — | Reports vpcd service status |
### Cluster Inspection
Operational commands for inspecting cluster state. These fan out NATS requests to all nodes and aggregate responses.
| Command | Flags | Prerequisites | Basic Logic | Output Columns | Status |
|---------|-------|---------------|-------------|----------------|--------|
| `spx get nodes` | `--timeout` (default: 3s) | Cluster must be running (NATS) | Loads config → publishes to `spinifex.node.status` fan-out topic → collects responses within timeout → merges config-known nodes with NATS responders → nodes that don't respond shown as `NotReady` | NAME, STATUS, IP, REGION, AZ, UPTIME, VMs, SERVICES | **DONE** |
| `spx get vms` | `--timeout` (default: 3s) | Cluster must be running (NATS) | Publishes to `spinifex.node.vms` fan-out topic → collects VM info from all nodes → sorts by node then instance ID → prints table. Alias: `spx get instances` | INSTANCE, STATUS, TYPE, VCPU, MEM, NODE, IP, AGE | **DONE** |
| Command | Flags | Description |
|---------|-------|-------------|
| `spx get nodes` | `--timeout` (default: 3s) | Loads config → publishes to `spinifex.node.status` fan-out topic → collects responses within timeout → merges config-known nodes with NATS responders → nodes that don't respond shown as `NotReady` |
| `spx get vms` | `--timeout` (default: 3s) | Publishes to `spinifex.node.vms` fan-out topic → collects VM info from all nodes → sorts by node then instance ID → prints table. Alias: `spx get instances` |
### Resource Monitoring
| Command | Flags | Prerequisites | Basic Logic | Output | Status |
|---------|-------|---------------|-------------|--------|--------|
| `spx top nodes` | `--timeout` (default: 3s) | Cluster must be running (NATS) | Publishes to `spinifex.node.status` fan-out topic → collects CPU/memory usage per node → aggregates instance type capacity across all nodes → prints two tables: per-node resource usage and cluster-wide instance type availability | Table 1: NAME, CPU (used/total), MEM (used/total), GPU (used/total), VMs — GPU column shows `allocated/total` when passthrough is enabled, `0/count*` when the node is GPU-capable but passthrough is disabled, or `-` when no GPU is present. Table 2: INSTANCE TYPE, AVAILABLE, VCPU, MEMORY | **DONE** |
| Command | Flags | Description |
|---------|-------|-------------|
| `spx top nodes` | `--timeout` (default: 3s) | Publishes to `spinifex.node.status` fan-out topic → collects CPU/memory usage per node → aggregates instance type capacity across all nodes → prints two tables: per-node resource usage and cluster-wide instance type availability |
### Cluster Initialization
| Command | Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------|---------------|-------------|------------|--------|
| `spx admin init` | `--nodes`, `--node`, `--bind`, `--port`, `--region`, `--az`, `--cluster-name`, `--cluster-bind`, `--cluster-routes`, `--predastore-nodes`, `--services`, `--formation-timeout`, `--token-ttl`, `--force` | None (first-time setup) | Generates root IAM credentials (AKIA-prefixed access key + secret) → creates master.key (AES-256, 32 bytes, 0600) → writes bootstrap.json (consumed on first start) → generates CA + server TLS certificates → generates join token (written to `join-token` file, displayed in join command) → creates NATS config with auth token → writes spinifex.toml, awsgw.toml, predastore.toml → configures AWS CLI `spx` profile → creates directory structure under `~/spinifex/` | 1. Init creates all config files<br>2. Root credentials printed once<br>3. master.key is 32 bytes, mode 0600<br>4. bootstrap.json consumed on first start<br>5. `--force` re-initializes existing config<br>6. AWS CLI profile `spx` auto-configured<br>7. Multi-node init generates join token and writes to `<config-dir>/join-token` | **DONE** |
| `spx admin join` | `--host` (required), `--node` (required), `--token` (required), `--bind`, `--port`, `--region`, `--az`, `--cluster-bind`, `--cluster-routes`, `--data-dir`, `--services` | Leader node must be running | Connects to leader node with join token (Authorization: Bearer header) → retrieves cluster configuration → configures local node to join cluster and participate in distributed operations | 1. Join existing cluster<br>2. Missing host (error)<br>3. Missing node name (error)<br>4. Missing token (error)<br>5. Invalid token (401)<br>6. Expired token (401) | **DONE** |
| Command | Flags | Description |
|---------|-------|-------------|
| `spx admin init` | `--nodes`, `--node`, `--bind`, `--port`, `--region`, `--az`, `--cluster-name`, `--cluster-bind`, `--cluster-routes`, `--predastore-nodes`, `--services`, `--formation-timeout`, `--token-ttl`, `--force` | Generates root IAM credentials (AKIA-prefixed access key + secret) → creates master.key (AES-256, 32 bytes, 0600) → writes bootstrap.json (consumed on first start) → generates CA + server TLS certificates → generates join token (written to `join-token` file, displayed in join command) → creates NATS config with auth token → writes spinifex.toml, awsgw.toml, predastore.toml → configures AWS CLI `spx` profile → creates directory structure under `~/spinifex/` |
| `spx admin join` | `--host` (required), `--node` (required), `--token` (required), `--bind`, `--port`, `--region`, `--az`, `--cluster-bind`, `--cluster-routes`, `--data-dir`, `--services` | Connects to leader node with join token (Authorization: Bearer header) → retrieves cluster configuration → configures local node to join cluster and participate in distributed operations |
### Version
| Command | Flags | Prerequisites | Basic Logic | Status |
|---------|-------|---------------|-------------|--------|
| `spx version` | — | None | Prints Spinifex version, commit hash, OS, and architecture (populated via build-time ldflags) | **DONE** |
| Command | Flags | Description |
|---------|-------|-------------|
| `spx version` | — | Prints Spinifex version, commit hash, OS, and architecture (populated via build-time ldflags) |
### Cluster Operations
| Command | Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------|---------------|-------------|------------|--------|
| `spx admin cluster shutdown` | `--force` (shutdown even if nodes don't respond), `--timeout` (max wait per phase, default 120s), `--dry-run` (print phase plan without executing) | Cluster must be running | Performs coordinated, phased shutdown of entire cluster. Phases execute in order: GATE (stop API/UI) → DRAIN (stop VMs) → STORAGE (stop viperblock) → PERSIST (stop predastore) → INFRA (stop NATS/daemon). Each phase waits for all nodes to ACK before proceeding. Uses JetStream state tracking. | 1. Shutdown running cluster<br>2. All nodes stop cleanly<br>3. Force shutdown with unresponsive nodes<br>4. Dry-run prints plan | **DONE** |
| 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
| Command | Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------|---------------|-------------|------------|--------|
| `spx admin cert renew` | `--extra-ip` (additional IPs for SANs), `--extra-dns` (additional DNS names for SANs) | Existing CA from `spx admin init` | Reads existing CA → regenerates server certificate with all current network interface IPs and machine hostname in SANs → writes new cert. Use after adding a new network interface or changing IP addresses. | 1. Renew with auto-detected IPs<br>2. Renew with extra IPs<br>3. Renew with extra DNS names | **DONE** |
| Command | Flags | Description |
|---------|-------|-------------|
| `spx admin cert renew` | `--extra-ip` (additional IPs for SANs), `--extra-dns` (additional DNS names for SANs) | Reads existing CA → regenerates server certificate with all current network interface IPs and machine hostname in SANs → writes new cert. Use after adding a new network interface or changing IP addresses. |
### Upgrade Management
| Command | Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------|---------------|-------------|------------|--------|
| `spx admin upgrade` | `--yes` (apply without prompting), `--config-dir` (persistent, default: `~/spinifex/config`), `--spinifex-dir` (persistent, default: `~/spinifex`) | Existing installation (`spinifex.toml` must exist in config dir) | Reads current config versions from registry → computes pending config migrations (from→to per target) → prints version summary and pending list → prompts for confirmation unless `--yes` → runs `migrate.DefaultRegistry.RunAllConfig()` to apply migrations to config files. Intended for upgrades between Spinifex versions. Operators can skip migrations during install by setting `INSTALL_SPINIFEX_SKIP_MIGRATE=1`, then run `spx admin upgrade` manually to review and apply. After completion, services must be restarted with `sudo systemctl restart spinifex.target`. Invoked non-interactively by `scripts/setup.sh` with `--yes`. | 1. No installation present (error, suggests `spx admin init`)<br>2. No pending migrations (prints "No pending config migrations")<br>3. Pending migrations with interactive confirmation (y/yes applies, anything else aborts)<br>4. Pending migrations with `--yes` (applies without prompt)<br>5. Migration failure surfaces error and exits non-zero | **DONE** |
| Command | Flags | Description |
|---------|-------|-------------|
| `spx admin upgrade` | `--yes` (apply without prompting), `--config-dir` (persistent, default: `~/spinifex/config`), `--spinifex-dir` (persistent, default: `~/spinifex`) | Reads current config versions from registry → computes pending config migrations (from→to per target) → prints version summary and pending list → prompts for confirmation unless `--yes` → runs `migrate.DefaultRegistry.RunAllConfig()` to apply migrations to config files. Intended for upgrades between Spinifex versions. Operators can skip migrations during install by setting `INSTALL_SPINIFEX_SKIP_MIGRATE=1`, then run `spx admin upgrade` manually to review and apply. After completion, services must be restarted with `sudo systemctl restart spinifex.target`. Invoked non-interactively by `scripts/setup.sh` with `--yes`. |
### Account Management
| Command | Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------|---------------|-------------|------------|--------|
| `spx admin account create` | `--name` | Cluster must be running (NATS), master key must exist | Connects to NATS → CAS loop on `spinifex-account-counter:next_id` for sequential 12-digit ID → creates Account record in `spinifex-accounts` KV → creates `admin` user in new account → creates access key for admin → creates AdministratorAccess policy (Action:*, Resource:*) → attaches policy → prints credentials | 1. Create first account (ID 000000000001)<br>2. Sequential IDs (000000000002, etc.)<br>3. Admin user has full access<br>4. Credentials printed once | **DONE** |
| `spx admin account list` | — | Cluster must be running (NATS), master key must exist | Connects to NATS → IAMService.ListAccounts() → prints table with Account ID, Name, Status, Created | 1. List all accounts<br>2. Empty list (only global account) | **DONE** |
| Command | Flags | Description |
|---------|-------|-------------|
| `spx admin account create` | `--name` | Connects to NATS → CAS loop on `spinifex-account-counter:next_id` for sequential 12-digit ID → creates Account record in `spinifex-accounts` KV → creates `admin` user in new account → creates access key for admin → creates AdministratorAccess policy (Action:*, Resource:*) → attaches policy → prints credentials |
| `spx admin account list` | — | Connects to NATS → IAMService.ListAccounts() → prints table with Account ID, Name, Status, Created |
### Image Management
| Command | Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------|---------------|-------------|------------|--------|
| `spx admin images import` | `--name`, `--file`, `--distro`, `--version`, `--arch`, `--platform`, `--boot-mode` (bios/uefi/uefi-preferred), `--tag`, `--force`, `--skip-verify` | Cluster must be running; either `--name` (catalog download) or `--file` (operator-supplied media). `--file` mode additionally requires `--distro`, `--version`, `--arch`, `--boot-mode`. | Catalog imports (`--name`) download the image, fetch the catalog `Checksum` URL, verify the SHA-256/SHA-512 digest, and inherit `BootMode` from the catalog entry. `--boot-mode` overrides the catalog value when set. Mismatch fails closed; the cached file is left on disk and `--force` re-downloads. `--file` imports skip checksum verification (operator-supplied media is outside Spinifex's trust boundary, the skip is logged at INFO for audit) and require an explicit `--boot-mode` because there is no catalog metadata to inherit from. `--skip-verify` bypasses verification for catalog imports and emits a WARN slog + stderr notice; use only for debugging or when upstream mirrors are confirmed-broken. | 1. Import valid catalog image (verifies checksum)<br>2. Tampered cache hit fails with `ErrChecksumMismatch`<br>3. `--force` recovers after a mismatch<br>4. `--file` import skips verification<br>5. `--skip-verify` bypasses checksum with WARN<br>6. `--file` without `--boot-mode` rejected<br>7. `--boot-mode=invalid` rejected | **DONE** |
| `spx admin images list` | — | None | Lists available OS images that can be imported or downloaded | 1. List available images | **DONE** |
| `spx admin images remove` | `--image-id` (required), `--force`, `--yes` | Cluster must be running; predastore reachable. Targets AMIs imported via `spx admin images import` (non-account `ImageOwnerAlias`, e.g. `"system"`). | Loads `ami-<id>/config.json`, walks transitive dependents — copied snapshots whose `VolumeID == imageID`, volumes whose `SnapshotID` references the internal `snap-ami-<id>` or any derived snap, and account AMIs created via `CopyImage` whose `SnapshotID` is a derived snap — then prompts (skipped with `--yes`) before deleting `ami-<id>/config.json` (the DescribeImages barrier) followed by the rest of `ami-<id>/` and `snap-ami-<id>/`. Account-owned AMIs are refused with a hint pointing at `aws ec2 deregister-image` + `aws ec2 delete-snapshot`. `--force` bypasses the dependency, ownership and config-corrupt checks for salvage of orphaned blocks. | 1. Happy path: system AMI, no deps → deleted<br>2. Account-owned → refused with AWS-flow hint<br>3. Missing/corrupt config → `InvalidAMIID.NotFound` (salvageable with `--force`)<br>4. Dependent volume (direct or via copied snap) → refused<br>5. Dependent account AMI → refused<br>6. `--force` overrides dependents<br>7. Idempotent re-run after partial delete | **DONE** |
#### Image integrity verification (CMMC SI.L1-3.14.2)
Catalog imports (`spx admin images import --name <name>`) verify the image
against the catalog-declared SHA-256/SHA-512 digest before extraction. The sums
file is fetched from the catalog `Checksum` URL over HTTPS only (cross-scheme
redirects refused), and verification runs on both fresh downloads and cache
hits so a poisoned cache is caught on the next import.
On mismatch the import exits non-zero, the cached file is left on disk for
inspection, and the printed guidance is `spx admin images import --name <name>
--force` to re-download.
`--file` imports skip verification by design: operator-supplied media is
outside Spinifex's trust boundary and the operator is responsible for
integrity (e.g. `sha256sum` against a trusted upstream digest before import).
The skip is recorded as an INFO `slog` event with `reason=local-file-import`
so a CMMC assessor can audit the decision from journald.
`--skip-verify` bypasses the checksum step for catalog imports. The command
still downloads via the catalog URL but does not compare the image digest
against the sums file. Intended for narrow cases such as debugging upstream
mirror issues or running against a transiently-broken `latest/` path; the
skip is logged at WARN with `reason=skip-verify-flag` and printed to stderr
so operators and assessors see it. Prefer `--file` with an out-of-band
verified image over `--skip-verify` whenever possible.
**Limitation:** verification confirms the image matches the digest the mirror
served. A mirror compromise that swaps both image and sums file is not
detected; closing that gap requires GPG signature verification of the sums
file, deferred to a later phase.
#### `spx admin images remove` caveats
Admin-imported AMIs (`ImageOwnerAlias = "system"`) live
under the `ami-<id>/` S3 prefix and use a viperblock-internal snap checkpoint
at `snap-ami-<id>/` — there is no `snap-<id>/metadata.json`. The AWS handlers
(`DeregisterImage`, `DeleteSnapshot`) reject system owners with
`UnauthorizedOperation`, which is the right behaviour for tenant API callers
but leaves no AWS-flow path to reclaim space. `spx admin images remove` is
the admin-trust-boundary counterpart that performs the dependency walk and
hard-deletes the blocks directly against predastore.
`CopyImage` of a system AMI is metadata-only: it writes a fresh
`snap-<acct>/metadata.json` whose `VolumeID` points at `ami-<sys>` and a new
`ami-<acct>/config.json` referencing that snap. Volumes launched from the
copied AMI read transitively from `ami-<sys>/chunks/...`. The remove command
walks this transitive set and refuses if anything references the target.
**TOCTOU window:** between the safety scan and the `config.json` delete a
concurrent `RunInstances` against the AMI could create a new dependent
volume. The window is sub-second on a healthy cluster. The admin running
this command is expected to know the fleet's operational state; if the race
fires the result is a `vol-<id>` with deleted backing blocks, recovered by
terminating the orphaned instance.
**`--force` bypasses every safety check** (dependents, ownership, missing /
corrupt `config.json`). Use only for salvage of orphaned blocks. Running it
against a live system AMI corrupts every dependent volume on the next disk
read.
| Command | Flags | Description |
|---------|-------|-------------|
| `spx admin images import` | `--name`, `--file`, `--distro`, `--version`, `--arch`, `--platform`, `--boot-mode` (bios/uefi/uefi-preferred), `--tag`, `--force`, `--skip-verify` | Catalog imports (`--name`) download the image, fetch the catalog `Checksum` URL, verify the SHA-256/SHA-512 digest, and inherit `BootMode` from the catalog entry. `--boot-mode` overrides the catalog value when set. Mismatch fails closed; the cached file is left on disk and `--force` re-downloads. `--file` imports skip checksum verification (operator-supplied media is outside Spinifex's trust boundary, the skip is logged at INFO for audit) and require an explicit `--boot-mode` because there is no catalog metadata to inherit from. `--skip-verify` bypasses verification for catalog imports and emits a WARN slog + stderr notice; use only for debugging or when upstream mirrors are confirmed-broken. |
| `spx admin images list` | — | Lists available OS images that can be imported or downloaded |
| `spx admin images remove` | `--image-id` (required), `--force`, `--yes` | Loads `ami-<id>/config.json`, walks transitive dependents — copied snapshots whose `VolumeID == imageID`, volumes whose `SnapshotID` references the internal `snap-ami-<id>` or any derived snap, and account AMIs created via `CopyImage` whose `SnapshotID` is a derived snap — then prompts (skipped with `--yes`) before deleting `ami-<id>/config.json` (the DescribeImages barrier) followed by the rest of `ami-<id>/` and `snap-ami-<id>/`. Account-owned AMIs are refused with a hint pointing at `aws ec2 deregister-image` + `aws ec2 delete-snapshot`. `--force` bypasses the dependency, ownership and config-corrupt checks for salvage of orphaned blocks. |
### GPU Management
| Command | Flags | Prerequisites | Basic Logic | Test Cases | Status |
|---------|-------|---------------|-------------|------------|--------|
| `spx admin gpu status` | `--node` (default: local node) | Cluster must be running (NATS) | Queries `spinifex.node.status` fan-out → finds the target node response → prints Node, GPU hardware (model list or "none detected"), IOMMU state, vfio-pci state, passthrough enabled/disabled, and GPU pool allocation (`allocated/total`). Also lists GPU-capable instance types when passthrough is active. | 1. Local node with passthrough enabled (shows pool and instance types)<br>2. Local node capable but passthrough disabled (shows "run enable")<br>3. Node without GPU hardware (shows "none detected")<br>4. `--node` targets a remote node<br>5. Node not found or not responding (error) | **DONE** |
| `spx admin gpu enable` | — | Cluster must be running (NATS); node must be GPU-capable (IOMMU + vfio-pci configured) | Checks current passthrough state via NATS → errors if already enabled or prerequisites not met (directs to `setup`) → writes `gpu_passthrough = true` to `spinifex.toml` via `admin.SetGPUPassthrough` → sends SIGHUP to `spinifex-daemon` via `systemctl kill -s HUP` → polls node status for up to 30 s until daemon confirms new state → prints final `gpu status` output. Must be run directly on the target host. | 1. Enable on capable node<br>2. Already enabled (no-op)<br>3. Prerequisites not met (error, suggests `setup`)<br>4. Daemon does not confirm within 30 s (timeout error) | **DONE** |
| `spx admin gpu disable` | — | Cluster must be running (NATS); no GPU instances may be running | Checks current passthrough state via NATS → errors if already disabled or if `AllocGPUs > 0` (must terminate GPU instances first) → writes `gpu_passthrough = false` → sends SIGHUP to `spinifex-daemon` → polls for up to 30 s → prints final `gpu status` output. Must be run directly on the target host. | 1. Disable with no GPU instances running<br>2. Already disabled (no-op)<br>3. GPU instances running (error with count)<br>4. Daemon does not confirm within 30 s (timeout error) | **DONE** |
| `spx admin gpu setup` | — | Must be run as root; NVIDIA or AMD GPU must be present | Idempotent host configuration for GPU passthrough. Steps (skipped if already applied): detect GPUs via `gpu.Discover` → collect PCI IDs for all IOMMU-group siblings → check/enable IOMMU in GRUB (`intel_iommu=on iommu=pt` or `amd_iommu=on iommu=pt`) → write vfio udev rule (`/etc/udev/rules.d/99-spinifex-vfio.rules`) → blacklist nouveau (`/etc/modprobe.d/blacklist-nouveau.conf`) → blacklist amdgpu if AMD GPU present (`/etc/modprobe.d/blacklist-amdgpu.conf`) → write vfio-pci early binding config (`/etc/modprobe.d/vfio-pci.conf`) → add vfio modules to initramfs. If any change requires a reboot: runs `update-initramfs -u` and exits with reboot instructions. After reboot: verifies `vfio_pci` module is loaded → verifies each GPU is bound to `vfio-pci` (binds explicitly via `driver_override` if unbound) → calls `gpu enable` to activate passthrough. | 1. First run on unconfigured host (GRUB updated, reboot required)<br>2. Re-run after reboot (verifies bindings, enables passthrough)<br>3. Already fully configured (all steps skipped, enables passthrough)<br>4. AMD GPU present (blacklists amdgpu in addition to nouveau)<br>5. Not run as root (error)<br>6. No GPU hardware detected (error) | **DONE** |
| `spx admin gpu mig status` | — (must be run on target host) | NVIDIA GPU with MIG support present | Runs `gpu.Discover()` locally → for each GPU prints: PCI address, model, MIG capability, MIG mode (enabled/disabled/N/A); for GPUs with MIG enabled lists active MIG slices with GI ID, profile name, and mdev path. | 1. Node with MIG enabled (shows slices)<br>2. MIG capable but not enabled (shows disabled)<br>3. No MIG-capable GPU (shows N/A for MIG fields) | **DONE** |
| `spx admin gpu mig enable` | `--profile <name>` (required, e.g. `1g.10gb`), `--gpu <pci-addr>` (optional, default: all MIG-capable GPUs) | Must be run as root; MIG-capable GPU present; no GPU instances running | Checks no GPU instances running (NATS) → discovers MIG-capable GPUs via `gpu.Discover()` (filtered by `--gpu` if set) → enables MIG mode on each target (`gpu.EnableMIGMode`) → lists available profiles (`gpu.ListProfiles`) and validates requested profile name → destroys any existing instances (`gpu.DestroyAllInstances`) → creates new instances filling GPU capacity (`gpu.CreateInstances`) → writes `mig_profile` to `spinifex.toml` via `admin.SetMIGProfile` → sends SIGHUP to `spinifex-daemon`. Must be run directly on the target host. | 1. Enable MIG with valid profile<br>2. Invalid profile name (error with available profile list)<br>3. No MIG-capable GPU (error)<br>4. GPU instances running (error)<br>5. `--gpu` targets specific GPU<br>6. Not run as root (error) | **DONE** |
| `spx admin gpu mig disable` | `--gpu <pci-addr>` (optional, default: all MIG-capable GPUs) | Must be run as root; no GPU instances running | Checks no GPU instances running (NATS) → discovers MIG-capable GPUs via `gpu.Discover()` (filtered by `--gpu` if set) → destroys all GPU instances (`gpu.DestroyAllInstances`) → disables MIG mode (`gpu.DisableMIGMode`) → clears `mig_profile` in `spinifex.toml` via `admin.SetMIGProfile` → sends SIGHUP to `spinifex-daemon`. Must be run directly on the target host. | 1. Disable with no running GPU instances<br>2. GPU instances running (error)<br>3. MIG not enabled on GPU (skips that GPU)<br>4. `--gpu` targets specific GPU<br>5. Not run as root (error) | **DONE** |
| Command | Flags | Description |
|---------|-------|-------------|
| `spx admin gpu status` | `--node` (default: local node) | Queries `spinifex.node.status` fan-out → finds the target node response → prints Node, GPU hardware (model list or "none detected"), IOMMU state, vfio-pci state, passthrough enabled/disabled, and GPU pool allocation (`allocated/total`). Also lists GPU-capable instance types when passthrough is active. |
| `spx admin gpu enable` | — | Checks current passthrough state via NATS → errors if already enabled or prerequisites not met (directs to `setup`) → writes `gpu_passthrough = true` to `spinifex.toml` via `admin.SetGPUPassthrough` → sends SIGHUP to `spinifex-daemon` via `systemctl kill -s HUP` → polls node status for up to 30 s until daemon confirms new state → prints final `gpu status` output. Must be run directly on the target host. |
| `spx admin gpu disable` | — | Checks current passthrough state via NATS → errors if already disabled or if `AllocGPUs > 0` (must terminate GPU instances first) → writes `gpu_passthrough = false` → sends SIGHUP to `spinifex-daemon` → polls for up to 30 s → prints final `gpu status` output. Must be run directly on the target host. |
| `spx admin gpu setup` | — | Idempotent host configuration for GPU passthrough. Steps (skipped if already applied): detect GPUs via `gpu.Discover` → collect PCI IDs for all IOMMU-group siblings → check/enable IOMMU in GRUB (`intel_iommu=on iommu=pt` or `amd_iommu=on iommu=pt`) → write vfio udev rule (`/etc/udev/rules.d/99-spinifex-vfio.rules`) → blacklist nouveau (`/etc/modprobe.d/blacklist-nouveau.conf`) → blacklist amdgpu if AMD GPU present (`/etc/modprobe.d/blacklist-amdgpu.conf`) → write vfio-pci early binding config (`/etc/modprobe.d/vfio-pci.conf`) → add vfio modules to initramfs. If any change requires a reboot: runs `update-initramfs -u` and exits with reboot instructions. After reboot: verifies `vfio_pci` module is loaded → verifies each GPU is bound to `vfio-pci` (binds explicitly via `driver_override` if unbound) → calls `gpu enable` to activate passthrough. |
| `spx admin gpu mig status` | — (must be run on target host) | Runs `gpu.Discover()` locally → for each GPU prints: PCI address, model, MIG capability, MIG mode (enabled/disabled/N/A); for GPUs with MIG enabled lists active MIG slices with GI ID, profile name, and mdev path. |
| `spx admin gpu mig enable` | `--profile <name>` (required, e.g. `1g.10gb`), `--gpu <pci-addr>` (optional, default: all MIG-capable GPUs) | Checks no GPU instances running (NATS) → discovers MIG-capable GPUs via `gpu.Discover()` (filtered by `--gpu` if set) → enables MIG mode on each target (`gpu.EnableMIGMode`) → lists available profiles (`gpu.ListProfiles`) and validates requested profile name → destroys any existing instances (`gpu.DestroyAllInstances`) → creates new instances filling GPU capacity (`gpu.CreateInstances`) → writes `mig_profile` to `spinifex.toml` via `admin.SetMIGProfile` → sends SIGHUP to `spinifex-daemon`. Must be run directly on the target host. |
| `spx admin gpu mig disable` | `--gpu <pci-addr>` (optional, default: all MIG-capable GPUs) | Checks no GPU instances running (NATS) → discovers MIG-capable GPUs via `gpu.Discover()` (filtered by `--gpu` if set) → destroys all GPU instances (`gpu.DestroyAllInstances`) → disables MIG mode (`gpu.DisableMIGMode`) → clears `mig_profile` in `spinifex.toml` via `admin.SetMIGProfile` → sends SIGHUP to `spinifex-daemon`. Must be run directly on the target host. |
## AWS-Compatible API
@@ -172,7 +113,7 @@ read.
| Command | Implemented Flags | Missing Flags | Status |
|---------|-------------------|---------------|--------|
| `run-instances` | `--image-id`, `--instance-type`, `--count`, `--key-name`, `--user-data`, `--subnet-id`, `--block-device-mappings` (DeviceName, VolumeSize, VolumeType, Iops, DeleteOnTermination), `--placement` (GroupName), `--iam-instance-profile` (Name/Arn) | `--security-group-ids`, `--tag-specifications`, `--dry-run`, `--client-token`, `--disable-api-termination`, `--ebs-optimized`, `--network-interfaces`, `--private-ip-address`, `--monitoring`, `--credit-specification`, `--cpu-options`, `--metadata-options`, `--launch-template`, `--hibernate-options` | **DONE** |
| `run-instances` | `--image-id`, `--instance-type`, `--count`, `--key-name`, `--user-data`, `--subnet-id`, `--security-group-ids`, `--tag-specifications` (instance-scoped), `--block-device-mappings` (DeviceName, VolumeSize, VolumeType, Iops, DeleteOnTermination), `--placement` (GroupName), `--iam-instance-profile` (Name/Arn), `--capacity-reservation-specification` (CapacityReservationTarget.CapacityReservationId, targeted-by-id only) | `--dry-run`, `--client-token`, `--disable-api-termination`, `--ebs-optimized`, `--network-interfaces`, `--private-ip-address`, `--monitoring`, `--credit-specification`, `--cpu-options`, `--metadata-options`, `--launch-template`, `--hibernate-options` | **DONE** |
| `describe-instances` | `--instance-ids`, `--filters` (instance-state-name, instance-id, instance-type, vpc-id, subnet-id, tag:*, tag-key, tag-value) | `--max-results`, `--next-token`, `--dry-run` | **DONE** |
| `start-instances` | `--instance-ids` | `--dry-run`, `--force` | **DONE** |
| `stop-instances` | `--instance-ids` | `--force`, `--hibernate`, `--dry-run` | **DONE** |
@@ -191,6 +132,11 @@ read.
role ARN and rejects cross-account profile ARNs. Placement strategies: `spread`
(1 per node, atomic CAS) and `cluster` (pin all to one node).
`run-instances --capacity-reservation-specification` consumes a reserved slot on
the node owning the targeted reservation (targeted-by-id only; `open`
auto-matching is not supported). The combination with `--placement` (group) is
rejected. `describe-instances` then reports the instance's `CapacityReservationId`.
### EC2 — IAM Instance Profile Associations
Stored as `vm.VM.IamInstanceProfileArn` + `IamInstanceProfileAssociationId`
@@ -243,7 +189,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
@@ -295,9 +241,6 @@ by downstream services.
### EC2 — VPC Core
VPC/Subnet/ENI/SG CRUD stores metadata in NATS KV and publishes events to
vpcd for OVN translation. Single AZ for Spinifex v1.
| Command | Implemented Flags | Missing Flags | Status |
|---------|-------------------|---------------|--------|
| `create-vpc` | `--cidr-block`, `--tag-specifications` | `--instance-tenancy`, `--dry-run` | **DONE** |
@@ -393,9 +336,6 @@ EIP commands are only registered when an external IPAM pool is configured.
### EC2 — NAT Gateway
Deleted gateways move to a separate KV bucket with 1-hour TTL for Terraform
polling.
| Command | Implemented Flags | Missing Flags | Status |
|---------|-------------------|---------------|--------|
| `create-nat-gateway` | `--subnet-id`, `--allocation-id` | `--connectivity-type`, `--tag-specifications`, `--dry-run` | **DONE** |
@@ -406,9 +346,6 @@ polling.
### EC2 — Placement Groups
Strategies: `spread` (1 instance per node, strict) and `cluster` (all pinned
to single node). `partition` rejected.
| Command | Implemented Flags | Missing Flags | Status |
|---------|-------------------|---------------|--------|
| `create-placement-group` | `--group-name`, `--strategy` (spread/cluster) | `--partition-count`, `--spread-level`, `--tag-specifications`, `--dry-run` | **DONE** |
@@ -501,9 +438,9 @@ to single node). `partition` rejected.
| `delete-dhcp-options` | — | `--dhcp-options-id`, `--dry-run` | **NOT STARTED** |
| `describe-dhcp-options` | — | `--dhcp-options-ids`, `--filters`, `--max-results` | **NOT STARTED** |
| `associate-dhcp-options` | — | `--dhcp-options-id`, `--vpc-id`, `--dry-run` | **NOT STARTED** |
| `create-capacity-reservation` | — | `--instance-type`, `--instance-platform`, `--availability-zone`, `--instance-count`, `--end-date`, `--end-date-type`, `--instance-match-criteria`, `--tag-specifications` | **NOT STARTED** |
| `cancel-capacity-reservation` | — | `--capacity-reservation-id`, `--dry-run` | **NOT STARTED** |
| `describe-capacity-reservations` | — | `--capacity-reservation-ids`, `--filters`, `--max-results` | **NOT STARTED** |
| `create-capacity-reservation` | `--instance-type`, `--instance-count`, `--availability-zone`, `--instance-platform`, `--instance-match-criteria`, `--tenancy` (default only), `--dry-run` | `--end-date`, `--end-date-type` (unlimited only), `--availability-zone-id`, `--tag-specifications` | **DONE** |
| `cancel-capacity-reservation` | `--capacity-reservation-id`, `--dry-run` | — | **DONE** |
| `describe-capacity-reservations` | `--capacity-reservation-ids`, `--filters` | `--max-results`, `--next-token` | **DONE** |
| `modify-capacity-reservation` | — | `--capacity-reservation-id`, `--instance-count`, `--end-date`, `--end-date-type` | **NOT STARTED** |
### EC2 — Misc
@@ -543,9 +480,6 @@ bypasses policy evaluation entirely.
### IAM — Access Keys
Max 2 keys per user. Secrets encrypted with AES-256-GCM using master key,
returned plaintext only at creation.
| Command | Implemented Flags | Missing Flags | Status |
|---------|-------------------|---------------|--------|
| `create-access-key` | `--user-name` | — | **DONE** |
@@ -555,34 +489,18 @@ returned plaintext only at creation.
### IAM — Policies
Policy documents require `Version: "2012-10-17"` and a valid Statement array.
Wildcard action matching supported (`ec2:*`, `ec2:Describe*`, `*`). Evaluation
order: explicit Deny > explicit Allow > implicit Deny.
| Command | Implemented Flags | Missing Flags | Status |
|---------|-------------------|---------------|--------|
| `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** |
| `detach-user-policy` | `--user-name`, `--policy-arn` | — | **DONE** |
| `list-attached-user-policies` | `--user-name` | `--path-prefix`, `--max-items`, `--marker` | **DONE** |
### IAM — Policy Evaluation (internal — not an AWS API)
| Feature | Status |
|---------|--------|
| Root user bypass | **DONE** |
| Default deny | **DONE** |
| Explicit allow | **DONE** |
| Explicit deny | **DONE** |
| Wildcard matching (`*`, `ec2:*`, `ec2:Describe*`) | **DONE** |
| Account-scoped evaluation | **DONE** |
| EC2 action mapping | **DONE** |
| IAM action mapping (16 actions) | **DONE** |
### IAM — Roles
| Command | Implemented Flags | Missing Flags | Status |
@@ -596,12 +514,11 @@ order: explicit Deny > explicit Allow > implicit Deny.
| `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
Containers for IAM roles that allow EC2 instances to assume a role. Required
for `run-instances --iam-instance-profile`. Max 1 role per profile.
| Command | Implemented Flags | Missing Flags | Status |
|---------|-------------------|---------------|--------|
| `create-instance-profile` | `--instance-profile-name`, `--path`, `--tags` | — | **DONE** |
@@ -612,6 +529,19 @@ for `run-instances --iam-instance-profile`. Max 1 role per profile.
| `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 |
@@ -631,27 +561,85 @@ for `run-instances --iam-instance-profile`. Max 1 role per profile.
## STS
| Command | Implemented Flags | Missing Flags (rejected if supplied) | Status |
| Command | Implemented Flags | Missing Flags | Status |
|---------|-------------------|--------------------------------------|--------|
| `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`, `--serial-number`, `--token-code` | **NOT STARTED** |
| `assume-role-with-web-identity` | — | `--role-arn`, `--role-session-name`, `--web-identity-token`, `--provider-id`, `--policy`, `--policy-arns`, `--duration-seconds` | **NOT STARTED** |
| `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`, `--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.
---
## IMDS (Instance Metadata Service)
Available at `169.254.169.254` from inside every running guest VM, matching AWS. The endpoint is reached from within a guest over plain HTTP, exactly as on EC2, with no in-VM agent to install. DHCP and fully static guests reach it identically, with no in-guest route configuration.
**IMDSv2-only.** Every read requires a session token. A tokenless (v1-style) `GET` returns `401 Unauthorized` with an empty body. Obtain a token with a `PUT /latest/api/token` carrying `X-aws-ec2-metadata-token-ttl-seconds` (1–21600), then send it back in `X-aws-ec2-metadata-token` on every read.
```bash
# Inside the guest VM:
TOKEN=$(curl -s -X 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/instance-id
# Tokenless GET → 401 Unauthorized (empty body)
curl -i http://169.254.169.254/latest/meta-data/instance-id
```
### IMDS — Supported Paths
| Path | Method | Source | Status |
|------|--------|--------|--------|
| `/latest/api/token` | PUT | Issues an ENI-bound IMDSv2 token; `X-aws-ec2-metadata-token-ttl-seconds` ∈ [1, 21600] required | **DONE** |
| `/` | GET | Supported API-version list (`2021-07-15`, `latest`); token-gated | **DONE** |
| `/latest` | GET | Top-level tree listing (`dynamic`, `meta-data`, `user-data`) | **DONE** |
| `/<date>/...` | GET/PUT | Any dated API version aliases to `/latest` (cloud-init parity) | **DONE** |
| `/latest/meta-data/` | GET | Directory listing of supported children | **DONE** |
| `/latest/meta-data/instance-id` | GET | `vm.ID` | **DONE** |
| `/latest/meta-data/instance-type` | GET | `vm.InstanceType` | **DONE** |
| `/latest/meta-data/ami-id` | GET | launch `ImageId` | **DONE** |
| `/latest/meta-data/ami-launch-index` | GET | Per-instance launch index (`0..n-1`), contiguous on partial failure | **DONE** |
| `/latest/meta-data/reservation-id` | GET | `DescribeInstances` `Reservation.ReservationId` | **DONE** |
| `/latest/meta-data/instance-life-cycle` | GET | `on-demand` (Spot not modelled) | **DONE** |
| `/latest/meta-data/local-ipv4` | GET | `ENIRecord.PrivateIpAddress` (== request source IP) | **DONE** |
| `/latest/meta-data/public-ipv4` | GET | EIP, else instance public IP; empty body if none | **DONE** |
| `/latest/meta-data/public-hostname` | GET | Mirrors `public-ipv4`; 404 when no public IP | **DONE** |
| `/latest/meta-data/mac` | GET | `ENIRecord.MacAddress` | **DONE** |
| `/latest/meta-data/security-groups` | GET | `ENIRecord.SecurityGroupIds`, newline-separated | **DONE** |
| `/latest/meta-data/hostname`, `/local-hostname` | GET | Synthesised `ip-<dashed-ip>.<region>.compute.internal` | **DONE** |
| `/latest/meta-data/placement/availability-zone` | GET | `ENIRecord.AvailabilityZone` | **DONE** |
| `/latest/meta-data/placement/region` | GET | Derived from AZ (trailing letter stripped) | **DONE** |
| `/latest/meta-data/services/{domain,partition}` | GET | Static: `amazonaws.com` / `aws` | **DONE** |
| `/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` | GET | Lists `instance-identity/` | **DONE** |
| `/latest/dynamic/instance-identity` | GET | Lists `document` (signed forms listed when the signing key lands) | **DONE** |
| `/latest/dynamic/instance-identity/document` | GET | Unsigned identity document from resolved ENI + instance facts | **DONE** |
| `/latest/dynamic/instance-identity/{signature,pkcs7,rsa2048}` | GET | Signed forms; need 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/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-action` | GET | `none` unless interruptible instances ship | **NOT STARTED** (404) |
| `/latest/meta-data/spot/{instance-action,termination-time}` | GET | Only meaningful once Spot is modelled | **NOT STARTED** (404) |
---
## ELBv2 (Application & Network Load Balancer)
The data plane uses a system-managed LB VM running HAProxy, launched automatically during `create-load-balancer`. HAProxy config is pushed via NATS on listener/target changes.
The data plane uses a system-managed LB VM, launched automatically during `create-load-balancer`. Application Load Balancers run **HAProxy** (L7: rules, fixed-response, redirect, HTTP/HTTPS). Network Load Balancers run **nginx `stream`** (L4: TCP, UDP, TLS, TCP_UDP) — HAProxy cannot load-balance UDP. The agent selects the engine from the `Engine` field on the config-delivery response.
### ELBv2 — Load Balancers
@@ -681,10 +669,54 @@ The data plane uses a system-managed LB VM running HAProxy, launched automatical
| 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 |
@@ -695,18 +727,32 @@ The data plane uses a system-managed LB VM running HAProxy, launched automatical
| 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 |
@@ -82,6 +82,18 @@ spx admin images import --name debian-13-arm64
Catalog imports verify the image against the catalog-declared SHA-256/SHA-512 digest before extraction. Use `--file` to import operator-supplied media (verification skipped — operator is responsible for integrity), or `--force` to re-download after a checksum mismatch.
### EKS node image
To run EKS, import the prebuilt node image from the catalog:
```bash
spx admin images import --name spinifex-eks-node
```
This pulls the Alpine + K3s node AMI from `iso.mulgadc.com`, verifies its
checksum, and registers it tagged `spinifex:managed-by=eks`. `eks create-cluster`
and `eks create-nodegroup` resolve the boot AMI by that tag, so no further
configuration is needed.
## Cluster Shutdown
@@ -164,6 +164,16 @@ spx admin images import --name debian-13-x86_64
cloud-init needs time to configure the instance after boot. Wait 30-60 seconds and retry.
If the connection **times out** rather than being refused, the security group is likely blocking port 22. The default security group denies all inbound traffic (matching AWS), so SSH must be explicitly allowed:
```bash
# Allow SSH from anywhere on the instance's security group
aws ec2 authorize-security-group-ingress \
--group-id $SG_ID --protocol tcp --port 22 --cidr 0.0.0.0/0
```
See [VPC Networking — Security Groups](/docs/vpc-networking#security-groups) for scoping rules to a trusted CIDR.
Verify the SSH key was specified correctly when launching:
```bash
+78 -14
View File
@@ -48,8 +48,9 @@ NIC. Each host runs `ovn-controller` which programs OpenFlow rules on `br-int`
## Private vs Public Subnets
A subnet's behavior depends on two things: whether the VPC has an Internet
Gateway, and whether the subnet has `MapPublicIpOnLaunch` enabled.
A subnet's behavior depends on three things: whether the VPC has an Internet
Gateway, whether the subnet's route table has a default route to that IGW, and
whether the subnet has `MapPublicIpOnLaunch` enabled.
## Private Subnet (Default)
@@ -61,9 +62,10 @@ internet or be reached from the WAN.
<img src="../../../.github/assets/diagrams/vpc-private-subnet-flow.svg" alt="Private subnet — instance hits router, no default route, packet dropped" width="900">
</p>
If the VPC has an IGW attached, private subnet instances CAN reach the internet
via the VPC router's SNAT rule (outbound only — they share the gateway IP). They
still cannot be reached from the WAN because they have no public IP.
Private subnet instances reach the internet only if their route table has a
default route to the IGW (shared SNAT, outbound only — they share the gateway
IP) or to a NAT gateway. With no default route, egress is dropped. Either way
they cannot be reached from the WAN because they have no public IP.
## Public Subnet
@@ -77,8 +79,18 @@ managed by OVN — the instance OS only sees its private IP.
**Requirements for a public subnet:**
1. VPC has an Internet Gateway attached
2. Subnet has `MapPublicIpOnLaunch = true`
3. External IP pool configured in `spinifex.toml`
2. A route table associated with the subnet has a `0.0.0.0/0` route to the IGW
3. Subnet has `MapPublicIpOnLaunch = true`
4. External IP pool configured in `spinifex.toml`
Spinifex follows AWS route-table semantics: a subnet is only "public" if its
effective route table carries a default route to the IGW. A new VPC's main
route table has the local route only — Spinifex does **not** add the IGW route
for you. Without it, the subnet's egress is gated with a drop policy, so
instances cannot reach the internet (and inbound connections cannot complete
because return traffic is dropped) even with a public IP and an attached IGW.
Add the route explicitly — either to the main route table, or to a custom route
table associated with the subnet (see [Quick Start](#3-create-vpc-with-public-subnet)).
## Comparison
@@ -86,7 +98,7 @@ managed by OVN — the instance OS only sees its private IP.
| ------------------------ | --------------------------------- | ------------------------------ |
| Private IP | Yes | Yes |
| Public IP | No | Auto-assigned from pool |
| Outbound internet | Only if VPC has IGW (shared SNAT) | Yes (own public IP via SNAT) |
| Outbound internet | Only with a default route to IGW/NAT GW | Yes (own public IP via SNAT) |
| Inbound from WAN | No | Yes (via 1:1 NAT to public IP) |
| Instance sees public IP? | N/A | No — only sees private IP |
| Elastic IP support | Only if explicitly associated | Yes |
@@ -190,8 +202,7 @@ use `setup-ovn.sh --dhcp` to obtain one from the router. This is the router's
DHCP — not Spinifex's internal OVN DHCP for VMs.
**Use when:** VMs only need outbound access (apt update, pulling images). Edge
deployments behind ISP NAT. Single WAN IP available. Future: use with AWS-style
NAT Gateway for private subnet internet access.
deployments behind ISP NAT. Single WAN IP available.
```toml
[network]
@@ -935,17 +946,39 @@ spx admin init
VPC=$(aws ec2 create-vpc --cidr-block 10.200.0.0/16 \
--query Vpc.VpcId --output text)
SUBNET=$(aws ec2 create-subnet --vpc-id $VPC \
--cidr-block 10.200.1.0/24 \
--query Subnet.SubnetId --output text)
IGW=$(aws ec2 create-internet-gateway \
--query InternetGateway.InternetGatewayId --output text)
aws ec2 attach-internet-gateway \
--internet-gateway-id $IGW --vpc-id $VPC
SUBNET=$(aws ec2 create-subnet --vpc-id $VPC \
--cidr-block 10.200.1.0/24 \
--query Subnet.SubnetId --output text)
# Route table — give the subnet a default route to the IGW. Spinifex does NOT
# add this automatically; without it the subnet's egress is dropped.
RT=$(aws ec2 create-route-table --vpc-id $VPC \
--query RouteTable.RouteTableId --output text)
aws ec2 create-route --route-table-id $RT \
--destination-cidr-block 0.0.0.0/0 --gateway-id $IGW
aws ec2 associate-route-table --route-table-id $RT --subnet-id $SUBNET
# Auto-assign a public IP to instances launched into the subnet
aws ec2 modify-subnet-attribute \
--subnet-id $SUBNET --map-public-ip-on-launch
# Allow SSH + ICMP on the VPC's default security group (blocks inbound by default)
SG=$(aws ec2 describe-security-groups \
--filters Name=vpc-id,Values=$VPC \
--query 'SecurityGroups[0].GroupId' --output text)
aws ec2 authorize-security-group-ingress --group-id $SG \
--protocol tcp --port 22 --cidr 0.0.0.0/0
aws ec2 authorize-security-group-ingress --group-id $SG \
--protocol icmp --port -1 --cidr 0.0.0.0/0
```
## 4. Launch Instance
@@ -1135,6 +1168,37 @@ ip route show
sudo ovn-nbctl lr-nat-list vpc-$VPC
```
### Instance Has Public IP But No Internet
The instance shows a public IP in `describe-instances` but cannot reach the
internet, and inbound connections hang. The usual cause is a missing default
route: the subnet's effective route table has no `0.0.0.0/0` route to the IGW,
so Spinifex gates the subnet with a drop policy.
```bash
# Find the route table that applies to the subnet and check its routes
aws ec2 describe-route-tables \
--filters Name=association.subnet-id,Values=$SUBNET \
--query 'RouteTables[0].Routes'
# Expect a route with DestinationCidrBlock 0.0.0.0/0 and a GatewayId of igw-...
```
If the subnet has no explicit association it falls back to the VPC's main route
table — check that one too, then add the route to whichever table applies:
```bash
aws ec2 create-route --route-table-id $RT \
--destination-cidr-block 0.0.0.0/0 --gateway-id $IGW
```
On the host, the drop policy installed when a subnet lacks an IGW route appears
as a `Logical_Router_Policy` on the VPC router:
```bash
sudo ovn-nbctl lr-policy-list vpc-$VPC
# A drop policy matching the subnet CIDR means the subnet is gated.
```
### Public IP Not Reachable from WAN
Work through these checks in order — each eliminates a class of issues.
+188
View File
@@ -0,0 +1,188 @@
---
title: "ECR (Container Registry)"
description: "Store and serve container images from Spinifex's AWS-compatible Elastic Container Registry — create a repository, authenticate Docker, push and pull images, and let your EKS workers pull from it, using the AWS CLI, the Spinifex console, or Terraform."
category: "Containers"
tags:
- ecr
- containers
- docker
- registry
- oci
sections:
- overview
- prerequisites
- instructions
- troubleshooting
resources:
- title: "Spinifex Repository"
url: "https://github.com/mulgadc/spinifex"
- title: "Terraform AWS Provider — ECR"
url: "https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ecr_repository"
- title: "OCI Distribution Spec"
url: "https://github.com/opencontainers/distribution-spec"
- title: "Docker CLI Reference"
url: "https://docs.docker.com/reference/cli/docker/"
---
# ECR (Container Registry)
> Spinifex's ECR service is an AWS-compatible, OCI-compliant container registry. You create repositories with `aws ecr`, authenticate Docker with a registry token, and `docker push` / `docker pull` exactly as you would on AWS — and your EKS clusters can pull from it directly.
## Overview
ECR on Spinifex serves a private container registry on the Spinifex gateway endpoint — the same `host:9999` you already use for the AWS API. Authentication is account-scoped by token, so you push and pull at the gateway host and never need extra DNS:
```
<spinifex-gateway-host>:9999/<repository>
```
`aws ecr describe-repositories` and `aws ecr get-login-password` return the exact host to use — always prefer that value. Where real DNS is configured, the AWS-parity per-account host `<account-id>.dkr.ecr.<region>.<your-spinifex-domain>:9999` also works.
Each repository holds image manifests and their layers. Images are stored in object storage ([Predastore](https://github.com/mulgadc/predastore)) in a per-account bucket, with blobs content-addressed and de-duplicated across repositories. Authentication is token-based: `aws ecr get-login-password` mints a short-lived bearer token that `docker login` uses against the registry's `/v2/` endpoint.
**What works today**
- Repository lifecycle — `CreateRepository`, `DeleteRepository`, `DescribeRepositories`.
- OCI push and pull — blobs, manifests, and tags, including cross-repository blob mounts.
- Token auth — `GetAuthorizationToken` plus bearer/basic auth on `/v2/`.
- Repository policies — set, get, and delete a repository policy document.
**Current limitations**
- **Image scanning is not supported** — scan APIs return an explicit "operation not supported" error.
- **No automatic garbage collection yet** — deleting images does not yet reclaim underlying blobs.
- Lifecycle-policy enforcement and tag-immutability enforcement are not active.
## Prerequisites
- **Spinifex running**, with the AWS CLI configured for the `spinifex` profile (see [Installing Spinifex](/docs/install)).
- **Docker** installed locally to build, push, and pull images.
- **Docker trusting the Spinifex CA.** The registry serves TLS signed by the Spinifex local CA. Install it into the host trust store once (Docker uses the host root pool for registry TLS, so this covers every account endpoint without per-host config):
```bash
sudo cp /etc/spinifex/ca.pem /usr/local/share/ca-certificates/spinifex-local-ca.crt
sudo update-ca-certificates
sudo systemctl restart docker
```
- **The registry endpoint** — the Spinifex gateway `host:9999`. `aws ecr get-login-password` and `aws ecr describe-repositories` return the exact `host:port`; copy it from there rather than constructing it by hand.
- **For EKS pulls:** worker nodes need the `AmazonEC2ContainerRegistryReadOnly` policy on their node IAM role (the EKS prerequisites already include this) and network egress to the registry endpoint.
## Instructions
Create a repository and push an image using your preferred tool.
:::tabs
@tab AWS CLI
### 1. Create a repository
```bash
export AWS_PROFILE=spinifex
aws ecr create-repository --repository-name my-app
aws ecr describe-repositories --repository-names my-app \
--query 'repositories[0].repositoryUri'
```
The `repositoryUri` is the value you tag and push to — the gateway `host:9999`
with the repository path, port included (for example `192.0.2.10:9999/my-app`).
Use it verbatim; don't hand-build a hostname.
### 2. Authenticate Docker
Take the registry host straight from the API so the `:9999` port is correct:
```bash
REGISTRY=$(aws ecr describe-repositories --repository-names my-app \
--query 'repositories[0].repositoryUri' --output text | cut -d/ -f1)
aws ecr get-login-password --region <region> \
| docker login --username AWS --password-stdin "$REGISTRY"
```
### 3. Build, tag, and push
```bash
docker build -t my-app:latest .
docker tag my-app:latest "$REGISTRY/my-app:latest"
docker push "$REGISTRY/my-app:latest"
```
### 4. Verify and pull
```bash
aws ecr describe-images --repository-name my-app
docker pull "$REGISTRY/my-app:latest"
```
@tab Spinifex UI
From the left navigation open **ECR → Repositories**.
<img src="ecr-list-repositories.png" alt="The ECR Repositories list in the Spinifex console, showing a repository with its registry URI, tag mutability, and creation date">
### 1. Create a repository
1. Click **Create Repository**.
2. Enter the **repository name** and, optionally, set **tag mutability** (mutable or immutable).
3. Submit — the repository appears in the list with its full registry URI.
### 2. Copy the push commands
Open the repository to see its detail page. The **Push commands** panel lists the exact `aws ecr get-login-password | docker login`, `docker build`, `docker tag`, and `docker push` commands, pre-filled with your registry host and repository URI — use the **Copy** button and run them locally.
<img src="ecr-push-commands.png" alt="A repository detail page in the Spinifex console, with the Push commands panel showing the docker login, build, tag, and push commands">
### 3. Manage the repository
- The **Permissions** tab edits the repository's access policy.
- The **Lifecycle** tab manages retention rules, and **Tag immutability** can be toggled from the detail page.
- The **Scan** tab reflects that image scanning is not supported on Spinifex.
- Delete a repository from the **Delete** action in the repositories list.
@tab Terraform
Repositories can be managed as code with the standard `aws_ecr_repository` resource, with the AWS provider pointed at Spinifex's `ecr` endpoint:
```hcl
provider "aws" {
region = "ap-southeast-2"
skip_credentials_validation = true
skip_requesting_account_id = true
endpoints {
ecr = "https://<your-spinifex-gateway>"
}
}
resource "aws_ecr_repository" "my_app" {
name = "my-app"
}
output "repository_url" {
value = aws_ecr_repository.my_app.repository_url
}
```
```bash
export AWS_PROFILE=spinifex
tofu init
tofu apply
```
Then authenticate Docker and push using the `repository_url` output, following steps 2–4 in the AWS CLI tab.
:::
## Troubleshooting
**`docker login` fails with an authorization error.** The token is account-scoped and short-lived. Re-run `aws ecr get-login-password` to mint a fresh token, and confirm the registry host in `docker login` matches your account's endpoint (`aws ecr describe-repositories` returns it).
**`docker push` cannot reach the registry.** Use the exact host from `aws ecr describe-repositories` — the Spinifex gateway `host:9999`. Docker dials it directly, so the `:9999` port must be present (without it docker tries `:443` and fails). If you are using the AWS-parity `<account-id>.dkr.ecr…` hostname instead, that path needs DNS resolving it to the gateway — prefer the gateway host the API returns.
**EKS workers cannot pull an image.** Confirm the node IAM role has `AmazonEC2ContainerRegistryReadOnly` and that the workers have egress to the registry endpoint (an Internet Gateway or NAT Gateway route). See the [EKS prerequisites](/docs/eks).
**Image-scanning commands return an error.** Image scanning is not supported on Spinifex; the scan APIs intentionally reject these calls. Remove scan-on-push configuration from your tooling.
**A pushed image still appears after deletion frees no space.** Garbage collection of unreferenced blobs is not yet automatic — deleting an image removes its manifest and tags but does not immediately reclaim the underlying layers.
Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 217 KiB

+248
View File
@@ -0,0 +1,248 @@
---
title: "EKS (Managed Kubernetes)"
description: "Run a managed, AWS-compatible Kubernetes cluster on Spinifex — provision the control plane and worker node group, wire up the VPC, IAM, and security groups it needs, and deploy your first workload via the AWS CLI, the Spinifex console, or Terraform."
category: "Containers"
tags:
- eks
- kubernetes
- containers
- vpc
- iam
sections:
- overview
- prerequisites
- instructions
- troubleshooting
resources:
- title: "EKS Quickstart Workbook"
url: "https://github.com/mulgadc/spinifex/tree/main/docs/terraform-workbooks/eks-quickstart"
- title: "EKS HTTPS Ingress Workbook (LBC + ACM)"
url: "https://github.com/mulgadc/spinifex/tree/main/docs/terraform-workbooks/eks-https-ingress"
- title: "EKS GitOps Workbook (Argo CD + EBS-CSI)"
url: "https://github.com/mulgadc/spinifex/tree/main/docs/terraform-workbooks/eks-gitops-argocd"
- title: "Terraform AWS Provider"
url: "https://registry.terraform.io/providers/hashicorp/aws/latest"
- title: "kubectl Documentation"
url: "https://kubernetes.io/docs/reference/kubectl/"
---
# EKS (Managed Kubernetes)
> Spinifex's EKS service gives you a managed, AWS-compatible Kubernetes API: you create a cluster and a node group, and Spinifex provisions and runs the control plane and workers for you. Standard tools — `aws eks`, `kubectl`, the Terraform AWS provider — work unchanged against your cluster.
## Overview
An EKS cluster on Spinifex has two parts: a **control plane** (the managed Kubernetes API server, provisioned on a Spinifex-managed VM and fronted by a network load balancer) and one or more **managed node groups** of worker VMs that run your pods. You provide the VPC, subnets, and IAM roles; Spinifex creates and reconciles the cluster, its load balancer, and its security groups.
Authentication uses **EKS access entries** (the API authentication mode) — IAM principals are mapped to Kubernetes RBAC groups, and `kubectl` authenticates with `aws eks get-token` exactly as it does on AWS.
**What you'll create:**
| Resource | Purpose |
|---|---|
| VPC + subnets | The network the cluster and workers run in |
| Internet Gateway / NAT Gateway | Egress so workers can pull container images |
| Cluster IAM role | Lets the control plane manage cluster resources |
| Node IAM role | Lets workers join the cluster and pull images |
| EKS cluster | The managed Kubernetes API endpoint |
| Managed node group | The worker VMs that run your pods |
| Security-group rule | Opens your app's port to reach the workers |
**Spinifex specifics**
- **Auth mode is API-only.** `authentication_mode` must be `API`; the legacy `CONFIG_MAP` and `API_AND_CONFIG_MAP` modes are rejected. Grant access with access entries, not an `aws-auth` ConfigMap.
- **Security groups are auto-managed.** Spinifex creates the cluster and node-group security groups deterministically (e.g. `eks-cluster-<name>-nodegroup-sg`); `vpc_config.security_group_ids` is ignored. To expose a workload you add an ingress rule to the auto-managed node-group SG.
- **The worker image is fixed.** Node groups always boot Spinifex's `eks-node` image. `ami_type` is recorded but does not select the image, and the image must be registered on the cluster before you create a node group.
- **Default Kubernetes version is `1.32`.**
## Prerequisites
Before creating a cluster you need the supporting infrastructure in place. The Terraform workbooks build all of this for you; if you are using the CLI or console, create it first.
- **Spinifex running**, with the AWS CLI configured for the `spinifex` profile (see [Installing Spinifex](/docs/install)) and `kubectl` installed.
- **The `eks-node` image** registered on the cluster. The console blocks cluster creation until it is present.
- **A VPC with at least one subnet.** The control plane launches in your first subnet; workers run in the subnets you pass to the node group.
- **Egress for the workers.** Workers must reach the internet to pull container images:
- **Public workers** — an **Internet Gateway** with a default route (`0.0.0.0/0 → IGW`) and public IPs.
- **Private workers** — a **NAT Gateway** in a public subnet with a default route (`0.0.0.0/0 → NAT`) from the private subnets.
- **Two IAM roles:**
- A **cluster role** trusted by `eks.amazonaws.com` with `AmazonEKSClusterPolicy` attached.
- A **node role** trusted by `ec2.amazonaws.com` with `AmazonEKSWorkerNodePolicy`, `AmazonEKS_CNI_Policy`, and `AmazonEC2ContainerRegistryReadOnly` attached (the last lets workers pull images from [ECR](/docs/ecr)).
## Instructions
The same cluster can be created three ways. Pick your tool — each path reaches the same working cluster.
:::tabs
@tab AWS CLI
### 1. Create the cluster
Point the cluster at your VPC subnets and the cluster role, with API authentication and a public endpoint:
```bash
export AWS_PROFILE=spinifex
aws eks create-cluster \
--name demo \
--role-arn arn:aws:iam::000000000000:role/eks-cluster-role \
--resources-vpc-config subnetIds=subnet-aaaa,subnet-bbbb,endpointPublicAccess=true \
--access-config authenticationMode=API \
--kubernetes-version 1.32
```
Wait for it to become active:
```bash
aws eks wait cluster-active --name demo
aws eks describe-cluster --name demo --query 'cluster.status'
```
### 2. Add a node group
```bash
aws eks create-nodegroup \
--cluster-name demo \
--nodegroup-name default \
--node-role-arn arn:aws:iam::000000000000:role/eks-node-role \
--subnets subnet-aaaa subnet-bbbb \
--scaling-config minSize=1,maxSize=2,desiredSize=1 \
--instance-types t3.medium
aws eks wait nodegroup-active --cluster-name demo --nodegroup-name default
```
### 3. Connect kubectl
```bash
aws eks update-kubeconfig --name demo
kubectl get nodes
```
### 4. Expose a workload
Workers join an auto-managed security group that only allows intra-cluster traffic. To reach a NodePort service, add one ingress rule to that SG (look it up by its deterministic name):
```bash
SG=$(aws ec2 describe-security-groups \
--filters Name=group-name,Values=eks-cluster-demo-nodegroup-sg \
--query 'SecurityGroups[0].GroupId' --output text)
aws ec2 authorize-security-group-ingress \
--group-id "$SG" --protocol tcp --port 30080 --cidr 0.0.0.0/0
```
Then deploy and publish your app:
```bash
kubectl create deployment hello --image=nginxdemos/hello --replicas=2
kubectl expose deployment hello --type=NodePort --port=80 \
--overrides='{"spec":{"ports":[{"port":80,"nodePort":30080}]}}'
```
@tab Spinifex UI
The Spinifex console drives the same workflow through a guided form. From the left navigation open **EKS → Clusters**.
<img src="eks-list-clusters.png" alt="The EKS Clusters list in the Spinifex console, with a Create Cluster button">
### 1. Create the cluster
1. Click **Create Cluster**.
2. Enter a **name** and choose the **Kubernetes version** (default `1.32`).
3. Choose the **cluster IAM role**, or create one inline from the dialog.
4. Select the **VPC and subnets** the cluster will run in.
5. Set **endpoint access** (public, private, or both) and submit.
<img src="eks-create-cluster.png" alt="The Create Cluster form in the Spinifex console, showing name, Kubernetes version, IAM role, VPC, subnets, and endpoint access fields">
The form notes that the authentication mode is API and that the legacy aws-auth ConfigMap is not supported. The cluster appears in the **Clusters** list as `CREATING` and flips to `ACTIVE` once the control plane is healthy.
### 2. Add a node group
1. Open the cluster and switch to the **Node groups** tab.
2. Click **Add node group**, choose the **node IAM role**, **subnets**, **instance type**, and **scaling** (desired / min / max).
3. Submit and wait for the node group to reach `ACTIVE`.
### 3. Manage access and addons
- The **Access** tab lists IAM principals and their cluster-access policies — add an access entry to let another user or role reach the cluster.
- The **Addons** tab installs and shows the health of managed addons.
- The **Networking** tab shows the cluster's VPC, subnets, endpoint access, and the auto-managed security groups.
To run `kubectl` against the cluster, follow the AWS CLI tab's `update-kubeconfig` step.
@tab Terraform
The Terraform workbooks are the fastest way to a working cluster — they build the VPC, IAM roles, cluster, node group, and a demo workload in one `apply`. Start with **eks-quickstart**:
```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
```
The cluster config points the AWS provider at Spinifex's `eks`, `ec2`, `iam`, and `sts` endpoints, then creates the cluster and node group:
```hcl
resource "aws_eks_cluster" "this" {
name = "eks-quickstart"
role_arn = aws_iam_role.cluster.arn
version = "1.32"
access_config {
authentication_mode = "API"
}
vpc_config {
subnet_ids = [aws_subnet.a.id, aws_subnet.b.id]
endpoint_public_access = true
}
}
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 = ["t3.medium"]
}
```
Apply it, then connect:
```bash
export AWS_PROFILE=spinifex
tofu init
tofu apply
aws eks update-kubeconfig --name eks-quickstart
kubectl get nodes
```
The workbooks form a ladder. **eks-quickstart** is a NodePort demo; **eks-https-ingress** adds private subnets, a NAT gateway, and HTTPS via the AWS Load Balancer Controller + ACM; **eks-gitops-argocd** delivers the app from a git repo with the Argo CD addon and persists state on an EBS-CSI (Viperblock-backed) volume. See the full templates linked in Resources.
:::
## Troubleshooting
**Cluster stuck in `CREATING`.** The control plane is still booting. Check `aws eks describe-cluster --name <name> --query 'cluster.health'` for reported issues. The control plane needs egress to bootstrap, so confirm its subnet has an Internet Gateway route.
**`kubectl` cannot connect.** Re-run `aws eks update-kubeconfig --name <name>` to refresh the endpoint and CA, and confirm the cluster is `ACTIVE`. `aws sts get-caller-identity` verifies your credentials reach the Spinifex endpoint.
**Workers stay `NotReady`.** Almost always an egress or IAM problem:
- Confirm the workers can pull images — a default route to an Internet Gateway (public) or NAT Gateway (private).
- Confirm the node role has `AmazonEKSWorkerNodePolicy`, `AmazonEKS_CNI_Policy`, and `AmazonEC2ContainerRegistryReadOnly`.
**Workload unreachable from outside the cluster.** The node-group security group only allows intra-cluster traffic by default. Add an ingress rule for your NodePort to `eks-cluster-<name>-nodegroup-sg`, as shown in the CLI tab.
**`create-cluster` rejected with an authentication-mode error.** `authentication_mode` must be `API`. Remove any `CONFIG_MAP` / `API_AND_CONFIG_MAP` setting and grant access with access entries instead.
**Addon install fails.** Only addons bundled into the registered `eks-node` image install successfully. If you pin an addon version in Terraform, leave `addon_version` unset so Spinifex selects the catalog default.
Binary file not shown.

After

Width:  |  Height:  |  Size: 224 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 124 KiB

@@ -34,7 +34,7 @@ resources:
## Overview
This guide assumes Spinifex is already installed and running. If not, follow one of the installation guides first:
- [Single-Node Install)](/docs/install)
- [Single-Node Install](/docs/install)
- [Multi-Node Install](/docs/install-multi-node)
- [Source Install](/docs/install-source)
@@ -50,7 +50,9 @@ export AWS_PROFILE=spinifex
## 1. Import an AMI
List available images and import one matching your architecture:
### Option A: Import a bundled image
List the bundled images and import one matching your architecture:
```bash
spx admin images list
@@ -64,13 +66,11 @@ ubuntu-26.04-arm64 | ubuntu | 26.04 | arm64 | uefi
ubuntu-26.04-x86_64 | ubuntu | 26.04 | x86_64 | uefi
```
Import an image:
```bash
spx admin images import --name ubuntu-26.04-x86_64
```
Or import a local image file:
### Option B: Import a local image file
```bash
spx admin images import --file ~/images/ubuntu-26.04.img --distro ubuntu --version 26.04 --arch x86_64 --boot-mode uefi
@@ -79,7 +79,7 @@ spx admin images import --file ~/images/ubuntu-26.04.img --distro ubuntu --versi
Verify the import and note the AMI ID:
```bash
aws ec2 describe-images
AMI_ID=$(aws ec2 describe-images --query 'Images[0].ImageId' --output text)
```
## 2. Create an SSH Key
@@ -115,8 +115,6 @@ aws ec2 describe-key-pairs
```bash
VPC_ID=$(aws ec2 create-vpc --cidr-block 10.200.0.0/16 \
--query 'Vpc.VpcId' --output text)
echo "VPC: $VPC_ID"
```
### Create an Internet Gateway
@@ -130,68 +128,95 @@ IGW_ID=$(aws ec2 create-internet-gateway \
aws ec2 attach-internet-gateway \
--internet-gateway-id $IGW_ID \
--vpc-id $VPC_ID
echo "IGW: $IGW_ID (attached to $VPC_ID)"
```
### Create a Public Subnet
### Create a Subnet
A public subnet auto-assigns a routable IP to each instance, making it directly reachable from your network.
Create the subnet your instances will launch into. The routing and public-IP steps below are what make it a *public* subnet.
```bash
SUBNET_ID=$(aws ec2 create-subnet \
--vpc-id $VPC_ID \
--cidr-block 10.200.1.0/24 \
--query 'Subnet.SubnetId' --output text)
```
# Enable auto-assign public IP
### Create a Route Table
A subnet is only **public** if its route table has a default route to the Internet Gateway. Spinifex does **not** add this route automatically — a new VPC's route table only routes traffic within the VPC (matching AWS). Without a `0.0.0.0/0` route to the IGW, instances in the subnet cannot reach the internet (and inbound connections cannot complete) even with a public IP assigned.
```bash
RT_ID=$(aws ec2 create-route-table \
--vpc-id $VPC_ID \
--query 'RouteTable.RouteTableId' --output text)
# Default route to the internet via the IGW
aws ec2 create-route \
--route-table-id $RT_ID \
--destination-cidr-block 0.0.0.0/0 \
--gateway-id $IGW_ID
# Associate the route table with the subnet
aws ec2 associate-route-table \
--route-table-id $RT_ID \
--subnet-id $SUBNET_ID
```
### Enable Auto-Assign Public IP
Give every instance launched into the subnet a routable public IP, making it directly reachable from your network.
```bash
aws ec2 modify-subnet-attribute \
--subnet-id $SUBNET_ID \
--map-public-ip-on-launch
echo "Subnet: $SUBNET_ID (public)"
```
### Allow SSH and ICMP
Every VPC gets a default security group that **blocks inbound traffic from outside the group** (matching AWS). Instances launched without an explicit security group use this default, so you must authorize ingress before you can SSH or ping the instance.
```bash
SG_ID=$(aws ec2 describe-security-groups \
--filters Name=vpc-id,Values=$VPC_ID \
--query 'SecurityGroups[0].GroupId' --output text)
# Allow SSH from anywhere
aws ec2 authorize-security-group-ingress \
--group-id $SG_ID \
--protocol tcp --port 22 --cidr 0.0.0.0/0
# Allow ICMP (ping) from anywhere
aws ec2 authorize-security-group-ingress \
--group-id $SG_ID \
--protocol icmp --port -1 --cidr 0.0.0.0/0
```
**Note:** `0.0.0.0/0` opens these ports to every source. For anything beyond a quick evaluation, scope the `--cidr` to a trusted range. Rule changes apply immediately — no instance restart needed.
### Verify
```bash
aws ec2 describe-vpcs --vpc-ids $VPC_ID
aws ec2 describe-subnets --subnet-ids $SUBNET_ID
aws ec2 describe-route-tables --route-table-ids $RT_ID
aws ec2 describe-security-groups --group-ids $SG_ID
```
## 4. Launch an Instance
Query available instance types for your hardware:
Launch an instance in the public subnet on a `t3.micro` (2 vCPU / 1 GiB).
> **Note:** On an **arm64** host, use `t4g.micro` instead of `t3.micro`.
```bash
aws ec2 describe-instance-types
```
Launch an instance in the public subnet, note this will select an instance type with 2 VCPU and 1024MB of memory.
> **Note:** Replace `AMI_NAME` with the previously imported image above with the `ami-` prefix.
```bash
AMI_NAME="ami-ubuntu-26.04-x86_64"
AMI_ID=$(aws ec2 describe-images \
--filters "Name=name,Values=$AMI_NAME" \
--query 'Images | sort_by(@, &CreationDate) | [-1].ImageId' \
--output text)
INSTANCE_TYPE=$(aws ec2 describe-instance-types \
--query "sort_by(InstanceTypes[?VCpuInfo.DefaultVCpus==\`2\` && MemoryInfo.SizeInMiB>=\`1024\`], &MemoryInfo.SizeInMiB)[0].InstanceType" \
--output text)
INSTANCE_ID=$(aws ec2 run-instances \
--image-id $AMI_ID \
--instance-type $INSTANCE_TYPE \
--instance-type t3.micro \
--key-name spinifex-key \
--subnet-id $SUBNET_ID \
--count 1 \
--query 'Instances[0].InstanceId' --output text)
echo "Instance: $INSTANCE_ID"
```
Wait for the instance to reach `running` state:
@@ -328,7 +353,36 @@ PRIVATE_SUBNET=$(aws ec2 create-subnet \
--query 'Subnet.SubnetId' --output text)
```
Instances in private subnets get a private IP only. They can still reach the internet via SNAT through the VPC router, but are not directly reachable from your network.
Instances in a private subnet get a private IP only and are not reachable from your network. By default they also have **no internet access**: with no `0.0.0.0/0` route in the subnet's route table, Spinifex gates egress with a drop policy — matching AWS, where a private subnet has no route off the VPC. They can still reach other instances in the same VPC.
To give a private subnet outbound-only internet access, deploy a NAT Gateway in a public subnet and point the private subnet's default route at it (the AWS pattern):
```bash
# Allocate an Elastic IP for the NAT Gateway
NAT_EIP=$(aws ec2 allocate-address --query AllocationId --output text)
# Create the NAT Gateway in the PUBLIC subnet (the one with the IGW route)
NATGW_ID=$(aws ec2 create-nat-gateway \
--subnet-id $SUBNET_ID \
--allocation-id $NAT_EIP \
--query 'NatGateway.NatGatewayId' --output text)
# Give the private subnet its own route table with a default route to the NAT Gateway
PRIVATE_RT=$(aws ec2 create-route-table \
--vpc-id $VPC_ID \
--query 'RouteTable.RouteTableId' --output text)
aws ec2 create-route \
--route-table-id $PRIVATE_RT \
--destination-cidr-block 0.0.0.0/0 \
--nat-gateway-id $NATGW_ID
aws ec2 associate-route-table \
--route-table-id $PRIVATE_RT \
--subnet-id $PRIVATE_SUBNET
```
Instances in the private subnet now reach the internet outbound through the NAT Gateway's public IP, but remain unreachable from the WAN.
### Multiple Accounts
@@ -348,7 +402,7 @@ journalctl -u spinifex-daemon -f
aws ec2 describe-images
```
### SSH Connection Refused
### SSH Connection Refused or Times Out
cloud-init needs 30-60 seconds after boot. Check instance state:
@@ -356,6 +410,20 @@ cloud-init needs 30-60 seconds after boot. Check instance state:
aws ec2 describe-instances --instance-ids $INSTANCE_ID
```
A connection that **times out** (rather than being refused) usually means the security group is blocking port 22. Confirm the default security group allows SSH ingress:
```bash
aws ec2 describe-security-groups --group-ids $SG_ID \
--query 'SecurityGroups[0].IpPermissions'
```
If there are no rules for TCP 22, authorize it (see [Allow SSH and ICMP](#allow-ssh-and-icmp)):
```bash
aws ec2 authorize-security-group-ingress \
--group-id $SG_ID --protocol tcp --port 22 --cidr 0.0.0.0/0
```
### No Public IP Assigned
Verify the subnet has `MapPublicIpOnLaunch` enabled:
@@ -378,7 +446,23 @@ aws ec2 describe-internet-gateways
### Instance Has No Internet Access
Check the VPC router's NAT rules (from the host):
First confirm the subnet's route table has a default route to the IGW. Spinifex gates a subnet's egress with a drop policy when this route is missing, so an instance with a public IP still cannot reach the internet:
```bash
aws ec2 describe-route-tables \
--filters Name=association.subnet-id,Values=$SUBNET_ID \
--query 'RouteTables[0].Routes'
# Expect a 0.0.0.0/0 route with a GatewayId of igw-...
```
If the route is missing, add it (see [Create a Route Table](#create-a-route-table)):
```bash
aws ec2 create-route --route-table-id $RT_ID \
--destination-cidr-block 0.0.0.0/0 --gateway-id $IGW_ID
```
If the route is present, check the VPC router's NAT rules (from the host):
```bash
sudo ovn-nbctl lr-nat-list $(sudo ovn-nbctl lr-list | awk '{print $2}' | head -1)
+2 -4
View File
@@ -12,8 +12,6 @@ For this tutorial download UTM.app on Mac to simplify the VM installation.
* UTM - [https://mac.getutm.app](https://mac.getutm.app)
## Download Debian ISO
Next, download the Debian ISO (ARM64, Apple Silicon)
@@ -28,7 +26,7 @@ Using the VM console, login using the `spinifex` user and your specified passwor
For a minimal Debian install additional dependencies are required prior to the Spinifex installation:
```
```bash
su root
apt update
apt install sudo curl bridge-utils
@@ -38,7 +36,7 @@ apt install sudo curl bridge-utils
Enable sudo for `spinifex` user as the root user:
```
```bash
su root
echo "spinifex ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/spinifex
```
@@ -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.
@@ -55,9 +57,9 @@ GPU passthrough requires a host kernel and OS that supports vfio-pci. Ubuntu 24.
## Prerequisites
- Supermicro H14 with 8× AMD Instinct MI350X installed
- Host OS: **Ubuntu 24.04 LTS** (kernel 6.8+) or **Debian 13** (kernel 6.12+) — minimum for vfio-pci support
- Host OS: **Ubuntu 26.04 LTS** (kernel 6.8+) or **Debian 13** (kernel 6.12+) — minimum for vfio-pci support
- Spinifex installed and all services running (`systemctl status spinifex.target`)
- AMD GPU AMI registered (`ami-ubuntu-amd-gpu`) — Ubuntu 26.04 LTS with ROCm-compatible kernel
- AMD GPU AMI registered (`ubuntu-26.04-amd-gpu-x86_64`) — Ubuntu 26.04 LTS with ROCm-compatible kernel
- AWS CLI configured with `AWS_PROFILE=spinifex` pointing at the Spinifex endpoint
- SSH key pair imported into Spinifex, VPC and security group created (see [Launching Instances](/docs/launching-instances))
@@ -0,0 +1,556 @@
---
title: "Mixed AI Workloads on a Single H200 Chassis: Guest-Managed MIG"
description: "Install Spinifex from source, configure host-local networking, attach Predastore storage, and run four concurrent AI workloads across MIG partitions managed inside guest VMs."
category: "Reference Architectures"
tags:
- nvidia
- h200
- mig
- vllm
- yolo
- predastore
- bare-metal
resources:
- title: "Install from Source"
url: "/docs/install-source"
- title: "VPC Networking"
url: "/docs/vpc-networking"
- title: "NVIDIA MIG User Guide"
url: "https://docs.nvidia.com/datacenter/tesla/mig-user-guide/"
sections:
- overview
- prerequisites
- instructions
---
## 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 AWS 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 guide documents a full bare-metal AI deployment on a single [Supermicro X13](https://www.supermicro.com/en/products/x13) chassis using NVIDIA H200 SXM GPUs. These GPUs include the [NVIDIA Multi-Instance GPU](https://www.nvidia.com/en-au/technologies/multi-instance-gpu/) (MIG) capability, which allows a single GPU to be "sliced" into up to seven independent GPU partitions that are hardware isolated. Each partition is capable of running its own workloads with reserved resources from the "host" GPU.
<p align="center"><img src="../../../.github/assets/images/h200/X13.png" alt="Supermicro X13 8U GPU System"></p>
In this setup, each EC2 instance receives an entire H200 via PCIe passthrough and manages its own MIG partitions. This gives each tenant full control over how they slice their GPU — including the ability to run heterogeneous workloads at different partition sizes on the same physical card.
A key consideration for this example is that we do not have access to the router upstream of the X13 host, or knowledge of potentially available IP addresses for the VMs we provision on it. The first step in the Instructions section outlines how we attach our own local IP address range to the bridge on the host, effectively creating a pseudo-airgapped environment - although in this specific case outbound connectivity is still required for remote access to the host, downloading required packages etc, with this configuration it is *not* required for host/EC2 instance communication.
### EC2 workload layout
| EC2 Workload | IP | MIG config | Workload | Model |
|---|---|---|---|---|
| `vm-llama3b` | 192.168.10.X | 7 × 1g.18gb | Chat inference × 7 | Llama-3.2-3B-Instruct |
| `vm-qwen32b` | 192.168.10.X | 2 × 3g.71gb | Chat inference × 2 | Qwen2.5-32B-Instruct |
| `vm-llama70b` | 192.168.10.X | 1 × 7g.141gb | Chat inference × 1 | Llama-3.1-70B-FP8 |
| `vm-yolo` | 192.168.10.X | 2 × 3g.71gb | Object detection × 2 | YOLO11x + YOLO11s |
Twelve concurrent inference endpoints in total: 7 fast 3B slots, 2 mid-tier 32B slots, 1 full-GPU 70B slot, and 2 real-time vision streams running side-by-side to compare detection models.
### Platform
| Component | Specification |
|---|---|
| **Chassis** | Supermicro X13 8U GPU System |
| **CPUs** | 2× Intel Xeon Platinum 8568Y+ (48 cores each, 96 cores / 192 threads total) |
| **RAM** | 2 TB DDR5-4800 (32× 64 GB SK Hynix DIMMs) |
| **Storage** | 4× 7.68 TB KIOXIA CD6 NVMe SSDs (30.72 TB raw) |
| **GPUs** | 8× NVIDIA H200 SXM5 (141 GiB HBM3e per GPU, ~1.13 TB total) — 4 used in this demo |
| **Host OS** | Ubuntu 26.04 LTS |
| **Orchestration** | Spinifex — EC2-compatible bare-metal API |
| **Guest OS** | Ubuntu 26.04 LTS |
| **GPU partitioning** | NVIDIA MIG — managed inside each guest VM |
| **Block storage** | Predastore — S3-compatible, NVMe-backed |
| **Container runtime** | Docker |
| **Inference runtime** | vLLM (`vllm-openai` image from local registry) |
| **Vision runtime** | Ultralytics YOLO11 |
## Prerequisites
### 1. Verify GPU visibility on host
Before making any changes, verify that the host can see its GPUs:
<img src="../../../.github/assets/images/h200/nvidia-smi.png" alt="NVIDIA SMI">
## Instructions
### 1. Configure host-local VPC networking
Spinifex utilises bridged networking via OVN. In this example, the remote host exposes only one public IP address, attached to a single physical NIC. Before provisioning Spinifex, we must first ensure that `br-wan` exists and enslave the physical NIC to it.
Since we have no access to an upstream router or knowledge of available IP address pools, we must also attach our own `192.168.10.0/24` address range to `br-wan` which will be used by the guest VMs to communicate with the host and each other.
For example:
```yaml
# /etc/netplan/…
bridges:
br-wan:
addresses:
- 192.168.10.1/24 # VM pool gateway — host-local
- 198.51.100.10/24 # existing WAN IP — unchanged
routes:
- to: default
via: 198.51.100.1
```
Then apply with `sudo netplan apply`. VMs, once provisioned, are reachable from the host at
`192.168.10.x`. For internet access through the host's WAN interface:
```bash
sysctl -w net.ipv4.ip_forward=1
iptables -t nat -A POSTROUTING -s 192.168.10.0/24 -o br-wan -j MASQUERADE
```
The exact process for this is described in the [VPC Networking](/docs/vpc-networking#host-local-subnet-no-upstream-router) guide.
### 2. Install Spinifex
Follow the [Single Node Install](/docs/install) guide. This process will install Spinifex and start Spinifex services, however `spinifex.toml` needs to be edited to finalise the changes made to the networking in the previous section, as described in the following section.
### 3. Configure spinifex.toml and restart services
Edit `/etc/spinifex/spinifex.toml` to point the external pool and VPCD at the bridge created in step 1:
```toml
[network]
external_mode = "pool"
[[network.external_pools]]
name = "wan"
source = "static" # required — no upstream DHCP for this range
range_start = "192.168.10.2"
range_end = "192.168.10.100"
gateway = "192.168.10.1" # second address on br-wan
prefix_len = 24
dns_servers = ["8.8.8.8"]
```
Then restart all services:
```bash
sudo systemctl start spinifex.target
sudo systemctl status spinifex.target
```
### 4. Attach Predastore storage
Spinifex distributes the Predastore object storage volume across multiple nodes using Reed–Solomon encoding for data redundancy. This chassis has four NVMe drives — one for the OS, three dedicated to data — so we back each Predastore storage node with its own physical drive, giving us fault-tolerant distributed storage on a single machine — conceptually similar to RAID 5, where data and parity are spread across drives so a single drive failure is recoverable.
Confirm drive assignments with `lsblk` before proceeding, as device names vary between systems.
```bash
lsblk # identify the OS drive and the three data drives
# Stop services so Predastore isn't writing while we relocate its data directories
sudo systemctl stop spinifex.target
# --- Repeat the block below for each data drive (node-1/nvme-1, node-2/nvme-2, node-3/nvme-3) ---
# Mount the physical drive at a stable path
sudo mkdir -p /mnt/nvme-1
sudo mount /dev/nvme1n1 /mnt/nvme-1
sudo mkdir -p /mnt/nvme-1/nodes /mnt/nvme-1/db
# Move Predastore's node data and metadata off the OS drive onto the physical NVMe
sudo mv /var/lib/spinifex/predastore/distributed/nodes/node-1 /mnt/nvme-1/nodes/node-1
sudo mv /var/lib/spinifex/predastore/distributed/db/node-1 /mnt/nvme-1/db/node-1
# Symlink the original paths back so Predastore finds its data unchanged
sudo ln -s /mnt/nvme-1/nodes/node-1 /var/lib/spinifex/predastore/distributed/nodes/node-1
sudo ln -s /mnt/nvme-1/db/node-1 /var/lib/spinifex/predastore/distributed/db/node-1
# Persist the mount across reboots
echo "/dev/nvme1n1 /mnt/nvme-1 auto defaults 0 2" | sudo tee -a /etc/fstab
# --- End of per-drive block ---
sudo systemctl start spinifex.target
```
Verify the nodes are healthy before proceeding:
```bash
export AWS_PROFILE=spinifex
aws s3 ls --endpoint-url https://localhost:8443
# Should return without error (empty bucket list is fine)
```
### 5. Enable GPU Passthrough
Spinifex allows GPUs to be utilised by guest VMS via PCIe-passthrough. This can be enabled via `sudo spx admin gpu setup`. Then, after a reboot, run `sudo spx admin gpu enable`. The Spinifex banner should update to reflect GPU passthrough state after every step:
<p align="center"><img src="../../../.github/assets/images/h200/spinifex-banner1.png" alt="GPU Enabled"></p>
### 6. Import the GPU AMI
The demo uses the standard Spinifex NVIDIA GPU AMI (`ubuntu-26.04-nvidia-gpu-x86_64`), which includes:
- Ubuntu 26.04 LTS guest image
- NVIDIA server driver (DKMS pre-built against the pinned kernel)
- Docker CE + nvidia-container-toolkit (`--gpus` support enabled at boot)
- Python 3 + venv, common utilities (tmux, curl, ffmpeg, etc.)
```bash
spx admin images import --name ubuntu-26.04-nvidia-gpu-x86_64
```
Confirm and set the AMI ID:
```bash
AMI_ID=$(aws ec2 describe-images \
--filters "Name=name,Values=ubuntu-26.04-nvidia-gpu-x86_64" \
--query 'Images[0].ImageId' --output text)
```
### 7. Create VPC resources
```bash
# Create VPC and subnet
VPC_ID=$(aws ec2 create-vpc --cidr-block 192.168.10.0/24 \
--query 'Vpc.VpcId' --output text)
SUBNET_ID=$(aws ec2 create-subnet --vpc-id $VPC_ID \
--cidr-block 192.168.10.0/24 \
--query 'Subnet.SubnetId' --output text)
# Security group — allow SSH + all vLLM/YOLO ports
SG_ID=$(aws ec2 create-security-group \
--group-name demo-sg --description "H200 MIG demo" \
--vpc-id $VPC_ID --query 'GroupId' --output text)
aws ec2 authorize-security-group-ingress --group-id $SG_ID \
--protocol tcp --port 22 --cidr 0.0.0.0/0
aws ec2 authorize-security-group-ingress --group-id $SG_ID \
--protocol tcp --port 8000-8011 --cidr 0.0.0.0/0
```
```bash
# Create SSH key
aws ec2 create-key-pair --key-name spinifex-key \
| jq -r '.KeyMaterial | rtrimstr("\n")' > ~/.ssh/spinifex-key
chmod 600 ~/.ssh/spinifex-key
ssh-keygen -y -f ~/.ssh/spinifex-key > ~/.ssh/spinifex-key.pub
```
Verify:
```bash
aws ec2 describe-key-pairs
```
### 8. Launch the four VMs
Each VM gets a whole H200 via PCIe passthrough. The `p5e.4xlarge` instance type maps one H200 per VM:
```bash
aws ec2 run-instances \
--image-id $AMI_ID \
--instance-type p5e.4xlarge \
--key-name spinifex-key \
--subnet-id $SUBNET_ID \
--security-group-ids $SG_ID \
--count 4
```
Wait until all four reach running state, then take note of the public IPs assigned to each instance:
```bash
aws ec2 describe-instances \
--query 'Reservations[*].Instances[*].[InstanceId,PublicIpAddress,State.Name]' \
--output table
```
### 9. Enable MIG inside each VM
MIG is enabled inside each guest VM, not on the host. SSH into each VM and enable it:
```bash
# Repeat on each VM IP
ssh -i ~/.ssh/spinifex-key ec2-user@<vm-ip>
sudo nvidia-smi -mig 1
nvidia-smi | grep "MIG M."
# Should show: MIG M. Enabled
```
### 10. Create MIG partitions inside each VM
With MIG enabled, we are now able to partition each VM's assigned H200 GPU into several separate GPU instances. This process assigns each GPU slice its own UUID, so each VM goes from seeing one whole GPU to seeing a number of "MIG devices":
<p align="center"><img src="../../../.github/assets/images/h200/mig-activated.png" alt="NVIDIA SMI"></p>
This allows us to run several separate workloads, each assigned to its own GPU instance, on the same physical GPU, and thus utilise more of the overall GPU's resources.
Run `nvidia-smi mig -lgip` to see the available partition types. MIG profiles are named Xg.Ygb, where:
* X is the number of GPU slices (GPU Instances, or GIs) allocated to the partition. On an H200 there are 7 allocatable GPU slices, so the largest profile is 7g.
* Y is the amount of HBM memory allocated to that partition.
Larger profiles also receive proportionally more SMs (Streaming Multiprocessors - analagous to CPU cores), cache, copy engines, encoders/decoders, and other GPU resources.
Thus we partition the GPUs assigned to our VMs as follows:
**vm-llama3b — 7 × 1g.18gb (Llama 3B):**
```bash
sudo nvidia-smi mig -cgi 1g.18gb,1g.18gb,1g.18gb,1g.18gb,1g.18gb,1g.18gb,1g.18gb -C
nvidia-smi -L # Verify 7 MIG devices
```
**vm-qwen32b — 2 × 3g.71gb (Qwen 32B):**
```bash
sudo nvidia-smi mig -cgi 3g.71gb,3g.71gb -C
nvidia-smi -L # Verify 2 MIG devices
```
**vm-llama70b — 1 × 7g.141gb (Llama 70B, full GPU):**
```bash
sudo nvidia-smi mig -cgi 7g.141gb -C
nvidia-smi -L # Verify 1 MIG device
```
**vm-yolo — 2 × 3g.71gb (YOLO):**
```bash
sudo nvidia-smi mig -cgi 3g.71gb,3g.71gb -C
nvidia-smi -L # Verify 2 MIG devices
```
Each MIG device gets a UUID of the form `MIG-xxxxxxxx-...`. These UUIDs are used to pin individual containers to their slice via `--gpus "device=<UUID>"` (for Docker) or `CUDA_VISIBLE_DEVICES=<UUID>` (for direct Python processes).
### 11. Configure Docker to use the local image registry
The vLLM image is served from the host's local Docker registry (`192.168.10.1:5000`).
Setting up the local registry on the host:
```bash
# Configure Docker to trust the local registry address
sudo tee /etc/docker/daemon.json > /dev/null <<'EOF'
{
"insecure-registries": ["192.168.10.1:5000"]
}
EOF
sudo systemctl restart docker
# Start the registry container, bound to the bridge interface only
docker run -d \
-p 192.168.10.1:5000:5000 \
--name registry \
--restart=always \
registry:2
# Pull the vLLM image and push it into the local registry
docker pull vllm/vllm-openai:latest
docker tag vllm/vllm-openai:latest 192.168.10.1:5000/vllm-openai:latest
docker push 192.168.10.1:5000/vllm-openai:latest
# Verify the image is available
curl http://192.168.10.1:5000/v2/_catalog
# {"repositories":["vllm-openai"]}
```
Each VM then needs to trust it as an insecure registry:
```bash
# On each VM — vm-yolo uses direct Python, not Docker for YOLO
sudo tee /etc/docker/daemon.json > /dev/null <<'EOF'
{
"insecure-registries": ["192.168.10.1:5000"]
}
EOF
sudo systemctl restart docker
```
### 12. Deploy the LLM workloads
Each LLM is downloaded using the Huggingface CLI onto the respective VM. First install the CLI:
```bash
pip install -U "huggingface_hub[cli]"
```
Then download the model and determine its snapshot directory. This directory contains the model’s config.json, tokenizer files, and weight files, and will be used as MODEL_DIR when launching vLLM.
```bash
#On vm-llama3b
hf download meta-llama/Llama-3.2-3B-Instruct
MODEL_DIR=$(dirname "$(find ~/.cache/huggingface/hub -path '*Llama-3.2-3B-Instruct*' -name config.json | head -1)")
echo "$MODEL_DIR"
#On vm-qwen32b
hf download Qwen/Qwen2.5-32B-Instruct
MODEL_DIR=$(dirname "$(find ~/.cache/huggingface/hub -path '*Qwen2.5-32B-Instruct*' -name config.json | head -1)")
echo "$MODEL_DIR"
#On vm-llama70b
hf download RedHatAI/Meta-Llama-3.1-70B-Instruct-FP8
MODEL_DIR=$(dirname "$(find ~/.cache/huggingface/hub -path '*Meta-Llama-3.1-70B-Instruct-FP8*' -name config.json | head -1)")
echo "$MODEL_DIR"
```
Then we run the docker containers as follows:
**vm-llama3b — 7 × Llama-3.2-3B-Instruct (one container per MIG slice):**
Enumerate the MIG UUIDs and start one vLLM container per slice:
```bash
mapfile -t UUIDS < <(nvidia-smi -L | grep -oP 'MIG-[0-9a-f-]+')
for i in "${!UUIDS[@]}"; do
docker run -d --rm \
--name "vllm-$i" \
--gpus "device=${UUIDS[$i]}" \
--ipc host \
-p $((8000 + i)):8000 \
-v "${MODEL_DIR}:/models:ro" \
192.168.10.1:5000/vllm-openai:latest \
vllm serve /models \
--served-model-name llama-3b \
--dtype bfloat16 \
--max-model-len 4096 \
--gpu-memory-utilization 0.90 \
--port 8000
done
```
**vm-qwen32b — 2 × Qwen2.5-32B-Instruct:**
```bash
mapfile -t UUIDS < <(nvidia-smi -L | grep -oP 'MIG-[0-9a-f-]+')
for i in "${!UUIDS[@]}"; do
docker run -d --rm \
--name "qwen32b-$i" \
--gpus "device=${UUIDS[$i]}" \
--ipc host \
-p $((8000 + i)):8000 \
-v "${MODEL_DIR}:/models:ro" \
192.168.10.1:5000/vllm-openai:latest \
vllm serve /models \
--served-model-name qwen2.5-32b \
--dtype bfloat16 \
--max-model-len 4096 \
--gpu-memory-utilization 0.90 \
--port 8000
done
```
**vm-llama70b — Llama-3.1-70B-Instruct-FP8 (full GPU slice):**
```bash
UUID=$(nvidia-smi -L | grep -oP 'MIG-[0-9a-f-]+' | head -1)
docker run -d --rm \
--name vllm \
--gpus "device=${UUID}" \
--ipc host \
-p 8000:8000 \
-v "${MODEL_DIR}:/models:ro" \
192.168.10.1:5000/vllm-openai:latest \
vllm serve /models \
--served-model-name meta-llama-3.1-70b \
--dtype auto \
--max-model-len 8192 \
--gpu-memory-utilization 0.90 \
--port 8000
```
### 13. Deploy YOLO object detection (vm-yolo)
For this demo we built a simple YOLO inference server that runs detection on a looping video file and serves the annotated output as an MJPEG stream. The same script runs twice on vm-yolo — once for YOLO11x (larger, more accurate) and once for YOLO11s (smaller, faster) — each pinned to its own MIG slice and listening on a different port.
The MIG pinning works through `CUDA_VISIBLE_DEVICES`: setting it to a MIG UUID before launch scopes the process to that slice, and CUDA remaps it to device index 0 inside the process. From the script's perspective it always sees one GPU at index 0 — the MIG boundary is invisible to it. `YOLO_MODEL` and `PORT` are what actually differentiate the two instances:
```bash
python3 -m venv ~/yolo-venv
~/yolo-venv/bin/pip install ultralytics fastapi "uvicorn[standard]" opencv-python-headless httpx
# Get MIG UUIDs
nvidia-smi -L
# GPU 0: NVIDIA H200 (UUID: GPU-...)
# MIG 3g.71gb Device 0: (UUID: MIG-<uuid0>)
# MIG 3g.71gb Device 1: (UUID: MIG-<uuid1>)
# YOLO11x on slice 0 — larger model, port 8010
CUDA_VISIBLE_DEVICES=MIG-<uuid0> \
VIDEO_PATH=<path-to-video> \
YOLO_MODEL=yolo11x.pt \
YOLO_DEVICE=0 \
PORT=8010 \
~/yolo-venv/bin/python ~/yolo_stream.py >> ~/yolo-x.log 2>&1 &
# YOLO11s on slice 1 — smaller/faster model, port 8011
CUDA_VISIBLE_DEVICES=MIG-<uuid1> \
VIDEO_PATH=<path-to-video> \
YOLO_MODEL=yolo11s.pt \
YOLO_DEVICE=0 \
PORT=8011 \
~/yolo-venv/bin/python ~/yolo_stream.py >> ~/yolo-s.log 2>&1 &
```
Each instance exposes a `/video` endpoint serving a `multipart/x-mixed-replace` MJPEG stream, consumable directly by browsers and most HTTP clients. YOLO11x (~75 MB) and YOLO11s (~9 MB) weights download automatically on first run from the Ultralytics model hub.
### 14. Dashboard
We also built a simple host-side dashboard — another FastAPI application that proxies all the VM streams to the browser so only one port on the host needs to be exposed. Each VM endpoint is wired in by address at startup, mapping directly to the IPs assigned in step 8:
```
http://vm-yolo-IP:8010 → vm-yolo YOLO11x MJPEG stream
http://vm-yolo-IP:8011 → vm-yolo YOLO11s MJPEG stream
http://vm-llama3b-IP:8000 → vm-llama3b vLLM endpoint 0
http://vm-llama3b-IP:8001 → vm-llama3b vLLM endpoint 1
... (one entry per endpoint across all three LLM VMs)
```
The dashboard has two proxy patterns: MJPEG passthrough for the YOLO feeds (forwarding the raw boundary stream from vm-yolo to the browser), and SSE passthrough for the LLM token streams (subscribing to each vLLM endpoint and re-emitting tokens as server-sent events). Both use a reconnect loop so the browser connection stays open if a VM is temporarily unreachable.
<p><video src="https://iso.mulgadc.com/h200-demo.mp4" controls width="100%" style="border-radius:6px"></video></p>
The dashboard shows:
- GPU allocation bars for all four EC2 instancess (proportional to MIG slice size)
- Live streaming LLM responses per endpoint, colour-coded by tier
- Side-by-side YOLO11x vs YOLO11s video feeds with FPS and detection counts
The dashboard also displays the overall GPU utilisation, derived as a percentage of maximum power usage. Importantly, it demonstrates how Spinifex combined with NVIDIA's MIG capability enables the deployment of multiple heterogeneous workloads on owned hardware.
### 15. Teardown
```bash
# On vm-yolo:
# Stop YOLO processes
pkill -f yolo_stream.py
# On each LLM instance:
# Stop docker containers
docker stop $(docker ps -q)
# Disable MIG inside each VM before terminating.
sudo nvidia-smi mig -dci
sudo nvidia-smi mig -dgi
sudo nvidia-smi -mig 0
# On the host:
# Terminate all four instances — releases 4× H200 back to the Spinifex pool
aws ec2 terminate-instances --instance-ids \
$(aws ec2 describe-instances \
--filters "Name=instance-state-name,Values=running" \
--query 'Reservations[*].Instances[*].InstanceId' \
--output text)
```
The four H200s are immediately returned to the host GPU pool once the instances terminate, ready for reallocation.
### 16. Conclusion
This document highlights how Spinifex can turn a single bare-metal chassis into a multi-tenant AI serving platform. Spinifex utilises the flexibility of PCIe passthrough combined with NVIDIA's MIG capability to allocate GPU resources in whichever configuration is required by the workload/s. This flexibility and fine-grain control ensures maximum GPU utilisation.
Importantly, it does so with standard `aws ec2` CLI calls — `run-instances`, `describe-instances`, `terminate-instances` — against Spinifex's EC2-compatible endpoint. Teams already operating AWS infrastructure can point their existing tooling at a Spinifex node with a single profile change, against GPUs that sit in their own rack.
@@ -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.
+38
View File
@@ -30,10 +30,12 @@ services:
publishes:
- "ec2.*"
- "elbv2.*"
- "acm.*"
- "iam.*"
depends_on:
- "ec2.*"
- "elbv2.*"
- "acm.*"
- "iam.*"
spinifex_daemon:
@@ -50,6 +52,7 @@ services:
- spinifex/daemon
- spinifex/handlers/ec2
- spinifex/handlers/elbv2
- spinifex/handlers/acm
- spinifex/handlers/iam
subscribes:
# EC2 instance lifecycle.
@@ -81,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"
@@ -95,6 +104,15 @@ services:
- "elbv2.CreateListener"
- "elbv2.DeleteListener"
- "elbv2.DescribeListeners"
# ACM-alike certificate store (handlers/acm/service_nats.go topic map).
- "acm.ImportCertificate"
- "acm.DescribeCertificate"
- "acm.ListCertificates"
- "acm.DeleteCertificate"
- "elbv2.AddListenerCertificates"
- "elbv2.RemoveListenerCertificates"
- "elbv2.DescribeListenerCertificates"
- "elbv2.DescribeSSLPolicies"
publishes:
# → vpcd
- "vpc.create"
@@ -121,6 +139,8 @@ services:
- "ebs.delete"
- "ebs.sync"
- "ebs.snapshot.*"
- "ebs.config"
- "ebs.config.*"
# → predastore (S3 / AMI / keypair store)
- "s3.*"
# IAM lifecycle event
@@ -169,6 +189,8 @@ services:
- "ebs.delete"
- "ebs.sync"
- "ebs.snapshot.*"
- "ebs.config"
- "ebs.config.*"
publishes:
- "ebs.mount.response"
- "ebs.unmount.response"
@@ -262,6 +284,22 @@ suites:
# into per-test fixture-driven coverage so the selector can pick
# subtests, not just whole suites.
e2e-iam:
path: tests/e2e/iam
covers: [awsgw, spinifex_daemon, vpcd, viperblockd, predastore]
# "Everything IAM": the IAM/STS control plane plus the instance-identity
# tests (instance-profile association + IMDS), which boot guest VMs — OVN
# via vpcd, AMI via predastore, root volume via viperblockd, lifecycle via
# spinifex_daemon. Single-mode, VM-booting leg.
e2e-ecr:
path: tests/e2e/ecr
covers: [awsgw, spinifex_daemon, predastore]
# ECR control plane (handlers/ecr, gateway/ecr*) + OCI data plane: blobs and
# manifests in predastore, control + auth bridge in awsgw, KV via
# spinifex_daemon. docker/crane/skopeo data-plane subtests skip when the
# client binary or registry-host resolution is absent.
e2e-ddil:
path: tests/e2e/ddil
covers: [awsgw, spinifex_daemon, vpcd, viperblockd, predastore]
@@ -0,0 +1,4 @@
README.md
*.tfstate
*.tfstate.backup
.terraform
@@ -0,0 +1,17 @@
# Spinifex EKS demo app — multi-stage build to a tiny distroless image.
#
# Stage 1 compiles a static binary; stage 2 ships only that binary on a
# distroless base (no shell, no package manager) for a small, production-shaped
# image suitable for pushing to ECR and pulling onto EKS workers.
FROM golang:1.23-alpine AS build
WORKDIR /src
COPY go.mod ./
COPY main.go ./
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/spinifex-demo .
FROM gcr.io/distroless/static:nonroot
COPY --from=build /out/spinifex-demo /spinifex-demo
EXPOSE 8080
USER nonroot:nonroot
ENTRYPOINT ["/spinifex-demo"]
@@ -0,0 +1,69 @@
# Spinifex EKS demo app
A small, Spinifex-themed web app shared by the EKS Terraform workbooks. It
replaces `nginxdemos/hello` with something production-shaped that you build
yourself and push to **ECR**, then pull onto your EKS workers.
The page reports the pod, node, cluster, and region that answered the request,
plus a hit counter — refresh and watch requests land on different pods across a
multi-node cluster.
## What it does
- `GET /` — themed HTML page.
- `GET /api/info` — JSON of the same facts.
- `GET /healthz` — liveness/readiness probe (`200 ok`), used by the ALB target
group health check.
All configuration is read from the environment, so one image serves every tier:
| Variable | Purpose | Source |
|---|---|---|
| `POD_NAME`, `NODE_NAME`, `POD_NAMESPACE` | identity shown on the page | downward API |
| `CLUSTER_NAME`, `AWS_REGION` | cluster/region labels | deployment env |
| `APP_TITLE` | page heading | deployment env (default `Spinifex EKS`) |
| `PORT` | listen port | default `8080` |
| `DATA_DIR` | when set, the hit counter is persisted here and survives pod restarts | mounted **EBS-CSI PVC** in `eks-gitops-argocd` |
When `DATA_DIR` points at a writable volume the page shows a **"persisted to EBS
volume"** badge — the gitops workbook mounts a Viperblock-backed PersistentVolume
there to demonstrate durable storage.
## Build and push to ECR
The image is named `spinifex-demo`. Build it once and push it to a repository in
your Spinifex ECR; every workbook references it by its ECR URI.
```bash
export AWS_PROFILE=spinifex
REGION=ap-southeast-2
# 1. Create the repository (the workbooks also create it via Terraform; skip if
# it already exists).
aws ecr create-repository --repository-name spinifex-demo --region "$REGION"
# 2. Resolve the registry host and authenticate Docker.
REGISTRY=$(aws ecr describe-repositories --repository-names spinifex-demo \
--region "$REGION" --query 'repositories[0].repositoryUri' --output text)
REGISTRY_HOST=${REGISTRY%%/*}
aws ecr get-login-password --region "$REGION" \
| docker login --username AWS --password-stdin "$REGISTRY_HOST"
# 3. Build, tag, and push.
docker build -t spinifex-demo:latest .
docker tag spinifex-demo:latest "${REGISTRY}:latest"
docker push "${REGISTRY}:latest"
echo "Image: ${REGISTRY}:latest"
```
Pass the resulting `${REGISTRY}:latest` value to a workbook as the `demo_image`
variable (or accept its default, which resolves the URI from the ECR repository
the workbook creates).
## Run locally
```bash
go run . # http://localhost:8080
DATA_DIR=/tmp/demo go run . # exercise the persisted-counter path
```
+3
View File
@@ -0,0 +1,3 @@
module github.com/mulgadc/spinifex-demo
go 1.23
+214
View File
@@ -0,0 +1,214 @@
// Command spinifex-demo is the demo web app served by the EKS Terraform
// workbooks. It renders a Spinifex-themed page reporting the pod, node, cluster,
// and region that answered the request, plus a hit counter.
//
// The counter is held in memory by default. When DATA_DIR points at a writable
// directory (an EBS-CSI PersistentVolume in the gitops workbook), the counter is
// persisted there and survives pod restarts — demonstrating Viperblock-backed
// durable storage. All configuration comes from the environment so a single
// image serves every workbook tier.
package main
import (
"encoding/json"
"html/template"
"log/slog"
"net/http"
"os"
"path/filepath"
"strconv"
"sync"
"time"
)
// instance captures the per-pod facts surfaced on the page, populated from the
// downward API and the deployment environment.
type instance struct {
Pod string
Node string
Namespace string
Cluster string
Region string
Title string
Persisted bool
}
func env(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
// counter tracks page hits, optionally persisting to dir/hits so the count
// survives a pod restart when backed by a PersistentVolume.
type counter struct {
mu sync.Mutex
n int64
path string
}
func newCounter(dir string) *counter {
c := &counter{}
if dir == "" {
return c
}
c.path = filepath.Join(dir, "hits")
if b, err := os.ReadFile(c.path); err == nil {
if n, perr := strconv.ParseInt(string(b), 10, 64); perr == nil {
c.n = n
}
}
return c
}
func (c *counter) inc() int64 {
c.mu.Lock()
defer c.mu.Unlock()
c.n++
if c.path != "" {
// Best-effort persist; a read-only or missing volume must not 500 the page.
if err := os.WriteFile(c.path, []byte(strconv.FormatInt(c.n, 10)), 0o644); err != nil {
slog.Warn("persist counter failed", "path", c.path, "err", err)
}
}
return c.n
}
func main() {
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
slog.SetDefault(logger)
dataDir := os.Getenv("DATA_DIR")
hits := newCounter(dataDir)
base := instance{
Pod: env("POD_NAME", "unknown"),
Node: env("NODE_NAME", "unknown"),
Namespace: env("POD_NAMESPACE", "default"),
Cluster: env("CLUSTER_NAME", "spinifex-eks"),
Region: env("AWS_REGION", env("AWS_DEFAULT_REGION", "ap-southeast-2")),
Title: env("APP_TITLE", "Spinifex EKS"),
Persisted: dataDir != "",
}
tmpl := template.Must(template.New("page").Parse(pageHTML))
mux := http.NewServeMux()
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
})
mux.HandleFunc("/api/info", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"pod": base.Pod,
"node": base.Node,
"namespace": base.Namespace,
"cluster": base.Cluster,
"region": base.Region,
"hits": hits.inc(),
"persisted": base.Persisted,
})
})
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" {
http.NotFound(w, r)
return
}
view := struct {
instance
Hits int64
Now string
}{base, hits.inc(), time.Now().UTC().Format("2006-01-02 15:04:05 MST")}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := tmpl.Execute(w, view); err != nil {
slog.Error("render failed", "err", err)
}
})
addr := ":" + env("PORT", "8080")
slog.Info("spinifex-demo listening", "addr", addr, "cluster", base.Cluster, "persisted", base.Persisted)
srv := &http.Server{
Addr: addr,
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
}
if err := srv.ListenAndServe(); err != nil {
slog.Error("server exited", "err", err)
os.Exit(1)
}
}
const pageHTML = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Title}}</title>
<style>
:root {
--bg: #0b0e14; --panel: #131823; --line: #232a39;
--fg: #e6edf3; --muted: #8b97a7; --accent: #f5b301; --accent2: #3ddc97;
}
* { box-sizing: border-box; }
body {
margin: 0; min-height: 100vh; background:
radial-gradient(1200px 600px at 80% -10%, rgba(245,179,1,.10), transparent 60%),
radial-gradient(900px 500px at -10% 110%, rgba(61,220,151,.08), transparent 55%), var(--bg);
color: var(--fg); font: 15px/1.55 ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
display: flex; align-items: center; justify-content: center; padding: 32px;
}
.card {
width: 100%; max-width: 640px; background: var(--panel);
border: 1px solid var(--line); border-radius: 16px; overflow: hidden;
box-shadow: 0 20px 60px rgba(0,0,0,.45);
}
.head { padding: 28px 32px; border-bottom: 1px solid var(--line); display: flex; align-items: center; gap: 16px; }
.logo { height: 44px; width: auto; flex: 0 0 auto; }
.head h1 { margin: 0; font-size: 20px; letter-spacing: .2px; }
.head p { margin: 2px 0 0; color: var(--muted); font-size: 13px; }
.body { padding: 24px 32px 8px; }
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px 20px; }
.kv { padding: 12px 14px; background: #0e131d; border: 1px solid var(--line); border-radius: 10px; }
.kv .k { color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: .08em; }
.kv .v { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 14px; word-break: break-all; margin-top: 3px; }
.hits { grid-column: 1 / -1; display: flex; align-items: baseline; justify-content: space-between; }
.hits .n { font-size: 30px; font-weight: 700; color: var(--accent); font-family: ui-monospace, monospace; }
.foot { padding: 16px 32px 24px; color: var(--muted); font-size: 12px; display: flex; justify-content: space-between; gap: 12px; }
.pill { color: var(--accent2); border: 1px solid rgba(61,220,151,.35); border-radius: 999px; padding: 2px 10px; font-size: 11px; }
</style>
</head>
<body>
<div class="card">
<div class="head">
<svg class="logo" viewBox="0 0 364 463" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M272.523 171.205C247.773 197.595 211.323 215.815 182.103 237.425C165.663 249.575 122.633 281.165 151.093 301.825C185.533 326.835 271.333 304.985 301.013 277.585C317.193 262.645 316.693 242.485 294.703 234.075C270.913 224.975 243.483 231.925 219.353 235.755C218.543 235.885 216.823 236.805 216.993 235.345C227.053 228.885 236.103 220.925 245.933 214.135C255.403 207.585 267.053 198.225 278.643 198.015C314.693 197.345 351.803 215.135 361.203 252.215C376.833 313.885 303.333 362.255 249.833 366.635L280.913 352.235C292.563 345.305 304.353 338.105 314.653 329.215C317.213 327.005 319.073 324.145 321.243 322.035C321.753 321.535 327.053 319.265 324.003 319.125C296.253 337.335 263.793 349.415 231.323 356.335C220.173 358.715 208.323 360.995 196.963 361.285C197.243 362.845 198.643 362.525 199.663 362.845C208.823 365.795 219.313 367.545 228.843 369.005C230.163 370.005 228.733 370.415 227.683 370.555C219.063 371.665 209.923 373.305 201.293 373.745C176.843 374.985 152.203 372.435 128.573 366.425L115.293 328.755C114.153 328.865 114.073 329.895 113.783 330.725C112.613 334.015 107.893 358.025 106.313 358.365C93.6234 354.515 82.2334 345.915 72.9034 336.575L95.0434 296.915L59.7134 316.375C54.7434 305.145 53.8334 292.435 55.4934 280.355L101.213 257.645C86.9934 256.705 72.6134 258.955 58.4734 260.425C60.8734 253.495 63.8034 246.615 67.3634 240.195C68.6634 237.855 75.5634 226.305 77.3834 226.245C93.9334 229.415 111.773 230.895 128.363 227.675L92.3534 212.825L91.0334 211.045C98.3834 204.125 105.843 197.235 114.113 191.325C129.603 195.825 145.523 199.455 161.733 199.655L130.633 179.885C134.133 177.125 137.813 174.575 141.553 172.165C144.643 170.175 153.283 164.425 156.423 164.835C168.933 169.105 181.923 172.125 195.123 173.185C189.093 166.935 177.633 162.325 171.833 156.465C171.203 155.825 170.713 155.785 171.003 154.585C186.153 145.885 203.003 137.235 216.053 125.375C229.363 113.265 243.423 93.4848 233.853 75.2048C244.893 78.7148 254.393 92.6648 254.153 104.325C253.493 136.595 219.073 165.375 197.483 185.615C230.833 171.615 268.993 142.685 268.923 102.735C268.863 70.2248 239.263 57.1948 211.283 54.2348C210.103 61.3848 207.573 68.9448 203.863 75.1748C195.623 89.0148 178.803 97.6648 170.013 112.055C164.023 121.865 162.883 135.065 150.163 138.825C144.973 140.365 138.293 140.595 132.903 140.615L144.743 134.365C158.003 124.625 157.773 107.025 165.893 93.9248C164.953 92.5948 155.413 90.0748 153.183 89.5248C143.363 87.1048 126.713 84.0248 116.883 84.6948C113.843 84.9048 100.143 87.0348 98.9634 89.3748C97.2634 92.7648 105.533 107.255 107.973 110.575C108.463 111.245 109.463 111.075 109.513 111.145C109.973 111.745 109.663 113.165 108.373 112.675C107.443 112.315 101.853 108.205 100.593 107.235C90.8934 99.7648 79.8234 88.4748 71.7234 79.3648C70.4034 76.1748 68.6334 72.6148 71.7634 69.9848C88.8634 59.4048 105.793 48.3148 123.163 38.1948C174.603 8.20482 233.143 -25.6952 282.353 29.2448C323.153 74.8048 311.503 129.615 272.493 171.195L272.523 171.205ZM114.153 67.5948C127.613 65.2648 153.013 78.6248 157.193 58.8348C157.603 56.9048 156.493 55.7948 157.583 53.9148C158.403 52.5048 163.083 49.1048 164.713 47.7848C169.903 43.6048 175.823 40.3348 180.923 36.0148C161.113 39.2048 141.163 50.5648 123.853 60.5948C120.983 62.2548 117.873 63.9948 115.153 65.8548C114.453 66.3348 113.713 65.7348 114.143 67.5848L114.153 67.5948ZM21.1634 276.415C-11.8666 312.635 -4.35661 364.395 30.6734 396.135C65.5434 427.725 120.403 438.925 166.373 436.725C207.813 434.735 249.893 421.075 290.773 434.965C306.663 440.365 320.533 450.465 332.363 462.135C330.773 454.415 326.123 446.365 321.623 439.825C280.593 380.225 200.893 402.565 140.123 390.665C107.163 384.215 72.8934 369.135 52.8234 341.225C35.4734 317.105 32.7734 288.665 41.3634 260.475C39.6134 258.725 23.0334 274.355 21.1634 276.415ZM143.743 109.535C136.373 116.045 128.573 122.235 120.403 127.775L97.1634 139.135C116.843 139.885 137.193 128.635 143.753 109.545L143.743 109.535Z" fill="#f5b301"/>
</svg>
<div>
<h1>{{.Title}}</h1>
<p>Served by a pod on your Spinifex-managed Kubernetes cluster</p>
</div>
</div>
<div class="body">
<div class="grid">
<div class="kv"><div class="k">Pod</div><div class="v">{{.Pod}}</div></div>
<div class="kv"><div class="k">Node</div><div class="v">{{.Node}}</div></div>
<div class="kv"><div class="k">Cluster</div><div class="v">{{.Cluster}}</div></div>
<div class="kv"><div class="k">Region</div><div class="v">{{.Region}}</div></div>
<div class="kv hits">
<div><div class="k">Requests served</div>{{if .Persisted}}<span class="pill">persisted to EBS volume</span>{{end}}</div>
<div class="n">{{.Hits}}</div>
</div>
</div>
</div>
<div class="foot">
<span>Namespace: {{.Namespace}}</span>
<span>{{.Now}}</span>
</div>
</div>
</body>
</html>`
@@ -0,0 +1,284 @@
---
title: "GitOps on EKS (Argo CD + EBS-CSI)"
description: "Deliver a Spinifex-themed app to EKS with GitOps — the Argo CD addon syncs it from a git repo, an EBS-CSI (Viperblock) PersistentVolume holds its state, and it is served over HTTPS through the AWS Load Balancer Controller + ACM, using Terraform on Spinifex."
category: "Terraform Workbooks"
tags:
- terraform
- eks
- kubernetes
- argocd
- gitops
- ebs-csi
- storage
- workbook
resources:
- title: "Argo CD"
url: "https://argo-cd.readthedocs.io/"
- title: "AWS Load Balancer Controller"
url: "https://kubernetes-sigs.github.io/aws-load-balancer-controller/latest/"
- 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"
---
# Terraform: GitOps on EKS (Argo CD + EBS-CSI)
> The top rung of the EKS ladder. It extends [EKS HTTPS Ingress](../eks-https-ingress) and changes how the app is delivered: the **Argo CD** addon syncs a more elaborate Spinifex-themed app from a **git repo**, an **EBS-CSI** PersistentVolume (Viperblock-backed) holds its state, and it is still served over **HTTPS** through the AWS Load Balancer Controller + ACM.
## Table of Contents
- [Overview](#overview)
- [Instructions](#instructions)
- [Troubleshooting](#troubleshooting)
---
## Overview
This workbook keeps everything from [EKS HTTPS Ingress](../eks-https-ingress) — the public/private VPC, the NAT gateway, the LBC addon, the ACM cert, the HTTPS Ingress — and adds the parts you reach for once an app is real: **GitOps delivery** and **persistent storage**.
Three changes over the previous rung:
1. **Argo CD delivers the app.** Instead of Terraform applying the Deployment, the **argocd** addon syncs the app from a git repository ([`mulgadc/eks-demo-app`](https://github.com/mulgadc/eks-demo-app)). Terraform only registers the repo credential and creates the Argo CD `Application`; Argo CD reconciles the manifests and self-heals drift.
2. **State lives on an EBS volume.** The **aws-ebs-csi-driver** addon ships a default gp3 StorageClass. The app's `PersistentVolumeClaim` (in the git repo) dynamically provisions a **Viperblock-backed EBS volume**; the demo's hit counter persists to it and survives pod restarts.
3. **Access-entry RBAC.** A second IAM principal is granted read-only cluster access via an access entry bound to `AmazonEKSViewPolicy`.
Terraform manages the cluster, the addons, the ACM cert, and the HTTPS Ingress; **Argo CD** manages the app's Deployment, Service, and PVC from git. The Ingress points at the Service Argo CD creates, so the app is reachable over HTTPS the moment Argo CD finishes its first sync.
**What you'll learn:**
- Installing the `argocd` and `aws-ebs-csi-driver` addons through the EKS API
- Registering a private git repo with Argo CD and driving an `Application` from Terraform
- Dynamically provisioning a Viperblock-backed EBS volume with a `PersistentVolumeClaim`
- Splitting ownership: Terraform owns infra + Ingress, Argo CD owns the workload
- Granting scoped read-only access with an EKS access entry
**What gets created**
| Resource | Name | Purpose |
|---|---|---|
| VPC + subnets | `eks-gitops-*` | Public/private network (10.32.0.0/16) with a NAT gateway |
| IAM Roles | `eks-gitops-cluster-role`, `-node-role`, `-viewer` | Control-plane, worker, and read-only viewer roles |
| ECR Repository | `spinifex-demo` | Holds the demo image the workers pull |
| EKS Cluster | `eks-gitops` | Public + private endpoints; `managed-ingress=false` |
| Node Group | `workers` | `node_desired_size` `t3.large` worker(s) — 1 or 3 |
| Addons | `aws-load-balancer-controller`, `argocd`, `aws-ebs-csi-driver` | Ingress, GitOps delivery, persistent storage |
| ACM Certificate | `eks-gitops-ingress` | Self-signed, imported; on the HTTPS listener |
| Access Entry + Assoc. | `eks-gitops-viewer` → `AmazonEKSViewPolicy` | Read-only, cluster scope |
| Argo CD repo Secret | `eks-demo-app-repo` | Credential for the private git repo |
| Argo CD Application | `spinifex-demo` | Syncs the app from git |
| K8s Ingress | `spinifex-demo` | `ingressClassName: alb`, HTTPS via the ACM cert |
| Git-managed (by Argo CD) | Deployment, Service, **PVC** | The app + its Viperblock-backed volume |
**Spinifex specifics**
- The `argocd` and `aws-ebs-csi-driver` bundles must be baked into the `eks-node` AMI — `describe-addon` returns `no baked bundle` otherwise.
- The EBS-CSI default StorageClass uses `provisioner: ebs.csi.aws.com`; a PVC against it provisions a **Viperblock-backed EBS volume**.
- **Verify dynamic provisioning on a live cluster.** EBS-CSI dynamic provisioning depends on the running Spinifex EBS/Viperblock data plane and may not be available on every build yet. If the PVC stays `Pending`, the app still serves (the counter falls back to in-memory) — confirm the volume binds on a stack where the driver is healthy.
- Leave addon versions unset (the AWS provider and the catalog disagree on the version-string format).
- The Argo CD `Application` is a `kubernetes_manifest`, so the `argoproj.io` CRDs must exist before `apply` — apply the parent and let the addon reach `ACTIVE` first.
**Prerequisites:**
- Spinifex running with an `eks-node` image carrying the LBC, Argo CD, and EBS-CSI bundles
- OpenTofu or Terraform, plus `kubectl`, the AWS CLI, and Docker
- The demo image built and pushed to ECR (see [`../demo-app/README.md`](../demo-app/README.md))
- A git repo with the app manifests ([`mulgadc/eks-demo-app`](https://github.com/mulgadc/eks-demo-app)); a read-only PAT if it is private
## 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-gitops-argocd
```
<!-- INCLUDE: main.tf lang:hcl -->
### Step 2. Deploy the Cluster + Addons
```bash
export AWS_PROFILE=spinifex
tofu init
tofu apply # add -var node_desired_size=3 for an HA-shaped cluster
```
Wait for the cluster to reach `ACTIVE` and for all three addons (`aws-load-balancer-controller`, `argocd`, `aws-ebs-csi-driver`) to report healthy:
```bash
aws eks list-addons --cluster-name eks-gitops
aws eks describe-addon --cluster-name eks-gitops --addon-name argocd --query 'addon.status'
```
### Step 3. Build and Push the Demo Image
```bash
cd ../demo-app
REGISTRY=$(cd ../eks-gitops-argocd && tofu output -raw ecr_repository_url)
REGISTRY_HOST=${REGISTRY%%/*}
aws ecr get-login-password | docker login --username AWS --password-stdin "$REGISTRY_HOST"
docker build -t "${REGISTRY}:latest" .
docker push "${REGISTRY}:latest"
cd ../eks-gitops-argocd
```
Point the app's manifests at this image: set the image ref in `eks-demo-app/manifests` (a kustomize `images:` override) to your `${REGISTRY}:latest`, and push that to the git repo.
### Step 4. Hand Delivery to Argo CD
```bash
cd workloads
tofu init
tofu apply \
-var git_repo_url=https://github.com/mulgadc/eks-demo-app.git \
-var git_token=<read-only-PAT> # omit for a public repo
```
This registers the repo credential, creates the Argo CD `Application`, the demo app's HTTPS Ingress, and an HTTPS Ingress for the Argo CD UI itself. Argo CD then syncs the Deployment, Service, and PVC from git.
<!-- INCLUDE: workloads/main.tf lang:hcl -->
### Step 5. Watch the Sync and Open the App
The demo app and the Argo CD UI share **one ALB** (an LBC IngressGroup). On
`:443` the app is the catch-all and `argocd.eks-gitops.spinifex.local`
host-routes to the UI; Argo CD also gets a hostless `:8443` listener so it is
reachable on the raw ALB IP before DNS exists. Grab the shared ALB address once:
```bash
# Point kubectl at the cluster first (writes ~/.kube/config) — without this every
# kubectl call fails to connect:
aws eks update-kubeconfig --name eks-gitops --region ap-southeast-2
kubectl -n argocd get applications
kubectl get pods -o wide
kubectl get pvc # the EBS-CSI volume should be Bound
DNSNAME=$(kubectl get ingress spinifex-demo -o jsonpath='{.status.loadBalancer.ingress[0].hostname}')
ALB_IP=$(aws elbv2 describe-load-balancers \
--query "LoadBalancers[?DNSName=='${DNSNAME}'].AvailabilityZones[].LoadBalancerAddresses[].IpAddress | [0]" \
--output text)
echo "$ALB_IP"
```
**Reach it by raw IP (no DNS, no host header):**
| Service | URL |
|---|---|
| Demo app | `https://<ALB_IP>/` |
| Argo CD UI | `https://<ALB_IP>:8443/` |
```bash
curl -k https://"$ALB_IP"/ # app — returns the Spinifex page
curl -k https://"$ALB_IP":8443/ # Argo CD UI
```
The bare ALB IP serves the **app** on `:443`. Hitting `https://<ALB_IP>/` and
expecting Argo CD gives the app instead — use `:8443` for the UI. Once northstar
(or Route 53) resolves the hostnames to the ALB, `app.eks-gitops.spinifex.local`
and `argocd.eks-gitops.spinifex.local` both work on `:443`. Self-signed cert —
accept the browser warning.
The page shows the **"persisted to EBS volume"** badge and a hit counter that keeps climbing — delete the pod (`kubectl delete pod -l app=spinifex-demo`) and the count survives, proving the volume is durable.
### Step 6. Open the Argo CD UI
Managing deployments through the Argo CD console is the point of this workbook, so
the UI is exposed on the **same ALB** as the app, not behind a port-forward. The
`workloads` module adds a `NodePort` Service in front of `argocd-server` (the addon
ships it `ClusterIP` only) and two Ingresses in the shared group — a host-routed
one on `:443` and a hostless one on `:8443` for raw-IP access. `argocd-server`
serves TLS on its own port, so both use `backend-protocol: HTTPS`.
**Get the admin credentials.** Argo CD generates a one-time `admin` password into
a Secret on install; the username is always `admin`:
```bash
kubectl -n argocd get secret argocd-initial-admin-secret \
-o jsonpath='{.data.password}' | base64 -d ; echo
```
(`tofu output argocd_admin_password_cmd` prints this same command.)
Open the UI and log in as `admin`:
- By raw IP: `https://<ALB_IP>:8443/`
- By DNS once wired: `https://argocd.eks-gitops.spinifex.local`
The `spinifex-demo` Application shows the sync status, the resource tree, and live
diffs against git — change a manifest in the repo and watch Argo CD reconcile it.
If you'd rather not expose the UI at all, port-forward instead:
```bash
kubectl -n argocd port-forward svc/argocd-server 8080:443
# then open https://localhost:8080
```
### Cleanup
Destroy in reverse — the Argo CD Application + Ingress first (so Argo CD prunes the workload and the LBC tears the ALB down), then the cluster:
```bash
cd workloads
tofu destroy
cd ..
tofu destroy
```
## Troubleshooting
### Application Won't Sync
```bash
kubectl -n argocd get applications
kubectl -n argocd describe application spinifex-demo # conditions show repo/auth errors
kubectl -n argocd logs deploy/argocd-repo-server --tail=50
```
For a private repo, confirm the `eks-demo-app-repo` Secret exists in the `argocd` namespace with the `argocd.argoproj.io/secret-type=repository` label and a valid token.
### `kubernetes_manifest` Fails: no matches for kind "Application"
The `argoproj.io` CRDs aren't installed yet. Apply the parent module and wait for the `argocd` addon to reach `ACTIVE` before applying `workloads/`.
### PVC Stuck in Pending
```bash
kubectl get pvc
kubectl describe pvc <name>
kubectl get storageclass
```
Confirm the `aws-ebs-csi-driver` addon is `ACTIVE` and the default StorageClass exists. EBS-CSI dynamic provisioning depends on the live Spinifex EBS/Viperblock data plane — verify on a stack where the driver is healthy. The app still serves with an in-memory counter if the volume never binds.
### The Ingress Has No Address
The LBC populates `status.loadBalancer` once it provisions the ALB. Check the addon and the controller logs:
```bash
kubectl describe ingress spinifex-demo
kubectl -n kube-system logs deploy/aws-load-balancer-controller --tail=50
```
Confirm the cluster carries `spinifex.io/managed-ingress = "false"` so the LBC owns ingress.
If the controller logs show `FailedBuildModel ... DescribeAvailabilityZones ...
403 ... AccessDenied`, the node role is missing the LBC permissions. The
controller runs with the node instance-profile credentials, so the
`${var.cluster_name}-node-lbc` policy (`aws_iam_policy.node_lbc`) must be
attached to the node role — re-run `tofu apply` on the parent module if it was
provisioned before that policy existed.
### Provider Connection Refused
```bash
sudo systemctl status spinifex.target
curl -k https://localhost:9999/
```
@@ -0,0 +1,745 @@
# Example: GitOps on EKS with Argo CD + a persistent EBS-CSI volume
#
# The top rung of the EKS ladder. It extends eks-https-ingress (LBC + ACM HTTPS)
# and changes how the app is delivered: instead of Terraform applying the
# workload, the Argo CD addon syncs a more elaborate Spinifex-themed app from a
# git repository, and the app stores state on a Viperblock-backed EBS volume
# provisioned dynamically through the EBS-CSI driver.
#
# What it adds over eks-https-ingress:
# * The argocd addon — GitOps continuous delivery, installed via the EKS API.
# * The aws-ebs-csi-driver addon — dynamic EBS (Viperblock) PersistentVolumes.
# * An access entry granting a second IAM principal read-only cluster access.
#
# The workloads/ module registers the (private) git repo with Argo CD, creates an
# Argo CD Application that syncs the app from it, and keeps the HTTPS Ingress
# (LBC + ACM) pointing at the git-managed Service. The app's manifests — including
# the PersistentVolumeClaim — live in the git repo (see ../../../../eks-demo-app).
#
# Usage:
# cd spinifex/docs/terraform-workbooks/eks-gitops-argocd
# export AWS_PROFILE=spinifex
# tofu init && tofu apply
# # build + push the demo image to the ECR repo this creates (see README),
# # then: cd workloads && tofu init && tofu apply -var git_repo_url=... -var git_token=...
# # finally: kubectl get ingress spinifex-demo -o wide → open https://<address>
terraform {
required_version = ">= 1.6.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.40, < 6.0"
}
tls = {
source = "hashicorp/tls"
version = ">= 4.0"
}
}
}
# ---------------------------------------------------------------------------
# Variables
# ---------------------------------------------------------------------------
variable "region" {
type = string
default = "ap-southeast-2"
}
variable "cluster_name" {
type = string
default = "eks-gitops"
}
variable "k8s_version" {
type = string
default = "1.32"
}
variable "node_instance_type" {
type = string
default = "t3.medium"
}
variable "node_desired_size" {
type = number
default = 1
description = "Worker count. Use 1 for a single-node demo, or 3 for an HA-shaped cluster."
validation {
condition = var.node_desired_size == 1 || var.node_desired_size == 3
error_message = "node_desired_size must be 1 or 3."
}
}
variable "node_port" {
type = number
default = 30080
description = "NodePort the demo Service is published on and the ALB forwards to"
}
variable "argocd_node_port" {
type = number
default = 30081
description = "NodePort the Argo CD UI Service is published on and its ALB forwards to"
}
variable "cert_common_name" {
type = string
default = "eks-gitops.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 "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
ecr = var.spinifex_endpoint
acm = var.spinifex_endpoint
}
skip_credentials_validation = true
skip_metadata_api_check = true
skip_requesting_account_id = true
skip_region_validation = true
}
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 image
# from ECR. The workers still join the cluster over the in-VPC private endpoint.
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"
}
# The AWS Load Balancer Controller runs with the node's instance-profile
# credentials (Spinifex wires creds at the node level, not IRSA — see the addon
# block below), so the permissions it needs to manage ALBs must live on the node
# role. This mirrors the upstream AWSLoadBalancerControllerIAMPolicy, trimmed to
# the actions an ALB Ingress exercises (Shield/WAF/Cognito are disabled on the
# controller). Resources are "*" since Spinifex evaluates grants by action.
# A customer-managed policy + attachment is used (Spinifex implements
# CreatePolicy/AttachRolePolicy, not inline PutRolePolicy).
resource "aws_iam_policy" "node_lbc" {
name = "${var.cluster_name}-node-lbc"
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = [
"ec2:DescribeAccountAttributes",
"ec2:DescribeAddresses",
"ec2:DescribeAvailabilityZones",
"ec2:DescribeInternetGateways",
"ec2:DescribeVpcs",
"ec2:DescribeSubnets",
"ec2:DescribeSecurityGroups",
"ec2:DescribeInstances",
"ec2:DescribeNetworkInterfaces",
"ec2:DescribeTags",
"ec2:GetCoipPoolUsage",
"ec2:DescribeCoipPools",
"ec2:CreateSecurityGroup",
"ec2:CreateTags",
"ec2:DeleteTags",
"ec2:AuthorizeSecurityGroupIngress",
"ec2:RevokeSecurityGroupIngress",
"ec2:DeleteSecurityGroup",
"elasticloadbalancing:*",
"acm:ListCertificates",
"acm:DescribeCertificate",
"acm:GetCertificate",
"iam:CreateServiceLinkedRole",
"iam:ListServerCertificates",
"iam:GetServerCertificate",
"tag:GetResources",
"tag:TagResources",
"wafv2:GetWebACLForResource",
"shield:GetSubscriptionState"
]
Resource = "*"
}
]
})
}
resource "aws_iam_role_policy_attachment" "node_lbc" {
role = aws_iam_role.node.name
policy_arn = aws_iam_policy.node_lbc.arn
}
# The EBS CSI driver also runs with the node's instance-profile credentials, so
# the volume-lifecycle permissions it needs must live on the node role too. This
# mirrors the upstream AmazonEBSCSIDriverPolicy. Resources are "*" since Spinifex
# evaluates grants by action; the CreateVolume/CreateSnapshot/CreateTags grants
# upstream scope with request/resource tag conditions, omitted here because
# Spinifex does not yet evaluate IAM tag conditions.
resource "aws_iam_policy" "node_ebs_csi" {
name = "${var.cluster_name}-node-ebs-csi"
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = [
"ec2:CreateSnapshot",
"ec2:AttachVolume",
"ec2:DetachVolume",
"ec2:ModifyVolume",
"ec2:DescribeAvailabilityZones",
"ec2:DescribeInstances",
"ec2:DescribeSnapshots",
"ec2:DescribeTags",
"ec2:DescribeVolumes",
"ec2:DescribeVolumesModifications",
"ec2:CreateTags",
"ec2:DeleteTags",
"ec2:CreateVolume",
"ec2:DeleteVolume",
"ec2:DeleteSnapshot"
]
Resource = "*"
}
]
})
}
resource "aws_iam_role_policy_attachment" "node_ebs_csi" {
role = aws_iam_role.node.name
policy_arn = aws_iam_policy.node_ebs_csi.arn
}
# ---------------------------------------------------------------------------
# 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" }
}]
})
}
# ---------------------------------------------------------------------------
# ECR — repository the workers pull the demo image from
# ---------------------------------------------------------------------------
resource "aws_ecr_repository" "demo" {
name = "spinifex-demo"
# tofu destroy must remove the repo even though it still holds the pushed demo
# image; without this, DeleteRepository (force=false) fails RepositoryNotEmpty.
force_delete = true
}
# ---------------------------------------------------------------------------
# EKS cluster — public + private endpoints, LBC-owned ingress
#
# The spinifex.io/managed-ingress = "false" tag disables K3s' built-in
# traefik/servicelb so the AWS Load Balancer Controller owns ingress, matching
# how ingress works on AWS EKS.
# ---------------------------------------------------------------------------
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]
# Spinifex's DescribeCluster doesn't echo these back the way the AWS provider
# expects: access_config reads as absent (and its ForceNew
# bootstrap_cluster_creator_admin_permissions would replace the live cluster on
# re-add), and vpc_config.security_group_ids reads back as drift. Ignore both so
# later applies (e.g. adding the Argo CD NodePort rule) neither replace the
# cluster nor detach its security groups.
lifecycle {
ignore_changes = [access_config, vpc_config[0].security_group_ids]
}
tags = {
Name = var.cluster_name
"spinifex.io/managed-ingress" = "false"
}
}
# ---------------------------------------------------------------------------
# Managed node group — 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,
aws_iam_role_policy_attachment.node_lbc,
aws_iam_role_policy_attachment.node_ebs_csi,
]
tags = {
Name = "${var.cluster_name}-workers"
}
}
# ---------------------------------------------------------------------------
# Addon — AWS Load Balancer Controller
#
# Spinifex wires the controller's AWS credentials and ELB-eligible subnets at the
# node level, so no IRSA role or subnet tagging is needed here. addon_version is
# omitted: the AWS provider demands a v-prefixed version that the catalog rejects,
# so let Spinifex default to its catalog version.
# ---------------------------------------------------------------------------
resource "aws_eks_addon" "lbc" {
cluster_name = aws_eks_cluster.this.name
addon_name = "aws-load-balancer-controller"
resolve_conflicts_on_create = "OVERWRITE"
depends_on = [aws_eks_node_group.workers]
tags = {
Name = "${var.cluster_name}-lbc"
}
}
# ---------------------------------------------------------------------------
# Addon — Argo CD (GitOps delivery)
#
# Installed through the EKS API; Spinifex stages the bundle host-side and the
# worker renders it into the K3s auto-deploy dir. The workloads module registers
# the git repo and creates the Argo CD Application that syncs the demo app.
# addon_version omitted (see the LBC note).
# ---------------------------------------------------------------------------
resource "aws_eks_addon" "argocd" {
cluster_name = aws_eks_cluster.this.name
addon_name = "argocd"
resolve_conflicts_on_create = "OVERWRITE"
depends_on = [aws_eks_node_group.workers]
tags = {
Name = "${var.cluster_name}-argocd"
}
}
# ---------------------------------------------------------------------------
# Addon — EBS CSI driver (Viperblock-backed PersistentVolumes)
#
# Ships a default gp3 StorageClass (provisioner ebs.csi.aws.com). A PVC in the
# demo app's git manifests dynamically provisions a Viperblock-backed EBS volume
# so the app's state survives pod restarts. addon_version omitted (see LBC note).
# ---------------------------------------------------------------------------
resource "aws_eks_addon" "ebs_csi" {
cluster_name = aws_eks_cluster.this.name
addon_name = "aws-ebs-csi-driver"
resolve_conflicts_on_create = "OVERWRITE"
depends_on = [aws_eks_node_group.workers]
tags = {
Name = "${var.cluster_name}-ebs-csi"
}
}
# ---------------------------------------------------------------------------
# Access entry — grant the viewer principal read-only access (API auth mode)
# ---------------------------------------------------------------------------
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]
}
# ---------------------------------------------------------------------------
# TLS — self-signed certificate imported into ACM
#
# Spinifex ACM supports ImportCertificate (not RequestCertificate), so the cert
# is generated locally by the tls provider and imported. The workloads Ingress
# attaches it to the ALB's HTTPS listener by ARN.
# ---------------------------------------------------------------------------
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"
}
# Wildcard SAN so one ALB (shared IngressGroup) can host-route both the demo
# app (app.<cn>) and the Argo CD UI (argocd.<cn>) off this single cert.
dns_names = [var.cert_common_name, "*.${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"
}
}
# ---------------------------------------------------------------------------
# Let the ALB reach the workers' NodePort
#
# Spinifex auto-manages the nodegroup SG and admits only intra-cluster traffic.
# The LBC-provisioned ALB lives in this VPC, so admit the VPC CIDR on the
# NodePort. 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.workers]
}
resource "aws_vpc_security_group_ingress_rule" "nodeport_from_vpc" {
security_group_id = data.aws_security_group.nodegroup.id
cidr_ipv4 = aws_vpc.main.cidr_block
from_port = var.node_port
to_port = var.node_port
ip_protocol = "tcp"
tags = {
Name = "${var.cluster_name}-alb-nodeport"
}
}
# Same, for the Argo CD UI NodePort its ALB forwards to.
resource "aws_vpc_security_group_ingress_rule" "argocd_nodeport_from_vpc" {
security_group_id = data.aws_security_group.nodegroup.id
cidr_ipv4 = aws_vpc.main.cidr_block
from_port = var.argocd_node_port
to_port = var.argocd_node_port
ip_protocol = "tcp"
tags = {
Name = "${var.cluster_name}-argocd-nodeport"
}
}
# ---------------------------------------------------------------------------
# Outputs
# ---------------------------------------------------------------------------
output "cluster_name" {
value = aws_eks_cluster.this.name
}
output "region" {
value = var.region
}
output "node_port" {
value = var.node_port
}
output "argocd_node_port" {
value = var.argocd_node_port
}
output "node_desired_size" {
value = var.node_desired_size
}
output "certificate_arn" {
value = aws_acm_certificate.ingress.arn
}
output "cert_common_name" {
value = var.cert_common_name
}
output "viewer_principal_arn" {
value = aws_iam_role.viewer.arn
}
output "ecr_repository_url" {
value = aws_ecr_repository.demo.repository_url
description = "Push the demo-app image here, then apply the workloads module"
}
output "ingress_address_hint" {
value = "The demo app and Argo CD UI share one ALB (LBC IngressGroup), host-routed: app.${var.cert_common_name} and argocd.${var.cert_common_name}. Get the address: kubectl get ingress spinifex-demo -o jsonpath='{.status.loadBalancer.ingress[0].hostname}{\"\\n\"}'."
}
output "argocd_ingress_hint" {
value = "Same ALB as the app. Open https://argocd.${var.cert_common_name} (admin password: kubectl -n argocd get secret argocd-initial-admin-secret -o jsonpath='{.data.password}' | base64 -d). Resolve the host to the ALB address, or curl -k --resolve."
}
output "update_kubeconfig" {
value = "aws eks update-kubeconfig --name ${aws_eks_cluster.this.name} --region ${var.region}"
}
@@ -0,0 +1,370 @@
# GitOps workload for eks-gitops-argocd — separate root module / state.
#
# Instead of applying the app directly, this module hands delivery to Argo CD:
# it registers the (private) git repo as an Argo CD repository credential and
# creates an Argo CD Application that syncs the demo app's manifests from it. The
# app's Deployment, Service, and PersistentVolumeClaim live in the git repo (see
# ../../../../eks-demo-app); the HTTPS Ingress (LBC + ACM) stays here and points
# at the Service that Argo CD creates.
#
# The Argo CD Application is a kubernetes_manifest, so the argoproj.io CRDs must
# already exist — apply the parent module (which installs the argocd addon) and
# let it reach ACTIVE before applying this module.
#
# Usage:
# cd spinifex/docs/terraform-workbooks/eks-gitops-argocd
# tofu init && tofu apply # parent: cluster + addons + ACM
# cd workloads && tofu init
# tofu apply -var git_repo_url=https://github.com/mulgadc/eks-demo-app.git \
# -var git_token=<a-read-only-PAT>
# # teardown is the reverse — destroy here first, then the parent.
terraform {
required_version = ">= 1.6.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.40, < 6.0"
}
kubernetes = {
source = "hashicorp/kubernetes"
version = ">= 2.20"
}
}
}
variable "spinifex_endpoint" {
type = string
default = "https://127.0.0.1:9999"
}
variable "git_repo_url" {
type = string
default = "https://github.com/mulgadc/eks-demo-app.git"
description = "Git repo Argo CD syncs the demo app from"
}
variable "git_revision" {
type = string
default = "main"
description = "Git branch, tag, or commit Argo CD tracks"
}
variable "git_path" {
type = string
default = "manifests"
description = "Path within the repo holding the app manifests"
}
variable "git_username" {
type = string
default = "git"
description = "Username for the git credential (any non-empty value for a PAT)"
}
variable "git_token" {
type = string
default = ""
sensitive = true
description = "Read-only personal access token for the private repo. Leave empty for a public repo."
}
provider "aws" {
region = data.terraform_remote_state.infra.outputs.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
}
data "terraform_remote_state" "infra" {
backend = "local"
config = {
path = "../terraform.tfstate"
}
}
locals {
cluster_name = data.terraform_remote_state.infra.outputs.cluster_name
region = data.terraform_remote_state.infra.outputs.region
cert_arn = data.terraform_remote_state.infra.outputs.certificate_arn
argocd_node_port = data.terraform_remote_state.infra.outputs.argocd_node_port
cert_cn = data.terraform_remote_state.infra.outputs.cert_common_name
private_repo = var.git_token != ""
# The demo app and the Argo CD UI share one ALB via an LBC IngressGroup; LBC
# gives each backend its own target group and host-routes between them.
alb_group = local.cluster_name
app_host = "app.${local.cert_cn}"
argocd_host = "argocd.${local.cert_cn}"
}
data "aws_eks_cluster" "this" {
name = local.cluster_name
}
provider "kubernetes" {
host = data.aws_eks_cluster.this.endpoint
cluster_ca_certificate = base64decode(data.aws_eks_cluster.this.certificate_authority[0].data)
exec {
api_version = "client.authentication.k8s.io/v1beta1"
command = "aws"
args = ["eks", "get-token", "--cluster-name", local.cluster_name, "--region", local.region]
}
}
# Repository credential for the private repo. Argo CD picks up Secrets in its
# namespace labelled argocd.argoproj.io/secret-type=repository. Created only when
# a token is supplied (a public repo needs no credential).
resource "kubernetes_secret_v1" "repo" {
count = local.private_repo ? 1 : 0
metadata {
name = "eks-demo-app-repo"
namespace = "argocd"
labels = {
"argocd.argoproj.io/secret-type" = "repository"
}
}
data = {
type = "git"
url = var.git_repo_url
username = var.git_username
password = var.git_token
}
}
# Argo CD Application: syncs the demo app from the git repo into the default
# namespace, self-healing and pruning so the cluster tracks the repo.
resource "kubernetes_manifest" "demo_app" {
manifest = {
apiVersion = "argoproj.io/v1alpha1"
kind = "Application"
# The resources finalizer makes deleting this Application cascade-prune the
# synced resources (Deployment, Service, PVC). Without it, tofu destroy drops
# the Application CR but orphans the live PVC, whose Delete-reclaim PV then
# leaks an EBS volume once the cluster (and ebs-csi) is torn down.
metadata = {
name = "spinifex-demo"
namespace = "argocd"
finalizers = ["resources-finalizer.argocd.argoproj.io"]
}
spec = {
project = "default"
source = {
repoURL = var.git_repo_url
targetRevision = var.git_revision
path = var.git_path
}
destination = {
server = "https://kubernetes.default.svc"
namespace = "default"
}
syncPolicy = {
automated = {
prune = true
selfHeal = true
}
syncOptions = ["CreateNamespace=true"]
}
}
}
depends_on = [kubernetes_secret_v1.repo]
}
# HTTPS Ingress (LBC + ACM), carried over from eks-https-ingress. It points at
# the spinifex-demo Service that Argo CD creates from the git manifests.
resource "kubernetes_ingress_v1" "demo" {
metadata {
name = "spinifex-demo"
namespace = "default"
annotations = {
"alb.ingress.kubernetes.io/scheme" = "internet-facing"
"alb.ingress.kubernetes.io/target-type" = "instance"
"alb.ingress.kubernetes.io/group.name" = local.alb_group
"alb.ingress.kubernetes.io/listen-ports" = "[{\"HTTP\":80},{\"HTTPS\":443}]"
"alb.ingress.kubernetes.io/certificate-arn" = local.cert_arn
"alb.ingress.kubernetes.io/ssl-redirect" = "443"
"alb.ingress.kubernetes.io/healthcheck-path" = "/healthz"
}
}
spec {
ingress_class_name = "alb"
# No host condition: the app is the ALB's catch-all on :443, so it answers on
# the raw ALB IP as well as app.<cn> once DNS is wired. Argo CD's host rule is
# more specific and still wins for argocd.<cn>.
rule {
http {
path {
path = "/"
path_type = "Prefix"
backend {
service {
name = "spinifex-demo"
port {
number = 80
}
}
}
}
}
}
}
depends_on = [kubernetes_manifest.demo_app]
}
# ---------------------------------------------------------------------------
# Argo CD UI exposure — managing the cluster through the GitOps console is the
# point of this demo, so the UI gets the same HTTPS-Ingress treatment as the app
# rather than a port-forward. The argocd addon ships argocd-server as a ClusterIP
# only; expose it via a NodePort the ALB targets. argocd-server serves TLS on
# 8080 (no --insecure), so the ALB speaks HTTPS to the backend.
#
# Same group.name as the demo Ingress, so LBC folds both onto ONE ALB. On :443
# the app is the catch-all and argocd.<cn> host-routes to the UI. Argo CD also
# gets a hostless :8443 listener (argocd_ip below) so the UI is reachable on the
# raw ALB IP before DNS exists — https://<alb-ip>:8443.
# ---------------------------------------------------------------------------
resource "kubernetes_service_v1" "argocd_server_nodeport" {
metadata {
name = "argocd-server-nodeport"
namespace = "argocd"
}
spec {
type = "NodePort"
selector = { "app.kubernetes.io/name" = "argocd-server" }
port {
port = 443
target_port = 8080
node_port = local.argocd_node_port
protocol = "TCP"
}
}
}
resource "kubernetes_ingress_v1" "argocd" {
metadata {
name = "argocd-server"
namespace = "argocd"
annotations = {
"alb.ingress.kubernetes.io/scheme" = "internet-facing"
"alb.ingress.kubernetes.io/target-type" = "instance"
"alb.ingress.kubernetes.io/group.name" = local.alb_group
"alb.ingress.kubernetes.io/listen-ports" = "[{\"HTTPS\":443}]"
"alb.ingress.kubernetes.io/certificate-arn" = local.cert_arn
"alb.ingress.kubernetes.io/backend-protocol" = "HTTPS"
"alb.ingress.kubernetes.io/healthcheck-protocol" = "HTTPS"
"alb.ingress.kubernetes.io/healthcheck-path" = "/healthz"
}
}
spec {
ingress_class_name = "alb"
rule {
host = local.argocd_host
http {
path {
path = "/"
path_type = "Prefix"
backend {
service {
name = kubernetes_service_v1.argocd_server_nodeport.metadata[0].name
port {
number = 443
}
}
}
}
}
}
}
}
# Hostless Argo CD listener on :8443 of the same ALB, so the UI is reachable on
# the raw ALB IP (https://<alb-ip>:8443) without DNS or a host header. argocd.<cn>
# on :443 still works via the host-routed ingress above.
resource "kubernetes_ingress_v1" "argocd_ip" {
metadata {
name = "argocd-server-ip"
namespace = "argocd"
annotations = {
"alb.ingress.kubernetes.io/scheme" = "internet-facing"
"alb.ingress.kubernetes.io/target-type" = "instance"
"alb.ingress.kubernetes.io/group.name" = local.alb_group
"alb.ingress.kubernetes.io/listen-ports" = "[{\"HTTPS\":8443}]"
"alb.ingress.kubernetes.io/certificate-arn" = local.cert_arn
"alb.ingress.kubernetes.io/backend-protocol" = "HTTPS"
"alb.ingress.kubernetes.io/healthcheck-protocol" = "HTTPS"
"alb.ingress.kubernetes.io/healthcheck-path" = "/healthz"
}
}
spec {
ingress_class_name = "alb"
rule {
http {
path {
path = "/"
path_type = "Prefix"
backend {
service {
name = kubernetes_service_v1.argocd_server_nodeport.metadata[0].name
port {
number = 443
}
}
}
}
}
}
}
}
output "application_name" {
value = kubernetes_manifest.demo_app.manifest.metadata.name
}
output "alb_address_hint" {
value = "One shared ALB. Get its IP: kubectl get ingress spinifex-demo -o jsonpath='{.status.loadBalancer.ingress[0].ip}{\"\\n\"}' (or [0].hostname). Reach by raw IP — app: https://<alb-ip>/ Argo CD: https://<alb-ip>:8443/ . By DNS — app: ${local.app_host} Argo CD: ${local.argocd_host}."
}
output "argocd_admin_password_cmd" {
value = "kubectl -n argocd get secret argocd-initial-admin-secret -o jsonpath='{.data.password}' | base64 -d ; echo"
}
output "argocd_url_hint" {
value = "Open https://${local.argocd_host} (admin password: kubectl -n argocd get secret argocd-initial-admin-secret -o jsonpath='{.data.password}' | base64 -d). Resolve the host to the ALB address (northstar/CNAME), or curl -k --resolve ${local.argocd_host}:443:<alb-ip>."
}
@@ -0,0 +1,207 @@
---
title: "EKS HTTPS Ingress (LBC + ACM)"
description: "Serve a Spinifex-themed demo app over HTTPS on EKS using the AWS Load Balancer Controller addon and an ACM certificate — an internet-facing ALB provisioned from a Kubernetes Ingress, using Terraform on Spinifex."
category: "Terraform Workbooks"
tags:
- terraform
- eks
- kubernetes
- ingress
- alb
- acm
- https
- workbook
resources:
- title: "AWS Load Balancer Controller"
url: "https://kubernetes-sigs.github.io/aws-load-balancer-controller/latest/"
- 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"
---
# Terraform: EKS HTTPS Ingress (LBC + ACM)
> Builds on the quickstart. Instead of a raw NodePort, it serves the Spinifex-themed demo app over **HTTPS** through an ALB that the **AWS Load Balancer Controller** provisions from a Kubernetes **Ingress**, terminating TLS with a certificate held in **ACM** — the same pattern you'd use on AWS EKS.
## Table of Contents
- [Overview](#overview)
- [Instructions](#instructions)
- [Troubleshooting](#troubleshooting)
---
## Overview
This is the second rung of the EKS ladder. It takes the cluster + demo app from [EKS Quickstart](../eks-quickstart) and publishes the app the way you would in production: a Kubernetes `Ingress` reconciled by the **AWS Load Balancer Controller (LBC)** into an internet-facing **Application Load Balancer** that terminates TLS with an **ACM** certificate.
To make room for an internet-facing ALB with private workers, the network grows from the quickstart's two public subnets to a public/private split: the ALB and a **NAT gateway** sit in the public subnets, the workers sit in the private subnets and reach ECR through the NAT. The cluster is tagged `spinifex.io/managed-ingress = "false"`, which disables K3s' built-in traefik/servicelb so the LBC owns ingress.
The LBC is installed as a managed **addon** (`aws_eks_addon`). Spinifex wires the controller's AWS credentials and the cluster's ELB-eligible subnets at the node level and injects those subnets into the `alb` `IngressClassParams`, so the Ingress needs **no IRSA role and no subnet tags** — just `ingressClassName: alb` and a handful of annotations.
**What you'll learn:**
- Installing the `aws-load-balancer-controller` addon and letting it own ingress
- Disabling the built-in K3s ingress with the `spinifex.io/managed-ingress` tag
- Importing a self-signed certificate into ACM and attaching it to an ALB listener
- Driving an ALB entirely from a Kubernetes `Ingress` (`ingressClassName: alb`)
- A public/private VPC with a NAT gateway for private workers
**What gets created**
| Resource | Name | Purpose |
|---|---|---|
| VPC | `eks-https-vpc` | Isolated network (10.31.0.0/16) |
| Subnets | `eks-https-public-a/-b`, `eks-https-private-a/-b` | Public (ALB + NAT) and private (workers) |
| Internet + NAT Gateway | `eks-https-igw`, `eks-https-nat` | Public egress; private-worker egress to ECR |
| IAM Roles | `eks-https-cluster-role`, `eks-https-node-role` | Control-plane and worker roles |
| ECR Repository | `spinifex-demo` | Holds the demo image the workers pull |
| EKS Cluster | `eks-https` | Public + private endpoints; `managed-ingress=false` |
| Node Group | `workers` | `node_desired_size` `t3.large` worker(s) — 1 or 3 |
| Addon | `aws-load-balancer-controller` | Provisions the ALB from the Ingress |
| ACM Certificate | `eks-https-ingress` | Self-signed, imported; attached to the HTTPS listener |
| SG Ingress Rule | `eks-https-alb-nodeport` | Admits the VPC CIDR to the workers' NodePort |
| K8s Deployment + Service | `spinifex-demo` | Themed demo image, 2 replicas, NodePort |
| K8s Ingress | `spinifex-demo` | `ingressClassName: alb`, HTTPS via the ACM cert |
**Spinifex specifics**
- **The LBC needs no IRSA or subnet tagging here.** Credentials and ELB-eligible subnets are injected by Spinifex at the node level and into the `alb` `IngressClassParams`. The Ingress just sets `ingressClassName: alb` plus the `alb.ingress.kubernetes.io/*` annotations.
- **`spinifex.io/managed-ingress = "false"`** disables the K3s built-in traefik/servicelb so the LBC owns ingress, matching AWS parity. Omit the tag (or set it `true`) and the built-in ingress stays on.
- The ALB targets the workers' **NodePort** (`target-type: instance`). The one SG rule admits the VPC CIDR on that port so the in-VPC ALB can reach the workers; the worker SG is otherwise auto-managed.
- Spinifex ACM supports **ImportCertificate only** — the cert is generated by the `tls` provider and imported, not requested.
- **Leave `addon_version` unset** (see the [quickstart/addons notes](../eks-quickstart)) — the AWS provider and the Spinifex catalog disagree on the version string format, so let the server choose.
**Prerequisites:**
- Spinifex installed and running, with the `eks-node` image (carrying the LBC bundle) available
- OpenTofu or Terraform, plus `kubectl`, the AWS CLI, and Docker
- The demo image built and pushed to ECR (see [`../demo-app/README.md`](../demo-app/README.md))
## 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 the Cluster
The cluster and the demo app are **two root modules with separate state**. Apply the cluster first (set `node_desired_size=3` for an HA-shaped cluster):
```bash
export AWS_PROFILE=spinifex
tofu init
tofu apply
```
This brings up the cluster, the workers, the NAT gateway, the LBC addon, the ACM cert, and the `spinifex-demo` ECR repository. Give it a few minutes to reach `ACTIVE` and for the LBC addon to report healthy.
### Step 3. Build and Push the Demo Image
```bash
cd ../demo-app
REGISTRY=$(cd ../eks-https-ingress && tofu output -raw ecr_repository_url)
REGISTRY_HOST=${REGISTRY%%/*}
aws ecr get-login-password | docker login --username AWS --password-stdin "$REGISTRY_HOST"
docker build -t "${REGISTRY}:latest" .
docker push "${REGISTRY}:latest"
cd ../eks-https-ingress
```
### Step 4. Deploy the App and Ingress
```bash
cd workloads
tofu init
tofu apply
```
> **Why two modules?** The Kubernetes provider in `workloads/` reads the cluster endpoint from a live `data "aws_eks_cluster"` source, so it's only configured while the cluster exists. Always destroy `workloads/` before the cluster, or the provider falls back to `http://localhost:80` and reports `connection refused`. Keep `AWS_PROFILE=spinifex` exported for both applies.
<!-- INCLUDE: workloads/main.tf lang:hcl -->
### Step 5. Open the Demo over HTTPS
The LBC takes a minute or two to provision the ALB after the Ingress is created. The Ingress publishes the ALB's **DNS name**, which has no resolver yet (northstar will add one) — resolve it to the ALB's public IP with `describe-load-balancers` so you can reach it without DNS or `/etc/hosts`:
```bash
# Point kubectl at the cluster first (writes ~/.kube/config) — without this every
# kubectl call fails to connect:
aws eks update-kubeconfig --name eks-https --region ap-southeast-2
kubectl get ingress spinifex-demo -o wide
DNSNAME=$(kubectl get ingress spinifex-demo -o jsonpath='{.status.loadBalancer.ingress[0].hostname}')
IP=$(aws elbv2 describe-load-balancers \
--query "LoadBalancers[?DNSName=='${DNSNAME}'].AvailabilityZones[].LoadBalancerAddresses[].IpAddress | [0]" \
--output text)
curl -k "https://$IP" # self-signed cert: -k skips verification
```
Open `https://$IP` in a browser (accept the self-signed certificate). The Spinifex-themed page reports the pod, node, cluster, and region that answered — refresh to watch requests land on different replicas, now over HTTPS through the ALB.
### Cleanup
Destroy in reverse — the app/Ingress first (so the LBC tears the ALB down while the cluster is up), then the cluster:
```bash
cd workloads
tofu destroy
cd ..
tofu destroy
```
## Troubleshooting
### The Ingress Has No Address
The LBC populates `status.loadBalancer` once it has provisioned the ALB. If it stays empty:
```bash
kubectl describe ingress spinifex-demo # events show LBC reconcile errors
kubectl -n kube-system logs deploy/aws-load-balancer-controller --tail=50
aws eks describe-addon --cluster-name eks-https --addon-name aws-load-balancer-controller --query 'addon.status'
```
Confirm the addon is `ACTIVE` and the cluster carries `spinifex.io/managed-ingress = "false"` (otherwise the built-in ingress competes).
> **Verify on a live cluster.** The LBC reconcile path (ALB creation, target registration, listener wiring) exercises the running Spinifex EKS data plane end to end. Stand this up on a live stack to confirm the ALB serves the page; the manifests here are the AWS-parity shape of that flow.
### HTTPS Loads but Returns 502 / 504
The ALB reached the workers but the backend didn't answer. Check the demo pods and the NodePort SG rule:
```bash
kubectl get pods -o wide
kubectl get svc spinifex-demo
aws ec2 describe-security-groups --filters "Name=group-name,Values=eks-cluster-eks-https-nodegroup-sg" \
--query 'SecurityGroups[0].IpPermissions'
```
The `eks-https-alb-nodeport` rule must admit the VPC CIDR on the NodePort, and the target group health check (`/healthz`) must pass.
### Addon Fails With "no baked bundle"
Spinifex only installs addons whose manifests are baked into the `eks-node` AMI. If `describe-addon` reports `CREATE_FAILED` with `no baked bundle`, the running worker image predates the LBC bundle — rebuild and republish the `eks-node` image, then recreate the addon.
### Pods Stuck in ImagePullBackOff
The private workers pull from ECR through the NAT gateway. Confirm the image was pushed (`tofu output ecr_repository_url`), the NAT route is in place, and the node role carries `AmazonEC2ContainerRegistryReadOnly`.
### Provider Connection Refused
```bash
sudo systemctl status spinifex.target
curl -k https://localhost:9999/
```
@@ -0,0 +1,577 @@
# Example: EKS HTTPS Ingress via the AWS Load Balancer Controller + ACM
#
# Builds on eks-quickstart. Instead of poking a NodePort open on the worker, this
# workbook serves the Spinifex-themed demo app over HTTPS through an Application
# Load Balancer that the AWS Load Balancer Controller (LBC) provisions from a
# Kubernetes Ingress — the same pattern you'd use on AWS EKS.
#
# What it adds over the quickstart:
# * Public subnets for the ALB + a NAT gateway, private subnets for the workers.
# * The aws-load-balancer-controller addon, installed through the EKS API.
# * The cluster tag spinifex.io/managed-ingress = "false", which disables K3s'
# built-in traefik/servicelb so the LBC owns ingress (AWS parity).
# * A self-signed certificate imported into ACM and attached to the ALB's HTTPS
# listener via an Ingress annotation.
#
# The workloads/ module then creates a Deployment, a NodePort Service, and an
# Ingress (ingressClassName: alb). The LBC reconciles that Ingress into an
# internet-facing ALB that terminates TLS with the ACM cert and forwards to the
# workers' NodePort. The cluster's ELB-eligible subnets are injected by Spinifex
# into the alb IngressClassParams, so the Ingress needs no subnet annotations.
#
# Usage:
# cd spinifex/docs/terraform-workbooks/eks-https-ingress
# export AWS_PROFILE=spinifex
# tofu init && tofu apply
# # build + push the demo image to the ECR repo this creates (see README),
# # then: cd workloads && tofu init && tofu apply
# # finally: kubectl get ingress spinifex-demo -o wide → open https://<address>
terraform {
required_version = ">= 1.6.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.40, < 6.0"
}
tls = {
source = "hashicorp/tls"
version = ">= 4.0"
}
}
}
# ---------------------------------------------------------------------------
# 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.medium"
}
variable "node_desired_size" {
type = number
default = 1
description = "Worker count. Use 1 for a single-node demo, or 3 for an HA-shaped cluster."
validation {
condition = var.node_desired_size == 1 || var.node_desired_size == 3
error_message = "node_desired_size must be 1 or 3."
}
}
variable "node_port" {
type = number
default = 30080
description = "NodePort the demo Service is published on and the ALB forwards to"
}
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 "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
ecr = var.spinifex_endpoint
acm = var.spinifex_endpoint
}
skip_credentials_validation = true
skip_metadata_api_check = true
skip_requesting_account_id = true
skip_region_validation = true
}
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.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" "public_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}-public-a"
}
}
resource "aws_subnet" "public_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}-public-b"
}
}
resource "aws_subnet" "private_a" {
vpc_id = aws_vpc.main.id
cidr_block = "10.31.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.31.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 image
# from ECR. The workers still join the cluster over the in-VPC private endpoint.
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"
}
# The AWS Load Balancer Controller runs with the node's instance-profile
# credentials (Spinifex wires creds at the node level, not IRSA — see the addon
# block below), so the permissions it needs to manage ALBs must live on the node
# role. This mirrors the upstream AWSLoadBalancerControllerIAMPolicy, trimmed to
# the actions an ALB Ingress exercises (Shield/WAF/Cognito are disabled on the
# controller). Resources are "*" since Spinifex evaluates grants by action.
# A customer-managed policy + attachment is used (Spinifex implements
# CreatePolicy/AttachRolePolicy, not inline PutRolePolicy).
resource "aws_iam_policy" "node_lbc" {
name = "${var.cluster_name}-node-lbc"
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = [
"ec2:DescribeAccountAttributes",
"ec2:DescribeAddresses",
"ec2:DescribeAvailabilityZones",
"ec2:DescribeInternetGateways",
"ec2:DescribeVpcs",
"ec2:DescribeSubnets",
"ec2:DescribeSecurityGroups",
"ec2:DescribeInstances",
"ec2:DescribeNetworkInterfaces",
"ec2:DescribeTags",
"ec2:GetCoipPoolUsage",
"ec2:DescribeCoipPools",
"ec2:CreateSecurityGroup",
"ec2:CreateTags",
"ec2:DeleteTags",
"ec2:AuthorizeSecurityGroupIngress",
"ec2:RevokeSecurityGroupIngress",
"ec2:DeleteSecurityGroup",
"elasticloadbalancing:*",
"acm:ListCertificates",
"acm:DescribeCertificate",
"acm:GetCertificate",
"iam:CreateServiceLinkedRole",
"iam:ListServerCertificates",
"iam:GetServerCertificate",
"tag:GetResources",
"tag:TagResources",
"wafv2:GetWebACLForResource",
"shield:GetSubscriptionState"
]
Resource = "*"
}
]
})
}
resource "aws_iam_role_policy_attachment" "node_lbc" {
role = aws_iam_role.node.name
policy_arn = aws_iam_policy.node_lbc.arn
}
# ---------------------------------------------------------------------------
# ECR — repository the workers pull the demo image from
# ---------------------------------------------------------------------------
resource "aws_ecr_repository" "demo" {
name = "spinifex-demo"
# tofu destroy must remove the repo even though it still holds the pushed demo
# image; without this, DeleteRepository (force=false) fails RepositoryNotEmpty.
force_delete = true
}
# ---------------------------------------------------------------------------
# EKS cluster — public + private endpoints, LBC-owned ingress
#
# The spinifex.io/managed-ingress = "false" tag disables K3s' built-in
# traefik/servicelb so the AWS Load Balancer Controller owns ingress, matching
# how ingress works on AWS EKS.
# ---------------------------------------------------------------------------
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
"spinifex.io/managed-ingress" = "false"
}
}
# ---------------------------------------------------------------------------
# Managed node group — 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 — AWS Load Balancer Controller
#
# Spinifex wires the controller's AWS credentials and ELB-eligible subnets at the
# node level, so no IRSA role or subnet tagging is needed here. addon_version is
# omitted: the AWS provider demands a v-prefixed version that the catalog rejects,
# so let Spinifex default to its catalog version.
# ---------------------------------------------------------------------------
resource "aws_eks_addon" "lbc" {
cluster_name = aws_eks_cluster.this.name
addon_name = "aws-load-balancer-controller"
resolve_conflicts_on_create = "OVERWRITE"
depends_on = [aws_eks_node_group.workers]
tags = {
Name = "${var.cluster_name}-lbc"
}
}
# ---------------------------------------------------------------------------
# TLS — self-signed certificate imported into ACM
#
# Spinifex ACM supports ImportCertificate (not RequestCertificate), so the cert
# is generated locally by the tls provider and imported. The workloads Ingress
# attaches it to the ALB's HTTPS listener by ARN.
# ---------------------------------------------------------------------------
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"
}
}
# ---------------------------------------------------------------------------
# Let the ALB reach the workers' NodePort
#
# Spinifex auto-manages the nodegroup SG and admits only intra-cluster traffic.
# The LBC-provisioned ALB lives in this VPC, so admit the VPC CIDR on the
# NodePort. 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.workers]
}
resource "aws_vpc_security_group_ingress_rule" "nodeport_from_vpc" {
security_group_id = data.aws_security_group.nodegroup.id
cidr_ipv4 = aws_vpc.main.cidr_block
from_port = var.node_port
to_port = var.node_port
ip_protocol = "tcp"
tags = {
Name = "${var.cluster_name}-alb-nodeport"
}
}
# ---------------------------------------------------------------------------
# Outputs
# ---------------------------------------------------------------------------
output "cluster_name" {
value = aws_eks_cluster.this.name
}
output "region" {
value = var.region
}
output "node_port" {
value = var.node_port
}
output "node_desired_size" {
value = var.node_desired_size
}
output "certificate_arn" {
value = aws_acm_certificate.ingress.arn
}
output "ecr_repository_url" {
value = aws_ecr_repository.demo.repository_url
description = "Push the demo-app image here, then apply the workloads module"
}
output "ingress_address_hint" {
value = "After applying workloads: kubectl get ingress spinifex-demo -o jsonpath='{.status.loadBalancer.ingress[0].hostname}{\"\\n\"}' — then open https://<that-address> (self-signed cert: curl -k)."
}
output "update_kubeconfig" {
value = "aws eks update-kubeconfig --name ${aws_eks_cluster.this.name} --region ${var.region}"
}
@@ -0,0 +1,258 @@
# Demo workload for eks-https-ingress — separate root module / state.
#
# Kept apart from the cluster root module on purpose: the kubernetes provider
# below reads the cluster endpoint from a *live* data source, so it is only ever
# configured while the cluster exists. Destroy this module before the parent and
# the provider never falls back to localhost.
#
# Creates the Spinifex-themed demo Deployment, a NodePort Service, and an Ingress
# (ingressClassName: alb). The AWS Load Balancer Controller reconciles the
# Ingress into an internet-facing ALB that terminates TLS with the imported ACM
# certificate and forwards to the workers' NodePort.
#
# Usage:
# cd spinifex/docs/terraform-workbooks/eks-https-ingress
# tofu init && tofu apply # parent: cluster + LBC + ACM
# cd workloads && tofu init && tofu apply # this module: demo app + Ingress
# # teardown is the reverse — destroy here first, then the parent.
terraform {
required_version = ">= 1.6.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.40, < 6.0"
}
kubernetes = {
source = "hashicorp/kubernetes"
version = ">= 2.20"
}
}
}
variable "spinifex_endpoint" {
type = string
default = "https://127.0.0.1:9999"
}
variable "replicas" {
type = number
default = 2
}
variable "demo_image" {
type = string
default = ""
description = "Demo image ref. Defaults to the parent's ECR repository URL at :latest."
}
variable "inbound_cidr" {
type = string
default = "0.0.0.0/0"
description = "CIDR allowed to reach the ALB HTTPS listener"
}
provider "aws" {
region = data.terraform_remote_state.infra.outputs.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
}
# Cluster identity comes from the parent module's state; the live endpoint/CA
# come from a data source so the provider is only configured while the cluster
# is up.
data "terraform_remote_state" "infra" {
backend = "local"
config = {
path = "../terraform.tfstate"
}
}
locals {
cluster_name = data.terraform_remote_state.infra.outputs.cluster_name
region = data.terraform_remote_state.infra.outputs.region
node_port = data.terraform_remote_state.infra.outputs.node_port
cert_arn = data.terraform_remote_state.infra.outputs.certificate_arn
demo_image = var.demo_image != "" ? var.demo_image : "${data.terraform_remote_state.infra.outputs.ecr_repository_url}:latest"
}
data "aws_eks_cluster" "this" {
name = local.cluster_name
}
provider "kubernetes" {
host = data.aws_eks_cluster.this.endpoint
cluster_ca_certificate = base64decode(data.aws_eks_cluster.this.certificate_authority[0].data)
exec {
api_version = "client.authentication.k8s.io/v1beta1"
command = "aws"
args = ["eks", "get-token", "--cluster-name", local.cluster_name, "--region", local.region]
}
}
# The Spinifex-themed demo app reports the pod, node, cluster, and region that
# served the request. The pod and node names come from the downward API.
resource "kubernetes_deployment_v1" "demo" {
metadata {
name = "spinifex-demo"
namespace = "default"
labels = { app = "spinifex-demo" }
}
spec {
replicas = var.replicas
selector {
match_labels = { app = "spinifex-demo" }
}
template {
metadata {
labels = { app = "spinifex-demo" }
}
spec {
container {
name = "spinifex-demo"
image = local.demo_image
port {
container_port = 8080
}
env {
name = "POD_NAME"
value_from {
field_ref {
field_path = "metadata.name"
}
}
}
env {
name = "NODE_NAME"
value_from {
field_ref {
field_path = "spec.nodeName"
}
}
}
env {
name = "POD_NAMESPACE"
value_from {
field_ref {
field_path = "metadata.namespace"
}
}
}
env {
name = "CLUSTER_NAME"
value = local.cluster_name
}
env {
name = "AWS_REGION"
value = local.region
}
env {
name = "APP_TITLE"
value = "Spinifex EKS — HTTPS Ingress"
}
readiness_probe {
http_get {
path = "/healthz"
port = 8080
}
initial_delay_seconds = 3
period_seconds = 10
}
}
}
}
}
}
# NodePort Service: the ALB target group registers the workers on this port.
resource "kubernetes_service_v1" "demo" {
metadata {
name = "spinifex-demo"
namespace = "default"
}
spec {
selector = { app = "spinifex-demo" }
type = "NodePort"
port {
port = 80
target_port = 8080
node_port = local.node_port
}
}
depends_on = [kubernetes_deployment_v1.demo]
}
# Ingress reconciled by the AWS Load Balancer Controller into an internet-facing
# ALB. target-type instance registers the workers' NodePort; the ACM cert ARN
# attaches to the HTTPS:443 listener; HTTP is redirected to HTTPS. ELB subnets
# are injected by Spinifex into the alb IngressClassParams, so none are set here.
resource "kubernetes_ingress_v1" "demo" {
metadata {
name = "spinifex-demo"
namespace = "default"
annotations = {
"alb.ingress.kubernetes.io/scheme" = "internet-facing"
"alb.ingress.kubernetes.io/target-type" = "instance"
"alb.ingress.kubernetes.io/listen-ports" = "[{\"HTTP\":80},{\"HTTPS\":443}]"
"alb.ingress.kubernetes.io/certificate-arn" = local.cert_arn
"alb.ingress.kubernetes.io/ssl-redirect" = "443"
"alb.ingress.kubernetes.io/healthcheck-path" = "/healthz"
"alb.ingress.kubernetes.io/inbound-cidrs" = var.inbound_cidr
}
}
spec {
ingress_class_name = "alb"
rule {
http {
path {
path = "/"
path_type = "Prefix"
backend {
service {
name = kubernetes_service_v1.demo.metadata[0].name
port {
number = 80
}
}
}
}
}
}
}
}
output "ingress_name" {
value = kubernetes_ingress_v1.demo.metadata[0].name
}
@@ -0,0 +1,214 @@
---
title: "EKS Quickstart"
description: "Stand up a minimal managed-Kubernetes cluster and a browsable Spinifex-themed demo app — VPC, IAM roles, an EKS cluster, a one- or three-worker node group, an ECR repository, 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 one- or three-worker cluster, an ECR repository, and a Spinifex-themed demo 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 creates an **ECR repository**, then deploys the Spinifex-themed demo app onto the cluster and publishes it on the worker's public IP. When `apply` finishes, open the **`demo_url`** output: the page reports the **pod, node, cluster, and region** 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 node group sized by **`node_desired_size`** (1 for a single-node demo, or 3 for an HA-shaped cluster). You build the demo image and push it to the ECR repository, then Terraform 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 |
| ECR Repository | `spinifex-demo` | Holds the demo image the workers pull |
| EKS Cluster | `eks-quickstart` | Public API endpoint, API auth mode, Kubernetes 1.32 |
| Node Group | `default` | `node_desired_size` `t3.medium` worker(s) — 1 or 3 |
| SG Ingress Rule | `eks-quickstart-demo-nodeport` | Opens the demo NodePort on the auto-managed worker SG |
| K8s Deployment | `spinifex-demo` | The themed demo image from ECR, 2 replicas |
| K8s Service | `spinifex-demo` | 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
- Docker, to build and push the demo image to ECR
## 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
The cluster and the demo app are **two root modules with separate state**. Apply the cluster first:
```bash
export AWS_PROFILE=spinifex
tofu init
tofu apply
```
This creates the cluster (which bootstraps a control-plane VM and brings up k3s — a few minutes in `CREATING`), launches the worker(s), creates the `spinifex-demo` ECR repository, and opens the NodePort.
Set the worker count with `node_desired_size` (`1` for a single node, `3` for an HA-shaped cluster):
```bash
tofu apply -var node_desired_size=3
```
Once the cluster is `ACTIVE`, **build and push the demo image** to the ECR repository this created (full commands in [`../demo-app/README.md`](../demo-app/README.md)):
```bash
cd ../demo-app
REGISTRY=$(cd ../eks-quickstart && tofu output -raw ecr_repository_url)
REGISTRY_HOST=${REGISTRY%%/*}
aws ecr get-login-password | docker login --username AWS --password-stdin "$REGISTRY_HOST"
docker build -t "${REGISTRY}:latest" .
docker push "${REGISTRY}:latest"
cd ../eks-quickstart
```
Then deploy the demo app from the nested `workloads/` module — it defaults `demo_image` to the parent's ECR repository at `:latest`:
```bash
cd workloads
tofu init
tofu apply
```
> **Why two modules?** The Kubernetes provider in `workloads/` reads the cluster endpoint from a live `data "aws_eks_cluster"` source, so it's only ever configured while the cluster exists. Keeping it out of the cluster module means `destroy` never tries to refresh a workload against a cluster that's already gone — the failure mode where the provider falls back to `http://localhost:80` and reports `connection refused`. Always destroy `workloads/` before the cluster.
> **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 both applies.
<!-- INCLUDE: workloads/main.tf lang:hcl -->
### Step 3. Open the Demo
Run from the cluster module directory (`cd ..` if you're still in `workloads/`):
```bash
tofu output demo_url
```
Open that URL in a browser. You'll see the Spinifex-themed page reporting the **pod, node, cluster, and region** 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
Destroy in reverse — the demo app first (while the cluster is still up), then the cluster:
```bash
cd workloads
tofu destroy
cd ..
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 pull from ECR: confirm you built and pushed the image (`tofu output ecr_repository_url`), that the IGW route and the worker's public IP are in place, and that the node role carries `AmazonEC2ContainerRegistryReadOnly`.
### 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,392 @@
# 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 one- or three-worker cluster, an ECR
# repository, and the Spinifex-themed demo 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 reports
# the pod, node, cluster, and region 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, an ECR repository the workers pull from, 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
# # build + push the demo image to the ECR repo this creates (see README),
# # then: cd workloads && tofu init && tofu apply
# # finally open the demo_url output
terraform {
required_version = ">= 1.6.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.40, < 6.0"
}
}
}
# ---------------------------------------------------------------------------
# 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_desired_size" {
type = number
default = 1
description = "Worker count. Use 1 for a single-node demo, or 3 for an HA-shaped cluster."
validation {
condition = var.node_desired_size == 1 || var.node_desired_size == 3
error_message = "node_desired_size must be 1 or 3."
}
}
variable "node_port" {
type = number
default = 30080
description = "NodePort the demo Service is published on"
}
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
ecr = var.spinifex_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"
}
# ---------------------------------------------------------------------------
# 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"
}
# ---------------------------------------------------------------------------
# ECR — repository the workers pull the demo image from
#
# Build and push the demo-app image here before applying the workloads module
# (see ../demo-app/README.md). The node role already carries
# AmazonEC2ContainerRegistryReadOnly, so workers can pull from it.
# ---------------------------------------------------------------------------
resource "aws_ecr_repository" "demo" {
name = "spinifex-demo"
# tofu destroy must remove the repo even though it still holds the pushed demo
# image; without this, DeleteRepository (force=false) fails RepositoryNotEmpty.
force_delete = true
}
# ---------------------------------------------------------------------------
# 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 = var.node_desired_size
min_size = var.node_desired_size
max_size = var.node_desired_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"
}
}
# ---------------------------------------------------------------------------
# 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 "region" {
value = var.region
}
output "node_port" {
value = var.node_port
}
output "node_desired_size" {
value = var.node_desired_size
}
output "ecr_repository_url" {
value = aws_ecr_repository.demo.repository_url
description = "Push the demo-app image here, then apply the workloads module"
}
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,198 @@
# Demo workload for eks-quickstart — separate root module / state.
#
# Kept apart from the cluster root module on purpose: the kubernetes provider
# below reads the cluster endpoint from a *live* data source, so it is only ever
# configured while the cluster exists. Destroy this module before the parent and
# the provider never falls back to localhost.
#
# Usage:
# cd spinifex/docs/terraform-workbooks/eks-quickstart
# tofu init && tofu apply # parent: cluster + infra
# cd workloads && tofu init && tofu apply # this module: demo app
# # teardown is the reverse — destroy here first, then the parent.
terraform {
required_version = ">= 1.6.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.40, < 6.0"
}
kubernetes = {
source = "hashicorp/kubernetes"
version = ">= 2.20"
}
}
}
variable "spinifex_endpoint" {
type = string
default = "https://127.0.0.1:9999"
description = "Spinifex AWS gateway endpoint"
}
variable "replicas" {
type = number
default = 2
description = "Demo app replicas; refresh the page to see requests land on different pods"
}
variable "demo_image" {
type = string
default = ""
description = "Demo image ref. Defaults to the parent's ECR repository URL at :latest."
}
provider "aws" {
region = data.terraform_remote_state.infra.outputs.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
}
# Cluster identity comes from the parent module's state; the live endpoint/CA
# come from a data source so the provider is only configured while the cluster
# is up.
data "terraform_remote_state" "infra" {
backend = "local"
config = {
path = "../terraform.tfstate"
}
}
locals {
cluster_name = data.terraform_remote_state.infra.outputs.cluster_name
region = data.terraform_remote_state.infra.outputs.region
node_port = data.terraform_remote_state.infra.outputs.node_port
demo_image = var.demo_image != "" ? var.demo_image : "${data.terraform_remote_state.infra.outputs.ecr_repository_url}:latest"
}
data "aws_eks_cluster" "this" {
name = local.cluster_name
}
# Authenticates 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 = data.aws_eks_cluster.this.endpoint
cluster_ca_certificate = base64decode(data.aws_eks_cluster.this.certificate_authority[0].data)
exec {
api_version = "client.authentication.k8s.io/v1beta1"
command = "aws"
args = ["eks", "get-token", "--cluster-name", local.cluster_name, "--region", local.region]
}
}
# The Spinifex-themed demo app reports the pod, node, cluster, and region that
# served the request. With multiple replicas, refreshing the demo_url alternates
# between them. The pod and node names come from the downward API.
resource "kubernetes_deployment_v1" "demo" {
metadata {
name = "spinifex-demo"
namespace = "default"
labels = { app = "spinifex-demo" }
}
spec {
replicas = var.replicas
selector {
match_labels = { app = "spinifex-demo" }
}
template {
metadata {
labels = { app = "spinifex-demo" }
}
spec {
container {
name = "spinifex-demo"
image = local.demo_image
port {
container_port = 8080
}
env {
name = "POD_NAME"
value_from {
field_ref {
field_path = "metadata.name"
}
}
}
env {
name = "NODE_NAME"
value_from {
field_ref {
field_path = "spec.nodeName"
}
}
}
env {
name = "POD_NAMESPACE"
value_from {
field_ref {
field_path = "metadata.namespace"
}
}
}
env {
name = "CLUSTER_NAME"
value = local.cluster_name
}
env {
name = "AWS_REGION"
value = local.region
}
readiness_probe {
http_get {
path = "/healthz"
port = 8080
}
initial_delay_seconds = 3
period_seconds = 10
}
}
}
}
}
}
resource "kubernetes_service_v1" "demo" {
metadata {
name = "spinifex-demo"
namespace = "default"
}
spec {
selector = { app = "spinifex-demo" }
type = "NodePort"
port {
port = 80
target_port = 8080
node_port = local.node_port
}
}
depends_on = [kubernetes_deployment_v1.demo]
}
@@ -142,7 +142,6 @@ The ALB is internet-facing, but the DNS name Spinifex returns (`*.elb.spinifex.l
ALB_IP=$(aws elbv2 describe-load-balancers --names nginx-alb \
--query 'LoadBalancers[0].AvailabilityZones[].LoadBalancerAddresses[].IpAddress' \
--output text)
echo "ALB public IP: $ALB_IP"
```
Then hit the ALB — successive requests should alternate between Server 1 and Server 2:
@@ -163,8 +162,6 @@ TG_ARN=$(aws elbv2 describe-target-groups \
--query 'TargetGroups[0].TargetGroupArn' \
--output text)
echo "Target Group ARN: $TG_ARN"
aws elbv2 describe-target-health --target-group-arn $TG_ARN
```
+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"
+21 -21
View File
@@ -13,21 +13,21 @@ 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.6.0
github.com/mulgadc/viperblock v1.6.0
github.com/nats-io/nats-server/v2 v2.14.1
github.com/mulgadc/predastore v1.9.0
github.com/mulgadc/viperblock v1.9.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
github.com/pelletier/go-toml/v2 v2.3.1
github.com/pelletier/go-toml/v2 v2.4.0
github.com/pterm/pterm v0.12.83
github.com/spf13/cobra v1.10.2
github.com/spf13/viper v1.21.0
github.com/stretchr/testify v1.11.1
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,8 +38,8 @@ require (
github.com/antithesishq/antithesis-sdk-go v0.7.0 // indirect
github.com/armon/go-metrics v0.4.1 // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aws/aws-sdk-go-v2 v1.41.9 // indirect
github.com/aws/smithy-go v1.26.0 // 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
github.com/boltdb/bolt v1.3.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
@@ -85,7 +85,7 @@ require (
github.com/jmespath/go-jmespath v0.4.0 // indirect
github.com/josharian/native v1.1.0 // indirect
github.com/klauspost/compress v1.18.6 // indirect
github.com/klauspost/reedsolomon v1.14.0 // indirect
github.com/klauspost/reedsolomon v1.14.1 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/lithammer/fuzzysearch v1.1.8 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
@@ -101,8 +101,8 @@ require (
github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/nats-io/jwt/v2 v2.8.1 // indirect
github.com/nats-io/nkeys v0.4.15 // indirect
github.com/nats-io/jwt/v2 v2.8.2 // indirect
github.com/nats-io/nkeys v0.4.16 // indirect
github.com/nats-io/nuid v1.0.1 // indirect
github.com/pierrec/lz4/v4 v4.1.14 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
@@ -110,7 +110,7 @@ require (
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.5 // indirect
github.com/prometheus/procfs v0.20.1 // indirect
github.com/quic-go/quic-go v0.59.1 // indirect
github.com/quic-go/quic-go v0.60.0 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/sagikazarmark/locafero v0.12.0 // indirect
github.com/spf13/afero v1.15.0 // indirect
@@ -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
)
+64 -62
View File
@@ -31,30 +31,30 @@ github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aws/aws-sdk-go v1.55.8 h1:JRmEUbU52aJQZ2AjX4q4Wu7t4uZjOu71uyNmaWlUkJQ=
github.com/aws/aws-sdk-go v1.55.8/go.mod h1:ZkViS9AqA6otK+JBBNH2++sx1sgxrPKcSzPPvQkUtXk=
github.com/aws/aws-sdk-go-v2 v1.41.9 h1:/rYeyO2+HrMztAmxAq9++XJtFMqSIpSsNA0yDGALYq4=
github.com/aws/aws-sdk-go-v2 v1.41.9/go.mod h1:+HsoOEX80qAVUitj1A2DhCNTjmb3edVyuDypb6LNEeo=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.11 h1:h5+3VT69KUBK24grGuuA5saDJTj2IIjLb9au668Fo5I=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.11/go.mod h1:dnakxebH6UwFvcvujL0LVggYQ8nEvBGjU4G/V79Nv94=
github.com/aws/aws-sdk-go-v2/credentials v1.19.19 h1:yuFzSV1U0aRNYCQGVaTY2zW2M/L93pYHnXnrJUphYhU=
github.com/aws/aws-sdk-go-v2/credentials v1.19.19/go.mod h1:7y63L1kGzeoDlJaQ3Z578KrnmfBut96JjvJUzGwR+YE=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.25 h1:Uii3frf9ztec/ABM2/FSH9/z7PLzxfpG8h4RpkUFflQ=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.25/go.mod h1:G6kntsA2GorAxDPbap6xgB2F+amSLUF8GJTi7PUoX44=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.25 h1:r1+/l6m+WaUJF9HISEsNOLHSNj5EXYQxK8VX6Cz9NlA=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.25/go.mod h1:cKf+D+NMDK1LndD7BowHbBZPgR9V0/5HubH0PFWvA+c=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.26 h1:A1PmWU2zfkIm9EyFlJncFXL4W4phML+h8KjltUsCvNQ=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.26/go.mod h1:dY4MRzXEizrD4hqtpKvWVGPX7QleSGGVY+EBolo1RmM=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.10 h1:d5/908OJ4bXg8lyjeMPvXetEKqoDoLi5Owy1zNue3yg=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.10/go.mod h1:a57l7Hwh+FWI+we50g5NPJHYUKeJKfXbc4w8SyXu8Ig=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.18 h1:W/EyPFl9A5rXrtoilfwHYEvzHER+K4SpBPtMXi24Mos=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.18/go.mod h1:UG50K+pvd/uy6xExbobg0rjqFBFZe6I3l75EPDZw4tg=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.25 h1:dD3dhHNglpd98gs72my22Ndqi1hqQGllFFg1F+twfxg=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.25/go.mod h1:0yAbjPfd64gG7mj85RW+fMEYdfBgCRZw8g/oWcL1pjc=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.25 h1:2pQEbwf+/6EDbiit/GcBE2K4IUpMZymaA0kOz3xK978=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.25/go.mod h1:KvT6NCcQ0EZ+ZkVRrlBMt04Po3ok23YELEp7WimhLhM=
github.com/aws/aws-sdk-go-v2/service/s3 v1.102.2 h1:ie4ElCmUKS26pzrZcIk/lmt4yWjAqLLcawstyQCh298=
github.com/aws/aws-sdk-go-v2/service/s3 v1.102.2/go.mod h1:zjsomFeX5duj+4PlMB+o4JoWTIx+G0XMyzjYrUbQkN0=
github.com/aws/smithy-go v1.26.0 h1:9ouqbi+NyKP7fV3Te7UElCwdAb6Y8uk7LGwPE5tVe/s=
github.com/aws/smithy-go v1.26.0/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/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.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.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.104.0 h1:ta8csKy5vN91F3i5gGR85lFV0srBqySEji7Jroes6rE=
github.com/aws/aws-sdk-go-v2/service/s3 v1.104.0/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=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
@@ -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=
@@ -238,8 +238,8 @@ github.com/klauspost/cpuid/v2 v2.0.10/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuOb
github.com/klauspost/cpuid/v2 v2.0.12/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/klauspost/reedsolomon v1.14.0 h1:5YSZeclzSYg5nl349+GDG/agDtQ6MZiwUYXvVKN1Jx0=
github.com/klauspost/reedsolomon v1.14.0/go.mod h1:yjqqjgMTQkBUHSG97/rm4zipffCNbCiZcB3kTqr++sQ=
github.com/klauspost/reedsolomon v1.14.1 h1:swE9kzyWXD/wVG+l5Pe8bWnQ0giIY7D1GjCBKk3kG2U=
github.com/klauspost/reedsolomon v1.14.1/go.mod h1:yjqqjgMTQkBUHSG97/rm4zipffCNbCiZcB3kTqr++sQ=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/kr/logfmt v0.0.0-20140226030751-b84e30acd515/go.mod h1:+0opPa2QZZtGFBFZlji/RkVcI2GknAs/DXo4wKdlNEc=
@@ -288,30 +288,30 @@ 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.6.0 h1:a0xJtrmAvmLlc19B5xUR9upbkl7VlDntpAKnxbkM07E=
github.com/mulgadc/predastore v1.6.0/go.mod h1:RJh0V0TG27s0q6n0o4CIQFu7CjjPYv4yGnKpIiP9tLs=
github.com/mulgadc/viperblock v1.6.0 h1:mFx0eVOJoT3WL+X/GPrZOI0ySqerpiCtUW4vPpUxbtI=
github.com/mulgadc/viperblock v1.6.0/go.mod h1:cwE2FVrvxUD+QaMWnAZeKVnxF8VtTG9zxiUaP1m5oL0=
github.com/mulgadc/predastore v1.9.0 h1:nXwyblTso9ICbDkzSVx7j2j1FpCgPe/4c1du5Ge0k18=
github.com/mulgadc/predastore v1.9.0/go.mod h1:FBFtu96XZlslhIJ2+Tv9NdR1S3Ks5XrWvJ+Q59iBxVA=
github.com/mulgadc/viperblock v1.9.0 h1:rogwhMefiHIo7T8ApDYMQEs6UNGFNHtH8Bj2W7RzXU8=
github.com/mulgadc/viperblock v1.9.0/go.mod h1:tq3msiqnmURyqEFd0oeMrVv3hpIgefXLurEn9exz+rI=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/nats-io/jwt/v2 v2.8.1 h1:V0xpGuD/N8Mi+fQNDynXohVvp7ZztevW5io8CUWlPmU=
github.com/nats-io/jwt/v2 v2.8.1/go.mod h1:nWnOEEiVMiKHQpnAy4eXlizVEtSfzacZ1Q43LIRavZg=
github.com/nats-io/nats-server/v2 v2.14.1 h1:wXs/a5fw9Hzm3CvuzLxGeIwpjPulSa7gMT3eSuhGkcg=
github.com/nats-io/nats-server/v2 v2.14.1/go.mod h1:4N17zLpuS7WMbG8T9gsE2B7z9hC9PraPyulVBfpK6nU=
github.com/nats-io/jwt/v2 v2.8.2 h1:XXRgB60MSTnqsRwejQurVDs/hcv2dkt+86GjI+I/bMc=
github.com/nats-io/jwt/v2 v2.8.2/go.mod h1:Ag/56sq9OblL4JgdYufDd16Egb17Kr/8WwwuO/forVc=
github.com/nats-io/nats-server/v2 v2.14.2 h1:Q7dRhCY03Y00rETFW3KV+KGaCIajlDfWgWUVgbMxyuk=
github.com/nats-io/nats-server/v2 v2.14.2/go.mod h1:lWpb1bSpRELZfRdlMkdz8E7lbXKKyNe8RIn0vvepIHs=
github.com/nats-io/nats.go v1.52.0 h1:n3avV4VBsCgsdwh71TppsTwtv+QdPs7ntSKM8qJLGsc=
github.com/nats-io/nats.go v1.52.0/go.mod h1:26HypzazeOkyO3/mqd1zZd53STJN0EjCYF9Uy2ZOBno=
github.com/nats-io/nkeys v0.4.15 h1:JACV5jRVO9V856KOapQ7x+EY8Jo3qw1vJt/9Jpwzkk4=
github.com/nats-io/nkeys v0.4.15/go.mod h1:CpMchTXC9fxA5zrMo4KpySxNjiDVvr8ANOSZdiNfUrs=
github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg=
github.com/nats-io/nkeys v0.4.16/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
github.com/ovn-kubernetes/libovsdb v0.8.1 h1:M2J8bcJt5mXCom0HqzfEtuHkT80CTSQRcYG7acT8gf4=
github.com/ovn-kubernetes/libovsdb v0.8.1/go.mod h1:ZlnHLzagmLOSvyd9qfxBIZp6wOSOw0IsRsc+6lNUGbU=
github.com/pascaldekloe/goe v0.1.0 h1:cBOtyMzM9HTpWjXfbbunk26uA6nG3a8n06Wieeh0MwY=
github.com/pascaldekloe/goe v0.1.0/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc=
github.com/pelletier/go-toml/v2 v2.3.1 h1:MYEvvGnQjeNkRF1qUuGolNtNExTDwct51yp7olPtrEc=
github.com/pelletier/go-toml/v2 v2.3.1/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pelletier/go-toml/v2 v2.4.0 h1:Mwu0mAkUKbittDs3/ADDWXqMmq3EOK2VHiuCkV00Row=
github.com/pelletier/go-toml/v2 v2.4.0/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pierrec/lz4/v4 v4.1.14 h1:+fL8AQEZtz/ijeNnpduH0bROTu0O3NZAlPjQxGn8LwE=
github.com/pierrec/lz4/v4 v4.1.14/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
@@ -353,8 +353,10 @@ github.com/pterm/pterm v0.12.36/go.mod h1:NjiL09hFhT/vWjQHSj1athJpx6H8cjpHXNAK5b
github.com/pterm/pterm v0.12.40/go.mod h1:ffwPLwlbXxP+rxT0GsgDTzS3y3rmpAO1NMjUkGTYf8s=
github.com/pterm/pterm v0.12.83 h1:ie+YmGmA727VuhxBlyGr74Ks+7McV6kT99IB8EU80aA=
github.com/pterm/pterm v0.12.83/go.mod h1:xlgc6bFWyJIMtmLJvGim+L7jhSReilOlOnodeIYe4Tk=
github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic=
github.com/quic-go/quic-go v0.59.1/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0=
github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk=
github.com/quic-go/quic-go v0.60.0 h1:xcQioE8OM66UQLeUMHltK1CCcOu3JbVB4JAQdDQSB+0=
github.com/quic-go/quic-go v0.60.0/go.mod h1:wpKpjmPpftl30sL6pFh7REVpjbcCVy4zt2vDyK1TuJk=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
@@ -426,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=
@@ -444,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=
@@ -454,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=
@@ -486,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=
@@ -536,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=
+112
View File
@@ -0,0 +1,112 @@
// 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 (
"bytes"
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"encoding/hex"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
"github.com/mulgadc/predastore/auth"
"github.com/mulgadc/spinifex/internal/tlsconfig"
)
// Client posts SigV4-signed requests to the AWS gateway for the "eks" service.
type Client struct {
baseURL string
accessKey string
secretKey string
region string
httpClient *http.Client
}
// New builds a client. caPath optionally pins the gateway TLS CA; when empty the
// client relies on the system trust store. region defaults to us-east-1 when
// empty (SigV4 requires a non-empty region).
func New(baseURL, caPath, accessKey, secretKey, region string) (*Client, error) {
if baseURL == "" {
return nil, fmt.Errorf("eksgw: baseURL is required")
}
if accessKey == "" || secretKey == "" {
return nil, fmt.Errorf("eksgw: access key and secret key are required")
}
if region == "" {
region = "us-east-1"
}
tlsCfg := &tls.Config{
MinVersion: tls.VersionTLS13,
CurvePreferences: tlsconfig.Curves,
}
if caPath != "" {
pem, err := os.ReadFile(caPath)
if err != nil {
return nil, fmt.Errorf("eksgw: read gateway CA %q: %w", caPath, err)
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(pem) {
return nil, fmt.Errorf("eksgw: gateway CA %q has no usable certificates", caPath)
}
tlsCfg.RootCAs = pool
}
return &Client{
baseURL: strings.TrimRight(baseURL, "/"),
accessKey: accessKey,
secretKey: secretKey,
region: region,
httpClient: &http.Client{
Timeout: 10 * time.Second,
Transport: &http.Transport{TLSClientConfig: tlsCfg, MaxIdleConns: 2, IdleConnTimeout: 30 * time.Second},
},
}, nil
}
// 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) {
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)
}
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 {
return nil, fmt.Errorf("sign request: %w", err)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("send request: %w", err)
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("gateway returned %d: %s", resp.StatusCode, strings.TrimSpace(string(respBody)))
}
return respBody, 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) {
-2
View File
@@ -1,2 +0,0 @@
.creds/
*.retry
-222
View File
@@ -1,222 +0,0 @@
# Ansible Dev Lifecycle (parallel to bash scripts)
Experimental Ansible-based lifecycle for a single-node Spinifex dev host.
Lives alongside the existing `scripts/dev-*.sh` and `scripts/reset-dev-env.sh`;
neither set is removed. Decide which to keep after parity testing.
Plan: `docs/development/improvements/ansible-dev-lifecycle.md` (mulga top-level).
## Prerequisites
Venv lives at sub-repo root (`spinifex/.venv/`) — one venv per sub-repo,
shared by any future python tooling in spinifex. Bootstrap from `spinifex/`:
```
sudo apt install python3.13-venv
python3 -m venv .venv
source .venv/bin/activate
pip install 'ansible>=9'
ansible-galaxy collection install -r scripts/ansible/requirements.yml
```
Then `cd scripts/ansible` to run playbooks — `ansible.cfg` resolves the
inventory and roles relative to CWD.
## Playbooks
| Playbook | Purpose | Bash parity |
|---|---|---|
| `playbooks/dev-preflight.yml` | Dependency + port check | `dev-setup.sh` §checks |
| `playbooks/dev-teardown.yml` | Wipe state only (no reinstall) | `reset-dev-env.sh` §teardown |
| `playbooks/dev-install.yml` | Build + install + init + smoketest on clean box | `dev-install.sh` |
| `playbooks/dev-reset.yml` | Capture settings → teardown → build → install → init → smoketest | `reset-dev-env.sh` (full) |
| `playbooks/dev-deploy.yml` | Rebuild + swap binaries/microvm artifacts + restart (no setup.sh, no smoketest) | `make deploy` |
| `playbooks/dev-status.yml` | Read-only health snapshot (units, ports, OVN/OVS, gateway, counts, drift) | none |
| `playbooks/dev-logs.yml` | Dump journald + `/var/log/spinifex/*` + status/networking/versions into a tarball | none |
| `playbooks/dev-snapshot.yml` | Quiesce spinifex+OVN+OVS, tar state paths to a named bundle, restart | none |
| `playbooks/dev-restore.yml` | Stop services, wipe + untar a named bundle, refresh CA, restart | none |
| `playbooks/dev-version.yml` | Build-vs-install drift report (spx, plugin, microvm) + sub-repo HEADs | none |
| `playbooks/dev-vpc.yml` | Idempotent dev VPC fixture (VPC + public subnet + IGW + default route); `-e dev_vpc_state=absent` tears down | partial overlap with `scripts/iac/examples/*` |
Upcoming (not yet implemented):
- `dev-mode-start.yml` / `dev-mode-stop.yml` — parity with `start-dev.sh` / `stop-dev.sh`
(lowest priority — deprecated bash scripts, CI-only callers).
## Invocation
```
ansible-playbook playbooks/dev-preflight.yml
ansible-playbook playbooks/dev-teardown.yml
ansible-playbook playbooks/dev-install.yml
ansible-playbook playbooks/dev-reset.yml
ansible-playbook playbooks/dev-deploy.yml
ansible-playbook playbooks/dev-status.yml
ansible-playbook playbooks/dev-logs.yml
ansible-playbook playbooks/dev-snapshot.yml -e snapshot_name=before-merge
ansible-playbook playbooks/dev-restore.yml -e snapshot_name=before-merge
ansible-playbook playbooks/dev-version.yml
ansible-playbook playbooks/dev-vpc.yml
```
Or via `make` (from `spinifex/`):
```
make ansible-dev-preflight
make ansible-dev-teardown
make ansible-dev-install
make ansible-dev-reset
make ansible-dev-deploy
make ansible-dev-status
make ansible-dev-logs
make ansible-dev-snapshot ANSIBLE_EXTRA='-e snapshot_name=before-merge'
make ansible-dev-restore ANSIBLE_EXTRA='-e snapshot_name=before-merge'
make ansible-dev-version
make ansible-dev-vpc
make ansible-dev-vpc ANSIBLE_EXTRA='-e dev_vpc_state=absent'
```
### When to use which
- First-time / clean box → `ansible-dev-install`
- Iterate on Go code, microvm initramfs, lb-agent → `ansible-dev-deploy` (fast)
- Changed systemd units, helper scripts, logrotate, setup.sh → `ansible-dev-reset` (slow, full rebuild)
- Need a clean slate without reinstall → `ansible-dev-teardown`
- "Is my dev box healthy?" → `ansible-dev-status` (read-only, never mutates)
- Filing a bug / capturing the state of a broken box → `ansible-dev-logs` (writes `/tmp/spinifex-logs-<host>-<ts>.tar.gz`; override `-e logs_since=10min` or `-e logs_include_dmesg=false`)
- Checkpoint before a risky op (branch switch, viperblock surgery, ovn restart cycle) → `ansible-dev-snapshot ANSIBLE_EXTRA='-e snapshot_name=<name>'`; roll back via `ansible-dev-restore` instead of a full `dev-reset`. Bundles live in `/var/lib/spinifex-snapshots/<name>.tar.gz` (root-owned, survives `dev-teardown`).
- "Do I need to `ansible-dev-deploy`?" → `ansible-dev-version` (drift-only check; faster than `dev-status`)
- Want a known-good VPC/subnet for manual EC2 testing without writing tofu → `ansible-dev-vpc` (smoketest will then find and use it automatically). Tear down with `ANSIBLE_EXTRA='-e dev_vpc_state=absent'`.
## Variable overrides
Defaults in `vars/defaults.yml`. Override per run with `-e`:
```
ansible-playbook playbooks/dev-teardown.yml -e spinifex_wipe_ssh_keys=true
```
Useful overrides:
| Var | Default | What it does |
|---|---|---|
| `spinifex_wipe_legacy_home` | `true` | Remove `$HOME/spinifex` (legacy dev-mode path) |
| `spinifex_wipe_ssh_keys` | `false` | Remove `~/.ssh/spinifex-key*` |
| `spinifex_wipe_aws_creds` | `false` | Remove spinifex profile from `~/.aws/credentials` |
| `teardown_process_wait_seconds` | `30` | Grace period for processes to exit |
| `spinifex_region` | `ap-southeast-2` | Region passed to `spx admin init` |
| `spinifex_az` | `{{ region }}a` | AZ passed to `spx admin init` |
| `spinifex_external_mode` | `pool` | External networking mode — `pool` or empty (NAT removed) |
| `spinifex_external_no_external` | `false` | `true` → emit `--no-external` and skip external networking |
| `spinifex_external_pool_start` | `""` | **Required** for `pool` mode — first IP of the static WAN pool |
| `spinifex_external_pool_end` | `""` | **Required** for `pool` mode — last IP of the static WAN pool |
| `spinifex_external_gateway` | `""` | WAN gateway (auto-detected when blank) |
| `spinifex_external_prefix_len` | `""` | WAN prefix length (auto-detected when blank) |
| `spinifex_external_gateway_ip` | `""` | OVN router external-IP override (`--gateway-ip`) |
`pool` mode is mandatory unless you set `spinifex_external_no_external=true`.
`spx admin init` rejects the run without a `--external-pool` range, so the
init role asserts `spinifex_external_pool_start/_end` upfront. Either persist
them in `vars/defaults.yml`, or pass them at invocation:
Direct `ansible-playbook` (use `-e` here — this is ansible's flag):
```
ansible-playbook playbooks/dev-install.yml \
-e spinifex_external_pool_start=192.168.1.150 \
-e spinifex_external_pool_end=192.168.1.250
```
Via `make` (use `EXTRA_VARS=` — make eats its own `-e`):
```
make ansible-dev-install \
EXTRA_VARS="spinifex_external_pool_start=192.168.1.150 spinifex_external_pool_end=192.168.1.250"
```
`dev-reset.yml` automatically captures `region`, `az`, `external_mode`,
pool range, gateway, prefix-len and `gateway_ip` from the existing
`/etc/spinifex/spinifex.toml` before teardown and replays them into
`init`. Legacy `external_mode = "nat"` is coerced to `"pool"` with a warn.
Use `-e` to override.
## Helper scripts
`scripts/aws-as-spinifex.sh` and `scripts/spx-as-spinifex.sh` wrap `aws`
and `spx` invocations in `sg spinifex -c '...'` so they run with the
`spinifex` supplementary group active. Both helpers preset
`AWS_PROFILE=spinifex`. All AWS-calling tasks in the roles use these
via `spinifex_aws_cli` / `spinifex_spx_cli` from `vars/defaults.yml`.
Background: `/etc/spinifex/ca.pem` lives under `drwxr-x--- root:spinifex`,
so the AWS CLI needs `spinifex` group membership to read the CA bundle.
Ansible's `become_user` is a no-op when the target user equals the
calling user (no `sudo -u` invoked → no `initgroups(3)` → supplementary
groups inherited from the operator's shell). On a fresh login the
operator's shell would have `spinifex` (added by `setup.sh`); on shells
that predate the group add it does not. The helpers sidestep this
entirely.
## Leak catalog
`roles/teardown/README.md` tracks every known state source. When teardown
misses something, add it there and in `roles/teardown/tasks/main.yml`.
## Multi-node cluster bootstrap
`playbooks/cluster-bootstrap.yml` provisions a 2+ node Spinifex cluster
across a long-running tofu env (`env1`, `env2`, `bryn`, etc). Inventory
is generated dynamically from `scripts/tofu-cluster/envs/<env>.tfvars`
via `inventory/tofu.py` — first node in `nodes = [...]` is the init host,
the rest are joins.
```bash
make ansible-cluster-bootstrap ENV=env1 POOL=192.168.1.150-192.168.1.159
```
`POOL` is optional; omit for DHCP fallback. Equivalent without make:
```bash
cd scripts/ansible
CLUSTER_ENV=env1 ansible-playbook -i inventory/tofu.py \
playbooks/cluster-bootstrap.yml \
-e cluster_env=env1 \
-e cluster_external_pool=192.168.1.150-192.168.1.159
```
Plays sequence:
1. **runner (localhost)** — `build` role produces `/tmp/spinifex-local.tar.gz`.
2. **all env hosts (parallel)** — copies tarball, stops any dev-layout
services (`~/spinifex/`), runs `preflight` + `install` roles. `setup.sh`
creates `/etc/spinifex`, the `spinifex` user/group, systemd units, and
installs `/usr/local/bin/spx`.
3. **init host only** — `cluster-init` role runs `spx admin init` in the
background, waits for the formation server, captures the join token.
CA is then copied into `/usr/local/share/ca-certificates/spinifex-ca.crt`
**before** `spinifex.target` starts (required — predastore enforces
strict TLS verify against the system trust store).
4. **join hosts (parallel)** — `cluster-join` role runs `spx admin join`
against the formation server, then installs CA, then starts
`spinifex.target`.
5. **all hosts** — polls `https://<ip>:4432/health` until `awsgw=ok`.
6. **init host only** — `cluster-post` role imports the SSH key + Ubuntu
AMI (both idempotent: skip if already present).
Re-running the playbook against an already-bootstrapped env is safe —
`setup.sh`, `spx admin init --force`, CA copy, key/AMI import are all
idempotent.
### Migration from `bootstrap.sh`
`scripts/tofu-cluster/bootstrap.sh` ran services under `~/spinifex/` as
`tf-user` via `start-dev.sh` with no systemd supervision, and installed
the cluster CA *after* services started — predastore would crash with
`x509: certificate signed by unknown authority` on every first boot.
`cluster-bootstrap.yml` replaces that flow with production-layout
(`/etc/spinifex/` + systemd) and correct CA-before-services ordering.
The first run on an env previously bootstrapped by `bootstrap.sh` will
stop the dev-layout services, install the prod layout, and re-init the
cluster from scratch via `spx admin init --force`.
`bootstrap.sh` stays in-tree for CI and pseudo-multinode envs.
-17
View File
@@ -1,17 +0,0 @@
[defaults]
inventory = inventory/localhost.yml
roles_path = roles
stdout_callback = ansible.builtin.default
host_key_checking = False
retry_files_enabled = False
interpreter_python = auto_silent
forks = 10
[inventory]
# inventory/tofu.py is an executable script inventory used by
# cluster-bootstrap.yml — opted into via `-i inventory/tofu.py` on the
# command line; not default to keep dev-* playbooks pointing at localhost.
enable_plugins = host_list, script, auto, yaml, ini, toml
[callback_default]
result_format = yaml
-6
View File
@@ -1,6 +0,0 @@
---
all:
hosts:
localhost:
ansible_connection: local
ansible_python_interpreter: "{{ ansible_playbook_python }}"
-170
View File
@@ -1,170 +0,0 @@
#!/usr/bin/env python3
"""Dynamic ansible inventory generator from tofu-cluster tfvars.
Reads scripts/tofu-cluster/envs/<env>.tfvars (HCL subset) and emits ansible
JSON inventory. First node in the tfvars `nodes = [...]` list is the init
host (forms the cluster); the rest are join hosts.
Usage:
inventory/tofu.py --list --env env1
inventory/tofu.py --host <ip> --env env1
Selecting the env:
CLUSTER_ENV=env1 inventory/tofu.py --list
inventory/tofu.py --list --env env1
"""
from __future__ import annotations
import argparse
import json
import os
import re
import sys
from pathlib import Path
def _repo_root() -> Path:
here = Path(__file__).resolve()
for parent in here.parents:
if (parent / "go.work").exists() or (parent / ".gitmodules").exists():
return parent
raise SystemExit(f"could not locate mulga repo root from {here}")
def _tfvars_path(env: str) -> Path:
root = _repo_root()
candidate = root / "scripts" / "tofu-cluster" / "envs" / f"{env}.tfvars"
if not candidate.exists():
raise SystemExit(f"tfvars file not found: {candidate}")
return candidate
_STRING_RE = re.compile(r'^\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*=\s*"([^"]*)"\s*$')
_NUMBER_RE = re.compile(r'^\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*=\s*([0-9]+)\s*$')
_NODE_FIELD_RE = re.compile(r'(\w+)\s*=\s*"([^"]*)"')
def _parse_tfvars(path: Path) -> dict:
"""Minimal tfvars parser — handles the subset used by envs/*.tfvars.
Supports:
key = "string"
key = 12345
nodes = [
{ name = "x", wan_ip = "1.2.3.4", ... },
...
]
"""
out: dict = {"nodes": []}
text = path.read_text()
# Strip line comments
text = re.sub(r"#.*", "", text)
# Pull the nodes block out first, then process remaining lines.
nodes_match = re.search(r"nodes\s*=\s*\[(.+?)\]\s*$", text, re.DOTALL | re.MULTILINE)
if nodes_match:
nodes_body = nodes_match.group(1)
for entry in re.finditer(r"\{([^}]+)\}", nodes_body):
fields = dict(_NODE_FIELD_RE.findall(entry.group(1)))
if "name" in fields and "wan_ip" in fields:
out["nodes"].append(fields)
text = text.replace(nodes_match.group(0), "")
for line in text.splitlines():
if m := _STRING_RE.match(line):
out[m.group(1)] = m.group(2)
elif m := _NUMBER_RE.match(line):
out[m.group(1)] = int(m.group(2))
return out
def _resolve_ssh_key(value: str | None) -> str:
if not value:
return os.path.expanduser("~/.ssh/tf-user-ap-southeast-2")
return os.path.expanduser(value)
def _build_inventory(env: str) -> dict:
data = _parse_tfvars(_tfvars_path(env))
nodes = data.get("nodes") or []
if not nodes:
raise SystemExit(f"{env}.tfvars has no nodes")
ssh_user = data.get("ssh_user", "tf-user")
ssh_key = _resolve_ssh_key(data.get("ssh_public_key_path", "").replace(".pub", ""))
hostvars: dict[str, dict] = {}
all_hosts: list[str] = []
init_hosts: list[str] = []
join_hosts: list[str] = []
for idx, node in enumerate(nodes):
ip = node["wan_ip"]
role = "init" if idx == 0 else "join"
hostvars[ip] = {
"ansible_host": ip,
"ansible_user": ssh_user,
"ansible_ssh_private_key_file": ssh_key,
"ansible_ssh_common_args": "-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR",
"spinifex_node_name": node["name"],
"spinifex_node_role": role,
"spinifex_node_index": idx,
"spinifex_wan_ip": ip,
}
all_hosts.append(ip)
(init_hosts if role == "init" else join_hosts).append(ip)
# Group-level vars derived from tfvars (operator can override with -e).
cluster_init_host = init_hosts[0]
group_vars = {
"cluster_env": env,
"cluster_init_host": cluster_init_host,
"cluster_init_ip": cluster_init_host,
"spinifex_node_count": len(nodes),
"spinifex_region": data.get("region", "ap-southeast-2"),
"spinifex_az": data.get("az", "ap-southeast-2a"),
}
if cidr := data.get("env_cidr"):
group_vars["cluster_env_cidr"] = cidr
inventory = {
"_meta": {"hostvars": hostvars},
env: {"hosts": all_hosts, "vars": group_vars},
"cluster": {"children": [env]},
"init": {"hosts": init_hosts},
"join": {"hosts": join_hosts},
}
return inventory
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--list", action="store_true", help="emit full inventory")
parser.add_argument("--host", help="emit hostvars for a single host (ignored — _meta carries them)")
parser.add_argument(
"--env",
default=os.environ.get("CLUSTER_ENV"),
help="tofu env name (matches scripts/tofu-cluster/envs/<env>.tfvars); also via CLUSTER_ENV env var",
)
args = parser.parse_args()
if not args.env:
sys.stderr.write("error: --env required (or CLUSTER_ENV)\n")
return 64
if args.host:
json.dump({}, sys.stdout)
sys.stdout.write("\n")
return 0
inventory = _build_inventory(args.env)
json.dump(inventory, sys.stdout, indent=2)
sys.stdout.write("\n")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -1,330 +0,0 @@
---
# cluster-bootstrap.yml — multi-node Spinifex cluster bootstrap against a
# tofu-provisioned env. Inventory comes from inventory/tofu.py which parses
# scripts/tofu-cluster/envs/<env>.tfvars.
#
# Usage:
# cd spinifex/scripts/ansible
# ansible-playbook -i inventory/tofu.py playbooks/cluster-bootstrap.yml \
# -e cluster_env=env1 -e cluster_external_pool=192.168.1.150-192.168.1.159
#
# Or via the make wrapper:
# make ansible-cluster-bootstrap ENV=env1 POOL=192.168.1.150-192.168.1.159
#
# Behaviour:
# - all hosts: preflight + install (setup.sh) — production layout
# - init host: spx admin init (backgrounded) + CA install + spinifex.target
# - join hosts: spx admin join + CA install + spinifex.target (parallel)
# - init host: import ssh key + ubuntu AMI (idempotent)
- name: Cluster bootstrap — build artifact once on runner
hosts: localhost
gather_facts: true
vars_files:
- ../vars/defaults.yml
tasks:
- name: assert cluster_env supplied
ansible.builtin.assert:
that: cluster_env | default('') | length > 0
fail_msg: "Pass -e cluster_env=<env> (matches scripts/tofu-cluster/envs/<env>.tfvars)"
roles:
- build
- name: Cluster bootstrap — install (all nodes, parallel)
hosts: "{{ cluster_env }}"
gather_facts: true
serial: 100%
vars:
install_run_ovn: false # we drive setup-ovn ourselves with role-aware args
cluster_bootstrap_purge: true # wipe stale single-node predastore.toml + raft data
vars_files:
- ../vars/defaults.yml
pre_tasks:
- name: copy build tarball to each node
ansible.builtin.copy:
src: "{{ build_tarball_path }}"
dest: "{{ build_tarball_path }}"
mode: "0644"
- name: stop dev-layout services if running (migration from ~/spinifex/)
become: true
# Bracket trick on the first letter prevents the pattern literal in the
# shell command line from matching the running shell itself (pkill -f
# against literal patterns is otherwise self-killing — rc=-9).
# `spx [a]dmin` catches lingering formation servers from prior bootstrap
# attempts that own port 4432 and steal joins from the new init.
ansible.builtin.shell:
cmd: |
pkill -9 -f "[b]in/spx service" 2>/dev/null || true
pkill -9 -f "spx [a]dmin" 2>/dev/null || true
pkill -9 -f "spinifex-[u]i" 2>/dev/null || true
pkill -9 -f "[n]ats-server" 2>/dev/null || true
pkill -9 -f "[p]redastore" 2>/dev/null || true
pkill -9 -f "[v]iperblock" 2>/dev/null || true
sleep 2
rm -rf "{{ ansible_env.HOME }}/spinifex" 2>/dev/null || true
exit 0
changed_when: false
roles:
- install
# Wipe OVN/OVS state before setup-ovn runs. Without this, re-bootstrapping a
# previously-built cluster leaves stale chassis rows in the SB DB, stale
# external_ids on Open_vSwitch, stale br-int flows + ports, stale
# systemd-networkd unit files, and stale ovs-monitor-ipsec strongSwan certs.
# Symptom: ovn-controller flows reference dead encap endpoints; IPsec SAs
# fail AUTHENTICATION_FAILED; new joins shadow ghosts under the same
# node-name.
- name: Cluster bootstrap — OVN/OVS state reset (all nodes, parallel)
hosts: "{{ cluster_env }}"
gather_facts: false
serial: 100%
vars_files:
- ../vars/defaults.yml
tasks:
- name: reset — stop OVN/OVS services
become: true
ansible.builtin.systemd:
name: "{{ item }}"
state: stopped
loop:
- ovn-controller
- ovn-central
- openvswitch-ipsec
- openvswitch-switch
failed_when: false
- name: reset — delete OVN NB/SB databases
become: true
ansible.builtin.file:
path: "{{ item }}"
state: absent
loop:
- "{{ ovn_nb_db }}"
- "{{ ovn_sb_db }}"
- name: reset — delete OVS conf.db + system-id (force fresh ovsdb)
become: true
ansible.builtin.file:
path: "{{ item }}"
state: absent
loop:
- /etc/openvswitch/conf.db
- /etc/openvswitch/.conf.db.~lock~
- /etc/openvswitch/system-id.conf
- name: reset — drop spinifex-managed systemd-networkd unit files
become: true
ansible.builtin.shell: |
rm -f /etc/systemd/network/14-spinifex-*.netdev \
/etc/systemd/network/15-spinifex-*.netdev \
/etc/systemd/network/15-spinifex-*.network \
/etc/systemd/network/16-spinifex-*.network
networkctl reload 2>/dev/null || true
changed_when: true
- name: reset — enumerate stale spinifex veth/tap netdevs
ansible.builtin.shell: |
ip -o link show | awk -F': ' '{print $2}' | cut -d'@' -f1 | \
grep -E '^(veth-wan-br|veth-wan-ovs|spx-ext-|spx-tap-|spx-)' || true
register: reset_links
changed_when: false
- name: reset — delete stale netdevs
become: true
ansible.builtin.command: "ip link delete {{ item }}"
loop: "{{ reset_links.stdout_lines }}"
changed_when: true
failed_when: false
- name: reset — clear ovs-monitor-ipsec strongSwan state
become: true
ansible.builtin.shell: |
rm -rf /var/lib/openvswitch/pki 2>/dev/null || true
rm -f /etc/ipsec.d/certs/ovs-* /etc/ipsec.d/private/ovs-* 2>/dev/null || true
rm -f /etc/ipsec.conf.d/ovs-* 2>/dev/null || true
changed_when: true
- name: reset — start openvswitch-switch with fresh conf.db
become: true
ansible.builtin.systemd:
name: openvswitch-switch
state: started
daemon_reload: true
- name: Cluster bootstrap — OVN central (init host only)
hosts: init
gather_facts: false
vars_files:
- ../vars/defaults.yml
tasks:
- name: setup-ovn --management on init host
become: true
ansible.builtin.command:
cmd: >-
/usr/local/share/spinifex/setup-ovn.sh
--management
--encap-ip={{ spinifex_wan_ip }}
--node-name={{ hostvars[inventory_hostname]['spinifex_node_name'] }}
changed_when: true
- name: Cluster bootstrap — OVN chassis (join hosts, parallel)
hosts: join
gather_facts: false
vars_files:
- ../vars/defaults.yml
tasks:
- name: setup-ovn --ovn-remote on join hosts
become: true
ansible.builtin.command:
cmd: >-
/usr/local/share/spinifex/setup-ovn.sh
--ovn-remote=tcp:{{ cluster_init_host }}:6642
--encap-ip={{ spinifex_wan_ip }}
--node-name={{ hostvars[inventory_hostname]['spinifex_node_name'] }}
changed_when: true
- name: Cluster bootstrap — init host (start formation server)
hosts: init
gather_facts: false
vars_files:
- ../vars/defaults.yml
roles:
- cluster-init
- name: Cluster bootstrap — join hosts run spx admin join (parallel)
hosts: join
gather_facts: false
serial: 100%
vars_files:
- ../vars/defaults.yml
roles:
- cluster-join
- name: Cluster bootstrap — wait for init to finish writing multi-node configs
hosts: init
gather_facts: false
vars_files:
- ../vars/defaults.yml
tasks:
- name: wait for spx admin init async job to complete
become: true
ansible.builtin.async_status:
jid: "{{ cluster_init_async.ansible_job_id }}"
register: cluster_init_done
until: cluster_init_done.finished
retries: 60
delay: 5
- name: dump init log on failure
ansible.builtin.command: tail -100 /tmp/spx-admin-init.log
register: cluster_init_log
changed_when: false
when: cluster_init_done.rc | default(1) != 0
- name: assert init exited cleanly
ansible.builtin.assert:
that: cluster_init_done.rc == 0
fail_msg: |
spx admin init failed (rc={{ cluster_init_done.rc }})
{{ cluster_init_log.stdout | default('(log not captured)') }}
- name: confirm multi-node predastore.toml written
become: true
ansible.builtin.shell: |
if grep -q '127\.0\.0\.1' {{ spinifex_etc_dir }}/predastore/predastore.toml 2>/dev/null; then
echo "ERROR: predastore.toml still references loopback — multi-node config not written"
exit 1
fi
grep -c '^\[\[nodes\]\]' {{ spinifex_etc_dir }}/predastore/predastore.toml 2>/dev/null || echo 0
register: predastore_node_count
changed_when: false
- name: Cluster bootstrap — install CA on all nodes (before service start)
hosts: "{{ cluster_env }}"
gather_facts: false
vars_files:
- ../vars/defaults.yml
tasks:
- name: install CA into system trust
become: true
ansible.builtin.copy:
src: "{{ spinifex_ca_src }}"
dest: "{{ spinifex_ca_trust_path }}"
mode: "0644"
remote_src: true
- name: rebuild CA bundle
become: true
ansible.builtin.command: update-ca-certificates
changed_when: true
# spinifex-ui reverse-proxies localhost:9999 (awsgw) and localhost:8443
# (predastore) — hardcoded in spinifex/services/spinifexui/spinifexui.go.
# spx admin init binds those services to BindIP only, so the loopback
# connection fails (BadGateway in UI). Patch to wildcard bind; cert
# already includes 127.0.0.1 in IP SANs.
- name: patch awsgw bind to 0.0.0.0 (UI proxies via localhost)
become: true
ansible.builtin.replace:
path: "{{ spinifex_config_file }}"
regexp: '^(\s*host\s*=\s*")[^"]+(:9999")'
replace: '\g<1>0.0.0.0\g<2>'
- name: patch predastore bind to 0.0.0.0 (UI proxies via localhost)
become: true
ansible.builtin.replace:
path: "{{ spinifex_config_file }}"
regexp: '^(\s*host\s*=\s*")[^"]+(:8443")'
replace: '\g<1>0.0.0.0\g<2>'
- name: Cluster bootstrap — start services (init first, then joins)
hosts: init
gather_facts: false
vars_files:
- ../vars/defaults.yml
tasks:
- name: start spinifex.target on init
become: true
ansible.builtin.systemd:
name: spinifex.target
state: started
- name: Cluster bootstrap — start services on join hosts
hosts: join
gather_facts: false
serial: 100%
vars_files:
- ../vars/defaults.yml
tasks:
- name: start spinifex.target on joiners
become: true
ansible.builtin.systemd:
name: spinifex.target
state: started
- name: Cluster bootstrap — readiness check (all nodes)
hosts: "{{ cluster_env }}"
gather_facts: false
vars_files:
- ../vars/defaults.yml
tasks:
- name: wait for awsgw responsiveness
ansible.builtin.uri:
url: "https://{{ spinifex_wan_ip }}:{{ cluster_formation_port }}/health"
validate_certs: false
return_content: true
register: cluster_health
until:
- cluster_health.status == 200
- cluster_health.json.service_health.awsgw == "ok"
retries: 30
delay: 5
- name: Cluster bootstrap — post-bootstrap fixtures (init host only)
hosts: init
gather_facts: true
vars_files:
- ../vars/defaults.yml
roles:
- cluster-post
-14
View File
@@ -1,14 +0,0 @@
---
# In-place artifact swap: rebuilds + replaces binaries/microvm artifacts on
# an already-installed node, then restarts spinifex.target. Use when iterating
# on code without altering systemd units, users, dirs, or running the full
# smoketest. For first-time or unit/script changes use dev-install / dev-reset.
- name: Dev deploy — build + swap binaries + restart (no setup.sh, no smoketest)
hosts: localhost
gather_facts: true
vars_files:
- ../vars/defaults.yml
roles:
- build
- deploy
-67
View File
@@ -1,67 +0,0 @@
---
- name: Dev install — build, install, init, smoketest (idempotent)
hosts: localhost
gather_facts: true
vars_files:
- ../vars/defaults.yml
pre_tasks:
- name: preflight
ansible.builtin.include_role:
name: preflight
- name: health — config file present?
ansible.builtin.stat:
path: "{{ spinifex_config_file }}"
register: health_config
- name: health — spinifex.target active?
become: true
ansible.builtin.command: systemctl is-active spinifex.target
register: health_target
changed_when: false
failed_when: false
- name: health — awsgw responds?
become: true
become_user: "{{ spinifex_invoking_user }}"
environment:
AWS_PROFILE: spinifex
ansible.builtin.command: aws ec2 describe-regions
register: health_gw
changed_when: false
failed_when: false
- name: health — spx binary installed?
ansible.builtin.stat:
path: /usr/local/bin/spx
register: health_spx
- name: health — set already_installed fact
ansible.builtin.set_fact:
spinifex_already_installed: >-
{{ health_config.stat.exists
and health_spx.stat.exists
and (health_target.stdout | trim) == 'active'
and health_gw.rc == 0 }}
- name: health — summary
ansible.builtin.debug:
msg: |
Health:
spx binary: {{ 'present' if health_spx.stat.exists else 'missing' }}
config: {{ 'present' if health_config.stat.exists else 'missing' }}
target: {{ health_target.stdout | trim }}
awsgw: {{ 'ok' if health_gw.rc == 0 else 'not responding' }}
Already installed: {{ spinifex_already_installed }}
{% if spinifex_already_installed %}
→ Skipping build/install/init. Smoketest only.
{% else %}
→ Running full build → install → init → smoketest.
{% endif %}
roles:
- { role: build, when: not (spinifex_already_installed | default(false)) }
- { role: install, when: not (spinifex_already_installed | default(false)) }
- { role: init, when: not (spinifex_already_installed | default(false)) }
- smoketest
-17
View File
@@ -1,17 +0,0 @@
---
# Capture spinifex + ovn/ovs + qemu/nbdkit logs from journald and
# /var/log/spinifex/ into a single tarball for bug reports.
# Tail mode is intentionally not provided — use `journalctl -u 'spinifex-*' -f`
# directly; ansible is the wrong tool for follow.
- name: Dev logs — dump journald + varlog + status into a bundle
hosts: localhost
gather_facts: true
vars_files:
- ../vars/defaults.yml
vars:
logs_since: "1h"
logs_output_dir: "/tmp"
logs_include_dmesg: true
roles:
- logs
@@ -1,8 +0,0 @@
---
- name: Dev preflight — validate host dependencies
hosts: localhost
gather_facts: true
vars_files:
- ../vars/defaults.yml
roles:
- preflight

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