Compare commits

..

69 Commits

Author SHA1 Message Date
Ben Duncan 69df9ea9bf feat(ui): console feedback v1 and the admin Nodes view (#1454)
* 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.

* 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.

* fix(ui): open a DNS record for editing from the keyboard

* 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.

* test: cover the node insight handlers and the facts file mode

* feat: report every CPU flag in node facts

* 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.

* chore(ui): rebuild dist

* fix: align the CPU facts fields

* fix: bound the network test server start and read ARM CPU features and cores

* chore(ui): rebuild dist

* test(ui): pin the instances baseline clock and take the screenshots from CI's Playwright image

* fix: retry a network test claim whose holder released it mid-check
2026-11-02 10:23:35 -06:00
Josh Siviour eafad1a37e feat(lambda): start the bootstrap unprivileged with no_new_privs (#1452)
StartBootstrap sets no_new_privs on a dedicated locked OS thread and
starts the bootstrap from it, as uid/gid 993 with no supplementary groups
and only the environment it is given. The thread is never unlocked, so
the flag never reaches the agent's other threads, and no dynamic loader
runs before no_new_privs is set. The test-only agent start uses it
instead of running the bootstrap as root. The normal agent still accepts
no invocations.
2026-11-02 23:37:36 +10:00
Josh Siviour 22cca42bf4 feat(lambda): authenticate the guest's control connection at startup (invocation disabled) (#1451)
* feat(lambda): open the authenticated control connection at guest startup

A normal lambda-agent build now reads its per-generation identity from
fw_cfg, unloads qemu_fw_cfg and refuses to start unless the fw_cfg tree is
gone, then dials the host under the pinned mutual TLS and holds the
connection. It accepts no invocations and never starts the bootstrap: any
data from the host fails closed until the message protocol exists.

The image loads qemu_fw_cfg and the virtio_net chain and configures the
guest interface from opt/spinifex/netcfg, as other system VMs do. The
lambdatestlink build also seals fw_cfg before starting the bootstrap.

* test(lambda): bound startup test waits and report host accept errors
2026-11-02 22:37:36 +10:00
Josh Siviour 814b15097b feat(operator): record ADR-0008:S9 quiescence evidence on the deletion job (#1447)
* feat(iam): record quiescence evidence on the deletion job, with the L09 scope unresolved until the final inventory re-read

* fix(iam): cite ADR-0008:S9 as the basis for the STS consumer
2026-11-02 22:17:30 +10:00
Josh Siviour a541ee312d feat(lambda): add the runtime image, guest agent and authenticated TCP control connection (not wired) (#1448)
* feat(lambda): add the Lambda runtime image and guest agent

* fix(lambda): stop the guest agent after a timed-out invocation

* docs(lambda): declare the L-GUEST executor-to-agent contract

* refactor(vm): remove the unused virtio-serial port builder

Lambda guest control moves to TCP over the microVM's network, so no
caller needs SerialPorts. This reverts d7905107b.

* fix(lambda): drop the serial carrier from guest startup and keep virtio-net

run-agent no longer waits for a carrier device named on the kernel
command line, init loads virtio_net instead of virtio_console, and the
image keeps virtio_net's failover and net_failover dependencies. The
normal agent still refuses to start without an authenticated transport.

* feat(lambda): authenticate the guest's TCP control connection with pinned mutual TLS

hostlink loads the per-generation identity from fw_cfg and dials the host
over TLS 1.3 with ALPN spinifex-lambda-guest/1. VerifyPeer requires exactly
the pinned certificate, validates it, and checks its single URI SAN names
the expected scope and role. The L-GUEST contract now describes this
transport; message framing and fencing stay open, and the normal agent
still refuses to start.

* feat(lambda): add the host end of the guest's TCP mutual TLS connection

guestconn creates fresh host and guest identities for each environment
generation, produces the fw_cfg entries the guest reads, and accepts only
that generation's exact guest certificate. Peers on the untrusted bridge
that fail the handshake are closed and the listener keeps waiting until
its context ends; the cancel callback finishes before the deadline is
cleared so the listener can be reused.

The pinned-certificate check and the L-GUEST vocabulary move to
foundation/pki and contracts/lambda/v1 so both ends share them.

* docs(lambda): describe the shared connection vocabulary in the contract README
2026-11-02 22:09:58 +10:00
Josh Siviour 8e3ccf45c4 feat(eks): give clusters one AWS-style cluster security group (#1445)
* test(eks): specify the AWS-style cluster security group layout

* feat(eks): give clusters one AWS-style cluster security group

Create the eks-cluster-sg-<name>-<id> SG in the customer VPC with the EKS tags and a self rule, report it as clusterSecurityGroupId, launch workers with it (or the launch template's SGs), and attach it to the private-endpoint ENI. New clusters no longer get customer-VPC control-plane and nodegroup SGs; clusters created before keep theirs, and delete finds the new SG by tag.

* fix(eks): own the cluster SG by its recorded id and scope the delete sweep

Workers and teardown use the SG id recorded on the cluster; a create retry only adopts an SG carrying tags the service alone can set. DeleteClusterSGs now revokes every rule only on cluster-owned SGs and, on any other SG in the VPC, only the rules that reference one.

* docs(eks): use the cluster security group in workbooks and e2e

Workbooks read vpc_config[0].cluster_security_group_id and drop the hand
NodePort rules. E2E covers an internet-facing instance-target ALB with no
hand rule, exec and logs, and a bystander security group surviving
DeleteCluster.

* docs(eks): drop stale node-group SG sentence
2026-11-02 21:42:51 +10:00
Josh Siviour b7d9245839 feat(operator): resume account deletions whose holder died (#1444)
* feat(operator): resume account deletions whose holder died

Each awsgw scans the deletion job bucket and takes over RUNNING jobs that
carry an accepted operation, a fence of at least 1 and an expired lease,
through the same fenced takeover a retried request uses. A resumed job runs
on a fresh timeout budget, so recovery no longer depends on the requester.

* chore: trim resumer comments to three lines
2026-11-02 21:07:41 +10:00
Josh Siviour d7905107bd feat(vm): render virtio-serial ports backed by listening unix sockets (#1443) 2026-11-02 20:30:52 +10:00
Josh Siviour a293d4e983 feat(iam): write TERMINATING only under a fenced deletion operation (#1442)
* refactor(cli): delete accounts only through the admin API

* docs: describe account delete as admin API only

* feat(iam): quiesce an account under a fenced deletion operation

* fix(iam): keep the termination capabilities off the shared interface and fence the account removal

* test(awsgw): guard the IAM termination binding on the operator deps

* docs: say which credentials the account delete signs with
2026-11-02 20:23:39 +10:00
Josh Siviour be07a22776 feat(lambda): add unregistered CreateFunction and GetFunction (#1441)
* feat(lambda): add unregistered CreateFunction and GetFunction

Function records live in a daemon-owned per-account KV bucket, keyed by
Region and name, claimed with one conditional create that carries the
function ID and a Pending state. A claim whose code copy never completes
expires, so a later create can take the name over; the original gateway
then fails to mark its code stored. Code copies are never deleted here.

CreateFunction checks the ZIP is readable, refuses an execution role that
does not trust lambda.amazonaws.com (using the STS trust evaluation), and
encrypts environment values in the gateway with the master key, bound to
the account and function ID. GetFunction returns Code.Location as a
10-minute presigned URL for the off-host Predastore address, signed by a
session that outlasts it; with no off-host address it fails instead of
naming an internal one. Lambda uses the REST-JSON error envelope.

The handlers are built in the gateway but not routed: no public Lambda API
is registered until a runtime image exists.

* fix(lambda): return and accept only the default LoggingConfig

Every function configuration now carries LoggingConfig with LogFormat Text
and LogGroup /aws/lambda/<name>. CreateFunction accepts the default when
supplied and refuses a JSON format, another log group or a log level.

Adds an integration test that drives a signed CreateFunction and
GetFunction through the gateway, the NATS record owner and the code store,
then downloads Code.Location and checks the bytes. The STS trust check
moves to stsdomain.RoleTrusts so the test uses the production evaluation.

* fix(lambda): render the stored record in the CreateFunction response

CodeStored now returns the record the owner wrote, and CreateFunction
renders it instead of stamping its own time, so CreatedAt, LastModified
and RevisionId match an immediate GetFunction.

* fix(lambda): match IAM error codes, not messages, in code-store provisioning

IAM errors now carry a message, so comparing err.Error() with the bare
code no longer matched EntityAlreadyExists or NoSuchEntity. Provisioning
then failed on every start after the first and could not adopt its own
role. Resolve the AWS error code instead.

* test(iam): check the resolved NoSuchEntity code in UpdateUser tests

IAM errors now carry a message, so err.Error() no longer equals the bare
code. Assert the resolved AWS error code instead.

* test(iam): account for UpdateUser as named by bare name

UpdateUser addresses its user by bare name, so the cross-tenant ARN table
cannot target another account; list it with the other byName IAM actions.
Add a focused test that a rename stays within the caller's account.
2026-11-02 20:10:00 +10:00
Julian Sommer 8ce3604449 fix(iam): name the missing or conflicting entity in NoSuchEntity, EntityAlreadyExists, DeleteConflict and LimitExceeded messages as AWS does (#1435) 2026-11-02 16:55:04 +11:00
Julian Sommer 50790d416c fix(iam): let RemoveUserFromGroup succeed for an existing user who is not a member, answering NoSuchEntity only for a missing user or group (#1440) 2026-11-02 16:54:47 +11:00
Julian Sommer 70f3543de7 feat(iam): implement UpdateUser so a user can be renamed or moved to a new path (#1439) 2026-11-02 16:54:06 +11:00
Julian Sommer dc830d82b6 fix(iam): return only AWS's role fields from UpdateRoleDescription, without MaxSessionDuration, Tags or RoleLastUsed (#1438) 2026-11-02 16:53:13 +11:00
Josh Siviour 1766fa2c92 feat(lambda): add the Regional Lambda code store (#1437)
Lambda code copies live in a system-account Predastore bucket reached
only through a short-lived session on a Regional role scoped to that
bucket. STS gains an in-process AssumeCodeStoreRole that derives the
Region's code-store role itself and assumes it as lambda.amazonaws.com;
it is not on the STS interface, NATS or HTTPS.

Provisioning creates or verifies the owner-tagged role, inline policy
and bucket on first use only, retries Lambda's own partial create,
and refuses an untagged or foreign resource with an operator-visible
reason. Copies are keyed by their SHA-256, so different bytes can never
replace a recorded revision. The gateway holds the root client and IAM;
the Lambda package receives only the bounded provisioning operations
and the scoped store.
2026-11-02 15:52:13 +10:00
Josh Siviour 18a02a3910 feat(admin): fence the account deletion record with an operation id and lease (#1434)
* test(admin): pin the unfenced deletion job record

* feat(admin): fence the account deletion record with an operation id and lease
2026-11-02 15:01:47 +10:00
Julian Sommer 7ee6a87381 fix(acm): refuse to import a certificate whose NotAfter has passed (#1433) 2026-11-02 15:47:38 +11:00
Julian Sommer 3ac6dd6a25 fix: key reconcile EIP intent by VPC so overlapping tenants keep their public IPs (#1429) 2026-11-02 15:42:27 +11:00
Julian Sommer 5bcef59ded fix: delete only the subnet's own DHCP_Options row in DeleteSubnet, not another VPC's row with the same CIDR (#1430) 2026-11-02 15:42:11 +11:00
Julian Sommer c00dc3eaaf fix: mint a secret ECS task credential ID instead of serving role credentials under the public task ID (#1431) 2026-11-02 15:41:53 +11:00
Julian Sommer 9979e3b6b2 fix: render ALB fixed-response content in single quotes so HAProxy cannot expand environment variables into the response, and validate ContentType and MessageBody at the API (#1423) 2026-11-02 15:40:22 +11:00
Josh Siviour 7b9260f8f7 feat: AWS-format EKS add-on versions, DescribeAddonVersions filters and declared refusal codes (#1426)
* test: pin AWS-format EKS add-on versions

* feat: return EKS add-on versions in AWS format and accept them as input

* test: DescribeAddonVersions query parameters, compatibilities and paging

* feat: honour DescribeAddonVersions filters and report compatibilities

* test: EKS parameter refusals must carry a code the operation declares

* fix: EKS parameter refusals use a code the operation declares

* docs: describe AWS-format EKS add-on versions

Document the v<x.y.z>-eksbuild.<n> form, the bare-form leniency, the
DescribeAddonVersions filters and compatibilities, and how to pin a
version in Terraform. Replace the leave-addon_version-unset guidance.
2026-11-02 14:39:48 +10:00
Julian Sommer 70c4e2e447 fix(ec2): refuse malformed image, instance and volume IDs on Describe instead of answering NotFound (#1425) 2026-11-02 15:29:26 +11: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
1375 changed files with 48739 additions and 15932 deletions
+1 -1
View File
@@ -104,7 +104,7 @@ jobs:
- 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 build-sqs-store-microvm-image build-sqs-worker-microvm-image
run: cd spinifex && make build-microvm-image build-sqs-worker-microvm-image
- name: Build amd64 Tarball
if: steps.version.outputs.exists != 'true'
+20
View File
@@ -1203,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
@@ -1354,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.
+1 -1
View File
@@ -58,7 +58,7 @@ jobs:
cache: false
- name: Build microVM artifacts (lb-agent + kernel + initramfs + qboot + SQS store and worker images)
run: cd spinifex && make build-microvm-image build-sqs-store-microvm-image build-sqs-worker-microvm-image
run: cd spinifex && make build-microvm-image build-sqs-worker-microvm-image
- name: Build amd64 Tarball
env:
+3 -3
View File
@@ -21,12 +21,12 @@ build/microvm/vmlinuz
build/microvm/initramfs.cpio.gz
build/microvm/qboot.rom
build/microvm/qboot.LICENSE
build/microvm/sqs-store/vmlinuz
build/microvm/sqs-store/initramfs.cpio.gz
build/microvm/sqs-store/.stage.*/
build/microvm/sqs-worker/vmlinuz
build/microvm/sqs-worker/initramfs.cpio.gz
build/microvm/sqs-worker/.stage.*/
build/microvm/lambda-runtime/vmlinuz
build/microvm/lambda-runtime/initramfs.cpio.gz
build/microvm/lambda-runtime/.stage.*/
build/qboot/
!spinifex/runtime/roles/spinifexui/frontend/dist/
tmp/
+30 -28
View File
@@ -153,37 +153,46 @@ $(MICROVM_ARTIFACTS) &: $(MICROVM_INPUTS)
bin/lb-agent:
$(MAKE) build-lb-agent
# The SQS store microVM image: a separate artifact from the LB image above, not
# installed or released by any target yet.
SQS_STORE_MICROVM_OUT_DIR := $(MICROVM_OUT_DIR)/sqs-store
SQS_STORE_MICROVM_ARTIFACTS := $(SQS_STORE_MICROVM_OUT_DIR)/vmlinuz $(SQS_STORE_MICROVM_OUT_DIR)/initramfs.cpio.gz
SQS_STORE_MICROVM_INPUTS := scripts/build-sqs-store-microvm-image.sh scripts/lib/sqs-store-microvm-publish.sh $(SQS_STORE_MICROVM_OUT_DIR)/init.sh $(SQS_STORE_MICROVM_OUT_DIR)/inittab $(addprefix $(SQS_STORE_MICROVM_OUT_DIR)/,sqs-store-lib.sh sqs-store-prepare sqs-store-run sqs-store-stop) bin/nats-server
$(SQS_STORE_MICROVM_ARTIFACTS) &: $(SQS_STORE_MICROVM_INPUTS)
./scripts/build-sqs-store-microvm-image.sh
# The dedicated SQS store server, built from the go.mod-pinned module so the
# image ships exactly the reviewed version. Static for the musl guest.
# 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
build-sqs-store-microvm-image: $(SQS_STORE_MICROVM_ARTIFACTS) ## Build the SQS store microVM kernel and initramfs
# 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 shared SQS worker microVM image: the store image's /init and kernel with
# the worker binary instead of nats-server. Not installed or released yet.
# 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_STORE_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-ready sqs-worker-stop) bin/sqs-worker
SQS_WORKER_MICROVM_INPUTS := scripts/build-sqs-store-microvm-image.sh scripts/lib/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)
SQS_MICROVM_PROFILE=worker ./scripts/build-sqs-store-microvm-image.sh
./scripts/build-sqs-store-microvm-image.sh
bin/sqs-worker: go.mod go.sum $(shell find cmd/sqs-worker spinifex/agents/sqs -name '*.go' ! -name '*_test.go')
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 shared SQS worker microVM kernel and initramfs
build-sqs-worker-microvm-image: $(SQS_WORKER_MICROVM_ARTIFACTS) ## Build the SQS microVM kernel and initramfs
# The Lambda runtime microVM image. Built on demand; not shipped or installed.
LAMBDA_RUNTIME_MICROVM_OUT_DIR := $(MICROVM_OUT_DIR)/lambda-runtime
LAMBDA_RUNTIME_MICROVM_ARTIFACTS := $(LAMBDA_RUNTIME_MICROVM_OUT_DIR)/vmlinuz $(LAMBDA_RUNTIME_MICROVM_OUT_DIR)/initramfs.cpio.gz
LAMBDA_RUNTIME_MICROVM_INPUTS := scripts/build-lambda-runtime-microvm-image.sh scripts/lib/microvm-image.sh $(addprefix $(LAMBDA_RUNTIME_MICROVM_OUT_DIR)/,init.sh inittab run-agent) bin/lambda-agent
$(LAMBDA_RUNTIME_MICROVM_ARTIFACTS) &: $(LAMBDA_RUNTIME_MICROVM_INPUTS)
./scripts/build-lambda-runtime-microvm-image.sh
bin/lambda-agent: go.mod go.sum $$(call go_local_srcs,./cmd/lambda-agent)
@mkdir -p bin
GOWORK=off CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags '-s -w' -o $@ ./cmd/lambda-agent
build-lambda-runtime-microvm-image: $(LAMBDA_RUNTIME_MICROVM_ARTIFACTS) ## Build the Lambda runtime microVM kernel and initramfs (not shipped)
# qboot is the microvm firmware. The source tarball ships with each release
# because qboot is GPL-2.0.
@@ -196,13 +205,7 @@ $(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-store-microvm: $(SQS_STORE_MICROVM_ARTIFACTS) ## Install the SQS store microVM image to /usr/share/spinifex/microvm/sqs-store/
sudo install -d /usr/share/spinifex/microvm/sqs-store
sudo rm -f /usr/share/spinifex/microvm/sqs-store/initramfs.cpio.gz
sudo install -m 0644 $(SQS_STORE_MICROVM_OUT_DIR)/vmlinuz /usr/share/spinifex/microvm/sqs-store/vmlinuz
sudo install -m 0644 $(SQS_STORE_MICROVM_OUT_DIR)/initramfs.cpio.gz /usr/share/spinifex/microvm/sqs-store/initramfs.cpio.gz
install-sqs-worker-microvm: $(SQS_WORKER_MICROVM_ARTIFACTS) ## Install the SQS worker microVM image to /usr/share/spinifex/microvm/sqs-worker/
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
@@ -322,7 +325,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.
@@ -514,7 +517,6 @@ 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_STORE_MICROVM_OUT_DIR) dist/amd64
./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
@@ -534,7 +536,7 @@ distro-arm64:
distro-clean:
rm -rf dist/
.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-store-microvm-image build-sqs-worker-microvm-image install-microvm install-sqs-store-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 \
.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 build-lambda-runtime-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 \
+1
View File
@@ -38,6 +38,7 @@ var allowlist = []allowance{
{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"},
+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
+68
View File
@@ -0,0 +1,68 @@
#!/bin/sh
mount -t proc proc /proc
mount -t sysfs sys /sys
mount -t devtmpfs dev /dev
# modprobe needs a usable modules.dep; insmod by explicit path does not, 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
}
# lambda-agent reads its identity from fw_cfg and unloads qemu_fw_cfg before
# anything else runs. virtio_net needs net_failover and failover, and the
# insmod fallback resolves no dependencies, so load the chain bottom-up.
load_mod qemu_fw_cfg || echo "[init] qemu_fw_cfg did not load" >/dev/console
load_mod failover || true
load_mod net_failover || true
load_mod virtio_net || echo "[init] virtio_net did not load" >/dev/console
ip link set lo up
# The guest's addresses come from the same netcfg entry as other system VMs.
NETCFG=/sys/firmware/qemu_fw_cfg/by_name/opt/spinifex/netcfg/raw
i=0
while [ "$i" -lt 25 ] && [ ! -f "$NETCFG" ]; do
sleep 0.04
i=$((i + 1))
done
if [ -f "$NETCFG" ]; then
# shellcheck source=/dev/null
. "$NETCFG"
for n in 0 1 2 3 4 5; do
eval mac=\$NIC${n}_MAC
[ -z "$mac" ] && continue
iface=""
for _ 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] no interface for MAC $mac (NIC$n)" >/dev/console
continue
fi
cidr="" gw="" default="" rdst="" rvia=""
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
else
# lambda-agent then fails to reach the host and the guest powers off.
echo "[init] fw_cfg netcfg not available" >/dev/console
fi
exec /sbin/init
@@ -2,9 +2,5 @@
# (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-store/sqs-store-prepare
::respawn:/usr/local/lib/sqs-store/sqs-store-run
::once:/usr/local/lib/lambda-runtime/run-agent
::ctrlaltdel:/sbin/reboot
# One action: the store stops and its disk unmounts first, and the generic
# umount -a -r runs only if the store is gone.
::shutdown:/usr/local/lib/sqs-store/sqs-store-stop
+6
View File
@@ -0,0 +1,6 @@
#!/bin/sh
# Runs the Lambda guest agent, then powers the guest off whatever its status.
/usr/local/bin/lambda-agent >/dev/console 2>&1
status=$?
echo "[run-agent] lambda-agent exited with status $status" >/dev/console
poweroff -f
-224
View File
@@ -1,224 +0,0 @@
# sqs-store-lib.sh — shared by the SQS store guest's prepare, run and stop
# steps. Sourced, never executed. Every path and command is overridable so the
# host test can drive the same code against a fake fw_cfg, sysfs and tools.
#
# Guest contract, read from fw_cfg under opt/spinifex/sqs-store/:
# data-serial virtio-blk serial of the already-formatted ext4 data disk
# listen-addr IPv4 address on a configured guest NIC; the only listener
# client-nkey NATS user NKey public key allowed to connect; never a seed
# Anything absent, malformed or ambiguous refuses the store. Nothing formats.
SQS_FWCFG=${SQS_FWCFG:-/sys/firmware/qemu_fw_cfg/by_name/opt/spinifex/sqs-store}
SQS_SYSBLOCK=${SQS_SYSBLOCK:-/sys/block}
SQS_DEVDIR=${SQS_DEVDIR:-/dev}
# The host test cannot create block nodes, so it relaxes this to -e.
SQS_NODE_TEST=${SQS_NODE_TEST:--b}
SQS_RUN=${SQS_RUN:-/run/sqs-store}
SQS_DATA=${SQS_DATA:-/data}
SQS_CONSOLE=${SQS_CONSOLE:-/dev/console}
SQS_MOUNTS=${SQS_MOUNTS:-/proc/mounts}
SQS_PORT=${SQS_PORT:-4222}
SQS_STOP_BUDGET_TENTHS=${SQS_STOP_BUDGET_TENTHS:-120}
NATS_BIN=${NATS_BIN:-/usr/local/bin/nats-server}
E2FSCK=${E2FSCK:-e2fsck}
MOUNT=${MOUNT:-mount}
UMOUNT=${UMOUNT:-umount}
IP=${IP:-ip}
sqs_log() { echo "[sqs-store] $*" >> "$SQS_CONSOLE"; }
sqs_fatal() { sqs_log "FATAL: $*"; return 1; }
# sqs_fwcfg <name>: print one fw_cfg value, refusing a missing, empty, oversized
# or multi-token file. The value is not echoed on failure.
sqs_fwcfg() {
f="$SQS_FWCFG/$1/raw"
[ -f "$f" ] || { sqs_fatal "fw_cfg $1 not supplied"; return 1; }
[ "$(wc -c < "$f")" -le 128 ] || { sqs_fatal "fw_cfg $1 is oversized"; return 1; }
v=$(cat "$f")
case "$v" in
"" | *[[:space:]]*) sqs_fatal "fw_cfg $1 must be exactly one value"; return 1 ;;
esac
printf '%s' "$v"
}
# A volume serial is the volume ID with dashes stripped: vol + 17 hex.
sqs_valid_serial() {
[ "${#1}" -eq 20 ] || return 1
case "$1" in vol*) ;; *) return 1 ;; esac
case "${1#vol}" in *[!0-9a-f]*) return 1 ;; esac
}
# A NATS user public NKey: "U" then 55 base32 characters. The checksum is
# left to the pinned server's own config test.
sqs_valid_user_nkey() {
[ "${#1}" -eq 56 ] || return 1
case "$1" in U*) ;; *) return 1 ;; esac
case "$1" in *[!A-Z2-7]*) return 1 ;; esac
}
sqs_valid_ipv4() {
case "$1" in *[!0-9.]* | .* | *. | *..*) return 1 ;; esac
old_ifs=$IFS; IFS=.
# shellcheck disable=SC2086 # split on dots on purpose
set -- $1
IFS=$old_ifs
[ "$#" -eq 4 ] || return 1
for o in "$@"; do
[ "${#o}" -le 3 ] && [ "$o" -le 255 ] || return 1
case "$o" in 0?*) return 1 ;; esac
done
}
# sqs_find_disk <serial>: print the /dev node of the one block device whose
# sysfs serial matches exactly. Zero or several matches refuse.
sqs_find_disk() {
found="" n=0
for b in "$SQS_SYSBLOCK"/*; do
[ -r "$b/serial" ] || continue
[ "$(cat "$b/serial")" = "$1" ] || continue
found=${b##*/}
n=$((n + 1))
done
[ "$n" -eq 1 ] || { sqs_fatal "expected one disk with serial $1, found $n"; return 1; }
test "$SQS_NODE_TEST" "$SQS_DEVDIR/$found" || { sqs_fatal "disk $found has no block device node"; return 1; }
printf '%s' "$SQS_DEVDIR/$found"
}
# sqs_has_addr <ipv4>: the address is assigned to a non-loopback interface.
sqs_has_addr() {
"$IP" -o -4 addr show 2>/dev/null \
| awk -v a="$1" '$2 != "lo" { split($4, p, "/"); if (p[1] == a) f = 1 } END { exit !f }'
}
# sqs_alive <pid>: running or stopped, not exited (a zombie counts as exited).
sqs_alive() { [ -r "/proc/$1/status" ] && ! grep -q '^State:[[:space:]]*Z' "/proc/$1/status" 2>/dev/null; }
# sqs_starttime <pid>: kernel start time (stat field 22). It survives exec, and
# pairing it with the pid reduces PID-reuse risk; without a pidfd the check and
# the signal stay separate steps, so it is not an absolute guarantee.
sqs_starttime() { sed 's/.*) //' "/proc/$1/stat" 2>/dev/null | cut -d' ' -f20; }
# sqs_same <pid> <starttime>: the recorded process is still alive.
sqs_same() { [ -n "$2" ] && sqs_alive "$1" && [ "$(sqs_starttime "$1")" = "$2" ]; }
# sqs_write_config <addr> <nkey> <file>: the dedicated server's config. Its
# max_payload admits a 1 MiB SQS message with its headers (ADR-0011:S4); the
# 1MB NATS default does not.
sqs_write_config() {
cat > "$3" <<EOF
listen: "$1:$SQS_PORT"
server_name: "sqs-store"
max_payload: 2MiB
jetstream {
store_dir: "$SQS_DATA"
sync_interval: always
}
authorization {
users = [ { nkey: "$2" } ]
}
EOF
}
# sqs_prepare: validate the contract, check and mount the data disk, write the
# server config and mark the store ready. Returns non-zero, unready, on any refusal.
sqs_prepare() {
rm -f "$SQS_RUN/ready"
mkdir -p "$SQS_RUN"
serial=$(sqs_fwcfg data-serial) || return 1
sqs_valid_serial "$serial" || { sqs_fatal "data-serial is not a volume serial"; return 1; }
addr=$(sqs_fwcfg listen-addr) || return 1
sqs_valid_ipv4 "$addr" || { sqs_fatal "listen-addr is not an IPv4 address"; return 1; }
nkey=$(sqs_fwcfg client-nkey) || return 1
sqs_valid_user_nkey "$nkey" || { sqs_fatal "client-nkey is not a user public NKey"; return 1; }
sqs_has_addr "$addr" || { sqs_fatal "listen-addr $addr is not on any guest NIC"; return 1; }
# The server checks the config, including the NKey checksum, before the
# disk is touched.
umask 077
sqs_write_config "$addr" "$nkey" "$SQS_RUN/nats.conf" || return 1
if ! "$NATS_BIN" -t -c "$SQS_RUN/nats.conf" > "$SQS_RUN/config-test.out" 2>&1; then
sqs_fatal "server config test failed: $(sed 's/U[A-Z2-7]\{55\}/<nkey>/g' "$SQS_RUN/config-test.out" | tr '\n' ' ')"
return 1
fi
dev=$(sqs_find_disk "$serial") || return 1
rc=0
"$E2FSCK" -p "$dev" > "$SQS_RUN/fsck.out" 2>&1 || rc=$?
sqs_log "e2fsck -p $dev rc=$rc"
# 0 clean, 1 corrected: proceed. Anything higher, including a disk that is
# not ext4 at all, refuses; the guest never formats.
[ "$rc" -le 1 ] || { sqs_fatal "e2fsck refused $dev: $(tr '\n' ' ' < "$SQS_RUN/fsck.out")"; return 1; }
mkdir -p "$SQS_DATA"
"$MOUNT" -t ext4 -o nodev,nosuid,noexec "$dev" "$SQS_DATA" || { sqs_fatal "mount $dev failed"; return 1; }
: > "$SQS_RUN/ready"
sqs_log "prepared: $dev mounted at $SQS_DATA, config written for $addr:$SQS_PORT; server not started yet"
}
# sqs_run: the respawned supervisor body. Its identity is recorded before the
# stopping check: a stop that misses it set stopping first, so this parks; a
# stop that reads it signals this process whether or not it has exec'd yet.
sqs_run() {
mkdir -p "$SQS_RUN"
echo "$$ $(sqs_starttime $$)" > "$SQS_RUN/pid"
if [ -e "$SQS_RUN/stopping" ]; then
sqs_log "run: shutdown in progress, not starting"
return 2
fi
if [ ! -e "$SQS_RUN/ready" ]; then
sqs_log "run: store not prepared, not starting"
return 2
fi
sqs_log "run: starting pid=$$"
# ADR-0011:S11 bounds the store's Go heap; the guest must be larger.
GOMEMLIMIT=512MiB exec "$NATS_BIN" -c "$SQS_RUN/nats.conf"
}
# sqs_stop: first shutdown action. Block any respawn, SIGTERM the recorded run
# process (a server, or a start not yet exec'd), SIGKILL only after the budget,
# then sync and unmount. Each signal rechecks the identity; a survivor keeps the disk mounted.
sqs_stop() {
mkdir -p "$SQS_RUN"
: > "$SQS_RUN/stopping"
sqs_log "stop: begin"
rec=$(cat "$SQS_RUN/pid" 2>/dev/null || true)
pid=${rec%% *} st=${rec#* }
[ "$st" != "$rec" ] || st=""
if [ -n "$pid" ] && sqs_same "$pid" "$st"; then
kill -TERM "$pid" 2>/dev/null || true
sqs_log "stop: SIGTERM sent to run pid=$pid"
i=0
while sqs_same "$pid" "$st" && [ "$i" -lt "$SQS_STOP_BUDGET_TENTHS" ]; do sleep 0.1; i=$((i + 1)); done
if sqs_same "$pid" "$st"; then
sqs_log "stop: run pid=$pid still running after $((SQS_STOP_BUDGET_TENTHS / 10))s, SIGKILL"
kill -KILL "$pid" 2>/dev/null || true
j=0
while sqs_same "$pid" "$st" && [ "$j" -lt 30 ]; do sleep 0.1; j=$((j + 1)); done
fi
if sqs_same "$pid" "$st"; then
sqs_log "stop: run pid=$pid STILL ALIVE, leaving $SQS_DATA mounted"
return 1
fi
sqs_log "stop: store exited after $((i / 10)).$((i % 10))s"
else
sqs_log "stop: no running store"
fi
sync
if grep -q " $SQS_DATA " "$SQS_MOUNTS" 2>/dev/null; then
rc=0
"$UMOUNT" "$SQS_DATA" || rc=$?
sqs_log "stop: umount $SQS_DATA rc=$rc"
fi
}
# sqs_shutdown: the only shutdown action. The generic read-only remount runs
# only after the store stopped; a surviving writer keeps every mount as it is.
sqs_shutdown() {
if ! sqs_stop; then
sync
sqs_log "shutdown: store survived, skipping umount -a -r"
return 1
fi
rc=0
"$UMOUNT" -a -r || rc=$?
echo "[init] shutdown: umount -a -r rc=$rc" >> "$SQS_CONSOLE"
}
@@ -1,9 +0,0 @@
#!/bin/sh
# sysinit: load the disk drivers, then validate the guest contract, check and
# mount the data disk and write the server config. A refusal leaves the store
# unprepared, so the run step never starts it.
. /usr/local/lib/sqs-store/sqs-store-lib.sh
modprobe virtio_blk 2>/dev/null || true
modprobe ext4 2>/dev/null || true
sqs_prepare || sqs_log "store refused; it will not start"
exit 0
-6
View File
@@ -1,6 +0,0 @@
#!/bin/sh
# Respawned by init. A store that must not run parks here instead of exiting,
# so init does not respawn it in a tight loop.
. /usr/local/lib/sqs-store/sqs-store-lib.sh
sqs_run
exec sleep 2147483647
-5
View File
@@ -1,5 +0,0 @@
#!/bin/sh
# The only shutdown action: stop the store and unmount its disk, then run the
# generic read-only remount only if the store is gone.
. /usr/local/lib/sqs-store/sqs-store-lib.sh
sqs_shutdown
@@ -33,6 +33,10 @@ 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
@@ -87,8 +91,8 @@ for n in 0 1 2 3 4 5; do
[ -n "$rdst" ] && [ -n "$rvia" ] && ip route add "$rdst" via "$rvia" dev "$iface"
done
# The store image takes no lb-agent environment. A CA bundle is optional here:
# nothing in this image dials out yet.
# 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
+1
View File
@@ -3,6 +3,7 @@
# 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
+136 -48
View File
@@ -1,19 +1,23 @@
# sqs-worker-lib.sh — shared by the SQS worker guest's readiness and stop
# 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.
#
# store-ready exists only while the running watcher's last check passed. A
# check proves the store accepted the worker key, not the store server's
# identity, and a store failure is noticed up to one interval plus call time late.
# 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_INTERVAL=${SQSW_PARK_INTERVAL:-30}
SQSW_PARK=${SQSW_PARK:-sleep 2147483647}
SQSW_BOOT_RETRY=${SQSW_BOOT_RETRY:-30}
UMOUNT=${UMOUNT:-umount}
sqsw_log() { echo "[sqs-worker] $*" >> "$SQSW_CONSOLE"; }
@@ -28,28 +32,102 @@ 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. Its
# identity is recorded before the stopping check, as the store's run step does.
# crashed could not remove its marker, so every start removes it first.
sqsw_run() {
mkdir -p "$SQSW_RUN"
echo "$$ $(sqsw_starttime $$)" > "$SQSW_RUN/ready.pid"
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
# Returning here would make init respawn and log at once, forever. Park
# instead, logging once: shutdown stops this recorded process, and a key
# that appears later lets the watcher start.
if [ -e "$SQSW_RUN/stopping" ] || [ ! -s "$SQSW_RUN/public-key" ]; then
if [ -e "$SQSW_RUN/stopping" ]; then
sqsw_log "ready: shutdown in progress, not starting"
else
sqsw_log "ready: no worker key; readiness not started, waiting"
fi
while [ -e "$SQSW_RUN/stopping" ] || [ ! -s "$SQSW_RUN/public-key" ]; do
sleep "$SQSW_PARK_INTERVAL"
done
[ ! -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.
@@ -62,37 +140,47 @@ sqsw_run() {
exec "$SQSW_BIN" ready >> "$SQSW_CONSOLE" 2>&1
}
# sqsw_stop: block any respawn, drop the marker, SIGTERM the recorded watcher,
# SIGKILL only after the budget, and unmount the worker disk only once the
# watcher is confirmed gone. A survivor keeps the disk mounted.
# 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"
rec=$(cat "$SQSW_RUN/ready.pid" 2>/dev/null || true)
pid=${rec%% *} st=${rec#* }
[ "$st" != "$rec" ] || st=""
if [ -n "$pid" ] && sqsw_same "$pid" "$st"; then
kill -TERM "$pid" 2>/dev/null || true
sqsw_log "stop: SIGTERM sent to readiness watcher 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: readiness watcher 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
rm -f "$SQSW_RUN/store-ready"
sqsw_log "stop: readiness watcher pid=$pid STILL ALIVE, leaving $SQSW_DATA mounted"
return 1
fi
sqsw_log "stop: readiness watcher pid=$pid exited"
else
sqsw_log "stop: no running readiness watcher"
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" "$SQSW_RUN/ready.pid"
rm -f "$SQSW_RUN/store-ready"
sync
if grep -q " $SQSW_DATA " "$SQSW_MOUNTS" 2>/dev/null; then
rc=0
@@ -102,11 +190,11 @@ sqsw_stop() {
}
# sqsw_shutdown: the only shutdown action. The generic read-only remount runs
# only after the watcher stopped; a survivor keeps every mount as it is.
# only after both processes stopped; a survivor keeps every mount as it is.
sqsw_shutdown() {
if ! sqsw_stop; then
sync
sqsw_log "shutdown: readiness watcher survived, skipping umount -a -r"
sqsw_log "shutdown: a guest process survived, skipping umount -a -r"
return 1
fi
rc=0
+5 -4
View File
@@ -1,8 +1,9 @@
#!/bin/sh
# sysinit: load the disk drivers, then select, check and mount the worker disk
# and load or create the worker's store key on it. Any refusal is logged; the
# key is never read or created on a disk that is not proven mounted.
# 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
/usr/local/bin/sqs-worker boot >> /dev/console 2>&1 || echo "[sqs-worker] boot refused" >> /dev/console
mkdir -p "$SQSW_RUN"
rm -f "$SQSW_RUN/receipt-ready" "$SQSW_RUN/store.conf" "$SQSW_RUN/boot-refused"
exit 0
+2 -3
View File
@@ -1,6 +1,5 @@
#!/bin/sh
# Respawned by init. A watcher that must not run parks here instead of exiting,
# so init does not respawn it in a tight loop.
# 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
exec sleep 2147483647
+2 -2
View File
@@ -1,5 +1,5 @@
#!/bin/sh
# The only shutdown action: stop the readiness watcher and unmount the worker
# disk, then run the generic read-only remount only if the watcher is gone.
# 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
+2
View File
@@ -17,6 +17,7 @@ import (
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"
@@ -66,6 +67,7 @@ func compareAll() ([]awsmodel.OperationCoverage, error) {
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
+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)
}
}
+24
View File
@@ -0,0 +1,24 @@
// lambda-agent runs inside a Lambda guest.
//
// A normal build reads its per-generation identity from fw_cfg, unloads
// fw_cfg so nothing started later can read the guest key, and opens the
// authenticated control connection to the host. It accepts no invocations and
// never starts the function's bootstrap: the message protocol and grant
// fencing do not exist yet. The agent never listens for the host.
//
// A lambdatestlink build instead relays invocations over an unauthenticated
// test carrier for local proofs; it is not the guest protocol.
package main
import (
"context"
"flag"
"os"
)
func main() { os.Exit(run()) }
func run() int {
flag.Parse()
return start(context.Background())
}
+84
View File
@@ -0,0 +1,84 @@
//go:build !lambdatestlink
package main
import (
"context"
"crypto/tls"
"errors"
"fmt"
"io"
"log/slog"
"github.com/mulgadc/spinifex/spinifex/agents/lambda/hostlink"
)
// Exit statuses: the guest refused to start, the control connection failed,
// or the host closed it.
const (
exitRefused = 2
exitLink = 1
exitClosed = 0
)
// errNoProtocol is returned when the host sends anything: this build defines no
// message protocol, so it accepts no work.
var errNoProtocol = errors.New("host sent data, but this build accepts no invocations")
type startup struct {
fwCfgDir string
fwCfgRoot string
unload func() error
}
func start(ctx context.Context) int {
s := startup{fwCfgDir: hostlink.FwCfgDir, fwCfgRoot: hostlink.FwCfgRoot, unload: hostlink.UnloadFwCfg}
code, err := s.run(ctx)
if err != nil {
slog.Error("lambda-agent stopping", "exit", code, "err", err)
}
return code
}
// run loads the identity, seals fw_cfg whether or not the load succeeded,
// then authenticates to the host and holds the connection until it closes.
func (s startup) run(ctx context.Context) (int, error) {
id, loadErr := hostlink.LoadIdentity(s.fwCfgDir)
if err := hostlink.SealFwCfg(s.fwCfgRoot, s.unload); err != nil {
return exitRefused, errors.Join(loadErr, err)
}
if loadErr != nil {
return exitRefused, loadErr
}
conn, err := hostlink.Dial(ctx, id)
if err != nil {
if ctx.Err() != nil {
err = fmt.Errorf("%w: %w", err, context.Cause(ctx))
}
return exitLink, err
}
defer conn.Close()
slog.Info("control connection open; invocations are not enabled in this build", "host", id.HostAddr)
if err := awaitHostClose(ctx, conn); err != nil {
return exitLink, err
}
return exitClosed, nil
}
// awaitHostClose reads until the host closes the connection. Under TLS 1.3 a
// host that rejects the guest certificate is only seen here, on first read.
func awaitHostClose(ctx context.Context, conn *tls.Conn) error {
stop := context.AfterFunc(ctx, func() { _ = conn.Close() })
defer stop()
n, err := conn.Read(make([]byte, 1))
switch {
case n > 0:
return errNoProtocol
case errors.Is(err, io.EOF):
return nil
case ctx.Err() != nil:
return fmt.Errorf("control connection: %w", context.Cause(ctx))
default:
return fmt.Errorf("control connection: %w", err)
}
}
+256
View File
@@ -0,0 +1,256 @@
//go:build !lambdatestlink
package main
import (
"context"
"crypto/tls"
"errors"
"net"
"os"
"path/filepath"
"testing"
"time"
lambdav1 "github.com/mulgadc/spinifex/contracts/lambda/v1"
"github.com/mulgadc/spinifex/spinifex/runtime/compute/lambda/guestconn"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const scope lambdav1.Scope = "ap-southeast-2/000000000001/fn-1/env-1/gen-1"
// guest is a fake fw_cfg tree; unload removes it as unloading the module would.
type guest struct {
root string
unloads int
sealedAt chan struct{}
}
func newGuest(t *testing.T, entries map[string][]byte) *guest {
t.Helper()
g := &guest{root: filepath.Join(t.TempDir(), "qemu_fw_cfg"), sealedAt: make(chan struct{}, 1)}
for name, b := range entries {
dir := filepath.Join(g.root, "by_name", lambdav1.FwCfgPrefix, name)
require.NoError(t, os.MkdirAll(dir, 0o700))
require.NoError(t, os.WriteFile(filepath.Join(dir, "raw"), b, 0o600))
}
return g
}
func (g *guest) startup() startup {
return startup{
fwCfgDir: filepath.Join(g.root, "by_name", lambdav1.FwCfgPrefix),
fwCfgRoot: g.root,
unload: func() error {
g.unloads++
g.sealedAt <- struct{}{}
return os.RemoveAll(g.root)
},
}
}
type host struct {
ln *net.TCPListener
gen *guestconn.Generation
conn chan accepted
}
func newHost(t *testing.T) *host {
t.Helper()
ln, err := net.ListenTCP("tcp", &net.TCPAddr{IP: net.IPv4(127, 0, 0, 1)})
require.NoError(t, err)
t.Cleanup(func() { ln.Close() })
gen, err := guestconn.NewGeneration(scope, time.Hour)
require.NoError(t, err)
return &host{ln: ln, gen: gen, conn: make(chan accepted, 1)}
}
func (h *host) entries() map[string][]byte { return h.gen.GuestEntries(h.ln.Addr().String()) }
type accepted struct {
conn *tls.Conn
err error
}
// accept runs the host end until ctx ends and reports what it accepted.
func (h *host) accept(ctx context.Context) {
go func() {
c, err := h.gen.AcceptGuest(ctx, h.ln, 2*time.Second)
h.conn <- accepted{c, err}
}()
}
// guest waits for the host to authenticate the guest.
func (h *host) guest(t *testing.T) *tls.Conn {
t.Helper()
a := wait(t, h.conn, "host to authenticate the guest")
require.NoError(t, a.err)
return a.conn
}
// wait bounds every test wait so a stalled host or startup fails promptly.
func wait[T any](t *testing.T, ch <-chan T, what string) T {
t.Helper()
select {
case v := <-ch:
return v
case <-time.After(10 * time.Second):
t.Fatalf("timed out waiting for %s", what)
panic("unreachable")
}
}
type result struct {
code int
err error
}
func runAsync(ctx context.Context, s startup) <-chan result {
out := make(chan result, 1)
go func() {
code, err := s.run(ctx)
out <- result{code, err}
}()
return out
}
func TestStartupAuthenticatesAfterSealingFwCfg(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
h := newHost(t)
h.accept(ctx)
g := newGuest(t, h.entries())
done := runAsync(ctx, g.startup())
conn := h.guest(t)
select {
case <-g.sealedAt:
default:
t.Fatal("guest dialled before fw_cfg was sealed")
}
_, err := os.Stat(g.root)
assert.ErrorIs(t, err, os.ErrNotExist, "fw_cfg still readable while connected")
require.NoError(t, conn.Close())
res := wait(t, done, "startup to return")
assert.Equal(t, exitClosed, res.code)
assert.NoError(t, res.err)
assert.Equal(t, 1, g.unloads)
}
func TestStartupRefusesHostData(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
h := newHost(t)
h.accept(ctx)
done := runAsync(ctx, newGuest(t, h.entries()).startup())
conn := h.guest(t)
defer conn.Close()
_, err := conn.Write([]byte{0})
require.NoError(t, err)
res := wait(t, done, "startup to return")
assert.Equal(t, exitLink, res.code)
assert.ErrorIs(t, res.err, errNoProtocol)
}
// The host pins another generation's guest; the guest learns of the refusal
// on its first read and fails with a link error, never a clean close.
func TestStartupFailsWhenHostRefusesGuest(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
h := newHost(t)
h.accept(ctx)
other, err := guestconn.NewGeneration(scope, time.Hour)
require.NoError(t, err)
entries := h.entries()
wrong := other.GuestEntries(h.ln.Addr().String())
entries[lambdav1.EntryGuestKey], entries[lambdav1.EntryGuestCert] = wrong[lambdav1.EntryGuestKey], wrong[lambdav1.EntryGuestCert]
g := newGuest(t, entries)
res := wait(t, runAsync(ctx, g.startup()), "startup to return")
assert.Equal(t, exitLink, res.code)
require.Error(t, res.err)
assert.Contains(t, res.err.Error(), "bad certificate")
assert.Equal(t, 1, g.unloads)
select {
case a := <-h.conn:
require.Error(t, a.err, "host accepted a guest it does not pin")
default:
}
}
func TestStartupRefusesWithoutDialling(t *testing.T) {
h := newHost(t)
cases := map[string]struct {
entries map[string][]byte
unload func(g *guest) func() error
want string
}{
"missing entry": {
entries: func() map[string][]byte { e := h.entries(); delete(e, lambdav1.EntryHostCert); return e }(),
want: "read host-cert",
},
"fw_cfg stays readable": {
entries: h.entries(),
unload: func(*guest) func() error { return func() error { return errors.New("module is built in") } },
want: "fw_cfg still readable",
},
"unload claims success but tree remains": {
entries: h.entries(),
unload: func(*guest) func() error { return func() error { return nil } },
want: "unload reported success",
},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
g := newGuest(t, tc.entries)
s := g.startup()
if tc.unload != nil {
s.unload = tc.unload(g)
}
code, err := s.run(context.Background())
assert.Equal(t, exitRefused, code)
assert.ErrorContains(t, err, tc.want)
})
}
// Nothing dialled the host in any case above.
require.NoError(t, h.ln.SetDeadline(time.Now().Add(50*time.Millisecond)))
_, err := h.ln.Accept()
var ne net.Error
require.ErrorAs(t, err, &ne)
assert.True(t, ne.Timeout())
}
// A missing entry still seals fw_cfg: the key may be present even when
// another entry is not.
func TestStartupSealsEvenWhenIdentityIsIncomplete(t *testing.T) {
h := newHost(t)
e := h.entries()
delete(e, lambdav1.EntryHostAddr)
g := newGuest(t, e)
code, err := g.startup().run(context.Background())
assert.Equal(t, exitRefused, code)
assert.ErrorContains(t, err, "read host-addr")
assert.Equal(t, 1, g.unloads)
_, statErr := os.Stat(g.root)
assert.ErrorIs(t, statErr, os.ErrNotExist)
}
func TestStartupStopsWithContext(t *testing.T) {
ctx, cancel := context.WithCancelCause(context.Background())
defer cancel(nil)
h := newHost(t)
h.accept(ctx)
done := runAsync(ctx, newGuest(t, h.entries()).startup())
conn := h.guest(t)
defer conn.Close()
reason := errors.New("guest shutting down")
cancel(reason)
res := wait(t, done, "startup to return")
assert.Equal(t, exitLink, res.code)
assert.ErrorIs(t, res.err, reason)
}
+87
View File
@@ -0,0 +1,87 @@
//go:build lambdatestlink
package main
import (
"context"
"errors"
"flag"
"fmt"
"log/slog"
"net"
"net/http"
"os"
"time"
"github.com/mulgadc/spinifex/spinifex/agents/lambda/agent"
"github.com/mulgadc/spinifex/spinifex/agents/lambda/hostlink"
"github.com/mulgadc/spinifex/spinifex/agents/lambda/runtimeapi"
)
const (
runtimeAddr = "127.0.0.1:9001"
taskRoot = "/var/task"
)
var (
linkPath = flag.String("link", os.Getenv("LAMBDA_AGENT_LINK"), "test carrier device path")
handler = flag.String("handler", os.Getenv("_HANDLER"), "function handler passed to the bootstrap")
)
func start(parent context.Context) int {
if *linkPath == "" {
slog.Error("no test carrier configured; refusing to start")
return 2
}
// The bootstrap is tenant code; fw_cfg must be unreadable before it runs.
if err := hostlink.SealFwCfg(hostlink.FwCfgRoot, hostlink.UnloadFwCfg); err != nil {
slog.Error("seal fw_cfg; refusing to start", "err", err)
return 2
}
dev, err := os.OpenFile(*linkPath, os.O_RDWR, 0)
if err != nil {
slog.Error("open test carrier; refusing to start", "err", err)
return 2
}
defer dev.Close()
link := hostlink.NewTestStream(dev)
srv := runtimeapi.New()
ln, err := net.Listen("tcp", runtimeAddr)
if err != nil {
slog.Error("listen runtime api", "err", err)
return 1
}
hs := &http.Server{Handler: srv.Handler(), ReadHeaderTimeout: 10 * time.Second}
go func() { _ = hs.Serve(ln) }()
defer hs.Close()
ctx, cancel := context.WithCancelCause(parent)
defer cancel(nil)
cmd, err := agent.StartBootstrap(ctx, taskRoot+"/bootstrap", []string{
"AWS_LAMBDA_RUNTIME_API=" + runtimeAddr,
"_HANDLER=" + *handler,
"LAMBDA_TASK_ROOT=" + taskRoot,
"PATH=/bin:/sbin:/usr/bin",
}, os.Stdout, os.Stderr)
if err != nil {
slog.Error("start bootstrap", "err", err)
return 1
}
exited := make(chan struct{})
go func() {
defer close(exited)
err := cmd.Wait()
cancel(fmt.Errorf("bootstrap exited: %w", err))
}()
err = agent.Run(ctx, link, srv)
cancel(errors.New("agent stopping"))
<-exited
if err != nil {
slog.Error("agent stopped", "err", err)
return 1
}
return 0
}
+44 -24
View File
@@ -1,17 +1,22 @@
// sqs-worker runs inside the shared SQS worker microVM.
// 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 worker's data disk named by fw_cfg, asks the
// gateway for its current first-store decision with its instance-role
// credentials, then loads or creates the store key on that disk and
// publishes only the public key. ready then keeps checking the bound store's
// JetStream with that key until it is stopped, keeping a readiness marker only
// while the check passes; it proves the store accepted the worker key, not
// the store server's identity.
// 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 (
@@ -23,10 +28,14 @@ import (
"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.
@@ -36,7 +45,11 @@ const caPath = "/sys/firmware/qemu_fw_cfg/by_name/opt/spinifex/ca-cert/raw"
// no static-key fallback.
func newClaimer() workerboot.NewClaimer {
return func(region, gatewayURL string) (workerkey.Claimer, error) {
return newClient(region, gatewayURL)
c, err := newClient(region, gatewayURL)
if err != nil {
return nil, err
}
return workerboot.GatewayClaimer{Client: c}, nil
}
}
@@ -48,13 +61,6 @@ func newClient(region, gatewayURL string) (*sqsworkergw.Client, error) {
return sqsworkergw.New(gatewayURL, region, ca, gwsign.NewInstanceRole())
}
type describer struct{ c *sqsworkergw.Client }
func (d describer) DescribeStore(ctx context.Context) (workerboot.Store, error) {
st, err := d.c.DescribeStore(ctx)
return workerboot.Store(st), err
}
func main() {
if len(os.Args) != 2 || (os.Args[1] != "boot" && os.Args[1] != "ready") {
fmt.Fprintln(os.Stderr, "usage: sqs-worker boot|ready")
@@ -66,11 +72,18 @@ func main() {
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",
@@ -99,19 +112,26 @@ func boot(logf func(string)) error {
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),
NewDescriber: func(region, gatewayURL string) (workerboot.StoreDescriber, error) {
c, err := newClient(region, gatewayURL)
if err != nil {
return nil, err
}
return describer{c}, nil
},
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
+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"`
}
+2 -2
View File
@@ -126,8 +126,8 @@ type Assign struct {
ENIPrivateIP string `json:"eniPrivateIp,omitempty"`
ENISubnetID string `json:"eniSubnetId,omitempty"`
// TaskRoleARN is the task's IAM role (from the taskdef); when set, the agent
// serves its credentials at 169.254.170.2 under CredID, defaulting CredID to
// the taskID when the scheduler leaves it empty.
// serves its credentials at 169.254.170.2 under CredID, a scheduler-minted
// secret. An assign without a CredID gets no credentials.
CredID string `json:"credId,omitempty"`
TaskRoleARN string `json:"taskRoleArn,omitempty"`
// ExecutionRoleARN is the task's execution role (from the taskdef); when set,
+66
View File
@@ -0,0 +1,66 @@
# Lambda v1 contracts
This package will own the Lambda process contracts declared by ADR-0009:S13.
It holds the L-GUEST declaration and, in `guest.go`, the connection vocabulary both ends share: the scope, the URI SAN roles, the ALPN identifier and the fw_cfg entry names. The transport and authentication are decided (ADR-0009:S17); the invocation message types and framing are not chosen, so no message types are defined here.
It is not an AWS tenant-facing API.
## L-GUEST: node executor to guest agent
**Owner:** the Lambda domain.
**Producers and consumers:** the node-local executor (`runtime/compute/lambda`) and the in-guest Lambda agent (`agents/lambda`), in both directions.
**Operations:** bootstrap with scoped material, readiness, invoke, completion, bounded logs, shutdown.
**Transport:** TCP over the microVM's network (ADR-0009:S17).
The agent dials one connection to a listener the executor owns for the environment, and the guest exposes no listener for host control.
At most one authenticated control connection is active per environment, and the agent may reconnect; how a prior connection and its in-flight work are handled is open (ADR-0009 M1).
The network is shared and untrusted: no source address, port or bridge membership is identity or authority.
Tenant code may be able to reach the listener, but it cannot open the agent's connection or key material, so any connection it makes fails authentication.
**Authentication:** mutual TLS 1.3, bound to the environment ID and realization generation, using material the user runtime cannot read (ADR-0009:S17).
For each generation the executor creates ephemeral self-signed host and guest certificates whose single URI SAN is `spinifex-lambda://<Region>/<account>/<function ID>/<environment ID>/<generation>/<role>`, with role `host` or `guest`.
Each end validates the peer certificate against the one pinned peer certificate and separately requires that URI SAN with the opposite role; hostname verification is not relied on.
ALPN carries the L-GUEST major version, `spinifex-lambda-guest/1`, and an unsupported version fails the handshake.
Under TLS 1.3 the guest's handshake can complete before the host refuses its certificate, so the guest treats the connection as accepted only after the host's first message.
**Bootstrap material:** the executor passes these fw_cfg entries under `opt/spinifex/lambda/`, and the agent reads them as root and unloads `qemu_fw_cfg` before user code starts.
| Entry | Content |
|---|---|
| `guest-key` | Guest private key, PEM |
| `guest-cert` | Guest certificate, PEM |
| `host-cert` | The one pinned host certificate, PEM |
| `expect` | `<Region>/<account>/<function ID>/<environment ID>/<generation>` |
| `host-addr` | Host listener as `host:port` |
The guest's own interface settings come from `opt/spinifex/netcfg`, the entry other system VMs use.
The agent unloads `qemu_fw_cfg` even when an entry is missing, and refuses to start if the fw_cfg tree is still readable afterwards.
Until the message format exists, the agent authenticates and holds the connection but accepts no host message and starts no user code.
**Authority:** one environment's attempt only.
The user runtime never holds these keys, and sharing the guest with the agent gives user code no platform authority.
**Idempotency and fencing:** an invoke carries the attempt-grant identity and the attempt ID.
The agent accepts a run command only under a current grant, and a completion under a superseded grant is discarded and reported.
How a grant is enforced and proven ended is open (ADR-0009 M1).
**Errors and versions:** the agent and executor fail closed on an unknown protocol version or an identity mismatch.
**Readiness:** environment readiness needs authenticated, current-generation agent evidence; a running hypervisor process does not count.
**Payloads:** the contract must carry a synchronous payload and response of up to 6 MB and an asynchronous payload of up to 1 MB, plus framing, over a path whose size bound is declared.
How payloads move is open (ADR-0009 M3).
### Compatibility boundary
The agent ships inside the runtime image pinned by each published version, so it is outside the coordinated release train.
The executor supports every L-GUEST major carried by a still-supported runtime image.
Retiring an image follows a published runtime-deprecation lifecycle that blocks create and update before it blocks invoke.
Evidence required before the first agent image ships: a function on an old image invoked by a new executor, and the reverse during rollback.
## L-RUNTIME
The guest agent serves the AWS Lambda Runtime API, version `2018-06-01`, to the user runtime at `AWS_LAMBDA_RUNTIME_API`.
Spinifex does not version this AWS contract, and conformance is measured against AWS.
+54
View File
@@ -0,0 +1,54 @@
// Package lambdav1 holds the Lambda process-contract vocabulary shared by the
// node executor and the guest agent. See README.md for the declarations.
package lambdav1
import (
"fmt"
"strings"
)
// ALPNv1 names L-GUEST major version 1; a peer offering any other fails the handshake.
const ALPNv1 = "spinifex-lambda-guest/1"
// FwCfgPrefix prefixes every fw_cfg entry the executor passes the guest.
const FwCfgPrefix = "opt/spinifex/lambda/"
// fw_cfg entry names under FwCfgPrefix.
const (
EntryGuestKey = "guest-key"
EntryGuestCert = "guest-cert"
EntryHostCert = "host-cert"
EntryScope = "expect"
EntryHostAddr = "host-addr"
)
// Roles named as the last URI SAN path segment.
const (
RoleHost = "host"
RoleGuest = "guest"
)
const uriScheme = "spinifex-lambda"
// Scope names one environment generation:
// Region/account/function ID/environment ID/generation.
type Scope string
// ParseScope accepts exactly five non-empty, slash-separated segments.
func ParseScope(s string) (Scope, error) {
parts := strings.Split(s, "/")
if len(parts) != 5 {
return "", fmt.Errorf("lambdav1: scope %q: want 5 segments, got %d", s, len(parts))
}
for _, p := range parts {
if p == "" || strings.ContainsAny(p, " \t\r\n?#%") {
return "", fmt.Errorf("lambdav1: scope %q: empty or invalid segment", s)
}
}
return Scope(s), nil
}
// URI is the single URI SAN a certificate for role in this scope carries.
func (s Scope) URI(role string) string {
return uriScheme + "://" + string(s) + "/" + role
}
+20
View File
@@ -0,0 +1,20 @@
package lambdav1
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestParseScope(t *testing.T) {
s, err := ParseScope("ap-southeast-2/000000000001/fn-1/env-1/gen-1")
require.NoError(t, err)
assert.Equal(t, "spinifex-lambda://ap-southeast-2/000000000001/fn-1/env-1/gen-1/host", s.URI(RoleHost))
for _, bad := range []string{
"", "a/b/c/d", "a/b/c/d/e/f", "a//c/d/e", "a/b/c/d/e/", "a/b/c/d/e?x", "a/b/c/d/e#x", "a b/b/c/d/e",
} {
_, err := ParseScope(bad)
assert.Error(t, err, bad)
}
}
+62
View File
@@ -74,3 +74,65 @@ type ListAccountsResponse struct {
Accounts []AccountSummary `json:"accounts"`
Count int `json:"count"`
}
// IAMQuiescence is the coordinator's recorded view of IAM quiescence for one
// fence. Recording only: it admits nothing, and QuiescedScope stays empty until
// a final inventory re-read exists to catch a late write.
type IAMQuiescence struct {
OperationID string `json:"operationId"`
Fence uint64 `json:"fence"`
Generation uint64 `json:"generation"`
QuiescedScope []string `json:"quiescedScope"`
Unresolved []UnresolvedScope `json:"unresolved"`
Consumers []ConsumerBasis `json:"consumers"`
Writers []AccountedWriter `json:"writers"`
Inventory []InventoryStatus `json:"inventory"`
EvaluatedAt string `json:"evaluatedAt"`
}
// UnresolvedScope names a scope or writer that blocks quiescence, with a fixed
// reason. It is never a timeout.
type UnresolvedScope struct {
Scope string `json:"scope"`
Class string `json:"class"`
Reason string `json:"reason"`
}
// ConsumerBasis cites an enforcement consumer and the accepted contract that
// lets it count as having applied the generation.
type ConsumerBasis struct {
Consumer string `json:"consumer"`
Basis string `json:"basis"`
}
// AccountedWriter is a writer that cannot be fenced and is covered by the
// owner's inventory instead. Ref names the tracked work that closes the gap.
type AccountedWriter struct {
Writer string `json:"writer"`
Basis string `json:"basis"`
Ref string `json:"ref"`
}
// InventoryStatus is the owner inventory's status for one kind at evaluation.
type InventoryStatus struct {
Kind string `json:"kind"`
Status string `json:"status"`
}
// Classes of an UnresolvedScope.
const (
UnresolvedClassFence = "fence"
UnresolvedClassConsumer = "consumer"
UnresolvedClassWriter = "writer"
)
// Fixed reasons of an UnresolvedScope.
const (
ReasonFenceMismatch = "fence-mismatch"
ReasonIAMReadbackFailed = "iam-readback-failed"
ReasonNoAcceptedEnforcement = "no-accepted-enforcement-contract"
ReasonNoCompleteOwnerInventory = "no-complete-owner-inventory"
ReasonParticipantNotDeclared = "participant-not-declared"
ReasonOwnerInventoryIncomplete = "owner-inventory-incomplete"
ReasonFinalInventoryRereadNotWired = "final-inventory-reread-not-wired"
)
+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"`
}
+22 -8
View File
@@ -152,12 +152,12 @@ Operational commands for inspecting cluster state. These fan out NATS requests t
| `spx admin account create` | `--name` (required) | Connects to NATS → refuses if an account already holds the name (case- and whitespace-insensitive) → reserves the name in `spinifex-account-names` KV → CAS loop on `spinifex-account-counter:next_id` for sequential 12-digit ID → creates Account record in `spinifex-accounts` KV → creates default VPC → creates `admin` user, AdministratorAccess policy (Action:*, Resource:*) and one access key → prints credentials and writes a `spinifex-<name>` AWS CLI profile |
| `spx admin account create --remote` | `--name` (required), `--endpoint` (default: this node's AWS gateway), `--region` (default: this node's region), `--ca-bundle` (default: this node's CA), `--client-token` (default: generated), `--source` (default: `spx-cli`) | Same account, created through `POST /admin/CreateAccount` over SigV4 instead of NATS — the path the public signup form uses, so a shell can exercise it. Credentials come from the standard AWS chain (env vars or `AWS_PROFILE`), never from the cluster master key; the caller must be an IAM user in the super-admin account holding `spinifex:CreateAccount`. Reuse the printed `--client-token` to retry: a fresh token would create a second account. |
| `spx admin account list` | — | Connects to NATS → IAMService.ListAccounts() → prints table with Account ID, Name, Status, Created |
| `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 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 credential chain: on a cluster node that means `AWS_PROFILE=spinifex` (there is no default profile, so without it the command fails to find credentials), or equivalent credentials; 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), `--endpoint`, `--region`, `--ca-bundle`, `--client-token` | Always runs through `POST /admin/DeleteAccount` over SigV4 and follows `DescribeAccountDeletion` to completion; the CLI never tears an account down in its own process, so a CLI delete and an API delete cannot race. 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`. 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 account delete --remote` | as above | Accepted for existing scripts and ignored: every delete already goes through the admin API. |
| `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. |
| `spx admin principal list` | — | Lists every principal in the super-admin account with its access-key count and what it may call. An attached policy is reported by name (`AdministratorAccess (attached)`) rather than expanded, which is how an unscoped principal is spotted. |
| `spx admin principal revoke <name>` | — | Deletes every access key the named principal holds, leaving the user and its policy in place. Immediate and cluster-wide; re-issue with `principal create`. |
@@ -194,18 +194,32 @@ Operational commands for inspecting cluster state. These fan out NATS requests t
### SQS System Instances
These commands start the first SQS system instances on one node. They do not serve the SQS API, and a bound VM is not a ready service.
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.
Both send a signed `spinifex` admin action (`EnsureSQSWorker`, `EnsureSQSStore`) 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 VMs, volumes, private VPC and ENIs 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. Each call waits up to 11 minutes.
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 ensure-worker` | `--node` (required), `--instance-type` (required), `--endpoint`, `--region`, `--ca-bundle` | Launches the Region's one SQS worker VM on `--node`, or reports the VM already bound there: node, region, instance, instance type and generation. The worker 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 1 GiB system-account data volume on which the guest generates its own store key. The platform records only the public key. 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. |
| `spx admin sqs ensure-store` | `--node` (required, the worker's node), `--instance-type` (required, more than 512 MiB memory), `--volume-size-gib` (required, 1–16384), `--endpoint`, `--region`, `--ca-bundle` | Launches the Region's SQS store VM on the worker's node with an encrypted system-account data volume, or reports the VM already bound there: node, region, instance, instance type and address. Needs the worker bound with its public key recorded, else `IncorrectState`. The store accepts only that key. A different size, type or key than the first call is `IncorrectState`. |
| `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 decides store readiness itself, by checking that the store is bound to its own public key and that a NATS connection with that key succeeds; nothing reports that readiness to the operator. The worker-to-store connection does not authenticate the store server. Real VPC routing, IMDS and gateway TLS from the guest, and worker-to-store traffic on a real interface have not been tested.
`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
+29 -115
View File
@@ -203,12 +203,14 @@ Viperblock responds with an NBD URI that QEMU uses to access the block device.
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.
Each data volume becomes a `virtio-blk-device` on the MMIO bus with the same id, node name, I/O thread, serial and `werror=stop,rerror=stop` policy as the PCI device, and takes no PCIe hot-plug port.
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 MMIO bus has no PCIe root port to plug into.
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.
@@ -276,8 +278,8 @@ 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 an internal primitive: nothing outside the SQS domain uses it, no daemon or service is wired to it, and it does not itself deliver an SQS API.
The dedicated JetStream server ADR-0011 describes is not deployed by this code, and Spinifex ships no host service, unit, user or firewall rule for it; the guest that could run it is described under *SQS store system instance (not wired)*.
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.
@@ -296,124 +298,36 @@ If that consumer delivers nothing for about two seconds while the stream holds a
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 store system instance (not wired)
### SQS system instance (operator-launched)
Spinifex can build, install, launch, restart and give storage to a dedicated SQS store microVM, but nothing calls any of it.
No SQS API, domain service, admin command or daemon start-up path creates a store, so none of this is in production use.
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.
The guest image is built by `make build-sqs-store-microvm-image`, separate from the load balancer image, and bundles the `nats-server` version pinned in `go.mod` plus the ext4 tools.
Release tarballs carry it as `microvm-sqs-store/`, and `setup.sh` installs it to `/usr/share/spinifex/microvm/sqs-store/`.
A release without a complete image removes any previously installed one, so a launch is then refused rather than booting a stale image.
`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.
At boot the guest reads three firmware config values: the data disk's serial, an IPv4 address on one of its interfaces, and one NATS user NKey public key.
It mounts exactly one matching disk at `/data` after `e2fsck -p`, refuses a missing, duplicate, unformatted or unrepairable disk, and never formats.
JetStream keeps its data on `/data` with `sync_interval: always` (ADR-0011:S3), `max_payload: 2MiB` admits a full 1 MiB SQS message with its headers (ADR-0011:S4), and the server listens only on that address and accepts only that key.
On shutdown the store is stopped before `/data` is unmounted; a store that survives SIGKILL leaves every mount in place.
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.
A launch uses the versioned subject `system.LaunchSQSStore.v1`, which a daemon without this code does not serve, so it fails with no responder instead of booting another guest.
The legacy `system.LaunchInstance` path refuses the store kind on both send and receive.
Every input check runs before capacity is reserved, and the data volume must be an available system-account volume.
It is mounted through the normal EBS path and detached, never deleted, on terminate, which is the protection standard EBS gives today and nothing stronger.
CPU mitigations stay enabled for the store, unlike the load balancer.
A successful launch means the VM is running, not that NATS is 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 instance record keeps the store's non-secret guest settings so a restart on the node that launched it rewrites the firmware config files.
A relaunch on any other node is refused, and cross-node recovery never claims a store.
Both checks exist only in daemons with this code: an older daemon can still claim and relaunch a store elsewhere, and an explicit start served by another node claims the instance before its relaunch is refused.
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.
`domains/sqs/storevolume` prepares a fresh data volume, because the guest never formats.
It checks for `mkfs.ext4` and `qemu-img` before allocating, creates the volume in `creating`, writes an empty ext4 image to it over NBD on the local node, unpublishes it, and only then makes it `available`.
While a volume is `creating`, attach, delete, modify, force-detach and snapshot are refused.
A volume is deleted only after a confirmed unpublish; any publish error, an unconfirmed unpublish or a failed release keeps it and reports its ID, with its state reported as unknown after a failed release.
`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 only: unit tests, real QEMU boots against disposable scratch disks, and one run of the preparer against the `qemunbdd` provider whose volume kept acknowledged writes across a reboot.
Viperblock, volume encryption, the EC2 volume record path, multiple nodes and host reachability of the guest address are not tested.
Credential issuance and rotation, store placement and NATS readiness checks do not exist, and there is no failover: a lost node leaves its store unavailable.
### SQS worker guest (operator-launched)
The shared SQS worker guest holds the SQS store's client credential, so that no platform component does (ADR-0011:S2).
Only an operator activates it, with the super-admin `EnsureSQSWorker` action described below; nothing starts it at daemon startup, and no worker service runs after boot.
`make build-sqs-worker-microvm-image` builds it with the store image's script and network `/init` under `SQS_MICROVM_PROFILE=worker`; it carries the static `sqs-worker` binary and the ext4 tools, no `nats-server`, and an empty `/data` mountpoint.
Releases build it beside the store image and ship it as `microvm-sqs-worker`, which `setup.sh` installs to `/usr/share/spinifex/microvm/sqs-worker` with the store image's rules: a release without it removes an earlier one, and an incomplete one is refused.
At boot `sqs-worker boot` reads three firmware config values under `opt/spinifex/sqs-worker/`: the worker disk's serial, its Region and the gateway URL; none is a first-store decision, because a static value survives a guest reset.
A missing, empty or malformed value refuses before any disk is touched.
It selects exactly one disk with that serial, runs `e2fsck -p`, mounts it at `/data` and then confirms from `/proc/self/mountinfo` that this disk, as a whole ext4 filesystem, is what is mounted there; an empty mountpoint, a foreign mount or a failed check or mount refuses, and the guest never formats.
Only then does it ask the gateway for the current decision with `ClaimFirstStore`, signed by `internal/sqsworkergw` with credentials from the instance metadata service only (`gwsign.NewInstanceRole`, no environment, profile or static-key fallback), over TLS 1.3 that trusts only the cluster CA from `opt/spinifex/ca-cert` and never follows a redirect.
Under that decision `workerkey.Acquire` creates the key on `/data/sqs-worker-key`, owner-only and synced, records only its public key and then uses it, or loads the recorded key; a missing or different key once one is recorded refuses and is never replaced.
The VM can start before its launch binds the instance, so its first calls are legitimately `AccessDenied`; that denial creates nothing and is asked again, as are a lost reply and a `ServiceUnavailable` or `Throttling` refusal, all within one bound (60 attempts 5 seconds apart), after which a worker that was never bound, or was replaced, refuses the boot.
A signature or other refusal, or an answer that is neither "create" nor one valid public key, refuses the boot at once.
`domains/sqs/firststore` is the SQS domain's first-store decision: one record per Region in the `spinifex-sqs-first-store` KV bucket moves from `claimed` to `key-recorded` to `launch-intent` under compare-and-swap, bound to the worker's instance ID, claim ID and generation.
A claim authorizes key creation only while no public key is recorded; once one is, every claim returns only that key, and a store launch intent needs it recorded first.
An older generation, a different identity at the same generation or an unreadable record authorizes nothing, and the record holds no private key material.
Reads are stream message gets answered by the stream leader, never direct gets a lagging replica can answer, and every authorizing answer is also confirmed by a compare-and-swap at the revision it read, so a stale or unavailable read authorizes nothing.
The authenticated caller that will carry this decision must derive the worker's instance ID and generation from authoritative instance state; a generation supplied in a request cannot itself authorize a takeover.
`domains/sqs/workerident` is that authoritative instance state: one record per Region in the `spinifex-sqs-worker-identity` KV bucket holds a monotonic generation, claim ID, pinned node and encrypted data volume, and moves from `launching` to `bound` to `stopped`, or from `launching` to `failed`, under compare-and-swap.
A launch intent is refused 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 `RecordStopped` records that nothing holds the volume, and each request names the generation it supersedes, so a replayed request cannot take a generation it never observed; there is no automatic takeover.
The launch path then raises the first-store fence to the successor's generation with `firststore.Fence` before `BeginLaunch`, which refuses a replacement until a leader read shows that fence; the fence only rises, and `firststore` refuses every call from a generation below it.
Only the current claim ID is recorded, so claim IDs are minted fresh per attempt and a VM launch must be idempotent on generation and claim together.
Binding writes the instance's index entry first and then binds the record to that instance.
The index is only a lookup: `Authorize` accepts an instance only while the Region's record is `bound` to exactly that instance, generation and claim, confirmed by a compare-and-swap at the leader-read revision, so an old, orphaned, rewritten or unbound entry authorizes nothing.
A same-instance reboot keeps its instance ID and identity; a replacement takes a new generation, and the first cut refuses another node or data volume and an unencrypted volume, although the encryption flag is the caller's claim and the launch wiring must verify it from the volume's metadata.
Both buckets are opened through `OpenDurableKV`, which refuses an existing bucket whose effective stream configuration could expire, evict, lose, lag or refuse records: non-limits retention, an age, per-message or delete-marker TTL, memory storage, asynchronous flush, a size, count or message-size cap, a discard policy other than discard-new, unacknowledged writes, a sealed stream, a mirror or sources, or fewer replicas than the cluster needs.
`workerkey.Acquire` drives that decision: it syncs a new key to disk, records its public key and only then returns it, so a crash before recording reuses the key already on disk.
`domains/sqs/awsapi/worker` carries the decision to the worker as `ClaimFirstStore` and `RecordWorkerPublicKey`, served under the internal `spinifex-sqs` signing service, which no AWS SDK signs for; the public AmazonSQS action inventory is untouched.
The gateway admits only an assumed-role session of `sqsWorkerRole` in the system account, then the role's policy, and only then resolves the session name, the instance ID STS set, through `workerident.Authorize`; the request carries no identity, and any parameter beyond the action's own is refused.
The first-store identity is the current record's instance, claim and generation, and a worker bound to another Region is refused.
A request that passed `Authorize` just before a confirmed stop and replacement still carries the old generation; the fence was raised before the successor began, so `firststore` refuses it whether or not the successor has claimed yet.
The adapter never logs a caller-chosen parameter name, because one could carry a secret.
`RecordWorkerPublicKey` accepts only a valid public user key, so a seed is refused without being recorded, logged or echoed.
The gateway binds both buckets per request and never creates them, so it provisions nothing and answers `ServiceUnavailable` until a worker launch has created them; the worker launch below ensures the role.
The private key stays on the worker disk; the guest logs and publishes only the public key to `/run/sqs-worker/public-key`.
`domains/sqs/workerlaunch` realizes the worker on the node it runs on, its only node: a bound record is returned as is; otherwise it takes the intent, then on the first launch marks the intent's one volume create, prepares the volume and records its ID, reads the volume's own record and refuses it unless encrypted, ensures the role, finds or creates the network for the claim and reads the recorded store key, and marks the intent dispatched before launching.
A dispatched launch is never sent again: a lost reply or error leaves the intent `launching` and dispatched, and every later call returns `ErrOutcomeUnknown` until the outcome is reconciled, since the launch path is not idempotent.
A failure before dispatch leaves an undispatched intent that the next call resumes with the same claim; a confirmed stop or failure is replaced only after `firststore.Fence` excludes the old generation, reusing the pinned volume.
The launch passes no store decision or key: the guest asks the gateway at boot.
The intent exists before any volume, so concurrent first launches allocate nothing for the losers. A Prepare proven to have sent no create withdraws the mark and returns `ServiceUnavailable`, or `UnsupportedOperation` when the cluster storage key is not configured, and a retry may prepare the volume; a create that may have applied is never sent again, and every later call returns `IncorrectState` until it is reconciled. A volume the call knows of but cannot use is reported with its ID in a `VolumeError` and left in place, never deleted. An intent that has sent no create may be re-typed; one that has keeps its type until replaced. The worker role is created with EC2-only trust, and an existing role is checked for that trust before any policy is granted; the instance profile must carry exactly the worker role, and one holding any other role is refused unchanged.
The daemon's `LaunchSQSWorkerInstance` boots the `sqs-worker` image through the same direct-boot path as the store, with the data volume attached at boot and kept on terminate, a required instance profile, and a required mgmt NIC; it re-reads the volume's own record and refuses one that is not available and encrypted, before anything is allocated.
Its firmware config adds the gateway URL on this node's mgmt bridge and the cluster CA from the daemon's own trust store; the caller cannot supply either, and no value written is secret.
A relaunch rebuilds that config from the VM record on the node that launched it and is refused anywhere else, and membership takeover already skips SQS-managed instances.
`sqsWorkerRole` is ensured in the system account granting exactly `spinifex-sqs:ClaimFirstStore` and `spinifex-sqs:RecordWorkerPublicKey`, from the `domains/sqs/workerrole` contract the gateway gate also reads; an existing role whose trust admits more than the EC2 service is refused.
Evidence is local only: unit tests with a fake firmware config, sysfs and mount table, tests running the boot through the real client against the real `spinifex-sqs` registration and records, and QEMU boots of the built image against disposable scratch disks and a host-side test gateway and metadata service, showing the first boot creates the key, reboots reuse it, a key lost after recording is never replaced, and the seed reaches no log or request.
The worker's network is a private SQS system VPC per Region, built by `systemvpc.EnsurePrivate` from the `[sqs]` supernet (default `10.240.0.0/14`): one VPC and one /24 subnet, with no internet gateway, NAT gateway, EIP, route table or daemon-to-store node port. Each launch claim gets one ENI in that subnet, in the VPC default security group, found again by its claim on a retry; two ENIs for one claim are refused. The launch intent is marked before the one create is sent, so a create whose ENI is not yet visible is never sent again: the launch fails closed with `IncorrectState` until the ENI appears or the outcome is reconciled. Worker-to-store TCP and the guest's route to the metadata service are not yet proven by any traffic test.
The super-admin `EnsureSQSWorker` action on the internal `spinifex` operator service takes a `NodeId`, which must be a node of this cluster and a single subject token, and an `InstanceType`, and sends a request to `sqs.worker.Ensure.v1.<node>` with no queue group, so only that node answers and the worker stays pinned.
That node's daemon re-checks the node the request names and the instance type, and refuses as `ServiceUnavailable` before allocating a volume or network or recording an intent unless the worker image's kernel and initramfs are non-empty files, its mgmt bridge exists and the cluster CA is readable; a call after the image is installed then launches.
It then runs its one composed launcher for its own Region, one launch at a time, in the daemon's own context with a 30-minute bound, so a caller's timeout does not cancel it.
A reply lost to that timeout is reported as `ServiceUnavailable`; calling again reports the bound worker, or `IncorrectState` for a dispatched launch whose outcome is unknown, which is never resent; a Region pinned to another node is refused as `InvalidParameterValue`.
The reply names the bound VM's node, Region, instance ID and generation; bound is not ready, and it says nothing about the guest's boot, its key or the store.
Stop/detach evidence for `RecordStopped` is not wired, the guest's metadata route and gateway TLS on a real ENI and worker-to-store TCP are unproven, and Viperblock's at-rest key is held by the platform.
The worker identity record holds the instance type of its generation; a request for another type is refused as `IncorrectState`, before dispatch and after binding, the reply names the recorded type, and only a replacement after a confirmed stop may change it.
Operators call both actions with `spx admin sqs ensure-worker --node --instance-type` and `spx admin sqs ensure-store --node --instance-type --volume-size-gib`, signed for the `spinifex` service with the standard AWS credential chain like the other `spx admin` gateway commands.
Each command refuses missing inputs before sending anything and reports only the bound VM; a bound VM is not a ready worker or store.
The super-admin `EnsureSQSStore` action takes a `NodeId`, an `InstanceType` and a `VolumeSizeGiB` and sends a request to `sqs.store.Ensure.v1.<node>` with no queue group; only the node the Region's worker is bound to can launch the store.
That node refuses an instance type whose memory does not exceed 512 MiB, the store's `GOMEMLIMIT` under ADR-0011:S11; this is a floor, not a validated size, and no type is defaulted.
It refuses before recording or allocating anything unless the store image and the cluster storage key are present, the worker is bound on this node, and that worker's identity has recorded its public key in the first-store decision, which it then advances to a launch intent.
The store's own launch record in `spinifex-sqs-store-launch` holds only that public key, the claim, the node and the operator's instance type and size, and marks each step before its uncertain create: the volume, the ENI in the worker's private subnet, and the launch.
A later request for a different instance type or size is refused as `IncorrectState`, before dispatch and after binding, and the reply names the type the store was launched with.
A volume preparation that failed before sending any create, such as a missing `mkfs.ext4` or a scratch or format failure, withdraws its mark and returns `ServiceUnavailable`, so a retry may try again once the host is fixed.
Any other marked step whose result is not visible is reported as `IncorrectState` and never resent; a volume that is not encrypted by its own record is refused as `UnsupportedOperation` with its ID kept, and the daemon re-checks the volume's encryption and the guest's size at launch.
The daemon never receives the worker's seed; the store's NATS server accepts only that public key, and the reply's bound VM and address are not readiness, which only the worker can check.
After boot, the worker's long-running `sqs-worker ready` step mounts and verifies its disk again and calls the internal `spinifex-sqs` `DescribeStore` action, which admits only the current worker and returns the bound store's address and recorded public key, or pending.
It loads its key only if it matches that public key, then connects to the store's NATS on port 4222 with it and asks JetStream for account info, each pass bounded to 30 seconds.
The step runs until shutdown with no overall deadline, so a store the operator launches at any time is picked up: it asks again every 5 seconds while the store is pending, not answering or the reply was lost, and re-checks a ready store every 30 seconds.
A pass writes only the address and public key to `/run/sqs-worker/store-ready`; any failed pass removes that file, and so does the step's own exit.
A start that finds shutdown in progress or no worker key logs once and waits in place rather than exiting, so init does not respawn it in a loop; shutdown stops it like a running watcher.
Init respawns the step, at most once every 5 seconds, and every start removes a marker left by a watcher that was killed or crashed before the new watcher runs.
A ready store that fails is noticed up to one re-check interval plus one bounded call later, so the marker can be up to about a minute stale.
Shutdown blocks further respawns, removes the marker, and stops the watcher by its recorded PID and start time, escalating to SIGKILL after a budget; it unmounts the worker disk only once the watcher is confirmed gone, and a survivor leaves every mount in place.
A mismatched key, a store that refuses the key, a key missing from the disk or an unclassified gateway refusal is asked again only every 5 minutes, so it never spins and still recovers if the operator fixes it; a missing key is never replaced.
The NKey authenticates the worker to the store, but nothing authenticates the store to the worker: there is no TLS, so a pass means only that something at that address accepted the key.
This is store readiness for the worker alone; there is no SQS API, and no production sizing or support is claimed.
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
+6 -5
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. |
@@ -369,8 +369,9 @@ while the branch is in flight; they do not describe the umbrella repository's
| `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#TBD` | `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#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
@@ -488,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.
@@ -688,7 +689,7 @@ 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 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#TBD`; 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` (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. |
+2 -2
View File
@@ -236,12 +236,12 @@ Each stage runs to empty before the next begins, so nothing is deleted while som
A large account takes minutes to tear down. The API does not hold the connection open for it: `DeleteAccount` starts the job, returns a `deletionId` immediately, and progress is read from `DescribeAccountDeletion`.
```bash
AWS_PROFILE=spinifex-operator spx admin account delete 000000000042 --remote \
AWS_PROFILE=spinifex-operator spx admin account delete 000000000042 \
--name customer@example.com --yes \
--endpoint https://node1.example.com:9999 --region us-west-1
```
`spx --remote` starts the job and then polls, printing each stage as it lands. Your own client can do the same, or start the job and check back later — the deletion record outlives the account, so `DescribeAccountDeletion` stays answerable after the account is gone.
`spx admin account delete` always goes through the admin API, so it signs with the standard AWS credential chain. On a cluster node that means `AWS_PROFILE=spinifex` (there is no default profile, so without it the command cannot find credentials), or equivalent credentials for an IAM user holding `spinifex:DeleteAccount` and `spinifex:DescribeAccountDeletion`, and a reachable gateway. The older `--remote` flag is accepted and ignored. The command starts the job and then polls, printing each stage as it lands. Your own client can do the same, or start the job and check back later — the deletion record outlives the account, so `DescribeAccountDeletion` stays answerable after the account is gone.
One job runs per account. A second `DeleteAccount` with a different token while one is running returns `OperationInProgress`; with the *same* token it returns the running job, which is what makes a retry safe.
+73 -8
View File
@@ -58,7 +58,7 @@ Authentication uses **EKS access entries** (the API authentication mode) — IAM
**Spinifex specifics**
- **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 cluster security group is auto-managed.** As on EKS, Spinifex creates one `eks-cluster-sg-<name>-<id>` in your VPC, tagged `kubernetes.io/cluster/<name>=owned` and `aws:eks:cluster-name`, allowing all traffic from itself. It is attached to the workers and the private-endpoint network interface, and `DescribeCluster` reports it as `clusterSecurityGroupId`. A launch template that names security groups replaces it on the workers, as on AWS. Clusters created before this change keep their `-control-plane-sg` and `-nodegroup-sg` until you recreate them. Deleting a cluster deletes the cluster security group and removes only the rules on your other security groups that reference it. `vpc_config.security_group_ids` is ignored.
- **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.
@@ -96,6 +96,72 @@ Limits:
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.
### Add-on versions
Add-on versions use the AWS form `v<x.y.z>-eksbuild.<n>`, for example `v1.40.1-eksbuild.1`.
`DescribeAddonVersions` returns versions in this form, newest first, and every other add-on response reports the version the same way.
Spinifex ships one EKS build of each upstream version, so `<n>` is `1` today.
`DescribeAddonVersions` honours `addonName`, `kubernetesVersion`, `types`, `publishers`, `owners`, `maxResults` and `nextToken`.
Each version carries `compatibilities` with the `clusterVersion`, the `platformVersions`, and a `defaultVersion` flag that is true for exactly one version per add-on and cluster version.
A `kubernetesVersion` that Spinifex does not run returns no add-ons.
`CreateAddon` and `UpdateAddon` accept the AWS form of a version in the catalogue.
They also accept the bare upstream form, such as `1.40.1`, and Spinifex logs each use at Info.
This is a Spinifex leniency that AWS does not offer, and it exists so that older scripts keep working.
Any other value is refused with a message that names `addonVersion` and lists the supported versions, for example `v1.40.1-eksbuild.2`, `1.40` or `v1.40.1`.
In Terraform, pass the AWS form to `aws_eks_addon.addon_version`, either as a literal or from `data.aws_eks_addon_version`.
The AWS provider validates the format before it calls the API, so it rejects the bare form.
Leaving `addon_version` unset still installs the catalogue default.
## Prerequisites
> [!IMPORTANT]
@@ -178,12 +244,11 @@ The list shows the node group workers only. The control-plane VMs never appear a
### 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):
Workers join the cluster security group, which only allows intra-cluster traffic. To reach a NodePort service, add one ingress rule to it (`DescribeCluster` reports its id):
```bash
SG=$(aws ec2 describe-security-groups \
--filters Name=group-name,Values=eks-cluster-demo-nodegroup-sg \
--query 'SecurityGroups[0].GroupId' --output text)
SG=$(aws eks describe-cluster --name demo \
--query 'cluster.resourcesVpcConfig.clusterSecurityGroupId' --output text)
aws ec2 authorize-security-group-ingress \
--group-id "$SG" --protocol tcp --port 30080 --cidr 0.0.0.0/0
@@ -225,7 +290,7 @@ The form notes that the authentication mode is API and that the legacy aws-auth
- The **Access** tab lists IAM principals and their cluster-access policies — add an access entry to let another user or role reach the cluster.
- The **Addons** tab installs and shows the health of managed addons.
- The **Networking** tab shows the cluster's VPC, subnets, endpoint access, and the auto-managed security groups.
- The **Networking** tab shows the cluster's VPC, subnets, endpoint access, and the auto-managed cluster security group.
To run `kubectl` against the cluster, follow the AWS CLI tab's `update-kubeconfig` step.
@@ -308,11 +373,11 @@ Control plane readiness and kubectl authentication above lists which clusters re
- Confirm the workers can pull images — a default route to an Internet Gateway (public) or NAT Gateway (private).
- Confirm the node role has `AmazonEKSWorkerNodePolicy`, `AmazonEKS_CNI_Policy`, and `AmazonEC2ContainerRegistryReadOnly`.
**Workload unreachable from outside the cluster.** The node-group security group only allows intra-cluster traffic by default. Add an ingress rule for your NodePort to `eks-cluster-<name>-nodegroup-sg`, as shown in the CLI tab.
**Workload unreachable from outside the cluster.** The cluster security group only allows intra-cluster traffic by default. Add an ingress rule for your NodePort to it, as shown in the CLI tab. An Ingress served by the AWS Load Balancer Controller needs no rule: the controller adds it to the cluster security group.
**`create-cluster` rejected with an authentication-mode error.** `CONFIG_MAP` is the one mode Spinifex rejects, because it has no `aws-auth` ConfigMap path. Set `authentication_mode` to `API` or `API_AND_CONFIG_MAP`, or leave it unset, and grant access with access entries.
**Addon install fails.** Only addons bundled into the registered `eks-node` image install successfully. If you pin an addon version in Terraform, leave `addon_version` unset so Spinifex selects the catalog default.
**Addon install fails.** Only addons bundled into the registered `eks-node` image install successfully. If you pin an addon version in Terraform, use the AWS form from `DescribeAddonVersions` (see Add-on versions above), or leave `addon_version` unset so Spinifex selects the catalog default.
## Reference Architectures
+3 -3
View File
@@ -17,7 +17,7 @@ tags:
## Overview
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.
The platform serves **406 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 |
|---|---:|
@@ -27,9 +27,9 @@ The platform serves **405 operations** across the AWS APIs below. Every page nam
| [ECS](/coverage/ecs) | 31 |
| [EKS](/coverage/eks) | 27 |
| [ELBv2](/coverage/elbv2) | 36 |
| [IAM](/coverage/iam) | 81 |
| [IAM](/coverage/iam) | 82 |
| [RDS](/coverage/rds) | 27 |
| [Route 53](/coverage/route53) | 13 |
| [S3](/coverage/s3) | 26 |
| [STS](/coverage/sts) | 5 |
| **Total** | **405** |
| **Total** | **406** |
+84 -3
View File
@@ -2938,7 +2938,8 @@
"UpdateAccessKey",
"UpdateAssumeRolePolicy",
"UpdateRole",
"UpdateRoleDescription"
"UpdateRoleDescription",
"UpdateUser"
],
"implemented": [
"AddRoleToInstanceProfile",
@@ -3021,7 +3022,8 @@
"UpdateAccessKey",
"UpdateAssumeRolePolicy",
"UpdateRole",
"UpdateRoleDescription"
"UpdateRoleDescription",
"UpdateUser"
],
"stubbed": [],
"unsupported": [],
@@ -3121,7 +3123,6 @@
"UpdateServerCertificate",
"UpdateServiceSpecificCredential",
"UpdateSigningCertificate",
"UpdateUser",
"UploadSSHPublicKey",
"UploadServerCertificate",
"UploadSigningCertificate"
@@ -3978,6 +3979,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",
+2 -1
View File
@@ -18,7 +18,7 @@ tags:
## Overview
Spinifex implements **81 operations** in the IAM `2010-05-08` API model.
Spinifex implements **82 operations** in the IAM `2010-05-08` API model.
### Scope
@@ -109,3 +109,4 @@ All IAM operations are account-scoped. The root user of an account bypasses poli
| `UpdateAssumeRolePolicy` |
| `UpdateRole` |
| `UpdateRoleDescription` |
| `UpdateUser` |
+16 -2
View File
@@ -987,7 +987,7 @@ spot-price: On owned hardware there is no spot-to-on-demand price differential,
## iam
`2010-05-08` — 81 of 180 modelled operations implemented (45.0%), 0 stubbed, 19 not applicable, 0 registered outside the pinned model.
`2010-05-08` — 82 of 180 modelled operations implemented (45.6%), 0 stubbed, 19 not applicable, 0 registered outside the pinned model.
### Not implemented
@@ -1069,7 +1069,6 @@ spot-price: On owned hardware there is no spot-to-on-demand price differential,
- `UpdateOpenIDConnectProviderThumbprint`
- `UpdateSAMLProvider`
- `UpdateServerCertificate`
- `UpdateUser`
- `UploadServerCertificate`
### Not applicable — do not build
@@ -1441,6 +1440,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.
+81 -150
View File
@@ -1,7 +1,7 @@
---
title: "Oracle Linux Hosts"
seoTitle: "Install Spinifex on Oracle Linux 10 — Spinifex Docs"
description: "Prepare Oracle Linux 10 hosts for Spinifex: UEK R8, SELinux, a NetworkManager br-wan bridge, the NFV SIG repository, IPsec on libreswan and host 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
@@ -17,55 +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, or a cluster in which every node runs Oracle Linux; servers beyond the third join as compute nodes, as on Debian.
- 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 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 owns libreswan's `ipsec.service`, which it keeps stopped on a single node and runs for its own tunnels on a cluster node, 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,118 +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
```
For a single node, continue with [Single-Node Install](/docs/install) from Step 2: `setup-ovn.sh --management`, `spx admin init` and `systemctl start spinifex.target`.
IPsec stays off on a single node, because there is no peer to encrypt traffic to.
The installer stops and masks firewalld; see [firewalld](#firewalld) before relying on any rule it held.
For a cluster, prepare and install every node this way, then continue with [Multi-Node Install](/docs/install-multi-node) from Step 2.
### Clusters
Every node in an Oracle Linux cluster runs Oracle Linux.
`install-node.sh` refuses a host list that mixes Oracle Linux with Debian or Ubuntu, but `spx admin join` does not check, so keep to one OS yourself when joining by hand.
IPsec encrypts the Geneve tunnels between nodes and is on by default, as on Debian.
On Oracle Linux it runs on libreswan, the IKE daemon Oracle ships, rather than strongSwan.
Once the node's IPsec certificate exists, the daemon unmasks and starts `ipsec.service` and `openvswitch-ipsec.service`.
`ovs-monitor-ipsec` then loads the node certificate, its key and the cluster CA into libreswan's NSS database at `/var/lib/ipsec/nss`, under nicknames that start with `ovs_cert`.
The tunnels are plain ESP (IP protocol 50), as on Debian, negotiated over UDP 500.
Open UDP 500, UDP 4500 and ESP between nodes: libreswan moves to UDP 4500 if it detects NAT between two nodes.
The Spinifex host firewall opens all three to cluster peers; a firewall between the nodes must allow them too.
While IKE to a peer fails, the node drops its Geneve traffic to that peer rather than send it in clear.
The nft table `inet spinifex_ipsec_guard` drops outbound UDP 6081 from the encap address whose route carries no SA, which also covers the moments pluto flushes the xfrm policy table as it starts and stops.
It is not persisted: `ipsec.service` puts it back at boot before ovn-controller builds the tunnels, and the daemon re-syncs it every 60 seconds.
`spx admin init --ipsec=false` leaves the tunnels unencrypted, as on any OS.
## 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.
On a cluster node it also stops `openvswitch-ipsec.service` and deletes the `ovs_cert*` entries from libreswan's NSS database, so the node can join a different cluster; other NSS identities and your connection files in `/etc/ipsec.d` stay.
It 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
@@ -229,41 +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`.
### `sudo spx` finds no command
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.
### Cluster nodes form no IPsec tunnels
Check that both IPsec services run, and that libreswan holds the node's certificate and the cluster CA:
```bash
systemctl is-active ipsec.service openvswitch-ipsec.service
sudo certutil -L -d sql:/var/lib/ipsec/nss # ovs_certkey_<node>, ovs_cert_cacert
sudo ipsec trafficstatus # one line per peer tunnel
```
If the services are inactive, check the daemon log with `journalctl -u spinifex-daemon` for the IPsec reconcile.
If the certificates are present but no tunnel is listed, check that UDP 500 and ESP pass between the nodes in both directions, and look for `SEC_ERROR_UNKNOWN_ISSUER` in `journalctl -u ipsec`, which means a peer's certificate is from another CA.
Until the tunnel forms, guest traffic between those two nodes is dropped, which is the guard in [Clusters](#clusters) working.
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
@@ -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.
+5 -5
View File
@@ -16,11 +16,11 @@ 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 (`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`, `TestDeleteClusterPrefix_SweepsAddonRecordsAndManifestsScopedToCluster` |
| 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` |
| 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. Versions are stored and used internally in the bare upstream form, and API responses show the AWS form `v<x.y.z>-eksbuild.<n>` (`Spec.AWSVersion`). Create and update accept the AWS form or the exact bare form and reject unknown names and other version strings with `InvalidParameterValue`, which the gateway returns as `InvalidParameterException`. | `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` |
| Observed state | `status` and `health` are overwritten by guest reports on the same record as the desired fields. `health` is the latest failure message, projected as one `AddonIssue` with only `message`. Statuses held: `CREATING`, `ACTIVE`, `UPDATING`, `DEGRADED`, `CREATE_FAILED`; `DELETING` is defined but never stored. | `TestAddonRecord_DecodesHandWrittenJSON`, `handlers/eks/addon_status_reconciler_test.go` `TestApplyAddonStatusReport` |
| Generation | None. Reports carry `version` and `ts`, and neither is read: any report for the add-on name applies to whatever is currently desired. | `TestAddonStatusReport_MismatchedVersionIsAccepted` |
@@ -37,9 +37,9 @@ There is no generation, so the record mixes desired fields with the latest obser
| 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` |
| 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. | `TestDeleteClusterPrefix_SweepsAddonRecordsAndManifestsScopedToCluster`, `handlers/eks/cluster_state_test.go` `TestDeleteClusterPrefix_SweepsEveryKey` |
| 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`. | |
@@ -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).
+2 -1
View File
@@ -99,6 +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) |
| 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 |
@@ -221,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, 4248, 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
+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
@@ -79,7 +79,8 @@ Terraform manages the cluster, the addons, the ACM cert, and the HTTPS Ingress;
- The `argocd` and `aws-ebs-csi-driver` bundles must be baked into the `eks-node` AMI — `describe-addon` returns `no baked bundle` otherwise.
- The EBS-CSI default StorageClass uses `provisioner: ebs.csi.aws.com`; a PVC against it provisions a **Viperblock-backed EBS volume**.
- If the PVC stays `Pending`, the app still serves (the counter falls back to in-memory) — see [Troubleshooting](#troubleshooting).
- Leave addon versions unset (the AWS provider and the catalog disagree on the version-string format).
- Addon versions are unset, so Spinifex installs each catalog default.
To pin one, use the AWS form that `DescribeAddonVersions` returns, such as `v3.0.23-eksbuild.1`.
- The Argo CD `Application` is a `kubernetes_manifest`, so the `argoproj.io` CRDs must exist before `apply` — apply the parent and let the addon reach `ACTIVE` first.
**Prerequisites:**
@@ -471,8 +471,7 @@ resource "aws_eks_cluster" "this" {
# expects: access_config reads as absent (and its ForceNew
# bootstrap_cluster_creator_admin_permissions would replace the live cluster on
# re-add), and vpc_config.security_group_ids reads back as drift. Ignore both so
# later applies (e.g. adding the Argo CD NodePort rule) neither replace the
# cluster nor detach its security groups.
# later applies neither replace the cluster nor detach its security groups.
lifecycle {
ignore_changes = [access_config, vpc_config[0].security_group_ids]
}
@@ -520,8 +519,8 @@ resource "aws_eks_node_group" "workers" {
#
# Spinifex wires the controller's AWS credentials and ELB-eligible subnets at the
# node level, so no IRSA role or subnet tagging is needed here. addon_version is
# omitted: the AWS provider demands a v-prefixed version that the catalog rejects,
# so let Spinifex default to its catalog version.
# omitted so Spinifex installs its catalog default. To pin one, use the AWS form
# from DescribeAddonVersions, for example v2.11.0-eksbuild.1.
# ---------------------------------------------------------------------------
resource "aws_eks_addon" "lbc" {
@@ -542,7 +541,7 @@ resource "aws_eks_addon" "lbc" {
# Installed through the EKS API; Spinifex stages the bundle host-side and the
# worker renders it into the K3s auto-deploy dir. The workloads module registers
# the git repo and creates the Argo CD Application that syncs the demo app.
# addon_version omitted (see the LBC note).
# addon_version omitted: the catalog default is installed (see the LBC note).
# ---------------------------------------------------------------------------
resource "aws_eks_addon" "argocd" {
@@ -562,7 +561,7 @@ resource "aws_eks_addon" "argocd" {
#
# Ships a default gp3 StorageClass (provisioner ebs.csi.aws.com). A PVC in the
# demo app's git manifests dynamically provisions a Viperblock-backed EBS volume
# so the app's state survives pod restarts. addon_version omitted (see LBC note).
# so the app's state survives pod restarts. addon_version omitted: catalog default.
# ---------------------------------------------------------------------------
resource "aws_eks_addon" "ebs_csi" {
@@ -642,53 +641,6 @@ resource "aws_acm_certificate" "ingress" {
}
}
# ---------------------------------------------------------------------------
# Let the ALB reach the workers' NodePort
#
# Spinifex auto-manages the nodegroup SG and admits only intra-cluster traffic.
# The LBC-provisioned ALB lives in this VPC, so admit the VPC CIDR on the
# NodePort. Look the SG up by its deterministic name and add one rule.
# ---------------------------------------------------------------------------
data "aws_security_group" "nodegroup" {
filter {
name = "group-name"
values = ["eks-cluster-${var.cluster_name}-nodegroup-sg"]
}
filter {
name = "vpc-id"
values = [aws_vpc.main.id]
}
depends_on = [aws_eks_node_group.workers]
}
resource "aws_vpc_security_group_ingress_rule" "nodeport_from_vpc" {
security_group_id = data.aws_security_group.nodegroup.id
cidr_ipv4 = aws_vpc.main.cidr_block
from_port = var.node_port
to_port = var.node_port
ip_protocol = "tcp"
tags = {
Name = "${var.cluster_name}-alb-nodeport"
}
}
# Same, for the Argo CD UI NodePort its ALB forwards to.
resource "aws_vpc_security_group_ingress_rule" "argocd_nodeport_from_vpc" {
security_group_id = data.aws_security_group.nodegroup.id
cidr_ipv4 = aws_vpc.main.cidr_block
from_port = var.argocd_node_port
to_port = var.argocd_node_port
ip_protocol = "tcp"
tags = {
Name = "${var.cluster_name}-argocd-nodeport"
}
}
# ---------------------------------------------------------------------------
# Outputs
# ---------------------------------------------------------------------------
@@ -64,7 +64,6 @@ The LBC is installed as a managed **addon** (`aws_eks_addon`). Spinifex wires th
| Node Group | `workers` | `node_desired_size` `t3.large` worker(s) — 1 or 3 |
| Addon | `aws-load-balancer-controller` | Provisions the ALB from the Ingress |
| ACM Certificate | `eks-https-ingress` | Self-signed, imported; attached to the HTTPS listener |
| SG Ingress Rule | `eks-https-alb-nodeport` | Admits the VPC CIDR to the workers' NodePort |
| K8s Deployment + Service | `spinifex-demo` | Themed demo image, 2 replicas, NodePort |
| K8s Ingress | `spinifex-demo` | `ingressClassName: alb`, HTTPS via the ACM cert |
@@ -72,9 +71,10 @@ The LBC is installed as a managed **addon** (`aws_eks_addon`). Spinifex wires th
- **The LBC needs no IRSA or subnet tagging here.** Credentials and ELB-eligible subnets are injected by Spinifex at the node level and into the `alb` `IngressClassParams`. The Ingress just sets `ingressClassName: alb` plus the `alb.ingress.kubernetes.io/*` annotations.
- **`spinifex.io/managed-ingress = "false"`** disables the K3s built-in traefik/servicelb so the LBC owns ingress, matching AWS parity. Omit the tag (or set it `true`) and the built-in ingress stays on.
- The ALB targets the workers' **NodePort** (`target-type: instance`). The one SG rule admits the VPC CIDR on that port so the in-VPC ALB can reach the workers; the worker SG is otherwise auto-managed.
- The ALB targets the workers' **NodePort** (`target-type: instance`). The AWS Load Balancer Controller adds the ALB-to-NodePort rule to the cluster security group itself, so the workbook creates no security group rule.
- Spinifex ACM supports **ImportCertificate only** — the cert is generated by the `tls` provider and imported, not requested.
- **Leave `addon_version` unset** (see the [quickstart/addons notes](/docs/eks-quickstart)) — the AWS provider and the Spinifex catalog disagree on the version string format, so let the server choose.
- **`addon_version` is unset**, so Spinifex installs its catalog default.
To pin a version, use the AWS form that `DescribeAddonVersions` returns, such as `v2.11.0-eksbuild.1`, or read it from `data.aws_eks_addon_version`.
**Prerequisites:**
@@ -179,16 +179,17 @@ Confirm the addon is `ACTIVE` and the cluster carries `spinifex.io/managed-ingre
### HTTPS Loads but Returns 502 / 504
The ALB reached the workers but the backend didn't answer. Check the demo pods and the NodePort SG rule:
The ALB reached the workers but the backend didn't answer. Check the demo pods and the rule the controller added to the cluster security group:
```bash
kubectl get pods -o wide
kubectl get svc spinifex-demo
aws ec2 describe-security-groups --filters "Name=group-name,Values=eks-cluster-eks-https-nodegroup-sg" \
SG=$(aws eks describe-cluster --name eks-https --query 'cluster.resourcesVpcConfig.clusterSecurityGroupId' --output text)
aws ec2 describe-security-groups --group-ids "$SG" \
--query 'SecurityGroups[0].IpPermissions'
```
The `eks-https-alb-nodeport` rule must admit the VPC CIDR on the NodePort, and the target group health check (`/healthz`) must pass.
The cluster security group must admit the load balancer's security group on the NodePort, and the target group health check (`/healthz`) must pass.
### Addon Fails With "no baked bundle"
@@ -16,7 +16,8 @@
# The workloads/ module then creates a Deployment, a NodePort Service, and an
# Ingress (ingressClassName: alb). The LBC reconciles that Ingress into an
# internet-facing ALB that terminates TLS with the ACM cert and forwards to the
# workers' NodePort. The cluster's ELB-eligible subnets are injected by Spinifex
# workers' NodePort, adding the ALB-to-NodePort rule to the cluster security group
# itself. The cluster's ELB-eligible subnets are injected by Spinifex
# into the alb IngressClassParams, so the Ingress needs no subnet annotations.
#
# Usage:
@@ -446,8 +447,8 @@ resource "aws_eks_node_group" "workers" {
#
# Spinifex wires the controller's AWS credentials and ELB-eligible subnets at the
# node level, so no IRSA role or subnet tagging is needed here. addon_version is
# omitted: the AWS provider demands a v-prefixed version that the catalog rejects,
# so let Spinifex default to its catalog version.
# omitted so Spinifex installs its catalog default. To pin one, use the AWS form
# from DescribeAddonVersions, for example v2.11.0-eksbuild.1.
# ---------------------------------------------------------------------------
resource "aws_eks_addon" "lbc" {
@@ -503,40 +504,6 @@ resource "aws_acm_certificate" "ingress" {
}
}
# ---------------------------------------------------------------------------
# Let the ALB reach the workers' NodePort
#
# Spinifex auto-manages the nodegroup SG and admits only intra-cluster traffic.
# The LBC-provisioned ALB lives in this VPC, so admit the VPC CIDR on the
# NodePort. Look the SG up by its deterministic name and add one rule.
# ---------------------------------------------------------------------------
data "aws_security_group" "nodegroup" {
filter {
name = "group-name"
values = ["eks-cluster-${var.cluster_name}-nodegroup-sg"]
}
filter {
name = "vpc-id"
values = [aws_vpc.main.id]
}
depends_on = [aws_eks_node_group.workers]
}
resource "aws_vpc_security_group_ingress_rule" "nodeport_from_vpc" {
security_group_id = data.aws_security_group.nodegroup.id
cidr_ipv4 = aws_vpc.main.cidr_block
from_port = var.node_port
to_port = var.node_port
ip_protocol = "tcp"
tags = {
Name = "${var.cluster_name}-alb-nodeport"
}
}
# ---------------------------------------------------------------------------
# Outputs
# ---------------------------------------------------------------------------
@@ -45,7 +45,7 @@ Under the hood it keeps things minimal: a VPC with two public subnets, an IAM ro
- Creating the EKS cluster and worker IAM roles with faithful managed-policy attachments
- Provisioning an `aws_eks_cluster` and a managed `aws_eks_node_group`
- Pointing the Kubernetes provider at the new cluster and deploying a workload from the same `apply`
- Opening one rule on the auto-managed worker security group to expose a NodePort
- Opening one rule on the cluster security group to expose a NodePort
**What gets created**
@@ -58,13 +58,13 @@ Under the hood it keeps things minimal: a VPC with two public subnets, an IAM ro
| ECR Repository | `spinifex-demo` | Holds the demo image the workers pull |
| EKS Cluster | `eks-quickstart` | Public API endpoint, API auth mode, Kubernetes 1.32 |
| Node Group | `default` | `node_desired_size` `t3.medium` worker(s) — 1 or 3 |
| SG Ingress Rule | `eks-quickstart-demo-nodeport` | Opens the demo NodePort on the auto-managed worker SG |
| SG Ingress Rule | `eks-quickstart-demo-nodeport` | Opens the demo NodePort on the cluster security group |
| K8s Deployment | `spinifex-demo` | The themed demo image from ECR, 2 replicas |
| K8s Service | `spinifex-demo` | NodePort publishing the demo on the worker |
**Spinifex specifics**
- The cluster's security groups are **auto-managed** — `vpc_config.security_group_ids` is ignored. To reach a NodePort, this workbook looks the worker SG up by its deterministic name (`eks-cluster-<name>-nodegroup-sg`) and adds a single ingress rule.
- The cluster security group is **auto-managed**, as on EKS: Spinifex creates `eks-cluster-sg-<name>-<id>`, attaches it to the workers, and reports it as `vpc_config[0].cluster_security_group_id`. `vpc_config.security_group_ids` is ignored. To reach a NodePort, this workbook adds a single ingress rule to that security group.
- The worker AMI is always Spinifex's `eks-node` image; `ami_type` is recorded but does **not** select the image.
- `authentication_mode` can be `"API"` or `"API_AND_CONFIG_MAP"`, and bare `"CONFIG_MAP"` is rejected. Access is granted through access entries, as there is no `aws-auth` ConfigMap path.
- The workers need outbound internet (here via the IGW) to pull the demo container image.
@@ -182,8 +182,9 @@ aws ec2 describe-instances --filters "Name=tag:spinifex:eks-cluster,Values=eks-q
# Did the demo pods roll out?
kubectl get pods -o wide
# Is the NodePort rule on the worker SG?
aws ec2 describe-security-groups --filters "Name=group-name,Values=eks-cluster-eks-quickstart-nodegroup-sg" \
# Is the NodePort rule on the cluster security group?
SG=$(tofu output -raw cluster_security_group_id)
aws ec2 describe-security-groups --group-ids "$SG" \
--query 'SecurityGroups[0].IpPermissions'
```
@@ -310,29 +310,15 @@ resource "aws_eks_node_group" "default" {
}
# ---------------------------------------------------------------------------
# Open the NodePort on the auto-managed nodegroup SG
# Open the NodePort on the cluster security group
#
# Spinifex creates the worker SG itself (vpc_config.security_group_ids is
# ignored) and admits only intra-cluster traffic. To reach the demo NodePort
# from a browser, look the SG up by its deterministic name and add one rule.
# Spinifex creates the cluster security group itself, as EKS does, and attaches
# it to the workers. It admits only intra-cluster traffic. To reach the demo
# NodePort from a browser, add one rule to the SG the cluster reports.
# ---------------------------------------------------------------------------
data "aws_security_group" "nodegroup" {
filter {
name = "group-name"
values = ["eks-cluster-${var.cluster_name}-nodegroup-sg"]
}
filter {
name = "vpc-id"
values = [aws_vpc.main.id]
}
depends_on = [aws_eks_node_group.default]
}
resource "aws_vpc_security_group_ingress_rule" "nodeport" {
security_group_id = data.aws_security_group.nodegroup.id
security_group_id = aws_eks_cluster.this.vpc_config[0].cluster_security_group_id
cidr_ipv4 = var.browse_cidr
from_port = var.node_port
to_port = var.node_port
@@ -367,6 +353,10 @@ output "region" {
value = var.region
}
output "cluster_security_group_id" {
value = aws_eks_cluster.this.vpc_config[0].cluster_security_group_id
}
output "node_port" {
value = var.node_port
}
+5 -1
View File
@@ -123,9 +123,13 @@ Internal and internet-facing ALB and NLB, HTTPS listeners, listener rules, `Modi
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, 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 wired with a projected SA token. 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.
`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
+79 -22
View File
@@ -24,7 +24,7 @@ All colours are OKLCH and come from mulgadc.com, so product and site match.
| Token | Use |
|---|---|
| `background` | The app page. Warm off-white in light, ink `#14181D` in dark. |
| `card`, `popover` | Raised surfaces. White in light, about `#1B2027` in dark. |
| `card`, `popover` | Raised surfaces. White in light, about `#1B2027` in dark. `popover` is every floating surface: tooltips, popovers, menus and pickers. |
| `muted`, `secondary`, `accent` | Hover rows, secondary buttons, quiet fills. |
| `border` | Every divider and card edge. `#DCD8CE` in light, `oklch(1 0 0 / 8%)` in dark. |
| `brand` | Brand orange `#EE4B12`, for the logo and decoration only. It fails text contrast, so it never carries text. |
@@ -39,15 +39,22 @@ Each status has a solid token plus `-foreground` (text on a tinted background),
| Status | Hue | AWS states |
|---|---|---|
| `success` | green | running, available, completed, healthy |
| `warning` | brand yellow `#F5B500` | pending, stopping, creating, modifying, deleting, draining |
| `danger` | cool red, hue 17 | stopped, error, failed, unhealthy, unavailable |
| `success` | green | settled and good: running, available, active, associated, attached, in-use, healthy, ok, main |
| `warning` | brand yellow `#F5B500` | changing state and will settle: pending, stopping, creating, modifying, deleting, draining |
| `caution` | orange, hue 58 | works but needs attention: degraded, impaired, insufficient-data |
| `danger` | cool red, hue 17 | stopped, error, failed, update-failed, unhealthy, unavailable, blackhole |
| `info` | blue | informational notes |
| `neutral` | grey `#9AA1A8` | terminated (muted text), and any unmapped state |
| `neutral` | grey `#9AA1A8` | gone or absent (terminated, deleted, no) in muted text, and any unmapped state |
`stopped` is danger because EC2 reads it that way, although RDS treats it as a resting state; splitting the map by service is its own change.
`StateBadge` (`src/components/state-badge.tsx`) is the only mapping from an AWS state to a tone, and it exposes the result as `data-tone` for tests.
Its tones are `success`, `progress` (the yellow `warning` token), `caution`, `danger`, `muted` and `neutral`.
A `progress` state carries the submit button's spinning loader, so a launching instance reads as work under way; `StatusBadge` does not, because its yellow can mean waiting on the user, as a zone's `pending nameservers` does.
It shows every state lowercase with hyphens, as EC2 writes them, so EKS's `ACTIVE` reads `active` and Bedrock's `IN_PROGRESS` reads `in-progress`; the API value, filters and CLI panels keep AWS's case.
A good or settled state is always green, never grey, and a status that is not an API state, such as a cluster's health, uses `StatusBadge` with a tone rather than a `Badge`.
The orange of `caution` is kept clear of the primary accent's hue, which marks actions and never a status.
`Badge`'s default variant is the brand primary and is never used for a status; a label that is not a status, such as `default v3` or `Leader`, uses a `secondary` or `outline` `Badge`.
Every status badge measures above 4.8:1 against its tinted background in both themes.
**Danger is separated from the primary accent by hue alone in dark mode**: the two have the same lightness.
@@ -67,18 +74,21 @@ Colour is never the only key: label every series.
| UI text, tables and forms | Geist (`font-sans`) |
| IDs, ARNs, IPs, CIDRs, code and eyebrow labels | JetBrains Mono (`font-mono`), with `tabular-nums` for figures |
| Step | Size | Use |
|---|---|---|
| `2xl` | 26px | page title (`PageHeading`) |
| `xl` | 22px | section heading |
| `lg` | 17px | |
| `base` | 15px | |
| `sm` | 14px | body and table default |
| `xs` | 13px | badges, secondary text |
| `2xs` | 12px | mono eyebrow labels |
| Step | rem | At the default 16px browser font | Use |
|---|---|---|---|
| `2xl` | 1.625 | 28.6px | page title (`PageHeading`) |
| `xl` | 1.375 | 24.2px | section heading |
| `lg` | 1.0625 | 18.7px | |
| `base` | 0.9375 | 16.5px | |
| `sm` | 0.875 | 15.4px | body and table default |
| `xs` | 0.8125 | 14.3px | badges, secondary text |
| `2xs` | 0.75 | 13.2px | mono eyebrow labels |
The scale sits a step above Tailwind's defaults, as consoles such as Vultr's do, because 13px body text read as cramped on a wide screen.
Code blocks are set at 12px (`--text-code-block`) so a long command still fits.
The root font size is 110% (`html` in `src/styles.css`), so one rem is 17.6px at the browser default.
Everything is sized in rem, so text, spacing and breakpoints scale together, as they would at 110% browser zoom, and a user's own font-size setting still applies.
The scale sits above Tailwind's defaults, as consoles such as Vultr's do, because the console read as small on a wide screen at 100%.
Code blocks are set at 0.75rem (`--text-code-block`) so a long command still fits.
Size new things in rem through the type and spacing scales, never in px, or they will not scale with the rest.
### Primary and contrast
@@ -116,7 +126,7 @@ The Go console serves `script-src 'self'; style-src 'self'`.
## Primitives
`src/components/ui/` holds the shadcn primitives on Base UI.
Added in Phase 1: breadcrumb, checkbox, command (`cmdk`), dialog, hover-card, popover, resizable (`react-resizable-panels`), scroll-area, slider, switch, table, toggle and toggle-group.
Added in Phase 1: breadcrumb, checkbox, command (Base UI Autocomplete), dialog, hover-card, popover, resizable (`react-resizable-panels`), scroll-area, slider, switch, table, toggle and toggle-group.
`button-group` joins buttons edge to edge, as the instance power buttons are.
`radio-group` is one of a few exclusive choices inside a form, such as a load balancer's type; a raw `<input type="radio">` is not used.
@@ -169,6 +179,22 @@ Every other list is a `DataTable`, and the test also fails when an allowed file
| S13 | a component outside `ui/` renders a raw `<input>`, `<select>` or `<textarea>` |
| S15 | a route imports the `ui/alert` primitive instead of using `ErrorBanner` |
Rules that ADR-0016 does not yet number are named by what they hold until their amendment is accepted:
| Rule | Fails when |
|---|---|
| status pills come from StateBadge or StatusBadge | a component outside `ui/` renders `<Badge>` with the default variant |
| status pills come from StateBadge or StatusBadge | a file outside `state-badge.tsx` builds a `rounded-full` pill on a status colour |
| floating surfaces follow the theme | a file fills with `bg-foreground` or `text-background`, or a tooltip, popover, hover card, menu, select or combobox is not on `bg-popover` and `text-popover-foreground` |
| expanding a panel keeps its tab | a `fullPage` link does not pass `search={{ tab }}` |
| resource tags are edited in place | `PanelTags` or `TagsEditor` comes back |
| lists filter through one FilterBar | a component other than `FilterBar` imports `ui/toggle-group` |
| a component handed a form reads errors through `useFormState` | a file typed with `UseFormReturn` reads `form.formState` |
The last one matters because the console builds with the React compiler and the unit tests run without it.
With the compiler on, a child handed a react-hook-form object is cached on that object, whose identity never changes, so errors read from `form.formState` never draw and the submit looks dead.
`useFormState({ control })` subscribes the child itself.
An exception is named in that rule's allowlist with its reason, and the test fails when an allowed file stops needing it.
S11, S14 and S16 are held by review, since none has a shape in the source a test can read without false alarms.
@@ -177,7 +203,7 @@ S11, S14 and S16 are held by review, since none has a shape in the source a test
`src/lib/nav.ts` is the one description of the console's navigation.
The sidebar, the command palette and the breadcrumbs all read it, and `nav.test.ts` checks every destination against the generated route tree, so a renamed route fails a test rather than leaving a dead link.
- **Sidebar.** Groups take generic names rather than AWS's service marks: Compute, Databases, Authentication and Object Storage, not EC2, RDS, IAM and S3. Pages and the rest of the console keep the AWS names, and each group's `service` keeps its AWS name findable in the command palette, where typing it exactly ranks that group first. Each service group folds, and a folded group stays folded across reloads (`localStorage["spinifex-ui-nav-collapsed"]`). Navigating into a folded group unfolds it so the active item shows, and it can then be folded again, the current group included. On a phone the sidebar is a sheet, and any navigation closes it.
- **Sidebar.** Groups take generic names rather than AWS's service marks: Compute, Databases, Authentication and Object Storage, not EC2, RDS, IAM and S3. Pages and the rest of the console keep the AWS names, and each group's `service` keeps its AWS name findable in the command palette, where typing it exactly ranks that group first. Each service group folds, and a folded group stays folded across reloads (`localStorage["spinifex-ui-nav-collapsed"]`). Navigating into a folded group unfolds it so the active item shows, and it can then be folded again, the current group included. Folding animates through the `nav` variant of `CollapsibleContent`, opening over 200ms and closing over 150ms, and switches at once under reduced motion. On a phone the sidebar is a sheet, and any navigation closes it.
- **Command palette.** ⌘K or Ctrl K, or the Search button in the header. It lists every page the user can reach, every create flow (`create` on a nav item), and a theme toggle. It is navigation only, with no resource search yet.
- **Breadcrumbs.** Service, then list page, then the resource or the create flow. A resource ID is set in mono. On a phone only the last crumb shows.
- **Sign in.** The sign-in card sits over the shell on a light, barely blurred overlay rather than on a page of its own, so the console stays in view. The shell behind it is `inert`, which keeps pointer, keyboard and screen reader out of it until the user signs in.
@@ -202,8 +228,13 @@ So columns drop out when a side panel narrows the list as well as on a small scr
List pages keep their filter, state and sort in the URL, so a filtered view can be bookmarked, shared and survives a reload.
EC2 Instances is the reference page (`ec2/(instances)/-components/instances-page.tsx`):
- The first column names the resource: the `Name` tag in the primary colour, with the ID in mono beneath it, opening its side panel.
- The first column is the instance ID in mono, opening its side panel, and the second its `Name` tag.
- A long value ends in an ellipsis and shows in full on hover (`TruncatedText`), so it never widens its column.
- A time column shows a relative time, `33 min ago` or `8 months ago`, with the full timestamp on hover (`RelativeTime`).
- A Tags column shows one tag key as a pill and `+N` for the rest, and hover lists every tag with its value (`TagPills`); it leaves out `Name`, which has its own column.
- Below `md`, only the ID, Name and State columns remain.
- Bulk actions appear above the table once a row is selected. Each action counts only the selected instances it applies to, so a mixed selection never sends a call that must fail, and Terminate asks first and lists every ID.
- Search and state filters sit in one `FilterBar` (`src/components/filter-bar.tsx`) above the table: a search box, an optional set of choices with counts, and Clear filters while anything is filtered.
- An empty account gets a call to action, and an empty filter result says the filters are why.
VPCs and Internet gateways follow the same pattern.
@@ -219,6 +250,12 @@ Every list with a delete action has row checkboxes. Selecting a row shows `Selec
- **The bulk delete and the panel's delete are the same function.** Where a single delete is several calls, such as removing dependants first, the sequence lives in one place and both use it.
- The verb follows the AWS console: Release for an Elastic IP, Deregister for an image.
### Lists inside a panel
A list inside a panel tab, such as a hosted zone's records, is a `DataTable` with row checkboxes, and a click on a row opens it for editing in a `FormPanel`.
Rows the user cannot change, such as a zone's own SOA and NS, are muted and cannot be selected (`isRowActionable`).
A batch delete uses the service's batch call where it has one, so Route 53 removes the whole selection in one `ChangeResourceRecordSets`.
### Typed confirmation
A delete that cannot be walked back asks the user to type a word first, as the AWS console does before deleting a volume.
@@ -264,13 +301,24 @@ Modelled on UniFi's console: a click on a row opens the resource in a panel besi
- **URL.** The panel is search state on the list route: `selected=<id>`, `tab=overview|monitoring|settings`, and `create=true`. Defaults are stripped, so a closed panel leaves the plain list address. Opening or closing a panel pushes a history entry, so Back closes it; a tab or filter change replaces the entry.
- **Layout.** From `lg` (1024px) the panel is fixed to the right edge, full height below the header, flush with no outer margin, and it slides in over the list. The list keeps its width and columns underneath, so nothing reflows when a panel opens, and a soft gradient on the panel's left edge reads as the panel lying on top. Below `lg` there is no room to share, so the panel replaces the list as a full page with a back arrow. `useWideLayout` decides, through `matchMedia`.
- **`DetailPanel`.** A header with the resource's own page (left) and close (right), the title and its ID, a summary of state and quick actions, then icon tabs with tooltips. Escape closes it unless a dialog or menu is open over it. Closing slides the panel out over 150ms, or at once under reduced motion. Each tab, and a create form, sits in its own error boundary, so a failed query shows in the panel and the list keeps working.
- **Contents.** `PanelSection` is a folding card, `PanelFields` and `PanelField` a label and value list, with `copy` for a one-click copy of IDs and addresses, and `PanelTags` the tag list. Create forms take an `onClose(id?)` prop: embedded, they hide their back link and heading, and a create selects the new resource.
- **Contents.** `PanelSection` is a folding card, `PanelFields` and `PanelField` a label and value list, with `copy` for a one-click copy of IDs and addresses, and `InlineTags` the tag list. Create forms take an `onClose(id?)` prop: embedded, they hide their back link and heading, and a create selects the new resource.
- **One component is both the panel and the page.** Each resource has a single details component (`InstanceDetails`, `VpcDetails`, `InternetGatewayDetails`) that takes `layout="auto" | "page"`. The list renders it as the panel; `/ec2/describe-instances/$id` and its kind render the same component as a page, as one centred column under a standard left-aligned heading, with state and actions on the right and text tabs. A feature added to the panel is on the page by construction, and the page ignores Escape and has a back arrow in place of close. Expanding the panel to its page keeps the tab, and query results such as fetched console output come along from the cache.
- **A click on the list's background closes the panel**, as UniFi's does. A row selects another resource instead, and links, buttons and inputs keep their own behaviour. Selecting rows for a bulk action closes it too, so the bulk bar and the panel never compete.
- **Actions.** State actions sit in the summary as a button group, and the rest go in an Actions menu, Unikraft style. Stop, Reboot and every destructive action confirm in a dialog.
- **Loading.** A panel or page still loading shows `PanelSpinner`, a centred spinner on the background, not a grey skeleton block.
- **`DetailCard`, `DetailRow` and `ListCard` are retired.** Every resource page uses the panel sections instead.
### Tags
`InlineTags` (`src/components/inline-tags.tsx`) shows a resource's tags in its panel and edits them in place.
Each row has a pencil, which turns the key and value into inputs and becomes a tick to save, and a bin to remove the tag; `+ Add tag` appends a row being edited.
Enter saves and Escape cancels the row without closing the panel.
Every row saves on its own request through the service's own tagging call, and a failure shows beside its row.
AWS has no rename, so changing a key puts the new key and then removes the old one, and a failure between the two leaves both rather than neither.
Keys are 1 to 128 characters with no `aws:` prefix and no duplicates, and values up to 256.
Without `onPut` and `onRemove` it is read-only, as for a system image or an AWS-managed policy.
Panels use the per-service wrappers in `src/components/resource-tags.tsx` and the writers in `src/mutations/tags.ts` rather than wiring the calls themselves.
### Forms from a resource's panel
A form that acts on an open resource, such as attaching a volume, scaling a node group or adding a listener, opens in `FormPanel` (`src/components/form-panel.tsx`), never in a dialog.
@@ -285,8 +333,16 @@ Every create flow opens in the side panel over its list, through `CreatePanel`,
- **URL.** `create=true` opens it, plus a list-local field where the flow needs context (`volume` for a snapshot, `instance` for an RDS snapshot, `launch=service|task` on ECS clusters). The old `create-*` addresses redirect to the list with the panel open, so links and bookmarks keep working.
- **Form.** The fields come in the order AWS asks for them. Settings that are on or off are `Switch`es, and a multi-select picker (subnets, security groups) is a `SwitchList`, a boxed list with one switch per row. Checkboxes only select table rows.
- **CLI.** Every create form ends with a `CliCommandPanel` showing the AWS CLI commands that send the same request, built from the form's live values and only the flags the console actually sends.
- **Submit.** `FormActions` turns the submit button into a spinner with its pending label in place, and `FormLock`, a native `<fieldset disabled>`, disables every control above it until the request settles. Nothing above the form appears or moves, and the panel does not scroll.
- **CLI.** Every create form ends with a `CliCommandPanel` showing the AWS CLI commands that send the same request, built from the form's live values and only the flags the console actually sends. The commands wrap to the panel's width and never scroll sideways; Copy takes the unwrapped text.
- **Sections.** Related fields are grouped in `PanelSection` boxes, as the instance launch form groups Image, Instance, Network and Root volume.
- **Nothing to pick, no field.** An optional picker with nothing usable to choose, such as Launch Template or Placement Group with none available, is left out of the form rather than shown holding only `none`.
- **Advanced settings.** Settings most users leave at their defaults sit in a collapsed Advanced settings section, which opens by itself when a field inside it fails validation.
- **Choices with a mark.** A few named products, such as database engines or distributions, are `ChoiceTile`s with the product's own mark from `src/assets/`, not a select. One that cannot be used stays visible and disabled, saying why.
- **Durations and windows.** A duration is a select of presets with the AWS default first and named (`Auto (5 min)` for a record's TTL), keeping a custom value the API returned. A time window, such as an RDS backup or maintenance window, is a day, start time and length picker that refuses an overlap before submitting.
- **Labels by type.** Where one field means different things by type, its label follows the type, as a record's value reads `IPv4 address` for A and `Mail server` for MX.
- **Long pickers.** A `SwitchList` that can run long, such as security groups, scrolls inside its box rather than pushing the form down.
- **Help.** A field's longer explanation goes in an (i) beside its label, through the `info` prop of `TextField` and `SelectField`, rather than in text under the field. Text under a field is for what the user must know before submitting.
- **Submit.** `FormActions` cross-fades the submit label into a spinner with its pending label over 150ms, at one width, so the button never changes size, and `FormLock`, a native `<fieldset disabled>`, disables every control above it until the request settles. Nothing above the form appears or moves, and the panel does not scroll.
- **Failure.** A refused request shows beside the button through `FormActions`'s `error`, and the form unlocks. The handler wraps only the mutation call in `try`, with an empty `catch` that returns, because react-hook-form rethrows whatever the handler throws and the error is already on screen from the mutation's state.
- **Success.** `onClose(id)` closes the panel and selects the new resource, where the list has a panel to select it in.
- **Appliance images.** A create that needs an ECS, EKS or RDS appliance image shows the missing-image callout inside its panel, never on the list, and only the step that needs the image is blocked.
@@ -306,6 +362,7 @@ A gap of more than three sample periods breaks the line rather than drawing acro
## Dates
`formatDateTime` writes the AWS console's form in the viewer's zone, `2026/06/04 02:00 GMT-5`, with a 24-hour clock and UTC as plain `GMT`.
In a table the time is relative (`relativeTime` in `src/lib/relative-time.ts`), rounded down to the largest whole unit, and the full form shows on hover.
## Testing
+5 -12
View File
@@ -97,16 +97,9 @@ The deploy script, `deploy-vultr.sh`, is in the Spinifex repository:
```bash
git clone https://github.com/mulgadc/spinifex.git
cd spinifex
git switch dev
cd scripts/terraform/vultr-spx
cd spinifex/scripts/terraform/vultr-spx
```
> [!NOTE]
> Vultr support is on the development branch and not yet in a published release.
> `git switch dev` gives you the deploy script for it, and `--channel dev` in [step 3](#3-deploy) installs the matching Spinifex build, the pre-release published from every change to `dev`.
> Once Vultr support is in a release, skip `git switch dev` and pass `--channel latest`.
## 2. Choose a deployment
| Use | Nodes | Plan | Cost while running |
@@ -143,14 +136,14 @@ Set your API key, then run the command for the deployment you chose:
export VULTR_API_KEY=<your key>
# Development and testing: one VM
./deploy-vultr.sh up --channel dev --plan vx1-g-16c-64g
./deploy-vultr.sh up --plan vx1-g-16c-64g
# Production: three VMs
./deploy-vultr.sh up --channel dev --plan vx1-g-16c-64g --nodes 3
./deploy-vultr.sh up --plan vx1-g-16c-64g --nodes 3
# Production on bare metal: one server, or three
./deploy-vultr.sh up --channel dev --plan vbm-8c-64gb-amd
./deploy-vultr.sh up --channel dev --plan vbm-8c-64gb-amd --nodes 3
./deploy-vultr.sh up --plan vbm-8c-64gb-amd
./deploy-vultr.sh up --plan vbm-8c-64gb-amd --nodes 3
```
One VM takes about 4 minutes and three VMs about 16 minutes.
+30 -33
View File
@@ -4,15 +4,15 @@ go 1.27.3
require (
github.com/aws/aws-sdk-go v1.55.8
github.com/aws/aws-sdk-go-v2 v1.47.4
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.24
github.com/aws/aws-sdk-go-v2/config v1.33.9
github.com/aws/aws-sdk-go-v2/credentials v1.20.9
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.3
github.com/aws/aws-sdk-go-v2/service/lambda v1.113.3
github.com/aws/aws-sdk-go-v2/service/s3 v1.114.4
github.com/aws/aws-sdk-go-v2/service/sqs v1.52.6
github.com/aws/smithy-go v1.28.6
github.com/aws/aws-sdk-go-v2 v1.47.5
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.25
github.com/aws/aws-sdk-go-v2/config v1.33.12
github.com/aws/aws-sdk-go-v2/credentials v1.20.12
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.5
github.com/aws/aws-sdk-go-v2/service/lambda v1.113.4
github.com/aws/aws-sdk-go-v2/service/s3 v1.114.6
github.com/aws/aws-sdk-go-v2/service/sqs v1.52.7
github.com/aws/smithy-go v1.28.7
github.com/cenkalti/backoff/v4 v4.3.0
github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/bubbletea v1.3.10
@@ -31,8 +31,8 @@ require (
github.com/mulgadc/northstar v1.23.0
github.com/mulgadc/predastore v1.23.0
github.com/mulgadc/viperblock v1.23.0
github.com/nats-io/nats-server/v2 v2.15.1
github.com/nats-io/nats.go v1.54.0
github.com/nats-io/nats-server/v2 v2.15.2
github.com/nats-io/nats.go v1.55.0
github.com/nats-io/nkeys v0.4.16
github.com/opencontainers/runtime-spec v1.3.0
github.com/oracle/oci-go-sdk/v65 v65.128.0
@@ -43,9 +43,9 @@ require (
github.com/spf13/pflag v1.0.10
github.com/spf13/viper v1.21.0
github.com/stretchr/testify v1.12.1
go.etcd.io/etcd/api/v3 v3.5.35
go.etcd.io/etcd/client/pkg/v3 v3.5.35
go.etcd.io/etcd/client/v3 v3.5.35
go.etcd.io/etcd/api/v3 v3.7.3
go.etcd.io/etcd/client/pkg/v3 v3.7.3
go.etcd.io/etcd/client/v3 v3.7.3
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.72.0
go.opentelemetry.io/otel v1.47.0
go.opentelemetry.io/otel/metric v1.47.0
@@ -53,14 +53,14 @@ require (
go.opentelemetry.io/otel/sdk/metric v1.47.0
go.opentelemetry.io/otel/trace v1.47.0
go.uber.org/goleak v1.3.1-0.20260602155401-5f791e3ef83b
go.yaml.in/yaml/v3 v3.0.5
go.yaml.in/yaml/v3 v3.0.6
golang.org/x/crypto v0.58.0
golang.org/x/sync v0.24.0
golang.org/x/sys v0.49.0
golang.org/x/term v0.47.0
golang.org/x/time v0.16.0
golang.org/x/tools v0.52.0
google.golang.org/grpc v1.83.2
google.golang.org/grpc v1.85.0
gopkg.in/ini.v1 v1.67.3
)
@@ -72,17 +72,17 @@ require (
github.com/Microsoft/hcsshim v0.15.0-rc.4 // indirect
github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.7 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.7 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.6 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.21 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.7 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.6 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.6 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.10.4 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.38.4 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.4 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.51.4 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.8 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.8 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.8 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.23 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.9 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.8 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.8 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.12.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.39.0 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.44.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.51.6 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/boltdb/bolt v1.3.1 // indirect
@@ -111,7 +111,7 @@ require (
github.com/containerd/plugin v1.1.0 // indirect
github.com/containerd/ttrpc v1.2.9 // indirect
github.com/containerd/typeurl/v2 v2.3.0 // indirect
github.com/coreos/go-semver v0.3.0 // indirect
github.com/coreos/go-semver v0.3.1 // indirect
github.com/coreos/go-systemd/v22 v22.7.0 // indirect
github.com/dgraph-io/badger/v4 v4.9.6 // indirect
github.com/dgraph-io/ristretto/v2 v2.4.0 // indirect
@@ -128,7 +128,6 @@ require (
github.com/go-playground/validator/v10 v10.30.5 // indirect
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/gofrs/flock v0.10.0 // indirect
github.com/gogo/protobuf v1.3.2 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/google/flatbuffers v25.12.19+incompatible // indirect
github.com/google/go-tpm v0.9.8 // indirect
@@ -175,7 +174,6 @@ require (
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pierrec/lz4/v4 v4.1.18 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus/client_golang v1.24.1 // indirect
github.com/prometheus/client_model v0.6.3 // indirect
github.com/prometheus/common v0.71.0 // indirect
@@ -203,10 +201,9 @@ require (
go.opentelemetry.io/otel/log v1.47.0 // indirect
go.opentelemetry.io/otel/sdk/log v1.47.0 // indirect
go.opentelemetry.io/proto/otlp v1.11.0 // indirect
go.uber.org/atomic v1.7.0 // indirect
go.uber.org/multierr v1.6.0 // indirect
go.uber.org/multierr v1.11.0 // indirect
go.uber.org/nilaway v0.0.0-20260721205819-4a0f7653488a // indirect
go.uber.org/zap v1.17.0 // indirect
go.uber.org/zap v1.27.1 // indirect
golang.org/x/exp/typeparams v0.0.0-20260611194520-c48552f49976 // indirect
golang.org/x/mod v0.42.0 // indirect
golang.org/x/net v0.61.0 // indirect
+61 -86
View File
@@ -22,46 +22,46 @@ github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aws/aws-sdk-go v1.55.8 h1:JRmEUbU52aJQZ2AjX4q4Wu7t4uZjOu71uyNmaWlUkJQ=
github.com/aws/aws-sdk-go v1.55.8/go.mod h1:ZkViS9AqA6otK+JBBNH2++sx1sgxrPKcSzPPvQkUtXk=
github.com/aws/aws-sdk-go-v2 v1.47.4 h1:bTvk6UdSSSvaQsfLVj1JRDEEqj/PDRwGf1KanaRzau0=
github.com/aws/aws-sdk-go-v2 v1.47.4/go.mod h1:XkrjAZvr705Nytv6PkPUhoSB2W9+vVHBp5oAWpkh8Ak=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.24 h1:t7FxqJ5KHDeKceyD4HtGaG6z7hMSeVGjs5/4c+L5ArM=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.24/go.mod h1:5XK6NRVlK8s8yj4zKcM43HLluGA/rAz+azLLvWDMRr0=
github.com/aws/aws-sdk-go-v2/config v1.33.9 h1:Hv6bcQUJVnmuWaKjqD6Ot8sE75NrWsWV20oGT5aWle0=
github.com/aws/aws-sdk-go-v2/config v1.33.9/go.mod h1:ivowmrZNfBQLcg4QkaItgi5wae4Bzot9RzIdbY1hfTg=
github.com/aws/aws-sdk-go-v2/credentials v1.20.9 h1:SNO55PnWhaDafsOcv16lCA0kdoFMl/YlegWoVsU02s0=
github.com/aws/aws-sdk-go-v2/credentials v1.20.9/go.mod h1:+2reOTRtdD3VSBqpRDNwrZIYy8yMCYa7oK3WKTJ/I1I=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.3 h1:gr3b87OLB29jymWPzp/CAyXwQRJVE+sC4Wm0qBIKHsk=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.3/go.mod h1:BRjf4L/Yx2hY54o+ZibPbLJyMMeSwqYQXwcJfqW66a8=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.7 h1:fSNAt6hs4qLjb9tMZc6vj3PHlOCYGXK2JjJZ7AW2tf4=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.7/go.mod h1:TMav+EuXQu5kNpZZz/6rIC8xx4mCsW+6AKHrjvOSAsE=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.7 h1:7NS6kqBjkWIN8P/AEdmtyq6eQGAhN1oHTZdzXclpH6Y=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.7/go.mod h1:1PFqtAUsIgF1nEVojzRJSf/v88zjzqrGW+w+UAtkXyQ=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.6 h1:Qmn60RWuk9+bd2ddvJJqwf3Cpac3VvGMOdbGemtUhX4=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.6/go.mod h1:h/YJhe4AVyQxEUMluph10iqV7mjajH7A9L+BHF5ppis=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.21 h1:e6YW5uME30TkrElR+4/xcY7NB6CCj2dcv7vtmGXhfhQ=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.21/go.mod h1:obpHAMP693+f3/A8PkLhIqfK1C+NWWn8zXzqD3OH520=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.7 h1:CwvFlKw0uUScfgOMflXSRZa+ayXgOf6wk7lhyMemQYU=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.7/go.mod h1:4SbD8BB+p+QEnG0aX6DoWwrtk9SufyYXRsUv0nve8LE=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.6 h1:LHHdobjo5le1sjJdolitV7uVvYg3wkaeM1Ju2fDgeZQ=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.6/go.mod h1:CBGB0evAiaMD3Lk4wPO7LQCcCZLuhzL5Qf/wfEUHQ5w=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.6 h1:mYViDr2azcgxOpRrObZOl631bvavqILoE/L+Kxgbwys=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.6/go.mod h1:OXVdAo9jNhggB+hJRGOyy7WolGTKB6Rg7IIulDeSzPM=
github.com/aws/aws-sdk-go-v2/service/lambda v1.113.3 h1:oSHldse7GDHpdq81XeAJnhIbsdDKP9bMIt00G8W6uXI=
github.com/aws/aws-sdk-go-v2/service/lambda v1.113.3/go.mod h1:Y//K046w4uhiR3o77beo/y52Jv2uEY13iJ+3+jicLO8=
github.com/aws/aws-sdk-go-v2/service/s3 v1.114.4 h1:MrkWRB58Qw5TEkb51QVooVLjlM3IevO2iVXEz74B8pg=
github.com/aws/aws-sdk-go-v2/service/s3 v1.114.4/go.mod h1:yfIxaKRHasvfy6tNEm3/LpqKsupMzQjMaWj9Y0jMVbo=
github.com/aws/aws-sdk-go-v2/service/signin v1.10.4 h1:tvpDjuUvgKURf3Yk4hOaGbZ4QdY+070OWcA5d6LUv2A=
github.com/aws/aws-sdk-go-v2/service/signin v1.10.4/go.mod h1:3ibVKg5Ng82Zby8++jELTD2nHxiPk2DJRLEeW/LKp8M=
github.com/aws/aws-sdk-go-v2/service/sqs v1.52.6 h1:Suq1LNhMBpsmShEv3vO+J3H8HEeK+GwYMJDYLE4IJSs=
github.com/aws/aws-sdk-go-v2/service/sqs v1.52.6/go.mod h1:rHGuzFg4AfVnWZa3/533tn5GQd1LzVkTXI9WMT8LqAc=
github.com/aws/aws-sdk-go-v2/service/sso v1.38.4 h1:ChCtagixSFOxltv0o6E3NT8F7ksvZtZGPwUBynO5Nmk=
github.com/aws/aws-sdk-go-v2/service/sso v1.38.4/go.mod h1:+FEO9jrVMpc9oN3CdAxJbtlQEu3z8UddDPSedpeM3PY=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.4 h1:j6c+HA4AoS0EBEjArPjavFEBrucJEryfrQU0TffmACk=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.4/go.mod h1:RhghaXUk9q66VtOQUX1FdPddQuHnqQZzbB0fEYXmPRA=
github.com/aws/aws-sdk-go-v2/service/sts v1.51.4 h1:OKEfbf2tcfsF2pBk/S9QqNpo6alaAtiBs4hysCsBjPw=
github.com/aws/aws-sdk-go-v2/service/sts v1.51.4/go.mod h1:mCd6qGo9b8hTtfjgsmNkFlzwjyce4owd7HDVFnCLXhM=
github.com/aws/smithy-go v1.28.6 h1:Pj+/sUviNYjow8ELRtNwKHZ9ECeF+7pATX3+shjLIrw=
github.com/aws/smithy-go v1.28.6/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/aws/aws-sdk-go-v2 v1.47.5 h1:5yA+A+BiXbDq1ZLbYwPViwhEGDMdR0MEhL8Jh2LnLS4=
github.com/aws/aws-sdk-go-v2 v1.47.5/go.mod h1:iNIkogrRvFwm+1OQIUOP2lPBtlXZboOIUWiCa+pHEMw=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.25 h1:MW0Iql1SOTgQPj8mCxrU/eH/K/AIDWi08ItOsXaWRLU=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.25/go.mod h1:+ucVYmHYB3JqVueY0E1RHahdXAnKQbIHqybSbvOUbOM=
github.com/aws/aws-sdk-go-v2/config v1.33.12 h1:IV1YARaXahwCqUIyP8IlWsP85Lil+5ozTozDQXPumRk=
github.com/aws/aws-sdk-go-v2/config v1.33.12/go.mod h1:h51R06bzMVwNBRZOX5ylHhNDnkB/azZeq/LvxM6q2pQ=
github.com/aws/aws-sdk-go-v2/credentials v1.20.12 h1:6P7Q7yH/GIwHg6MLKv3ySsBqaCoE1z+C0OTzYMenhEY=
github.com/aws/aws-sdk-go-v2/credentials v1.20.12/go.mod h1:Kw/SaoW/wc7IqJErqXJIzKo8b7Aelxfbuc6XlbrRnsc=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.5 h1:iXjy2SFhGYNB1jWZBhQqq8HbhsbPF23w+/zbMvPXsTg=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.5/go.mod h1:k0RUiBnyXU0AXRJ9tJmY2jQke6XUIX2PNVnWtvgaPtc=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.8 h1:gzIWc3DTPfPbWKrQnmL6BV6WD7NPD1htxzgdk/bQ/Z4=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.8/go.mod h1:vvUA5cDAZWGo4R14FZPZfXfJCmgsafOWEq78Kg0D+ug=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.8 h1:PWv+vG8aCLS8qd+HPdYhK7Vees90aKHWFFQ1AGYD/8E=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.8/go.mod h1:xxPZEpflYHJA12366ZGyq66z9dHPCoeCIu99Jck+MPw=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.8 h1:WOp5De9iWdEHDPh2Kb0tBP3vGBm/dVAuRGh4a4tqduo=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.8/go.mod h1:z8W8YgfhbWl62XqcTtK+CxPrE+iHGRVUOtrrbIOp7ZY=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.23 h1:MCIR6iZaCKO4UE0hUvoWWLI6JVjppopV64JfAJaMjng=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.23/go.mod h1:pphWFS+z0MmIPivj8c/6bATd1Z+Av7Ust75DPdEFsPA=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.9 h1:GACgECI422Xt5n4Pojr3O0aocPv8lfH4PJe2Aon5Wv8=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.9/go.mod h1:lPfnRkHpn1qWZJLbVEw10rIqSuCee1pNQTyGkUjAYh8=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.8 h1:+PMpWlJYOn2tm7sm6OZ/ZLd4jStl17T7wjNapd1Rczs=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.8/go.mod h1:cnG3xZLGQL4c/bt8pjdzlFtZsW7epBBuNS04a5xMjdo=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.8 h1:RS1Wt+K0r4X0ci9Vf4JYTUfHzsQihYA2LgGJeEneKpk=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.8/go.mod h1:adMnTy5kyoUW86jhXnGSVc/mvMQI4GZd1qYWMeaS8M8=
github.com/aws/aws-sdk-go-v2/service/lambda v1.113.4 h1:wvAdq1sNfwuedO1BX8GjY+oZWzoyeD4hhdjJI+4EPnI=
github.com/aws/aws-sdk-go-v2/service/lambda v1.113.4/go.mod h1:HgkYN4d72Fawg3MAJQepmNnROFoN11XYu14hgFfChTY=
github.com/aws/aws-sdk-go-v2/service/s3 v1.114.6 h1:to7+Vr3rLkpYRwVKJ5QRriI0eE306vrVN/6TnVHgxvY=
github.com/aws/aws-sdk-go-v2/service/s3 v1.114.6/go.mod h1:Jc89ASa2k7HMAskj3bSnt8bf+k+p2IApzkoXnEjkgUw=
github.com/aws/aws-sdk-go-v2/service/signin v1.12.0 h1:3vu/eUqh0rjh7Zy4N4YKcpZX4Aud+zinB3VsLhhlQFw=
github.com/aws/aws-sdk-go-v2/service/signin v1.12.0/go.mod h1:wxAk0D2oVBSjuXEIzvcKxTZfM90zticdAbh/VD42L0Y=
github.com/aws/aws-sdk-go-v2/service/sqs v1.52.7 h1:Eip+mZ0wFZY/10e5w89lWhF09Pa8tbbGwxePP3T2iMc=
github.com/aws/aws-sdk-go-v2/service/sqs v1.52.7/go.mod h1:bXp38eBNyvSevFIao60mYcC3pQIAkPYWOddhFVv9z8E=
github.com/aws/aws-sdk-go-v2/service/sso v1.39.0 h1:kIEpD5dUry7VET3Ybl7zj7PPHRB4lb52G9iTz+a1Tqk=
github.com/aws/aws-sdk-go-v2/service/sso v1.39.0/go.mod h1:UGsEYgWz4Gi+46vDQ5zkFLqUgJziyOE2SXjZjgmfmHc=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.44.0 h1:WfFev+HgTodggcNon5HieiLxxwWwb8Br8R2+nDkixvk=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.44.0/go.mod h1:gJSdIY6YUzDvqQLcMtPVZgBRHrFwzHryvpE8Igzbxps=
github.com/aws/aws-sdk-go-v2/service/sts v1.51.6 h1:BVjKrtRUtSYKztBCCfE1oAB+d3ViKcW2cKZfserOqMQ=
github.com/aws/aws-sdk-go-v2/service/sts v1.51.6/go.mod h1:eSukFLeLkPMm+1OxDR+F0joauZdyBkF47Ow7MqjZiJw=
github.com/aws/smithy-go v1.28.7 h1:h/n1SHrwVtaDYLz8mR1/Kq4Xh4RaFbFW7bOYckg9mfE=
github.com/aws/smithy-go v1.28.7/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
@@ -128,8 +128,8 @@ github.com/containerd/ttrpc v1.2.9 h1:ha0ak962T0s3CA/RoZ6S6xiWZQF24GrBaEpiGX1uih
github.com/containerd/ttrpc v1.2.9/go.mod h1:jjtQRwXm4DL3KsHKW8vDiUOV6wO0hi6IPhmJhxU7aEs=
github.com/containerd/typeurl/v2 v2.3.0 h1:HZHPhRWo5XMy3QGQoPrUzbW/2ckwjfweHmOwlkIrPAQ=
github.com/containerd/typeurl/v2 v2.3.0/go.mod h1:Qk+PAdUYArVj41TnGi6rJ+48RF0PkcTc4i/taoBcK0w=
github.com/coreos/go-semver v0.3.0 h1:wkHLiw0WNATZnSG7epLsujiMCgPAc9xhjJ4tgnAxmfM=
github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
github.com/coreos/go-semver v0.3.1 h1:yi21YpKnrx1gt5R+la8n5WgS0kCrsPp33dmEyHReZr4=
github.com/coreos/go-semver v0.3.1/go.mod h1:irMmmIw/7yzSRPWryHsK7EYSg09caPQL03VsM8rvUec=
github.com/coreos/go-systemd/v22 v22.7.0 h1:LAEzFkke61DFROc7zNLX/WA2i5J8gYqe0rSj9KI28KA=
github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
@@ -181,8 +181,6 @@ github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPE
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/gofrs/flock v0.10.0 h1:SHMXenfaB03KbroETaCMtbBg3Yn29v4w1r+tgy4ff4k=
github.com/gofrs/flock v0.10.0/go.mod h1:FirDy1Ing0mI2+kB6wk+vyyAH+e6xiE+EYA0jnzV9jc=
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
@@ -249,8 +247,6 @@ github.com/josharian/native v1.1.0 h1:uuaP0hAbW7Y4l0ZRQ6C9zfb7Mg1mbFKry/xzDAfmtL
github.com/josharian/native v1.1.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w=
github.com/jstemmer/go-junit-report/v2 v2.1.0 h1:X3+hPYlSczH9IMIpSC9CQSZA0L+BipYafciZUWHEmsc=
github.com/jstemmer/go-junit-report/v2 v2.1.0/go.mod h1:mgHVr7VUo5Tn8OLVr1cKnLuEy0M92wdRntM99h7RkgQ=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.20.3 h1:zWNoFqq/lSuYQRim3dUXlXeA0IqwuZGicdG5ZTfY5j4=
github.com/klauspost/compress v1.20.3/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/klauspost/cpuid/v2 v2.4.1 h1:I9KxadAkvo+QjtcEE2f2tyfEvmIla/T0m+14PrZNl0U=
@@ -317,10 +313,10 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/nats-io/jwt/v2 v2.8.2 h1:XXRgB60MSTnqsRwejQurVDs/hcv2dkt+86GjI+I/bMc=
github.com/nats-io/jwt/v2 v2.8.2/go.mod h1:Ag/56sq9OblL4JgdYufDd16Egb17Kr/8WwwuO/forVc=
github.com/nats-io/nats-server/v2 v2.15.1 h1:fK3AeYFIji1Smng60jB0hVoAPZezA/UzsaI3BqXFNEI=
github.com/nats-io/nats-server/v2 v2.15.1/go.mod h1:yeuYRtKVKIFtmMz2/YPk+6hvmNBnk+WwTUzErBdH6IE=
github.com/nats-io/nats.go v1.54.0 h1:vsXoOxjHp/GmPUN+EcI7uOf/uB+iAP+kEsAFNQN0yzA=
github.com/nats-io/nats.go v1.54.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k=
github.com/nats-io/nats-server/v2 v2.15.2 h1:yLsgt2kslgymvxvjFq1WGWck5hkat2YYfbXvQ145tzU=
github.com/nats-io/nats-server/v2 v2.15.2/go.mod h1:VXHCZgDglV5wO2B8ZE58yG2HzqxQxYUbQqibUh5Xcr8=
github.com/nats-io/nats.go v1.55.0 h1:Ng8QKHdvdHIWoaiq0+d/aH1BWkxvcJxWJ0Y/9BPKx3M=
github.com/nats-io/nats.go v1.55.0/go.mod h1:y+DZoD1oBOYfZTU681eTUiUjI0vbqYGixNVFHcjHJ0k=
github.com/nats-io/nkeys v0.4.16 h1:rd5oAuLOb8mnAycB0xleuEBNS1pVVnN0fv/FF34Eypg=
github.com/nats-io/nkeys v0.4.16/go.mod h1:llLgWoI0o4z/Q57q2R1kHfmocyhGV6VG/U18Glg1Afs=
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
@@ -340,7 +336,6 @@ github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8
github.com/pierrec/lz4/v4 v4.1.14/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pierrec/lz4/v4 v4.1.18 h1:xaKrnTkyoqfh1YItXl56+6KJNVYWlEEPuAQW9xsplYQ=
github.com/pierrec/lz4/v4 v4.1.18/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
@@ -409,17 +404,15 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 h1:ilQV1hzziu+LLM3zUTJ0trRztfwgjqKnBWNtSRkbmwM=
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78/go.mod h1:aL8wCCfTfSfmXjznFBSZNN13rSJjlIOI1fUNAtF7rmI=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.etcd.io/bbolt v1.5.0 h1:S7GAl7Fxv12yohbwFfIbQCGDWbQbtDGPET4P/bD4lxU=
go.etcd.io/bbolt v1.5.0/go.mod h1:mkltfYE5aUHQxUct9N9V+Kp7aSjFqjgrhcXIS70Lrdk=
go.etcd.io/etcd/api/v3 v3.5.35 h1:lB//Av9mFrkljv94XDNAY+qiyQA9W6S/TTA2Uwx2jsI=
go.etcd.io/etcd/api/v3 v3.5.35/go.mod h1:z1FGQolu/tpmQ2MPFwQNt7G3cXBiSkTevZBhfOtEArQ=
go.etcd.io/etcd/client/pkg/v3 v3.5.35 h1:nUZutZyJVvsKddK6zddXMBxIhmVi0SzPMBRS0FnNM3o=
go.etcd.io/etcd/client/pkg/v3 v3.5.35/go.mod h1:0MNjMMqajrOI0/l8OLFkJBhE5Vm6BAi7cerKlrO38hw=
go.etcd.io/etcd/client/v3 v3.5.35 h1:dk0aSLjY5D1eOR0SNnPXWOWCVA9yIzJoB8a912qxmoI=
go.etcd.io/etcd/client/v3 v3.5.35/go.mod h1:LjtCl6G5li7PgsGjoQIpYXz/OvSbI1pCxpSp1kqo9P4=
go.etcd.io/etcd/api/v3 v3.7.3 h1:2tumepXe+wi7Wab64uUO71K7ISQyUWLGC1DJbbc59sY=
go.etcd.io/etcd/api/v3 v3.7.3/go.mod h1:HOR9Qn4gb/1/hVAmYprmlLpvTe6ZgfgNS9Z/+Ty65po=
go.etcd.io/etcd/client/pkg/v3 v3.7.3 h1:5zMwNu3ycrzCeA5NwOfQbxFRhqq974chq1DrYMua4QI=
go.etcd.io/etcd/client/pkg/v3 v3.7.3/go.mod h1:/OhzqYrF0U7d1NOk55VMyUQGbYD1WoIe4bJwxb47wOk=
go.etcd.io/etcd/client/v3 v3.7.3 h1:UrXcetgkuSEocaBlQlulQRJlXLEnUAHuBRbHh0lue1Q=
go.etcd.io/etcd/client/v3 v3.7.3/go.mod h1:DcPxpqcpV8WIaRi/2waPCx0/Uq8rQUweDJaDYZk1gEE=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/bridges/otelslog v0.21.0 h1:/CdgE52i1OEPHhsGUqrggYNRaZy4v4YgVenGd52dRBI=
@@ -456,26 +449,22 @@ go.opentelemetry.io/otel/trace v1.47.0 h1:JOjX/Oci8K94QHddo+bbfya/Ai/nf6/dt9ZfrF
go.opentelemetry.io/otel/trace v1.47.0/go.mod h1:jNaSLa2PZEYFG6fRjJABAu+bw4FS08uDmPg28lTghu0=
go.opentelemetry.io/proto/otlp v1.11.0 h1:5rrYs0Ykyj50sdU/JU0x8etU+LubXWb+gED6TbEdMIk=
go.opentelemetry.io/proto/otlp v1.11.0/go.mod h1:SmVizdCOAm3XBtG1g1NnOdhW6jtddT72hLMhv8VwA8E=
go.uber.org/atomic v1.7.0 h1:ADUqmZGgLDDfbSL9ZmPxKTybcoEYHgpYfELNoN+7hsw=
go.uber.org/atomic v1.7.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/goleak v1.3.1-0.20260602155401-5f791e3ef83b h1:FHYZT12VzXnb6P399kd1i9Ai7aLF+CFSUsOFylDt7Lk=
go.uber.org/goleak v1.3.1-0.20260602155401-5f791e3ef83b/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.uber.org/multierr v1.6.0 h1:y6IPFStTAIT5Ytl7/XYmHvzXQ7S3g/IeZW9hyZ5thw4=
go.uber.org/multierr v1.6.0/go.mod h1:cdWPpRnG4AhwMwsgIHip0KRBQjJy5kYEpYjJxpXp9iU=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/nilaway v0.0.0-20260721205819-4a0f7653488a h1:CsRd824LAzMmlpu9nYv2PHOqPQYv4Zjd4kGM+g0ahS0=
go.uber.org/nilaway v0.0.0-20260721205819-4a0f7653488a/go.mod h1:i9Hi8GeZFLBtUCXP73ZNSHKZ4K2JtxT0pLmo3sbJzlo=
go.uber.org/zap v1.17.0 h1:MTjgFu6ZLKvY6Pvaqk97GlxNBuMpV4Hy/3P6tRGlI2U=
go.uber.org/zap v1.17.0/go.mod h1:MXVU+bhUf/A7Xi2HNOnopQOrmycQ5Ih87HtOu4q5SSo=
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
go.yaml.in/yaml/v3 v3.0.6 h1:zcDfvjXEqTQmpjniUBLc91xYJMB2c4QOFxEF/LCEckM=
go.yaml.in/yaml/v3 v3.0.6/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.58.0 h1:COkYLr4k7nDI4r6QtMML2AfzeIswWHAJthLS6K/M/54=
golang.org/x/crypto v0.58.0/go.mod h1:Xh+kl5A+M0gMiWDytH2Hhr5Z7WbNCpv/eB/EFgfSk+w=
@@ -483,33 +472,24 @@ golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f h1:XdNn9LlyWAhLVp6P/i8QYBW+h
golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f/go.mod h1:D5SMRVC3C2/4+F/DB1wZsLRnSNimn2Sp/NPsCrsv8ak=
golang.org/x/exp/typeparams v0.0.0-20260611194520-c48552f49976 h1:GTD/WuaexTazIG/SxLOz4rEKZPDVilmVVC2nz4xhwfE=
golang.org/x/exp/typeparams v0.0.0-20260611194520-c48552f49976/go.mod h1:PqrXSW65cXDZH0k4IeUbhmg/bcAZDbzNz3byBpKCsXo=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.42.0 h1:ICeL3t2L30ljSPHHxDnR4fqVcBt/zR2zJAb9juavmQg=
golang.org/x/mod v0.42.0/go.mod h1:u5fzIrRInkL8MaO1cbRkUHT64v303YIT5KEqpNr7jRs=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.61.0 h1:RR+6j/BTBCrggOLugxa+66R8FvoN6MHlzDlZIbN9YFw=
golang.org/x/net v0.61.0/go.mod h1:WgDCOTH8iwtB66T4/GfI94AHo/hhdb7Hah9NDseQfiI=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.24.0 h1:iqiDrGoDgyuvAKrhLQthUEzU/R378N78gXbBjxed7oA=
golang.org/x/sync v0.24.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -542,8 +522,6 @@ golang.org/x/time v0.16.0 h1:vMb6ptszcQMkcwiRTAuNNU50gom6++Q/6gY2hDM6VDE=
golang.org/x/time v0.16.0/go.mod h1:rVKOqvZeKvrDKTQiAHJ7wmwP0RzleSphoEA9RcdLA0s=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.52.0 h1:ryxg4U63oaUYk7uOLTF37n43f4qgtGvqV4Ioit1BaEs=
@@ -555,17 +533,14 @@ golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGN
golang.org/x/vuln v1.6.0 h1:FeMO9Rm/HwyduOztbvKcOw+zvDEPr4I4aQNSfevFcKY=
golang.org/x/vuln v1.6.0/go.mod h1:bWlG2493/sjR7ksvicBgMrznH3eYQEyK8ifUYBrqUbg=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/genproto/googleapis/api v0.0.0-20260928230214-8a89bd6388cc h1:rZHRz0ogq4Pid1IKtA/ivHi8XONVF5tqUQXA2TtgpkE=
google.golang.org/genproto/googleapis/api v0.0.0-20260928230214-8a89bd6388cc/go.mod h1:K/n0XLazCJHjD73YuMyNJsN38BPPy1kfFnKxUXaThFU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260928230214-8a89bd6388cc h1:4bNTbnb44EqGVy9HaQxSY2jnafSUdgV3qHtedvNpDKg=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260928230214-8a89bd6388cc/go.mod h1:OaIUM3+LpYcK2GXM4FTmhWoIq371Owdr+Cc7/BsYHHc=
google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU=
google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8=
google.golang.org/grpc v1.85.0 h1:BPBCfuVRQ1qvBhKDOq9uvQllMvO59rKsYhXpp7Dd8vw=
google.golang.org/grpc v1.85.0/go.mod h1:0rQrOD58yjal/0EaVbGBANindixsCZNQPDV3VKtaKuQ=
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -575,7 +550,7 @@ gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
pgregory.net/rapid v1.3.0 h1:vBvO0VSqti75J1jjYqpgPNBLKMd1+gxa9fYo7vk/Exc=
pgregory.net/rapid v1.3.0/go.mod h1:dPlE4OBBxgXPqkP79flB6sJL1dx5azpI7HQ9MY9Z7uk=
+1
View File
@@ -19,5 +19,6 @@ var modelFiles = map[Service]string{
RDS: "models/rds-2014-10-31.json.gz",
Route53: "models/route-53-2013-04-01.json.gz",
S3: "models/s3-2006-03-01.json.gz",
SQS: "models/sqs-2012-11-05.json.gz",
STS: "models/sts-2011-06-15.json.gz",
}
+1
View File
@@ -36,6 +36,7 @@ const (
RDS Service = "rds"
Route53 Service = "route53"
S3 Service = "s3"
SQS Service = "sqs"
STS Service = "sts"
)
+1 -1
View File
@@ -8,7 +8,7 @@ import (
)
func TestServices(t *testing.T) {
want := []Service{ACM, EC2, ECR, ECS, EKS, ElasticLoadBalancingV2, IAM, RDS, Route53, S3, STS}
want := []Service{ACM, EC2, ECR, ECS, EKS, ElasticLoadBalancingV2, IAM, RDS, Route53, S3, SQS, STS}
if got := Services(); !reflect.DeepEqual(got, want) {
t.Fatalf("Services() = %v, want %v", got, want)
}
Binary file not shown.
+96 -62
View File
@@ -1,6 +1,6 @@
// Package sqsworkergw is the shared SQS worker guest's SigV4 client for the
// gateway's internal spinifex-sqs service. It sends only public values: the
// worker's identity is the signing session, and the store seed never leaves
// worker's identity is the signing session, and the receipt key never leaves
// the guest.
package sqsworkergw
@@ -10,20 +10,21 @@ import (
"crypto/sha256"
"crypto/tls"
"crypto/x509"
"encoding/base64"
"encoding/hex"
"encoding/json"
"encoding/xml"
"errors"
"fmt"
"io"
"maps"
"net"
"net/http"
"net/url"
"strings"
"time"
"github.com/mulgadc/bluebottle/pkg/tlsconfig"
"github.com/nats-io/nkeys"
"github.com/mulgadc/spinifex/spinifex/domains/sqs/relay"
)
const (
@@ -33,9 +34,12 @@ const (
callTimeout = 30 * time.Second
maxResponse = 64 << 10
actionClaim = "ClaimFirstStore"
actionDescribe = "DescribeStore"
actionRecord = "RecordWorkerPublicKey"
actionClaim = "ClaimReceiptKey"
actionRecord = "RecordReceiptKey"
actionPoll = "PollWork"
actionComplete = "CompleteWork"
// maxPollResponse fits the gateway's 16 MiB poll batch as base64 XML.
maxPollResponse = 32 << 20
)
// Signer signs a request in place for service and region.
@@ -43,7 +47,7 @@ type Signer interface {
Sign(r *http.Request, payloadHash, service, region string) error
}
// Client calls the internal first-store actions for this worker.
// Client calls the internal SQS worker actions for this worker.
type Client struct {
baseURL string
region string
@@ -114,80 +118,72 @@ func (e *TransientError) Error() string { return e.Err.Error() }
func (e *TransientError) Unwrap() error { return e.Err }
func (e *TransientError) Retryable() bool { return true }
type claimResult struct {
CreateKey bool `xml:"CreateKey"`
PublicKey string `xml:"PublicKey"`
// ReceiptKey is the worker volume's receipt key initialization as the
// gateway reports it: pending authorizes creating or resuming one, otherwise
// Fingerprint names the key the disk must already hold.
type ReceiptKey struct {
Pending bool
Fingerprint string
// HostNodes is the gateway's declared cluster node count; a gateway
// that does not report one reads as zero.
HostNodes int
}
// Claim asks for this worker's current first-store decision. A decision
// that is neither "create" nor one valid public key is refused here.
func (c *Client) Claim(ctx context.Context) (bool, string, error) {
type claimResult struct {
Pending bool `xml:"Pending"`
Fingerprint string `xml:"Fingerprint"`
HostNodes int `xml:"HostNodes"`
}
// ClaimReceiptKey asks for the receipt key decision. A reply that is neither
// pending nor one valid fingerprint is refused here.
func (c *Client) ClaimReceiptKey(ctx context.Context) (ReceiptKey, error) {
var res claimResult
if err := c.call(ctx, actionClaim, nil, &res); err != nil {
return false, "", err
return ReceiptKey{}, err
}
switch {
case res.CreateKey && res.PublicKey == "":
return true, "", nil
case !res.CreateKey && nkeys.IsValidPublicUserKey(res.PublicKey):
return false, res.PublicKey, nil
if res.Pending == (res.Fingerprint != "") || (res.Fingerprint != "" && !ValidFingerprint(res.Fingerprint)) {
return ReceiptKey{}, errors.New("spinifex-sqs ClaimReceiptKey: response is not a valid decision")
}
return false, "", errors.New("spinifex-sqs ClaimFirstStore: response is not a valid decision")
return ReceiptKey(res), nil
}
// Store is the Region's store launch as the gateway reports it. Bound means
// a VM was launched for it, not that it is ready.
type Store struct {
Bound bool
Address string
PublicKey string
}
type describeResult struct {
State string `xml:"State"`
Address string `xml:"Address"`
PublicKey string `xml:"PublicKey"`
}
// DescribeStore reports this worker's store. A bound reply must carry an
// IPv4 address and a valid public key; a pending one must carry neither.
func (c *Client) DescribeStore(ctx context.Context) (Store, error) {
var res describeResult
if err := c.call(ctx, actionDescribe, nil, &res); err != nil {
return Store{}, err
// RecordReceiptKey records the key's fingerprint. Recording the same one
// again succeeds, so a lost reply is safe to repeat.
func (c *Client) RecordReceiptKey(ctx context.Context, fingerprint string) error {
if !ValidFingerprint(fingerprint) {
return errors.New("spinifex-sqs RecordReceiptKey: refusing to send a value that is not a fingerprint")
}
switch {
case res.State == "pending" && res.Address == "" && res.PublicKey == "":
return Store{}, nil
case res.State == "bound" && isIPv4(res.Address) && nkeys.IsValidPublicUserKey(res.PublicKey):
return Store{Bound: true, Address: res.Address, PublicKey: res.PublicKey}, nil
}
return Store{}, errors.New("spinifex-sqs DescribeStore: response is not a valid store state")
return c.call(ctx, actionRecord, url.Values{"Fingerprint": {fingerprint}}, nil)
}
func isIPv4(s string) bool {
ip := net.ParseIP(s)
return ip != nil && ip.To4() != nil && !strings.Contains(s, ":")
}
// RecordPublicKey records the worker's public key. Anything other than a
// user public key is refused before it is sent.
func (c *Client) RecordPublicKey(ctx context.Context, publicKey string) error {
if !nkeys.IsValidPublicUserKey(publicKey) {
return errors.New("spinifex-sqs RecordWorkerPublicKey: refusing to send a value that is not a user public key")
// ValidFingerprint reports a lowercase hex SHA-256 fingerprint.
func ValidFingerprint(s string) bool {
if len(s) != 2*sha256.Size || strings.ToLower(s) != s {
return false
}
return c.call(ctx, actionRecord, url.Values{"PublicKey": {publicKey}}, nil)
_, err := hex.DecodeString(s)
return err == nil
}
func (c *Client) call(ctx context.Context, action string, params url.Values, out any) error {
form := url.Values{"Action": {action}, "Version": {apiVersion}}
maps.Copy(form, params)
body := []byte(form.Encode())
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/", bytes.NewReader(body))
return c.do(ctx, action, "", []byte(form.Encode()), "application/x-www-form-urlencoded; charset=utf-8", maxResponse, out)
}
// do signs and sends one request. rawQuery, when set, carries the action and
// its arguments; the body hash is signed either way.
func (c *Client) do(ctx context.Context, action, rawQuery string, body []byte, contentType string, limit int64, out any) error {
target := c.baseURL + "/"
if rawQuery != "" {
target += "?" + rawQuery
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, target, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("spinifex-sqs %s: build request: %w", action, err)
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=utf-8")
req.Header.Set("Content-Type", contentType)
sum := sha256.Sum256(body)
if err := c.signer.Sign(req, hex.EncodeToString(sum[:]), signingService, c.region); err != nil {
return &TransientError{Err: fmt.Errorf("spinifex-sqs %s: sign: %w", action, err)}
@@ -197,7 +193,7 @@ func (c *Client) call(ctx context.Context, action string, params url.Values, out
return &TransientError{Err: fmt.Errorf("spinifex-sqs %s: send: %w", action, err)}
}
defer resp.Body.Close()
respBody, err := io.ReadAll(io.LimitReader(resp.Body, maxResponse))
respBody, err := io.ReadAll(io.LimitReader(resp.Body, limit))
if err != nil {
return &TransientError{Err: fmt.Errorf("spinifex-sqs %s: read response: %w", action, err)}
}
@@ -210,6 +206,44 @@ func (c *Client) call(ctx context.Context, action string, params url.Values, out
return decodeResult(action, respBody, out)
}
// PollWork long-polls the gateway for owner calls queued for this worker.
// An empty list is a poll that found nothing.
func (c *Client) PollWork(ctx context.Context) ([]relay.Work, error) {
var res struct {
Items []string `xml:"Items>member"`
}
if err := c.do(ctx, actionPoll, "", []byte(url.Values{"Action": {actionPoll}, "Version": {apiVersion}}.Encode()),
"application/x-www-form-urlencoded; charset=utf-8", maxPollResponse, &res); err != nil {
return nil, err
}
out := make([]relay.Work, 0, len(res.Items))
for _, item := range res.Items {
raw, err := base64.StdEncoding.DecodeString(item)
if err != nil {
return nil, errors.New("spinifex-sqs PollWork: item is not base64")
}
var w relay.Work
if err := json.Unmarshal(raw, &w); err != nil || w.ID == "" || w.Op == "" {
return nil, errors.New("spinifex-sqs PollWork: item is not a work item")
}
out = append(out, w)
}
return out, nil
}
// CompleteWork posts an encoded result for work item id and returns the
// gateway's outcome name. Any error leaves delivery unknown.
func (c *Client) CompleteWork(ctx context.Context, id string, result []byte) (string, error) {
q := url.Values{"Action": {actionComplete}, "Version": {apiVersion}, "WorkId": {id}}
var res struct {
Outcome string `xml:"Outcome"`
}
if err := c.do(ctx, actionComplete, q.Encode(), result, "application/json", maxResponse, &res); err != nil {
return "", err
}
return res.Outcome, nil
}
func decodeResult(action string, body []byte, out any) error {
want := action + "Result"
dec := xml.NewDecoder(bytes.NewReader(body))
+36 -95
View File
@@ -10,8 +10,6 @@ import (
"strings"
"sync/atomic"
"testing"
"github.com/nats-io/nkeys"
)
type nopSigner struct{ err error }
@@ -24,15 +22,10 @@ func (s nopSigner) Sign(r *http.Request, _, service, region string) error {
return nil
}
func userKey(t *testing.T) (pub, seed string) {
t.Helper()
kp, err := nkeys.CreateUser()
if err != nil {
t.Fatal(err)
}
p, _ := kp.PublicKey()
s, _ := kp.Seed()
return p, string(s)
var fp = strings.Repeat("ab", 32)
func claimBody(inner string) string {
return `<ClaimReceiptKeyResponse><ClaimReceiptKeyResult>` + inner + `</ClaimReceiptKeyResult></ClaimReceiptKeyResponse>`
}
// server answers with body and status, counting and keeping requests.
@@ -73,63 +66,56 @@ func TestNew_RefusesWithoutHTTPSRegionCAOrSigner(t *testing.T) {
}
func TestClaim_SignsForTheInternalServiceAndRegion(t *testing.T) {
c, _, seen := server(t, 200, `<ClaimFirstStoreResponse><ClaimFirstStoreResult><CreateKey>true</CreateKey></ClaimFirstStoreResult></ClaimFirstStoreResponse>`)
create, pub, err := c.Claim(context.Background())
if err != nil || !create || pub != "" {
t.Fatalf("claim = %v %q %v", create, pub, err)
c, _, seen := server(t, 200, claimBody(`<Pending>true</Pending><HostNodes>1</HostNodes>`))
got, err := c.ClaimReceiptKey(context.Background())
if err != nil || !got.Pending || got.Fingerprint != "" || got.HostNodes != 1 {
t.Fatalf("claim = %+v %v", got, err)
}
if got := (*seen)[0]; !strings.HasPrefix(got, "test spinifex-sqs/ap-southeast-2|") || !strings.Contains(got, "Action=ClaimFirstStore") {
t.Fatalf("request = %q", got)
if req := (*seen)[0]; !strings.HasPrefix(req, "test spinifex-sqs/ap-southeast-2|") || !strings.Contains(req, "Action=ClaimReceiptKey") {
t.Fatalf("request = %q", req)
}
}
// Only "create" or exactly one valid public key is a decision.
// Only pending alone, or exactly one valid fingerprint, is a decision.
func TestClaim_RefusesAnythingButOneDecision(t *testing.T) {
pub, seed := userKey(t)
cases := map[string]string{
"create with a key": `<CreateKey>true</CreateKey><PublicKey>` + pub + `</PublicKey>`,
"neither": `<CreateKey>false</CreateKey>`,
"seed as key": `<CreateKey>false</CreateKey><PublicKey>` + seed + `</PublicKey>`,
"no result": ``,
}
for name, inner := range cases {
for name, inner := range map[string]string{
"pending with a key": `<Pending>true</Pending><Fingerprint>` + fp + `</Fingerprint>`,
"neither": `<Pending>false</Pending>`,
"uppercase key": `<Pending>false</Pending><Fingerprint>` + strings.ToUpper(fp) + `</Fingerprint>`,
"short key": `<Pending>false</Pending><Fingerprint>` + fp[:62] + `</Fingerprint>`,
"not the claim reply": "",
} {
t.Run(name, func(t *testing.T) {
body := `<ClaimFirstStoreResponse><ClaimFirstStoreResult>` + inner + `</ClaimFirstStoreResult></ClaimFirstStoreResponse>`
body := claimBody(inner)
if inner == "" {
body = `<Other/>`
}
c, _, _ := server(t, 200, body)
_, _, err := c.Claim(context.Background())
if err == nil {
if _, err := c.ClaimReceiptKey(context.Background()); err == nil {
t.Fatal("accepted a non-decision")
}
if strings.Contains(err.Error(), seed) {
t.Fatal("the error echoes a seed")
}
})
}
t.Run("existing", func(t *testing.T) {
c, _, _ := server(t, 200, `<ClaimFirstStoreResponse><ClaimFirstStoreResult><CreateKey>false</CreateKey><PublicKey>`+pub+`</PublicKey></ClaimFirstStoreResult></ClaimFirstStoreResponse>`)
create, got, err := c.Claim(context.Background())
if err != nil || create || got != pub {
t.Fatalf("claim = %v %q %v", create, got, err)
}
})
c, _, _ := server(t, 200, claimBody(`<Pending>false</Pending><Fingerprint>`+fp+`</Fingerprint>`))
got, err := c.ClaimReceiptKey(context.Background())
if err != nil || got.Pending || got.Fingerprint != fp {
t.Fatalf("recorded claim = %+v %v", got, err)
}
}
func TestRecordPublicKey_SendsOnlyAPublicKey(t *testing.T) {
pub, seed := userKey(t)
c, n, seen := server(t, 200, `<RecordWorkerPublicKeyResponse/>`)
if err := c.RecordPublicKey(context.Background(), seed); err == nil || strings.Contains(err.Error(), seed) {
t.Fatalf("seed err = %v", err)
func TestRecordReceiptKey_SendsOnlyAFingerprint(t *testing.T) {
c, n, seen := server(t, 200, `<RecordReceiptKeyResponse/>`)
const raw = "not-a-fingerprint-and-maybe-key-material"
if err := c.RecordReceiptKey(context.Background(), raw); err == nil || strings.Contains(err.Error(), raw) {
t.Fatalf("invalid err = %v", err)
}
if n.Load() != 0 {
t.Fatal("a seed was sent")
t.Fatal("a non-fingerprint was sent")
}
if err := c.RecordPublicKey(context.Background(), pub); err != nil {
if err := c.RecordReceiptKey(context.Background(), fp); err != nil {
t.Fatal(err)
}
if !strings.Contains((*seen)[0], "PublicKey="+pub) {
if !strings.Contains((*seen)[0], "Fingerprint="+fp) {
t.Fatalf("request = %q", (*seen)[0])
}
}
@@ -151,7 +137,7 @@ func TestErrors_ClassifiedWithoutEchoingTheBody(t *testing.T) {
}
for _, tc := range cases {
c, _, _ := server(t, tc.status, `<ErrorResponse><Error><Code>`+tc.code+`</Code><Message>secret-ish detail</Message></Error></ErrorResponse>`)
_, _, err := c.Claim(context.Background())
_, err := c.ClaimReceiptKey(context.Background())
api, ok := errors.AsType[*APIError](err)
if !ok || api.Code != tc.code || api.Retryable() != tc.retryable || api.Pending() != (tc.code == "AccessDenied") {
t.Fatalf("%s: err = %v", tc.code, err)
@@ -163,7 +149,7 @@ func TestErrors_ClassifiedWithoutEchoingTheBody(t *testing.T) {
c, _, _ := server(t, 200, "")
c.baseURL = "https://127.0.0.1:1"
_, _, err := c.Claim(context.Background())
_, err := c.ClaimReceiptKey(context.Background())
tr, ok := errors.AsType[*TransientError](err)
if !ok || !tr.Retryable() {
t.Fatalf("unreachable err = %v, want TransientError", err)
@@ -181,55 +167,10 @@ func TestCall_DoesNotFollowRedirects(t *testing.T) {
}))
t.Cleanup(srv.Close)
c.baseURL = srv.URL
if _, _, err := c.Claim(context.Background()); err == nil {
if _, err := c.ClaimReceiptKey(context.Background()); err == nil {
t.Fatal("a redirect was accepted as a decision")
}
if followed.Load() {
t.Fatal("the signed request followed a redirect")
}
}
// Only "pending" with nothing else, or "bound" with an IPv4 address and one
// valid public key, is a store state.
func TestDescribeStore_AcceptsOnlyAValidState(t *testing.T) {
pub, seed := userKey(t)
wrap := func(inner string) string {
return `<DescribeStoreResponse><DescribeStoreResult>` + inner + `</DescribeStoreResult></DescribeStoreResponse>`
}
t.Run("pending", func(t *testing.T) {
c, _, seen := server(t, 200, wrap(`<State>pending</State>`))
st, err := c.DescribeStore(context.Background())
if err != nil || st.Bound {
t.Fatalf("store = %+v %v", st, err)
}
if !strings.Contains((*seen)[0], "Action=DescribeStore") {
t.Fatalf("request = %q", (*seen)[0])
}
})
t.Run("bound", func(t *testing.T) {
c, _, _ := server(t, 200, wrap(`<State>bound</State><Address>10.240.0.9</Address><PublicKey>`+pub+`</PublicKey>`))
st, err := c.DescribeStore(context.Background())
if err != nil || !st.Bound || st.Address != "10.240.0.9" || st.PublicKey != pub {
t.Fatalf("store = %+v %v", st, err)
}
})
for name, inner := range map[string]string{
"pending with address": `<State>pending</State><Address>10.240.0.9</Address>`,
"bound without key": `<State>bound</State><Address>10.240.0.9</Address>`,
"bound with seed": `<State>bound</State><Address>10.240.0.9</Address><PublicKey>` + seed + `</PublicKey>`,
"bound with hostname": `<State>bound</State><Address>store.example</Address><PublicKey>` + pub + `</PublicKey>`,
"bound with IPv6": `<State>bound</State><Address>::ffff:10.240.0.9</Address><PublicKey>` + pub + `</PublicKey>`,
"unknown state": `<State>ready</State>`,
} {
t.Run(name, func(t *testing.T) {
c, _, _ := server(t, 200, wrap(inner))
_, err := c.DescribeStore(context.Background())
if err == nil {
t.Fatal("accepted an invalid store state")
}
if strings.Contains(err.Error(), seed) {
t.Fatal("the error echoes a seed")
}
})
}
}
+7
View File
@@ -0,0 +1,7 @@
// Package deadpid gives tests a PID that no process can hold.
package deadpid
// PID is above the kernel's PID_MAX_LIMIT (4194304), so kill(2) on it always
// fails with ESRCH. A plausible-looking literal such as 99999 is a real PID on
// a busy host, and signalling it terminates whatever process holds it.
const PID = 1 << 30
+6 -15
View File
@@ -11,7 +11,6 @@ import (
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"errors"
"math/big"
"net"
"os"
@@ -70,7 +69,7 @@ func Start(t *testing.T, selfID uint64, members []Member) *Server {
})
s := &Server{Endpoint: "https://" + lis.Addr().String(), CertDir: dir, members: members, selfID: selfID}
gs := grpc.NewServer(grpc.Creds(creds))
pb.RegisterClusterServer(gs, &cluster{s})
pb.RegisterClusterServer(gs, &cluster{s: s})
pb.RegisterMaintenanceServer(gs, &maintenance{s: s})
go func() { _ = gs.Serve(lis) }()
t.Cleanup(gs.Stop)
@@ -91,7 +90,11 @@ func (s *Server) FailWith(err error) {
s.failAll = err
}
type cluster struct{ s *Server }
type cluster struct {
pb.UnimplementedClusterServer
s *Server
}
func (c *cluster) MemberList(context.Context, *pb.MemberListRequest) (*pb.MemberListResponse, error) {
c.s.mu.Lock()
@@ -116,18 +119,6 @@ func (c *cluster) MemberRemove(_ context.Context, req *pb.MemberRemoveRequest) (
return &pb.MemberRemoveResponse{Header: &pb.ResponseHeader{MemberId: c.s.selfID}}, nil
}
func (c *cluster) MemberAdd(context.Context, *pb.MemberAddRequest) (*pb.MemberAddResponse, error) {
return nil, errors.New("not implemented")
}
func (c *cluster) MemberUpdate(context.Context, *pb.MemberUpdateRequest) (*pb.MemberUpdateResponse, error) {
return nil, errors.New("not implemented")
}
func (c *cluster) MemberPromote(context.Context, *pb.MemberPromoteRequest) (*pb.MemberPromoteResponse, error) {
return nil, errors.New("not implemented")
}
type maintenance struct {
pb.UnimplementedMaintenanceServer
+108
View File
@@ -0,0 +1,108 @@
package testutil
import (
"context"
"sync"
"github.com/nats-io/nats.go/jetstream"
)
// StaleReadKV wraps a bucket so that the first Get of a key after a write to it
// returns the entry as it stood before that write, as a lagging replica does
// when it answers a direct get. Later Gets see the current entry.
type StaleReadKV struct {
jetstream.KeyValue
mu sync.Mutex
stale map[string]jetstream.KeyValueEntry
}
var _ jetstream.KeyValue = (*StaleReadKV)(nil)
// NewStaleReadKV wraps kv.
func NewStaleReadKV(kv jetstream.KeyValue) *StaleReadKV {
return &StaleReadKV{KeyValue: kv, stale: map[string]jetstream.KeyValueEntry{}}
}
// remember captures the entry about to be overwritten.
func (k *StaleReadKV) remember(ctx context.Context, key string) jetstream.KeyValueEntry {
prev, err := k.KeyValue.Get(ctx, key)
if err != nil {
return nil
}
return prev
}
func (k *StaleReadKV) arm(key string, prev jetstream.KeyValueEntry) {
if prev == nil {
return
}
k.mu.Lock()
k.stale[key] = prev
k.mu.Unlock()
}
func (k *StaleReadKV) Get(ctx context.Context, key string) (jetstream.KeyValueEntry, error) {
k.mu.Lock()
prev, ok := k.stale[key]
delete(k.stale, key)
k.mu.Unlock()
if ok {
return prev, nil
}
return k.KeyValue.Get(ctx, key)
}
func (k *StaleReadKV) Update(ctx context.Context, key string, value []byte, last uint64) (uint64, error) {
prev := k.remember(ctx, key)
rev, err := k.KeyValue.Update(ctx, key, value, last)
if err == nil {
k.arm(key, prev)
}
return rev, err
}
func (k *StaleReadKV) Put(ctx context.Context, key string, value []byte) (uint64, error) {
prev := k.remember(ctx, key)
rev, err := k.KeyValue.Put(ctx, key, value)
if err == nil {
k.arm(key, prev)
}
return rev, err
}
// RejectingKV wraps a bucket so that writes for which reject returns an error
// fail with it, and every other operation passes through.
type RejectingKV struct {
jetstream.KeyValue
reject func(key string, value []byte) error
}
var _ jetstream.KeyValue = (*RejectingKV)(nil)
// NewRejectingKV wraps kv. reject runs before each Put, Create and Update.
func NewRejectingKV(kv jetstream.KeyValue, reject func(key string, value []byte) error) *RejectingKV {
return &RejectingKV{KeyValue: kv, reject: reject}
}
func (k *RejectingKV) Put(ctx context.Context, key string, value []byte) (uint64, error) {
if err := k.reject(key, value); err != nil {
return 0, err
}
return k.KeyValue.Put(ctx, key, value)
}
func (k *RejectingKV) Create(ctx context.Context, key string, value []byte, opts ...jetstream.KVCreateOpt) (uint64, error) {
if err := k.reject(key, value); err != nil {
return 0, err
}
return k.KeyValue.Create(ctx, key, value, opts...)
}
func (k *RejectingKV) Update(ctx context.Context, key string, value []byte, last uint64) (uint64, error) {
if err := k.reject(key, value); err != nil {
return 0, err
}
return k.KeyValue.Update(ctx, key, value, last)
}
+9 -43
View File
@@ -7,17 +7,14 @@ package pagedstore
import (
"context"
"sort"
"strconv"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/s3"
"github.com/mulgadc/spinifex/spinifex/providers/objectstore"
)
// Store wraps a MemoryObjectStore and splits ListObjectsV2's answer into
// pages of at most PageSize objects, sorted by key so pagination is
// deterministic across runs (MemoryObjectStore itself iterates a map).
// Store wraps a MemoryObjectStore whose ListObjectsV2 pages like S3, capping
// every page at PageSize entries so small fixtures span several pages.
type Store struct {
*objectstore.MemoryObjectStore
@@ -28,51 +25,20 @@ type Store struct {
Calls int
}
// New returns a paging store with the given per-page object limit.
// New returns an empty Store that serves at most pageSize entries per page.
func New(pageSize int) *Store {
return &Store{MemoryObjectStore: objectstore.NewMemoryObjectStore(), PageSize: pageSize}
}
var _ objectstore.ObjectStore = (*Store)(nil)
// ListObjectsV2 delegates to MemoryObjectStore for prefix/delimiter matching,
// then slices the sorted result into one page per call, resuming from the
// offset encoded in ContinuationToken.
// ListObjectsV2 lowers the caller's MaxKeys to PageSize and lets the memory
// store page, so tokens, ordering and common prefixes follow S3's rules.
func (s *Store) ListObjectsV2(ctx context.Context, input *s3.ListObjectsV2Input) (*s3.ListObjectsV2Output, error) {
s.Calls++
full, err := s.MemoryObjectStore.ListObjectsV2(ctx, input)
if err != nil {
return nil, err
page := *input
if page.MaxKeys == nil || *page.MaxKeys > int64(s.PageSize) {
page.MaxKeys = aws.Int64(int64(s.PageSize))
}
sort.Slice(full.Contents, func(i, j int) bool {
return aws.StringValue(full.Contents[i].Key) < aws.StringValue(full.Contents[j].Key)
})
start := 0
if tok := aws.StringValue(input.ContinuationToken); tok != "" {
start, err = strconv.Atoi(tok)
if err != nil {
return nil, err
}
}
end := min(start+s.PageSize, len(full.Contents))
if start > end {
start = end
}
page := &s3.ListObjectsV2Output{
Contents: full.Contents[start:end],
CommonPrefixes: full.CommonPrefixes,
Name: input.Bucket,
KeyCount: aws.Int64(int64(end - start)),
}
if end < len(full.Contents) {
page.IsTruncated = aws.Bool(true)
page.NextContinuationToken = aws.String(strconv.Itoa(end))
} else {
page.IsTruncated = aws.Bool(false)
}
return page, nil
return s.MemoryObjectStore.ListObjectsV2(ctx, &page)
}
+1 -1
View File
@@ -6,7 +6,7 @@ Node installation, dev-environment lifecycle, guest image builds, and verificati
| Script | What it does |
|---|---|
| `setup.sh` | The production binary installer, served from `install.mulgadc.com`. Installs deps, users, sudoers, the host firewall policy, files, systemd units, logrotate and udev rules, and loads `openvswitch`, `geneve`, `vhost_net` and `tun`, listing them in `/etc/modules-load.d/spinifex.conf` for later boots. It masks firewalld wherever it is installed, since the Spinifex nftables policy is the only supported node firewall. It writes a needrestart drop-in so an apt upgrade never restarts the Spinifex, OVS or OVN units; restarting them after an upgrade is left to the operator. An unset `--firewall` keeps the mode recorded in `/etc/spinifex/firewall/mode`, and otherwise arms the policy when firewalld was active. Where systemd-resolved is installed but not running, resolvconf is absent and NetworkManager runs, it hands host DNS to resolved: it saves `/etc/resolv.conf` once to `/var/lib/spinifex-reset/resolv.conf.orig`, links `/etc/resolv.conf` to the resolved stub, and stops unless resolved has upstream servers and the package repository host still resolves. It links `/usr/bin/spx` to `/usr/local/bin/spx` where sudo's PATH leaves out `/usr/local/bin`, and takes the nbdkit plugin directory from `nbdkit --dump-config`, stopping when nbdkit cannot say. `SETUP_STAGES` runs a subset; `ISO_BUILD=1` runs it inside the ISO builder's chroot. |
| `setup.sh` | The production binary installer, served from `install.mulgadc.com`. Installs deps, users, sudoers, the host firewall policy, files, systemd units, logrotate and udev rules, and loads `openvswitch`, `geneve`, `vhost_net` and `tun`, listing them in `/etc/modules-load.d/spinifex.conf` for later boots. It masks firewalld wherever it is installed, since the Spinifex nftables policy is the only supported node firewall. It writes a needrestart drop-in so an apt upgrade never restarts the Spinifex, OVS or OVN units; restarting them after an upgrade is left to the operator. An unset `--firewall` keeps the mode recorded in `/etc/spinifex/firewall/mode`, and otherwise arms the policy when firewalld was active. Where systemd-resolved is installed but not running, resolvconf is absent and NetworkManager runs, it hands host DNS to resolved: it saves `/etc/resolv.conf` once to `/var/lib/spinifex-reset/resolv.conf.orig`, writes `/etc/NetworkManager/conf.d/90-spinifex-dns.conf` (`dns=systemd-resolved`, `rc-manager=unmanaged`) so NetworkManager never rewrites `/etc/resolv.conf`, links `/etc/resolv.conf` to the resolved stub, and stops unless the link holds, resolved has upstream servers and the package repository host still resolves. A rerun after that handover adds a missing drop-in, reloads NetworkManager and relinks the stub. It links `/usr/bin/spx` to `/usr/local/bin/spx` where sudo's PATH leaves out `/usr/local/bin`, and takes the nbdkit plugin directory from `nbdkit --dump-config`, stopping when nbdkit cannot say. `SETUP_STAGES` runs a subset; `ISO_BUILD=1` runs it inside the ISO builder's chroot. |
| `setup-ovn.sh` | Bootstraps a compute node for OVN VPC networking: installs OVN/OVS, creates `br-int`, configures the WAN bridge (bridged, DHCP, or routed-NAT uplink), sets the chassis identity, and applies overlay sysctls. It runs OVS and OVN as root, setting `OVS_USER_ID` and `OVN_USER_ID` where `/etc/sysconfig` holds them, and hooks the socket-permission helper onto `ovs-vswitchd`, `ovn-controller`, `openvswitch-ipsec` and, on EL, `ovn-northd`. `--management` also starts the OVN central services, writes their `ovn-ctl` flags to `/etc/default/ovn-central` (Debian) or `/etc/sysconfig/ovn-northd` (EL), and records the role in `/etc/spinifex/ovn-role`. With clustered databases it returns only once this node's NB and SB members are back in their RAFT clusters under a known leader, and fails after `OVN_RAFT_REJOIN_WAIT` seconds (default 60) otherwise, so reruns one node at a time never take two members of a cluster down. A later run without `--management` refuses while that file, or the `OVN_CTL_OPTS` line older nodes carry in `/etc/default/ovn-central`, records the role. It adds `maxsize 100M` to the OVS and OVN logrotate files, plus `rotate 5` where the file sets no count, and rotates as root, matching the daemons. On a host whose IPsec stack is libreswan it enables, stops and masks `openvswitch-ipsec.service` and `ipsec.service` for the daemon to unmask when IPsec is on, unless `openvswitch-ipsec.service` is already running; elsewhere it enables and starts `openvswitch-ipsec.service`, unless the daemon has masked it to keep IPsec off. The links it creates persist through systemd-networkd where that is enabled, and otherwise through `/etc/spinifex/hostnet.json`, which `spinifex-hostnet.service` (`spx admin hostnet apply`) replays at boot and on every OVS start, starting `ovn-controller` again when an OVS stop took it down; the first run records the choice in `/etc/spinifex/hostnet-backend`. Where NetworkManager runs, it leaves the links Spinifex owns unmanaged. |
| `install-node.sh` | Forms a multi-node cluster across hosts that are already running Spinifex: rebuilds the OVN database in clustered form, runs `spx admin init` on the first host and `spx admin join` on the rest, then verifies. Before touching any host it refuses hosts of different OS families (`distro_os_family`) or `spx` versions, and `--ipsec on` when any host uses libreswan. The scripted form of [install-multi-node](../docs/install/install-multi-node/README.md). Destroys the joining nodes' logical network state and crypto — see its `--help`. |
| `dev-install.sh` | Full local dev setup through the production installer: builds from source, packs a tarball, runs `setup.sh`, initialises the cluster, and starts services under systemd. |
+97
View File
@@ -0,0 +1,97 @@
#!/bin/bash
# build-lambda-runtime-microvm-image.sh — build the Lambda runtime microVM kernel
# + initramfs: a guest running the Lambda guest agent, with virtio-net for its
# host control connection. It is not shipped.
#
# Inputs:
# $REPO_ROOT/build/microvm/lambda-runtime/{init.sh,inittab,run-agent}
# $REPO_ROOT/bin/lambda-agent (make bin/lambda-agent)
#
# Outputs:
# $REPO_ROOT/build/microvm/lambda-runtime/vmlinuz
# $REPO_ROOT/build/microvm/lambda-runtime/initramfs.cpio.gz
#
# Rootfs strategy and module stripping: scripts/lib/microvm-image.sh.
# shellcheck disable=SC2034 # variables are consumed by the sourced library
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
BUILD_DIR="$REPO_ROOT/build/microvm/lambda-runtime"
INIT_SH="$BUILD_DIR/init.sh"
INITTAB="$BUILD_DIR/inittab"
RUN_AGENT="$BUILD_DIR/run-agent"
LAMBDA_AGENT_BIN="$REPO_ROOT/bin/lambda-agent"
TAG="[build-lambda-runtime-microvm-image]"
ALPINE_VERSION="${ALPINE_VERSION:-3.24}"
ALPINE_MIRROR="${ALPINE_MIRROR:-https://dl-cdn.alpinelinux.org/alpine}"
# kmod: the image's own depmod/modprobe rebuild module metadata. busybox ships ip.
ALPINE_PACKAGES="busybox linux-virt kmod ca-certificates"
# tiny_power_button turns the ACPI power button into a signal busybox init
# handles as poweroff.
MICROVM_EXTRA_MODULES="${MICROVM_EXTRA_MODULES:-tiny_power_button}"
# virtio_net needs failover and net_failover, which the virtio* glob misses.
MICROVM_EXTRA_KEEP_GLOBS=("*failover*.ko*")
MICROVM_GATE_MODULES="qemu_fw_cfg virtio_net"
# shellcheck source=lib/microvm-image.sh
source "$REPO_ROOT/scripts/lib/microvm-image.sh"
echo "$TAG repo root: $REPO_ROOT"
echo "$TAG build dir: $BUILD_DIR"
microvm_check_tools
for f in "$INIT_SH" "$INITTAB" "$RUN_AGENT"; do
if [ ! -f "$f" ]; then
echo "ERROR: $f not found — cannot build initramfs" >&2
exit 1
fi
done
if [ ! -x "$LAMBDA_AGENT_BIN" ]; then
echo "ERROR: $LAMBDA_AGENT_BIN not found — run 'make bin/lambda-agent'" >&2
exit 1
fi
if ! file -b "$LAMBDA_AGENT_BIN" | grep -q "statically linked"; then
echo "ERROR: $LAMBDA_AGENT_BIN is not statically linked — it cannot run on the musl guest" >&2
exit 1
fi
microvm_init_dirs
microvm_make_rootfs
microvm_empty_dev
echo "$TAG installing init, inittab, run-agent and lambda-agent..."
install -m 0755 "$INIT_SH" "$CHROOT_DIR/init"
install -m 0644 "$INITTAB" "$CHROOT_DIR/etc/inittab"
mkdir -p "$CHROOT_DIR/usr/local/lib/lambda-runtime" "$CHROOT_DIR/usr/local/bin"
install -m 0755 "$RUN_AGENT" "$CHROOT_DIR/usr/local/lib/lambda-runtime/run-agent"
install -m 0755 "$LAMBDA_AGENT_BIN" "$CHROOT_DIR/usr/local/bin/lambda-agent"
# Function-code mountpoint and scratch space ship empty.
mkdir -p "$CHROOT_DIR/var/task" "$CHROOT_DIR/tmp" \
"$CHROOT_DIR/proc" "$CHROOT_DIR/sys" "$CHROOT_DIR/dev" "$CHROOT_DIR/etc/ssl/certs"
# The bootstrap runs unprivileged, so / must be traversable (mktemp -d makes it
# 0700) and nothing it runs from may be group-writable.
chmod 0755 "$CHROOT_DIR" "$CHROOT_DIR/var/task" "$CHROOT_DIR/usr/local/lib/lambda-runtime"
microvm_locate_kernel
microvm_strip_modules
microvm_strip_rootfs
microvm_stage_kernel
microvm_pack_initramfs
# Nothing reaches $BUILD_DIR until the staged pair passes these checks.
for out in vmlinuz initramfs.cpio.gz; do
if [ ! -s "$STAGE_DIR/$out" ]; then
echo "ERROR: staged $out is missing or empty" >&2
exit 1
fi
done
if ! gzip -t "$STAGE_DIR/initramfs.cpio.gz"; then
echo "ERROR: staged initramfs.cpio.gz is not valid gzip" >&2
exit 1
fi
mv -f "$STAGE_DIR/vmlinuz" "$BUILD_DIR/vmlinuz"
mv -f "$STAGE_DIR/initramfs.cpio.gz" "$BUILD_DIR/initramfs.cpio.gz"
echo ""
echo "$TAG done."
+48 -404
View File
@@ -1,44 +1,37 @@
#!/bin/bash
# build-sqs-store-microvm-image.sh — build the SQS store microVM kernel +
# initramfs: the dedicated nats-server, ext4 support, the store's guest steps
# and a clean power-down. The guest never formats a disk; build/microvm/
# sqs-store/sqs-store-lib.sh states its fw_cfg contract. The LB image
# (build-microvm-image.sh) is separate and unchanged.
#
# SQS_MICROVM_PROFILE=worker builds the shared SQS worker guest instead: the
# same network /init and kernel, build/microvm/sqs-worker's steps and
# $REPO_ROOT/bin/sqs-worker, with no nats-server.
# build-sqs-store-microvm-image.sh — build the SQS microVM kernel + initramfs:
# one guest running the dedicated SQS nats-server and the SQS worker as separate
# processes, ext4 support, its guest steps and a clean power-down. The guest
# never formats a disk. The LB image (build-microvm-image.sh) is separate.
#
# Inputs:
# $REPO_ROOT/build/microvm/sqs-store/init.sh, inittab
# $REPO_ROOT/build/microvm/sqs-store/sqs-store-{lib.sh,prepare,run,stop}
# $REPO_ROOT/build/microvm/sqs-worker/init.sh, inittab
# $REPO_ROOT/build/microvm/sqs-worker/sqs-worker-{lib.sh,prepare,ready,stop}
# $REPO_ROOT/bin/nats-server (make bin/nats-server, the go.mod-pinned version)
# $REPO_ROOT/bin/sqs-worker
#
# Outputs:
# $REPO_ROOT/build/microvm/sqs-store/vmlinuz
# $REPO_ROOT/build/microvm/sqs-store/initramfs.cpio.gz
# $REPO_ROOT/build/microvm/sqs-worker/vmlinuz
# $REPO_ROOT/build/microvm/sqs-worker/initramfs.cpio.gz
#
# Rootfs strategy (first match wins):
# 1. apk available on host → install directly into a temp chroot (Alpine CI/CD)
# 2. docker available → run alpine container, export filesystem
# 3. podman available → same via podman
# shellcheck disable=SC2034 # variables are consumed by the sourced library
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
BUILD_DIR="$REPO_ROOT/build/microvm/sqs-store"
BUILD_DIR="$REPO_ROOT/build/microvm/sqs-worker"
INIT_SH="$BUILD_DIR/init.sh"
INITTAB="$BUILD_DIR/inittab"
NATS_SERVER_BIN="$REPO_ROOT/bin/nats-server"
SQS_WORKER_BIN="$REPO_ROOT/bin/sqs-worker"
PROFILE="${SQS_MICROVM_PROFILE:-store}"
PROFILE="${SQS_MICROVM_PROFILE:-worker}"
case "$PROFILE" in
store) ;;
worker)
BUILD_DIR="$REPO_ROOT/build/microvm/sqs-worker"
INITTAB="$BUILD_DIR/inittab"
SQS_WORKER_BIN="$REPO_ROOT/bin/sqs-worker"
;;
*) echo "ERROR: SQS_MICROVM_PROFILE must be store or worker, not $PROFILE" >&2; exit 1 ;;
worker) ;;
*) echo "ERROR: SQS_MICROVM_PROFILE must be worker, not $PROFILE" >&2; exit 1 ;;
esac
TAG="[build-sqs-$PROFILE-microvm-image]"
@@ -52,32 +45,28 @@ ALPINE_PACKAGES="busybox linux-virt iproute2 ca-certificates e2fsprogs kmod"
# button (QMP system_powerdown) into a signal busybox init handles as poweroff.
MICROVM_EXTRA_MODULES="${MICROVM_EXTRA_MODULES:-ext4 tiny_power_button}"
# Module file globs kept on top of virtio*/*fw_cfg*: virtio_net deps for live migration.
MICROVM_EXTRA_KEEP_GLOBS=("*failover*.ko*")
# Modules the image modprobe must resolve, beyond the extras.
MICROVM_GATE_MODULES="qemu_fw_cfg virtio_net"
# shellcheck source=lib/microvm-image.sh
source "$REPO_ROOT/scripts/lib/microvm-image.sh"
echo "$TAG repo root: $REPO_ROOT"
echo "$TAG build dir: $BUILD_DIR"
mkdir -p "$BUILD_DIR"
# --- Verify non-negotiable tools ---
# fakeroot is mandatory: device nodes are written into the cpio archive inside a
# faked-root environment so no real device node is ever created on the host
# filesystem (which previously required sudo mknod and risked clobbering host
# /dev/null on cleanup).
for tool in cpio gzip find fakeroot; do
if ! command -v "$tool" >/dev/null 2>&1; then
echo "ERROR: required tool not found: $tool" >&2
exit 1
fi
done
microvm_check_tools
if [ "$PROFILE" = worker ]; then
if [ ! -x "$SQS_WORKER_BIN" ]; then
echo "ERROR: $SQS_WORKER_BIN not found — run 'make bin/sqs-worker'" >&2
exit 1
fi
if ! file -b "$SQS_WORKER_BIN" | grep -q "statically linked"; then
echo "ERROR: $SQS_WORKER_BIN is not statically linked — it cannot run on the musl guest" >&2
exit 1
fi
else
if [ ! -x "$SQS_WORKER_BIN" ]; then
echo "ERROR: $SQS_WORKER_BIN not found — run 'make bin/sqs-worker'" >&2
exit 1
fi
if ! file -b "$SQS_WORKER_BIN" | grep -q "statically linked"; then
echo "ERROR: $SQS_WORKER_BIN is not statically linked — it cannot run on the musl guest" >&2
exit 1
fi
# --- Verify the dedicated nats-server is the go.mod-pinned build ---
# A stale or host-installed binary would ship an unreviewed server version.
NATS_SERVER_PIN=$(awk '$1 == "github.com/nats-io/nats-server/v2" { print $2; exit }' "$REPO_ROOT/go.mod")
@@ -99,102 +88,12 @@ if [ "$nats_server_version" != "$NATS_SERVER_PIN" ]; then
exit 1
fi
echo "$TAG nats-server: $nats_server_version (matches go.mod)"
fi
# --- Create chroot ---
CHROOT_DIR=$(mktemp -d)
# Guard: every destructive op below targets "$CHROOT_DIR/...". An empty or
# unexpected value would aim rm -rf at the host (e.g. /dev). Refuse anything that
# is not a freshly-created temp dir.
case "${CHROOT_DIR:?mktemp -d returned empty}" in
/tmp/*|/var/tmp/*|"${TMPDIR:-/nonexistent}"/*) ;;
*) echo "ERROR: refusing to use unexpected CHROOT_DIR: $CHROOT_DIR" >&2; exit 1 ;;
esac
# Outputs are written here first, beside $BUILD_DIR so publishing is a rename.
STAGE_DIR=$(mktemp -d "$BUILD_DIR/.stage.XXXXXX")
CONTAINER_TOOL=""
CONTAINER_CID=""
cleanup() {
echo "$TAG cleaning up chroot: $CHROOT_DIR"
rm -rf "$CHROOT_DIR"
rm -rf "${STAGE_DIR:?}"
if [ -n "$CONTAINER_CID" ] && [ -n "$CONTAINER_TOOL" ]; then
echo "$TAG cleaning up container: $CONTAINER_CID"
$CONTAINER_TOOL rm -f "$CONTAINER_CID" >/dev/null 2>&1 || true
fi
}
trap cleanup EXIT
microvm_init_dirs
# --- Populate rootfs ---
# Strategy 1: native apk (Alpine hosts, CI)
if command -v apk >/dev/null 2>&1; then
echo "$TAG rootfs: native apk"
mkdir -p \
"$CHROOT_DIR/etc/apk" \
"$CHROOT_DIR/lib/apk/db" \
"$CHROOT_DIR/var/cache/apk"
cat > "$CHROOT_DIR/etc/apk/repositories" <<EOF
${ALPINE_MIRROR}/v${ALPINE_VERSION}/main
${ALPINE_MIRROR}/v${ALPINE_VERSION}/community
EOF
apk add \
--root "$CHROOT_DIR" \
--arch x86_64 \
--no-cache \
--allow-untrusted \
--initdb \
$ALPINE_PACKAGES
microvm_make_rootfs
# Strategy 2: docker -> BuildKit local export.
# Do NOT use `docker export` here. On Docker's overlay2 driver it leaks the
# container's overlay mount (the mount survives `docker rm`), orphaning layers
# under /var/lib/docker that only a daemon restart reclaims; on a busy CI host
# this grows without bound. `buildx --output type=local` writes the image
# filesystem straight to a directory and releases every mount, so nothing leaks.
elif command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then
echo "$TAG rootfs: docker buildx --output type=local (alpine:${ALPINE_VERSION})"
BUILD_CTX=$(mktemp -d)
cat > "$BUILD_CTX/Dockerfile" <<DOCKERFILE
FROM alpine:${ALPINE_VERSION}
RUN apk add --no-cache ${ALPINE_PACKAGES}
DOCKERFILE
docker buildx build \
--platform linux/amd64 \
--output "type=local,dest=${CHROOT_DIR}" \
"$BUILD_CTX"
rm -rf "$BUILD_CTX"
# Strategy 3: podman -> run + export. Podman's store does not exhibit the docker
# overlay2 export-mount leak, and podman has no buildx.
elif command -v podman >/dev/null 2>&1; then
echo "$TAG rootfs: podman export (alpine:${ALPINE_VERSION})"
CONTAINER_TOOL=podman
CONTAINER_CID=$(podman run -d \
"alpine:${ALPINE_VERSION}" \
sh -c "apk add --no-cache ${ALPINE_PACKAGES}")
exit_code=$(podman wait "$CONTAINER_CID")
if [ "$exit_code" != "0" ]; then
echo "ERROR: package installation failed in container (exit $exit_code)" >&2
exit 1
fi
podman export "$CONTAINER_CID" | tar -x -C "$CHROOT_DIR"
else
echo "ERROR: no rootfs build tool available." >&2
echo " Install one of: apk (Alpine), docker, podman" >&2
exit 1
fi
# --- /dev: empty mountpoint only ---
# tar extraction without root silently creates 0-byte regular files for device
# nodes. Drop them and leave an empty /dev mountpoint. The two static nodes the
# kernel needs (/dev/console to wire init's stdio, /dev/null) are written into
# the cpio archive under fakeroot at pack time — NOT created on the host. init
# mounts devtmpfs over /dev as its first action for everything else at runtime.
rm -rf "${CHROOT_DIR:?}/dev"
mkdir "$CHROOT_DIR/dev"
microvm_empty_dev
# --- Place init script ---
echo "$TAG installing init.sh as /init..."
@@ -213,39 +112,23 @@ fi
echo "$TAG installing inittab..."
install -m 0644 "$INITTAB" "$CHROOT_DIR/etc/inittab"
if [ "$PROFILE" = worker ]; then
echo "$TAG installing worker guest steps..."
for step in sqs-worker-lib.sh sqs-worker-prepare sqs-worker-ready sqs-worker-stop; do
if [ ! -f "$BUILD_DIR/$step" ]; then
echo "ERROR: $BUILD_DIR/$step not found — cannot build initramfs" >&2
exit 1
fi
done
mkdir -p "$CHROOT_DIR/usr/local/lib/sqs-worker" "$CHROOT_DIR/usr/local/bin"
install -m 0755 "$BUILD_DIR/sqs-worker-lib.sh" "$BUILD_DIR/sqs-worker-prepare" "$BUILD_DIR/sqs-worker-ready" "$BUILD_DIR/sqs-worker-stop" \
"$CHROOT_DIR/usr/local/lib/sqs-worker/"
install -m 0755 "$SQS_WORKER_BIN" "$CHROOT_DIR/usr/local/bin/sqs-worker"
# The mountpoint ships empty; the worker proves its disk is mounted here.
mkdir -p "$CHROOT_DIR/data"
else
# --- Place the store's guest steps ---
echo "$TAG installing store guest steps..."
mkdir -p "$CHROOT_DIR/usr/local/lib/sqs-store"
for step in sqs-store-lib.sh sqs-store-prepare sqs-store-run sqs-store-stop; do
echo "$TAG installing worker guest steps..."
for step in sqs-worker-lib.sh sqs-worker-prepare sqs-worker-store sqs-worker-ready sqs-worker-stop; do
if [ ! -f "$BUILD_DIR/$step" ]; then
echo "ERROR: $BUILD_DIR/$step not found — cannot build initramfs" >&2
exit 1
fi
done
install -m 0644 "$BUILD_DIR/sqs-store-lib.sh" "$CHROOT_DIR/usr/local/lib/sqs-store/"
install -m 0755 "$BUILD_DIR/sqs-store-prepare" "$BUILD_DIR/sqs-store-run" "$BUILD_DIR/sqs-store-stop" \
"$CHROOT_DIR/usr/local/lib/sqs-store/"
mkdir -p "$CHROOT_DIR/usr/local/lib/sqs-worker" "$CHROOT_DIR/usr/local/bin"
install -m 0755 "$BUILD_DIR/sqs-worker-lib.sh" "$BUILD_DIR/sqs-worker-prepare" "$BUILD_DIR/sqs-worker-store" "$BUILD_DIR/sqs-worker-ready" "$BUILD_DIR/sqs-worker-stop" \
"$CHROOT_DIR/usr/local/lib/sqs-worker/"
install -m 0755 "$SQS_WORKER_BIN" "$CHROOT_DIR/usr/local/bin/sqs-worker"
# The mountpoint ships empty; the worker proves its disk is mounted here.
mkdir -p "$CHROOT_DIR/data"
# --- Copy nats-server binary ---
echo "$TAG copying nats-server binary..."
mkdir -p "$CHROOT_DIR/usr/local/bin"
install -m 0755 "$NATS_SERVER_BIN" "$CHROOT_DIR/usr/local/bin/nats-server"
fi
# --- Create required directories for init ---
mkdir -p \
@@ -254,30 +137,8 @@ mkdir -p \
"$CHROOT_DIR/dev" \
"$CHROOT_DIR/etc/ssl/certs"
# --- Locate kernel ---
KERNEL_IMG=$(find "$CHROOT_DIR/boot" -name "vmlinuz*" | sort | tail -1)
if [ -z "$KERNEL_IMG" ]; then
echo "ERROR: no vmlinuz found in $CHROOT_DIR/boot" >&2
exit 1
fi
echo "$TAG kernel image: $KERNEL_IMG"
KERNEL_VER=$(basename "$KERNEL_IMG" | sed 's/vmlinuz-//')
KERNEL_CONFIG="$CHROOT_DIR/boot/config-${KERNEL_VER}"
# --- Assert fw_cfg module ---
if ! find "$CHROOT_DIR/lib/modules" \
-name "qemu_fw_cfg.ko*" \
-o -name "fw_cfg_sysfs.ko*" \
| grep -q .; then
if ! grep -q "CONFIG_FW_CFG_SYSFS=y" "$KERNEL_CONFIG" 2>/dev/null; then
echo "ERROR: qemu_fw_cfg module missing from initramfs — fw_cfg reads will fail at boot" >&2
exit 1
fi
echo "$TAG fw_cfg: built-in (CONFIG_FW_CFG_SYSFS=y)"
else
echo "$TAG fw_cfg: module found in lib/modules"
fi
microvm_locate_kernel
# --- Assert ext4 userspace tools (data volume) ---
for fs_tool in mkfs.ext4 e2fsck; do
@@ -288,227 +149,10 @@ for fs_tool in mkfs.ext4 e2fsck; do
done
echo "$TAG e2fsprogs: mkfs.ext4 + e2fsck present"
# --- Strip kernel modules (Phase 2 keep-list approach) ---
# Only modules needed by a virtio-mmio microvm guest are kept:
# virtio*.ko* — virtio bus, net, blk, rng, console, and helpers
# *fw_cfg*.ko* — QEMU fw_cfg sysfs driver (delivers boot blobs)
# *failover*.ko* — net_failover + failover (virtio_net deps for live migration)
# $MICROVM_EXTRA_MODULES — ext4 by default, plus dependency closure
# All other loadable modules are removed. Drivers compiled into the kernel
# (CONFIG_VIRTIO_MMIO=y etc.) are unaffected — they live in vmlinuz, not here.
# module_path prints a module's kernel/ path from the stock modules.dep, by
# name or modules.alias; it fails when the module is not loadable.
module_path() {
local kdir="$1" name="${2//-/_}" path alias_target
path=$(awk -F: -v n="$name" '{ f = $1; sub(/.*\//, "", f); sub(/\.ko(\.gz|\.xz|\.zst)?$/, "", f);
gsub(/-/, "_", f); if (f == n) { print $1; exit } }' "$kdir/modules.dep")
if [ -z "$path" ] && [ -f "$kdir/modules.alias" ]; then
alias_target=$(awk -v n="$2" '$1 == "alias" && $2 == n { print $3; exit }' "$kdir/modules.alias")
[ -n "$alias_target" ] && [ "$alias_target" != "$2" ] && path=$(module_path "$kdir" "$alias_target")
fi
[ -n "$path" ] && printf '%s\n' "$path"
}
# module_builtin reports whether a module name is compiled into the kernel.
module_builtin() {
local kdir="$1" name="${2//-/_}"
[ -f "$kdir/modules.builtin" ] || return 1
awk -v n="$name" '{ f = $0; sub(/.*\//, "", f); sub(/\.ko$/, "", f); gsub(/-/, "_", f);
if (f == n) { found = 1; exit } } END { exit !found }' "$kdir/modules.builtin"
}
# image_kmod runs the image's own kmod (depmod, modprobe) through its musl
# loader, so module metadata matches the guest's modprobe without a host kmod.
image_kmod() {
"$CHROOT_DIR/lib/ld-musl-x86_64.so.1" --library-path "$CHROOT_DIR/lib:$CHROOT_DIR/usr/lib" \
--argv0 "$1" "$CHROOT_DIR/bin/kmod" "${@:2}"
}
echo "$TAG stripping kernel modules to virtio+fw_cfg+${MICROVM_EXTRA_MODULES} only..."
for kver_dir in "$CHROOT_DIR/lib/modules"/*/; do
kver="$(basename "$kver_dir")"
kernel_dir="$kver_dir/kernel"
[ -d "$kernel_dir" ] || continue
if [ ! -s "$kver_dir/modules.dep" ]; then
echo "ERROR: stock ${kver_dir}modules.dep missing — cannot resolve module dependencies" >&2
exit 1
fi
# Extra modules start the closure; the glob families are kept as found.
keep_list=$(mktemp)
: > "$keep_list"
for name in $MICROVM_EXTRA_MODULES; do
if ! path=$(module_path "$kver_dir" "$name"); then
if module_builtin "$kver_dir" "$name"; then continue; fi
echo "ERROR: module $name is neither loadable nor built into $kver" >&2
exit 1
fi
echo "$path" >> "$keep_list"
done
# Close over modules.dep hard dependencies and modules.softdep pre:/post:
# names until the set stops growing. A softdep must be loadable or built in.
while :; do
before=$(sort -u "$keep_list" | wc -l)
for path in $(sort -u "$keep_list"); do
awk -F': ' -v p="$path" '$1 == p { n = split($2, d, " "); for (i = 1; i <= n; i++) print d[i] }' \
"$kver_dir/modules.dep" >> "$keep_list"
mod=$(basename "$path"); mod="${mod%%.ko*}"; mod="${mod//-/_}"
[ -f "$kver_dir/modules.softdep" ] || continue
for soft in $(awk -v n="$mod" '{ m = $2; gsub(/-/, "_", m) }
$1 == "softdep" && m == n { for (i = 3; i <= NF; i++) if ($i != "pre:" && $i != "post:") print $i }' \
"$kver_dir/modules.softdep"); do
if soft_path=$(module_path "$kver_dir" "$soft"); then
echo "$soft_path" >> "$keep_list"
elif ! module_builtin "$kver_dir" "$soft"; then
echo "ERROR: softdep $soft of $mod is neither loadable nor built into $kver" >&2
exit 1
fi
done
done
after=$(sort -u "$keep_list" | wc -l)
[ "$before" -eq "$after" ] && break
done
extra_names=$(sort -u "$keep_list" | while read -r p; do m=$(basename "$p"); m="${m%%.ko*}"; echo "${m//-/_}"; done)
(cd "$kver_dir" && find kernel -name "virtio*.ko*" -o -name "*fw_cfg*.ko*" \
-o -name "*failover*.ko*") >> "$keep_list"
# Copy the keeper set out, preserving each kernel/ relative path.
tmp_keep=$(mktemp -d)
while read -r rel; do
rel="${rel#kernel/}"
mkdir -p "$tmp_keep/$(dirname "$rel")"
cp "$kernel_dir/$rel" "$tmp_keep/$rel"
done < <(sort -u "$keep_list")
rm -f "$keep_list"
# Replace kernel module tree with the keeper set.
rm -rf "$kernel_dir"
mkdir -p "$kernel_dir"
# Restore each kept file into the same relative path.
(cd "$tmp_keep" && find . -name "*.ko*" | while read -r f; do
dest_dir="$kernel_dir/$(dirname "${f#./}")"
mkdir -p "$dest_dir"
cp "$f" "$dest_dir/"
done)
rm -rf "$tmp_keep"
# Decompress kept modules to plain .ko. Alpine ships .ko.gz, but a host
# kmod that lacks gzip support (e.g. Debian trixie's kmod 34) silently
# produces an EMPTY modules.dep from compressed modules — the guest's
# modprobe then no-ops and qemu_fw_cfg never loads. Plain .ko sidesteps the
# host-kmod dependency entirely and the guest needs no decompressor either.
find "$kernel_dir" -name "*.ko.gz" -exec gunzip -f {} +
# Independent closure gate: each extra-closure module's own .modinfo depends=
# must name a kept module or a built-in, so a resolver bug fails here.
kept_names=$(find "$kernel_dir" -name "*.ko" -exec basename {} .ko \; | tr '-' '_' | sort -u)
while read -r ko; do
ko_name=$(basename "$ko" .ko); ko_name="${ko_name//-/_}"
grep -qx "$ko_name" <<< "$extra_names" || continue
deps=$(tr '\0' '\n' < "$ko" | grep -a -m1 '^depends=' | cut -d= -f2 | tr ',' ' ' || true)
for dep in $deps; do
dep="${dep//-/_}"
if ! grep -qx "$dep" <<< "$kept_names" && ! module_builtin "$kver_dir" "$dep"; then
echo "ERROR: $(basename "$ko") depends on $dep, which is neither kept nor built in" >&2
exit 1
fi
done
done < <(find "$kernel_dir" -name "*.ko")
for name in $MICROVM_EXTRA_MODULES; do
if ! grep -qx "${name//-/_}" <<< "$kept_names" && ! module_builtin "$kver_dir" "$name"; then
echo "ERROR: required module $name missing from the stripped module tree" >&2
exit 1
fi
done
echo "$TAG extra module closure: $(echo "$extra_names" | tr '\n' ' ')"
echo "$TAG modules kept: $(echo "$kept_names" | tr '\n' ' ')"
# Rebuild module metadata with the image's own kmod, the binary the guest's
# modprobe runs, so no host depmod is needed. Stock indexes still name .ko.gz
# files, so they are removed first and a failed rebuild cannot ship them.
for f in dep dep.bin alias alias.bin softdep weakdep symbols symbols.bin \
builtin.bin builtin.alias.bin devname; do
rm -f "$kver_dir/modules.$f"
done
if ! image_kmod depmod -b "$CHROOT_DIR" "$kver"; then
echo "ERROR: image depmod failed for $kver" >&2
exit 1
fi
if [ ! -s "$kver_dir/modules.dep" ] || [ ! -s "$kver_dir/modules.dep.bin" ]; then
echo "ERROR: ${kver_dir}modules.dep or modules.dep.bin missing or empty after depmod" >&2
exit 1
fi
# Resolution gate: the image's modprobe must resolve every required module
# to plain .ko files present in the stripped tree.
for name in $MICROVM_EXTRA_MODULES qemu_fw_cfg virtio_net; do
module_builtin "$kver_dir" "$name" && continue
if ! plan=$(image_kmod modprobe -d "$CHROOT_DIR" -S "$kver" --show-depends "$name"); then
echo "ERROR: image modprobe cannot resolve $name for $kver" >&2
exit 1
fi
while read -r verb path _; do
[ "$verb" = "insmod" ] || continue
case "$path" in
"$CHROOT_DIR/lib/modules/$kver/kernel/"*.ko) [ -f "$path" ] && continue ;;
esac
echo "ERROR: modprobe plan for $name names $path, not a kept plain .ko" >&2
exit 1
done <<< "$plan"
echo "$TAG modprobe $name: $(awk '{ n = split($2, a, "/"); printf "%s ", a[n] }' <<< "$plan")"
done
done
# --- Strip package-manager artifacts (not needed at runtime) ---
echo "$TAG stripping package manager artifacts..."
rm -rf \
"$CHROOT_DIR/lib/apk" \
"$CHROOT_DIR/var/cache/apk" \
"$CHROOT_DIR/etc/apk" \
"$CHROOT_DIR/usr/share/man" \
"$CHROOT_DIR/usr/share/doc" \
"$CHROOT_DIR/usr/share/apk" \
"$CHROOT_DIR/usr/include" \
"$CHROOT_DIR/usr/lib/pkgconfig"
# --- Strip debug symbols from binaries ---
# nats-server in /usr/local/bin is left as built (-s -w), so the shipped bytes
# are the ones whose version was checked above.
echo "$TAG stripping debug symbols from binaries..."
find "$CHROOT_DIR/usr/sbin" "$CHROOT_DIR/usr/bin" \
"$CHROOT_DIR/sbin" "$CHROOT_DIR/bin" \
-type f 2>/dev/null | while read -r bin; do
strip --strip-all "$bin" 2>/dev/null || true
done
# --- Copy vmlinuz out before stripping /boot from chroot ---
VMLINUZ_OUT="$STAGE_DIR/vmlinuz"
echo "$TAG copying kernel: $VMLINUZ_OUT"
cp "$KERNEL_IMG" "$VMLINUZ_OUT"
# Drop /boot from the chroot — vmlinuz, Alpine's stock initramfs-virt, System.map
# and kernel config all live here (~25 MiB). None are needed at runtime: the
# guest already has the kernel loaded by QEMU's -kernel, and Alpine's initramfs
# is unused because we replace it with our own /init. Leaving /boot in the cpio
# nearly doubles uncompressed initramfs size and forces a 512 MiB guest just to
# survive decompression.
rm -rf "${CHROOT_DIR:?}/boot"
# --- Build initramfs ---
INITRAMFS_OUT="$STAGE_DIR/initramfs.cpio.gz"
echo "$TAG building initramfs: $INITRAMFS_OUT"
# Create the static device nodes and pack the archive inside a single fakeroot
# session: mknod/cpio see faked device entries and write real char-device
# records into the cpio, while the host filesystem never gets an actual node.
fakeroot sh -c '
cd "$1" || exit 1
mknod -m 600 dev/console c 5 1
mknod -m 666 dev/null c 1 3
find . | cpio --quiet -o -H newc | gzip -9 > "$2"
' _ "$CHROOT_DIR" "$INITRAMFS_OUT"
microvm_strip_modules
microvm_strip_rootfs
microvm_stage_kernel
microvm_pack_initramfs
# --- Gate the staged pair, then publish it ---
# Nothing reaches $BUILD_DIR until every gate passes, so a failed build cannot
+21 -5
View File
@@ -11,12 +11,14 @@ set -eu
# cron can branch without re-parsing, then disables itself so it runs exactly
# once.
#
# server → eks-token-webhook, k3s (server), k3s-first-boot (bootstrap
# publish), mulga-eks-state-report
# server → eks-token-webhook, k3s (server), eks-pod-identity-webhook,
# k3s-first-boot (bootstrap publish), mulga-eks-state-report,
# mulga-eks-node-gc
# server-join → eks-token-webhook, k3s (server, joins the first server's etcd
# quorum), mulga-eks-state-report. NO k3s-first-boot: the first
# server already publishes the cluster-identical bootstrap
# artifacts; a join re-publish only races the bootstrap bus.
# quorum), eks-pod-identity-webhook, mulga-eks-state-report,
# mulga-eks-node-gc. NO k3s-first-boot: the first server
# already publishes the cluster-identical bootstrap artifacts;
# a join re-publish only races the bootstrap bus.
# agent → k3s-agent
#
# Paths are overridable via env so the selection logic is unit-testable (bats)
@@ -80,6 +82,14 @@ svc_start() {
esac
}
# IRSA injector: every server runs one, called by its own apiserver. It is
# optional (failurePolicy Ignore), so it goes last and a fault only costs
# injection, never the control plane.
svc_optional() {
svc_enable "$1" && svc_start "$1" ||
log "WARN: optional service $1 failed to enable or start; continuing without it"
}
svc_disable() {
case "${INIT_SYSTEM}" in
openrc) rc-update del "$1" default 2>/dev/null || true ;;
@@ -140,6 +150,7 @@ case "${ROLE}" in
# writer owns. HA multi-server addon delivery is tracked separately
# Server-join nodes do not run this setup.
svc_enable mulga-eks-addon-sync
svc_enable mulga-eks-node-gc
svc_start eks-token-webhook
svc_start mulga-eks-k3s-recovery
svc_start k3s
@@ -147,6 +158,8 @@ case "${ROLE}" in
svc_start k3s-first-boot
svc_start mulga-eks-state-report
svc_start mulga-eks-addon-sync
svc_start mulga-eks-node-gc
svc_optional eks-pod-identity-webhook
;;
server-join)
log "configuring server-join role"
@@ -158,11 +171,14 @@ case "${ROLE}" in
svc_enable k3s
svc_enable konnectivity-server
svc_enable mulga-eks-state-report
svc_enable mulga-eks-node-gc
svc_start eks-token-webhook
svc_start mulga-eks-k3s-recovery
svc_start k3s
svc_start konnectivity-server
svc_start mulga-eks-state-report
svc_start mulga-eks-node-gc
svc_optional eks-pod-identity-webhook
;;
agent)
log "configuring agent role"
+38 -1
View File
@@ -22,6 +22,7 @@ for tool in rc-update rc-service; do
cat > "${STUB_OPENRC}/${tool}" <<EOF
#!/bin/sh
echo "${tool} \$*" >> "\${CALLS}"
case "\$*" in *"\${STUB_FAIL:-@none@}"*) exit 1 ;; esac
exit 0
EOF
chmod +x "${STUB_OPENRC}/${tool}"
@@ -32,6 +33,7 @@ mkdir -p "${STUB_SYSTEMD}"
cat > "${STUB_SYSTEMD}/systemctl" <<'EOF'
#!/bin/sh
echo "systemctl $*" >> "${CALLS}"
case "$*" in *"${STUB_FAIL:-@none@}"*) exit 1 ;; esac
exit 0
EOF
chmod +x "${STUB_SYSTEMD}/systemctl"
@@ -129,6 +131,7 @@ for INIT in openrc systemd; do
check "server(${INIT}): enables k3s-first-boot" "yes" "$(present "${CALLS}" "$(enable_cmd "${INIT}" k3s-first-boot)")"
check "server(${INIT}): starts k3s" "yes" "$(present "${CALLS}" "$(start_cmd "${INIT}" k3s)")"
check "server(${INIT}): enables konnectivity-server" "yes" "$(present "${CALLS}" "$(enable_cmd "${INIT}" konnectivity-server)")"
check "server(${INIT}): enables pod-identity-webhook" "yes" "$(present "${CALLS}" "$(enable_cmd "${INIT}" eks-pod-identity-webhook)")"
check "server(${INIT}): enables k3s-recovery" "yes" "$(present "${CALLS}" "$(enable_cmd "${INIT}" mulga-eks-k3s-recovery)")"
# Recovery is enabled (so it also runs pre-k3s on later boots) AND started
# inline now — a restore-snapshot DR seed's directive must apply on this
@@ -141,8 +144,38 @@ for INIT in openrc systemd; do
END { print (rn > 0 && kn > 0 && rn < kn) ? "yes" : "no" }
' "${CALLS}"
)"
check "server(${INIT}): enables node-gc" "yes" "$(present "${CALLS}" "$(enable_cmd "${INIT}" mulga-eks-node-gc)")"
check "server(${INIT}): starts node-gc" "yes" "$(present "${CALLS}" "$(start_cmd "${INIT}" mulga-eks-node-gc)")"
check "server(${INIT}): no k3s-agent" "no" "$(present "${CALLS}" "$(enable_cmd "${INIT}" k3s-agent)")"
check "server(${INIT}): self-disables" "yes" "$(present "${CALLS}" "$(disable_cmd "${INIT}" eks-node-role)")"
check "server(${INIT}): pod-identity-webhook is the last call" "$(start_cmd "${INIT}" eks-pod-identity-webhook)" "$(grep -v "$(disable_cmd "${INIT}" eks-node-role)" "${CALLS}" | tail -n 1)"
check "server(${INIT}): pod-identity-webhook enabled after addon-sync start" "yes" "$(
awk -v a="$(start_cmd "${INIT}" mulga-eks-addon-sync)" -v w="$(enable_cmd "${INIT}" eks-pod-identity-webhook)" '
index($0, a) == 1 { an = NR }
index($0, w) == 1 { wn = NR }
END { print (an > 0 && wn > an) ? "yes" : "no" }
' "${CALLS}"
)"
# A broken optional webhook must leave the control plane booting.
for FAILING in enable start; do
run_case "${INIT}" server_${FAILING}_fail
printf 'SPINIFEX_K3S_ROLE=server\nEKS_CLUSTER_NAME=alpha\n' > "${EKS_NODE_ENVFILE}"
if [ "${FAILING}" = enable ]; then
STUB_FAIL="$(enable_cmd "${INIT}" eks-pod-identity-webhook)"
export STUB_FAIL
STUB_FAIL="${STUB_FAIL#rc-update add }"; STUB_FAIL="${STUB_FAIL#systemctl enable }"
else
STUB_FAIL="$(start_cmd "${INIT}" eks-pod-identity-webhook)"
export STUB_FAIL
STUB_FAIL="${STUB_FAIL#rc-service }"; STUB_FAIL="${STUB_FAIL#systemctl start --no-block }"
fi
invoke
unset STUB_FAIL
check "server(${INIT}): webhook ${FAILING} failure still exits 0" 0 "${rc}"
check "server(${INIT}): webhook ${FAILING} failure still starts mulga-eks-addon-sync" "yes" "$(present "${CALLS}" "$(start_cmd "${INIT}" mulga-eks-addon-sync)")"
check "server(${INIT}): webhook ${FAILING} failure still self-disables" "yes" "$(present "${CALLS}" "$(disable_cmd "${INIT}" eks-node-role)")"
done
# --- server-join ---
run_case "${INIT}" server_join
@@ -153,12 +186,16 @@ for INIT in openrc systemd; do
check "server-join(${INIT}): enables webhook" "yes" "$(present "${CALLS}" "$(enable_cmd "${INIT}" eks-token-webhook)")"
check "server-join(${INIT}): enables k3s" "yes" "$(present "${CALLS}" "$(enable_cmd "${INIT}" k3s)")"
check "server-join(${INIT}): enables state-report" "yes" "$(present "${CALLS}" "$(enable_cmd "${INIT}" mulga-eks-state-report)")"
check "server-join(${INIT}): enables node-gc" "yes" "$(present "${CALLS}" "$(enable_cmd "${INIT}" mulga-eks-node-gc)")"
check "server-join(${INIT}): starts node-gc" "yes" "$(present "${CALLS}" "$(start_cmd "${INIT}" mulga-eks-node-gc)")"
check "server-join(${INIT}): enables konnectivity-server" "yes" "$(present "${CALLS}" "$(enable_cmd "${INIT}" konnectivity-server)")"
check "server-join(${INIT}): enables pod-identity-webhook" "yes" "$(present "${CALLS}" "$(enable_cmd "${INIT}" eks-pod-identity-webhook)")"
check "server-join(${INIT}): enables k3s-recovery" "yes" "$(present "${CALLS}" "$(enable_cmd "${INIT}" mulga-eks-k3s-recovery)")"
check "server-join(${INIT}): starts k3s-recovery" "yes" "$(present "${CALLS}" "$(start_cmd "${INIT}" mulga-eks-k3s-recovery)")"
# Join servers must NOT re-publish bootstrap: the first server already
# published the cluster-identical artifacts, and a join re-publish would
# only race the bootstrap bus. This is deliberate, not an oversight.
check "server-join(${INIT}): pod-identity-webhook is the last call" "$(start_cmd "${INIT}" eks-pod-identity-webhook)" "$(grep -v "$(disable_cmd "${INIT}" eks-node-role)" "${CALLS}" | tail -n 1)"
check "server-join(${INIT}): no k3s-first-boot" "yes" "$(absent_all "${CALLS}" "k3s-first-boot")"
check "server-join(${INIT}): no k3s-agent" "yes" "$(absent_all "${CALLS}" "$(enable_cmd "${INIT}" k3s-agent)")"
check "server-join(${INIT}): self-disables" "yes" "$(present "${CALLS}" "$(disable_cmd "${INIT}" eks-node-role)")"
@@ -170,7 +207,7 @@ for INIT in openrc systemd; do
check "agent(${INIT}): exit 0" 0 "${rc}"
check "agent(${INIT}): role file" "agent" "$(cat "${ROLE_FILE}")"
check "agent(${INIT}): enables k3s-agent" "yes" "$(present "${CALLS}" "$(enable_cmd "${INIT}" k3s-agent)")"
check "agent(${INIT}): no server services" "yes" "$(absent_all "${CALLS}" "eks-token-webhook" "k3s-first-boot" "$(enable_cmd "${INIT}" k3s)")"
check "agent(${INIT}): no server services" "yes" "$(absent_all "${CALLS}" "eks-token-webhook" "eks-pod-identity-webhook" "k3s-first-boot" "mulga-eks-node-gc" "$(enable_cmd "${INIT}" k3s)")"
# --- agent (inferred from agent.env, no explicit role) ---
run_case "${INIT}" agent_inferred
@@ -0,0 +1,26 @@
#!/sbin/openrc-run
# eks-pod-identity-webhook — IRSA mutating admission webhook. Binds 127.0.0.1:8445
# only; the local kube-apiserver is the sole client. Supervised and respawned so
# it can wait out k3s writing its CAs without ever delaying k3s or the token
# webhook: it exits until the CAs exist and supervise-daemon retries.
description="EKS pod identity webhook (Mulga)"
command="/usr/local/bin/eks-pod-identity-webhook"
command_args="--addr=127.0.0.1:8445"
pidfile="/run/eks-pod-identity-webhook.pid"
supervisor="supervise-daemon"
respawn_delay=5
respawn_max=0
output_log="/dev/console"
error_log="/dev/console"
if [ -f /etc/spinifex-eks/first-boot.env ]; then
# shellcheck disable=SC1091
. /etc/spinifex-eks/first-boot.env
export EKS_REGION EKS_ADDON_GATEWAY_URL EKS_GATEWAY_CA
fi
depend() {
need net
after k3s
}
+5 -2
View File
@@ -22,9 +22,9 @@ APK_PACKAGES="ca-certificates curl jq iptables ip6tables conntrack-tools cgroup-
# at a pinned tag (its GitHub releases ship only container images, and its go.mod
# carries replace directives that block `go install pkg@version`). The agent half
# is the matching proxy-agent container image, pinned in k3s.initd.
BUILD_COMMANDS="CGO_ENABLED=0 GOFIPS140=v1.0.0 go build -o bin/eks-token-webhook ./cmd/eks-token-webhook && CGO_ENABLED=0 GOFIPS140=v1.0.0 go build -o bin/eks-gateway-publish ./cmd/eks-gateway-publish && CGO_ENABLED=0 GOFIPS140=v1.0.0 go build -o bin/eks-gateway-fetch ./cmd/eks-gateway-fetch && CGO_ENABLED=0 GOFIPS140=v1.0.0 go build -o bin/eks-webhook-cert ./cmd/eks-webhook-cert && CGO_ENABLED=0 GOFIPS140=v1.0.0 go build -o bin/eks-konnectivity-cert ./cmd/eks-konnectivity-cert && CGO_ENABLED=0 GOFIPS140=v1.0.0 go build -o bin/eks-etcd-member ./cmd/eks-etcd-member && CGO_ENABLED=0 GOFIPS140=v1.0.0 go build -o bin/ecr-credential-provider ./cmd/ecr-credential-provider && rm -rf /tmp/anp-build && git clone --depth 1 --branch v0.30.3 https://github.com/kubernetes-sigs/apiserver-network-proxy /tmp/anp-build && ( cd /tmp/anp-build && CGO_ENABLED=0 GOFIPS140=v1.0.0 go build -o /tmp/anp-build/konnectivity-server ./cmd/server ) && cp /tmp/anp-build/konnectivity-server bin/konnectivity-server && rm -rf /tmp/anp-build"
BUILD_COMMANDS="CGO_ENABLED=0 GOFIPS140=v1.0.0 go build -o bin/eks-token-webhook ./cmd/eks-token-webhook && CGO_ENABLED=0 GOFIPS140=v1.0.0 go build -o bin/eks-gateway-publish ./cmd/eks-gateway-publish && CGO_ENABLED=0 GOFIPS140=v1.0.0 go build -o bin/eks-gateway-fetch ./cmd/eks-gateway-fetch && CGO_ENABLED=0 GOFIPS140=v1.0.0 go build -o bin/eks-webhook-cert ./cmd/eks-webhook-cert && CGO_ENABLED=0 GOFIPS140=v1.0.0 go build -o bin/eks-konnectivity-cert ./cmd/eks-konnectivity-cert && CGO_ENABLED=0 GOFIPS140=v1.0.0 go build -o bin/eks-etcd-member ./cmd/eks-etcd-member && CGO_ENABLED=0 GOFIPS140=v1.0.0 go build -o bin/eks-pod-identity-webhook ./cmd/eks-pod-identity-webhook && CGO_ENABLED=0 GOFIPS140=v1.0.0 go build -o bin/ecr-credential-provider ./cmd/ecr-credential-provider && rm -rf /tmp/anp-build && git clone --depth 1 --branch v0.30.3 https://github.com/kubernetes-sigs/apiserver-network-proxy /tmp/anp-build && ( cd /tmp/anp-build && CGO_ENABLED=0 GOFIPS140=v1.0.0 go build -o /tmp/anp-build/konnectivity-server ./cmd/server ) && cp /tmp/anp-build/konnectivity-server bin/konnectivity-server && rm -rf /tmp/anp-build"
INSTALL_BINARIES="bin/eks-token-webhook:/usr/local/bin/eks-token-webhook bin/eks-gateway-publish:/usr/local/bin/eks-gateway-publish bin/eks-gateway-fetch:/usr/local/bin/eks-gateway-fetch bin/eks-webhook-cert:/usr/local/bin/eks-webhook-cert bin/eks-konnectivity-cert:/usr/local/bin/eks-konnectivity-cert bin/eks-etcd-member:/usr/local/bin/eks-etcd-member bin/konnectivity-server:/usr/local/bin/konnectivity-server bin/ecr-credential-provider:/usr/local/bin/ecr-credential-provider"
INSTALL_BINARIES="bin/eks-token-webhook:/usr/local/bin/eks-token-webhook bin/eks-gateway-publish:/usr/local/bin/eks-gateway-publish bin/eks-gateway-fetch:/usr/local/bin/eks-gateway-fetch bin/eks-webhook-cert:/usr/local/bin/eks-webhook-cert bin/eks-konnectivity-cert:/usr/local/bin/eks-konnectivity-cert bin/eks-etcd-member:/usr/local/bin/eks-etcd-member bin/eks-pod-identity-webhook:/usr/local/bin/eks-pod-identity-webhook bin/konnectivity-server:/usr/local/bin/konnectivity-server bin/ecr-credential-provider:/usr/local/bin/ecr-credential-provider"
# All role services ship; none but the selector are enabled at bake time.
# eks-node-role picks server vs agent at first boot and enables the rest.
@@ -36,12 +36,15 @@ scripts/images/eks-node/k3s-prestart.sh:/usr/local/sbin/k3s-prestart \
scripts/images/eks-node/k3s-agent.initd:/etc/init.d/k3s-agent \
scripts/images/eks-node/k3s-agent-prestart.sh:/usr/local/sbin/k3s-agent-prestart \
scripts/images/eks-node/eks-token-webhook.initd:/etc/init.d/eks-token-webhook \
scripts/images/eks-node/eks-pod-identity-webhook.initd:/etc/init.d/eks-pod-identity-webhook \
scripts/images/eks-node/k3s-first-boot.initd:/etc/init.d/k3s-first-boot \
scripts/images/eks-node/k3s-first-boot.sh:/usr/local/sbin/k3s-first-boot \
scripts/images/eks-node/mulga-eks-state-report.initd:/etc/init.d/mulga-eks-state-report \
scripts/images/eks-node/mulga-eks-state-report.sh:/usr/local/sbin/mulga-eks-state-report \
scripts/images/eks-node/mulga-eks-addon-sync.initd:/etc/init.d/mulga-eks-addon-sync \
scripts/images/eks-node/mulga-eks-addon-sync.sh:/usr/local/sbin/mulga-eks-addon-sync \
scripts/images/eks-node/mulga-eks-node-gc.initd:/etc/init.d/mulga-eks-node-gc \
scripts/images/eks-node/mulga-eks-node-gc.sh:/usr/local/sbin/mulga-eks-node-gc \
scripts/images/eks-node/konnectivity-server.initd:/etc/init.d/konnectivity-server \
scripts/images/eks-node/konnectivity-server-prestart.sh:/usr/local/sbin/konnectivity-server-prestart \
scripts/images/eks-node/konnectivity/konnectivity-agent.yaml:/usr/share/spinifex-eks/konnectivity/konnectivity-agent.yaml \
@@ -0,0 +1,27 @@
#!/sbin/openrc-run
# mulga-eks-node-gc — server-role service that deletes the Node of every
# nodegroup worker the nodegroup has terminated, every NODE_GC_INTERVAL
# seconds. Enabled on every server role by eks-node-role.sh.
description="Mulga EKS terminated-worker Node cleanup"
command="/usr/local/sbin/mulga-eks-node-gc"
command_background="yes"
pidfile="/run/mulga-eks-node-gc.pid"
output_log="/var/log/mulga-eks-node-gc.log"
error_log="/var/log/mulga-eks-node-gc.log"
# A Node goes NotReady about 40s after its worker stops, so 30s deletes it
# within a couple of minutes of the terminate.
export NODE_GC_INTERVAL="${NODE_GC_INTERVAL:-30}"
depend() {
need net
after k3s
}
start_pre() {
if [ ! -f /etc/spinifex-eks/first-boot.env ]; then
eerror "/etc/spinifex-eks/first-boot.env not present — cloud-init did not seed gateway creds"
return 1
fi
}
@@ -0,0 +1,89 @@
#!/bin/sh
set -eu
# mulga-eks-node-gc — deletes the Node of a nodegroup worker whose instance the
# nodegroup has terminated (DeleteNodegroup, scale-down, reclaim), as the AWS
# cloud controller deletes a Node whose instance no longer exists. K3s runs no
# AWS cloud provider: its own reports every instance as existing, so without
# this a terminated worker stays a NotReady Node forever, its pods stay bound to
# it, and once most Nodes are NotReady the node lifecycle controller stops
# evicting at all.
#
# Pull (host→VM): GET /clusters/{cluster}/internal-workers/{accountId}, the
# instance ID of every worker the cluster's nodegroups still own.
#
# A Node is deleted only when all of these hold, so a live worker is never
# touched: it carries the eks.amazonaws.com/nodegroup label, its providerID is
# aws:///<az>/<instance-id>, it is not Ready, and its instance is not in the
# fetched set. A failed fetch deletes nothing.
#
# Runs on every server; deletes are idempotent. Loops every NODE_GC_INTERVAL
# seconds in service mode, one-shot otherwise.
ENVFILE=${ENVFILE:-/etc/spinifex-eks/first-boot.env}
[ -f "${ENVFILE}" ] || { logger -t mulga-eks-node-gc "${ENVFILE} missing — exiting"; exit 0; }
# set -a so the sourced creds are exported to the eks-gateway-fetch child.
set -a
# shellcheck disable=SC1090
. "${ENVFILE}"
set +a
: "${EKS_GATEWAY_URL:?}"
: "${EKS_ACCOUNT_ID:?}"
: "${EKS_CLUSTER_NAME:?}"
KUBECONFIG=${KUBECONFIG:-/etc/rancher/k3s/k3s.yaml}
export KUBECONFIG
# One "name|providerID|nodegroup|Ready" row per Node.
NODE_ROWS='jsonpath={range .items[*]}{.metadata.name}{"|"}{.spec.providerID}{"|"}{.metadata.labels.eks\.amazonaws\.com/nodegroup}{"|"}{.status.conditions[?(@.type=="Ready")].status}{"\n"}{end}'
log() {
logger -t mulga-eks-node-gc "$*"
echo "[mulga-eks-node-gc] $*" > "${CONSOLE:-/dev/console}" 2>/dev/null || true
}
# stale_nodes LIVE_FILE — reads Node rows on stdin and prints the name of each
# Node to delete: a nodegroup worker, not Ready, whose instance is not listed
# in LIVE_FILE.
stale_nodes() {
awk -F'|' -v live="$1" '
BEGIN { while ((getline id < live) > 0) keep[id] = 1 }
$3 == "" || $4 == "True" { next }
$2 !~ /^aws:\/\/\/[^\/]+\/i-[0-9a-f]+$/ { next }
{ n = split($2, p, "/"); if (!(p[n] in keep)) print $1 }
'
}
sync_once() {
# Nodes are listed before the workers are fetched. A worker is recorded on
# its nodegroup before it boots, so every Node listed here is already in a
# set fetched after.
if ! rows=$(kubectl get nodes -o "${NODE_ROWS}" 2>/dev/null); then
log "kubectl get nodes failed; skipping this pass"
return 0
fi
live=$(mktemp)
if ! eks-gateway-fetch -resource workers > "${live}" 2>/dev/null; then
log "fetch of the cluster's nodegroup workers failed; deleting nothing"
rm -f "${live}"
return 0
fi
printf '%s\n' "${rows}" | stale_nodes "${live}" | while read -r node; do
if kubectl delete node "${node}" --ignore-not-found --wait=false >/dev/null 2>&1; then
log "deleted Node ${node}: its instance is no longer a nodegroup worker"
else
log "delete of Node ${node} failed; retrying next pass"
fi
done
rm -f "${live}"
}
if [ -n "${NODE_GC_INTERVAL:-}" ] && [ "${NODE_GC_INTERVAL}" -gt 0 ] 2>/dev/null; then
while true; do
sync_once
sleep "${NODE_GC_INTERVAL}"
done
else
sync_once
fi
@@ -0,0 +1,107 @@
#!/bin/sh
# Self-contained POSIX test for mulga-eks-node-gc.sh. kubectl, eks-gateway-fetch
# and logger are stubbed on PATH: kubectl serves canned Node rows and records
# every delete, and eks-gateway-fetch serves a canned worker set or fails.
#
# Run: sh scripts/images/eks-node/mulga-eks-node-gc_test.sh
set -eu
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
AGENT="${SCRIPT_DIR}/mulga-eks-node-gc.sh"
WORK=$(mktemp -d)
trap 'rm -rf "${WORK}"' EXIT
STUBBIN="${WORK}/bin"
mkdir -p "${STUBBIN}"
cat > "${STUBBIN}/kubectl" <<'EOF'
#!/bin/sh
case "$1 $2" in
"get nodes")
[ "${NODES_RC:-0}" -eq 0 ] || exit "${NODES_RC}"
printf '%s\n' "${NODES_BODY:-}" ;;
"delete node") echo "$3" >> "${DELETED}" ;;
esac
EOF
cat > "${STUBBIN}/eks-gateway-fetch" <<'EOF'
#!/bin/sh
echo "$*" >> "${FETCHED}"
[ "${FETCH_RC:-0}" -eq 0 ] || exit "${FETCH_RC}"
printf '%s' "${LIVE_BODY:-}"
EOF
cat > "${STUBBIN}/logger" <<'EOF'
#!/bin/sh
:
EOF
chmod +x "${STUBBIN}"/*
PATH="${STUBBIN}:${PATH}"
export PATH
ENVFILE="${WORK}/first-boot.env"
cat > "${ENVFILE}" <<'EOF'
EKS_GATEWAY_URL=https://gw.invalid:9999
EKS_ACCOUNT_ID=000000000001
EKS_CLUSTER_NAME=demo3
EOF
CONSOLE="${WORK}/console"
DELETED="${WORK}/deleted"
FETCHED="${WORK}/fetched"
export ENVFILE CONSOLE DELETED FETCHED
PASS=0
FAIL=0
check() {
if [ "$2" = "$3" ]; then
PASS=$((PASS + 1))
else
FAIL=$((FAIL + 1))
printf 'FAIL: %s\n want: %s\n got: %s\n' "$1" "$2" "$3"
fi
}
run_agent() {
: > "${DELETED}"
: > "${FETCHED}"
sh "${AGENT}"
}
deleted() { tr '\n' ' ' < "${DELETED}" | sed 's/ $//'; }
# Every way a Node can look: only a NotReady nodegroup worker whose instance the
# cluster's nodegroups no longer own is deleted.
NODES_BODY='live|aws:///z1/i-0aaa|ng1|True
gone|aws:///z1/i-0bbb|ng1|Unknown
gone-never-reported|aws:///z1/i-0ddd|ng2|
owned-notready|aws:///z1/i-0ccc|ng1|False
gone-but-ready|aws:///z1/i-0eee|ng1|True
self-managed|aws:///z1/i-0fff||Unknown
k3s-provider|k3s://k3s-node|ng1|Unknown'
LIVE_BODY='i-0aaa
i-0ccc
'
export NODES_BODY LIVE_BODY
run_agent
check "deletes only NotReady nodegroup Nodes whose instance is gone" "gone gone-never-reported" "$(deleted)"
check "fetches the workers resource" "-resource workers" "$(cat "${FETCHED}")"
# A cluster with no nodegroups left: every NotReady nodegroup Node goes.
LIVE_BODY='' run_agent
check "empty worker set deletes every NotReady nodegroup Node" "gone gone-never-reported owned-notready" "$(deleted)"
# A failed fetch is not an empty set: nothing is deleted.
FETCH_RC=1 run_agent
check "failed fetch deletes nothing" "" "$(deleted)"
# Without a Node listing the workers are not fetched, so a set fetched before a
# Node registered can never be compared against it.
NODES_RC=1 run_agent
check "failed node listing deletes nothing" "" "$(deleted)"
check "failed node listing does not fetch" "" "$(cat "${FETCHED}")"
echo "---"
echo "PASS=${PASS} FAIL=${FAIL}"
[ "${FAIL}" -eq 0 ]
+4 -2
View File
@@ -44,9 +44,11 @@ ln -sf /usr/local/bin/k3s /usr/local/bin/ctr
# role's services are baked; the selector enables the right ones at first boot.
chmod 0755 /etc/init.d/eks-node-role /etc/init.d/k3s /etc/init.d/k3s-agent \
/etc/init.d/eks-token-webhook /etc/init.d/k3s-first-boot /etc/init.d/mulga-eks-state-report \
/etc/init.d/mulga-eks-addon-sync /etc/init.d/konnectivity-server
/etc/init.d/mulga-eks-addon-sync /etc/init.d/mulga-eks-node-gc /etc/init.d/konnectivity-server \
/etc/init.d/eks-pod-identity-webhook
chmod 0755 /usr/local/sbin/eks-node-role /usr/local/sbin/k3s-first-boot \
/usr/local/sbin/mulga-eks-state-report /usr/local/sbin/mulga-eks-addon-sync
/usr/local/sbin/mulga-eks-state-report /usr/local/sbin/mulga-eks-addon-sync \
/usr/local/sbin/mulga-eks-node-gc
# start_pre helpers shared with the systemd wrappers that will eventually
# replace these runscripts.
chmod 0755 /usr/local/sbin/k3s-prestart /usr/local/sbin/k3s-agent-prestart \
@@ -0,0 +1,25 @@
#!/bin/sh
# Every /etc/init.d script that INSTALL_FILES ships at 0644 must be chmod'd
# 0755 by setup.sh, or OpenRC refuses to enable it ("is not executable") and
# eks-node-role, running under set -eu, aborts before k3s starts.
#
# Run: sh scripts/images/eks-node/setup_initd_test.sh
set -eu
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
MANIFEST="${SCRIPT_DIR}/manifest.conf"
SETUP="${SETUP_SH:-${SCRIPT_DIR}/setup.sh}"
fail=0
for dst in $(grep -o ':/etc/init\.d/[A-Za-z0-9._-]*' "${MANIFEST}" | cut -d: -f2); do
if ! grep -E '^[[:space:]]*(chmod 0755 )?.*'"${dst}"'([[:space:]\\]|$)' "${SETUP}" \
| grep -q . ; then
echo "FAIL: ${dst} is installed 0644 and never chmod'd 0755 in setup.sh"
fail=1
fi
done
if [ "${fail}" -ne 0 ]; then
exit 1
fi
echo "PASS: every shipped init script is chmod'd 0755"
+391
View File
@@ -0,0 +1,391 @@
#!/bin/bash
# microvm-image.sh — shared steps for the microVM image builders, sourced by
# build-sqs-store-microvm-image.sh and build-lambda-runtime-microvm-image.sh.
#
# The caller sets before use:
# TAG log prefix
# BUILD_DIR output directory; STAGE_DIR is created beside it
# ALPINE_VERSION, ALPINE_MIRROR, ALPINE_PACKAGES
# MICROVM_EXTRA_MODULES modules kept by name, with their dependency closure
# MICROVM_GATE_MODULES modules the image modprobe must resolve, beyond the extras
# MICROVM_EXTRA_KEEP_GLOBS bash array of module file globs kept on top of virtio*/*fw_cfg*
# Functions set CHROOT_DIR, STAGE_DIR, KERNEL_IMG and KERNEL_VER for the caller.
microvm_check_tools() {
# --- Verify non-negotiable tools ---
# fakeroot is mandatory: device nodes are written into the cpio archive inside a
# faked-root environment so no real device node is ever created on the host
# filesystem (which previously required sudo mknod and risked clobbering host
# /dev/null on cleanup).
for tool in cpio gzip find fakeroot; do
if ! command -v "$tool" >/dev/null 2>&1; then
echo "ERROR: required tool not found: $tool" >&2
exit 1
fi
done
}
microvm_init_dirs() {
# --- Create chroot ---
CHROOT_DIR=$(mktemp -d)
# Guard: every destructive op below targets "$CHROOT_DIR/...". An empty or
# unexpected value would aim rm -rf at the host (e.g. /dev). Refuse anything that
# is not a freshly-created temp dir.
case "${CHROOT_DIR:?mktemp -d returned empty}" in
/tmp/*|/var/tmp/*|"${TMPDIR:-/nonexistent}"/*) ;;
*) echo "ERROR: refusing to use unexpected CHROOT_DIR: $CHROOT_DIR" >&2; exit 1 ;;
esac
# Outputs are written here first, beside $BUILD_DIR so publishing is a rename.
STAGE_DIR=$(mktemp -d "$BUILD_DIR/.stage.XXXXXX")
CONTAINER_TOOL=""
CONTAINER_CID=""
trap microvm_cleanup EXIT
}
microvm_cleanup() {
echo "$TAG cleaning up chroot: $CHROOT_DIR"
rm -rf "$CHROOT_DIR"
rm -rf "${STAGE_DIR:?}"
if [ -n "$CONTAINER_CID" ] && [ -n "$CONTAINER_TOOL" ]; then
echo "$TAG cleaning up container: $CONTAINER_CID"
$CONTAINER_TOOL rm -f "$CONTAINER_CID" >/dev/null 2>&1 || true
fi
}
# microvm_make_rootfs populates $CHROOT_DIR from ALPINE_PACKAGES.
microvm_make_rootfs() {
# --- Populate rootfs ---
# Strategy 1: native apk (Alpine hosts, CI)
if command -v apk >/dev/null 2>&1; then
echo "$TAG rootfs: native apk"
mkdir -p \
"$CHROOT_DIR/etc/apk" \
"$CHROOT_DIR/lib/apk/db" \
"$CHROOT_DIR/var/cache/apk"
cat > "$CHROOT_DIR/etc/apk/repositories" <<EOF
${ALPINE_MIRROR}/v${ALPINE_VERSION}/main
${ALPINE_MIRROR}/v${ALPINE_VERSION}/community
EOF
apk add \
--root "$CHROOT_DIR" \
--arch x86_64 \
--no-cache \
--allow-untrusted \
--initdb \
$ALPINE_PACKAGES
# Strategy 2: docker -> BuildKit local export.
# Do NOT use `docker export` here. On Docker's overlay2 driver it leaks the
# container's overlay mount (the mount survives `docker rm`), orphaning layers
# under /var/lib/docker that only a daemon restart reclaims; on a busy CI host
# this grows without bound. `buildx --output type=local` writes the image
# filesystem straight to a directory and releases every mount, so nothing leaks.
elif command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then
echo "$TAG rootfs: docker buildx --output type=local (alpine:${ALPINE_VERSION})"
BUILD_CTX=$(mktemp -d)
cat > "$BUILD_CTX/Dockerfile" <<DOCKERFILE
FROM alpine:${ALPINE_VERSION}
RUN apk add --no-cache ${ALPINE_PACKAGES}
DOCKERFILE
docker buildx build \
--platform linux/amd64 \
--output "type=local,dest=${CHROOT_DIR}" \
"$BUILD_CTX"
rm -rf "$BUILD_CTX"
# Strategy 3: podman -> run + export. Podman's store does not exhibit the docker
# overlay2 export-mount leak, and podman has no buildx.
elif command -v podman >/dev/null 2>&1; then
echo "$TAG rootfs: podman export (alpine:${ALPINE_VERSION})"
CONTAINER_TOOL=podman
CONTAINER_CID=$(podman run -d \
"alpine:${ALPINE_VERSION}" \
sh -c "apk add --no-cache ${ALPINE_PACKAGES}")
exit_code=$(podman wait "$CONTAINER_CID")
if [ "$exit_code" != "0" ]; then
echo "ERROR: package installation failed in container (exit $exit_code)" >&2
exit 1
fi
podman export "$CONTAINER_CID" | tar -x -C "$CHROOT_DIR"
else
echo "ERROR: no rootfs build tool available." >&2
echo " Install one of: apk (Alpine), docker, podman" >&2
exit 1
fi
}
microvm_empty_dev() {
# --- /dev: empty mountpoint only ---
# tar extraction without root silently creates 0-byte regular files for device
# nodes. Drop them and leave an empty /dev mountpoint. The two static nodes the
# kernel needs (/dev/console to wire init's stdio, /dev/null) are written into
# the cpio archive under fakeroot at pack time — NOT created on the host. init
# mounts devtmpfs over /dev as its first action for everything else at runtime.
rm -rf "${CHROOT_DIR:?}/dev"
mkdir "$CHROOT_DIR/dev"
}
# microvm_locate_kernel finds the kernel and asserts fw_cfg is loadable or built in.
microvm_locate_kernel() {
# --- Locate kernel ---
KERNEL_IMG=$(find "$CHROOT_DIR/boot" -name "vmlinuz*" | sort | tail -1)
if [ -z "$KERNEL_IMG" ]; then
echo "ERROR: no vmlinuz found in $CHROOT_DIR/boot" >&2
exit 1
fi
echo "$TAG kernel image: $KERNEL_IMG"
KERNEL_VER=$(basename "$KERNEL_IMG" | sed 's/vmlinuz-//')
KERNEL_CONFIG="$CHROOT_DIR/boot/config-${KERNEL_VER}"
# --- Assert fw_cfg module ---
if ! find "$CHROOT_DIR/lib/modules" \
-name "qemu_fw_cfg.ko*" \
-o -name "fw_cfg_sysfs.ko*" \
| grep -q .; then
if ! grep -q "CONFIG_FW_CFG_SYSFS=y" "$KERNEL_CONFIG" 2>/dev/null; then
echo "ERROR: qemu_fw_cfg module missing from initramfs — fw_cfg reads will fail at boot" >&2
exit 1
fi
echo "$TAG fw_cfg: built-in (CONFIG_FW_CFG_SYSFS=y)"
else
echo "$TAG fw_cfg: module found in lib/modules"
fi
}
# --- Strip kernel modules (Phase 2 keep-list approach) ---
# Only modules needed by a virtio-mmio microvm guest are kept:
# virtio*.ko* — virtio bus, net, blk, rng, console, and helpers
# *fw_cfg*.ko* — QEMU fw_cfg sysfs driver (delivers boot blobs)
# $MICROVM_EXTRA_KEEP_GLOBS — caller-named module file globs
# $MICROVM_EXTRA_MODULES — caller-named modules, plus dependency closure
# All other loadable modules are removed. Drivers compiled into the kernel
# (CONFIG_VIRTIO_MMIO=y etc.) are unaffected — they live in vmlinuz, not here.
# module_path prints a module's kernel/ path from the stock modules.dep, by
# name or modules.alias; it fails when the module is not loadable.
module_path() {
local kdir="$1" name="${2//-/_}" path alias_target
path=$(awk -F: -v n="$name" '{ f = $1; sub(/.*\//, "", f); sub(/\.ko(\.gz|\.xz|\.zst)?$/, "", f);
gsub(/-/, "_", f); if (f == n) { print $1; exit } }' "$kdir/modules.dep")
if [ -z "$path" ] && [ -f "$kdir/modules.alias" ]; then
alias_target=$(awk -v n="$2" '$1 == "alias" && $2 == n { print $3; exit }' "$kdir/modules.alias")
[ -n "$alias_target" ] && [ "$alias_target" != "$2" ] && path=$(module_path "$kdir" "$alias_target")
fi
[ -n "$path" ] && printf '%s\n' "$path"
}
# module_builtin reports whether a module name is compiled into the kernel.
module_builtin() {
local kdir="$1" name="${2//-/_}"
[ -f "$kdir/modules.builtin" ] || return 1
awk -v n="$name" '{ f = $0; sub(/.*\//, "", f); sub(/\.ko$/, "", f); gsub(/-/, "_", f);
if (f == n) { found = 1; exit } } END { exit !found }' "$kdir/modules.builtin"
}
# image_kmod runs the image's own kmod (depmod, modprobe) through its musl
# loader, so module metadata matches the guest's modprobe without a host kmod.
image_kmod() {
"$CHROOT_DIR/lib/ld-musl-x86_64.so.1" --library-path "$CHROOT_DIR/lib:$CHROOT_DIR/usr/lib" \
--argv0 "$1" "$CHROOT_DIR/bin/kmod" "${@:2}"
}
microvm_strip_modules() {
echo "$TAG stripping kernel modules to virtio+fw_cfg+${MICROVM_EXTRA_MODULES} only..."
for kver_dir in "$CHROOT_DIR/lib/modules"/*/; do
kver="$(basename "$kver_dir")"
kernel_dir="$kver_dir/kernel"
[ -d "$kernel_dir" ] || continue
if [ ! -s "$kver_dir/modules.dep" ]; then
echo "ERROR: stock ${kver_dir}modules.dep missing — cannot resolve module dependencies" >&2
exit 1
fi
# Extra modules start the closure; the glob families are kept as found.
keep_list=$(mktemp)
: > "$keep_list"
for name in $MICROVM_EXTRA_MODULES; do
if ! path=$(module_path "$kver_dir" "$name"); then
if module_builtin "$kver_dir" "$name"; then continue; fi
echo "ERROR: module $name is neither loadable nor built into $kver" >&2
exit 1
fi
echo "$path" >> "$keep_list"
done
# Close over modules.dep hard dependencies and modules.softdep pre:/post:
# names until the set stops growing. A softdep must be loadable or built in.
while :; do
before=$(sort -u "$keep_list" | wc -l)
for path in $(sort -u "$keep_list"); do
awk -F': ' -v p="$path" '$1 == p { n = split($2, d, " "); for (i = 1; i <= n; i++) print d[i] }' \
"$kver_dir/modules.dep" >> "$keep_list"
mod=$(basename "$path"); mod="${mod%%.ko*}"; mod="${mod//-/_}"
[ -f "$kver_dir/modules.softdep" ] || continue
for soft in $(awk -v n="$mod" '{ m = $2; gsub(/-/, "_", m) }
$1 == "softdep" && m == n { for (i = 3; i <= NF; i++) if ($i != "pre:" && $i != "post:") print $i }' \
"$kver_dir/modules.softdep"); do
if soft_path=$(module_path "$kver_dir" "$soft"); then
echo "$soft_path" >> "$keep_list"
elif ! module_builtin "$kver_dir" "$soft"; then
echo "ERROR: softdep $soft of $mod is neither loadable nor built into $kver" >&2
exit 1
fi
done
done
after=$(sort -u "$keep_list" | wc -l)
[ "$before" -eq "$after" ] && break
done
extra_names=$(sort -u "$keep_list" | while read -r p; do m=$(basename "$p"); m="${m%%.ko*}"; echo "${m//-/_}"; done)
find_args=(-name "virtio*.ko*" -o -name "*fw_cfg*.ko*")
for glob in ${MICROVM_EXTRA_KEEP_GLOBS[@]+"${MICROVM_EXTRA_KEEP_GLOBS[@]}"}; do
find_args+=(-o -name "$glob")
done
(cd "$kver_dir" && find kernel "${find_args[@]}") >> "$keep_list"
# Copy the keeper set out, preserving each kernel/ relative path.
tmp_keep=$(mktemp -d)
while read -r rel; do
rel="${rel#kernel/}"
mkdir -p "$tmp_keep/$(dirname "$rel")"
cp "$kernel_dir/$rel" "$tmp_keep/$rel"
done < <(sort -u "$keep_list")
rm -f "$keep_list"
# Replace kernel module tree with the keeper set.
rm -rf "$kernel_dir"
mkdir -p "$kernel_dir"
# Restore each kept file into the same relative path.
(cd "$tmp_keep" && find . -name "*.ko*" | while read -r f; do
dest_dir="$kernel_dir/$(dirname "${f#./}")"
mkdir -p "$dest_dir"
cp "$f" "$dest_dir/"
done)
rm -rf "$tmp_keep"
# Decompress kept modules to plain .ko. Alpine ships .ko.gz, but a host
# kmod that lacks gzip support (e.g. Debian trixie's kmod 34) silently
# produces an EMPTY modules.dep from compressed modules — the guest's
# modprobe then no-ops and qemu_fw_cfg never loads. Plain .ko sidesteps the
# host-kmod dependency entirely and the guest needs no decompressor either.
find "$kernel_dir" -name "*.ko.gz" -exec gunzip -f {} +
# Independent closure gate: each extra-closure module's own .modinfo depends=
# must name a kept module or a built-in, so a resolver bug fails here.
kept_names=$(find "$kernel_dir" -name "*.ko" -exec basename {} .ko \; | tr '-' '_' | sort -u)
while read -r ko; do
ko_name=$(basename "$ko" .ko); ko_name="${ko_name//-/_}"
grep -qx "$ko_name" <<< "$extra_names" || continue
deps=$(tr '\0' '\n' < "$ko" | grep -a -m1 '^depends=' | cut -d= -f2 | tr ',' ' ' || true)
for dep in $deps; do
dep="${dep//-/_}"
if ! grep -qx "$dep" <<< "$kept_names" && ! module_builtin "$kver_dir" "$dep"; then
echo "ERROR: $(basename "$ko") depends on $dep, which is neither kept nor built in" >&2
exit 1
fi
done
done < <(find "$kernel_dir" -name "*.ko")
for name in $MICROVM_EXTRA_MODULES; do
if ! grep -qx "${name//-/_}" <<< "$kept_names" && ! module_builtin "$kver_dir" "$name"; then
echo "ERROR: required module $name missing from the stripped module tree" >&2
exit 1
fi
done
echo "$TAG extra module closure: $(echo "$extra_names" | tr '\n' ' ')"
echo "$TAG modules kept: $(echo "$kept_names" | tr '\n' ' ')"
# Rebuild module metadata with the image's own kmod, the binary the guest's
# modprobe runs, so no host depmod is needed. Stock indexes still name .ko.gz
# files, so they are removed first and a failed rebuild cannot ship them.
for f in dep dep.bin alias alias.bin softdep weakdep symbols symbols.bin \
builtin.bin builtin.alias.bin devname; do
rm -f "$kver_dir/modules.$f"
done
if ! image_kmod depmod -b "$CHROOT_DIR" "$kver"; then
echo "ERROR: image depmod failed for $kver" >&2
exit 1
fi
if [ ! -s "$kver_dir/modules.dep" ] || [ ! -s "$kver_dir/modules.dep.bin" ]; then
echo "ERROR: ${kver_dir}modules.dep or modules.dep.bin missing or empty after depmod" >&2
exit 1
fi
# Resolution gate: the image's modprobe must resolve every required module
# to plain .ko files present in the stripped tree.
for name in $MICROVM_EXTRA_MODULES $MICROVM_GATE_MODULES; do
module_builtin "$kver_dir" "$name" && continue
if ! plan=$(image_kmod modprobe -d "$CHROOT_DIR" -S "$kver" --show-depends "$name"); then
echo "ERROR: image modprobe cannot resolve $name for $kver" >&2
exit 1
fi
while read -r verb path _; do
[ "$verb" = "insmod" ] || continue
case "$path" in
"$CHROOT_DIR/lib/modules/$kver/kernel/"*.ko) [ -f "$path" ] && continue ;;
esac
echo "ERROR: modprobe plan for $name names $path, not a kept plain .ko" >&2
exit 1
done <<< "$plan"
echo "$TAG modprobe $name: $(awk '{ n = split($2, a, "/"); printf "%s ", a[n] }' <<< "$plan")"
done
done
}
# microvm_strip_rootfs drops package-manager artifacts and debug symbols.
microvm_strip_rootfs() {
# --- Strip package-manager artifacts (not needed at runtime) ---
echo "$TAG stripping package manager artifacts..."
rm -rf \
"$CHROOT_DIR/lib/apk" \
"$CHROOT_DIR/var/cache/apk" \
"$CHROOT_DIR/etc/apk" \
"$CHROOT_DIR/usr/share/man" \
"$CHROOT_DIR/usr/share/doc" \
"$CHROOT_DIR/usr/share/apk" \
"$CHROOT_DIR/usr/include" \
"$CHROOT_DIR/usr/lib/pkgconfig"
# --- Strip debug symbols from binaries ---
echo "$TAG stripping debug symbols from binaries..."
find "$CHROOT_DIR/usr/sbin" "$CHROOT_DIR/usr/bin" \
"$CHROOT_DIR/sbin" "$CHROOT_DIR/bin" \
-type f 2>/dev/null | while read -r bin; do
strip --strip-all "$bin" 2>/dev/null || true
done
}
# microvm_stage_kernel copies vmlinuz to $STAGE_DIR, then drops /boot from the chroot.
microvm_stage_kernel() {
# --- Copy vmlinuz out before stripping /boot from chroot ---
VMLINUZ_OUT="$STAGE_DIR/vmlinuz"
echo "$TAG copying kernel: $VMLINUZ_OUT"
cp "$KERNEL_IMG" "$VMLINUZ_OUT"
# Drop /boot from the chroot — vmlinuz, Alpine's stock initramfs-virt, System.map
# and kernel config all live here (~25 MiB). None are needed at runtime: the
# guest already has the kernel loaded by QEMU's -kernel, and Alpine's initramfs
# is unused because we replace it with our own /init. Leaving /boot in the cpio
# nearly doubles uncompressed initramfs size and forces a 512 MiB guest just to
# survive decompression.
rm -rf "${CHROOT_DIR:?}/boot"
}
microvm_pack_initramfs() {
# --- Build initramfs ---
INITRAMFS_OUT="$STAGE_DIR/initramfs.cpio.gz"
echo "$TAG building initramfs: $INITRAMFS_OUT"
# Create the static device nodes and pack the archive inside a single fakeroot
# session: mknod/cpio see faked device entries and write real char-device
# records into the cpio, while the host filesystem never gets an actual node.
fakeroot sh -c '
cd "$1" || exit 1
mknod -m 600 dev/console c 5 1
mknod -m 666 dev/null c 1 3
find . | cpio --quiet -o -H newc | gzip -9 > "$2"
' _ "$CHROOT_DIR" "$INITRAMFS_OUT"
}
-293
View File
@@ -1,293 +0,0 @@
#!/bin/sh
# sqs-store-guest_test.sh — the SQS store guest steps start the store only on
# a valid contract and one checked, mounted disk, never format, and stop the
# store before unmounting it.
#
# Drives build/microvm/sqs-store/sqs-store-lib.sh against a fake fw_cfg, sysfs
# and stub tools. The stop cases signal only test-owned children.
# The case settings below are read by the sourced library, not by this file.
# shellcheck disable=SC2034
set -eu
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/../.." && pwd)
LIB=${SQS_GUEST_LIB:-"$ROOT/build/microvm/sqs-store/sqs-store-lib.sh"}
TMP=$(mktemp -d)
CHILDREN=""
cleanup() {
for p in $CHILDREN; do kill -KILL "$p" 2>/dev/null || true; done
rm -rf "$TMP"
}
trap cleanup EXIT
fails=0
pass() { echo " ok - $1"; }
fail() { echo " FAIL - $1"; fails=$((fails + 1)); }
check() { if eval "$2"; then pass "$1"; else fail "$1"; fi; }
echo "-- build/microvm/sqs-store/sqs-store-lib.sh"
SERIAL=vol0123456789abcdef0
NKEY=UAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB
ADDR=10.11.12.13
# Stub tools record each call to calls.log; their exit codes come from files.
mkdir -p "$TMP/bin"
cat > "$TMP/bin/e2fsck" <<'EOF'
#!/bin/sh
echo "e2fsck $*" >> "$T/calls.log"
exit "$(cat "$T/fsck.rc")"
EOF
cat > "$TMP/bin/mount" <<'EOF'
#!/bin/sh
echo "mount $*" >> "$T/calls.log"
exit "$(cat "$T/mount.rc")"
EOF
cat > "$TMP/bin/umount" <<'EOF'
#!/bin/sh
echo "umount $*" >> "$T/calls.log"
EOF
for f in mkfs mkfs.ext4 mke2fs; do
printf '#!/bin/sh\necho "mkfs %s $*" >> "$T/calls.log"\n' "$f" > "$TMP/bin/$f"
done
cat > "$TMP/bin/ip" <<'EOF'
#!/bin/sh
cat "$T/ip.out"
EOF
cat > "$TMP/bin/nats-server" <<'EOF'
#!/bin/sh
echo "nats-server $*" >> "$T/calls.log"
[ "$1" = -t ] || echo "GOMEMLIMIT=${GOMEMLIMIT:-unset}" >> "$T/env.log"
[ "$1" = -t ] && exit "$(cat "$T/configtest.rc")"
exit 0
EOF
chmod +x "$TMP/bin"/*
# fresh <name>: a case directory with a valid contract and one matching disk
# among others. Callers then break one thing.
fresh() {
T="$TMP/$1"
export T
mkdir -p "$T/fwcfg" "$T/sys" "$T/dev" "$T/run" "$T/data"
for k in data-serial listen-addr client-nkey; do mkdir -p "$T/fwcfg/$k"; done
printf '%s\n' "$SERIAL" > "$T/fwcfg/data-serial/raw"
printf '%s\n' "$ADDR" > "$T/fwcfg/listen-addr/raw"
printf '%s\n' "$NKEY" > "$T/fwcfg/client-nkey/raw"
disk vda vol00000000000000000
disk vdb "$SERIAL"
echo 0 > "$T/fsck.rc"
echo 0 > "$T/mount.rc"
echo 0 > "$T/configtest.rc"
: > "$T/calls.log"
: > "$T/console"
printf '1: lo inet 127.0.0.1/8 scope host lo\n2: eth0 inet %s/24 brd 10.11.12.255 scope global eth0\n' "$ADDR" > "$T/ip.out"
}
disk() { mkdir -p "$T/sys/$1"; printf '%s\n' "$2" > "$T/sys/$1/serial"; : > "$T/dev/$1"; }
# prepare: run sqs_prepare in a subshell with every path pointed at the case.
prepare() {
(
SQS_FWCFG="$T/fwcfg" SQS_SYSBLOCK="$T/sys" SQS_DEVDIR="$T/dev" SQS_NODE_TEST=-e
SQS_RUN="$T/run" SQS_DATA="$T/data" SQS_CONSOLE="$T/console"
E2FSCK="$TMP/bin/e2fsck" MOUNT="$TMP/bin/mount" IP="$TMP/bin/ip" NATS_BIN="$TMP/bin/nats-server"
PATH="$TMP/bin:$PATH"
export PATH
# shellcheck source=../../build/microvm/sqs-store/sqs-store-lib.sh
. "$LIB"
sqs_prepare
) > /dev/null 2>&1
}
refused() { [ ! -e "$T/run/ready" ] && grep -q FATAL "$T/console"; }
never_mounted() { ! grep -q '^mount ' "$T/calls.log"; }
never_formatted() { ! grep -q '^mkfs ' "$T/calls.log"; }
never_checked() { ! grep -q '^e2fsck ' "$T/calls.log"; }
# --- A valid contract prepares the store ---
fresh valid
check "valid contract is prepared" 'prepare && [ -e "$T/run/ready" ]'
check "the matching disk is checked, then mounted at the data dir" \
'grep -qx "e2fsck -p $T/dev/vdb" "$T/calls.log" && grep -qx "mount -t ext4 -o nodev,nosuid,noexec $T/dev/vdb $T/data" "$T/calls.log"'
check "config listens only on the supplied address" \
'grep -c "listen" "$T/run/nats.conf" | grep -qx 1 && grep -qx "listen: \"$ADDR:4222\"" "$T/run/nats.conf"'
check "config admits only the supplied user NKey" 'grep -q "users = \[ { nkey: \"$NKEY\" } \]" "$T/run/nats.conf"'
check "config syncs JetStream on the data disk" \
'grep -q "store_dir: \"$T/data\"" "$T/run/nats.conf" && grep -q "sync_interval: always" "$T/run/nats.conf"'
check "config admits a full SQS message (2MiB max_payload)" \
'grep -c "max_payload" "$T/run/nats.conf" | grep -qx 1 && grep -qx "max_payload: 2MiB" "$T/run/nats.conf"'
check "config is tested by the server before ready" 'grep -qx "nats-server -t -c $T/run/nats.conf" "$T/calls.log"'
check "no auth material on the console" '! grep -q "$NKEY" "$T/console"'
check "prepare reports the disk and config, not a listener" 'grep -q "prepared: $T/dev/vdb mounted" "$T/console" && ! grep -qi "listening" "$T/console"'
fresh fsck-corrected; echo 1 > "$T/fsck.rc"
check "e2fsck exit 1 (corrected) proceeds" 'prepare && [ -e "$T/run/ready" ]'
# --- Disk refusals: nothing mounted, nothing formatted ---
fresh no-disk; rm -rf "$T/sys/vdb"
check "missing disk is refused before any check or mount" '! prepare && refused && never_checked && never_mounted'
fresh wrong-disk; printf 'vol0fffffffffffffff0\n' > "$T/fwcfg/data-serial/raw"
check "disk with another serial is refused" '! prepare && refused && never_checked && never_mounted'
fresh two-disks; disk vdc "$SERIAL"
check "two disks with the serial are refused" '! prepare && refused && never_checked && never_mounted'
fresh bad-serial; printf 'vol-0123456789abcdef\n' > "$T/fwcfg/data-serial/raw"
check "malformed serial is refused" '! prepare && refused && never_mounted'
fresh no-node; rm -f "$T/dev/vdb"
check "disk without a device node is refused" '! prepare && refused && never_mounted'
for rc in 2 4 8; do
fresh "fsck-$rc"; echo "$rc" > "$T/fsck.rc"
check "e2fsck exit $rc is refused without mount or format" '! prepare && refused && never_mounted && never_formatted'
done
fresh mount-fail; echo 32 > "$T/mount.rc"
check "failed mount is refused" '! prepare && refused'
# --- Auth and listener refusals happen before the disk is touched ---
fresh no-nkey; rm -rf "$T/fwcfg/client-nkey"
check "missing NKey is refused before the disk" '! prepare && refused && never_checked && never_mounted'
for bad in seed lower short two empty; do
fresh "nkey-$bad"
case $bad in
seed) v=SUAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA ;;
lower) v=uaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaab ;;
short) v=UAAAA ;;
two) v="$NKEY
$NKEY" ;;
empty) v="" ;;
esac
printf '%s\n' "$v" > "$T/fwcfg/client-nkey/raw"
check "NKey ($bad) is refused before the disk, without echoing it" \
'! prepare && refused && never_checked && { [ -z "$v" ] || ! grep -qF "$v" "$T/console"; }'
done
fresh nkey-checksum; echo 1 > "$T/configtest.rc"
check "NKey the server rejects is refused before the disk" '! prepare && refused && never_checked && never_mounted'
fresh no-addr; rm -rf "$T/fwcfg/listen-addr"
check "missing listen address is refused" '! prepare && refused && never_mounted'
fresh addr-absent; printf '1: lo inet 127.0.0.1/8 scope host lo\n2: eth0 inet 10.11.12.99/24 scope global eth0\n' > "$T/ip.out"
check "listen address not on a guest NIC is refused" '! prepare && refused && never_mounted'
fresh addr-loopback; printf '127.0.0.1\n' > "$T/fwcfg/listen-addr/raw"
check "loopback listen address is refused" '! prepare && refused && never_mounted'
for bad in 0.0.0.0x 1.2.3 1.2.3.256 01.2.3.4; do
fresh "addr-$bad"; printf '%s\n' "$bad" > "$T/fwcfg/listen-addr/raw"
check "malformed listen address $bad is refused" '! prepare && refused && never_mounted'
done
# --- Run: the store starts only when prepared and not stopping ---
run_step() {
(
SQS_RUN="$T/run" SQS_CONSOLE="$T/console" NATS_BIN="$TMP/bin/nats-server"
# shellcheck source=../../build/microvm/sqs-store/sqs-store-lib.sh
. "$LIB"
sqs_run
) > /dev/null 2>&1
}
fresh run-ready; prepare; : > "$T/calls.log"
check "a prepared store starts the server" 'run_step && grep -qx "nats-server -c $T/run/nats.conf" "$T/calls.log"'
fresh run-memlimit; prepare; : > "$T/env.log"
check "the server runs under the ADR-0011:S11 heap limit" 'run_step && grep -qx "GOMEMLIMIT=512MiB" "$T/env.log"'
fresh run-unready
check "an unprepared store never starts the server" '! run_step && ! grep -q "^nats-server" "$T/calls.log"'
fresh run-stopping; prepare; : > "$T/calls.log"; : > "$T/run/stopping"
check "a stopping store never starts the server" '! run_step && ! grep -q "^nats-server" "$T/calls.log"'
# --- Stop: SIGTERM the verified store, then unmount ---
STORE="$TMP/bin/fake-store"
cp "$(command -v sleep)" "$STORE"
stop_step() {
(
SQS_RUN="$T/run" SQS_DATA="$T/data" SQS_CONSOLE="$T/console" SQS_MOUNTS="$T/mounts"
UMOUNT="$TMP/bin/umount" NATS_BIN="$STORE" SQS_STOP_BUDGET_TENTHS=10
# shellcheck source=../../build/microvm/sqs-store/sqs-store-lib.sh
. "$LIB"
sqs_stop
) > /dev/null 2>&1
}
gone() { [ ! -r "/proc/$1/status" ] || grep -q '^State:[[:space:]]*Z' "/proc/$1/status"; }
# record <pid>: write the identity sqs_run would record for that process.
# shellcheck source=../../build/microvm/sqs-store/sqs-store-lib.sh
record() { echo "$1 $( . "$LIB"; sqs_starttime "$1" )" > "$T/run/pid"; }
fresh stop-orderly; echo "dev $T/data ext4 rw 0 0" > "$T/mounts"
"$STORE" 60 & pid=$!; CHILDREN="$CHILDREN $pid"; record "$pid"
stop_step
check "stop sends SIGTERM and the store exits" 'gone "$pid" && grep -q "SIGTERM sent" "$T/console" && ! grep -q SIGKILL "$T/console"'
check "stop blocks respawn and unmounts after the store exits" \
'[ -e "$T/run/stopping" ] && grep -qx "umount $T/data" "$T/calls.log"'
fresh stop-hung; echo "dev $T/data ext4 rw 0 0" > "$T/mounts"
(trap '' TERM; exec "$STORE" 60) & pid=$!; CHILDREN="$CHILDREN $pid"; record "$pid"
stop_step
check "store ignoring SIGTERM is killed after the budget, then unmounted" \
'gone "$pid" && grep -q SIGKILL "$T/console" && grep -qx "umount $T/data" "$T/calls.log"'
fresh stop-reused; echo "dev $T/data ext4 rw 0 0" > "$T/mounts"
"$STORE" 60 & pid=$!; CHILDREN="$CHILDREN $pid"; echo "$pid 1" > "$T/run/pid"
stop_step
check "a recorded PID with another start time is never signalled" '! gone "$pid" && grep -q "no running store" "$T/console"'
kill -KILL "$pid"
# A start held after its stopping check and before its exec: the run step
# blocks writing "run: starting" to a FIFO console until the test reads it.
fresh stop-pending-start; echo "dev $T/data ext4 rw 0 0" > "$T/mounts"
: > "$T/run/ready"
mkfifo "$T/run-console"
printf '#!/bin/sh\n: > "%s/started"\nexec "%s" 60\n' "$T" "$STORE" > "$TMP/bin/pending-nats"
chmod +x "$TMP/bin/pending-nats"
SQS_RUN="$T/run" SQS_CONSOLE="$T/run-console" NATS_BIN="$TMP/bin/pending-nats" \
sh -c '. "$1"; sqs_run' sh "$LIB" & pid=$!; CHILDREN="$CHILDREN $pid"
i=0
while [ "$i" -lt 100 ] && ! grep -q "^$pid " "$T/run/pid" 2>/dev/null; do i=$((i + 1)); sleep 0.05; done
check "pending start is held before its exec" 'grep -q "^$pid " "$T/run/pid" && ! gone "$pid" && [ ! -e "$T/started" ]'
stop_step
# Release the FIFO: a start that survived the stop would exec the server now.
cat "$T/run-console" > /dev/null 2>&1 &
reader=$!; CHILDREN="$CHILDREN $reader"
i=0
while [ "$i" -lt 20 ] && ! gone "$pid"; do i=$((i + 1)); sleep 0.05; done
check "stop ends a pending start before it can exec the server" 'gone "$pid" && [ ! -e "$T/started" ]'
check "stop unmounts only after the pending start is gone" \
'grep -q "store exited" "$T/console" && grep -qx "umount $T/data" "$T/calls.log"'
kill -KILL "$reader" 2>/dev/null || true
# --- Shutdown: the generic remount runs only once the store is gone ---
# shutdown_step [survivor-pid]: with a pid, that process is reported alive
# whatever happens to it, as a writer stuck past SIGKILL would be.
shutdown_step() {
(
SQS_RUN="$T/run" SQS_DATA="$T/data" SQS_CONSOLE="$T/console" SQS_MOUNTS="$T/mounts"
UMOUNT="$TMP/bin/umount" SQS_STOP_BUDGET_TENTHS=5
# shellcheck source=../../build/microvm/sqs-store/sqs-store-lib.sh
. "$LIB"
if [ -n "${1:-}" ]; then sqs_same() { [ "$1" = "$survivor" ]; }; survivor=$1; fi
sqs_shutdown
) > /dev/null 2>&1
}
fresh shutdown-orderly; echo "dev $T/data ext4 rw 0 0" > "$T/mounts"
"$STORE" 60 & pid=$!; CHILDREN="$CHILDREN $pid"; record "$pid"
check "orderly shutdown succeeds" 'shutdown_step'
check "orderly shutdown unmounts the data disk, then runs umount -a -r" \
'[ "$(grep "^umount" "$T/calls.log" | tr "\n" "|")" = "umount $T/data|umount -a -r|" ]'
fresh shutdown-survivor; echo "dev $T/data ext4 rw 0 0" > "$T/mounts"
"$STORE" 60 & pid=$!; CHILDREN="$CHILDREN $pid"; record "$pid"
check "shutdown with a surviving store reports failure" '! shutdown_step "$pid"'
check "a surviving store leaves the data disk and every other mount alone" \
'! grep -q "^umount" "$T/calls.log" && grep -q "STILL ALIVE" "$T/console" && grep -q "skipping umount -a -r" "$T/console"'
fresh stop-unmounted; : > "$T/mounts"
stop_step
check "stop with no data mount does not unmount" '! grep -q "^umount" "$T/calls.log"'
if [ "$fails" -ne 0 ]; then
echo " $fails failure(s)"
exit 1
fi
+4 -11
View File
@@ -11,13 +11,11 @@ STAGE_DIR="${1:?usage: sqs-store-microvm-publish.sh STAGE_DIR OUT_DIR}"
OUT_DIR="${2:?usage: sqs-store-microvm-publish.sh STAGE_DIR OUT_DIR}"
VMLINUZ="$STAGE_DIR/vmlinuz"
INITRAMFS="$STAGE_DIR/initramfs.cpio.gz"
PROFILE="${3:-store}"
PROFILE="${3:-worker}"
case "$PROFILE" in
store)
payload="usr/local/bin/nats-server usr/local/lib/sqs-store/sqs-store-lib.sh usr/local/lib/sqs-store/sqs-store-prepare usr/local/lib/sqs-store/sqs-store-run usr/local/lib/sqs-store/sqs-store-stop" ;;
worker)
payload="usr/local/bin/sqs-worker usr/local/lib/sqs-worker/sqs-worker-lib.sh usr/local/lib/sqs-worker/sqs-worker-prepare usr/local/lib/sqs-worker/sqs-worker-ready usr/local/lib/sqs-worker/sqs-worker-stop data" ;;
*) echo "ERROR: profile must be store or worker, not $PROFILE" >&2; exit 1 ;;
payload="usr/local/bin/nats-server usr/local/bin/sqs-worker usr/local/lib/sqs-worker/sqs-worker-lib.sh usr/local/lib/sqs-worker/sqs-worker-prepare usr/local/lib/sqs-worker/sqs-worker-store usr/local/lib/sqs-worker/sqs-worker-ready usr/local/lib/sqs-worker/sqs-worker-stop data" ;;
*) echo "ERROR: profile must be worker, not $PROFILE" >&2; exit 1 ;;
esac
tag="[build-sqs-$PROFILE-microvm-image]"
@@ -56,12 +54,7 @@ for want_ko in ext4 tiny_power_button; do
fi
done
if grep -qE '(^|/)(lb-agent|haproxy|nginx)$' <<< "$archive_list"; then
echo "ERROR: LB data-plane binaries found in the SQS store image" >&2
exit 1
fi
# The worker holds the store client key; it must never carry the store server.
if [ "$PROFILE" = worker ] && grep -qE '(^|/)nats-server$' <<< "$archive_list"; then
echo "ERROR: nats-server found in the SQS worker image" >&2
echo "ERROR: LB data-plane binaries found in the SQS image" >&2
exit 1
fi
echo "$tag content gate: PASS"
@@ -1,5 +1,5 @@
#!/bin/sh
# sqs-store-microvm-publish_test.sh — a staged SQS store microVM pair reaches
# sqs-store-microvm-publish_test.sh — a staged SQS microVM pair reaches
# the output directory only when every gate passes, and a failed build cannot
# leave a pair that make then takes as up to date.
#
@@ -28,9 +28,10 @@ done
stage() {
dir=$1 omit=${2:-}
root="$TMP/root.$$.$(date +%s%N)"
for p in init etc/inittab usr/local/bin/nats-server sbin/mkfs.ext4 sbin/e2fsck \
usr/local/lib/sqs-store/sqs-store-lib.sh usr/local/lib/sqs-store/sqs-store-prepare \
usr/local/lib/sqs-store/sqs-store-run usr/local/lib/sqs-store/sqs-store-stop \
for p in init etc/inittab usr/local/bin/nats-server usr/local/bin/sqs-worker data sbin/mkfs.ext4 sbin/e2fsck \
usr/local/lib/sqs-worker/sqs-worker-lib.sh usr/local/lib/sqs-worker/sqs-worker-prepare \
usr/local/lib/sqs-worker/sqs-worker-store usr/local/lib/sqs-worker/sqs-worker-ready \
usr/local/lib/sqs-worker/sqs-worker-stop \
dev/console dev/null lib/modules/6.12-virt/kernel/fs/ext4/ext4.ko \
lib/modules/6.12-virt/kernel/drivers/acpi/tiny-power-button.ko; do
[ "$p" = "$omit" ] && continue
@@ -78,7 +79,7 @@ else
fi
# --- Each failing gate keeps the previous valid pair exactly ---
for case in nats-server store-stop ext4 size; do
for case in nats-server worker-store ext4 size; do
d="$TMP/keep-$case"
stage "$d/prev"
bash "$PUBLISH" "$d/prev" "$d/out" >/dev/null 2>&1
@@ -86,7 +87,7 @@ for case in nats-server store-stop ext4 size; do
max=50
case $case in
nats-server) stage "$d/stage" usr/local/bin/nats-server ;;
store-stop) stage "$d/stage" usr/local/lib/sqs-store/sqs-store-stop ;;
worker-store) stage "$d/stage" usr/local/lib/sqs-worker/sqs-worker-store ;;
ext4) stage "$d/stage" lib/modules/6.12-virt/kernel/fs/ext4/ext4.ko ;;
size) stage "$d/stage"; max=0 ;;
esac
+140 -27
View File
@@ -1,7 +1,8 @@
#!/bin/sh
# sqs-worker-guest_test.sh — the SQS worker guest's readiness watcher is
# supervised by init, never leaves a store-ready marker behind a dead watcher,
# and shutdown unmounts the worker disk only once the watcher is gone.
# sqs-worker-guest_test.sh — the SQS guest's store starts only on a config
# prepared after the receipt key settled, its readiness watcher never leaves a
# store-ready marker behind a dead watcher, and shutdown unmounts the data disk
# only once both the watcher and the store are gone.
#
# Drives build/microvm/sqs-worker/sqs-worker-lib.sh with test-owned processes
# and a stub umount. Every signal goes only to this test's own children.
@@ -12,6 +13,7 @@ set -eu
ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/../.." && pwd)
LIB=${SQSW_GUEST_LIB:-"$ROOT/build/microvm/sqs-worker/sqs-worker-lib.sh"}
INITTAB="$ROOT/build/microvm/sqs-worker/inittab"
GUEST_INIT="$ROOT/build/microvm/sqs-worker/init.sh"
TMP=$(mktemp -d)
CHILDREN=""
cleanup() {
@@ -41,26 +43,56 @@ echo "$$" >> "$T/watchers"
echo "10.0.0.5 UPUB" > "$T/run/store-ready"
exec sleep 60
EOF
# The fake server checks its config, or notes how it was started and stays.
cat > "$TMP/bin/nats" <<'EOF'
#!/bin/sh
if [ "$1" = -t ]; then [ -z "${NATS_BAD:-}" ]; exit; fi
echo "$GOMEMLIMIT $*" >> "$T/servers"
exec sleep 60
EOF
# The fake boot takes its exit codes in order from $T/boot-rcs, and like the
# real one writes the receipt marker only on success; "hold" blocks like a slow call.
cat > "$TMP/bin/boot" <<'EOF'
#!/bin/sh
echo "$*" >> "$T/boots"
rc=$(head -n1 "$T/boot-rcs"); sed -i 1d "$T/boot-rcs"
if [ "$rc" = hold ]; then echo "$$" > "$T/boot-child"; exec sleep 60; fi
[ "$rc" != 0 ] || echo FP > "$T/run/receipt-ready"
exit "$rc"
EOF
chmod +x "$TMP/bin"/*
fresh() {
T="$TMP/$1"
export T
mkdir -p "$T/run" "$T/data"
echo UPUBLICKEY > "$T/run/public-key"
echo FP > "$T/run/receipt-ready"
echo "listen: 127.0.0.1:4222" > "$T/run/store.conf"
for f in calls.log console mounts; do : > "$T/$f"; done
}
gone() { [ ! -r "/proc/$1/status" ] || grep -q '^State:[[:space:]]*Z' "/proc/$1/status" 2>/dev/null; }
waitfor() { i=0; while [ "$i" -lt 100 ] && ! eval "$1"; do i=$((i + 1)); sleep 0.05; done; eval "$1"; }
# start_run: one init respawn of the readiness step, in the background.
start_run() {
SQSW_RUN="$T/run" SQSW_CONSOLE="$T/console" SQSW_BIN="$TMP/bin/watcher" SQSW_RESPAWN_GAP="${GAP:-0}" SQSW_PARK_INTERVAL="${PARK:-30}" \
SQSW_RUN="$T/run" SQSW_CONSOLE="$T/console" SQSW_BIN="$TMP/bin/watcher" SQSW_RESPAWN_GAP="${GAP:-0}" SQSW_PARK="sleep 60" \
sh -c '. "$1"; sqsw_run' sh "$LIB" & pid=$!
CHILDREN="$CHILDREN $pid"
}
# start_store: one init respawn of the store step, in the background.
start_store() {
SQSW_RUN="$T/run" SQSW_DATA="$T/data" SQSW_CONSOLE="$T/console" SQSW_NATS_BIN="$TMP/bin/nats" \
SQSW_BIN="$TMP/bin/boot" SQSW_BOOT_RETRY=0 SQSW_PARK="sleep 60" SQSW_LIB="$LIB" \
sh -c '. "$1"; sqsw_store' sh "$LIB" & spid=$!
CHILDREN="$CHILDREN $spid"
}
# prepare_store: the sysinit step's store preparation, after boot.
prepare_store() {
SQSW_RUN="$T/run" SQSW_DATA="$T/data" SQSW_CONSOLE="$T/console" SQSW_NATS_BIN="$TMP/bin/nats" \
sh -c '. "$1"; sqsw_prepare_store' sh "$LIB"
}
# record <pid>: write the identity sqs_run would record for that process.
# shellcheck source=../../build/microvm/sqs-worker/sqs-worker-lib.sh
record() { echo "$1 $( . "$LIB"; sqsw_starttime "$1" )" > "$T/run/ready.pid"; }
record() { echo "$1 $( . "$LIB"; sqsw_starttime "$1" )" > "$T/run/${2:-ready}.pid"; }
# shutdown_step [survivor-pid]: with a pid, that process is reported alive
# whatever happens to it, as a watcher stuck past SIGKILL would be.
shutdown_step() {
@@ -74,8 +106,86 @@ shutdown_step() {
) > /dev/null 2>&1
}
check "init respawns the readiness step rather than running it once" \
'grep -qx "::respawn:/usr/local/lib/sqs-worker/sqs-worker-ready" "$INITTAB" && ! grep -q "::once:" "$INITTAB"'
check "init respawns the store and readiness steps rather than running them once" \
'grep -qx "::respawn:/usr/local/lib/sqs-worker/sqs-worker-store" "$INITTAB" &&
grep -qx "::respawn:/usr/local/lib/sqs-worker/sqs-worker-ready" "$INITTAB" && ! grep -q "::once:" "$INITTAB"'
# The store binds 127.0.0.1, which a fresh guest kernel leaves down.
lo_at=$(grep -n -x "ip link set lo up" "$GUEST_INIT" | cut -d: -f1 | head -n 1)
init_at=$(grep -n -x "exec /sbin/init" "$GUEST_INIT" | cut -d: -f1 | head -n 1)
check "guest init brings loopback up before init starts the store" \
'[ -n "$lo_at" ] && [ -n "$init_at" ] && [ "$lo_at" -lt "$init_at" ]'
# --- The store is prepared only after boot settled the receipt key ---
fresh prepare-ok; rm -f "$T/run/store.conf"
check "a settled receipt key prepares the store" 'prepare_store && [ -s "$T/run/store.conf" ]'
check "the store listens only on loopback, with no auth, monitor, route or cluster listener" \
'grep -qx "listen: \"127.0.0.1:4222\"" "$T/run/store.conf" && grep -qx " store_dir: \"$T/data/jetstream\"" "$T/run/store.conf" &&
! grep -Eq "authorization|http|monitor|cluster|routes|leafnodes|websocket|mqtt" "$T/run/store.conf"'
fresh prepare-no-key; rm -f "$T/run/store.conf" "$T/run/receipt-ready"
check "a receipt key boot did not settle leaves the store unprepared" '! prepare_store && [ ! -e "$T/run/store.conf" ]'
fresh prepare-bad; rm -f "$T/run/store.conf"
check "a config the server rejects leaves the store unprepared" '! NATS_BAD=1 prepare_store && [ ! -e "$T/run/store.conf" ]'
# --- The store step starts the server only on a prepared config ---
fresh store-run
start_store
check "a prepared store starts the dedicated server with its heap bound" \
'waitfor "[ -s \"\$T/servers\" ]" && grep -qx "512MiB -c $T/run/store.conf" "$T/servers"'
kill -KILL "$spid" 2>/dev/null || true
fresh store-refused; : > "$T/run/boot-refused"
start_store; waitfor "grep -q 'store: boot refused' \"\$T/console\"" || true
check "a store whose boot was refused parks instead of starting or respawning" \
'! gone "$spid" && [ ! -e "$T/servers" ] && [ ! -e "$T/boots" ]'
kill -KILL "$spid" 2>/dev/null || true
# --- Boot is supervised: a transient failure is retried, a refusal is final ---
fresh boot-transient; rm -f "$T/run/store.conf" "$T/run/receipt-ready"; printf '75\n0\n' > "$T/boot-rcs"
start_store
check "a transient boot failure exits so init runs the step again, starting nothing" \
'waitfor "gone $spid" && [ ! -e "$T/servers" ] && [ ! -e "$T/run/boot-refused" ]'
start_store
check "the respawn after recovery boots again and starts the store" \
'waitfor "[ -s \"\$T/servers\" ]" && [ "$(wc -l < "$T/boots")" -eq 2 ] && [ -s "$T/run/store.conf" ]'
kill -KILL "$spid" 2>/dev/null || true
fresh boot-final; rm -f "$T/run/store.conf" "$T/run/receipt-ready"; printf '1\n0\n' > "$T/boot-rcs"
start_store; waitfor "grep -q 'boot refused (rc=1)' \"\$T/console\"" || true
check "a final boot refusal parks the store and records the refusal" \
'! gone "$spid" && [ -e "$T/run/boot-refused" ] && [ ! -e "$T/servers" ]'
kill -KILL "$spid" 2>/dev/null || true
start_store; waitfor "grep -q 'store: boot refused, not starting' \"\$T/console\"" || true
check "a later respawn never boots again after a final refusal" '[ "$(wc -l < "$T/boots")" -eq 1 ]'
kill -KILL "$spid" 2>/dev/null || true
fresh boot-settled; rm -f "$T/run/store.conf"; : > "$T/boot-rcs"
start_store
check "a respawn after the key settled starts the store without booting again" \
'waitfor "[ -s \"\$T/servers\" ]" && [ ! -e "$T/boots" ]'
kill -KILL "$spid" 2>/dev/null || true
fresh boot-held; rm -f "$T/run/store.conf" "$T/run/receipt-ready"; echo hold > "$T/boot-rcs"
echo "/dev/vdb $T/data ext4 rw 0 0" > "$T/mounts"
start_store; waitfor '[ -s "$T/boot-child" ]' || true
bchild=$(cat "$T/boot-child" 2>/dev/null); CHILDREN="$CHILDREN $bchild"
shutdown_step
check "shutdown stops a boot still in progress before it unmounts the disk" \
'waitfor "gone $bchild" && grep -q "umount $T/data" "$T/calls.log"'
kill -KILL "$spid" "$bchild" 2>/dev/null || true
fresh boot-held-survivor; rm -f "$T/run/store.conf" "$T/run/receipt-ready"; echo hold > "$T/boot-rcs"
echo "/dev/vdb $T/data ext4 rw 0 0" > "$T/mounts"
start_store; waitfor '[ -s "$T/boot-child" ]' || true
bchild=$(cat "$T/boot-child" 2>/dev/null); CHILDREN="$CHILDREN $bchild"
check "a boot that survives SIGKILL fails shutdown and keeps the disk mounted" \
'! shutdown_step "$bchild" && ! grep -q umount "$T/calls.log" && grep -q "boot pid=$bchild STILL ALIVE" "$T/console"'
kill -KILL "$spid" "$bchild" 2>/dev/null || true
# Shutdown sets stopping after the store's own check but before the boot child
# runs: the child must still record itself and never start sqs-worker boot.
fresh boot-window; rm -f "$T/run/store.conf" "$T/run/receipt-ready"; echo 0 > "$T/boot-rcs"
: > "$T/run/stopping"
SQSW_RUN="$T/run" SQSW_CONSOLE="$T/console" SQSW_BIN="$TMP/bin/boot" SQSW_BOOT_RETRY=0 SQSW_PARK="sleep 60" SQSW_LIB="$LIB" \
sh -c '. "$1"; sqsw_boot' sh "$LIB" & wpid=$!
CHILDREN="$CHILDREN $wpid"
waitfor "grep -q 'shutdown during boot' \"\$T/console\"" || true
check "a boot child started after shutdown began never runs boot" \
'grep -q "shutdown during boot" "$T/console" && [ ! -e "$T/boots" ] && [ ! -e "$T/run/boot-refused" ]'
kill -KILL "$wpid" 2>/dev/null || true
# --- A watcher killed after readiness: its marker must not outlive it ---
fresh killed-after-ready
@@ -103,8 +213,7 @@ kill -KILL "$pid" 2>/dev/null || true
# A run that cannot start parks rather than exiting, so init does not respawn
# it in a loop; a second is many park intervals, and it must log only once.
fresh run-stopping; : > "$T/run/stopping"; echo stale > "$T/run/store-ready"
PARK=0.05 start_run; waitfor "grep -q 'shutdown in progress' \"\$T/console\"" || true
sleep 1
start_run; waitfor "grep -q 'shutdown in progress' \"\$T/console\"" || true
check "a start during shutdown parks instead of exiting and respawning" '! gone "$pid"'
check "a parked start during shutdown starts no watcher and removes the marker" \
'[ ! -e "$T/watchers" ] && [ ! -e "$T/run/store-ready" ]'
@@ -112,26 +221,24 @@ check "a parked start during shutdown logs once" '[ "$(grep -c "shutdown in prog
shutdown_step
check "shutdown stops the parked start" 'waitfor "gone $pid"'
fresh run-no-key; rm -f "$T/run/public-key"
PARK=0.05 start_run; waitfor "grep -q 'no worker key' \"\$T/console\"" || true
sleep 1
check "a guest without a worker key parks instead of exiting and respawning" '! gone "$pid" && [ ! -e "$T/watchers" ]'
check "a parked guest without a worker key logs once" '[ "$(grep -c "no worker key" "$T/console")" -eq 1 ]'
echo UPUBLICKEY > "$T/run/public-key"
check "a key that appears later starts the watcher" 'waitfor "[ -s \"\$T/watchers\" ]"'
kill -KILL "$pid" 2>/dev/null || true
fresh run-no-key-stop; rm -f "$T/run/public-key"
PARK=0.05 start_run; waitfor "grep -q 'no worker key' \"\$T/console\"" || true
fresh run-unprepared; rm -f "$T/run/store.conf"
start_run
check "a watcher whose store is not prepared yet exits to be respawned and never reports ready" \
'waitfor "gone $pid" && [ ! -e "$T/watchers" ] && [ ! -e "$T/run/store-ready" ]'
fresh run-refused; rm -f "$T/run/store.conf"; : > "$T/run/boot-refused"
start_run; waitfor "grep -q 'ready: boot refused' \"\$T/console\"" || true
check "a guest whose boot was refused parks its watcher and never reports ready" \
'! gone "$pid" && [ ! -e "$T/watchers" ] && [ ! -e "$T/run/store-ready" ]'
shutdown_step
check "shutdown stops a start parked for a missing key" 'waitfor "gone $pid" && [ ! -e "$T/watchers" ]'
check "shutdown stops a watcher parked after a refused boot" 'waitfor "gone $pid" && [ ! -e "$T/watchers" ]'
# --- Shutdown: unmount only once the watcher is confirmed gone ---
# --- Shutdown: unmount only once the watcher and the store are gone ---
fresh shutdown-orderly; echo "dev $T/data ext4 rw 0 0" > "$T/mounts"
start_run; waitfor "[ -s \"\$T/run/store-ready\" ]"
start_store; start_run; waitfor "[ -s \"\$T/run/store-ready\" ]"; waitfor "[ -s \"\$T/servers\" ]"
check "orderly shutdown succeeds" 'shutdown_step'
check "orderly shutdown confirms the watcher exited, removes the marker, then unmounts" \
'gone "$pid" && grep -q "readiness watcher pid=$pid exited" "$T/console" && [ ! -e "$T/run/store-ready" ] &&
check "orderly shutdown stops the watcher, then the store, removes the marker, then unmounts" \
'gone "$pid" && gone "$spid" && [ ! -e "$T/run/store-ready" ] &&
[ "$(grep "exited" "$T/console" | sed "s/.*stop: \([a-z]*\) pid.*/\1/" | tr "\n" "|")" = "ready|store|" ] &&
[ "$(grep "^umount" "$T/calls.log" | tr "\n" "|")" = "umount $T/data|umount -a -r|" ]'
check "shutdown blocks any later respawn" '[ -e "$T/run/stopping" ]'
@@ -151,10 +258,16 @@ check "a surviving watcher leaves every mount alone and is never reported stoppe
check "a surviving watcher still leaves no readiness marker" '[ ! -e "$T/run/store-ready" ]'
kill -KILL "$pid" 2>/dev/null || true
fresh shutdown-store-survivor; echo "dev $T/data ext4 rw 0 0" > "$T/mounts"
(trap '' TERM; exec sleep 60) & pid=$!; CHILDREN="$CHILDREN $pid"; record "$pid" store
check "shutdown with a surviving store reports failure and leaves every mount alone" \
'! shutdown_step "$pid" && ! grep -q "^umount" "$T/calls.log" && grep -q "store pid=$pid STILL ALIVE" "$T/console"'
kill -KILL "$pid" 2>/dev/null || true
fresh shutdown-reused; echo "dev $T/data ext4 rw 0 0" > "$T/mounts"
(exec sleep 60) & pid=$!; CHILDREN="$CHILDREN $pid"; echo "$pid 1" > "$T/run/ready.pid"
shutdown_step
check "a recorded PID with another start time is never signalled" '! gone "$pid" && grep -q "no running readiness watcher" "$T/console"'
check "a recorded PID with another start time is never signalled" '! gone "$pid" && grep -q "no running ready" "$T/console"'
kill -KILL "$pid" 2>/dev/null || true
if [ "$fails" -ne 0 ]; then
+3 -4
View File
@@ -1,11 +1,10 @@
#!/bin/sh
# stage-sqs-store-microvm.sh <image-dir> <dist-root> [sqs-store|sqs-worker]:
# copy a built SQS microVM image into a release tree as <dist-root>/microvm-<name>.
# stage-sqs-store-microvm.sh <image-dir> <dist-root> [sqs-worker]: copy the
# built SQS microVM image into a release tree as <dist-root>/microvm-sqs-worker.
# Without a complete image it stages nothing and says so, as the LB image does.
set -eu
src=$1 name=${3:-sqs-store}
src=$1 name=${3:-sqs-worker}
case "$name" in
sqs-store) label="SQS store" ;;
sqs-worker) label="SQS worker" ;;
*) echo "[distro] unknown SQS microVM image '$name'" >&2; exit 2 ;;
esac
+2 -1
View File
@@ -100,7 +100,8 @@ profile_binaries() {
./cmd/eks-gateway-fetch:usr/local/bin/eks-gateway-fetch \
./cmd/eks-webhook-cert:usr/local/bin/eks-webhook-cert \
./cmd/eks-konnectivity-cert:usr/local/bin/eks-konnectivity-cert \
./cmd/eks-etcd-member:usr/local/bin/eks-etcd-member"
./cmd/eks-etcd-member:usr/local/bin/eks-etcd-member \
./cmd/eks-pod-identity-webhook:usr/local/bin/eks-pod-identity-webhook"
;;
ecs) echo "./cmd/ecs-agent:usr/local/bin/ecs-agent" ;;
*) echo "" ;;
+1 -1
View File
@@ -7,7 +7,7 @@
# Debian is the build host regardless of what is being built — mkosi bootstraps
# the target distribution from its own archive, so an Ubuntu image builds fine
# from here (see ubuntu-keyring below).
FROM debian:trixie-slim@sha256:62bd423aeee912639ca8ee681c2b762cb3e25137e0a92b20be455898a9f034ad
FROM debian:trixie-slim@sha256:f6b08b09a05725f9431502d3b5714f5c4576e82c1a84fa1debe0c24a7e0c3ec4
# The runner user is uid 1000; matching it keeps bind-mounted build output
# owned by the invoking user instead of root.
+84 -15
View File
@@ -732,7 +732,8 @@ table inet spinifex_filter {
# inventory and running instances to anyone who asks. 8660 is
# predastore's unauthenticated /healthz and /readyz, which name the
# peers a gate cannot reach, so it belongs here for the same reason.
ip saddr $spinifex_peers tcp dport { 4222, 4248, 4432, 5300, 6641, 6642, 6643, 6644, 8660 } accept
# 5001 is the admin network test, listening only while one runs.
ip saddr $spinifex_peers tcp dport { 4222, 4248, 4432, 5001, 5300, 6641, 6642, 6643, 6644, 8660 } accept
ip saddr $spinifex_peers udp dport { 5300, 6660, 7660 } accept
# Encap plane, peer-scoped: Geneve, IKE, NAT-T and ESP. Geneve is a
@@ -1014,6 +1015,9 @@ RESOLV_CONF="${SPINIFEX_RESOLV_CONF:-/etc/resolv.conf}"
RESOLVED_STUB=/run/systemd/resolve/stub-resolv.conf
# node-reset.sh keeps /var/lib/spinifex-reset, so the copy survives until uninstall.
RESOLV_CONF_SAVED="${SPINIFEX_RESOLV_CONF_SAVED:-/var/lib/spinifex-reset/resolv.conf.orig}"
# NetworkManager started while resolv.conf is a plain file rewrites it on its
# next DNS update, replacing the stub link; this drop-in stops that.
NM_DNS_DROPIN="${SPINIFEX_NM_DNS_DROPIN:-/etc/NetworkManager/conf.d/90-spinifex-dns.conf}"
# systemd-resolved ships resolvconf as a link to resolvectl, which only works
# while resolved runs, so that one is not a resolvconf backend.
@@ -1066,10 +1070,52 @@ dns_handover_failed() {
fatal "Host DNS through systemd-resolved does not work: $1.
To put host DNS back as it was before this installer:
sudo systemctl disable --now systemd-resolved
sudo rm -f $NM_DNS_DROPIN
sudo cp -a --remove-destination $RESOLV_CONF_SAVED $RESOLV_CONF
sudo systemctl restart NetworkManager"
}
# Prints the rc-manager NetworkManager would use if it is not unmanaged, which
# means a file read after the Spinifex drop-in overrides it.
nm_rc_manager_override() {
local config
config=$($SUDO NetworkManager --print-config) || return 1
printf '%s\n' "$config" | awk -F= '
/^\[/ { main = ($0 == "[main]"); next }
main && $1 == "rc-manager" { rc = $2 }
END { if (rc != "unmanaged") print "rc-manager=" (rc == "" ? "symlink" : rc) }'
}
# Writes the NetworkManager drop-in and checks nothing overrides it. Returns 1
# when it was already in place.
install_nm_dns_dropin() {
local want override changed=1
want=$(printf '[main]\ndns=systemd-resolved\nrc-manager=unmanaged')
if [ "$(cat "$NM_DNS_DROPIN" 2>/dev/null)" != "$want" ]; then
$SUDO mkdir -p "$(dirname "$NM_DNS_DROPIN")"
printf '%s\n' "$want" | $SUDO tee "$NM_DNS_DROPIN" > /dev/null || dns_handover_failed "cannot write $NM_DNS_DROPIN"
info " $NM_DNS_DROPIN: NetworkManager leaves $RESOLV_CONF alone"
changed=0
fi
override=$(nm_rc_manager_override) || dns_handover_failed "cannot read NetworkManager's configuration with 'NetworkManager --print-config'"
if [ -n "$override" ]; then
dns_handover_failed "NetworkManager reads $override from a file after $NM_DNS_DROPIN, so it would replace the $RESOLV_CONF link. Remove that setting ('NetworkManager --print-config' shows the files it reads) and re-run this installer"
fi
return "$changed"
}
# Links the stub and checks the link is still there a second later, relinking a
# bounded number of times in case a write NetworkManager had queued lands first.
link_resolved_stub() {
local i
for i in 1 2 3 4 5; do
$SUDO ln -sfn "$RESOLVED_STUB" "$RESOLV_CONF"
sleep 1
[ "$(readlink "$RESOLV_CONF")" = "$RESOLVED_STUB" ] && return 0
done
dns_handover_failed "$RESOLV_CONF is not a link to $RESOLVED_STUB 5 seconds after linking it, so something keeps rewriting it"
}
# Checks resolved has upstream servers and still resolves the package
# repository host, the one name this host is known to have resolved.
check_resolved_dns() {
@@ -1103,8 +1149,13 @@ hand_dns_to_resolved() {
[ "${ISO_BUILD:-0}" = "1" ] && return 0
if systemctl is-active --quiet systemd-resolved.service; then
# A handover made by an earlier run is checked again on every run.
# A handover made by an earlier run is checked again, and repaired, on
# every run: installs before the drop-in could lose the link.
if [ -e "$RESOLV_CONF_SAVED" ] || [ -L "$RESOLV_CONF_SAVED" ]; then
if install_nm_dns_dropin && systemctl is-active --quiet NetworkManager.service; then
$SUDO systemctl reload NetworkManager.service || dns_handover_failed "NetworkManager did not reload"
fi
[ "$(readlink "$RESOLV_CONF")" = "$RESOLVED_STUB" ] || link_resolved_stub
check_resolved_dns
fi
return 0
@@ -1131,10 +1182,15 @@ hand_dns_to_resolved() {
$SUDO cp -a "$RESOLV_CONF" "$RESOLV_CONF_SAVED" || fatal "Cannot save $RESOLV_CONF to $RESOLV_CONF_SAVED"
info " saved $RESOLV_CONF to $RESOLV_CONF_SAVED"
fi
install_nm_dns_dropin || true
$SUDO systemctl enable --now systemd-resolved.service > /dev/null 2>&1 \
|| dns_handover_failed "systemd-resolved did not start"
$SUDO systemctl restart NetworkManager.service || dns_handover_failed "NetworkManager did not restart"
$SUDO ln -sfn "$RESOLVED_STUB" "$RESOLV_CONF"
# Best effort: a link with no DHCP server can hold startup past the timeout.
if command -v nm-online > /dev/null; then
nm-online -s -q --timeout=15 || true
fi
link_resolved_stub
check_resolved_dns
info " $RESOLV_CONF -> $RESOLVED_STUB"
}
@@ -1254,6 +1310,7 @@ qemu-utils gdisk ovmf qemu-efi-aarch64 less
libvirt-daemon-system libvirt-clients
pciutils
jq curl iproute2 iputils-arping ethtool netcat-openbsd wget unzip xz-utils file
dmidecode iperf
ovn-central ovn-host openvswitch-switch openvswitch-ipsec strongswan-charon dhcpcd-base
chrony nftables"
@@ -1263,7 +1320,7 @@ chrony nftables"
OL_RUNTIME_PACKAGES="nbdkit nbdkit-basic-filters
qemu-kvm qemu-img edk2-ovmf seabios-bin less
pciutils
jq curl iproute iputils ethtool nmap-ncat wget unzip xz file
jq curl iproute iputils ethtool nmap-ncat wget unzip xz file dmidecode
ovn25.03-central ovn25.03-host openvswitch3.5 openvswitch3.5-ipsec libreswan dhcpcd
chrony nftables iptables-nft systemd-resolved"
@@ -1530,6 +1587,15 @@ download_spinifex() {
EXTRACT_DIR="$SPINIFEX_TMPDIR"
}
# install.mulgadc.com serves the latest release's setup.sh, which cannot know what a
# newer --channel or --version tarball needs installed, so it runs the tarball's own.
hand_off_to_packaged_setup() {
[ -z "${INSTALL_SPINIFEX_TARBALL:-}" ] && [ -f "$EXTRACT_DIR/setup.sh" ] || return 0
info "Running the setup.sh shipped in the downloaded release"
export INSTALL_SPINIFEX_TARBALL="$TARBALL" SPINIFEX_HANDOFF_TMPDIR="$SPINIFEX_TMPDIR"
exec bash "$EXTRACT_DIR/setup.sh" "$@"
}
# OL's sudo secure_path leaves out /usr/local/bin, so `sudo spx` would find
# nothing there. A link in /usr/bin changes sudo for nothing else on the host.
link_spx_for_sudo() {
@@ -1624,13 +1690,12 @@ install_files() {
$SUDO cp "$EXTRACT_DIR/microvm/"* /usr/share/spinifex/microvm/
info " /usr/share/spinifex/microvm/*"
fi
install_sqs_store_image
install_sqs_worker_image
}
# The SQS images ship beside the load balancer's, not inside it: an older
# installer copies microvm/* without -r and would stop at a subdirectory.
install_sqs_store_image() { install_sqs_microvm_image sqs-store "SQS store"; }
# The SQS image ships beside the load balancer's, not inside it: an older
# installer copies microvm/* without -r and would stop at a subdirectory. A
# store image left by an earlier install is not touched: nothing boots it.
install_sqs_worker_image() { install_sqs_microvm_image sqs-worker "SQS worker"; }
# install_sqs_microvm_image <name> <label>
@@ -2178,6 +2243,7 @@ EOF
# --- Main ---
main() {
local args=("$@")
while [ $# -gt 0 ]; do
case "$1" in
--firewall=*) INSTALL_SPINIFEX_FIREWALL="${1#*=}" ;;
@@ -2207,18 +2273,20 @@ main() {
detect_os
detect_arch
# Orchestration (stop running services, fix stale /run/spinifex/nbd perms)
# only applies in full-install mode on a live host.
if [ "${ISO_BUILD:-0}" != "1" ] && [ -z "${SETUP_STAGES:-}" ]; then
handle_upgrade
fi
# Stages that need EXTRACT_DIR: files, directories, systemd, logrotate, udev.
# Only download/extract when at least one such stage is enabled.
# Only download/extract when at least one such stage is enabled, and before
# anything on the host changes, so the hand-off finds it untouched.
if stage_enabled files || stage_enabled directories \
|| stage_enabled systemd || stage_enabled logrotate \
|| stage_enabled udev; then
download_spinifex
hand_off_to_packaged_setup "${args[@]}"
fi
# Orchestration (stop running services, fix stale /run/spinifex/nbd perms)
# only applies in full-install mode on a live host.
if [ "${ISO_BUILD:-0}" != "1" ] && [ -z "${SETUP_STAGES:-}" ]; then
handle_upgrade
fi
stage_enabled deps && install_deps
@@ -2248,6 +2316,7 @@ main() {
fi
[ -n "${SPINIFEX_TMPDIR:-}" ] && rm -rf "$SPINIFEX_TMPDIR"
[ -n "${SPINIFEX_HANDOFF_TMPDIR:-}" ] && rm -rf "$SPINIFEX_HANDOFF_TMPDIR"
# Post-install orchestration (service restart, summary, newgrp) only in
# full-install mode on a live host.
+88
View File
@@ -0,0 +1,88 @@
#!/bin/sh
# setup_handoff_test.sh — tests that setup.sh runs the setup.sh shipped in the
# tarball it downloaded, before it changes anything on the host.
#
# setup.sh is sourced with INSTALL_SPINIFEX_LIB_ONLY=1 and main runs with the
# host-touching functions stubbed; the packaged setup.sh is a fixture that
# prints what it was given.
set -eu
SCRIPT_DIR=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd)
SETUP="$SCRIPT_DIR/setup.sh"
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
fails=0
pass() { echo " ok - $1"; }
fail() { echo " FAIL - $1"; fails=$((fails + 1)); }
echo "-- setup.sh hand-off to the packaged setup.sh"
mkdir -p "$TMP/extract"
cat > "$TMP/packaged-setup.sh" <<'EOF'
echo "PACKAGED args=[$*] tarball=${INSTALL_SPINIFEX_TARBALL:-} handoff=${SPINIFEX_HANDOFF_TMPDIR:-}"
EOF
# run <packaged: yes|no> [main args...] — runs main with every step that would
# touch the host stubbed to print its name, and sets OUT.
run() {
packaged="$1"
shift
rm -f "$TMP/extract/setup.sh"
[ "$packaged" = yes ] && cp "$TMP/packaged-setup.sh" "$TMP/extract/setup.sh"
OUT=$(INSTALL_SPINIFEX_LIB_ONLY=1 TMPROOT="$TMP" bash -c '
. "$1"; shift
info() { :; }
setup_sudo() { :; }
resolve_firewall_mode() { :; }
detect_os() { :; }
detect_arch() { :; }
download_spinifex() {
SPINIFEX_TMPDIR="$TMPROOT/extract"
TARBALL="$SPINIFEX_TMPDIR/spinifex.tar.gz"
EXTRACT_DIR="$SPINIFEX_TMPDIR"
}
handle_upgrade() { echo HANDLE_UPGRADE; exit 0; }
main "$@"
' _ "$SETUP" "$@" 2>&1) || true
OUT=$(printf '%s\n' "$OUT" | sed '/^$/d')
}
run yes --channel dev --firewall off
want="PACKAGED args=[--channel dev --firewall off] tarball=$TMP/extract/spinifex.tar.gz handoff=$TMP/extract"
if [ "$OUT" = "$want" ]; then
pass "a downloaded tarball's setup.sh runs with the same arguments, before services are stopped"
else
fail "downloaded tarball: got [$OUT], want [$want]"
fi
run no --channel dev
if [ "$OUT" = HANDLE_UPGRADE ]; then
pass "a tarball without setup.sh is installed by the running script"
else
fail "no packaged setup.sh: got [$OUT]"
fi
run_local() {
rm -f "$TMP/extract/setup.sh"
cp "$TMP/packaged-setup.sh" "$TMP/extract/setup.sh"
OUT=$(INSTALL_SPINIFEX_LIB_ONLY=1 INSTALL_SPINIFEX_TARBALL=/somewhere/spinifex.tar.gz bash -c '
. "$1"
info() { :; }
EXTRACT_DIR="$2"; TARBALL="$2/spinifex.tar.gz"; SPINIFEX_TMPDIR="$2"
hand_off_to_packaged_setup --channel dev
echo STAYED
' _ "$SETUP" "$TMP/extract" 2>&1) || true
}
run_local
if [ "$OUT" = STAYED ]; then
pass "a local tarball, including the one a hand-off passes on, is not handed off again"
else
fail "local tarball: got [$OUT]"
fi
if [ "$fails" -gt 0 ]; then
echo "setup_handoff_test.sh: $fails failure(s)"
exit 1
fi
echo "setup_handoff_test.sh: ok"
+70 -5
View File
@@ -20,9 +20,10 @@ fail() { echo " FAIL - $1"; fails=$((fails + 1)); }
echo "-- setup.sh host DNS, chrony, needrestart and nbdkit"
mkdir -p "$TMP/sys"
for tool in bash awk sed grep readlink basename dirname cp ln mkdir cat head tail wc tee rm chmod install; do
for tool in bash awk sed grep readlink basename dirname cp ln mkdir cat head tail wc tee rm chmod install mv; do
ln -s "$(command -v "$tool")" "$TMP/sys/$tool"
done
ln -s "$(command -v sleep)" "$TMP/sys/realsleep"
# stub <name> <body> — a stub command; $S is the fixture's state directory.
stub() {
@@ -30,10 +31,17 @@ stub() {
chmod +x "$TMP/bin/$1"
}
# nm_settle — waits for the fixture's NetworkManager to finish writing.
nm_settle() {
n=0
while [ -e "$TMP/state/nm-pending" ] && [ $n -lt 500 ]; do "$TMP/sys/realsleep" 0.01; n=$((n + 1)); done
}
# host — a fresh fixture: resolved installed and inactive, resolvconf the
# resolvectl link, NetworkManager running with no DNS settings, a dnf host
# whose first enabled repository is yum.oracle.com.
host() {
nm_settle
rm -rf "${TMP:?}/state" "${TMP:?}/root" "${TMP:?}/bin"
mkdir -p "$TMP/state/active" "$TMP/root/etc" "$TMP/root/usr/lib/systemd/system" "$TMP/bin"
: > "$TMP/root/usr/lib/systemd/system/systemd-resolved.service"
@@ -42,11 +50,28 @@ host() {
printf '[main]\n# dns=default\nconfigure-and-quit=no\n' > "$TMP/state/nm-config"
printf 'Global:\nLink 2 (br-wan): 192.168.1.1\n' > "$TMP/state/resolvectl-dns"
printf '[ol10_UEKR8]\nbaseurl = https://mirror.example/uek/\nenabled = 0\n[ol10_baseos_latest]\nbaseurl = https://yum.oracle.com/repo/OracleLinux/OL10/baseos/latest/x86_64/\nenabled = 1\n' > "$TMP/state/dnf-dump"
# A (re)started NetworkManager whose rc-manager is not unmanaged writes
# resolv.conf as a plain file on its first DNS update, which can land just
# after the installer links the stub.
stub systemctl '[ "$1" = is-active ] && { [ -e "$S/active/$3" ]; exit; }
echo "$*" >> "$S/calls"
[ "$1 $2" = "enable --now" ] && : > "$S/active/$3"
case "$1 $2" in
"restart NetworkManager.service" | "reload NetworkManager.service")
NetworkManager --print-config | grep -qx rc-manager=unmanaged && exit 0
: > "$S/nm-pending"
(
n=0
while [ ! -L "$S/../root/etc/resolv.conf" ] && [ $n -lt 300 ]; do realsleep 0.01; n=$((n + 1)); done
printf "nameserver 192.168.1.1\n" > "$S/nm-resolv.conf"
mv -f "$S/nm-resolv.conf" "$S/../root/etc/resolv.conf"
rm -f "$S/nm-pending"
) > /dev/null 2>&1 &
;;
esac
exit 0'
stub NetworkManager 'cat "$S/nm-config"'
stub NetworkManager 'cat "$S/nm-config"; cat "$S/../root/nm/90-spinifex-dns.conf" "$S/nm-config-late" 2>/dev/null; :'
stub nm-online ':'
stub resolvectl '[ "$1" = dns ] && cat "$S/resolvectl-dns"'
stub resolvectl-real ':'
ln -s resolvectl "$TMP/bin/resolvconf"
@@ -60,10 +85,17 @@ run() {
RC=0
OUT=$(PATH="$TMP/bin:$TMP/sys" DISTRO_ROOT="$TMP/root" \
SPINIFEX_RESOLV_CONF="$TMP/root/etc/resolv.conf" SPINIFEX_RESOLV_CONF_SAVED="$TMP/root/saved/resolv.conf.orig" \
SPINIFEX_NM_DNS_DROPIN="$TMP/root/nm/90-spinifex-dns.conf" \
INSTALL_SPINIFEX_LIB_ONLY=1 bash -c '. "$1"; shift; for f in "$@"; do $f; done' _ "$SETUP" "$@" 2>&1) || RC=$?
}
calls() { cat "$TMP/state/calls" 2>/dev/null || true; }
# linked — whether resolv.conf is still the stub link once NetworkManager settles.
linked() {
nm_settle
[ "$(readlink "$TMP/root/etc/resolv.conf")" = /run/systemd/resolve/stub-resolv.conf ]
}
dropin() { cat "$TMP/root/nm/90-spinifex-dns.conf" 2>/dev/null || echo "<none>"; }
saved() { cat "$TMP/root/saved/resolv.conf.orig" 2>/dev/null || echo "<none>"; }
expect_untouched() {
if [ "$RC" = 0 ] && [ -z "$(calls)" ] && [ ! -L "$TMP/root/etc/resolv.conf" ] && [ "$(saved)" = "<none>" ]; then
@@ -86,7 +118,7 @@ expect_fatal() { # expect_fatal <desc> <text>...
# --------------------------------------------------------------------------
host
run hand_dns_to_resolved
if [ "$RC" = 0 ] && [ "$(readlink "$TMP/root/etc/resolv.conf")" = /run/systemd/resolve/stub-resolv.conf ] \
if [ "$RC" = 0 ] && linked \
&& [ "$(saved)" = "$(printf 'nameserver 192.168.1.1\nsearch example.lan')" ] \
&& [ "$(calls)" = "$(printf 'enable --now systemd-resolved.service\nrestart NetworkManager.service')" ] \
&& [ "$(cat "$TMP/state/looked-up")" = yum.oracle.com ]; then
@@ -98,12 +130,44 @@ fi
: > "$TMP/state/calls"
rm -f "$TMP/state/looked-up"
run hand_dns_to_resolved
if [ "$RC" = 0 ] && [ -z "$(calls)" ] && [ "$(cat "$TMP/state/looked-up")" = yum.oracle.com ]; then
if [ "$RC" = 0 ] && [ -z "$(calls)" ] && linked && [ "$(cat "$TMP/state/looked-up")" = yum.oracle.com ]; then
pass "a rerun after a handover checks DNS again and changes nothing"
else
fail "rerun after handover: rc=$RC calls='$(calls)': $OUT"
fi
if [ "$(dropin)" = "$(printf '[main]\ndns=systemd-resolved\nrc-manager=unmanaged')" ]; then
pass "the handover tells NetworkManager to send DNS to resolved and leave resolv.conf alone"
else
fail "NetworkManager drop-in: '$(dropin)'"
fi
# An install made before the drop-in, whose NetworkManager replaced the link.
host
mkdir -p "$TMP/root/saved"
echo "the original" > "$TMP/root/saved/resolv.conf.orig"
: > "$TMP/state/active/systemd-resolved.service"
run hand_dns_to_resolved
if [ "$RC" = 0 ] && linked && [ "$(calls)" = "reload NetworkManager.service" ] \
&& [ "$(dropin)" = "$(printf '[main]\ndns=systemd-resolved\nrc-manager=unmanaged')" ]; then
pass "a rerun after a handover whose link NetworkManager replaced adds the drop-in, reloads NM and relinks the stub"
else
fail "rerun repair: rc=$RC calls='$(calls)' link='$(readlink "$TMP/root/etc/resolv.conf")' dropin='$(dropin)': $OUT"
fi
host
printf '[main]\nrc-manager=symlink\n' > "$TMP/state/nm-config-late"
run hand_dns_to_resolved
expect_fatal "a NetworkManager file read after the drop-in that sets rc-manager stops the install, naming it" \
"rc-manager=symlink" "rm -f $TMP/root/nm/90-spinifex-dns.conf"
case "$(calls)" in *"restart NetworkManager"*) fail "rc-manager override: restarted NetworkManager first: $(calls)" ;; esac
host
stub ln ':'
run hand_dns_to_resolved
expect_fatal "a resolv.conf that will not stay linked to the stub stops the install" \
"is not a link to /run/systemd/resolve/stub-resolv.conf"
host
mkdir -p "$TMP/root/saved"
echo "the original" > "$TMP/root/saved/resolv.conf.orig"
@@ -155,7 +219,8 @@ host
printf 'Global:\nLink 2 (br-wan):\n' > "$TMP/state/resolvectl-dns"
run hand_dns_to_resolved
expect_fatal "no upstream servers stops the install, naming the check and printing the restore" \
"'resolvectl dns' lists no DNS servers" "systemctl disable --now systemd-resolved" "cp -a --remove-destination $TMP/root/saved/resolv.conf.orig"
"'resolvectl dns' lists no DNS servers" "systemctl disable --now systemd-resolved" "cp -a --remove-destination $TMP/root/saved/resolv.conf.orig" \
"rm -f $TMP/root/nm/90-spinifex-dns.conf"
host
: > "$TMP/state/getent-fail"
+48 -88
View File
@@ -1,6 +1,6 @@
#!/bin/sh
# setup_sqs_store_image_test.sh — tests for shipping the SQS store and worker
# microVM images: staging them into a release tree and installing with setup.sh.
# setup_sqs_worker_image_test.sh — tests for shipping the SQS microVM
# image: staging it into a release tree and installing with setup.sh.
#
# setup.sh is sourced with INSTALL_SPINIFEX_LIB_ONLY=1 so main never runs, and
# installs into a temporary SPINIFEX_MICROVM_DIR with SUDO cleared.
@@ -16,7 +16,7 @@ fails=0
pass() { echo " ok - $1"; }
fail() { echo " FAIL - $1"; fails=$((fails + 1)); }
echo "-- SQS store and worker microVM image release and install"
echo "-- SQS microVM image release and install"
# image <dir> <tag>: a fake built image whose bytes name its tag.
image() {
@@ -35,11 +35,11 @@ release() {
}
# install_image <extract-dir> <microvm-dir> [path-prefix]: runs
# install_sqs_store_image and sets RC and OUT.
# install_sqs_worker_image and sets RC and OUT.
install_image() {
RC=0
OUT=$(PATH="${3:-}$PATH" EXTRACT_DIR="$1" SPINIFEX_MICROVM_DIR="$2" INSTALL_SPINIFEX_LIB_ONLY=1 \
bash -c '. "$1"; SUDO=""; install_sqs_store_image' _ "$SETUP" 2>&1) || RC=$?
bash -c '. "$1"; SUDO=""; install_sqs_worker_image' _ "$SETUP" 2>&1) || RC=$?
}
same() { cmp -s "$1" "$2"; }
@@ -48,13 +48,13 @@ same() { cmp -s "$1" "$2"; }
image "$TMP/built" v1
release "$TMP/rel"
sh "$STAGE" "$TMP/built" "$TMP/rel" > "$TMP/stage.out"
if same "$TMP/built/vmlinuz" "$TMP/rel/microvm-sqs-store/vmlinuz" \
&& same "$TMP/built/initramfs.cpio.gz" "$TMP/rel/microvm-sqs-store/initramfs.cpio.gz"; then
if same "$TMP/built/vmlinuz" "$TMP/rel/microvm-sqs-worker/vmlinuz" \
&& same "$TMP/built/initramfs.cpio.gz" "$TMP/rel/microvm-sqs-worker/initramfs.cpio.gz"; then
pass "stages the built image beside the load balancer's"
else
fail "staged image differs from the build"
fi
if [ ! -e "$TMP/rel/microvm/sqs-store" ] && [ "$(ls "$TMP/rel/microvm" | wc -l)" -eq 3 ]; then
if [ ! -e "$TMP/rel/microvm/sqs-worker" ] && [ "$(ls "$TMP/rel/microvm" | wc -l)" -eq 3 ]; then
pass "leaves the load balancer's microvm directory as it was"
else
fail "load balancer directory changed: $(ls "$TMP/rel/microvm")"
@@ -63,21 +63,21 @@ fi
release "$TMP/rel-none"
mkdir -p "$TMP/half"; printf 'k\n' > "$TMP/half/vmlinuz"
OUT=$(sh "$STAGE" "$TMP/half" "$TMP/rel-none")
if [ ! -e "$TMP/rel-none/microvm-sqs-store" ] && echo "$OUT" | grep -q 'WARNING: SQS store microVM image missing'; then
if [ ! -e "$TMP/rel-none/microvm-sqs-worker" ] && echo "$OUT" | grep -q 'WARNING: SQS worker microVM image missing'; then
pass "stages nothing from an incomplete build, and says so"
else
fail "incomplete build staged or unannounced: $OUT"
fi
# --- an older installer meets a release that carries the store image ---
# --- an older installer meets a release that carries the SQS image ---
# It copies microvm/* without -r; the sibling directory must not reach it.
mkdir -p "$TMP/old-dest"
if cp "$TMP/rel/microvm/"* "$TMP/old-dest/" 2> "$TMP/old.err" && [ ! -e "$TMP/old-dest/sqs-store" ]; then
pass "an older installer's copy still succeeds and ignores the store image"
if cp "$TMP/rel/microvm/"* "$TMP/old-dest/" 2> "$TMP/old.err" && [ ! -e "$TMP/old-dest/sqs-worker" ]; then
pass "an older installer's copy still succeeds and ignores the SQS image"
else
fail "older installer copy failed: $(cat "$TMP/old.err")"
fi
mkdir -p "$TMP/nested/microvm/sqs-store" "$TMP/nested-dest"
mkdir -p "$TMP/nested/microvm/sqs-worker" "$TMP/nested-dest"
printf 'x\n' > "$TMP/nested/microvm/vmlinuz"
if cp "$TMP/nested/microvm/"* "$TMP/nested-dest/" 2> /dev/null; then
fail "control: a nested directory did not break the older copy, so the test proves nothing"
@@ -87,58 +87,58 @@ fi
# --- install ---
install_image "$TMP/rel" "$TMP/mv"
if [ "$RC" = 0 ] && same "$TMP/built/vmlinuz" "$TMP/mv/sqs-store/vmlinuz" \
&& same "$TMP/built/initramfs.cpio.gz" "$TMP/mv/sqs-store/initramfs.cpio.gz"; then
if [ "$RC" = 0 ] && same "$TMP/built/vmlinuz" "$TMP/mv/sqs-worker/vmlinuz" \
&& same "$TMP/built/initramfs.cpio.gz" "$TMP/mv/sqs-worker/initramfs.cpio.gz"; then
pass "installs the staged image where the daemon looks"
else
fail "install rc=$RC: $OUT"
fi
mode=$(stat -c '%a' "$TMP/mv/sqs-store/initramfs.cpio.gz" 2> /dev/null || echo none)
mode=$(stat -c '%a' "$TMP/mv/sqs-worker/initramfs.cpio.gz" 2> /dev/null || echo none)
if [ "$mode" = 644 ]; then pass "installs the image 0644"; else fail "initramfs mode $mode"; fi
image "$TMP/built2" v2
release "$TMP/rel2"
sh "$STAGE" "$TMP/built2" "$TMP/rel2" > /dev/null
install_image "$TMP/rel2" "$TMP/mv"
if [ "$RC" = 0 ] && same "$TMP/built2/vmlinuz" "$TMP/mv/sqs-store/vmlinuz" \
&& same "$TMP/built2/initramfs.cpio.gz" "$TMP/mv/sqs-store/initramfs.cpio.gz"; then
if [ "$RC" = 0 ] && same "$TMP/built2/vmlinuz" "$TMP/mv/sqs-worker/vmlinuz" \
&& same "$TMP/built2/initramfs.cpio.gz" "$TMP/mv/sqs-worker/initramfs.cpio.gz"; then
pass "an upgrade replaces both files"
else
fail "upgrade rc=$RC: $OUT"
fi
# A release without the store image: nothing installed, and a clear warning.
# A release without the SQS image: nothing installed, and a clear warning.
install_image "$TMP/rel-none" "$TMP/mv-none"
if [ "$RC" = 0 ] && [ ! -e "$TMP/mv-none/sqs-store" ] && echo "$OUT" | grep -q 'no SQS store microVM image; SQS store launches will be refused'; then
if [ "$RC" = 0 ] && [ ! -e "$TMP/mv-none/sqs-worker" ] && echo "$OUT" | grep -q 'no SQS worker microVM image; SQS worker launches will be refused'; then
pass "a release without the image installs nothing and warns"
else
fail "release without image: rc=$RC exists=$([ -e "$TMP/mv-none/sqs-store" ] && echo yes || echo no) $OUT"
fail "release without image: rc=$RC exists=$([ -e "$TMP/mv-none/sqs-worker" ] && echo yes || echo no) $OUT"
fi
# installed <dir>: a store image from an earlier install, plus an unrelated file.
# installed <dir>: an SQS image from an earlier install, plus an unrelated file.
installed() {
image "$1/sqs-store" old
printf 'keep\n' > "$1/sqs-store/operator-note"
image "$1/sqs-worker" old
printf 'keep\n' > "$1/sqs-worker/operator-note"
}
gone() { [ ! -e "$1/sqs-store/vmlinuz" ] && [ ! -e "$1/sqs-store/initramfs.cpio.gz" ]; }
kept() { [ "$(cat "$1/sqs-store/operator-note" 2> /dev/null)" = keep ]; }
gone() { [ ! -e "$1/sqs-worker/vmlinuz" ] && [ ! -e "$1/sqs-worker/initramfs.cpio.gz" ]; }
kept() { [ "$(cat "$1/sqs-worker/operator-note" 2> /dev/null)" = keep ]; }
# Upgrading to a release without the image must not leave the old one usable.
installed "$TMP/mv-up"
install_image "$TMP/rel-none" "$TMP/mv-up"
if [ "$RC" = 0 ] && gone "$TMP/mv-up" && kept "$TMP/mv-up" \
&& echo "$OUT" | grep -q 'Removed the SQS store microVM image left by an earlier install' \
&& echo "$OUT" | grep -q 'SQS store launches will be refused'; then
&& echo "$OUT" | grep -q 'Removed the SQS worker microVM image left by an earlier install' \
&& echo "$OUT" | grep -q 'SQS worker launches will be refused'; then
pass "a release without the image removes the earlier one, keeps other files, and warns"
else
fail "release without image over an installed one: rc=$RC $OUT"
fi
# An incomplete store image refuses and removes the earlier pair, because the
# An incomplete SQS image refuses and removes the earlier pair, because the
# binaries this release installed before it would otherwise boot the old one.
installed "$TMP/mv-bad"
cp -r "$TMP/rel2" "$TMP/rel-bad"
rm "$TMP/rel-bad/microvm-sqs-store/initramfs.cpio.gz"
rm "$TMP/rel-bad/microvm-sqs-worker/initramfs.cpio.gz"
install_image "$TMP/rel-bad" "$TMP/mv-bad"
if [ "$RC" != 0 ] && echo "$OUT" | grep -q 'incomplete: initramfs.cpio.gz is missing' \
&& gone "$TMP/mv-bad" && kept "$TMP/mv-bad"; then
@@ -154,17 +154,17 @@ for case in missing incomplete; do
sh "$STAGE" "$TMP/built" "$TMP/rel-$case" > /dev/null
if [ "$case" = missing ]; then rm -r "$TMP/built-$case"; else rm "$TMP/built-$case/initramfs.cpio.gz"; fi
OUT=$(sh "$STAGE" "$TMP/built-$case" "$TMP/rel-$case")
if [ ! -e "$TMP/rel-$case/microvm-sqs-store" ] && echo "$OUT" | grep -q 'tarball will not include it'; then
if [ ! -e "$TMP/rel-$case/microvm-sqs-worker" ] && echo "$OUT" | grep -q 'tarball will not include it'; then
pass "restaging from a $case build ships no earlier image"
else
fail "restaging from a $case build left: $(ls -R "$TMP/rel-$case/microvm-sqs-store" 2> /dev/null)"
fail "restaging from a $case build left: $(ls -R "$TMP/rel-$case/microvm-sqs-worker" 2> /dev/null)"
fi
done
mkdir -p "$TMP/rel-other/microvm-sqs-store"
image "$TMP/rel-other/microvm-sqs-store" old
printf 'keep\n' > "$TMP/rel-other/microvm-sqs-store/note"
mkdir -p "$TMP/rel-other/microvm-sqs-worker"
image "$TMP/rel-other/microvm-sqs-worker" old
printf 'keep\n' > "$TMP/rel-other/microvm-sqs-worker/note"
sh "$STAGE" "$TMP/no-such-build" "$TMP/rel-other" > /dev/null
if [ ! -e "$TMP/rel-other/microvm-sqs-store/vmlinuz" ] && [ "$(cat "$TMP/rel-other/microvm-sqs-store/note")" = keep ]; then
if [ ! -e "$TMP/rel-other/microvm-sqs-worker/vmlinuz" ] && [ "$(cat "$TMP/rel-other/microvm-sqs-worker/note")" = keep ]; then
pass "restaging removes only the image files"
else
fail "restaging touched more than the image files"
@@ -180,65 +180,25 @@ exec /usr/bin/install "$@"
EOF
chmod +x "$TMP/bin/install"
install_image "$TMP/rel" "$TMP/mv" "$TMP/bin:"
if [ "$RC" != 0 ] && same "$TMP/built/vmlinuz" "$TMP/mv/sqs-store/vmlinuz" && [ ! -e "$TMP/mv/sqs-store/initramfs.cpio.gz" ]; then
if [ "$RC" != 0 ] && same "$TMP/built/vmlinuz" "$TMP/mv/sqs-worker/vmlinuz" && [ ! -e "$TMP/mv/sqs-worker/initramfs.cpio.gz" ]; then
pass "an interrupted install leaves the initramfs missing, not mismatched"
else
fail "interrupted install: rc=$RC initramfs=$([ -e "$TMP/mv/sqs-store/initramfs.cpio.gz" ] && echo present || echo missing)"
fail "interrupted install: rc=$RC initramfs=$([ -e "$TMP/mv/sqs-worker/initramfs.cpio.gz" ] && echo present || echo missing)"
fi
# --- SQS worker image: same staging and install path, its own directory ---
install_worker() {
RC=0
OUT=$(EXTRACT_DIR="$1" SPINIFEX_MICROVM_DIR="$2" INSTALL_SPINIFEX_LIB_ONLY=1 \
bash -c '. "$1"; SUDO=""; install_sqs_worker_image' _ "$SETUP" 2>&1) || RC=$?
}
image "$TMP/wbuilt" w1
release "$TMP/wrel"
sh "$STAGE" "$TMP/built" "$TMP/wrel" > /dev/null
sh "$STAGE" "$TMP/wbuilt" "$TMP/wrel" sqs-worker > /dev/null
if same "$TMP/wbuilt/vmlinuz" "$TMP/wrel/microvm-sqs-worker/vmlinuz" \
&& same "$TMP/wbuilt/initramfs.cpio.gz" "$TMP/wrel/microvm-sqs-worker/initramfs.cpio.gz" \
&& same "$TMP/built/vmlinuz" "$TMP/wrel/microvm-sqs-store/vmlinuz"; then
pass "the worker image stages beside the store image"
image "$TMP/keep/sqs-store" old-store
install_image "$TMP/rel" "$TMP/keep"
if [ "$RC" = 0 ] && [ "$(cat "$TMP/keep/sqs-store/vmlinuz")" = kernel-old-store ] \
&& [ "$(cat "$TMP/keep/sqs-store/initramfs.cpio.gz")" = initramfs-old-store ]; then
pass "an install leaves a separate store image from an earlier install untouched"
else
fail "worker staging: $(ls -R "$TMP/wrel")"
fail "earlier store image: rc=$RC $(ls -R "$TMP/keep")"
fi
install_worker "$TMP/wrel" "$TMP/wmv"
if [ "$RC" = 0 ] && same "$TMP/wbuilt/initramfs.cpio.gz" "$TMP/wmv/sqs-worker/initramfs.cpio.gz" \
&& [ ! -e "$TMP/wmv/sqs-store" ]; then
pass "the worker image installs into its own directory only"
if OUT=$(sh "$STAGE" "$TMP/built" "$TMP/rel" sqs-store 2>&1); then
fail "the separate store image was staged"
else
fail "worker install: rc=$RC $OUT"
fi
release "$TMP/wrel-none"
OUT=$(sh "$STAGE" "$TMP/no-such-build" "$TMP/wrel-none" sqs-worker)
if [ ! -e "$TMP/wrel-none/microvm-sqs-worker" ] && echo "$OUT" | grep -q 'SQS worker microVM image missing'; then
pass "a missing worker build stages nothing and says so"
else
fail "missing worker build: $OUT"
fi
install_worker "$TMP/wrel-none" "$TMP/wmv"
if [ "$RC" = 0 ] && [ ! -e "$TMP/wmv/sqs-worker/vmlinuz" ] && [ ! -e "$TMP/wmv/sqs-worker/initramfs.cpio.gz" ] \
&& echo "$OUT" | grep -q 'no SQS worker microVM image; SQS worker launches will be refused'; then
pass "a release without the worker image removes the earlier one and warns"
else
fail "release without worker image: rc=$RC $OUT"
fi
cp -r "$TMP/wrel" "$TMP/wrel-bad"
rm "$TMP/wrel-bad/microvm-sqs-worker/vmlinuz"
install_worker "$TMP/wrel" "$TMP/wmv"
install_worker "$TMP/wrel-bad" "$TMP/wmv"
if [ "$RC" != 0 ] && echo "$OUT" | grep -q 'SQS worker microVM image in this release is incomplete: vmlinuz is missing' \
&& [ ! -e "$TMP/wmv/sqs-worker/initramfs.cpio.gz" ]; then
pass "an incomplete worker image is refused and the earlier pair is removed"
else
fail "incomplete worker image: rc=$RC $OUT"
fi
if OUT=$(sh "$STAGE" "$TMP/wbuilt" "$TMP/wrel" lb 2>&1); then
fail "an unknown image name was staged"
else
pass "an unknown image name is refused"
pass "the separate store image is no longer staged"
fi
[ "$fails" = 0 ] || { echo "FAIL: $fails"; exit 1; }
echo " setup_sqs_store_image_test ok"
echo " setup_sqs_worker_image_test ok"

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