Compare commits

..

148 Commits

Author SHA1 Message Date
Ben Duncan e4055d4d89 chore(ui): rebuild dist 2026-11-02 14:28:37 +10:00
Ben Duncan a7753557dc fix: bound the network test server start and read ARM CPU features and cores 2026-11-02 14:28:05 +10:00
Ben Duncan fe97c45793 fix: align the CPU facts fields 2026-11-02 14:28:05 +10:00
Ben Duncan 1713c711c4 chore(ui): rebuild dist 2026-11-02 14:28:05 +10:00
Ben Duncan bab4e1e53c feat(ui): node table on the dashboard, every CPU flag and a readable topology
The dashboard's nodes section is the Nodes page table, open by default, and a
row opens that node. Ready reads green, capacity sorts by most available, and
the processor's features tip lists every flag. Topology draws one row per
port with switches in a muted card, highlights a node's links on hover, and
offers a left-to-right layout beside top-down.
2026-11-02 14:28:05 +10:00
Ben Duncan 1a91d5de9e feat: report every CPU flag in node facts 2026-11-02 14:28:05 +10:00
Ben Duncan e958e4a57b test: cover the node insight handlers and the facts file mode 2026-11-02 14:28:05 +10:00
Ben Duncan a44fd01b85 feat: admin node inventory, topology, live host metrics and network test
Each node collects its hardware facts as root at daemon start (dmidecode,
ethtool, networkctl LLDP where available) into /etc/spinifex/node-facts, and
the daemon serves them with its plane wiring over NATS.

New admin-only actions, refused for every account but the system admin
account regardless of policy:

- GetNodeFacts: hardware, NICs, LLDP neighbours and planes per node
- GetNodeMetrics: live CPU, memory and per-plane traffic for one node
- StartNetworkTest / DescribeNetworkTests: iperf2 between nodes over a
  chosen plane, both directions, one test cluster-wide at a time, recorded
  in KV and converged asynchronously

The iperf listener runs only for the duration of a run, without
capabilities, on TCP 5001, which the peers firewall rule now allows. A run
refuses any target outside the node's own plane subnet.

The console gains a node panel (hardware, network, metrics, network test
with a dial scaled to the port's line rate) and a topology view built with
@xyflow/react. setup.sh installs dmidecode and iperf.
2026-11-02 14:28:05 +10:00
Ben Duncan 72f2786360 fix(ui): open a DNS record for editing from the keyboard 2026-11-02 14:27:56 +10:00
Ben Duncan 8916904285 feat(ui): size RDS instance classes and give each record value its own input
An RDS class is a db.-prefixed EC2 type, so the class picker shows its vCPUs
and memory in every option and under the field, as the EC2 launch form does,
on create, modify and restore.

A Route 53 record's values were one textarea, which let a line break into a
value. Each value is now a single-line input with Add value; Enter starts the
next one, a pasted list splits into rows, and CNAME takes exactly one.
2026-11-02 14:27:56 +10:00
Ben Duncan 2630d29e13 feat(ui): console feedback round: state tones, inline tags, filter bar and form fixes
Settled states go green and transitions yellow with a spinner, status text is
lowercase, tags are edited inline row by row, every list filters through one
filter bar, long cells truncate with a tooltip and times are relative.

EC2 launch takes a Name and offers ICMP echo, and leaves Placement Group out
when no group is available. RDS create is reordered with engine tiles and
window pickers. Route 53 records get bulk delete, a TTL select, value labels
that follow the type, and NS. Tooltips use the popover tokens, and the console
is sized over a 110% root.
2026-11-02 14:27:56 +10:00
Julian Sommer e7c0f0ebae fix(ec2): omit network interface PrivateDnsName while the VPC has enableDnsHostnames off (#1424) 2026-11-02 15:15:20 +11:00
Julian Sommer d79e537330 fix(ecs): always return clusters and failures lists from DescribeClusters (#1418) 2026-11-02 14:53:10 +11:00
Julian Sommer fe349fb994 fix: return ClusterNotFoundException from ECS ListTasks on a missing cluster (#1419) 2026-11-02 14:52:49 +11:00
Julian Sommer 061ea49832 fix(ecs): reject task definitions whose container has no image or no memory setting (#1420) 2026-11-02 14:52:35 +11:00
Julian Sommer f4af10713d fix(elbv2): default TCP target groups to thresholds 5/2 and omit empty HealthCheckPath and Matcher (#1421) 2026-11-02 14:52:21 +11:00
Julian Sommer 0eeed40cb6 chore: cleanup oracle linux install doc 2026-11-02 13:46:25 +10:00
Josh Siviour ad62b56555 test(sqs): crash once for all conformance crash cases (#1417)
The four crash cases each restarted the store, which on the three-node
cluster means a full Raft re-election every time. They now set up in
separate namespaces, crash once, and run the same post-crash checks as
named subtests, cutting TestR3/Conformance from ~39s to ~11s.
2026-11-02 13:45:13 +10:00
Josh Siviour 6e6690a35a refactor(compute): extract a shared direct-boot microVM builder (#1416)
* refactor(compute): extract a shared direct-boot microVM builder

runtime/compute/vm gains MicroVMSpec, NewMicroVMConfig and
MicroVMMachineType, with CPU mitigations on unless a caller sets
MitigationsOff. The ELB and SQS system-instance launches pass their own
settings to it; only the load balancer opts out of mitigations. A golden
test pins the full QEMU argv for both launches, unchanged by the move.

* test(daemon): stub QEMU on PATH in the microVM argv golden test

Execute resolves a QEMU binary before returning the command, and CI
runners have none installed. The test now puts an empty
qemu-system-x86_64 on PATH, so it checks the real argument construction
without QEMU.
2026-11-02 13:25:50 +10:00
Josh Siviour 2ad9ab9450 test(sqs): run three-node R3 scenarios in the race tier, not preflight (#1415)
TestR3 restarts real three-node JetStream clusters and waits out Raft
elections, adding ~40s to the slowest preflight package while covering
~0.1% of its statements. It now skips unless SPINIFEX_SQS_R3 is set, and
make test-race sets it, so CI's Race Detection job runs it on every PR.
2026-11-02 12:53:54 +10:00
Josh Siviour 3128713f7b fix(awsgw): admit accounts synchronously per ADR-0005:S7 (#1412)
* fix(awsgw): admit accounts with a synchronous in-process status read

* fix(iam): refuse to move the system and admin accounts off ACTIVE

* fix(awsgw): deny a missing account record instead of failing the request
2026-11-02 12:43:48 +10:00
Julian Sommer 9f74933546 test: speed up pipeline and account lifecycle suites (#1414) 2026-11-02 13:09:00 +11:00
Julian Sommer 9dc32e3373 test: parallelize isolated SQS JetStream R3 scenarios (#1413) 2026-11-02 12:52:13 +11:00
Julian Sommer a6459ba7e4 fix: default deploy-vultr.sh to the latest release channel 2026-11-02 11:49:17 +10:00
Julian Sommer 805a71a63c fix: run the setup.sh shipped in the downloaded tarball
install.mulgadc.com serves the latest release's setup.sh, so a --channel dev
or --version install ran an installer older than the tarball it unpacked and
missed files it did not know about, such as lib/distro.sh. setup.sh now hands
off to the tarball's own setup.sh, with the same arguments, before it changes
anything on the host.

The Vultr guide now installs from the latest release instead of dev.
2026-11-02 11:42:40 +10:00
Julian Sommer 2d9eadbf23 fix: lint issue 2026-11-02 11:15:13 +10:00
Julian Sommer 581be4067a chore: use default base-ui for cmdk search 2026-11-02 11:13:00 +10:00
Julian Sommer 96e62ac213 chore: centre the search dialog 2026-11-02 10:47:56 +10:00
Julian Sommer 02bba41669 chore: title case headings on ui 2026-11-02 10:45:25 +10:00
Josh Siviour 8fa33f07ce feat(eks): inject IRSA credentials into pods of annotated ServiceAccounts (#1396)
* test: pod identity webhook mutation, publisher and webhook config contracts

* feat: pod identity webhook mutator, CA bundle publisher and webhook configuration

* feat(eks): run the pod identity webhook on every control-plane server

Add the eks-pod-identity-webhook binary and its OpenRC service. It listens on
127.0.0.1:8444 with a serving certificate signed by the k3s server CA, authenticates
to the API server with a client certificate signed by the k3s client CA, and runs
under its own least-privilege ClusterRole. Each server stages the RBAC and the
MutatingWebhookConfiguration (caBundle is the server CA) into the k3s manifests
directory, so server-join nodes get them too.

Match upstream's container skip rule: skip only when the regional STS key is
present, and add the token mount otherwise.

* fix(eks): issue pod identity webhook certs for one year and renew in the running process

Both the serving and client certificates were valid for ten years. Kubernetes
cannot revoke a client certificate, so a copy taken off a control-plane disk
would stay a valid identity for a decade. Issue both for one year with the
existing 30-day renew window.

The process loaded the serving certificate once. A daily check now exits
with ErrCertRenewal when either certificate enters the renew window, so the
supervisor restarts it and EnsureCerts reissues.

Add tests for KubeClient against a TLS fake API server and for Run, which the
package had none of.

* test: e2e covers pod-identity webhook and unmodified LBC chart

IRSAPod now uses a ServiceAccount annotation only and asserts the injected
env and volumes plus an untouched unannotated pod. LBCChart installs the
upstream chart 1.11.0 and reconciles an Ingress into an active ALB.

* test: pass the tagging endpoint to the LBC chart and mark Shield/WAF flags Spinifex-only

* fix: make the pod identity webhook init script executable in the eks-node image

setup.sh chmods each shipped init script to 0755 explicitly, and the new
service was missing from the list. rc-update then refused it, and
eks-node-role, running under set -eu, aborted before starting k3s.

Add a guard test that every init script in INSTALL_FILES is chmod'd.

* fix: start the pod identity webhook last and keep its failures non-fatal

eks-node-role runs under set -eu, so a fault enabling or starting the
optional webhook aborted the control plane boot before k3s-first-boot,
state-report and addon-sync. Enable and start it at the end of the server
and server-join roles and log a warning on failure instead.

The role test now asserts the webhook is the last call and that an enable
or start failure still exits 0 and finishes the role.

* fix: move the pod identity webhook off the token webhook readiness port

eks-token-webhook already serves its plain HTTP readiness endpoint on 8444,
which the API server target group health-checks. The pod identity webhook
bound the same port, failed with address already in use and respawned
forever, so no pod was ever injected.

Listen on 127.0.0.1:8445 and add a test that the two ports differ.

* docs: describe pod identity webhook for user workloads in the EKS guide

* fix: split STUB_FAIL assignment from export in the role test
2026-11-02 10:42:38 +10:00
Josh Siviour 3c192b3558 feat(iam): evict cached account status on change via an accounts watcher (#1411) 2026-11-02 10:29:02 +10:00
Ben Duncan 8cded67776 feat(eks): DeleteCluster returns DELETING and converges the teardown in the background (#1394)
* feat(eks): converge DeleteCluster's teardown behind the recorded DELETING

DeleteCluster ran the whole cluster teardown inside the NATS request. Each
control-plane VM that ignores ACPI costs 40 s (20 s graceful powerdown, then
20 s waiting for the PID file), so a two-member cluster overran the 30 s
budget: the caller got a 500, and its retry queued behind the first handler
on the serial subscription and got a 404.

The request now validates, answers a DELETING cluster from its record,
records DELETING and returns. The teardown runs in a background converger
started by the request and backed by the deleting reaper:

- An in-process per-cluster gate sits in front of the teardown lease, which
  re-adopts a key its own node holds; one per-node bound covers kicks and
  reaper hand-offs.
- The lease renews on the service context, not the request's, and the
  converger stops at a step boundary if it is lost.
- The converger acts only on a record that is DELETING when re-read, refuses
  to remove a record that still has a node group, deletes the cluster's
  child records before the meta, and deletes the meta only if no ownership
  ref appeared that its pass did not tear down.
- DELETING is sticky for every status writer, and the create launch's meta
  writes add their refs to a DELETING record instead of overwriting it, and
  never recreate a swept one.
- The reaper hands work to the converger instead of running it inline,
  retries at a backoff capped at 30 minutes without ever abandoning a
  record, and resumes records an older build flagged exhausted.
- A stuck delete is surfaced in DescribeCluster as one INTERNAL_FAILURE
  issue naming the step, never the internal error.

* fix(eks): refuse DeleteCluster while node groups are attached

AWS answers ResourceInUseException ("Cluster has nodegroups attached") when
managed node groups remain. DeleteCluster went ahead instead, and its sweep
erased the node group records, leaving their workers running in the
customer account with no record to own them and no control plane.

The request now decides that error before recording anything. The e2e
suites, the terraform workbooks and the account teardown engine all delete
node groups first; the console shows the refusal's message.

* fix(eks): refuse new add-ons and access entries on a deleting cluster

CreateAddon and CreateAccessEntry checked only that the cluster record
existed, so a dependant could be written to a cluster whose delete was
already recorded, and the teardown would then sweep it with the record.
Both now answer ResourceInUseException for a DELETING cluster, a code both
operations declare in the EKS model.

* test(e2e): DeleteCluster returns DELETING at once and never reads ACTIVE after

The first delete-cluster must return within 5 s with status DELETING, as
AWS does. While the cluster is still listed every DescribeCluster must read
DELETING, and a repeat delete must answer DELETING from the record; once it
is gone a further delete answers ResourceNotFoundException.

* docs(eks): record the cluster delete lifecycle contract

The contract ADR-0003 S1 asks for before a conformance claim: what the
request decides, the recorded obligation, how the teardown converges and
recovers, and the S8 and S7 tests that pin each item, with the remaining
gaps listed.

* test(eks): wait for the delete request before counting terminates

On a build whose DeleteCluster is synchronous the request goroutine is still
recording terminates when the assertions read, so the tests must wait for it
to return as well as for the background teardown.

* test(eks): classify every eks request subject by its handler's work

The ADR-0003 S8 subject table for EKS. Every eks.* subject the gateway sends
and the daemon serves is classified in eksSubjects, and the table fails for
one that is not, so a new subject is classified deliberately rather than by
inheriting the 30 s default. DeleteCluster is request-scaled. Four handlers
still hold the request for work that grows with the resource and are named
as not conformant: CreateCluster's FAILED-name reclaim, DeleteNodegroup,
UpdateNodegroupConfig and RestoreSnapshot. Their budget stays 30 s, since a
larger one would hide the defect.

* fix(eks): leave no child record behind a launch overtaken by a delete

A create launch still running when its cluster was deleted and swept could
seed the creator's access entry afterwards, leaving it under a cluster with
no record. The launch now re-checks the record after seeding and, finding it
gone, removes the cluster's child keys with the infra it holds. The teardown
also sweeps child keys again after deleting the meta, for a write that lands
between its two deletes.

* fix(eks): fence the create launch on its generation and keep its infra owned

Review findings on the async delete, each with a test that fails before:

- The launch's meta write checked only the status, so a launch overtaken by
  a delete could overwrite a newer cluster of the same name and then release
  by name. Writes are now fenced on CreatedAt and the ARN; a superseded
  launch releases only the instances it started, by ID.
- A launch whose record was swept released its infra once, best effort. It
  now recreates the record as DELETING with its refs, so the teardown and
  the reaper own and retry the release.
- A record marked FAILED under a running launch was treated as owned by a
  teardown; the launch now merges its refs and releases what it built.
- Child records: a fresh claim clears keys an earlier cluster of the name
  left, the teardown stops the cluster's bootstrap subscriber, bootstrap
  refuses to write into a record that is gone or DELETING, the creator's
  access entry and the GPU device add-on are gated on the record.
- Deleting the last node group of a DELETING cluster resumes its delete,
  and waiting on node groups no longer counts as a failed pass.
- One converger run is bounded at 15 minutes, so a hung step cannot hold
  the gate and lease for ever; the timed-out pass is recorded.
- The FAILED-name reclaim purge runs under the teardown gate and lease.
- The meta-last test drives the converger with a failing KV instead of
  DeleteClusterPrefix, which production no longer called and is removed.
- The S7 test asserts the teardown issues no volume delete and terminates
  no worker.

* fix(eks): keep a deleted cluster's record while its create launch runs

Second review of the async delete, each behavioural change with a test that
fails before:

- A create launch now heartbeats its owner and time on the record, and the
  teardown keeps the record while that heartbeat is fresh, so DescribeCluster
  never answers NotFound and then DELETING again when an overtaken launch
  recreates the record. The launch hands its refs over at its next write and
  starts the teardown when it returns; a heartbeat silent for 90 s is taken
  as a crashed launch, and recreating a swept record stays as the backstop.
- The launch's OIDC keypair writes are fenced on the record's generation, so
  an overtaken launch cannot replace a newer same-name cluster's signing key.
- Re-creating a FAILED cluster's name is no longer refused by the per-node
  teardown bound.
- A deadline reached while removing the record is still recorded; an exited
  bootstrap subscriber removes its own entry; a record that is not DELETING
  is refused as not recorded even when it has node groups.
2026-11-01 18:28:05 -06:00
Julian Sommer aae7f68097 fix: send x-amzn-RequestId on every JSON error response and record it in the access log (#1407) 2026-11-02 11:21:04 +11:00
Julian Sommer df1873b417 fix(ecr): refuse an unknown filter.tagStatus on ListImages and DescribeImages (#1403) 2026-11-02 11:15:11 +11:00
Julian Sommer bf3c5e3dba fix: refuse ECR policy texts that are omitted or outside the model's length before parsing them (#1402) 2026-11-02 11:13:48 +11:00
Julian Sommer 9a571a7438 fix(ecs): refuse DeleteCluster without a cluster instead of deleting the default cluster (#1408) 2026-11-02 11:12:46 +11:00
Julian Sommer 045816a44d fix(ecr): refuse a tag with no Value on CreateRepository and TagResource (#1404) 2026-11-02 11:11:34 +11:00
Josh Siviour f6686b5436 feat(sts): refuse to mint credentials for an account that is not ACTIVE (#1410)
* feat(sts): refuse to mint credentials for an account that is not ACTIVE

* feat(sts): log a refused mint for an account that is not ACTIVE
2026-11-02 09:48:11 +10:00
Josh Siviour b034f3b2e9 fix(sqs): bring up loopback in the SQS guest before its store starts (#1409)
The guest's SQS nats-server listens on 127.0.0.1 and the worker dials it
there, but the kernel leaves lo down, so the store failed to bind and the
worker never polled. Also correct the stale CA comment in init.
2026-11-02 09:40:28 +10:00
Julian Sommer 3d387c839d fix: return AWS's task-definition defaults, registeredAt and deregisteredAt, and tags only with include TAGS (#1398) 2026-11-02 10:19:59 +11:00
Julian Sommer a5c07f37fd fix: return createdAt, startedAt and stoppedAt from ECS DescribeTasks (#1401) 2026-11-02 10:19:42 +11:00
Julian Sommer c50cc2512a fix: fail ELBv2 Describe calls for unknown target groups, load balancer names and listener LB ARNs (#1405)
DescribeTargetGroups rejects a malformed target group ARN with ValidationError
and reports a missing group with AWS's message, DescribeLoadBalancers fails with
LoadBalancerNotFound for an unknown name, and DescribeListeners fails with
LoadBalancerNotFound for an unknown load balancer ARN. The EKS cluster NLB lookup
treats the new not-found as absent.
2026-11-02 10:19:17 +11:00
Julian Sommer 5310c82e95 fix: report EBS encryption by default from the platform's key posture and refuse disabling it (#1397) 2026-11-02 10:18:39 +11:00
Julian Sommer 943f191bf3 fix: refuse an ECR encryptionConfiguration that omits encryptionType (#1399) 2026-11-02 10:18:24 +11:00
Josh Siviour 671a8a9ff5 feat(sqs): fresh single-node SQS in one system microVM (#1389)
* feat(sqs): run queue and message owners in the worker by signed pull

The SQS worker's ready step now opens the queue and message owners on
its dedicated store and serves owner calls it pulls from its node's
gateway over the existing instance-role channel (PollWork,
CompleteWork). Each poll and post is authorized against the current
worker identity, and a result is accepted only from the identity that
took the work. No payload crosses platform NATS.

A receipt-signing key is created on the worker disk only under the
first-store decision that creates the store key, and its fingerprint
is bound in the store; a missing or changed key refuses to serve.
Received messages are released only when the gateway proves the caller
left; receives are capped so a worst-case result fits one post.

The public relay is not registered in production.

* fix: harden SQS relay results and bound pending bytes

Unavailable outranks client faults when encoding owner errors, and wire
errors carry fixed text only, never owner or backend text. The ingress
rejects incomplete or mismatched success results as unavailable, and the
relay bounds total pending input bytes as well as request count.

Adds tests of every relay action against real owners, the receive claim
limit, release, faulting work, the receipt-key fingerprint marker and the
poll loop through gateway failures.

* fix: refuse SQS serving outside a single-node host cluster

DescribeStore now reports the gateway's declared cluster node count, and
the worker refuses to serve, before reading keys, connecting or creating
any store state, unless that count is exactly one. A gateway that reports
no count reads as zero and is refused.

* feat(sqs): record a store server key pin once per store instance

Add the store role and its internal actions so the bound store instance
can learn its claim and record the public pin of its TLS server key,
once, without the platform holding the private key. The worker sees the
store as bound only after a pin exists and must receive a valid pin.

* refactor(sqs): serve store server keys from a separate capability

Keep the worker Stores interface unchanged and give the store guest's
actions their own ServerKeys dependency, so existing Stores fakes need
no store-key methods.

* feat(sqs): launch the store with a mgmt NIC, CA and scoped store role

The store now boots with the region, gateway URL and the daemon's own
cluster CA, a mgmt NIC to reach the gateway, and an sqsStoreRole profile
granted only the store's two internal actions, so it can authenticate
its server key pin. A store record that predates these fields refuses
to relaunch; its volume is kept.

* feat(sqs): keep the store TLS server key on its disk and record its pin

The store image gains sqs-store-key, run after the data disk is mounted.
It loads or creates a P-256 server key under the disk's tls directory,
records only the key's public pin with the gateway as the bound store
instance, and writes a self-signed certificate for it to the run
directory. nats-server then requires TLS 1.3 with that key.

A record for another volume, a recorded pin with a missing key, or a key
that does not match the recorded pin refuses the store; no key is
regenerated and nothing on the disk is deleted. Launch-not-yet-bound and
lost replies are retried. The store role vocabulary moves to
contracts/sqs/v1 so the guest agent does not import a domain.

* feat(sqs): pin the worker's store connection to the recorded server key

The worker's readiness check and serving connection now use TLS 1.3 and
accept only the store server key whose pin the bound store recorded with
the gateway. The pin is checked before the worker key or any queue or
message data is sent, and on every reconnect. A store with another key,
a plaintext store or a malformed pin is refused as final.

The worker NKey client authentication is unchanged. The host still
holds the store's data disk key, so the pin does not protect the store
from a privileged host.

* fix(sqs): retry store key bootstrap and track guest binary sources

The store's server key check moves from sysinit into the respawned run
step: a gateway or IMDS outage exits after a pause so init retries it,
without remounting or creating a new key, while a missing, changed or
wrong-store key is recorded as refused and never retried. Stop also ends
a running key helper before unmounting.

The guest helper and worker binaries now depend on every main-module
package dir and file they link, from go list, so edits, additions,
deletions and renames all rebuild them.

* feat(sqs): operator enable and status with a signed ingress test

Add a per-Region SQS service record (disabled by default, pinned to one
node, refused on multi-node clusters before any write), gate the relay on
it, and expose `spx admin sqs enable` and `spx admin sqs status`. The
gateway now hands queued work to the polling worker and reports whether
one is polling.

The public SQS service stays unregistered in production until its
capability profile is reviewed. An integration test registers it through
the harness and drives real SigV4 and IAM requests to a real worker for
create, send, receive and delete, asserting no SQS body crosses platform
NATS.

* fix(sqs): report readiness only from observed component state

Status now reports whether the tenant SQS API is registered on this
gateway and is never Ready while it is not. The worker stops polling while
its store connection is down, so a worker that lost its store drops out of
the gateway's poller window instead of looking healthy.

* wip: sqs model inventory

* wip: sqs public registration

* wip: sqs registration review fixes

* fix(sqs): refuse pre-API store and worker records on enable

A store or worker record this release creates now carries an explicit
contract. enable reads both records without creating a bucket and refuses
any record without it before it writes desired state or launches anything;
status names the record and is never Ready. A worker replacement keeps its
predecessor's contract, since it reuses the same data volume.

Missing metadata is only ever grounds to refuse: nothing is upgraded,
inspected, emptied, replaced or deleted. Upgrading a pre-API dev install is
not supported.

* fix(sqs): describe the enable refusal as an unsupported launch contract

A missing or unknown contract proves neither when a record was written nor
that it predates authenticated bootstrap: an older writer can drop a newer
field, and a future contract is not pre-API. The refusal is unchanged; its
message, status field and docs now name a missing or unsupported launch
contract and say any existing volume is kept.

* wip: port reviewed SQS serving path onto dev without the store VM

* wip: single SQS system VM

* fix(sqs): retry receipt bootstrap under supervision and drop store install path

Run the receipt-key boot step from the respawned store service so a
transient gateway or IMDS outage recovers without a reboot; final key
and volume refusals stay refused. Remove the separate store image
install and staging path from setup.

* fix(sqs): align worker ready config fields

* fix(sqs): stop an in-progress boot before unmounting the worker disk

The receipt-key boot step ran as an unrecorded child of the store shell,
so shutdown stopped only the shell and could unmount the disk under a
boot still talking to the gateway. Record the boot child like the other
guest processes and stop it before unmounting; a survivor keeps the disk
mounted.

* fix(sqs): record the boot child before its stopping check

The parent wrote boot.pid only after starting the child, so a shutdown
in that gap could unmount under an unrecorded boot. The child now
records itself and checks stopping before it runs sqs-worker boot.
2026-11-02 09:02:06 +10:00
dependabot[bot] 529acf7ef6 chore: bump the go-dependencies group with 16 updates (#1376)
* chore: bump the go-dependencies group with 16 updates

Bumps the go-dependencies group with 16 updates:

| Package | From | To |
| --- | --- | --- |
| [github.com/aws/aws-sdk-go-v2](https://github.com/aws/aws-sdk-go-v2) | `1.47.4` | `1.47.5` |
| [github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream](https://github.com/aws/aws-sdk-go-v2) | `1.7.24` | `1.7.25` |
| [github.com/aws/aws-sdk-go-v2/config](https://github.com/aws/aws-sdk-go-v2) | `1.33.9` | `1.33.12` |
| [github.com/aws/aws-sdk-go-v2/credentials](https://github.com/aws/aws-sdk-go-v2) | `1.20.9` | `1.20.12` |
| [github.com/aws/aws-sdk-go-v2/feature/ec2/imds](https://github.com/aws/aws-sdk-go-v2) | `1.20.3` | `1.20.5` |
| [github.com/aws/aws-sdk-go-v2/service/lambda](https://github.com/aws/aws-sdk-go-v2) | `1.113.3` | `1.113.4` |
| [github.com/aws/aws-sdk-go-v2/service/s3](https://github.com/aws/aws-sdk-go-v2) | `1.114.4` | `1.114.6` |
| [github.com/aws/aws-sdk-go-v2/service/sqs](https://github.com/aws/aws-sdk-go-v2) | `1.52.6` | `1.52.7` |
| [github.com/aws/smithy-go](https://github.com/aws/smithy-go) | `1.28.6` | `1.28.7` |
| [github.com/nats-io/nats-server/v2](https://github.com/nats-io/nats-server) | `2.15.1` | `2.15.2` |
| [github.com/nats-io/nats.go](https://github.com/nats-io/nats.go) | `1.54.0` | `1.55.0` |
| [go.etcd.io/etcd/api/v3](https://github.com/etcd-io/etcd) | `3.5.35` | `3.7.3` |
| [go.etcd.io/etcd/client/pkg/v3](https://github.com/etcd-io/etcd) | `3.5.35` | `3.7.3` |
| [go.etcd.io/etcd/client/v3](https://github.com/etcd-io/etcd) | `3.5.35` | `3.7.3` |
| [go.yaml.in/yaml/v3](https://github.com/yaml/go-yaml) | `3.0.5` | `3.0.6` |
| [google.golang.org/grpc](https://github.com/grpc/grpc-go) | `1.83.2` | `1.85.0` |


Updates `github.com/aws/aws-sdk-go-v2` from 1.47.4 to 1.47.5
- [Release notes](https://github.com/aws/aws-sdk-go-v2/releases)
- [Commits](https://github.com/aws/aws-sdk-go-v2/compare/v1.47.4...v1.47.5)

Updates `github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream` from 1.7.24 to 1.7.25
- [Release notes](https://github.com/aws/aws-sdk-go-v2/releases)
- [Commits](https://github.com/aws/aws-sdk-go-v2/compare/aws/protocol/eventstream/v1.7.24...aws/protocol/eventstream/v1.7.25)

Updates `github.com/aws/aws-sdk-go-v2/config` from 1.33.9 to 1.33.12
- [Release notes](https://github.com/aws/aws-sdk-go-v2/releases)
- [Commits](https://github.com/aws/aws-sdk-go-v2/compare/config/v1.33.9...config/v1.33.12)

Updates `github.com/aws/aws-sdk-go-v2/credentials` from 1.20.9 to 1.20.12
- [Release notes](https://github.com/aws/aws-sdk-go-v2/releases)
- [Commits](https://github.com/aws/aws-sdk-go-v2/compare/service/kms/v1.20.9...service/sso/v1.20.12)

Updates `github.com/aws/aws-sdk-go-v2/feature/ec2/imds` from 1.20.3 to 1.20.5
- [Release notes](https://github.com/aws/aws-sdk-go-v2/releases)
- [Commits](https://github.com/aws/aws-sdk-go-v2/compare/v1.20.3...service/mq/v1.20.5)

Updates `github.com/aws/aws-sdk-go-v2/service/lambda` from 1.113.3 to 1.113.4
- [Release notes](https://github.com/aws/aws-sdk-go-v2/releases)
- [Commits](https://github.com/aws/aws-sdk-go-v2/compare/service/s3/v1.113.3...service/s3/v1.113.4)

Updates `github.com/aws/aws-sdk-go-v2/service/s3` from 1.114.4 to 1.114.6
- [Release notes](https://github.com/aws/aws-sdk-go-v2/releases)
- [Commits](https://github.com/aws/aws-sdk-go-v2/compare/service/s3/v1.114.4...service/s3/v1.114.6)

Updates `github.com/aws/aws-sdk-go-v2/service/sqs` from 1.52.6 to 1.52.7
- [Release notes](https://github.com/aws/aws-sdk-go-v2/releases)
- [Commits](https://github.com/aws/aws-sdk-go-v2/compare/service/ssm/v1.52.6...service/sqs/v1.52.7)

Updates `github.com/aws/smithy-go` from 1.28.6 to 1.28.7
- [Release notes](https://github.com/aws/smithy-go/releases)
- [Changelog](https://github.com/aws/smithy-go/blob/main/CHANGELOG.md)
- [Commits](https://github.com/aws/smithy-go/compare/v1.28.6...v1.28.7)

Updates `github.com/nats-io/nats-server/v2` from 2.15.1 to 2.15.2
- [Release notes](https://github.com/nats-io/nats-server/releases)
- [Changelog](https://github.com/nats-io/nats-server/blob/main/RELEASES.md)
- [Commits](https://github.com/nats-io/nats-server/compare/v2.15.1...v2.15.2)

Updates `github.com/nats-io/nats.go` from 1.54.0 to 1.55.0
- [Release notes](https://github.com/nats-io/nats.go/releases)
- [Commits](https://github.com/nats-io/nats.go/compare/v1.54.0...v1.55.0)

Updates `go.etcd.io/etcd/api/v3` from 3.5.35 to 3.7.3
- [Release notes](https://github.com/etcd-io/etcd/releases)
- [Commits](https://github.com/etcd-io/etcd/compare/v3.5.35...v3.7.3)

Updates `go.etcd.io/etcd/client/pkg/v3` from 3.5.35 to 3.7.3
- [Release notes](https://github.com/etcd-io/etcd/releases)
- [Commits](https://github.com/etcd-io/etcd/compare/v3.5.35...v3.7.3)

Updates `go.etcd.io/etcd/client/v3` from 3.5.35 to 3.7.3
- [Release notes](https://github.com/etcd-io/etcd/releases)
- [Commits](https://github.com/etcd-io/etcd/compare/v3.5.35...v3.7.3)

Updates `go.yaml.in/yaml/v3` from 3.0.5 to 3.0.6
- [Commits](https://github.com/yaml/go-yaml/compare/v3.0.5...v3.0.6)

Updates `google.golang.org/grpc` from 1.83.2 to 1.85.0
- [Release notes](https://github.com/grpc/grpc-go/releases)
- [Commits](https://github.com/grpc/grpc-go/compare/v1.83.2...v1.85.0)

---
updated-dependencies:
- dependency-name: github.com/aws/aws-sdk-go-v2
  dependency-version: 1.47.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go-dependencies
- dependency-name: github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream
  dependency-version: 1.7.25
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go-dependencies
- dependency-name: github.com/aws/aws-sdk-go-v2/config
  dependency-version: 1.33.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go-dependencies
- dependency-name: github.com/aws/aws-sdk-go-v2/credentials
  dependency-version: 1.20.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go-dependencies
- dependency-name: github.com/aws/aws-sdk-go-v2/feature/ec2/imds
  dependency-version: 1.20.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go-dependencies
- dependency-name: github.com/aws/aws-sdk-go-v2/service/lambda
  dependency-version: 1.113.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go-dependencies
- dependency-name: github.com/aws/aws-sdk-go-v2/service/s3
  dependency-version: 1.114.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go-dependencies
- dependency-name: github.com/aws/aws-sdk-go-v2/service/sqs
  dependency-version: 1.52.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go-dependencies
- dependency-name: github.com/aws/smithy-go
  dependency-version: 1.28.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go-dependencies
- dependency-name: github.com/nats-io/nats-server/v2
  dependency-version: 2.15.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go-dependencies
- dependency-name: github.com/nats-io/nats.go
  dependency-version: 1.55.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-dependencies
- dependency-name: go.etcd.io/etcd/api/v3
  dependency-version: 3.7.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-dependencies
- dependency-name: go.etcd.io/etcd/client/pkg/v3
  dependency-version: 3.7.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-dependencies
- dependency-name: go.etcd.io/etcd/client/v3
  dependency-version: 3.7.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-dependencies
- dependency-name: go.yaml.in/yaml/v3
  dependency-version: 3.0.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go-dependencies
- dependency-name: google.golang.org/grpc
  dependency-version: 1.85.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>

* fix: embed UnimplementedClusterServer in fakeetcd for etcd v3.7

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Julian Sommer <115516918+juliansommer@users.noreply.github.com>
2026-11-02 09:27:57 +11:00
Julian Sommer 44f71e95d8 chore: fix ui lint issues 2026-11-02 08:24:37 +10:00
dependabot[bot] d6c9efb8c3 chore: bump the npm-dependencies group (#1377)
Bumps the npm-dependencies group in /spinifex/runtime/roles/spinifexui/frontend with 30 updates:

| Package | From | To |
| --- | --- | --- |
| [@aws-sdk/client-acm](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-acm) | `3.1155.0` | `3.1159.0` |
| [@aws-sdk/client-bedrock](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-bedrock) | `3.1155.0` | `3.1159.0` |
| [@aws-sdk/client-bedrock-agent](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-bedrock-agent) | `3.1155.0` | `3.1159.0` |
| [@aws-sdk/client-bedrock-agent-runtime](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-bedrock-agent-runtime) | `3.1155.0` | `3.1159.0` |
| [@aws-sdk/client-bedrock-runtime](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-bedrock-runtime) | `3.1155.0` | `3.1159.0` |
| [@aws-sdk/client-ec2](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-ec2) | `3.1155.0` | `3.1159.0` |
| [@aws-sdk/client-ecr](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-ecr) | `3.1155.0` | `3.1159.0` |
| [@aws-sdk/client-ecs](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-ecs) | `3.1155.0` | `3.1159.0` |
| [@aws-sdk/client-eks](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-eks) | `3.1155.0` | `3.1159.0` |
| [@aws-sdk/client-elastic-load-balancing-v2](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-elastic-load-balancing-v2) | `3.1155.0` | `3.1159.0` |
| [@aws-sdk/client-iam](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-iam) | `3.1155.0` | `3.1159.0` |
| [@aws-sdk/client-rds](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-rds) | `3.1155.0` | `3.1159.0` |
| [@aws-sdk/client-route-53](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-route-53) | `3.1156.0` | `3.1159.0` |
| [@aws-sdk/client-s3](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-s3) | `3.1155.0` | `3.1159.0` |
| [@aws-sdk/client-sts](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-sts) | `3.1155.0` | `3.1159.0` |
| [@aws-sdk/lib-storage](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/lib/lib-storage) | `3.1155.0` | `3.1159.0` |
| [@aws-sdk/xhr-http-handler](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/packages/xhr-http-handler) | `3.1155.0` | `3.1159.0` |
| [@hookform/resolvers](https://github.com/react-hook-form/resolvers) | `6.0.0` | `6.0.2` |
| [@tanstack/react-query](https://github.com/TanStack/query/tree/HEAD/packages/react-query) | `5.104.5` | `5.105.0` |
| [@tanstack/react-router](https://github.com/TanStack/router/tree/HEAD/packages/react-router) | `1.170.45` | `1.170.46` |
| [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) | `1.61.0` | `1.63.0` |
| [react-hook-form](https://github.com/react-hook-form/react-hook-form) | `7.90.0` | `7.91.0` |
| [react-resizable-panels](https://github.com/bvaughn/react-resizable-panels) | `4.15.0` | `4.15.1` |
| [@tanstack/eslint-plugin-query](https://github.com/TanStack/query/tree/HEAD/packages/eslint-plugin-query) | `5.104.5` | `5.105.0` |
| [@tanstack/router-plugin](https://github.com/TanStack/router/tree/HEAD/packages/router-plugin) | `1.168.47` | `1.168.48` |
| [oxc-transform-react](https://github.com/oxc-project/oxc/tree/HEAD/napi/transform-react) | `0.155.0` | `0.156.0` |
| [oxfmt](https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt) | `0.74.0` | `0.75.0` |
| [oxlint](https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint) | `1.89.0` | `1.90.0` |
| [oxlint-tsgolint](https://github.com/oxc-project/tsgolint) | `7.0.2004` | `7.0.2005` |
| [ultracite](https://github.com/haydenbleasel/ultracite) | `8.0.1` | `8.1.0` |


Updates `@aws-sdk/client-acm` from 3.1155.0 to 3.1159.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-acm/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1159.0/clients/client-acm)

Updates `@aws-sdk/client-bedrock` from 3.1155.0 to 3.1159.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-bedrock/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1159.0/clients/client-bedrock)

Updates `@aws-sdk/client-bedrock-agent` from 3.1155.0 to 3.1159.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-bedrock-agent/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1159.0/clients/client-bedrock-agent)

Updates `@aws-sdk/client-bedrock-agent-runtime` from 3.1155.0 to 3.1159.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-bedrock-agent-runtime/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1159.0/clients/client-bedrock-agent-runtime)

Updates `@aws-sdk/client-bedrock-runtime` from 3.1155.0 to 3.1159.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-bedrock-runtime/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1159.0/clients/client-bedrock-runtime)

Updates `@aws-sdk/client-ec2` from 3.1155.0 to 3.1159.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-ec2/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1159.0/clients/client-ec2)

Updates `@aws-sdk/client-ecr` from 3.1155.0 to 3.1159.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-ecr/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1159.0/clients/client-ecr)

Updates `@aws-sdk/client-ecs` from 3.1155.0 to 3.1159.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-ecs/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1159.0/clients/client-ecs)

Updates `@aws-sdk/client-eks` from 3.1155.0 to 3.1159.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-eks/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1159.0/clients/client-eks)

Updates `@aws-sdk/client-elastic-load-balancing-v2` from 3.1155.0 to 3.1159.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-elastic-load-balancing-v2/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1159.0/clients/client-elastic-load-balancing-v2)

Updates `@aws-sdk/client-iam` from 3.1155.0 to 3.1159.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-iam/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1159.0/clients/client-iam)

Updates `@aws-sdk/client-rds` from 3.1155.0 to 3.1159.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-rds/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1159.0/clients/client-rds)

Updates `@aws-sdk/client-route-53` from 3.1156.0 to 3.1159.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-route-53/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1159.0/clients/client-route-53)

Updates `@aws-sdk/client-s3` from 3.1155.0 to 3.1159.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-s3/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1159.0/clients/client-s3)

Updates `@aws-sdk/client-sts` from 3.1155.0 to 3.1159.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-sts/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1159.0/clients/client-sts)

Updates `@aws-sdk/lib-storage` from 3.1155.0 to 3.1159.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/lib/lib-storage/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1159.0/lib/lib-storage)

Updates `@aws-sdk/xhr-http-handler` from 3.1155.0 to 3.1159.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/packages/xhr-http-handler/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1159.0/packages/xhr-http-handler)

Updates `@hookform/resolvers` from 6.0.0 to 6.0.2
- [Release notes](https://github.com/react-hook-form/resolvers/releases)
- [Commits](https://github.com/react-hook-form/resolvers/compare/v6.0.0...v6.0.2)

Updates `@tanstack/react-query` from 5.104.5 to 5.105.0
- [Release notes](https://github.com/TanStack/query/releases)
- [Changelog](https://github.com/TanStack/query/blob/main/packages/react-query/CHANGELOG.md)
- [Commits](https://github.com/TanStack/query/commits/@tanstack/react-query@5.105.0/packages/react-query)

Updates `@tanstack/react-router` from 1.170.45 to 1.170.46
- [Release notes](https://github.com/TanStack/router/releases)
- [Changelog](https://github.com/TanStack/router/blob/main/packages/react-router/CHANGELOG.md)
- [Commits](https://github.com/TanStack/router/commits/@tanstack/react-router@1.170.46/packages/react-router)

Updates `lucide-react` from 1.61.0 to 1.63.0
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.63.0/packages/lucide-react)

Updates `react-hook-form` from 7.90.0 to 7.91.0
- [Release notes](https://github.com/react-hook-form/react-hook-form/releases)
- [Changelog](https://github.com/react-hook-form/react-hook-form/blob/master/CHANGELOG.md)
- [Commits](https://github.com/react-hook-form/react-hook-form/compare/v7.90.0...v7.91.0)

Updates `react-resizable-panels` from 4.15.0 to 4.15.1
- [Release notes](https://github.com/bvaughn/react-resizable-panels/releases)
- [Changelog](https://github.com/bvaughn/react-resizable-panels/blob/main/CHANGELOG.md)
- [Commits](https://github.com/bvaughn/react-resizable-panels/compare/4.15.0...4.15.1)

Updates `@tanstack/eslint-plugin-query` from 5.104.5 to 5.105.0
- [Release notes](https://github.com/TanStack/query/releases)
- [Changelog](https://github.com/TanStack/query/blob/main/packages/eslint-plugin-query/CHANGELOG.md)
- [Commits](https://github.com/TanStack/query/commits/@tanstack/eslint-plugin-query@5.105.0/packages/eslint-plugin-query)

Updates `@tanstack/router-plugin` from 1.168.47 to 1.168.48
- [Release notes](https://github.com/TanStack/router/releases)
- [Changelog](https://github.com/TanStack/router/blob/main/packages/router-plugin/CHANGELOG.md)
- [Commits](https://github.com/TanStack/router/commits/@tanstack/router-plugin@1.168.48/packages/router-plugin)

Updates `oxc-transform-react` from 0.155.0 to 0.156.0
- [Release notes](https://github.com/oxc-project/oxc/releases)
- [Changelog](https://github.com/oxc-project/oxc/blob/main/napi/transform-react/CHANGELOG.md)
- [Commits](https://github.com/oxc-project/oxc/commits/crates_v0.156.0/napi/transform-react)

Updates `oxfmt` from 0.74.0 to 0.75.0
- [Release notes](https://github.com/oxc-project/oxc/releases)
- [Changelog](https://github.com/oxc-project/oxc/blob/main/npm/oxfmt/CHANGELOG.md)
- [Commits](https://github.com/oxc-project/oxc/commits/oxfmt_v0.75.0/npm/oxfmt)

Updates `oxlint` from 1.89.0 to 1.90.0
- [Release notes](https://github.com/oxc-project/oxc/releases)
- [Changelog](https://github.com/oxc-project/oxc/blob/main/npm/oxlint/CHANGELOG.md)
- [Commits](https://github.com/oxc-project/oxc/commits/oxlint_v1.90.0/npm/oxlint)

Updates `oxlint-tsgolint` from 7.0.2004 to 7.0.2005
- [Release notes](https://github.com/oxc-project/tsgolint/releases)
- [Commits](https://github.com/oxc-project/tsgolint/compare/v7.0.2004...v7.0.2005)

Updates `ultracite` from 8.0.1 to 8.1.0
- [Release notes](https://github.com/haydenbleasel/ultracite/releases)
- [Commits](https://github.com/haydenbleasel/ultracite/compare/ultracite@8.0.1...ultracite@8.1.0)

---
updated-dependencies:
- dependency-name: "@aws-sdk/client-acm"
  dependency-version: 3.1159.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@aws-sdk/client-bedrock"
  dependency-version: 3.1159.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@aws-sdk/client-bedrock-agent"
  dependency-version: 3.1159.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@aws-sdk/client-bedrock-agent-runtime"
  dependency-version: 3.1159.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@aws-sdk/client-bedrock-runtime"
  dependency-version: 3.1159.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@aws-sdk/client-ec2"
  dependency-version: 3.1159.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@aws-sdk/client-ecr"
  dependency-version: 3.1159.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@aws-sdk/client-ecs"
  dependency-version: 3.1159.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@aws-sdk/client-eks"
  dependency-version: 3.1159.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@aws-sdk/client-elastic-load-balancing-v2"
  dependency-version: 3.1159.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@aws-sdk/client-iam"
  dependency-version: 3.1159.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@aws-sdk/client-rds"
  dependency-version: 3.1159.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@aws-sdk/client-route-53"
  dependency-version: 3.1159.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@aws-sdk/client-s3"
  dependency-version: 3.1159.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@aws-sdk/client-sts"
  dependency-version: 3.1159.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@aws-sdk/lib-storage"
  dependency-version: 3.1159.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@aws-sdk/xhr-http-handler"
  dependency-version: 3.1159.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@hookform/resolvers"
  dependency-version: 6.0.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: "@tanstack/react-query"
  dependency-version: 5.105.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@tanstack/react-router"
  dependency-version: 1.170.46
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: lucide-react
  dependency-version: 1.63.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: react-hook-form
  dependency-version: 7.91.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: react-resizable-panels
  dependency-version: 4.15.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: "@tanstack/eslint-plugin-query"
  dependency-version: 5.105.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@tanstack/router-plugin"
  dependency-version: 1.168.48
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: oxc-transform-react
  dependency-version: 0.156.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: oxfmt
  dependency-version: 0.75.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: oxlint
  dependency-version: 1.90.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: oxlint-tsgolint
  dependency-version: 7.0.2005
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: ultracite
  dependency-version: 8.1.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-02 09:21:54 +11:00
dependabot[bot] bbd4e84f15 chore: bump the docker-images group across 3 directories with 3 updates (#1375)
Bumps the docker-images group with 1 update in the /build directory: golang.
Bumps the docker-images group with 2 updates in the /docs/terraform-workbooks/demo-app directory: golang and distroless/static.
Bumps the docker-images group with 1 update in the /scripts directory: debian.


Updates `golang` from `972b411` to `d9b6a1b`

Updates `golang` from `f2c872b` to `d200d2d`

Updates `distroless/static` from `18f4cc2` to `25ac8f0`

Updates `debian` from `62bd423` to `f6b08b0`

---
updated-dependencies:
- dependency-name: golang
  dependency-version: 1.27.3-bookworm
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: docker-images
- dependency-name: golang
  dependency-version: 1.27.3-alpine
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: docker-images
- dependency-name: distroless/static
  dependency-version: nonroot
  dependency-type: direct:production
  dependency-group: docker-images
- dependency-name: debian
  dependency-version: trixie-slim
  dependency-type: direct:production
  dependency-group: docker-images
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-11-02 09:05:45 +11:00
Ben Duncan 14f6ea83d0 fix: root-cause every flaky unit test from the last 14 days of the PR gate (#1395)
* fix(test): never hand KillProcess a PID a real process can hold

* fix(iam): queue same-record writers so a looping self-write cannot starve DetachUserPolicy

* fix(ecs-agent): report RUNNING before starting container exit-waits

* fix(rds): wait for the lease-loss cancellation instead of sleeping in the takeover test

* fix(cli): name the account when the follow deadline lands mid-poll

* test(imagecatalog): never answer from the deadline fixture

* test(nats): order Gather StopOnFirst frames causally, not by sleep

* fix(ec2/key): find a key pair by name past an unreadable metadata record

* fix(accountlifecycle): re-list at the drain deadline so a just-deleted resource is not stuck

* fix(eks): give only the exhausting subtest the short SG delete budget

* fix(eks): stop healthz and nodegroup Ready-gate tests racing fixed wall-clock windows

* fix(test): let in-process NATS clusters bind their own route ports

* fix(objectstore): list the memory store in S3 key order with real paging

* test(viperblock): create a fresh volume on every run of the local-state test

* test(ochre): close router streams so drained tests release their admission slot

* test(cli): restore shared cobra flags after each ochre command test

* test(daemon): keep shared-JetStream, metric and RSS tests correct on repeat runs

* fix(ochre): let a warm-up probe stop so a test's poller cannot hit a later test's server

* test(cli): pin the package's cluster size so a multi-node config load cannot strand later fixtures
2026-11-01 15:05:04 -06:00
Josh Siviour 30b8c24776 feat(accountlifecycle): seal quota overrides during account teardown (#1390)
* feat(admission): add the quota override seal capability contract

* feat(awsgw): serve the quota override seal capability per node

* refactor(accountlifecycle): parameterise the seal capability probe and add the quota one

* test(accountlifecycle): pin the probe wire shape against both capability contracts

* feat(accountlifecycle): seal the quota override record during account teardown

* docs: record the quota override seal contract and teardown behaviour

* docs: fill in the quota override seal PR number in the package boundary migration
2026-11-02 06:59:06 +10:00
Ben Duncan 8855d60c94 fix(ci): release the ISO build's root-owned cache when the ISO cells end (#1393)
The ISO cells build the rootfs as root inside the workspace and only cleaned
it at the start of their own job, so the next job on the same runner from any
workflow failed its checkout on files it could not remove.
2026-11-01 10:46:41 -06:00
Ben Duncan d420bff09c fix(eks): delete the Node of a terminated nodegroup worker (#1392)
* fix(eks): delete the Node of a terminated nodegroup worker

K3s runs no AWS cloud provider, and its own reports every instance as
existing, so a worker terminated by DeleteNodegroup, scale-down or reclaim
stayed a NotReady Node forever. Its pods stayed bound to it, and once most
Nodes were NotReady the node lifecycle controller stopped evicting, leaving
metrics-server on a dead Node.

Add an internal-workers route the control plane pulls the cluster's live
nodegroup workers from, and a mulga-eks-node-gc agent on every server that
deletes a nodegroup Node only when it is not Ready and its instance is no
longer one of those workers, as the AWS cloud controller does.

* test(e2e): check the live worker's Node and wait out nodegroup deletes

WorkerNodeReady accepted any Ready Node, the tolerate-all DaemonSet check
dropped pods with no Ready condition, and nodegroup cleanup returned before
the nodegroup was gone. Scope the Ready check to the CPIsolation nodegroup,
count a pod with no Ready condition as not Ready, wait for each nodegroup
delete, and assert that deleted nodegroups leave no Node behind.

* test(integration): account for the internal ListInternalWorkers route in the cross-tenant table
2026-11-01 10:46:36 -06:00
Ben Duncan 6c3d6c6be3 fix(setup): keep NetworkManager from replacing the resolved stub link (#1391)
NetworkManager restarted while /etc/resolv.conf is a plain file runs in
rc-manager=symlink mode and writes resolv.conf on its first DNS update, which
can land after the installer links the stub. Write a conf.d drop-in
(dns=systemd-resolved, rc-manager=unmanaged) before the restart, refuse a
later file that overrides rc-manager, wait for NetworkManager to settle, and
assert the link holds with bounded relinks. A rerun after an older handover
adds the drop-in, reloads NetworkManager and relinks the stub.
2026-11-01 10:30:43 -06:00
Josh Siviour 2466a9c8af fix: stamp the ENI owner in the attach write and release a failed LB's VM (#1388)
* test: pin the ENI owner stamp and failed-LB cleanup contracts

Failing tests: an ENI update after the caller's own write must survive a
lagging replica read, an unwritable owner stamp must fail the system
launch, and the reaper must terminate the failed LB's own VM only.

* fix: stamp the ENI owner in the attach write and release a failed LB's VM

A lagging-replica read before the owner stamp's compare-and-swap lost the
stamp, leaving a system VM that IMDS could never resolve.

- AttachENIWithOwner records the instance owner in the attach write.
- ENI read-modify-writes re-read and re-apply on a revision conflict,
  bounded, and report the store error to the caller's log.
- A system launch whose ENI cannot be attached or stamped fails and cleans
  up the VM.
- The reaper that marks an LB failed terminates that LB's own VM and
  detaches its ENIs.

* fix: log ENI write retries and exhaustion

An Info line on a write that succeeds after a revision conflict and an
Error line when the bounded retries run out, so the sink can count both.

* test: cover an internal ALB in a private subnet and report the ENI attachment owner

DescribeNetworkInterfaces now returns Attachment.InstanceOwnerId when the record has one, as AWS does, so the owner stamp is observable. The e2e launches an internal ALB in a VPC with no internet gateway and checks the owner, the private address and in-VPC traffic. The ENI write error log now reports the real attempt count, so a non-conflict store error is not counted as an exhausted retry.

* revert: stop reporting the ENI attachment owner through Describe

AWS always returns Attachment.InstanceOwnerId, so reporting it only for system-owned ENIs adds a divergence. The e2e now relies on the LB reaching active with a healthy target, which needs the owner stamp to resolve.
2026-11-01 22:39:34 +10:00
Josh Siviour f37776384c docs: fill in the 11d-2 PR number in the package boundary migration (#1386) 2026-11-01 20:53:26 +10:00
Josh Siviour ea1ca38c52 fix: boot LB and SQS worker microvms with PCIe NICs (#1385)
* test: direct-boot LB and SQS worker NICs must be virtio-net-pci on microvm pcie=on

* fix: boot LB and SQS worker microvms on PCIe with virtio-net-pci NICs

virtio-mmio devices share level-triggered IRQ lines and a NIC's RX path
stalled on them. The direct-boot machine gets pcie=on, so NICs are
virtio-net-pci with MSI-X, and the virtio_mmio.device= kernel args go.
IsMMIO is now the transport check (false with pcie=on) and IsMicrovm the
machine check. Data volumes keep their serial and ID on virtio-blk-pci.
The LB guest console loglevel is raised to 7.

* refactor: trim microvmMachineType comment

* docs: describe direct-boot microVM NICs and data volumes on PCIe
2026-11-01 20:51:40 +10:00
Josh Siviour 8add7c03b3 refactor: move the gateway into ingress/aws/pipeline and delete it (11d-2) (#1384)
* refactor: move the gateway into ingress/aws/pipeline and retire the gateway layering rows

The gateway's request pipeline joins the packages it already consumed.
Operation inventory moves to ingress/aws/dispatch. Files keep their names;
only the package line, the import paths and the now-redundant pipeline
qualifier change. The layering rows for the gateway root are removed: the
package is under ingress, so R2 binds it, and the daemon-direction test now
asserts R2 rejects runtime, operator and domain imports.

An empty registry_helper_test.go was dropped rather than moved.

* refactor: rename pipeline.GatewayConfig to pipeline.Config

* docs: update the design and package boundary docs for the gateway move

* docs: point the EKS add-on lifecycle evidence at the tests' current files
2026-11-01 20:42:27 +10:00
Ben Duncan 34f7b4d8e5 fix(vm): reboot by relaunching QEMU after a clean guest shutdown (#1383)
RebootInstances powered the guest down under -action shutdown=pause and
then ran system_reset + cont in the same QEMU process. On some hosts the
reset left the guest silent in firmware with QMP still answering, while a
fresh QEMU on the same volumes always booted.

A reboot now shuts the guest down over ACPI (forcing it off after 60s, as
before), ends that QEMU, and starts a fresh one on the same mounted volumes,
taps, IMDS datapath and identity, the way nova's soft reboot and Proxmox's
qm reboot do. The instance stays running in Describe, no hooks fire, the
console log is appended rather than truncated, and the existing QMP client
is reconnected in place so the heartbeat carries on.

A relaunched guest that writes nothing to its console within 60s has its
run state, vCPUs, vCPU thread states and registers (sampled twice) logged,
and if it has printed before, its QEMU is replaced once more. A relaunch
that cannot start goes to crash recovery. Stop, terminate and hot-plug
wait for a relaunch in flight so volumes are never sealed under a process
being started. Guest-initiated reboots stay an in-process reset.

The daemon now logs the host kernel, QEMU version and the OVMF package
and checksum at start, and every reboot relaunch carries them.
2026-11-01 02:48:00 -06:00
Ben Duncan 8e5680f77f fix(e2e): harden nightly e2e against seven false-red signatures; release auto-assigned IP before stopped (#1381)
* fix(vm): release an auto-assigned public IP before a stop publishes stopped

* fix(e2e): skip a TryReady caller once the suite's SSH datapath is marked broken

* fix(e2e): skip a catalog entry whose third-party mirror is unreachable, still fail on a non-2xx

* fix(e2e): retry the diskperf fio install with backoff before calling it an infra fault

* fix(e2e): make the storagefault root-load check observe the load it started

* fix(ci): give the single suite a 40m budget and the nightly cells that run it 60m

* fix(ci): fail a nightly cell fast when a hypervisor it provisions on is out of disk
2026-11-01 01:49:27 -05:00
Ben Duncan cf31090043 fix(fence): stop the writer first, deliver the fence notice, kill the fenced guest (#1382)
Three defects in the fence path made partition recovery fail:

- onVolumeLeaseLost read the lease holder before killing nbdkit, with no
  deadline, spending the 5s margin between local validity and the server
  TTL. The kill now comes first and the read is bounded at 2s.
- The fence notice was one NATS publish, lost when the node's NATS
  restarts mid-fence. It is now a request repeated until the daemon
  acknowledges, dropped once the volume is exported here again.
- A fenced guest got a 20s ACPI powerdown, a 20s pidfile wait and a
  SIGTERM, keeping QEMU alive while a survivor relaunched it. It is now
  SIGKILLed, and resumeAfterIOError never resumes a fenced guest.
2026-11-01 01:49:22 -05:00
Ben Duncan 0604472ffe fix(vpcd): converge every node's VPC ingress routes from intent (#1380)
The routed-NAT host route to a VPC CIDR was installed only by the vpcd
that received the IGW attach event, which goes through a queue group,
and by whichever node won a leased drift pass. Every other node waited
for the five-minute resync, and with two VPCs on one CIDR each node kept
whichever route it met first.

Each node now runs a host ingress loop outside the drift lease. It reads
intent and the gateway LRPs on every intent write, on a nudge from the
local IGW hooks and every 30s, installs or repoints the routes, and
prunes tagged routes no attached VPC holds. A shared CIDR goes to the
bootstrap VPC, then to the lowest VPC ID.
2026-11-01 01:49:17 -05:00
Ben Duncan 956b830f18 fix(firewall): keep conntrack running while the host policy is disarmed (#1379)
The kernel tracks connections only while some rule uses conntrack. With
inet spinifex_filter unloaded nothing tracked, so OVN RAFT links formed
while disarmed (or before the daemon's first arm) were first seen
mid-stream at arm time. With tcp_loose a packet from the server end became
a new flow from peer:6643/6644 to an ephemeral port and hit the drop rule,
and RAFT lost its leader.

Add table inet spinifex_conntrack (accept-only) and keep it loaded on
every node: setup.sh loads it at install, the boot unit loads it
unconditionally, firstboot loads it before OVN, and disable swaps the
policy for it in one transaction instead of turning conntrack off.
2026-11-01 01:49:12 -05:00
Josh Siviour f0a704cfcd refactor: move the gateway's public routes, admin wiring and unread config out (11d-1) (#1378)
* test: pin the order of the unauthenticated mounts against SigV4 and the throttle

* refactor: mount the OIDC and registry routes through a public-routes hook

* refactor: take the admin registration name and request-id context as gateway config

* refactor: move node discovery out of the gateway into the awsgw role

* refactor: drop the gateway config fields production never reads (Config, RootCAs, InternalSuffix)

* refactor: drop the gateway Version and Commit config fields

* refactor: compose the production gateway config in one tested function

* refactor: drop the gateway IAMService, STSService, NATSConn and ExpectedNodes config fields

Production never reads them: composeGateway builds Policy and Readiness from
its own inputs. Tests that used the config as a carrier now keep those
collaborators in gwfix.Deps, recorded per config with gwfix.WithDeps and read
back with gwfix.DepsOf. Gateway non-test code now imports nothing under
domains/, runtime/ or operator/.

Test edits are setup only: 153 GatewayConfig literals in 45 files had the four
keys moved into gwfix.WithDeps(..., gwfix.Deps{...}), and 113 field reads
became gwfix.DepsOf(x).{IAM,STS,NATS,Nodes}. No assertion changed.

Script 1, literal rewrite (balanced-brace scanner, keys moved out of the
literal):

    import re,sys,subprocess,os
    KEYS={'IAMService':'IAM','STSService':'STS','NATSConn':'NATS','ExpectedNodes':'Nodes'}
    def scan_close(s,i):
        # s[i]=='{' ; return index of matching '}' ; skips strings, runes, comments
        d=0;n=len(s);k=i
        while k<n:
            c=s[k]
            if c=='"':
                k+=1
                while s[k]!='"':
                    k+= 2 if s[k]=='\\' else 1
            elif c=='`':
                k=s.index('`',k+1)
            elif c=="'":
                k+=1
                while s[k]!="'":
                    k+= 2 if s[k]=='\\' else 1
            elif s.startswith('//',k):
                k=s.index('\n',k)
                continue
            elif s.startswith('/*',k):
                k=s.index('*/',k)+1
            elif c in '{([': d+=1
            elif c in '})]':
                d-=1
                if d==0: return k
            k+=1
        raise Exception('unbalanced')
    def split_top(body):
        parts=[];d=0;start=0;k=0;n=len(body)
        while k<n:
            c=body[k]
            if c=='"':
                k+=1
                while body[k]!='"': k+= 2 if body[k]=='\\' else 1
            elif c=='`': k=body.index('`',k+1)
            elif c=="'":
                k+=1
                while body[k]!="'": k+= 2 if body[k]=='\\' else 1
            elif body.startswith('//',k):
                k=body.index('\n',k); continue
            elif body.startswith('/*',k): k=body.index('*/',k)+1
            elif c in '{([': d+=1
            elif c in '})]': d-=1
            elif c==',' and d==0:
                parts.append(body[start:k]); start=k+1
            k+=1
        parts.append(body[start:])
        return parts
    changed={}
    files=[l for l in subprocess.check_output(['git','ls-files','*.go']).decode().split() if os.path.exists(l)]
    SKIP={'spinifex/gateway/gateway.go','spinifex/runtime/roles/awsgw/compose.go','spinifex/runtime/roles/awsgw/awsgw.go'}
    lit=re.compile(r'(&?)((?:gateway\.)?GatewayConfig)\{')
    total=0
    for f in files:
        if f in SKIP: continue
        s=open(f).read()
        if 'GatewayConfig{' not in s: continue
        out=[];pos=0;cnt=0
        for m in lit.finditer(s):
            if m.start()<pos: continue
            o=m.end()-1
            c=scan_close(s,o)
            body=s[o+1:c]
            parts=split_top(body)
            keep=[];moved=[]
            for p in parts:
                km=re.match(r'\s*(IAMService|STSService|NATSConn|ExpectedNodes)\s*:\s*(.*)$',p,re.S)
                if km: moved.append((KEYS[km.group(1)],km.group(2).strip()))
                else: keep.append(p)
            if not moved: continue
            # rebuild
            kb=','.join(keep)
            if kb.strip()=='' : kb=''
            elif not kb.rstrip().endswith('\n') and '\n' in body: pass
            d=', '.join(f'{k}: {v}' for k,v in moved)
            new=f'gwfix.WithDeps({m.group(1)}{m.group(2)}{{{kb}}}, gwfix.Deps{{{d}}})'
            if not m.group(1): new=f'gwfix.WithDeps(&{m.group(2)}{{{kb}}}, gwfix.Deps{{{d}}})'  # value literals flagged below
            out.append((m.start(),c+1,new)); pos=c+1; cnt+=1
            if not m.group(1): print('VALUE LITERAL',f,s.count('\n',0,m.start())+1)
        if not out: continue
        r=[];last=0
        for a,b,n in out:
            r.append(s[last:a]);r.append(n);last=b
        r.append(s[last:])
        s2=''.join(r)
        if 'testkit/gwfix"' not in s2:
            s2=re.sub(r'import \(\n','import (\n\t"github.com/mulgadc/spinifex/internal/testkit/gwfix"\n',s2,1)
        open(f,'w').write(s2); total+=cnt; changed[f]=cnt
    print('literals rewritten',total,'files',len(changed))

Script 2, read rewrite (driven by the compiler's "has no field or method"
errors for the type, so only GatewayConfig receivers are touched):

    import re,subprocess,sys,collections
    M={'IAMService':'IAM','STSService':'STS','NATSConn':'NATS','ExpectedNodes':'Nodes'}
    def run():
        env=dict(__import__('os').environ,GOWORK='off')
        out=''
        for tags,pk in [('',['./spinifex/gateway','./spinifex/runtime/roles/awsgw/...']),('integration',['./tests/integration/...'])]:
            cmd=['go','test','-count=1','-run','NONE_X','-gcflags=-e']+(['-tags',tags] if tags else [])+pk
            r=subprocess.run(cmd,capture_output=True,text=True,env=env)
            out+=r.stdout+r.stderr
        return out
    fixed=0
    for it in range(8):
        out=run()
        errs=re.findall(r'^(\S+\.go):(\d+):(\d+): (.*?) undefined \(type (\*?(?:gateway\.)?GatewayConfig) has no field or method (\w+)\)',out,re.M)
        if not errs:
            print(out[-3000:]); break
        byfile=collections.defaultdict(list)
        for f,l,c,expr,t,fld in errs: byfile[f].append((int(l),int(c),expr,fld))
        for f,es in byfile.items():
            lines=open(f).read().split('\n')
            for l,c,expr,fld in sorted(set(es),reverse=True):
                line=lines[l-1]
                # expr like 'gw.IAMService' or 'x.y.IAMService'
                new=f'gwfix.DepsOf({expr[:-len(fld)-1]}).{M[fld]}'
                if expr not in line: print('MISS',f,l,expr); continue
                lines[l-1]=line.replace(expr,new)
                fixed+=1
            s='\n'.join(lines)
            if 'testkit/gwfix"' not in s:
                s=re.sub(r'import \(\n','import (\n\t"github.com/mulgadc/spinifex/internal/testkit/gwfix"\n',s,count=1)
            open(f,'w').write(s)
    print('read sites fixed',fixed)

* docs: record the gateway mount hooks and config-field removal in the package boundary migration

* docs: fill in the 11d-1 PR number in the package boundary migration
2026-11-01 13:04:31 +10:00
Josh Siviour 83ec8b512d refactor: move service-named pipeline behaviour into registration metadata (ADR-0002:S9) (#1374)
* test: pin service-keyed gateway behaviour before it moves to registration metadata

* refactor: declare the XML throttle code on the registration

The pipeline picked Throttling for iam, sts, eks and route53 by name. EKS is a JSON registration and never reached that arm, so it is not carried over.

* refactor: declare the XML error shape on the registration

EC2 and the operator surface declare the EC2 shape. S3 is not served here, so the composition declares its scope traits and a rejected S3 request keeps S3's body.

* refactor: declare the global signing region on the registration

* refactor: supply the unsigned web-identity exchange as a pre-auth handler

STS composition now hands the pipeline a dispatch.PreAuth. The gateway no longer recognises STS or the action by name, and the layering allowlist row for gateway -> sts/awsapi is paid off.

* refactor: declare payload-body scopes as traits

The action recovery for a rejected request skipped S3 by name. The composition now declares the S3 scope as carrying object data, and the gateway reads the trait.

* test: wire the integration harness through PreAuth
2026-11-01 11:34:05 +10:00
Josh Siviour 1cfaf15c9d fix: EKS control-plane VMs are not Kubernetes Nodes (#1373)
* test: pin control-plane no-agent config, etcd member removal and CP isolation e2e

* test: tighten CP isolation e2e and assert a logged prune failure

* fix: run EKS control planes without a k3s agent and prune etcd members directly

Control-plane VMs no longer register as Nodes, so pods cannot schedule onto a
network that cannot reach the workers or the advertised endpoint. With no Node
to delete, a replaced peer's etcd member is removed by a new eks-etcd-member
helper that matches exactly one member by peer-URL host.

* fix: refuse to remove the local etcd member by ID, not only by IP

Self-protection no longer depends on first boot deriving its own address: the
helper asks the connected endpoint for its member ID and refuses a match on it.

* test(e2e): align EKS suite with control planes that are not Nodes

Assert zero Nodes and delivered addon objects before any nodegroup. Check Ready worker, metrics APIService, addon deletion and pod logs once a worker exists, scoping logs to Ready nodes and waiting 10m for metrics-server to reschedule.

* docs: EKS control-plane hosts are not Kubernetes Nodes

Document that kubectl get nodes lists workers only, add upgrade notes for existing clusters, and list the eks-etcd-member helper.

* docs: explain why addon manifests keep the CriticalAddonsOnly toleration

Comment-only. The toleration stays for clusters created before control-plane hosts stopped registering as Nodes.

* test: cover the etcd member client and CLI against a TLS fake etcd

The real etcd client and the command had no tests, leaving the PR below the
diff coverage threshold. A fake etcd serving the Cluster and Maintenance APIs
over mutual TLS drives the client with the K3s certificate layout, and main
becomes a testable run function.
2026-11-01 10:55:57 +10:00
Josh Siviour be99d9c4fe test: move remaining service and verdict tests out of gateway (#1372)
* refactor: move gateway auth fixtures to runtime-free gwfix package

* refactor: move ACM gateway tests to awsgw

* refactor: keep gateway-internal ACM tests in gateway and drop test-only exports

* refactor: move RDS gateway tests to awsgw

* refactor: move ELBv2 gateway tests to awsgw

* refactor: move IAM gateway tests to awsgw

* refactor: move STS gateway tests to awsgw

* refactor: move tagging gateway tests to awsgw

* refactor: move operator and bedrock gateway tests to awsgw

* refactor: move ECR gateway tests to awsgw

* refactor: move ECS and EKS gateway tests to awsgw

* refactor: move EC2 gateway tests to awsgw

* refactor: move credential verdict tests to awsgw

* refactor: move policy-gate dispatch tests to their service packages

* refactor: move checkPolicy verdict tests to policyauth

* refactor: run gateway tests on a fake verifier, real auth only for listed verdict tests
2026-11-01 08:42:07 +10:00
Ben Duncan e3a44893fa fix: bound the console's instance-type payload and stop polling it (#1371)
* fix: bound the console's instance-type payload and stop polling it

DescribeInstanceTypes with capacity=true returned one entry per free slot per
node: 3,528,765 bytes of XML for 50 distinct types on prod, refetched every
five seconds. The response was sized by free capacity, so the emptier the
cluster the larger it got, and at 44,346 slots it exceeded the NATS maximum
payload and the console reported no compute at all.

The listing now names each admissible type once. The free-slot figure crosses
as a number through a new GetInstanceTypeCapacity action, open to any account
because the launch form a tenant uses is what needs it. The catalogue query no
longer polls, and the EKS and ECS dialogs read it only when open.

The gateway negotiates gzip inside the SigV4 group, which excludes the OCI
Distribution registry structurally. A response is compressed only if the client
offered gzip, the handler declared neither a length nor an encoding, the
content type is neither streaming nor already compressed, the status carries a
body, and 1 KiB was written before any flush.

An unknown capacity figure no longer caps the count at 1. The launch decides,
and RunInstances answers InsufficientInstanceCapacity if it cannot.

* test: read the free slot count from its own action in the console e2e

Two e2e tests encoded capacity as repeated instance-type entries, so one
expected a caption the deduplicated listing can no longer produce. The fixture
answers GetInstanceTypeCapacity instead, which lets a test give the catalogue
and the figure different answers.

A third test covers the case the change introduces: a session whose policy does
not grant the action reads no figure, so the form offers the type with no
capacity caption and leaves the launch enabled.

The committed bundle is rebuilt, which rehashes every chunk reaching the two
query modules.
2026-10-31 14:03:19 -05:00
Ben Duncan 1d3c2a7cbd fix: bound the capacity describe reply by instance-type count, not free slots (#1368)
* fix: bound the capacity describe reply by instance-type count, not free slots

A node answering `DescribeInstanceTypes` with `capacity=true` sent one full copy of an instance type's description per free slot, so the reply grew with how much capacity the host had free.
At 1344 bytes per slot the four-node production cluster, holding no guests, produced 874 slots and a reply over the 1 MB NATS maximum payload.
Every node's reply was dropped, the gateway collected zero frames, and it returned an empty list as a success.
The console reads an empty capacity list as no capacity and refuses every launch, which is how a cluster with hundreds of free vCPU reported "No compute available".

The emptier the cluster, the more likely the failure, which is the opposite of what the shape should be.

A node now returns each admissible type once with the number of instances of it the node can take, so its reply is bounded by the size of the instance-type catalogue and not by free capacity.
On the production catalogue that is 84 KB for 62 types whatever the slot count, against 59 MB for the same reply on a large idle host under the old shape.
The gateway sums the per-type counts across nodes and expands them on the last hop, because the client SDK drops response fields it does not model and the console displays the figure to the customer.

A capacity fan-out that collected no frames now returns a retryable ServiceUnavailable.
Reporting an empty list as a success is what turned a transport failure into a false statement about the cluster.

Two tests pin the shape: the node reply for an idle 4096-vCPU host stays under the NATS payload limit, and a capacity fan-out with no responders does not return an empty success.

* test: cover the console against a real cluster, and pin the capacity banner

The console has never been exercised against a real control plane.
`playwright.config.ts` pins `localhost:3101` and `:3102` and intercepts every API call, so the suite cannot be pointed at a cluster and cannot see a defect whose whole content is what the control plane actually sends.
That is why an idle production cluster came to tell customers it had no compute: the capacity listing was dropped in transport, the empty result read as no capacity, and nothing in CI could reach the page that says so.

`playwright.live.config.ts` runs the same console against a cluster named by `SPX_CONSOLE_URL`, signing in with `SPX_ACCESS_KEY_ID` and `SPX_SECRET_ACCESS_KEY`.
It starts no server, mocks nothing, runs one worker because it launches real guests, and is never part of `pnpm e2e`.
The suite signs in, asserts a cluster with free capacity offers instance types rather than reporting none, reads the per-type capacity figure, launches an instance and waits for `running`, reads its live metric graphs, terminates it in a `finally` block, and loads the nine listing pages a customer reaches from the sidebar.

Three tests in the mocked suite pin the banner's own logic, which is cheap and catches the UI side of the same failure: the form offers what the capacity listing reports, it shows the free slot count for the selected type, and an account with genuinely no capacity is told so and cannot submit.
The third passes on the current code and is the one that documents why the banner exists at all.

* test: correct three live-console selectors found by running it against prod

The first run of the live suite was 11 passed and 2 failed, both in the suite rather than in the console.

The dashboard check looked for node status on `/`, where it is not shown.
It now loads `/nodes` and asserts the `Resource usage by node` and `Instance type capacity` tables, which is where the daemon reports a node's own capacity.

The OS tile's radio is `sr-only` behind the distribution logo, so `check()` failed its actionability test on the `<img>` covering it.
The click goes to the enclosing `<label>` instead and the browser forwards it.

The terminate dialog holds its confirm disabled until the phrase is typed, so the teardown waited out its timeout on a disabled button and left the instance running.
The helper types `terminate` first.

With those three corrected the launch, running, live-metrics and terminate path passes against the production console in 34 s.
2026-10-31 13:42:13 -05:00
Ben Duncan 9c99312f74 feat(ui): enforce ADR-0016 with guard tests and move dialog forms into FormPanel (#1369) 2026-10-31 13:41:53 -05:00
Ben Duncan 9e3a7b9c8c fix: let a snapshot go once every volume cloned from it is already deleting (#1370)
* fix: let a snapshot go once every volume cloned from it is already deleting

DeleteVolume now records State=deleting and leaves the teardown to the
reclaim reaper, so a clone stays listed after the tenant has deleted it.
DeleteSnapshot counted that clone as still reading the snapshot and refused
with InvalidSnapshot.InUse, which broke DeleteDBSnapshot after deleting the
DB instance restored from it and left the retained source volume behind.

A deleting volume's teardown removes only its own objects and nothing moves
it out of deleting, so it no longer holds the snapshot's chunks.

* test(e2e/rds): delete a snapshot straight after the instance restored from it

The subtest deleted the source instance between the two, which gave the
volume reclaim reaper time to finish the restored volume on a fast node and
hid the refusal the nightly hit. Deleting the snapshot immediately makes the
regression deterministic.

The release wait now covers a full reclaim sweep: since deletion became
asynchronous a released volume is gone only after the reaper's next pass.
2026-10-31 13:27:01 -05:00
Josh Siviour c3bed5535b test: move per-service gateway tests into their domains (#1366)
* refactor: one authorization gate in ingress/aws/pipeline

The key merge and the caller guards move out of the gateway so tests that
drive a registration directly use the same gate as the pipeline.

* test: run ACM handler and policy tests in domains/acm/awsapi

Adds internal/testkit/gwtest, which invokes a registration through the
pipeline's own authorization gate with real policy evaluation.

* test: share policy assertion helpers through gwtest

* test: run ELBv2 handler and policy tests in domains/elbv2/awsapi

* refactor: one actor projection in ingress/aws/pipeline

* test: run RDS handler and policy tests in domains/rds/awsapi

* test: run ECS handler and policy tests in domains/ecs/awsapi

* test: assert the exact policy gate verdict in gwtest

* test: run EKS authz and wire tests in domains/eks/awsapi

* test: run IAM authorization tests in domains/iam/awsapi

The policy-driven DeleteRole and AddRoleToInstanceProfile tests run in the IAM package through gwtest, with PassRoleTo shared. The HTTP-handler, examples and signed-pipeline tests stay in gateway.

* test: run ECR authorization tests in domains/ecr/awsapi

* test: run ECR repository, image and lifecycle-preview tests in domains/ecr/awsapi

* test: run ECR dispatch error tests in domains/ecr/awsapi

* test: run Bedrock authorization tests in domains/ochre/awsapi

* refactor: move EC2 authz tests into domains/ec2/awsapi

* test: restore the ECS scope-table completeness gate in domains/ecs/awsapi

* Revert "test: restore the ECS scope-table completeness gate in domains/ecs/awsapi"

This reverts commit 0211a0a1bf.
2026-10-31 23:45:35 +10:00
Ben Duncan 4468f13e53 docs: add a diagram of a Vultr deployment to the guide (#1367)
Shows deploy-vultr.sh building a private Vultr VPC with one node or three,
the nodes clustering over the private network for the control plane, EBS
and S3, Spinifex calling the Vultr API for reserved IPs, and the AWS
services running across the fleet.
2026-10-31 08:36:10 -05:00
Ben Duncan 8a71854463 docs: tidy the Vultr guide for publication (#1365)
Drops the horizontal rules the docs site rendered as stray text, links
the AWS CLI install page, captions each screenshot, shows the expected
spx get nodes output for three nodes, explains what a three-node
deployment protects against, and simplifies the limits to the sizing
and quorum a reader needs.
2026-10-31 08:20:53 -05:00
Josh Siviour e155bcf155 feat(sqs): queue and Lambda-facing message API foundation (#1364)
* feat(sqs): queue and Lambda-facing message API foundation

Adds the SQS JSON 1.0 adapter (unregistered in production) with queue
create, lookup, attributes, list, tags and the message operations a
Lambda poller uses: SendMessage(Batch), ReceiveMessage with long
polling and attribute selection, DeleteMessage(Batch) and
ChangeMessageVisibility(Batch), backed by the queue record owner and
the JetStream message store. String and Binary message attributes are
validated, stored and checksummed; Number attributes are refused until
AWS behaviour is confirmed.

* test(sqs): poller lifecycle through the message owners

Covers receive, release, redelivery, delete, visibility extension, queue
binding of receipt handles and long polling against the real queue and
JetStream message stores, from the message package itself.

* test(sqs): move SDK coverage from the gateway to the SQS domain

Drives the registration through dispatch.Invocation over the real queue
and message owners, so the gateway package carries no SQS changes.
2026-10-31 22:27:11 +10:00
Josh Siviour 7aa76c5bd6 refactor: gateway request record and capabilities (ADR-0002:S7, S8) (#1363)
* test: pin gateway pipeline order, request ids and region

* refactor: carry request facts in one ingress request record

* fix: seal the request principal once

* refactor: build the caller ARN once in credverify

* refactor: gateway takes policy and admission as capabilities

* refactor: gateway takes cluster readiness as a capability

* chore: gofmt GatewayConfig field alignment

* docs: record slice 11a request record and capabilities

* docs: add PR number to slice 11a migration row

* refactor: move pipeline capability interfaces to ingress/aws/pipeline
2026-10-31 17:57:15 +10:00
Josh Siviour b80d4e2371 ci: ask PR authors whether Mulga design needs to change (#1362) 2026-10-31 15:56:46 +10:00
Ben Duncan b4dd606104 feat: run Spinifex on Vultr, with failover, workbooks and the fixes they needed (#1361)
* feat: Vultr reserved-IP provider and floating external-address pool

Adds a source="vultr" external pool backed by Vultr reserved IPs: an API
client pinned by captured responses, an in-memory fake with the measured
semantics, and a floating allocator that creates, claims, reconciles and
releases labelled addresses. OCI and Vultr claims now share one
AddressClaimer pass.

Also fixes the host firewall dropping guest IMDS when imds_host_meta_ip is
set, by matching the pre-DNAT destination, and arms the Spinifex firewall in
place of an active ufw.

* fix: detach a Vultr reserved IP before deleting it

Vultr was measured refusing delete of an attached address with 400 "Unable to detach subnet", so release, rollback and reconcile now detach first. A first claim after allocation logs at info rather than warn.

* fix: release the auto-assigned public IP an Elastic IP displaces

AssociateAddress restamped the ENI with the EIP and never handed back the
address launch had assigned. Nothing referred to it afterwards, so it stayed
allocated for good, and on a cloud pool every associate left a billed address.
AWS releases it on associate; withdraw its NAT rule and release it, scoped to
the ENI, leaving any address another associated EIP holds alone.

* fix: retry Vultr's refused detach on reserved-IP detach and delete

Vultr has answered a detach with 400 "Unable to detach subnet" and accepted
the same detach moments later. Surfacing it left the address for reconcile
to collect, up to twenty billed minutes later; retry it like a 5xx instead.

* fix: stop reporting the released launch address after an EIP is disassociated

Associating an EIP now releases the auto-assigned public IP, but the stored
instance kept its launch stamp, and DescribeInstances fell back to it once the
EIP was disassociated. The public-IP change handler now moves the stamp with
the runtime address.

* feat: add a Vultr deploy script for a single-node Spinifex on a VX VM

deploy.sh up/destroy/status drives Terraform for the VPC and VM, installs and
forms Spinifex over SSH, copies the API key to the node rather than through
user-data, and sweeps boot volumes and reserved IPs Terraform does not own.
Cloud-init keeps needrestart off the Spinifex, OVS and OVN units and disables
unattended upgrades on these disposable hosts.

* feat: form a three-node Vultr deployment with install-node.sh over the VPC

Each node comes up as an armed single node, the state install-node.sh forms
from, with the cluster and Geneve planes on the VPC NIC. The pool and every
node's provider_host_id go on each node after formation.

* fix: read the pinned host key with ssh-keygen -F when forming a Vultr cluster

ssh_config hashes known_hosts entries by default on Debian, so a grep for the
address found nothing and set -e ended the run without a message.

* docs: add the Vultr guide, one command to deploy one or three nodes and one to remove them

* docs: drop the failover write note from the Vultr guide

* fix: keep needrestart from restarting the Spinifex, OVS and OVN units

Guest QEMU runs in spinifex-daemon's cgroup, so needrestart charged QEMU's
replaced libraries to the daemon and restarted it after every dpkg run. The
restart never cleared them, so an unattended upgrade ran the unit into its
start limit and left the control plane down.

setup.sh now writes /etc/needrestart/conf.d/90-spinifex.conf, deselecting
spinifex-, ovs-, ovsdb-, ovn- and openvswitch- units. needrestart still lists
them, so restarting after an upgrade stays the operator's choice.


* chore: drop the Vultr cloud-init needrestart override now setup.sh writes it


* docs: replace the Vultr guide's apt upgrade warning now the installer keeps needrestart off the stack


* docs: install the Vultr guide's build from the dev channel until Vultr support is released


* fix: answer ARP for Vultr reserved IPs on bare metal

Vultr routes a reserved IP to a VM by MAC but ARPs for it on a bare-metal
server's public segment, and a Vultr pool gave vpcd neither a gateway nor a
bind bridge to proxy-ARP on, so nothing answered and no traffic arrived. A
Vultr pool now proxy-ARPs on the interface the node routes the internet
through. setup.sh installs arping, which the gratuitous ARP already called.


* feat: deploy Spinifex on Vultr bare metal, renamed deploy-vultr.sh

A vbm- plan creates bare-metal servers, one or three. Without --name the
deployment writes the AWS profile spinifex, up prints every node's address
and the API key allowlist, credentials and ssh subcommands are added, no AMI
is imported unless --image is given, and OpenTofu or Terraform is used. The
host key is pinned only once cloud-init is done, since bare metal first
answers SSH from Vultr's installer with a different key.


* docs: rework the Vultr guide into the OCI guide's flow, with bare metal


* fix: restamp LaunchTime when a stopped or recovered instance relaunches

The pending watchdog times a launch from LaunchTime, which still held the
original launch. An instance recovered from a failed node, or started more
than five minutes after it was first launched, was terminated as stuck in
pending seconds after QEMU started. AWS restamps LaunchTime on every start.


* feat: spread the nginx-alb workbook across nodes, and power Vultr nodes off and on from deploy-vultr.sh

The workbook takes server_count and placement_strategy, so three web servers
can run one per node. Its README now matches the template (public subnets,
no NAT gateway) and the diagram shows the spread. deploy-vultr.sh gains
stop and start, which halt and start a node through the Vultr API.


* fix: let a terminate finish tearing down before the reaper calls it wedged

OrphanQEMUReaper finalized any shutting-down instance whose QEMU had exited,
on the premise that a live terminate still has a live QEMU. A terminate is
still deleting volumes, releasing its IP and leaving its placement group for
tens of seconds after QEMU exits, so the instance was reported terminated
first. Terraform then deleted the placement group and got
InvalidPlacementGroup.InUse. The reconcile now waits two minutes from
shutting-down, well inside the ten-minute stuck-terminate backstop.


* fix: release an ovs-vswitchd left in flow-restore-wait after a node boots

ovn-controller clears flow-restore-wait once its flows are installed and
sets it again straight after. When both writes reach ovs-vswitchd as one
update, it only sees true, starts no upcall threads and forwards nothing.
Seen on Vultr bare metal after a hard power-off: instances recovered onto
the rebooted node ran but were unreachable. vpcd now clears the flag on any
node held that way for a minute, longer than ovs-ctl's own restore takes.


* docs: add a resiliency test to the Vultr guide and the nginx-alb workbook

The Vultr guide gains step 8, which spreads three web servers across a
three-node deployment, powers a node off and back on, and shows what was
measured on VMs and bare metal. The workbook gains the same test as step 6.
The guide's table of contents is repaired, and its limits now cover losing
two nodes and recovery inside a spread placement group.


* fix: never terminate an instance that has run because a relaunch failed

The pending watchdog terminated any instance pending past five minutes, and
terminating deletes DeleteOnTermination volumes. A start of a stopped
instance, a recovery from a failed node and a daemon relaunch all pass
through pending, so a slow one destroyed an existing instance and its root
disk. On Vultr a recovered instance was terminated seconds after QEMU
started and its root volume deleted.

An instance is now marked FirstLaunch from RunInstances until it first
reaches running, cleared before the running state is written. The watchdog
acts only on first launches, as AWS terminates only a launch that never
ran. MarkFailed refuses to terminate any other instance and marks it
recovery-failed instead, which keeps its volumes, ENI and addresses and
shows it as stopped. A claimed relaunch is never a first launch.

Invariant tests pin ADR-0003:S7. The instancerecovery e2e suite gains a
test that ages its guest past the watchdog before recovering it, then
stops and starts it, and requires the instance, its root volume and the
data on it to survive both.


* feat: run the Terraform workbooks against a Vultr deployment on demand, and show the Vultr portal in the guide

e2e-cloudvendor-vultr.yml is dispatch-only. It builds the ref, deploys it
with deploy-vultr.sh on one or three VMs or bare-metal servers, runs the
published workbooks from node1 with the same driver as the hypervisor
nightly and OCI, and destroys the deployment, failing if anything is left
billing. It needs one secret, VULTR_API_KEY, and generates its SSH key per
run. The guide gains screenshots of the API key's access control list and
of the nodes in the Vultr portal, and its portal navigation now matches.


* fix: install setup.sh on Vultr nodes when the tarball does not carry it

build-install-artifacts.sh writes setup.sh beside the tarball, so a node
installed from its tarball had no /usr/local/share/spinifex/setup.sh and
arming the firewall failed.


* fix: pass the remote command after the destination in the Vultr e2e driver


* fix: run each workbook in the cluster's own region

The workbook driver passed no region, so every workbook used its default,
ap-southeast-2, and on a cluster in any other region every request failed
SignatureDoesNotMatch. It now passes the region of the node's spinifex
profile.


* feat: count a node's vCPUs as online CPUs and reserve by host size (ADR-0015)

A node counted physical cores, collapsing SMT threads, and held back a
fixed 2 vCPU, so a 16-thread host offered 6 vCPU to instances. A vCPU is
now one online CPU, as AWS counts it: a thread with SMT on, a core with
SMT off or on ARM. The OS and Spinifex keep 1 CPU up to 8, 2 up to 16, 4
up to 63 and 8 from 64, and the rest is allocated one to one. The
existing overrides stay operator-only. Invariant tests pin every tier
boundary.


* docs: state the Vultr guide's sizing under the online-CPU count


* docs: drop an unmeasured EKS sizing claim from the Vultr README


* fix: let a guest reach an Elastic IP hosted on its own node

A guest's packet to an EIP on its own node left its VPC from the transit
range, was masqueraded to the transit host address, and went back into
OVN, which exempts that range from EIP DNAT, so it was never answered.
On Vultr an EKS worker could not reach its control plane's endpoint, so
it never joined. AWS lets an instance reach a public address in its own
VPC.

Hairpinned transit traffic now jumps to SPX-EIP-HAIRPIN, above the egress
MASQUERADE, where each EIP's rule sends it from the uplink address, as the
host's own traffic to the EIP already is. The rule follows the EIP's
ingress plumbing in and out, and anything else returns to the MASQUERADE.


* fix: resolve Spinifex's LB and EKS names on Vultr nodes

Vultr's Ubuntu image writes a static resolv.conf naming Vultr's resolvers,
so lookups bypassed the split DNS Spinifex configures in systemd-resolved
and an EKS endpoint name did not resolve on the node. deploy-vultr.sh
points resolv.conf at the resolved stub, as stock Ubuntu does.


* test: prove a guest reaches an EIP on its own node in the natuplink e2e

The nat-single cells run it with a public pool, so the hairpin path that
left EKS workers unable to reach their control plane on Vultr is now
covered nightly.


* docs: record the natuplink hairpin check in the e2e coverage index


* fix: keep each Vultr node's logs when an e2e run fails

The driver destroyed a node whose install failed together with the log
that said why. It now copies /root/*.log from every node into the run's
artifacts before the teardown, and the workflow uploads them.


---------
2026-10-30 23:12:58 -05:00
Josh Siviour e709717fde fix: launch EKS workers with IMDS hop limit 2 and honour launch template metadata options (#1360)
* test: pin EKS worker IMDS metadata options and launch template resolution

* fix: launch EKS workers with IMDS hop limit 2 and honour launch template metadata options

* fix: default unset launch template metadata fields for EKS workers

* test: e2e coverage for EKS worker IMDS hop limit and launch template metadata options

* test: drop the unneeded GetCallerIdentity policy from the worker IMDS e2e

* docs: describe the EKS worker IMDS defaults and launch template overrides

* test: drop the stale GetCallerIdentity policy from the IRSA pod e2e
2026-10-31 14:07:05 +10:00
Ben Duncan f09250d2bb feat: count a node's vCPUs as online CPUs and reserve by host size (ADR-0015) (#1359)
A node counted physical cores, collapsing SMT threads, and held back a
fixed 2 vCPU, so a 16-thread host offered 6 vCPU to instances. A vCPU is
now one online CPU, as AWS counts it: a thread with SMT on, a core with
SMT off or on ARM. The OS and Spinifex keep 1 CPU up to 8, 2 up to 16, 4
up to 63 and 8 from 64, and the rest is allocated one to one. The
existing overrides stay operator-only. Invariant tests pin every tier
boundary.
2026-10-30 22:53:45 -05:00
Ben Duncan 8839407c71 feat(ui): console design system, side-panel resource views and live EC2 metrics (#1358)
* feat: lay the Spinifex UI design-system foundation

Brand tokens in OKLCH with semantic status and a colour-blind-checked
chart palette, Archivo headings and a 13px type scale, and a pre-paint
theme script so dark mode never flashes white under the console CSP.

Adds the missing Base UI primitives, a /design catalogue route, a route
ratchet against hand-written tables, a ban on chart-library imports in
routes, and lets the content column shrink so wide tables scroll in
place. The dev server now forwards /api and /proxy to the Go console.

Sonner is removed: its inline style element is blocked by the CSP.

* feat(ui): side panels, TanStack table, uPlot charts and e2e gate

- DataTable renders @tanstack/react-table, with column hiding as container
  queries so columns drop when a side panel narrows the list.
- UniFi-style DetailPanel and ResourceView: Instances, VPCs and Internet
  gateways open in a panel beside the list from 1024px and as a full page
  below; selected, tab and create live in the URL. Create flows for
  instances, VPCs and gateways open in the same panel.
- Instance monitoring charts on uPlot (CPU, network, packets), on simulated
  data and badged as such.
- Dates in the AWS console form, e.g. 2026/06/04 02:00 GMT-5.
- Tabs style Base UI's data-active, so the selected tab now shows.
- Playwright suite gates CI as the UI End-to-End job in ui.yml.

* feat(ui): one details component for panel and page, UniFi-style overlay panel

The side panel now lies over the list at full height with a shaded edge, and a
click on the list's background closes it. Instances, VPCs and internet
gateways each render one details component as either the panel or the
resource's own page, so the page gains the monitoring graphs and the panel
gains IAM role, GPU, console output, change type and create image. SSH moves
into Instance information as rows. Internet gateways attach and detach from
the panel.

* feat(ui): roll the panel pattern out to every service and gate screenshots in CI

Every EC2, IAM, Route 53, Bedrock, EKS, ECS and RDS resource is now a
list plus a side panel, with one details component serving as both the
panel and the one-column page. DetailCard, DetailRow, ListCard,
ResourceList and DeleteResourceButton are deleted.

- Panel: slide-out close, row selection closes it, expand keeps the tab
  and fetched console output, PanelSpinner while loading.
- Instances: info tips, theme-aware coloured console output, power
  button group plus Actions menu, Stop and Reboot confirms, security
  group, Elastic IP and network interface sections, and a live
  one-second monitoring window on simulated data.
- Launch instance: OS tiles picking the newest version, architecture
  choice, location tile, expanded root volume, honest default subnet.
- On/off settings are switches app-wide; disabled controls show
  cursor: not-allowed.
- The e2e job runs in the pinned Playwright image with screenshots
  gating; make ui-baselines and make ui-e2e-container use the same image.

* feat: bulk select and delete on every resource list, OS marks and instance type sizes

Every list with a delete action gets row checkboxes and a selection bar.
Delete confirms in a dialog naming each resource, makes one call per
resource, keeps each refusal against its row and retries only failures.

The Launch form shows distribution marks for Ubuntu, Debian, Rocky,
Alpine and CentOS, served as files under the CSP, and states the
selected instance type's vCPUs and memory.

* feat: sign-in overlay, larger type scale, typed confirms on irreversible deletes and dialog shortcuts

The sign-in card sits over an inert shell. Any sidebar group folds, and entering one unfolds it. Instance DNS names copy. Escape cancels and Cmd/Ctrl+Enter confirms every dialog. Volume, snapshot, RDS, EKS, ECS and hosted zone deletes need "delete" typed, and terminate needs "terminate".

* feat: fade sign-in and sign-out in place, and show the confirm shortcut

Signing in no longer reloads the page: the card and a lighter veil fade out and the console routes to the dashboard. Signing out cross-fades into the sign-in card with a Signed out notice. Confirm buttons show Cmd or Ctrl Enter for the platform.

* feat(ui): generic sidebar group names and a paged S3 object browser

The sidebar names its groups Compute, Databases, Authentication and Object
Storage; the command palette still finds each by its AWS service name.

Buckets get row selection and delete. Inside a bucket, folders and objects
share one table paged by ListObjectsV2 continuation tokens, with a find box
that queries by prefix, multi-select delete through DeleteObjects (folders
with everything under them), and an object panel showing HeadObject
properties, user metadata and a download.

* feat(ui): show upload progress and stop the S3 proxy cutting off slow transfers

The Upload button shows a running batch's percentage, and a large or slow
batch opens a dialog with bytes sent, bit-rate, time left and per-file
cancel and retry. Uploads run in a store outside React, so they continue
while the user browses.

The console's S3 proxy now allows an hour per request. The server's 15 s
ReadTimeout cancelled every upload part that took longer, so large uploads
failed on links below roughly 18 Mbit/s.

* feat(ui): stream live EC2 metrics to the Monitoring tab

The Monitoring tab drew simulated data. It now streams CPU, network
bytes and packets from the owning node's qmp-collector every 500 ms
while someone is watching, with nothing stored.

- qmp-collector: a watched sampler on schedstat and tap counters, on a
  30 s lease, keeping five minutes in memory for reconnects. It answers
  only watches for the account in the VM's discovery file.
- awsgw: spinifex:GetInstanceMetrics, checked against the instance ARN,
  answered as text/event-stream in 60 s segments. Another account's
  instance is InvalidInstanceID.NotFound.
- console: a signed stream reader that reconnects with Since and backs
  off on failure or an empty segment, stops on InvalidAction, and a
  chart axis that scrolls between samples. The simulator and its badge
  are removed.

* feat: catch refused creates in every form, answer the third review, and guard tables

Every create form now catches a refused mutation, so the failure shows beside
the submit button and no longer escapes as an unhandled rejection. Three EKS
tabs that swallowed a refused create now show it.

Third-review answers: the attach dialog offers running or stopped instances in
the volume's zone; the default security group's delete is disabled with its
reason; the load balancer panel splits into Listeners and Attributes tabs; a
main route table lists its implicit subnets; IAM single delete dialogs name the
same dependants as the bulk ones; a snapshot can be deleted in any state.

Every list in a route renders through DataTable, enforced by a unit test with a
named allowance, and the /design baselines are redrawn in the pinned container.

* feat: cap live metrics streams, prune hidden selections, and move panel focus

Each gateway holds at most 10 live metrics streams per account and 200 in
total, refusing past either with RequestLimitExceeded, and counts opened and
refused streams. A row that leaves a list leaves its selection, the instances
page uses the shared selection hook, the detail panel takes focus on open and
returns it on close, and Escape yields only to the popup it was pressed in or
a modal. The UI workflow also runs on live metrics backend changes.
2026-10-30 22:40:47 -05:00
Josh Siviour dee37b976a fix: keep an ECR token valid after its role session expires (#1357)
* test: authorise an ECR token from the live role once its session is gone

* feat: carry the role ID in an ECR token minted for an assumed role

* feat: rehydrate an assumed-role caller from the live role

* fix: authorise an ECR token from its live role after the session expires

* docs: note that an ECR token outlives its role session
2026-10-31 12:20:24 +10:00
Josh Siviour ce8272cf5e refactor: register EC2 through the ingress seam and delete the legacy service switch (#1355)
* test: characterise EC2 request labels and the cluster-unavailable gate order

Pins the span, audit, throttle and policy-gate labels an EC2 request carries through the signed pipeline, and that the NATS gate answers before policy, action lookup and gateway-local actions.

* refactor(gateway): register EC2 through the ADR-0002 seam

EC2 is composed in awsgw from domains/ec2/awsapi and served by the
registration seam. The legacy service switch and its tables are removed,
along with the two paid-off gateway allowlist rows.

* docs: record the EC2 seam registration in the package boundary migration

* docs: record the EC2 seam registration PR number
2026-10-31 12:09:59 +10:00
Ben Duncan e4099f74b6 fix: make volume deletion asynchronous so a large volume cannot fail its own delete (#1356)
DeleteVolume returned only once every object behind the volume was gone, which
made the call's latency a function of the volume's size. Object removal costs
one round trip per object, so no timeout is both short enough for a control
plane request and long enough for a large volume: on a 100 GiB volume the
provider request overran its 30 s budget at 52 s, the tenant was told the
delete failed, and the teardown that had already started was left with no
record accountable for it.

AWS does not work this way. DeleteVolume's output in the EC2 model is
smithy.api#Unit, VolumeState includes deleting, and the documentation says a
volume can remain in that state for several minutes.

DeleteVolume now validates, decides every AWS-returnable error, records
State=deleting and returns. VolumeReclaimReaper converges the teardown and
removes the record only once it is complete, so an unreachable or saturated
blob node delays reclamation instead of failing a delete the tenant has already
been told succeeded. The reaper is cluster-wide and leader-gated, which is also
what serialises the teardown: three concurrent destructive handlers ran one
volume's objects on prod because a retry had nothing to collapse against.

A volume already recorded as deleting answers a second delete from the record,
which collapses both the tenant's retry and DeleteOnTermination's own delete.

Two ordering fixes behind it. The provider handler now tears down local state
before deleting objects, because the local half is fixed-cost and sat behind the
half that scales, so a timeout left this node's WAL and dirty marker behind for
good. And volume.delete and snapshot.delete are classified as data movement,
since both reference real data and neither answers from state already held.
2026-10-30 15:49:33 -05:00
Ben Duncan 9d9e3aab6a fix: keep needrestart from restarting the Spinifex, OVS and OVN units (#1341)
Guest QEMU runs in spinifex-daemon's cgroup, so needrestart charged QEMU's
replaced libraries to the daemon and restarted it after every dpkg run. The
restart never cleared them, so an unattended upgrade ran the unit into its
start limit and left the control plane down.

setup.sh now writes /etc/needrestart/conf.d/90-spinifex.conf, deselecting
spinifex-, ovs-, ovsdb-, ovn- and openvswitch- units. needrestart still lists
them, so restarting after an upgrade stays the operator's choice.
2026-10-30 15:17:15 -05:00
Josh Siviour 0fc93e975a refactor: serve EC2 actions from domains/ec2/awsapi (#1354)
* test: characterise EC2 through the signed gateway pipeline

* refactor: serve EC2 actions from domains/ec2/awsapi over composed capabilities

The EC2 action table, idempotency and validation move out of the gateway behind a Deps struct. Quota is a consumer-side interface and the instance adapters take a narrow InstanceProfiles interface instead of the whole IAM service. The gateway only composes Deps and calls Dispatch. The 18 gateway to EC2 awsapi allowlist rows are deleted.

* test: drive every EC2 action through Dispatch in its own package

Covers the request errors, the gateway-local actions without NATS and each routed action reaching its handler against a stub daemon.

* docs: record the EC2 capability move in the package boundary migration

* docs: record the EC2 capability move PR number
2026-10-31 00:30:51 +10:00
Josh Siviour d066786faa feat: operator-launched SQS worker and store system VMs (#1353)
* feat: persist the SQS worker's store key on its own disk

The shared SQS worker generates its store-client NKey inside the guest and
keeps it in an owner-only directory on its own persistent disk, so no platform
component creates or unseals the seed. The key is synced and linked into place
without replacing anything; a missing, mismatched, unreadable or loosely
permissioned key fails closed instead of being regenerated.

* fix: make the SQS worker key durably named before returning it

Every successful call now syncs the key directory and its entry in the parent
before handing out a key, so a retry after a failed directory sync cannot
return a key whose name is not yet durable. Only the key directory is created,
inside a parent that must exist, so an unmounted worker disk fails closed. The
first-start decision is explicit: an existing store's authorized key is
required and never defaults to generating a new one.

* docs: state that workerkey cannot detect an unmounted worker disk

An existing parent directory does not prove the worker disk is mounted, since
the mountpoint can exist on the initramfs after a failed mount. The error now
says only that the parent is missing, and the package documents that its
caller must verify the expected data volume is mounted first.

* feat: add the SQS worker guest bootstrap

The shared SQS worker microVM boots with sqs-worker boot, which selects
exactly the disk whose serial fw_cfg names, checks and mounts it at /data,
and proves from the mount table that this disk is what is mounted before
loading or creating its store key there. A missing, empty or unknown store
status, a contradictory or invalid store key, a missing or ambiguous disk, a
failed check or mount, an empty mountpoint or a foreign mount refuses without
creating a key. Only the public key is logged and published.

The image is built by the store image script under SQS_MICROVM_PROFILE=worker,
with the worker binary, no nats-server and an empty /data mountpoint; the
store image's contents are unchanged.

* fix: refuse replayable new-store status at SQS worker boot

A static fw_cfg store-status of "new" survives a guest reset, so after the
store exists it could still reach key creation and replace a lost worker key.
Boot now accepts only "existing" with the store's authorized public key and
refuses "new" before touching any disk, until a current authoritative
first-store decision exists. workerkey.ForNewStore remains, uncalled.

* feat: add SQS first-store claim and worker key acquisition

The SQS domain records one first-store decision per Region in its own KV
bucket, moving claimed -> key-recorded -> launch-intent under CAS and bound
to the worker's instance ID, claim ID and generation. Key creation is
authorized only while no public key is recorded; afterwards only the
recorded key is returned, and a launch intent requires it. Older
generations, same-generation rivals and unreadable records authorize
nothing, and records hold no private key material.

workerkey.Acquire syncs a new key to disk and returns it only after its
public key is recorded, reusing the on-disk key after a crash before
recording and failing closed on a missing or different key afterwards.
No transport carries the decision yet.

* fix: read SQS first-store decisions from the stream leader

KV.Get uses a direct get, which a lagging replica can answer, so a stale
claimed read could authorize key creation after the leader had recorded
the worker's public key, or make a superseded generation look current.
Reads now use the SQS store's leader-answered stream message get, and
every authorizing answer is also confirmed by a compare-and-swap at the
revision read, so a stale or unavailable read authorizes nothing.

* feat(sqs): add regional worker identity record and instance index

The SQS domain keeps one worker identity record per Region with a monotonic
generation, claim ID, pinned node and encrypted data volume, moving from
launching to bound or failed under compare-and-swap. A launch intent takes
the next generation before any instance exists, so the previous worker stops
being current; binding writes the instance index entry first.

Authorize accepts an instance only while the Region's leader-read record is
bound to exactly that instance, generation and claim, confirmed by a
compare-and-swap at the read revision, so an old, orphaned, rewritten or
unbound index entry, or a lagging replica, authorizes nothing.

Both this bucket and the first-store bucket now open through the kvutil
chokepoint and refuse a stored configuration that could expire, cap or evict
records or holds fewer replicas than the cluster needs. Nothing calls the
store yet.

* fix(sqs): make worker launch exclusive and harden durable buckets

BeginLaunch now refuses a new claim while the previous attempt is launching
or bound, including one whose outcome is unknown, so only one caller ever
holds an intent to start a VM on the pinned volume. A replacement takes the
next generation only after FailLaunch or the new RecordStopped confirms that
nothing holds the volume, and each request names the generation it
supersedes, so a replayed request cannot take a generation it never saw.

The durable bucket opener also refuses non-limits retention, per-message
TTLs, asynchronous flush, a message-size cap, any discard policy other than
discard-new or a per-subject discard, unacknowledged writes, a sealed
stream, and a mirror or sources.

* feat: add internal SQS worker first-store gateway actions

Serve ClaimFirstStore and RecordWorkerPublicKey under the internal
spinifex-sqs signing service, apart from the public AmazonSQS inventory.
Only an sqsWorkerRole session in the system account passes the class
gate, then the role policy; the worker identity is resolved from the
current workerident record by the STS-set session name, never from the
request, and any extra parameter is refused. Seeds and other non-public
keys are refused without being recorded, logged or echoed.

The gateway binds the worker-identity and first-store buckets per
request and never creates them, so it provisions nothing and answers
ServiceUnavailable until a worker launch has created them.

* fix(sqs): fence replaced worker generations out of first-store

A request that passed worker authorization before a confirmed stop and
replacement could reach an unclaimed first-store record ahead of the
successor and create or record the old generation's key. The launch path
now raises a monotonic first-store fence past the replaced generation
before BeginLaunch, which refuses a replacement until a leader read shows
it; firststore refuses every call from a generation below the fence.

The internal adapter no longer logs caller-chosen parameter names.

* feat(sqs): launch the shared SQS worker on its pinned node

Add the SQS worker system-instance kind and a domain launcher that
realizes the Region's worker from its identity record without ever
starting a second VM:

- domains/sqs/workerlaunch returns a bound worker as is, prepares the
  data volume on first launch only, refuses a volume whose own record
  is not encrypted, marks the intent dispatched before launching and
  never resends a dispatched launch: an ambiguous outcome stays
  launching until reconciled. A replacement starts only after a
  confirmed stop or failure and the first-store fence, reusing the
  pinned volume.
- workerident gains MarkDispatched and Current; firststore gains a
  leader-read RecordedKey.
- The daemon launches the worker through the shared direct-boot path
  with its encrypted volume attached at boot and kept on terminate, a
  required instance profile and a mandatory mgmt NIC. Its firmware
  config carries only public values: the data serial, the store
  decision, the gateway URL on this node's bridge and the daemon's own
  cluster CA. A relaunch is refused on any other node.
- sqsWorkerRole is ensured in the system account granting exactly the
  two internal actions; an existing role whose trust admits more than
  EC2 is refused. The role contract lives in domains/sqs/workerrole so
  the composition root does not import the gateway adapter.

The worker network allocator and any caller remain unwired, and the
guest still refuses to create a key for a new store.

* fix: verify SQS worker role trust before granting and report unclaimed volumes

Check that an existing or racing-created sqsWorkerRole trusts only EC2
before writing its policy, and accept the instance profile only when a
re-read shows it carries exactly that role. Refuse the first launch
before allocating when the cluster storage key is not configured, and
return the ID of any volume created but not confirmed in a launch
intent instead of dropping it.

* feat: add private SQS system VPC and worker ENI allocator

Add systemvpc.EnsurePrivate, which builds one VPC and one private subnet
from the VPC and subnet surface only, so it cannot create an internet
gateway, route table, NAT gateway or EIP. The daemon's SQS worker network
adapter uses it with a [sqs] supernet defaulting to 10.240.0.0/14 and
finds or creates one ENI per launch claim in the VPC default security
group, refusing a duplicated claim or an address outside the subnet.

Reword UnclaimedVolumeError: a lost BeginLaunch reply may still have
recorded the volume.

* feat: worker takes its first-store decision from the gateway at boot

The SQS worker guest no longer trusts a static fw_cfg store status. After
selecting, checking and verifying the mount of its own data disk, it asks
the internal spinifex-sqs gateway service for its current first-store
decision, signed with instance-metadata credentials only, over TLS 1.3
trusting only the cluster CA. It then creates and records or loads the
key on that disk; a key lost after recording is never replaced.

- internal/sqsworkergw: SigV4 client for ClaimFirstStore and
  RecordWorkerPublicKey; refuses non-public keys, non-decisions and
  redirects; errors carry only the parsed code
- gwsign.NewInstanceRole: IMDS-only credentials, no env/profile fallback
- workerboot: retries only lost replies and ServiceUnavailable/Throttling
- launch contract carries the Region instead of a store status or key

* fix: worker retries a denial while its launch is still binding it

The worker VM can reach the gateway before its launch binds the instance
identity, and that early call is legitimately AccessDenied. The guest
treated the denial as final and stayed unprepared even after binding.
AccessDenied is now a pending authorization the guest asks about again
within its existing bound; a denied claim creates nothing, and a worker
that is never bound, or was replaced, gives up at the bound.

* feat(sqs): add operator EnsureSQSWorker action and node launch handler

Add the super-admin EnsureSQSWorker(NodeId, InstanceType) action on the
internal spinifex operator service. It validates the node against the
cluster and as a single subject token, then requests
sqs.worker.Ensure.v1.<node> with no queue group, so only that node answers
and the worker stays pinned.

The node handler re-checks the named node and instance type and runs one
composed workerlaunch.Launcher (real volume preparer, role, private network
and direct-boot launch), one launch at a time, in the daemon's own context
with a 30-minute bound. A caller timeout does not cancel the launch; a
retry reports the bound worker, or IncorrectState for a dispatched launch
whose outcome is unknown, which is never resent. The reply names the bound
VM and does not claim the worker or SQS is ready. Nothing starts a worker
at daemon startup.

* feat: ship the SQS worker image and check it before allocating

Build the SQS worker microVM image in both release workflows, stage it into
the tarball as microvm-sqs-worker, and install it with setup.sh under the
store image's rules: a release without it removes an earlier one and an
incomplete one is refused. Add make install-sqs-worker-microvm.

The worker launcher now asks a Host capability whether this node can boot a
worker before it prepares a volume, records or resumes an intent, fences a
replaced generation, allocates the network or dispatches. The daemon checks
for a non-empty kernel and initramfs, its mgmt bridge and the cluster CA, and
the EnsureSQSWorker handler reports a refusal as ServiceUnavailable, so a
call after the image is installed launches instead of leaving an ambiguous
dispatched intent.

Cover missing and incomplete images through the admin action and handler,
a stopped worker left untouched, and an ENI create whose reply is lost.

* fix: never repeat an uncertain SQS worker ENI create

The launcher now looks for the claim's ENI without creating one, marks the
launch intent NetworkAttempted, and only then sends the single create. Once
marked, a missing ENI fails closed with IncorrectState instead of a second
create that could give the claim two interfaces; a visible ENI is reused.

Adds a test that buildSQSWorkerLauncher wires the daemon's real host check.

* feat(sqs): operator-triggered SQS store launch on the worker's node

Add the super-admin EnsureSQSStore action and its node handler. The
store is launched only on the node the Region's worker is bound to,
with the public key that worker recorded in the first-store decision;
the daemon never receives the worker's seed.

A per-Region store launch record marks each step before its uncertain
create (volume, ENI in the worker's private subnet, VM launch), so a
step whose result is not visible fails closed and is never resent.
Volumes are refused unless their own record is encrypted, and their
IDs are kept. The store guest runs under GOMEMLIMIT=512MiB and
instance types at or below that are refused; this is a floor, not a
validated size.

* fix: retry uncreated SQS store volumes and fix the store instance type

A volume preparation that failed before sending any create (a missing
tool, scratch or format failure, or no cluster storage key) now carries
storevolume.ErrNotCreated. The store launcher withdraws its volume mark
and returns ServiceUnavailable, so a retry may try again once the host
is fixed; any create that was sent stays one-shot.

The store launch record now keeps the operator's instance type. A
request for a different type is refused as IncorrectState before
dispatch and after binding, and the reply names the launched type,
which the admin action checks.

* feat: SQS worker store readiness and store size check

The worker's one-shot ready step mounts and verifies its disk, asks the
internal spinifex-sqs DescribeStore action for the bound store, loads its
key only if it matches the recorded public key, and checks the store's
JetStream with it. It retries within a bound while the store is pending or
not answering, and stops on a mismatched or refused key. The NKey
authenticates the worker only; the store server is not authenticated.

EnsureSQSStore now also refuses a different volume size once the store is
bound, instead of returning the existing store.

* fix(sqs): keep worker store readiness current until shutdown

The readiness step now runs as a guest-local watch loop with no overall
deadline: each pass is bounded, a pending or unreachable store is retried
every few seconds, and a ready store is rechecked periodically. The
store-ready marker is removed on any failed pass, cleared at start and
removed on exit; shutdown stops the watcher by its recorded PID. Terminal
refusals such as a rejected or mismatched key back off for minutes rather
than spinning. The NKey still authenticates only the worker to the store.

* feat(sqs): supervise the worker readiness watcher and add spx admin sqs

Run the worker guest's readiness watcher under init respawn, rate-limited,
and remove any stale store-ready marker before each start, so a killed or
crashed watcher cannot leave a false readiness marker. Shutdown blocks
respawn, removes the marker, stops the recorded watcher by PID and start
time with SIGKILL escalation, and unmounts /data only once it has exited;
a surviving watcher leaves every mount in place.

Add spx admin sqs ensure-worker and ensure-store, which call the existing
EnsureSQSWorker and EnsureSQSStore actions through the signed spinifex
gateway path with the standard AWS credential chain. Both refuse missing
inputs before sending anything and report only the bound VM, never
readiness.

* fix: park SQS worker readiness and fix the worker instance type

A readiness start that finds shutdown in progress or no worker key now
logs once and waits in place instead of exiting, so init's respawn does
not restart and log it in a loop; shutdown stops it like a watcher.

The worker identity record keeps each generation's instance type. A
request for another type is refused as IncorrectState before dispatch
and after binding, and only a replacement may change it. The ensure
reply, admin action and spx admin sqs ensure-worker report and check
the recorded type.

* fix: record the SQS worker volume intent before creating it

The first worker launch prepared its data volume before any record
existed, so a create that applied but returned no ID left nothing for the
next call to find, and it could prepare a second volume.

The launch intent is now recorded first, and its one volume create is
marked before it is sent. A Prepare proven to have sent nothing withdraws
the mark and is retryable; any other outcome is never resent and returns
IncorrectState, naming the volume when its ID is known. An intent that
has sent no create may still be re-typed.

* docs: document spx admin sqs ensure-worker and ensure-store

Record the flags, the caller and owning accounts, the bound-versus-ready
distinction and the current retry and recovery limits.

* docs: distinguish SQS ensure timeouts from refusals that created nothing

A gateway timeout means only that no reply arrived; the launch may still be
running. The worker type locks once any step has been attempted.

* docs: state what each SQS ensure ServiceUnavailable establishes

No refusal proves earlier attempts created nothing; a retry checks the
durable attempt and continues only when that is safe.
2026-10-31 00:17:25 +10:00
Josh Siviour 55054ad0ac fix: interim guard against opening a volume behind a live owner holding unsealed writes (#1352)
* test: relay a capacity refusal from an owner holding unsealed writes

* fix: refuse a start elsewhere when the owner holds unsealed writes

* fix: bound the unsealed-writes check well under the start forward timeout

* test: refuse a volume takeover while the marker owner is live

* fix: refuse a volume takeover while the previous owner is live

* fix: retry EKS control-plane starts refused for capacity or a held volume

* docs: describe the interim unsealed-writes takeover guards

* test: give the viperblockd conformance harness a cluster-state bucket

The takeover guard reads node liveness from the cluster-state bucket the
daemon creates. The harness had none, so the real reader errored and the
guard refused every takeover. Create the bucket the way the daemon does so
the real reader runs and finds no heartbeat.

* fix: leave no dirty marker behind when opening a volume that does not exist

An open takes the lease and writes the dirty marker before the volume's
state is read. When the volume turned out to be absent the marker stayed,
so a later open on another node met the takeover guard and was refused
as held elsewhere instead of answering not found. Clear the marker this
open wrote when the state load reports the volume missing.
2026-10-30 23:53:41 +10:00
Josh Siviour 27304bbfd6 refactor: serve the four Bedrock services from one Ochre registration (#1351)
* test: characterise the four Bedrock services through the SigV4 pipeline

* refactor: serve the four Bedrock services from one ochre registration

The Bedrock adapters move from gateway to domains/ochre/awsapi behind a
single dispatch registration. The sub-service scopes are aliases, a
sub-service scope selects its dispatcher outright and only the shared
bedrock scope is split by path. The thirteen Bedrock fields leave
GatewayConfig for awsapi.Deps, and the bodyscope allowlist row for
domains/ochre is deleted.

* docs: record the Bedrock registration in the package boundary migration

* docs: record the Bedrock registration PR number
2026-10-30 23:06:24 +10:00
Josh Siviour a52b078678 refactor: move the ECR /v2 bridge out of the gateway (#1350)
* test: pin the ECR /v2 pipeline through the gateway handler

* feat: rehydrate a principal from an access key id in credverify

Adds credential.Rehydrator and CallerARN on Principal, implemented beside Authenticate and Resolve on the same key, session and user lookups, with a parity table against the gateway's ECR rehydrate.

* refactor: share the credverify key and session lookups with Rehydrate

Authenticate, Resolve and Rehydrate now call one lookup per credential kind, so the checks cannot drift. The parity table also asserts the gateway and credverify caller ARN builders agree.

* refactor: move registry host matching to ingress/http

The matcher takes its service labels from the caller and exposes the parsed account and region through accessors, so ingress names no service.

* refactor: extract the identity-policy evaluator into policyauth

The SigV4 gateway path and the ECR registry authorize through one evaluator and one set of request condition keys, over the credential principal.

* refactor: extract account admission into accountadmission

The SigV4 gateway path asks one Admitter for account status, with the ACTIVE-only cache moved with it, so the ECR registry can use the same capability.

* refactor: move the ECR /v2 bridge out of the gateway

The bridge, token exchange and per-operation authorization now live in
domains/ecr/registry behind registry-owned Principal, Rehydrator and
Authorizer types, so the package imports no ingress code. The awsgw role
adapts credverify, policyauth and host matching to them, and the gateway
keeps only a RegistryHandler mounted at /v2. The bridge refuses to
compose without a verifier, issuer, rehydrator, authorizer or admission,
so the open-registry mode for a nil verifier is gone.

* test: carry the retired ECR principal parity rows into credverify

Global root, a verifier with no IAM, a record with no principal type and
the gone, replaced and legacy session verdicts for user and role sessions
were only exercised by the gateway tests that went with the old path.

* docs: record the ECR /v2 bridge move in the package boundary migration

* docs: record the ECR /v2 bridge PR number
2026-10-30 15:57:37 +10:00
Josh Siviour 26d53602a3 fix: accept AWS-format IRSA trust-policy condition keys (#1349)
* test: cover AWS-form IRSA condition keys and StringLike on web identity

* fix: match AWS-form IRSA condition keys and allow StringLike on web identity

* fix: keep the provider ARN host-path unnormalised for web identity

* feat: log the reason a web identity trust policy denies

* fix: satisfy errname, modernize and gofmt on the denial error

* test: cover the stock AWS IRSA trust policy in the EKS e2e

* test: make the IRSA denial prove the policy was evaluated
2026-10-30 14:34:08 +10:00
Josh Siviour 060204514e refactor: register ECS and EKS through the ingress seam with shared PassRole (#1348)
* test: pin ECS and EKS dispatch through the SigV4 pipeline

* refactor: register ECS and EKS on the ingress seam

EKS resolves the cluster and nodegroup role through passrole.Check with the
IAM role resolver, in the order it had. Responses and check order are unchanged.

* fix: match AWS role handling for ECS and EKS PassRole cases

ECS refuses a foreign task role before PassRole, accepts a role that is not an ARN or does not exist after PassRole, and reports a RunTask override role it cannot assume. EKS CreateCluster and CreateNodegroup refuse a foreign or non-role ARN with AccessDeniedException before PassRole.

* test: cover ECS and EKS awsapi registration dispatch

* fix: report EKS parameter faults as InvalidParameterException

* docs: record ECS and EKS gateway migration and EKS divergences
2026-10-30 14:18:53 +10:00
Josh Siviour 12cea4f6f7 feat: add IAM role resolution and the shared iam:PassRole flow (#1347)
* test: pin the current role-resolution gaps in EC2, ECS and EKS

* feat: add the shared iam:PassRole flow with classified role resolution

* feat: resolve same-account roles for iam:PassRole in domains/iam
2026-10-30 12:54:44 +10:00
Josh Siviour 099a69b23b refactor: register IAM and STS through the ingress seam (#1346)
* docs: point the /admin migration row at spinifex#1345

* test: characterise IAM and STS dispatch through the SigV4 pipeline

* refactor: register IAM through the ingress seam

* refactor: register STS through the ingress seam

* docs: point the IAM and STS migration rows at spinifex#1346
2026-10-30 12:08:15 +10:00
Josh Siviour 6462ccc41f refactor: serve the /admin operator API from the spinifex registration (#1345)
* refactor: move the /admin handlers and wire types into operator/admin

* refactor: serve /admin through the spinifex registration

* docs: record the /admin move into the spinifex registration
2026-10-30 10:50:47 +10:00
Josh Siviour ae8ee1e5fe refactor: register RDS through the ingress seam (#1344)
* refactor(gateway): register RDS through the ingress seam

RDS dispatch moves from gateway/rds.go to domains/rds/awsapi.NewRegistration, composed by awsgw and the integration harness.
The caller comes from dispatch.Actor and the principal-class gate still runs before the policy check.

* docs: point the RDS migration row at spinifex#1344

* chore: group the imports merged in the actor projection test
2026-10-30 09:59:15 +10:00
Josh Siviour 488f1c7bf9 feat: SQS store guest on a prepared cold-attached EBS volume (not wired) (#1343)
* feat: build a separate SQS store microVM image

Package the go.mod-pinned nats-server, ext4 support and a clean ACPI
power-down into a microVM kernel and initramfs of their own. The build
checks the packed archive's contents and refuses a nats-server whose
version differs from the pin. The image does not start the store and
nothing installs or releases it; the LB image is unchanged.

* fix: publish the SQS store image only after its gates pass

Stage the kernel and initramfs and move them into place only once the
content, size and guest-RAM gates pass, so a failed build cannot leave a
pair make takes as up to date. The old initramfs is removed first and the
new one lands last, so an interrupted publish never leaves a mixed pair.
Check the module list without piping into grep -q, which under pipefail
reported a present module as missing.

* feat: start the SQS store guest on its checked data disk

The SQS store image now prepares, runs and stops a dedicated JetStream
server. It reads a narrow fw_cfg contract (data disk serial, listen
address, one user NKey public key), refuses anything absent, malformed
or ambiguous, has the pinned server test the config, then checks and
mounts exactly one matching ext4 disk. It never formats and has no
ephemeral fallback. The server listens only on the supplied guest NIC
address, admits only that NKey and syncs every write.

Shutdown stops the recorded run process first, whether it is the server
or a start not yet exec'd, and runs the generic read-only remount only
once it is gone. A store that survives SIGKILL leaves every mount alone.

* fix: report the SQS store as prepared, not listening, before it starts

* feat: launch the SQS store guest on its own versioned subject

An explicit SQS store kind carries an existing system-account data volume
and the one NATS user public key the store accepts. It is served only on
system.LaunchSQSStore.v1, so a daemon without this code has no responder
and the launch fails instead of booting a load balancer without its disk;
the legacy launch path refuses the kind on both send and receive.

Every refusal happens before capacity, an ENI or a VM record exists. The
volume goes through the normal EBS mount and terminate detach and is
never deleted on termination. The store boots its own image with CPU
mitigations on, gets only its network, disk serial, listen address and
key, and cannot be relaunched after a host restart yet.

* feat: ship the SQS store microVM image in releases and installs

Release builds now build the SQS store image and stage it beside the load
balancer's as microvm-sqs-store/, so an older installer that copies
microvm/* without -r still works. setup.sh installs it to
/usr/share/spinifex/microvm/sqs-store/, where the daemon looks for it.

A release without a complete image never leaves an older one usable:
staging drops a previously staged image, and the installer removes an
earlier installed pair before warning (missing) or failing (incomplete).
Only the two image files are removed. The initramfs is installed last, so
an interrupted install leaves no bootable mismatched pair.

* feat: restart the SQS store guest on its launching node

Persist the non-secret guest config of an SQS store instance (launching
node, data volume, listen address, client public NKey and rendered
network config) in the instance record, and rebuild its fw_cfg files
from it on relaunch. The relaunch refuses on any other node, or when the
record is missing or malformed, before writing any file; the data volume
is remounted through the normal EBS path and kept on terminate.

Cross-node recovery now never selects or claims an SQS store record, so
no other node spends retry budget on it or relaunches it. Same-node
restore is unchanged, as are load balancer restarts. Older daemons know
neither the exclusion nor the record field, so this does not prevent a
cross-node relaunch by a mixed-version cluster.

* feat: hold prepared system volumes in creating until released

Add an internal CreateVolumeForPreparation path that writes a new
volume's first record as creating, and CompleteVolumePreparation to
release an unattached creating volume in the caller's account to
available. Ordinary CreateVolume is unchanged.

Attach and delete already refuse a creating volume. ModifyVolume,
ForceDetachVolume and CreateSnapshot now refuse it with IncorrectState
so nothing can expand, release or capture a volume mid-preparation.

* feat: prepare fresh SQS store volumes with an ext4 filesystem

The SQS store guest never formats a disk, so a new volume gets its
filesystem on the host. The preparer checks for mkfs.ext4 and qemu-img,
builds an ext4 image in private scratch, creates the volume in
"creating", publishes it on this node, copies the image with the NBD
import helper, unpublishes, and only then releases it as available.

It never writes a volume it did not just create. A volume is deleted
only when no export can still be live: after a confirmed unpublish, or
when the export was not NBD. Any publish error, a failed or ambiguous
unpublish, or a failed release retains the volume and reports its ID.
A publish refused as in-use or already-exists sends no unpublish, since
another export may exist. Cleanup ignores caller cancellation but is
bounded at 30s.

The raw-image copy moves to runtime/compute/nbd unchanged, and the
domain receives it as an injected writer. The volume service gains a
discard path for an unattached volume still in "creating".

* fix: report unknown state and match qemu-img lookup in volume prep

A failed release may still have made the volume available, so the
retained-volume error now says its state is unknown rather than
claiming it is still creating.

The copy runs qemu-img by name through PATH, so the pre-allocation
check now uses a PATH lookup for it too. mkfs.ext4 keeps the sbin
fallback because it runs from the resolved path.

* docs: describe the unwired SQS store system instance

Records what the guest image, versioned launch, same-node restart and
fresh-volume preparation provide, that nothing calls them, and what is
untested or missing before production use.
2026-10-30 08:16:43 +10:00
Ben Duncan 3617e4a045 fix: log an expected outcome at its own level, not ERROR (#1342)
A dispatch failure was logged at ERROR whatever the caller was about to
be given, so DryRunOperation, which AWS documents as the success answer
to a dry run, read as a fault of ours along with every NotFound and
malformed parameter. The level now comes from the status the caller
receives, resolved the way ErrorHandler resolves it.

QEMU announces its own exit on stderr, so a guest stopping on the
SIGTERM the daemon sent it was logged at ERROR too.
2026-10-29 14:38:41 -05:00
Josh Siviour 24281cb860 fix: count volume lease validity from the write, not its reply (#1340)
* fix: count volume lease validity from the write, not its reply

The server starts a lease entry's TTL when it applies the write, but the
holder started its validity window when the acknowledgement arrived, so a
slow reply stretched validity past the server's expiry and a peer could
acquire the volume while the holder still considered it valid.

Validity now counts from when each create, generation publish and renewal
was sent. An acquisition acknowledged after validity lapsed is refused as
retryable, and a lost or lapsed lease still awaiting its fence is no
longer shared with another open on the same node. A renewal that lands
after the lease was given up, or after its validity elapsed, cannot revive
it: the lapse check and the update run under the lease lock, and fencing
runs once. The renew loop also fences at the validity deadline itself
rather than at the next check, which could consume the whole margin.

This bounds local validity only; it is not backend-enforced fencing, and a
paused host can still write after its validity ends.

* docs: describe volume lease validity, fencing and their limits
2026-10-29 21:38:22 +10:00
Josh Siviour e58838bc67 refactor: verify SigV4 credentials through an ingress capability (#1339)
* refactor(gateway): verify SigV4 credentials behind a CredentialVerifier

The authenticator no longer imports the IAM and STS domains. It hands the
signature check to a verifier as a callback, so the secret never leaves the
adapter, and runs the account-active check between Authenticate and Resolve.
A nil verifier fails every request closed.

The adapter lives in runtime/roles/awsgw/credverify. ResolveCallerUser and the
principal-type constants move from domains/sts/awsapi to domains/sts so the
adapter can use them without importing an awsapi package.

* fix(gateway): guard a nil credential verifier and tighten its tests

A typed-nil credverify.Verifier now fails closed instead of panicking.
The session verdict test goes through the real verifier into the lockout boundary.
Test setups build the verifier from the same mock as the gateway services.

* test(gateway): commit the verifier test helper
2026-10-29 21:30:06 +10:00
Josh Siviour f0e4ffe70c refactor: give seam services the verified caller's identity facts (#1338)
* test: pin the caller facts the gateway gives a registered service per principal kind

* refactor(gateway): seal the verified caller into one request-context value

* feat(dispatch): give registered services the caller's principal ARN, user ID, user name and session name

Actor gains PrincipalARN, UserID, UserName and SessionName, filled from the
sealed principal, and RoleName(), which keeps the account-checked role ARN
rule in one place. No consumer reads them yet.

* refactor(gateway): read the caller from dispatch.Actor in the RDS, EKS, STS, IAM and admin handlers

callerActor builds the Actor the seam hands services, and the seven readers that
took fields off the sealed principal now take them from it. RDS and EKS take the
role name from Actor.RoleName(). No behaviour change.
2026-10-29 16:13:41 +10:00
Josh Siviour 18adea9486 refactor: register the spinifex operator service through the ingress seam (#1337)
* test: characterize the spinifex Query and /admin surfaces through the full gateway pipeline

* refactor: register the spinifex Query service from operator/admin

* docs: point the spinifex registration migration row at the PR
2026-10-29 14:55:50 +10:00
Julian Sommer f06def6894 chore: update operation count 2026-10-29 13:50:59 +10:00
Julian Sommer ef7d30578f chore(awsmodel): sync AWS models to api-models-aws e10d6a1c (2026-10-28)
Seven models changed and add 26 unimplemented operations. The request sweep gains 33 requests with no new findings.
2026-10-29 13:35:39 +10:00
Julian Sommer b76679e571 fix(integration): restore ELBv2 to the request conformance sweep
The ELBv2 move to the ingress seam added its inventory to the coverage tool but not to TestRequestConformance, so 36 operations and 45 findings dropped out while the suite passed. A served service with no implemented operations now fails the sweep.
2026-10-29 13:34:12 +10:00
Josh Siviour d7d922e963 refactor: register Tagging and ELBv2 through the ingress seam (#1336)
* test: characterize Tagging dispatch, authorization and GetResources responses through the gateway pipeline

* refactor: register Resource Groups Tagging through the ingress seam

* test: characterize ELBv2 through the full gateway pipeline

* refactor: register ELBv2 through the ingress seam

* test: cover the ELBv2 registration, query parser and load balancer limit

* test: characterize RDS through the full gateway pipeline

* docs: point the Tagging and ELBv2 migration rows at the PR

* test: register Tagging and ELBv2 on the integration harness gateway
2026-10-29 13:27:43 +10:00
Julian Sommer 64e3711893 fix(awsmodel): accept InvalidGatewayID.NotFound in the EC2 error catalog
AWS's EC2 error reference documents it, and DeleteEgressOnlyInternetGateway and ReplaceRoute answer it, so the integration suite reported two false violations.
2026-10-29 13:27:09 +10:00
Josh Siviour ad9568a6c6 refactor: move gateway/spx, admin wire types and EKS OIDC discovery out of gateway (#1334)
* refactor: move gateway/spx to operator/admin

* test: pin the admin wire types and method list in golden JSON

* refactor: move the admin wire types and method list to contracts/operator/v1

* refactor: serve EKS OIDC discovery and JWKS from the EKS domain

* refactor: assert the EKS OIDC publisher satisfies the gateway interface

* docs: cite the pull request in the gateway root-move rows
2026-10-29 12:22:16 +10:00
Josh Siviour dedb3d8d75 ci: keep the E2E WAN packet captures only for runs that did not pass (#1335)
The captures are about 90% of each E2E artifact and nothing reads them unless
a run failed, so a clean single-node or multi-node pass drops them before the
artifact upload.
2026-10-29 12:21:30 +10:00
Julian Sommer cc1186fa48 fix: grant the build user passwordless sudo for the appliance build when it lacks it, and remove the grant at seal 2026-10-29 11:10:51 +10:00
Julian Sommer dbdb827a69 fix: install tar on a minimal Oracle Linux build system before copying the appliance payload 2026-10-29 11:05:35 +10:00
Josh Siviour c94b0f22f6 feat: accept EnaSupport on RegisterImage, report it on instances, and name refused parameters (#1332)
* feat: accept EnaSupport on RegisterImage and name the refused parameter in its errors

EnaSupport true and false are stored on the image record, returned by DescribeImages when set and carried through CopyImage. Every validation error in ValidateRegisterImageInput keeps its code and now says which parameter and value it refused.

* feat: set EnaSupport on images created from an instance

AWS takes an image's EnaSupport from the instance it was made from, and every instance here reports it, so the source AMI's value is not inherited.

* docs: record RegisterImage EnaSupport support and the refused parameters

* fix: report EnaSupport on DescribeInstances and RunInstances
2026-10-29 10:53:30 +10:00
Julian Sommer 71567d0998 fix: show the appliance password rules before the first-login change 2026-10-29 10:51:06 +10:00
Josh Siviour 11e8b68ddc refactor: remove the unused host SQS message store service (#1333)
* refactor: remove the host SQS store systemd unit

The SQS message store will run inside system-owned instances rather than
as a host daemon, so spinifex.target no longer starts it. Bump the target
to version 4 and drop the unit from the embedded set and its invariants.

* refactor: remove host SQS store config provisioning

The SQS store will run in system-owned instances, so admin init and join
no longer generate or distribute a host store token, routes or config,
and the formation join payload drops the store token field.

* refactor: remove host SQS store service command and role

The SQS store will run in system-owned instances, so drop the unused
spx service sqs-store command, its service registration and the host
sqsstore role. The domains/sqs/message JetStream store is unaffected.

* refactor: remove host SQS store provisioning from install scripts

The SQS store will run in system-owned instances, so setup no longer
creates the spinifex-sqsstore user, its directories or the 4223/4249
peer firewall ports, and install, reset and uninstall stop handling the
retired unit, user and data directory.

* docs: drop the host SQS store from firewall and connection tables

Spinifex no longer ships a host SQS store service, so remove its ports
from the host firewall and network connection references and say in
DESIGN.md that no host service, unit, user or firewall rule exists for it.
2026-10-29 10:48:05 +10:00
Julian Sommer 8f4754da13 fix: install the appliance with the setup.sh from the release tarball, since install.mulgadc.com serves the stable one 2026-10-29 10:30:48 +10:00
Julian Sommer 8b965d24ce fix: refuse Route 53 requests that break the model's bounds with InvalidInput (#1329) 2026-10-29 11:29:45 +11:00
Julian Sommer ac2620df58 fix: report an SSH failure in build-oracle-appliance.sh as one, not as missing sudo 2026-10-29 10:26:09 +10:00
Julian Sommer 2feb264863 feat: Oracle Linux multi-node clusters with OVN IPsec (#1327) 2026-10-29 11:17:22 +11:00
Josh Siviour ddadfa2812 chore: remove unused e2e suite-selection manifest and drift lints (#1331)
The targeted suite selector (select-suites) has not run in CI since its shadow-mode step was removed in #556.
The service-interfaces manifest, manifest-check and manifest-lint existed only to feed it, so they go too.
Also drops the now-meaningless e2e:allow-create markers, keeping their rationale as plain comments.
2026-10-29 10:04:31 +10:00
Josh Siviour 5923143062 feat: set X-Forwarded-For, -Proto and -Port on ALB requests (#1330)
* feat: render X-Forwarded-For, -Proto and -Port headers in ALB HAProxy frontends

Append joins every client X-Forwarded-For line and adds the client address as AWS does, preserve and remove follow the load balancer attribute, and Proto and Port are always overwritten.

* feat: validate the ALB forwarding attributes and re-render config when they change

ModifyLoadBalancerAttributes rejects bad xff_header_processing.mode and xff_client_port.enabled values with ValidationError before storing anything, and regenerates the stored config when either key changes.

* test: assert the ALB X-Forwarded headers and the remove mode in the lb e2e

The app instances echo the request headers on /__headers. The internal ALB is driven from the probe client and the HTTPS ALB from the runner, covering the caller address, a client value, spoofed Proto and Port, mode remove and a rejected mode.

* test: let the lb probe client trigger accept a null header list

* docs: document the ALB forwarding headers and their attributes
2026-10-29 09:33:39 +10:00
Julian Sommer 46ca3dfba1 chore: comment 2026-10-29 09:13:32 +10:00
Julian Sommer 596b5b1733 fix: refuse DeleteVpc while an egress-only internet gateway is attached (#1325) 2026-10-29 09:53:48 +11:00
Josh Siviour 1c5eaaf852 fix: use canonical OCI header names in the ECR registry (#1328) 2026-10-29 08:53:39 +10:00
Julian Sommer 49117cf7b0 fix: return InvalidIPAddress.InUse when releasing an associated elastic IP (#1326) 2026-10-29 09:53:31 +11:00
Julian Sommer 3a9985c79d fix: return ResourceNotFoundException from EKS DeleteCluster for a nonexistent cluster (#1324) 2026-10-29 09:53:16 +11:00
Josh Siviour c42a352064 feat: add store-neutral SQS message store with a JetStream adapter (#1323)
* feat: add the store-neutral SQS message store contract and conformance suite

Defines message.Store over opaque shard and queue ID namespaces with no
create call, plus a reusable conformance runner exercised against a
test-only in-memory reference store.

* feat: add the SQS message store's JetStream layout, append and state CAS

Provisions the fixed set of shared message streams and state buckets,
publishes each queue on sqs.m.<shard>.<queue>, dedupes idempotent appends
with a queue-scoped Nats-Msg-Id, and maps state revision CAS onto KV.

* fix: give each SQS queue immutable message and state shard allocations

A changed stream or bucket count applies only to new queues: the namespace
names its message shard and state shard directly, retained streams and
buckets stay addressable, and the store refuses cluster sizes other than
one node (R1) or three (R3).

* fix: enumerate complete SQS namespaces and backstop state buckets

Enumeration now returns each namespace with both allocations, so a
state-only namespace can be counted and cleaned without its queue record,
and state buckets expire records after 14 days plus 1 hour.

* feat: add the SQS JetStream store's reads, counts and cleanup

Get and Delete are scoped to the namespace's subject, DeleteThrough and
DeleteNamespace are subject-filtered purges that leave shard neighbours
intact, and Count reads the per-subject message total and live state keys.

* fix: count distinct SQS state keys and validate before namespace purge

The key lister can repeat a key under concurrent writes, so Count tallies
distinct names; DeleteNamespace resolves both the message stream and the
state bucket before purging either, so a wrong state shard deletes nothing.

* feat: add the SQS JetStream store's ordered message-shard feed

Append records the namespace's state shard in an internal header that is
never returned as metadata. Feed reads one shared message stream through
an ephemeral, headers-only ordered consumer with no acknowledgement state,
rebuilds each change's complete namespace, refuses a malformed header with
ErrCorruptRecord, and deletes its consumer on Close.

* feat: enumerate SQS namespaces and run conformance on the JetStream store

Namespaces joins per-subject body counts, whose internal header names the
state shard, with live state keys from every state bucket that exists,
found by listing so a historical bucket counts and none is created. An
allocation conflict is reported as ErrCorruptRecord. The store now
implements message.Store and runs the conformance suite on R1, per case
with crash reopen and on one shared instance.

* fix: check every SQS body's state shard when enumerating namespaces

Sampling the first and last body let a middle body naming another state
shard pass. Namespaces now reads every body's header in one headers-only
ordered pass over the shard's stream, counting each queue's bodies from
that pass, and deletes its consumer when done.

* test: run SQS store conformance on R3 and guard against consumer state

TestR3Conformance runs the conformance suite on a real three-node
JetStream cluster in process, with Reopen restarting every node over the
same data, and a check that every stream and state bucket is R3.
TestS5_NoConsumerDeliveryStateInSQS fails on any acknowledgement,
redelivery, durable-consumer or ack-retention mechanism in SQS source.

* test: bar legacy NATS acknowledgement helpers from SQS source

Adds AckSync, ManualAck, AckAll and AckExplicit to the ADR-0011:S5
consumer-state guard.

* feat: add a bounded SQS store scan and the tombstoned-body sweeper

Store gains Scan: a paginated, headers-only pass over a message shard,
bounded at the shard's last position when it starts, skipping deleted
positions and never returning a later append. The conformance suite and
fake cover continuation, deleted runs and the bound.

The JetStream adapter reads every bounded range (scan, body enumeration
and state keys) through one reader whose completion rests on a direct get
of the next match, not on consumer state; the client's key lister and
pending counts are no longer trusted to end a pass.

message.Sweep walks a shard with Scan and, for each body whose record the
domain reports terminal, deletes the body then the record by revision.

* test: add an opt-in SQS store conformance run on an external R3 cluster

TestR3ExternalConformance runs the conformance suite against the
dedicated three-node SQS JetStream cluster named by SQS_STORE_R3_URLS,
checks every stream is R3 with two peers, and uses SQS_STORE_R3_RESTART
to restart every node for the crash cases. It skips when unset.

* test: run the SQS sweeper against the reference store fake

Covers live records kept, tombstones swept, another shard untouched, a
no-op rerun, and a retry after the body delete succeeds and the state
delete fails.

* test: harden the SQS store R3 harnesses against restart and placement races

The external R3 harness reused one connection across restarts, so a case's
cleanup closed it for every later case; each case and restart now opens its
own connection and waits for stream leaders.

The in-process cluster released its free ports before the servers bound them,
and R3 placement can fail until the meta leader has every peer's config and
stats. Client ports are now server-chosen, route ports are reserved together,
and provisioning retries the idempotent New until placement succeeds.

* fix: stop the SQS store feed idling for a whole pull expiry

Under load an ordered feed on a three-node cluster sat for nats.go's 30s
default pull expiry, once 60s, with stored changes pending and no pull
waiting on its consumer, both on feeds opened after a full restart and on
feeds kept open across one. Feed now sets a 2s pull expiry.

Adds restart tests for a feed opened after a full-cluster restart and one
open across it, each replaying a shared-shard backlog then a live append and
failing on any 5s gap. The in-process cluster keeps client ports across
restarts so open connections reconnect, and can write node logs.

* fix: read SQS message state from the stream leader

GetState used nats.go's KV Get, which switches to a direct get whenever
the bucket allows it. On a replicated bucket a follower can answer that
from state it has not applied yet, so a record the leader had already
deleted or purged could still be returned.

Read the key's last message with a stream message get instead, which
only the stream leader answers, and apply KV Get's delete and purge
marker rules to the result. Bucket configuration is unchanged. Missing,
deleted and purged records still return ErrNotFound; values, revisions,
context errors and the API prefix or domain of the caller's JetStream
context are preserved.

* docs: narrow the SQS GetState leader-read comment to what it avoids

* fix: enumerate SQS state keys from the stream leader

Count and Namespaces found live state keys with a direct-get probe and a
headers-only ordered consumer on the state bucket's stream. On a
replicated bucket a follower can answer either from state it has not
applied, so keys the leader had already deleted could still be counted.

Walk the key prefix with stream message gets by next subject, which
only the stream leader answers, through the stream's last sequence at
the call. Delete and purge markers drop a key, as GetState reads them.
This costs one leader request per record under the prefix. Bucket
configuration, historical state shards, the message-body scans and the
feed are unchanged.

* test: keep SQS API recorder deliveries in arrival order

The recorder fed API subjects and its sentinel to two async
subscriptions, which nats.go delivers on separate goroutines, so drain
could return before the last request was recorded. Both now share one
channel that the connection fills in arrival order.

* fix: read SQS body enumerations from the stream leader

Namespaces and Scan read bodies through an ephemeral ordered consumer,
which the server may place on a follower that has not applied a purge,
so a deleted queue could still be listed or scanned. Each stored match
through the bound is now a leader stream message get. That transfers
every full body, where the consumer read headers only.

* test: stop the SQS API recorder goroutine with its test

The recorder ranged over a caller-owned ChanSubscribe channel, which
nats.go never closes, so its goroutine outlived every test. Cleanup now
unsubscribes both subscriptions, then signals the goroutine and waits
for it to return; the sentinel send also watches that signal.

* fix: recover a stalled SQS feed from its cursor

An ordered feed was seen to idle with stored changes pending after a
cluster restart. Next now waits at most an idle check on the consumer,
then asks the stream leader whether a change follows the cursor; if one
does, the consumer is deleted and reopened just after the cursor, so no
change is skipped or repeated. The root cause of the stall is unknown.

* fix: make closing an SQS feed terminal and safe against Next

Close now marks the feed closed under a lock, cancels any in-flight
Next, stops the iterator and deletes the consumer. A later or in-flight
Next returns ErrFeedClosed and never opens a consumer; one opened by a
recovery racing Close is deleted rather than adopted. Repeated Close
returns nil.

* fix: define SQS feed close in the store contract

message.Feed now documents one Next reader with Close callable at any
time and more than once, and message.ErrFeedClosed replaces the
adapter's own error. Once Close has begun, Next returns ErrFeedClosed
and withholds any change it had received; a feed from the last cursor
still delivers it. Shared conformance cases and the reference fake
enforce this.

The JetStream feed's Close also waits, within a bound, for a consumer
open already in progress, so a consumer created during Close is deleted
before Close returns nil; past the bound, or if that delete fails, Close
returns an error and a repeated Close waits again.

* fix: track SQS feed consumer cleanup until Close succeeds

Every consumer open and release is now tracked work recorded under the
lock that takes the consumer, and a delete that fails is kept for Close
to retry. Close returns nil only once no such work is in progress and no
failed delete remains, waiting within a bound and otherwise returning an
error; concurrent and repeated Close calls wait the same way.

Next claims a change under that lock and advances its cursor there, so a
change is either returned before Close begins or withheld for a feed
resumed from the last cursor.

A consumer create no longer inherits the caller's cancellation, which
was seen to leave most cancelled creates on the server with no owner;
it has its own timeout, and the name of one that fails is deleted and
kept for Close to delete again.

* fix: release SQS feed consumers opened after the caller's context ends

Feed and recovery now check the caller's context before starting a
consumer open and after it completes. A create that finishes after the
context ended is released as tracked work and the context's error is
returned, or ErrFeedClosed when Close won; no change is returned.

The feed contract and adapter docs now describe cleanup as complete for
consumers the feed knows of, with the server's one-minute inactive
threshold removing one an ambiguous create lands after its last delete.

* fix: report cancellation when an SQS feed open fails after it

A first consumer open that fails after the caller's context ended now
returns the context's error joined with the failure, so cancellation
stays detectable. The feed contract's cancellation text is narrowed to
opening and reopening, whose cleanup is best effort.

* docs: describe the internal SQS message store and its limits

DESIGN.md gains a section on the store-neutral SQS message store, its
JetStream adapter and conformance suite: an internal primitive with no
service wired to it, its shared stream and bucket layout, leader-directed
reads and their full-body cost, feed recovery and its best-effort
consumer cleanup, and the absence of end-to-end or multi-host
validation. The Scan contract says metadata only, not headers only.
2026-10-29 08:43:00 +10:00
Josh Siviour 61686f2530 test: run the LB health suites serially after the parallel internal group on multi-node (#1320) 2026-10-29 07:53:49 +10:00
Josh Siviour d5f4d48956 feat: serve the OCI referrers API for ECR repositories (#1319)
* feat: record manifest subject and artifactType and return OCI-Subject on push

* fix: resolve the manifest media type once so a parameterised Content-Type cannot misclassify an index

* feat: index referrers per subject and serve them through the metadata service

* feat: serve the OCI referrers API for ECR repositories

* test: cover the ECR referrers route in the gateway authorization matrices

* feat: delete a subject image's referrers with it

* fix: satisfy nilerr in the referrers manifest parser

* fix: return the manifest parse error and format the referrers code

* docs: describe the ECR OCI referrers API and cosign support

* docs: name oras cp and crane copy as the way to carry v3 signatures to an ECR mirror

* docs: present oras cp and crane copy as one remedy for copied v3 signatures
2026-10-28 22:52:47 +10:00
Josh Siviour acfa078586 fix: take an EKS control plane out of rotation when its gateway is down (#1318)
* feat: add a gateway readiness endpoint to the EKS token webhook

* feat: serve token webhook readiness beyond loopback and admit it to the control-plane SG

* feat: health-check the EKS apiserver target group on the token webhook readiness port

* style: order the strconv import

* fix: export the readiness listener address to the EKS token webhook

* docs: regenerate the ELB coverage README

* docs: describe EKS control plane readiness and what happens when a gateway stops

* test: range over SplitSeq in the webhook initd guard
2026-10-28 21:12:29 +10:00
Josh Siviour 17dd3f325b fix: stop routing to unhealthy load balancer targets and apply health-check changes (#1317)
* feat: add routableTargets helper that skips unhealthy targets and fails open

* fix: drop unhealthy targets from the NLB nginx upstream, failing open when none are healthy

* fix: re-render load balancer config when a target health state changes

* fix: write load balancer config and heartbeat fields under CAS so a re-render is never rolled back

* fix: write live load balancer modifications under CAS so they cannot roll back a re-rendered config

* fix: let ALB backends fail open to unchecked backup servers when every target is down

* test: cover NLB unhealthy-target routing and ALB fail open in the lb e2e suite

* test: flip ALB backend health per instance and log how long each health phase takes

* style: gofmt haproxy backend struct and the attributes fetch table

* fix: re-render load balancer config when a target group's health check changes

* fix: return the health-target marshal error instead of discarding it

* test: mark the health-check target group create as an intentional create-path test

* docs: describe target health, fail open and time to effect in the ELBv2 guide
2026-10-28 19:54:49 +10:00
Josh Siviour d2b02414b5 feat: direct-boot microvm data volumes and refused hot-attach (#1316)
* feat: render a data volume as an MMIO virtio-blk-device

A package-private helper reuses the PCI volume device's id, drive,
iothread, serial and stop-on-error options, without a PCI bus.

* feat: cold-boot microvm data volumes on MMIO without a PCI hot-plug port

microvm has no PCIe root ports, so its data volumes render as
virtio-blk-device and take no hot-plug port; a recorded port is kept.
NBD and host-block exports and q35 PCI allocation are unchanged.

* feat: compose a launch-only microvm config with EBS data volumes

directBootLaunchConfig copies a clean direct-boot base and appends the
mounted data volumes after its devices, rejecting boot and EFI volumes
and non-microvm bases. Neither input is modified, so repeated launches
never accumulate entries or keep a stale endpoint.

* feat: launch direct-boot microvms with mounted data volumes from a launch-only config

startQEMU composes the direct-boot command from the saved base and one
snapshot of the mounted requests, which also feeds the readiness probes,
so the launched endpoint is the one checked and is never saved.

* fix: refuse volume hot-attach on direct-boot microvms before any mount

A microvm's MMIO bus has no PCIe root port to hot-plug into, so
AttachVolume returns ErrHotAttachUnsupported before mounting the volume
or changing instance or volume state.

* fix: return UnsupportedOperation for microvm volume hot-attach

AttachVolume's ErrHotAttachUnsupported is a caller-fixable refusal, so
the attach/detach error boundary maps it to a 400 UnsupportedOperation
instead of a generic server error.

* docs: describe direct-boot microvm data volumes and refused hot-attach
2026-10-28 19:45:47 +10:00
Julian Sommer af3fe610f9 refactor: enable eslint no-use-before-define in spinifexui
Enables the rule with functions: false, since hoisted declarations are safe, and defines CONDITION_FIELD_TEXT and the handoff message listener before their first use.
2026-10-28 15:30:07 +10:00
Julian Sommer c8092975c2 refactor: enable eslint complexity in spinifexui and split the components that exceeded it (#1315)
Turns on oxlint's eslint/complexity rule (max 20) and brings the 18 offending functions under the limit by extracting helpers, hooks and subcomponents with no behaviour change.
Shares the RDS subnet and parameter group option builders through rds-form-fields, and rebuilds dist.
2026-10-28 16:18:57 +11:00
Josh Siviour 49a4a07e00 fix: trust every gateway's ECR registry host on EKS workers (#1314)
* refactor: share the ECR registry address precedence in domains/ecr

* feat: expose every gateway's ECR registry host to the EKS service

* fix: skip wildcard peer hosts when listing ECR registry hosts

* feat: trust every gateway's ECR registry host on EKS workers

* docs: describe how EKS workers trust every gateway's ECR registry host

* docs: split the ECR gateway limits to one sentence per line

* refactor: format the registry URI host in domains/ecr so the daemon avoids the awsapi adapter

* docs: state that a refused gateway fails over at once and record the 3-node ECR measurements
2026-10-28 14:28:12 +10:00
Julian Sommer 02fd49be7e fix: block DeleteSubnet when an ENI record in the account cannot be decoded (#1313) 2026-10-28 14:14:25 +11:00
Josh Siviour 2aac488fa7 fix: stop TCP stalling through the VPC gateway by setting gateway MTU and clamping MSS on the host hop (#1312)
* fix: add GatewayMTU capping the gateway MTU at the internet path MTU

* fix: set gateway_mtu on the VPC gateway router port so oversized packets get ICMP frag-needed

* fix: wire the gateway MTU into vpcd and cover reconcile convergence

* feat: add host MSS clamp tables for the veth and routed uplinks

* fix: shorten MSS clamp comments and log the periodic ensure at debug

* feat: run the host MSS clamp loop in vpcd

* fix: run nft under the ambient CAP_NET_ADMIN instead of sudo

* docs: describe the gateway MTU and the host MSS clamp for the internet path
2026-10-28 12:01:50 +10:00
Julian Sommer 11ef3b63b8 chore: parallelise silent-node tests in operator/accountlifecycle (#1311)
Each silent node costs the full fan-out deadline, which the test cannot
shorten, so let those tests and their subtests overlap.
2026-10-28 11:32:20 +11:00
Julian Sommer d5fbb7b628 chore: cut wall time of dns, ochre, gateway and operator/cli test packages (#1310)
Bound the zone reload wait with a test context, generate 2048-bit keys in
cert renew tests via the existing seam, replace 100ms negative waits with
deterministic checks, and run tests bound by production timeouts in parallel.
2026-10-28 11:10:05 +11:00
Julian Sommer d953d0a973 fix: treat Ubuntu's openvswitch.service alias as the Debian OVS unit
Ubuntu 26.04's openvswitch-switch declares Alias=openvswitch.service, so
enabling it leaves an /etc symlink that distro_ovs_unit read as a second,
EL-style umbrella unit. setup-ovn.sh then refused to pick one and every
ubuntu-26.04 bootstrap failed. A symlink back to openvswitch-switch.service
now counts as the Debian unit; a real or masked openvswitch.service still
does not.
2026-10-28 09:46:45 +10:00
Julian Sommer 43e84874df fix: validate GetResources page size and token as AWS does (#1309)
ResourcesPerPage outside 1-100 and a malformed PaginationToken now return
InvalidParameterException. An unbounded page size let start+size overflow
negative and panic on the slice.
2026-10-28 10:17:18 +11:00
Julian Sommer 3f7664ddf9 feat: send Route 53 through the request conformance suite (#1308) 2026-10-28 10:08:28 +11:00
Julian Sommer 7528c063cb feat: make TestMultinodeIPSec fail unless cross-node guest traffic leaves as ESP (#1307)
The test checked IPsec config and that AES-GCM SAs exist, and only warned when
a 5s idle capture saw no ESP, so a cluster sending plaintext Geneve passed.

It now launches an instance pair on distinct nodes, pings 200 x 1000 bytes
between them, and for each direction fails unless the outbound SA counters to
the peer's ovn-encap-ip rose by the payload, an outbound-only capture on the
interface routing to the peer saw ESP or UDP/4500 ESP, and saw no UDP/6081.

The distinct-node launch loop moves from the baseline cross-host test into a
shared helper. The test is no longer parallel since it now launches instances.
2026-10-28 10:00:47 +11:00
Julian Sommer 772e1b4c37 fix: report a missing EKS node group with the AWS node-group message instead of the cluster message (#1306) 2026-10-28 09:19:58 +11:00
Julian Sommer 05256f1cfc fix: let DeleteSnapshot name the unreadable record or provider failure instead of a bare ServerInternal (#1305) 2026-10-28 09:16:37 +11:00
Julian Sommer 920f0487a3 feat: include the Route 53 API in the AWS operation coverage report
Vendor the route-53 2013-04-01 Smithy model at the pinned commit, add the
Route53 service, and pass route53awsapi.OperationInventory() to the
generator. coverage.json and docs/coverage/route53 now list the 13 served
operations; the Spinifex activation extensions stay out of the model count.

The generator now fails when a served service has no embedded model, so
the next one cannot drop out of the report silently. Request encoding
skips rest-xml by protocol, which covers Route 53 as well as S3.
2026-10-28 08:12:55 +10:00
Julian Sommer 919d083bbf chore: name the Route 53 inventory in the aws-model-coverage layering exception
cmd/aws-model-coverage reads route53awsapi.OperationInventory() as it does
ECR's and ACM's, under the same recorded ADR-0001 rule 5 exception.
2026-10-28 08:12:55 +10:00
Julian Sommer 816b3168b7 docs: use --node-role in the EKS guide's create-nodegroup example (#1304) 2026-10-28 09:10:21 +11:00
Julian Sommer a33fbcc696 fix: serve the node's configured region in IMDS instead of deriving it from the zone name (#1303) 2026-10-28 09:09:23 +11:00
2621 changed files with 175794 additions and 53679 deletions
+3
View File
@@ -0,0 +1,3 @@
# The committed bundle carries the AWS SDK's template strings verbatim,
# including trailing spaces that are part of their values.
spinifex/runtime/roles/spinifexui/frontend/dist/** -whitespace
+33 -46
View File
@@ -19,8 +19,8 @@
</defs>
<rect width="900" height="480" fill="url(#bg)" rx="12"/>
<text x="24" y="30" font-family="'Segoe UI', system-ui, sans-serif" font-size="11" fill="#a1a1aa" letter-spacing="2.5" font-weight="600">NGINX + ALB · VPC WITH PUBLIC AND PRIVATE SUBNETS</text>
<text x="876" y="30" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#71717a" text-anchor="end">10.20.0.0/16 · multi-AZ</text>
<text x="24" y="30" font-family="'Segoe UI', system-ui, sans-serif" font-size="11" fill="#a1a1aa" letter-spacing="2.5" font-weight="600">NGINX + ALB · PUBLIC SUBNETS · SPREAD ACROSS NODES</text>
<text x="876" y="30" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#71717a" text-anchor="end">10.20.0.0/16 · server_count = 3</text>
<!-- Internet -->
<rect x="350" y="54" width="200" height="40" rx="8" fill="url(#inet-grad)" stroke="#3fb950" stroke-width="1.1" stroke-opacity="0.55"/>
@@ -41,58 +41,45 @@
<line x1="230" y1="184" x2="230" y2="202" stroke="#DC2626" stroke-width="1" stroke-dasharray="3,3" opacity="0.6"/>
<line x1="670" y1="184" x2="670" y2="202" stroke="#DC2626" stroke-width="1" stroke-dasharray="3,3" opacity="0.6"/>
<!-- public_a -->
<g transform="translate(108, 202)">
<rect width="244" height="104" rx="8" fill="url(#pub-grad)" stroke="#3fb950" stroke-width="1.2" stroke-opacity="0.55"/>
<rect width="244" height="150" rx="8" fill="url(#pub-grad)" stroke="#3fb950" stroke-width="1.2" stroke-opacity="0.55"/>
<text x="20" y="24" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#3fb950" font-weight="700">public_a</text>
<text x="224" y="24" font-family="'Segoe UI', sans-serif" font-size="9.5" fill="#7ee787" text-anchor="end">az-a</text>
<text x="224" y="24" font-family="'Segoe UI', sans-serif" font-size="9.5" fill="#7ee787" text-anchor="end">10.20.1.0/24</text>
<line x1="16" y1="32" x2="228" y2="32" stroke="#3fb950" stroke-opacity="0.3" stroke-width="0.5"/>
<rect x="20" y="42" width="200" height="22" rx="4" fill="#3fb9501a" stroke="#3fb950" stroke-opacity="0.5" stroke-width="0.6"/>
<text x="120" y="57" font-family="'SF Mono', ui-monospace, monospace" font-size="10.5" fill="#7ee787" text-anchor="middle" font-weight="600">ALB ENI</text>
<rect x="20" y="70" width="200" height="22" rx="4" fill="#3fb9501a" stroke="#3fb950" stroke-opacity="0.5" stroke-width="0.6"/>
<text x="120" y="85" font-family="'SF Mono', ui-monospace, monospace" font-size="10.5" fill="#7ee787" text-anchor="middle" font-weight="600">NAT GW</text>
<rect x="22" y="42" width="200" height="22" rx="4" fill="#3fb9501a" stroke="#3fb950" stroke-opacity="0.5" stroke-width="0.6"/>
<text x="122" y="57" font-family="'SF Mono', ui-monospace, monospace" font-size="10.5" fill="#7ee787" text-anchor="middle" font-weight="600">ALB ENI</text>
<rect x="22" y="70" width="200" height="36" rx="4" fill="#3fb9501a" stroke="#3fb950" stroke-opacity="0.5" stroke-width="0.6"/>
<text x="122" y="86" font-family="'SF Mono', ui-monospace, monospace" font-size="10.5" fill="#7ee787" text-anchor="middle" font-weight="600">nginx-alb-1</text>
<text x="122" y="100" font-family="'Segoe UI', sans-serif" font-size="9.5" fill="#a1a1aa" text-anchor="middle">public IP · Ubuntu · cloud-init</text>
<rect x="22" y="110" width="200" height="36" rx="4" fill="#3fb9501a" stroke="#3fb950" stroke-opacity="0.5" stroke-width="0.6"/>
<text x="122" y="126" font-family="'SF Mono', ui-monospace, monospace" font-size="10.5" fill="#7ee787" text-anchor="middle" font-weight="600">nginx-alb-3</text>
<text x="122" y="140" font-family="'Segoe UI', sans-serif" font-size="9.5" fill="#a1a1aa" text-anchor="middle">public IP · Ubuntu · cloud-init</text>
</g>
<!-- public_b -->
<g transform="translate(548, 202)">
<rect width="244" height="104" rx="8" fill="url(#pub-grad)" stroke="#3fb950" stroke-width="1.2" stroke-opacity="0.55"/>
<rect width="244" height="150" rx="8" fill="url(#pub-grad)" stroke="#3fb950" stroke-width="1.2" stroke-opacity="0.55"/>
<text x="20" y="24" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#3fb950" font-weight="700">public_b</text>
<text x="224" y="24" font-family="'Segoe UI', sans-serif" font-size="9.5" fill="#7ee787" text-anchor="end">az-b</text>
<text x="224" y="24" font-family="'Segoe UI', sans-serif" font-size="9.5" fill="#7ee787" text-anchor="end">10.20.2.0/24</text>
<line x1="16" y1="32" x2="228" y2="32" stroke="#3fb950" stroke-opacity="0.3" stroke-width="0.5"/>
<rect x="20" y="56" width="200" height="22" rx="4" fill="#3fb9501a" stroke="#3fb950" stroke-opacity="0.5" stroke-width="0.6"/>
<text x="120" y="71" font-family="'SF Mono', ui-monospace, monospace" font-size="10.5" fill="#7ee787" text-anchor="middle" font-weight="600">ALB ENI</text>
<rect x="22" y="42" width="200" height="22" rx="4" fill="#3fb9501a" stroke="#3fb950" stroke-opacity="0.5" stroke-width="0.6"/>
<text x="122" y="57" font-family="'SF Mono', ui-monospace, monospace" font-size="10.5" fill="#7ee787" text-anchor="middle" font-weight="600">ALB ENI</text>
<rect x="22" y="70" width="200" height="36" rx="4" fill="#3fb9501a" stroke="#3fb950" stroke-opacity="0.5" stroke-width="0.6"/>
<text x="122" y="86" font-family="'SF Mono', ui-monospace, monospace" font-size="10.5" fill="#7ee787" text-anchor="middle" font-weight="600">nginx-alb-2</text>
<text x="122" y="100" font-family="'Segoe UI', sans-serif" font-size="9.5" fill="#a1a1aa" text-anchor="middle">public IP · Ubuntu · cloud-init</text>
</g>
<!-- SNAT arrow from public_a NAT → private row -->
<line x1="230" y1="306" x2="230" y2="340" stroke="#F87171" stroke-width="1.2" stroke-dasharray="3,3" opacity="0.65"/>
<polygon points="226,336 234,336 230,344" fill="#F87171" opacity="0.8"/>
<rect x="256" y="314" width="240" height="20" rx="10" fill="#0a0a0a" stroke="#F87171" stroke-width="0.6" stroke-opacity="0.55"/>
<text x="376" y="328" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#FCA5A5" text-anchor="middle">SNAT for private subnets (egress)</text>
<!-- private_a -->
<g transform="translate(108, 350)">
<rect width="244" height="104" rx="8" fill="url(#priv-grad)" stroke="#a855f7" stroke-width="1.2" stroke-opacity="0.55"/>
<text x="20" y="24" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#c084fc" font-weight="700">private_a</text>
<text x="224" y="24" font-family="'Segoe UI', sans-serif" font-size="9.5" fill="#d8b4fe" text-anchor="end">az-a</text>
<line x1="16" y1="32" x2="228" y2="32" stroke="#a855f7" stroke-opacity="0.3" stroke-width="0.5"/>
<rect x="20" y="44" width="200" height="48" rx="5" fill="#a855f71a" stroke="#a855f7" stroke-opacity="0.5" stroke-width="0.6"/>
<text x="120" y="64" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#d8b4fe" text-anchor="middle" font-weight="700">nginx_1</text>
<text x="120" y="80" font-family="'Segoe UI', sans-serif" font-size="9.5" fill="#a1a1aa" text-anchor="middle">Debian 12 · cloud-init</text>
</g>
<!-- private_b -->
<g transform="translate(548, 350)">
<rect width="244" height="104" rx="8" fill="url(#priv-grad)" stroke="#a855f7" stroke-width="1.2" stroke-opacity="0.55"/>
<text x="20" y="24" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#c084fc" font-weight="700">private_b</text>
<text x="224" y="24" font-family="'Segoe UI', sans-serif" font-size="9.5" fill="#d8b4fe" text-anchor="end">az-b</text>
<line x1="16" y1="32" x2="228" y2="32" stroke="#a855f7" stroke-opacity="0.3" stroke-width="0.5"/>
<rect x="20" y="44" width="200" height="48" rx="5" fill="#a855f71a" stroke="#a855f7" stroke-opacity="0.5" stroke-width="0.6"/>
<text x="120" y="64" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#d8b4fe" text-anchor="middle" font-weight="700">nginx_2</text>
<text x="120" y="80" font-family="'Segoe UI', sans-serif" font-size="9.5" fill="#a1a1aa" text-anchor="middle">Debian 12 · cloud-init</text>
</g>
<!-- spread placement group -->
<rect x="108" y="372" width="684" height="88" rx="8" fill="url(#priv-grad)" stroke="#a855f7" stroke-width="1.2" stroke-opacity="0.55"/>
<text x="128" y="394" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#c084fc" font-weight="700">nginx-alb-spread</text>
<text x="772" y="394" font-family="'Segoe UI', sans-serif" font-size="9.5" fill="#d8b4fe" text-anchor="end">placement group · strategy spread · one server per node</text>
<line x1="124" y1="402" x2="776" y2="402" stroke="#a855f7" stroke-opacity="0.3" stroke-width="0.5"/>
<rect x="128" y="412" width="204" height="36" rx="5" fill="#a855f71a" stroke="#a855f7" stroke-opacity="0.5" stroke-width="0.6"/>
<text x="230" y="428" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#d8b4fe" text-anchor="middle" font-weight="700">node1</text>
<text x="230" y="442" font-family="'Segoe UI', sans-serif" font-size="9.5" fill="#a1a1aa" text-anchor="middle">one web server</text>
<rect x="348" y="412" width="204" height="36" rx="5" fill="#a855f71a" stroke="#a855f7" stroke-opacity="0.5" stroke-width="0.6"/>
<text x="450" y="428" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#d8b4fe" text-anchor="middle" font-weight="700">node2</text>
<text x="450" y="442" font-family="'Segoe UI', sans-serif" font-size="9.5" fill="#a1a1aa" text-anchor="middle">one web server</text>
<rect x="568" y="412" width="204" height="36" rx="5" fill="#a855f71a" stroke="#a855f7" stroke-opacity="0.5" stroke-width="0.6"/>
<text x="670" y="428" font-family="'SF Mono', ui-monospace, monospace" font-size="11" fill="#d8b4fe" text-anchor="middle" font-weight="700">node3</text>
<text x="670" y="442" font-family="'Segoe UI', sans-serif" font-size="9.5" fill="#a1a1aa" text-anchor="middle">one web server</text>
</svg>

Before

Width:  |  Height:  |  Size: 7.1 KiB

After

Width:  |  Height:  |  Size: 7.5 KiB

@@ -0,0 +1,110 @@
<svg xmlns="http://www.w3.org/2000/svg" width="900" height="580" viewBox="0 0 900 580">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#0a0a0a"/>
<stop offset="100%" style="stop-color:#0d1117"/>
</linearGradient>
<linearGradient id="vpc-grad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#58a6ff14"/>
<stop offset="100%" style="stop-color:#58a6ff05"/>
</linearGradient>
<linearGradient id="node-grad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#3fb9501f"/>
<stop offset="100%" style="stop-color:#3fb95008"/>
</linearGradient>
<linearGradient id="svc-grad" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" style="stop-color:#a855f71f"/>
<stop offset="100%" style="stop-color:#a855f708"/>
</linearGradient>
<marker id="arrow" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M0,0 L10,5 L0,10 z" fill="#71717a"/>
</marker>
</defs>
<rect width="900" height="580" fill="url(#bg)" rx="12"/>
<text x="24" y="30" font-family="'Segoe UI', system-ui, sans-serif" font-size="11" fill="#a1a1aa" letter-spacing="2.5" font-weight="600">SPINIFEX ON VULTR · ONE NODE OR THREE</text>
<text x="876" y="30" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#71717a" text-anchor="end">--nodes 1 or 3 · vx1-g-16c-64g or bare metal</text>
<rect x="30" y="52" width="250" height="56" rx="8" fill="#e4e4e70d" stroke="#e4e4e7" stroke-width="1.1" stroke-opacity="0.6"/>
<text x="155.0" y="76" font-family="'SF Mono', ui-monospace, monospace" font-size="12.5" fill="#e4e4e7" text-anchor="middle" font-weight="700">Your machine</text>
<text x="155.0" y="94" font-family="'Segoe UI', system-ui, sans-serif" font-size="10" fill="#a1a1aa" text-anchor="middle">deploy-vultr.sh · OpenTofu or Terraform</text>
<rect x="325" y="52" width="250" height="56" rx="8" fill="#3fb9500d" stroke="#3fb950" stroke-width="1.1" stroke-opacity="0.6"/>
<text x="450.0" y="76" font-family="'SF Mono', ui-monospace, monospace" font-size="12.5" fill="#3fb950" text-anchor="middle" font-weight="700">Internet</text>
<text x="450.0" y="94" font-family="'Segoe UI', system-ui, sans-serif" font-size="10" fill="#7ee787" text-anchor="middle">customers reach your EC2 and load balancers</text>
<rect x="620" y="52" width="250" height="56" rx="8" fill="#58a6ff12" stroke="#58a6ff" stroke-width="1.1" stroke-opacity="0.6"/>
<text x="745.0" y="76" font-family="'SF Mono', ui-monospace, monospace" font-size="12.5" fill="#79c0ff" text-anchor="middle" font-weight="700">Vultr API</text>
<text x="745.0" y="94" font-family="'Segoe UI', system-ui, sans-serif" font-size="10" fill="#a5d6ff" text-anchor="middle">VPC · servers · reserved IPs</text>
<rect x="30" y="160" width="840" height="400" rx="10" fill="url(#vpc-grad)" stroke="#58a6ff" stroke-width="1.2" stroke-opacity="0.55" stroke-dasharray="6,4"/>
<text x="50" y="184" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#79c0ff" font-weight="700">Vultr VPC</text>
<text x="850" y="184" font-family="'SF Mono', ui-monospace, monospace" font-size="10" fill="#58a6ff" text-anchor="end">private network 10.10.8.0/24</text>
<line x1="155" y1="108" x2="155" y2="158" stroke="#71717a" stroke-width="1.2" opacity="0.8" marker-end="url(#arrow)"/>
<text x="165" y="138" font-family="'Segoe UI', system-ui, sans-serif" font-size="10" fill="#a1a1aa" text-anchor="start">Terraform builds · SSH installs</text>
<line x1="450" y1="108" x2="450" y2="158" stroke="#71717a" stroke-width="1.2" opacity="0.8" marker-end="url(#arrow)"/>
<text x="460" y="138" font-family="'Segoe UI', system-ui, sans-serif" font-size="10" fill="#a1a1aa" text-anchor="start">reserved IPs</text>
<line x1="745" y1="158" x2="745" y2="110" stroke="#71717a" stroke-width="1.2" opacity="0.8" marker-end="url(#arrow)"/>
<text x="735" y="138" font-family="'Segoe UI', system-ui, sans-serif" font-size="10" fill="#a1a1aa" text-anchor="end">Spinifex calls the API</text>
<rect x="60" y="205" width="250" height="118" rx="8" fill="url(#node-grad)" stroke="#3fb950" stroke-width="1.2" stroke-opacity="0.6"/>
<text x="78" y="229" font-family="'SF Mono', ui-monospace, monospace" font-size="12.5" fill="#3fb950" font-weight="700">node1</text>
<text x="292" y="229" font-family="'Segoe UI', system-ui, sans-serif" font-size="9.5" fill="#7ee787" text-anchor="end">Ubuntu 24.04 · Spinifex</text>
<line x1="74" y1="237" x2="296" y2="237" stroke="#3fb950" stroke-opacity="0.3" stroke-width="0.6"/>
<rect x="76" y="250" width="74" height="24" rx="5" fill="#3fb9501a" stroke="#3fb950" stroke-opacity="0.5" stroke-width="0.6"/>
<text x="113.0" y="266" font-family="'SF Mono', ui-monospace, monospace" font-size="10.5" fill="#7ee787" text-anchor="middle">AWS API</text>
<rect x="160" y="250" width="60" height="24" rx="5" fill="#3fb9501a" stroke="#3fb950" stroke-opacity="0.5" stroke-width="0.6"/>
<text x="190.0" y="266" font-family="'SF Mono', ui-monospace, monospace" font-size="10.5" fill="#7ee787" text-anchor="middle">EBS</text>
<rect x="230" y="250" width="60" height="24" rx="5" fill="#3fb9501a" stroke="#3fb950" stroke-opacity="0.5" stroke-width="0.6"/>
<text x="260.0" y="266" font-family="'SF Mono', ui-monospace, monospace" font-size="10.5" fill="#7ee787" text-anchor="middle">S3</text>
<text x="185" y="300" font-family="'Segoe UI', system-ui, sans-serif" font-size="9.5" fill="#a1a1aa" text-anchor="middle">VPC networking · your instances</text>
<rect x="325" y="205" width="250" height="118" rx="8" fill="url(#node-grad)" stroke="#3fb950" stroke-width="1.2" stroke-opacity="0.6" stroke-dasharray="5,3"/>
<text x="343" y="229" font-family="'SF Mono', ui-monospace, monospace" font-size="12.5" fill="#3fb950" font-weight="700">node2</text>
<text x="557" y="229" font-family="'Segoe UI', system-ui, sans-serif" font-size="9.5" fill="#7ee787" text-anchor="end">with --nodes 3</text>
<line x1="339" y1="237" x2="561" y2="237" stroke="#3fb950" stroke-opacity="0.3" stroke-width="0.6"/>
<rect x="341" y="250" width="74" height="24" rx="5" fill="#3fb9501a" stroke="#3fb950" stroke-opacity="0.5" stroke-width="0.6"/>
<text x="378.0" y="266" font-family="'SF Mono', ui-monospace, monospace" font-size="10.5" fill="#7ee787" text-anchor="middle">AWS API</text>
<rect x="425" y="250" width="60" height="24" rx="5" fill="#3fb9501a" stroke="#3fb950" stroke-opacity="0.5" stroke-width="0.6"/>
<text x="455.0" y="266" font-family="'SF Mono', ui-monospace, monospace" font-size="10.5" fill="#7ee787" text-anchor="middle">EBS</text>
<rect x="495" y="250" width="60" height="24" rx="5" fill="#3fb9501a" stroke="#3fb950" stroke-opacity="0.5" stroke-width="0.6"/>
<text x="525.0" y="266" font-family="'SF Mono', ui-monospace, monospace" font-size="10.5" fill="#7ee787" text-anchor="middle">S3</text>
<text x="450" y="300" font-family="'Segoe UI', system-ui, sans-serif" font-size="9.5" fill="#a1a1aa" text-anchor="middle">VPC networking · your instances</text>
<rect x="590" y="205" width="250" height="118" rx="8" fill="url(#node-grad)" stroke="#3fb950" stroke-width="1.2" stroke-opacity="0.6" stroke-dasharray="5,3"/>
<text x="608" y="229" font-family="'SF Mono', ui-monospace, monospace" font-size="12.5" fill="#3fb950" font-weight="700">node3</text>
<text x="822" y="229" font-family="'Segoe UI', system-ui, sans-serif" font-size="9.5" fill="#7ee787" text-anchor="end">with --nodes 3</text>
<line x1="604" y1="237" x2="826" y2="237" stroke="#3fb950" stroke-opacity="0.3" stroke-width="0.6"/>
<rect x="606" y="250" width="74" height="24" rx="5" fill="#3fb9501a" stroke="#3fb950" stroke-opacity="0.5" stroke-width="0.6"/>
<text x="643.0" y="266" font-family="'SF Mono', ui-monospace, monospace" font-size="10.5" fill="#7ee787" text-anchor="middle">AWS API</text>
<rect x="690" y="250" width="60" height="24" rx="5" fill="#3fb9501a" stroke="#3fb950" stroke-opacity="0.5" stroke-width="0.6"/>
<text x="720.0" y="266" font-family="'SF Mono', ui-monospace, monospace" font-size="10.5" fill="#7ee787" text-anchor="middle">EBS</text>
<rect x="760" y="250" width="60" height="24" rx="5" fill="#3fb9501a" stroke="#3fb950" stroke-opacity="0.5" stroke-width="0.6"/>
<text x="790.0" y="266" font-family="'SF Mono', ui-monospace, monospace" font-size="10.5" fill="#7ee787" text-anchor="middle">S3</text>
<text x="715" y="300" font-family="'Segoe UI', system-ui, sans-serif" font-size="9.5" fill="#a1a1aa" text-anchor="middle">VPC networking · your instances</text>
<line x1="310" y1="264" x2="325" y2="264" stroke="#58a6ff" stroke-width="1.6" stroke-dasharray="3,3" opacity="0.9"/>
<line x1="575" y1="264" x2="590" y2="264" stroke="#58a6ff" stroke-width="1.6" stroke-dasharray="3,3" opacity="0.9"/>
<text x="450" y="348" font-family="'Segoe UI', system-ui, sans-serif" font-size="10" fill="#79c0ff" text-anchor="middle">nodes cluster over the private network: control plane, EBS replicas and S3 shards</text>
<rect x="60" y="370" width="780" height="166" rx="8" fill="url(#svc-grad)" stroke="#a855f7" stroke-width="1.2" stroke-opacity="0.55"/>
<text x="80" y="394" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#c084fc" font-weight="700">AWS services across the fleet</text>
<text x="820" y="394" font-family="'Segoe UI', system-ui, sans-serif" font-size="9.5" fill="#d8b4fe" text-anchor="end">spread across the nodes; with three, moved to a survivor if one fails</text>
<line x1="76" y1="402" x2="824" y2="402" stroke="#a855f7" stroke-opacity="0.3" stroke-width="0.6"/>
<rect x="76" y="418" width="86" height="52" rx="6" fill="#a855f71a" stroke="#a855f7" stroke-opacity="0.5" stroke-width="0.6"/>
<text x="119.0" y="440" font-family="'SF Mono', ui-monospace, monospace" font-size="11.5" fill="#d8b4fe" text-anchor="middle" font-weight="700">EC2</text>
<text x="119.0" y="457" font-family="'Segoe UI', system-ui, sans-serif" font-size="9" fill="#a1a1aa" text-anchor="middle">instances</text>
<rect x="169" y="418" width="86" height="52" rx="6" fill="#a855f71a" stroke="#a855f7" stroke-opacity="0.5" stroke-width="0.6"/>
<text x="212.0" y="440" font-family="'SF Mono', ui-monospace, monospace" font-size="11.5" fill="#d8b4fe" text-anchor="middle" font-weight="700">ALB / NLB</text>
<text x="212.0" y="457" font-family="'Segoe UI', system-ui, sans-serif" font-size="9" fill="#a1a1aa" text-anchor="middle">load balancers</text>
<rect x="262" y="418" width="86" height="52" rx="6" fill="#a855f71a" stroke="#a855f7" stroke-opacity="0.5" stroke-width="0.6"/>
<text x="305.0" y="440" font-family="'SF Mono', ui-monospace, monospace" font-size="11.5" fill="#d8b4fe" text-anchor="middle" font-weight="700">EKS</text>
<text x="305.0" y="457" font-family="'Segoe UI', system-ui, sans-serif" font-size="9" fill="#a1a1aa" text-anchor="middle">Kubernetes</text>
<rect x="355" y="418" width="86" height="52" rx="6" fill="#a855f71a" stroke="#a855f7" stroke-opacity="0.5" stroke-width="0.6"/>
<text x="398.0" y="440" font-family="'SF Mono', ui-monospace, monospace" font-size="11.5" fill="#d8b4fe" text-anchor="middle" font-weight="700">ECS</text>
<text x="398.0" y="457" font-family="'Segoe UI', system-ui, sans-serif" font-size="9" fill="#a1a1aa" text-anchor="middle">containers</text>
<rect x="448" y="418" width="86" height="52" rx="6" fill="#a855f71a" stroke="#a855f7" stroke-opacity="0.5" stroke-width="0.6"/>
<text x="491.0" y="440" font-family="'SF Mono', ui-monospace, monospace" font-size="11.5" fill="#d8b4fe" text-anchor="middle" font-weight="700">RDS</text>
<text x="491.0" y="457" font-family="'Segoe UI', system-ui, sans-serif" font-size="9" fill="#a1a1aa" text-anchor="middle">databases</text>
<rect x="541" y="418" width="86" height="52" rx="6" fill="#a855f71a" stroke="#a855f7" stroke-opacity="0.5" stroke-width="0.6"/>
<text x="584.0" y="440" font-family="'SF Mono', ui-monospace, monospace" font-size="11.5" fill="#d8b4fe" text-anchor="middle" font-weight="700">S3</text>
<text x="584.0" y="457" font-family="'Segoe UI', system-ui, sans-serif" font-size="9" fill="#a1a1aa" text-anchor="middle">object storage</text>
<rect x="634" y="418" width="86" height="52" rx="6" fill="#a855f71a" stroke="#a855f7" stroke-opacity="0.5" stroke-width="0.6"/>
<text x="677.0" y="440" font-family="'SF Mono', ui-monospace, monospace" font-size="11.5" fill="#d8b4fe" text-anchor="middle" font-weight="700">EBS</text>
<text x="677.0" y="457" font-family="'Segoe UI', system-ui, sans-serif" font-size="9" fill="#a1a1aa" text-anchor="middle">volumes</text>
<rect x="727" y="418" width="86" height="52" rx="6" fill="#a855f71a" stroke="#a855f7" stroke-opacity="0.5" stroke-width="0.6"/>
<text x="770.0" y="440" font-family="'SF Mono', ui-monospace, monospace" font-size="11.5" fill="#d8b4fe" text-anchor="middle" font-weight="700">IAM</text>
<text x="770.0" y="457" font-family="'Segoe UI', system-ui, sans-serif" font-size="9" fill="#a1a1aa" text-anchor="middle">access</text>
<text x="450" y="500" font-family="'Segoe UI', system-ui, sans-serif" font-size="10.5" fill="#a1a1aa" text-anchor="middle">Use the AWS CLI, SDKs and Terraform against your own endpoint, unmodified.</text>
<text x="450" y="520" font-family="'Segoe UI', system-ui, sans-serif" font-size="9.5" fill="#71717a" text-anchor="middle">Public addresses are Vultr reserved IPs, attached to the node each resource runs on.</text>
</svg>

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 725 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 743 KiB

+31 -5
View File
@@ -8,7 +8,8 @@
"arch": "x86_64",
"runner": "ci-single",
"extra": "",
"suite": ""
"suite": "",
"timeout_minutes": 60
},
{
"cell": 2,
@@ -19,7 +20,8 @@
"arch": "x86_64",
"runner": "ci-single",
"extra": "",
"suite": ""
"suite": "",
"timeout_minutes": 60
},
{
"cell": 3,
@@ -30,7 +32,8 @@
"arch": "x86_64",
"runner": "ci-single",
"extra": "",
"suite": ""
"suite": "",
"timeout_minutes": 60
},
{
"cell": 8,
@@ -165,7 +168,7 @@
"runner": "ci-multi",
"extra": "",
"suite": "instancerecovery",
"timeout_minutes": 105
"timeout_minutes": 125
},
{
"cell": 32,
@@ -200,7 +203,8 @@
"arch": "x86_64",
"runner": "ci-single",
"extra": "",
"suite": ""
"suite": "",
"timeout_minutes": 60
},
{
"cell": 35,
@@ -223,5 +227,27 @@
"runner": "ci-single",
"extra": "nat-single",
"suite": ""
},
{
"cell": 37,
"install": "binary",
"topo": "real-multi",
"net": "dhcp",
"host": "oracle-10",
"arch": "x86_64",
"runner": "ci-multi",
"extra": "",
"suite": ""
},
{
"cell": 38,
"install": "binary",
"topo": "real-multi",
"net": "nat",
"host": "oracle-10",
"arch": "x86_64",
"runner": "ci-multi",
"extra": "nat-multi",
"suite": ""
}
]
+4
View File
@@ -16,6 +16,10 @@
- Evidence added or rechecked:
- ADR-0008 lifecycle register / ADR-0001 migration-record / bead updates:
## Mulga design
<!-- One line, so a reviewer knows whether the design records in the private mulga repository need to change. Write either "Updated: <mulga PR or bead id>" or "Not needed: <reason>". This is a reminder, not a gate. -->
## Community Contribution License
- [ ] All community contributions in this pull request are licensed to Mulga under the terms of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). By creating this pull request I represent that I have the right to license these contributions to Mulga under the Apache License 2.0.
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env bash
# Usage: hypervisor-disk-preflight.sh <env.tfvars>
#
# Fails fast when a hypervisor named in the tofu env is short of disk, before
# `tofu apply` tries to define a domain there. libvirt writes each domain's XML
# under /etc/libvirt/qemu on the root filesystem and its disks into the pool, and
# a full one fails the apply minutes in as "Domain Creation Failed".
#
# Hosts are reached the way the libvirt provider reaches them (ssh <user>@<name>,
# resolved through ~/.ssh/config). A host that cannot be reached is reported and
# skipped: tofu's own error for it is clearer than anything said here.
#
# Env: HV_SSH_USER (tf-user), HV_MIN_ROOT_FREE_MB (1024), HV_MIN_POOL_FREE_GB (20).
set -uo pipefail
tfvars="${1:?usage: $0 <env.tfvars>}"
user="${HV_SSH_USER:-tf-user}"
min_root_kb=$(( ${HV_MIN_ROOT_FREE_MB:-1024} * 1024 ))
min_pool_kb=$(( ${HV_MIN_POOL_FREE_GB:-20} * 1024 * 1024 ))
if [ ! -r "$tfvars" ]; then
echo "::error::hypervisor disk preflight: cannot read $tfvars" >&2
exit 1
fi
# One node per line in every env file: { name = "...", ..., storage_pool = "..." }.
pairs=$(grep -v '^[[:space:]]*#' "$tfvars" |
sed -nE 's/.*name[[:space:]]*=[[:space:]]*"([^"]+)".*storage_pool[[:space:]]*=[[:space:]]*"([^"]+)".*/\1 \2/p')
if [ -z "$pairs" ]; then
echo "::warning::hypervisor disk preflight: no nodes with a storage_pool in $tfvars; nothing checked"
exit 0
fi
# Prints "<root-avail-kb> <pool-avail-kb> <pool-path>" for the pool named in $1.
# shellcheck disable=SC2016 # expanded on the hypervisor, not here
remote='set -u
root=$(df -Pk /etc/libvirt/qemu | awk "NR==2{print \$4}")
path=$(virsh -c qemu:///system pool-dumpxml "$1" 2>/dev/null | sed -n "s:.*<path>\(.*\)</path>.*:\1:p" | head -n1)
pool=-
[ -n "$path" ] && pool=$(df -Pk "$path" | awk "NR==2{print \$4}")
echo "$root $pool ${path:--}"'
fail=0
while read -r host pool; do
[ -n "$host" ] || continue
if ! out=$(ssh -o BatchMode=yes -o ConnectTimeout=10 "${user}@${host}" bash -s -- "$pool" <<<"$remote" 2>&1); then
echo "::warning::hypervisor disk preflight: cannot reach $host ($out); its disk is unchecked"
continue
fi
read -r root_kb pool_kb pool_path <<<"$(tail -n1 <<<"$out")"
if ! [[ "$root_kb" =~ ^[0-9]+$ ]]; then
echo "::warning::hypervisor disk preflight: unexpected answer from $host: $out"
continue
fi
echo "$host: root fs $((root_kb / 1024)) MiB free; pool $pool ($pool_path) $([[ "$pool_kb" =~ ^[0-9]+$ ]] && echo "$((pool_kb / 1024 / 1024)) GiB free" || echo "not found")"
if [ "$root_kb" -lt "$min_root_kb" ]; then
echo "::error::hypervisor $host has $((root_kb / 1024)) MiB free on the filesystem holding /etc/libvirt/qemu (need ${HV_MIN_ROOT_FREE_MB:-1024} MiB): libvirt cannot write a domain definition there. Free space on $host before re-running." >&2
fail=1
fi
if [[ "$pool_kb" =~ ^[0-9]+$ ]] && [ "$pool_kb" -lt "$min_pool_kb" ]; then
echo "::error::hypervisor $host has $((pool_kb / 1024 / 1024)) GiB free in storage pool $pool at $pool_path (need ${HV_MIN_POOL_FREE_GB:-20} GiB): the node's disk cannot grow. Free space on $host before re-running." >&2
fail=1
fi
done <<<"$pairs"
exit "$fail"
+87
View File
@@ -0,0 +1,87 @@
#!/bin/sh
# hypervisor-disk-preflight_test.sh — drives hypervisor-disk-preflight.sh against
# a stub ssh that answers for each hypervisor from a canned file, so every
# verdict is checked without a real host.
set -eu
SCRIPT_DIR=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd)
PREFLIGHT="$SCRIPT_DIR/hypervisor-disk-preflight.sh"
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
fails=0
pass() { echo " ok - $1"; }
fail() { echo " FAIL - $1"; fails=$((fails + 1)); }
# The stub answers "<root-kb> <pool-kb> <path>" from $FAKE/<host>, or exits 255
# when that host has no file, the way ssh does for a host it cannot reach.
mkdir -p "$TMP/bin" "$TMP/hosts"
cat > "$TMP/bin/ssh" <<'STUB'
#!/bin/sh
for a in "$@"; do case $a in *@*) host=${a#*@} ;; esac; done
cat >/dev/null
if [ -f "$FAKE/$host" ]; then cat "$FAKE/$host"; exit 0; fi
echo "ssh: Could not resolve hostname $host" >&2
exit 255
STUB
chmod +x "$TMP/bin/ssh"
PATH="$TMP/bin:$PATH"
FAKE="$TMP/hosts"
export PATH FAKE
cat > "$TMP/env.tfvars" <<'TFVARS'
nodes = [
{ name = "wattle", wan_ip = "192.0.2.1", storage_pool = "drive1-pool", base_image = "/x.qcow2" },
# { name = "retired", wan_ip = "192.0.2.9", storage_pool = "drive1-pool", base_image = "/x.qcow2" },
{ name = "banksia", wan_ip = "192.0.2.2", storage_pool = "nvme1-pool", base_image = "/x.qcow2" },
]
TFVARS
GiB=1048576
plenty="$((50 * GiB)) $((200 * GiB)) /mnt/drive1/pool"
# run <label> <want-rc> <want-text>
run() {
rc=0
out=$(bash "$PREFLIGHT" "$TMP/env.tfvars" 2>&1) || rc=$?
if [ "$rc" -ne "$2" ]; then
fail "$1: exit $rc, want $2"
echo "$out" | sed 's/^/ /'
return
fi
case $out in
*"$3"*) pass "$1" ;;
*) fail "$1: output lacks '$3'"; echo "$out" | sed 's/^/ /' ;;
esac
}
echo "-- hypervisor-disk-preflight.sh"
echo "$plenty" > "$FAKE/wattle"
echo "$plenty" > "$FAKE/banksia"
run "every host has room" 0 "banksia: root fs 51200 MiB free"
echo "0 $((200 * GiB)) /mnt/drive1/pool" > "$FAKE/wattle"
run "a full root filesystem fails fast" 1 "::error::hypervisor wattle has 0 MiB free on the filesystem holding /etc/libvirt/qemu"
echo "$plenty" > "$FAKE/wattle"
echo "$((50 * GiB)) $((3 * GiB)) /mnt/nvme1/pool" > "$FAKE/banksia"
run "a nearly full pool fails fast" 1 "::error::hypervisor banksia has 3 GiB free in storage pool nvme1-pool"
echo "$plenty" > "$FAKE/banksia"
rm -f "$FAKE/wattle"
run "an unreachable host is reported, not failed" 0 "::warning::hypervisor disk preflight: cannot reach wattle"
echo "$plenty" > "$FAKE/wattle"
echo "$((50 * GiB)) - -" > "$FAKE/banksia"
run "a pool libvirt does not know is reported, not failed" 0 "pool nvme1-pool (-) not found"
echo "0 0 /x" > "$FAKE/retired"
echo "$plenty" > "$FAKE/banksia"
run "a commented-out node is never checked" 0 "wattle: root fs"
if [ "$fails" -ne 0 ]; then
echo "$fails failure(s)"
exit 1
fi
echo " hypervisor-disk-preflight ok"
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env python3
"""Warn when a PR body has no filled "Mulga design" line. Reads PR_BODY; never fails the job."""
import os
import re
COMMENT_RE = re.compile(r"<!--.*?-->", re.S)
SECTION_RE = re.compile(r"^#{1,6}\s*Mulga design\s*$", re.I)
LINE_RE = re.compile(r"^\s*(Updated|Not needed)\s*:\s*(.*?)\s*$", re.I)
PLACEHOLDER_RE = re.compile(r"<[^<>]*>")
def filled(body):
inside = False
for line in COMMENT_RE.sub("", body or "").splitlines():
if SECTION_RE.match(line):
inside = True
elif inside and line.lstrip().startswith("#"):
return False
elif inside:
m = LINE_RE.match(line)
if m and PLACEHOLDER_RE.sub("", m.group(2)).strip():
return True
return False
def main():
if filled(os.environ.get("PR_BODY", "")):
print("The Mulga design line is filled in.")
else:
print('::warning title=Mulga design::Add a line under "## Mulga design": "Updated: <mulga PR or bead id>" or "Not needed: <reason>".')
return 0
if __name__ == "__main__":
raise SystemExit(main())
+7 -2
View File
@@ -117,8 +117,9 @@ e2e_suite_timeout() {
# storage case waits for a pause measured at 4m47s and then holds four
# minutes to prove nothing moved. Three tests, each taking a node away and
# bringing it back, and the partition case adds a lease validity, a lease
# TTL and a boot on top of everything the other two wait for.
instancerecovery) echo "90m" ;;
# TTL and a boot on top of everything the other two wait for. The data
# test lets its guest age past the pending watchdog twice, about 20 more.
instancerecovery) echo "110m" ;;
# lbrecovery pays for a load balancer twice: once to stand one up with two
# backends placed around it, and once for the recovery itself — a stale
# owner, a lease, a boot, HAProxy's own start, and its health checks against
@@ -129,6 +130,10 @@ e2e_suite_timeout() {
# budget on a node stopping and starting: seven units down, a raft election,
# and the whole service stack back up with every stream caught up.
kvquorum) echo "40m" ;;
# single is the broadest suite and finishes, it does not hang: passing runs
# took 24-29m, and an Oracle host ran out at 30m inside the last test's
# cleanup. The default left it no headroom at all.
single) echo "40m" ;;
*) echo "30m" ;;
esac
}
@@ -0,0 +1,23 @@
import unittest
from mulga_design_check import filled
class FilledTest(unittest.TestCase):
def test_accepts_either_form(self):
self.assertTrue(filled("## Mulga design\nUpdated: mulga#275"))
self.assertTrue(filled("## Mulga design\n\nnot needed: internal refactor\n## Next"))
def test_rejects_unanswered(self):
self.assertFalse(filled(""))
self.assertFalse(filled("## Mulga design\n<!-- Updated: x -->"))
self.assertFalse(filled("## Mulga design\nUpdated: <mulga PR or bead id>"))
self.assertFalse(filled("## Mulga design\nUpdated:"))
def test_scoped_to_the_section(self):
self.assertFalse(filled("Updated: mulga#1"))
self.assertFalse(filled("## Mulga design\n## Other\nUpdated: mulga#1"))
if __name__ == "__main__":
unittest.main()
+8
View File
@@ -18,6 +18,11 @@ workflows:
- 'actions/create-github-app-token@v3.3.0'
- 'actions/upload-artifact@v7.0.2'
- 'hashicorp/setup-terraform@v4.0.1'
'.github/workflows/e2e-cloudvendor-vultr.yml':
- 'actions/checkout@v7.0.1'
- 'actions/create-github-app-token@v3.3.0'
- 'actions/upload-artifact@v7.0.2'
- 'hashicorp/setup-terraform@v4.0.1'
'.github/workflows/e2e-ddil.yml':
- 'actions/checkout@v7.0.1'
- 'actions/create-github-app-token@v3.3.0'
@@ -45,6 +50,8 @@ workflows:
'.github/workflows/image-scripts.yml':
- 'actions/checkout@v7.0.1'
- 'actions/setup-go@v7.0.0'
'.github/workflows/mulga-design.yml':
- 'actions/checkout@v7.0.1'
'.github/workflows/perf-3node.yml':
- 'actions/checkout@v7.0.1'
- 'actions/create-github-app-token@v3.3.0'
@@ -73,6 +80,7 @@ workflows:
'.github/workflows/ui.yml':
- 'actions/checkout@v7.0.1'
- 'actions/setup-node@v7.1.0'
- 'actions/upload-artifact@v7.0.2'
- 'pnpm/action-setup@v6.1.0'
dependencies:
'actions/cache@v6.1.0':
+2 -2
View File
@@ -102,9 +102,9 @@ jobs:
env:
GH_TOKEN: ${{ github.token }}
- name: Build microVM artifacts (lb-agent + kernel + initramfs + qboot)
- name: Build microVM artifacts (lb-agent + kernel + initramfs + qboot + SQS store and worker images)
if: steps.version.outputs.exists != 'true'
run: cd spinifex && make build-microvm-image
run: cd spinifex && make build-microvm-image build-sqs-worker-microvm-image
- name: Build amd64 Tarball
if: steps.version.outputs.exists != 'true'
+142
View File
@@ -0,0 +1,142 @@
name: E2E Cloud Vendor Vultr
# On demand only. Deploys a ref to Vultr with deploy-vultr.sh, runs the same
# published Terraform workbooks as the hypervisor nightly and OCI, and destroys
# the deployment, failing if anything is left billing.
on:
workflow_dispatch:
inputs:
nodes:
description: "1 or 3 nodes"
required: false
default: "1"
type: choice
options: ["1", "3"]
plan:
description: "Vultr plan: vx1-g-16c-64g (VM) or vbm-8c-64gb-amd (bare metal)"
required: false
default: vx1-g-16c-64g
region:
description: "Vultr region"
required: false
default: ord
workbooks:
description: "Space-separated workbooks (blank = the driver's full list)"
required: false
source:
description: "tree (build this ref) or release (the published installer)"
required: false
default: tree
type: choice
options: [tree, release]
channel:
description: "With source=release: latest, or dev for the newest prerelease"
required: false
default: latest
keep_on_fail:
description: "Leave a failed deployment up to inspect. It keeps billing."
required: false
type: boolean
default: false
runner:
description: "Runner label (blank = the VULTR_E2E_RUNNER variable, else ubuntu-26.04)"
required: false
permissions:
contents: read
# Deployments are named per run, but the account's reserved-IP quota is shared,
# so a second dispatch queues rather than competing with the first.
concurrency:
group: e2e-vultr
cancel-in-progress: false
jobs:
vultr:
# Hosted by default: the job only drives Terraform and SSH. The API key's
# access control must admit this runner, so a hosted runner needs a key
# whose list holds 0.0.0.0/0; a self-hosted runner can use a fixed address.
runs-on: ${{ inputs.runner || vars.VULTR_E2E_RUNNER || 'ubuntu-26.04' }}
timeout-minutes: 240
env:
VULTR_NAME: e2e-${{ github.run_id }}
VULTR_NODES: ${{ inputs.nodes }}
VULTR_PLAN: ${{ inputs.plan }}
VULTR_REGION: ${{ inputs.region }}
VULTR_SOURCE: ${{ inputs.source }}
VULTR_CHANNEL: ${{ inputs.channel }}
VULTR_ARTIFACT_DIR: ${{ github.workspace }}/e2e-vultr
SPX_REF: ${{ github.ref_name }}
steps:
- uses: actions/checkout@v7.0.1
with:
path: spinifex
fetch-depth: 0
- uses: actions/create-github-app-token@v3.3.0
id: app-token
with:
client-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
repositories: mulga
# build-install-artifacts.sh lives in mulga; the workbooks and their driver
# are in spinifex, so only the one directory is needed here.
- uses: actions/checkout@v7.0.1
with:
repository: mulgadc/mulga
path: mulga
token: ${{ steps.app-token.outputs.token }}
sparse-checkout: scripts/tofu-cluster
- uses: hashicorp/setup-terraform@v4.0.1
with:
terraform_wrapper: false
- name: Run Vultr
working-directory: spinifex/scripts/terraform/vultr-spx
env:
VULTR_API_KEY: ${{ secrets.VULTR_API_KEY }}
MULGA_ROOT: ${{ github.workspace }}/mulga
# Through the environment, never interpolated into the script: a
# dispatch input is untrusted text to the shell.
WORKBOOKS_INPUT: ${{ inputs.workbooks }}
VULTR_KEEP_ON_FAIL: ${{ inputs.keep_on_fail && '1' || '0' }}
run: |
set -uo pipefail
if [ -n "$WORKBOOKS_INPUT" ]; then export WORKBOOKS="$WORKBOOKS_INPUT"; fi
./e2e-vultr.sh 2>&1 | tee "$RUNNER_TEMP/e2e-vultr.log"
exit "${PIPESTATUS[0]}"
# always(): a cancelled or timed-out job is the one that would leave
# servers and reserved IPs billing. destroy works from this run's state.
- name: Sweep anything left behind
if: always() && !inputs.keep_on_fail
working-directory: spinifex/scripts/terraform/vultr-spx
env:
VULTR_API_KEY: ${{ secrets.VULTR_API_KEY }}
run: ./e2e-vultr.sh --sweep-only
- name: Publish the verdict
if: always()
run: |
{
echo "### Vultr: ${VULTR_NODES} x ${VULTR_PLAN} in ${VULTR_REGION}, ${VULTR_SOURCE} build of ${SPX_REF}"
echo '```'
cat "$VULTR_ARTIFACT_DIR/verdict.txt" 2>/dev/null || echo "no verdict written"
echo '```'
} >> "$GITHUB_STEP_SUMMARY"
# Logs and the verdict only. The throwaway SSH key and the Terraform state
# under ~/.spinifex never reach an artifact.
- name: Upload the run
if: always()
uses: actions/upload-artifact@v7.0.2
with:
name: e2e-vultr-${{ github.run_id }}
path: |
${{ runner.temp }}/e2e-vultr.log
${{ github.workspace }}/e2e-vultr/*.log
${{ github.workspace }}/e2e-vultr/verdict.txt
${{ github.workspace }}/e2e-vultr/node*/*.log
retention-days: 14
+41 -3
View File
@@ -10,7 +10,7 @@ on:
workflow_dispatch:
inputs:
cells:
description: "Comma-separated cell numbers to run, no spaces (blank = all). 1-3, 8-10, 17-19 tofu permutation matrix, 34-36 Oracle Linux single node (basic, reboot, routed NAT), 20 baremetal, 21-22 ISO, 23 predastore throughput, 24 predastore fault injection, 25-27 service suites (eks, ecs, rds), 28 guest disk performance, 29 storage fault injection, 30 routed NAT on three nodes, 31 instance recovery on three nodes, 32 load balancer recovery on three nodes, 33 KV bucket quorum under node loss on three nodes"
description: "Comma-separated cell numbers to run, no spaces (blank = all). 1-3, 8-10, 17-19 tofu permutation matrix, 34-36 Oracle Linux single node (basic, reboot, routed NAT), 37-38 Oracle Linux on three nodes (multi-node subset with IPsec, routed NAT), 20 baremetal, 21-22 ISO, 23 predastore throughput, 24 predastore fault injection, 25-27 service suites (eks, ecs, rds), 28 guest disk performance, 29 storage fault injection, 30 routed NAT on three nodes, 31 instance recovery on three nodes, 32 load balancer recovery on three nodes, 33 KV bucket quorum under node loss on three nodes"
required: false
permissions:
@@ -184,6 +184,14 @@ jobs:
echo "running qemu: $(pgrep -c qemu-system-x86 || echo 0)"
} >> "${ARTIFACT_DIR}/hypervisor-load.txt"
# A hypervisor out of disk fails the apply minutes in, as "Domain Creation
# Failed" from libvirt writing the domain XML. Say so before starting.
- name: Preflight hypervisor disk
working-directory: mulga/scripts/tofu-cluster
run: |
"${GITHUB_WORKSPACE}/spinifex/.github/scripts/hypervisor-disk-preflight.sh" \
"envs/${SPINIFEX_CI_ENV}.tfvars"
- name: Provision VMs
working-directory: mulga/scripts/tofu-cluster
env:
@@ -241,6 +249,11 @@ jobs:
. "${GITHUB_WORKSPACE}/spinifex/.github/scripts/suite-sets.sh"
e2e_suites_assert_subset "e2e-nightly single" "${E2E_SUITES_NIGHTLY_SINGLE}" "${E2E_SUITES_SINGLE}"
# The select job sized this cell's budget from these same timeouts, so
# resolve them here and carry the pairs into the remote loop.
SUITE_TMOS=""
for s in ${E2E_SUITES_NIGHTLY_SINGLE}; do SUITE_TMOS="${SUITE_TMOS}${s}:$(e2e_suite_timeout "${s}") "; done
INVENTORY=$(tofu output -json inventory)
WAN_IP=$(echo "$INVENTORY" | jq -r '.nodes[0].wan_ip')
SSH_KEY=$(echo "$INVENTORY" | jq -r '.ssh_key_path')
@@ -250,9 +263,10 @@ jobs:
for SUITE in ${E2E_SUITES_NIGHTLY_SINGLE}; do \
echo \"::group::suite \$SUITE\"; \
date -u +%Y-%m-%dT%H:%M:%SZ > '${ARTIFACT_DIR}/test-'\$SUITE'.start'; \
TMO=30m; for kv in ${SUITE_TMOS}; do case \$kv in \$SUITE:*) TMO=\${kv#*:} ;; esac; done; \
GITHUB_ACTIONS=true FORCE_COLOR=1 SPINIFEX_E2E=1 SPINIFEX_MODE=single \
ARTIFACT_DIR='${ARTIFACT_DIR}' \
./\$SUITE.test -test.v -test.timeout=30m \
./\$SUITE.test -test.v -test.timeout=\$TMO \
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; \
@@ -324,16 +338,20 @@ jobs:
NODES_CSV="${NODES_CSV:+$NODES_CSV,}$ip"
done
SUITE_TMOS=""
for s in ${E2E_SUITES_NIGHTLY_MULTI}; do SUITE_TMOS="${SUITE_TMOS}${s}:$(e2e_suite_timeout "${s}") "; done
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 ${E2E_SUITES_NIGHTLY_MULTI}; do \
echo \"::group::suite \$SUITE\"; \
date -u +%Y-%m-%dT%H:%M:%SZ > '${ARTIFACT_DIR}/test-'\$SUITE'.start'; \
TMO=30m; for kv in ${SUITE_TMOS}; do case \$kv in \$SUITE:*) TMO=\${kv#*:} ;; esac; done; \
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 \
./\$SUITE.test -test.v -test.timeout=\$TMO \
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; \
@@ -1185,6 +1203,16 @@ jobs:
sleep 2
rm -f "$TMPDIR"/tmp.*.qcow2 "$TMPDIR"/tmp.*.log "$TMPDIR"/tmp.*-node.log
# The ISO build runs as root inside the workspace, and the next job on
# this runner, from any workflow, cannot remove what it leaves there.
- name: Release the workspace
if: always()
run: |
sudo umount -R -l "$GITHUB_WORKSPACE"/mulga/scripts/iso-builder/.cache/rootfs 2>/dev/null || true
sudo rm -rf --one-file-system "$GITHUB_WORKSPACE"/mulga/scripts/iso-builder/.cache \
"$GITHUB_WORKSPACE"/mulga/scripts/iso-builder/dist
sudo chown -R "$(id -u):$(id -g)" "$GITHUB_WORKSPACE"
iso-multi:
name: cell-22 (iso/dhcp/three-node)
# After cell-21 rather than beside it: same concurrency group, and running
@@ -1336,6 +1364,16 @@ jobs:
rm -rf /tmp/cell22-artifacts-$GITHUB_RUN_ID /tmp/cell22-$GITHUB_RUN_ID.log \
"$WORK_DIR"
# The ISO build runs as root inside the workspace, and the next job on
# this runner, from any workflow, cannot remove what it leaves there.
- name: Release the workspace
if: always()
run: |
sudo umount -R -l "$GITHUB_WORKSPACE"/mulga/scripts/iso-builder/.cache/rootfs 2>/dev/null || true
sudo rm -rf --one-file-system "$GITHUB_WORKSPACE"/mulga/scripts/iso-builder/.cache \
"$GITHUB_WORKSPACE"/mulga/scripts/iso-builder/dist
sudo chown -R "$(id -u):$(id -g)" "$GITHUB_WORKSPACE"
# Predastore throughput ("cell 23"). Predastore has no runners of its own and
# spinifex depends on it, so the benchmark lives here and checks predastore
# out as a sibling.
+14
View File
@@ -536,6 +536,13 @@ jobs:
log-dir: ${{ env.ARTIFACT_DIR }}
title: "E2E failure analysis"
# The WAN capture is most of the artifact and only matters when a run
# failed. Anything short of a clean pass (suite rc, a failed setup or
# collect step, a missing rc) keeps it.
- name: Drop the packet capture from a passing single-node run
if: always() && steps.e2e.outputs.rc == '0' && job.status == 'success'
run: rm -f "${ARTIFACT_DIR}"/net-diag/wan.pcap
- uses: actions/upload-artifact@v7.0.2
if: always()
with:
@@ -990,6 +997,13 @@ jobs:
log-dir: ${{ env.ARTIFACT_DIR }}
title: "Multi-Node E2E failure analysis"
# The WAN captures are 90% of the artifact and only matter when a run
# failed. Anything short of a clean pass (suite rc, a failed setup or
# collect step, a missing rc) keeps them.
- name: Drop the packet captures from a passing multi-node run
if: always() && steps.e2e.outputs.rc == '0' && job.status == 'success'
run: rm -f "${ARTIFACT_DIR}"/net-diag-node*/wan.pcap
- uses: actions/upload-artifact@v7.0.2
if: always()
with:
+33
View File
@@ -0,0 +1,33 @@
name: Mulga design
# Reminds the author to say whether the Mulga design records need to change.
# It reads only the pull request body, uses no secrets, and warns without ever
# failing: the check is a prompt, not a gate.
on:
pull_request:
branches:
- dev
types: [opened, edited, reopened]
permissions:
contents: read
jobs:
mulga_design:
name: Mulga design line
if: ${{ !endsWith(github.actor, '[bot]') }}
runs-on: ubuntu-26.04
steps:
- name: Checkout Code
uses: actions/checkout@v7.0.1
with:
sparse-checkout: .github/scripts
- name: Check the Mulga design line
env:
PR_BODY: ${{ github.event.pull_request.body }}
working-directory: .github/scripts
run: |
python3 -m unittest -q test_mulga_design_check
python3 mulga_design_check.py
+2 -2
View File
@@ -57,8 +57,8 @@ jobs:
go-version-file: spinifex/go.mod
cache: false
- name: Build microVM artifacts (lb-agent + kernel + initramfs + qboot)
run: cd spinifex && make build-microvm-image
- name: Build microVM artifacts (lb-agent + kernel + initramfs + qboot + SQS store and worker images)
run: cd spinifex && make build-microvm-image build-sqs-worker-microvm-image
- name: Build amd64 Tarball
env:
-3
View File
@@ -119,9 +119,6 @@ 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
env:
SPINIFEX_REQUIRE_CONFORMANCE_TOOLS: "1"
+92
View File
@@ -7,6 +7,11 @@ on:
- dev
paths:
- spinifex/runtime/roles/spinifexui/frontend/**
- spinifex/runtime/roles/spinifexui/*.go
- spinifex/operator/admin/instance_metrics*.go
- spinifex/operator/admin/live_streams*.go
- spinifex/runtime/roles/qmpcollector/**
- contracts/telemetry/**
- .github/workflows/ui.yml
pull_request:
branches:
@@ -14,6 +19,11 @@ on:
- dev
paths:
- spinifex/runtime/roles/spinifexui/frontend/**
- spinifex/runtime/roles/spinifexui/*.go
- spinifex/operator/admin/instance_metrics*.go
- spinifex/operator/admin/live_streams*.go
- spinifex/runtime/roles/qmpcollector/**
- contracts/telemetry/**
- .github/workflows/ui.yml
workflow_dispatch:
@@ -69,3 +79,85 @@ jobs:
echo "$drift"
exit 1
fi
# The pinned image lives in one file that scripts/ui-playwright.sh also reads,
# so make ui-baselines and the job below draw screenshots in the same image.
ui_e2e_image:
name: UI End-to-End Image
runs-on: ubuntu-26.04
outputs:
image: ${{ steps.read.outputs.image }}
steps:
- name: Checkout Code
uses: actions/checkout@v7.0.1
with:
sparse-checkout: spinifex/runtime/roles/spinifexui/frontend/playwright.image
sparse-checkout-cone-mode: false
- name: Read Playwright Image
id: read
run: echo "image=$(tr -d '[:space:]' < spinifex/runtime/roles/spinifexui/frontend/playwright.image)" >> "$GITHUB_OUTPUT"
# The browser suite: the console against a faked API on Vite dev, plus the
# built dist under the production CSP. It runs in Playwright's own image, which
# carries the browsers, so screenshots rasterise as they did for the baselines.
ui_e2e:
name: UI End-to-End
needs: ui_e2e_image
runs-on: ubuntu-26.04
timeout-minutes: 20
container:
image: ${{ needs.ui_e2e_image.outputs.image }}
options: --ipc=host
defaults:
run:
working-directory: spinifex/runtime/roles/spinifexui/frontend
steps:
- name: Checkout Code
uses: actions/checkout@v7.0.1
with:
sparse-checkout: spinifex/runtime/roles/spinifexui/frontend
- name: Setup pnpm
uses: pnpm/action-setup@v6.1.0
with:
package_json_file: spinifex/runtime/roles/spinifexui/frontend/package.json
- name: Setup Node.js
uses: actions/setup-node@v7.1.0
with:
cache: "pnpm"
cache-dependency-path: spinifex/runtime/roles/spinifexui/frontend/pnpm-lock.yaml
node-version-file: spinifex/runtime/roles/spinifexui/frontend/.nvmrc
- name: Install Dependencies
run: pnpm install --frozen-lockfile
# The image's browsers belong to one Playwright release, so a lockfile
# bump without an image bump would otherwise fail on a missing executable.
- name: Check Playwright Matches the Image
env:
IMAGE: ${{ needs.ui_e2e_image.outputs.image }}
run: |
version=$(pnpm exec playwright --version | awk '{print $2}')
case "$IMAGE" in
*":v$version-"*) ;;
*) echo "::error::Lockfile has Playwright $version but the image is $IMAGE. Update playwright.image."; exit 1 ;;
esac
- name: Build Frontend
run: pnpm build
- name: Run End-to-End Suite
run: pnpm e2e
- name: Upload Screenshot Diffs
if: failure()
uses: actions/upload-artifact@v7.0.2
with:
name: ui-e2e-test-results
path: spinifex/runtime/roles/spinifexui/frontend/test-results/
if-no-files-found: ignore
retention-days: 14
+3
View File
@@ -21,6 +21,9 @@ build/microvm/vmlinuz
build/microvm/initramfs.cpio.gz
build/microvm/qboot.rom
build/microvm/qboot.LICENSE
build/microvm/sqs-worker/vmlinuz
build/microvm/sqs-worker/initramfs.cpio.gz
build/microvm/sqs-worker/.stage.*/
build/qboot/
!spinifex/runtime/roles/spinifexui/frontend/dist/
tmp/
+61 -25
View File
@@ -56,6 +56,19 @@ build-ui:
@echo -e "\n....Building spinifex-ui frontend...."
cd spinifex/runtime/roles/spinifexui/frontend && pnpm build
# Playwright suite for the console, against Vite dev and the built dist under
# the console's CSP. Not part of preflight; needs `pnpm exec playwright install chromium`.
test-ui-e2e: build-ui
cd spinifex/runtime/roles/spinifexui/frontend && pnpm e2e
# The same suite inside the pinned Playwright image CI uses (podman or docker),
# so screenshots compare as they will on the runner. ui-baselines redraws them.
ui-e2e-container:
scripts/ui-playwright.sh compare
ui-baselines:
scripts/ui-playwright.sh baselines
# GO commands
VERSION ?= $(shell git describe --tags --always --dirty --exclude '*-dev')
COMMIT ?= $(shell git rev-parse --short HEAD)
@@ -140,6 +153,33 @@ $(MICROVM_ARTIFACTS) &: $(MICROVM_INPUTS)
bin/lb-agent:
$(MAKE) build-lb-agent
# The dedicated SQS server, built from the go.mod-pinned module so the image
# ships exactly the reviewed version. Static for the musl guest.
bin/nats-server: go.mod go.sum
@mkdir -p bin
GOWORK=off CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags '-s -w' -o $@ github.com/nats-io/nats-server/v2
# go_local_srcs lists the main module package dirs and files guest package $(1)
# links, scanned with the guest build settings; a dir's mtime catches a deleted
# or renamed file. A failed scan names a missing prerequisite, so make stops.
go_local_srcs = $(shell GOWORK=off CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go list -deps -f '{{if and .Module .Module.Main}}{{.Dir}} {{range .GoFiles}}{{$$.Dir}}/{{.}} {{end}}{{range .EmbedFiles}}{{$$.Dir}}/{{.}} {{end}}{{end}}' $(1) || echo go-list-failed-for-$(1))
.SECONDEXPANSION:
# The SQS microVM image: one guest running its own SQS nats-server and the SQS
# worker as separate processes. A separate artifact from the LB image above.
SQS_WORKER_MICROVM_OUT_DIR := $(MICROVM_OUT_DIR)/sqs-worker
SQS_WORKER_MICROVM_ARTIFACTS := $(SQS_WORKER_MICROVM_OUT_DIR)/vmlinuz $(SQS_WORKER_MICROVM_OUT_DIR)/initramfs.cpio.gz
SQS_WORKER_MICROVM_INPUTS := scripts/build-sqs-store-microvm-image.sh scripts/lib/sqs-store-microvm-publish.sh $(SQS_WORKER_MICROVM_OUT_DIR)/init.sh $(SQS_WORKER_MICROVM_OUT_DIR)/inittab $(addprefix $(SQS_WORKER_MICROVM_OUT_DIR)/,sqs-worker-lib.sh sqs-worker-prepare sqs-worker-store sqs-worker-ready sqs-worker-stop) bin/nats-server bin/sqs-worker
$(SQS_WORKER_MICROVM_ARTIFACTS) &: $(SQS_WORKER_MICROVM_INPUTS)
./scripts/build-sqs-store-microvm-image.sh
bin/sqs-worker: go.mod go.sum $$(call go_local_srcs,./cmd/sqs-worker)
@mkdir -p bin
GOWORK=off CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags '-s -w' -o $@ ./cmd/sqs-worker
build-sqs-worker-microvm-image: $(SQS_WORKER_MICROVM_ARTIFACTS) ## Build the SQS microVM kernel and initramfs
# qboot is the microvm firmware. The source tarball ships with each release
# because qboot is GPL-2.0.
QBOOT_COMMIT := $(shell sed -n 's/^QBOOT_COMMIT="\(.*\)"$$/\1/p' scripts/build-qboot.sh)
@@ -151,6 +191,12 @@ $(QBOOT_ARTIFACTS) &: scripts/build-qboot.sh
build-microvm-image: $(MICROVM_ARTIFACTS) $(QBOOT_ARTIFACTS) ## Build microVM kernel, initramfs and qboot firmware (incremental — skips when up to date)
install-sqs-worker-microvm: $(SQS_WORKER_MICROVM_ARTIFACTS) ## Install the SQS microVM image to /usr/share/spinifex/microvm/sqs-worker/
sudo install -d /usr/share/spinifex/microvm/sqs-worker
sudo rm -f /usr/share/spinifex/microvm/sqs-worker/initramfs.cpio.gz
sudo install -m 0644 $(SQS_WORKER_MICROVM_OUT_DIR)/vmlinuz /usr/share/spinifex/microvm/sqs-worker/vmlinuz
sudo install -m 0644 $(SQS_WORKER_MICROVM_OUT_DIR)/initramfs.cpio.gz /usr/share/spinifex/microvm/sqs-worker/initramfs.cpio.gz
install-microvm: $(MICROVM_ARTIFACTS) $(QBOOT_ARTIFACTS) ## Install microVM artifacts to /usr/share/spinifex/microvm/
sudo install -d /usr/share/spinifex/microvm
sudo install -m 0644 $(MICROVM_OUT_DIR)/vmlinuz /usr/share/spinifex/microvm/vmlinuz
@@ -158,15 +204,14 @@ install-microvm: $(MICROVM_ARTIFACTS) $(QBOOT_ARTIFACTS) ## Install microVM arti
sudo install -m 0644 $(MICROVM_OUT_DIR)/qboot.rom /usr/share/spinifex/microvm/qboot.rom
sudo install -m 0644 $(MICROVM_OUT_DIR)/qboot.LICENSE /usr/share/spinifex/microvm/qboot.LICENSE
# the pre-commit gate: manifest checks, lint, vuln, and the unit and e2e-harness tiers.
# the pre-commit gate: lint, vuln, and the unit and e2e-harness tiers.
# integration and race tests skipped to keep quick, they run in CI.
#
# -j2 overlaps the only two heavy gates, lint and test-cover: alone each leaves
# most of the machine idle, so pairing them cuts a cold run by about a quarter.
# -Otarget keeps each gate's output in one block instead of interleaving them.
# manifest-check manifest-lint test-package-check removed, can add back later
preflight:
@$(MAKE) --no-print-directory -j2 -Otarget QUIET=1 lint govulncheck test-cover diff-coverage test-harness test-build-scripts manifest-check manifest-lint
@$(MAKE) --no-print-directory -j2 -Otarget QUIET=1 lint govulncheck test-cover diff-coverage test-harness test-build-scripts
@echo -e "\n ✅ Preflight passed — safe to commit."
# Shell suites + shellcheck for build/scripts/, the systemd-unit helpers that
@@ -178,7 +223,7 @@ preflight:
# reason: between them they install the software, wire OVN, erase the state and
# remove the install, so a shellcheck warning in one is an outage or a wrong
# deletion, not a style note.
NODE_SCRIPTS := scripts/setup.sh scripts/setup-ovn.sh scripts/install-node.sh scripts/smoke-test.sh scripts/node-reset.sh scripts/uninstall-spx.sh scripts/lib/distro.sh
NODE_SCRIPTS := scripts/setup.sh scripts/setup-ovn.sh scripts/install-node.sh scripts/smoke-test.sh scripts/node-reset.sh scripts/uninstall-spx.sh scripts/lib/distro.sh scripts/lib/stage-sqs-store-microvm.sh
test-build-scripts:
@echo -e "\n....Running build/scripts/**/*_test.sh...."
@@ -186,13 +231,20 @@ test-build-scripts:
echo "-- $$t"; \
sh "$$t" || exit 1; \
done
@echo -e "\n....Running scripts/*_test.sh and scripts/lib/*_test.sh...."
@for t in $$( (find scripts -maxdepth 1 -name '*_test.sh'; find scripts/lib -name '*_test.sh') | sort); do \
@echo -e "\n....Running scripts/*_test.sh, scripts/lib/*_test.sh and scripts/appliance/*_test.sh...."
@for t in $$( (find scripts -maxdepth 1 -name '*_test.sh'; find scripts/lib scripts/appliance -name '*_test.sh') | sort); do \
echo "-- $$t"; \
sh "$$t" || exit 1; \
done
@echo -e "\n....Running .github/scripts/*_test.sh...."
@for t in $$(find .github/scripts -maxdepth 1 -name '*_test.sh' | sort); do \
echo "-- $$t"; \
sh "$$t" || exit 1; \
done
shellcheck -S warning .github/scripts/hypervisor-disk-preflight.sh .github/scripts/hypervisor-disk-preflight_test.sh
@echo -e "\n....Running shellcheck over build/scripts/**/*.sh + the node scripts...."
shellcheck -S warning $$(find build/scripts -name '*.sh' | sort) $(NODE_SCRIPTS)
shellcheck -S warning -x scripts/build-oracle-appliance.sh $$(find scripts/appliance -name '*.sh' | sort)
@echo " test-build-scripts ok"
# E2E harness unit tests. Build-tagged `e2e` so they're skipped by the
@@ -239,23 +291,6 @@ test-segscan-oracle:
@echo -e "\n....Running segscan storage oracle test...."
$(_Q)LOG_IGNORE=1 go test -tags=integration,segscanoracle -timeout 120s ./tests/integration/... -run TestSegscanOracle $(_RACEQ)
# Validate docs/service-interfaces.yaml. Schema check + cross-reference
# of services/suites/fixtures + on-disk path existence.
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: 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)...."
@@ -276,7 +311,7 @@ test-cover:
# Run unit tests with race detector
test-race:
@echo -e "\n....Running tests with race detector for $(GO_PROJECT_NAME)...."
$(_Q)LOG_IGNORE=1 go test $(GOTESTFLAGS) -race -timeout 300s ./spinifex/... ./cmd/... ./internal/... ./contracts/... ./architecture/... $(_RACEQ)
$(_Q)LOG_IGNORE=1 SPINIFEX_SQS_R3=1 go test $(GOTESTFLAGS) -race -timeout 300s ./spinifex/... ./cmd/... ./internal/... ./contracts/... ./architecture/... $(_RACEQ)
# Unit tests for in-repo GitHub Actions (e.g. .github/actions/e2e-analyze).
# Kept out of `test-cover` so coverage % isn't diluted by CI-only tooling.
@@ -468,6 +503,7 @@ distro-amd64:
echo "[distro-amd64] WARNING: microvm artifacts missing — tarball will not include them"; \
echo "[distro-amd64] run 'make build-microvm-image' before 'make distro-amd64'"; \
fi
./scripts/lib/stage-sqs-store-microvm.sh $(SQS_WORKER_MICROVM_OUT_DIR) dist/amd64 sqs-worker
tar -czf dist/spinifex-$(VERSION)-linux-amd64.tar.gz -C dist/amd64 .
sha256sum dist/spinifex-$(VERSION)-linux-amd64.tar.gz > dist/spinifex-$(VERSION)-linux-amd64.tar.gz.sha256
@@ -486,7 +522,7 @@ distro-arm64:
distro-clean:
rm -rf dist/
.PHONY: test-package-check build build-ui build-installer build-lb-agent build-ecs-agent build-system-image build-eks-node-image import-eks-node-image publish-eks-node-image build-ecs-node-image import-ecs-node-image build-rds-postgres-image import-rds-postgres-image build-rds-mariadb-image import-rds-mariadb-image build-microvm-image install-microvm go_build preflight test test-cover test-race diff-coverage bench fuzz test-actions test-images test-build-scripts test-harness test-integration generate-aws-model-coverage aws-model-coverage test-segscan-oracle manifest-check manifest-lint manifest-lint-update \
.PHONY: test-package-check build build-ui test-ui-e2e ui-e2e-container ui-baselines build-installer build-lb-agent build-ecs-agent build-system-image build-eks-node-image import-eks-node-image publish-eks-node-image build-ecs-node-image import-ecs-node-image build-rds-postgres-image import-rds-postgres-image build-rds-mariadb-image import-rds-mariadb-image build-microvm-image build-sqs-worker-microvm-image install-microvm install-sqs-worker-microvm go_build preflight test test-cover test-race diff-coverage bench fuzz test-actions test-images test-build-scripts test-harness test-integration generate-aws-model-coverage aws-model-coverage test-segscan-oracle \
deploy reinstall clean \
install-system install-go install-aws quickinstall \
lint fix govulncheck nilaway \
+3 -1
View File
@@ -54,7 +54,7 @@ For Terraform, start from the [example workbooks](https://docs.mulgadc.com/docs/
## Services
The platform serves **392 AWS API operations** across the services below. See exactly which ones in the [coverage report](https://docs.mulgadc.com/coverage).
The platform serves **405 AWS API operations** across the services below. See exactly which ones in the [coverage report](https://docs.mulgadc.com/coverage).
| Service | What it is | Built on | Status |
| --- | --- | --- | --- |
@@ -68,8 +68,10 @@ The platform serves **392 AWS API operations** across the services below. See ex
| **ECR** | Container registry | Predastore, deduplicated per account | Available |
| **ECS** | Container service | Spinifex instances | Available |
| **RDS** | Databases | PostgreSQL and MariaDB, with automated backups | Available |
| **Route 53** | DNS | [Northstar](https://github.com/mulgadc/northstar): authoritative DNS with hosted zones | Available |
| **Bedrock** | AI deployment | vLLM for text generation and Hugging Face TEI for embeddings and reranking | Q4 2026 |
| **Lambda** | Serverless functions | | Q1 2027 |
| **SQS** | Message queues | | Q1 2027 |
Roadmap services ship under the same AWS API surface, so code written for AWS today works the moment they land. [Track what's shipped in the release notes](https://github.com/mulgadc/spinifex/releases).
+11 -35
View File
@@ -29,8 +29,17 @@ var allowlist = []allowance{
{From: "agents/rds/agent", To: "domains/rds/instance", Debt: "agents/rds/agent (dependency, narrowed)", Reason: "command protocol, wire and record types; needs a guest/controller wire contract"},
{From: "cmd/aws-model-coverage", To: "domains/acm/awsapi", Debt: "cmd/aws-model-coverage", Reason: "reads declared ECR/ACM inventories; named exception, must not construct a registration"},
{From: "cmd/aws-model-coverage", To: "domains/ecr/awsapi", Debt: "cmd/aws-model-coverage", Reason: "reads declared ECR/ACM inventories; named exception, must not construct a registration"},
{From: "cmd/aws-model-coverage", To: "domains/acm/awsapi", Debt: "cmd/aws-model-coverage", Reason: "reads declared service inventories; named exception, must not construct a registration"},
{From: "cmd/aws-model-coverage", To: "domains/ecr/awsapi", Debt: "cmd/aws-model-coverage", Reason: "reads declared service inventories; named exception, must not construct a registration"},
{From: "cmd/aws-model-coverage", To: "domains/route53/awsapi", Debt: "cmd/aws-model-coverage", Reason: "reads declared service inventories; named exception, must not construct a registration"},
{From: "cmd/aws-model-coverage", To: "domains/elbv2/awsapi", Debt: "cmd/aws-model-coverage", Reason: "reads declared service inventories; named exception, must not construct a registration"},
{From: "cmd/aws-model-coverage", To: "domains/rds/awsapi", Debt: "cmd/aws-model-coverage", Reason: "reads declared service inventories; named exception, must not construct a registration"},
{From: "cmd/aws-model-coverage", To: "domains/ec2/awsapi", Debt: "cmd/aws-model-coverage", Reason: "reads declared service inventories; named exception, must not construct a registration"},
{From: "cmd/aws-model-coverage", To: "domains/ecs/awsapi", Debt: "cmd/aws-model-coverage", Reason: "reads declared service inventories; named exception, must not construct a registration"},
{From: "cmd/aws-model-coverage", To: "domains/eks/awsapi", Debt: "cmd/aws-model-coverage", Reason: "reads declared service inventories; named exception, must not construct a registration"},
{From: "cmd/aws-model-coverage", To: "domains/iam/awsapi", Debt: "cmd/aws-model-coverage", Reason: "reads declared service inventories; named exception, must not construct a registration"},
{From: "cmd/aws-model-coverage", To: "domains/sqs/awsapi", Debt: "cmd/aws-model-coverage", Reason: "reads declared service inventories; named exception, must not construct a registration"},
{From: "cmd/aws-model-coverage", To: "domains/sts/awsapi", Debt: "cmd/aws-model-coverage", Reason: "reads declared service inventories; named exception, must not construct a registration"},
{From: "runtime/daemon", To: "domains/ec2/awsapi/instance", Debt: "Instance adapter consumers outside EC2", Reason: "runtime/daemon/eks_cp_control.go and rds_deps.go call the instance adapter; needs an EC2 capability"},
@@ -52,9 +61,6 @@ var allowlist = []allowance{
{From: "domains/ec2/awsapi/instance", To: "runtime/compute/cache", Debt: "domains/ec2/awsapi/instance (dependency)", Reason: "node liveness and VM state; needs an EC2-owned projection"},
{From: "domains/ec2/awsapi/instance", To: "runtime/compute/vm", Debt: "domains/ec2/awsapi/instance (dependency)", Reason: "node liveness and VM state; needs an EC2-owned projection"},
{From: "domains/ec2/awsapi/spotinstance", To: "domains/admission/quota", Debt: "domains/ec2/awsapi/spotinstance (dependency)", Reason: "RequestSpotInstances.go uses IAMService and quota.Service"},
{From: "domains/ec2/awsapi/spotinstance", To: "domains/iam", Debt: "domains/ec2/awsapi/spotinstance (dependency)", Reason: "RequestSpotInstances.go uses IAMService and quota.Service"},
{From: "domains/ec2/eip", To: "domains/network/topology", Debt: "EC2 → network implementation", Reason: "EC2 reads network implementation; clears with the EC2-owned projection"},
{From: "domains/ec2/guestmetadata", To: "domains/dns", Debt: "Guest metadata: the cross-domain and runtime imports above", Reason: "guest-metadata relocation residual; needs capabilities, projections or contracts"},
@@ -91,42 +97,14 @@ var allowlist = []allowance{
{From: "domains/ochre", To: "domains/ec2/systeminstance", Debt: "Cross-domain", Reason: "consumes another domain's implementation rather than a capability"},
{From: "domains/ochre", To: "domains/iam", Debt: "IAM implementation consumers in STS, Ochre and ELBv2", Reason: "secret encrypt/decrypt helpers; needs a shared crypto boundary"},
{From: "domains/ochre", To: "domains/network/systemvpc", Debt: "Cross-domain", Reason: "consumes another domain's implementation rather than a capability"},
{From: "domains/ochre", To: "ingress/aws/bodyscope", Debt: "domains/ochre (action adapters)", Reason: "Bedrock action adapters still live in the Ochre package; they move to domains/ochre/awsapi"},
{From: "domains/ochre", To: "runtime/compute/gpu", Debt: "Domain → runtime", Reason: "GPU runtime state read directly; needs a domain-owned projection"},
{From: "domains/ochre/vector", To: "domains/iam", Debt: "Cross-domain", Reason: "consumes another domain's implementation rather than a capability"},
{From: "gateway", To: "domains/ec2/awsapi", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
{From: "gateway", To: "domains/ec2/awsapi/account", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
{From: "gateway", To: "domains/ec2/awsapi/capacityreservation", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
{From: "gateway", To: "domains/ec2/awsapi/eigw", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
{From: "gateway", To: "domains/ec2/awsapi/eip", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
{From: "gateway", To: "domains/ec2/awsapi/idem", Debt: "gateway/ec2.go (registration; ec2_idempotency.go)", Reason: "dispatch-time idempotency wrapper; moves with the EC2 dispatch map"},
{From: "gateway", To: "domains/ec2/awsapi/igw", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
{From: "gateway", To: "domains/ec2/awsapi/image", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
{From: "gateway", To: "domains/ec2/awsapi/instance", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
{From: "gateway", To: "domains/ec2/awsapi/key", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
{From: "gateway", To: "domains/ec2/awsapi/launchtemplate", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
{From: "gateway", To: "domains/ec2/awsapi/natgw", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
{From: "gateway", To: "domains/ec2/awsapi/placementgroup", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
{From: "gateway", To: "domains/ec2/awsapi/routetable", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
{From: "gateway", To: "domains/ec2/awsapi/snapshot", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
{From: "gateway", To: "domains/ec2/awsapi/spotinstance", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
{From: "gateway", To: "domains/ec2/awsapi/tags", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
{From: "gateway", To: "domains/ec2/awsapi/volume", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
{From: "gateway", To: "domains/ec2/awsapi/vpc", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
{From: "gateway", To: "domains/ec2/awsapi/zone", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
{From: "domains/rds/awsapi", To: "domains/ec2/awsapi/instance", Debt: "Instance adapter consumers outside EC2", Reason: "enginecatalog.go calls the instance adapter; needs an EC2 capability"},
{From: "domains/sts/awsapi", To: "domains/iam", Debt: "IAM implementation consumers in STS, Ochre and ELBv2", Reason: "GetCallerIdentity takes IAMService; needs an IAM capability"},
{From: "gateway", To: "domains/eks/awsapi", Debt: "gateway (service registration)", Reason: "IAM, STS, EKS and RDS dispatch stays in gateway until the ingress registration seam"},
{From: "gateway", To: "domains/iam/awsapi", Debt: "gateway (service registration)", Reason: "IAM, STS, EKS and RDS dispatch stays in gateway until the ingress registration seam"},
{From: "gateway", To: "domains/rds/awsapi", Debt: "gateway (service registration)", Reason: "IAM, STS, EKS and RDS dispatch stays in gateway until the ingress registration seam"},
{From: "gateway", To: "domains/sts/awsapi", Debt: "gateway (service registration)", Reason: "IAM, STS, EKS and RDS dispatch stays in gateway until the ingress registration seam"},
{From: "gateway", To: "domains/ecs/awsapi", Debt: "gateway (service registration)", Reason: "ECS and ELBv2 dispatch stays in gateway until the ingress registration seam"},
{From: "gateway", To: "domains/elbv2/awsapi", Debt: "gateway (service registration)", Reason: "ECS and ELBv2 dispatch stays in gateway until the ingress registration seam"},
{From: "domains/ecs", To: "domains/ec2/ebs/metadata", Debt: "close-out: handlers/ecs (ADR-0004 blocked-dependency list; inventory-ecs boundary imports)", Reason: "legacy ECS calls another domain's implementation as an internal API"},
{From: "domains/ecs", To: "domains/ec2/eip", Debt: "close-out: handlers/ecs (ADR-0004 blocked-dependency list; inventory-ecs boundary imports)", Reason: "legacy ECS calls another domain's implementation as an internal API"},
{From: "domains/ecs", To: "domains/ec2/instancetypes", Debt: "close-out: handlers/ecs (ADR-0004 blocked-dependency list; inventory-ecs boundary imports)", Reason: "legacy ECS calls another domain's implementation as an internal API"},
@@ -159,6 +137,4 @@ var allowlist = []allowance{
{From: "domains/rds/instance", To: "runtime/compute/vm", Debt: "close-out: handlers/rds (ADR-0004 blocked-dependency list; inventory-rds boundary imports)", Reason: "legacy RDS calls another domain's or runtime implementation directly"},
{From: "domains/sts", To: "domains/iam", Debt: "IAM implementation consumers in STS, Ochre and ELBv2", Reason: "STS reads IAM service, trust-policy and OIDC-provider state; needs an IAM capability"},
{From: "operator/cli", To: "gateway", Debt: "operator/cli (dependency)", Reason: "command tree imports implementations; needs public capabilities"},
}
+39 -16
View File
@@ -192,24 +192,47 @@ func TestADR0001_LayeringCheckerDetectsFaults(t *testing.T) {
}
}
// TestADR0001_InterimDaemonDirectionCoversRuntimeDaemon: the daemon left its
// legacy root, so gateway importing runtime/daemon must still be rejected, and
// the former handlers packages are held to it by R5 at their domain homes.
func TestADR0001_InterimDaemonDirectionCoversRuntimeDaemon(t *testing.T) {
daemon, _ := classify("spinifex/runtime/daemon")
checks := []struct{ rule, from string }{
{"Interim_DaemonGatewayHandlersDirection", "spinifex/gateway"},
{"R5_DomainImportsNoRuntimeOrOperator", "spinifex/domains/ecs"},
// TestADR0001_R2_IngressRejectsRuntimeOperatorAndDomains: the request pipeline
// lives under ingress/aws, so it must not import the daemon, any runtime or
// operator package, or a domain implementation.
func TestADR0001_R2_IngressRejectsRuntimeOperatorAndDomains(t *testing.T) {
r := ruleByID("R2_IngressImportsNoImplementation")
from := "spinifex/ingress/aws/pipeline"
f, ok := classify(from)
if !ok || !r.subject(f, from) {
t.Fatalf("%s is not governed by R2", from)
}
for _, c := range checks {
r := ruleByID(c.rule)
f, _ := classify(c.from)
if !r.subject(f, c.from) || !r.forbids(f, daemon, c.from) {
t.Errorf("%s -> runtime/daemon is not rejected by %s", c.from, c.rule)
for _, to := range []string{
"spinifex/runtime/daemon", "spinifex/runtime/host/process", "spinifex/runtime/roles/awsgw",
"spinifex/operator/admin", "spinifex/domains/ecs", "spinifex/domains/iam/awsapi",
} {
target, _ := classify(to)
if !r.forbids(f, target, from) {
t.Errorf("%s -> %s is not rejected by R2", from, to)
}
}
other, _ := classify("spinifex/runtime/host/process")
if gw, _ := classify("spinifex/gateway"); ruleByID("Interim_DaemonGatewayHandlersDirection").forbids(gw, other, "spinifex/gateway") {
t.Error("gateway -> runtime/host/process is rejected, but it is not the daemon")
}
// TestADR0002_S7_RequestRecordRejectsOutsiders: the request record is readable
// by packages under ingress/aws only.
func TestADR0002_S7_RequestRecordRejectsOutsiders(t *testing.T) {
record, ok := classify("spinifex/ingress/aws/reqctx")
if !ok || !record.requestRecord {
t.Fatalf("ingress/aws/reqctx is not classified as the request record: %+v", record)
}
r := ruleByID("S7_RequestRecordPrivateToIngress")
for name, wantRejected := range map[string]bool{
"domains/ecs/awsapi": true,
"runtime/roles/awsgw": true,
"operator/admin": true,
"ingress/aws/dispatch": false,
} {
from, ok := classify(fullPath(name))
if !ok {
t.Fatalf("%s is unclassified", name)
}
if got := r.forbids(from, record, name); got != wantRejected {
t.Errorf("%s -> ingress/aws/reqctx rejected=%v, want %v", name, got, wantRejected)
}
}
}
+10 -20
View File
@@ -29,12 +29,11 @@ const (
type domainKind int
const (
notDomain domainKind = iota
domainImpl // domains/<d>/... resource implementation
domainAWSAPI // domains/<d>/awsapi/... AWS action adapters
domainProj // domains/<d>/projection/... authorised view
legacyImpl // handlers/<d>/... legacy domain implementation
legacyAdapter // gateway/<d>/... legacy AWS action adapters
notDomain domainKind = iota
domainImpl // domains/<d>/... resource implementation
domainAWSAPI // domains/<d>/awsapi/... AWS action adapters
domainProj // domains/<d>/projection/... authorised view
legacyImpl // handlers/<d>/... legacy domain implementation
)
type class struct {
@@ -43,6 +42,8 @@ type class struct {
domain string // owning domain for domain-shaped and agent packages
kind domainKind
daemon bool // runtime/daemon, the interim daemon -> gateway -> handlers root
// requestRecord marks ingress/aws/reqctx, the request record ADR-0002:S7 keeps private to ingress.
requestRecord bool
}
func (c class) domainShaped() bool { return c.kind != notDomain }
@@ -58,17 +59,8 @@ func (c class) isLegacy(roots ...string) bool {
return slices.Contains(roots, c.legacy)
}
// legacyRoots are pre-ADR-0001 application roots still standing.
var legacyRoots = map[string]bool{
"gateway": true,
}
// gatewayDomains names the domain each gateway/<service> adapter belongs to;
// Bedrock is the AWS surface of the Ochre domain.
var gatewayDomains = map[string]string{
"bedrock": "ochre", "ecs": "ecs", "eks": "eks", "elbv2": "elbv2", "iam": "iam",
"rds": "rds", "sts": "sts",
}
// legacyRoots are pre-ADR-0001 application roots still standing. None remain.
var legacyRoots = map[string]bool{}
// classify maps a module-relative package directory to its layer. ok is false
// for a package that has no deliberate classification.
@@ -103,7 +95,7 @@ func classifyApp(app []string) (class, bool) {
case "foundation":
return class{layer: layerFoundation}, true
case "ingress":
return class{layer: layerIngress}, true
return class{layer: layerIngress, requestRecord: len(app) >= 3 && app[1] == "aws" && app[2] == "reqctx"}, true
case "runtime":
return class{layer: layerRuntime, daemon: len(app) >= 2 && app[1] == "daemon"}, true
case "operator":
@@ -140,8 +132,6 @@ func classifyApp(app []string) (class, bool) {
switch {
case app[0] == "handlers" && len(app) >= 2:
c.domain, c.kind = app[1], legacyImpl
case app[0] == "gateway" && len(app) >= 2 && gatewayDomains[app[1]] != "":
c.domain, c.kind = gatewayDomains[app[1]], legacyAdapter
}
return c, true
}
+2 -8
View File
@@ -9,14 +9,11 @@ import (
// frozenLegacyPackages is every production package still under a legacy root.
// It only shrinks: a new entry is a new legacy package, a missing one is a
// completed move whose line must be deleted.
var frozenLegacyPackages = []string{
"gateway",
"gateway/spx",
}
var frozenLegacyPackages = []string{}
// retiredLegacyRoots were legacy roots whose every package has moved to its
// ADR-0001 target; recreating one would reopen a closed migration row.
var retiredLegacyRoots = []string{"accountteardown", "admin", "daemon", "handlers", "lbagent", "services", "utils", "vpcd"}
var retiredLegacyRoots = []string{"accountteardown", "admin", "daemon", "gateway", "handlers", "lbagent", "services", "utils", "vpcd"}
// TestADR0001_LegacyRootsFrozen: the current-to-target map gives every legacy
// root a destination, so none may gain a package and no retired root may return.
@@ -36,9 +33,6 @@ func TestADR0001_LegacyRootsFrozen(t *testing.T) {
present[name] = true
}
}
if len(present) == 0 {
t.Fatal("found no legacy packages; the freeze would pass vacuously")
}
frozen := map[string]bool{}
for _, p := range frozenLegacyPackages {
frozen[p] = true
+14 -16
View File
@@ -1,6 +1,9 @@
package layering
import "slices"
import (
"slices"
"strings"
)
// rule is one ADR-0001 dependency rule over a classified import edge.
type rule struct {
@@ -123,10 +126,8 @@ var rules = []rule{
case t.layer == layerAgents:
return true
case t.layer == layerIngress:
return f.kind != domainAWSAPI && f.kind != legacyAdapter
case t.isLegacy("gateway") && !t.domainShaped():
return true
case t.isLegacy("handlers", "gateway"):
return f.kind != domainAWSAPI
case t.isLegacy("handlers"):
return f.layer == layerDomain
}
return false
@@ -152,18 +153,15 @@ var rules = []rule{
},
},
{
id: "Interim_DaemonGatewayHandlersDirection",
clause: "during migration the existing daemon -> gateway -> handlers rule remains the guardrail (Q-20, Q-29)",
hint: "keep the dependency pointing daemon -> gateway -> handlers; invert it through a callback or capability",
subject: func(f class, _ string) bool { return f.isLegacy("handlers", "gateway") },
forbids: func(f, t class, _ string) bool {
switch {
case f.isLegacy("handlers"):
return t.isLegacy("gateway") || t.daemon
case f.isLegacy("gateway"):
return t.daemon
id: "S7_RequestRecordPrivateToIngress",
clause: "ADR-0002:S7: the request record is private to ingress, so no domain, runtime or operator package reads it; only packages under ingress/aws may import ingress/aws/reqctx",
hint: "receive what you need through dispatch.Invocation instead of the request record",
subject: func(_ class, _ string) bool { return true },
forbids: func(_, t class, fromName string) bool {
if !t.requestRecord {
return false
}
return false
return !strings.HasPrefix(fromName, "ingress/aws/")
},
},
}
+1 -1
View File
@@ -1,6 +1,6 @@
ARG VERSION=dev
FROM golang:1.27.3-bookworm@sha256:972b4110ba0f47539a975c31e52c64765e1ff19f2d74469ae4a595c6676e80a5 AS builder
FROM golang:1.27.3-bookworm@sha256:d9b6a1beb9474adc2a1ce842573639b44b2a0dd49b6669f51d3821baf9ec37c9 AS builder
ARG VERSION
+100
View File
@@ -0,0 +1,100 @@
#!/bin/sh
mount -t proc proc /proc
mount -t sysfs sys /sys
mount -t devtmpfs dev /dev
# virtio_mmio is built-in (CONFIG_VIRTIO_MMIO=y) so virtio-net devices are
# already enumerated by the time PID 1 runs — no modprobe needed.
# qemu_fw_cfg and virtio_net are =m in stock Alpine linux-virt, so load them.
#
# modprobe 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
# The guest's SQS store listens on 127.0.0.1 and the worker dials it there;
# the kernel leaves lo down, so bring it up before init starts either.
ip link set lo up
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 "$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
. "$NETCFG"
for n in 0 1 2 3 4 5; do
eval mac=\$NIC${n}_MAC
[ -z "$mac" ] && continue
iface=""
for attempt in 1 2 3 4 5; do
iface=$(ip -o link | awk -v m="$mac" 'tolower($0) ~ tolower(m) {print $2}' | tr -d :)
[ -n "$iface" ] && break
sleep 0.02
done
if [ -z "$iface" ]; then
echo "[init] WARNING: no interface found for MAC $mac (NIC$n), skipping" >&2
continue
fi
eval cidr=\$NIC${n}_CIDR
eval gw=\$NIC${n}_GW
eval default=\$NIC${n}_DEFAULT
eval rdst=\$NIC${n}_ROUTE_DST
eval rvia=\$NIC${n}_ROUTE_VIA
ip link set "$iface" up
[ -n "$cidr" ] && ip addr add "$cidr" dev "$iface"
[ "$default" = "1" ] && [ -n "$gw" ] && ip route add default via "$gw" dev "$iface"
[ -n "$rdst" ] && [ -n "$rvia" ] && ip route add "$rdst" via "$rvia" dev "$iface"
done
# The image takes no lb-agent environment. The worker reads the cluster CA
# from fw_cfg itself; this copy only serves other tools in the guest.
CA_CERT=/sys/firmware/qemu_fw_cfg/by_name/opt/spinifex/ca-cert/raw
mkdir -p /etc/ssl/certs
[ -f "$CA_CERT" ] && cp "$CA_CERT" /etc/ssl/certs/ca-certificates.crt
exec /sbin/init
+9
View File
@@ -0,0 +1,9 @@
# The power button loads here, not in /init: busybox init has its SIGUSR2
# (poweroff) handler installed before sysinit runs, so a press can never reach
# the pre-exec shell PID 1. A press before this line is lost; the host re-sends.
::sysinit:/sbin/modprobe tiny_power_button power_signal=12
::sysinit:/usr/local/lib/sqs-worker/sqs-worker-prepare
::respawn:/usr/local/lib/sqs-worker/sqs-worker-store
::respawn:/usr/local/lib/sqs-worker/sqs-worker-ready
::ctrlaltdel:/sbin/reboot
::shutdown:/usr/local/lib/sqs-worker/sqs-worker-stop
+203
View File
@@ -0,0 +1,203 @@
# sqs-worker-lib.sh — shared by the SQS guest's store, readiness and stop
# steps. Sourced, never executed. Every path and command is overridable so the
# host test can drive the same code against test-owned processes and stub tools.
#
# The guest runs its own SQS nats-server on loopback and the worker beside it.
# The server starts only after boot settled the receipt key; store-ready exists
# only while the running watcher's last check of that server passed.
SQSW_RUN=${SQSW_RUN:-/run/sqs-worker}
SQSW_DATA=${SQSW_DATA:-/data}
SQSW_CONSOLE=${SQSW_CONSOLE:-/dev/console}
SQSW_MOUNTS=${SQSW_MOUNTS:-/proc/mounts}
SQSW_BIN=${SQSW_BIN:-/usr/local/bin/sqs-worker}
SQSW_LIB=${SQSW_LIB:-/usr/local/lib/sqs-worker/sqs-worker-lib.sh}
SQSW_NATS_BIN=${SQSW_NATS_BIN:-/usr/local/bin/nats-server}
SQSW_STORE_PORT=${SQSW_STORE_PORT:-4222}
SQSW_STOP_BUDGET_TENTHS=${SQSW_STOP_BUDGET_TENTHS:-100}
SQSW_RESPAWN_GAP=${SQSW_RESPAWN_GAP:-5}
SQSW_PARK=${SQSW_PARK:-sleep 2147483647}
SQSW_BOOT_RETRY=${SQSW_BOOT_RETRY:-30}
UMOUNT=${UMOUNT:-umount}
sqsw_log() { echo "[sqs-worker] $*" >> "$SQSW_CONSOLE"; }
# sqsw_alive <pid>: running or stopped, not exited (a zombie counts as exited).
sqsw_alive() { [ -r "/proc/$1/status" ] && ! grep -q '^State:[[:space:]]*Z' "/proc/$1/status" 2>/dev/null; }
# sqsw_starttime <pid>: kernel start time (stat field 22). It survives exec, and
# pairing it with the pid reduces PID-reuse risk; it is not an absolute guarantee.
sqsw_starttime() { sed 's/.*) //' "/proc/$1/stat" 2>/dev/null | cut -d' ' -f20; }
# sqsw_same <pid> <starttime>: the recorded process is still alive.
sqsw_same() { [ -n "$2" ] && sqsw_alive "$1" && [ "$(sqsw_starttime "$1")" = "$2" ]; }
# sqsw_write_config <file>: the guest's own server. It listens only on
# loopback, with no monitor, route or cluster listener. Its max_payload admits
# a 1 MiB SQS message with its headers (ADR-0011:S4); the 1MB default does not.
sqsw_write_config() {
cat > "$1" <<CONF
listen: "127.0.0.1:$SQSW_STORE_PORT"
server_name: "sqs-store"
max_payload: 2MiB
jetstream {
store_dir: "$SQSW_DATA/jetstream"
sync_interval: always
}
CONF
}
# sqsw_prepare_store: write and test the server config once boot has settled
# the receipt key. Returns non-zero, leaving the store unstarted, otherwise.
sqsw_prepare_store() {
rm -f "$SQSW_RUN/store.conf"
[ -s "$SQSW_RUN/receipt-ready" ] || { sqsw_log "store: receipt key not settled, store not prepared"; return 1; }
umask 077
sqsw_write_config "$SQSW_RUN/store.conf.new" || return 1
if ! "$SQSW_NATS_BIN" -t -c "$SQSW_RUN/store.conf.new" > "$SQSW_RUN/config-test.out" 2>&1; then
sqsw_log "store: FATAL: server config test failed: $(tr '\n' ' ' < "$SQSW_RUN/config-test.out")"
return 1
fi
mv "$SQSW_RUN/store.conf.new" "$SQSW_RUN/store.conf"
sqsw_log "store: config written for 127.0.0.1:$SQSW_STORE_PORT; server not started yet"
}
# sqsw_park <why>: init respawns a step that exits, so a step that must not run
# logs once and waits for shutdown instead of looping.
sqsw_park() {
sqsw_log "$1"
exec $SQSW_PARK
}
# sqsw_record <name>: record this process's identity before the stopping
# check, so a stop that misses the record has already set stopping.
sqsw_record() { echo "$$ $(sqsw_starttime $$)" > "$SQSW_RUN/$1.pid"; }
# sqsw_boot: settle the disk and receipt key once per guest boot. A transient
# failure (exit 75) waits and exits so init runs it again; boot never remounts
# a disk already mounted. Any other failure is final for this guest boot.
sqsw_boot() {
[ ! -e "$SQSW_RUN/boot-refused" ] || sqsw_park "store: boot refused, not starting"
[ -s "$SQSW_RUN/receipt-ready" ] && return 0
# The boot child records itself before its stopping check, as the other
# steps do, so shutdown either sees its record or it never starts.
SQSW_RUN="$SQSW_RUN" SQSW_BIN="$SQSW_BIN" sh -c '. "$1"; sqsw_record boot; [ ! -e "$SQSW_RUN/stopping" ] || exit 75; exec "$SQSW_BIN" boot' \
sh "$SQSW_LIB" >> "$SQSW_CONSOLE" 2>&1 &
child=$!
rc=0
wait "$child" || rc=$?
rm -f "$SQSW_RUN/boot.pid"
[ ! -e "$SQSW_RUN/stopping" ] || sqsw_park "store: shutdown during boot, not starting"
case "$rc" in
0) return 0 ;;
75) sqsw_log "store: boot not settled, retrying in ${SQSW_BOOT_RETRY}s"
sleep "$SQSW_BOOT_RETRY"
exit 1 ;;
*) : > "$SQSW_RUN/boot-refused"
sqsw_park "store: boot refused (rc=$rc), not starting" ;;
esac
}
# sqsw_store: the respawned server body. It starts only on a prepared config.
sqsw_store() {
mkdir -p "$SQSW_RUN"
sqsw_record store
[ ! -e "$SQSW_RUN/stopping" ] || sqsw_park "store: shutdown in progress, not starting"
sqsw_boot
if [ ! -s "$SQSW_RUN/store.conf" ] && ! sqsw_prepare_store; then
: > "$SQSW_RUN/boot-refused"
sqsw_park "store: not prepared, not starting"
fi
sqsw_log "store: starting pid=$$"
# ADR-0011:S11 bounds the store's Go heap; the guest must be larger.
GOMEMLIMIT=512MiB exec "$SQSW_NATS_BIN" -c "$SQSW_RUN/store.conf"
}
# sqsw_run: the respawned readiness body. A predecessor that was killed or
# crashed could not remove its marker, so every start removes it first.
sqsw_run() {
mkdir -p "$SQSW_RUN"
sqsw_record ready
if [ -e "$SQSW_RUN/store-ready" ]; then
rm -f "$SQSW_RUN/store-ready"
sqsw_log "ready: removed a store-ready marker left by an earlier watcher"
fi
[ ! -e "$SQSW_RUN/stopping" ] || sqsw_park "ready: shutdown in progress, not starting"
[ ! -e "$SQSW_RUN/boot-refused" ] || sqsw_park "ready: boot refused, readiness not started"
# Boot may still be retrying; check again after a gap rather than parking.
if [ ! -s "$SQSW_RUN/store.conf" ]; then
sleep "$SQSW_RESPAWN_GAP"
exit 0
fi
# Init respawns at once; a watcher that keeps exiting restarts at most
# once per gap instead of in a tight loop.
now=$(date +%s)
last=$(cat "$SQSW_RUN/ready.last" 2>/dev/null || echo 0)
wait=$((last + SQSW_RESPAWN_GAP - now))
if [ "$wait" -gt 0 ] && [ "$wait" -le "$SQSW_RESPAWN_GAP" ]; then sleep "$wait"; fi
date +%s > "$SQSW_RUN/ready.last"
sqsw_log "ready: starting watcher pid=$$"
exec "$SQSW_BIN" ready >> "$SQSW_CONSOLE" 2>&1
}
# sqsw_end <name>: SIGTERM the recorded process, SIGKILL only after the
# budget. Returns non-zero while it is still alive.
sqsw_end() {
rec=$(cat "$SQSW_RUN/$1.pid" 2>/dev/null || true)
pid=${rec%% *} st=${rec#* }
[ "$st" != "$rec" ] || st=""
if [ -z "$pid" ] || ! sqsw_same "$pid" "$st"; then
sqsw_log "stop: no running $1"
return 0
fi
kill -TERM "$pid" 2>/dev/null || true
sqsw_log "stop: SIGTERM sent to $1 pid=$pid"
i=0
while sqsw_same "$pid" "$st" && [ "$i" -lt "$SQSW_STOP_BUDGET_TENTHS" ]; do sleep 0.1; i=$((i + 1)); done
if sqsw_same "$pid" "$st"; then
sqsw_log "stop: $1 pid=$pid still running after $((SQSW_STOP_BUDGET_TENTHS / 10))s, SIGKILL"
kill -KILL "$pid" 2>/dev/null || true
j=0
while sqsw_same "$pid" "$st" && [ "$j" -lt 30 ]; do sleep 0.1; j=$((j + 1)); done
fi
if sqsw_same "$pid" "$st"; then
sqsw_log "stop: $1 pid=$pid STILL ALIVE"
return 1
fi
sqsw_log "stop: $1 pid=$pid exited"
rm -f "$SQSW_RUN/$1.pid"
}
# sqsw_stop: block any respawn, drop the marker, stop the watcher, the server
# and any boot in progress, and unmount the disk only once all are confirmed
# gone. A survivor keeps the disk mounted.
sqsw_stop() {
mkdir -p "$SQSW_RUN"
: > "$SQSW_RUN/stopping"
rm -f "$SQSW_RUN/store-ready"
if ! sqsw_end ready || ! sqsw_end store || ! sqsw_end boot; then
rm -f "$SQSW_RUN/store-ready"
sqsw_log "stop: leaving $SQSW_DATA mounted"
return 1
fi
rm -f "$SQSW_RUN/store-ready"
sync
if grep -q " $SQSW_DATA " "$SQSW_MOUNTS" 2>/dev/null; then
rc=0
"$UMOUNT" "$SQSW_DATA" || rc=$?
sqsw_log "stop: umount $SQSW_DATA rc=$rc"
fi
}
# sqsw_shutdown: the only shutdown action. The generic read-only remount runs
# only after both processes stopped; a survivor keeps every mount as it is.
sqsw_shutdown() {
if ! sqsw_stop; then
sync
sqsw_log "shutdown: a guest process survived, skipping umount -a -r"
return 1
fi
rc=0
"$UMOUNT" -a -r || rc=$?
echo "[init] shutdown: umount -a -r rc=$rc" >> "$SQSW_CONSOLE"
}
+9
View File
@@ -0,0 +1,9 @@
#!/bin/sh
# sysinit: load the disk drivers and clear this boot's markers. The respawned
# store step mounts the disk, settles the receipt key and starts the store.
. /usr/local/lib/sqs-worker/sqs-worker-lib.sh
modprobe virtio_blk 2>/dev/null || true
modprobe ext4 2>/dev/null || true
mkdir -p "$SQSW_RUN"
rm -f "$SQSW_RUN/receipt-ready" "$SQSW_RUN/store.conf" "$SQSW_RUN/boot-refused"
exit 0
+5
View File
@@ -0,0 +1,5 @@
#!/bin/sh
# Respawned by init: the worker, reporting ready only while the local store
# answers. A watcher that must not run parks instead of exiting.
. /usr/local/lib/sqs-worker/sqs-worker-lib.sh
sqsw_run
+5
View File
@@ -0,0 +1,5 @@
#!/bin/sh
# The only shutdown action: stop the worker, then the store, and unmount the
# data disk, then run the generic read-only remount only if both are gone.
. /usr/local/lib/sqs-worker/sqs-worker-lib.sh
sqsw_shutdown
+5
View File
@@ -0,0 +1,5 @@
#!/bin/sh
# Respawned by init: the guest's own SQS nats-server, started only on a
# prepared config. A store that must not run parks instead of exiting.
. /usr/local/lib/sqs-worker/sqs-worker-lib.sh
sqsw_store
+2 -1
View File
@@ -1,4 +1,4 @@
# spinifex-unit-version: 5
# spinifex-unit-version: 6
[Unit]
Description=Spinifex Daemon (VM Orchestration)
PartOf=spinifex.target
@@ -15,6 +15,7 @@ Slice=spinifex-system.slice
User=spinifex-daemon
Group=spinifex
ExecStartPre=/usr/local/bin/spx admin hostnet check
ExecStartPre=-+/usr/local/bin/spx admin node-facts collect
ExecStart=/usr/local/bin/spx service spinifex start
Restart=on-failure
RestartSec=5
+6 -4
View File
@@ -1,11 +1,10 @@
# spinifex-unit-version: 1
# spinifex-unit-version: 2
[Unit]
Description=Spinifex host firewall (nftables input policy)
Documentation=file:/etc/spinifex/firewall/spinifex.nft
Wants=network-pre.target
Before=network-pre.target shutdown.target
DefaultDependencies=no
ConditionPathExists=/etc/spinifex/firewall/peers.nft
[Service]
Type=oneshot
@@ -13,8 +12,11 @@ RemainAfterExit=yes
StandardInput=null
ProtectSystem=full
ProtectHome=true
ExecStart=/usr/local/lib/spinifex/spinifex-firewall-apply
ExecReload=/usr/local/lib/spinifex/spinifex-firewall-apply
# Unconditional: connection tracking loads on every node, so a flow that opens
# while the policy is not armed survives the arm. The policy itself still loads
# only where the daemon has written a peer file.
ExecStart=/usr/local/lib/spinifex/spinifex-firewall-apply boot
ExecReload=/usr/local/lib/spinifex/spinifex-firewall-apply boot
TimeoutStopSec=10s
# No ExecStop: flushing on stop would take vpcd's MASQUERADE and per-EIP FORWARD
-57
View File
@@ -1,57 +0,0 @@
# spinifex-unit-version: 1
[Unit]
Description=Spinifex SQS Message Store (dedicated JetStream)
PartOf=spinifex.target
ConditionPathExists=/etc/spinifex/sqs-store/sqs-store.conf
[Service]
EnvironmentFile=-/etc/spinifex/telemetry.env
Type=simple
Slice=spinifex-system.slice
User=spinifex-sqsstore
Group=spinifex
ExecStart=/usr/local/bin/spx service sqs-store start
Restart=on-failure
RestartSec=5
# JetStream store shutdown and raft stepdown have no internal deadline.
TimeoutStopSec=20
OOMScoreAdjust=-500
# Soft Go heap limit for the store; MemoryHigh/Max bound a runaway on top.
Environment=GOMEMLIMIT=512MiB
MemoryHigh=10%
MemoryMax=20%
Environment=SPINIFEX_CONFIG_PATH=/etc/spinifex/sqs-store/sqs-store.conf
# Filesystem access
NoNewPrivileges=yes
ProtectSystem=strict
PrivateDevices=yes
ReadOnlyPaths=/etc/spinifex/sqs-store /etc/spinifex/server.pem /etc/spinifex/server.key /etc/spinifex/ca.pem
ReadWritePaths=/var/lib/spinifex/sqs-store /var/log/spinifex
# Hardening
ProtectHome=yes
PrivateTmp=yes
RestrictSUIDSGID=yes
LockPersonality=yes
RestrictRealtime=yes
RestrictNamespaces=yes
ProtectClock=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectProc=invisible
SystemCallArchitectures=native
MemoryDenyWriteExecute=yes
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
CapabilityBoundingSet=
ProtectHostname=yes
KeyringMode=private
RemoveIPC=yes
LimitCORE=0
LimitNOFILE=65536
LimitNPROC=4096
[Install]
WantedBy=spinifex.target
+2 -2
View File
@@ -1,9 +1,9 @@
# spinifex-unit-version: 3
# spinifex-unit-version: 4
[Unit]
Description=Spinifex Infrastructure Platform
After=network-online.target
Wants=network-online.target
Wants=spinifex-nats.service spinifex-sqs-store.service spinifex-predastore.service spinifex-northstar.service spinifex-viperblock.service spinifex-daemon.service spinifex-awsgw.service spinifex-vpcd.service spinifex-ui.service spinifex-qmp-collector.service spinifex-shutdown.service spinifex-nats-watchdog.timer ovsdb-server.service ovs-vswitchd.service ovn-controller.service
Wants=spinifex-nats.service spinifex-predastore.service spinifex-northstar.service spinifex-viperblock.service spinifex-daemon.service spinifex-awsgw.service spinifex-vpcd.service spinifex-ui.service spinifex-qmp-collector.service spinifex-shutdown.service spinifex-nats-watchdog.timer ovsdb-server.service ovs-vswitchd.service ovn-controller.service
[Install]
WantedBy=multi-user.target
+29 -4
View File
@@ -4,12 +4,21 @@ import (
"flag"
"fmt"
"os"
"slices"
"github.com/mulgadc/predastore/s3api"
"github.com/mulgadc/spinifex/internal/awsmodel"
acmawsapi "github.com/mulgadc/spinifex/spinifex/domains/acm/awsapi"
ec2awsapi "github.com/mulgadc/spinifex/spinifex/domains/ec2/awsapi"
awsapi "github.com/mulgadc/spinifex/spinifex/domains/ecr/awsapi"
"github.com/mulgadc/spinifex/spinifex/gateway"
ecsawsapi "github.com/mulgadc/spinifex/spinifex/domains/ecs/awsapi"
eksawsapi "github.com/mulgadc/spinifex/spinifex/domains/eks/awsapi"
elbv2awsapi "github.com/mulgadc/spinifex/spinifex/domains/elbv2/awsapi"
iamawsapi "github.com/mulgadc/spinifex/spinifex/domains/iam/awsapi"
rdsawsapi "github.com/mulgadc/spinifex/spinifex/domains/rds/awsapi"
route53awsapi "github.com/mulgadc/spinifex/spinifex/domains/route53/awsapi"
sqsawsapi "github.com/mulgadc/spinifex/spinifex/domains/sqs/awsapi"
stsawsapi "github.com/mulgadc/spinifex/spinifex/domains/sts/awsapi"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/dispatch"
_ "github.com/mulgadc/bluebottle/pkg/fipsboot"
@@ -48,10 +57,26 @@ func fail(err error) {
func compareAll() ([]awsmodel.OperationCoverage, error) {
// Registered services contribute their declared inventory, which needs no
// live dependencies; nothing here serves a request.
inventories := gateway.AWSOperationInventory(map[string]dispatch.Inventory{
awsapi.ServiceName: awsapi.OperationInventory(),
acmawsapi.ServiceName: acmawsapi.OperationInventory(),
inventories := dispatch.AWSOperationInventory(map[string]dispatch.Inventory{
awsapi.ServiceName: awsapi.OperationInventory(),
acmawsapi.ServiceName: acmawsapi.OperationInventory(),
route53awsapi.ServiceName: route53awsapi.OperationInventory(),
elbv2awsapi.InventoryName: elbv2awsapi.OperationInventory(),
rdsawsapi.ServiceName: rdsawsapi.OperationInventory(),
ec2awsapi.ServiceName: ec2awsapi.OperationInventory(),
ecsawsapi.ServiceName: ecsawsapi.OperationInventory(),
eksawsapi.ServiceName: eksawsapi.OperationInventory(),
iamawsapi.ServiceName: iamawsapi.OperationInventory(),
sqsawsapi.ServiceName: sqsawsapi.OperationInventory(),
stsawsapi.ServiceName: stsawsapi.OperationInventory(),
})
// The loop below walks the embedded models, so a served service with no
// model would otherwise drop out of the report without a word.
for name := range inventories {
if !slices.Contains(awsmodel.Services(), awsmodel.Service(name)) {
return nil, fmt.Errorf("no embedded AWS model for served service %s", name)
}
}
coverages := make([]awsmodel.OperationCoverage, 0, len(awsmodel.Services()))
for _, service := range awsmodel.Services() {
inventory, ok := inventories[string(service)]
+50
View File
@@ -0,0 +1,50 @@
// eks-etcd-member removes a dead peer from the embedded etcd cluster of an EKS
// K3s control-plane VM. A member that is already gone exits 0; any other
// failure exits non-zero.
package main
import (
"context"
"flag"
"fmt"
"io"
"os"
"time"
_ "github.com/mulgadc/bluebottle/pkg/fipsboot"
"github.com/mulgadc/spinifex/spinifex/agents/eks/etcdmember"
)
func main() {
os.Exit(run(os.Args[1:], os.Stdout, os.Stderr))
}
func run(args []string, stdout, stderr io.Writer) int {
if len(args) < 1 || args[0] != "remove" {
fmt.Fprintln(stderr, "usage: eks-etcd-member remove --peer-ip 10.0.0.12 [--self-ip 10.0.0.10]")
return 2
}
fs := flag.NewFlagSet("remove", flag.ContinueOnError)
fs.SetOutput(stderr)
var cfg etcdmember.Config
endpoint := fs.String("endpoint", etcdmember.DefaultEndpoint, "etcd client endpoint")
certDir := fs.String("cert-dir", etcdmember.DefaultCertDir, "directory holding the K3s etcd client.crt, client.key and server-ca.crt")
timeout := fs.Duration("timeout", 30*time.Second, "overall deadline")
fs.StringVar(&cfg.PeerIP, "peer-ip", "", "peer URL host of the member to remove")
fs.StringVar(&cfg.SelfIP, "self-ip", "", "this host's own address; removal of it is refused")
if err := fs.Parse(args[1:]); err != nil {
return 2
}
ctx, cancel := context.WithTimeout(context.Background(), *timeout)
defer cancel()
cli, err := etcdmember.Dial(*endpoint, *certDir, *timeout)
if err != nil {
fmt.Fprintln(stderr, "etcd member remove failed:", err)
return 1
}
defer func() { _ = cli.Close() }()
return etcdmember.Run(ctx, cli, cfg, stdout)
}
+63
View File
@@ -0,0 +1,63 @@
package main
import (
"bytes"
"testing"
"github.com/mulgadc/spinifex/internal/testkit/fakeetcd"
"github.com/stretchr/testify/assert"
)
var fakeMembers = []fakeetcd.Member{
{ID: 0xa1, Name: "cp-a", PeerURLs: []string{"https://10.0.0.10:2380"}},
{ID: 0xb2, Name: "cp-b", PeerURLs: []string{"https://10.0.0.12:2380"}},
}
func TestRunRequiresTheRemoveSubcommand(t *testing.T) {
t.Parallel()
for _, args := range [][]string{nil, {"list"}} {
var out, errOut bytes.Buffer
assert.Equal(t, 2, run(args, &out, &errOut))
assert.Contains(t, errOut.String(), "usage: eks-etcd-member remove")
}
}
func TestRunRejectsUnknownFlags(t *testing.T) {
t.Parallel()
var out, errOut bytes.Buffer
assert.Equal(t, 2, run([]string{"remove", "--bogus"}, &out, &errOut))
}
func TestRunFailsWhenEtcdCertificatesAreMissing(t *testing.T) {
t.Parallel()
var out, errOut bytes.Buffer
code := run([]string{"remove", "--peer-ip", "10.0.0.12", "--cert-dir", t.TempDir()}, &out, &errOut)
assert.Equal(t, 1, code)
assert.Contains(t, errOut.String(), "etcd member remove failed")
}
func TestRunRemovesTheNamedPeer(t *testing.T) {
t.Parallel()
srv := fakeetcd.Start(t, 0xa1, fakeMembers)
var out, errOut bytes.Buffer
code := run([]string{"remove", "--peer-ip", "10.0.0.12", "--self-ip", "10.0.0.10",
"--endpoint", srv.Endpoint, "--cert-dir", srv.CertDir}, &out, &errOut)
assert.Zero(t, code, errOut.String()+out.String())
assert.Contains(t, out.String(), "removed etcd member 10.0.0.12")
assert.Equal(t, []uint64{0xb2}, srv.Removed())
}
func TestRunTreatsAnAbsentPeerAsSuccess(t *testing.T) {
t.Parallel()
srv := fakeetcd.Start(t, 0xa1, fakeMembers)
var out, errOut bytes.Buffer
code := run([]string{"remove", "--peer-ip", "10.0.0.99",
"--endpoint", srv.Endpoint, "--cert-dir", srv.CertDir}, &out, &errOut)
assert.Zero(t, code)
assert.Contains(t, out.String(), "member already absent")
assert.Empty(t, srv.Removed())
}
+2 -1
View File
@@ -6,6 +6,7 @@
//
// eks-gateway-fetch -resource addons # GET /clusters/{cluster}/internal-addons/{accountId}
// eks-gateway-fetch -resource recovery -instance-id i-… # GET /clusters/{cluster}/internal-recovery/{accountId}/{instanceId}
// eks-gateway-fetch -resource workers # GET /clusters/{cluster}/internal-workers/{accountId}
//
// Flags default to environment variables seeded by cloud-init:
// EKS_GATEWAY_URL, EKS_GATEWAY_CA, EKS_ACCESS_KEY, EKS_SECRET_KEY, EKS_REGION,
@@ -33,7 +34,7 @@ func main() {
flag.StringVar(&cfg.AccountID, "account-id", os.Getenv("EKS_ACCOUNT_ID"), "Cluster account ID")
flag.StringVar(&cfg.ClusterName, "cluster", os.Getenv("EKS_CLUSTER_NAME"), "Cluster name")
flag.StringVar(&cfg.InstanceID, "instance-id", os.Getenv("EKS_INSTANCE_ID"), "This member's EC2 instance ID (recovery resource)")
flag.StringVar(&cfg.Resource, "resource", "addons", "Resource to fetch: addons | recovery")
flag.StringVar(&cfg.Resource, "resource", "addons", "Resource to fetch: addons | recovery | workers")
flag.Parse()
if err := gatewayfetch.Run(context.Background(), cfg); err != nil {
+32
View File
@@ -0,0 +1,32 @@
// Command eks-pod-identity-webhook is the IRSA mutating admission webhook run
// on every EKS control-plane server; the implementation is in
// agents/eks/podidentity. It listens on loopback only and is called by the
// local kube-apiserver.
package main
import (
"context"
"log/slog"
"os"
"os/signal"
"syscall"
_ "github.com/mulgadc/bluebottle/pkg/fipsboot"
"github.com/mulgadc/spinifex/spinifex/agents/eks/podidentity"
)
func main() {
log := slog.New(slog.NewTextHandler(os.Stderr, nil))
cfg, err := podidentity.ParseServiceConfig(os.Args[1:], os.Getenv, os.Stderr)
if err != nil {
log.Error("invalid configuration", "error", err)
os.Exit(2)
}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
if err := podidentity.Run(ctx, cfg, log); err != nil {
log.Error("pod identity webhook stopped", "error", err)
os.Exit(1)
}
}
+1
View File
@@ -29,6 +29,7 @@ func loadConfig() (tokenwebhook.Config, error) {
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"),
ReadyAddr: os.Getenv("EKS_WEBHOOK_READY_ADDR"),
}
// Static SigV4 creds are optional: when absent, eksgw.New signs with the
// AWS SDK chain (IMDS instance-role creds), the same path the CP VM's
+140
View File
@@ -0,0 +1,140 @@
// sqs-worker runs inside the SQS microVM, next to its own SQS NATS server.
//
// Usage:
//
// sqs-worker boot
// sqs-worker ready
//
// boot selects and mounts the guest's data disk named by fw_cfg, asks the
// gateway for its receipt key decision with its instance-role credentials,
// then loads the recorded key, or creates one on a new volume and records its
// fingerprint, before the guest's SQS store first starts on that disk. ready
// then keeps checking the guest's own loopback JetStream, keeping a readiness
// marker only while it answers, and serves SQS owner calls against it, pulling
// them from the gateway with the same credentials.
//
// Serving is refused unless the gateway reports a single-node host cluster.
// A disk whose receipt key is missing or does not match the recorded one
// refuses to serve; recovering it needs a procedure not yet designed, and
// nothing deletes or replaces the volume.
package main
import (
"context"
"errors"
"fmt"
"os"
"os/signal"
"syscall"
"time"
"github.com/nats-io/nats.go/jetstream"
"github.com/mulgadc/spinifex/internal/gwsign"
"github.com/mulgadc/spinifex/internal/sqsworkergw"
"github.com/mulgadc/spinifex/spinifex/agents/sqs/workerboot"
"github.com/mulgadc/spinifex/spinifex/agents/sqs/workerkey"
"github.com/mulgadc/spinifex/spinifex/domains/sqs/workerserve"
"github.com/mulgadc/spinifex/spinifex/foundation/state/clustersize"
)
// The cluster CA the daemon supplies; the only root the client trusts.
const caPath = "/sys/firmware/qemu_fw_cfg/by_name/opt/spinifex/ca-cert/raw"
// newClaimer signs with the IMDS instance-role credentials only; there is
// no static-key fallback.
func newClaimer() workerboot.NewClaimer {
return func(region, gatewayURL string) (workerkey.Claimer, error) {
c, err := newClient(region, gatewayURL)
if err != nil {
return nil, err
}
return workerboot.GatewayClaimer{Client: c}, nil
}
}
func newClient(region, gatewayURL string) (*sqsworkergw.Client, error) {
ca, err := os.ReadFile(caPath)
if err != nil {
return nil, fmt.Errorf("read cluster CA: %w", err)
}
return sqsworkergw.New(gatewayURL, region, ca, gwsign.NewInstanceRole())
}
func main() {
if len(os.Args) != 2 || (os.Args[1] != "boot" && os.Args[1] != "ready") {
fmt.Fprintln(os.Stderr, "usage: sqs-worker boot|ready")
os.Exit(2)
}
logf := func(msg string) { fmt.Fprintf(os.Stderr, "[sqs-worker] %s\n", msg) }
run := boot
if os.Args[1] == "ready" {
run = ready
}
if err := run(logf); err != nil {
if os.Args[1] == "boot" && workerboot.Transient(err) {
logf("boot not settled, will retry: " + err.Error())
os.Exit(exitTransient)
}
logf("FATAL: " + err.Error())
os.Exit(1)
}
}
// exitTransient tells the guest's supervisor that boot may be run again.
const exitTransient = 75
func config(logf func(string)) workerboot.Config {
return workerboot.Config{
FwCfgDir: "/sys/firmware/qemu_fw_cfg/by_name/opt/spinifex/sqs-worker",
SysBlock: "/sys/block",
DevDir: "/dev",
MountInfo: "/proc/self/mountinfo",
DataDir: "/data",
RunDir: "/run/sqs-worker",
Disk: workerboot.SystemDisk{E2fsck: "/sbin/e2fsck"},
IsBlockDevice: workerboot.IsBlockDevice,
NewClaimer: newClaimer(),
Attempts: 60,
Wait: 5 * time.Second,
Log: logf,
}
}
func boot(logf func(string)) error {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
defer cancel()
_, err := workerboot.Boot(ctx, config(logf))
return err
}
// ready runs until init stops it; the marker is removed on the way out.
func ready(logf func(string)) error {
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
defer cancel()
// The store is one guest, so its streams and buckets are R1.
if err := clustersize.Declare(1); err != nil {
return err
}
err := workerboot.Watch(ctx, workerboot.ReadyConfig{
Config: config(logf),
Checker: workerboot.NATSChecker{},
CallTimeout: 30 * time.Second,
Refresh: 30 * time.Second,
Refused: 5 * time.Minute,
Serve: &workerboot.ServeConfig{
Connector: workerboot.NATSConnector{},
Run: func(ctx context.Context, region, gatewayURL string, js jetstream.JetStream, receiptKey []byte) error {
c, err := newClient(region, gatewayURL)
if err != nil {
return err
}
return workerserve.Run(ctx, c, js, receiptKey, logf)
},
},
})
if errors.Is(err, context.Canceled) {
return nil
}
return err
}
+13 -3
View File
@@ -10,7 +10,7 @@ quota state.
a request/reply route on which a gateway, in the `awsgw-quota-seal` queue
group, irreversibly seals one account's quota override record.
`domains/admission/quota` owns it: `awsgw` serves it.
`accountteardown` is the consumer: it calls this subject to fence every
`operator/accountlifecycle` is the consumer: it calls this subject to fence every
override writer at the quota owner before the account record is removed.
The request names the account in its body (`account_id`) and may carry an
@@ -31,10 +31,20 @@ Any current gateway may answer; the leader-accepted revision is the evidence
whichever one does, and the override bucket is opened at seal time even on a
gateway whose quotas were disabled at startup.
## Seal-quota-override capability
`AccountQuotaOverrideSealCapabilitySubject(node)` (`admission.v1.AccountQuotaOverrideSealCapability.<node>`) is a per-node, read-only request/reply route on which `operator/accountlifecycle` asks one configured gateway whether it supports the quota override seal, immediately before every seal attempt.
The quota owner (`domains/admission/quota`) owns it: `awsgw` serves it with no queue group, so a request addressed to one node reaches only subscribers configured as that node, and two gateways sharing a node ID both answer.
The consumer's quota seal reaper probes every configured node that runs `awsgw` and sends no seal request unless every one answers compatible.
`AccountQuotaOverrideSealCapabilityVersion` is a claim about the whole binary's writers, not about the responder: version 1 means every quota override writer in the binary is revision-guarded and refuses a sealed record, and the binary serves the seal subject.
A gateway binary without this responder has no subscriber, so the probe for that node reads as incomplete (`no-reply`), the same as a stopped or partitioned node.
The reply proves nothing beyond the moment it was sent: a gateway downgraded or replaced after a successful probe is outside the guarantee.
### Compatibility boundary
The subject name carries the contract major; `account_quota_seal_test.go`
preserves the version-1 JSON shape.
The subject name carries the contract major; `account_quota_seal_test.go` and `account_quota_seal_capability_test.go`
preserve the version-1 JSON shapes and the subject builder.
Producer and consumer are in the coordinated Spinifex release, so the window
is N and N-1.
A gateway that predates the subject has no subscription, so the request gets
@@ -0,0 +1,39 @@
package admissionv1
import "strings"
// AccountQuotaOverrideSealCapabilityVersion is the version this binary reports.
// Version 1: every quota override writer in the binary is revision-guarded and
// refuses a sealed record, and the seal subject is served.
const AccountQuotaOverrideSealCapabilityVersion = 1
// AccountQuotaOverrideSealCapabilitySubject is one gateway's read-only
// seal-capability route, addressed per node so a probe reaches exactly the
// node it asks about.
func AccountQuotaOverrideSealCapabilitySubject(node string) string {
return "admission.v1.AccountQuotaOverrideSealCapability." + node
}
// ValidSealCapabilityNode reports whether node is exactly one NATS subject
// token. Responder and probe both check it before building a subject, so
// neither can address a wildcard or another node's route.
func ValidSealCapabilityNode(node string) bool {
return node != "" && !strings.ContainsAny(node, ".*>\t\n\r ")
}
// AccountQuotaOverrideSealCapabilityRequest asks node whether it supports the
// quota override seal. CorrelationRef is an opaque caller reference echoed for
// logs; it grants nothing and is never a fence or authority to write.
type AccountQuotaOverrideSealCapabilityRequest struct {
Node string `json:"node"`
CorrelationRef string `json:"correlation_ref,omitempty"`
}
// AccountQuotaOverrideSealCapabilityReply reports the responding node's
// capability version. It carries no account data and proves nothing beyond
// the moment it was sent.
type AccountQuotaOverrideSealCapabilityReply struct {
Node string `json:"node"`
CapabilityVersion int `json:"capability_version"`
CorrelationRef string `json:"correlation_ref,omitempty"`
}
@@ -0,0 +1,43 @@
package admissionv1
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/require"
)
func TestAccountQuotaOverrideSealCapabilitySubject(t *testing.T) {
require.Equal(t, "admission.v1.AccountQuotaOverrideSealCapability.node-1", AccountQuotaOverrideSealCapabilitySubject("node-1"))
require.Equal(t, "admission.v1.AccountQuotaOverrideSealCapability.node-2", AccountQuotaOverrideSealCapabilitySubject("node-2"))
}
func TestAccountQuotaOverrideSealCapabilityRequestJSONContract(t *testing.T) {
data, err := json.Marshal(AccountQuotaOverrideSealCapabilityRequest{Node: "node-1", CorrelationRef: "req-1"})
require.NoError(t, err)
require.JSONEq(t, `{"node":"node-1","correlation_ref":"req-1"}`, string(data))
data, err = json.Marshal(AccountQuotaOverrideSealCapabilityRequest{Node: "node-1"})
require.NoError(t, err)
require.JSONEq(t, `{"node":"node-1"}`, string(data))
}
func TestAccountQuotaOverrideSealCapabilityReplyJSONContract(t *testing.T) {
reply := AccountQuotaOverrideSealCapabilityReply{Node: "node-1", CapabilityVersion: 1, CorrelationRef: "req-1"}
data, err := json.Marshal(reply)
require.NoError(t, err)
require.JSONEq(t, `{"node":"node-1","capability_version":1,"correlation_ref":"req-1"}`, string(data))
data, err = json.Marshal(AccountQuotaOverrideSealCapabilityReply{Node: "node-1", CapabilityVersion: 1})
require.NoError(t, err)
require.JSONEq(t, `{"node":"node-1","capability_version":1}`, string(data))
}
func TestValidSealCapabilityNode(t *testing.T) {
for _, node := range []string{"node-1", "node_2", "n3"} {
require.True(t, ValidSealCapabilityNode(node), node)
}
for _, node := range []string{"", "a.b", "*", ">", "node-*", "node >", "node\t1", "node\n1"} {
require.False(t, ValidSealCapabilityNode(node), "%q", node)
}
}
+211
View File
@@ -0,0 +1,211 @@
package clusterv1
import "regexp"
const (
// NodeFactsSubject fans out a request for each daemon's hardware and
// cabling description. Its reply payload is NodeFacts.
NodeFactsSubject = "spinifex.node.facts"
nodeNetTestPrefix = "spinifex.node.nettest."
)
var nodeIDPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`)
// ValidNodeID reports whether a node name is safe to place in a subject: no
// NATS token separator or wildcard can get through.
func ValidNodeID(node string) bool {
return nodeIDPattern.MatchString(node)
}
// NetTestServeSubject asks one node to start a one-shot throughput server.
func NetTestServeSubject(node string) string {
return nodeNetTestPrefix + node + ".serve"
}
// NetTestRunSubject asks one node to run the client side of a throughput test.
func NetTestRunSubject(node string) string {
return nodeNetTestPrefix + node + ".run"
}
// Planes a node's NICs can carry, named as the installer names them.
const (
PlaneWAN = "wan"
PlaneLAN = "lan"
PlaneVPC = "vpc"
)
// NetTestPort is the TCP port reserved for node-to-node throughput tests.
const NetTestPort = 5001
// NodeFacts describes one node's hardware, OS and cabling. Fields a host
// cannot report are left empty, and Sources says why.
type NodeFacts struct {
Node string `json:"node"`
CollectedAt string `json:"collected_at,omitempty"`
FormFactor string `json:"form_factor"`
Virt string `json:"virt,omitempty"`
System SystemFacts `json:"system"`
OS OSFacts `json:"os"`
CPU CPUFacts `json:"cpu"`
Memory MemoryFacts `json:"memory"`
NICs []NICFacts `json:"nics"`
Planes []PlaneFacts `json:"planes"`
Sources map[string]string `json:"sources"`
}
// SystemFacts is the machine's identity from DMI.
type SystemFacts struct {
Vendor string `json:"vendor,omitempty"`
Product string `json:"product,omitempty"`
Family string `json:"family,omitempty"`
Version string `json:"version,omitempty"`
Serial string `json:"serial,omitempty"`
UUID string `json:"uuid,omitempty"`
ChassisType string `json:"chassis_type,omitempty"`
ChassisAsset string `json:"chassis_asset,omitempty"`
BoardVendor string `json:"board_vendor,omitempty"`
BoardProduct string `json:"board_product,omitempty"`
BoardSerial string `json:"board_serial,omitempty"`
BIOSVendor string `json:"bios_vendor,omitempty"`
BIOSVersion string `json:"bios_version,omitempty"`
BIOSDate string `json:"bios_date,omitempty"`
DeviceModel string `json:"device_model,omitempty"`
}
// OSFacts names the running OS and Spinifex build.
type OSFacts struct {
Name string `json:"name,omitempty"`
Kernel string `json:"kernel,omitempty"`
Arch string `json:"arch,omitempty"`
Version string `json:"spx_version,omitempty"`
Commit string `json:"spx_commit,omitempty"`
UptimeSeconds int64 `json:"uptime_seconds,omitempty"`
Hostname string `json:"hostname,omitempty"`
}
// CPUFacts summarises the sockets. Threads and Cores are totals. Features are
// the flags worth naming; Flags is every flag the CPU reports.
type CPUFacts struct {
Model string `json:"model,omitempty"`
Vendor string `json:"vendor,omitempty"`
Sockets int `json:"sockets"`
CoresPerSocket int `json:"cores_per_socket,omitempty"`
Cores int `json:"cores"`
Threads int `json:"threads"`
MaxMHz int `json:"max_mhz,omitempty"`
CurrentMHz int `json:"current_mhz,omitempty"`
Virtualization string `json:"virtualization,omitempty"`
Features []string `json:"features,omitempty"`
Flags []string `json:"flags,omitempty"`
}
// MemoryFacts groups identical DIMMs so a 32-slot board reads as one line.
type MemoryFacts struct {
TotalBytes uint64 `json:"total_bytes"`
SlotsUsed int `json:"slots_used"`
SlotsTotal int `json:"slots_total"`
MaxCapacity string `json:"max_capacity,omitempty"`
ECC string `json:"ecc,omitempty"`
Groups []MemoryGroup `json:"groups"`
OSTotalBytes uint64 `json:"os_total_bytes,omitempty"`
OSAvailBytes uint64 `json:"os_available_bytes,omitempty"`
HugePageBytes uint64 `json:"huge_page_bytes,omitempty"`
}
// MemoryGroup is a set of DIMMs alike in every field shown. ConfiguredMTs
// below SpeedMTs means the board runs them slower than rated.
type MemoryGroup struct {
Count int `json:"count"`
SizeBytes uint64 `json:"size_bytes"`
Type string `json:"type,omitempty"`
TypeDetail string `json:"type_detail,omitempty"`
FormFactor string `json:"form_factor,omitempty"`
SpeedMTs int `json:"speed_mts,omitempty"`
ConfiguredMTs int `json:"configured_mts,omitempty"`
Manufacturer string `json:"manufacturer,omitempty"`
PartNumber string `json:"part_number,omitempty"`
Rank string `json:"rank,omitempty"`
Serials []string `json:"serials,omitempty"`
}
// NICFacts is one physical network interface and, where LLDP reports it, the
// switch port it is cabled to.
type NICFacts struct {
Name string `json:"name"`
MAC string `json:"mac,omitempty"`
Driver string `json:"driver,omitempty"`
Firmware string `json:"firmware,omitempty"`
BusInfo string `json:"bus_info,omitempty"`
Model string `json:"model,omitempty"`
SpeedMbps int `json:"speed_mbps,omitempty"`
MaxMbps int `json:"max_mbps,omitempty"`
Duplex string `json:"duplex,omitempty"`
Port string `json:"port,omitempty"`
MTU int `json:"mtu,omitempty"`
State string `json:"state"`
Master string `json:"master,omitempty"`
Planes []string `json:"planes,omitempty"`
Neighbor *LLDPNeighbor `json:"neighbor,omitempty"`
}
// LLDPNeighbor is what the switch on the other end of a NIC advertises.
type LLDPNeighbor struct {
ChassisID string `json:"chassis_id"`
SystemName string `json:"system_name,omitempty"`
SystemDescription string `json:"system_description,omitempty"`
PortID string `json:"port_id,omitempty"`
PortDescription string `json:"port_description,omitempty"`
Capabilities []string `json:"capabilities,omitempty"`
}
// PlaneFacts is the address a plane uses on this node and the NICs under it.
type PlaneFacts struct {
Plane string `json:"plane"`
Bridge string `json:"bridge,omitempty"`
Address string `json:"address,omitempty"`
Prefix int `json:"prefix,omitempty"`
NICs []string `json:"nics,omitempty"`
MTU int `json:"mtu,omitempty"`
}
// NetTestServeRequest starts a server bound to the node's address on Plane.
type NetTestServeRequest struct {
Plane string `json:"plane"`
DurationSeconds int `json:"duration_seconds"`
}
// NetTestServeResponse names the address the server listens on.
type NetTestServeResponse struct {
Address string `json:"address,omitempty"`
Port int `json:"port,omitempty"`
Error string `json:"error,omitempty"`
}
// NetTestRunRequest runs the client towards Address from the node's own
// address on Plane.
type NetTestRunRequest struct {
Plane string `json:"plane"`
Address string `json:"address"`
Port int `json:"port"`
DurationSeconds int `json:"duration_seconds"`
Streams int `json:"streams"`
}
// NetTestRunResponse is one direction of a pair's measurement.
type NetTestRunResponse struct {
SourceAddress string `json:"source_address,omitempty"`
BitsPerSecond float64 `json:"bits_per_second"`
Bytes int64 `json:"bytes"`
Seconds float64 `json:"seconds"`
Streams int `json:"streams"`
StreamBPS []float64 `json:"stream_bps,omitempty"`
RTTMinMillis float64 `json:"rtt_min_ms,omitempty"`
RTTAvgMillis float64 `json:"rtt_avg_ms,omitempty"`
RTTMaxMillis float64 `json:"rtt_max_ms,omitempty"`
PacketLoss float64 `json:"packet_loss_percent,omitempty"`
LinkMbps int `json:"link_mbps,omitempty"`
MTU int `json:"mtu,omitempty"`
Tool string `json:"tool,omitempty"`
Error string `json:"error,omitempty"`
}
+29
View File
@@ -0,0 +1,29 @@
package clusterv1
import "time"
// ClusterStateBucket is the KV bucket holding each daemon's heartbeat alongside
// other cluster-wide state.
const ClusterStateBucket = "spinifex-cluster-state"
// HeartbeatKey is the ClusterStateBucket key a node writes its heartbeat under.
func HeartbeatKey(node string) string {
return "heartbeat." + node
}
// HeartbeatTimestamp is the read side of a heartbeat for node liveness. It
// decodes only the timestamp, so a reader does not depend on the capacity
// figures the daemon writes beside it.
type HeartbeatTimestamp struct {
Timestamp string `json:"timestamp"`
}
// Time returns when the heartbeat was written. A timestamp that is missing or
// unparseable vouches for nothing, so ok is false.
func (h HeartbeatTimestamp) Time() (written time.Time, ok bool) {
parsed, err := time.Parse(time.RFC3339, h.Timestamp)
if err != nil {
return time.Time{}, false
}
return parsed.UTC(), true
}
+35
View File
@@ -0,0 +1,35 @@
package clusterv1_test
import (
"encoding/json"
"testing"
"time"
clusterv1 "github.com/mulgadc/spinifex/contracts/cluster/v1"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// The bucket and key are the deployed layout: a rolling upgrade has old daemons
// writing and new readers reading, so these strings cannot change.
func TestHeartbeatKey_IsTheDeployedLayout(t *testing.T) {
assert.Equal(t, "spinifex-cluster-state", clusterv1.ClusterStateBucket)
assert.Equal(t, "heartbeat.node-a", clusterv1.HeartbeatKey("node-a"))
}
func TestHeartbeatTimestamp_ReadsAFullDaemonHeartbeat(t *testing.T) {
full := `{"node":"node-a","epoch":3,"timestamp":"2026-10-30T01:02:03Z","vm_count":4,"allocated_vcpu":8}`
var h clusterv1.HeartbeatTimestamp
require.NoError(t, json.Unmarshal([]byte(full), &h))
written, ok := h.Time()
require.True(t, ok)
assert.True(t, written.Equal(time.Date(2026, 10, 30, 1, 2, 3, 0, time.UTC)))
}
func TestHeartbeatTimestamp_UndatedBeatVouchesForNothing(t *testing.T) {
for _, ts := range []string{"", "yesterday"} {
_, ok := clusterv1.HeartbeatTimestamp{Timestamp: ts}.Time()
assert.False(t, ok, "timestamp %q", ts)
}
}
+7
View File
@@ -0,0 +1,7 @@
package ec2v1
// VolumeHeldElsewhereText is the text of a start refused because another node
// holds one of the instance's volumes. It crosses NATS as message text, so
// readers match on it; the provider that refuses and every reader share this
// string so they cannot drift.
const VolumeHeldElsewhereText = "volume is leased by another owner"
+5
View File
@@ -8,3 +8,8 @@ administration. It is separate from AWS tenant-facing service contracts.
roles and Reed-Solomon configuration. It is an operational status contract,
not a generic provider abstraction; do not change its subject or JSON shape
incompatibly. Add a new version when that is required.
`admin.go` holds the `/admin/<Method>` request and response bodies the private admin surface shares with the CLI.
`AdminMethodNames` is the list of callable methods, and the gateway router builds its accept set from it.
`AccountQuotaRequest`, `AccountQuotaResponse` and `DeleteAccountResponse` stay in the gateway until the `/admin` handlers move, because they embed types the contracts layer may not import.
Do not change a field name, JSON tag or omitempty incompatibly; add a new version when that is required.
+76
View File
@@ -0,0 +1,76 @@
package operatorv1
import "slices"
// adminMethodNames is the callable /admin/<Method> list. The gateway router
// builds its accept set from it, so a name cannot be served without being listed.
var adminMethodNames = []string{
"CreateAccount",
"DeleteAccount",
"DescribeAccountDeletion",
"GetAccountQuota",
"ListAccounts",
"PutAccountQuota",
}
// AdminMethodNames returns the callable /admin/<Method> names in sorted order.
// Tooling that mints a credential grants these by name, so the list has to be
// the one the router serves rather than a copy that can drift out of step.
func AdminMethodNames() []string {
return slices.Sorted(slices.Values(adminMethodNames))
}
// Deletion job states.
const (
DeletionStateRunning = "RUNNING"
DeletionStateCompleted = "COMPLETED"
DeletionStateFailed = "FAILED"
)
// CreateAccountRequest is the /admin/CreateAccount request body.
type CreateAccountRequest struct {
Name string `json:"name"`
ClientToken string `json:"clientToken"`
Source string `json:"source,omitempty"`
}
// CreateAccountResponse is the /admin/CreateAccount success body. It is stored
// verbatim (with the secret encrypted) so a replay returns an identical answer.
type CreateAccountResponse struct {
AccountID string `json:"accountId"`
AccountName string `json:"accountName"`
AdminUser string `json:"adminUser"`
AccessKeyID string `json:"accessKeyId"`
SecretAccessKey string `json:"secretAccessKey"`
DefaultVpcID string `json:"defaultVpcId"`
}
// DeleteAccountRequest is the /admin/DeleteAccount request body.
type DeleteAccountRequest struct {
AccountID string `json:"accountId"`
AccountName string `json:"accountName"`
ClientToken string `json:"clientToken"`
Force bool `json:"force,omitempty"`
DryRun bool `json:"dryRun,omitempty"`
}
// DescribeAccountDeletionRequest is the /admin/DescribeAccountDeletion body.
type DescribeAccountDeletionRequest struct {
AccountID string `json:"accountId"`
}
// AccountSummary is one row of /admin/ListAccounts. It carries no credential
// material: the caller can already create and delete accounts, but that is no
// reason for a listing to hand out keys.
type AccountSummary struct {
AccountID string `json:"accountId"`
AccountName string `json:"accountName"`
Status string `json:"status"`
CreatedAt string `json:"createdAt,omitempty"`
}
// ListAccountsResponse is the /admin/ListAccounts success body.
type ListAccountsResponse struct {
Accounts []AccountSummary `json:"accounts"`
Count int `json:"count"`
}
+65
View File
@@ -0,0 +1,65 @@
package operatorv1
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/require"
)
func goldenJSON(t *testing.T, v any, want string) {
t.Helper()
data, err := json.Marshal(v)
require.NoError(t, err)
require.JSONEq(t, want, string(data))
}
func TestAdminWire_CreateAccountRequest(t *testing.T) {
goldenJSON(t, CreateAccountRequest{Name: "acme", ClientToken: "tok-1", Source: "cli"},
`{"name":"acme","clientToken":"tok-1","source":"cli"}`)
goldenJSON(t, CreateAccountRequest{Name: "acme", ClientToken: "tok-1"},
`{"name":"acme","clientToken":"tok-1"}`)
}
func TestAdminWire_CreateAccountResponse(t *testing.T) {
goldenJSON(t, CreateAccountResponse{
AccountID: "000000000042", AccountName: "acme", AdminUser: "admin",
AccessKeyID: "AKIAEXAMPLE", SecretAccessKey: "secret", DefaultVpcID: "vpc-1",
}, `{"accountId":"000000000042","accountName":"acme","adminUser":"admin","accessKeyId":"AKIAEXAMPLE","secretAccessKey":"secret","defaultVpcId":"vpc-1"}`)
}
func TestAdminWire_DeleteAccountRequest(t *testing.T) {
goldenJSON(t, DeleteAccountRequest{AccountID: "000000000042", AccountName: "acme", ClientToken: "tok-2", Force: true, DryRun: true},
`{"accountId":"000000000042","accountName":"acme","clientToken":"tok-2","force":true,"dryRun":true}`)
goldenJSON(t, DeleteAccountRequest{AccountID: "000000000042", AccountName: "acme", ClientToken: "tok-2"},
`{"accountId":"000000000042","accountName":"acme","clientToken":"tok-2"}`)
}
func TestAdminWire_DescribeAccountDeletionRequest(t *testing.T) {
goldenJSON(t, DescribeAccountDeletionRequest{AccountID: "000000000042"}, `{"accountId":"000000000042"}`)
}
func TestAdminWire_DeletionStates(t *testing.T) {
require.Equal(t, "RUNNING", DeletionStateRunning)
require.Equal(t, "COMPLETED", DeletionStateCompleted)
require.Equal(t, "FAILED", DeletionStateFailed)
}
func TestAdminWire_ListAccountsResponse(t *testing.T) {
goldenJSON(t, ListAccountsResponse{
Accounts: []AccountSummary{
{AccountID: "000000000042", AccountName: "acme", Status: "ACTIVE", CreatedAt: "2026-01-02T03:04:05Z"},
{AccountID: "000000000043", AccountName: "bolt", Status: "TERMINATING"},
},
Count: 2,
}, `{"accounts":[
{"accountId":"000000000042","accountName":"acme","status":"ACTIVE","createdAt":"2026-01-02T03:04:05Z"},
{"accountId":"000000000043","accountName":"bolt","status":"TERMINATING"}],"count":2}`)
}
func TestAdminWire_AdminMethodNames(t *testing.T) {
require.Equal(t, []string{
"CreateAccount", "DeleteAccount", "DescribeAccountDeletion",
"GetAccountQuota", "ListAccounts", "PutAccountQuota",
}, AdminMethodNames())
}
+71
View File
@@ -0,0 +1,71 @@
package telemetryv1
import (
"regexp"
)
// LiveSamplePeriodMillis is how often a watched instance is sampled for the
// console's live view.
const LiveSamplePeriodMillis = 500
// LiveWatchLeaseMillis is how long one watch keeps the live sampler running.
// Watchers renew well inside it, so a viewer that goes away costs at most this.
const LiveWatchLeaseMillis = 30_000
// LiveRingSamples bounds the in-memory history a live sampler keeps for a
// reconnecting viewer: five minutes at LiveSamplePeriodMillis.
const LiveRingSamples = 5 * 60 * 1000 / LiveSamplePeriodMillis
// Live subjects sit outside metrics.> so nothing that ingests the periodic
// CloudWatch batches ever reads a sub-second rate as a period. The account is part
// of the subject, so only the poller for that account's instance answers.
const (
liveWatchSubjectPrefix = "telemetry.watch.ec2."
liveSampleSubjectPrefix = "telemetry.live.ec2."
)
var (
liveAccountPattern = regexp.MustCompile(`^[0-9]{12}$`)
liveInstancePattern = regexp.MustCompile(`^i-[0-9a-f]{8,17}$`)
)
// ValidLiveTarget reports whether accountID and instanceID are safe to place
// in a live subject: no NATS token separators or wildcards can get through.
func ValidLiveTarget(accountID, instanceID string) bool {
return liveAccountPattern.MatchString(accountID) && liveInstancePattern.MatchString(instanceID)
}
// LiveWatchSubject is where a viewer asks the owning node to keep sampling. A
// request on it is answered with the ring since LiveWatch.SinceMillis.
func LiveWatchSubject(accountID, instanceID string) string {
return liveWatchSubjectPrefix + accountID + "." + instanceID
}
// LiveSampleSubject carries one LiveSample per sampling period while watched.
func LiveSampleSubject(accountID, instanceID string) string {
return liveSampleSubjectPrefix + accountID + "." + instanceID
}
// LiveWatch is the body of a watch. SinceMillis is the newest sample the
// viewer already has; zero asks for the whole ring.
type LiveWatch struct {
SinceMillis int64 `json:"since_ms,omitempty"`
}
// LiveRing answers a watch request with the samples newer than SinceMillis.
type LiveRing struct {
Samples []LiveSample `json:"samples"`
}
// LiveSample is one live measurement. Every value is a rate over the interval
// ending at TimeMillis, measured against the previous sample's clock.
type LiveSample struct {
TimeMillis int64 `json:"t"`
CPUPercent float64 `json:"cpu"`
// NetworkIn and NetworkOut are bytes per second from the guest's side.
NetworkIn float64 `json:"netIn"`
NetworkOut float64 `json:"netOut"`
// PacketsIn and PacketsOut are packets per second from the guest's side.
PacketsIn float64 `json:"pktIn"`
PacketsOut float64 `json:"pktOut"`
}
+42
View File
@@ -0,0 +1,42 @@
package telemetryv1
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// The console parses these names, so a rename here breaks its live charts.
func TestLiveSampleJSONContract(t *testing.T) {
data, err := json.Marshal(LiveSample{
TimeMillis: 1793450123250, CPUPercent: 12.5,
NetworkIn: 48213, NetworkOut: 1290, PacketsIn: 41, PacketsOut: 12,
})
require.NoError(t, err)
require.JSONEq(t, `{"t":1793450123250,"cpu":12.5,"netIn":48213,"netOut":1290,"pktIn":41,"pktOut":12}`, string(data))
}
func TestValidLiveTarget(t *testing.T) {
assert.True(t, ValidLiveTarget("123456789012", "i-0123456789abcdef0"))
assert.True(t, ValidLiveTarget("123456789012", "i-0abc1234"))
for _, tc := range []struct{ account, instance string }{
{"", "i-0123456789abcdef0"},
{"12345678901", "i-0123456789abcdef0"},
{"123456789012", ""},
{"123456789012", ">"},
{"123456789012", "*"},
{"123456789012", "i-0123.abcdef"},
{"123456789012", "i-0123456789ABCDEF0"},
{"123456789012", "vol-0123456789abcdef0"},
} {
assert.False(t, ValidLiveTarget(tc.account, tc.instance), "%q %q", tc.account, tc.instance)
}
}
func TestLiveSubjectsAreOutsideMetrics(t *testing.T) {
assert.Equal(t, "telemetry.watch.ec2.123456789012.i-0abc1234", LiveWatchSubject("123456789012", "i-0abc1234"))
assert.Equal(t, "telemetry.live.ec2.123456789012.i-0abc1234", LiveSampleSubject("123456789012", "i-0abc1234"))
}
+53
View File
@@ -0,0 +1,53 @@
package telemetryv1
import "regexp"
// Node live subjects mirror the instance ones, keyed by node name. Only the
// super-admin account's gateway handler subscribes to them.
const (
nodeLiveWatchSubjectPrefix = "telemetry.watch.node."
nodeLiveSampleSubjectPrefix = "telemetry.live.node."
)
var liveNodePattern = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`)
// ValidLiveNode reports whether a node name is safe to place in a subject.
func ValidLiveNode(node string) bool {
return liveNodePattern.MatchString(node)
}
// NodeLiveWatchSubject is where a viewer asks a node to keep sampling itself.
// A request on it is answered with a NodeLiveRing.
func NodeLiveWatchSubject(node string) string {
return nodeLiveWatchSubjectPrefix + node
}
// NodeLiveSampleSubject carries one NodeLiveSample per period while watched.
func NodeLiveSampleSubject(node string) string {
return nodeLiveSampleSubjectPrefix + node
}
// NodeLiveRing answers a watch with the samples newer than its SinceMillis.
type NodeLiveRing struct {
Samples []NodeLiveSample `json:"samples"`
}
// NodeLiveSample is one host measurement. Rates are per second over the
// interval ending at TimeMillis.
type NodeLiveSample struct {
TimeMillis int64 `json:"t"`
CPUPercent float64 `json:"cpu"`
// MemoryUsedPercent is used memory less page cache, as free(1) reports it.
MemoryUsedPercent float64 `json:"mem"`
Load1 float64 `json:"load1"`
// Planes holds the traffic of each plane's physical NICs, keyed wan, lan, vpc.
Planes map[string]NodeLivePlane `json:"planes"`
}
// NodeLivePlane is one plane's traffic from the host's side.
type NodeLivePlane struct {
NetworkIn float64 `json:"netIn"`
NetworkOut float64 `json:"netOut"`
PacketsIn float64 `json:"pktIn"`
PacketsOut float64 `json:"pktOut"`
}
@@ -60,6 +60,10 @@ moved to another node; the local daemon subscribes to stop the guest that
was using it. Node-addressed, like mount and unmount: the guest is local, and
no other node's daemon has anything to stop.
The event is sent as a request and repeated until the daemon replies with `VolumeFencedAck`, because the fence often runs while the node's NATS server is restarting and a single publish can reach no subscriber.
The daemon replies only when the event carries a reply subject, so a plain publish is still accepted.
Repeats are harmless, since fencing a guest that is already fenced changes nothing.
## Abandon route
`VolumeAbandonSubject(node)` is `ebs.<node>.abandon`. The daemon uses
+8
View File
@@ -19,3 +19,11 @@ type VolumeFencedEvent struct {
Winner string `json:"winner"`
Reason string `json:"reason"`
}
// VolumeFencedAck is the daemon's answer to a VolumeFencedEvent. Any answer
// ends the sender's retries; Instance names the guest stopped, if one was, and
// Error says why the daemon could not act on the event.
type VolumeFencedAck struct {
Instance string `json:"instance,omitempty"`
Error string `json:"error,omitempty"`
}
@@ -37,3 +37,18 @@ func TestVolumeFencedEvent_JSON(t *testing.T) {
require.NoError(t, json.Unmarshal([]byte(golden), &decoded))
assert.Equal(t, event, decoded)
}
// TestVolumeFencedAck_JSON pins the daemon's answer, which is what ends
// viperblockd's retries of a fence notice.
func TestVolumeFencedAck_JSON(t *testing.T) {
golden := `{"instance":"i-1","error":"bad event"}`
ack := viperblocklegacyv1.VolumeFencedAck{Instance: "i-1", Error: "bad event"}
marshaled, err := json.Marshal(ack)
require.NoError(t, err)
assert.JSONEq(t, golden, string(marshaled))
empty, err := json.Marshal(viperblocklegacyv1.VolumeFencedAck{})
require.NoError(t, err)
assert.JSONEq(t, `{}`, string(empty), "an ack for a volume with no local guest carries nothing")
}
+32 -3
View File
@@ -26,7 +26,7 @@ Every service is `PartOf=spinifex.target` and ordered by its own `After=`/`Wants
| `spinifex-qmp-collector.service` | `spx service qmp-collector start` | Reads the `contracts/telemetry/v1` per-VM sidecar, polls telemetry QMP sockets and tap counters, publishes to NATS `metrics.ec2.*` |
| `spinifex-shutdown.service` | `/bin/true`; the work is its `ExecStop` | Runs `spx admin node drain --local --unless-restarting`. `After=` every service, so it stops first and drains guests while the stack is still up |
| `spinifex-nats-watchdog.service` / `.timer` | `nats-js-watchdog.sh` | JetStream liveness probe every 30s. `try-restart`s NATS when JetStream stops accepting writes while the process stays alive — what `Restart=on-failure` cannot catch |
| `spinifex-firewall.service` | `spinifex-firewall-apply` | Oneshot nftables input policy, `WantedBy=sysinit.target` rather than the target. No `ExecStop`: remove it with `nft delete table inet spinifex_filter` |
| `spinifex-firewall.service` | `spinifex-firewall-apply boot` | Oneshot nftables input policy, `WantedBy=sysinit.target` rather than the target. Loads the `inet spinifex_conntrack` tracking table on every node, and the policy only once the daemon has written a peer file. No `ExecStop`: remove the policy with `spinifex-firewall-apply disable` |
| `regenerate-ssh-host-keys.service` | `ssh-keygen -A` | Oneshot for AMI-built nodes, conditional on the host key being absent |
#### Operations
@@ -136,7 +136,7 @@ Operational commands for inspecting cluster state. These fan out NATS requests t
| Command | Flags | Description |
|---------|-------|-------------|
| `spx admin volumes orphans` | — | Lists volumes the storage provider holds that the control plane has no record of. Their blocks consume space but they have no API handle, so they can be neither described nor deleted through the EC2 API. **Reports only, never deletes**: the evidence that an orphan is still wanted is the record that went missing, so removal is an operator decision taken per volume. |
| `spx admin volumes unsealed` | — | Lists volumes a node has open, or had open, without a confirmed seal to the backend — the node named holds writes predastore may not have. Starting such a volume elsewhere is allowed, and is preferred over leaving the instance unable to run, but it opens from the last checkpoint that did reach the backend. This is the list of volumes where that would cost something. |
| `spx admin volumes unsealed` | — | Lists volumes a node has open, or had open, without a confirmed seal to the backend — the node named holds writes predastore may not have. Starting such a volume elsewhere is refused while that node is alive (its daemon heartbeat is under 60 seconds old, or cannot be read) and is otherwise allowed, in which case it opens from the last checkpoint that did reach the backend. This is the list of volumes where that would cost something. |
| `spx admin storage status` | `--endpoint` (default: this node's AWS gateway), `--region` (default: this node's region), `--ca-bundle` (default: this node's CA) | Sends the signed `GetStorageStatus` action to the AWS gateway with credentials from the standard AWS chain (e.g. `AWS_PROFILE=spinifex`) and prints what the console's Service Metrics page shows: predastore encoding, meta and blob node counts, cluster health, a meta node table (health, raft state, leader, term, commit and applied index) and the blob nodes. Cluster health is `Degraded` when any meta node is unreachable or the nodes reporting raft state do not agree on one leader. A meta node read only through its host's `/readyz` shows healthy with blank raft fields. |
### Upgrade Management
@@ -155,7 +155,7 @@ Operational commands for inspecting cluster state. These fan out NATS requests t
| `spx admin account list --remote` | `--endpoint` (default: this node's AWS gateway), `--region` (default: this node's region), `--ca-bundle` (default: this node's CA) | Same listing read through `POST /admin/ListAccounts` over SigV4 instead of NATS, so an off-cluster operator or the load-test harness sees its tenants without SSH access to a node. Credentials come from the standard AWS chain; the caller must be an IAM user in the super-admin account holding `spinifex:ListAccounts`. |
| `spx admin account quota get <account-id>` | `--endpoint` (default: this node's AWS gateway), `--region` (default: this node's region), `--ca-bundle` (default: this node's CA) | Reads the limits in force for one account through `POST /admin/GetAccountQuota` over SigV4, and reports for each dimension whether it came from the `[quota]` block in `awsgw.toml` (`config`) or from an override stored against the account (`override`). Credentials come from the standard AWS chain; the caller must be an IAM user in the super-admin account holding `spinifex:GetAccountQuota`. |
| `spx admin account quota set <account-id>` | `--vcpus`, `--vpcs`, `--subnets`, `--eips`, `--volumes`, `--volumes-gib`, `--rds-instances`, `--load-balancers`, `--clear`, plus `--endpoint`, `--region`, `--ca-bundle` | Overrides individual limits for one account through `POST /admin/PutAccountQuota`, leaving every dimension not named inheriting the configured baseline. `-1` uncaps a dimension; `0` is a real limit that denies every request on it. `--clear` removes the whole override record and cannot be combined with a limit flag. The account must already exist. Prints the resulting limits, so a caller never has to guess what it applied. The caller must hold `spinifex:PutAccountQuota`. |
| `spx admin account delete <account-id>` | `--dry-run`, `--force`, `--yes` (requires `--name`), `--name` (account name, must match the stored record) | Prints the inventory it is about to remove → confirms the account name (prompted unless `--name` is given) → marks the account `TERMINATING`, which blocks its credentials cluster-wide → deletes its resources in dependency order (compute, attachments, storage, network, platform, identity, account), waiting for each stage to actually drain before starting the next → releases the name reservation and quota counter → deletes the account record. Anything that will not delete is reported as **stuck** with the reason, and the account stays `TERMINATING` so the residue keeps an owner. `000000000000` and `000000000001` are refused. `--dry-run` lists and changes nothing. The compute stage reaps ECS clusters, EKS nodegroups and clusters, and RDS instances before it reaps plain instances, because their backing VMs belong to the system account and a tenant-scoped instance listing cannot see them. The storage stage reaps the account's S3 buckets: uploads in flight are aborted, then the objects are removed a page at a time, then the bucket. Only buckets the account owns are ever listed, so the shared platform buckets are out of reach rather than filtered out. Platform-managed serving endpoints are reaped ahead of instances, because only their own delete releases the ENI and attached data volume alongside the VM, and spot instance requests are cancelled first so a live request cannot launch a replacement for something already terminated. The network stage reaps load balancers, then target groups, then NAT gateways and egress-only internet gateways before the subnets, security groups and addresses they hold. The platform stage reaps ACM certificates. |
| `spx admin account delete <account-id>` | `--dry-run`, `--force`, `--yes` (requires `--name`), `--name` (account name, must match the stored record) | Prints the inventory it is about to remove → confirms the account name (prompted unless `--name` is given) → marks the account `TERMINATING`, which blocks its credentials cluster-wide → deletes its resources in dependency order (compute, attachments, storage, network, platform, identity, account), waiting for each stage to actually drain before starting the next → seals the account's quota override record so no later write can recreate it, which needs every gateway node the cluster configures to answer a read-only probe first (otherwise nothing is sealed and the account stays `TERMINATING`) → releases the name reservation and quota counter → deletes the account record. Anything that will not delete is reported as **stuck** with the reason, and the account stays `TERMINATING` so the residue keeps an owner. `000000000000` and `000000000001` are refused. `--dry-run` lists and changes nothing. The compute stage reaps ECS clusters, EKS nodegroups and clusters, and RDS instances before it reaps plain instances, because their backing VMs belong to the system account and a tenant-scoped instance listing cannot see them. The storage stage reaps the account's S3 buckets: uploads in flight are aborted, then the objects are removed a page at a time, then the bucket. Only buckets the account owns are ever listed, so the shared platform buckets are out of reach rather than filtered out. Platform-managed serving endpoints are reaped ahead of instances, because only their own delete releases the ENI and attached data volume alongside the VM, and spot instance requests are cancelled first so a live request cannot launch a replacement for something already terminated. The network stage reaps load balancers, then target groups, then NAT gateways and egress-only internet gateways before the subnets, security groups and addresses they hold. The platform stage reaps ACM certificates. |
| `spx admin account delete --force` | as above | Escalates only for resources the ordinary API refuses: it clears a volume attachment in the control plane without the guest's cooperation (`ec2.ForceDetachVolume`), hard-destroys after a graceful attempt times out, and treats an already-missing resource as deleted. It never reorders the stages and never widens scope beyond the account. Use it once the guest is being destroyed — this is the escape from a volume stuck attached to an instance that will not terminate, where neither can otherwise be deleted. |
| `spx admin account delete --remote` | as above, plus `--endpoint`, `--region`, `--ca-bundle`, `--client-token` | Same teardown driven through `POST /admin/DeleteAccount` over SigV4 instead of NATS, then follows `DescribeAccountDeletion` to completion, printing each stage as it lands. Credentials come from the standard AWS chain; the caller must be an IAM user in the super-admin account holding `spinifex:DeleteAccount` and `spinifex:DescribeAccountDeletion`. |
| `spx admin principal create <name>` | `--grant` (repeatable or comma-separated; default: every admin method — `CreateAccount`, `DeleteAccount`, `DescribeAccountDeletion`, `GetAccountQuota`, `ListAccounts`, `PutAccountQuota`) | Creates an IAM user in the super-admin account with an inline `spx-admin-methods` policy allowing the requested methods, revokes any access key left from a previous run, and mints one new key. Each method is named rather than granted as `spinifex:*`, so a later addition to the admin surface is not authorised by an existing key. The principal's grants are exactly that one policy: re-running with fewer `--grant` values narrows it, and any other inline policy on the user is removed, so a rename cannot leave a wider grant behind. The secret is printed once; store it in a secret manager or an AWS profile, never in a config file. `admin` is refused as a name — it is the account's own administrator and rotating its key would revoke the bootstrap credential. |
@@ -192,6 +192,35 @@ Operational commands for inspecting cluster state. These fan out NATS requests t
|---------|-------|-------------|
| `spx admin eks restore-snapshot` | `--cluster` (required), `--snapshot` (optional, defaults to the latest snapshot in predastore), `--account` (optional, defaults to the bootstrap account) | Single-CP total-loss DR path (fail-safe): validates the snapshot exists in predastore BEFORE any mutation (a typo'd/missing key hard-fails, never resets into an empty datastore) → launches a fresh control-plane VM as a cluster-init seed (replaying the persisted create-time launch template) → sets a required-snapshot `RecoveryDirective` (`cluster-reset`) so the boot-time recovery agent aborts rather than resets-into-empty if it cannot fetch the snapshot → persists the replacement in cluster meta BEFORE re-pointing the NLB (so an NLB failure is convergeable by the reconciler, returned as a provisional status, not a hard error) → re-points the cluster NLB's apiserver and konnectivity target groups from the old CP's ENI to the new one → fences the old CP with retries, failing loudly if it cannot be confirmed terminated (split-brain guard). Any failure before the meta commit unwinds the fresh CP (terminate + clear directive) so a re-run does not stack a second resetting control plane. The returned status is provisional — success means the sequence completed, not that etcd is restored and serving; verify cluster health. HA clusters (a spread with a potentially surviving quorum) are rejected — recover those via quorum reformation instead. |
### SQS System Instances
These commands enable SQS on a single-node cluster and start its one system VM.
A bound VM is not a ready service: `spx admin sqs status` reports readiness.
The SQS VM runs two processes on one encrypted data volume: the Region's dedicated SQS NATS server, which listens only on the guest's loopback address and is separate from platform NATS, and the worker, which serves SQS owner calls from that local store. The worker starts serving only after the local store answers and the receipt-signing key on the volume matches the fingerprint the platform recorded.
SQS supports only a fresh single-node install. A worker record without a supported launch contract, or any record of the separate SQS store VM from an earlier dev build, cannot be upgraded: `enable` refuses it, and any existing volume is kept, not inspected, emptied or replaced. An existing separate store VM is refused on relaunch with its volume kept, and an existing worker VM is not restarted into a supported mode; upgrading such an install is unsupported.
**Do not put tenant production data in SQS.** SQS-managed per-message encryption is not implemented: `GetQueueAttributes` reports `SqsManagedSseEnabled=false`, and a request to enable SSE-SQS or SSE-KMS is rejected.
The SQS data volume must be encrypted, which is a separate layer under every queue and not SSE.
There is no `DeleteQueue` or `PurgeQueue`, so a queue and its unexpired messages cannot be removed once created.
Retention is checked when a message is received, against the queue's retention at that moment, and nothing deletes an expired message: the store drops a body only at its age backstop of 14 days and 1 hour.
So a message past a short retention can be delivered again if `MessageRetentionPeriod` is later raised while its body is still stored.
Account termination does not yet remove SQS queues or messages, and there is no disable: the VM's volume, receipt key and data are kept.
Each sends a signed `spinifex` admin action (`EnableSQS`, `DescribeSQS`, `EnsureSQSWorker`) to the AWS gateway using the standard AWS credential chain, for example `AWS_PROFILE=spinifex`. The caller must be in the super-admin account (`000000000001`) and hold `spinifex:<Action>`. The VM, volume, private VPC and ENI belong to the system account (`000000000000`). `--endpoint`, `--region` and `--ca-bundle` default to this node's gateway, region and `config/ca.pem`; off a node, `--endpoint` and `--region` are required. `ensure-worker` and `enable` wait up to 11 minutes, and `status` up to 1 minute.
| Command | Flags | Description |
|---------|-------|-------------|
| `spx admin sqs enable` | `--node` (required), `--instance-type` (required, more than 512 MiB memory), `--volume-size-gib` (required, 1–16384), `--endpoint`, `--region`, `--ca-bundle` | Records SQS as enabled for the Region, pinned to `--node`, then launches or reports the SQS VM there. The desired state is recorded first, so rerunning the command resumes an interrupted enable. More than one node in the cluster is refused before anything is recorded. A worker record with a missing or unsupported launch contract, or a record of the separate store VM, is also refused before anything is recorded or launched; any existing volume is kept, and upgrading it is not supported. |
| `spx admin sqs status` | `--endpoint`, `--region`, `--ca-bundle` | Shows the requested state, node, generation, VM state, receipt key state, whether a worker is polling this gateway, whether this gateway serves the SQS API, and `Ready`, which is true only when all of them hold. `Unsupported` names a record whose missing or unsupported launch contract blocks enable. |
| `spx admin sqs ensure-worker` | `--node` (required), `--instance-type` (required, more than 512 MiB memory), `--volume-size-gib` (required, 1–16384), `--endpoint`, `--region`, `--ca-bundle` | Launches the Region's one SQS VM on `--node`, or reports the VM already bound there: node, region, instance, instance type and generation. The VM is pinned to its first node; another node is refused with `InvalidParameterValue`, and there is no takeover. It gets a private system VPC with one /24 subnet and no internet gateway, NAT or Elastic IP, and an encrypted system-account data volume of `--volume-size-gib` that holds the queue data and the receipt-signing key the guest creates on first boot. The platform records only the key's fingerprint. A different `--instance-type` is accepted only while no volume, network interface or launch step has been attempted; once any has, it is `IncorrectState`, whether or not the step completed. A different `--volume-size-gib` than the recorded one is `IncorrectState`. |
`ServiceUnavailable` never proves that no SQS resources exist, because an earlier attempt may already have created some. Its message says what it does establish. When no node serves the subject, this request was not delivered. When a volume create was not sent, that particular create did not happen. When the node cannot boot the guest yet or its launcher lacks a capability, this request stopped before allocating or recording anything. When the gateway times out waiting for the node's reply, nothing is known: the launch may still be running, so never assume it created nothing. In every case, call the command again with the same arguments. A retry first checks the durable record of the attempt and continues only when that is safe; it can proceed to the first launch after a create that was proven unsent, and otherwise reports the recorded outcome as bound or `IncorrectState`, or returns `ServiceUnavailable` again. `IncorrectState` from a volume, network interface or launch step means that step was recorded as sent but its outcome was never observed. It is never resent, the message names the volume when its ID is known, and no tool reconciles it yet: recovery is manual.
`bound` says only that the VM was launched for the current record. The worker is ready only when the guest's local store answers, and it serves only while its receipt key matches the recorded fingerprint; a missing or different key once one is recorded refuses serving and is never replaced.
Real VPC routing, IMDS and gateway TLS from the guest have not been tested on a live host.
## Private Admin API
Not an AWS API: a Spinifex-internal surface on the AWS gateway, JSON in and JSON out, one method per path segment at `POST /admin/<Method>`. It exists so a self-service signup form, a provisioning system or a load-test harness can create and remove tenant accounts without an operator shell on a node.
+126 -53
View File
@@ -33,67 +33,32 @@ The gateway (`spinifex/runtime/roles/awsgw/awsgw.go`) is the entry point:
// Connect to NATS (retries while the local broker comes up)
natsConn, err := natsmsg.ConnectNATSWithRetry(...)
// Load IAM (master key + JetStream KV) and create the gateway
gw := gateway.GatewayConfig{
NATSConn: natsConn,
Config: nodeConfig.AWSGW.Config,
IAMService: iamService,
// Load IAM (master key + JetStream KV), then compose the gateway: composeGateway
// sets every production hook and wires the service registry from each domain's
// awsapi registration.
gw, err := composeGateway(gatewayInputs{
NATS: natsConn,
IAM: iamService,
Credentials: credentials,
Admission: admission,
// ...
}
}, ecrControlPlane, acmControlPlane, ec2ControlPlane /* ... */)
// Serve over TLS
server.ListenAndServeTLS("", "")
```
Request routing (`spinifex/gateway/gateway.go`):
Request routing (`spinifex/ingress/aws/pipeline/gateway.go`):
1. **Authentication**: SigV4 middleware validates AWS credentials and resolves the account ID
2. **Throttling**: Per-account+action token bucket rejects bursts post-auth
3. **Service Detection**: Extracts service name from the Authorization header
4. **Action Dispatch**: Routes to service-specific handler. A service registered through the ingress dispatch registry (`ingress/aws/dispatch`, wired by `runtime/roles/awsgw`) is dispatched by its registration; currently those are the ECR and ACM control planes, registered from `domains/ecr/awsapi` and `domains/acm/awsapi`. Every other service uses the legacy switch:
```go
switch svc {
case "ec2":
err = gw.EC2_Request(w, r)
case "iam":
err = gw.IAM_Request(w, r)
case "sts":
err = gw.STS_Request(w, r)
case "elasticloadbalancing":
err = gw.ELBv2_Request(w, r)
case "eks":
err = gw.EKS_Request(w, r)
case "bedrock":
err = gw.Bedrock_Request(w, r)
// ... bedrock-runtime, bedrock-agent, bedrock-agent-runtime
case "ecs":
err = gw.ECS_Request(w, r)
case "rds":
err = gw.RDS_Request(w, r)
case "tagging":
err = gw.Tagging_Request(w, r)
case "spinifex":
err = gw.Spinifex_Request(w, r)
}
```
1. **Public routes**: `/oidc/eks/...` and `/v2` (the ECR registry) are mounted ahead of SigV4 by `runtime/roles/awsgw`
2. **Authentication**: SigV4 middleware validates AWS credentials and resolves the account ID
3. **Throttling**: Per-account+action token bucket rejects bursts post-auth
4. **Service Detection**: Extracts service name from the Authorization header
5. **Action Dispatch**: Every service is a registration (`ingress/aws/dispatch`) built by its domain's `awsapi.NewRegistration` and wired by `runtime/roles/awsgw`. The pipeline dispatches by registered service name and holds no per-service code. The private `/admin/<Method>` surface dispatches to the registration `awsgw` names as `Admin.Service`.
### 3. EC2 Handler
The EC2 handler (`spinifex/gateway/ec2.go`) parses the `Action` parameter and delegates to specific handlers via the generic `ec2Handler` and `ec2HandlerWithReq` wrappers. The account ID resolved by SigV4 auth is threaded into every handler so requests are scoped per IAM principal:
```go
"RunInstances": ec2HandlerWithReq(func(ctx context.Context, input *ec2.RunInstancesInput, gw *GatewayConfig, accountID string, r *http.Request) (any, error) {
// ... PassRole and launch-quota checks, then the vCPU quota charge
return ec2instanceapi.RunInstances(ctx, input, gw.NATSConn, gw.IAMService, accountID, passRoleCheck, launchQuotaCheck, gw.ExpectedNodes)
}),
"DescribeInstances": ec2Handler(func(ctx context.Context, input *ec2.DescribeInstancesInput, gw *GatewayConfig, accountID string) (any, error) {
return ec2instanceapi.DescribeInstancesChecked(ctx, input, gw.NATSConn, 0, gw.NodeIDs, accountID)
}),
// ... + volumes, snapshots, VPCs, subnets, route tables, IGWs, NAT gateways,
// security groups, network interfaces, elastic IPs, placement groups, key pairs,
// images, tags, capacity reservations, spot instances
```
The EC2 registration (`spinifex/domains/ec2/awsapi`, `NewRegistration`) parses the `Action` parameter and looks the handler up in its action table. The account ID resolved by SigV4 auth is threaded into every handler so requests are scoped per IAM principal. Each action's handler is built from the owning EC2 resource package (`domains/ec2/instance`, `volume`, `vpc` and so on), with PassRole, quota and client-token idempotency wrapped on at the action table rather than inside the resource.
### 4. NATS Messaging
@@ -181,12 +146,17 @@ type Daemon struct {
Tracks available and allocated CPU/memory to prevent overcommit, and drives the dynamic `ec2.RunInstances.{type}` subscriptions:
One instance vCPU is one CPU the kernel has online: a thread with SMT on, a core with SMT off or on ARM, as AWS counts a vCPU.
The OS and Spinifex's own services keep 1 CPU on a host with up to 8, 2 up to 16, 4 up to 63 and 8 from 64, and every other CPU is schedulable one-to-one with no overcommit.
[ADR-0015](https://github.com/mulgadc/mulga/blob/main/docs/adr/0015-host-cpu-capacity-and-reservation.md) fixes this rule.
```go
type ResourceManager struct {
mu sync.RWMutex
hostVCPU int // raw runtime.NumCPU
hostVCPU int // online CPUs (ADR-0015:S1)
hostMemGB float64 // raw /proc/meminfo MemTotal
reservedVCPU int // held back for spinifex services
reservedVCPU int // ADR-0015:S2 tier: 1, 2, 4 or 8
reservedMem float64 // held back for spinifex services
allocatedVCPU int
allocatedMem float64
@@ -229,6 +199,55 @@ type EBSRequest struct {
Viperblock responds with an NBD URI that QEMU uses to access the block device.
#### Direct-boot microVM data volumes
A direct-boot microVM keeps the launcher's saved `vm.Config` as a clean base, and `startQEMU` adds data volumes only to a launch-only copy of it (`runtime/compute/vm/lifecycle.go`).
At each start the daemon takes one snapshot of the instance's already-mounted data requests, probes each NBD or host block export from that snapshot, and builds the command from the same snapshot, so the launched endpoint is the one that was checked and is never written back into the saved base.
The microVM boots with `pcie=on`, so its NICs are `virtio-net-pci` devices with MSI-X interrupts instead of `virtio-net-device` MMIO devices.
Each data volume becomes a `virtio-blk-pci` device on the default PCIe bus with the same id, node name, I/O thread, serial and `werror=stop,rerror=stop` policy as the q35 device, and takes no PCIe hot-plug port.
A microVM started without `pcie=on` keeps the `virtio-*-device` MMIO transports.
Boot and EFI requests are rejected on this path, and a direct-boot instance with no data requests launches its base unchanged.
Disk-boot (q35) instances keep the existing PCI hot-plug path.
Hot-attaching a volume to a running direct-boot microVM is not supported, because the microVM has no PCIe hot-plug root port to plug into.
`AttachVolume` refuses it before mounting the volume or changing any instance or volume state, and the EC2 API returns `UnsupportedOperation`.
This covers only requests that are already mounted when the instance starts; nothing yet supplies data volumes to a system instance, and the path is verified with fake-QEMU unit tests, not with a real EBS volume in a guest.
#### Volume leases
Before opening an engine on a volume, the Viperblock worker takes a lease: one entry per volume in the `VIPERBLOCK_VOLUME_LEASES` JetStream KV bucket, which expires 45 seconds after its last write (`providers/ebs/viperblock/volume_lease.go`).
A renewal becomes eligible once the last confirmed write is 10 seconds old, and the holder checks for that periodically, so the rewrite lands some time after that point.
Each rewrite is conditioned on the revision the holder last wrote, and a refused write that finds the entry gone or naming another owner or generation fences the export as a takeover.
It may keep writing for 40 seconds after the last write the store acknowledged, measured from when that write was sent, because the server starts the entry's expiry when it applies the write and never earlier.
A slow acknowledgement therefore shortens the remaining validity rather than extending it, and an acquisition acknowledged after its validity has already run out is refused as retryable.
For a holder that cannot renew, a timer is armed for the validity deadline, leaving a nominal 5 seconds before the entry can expire; when it fires, the export's nbdkit is fenced with SIGKILL (`volume_fence.go`).
The fence runs on its own goroutine, so Go scheduling, that goroutine and process termination all delay the kill, and enough delay can use up the whole margin; a kill held in uninterruptible sleep, which the fence waits up to 30 seconds for, is the extreme case.
Once a lease is lost or has lapsed it stays lost: a renewal that succeeds afterwards is ignored, and another open on the same node cannot share it while the fence runs.
These rules bound only the holder's own clock.
The storage backend does not reject a stale writer, so a paused or partitioned host that resumes after its validity can still write; generation enforcement at the write boundary is required by Q-24 in the umbrella repository's `docs/specs/viperblock/rfc3-1_overview.md` and is not implemented.
An export that survives a daemon restart writes without a renewed lease until recovery adopts it, and a survivor that recovery cannot adopt while a guest still uses it is left running without a lease or a fence.
#### Unsealed writes and start elsewhere (interim)
A volume is unsealed while its node holds writes the backend has not confirmed; the node records this in a dirty marker naming itself as owner (`providers/ebs/viperblock/volume_dirty.go`).
Opening such a volume on another node loses those writes, so two guards stop it while the previous owner may still hold them.
This is an interim guard until the instance lifecycle work defines ownership, and it is not a fence.
When `ec2.start` is forwarded to the node that last ran a stopped instance and that node refuses for capacity, the forwarding node normally starts the instance locally.
If the refusing node still holds unsealed writes for any of the instance's volumes, it sets the `X-Unsealed-Writes` NATS reply header and the forwarder relays `InsufficientInstanceCapacity` instead of starting elsewhere.
The check asks the owner's viperblockd over the per-node `unsealed` verb with a 5 second bound, and a check that fails or times out counts as holding, so the refusal is kept.
Independently, a node that finds another node's dirty marker on a volume refuses to open it when that node's daemon heartbeat (`heartbeat.<node>` in the cluster-state bucket) is under 60 seconds old.
It also refuses when the heartbeat cannot be read or no heartbeat source is wired, and the refusal maps to the held-elsewhere error that recovery retries.
A previous owner that has stopped heartbeating is opened over with a warning, as before.
The heartbeat age is compared across node clocks, so the 60 second window is also the clock-skew allowance.
EKS control-plane recovery treats both refusals, capacity and held-elsewhere, as retryable: it keeps retrying the stopped member and does not count them against its restart attempts.
### Predastore (S3)
Object storage used for:
@@ -256,6 +275,60 @@ file. It does not connect to Predastore to do so, and no Predastore node
exposes a status endpoint. This is an operator status interface, not a generic
storage-provider contract.
### SQS message store (internal)
`domains/sqs/message` defines the store-neutral interface the SQS domain reaches its message store through (ADR-0011:S6), with a JetStream adapter in `domains/sqs/message/jetstream` and a shared conformance suite in `domains/sqs/message/conformance`.
It is used only by the SQS worker inside the SQS system instance and does not itself deliver an SQS API.
Spinifex ships no host service, unit, user or firewall rule for the dedicated JetStream server; the system instance that runs it is described under *SQS system instance (operator-launched)*.
The interface covers durable and idempotent append, body and metadata reads, conditional state writes and deletes, per-namespace counts, namespace enumeration, an ordered change feed, a bounded paginated scan, namespace deletion and a sweep of tombstoned bodies.
A namespace carries an immutable message-shard and state-shard allocation chosen when the queue is created, so a later change in shard counts never moves an existing queue.
The adapter provisions a fixed set of shared message streams (`SQS_M_<shard>`, one subject per queue) and shared state buckets (`sqs_state_<shard>`), keeps historical ones, and applies a 14-day-plus-one-hour retention backstop to both.
It supports one node (R1) and three nodes (R3) only and refuses any other declared cluster size before provisioning.
Reads that must observe a completed write or delete go to the stream leader: `GetState`, the state-key pass behind `Count` and `Namespaces`, and the body pass behind `Namespaces` and `Scan`.
This avoids the follower-served direct gets and follower-placed consumers that returned purged data in local three-node fault-injection tests; it is not a general linearizability guarantee.
Each leader read is one request per record, and the body pass transfers each full body internally even though `Scan` returns metadata only, which is a material cost for a large backlog.
A state-key pass is bounded but is not a snapshot when a key is overwritten during it.
The feed reads through an in-memory, headers-only ordered consumer.
If that consumer delivers nothing for about two seconds while the stream holds a change after the feed's cursor, the feed replaces it from the cursor, so a stored change does not wait on a stalled consumer; the cause of those stalls is not established.
`Close` is terminal, waits for consumer opens and deletes in progress and retries failed deletes, but cleanup is best effort for a create whose outcome is unknown: a consumer that lands after its last delete is removed by the server's one-minute inactive threshold.
Coverage is unit and conformance tests on in-process R1 and R3 servers; there is no end-to-end or multi-host validation of this code, and earlier multi-host evidence predates the final feed.
### SQS system instance (operator-launched)
One system-owned microVM per Region runs both the SQS store and the SQS worker as separate processes, on one encrypted data volume pinned to one node (ADR-0011 Amendment 5, ADR-0012 Amendment 6).
Only an operator activates it with the super-admin `EnsureSQSWorker` or `EnableSQS` actions; nothing starts it at daemon startup, and platform NATS is neither used nor changed for SQS data.
This first profile is fresh single-node only: a host cluster of more than one node is refused before anything is recorded or allocated, and there is no disable.
`make build-sqs-worker-microvm-image` builds the one `sqs-worker` image: the static `sqs-worker` binary, the pinned `nats-server`, the ext4 tools and a network `/init`; releases ship it as `microvm-sqs-worker`, which `setup.sh` installs to `/usr/share/spinifex/microvm/sqs-worker`.
At boot the guest reads three firmware config values under `opt/spinifex/sqs-worker/` (the data disk's serial, its Region and the gateway URL on this node's mgmt bridge) and refuses before touching any disk if one is missing or malformed.
It selects exactly one disk with that serial, runs `e2fsck -p`, mounts it at `/data` and confirms from `/proc/self/mountinfo` that the whole disk is what is mounted; the guest never formats.
The store is a dedicated `nats-server` listening only on `127.0.0.1`, with its JetStream directory at `/data/jetstream` and `GOMEMLIMIT=512MiB` (ADR-0011:S11), so nothing outside the VM can reach it and it needs no network identity of its own.
The store starts only after the receipt key is settled, and the worker connects only after the store answers JetStream account info, so an unavailable store never appears ready.
The receipt-signing key lives on the data disk under `/data/sqs-worker-key`, written to a temporary file, synced, renamed and its directory synced before its fingerprint is recorded.
`domains/sqs/workerident` holds the platform's receipt-key state on the worker's identity record, beside its generation, claim, pinned node and volume: `pending` is set only when the record first creates its volume, and `recorded` holds the fingerprint.
Under `pending` the guest may create a key, or reuse a valid key it already wrote before a crash, only while no `/data/jetstream` directory exists; any store data under `pending` refuses, because it proves the volume is not new.
Under `recorded` a missing, unreadable or different key refuses the boot and is never regenerated; a record with no receipt-key state is refused as not proven new.
`RecordReceiptKey` writes the fingerprint by compare-and-swap that re-checks the current instance, generation, claim and volume, and a lost reply is answered by reading the record again, never by a new key.
A replacement worker reuses the pinned volume and keeps its predecessor's receipt-key state.
The worker reaches the gateway over TLS 1.3 trusting only the cluster CA, signing with instance-role credentials from the metadata service only.
The internal `spinifex-sqs` actions are `ClaimReceiptKey`, `RecordReceiptKey`, `PollWork` and `CompleteWork`; the gateway admits only an assumed-role session of `sqsWorkerRole` in the system account and resolves the instance through `workerident.Authorize`, a point-in-time check against the current bound record.
The worker pulls tenant work from its own node's gateway relay and posts results back, so no SQS payload crosses platform NATS; the relay holds a bounded, in-memory set of waiting requests and answers `ServiceUnavailable` when it is full or no worker is polling.
`EnsureSQSWorker` takes a `NodeId`, an `InstanceType` and a `VolumeSizeGiB`; the instance type's memory must exceed the store's 512 MiB limit, and a later request for a different type or size is refused as `IncorrectState`.
`EnableSQS` records the Region's desired state, pinned to one node, and then ensures the worker; `status` reports it ready only when the worker is bound, polling and the tenant API is registered.
A store launch record or VM from the earlier separate-store design, or a worker record without a supported launch contract, is refused with its volume kept: enable stops before recording anything, and a relaunch of an old store VM is marked failed rather than started.
Evidence is local: unit tests, the guest shell test and an AWS SDK test that creates a queue and sends, receives and deletes a message through the real worker loop against a real local JetStream store and a restart.
A real guest boot, its metadata route on a real ENI and the signed path on a live node are unproven, Viperblock's at-rest key is held by the platform, and SQS-managed message encryption is not implemented, so the profile must not hold tenant production data.
## Configuration
Cluster configuration (`spinifex/bootstrap/config/config.go`):
+35 -18
View File
@@ -60,7 +60,7 @@ intact; ADR-0001 explicitly defers that design.
| Current path | Standing | Target ownership | Rule |
|---|---|---|---|
| `cmd/aws-model-coverage` | Qualification generator | Thin main; reusable code in `internal/tooling` | Produces the dispatch-versus-model inventory; never imported by product packages. |
| `cmd/aws-model-coverage` | Qualification generator | Thin main; reusable code in `internal/tooling` | Produces the dispatch-versus-model inventory; never imported by product packages. Debt: it reads each domain's declared `OperationInventory` from `domains/<service>/awsapi` (ACM, EC2, ECR, ECS, EKS, ELBv2, IAM, RDS, Route 53, SQS, STS) and never constructs a registration. |
| `cmd/dhcptest` | Development diagnostic | Thin main; reusable code in `internal/tooling` if extraction is useful | It is a DHCP probe, not a supported `spx admin` command. |
| `cmd/spx-loadgen` | Qualification/load tool | Thin main; reusable code in `internal/testkit` | It drives test load and is not shipped to nodes. |
| `tests/e2e/*/cmd/*`, `tests/bench/*/cmd/*` | Test and benchmark-private tools | Remain test-private | They are invoked by a test or benchmark harness, not a product process. |
@@ -351,8 +351,27 @@ while the branch is in flight; they do not describe the umbrella repository's
| `spinifex#1269` | `spinifex/handlers/elbv2` | `spinifex/domains/elbv2` (package `elbv2`) | A mechanical whole-package relocation under the 2026-10-26 ADR-0001 amendment: the ELBv2 control plane keeps its files, tests and current dependencies, and importers alias it `elbv2domain`. No identifier, API, NATS subject, KV bucket, metric, error string or behaviour changes. Its allowlist entries are now sourced from `domains/elbv2`. Q-30's data-plane boundary stays deferred, recorded as `agents/elbv2` (Q-30 data-plane boundary) debt. |
| `spinifex#1271` | `spinifex/handlers/ecs` | `spinifex/domains/ecs` (package `ecs`) | A mechanical whole-package relocation: ADR-0001's `handlers/ecs` row targets `domains/ecs`, the files are already organised by resource, and resource packages are introduced only where callers need a compiler boundary. The ECS control plane keeps its files, tests and current dependencies alongside the already-extracted `domains/ecs/taskdefinition`, and importers alias it `ecsdomain`. No identifier, API, NATS subject, KV bucket, metric, error string or behaviour changes. Its allowlist entries are now sourced from `domains/ecs`. `handlers` has no package left and is a retired legacy root; R5 holds `domains/ecs` to the interim rule's ban on importing the daemon. |
| `spinifex#1273` | `spinifex/gateway/{ecs,elbv2}` | `spinifex/domains/{ecs,elbv2}/awsapi` (`awsapi`, imported as `ecsawsapi`, `elbv2awsapi`) | A mechanical relocation of the ECS and ELBv2 AWS action adapters to their ADR-0001 domain homes, as `spinifex#1265` did for IAM, STS, EKS and RDS; APIs, NATS subjects, KV buckets and behaviour are unchanged. The root `gateway` dispatch still imports each package, recorded as `gateway` (service registration) debt, and `domains/admission/quota` keeps its live ELBv2 counter import, now of `domains/elbv2/awsapi`. |
| `spinifex#1336` | `spinifex/gateway/tagging.go` (`Tagging_Request`, `taggingActions`, `taggingActionFromTarget`) and the `GatewayConfig.TaggingResources` field | `spinifex/domains/tagging/awsapi` (`NewRegistration`, `Deps`, `Actions`, `OperationInventory`), composed by `spinifex/runtime/roles/awsgw` | Resource Groups Tagging registers through the ADR-0002 seam like ACM and Route 53. The check order is kept: action, scope, authorization, account, body, aggregator, so a denied caller still gets `AccessDenied` before any body error and an unwired aggregator answers `ServerInternal`. The `checkPolicyResources` stand-in of `*` for every action is unchanged. The legacy switch case, the `supportedServices` entry and the `jsonErrorService` case are removed, and the registration declares the JSON 1.1 envelope. No allowlist row existed for this path. The nil-NATS check moves to the gateway-wide gate, which answers `ServiceUnavailableException` before dispatch as it did for every service. `cmd/aws-model-coverage` output is byte-identical because there is no embedded Tagging model. The characterization tests that pin the pipeline behaviour were committed first and pass unchanged against the registration; only the `withTagging` test helper changed. |
| `spinifex#1336` | `spinifex/gateway/elbv2.go` (`ELBv2_Request`, `elbv2Actions`, `elbv2Handler`, `accountLoadBalancerLimit`) | `spinifex/domains/elbv2/awsapi` (`NewRegistration`, `Deps`, `Quota`, `ActionNames`, `OperationInventory`), `spinifex/domains/admission/quota` (`Service.LoadBalancerLimit`), `spinifex/ingress/aws/query` (`ParseArgs`), composed by `spinifex/runtime/roles/awsgw` | ELBv2 registers through the ADR-0002 seam as the first AWS Query service. The check order is kept: query parse, action, account, input parse, scope, authorization, NATS, dispatch, so a denied caller still gets `AccessDenied` before the handler runs. The gateway members ELBv2 read are now supplied as `Deps`: `NATSConn` as `Deps.NATS`, `Quota` as the `Deps.Quota` interface declared in `awsapi` because `domains/admission/quota` imports `awsapi`, `Region` as `Invocation.Region`, and the policy gate as `Invocation.Authorize`. The `DescribeAccountLimits` cap resolution moves to `Service.LoadBalancerLimit` unchanged, and a nil `Deps.Quota` enforces nothing and reports the AWS default. AWSGW now wires the registry after it builds the quota service, because the registration captures it. The registration declares the XML envelope and the inventory name `elasticloadbalancingv2`, so `cmd/aws-model-coverage` keys it explicitly and its output is byte-identical. `gateway.ParseAWSQueryArgs` delegates to `query.ParseArgs`. The legacy switch case, the `supportedServices` entry, the inventory entry and the `gateway -> domains/elbv2/awsapi` allowlist row are removed. `cmd/aws-model-coverage` imports `domains/elbv2/awsapi` for the inventory, under a fourth named-exception allowlist row whose reason is now generic for all four. Finding, not fixed here: the XML for `DescribeAccountLimits` and `DescribeLoadBalancers` lists member fields in a different order from run to run because the marshal is map-ordered, so the characterization tests pin decoded content rather than bytes. The characterization tests that pin the pipeline behaviour were committed first and pass unchanged against the registration; only the `withELBv2` and `serveELBv2` test helpers and the setup of two older gateway tests changed. |
| `spinifex#1337` | `spinifex/gateway/spinifex.go` (`Spinifex_Request`, `spinifexAdminActions`) | `spinifex/operator/admin` (`NewRegistration`, `Deps`, `ModelAccess`, `ActionNames`), composed by `spinifex/runtime/roles/awsgw` | The `spinifex` Query service registers through the ADR-0002 seam with the XML error envelope it had. The check order is kept: query parse, action, authorization with the wildcard resource, account, the super-admin account gate, then the action, so a denied caller learns nothing about which actions exist. The gateway members the actions read are now `Deps`: `Version` and `Commit` strings, `NATSConn` as `Deps.NATS`, `RootCAs`, `DiscoverActiveNodes` as `Deps.ActiveNodes`, `BedrockAccessAdmin` as the `ModelAccess` interface, and the credential and access resolvers as `Deps.Credentials` and `Deps.Access`. The query actions need only the caller account, so the actor is not widened. The cluster-unavailable answer stays in the shared request path ahead of authorization. `/admin/{method}` moved onto the same registration in `mulga-3nbla.7`, recorded below. |
| `spinifex#1344` | `spinifex/gateway/rds.go` (`RDS_Request`, `rdsCaller`) | `spinifex/domains/rds/awsapi` (`NewRegistration`, `Deps`, `Quota`, `CallerFromActor`, `OperationInventory`), composed by `spinifex/runtime/roles/awsgw` | RDS registers through the ADR-0002 seam with the XML envelope it had. The check order is kept: query parse, action, account, the principal-class gate for the internal agent actions, scope, authorization, NATS, quota and dispatch, so a denied caller still gets `AccessDenied` before the handler runs and no customer grant is evaluated against an agent action. The gateway members RDS read are now supplied as `Deps`: `NATSConn` as `Deps.NATS`, `ExpectedNodes` as `Deps.ExpectedNodes`, `Quota.EnforceRDSInstances` as the `Deps.Quota` interface, `Region` as `Invocation.Region` and the policy gate as `Invocation.Authorize`. A nil `Deps.Quota` skips the DB-instance cap. The caller comes from `dispatch.Actor`: the session name for an assumed-role session and the user name otherwise, and the role name from `Actor.RoleName()`, which parses the underlying role ARN and never the session name. The account comes from `Invocation.AccountID`, so a caller with no composable ARN still reaches the policy check and is refused there as before. The legacy switch case, the `supportedServices` entry, the inventory entry and the `gateway -> domains/rds/awsapi` allowlist row are removed, and `cmd/aws-model-coverage` reads the inventory under a fifth named-exception allowlist row. The characterization tests that pin the pipeline behaviour were committed first with spinifex#1336 and pass unchanged against the registration; only the `withRDS` and `serveRDS` test helpers and the setup of the direct-call RDS tests changed. |
| `#1277` | `spinifex/accountteardown` | `spinifex/operator/accountlifecycle` (package `accountlifecycle`, imported as `accountteardown`) | A mechanical whole-package relocation under the 2026-10-27 ADR-0001 amendment: the account deletion coordinator keeps its APIs, NATS subjects, KV access and behaviour; importers keep the `accountteardown` name as an alias, so only their import paths change. Replacing its direct domain and adapter cleanup calls with owner capabilities stays deferred debt. |
| `#1285` | `spinifex/gateway/requestaudit.go` and `slogRequestLogger` from `spinifex/gateway/gateway.go` | `spinifex/ingress/aws/audit` (package `audit`) | The first generic-ingress extraction from the root gateway: the request-audit middleware and access-log middleware move unchanged in behaviour. The gateway's authentication flow and dispatch still populate the record, now through the exported `audit.From` and `Record` setters at the same call sites; the package imports only `foundation/telemetry` and `ingress/http`. Its unit tests moved with it; the tests that run it behind SigV4 stay in `gateway`. |
| `spinifex#1334` | `spinifex/gateway/spx` (`spx.go`, `storage.go`, `promote_image.go` and their tests) | `spinifex/operator/admin` (package `admin`) | The version, node, VM and storage-status gather helpers and the image-promote NATS request move unchanged with no shim; the `spinifex` service dispatch that calls them stays in `gateway/spinifex.go` until the `spinifex` registration slice. `operator/admin` already imports `domains/ec2/image` and `contracts/operator/v1`, so the move adds no edge and the layering ratchet needed no allowlist row. The `gateway/spx` frozen-legacy entry is removed. |
| `spinifex#1334` | `spinifex/gateway` (`CreateAccountRequest`/`Response`, `DeleteAccountRequest`, `DescribeAccountDeletionRequest`, `DeletionState*`, `AccountSummary`, `ListAccountsResponse`, `AdminMethodNames`) | `contracts/operator/v1` (`admin.go`) | The `/admin` bodies the CLI shares with the gateway move unchanged in field names, JSON tags and omitempty, pinned by golden JSON written against the gateway types first and moved with them. `AdminMethodNames` is now owned by the contract and the gateway builds its `adminMethods` accept set from it, with a test that the router accepts exactly that list. `AccountQuotaRequest`/`AccountQuotaResponse` (embed `quota.Overrides` from `domains/admission/quota`) and `DeleteAccountResponse` (embeds `accountlifecycle.Result`) stay in `gateway`: `contracts` may import only `foundation` and `contracts`, so they are settled when the `/admin` handlers move to `operator/admin`. `accountlifecycle.Stage` serialises as an integer, not its name; the golden test pins that. |
| `spinifex#1345` | `spinifex/gateway/admin*.go` (the `/admin/{method}` handlers, gates and body cap) and the `AccountQuotaRequest`/`AccountQuotaResponse`/`DeleteAccountResponse` wire types | `spinifex/operator/admin` (`rest.go`, `account_*.go`), served by the same `spinifex` registration | `/admin/{method}` stays a chi route in `gateway`, ahead of the service selector, but `Admin_Request` now only mints the request ID and header and hands the request to the registered entry through `dispatchRegistered`, the path every Query request takes. `dispatch.Invocation` gains `Service`, the credential-scope service, so the scope gate is the first identity gate in the dispatcher and a key signed for another service is denied exactly as before. The order is unchanged: verb and method, then the five fail-closed gates (scope, super-admin account, IAM user, identity, `spinifex:<Method>` policy), then the cluster check, then the 8 KiB body cap. The dispatcher returns errors and the route renders them as the JSON envelope with `requestId`, then logs once from the real result, so a denial or a 5xx is never logged as a completed request; the registration's `ResolveAction` labels the method, which removes the `auth.go` special case. The caller is read from `dispatch.Actor` (`UserName` for the gate and `updatedBy`). `BucketStore` and `SignupMaxAccounts` leave `GatewayConfig` for `operator/admin.Deps`, composed in `awsgw` beside `IAM`, `Quota`, `ExpectedNodes` and `NodeIDs`. The three wire types that could not enter `contracts` move to `operator/admin` with their handlers, `operator/cli` imports them from there, and the last `operator/cli` → `gateway` allowlist entry is deleted. |
| `spinifex#1346` | `spinifex/gateway/iam.go` (`IAM_Request`, the IAM action table, `getUserForCaller`, the PassRole check) | `spinifex/domains/iam/awsapi` (`NewRegistration`, `Deps`, `ActionNames`), composed by `spinifex/runtime/roles/awsgw` | IAM registers through the ADR-0002 seam with the XML error envelope it had. The check order is kept: query parse, action, service and account checks, then one parse of the input, the `GetUser` default for a missing `UserName`, scope resolution, authorization, the `iam:PassRole` check for `AddRoleToInstanceProfile`, then the handler. The caller is read from `dispatch.Actor` (`PrincipalType`, `UserName`), with no ARN parsing. The dependencies are `Deps.IAM` and `Deps.CountInstanceProfileAssociations`, composed in `awsgw`, and the gateway keeps no IAM fallback. The `cmd/aws-model-coverage` named exception gains an `iam` row. |
| `spinifex#1346` | `spinifex/gateway/sts.go` (`STS_Request`, the STS action table, the anonymous STS dispatch) | `spinifex/domains/sts/awsapi` (`NewRegistration`, `Deps`, `ActionNames`, `NewAnonymousAssumeRoleWithWebIdentity`), composed by `spinifex/runtime/roles/awsgw` | STS registers through the ADR-0002 seam with the XML error envelope it had. The check order is kept: query parse, action, service check, caller, the `AssumeRole` identity gate scoped to the stored role ARN, then the handler. The caller is read from `dispatch.Actor` (`CallerARN`, `UserName`, `SessionName`, `UserID`, `AccessKeyID`), with no ARN parsing. The gateway's authorization failure wraps a generic `dispatch.PolicyDenialError`, and STS matches it to answer with the caller, action and resource, so the gateway holds no STS-specific denial code. An unsigned `AssumeRoleWithWebIdentity` stays ahead of SigV4 in the gateway and reaches the handler through `GatewayConfig.AnonymousSTS`, a func composed in `awsgw`, never the STS service. The `cmd/aws-model-coverage` named exception gains an `sts` row. |
| `spinifex#1348` | `spinifex/gateway/ecs.go` (`ECS_Request`) and `spinifex/gateway/eks.go` (`EKS_Request`, the EKS route table) | `spinifex/domains/ecs/awsapi` and `spinifex/domains/eks/awsapi` (`NewRegistration`, `Deps`, `OperationInventory`), composed by `spinifex/runtime/roles/awsgw` | ECS (AWS JSON 1.1) and EKS (REST JSON) register through the ADR-0002 seam with the JSON error envelopes they had, both declared as AWS JSON 1.1 (see the envelope divergence below). The check order and every response are kept, pinned by the pipeline characterisation tests committed first. EKS and ECS resolve roles through `foundation/aws/passrole` `Check` with `domains/iam` `RoleResolver`: a malformed or foreign role is refused first, a missing role is authorized as written before it is reported, and a resolver outage is returned unchanged. Role handling now follows the AWS capture of 2026-10-30. ECS RegisterTaskDefinition refuses a foreign role with `ClientException` "Role is not valid" before PassRole and accepts a nonexistent, invented-path or non-ARN role from a caller who can pass roles. ECS RunTask refuses a foreign `taskRoleArn` or `executionRoleArn` override the same way and answers a nonexistent or invented-path one with "ECS was unable to assume the role". EKS CreateCluster and CreateNodegroup answer a non-ARN or foreign role with `AccessDeniedException` "Cross-account pass role is not allowed.". EKS public routes now report every parameter fault as `InvalidParameterException`, the code the EKS model declares, while the internal control-plane routes keep `InvalidParameterValue` because the guest agents match on it. Two deliberate EKS divergences remain. First, CreateCluster still refuses a nonexistent or invented-path role with `InvalidParameterException` "role ... does not exist", where AWS accepts it and the cluster later ends FAILED. Our EKS only stores, validates and echoes the cluster role and never assumes it, so accepting would leave an ACTIVE cluster bound to a role that does not exist. Second, CreateNodegroup keeps its existing refusal of a nonexistent role because AWS was not captured for it. A third divergence is the error envelope. EKS is restJson1 but registers `ErrorEnvelopeJSON`, so errors carry `application/x-amz-json-1.1` and a `__type` body where AWS sends `application/json` and a `message` body. The `X-Amzn-Errortype` header is correct and SDKs read the code from it, so impact is low. Switching to `ErrorEnvelopeRESTJSON` also changes the throttle code and the default internal-error code (`ServiceException` where the EKS model declares `ServerException`), so it is left for its own change. Verified live on env14: the ECS and EKS role cases above, and a valid CreateCluster reaching ACTIVE with the control-plane agent publishing through the gateway. The EKS route table and its tests move with the registration, and the gateway keeps no ECS or EKS fallback. The `cmd/aws-model-coverage` named exception gains an `ecs` and an `eks` row, and the two `gateway` allowlist rows are removed. |
| `spinifex#1350` | `spinifex/gateway` (`ecr.go`, `ecrauth_middleware.go`, the `/v2/token` exchange, `ecr_principal.go` with `ResolveECRPrincipal`, the per-operation `/v2` authorization, the registry host matcher, `ctxECRPrincipal`) | `spinifex/domains/ecr/registry` (`Bridge`, `NewBridge`, `BridgeConfig`, and its own `Principal`, `Rehydrator`, `Authorizer`, `Admission` types with `ErrInvalidCredential` and `ErrUnavailable`), `spinifex/ingress/http` (`HostMatcher`, `TargetAccount`, `RequestClientIP`; names no service), `spinifex/ingress/aws/credential` (`Rehydrator`, `Principal.CallerARN`), and under `spinifex/runtime/roles/awsgw`: `credverify` (`Rehydrate`, sharing the SigV4 key and session lookups), `policyauth`, `accountadmission`, `registryadapt` | ADR-0001 amendment 2026-10-29 and ADR-0005:S2: the OCI `/v2` bridge and its token auth move out of the gateway, and ECR resolves its principal through the credential capability instead of reading `IAMService` and `STSService` directly. ADR-0002:S5 keeps `/v2` outside the seam, so the gateway keeps only a `RegistryHandler` mount that answers 501 when unset. `domains/ecr/registry` imports no ingress package (`TestADR0001_Layering` unchanged): the principal, rehydrator and authorizer are registry-local types and `registryadapt` maps `credential.Failure` codes onto `ErrInvalidCredential` (401) and `ErrUnavailable` (503). No widening of `dispatch.Actor` or `dispatch.Invocation`. The SigV4 path and the bridge share one policy evaluator (`policyauth`) and one account-admission check. Deliberate change: the open-registry mode for a nil token verifier is deleted and `NewBridge` refuses a nil Verifier, Issuer, Rehydrator, Authorizer or Admission, so tests that want an unauthenticated registry mount `registry.Handler` directly. The pre-move pipeline characterisation (status codes, error bodies, the `WWW-Authenticate` challenge, referrers, token exchange, revoked and deleted principals) passes unchanged apart from setup, and the retired `ResolveECRPrincipal` parity rows live on as `credverify` `Rehydrate` rows and `Bridge` claim-check rows. Live on env14 (DHCP external IP): crane, skopeo and docker push and pull (`tests/e2e/ecr`), a cosign sign and verify in the legacy tag mode and in OCI 1.1 referrers mode with the signature returned by `GET /v2/{repo}/referrers/{digest}`, `/v2/token` refresh from fresh logins, and a deactivated access key answering 401 on `/v2/token`, manifest GET, crane pull and crane push, then 200 again once reactivated. The ECR signing-key deadlock bead (`mulga-6wqce`) is untouched. |
| `spinifex#1351` | `spinifex/gateway/{bedrock,bedrockruntime,bedrockagent,bedrockagentruntime}.go` (`Bedrock_Request`, `BedrockRuntime_Request`, `BedrockAgent_Request`, `BedrockAgentRuntime_Request`, the four route tables), `spinifex/domains/ochre/authz.go`, and the 13 `Bedrock*` fields of `GatewayConfig` | `spinifex/domains/ochre/awsapi` (`NewRegistration`, `Deps`, `Quota`, `OperationInventory`, `ServiceName`), composed by `spinifex/runtime/roles/awsgw` | The four Bedrock services register through the ADR-0002 seam as one registration, per ADR-0002:S1 (one per signing service) and the ADR-0001 amendment of 2026-10-06: the service is `bedrock` and `bedrock-runtime`, `bedrock-agent` and `bedrock-agent-runtime` are aliases. A request signed with a sub-service scope is dispatched to that sub-service whatever its path, so a path owned by another sub-service answers `InvalidActionException` as before; only the shared `bedrock` scope is split by path. The pipeline characterisation tests committed first (status, error type and envelope per service, metering order, nil-capability answers) pass unchanged apart from setup. The 13 gateway fields become `awsapi.Deps`: `Credentials`, `Endpoints`, `Access`, `Recorder`, `Logging`, `Provisioned`, `Guardrails`, `Embedder`, `KB`, `DataSources`, `Vector` and `Quota`, with the documented nil fallbacks (no grants denies every model, a nil store errors rather than panics); `BedrockAccessAdmin` had no reader outside one test and is gone, and `Deps.NATS` is dropped because the nil-NATS answer is given by the gateway-wide gate before dispatch. `Logging`, `Provisioned`, `Guardrails`, `KB` and `DataSources` are the concrete exported store types, a temporary edge until Ochre exposes capability interfaces for them. The wire-typed action functions stay in the `domains/ochre` root and the SDK-typed adapters move; `authz.go` moves with them, which removes the `domains/ochre` to `ingress/aws/bodyscope` allowlist row, the only allowlist change. The legacy switch cases, the `supportedServices` and `jsonErrorService` entries and the path-based sub-service rewrite in `GetService` are removed, so `ec2` is the only service left on the legacy table; the rewrite's rows are pinned in `awsapi` as `resolveSubservice` and dispatch tests. The registration declares the AWS JSON 1.1 envelope it had and `cmd/aws-model-coverage` is not wired, so no coverage allowlist row is added. Recorded divergences, not changed by this move: Bedrock is restJson1 in AWS but answers with the JSON 1.1 envelope (`application/x-amz-json-1.1`, `__type` body), and CreateGuardrail answers 200 where AWS answers 202; both are tracked by `mulga-xug8b`. No change to `dispatch.Actor` or `dispatch.Invocation`. |
| `spinifex#1354` | `spinifex/gateway/ec2.go` (the EC2 action table, `ec2Handler`, `ec2Actions`, the dispatch pipeline), `ec2_idempotency.go`, `ec2_validation.go`, and the EC2 token-bucket fields of `GatewayConfig` | `spinifex/domains/ec2/awsapi` (`Handler`, `NewHandler`, `Deps`, `Quota`, `Dispatch`, `ActionNames`, `InputHasField`), composed by `spinifex/gateway/ec2.go` | Slice 9a of the EC2 move: the capabilities first, with no behaviour change. The EC2 action table, idempotency and validation move to `domains/ec2/awsapi` behind `Deps{NATS, IAM, Quota, ActiveNodes, ExpectedNodes, NodeIDs, AZ, Endpoint, InstanceStatus}`; the gateway keeps the composition and `EC2_Request`, still on the legacy switch until slice 9b registers EC2 through the ADR-0002 seam. `Quota` is declared in `awsapi` because `domains/admission/quota` imports the EC2 action packages and cannot be imported back, and the gateway wraps `quota.Service` to supply the NATS instance-type resolver its retype gate takes. The instance and spot-instance adapters take the two-method `InstanceProfiles` interface (`ResolveInstanceProfile`, `CanonicalResourceARN`) instead of `iamdomain.IAMService`, which pays off the `spotinstance` to `domains/admission/quota` and `domains/iam` allowlist rows. Allowlist changes are 20 deletions and no additions: the 18 `gateway` to `domains/ec2/awsapi/*` rows and those 2. The pipeline characterisation tests committed first (error envelopes, check order, gateway-local actions, idempotent replay, PassRole, ImportKeyPair padding, launch-template expansion before authorization, RunInstances instance-profile refusal order) pass unchanged. Package-local tests drive every routed action through `Dispatch` against a stub daemon. No change to `dispatch.Actor` or `dispatch.Invocation`. Live on env14: DescribeRegions, DescribeAvailabilityZones and DescribeAccountAttributes, an idempotent CreateVolume replay and its parameter mismatch, the DryRun and MaxResults errors, a no-policy user refused with UnauthorizedOperation (including on the gateway-local DescribeRegions), an assumed-role session allowed and refused per its policy, and one RunInstances to running then terminated. |
| `spinifex#1355` | `spinifex/gateway/ec2.go` (`EC2_Request` in production, the legacy service switch, its `supportedServices` and JSON tables) and the `Quota` field of `GatewayConfig` | `spinifex/domains/ec2/awsapi` (`NewRegistration`, `OperationInventory`, `ServiceName`), composed by `spinifex/runtime/roles/awsgw` (`ec2.go`: `newEC2Registration`, `advertisedEndpoint`; `ec2quota`) | Slice 9b of the EC2 move: EC2 registers through the ADR-0002 seam as service `ec2` and the legacy gateway switch is empty. The EC2 query-protocol error envelope is kept through `ErrorEnvelopeXML`, and the policy denial match is now `*dispatch.PolicyDenialError`. The characterisation tests committed first also pin the Authorize service name and action, the trace span and audit action, the throttle key (account plus action) and the cluster-unavailable gate, which runs before policy and action lookup for gateway-local and NATS-backed actions alike. They and the 9a tests pass unchanged apart from setup: STS and EC2 test setups now register the services they sign as, and the two tests that claimed a legacy service name are removed because no legacy name remains. `EC2_Request` survives only as a test shim. Allowlist changes are two deletions (the `gateway` to `domains/ec2/awsapi` and `domains/ec2/awsapi/instance` rows) and one addition, the per-service model-coverage row `cmd/aws-model-coverage` to `domains/ec2/awsapi`. `startInstanceStatus` stays in `awsgw.go` because ADR-0007:S6 records the raw instance-state uses by file. No change to `dispatch.Actor` or `dispatch.Invocation`. Live on env14: DescribeRegions, DescribeAvailabilityZones and DescribeAccountAttributes, an idempotent CreateVolume replay and its parameter mismatch, the DryRun and InvalidVolume.NotFound errors, a no-policy user refused with UnauthorizedOperation (including on the gateway-local DescribeRegions), an assumed-role session allowed and refused per its policy, and one RunInstances to running then terminated. The gateway request logs carry `service=ec2` and the per-action label. Not exercised live: a throttled request (the default limits are not reachable from one client; the throttle key is pinned by the characterisation tests) and an agent-signed EC2 call. |
| `spinifex#1363` | `spinifex/gateway` context keys (`ctxService`, `ctxRegion`, `ctxAction`, the principal and access key id keys, and the unread `ctxQueryArgs`), the duplicate `callerPrincipal` and `principalContext` types, `gateway/caller_arn.go` (`buildCallerARN`), and the direct `policyauth`, `accountadmission` and NATS-connection reads in the auth pipeline | `spinifex/ingress/aws/reqctx` (the request record), `credverify.CallerARN`, and the gateway-consumed capabilities `PolicyEvaluator`, `AccountAdmission` and `Readiness`, composed by `spinifex/runtime/roles/awsgw` (`clusterready`) | Slice 11a of the gateway removal: the request-context record and the ADR-0002:S8 capabilities, with no behaviour change. The record is a copy-on-write value under one context key; the principal is sealed once and a second `WithPrincipal` keeps the first caller. The layering rule `S7_RequestRecordPrivateToIngress` lets only `gateway` and `ingress/aws/*` import `reqctx`, with a fault-injection test, and slice 11c moves the package to `ingress/aws/internal/reqctx` and drops the rule. The caller ARN is built once in `credverify` (root cases kept) and filled on `Principal.CallerARN`. The policy evaluator, account admission (`credential.ErrAccountNotActive`, with `accountadmission.ErrNotActive` an alias) and cluster readiness (`Available() bool`) are interfaces the gateway consumes and fails closed on when nil. The step 1 characterisation tests (throttle against both readiness gates and the policy gate for ECS and IAM, `/admin` skipping `GetService` and the second gate, the nil-connection difference between the two gates, five error writers each minting a request id that differs from the audit record, and the credential-scope region reaching only the span) pass unchanged apart from setup. No allowlist rows change, and there is no change to `dispatch.Actor` or `dispatch.Invocation`. Debt for slice 11c: the test-only `defaultCapabilities` seam (a package variable set by a test `init` so about 60 gateway test files can build a config from `IAMService` alone), the nil-connection case of the early readiness gate, the remaining `NATSConn` readers (`DiscoverActiveNodes` and the field that `awsgw` and the integration harness set), and the `IAMService`, `STSService`, `Config`, `RootCAs` and `InternalSuffix` fields of `GatewayConfig`, which no production code reads. Live on env14: DescribeRegions, DescribeAvailabilityZones and DescribeAccountAttributes, an idempotent CreateVolume replay and its parameter mismatch, the DryRun and InvalidVolume.NotFound errors, a bad access key refused with InvalidClientTokenId on a Query service and InvalidClientTokenIdException on a JSON service, a no-policy user refused with UnauthorizedOperation, and an assumed-role session allowed and refused per its policy. Not exercised live: a suspended-account refusal, a throttled request and the NATS-down 503, which the step 1 tests pin. |
| `spinifex#1378` | `spinifex/gateway` `oidc_discovery.go` and `registry.go` (the `/oidc/eks` routes and the `/v2` mount), the `DiscoverActiveNodes` method and its cache, and the `GatewayConfig` fields `Config`, `RootCAs`, `InternalSuffix`, `Version`, `Commit`, `IAMService`, `STSService`, `NATSConn` and `ExpectedNodes` | `spinifex/runtime/roles/awsgw` (`PublicRoutes`, `NodeDiscovery.ActiveNodes`, `composeGateway`); the gateway keeps one `PublicRoutes` mount hook and the `Admin` registration name supplied by the role |
| `spinifex#1384` | `spinifex/gateway` (`GatewayConfig`, the SigV4 and policy middleware, `Request`, the admin dispatch, `AWSOperationInventory`) | `spinifex/ingress/aws/pipeline` (`Config`) and `spinifex/ingress/aws/dispatch` (`AWSOperationInventory`); the gateway layering rows are retired and R2 binds the pipeline |
| `spinifex#1334` | `spinifex/gateway/oidc_discovery.go` (`eksAccountKV`, the discovery-document and JWKS read and construction) | `spinifex/domains/eks/cluster` (`OIDCPublisher`, `OIDCDiscoveryDocument`), composed by `spinifex/runtime/roles/awsgw` | The EKS domain now reads its own account bucket and builds the per-cluster discovery document and JWKS responses (ADR-0005:S4), and the gateway only maps the unauthenticated `/oidc/eks/...` routes onto an `OIDCPublisher` interface it is handed. Responses are byte-identical, pinned by `oidc_discovery_current_behaviour_test.go`, whose assertions are unchanged. The gateway no longer imports `domains/eks/cluster` in production code. `STS` keeps its own `ClusterJWKSResolver` (`OIDCJWKSReader`), untouched. Recorded debt, not repaired here: a stored JWKS is served verbatim with 200 even when it is not valid JSON. |
| `spinifex#1245`, `spinifex#1390` | — | `contracts/admission/v1` | The quota override seal route (`admission.v1.SealAccountQuotaOverride`, served by `awsgw`) and the per-gateway read-only capability probe (`admission.v1.AccountQuotaOverrideSealCapability.<node>`) are an explicit versioned cross-process contract between the quota owner and `operator/accountlifecycle`. The consumer wiring is new: `NewClusterEngine` takes a `ClusterNodes` value with separate daemon and gateway node sets, `ClusterConfig.NodesRunning` is the single definition of which nodes run a service, and the seal reaper runs in `StageAccount` after the EC2 settings seal. A termination whose gateway probe is incomplete seals nothing and leaves the account `TERMINATING`. Contract, responder, probe and reaper tests pin the subjects, JSON shapes and fail-closed cases; `TestAccountTermination_QuotaOverrideEndsSealed` replaces the former characterization that overrides survive teardown. |
The EC2 contract row is intentionally different from the directory moves: it creates
a compatibility boundary. `ec2.cmd.*` retains its deployed one-token NATS
subscription and permission shape, while callers use
@@ -387,9 +406,9 @@ the canonical IAM/STS principal before dispatch, because ECR must not depend on
gateway identity context or ARN construction.
All implemented ECR control-plane actions now dispatch through explicit ECR
capabilities. The remaining ECR-named gateway code is generic `/v2/*` route
assembly, SigV4/registry authentication and policy enforcement; it remains in
gateway rather than inverting those dependencies into the ECR domain.
capabilities. The OCI `/v2` bridge, its token exchange and its principal
resolution now live in `domains/ecr/registry`; the gateway keeps only the
`RegistryHandler` mount, and `awsgw` composes the bridge.
### ACM relocation residuals
@@ -438,7 +457,7 @@ These residuals were recorded by individual slices and must be resolved once the
- `runtime/roles/vpcd` (placement): the role still mixes process/role supervision with network-controller responsibilities. `lease_reaper.go` (orphaned external-pool DHCP lease sweep), `ovn_controller_watchdog.go` (OVN controller liveness and restart), `host_eip_loop.go` (host-EIP reconcile loop) and `lease_datapath_reconcile.go` (lease datapath reconcile) are periodic network-control loops, not role wiring; `vpcd.go` itself also carries bridge-mode detection and verification, chassis-list parsing, external-CIDR resolution, DHCP manager startup and lease/IP-change handling, and gateway/host-EIP allocator and neighbour flush/prime construction alongside `Service.Start`. The future placement question is whether these belong in `domains/network`. Removal condition: an accepted decision on network-controller placement, and extraction of those loops and helpers behind the role.
- ECR control-plane registration: `awsapi.Deps.NATS` is a raw `*nats.Conn`, not a narrow capability. Every NATS-backed `Handler` passes it to `domains/ecr.NewNATSMetaStore`, which calls `natsmsg.NATSRequest`, so narrowing it means changing the exported `Handler` type, every handler and the meta store; it must not be broadened further meanwhile.
- ACM control-plane registration: `domains/acm/awsapi.Deps.NATS` (`registration.go`) is likewise a raw `*nats.Conn` carried by every `Handler`, for the same reason; it must not be broadened further meanwhile.
- `cmd/aws-model-coverage` imports `ingress/aws/dispatch`, `domains/ecr/awsapi`, `domains/acm/awsapi` and `gateway` to read ECR's and ACM's declared inventories. It is a non-serving tool and the only named exception to the ADR-0002 composition invariant; it must not construct a registration.
- `cmd/aws-model-coverage` imports `ingress/aws/dispatch`, `gateway` and the `awsapi` package of each registered service (`domains/ecr/awsapi`, `domains/acm/awsapi`, `domains/route53/awsapi`, `domains/elbv2/awsapi`, `domains/rds/awsapi`, `domains/ecs/awsapi`, `domains/eks/awsapi`) to read their declared inventories. It is a non-serving tool and the only named exception to the ADR-0002 composition invariant; it must not construct a registration.
- Ingress pipeline (ADR-0002 phase 4): the request-audit record, the trace action enricher, the throttle key functions and the gateway request-context keys stay in `gateway` until the auth/IAM and request-context boundary is designed. SigV4 authentication writes the audit record and context keys from four places, so moving them now would export exactly the setters ADR-0002 says must not be exported solely to make a directory move possible.
- NAT projection delivery is inconsistent and is preserved as found: EIP `AssociateAddress` sends `vpc.add-nat` unacknowledged (`AnnounceNATAdd`), skipping the 45s OVN barrier every other add path waits on, and `DeleteNetworkInterface` sends `vpc.delete-nat` unacknowledged (`AnnounceNATDelete`), skipping the ack and the ErrNoResponders retry. The reconciler's orphan sweep is the only backstop for both. Clears when those call sites use `AddNAT`/`AddNATBestEffort` and `RemoveNAT`, a reliability change of its own.
- `vpc.add-system-egress` has a `vpcd` subscriber and a contract entry but no producer (its former `domains/ec2/eip` publisher is gone); `domains/eks/cluster` still publishes the delete. Either a producer is missing or the add route is dead; it needs an owner decision.
@@ -446,7 +465,7 @@ These residuals were recorded by individual slices and must be resolved once the
- `foundation/messaging/nats/nats_trace.go` still names its OpenTelemetry tracer `github.com/mulgadc/spinifex/spinifex/utils`. It is an emitted instrumentation-scope name that dashboards may filter on, so renaming it is a telemetry change, not a path fix.
- `runtime/compute/cache.New`: two construction-time panics carry a lint exemption pending a constructor that returns an error.
- `domains/ec2/systeminstance`: managed-service parent metadata is only the `ManagedBy` tag value.
- `domains/ochre` (action adapters): since `spinifex#1266` the Bedrock action layer (handlers, action authorization, event-stream framing and the CRUD, catalogue and Converse/Invoke entry points) shares package `ochre` with the domain core, stores and inference engine, and imports `ingress/aws/bodyscope`. Moving it to `domains/ochre/awsapi` is not mechanical: the adapters reach package-private store internals (the guardrail and provisioned-throughput store handles and get/put/update methods), and adapter tests override the package-private model catalogue. It needs core capabilities first, then the move; the top-level `gateway/bedrock*.go` HTTP handlers additionally wait for the ingress registration seam.
- `domains/ochre` (action adapters): cleared by the Bedrock registration in `spinifex#1351`. The SDK-typed adapters and `authz.go` moved to `domains/ochre/awsapi`, the `ingress/aws/bodyscope` allowlist row is deleted, and the adapters take the concrete exported Ochre stores through `awsapi.Deps` as a temporary edge until Ochre exposes capability interfaces for them.
- `domains/network`: `reconcile/intent.go` builds desired network state by reading EC2's KV buckets and decoding EC2's private VPC, subnet, ENI, route-table, gateway, EIP, NAT and security-group records, and `reconcile/drift.go` watches those buckets. ADR-0001 names this pair explicitly: EC2 and network communicate through a capability/projection or versioned contract, never by one being the other's KV reader. Step 7 fix: an EC2-owned `domains/ec2/projection` exposing network intent with list and watch (it may stay KV-backed, so no data moves); a narrow intent interface declared by `reconcile`; wiring at the composition root; and the duplicated `PurposeEIP`/`PurposeIGWLRP` constants and mirrored `SGRule`/`SGEvent` folded into the projection or `contracts/ec2/v1`. The drift-watch coverage test and reconcile scenario tests are the evidence the projection must keep. `domains/network` also imports `runtime/compute/vm` and `runtime/host/command` (domain-to-runtime).
- `domains/ec2/instance`: imports `runtime/compute/vm` and `runtime/compute/gpu` (domain-to-runtime) and `domains/dns` (cross-domain implementation import).
- `domains/ec2/instance/state` (dependency): the vpcd IMDS local reader imports `runtime/compute/vm` to return `vm.VM`. Removal condition: that type moves into a domain-owned package that `runtime/compute/vm` consumes (runtime to domain is allowed).
@@ -456,14 +475,12 @@ These residuals were recorded by individual slices and must be resolved once the
- `operator/installer/firstboot` (security): `Config.InstallCallback` is rendered into `spinifex-firstboot.sh`, which is world-readable (0755). The field is currently unused (`fireInstallCallback` reads `SPINIFEX_INSTALL_CALLBACK` from the environment instead), but any future wiring must not place secrets or tokenised callback URLs in the generated script; sensitive callback credentials should reach first boot through a protected channel such as a 0600 file or a credential store.
- `operator/cli` (known security risk): `spx admin join` contacts the formation server with `InsecureSkipVerify`, sends the join token as a bearer credential and receives the cluster CA certificate and key, master key, Viperblock key and shared credentials, so an on-path attacker during formation can capture the token and the cluster root secrets or substitute a CA. This predates the move; lint surfaced it when the tree left `cmd/`. It is accepted for now and carries a line-level gosec annotation stating it is not a false positive. Removal condition: authenticate formation TLS with a pinned key or certificate delivered with the join material, or an out-of-band CA, with tests proving a mismatched server certificate fails before credentials are sent.
- `domains/eks/cluster` (potential security risk): `ClusterMeta.ControlPlaneTemplate` (`domains/eks/cluster/service_impl.go`) clears the template's credentials but keeps `OIDCPrivateKeyPEM` and the k3s `JoinToken`, so both are persisted in the `eks-account-{acct}` cluster record, which the gateway also reads. Whether that record is encrypted at rest is unconfirmed; if it is not, a reader of the bucket can forge service-account tokens for the cluster's OIDC issuer and join nodes to it. This predates the refactor and is not fixed in it. Removal condition: the key and token are held by a dedicated secret store (a future KMS or Secrets Manager capability is the likely owner) and the cluster record keeps only a reference, with a test proving the persisted record contains neither secret.
- `operator/cli` (dependency): the command tree still imports domain and service implementations directly (`handlers/{iam,eks}`, `domains/admission/quota`, `gateway`, `domains/ochre`, `vpcd`, the Viperblock adapter and runtime roles). The ADR's `cmd/spinifex` current-to-target map row requires command packages to reach domains through public capabilities; splitting supported use cases into `operator/*` packages is later work.
- `operator/cli` (dependency): the command tree still imports domain and service implementations directly (`handlers/{iam,eks}`, `domains/admission/quota`, `domains/ochre`, `vpcd`, the Viperblock adapter and runtime roles). The ADR's `cmd/spinifex` current-to-target map row requires command packages to reach domains through public capabilities; splitting supported use cases into `operator/*` packages is later work.
- `providers/ebs/viperblock` → `runtime/compute/nbd` and `runtime/host/process` (dependency, moved unchanged from `services/viperblockd`): the adapter formats NBD endpoints and manages nbdkit processes through runtime packages. ADR-0001 does not list providers → runtime as forbidden and the layering test does not enforce it (same category as `providers/ebs/conformance` → `runtime/compute/nbd`), but a provider should not depend on node runtime. Clears when NBD endpoint naming has a provider-facing home (a candidate for `contracts/ebs/v1`) and process supervision is injected by the role. The adapter also imports `providers/objectstore`, a provider-to-provider edge that is allowed.
- `gateway/ec2.go` (registration): the EC2 dispatch map stays a `GatewayConfig` method in `gateway` and imports every `domains/ec2/awsapi/*` package; `gateway/ec2_idempotency.go` also imports `domains/ec2/awsapi/idem` (`KVBucket`, `TTL`, `TokenAndParams`) for the dispatch-time idempotency wrapper. ADR-0001 makes the domain own its action registration; moving the map waits on the generic ingress registration seam and must not be done as a package move.
- `gateway` (service registration): the root `gateway` dispatch for IAM, STS, EKS, RDS, ECS and ELBv2 imports `domains/{iam,sts,eks,rds,ecs,elbv2}/awsapi`, which only AWSGW may wire. It clears when each service registers through the ADR-0002 ingress registration seam; as with EC2, moving the dispatch must not be done as a package move.
- `gateway` (service registration): the root `gateway` dispatch for IAM, STS, EKS, RDS and ECS imports `domains/{iam,sts,eks,rds,ecs}/awsapi`, which only AWSGW may wire. It clears when each service registers through the ADR-0002 ingress registration seam; as with EC2, moving the dispatch must not be done as a package move.
- `operator/accountlifecycle` (dependency): its EC2 reapers call `domains/ec2/awsapi/*` action adapters directly, as they did under `gateway/ec2`. The amended ADR-0001 `accountteardown` row keeps replacing these with domain-owned cleanup capabilities as deferred debt; it was not a relocation prerequisite. It also holds a read-only consumer of the EC2 platform-inventory contract (`contracts/ec2/v1`, `ec2.v1.PlatformInventory`), served by `domains/ec2/accountinventory`; only the explicit dry run (`Engine.DryRun`) calls it, to log a shadow comparison, and the key-pair, placement-group and launch-template reapers still use the `awsapi` adapters. A domain-owned cleanup capability for the proven-safe subset of those three kinds (key pairs and empty placement groups no instance, spot request or live launch-template version names, and launch-template residue; never live launch templates) exists as `domains/ec2/accountcleanup` (a Go interface with injected owners and reference readers, no NATS subject); nothing calls it yet. It also holds the StageAccount settings-seal reaper, a consumer of `contracts/ec2/v1`'s `ec2.v1.SealAccountSettings` and per-node `ec2.v1.AccountSettingsSealCapability.<node>` subjects, served by `domains/ec2/account` through the daemon; the reaper reads no KV and imports no domain package, reaching both subjects only through NATS and the configured node set `NewClusterEngine` already receives.
- `domains/admission/quota` (dependency, moved from `handlers/quota` unchanged): `live.go` imports `domains/elbv2/awsapi` and `domains/rds/instance` for its live ELBv2 and RDS counters; `records.go` imports `runtime/compute/vm` for the instance-record vCPU sweep; `live.go`, `vcpu.go` and `storage.go` call `domains/ec2/awsapi/{vpc,eip,volume}` describe adapters to count VPCs, subnets, addresses and volumes, as they did under `gateway/ec2`. The ADR's `handlers/quota` current-to-target map row replaces these with reader capabilities once their final locations exist.
- `domains/ec2/awsapi/instance` (dependency): imports `domains/iam` (`IAMService` in `DescribeInstances.go`, `RunInstances.go` and `IamInstanceProfileAssociation.go`; `InstanceProfile` in the latter two), `runtime/compute/cache` (`NodeState`, `NodeUnknown`, `NodeLive`) and `runtime/compute/vm` (`VM`), both in `status_synthesis.go`. The IAM edge needs an IAM capability; the runtime edges are domain-to-runtime and need the instance record and node liveness exposed through an EC2-owned projection.
- `domains/ec2/awsapi/spotinstance` (dependency): imports `domains/iam.IAMService` and `domains/admission/quota.Service` in `RequestSpotInstances.go`.
- `domains/ec2/awsapi/instance` (dependency): imports `domains/iam` for the `InstanceProfile` type that `InstanceProfiles.ResolveInstanceProfile` returns (`RunInstances.go`, `IamInstanceProfileAssociation.go`), `runtime/compute/cache` (`NodeState`, `NodeUnknown`, `NodeLive`) and `runtime/compute/vm` (`VM`), both in `status_synthesis.go`. The IAM edge is now the two-method `InstanceProfiles` interface plus that type; the runtime edges are domain-to-runtime and need the instance record and node liveness exposed through an EC2-owned projection.
- Instance adapter consumers outside EC2: `domains/rds/awsapi/enginecatalog.go`, `runtime/daemon/eks_cp_control.go`, `runtime/daemon/rds_deps.go`, `domains/admission/quota`'s `vcpu.go` and `gateway/iam.go` (`CountInstanceProfileAssociations`) call `domains/ec2/awsapi/instance` directly; each needs an EC2 capability rather than the AWS action adapter.
- `runtime/daemon` (Q-28 ownership and deployment): the daemon moved whole, keeping its current ownership, deployment and `daemon → gateway → handlers` dependencies. Q-28's write-side consolidation and any daemon decomposition or split remain deferred by ADR-0001 and are not paid by the move.
@@ -472,8 +489,8 @@ Enforcement: the owner decided the layering test is blocking now, with a ratchet
- `domains/rds/instance` → `domains/rds/parametergroup` (legacy edges): `parameterGroupReferences` binds `Dependants` by scanning instance records; this clears when the RDS instance gains its own owner and answers `Dependants` from it. `tags.go` writes the meta record through its generic tag registry; this clears when tagging invokes a parameter-group-owned tag mutation capability, and moving the tagging adapter to `domains/rds/awsapi` alone does not clear it. The Modify handler still calls `propagateParameterGroup` after the owner's write; propagation is instance-side realization and stays legacy until it is designed as asynchronous realization (group generation, instance staleness, retry). `parameterGroupValues` returns the persisted `Record` and overrides to instance code; when the instance owner is extracted, replace them with a consumer-owned resolved-parameters projection.
- `domains/ecs` → `domains/ecs/taskdefinition` (owner bypass): `tags.go` reads and writes the revision record through its generic tag registry; this clears when ECS tagging invokes an owner-provided tag mutation capability, and moving `tags.go` to `domains/ecs/awsapi` alone does not clear it. `taskDefinitionResolver` returns the persisted `Record` to task and service launch code; when the task and service owners are extracted, replace the record crossing that boundary with a consumer-owned resolved task-definition (launch-spec) projection. Moving packages has not made the record private.
- `domains/ecs/taskdefinition` (deliberate duplication, not debt): private `getJSON`, `putJSON` and `keysWithPrefix` copy the raw-KV helpers in `domains/ecs` so the extraction keeps ECS's get/put/list error and recovery semantics. Revisit only if a later, tested state abstraction proves it preserves those semantics; do not migrate ECS to `kvstore` merely to remove them.
- `domains/eks/cluster` → `domains/eks/access` (legacy edges): `ResolveTokenReview` opens the EKS bucket in the gateway process and reads records through the exported `access.Get`; this clears when token review asks the owner across a contract rather than opening the bucket itself. `DeleteClusterPrefix` erases access-entry keys as part of its blanket cluster sweep; this clears when cluster delete invokes an owner-provided removal of the cluster's entries. The owner returns the persisted `Record` to the AWS adapter and token review; replace it with consumer-owned projections when those consumers are extracted.
- `domains/eks/cluster` → `domains/eks/addon` (legacy edges): `DeleteClusterPrefix` erases add-on records and manifests in its blanket cluster sweep; this clears when cluster delete invokes an add-on-owned teardown capability. `stageGPUDeviceAddon` is a temporary node-group-to-add-on orchestration edge: best-effort, errors logged and swallowed, never undone on node-group delete; it clears when the node-group owner is extracted and the dependency is designed. `ClusterReconciler` hosts the add-on status subscription; it clears when add-on delivery observation has its own host under the lifecycle correction. Cluster existence is checked in the handler, not through an owner-declared capability; it moves into the owner when cluster admission is defined (today status is ignored). Guest wire types (`AddonStatusReport`, `AddonDeliveryPhase`, `AddonStatusSubject`, `StagedAddonManifest`, `InternalAddonsResponse`) are owned by `contracts/eks/v1`, which `domains/eks/cluster`, `gateway/eks` and `agents/eks/gatewayfetch` import directly.
- `domains/eks/cluster` → `domains/eks/access` (legacy edges): `ResolveTokenReview` opens the EKS bucket in the gateway process and reads records through the exported `access.Get`; this clears when token review asks the owner across a contract rather than opening the bucket itself. `DeleteClusterChildren` erases access-entry keys as part of its blanket cluster sweep; this clears when cluster delete invokes an owner-provided removal of the cluster's entries. The owner returns the persisted `Record` to the AWS adapter and token review; replace it with consumer-owned projections when those consumers are extracted.
- `domains/eks/cluster` → `domains/eks/addon` (legacy edges): `DeleteClusterChildren` erases add-on records and manifests in its blanket cluster sweep; this clears when cluster delete invokes an add-on-owned teardown capability. `stageGPUDeviceAddon` is a temporary node-group-to-add-on orchestration edge: best-effort, errors logged and swallowed, never undone on node-group delete; it clears when the node-group owner is extracted and the dependency is designed. `ClusterReconciler` hosts the add-on status subscription; it clears when add-on delivery observation has its own host under the lifecycle correction. Cluster existence is checked in the handler, not through an owner-declared capability; it moves into the owner when cluster admission is defined (today status is ignored). Guest wire types (`AddonStatusReport`, `AddonDeliveryPhase`, `AddonStatusSubject`, `StagedAddonManifest`, `InternalAddonsResponse`) are owned by `contracts/eks/v1`, which `domains/eks/cluster`, `gateway/eks` and `agents/eks/gatewayfetch` import directly.
- `domains/eks/cluster` and `domains/eks/awsapi` → `domains/eks/addon` (further edges): the AWS adapter reads the persisted `Record` through `addon.Get`/`addon.List`; this clears when the owner returns a view and the add-on adapter moves to `domains/eks/awsapi`. The guest route reaches the owner through the `handlers_eks` NATS RPC (`ListStagedAddonManifests`); this clears with per-resource commands behind EKS registration. The add-on ARN is derived in both the owner and `domains/eks/awsapi/authz.go`; this clears when authorization obtains the ARN from the owner, which an incarnation suffix would require. The owner's `Manifest`/`Report` beside the `contracts/eks/v1` types is deliberate duplication, not debt.
- `agents/rds/agent` (dependency, narrowed): engine metadata now arrives through the agent-declared `EngineCatalog`, but the agent still imports `domains/rds/instance` for the command protocol, wire and record types (`Command*`, `Parameter`, `EngineHealth*`, `BootstrapMode*`) and API output types. This clears when those move behind a guest/controller wire contract.
- `domains/rds/awsapi` → `domains/rds/instance` (`InstanceSizing`): the gateway obtains its `Sizing` through the RDS handler package's adapter rather than its own composition root. This clears when the gateway's RDS composition moves to `runtime/roles/awsgw` and builds `Sizing` there.
@@ -672,12 +689,12 @@ series above; "needs decision" is open.
| `handlers/rds` | — | Done. The remaining RDS instance resource moved to `domains/rds/instance` (package `instance`) in `spinifex#1251` with no API, NATS subject, storage or behaviour change, fulfilling ADR-0001's organise-by-resource condition alongside the already-extracted subnet group, parameter group and engine catalogue. The ADR-0004 blocked-dependency imports (quota admission, the duplicated expected-node fan-out, the IAM ensurer and the Bluebottle master key) remain dependency-rule debt, with their allowlist entries now sourced from `domains/rds/instance`. |
| `handlers/elbv2` | — | Done. The implementation moved to `domains/elbv2` (package `elbv2`) with no API, NATS subject, storage or behaviour change. Q-30's data-plane boundary remains deferred debt; the amended ADR-0001 row makes it not a relocation prerequisite. Its cross-domain, IAM and `agents/elbv2` imports remain dependency-rule debt, with their allowlist entries now sourced from `domains/elbv2`. |
| `handlers/iam/mock` | — | Done. Moved with the implementation to `domains/iam/mock`. |
| `gateway` (root) | Generic HTTP route assembly, SigV4/registry authentication, the policy gate, request-audit middleware, request-context keys, and the legacy per-service dispatch switch for every service not yet registered. | Deferred: the ADR-0002 phases above (auth/IAM and request-context boundary design) gate moving the audit, context-key and legacy-dispatch mechanics. |
| `gateway/bedrock` | — | Done. The whole package moved into `domains/ochre` in `spinifex#1266` with its package-private core and tests intact and no behaviour change. Separating the action adapters into `domains/ochre/awsapi` remains deferred, recorded as `domains/ochre` (action adapters) debt above. |
| `gateway` (root) | Generic HTTP route assembly, SigV4 authentication, the policy gate, request-audit middleware and request-context keys. | Done and deleted. The request pipeline moved into `ingress/aws/pipeline` (as `pipeline.Config`) and the operation inventory into `ingress/aws/dispatch` in `spinifex#1384`; the `/oidc`, `/v2` and node-discovery code had already moved to `runtime/roles/awsgw` in `spinifex#1378`. The layering test binds the pipeline with R2 and `gateway` is a retired legacy root. |
| `gateway/bedrock` | — | Done. The whole package moved into `domains/ochre` in `spinifex#1266` with its package-private core and tests intact and no behaviour change. Separating the action adapters into `domains/ochre/awsapi` remains deferred, recorded as `domains/ochre` (action adapters) debt above. The action adapters then moved to `domains/ochre/awsapi` behind one ADR-0002 registration in `spinifex#1351`. |
| `gateway/{iam,sts,eks,rds}` | — | Done. The action adapters moved to `domains/{iam,sts,eks,rds}/awsapi` in `spinifex#1265` with no API, NATS subject, storage or behaviour change. ADR-0001 does not make ADR-0002 registration a prerequisite for relocating a domain's adapters, as the EC2 adapter moves already showed; the root `gateway` dispatch that still imports them is recorded `gateway` (service registration) debt. |
| `gateway/{ecs,elbv2}` | — | Done. The action adapters moved to `domains/{ecs,elbv2}/awsapi` in `spinifex#1273` with no API, NATS subject, storage or behaviour change; the root `gateway` dispatch that still imports them is recorded `gateway` (service registration) debt. |
| `gateway/spx` | `promote_image.go` and `spx.go` dispatch (`storage.go` already consumes `contracts/operator/v1`). | Needs decision: which package owns operator-plane action dispatch registration — an `ingress/aws`-style seam for the operator plane, or `gateway` stays its home. |
| `gateway/tagging` | — | Done. `domains/tagging` owns the read-only cross-resource `GetResources` aggregation and its API semantics (`0d830e901`, `aedd0b2c4`); AWSGW composes the owner listers, the EC2 tag index and ELBv2 (`f3545e3ea`); the root `gateway` keeps only the legacy dispatch in `gateway/tagging.go`. The package is removed, and its two allowlist rows and frozen-legacy entry are gone. Owner tag mutation capabilities remain future direction, so the RDS and ECS tag-writing edges above stay open. |
| `gateway/spx` | — | Done. The gather helpers, storage-status config and image-promote request moved to `operator/admin` with no behaviour change and the package is removed. The `spinifex` Query dispatch and the `/admin/{method}` handlers that call them now live in `operator/admin` as one seam registration. |
| `gateway/tagging` | — | Done. `domains/tagging` owns the read-only cross-resource `GetResources` aggregation and its API semantics (`0d830e901`, `aedd0b2c4`); AWSGW composes the owner listers, the EC2 tag index and ELBv2 (`f3545e3ea`); the dispatch moved to `domains/tagging/awsapi` and the root `gateway` no longer serves Tagging. The package is removed, and its two allowlist rows and frozen-legacy entry are gone. Owner tag mutation capabilities remain future direction, so the RDS and ECS tag-writing edges above stay open. |
| `services/viperblockd` | — | Done. The adapter is `providers/ebs/viperblock` (with the read-only `dirty` reader), the shared vocabulary is `contracts/viperblockd/legacy/v1`, and the role is `runtime/roles/viperblockd`. `services/` is empty. |
| `utils` | — | Done and deleted. `ec2_subject.go` went to `contracts/ec2/v1`, the vpcd protocol to `contracts/network/v1` with the typed client in `domains/network/projection`, and the master-key loader to `domains/ec2/ebs/encryption`. |
| `vpcd` | — | Done. The IMDS local-file reader and record-space opener moved to `domains/ec2/instance/state` and are injected through `guestmetadata.LocalStateReader`/`RecordLoader` (recorded above), so `vpcd` no longer imports `daemon` directly; its inter-process boundary now exists (`contracts/network/v1`, with `domains/network/subscribers` as the consumer and `domains/network/projection` as the producer client). The package moved unchanged to `runtime/roles/vpcd` (recorded below), and the legacy directory is removed. |
@@ -696,7 +713,7 @@ ADR-0001 completion is structural, so none of these is cleared by remediating se
| Class | Paths | Status |
|---|---|---|
| No missing boundary (candidate moves) | `handlers/ecs/bus` → `contracts/ecs/v1` (`a130f6b8e`); `admin/images_promote.go` → `domains/ec2/image` (`263c58167`); `admin` bootstrap account identifiers → `foundation/aws/identifiers` (`3fdaa54d9`); `admin` credential-format generators → `foundation/aws/credentials` `0368e861a`, retiring `admin` | Done. |
| One narrow missing boundary | Done: `handlers/sts` EKS JWKS lookup → `handlers/eks` `ClusterJWKSResolver` (`254d898a3`); `gateway/tagging` → `domains/tagging` with AWSGW-composed listers (`0d830e901`, `f3545e3ea`, `aedd0b2c4`). Open: `gateway/spx` (owner of operator-plane dispatch registration) | Each needs one decision or one capability, then a mechanical move. |
| One narrow missing boundary | Done: `handlers/sts` EKS JWKS lookup → `handlers/eks` `ClusterJWKSResolver` (`254d898a3`); `gateway/tagging` → `domains/tagging` with AWSGW-composed listers (`0d830e901`, `f3545e3ea`, `aedd0b2c4`); `gateway/spx` → `operator/admin`. Done: the `spinifex` service and `/admin` dispatch registration, owned by `operator/admin` | Each needs one decision or one capability, then a mechanical move. |
| Broader boundary or follows another package | `gateway` root (ADR-0002 auth and request-context phase) | Recorded debt with removal conditions above. |
Per-consumer IAM capabilities are the widest unblocker: they gate STS's IAM reads, Ochre's IAM reads, the ELBv2 role path and the ECS, EKS and RDS ensurers.
+6 -3
View File
@@ -73,7 +73,6 @@ The cluster plane is peer-scoped. On a single-NIC node the planes collapse onto
| Ports | Serving |
|---|---|
| TCP 4222, 4248 | NATS client and cluster routes |
| TCP 4223, 4249 | SQS message store client and cluster routes |
| TCP 4432 | Formation, then the daemon cluster manager |
| TCP and UDP 5300 | Internal DNS |
| TCP 6641, 6642 | OVN northbound and southbound |
@@ -156,8 +155,11 @@ Restarting the daemon is what re-arms it: the node rebuilds its peer list from t
`disable` removes Spinifex's own table and the peer file and leaves every other nftables table alone. It is idempotent, and it is the recovery path — it runs even on a node with no policy installed, so it cannot leave you with a node you can neither arm nor disarm.
`disable` leaves `table inet spinifex_conntrack` loaded, and the boot-time unit loads it on every node whether or not the policy is armed. It accepts everything and exists only to keep connection tracking running. Without it, a connection opened while the policy is off, such as an OVN RAFT link formed during that window, is first seen mid-stream when the policy comes back, and its replies are dropped as new connections. Do not delete it.
> [!WARNING]
> Do not flush the whole nftables ruleset to clear the policy. The VPC daemon writes SNAT and per-elastic-IP forward rules into the `ip nat` and `ip filter` tables and reinstalls them only when it starts, so `nft flush ruleset` breaks elastic IPs silently until the next `systemctl restart spinifex-vpcd`. Use `disable`, which only ever touches `inet spinifex_filter`.
> On Oracle Linux with IPsec on, a flush also removes `inet spinifex_ipsec_guard`, so Geneve to a peer without an SA goes out in clear until the daemon puts the table back, within 60 seconds.
## The Files
@@ -171,6 +173,7 @@ Everything lives under `/etc/spinifex/firewall/`. Which files are safe to edit f
| `open-ports.nft` | The apply helper — the formation window only | No. Reset closed on install and upgrade |
| `custom.nft` | **You** — `trusted_ssh_peers` and anything else you add | **Yes.** Created once, never rewritten |
| `mode` | `setup.sh` — whether the install path armed the policy | No |
| `conntrack.nft` | `setup.sh` — the tracking table, loaded armed or not | No. Edits are overwritten |
The daemon cannot write `/etc` itself, and a blanket `nft` grant would be root-equivalent, so changes go through `/usr/local/lib/spinifex/spinifex-firewall-apply` — a root helper that accepts a fixed set of verbs and re-validates every address handed to it.
@@ -190,10 +193,10 @@ Dropped packets are logged, rate-limited to five a minute, so this tells you whe
sudo journalctl -k | grep 'spinifex-fw drop'
```
An empty log with a connection still failing means the policy is not the cause. A node with no table loaded at all is not armed:
An empty log with a connection still failing means the policy is not the cause. A node with no peer file is not armed, and its boot-time unit loads only the tracking table:
```bash
systemctl is-enabled spinifex-firewall.service
sudo test -e /etc/spinifex/firewall/peers.nft && echo armed || echo not armed
```
## Troubleshooting
+1 -1
View File
@@ -106,7 +106,7 @@ done
| `spinifex-rds-postgres` | RDS, `--engine postgres` | Alpine 3.24.1 + PostgreSQL 18 + `rds-init` |
| `spinifex-rds-mariadb` | RDS, `--engine mariadb` | Alpine 3.24.1 + MariaDB 11.8 + `rds-init` |
| `spinifex-ecs-node` | ECS container instances | Alpine 3.21.7 + containerd + ecs-agent |
| `spinifex-eks-node` | EKS control plane and nodegroups | Alpine 3.21.7 + K3s v1.32.5 + eks-token-webhook |
| `spinifex-eks-node` | EKS control plane and nodegroups | Alpine 3.21.7 + K3s v1.32.5 + eks-token-webhook + eks-etcd-member |
All four boot **BIOS**, unlike the distro images, which boot UEFI. That is a property of how they are built and nothing you need to pass.
+5 -1
View File
@@ -150,13 +150,17 @@ aws ec2 describe-instance-types \
--query "InstanceTypes[?InstanceType=='t3.small']"
```
Show capacity (how many of each type can still be launched):
List only the types that can still be launched:
```bash
aws ec2 describe-instance-types \
--filters Name=capacity,Values=true
```
Each type appears once, whatever its free capacity.
This filter is a Spinifex extension and AWS does not declare it, so it is ignored by a region that does not implement it.
AWS reports exhaustion at launch instead, with `InsufficientInstanceCapacity`, and so does Spinifex.
## SSH
To SSH into instances via their public IPs, see [Setting Up Your Cluster](/docs/setting-up-your-cluster).
+21
View File
@@ -144,6 +144,27 @@ The guest MTU moves with the setting, but a running guest holds its DHCP lease f
**Expect launches to fail for a minute or two after re-enabling.** `RunInstances` returns `ServerInternal` with `vpc.add-nat` timing out, because the OVN flows-ready barrier cannot confirm while `ovn-controller` reprograms every tunnel for the new SAs. It clears once `ip xfrm state` settles. The error is reported as a capacity problem, which it is not.
### Containers and nested overlays inside a guest
A Docker bridge, Kubernetes pod network or other overlay inside a guest defaults to an MTU of 1500, which is larger than the guest's 1408 or 1442.
Downloads from the internet still work for TCP on the veth and routed uplinks, because vpcd clamps the TCP MSS on the host hop (see [Performance Tuning → MTU](/docs/performance-tuning#4-mtu)).
That was measured on a single node (env12) with a Docker container; it was not measured on a physical uplink or on a multi-node cluster.
On a physical uplink there is no host hop to clamp, and UDP is never clamped, so set the inner MTU to the guest MTU.
For Docker, in `/etc/docker/daemon.json`:
```json
{
"mtu": 1408,
"default-network-opts": {
"bridge": { "com.docker.network.driver.mtu": "1408" }
}
}
```
Use 1442 when `ipsec_enabled = false`, and restart the Docker daemon so the default bridge is recreated.
The gateway MTU follows `ipsec_enabled` and converges on the next reconcile pass, so change the inner MTU together with the guest MTU.
## Private vs Public Subnets
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.
+67 -1
View File
@@ -45,7 +45,8 @@ Each repository holds image manifests and their layers. Images are stored in obj
- 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/`.
- OCI 1.1 referrers — the referrers API, so cosign v3 signatures and other artifacts attached to an image can be listed (see [OCI referrers and image signing](#oci-referrers-and-image-signing)).
- Token auth — `GetAuthorizationToken` plus bearer/basic auth on `/v2/`. A token is valid for 12 hours. One minted by an assumed-role session (an EKS node, for example) stays valid after that session expires and is checked against the live role on every request, so it stops working when the role is deleted or loses its ECR permissions.
- Repository policies — set, get, and delete a repository policy document.
**Current limitations**
@@ -176,6 +177,71 @@ Then authenticate Docker and push using the `repository_url` output, following s
:::
## EKS workers and multiple gateways
Each gateway puts its own address in the `repositoryUri` it returns, so repositories created through different gateways can carry different hosts.
When an EKS worker launches, the daemon that launches it writes every cluster gateway into the worker's registry mirrors, TLS trust and credential-provider config.
A pod can then pull from any `repositoryUri` host in the cluster.
The worker tries the gateway named in the image reference first and falls back to the others in cluster order.
Limits:
- The gateway list comes from the cluster config the launching daemon loaded at start.
A gateway added later is not trusted by existing workers; restart the launching daemon and replace the worker.
- Peer gateways are assumed to listen on port 9999.
A hand-edited `awsgw` bind on a peer is not covered.
- A gateway address that does not answer costs about 30 seconds on each cold pull while the worker's connection attempt gives up.
A stopped gateway on a host that is still up refuses the connection, and the worker moves to the next gateway at once.
Measured on a single-node environment: pulls by `repositoryUri` and by the account's ECR DNS name, a pull rejected with `x509: certificate signed by unknown authority` when the gateway host was not in the worker config, and a 31 second pull through an unreachable first endpoint.
Measured on a 3-node environment: a pod pulling each of the three gateways' `repositoryUri` on a worker launched with this change (two of the three failed with `x509` on a worker launched without it), and a pull of the stopped gateway's own `repositoryUri` that succeeded through another gateway within one retry (the pull itself took 147 ms after the first endpoint refused).
## OCI referrers and image signing
A manifest can name another manifest as its `subject`, which is how tools such as cosign v3 attach signatures and SBOMs to an image.
The registry records the subject when the manifest is pushed and answers with an `OCI-Subject` header on the `201`.
The `subject` does not have to exist in the repository when the referrer is pushed.
`GET /v2/<name>/referrers/<digest>` lists the manifests that name `<digest>` as their subject.
The request is authorised as `ecr:BatchGetImage` on the repository.
The response is `200` with `Content-Type: application/vnd.oci.image.index.v1+json` and an index whose `manifests` hold each referrer's `mediaType`, `digest`, `size`, `artifactType` and annotations.
A digest with no referrers, or one that is not stored, returns an empty index.
A malformed digest returns `400` with `DIGEST_INVALID`.
Adding `?artifactType=<type>` returns only referrers of that type and sets `OCI-Filters-Applied: artifactType`.
The header is also set when nothing matches.
An image manifest without an `artifactType` is listed under its config `mediaType`; an index without one has none.
Deleting an image by digest (`DELETE /v2/<name>/manifests/<digest>`, `aws ecr batch-delete-image` with `imageDigest`) also deletes its referrers, and their referrers in turn.
Deleting by tag removes only the tag and leaves the image and its referrers.
cosign v3 sign and verify work against the registry, including on a child manifest of an image index copied with `cosign copy`.
cosign v2 keeps writing its `sha256-<digest>.sig` tags and is unaffected.
`GET` on a manifest still returns `406` when the request's `Accept` header does not include the stored `mediaType`.
Limits:
- `cosign copy` does not copy v3 signatures to the destination repository.
This is client behaviour.
To keep the original signature, copy with `oras cp -r <source> <mirror>`, which copies the image and its referrers in one command, or `crane copy` the signature manifest by digest after the image.
Otherwise, sign the copy again.
- Referrers pushed before this version are not listed.
Push them again to add them to the index.
- A crash between deleting an image and deleting its referrers can leave referrers behind that nothing lists.
- The referrers listing is not paginated.
- `HEAD` on the referrers route is not served.
Measured on a 3-node environment with cosign v3.1.4 and v2.6.5:
- cosign v3.1.4 signed a fresh image index, with one `GET /referrers` answered `200`, no `sha256-<digest>` tag written and no `406`.
- Verify passed.
- A copied index's child manifest signed and verified, which returned `406` before this change.
- The listing returned the signature with its `artifactType`, the filter returned one entry for the signature's type and none for another type.
- `aws ecr batch-delete-image` of the signed image left an empty listing and `404` for both the signature and the image.
- cosign v2.6.5 signed and verified a fresh image.
Not measured: `oras`, `notation`, a listing with many referrers, and a crash in the middle of a delete.
## 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).
+96 -1
View File
@@ -35,6 +35,8 @@ resources:
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.
Control-plane hosts are not Kubernetes Nodes, as on AWS: `kubectl get nodes` lists only your node group workers, so a cluster with no node group lists none, and no pod runs on a control-plane host. Add-ons that used to tolerate the control-plane `CriticalAddonsOnly` taint, such as CoreDNS, metrics-server and konnectivity-agent, run on workers, so a Ready worker is required before they become available.
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.
<p align="center">
@@ -58,8 +60,89 @@ Authentication uses **EKS access entries** (the API authentication mode) — IAM
- **Access entries are the only auth path.** `authentication_mode` accepts `API` (the default when unset) and `API_AND_CONFIG_MAP`; bare `CONFIG_MAP` is rejected. Spinifex has no `aws-auth` ConfigMap path, so `API_AND_CONFIG_MAP` behaves as `API`: grant access with access entries.
- **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.
- **Workers launch with IMDSv2 required and an IMDS hop limit of 2.**
This matches AWS managed node groups and lets a pod without IRSA reach the node role through the SDK default credential chain.
A node group launch template that sets `metadata_options` overrides each field it sets, and any field it leaves unset keeps the default.
Existing workers keep their hop limit until they are replaced or changed with `ModifyInstanceMetadataOptions`.
- **Default Kubernetes version is `1.32`.**
### Control plane readiness and kubectl authentication
Each control-plane VM authenticates `kubectl` tokens through a webhook that calls the AWS gateway on the host the VM runs on.
When that gateway is stopped, the webhook cannot validate tokens and `kubectl` reports `Unauthorized` for the share of requests that the load balancer sends to that control plane.
The webhook serves `/readyz` on port 8444 of each control-plane VM.
It returns 200 only when the host gateway answers a TLS handshake and the local API server accepts a TCP connection, and 503 otherwise.
The API server target group health-checks `HTTP :8444/readyz` every 5 seconds with a healthy and unhealthy threshold of 2.
The target group still forwards TCP on 6443.
A control plane that cannot authenticate leaves rotation, and returns when its gateway is back.
Measured on a three-node cluster, one control plane per host, with `kubectl get ns` run continuously after stopping one gateway:
| Gateway stopped | Result |
| --- | --- |
| Host without the cluster load balancer VM, first run | 4 of 20 calls failed in the first round, 0 of 20 in the next round 54 s after the stop |
| Host without the cluster load balancer VM, second run | 2 failures in 100 s, at 3 s and 8 s after the stop |
| Host running the cluster load balancer VM | Not fixed: 20 of 60 calls failed over 100 s |
Before this change the same test failed 3, 4 and 3 calls of 10 per stopped gateway.
Limits:
- Only clusters created with the current `eks-node` image get the readiness listener, and only clusters created from now on get the health check on the target group.
Older clusters keep the TCP check on 6443 and are not changed.
- A cluster created with an older image gets the health check on its target group but no listener on 8444, so every control plane fails the check and the load balancer fails open to all of them.
- A stopped gateway on the host that runs the cluster load balancer VM is not covered.
The load balancer VM reports target health through its own host's gateway, so while that gateway is down the unhealthy control plane stays in rotation.
- Not measured: more than one control plane per host, a physical-node failure instead of a stopped gateway, and the target health state as `DescribeTargetHealth` reports it, because the load balancer and control-plane VMs belong to the system account.
### IAM roles for service accounts in your workloads
A pod gets an IAM role by running under a ServiceAccount that carries the role ARN in an annotation.
No pod-spec wiring is needed.
```yaml
apiVersion: v1
kind: ServiceAccount
metadata:
name: app
annotations:
eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/app-role
```
Every control plane runs a mutating webhook that acts on pod creation.
When the pod's ServiceAccount has the annotation, the webhook adds these to each container and init container:
- `AWS_ROLE_ARN` and `AWS_WEB_IDENTITY_TOKEN_FILE`, with a projected token volume named `aws-iam-token` mounted at `/var/run/secrets/eks.amazonaws.com/serviceaccount`.
- `AWS_REGION`, `AWS_DEFAULT_REGION` and `AWS_STS_REGIONAL_ENDPOINTS`.
- `AWS_ENDPOINT_URL_STS`, so the SDK calls the Spinifex STS endpoint.
- `AWS_CA_BUNDLE`, pointing at `/etc/spinifex/ca-bundle/ca.pem`, a ConfigMap named `spinifex-ca-bundle` that is created in every namespace and holds the system roots plus the gateway CA.
- `SSL_CERT_FILE` with the same path, only when the container does not set it. Clients such as the AWS Load Balancer Controller read this variable instead of `AWS_CA_BUNDLE`.
Your workload still sets `AWS_ENDPOINT_URL` (or the per-service variable) for every service other than STS.
The token audience defaults to `sts.amazonaws.com`.
The expiry defaults to a value between 79200 and 86400 seconds and is clamped to 600 to 86400 seconds.
Set them with the `eks.amazonaws.com/audience` and `eks.amazonaws.com/token-expiration` annotations on the ServiceAccount.
The `token-expiration` annotation on the pod wins over the ServiceAccount.
A container is left alone when it already has the role, region and regional STS settings, which is how the managed add-ons and your own explicit wiring are skipped.
When some are present, the missing ones and the mount are added.
Containers named in the pod's `eks.amazonaws.com/skip-containers` annotation (comma separated) are not touched.
A pod whose ServiceAccount has no role annotation is not changed.
The webhook fails open: if it cannot decide, the pod is created without injection.
Each injection, skip and failure is logged with the namespace, pod and reason in the control-plane console log.
Because it fails open, a pod that needs a role but starts without credentials is the symptom to look for, and the console log says why.
The webhook listens on `127.0.0.1:8445` of each control plane, and each API server calls its own.
Port 8444 is a different listener, the readiness check described above.
Existing clusters get the webhook only when their control planes are replaced by the current `eks-node` image.
No user-facing operation replaces a control plane, and automatic replacement does not currently work when the replacement reuses the failed member's IP.
Recreate the cluster to adopt it.
User-workload injection through a cluster created with an older image is not measured.
## Prerequisites
> [!IMPORTANT]
@@ -123,7 +206,7 @@ aws eks describe-cluster --name demo --query 'cluster.status'
aws eks create-nodegroup \
--cluster-name demo \
--nodegroup-name default \
--node-role-arn arn:aws:iam::000000000000:role/eks-node-role \
--node-role 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
@@ -138,6 +221,8 @@ aws eks update-kubeconfig --name demo
kubectl get nodes
```
The list shows the node group workers only. The control-plane VMs never appear as 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):
@@ -250,12 +335,22 @@ The workbooks form a ladder. **eks-quickstart** is a NodePort demo; **eks-https-
:::
### Upgrade notes
**Control-plane hosts no longer register as Nodes.** `kubectl get nodes` shows workers only, so node counts drop by the number of control planes, for example 6 to 3 on a three-control-plane cluster with three workers. Add-ons that tolerated `CriticalAddonsOnly` now run on workers.
**Existing clusters keep the old behaviour.** A cluster created before this change keeps registering its control planes as tainted Nodes until it is recreated. Recreate the cluster to adopt the new behaviour: automatic control-plane replacement does not currently work when the replacement reuses the failed member's IP, so it is not a supported way to upgrade.
## 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.
**`kubectl` returns `Unauthorized` for some requests and works for others.**
One control plane cannot reach its host's AWS gateway, so check `systemctl status spinifex-awsgw` on each node.
Control plane readiness and kubectl authentication above lists which clusters recover on their own.
**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`.
+3 -2
View File
@@ -17,7 +17,7 @@ tags:
## Overview
The platform serves **392 operations** across the AWS APIs below. Every page names the operations its service implements from the pinned model, generated from the dispatch tables on each build rather than written by hand.
The platform serves **405 operations** across the AWS APIs below. Every page names the operations its service implements from the pinned model, generated from the dispatch tables on each build rather than written by hand.
| Service | Operations |
|---|---:|
@@ -29,6 +29,7 @@ The platform serves **392 operations** across the AWS APIs below. Every page nam
| [ELBv2](/coverage/elbv2) | 36 |
| [IAM](/coverage/iam) | 81 |
| [RDS](/coverage/rds) | 27 |
| [Route 53](/coverage/route53) | 13 |
| [S3](/coverage/s3) | 26 |
| [STS](/coverage/sts) | 5 |
| **Total** | **392** |
| **Total** | **405** |
+308 -2
View File
@@ -2,8 +2,8 @@
"schema_version": 1,
"model_source": {
"repository": "github.com/aws/api-models-aws",
"commit": "9b1a4929274443d8671ea8acfda7afbe856e29e9",
"commit_date": "2026-09-25"
"commit": "e10d6a1c5ac36af7c0c61d1f67b3c3ebbe41de49",
"commit_date": "2026-10-28"
},
"services": [
{
@@ -300,6 +300,7 @@
"DeleteCapacityManagerDataExport",
"DeleteCarrierGateway",
"DeleteClientVpnEndpoint",
"DeleteClientVpnEndpointAuthorizationPolicy",
"DeleteClientVpnRoute",
"DeleteCoipCidr",
"DeleteCoipPool",
@@ -681,7 +682,9 @@
"GetCapacityManagerMetricData",
"GetCapacityManagerMetricDimensions",
"GetCapacityManagerMonitoredTagKeys",
"GetCapacityManagerSupportabilityScores",
"GetCapacityReservationUsage",
"GetClientVpnEndpointAuthorizationPolicy",
"GetCoipPoolUsage",
"GetConsoleOutput",
"GetConsoleScreenshot",
@@ -695,6 +698,7 @@
"GetHostReservationPurchasePreview",
"GetImageAncestry",
"GetImageBlockPublicAccessState",
"GetImageFindings",
"GetInstanceMetadataDefaults",
"GetInstanceTpmEkPub",
"GetInstanceTypesFromInstanceRequirements",
@@ -734,6 +738,7 @@
"GetSnapshotBlockPublicAccessState",
"GetSpotPlacementScores",
"GetSubnetCidrReservations",
"GetTransitGatewayAttachmentNatConfiguration",
"GetTransitGatewayAttachmentPropagations",
"GetTransitGatewayMeteringPolicyEntries",
"GetTransitGatewayMulticastDomainAssociations",
@@ -766,6 +771,7 @@
"ModifyCapacityReservation",
"ModifyCapacityReservationFleet",
"ModifyClientVpnEndpoint",
"ModifyClientVpnEndpointAuthorizationPolicy",
"ModifyDefaultCreditSpecification",
"ModifyEbsDefaultKmsKeyId",
"ModifyFleet",
@@ -814,6 +820,7 @@
"ModifyTrafficMirrorFilterRule",
"ModifyTrafficMirrorSession",
"ModifyTransitGateway",
"ModifyTransitGatewayAttachmentNatConfiguration",
"ModifyTransitGatewayMeteringPolicy",
"ModifyTransitGatewayPolicyTableEntry",
"ModifyTransitGatewayPrefixListReference",
@@ -1335,6 +1342,7 @@
"DeleteCapacityManagerDataExport",
"DeleteCarrierGateway",
"DeleteClientVpnEndpoint",
"DeleteClientVpnEndpointAuthorizationPolicy",
"DeleteClientVpnRoute",
"DeleteCoipCidr",
"DeleteCoipPool",
@@ -1653,7 +1661,9 @@
"GetCapacityManagerMetricData",
"GetCapacityManagerMetricDimensions",
"GetCapacityManagerMonitoredTagKeys",
"GetCapacityManagerSupportabilityScores",
"GetCapacityReservationUsage",
"GetClientVpnEndpointAuthorizationPolicy",
"GetCoipPoolUsage",
"GetConsoleScreenshot",
"GetDeclarativePoliciesReportSummary",
@@ -1665,6 +1675,7 @@
"GetHostReservationPurchasePreview",
"GetImageAncestry",
"GetImageBlockPublicAccessState",
"GetImageFindings",
"GetInstanceMetadataDefaults",
"GetInstanceTpmEkPub",
"GetInstanceTypesFromInstanceRequirements",
@@ -1701,6 +1712,7 @@
"GetSnapshotBlockPublicAccessState",
"GetSpotPlacementScores",
"GetSubnetCidrReservations",
"GetTransitGatewayAttachmentNatConfiguration",
"GetTransitGatewayAttachmentPropagations",
"GetTransitGatewayMeteringPolicyEntries",
"GetTransitGatewayMulticastDomainAssociations",
@@ -1732,6 +1744,7 @@
"ModifyCapacityReservation",
"ModifyCapacityReservationFleet",
"ModifyClientVpnEndpoint",
"ModifyClientVpnEndpointAuthorizationPolicy",
"ModifyDefaultCreditSpecification",
"ModifyEbsDefaultKmsKeyId",
"ModifyFleet",
@@ -1773,6 +1786,7 @@
"ModifyTrafficMirrorFilterRule",
"ModifyTrafficMirrorSession",
"ModifyTransitGateway",
"ModifyTransitGatewayAttachmentNatConfiguration",
"ModifyTransitGatewayMeteringPolicy",
"ModifyTransitGatewayPolicyTableEntry",
"ModifyTransitGatewayPrefixListReference",
@@ -1897,6 +1911,7 @@
"DescribeImages",
"DescribePullThroughCacheRules",
"DescribeRegistry",
"DescribeReplicationBackfillResults",
"DescribeRepositories",
"DescribeRepositoryCreationTemplates",
"GetAccountSetting",
@@ -1912,6 +1927,7 @@
"ListImageReferrers",
"ListImages",
"ListPullTimeUpdateExclusions",
"ListReplicationBackfills",
"ListTagsForResource",
"PutAccountSetting",
"PutImage",
@@ -1926,6 +1942,8 @@
"SetRepositoryPolicy",
"StartImageScan",
"StartLifecyclePolicyPreview",
"StartReplicationBackfill",
"StopReplicationBackfill",
"TagResource",
"UntagResource",
"UpdateImageStorageClass",
@@ -2031,14 +2049,18 @@
"DescribeImageReplicationStatus",
"DescribeImageSigningStatus",
"DescribePullThroughCacheRules",
"DescribeReplicationBackfillResults",
"DescribeRepositoryCreationTemplates",
"GetAccountSetting",
"GetSigningConfiguration",
"ListImageReferrers",
"ListPullTimeUpdateExclusions",
"ListReplicationBackfills",
"PutAccountSetting",
"PutSigningConfiguration",
"RegisterPullTimeUpdateExclusion",
"StartReplicationBackfill",
"StopReplicationBackfill",
"UpdateImageStorageClass",
"UpdatePullThroughCacheRule",
"UpdateRepositoryCreationTemplate",
@@ -2058,11 +2080,13 @@
"note": "",
"operations": {
"modelled": [
"AssociateGatewayWithCluster",
"ContinueServiceDeployment",
"CreateCapacityProvider",
"CreateCluster",
"CreateDaemon",
"CreateExpressGatewayService",
"CreateGateway",
"CreateService",
"CreateTaskSet",
"DeleteAccountSetting",
@@ -2072,6 +2096,7 @@
"DeleteDaemon",
"DeleteDaemonTaskDefinition",
"DeleteExpressGatewayService",
"DeleteGateway",
"DeleteService",
"DeleteTaskDefinitions",
"DeleteTaskSet",
@@ -2085,12 +2110,16 @@
"DescribeDaemonRevisions",
"DescribeDaemonTaskDefinition",
"DescribeExpressGatewayService",
"DescribeGateway",
"DescribeGatewayResources",
"DescribeGatewayRoute",
"DescribeServiceDeployments",
"DescribeServiceRevisions",
"DescribeServices",
"DescribeTaskDefinition",
"DescribeTaskSets",
"DescribeTasks",
"DisassociateGatewayFromCluster",
"DiscoverPollEndpoint",
"ExecuteCommand",
"GetTaskProtection",
@@ -2101,6 +2130,9 @@
"ListDaemonDeployments",
"ListDaemonTaskDefinitions",
"ListDaemons",
"ListGatewayClusterAssociations",
"ListGatewayRoutes",
"ListGateways",
"ListServiceDeployments",
"ListServices",
"ListServicesByNamespace",
@@ -2131,6 +2163,7 @@
"UpdateContainerInstancesState",
"UpdateDaemon",
"UpdateExpressGatewayService",
"UpdateGateway",
"UpdateService",
"UpdateServicePrimaryTaskSet",
"UpdateTaskProtection",
@@ -2219,15 +2252,18 @@
],
"unsupported": [],
"missing": [
"AssociateGatewayWithCluster",
"ContinueServiceDeployment",
"CreateDaemon",
"CreateExpressGatewayService",
"CreateGateway",
"CreateTaskSet",
"DeleteAccountSetting",
"DeleteAttributes",
"DeleteDaemon",
"DeleteDaemonTaskDefinition",
"DeleteExpressGatewayService",
"DeleteGateway",
"DeleteTaskDefinitions",
"DeleteTaskSet",
"DescribeDaemon",
@@ -2235,9 +2271,13 @@
"DescribeDaemonRevisions",
"DescribeDaemonTaskDefinition",
"DescribeExpressGatewayService",
"DescribeGateway",
"DescribeGatewayResources",
"DescribeGatewayRoute",
"DescribeServiceDeployments",
"DescribeServiceRevisions",
"DescribeTaskSets",
"DisassociateGatewayFromCluster",
"DiscoverPollEndpoint",
"ExecuteCommand",
"GetTaskProtection",
@@ -2245,6 +2285,9 @@
"ListDaemonDeployments",
"ListDaemonTaskDefinitions",
"ListDaemons",
"ListGatewayClusterAssociations",
"ListGatewayRoutes",
"ListGateways",
"ListServiceDeployments",
"PutAccountSettingDefault",
"PutAttributes",
@@ -2257,6 +2300,7 @@
"UpdateContainerAgent",
"UpdateDaemon",
"UpdateExpressGatewayService",
"UpdateGateway",
"UpdateServicePrimaryTaskSet",
"UpdateTaskProtection",
"UpdateTaskSet"
@@ -3099,6 +3143,7 @@
"ApplyPendingMaintenanceAction",
"AuthorizeDBSecurityGroupIngress",
"BacktrackDBCluster",
"CancelDBCapacityReservation",
"CancelExportTask",
"CopyDBClusterParameterGroup",
"CopyDBClusterSnapshot",
@@ -3107,6 +3152,7 @@
"CopyOptionGroup",
"CreateBlueGreenDeployment",
"CreateCustomDBEngineVersion",
"CreateDBCapacityReservation",
"CreateDBCluster",
"CreateDBClusterEndpoint",
"CreateDBClusterParameterGroup",
@@ -3150,6 +3196,7 @@
"DescribeAccountAttributes",
"DescribeBlueGreenDeployments",
"DescribeCertificates",
"DescribeDBCapacityReservations",
"DescribeDBClusterAutomatedBackups",
"DescribeDBClusterBacktracks",
"DescribeDBClusterEndpoints",
@@ -3204,6 +3251,7 @@
"ModifyCertificates",
"ModifyCurrentDBClusterCapacity",
"ModifyCustomDBEngineVersion",
"ModifyDBCapacityReservation",
"ModifyDBCluster",
"ModifyDBClusterEndpoint",
"ModifyDBClusterParameterGroup",
@@ -3354,6 +3402,7 @@
"ApplyPendingMaintenanceAction",
"AuthorizeDBSecurityGroupIngress",
"BacktrackDBCluster",
"CancelDBCapacityReservation",
"CancelExportTask",
"CopyDBClusterParameterGroup",
"CopyDBClusterSnapshot",
@@ -3362,6 +3411,7 @@
"CopyOptionGroup",
"CreateBlueGreenDeployment",
"CreateCustomDBEngineVersion",
"CreateDBCapacityReservation",
"CreateDBClusterEndpoint",
"CreateDBClusterParameterGroup",
"CreateDBClusterSnapshot",
@@ -3392,6 +3442,7 @@
"DescribeAccountAttributes",
"DescribeBlueGreenDeployments",
"DescribeCertificates",
"DescribeDBCapacityReservations",
"DescribeDBClusterAutomatedBackups",
"DescribeDBClusterBacktracks",
"DescribeDBClusterEndpoints",
@@ -3433,6 +3484,7 @@
"ModifyCertificates",
"ModifyCurrentDBClusterCapacity",
"ModifyCustomDBEngineVersion",
"ModifyDBCapacityReservation",
"ModifyDBClusterEndpoint",
"ModifyDBClusterParameterGroup",
"ModifyDBClusterSnapshotAttribute",
@@ -3483,6 +3535,180 @@
]
}
},
{
"service": "route53",
"api_version": "2013-04-01",
"opaque": false,
"note": "",
"operations": {
"modelled": [
"ActivateKeySigningKey",
"AssociateVPCWithHostedZone",
"ChangeCidrCollection",
"ChangeResourceRecordSets",
"ChangeTagsForResource",
"CreateCidrCollection",
"CreateHealthCheck",
"CreateHostedZone",
"CreateKeySigningKey",
"CreateQueryLoggingConfig",
"CreateReusableDelegationSet",
"CreateTrafficPolicy",
"CreateTrafficPolicyInstance",
"CreateTrafficPolicyVersion",
"CreateVPCAssociationAuthorization",
"DeactivateKeySigningKey",
"DeleteCidrCollection",
"DeleteHealthCheck",
"DeleteHostedZone",
"DeleteKeySigningKey",
"DeleteQueryLoggingConfig",
"DeleteReusableDelegationSet",
"DeleteTrafficPolicy",
"DeleteTrafficPolicyInstance",
"DeleteVPCAssociationAuthorization",
"DisableHostedZoneDNSSEC",
"DisassociateVPCFromHostedZone",
"EnableHostedZoneDNSSEC",
"GetAccountLimit",
"GetChange",
"GetCheckerIpRanges",
"GetDNSSEC",
"GetGeoLocation",
"GetHealthCheck",
"GetHealthCheckCount",
"GetHealthCheckLastFailureReason",
"GetHealthCheckStatus",
"GetHostedZone",
"GetHostedZoneCount",
"GetHostedZoneLimit",
"GetQueryLoggingConfig",
"GetReusableDelegationSet",
"GetReusableDelegationSetLimit",
"GetTrafficPolicy",
"GetTrafficPolicyInstance",
"GetTrafficPolicyInstanceCount",
"ListCidrBlocks",
"ListCidrCollections",
"ListCidrLocations",
"ListGeoLocations",
"ListHealthChecks",
"ListHostedZones",
"ListHostedZonesByName",
"ListHostedZonesByVPC",
"ListQueryLoggingConfigs",
"ListResourceRecordSets",
"ListReusableDelegationSets",
"ListTagsForResource",
"ListTagsForResources",
"ListTrafficPolicies",
"ListTrafficPolicyInstances",
"ListTrafficPolicyInstancesByHostedZone",
"ListTrafficPolicyInstancesByPolicy",
"ListTrafficPolicyVersions",
"ListVPCAssociationAuthorizations",
"TestDNSAnswer",
"UpdateHealthCheck",
"UpdateHostedZoneComment",
"UpdateHostedZoneFeatures",
"UpdateTrafficPolicyComment",
"UpdateTrafficPolicyInstance"
],
"registered": [
"ChangeResourceRecordSets",
"ChangeTagsForResource",
"CreateHostedZone",
"DeleteHostedZone",
"GetChange",
"GetHostedZone",
"GetHostedZoneCount",
"ListHostedZones",
"ListHostedZonesByName",
"ListResourceRecordSets",
"ListTagsForResource",
"ListTagsForResources",
"UpdateHostedZoneComment"
],
"implemented": [
"ChangeResourceRecordSets",
"ChangeTagsForResource",
"CreateHostedZone",
"DeleteHostedZone",
"GetChange",
"GetHostedZone",
"GetHostedZoneCount",
"ListHostedZones",
"ListHostedZonesByName",
"ListResourceRecordSets",
"ListTagsForResource",
"ListTagsForResources",
"UpdateHostedZoneComment"
],
"stubbed": [],
"unsupported": [],
"missing": [
"ActivateKeySigningKey",
"AssociateVPCWithHostedZone",
"ChangeCidrCollection",
"CreateCidrCollection",
"CreateHealthCheck",
"CreateKeySigningKey",
"CreateQueryLoggingConfig",
"CreateReusableDelegationSet",
"CreateTrafficPolicy",
"CreateTrafficPolicyInstance",
"CreateTrafficPolicyVersion",
"CreateVPCAssociationAuthorization",
"DeactivateKeySigningKey",
"DeleteCidrCollection",
"DeleteHealthCheck",
"DeleteKeySigningKey",
"DeleteQueryLoggingConfig",
"DeleteReusableDelegationSet",
"DeleteTrafficPolicy",
"DeleteTrafficPolicyInstance",
"DeleteVPCAssociationAuthorization",
"DisableHostedZoneDNSSEC",
"DisassociateVPCFromHostedZone",
"EnableHostedZoneDNSSEC",
"GetAccountLimit",
"GetCheckerIpRanges",
"GetDNSSEC",
"GetGeoLocation",
"GetHealthCheck",
"GetHealthCheckCount",
"GetHealthCheckLastFailureReason",
"GetHealthCheckStatus",
"GetHostedZoneLimit",
"GetQueryLoggingConfig",
"GetReusableDelegationSet",
"GetReusableDelegationSetLimit",
"GetTrafficPolicy",
"GetTrafficPolicyInstance",
"GetTrafficPolicyInstanceCount",
"ListCidrBlocks",
"ListCidrCollections",
"ListCidrLocations",
"ListGeoLocations",
"ListHealthChecks",
"ListHostedZonesByVPC",
"ListQueryLoggingConfigs",
"ListReusableDelegationSets",
"ListTrafficPolicies",
"ListTrafficPolicyInstances",
"ListTrafficPolicyInstancesByHostedZone",
"ListTrafficPolicyInstancesByPolicy",
"ListTrafficPolicyVersions",
"ListVPCAssociationAuthorizations",
"TestDNSAnswer",
"UpdateHealthCheck",
"UpdateHostedZoneFeatures",
"UpdateTrafficPolicyComment",
"UpdateTrafficPolicyInstance"
],
"extra": []
}
},
{
"service": "s3",
"api_version": "2006-03-01",
@@ -3752,6 +3978,86 @@
"extra": []
}
},
{
"service": "sqs",
"api_version": "2012-11-05",
"opaque": false,
"note": "",
"operations": {
"modelled": [
"AddPermission",
"CancelMessageMoveTask",
"ChangeMessageVisibility",
"ChangeMessageVisibilityBatch",
"CreateQueue",
"DeleteMessage",
"DeleteMessageBatch",
"DeleteQueue",
"GetQueueAttributes",
"GetQueueUrl",
"ListDeadLetterSourceQueues",
"ListMessageMoveTasks",
"ListQueueTags",
"ListQueues",
"PurgeQueue",
"ReceiveMessage",
"RemovePermission",
"SendMessage",
"SendMessageBatch",
"SetQueueAttributes",
"StartMessageMoveTask",
"TagQueue",
"UntagQueue"
],
"registered": [
"ChangeMessageVisibility",
"ChangeMessageVisibilityBatch",
"CreateQueue",
"DeleteMessage",
"DeleteMessageBatch",
"GetQueueAttributes",
"GetQueueUrl",
"ListQueueTags",
"ListQueues",
"ReceiveMessage",
"SendMessage",
"SendMessageBatch",
"SetQueueAttributes",
"TagQueue",
"UntagQueue"
],
"implemented": [
"ChangeMessageVisibility",
"ChangeMessageVisibilityBatch",
"CreateQueue",
"DeleteMessage",
"DeleteMessageBatch",
"GetQueueAttributes",
"GetQueueUrl",
"ListQueueTags",
"ListQueues",
"ReceiveMessage",
"SendMessage",
"SendMessageBatch",
"SetQueueAttributes",
"TagQueue",
"UntagQueue"
],
"stubbed": [],
"unsupported": [],
"missing": [
"AddPermission",
"CancelMessageMoveTask",
"DeleteQueue",
"ListDeadLetterSourceQueues",
"ListMessageMoveTasks",
"PurgeQueue",
"RemovePermission",
"StartMessageMoveTask"
],
"extra": []
}
},
{
"service": "sts",
"api_version": "2011-06-15",
+15
View File
@@ -28,6 +28,21 @@ Spot Instance Requests are a mock over the on-demand `RunInstances` path. A requ
An AMI cannot be shared with another account or made public. `ModifyImageAttribute` refuses `LaunchPermission`, `UserIds`, `UserGroups`, `OrganizationArns`, `OrganizationalUnitArns` and `OperationType`, and `ResetImageAttribute` resets only `description`, not `launchPermission`. Each refusal is `InvalidParameterValue`. Terraform's `aws_ami_launch_permission` therefore cannot be created. `description` is the only image attribute that can be modified or reset.
### RegisterImage Registers From a Snapshot
`RegisterImage` takes a root EBS snapshot in `BlockDeviceMappings` and writes an image that points at it.
`EnaSupport` is supported, with `true` and `false` both accepted and stored on the image.
`DescribeImages` returns `EnaSupport` when the image has one and leaves the member out when it was never set, as AWS does.
`CopyImage` carries the value to the copy, and `CreateImage` sets it to `true` because every instance reports ENA support.
`RunInstances` does not read the flag, so an image registered with `EnaSupport=false` is not held back from instance types that need ENA on AWS, since guests use virtio networking either way.
`RegisterImage` refuses `ImageLocation`, `KernelId`, `RamdiskId`, `TpmSupport`, `ImdsSupport` and `SriovNetSupport`, and any `VirtualizationType` other than `hvm`.
Each refusal is `InvalidParameterValue`, and the message names the parameter and the value, for example `Value (aki-123) for parameter KernelId is invalid. KernelId is not supported.`
The same form is used for a bad `Architecture`, `BootMode` or `Name` length.
A missing `Name`, `BlockDeviceMappings` or root snapshot answers `MissingParameter` and names what is missing.
Registering with `--ena-support`, `--no-ena-support` and neither, copying the image, and three refusals were run on a three-node cluster with the AWS CLI.
Launching an instance from an `EnaSupport` image was not run, so the launch path is unchanged by construction and not by test.
### Operations
| Operation |
+15
View File
@@ -5,3 +5,18 @@ Spot Instance Requests are a mock over the on-demand `RunInstances` path. A requ
### AMI Launch Permissions Are Refused
An AMI cannot be shared with another account or made public. `ModifyImageAttribute` refuses `LaunchPermission`, `UserIds`, `UserGroups`, `OrganizationArns`, `OrganizationalUnitArns` and `OperationType`, and `ResetImageAttribute` resets only `description`, not `launchPermission`. Each refusal is `InvalidParameterValue`. Terraform's `aws_ami_launch_permission` therefore cannot be created. `description` is the only image attribute that can be modified or reset.
### RegisterImage Registers From a Snapshot
`RegisterImage` takes a root EBS snapshot in `BlockDeviceMappings` and writes an image that points at it.
`EnaSupport` is supported, with `true` and `false` both accepted and stored on the image.
`DescribeImages` returns `EnaSupport` when the image has one and leaves the member out when it was never set, as AWS does.
`CopyImage` carries the value to the copy, and `CreateImage` sets it to `true` because every instance reports ENA support.
`RunInstances` does not read the flag, so an image registered with `EnaSupport=false` is not held back from instance types that need ENA on AWS, since guests use virtio networking either way.
`RegisterImage` refuses `ImageLocation`, `KernelId`, `RamdiskId`, `TpmSupport`, `ImdsSupport` and `SriovNetSupport`, and any `VirtualizationType` other than `hvm`.
Each refusal is `InvalidParameterValue`, and the message names the parameter and the value, for example `Value (aki-123) for parameter KernelId is invalid. KernelId is not supported.`
The same form is used for a bad `Architecture`, `BootMode` or `Name` length.
A missing `Name`, `BlockDeviceMappings` or root snapshot answers `MissingParameter` and names what is missing.
Registering with `--ena-support`, `--no-ena-support` and neither, copying the image, and three refusals were run on a three-node cluster with the AWS CLI.
Launching an instance from an `EnaSupport` image was not run, so the launch path is unchanged by construction and not by test.
+68
View File
@@ -24,6 +24,74 @@ Spinifex implements **36 operations** in the ELBv2 `2015-12-01` API model.
The data plane is a system-managed load balancer VM, launched automatically during `CreateLoadBalancer`. Application Load Balancers run HAProxy for the L7 surface — rules, fixed responses and redirects over HTTP and HTTPS. Network Load Balancers run nginx `stream` for the L4 surface — TCP, UDP, TLS and TCP_UDP — because HAProxy cannot load-balance UDP. The agent selects the engine from the configuration the control plane delivers, so the choice follows the load balancer type and is not separately configurable.
### Target health and routing
Traffic goes only to targets that are healthy or still `initial`.
A target that has failed its health check is removed from the data plane on the next render and put back when it passes again.
When every target in a group is unhealthy, the load balancer fails open and sends traffic to all of them rather than refusing it.
A Network Load Balancer renders every registered server in that case, and an Application Load Balancer falls back to unchecked backup servers that HAProxy uses only when every checked server is down.
`DescribeTargetHealth` keeps reporting the targets as unhealthy while this happens.
Draining targets and targets without an address never receive traffic.
An `initial` target receives traffic before its first successful check.
This differs from AWS, where a target takes no traffic until it passes.
Health state is reset to `initial` for every target when the control plane restarts, so excluding `initial` targets would drop all traffic after a restart.
Changing the health check of a target group with `ModifyTargetGroup` takes effect on the next render of every load balancer that uses it.
The time from a backend failing to traffic stopping is the health check interval times the unhealthy threshold, plus the agent poll of about 5 seconds and the heartbeat that reports the result.
Recovery takes the interval times the healthy threshold, plus the same delays.
Measured on a three-node cluster with a 5 second interval and thresholds of 2:
| Event | Time |
| --- | --- |
| Backend starts failing to reported `unhealthy` | 21 s |
| Backend recovers to reported `healthy` | 15 s, serving again 6 s later |
| Every backend failing to reported `unhealthy` | 12 s, fail open immediate |
A TCP connection held through a Network Load Balancer survived four configuration reloads while the other backend changed state, because the agent applies nginx with a graceful reload and HAProxy with a graceful restart.
The HAProxy configuration with the backup servers was accepted by a live Application Load Balancer, but its rendered text was not dumped and checked by hand.
### Forwarding headers
An Application Load Balancer sets three headers on every HTTP and HTTPS request it forwards.
`X-Forwarded-For` carries the client address, `X-Forwarded-Proto` is `http` or `https` for the listener that accepted the request, and `X-Forwarded-Port` is that listener's port.
`X-Forwarded-Proto` and `X-Forwarded-Port` are always overwritten, so a value sent by the client never reaches the target.
Network Load Balancers do not touch the stream.
`routing.http.xff_header_processing.mode` decides what happens to `X-Forwarded-For`:
| Mode | Behaviour |
| --- | --- |
| `append` (default) | Keeps what the client sent and adds the client address last, as one comma-separated line such as `203.0.113.9, 192.0.2.10`. |
| `preserve` | Forwards the client's value unchanged and adds nothing. The header is absent when the client sent none. |
| `remove` | Drops the header, including any client value. |
`routing.http.xff_client_port.enabled` (default `false`) appends the client's source port in `append` mode, as `192.0.2.10:54321`.
An IPv6 client is written `[2001:db8::1]:54321`.
The setting has no effect in `preserve` and `remove` modes.
`ModifyLoadBalancerAttributes` accepts only `append`, `preserve` or `remove` for the mode and `true` or `false` for the client port, and rejects anything else with `ValidationError`.
A change re-renders the load balancer configuration, and a call that leaves both values as they were does not.
In a live test each change reached the target within one 3 second poll.
A client that sends `X-Forwarded-For` more than once has its lines joined into the single comma-separated value.
At most eight client lines are joined, and any beyond the eighth are dropped, while the client address is still added last.
Every other `routing.http.*` attribute is stored and returned by `DescribeLoadBalancerAttributes` but has no effect on the data plane.
Measured on a three-node cluster, with the caller on the first node:
| Listener | `X-Forwarded-For` the target saw |
| --- | --- |
| Internet-facing HTTP | The node's real address, `192.168.0.120` |
| Internet-facing HTTPS | The same address, with `https` and port 443 |
| Internal HTTP | The calling instance's private address |
| Internet-facing HTTP with a client value of `203.0.113.9` | `203.0.113.9, 192.168.0.120` on one line |
A packet capture on the load balancer's interface showed HAProxy receiving the real client address, so nothing translates it first.
Behaviour against AWS was not compared, and the cases above were not run with an IPv6 client.
### Operations
| Operation |
+68
View File
@@ -1,3 +1,71 @@
### Two data planes
The data plane is a system-managed load balancer VM, launched automatically during `CreateLoadBalancer`. Application Load Balancers run HAProxy for the L7 surface — rules, fixed responses and redirects over HTTP and HTTPS. Network Load Balancers run nginx `stream` for the L4 surface — TCP, UDP, TLS and TCP_UDP — because HAProxy cannot load-balance UDP. The agent selects the engine from the configuration the control plane delivers, so the choice follows the load balancer type and is not separately configurable.
### Target health and routing
Traffic goes only to targets that are healthy or still `initial`.
A target that has failed its health check is removed from the data plane on the next render and put back when it passes again.
When every target in a group is unhealthy, the load balancer fails open and sends traffic to all of them rather than refusing it.
A Network Load Balancer renders every registered server in that case, and an Application Load Balancer falls back to unchecked backup servers that HAProxy uses only when every checked server is down.
`DescribeTargetHealth` keeps reporting the targets as unhealthy while this happens.
Draining targets and targets without an address never receive traffic.
An `initial` target receives traffic before its first successful check.
This differs from AWS, where a target takes no traffic until it passes.
Health state is reset to `initial` for every target when the control plane restarts, so excluding `initial` targets would drop all traffic after a restart.
Changing the health check of a target group with `ModifyTargetGroup` takes effect on the next render of every load balancer that uses it.
The time from a backend failing to traffic stopping is the health check interval times the unhealthy threshold, plus the agent poll of about 5 seconds and the heartbeat that reports the result.
Recovery takes the interval times the healthy threshold, plus the same delays.
Measured on a three-node cluster with a 5 second interval and thresholds of 2:
| Event | Time |
| --- | --- |
| Backend starts failing to reported `unhealthy` | 21 s |
| Backend recovers to reported `healthy` | 15 s, serving again 6 s later |
| Every backend failing to reported `unhealthy` | 12 s, fail open immediate |
A TCP connection held through a Network Load Balancer survived four configuration reloads while the other backend changed state, because the agent applies nginx with a graceful reload and HAProxy with a graceful restart.
The HAProxy configuration with the backup servers was accepted by a live Application Load Balancer, but its rendered text was not dumped and checked by hand.
### Forwarding headers
An Application Load Balancer sets three headers on every HTTP and HTTPS request it forwards.
`X-Forwarded-For` carries the client address, `X-Forwarded-Proto` is `http` or `https` for the listener that accepted the request, and `X-Forwarded-Port` is that listener's port.
`X-Forwarded-Proto` and `X-Forwarded-Port` are always overwritten, so a value sent by the client never reaches the target.
Network Load Balancers do not touch the stream.
`routing.http.xff_header_processing.mode` decides what happens to `X-Forwarded-For`:
| Mode | Behaviour |
| --- | --- |
| `append` (default) | Keeps what the client sent and adds the client address last, as one comma-separated line such as `203.0.113.9, 192.0.2.10`. |
| `preserve` | Forwards the client's value unchanged and adds nothing. The header is absent when the client sent none. |
| `remove` | Drops the header, including any client value. |
`routing.http.xff_client_port.enabled` (default `false`) appends the client's source port in `append` mode, as `192.0.2.10:54321`.
An IPv6 client is written `[2001:db8::1]:54321`.
The setting has no effect in `preserve` and `remove` modes.
`ModifyLoadBalancerAttributes` accepts only `append`, `preserve` or `remove` for the mode and `true` or `false` for the client port, and rejects anything else with `ValidationError`.
A change re-renders the load balancer configuration, and a call that leaves both values as they were does not.
In a live test each change reached the target within one 3 second poll.
A client that sends `X-Forwarded-For` more than once has its lines joined into the single comma-separated value.
At most eight client lines are joined, and any beyond the eighth are dropped, while the client address is still added last.
Every other `routing.http.*` attribute is stored and returned by `DescribeLoadBalancerAttributes` but has no effect on the data plane.
Measured on a three-node cluster, with the caller on the first node:
| Listener | `X-Forwarded-For` the target saw |
| --- | --- |
| Internet-facing HTTP | The node's real address, `192.168.0.120` |
| Internet-facing HTTPS | The same address, with `https` and port 443 |
| Internal HTTP | The calling instance's private address |
| Internet-facing HTTP with a client value of `203.0.113.9` | `203.0.113.9, 192.168.0.120` on one line |
A packet capture on the load balancer's interface showed HAProxy receiving the real client address, so nothing translates it first.
Behaviour against AWS was not compared, and the cases above were not run with an IPv6 client.
+111 -5
View File
@@ -4,7 +4,7 @@ Generated by `make aws-model-coverage`. This file is not published: it carries t
**Not implemented** is the candidate work. **Not applicable** is declared in `pages.json` and is not work: those operations describe something this platform will not have.
Pinned model source: `github.com/aws/api-models-aws@9b1a49292744, 2026-09-25`.
Pinned model source: `github.com/aws/api-models-aws@e10d6a1c5ac3, 2026-10-28`.
## acm
@@ -46,7 +46,7 @@ Pinned model source: `github.com/aws/api-models-aws@9b1a49292744, 2026-09-25`.
## ec2
`2016-11-15` — 127 of 804 modelled operations implemented (15.8%), 0 stubbed, 39 not applicable, 0 registered outside the pinned model.
`2016-11-15` — 127 of 811 modelled operations implemented (15.7%), 0 stubbed, 39 not applicable, 0 registered outside the pinned model.
### Not implemented
@@ -190,6 +190,7 @@ Pinned model source: `github.com/aws/api-models-aws@9b1a49292744, 2026-09-25`.
- `DeleteApplicationStatusCheck`
- `DeleteCapacityManagerDataExport`
- `DeleteClientVpnEndpoint`
- `DeleteClientVpnEndpointAuthorizationPolicy`
- `DeleteClientVpnRoute`
- `DeleteCoipCidr`
- `DeleteCoipPool`
@@ -484,7 +485,9 @@ Pinned model source: `github.com/aws/api-models-aws@9b1a49292744, 2026-09-25`.
- `GetCapacityManagerMetricData`
- `GetCapacityManagerMetricDimensions`
- `GetCapacityManagerMonitoredTagKeys`
- `GetCapacityManagerSupportabilityScores`
- `GetCapacityReservationUsage`
- `GetClientVpnEndpointAuthorizationPolicy`
- `GetCoipPoolUsage`
- `GetConsoleScreenshot`
- `GetDeclarativePoliciesReportSummary`
@@ -496,6 +499,7 @@ Pinned model source: `github.com/aws/api-models-aws@9b1a49292744, 2026-09-25`.
- `GetHostReservationPurchasePreview`
- `GetImageAncestry`
- `GetImageBlockPublicAccessState`
- `GetImageFindings`
- `GetInstanceMetadataDefaults`
- `GetInstanceTpmEkPub`
- `GetInstanceTypesFromInstanceRequirements`
@@ -532,6 +536,7 @@ Pinned model source: `github.com/aws/api-models-aws@9b1a49292744, 2026-09-25`.
- `GetSnapshotBlockPublicAccessState`
- `GetSpotPlacementScores`
- `GetSubnetCidrReservations`
- `GetTransitGatewayAttachmentNatConfiguration`
- `GetTransitGatewayAttachmentPropagations`
- `GetTransitGatewayMeteringPolicyEntries`
- `GetTransitGatewayMulticastDomainAssociations`
@@ -563,6 +568,7 @@ Pinned model source: `github.com/aws/api-models-aws@9b1a49292744, 2026-09-25`.
- `ModifyCapacityReservation`
- `ModifyCapacityReservationFleet`
- `ModifyClientVpnEndpoint`
- `ModifyClientVpnEndpointAuthorizationPolicy`
- `ModifyDefaultCreditSpecification`
- `ModifyEbsDefaultKmsKeyId`
- `ModifyFleet`
@@ -601,6 +607,7 @@ Pinned model source: `github.com/aws/api-models-aws@9b1a49292744, 2026-09-25`.
- `ModifyTrafficMirrorFilterRule`
- `ModifyTrafficMirrorSession`
- `ModifyTransitGateway`
- `ModifyTransitGatewayAttachmentNatConfiguration`
- `ModifyTransitGatewayMeteringPolicy`
- `ModifyTransitGatewayPolicyTableEntry`
- `ModifyTransitGatewayPrefixListReference`
@@ -762,7 +769,7 @@ spot-price: On owned hardware there is no spot-to-on-demand price differential,
## ecr
`2015-09-21` — 23 of 58 modelled operations implemented (39.7%), 10 stubbed, 0 not applicable, 3 registered outside the pinned model.
`2015-09-21` — 23 of 62 modelled operations implemented (37.1%), 10 stubbed, 0 not applicable, 3 registered outside the pinned model.
### Not implemented
@@ -776,14 +783,18 @@ spot-price: On owned hardware there is no spot-to-on-demand price differential,
- `DescribeImageReplicationStatus`
- `DescribeImageSigningStatus`
- `DescribePullThroughCacheRules`
- `DescribeReplicationBackfillResults`
- `DescribeRepositoryCreationTemplates`
- `GetAccountSetting`
- `GetSigningConfiguration`
- `ListImageReferrers`
- `ListPullTimeUpdateExclusions`
- `ListReplicationBackfills`
- `PutAccountSetting`
- `PutSigningConfiguration`
- `RegisterPullTimeUpdateExclusion`
- `StartReplicationBackfill`
- `StopReplicationBackfill`
- `UpdateImageStorageClass`
- `UpdatePullThroughCacheRule`
- `UpdateRepositoryCreationTemplate`
@@ -819,19 +830,22 @@ spot-price: On owned hardware there is no spot-to-on-demand price differential,
## ecs
`2014-11-13` — 31 of 77 modelled operations implemented (40.3%), 5 stubbed, 0 not applicable, 3 registered outside the pinned model.
`2014-11-13` — 31 of 88 modelled operations implemented (35.2%), 5 stubbed, 0 not applicable, 3 registered outside the pinned model.
### Not implemented
- `AssociateGatewayWithCluster`
- `ContinueServiceDeployment`
- `CreateDaemon`
- `CreateExpressGatewayService`
- `CreateGateway`
- `CreateTaskSet`
- `DeleteAccountSetting`
- `DeleteAttributes`
- `DeleteDaemon`
- `DeleteDaemonTaskDefinition`
- `DeleteExpressGatewayService`
- `DeleteGateway`
- `DeleteTaskDefinitions`
- `DeleteTaskSet`
- `DescribeDaemon`
@@ -839,9 +853,13 @@ spot-price: On owned hardware there is no spot-to-on-demand price differential,
- `DescribeDaemonRevisions`
- `DescribeDaemonTaskDefinition`
- `DescribeExpressGatewayService`
- `DescribeGateway`
- `DescribeGatewayResources`
- `DescribeGatewayRoute`
- `DescribeServiceDeployments`
- `DescribeServiceRevisions`
- `DescribeTaskSets`
- `DisassociateGatewayFromCluster`
- `DiscoverPollEndpoint`
- `ExecuteCommand`
- `GetTaskProtection`
@@ -849,6 +867,9 @@ spot-price: On owned hardware there is no spot-to-on-demand price differential,
- `ListDaemonDeployments`
- `ListDaemonTaskDefinitions`
- `ListDaemons`
- `ListGatewayClusterAssociations`
- `ListGatewayRoutes`
- `ListGateways`
- `ListServiceDeployments`
- `PutAccountSettingDefault`
- `PutAttributes`
@@ -861,6 +882,7 @@ spot-price: On owned hardware there is no spot-to-on-demand price differential,
- `UpdateContainerAgent`
- `UpdateDaemon`
- `UpdateExpressGatewayService`
- `UpdateGateway`
- `UpdateServicePrimaryTaskSet`
- `UpdateTaskProtection`
- `UpdateTaskSet`
@@ -1085,7 +1107,7 @@ signing-certificates: X.509 signing certificates are an EC2-Classic SOAP credent
## rds
`2014-10-31` — 27 of 164 modelled operations implemented (16.5%), 0 stubbed, 4 not applicable, 5 registered outside the pinned model.
`2014-10-31` — 27 of 168 modelled operations implemented (16.1%), 0 stubbed, 4 not applicable, 5 registered outside the pinned model.
### Not implemented
@@ -1095,6 +1117,7 @@ signing-certificates: X.509 signing certificates are an EC2-Classic SOAP credent
- `ApplyPendingMaintenanceAction`
- `AuthorizeDBSecurityGroupIngress`
- `BacktrackDBCluster`
- `CancelDBCapacityReservation`
- `CancelExportTask`
- `CopyDBClusterParameterGroup`
- `CopyDBClusterSnapshot`
@@ -1103,6 +1126,7 @@ signing-certificates: X.509 signing certificates are an EC2-Classic SOAP credent
- `CopyOptionGroup`
- `CreateBlueGreenDeployment`
- `CreateCustomDBEngineVersion`
- `CreateDBCapacityReservation`
- `CreateDBClusterEndpoint`
- `CreateDBClusterParameterGroup`
- `CreateDBClusterSnapshot`
@@ -1133,6 +1157,7 @@ signing-certificates: X.509 signing certificates are an EC2-Classic SOAP credent
- `DescribeAccountAttributes`
- `DescribeBlueGreenDeployments`
- `DescribeCertificates`
- `DescribeDBCapacityReservations`
- `DescribeDBClusterAutomatedBackups`
- `DescribeDBClusterBacktracks`
- `DescribeDBClusterEndpoints`
@@ -1174,6 +1199,7 @@ signing-certificates: X.509 signing certificates are an EC2-Classic SOAP credent
- `ModifyCertificates`
- `ModifyCurrentDBClusterCapacity`
- `ModifyCustomDBEngineVersion`
- `ModifyDBCapacityReservation`
- `ModifyDBClusterEndpoint`
- `ModifyDBClusterParameterGroup`
- `ModifyDBClusterSnapshotAttribute`
@@ -1243,6 +1269,71 @@ option-groups: Option groups configure engine add-ons for engines this platform
- `DescribeOptionGroups`
- `ModifyOptionGroup`
## route53
`2013-04-01` — 13 of 71 modelled operations implemented (18.3%), 0 stubbed, 0 not applicable, 0 registered outside the pinned model.
### Not implemented
- `ActivateKeySigningKey`
- `AssociateVPCWithHostedZone`
- `ChangeCidrCollection`
- `CreateCidrCollection`
- `CreateHealthCheck`
- `CreateKeySigningKey`
- `CreateQueryLoggingConfig`
- `CreateReusableDelegationSet`
- `CreateTrafficPolicy`
- `CreateTrafficPolicyInstance`
- `CreateTrafficPolicyVersion`
- `CreateVPCAssociationAuthorization`
- `DeactivateKeySigningKey`
- `DeleteCidrCollection`
- `DeleteHealthCheck`
- `DeleteKeySigningKey`
- `DeleteQueryLoggingConfig`
- `DeleteReusableDelegationSet`
- `DeleteTrafficPolicy`
- `DeleteTrafficPolicyInstance`
- `DeleteVPCAssociationAuthorization`
- `DisableHostedZoneDNSSEC`
- `DisassociateVPCFromHostedZone`
- `EnableHostedZoneDNSSEC`
- `GetAccountLimit`
- `GetCheckerIpRanges`
- `GetDNSSEC`
- `GetGeoLocation`
- `GetHealthCheck`
- `GetHealthCheckCount`
- `GetHealthCheckLastFailureReason`
- `GetHealthCheckStatus`
- `GetHostedZoneLimit`
- `GetQueryLoggingConfig`
- `GetReusableDelegationSet`
- `GetReusableDelegationSetLimit`
- `GetTrafficPolicy`
- `GetTrafficPolicyInstance`
- `GetTrafficPolicyInstanceCount`
- `ListCidrBlocks`
- `ListCidrCollections`
- `ListCidrLocations`
- `ListGeoLocations`
- `ListHealthChecks`
- `ListHostedZonesByVPC`
- `ListQueryLoggingConfigs`
- `ListReusableDelegationSets`
- `ListTrafficPolicies`
- `ListTrafficPolicyInstances`
- `ListTrafficPolicyInstancesByHostedZone`
- `ListTrafficPolicyInstancesByPolicy`
- `ListTrafficPolicyVersions`
- `ListVPCAssociationAuthorizations`
- `TestDNSAnswer`
- `UpdateHealthCheck`
- `UpdateHostedZoneFeatures`
- `UpdateTrafficPolicyComment`
- `UpdateTrafficPolicyInstance`
## s3
`2006-03-01` — 26 of 112 modelled operations implemented (23.2%), 0 stubbed, 6 not applicable, 0 registered outside the pinned model.
@@ -1350,6 +1441,21 @@ request-payment: Requester Pays shifts transfer charges to the caller, which nee
- `GetBucketRequestPayment`
- `PutBucketRequestPayment`
## sqs
`2012-11-05` — 15 of 23 modelled operations implemented (65.2%), 0 stubbed, 0 not applicable, 0 registered outside the pinned model.
### Not implemented
- `AddPermission`
- `CancelMessageMoveTask`
- `DeleteQueue`
- `ListDeadLetterSourceQueues`
- `ListMessageMoveTasks`
- `PurgeQueue`
- `RemovePermission`
- `StartMessageMoveTask`
## sts
`2011-06-15` — 5 of 11 modelled operations implemented (45.5%), 0 stubbed, 0 not applicable, 0 registered outside the pinned model.
+14
View File
@@ -247,6 +247,20 @@
"option-groups": "Option groups configure engine add-ons for engines this platform does not offer, such as Oracle and SQL Server."
}
},
"route53": {
"slug": "route53",
"name": "Route 53",
"title": "Route 53 API Coverage",
"seoTitle": "Amazon Route 53 API Coverage on Spinifex — Spinifex Docs",
"description": "The Amazon Route 53 API operations Spinifex implements, covering the hosted zones it serves, their resource record sets, change status and tags on each zone.",
"tags": [
"aws",
"compatibility",
"coverage",
"route53",
"dns"
]
},
"s3": {
"slug": "s3",
"name": "S3",
+38
View File
@@ -0,0 +1,38 @@
---
title: "Route 53 API Coverage"
seoTitle: "Amazon Route 53 API Coverage on Spinifex — Spinifex Docs"
description: "The Amazon Route 53 API operations Spinifex implements, covering the hosted zones it serves, their resource record sets, change status and tags on each zone."
category: "Coverage"
sections:
- overview
tags:
- aws
- compatibility
- coverage
- route53
- dns
---
# Route 53 API Coverage
## Overview
Spinifex implements **13 operations** in the Route 53 `2013-04-01` API model.
### Operations
| Operation |
|---|
| `ChangeResourceRecordSets` |
| `ChangeTagsForResource` |
| `CreateHostedZone` |
| `DeleteHostedZone` |
| `GetChange` |
| `GetHostedZone` |
| `GetHostedZoneCount` |
| `ListHostedZones` |
| `ListHostedZonesByName` |
| `ListResourceRecordSets` |
| `ListTagsForResource` |
| `ListTagsForResources` |
| `UpdateHostedZoneComment` |
+7 -1
View File
@@ -163,11 +163,14 @@ Open between all hosts:
| Protocol and port | Used by |
|---|---|
| UDP 6081 | Geneve tunnels |
| UDP 500, 4500 and IP protocol 50 (ESP) | IPsec for the Geneve tunnels, while IPsec is on |
| TCP 4222, 4248 | NATS |
| TCP 6641, 6642 | OVN northbound and southbound |
| TCP 8443 | Predastore S3 gate |
| UDP 6660, 7660 | Object shards, metadata consensus |
UDP 4500 carries the tunnel traffic only where IKE detects NAT between two nodes; otherwise it travels as ESP, on Oracle Linux as on Debian.
Open between the three OVN database servers (servers 1 to 3) only:
| Protocol and port | Used by |
@@ -207,7 +210,10 @@ Spinifex's own **host** firewall is separate, and already carries this policy
Choose one method and apply it to **every** server in the cluster.
**Option A — existing OS.** On a server already running Ubuntu 26.04 or Debian 13 (Oracle Linux runs a single node only for now, see [Oracle Linux Hosts](/docs/install-oracle-linux)):
**Option A — existing OS.** Use this on servers already running Ubuntu 26.04, Debian 13 or Oracle Linux 10, and prepare an Oracle Linux server with [Oracle Linux Hosts](/docs/install-oracle-linux) first.
Every server in a cluster must run the same OS: Oracle Linux never shares a cluster with Debian or Ubuntu, and `spx admin join` does not check this for you.
Run the installer on each server:
```bash
curl -fsSL https://install.mulgadc.com | bash
+87 -120
View File
@@ -1,12 +1,13 @@
---
title: "Oracle Linux Hosts"
seoTitle: "Install Spinifex on Oracle Linux 10 — Spinifex Docs"
description: "Prepare an Oracle Linux 10 host for Spinifex: UEK R8, SELinux, a NetworkManager br-wan bridge, the CentOS NFV SIG repository, firewalld handover and updates."
description: "Install Spinifex on Oracle Linux 10: prepare the host with UEK R8, SELinux permissive and a NetworkManager br-wan bridge, then run the single-node install."
category: "Install"
tags:
- install
- oracle linux
- single node
- multi node
resources:
- title: "Oracle Linux 10 Documentation"
url: "https://docs.oracle.com/en/operating-systems/oracle-linux/10/"
@@ -16,56 +17,42 @@ resources:
# Oracle Linux Hosts
> What differs when the Spinifex host runs Oracle Linux 10 instead of Debian or Ubuntu.
> Install Spinifex on Oracle Linux 10.
## Table of Contents
- [Overview](#overview)
- [Prerequisites](#prerequisites)
- [Instructions](#instructions)
- [What the Installer Changes](#what-the-installer-changes)
- [Troubleshooting](#troubleshooting)
---
## Overview
Spinifex installs on Oracle Linux 10.2 or a later 10.x release, running the UEK R8 kernel, on x86_64.
The installer, the release tarball and the install steps are the same as on Debian; this page covers what the host needs first and what the installer changes on it.
Follow it alongside [Single-Node Install](/docs/install).
Spinifex runs on Oracle Linux 10.2, on the UEK R8 kernel, on x86_64 with an x86-64-v3 CPU (Intel Haswell, AMD Excavator or later).
**Supported on Oracle Linux:**
The host must be a dedicated Spinifex host: Spinifex claims every `tap*` device and owns libreswan's `ipsec.service`.
- A single node.
- Guests, VPCs, routed NAT, Elastic IPs, and Application and Network Load Balancers.
- Whole-GPU passthrough, on x86_64 GPU hosts (see [GPU Passthrough](/docs/gpu-passthrough)).
**Not supported yet:** clusters mixing Oracle Linux with Debian or Ubuntu, SELinux enforcing, aarch64, Oracle Linux 9 or earlier, the Red Hat Compatible Kernel, a ZFS root, NVIDIA MIG and air-gapped installs.
**Not supported on Oracle Linux yet:**
## Instructions
- Clusters of more than one node. They wait for IPsec on libreswan, so `spx admin init` turns IPsec off on Oracle Linux and refuses `--ipsec=true`.
- Clusters that mix Oracle Linux with Debian or Ubuntu nodes, on any release.
- SELinux enforcing, aarch64, Oracle Linux 9 or earlier, the Red Hat Compatible Kernel, a ZFS root, and NVIDIA MIG.
Work through both tabs in order.
Open vSwitch and OVN come from the CentOS NFV SIG, because Oracle ships neither for Oracle Linux 10.
The NFV SIG is a community repository, and Oracle does not support the packages it publishes.
Spinifex installs `openvswitch3.5` and `ovn25.03`, the versions Debian 13 ships, and its repository file lets the SIG supply nothing else.
QEMU comes from Oracle's KVM Utilities repository, because only that build has the `microvm` machine type Spinifex load balancers boot on.
:::tabs
@tab 1. Prepare Oracle Linux
An Oracle Linux node must be a dedicated Spinifex host.
Spinifex claims every `tap*` device and stops libreswan's `ipsec.service`, so VPNs, hand-run QEMU guests and libreswan tunnels of your own cannot share the host.
Crypto policies other than `DEFAULT` and `FIPS` are untested, and so is Secure Boot.
### Update and check the host
## Prerequisites
### Operating system and kernel
Install Oracle Linux 10 with Oracle's ISO or a KVM template, then update it to the current minor release and reboot:
Update to the current minor release and reboot:
```bash
sudo dnf update -y
sudo reboot
```
After the reboot, check the release, the kernel and the CPU:
Then check the release, the running kernel and the CPU:
```bash
cat /etc/oracle-release # Oracle Linux Server release 10.2 or later
@@ -73,17 +60,7 @@ uname -r # 6.12.0-...el10uek.x86_64
grep -c -w avx2 /proc/cpuinfo # non-zero
```
The kernel must be UEK R8 (`6.12.0-*uek`), and it must be the running kernel: an installed but not booted `kernel-uek` is refused.
The CPU must be x86-64-v3 or newer (Intel Haswell, AMD Excavator or later), since the NFV SIG and KVM Utilities builds target it.
The installer names any missing CPU flag.
The installer adds `kernel-uek-modules` for the running kernel, which carries `openvswitch.ko` and pulls in `linux-firmware`, about 800 MB.
Oracle publishes modules for each kernel separately, so a host running an old kernel may find its modules gone from the repository; the installer then stops and asks for a kernel update and a reboot.
### SELinux
Spinifex ships no SELinux policy yet, so SELinux must be permissive or disabled, both now and at the next boot.
Oracle's images boot enforcing.
### Set SELinux permissive
```bash
sudo setenforce 0
@@ -91,14 +68,9 @@ sudo sed -i 's/^SELINUX=.*/SELINUX=permissive/' /etc/selinux/config
getenforce # Permissive
```
The installer and `spx admin preflight` refuse a host that is enforcing in either place.
### Create the br-wan bridge
### WAN bridge with NetworkManager
As on every OS, the host's uplink must already sit on a Linux bridge named `br-wan`, with the host address and default route on the bridge (see [Single-Node Install](/docs/install#prerequisites)).
On Oracle Linux, NetworkManager owns `br-wan`; Spinifex leaves the bridge and its NIC to it and marks the links it creates itself as unmanaged.
Create the bridge from the console, not over SSH on the NIC being moved, since the host loses its address while the profile changes.
Run this from the console, not over SSH on the NIC being moved.
Replace `eno1` with the uplink NIC and `"System eno1"` with its current profile (`nmcli connection show`):
```bash
@@ -109,96 +81,88 @@ sudo nmcli connection down "System eno1"
sudo nmcli connection up br-wan
```
For a static address, give the bridge `ipv4.method manual` with `ipv4.addresses`, `ipv4.gateway` and `ipv4.dns` instead of `ipv4.method auto`.
`bridge.stp no` matters: a NetworkManager bridge runs STP by default, which holds the port in its forwarding delay for about 15 seconds after every link up, long enough for DHCP on the bridge to time out.
Then verify, as on Debian:
For a static address, use `ipv4.method manual` with `ipv4.addresses`, `ipv4.gateway` and `ipv4.dns`.
Keep `bridge.stp no`, or DHCP on the bridge can time out.
```bash
ip -br link show br-wan # UP
ip route # default via ... dev br-wan
```
### Network access during install
@tab 2. Install Single Node
Besides `install.mulgadc.com` and the AWS CLI download, the install reaches `yum.oracle.com` for the Oracle Linux repositories, `mirror.stream.centos.org` for the NFV SIG packages, and `www.centos.org` for the NFV SIG signing key.
An air-gapped install on Oracle Linux is not documented yet.
## Instructions
## Step 1. Install Spinifex
Run the installer as a user with `sudo`, exactly as on Debian:
### Install Spinifex
```bash
curl -fsSL https://install.mulgadc.com | bash
```
Then continue with [Single-Node Install](/docs/install) from Step 2: `setup-ovn.sh --management`, `spx admin init` and `systemctl start spinifex.target`.
`spx admin init` prints that IPsec is off on this host; on a single node there is no peer to encrypt traffic to.
The installer stops and masks firewalld; see [firewalld](#firewalld) before relying on any rule it held.
## Step 2. Know What the Installer Changed
On Oracle Linux the installer makes these changes, which Debian hosts do not need:
- **Repositories.** It enables `ol10_UEKR8` and `ol10_kvm_utils`, and adds `qemu-kvm*` and `qemu-img*` to the `excludepkgs` of `ol10_appstream`, keeping any excludes already set, so a later AppStream build can never replace the KVM Utilities QEMU.
- **NFV SIG.** It writes `/etc/yum.repos.d/spinifex-nfv-sig.repo` with an `includepkgs=` list of the OVS and OVN packages, and imports the SIG key to `/etc/pki/rpm-gpg/RPM-GPG-KEY-CentOS-SIG-NFV` only when its fingerprint is `35154228174901BEFA8E69A621465E289D2A76A7`.
- **OVS and OVN user.** Both run as root, as on Debian, through `OVS_USER_ID` and `OVN_USER_ID` in `/etc/sysconfig/openvswitch` and `/etc/sysconfig/ovn`.
- **DNS.** Where systemd-resolved is installed but not running, it saves `/etc/resolv.conf` to `/var/lib/spinifex-reset/resolv.conf.orig`, starts resolved behind NetworkManager and links `/etc/resolv.conf` to the resolved stub. It then checks that names still resolve and stops, printing the commands that put DNS back, if they do not.
- **chrony.** It appends `confdir /etc/chrony.d` to `/etc/chrony.conf` once, and writes its step policy to `/etc/chrony.d/spinifex.conf`.
- **`spx` under `sudo`.** Oracle Linux's `sudo` path leaves out `/usr/local/bin`, so the installer links `/usr/bin/spx` to `/usr/local/bin/spx`.
The kernel modules list (`/etc/modules-load.d/spinifex.conf`) and the host firewall below apply on every OS.
## Step 3. Replace firewalld Rules You Depend On
The installer stops and masks firewalld, because with it running a packet must pass both firewalld's table and the Spinifex one, which silently breaks routed NAT, Elastic IPs and instance metadata.
When firewalld was active, the installer turns on the Spinifex nftables policy in its place; pass `--firewall=on` or `--firewall=off` to choose yourself.
firewalld's rules are not saved or migrated.
The Spinifex policy filters inbound traffic only and opens 53, 443, 3000, 8443 and 9999 to any source.
It accepts SSH from every IPv4 source until `$trusted_ssh_peers` in `/etc/spinifex/firewall/custom.nft` narrows it.
Restate any other restriction you had, such as SSH limited to a management subnet, in that file; [Host Firewall](/docs/host-firewall) shows how.
From the moment firewalld stops until the daemon first loads the Spinifex policy, the host accepts every connection.
Uninstalling Spinifex leaves firewalld unmasked but disabled; it does not turn it back on.
## Step 4. Keep the Host Updated
Do not enable `dnf-automatic`.
An Open vSwitch package upgrade restarts OVS, which interrupts guest networking, so update `openvswitch3.5`, `ovn25.03*` and `qemu-kvm*` in a maintenance window.
Stay on the current Oracle Linux 10 minor release, which is what Spinifex is tested on.
Before rebooting into a new kernel, check that its modules are installed, or OVS cannot start after the reboot:
### Set up OVN networking
```bash
rpm -q kernel-uek-core kernel-uek-modules
sudo /usr/local/share/spinifex/setup-ovn.sh --management
```
Each `kernel-uek-core` version needs a `kernel-uek-modules` of the same version; install a missing one with `sudo dnf install kernel-uek-modules-<version>`.
### Initialize
Track Oracle Linux security advisories (ELSA) for the host, the kernel and QEMU, for example with `dnf updateinfo list --security`.
Those advisories do not cover the NFV SIG packages, so follow Open vSwitch and OVN security announcements for those.
```bash
sudo spx admin init --node node1 --nodes 1
```
## Step 5. Uninstall
### Start services
`/usr/local/share/spinifex/uninstall-spx.sh` removes Spinifex as on Debian, and lists what it leaves in place on Oracle Linux: the repository changes and the SIG key, `OVS_USER_ID` and `OVN_USER_ID`, the OVS and OVN logrotate edits, the chrony include, `/etc/modules-load.d/spinifex.conf`, the SELinux mode, a masked `ipsec.service`, firewalld disabled, and any GPU passthrough configuration.
`--purge-deps` is refused on Oracle Linux, because dnf cannot tell the packages Spinifex installed from the host's own; remove them with `dnf remove` yourself.
```bash
sudo systemctl start spinifex.target
```
### Verify
```bash
export AWS_PROFILE=spinifex
aws ec2 describe-instance-types
```
If this returns a list of instance types, Spinifex is installed.
Continue to [Setting Up Your Cluster](/docs/setting-up-your-cluster) to import an AMI and launch your first instance.
:::
**For a cluster**, prepare and install every node with the steps above, then continue with [Multi-Node Install](/docs/install-multi-node) from Step 2.
Every node must run Oracle Linux.
IPsec between nodes runs on libreswan, so open UDP 500, UDP 4500 and ESP between nodes on any firewall between them.
## What the Installer Changes
On Oracle Linux the installer also:
- Enables `ol10_UEKR8` and `ol10_kvm_utils`, and excludes `qemu-kvm*` and `qemu-img*` from `ol10_appstream`.
- Adds the NFV SIG repository as `/etc/yum.repos.d/spinifex-nfv-sig.repo`, limited to the OVS and OVN packages.
- Installs `kernel-uek-modules` for the running kernel, about 800 MB with `linux-firmware`.
- Switches DNS to systemd-resolved behind NetworkManager where resolved is installed but not running.
- Links `/usr/bin/spx` to `/usr/local/bin/spx`, so `sudo spx` works.
### firewalld
The installer stops and masks firewalld and turns on the Spinifex nftables policy in its place; firewalld's rules are not migrated.
The Spinifex policy opens 53, 443, 3000, 8443, 9999 and SSH to any source.
Restate any restriction you relied on, such as SSH limited to a management subnet, in `/etc/spinifex/firewall/custom.nft`; see [Host Firewall](/docs/host-firewall).
### Updates
Do not enable `dnf-automatic`: an Open vSwitch upgrade restarts OVS and interrupts guest networking, so update `openvswitch3.5`, `ovn25.03*` and `qemu-kvm*` in a maintenance window.
Before rebooting into a new kernel, check `rpm -q kernel-uek-core kernel-uek-modules` shows matching versions, or OVS cannot start.
### Uninstall
`/usr/local/share/spinifex/uninstall-spx.sh` removes Spinifex and lists what it leaves in place.
`--purge-deps` is refused on Oracle Linux; remove packages with `dnf remove` yourself.
## Troubleshooting
### Installer stops: SELinux is enforcing
### Installer stops on SELinux, the kernel or kernel modules
Set SELinux permissive now and in `/etc/selinux/config`, as in [SELinux](#selinux), then run the installer again.
Fixing only one of the two is refused too.
### Installer stops: the running kernel is not UEK R8
Install `kernel-uek`, reboot into it, check `uname -r`, then run the installer again.
### Installer stops: kernel-uek-modules is no longer in the repositories
The running kernel is older than anything the repository still carries modules for.
Set SELinux permissive both now and in `/etc/selinux/config`, or update and boot into the current UEK R8 kernel, then run the installer again:
```bash
sudo dnf update -y kernel-uek
@@ -207,27 +171,30 @@ sudo reboot
### Installer stops: a repository is unreachable
The message names the repository.
Check the host can reach `yum.oracle.com` and `mirror.stream.centos.org` over HTTPS, including through any proxy dnf uses, then run the installer again.
Check the host can reach `yum.oracle.com` and `mirror.stream.centos.org` over HTTPS, including through any proxy dnf uses.
### DHCP on br-wan times out
Check STP is off on the bridge:
STP is on. Turn it off:
```bash
nmcli -g bridge.stp connection show br-wan
sudo nmcli connection modify br-wan bridge.stp no
sudo nmcli connection up br-wan
```
If it prints `yes`, run `sudo nmcli connection modify br-wan bridge.stp no` and `sudo nmcli connection up br-wan`.
### Cluster nodes form no IPsec tunnels
### `sudo spx` finds no command
```bash
systemctl is-active ipsec.service openvswitch-ipsec.service
sudo ipsec trafficstatus # one line per peer tunnel
```
Check that `/usr/bin/spx` links to `/usr/local/bin/spx`; run the installer again if it does not, or call `sudo /usr/local/bin/spx` meanwhile.
If the services are inactive, check `journalctl -u spinifex-daemon`.
If no tunnel is listed, check UDP 500 and ESP pass between the nodes in both directions.
Guest traffic between two nodes is dropped until their tunnel forms.
### CA certificate not trusted
`sudo spx admin init` installs the CA automatically. To install it again by hand on Oracle Linux:
```bash
sudo cp /etc/spinifex/ca.pem /etc/pki/ca-trust/source/anchors/spinifex-ca.crt
sudo update-ca-trust
+1 -1
View File
@@ -39,7 +39,7 @@ This guide installs Spinifex on a single server using the binary installer. For
- Ubuntu 26.04
- Debian 13
- Oracle Linux 10.2 or a later 10.x, on UEK R8, x86_64 only, as a single node. Open vSwitch and OVN come from the CentOS NFV SIG, a community repository that Oracle does not support. Prepare the host with [Oracle Linux Hosts](/docs/install-oracle-linux) first.
- Oracle Linux 10.2 or a later 10.x, on UEK R8, x86_64 only, as a single node or, with [Multi-Node Install](/docs/install-multi-node), a cluster. Open vSwitch and OVN come from the CentOS NFV SIG, a community repository that Oracle does not support. Prepare the host with [Oracle Linux Hosts](/docs/install-oracle-linux) first.
**What Gets Installed:**
+29
View File
@@ -269,6 +269,35 @@ ip link set <dev> mtu 9000 # both ends
ping -M do -s 8972 -c 3 <peer> # must succeed
```
### The internet path
A guest MTU of 1408 or 1442 sits below the 1500 bytes the internet carries, so a server can send the guest segments it cannot receive.
The VPC gateway port carries `gateway_mtu = min(guest MTU, 1500)` and answers an oversized packet with ICMP fragmentation-needed (IPv4) or Packet Too Big (IPv6).
It follows `ipsec_enabled` and `network.underlay_mtu` and converges on the next reconcile pass, with no VPC recreate.
Path MTU discovery depends on that ICMP reaching the server, and an upstream NAT router or a CDN often drops it.
vpcd therefore also clamps the TCP MSS on the host hop to the gateway MTU minus 40 (IPv4) or minus 60 (IPv6), and re-asserts the rule every 30 seconds so a firewall reload cannot remove it for long.
| Uplink | Clamp | Table |
| --- | --- | --- |
| veth (distributed NAT) | yes | `bridge spinifex_mss`, matching the `veth-wan-br` port |
| routed NAT | yes | `inet spinifex_mss`, matching `spx-nat-host` |
| physical | no host hop exists | path MTU discovery only |
On a physical uplink the only cover is path MTU discovery, which fails when the upstream drops the ICMP or the peer ignores it.
Measured on one node (env12), IPsec on, Ubuntu 26.04 guest, 16 MB HTTPS download inside a Docker container at the default bridge MTU of 1500:
| Uplink | Without the clamp | With the clamp |
| --- | --- | --- |
| veth, instance with a public IP | stalled 3/3 | completed 3/3 |
| routed NAT | stalled 3/3, no ICMP seen on the host | completed 3/3 |
An instance behind a NAT gateway on the veth uplink also completed 3/3 with the clamp; its unclamped internet run was not measured.
The guest's own `curl` completed in every case.
Without the clamp, `gateway_mtu` alone fixed the download only against a server on the same L2 with no NAT in between (completed 3/3), and not against the internet through the lab's upstream NAT router.
Not measured: a physical uplink, a multi-node cluster, IPv6, and a Debian guest.
## 5. Measurement notes
Throughput on this stack is **noisy** — repeated identical 20s runs varied between
@@ -16,16 +16,16 @@ Create, update, delete and policy association complete synchronously within the
|---|---|---|
| Identity | Cluster name plus principal ARN. Key and ARN suffix use `sha256hex(principalARN)` (`domains/eks/access/store.go` `PrincipalARNHash`). ARN `arn:aws:eks:{region}:{account}:access-entry/{cluster}/{hash}` (`foundation/aws/arn/eks.go`); the AWS ARN shape differs and is not verified. | `handlers/eks/service_impl_test.go` `TestAccessEntry_CreateDescribeListDelete`, `domains/eks/access/store_test.go` `TestKeyPaths`, `gateway/eks/authz_test.go` `TestResourceARNsAccessEntryMatchesHandler` |
| Scope | Account and Region, within one cluster: keys live under `clusters/{cluster}/access-entries/` in the `eks-account-{account}` bucket. | `handlers/eks/access_lifecycle_test.go` `TestAccessEntryPersistedLayout_TopLevelAndNestedFieldNames`, `TestAccessEntryRecord_DecodesHandWrittenJSON`, `handlers/eks/create_cluster_test.go` `TestCreateCluster_SeedsCreatorAdminAccessEntry` |
| Durable owner and record | `domains/eks/access` (`Owner`) is the only writer of `Record` with nested `AssociatedPolicy` and `Scope`: Create, SeedCreatorAdmin (cluster launch), Update, Associate, Disassociate and Delete. Keys and field names are unchanged by the move. `DeleteClusterPrefix` (`handlers/eks/cluster_state.go`) still erases entries with the cluster, and the gateway process reads records through the exported `access.Get` in `ResolveTokenReview` (`handlers/eks/token_review.go`). | `handlers/eks/access_lifecycle_test.go` `TestAccessEntryPersistedLayout_TopLevelAndNestedFieldNames`, `TestAccessEntryRecord_DecodesHandWrittenJSON`, `domains/eks/access/owner_test.go`, `domains/eks/access/store_test.go`, `handlers/eks/create_cluster_test.go` `TestCreateCluster_SeedsCreatorAdminAccessEntry` |
| Durable owner and record | `domains/eks/access` (`Owner`) is the only writer of `Record` with nested `AssociatedPolicy` and `Scope`: Create, SeedCreatorAdmin (cluster launch), Update, Associate, Disassociate and Delete. Keys and field names are unchanged by the move. `DeleteClusterChildren` (`domains/eks/cluster/cluster_state.go`) still erases entries with the cluster, and the gateway process reads records through the exported `access.Get` in `ResolveTokenReview` (`handlers/eks/token_review.go`). | `handlers/eks/access_lifecycle_test.go` `TestAccessEntryPersistedLayout_TopLevelAndNestedFieldNames`, `TestAccessEntryRecord_DecodesHandWrittenJSON`, `domains/eks/access/owner_test.go`, `domains/eks/access/store_test.go`, `handlers/eks/create_cluster_test.go` `TestCreateCluster_SeedsCreatorAdminAccessEntry` |
| AWS adapter | `handlers/eks` keeps request validation (entry type, supported policy, scope), the cluster existence check (`acctKVForCluster`), awserrors mapping and the AWS projection. `gateway/eks/authz.go` uses `access.PrincipalARNHash`. | `handlers/eks/service_impl_test.go` access cases, `gateway/eks/authz_test.go` `TestResourceARNsAccessEntryMatchesHandler` |
| Desired state | Principal, Kubernetes username and groups, type (`STANDARD` only), tags (set at create only) and cluster-scoped access policy associations. | `handlers/eks/service_impl_test.go` `TestAccessEntry_RejectsNodeType`, `TestAccessPolicy_AssociateRejectsUnsupportedPolicyAndScope`, `TestAccessPolicy_AssociateRejectsNamespaceScope`, `handlers/eks/access_lifecycle_test.go` `TestAccessEntry_UpdateWithNoFieldsKeepsUsernameAndGroups` |
| Generation | None. Update, associate and disassociate retry against the KV revision; create and delete do not. | |
| Observed state | Not tracked; the record is the only state. | |
| Readiness | Usable by token review as soon as the write returns. | `handlers/eks/token_review_test.go` `TestResolveTokenReview_AuthenticatesViaVerifyAndKV`, `TestResolveTokenReview_NoAccessEntryDenies`, `handlers/eks/service_impl_test.go` `TestAccessPolicy_DisassociateRemovesProjectedGroup` |
| Idempotency | `clientRequestToken` is ignored. Update always advances `modifiedAt`, even when nothing changes. A duplicate create returns `ResourceInUseException`; a repeated delete returns `ResourceNotFoundException`; disassociating an unbound policy succeeds without change. Internal NATS redelivery of create returns `ResourceInUseException` to the redelivered request. | `handlers/eks/service_impl_test.go` `TestAccessEntry_CreateDescribeListDelete`, `TestAccessEntry_DescribeDeleteMissingIsNotFound`, `handlers/eks/access_lifecycle_test.go` `TestAccessPolicy_DisassociateUnassociatedLeavesRecordUnchanged`, `TestAccessPolicy_AssociateWithNoEntryIsNotFound` |
| Dependencies | Every action requires only that the cluster's meta record exists (`acctKVForCluster`); cluster status is not read, so actions succeed on a `CREATING`, `FAILED` or `DELETING` cluster. Token review does not check the cluster at all. The principal is not checked against IAM. | `handlers/eks/service_impl_test.go` `TestAccessEntry_UnknownClusterIsNotFound`, `handlers/eks/access_lifecycle_test.go` `TestAccessEntryActionsIgnoreClusterLifecycleStatus`, `TestResolveTokenReview_AuthenticatesWithoutClusterMeta` |
| Dependencies | Every action requires that the cluster's meta record exists (`acctKVForCluster`). `CreateAccessEntry` also refuses a `DELETING` cluster with `ResourceInUseException`, since a recorded delete admits no new dependant; no other status is read, so actions succeed on a `CREATING` or `FAILED` cluster. Token review does not check the cluster at all. The principal is not checked against IAM. | `handlers/eks/service_impl_test.go` `TestAccessEntry_UnknownClusterIsNotFound`, `handlers/eks/access_lifecycle_test.go` `TestAccessEntryActionsIgnoreFailedClusterStatus`, `delete_cluster_async_test.go` `TestS2_EKSDeletingClusterAdmitsNoNewDependants`, `TestResolveTokenReview_AuthenticatesWithoutClusterMeta` |
| Authorization | Gateway authz derives the access-entry ARN with `PrincipalARNHash` (`gateway/eks/authz.go`). No `iam:PassRole`. The STS step of token review is the existing `eks.VerifyToken` NATS contract served by awsgw. | `gateway/eks/authz_test.go` `TestResourceARNsAccessEntryMatchesHandler` |
| Deletion ordering | Delete is get-then-delete, not conditional. Cluster delete erases entries in the prefix purge only after all infrastructure teardown succeeds; on failure they survive while the cluster stays `DELETING`. | `handlers/eks/access_lifecycle_test.go` `TestDeleteClusterPrefix_SweepsAccessEntriesScopedToCluster` |
| Deletion ordering | Delete is get-then-delete, not conditional. Cluster delete erases entries with the cluster's other child records, before the meta, only after all infrastructure teardown succeeds; on failure they survive while the cluster stays `DELETING`. | `handlers/eks/access_lifecycle_test.go` `TestDeleteClusterChildren_SweepsAccessEntriesScopedToCluster` |
| Restart and interruption | The prefix purge deletes keys in listing order; an interruption after the meta key is gone leaves entries that a new cluster of the same name inherits. Reclaiming a `FAILED` cluster and a failed launch purge with the meta kept, so the recreated cluster inherits the failed attempt's entries. | none |
| End-to-end | `tests/e2e/eks/eks_test.go` `AccessEntry` and `GetToken`; `tests/integration/cross_tenant_test.go`. No Terraform apply/destroy evidence for `aws_eks_access_entry` or `aws_eks_access_policy_association`. | |
@@ -33,7 +33,7 @@ Create, update, delete and policy association complete synchronously within the
1. Create is get-then-put with no exclusive create, so concurrent creates for one principal both succeed and the later write wins.
2. The creator-admin seed at cluster launch is an unconditional put after the cluster meta is final, so it replaces an entry for the same principal created while the cluster was `CREATING`, including its policy associations.
3. Access actions do not check cluster status (see dependencies row), and opening the bucket creates it for an account that has none.
3. Access actions other than `CreateAccessEntry` do not check cluster status (see dependencies row), and opening the bucket creates it for an account that has none.
4. Entries can outlive their cluster (interrupted prefix purge) or carry over to a recreated cluster (reclaim and failed launch keep the meta and do not purge entries separately).
5. Token review authenticates against an entry without checking the cluster exists or its status.
6. `ResolveTokenReview` opens the EKS bucket and reads the record in the gateway process through the exported `access.Get`, outside the owner.
+9 -9
View File
@@ -15,10 +15,10 @@ There is no generation, so the record mixes desired fields with the latest obser
| Item | Present behaviour | Evidence |
|---|---|---|
| Identity | Cluster name plus add-on name; one add-on per name per cluster. ARN `arn:aws:eks:{region}:{account}:addon/{cluster}/{addon}` (`foundation/aws/arn/eks.go` `FormatEKSAddon`), deterministic by name, with no per-incarnation suffix. The gateway authorizer derives the same ARN (`gateway/eks/authz.go` `addonARN`). | `handlers/eks/addon_lifecycle_test.go` `TestAddonPersistedLayout_RecordAndManifestKeysAndFieldNames`, `gateway/eks/authz_test.go` `TestResourceARNsFidelity` |
| Scope | Account and Region, within one cluster: keys live under `clusters/{cluster}/addons/` in the `eks-account-{account}` bucket. | `TestAddonPersistedLayout_RecordAndManifestKeysAndFieldNames`, `TestDeleteClusterPrefix_SweepsAddonRecordsAndManifestsScopedToCluster` |
| Identity | Cluster name plus add-on name; one add-on per name per cluster. ARN `arn:aws:eks:{region}:{account}:addon/{cluster}/{addon}` (`foundation/aws/arn/eks.go` `FormatEKSAddon`), deterministic by name, with no per-incarnation suffix. The gateway authorizer derives the same ARN (`domains/eks/awsapi/authz.go` `addonARN`). | `handlers/eks/addon_lifecycle_test.go` `TestAddonPersistedLayout_RecordAndManifestKeysAndFieldNames`, `domains/eks/awsapi/authz_test.go` `TestResourceARNsFidelity` |
| Scope | Account and Region, within one cluster: keys live under `clusters/{cluster}/addons/` in the `eks-account-{account}` bucket. | `TestAddonPersistedLayout_RecordAndManifestKeysAndFieldNames`, `TestDeleteClusterChildren_SweepsAddonRecordsAndManifestsScopedToCluster` |
| Durable record | `clusters/{cluster}/addons/{addon}` holds `Record` (`domains/eks/addon/record.go`): `addonName`, `addonVersion`, `status`, `serviceAccountRoleArn`, `configurationValues`, `health`, `arn`, `tags`, `createdAt`, `modifiedAt` (the optional ones `omitempty`). `clusters/{cluster}/addons/{addon}/manifest` holds `Manifest` (`domains/eks/addon/record.go`): `addonName`, `addonVersion`, `serviceAccountRoleArn`, `configurationValues`. Listing skips the manifest sub-key by segment count. | `TestAddonPersistedLayout_RecordAndManifestKeysAndFieldNames`, `TestAddonRecord_DecodesHandWrittenJSON`, `domains/eks/addon/store_test.go` `TestRecord_RoundTrip`, `TestList_SkipsManifestSubKeys` |
| Writers | Record: the owner in `domains/eks/addon/owner.go`, reached from the `CreateAddon`, `UpdateAddon` and `DeleteAddon` handlers, the cluster reconciler's report relay and the GPU node group launch: `Owner.Create` (unconditional put), `Owner.Update` and its failure marking (revision CAS), `Owner.Delete` (get then delete), `Owner.ApplyReport` (revision CAS), `Owner.EnsureGPUDevicePlugin` through `Owner.Create`; and the cluster prefix purge (`DeleteClusterPrefix`). Manifest: `StagingInstaller.Install` (unconditional put on create and update), `StagingInstaller.Uninstall` (`domains/eks/addon/delivery.go`), `Owner.Delete` and the prefix purge. | `TestAddonPersistedLayout_RecordAndManifestKeysAndFieldNames`, `TestUpdateAddon_ReportsSuccessfulWhileRecordIsUpdating`, `TestDeleteAddon_ReturnsDeletingAfterErasingRecordAndManifest`, `TestDeleteClusterPrefix_SweepsAddonRecordsAndManifestsScopedToCluster`, `domains/eks/addon/store_test.go` `TestCasUpdate`, `TestDeleteRecord_RemovesRecordAndManifest` |
| Writers | Record: the owner in `domains/eks/addon/owner.go`, reached from the `CreateAddon`, `UpdateAddon` and `DeleteAddon` handlers, the cluster reconciler's report relay and the GPU node group launch: `Owner.Create` (unconditional put), `Owner.Update` and its failure marking (revision CAS), `Owner.Delete` (get then delete), `Owner.ApplyReport` (revision CAS), `Owner.EnsureGPUDevicePlugin` through `Owner.Create`; and the cluster prefix purge (`DeleteClusterChildren`). Manifest: `StagingInstaller.Install` (unconditional put on create and update), `StagingInstaller.Uninstall` (`domains/eks/addon/delivery.go`), `Owner.Delete` and the prefix purge. | `TestAddonPersistedLayout_RecordAndManifestKeysAndFieldNames`, `TestUpdateAddon_ReportsSuccessfulWhileRecordIsUpdating`, `TestDeleteAddon_ReturnsDeletingAfterErasingRecordAndManifest`, `TestDeleteClusterChildren_SweepsAddonRecordsAndManifestsScopedToCluster`, `domains/eks/addon/store_test.go` `TestCasUpdate`, `TestDeleteRecord_RemovesRecordAndManifest` |
| Readers | `ListAddons`, `DescribeAddon`, `CreateAddon` (existence check) and the reconciler read the record. `ListStagedAddonManifests` reads every `/manifest` key for the cluster (`addon.ListManifests`); it is served on `eks.ListStagedAddonManifests` (`daemon/daemon.go`) and reached only through the gateway's internal-addons route. | `handlers/eks/addons_test.go` `TestAddon_DescribeListDelete`, `TestListStagedAddonManifests` |
| Catalogue | Static in-binary catalogue (`domains/eks/addon/catalog.go`): `aws-load-balancer-controller`, `argocd`, `aws-ebs-csi-driver`, and hidden-but-creatable `nvidia-device-plugin` and `spinifex-noop`; one version each, the first being the default. Create and update reject unknown names and versions with `InvalidParameterValue`. | `domains/eks/addon/catalog_test.go`, `handlers/eks/addons_test.go` `TestDescribeAddonVersions_ReturnsCatalog`, `TestCreateAddon_UnknownAddonRejected`, `TestCreateAddon_UnknownVersionRejected`, `TestUpdateAddon_UnknownVersionRejected` |
| Desired state | Version, `serviceAccountRoleArn`, `configurationValues` and create-time tags. `resolveConflicts`, `podIdentityAssociations` and `clientRequestToken` are accepted and dropped; `preserve` on delete is ignored. `configurationValues` reaches the guest but the agent discards it (`mulga-eks-addon-sync.sh` `sync_once`), so it has no effect. | `TestAddonPersistedLayout_RecordAndManifestKeysAndFieldNames`, `TestDeleteAddon_ReturnsDeletingAfterErasingRecordAndManifest` |
@@ -29,17 +29,17 @@ There is no generation, so the record mixes desired fields with the latest obser
| Readiness | `ready` maps any non-`ACTIVE` status to `ACTIVE` and clears `health`; `applied` and unknown phases change nothing. The agent reports `ready` when K3s records the add-on's `gvks` annotation, or after a 90 s grace once the bundle's workloads are rolled out; the check is per rendered file, not per version. | `domains/eks/addon/owner_test.go` `TestNextStatus`, `TestOwnerApplyReport`, `handlers/eks/addon_status_reconciler_test.go` `TestApplyAddonStatusReport`, `tests/e2e/eks/eks_test.go` `TestEKS/AddonDelivery` |
| Failure | Staging failure on create or update marks the record `CREATE_FAILED` with the error as `health` and returns the raw error. A `failed` report maps `ACTIVE` to `DEGRADED`, keeps `DEGRADED` and `CREATE_FAILED`, and maps anything else (including `UPDATING`) to `CREATE_FAILED`. Neither failure state is terminal: a later `ready` report returns the add-on to `ACTIVE`. | `TestCreateAddon_InstallerFailureLeavesDurableCreateFailedRecord`, `TestUpdateAddon_InstallerFailureMarksCreateFailed`, `TestAddonStatusReport_MismatchedVersionIsAccepted`, `handlers/eks/addons_test.go` `TestCreateAddon_InstallerFailureMarksCreateFailed` |
| Delete | Read the record, build a `DELETING` response from it, delete the manifest, then delete the record and manifest again (`Owner.Delete`). Both keys are gone when the call returns. The guest later notices the missing manifest, removes the rendered file and runs `kubectl delete --wait=false`; it sends no report, so nothing observes removal. | `TestDeleteAddon_ReturnsDeletingAfterErasingRecordAndManifest`, `TestListStagedAddonManifests`, `tests/e2e/eks/eks_test.go` `TestEKS/AddonDelivery` |
| Guest fetch | `GET /clusters/{cluster}/internal-addons/{accountId}` (`gateway/eks.go`, `ListInternalAddons`) is admitted only for a control-plane agent serving that cluster (`AuthorizeInternal`), relays to `eks.ListStagedAddonManifests`, and wraps the result as `contracts/eks/v1` `InternalAddonsResponse`. The body is written by the SDK REST-JSON marshaller, which ignores the `json` tags: it is `{"Addons":[{"AddonName","AddonVersion","ServiceAccountRoleArn","ConfigurationValues"}]}` with empty strings present, sorted by add-on name. | `gateway/eks_addon_wire_test.go` `TestEKSRequest_InternalAddonsWireBody`, `contracts/eks/v1` `TestInternalAddonsResponse_RESTJSONBody`, `gateway/eks_authz_test.go` `TestEKSRequest_InternalRoutesAdmitTheCPAgentPrincipal`, `gateway/eks/internal_authz_test.go` `TestAuthorizeInternal_RejectsClusterTheCallerDoesNotServe` |
| Guest fetch | `GET /clusters/{cluster}/internal-addons/{accountId}` (`domains/eks/awsapi/internal_addons.go`, `ListInternalAddons`) is admitted only for a control-plane agent serving that cluster (`AuthorizeInternal`), relays to `eks.ListStagedAddonManifests`, and wraps the result as `contracts/eks/v1` `InternalAddonsResponse`. The body is written by the SDK REST-JSON marshaller, which ignores the `json` tags: it is `{"Addons":[{"AddonName","AddonVersion","ServiceAccountRoleArn","ConfigurationValues"}]}` with empty strings present, sorted by add-on name. | `domains/eks/awsapi/gateway_wire_test.go` `TestEKSRequest_InternalAddonsWireBody`, `contracts/eks/v1` `TestInternalAddonsResponse_RESTJSONBody`, `domains/eks/awsapi/gateway_authz_test.go` `TestEKSRequest_InternalRoutesAdmitTheCPAgentPrincipal`, `domains/eks/awsapi/internal_authz_test.go` `TestAuthorizeInternal_RejectsClusterTheCallerDoesNotServe` |
| TSV conversion | `eks-gateway-fetch -resource addons` (`agents/eks/gatewayfetch`) decodes the body into its own copy of the shape (matching the Go field names only because `encoding/json` is case-insensitive) and writes `name\tversion\troleArn\tbase64(config)` per add-on; an empty set writes nothing. It retries the GET up to 30 times at 5 s. | `agents/eks/gatewayfetch/addons_wire_test.go` `TestEmitAddonsTSV_GatewayWireBody`, `agents/eks/gatewayfetch/emit_test.go` `TestEmitAddonsTSV`, `TestEmitAddonsTSV_Empty`, `agents/eks/gatewayfetch/fetch_test.go` `TestRun_RendersAddons`, `TestRun_FailsAfterAttemptBudget` |
| Guest render and report | Every 30 s (`ADDON_SYNC_INTERVAL`) the agent renders each staged add-on, substituting the role ARN into web-identity env when set, then publishes `{"addon","version","phase","message","ts"}` through `eks-gateway-publish -channel addon`. The gateway relays the raw payload to core NATS `eks.addon.{account}.{cluster}.status` (`gateway/eks/internal_publish.go`). A fetch failure keeps the current render. | `gateway/eks/internal_publish_test.go` `TestPublishInternal_RelaysToSubjects`, `agents/eks/gatewaypublish/publish_test.go` `TestRun_PublishesWrappedPayload`, `TestRun_FailsAfterAttemptBudget` |
| Guest render and report | Every 30 s (`ADDON_SYNC_INTERVAL`) the agent renders each staged add-on, substituting the role ARN into web-identity env when set, then publishes `{"addon","version","phase","message","ts"}` through `eks-gateway-publish -channel addon`. The gateway relays the raw payload to core NATS `eks.addon.{account}.{cluster}.status` (`domains/eks/awsapi/internal_publish.go`). A fetch failure keeps the current render. | `domains/eks/awsapi/internal_publish_test.go` `TestPublishInternal_RelaysToSubjects`, `agents/eks/gatewaypublish/publish_test.go` `TestRun_PublishesWrappedPayload`, `TestRun_FailsAfterAttemptBudget` |
| Retry meaning | Reports are level-triggered: each tick re-sends the current phase for every staged add-on, so a lost report is repeated on the next tick. `eks-gateway-publish` retries the POST up to 30 times at 5 s and the agent ignores its failure. Core NATS keeps nothing: a report published while no reconciler holds the cluster's lease, or after the reconciler stops on a terminal cluster status, is dropped. | none |
| Report ordering | The reconciler subscribes plainly (no queue group) on the lease holder and applies reports in arrival order through a revision CAS. Nothing orders a report against a later update: a report produced for the previous version settles the new `UPDATING` record, and a stale `failed` turns it `CREATE_FAILED`. A report for a deleted add-on is a no-op. | `TestAddonStatusReport_MismatchedVersionIsAccepted`, `TestDeleteAddon_ReturnsDeletingAfterErasingRecordAndManifest` |
| Cross-resource dependency | A GPU node group's launch calls `stageGPUDeviceAddon`, which checks the cluster exists and calls `Owner.EnsureGPUDevicePlugin` for `nvidia-device-plugin`; an existing record is a no-op and every other error is logged only. Deleting the node group never unstages it, and an existing record in any status, including `CREATE_FAILED`, blocks re-staging. | `handlers/eks/nodegroup_test.go` `TestCreateNodegroup_GPUNodegroupStagesDevicePluginAddon`, `domains/eks/addon/owner_test.go` `TestOwnerEnsureGPUDevicePlugin_StagesDefaultVersionOnceAndKeepsAnyExistingRecord`, `TestCreateNodegroup_NonGPUNodegroupDoesNotStageDevicePluginAddon`, `tests/e2e/gpu/eks_gpu_test.go` `TestEKSGPUPodExposure` |
| Idempotency | `clientRequestToken` is ignored. A duplicate create, including an SDK retry with the same token, returns `ResourceInUseException`. A repeated update re-stages and returns another `Successful` with the same id. A repeated delete returns `ResourceNotFoundException`. | `TestCreateAddon_RepeatedClientRequestTokenIsResourceInUse`, `TestUpdateAddon_ReportsSuccessfulWhileRecordIsUpdating`, `TestDeleteAddon_ReturnsDeletingAfterErasingRecordAndManifest`, `handlers/eks/addons_test.go` `TestCreateAddon_CreatesStagingAndDefaultsVersion` |
| Absent resource | An unknown cluster returns `ResourceNotFoundException` for every action, including the internal manifest listing. A missing add-on returns `ResourceNotFoundException` from describe, update and delete. | `handlers/eks/addons_test.go` `TestCreateAddon_UnknownClusterIsNotFound`, `TestDescribeDeleteAddon_MissingIsNotFound`, `TestUpdateAddon_MissingIsNotFound`, `TestListStagedAddonManifests` |
| Dependencies | Only the cluster meta's existence is checked; cluster status is not read, so add-ons can be created on a `DELETING` cluster. | `TestAddonActionsIgnoreClusterLifecycleStatus` |
| Authorization | Gateway policy uses the add-on ARN above. `serviceAccountRoleArn` requires `iam:PassRole` on `CreateAddon` and `UpdateAddon` (`gateway/eks/passrole.go`), the same check `CreateCluster`/`CreateNodegroup` apply, including that the role exists. Tag actions on an add-on ARN return `NotImplemented`. | `gateway/eks_addon_wire_test.go` `TestPassedRoleARNs_AddonServiceAccountRoleIsChecked`, `gateway/eks_passrole_test.go` `TestEKSCreateAddon_PassRole`, `TestEKSUpdateAddon_PassRole`, `gateway/eks/authz_test.go` `TestResourceARNsFidelity` |
| Cluster deletion | Add-on records and manifests are erased only by the cluster prefix purge, after the cluster's infrastructure teardown succeeds; there is no per-add-on delete. Reclaiming a failed cluster keeps the meta and does not purge, so the recreated cluster inherits the add-ons. | `TestDeleteClusterPrefix_SweepsAddonRecordsAndManifestsScopedToCluster`, `handlers/eks/cluster_state_test.go` `TestDeleteClusterPrefix_SweepsEveryKey` |
| Dependencies | `CreateAddon` checks the cluster meta exists and refuses a `DELETING` cluster with `ResourceInUseException`, since a recorded delete admits no new dependant; other statuses are not read. | `TestCreateAddonRefusesOnlyADeletingCluster` |
| Authorization | Gateway policy uses the add-on ARN above. `serviceAccountRoleArn` requires `iam:PassRole` on `CreateAddon` and `UpdateAddon` (`domains/eks/awsapi/passrole.go`), the same check `CreateCluster`/`CreateNodegroup` apply, including that the role exists. Tag actions on an add-on ARN return `NotImplemented`. | `domains/eks/awsapi/gateway_wire_test.go` `TestPassedRoleARNs_AddonServiceAccountRoleIsChecked`, `domains/eks/awsapi/gateway_passrole_test.go` `TestEKSCreateAddon_PassRole`, `TestEKSUpdateAddon_PassRole`, `domains/eks/awsapi/authz_test.go` `TestResourceARNsFidelity` |
| Cluster deletion | Add-on records and manifests are erased only by the cluster prefix purge, after the cluster's infrastructure teardown succeeds; there is no per-add-on delete. Reclaiming a failed cluster keeps the meta and does not purge, so the recreated cluster inherits the add-ons. | `TestDeleteClusterChildren_SweepsAddonRecordsAndManifestsScopedToCluster`, `handlers/eks/cluster_state_test.go` `TestDeleteClusterChildren_SweepsEveryKey` |
| Restart and interruption | Create interrupted between the record put and staging leaves a `CREATING` record with no manifest that nothing re-stages and that blocks a retry. Delete interrupted after unstaging leaves a record in its previous status while the guest removes the software. An update interrupted after the CAS leaves `UPDATING` with the previous manifest. A daemon restart loses nothing durable, but reports sent while no reconciler runs are lost until the next tick. | none |
| End-to-end | `tests/e2e/eks/eks_test.go` `TestEKS/AddonDelivery` (create to `ACTIVE`, objects present, delete removes the namespace) and `tests/e2e/gpu/eks_gpu_test.go` `TestEKSGPUPodExposure`; `tests/integration/cross_tenant_test.go` `TestCrossTenant_EveryScopedActionIsAccountedFor`. No E2E for update, failure, configuration or the role. No Terraform apply/destroy evidence for `aws_eks_addon`. | |
@@ -52,7 +52,7 @@ There is no generation, so the record mixes desired fields with the latest obser
5. `DELETING` exists only in the delete response; the record and manifest are erased before any guest removal, so `DescribeAddon` returns not found immediately.
6. No observed deletion acknowledgement: the agent removes objects with `--wait=false` and reports nothing.
7. `configurationValues` is stored, staged and transported but discarded by the agent; `resolveConflicts`, `preserve` and `podIdentityAssociations` are accepted and never used.
8. Fixed: `serviceAccountRoleArn` now requires `iam:PassRole` on `CreateAddon` and `UpdateAddon`, matching `CreateCluster`/`CreateNodegroup` (`gateway/eks/passrole.go`).
8. Fixed: `serviceAccountRoleArn` now requires `iam:PassRole` on `CreateAddon` and `UpdateAddon`, matching `CreateCluster`/`CreateNodegroup` (`domains/eks/awsapi/passrole.go`).
9. AWS `UPDATE_FAILED` and `DELETE_FAILED` are not represented: a failed update becomes `CREATE_FAILED` and a failed delete leaves the prior status.
10. The add-on ARN lacks the per-incarnation suffix AWS appends, so a recreated add-on reuses its predecessor's ARN. This is a compatibility gap; extraction must keep the current ARN.
11. The guest/controller wire shape is versioned as `contracts/eks/v1` (subject, delivery phases, status report, staged manifest, internal-addons body), used directly by the handler, gateway and `agents/eks/gatewayfetch`. The add-on owner keeps its own `Manifest` and `Report`, and the shell agent still builds the report with `printf`. The HTTP body uses Go field names because the REST-JSON marshaller ignores the `json` tags, and the agent decodes it only through case-insensitive matching; the contract pins that form rather than changing it.
@@ -0,0 +1,42 @@
# EKS cluster: delete lifecycle contract
Contract under ADR-0003 S1 for the delete half of an EKS cluster's lifecycle, the half ADR-0003 S8 constrains.
Create, update and the control-plane reconciler are out of its scope and keep the observed gaps recorded elsewhere.
Paths are relative to `spinifex/` unless they start with `tests/`, which is relative to the spinifex repo root.
Test names without a path are in `domains/eks/cluster/`.
## Scope
A cluster is a composite resource: its record at `clusters/{name}/meta` in the `eks-account-{account}` bucket names the dependants a delete must remove.
In the system account those are the control-plane VMs and their root volumes, the endpoint load balancer with its target groups, the managed control-plane VPC and its network, and the spread placement group.
In the customer account they are the egress address, the private-endpoint interface and security group, the cluster security groups, a cluster-owned internet gateway, and load balancers the in-cluster controller created.
Node group workers and CSI-provisioned EBS volumes are never removed by a cluster delete.
## Contract
| Item | Behaviour | Evidence |
|---|---|---|
| Request | `DeleteCluster` validates, answers `ResourceNotFoundException` for an absent cluster, answers a `DELETING` cluster from its record, refuses a cluster with node groups with `ResourceInUseException`, records `DELETING` and returns. It waits for no teardown step. | `TestS8_EKSDeleteClusterReturnsBeforeTheTeardownCompletes`, `TestRLC1_EKSDeleteClusterNotFoundOnAbsent`, `TestS8_EKSDeleteClusterRefusesWhileNodegroupsExist`, `tests/e2e/eks` `TestEKS/DeleteCluster` |
| Obligation | `DELETING` with `DeletingSince` on the record is the tenant's decision. No status writer moves a record off it, and a create launch still running adds its refs to it instead of overwriting it. | `TestS2_EKSDeletingIsStickyForStatusWriters`, `TestS2_EKSLaunchWriteAfterDeleteNeitherRevertsNorRecreates` |
| Retry | A repeat delete answers `DELETING` from the record and starts nothing. | `TestS8_EKSSecondDeleteAnswersFromTheRecordWithoutASecondTeardown` |
| Convergence | `convergeClusterDeletion` (`domains/eks/cluster/cluster_teardown.go`), started by the request and backed by `EKSDeletingReaper`, runs under a per-cluster in-process gate and the cross-node teardown lease, renewed on the service context. It acts only on a record that is `DELETING` when re-read. | `TestS8_EKSKickAndSameNodeSweepRunOneTeardown`, `TestS8_EKSTeardownLeaseOutlivesTheRequest`, `TestS7_EKSTeardownActsOnlyOnARecordedDelete` |
| Retained cleanup | A failed step keeps the record `DELETING` with its refs and the error. The record is removed last, after its child records, and only if no ref appeared that the pass did not tear down. A record with a node group is never removed; a record that is not `DELETING` is refused before anything else is checked. | `TestRLC2_EKSBillableTeardownBeforeKVSweep`, `TestS2_EKSTeardownRemovesTheMetaLast`, `TestS2_EKSTeardownDoesNotSweepARecordChangedDuringTeardown`, `TestS7_EKSTeardownNeverRemovesAClusterWithNodegroupWorkers` |
| Recovery | A teardown interrupted by a crash or a lost lease is finished by the reaper on any node. Failed passes back off from 90 s, doubling to a 30 min cap, and are never abandoned; `DeleteReapExhausted` only flags a long failure for an operator. | `TestS2_EKSTeardownResumesAfterAnInterruptedConverge`, `TestRLC4_DeletingReaperReDrivesWedgedTeardown`, `TestDeletingReaper_KeepsRetryingPastTheOldAttemptLimit`, `TestDeletingReaper_ResumesAnAlreadyExhaustedRecord` |
| Visibility | `DescribeCluster` and `ListClusters` show the cluster `DELETING` until the record is removed, then `ResourceNotFoundException`. A failed pass shows one `INTERNAL_FAILURE` health issue naming the step, never the internal error. | `TestDescribeCluster_StuckDeleteShowsTheStepNotTheError`, `TestRLC2_EKSBillableTeardownBeforeKVSweep` |
| New dependants | `CreateNodegroup` requires `ACTIVE`; `CreateAddon` and `CreateAccessEntry` refuse a `DELETING` cluster with `ResourceInUseException`. | `TestCreateAddonRefusesOnlyADeletingCluster`, `TestS2_EKSDeletingClusterAdmitsNoNewDependants` |
| Subject budgets | Every `eks.*` request subject is classified by its handler's work in `eksSubjects` (`domains/eks/cluster/service_nats.go`) and keeps the 30 s budget; `eks.DeleteCluster` is request-scaled. A subject the gateway sends or the daemon serves that is not classified fails the table. | `TestS8_EKSSubjectBudgets` |
| Late child records | No child record outlives the cluster or leaks into a later one of the same name. The teardown sweeps child keys again after removing the meta and stops the cluster's bootstrap subscriber, bootstrap refuses to write into a record that is gone or `DELETING`, the launch re-checks its record around seeding the creator's access entry, and a fresh claim clears whatever an earlier cluster left under the name. | `TestS2_EKSLaunchOvertakenByDeleteLeavesNoChildRecords`, `TestS2_EKSBootstrapWritesNothingIntoADeletingCluster`, `TestS2_EKSFreshClaimInheritsNoStaleChildRecords` |
| Launch generation | The create launch's writes are fenced on the record's generation (`CreatedAt` and ARN). An overtaken launch never writes a newer cluster of the same name and releases only the instances it started, by ID. One whose record was swept recreates it as `DELETING` with its refs, so the teardown and reaper own the release. One whose record was marked `FAILED` merges its refs and releases what it built. | `TestS2_EKSOvertakenLaunchNeverWritesANewerClusterOfTheSameName`, `TestS2_EKSOvertakenLaunchHandsItsInfraToTheTeardown`, `TestS2_EKSLaunchMarkedFailedReleasesWhatItBuilt` |
| Running launch | A create launch heartbeats its owner and time on the record (`LaunchOwner`, `LaunchHeartbeat`). The teardown keeps the record while that heartbeat is fresh, so the cluster reads `DELETING` throughout and never `ResourceNotFoundException` then `DELETING` again; the launch hands its refs over at its next write and starts the teardown when it returns. A heartbeat older than 90 s is taken as a crashed launch and no longer waited for, and recreating a swept record stays as the backstop. The launch's OIDC keypair writes are fenced on the record's generation. | `TestS2_EKSDeletedClusterNeverReadsMissingWhileItsLaunchRuns`, `TestS2_EKSCrashedLaunchDoesNotPinTheDelete`, `TestS2_EKSOvertakenLaunchCannotReplaceANewerClustersSigningKey` |
| Bounded passes | One converger run is bounded at 15 minutes, so a hung step cannot hold the gate and lease; the timed-out pass is recorded and backed off. Waiting on node groups is not counted as a failed pass, and deleting the last node group resumes the delete. | `TestS8_EKSHungTeardownStepReleasesTheGate`, `TestS2_EKSLastNodegroupDeletedResumesTheClusterDelete` |
| FAILED reclaim | Re-creating a `FAILED` cluster's name tears the old attempt down under the same gate and lease as a delete, and answers `ResourceInUseException` while another holder has it; the per-node teardown bound does not apply to it. | `TestS2_EKSFailedClusterReclaimRunsUnderTheTeardownLease`, `TestS2_EKSFailedClusterReclaimIsNotRefusedForCapacity` |
| Tenant data | The teardown never deletes a CSI-provisioned volume or terminates a node group worker. | `TestS7_EKSTeardownNeverDeletesCSIVolumesOrWorkers`, `TestS7_EKSTeardownNeverRemovesAClusterWithNodegroupWorkers` |
## Gaps
1. Four `eks.*` handlers still hold their request for work that grows with the resource, and the subject table names them as not conformant: `CreateCluster` (reclaiming a `FAILED` cluster of the same name tears its infra down inline), `DeleteNodegroup` (one awaited terminate per worker), `UpdateNodegroupConfig` (one awaited launch or terminate per worker added or removed) and `RestoreSnapshot` (launches a control-plane VM and fences the old one inline).
2. AWS's answer to a repeat delete on a `DELETING` cluster has not been observed; `DELETING` from the record is the chosen behaviour.
3. Control-plane, load balancer and VPC resources in the system account are keyed by the bare cluster name, so a teardown by name can reach another tenant's same-named cluster.
4. The teardown deletes load balancers the in-cluster controller created in the customer account, which AWS leaves behind.
5. An overtaken launch's late write can still land under a newer cluster of the same name in the instant between that cluster's claim sweep and the old launch's generation check; it is bounded by one write, since the old launch stops at its next fenced write.
6. The `FAILED`-name reclaim purge still runs inside the `CreateCluster` request (gap 1).
@@ -18,7 +18,7 @@ The characterization tests below were written in `handlers/rds` before the move;
## Discovered and corrected divergence
`ValidateMasterUserPassword` built its empty-password error with a bare `errors.New`, which `awserrors` does not resolve, so `CreateDBInstance` without `MasterUserPassword` returned HTTP 500 `ServerInternal` where AWS returns a 400 client error.
It now returns 400 `InvalidParameterValue`; `gateway/rds_test.go` `TestRDSRequest_CreateDBInstance_EmptyMasterUserPasswordReturns400` covers the gateway, NATS and RDS service path.
It now returns 400 `InvalidParameterValue`; `ingress/aws/pipeline/rds_test.go` `TestRDSRequest_CreateDBInstance_EmptyMasterUserPasswordReturns400` covers the gateway, NATS and RDS service path.
## Boundaries kept by the move
+8 -8
View File
@@ -99,9 +99,7 @@ behavior in other words.
| 4432 | Formation server | HTTPS | Cluster (bootstrap only) | Cluster join coordination; active only while a join token is valid. Binds the node's `--bind` (lan) address. See *Formation port lifecycle* below. | Short-lived bearer token + TLS¹ |
| 4222 | spinifex-nats (client) | NATS + TLS | Cluster | Internal service bus for EC2/EBS/VPC/S3 handlers | Token + mutual TLS (cluster CA) |
| 4248 | spinifex-nats (cluster) | NATS + TLS | Cluster | Inter-node NATS federation | Token + mutual TLS (cluster CA) |
| 4223 | spinifex-sqs-store (client) | NATS + TLS | Cluster | SQS message data, separate from the control-plane bus | Store-only token + TLS |
| 4249 | spinifex-sqs-store (cluster) | NATS + TLS | Cluster | Inter-node SQS store replication | Store-only token + mutual TLS (cluster CA) |
| 8223 | spinifex-sqs-store (monitor) | HTTP | Loopback | varz and jsz for the store | Loopback only |
| 5001 | iperf2 (admin network test) | TCP | Cluster, any plane | Throughput test between two nodes, started only by the super-admin account. Listens on one plane address for the length of one test, then exits. Runs with no capabilities. | None — peer-scoped by firewall, and the client may only target its own plane subnet |
| 5300 | northstar | DNS (UDP + TCP) | Cluster | Forward target for every node's per-instance DNS shim, dialled cross-node. Binds the wildcard by design. | None — restrict by firewall |
| 6660 | predastore (blob node) | QUIC / UDP | Cluster | Erasure-coded object shard transport between hosts. Multi-node clusters only — see *Predastore ports* below. | TLS 1.3, server certificate verified against the cluster CA |
| 7660 | predastore (meta node) | QUIC / UDP | Cluster | Raft consensus over global state — buckets and the object index — between hosts. Multi-node clusters only. | TLS 1.3, server certificate verified against the cluster CA |
@@ -111,8 +109,8 @@ behavior in other words.
| 6643 | OVN Northbound DB (RAFT) | OVSDB/TCP | Cluster | NB database RAFT replication between the 3 quorum nodes | Cluster network only; TLS planned |
| 6644 | OVN Southbound DB (RAFT) | OVSDB/TCP | Cluster | SB database RAFT replication between the 3 quorum nodes | Cluster network only; TLS planned |
| 6081 | OVN (Geneve) | UDP | Encap | Tenant traffic overlay between chassis. A kernel UDP-tunnel socket, so packets are delivered locally and traverse the host's netfilter input hook before OVS sees them — a host firewall must accept them explicitly. The socket is opened by the kernel tunnel driver and takes no bind address, so it binds the wildcard by design and reach must be restricted by firewall. | None — see 500/4500 |
| 500, 4500 | strongSwan `charon` | IKEv2 / UDP | Encap | IKE and NAT-T for the IPsec protecting Geneve, managed entirely by `ovs-monitor-ipsec`. Binds the wildcard by design (the upstream strongSwan default, accepted rather than overridden), so reach must be restricted by firewall. | Certificate-based, against the cluster CA |
| — | ESP | IP proto 50 | Encap | The IPsec payload itself, once IKE has negotiated an SA | Cluster CA |
| 500, 4500 | strongSwan `charon`, or libreswan `pluto` on Oracle Linux | IKEv2 / UDP | Encap | IKE and NAT-T for the IPsec protecting Geneve, managed entirely by `ovs-monitor-ipsec`; 4500 carries the ESP payload only where IKE detects NAT between two nodes. `charon` binds the wildcard by design (the upstream strongSwan default, accepted rather than overridden); `pluto` binds each host address separately, the WAN address included, which is no narrower. Either way reach must be restricted by firewall. | Certificate-based, against the cluster CA |
| — | ESP | IP proto 50 | Encap | The IPsec payload itself, once IKE has negotiated an SA, on Oracle Linux as on Debian. | Cluster CA |
| 8222 | spinifex-nats (monitoring) | HTTP | Localhost | `varz`/`subsz` metrics consumed by the daemon | Loopback only |
| 323 | chronyd | NTP | Localhost | Time sync client control socket | Loopback only |
| 169.254.169.254:80 | spinifex-vpcd (IMDS) | HTTP | Guest | Instance metadata service. One socket per instance, bound to that instance's `ime-*` interface. Terminates on the host, so it traverses the netfilter input hook. | Instance identity by interface; IMDSv2 tokens |
@@ -177,7 +175,7 @@ Control-plane and data-plane traffic between Spinifex nodes, for completeness an
| OVN NB/SB (client) | 6641 / 6642 | Cluster network only (TLS planned) | Network control plane; vpcd and ovn-controller dial the quorum |
| OVN NB/SB (RAFT) | 6643 / 6644 | Cluster network only (TLS planned) | NB/SB database replication across the 3 quorum nodes |
| OVN tunnels (Geneve) | UDP 6081 | Encapsulated by IPsec when `network.ipsec_enabled` is true, which is the default on multi-node clusters | Tenant traffic overlay between chassis, on the `vpc` plane |
| OVN IPsec (IKE / NAT-T / ESP) | UDP 500, UDP 4500, IP proto 50 | Certificate-based against the cluster CA, negotiated by `ovs-monitor-ipsec` | Protects the Geneve tunnels above. OVN-native only — no layer manages strongSwan directly. |
| OVN IPsec (IKE / NAT-T / ESP) | UDP 500, UDP 4500, IP proto 50 | Certificate-based against the cluster CA, negotiated by `ovs-monitor-ipsec` | Protects the Geneve tunnels above. OVN-native only: no layer configures strongSwan or libreswan directly. On Oracle Linux the daemon also blocks outbound Geneve that no SA covers, so a peer failing IKE gets no plaintext. |
| Instance DNS forwarding | UDP/TCP 5300 | None | Each node's per-instance DNS shim forwards guest queries to peer nodes' northstar `:5300` |
Nodes **must** sit on a network segment that is not routed to tenant/guest VMs or to the internet. The Predastore blob and meta transports and the OVN DBs are cluster-internal and must not be reachable from anywhere else.
@@ -224,7 +222,7 @@ table inet spinifex_filter {
# Cluster, from peer nodes only. Replace with your nodes' lan-plane
# addresses — a CIDR does not generalise to nodes on different subnets.
ip saddr { 10.0.1.1, 10.0.1.2, 10.0.1.3 } tcp dport { 4222, 4223, 4248, 4249, 4432, 5300, 6641, 6642, 6643, 6644, 8660 } accept
ip saddr { 10.0.1.1, 10.0.1.2, 10.0.1.3 } tcp dport { 4222, 4248, 4432, 5001, 5300, 6641, 6642, 6643, 6644, 8660 } accept
ip saddr { 10.0.1.1, 10.0.1.2, 10.0.1.3 } udp dport { 5300, 6660, 7660 } accept
# Encap, from peer chassis only. Replace with your nodes' vpc-plane
@@ -239,6 +237,8 @@ Notes that make the difference between this working and locking you out:
- **Use a dedicated table and never flush the others.** `vpcd` writes MASQUERADE and per-EIP FORWARD rules into the `ip filter` and `ip nat` tables, and only reinstalls them when the service starts. `iptables -F`, `nft flush ruleset`, `ufw enable` and `firewalld` all destroy them silently, and instance networking stays broken until the next restart.
- **Do not add a `forward` hook.** nftables evaluates every table registered on a hook and any `drop` is final, so a default-deny forward chain here would override `vpcd`'s accepts in the other table and break every routed-NAT instance and every EIP. Filtering forwarded guest traffic is the security group's job, not the host firewall's.
- **`vpcd` owns a second table named `spinifex_mss`, separate from `inet spinifex_filter`.** It is a `bridge` table on the veth uplink and an `inet` table on the routed uplink, it has a `forward` hook with policy `accept`, and it only rewrites the MSS option of TCP SYN packets on the host hop to the gateway. It never drops or accepts traffic and does not replace the forward-hook advice above. `vpcd` re-installs it every 30 seconds, so a flush is repaired within that interval, and there is none on a physical uplink.
- **Keep connection tracking running while the policy is unloaded.** The kernel tracks connections only while some rule uses conntrack, so a node with no ruleset tracks nothing. A connection that opens then, such as an OVN RAFT link, is first seen mid-stream when the policy loads, and if the server's end speaks first its packet looks like a new connection to an ephemeral port and is dropped. Spinifex keeps `table inet spinifex_conntrack`, which accepts everything, loaded on every node for this reason.
- **`output` is deliberately untouched.** The metadata service's reply path egresses through per-instance policy routing; filtering `output` breaks it in ways that are hard to attribute.
- **On a single-NIC node the cluster and encap sets are the node's public addresses**, because the planes collapse. The rules are still correct, but they are no longer a boundary — an upstream ACL is the only real control there.
@@ -250,7 +250,7 @@ Every listener and outbound destination is controlled by one of these files. Cha
| File | Keys | Controls |
|------|------|----------|
| `/etc/spinifex/spinifex.toml` | `nodes.<node>.{awsgw,nats,predastore,daemon}.host`, `nodes.<node>.vpcd.ovn_{nb,sb}_addr`, `nodes.<node>.daemon.dev_networking`, `network.ipsec_enabled` | Per-service bind addresses/ports; dev-mode QEMU port forwarding. `network.ipsec_enabled` (default `true`) decides whether OVN-native IPsec protects the Geneve tunnels, and therefore whether `charon` listens on 500/4500 — single-node clusters never enable it. |
| `/etc/spinifex/spinifex.toml` | `nodes.<node>.{awsgw,nats,predastore,daemon}.host`, `nodes.<node>.vpcd.ovn_{nb,sb}_addr`, `nodes.<node>.daemon.dev_networking`, `network.ipsec_enabled` | Per-service bind addresses/ports; dev-mode QEMU port forwarding. `network.ipsec_enabled` (default `true`) decides whether OVN-native IPsec protects the Geneve tunnels, and therefore whether the IKE daemon (`charon`, or `pluto` on Oracle Linux) listens on 500/4500 — single-node clusters never enable it. |
| `/etc/spinifex/nats.conf` | `listen`, `cluster.listen`, `cluster.routes`, `http`, `tls`, `cluster.authorization` | NATS client/cluster/monitoring listeners, peer routes, TLS, cluster token. |
| `/etc/spinifex/predastore/predastore.toml` | `[[host]].bind_addr`, `[[host]].addr`, `[[host]].tls_cert`, `[[host]].tls_key`, `[[host.node]].role`, `[[host.node]].port` | Predastore host and node layout: `bind_addr` is the address the host's sockets bind, `addr` is the address peer hosts dial it on, and both carry no port — the nodes pinned to the host supply their own. The service's `--host`/`--port` override the bind address and the gate's S3 port. |
| `/etc/spinifex/northstar/northstar.toml` | `listen`, `forwarders` | DNS listen addresses (`:53` on the advertise address, `:5300` wildcard) and upstream resolvers. |
-521
View File
@@ -1,521 +0,0 @@
# Service interface manifest.
#
# Source of truth for "which services produce/consume which NATS subjects"
# and "which e2e suites cover which services". Consumed by
# tests/e2e/select-suites (Bead 4) to derive targeted suite selection from
# a PR diff.
#
# See docs/development/improvements/e2e-targeted-suite-selection.md.
#
# Editing rules
# -------------
# - subscribes / publishes: subjects this service receives / sends. Both
# lists use the literal subject string, or a "<prefix>.*" wildcard for
# pattern subscriptions (e.g. "ebs.snapshot.*").
# - depends_on: subjects this service requires from peers. Used by the
# selector to compute "if X changes, which suites might break".
# - Subjects must match what is actually referenced in source. The lint
# in Bead 5 greps `"(ec2|vpc|ebs|s3|iam|elbv2|spinifex)\.[a-zA-Z._-]+"`
# and fails if any subject is absent from this manifest.
version: 1
services:
awsgw:
path: spinifex/runtime/roles/awsgw
# TLS frontend, SigV4 auth. Pure router: HTTP → NATS request/response.
# No subscribes; only publishes (via NATSRequest).
subscribes: []
publishes:
- "ec2.*"
- "elbv2.*"
- "acm.*"
- "route53.*"
- "iam.*"
- "rds.*"
- "ecs.*"
- "ochre.vector.*"
depends_on:
- "ec2.*"
- "elbv2.*"
- "acm.*"
- "route53.*"
- "iam.*"
- "rds.*"
- "ecs.*"
- "ochre.vector.*"
spinifex_daemon:
path: spinifex/runtime/roles/spinifex
# Orchestrator. Owns most ec2.* + all elbv2.* + iam event publication.
# Translates VPC / EBS / S3 operations into vpc.* / ebs.* / s3.* and
# forwards to vpcd / viperblockd / predastore.
#
# Source layout: spinifex/runtime/roles/spinifex (entry), spinifex/runtime/daemon
# (subscribe registration), spinifex/domains/<domain> and the legacy
# spinifex/handlers/{ecs,eks,elbv2,iam,rds} packages (per-subject handler
# bodies). Selector treats these paths as belonging to this service via
# the `additional_paths` field.
additional_paths:
- spinifex/runtime/daemon
- spinifex/domains/ec2/account
- spinifex/domains/ec2/eigw
- spinifex/domains/ec2/eip
- spinifex/domains/ec2/igw
- spinifex/domains/ec2/image
- spinifex/domains/ec2/instance
- spinifex/domains/ec2/key
- spinifex/domains/ec2/launchtemplate
- spinifex/domains/ec2/natgw
- spinifex/domains/ec2/placementgroup
- spinifex/domains/ec2/routetable
- spinifex/domains/ec2/snapshot
- spinifex/domains/ec2/spotinstance
- spinifex/domains/ec2/tags
- spinifex/domains/ec2/volume
- spinifex/domains/ec2/volumestate
- spinifex/domains/ec2/vpc
- spinifex/domains/elbv2
- spinifex/domains/acm
- spinifex/domains/route53
- spinifex/domains/iam
- spinifex/domains/ecs
- spinifex/domains/rds/instance
- spinifex/domains/rds/subnetgroup
- spinifex/domains/rds/parametergroup
- spinifex/domains/rds/engine
- spinifex/domains/ecs/taskdefinition
- spinifex/domains/eks/cluster
- spinifex/domains/eks/access
- spinifex/domains/eks/addon
# Guest add-on wire contract (subject, report, staged-manifest body).
- contracts/eks/v1
# ECS agent/scheduler Layer-2 bus wire contract (subjects, message types).
- contracts/ecs/v1
- spinifex/domains/ochre/vector
subscribes:
# EC2 instance lifecycle.
- "ec2.RunInstances"
- "ec2.DescribeInstances"
- "ec2.DescribeStoppedInstances"
- "ec2.DescribeTerminatedInstances"
- "ec2.DescribeInstanceAttribute"
- "ec2.DescribeInstanceStatus"
- "ec2.DescribeInstanceTypes"
- "ec2.ModifyInstanceAttribute"
- "ec2.ModifyVolume"
- "ec2.start"
- "ec2.terminate"
- "ec2.launch"
# contracts/ec2/v1 owns the command payload and this subject vocabulary.
# The wildcard is intentionally one final instance-ID token, not `>`.
- "ec2.cmd.*"
# EC2 VPC / network ops handled by daemon before delegation to vpcd.
- "ec2.CreateVpc"
- "ec2.DescribeVpcs"
- "ec2.CreateSubnet"
- "ec2.CreateInternetGateway"
- "ec2.CreateKeyPair"
- "ec2.CreateImage"
- "ec2.DescribePlacementGroups"
# Cluster coordination subjects.
- "ec2.ReserveClusterNode"
- "ec2.FinalizeClusterInstances"
# An EIP association reaches whichever node holds the instance, which is
# rarely the node that served the request (fan-out, no queue group).
- "ec2.eni.public-ip-changed"
# contracts/cluster/v1 owns the node status/capacity payload and subject.
- "spinifex.node.status"
# contracts/cluster/v1 owns the VM inventory payload and subject.
- "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"
# Operator-plane Predastore topology contract (contracts/operator/v1).
- "spinifex.storage.config"
# ELBv2 control plane (domains/elbv2/service_nats.go topic map).
- "elbv2.CreateLoadBalancer"
- "elbv2.DeleteLoadBalancer"
- "elbv2.DescribeLoadBalancers"
- "elbv2.CreateTargetGroup"
- "elbv2.DeleteTargetGroup"
- "elbv2.DescribeTargetGroups"
- "elbv2.RegisterTargets"
- "elbv2.DeregisterTargets"
- "elbv2.DescribeTargetHealth"
- "elbv2.CreateListener"
- "elbv2.DeleteListener"
- "elbv2.DescribeListeners"
# ACM-alike certificate store (domains/acm; subscribed in daemon.go).
- "acm.ImportCertificate"
- "acm.DescribeCertificate"
- "acm.ListCertificates"
- "acm.DeleteCertificate"
# Route 53 hosted zones (domains/route53; subscribed in daemon.go).
- "route53.*"
# Operator-only zone activation and delegation, sent by spx admin dns.
- "route53.admin.*"
- "elbv2.AddListenerCertificates"
- "elbv2.RemoveListenerCertificates"
- "elbv2.DescribeListenerCertificates"
- "elbv2.DescribeSSLPolicies"
# RDS control plane (domains/rds/instance/subjects.go). One wildcard rather than
# 30 literals: every action subject is registered from a constant, so a
# per-subject list would be a hand-mirror of that file with nothing
# checking it.
- "rds.*"
# ECS control plane (domains/ecs/service_nats.go, registered in
# daemon.go). One wildcard, as for RDS: every action subject is a literal
# in those two files, so a per-subject list would only mirror them.
- "ecs.*"
# Layer-2 agent bus: register and health are wildcard subscriptions over
# {account}.{db-instance}, so the subject is built at runtime and only the
# prefix is declarable.
- "rds.bus.*"
# ECS Layer-2 scheduler/agent bus (contracts/ecs/v1): account, cluster and
# instance/task IDs are runtime-built, so only the prefix is declarable.
# Only the leader holds these subscriptions, so there is no queue group.
- "ecs.bus.*"
# Ochre vector store tenant surface (domains/ochre/vector/api.go),
# gated behind config.OchreVectorConfig.Enabled (default off).
- "ochre.vector.createIndex"
- "ochre.vector.deleteIndex"
- "ochre.vector.listIndexes"
- "ochre.vector.ingest"
- "ochre.vector.describeJob"
- "ochre.vector.query"
- "ochre.vector.listJobs"
- "ochre.vector.stopJob"
# Fence route owned by contracts/viperblockd/legacy/v1: viperblockd
# announces a torn-down export so this node stops the guest that was
# using it. "ebs.fenced" is the single-node form; a named node is
# addressed as "ebs.<node>.fenced".
- "ebs.fenced"
publishes:
# → vpcd
- "vpc.create"
- "vpc.delete"
- "vpc.create-subnet"
- "vpc.delete-subnet"
- "vpc.create-sg"
- "vpc.delete-sg"
- "vpc.update-sg"
- "vpc.create-port"
- "vpc.delete-port"
- "vpc.update-port-sgs"
# EC2-to-network projection owned by contracts/ec2/v1. The network
# subscriber realizes it in OVN without reading EC2 private state.
- "vpc.igw-attach"
- "vpc.igw-detach"
- "vpc.add-nat-gateway"
- "vpc.delete-nat-gateway"
- "vpc.add-nat"
- "vpc.delete-nat"
- "vpc.dhcp.acquire"
- "vpc.dhcp.release"
# Per-subnet route-table projections owned by domains/ec2/routetable:
# IGW default-route reachability and the egress gate/ungate decision it
# implies, published fire-and-forget on every route-table mutation.
- "vpc.add-igw-route"
- "vpc.delete-igw-route"
- "vpc.gate-subnet-egress"
- "vpc.ungate-subnet-egress"
# Egress-only reroute removal for a single system instance
# (domains/eks/cluster). Fire-and-forget. vpc.add-system-egress has no
# current producer.
- "vpc.delete-system-egress"
# → viperblockd
# The migrated legacy ebs.config and ebs.sync payloads and routes are
# owned by contracts/viperblockd/legacy/v1. They are transitional and
# distinct from the generic providers/ebs protocol; other legacy ebs.*
# routes remain to be migrated separately.
- "ebs.mount"
- "ebs.unmount"
- "ebs.delete"
- "ebs.sync"
- "ebs.snapshot.*"
- "ebs.config"
- "ebs.config.*"
# Abandon route owned by contracts/viperblockd/legacy/v1: the daemon
# asks viperblockd to tear an export down without sealing. "ebs.abandon"
# is the single-node form; a named node addresses "ebs.<node>.abandon".
- "ebs.abandon"
# → predastore (S3 / AMI / keypair store)
- "s3.*"
# → every daemon, so the one holding the instance reports the new address
- "ec2.eni.public-ip-changed"
# IAM lifecycle event
- "iam.account.created"
# → the in-guest rds-agent: set-password and the like, published on the
# per-instance command subject and answered on its reply subject.
- "rds.bus.*"
# ECS service-reconcile self-wake (contracts/ecs/v1): the worker that
# recorded a task-state change is rarely the leader, so this fans the
# wake out and the leader's own "ecs.bus.*" subscription picks it up.
- "ecs.bus.*"
depends_on:
- "vpc.*"
- "ebs.*"
- "s3.*"
vpcd:
path: spinifex/runtime/roles/vpcd
# OVN-backed VPC / subnet / SG / NAT / IGW data plane.
subscribes:
- "vpc.create"
- "vpc.delete"
- "vpc.create-subnet"
- "vpc.delete-subnet"
- "vpc.create-sg"
- "vpc.delete-sg"
- "vpc.update-sg"
- "vpc.create-port"
- "vpc.delete-port"
- "vpc.update-port-sgs"
- "vpc.igw-attach"
- "vpc.igw-detach"
- "vpc.add-nat-gateway"
- "vpc.delete-nat-gateway"
- "vpc.add-nat"
- "vpc.delete-nat"
- "vpc.dhcp.acquire"
- "vpc.dhcp.release"
- "vpc.add-igw-route"
- "vpc.delete-igw-route"
- "vpc.gate-subnet-egress"
- "vpc.ungate-subnet-egress"
- "vpc.add-system-egress"
- "vpc.delete-system-egress"
publishes:
- "vpc.port-status"
- "vpc.dhcp.lease-changed"
- "vpc.dhcp.owner-check"
depends_on: []
viperblockd:
path: spinifex/runtime/roles/viperblockd
# EBS data plane. Backed by the viperblock storage engine in the
# mulga/viperblock submodule. Submodule pointer bump → selector forces
# "run all" (contract changes can be invisible at the spinifex source
# level).
# The migrated legacy ebs.config and ebs.sync routes are versioned by
# contracts/viperblockd/legacy/v1, not by providers/ebs. Other legacy
# ebs.* routes remain to be migrated separately.
# The dirty-marker JetStream KV bucket VIPERBLOCK_VOLUME_DIRTY is owned
# by the same contract; not a NATS subject, so not listed below.
#
# Source layout: spinifex/runtime/roles/viperblockd (role: process lifecycle,
# NATS subscription wiring) and spinifex/providers/ebs/viperblock (the
# Viperblock EBS provider adapter the role wires up). Selector treats the
# adapter path as belonging to this service via `additional_paths`.
additional_paths:
- spinifex/providers/ebs/viperblock
subscribes:
- "ebs.mount"
- "ebs.unmount"
- "ebs.delete"
- "ebs.sync"
- "ebs.snapshot.*"
- "ebs.config"
- "ebs.config.*"
# Abandon route (contracts/viperblockd/legacy/v1): request/reply from
# the daemon, answered by the Viperblock adapter on the named node.
- "ebs.abandon"
publishes:
- "ebs.mount.response"
# Fence route (contracts/viperblockd/legacy/v1): announced after the
# Viperblock adapter tears an export down because its lease moved.
- "ebs.fenced"
- "ebs.unmount.response"
depends_on: []
predastore:
path: spinifex/runtime/roles/predastore
# S3 / AMI / keypair storage. External submodule under mulga/predastore;
# this dir contains only the spinifex-side adapter. Submodule pointer
# bump → selector forces "run all".
subscribes:
- "s3.*"
publishes: []
depends_on: []
nats:
path: spinifex/runtime/roles/nats
# NATS broker bootstrap / cluster config. Not a NATS pub/sub
# participant. Any change here is infra-path; selector forces
# "run all".
subscribes: []
publishes: []
depends_on: []
spinifexui:
path: spinifex/runtime/roles/spinifexui
# Web UI. Talks to awsgw over HTTP, never NATS. Diffs limited to this
# dir do not need any e2e suite to run.
subscribes: []
publishes: []
depends_on: []
covers_no_suite: true
# Fixture → service mapping. Each Ensure* helper in
# tests/e2e/harness/fixtures.go exercises this set of services; the
# Bead 4 selector AST-scans test files for harness.Ensure* call sites
# and resolves coverage via this block.
fixtures:
EnsureKeyPair:
services: [spinifex_daemon, predastore]
EnsureAMI:
services: [spinifex_daemon, predastore, viperblockd]
EnsureDefaultVPC:
services: [vpcd, spinifex_daemon]
EnsureSubnet:
services: [vpcd, spinifex_daemon]
EnsureSG:
services: [vpcd, spinifex_daemon]
EnsureInstance:
services: [spinifex_daemon, viperblockd, vpcd]
EnsureVolume:
services: [spinifex_daemon, viperblockd]
EnsureSnapshot:
services: [spinifex_daemon, viperblockd, predastore]
EnsureNATGateway:
services: [vpcd, spinifex_daemon]
EnsureLoadBalancer:
services: [spinifex_daemon, awsgw]
suites:
e2e-cert:
path: tests/e2e/cert
covers: [awsgw, spinifex_daemon, vpcd, predastore]
e2e-lb:
path: tests/e2e/lb
covers: [awsgw, spinifex_daemon, vpcd, viperblockd, predastore]
# Exercises full ELBv2 control plane (domains/elbv2) + data plane
# (LB agent on the system VM). ELBv2 is part of spinifex_daemon in
# this manifest; revisit if it splits into a standalone service.
e2e-single:
path: tests/e2e/single
covers: [awsgw, spinifex_daemon, vpcd, viperblockd, predastore]
# Full single-node lifecycle: VPC, AMI, keypair, instance, volume,
# snapshot, IAM. Currently runs all phases sequentially under one
# `TestSingleNode`. Bead 3 (single-fixture-migration) splits this
# 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 (domains/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-bedrock:
path: tests/e2e/bedrock
covers: [awsgw]
# Ochre (AWS-Bedrock) control plane: static catalog list/get served
# straight from awsgw (gateway/bedrock.go, domains/ochre/) — no NATS
# hop to spinifex_daemon. Converse (Anthropic-direct / self-host vLLM)
# subtests are gated on backend availability and skip by default.
e2e-ecs:
path: tests/e2e/ecs
covers: [awsgw, spinifex_daemon, vpcd, viperblockd, predastore]
# ECS data plane end-to-end: cluster + a real container instance launched
# from the spinifex-ecs-node AMI (boots, registers over the gateway, runs
# tasks through containerd), standalone tasks in awsvpc/bridge/host modes,
# and an awsvpc service fronted by an ELBv2 target group. Boots a guest VM,
# so vpcd (OVN + awsvpc ENI), viperblockd (root volume), predastore (AMI)
# are all in the path.
e2e-rds:
path: tests/e2e/rds
covers: [awsgw, spinifex_daemon, vpcd, viperblockd, predastore]
# RDS create/describe/connect: CreateDBInstance boots a dual-NIC DB VM from
# the engine AMI (predastore) with a Viperblock data volume (viperblockd) and
# a customer ENI (vpcd), and the reconciler flips it to available on the
# in-guest agent's first healthy heartbeat over awsgw. Every test deletes the
# instances it creates, so the suite needs no gate beyond SPINIFEX_E2E.
e2e-ddil:
path: tests/e2e/ddil
covers: [awsgw, spinifex_daemon, vpcd, viperblockd, predastore]
# DDIL = degraded / disconnected / intermittent / low-bandwidth.
# Exercises the full daemon stack under network failure injection;
# any service touching the wire path is in scope.
e2e-route53:
path: tests/e2e/route53
covers: [awsgw, spinifex_daemon, predastore]
# Hosted zones end to end: delegation check, publish to the northstar zone
# store in predastore, and every node answering before a change is INSYNC.
# Suites below are bash-only today. Entries activate as the Go ports
# land (Bead 2 fixtures unblock new-shape ports for multinode etc.).
#
# e2e-multinode:
# path: spinifex/tests/e2e/multinode
# covers: [awsgw, spinifex_daemon, vpcd, viperblockd, predastore]
# e2e-reboot:
# path: spinifex/tests/e2e/reboot
# covers: [spinifex_daemon, viperblockd]
# e2e-tofu:
# path: spinifex/tests/e2e/tofu
# covers: [awsgw, spinifex_daemon, vpcd]
# e2e-bm:
# path: spinifex/tests/e2e/baremetal
# covers: [awsgw, spinifex_daemon, vpcd, viperblockd, predastore]
#
# e2e-natuplink activates once CI can provision an external_mode=nat
# node (setup-ovn.sh --nat-uplink + spx admin init --external-mode=nat).
# e2e-natuplink:
# path: spinifex/tests/e2e/natuplink
# covers: [awsgw, spinifex_daemon, vpcd, viperblockd, predastore]
# Special-case rules consumed by the selector (Bead 4). Hard-coded in
# the tool, listed here for visibility.
#
# infra_paths_force_all:
# - spinifex/runtime/roles/nats/**
# - spinifex/tests/e2e/harness/**
# - spinifex/tests/e2e/lib/**
# - spinifex/.github/workflows/e2e-go.yml
# - spinifex/.github/scripts/**
# - spinifex/Makefile
# - mulga/scripts/tofu-cluster/**
#
# submodule_force_all:
# - mulga/predastore (submodule pointer)
# - mulga/viperblock (submodule pointer)
#
# many_services_force_all_threshold: 3 # ≥ N services touched → run all
@@ -69,8 +69,8 @@ Clone the Terraform examples from the Spinifex repository:
```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
cd docs/terraform/bastion-private-subnet
git sparse-checkout set docs/terraform-workbooks
cd docs/terraform-workbooks/bastion-private-subnet
```
Or create a `main.tf` file and paste the full configuration below.
+2 -2
View File
@@ -4,13 +4,13 @@
# 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.27.3-alpine@sha256:f2c872bcee949b7be9c490efd02b48a8a2fbf947cdf533f1c58525ec9d8170fd AS build
FROM golang:1.27.3-alpine@sha256:d200d2dd76b03a99b52651fd1325aff7dcd35422c35c01bac56571b0507f3deb 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@sha256:18f4cc26c9ffa7f20a359298be7deadac5c607c8e03a02b8119f1a5fc2f82c99
FROM gcr.io/distroless/static:nonroot@sha256:25ac8f0c3b2334a9eb05452266bd4697864a8c8ef0ef71e5e6480d7f92868329
COPY --from=build /out/spinifex-demo /spinifex-demo
EXPOSE 8080
USER nonroot:nonroot
@@ -153,6 +153,8 @@ kubectl get nodes
kubectl get pods -o wide
```
`kubectl get nodes` lists the worker nodes only, because control-plane hosts are not Kubernetes Nodes.
`kubectl get pods -o wide` shows the demo pods and which node each landed on.
### Cleanup
+86 -29
View File
@@ -1,7 +1,7 @@
---
title: "Nginx Web Server (Load Balanced)"
seoTitle: "Load-Balanced Nginx Behind an AWS ALB — Spinifex Docs"
description: "Deploy a VPC with two private EC2 instances running Nginx behind an internet-facing Application Load Balancer on Spinifex, using Terraform or OpenTofu."
description: "Deploy Nginx EC2 instances behind an internet-facing Application Load Balancer on Spinifex with Terraform, then spread them across nodes and test failover."
category: "Terraform Workbooks"
tags:
- terraform
@@ -10,6 +10,8 @@ tags:
- elbv2
- alb
- vpc
- placement-group
- resiliency
- workbook
resources:
- title: "Terraform AWS Provider"
@@ -22,7 +24,7 @@ resources:
# Terraform: Nginx Web Servers with ALB
> Deploys two Nginx web server instances behind an Application Load Balancer on Spinifex.
> Deploys Nginx web server instances behind an Application Load Balancer on Spinifex, optionally spread one per node so the site survives losing a node.
## Table of Contents
@@ -34,18 +36,25 @@ resources:
## Overview
Deploy two Nginx web servers behind an internet-facing Application Load Balancer on Spinifex using Terraform/OpenTofu. This workbook provisions a VPC with public and private subnets, an internet gateway and NAT Gateway, route tables, security group, SSH key pair, an application load balancer (ALB) and two EC2 instances with cloud-init user-data that installs and starts Nginx. Only the ALB is reachable from outside the VPC — the Nginx instances live in the **private subnets** and reach the internet only for cloud-init bootstrapping via the NAT Gateway.
Deploy Nginx web servers behind an internet-facing Application Load Balancer on Spinifex using Terraform or OpenTofu.
This workbook provisions a VPC with two public subnets, an internet gateway, a route table, a security group, an SSH key pair, an Application Load Balancer (ALB) and two or more EC2 instances with cloud-init user-data that installs and starts Nginx.
The web servers get public IPs so cloud-init can install Nginx, and the ALB reaches them over the VPC by their private addresses.
On a cluster of three or more nodes, a spread placement group puts each web server on a different node.
Losing a node then takes out at most one server, the ALB stops sending it traffic, and Spinifex restarts the lost instances on the surviving nodes.
[Step 6](#step-6-test-resiliency) shows how to watch that happen.
<p align="center">
<img src="../../../.github/assets/diagrams/tf-nginx-alb.svg" alt="Nginx + ALB VPC — IGW, two public subnets with ALB ENIs and NAT GW, two private subnets hosting nginx" width="900">
<img src="../../../.github/assets/diagrams/tf-nginx-alb.svg" alt="Nginx + ALB VPC — IGW, two public subnets with the ALB and the nginx web servers" width="900">
</p>
**What you'll learn:**
- Configuring the AWS Terraform provider to target Spinifex
- Creating a VPC with public + private subnets, an IGW and a NAT Gateway
- Provisioning a multi-AZ internet-facing ALB fronting private workers
- Provisioning an EC2 instance with cloud-init user-data
- Creating a VPC with public subnets and an internet gateway
- Provisioning an internet-facing ALB in front of a group of web servers
- Provisioning EC2 instances with cloud-init user-data
- Spreading instances across nodes with a placement group
- Generating SSH key pairs with the TLS provider
**What gets created**
@@ -53,22 +62,33 @@ Deploy two Nginx web servers behind an internet-facing Application Load Balancer
| Resource | Name | Purpose |
|---|---|---|
| VPC | `nginx-alb-vpc` | Isolated network (10.20.0.0/16) |
| Public Subnets | `nginx-alb-public-a`, `nginx-alb-public-b` | Two AZs hosting the ALB and NAT Gateway |
| Private Subnets | `nginx-alb-private-a`, `nginx-alb-private-b` | Two AZs hosting the Nginx workers |
| Public Subnets | `nginx-alb-public-a`, `nginx-alb-public-b` | Host the ALB and the web servers |
| Internet Gateway | `nginx-alb-igw` | Routes internet traffic for the public subnets |
| Elastic IP | `nginx-alb-nat-eip` | Public address for the NAT Gateway |
| NAT Gateway | `nginx-alb-nat` | Outbound internet for the private subnets (cloud-init apt bootstrap) |
| Security Group | `nginx-alb-sg` | Allows SSH (22) and HTTP (80) inbound |
| EC2 Instances | `nginx-alb-1`, `nginx-alb-2` | Ubuntu 26.04 with Nginx via cloud-init (private subnets) |
| Placement Group | `nginx-alb-spread` | Only with `placement_strategy`; spread puts one web server per node |
| EC2 Instances | `nginx-alb-1`, `nginx-alb-2`, … | Ubuntu with Nginx via cloud-init, one per `server_count` |
| ALB | `nginx-alb` | Internet-facing Application Load Balancer on port 80 |
| Target Group | `nginx-alb-tg` | HTTP health-checked group for both instances |
| Target Group | `nginx-alb-tg` | HTTP health-checked group of every web server |
| Listener | HTTP :80 | Forwards traffic to the target group |
**Variables**
| Variable | Default | Purpose |
|---|---|---|
| `instance_type` | `t3.small` | Instance type of the web servers |
| `server_count` | `2` | Number of web servers behind the ALB |
| `placement_strategy` | none | `spread` puts each web server on a different node; `cluster` puts them all on one |
| `region` | `ap-southeast-2` | Spinifex region |
| `spinifex_endpoint` | `https://127.0.0.1:9999` | Spinifex AWS endpoint |
A spread placement group on Spinifex spreads across nodes, so `server_count` can be at most the number of nodes in the cluster.
**Prerequisites:**
- Spinifex installed and running (see [Installing Spinifex](/docs/install))
- An Ubuntu 26.04 AMI imported (see [Setting Up Your Cluster](/docs/setting-up-your-cluster))
- An Ubuntu 26.04 or 24.04 AMI imported (see [Setting Up Your Cluster](/docs/setting-up-your-cluster))
- OpenTofu or Terraform installed
- For [Step 6](#step-6-test-resiliency), a cluster of three nodes
## Instructions
@@ -79,20 +99,20 @@ Clone the Terraform examples from the Spinifex repository:
```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
cd docs/terraform/nginx-alb
git sparse-checkout set docs/terraform-workbooks
cd docs/terraform-workbooks/nginx-alb
```
Or create a `main.tf` file and paste the full configuration below.
<!-- INCLUDE: main.tf lang:hcl -->
### Step 2. Install Debian AMI
### Step 2. Import the Ubuntu AMI
Install the Debian 13 AMI which is used in the example to host the `nginx` webservers as an EC2 instance.
Import the Ubuntu 26.04 AMI the web servers run on, from any node of the cluster:
```bash
spx admin images import --name debian-13-x86_64
spx admin images import --name ubuntu-26.04-x86_64
```
> **Note:** The load balancer itself runs as a direct-boot QEMU microvm using the kernel + initramfs bundled with the Spinifex distribution (`/usr/share/spinifex/microvm/`), so no separate LB AMI import is required.
@@ -133,9 +153,16 @@ Next, apply and launch the template:
tofu apply
```
On a three-node cluster, launch three web servers instead, one on each node:
```bash
tofu apply -var server_count=3 -var placement_strategy=spread
```
### Step 5. Verify
> **Note:** EC2 instances can take 30+ seconds to boot after apply, and the NAT Gateway must be `available` before cloud-init on the workers can reach the apt repository. If the ALB returns 5xx or HTTP is unreachable, wait and retry — the target group health checks need a moment to mark both instances healthy once Nginx has installed.
> **Note:** EC2 instances can take 30+ seconds to boot after apply, and Nginx installs after that.
> If the ALB returns 5xx or HTTP is unreachable, wait and retry, since the target group health checks need a moment to mark the instances healthy once Nginx has installed.
The ALB is internet-facing, but the DNS name Spinifex returns (`*.elb.spinifex.local`) will not resolve from your host. Fetch the ALB's public IP with the AWS CLI:
@@ -145,7 +172,7 @@ ALB_IP=$(aws elbv2 describe-load-balancers --names nginx-alb \
--output text)
```
Then hit the ALB — successive requests should alternate between Server 1 and Server 2:
Then hit the ALB, and successive requests rotate between the servers:
```bash
curl http://$ALB_IP
@@ -154,7 +181,8 @@ curl http://$ALB_IP
Open `http://$ALB_IP` in your browser and refresh to see the page alternate content served from each instance.
The Nginx instances themselves only have private IPs (see the `instance_1_private_ip` / `instance_2_private_ip` outputs) and are only reachable from inside the VPC — go through the ALB.
Each web server also has a public IP of its own, listed by `tofu output instance_public_ips`.
The ALB reaches them by their private IPs, listed by `tofu output instance_private_ips`.
Check target health via AWS CLI:
@@ -166,12 +194,40 @@ TG_ARN=$(aws elbv2 describe-target-groups \
aws elbv2 describe-target-health --target-group-arn $TG_ARN
```
### Step 6. Test resiliency
On a cluster of three nodes, apply with `-var server_count=3 -var placement_strategy=spread`, then check that each web server is on its own node:
```bash
spx get vms
```
The `NODE` column names each node once for the three `t3.small` web servers, and the load balancer is the `sys.micro` instance.
Request the page once a second in a second terminal:
```bash
while :; do
printf '%s %s\n' "$(date +%T)" "$(curl -s -m2 "http://$ALB_IP/" | grep -o 'Server [0-9]' || echo FAILED)"
sleep 1
done
```
Then power off one node, the way a hardware failure would, without shutting it down first.
On Vultr, `deploy-vultr.sh stop node3` does this, as described in [Running Spinifex on Vultr](/docs/vultr-integration#8-test-resiliency).
Within about a minute the load balancer stops sending requests to the lost web server, and the other two answer every request.
About three minutes after the power-off, Spinifex has restarted the lost web server on a surviving node with the same instance ID, private IP and public IP, and the load balancer sends it requests again once its health checks pass.
Power the node back on and wait until `spx get nodes` shows it `Ready` before you test another node.
### Cleanup
```bash
tofu destroy
```
If you applied with `-var` options, pass the same ones to `tofu destroy`.
## Troubleshooting
### AMI Not Found
@@ -213,18 +269,19 @@ If targets stay unhealthy, verify the instances are running:
aws ec2 describe-instances --profile spinifex
```
If cloud-init on the workers never finished, confirm the NAT Gateway is `available` (the private subnets rely on it for outbound apt access):
```bash
aws ec2 describe-nat-gateways --query 'NatGateways[].[NatGatewayId,State]'
```
If cloud-init on the workers never finished, SSH to one by its public IP and read its log, as described in [Nginx Not Responding](#nginx-not-responding).
### Nginx Not Responding
The Nginx instances have no public IP, so you can't SSH in directly from your host. If you need to inspect cloud-init logs, launch a small jump host in the same VPC or run commands via the Spinifex console, then:
SSH to the web server by its public IP, from `tofu output instance_public_ips`, and check cloud-init and Nginx:
```bash
ssh -i nginx-alb-demo.pem ubuntu@<instance_private_ip>
ssh -i nginx-alb-demo.pem ubuntu@<instance_public_ip>
sudo journalctl -u cloud-init --no-pager
sudo systemctl status nginx
```
### Spread Launch Fails With InsufficientInstanceCapacity
A spread placement group puts at most one of its instances on each node.
Set `server_count` no higher than the number of nodes, or leave `placement_strategy` empty.
+87 -66
View File
@@ -12,10 +12,16 @@
#
# Demonstrates: VPC, public subnets, internet gateway, route tables,
# security group, key pair, cloud-init user-data, EC2 instances with
# auto-assigned public IPs, ALB, target group, and listener.
# auto-assigned public IPs, an optional placement group, ALB, target group,
# and listener.
#
# server_count sets how many web servers launch (default 2). Setting
# placement_strategy = "spread" puts each one on a different Spinifex node,
# so a three-node cluster with server_count = 3 can lose any node and keep
# serving through the ALB.
#
# Usage:
# cd spinifex/docs/terraform/nginx-alb
# cd spinifex/docs/terraform-workbooks/nginx-alb
# export AWS_PROFILE=spinifex
# tofu init && tofu apply
#
@@ -28,6 +34,9 @@
#
# Then:
# curl http://<alb_public_ip> # Load-balanced Nginx (alternates between instances)
#
# Three servers, one per node:
# tofu apply -var server_count=3 -var placement_strategy=spread
terraform {
required_version = ">= 1.6.0"
@@ -62,6 +71,28 @@ variable "instance_type" {
default = "t3.small"
}
variable "server_count" {
type = number
default = 2
description = "Number of Nginx web servers behind the ALB"
validation {
condition = var.server_count >= 1
error_message = "server_count must be at least 1."
}
}
variable "placement_strategy" {
type = string
default = ""
description = "Placement group strategy for the web servers: spread (one per node), cluster (all on one node), or empty for none"
validation {
condition = contains(["", "spread", "cluster"], var.placement_strategy)
error_message = "placement_strategy must be spread, cluster, or empty."
}
}
variable "spinifex_endpoint" {
type = string
default = "https://127.0.0.1:9999"
@@ -241,14 +272,28 @@ resource "aws_security_group" "web" {
}
# ---------------------------------------------------------------------------
# EC2 Instances — two Nginx servers with distinct landing pages
# Placement Group — optional; spread puts each web server on its own node
# ---------------------------------------------------------------------------
resource "aws_instance" "nginx_1" {
ami = data.aws_ami.ubuntu.id
instance_type = var.instance_type
resource "aws_placement_group" "web" {
count = var.placement_strategy == "" ? 0 : 1
name = "nginx-alb-${var.placement_strategy}"
strategy = var.placement_strategy
}
subnet_id = aws_subnet.public_a.id
# ---------------------------------------------------------------------------
# EC2 Instances — Nginx servers with distinct landing pages, alternating
# between the two public subnets
# ---------------------------------------------------------------------------
resource "aws_instance" "nginx" {
count = var.server_count
ami = data.aws_ami.ubuntu.id
instance_type = var.instance_type
placement_group = one(aws_placement_group.web[*].name)
subnet_id = count.index % 2 == 0 ? aws_subnet.public_a.id : aws_subnet.public_b.id
vpc_security_group_ids = [aws_security_group.web.id]
key_name = aws_key_pair.nginx.key_name
@@ -266,7 +311,7 @@ resource "aws_instance" "nginx_1" {
<head><title>Spinifex ALB Demo</title></head>
<body style="font-family: sans-serif; max-width: 600px; margin: 80px auto;">
<h1>Hello from Spinifex!</h1>
<p><strong>Instance:</strong> $INSTANCE_ID (Server 1)</p>
<p><strong>Instance:</strong> $INSTANCE_ID (Server ${count.index + 1})</p>
<p>This Nginx server is behind an Application Load Balancer.</p>
<hr>
<p><small>Provisioned via cloud-init user-data.</small></p>
@@ -280,48 +325,19 @@ resource "aws_instance" "nginx_1" {
)
tags = {
Name = "nginx-alb-1"
Name = "nginx-alb-${count.index + 1}"
}
}
resource "aws_instance" "nginx_2" {
ami = data.aws_ami.ubuntu.id
instance_type = var.instance_type
# Earlier versions declared the two servers separately.
moved {
from = aws_instance.nginx_1
to = aws_instance.nginx[0]
}
subnet_id = aws_subnet.public_b.id
vpc_security_group_ids = [aws_security_group.web.id]
key_name = aws_key_pair.nginx.key_name
user_data_base64 = base64encode(<<-USERDATA
#!/bin/bash
set -euo pipefail
apt-get update -y
apt-get install -y nginx
INSTANCE_ID=$(cat /var/lib/cloud/data/instance-id 2>/dev/null || hostname)
cat > /var/www/html/index.html <<HTML
<!DOCTYPE html>
<html>
<head><title>Spinifex ALB Demo</title></head>
<body style="font-family: sans-serif; max-width: 600px; margin: 80px auto;">
<h1>Hello from Spinifex!</h1>
<p><strong>Instance:</strong> $INSTANCE_ID (Server 2)</p>
<p>This Nginx server is behind an Application Load Balancer.</p>
<hr>
<p><small>Provisioned via cloud-init user-data.</small></p>
</body>
</html>
HTML
systemctl enable nginx
systemctl restart nginx
USERDATA
)
tags = {
Name = "nginx-alb-2"
}
moved {
from = aws_instance.nginx_2
to = aws_instance.nginx[1]
}
# ---------------------------------------------------------------------------
@@ -365,19 +381,25 @@ resource "aws_lb_target_group" "nginx" {
}
# ---------------------------------------------------------------------------
# Register both instances as targets
# Register every web server as a target
# ---------------------------------------------------------------------------
resource "aws_lb_target_group_attachment" "nginx_1" {
resource "aws_lb_target_group_attachment" "nginx" {
count = var.server_count
target_group_arn = aws_lb_target_group.nginx.arn
target_id = aws_instance.nginx_1.id
target_id = aws_instance.nginx[count.index].id
port = 80
}
resource "aws_lb_target_group_attachment" "nginx_2" {
target_group_arn = aws_lb_target_group.nginx.arn
target_id = aws_instance.nginx_2.id
port = 80
moved {
from = aws_lb_target_group_attachment.nginx_1
to = aws_lb_target_group_attachment.nginx[0]
}
moved {
from = aws_lb_target_group_attachment.nginx_2
to = aws_lb_target_group_attachment.nginx[1]
}
# ---------------------------------------------------------------------------
@@ -401,12 +423,11 @@ resource "aws_lb_listener" "http" {
output "note" {
value = <<-EOT
EC2 instances can take 30+ seconds to boot after apply — if HTTP is
unreachable, wait and retry.
EC2 instances can take 30+ seconds to boot after apply, and Nginx installs
after that — if HTTP is unreachable, wait and retry.
The Nginx instances have private IPs only. The ALB DNS name ends in
.elb.spinifex.local and will not resolve from your host, so fetch the
ALB's public IP with:
The ALB DNS name ends in .elb.spinifex.local and will not resolve from
your host, so fetch the ALB's public IP with:
aws elbv2 describe-load-balancers --names nginx-alb \
--query 'LoadBalancers[0].AvailabilityZones[].LoadBalancerAddresses[].IpAddress' \
@@ -428,18 +449,18 @@ output "alb_dns_name" {
value = aws_lb.web.dns_name
}
output "instance_1_id" {
value = aws_instance.nginx_1.id
output "instance_ids" {
value = aws_instance.nginx[*].id
}
output "instance_1_private_ip" {
value = aws_instance.nginx_1.private_ip
output "instance_private_ips" {
value = aws_instance.nginx[*].private_ip
}
output "instance_2_id" {
value = aws_instance.nginx_2.id
output "instance_public_ips" {
value = aws_instance.nginx[*].public_ip
}
output "instance_2_private_ip" {
value = aws_instance.nginx_2.private_ip
output "placement_group" {
value = one(aws_placement_group.web[*].name)
}
@@ -56,8 +56,8 @@ Clone the Terraform examples from the Spinifex repository:
```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
cd docs/terraform/nginx-webserver
git sparse-checkout set docs/terraform-workbooks
cd docs/terraform-workbooks/nginx-webserver
```
Or create a `main.tf` file and paste the full configuration below.
+2 -2
View File
@@ -74,8 +74,8 @@ Clone the Terraform examples from the Spinifex repository:
```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
cd docs/terraform/s3-webapp
git sparse-checkout set docs/terraform-workbooks
cd docs/terraform-workbooks/s3-webapp
```
Or create the files manually and paste the full configuration below.
+26 -4
View File
@@ -29,6 +29,7 @@ The largest suite, 27 top-level tests. Notable entry points:
- `TestInstanceMetadata` — IMDS from inside a guest.
- **`TestIMDSDatapathSurvivesVswitchdRestart`** restarts `ovs-vswitchd` under a guest with an Elastic IP, once through systemd and once with SIGKILL. After each, the guest must answer over its EIP and fetch its instance ID through IMDSv2 within 60 seconds, and `br-imds` must hold all 7 of the guest's own flows. It is sequential because it takes the whole node's datapath down.
- `TestStopStart`, `TestEarlyRebootLiveness`, `TestGuestChurnDurability`, `TestSnapshotLifecycle`, `TestSnapshotBackedLaunch`, `TestCreateImage`, `TestVolumeLifecycle`, `TestSpotInstanceLifecycle`, `TestLaunchTemplateBoot`.
- **`TestRebootLoop`** is opt-in and skipped in a whole-suite run. Name it in `-test.run`, or set `SPINIFEX_REBOOT_LOOP_ROUNDS` (default 10), and it reboots one fresh guest through the API that many times and then once from inside, requiring a new boot ID every time.
- `TestNegativeErrorPaths` — the `8a`–`8t` AWS error-shape cases (malformed AMI ID, invalid instance type, volume in use, detach-root forbidden, unknown or malformed instance IDs on the lifecycle verbs, and so on).
- **`TestKVReplication`** — the single-node half of the KV replication policy, and it is not the multi-node assertion with a smaller number in it. On one node one replica per bucket is the correct answer rather than the defect, so what it proves is the opposite: that nothing declared a replica count this deployment cannot place, that `spx admin kv replicas` exits 0 and reports `WANT 1`, and that `--repair` run twice against a cluster with nothing to repair changes no bucket's replica count and leaves every bucket serving. The multi-node suites cannot cover any of that, because on three nodes the unplaceable count is the right one. It lives here rather than in its own suite so it runs in every single-node nightly cell, across every host and network permutation.
@@ -36,11 +37,13 @@ The largest suite, 27 top-level tests. Notable entry points:
Runs **on** a node, shelling out to `ip`/`iptables`/`ovn-nbctl` locally. Skips unless `spinifex.toml` says `external_mode = "nat"`. Nine sequential phases: host wiring (transit veth, `ip_forward`, NAT egress rules), config, the EIP surface (enabled or disabled depending on whether a public pool exists), default-subnet public IP mapping, OVN gateway and SNAT on the transit net, instance egress **proven via the serial console**, host-to-guest Tier 1 ingress, a second VPC getting a unique transit gateway IP, and the EIP ingress lifecycle.
**The EIP ingress phase also proves hairpin traffic.** On a single node a second guest must complete a TCP handshake to the first guest's EIP on port 22, as an AWS instance reaches a public address in its own VPC. Without the `SPX-EIP-HAIRPIN` SNAT the packet returns to OVN from the transit range, which is exempt from EIP DNAT, and is never answered; found when an EKS worker on Vultr could not reach its control plane's endpoint.
**Phase 9 has two shapes.** On one node it is the EIP lifecycle: allocate, associate, host delivery plumbing, a TCP handshake to the address, a vpcd restart that must replay the plumbing, and disassociate tearing it down. On a cluster it is the distributed lane instead — a guest on every node, each public IP plumbed by the node running its guest and by **no other**, every transit veth carrying the one cluster-wide MAC, each `dnat_and_snat` row carrying `external_mac` and `logical_port`, inbound proven by a handshake and outbound by each guest's own console. **That lane is the regression guard for the two defects that made routed NAT work on one node only**, and neither is visible to a single-node run.
Phases 7 and 8 are gated on a cluster by what the datapath can actually do: the transit segment is a localnet, so host-to-guest Tier 1 ingress only exists where the VPC's gateway router port and the guest are both on this host. The VPC ingress route itself is installed on every node, so phase 8 checks it unconditionally.
Three nightly cells run it: `nat-single` (cell 19), its Oracle Linux twin (cell 36) and `nat-multi` (cell 30). Cell 20 is the bare-metal job and is not in the permutation matrix at all, which is why the multi-node NAT cell is numbered past the end rather than filling a gap.
Four nightly cells run it: `nat-single` (cell 19) and `nat-multi` (cell 30), and their Oracle Linux twins (cells 36 and 38). Cell 20 is the bare-metal job and is not in the permutation matrix at all, which is why the multi-node NAT cell is numbered past the end rather than filling a gap.
### `multinode` — behaviour that only exists on more than one node
@@ -50,6 +53,17 @@ Three nightly cells run it: `nat-single` (cell 19), its Oracle Linux twin (cell
**`TestMultinodeDNS` convergence budgets are env-tunable.** `SPINIFEX_MULTINODE_DNS_TOPOLOGY_TIMEOUT` (default `90s`) bounds every node agreeing on the NS topology, `SPINIFEX_MULTINODE_DNS_TIMEOUT` (default `2m`) bounds a record becoming answerable from a guest or a node, and `SPINIFEX_MULTINODE_DNS_POLL` (default `3s`) is the poll interval for both. Values are Go durations; a malformed or non-positive value panics rather than falling back to the default.
**`TestMultinodeOVNRaft` restarts each node's OVN central in turn before it kills the NB leader.**
A `setup-ovn.sh` rerun restarts a node's NB and SB members together, on EL through the one `ovn-northd.service`, so the test does the same on every database node in turn, waits for both members to be back as cluster members under a known leader, and requires an NB write to land after each step and the full quorum with one leader at the end.
**`TestMultinodeIPSec` proves guest traffic is encrypted on the wire, not only that IPsec is configured.**
It first requires every node to carry the OVS certificate, key and CA pointers, `NB_Global.ipsec=true` never asserted over an unconfigured chassis, and an AES-GCM SA in `ip xfrm state`.
None of those fails when SAs exist but traffic bypasses them, so it then launches two instances on distinct nodes and sends 200 pings of 1000 bytes between them.
For each direction of that pair it fails unless the outbound SAs to the peer's `ovn-encap-ip` carried at least the payload sent, an outbound-only capture on the interface routing to the peer saw ESP (raw or UDP/4500-encapsulated), and that capture saw no plaintext Geneve (UDP/6081).
The capture is outbound-only because inbound ESP is decrypted and seen again as Geneve on the same interface, which would read as plaintext on a healthy node.
It proves the node pair the guests landed on; the SA check covers every node.
It skips when the node config sets `network.ipsec_enabled=false`, or when the scheduler places all four launches on one node.
### `kvquorum` — the cluster's own state survives losing a node (multi-node, needs three)
**The control plane keeps its state in JetStream KV buckets, and this is the suite that proves those buckets are actually replicated.** A bucket on one replica lives on one JetStream-chosen server recorded in no config, so losing that node makes it return `nats: no responders available for request` from *every* node at once — a cluster-wide outage caused by a single-node event. Leader leases are the worst case: acquiring one is a write, so a lease bucket without a quorum cannot be acquired by anyone, and every reconciler sharing it stops cluster-wide at exactly the moment a node has failed and there is repair work to do.
@@ -68,6 +82,8 @@ Three entry points, in the order they run:
**Asserted from both sides, and the pair is the point.** `InstanceAutoRecovery` takes a node down and requires the guest to come back on exactly one survivor, identified by the qemu process rather than by what the API says, then brings the node back and requires it to drop the local copy instead of relaunching it. `InstanceRecoveryRefusesStorageFault` freezes predastore cluster-wide first, so the guest is paused by `werror=stop` when its node goes away, and requires that **nothing moves** — a store that refuses every node cannot be fixed by moving a guest, and the relaunch would cost it the request QEMU is holding. A reconciler that cannot tell a host failure from a storage failure passes the first and fails the second. Neither turns recovery on, because there is no setting for it: both wait for every node to report the reconciler active on a cluster nothing configured, so a build that reintroduced a switch fails here.
**`InstanceRecoveryKeepsAnOlderInstanceAndItsDisk` asserts the guest survives with its data, which the first test never did.** Its guest writes a token to its root volume and prints it from the volume at every boot, and it is left running past the pending watchdog's timeout before its node is taken away, because a real instance is always older than a minute. After the survivor relaunches it, the instance must stay running for three watchdog ticks with its root volume present, and the new console must print the token, so the guest booted from the disk it wrote. The node then returns, and the instance is stopped and started once its last launch is older than the timeout again, with the same checks and a later `LaunchTime`. Before ADR-0003:S7, both relaunches carried the original launch time, and the watchdog terminated the instance seconds after QEMU started and deleted its root volume.
**`InstancePartitionRecovery` is the fault this feature exists for, and the only one of the three that produces it rather than approximating it.** It drops NATS on 4222 and 4248 between one node and its peers and nothing else, so that node keeps its services, keeps its QEMU and keeps answering its own local clients while it cannot reach consensus — half healthy, which is what a real network fault looks like and what a service stop cannot imitate. It then requires that the node gives up the volume on a clock it can evaluate without reaching anything, that a survivor takes the guest over, and that the rejoining node forgets its copy rather than relaunching it. **The assertion it exists for is the one no single sample can reach:** a watch samples every node every three seconds for the whole run and fails on any sample naming two hosts, because two guests writing one volume through one object store is data loss already under way rather than a race that resolves. The evidence for *why* the guest stopped is the surrender and the fence in the node's own journal — a guest killed for any other reason satisfies the process count and means the opposite.
**The partition gets its own nft table and a systemd deadman timer, for reasons worth keeping.** The node's real firewall is `inet spinifex_filter`, so a test that flushed or edited it would leave the node unfirewalled with no sign of which rules it lost; a separate table at priority -300 says exactly "these packets, dropped" and heals in one atomic delete. `systemd-run --on-active` removes it independently, so a cancelled workflow or a panicking test does not leave a node cut off from its cluster until somebody notices. It also proves SSH still works and proves a peer port is actually unreachable before asserting anything — a rule set that installed cleanly and dropped nothing would otherwise read as the system tolerating a partition.
@@ -104,9 +120,16 @@ CA installed and downloadable, fingerprint matches config, hostnames present in
Internal and internet-facing ALB and NLB, HTTPS listeners, listener rules, `ModifyListener`, and NLB UDP.
Unhealthy-target routing: an NLB with an HTTP health check on a TCP target group stops sending traffic to a target that fails it while still accepting TCP, sends traffic again once it recovers, and fails open when every target is unhealthy.
An ALB whose targets all fail their health check keeps serving from them instead of answering 503.
`TestInternalALBPrivateSubnet` launches an internal ALB in a private subnet of a non-default VPC with no internet gateway and no public route.
The load balancer must reach active with a healthy target, which needs the owner stamp on its ENI to resolve, and a client inside the VPC must be served by it on every request.
The client reports on its serial console because the runner has no route into the VPC.
### `eks`, `ecs`, `ecr` — container services
`eks`: cluster create/delete, access entries, addon delivery, `GetToken`, kubeconfig artifacts, the EBS CSI volume path, and an IRSA pod wired with a projected SA token. `ecs`: clusters, container instances, the three network modes (`awsvpc`, `bridge`, `host`), task-role credentials fetched from the container credentials endpoint, a service behind an ELB, and ENI release on terminate. `ecr`: control-plane CRUD plus a real OCI data plane — docker login/build/push/pull round trips, multi-layer images, and raw blob HTTP.
`eks`: cluster create/delete, access entries, addon delivery, `GetToken`, kubeconfig artifacts, the EBS CSI volume path, an IRSA web-identity exchange under the stock AWS trust policy (`StringEquals` on the issuer host-path `:sub`/`:aud`, a mismatched `:sub` denied with `AccessDenied`, a `StringLike` `:sub` granted), and an IRSA pod whose spec names only a ServiceAccount with the role-arn annotation, where the pod-identity webhook injects the token volume, `AWS_*` environment, STS endpoint and CA bundle and the pod assumes the role, while a pod of an unannotated ServiceAccount is left untouched. The unmodified aws-load-balancer-controller chart 1.11.0 installs with the values an AWS user passes plus the gateway endpoint override, the Shield and WAF switches and the single-subnet feature gate, and reconciles an Ingress into an active ALB. Worker IMDS is covered too: a managed nodegroup worker reports IMDSv2 required with hop limit 2, a pod with no IRSA resolves to the node role through IMDS, and a nodegroup built from a launch template takes the template's metadata options. Control-plane isolation is covered as well: no control-plane host registers as a Node, a DaemonSet tolerating every taint runs on workers only, metrics-server is Ready, and logs work for every kube-system pod. `ecs`: clusters, container instances, the three network modes (`awsvpc`, `bridge`, `host`), task-role credentials fetched from the container credentials endpoint, a service behind an ELB, and ENI release on terminate. `ecr`: control-plane CRUD plus a real OCI data plane — docker login/build/push/pull round trips, multi-layer images, and raw blob HTTP.
### `rds` — managed databases
@@ -117,7 +140,7 @@ The create/describe/connect path against a live cluster: a DB VM that actually b
- **`storagefault`** — what a guest does when its storage backend stops answering, and what stop/start does when the seal failed. **The claim is about corruption, not availability.** SIGSTOPs a cluster-wide service, so it always runs last and in its own cell. Three-node, because the RS(2,1) shard floor and cross-node takeover after a failed seal do not exist on one node.
- **`partialblock`** — concurrent sub-block writes to the same block surviving a cold reread through NBD from a real guest.
- **`storagegrowth`** — volume growth and image pull. Requires `external_mode=pool`; skips on a nat env, which has no EIP and therefore no guest SSH.
- **`reboot`** — a slow guest waited for and keeping what it wrote, and a wedged guest reset once its budget expires. Proves a real reboot by requiring a *new* boot ID.
- **`reboot`** — a slow guest waited for and keeping what it wrote, and a wedged guest forced off and relaunched once its budget expires. Proves a real reboot by requiring a *new* boot ID.
- **`diskperf`** — guest disk performance gate, pinned fio profile on a dedicated blank volume. Also requires `external_mode=pool` for guest SSH.
### Other
@@ -129,7 +152,6 @@ The create/describe/connect path against a live cluster: a DB VM that actually b
- **`ddil`** — Denied, Disrupted, Intermittent, Limited network scenarios.
- **`teardown`** — sweeps leftovers.
- **`harness`** — shared primitives, not a suite. `harness/vpc_egress.go` carries the VPC/subnet/IGW/NAT-gateway fixtures the egress scenarios build on.
- **`manifest-check`, `manifest-lint`** — manifest validation, not cluster behaviour.
## Keeping this honest

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