Compare commits

...

260 Commits

Author SHA1 Message Date
Julian Sommer 840b6e93c5 release: update go.mod for v1.20.0 2026-09-16 10:11:22 +10:00
Julian Sommer 9bac0e2b45 chore: update COMMANDS.md 2026-09-16 09:44:52 +10:00
Julian Sommer f9c5a628c5 chore: aws coverage regen 2026-09-16 09:40:15 +10:00
Josh Siviour dd7a3e02a0 fix(acm,elbv2): serialise target group writes and fail fast on a missing tenant CA (#1041)
RegisterTargets read the target group, appended in memory and wrote the whole record back with no revision guard, so two concurrent registrations lost one. Terraform parallelises by default, so any target group with more than one member hit it, and the apply exited 0 with half the fleet not behind the load balancer.

Every writer of the record now goes through the store's existing CAS rather than a wholesale Put, because a CAS loop on one writer is defeated by a non-CAS writer on the same key. Contention stays per target group; no global lock is introduced.

RequestCertificate returned InternalErrorException for a missing tenant CA, which is a client-actionable precondition rather than a server fault. Worse, that code is classified retryable, so the provider backed off and retried instead of failing, and a single certificate sat at Still creating for over nine minutes. It now returns ACM's not-found code, which is terminal, and carries a message naming the missing CA and how to create one.

ListCertificates omitted CertificateSummaryList entirely on an empty account, since the SDK marshaller drops a nil slice unconditionally.

Closes: mulga-c6khe
Closes: mulga-wbjal
Closes: mulga-l6yft
Closes: mulga-61h3h
2026-09-16 08:53:37 +10:00
Josh Siviour 186f93a986 fix(iam): persist policy tags and count attachments across all principal kinds (#1040)
* fix(iam): persist policy tags and count attachments across all principal kinds

CreatePolicy built its record with an empty tag list and never read input.Tags, and GetPolicy, ListPolicies and CreatePolicy's own response projected no Tags field at all. Both halves are fixed, matching the role path.

buildAttachmentCounts scanned only the users bucket, so a policy attached to a role or group counted as unattached. That is not just a misleading number: DeletePolicy gates its conflict check on the same count, so an attached policy could be deleted, leaving the principal holding a dangling ARN.

The count and the reverse lookup are the same traversal with the names kept or discarded, so both now derive from one buildPolicyAttachments over users, roles and groups. Nothing is stored, so no counter can drift from the attachments it counts.

ListEntitiesForPolicy is implemented on that traversal, honouring EntityFilter and PathPrefix.

Closes: mulga-fvhlc
Closes: mulga-9i6uh
Closes: mulga-ryu30
Closes: mulga-8ranb

* test(iam): account for ListEntitiesForPolicy in the cross-tenant and policy-ARN gates

The action resolves PolicyArn through the same branch as GetPolicy, so it
belongs with that helper's siblings; it was in neither gate, which is what
the accounting test exists to catch.
2026-09-16 08:53:02 +10:00
Josh Siviour 3bc4b63a19 fix(ecr): store and project repository encryption, scanning and tags (#1039)
* fix(ecr): store and project repository encryption, scanning and tags

CreateRepository parsed none of encryptionConfiguration, imageScanningConfiguration or tags, so none were stored and none came back. Both blocks are now parsed, persisted on RepoMeta and projected, defaulting to AES256 and scanOnPush false as AWS does.

encryptionType KMS is rejected rather than echoed. Predastore seals every fragment with AES-256-GCM under a required master key, so reporting AES256 states something true, but no customer-managed key material is plumbed anywhere and accepting a KMS key ARN would assert a protection that does not exist.

Repository tags are stored on the same record, and TagResource and UntagResource now work instead of returning NotImplemented.

PutImageScanningConfiguration gets its own handler: asking for scanning off succeeds as a no-op, which is the already-true state, while asking for it on still returns OperationNotSupportedException because no scanner exists. Reporting scanning as enabled when nothing scans would read as a false all-clear.

The AWS Repository shape was built in three places with no shared helper, so every new field would have been added three times. They now share one buildRepository projection.

Closes: mulga-ywvtu
Closes: mulga-j2y54
Closes: mulga-3whvb

* fix: accept and store repository tags on ECR CreateRepository

The AWS API takes a tags parameter on CreateRepository; the gateway accepted
the request and discarded them, leaving the repository untagged with no error.
Decode them into the RepoMeta tag map at create time, rejecting an empty key
the same way TagResource does.
2026-09-16 07:40:42 +10:00
Josh Siviour fd4f3bcb49 fix(ecs): complete Describe read-back for task definitions, services and clusters (#1038)
DescribeTaskDefinition now echoes portMappings[].name and runtimePlatform,
DescribeServices projects the stored networkConfiguration and
enableECSManagedTags, DescribeClusters returns the four instance, task and
service counts, and the five list operations return their ARN key as a
present empty list instead of omitting it.

UpdateService now honours networkConfiguration and enableECSManagedTags so a
changed value converges instead of producing a permanent diff.

The eleven container runtime fields the task definition also drops are
deliberately left absent: nothing between the daemon and the on-host agent
carries them, and reporting a container as privileged or read-only-root when
nothing enforces it is worse than reporting nothing.

Closes: mulga-0t9q2
Closes: mulga-7thn7
Closes: mulga-d8kif
Closes: mulga-5z8z9
2026-09-16 07:39:41 +10:00
Josh Siviour c818255365 fix: answer in the envelope the caller's protocol expects, and say which service was refused (#1037)
Three defects at the gateway's HTTP error boundary, none of which change an authorisation outcome.

xmlErrorBody defaulted to the EC2 query envelope, so any scope it did not enumerate, including every service the gateway does not serve, received a shape a REST-XML client cannot deserialize and the real code never surfaced. The generic ErrorResponse is now the default and the query-protocol services are named explicitly. An unparsed signature keeps the EC2 shape, since no service is known there and that path is not what this changes.

The supportedServices gate logged the truthful reason and then emitted the canned credentials text, because writeSigV4Error had no parameter to carry a message. It takes one now, and the gate names the service it refused. The signature is never verified at that point, so blaming the caller's credentials was actively misleading.

ctxAction was set only for the query protocol, so every JSON-1.1 request shared the throttle bucket "<account>:unknown" and no per-action limit could ever match. It now resolves from X-Amz-Target, and for the path-routed rest-json services from the route's own lookup function.

Also sets X-Amzn-Errortype on the four JSON emitters, which JSON-protocol SDKs read to resolve the modelled exception type.
2026-09-16 07:39:04 +10:00
Ben Duncan e17742a43a fix: a terminate or stop survives a daemon restart that finds QEMU alive (#1043)
Recovery tested whether the QEMU process was still running before it looked at the instance's state, so a live process short-circuited the transitional-state handling and reconnectInstance then set the instance back to running. A stop or terminate the caller had already been told was under way was silently discarded.

Check for StateStopping and StateShuttingDown first, kill a QEMU that outlived the transition, and finalize. reconnectInstance now refuses a transitional instance outright rather than promoting it.
2026-09-15 14:12:35 -05:00
Ben Duncan 8d7b0f0f47 ci: capture br-mgmt topology and peer reachability from every e2e node (#1042)
Whether br-mgmt is a per-host island or one L2 segment spanning the cluster
decides whether a system instance can reach the gateway address it was baked
with. No artifact recorded which one a run built, and the environment is
destroyed before it can be asked, so the decisive evidence for an LB agent
heartbeat failure was gone by the time anyone looked.

Records ports, address and fail_mode per node, then probes every node's
br-mgmt address from every node. Islands reach only themselves and spanned
segments reach everything; a mixed result is the failure this catches.
2026-09-15 13:13:05 -05:00
Josh Siviour e03d15227d fix: carry a handler's refusal message to the client without leaking wrapper context (#1036)
* fix: carry a handler's own refusal message to the client without leaking wrapper context

A daemon that explains a refusal had that explanation discarded one step short of the client, so every NATS-backed error surfaced as the generic ErrorLookup blurb for its code. The message crossed NATS intact; resolveErrorDetail then dropped it because decodedNATSError was not the shape it recognises.

Rebuild the decoded response through awserrors.Errorf so the walk finds the message, and send only ResolveErrorDetail's curated message from the two encode sites instead of err.Error(). The latter rendered every %w layer, including context a call site added purely for its own logs, so restoring the message without also fixing the encode side would have turned a fidelity bug into an information disclosure.

Two existing tests pinned that wrapper context onto the wire, directly contradicting the gateway's own no-leak assertion; both are rewritten to pin the sanitized contract instead.

Also name the rejected filter in the twenty-four ParseFilters sites that logged the detail and returned a bare code, and give the ACM force-renew refusals a registered code so their reason still reaches the caller.

* fix: stop the filter rejection repeating its own error code to the client

ParseFilters spelled the code into its message text, so once the code began travelling separately the client saw it twice: "InvalidParameterValue: InvalidParameterValue: The filter 'x' is invalid".

Give ParseFilters the code directly via awserrors.Errorf and have its twenty-five callers return that error unwrapped rather than re-wrapping it. All of them already mapped it to InvalidParameterValue, so nothing changes but the duplication.

* fix: surface why a security group rule was rejected

ipPermissionsToSGRules already names the reason, "IPv6 rules are not supported" or "IpPermission must specify at least one IpRange or UserIdGroupPair", and both Authorize call sites logged it and returned a bare InvalidParameterValue, so the caller saw only the canned blurb.

This is the case mulga-e9qqx names specifically. It does not make IPv6 rules work; it makes the refusal legible.
2026-09-16 00:06:21 +10:00
Josh Siviour 18b393fd62 fix: describe an EKS cluster and node group well enough to plan against (#1035)
CreateCluster and CreateNodegroup accepted fields that DescribeCluster and DescribeNodegroup never returned. The unmodified terraform-aws-modules/eks/aws module reads those fields back, finds them empty, and either aborts the apply or proposes a destroy of a cluster that is already correct.

Two of the omissions aborted the apply outright. A module precondition on data.aws_eks_cluster failed with cluster_service_cidr is "" because kubernetesNetworkConfig was absent, and the tag resource address built from clusterSecurityGroupId came out as (,suite) because that field was empty.

Store what the caller asked for on ClusterMeta and NodegroupRecord at create time, and project it back. Both records are encoding/json with omitempty, so records already in KV stay readable and project exactly as they did before.

The read-backs echo the request rather than synthesising an AWS default, because UpdateClusterConfig is not implemented — a value the caller did not ask for would be a diff they could never converge. Where a field does report a default it is the value genuinely in force: 10.43.0.0/16 is k3s's own service CIDR, API is the only authentication mode implemented, and logging reports every type disabled because no control-plane log export exists.

resourcesVpcConfig.securityGroupIds previously overwrote the caller's own groups with the platform's pair, which live in a VPC the caller cannot see. It now echoes the caller. The customer-facing cluster security group is created in the caller's account and VPC at cluster-create time, so the id reported by clusterSecurityGroupId resolves for the tenant that owns it. That creation is idempotent with the one launchNodegroupInfra already makes, and matches AWS, which creates the group with the cluster.

updateNodegroupConfig accepted UpdateConfig and Taints, ignored both, and returned SUCCESSFUL. It now applies them under the existing compare-and-swap, so a plan converges instead of asking forever. Caller-supplied labels and taints still never reach kubelet; that gap predates this change and is tracked separately.

Verified on env19: the module applies clean and a re-plan reports no changes.
2026-09-15 22:26:30 +10:00
Josh Siviour d62fe703bb fix: describe an EC2 instance well enough to plan against (#1034)
Six defects in the EC2 describe path, found by running unmodified Terraform Registry modules against a live cluster and planning immediately after a clean apply. They share one shape: the write succeeds, the read-back is incomplete, and the caller cannot tell.

Populate Association on the primary network interface. The public IP was projected onto the instance but never onto the NIC, so the AWS provider read associate_public_ip_address as false for an instance that had a working public address. That attribute is force-new, so a no-op plan proposed destroying and recreating the instance.

Stamp EnclaveOptions. AWS always returns it, and its absence rendered as a permanent enclave_options block in every plan.

Stop emitting empty Reservations. The reservation map entry was created before the filter predicate ran, so a filter that excluded every instance in a reservation still emitted the reservation with no instances. Projection and filtering now run before the insert, in both the live and the KV path.

Merge reservations by ID in the gateway. Response frames were concatenated rather than merged, so one reservation could appear twice in a single response and which copy landed first varied between calls.

Mint the reservation ID once per RunInstances call. Each node minted its own, so a launch spread across nodes came back as several reservations. The gateway now allocates the ID and passes it to every node in a NATS header; a node that does not read the header mints its own as before, so the change is additive in both directions.

Answer an incomplete DescribeInstances sweep with a retryable ServiceUnavailable. Suppressing a false InvalidInstanceID.NotFound when a node had not answered was correct, but no branch was ever written for that case, so the caller received an empty 200 that read as authoritative. A filters-only query with no matches is still a legitimate empty result.

Accept the AWS spellings of the DescribeVpcs filters. The filters were implemented but named cidr-block and is-default where AWS uses cidr and isDefault, so Terraform's aws_vpc data source was rejected outright. Both spellings now work, since an internal caller depends on the old one. The parse error also names the offending filter to the client instead of only the log.

Resolve image owner aliases before filtering on them. describe-images --owners self compared an alias against a numeric account ID and matched nothing; the correct resolution already existed but ran after the filter.

Also builds the segscan test oracle with -buildvcs=false. That is unrelated to the above and fails on current toolchains regardless of this change, but it blocks preflight.
2026-09-15 21:02:16 +10:00
Julian Sommer 442444a274 chore: pin to latest predastore 2026-09-15 16:16:42 +10:00
Julian Sommer ab737e6bb1 chore: update readme to say bedrock q4 2026-09-15 16:15:15 +10:00
Julian Sommer 3537e36bd2 fix: use derived state 2026-09-15 12:00:06 +10:00
Julian Sommer 3647fdd5de fix: theme button animation 2026-09-15 11:57:57 +10:00
Julian Sommer 986ca34e34 chore: enable no-array-index-key rule 2026-09-15 11:46:06 +10:00
Julian Sommer 7b27d39fe9 fix: array index key 2026-09-15 11:44:22 +10:00
Julian Sommer 6c126be813 fix: test issue 2026-09-15 11:36:00 +10:00
Julian Sommer a292419356 fix: screen reader issues 2026-09-15 11:33:02 +10:00
Julian Sommer 3e83657a26 fix: radio buttons issue 2026-09-15 11:31:09 +10:00
Julian Sommer c0d54e6131 docs: publish one column of implemented operations and nothing else 2026-09-15 11:27:17 +10:00
Julian Sommer 881a66ab57 docs: publish only the operations the platform serves 2026-09-15 11:22:17 +10:00
Julian Sommer deffa31c37 fix: use safeDecoreURIComponent 2026-09-15 11:18:42 +10:00
Julian Sommer 87608a3b52 docs: undeclare anything the platform could plausibly build
Host reservations, ECR image scanning and the flow-logs integration template
were declared not applicable, and the reserved instance family with them. None
of those are ruled out: reservations and scheduled instances can be mocked over
capacity reservations the way spot instances already are, scanning needs only a
scanner behind the API, and the template needs CloudFormation.

What stays is what the platform cannot have: retired AWS features, operations
whose content is an AWS price or counterparty, and AWS-hosted services with
nothing behind them here.
2026-09-15 11:10:57 +10:00
Julian Sommer bf622f3745 docs: stop declaring roadmap work as not applicable
The roadmap carries IPv4 pools, local zones as an installation option, and an
S3 gap closure naming inventory and intelligent tiering. All three were
published as operations this platform will never serve.

Twenty-nine EC2 operations — local gateways, CoIP pools, BYOIP and the zone
group opt-in — and nine S3 operations are no longer declared. Carrier gateways
stay, as a route into a telco's 5G network in a Wavelength Zone.
2026-09-15 11:08:38 +10:00
Julian Sommer b89334d76f docs: keep the not-applicable operations out of the internal gap list
The internal report listed every unimplemented operation together, so the 87
EC2 operations declared not applicable sat in the same list as the work, where
anything reading it for candidates would pick them up.

They are now listed apart, grouped under the reason they were declared with,
and the counts line reports them as not applicable rather than as gaps.
2026-09-15 11:04:35 +10:00
Julian Sommer 4380bf1de5 docs: declare the EC2 operations this platform can never serve
EC2 published one not-applicable operation against 625 in the model, so the
page was silent about a large surface that is absent for a structural reason
rather than an unfinished one.

Eighty-six more are declared across nine families: EC2-Classic, the 2016
resource ID migration, FPGA images, Elastic Graphics, Outposts and the edge
zone gateways, capacity purchasing, AWS backbone telemetry, Marketplace
product codes and BYOIP. S3 loses its replication and analytics declarations,
which named work that is not ruled out.
2026-09-15 11:00:40 +10:00
Julian Sommer 4cbf7f33c7 chore: trim s3 coverage intro 2026-09-15 10:58:00 +10:00
Julian Sommer 3b523498fc docs: stop publishing planned RDS work as never
Multi-AZ clusters, read replicas and point-in-time restore were declared not
applicable, which says the platform will never serve them. All three are
planned, so the pages claimed something the roadmap contradicts.

Only a declaration now publishes that claim. A handler that refuses an
operation today establishes nothing about tomorrow, so it reads as the gap it
is rather than a permanent one.
2026-09-15 10:56:29 +10:00
Julian Sommer c24636c734 docs: put the not-applicable operations back in the one table
Splitting them into a second table repeated a reason on every row that shared
it and separated an operation from the rest of the surface it belongs to. One
table carries every operation the platform answers, served or refused, and the
reasons return to numbered footnotes below it.
2026-09-15 10:53:37 +10:00
Julian Sommer c8d42ac160 docs: publish S3 API coverage
S3 was the one served surface with no coverage page. The gateway has no
operation-name dispatch table for it, so there was nothing to compare against
the model.

Predastore now declares the operations each of its S3 routes answers, so the
generator compares that inventory the same way it compares the other nine
services. The page credits Predastore rather than Spinifex, and its intro
separates what is routed from what conforms, pointing at the ceph/s3-tests
report for the second.
2026-09-15 10:51:19 +10:00
Julian Sommer a3fbf82b9b chore: fix coverage text 2026-09-15 10:37:02 +10:00
Julian Sommer 05830f3499 docs: total the served operations on the coverage index
The index listed a count per service and left the reader to add them up. It
now opens with the total and closes the table with it, counting only the
services it links.
2026-09-15 10:31:01 +10:00
Julian Sommer e2fb37c0ac docs: publish only the AWS operations Spinifex serves
The coverage pages led with a percentage and an "x of y" count, and every
table listed the modelled operations Spinifex does not implement, which reads
as a scorecard against AWS rather than a statement of what the platform serves.

A published page now opens with the operation count alone, tables the
operations the gateway serves, and carries a separate not-applicable table with
each reason inline instead of footnotes. The unimplemented operations and the
counts move to docs/coverage/internal-report.md, which no docs-site slug points
at. A service with no implemented operations has no publishable page.
2026-09-15 10:28:35 +10:00
Julian Sommer 546053d430 feat: implement UpdateSecurityGroupRuleDescriptions for ingress and egress (#1033) 2026-09-15 09:42:29 +10:00
Ben Duncan 1dc7353bc1 fix: build one set of default VPC resources per account across racing nodes (#1032)
Every node's startup pass, the account.created event and the gateway's
EnsureDefaultVpc request can each build an account's defaults on a different
node. Each checked for an existing record, then wrote a fresh random ID with
Put, so racing nodes built a VPC, security group, route table and internet
gateway each. A per-account claim, written with Create, now fixes the IDs
before anything is built; every record is created under those IDs with
Create, and only the creator announces it to vpcd. nextVNI retries its CAS
instead of failing the loser.
2026-09-14 16:38:43 -05:00
Ben Duncan fea03a5b22 feat: verify JetStream replicas agree after formation, discard stores by default (#1031)
spx admin init now discards the node's pre-formation JetStream store by default for --nodes >= 2; --discard-jetstream=false restores the refusal. spx admin kv digest gains --json with stream replica, retention and limit settings and per-sequence subject, time and TTL. The new spx admin kv compare reads every node's digest and fails only on real divergence: different content at one sequence, a missing message nothing explains, or wrong replica placement. install-node.sh gates formation on it and gains --ipsec on|off, verified on every node. The multi-node guide documents both.
2026-09-14 16:38:33 -05:00
Ben Duncan 573a42a617 ci: run the e2e nightly every day, off the top of the hour (#1027)
A weekend gap is two runs of evidence lost on a suite whose remaining
failures are a rate rather than a bug. GitHub queues scheduled runs behind
every other top-of-hour cron and sheds them under load, which is why
2026-09-14 never fired and why every run started 3h15m-3h45m late.
2026-09-14 15:40:25 -05:00
Ben Duncan ea3e01bb20 fix: reboot a guest by asking it, not by pulling its reset line (#1028)
* fix: reboot a guest by asking it, not by pulling its reset line

RebootInstances was QMP system_reset alone. The guest kernel is never told,
so it does not sync, does not unmount and loses every dirty page - which is
how a reboot issued seconds after a write loses that write, and how a guest
rebooted during cloud-init can fail to come back at all.

Guests now launch with -action shutdown=pause, so a clean guest poweroff
leaves QEMU paused rather than gone. Reboot presses the power button, waits
for the guest to reach that state, then resets and resumes the same process:
no volume, tap or state churn, and no window where the guest is missing. A
guest that will not go quietly is hard-reset, which is what AWS documents.

The button is pressed again every 10s while the guest ignores it. One signal
is not a shutdown request on a guest whose acpid has not started, which is
the same boot race that produced the failure.

Two consequences handled here: a guest that powers itself off now leaves
QEMU paused, so the heartbeat quits it; and a guest launched before the flag
exits on powerdown rather than pausing, so it is reset without being asked.

* fix: answer a reboot when it is accepted, not when the guest is back

Asking a guest to shut down takes as long as the guest takes. RebootInstances
gives the daemon five seconds to reply, so a reboot that waited for the guest
reported a reboot that had in fact succeeded as InvalidInstanceID.NotFound.

Manager.Reboot now admits the reboot — the instance exists and is running — and
runs the powerdown and reset in the background, which is the contract EC2
documents. The two admission failures are still reported; nothing after them
can be, so a reset that fails is logged and left to the heartbeat.

The gateway also stops calling a NATS timeout NotFound. Only the absence of a
subscriber says anything about where the instance is, which is how the sibling
start and terminate paths already read it.

* test: prove the churn reboot by its boot ID, not by how fast it happened

The subtest waited ten seconds and then read /proc/uptime. A reboot is
asynchronous, so the guest answering at that point may be the one that has
not shut down yet, and its uptime is whatever it was before — which the test
reads as "reboot may not have occurred".

Waiting for the boot ID to change is positive proof the guest rebooted and
does not depend on how long the guest takes to go down. The uptime assertion
stays, now measured against a boot that is known to be new.

* test: prove the graceful reboot against real guests and a real engine

Two tests the reboot design asked for and the unit tests cannot give.

Cell 18 launches two guests that differ only in how they answer the power
button. One installs a shutdown hook writing an fsynced line a second, so the
file that survives measures how much of its shutdown it was allowed to run —
a guest reset instead of asked leaves none of it. The other ignores the power
key entirely and must be reset once its budget expires, with the daemon saying
so. Each variant asserts the evidence only it can produce, and the slow one
asserts the absence of the line the wedged one requires.

Cell 27 loads a PostgreSQL instance past its shared buffers with pgbench and
reboots it dirty. The probe is an UNLOGGED table: PostgreSQL writes no WAL for
one, so a clean shutdown checkpoints it and crash recovery truncates it
unconditionally. Rows committed normally survive a hard reset too, so the
obvious assertion would pass against the bug.

The rds suite and cell 27 both gain budget for the load phase.

* fix: stop the process-group test misreading a reaped child as alive

The test polled until the child was gone, then checked once more. A task
being reaped shows state X for an instant, which processRunning counted as
running, so that second look failed about 1 run in 50 under -race.

* fix: wait for the recovery retry loop before removing a test's data dir

restoreInstances starts the retry loop untracked, and it rewrites the local
state file when it ends. A write landing during t.TempDir cleanup failed
TestRestoreInstances_* with RemoveAll: directory not empty.
2026-09-14 15:39:27 -05:00
Ben Duncan 455c97a2e7 fix: prod review of 14 September — IPsec follower reads, stopped-instance EIP probes, audit client IP and JetStream formation divergence (#1029)
* fix: read NB_Global.ipsec through the NB remote list so followers and compute nodes answer quietly

* fix: stop probing and nudging the EIP datapath of a stopped instance's port

* fix: log the real client IP, request ID and duration_ms in the awsgw access line

* test: mark the UI proxy header test as in-package

* fix: discard pre-formation JetStream data when forming a multi-node cluster

A node that ran as its own single-node cluster carries KV streams into
formation. nats-server adopts a local stream with the same name as the
clustered one, and raft catch-up only fetches sequences beyond a
follower's last, so the conflicting prefix is never repaired and replicas
diverge permanently.

- spx admin join removes the local JetStream store once formation
  succeeds. A store holding streams now needs --force, and the
  identity-discard warning names it.
- spx admin init --nodes >= 2 refuses a store that holds streams unless
  --discard-jetstream is given, and removes it once every node has joined.
  Keeping it as the seed is unsafe: stream leadership ignores who holds
  data, and unassigned local streams are deleted as orphans.
- Neither removes a store while `spx service nats` runs, --force included.
- install-node.sh passes --discard-jetstream and waits for every
  `spx service` process to exit before formation.

* feat: add spx admin kv digest for per-replica JetStream consistency checks

Replicas of a stream can hold different content while raft reports them
current, and nothing in the cluster detects it. A direct get over NATS is
no way to inspect one replica: a client's request goes to a random member
of the direct-get queue group, routed replicas included.

kv digest copies this node's $G streams tree (without consumer state) to a
temporary directory, opens the copy in a private in-process server with no
listener, and prints first/last sequence, message count and a SHA-256 over
every stored message per stream. Running it on each node and diffing the
output shows which streams diverge; --seqs locates the first differing
sequence. The live store is only ever read.

* fix: let spx admin kv digest read replicated streams

A replicated stream's meta.inf carries num_replicas > 1, which a standalone
server refuses to recover, so the digest could not read any production store.
Rewrite the copy's config to one replica with no expiry and a recomputed
checksum, and test it against a three-node cluster and an expired bucket.

* test: use assert.ErrorIs for the removed JetStream store

* test: mark the JetStream store and digest tests as in-package

* fix: stop the process-group test misreading a reaped child as alive

The test polled until the child was gone, then checked once more. A task
being reaped shows state X for an instant, which processRunning counted as
running, so that second look failed about 1 run in 50 under -race.

* fix: wait for the recovery retry loop before removing a test's data dir

restoreInstances starts the retry loop untracked, and it rewrites the local
state file when it ends. A write landing during t.TempDir cleanup failed
TestRestoreInstances_* with RemoveAll: directory not empty.
2026-09-14 15:39:03 -05:00
Ben Duncan d7c2a202fc fix: two flaky tests failing the Race Detection job (#1030)
* fix: stop the process-group test misreading a reaped child as alive

The test polled until the child was gone, then checked once more. A task
being reaped shows state X for an instant, which processRunning counted as
running, so that second look failed about 1 run in 50 under -race.

* fix: wait for the recovery retry loop before removing a test's data dir

restoreInstances starts the retry loop untracked, and it rewrites the local
state file when it ends. A write landing during t.TempDir cleanup failed
TestRestoreInstances_* with RemoveAll: directory not empty.
2026-09-14 15:38:22 -05:00
Ben Duncan 1afc7fa8aa fix: retry a recovery relaunch that lost a race with a starting dependency (#1020)
* fix: retry a recovery relaunch that lost a race with a starting dependency

A guest drain-stopped by a coordinated cluster shutdown could be lost for good.
The relaunch ran before JetStream was serving, the volume lease claim timed out,
and the failure was recorded as permanent.

The backoff this needed already existed. relaunchWithRetry retries five times,
but only on ErrMountRetryable, and mountErrRetryable recognised exactly two
viperblock state-load sentinels. A lease claim returning context deadline
exceeded matched neither, so the mount was reported as permanently failed and
the backoff never engaged once.

- Classify a lease store that cannot answer as ErrLeaseStoreUnavailable, and
  include it in mountErrRetryable. A lease held by another node is deliberately
  excluded: that is an answer, not an outage.
- Probe readiness rather than connectedness. checkViperblockReady tested
  natsConn.IsConnected(), which is true against a server whose JetStream is
  still starting; it now calls AccountInfo with a timeout.
- Reconsider a latched failure. RetryRecoveryFailed relaunches instances parked
  in error by a dependency-unavailable reason, once the backing store is
  confirmed ready, for a bounded number of passes, then leaves them alone.
  Only mount-state and launch failures qualify; a failed reconnect or
  pre-relaunch hook can mean the guest itself needs looking at.

Closes the e2e gap that made this feel covered. The node-failure tests stop the
service units directly so guests keep running and recovery is a QMP re-attach to
a live process. Nothing stopped a guest, so nothing relaunched one from a sealed
volume against dependencies that were still starting.
TestMultinodeClusterRestartResumesGuests does, including the wait for every spx
process to exit that makes it a real restart rather than a stop that returned
early.

Beads mulga-v7kp4, mulga-kyb1d.

* fix: stop every spx process by unit in the cluster-restart helper

The first CI run of TestMultinodeClusterRestartResumesGuests failed at the
process wait with "node1 still has 2 spx process(es)", and the same thing
happened by hand on a prod node.

Every service is the same binary, and the stop was driven from a hard-coded
list. StopNode names seven units and omits spinifex-northstar and
spinifex-qmp-collector, which is exactly the two survivors. The unit name now
comes from the surviving process's cgroup, so the set that stops is whoever is
still running the binary and the list cannot go stale when a service is added.

StopNode itself is left alone: its list is deliberate for the hard outage it
simulates, where the target's drain must not fire.

Also marks recovery_retry_test.go as //test:in-package — the retry policy is
unexported and the tests drive it through the seams restore_test.go uses.

* fix: stop spinifex.target in the cluster-restart helper, not the units

The second CI run reformed NATS to 2 peers of 3: node3's NATS was stopped and
never started again.

StopNode stops the service units directly and leaves spinifex.target active.
That is right for the hard outage it simulates and wrong for a real shutdown —
the target then reads as satisfied while its units are dead, and the later start
raced a stop job that was still queued.

The helper now runs what the operator procedure and update-nodes.sh run: stop
spinifex.target, wait for every spx process to exit, and additionally wait for
systemd to have no spinifex job left queued before starting. That last wait is
the one that closes this race.

* fix: wait for the right NATS peer count in the cluster-restart helper

WaitNATSPeers counts remote peers, so a healthy three-node cluster reports two.
Asking for three could never be satisfied, and the run that hit it had in fact
restarted all three NATS correctly.
2026-09-14 10:39:06 -05:00
Ben Duncan 939b6a99ce fix: repair the three guards protecting a node reset (#1019)
The QEMU guard never fired. pgrep -x and pkill -x match comm, which the kernel
truncates to 15 characters, so qemu-system-x86_64 is stored as qemu-system-x86
and neither the -x name nor the -x alternation could ever match. The script
therefore never signalled a guest and never waited, and wiped viperblock state
out from under live guests. Match the 11-character prefix against comm, the same
rule qemuProcessPrefix already applies in vm/orphan_scan.go, and scope it to the
user the daemon runs guests as so a host carrying libvirt guests alongside
Spinifex ones does not lose the wrong set. Not -f: that matches the whole
command line, where argv[0] may carry a path.

Nothing waited for spx either. Stopping spinifex.target returns once the target
is inactive, but spinifex-shutdown.service is ordered After= the storage
services, so their stop jobs queue behind its ExecStop and NATS can still be
running. JetStream then rewrites its index after the rm and before the
verification, failing the reset on a file the script had already deleted. Add
the wait update-nodes.sh has carried since d9bc6e35.

The firewall restore was gated on a mode file the failed run had already
deleted, so a retry read the absence as "never armed", skipped the restore, and
install-node.sh honoured that and formed the cluster around an open node. Stash
the mode outside every wiped tree, and fail rather than warn when a node that
arrived armed cannot be put back.

install-node.sh had the other half of that cascade: it disarms every host to let
the OVN raft ports through and only re-arms at the end, so any abort in between
left them all open. Re-arm from an EXIT trap, and say so loudly when a host
arrives with no policy.
2026-09-14 10:38:42 -05:00
Ben Duncan 930afa9fcb fix: remove the JetStream store during a node reset (#1018)
The wipe loop deletes files but keeps every directory, so one empty
$G/streams/KV_<name> directory per stream survived the reset. NATS enumerates
those on start and reports them as HA assets it must restore, leaving the meta
group at size=4 with no leader over hundreds of assets that have no data behind
them. Every service then fails to reach JetStream and the daemon aborts its
bootstrap.

Remove the tree outright, mirroring the predastore/cluster removal above it.
2026-09-14 10:38:23 -05:00
Julian Sommer 0d88fd14b4 feat: implement EC2 GetSecurityGroupsForVpc (#1026) 2026-09-14 16:13:46 +10:00
Julian Sommer 53ef0ac491 feat: implement EC2 DescribeInstanceTypeOfferings (#1025) 2026-09-14 16:03:31 +10:00
Julian Sommer 0984eb31fb feat: implement ELBv2 DescribeAccountLimits (#1024) 2026-09-14 15:53:07 +10:00
Julian Sommer 6f44883e99 feat: implement sts:GetAccessKeyInfo (#1023) 2026-09-14 15:35:29 +10:00
Julian Sommer d5a648bded fix: README 2026-09-14 14:40:55 +10:00
Julian Sommer 52fad7cc71 fix: doc titles 2026-09-14 12:51:46 +10:00
Julian Sommer 5b70435bb4 fix: links 2026-09-14 12:42:58 +10:00
Julian Sommer ba98509c5b fix: simplify mulga-docs build step by collapsing coverage paths + hardware 2026-09-14 12:41:02 +10:00
Julian Sommer 613231fd18 feat: hide internal commands from compatibility page 2026-09-14 12:13:32 +10:00
Julian Sommer 19a047bccb fix: footnotes instead of notes for compatibility 2026-09-14 12:05:32 +10:00
Julian Sommer bba735497f fix: add not applicable to some actions 2026-09-14 11:58:21 +10:00
Julian Sommer 29faaa78fd chore: update compatibility pages 2026-09-14 11:49:46 +10:00
Julian Sommer fb6494da01 feat: coverage docs 2026-09-14 11:42:01 +10:00
Julian Sommer 2265aee120 feat: add eks to aws coverage 2026-09-14 11:30:55 +10:00
Julian Sommer 1eccfded6e fix: scope Tailwind source detection to src 2026-09-14 10:00:50 +10:00
Julian Sommer a608030330 chore: frontend deps 2026-09-14 09:52:53 +10:00
dependabot[bot] e3ea38ccf1 chore: bump the go-dependencies group with 10 updates (#1022)
Bumps the go-dependencies group with 10 updates:

| Package | From | To |
| --- | --- | --- |
| [github.com/aws/aws-sdk-go-v2](https://github.com/aws/aws-sdk-go-v2) | `1.45.1` | `1.47.0` |
| [github.com/aws/aws-sdk-go-v2/config](https://github.com/aws/aws-sdk-go-v2) | `1.33.2` | `1.33.4` |
| [github.com/aws/aws-sdk-go-v2/credentials](https://github.com/aws/aws-sdk-go-v2) | `1.20.2` | `1.20.4` |
| [github.com/aws/aws-sdk-go-v2/feature/ec2/imds](https://github.com/aws/aws-sdk-go-v2) | `1.19.1` | `1.20.0` |
| [github.com/jackc/pgx/v5](https://github.com/jackc/pgx) | `5.10.0` | `5.11.0` |
| [golang.org/x/crypto](https://github.com/golang/crypto) | `0.56.0` | `0.57.0` |
| [golang.org/x/sync](https://github.com/golang/sync) | `0.22.0` | `0.23.0` |
| [golang.org/x/sys](https://github.com/golang/sys) | `0.47.0` | `0.48.0` |
| [golang.org/x/time](https://github.com/golang/time) | `0.15.0` | `0.16.0` |
| [golang.org/x/tools](https://github.com/golang/tools) | `0.49.0` | `0.50.0` |


Updates `github.com/aws/aws-sdk-go-v2` from 1.45.1 to 1.47.0
- [Release notes](https://github.com/aws/aws-sdk-go-v2/releases)
- [Commits](https://github.com/aws/aws-sdk-go-v2/compare/v1.45.1...v1.47.0)

Updates `github.com/aws/aws-sdk-go-v2/config` from 1.33.2 to 1.33.4
- [Release notes](https://github.com/aws/aws-sdk-go-v2/releases)
- [Commits](https://github.com/aws/aws-sdk-go-v2/compare/config/v1.33.2...config/v1.33.4)

Updates `github.com/aws/aws-sdk-go-v2/credentials` from 1.20.2 to 1.20.4
- [Release notes](https://github.com/aws/aws-sdk-go-v2/releases)
- [Commits](https://github.com/aws/aws-sdk-go-v2/compare/v1.20.2...service/mq/v1.20.4)

Updates `github.com/aws/aws-sdk-go-v2/feature/ec2/imds` from 1.19.1 to 1.20.0
- [Release notes](https://github.com/aws/aws-sdk-go-v2/releases)
- [Changelog](https://github.com/aws/aws-sdk-go-v2/blob/v1.20.0/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-go-v2/compare/v1.19.1...v1.20.0)

Updates `github.com/jackc/pgx/v5` from 5.10.0 to 5.11.0
- [Release notes](https://github.com/jackc/pgx/releases)
- [Changelog](https://github.com/jackc/pgx/blob/master/CHANGELOG.md)
- [Commits](https://github.com/jackc/pgx/compare/v5.10.0...v5.11.0)

Updates `golang.org/x/crypto` from 0.56.0 to 0.57.0
- [Commits](https://github.com/golang/crypto/compare/v0.56.0...v0.57.0)

Updates `golang.org/x/sync` from 0.22.0 to 0.23.0
- [Commits](https://github.com/golang/sync/compare/v0.22.0...v0.23.0)

Updates `golang.org/x/sys` from 0.47.0 to 0.48.0
- [Commits](https://github.com/golang/sys/compare/v0.47.0...v0.48.0)

Updates `golang.org/x/time` from 0.15.0 to 0.16.0
- [Commits](https://github.com/golang/time/compare/v0.15.0...v0.16.0)

Updates `golang.org/x/tools` from 0.49.0 to 0.50.0
- [Release notes](https://github.com/golang/tools/releases)
- [Commits](https://github.com/golang/tools/compare/v0.49.0...v0.50.0)

---
updated-dependencies:
- dependency-name: github.com/aws/aws-sdk-go-v2
  dependency-version: 1.47.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-dependencies
- dependency-name: github.com/aws/aws-sdk-go-v2/config
  dependency-version: 1.33.4
  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.4
  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.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-dependencies
- dependency-name: github.com/jackc/pgx/v5
  dependency-version: 5.11.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-dependencies
- dependency-name: golang.org/x/crypto
  dependency-version: 0.57.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-dependencies
- dependency-name: golang.org/x/sync
  dependency-version: 0.23.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-dependencies
- dependency-name: golang.org/x/sys
  dependency-version: 0.48.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-dependencies
- dependency-name: golang.org/x/time
  dependency-version: 0.16.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-dependencies
- dependency-name: golang.org/x/tools
  dependency-version: 0.50.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-14 09:02:54 +10:00
dependabot[bot] b3a0a42c9a chore: bump actions/download-artifact in the github-actions group (#1021) 2026-09-14 09:02:32 +10:00
Josh Siviour a2b4b71840 test: add large multi-layer ECR push/pull e2e coverage (#1017)
The existing docker push e2e test pushes a single 4KB scratch layer, too
small to exercise predastore's storage path for a real multi-hundred-MB
blob. Add a 3-layer (100+80+60MiB) push/pull case that verifies re-pulled
layer digests against the pushed image and directly checks Content-Length
on a raw HTTP GET of the largest blob, targeting a reported defect where
large blob GETs returned 200 with content-length: 0. Re-pull uses
`docker builder prune -af`, not just `rmi`, since buildx's build cache can
otherwise satisfy the pull without touching the registry.
2026-09-11 13:36:28 +10:00
Josh Siviour b0faa72428 ci: benchmark Predastore across a real 3-node Spinifex env (#1015)
* ci: benchmark Predastore across a real 3-node Spinifex env

The nightly throughput job runs three s3d processes on loopback aliases on a
single box against local NVMe, so nothing measures Predastore in the shape it
ships in: three machines, with reads crossing a real NIC between hosts.

This provisions a 3-node env the way the multi-node e2e job does, then hands
its three gates to the existing harness through PERF_EXTERNAL_HOSTS. The
harness skips its own start and stop and measures them directly, so the
workloads and analysis are unchanged and the two numbers stay comparable.

It runs on a systemd LAN runner rather than the ephemeral pool because a pool
VM sits behind the geneve overlay at MTU 1408, which would put the runner's
own network path inside the measurement.

* ci: trigger the 3-node benchmark on its own branch for a first run

A workflow_dispatch only resolves against the default branch, so this cannot
be dispatched until it is merged. The push trigger is scoped to this branch
and comes back out once the workflow is on dev.

* fix: measure as the identity the provisioned cluster actually mints

spx admin init generates a random access key pair at bootstrap and writes it
to node1's ~/.aws/credentials, so the benchmark's built-in key is one this
cluster has never heard of and every run died on the first CreateBucket.

The pair is read and used inside a single step, the way e2e-ddil.yml does it:
a step-level env: block is echoed verbatim into the run log, a shell variable
is not.

* ci: default the 3-node benchmark to the compare preset

smoke was the right default while the pipeline was unproven. It is green now,
and compare is what the nightly loopback run uses, so it is the preset whose
numbers can be set beside it.

* ci: make the cluster's ref a knob, pinned to v1.18.0 for one run

The harness stays on this branch while the cluster is built from whatever ref
is asked for, so an old release is measured with the same ruler as the current
one. run-info.txt records the deployed ref rather than the harness checkout.

The v1.18.0 default is temporary: a push event carries no dispatch inputs, and
this cannot be dispatched until it is on dev. It comes back out after the run.

* test: probe ranged GETs, which no workload here exercises

Warp reads whole objects, so the throughput figures stay healthy while ranged
GETs read the whole object per request. The probe reads the same boundary
crossing range from a 64 MiB and a 256 MiB object: under read amplification
the cost tracks object size, and otherwise the two match.

Cluster ref is pinned to the fix branch for this run and comes back out after.

* test: run the ranged GET probe against dev to prove it fails there

A probe that has only ever been green proves nothing about the fix. Pointing
the cluster at dev, which still has the defect, is the control that shows the
probe detects it. The pin comes back out after.

* ci: drop the branch push trigger and the cluster ref pin

Both were scaffolding to run this before it existed on dev. The workflow is
dispatch only again, and cluster_ref defaults to the branch under test.
2026-09-10 15:39:00 +10:00
Julian Sommer 5c367cac43 chore: update github.com/containerd/containerd/v2 2026-09-10 14:46:20 +10:00
Julian Sommer a7d55e931d fix: doc formatting 2026-09-10 13:36:48 +10:00
Julian Sommer 93cef3213b fix: invalidate the host ARP entry when a management IP is released (#1016) 2026-09-10 11:49:11 +10:00
Julian Sommer 368f4efa40 chore: trim comments from workflows 2026-09-10 11:48:14 +10:00
Julian Sommer 60509e7921 fix: remove currency block on e2e.yml 2026-09-10 11:39:43 +10:00
Julian Sommer b65be5992d fix: bind STS session credentials to immutable principal IDs (#1014) 2026-09-09 15:58:32 +10:00
dependabot[bot] 393f78a9cd chore: bump google.golang.org/grpc (#1013) 2026-09-09 11:35:37 +10:00
Julian Sommer ea51cd4d7c release: update go.mod for v1.19.0 2026-09-08 10:34:03 +10:00
Julian Sommer 3004221982 fix: update security email 2026-09-08 08:58:28 +10:00
joshsiv-mulga 2b56887b89 chore: mark three in-package test files as intentional
All three need unexported access and cannot move to an external test
package: the daemon suite drives shared package fixtures and reads vmMgr,
resourceMgr and localStatePath, while the other two assert on buildArgs
and volumeVBConfig directly.

They were added on dev without the marker, so check-test-package.sh
failed for anyone running preflight on dev, where the base ref is
origin/main rather than origin/dev.
2026-09-08 08:50:05 +10:00
joshsiv-mulga 2e397d6e97 fix: keep the apiserver controlplane egress direct
The controlplane egress selector also carries the authentication
token-webhook dial, which targets 127.0.0.1:8443 on the control-plane VM.
Tunnelling it through konnectivity hands that dial to a randomly chosen
agent, where nothing is listening, so the authenticator returns an error
rather than a decision and every bearer-token request fails closed with a
bare 401.

It only surfaced once a second agent existed. The control-plane node runs
an agent itself, so while it is the only backend every tunnelled dial
lands back home and authentication works by luck. The apiserver's webhook
client also holds its connection for the 90s IdleConnTimeout, so there is
one backend selection per connection rather than per request.

TestEKS/IRSAPod hit both conditions every run: it builds a kubeconfig and
then waits ~105s for its nodegroup to go ACTIVE before issuing any
request, which both expires the pooled connection and brings up the
cluster's second agent.

Only the cluster egress needs the tunnel, for kubelet exec/logs/
portforward. This matches the upstream reference configuration.
2026-09-08 08:46:43 +10:00
joshsiv-mulga a46f02cc63 fix: pin cell-25 to the NVMe-backed single-node runner
The three ci-single runners map to env3/bottlebrush, env10/banksia and
env11/wattle, and the cell landed on whichever was free. Only bottlebrush
has NVMe; banksia and wattle are Dells with none, which is where every
etcd flush measurement to date was taken.
2026-09-08 07:50:50 +10:00
joshsiv-mulga 84565bc13b fix: set ci.run_id on bare-metal dispatch runs
A dispatch has no otel-attrs input, so the cell's telemetry reached the
sink with no run id and could only be found by guessing a time window.
2026-09-08 07:46:00 +10:00
joshsiv-mulga 23d5b81cf0 fix: correct the eks-node build paths on the bare-metal cell
spinifex is the workspace root there, not a subdirectory as in the
nightly, so the copied paths resolved one level too deep.
2026-09-07 23:31:26 +10:00
joshsiv-mulga 01dd2a761a feat: let the bare-metal cell run the eks suite
The eks suite boots its control plane from the eks-node AMI, which only
the nightly built. Bare metal had no EKS or mkosi step at all, so naming
the suite there produced a run that could never pass.
2026-09-07 23:27:11 +10:00
joshsiv-mulga befdb1ca10 fix: pin viperblock for WALBaseDir and set it on the mounted volume path
dev did not build: the WALBaseDir call sites landed while go.mod still
pinned viperblock v1.18.0, which predates the field.

constructMountedVB passed WALBaseDir to the nbdkit plugin but not to its
own engine, so the daemon would look for WALs under BaseDir while the
plugin wrote them under WALBaseDir.
2026-09-07 22:09:21 +10:00
Josh Siviour 97d8e4f119 feat: let a node put every volume's WAL on its own device (#1010)
A WAL fsync shares the device queue with predastore, the chunk cache and
every other volume on the node. Measured on the e2e stack, competing
filesystem traffic alone moves fsync p99 from 4.7ms to 22ms with no
change to viperblock, which is the tail an etcd guest reports as its
commit latency.

wal_base_dir under [nodes.<n>.viperblock] routes the WAL elsewhere. It
reaches both the short-lived VBs the service opens and, through the
nbdkit argv, the plugin backing each mounted volume. Unset keeps the WAL
under base_dir.
2026-09-07 21:53:55 +10:00
Josh Siviour 8e11908dfb fix: make the EKS control plane's disk latency observable, and give it room to run (#1009)
* fix: report CPU and memory pressure in the EKS control-plane health diagnostic

An unhealthy control plane reported readyz, etcd reachability and etcd-disk
free space. When etcd goes unreachable with disk:ok and no OOM kill, that set
cannot distinguish a starved guest from a genuine etcd fault, so add the
loadavg and MemAvailable that separate them.

An empty /readyz body also rendered as readyz:[none], which reads as "no
subcheck failed" when it means the probe never answered. Report that as
unreachable instead.

* fix: sample the node's load, not just the runner host's

Every runner agent runs on one host while their nodes sit on several, so the
existing sample measured the runner's host and was labelled as the hypervisor.
It happens to coincide when the node is on that same host and misleads
otherwise. Relabel it and add a node-side sample, which is where the guests are
actually scheduled and so where a starved guest shows up.

* feat: build the mkosi EKS image in CI and give its control plane telemetry

The Ubuntu/systemd EKS image is what customers run, and nothing built it: the
composition existed in mkosi-build.sh's image_profiles table but had no caller,
so a change that broke it was found by hand or not at all. Cell-25 builds the
Alpine image, which leaves the published one with no gate. Add a Makefile target
beside the Alpine one and a job in the existing path-filtered image workflow.

The guest also collected nothing. k3s has served embedded-etcd metrics on
127.0.0.1:2381 since the daemon started setting etcd-expose-metrics, and no
collector ever read them, so an etcd stall was only ever visible as
"etcd:unreachable" in the state report — which cannot separate a slow disk from
a dead one. Ship otelcol-contrib pinned to the version the cluster hosts already
run, scraping etcd and guest host metrics.

The unit is gated on ConditionPathExists for its endpoint file rather than
started unconditionally: whether the control-plane guest can reach the sink from
the isolated CP VPC is unverified, and a collector with nowhere to export is a
restart loop, not telemetry.

* fix: report etcd fsync latency in the control-plane health diagnostic

disk:ok measures free space, which says nothing about how long a write takes.
etcd stalls on fsync latency long before it runs out of room, and this guest's
disk is an EBS volume served by viperblock, so a fsync is a network round trip.
An unhealthy control plane reporting healthy memory and disk was uninterpretable
without it.

Derived from the histogram's sum and count rather than a bucket percentile: two
greps against an interpolation this script has no business doing, and the
difference between a healthy single-digit reading and a stalled one is an order
of magnitude, not a tail. Rides the state report's existing gateway path, so it
needs no route the CP VPC does not already grant, and lands on both images
because the script is shared.

The scrape's failure is guarded explicitly. Under set -e a bare assignment from
a curl against a port nothing answers aborts the whole diagnosis, dropping the
reason field exactly when it is wanted.

* feat: publish etcd fsync on every control-plane report, not only failing ones

A figure that appears only once the apiserver is already unhealthy has no
baseline to be read against, which is the same gap a passing cell that uploads
no journal leaves: the number is unanchored when it finally arrives. Move the
scrape into its own helper, emit it on healthy reports too, and carry it as a
typed field rather than only inside the reason string.

Slowness is logged, never gated. observe() decides cluster health, and a working
cluster on a slow disk is working — returning an issue there would fail
customers' clusters on a latency threshold. The warning is latched on the
transition instead, because the control plane republishes on its own timer and a
line every interval is how a real signal gets tuned out.

An absent sample is not a fast one. Too few fsyncs to average, an etcd serving
no metrics and an older AMI all arrive as nil, and the field is omitted rather
than defaulted so none of them can be read as healthy. The minimum sample count
is the same lesson as the slow-shard floor: a mean over three fsyncs on a
just-started etcd is a number with no meaning.

* fix: log an etcd fsync baseline and name the two no-sample cases apart

A healthy cluster logs no health lines, so the fsync figure reached the
daemon on every report and was never surfaced — a passing cell left nothing
for a later slow reading to be compared against. Log the first known sample.

Split the "unknown" sentinel into nometrics (etcd served none) and warmup
(too few fsyncs to average): different faults, different fixes. The payload
guard now tests for a number rather than the sentinel word, so a future
sentinel cannot leak into the JSON as a bare value.

* feat: measure fdatasync latency in the diskperf gate

The two pinned profiles are buffered-bypassing throughput jobs at queue
depth 32. Neither measures the cost of making a write durable, which is what
a consensus datastore pays per raft entry and stalls on long before it runs
short of bandwidth. A 5ms barrier on bare metal went unseen as a result.

syncwrite-4k is the etcd-shaped profile: sequential 4k, queue depth 1,
fdatasync every write, buffered psync so the sync has a page cache to flush.
fio reports the sync distribution apart from the write stream, so the parser
reads it separately — gating on write clat would score the time to reach the
cache and call it durability.

No figures captured on this hardware yet, so the baseline entry is zero and
the job records without gating.

* fix: use the real otelcol checksums asset name in the EKS server image

The checksums file is named for the release repo and the distribution and
carries no version, unlike the tarball beside it. The guessed name 404'd and
took the image build with it.

* fix: extract otelcol as root in the EKS server image

The upstream tarball records its build uid (runner/docker, 1001:118), which
does not exist in the image. tar running as root tries to restore it and
fails outright rather than degrading, taking the build with it.

---------

Co-authored-by: Ben Duncan <5178243+benduncan@users.noreply.github.com>
2026-09-07 21:53:41 +10:00
Julian Sommer a1a46c6402 fix: remove component from feature request 2026-09-07 15:28:53 +10:00
Julian Sommer f58d5ee2e8 fix: remove unneeded security report in issue 2026-09-07 15:27:08 +10:00
Julian Sommer 612c66c73c fix: add SECURITY.md 2026-09-07 15:17:13 +10:00
Julian Sommer 4be30b28f1 fix: add ISSUE_TEMPLATE 2026-09-07 15:13:03 +10:00
Julian Sommer f9d72d9487 fix: ui test warnings 2026-09-07 13:38:22 +10:00
Julian Sommer b435b858e4 chore: update pnpm 2026-09-07 11:53:55 +10:00
Julian Sommer 0d8855d1aa fix: authorize EC2 PassRole against the stored role ARN (#1008) 2026-09-07 10:54:05 +10:00
Julian Sommer e4f0050a1a fix: update COMMANDS.md with systemctl commands 2026-09-07 09:56:56 +10:00
Julian Sommer 8d8f020ed3 fix: update COMMANDS.md 2026-09-07 09:40:48 +10:00
Julian Sommer 6f5cb9dfaa fix: lint issues 2026-09-07 09:13:25 +10:00
dependabot[bot] 63393fea72 chore: bump the npm-dependencies group (#1006) 2026-09-07 09:03:38 +10:00
dependabot[bot] 7d639cf283 chore: bump the go-dependencies group with 6 updates (#1005) 2026-09-07 09:02:16 +10:00
Ben Duncan 18853cef31 fix: close the four defects keeping the e2e nightly red (#1007)
* fix: stop the e2e analyser inventing failures and let a detach outlast its seal

go-junit-report emits <error message="No test result found"> for a === RUN with
no matching result line, and attaches the test's own captured log as the body.
The analyser counted those as failures, so the baremetal cell reported 25
failures against a junit file recording 1, with log output reading as
assertions. Separate the marker from a real error in both the parse and the
roll-up, count the unresolved cases and say so in the summary.

The gateway gave a detach 30s while the daemon budgets 180s for the ebs.unmount
seal and 180s more for the QMP unplug chain, so any detach whose seal replayed a
WAL was reported as ServerInternal while it went on to succeed.

Also switch four nightly job gates from always() to !cancelled(): always()
overrides cancellation, so a cancelled run kept starting jobs.

* fix: serialise the e2e workflows on one group and retry a dropped libvirt

e2e.yml had no concurrency block at all, so every push to dev could provision
alongside every other push and alongside the nightly. All four workflows that
build on these hypervisors now share e2e-hypervisors, queued rather than
cancelled. A per-environment group is not expressible: GitHub picks the group
before it picks a runner, and the environment is a property of the runner.

tofu apply also failed outright on a transient libvirt connect, which fails a
45-minute job in 90 seconds and reports it as a test failure. retry_run retries
only when that attempt's own output names a connection failure, so a real
failure still fails once.

Also collect the unfiltered journal from the matrix cells and the node journal
from cell-21: the unit filter drops the kernel and OOM lines that explain why
one node behaved unlike its siblings, and a passing run's journal is what makes
a failing one readable.

* fix: build the baremetal junit from the unfiltered suite log

run-baremetal-e2e.sh streams the full suite output to suite-output.log and a
grep-filtered summary to its stdout, which the workflow tees into
test-baremetal.log. That filter anchors --- PASS at column 0 and Go indents
every subtest result by four spaces, so the junit was generated from a log with
no subtest results in it: go-junit-report saw each === RUN with no outcome and
emitted <error message="No test result found">.

17 of cell-28's 39 testcases and 93 of cell-20's 120 were that. Regenerating
cell-28 from suite-output.log gives 39 tests, 0 failures, 0 errors.

* fix: converge released EIPs instead of trusting a fire-and-forget teardown

A disassociate leaves the ENI running, so the orphan sweep's owning-port test
never fires for it. The teardown then rests entirely on vpc.delete-nat, which is
core NATS and is lost whenever vpcd is restarting — and the intent KV watch is
UpdatesOnly, so the same window hides the write that would have driven a repair
pass. The dnat_and_snat row and its host /32 route to a released public address
survived until something else happened to touch them, which for a live ENI was
nothing at all.

Give the sweep the second test it was missing: AddEIP is the only writer of
dnat_and_snat, so the floating IPs intent asks for are the complete set of
addresses that may hold one, and a row for any other address is stale however
alive its port. Ports and external IPs travel together in LiveEIPs, both unioned
from the start-of-pass snapshot and the fresh re-read, so a mid-pass launch is
still spared. The host unbind now runs even when the row had already gone, which
is the state a crash between the OVN delete and the unbind leaves.

Run that sweep on the startup pass too. Startup is the pass that follows the
blind window, so skipping it deferred repair of exactly the changes that were
missed. The port-group and ENI-port sweeps stay gated there for the in-flight
create they were written for; the EIP sweep re-reads intent immediately before
deciding and abandons the whole sweep if that read fails, which is the same
protection by a stronger route.

* fix: keep a release verify off the hypervisors an e2e run is using

release.yml is the fifth workflow that provisions on the ci-single pool and had
no concurrency block at all, so a tag push could land a tofu apply on the same
libvirt host a nightly cell was mid-provision on. The group goes on the verify
job rather than the workflow: the build jobs touch no hypervisor and should not
queue behind a nightly.

* fix: stop cell-27 handing a 50-minute suite a 50-minute job

The rds suite is budgeted 50m in suite-sets.sh and cell-27 took the matrix
default of 50 minutes for the whole job, so provisioning, install, the suite and
teardown had to fit inside the suite's allowance alone. It never could: the last
measured run spent 6 minutes reaching the suite and was killed 44 minutes into
it, mid-TestAutomatedBackups. A job timeout reports "The operation was canceled"
and nothing else, which is why this cell has no verdict in any nightly rather
than a failure anyone could read.

cell-27 gets 75 minutes, and the select job now refuses to start a run whose job
budget does not clear its suite budget plus a setup allowance. The inner timeout
firing first is the property that matters: go test says where it stopped and
what failed, and the job timeout says nothing at all. cell-29 already had the
same fix applied by hand; nothing stopped the next suite from repeating it.

* fix: converge host EIP plumbing against intent, not against the NAT row

The released-EIP sweep landed and did not fire. Its premise was that a stranded
dnat_and_snat row is what a stranded host route can be found by, and on the path
that actually strands one there is no row left. AddEIP scrubs the predecessor
row sharing the private IP before installing the new one, so when reconcile
re-added the instance's auto-assigned address it silently deleted the released
EIP's row — and applyEIPs runs ahead of the sweep, which then walked a table
with nothing stale in it while the /32 route and its proxy-ARP stayed up.

Give the sweep the host's own inventory. ListEIPIngress reads the EIPs back from
the FORWARD accepts EnsureEIPIngress stamps with its own comment, so a binding is
found by what installed it rather than by a row some other path is free to
delete. Anything plumbed that intent no longer asks for is torn down, which also
covers a crash between the OVN delete and the unbind. A failed inventory fails
the pass: an empty list and an unreadable one are indistinguishable, and reading
the second as the first is a sweep that reports success having done nothing.

Close the window inside the pass too. The predecessor scrub now reads the row it
is about to drop and releases that address's host state, so the strand does not
exist between applyEIPs and the sweep rather than merely being repaired a step
later.

* fix: retry the release verify's libvirt calls like every other job does

The verify job drives the same libvirt provider on the same hypervisors as the
e2e cells, where a dropped connection under concurrent load is the steady state
and is retried. Here it was a hard failure that reads as a broken release. It
was skipped earlier only because the job checks out mulga and not spinifex, so
the shared helpers were not on disk — a sparse checkout of .github/scripts costs
nothing and makes apply and destroy behave like their e2e counterparts.

* fix: do not report a rebooting DB instance available on the pre-reboot agent's health

RegisterDBInstance stamped a fresh LastSeen while leaving Agent.EngineHealth at
the value the previous agent process reported. After a reboot the new agent
registers before it has probed anything, so the record read as a healthy engine
with a beat newer than the transition, and the reconciler moved the instance to
available while PostgreSQL was still starting up. A client that connects the
moment the status turns green then gets "the database system is starting up".

A register now discards the previous process's verdict and records when this
agent process started. The restart reconciler requires that registration to be
newer than the transition, which also closes the window where a beat lands
between the engine being stopped and the VM going down.

* test: record the credential kubectl sent when an EKS call comes back Unauthorized

The IRSA pod subtest's first kubectl is the gateway-CA configmap, which lands
before the pod dumps are registered, so a 401 there left no evidence at all. A
verbose read-only probe reports whether an Authorization header was sent, which
separates a token the apiserver rejected from one the exec plugin never made.

* fix: report a starting engine promptly, and budget CI jobs from the suite

The agent waited a full heartbeat interval before its first beat, so a restart
could not be observed as finished for up to 30 seconds after the engine was
already serving. Beat at startup instead, and report every 5 seconds until the
engine has answered once — the control plane is actively waiting on that beat,
and the steady-state cadence it hands back is for a database already serving.

The rds suite is the one most sensitive to what else the hypervisor is doing:
its DB VMs serialise behind a 4 GiB semaphore, so its wall clock is set by how
long it waits for the budget. The same pass ran 45m beside four nightly cells
and overran 50m beside nine, panicking with two tests still running. Raise its
budget to 60m.

That budget was then larger than the jobs meant to contain it. A job cancelled
on timeout reports only "The operation was canceled" and takes the artifacts
with it, so the inner `go test` timeout must always fire first — and it could
not for rds on e2e.yml, nor for storagefault or diskperf, which have asked for
90m and 180m inside a 60m job since they were added. Both workflows now resolve
their job budget from suite-sets.sh rather than carrying a literal: e2e.yml
from the largest requested suite, e2e-suites.yml per matrix leg, so a leg is
bounded by its own suite and not by the longest one beside it. The default push
set resolves to exactly the timeouts both workflows had before.

* fix: check a permutation cell's job budget against the suites it runs

The guard only saw cells that name a suite, so the nine that run a topology's
nightly subset were never checked against it — the same class of mistake the
guard exists to catch, in the majority of the cells. Bound each by the longest
suite in its subset: max() and not sum(), because whichever suite hangs must
panic on its own timeout first, and a sum would demand a budget the set has
never come close to using. Every cell clears it today.

* fix: report why a PostgreSQL engine would not start, and stop failing a stop that landed

Three defects, all found by the first rds pass that ever ran deep enough to
reach them.

A wait that saw an instance reach `failed` called t.Fatalf from inside the
polling condition, and testify runs that condition on its own goroutine. The
Goexit killed the tick and left the collector empty, which reads as the
condition being satisfied — so the wait returned successfully on an instance it
had just declared broken, logged "reached status available" about one that was
failed, and let the test carry on against it. Six conditions across four suites
call Fatal or require the same way. EventuallyErr now runs its condition on the
test's goroutine, which makes all six behave as written.

A DB VM stop has two drivers: the API call and the reconciler resuming one whose
caller it assumes has died. When they overlap the loser is told the VM is not in
a state to be stopped — which is the state the stop was heading for — and the
customer's StopDBInstance returned InvalidDBInstanceState for a stop that had
just completed. Both paths now treat a refused command the way they already
treat an unanswered one: it says nothing about the VM, so the fleet view
settles it.

PostgreSQL had no equivalent of the engine log tail MariaDB has carried since it
was added, so a postmaster that refuses a setting produced "engine did not
respond on /run/postgresql:5432" and nothing else — on a guest with no SSH,
whose console records only that pg_ctl could not start the server. The probe now
quotes the tail of the log the image already directs the postmaster to, and the
reboot and modify paths carry the agent's reason into the failure the way the
create path always has. The reason reaches the customer through StatusInfos.

The rds suite's budget goes to 90m and cell-27's job to 100m. Its wall clock is
what the DB-VM queue costs rather than what the tests do — single tests logged
11m, 22m and 26m waiting for budget — and the pass above still had three of
seventeen tests running at 60m.

* fix: keep a DB VM recoverable when its agent cannot start, and flush the volume before a snapshot

An rds-agent that failed to construct exited, so a transient IMDS credential
resolution took the guest down for good: rds-datadir and rds-init cascade on the
missing handoff, the engine never starts, and the control plane sees only silence
until it declares the instance failed ten minutes later. Construction is retried
like every other boot step instead.

OpenRC skips start_post when the start itself fails, which is the one case with
no diagnostic at all — the console carried nothing but "died". The log mirror
moves out of the handoff wait so a failed start uses it too.

A snapshot quiesced the engine and nothing else. The engine repairs its own files
from the WAL, but postgresql.conf sits beside them and it does not: a restore
came up on a torn tail and the postmaster stopped on a syntax error at the
unterminated include hook. The quiesce now flushes the whole data mount, and the
hook is renamed into place rather than appended, so a boot that finds a half
written one replaces it instead of appending after its open quote.

* fix: flush the config cloud-init wrote before a reset can take it

A DB instance rebooted seconds after it went available came back with no
RDS_GATEWAY_URL, so rds-agent could not reach the control plane and the engine
never started. cloud-init writes agent.env once, per-instance, and never again;
a reboot is a QMP system_reset with no guest shutdown, so the file was still
dirty page cache when the machine reset under it and the next boot read nothing.
The handoff wait now syncs, which is the point at which the agent has proved it
read that file.

* fix: stop dropping an EIP teardown published while vpcd is restarting

DisassociateAddress publishes vpc.delete-nat fire-and-forget. On run
34046316024 vpcd disconnected from NATS at 03:51:26.73 and its replacement
subscribed at 03:51:27.09; the publish landed at 03:51:26.92, in the gap, and
went nowhere. ReleaseAddress then returned the address to the pool two minutes
later with its /32 host route still delivering to the guest that had held it.

vpc.delete-nat now uses request-reply like vpc.add-nat, and retries only on
no-responders — which returns immediately, so the cost is paid solely when
nothing is listening. A teardown that still fails is logged as an error naming
the address rather than lost silently. Neither is fatal to the caller: the
reconciler's orphan sweep remains the backstop.

vpc.add-nat on the EIP path stays fire-and-forget. The shared helper blocks it
on the flows barrier for up to 45s, and associating an address should not
inherit that.

Two reconcile passes in that run had the address absent from intent and did not
sweep its host route, and the journal cannot say why: the sweep logged only when
it acted, so a route it kept was indistinguishable from one it never saw. It now
logs the bindings it found, kept and wanted.

* ci: render one run-level table for the e2e nightly

Reading a nightly meant opening nineteen jobs, and the Actions UI does not
separate a cell that failed from one cancelled out from under it — a
distinction every investigation of this suite has needed, and one the run
34046316024 analysis had to reach for the jobs API to get.

A final job, needs: every cell and if: always(), renders cell, topology,
result, duration, runner and per-cell test counts into the step summary, with
run-level totals, the three slowest cells and the failing test names.

Result, duration and runner come from the jobs API so a cell that died before
writing junit still gets a row. Counts come from a new junit-only artifact per
cell rather than the full cell artifact, so the roll-up does not pull nineteen
journals it has no use for.

Verified against run 34046316024: 14 success, 2 cancelled, 1 failure, with the
two cancellations shown as cancelled rather than folded into failure.

* fix: assert EC2's actual EIP semantics, and probe EKS auth on an authenticated path

natuplink asserted that an instance's auto-assigned public IP is still plumbed
after an EIP associated to the same instance is torn down. EC2 does the
opposite: associating an EIP releases the auto-assigned address back into the
pool, and it cannot be reused. Our datapath already does that — run 34059879666
shows 192.168.0.72's ingress removed at 07:09:44.000, 36ms before .73's was
installed — so the test was asserting behaviour neither AWS nor we have.

It now asserts the release at the point it happens, and that no public delivery
remains after the teardown. This surfaced only once the teardown itself started
working: the run's other assertions passed, including .73's removal, which was
the failure before.

Our records still disagree with our datapath — the ENI keeps the released
address, which is why termination tears the old one down twice and never the
current one. That is the remaining half of mulga-7zc9w and is not fixed here;
it needs an IPAM release on the associate path.

Separately, AuthDiagnostics read /version, which is anonymously readable, so its
200 was consistent with both branches it claimed to distinguish. It now runs
auth whoami, reads kube-system, and invokes the credential plugin directly,
reporting exit code, stderr and whether a token was produced — never the token,
since CI logs outlive it.

* test: capture per-guest state when a spread guest never answers SSH

TestMultinodeSpread's only failure dump is dumpPlacementNATDiag, which collects
OVN, OVS, ARP and route state aimed at the NAT and EIP half of the test. The
failure it actually hits is a guest that never accepted SSH — run 34040324968,
the guest on node3 at 10.100.2.5 — and for that it recorded no console, no port
binding and no tap state. There was no way to tell from the artifact whether the
guest booted at all.

Now dumps, per guest: the console, the NB logical switch port, the SB port
binding, the OVS interface backing its tap, and br-int's port list. Probed on
every node rather than the expected one, because a port bound on the wrong
chassis is exactly what a hosting-node-only dump cannot show.

Covers all three guests, not just the failing one, so a working guest is
available as the control.

* ci: sample hypervisor load either side of every cell

Eight runner names map onto three hypervisors, so cells that never share a
runner still share a host: when cell 10's SSH wait expired on run 34040324968,
cells 22, 17, 18 and 28 were concurrently live, two of them multi-node. Nothing
in the artifacts measured host load, which left co-tenancy as an explanation
that could be neither confirmed nor ruled out.

Records loadavg, MemAvailable, SwapFree, the cpu line carrying steal, and the
QEMU process count before and after each cell, into the cell artifact.

* ci: note that main's missing concurrency block is why the nightly runs ungrouped

Three refs carry three different groupings: main has no concurrency block at
all, dev uses e2e-nightly, this branch uses e2e-hypervisors. Scheduled runs load
the workflow from the default branch, so every cron nightly runs ungrouped and
cannot queue against anything — which is how a dispatch of 34050630627 ran
alongside the scheduled 34046316024 and cost it cells 24 and 27.

e2e-hypervisors is the right group and deliberately broader than dev's, since
e2e.yml, e2e-suites.yml and e2e-ddil.yml draw on the same runner pools. Nothing
on a feature branch can fix this; the block has to reach main.
2026-09-06 16:53:32 -05:00
Ben Duncan c2aad92044 fix: stop the detach seal, EIP teardown and key-pair races failing the nightly (#1003)
* fix: stop the detach seal, EIP teardown and key-pair races failing the nightly

Four independent faults the full-suite nightly surfaced, each of which had
been masked while the heavy cells were skipped.

unmountVolume SIGKILLed nbdkit the moment the request arrived. Removing the
guest device has already started the plugin's own Close, which drains to the
backend and leaves a seal receipt, so the kill landed 61ms into that flush and
threw it away — the receipt path never fired once across a whole run. What was
left was 74 orphaned WAL files for the fallback seal to replay, which ran past
the caller's 180s deadline and returned DetachVolume ServerInternal with the
volume still attached. It now terminates gracefully first, bounded by the
plugin's own drain budget, and only escalates if that does not take.

DeleteEIP returned early when the dnat_and_snat row was already absent, before
the neighbour flush and the host unbind. An absent row is not a reassignment —
nobody owns the address — so the /32 route and proxy-ARP entry were orphaned
and a disassociate left the address still answering from the host. The
reassignment cases keep their early return.

EnsureKeyPair was not atomic: a failure after CreateKeyPair left the pair on
the cluster with no cleanup registered, so every later caller failed with
InvalidKeyPair.Duplicate in a test that never ran. The describe that follows a
create also had no tolerance for the propagation window on a multi-node
cluster; it now retries, and still reports a duplicate the moment it sees one.

An admission refusal could not name its cause. The static vCPU/memory budget
and the live MemAvailable clamp both surface as InsufficientInstanceCapacity,
and they have different fixes; the subscription transition now records which
gate closed.

* fix: identify the default-deny by priority, not by being a drop

DenyIsNotEthertypeScoped bucketed every drop ACL by direction, so the last one
seen won each direction. The platform WAN egress block added at priority 1049
is also a drop and is legitimately ip4-scoped, so it displaced the default-deny
in the map and the assertion fired on its qualifier instead:

  from-lport deny on sg_c94870c0d68288dc6 is IPv4-scoped:
  "inport == @sg_c94870c0d68288dc6 && ip4 && ip4.dst != {10.0.0.0/8, ...}"

Select the two default-denies by their own priorities. What the test asserts is
unchanged — the default-deny still must carry no ethertype qualifier, for the
reason denyIngressACL states: OVN allows on no-match, so an ip4-scoped deny
lets every other ethertype through.

* fix: drain every EIP on an ENI at teardown, not just the first

findByENI returned the first matching record and stopped, so an ENI holding
more than one EIP had all but one left marked associated to an interface being
deleted — with the host route still installed and no record left naming the ENI
to find them. Teardown now clears them all.

* ci: serialise e2e-nightly runs so two do not share the hypervisors

Only iso-single and iso-multi were guarded, by the iso-qemu-rack group. Every
other job could overlap a second run of the workflow, with both sets of cells
building clusters on the same five hosts. Queue rather than cancel: a dispatch
is usually a verification run whose result someone is waiting on.
2026-09-05 21:41:02 -05:00
Ben Duncan ac5daa56ab fix: make cell-24 able to pass — nvme work root and a realistic timeout (#1002)
* fix: point cell-24 stress work root at the runner nvme

The fault-injection harness ignores TMPDIR for large-object and
multipart-upload, defaulting them to $HOME/.cache/predastore-e2e because
TMPDIR is commonly tmpfs. On this runner $HOME is a 110G root disk with
14G free while TMPDIR is a 938G nvme, so the preflight guard passed on a
filesystem the scenarios that need disk never touch.

Set STRESS_WORK_ROOT alongside TMPDIR and check free space on both at a
threshold that covers the 1GiBx8 multipart case, so a run that would skip
a size for want of disk fails preflight instead of reporting a pass with
less coverage.

* fix: give cell-24 room for the full thirteen-scenario stress suite

The scenarios run serially and are ~35 minutes of work on their own,
measured from the per-scenario predastore matrix. The job allowed 30, so
it could not have passed whatever the disk did.
2026-09-05 21:38:19 -05:00
Ben Duncan 2727f4f121 ci: build the nightly matrix from the selected cells, not filter inside them (#1004)
The matrix job gated all 32 of its steps on a step output. A job whose every
step is skipped still completes green, so a dispatch that ran one cell reported
as a nineteen-job pass and was indistinguishable in the UI from a full one. The
only tell was duration: a deselected cell finished in 11-12 seconds.

Every green run in the recent history is such a dispatch, and every run that
actually executed the suite has failed. There was no known-good baseline; there
was a display bug.

A job-level if cannot fix it — matrix is not available in that context, since
it is evaluated before the matrix expands. So the selection moves ahead of the
matrix: a select job filters the cell table and the matrix is built from what
comes back. A cell that was not selected produces no job at all, which is
plainer than skipped and cannot be read as a pass.

The table moves to .github/e2e-cells.json so both the workflow and the filter
read one copy.
2026-09-05 17:18:15 -05:00
Ben Duncan 7ddcac53bb feat: block outbound SMTP egress via platform OVN ACL (#1001)
* feat: block outbound SMTP egress via platform OVN ACL

Add a platform egress ACL that drops outbound TCP to 25/465/587 for public
destinations on every guest, matching AWS's default outbound-SMTP block. The
control is global by default with an operator-held per-VPC exemption; tenants
cannot open the ports via a security group because the block sits above the
tenant-allow priority band. Private destinations are always exempt.

Config exposes blocked_ports_wan (nil defaults to [25,465,587], [] disables)
and egress_block_exempt_vpcs. Adds unit tests for the ACL builder, the SG
manager wiring and the config resolution, plus an e2e that digs gmail.com MX
and confirms a 5s SMTP connect is blocked while other WAN egress works.

* docs: note the multi-node sync + restart requirement for the egress block
2026-09-04 16:45:26 -05:00
Ben Duncan 80f522e3fc feat: map virtio-blk virtqueues across an IOThread pool (#963)
* feat: map virtio-blk virtqueues across an IOThread pool

Boot volumes ran every virtqueue through a single IOThread. Size a pool
from the vCPU count and hand QEMU an iothread-vq-mapping so the queues
spread across it. Falls back to the scalar iothread= form for MMIO
machines, single-queue devices and a pool of one.

SPINIFEX_BLK_IOTHREADS is experiment scaffolding for sweeping the pool
size without a rebuild, and comes out before this merges.

* feat: name QEMU guests with debug-threads=on

Config.Name held the instance ID but was never emitted, so QEMU left
every thread called qemu-system-x86 and a host-side pidstat -t could
not tell an IOThread from a vCPU thread. Emit -name guest=<id> with
debug-threads=on, which is what libvirt does for the same reason.

* refactor: drop the IOThread pool-size experiment override

SPINIFEX_BLK_IOTHREADS existed to sweep the pool size on dev-prod
without a rebuild and redeploy per value. The sweep is done, so the
derived size is the only path and buildDrives keeps its own doc comment.
2026-09-04 14:08:18 -05:00
Ben Duncan d65c4f3673 fix: family-qualify unscoped firewall accepts to IPv4 (#999)
The public, formation and DNS accept rules carried no address family, so
on a node that autoconfigures a global IPv6 from an upstream Router
Advertisement they served the console, predastore S3 gate, AWS gateway
and DNS on that public address with none of the source scoping applied on
IPv4. Qualify them with meta nfproto ipv4 so v6 falls to the default
drop; the scoped rules are already v4-only via ip saddr.
2026-09-04 13:21:36 -05:00
Josh Siviour f719ce760c feat: report impaired status for instances on a node that stopped answering (#996)
* refactor: extract DescribeInstanceStatus selection and projection

The request semantics — the instance-ID filter, filter parsing, the visibility
check, state membership defaulting to running-only, and the status filters that
can only run against a built frame — live inside the node handler's vmMgr.View
closure, so nothing else can apply them. A second caller answering the same
request from cached records would have to restate them, and the copy that
drifts is the one no customer request exercises until it does.

StatusSelection parses the request once; StatusFor applies it to one VM and
projects, returning nil when the request does not cover it. Per-VM rather than
per-slice so the node keeps reading VM fields inside its lock exactly as before.

buildInstanceStatus takes az rather than reading s.config.AZ, because a caller
projecting another node's record must use the AZ on that record. The node
passes its own config value, so behaviour is unchanged. systemImpaired stays a
parameter: it is node-local memory pressure only an answering node can report.

No behaviour change; the twenty existing status tests pass untouched.

* feat: read node liveness from the heartbeat store

A gateway answering from cached records has no way to tell a node that is
quietly healthy from one that has died, because an idle node writes no records
and record age measures activity rather than liveness. Liveness reads the
heartbeats the daemon already publishes and judges a node stale after three and
a half missed beats.

Unknown is a distinct answer from Stale and the two must not collapse. A node
nobody has ever heard from may simply never have published, and a store the
gateway cannot reach is not evidence that anything died — both report Unknown,
and a failed read clears the previous answers rather than keeping them.

The threshold is deliberately tighter than the IPsec barrier's six beats. That
one gates a cluster encryption decision where calling a live node dead churns a
security barrier; this one decides whether to admit the control plane cannot
confirm a host, where the costly error is the opposite one. The IPsec window is
left alone. A test in the daemon asserts the publish interval and the gateway's
copy of it have not drifted, since the gateway cannot import that package.

* feat: report impaired status for instances on a node that stopped answering

DescribeInstanceStatus fans out over NATS and returns whatever the nodes send
back. A node that is down sends nothing, so its instances disappear from the
answer entirely and a dead host reads as no host.

Backfill from the instance record cache: for any cached instance no node
answered for, synthesise a status frame and mark the system status from the
heartbeat store. A stale node reports impaired; an unreadable heartbeat store
reports insufficient-data, because not knowing a host is healthy is not knowing
it is not.

A frame an answering node returned is never touched. The covered set is what
guarantees that, and it is asserted directly rather than only through the
handler, where dedupStatuses being first-writer-wins makes either guard alone
sufficient. A live node's omission is its own answer and is not backfilled, and
a cache that has not finished its first whole-set sync contributes nothing.

The zero-value StatusSynthesis disables the whole path, so a gateway wired
without a cache answers exactly as it did before.

* fix: use fan-out responder identity, not heartbeat age, to gate status synthesis

DescribeInstanceStatus previously skipped synthesising an instance whenever
its owning node's heartbeat was still live, even when that node did not
answer this particular fan-out. A node that goes down loses its instances
from the response entirely until its heartbeat crosses the stale threshold.

Switch the Gather call to ExpectedResponders so it tracks per-node identity,
and use SuccessResponders to decide whether an omission was deliberate.
Heartbeat age now only selects the label (impaired vs insufficient-data),
never whether to synthesise. Falls back to the old heartbeat-only rule when
any frame cannot be attributed to a node.

* test: tag existing status-synthesis tests with node identity

answerWith never set X-Node-ID, so every existing test in
status_synthesis_test.go silently ran down the Unidentified fallback
branch instead of the responder-set path real daemons take. Give
answerWith a node-ID parameter (reusing subscribeAsNode) and retag each
call to match the record it is about, so these tests exercise the
success-responder guard they are named for. Keep exactly one test,
via a new answerAnonymously helper, on the fallback branch deliberately.

* test: tag DescribeInstanceStatus_test.go replies with node identity

Same rot as status_synthesis_test.go: raw nc.Subscribe replies carry no
X-Node-ID, so every describe here also sat on the 3s Gather deadline.
These tests mostly pass StatusSynthesis{}, so synthesize bails at
Records == nil before the fallback branch is ever reached — the payoff
is dropping the per-test timeout, not fixing a correctness gap.
2026-09-04 13:25:44 +10:00
Josh Siviour 31eb3bc672 fix: fail a credential refresh instead of extending a dead key's expiry (#995)
ec2rolecreds implements aws.HandleFailRefreshCredentialsCacheStrategy, which
aws.CredentialsCache prefers over returning the refresh error. When the cached
credential is expired or within five minutes of expiring and the refresh fails,
it hands back the same access key, secret and session token with Expires pushed
a random five to fifteen minutes out, reported as a success. Every later
refresh fails the same way, so ecs-agent signs with a credential the control
plane has already rejected, indefinitely, and nothing surfaces it.

The cache picks its strategies by asserting on the provider's dynamic type, so
returning aws.CredentialsProvider from NewProvider does not disarm it. Wrap the
provider in a type whose method set omits HandleFailToRefresh, and the cache
falls through to returning the error. AdjustExpiresBy is forwarded on purpose:
it only fires under an ExpiryWindow, which no caller sets today, but dropping
it silently would change behaviour for whoever sets one.

Fixed in imdscreds rather than at the one exposed call site, so a future caller
that wraps this provider in a cache is safe by construction.
ecr-credential-provider was never affected: it calls Retrieve uncached.

Two comments claimed the hazard was ec2rolecreds capping Expires to an hour.
That cap only ever shortens a too-far-future expiry and is unrelated.
2026-09-04 12:00:31 +10:00
Josh Siviour 8c8cd11936 feat: record a launch before replying to the caller (#994)
* feat: time the local and KV halves of a state write separately

RunInstances responds before vmMgr.Insert and WriteState, so the cost of
gating the respond on the local write is measurable today: it is the
ec2.RecordRunInstances span. Splitting persistState's timing by store says
which half that cost is, and the summed instance count says whether the
local write scales with the node's set size.

Duration is a sum plus an op count rather than a histogram, because ES|QL
cannot aggregate a native histogram field.

* fix: count only the records a KV state write actually touched

WriteRunningSet is digest-guarded and writes just the changed records, so
attributing the node's whole VM count to it overstated the per-record cost.
Use Written+Retired, and rename the metric to records.sum with a description
that spells out the two meanings: the whole set locally, the changed ones in
KV.

* feat: record a launch before replying to the caller

RunInstances replied with the reservation and only then inserted the records
and wrote state, so a caller holding an instance ID could describe it and get
nothing back, and a guest could read IMDS before this node's state file knew
the instance existed. Move the reply after the insert, the state write and the
per-instance subscribe loop, so by the time an ID reaches the customer the node
can answer for it and a terminate issued immediately finds a responder.

A local write that fails now fails the request: the file is what this node
restores from, so a launch it did not record is a launch nothing can recover.
The handler undoes the insert with DeleteIf and returns the capacity, rather
than running instances the caller was told had failed.

Measured on env19 first: the newly-serialised work is 7-23ms against a 2.3-10.8s
end-to-end launch, under 1%, so no split of the state write was needed.
2026-09-04 11:25:46 +10:00
Ben Duncan 828b5df4aa fix: pause a guest on a backend I/O error instead of handing it EIO (#977)
* fix: fail DRAIN when a volume seal failed

shutdownAndUnmount logged the volume unmount error and returned nothing, so
StopAll returned nil and the DRAIN phase ACKed success with vms_stopped set.
The coordinator then advanced to STORAGE and stopped viperblock while the seal
was still in flight, tearing the VBState envelope and leaving the guest unable
to mount its root volume on the next boot. The deploy reported success.

shutdownAndUnmount now returns ErrVolumeSealFailed when the unmount did not
seal; QMP, PID-file, fw_cfg and telemetry cleanup stay best-effort. stopCleanup
and stopOne carry it without aborting the rest of the teardown, since QEMU is
already down by then, and StopAll aggregates the per-VM failures so
handleShutdownDrain ACKs with Error set and never reports vms_stopped.

volumeMounterAdapter.Unmount was the second swallow site: it logged each failed
seal and returned nil unconditionally. It now attempts every volume and returns
the aggregate, which is what the VolumeMounter contract already described.

Terminate still tolerates a failed seal — the volumes are deleted next, so an
unsealed block map loses nothing a caller can ask for.

This does not fix the seal itself. It converts silent data loss into a refused
upgrade that an operator can act on.

* test: add the storagefault e2e suite for backend-outage guest survival

Freezes predastore with SIGSTOP to hold connections open while answering
nothing, which is the fault a timeout-free path cannot distinguish from a
slow peer. On one node that is a total outage; on three it freezes every
node but the guest's own, breaking the RS(2,1) shard floor.

The suite writes and flushes a verifiable region before the fault and
churns a separate unverified region during it, so an interrupted write is
never mistaken for lost data. The root-filesystem load runs from the
user's home rather than /tmp, which is tmpfs on this image and would move
no bytes on the root device.

* test: verify the root device is under write pressure, and size the outage

The previous guard matched /^ *vda / against /proc/diskstats, where the
device name is the third field, and read $7 which is ms_reading rather
than sectors_written. It printed empty and asserted nothing, so a green
run could not distinguish a surviving guest from an idle disk. It now
samples sectors_written twice and fails unless it advances.

The outage is sized from viperblock's absorption rather than guessed:
a workload must fill DefaultMaxPendingBytes before backpressure engages
at all, so a short freeze measures the retry budget rather than the fault.

* test: add nbdkit fault injection to the storage-fault e2e suite

The predastore freeze takes the whole storage cluster away from every
guest on it, which is too broad to run beside anything else. Removing a
single volume's NBD server is the same fault at one-volume scope: one
host, one guest, nothing cluster-wide stopped.

Both signals are here because they sit on opposite sides of the defect.
SIGSTOP holds the socket open and answers nothing, so the guest blocks
and no error reaches ext4. SIGKILL closes it, and QEMU's nbd driver at
the default reconnect-delay of 0 fails requests at once, which under
werror=report aborts the journal.

Signalling is guarded: a pid is matched on comm, our plugin and the
volume argument together, and re-confirmed immediately before the
signal, because these hosts run nbdkit for other volumes and pids are
reused.

* fix: pause a guest on a backend I/O error instead of handing it EIO

Every virtio-blk device was created with werror=report,rerror=report, so a
backend outage was delivered to the guest as EIO. ext4 aborts its journal on
that and remounts read-only, and the write that failed is simply gone. Under
werror=stop QEMU pauses the VM and holds the failed request, so a backend that
comes back resumes the guest with nothing lost.

The boot drive, the cold-boot data volume and the hot-plugged data volume all
move to stop. Alongside it the NBD transport gets reconnect-delay=30, which
pauses requests rather than failing them while a storage process restarts on
the same socket; on the legacy -drive form it has to be file.reconnect-delay,
because the bare name reaches the raw format driver, which has no such option.

The QMP heartbeat now recognises run state io-error and issues cont. After ten
attempts the instance is reported impaired and the cadence drops to one attempt
per ten heartbeats, but it never stops: QEMU holds the request for as long as
the guest exists, so a backend that returns an hour later can still be resumed.

The storagefault suite gains the fault this was written for — kill the volume's
own nbdkit, hold, then put it back on the same socket — and it now verifies a
sha256 region across the outage rather than trusting the guest to be reachable.
Three harness defects went with it: the socket-liveness poll ran unprivileged
against a 0770 directory and could never succeed, the relaunched socket was
left 0755 where QEMU needs group write, and the two variants shared one guest
so a fault in the first was diagnosed in the second.

* test: cover the io-error resume policy

* test: stop two parallel tests racing on the terminate backoff variable

* fix: report a guest paused on a backend I/O error as impaired

A guest held by werror=stop kept reporting ok on both status checks. QMP stays
responsive while the guest is paused -- cont is being sent and answered -- so
the QMP failure gate that DescribeInstanceStatus keys on never trips, and the
one state a customer most needs to see looked healthy.

Instance status now reports impaired/failed while the resume budget is
non-zero, timestamped from IOErrorSince so impairedSince is when the guest
paused rather than when it was escalated. System status reports impaired too:
a backend I/O error is an infrastructure fault, not a guest one, and that
distinction is what the two checks exist to draw. The counter is cleared by
the first poll that sees the guest running, so both clear on recovery.

* test: reap the relaunched nbdkit when the fault test ends

relaunchNbdkit detaches the server with setsid so it outlives the SSH session
that started it, and viperblockd never learned it exists. Nothing reaped it, so
every run left an nbdkit holding a socket and a cache for a volume that was
later detached or deleted -- four were found running on dev-prod against two
detached and two deleted volumes.

The PID is resolved from the socket path rather than the volume id, so a
legitimate viperblockd server for the same volume is never the one killed.

* test: prove the backend is serving before a fault is called cleared

Thawing the process a fault stopped is not the same as restoring the service.
The outage test treated SIGCONT as the end of the fault and went straight to
judging the guest, so it passed while the cluster it ran on stayed unable to
serve a single object for the next 35 minutes, and the next test would have
inherited that.

thawPredastore now puts and gets an object through every gate, with a budget,
and fails the test if the backend does not come back.

* fix: do not hand back a mount entry whose export is gone

A volume whose unmount failed kept its entry in MountedVolumes while its nbdkit
was gone. Every later ebs.mount then matched that entry and returned its socket
path, so vmMgr waited for a socket nothing would ever create and the instance
could not be started again — on dev-prod a guest stopped against a frozen
backend was left permanently stopped, four start attempts failing identically
on a socket the node had already removed.

Drop the entry when there is positive evidence the export is dead: the socket
is absent, or the recorded pid is not running. Neither can be true of a live
export, so the double-writer hazard the guard exists to prevent is unaffected.

Also make the durability test's pattern helpers report failures. The read dd sat
in a pipeline, so its exit status was discarded and the harness hashed nothing
and returned the empty-string sha256 as a checksum.

* test: report the guest console when a restart never answers SSH

A restart that reaches running but never comes back is indistinguishable from
a dead root filesystem without the serial console, and the log is gone once
the fixture terminates the instance.

* fix: converge guest port datapaths concurrently, not one deadline after another

Each guest port that misses its SB binding burns the full convergence deadline,
and applyEIPs spent them in series. Three ports belonging to long-stopped
instances therefore held a newly launched guest's EIP dark for the sum of them
before its own probe started.

AddEIP stays serial since it mutates NB. Only the waits fan out, bounded, and
sb-cluster-state-reset is now serialised with a minimum interval so concurrent
probes cannot each fire a cluster-wide resync.

* fix: refuse a stale cross-node start after the owning node is gone

The volume lease already refused a cross-node mount, but it is a liveness
claim: 45s TTL, renewed every 15s. A node that dies holding un-uploaded
writes stops renewing, and 45 seconds later nothing refuses a start elsewhere
that loads a stale backend checkpoint.

A failed seal now writes a durable marker naming the node holding the only
current copy, cleared on a seal that reaches the backend and on delete. The
lease covers a live holder, the marker a dead one.

The refusal also reaches the caller instead of being flattened to
ServerInternal, which told an operator to retry something that cannot succeed
while the holder is down.

* fix: keep the daemon's message when a start is refused

Rebuilding the error from the code alone rendered the generic ErrorLookup
text, so a refusal naming the node holding the volume reached the caller as
boilerplate about attaching volumes.

* fix(ec2): terminate detaches every DeleteOnTermination=false volume

TerminateStoppedInstance skipped the detach for a non-Boot DoT=false volume
on the assumption that Stop's Unmount had already cleared its attachment.
Unmount only clears it when the seal succeeded, so a volume whose seal failed
stayed in-use pointing at an instance that terminate then deleted, leaving it
unreachable with no owner to release it.

The running-instance path already detaches every DoT=false volume; the stopped
path now matches it, and tolerates an already-gone volume the same way.

DeleteVolume also keeps its provider error on the way out. The terminate reaper
retries the delete every two minutes and logged a bare ServerInternal, which
made a blob node being down indistinguishable from a transient fault.

* fix(ec2): DetachVolume --force releases a volume whose instance is gone

The ordinary detach routes to ec2.cmd.{instanceID} for the guest unplug, so a
volume left attached by a part-way teardown could never be detached: the
instance no longer runs anywhere and nothing answers. That also made it
undeletable, since DeleteVolume refuses an attached volume.

ForceDetachVolume already existed for this deadlock but was reachable only from
account teardown. Force now falls back to it when the instance has no responder,
which is the one case where clearing the attachment cannot strand a live guest.

* fix(viperblock): unsealed writes steer placement instead of pinning a volume

The dirty marker refused any mount from a node other than the one holding
unsealed writes. Instance start already forwards to the node that last ran the
instance and falls back after a 30s window, so that refusal turned a working
fallback into a volume that could run nowhere once its owner was gone. It
traded a rare data-loss risk for a permanent availability failure.

The marker is now a record and a warning, not a gate. A takeover logs the
previous holder and why it could not seal, then adopts the marker so the old
node is no longer treated as the better source. Live exclusion is unchanged and
still enforced by the lease.

It is also written on open rather than on seal failure. A node killed mid-write
never reaches its seal, so the old trigger left no trace in exactly the case the
marker exists to describe.

Adds 'spx admin volumes unsealed' to see which volumes would cost something if
started elsewhere.

* fix(viperblock): fence a volume whose lease moved instead of logging it

The lease was checked once, at mount, and never again. A node that lost it
kept its export up and kept writing into a volume another node owned, so
both copies advanced and the backend settled it by arrival order.

Losing a renewal now decides: another holder means tear the export down,
no holder means the entry merely aged out, so reclaim it and leave the
guest running. Fencing never seals — this node's copy is the stale one —
and leaves the dirty marker for the winner to take over.

This is not an epoch fence. It narrows the window from the 45s TTL to one
renewal interval at no write-path cost; the backend still cannot refuse a
stale writer.

* fix(network): advertise EIPs so a recycled address is reachable

The gateway switch port only asked ovn-northd to publish the router's NAT
addresses in centralised and routed modes, so on a distributed cluster nothing
ever announced an elastic IP. OVN still answers ARP for it, which is why a
first-time allocation works and hid this — but no gratuitous ARP is sent when
the address moves to a new ENI, so every host on the segment keeps resolving it
to the previous holder's MAC until its cache ages out. Reusing an address from
the pool then lands on a dead MAC and the instance is unreachable from outside.

Distributed mode needs the option as much as the others: northd scopes each
entry with is_chassis_resident(<vif port>), so the announcement leaves from the
chassis running the instance rather than the gateway, which is what distributed
NAT wants.

Converge on the already-attached path too. AttachIGW returns early once the
gateway port exists and nothing else rewrites it, so without that an existing
VPC would stay silent for its whole life.

Confirmed on dev-prod: setting the option moved three EIPs from unadvertised to
carrying their correct external_mac, each scoped to its own ENI's port.

* fix(viperblock): make the volume fence and its dirty marker ownership-safe

Four corrections to the lease-loss fence, all found in review.

Drop the reacquire path. Losing a lease and finding no successor was treated as
a lapsed entry worth reclaiming, but leases.acquire returns the cached entry —
already flagged lost, with a dead renewal goroutine — so the "reclaimed" lease
renewed nothing and the node went on writing a volume it could not prove it
owned. The two errors are not symmetric: a false fence costs one guest a
restart, a false reclaim leaves two nodes writing one volume. Fence in both
cases, and report which happened through the outcome attribute.

Surrender a lease this node cannot confirm. Renewal only noticed a loss when
the server rejected an update, so a node partitioned from NATS kept its export
up while the TTL expired and another node took the volume. Track the last
confirmed renewal and give the volume up once that ages past the point where
the server may admit another owner. Bound the renewal call too, so a hung
JetStream request cannot hold the goroutine past that point.

Do not act on a kill that did not take. Both the fence and ebs.unmount removed
the mount entry, released the lease and announced the result before knowing
nbdkit had exited — the fence then reported a writer stopped that was still
running, and the unmount sealed into a directory a live process was writing.
Wait for the exit, and on failure leave the volume mounted and say so. An
already-exited process now counts as success, which is what the caller asked.

Give the dirty marker a generation. It recorded an owner and nothing to order
two nodes' claims, so a node returning after a takeover could clear or overwrite
the winner's marker and point placement back at the stale copy. Stamp the lease
generation, refuse a write from an older one, and only clear a marker this node
still owns. A marker that cannot be written now fails the open rather than
leaving writes nothing records.

* test(viperblock): cover the stall surrender and the unmount kill guard

The two ownership cases the fence work left untested.

A holder partitioned from NATS while the winner keeps it never gets an update
rejected, so revision checking cannot see it — it has to give the volume up on
its own clock. Pins that validity sits below the server TTL with room for a
check in between, that a lapsed holder reports the loss as a stall rather than
as a takeover, and that releasing a surrendered lease leaves the entry for
whoever holds it now.

The unmount side of the kill guard: sealing under a live nbdkit writes a state
torn between two writers, so a kill that did not take has to fail the unmount
and leave the entry mounted for a retry.

* fix(e2e): keep the guest private key for as long as the key pair

EnsureKeyPair computed the PEM path from the caller's directory but wrote
the file inside the ensureOnce closure, which runs only on the first call.
Callers passed a per-test artifact dir, and ArtifactDir removes that dir
when the test passes — so the first test's success deleted the only copy
of the private key while the key pair stayed registered, and every later
call returned a path nothing had ever been written to.

Guests after the first were then launched against a public key whose
private half no longer existed, so the harness could never authenticate
and gave up after the full five-minute budget. The guests were healthy
and reachable on tcp/22 throughout, which is why this read as a storage
or network fault for so long.

The directory parameter was the defect, so it is gone: the PEM now lives
in a directory the fixture owns and removes with the key pair, and no
caller can supply one that is shorter-lived.

Also reorder the killed-nbdkit teardown. The reaper registered its own
cleanup after the detach registered one, so LIFO killed the block server
before the platform detached the disk and DetachVolume then failed with
"Node ... is in use". The detach cleanup now reaps once it is done.

* fix: stop leaking an nbdkit export when recovery cannot adopt it

Restart recovery adopts a guest's boot volume, then routinely fails the
-efi volume with "claim lease: context deadline exceeded" -- JetStream is
still catching up at daemon start and the KV claim runs on the client's
5s default. Recovery gave up on the first error and never retried, so the
export stayed live with no registry entry. At terminate the unmount found
nothing to seal, returned NotFound and killed nothing, and the process
outlived its guest holding the volume and socket open. EFI is always the
second volume adopted, which is why it was always the one that leaked.

Two changes, because either alone leaves a hole:

- rebuildMountedVolume retries the build (4 attempts, 3s apart) so a
  JetStream that is merely slow no longer orphans an export for the life
  of the process.
- unmountVolume reaps a corroborated export for the volume it was asked
  to unmount when the registry has no entry for it, so an export orphaned
  by any means is still cleaned up. The reap goes through
  reapOrphanedNbdkit, which declines anything a live process references:
  at unmount the guest's blockdev may not be torn down yet, and killing
  the server underneath it strands QEMU.

* test: hold the backend-serving probe and assert predastore readiness

The backend-outage test declared the backend restored on one successful
round trip through each gate. A gate can answer the request that arrives
just after a thaw and then lose its blob peers under the load that
follows, which is a backend that never recovered reported as one that did
-- and that is what happened: the probe reported all three gates serving
minutes before the guest was still taking BLOCK_IO_ERROR on every resume.

The round trip has to hold across three rounds now, and the count restarts
rather than decays when one fails, so a gate that serves and then stops is
the failure the hold is looking for rather than noise it averages out.

It also checks /readyz on each host and reports any failed check by name.
The round trip only proves this workstation can write through a gate; it
says nothing about which blob peers that gate can reach, and a gate one
node short of the write floor serves a small object and fails a guest. A
host that serves no admin listener is logged and skipped, since the port
is optional configuration.

* fix: scope predastore's admin port to the cluster plane in the host policy

predastore's admin listener binds the host's cluster bind_addr and serves
/healthz and /readyz unauthenticated. Readiness now names which blob peers
a gate cannot reach, which is exactly the kind of topology detail 4432 is
peer-scoped for, so 8660 joins it in the peer-scoped rule rather than the
public one.

* fix: keep resuming a paused guest at heartbeat rate after a long outage

Past ten attempts the cont dropped to one per ten heartbeats, so a guest
whose backend had come back could sit paused for another five minutes
before anything retried it. Measured on dev-prod: the backend was proved
serving through all three gates while the guest was still waiting on
attempt 60, five minutes after attempt 50.

The slowdown was there to avoid paying a failed I/O every thirty seconds
on a backend still down. That is nearly free, and five minutes of downtime
after recovery is not, so only the log line is throttled now.

* test: fail the run when a key pair name resolves to more than one pair

A create that lands twice leaves two key pairs with one name and different
material. RunInstances resolves the name to one of them while the harness
holds the private key of the other, and the only symptom is every guest in
the run refusing the key at the SSH gate — five minutes of waiting per
guest, four guests, and nothing anywhere naming the cause. Observed on
dev-prod: two pairs created a millisecond apart, both named
e2e-key-30de10ba.

One DescribeKeyPairs after the create says so outright instead.

* test: tolerate an already-released volume in the detach teardown

Teardown runs DetachVolumeWait after a terminate that releases the volume
itself, so the detach can arrive at a volume already on its way to
available. That is the outcome it wanted, and treating the IncorrectState
refusal as fatal failed a backend-outage run whose assertions had all
passed — the guest came back, every acknowledged write verified, and the
test still went red on the way out.

VolumeInUse is still fatal: that one means the volume is attached
somewhere it should not be.

* test: carry the merged suites onto the reworked key-pair and account-scoped seams

The diskperf suite arrived on dev calling EnsureKeyPair with the artifact dir
this branch removed, and DeleteVolume's metadata lookup is account-scoped now,
so the provider-failure test has to seed and delete under the same account.

* refactor: make the fence outcome a type in one const block

Four separate exported string constants with a paragraph of comment each,
passed to RecordVolumeFence as a bare string. Group them, give them a
FenceOutcome type, and take that type at both call sites so an arbitrary
value can no longer reach the metric attribute.

* refactor: move viperblockd's wire contract into a leaf vbwire package

The daemon imported viperblockd for exactly two symbols -- the fenced
volume subject and its event -- which put the storage service back on the
daemon's dependency graph to name something it only listens for.

vbwire holds the contract instead: the subject and event, the dirty
bucket name and record shape, and the read-only listing an operator tool
needs. It imports nothing from spinifex, so both the daemon and the admin
CLI drop their edge to viperblockd entirely. cmd/service.go keeps its
one, which is genuine: it constructs the service.

* docs: drop a specific host name from the start-refusal comment

Named a real dev-prod host in a comment and in test fixtures. Say what
the name is instead.

* ci: give storagefault a dedicated nightly cell

The suite is eligible for both topologies and runs on the PR gate, but no
nightly cell ran it: the permutation cells run a deliberately narrow
subset and none of the dedicated suite cells named it.

Cell 29, single-node, following eks/ecs/rds. It cannot join a permutation
cell -- it SIGSTOPs a cluster-wide service, so every suite sharing that
environment would report the outage as its own failure.

* ci: run storagefault nightly on three nodes, not one

The previous commit put its dedicated cell on a single-node runner. That was
the wrong topology and the wrong justification: single-node is RS(1,0), where
the shard floor cannot be broken and a cross-node takeover after a failed seal
does not exist, so the two assertions worth having a dedicated cell for were
the two it could not make. Cell 29 is now real-multi on ci-multi.

It also corrects the claim that the PR gate already covers the multi-node case.
The suite is eligible for both topologies, but e2e.yml's default suite list
never names it, so it has never run there. A full pass is ~63 minutes on
dev-prod bare metal, almost all of it deliberate waiting -- one test holds
predastore SIGSTOPped for seven minutes -- which is too long to add to a gate
that currently finishes well inside its budget. Nightly is where it belongs.

Consequences: a per-cell timeout_minutes, defaulting to the previous 50, since
90 minutes of suite cannot run in a 50-minute job; the permutation steps gated
on matrix.suite == '' so the dedicated cells do not also run them; and a Run
Multi Suite E2E step that builds NODES_CSV from the tofu inventory and asserts
the suite against E2E_SUITES_MULTI before running it.

* fix: stop a startup KV race leaving vpcd serving keyless guests

vpcd opens five KV buckets while NATS is still coming up, each attempt
carrying a ~5s JetStream API deadline, and applied three different policies to
the same failure: spinifex-vpc-enis was fatal, while instance-state,
security-groups, subnets and vpcs each degraded silently for the lifetime of
the process. A five-second blip during a cold start therefore decided how a
node served metadata until someone restarted it.

On dev-prod all three nodes hit this after a deploy. Two were saved by
accident: their ENI bucket also timed out, so they exited 1 and systemd
restarted them cleanly. The third's ENI bucket opened in time, so it never
crashed and ran for hours with no instance record loader. With the local state
file unreadable as well, every instance-backed IMDS leaf 404'd, cloud-init's
crawl died on ami-id and fell back to DataSourceNone, and every guest launched
on that node booted with no authorized_keys.

kvstore.OpenWithRetry retries for a bounded window before any policy applies,
logs the cause per attempt and the wait on a late success, wraps the last error
with the bucket name, and returns at once on a cancelled context. All five
opens use it. Failing to open the instance record space is now fatal, matching
the ENI bucket: the dependency whose loss stops guests logging in should not be
treated more leniently than the one whose loss stops ENI resolution.

Also fixes the reason the fallback was load bearing at all. The local state
file was written 0600 and vpcd reads it as a different user in the shared
spinifex group, so the local-first path had never once worked and its
mtime+size cache had never fired. It is 0640 now, set with an explicit Chmod so
an inherited umask cannot undo it.

Closes mulga-gtpzz, mulga-dhqxr. Still open: mulga-mm2oc, IMDS serving an
unresolvable instance as "no key pair" with nothing logged.

* fix: scope the ime-* firewall accept to IMDS and VPC DNS only

The per-tap OVS demux delivers any guest packet addressed to
169.254.169.254 or .253 onto an ime-* host port regardless of L4 port,
and iifname "ime-*" accept then admitted all of it. Every wildcard-bound
host service — sshd, the AWS gateway, northstar, the console — answered
guest packets on those addresses; only asymmetric reply routing kept the
handshake from completing, and a guest could still drive northstar to
recurse unthrottled. Scope the accept to IMDS (.254:80) and VPC DNS
(.253:53), letting everything else fall through to policy drop.

* docs: document the instance-to-host plane and how to protect it without the ISO

The metadata/VPC-DNS link-local plane (169.254.169.254/.253) is served by
host services and is invisible to a NIC-level or cloud firewall, since the
traffic is intra-host on the OVS bridge. Explain that the ISO ships the
scoping rule armed, that the binary installer ships the firewall off, and
that a non-ISO install must enable it with setup.sh --firewall=on or
firewall_enabled=true. Include verification from an instance and the host.

* fix: withhold the IMDS meta-data listing when the instance does not resolve

Every omission from the meta-data root is a positive claim about the instance —
public-keys/ absent means "launched without a key pair", and cloud-init believes
it, writes no authorized_keys and boots the guest keyless. So resolving the
instance was best-effort for a listing whose whole meaning depends on it: a
backend error or a miss shortened the listing instead of failing it, and nothing
anywhere recorded that the answer was a guess.

Withhold the listing with a 500 instead. cloud-init retries a 5xx and cannot
retry a 200 that is merely wrong, so this converts a permanent silent failure
into a transient loud one. servePublicKeys already had this policy for the leaf;
the root is what contradicted it.

A profile lookup that fails still serves the listing minus iam/, and now logs.
The key pair is the field a guest cannot recover from losing, while a missing
role surfaces through the credentials leaf, which reports the miss itself.

Also stops the storagefault cross-node test failing on its own access path. It
tolerates the pattern changing across a takeover, but readPattern needs sudo,
and a volume opened from the backend checkpoint may predate cloud-init writing
its sudoers drop-in — indistinguishable from an unreadable device at the dd call
site. It now checks sudo first, and reads cloud-init's recorded datasource to
tell a rolled-back root filesystem from IMDS never having answered, failing
loudly on the latter rather than accepting it as storage loss.

---------

Co-authored-by: brynmailer-mdc <bdm@mulgadc.com>
2026-09-03 19:45:33 -05:00
Josh Siviour e2c5397b5d feat: gateway-side instance record cache (Phase 2) (#992)
* feat: add read-only instance record cache (Phase 2)

Adds spinifex/instancecache: a revision-fenced snapshot+watch informer
over the instance record KV space, with a per-account index and
OTel metrics. Wired up and running in awsgw at startup, but not yet
consulted by any read path.

* fix: cover the fence with real syncs, bound decode-failure cardinality, expose degraded

Adds end-to-end fencing tests that drive freshSync/periodicResync
against the embedded NATS server via a test-only postSnapshotHook
seam, since drainBuffered unit tests alone don't exercise the
buffer-before-snapshot ordering. Drops the per-key attribute from
the decode_failures counter to keep it O(1) time series; the key
is still logged where the decode happens. Adds a Degraded accessor
set on sync failure and cleared on the next success, per D7,
without changing what List or Get answer.

* refactor: replace instancecache's buffering fence with ResumeFromRevision

The watch/snapshot fence no longer needs a side buffer, watcher modes, or
an overflow failure mode. Every sync now snapshots first, then opens a
watcher with kvstore.Bucket.WatchFrom (ResumeFromRevision), and replays
onto a private candidate map until the end-of-replay marker arrives.
Only a fully caught-up candidate is swapped in as the live map, together
with its watcher becoming active; the previous watcher is stopped and
joined only after, so two watchers never apply to the same map.

kvstore.Bucket gains WatchFrom as a sibling to Watch, opened through the
same withKV wrapper, for a caller that needs to replay from a snapshot's
high-water mark rather than an UpdatesOnly live feed.

* fix: cover the fence with production entry points, not a reimplementation

The four fence tests recomputed hw+1 and called kvstore.WatchFrom/replay
themselves instead of going through syncOnce, so they verified nats.go's
resume behavior (already covered by TestResumeFromRevision_...) rather
than the cache's own revision handling. A mutation inside syncOnce
(hw+1 -> hw+1_000_000) passed all four.

Reinstate a postSnapshotHook seam (nil in production) so the initial-sync
pair can call syncOnce/installSync directly and the periodic-resync pair
can call periodicResync directly, landing the write inside syncOnce's
internal snapshot-to-watch window without duplicating it. The same
mutation now fails all four; reverting passes all four.

* feat: count the records a resumed watch replays, so the fence is observable live
2026-09-04 08:08:46 +10:00
Ben Duncan b6d01370dd fix: render northstar's recursion allowlist so a node is not an open resolver (#993)
* fix: render northstar's recursion allowlist so a node is not an open resolver

northstar.toml is written with allow_recursion = false and an allow_recursion_from
list built from cluster membership, so a freshly formed node serves recursion to
the cluster and refuses it to the internet.

The list is the node's own bind and advertise addresses plus every peer's host,
with any port stripped. Both local addresses matter: a node's resolv.conf points
at its own advertised address, so the query arrives from that public IP over lo
rather than from 127.0.0.1, and an allowlist of loopback alone would stop every
node resolving internal names.

Guests are covered without appearing in the list. vpcd's per-tap DNS shim relays
guest queries to a node's :5300 from a node address, so trusting the nodes trusts
every guest behind them and no guest CIDR is needed.

The template comment carries the caveats for allow_recursion = true, since the
cost of setting it is remote from the setting: an open resolver is found by
scanners within hours and gets the node's whole address range blocklisted, which
is an outage for every tenant on it.

The list is rendered at formation, so a node added later is not in its peers'
files. Regenerating it live from membership, as the daemon already does for
peers.nft, is follow-on work tracked on mulga-t3mub.

* chore: pin northstar to the recursion ACL and atomic zone reload

The recursion allowlist this branch renders is enforced by northstar's
RecursionPolicy, which lands in dev at a0e741e. CI resolves northstar from
go.mod rather than the workspace, so the build failed on NewRecursionPolicy
being undefined until this pin moved. The AWS SDK bumps come with it.
2026-09-03 12:48:16 -05:00
Josh Siviour 06df1b799b fix: tunnel apiserver controlplane egress through konnectivity (#991)
The EgressSelectorConfiguration written to every EKS control-plane VM
defined only the `cluster` egress selection, which carries kubelet
exec/logs/portforward. Admission webhooks and aggregated apiservers dial
via the `controlplane` selection instead, and an undefined selection
falls back to a direct dial. The control-plane VM sits alone in its own
VPC with no route to the service CIDR, so every webhook call timed out
after 10s and konnectivity never saw a single dial request for one.

Define both selections on the konnectivity-server UDS so webhook traffic
rides the agent-initiated reverse tunnel alongside kubelet traffic.
2026-09-03 16:56:52 +10:00
Julian Sommer 4d3ba022ce fix: poll for the outcome point instead of racing the reply
natsMetricsHandler records after the handler has already replied, so a
test that collects the moment nc.Request returns can beat the point to
the reader. Matches the poll the ec2.cmd outcome tests already use.
2026-09-03 16:23:35 +10:00
Josh Siviour d9593c0a48 feat: repair instance records on a ticker, not only on state change (#988)
* feat: repair instance records on a ticker, not only on state change

A record write is best-effort, and a failed one forgets its digest so the
next reconcile retries it. But the only caller is persistState, reached
from WriteState, which fires on state change. An instance that launches,
has its record write fail, and then simply runs has no next state change,
so its record is absent for as long as it lives.

A 60s sweep on the node now reconciles the live instance set against the
digest of what it has published. The digest guard means a node with
nothing outstanding writes nothing, so the cost tracks failures rather
than instance count. The interval is the point of the change — it is what
turns an unbounded window into a bounded one — so it is asserted directly
rather than inferred from a sweep.

The sweep does not go through persistState: the local state file is
already correct and rewriting it would advance the state revision for no
reason. Only the record half needs repairing.

WriteRunningSet now reports what it did, so a sweep that found work can
say so and a healthy node stays silent.

* fix: keep the QMP liveness stamp out of record change detection

Found on env19: the node owning a running instance rewrote its record on
every sweep, forever. The digest guard was working exactly as coded — it
correctly found a real difference each tick, because Health.LastQMPSuccess
advances on every successful QMP poll and the collector runs on its own
cadence. Left alone this is a KV write per running instance per node per
minute, indefinitely.

The stamp is now held out of the digest. Nothing reads it back: impairment
is judged from QMPConsecutiveFailures and ImpairedSince, and node liveness
comes from the heartbeat store, so holding it out costs no reader anything
and restores the publication cadence the record had before the sweep
existed. QMPConsecutiveFailures and ImpairedSince stay in, because an
impairment is a real transition that has to reach the record space.

Both halves are covered: a stamp moving on its own must not write, and an
impairment must still write. The first fails without this change.
2026-09-03 15:54:43 +10:00
Julian Sommer 7b4a83ccfe fix: expect InvalidSnapshot.NotFound for cross-account DeleteSnapshot
Snapshot metadata is now keyed by owning account, so a snapshot outside
the caller's prefix is absent rather than forbidden. The e2e assertion
still encoded the pre-partitioning UnauthorizedOperation contract.
2026-09-03 15:51:27 +10:00
Julian Sommer d8d3e2268d feat: partition EBS metadata keys by owning account (#989) 2026-09-03 15:38:23 +10:00
Josh Siviour de44fa3264 fix: cover the full ELBv2 attribute key set (#990)
ModifyLoadBalancerAttributes and ModifyTargetGroupAttributes reject any
key absent from the per-type default maps with ValidationError, and the
rejection fails the whole call rather than the single key. Terraform and
the AWS Load Balancer Controller send the full attribute set on every
apply, so one missing key breaks the resource outright.

Adds the 17 keys the ELBv2 API reference documents but the default maps
lacked:

  ALB: health_check_logs.s3.{enabled,bucket,prefix}, ipv6.deny_all_igw_traffic
  NLB: secondary_ips.auto_assigned.per_subnet
  Target group: load_balancing.algorithm.anomaly_mitigation,
    stickiness.app_cookie.{cookie_name,duration_seconds},
    lambda.multi_value_headers.enabled, target_group_health.* (4),
    deregistration_delay.connection_termination.enabled,
    preserve_client_ip.enabled, target_health_state.unhealthy.* (2),
    target_failover.{on_deregistration,on_unhealthy}

health_check_logs.s3.* is what surfaced this: it is ALB-only, and an
aws_lb apply against the sandbox failed on it.

The target group map stays a single flat union rather than splitting by
protocol, so it remains a permissive superset. Values are store-and-echo
only; nothing in the data plane reads them.
2026-09-03 15:06:01 +10:00
Bryn Mailer eccc61a69e fix: hold back JetStream recovery headroom on the store filesystem (#967)
Filling the filesystem behind the JetStream store to zero free bytes leaves
nats-server with nowhere to write while it recovers a stream. Every error
return inside the locked region of newFileStoreWithCreated assigns nil to the
named return value fs, and the deferred unlock then dereferences it, so a
recovery write failure surfaces as a nil-pointer panic instead of an error.
That structure is unchanged in v2.14.3, in the pinned v2.14.5 and in the
current v2.14.6, so no available version bump addresses it.

The nats service now keeps a preallocated reserve file beside the jetstream
directory and hands it back at startup when the filesystem can no longer
supply that headroom on its own, re-arming once space returns. Recovery
therefore always has somewhere to write, whichever process filled the disk.
2026-09-03 14:46:11 +10:00
Bryn Mailer 18aa67b0e9 feat: add the e2e guest disk-performance gate (#964)
* feat: add the e2e guest disk-performance gate

Nothing measured guest disk performance in CI, which is why a quadratic
index scan shipped in viperblock 35dd9f2 and survived three subsequent
fixes to adjacent code. It is also why the two availability clauses at
the end of the ebs-performance plan's Stage 0 have never been evaluated:
a sustained random-write workload took the guest, and then the host,
down, and nothing since has demonstrated that it no longer does.

tests/e2e/diskperf/ launches an instance, attaches a dedicated blank
volume per repetition, and drives a pinned fio profile over guest SSH.
Two profiles: randwrite-4k at 4 jobs x 4 GiB, the shape the exit
criterion names, and randrw-70-30-4k, the mixed workload the headline
IOPS came from. The target is a raw device with direct=1 rather than a
file on the boot disk, so guest page cache, filesystem allocation and
journal traffic are out of the path.

Two classes of assertion, kept apart because folding them together lets
a run that left the host unreachable pass on a respectable IOPS figure.
Availability is hard pass/fail and absolute, so it gates from the first
run: the job completes inside its budget, a guest responsiveness probe
answers throughout on a second SSH channel, and the host's own sshd
answers throughout. The guest probe reads the root volume while the
workload runs on a different one, so a pass is evidence of isolation
between volumes rather than of the job merely being slow. The host probe
is a TCP connect plus an SSH banner read, which needs no credential and
still proves the kernel scheduled sshd.

Throughput and latency are banded against a committed baseline: fail
above 25% regression, warn above 10%, per-metric median across
repetitions. baseline.json is committed and embedded, with every figure
zeroed, and a zero reference records and warns rather than failing. Only
the availability class gates until numbers are captured on the bare-metal
cell; a throughput band nobody measured would be a fabricated gate.

A fresh volume per repetition is the reset. Extent count grows
monotonically, so reusing a volume makes the gate order-dependent and
drifts its own numbers upward over time, slowly normalising the
regression it exists to catch. Each job is its own guest invocation and
fio's JSON is fetched by a separate command, so a wedged job loses only
itself and reports as a budget breach rather than a parse failure.

e2e-baremetal.yml gains suites, suite-env and cell inputs so one reusable
workflow serves both the correctness cell and nightly cell 28, which
queues behind cell 20 on the shared concurrency group. Cells 25-27 are
the dedicated eks, ecs and rds cells. The per-suite Go test timeout is
resolved from e2e_suite_timeout in suite-sets.sh, where diskperf declares
its 180-minute budget beside rds's rather than in a second place.

The gate's own arithmetic runs in preflight via test-harness: band edges,
the unmeasured cases, per-stream metric selection, fio JSON parsing, and
the probe accounting including a slow-but-successful answer counting as a
breach. A gate whose comparison logic is untested is the failure mode the
plan is otherwise full of.

The extent-count ceiling named as a fourth availability assertion is not
implemented: it needs viperblock.index.entries from S1.5, which does not
exist.

* feat: let a manual bare-metal run choose its e2e suites

A dispatch run only sees inputs declared under workflow_dispatch, which had
none, so suites and suite-env resolved empty and fell back to the correctness
set. Capturing a diskperf baseline by hand was therefore unreachable without a
workflow_call caller.

* fix: build the bare-metal suites the run step will execute

The build step packs pre-built binaries into the tarball and did not see the
suite selection, so a selected suite was absent from the node unless it
happened to be one of the three the build script hardcoded.

* feat: commit the measured disk-performance baseline

Medians of three repetitions on the bare-metal cell. The throughput and
latency bands now gate instead of warning, so a 2x regression in either
job fails the suite.

randwrite-4k's write tail is two orders above the mixed job's. It is
recorded as measured rather than zeroed: an ungated metric would miss the
regression class the gate exists to catch.
2026-09-03 14:33:21 +10:00
Josh Siviour c767f2b6ee feat: judge DescribeInstances completeness by responder identity, and resolve IMDS locally (#985)
* feat: stamp the owning node's AZ on the instance record

RunInstance never persists a node's AZ onto the stored instance, so
today only the launching node's own ProjectInstance call can produce
a correct AZ for a describe. Add VM.AZ, carry it on InstanceStatus
since it is placement observed by the running node, and stamp it in
WriteRunningSet alongside Status.LastNode from the node's own
config.AZ.

* docs: trim the AZ field comment to the project's three-line limit

* fix: identify Gather responders by node, not by frame count

utils.Gather previously counted reply frames toward completeness, so
two subscriptions replying from one host (a daemon mid-restart) could
satisfy a fan-out while a genuinely configured node stayed silent.

Every daemon reply now carries an X-Node-ID header set by the shared
response helpers via nats.Msg.RespondMsg, so Gather can attribute each
frame to the node that sent it. Gather gains a CollectionMode
(CollectServeData preserves today's early-exit behavior;
CollectUntilDeadline collects to the full deadline for absence-proof
callers) and returns node-identified Frames plus Responders/
SuccessResponders/ErrorResponders sets. Identity mode retains only the
first payload seen per node; a disagreeing later frame from the same
node flags a conflict but never overwrites the retained data.

Node identity uses one namespace across the reply header,
config.ClusterConfig.Nodes keys, daemon.Heartbeat.Node, and
vm.VM.LastNode, all traced back to Daemon.node - confirmed to already
agree, with a new test asserting it at runtime.

This is a mechanical caller-side follow-on: every existing
utils.Gather caller now reads frame.Data instead of frame directly.

* feat: thread configured node set to DescribeInstances

GatewayConfig gains NodeIDs, the keys of config.ClusterConfig.Nodes,
distinct from the existing ExpectedNodes count. awsgw builds it from
cluster config and wires it into GatewayConfig. The DescribeInstances
action passes gw.NodeIDs through instead of relying on
gw.DiscoverActiveNodes(ctx) at that call site, so completeness can be
judged against the nodes that are actually configured rather than the
nodes that happened to answer a discovery probe.

* fix: judge DescribeInstances completeness by node identity

DescribeInstancesChecked now judges completeness on decoded frames
against the configured node set (ValidResponders) rather than on
Gather's frame/byte counts, which cannot know the describe schema. A
node that answers with a nil Reservations list is a valid responder,
not an invalid one - the existing merge guard on Reservations != nil
governs how frames combine, not whether the node counted as having
answered.

The explicit-instance-ID 404 path now collects with
CollectUntilDeadline so a node that hasn't replied yet cannot be
mistaken for a node that has nothing to report; general listing stays
on CollectServeData. Any ambiguity - an unidentified frame, a node
appearing in both the success and error sets, a node replying twice
with disagreeing frames, a Gather cap hit, or an empty/unset node set
- suppresses the NotFound rather than risking a false one.

* fix: let a describe by instance id stop once every id is found

CollectUntilDeadline made every DescribeInstances with explicit ids wait the
full 3s fan-out deadline, measured at 4650ms against 1655ms before, on a
healthy cluster where all three nodes answer in about a millisecond.

Gather gains a Settled predicate, consulted only under CollectUntilDeadline,
that ends collection once the replies already answer the question. Absence
still pays the whole deadline, and an instance the caller cannot see is
filtered out of every frame so it takes the absence path too, which keeps the
two NotFound answers indistinguishable in time.

* refactor: resolve IMDS instance metadata local-first, drop the fan-out

IMDS only ever serves a guest on its own node, so a DescribeInstances
fan-out to the whole cluster is unnecessary latency and load. Resolve
from the node's own on-disk VM state first, falling back to a direct
record-space read for an instance that has moved off this node.
Neither path issues a NATS request.

* perf: cache the local instance state parse for IMDS lookups

IMDS request rate is guest-driven and unbounded, so re-reading and
re-parsing the whole on-disk VM state on every request does not
scale with VM count on a node. Cache the parsed state and only
re-read when the file's mtime or size changes; a stat or parse
failure clears the cache and falls back to the record space rather
than serving a stale hit.

* fix: share ManagedBy visibility rule between DescribeInstances and IMDS

Extracts IsInstanceVisibleToCaller so account ownership and the
platform-managed hide rule live in one place instead of two
independently maintained copies.

* refactor: decouple IMDS instance lookup from the daemon package

handlers/imds no longer imports daemon: NewIMDSServiceImpl now takes a
localStateReader and a recordLoader interface instead of a dataDir
string, and vpcd (the composition root for both planes) constructs
both adapters, including the stat-guarded local-state cache that used
to live inline in handlers/imds. Either adapter may be nil, degrading
that source to a miss rather than failing IMDS to start.

* test: move the local state reader test to an external package

* chore: exempt export_test.go from the external test package check

The check's own remedy text tells you to move unexported hooks into an
export_test.go, then flags that file for being in-package — which it is by
definition, since exporting internals is its whole purpose.

* fix: bound how many describes may hold a full fan-out deadline

A describe naming an instance the caller cannot see is filtered out of every
frame, so it never settles early and holds a gateway goroutine and inbox
subscription for the whole 3s deadline. Issuing those is free for the caller,
so nothing stopped a burst of them pinning unbounded gateway state.

Absence proofs now draw on a fixed budget. Past it, callers get a retryable
RequestLimitExceeded rather than being queued, since queueing would let the
backlog grow. The decision is taken before any reply is read, so a nonexistent
id and another account's id throttle identically and the throttle cannot become
the timing oracle the deadline exists to close.

* perf: settle describe-by-id on responder coverage, not the full deadline

Every describe-by-id was paying the whole 3s fan-out deadline whenever the
requested instance was not in a running-instance frame — which includes a
stopped instance, a terminated one, an id belonging to another account, and
an id that does not exist.

Gather gains ResponderGrace: once every ExpectedResponders node has
answered, collection closes after a short grace window instead of running to
the deadline. Core NATS gives a request only to the subscribers present when
it was published, so an overlapping subscription replies alongside the first
and one that arrives later never receives the request at all. Waiting past
full coverage cannot find a reply that full coverage missed. The window is
armed once and never extended, so a node replying repeatedly cannot hold the
fan-out open. The full deadline is now paid only when a node stays silent,
which is the case it was always meant to cover.

The stopped and terminated bucket queries now run alongside the fan-out
rather than after it, so their latency overlaps it instead of adding to it.
They feed a shared instanceSettler that ends collection as soon as every
requested id has been seen, skipping even the grace window.

The timing oracle is unchanged. An instance the caller cannot see is
filtered out of every source before the reply is sent, so it takes the same
path as an id that does not exist: both answer NotFound after full responder
coverage, and remain indistinguishable in time.

* fix: stop parallel tests racing the terminate backoff seam

terminateRetrySleep is a package global, and every test that needed it
neutered swapped it and restored the old value from t.Cleanup. Those tests
run in parallel, so two cleanups could write the seam at once — a real data
race the detector caught once enough parallel tests shared the binary.

Installed once in TestMain instead. Nothing in this package wants the real
backoff, so there is no value in restoring it per test.
2026-09-03 13:25:19 +10:00
Julian Sommer a5fc2b517c fix: supply aws:userid on the gateway authorization path (#987) 2026-09-03 10:41:14 +10:00
Julian Sommer ab167a192c fix: reject an unresolvable policy variable on the IAM write path (#986) 2026-09-03 09:57:10 +10:00
Julian Sommer 4522494c17 fix: define the per-suite e2e test timeout once, in suite-sets.sh
The timeout was written out separately in each workflow and drifted: rds
ran 50m on the PR gate and 30m nightly, where it panicked and took six
in-flight subtests with it. e2e-suites.yml had the same hardcoded 30m on
its single path, which also runs rds.

A new e2e_suite_timeout helper sits beside the eligibility lists it
already guards, and all four call sites resolve through it. e2e.yml loops
over suites on the remote VM, which has no checkout, so it resolves the
pairs on the runner and looks them up in the loop.

Also lift the nightly cell ceiling to 50 minutes so the rds cell is not
cut short by the job before its own timeout applies.
2026-09-03 09:21:17 +10:00
Julian Sommer 0b518d2f79 fix: name the blocking resource in DeleteSecurityGroup DependencyViolation
checkSGDependencies knew which ENI or referencing security group blocked the
delete and discarded it, returning a bare DependencyViolation with no log line,
so the blocking id had to be recovered by cross-referencing the ENI table.
Return it via awserrors.Errorf and log it at the point of refusal.
2026-09-03 09:15:31 +10:00
Bryn Mailer e07f25ccb2 fix: fail DRAIN when a volume seal failed (#966)
shutdownAndUnmount logged the volume unmount error and returned nothing, so
StopAll returned nil and the DRAIN phase ACKed success with vms_stopped set.
The coordinator then advanced to STORAGE and stopped viperblock while the seal
was still in flight, tearing the VBState envelope and leaving the guest unable
to mount its root volume on the next boot. The deploy reported success.

shutdownAndUnmount now returns ErrVolumeSealFailed when the unmount did not
seal; QMP, PID-file, fw_cfg and telemetry cleanup stay best-effort. stopCleanup
and stopOne carry it without aborting the rest of the teardown, since QEMU is
already down by then, and StopAll aggregates the per-VM failures so
handleShutdownDrain ACKs with Error set and never reports vms_stopped.

volumeMounterAdapter.Unmount was the second swallow site: it logged each failed
seal and returned nil unconditionally. It now attempts every volume and returns
the aggregate, which is what the VolumeMounter contract already described.

Terminate still tolerates a failed seal — the volumes are deleted next, so an
unsealed block map loses nothing a caller can ask for.

This does not fix the seal itself. It converts silent data loss into a refused
upgrade that an operator can act on.
2026-09-02 16:58:30 +10:00
Julian Sommer be94beb52a ci: run eks, ecs and rds nightly in dedicated cells (#984) 2026-09-02 16:16:05 +10:00
Julian Sommer 0247f57d6c fix: derive S3 Service Metrics cluster health from raft quorum (#983) 2026-09-02 15:21:53 +10:00
Julian Sommer 677055a478 test: cover the launch-template boot path end to end (#982) 2026-09-02 14:58:13 +10:00
Julian Sommer e8f1718ebd fix: seed the gateway nexthop MAC binding for a host-local nexthop (#981) 2026-09-02 14:22:43 +10:00
Julian Sommer dc9b8429ae fix: derive the EKS nodegroup ARN discriminator so an exact-ARN Deny fires
The policy gate had the cluster and nodegroup name but not the trailing
UUID, so it evaluated nodegroup/<cluster>/<name>/*. That value matches the
AWS-documented wildcard policy spelling but not one naming the exact stored
ARN, which is what a tenant gets by pasting DescribeNodegroup output, so
that Deny stayed inert.

The discriminator is now derived from (account, cluster, name) as an RFC
9562 version-8 UUID, so the gateway and the handler compute the same value
independently and the gate names the stored ARN exactly with no lookup.
Nodegroups created before this keep their random discriminator and their
existing wildcard-only behaviour.
2026-09-02 13:37:16 +10:00
Julian Sommer f7bcccb1fe test: cover spread placement distribution and the default VPC handlers 2026-09-02 12:30:28 +10:00
Julian Sommer e53e7a5884 test: cover subscriber topology handler happy paths
Drives every vpc.* topology topic end to end against the mock OVN client and
asserts the resulting rows: router, subnet switch and router port, logical
switch port addresses and port-group membership, gateway router port, NAT
rules and egress policies. Invalid CIDR, IP and MAC events are pinned to a
failed reply envelope with no partial write.

Moves the elbv2 dispatch table to an external test package.

subscribers 53.5% -> 73.4%.
2026-09-02 12:12:17 +10:00
Julian Sommer 13c5b00066 test: pin elbv2 gateway subject wiring and validation guards
Adds a 35-row dispatch table covering every NATS-backed elbv2 gateway
wrapper. Each row subscribes a stub only to the expected subject, so a
mis-wired subject fails as no-responders, and asserts the returned value,
the forwarded request body and the account header. A second table covers
the 28 validation guards that had no test, each pinned to its AWS error
code.

Package coverage 49.5% -> 93.8%.
2026-09-02 12:05:56 +10:00
Julian Sommer 80f4db05bc test: cover the ebs provider NATS server, memory provider and null provider
Tables the twelve subjects Serve subscribes through the malformed-body,
version-skew and provider-failure arms, and round-trips every verb through
NATSProvider against a served MemoryProvider so client encoding and server
decoding are asserted against each other.

Covers MemoryProvider.ListVolumes, ListSnapshots and CopySnapshot, all of
which were at zero, plus NATSProvider's nil-payload and subject-token guards.

natsserve 28.8% -> 91.3%, ebsprovider 67.5% -> 86.3%, nullprovider 0% -> 100%.
2026-09-02 11:51:50 +10:00
Julian Sommer bd8ac8efa1 test: cover the installer wizard key handlers and screen rendering
Drives the model through Update with real key messages: a full walkthrough
asserting the config it produces, every validation gate keeping the operator on
the screen with the cursor on the field at fault, and the rendered output for
facts that appear nowhere else (role layout, pool geometry, folded planes).
2026-09-02 11:42:13 +10:00
Julian Sommer 4b4542a4c5 test: retry the multinode same-SG cross-host ping and capture OVN state at first failure
The cross-host ICMP probe fired once, with no convergence budget, the moment
sshd first answered on the source guest. The default SG self-ingress rule
matches on the northd-derived $<pg>_ip4 address set, which only reaches the
remote chassis after northd regenerates it from port-group membership and
ovn-controller installs the flows there, so a first-packet burst can be
swallowed by the priority-900 default drop with no log line anywhere.

Give the probe the same 45s/2s budget the single-node suite already uses, and
snapshot NB/SB address sets, port groups, ACLs, port bindings and br-int flows
from every node plus the source guest on the first failed burst rather than at
teardown, so the state is captured while the drop is still observable. Emit
attempt count and convergence time so a pass that needed most of the budget
still surfaces as a propagation delay instead of a silent green.
2026-09-02 11:37:22 +10:00
Julian Sommer d535718bcb test: add an ovn.Client conformance suite across live and mock
One behavioural suite runs against LiveClient over the in-memory NB server and
against mock.Client, so the semantics reconcile, policy, external, topology,
subscribers and vpcd rely on are pinned in both. Covers NAT router scoping and
duplicate deletion, the not-found sentinels, gateway chassis idempotence,
ReplaceACLs no-churn, port group membership and DHCP lookups.
2026-09-02 11:32:54 +10:00
Julian Sommer babde88de0 test: cover waitForClusterReady via a sleep and deadline seam
The readiness loop terminates on wall clock, so a stubbed sleep alone still
costs two minutes; clusterReadyMaxWait becomes a var alongside clusterReadySleep
so the timeout branch is reachable. Adds the in-package marker to the EIP
association tests, which drive setupTestEIP and read svc.natsConn.
2026-09-02 11:27:29 +10:00
Julian Sommer ce9a03bc72 test: cover the EIP association path and the SG rule adapter
setupTestEIP built the EIP service with a nil VPCService, so every ENI
lookup AssociateAddress performs was structurally unreachable: lookupENI,
lookupENIByInstance and AssociatedPublicIPForInstance had no test at all
and any test reaching them would have panicked. Wire a real VPC service
over the same embedded NATS and cover association by ENI id and by
instance id, the private-IP override, both not-found codes, and the
add-nat/delete-nat events the NAT rule is built from.

sgRulesToPolicyRules was never carried a tenant security-group rule: the
golden scenarios build IntentState by hand and bypass the adapter, so a
transposed FromPort/ToPort or a dropped SourceSG would have shipped
silently. Load real SG records through KV and pin the field mapping.
2026-09-02 11:17:22 +10:00
Julian Sommer 5b09cc9172 test: add a live E2E identity-policy authorization suite (#980) 2026-09-02 11:06:54 +10:00
Julian Sommer 675243753e test: add completeness gates for the ECS, ECR and RDS scope tables and the condition keys
ECS and ECR take the existing both-directions shape against their Actions
tables. RDS carries its scopes on the dispatch table itself, so the gate is the
account-level half instead: every served action is either scoped or carries a
written reason, and one in both sets or neither fails. HasScope and
ScopedActions are added to drive it.

The condition-key gate pins the door's key set and asserts the IAM write path
accepts exactly the operator/key pairs the evaluator enforces.
2026-09-02 10:42:30 +10:00
Julian Sommer a9a457489d fix: re-anchor a caller-supplied policy ARN onto the caller's account
The seven sourcePolicyARN actions resolved to the PolicyArn the caller sent,
so the gate decided against an ARN whose account the caller chose. The scope
now takes the ARN's path and name and rebuilds it on the caller's account, as
sourceOIDCARN beside it already does, and widens to account-wide when the ARN
carries no policy component. Lands with the cross-tenant integration suite that
found it.
2026-09-02 10:18:50 +10:00
Julian Sommer b033a10200 fix: scope tagging policy checks and pin the table exhaustive (#979) 2026-09-02 09:29:47 +10:00
Josh Siviour 28324fd6c6 fix: take the volume lease when snapshotting an unmounted volume (#978)
snapshotVolumeEngine's unmounted branch built its engine by hand instead of
going through openVolumeVB, so it was the last control-plane path that opened
an engine over a volume without holding the cluster-wide lease. A snapshot
request landing on a node that does not have the volume mounted would open a
second engine over storage another node was writing.

Two engines on one encrypted volume read the same SeqNumHighWater and issue the
same AES-GCM nonces, which discloses the XOR of the plaintexts and permits
recovery of the authentication subkey, so this is not only a consistency
question.

Route the branch through openVolumeVB and give the lease the engine's lifetime.
The tail both branches shared moves into createSnapshotWithVB, mirroring
copySnapshotOnVolume, which was converted earlier and is what this one was
measured against.

A refusal is reported as unavailable rather than internal: the holder gives the
volume up on detach, so the caller should retry instead of treating a live
mount elsewhere as permanent.
2026-09-02 08:59:35 +10:00
Josh Siviour 0ff37c313a fix: render S3-scoped gateway errors in the flat S3 error envelope (#976)
The gateway does not serve S3, so an S3-scoped credential is rejected on
scope by the SigV4 middleware. That rejection was written through
GenerateEC2ErrorResponse, giving the client the EC2 query envelope
<Response><Errors><Error>. SDKs look for a flat top-level <Error><Code>
on an S3 response, find nothing, and report an empty code and an empty
message, which reads as a credential fault rather than a wrong endpoint.

Add the flat S3 envelope and resolve the envelope from the service the
client actually signed with: GetService reports an unserved scope as
empty, which is exactly the case that has to render as S3.

The service-to-envelope decision was copied into four emitters and the
copies had already drifted - writeClusterUnavailable put ELBv2 on the
EC2 envelope while ErrorHandler and writeThrottleError put it on the IAM
one. Collapse them onto a single xmlErrorBody so a new service cannot be
wired into some emitters and missed by others.

s3 is deliberately not added to supportedServices: only the shape of the
rejection changes, not the rejection.
2026-09-01 22:51:03 +10:00
Josh Siviour 687974995f fix: ECS RunTask and CreateLaunchTemplateVersion honour ClientToken (#974)
* fix: ECS RunTask and CreateLaunchTemplateVersion honour ClientToken

A retried RunTask placed the tasks again: it mints a fresh task ID per task,
reserves CPU/memory/GPU, hot-plugs an ENI in awsvpc mode and starts the
containers, so a resend with Count 5 left ten running tasks, double the reserved
capacity and five leaked ENIs. CreateLaunchTemplateVersion added a second
identical version and moved $Latest.

RunTask gets a per-action wrapper over idempotency.Do rather than a dispatch
decorator: it is the only ECS action that needs one. A partial placement
finalizes rather than aborts, since re-running would add tasks on top of the
ones already started.

CreateLaunchTemplateVersion reuses the EC2 dispatch decorator, so it is an
opt-in rather than new machinery.

CreateLaunchTemplate, ModifyLaunchTemplate and ECS CreateService accept a token
they do not store; each is name-keyed or a CAS and cannot duplicate, which is
now recorded at the handler.

* test: cover the RunTask client-token binding layer

Split the token layer out of RunTask's NATS binding as runTaskIdempotent, the
same seam runTask already has, so the no-token, store-failure and replay paths
are reachable without a live connection.
2026-09-01 21:54:32 +10:00
Josh Siviour 44abc812b6 fix: seven EC2 creates honour ClientToken instead of duplicating on a retry (#973)
CreateVolume, CreateNetworkInterface, CreateNatGateway, CreateRouteTable,
CreateEgressOnlyInternetGateway, CopyImage and CreateCapacityReservation parsed
a ClientToken and never read it, so a retried request created a second resource.

Adds ec2IdempotentHandler at the EC2 dispatch table rather than seven
hand-written wrappers, keyed <action>.<account>.<token> in the bucket
RunInstances already uses. RunInstances keeps its unprefixed keys: re-keying
live records would make a retry re-launch instead of replay.

Promotes the shared orchestration to idempotency.Do, now that eight callers
share it, and wraps store failures in ErrUnavailable so callers map every
non-mismatch token failure onto one API error.
2026-09-01 20:10:22 +10:00
Julian Sommer 1fd4373b35 fix: verify caller-supplied role ARNs against the stored canonical ARN (#972) 2026-09-01 17:06:31 +10:00
Josh Siviour e2c38ca7ef refactor: one paging walk and one client-token store, not six and two (#971) 2026-09-01 15:27:43 +10:00
Julian Sommer 4d4f7f84f1 fix: wait for the NATS subscriber callback before asserting on recorded spans
ServeNATSRequestCtx ends its consumer span in a defer that runs after the
reply is published, so a test returning from NATSRequest could read the
span recorder before the consumer span had ended and see no account
attribute. The subscriber callbacks now signal completion and both tests
wait on that signal instead of on the reply.
2026-09-01 14:55:46 +10:00
Julian Sommer db97c90a9a fix: bind the EKS internal control-plane routes to the calling CP VM (#970) 2026-09-01 14:51:42 +10:00
Julian Sommer 12408268be chore: trim unused entries from the go build cache before saving
The cache was a union that only grew: each run restored everything, added
what it compiled and saved the result, so objects superseded by later
commits stayed for Go's full five-day window. Deleting every entry and
letting it rebuild took Build and Test from 3434 MB to 478 MB, restore
from 75s to 13s and save from 64s to 7s.

Dropping entries nothing has touched in four hours holds it near that.
Go bumps an entry's mtime when a run uses it, so the working set
survives; the step runs after the tests, so a pruned entry costs a later
run a partial recompile at worst and never the current one.
2026-09-01 14:26:54 +10:00
Julian Sommer da92c7a083 chore: only save the go build cache from dev and main
Restore is what makes CI fast; save is what got expensive. A same-era A/B
across the switch to this cache shows what restore buys: wall time went
6:50 to 3:52, Build Spinifex 63s to 10s, test-race 385s to 188s. Since
then the cache has grown to its steady state and the work has not moved,
so Build and Test now spends 75s restoring and 64s saving where it once
spent 13s and 8s.

A PR's save is read by nothing else. Splitting restore from save and
gating the save on dev and main keeps every branch reading a cache a dev
merge refreshes minutes later, and takes the upload off the 56% of runs
that are pull requests.
2026-09-01 14:19:56 +10:00
Josh Siviour bbd0ebebed feat: retry a transient reconcile failure before the resync, not after it (#969)
pass and keyPass logged every failure at WARN as "retrying next cycle" and
returned the callee's deadline, which for a failed callee is zero. Zero means
no deadline, so the next attempt was the resync — five minutes by default.

Two things were wrong with that. A fault that clears in seconds left the world
unconverged for minutes, when the loop already had the mechanism to ask for
sooner. And a terminal failure was announced as recoverable: "retrying next
cycle" is false for a 400, which will be a 400 every cycle, forever, at WARN.

Both now go through failed(), which classifies with awserrors.IsTerminal — the
predicate added in #923 for exactly this decision and until now called by
nothing. A terminal error is logged at ERROR and asks for no early retry; any
other error asks to be revisited after DefaultErrorRetry. A deadline the callee
named survives when it is sooner, and clampRevisit still caps every request at
the resync, so a loop can only become more eager than it is today.
2026-09-01 14:08:36 +10:00
Julian Sommer 91bab3b910 chore: merge integration job into lint 2026-09-01 13:58:03 +10:00
Julian Sommer 3cfa94d49a chore: drop the go build cache age census
It answered its question: of 18 GB on disk, 1.45 GB is untouched for a
day and nothing at all survives three. Go's own trim and the rate CI
runs already keep the cache live, so pruning before the save would shed
under 4% of the bytes at a two-day cut and risk recompiles for it.
2026-09-01 13:54:32 +10:00
Julian Sommer eccf068a25 fix: run the instance handler subtests in parallel too 2026-09-01 13:02:46 +10:00
Josh Siviour a0dfd311b2 refactor: make the elected reconcile node the only DNS zone writer (#968)
The lifecycle handlers published record changes onto the dns.recordset.change
queue group, which load-balances rather than serialises, so any daemon could be
read-modify-writing a zone object at any moment. zonelock.go existed to make
that safe. With the reconcile pass now applying its own batch on the node that
won the election, the desired set is the only path into a zone and the elected
node is its only writer, so both the publish path and the lock go.

Deleted: the four lifecycle publish call sites and their helpers, publish.go,
zonelock.go, SubjectRecordsetChange and QueueGroup, and the lock from
applyZone. Every record they published is already in the desired set the
reconcile rebuilds each pass, and every record they withdrew is already pruned
by it, so the classes DNS carries are unchanged.
2026-09-01 12:59:37 +10:00
Julian Sommer 805dab275c chore: measure go build cache staleness in CI
The build cache key rolls per run, so each job restores the previous
run's cache, adds to it and uploads the result. Nothing removes entries,
and Go only trims at 5 days, so every restore and save carries whatever
dead weight has accumulated. Build and Test moves 3.2 GB per run: 59s
down, 56s up.

Whether pruning that is worth doing depends on how much of the 3.2 GB is
still live, which is not visible from outside the runner. This reports
the age distribution and deletes nothing. It comes out once the number
is known.
2026-09-01 12:57:36 +10:00
Julian Sommer 07dce613eb fix: replace fixed test waits with exact barriers and overridable deadlines 2026-09-01 12:54:08 +10:00
Julian Sommer 4d429f231b fix: run the parallelised handler subtests in parallel too 2026-09-01 12:46:01 +10:00
Julian Sommer 6c68c236aa fix: run the vpc, elbv2 and iam handler tests in parallel 2026-09-01 12:32:27 +10:00
Julian Sommer d143757598 fix: stop the eks tests waiting out fixed fan-out and backoff windows 2026-09-01 12:25:10 +10:00
Julian Sommer 01cf08c22b refactor: replace hand-rolled loops with stdlib slices and maps helpers 2026-09-01 12:21:22 +10:00
Julian Sommer 16c2425184 fix: stop the utils tests waiting out production defaults
The reconnect tests paid the client's one-second default sleep between
attempts, which ConnectNATS already lets a caller override; the retry test
scales its floor with the delay it sets, so it still proves a retry happened.

7.9s to 5.0s.
2026-09-01 12:11:20 +10:00
Josh Siviour dfd2760af4 refactor: apply the DNS reconcile batch on the elected node instead of publishing it (#965)
The reconcile elected a leader, computed a converging batch, then published it
to the queue group, which load-balances rather than serialises. The batch was
therefore applied by an arbitrary peer, not by the node that won the election.

That split the read that decides which records are stale from the write that
removes them. A lifecycle upsert landing between the two was invisible to the
second, so a record re-added after the reconcile read it was deleted by a
decision that predated it. Applying locally closes the window, and makes the
elected node the only writer the reconcile puts on a zone.

Deleted with their tests: the batch splitter, the payload limits and the
partial-acknowledgement check, none of which have anything left to bound now
that no batch crosses a NATS connection. The lifecycle publishers and the zone
lock are untouched: they are still a second writer, and retiring them is
separate.
2026-09-01 12:10:54 +10:00
Julian Sommer 3423b2b08f fix: speed up the kvstore tests
Every test already owns its embedded JetStream server on an ephemeral port
under its own temp dir, so nothing here has to run serially. The recovery
failure test also left its doomed Set on a deadline-free context, which made
it wait out the jetstream client's own five-second default.

9.5s to 0.8s, and 11.8s to 2.0s under -race.
2026-09-01 12:07:25 +10:00
Julian Sommer ee258fd248 refactor: back the e2e Eventually helpers with testify
Delegate the hand-rolled deadline loops to require.Eventually and
require.EventuallyWithT instead of open-coding the polling.
2026-09-01 12:02:39 +10:00
Julian Sommer a5d942ec24 ci: skip the e2e suites on test-only pushes
A unit test cannot change what e2e exercises, so a push touching only _test.go
files no longer provisions the tofu VMs. tests/e2e/** is listed after the
exclusion so the e2e tests themselves still trigger their own suites.
2026-09-01 11:58:27 +10:00
Julian Sommer 30815e33ea refactor: use slices.Sorted(maps.Keys()) for sorted action name lists 2026-09-01 11:54:01 +10:00
Julian Sommer 4abbab59af fix: drop NeedDeps from the slog duration guard's package load
The guard only reads types for the syntax it walks, which resolves from
dependency export data; NeedDeps type-checked the whole graph from source for
no extra coverage. Verified identical detection either way (160 packages, 993
files, 3684 slog call sites, and both violation classes still caught).
19.0s to 7.3s under -race.
2026-09-01 11:48:30 +10:00
Julian Sommer 481df170f5 fix: run the reconciler tests in parallel
Each test starts its own embedded JetStream server and bucket, so they were
already independent; running them serially just stacked up the fixed settle
sleeps. 13.3s to 2.8s under -race.
2026-09-01 11:33:55 +10:00
Julian Sommer 609b4121f0 fix: report an IGW attachment only once the OVN gateway is up (#962) 2026-09-01 11:25:35 +10:00
Julian Sommer cde092cbfb fix: speedup daemon tests 2026-09-01 11:19:50 +10:00
Julian Sommer 7923545fc1 fix: speedup viperblockd tests 2026-09-01 11:17:21 +10:00
Josh Siviour 6bb854ee62 fix: build the EC2 DNS desired set from the instance records so stale records prune (#960)
* fix: build the EC2 DNS desired set from the instance records so stale records prune

* fix: keep DNS records for stopped instances in the reconcile desired set

The desired set filtered on Running, but the lifecycle publish deliberately
does not withdraw a stopped instance's records: stop and start retain the
address, so they retain the name. With EC2 pruning enabled, absence from the
desired set became a deletion, so a stopped instance would have lost both its
names until the next publish.

Both sides now read one predicate, InstanceRetainsRecords, which replaces
needsDNSWithdrawal and lives next to the names it governs. Records carrying a
DeletionTimestamp are skipped as well, so a re-upsert cannot race the
withdrawal the terminate path has already published.
2026-09-01 11:05:08 +10:00
Julian Sommer 3e02fc7613 fix: release the ENI and external address of an abandoned partial launch (#961) 2026-09-01 09:31:21 +10:00
Josh Siviour ebc95260ac refactor: wake the vpcd drift loop on intent changes, not a fixed 5-minute ticker (#959)
* refactor: wake the vpcd drift loop on intent changes, not a fixed 5-minute ticker

DriftLoop was the last ticker-driven reconcile loop in the event-driven
reconciliation work. It runs on reconciler.Run now, watching the eight KV
buckets LoadIntentFromKV reads, with DriftInterval kept as the resync
backstop and the existing backoff ladder returned as the pass deadline.

Repair latency for a dropped vpc.* event drops from a uniform 0-300s to
the debounce plus a 5s floor. The floor is new and deliberate: a pass
loads eight buckets and scans the OVN NB DB, so without it a launch storm
would turn a five-minute scan into a continuous one.

The buckets are enumerated rather than fixed so watching never creates
one: the EC2 handlers own them and set their history depth.

The bootstrap ReconcileApplyOnly pass stays the startup scan. The loop's
own startup pass does no work and returns the seed deadline instead, so a
clean boot still waits a full interval and a failed one still requeues at
5s.

* fix: keep the intent watchers up when a bucket lookup blips

A per-bucket resolve failure was skipped, which drops the bucket out of
the enumerated set and has the reconciler tear its watcher down until the
next resync. Only a genuine ErrBucketNotFound is a skip now; anything else
fails the enumeration, which is what tells the caller to leave the
watchers it already has in place.

* fix: measure the drift pass floor from the end of a pass, not its start

Stamping before the scan means a pass slower than the floor leaves no gap
at all behind it. The floor is meant to be the minimum time between
passes, so it is stamped once the scan returns.

* test: wait for the intent watcher before the write it must catch
2026-09-01 07:39:17 +10:00
Josh Siviour 4c948512d1 fix: follow ListObjectsV2 continuation tokens on every production caller (#958)
* fix: follow ListObjectsV2 continuation tokens on every production caller

Six ListObjectsV2 call sites (DeleteSnapshot, DescribeSnapshots, EKS
etcd-snapshot selection, DescribeTags, and both key-pair listings) issued a
single request and never read IsTruncated or NextContinuationToken. This was
latent while predastore ignored max-keys and always answered IsTruncated:
false; predastore#117 pages listings at 1000 keys, turning a truncated
listing beyond that into a silently wrong answer rather than a short one.
DeleteSnapshot in particular could delete 1000 objects, report success, and
orphan the rest, and the EKS restore path could resolve to a stale snapshot
if the newest keys fell past the first page.

Add objectstore.ListAll, a shared helper that drains a listing to
exhaustion, accumulating Contents and CommonPrefixes across pages and
erroring instead of returning a partial result if the server claims
truncation with no token to resume from or never finishes within a bounded
page budget. All six call sites, plus the ebsfake test double with the same
shape, now use it. Bump the predastore pin to dev head ff6a1dc4 so a real
cluster picks up the server-side pagination fix this depends on.

* fix: use external test packages and exclude test fakes from diff coverage

test-package-check requires new test files to bind to the exported API so
internals can change without rewriting tests. Move list_test.go and
delete_snapshot_pagination_test.go to their _test packages, adding a small
export_test.go for the one unexported constant a test needs to assert
against. Simplify list_test.go's pagingStore to embed MemoryObjectStore
instead of hand-stubbing unused methods.

Exclude spinifex/testutil from diff-coverage.sh: those files are fakes and
harnesses driven only from other packages' _test.go files, so go test
records them as zero-covered under the package they live in however hard
another package's tests exercise them, which is not a real coverage gap.
2026-08-31 21:59:12 +10:00
Josh Siviour d028637920 refactor: drive the ECS scheduler from the bus and deadlines, not four tickers (#956)
* refactor: drive the ECS scheduler from the bus and deadlines, not four tickers

The scheduler ran four tickers — service reconcile every 10s, heartbeat reaper
every 30s, stopped-task sweep every 60s, instance-role converge every 5m — and
the 10s one rewrote every service record whether or not anything changed.

A KV watch is the wrong tool here: the account bucket takes a heartbeat write
per instance per interval and the key is one NATS token, so no filter can
exclude them. Instead the service reconcile is triggered from the task-state
subscription, which is already separate from the heartbeat one at the bus
layer, and the three timed jobs return their own deadlines.

The service write becomes conditional so an idle service is no longer churned,
and a mutex keeps the two loops from overlapping, which the four tickers got
from sharing one goroutine.

* fix: wake the ECS service reconcile from the path the agent actually uses

The agent stopped publishing to ecs.bus when it moved onto the gateway, so
nothing publishes those subjects any more: the scheduler's task-state
subscription is dead in production and an in-process trigger off it would
never have fired on a live cluster.

A task state report now arrives as SubmitTaskStateChange on whichever worker
serves the call, so that path publishes a payload-free wake on the cluster's
service-reconcile subject and the leader — usually another node — subscribes
to it.

The pass also names its own deadline rather than trusting the wake to arrive:
a service with a task still in flight asks to be revisited at the old 10s
cadence, and only a settled fleet asks for nothing.
2026-08-31 21:18:13 +10:00
Julian Sommer 580777d867 fix: release an ENI's EIP association and reap ENIs whose instance is gone (#955) 2026-08-31 16:44:14 +10:00
Josh Siviour e291d546c0 fix: follow continuation tokens when listing metadata documents (#957)
listDocuments issued a single ListObjectsV2 and iterated result.Contents.
It never read IsTruncated or NextContinuationToken, so above the server's
page limit the listing returned a prefix of the truth and reported success.

Every caller of ListVolumes, ListVolumesStrict, ListAMIs and ListAMIsStrict
inherited it. The strict variants are the worse case: their whole premise
is that a document they did not see is not evidence of absence, and a
truncated page gives them exactly that with no error to notice.

Key collection now walks the prefix to exhaustion before any document is
fetched. A page that claims truncation without a token to resume from is an
error rather than a short answer, since it is the one truncation a client
cannot honour, and a page count bounds a server that keeps claiming there
is more so it fails instead of hanging.

Nil keys are dropped while collecting rather than carried as empty slots
through the fetch, which removes the empty flag from the result struct.
2026-08-31 16:41:27 +10:00
Julian Sommer 6a533eb639 fix: run ping under CAP_NET_RAW instead of escalating it through sudo (#954) 2026-08-31 15:38:54 +10:00
Julian Sommer ce9b6e5b41 fix: gate cluster-wide IPsec on every chassis being configured (#953) 2026-08-31 15:31:59 +10:00
Josh Siviour e9404f1d70 feat: wake the EKS cluster reconciler on the signals it already receives (#952)
The loop polled every 30s for two things it was already being told about: the
bootstrap artifacts are keys in the account bucket a watch can see land, and the
control plane's health arrives over core NATS into a field the reconciler read
only on the next tick.

It now runs on reconciler.Run over a watch of the account bucket, plus a new
Config.Trigger fed by the state-report subscription, and returns a revisit
deadline for what elapsed time alone decides: the create timeout, and the instant
the newest report goes stale. A degraded cluster keeps the old interval rather
than restating the three recovery ladders' grace and backoff arithmetic in a
second place.

The trigger only fires on a report that changes health. The control plane
publishes on its own timer regardless, so forwarding every report would have been
the same 30s tick under another name.
2026-08-31 14:50:32 +10:00
Julian Sommer c4e5ceb5e6 fix: re-read intent before SG and ENI orphan sweeps (#951) 2026-08-31 14:29:14 +10:00
Julian Sommer fd11077b3a fix: skip the guest-port datapath probe when the LSP is absent
applyPorts refuses a port whose SG policy cannot be programmed, but applyEIPs
still ran ensureGuestPortDatapath for every public-IP guest it skipped. GuestPortUp
reports false, not an error, for a port that does not exist, so each refused port
burned the full 45s deadline and ~9 ovn-controller recomputes before giving up.
2026-08-31 13:36:26 +10:00
Josh Siviour 11e0939652 feat: give a reconcile pass its own revisit deadline, and convert the RDS loop (#948)
* feat: give a reconcile pass its own revisit deadline, and convert the RDS loop

The reconciler assumed every loop was a pure function of the buckets it
watches. Quota and DNS are; RDS, EKS and ECS are not. They also wait on an
EC2 VM reaching running, an endpoint answering, and an agent falling silent,
none of which write to KV, so a watch never sees them.

Converting RDS on the watch alone would have traded traffic for detection
latency: an agent going silent writes nothing, so a dead database would have
been noticed on the 5 minute resync rather than within 15s of its heartbeat
expiring.

Reconcile and ReconcileKey now return a revisit deadline, clamped to the
resync. The RDS reconciler returns the earliest across the fleet: the old
interval for a transitional record, the instant the heartbeat expires for a
settled one, the resolve timeout for a snapshot stuck in creating, and
nothing at all for a region with no databases.

* fix: keep a deadline while a dark instance runs down its failure grace

revisitFor measured a settled instance against its heartbeat expiry, and
returned that difference directly. Once the beat expired the difference went
non-positive, which the loop reads as no deadline at all.

That is the window the detector lives in. Between the beat expiring and the
grace closing nothing writes to KV, so there was nothing left to schedule the
pass that does the failing, and a dead database would have waited for the
resync — slower than the ticker this replaced.

The deadline is now the beat expiry while it is still ahead, and whatever
remains of the failure grace once it is not. A failed instance asks for
nothing: it is terminal, and recovery arrives as a heartbeat, which is a write
the watch already delivers. Already due but still not failed is the degraded
reading, dark agent over a live VM, so it retries at the old interval rather
than immediately.
2026-08-31 13:29:31 +10:00
Josh Siviour e30ec82f6a feat: feature-flag the Ochre console (off by default until release) (#950)
* feat: gate the Ochre console behind a runtime flag

Add OchreEnabled to the spinifexui service config and /api/config
body, defaulting to false so the console and every /bedrock/* route
stay hidden until a node explicitly turns it on.

* feat: wire the Ochre flag through to the spinifex-ui service unit

Add a spinifex-ui-ochre-enabled flag (SPINIFEX_UI_OCHRE_ENABLED, off
by default) so the console flag can actually be set on a deployed
node, and bump the service unit so the reconciler picks it up.
2026-08-31 12:58:27 +10:00
Josh Siviour 7052411306 feat: Ochre console — Knowledge Base + data source create/delete (#949)
* feat: add knowledge base and data source create/delete to the console

Ochre's KB pages were read-only. Adds create + delete for knowledge
bases and their S3 data sources, mirroring the guardrail CRUD pattern.

* feat: note data source is added after KB creation

Users expect to pick a data source at create time; the create form now
says up front that S3 data sources are attached from the detail page
after the knowledge base exists, then ingested separately.
2026-08-31 12:48:44 +10:00
Julian Sommer d1de76b630 fix: never create a switch port in zero port groups
applyPorts dropped any security group whose OVN port group was missing and
carried on. When that removed the last one, the ENI's logical switch port was
created belonging to no port group, so no SG ACL applied — including the
priority 900/800 default-denies, which are attached per port group — and the
port was fully unrestricted until a later pass healed it.

Fail closed instead: a port whose policy cannot be programmed in full is
refused and reported unconverged, so the drift loop retries it once applySGs
catches up. The update path is skipped too, leaving a live guest on a stale
policy rather than stripping it bare.

EnsurePort and SetPortSecurityGroups get the same guard for an empty SG list.
Every ENI record written by CreateNetworkInterface has at least one SG, but the
field is omitempty with no migration, so a legacy row would otherwise yield an
unrestricted port.
2026-08-31 12:28:45 +10:00
Julian Sommer f919a257ef fix: make the SG default-deny ACLs match every ethertype (#947) 2026-08-31 12:15:56 +10:00
Julian Sommer 69c7a6ba4a fix: reject unsupported SG protocols instead of emitting an allow-all ACL (#946) 2026-08-31 10:48:08 +10:00
Josh Siviour 6960ef6620 fix: never lower a quota counter from a stale instance view (#943) 2026-08-31 10:46:57 +10:00
Julian Sommer a213af1289 fix(ui): content-hash asset filenames so a deploy cannot mix builds (#945) 2026-08-31 09:59:33 +10:00
Julian Sommer 82ce31626b fix: reject PermissionsBoundary on CreateRole and CreateUser (#944) 2026-08-31 09:51:29 +10:00
Julian Sommer e90dfa4ea3 chore: rebuild ui with latest deps 2026-08-31 09:24:56 +10:00
dependabot[bot] 465231680c chore: bump the npm-dependencies group (#942) 2026-08-31 09:23:42 +10:00
dependabot[bot] 7ff125a343 chore: bump the go-dependencies group with 12 updates (#941) 2026-08-31 09:01:55 +10:00
Josh Siviour 3409d50ec4 feat: sweep quota from the instance record space on the per-key reconciler (#940)
The vCPU sweep ran on a 30s ticker and called the account-filtered
DescribeInstances fan-out once per account per pass, costing
passes x accounts x (nodes + 2) and measured at ~62,000 instance
describes a day, none of it user traffic and none of it tracking how
often anything actually changed.

The account is on the instance record and vCPUs derive from the instance
type, which is spec, so the whole sweep is one read of the record space.
The loop moves onto reconciler.Run: a change to one instance recomputes
that one account, and the resync sweeps every account as the backstop.

The completeness flag has to stay, for a different reason than the one it
was written for. nats.go hardcodes AllowDirect on KV buckets, so a get at
R3 may be served by a lagging replica, and ChargeLaunch increments the
counter while reconcile overwrites it absolutely. A scan that misses a
just-charged launch would lower the counter and lift the cap. The lower
is now written only while the counter still stands at the revision read
before the scan started, so a charge landing mid-scan wins and the scan
is dropped for the next pass.

Two behaviours change with the source. A failed sweep was a per-account
describe error the pass continued past; it is now one read covering every
account, so a failure moves no counter at all. And an account holding
nothing is absent from the scan rather than reporting empty, so the
caller's account list is what zeroes it.

The retype gate and the volume delete guard still use the fan-out; only
the sweep comes off it.
2026-08-30 13:48:04 +10:00
Josh Siviour 423c6bee26 feat: add a per-key path to the reconciler (#939)
The loop watches KV buckets and runs a whole-set recompute when anything
in them changes, discarding the update it already holds. A caller whose
work is per resource can now set ReconcileKey and be handed the key that
changed, so a change to one instance costs one instance's work.

Reconcile stays required. A per-key queue only converges if something
behind it covers what the queue never learned: a key deleted while the
watch was down is enqueued by nothing, because nobody observes an
absence. Keeping the whole-set pass as the startup pass and the resync
backstop is what makes that real, and it makes the change additive — a
caller that sets only Reconcile behaves exactly as before.

KeyFor maps an update onto the caller's unit of work, which is not
always the KV key: fifty instances changing in one account are one
recompute. Reporting ok=false falls back to a whole-set pass, which is
the honest answer for a delete — a tombstone carries no value, so a
caller whose work key lives in the value cannot name it.

A dropped and re-established watch queues the whole set rather than any
key, since UpdatesOnly hides the gap. One worker drains the queue, and a
key taken off it is no longer pending, so a change arriving mid-reconcile
is queued again rather than folded into the pass already running.
2026-08-30 09:52:46 +10:00
Josh Siviour 4804c77946 feat: cut instance records over to one key per instance (#938)
* feat: decide instance set membership from the record, not the key prefix

* feat: cut instance records over to one key per instance

The record space becomes the only copy. Which set an instance belongs to
was structural before this — a stopped instance sat at instance.<id> and a
running one inside the node.<nodeID> blob, so it could not be wrong — and
is now a predicate over the record, decided in one place rather than at
each call site.

The old key spaces are frozen, not deleted. A migration that removed them
would make the crossing unrollbackable, and the node blob is the only
record of what a node was running that a build predating the cutover can
read. instance.<id> and terminated.<id> drain on the delete path.

The per-node key survives as a presence marker at its own prefix. Reads
need it because a node with no instances and a node the cluster has no
record of both scan empty, and only one of them may replace what the node
has locally. It carries no instances, so a state change no longer rewrites
the node's whole running set.

Claiming a stopped instance becomes a compare-and-set. It cannot wait for
a later change: with one key holding an instance for its whole life, a
claim that deletes is deleting the instance it is about to start. The
winner clears the stop intent and leaves LastNode alone, so an instance
whose launch never happens is still recoverable by the node that ran it.

A build predating the cutover now refuses to open a bucket stamped past
what it understands, rather than reading the keys it knows and writing
back a view assembled from half a key space.

* fix: carry node ownership out of the blob key during the cutover migration

The node.<id> key carried two facts in its name: that the node exists, and
which instances are its. The 4->5 migration moved the first and not the
second.

A blob member's own LastNode is empty, because the key was the owner, and so
is last_node on the record copied forward from it. After the cutover
ownership is read off the record, so an unowned record is one no node lists
- and with the presence marker seeded, LoadRunningState returns found=true
with an empty set, which restore adopts over the instances the node is
actually running.

The migration now stamps last_node from the blob's key onto every record in
it that has no owner, and leaves a record that already names one alone.

Found on a live cluster before crossing it. The test helpers built blob
members with LastNode already set, which production blobs never have; the
new tests seed them the way the bucket actually holds them.
2026-08-30 08:46:29 +10:00
Josh Siviour 6dd1862476 feat: add guardrail create, edit and delete to the console (#937)
* feat: add guardrail create, edit and delete to the console

Adds CRUD for Bedrock guardrails alongside the existing read-only view:
create and edit forms covering topic, content, word, PII, regex and
contextual-grounding policies, a delete confirmation flow, and a
create-version action, all built on the same real-SDK client pattern
already used for guardrail reads and runtime testing.

* fix: mount the guardrail edit form instead of no-opping the link

The guardrail detail route file dot-nested under the edit route
(detail as parent, edit as child), but the detail page renders no
outlet, so navigating to the edit URL matched the route without ever
mounting the edit form. Renaming the detail route file to an index
sibling of edit fixes the mount without changing any full path.
2026-08-28 16:03:31 +10:00
Josh Siviour 3680e5b5bc feat: render predastore admin_port and gate deploys on /readyz (#936)
* feat: render predastore admin_port and consult /readyz in health probe

Spinifex now allocates predastore's admin listener port (8660, after the
existing gate/blob/meta bases) and renders admin_port into both the
single-node and multi-node predastore.toml templates. The daemon's health
probe consults /readyz over that port on the two weak paths (single-host
gate fallback and hosts running no meta node), falling back to the prior
behavior when no admin port is configured or the listener refuses.

* build: pin predastore to the release carrying Host.AdminPort

The admin port field this branch reads is new in predastore, so CI could not
compile against the pinned version: go.work hid the gap locally by resolving
predastore to the checkout instead of the module.
2026-08-28 15:19:35 +10:00
Josh Siviour 1d0a53a4b8 feat: add admin Model Access console page (#935)
* feat: add admin Model Access console page

Lets an admin grant or revoke a provider-tier model for a tenant
account from the console, cross-referencing the model catalog with
that account's current grants via the existing signed-proxy actions.

* feat: replace free-text account id with a real account picker

Sources the Model Access account selector from /admin/ListAccounts
via a new signed JSON admin-API fetch helper, sorted by name and
excluding the global account. Falls back to a validated 12-digit
free-text account id when enumeration is unauthorized or empty, since
that call requires a long-lived IAM user in the super-admin account.
2026-08-28 14:43:57 +10:00
Josh Siviour da0216767c feat: mirror each node's running set onto the i.<id> key space (#933)
* feat: mirror each node's running set onto the i/<id> key space

* refactor: re-key instance records onto a dot separator

A reconciler Filter is a NATS subject filter, where "*" matches one
dot-delimited token. "i/<id>" is a single token, so "i/*" matches nothing
and a watch over the record space would have had to take the whole bucket
with ">" — waking on every key rather than on instance state, which is
worse than the "node.*" it replaces. "i.<id>" is two tokens, so "i.*" is
the filter the DNS reconcile can narrow to at the cutover.

The space stays disjoint from the three it replaces for the same reason
the slash did: List and DeletePrefix match a plain string prefix, and
"instance.i-1" does not begin "i.".

Each bucket gains a re-key step that moves "i/<id>" to "i.<id>" and
deletes the old key. Deleting is safe under the shadow rule: a node still
writing slashes also reads them, so what it loses is a mirror, and a
missing mirror falls back to the key it mirrors rather than erroring.

A pair of tests asserts both halves — that "i.*" delivers a record write
and that "i/*" does not — so the reasoning that chose the separator is
checked rather than trusted.

* test: benchmark what the node blob costs per state change

The case for splitting the blob is that one instance changing state
rewrites every instance on the node, and that cost grows with N while the
record's does not. That was asserted rather than measured.

Two benchmarks measure both sides on a record sized from env19, which
averages around 10KB terminated: marshalling the blob at N of 1, 10, 50
and 100, and marshalling one record. Each reports the bytes it produced
alongside the time, so the amplification is readable from the output
rather than needing a second calculation.

There is no assertion, because there is no threshold worth failing a
build on. Benchmarks compile in CI and run only when asked, which is what
a measurement wants — a test that cannot fail would run every pass and
gate nothing.
2026-08-28 13:38:10 +10:00
Josh Siviour fb499a8c6a fix: cover the embedder cold-start window with retry and Retry-After (#934)
* fix: cover the embedder cold-start window with retry and Retry-After

Guardrail and knowledge-base embed calls hard-failed with a raw 503
whenever the embedding endpoint's connection was refused mid-relaunch,
instead of riding out the short window until it accepted connections.

embedBatch now retries a bounded number of times on connection-refused
or connection-reset, and any resulting 503 carries a Retry-After header
so callers back off instead of hammering the endpoint. A background
warm-up probe gates the embed path for statically pinned endpoints,
logging once when the endpoint first becomes reachable. Guardrail and
embedder infra logs now carry service/action labels for easier triage.

Fail-closed guardrail semantics are unchanged: an unavailable embedder
still denies unverified content, this only changes how quickly it
recovers once the endpoint is reachable again.

* fix: re-verify a persisted READY endpoint's liveness before resolving it

DynamicEndpointResolver's StateReady branch trusted the KV record's base
URL unconditionally. After an awsgw restart the record can still say
READY while the co-resident engine on that VM is mid-rebind, so the
resolver handed callers an address nothing was listening on yet.

describeAndEnsure now re-probes a READY member's own readiness route
before trusting it, short-TTL cached so a warm model still resolves at
no extra cost per request. A member that is not answering falls through
to the same not-ready path as a launch already in flight, so guardrail
and embed callers get the existing retryable answer instead of dialing
a dead address. This applies at the resolver seam, so it covers every
co-served member, not just the embedder.
2026-08-28 13:04:25 +10:00
Josh Siviour 3f55b89cd1 feat: dual write and read the i/<id> instance record key space (#932)
* feat: dual write and read the i/<id> instance record key space

* fix: make the i/<id> key space a shadow until the cutover
2026-08-28 11:04:49 +10:00
Josh Siviour 9799ea410c fix: preserve escaped path through the console proxy for SigV4 (#931)
The spinifex-ui reverse proxy copied only the decoded URL path and dropped
RawPath, so a percent-encoded path segment (a ':' in a bedrock modelId) was
re-encoded before reaching the gateway. The gateway then canonicalised a
different path than the one the browser SDK signed, and SigV4 rejected the
request with a signature mismatch — surfacing as an unparseable '<?xml'
deserialization error in the console Playground.

Carry RawPath through the rewrite so the escaped path arrives byte-identical.
Also route the auth, cluster-unavailable, and throttle error emitters through a
shared jsonErrorService helper so the bedrock family receives AWS JSON 1.1
errors the SDK can parse, and stamp the signed service onto the request before
verification so a rejection renders in the client's format.
2026-08-28 10:47:40 +10:00
Josh Siviour aa997b7b45 feat: Ochre console Playground page (#929)
* feat: add Ochre console Playground page

A Converse chat surface with model picker, system prompt, inference params,
optional guardrail, per-turn tokens, raw request/response JSON, and copy-as-CLI.
The transport lives in one module so a later ConverseStream swap edits a single
file; the message list appends and mutates the last turn in place. A cold model
degrades to a retryable warming-up turn instead of an error.

* feat: pin the playground composer, send on Enter, render markdown

The chat composer sat below the message list in page flow, so it slid down as
the conversation grew and the reader had to scroll to reach it. Bound the
chat + composer to a fixed-height column: the message pane scrolls internally
and autoscrolls to the newest turn while the composer stays pinned.

Enter now sends (Shift+Enter inserts a newline), and assistant replies render
markdown — lists, inline code, code blocks, and links — instead of raw text.
2026-08-28 10:47:22 +10:00
Josh Siviour 82dd056fa2 feat: add the i/<id> instance record store (#930)
Adds the accessors for the per-resource instance record key space, over both
buckets that carry instance records. Nothing calls them yet.

The prefix is disjoint from the three it replaces: List and DeletePrefix match
on a plain string prefix rather than on NATS subject tokens, so i/ shares a
bucket with instance., node. and terminated. without colliding with any of them.

mutateVM and listVMs become generic so the record accessors reuse them rather
than carrying a second copy.
2026-08-28 09:21:11 +10:00
Josh Siviour a6a8d30349 feat: add the instance spec/status record type (#928)
InstanceRecord is resource.Object[InstanceSpec, InstanceStatus], the persisted
form of an instance under the per-resource key space the re-key introduces.
Nothing reads or writes it yet: the boundary is settled and tested before the
keys move onto it.

The wrappers around the EBS and ENI request slices are dropped on the way in
and rebuilt on the way out, so the record does not carry a mutex.

Instance and Reservation are carried whole. They are SDK types returned
verbatim by DescribeInstances and hold both halves in one object, so the
boundary does not hold there and the comment says so.
2026-08-28 08:54:28 +10:00
Josh Siviour 266ef05c56 feat: add the resource envelope for spec/status records (#927)
Object[Spec, Status] separates what an operator asked for from what a node
observed, with the identity and lifecycle bookkeeping common to both.

Generation is bumped only through MutateSpec so NeedsSync means something, and
ObserveGeneration records the generation the node acted on rather than the
current one, so a spec change landing mid-reconcile is not marked done.

No ARN or tag helpers: the arn package already builds ARNs per service, and the
tag helpers in utils already cover the map every record carries.
2026-08-28 07:48:48 +10:00
Josh Siviour 1a49f15283 refactor: give StateReason and the hostfwd map a single writer each (#925)
StateReason was cleared by ModifyInstanceAttribute, so the API wrote a field
the node owns. Move the clear onto the launch path.

ExtraHostfwd held the requested guest port and the bound host port in one map,
with zero standing in for both 'not bound yet' and a real value. Split it into
HostfwdPorts and HostfwdPortMap, rebuilt per launch, and fold legacy records on
decode.
2026-08-27 23:30:33 +10:00
Josh Siviour 81c73573bc refactor: replace persisted command attributes with desired state (#924)
The command an instance was last handled with was stored on the record, but
only stop_instance and delete_instance were ever read back. Replace them with
DesiredState and DeletionTimestamp, which say what the read sites actually
mean, and drop the other twelve booleans.

Legacy records are folded on decode so an operator-stopped instance is not
relaunched on upgrade.
2026-08-27 23:30:33 +10:00
Josh Siviour b94ed1285d feat: add awserrors.IsTerminal for reconcile retry decisions (#923) 2026-08-27 22:07:05 +10:00
Josh Siviour da5b7de2bb feat: open staged self-host models to all accounts (#922)
A staged self-host model is granted to every account without a per-account
grant, making the admin import the only v1 gate. Wrap the access resolver so
every gate opens together; the admin catalog reports self-host state
weights-first. Per-account grants remain as a dormant seam.
2026-08-27 21:42:35 +10:00
Josh Siviour 7e36922931 refactor: store the node instance-state record in the local state envelope (#921) 2026-08-27 21:42:18 +10:00
Josh Siviour 196b7c8111 refactor: report kvstore.ErrNotFound from the StateStore interfaces (#920) 2026-08-27 21:41:50 +10:00
Josh Siviour 5760b74bf6 feat: add Ochre Model Catalog console page (#918)
Operator-only catalog page listing every model with modality, streaming,
price, VRAM/instance, co-serve group, and availability with a fix-affordance
hint per reason. Reads the ListOchreCatalog admin action, mirroring the
existing admin-service query pattern.
2026-08-27 21:20:47 +10:00
Josh Siviour abb817c7d5 feat: add admin Ochre catalog read and drop provider-direct model (#917)
Expose per-model availability and reason on an operator-only ListOchreCatalog
action for the console, keeping the tenant ListFoundationModels omission
intact. Remove the unbuilt provider-direct entry from the v1 catalog.
2026-08-27 21:13:44 +10:00
Josh Siviour d5d5d3defc refactor: move instance state onto kvstore typed stores (#916) 2026-08-27 20:59:25 +10:00
Josh Siviour 286e11da03 feat: recover kvstore buckets from stream loss (#915) 2026-08-27 16:19:41 +10:00
Julian Sommer 8336a73e4d fix: stop the leader-bucket test racing its own create advisory
The server queues the API audit advisory after the reply the caller
waits on, so subscribing once the bucket exists could still catch the
create the first call made. Watch from the start and account for it.
2026-08-27 16:12:22 +10:00
Julian Sommer 9133c48fdb fix: wait for the EC2 command outcome instead of racing the handler
The handler records its request point after it replies, so asserting as
soon as Request returns could read the metric before it landed. Poll for
the outcome the way the sibling drain test does.
2026-08-27 16:02:26 +10:00
Julian Sommer 4ae281bd1f fix: give reachable-agent RDS tests a realistic apply-params budget
The 40ms apply-params timeout was shared by the tests that expect the
agent to answer and those that expect it not to, so a parallel coverage
run could miss the reply. Only the unreachable-agent cases keep the
short budget.
2026-08-27 15:56:44 +10:00
Josh Siviour c9da4221b9 refactor: move stream-loss detection into kvutil (#914) 2026-08-27 15:52:48 +10:00
Josh Siviour fbbb9fb30b fix: route every running-state write through the local file (#912) 2026-08-27 15:20:48 +10:00
Julian Sommer c81abd1836 fix: add fipsboot guard to missing services 2026-08-27 15:05:49 +10:00
Julian Sommer 8c7e3dfa7b refactor: use bluebottle/pkg/safecast for the numeric conversions (#913) 2026-08-27 14:55:44 +10:00
Josh Siviour 68156c7348 feat: semantic topic detection for guardrails via embedding similarity (#908)
* feat: semantic topic detection for guardrails via embedding similarity

Guardrail topic policy previously only blocked on an exact word-boundary
match against a denied topic's name or listed examples, so a paraphrase or
synonym for a blocked topic sailed straight through. This adds an
embedding-similarity check: each denied topic's name, definition, and
examples are embedded and cached, incoming text is embedded and compared by
max cosine similarity against those phrase vectors, and a topic blocks when
that similarity clears a conservative threshold. The literal exact-match
check still runs first and always blocks on its own. If no embedder is
configured, or an embed call fails, the check falls back to the existing
literal match alone rather than ever failing open.

* fix: fail closed when guardrail topic embedding is unavailable

The topic policy's semantic check silently fell back to its literal-only
matcher whenever the embedder was configured but temporarily unreachable
(for example during model cold-start), so a paraphrase of a denied topic
could pass through unblocked while the embedding endpoint was down. Now that
case is distinguished from an unconfigured embedder: if literal matching
already caught the request, that block still wins outright. Otherwise, when
the embedder is wired but the embed call itself fails, the guardrail check
returns an error and the caller aborts the request rather than letting
unverified content through.
2026-08-27 14:54:42 +10:00
Julian Sommer 95afec6a76 refactor: use bluebottle/pkg/fipsboot for the FIPS 140-3 startup guard (#911) 2026-08-27 14:38:36 +10:00
Julian Sommer 97acfbbd80 refactor: use bluebottle/pkg/tlsconfig for the TLS curve allowlist (#910) 2026-08-27 14:31:29 +10:00
Josh Siviour fc1e950518 fix: keep a node's instances when its KV record is absent at boot (#909) 2026-08-27 14:26:31 +10:00
Josh Siviour f410b1b868 feat: converge DNS on change rather than on a timer (#902)
* feat: converge DNS on change rather than on a timer

The drift backstop now watches the four stores dnsDesiredSet reads: instance state, ELBv2 load balancers, and the per-account EKS and RDS buckets. Only the trigger changes; the leader election, computeBatch and the publish path are untouched.

The interval becomes the backstop for drift the buckets cannot report, such as a record edited directly in the zone object, rather than the path by which a lifecycle event reaches DNS.

* test: cover the DNS watch wiring and reconciler run path
2026-08-27 12:43:40 +10:00
Josh Siviour 63f47b202b feat: Ochre console — Guardrails page (#906)
* feat: add Ochre console Guardrails page

* fix: treat guardrail action BLOCKED without detected flag as blocked
2026-08-27 12:33:40 +10:00
Julian Sommer 347c2b2f80 fix: emit required IAM list members when nothing matches
ListUsers, ListRoles, ListInstanceProfiles and ListGroupsForUser built their
results from a nil slice when the filtering loop matched nothing. xmlutil
drops a nil slice entirely rather than emitting an empty element, so each
response omitted a member its output shape marks required.

Each operation's empty-bucket shortcut already returned an empty non-nil
slice, so only the filtering path was affected. Start those slices empty and
non-nil too.

ListAttachedGroupPolicies, ListAttachedRolePolicies, ListPolicies and
ListAttachedUserPolicies declare no required output members, so their nil
slices are legal and are left alone.
2026-08-27 11:51:35 +10:00
Julian Sommer fae6b08acc fix: conform DescribeImages BootMode and IAM list required members
DescribeImages projected BootMode through aws.String unconditionally, so an
AMI recording none emitted "", which is outside the model enum. RegisterImage
takes BootMode as an optional input, so this is the normal path for an AMI
registered without one, not stale data. Set the member only when present.

ListAccessKeys and ListGroups built their results from a nil slice when
nothing matched. xmlutil drops a nil slice entirely rather than emitting an
empty element, so the response lost a member the model marks required. Start
both slices empty and non-nil.

Integration conformance drops from 18 violations to 15; the remainder are the
EC2 AccessDenied error-code findings, which are unrelated.
2026-08-27 11:46:39 +10:00
Julian Sommer 23f7081a2a test: pin the ListVolumes/ListSnapshots page-size clamp
The conformance subtest asserting the clamp ran against a provider holding
no volumes, so len(resp.Volumes) <= MaxListResults held whatever PageSize
did. Removing the clamp branch left the suite green.

Add a unit test on PageSize covering above the cap, at it, below it, and at
or below zero, and give the conformance subtests a fixture so the claim they
keep, that an over-cap request returns a page rather than an error, can fail.
2026-08-27 11:34:40 +10:00
Julian Sommer 40a73a8bba fix: project instance status state through vm.EC2APIState
buildInstanceStatus read vm.EC2StateCodes directly, so a StateError instance
surfaced the non-AWS name "error" while DescribeInstances returned "stopped"
for the same instance.
2026-08-27 11:08:57 +10:00
Julian Sommer 35a00a14be fix: project Placement.AvailabilityZone for every instance (mulga-2ep36) 2026-08-27 10:58:05 +10:00
Julian Sommer 62c1378b79 fix: raise the Ubuntu minimum to 24.04 (mulga-ucvsq)
Ubuntu 22.04 was accepted by detect_os but the runtime apt list has never
resolved on jammy, which ships dhcpcd5 rather than dhcpcd-base. No CI cell
or E2E matrix installs on it, and the install docs already claim Ubuntu
26.04 and Debian 13 only, so drop the claim instead of carrying a
jammy-conditional package branch for an untested suite.
2026-08-27 10:49:46 +10:00
Julian Sommer d82ee71b54 refactor: drop duplicated bluebottle logic and hand-rolled token buckets (#905) 2026-08-27 10:29:06 +10:00
Josh Siviour db2bf8cf0d feat: Ochre console — Knowledge Bases page (mulga-0t256) (#901)
* feat: add Ochre console Knowledge Bases page

Read-only Knowledge Bases section for the Ochre console, plus the
one-time bedrock-agent client/query plumbing later Ochre pages reuse.

* feat: render retrieve tester chunks in a monospace scroll block
2026-08-27 09:45:46 +10:00
Julian Sommer 0362cdf371 refactor: drop the forked HTTP middleware for the shared one (#904) 2026-08-27 09:30:40 +10:00
Julian Sommer 2da27c4e2a fix: return InsufficientInstanceCapacity with a 5xx status (#903) 2026-08-27 09:14:30 +10:00
Julian Sommer 3bf0379daa fix: gate sts:AssumeRole on the caller's identity policy (#889) 2026-08-27 09:04:25 +10:00
Josh Siviour 6fd9b17f62 feat: add kvstore watch and the reconciler loop (#900)
Watch is used zero times in spinifex against 33 ticker loops. This adds the primitive on Bucket, which already owns the memoised handle, plus the watch/debounce/resync/re-establish loop, so each converted caller changes only its trigger.

Watches are established before the first pass: a change landing between the two would otherwise wait for the next resync, since the pass that would have seen it ran before the watch existed.
2026-08-27 08:53:21 +10:00
Josh Siviour 2d370081c3 refactor: route bedrock serving endpoints through kvstore (#899)
Service.bucket() resolved a JetStream client and did a get-or-create round trip on every KV operation; kvstore.Store memoises the handle per Service.

Add Store.Purge, since the endpoint records are deleted and re-created under the same key and Create is the single-writer claim: a delete marker is not the same starting state as an absent key.
2026-08-27 00:17:26 +10:00
Josh Siviour 8dddf9d55e refactor: convert the bedrock access, credential and usage stores to kvstore (#898) 2026-08-26 23:55:58 +10:00
Josh Siviour f0b650a83b refactor: convert the bedrock guardrail and provisioned stores to kvstore (#897) 2026-08-26 23:38:33 +10:00
Josh Siviour 526cd2d070 feat: enforce guardrail topicPolicy denied-topics at inference (#896) 2026-08-26 23:33:58 +10:00
Josh Siviour 70414154c5 refactor: convert the bedrock weights, prices and logging stores to kvstore (#895) 2026-08-26 23:23:25 +10:00
Josh Siviour e24fd42bdc refactor: convert the ochrevector appliance to kvstore (#894) 2026-08-26 23:02:13 +10:00
Josh Siviour e54b79b7bf fix: harden ochre ingest progress, job cancel, and retrieve error typing (#893)
Persist ingestion progress and bound per-document work with a timeout so a
job on a dead backend fails fast and visibly instead of sitting silently
IN_PROGRESS; add StopIngestionJob so a stuck job can be cancelled; map a
backend-down Retrieve/RetrieveAndGenerate error to 503 instead of an opaque
500.
2026-08-26 23:01:57 +10:00
Josh Siviour d1c9f5c6ae refactor: convert the ochrevector registry and kb stores to kvstore (#892) 2026-08-26 22:36:19 +10:00
Josh Siviour 9e450ac94b test: verify embedding sub-batching splits oversized input into bounded requests (#891) 2026-08-26 22:08:04 +10:00
Josh Siviour 5fc4eb0289 refactor: convert the acm certificate store to kvstore (#890) 2026-08-26 22:07:46 +10:00
Josh Siviour d43c0e4302 feat: add kvstore typed store and convert the ochrevector job store (#888) 2026-08-26 15:16:28 +10:00
Julian Sommer 46d8c03293 fix: pin predastore to the dev build that drops ExtractPolicyName
The bluebottle bump in #887 removed pkg/auth.ExtractPolicyName, which
predastore v1.18.0 still calls, breaking e2e-build on the runner.
2026-08-26 15:08:35 +10:00
Julian Sommer 08da638375 refactor: route policy ARN parsing through the shared bluebottle parser (#887) 2026-08-26 14:51:18 +10:00
Josh Siviour 155f869d23 refactor: convert placement group CAS loops to kvutil (#886) 2026-08-26 13:19:21 +10:00
1728 changed files with 88389 additions and 16326 deletions
+59
View File
@@ -0,0 +1,59 @@
name: Bug report
description: Something is not working as it should.
labels: ["bug"]
body:
- type: dropdown
id: component
attributes:
label: Component
options:
- EC2 / instances
- EBS / volumes
- S3 / object store
- VPC / networking
- IAM / authentication
- EKS
- API gateway
- Web UI
- CLI / daemon
- Observability / telemetry
- Build / CI / packaging
- Documentation
- Other / not sure
validations:
required: true
- type: input
id: version
attributes:
label: Spinifex version
description: Output of `spinifex --version`, or the commit SHA you are on.
placeholder: v1.0.0, or commit abc1234
validations:
required: true
- type: textarea
id: what-happened
attributes:
label: What happened
description: What you saw, what you expected instead, and how to reproduce it.
validations:
required: true
- type: textarea
id: logs
attributes:
label: Logs and output
description: Relevant log lines, error messages or command output.
render: shell
- type: textarea
id: platform
attributes:
label: Platform
description: Where you are running it, and how you deployed it.
placeholder: |
OS: Debian 13
Kernel: 6.12
Arch: amd64
Deployment: single node, systemd
+11
View File
@@ -0,0 +1,11 @@
name: Feature request
description: Suggest something new.
labels: ["enhancement"]
body:
- type: textarea
id: proposal
attributes:
label: What would you like to see?
description: What you are trying to do, and what stops you today.
validations:
required: true
+20
View File
@@ -0,0 +1,20 @@
name: Documentation
description: Something in the docs is wrong, missing or unclear.
labels: ["documentation"]
body:
- type: input
id: location
attributes:
label: Where
description: Link to the page, or the path to the file in this repository.
placeholder: https://docs.mulgadc.com/..., or README.md
validations:
required: true
- type: textarea
id: problem
attributes:
label: What is wrong
description: What is inaccurate, missing or confusing, and what it should say instead.
validations:
required: true
+5
View File
@@ -0,0 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: Documentation
url: https://docs.mulgadc.com
about: Guides, configuration reference and API compatibility notes.
+70 -21
View File
@@ -91,9 +91,12 @@ type SuiteReport struct {
Label string // short name, e.g. "single"
Total int
FailCount int
Root *Failure // nil if no failures
Cascades []Failure // everything else that failed in this suite
Buckets [][]Failure // failures grouped by signature (root's bucket first)
// Unresolved counts testcases go-junit-report could not find an outcome
// for. They are not failures and the suite can be green with hundreds.
Unresolved int
Root *Failure // nil if no failures
Cascades []Failure // everything else that failed in this suite
Buckets [][]Failure // failures grouped by signature (root's bucket first)
// EndedAt is go-junit-report's <testsuite timestamp>, which records when
// the XML was produced — i.e. when the suite finished, not when it began.
EndedAt time.Time
@@ -131,21 +134,22 @@ var cascadeMarkers = []string{
"Expected value not to be nil",
}
// teardownPatterns match the content a test logs from t.Cleanup or from the
// harness's on-failure diagnostics — i.e. after it has already failed. They are
// the LAST file:line lines in the failure body, so extractErrorLine's
// keep-the-last rule picks them over the assertion unless they are skipped.
// Anchored deliberately: a fragment loose enough to match an assertion would
// blank out the one line the report exists to show.
var teardownPatterns = []*regexp.Regexp{
// nonFailurePatterns match content a test emits AFTER the assertion that failed
// — t.Cleanup, harness diagnostics, or a t.Logf on a passing branch — which
// extractErrorLine's keep-the-last rule would otherwise pick over the assertion.
// Go emits t.Logf and t.Errorf identically, so anchor every entry: a fragment
// loose enough to match an assertion blanks out the line the report exists to
// show. `\bas expected\b` unanchored also matches "did not fail as expected".
var nonFailurePatterns = []*regexp.Regexp{
regexp.MustCompile(`^DB instance \S+ is gone$`),
regexp.MustCompile(`^db diagnostics \S+:`),
regexp.MustCompile(`^\S+: management bridge \S+:\d+ refused as expected$`),
}
// isTeardownLine reports whether a candidate error line was emitted after the
// failure it would otherwise be mistaken for.
func isTeardownLine(content string) bool {
for _, re := range teardownPatterns {
// isNonFailureLine reports whether a candidate error line was emitted after the
// failure it would otherwise be mistaken for, or reports an expected outcome.
func isNonFailureLine(content string) bool {
for _, re := range nonFailurePatterns {
if re.MatchString(content) {
return true
}
@@ -242,12 +246,12 @@ func extractErrorLine(body string) string {
// progress lines from the same file:line pattern before the
// failing assertion finally fires. Cleanup runs after it, though,
// so its lines are last of all and have to be skipped.
if content := strings.TrimSpace(m[1]); !isTeardownLine(content) {
if content := strings.TrimSpace(m[1]); !isNonFailureLine(content) {
goFileLine = content
}
continue
}
if fileHintRe.MatchString(l) && !isTeardownLine(l) {
if fileHintRe.MatchString(l) && !isNonFailureLine(l) {
lastTestLine = l
}
}
@@ -293,15 +297,23 @@ func extractFileHint(body string) string {
}
// Scanned line by line rather than across the body so a cleanup line's own
// file:line does not become the site the report points a reader at.
hint := ""
hint, filtered := "", ""
for _, raw := range strings.Split(body, "\n") {
l := strings.TrimSpace(raw)
if m := goFileLineRe.FindStringSubmatch(l); m != nil && isTeardownLine(strings.TrimSpace(m[1])) {
all := fileHintRe.FindAllString(l, -1)
if len(all) == 0 {
continue
}
if all := fileHintRe.FindAllString(l, -1); len(all) > 0 {
hint = all[len(all)-1]
if m := goFileLineRe.FindStringSubmatch(l); m != nil && isNonFailureLine(strings.TrimSpace(m[1])) {
filtered = all[len(all)-1]
continue
}
hint = all[len(all)-1]
}
// A body whose only file:line was allowlisted still gets a pointer: coarse
// beats none, and extractErrorLine has the same last-resort shape.
if hint == "" {
return filtered
}
return hint
}
@@ -366,7 +378,7 @@ func computeLeafSet(cases []junitTC) map[string]bool {
func computeRolledUpSet(cases []junitTC) map[string]bool {
rolledUp := make(map[string]bool, len(cases))
for _, tc := range cases {
if tc.Failure == nil && tc.Error == nil {
if tc.Failure == nil && !isRealError(tc.Error) {
continue
}
// Mark every ancestor: the failure belongs to the deepest case that
@@ -412,6 +424,18 @@ func parseStartTime(s string) time.Time {
return t
}
// noResultMessage is go-junit-report's marker for a testcase it saw start and
// never saw finish. On cell-20 it accounted for 93 of the 120 testcases and 24
// of the 25 reported failures, none of which had failed.
const noResultMessage = "No test result found"
// isRealError separates a genuine <error> from the parser's no-result marker.
// A parent must not be treated as rolled up by a child whose outcome is merely
// unknown, or the parent's own failure is dropped along with it.
func isRealError(e *junitFailure) bool {
return e != nil && strings.TrimSpace(e.Message) != noResultMessage
}
// suiteLabel turns "junit-single.xml" into "single".
func suiteLabel(path string) string {
base := filepath.Base(path)
@@ -457,6 +481,17 @@ func ParseFile(path string, data []byte) (SuiteReport, error) {
case tc.Failure != nil:
body = tc.Failure.Body
case tc.Error != nil:
// go-junit-report emits this when a `=== RUN` line has no
// matching result line. It means the outcome is unknown, not
// that the test errored, and it attaches the test's own log
// output — which then reads as an assertion.
if !isRealError(tc.Error) {
rep.Unresolved++
if leaf {
cumul += tc.Time
}
continue
}
body = tc.Error.Body
default:
if leaf {
@@ -553,6 +588,18 @@ func ParseFile(path string, data []byte) (SuiteReport, error) {
}
// Render writes the markdown report.
// writeUnresolved names the testcases whose outcome the parser could not read.
// Worth surfacing because a suite that leaves most of its subtests unresolved
// is reporting on far less than its test count suggests.
func writeUnresolved(b *strings.Builder, s SuiteReport) {
if s.Unresolved == 0 {
return
}
fmt.Fprintf(b, "ℹ️ %d of %d testcases have no result line in the log, so `go-junit-report` "+
"could not read their outcome. Not failures — but this suite reports on %d tests, not %d.\n\n",
s.Unresolved, s.Total, s.Total-s.Unresolved, s.Total)
}
func Render(r Report) string {
var b strings.Builder
fmt.Fprintf(&b, "## %s\n\n", r.Title)
@@ -579,9 +626,11 @@ func Render(r Report) string {
for _, s := range r.Suites {
if s.FailCount == 0 {
fmt.Fprintf(&b, "### Suite `%s`: ✅ pass (%d tests)\n\n", s.Label, s.Total)
writeUnresolved(&b, s)
continue
}
fmt.Fprintf(&b, "### Suite `%s`: %d failed, 1 root cause likely\n\n", s.Label, s.FailCount)
writeUnresolved(&b, s)
if s.Root != nil {
// XML order is completion order, so "earliest" only means earliest
@@ -141,6 +141,46 @@ func TestExtractErrorLine_SkipsCleanupLoggedAfterTheFailure(t *testing.T) {
}
}
// A subtest looping over two subjects calls t.Errorf for the first and t.Logf
// for the second, so the success line is last in the body. Nightly run
// 34383800404 reported that log line as the failure.
func TestExtractErrorLine_SkipsSuccessLoggedAfterTheFailure(t *testing.T) {
body := ` isolation_test.go:80: rds-e2e-iso-a-1788981456: management bridge 10.15.8.10:5432 could not be tested: want connection refused, got dial tcp 10.15.8.10:5432: i/o timeout
isolation_test.go:84: rds-e2e-iso-b-1788981456: management bridge 10.15.8.11:5432 refused as expected`
got := extractErrorLine(body)
if !strings.Contains(got, "could not be tested") {
t.Errorf("extractErrorLine = %q, want the assertion at isolation_test.go:80", got)
}
if hint := extractFileHint(body); hint != "isolation_test.go:80" {
t.Errorf("extractFileHint = %q, want isolation_test.go:80", hint)
}
}
// "as expected" reads the same in an affirmation and in its negation, so an
// unanchored pattern suppresses the assertion it was meant to outrank and
// promotes a harness progress line in its place.
func TestExtractErrorLine_KeepsNegatedExpectationAssertions(t *testing.T) {
body := ` isolation_test.go:60: waiting for the db instance to become available
isolation_test.go:80: rds-e2e-iso-a: management bridge 10.15.8.10:5432 was not refused as expected: got a connection`
got := extractErrorLine(body)
if !strings.Contains(got, "was not refused as expected") {
t.Errorf("extractErrorLine = %q, want the assertion at isolation_test.go:80", got)
}
if hint := extractFileHint(body); hint != "isolation_test.go:80" {
t.Errorf("extractFileHint = %q, want isolation_test.go:80", hint)
}
}
// Every candidate allowlisted leaves extractFileHint with nothing to return,
// and unlike extractErrorLine it has no last-resort branch. A coarse pointer
// beats sending the reader to no file at all.
func TestExtractFileHint_FallsBackWhenEveryCandidateFiltered(t *testing.T) {
body := ` isolation_test.go:84: rds-e2e-iso-b: management bridge 10.15.8.11:5432 refused as expected`
if hint := extractFileHint(body); hint != "isolation_test.go:84" {
t.Errorf("extractFileHint = %q, want isolation_test.go:84", hint)
}
}
func TestExtractFileHint_PicksLastMatch(t *testing.T) {
body := "ec2helpers.go:50: setup\n vpc_test.go:227: Eventually: condition not met"
got := extractFileHint(body)
@@ -174,6 +214,55 @@ func TestParseFile_SkipsEmptyParentFailure(t *testing.T) {
}
}
// go-junit-report emits <error message="No test result found"> for a `=== RUN`
// with no matching result line, and attaches the test's own log output. On a
// baremetal cell that was 93 of 120 testcases and 24 of 25 reported failures,
// none of which had failed: "ALB internal: 20/20 successful" read as an error.
func TestParseFile_NoResultFoundIsNotAFailure(t *testing.T) {
xml := []byte(`<?xml version="1.0"?>
<testsuites><testsuite name="" tests="3" failures="1" errors="2">
<testcase name="TestLB/Internal_ALB" time="0.0"><error message="No test result found"><![CDATA[
lb_test.go:88: ALB internal: 20/20 successful, 2 unique
]]></error></testcase>
<testcase name="TestLB/Internal_NLB" time="0.0"><error message="No test result found"></error></testcase>
<testcase name="TestX/RealFailure" time="0.5"><failure message="Failed"><![CDATA[
foo_test.go:10:
Messages: real assertion message
]]></failure></testcase>
</testsuite></testsuites>`)
sr, err := ParseFile("junit-x.xml", xml)
if err != nil {
t.Fatalf("ParseFile: %v", err)
}
if sr.FailCount != 1 {
t.Fatalf("FailCount = %d, want 1 (no-result entries are not failures)", sr.FailCount)
}
if sr.Unresolved != 2 {
t.Fatalf("Unresolved = %d, want 2", sr.Unresolved)
}
if sr.Root == nil || sr.Root.Name != "TestX/RealFailure" {
t.Fatalf("expected the real failure as root, got %+v", sr.Root)
}
}
// An <error> that is not the parser's no-result marker is a genuine error and
// must still be reported.
func TestParseFile_KeepsARealError(t *testing.T) {
xml := []byte(`<?xml version="1.0"?>
<testsuites><testsuite name="" tests="1" failures="0" errors="1">
<testcase name="TestX" time="0.5"><error message="panic"><![CDATA[
foo_test.go:10: panic: runtime error: index out of range
]]></error></testcase>
</testsuite></testsuites>`)
sr, err := ParseFile("junit-x.xml", xml)
if err != nil {
t.Fatalf("ParseFile: %v", err)
}
if sr.FailCount != 1 || sr.Unresolved != 0 {
t.Fatalf("FailCount = %d, Unresolved = %d, want 1 and 0", sr.FailCount, sr.Unresolved)
}
}
func TestParseFile_SkipsParentRolledUpBySubtests(t *testing.T) {
// A parent that logs during cleanup gets a non-empty body, and a t.Log like
// "DB instance … is gone" — which passing tests emit too — reads exactly
+147
View File
@@ -0,0 +1,147 @@
[
{
"cell": 1,
"install": "binary",
"topo": "single",
"net": "dhcp",
"host": "debian-13",
"arch": "x86_64",
"runner": "ci-single",
"extra": "",
"suite": ""
},
{
"cell": 2,
"install": "binary",
"topo": "single",
"net": "static",
"host": "ubuntu-26.04",
"arch": "x86_64",
"runner": "ci-single",
"extra": "",
"suite": ""
},
{
"cell": 3,
"install": "binary",
"topo": "single",
"net": "dhcp",
"host": "ubuntu-26.04",
"arch": "x86_64",
"runner": "ci-single",
"extra": "",
"suite": ""
},
{
"cell": 8,
"install": "binary",
"topo": "real-multi",
"net": "dhcp",
"host": "debian-13",
"arch": "x86_64",
"runner": "ci-multi",
"extra": "",
"suite": ""
},
{
"cell": 9,
"install": "binary",
"topo": "real-multi",
"net": "static",
"host": "ubuntu-26.04",
"arch": "x86_64",
"runner": "ci-multi",
"extra": "",
"suite": ""
},
{
"cell": 10,
"install": "binary",
"topo": "real-multi",
"net": "dhcp",
"host": "ubuntu-26.04",
"arch": "x86_64",
"runner": "ci-multi",
"extra": "",
"suite": ""
},
{
"cell": 17,
"install": "binary",
"topo": "single",
"net": "static",
"host": "debian-13",
"arch": "x86_64",
"runner": "ci-pseudo",
"extra": "tofu-examples",
"suite": ""
},
{
"cell": 18,
"install": "binary",
"topo": "single",
"net": "static",
"host": "debian-13",
"arch": "x86_64",
"runner": "ci-single",
"extra": "reboot-single",
"suite": ""
},
{
"cell": 19,
"install": "binary",
"topo": "single",
"net": "nat",
"host": "debian-13",
"arch": "x86_64",
"runner": "ci-single",
"extra": "nat-single",
"suite": ""
},
{
"cell": 25,
"install": "binary",
"topo": "single",
"net": "static",
"host": "debian-13",
"arch": "x86_64",
"runner": "ci-single-nvme",
"extra": "eks",
"suite": "eks"
},
{
"cell": 26,
"install": "binary",
"topo": "single",
"net": "static",
"host": "debian-13",
"arch": "x86_64",
"runner": "ci-single",
"extra": "ecs",
"suite": "ecs"
},
{
"cell": 27,
"install": "binary",
"topo": "single",
"net": "static",
"host": "debian-13",
"arch": "x86_64",
"runner": "ci-single",
"extra": "rds",
"suite": "rds",
"timeout_minutes": 120
},
{
"cell": 29,
"install": "binary",
"topo": "real-multi",
"net": "static",
"host": "debian-13",
"arch": "x86_64",
"runner": "ci-multi",
"extra": "",
"suite": "storagefault",
"timeout_minutes": 100
}
]
+34
View File
@@ -61,3 +61,37 @@ quiet_run() {
return "$rc"
fi
}
# Connection-layer failures from the libvirt tofu provider. The socket drops
# when a hypervisor is carrying several environments at once, which is the
# steady state here. A resource that failed to build says something else.
CI_LIBVIRT_TRANSIENT='Unable to Connect to Libvirt|error connecting to libvirt|procedure interrupted|Cannot recv data|Cannot write data|End of file while reading data'
# retry_run LABEL LOG_FILE -- CMD ARGS …
# quiet_run with a bounded retry, taken only when that attempt's own output
# names a libvirt connection failure. Every other failure is reported on the
# first attempt, so a real bug never becomes a slow flake.
retry_run() {
local label="$1" log="$2"; shift 2
local _attempts="${CI_RETRY_ATTEMPTS:-3}" _delay="${CI_RETRY_DELAY:-30}" _i _rc=1 _mark
for ((_i = 1; _i <= _attempts; _i++)); do
_mark=$(wc -c < "$log" 2>/dev/null || echo 0)
# rc must be read inside the else: an `if` whose condition fails and has no
# else exits 0, so reading $? after `fi` reports success for a failed run.
if "$@" >> "$log" 2>&1; then
if [[ $_i -gt 1 ]]; then ok "$label (libvirt recovered on attempt $_i)"; else ok "$label"; fi
return 0
else
_rc=$?
fi
tail -c "+$((_mark + 1))" "$log" | grep -Eq "$CI_LIBVIRT_TRANSIENT" || break
[[ $_i -eq $_attempts ]] && break
warn "$label: libvirt connection dropped (attempt $_i/$_attempts), retrying in ${_delay}s"
sleep "$_delay"
done
bad "$label (rc=$_rc) — last 80 lines of $(basename "$log"):"
group_open "tail $(basename "$log")"
tail -n 80 "$log"
group_close
return "$_rc"
}
+220
View File
@@ -0,0 +1,220 @@
#!/usr/bin/env python3
"""Render one run-level table for the e2e nightly into the job summary.
Reading a nightly meant opening nineteen jobs, and the Actions UI does not
distinguish a cell that failed from one that was cancelled out from under it —
a distinction every investigation of this suite has needed.
Inputs (env):
GITHUB_TOKEN Token with actions:read.
GITHUB_REPOSITORY owner/repo.
GITHUB_RUN_ID Run to summarise.
JUNIT_ROOT Directory holding downloaded junit-cell-* artifacts.
GITHUB_STEP_SUMMARY Appended to.
Test counts come from the junit artifacts; result, duration and runner come
from the jobs API, so a cell that died before writing junit still gets a row.
"""
from __future__ import annotations
import glob
import json
import os
import sys
import urllib.error
import urllib.request
import xml.etree.ElementTree as ET
from datetime import datetime
API = "https://api.github.com"
# A cell whose job produced no verdict is not a pass and not a failure. Keeping
# them apart is the point of the table.
VERDICT_ICON = {
"success": "✅",
"failure": "❌",
"cancelled": "🚫",
"skipped": "⏭️",
"timed_out": "⏱️",
}
def fetch_jobs(repo: str, run_id: str, token: str) -> list[dict]:
jobs: list[dict] = []
page = 1
while True:
url = f"{API}/repos/{repo}/actions/runs/{run_id}/jobs?per_page=100&page={page}"
req = urllib.request.Request(
url,
headers={
"Authorization": f"Bearer {token}",
"Accept": "application/vnd.github+json",
"X-GitHub-Api-Version": "2022-11-28",
},
)
try:
with urllib.request.urlopen(req, timeout=30) as resp:
payload = json.load(resp)
except (urllib.error.URLError, TimeoutError) as exc:
print(f"::warning::jobs API page {page} failed: {exc}", file=sys.stderr)
break
batch = payload.get("jobs", [])
jobs.extend(batch)
if len(batch) < 100:
break
page += 1
return jobs
def duration_s(job: dict) -> int | None:
started, completed = job.get("started_at"), job.get("completed_at")
if not started or not completed:
return None
fmt = "%Y-%m-%dT%H:%M:%SZ"
try:
return int((datetime.strptime(completed, fmt) - datetime.strptime(started, fmt)).total_seconds())
except ValueError:
return None
def human(seconds: int | None) -> str:
if seconds is None:
return "—"
if seconds < 0:
return "0s"
return f"{seconds // 60}m{seconds % 60:02d}s" if seconds >= 60 else f"{seconds}s"
def junit_counts(root: str) -> dict[str, dict]:
"""Map cell number to its aggregate junit counts and failing test names."""
per_cell: dict[str, dict] = {}
for path in glob.glob(os.path.join(root, "**", "junit-*.xml"), recursive=True):
cell = cell_from_artifact_path(path)
if cell is None:
continue
acc = per_cell.setdefault(cell, {"tests": 0, "failures": 0, "skipped": 0, "failing": []})
try:
root_el = ET.parse(path).getroot()
except ET.ParseError as exc:
print(f"::warning::unparseable junit {path}: {exc}", file=sys.stderr)
continue
suites = root_el.iter("testsuite") if root_el.tag != "testsuite" else [root_el]
for suite in suites:
acc["tests"] += int(suite.get("tests") or 0)
acc["failures"] += int(suite.get("failures") or 0) + int(suite.get("errors") or 0)
acc["skipped"] += int(suite.get("skipped") or 0)
for case in root_el.iter("testcase"):
if case.find("failure") is not None or case.find("error") is not None:
name = case.get("name") or "?"
if name not in acc["failing"]:
acc["failing"].append(name)
return per_cell
def cell_from_artifact_path(path: str) -> str | None:
"""Recover the cell number from a junit-cell-<n>-<run_id> artifact directory."""
for part in path.split(os.sep):
if part.startswith("junit-cell-"):
rest = part[len("junit-cell-"):]
cell = rest.split("-", 1)[0]
return cell if cell.isdigit() else None
return None
def cell_number(job_name: str) -> str | None:
"""cell-19 (single/static/debian-13) -> 19."""
if not job_name.startswith("cell-"):
return None
cell = job_name[len("cell-"):].split(" ", 1)[0]
return cell if cell.isdigit() else None
def main() -> int:
repo = os.environ.get("GITHUB_REPOSITORY", "")
run_id = os.environ.get("GITHUB_RUN_ID", "")
token = os.environ.get("GITHUB_TOKEN", "")
summary_path = os.environ.get("GITHUB_STEP_SUMMARY", "")
junit_root = os.environ.get("JUNIT_ROOT", "")
if not (repo and run_id and token and summary_path):
print("::warning::e2e-run-summary: missing required environment", file=sys.stderr)
return 0
jobs = fetch_jobs(repo, run_id, token)
counts = junit_counts(junit_root) if junit_root and os.path.isdir(junit_root) else {}
rows = []
for job in jobs:
cell = cell_number(job.get("name", ""))
if cell is None:
continue
rows.append((int(cell), job))
rows.sort(key=lambda r: r[0])
lines = [f"## e2e nightly — run summary ({len(rows)} cells)", ""]
if not rows:
lines.append("No cell jobs found for this run.")
write(summary_path, lines)
return 0
lines += [
"| Cell | Topology | Result | Duration | Runner | Tests | Failed | Skipped |",
"| --- | --- | --- | --- | --- | --- | --- | --- |",
]
tally: dict[str, int] = {}
total_tests = total_failed = total_seconds = 0
failing_detail: list[tuple[int, list[str]]] = []
for cell, job in rows:
name = job.get("name", "")
# A cell dispatched to a reusable workflow reports as
# "cell-20 (baremetal/pxe/real-hw) / Bare-Metal Single-Node E2E".
topo = name.split("(", 1)[1].split(")", 1)[0] if "(" in name else "—"
verdict = job.get("conclusion") or job.get("status") or "unknown"
tally[verdict] = tally.get(verdict, 0) + 1
secs = duration_s(job)
total_seconds += secs or 0
c = counts.get(str(cell), {})
total_tests += c.get("tests", 0)
total_failed += c.get("failures", 0)
if c.get("failing"):
failing_detail.append((cell, c["failing"]))
lines.append(
f"| {cell} | {topo} | {VERDICT_ICON.get(verdict, '❔')} {verdict} | {human(secs)} "
f"| {job.get('runner_name') or '—'} | {c.get('tests', '—')} "
f"| {c.get('failures', '—')} | {c.get('skipped', '—')} |"
)
verdicts = ", ".join(f"{n} {v}" for v, n in sorted(tally.items(), key=lambda kv: -kv[1]))
lines += [
"",
f"**Cells:** {verdicts}",
f"**Tests:** {total_tests} run, {total_failed} failed",
f"**Cell wall clock:** {human(total_seconds)} across all cells",
]
slowest = sorted(rows, key=lambda r: duration_s(r[1]) or 0, reverse=True)[:3]
if slowest:
lines.append(
"**Slowest:** " + ", ".join(f"cell {c} ({human(duration_s(j))})" for c, j in slowest)
)
if failing_detail:
lines += ["", "<details><summary>Failing tests</summary>", ""]
for cell, names in failing_detail:
lines.append(f"- **cell {cell}** — " + ", ".join(f"`{n}`" for n in names))
lines += ["", "</details>"]
write(summary_path, lines)
return 0
def write(path: str, lines: list[str]) -> None:
with open(path, "a", encoding="utf-8") as fh:
fh.write("\n".join(lines) + "\n")
if __name__ == "__main__":
sys.exit(main())
+31
View File
@@ -0,0 +1,31 @@
#!/bin/bash
# Prepare a runner to build a guest image with build-system-image.sh.
#
# Installs libguestfs + qemu-utils if they are missing and makes a host kernel
# readable, so the libguestfs appliance can boot. Nothing here touches a host
# block device: virt-customize works inside an isolated userspace appliance, so
# this is safe on a shared runner.
#
# Callers must still export LIBGUESTFS_BACKEND=direct themselves — it has to be
# set in the environment the build runs in, which is not this subshell.
set -euo pipefail
# Three nightly service cells share one ci-single runner and start together, so
# concurrent apt-get runs are the normal case rather than the exception. Two of
# them lost the lock outright before this guard existed. Skipping the install
# when the tools are already present avoids most of the contention; the lock
# timeout covers the rest, including apt runs started by anything else on the
# box.
if command -v virt-customize >/dev/null && command -v qemu-img >/dev/null; then
echo "libguestfs-tools and qemu-utils already present"
else
APT_OPTS=(-o DPkg::Lock::Timeout=600)
sudo apt-get "${APT_OPTS[@]}" update -qq
sudo apt-get "${APT_OPTS[@]}" install -y --no-install-recommends \
libguestfs-tools qemu-utils
fi
# Debian/Ubuntu ship /boot/vmlinuz-* mode 0600 and the appliance needs to read a
# host kernel. Tolerated on failure: a runner whose kernel is already readable
# has no vmlinuz to chmod under some images.
sudo chmod 0644 /boot/vmlinuz-* || true
+69 -8
View File
@@ -28,23 +28,84 @@
# that runs identically on either, and because a quota left switched on would
# cap every suite sharing the environment — so it must be proved to restore
# itself wherever it runs, not only on one node.
E2E_SUITES_SINGLE="single iam cert eks ecs storagegrowth partialblock rds quota"
# storagefault runs last in both lists and belongs to both topologies, for
# different reasons. On one node it is RS(1,0), so freezing the only predastore
# is a total outage and the guest-survival assertion is unambiguous. On three it
# is RS(2,1) with degraded writes on, so two must be frozen to break the shard
# floor — and only there is a cross-node restart possible at all, which is the
# case that takes a volume over from a node holding writes the backend never
# received. It is last because it SIGSTOPs a cluster-wide
# service: anything sharing the environment would see the same outage and report
# a failure that says nothing about itself.
E2E_SUITES_SINGLE="single iam cert eks ecs storagegrowth partialblock rds quota storagefault"
# Suites runnable against a multi-node environment.
E2E_SUITES_MULTI="multinode lb cert quota"
E2E_SUITES_MULTI="multinode lb cert quota storagefault"
# grep -xE alternation form, for narrowing a requested set to the eligible one.
E2E_SUITES_SINGLE_RE="$(printf '%s' "${E2E_SUITES_SINGLE}" | tr ' ' '|')"
E2E_SUITES_MULTI_RE="$(printf '%s' "${E2E_SUITES_MULTI}" | tr ' ' '|')"
# e2e-nightly runs a narrower set than the two sets above, deliberately: its
# ~19 cells each have a 35-minute budget and exist to prove that every
# install / network / host-OS permutation boots and serves. Suite breadth is
# the PR gate's job, and it already runs on every push. These are subsets, and
# e2e_suites_assert_subset below enforces that.
E2E_SUITES_NIGHTLY_SINGLE="single cert"
# What every e2e-nightly permutation cell must prove, deliberately narrower
# than the two sets above: those cells have a 35-minute budget and exist to show
# that every install / network / host-OS combination boots and serves. The
# service suites are not deferred to the PR gate — eks, ecs and rds each run
# nightly in their own dedicated cell, because each needs a guest AMI built
# first and a service failure should not mask whether a host OS boots. These
# are subsets, and e2e_suites_assert_subset below enforces that.
#
# storagefault has a dedicated cell for two reasons. It SIGSTOPs a cluster-wide
# service, so in a permutation cell every suite sharing that environment would
# report the outage as its own failure. And a full pass is ~63 minutes, nearly
# all of it deliberate waiting, which is too long to add to the PR gate — where
# it has never run, since the gate's default suite list does not name it.
#
# Its cell is three-node: the RS(2,1) shard floor and the cross-node takeover
# after a failed seal do not exist on one node, and they are the assertions
# worth a nightly.
E2E_SUITES_NIGHTLY_SINGLE="single cert iam"
E2E_SUITES_NIGHTLY_MULTI="multinode cert lb"
# How long a suite gets, for the same reason the lists above live here: the
# timeout was written out separately in each workflow and drifted, so rds ran
# 50m on the PR gate and 30m nightly and panicked there. A suite needing more
# than the default declares it once, here.
# e2e_suite_timeout <suite>
e2e_suite_timeout() {
case "$1" in
# rds serialises its DB VMs behind a 3 GiB semaphore, so its wall clock is
# what the queue costs, not what the tests do: single tests logged 11m, 22m
# and 26m waiting for budget. 17 tests at that concurrency is over an hour
# before anything goes wrong, and the first pass to run deep into the suite
# still had three left at 60m. The loaded graceful-reboot case adds an
# instance that holds its slot through a pgbench load as well as a create.
rds) echo "105m" ;;
# diskperf runs two 16 GiB fio profiles per repetition, each on a volume it
# creates first. One pass is ~20 minutes on the bare-metal cell and a
# baseline capture at SPINIFEX_DISKPERF_REPS=3 is three times that.
diskperf) echo "180m" ;;
# storagefault is mostly deliberate waiting: one test alone holds predastore
# SIGSTOPped for seven minutes to prove a guest survives it. A full pass on
# dev-prod is ~63 minutes, so the 30m default truncated it mid-suite.
storagefault) echo "90m" ;;
*) echo "30m" ;;
esac
}
# One matrix leg per suite, carrying the job budget that suite needs. The outer
# timeout must always be later than the `go test` one inside it: go test reports
# where it stopped, a cancelled job reports only "The operation was canceled".
# e2e_suite_matrix <suite-list> <floor-minutes> [allowance-minutes]
e2e_suite_matrix() {
local list="$1" floor="$2" allowance="${3:-15}" suite budget
for suite in ${list}; do
budget="$(e2e_suite_timeout "${suite}")"
budget=$((${budget%m} + allowance))
if [ "${budget}" -lt "${floor}" ]; then budget="${floor}"; fi
printf '%s\t%s\n' "${suite}" "${budget}"
done | jq -cRs 'split("\n")|map(select(length>0)|split("\t")|{suite:.[0],timeout:(.[1]|tonumber)})'
}
# Fail loudly when a narrowed list names a suite its topology cannot run. This
# is the guard that stops the lists drifting again: a suite added to a nightly
# subset without also being declared eligible above stops the job here rather
-2
View File
@@ -33,8 +33,6 @@ jobs:
strategy:
fail-fast: false
matrix:
# The suites the nightly E2E matrix actually installs on. Ubuntu 22.04 is
# accepted by detect_os but has never been exercised and does not resolve.
image:
- ubuntu:24.04
- ubuntu:26.04
+86 -13
View File
@@ -1,7 +1,26 @@
name: E2E Bare-Metal
on:
# Mirrors the suite selection of workflow_call so a baseline capture can be
# driven by hand. A dispatch run sees only the inputs declared here, so
# omitting these would silently fall back to the correctness set.
workflow_dispatch:
inputs:
suites:
description: "Space-separated e2e suites to run on the node. Default is the correctness set; a performance run names its own so its numbers come off an otherwise idle cell"
required: false
type: string
default: "single cert lb"
suite-env:
description: "Extra environment for the suite invocation, e.g. SPINIFEX_DISKPERF_REPS=3"
required: false
type: string
default: ""
cell:
description: "Nightly cell number this run reports as. Names the artifact bundle and the ES dimension, so two callers of this workflow stay distinguishable in the sink"
required: false
type: string
default: "20"
workflow_call:
inputs:
ref:
@@ -14,6 +33,21 @@ on:
required: false
type: string
default: ""
suites:
description: "Space-separated e2e suites to run on the node. Default is the correctness set; a performance caller names its own so its numbers come off an otherwise idle cell"
required: false
type: string
default: "single cert lb"
suite-env:
description: "Extra environment for the suite invocation, e.g. SPINIFEX_DISKPERF_REPS=3"
required: false
type: string
default: ""
cell:
description: "Nightly cell number this run reports as. Names the artifact bundle and the ES dimension, so two callers of this workflow stay distinguishable in the sink"
required: false
type: string
default: "20"
permissions:
contents: read
@@ -21,25 +55,21 @@ permissions:
env:
GIT_BRANCH: ${{ github.ref_name }}
# Same central ES/APM sink as the other four e2e workflows. Bare-metal had
# no default here before — this cell's telemetry never reached the sink at
# all (not just missing ci.cell), see bm-bootstrap.sh's telemetry.env step.
SPINIFEX_OTLP_ENDPOINT: ${{ vars.SPINIFEX_OTLP_ENDPOINT || 'http://192.168.1.13:8200' }}
jobs:
e2e_baremetal:
name: Bare-Metal Single-Node E2E
runs-on: [ self-hosted, ci-baremetal ]
timeout-minutes: 120
runs-on: [self-hosted, ci-baremetal]
timeout-minutes: 240
concurrency:
group: e2e-baremetal-cluster
cancel-in-progress: false
env:
BM_ISO_CACHE: /var/cache/spinifex-iso-builder
ARTIFACT_DIR: /tmp/spinifex-e2e-nightly-cell-20-${{ github.run_id }}
# Caller-supplied (workflow_call input); empty for a manual
# workflow_dispatch run, which bm-bootstrap.sh treats as "no attrs".
SPINIFEX_OTEL_ATTRS: ${{ inputs.otel-attrs }}
BM_CELL: ${{ inputs.cell || '20' }}
ARTIFACT_DIR: /tmp/spinifex-e2e-nightly-cell-${{ inputs.cell || '20' }}-${{ github.run_id }}
SPINIFEX_OTEL_ATTRS: ${{ inputs.otel-attrs || format('ci.run_id={0},ci.workflow={1},ci.cell={2},ci.matrix=baremetal/pxe/real-hw', github.run_id, github.workflow, inputs.cell || '20') }}
steps:
- name: Clean Workspace
run: |
@@ -74,6 +104,10 @@ jobs:
ssh-keygen -y -f "${RUNNER_TEMP}/bm-ci" > "${RUNNER_TEMP}/bm-ci.pub"
- name: Build Artifacts
env:
# The node runs pre-built binaries, so a suite absent here cannot be
# selected later. Same list as the run step for that reason.
BM_SUITES: ${{ inputs.suites || 'single cert lb' }}
run: |
mulga/scripts/baremetal/bm-build-artifacts.sh \
"$GITHUB_WORKSPACE" \
@@ -119,6 +153,21 @@ jobs:
- name: Install go-junit-report on runner
run: go install github.com/jstemmer/go-junit-report/v2@latest
# The eks suite's CreateCluster boots a control-plane VM from the eks-node
# AMI. Built here because libguestfs needs source and network, which the
# runner has and the PXE-booted node does not; bm-bootstrap.sh imports it.
- name: Build eks-node AMI (libguestfs, runner)
if: contains(inputs.suites, 'eks')
run: |
set -euo pipefail
# direct backend = no libvirt. Set here rather than in the script:
# it has to be in the environment the build itself runs in.
export LIBGUESTFS_BACKEND=direct
# spinifex is the workspace root here, unlike the nightly where it is
# checked out into a subdirectory.
"${GITHUB_WORKSPACE}/.github/scripts/ensure-libguestfs.sh"
make build-eks-node-image
- name: Run Bare-Metal E2E
env:
BM_CI_PASSWORD: ${{ secrets.BM_CI_PASSWORD }}
@@ -126,9 +175,25 @@ jobs:
BM_EPDU_ENV: ${{ runner.temp }}/epdu.env
BM_UBUNTU_IMAGE: ${{ env.BM_ISO_CACHE }}/ubuntu-26.04-cloudimg-amd64.img
SPX_EXTERNAL_POOL: ${{ env.SPINIFEX_CI_POOL }}
BM_SUITES: ${{ inputs.suites || 'single cert lb' }}
BM_SUITE_ENV: ${{ inputs.suite-env }}
# Empty unless the eks build above ran, and bm-bootstrap.sh skips the
# import when unset, so the other suites are unaffected.
BM_EKS_IMAGE: ${{ contains(inputs.suites, 'eks') && '/tmp/eks-node-image-build/eks-node-alpine.raw' || '' }}
BM_EKS_MANIFEST: ${{ contains(inputs.suites, 'eks') && format('{0}/scripts/images/eks-node/manifest.conf', github.workspace) || '' }}
run: |
mkdir -p "${ARTIFACT_DIR}"
set -o pipefail
. "${GITHUB_WORKSPACE}/.github/scripts/suite-sets.sh"
BM_TEST_TIMEOUT=""
for suite in ${BM_SUITES}; do
tmo="$(e2e_suite_timeout "${suite}")"
if [ -z "${BM_TEST_TIMEOUT}" ] || [ "${tmo%m}" -gt "${BM_TEST_TIMEOUT%m}" ]; then
BM_TEST_TIMEOUT="${tmo}"
fi
done
export BM_TEST_TIMEOUT
echo "suites=${BM_SUITES} test-timeout=${BM_TEST_TIMEOUT}"
sudo -E mulga/scripts/baremetal/run-baremetal-e2e.sh \
--vmlinuz "${BM_ISO_CACHE}/vmlinuz" \
--initrd "${BM_ISO_CACHE}/initrd.img" \
@@ -139,7 +204,15 @@ jobs:
- name: Generate JUnit report
if: always()
run: |
LOG="${ARTIFACT_DIR}/test-baremetal.log"
# suite-output.log, not test-baremetal.log. The harness streams the
# full suite output to the first and a grep-filtered summary to its
# stdout, and that filter anchors --- PASS at column 0, so every
# indented subtest result is dropped. Parsed from the filtered log,
# go-junit-report saw a === RUN with no result and emitted
# <error message="No test result found"> — 17 of 39 testcases on a
# green cell-28, 93 of 120 on cell-20.
LOG="${ARTIFACT_DIR}/suite-output.log"
[ -s "$LOG" ] || LOG="${ARTIFACT_DIR}/test-baremetal.log"
[ -f "$LOG" ] || exit 0
go-junit-report -in "$LOG" -out "${ARTIFACT_DIR}/junit-baremetal.xml" || true
@@ -151,7 +224,7 @@ jobs:
uses: ./.github/actions/junit-to-es
with:
junit-glob: ${{ env.ARTIFACT_DIR }}/junit-*.xml
cell: "20"
cell: ${{ env.BM_CELL }}
- name: Analyze e2e failures
if: always()
@@ -159,12 +232,12 @@ jobs:
with:
junit-glob: ${{ env.ARTIFACT_DIR }}/junit-*.xml
log-dir: ${{ env.ARTIFACT_DIR }}
title: "E2E failure analysis (cell 20)"
title: "E2E failure analysis (cell ${{ env.BM_CELL }})"
- uses: actions/upload-artifact@v7
if: always()
with:
name: e2e-artifacts-cell-20-${{ github.run_id }}
name: e2e-artifacts-cell-${{ env.BM_CELL }}-${{ github.run_id }}
path: ${{ env.ARTIFACT_DIR }}
if-no-files-found: warn
retention-days: 14
+12 -12
View File
@@ -1,7 +1,5 @@
name: E2E DDIL
# This workflow is manually triggered while the DDIL scenarios are being
# validated.
on:
workflow_dispatch:
inputs:
@@ -10,29 +8,31 @@ on:
required: false
default: ""
# Shared with e2e-nightly.yml and e2e-suites.yml: every one of them provisions
# many cells on the same runner pools and the same hypervisors, so two in
# flight compete for their disks and their libvirt guests. Queued rather than
# cancelled — a run in progress is someone's result, not a stale one. e2e.yml
# is outside the group: one cluster per run is cheap enough to overlap.
concurrency:
group: e2e-hypervisors
cancel-in-progress: false
permissions:
contents: read
# actions: read lets the otel-job-id action look up this job's numeric id via
# the Actions API — github.job is only the YAML key, no context exposes it.
actions: read
env:
GIT_BRANCH: ${{ github.ref_name }}
# OTLP export to the central Elastic sink (wattle). Repo variable overrides;
# empty string disables telemetry entirely.
SPINIFEX_OTLP_ENDPOINT: ${{ vars.SPINIFEX_OTLP_ENDPOINT || 'http://192.168.1.13:8200' }}
SPINIFEX_OTEL_ATTRS: ci.run_id=${{ github.run_id }},ci.workflow=${{ github.workflow }}
jobs:
ddil:
name: DDIL Real Multi-Node
runs-on: [ self-hosted, ci-multi ]
runs-on: [self-hosted, ci-multi]
timeout-minutes: 60
env:
TOFU_LOG: /tmp/e2e-tofu-ddil-${{ github.run_id }}.log
# Job-level env wins over the workflow-level default. ci.cell=ddil marks
# this workflow's telemetry so a DDIL run's partition-induced errors are
# never mistaken for a healthy multi-node run's in the sink.
SPINIFEX_OTEL_ATTRS: ci.run_id=${{ github.run_id }},ci.workflow=${{ github.workflow }},ci.cell=ddil
steps:
- name: Clean Workspace
@@ -103,7 +103,7 @@ jobs:
quiet_run "tofu workspace select" "$TOFU_LOG" tofu workspace select -or-create "${SPINIFEX_CI_ENV}"
quiet_run "tofu destroy (pre-clean)" "$TOFU_LOG" tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false || true
quiet_run "scrub-env (pre-clean)" "$TOFU_LOG" ./scrub-env.sh "${SPINIFEX_CI_ENV}" || true
quiet_run "tofu apply" "$TOFU_LOG" tofu apply -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false
retry_run "tofu apply" "$TOFU_LOG" tofu apply -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false
ok "VMs provisioned"
- name: Bootstrap via Binary Install
@@ -228,7 +228,7 @@ jobs:
source "${GITHUB_WORKSPACE}/spinifex/.github/scripts/ci-fmt.sh"
quiet_run "tofu workspace select" "$TOFU_LOG" tofu workspace select -or-create "${SPINIFEX_CI_ENV}"
quiet_run "telemetry flush" "$TOFU_LOG" timeout 120 ./flush-telemetry.sh "${SPINIFEX_CI_ENV}" || true
quiet_run "tofu destroy" "$TOFU_LOG" tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false
retry_run "tofu destroy" "$TOFU_LOG" tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false
quiet_run "scrub-env" "$TOFU_LOG" ./scrub-env.sh "${SPINIFEX_CI_ENV}" || true
ok "teardown complete"
+583 -86
View File
@@ -2,86 +2,135 @@ name: E2E Nightly
on:
schedule:
- cron: "0 14 * * 0-4" # 00:00 Mon-Fri AEST = 14:00 Sun-Thu UTC
# 14:00 UTC is midnight AEST (01:00 AEDT — cron has no DST), when the
# hypervisors are idle. Every day, not weeknights: a weekend gap is two runs
# of evidence lost on a suite whose remaining failures are a rate, not a bug.
# :17 rather than :00 because GitHub queues scheduled runs behind every other
# top-of-hour cron and drops them under load, which is why 2026-09-14 never
# fired and why every run in that window started 3h15m-3h45m late.
- cron: "17 14 * * *"
workflow_dispatch:
inputs:
cells:
description: "Comma-separated cell numbers to run, no spaces (blank = all). 1-19 tofu matrix, 20 baremetal, 21-22 ISO, 23 predastore throughput, 24 predastore fault injection"
description: "Comma-separated cell numbers to run, no spaces (blank = all). 1-3, 8-10, 17-19 tofu permutation matrix, 20 baremetal, 21-22 ISO, 23 predastore throughput, 24 predastore fault injection, 25-27 service suites (eks, ecs, rds), 28 guest disk performance, 29 storage fault injection"
required: false
permissions:
contents: read
# actions: read lets the "Resolve CI job id" step below look up this job's
# numeric id via the Actions API — github.job is only the YAML key, no
# context exposes the id itself.
actions: read
# Scheduled runs are loaded from the default branch (main), so the schedule
# trigger must stay in sync on main for cron to fire.
# Every cell builds its cluster on the same five hypervisors, so two runs in
# flight at once compete for their disks and their libvirt guests. Queue the
# second rather than cancelling it: a dispatch is usually a verification run
# and discarding it loses the result someone is waiting on.
#
# The group is shared with e2e-suites.yml and e2e-ddil.yml, which draw on the
# same runner pools. e2e.yml is deliberately outside it: a single-cluster push
# run is cheap enough to overlap, and grouping it stalled every push behind a
# multi-hour nightly.
concurrency:
group: e2e-hypervisors
cancel-in-progress: false
env:
GIT_BRANCH: ${{ github.event_name == 'schedule' && 'dev' || github.ref_name }}
UBUNTU_GOLD_IMAGE: /mnt/disk1/gold-images/spinifex-image-builder-ubuntu-26.04.qcow2
# OTLP export to the central Elastic sink (wattle). Repo variable overrides;
# empty string disables telemetry entirely.
SPINIFEX_OTLP_ENDPOINT: ${{ vars.SPINIFEX_OTLP_ENDPOINT || 'http://192.168.1.13:8200' }}
SPINIFEX_OTEL_ATTRS: ci.run_id=${{ github.run_id }},ci.workflow=${{ github.workflow }}
jobs:
select:
runs-on: ubuntu-26.04
outputs:
cells: ${{ steps.pick.outputs.cells }}
any: ${{ steps.pick.outputs.any }}
steps:
- uses: actions/checkout@v7
with:
sparse-checkout: |
.github/e2e-cells.json
.github/scripts/suite-sets.sh
sparse-checkout-cone-mode: false
- id: pick
env:
CELLS: ${{ github.event_name == 'workflow_dispatch' && inputs.cells || '' }}
SETUP_ALLOWANCE_MIN: 10
run: |
if [ -z "$CELLS" ]; then
sel=$(jq -c . .github/e2e-cells.json)
else
sel=$(jq -c --arg c "$CELLS" \
'[.[] | . as $e | select(($c | split(",")) | index($e.cell|tostring))]' \
.github/e2e-cells.json)
fi
echo "cells=$sel" >> "$GITHUB_OUTPUT"
[ "$(jq 'length' <<<"$sel")" -gt 0 ] && any=true || any=false
echo "any=$any" >> "$GITHUB_OUTPUT"
echo "selected: $(jq -c '[.[].cell]' <<<"$sel")"
# The inner timeout must always fire before the outer one. go test
# reports where it stopped and the suite's own failure; a job timeout
# reports only "The operation was canceled". cell-27 ran a 50m rds
# suite inside a 50m job and could never finish either way.
. .github/scripts/suite-sets.sh
# Cells with no suite are filtered in jq, not with an empty-field test:
# tab is IFS whitespace, so read collapses a run of them and an empty
# middle column silently shifts every later one left.
bad=0
while IFS=$'\t' read -r cell suite budget; do
want="$(e2e_suite_timeout "$suite")"; want="${want%m}"
if [ "$budget" -lt $((want + SETUP_ALLOWANCE_MIN)) ]; then
echo "::error::cell-$cell gives suite '$suite' ${want}m inside a ${budget}m job. Raise timeout_minutes in .github/e2e-cells.json to at least $((want + SETUP_ALLOWANCE_MIN)), or lower the suite budget in suite-sets.sh." >&2
bad=1
fi
done < <(jq -r '.[]|select((.suite//"")!="")|[.cell,.suite,(.timeout_minutes//50)]|@tsv' <<<"$sel")
# A permutation cell names no suite and runs the nightly subset for its
# topology, so its bound is the longest of those. max() and not sum():
# whichever suite hangs must panic on its own timeout first, and a sum
# would demand a budget the set has never come close to using.
while IFS=$'\t' read -r cell topo budget; do
case "$topo" in
*multi) set -- ${E2E_SUITES_NIGHTLY_MULTI} ;;
*) set -- ${E2E_SUITES_NIGHTLY_SINGLE} ;;
esac
want=0
for suite in "$@"; do
t="$(e2e_suite_timeout "$suite")"; t="${t%m}"
[ "$t" -gt "$want" ] && want="$t"
done
if [ "$budget" -lt $((want + SETUP_ALLOWANCE_MIN)) ]; then
echo "::error::cell-$cell runs the $topo nightly subset, whose longest suite asks ${want}m, inside a ${budget}m job. Raise timeout_minutes in .github/e2e-cells.json to at least $((want + SETUP_ALLOWANCE_MIN))." >&2
bad=1
fi
done < <(jq -r '.[]|select((.suite//"")=="")|[.cell,.topo,(.timeout_minutes//50)]|@tsv' <<<"$sel")
[ "$bad" -eq 0 ]
matrix:
name: "cell-${{ matrix.cell }} (${{ matrix.topo }}/${{ matrix.net }}/${{
matrix.host }}${{ matrix.extra && format('/{0}', matrix.extra) || '' }})"
needs: select
if: needs.select.outputs.any == 'true'
strategy:
fail-fast: false
max-parallel: 3
matrix:
include:
- { cell: 1, install: binary, topo: single, net: dhcp, host: debian-13, arch: x86_64, runner: ci-single, extra: "" }
- { cell: 2, install: binary, topo: single, net: static, host: ubuntu-26.04, arch: x86_64, runner: ci-single, extra: "" }
- { cell: 3, install: binary, topo: single, net: dhcp, host: ubuntu-26.04, arch: x86_64, runner: ci-single, extra: "" }
- { cell: 8, install: binary, topo: real-multi, net: dhcp, host: debian-13, arch: x86_64, runner: ci-multi, extra: "" }
- { cell: 9, install: binary, topo: real-multi, net: static, host: ubuntu-26.04, arch: x86_64, runner: ci-multi, extra: "" }
- { cell: 10, install: binary, topo: real-multi, net: dhcp, host: ubuntu-26.04, arch: x86_64, runner: ci-multi, extra: "" }
- { cell: 17, install: binary, topo: single, net: static, host: debian-13, arch: x86_64, runner: ci-pseudo, extra: "tofu-examples" }
- { cell: 18, install: binary, topo: single, net: static, host: debian-13, arch: x86_64, runner: ci-single, extra: "reboot-single" }
- { cell: 19, install: binary, topo: single, net: nat, host: debian-13, arch: x86_64, runner: ci-single, extra: "nat-single" }
runs-on: [ self-hosted, "${{ matrix.runner }}" ]
timeout-minutes: ${{ matrix.extra == 'tofu-examples' && 45 || 35 }}
include: ${{ fromJSON(needs.select.outputs.cells) }}
runs-on: [self-hosted, "${{ matrix.runner }}"]
timeout-minutes: ${{ matrix.timeout_minutes || 50 }}
env:
TOFU_LOG: /tmp/e2e-nightly-cell-${{ matrix.cell }}-${{ github.run_id }}.log
ARTIFACT_DIR: /tmp/spinifex-e2e-nightly-cell-${{ matrix.cell }}-${{ github.run_id }}
# Job-level env wins over the workflow-level default above, so telemetry
# from this cell carries ci.cell/ci.matrix. Nine cells run under one
# run id and every cell names its hosts node1/node2/node3, so without
# this every cell's telemetry collapses into one unattributable bucket.
SPINIFEX_OTEL_ATTRS: ci.run_id=${{ github.run_id }},ci.workflow=${{ github.workflow }},ci.cell=${{ matrix.cell }},ci.matrix=${{ matrix.topo }}/${{ matrix.net }}/${{ matrix.host }}
steps:
# blank or scheduled run = full matrix.
# Input must be comma-separated with no whitespace (e.g. '1,2,17', not '1, 2').
- name: Gate on Dispatch Input
id: gate
env:
CELL: ${{ matrix.cell }}
CELLS: ${{ github.event_name == 'workflow_dispatch' && inputs.cells || '' }}
run: |
if [ -z "$CELLS" ] || [[ ",${CELLS}," == *",${CELL},"* ]]; then
echo "selected=true" >> "$GITHUB_OUTPUT"
echo "cell ${CELL} selected"
else
echo "selected=false" >> "$GITHUB_OUTPUT"
echo "cell ${CELL} not in '${CELLS}' — subsequent steps will skip"
fi
- name: Clean Workspace
if: steps.gate.outputs.selected == 'true'
run: |
sudo umount -R -l /var/cache/spinifex-iso-builder/rootfs 2>/dev/null || true
if [ ! -c /dev/null ]; then sudo rm -f /dev/null; sudo mknod -m 666 /dev/null c 1 3; fi
sudo rm -rf --one-file-system "$GITHUB_WORKSPACE"/*
- uses: actions/create-github-app-token@v3
if: steps.gate.outputs.selected == 'true'
id: app-token
with:
client-id: ${{ secrets.APP_ID }}
@@ -89,7 +138,6 @@ jobs:
repositories: mulga
- uses: actions/checkout@v7
if: steps.gate.outputs.selected == 'true'
with:
repository: mulgadc/mulga
token: ${{ steps.app-token.outputs.token }}
@@ -97,31 +145,47 @@ jobs:
path: mulga
- uses: actions/checkout@v7
if: steps.gate.outputs.selected == 'true'
with:
path: spinifex
# Nine cells share one run id and every cell names its hosts
# Twelve cells share one run id and every cell names its hosts
# node1/node2/node3, so ci.cell above separates them and ci.job_id here
# links each cell's telemetry to the job log that produced it. Runs
# before Bootstrap bakes SPINIFEX_OTEL_ATTRS into the VM's telemetry.env.
- name: Resolve CI job id
if: steps.gate.outputs.selected == 'true'
uses: ./spinifex/.github/actions/otel-job-id
with:
job-name: "cell-${{ matrix.cell }} (${{ matrix.topo }}/${{ matrix.net }}/${{ matrix.host }}${{ matrix.extra && format('/{0}', matrix.extra) || '' }})"
- name: Build Install Artifacts
if: steps.gate.outputs.selected == 'true'
working-directory: mulga/scripts/tofu-cluster
env:
MATRIX_SUITE: ${{ matrix.suite }}
run: |
# The script's default set is the nightly baseline. A service cell
# runs one suite outside it, and asks for that binary by name.
./build-install-artifacts.sh "${GIT_BRANCH}" \
"${RUNNER_TEMP}/spinifex-distro.tar.gz" \
"${RUNNER_TEMP}/setup.sh" \
"${RUNNER_TEMP}/spinifex-tests.tar"
"${RUNNER_TEMP}/spinifex-tests.tar" \
--extra-suites "${MATRIX_SUITE}"
# Every runner agent runs on one host, while their nodes sit on several, so
# this samples the runner's host and not the node's hypervisor. Kept for
# runner-side contention; the node's own capacity is sampled after.
- name: Sample runner-host load (before)
if: always()
run: |
mkdir -p "${ARTIFACT_DIR}"
{
echo "=== before: $(date -u +%Y-%m-%dT%H:%M:%SZ) runner=${RUNNER_NAME} host=$(hostname) ==="
echo "loadavg: $(cat /proc/loadavg)"
grep -E '^(MemTotal|MemAvailable|SwapFree):' /proc/meminfo || true
echo "cpu(steal is field 8): $(grep '^cpu ' /proc/stat || true)"
echo "running qemu: $(pgrep -c qemu-system-x86 || echo 0)"
} >> "${ARTIFACT_DIR}/hypervisor-load.txt"
- name: Provision VMs
if: steps.gate.outputs.selected == 'true'
working-directory: mulga/scripts/tofu-cluster
env:
MATRIX_HOST: ${{ matrix.host }}
@@ -136,14 +200,15 @@ jobs:
ubuntu-*) TOFU_VAR_ARGS="-var=host_image_override=${UBUNTU_GOLD_IMAGE}" ;;
esac
source "${GITHUB_WORKSPACE}/spinifex/.github/scripts/ci-fmt.sh"
tofu init -input=false >> "$TOFU_LOG" 2>&1
tofu workspace select -or-create "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1
tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars ${TOFU_VAR_ARGS} -auto-approve -input=false >> "$TOFU_LOG" 2>&1 || true
./scrub-env.sh "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1 || true
tofu apply -var-file=envs/${SPINIFEX_CI_ENV}.tfvars ${TOFU_VAR_ARGS} -auto-approve -input=false >> "$TOFU_LOG" 2>&1
# shellcheck disable=SC2086
retry_run "tofu apply" "$TOFU_LOG" tofu apply -var-file=envs/${SPINIFEX_CI_ENV}.tfvars ${TOFU_VAR_ARGS} -auto-approve -input=false
- name: Bootstrap via Binary Install
if: steps.gate.outputs.selected == 'true'
working-directory: mulga/scripts/tofu-cluster
env:
MATRIX_NET: ${{ matrix.net }}
@@ -166,7 +231,7 @@ jobs:
--test-tar "${RUNNER_TEMP}/spinifex-tests.tar"
- name: Run Single Node E2E
if: steps.gate.outputs.selected == 'true' && matrix.topo == 'single' && matrix.extra == ''
if: matrix.topo == 'single' && matrix.extra == '' && matrix.suite == ''
working-directory: mulga/scripts/tofu-cluster
run: |
set -euo pipefail
@@ -194,7 +259,7 @@ jobs:
done; exit \$FAIL"
- name: Run NAT Uplink E2E
if: steps.gate.outputs.selected == 'true' && matrix.extra == 'nat-single'
if: matrix.extra == 'nat-single'
working-directory: mulga/scripts/tofu-cluster
# Skips cleanly (exit 0) when the tested branch predates the natuplink
# suite so scheduled runs against dev stay green until the merge lands.
@@ -216,7 +281,7 @@ jobs:
if [ \$rc -ne 0 ]; then echo 'natuplink: FAILED' >> '${ARTIFACT_DIR}/suite-status.txt'; fi; exit \$rc"
- name: Run Multi Node E2E
if: steps.gate.outputs.selected == 'true' && matrix.topo == 'real-multi'
if: matrix.topo == 'real-multi' && matrix.suite == ''
working-directory: mulga/scripts/tofu-cluster
run: |
set -euo pipefail
@@ -251,8 +316,221 @@ jobs:
if [ \$rc -ne 0 ]; then echo \"\$SUITE: FAILED\" >> '${ARTIFACT_DIR}/suite-status.txt'; FAIL=1; fi; \
done; exit \$FAIL"
# The dedicated suite cells only. Resolved once here rather than in each
# step below, which would repeat the same three lines five times.
- name: Resolve VM SSH details
id: vm
if: matrix.suite != '' && matrix.topo == 'single'
working-directory: mulga/scripts/tofu-cluster
run: |
set -euo pipefail
INVENTORY=$(tofu output -json inventory)
SSH_KEY=$(echo "$INVENTORY" | jq -r '.ssh_key_path')
echo "wan_ip=$(echo "$INVENTORY" | jq -r '.nodes[0].wan_ip')" >> "$GITHUB_OUTPUT"
echo "ssh_key=${SSH_KEY/#\~/$HOME}" >> "$GITHUB_OUTPUT"
# The eks suite's CreateCluster boots a control-plane VM from the eks-node
# AMI, so that AMI must be in the VM's image store before the suite runs.
# build-system-image.sh customizes with libguestfs (an isolated userspace
# appliance touching no host block device), so it is safe on the shared
# runner. The build needs source + network (runner only) and the import
# needs predastore/NATS (VM only), hence the split.
- name: Build eks-node AMI (libguestfs, runner)
if: matrix.suite == 'eks'
working-directory: spinifex
run: |
set -euo pipefail
# direct backend = no libvirt. Set here rather than in the script:
# it has to be in the environment the build itself runs in.
export LIBGUESTFS_BACKEND=direct
"${GITHUB_WORKSPACE}/spinifex/.github/scripts/ensure-libguestfs.sh"
make build-eks-node-image
- name: Import eks-node AMI on VM
if: matrix.suite == 'eks'
run: |
set -euo pipefail
eval "$(grep -E '^(ALPINE_VERSION|AMI_NAME|SYSTEM_TAG)=' \
spinifex/scripts/images/eks-node/manifest.conf)"
SSH_OPTS=(-o StrictHostKeyChecking=no -i "${{ steps.vm.outputs.ssh_key }}")
VM=tf-user@${{ steps.vm.outputs.wan_ip }}
scp "${SSH_OPTS[@]}" \
/tmp/eks-node-image-build/eks-node-alpine.raw \
"$VM":/tmp/eks-node-alpine.raw
ssh "${SSH_OPTS[@]}" "$VM" \
"spx admin images import --file /tmp/eks-node-alpine.raw \
--distro alpine --version ${ALPINE_VERSION} --arch x86_64 \
--boot-mode bios --ami-name ${AMI_NAME} --tag ${SYSTEM_TAG}" >/dev/null 2>&1
# The eks data-plane subtest (TestEKS/KubectlGetNodes) drives the full
# `aws eks get-token` -> kubectl -> apiserver -> token-webhook -> gateway
# broker -> STS verify path. The e2e binaries run ON the VM, so kubectl
# must be on the VM's PATH or harness.NewKubectl skips the subtest and the
# cell reports green having never exercised the data plane. Pinned to the
# k3s server version (setup.sh K3S_VERSION) and checksum-verified.
- name: Install kubectl on VM
if: matrix.suite == 'eks'
run: |
set -euo pipefail
SSH_OPTS=(-o StrictHostKeyChecking=no -i "${{ steps.vm.outputs.ssh_key }}")
ssh "${SSH_OPTS[@]}" tf-user@${{ steps.vm.outputs.wan_ip }} 'bash -s' <<'EOF'
set -euo pipefail
KVER=v1.32.5
case "$(uname -m)" in
x86_64) ARCH=amd64 ;;
aarch64) ARCH=arm64 ;;
*) echo "unsupported arch $(uname -m)" >&2; exit 1 ;;
esac
cd /tmp
curl -fsSLO "https://dl.k8s.io/release/${KVER}/bin/linux/${ARCH}/kubectl"
curl -fsSLO "https://dl.k8s.io/release/${KVER}/bin/linux/${ARCH}/kubectl.sha256"
echo "$(cat kubectl.sha256) kubectl" | sha256sum -c -
sudo install -m 0755 kubectl /usr/local/bin/kubectl
kubectl version --client
EOF
# RegisterContainerInstance boots the container instance from the ecs-node
# AMI, and resolveECSNodeAMI matches on the managed-by tag rather than the
# name, so the tag is not optional. The image directory is named for
# IMAGE_NAME in the manifest (ecs-agent), not for the make target.
- name: Build ecs-node AMI (libguestfs, runner)
if: matrix.suite == 'ecs'
working-directory: spinifex
run: |
set -euo pipefail
# direct backend = no libvirt. Set here rather than in the script:
# it has to be in the environment the build itself runs in.
export LIBGUESTFS_BACKEND=direct
"${GITHUB_WORKSPACE}/spinifex/.github/scripts/ensure-libguestfs.sh"
make build-ecs-node-image
- name: Import ecs-node AMI on VM
if: matrix.suite == 'ecs'
run: |
set -euo pipefail
eval "$(grep -E '^(ALPINE_VERSION|AMI_NAME|SYSTEM_TAG)=' \
spinifex/scripts/images/ecs-agent/manifest.conf)"
SSH_OPTS=(-o StrictHostKeyChecking=no -i "${{ steps.vm.outputs.ssh_key }}")
VM=tf-user@${{ steps.vm.outputs.wan_ip }}
scp "${SSH_OPTS[@]}" \
/tmp/ecs-agent-image-build/ecs-agent-alpine.raw \
"$VM":/tmp/ecs-agent-alpine.raw
ssh "${SSH_OPTS[@]}" "$VM" \
"spx admin images import --file /tmp/ecs-agent-alpine.raw \
--distro alpine --version ${ALPINE_VERSION} --arch x86_64 \
--boot-mode bios --ami-name ${AMI_NAME} --tag ${SYSTEM_TAG}" >/dev/null 2>&1
# CreateDBInstance boots the DB VM from an RDS AMI, so both engines' images
# must be in the store before the suite runs. The suite's psql and mariadb
# clients need no step here: they run inside a client VM that installs them
# from its own user-data, because a private-only endpoint is unreachable
# from the runner and from the cluster host alike.
- name: Build RDS AMIs (libguestfs, runner)
if: matrix.suite == 'rds'
working-directory: spinifex
run: |
set -euo pipefail
# direct backend = no libvirt. Set here rather than in the script:
# it has to be in the environment the build itself runs in.
export LIBGUESTFS_BACKEND=direct
"${GITHUB_WORKSPACE}/spinifex/.github/scripts/ensure-libguestfs.sh"
for IMAGE in rds-postgres rds-mariadb; do
make "build-${IMAGE}-image"
done
- name: Import RDS AMIs on VM
if: matrix.suite == 'rds'
run: |
set -euo pipefail
SSH_OPTS=(-o StrictHostKeyChecking=no -i "${{ steps.vm.outputs.ssh_key }}")
VM=tf-user@${{ steps.vm.outputs.wan_ip }}
for IMAGE in rds-postgres rds-mariadb; do
eval "$(grep -E '^(ALPINE_VERSION|AMI_NAME|SYSTEM_TAG)=' \
"spinifex/scripts/images/${IMAGE}/manifest.conf")"
# SYSTEM_TAG carries four tags, not one: managed-by marks it a system
# image, engine/engine-version are what an EngineVersion request
# resolves against, and the contract tag gates the data-volume format
# grant. --tag is repeatable, so expand rather than pass the whole
# string as a single value.
TAG_ARGS=""
for tag in ${SYSTEM_TAG}; do TAG_ARGS="${TAG_ARGS} --tag ${tag}"; done
scp "${SSH_OPTS[@]}" \
"/tmp/${IMAGE}-image-build/${IMAGE}-alpine.raw" \
"$VM":"/tmp/${IMAGE}-alpine.raw"
ssh "${SSH_OPTS[@]}" "$VM" \
"spx admin images import --file /tmp/${IMAGE}-alpine.raw \
--distro alpine --version ${ALPINE_VERSION} --arch x86_64 \
--boot-mode bios --ami-name ${AMI_NAME}${TAG_ARGS}" >/dev/null 2>&1
done
- name: Run Service Suite E2E
if: matrix.suite != '' && matrix.topo == 'single'
env:
SUITE: ${{ matrix.suite }}
run: |
set -euo pipefail
# Same guard as the permutation steps, against the full single-node
# set: a cell naming a suite this topology cannot fully exercise fails
# here rather than skipping half its assertions.
. "${GITHUB_WORKSPACE}/spinifex/.github/scripts/suite-sets.sh"
e2e_suites_assert_subset "e2e-nightly cell-${{ matrix.cell }}" "${SUITE}" "${E2E_SUITES_SINGLE}"
TMO="$(e2e_suite_timeout "${SUITE}")"
ssh -tt -o StrictHostKeyChecking=no -i "${{ steps.vm.outputs.ssh_key }}" \
tf-user@${{ steps.vm.outputs.wan_ip }} \
"set -u; mkdir -p '${ARTIFACT_DIR}'; cd \$HOME/e2e-tests/_bin; \
echo \"::group::suite ${SUITE}\"; \
date -u +%Y-%m-%dT%H:%M:%SZ > '${ARTIFACT_DIR}/test-${SUITE}.start'; \
GITHUB_ACTIONS=true FORCE_COLOR=1 SPINIFEX_E2E=1 SPINIFEX_MODE=single \
ARTIFACT_DIR='${ARTIFACT_DIR}' \
./${SUITE}.test -test.v -test.timeout=${TMO} \
2>&1 | tee '${ARTIFACT_DIR}/test-${SUITE}.log'; \
rc=\${PIPESTATUS[0]}; echo \"::endgroup::\"; \
if [ \$rc -ne 0 ]; then echo \"${SUITE}: FAILED\" >> '${ARTIFACT_DIR}/suite-status.txt'; fi; \
exit \$rc"
# The multi-node twin of the step above. Separate rather than merged
# because the suite needs every node's address to reach the whole cluster,
# which the single-node path has no way to supply.
- name: Run Multi Suite E2E
if: matrix.suite != '' && matrix.topo == 'real-multi'
working-directory: mulga/scripts/tofu-cluster
env:
SUITE: ${{ matrix.suite }}
run: |
set -euo pipefail
. "${GITHUB_WORKSPACE}/spinifex/.github/scripts/suite-sets.sh"
e2e_suites_assert_subset "e2e-nightly cell-${{ matrix.cell }}" "${SUITE}" "${E2E_SUITES_MULTI}"
TMO="$(e2e_suite_timeout "${SUITE}")"
INVENTORY=$(tofu output -json inventory)
NODE1_IP=$(echo "$INVENTORY" | jq -r '.nodes[0].wan_ip')
SSH_KEY=$(echo "$INVENTORY" | jq -r '.ssh_key_path')
SSH_KEY="${SSH_KEY/#\~/$HOME}"
NODES_CSV=""
NODE_COUNT=$(echo "$INVENTORY" | jq '.nodes | length')
for i in $(seq 0 $((NODE_COUNT - 1))); do
ip=$(echo "$INVENTORY" | jq -r ".nodes[$i].wan_ip")
NODES_CSV="${NODES_CSV:+$NODES_CSV,}$ip"
done
ssh -tt -o StrictHostKeyChecking=no -i "$SSH_KEY" tf-user@${NODE1_IP} \
"set -u; mkdir -p '${ARTIFACT_DIR}'; cd \$HOME/e2e-tests/_bin; \
echo \"::group::suite ${SUITE}\"; \
date -u +%Y-%m-%dT%H:%M:%SZ > '${ARTIFACT_DIR}/test-${SUITE}.start'; \
GITHUB_ACTIONS=true FORCE_COLOR=1 SPINIFEX_E2E=1 SPINIFEX_MODE=multinode \
SPINIFEX_NODES='${NODES_CSV}' SPINIFEX_NODE_IPS='${NODES_CSV}' \
SPINIFEX_SSH_USER=tf-user SPINIFEX_SSH_KEY=\$HOME/.ssh/tf-user-ap-southeast-2 \
ARTIFACT_DIR='${ARTIFACT_DIR}' \
./${SUITE}.test -test.v -test.timeout=${TMO} \
2>&1 | tee '${ARTIFACT_DIR}/test-${SUITE}.log'; \
rc=\${PIPESTATUS[0]}; echo \"::endgroup::\"; \
if [ \$rc -ne 0 ]; then echo \"${SUITE}: FAILED\" >> '${ARTIFACT_DIR}/suite-status.txt'; fi; \
exit \$rc"
- name: Import RDS PostgreSQL AMI
if: steps.gate.outputs.selected == 'true' && matrix.extra == 'tofu-examples'
if: matrix.extra == 'tofu-examples'
working-directory: mulga/scripts/tofu-cluster
run: |
set -euo pipefail
@@ -264,7 +542,7 @@ jobs:
"spx admin images import --name spinifex-rds-postgres"
- name: Run Tofu Workbooks
if: steps.gate.outputs.selected == 'true' && matrix.extra == 'tofu-examples'
if: matrix.extra == 'tofu-examples'
working-directory: mulga/scripts/tofu-cluster
# The driver emits go test -v markers per workbook; teeing to
# test-tofu.log feeds the same go-junit-report -> e2e-analyze -> upload
@@ -285,7 +563,7 @@ jobs:
exit $rc
- name: Run Reboot Resilience E2E
if: steps.gate.outputs.selected == 'true' && matrix.extra == 'reboot-single'
if: matrix.extra == 'reboot-single'
working-directory: mulga/scripts/tofu-cluster
# reboot.test runs from the runner (NOT via ssh-and-run) so it survives the
# systemctl-reboot-induced ssh disconnect mid-run.
@@ -320,8 +598,46 @@ jobs:
if [ $rc -ne 0 ]; then echo "reboot: FAILED" >> "${ARTIFACT_DIR}/suite-status.txt"; fi
exit $rc
- name: Sample runner-host load (after)
if: always()
run: |
mkdir -p "${ARTIFACT_DIR}"
{
echo "=== after: $(date -u +%Y-%m-%dT%H:%M:%SZ) runner=${RUNNER_NAME} host=$(hostname) ==="
echo "loadavg: $(cat /proc/loadavg)"
grep -E '^(MemTotal|MemAvailable|SwapFree):' /proc/meminfo || true
echo "cpu(steal is field 8): $(grep '^cpu ' /proc/stat || true)"
echo "running qemu: $(pgrep -c qemu-system-x86 || echo 0)"
} >> "${ARTIFACT_DIR}/hypervisor-load.txt"
# The node, not the runner's host, is what the guests are scheduled on, so
# its load is the figure that explains a starved guest. Never fails the
# cell: a node already gone is the case this is trying to describe.
- name: Sample node load (after)
if: always()
working-directory: mulga/scripts/tofu-cluster
run: |
set +e
mkdir -p "${ARTIFACT_DIR}"
INVENTORY=$(tofu output -json inventory 2>/dev/null)
NODE1_IP=$(echo "$INVENTORY" | jq -r '.nodes[0].wan_ip' 2>/dev/null)
SSH_KEY=$(echo "$INVENTORY" | jq -r '.ssh_key_path' 2>/dev/null)
SSH_KEY="${SSH_KEY/#\~/$HOME}"
[ -n "$NODE1_IP" ] && [ "$NODE1_IP" != "null" ] || exit 0
[ -n "$SSH_KEY" ] && [ "$SSH_KEY" != "null" ] || exit 0
{
echo "=== node after: $(date -u +%Y-%m-%dT%H:%M:%SZ) ip=${NODE1_IP} ==="
ssh -o StrictHostKeyChecking=no -o BatchMode=yes -o ConnectTimeout=10 \
-i "$SSH_KEY" "tf-user@${NODE1_IP}" \
'echo "host: $(hostname)"; echo "loadavg: $(cat /proc/loadavg)";
grep -E "^(MemTotal|MemAvailable|SwapFree):" /proc/meminfo;
echo "cpu(steal is field 8): $(grep "^cpu " /proc/stat)";
echo "running qemu: $(pgrep -c qemu-system-x86 || echo 0)"' 2>&1
} >> "${ARTIFACT_DIR}/node-load.txt"
exit 0
- name: Collect e2e artifacts
if: always() && steps.gate.outputs.selected == 'true'
if: always()
working-directory: mulga/scripts/tofu-cluster
run: |
set +e
@@ -344,6 +660,11 @@ jobs:
> "${ARTIFACT_DIR}/spinifex-journal-node${idx}.log" 2>/dev/null
ssh "${SSH_OPTS[@]}" tf-user@${IP} 'sudo journalctl -u openvswitch-ipsec.service --no-pager --output=short-iso --since "60 min ago"' \
> "${ARTIFACT_DIR}/openvswitch-ipsec-node${idx}.log" 2>/dev/null
# The unit filter above drops kernel and OOM messages, which are
# exactly what explains one node behaving unlike its siblings.
# Gzipped on the far side; the whole journal is far larger.
ssh "${SSH_OPTS[@]}" tf-user@${IP} 'sudo journalctl --no-pager --output=short-iso --since "90 min ago" | gzip -6' \
> "${ARTIFACT_DIR}/full-journal-node${idx}.log.gz" 2>/dev/null
# ovn-controller and ovs-vswitchd log to files, never to journald:
# without these a cross-chassis black-hole cannot be attributed to
# IPsec versus SB port binding.
@@ -356,14 +677,74 @@ jobs:
> "${ARTIFACT_DIR}/spinifex-journal.log" 2>/dev/null || true
echo "collected: $(ls "${ARTIFACT_DIR}" 2>/dev/null | wc -l) entries"
# Whether br-mgmt is a per-host island or one L2 segment spanning the
# cluster decides whether a system instance can reach the gateway address
# it was baked with. Nothing in a failing run records which one was built,
# and the environment is destroyed before it can be asked.
- name: Collect management-fabric topology
if: always()
working-directory: mulga/scripts/tofu-cluster
run: |
set +e
mkdir -p "${ARTIFACT_DIR}"
OUT="${ARTIFACT_DIR}/mgmt-fabric.txt"
INVENTORY=$(tofu output -json inventory 2>/dev/null) || { echo "no inventory; skipping"; exit 0; }
SSH_KEY=$(echo "$INVENTORY" | jq -r '.ssh_key_path'); SSH_KEY="${SSH_KEY/#\~/$HOME}"
NODE_COUNT=$(echo "$INVENTORY" | jq '.nodes | length')
SSH_OPTS=(-o StrictHostKeyChecking=no -o ConnectTimeout=5 -i "$SSH_KEY")
declare -a WAN=() MGMT=()
for i in $(seq 0 $((NODE_COUNT - 1))); do
WAN[$i]=$(echo "$INVENTORY" | jq -r ".nodes[$i].wan_ip")
done
for i in $(seq 0 $((NODE_COUNT - 1))); do
idx=$((i + 1))
{
echo "=== node${idx} (${WAN[$i]}) ==="
ssh "${SSH_OPTS[@]}" tf-user@"${WAN[$i]}" \
'sudo ovs-vsctl list-ports br-mgmt 2>&1 | sed "s/^/ports: /"; \
ip -4 addr show br-mgmt 2>&1 | sed "s/^/addr: /"; \
sudo ovs-vsctl get Bridge br-mgmt fail_mode 2>&1 | sed "s/^/fail: /"'
echo
} >> "$OUT" 2>&1
MGMT[$i]=$(ssh "${SSH_OPTS[@]}" tf-user@"${WAN[$i]}" \
"ip -4 -o addr show br-mgmt 2>/dev/null | awk '{print \$4}' | cut -d/ -f1" 2>/dev/null)
done
# An island cluster reaches only its own address and that is correct;
# a spanned one must reach every peer. Either result is a finding, and
# a mixed result is the failure this matrix exists to catch.
{
echo "=== br-mgmt reachability (row = prober, column = target) ==="
printf '%-10s' "from\\to"
for i in $(seq 0 $((NODE_COUNT - 1))); do printf '%-18s' "node$((i+1)):${MGMT[$i]:-none}"; done
echo
for i in $(seq 0 $((NODE_COUNT - 1))); do
printf '%-10s' "node$((i+1))"
for j in $(seq 0 $((NODE_COUNT - 1))); do
if [ -z "${MGMT[$j]}" ]; then printf '%-18s' "-"; continue; fi
if ssh "${SSH_OPTS[@]}" tf-user@"${WAN[$i]}" \
"ping -c1 -W1 ${MGMT[$j]} >/dev/null 2>&1"; then
printf '%-18s' ok
else
printf '%-18s' FAIL
fi
done
echo
done
} >> "$OUT" 2>&1
cat "$OUT"
- name: Install go-junit-report
if: always() && steps.gate.outputs.selected == 'true'
if: always()
run: |
go install github.com/jstemmer/go-junit-report/v2@latest
echo "$HOME/go/bin" >> "$GITHUB_PATH"
- name: Convert test logs to JUnit XML
if: always() && steps.gate.outputs.selected == 'true'
if: always()
run: |
shopt -s nullglob
for LOG in "${ARTIFACT_DIR}"/test-*.log; do
@@ -374,7 +755,7 @@ jobs:
# Tolerated on failure: the sink being unreachable must not turn a
# green test run red.
- name: Ship test results to Elasticsearch
if: always() && steps.gate.outputs.selected == 'true'
if: always()
continue-on-error: true
uses: ./spinifex/.github/actions/junit-to-es
with:
@@ -382,7 +763,7 @@ jobs:
cell: ${{ matrix.cell }}
- name: Analyze e2e failures
if: always() && steps.gate.outputs.selected == 'true'
if: always()
uses: ./spinifex/.github/actions/e2e-analyze
with:
junit-glob: ${{ env.ARTIFACT_DIR }}/junit-*.xml
@@ -390,18 +771,29 @@ jobs:
title: "E2E failure analysis (cell ${{ matrix.cell }})"
- uses: actions/upload-artifact@v7
if: always() && steps.gate.outputs.selected == 'true'
if: always()
with:
name: e2e-artifacts-cell-${{ matrix.cell }}-${{ github.run_id }}
path: ${{ env.ARTIFACT_DIR }}
if-no-files-found: warn
retention-days: 14
# The XML on its own, so the roll-up can read every cell's counts without
# pulling nineteen journals it has no use for.
- name: Upload junit only
if: always()
uses: actions/upload-artifact@v7
with:
name: junit-cell-${{ matrix.cell }}-${{ github.run_id }}
path: ${{ env.ARTIFACT_DIR }}/junit-*.xml
if-no-files-found: ignore
retention-days: 14
# Gated like every other step, not just always(): a cell the dispatch
# deselected never checked out or ran tofu init, so destroying would fail
# on missing plugins and report the cell red for correctly doing nothing.
- name: Teardown
if: always() && steps.gate.outputs.selected == 'true'
if: always()
working-directory: mulga/scripts/tofu-cluster
env:
MATRIX_HOST: ${{ matrix.host }}
@@ -412,13 +804,15 @@ jobs:
case "$MATRIX_HOST" in
ubuntu-*) TOFU_VAR_ARGS="-var=host_image_override=${UBUNTU_GOLD_IMAGE}" ;;
esac
source "${GITHUB_WORKSPACE}/spinifex/.github/scripts/ci-fmt.sh"
tofu workspace select -or-create "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1
timeout 120 ./flush-telemetry.sh "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1 || true
tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars ${TOFU_VAR_ARGS} -auto-approve -input=false >> "$TOFU_LOG" 2>&1
# shellcheck disable=SC2086
retry_run "tofu destroy" "$TOFU_LOG" tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars ${TOFU_VAR_ARGS} -auto-approve -input=false
./scrub-env.sh "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1 || true
- name: Upload tofu log
if: always() && steps.gate.outputs.selected == 'true'
if: always()
uses: actions/upload-artifact@v7
with:
name: tofu-log-cell-${{ matrix.cell }}-${{ github.run_id }}
@@ -428,7 +822,7 @@ jobs:
# Bare-metal single-node suite. Doesn't fit the tofu matrix shape, so it
# runs as a sibling job via the reusable e2e-baremetal workflow. Gated like a
# matrix cell ("cell 20"): every weeknight cron, or dispatch with 20 selected
# matrix cell ("cell 20"): every nightly cron, or dispatch with 20 selected
# (blank cells = all). Tests `dev` on schedule to match GIT_BRANCH above.
baremetal:
name: cell-20 (baremetal/pxe/real-hw)
@@ -448,6 +842,34 @@ jobs:
otel-attrs: ci.run_id=${{ github.run_id }},ci.workflow=${{ github.workflow }},ci.cell=20,ci.matrix=baremetal/pxe/real-hw
secrets: inherit
# The guest disk-performance gate. Same bare-metal node as cell 20 and
# therefore queued behind it by the shared concurrency group, which is the
# point: perf numbers off a cell still running other suites are noise, and
# the availability assertions measure host responsiveness under a single
# tenant's load, which a co-tenant correctness suite would confound.
#
# It reimages the cluster first, like every other caller of that workflow, so
# it is a fresh node rather than whatever cell-20 left behind.
diskperf:
name: cell-28 (baremetal/diskperf)
needs: baremetal
if: >-
!cancelled() && (
github.event_name == 'schedule' ||
inputs.cells == '' ||
contains(format(',{0},', inputs.cells), ',28,')
)
permissions:
contents: read
statuses: write
uses: ./.github/workflows/e2e-baremetal.yml
with:
ref: ${{ github.event_name == 'schedule' && 'dev' || github.ref_name }}
cell: "28"
suites: diskperf
otel-attrs: ci.run_id=${{ github.run_id }},ci.workflow=${{ github.workflow }},ci.cell=28,ci.matrix=baremetal/pxe/diskperf
secrets: inherit
# ISO cells. Unlike 1-19 these need no tofu: the harnesses run QEMU directly
# on the runner. The nodes are therefore L1, which keeps the instances they
# launch at L2 and their benchmark numbers comparable between the two cells.
@@ -634,6 +1056,22 @@ jobs:
EOF
./test/test-workload.sh --env /tmp/cell21.env --benchmark
# The node's own journal, which nothing else preserves: the serial log
# stops at login and the harness owns the only node there is. Pulled
# before teardown and never fatal, so a passing night keeps it too.
- name: Collect node journal
if: always() && steps.smoke.outputs.node_ip != ''
env:
NODE_IP: ${{ steps.smoke.outputs.node_ip }}
run: |
mkdir -p /tmp/cell21-artifacts-$GITHUB_RUN_ID
timeout 180 ssh -o BatchMode=yes -o StrictHostKeyChecking=no \
-o UserKnownHostsFile=/dev/null -o ConnectTimeout=10 \
-i "$HOME/.ssh/spinifex-key" "spinifex@${NODE_IP}" \
'sudo journalctl -b --no-pager --output=short-iso | gzip -6' \
> "/tmp/cell21-artifacts-$GITHUB_RUN_ID/node-journal.log.gz" ||
echo "::warning::could not pull the node journal from ${NODE_IP}"
- name: Collect logs
if: always()
run: |
@@ -697,7 +1135,7 @@ jobs:
# them together would need 32 GiB on one box.
needs: [iso-single]
if: >-
always() && (
!cancelled() && (
github.event_name == 'schedule' ||
inputs.cells == '' ||
contains(format(',{0},', inputs.cells), ',22,') )
@@ -852,13 +1290,14 @@ jobs:
# bottlebrush, so "a different label" would not have separated them. needs: on
# a matrix job waits for every leg, so this starts on a quiet machine.
#
# always() because a failed cell must not silently skip the benchmark and
# leave a green-looking night with no number in it.
# !cancelled() rather than always(): a failed cell must not silently skip the
# benchmark and leave a green-looking night with no number in it, but always()
# also overrides cancellation and keeps the run starting jobs after a cancel.
performance:
name: cell-23 (predastore/throughput)
needs: [matrix, baremetal, iso-single, iso-multi]
if: >-
always() && (
!cancelled() && (
github.event_name == 'schedule' ||
inputs.cells == '' ||
contains(format(',{0},', inputs.cells), ',23,') )
@@ -970,12 +1409,15 @@ jobs:
name: cell-24 (predastore/fault-injection)
needs: [matrix, baremetal, iso-single, iso-multi, performance]
if: >-
always() && (
!cancelled() && (
github.event_name == 'schedule' ||
inputs.cells == '' ||
contains(format(',{0},', inputs.cells), ',24,') )
runs-on: [self-hosted, ci-single]
timeout-minutes: 30
# The thirteen scenarios run serially and are ~35 minutes of work on their
# own, measured from the per-scenario predastore matrix, before checkout
# and build. 30 could not have passed whatever the disk did.
timeout-minutes: 75
env:
# Freezes a follower. STRESS_HOST=leader is a known failure: a gate keeps
# the frozen node first in its meta read order and pays the full client
@@ -988,12 +1430,20 @@ jobs:
- name: Clean Workspace
run: sudo rm -rf --one-file-system "$GITHUB_WORKSPACE"/*
# STRESS_WORK_ROOT is set as well as TMPDIR because the harness ignores
# TMPDIR for the scenarios that need the most disk: large-object and
# multipart-upload default to $HOME/.cache/predastore-e2e, on the theory
# that TMPDIR is commonly tmpfs. Here it is a 938G nvme, so pointing them
# at it is both correct and the only path with room.
- name: Configure work directories
run: |
E2E_ROOT=/mnt/nvme/e2e
mkdir -p "$E2E_ROOT/work" 2>/dev/null ||
{ sudo mkdir -p "$E2E_ROOT/work" && sudo chown "$(id -u):$(id -g)" "$E2E_ROOT" "$E2E_ROOT/work"; }
for d in work stress-work; do
mkdir -p "$E2E_ROOT/$d" 2>/dev/null ||
{ sudo mkdir -p "$E2E_ROOT/$d" && sudo chown "$(id -u):$(id -g)" "$E2E_ROOT" "$E2E_ROOT/$d"; }
done
echo "TMPDIR=$E2E_ROOT/work" >> "$GITHUB_ENV"
echo "STRESS_WORK_ROOT=$E2E_ROOT/stress-work" >> "$GITHUB_ENV"
echo "STRESS_RESULTS_ROOT=$E2E_ROOT/predastore-stress-$GITHUB_RUN_ID" >> "$GITHUB_ENV"
- name: Checkout Spinifex
@@ -1021,15 +1471,25 @@ jobs:
- name: Preflight
run: |
set -euo pipefail
mkdir -p "$TMPDIR"
find "$TMPDIR" -maxdepth 1 -name 'predastore-e2e-stress.*' \
-type d -mtime +0 -exec rm -rf {} + 2>/dev/null || true
df -h "$TMPDIR"
avail_gb=$(df -BG --output=avail "$TMPDIR" | tail -1 | tr -dc '0-9')
if [ "$avail_gb" -lt 10 ]; then
echo "::error::$TMPDIR has ${avail_gb} GiB free; the run needs headroom above predastore's 5% watermark."
exit 1
fi
mkdir -p "$TMPDIR" "$STRESS_WORK_ROOT"
for d in "$TMPDIR" "$STRESS_WORK_ROOT" "$HOME/.cache/predastore-e2e"; do
[ -d "$d" ] || continue
find "$d" -maxdepth 1 -name 'predastore-e2e-stress.*' \
-type d -mtime +0 -exec rm -rf {} + 2>/dev/null || true
done
# Both paths, because they can be different filesystems and the one
# holding the large objects is not the one TMPDIR names. large-object
# needs 2GiB and 4GiB sharded with parity, multipart 1GiBx8.
rc=0
for d in "$TMPDIR" "$STRESS_WORK_ROOT"; do
df -h "$d"
avail_gb=$(df -BG --output=avail "$d" | tail -1 | tr -dc '0-9')
if [ "$avail_gb" -lt 40 ]; then
echo "::error::$d has ${avail_gb} GiB free; the run needs headroom above predastore's 5% watermark and room for the 1GiBx8 multipart case."
rc=1
fi
done
exit "$rc"
# Leftovers from a run killed before its cleanup trap: a surviving node
# holds its ports and fails this run somewhere unrelated.
@@ -1059,3 +1519,40 @@ jobs:
run: |
pkill -KILL -f '/bin/s3d -config' || true
rm -rf "$STRESS_RESULTS_ROOT"
# Reading a nightly meant opening every cell job, and the Actions UI does not
# separate a cell that failed from one cancelled out from under it. needs:
# every cell so this runs last; always() so a red run is the one it describes.
summary:
name: run summary
needs:
[
select,
matrix,
baremetal,
diskperf,
iso-single,
iso-multi,
performance,
stress,
]
if: always()
runs-on: ubuntu-26.04
steps:
- uses: actions/checkout@v7
with:
sparse-checkout: .github/scripts/e2e-run-summary.py
sparse-checkout-cone-mode: false
- name: Collect junit
uses: actions/download-artifact@v8
with:
pattern: junit-cell-*-${{ github.run_id }}
path: junit
continue-on-error: true
- name: Write run summary
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
JUNIT_ROOT: junit
run: python3 .github/scripts/e2e-run-summary.py
+34 -31
View File
@@ -43,18 +43,22 @@ on:
required: false
default: ""
# Shared with e2e-nightly.yml and e2e-ddil.yml: every one of them provisions
# many cells on the same runner pools and the same hypervisors, so two in
# flight compete for their disks and their libvirt guests. Queued rather than
# cancelled — a run in progress is someone's result, not a stale one. e2e.yml
# is outside the group: one cluster per run is cheap enough to overlap.
concurrency:
group: e2e-hypervisors
cancel-in-progress: false
permissions:
contents: read
# actions: read lets the otel-job-id action look up a provision job's numeric
# id via the Actions API — github.job is only the YAML key, no context
# exposes it.
actions: read
env:
GIT_BRANCH: ${{ github.ref_name }}
TEST_RUN: ${{ inputs.test_run || '' }}
# OTLP export to the central Elastic sink (wattle). Repo variable overrides;
# empty string disables telemetry entirely.
SPINIFEX_OTLP_ENDPOINT: ${{ vars.SPINIFEX_OTLP_ENDPOINT || 'http://192.168.1.13:8200' }}
SPINIFEX_OTEL_ATTRS: ci.run_id=${{ github.run_id }},ci.workflow=${{ github.workflow }}
@@ -73,12 +77,6 @@ jobs:
matrix: ${{ steps.suites.outputs.matrix }}
env:
TOFU_LOG: /tmp/e2e-tofu-single-${{ github.run_id }}.log
# This job (not single_suite) is where "Bootstrap via Binary Install"
# bakes SPINIFEX_OTEL_ATTRS into the VM's telemetry.env, before the
# suite matrix even exists — single_suite's legs share this one
# already-booted VM, so daemon telemetry can't be scoped any finer
# than "single env" per suite leg. ci.cell=single distinguishes it
# from multi_provision's VMs within the same run id.
SPINIFEX_OTEL_ATTRS: ci.run_id=${{ github.run_id }},ci.workflow=${{ github.workflow }},ci.cell=single
steps:
- name: Reap Orphan Procs
@@ -143,10 +141,9 @@ jobs:
LIST="${REQUESTED:-${E2E_SUITES_SINGLE}}"
e2e_suites_assert_subset "single_suites" "${LIST}" "${E2E_SUITES_SINGLE}"
echo "list=${LIST}" >> "$GITHUB_OUTPUT"
# Unquoted on purpose: the space-separated list must word-split into
# one JSON element per suite.
# shellcheck disable=SC2086
JSON=$(printf '%s\n' ${LIST} | jq -R . | jq -cs .)
# 45 is the floor this job has always had, so resolving each leg's
# budget can only raise it — storagefault and rds ask for more.
JSON=$(e2e_suite_matrix "${LIST}" 45)
echo "matrix=${JSON}" >> "$GITHUB_OUTPUT"
- name: Reclaim docker build cache
@@ -182,7 +179,7 @@ jobs:
quiet_run "tofu workspace select" "$TOFU_LOG" tofu workspace select -or-create "${SPINIFEX_CI_ENV}"
quiet_run "tofu destroy (pre-clean)" "$TOFU_LOG" tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false || true
quiet_run "scrub-env (pre-clean)" "$TOFU_LOG" ./scrub-env.sh "${SPINIFEX_CI_ENV}" || true
quiet_run "tofu apply" "$TOFU_LOG" tofu apply -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false
retry_run "tofu apply" "$TOFU_LOG" tofu apply -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false
ok "VM provisioned"
- name: Bootstrap via Binary Install
@@ -311,14 +308,16 @@ jobs:
retention-days: 14
single_suite:
name: Single env · suite
name: Single env · ${{ matrix.suite }}
needs: single_provision
runs-on: [self-hosted, ci-single]
timeout-minutes: 45
# Per leg, from suite-sets.sh: one number for every suite would have to be
# storagefault's, and every other leg would then hang for 105 minutes.
timeout-minutes: ${{ matrix.timeout }}
strategy:
fail-fast: false
matrix:
suite: ${{ fromJSON(needs.single_provision.outputs.matrix) }}
include: ${{ fromJSON(needs.single_provision.outputs.matrix) }}
env:
WAN_IP: ${{ needs.single_provision.outputs.wan_ip }}
SSH_KEY: ${{ needs.single_provision.outputs.ssh_key }}
@@ -343,6 +342,8 @@ jobs:
run: |
set +e
SUITE="${{ matrix.suite }}"
. "${GITHUB_WORKSPACE}/spinifex/.github/scripts/suite-sets.sh"
TMO="$(e2e_suite_timeout "${SUITE}")"
VM_HOST="tf-user@${WAN_IP}"
TEST_RUN_FLAG=""
[ -n "${TEST_RUN}" ] && TEST_RUN_FLAG="-test.run=${TEST_RUN}"
@@ -360,7 +361,7 @@ jobs:
GITHUB_ACTIONS=true FORCE_COLOR=1 SPINIFEX_E2E=1 SPINIFEX_MODE=single \
SPINIFEX_E2E_RUN_ID='${{ github.run_id }}' \
ARTIFACT_DIR='${ARTIFACT_DIR}' \
./${SUITE}.test -test.v -test.timeout=30m ${TEST_RUN_FLAG} \
./${SUITE}.test -test.v -test.timeout=${TMO} ${TEST_RUN_FLAG} \
2>&1 | tee '${ARTIFACT_DIR}/test-${SUITE}.log' \
| sed -uE 's/^[[:space:]]+([a-zA-Z0-9_]+_test\.go:[0-9]+: )?//'; \
exit \${PIPESTATUS[0]}"
@@ -516,7 +517,7 @@ jobs:
find "$WS" -name '.terraform.tfstate.lock.info' -delete 2>/dev/null
quiet_run "tofu workspace select" "$TOFU_LOG" tofu workspace select -or-create "${SPINIFEX_CI_ENV}"
quiet_run "telemetry flush" "$TOFU_LOG" timeout 120 ./flush-telemetry.sh "${SPINIFEX_CI_ENV}" || true
quiet_run "tofu destroy" "$TOFU_LOG" timeout 600 tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false
retry_run "tofu destroy" "$TOFU_LOG" timeout 600 tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false
quiet_run "scrub-env" "$TOFU_LOG" ./scrub-env.sh "${SPINIFEX_CI_ENV}"
ok "teardown complete"
@@ -627,7 +628,7 @@ jobs:
quiet_run "tofu workspace select" "$TOFU_LOG" tofu workspace select -or-create "${SPINIFEX_CI_ENV}"
quiet_run "tofu destroy (pre-clean)" "$TOFU_LOG" tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false || true
quiet_run "scrub-env (pre-clean)" "$TOFU_LOG" ./scrub-env.sh "${SPINIFEX_CI_ENV}" || true
quiet_run "tofu apply" "$TOFU_LOG" tofu apply -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false
retry_run "tofu apply" "$TOFU_LOG" tofu apply -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false
ok "3-node cluster provisioned"
- name: Bootstrap via Binary Install
@@ -669,10 +670,9 @@ jobs:
LIST="${REQUESTED:-${E2E_SUITES_MULTI}}"
e2e_suites_assert_subset "multi_suites" "${LIST}" "${E2E_SUITES_MULTI}"
echo "list=${LIST}" >> "$GITHUB_OUTPUT"
# Unquoted on purpose: the space-separated list must word-split into
# one JSON element per suite.
# shellcheck disable=SC2086
JSON=$(printf '%s\n' ${LIST} | jq -R . | jq -cs .)
# 60 is the floor this job has always had; multi-node legs carry a
# heavier setup than single-node ones, which is why it is higher.
JSON=$(e2e_suite_matrix "${LIST}" 60)
echo "matrix=${JSON}" >> "$GITHUB_OUTPUT"
- name: Build multinode binaries on runner
@@ -722,14 +722,15 @@ jobs:
retention-days: 14
multi_suite:
name: Multi env · suite
name: Multi env · ${{ matrix.suite }}
needs: multi_provision
runs-on: [self-hosted, ci-multi]
timeout-minutes: 60
# See the note on single_suite: the budget is the suite's, not the job's.
timeout-minutes: ${{ matrix.timeout }}
strategy:
fail-fast: false
matrix:
suite: ${{ fromJSON(needs.multi_provision.outputs.matrix) }}
include: ${{ fromJSON(needs.multi_provision.outputs.matrix) }}
env:
NODE1_IP: ${{ needs.multi_provision.outputs.node1_ip }}
SSH_KEY: ${{ needs.multi_provision.outputs.ssh_key }}
@@ -755,6 +756,8 @@ jobs:
run: |
set +e
SUITE="${{ matrix.suite }}"
. "${GITHUB_WORKSPACE}/spinifex/.github/scripts/suite-sets.sh"
TMO="$(e2e_suite_timeout "${SUITE}")"
VM_HOST="tf-user@${NODE1_IP}"
TEST_RUN_FLAG=""
[ -n "${TEST_RUN}" ] && TEST_RUN_FLAG="-test.run=${TEST_RUN}"
@@ -773,7 +776,7 @@ jobs:
SPINIFEX_NODES='${NODES_CSV}' SPINIFEX_NODE_IPS='${NODES_CSV}' \
SPINIFEX_SSH_USER=tf-user SPINIFEX_SSH_KEY=\$HOME/.ssh/tf-user-ap-southeast-2 \
ARTIFACT_DIR='${ARTIFACT_DIR}' \
./${SUITE}.test -test.v -test.timeout=30m ${TEST_RUN_FLAG} \
./${SUITE}.test -test.v -test.timeout=${TMO} ${TEST_RUN_FLAG} \
2>&1 | tee '${ARTIFACT_DIR}/test-${SUITE}.log' \
| sed -uE 's/^[[:space:]]+([a-zA-Z0-9_]+_test\.go:[0-9]+: )?//'; \
exit \${PIPESTATUS[0]}"
@@ -899,7 +902,7 @@ jobs:
find "$WS" -name '.terraform.tfstate.lock.info' -delete 2>/dev/null
quiet_run "tofu workspace select" "$TOFU_LOG" tofu workspace select -or-create "${SPINIFEX_CI_ENV}"
quiet_run "telemetry flush" "$TOFU_LOG" timeout 120 ./flush-telemetry.sh "${SPINIFEX_CI_ENV}" || true
quiet_run "tofu destroy" "$TOFU_LOG" timeout 600 tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false
retry_run "tofu destroy" "$TOFU_LOG" timeout 600 tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false
quiet_run "scrub-env" "$TOFU_LOG" ./scrub-env.sh "${SPINIFEX_CI_ENV}"
ok "teardown complete"
+69 -12
View File
@@ -7,6 +7,7 @@ on:
- dev
paths:
- "**/*.go"
- "!**/*_test.go"
- go.mod
- go.sum
- Makefile
@@ -26,20 +27,67 @@ on:
permissions:
contents: read
statuses: write
# actions: read lets the otel-job-id action look up a job's numeric id via
# the Actions API — github.job is only the YAML key, no context exposes it.
actions: read
env:
GIT_BRANCH: ${{ github.ref_name }}
SUITES: ${{ inputs.suites || 'e2e-single e2e-multinode e2e-cert e2e-lb e2e-iam e2e-partialblock' }}
TEST_RUN: ${{ inputs.test_run || '' }}
# OTLP export to the central Elastic sink (wattle). Repo variable overrides;
# empty string disables telemetry entirely.
SPINIFEX_OTLP_ENDPOINT: ${{ vars.SPINIFEX_OTLP_ENDPOINT || 'http://192.168.1.13:8200' }}
SPINIFEX_OTEL_ATTRS: ci.run_id=${{ github.run_id }},ci.workflow=${{ github.workflow }}
jobs:
# The inner timeout must fire before the outer one. `go test` reports where it
# stopped and the suite's own failure; a job cancelled on timeout reports only
# "The operation was canceled" and takes the artifacts with it.
#
# A job budget cannot be a literal here because the suite set is a dispatch
# input: storagefault asks for 90m and diskperf for 180m, both eligible on one
# node, so any fixed number is either wrong for them or wasteful for everyone
# else. Resolving it from suite-sets.sh keeps the one source of truth and
# leaves the default push set at exactly the 60m it has always had.
plan:
name: Resolve Job Budgets
runs-on: ubuntu-26.04
timeout-minutes: 5
outputs:
single_timeout: ${{ steps.budgets.outputs.single_timeout }}
multi_timeout: ${{ steps.budgets.outputs.multi_timeout }}
steps:
- uses: actions/checkout@v7
with:
path: spinifex
sparse-checkout: .github/scripts
- name: Resolve Budgets
id: budgets
env:
# Provision, install and teardown around the suites, plus the headroom
# a multi-suite job needs for whatever ran before the one that hangs.
SETUP_ALLOWANCE_MIN: 15
# What the job has always had. Kept as a floor so resolving the budget
# can only ever raise it, never quietly shorten an existing run.
FLOOR_MIN: 60
run: |
. "${GITHUB_WORKSPACE}/spinifex/.github/scripts/suite-sets.sh"
# Only max() is a sound bound: the suites share one job, so a sum would
# demand hours for a set that has never taken them. Max is what makes
# the hanging suite panic on its own timeout first, which is the point.
budget() {
local want="${FLOOR_MIN}" s t
for s in $(echo "${SUITES}" | sed 's/e2e-//g' | tr ' ' '\n' | grep -xE "$1"); do
t="$(e2e_suite_timeout "${s}")"
t=$(( ${t%m} + SETUP_ALLOWANCE_MIN ))
[ "${t}" -gt "${want}" ] && want="${t}"
done
echo "${want}"
}
single=$(budget "${E2E_SUITES_SINGLE_RE}")
multi=$(budget "${E2E_SUITES_MULTI_RE}")
echo "single_timeout=${single}" >> "$GITHUB_OUTPUT"
echo "multi_timeout=${multi}" >> "$GITHUB_OUTPUT"
echo "suites='${SUITES}' -> single ${single}m, multi ${multi}m"
e2e_single:
name: Single-Node E2E
# The dispatch check gates on exactly the suites this job's Run step
@@ -51,8 +99,9 @@ jobs:
# tear down. Push triggers keep running via the null-check on
# inputs.suites.
if: github.event_name != 'workflow_dispatch' || contains(github.event.inputs.suites, 'e2e-single') || contains(github.event.inputs.suites, 'e2e-iam') || contains(github.event.inputs.suites, 'e2e-cert') || contains(github.event.inputs.suites, 'e2e-eks') || contains(github.event.inputs.suites, 'e2e-ecs') || contains(github.event.inputs.suites, 'e2e-storagegrowth') || contains(github.event.inputs.suites, 'e2e-partialblock') || contains(github.event.inputs.suites, 'e2e-rds')
needs: plan
runs-on: [self-hosted, ci-single]
timeout-minutes: 60
timeout-minutes: ${{ fromJSON(needs.plan.outputs.single_timeout) }}
env:
TOFU_LOG: /tmp/e2e-tofu-single-${{ github.run_id }}.log
ARTIFACT_DIR: /tmp/spinifex-e2e-artifacts-${{ github.run_id }}
@@ -152,7 +201,7 @@ jobs:
quiet_run "tofu workspace select" "$TOFU_LOG" tofu workspace select -or-create "${SPINIFEX_CI_ENV}"
quiet_run "tofu destroy (pre-clean)" "$TOFU_LOG" tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false || true
quiet_run "scrub-env (pre-clean)" "$TOFU_LOG" ./scrub-env.sh "${SPINIFEX_CI_ENV}" || true
quiet_run "tofu apply" "$TOFU_LOG" tofu apply -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false
retry_run "tofu apply" "$TOFU_LOG" tofu apply -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false
ok "VM provisioned"
- name: Bootstrap via Binary Install
@@ -357,6 +406,10 @@ jobs:
# suite-sets.sh also carries the per-suite rationale.
. "${GITHUB_WORKSPACE}/spinifex/.github/scripts/suite-sets.sh"
SUITES_RUN=$(echo "${SUITES}" | sed 's/e2e-//g' | tr ' ' '\n' | grep -xE "${E2E_SUITES_SINGLE_RE}" | tr '\n' ' ')
# Resolve each suite's timeout here, where suite-sets.sh is readable, and
# carry the pairs into the remote loop below — the VM has no checkout.
SUITE_TMOS=""
for s in ${SUITES_RUN}; do SUITE_TMOS="${SUITE_TMOS}${s}:$(e2e_suite_timeout "${s}") "; done
if [ -z "$(echo "${SUITES_RUN}" | tr -d ' ')" ]; then
echo "no in-VM suites requested (SUITES=${SUITES}); skipping"
echo "rc=0" >> "$GITHUB_OUTPUT"
@@ -388,7 +441,7 @@ jobs:
echo \"::group::suite \$SUITE\"; \
printf '\n%s%s━━ suite %s ━━%s\n' \"\$B\" \"\$C\" \"\$SUITE\" \"\$R\"; \
date -u +%Y-%m-%dT%H:%M:%SZ > '${ARTIFACT_DIR}/test-'\$SUITE'.start'; \
case \$SUITE in rds) TMO=50m ;; *) TMO=30m ;; esac; \
TMO=30m; for kv in ${SUITE_TMOS}; do case \$kv in \$SUITE:*) TMO=\${kv#*:} ;; esac; done; \
GITHUB_ACTIONS=true FORCE_COLOR=1 SPINIFEX_E2E=1 SPINIFEX_MODE=single \
ARTIFACT_DIR='${ARTIFACT_DIR}' \
./\$SUITE.test -test.v -test.timeout=\$TMO ${TEST_RUN_FLAG} \
@@ -520,7 +573,7 @@ jobs:
find "$WS" -name '.terraform.tfstate.lock.info' -delete 2>/dev/null
quiet_run "tofu workspace select" "$TOFU_LOG" tofu workspace select -or-create "${SPINIFEX_CI_ENV}"
quiet_run "telemetry flush" "$TOFU_LOG" timeout 120 ./flush-telemetry.sh "${SPINIFEX_CI_ENV}" || true
quiet_run "tofu destroy" "$TOFU_LOG" timeout 600 tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false
retry_run "tofu destroy" "$TOFU_LOG" timeout 600 tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false
quiet_run "scrub-env" "$TOFU_LOG" ./scrub-env.sh "${SPINIFEX_CI_ENV}"
ok "teardown complete"
@@ -549,8 +602,9 @@ jobs:
# provisions the 3-node env when requested on its own, and the env stays
# unprovisioned when none of them are asked for.
if: github.event_name != 'workflow_dispatch' || contains(github.event.inputs.suites, 'e2e-multinode') || contains(github.event.inputs.suites, 'e2e-lb') || contains(github.event.inputs.suites, 'e2e-cert')
needs: plan
runs-on: [self-hosted, ci-multi]
timeout-minutes: 60
timeout-minutes: ${{ fromJSON(needs.plan.outputs.multi_timeout) }}
env:
TOFU_LOG: /tmp/e2e-tofu-multi-${{ github.run_id }}.log
ARTIFACT_DIR: /tmp/spinifex-e2e-multi-artifacts-${{ github.run_id }}
@@ -646,7 +700,7 @@ jobs:
quiet_run "tofu workspace select" "$TOFU_LOG" tofu workspace select -or-create "${SPINIFEX_CI_ENV}"
quiet_run "tofu destroy (pre-clean)" "$TOFU_LOG" tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false || true
quiet_run "scrub-env (pre-clean)" "$TOFU_LOG" ./scrub-env.sh "${SPINIFEX_CI_ENV}" || true
quiet_run "tofu apply" "$TOFU_LOG" tofu apply -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false
retry_run "tofu apply" "$TOFU_LOG" tofu apply -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false
ok "3-node cluster provisioned"
- name: Bootstrap via Binary Install
@@ -772,6 +826,8 @@ jobs:
# Allow-list, not a deny-list — see the single-node job for why.
. "${GITHUB_WORKSPACE}/spinifex/.github/scripts/suite-sets.sh"
SUITES_RUN=$(echo "${SUITES}" | sed 's/e2e-//g' | tr ' ' '\n' | grep -xE "${E2E_SUITES_MULTI_RE}" | tr '\n' ' ')
SUITE_TMOS=""
for s in ${SUITES_RUN}; do SUITE_TMOS="${SUITE_TMOS}${s}:$(e2e_suite_timeout "${s}") "; done
if [ -z "$(echo "${SUITES_RUN}" | tr -d ' ')" ]; then
echo "no multinode-eligible suites requested (SUITES=${SUITES}); skipping"
echo "rc=0" >> "$GITHUB_OUTPUT"
@@ -803,13 +859,14 @@ jobs:
echo \"::group::suite \$SUITE\"; \
printf '\n%s%s━━ suite %s ━━%s\n' \"\$B\" \"\$C\" \"\$SUITE\" \"\$R\"; \
date -u +%Y-%m-%dT%H:%M:%SZ > '${ARTIFACT_DIR}/test-'\$SUITE'.start'; \
TMO=30m; for kv in ${SUITE_TMOS}; do case \$kv in \$SUITE:*) TMO=\${kv#*:} ;; esac; done; \
GITHUB_ACTIONS=true FORCE_COLOR=1 SPINIFEX_E2E=1 SPINIFEX_MODE=multinode \
SPINIFEX_NODES='${NODES_CSV}' \
SPINIFEX_NODE_IPS='${NODES_CSV}' \
SPINIFEX_SSH_USER=tf-user \
SPINIFEX_SSH_KEY=\$HOME/.ssh/tf-user-ap-southeast-2 \
ARTIFACT_DIR='${ARTIFACT_DIR}' \
./\$SUITE.test -test.v -test.timeout=30m ${TEST_RUN_FLAG} \
./\$SUITE.test -test.v -test.timeout=\$TMO ${TEST_RUN_FLAG} \
2>&1 | tee '${ARTIFACT_DIR}/test-'\$SUITE'.log' \
| sed -uE 's/^[[:space:]]+([a-zA-Z0-9_]+_test\.go:[0-9]+: )?//'; \
rc=\${PIPESTATUS[0]}; \
@@ -972,7 +1029,7 @@ jobs:
find "$WS" -name '.terraform.tfstate.lock.info' -delete 2>/dev/null
quiet_run "tofu workspace select" "$TOFU_LOG" tofu workspace select -or-create "${SPINIFEX_CI_ENV}"
quiet_run "telemetry flush" "$TOFU_LOG" timeout 120 ./flush-telemetry.sh "${SPINIFEX_CI_ENV}" || true
quiet_run "tofu destroy" "$TOFU_LOG" timeout 600 tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false
retry_run "tofu destroy" "$TOFU_LOG" timeout 600 tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false
quiet_run "scrub-env" "$TOFU_LOG" ./scrub-env.sh "${SPINIFEX_CI_ENV}"
ok "teardown complete"
-49
View File
@@ -56,15 +56,7 @@ concurrency:
cancel-in-progress: false
env:
# OTLP export to the central Elastic sink (wattle is the sink itself — see
# Part A.2). Repo variable overrides; carried for correlation even though
# the GPU suite's own daemon-side spans are emitted by wattle's already-
# configured obs-agent, not by this job.
SPINIFEX_OTLP_ENDPOINT: ${{ vars.SPINIFEX_OTLP_ENDPOINT || 'http://192.168.1.13:8200' }}
# ci.cell=gpu keeps this workflow's telemetry separable from the tofu-env
# workflows'. Nothing in this job bakes these attrs into a daemon (see the
# note above), so the label only takes effect once wattle's obs-agent is
# given them; there is deliberately no ci.job_id lookup for the same reason.
SPINIFEX_OTEL_ATTRS: ci.run_id=${{ github.run_id }},ci.workflow=${{ github.workflow }},ci.cell=gpu
WATTLE_SSH_HOST: ${{ vars.WATTLE_SSH_HOST || '192.168.1.13' }}
WATTLE_SSH_USER: ${{ vars.WATTLE_SSH_USER || 'tf-user' }}
@@ -78,8 +70,6 @@ jobs:
env:
ARTIFACT_DIR: /tmp/spinifex-gpu-e2e-artifacts-${{ github.run_id }}
steps:
# Manual-work mutex gate — see setup note 4 above. Checked BEFORE any
# SSH to wattle so a paused run touches the box zero times.
- name: Manual-work mutex gate
id: gate
env:
@@ -122,8 +112,6 @@ jobs:
echo "${{ secrets.WATTLE_SSH_KEY }}" > "${RUNNER_TEMP}/wattle-ci"
echo "ssh_opts=-o StrictHostKeyChecking=no -o ConnectTimeout=10 -i ${RUNNER_TEMP}/wattle-ci" >> "$GITHUB_ENV"
# Healthcheck: confirm wattle's persistent AWS-compat control plane is
# actually up before spending time building/shipping the test binary.
- name: Healthcheck — describe-regions
if: steps.gate.outputs.paused != 'true'
run: |
@@ -131,18 +119,6 @@ jobs:
ssh ${{ env.ssh_opts }} "${WATTLE_SSH_USER}@${WATTLE_SSH_HOST}" \
'AWS_PROFILE=spinifex aws ec2 describe-regions --output text'
# Ensure the base EC2 GPU AMI is present — the raw-EC2 GPU passthrough
# test is the always-on baseline, so CI self-provisions it rather than
# depending on a manual import. `--name` selects the catalog entry (pulls
# the prebuilt image from iso.mulgadc.com); `--ami-name` is REQUIRED to
# name the AMI ubuntu-26.04-nvidia-gpu-x86_64 — without it the import
# falls back to the generic ami-<distro>-<version>-<arch>, which the
# tests/e2e/gpu filter would never match. Idempotent — skipped once
# wattle's persistent loop-fs already has the named AMI (the 4.4G source
# qcow2 is cached in the images dir, so a re-import wouldn't re-download).
# `spx admin images import` writes local spinifex storage, so it needs
# the `spinifex` group active (sg); aws-CLI calls hit the gateway over
# HTTPS and don't.
- name: Ensure base GPU AMI present (import-if-absent)
if: steps.gate.outputs.paused != 'true'
run: |
@@ -171,11 +147,6 @@ jobs:
'
ssh ${{ env.ssh_opts }} "${WATTLE_SSH_USER}@${WATTLE_SSH_HOST}" "$REMOTE"
# Preflight — the base GPU AMI is REQUIRED: the setup step above just
# ensured it, so a miss here means the import silently failed. Hard-fail
# rather than run a false-green all-skipped suite. ECS/EKS node AMIs are
# opt-in tiers (heavier — node AMIs + cluster spin-up); their tests
# self-skip when absent, so only warn.
- name: Preflight — verify GPU AMIs
if: steps.gate.outputs.paused != 'true'
run: |
@@ -205,10 +176,6 @@ jobs:
'
ssh ${{ env.ssh_opts }} "${WATTLE_SSH_USER}@${WATTLE_SSH_HOST}" "$REMOTE"
# Same build command CI's other e2e workflows use to cross-compile a
# suite into a shippable binary (tests/e2e/Makefile e2e-build target,
# SUITES_BUILD scoped to just gpu). Building on the runner keeps go
# toolchain / module cache off wattle entirely.
- name: Build GPU e2e test binary on runner
if: steps.gate.outputs.paused != 'true'
working-directory: tests/e2e
@@ -231,12 +198,6 @@ jobs:
ssh ${{ env.ssh_opts }} "$VM" "mkdir -p \$HOME/spinifex-gpu-e2e-bin"
scp ${{ env.ssh_opts }} tests/e2e/_bin/gpu.test "$VM":spinifex-gpu-e2e-bin/gpu.test
# Same invocation shape e2e.yml uses to run a pre-built suite binary
# (SPINIFEX_E2E=1 SPINIFEX_MODE=single, ./<suite>.test -test.v
# -test.timeout=NNm), plus SPINIFEX_WAN_IP: wattle's ~24 bridges make
# net.InterfaceAddrs() enumeration pick a co-tenant bridge instead of
# the advertised gateway, so the override is required here. The harness
# should prefer spinifex.toml's advertised bridge.
- name: Run GPU e2e suite on wattle
id: e2e
if: steps.gate.outputs.paused != 'true'
@@ -287,8 +248,6 @@ jobs:
junit-glob: ${{ env.ARTIFACT_DIR }}/junit-*.xml
title: "GPU E2E results"
# Tolerated on failure: the sink being unreachable must not turn a
# green test run red.
- name: Ship test results to Elasticsearch
if: always() && steps.gate.outputs.paused != 'true'
continue-on-error: true
@@ -313,14 +272,6 @@ jobs:
if-no-files-found: warn
retention-days: 14
# Cleanup is NEVER a host teardown: it never runs setup-ovn.sh,
# dev-reset, or cluster-reset. It only reconciles EC2-API-level state —
# terminate any GPU-typed instance still pending/running (the Go
# suite's own t.Cleanup already does this on normal pass/fail exit;
# this is the backstop for a hard `-test.timeout` kill or a workflow
# cancellation, which skip deferred Go cleanups). Termination triggers
# gpuManager.Release() on wattle, returning the A1000 to the pool for
# the next queued run or manual test.
- name: Cleanup — terminate leftover GPU instances, release GPU
if: always() && steps.gate.outputs.paused != 'true'
run: |
+22 -4
View File
@@ -1,9 +1,5 @@
name: Image Scripts
# Separate from spinifex.yml on purpose: scripts/images/ and images/ are shell
# and mkosi assets baked into system images, not Go, so they get their own
# path-filtered gate instead of running on every Go contributor's push (see
# ui.yml for the same pattern applied to the frontend).
on:
push:
branches:
@@ -37,3 +33,25 @@ jobs:
- name: Run image script tests + shellcheck
run: make test-images
eks_mkosi_image:
name: Build mkosi EKS image
runs-on: [self-hosted, ci-single]
concurrency:
group: eks-mkosi-image-${{ github.ref }}
cancel-in-progress: true
steps:
- name: Checkout Code
uses: actions/checkout@v7
- name: Set up Go
uses: actions/setup-go@v7
with:
go-version-file: "go.mod"
# Self-hosted: the module + build cache already persists on local
# disk, and cache-save would re-upload hundreds of MB for nothing.
cache: false
- name: Build the image
run: make build-eks-node-mkosi-image
+415
View File
@@ -0,0 +1,415 @@
name: Predastore 3-Node Performance
on:
workflow_dispatch:
inputs:
perf_preset:
description: "Warp workload preset: smoke (30s) or compare (2m)"
required: false
default: compare
type: choice
options:
- smoke
- compare
cluster_ref:
description: "Git ref to build the cluster from, e.g. v1.18.0. Empty uses this branch."
required: false
default: ""
type: string
# Shared with e2e.yml and e2e-nightly.yml: this job provisions on the same
# hypervisors they do, so a run in flight there and here would compete for
# disks and libvirt guests. Queued, not cancelled, same as those workflows.
concurrency:
group: e2e-hypervisors
cancel-in-progress: false
permissions:
contents: read
env:
GIT_BRANCH: ${{ github.ref_name }}
SPINIFEX_OTLP_ENDPOINT: ${{ vars.SPINIFEX_OTLP_ENDPOINT || 'http://192.168.1.13:8200' }}
jobs:
perf_3node:
name: Predastore Throughput (3-node)
# ci-multi is a systemd runner on the LAN, not the ephemeral pool. A pool
# VM sits behind the geneve overlay at MTU 1408, which would put the
# runner's own network path inside the thing being measured.
runs-on: [self-hosted, ci-multi]
timeout-minutes: 90
env:
TOFU_LOG: /tmp/e2e-tofu-perf3node-${{ github.run_id }}.log
SPINIFEX_OTEL_ATTRS: ci.run_id=${{ github.run_id }},ci.workflow=${{ github.workflow }},ci.cell=perf-3node
steps:
- name: Reap Orphan Procs
run: |
set +e
UID_SELF=$(id -u)
WS="${GITHUB_WORKSPACE}"
reap() {
local sig="$1"
for pid in $(pgrep -u "$UID_SELF" -x tofu) $(pgrep -u "$UID_SELF" -x terraform); do
cwd=$(readlink "/proc/$pid/cwd" 2>/dev/null) || continue
case "$cwd" in
"$WS"|"$WS"/*) echo "reap $sig pid=$pid cwd=$cwd"; kill "-$sig" "$pid" 2>/dev/null ;;
esac
done
}
reap TERM
sleep 3
reap KILL
find "$WS" -name '.terraform.tfstate.lock.info' -delete 2>/dev/null
true
- name: Clean Workspace
run: |
awk -v w="$GITHUB_WORKSPACE" '$2 ~ "^"w {print $2}' /proc/mounts \
| sort -r | xargs -r -I{} sudo umount -l -R {} || true
sudo rm -rf --one-file-system "$GITHUB_WORKSPACE"/*
# ci-multi and ci-single resolve to the same physical host, so the
# nvme path cell-23 uses for its throughput run is already there.
# Pointing TMPDIR at it keeps warp's payloads off a small /tmp tmpfs.
- name: Configure work directories
run: |
E2E_ROOT=/mnt/nvme/e2e
mkdir -p "$E2E_ROOT/work" 2>/dev/null ||
{ sudo mkdir -p "$E2E_ROOT/work" && sudo chown "$(id -u):$(id -g)" "$E2E_ROOT" "$E2E_ROOT/work"; }
echo "TMPDIR=$E2E_ROOT/work" >> "$GITHUB_ENV"
echo "PERF_RESULTS_ROOT=$E2E_ROOT/predastore-perf-3node-$GITHUB_RUN_ID" >> "$GITHUB_ENV"
- uses: actions/create-github-app-token@v3
id: app-token
with:
client-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
repositories: mulga
- uses: actions/checkout@v7
with:
repository: mulgadc/mulga
token: ${{ steps.app-token.outputs.token }}
sparse-checkout: scripts/tofu-cluster
path: mulga
- uses: actions/checkout@v7
with:
path: spinifex
- uses: actions/setup-go@v7
with:
go-version-file: spinifex/go.mod
cache: false
- name: Re-enable Go toolchain auto-download
run: echo "GOTOOLCHAIN=auto" >> "$GITHUB_ENV"
# Same-named branch when one exists, dev otherwise. Matches how the
# e2e path picks siblings, so this measures the same predastore
# commit a cross-repo change would be built and tested against.
- name: Resolve sibling refs
id: siblings
env:
BRANCH: ${{ env.GIT_BRANCH }}
run: bash .github/resolve-sibling-refs.sh
working-directory: spinifex
- uses: actions/checkout@v7
id: predastore
with:
repository: mulgadc/predastore
ref: ${{ steps.siblings.outputs.predastore }}
path: predastore
# The cluster's ref is a knob and the harness's is not. Measuring an old
# release with a new harness is the only way its number can be set beside
# a current one; rebuilding both would change the ruler and the thing.
- name: Build Install Artifacts
working-directory: mulga/scripts/tofu-cluster
env:
CLUSTER_REF: ${{ inputs.cluster_ref }}
run: |
./build-install-artifacts.sh "${CLUSTER_REF:-$GIT_BRANCH}" \
"${RUNNER_TEMP}/spinifex-distro.tar.gz" \
"${RUNNER_TEMP}/setup.sh" \
"${RUNNER_TEMP}/spinifex-tests.tar" \
--skip-e2e-build
# The libvirt provider dials the hypervisors by bare hostname. A pool VM
# sits inside a VPC and cannot resolve them; a LAN runner already can, so
# this is self-gating on the lookup rather than on the runner's labels.
- name: Map hypervisor names
working-directory: mulga/scripts/tofu-cluster
run: |
set -euo pipefail
if getent hosts banksia >/dev/null; then
echo "hypervisor names already resolve"
else
sudo tee -a /etc/hosts < network/hosts >/dev/null
echo "added $(grep -c '^[0-9]' network/hosts) entries to /etc/hosts"
fi
# Pinned rather than StrictHostKeyChecking=no: these hops reach the
# hypervisors themselves, so an unverified key is the one place in
# this job where accepting anything would matter.
if ssh-keygen -F banksia >/dev/null 2>&1; then
echo "hypervisor host keys already known"
else
install -d -m 700 ~/.ssh
cat network/known_hosts >> ~/.ssh/known_hosts
chmod 600 ~/.ssh/known_hosts
echo "pinned $(grep -c '^[a-z]' network/known_hosts) host keys"
fi
- name: Provision VMs
working-directory: mulga/scripts/tofu-cluster
run: |
set -euo pipefail
source "${GITHUB_WORKSPACE}/spinifex/.github/scripts/ci-fmt.sh"
export TF_VAR_git_branch="${GIT_BRANCH}"
quiet_run "tofu init" "$TOFU_LOG" tofu init -input=false
quiet_run "tofu workspace select" "$TOFU_LOG" tofu workspace select -or-create "${SPINIFEX_CI_ENV}"
quiet_run "tofu destroy (pre-clean)" "$TOFU_LOG" tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false || true
quiet_run "scrub-env (pre-clean)" "$TOFU_LOG" ./scrub-env.sh "${SPINIFEX_CI_ENV}" || true
retry_run "tofu apply" "$TOFU_LOG" tofu apply -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false
ok "3-node cluster provisioned"
- name: Bootstrap via Binary Install
working-directory: mulga/scripts/tofu-cluster
run: |
SPX_EXTERNAL_POOL="${SPINIFEX_CI_POOL}" \
./bootstrap-install.sh "${SPINIFEX_CI_ENV}" \
"${RUNNER_TEMP}/setup.sh" \
"${RUNNER_TEMP}/spinifex-distro.tar.gz" \
--test-tar "${RUNNER_TEMP}/spinifex-tests.tar"
- name: Resolve cluster SSH details
id: cluster
working-directory: mulga/scripts/tofu-cluster
run: |
INVENTORY=$(tofu output -json inventory)
NODE1_IP=$(echo "$INVENTORY" | jq -r '.nodes[0].wan_ip')
SSH_KEY=$(echo "$INVENTORY" | jq -r '.ssh_key_path')
SSH_KEY="${SSH_KEY/#\~/$HOME}"
SSH_USER=$(echo "$INVENTORY" | jq -r '.ssh_user')
REGION=$(echo "$INVENTORY" | jq -r '.region')
NODE_COUNT=$(echo "$INVENTORY" | jq '.nodes | length')
NODES_CSV=""
for i in $(seq 0 $((NODE_COUNT - 1))); do
ip=$(echo "$INVENTORY" | jq -r ".nodes[$i].wan_ip")
if [ -z "$NODES_CSV" ]; then NODES_CSV="$ip"; else NODES_CSV="$NODES_CSV,$ip"; fi
done
echo "node1_ip=${NODE1_IP}" >> "$GITHUB_OUTPUT"
echo "ssh_key=${SSH_KEY}" >> "$GITHUB_OUTPUT"
echo "ssh_user=${SSH_USER}" >> "$GITHUB_OUTPUT"
echo "region=${REGION}" >> "$GITHUB_OUTPUT"
echo "nodes_csv=${NODES_CSV}" >> "$GITHUB_OUTPUT"
# PERF_EXTERNAL_HOSTS wants host:port, and the gate listens on 8443 on
# every node. run-info.txt from this run is then comparable with the
# nightly's loopback one, since the harness itself never changes.
- name: Resolve PERF_EXTERNAL_HOSTS
id: perf_hosts
run: |
set -euo pipefail
IFS=',' read -ra NODE_LIST <<< "${{ steps.cluster.outputs.nodes_csv }}"
HOSTS=""
for ip in "${NODE_LIST[@]}"; do
[ -z "$ip" ] && continue
if [ -z "$HOSTS" ]; then HOSTS="$ip:8443"; else HOSTS="$HOSTS,$ip:8443"; fi
done
echo "hosts=${HOSTS}" >> "$GITHUB_OUTPUT"
# A slow bootstrap should fail here, legibly, rather than inside warp
# with a wall of connection-refused errors. Bounded at 5 minutes per
# gate so a genuinely dead node still fails the job in finite time.
- name: Wait for gates to answer on :8443
run: |
set -euo pipefail
IFS=',' read -ra GATES <<< "${{ steps.perf_hosts.outputs.hosts }}"
for gate in "${GATES[@]}"; do
echo "waiting for https://$gate"
up=0
for i in $(seq 1 60); do
if curl -k -sS --max-time 5 -o /dev/null "https://$gate/"; then
up=1
break
fi
sleep 5
done
[ "$up" -eq 1 ] || { echo "::error::gate $gate never answered HTTPS on :8443"; exit 1; }
echo "gate $gate is up"
done
# Warp reads whole objects, so no workload here touches a ranged read and
# the throughput figures stay healthy while ranged GETs are broken. This
# probe is the only thing in CI that looks at them.
- name: Ranged GET probe
env:
GATE: ${{ steps.cluster.outputs.node1_ip }}
SSH_KEY: ${{ steps.cluster.outputs.ssh_key }}
SSH_USER: ${{ steps.cluster.outputs.ssh_user }}
NODE1_IP: ${{ steps.cluster.outputs.node1_ip }}
CLUSTER_REGION: ${{ steps.cluster.outputs.region }}
run: |
set -euo pipefail
CREDS=$(ssh -i "${SSH_KEY}" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
"${SSH_USER}@${NODE1_IP}" 'cat ~/.aws/credentials')
AK=$(echo "$CREDS" | awk -F '= *' '/^aws_access_key_id/ {print $2; exit}')
SK=$(echo "$CREDS" | awk -F '= *' '/^aws_secret_access_key/ {print $2; exit}')
echo "::add-mask::$SK"
B="ranged-probe-${GITHUB_RUN_ID}"
AWSX=(--region "$CLUSTER_REGION" --endpoint-url "https://${GATE}:8443" --no-verify-ssl)
export AWS_ACCESS_KEY_ID="$AK" AWS_SECRET_ACCESS_KEY="$SK"
aws s3 mb "s3://$B" "${AWSX[@]}"
trap 'aws s3 rb "s3://$B" --force "${AWSX[@]}" >/dev/null 2>&1 || true' EXIT
# Two sizes: read amplification makes a boundary-crossing range cost
# the whole object, so the fault shows as time tracking object size.
for mb in 64 256; do
head -c $((mb*1024*1024)) /dev/urandom > "${TMPDIR}/probe-${mb}.bin"
aws s3 cp "${TMPDIR}/probe-${mb}.bin" "s3://$B/probe-${mb}.bin" --only-show-errors "${AWSX[@]}"
rm -f "${TMPDIR}/probe-${mb}.bin"
done
t() {
curl -sk --aws-sigv4 "aws:amz:${CLUSTER_REGION}:s3" -u "$AK:$SK" \
-H "Range: bytes=0-$1" -o /dev/null -w '%{time_total}' \
--url "https://${GATE}:8443/${B}/probe-${2}.bin"
}
# 4194304 is the last byte inside the first 4 MiB block; one more
# crosses into the second and leaves the single-shard fast path.
IN64=$(t 4194303 64); OUT64=$(t 4194304 64)
IN256=$(t 4194303 256); OUT256=$(t 4194304 256)
printf '%-10s in-block=%ss crossing=%ss\n' "64MiB" "$IN64" "$OUT64"
printf '%-10s in-block=%ss crossing=%ss\n' "256MiB" "$IN256" "$OUT256"
# The 4x object-size ratio is the signal, and it is compared against
# the crossing reads alone so a slow runner cancels out of both sides.
awk -v a="$OUT64" -v b="$OUT256" 'BEGIN{
r = (a > 0) ? b/a : 0
printf "crossing-range cost ratio 256MiB/64MiB = %.2f (object size ratio is 4.00)\n", r
if (r > 2.0) {
print "::error::a boundary-crossing ranged GET scales with object size, so it is reading the whole object"
exit 1
}
}'
echo "ranged GET does not scale with object size"
# The benchmark works in $TMPDIR and predastore refuses writes below a 5%
# free-space watermark, so this asserts the same headroom cell-23 does.
# tmpfs is rejected outright: that would measure RAM, not the gates.
- name: Preflight
run: |
set -euo pipefail
mkdir -p "$TMPDIR"
fstype=$(df --output=fstype "$TMPDIR" | tail -1 | tr -d ' ')
case "$fstype" in
tmpfs|ramfs)
echo "::error::$TMPDIR is $fstype — a storage benchmark there measures RAM"
exit 1 ;;
esac
find "$TMPDIR" -maxdepth 1 -name 'predastore-e2e-performance.*' \
-type d -mtime +0 -exec rm -rf {} + 2>/dev/null || true
df -h "$TMPDIR"
avail_gb=$(df -BG --output=avail "$TMPDIR" | tail -1 | tr -dc '0-9')
pct_used=$(df --output=pcent "$TMPDIR" | tail -1 | tr -dc '0-9')
if [ "$pct_used" -ge 90 ] || [ "$avail_gb" -lt 20 ]; then
echo "::error::$TMPDIR ($fstype) is ${pct_used}% full with ${avail_gb} GiB free."
echo "::error::The benchmark needs headroom above predastore's 5% watermark."
exit 1
fi
- name: Install warp
working-directory: predastore
run: make warp-install
# PERF_EXTERNAL_HOSTS skips profile selection, port offsetting, and
# cluster start/stop, and measures these three gates directly. Same
# workloads and analysis as the nightly loopback run, so the numbers
# from the two are directly comparable.
#
# The cluster's credentials are read and used inside this one step, the
# way e2e-ddil.yml does it: a step-level env: block is echoed verbatim
# into the run log, and a shell variable is not.
- name: Run 3-node throughput benchmark
working-directory: predastore
env:
PERF_EXTERNAL_HOSTS: ${{ steps.perf_hosts.outputs.hosts }}
PERF_CONFIGS: spinifex-3node
PERF_PRESET: ${{ inputs.perf_preset || 'compare' }}
# What is deployed, not what the harness was checked out at. With a
# cluster_ref those differ, and run-info.txt records the cluster.
PERF_EXTERNAL_SHA: ${{ inputs.cluster_ref || steps.predastore.outputs.commit }}
SSH_KEY: ${{ steps.cluster.outputs.ssh_key }}
SSH_USER: ${{ steps.cluster.outputs.ssh_user }}
NODE1_IP: ${{ steps.cluster.outputs.node1_ip }}
CLUSTER_REGION: ${{ steps.cluster.outputs.region }}
run: |
set -euo pipefail
# spx admin init mints a random pair at bootstrap and writes it to
# node1's ~/.aws/credentials. The benchmark's built-in key is the one
# the loopback configs trust, which this cluster has never heard of.
CREDS=$(ssh -i "${SSH_KEY}" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
"${SSH_USER}@${NODE1_IP}" 'cat ~/.aws/credentials')
PERF_ACCESS_KEY=$(echo "$CREDS" | awk -F '= *' '/^aws_access_key_id/ {print $2; exit}')
PERF_SECRET_KEY=$(echo "$CREDS" | awk -F '= *' '/^aws_secret_access_key/ {print $2; exit}')
if [ -z "$PERF_ACCESS_KEY" ] || [ -z "$PERF_SECRET_KEY" ]; then
echo "::error::could not read spinifex AWS credentials from ${NODE1_IP}"
exit 1
fi
echo "::add-mask::$PERF_SECRET_KEY"
mkdir -p "$PERF_RESULTS_ROOT"
PERF_ACCESS_KEY="$PERF_ACCESS_KEY" \
PERF_SECRET_KEY="$PERF_SECRET_KEY" \
PERF_REGION="$CLUSTER_REGION" \
make e2e-performance 2>&1 | tee "$PERF_RESULTS_ROOT/e2e-performance.log"
# Report-only, matching cell-23: the baseline comparison and any
# regression gate are future work once run-to-run spread is known from
# real bare-metal runs.
- uses: actions/upload-artifact@v7
if: always()
with:
name: predastore-perf-3node-${{ github.run_id }}
path: ${{ env.PERF_RESULTS_ROOT }}
if-no-files-found: warn
retention-days: 90
- name: Clean up results
if: always()
run: rm -rf "${PERF_RESULTS_ROOT}"
- name: Teardown
if: always()
working-directory: mulga/scripts/tofu-cluster
run: |
set +e
source "${GITHUB_WORKSPACE}/spinifex/.github/scripts/ci-fmt.sh"
UID_SELF=$(id -u)
WS="${GITHUB_WORKSPACE}"
reap() {
local sig="$1"
for pid in $(pgrep -u "$UID_SELF" -x tofu) $(pgrep -u "$UID_SELF" -x terraform); do
cwd=$(readlink "/proc/$pid/cwd" 2>/dev/null) || continue
case "$cwd" in
"$WS"|"$WS"/*) kill "-$sig" "$pid" 2>/dev/null ;;
esac
done
}
reap TERM
sleep 2
reap KILL
find "$WS" -name '.terraform.tfstate.lock.info' -delete 2>/dev/null
quiet_run "tofu workspace select" "$TOFU_LOG" tofu workspace select -or-create "${SPINIFEX_CI_ENV}"
quiet_run "telemetry flush" "$TOFU_LOG" timeout 120 ./flush-telemetry.sh "${SPINIFEX_CI_ENV}" || true
retry_run "tofu destroy" "$TOFU_LOG" timeout 600 tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false
quiet_run "scrub-env" "$TOFU_LOG" ./scrub-env.sh "${SPINIFEX_CI_ENV}"
ok "teardown complete"
- name: Upload tofu log
if: always()
uses: actions/upload-artifact@v7
with:
name: tofu-log-perf3node-${{ github.run_id }}
path: ${{ env.TOFU_LOG }}
if-no-files-found: ignore
retention-days: 14
+3 -6
View File
@@ -60,7 +60,7 @@ concurrency:
jobs:
matrix:
name: Resolve image matrix
runs-on: [ self-hosted, ci-single ]
runs-on: [self-hosted, ci-single]
outputs:
images: ${{ steps.gen.outputs.images }}
steps:
@@ -84,7 +84,7 @@ jobs:
publish:
name: Build + publish ${{ matrix.name }}
needs: matrix
runs-on: [ self-hosted, ci-single ]
runs-on: [self-hosted, ci-single]
strategy:
fail-fast: false
# ci-single is one runner and each GPU build needs ~16G of scratch disk;
@@ -131,9 +131,7 @@ jobs:
with:
go-version-file: "spinifex/go.mod"
# Self-hosted runner: the Go module + build cache already persists on
# local disk between runs. setup-go's cache-save ("Post Setup Go")
# otherwise re-uploads hundreds of MB to the Actions cache backend,
# taking longer than the build itself.
# local disk between runs.
cache: false
- name: Setup Go Workspace
@@ -160,7 +158,6 @@ jobs:
- name: Build + publish ${{ matrix.name }} to R2
working-directory: spinifex
env:
# Map the repo's R2 secrets onto the names publish-system-image.sh reads.
AWS_ACCESS_KEY_ID: ${{ secrets.CF_R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.CF_R2_SECRET_ACCESS_KEY }}
R2_ENDPOINT: ${{ secrets.CF_R2_ENDPOINT_URL }}
+18 -2
View File
@@ -148,6 +148,13 @@ jobs:
verify:
needs: release
runs-on: [ self-hosted, ci-single ]
# Shared with the e2e workflows: this job provisions on the same runner
# pools and the same hypervisors, so two in flight compete for their disks
# and their libvirt guests. Job-level, so the build jobs are not queued
# behind a nightly that has nothing to do with them.
concurrency:
group: e2e-hypervisors
cancel-in-progress: false
timeout-minutes: 25
env:
TOFU_LOG: /tmp/release-verify-${{ github.run_id }}.log
@@ -174,6 +181,13 @@ jobs:
sparse-checkout: scripts/tofu-cluster
path: mulga
# Only the shared shell helpers: this job builds nothing from source, but
# it drives the same libvirt provider the e2e cells retry around.
- uses: actions/checkout@v7
with:
sparse-checkout: .github/scripts
path: spinifex
- name: Download Release Assets
uses: actions/download-artifact@v8
with:
@@ -183,11 +197,12 @@ jobs:
- name: Provision VM
working-directory: mulga/scripts/tofu-cluster
run: |
source "${GITHUB_WORKSPACE}/spinifex/.github/scripts/ci-fmt.sh"
tofu init -input=false >> "$TOFU_LOG" 2>&1
tofu workspace select -or-create "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1
tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1 || true
./scrub-env.sh "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1 || true
tofu apply -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1
retry_run "tofu apply" "$TOFU_LOG" tofu apply -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false
- name: Bootstrap via Binary Install
working-directory: mulga/scripts/tofu-cluster
@@ -200,8 +215,9 @@ jobs:
if: always()
working-directory: mulga/scripts/tofu-cluster
run: |
source "${GITHUB_WORKSPACE}/spinifex/.github/scripts/ci-fmt.sh"
tofu workspace select -or-create "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1
tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false >> "$TOFU_LOG" 2>&1
retry_run "tofu destroy" "$TOFU_LOG" tofu destroy -var-file=envs/${SPINIFEX_CI_ENV}.tfvars -auto-approve -input=false
./scrub-env.sh "${SPINIFEX_CI_ENV}" >> "$TOFU_LOG" 2>&1 || true
baremetal:
+73 -88
View File
@@ -89,8 +89,11 @@ jobs:
# Build artifacts follow source, so the key rolls per run and falls back to
# this job's newest entry. Keyed per job because -race, -covermode=atomic
# and plain builds produce disjoint entries and would evict each other.
- name: Cache Go build
uses: actions/cache@v6
# Restore runs everywhere; the matching save is gated to dev and main, so
# a PR reads the base branch's entry without spending a minute uploading
# one only that PR would ever read.
- name: Restore Go build cache
uses: actions/cache/restore@v6
with:
path: ~/.cache/go-build
key: gobuild-${{ github.job }}-${{ github.run_id }}
@@ -131,91 +134,26 @@ jobs:
- name: Check Diff Coverage
run: cd spinifex && scripts/diff-coverage.sh coverage.out --base HEAD~1
# In-process integration tier (gateway.SetupRoutes over embedded NATS
# JetStream). Its own job so it runs in parallel with the unit tiers rather
# than adding to their wall time, and it still blocks a PR — unlike e2e.yml's
# "Integration (front gate)" job, which is push-triggered against main/dev on
# a self-hosted runner and so never gates a PR before merge.
integration_tests:
name: Integration Tests
runs-on: ubuntu-26.04
# Compiled output stays valid whatever the tests said, and skipping the
# save on a red run would leave the next one rebuilding it, so this is
# gated on the branch rather than on success.
# Go bumps an entry's mtime when a run uses it, so this run's working set
# survives and objects superseded by later commits do not. Runs after the
# work, so it costs this job nothing and only shrinks what the next reads.
- name: Trim Go build cache
if: ${{ !cancelled() && (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main') }}
run: |
C="$(go env GOCACHE)"
before=$(du -sm "$C" | cut -f1)
find "$C" -type f -mmin +240 -delete
echo "trimmed ${before}MB -> $(du -sm "$C" | cut -f1)MB"
steps:
- name: Checkout Spinifex
uses: actions/checkout@v7
with:
path: spinifex
fetch-depth: 2
- name: Resolve sibling refs
id: siblings
env:
BRANCH: ${{ github.head_ref || github.ref_name }}
run: bash .github/resolve-sibling-refs.sh
working-directory: spinifex
- name: Checkout Viperblock
uses: actions/checkout@v7
with:
repository: mulgadc/viperblock
ref: ${{ steps.siblings.outputs.viperblock }}
path: viperblock
- name: Checkout Predastore
uses: actions/checkout@v7
with:
repository: mulgadc/predastore
ref: ${{ steps.siblings.outputs.predastore }}
path: predastore
- name: Checkout Northstar
uses: actions/checkout@v7
with:
repository: mulgadc/northstar
ref: ${{ steps.siblings.outputs.northstar }}
path: northstar
- name: Checkout Bluebottle
uses: actions/checkout@v7
with:
repository: mulgadc/bluebottle
ref: ${{ steps.siblings.outputs.bluebottle }}
path: bluebottle
# setup-go's own cache only writes on a key miss, so it froze a snapshot at
# the last go.sum change and never refreshed it. Both caches are managed
# below instead.
- name: Setup Go
uses: actions/setup-go@v7
with:
go-version-file: "spinifex/go.mod"
cache: false
# Module contents follow the five go.sum files, so a content key is exact.
# setup-go hashed spinifex/go.sum alone, which left the sibling modules'
# dependencies out of the key and re-downloading on every run.
- name: Cache Go modules
uses: actions/cache@v6
with:
path: ~/go/pkg/mod
key: gomod-${{ hashFiles('spinifex/go.sum', 'viperblock/go.sum', 'predastore/go.sum', 'northstar/go.sum', 'bluebottle/go.sum') }}
restore-keys: gomod-
# Build artifacts follow source, so the key rolls per run and falls back to
# this job's newest entry. Keyed per job because -race, -covermode=atomic
# and plain builds produce disjoint entries and would evict each other.
- name: Cache Go build
uses: actions/cache@v6
- name: Save Go build cache
if: ${{ !cancelled() && (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main') }}
uses: actions/cache/save@v6
with:
path: ~/.cache/go-build
key: gobuild-${{ github.job }}-${{ github.run_id }}
restore-keys: gobuild-${{ github.job }}-
- name: Setup Go Workspace
run: go work init ./spinifex ./viperblock ./predastore ./northstar ./bluebottle
- name: Run Integration Tests
run: make -C spinifex test-integration
race_detection:
name: Race Detection
@@ -284,8 +222,11 @@ jobs:
# Build artifacts follow source, so the key rolls per run and falls back to
# this job's newest entry. Keyed per job because -race, -covermode=atomic
# and plain builds produce disjoint entries and would evict each other.
- name: Cache Go build
uses: actions/cache@v6
# Restore runs everywhere; the matching save is gated to dev and main, so
# a PR reads the base branch's entry without spending a minute uploading
# one only that PR would ever read.
- name: Restore Go build cache
uses: actions/cache/restore@v6
with:
path: ~/.cache/go-build
key: gobuild-${{ github.job }}-${{ github.run_id }}
@@ -297,8 +238,26 @@ jobs:
- name: Run Tests with Race Detector
run: make -C spinifex test-race GOTESTFLAGS=-count=1
# Go bumps an entry's mtime when a run uses it, so this run's working set
# survives and objects superseded by later commits do not. Runs after the
# work, so it costs this job nothing and only shrinks what the next reads.
- name: Trim Go build cache
if: ${{ !cancelled() && (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main') }}
run: |
C="$(go env GOCACHE)"
before=$(du -sm "$C" | cut -f1)
find "$C" -type f -mmin +240 -delete
echo "trimmed ${before}MB -> $(du -sm "$C" | cut -f1)MB"
- name: Save Go build cache
if: ${{ !cancelled() && (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main') }}
uses: actions/cache/save@v6
with:
path: ~/.cache/go-build
key: gobuild-${{ github.job }}-${{ github.run_id }}
lint_and_security:
name: Lint and Security
name: Lint + Security + Integration
runs-on: ubuntu-26.04
steps:
@@ -306,6 +265,7 @@ jobs:
uses: actions/checkout@v7
with:
path: spinifex
fetch-depth: 2
- name: Resolve sibling refs
id: siblings
@@ -364,8 +324,11 @@ jobs:
# Build artifacts follow source, so the key rolls per run and falls back to
# this job's newest entry. Keyed per job because -race, -covermode=atomic
# and plain builds produce disjoint entries and would evict each other.
- name: Cache Go build
uses: actions/cache@v6
# Restore runs everywhere; the matching save is gated to dev and main, so
# a PR reads the base branch's entry without spending a minute uploading
# one only that PR would ever read.
- name: Restore Go build cache
uses: actions/cache/restore@v6
with:
path: ~/.cache/go-build
key: gobuild-${{ github.job }}-${{ github.run_id }}
@@ -382,3 +345,25 @@ jobs:
- name: Govulncheck
run: make -C spinifex govulncheck
- name: Run Integration Tests
if: ${{ !cancelled() }}
run: make -C spinifex test-integration
# Go bumps an entry's mtime when a run uses it, so this run's working set
# survives and objects superseded by later commits do not. Runs after the
# work, so it costs this job nothing and only shrinks what the next reads.
- name: Trim Go build cache
if: ${{ !cancelled() && (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main') }}
run: |
C="$(go env GOCACHE)"
before=$(du -sm "$C" | cut -f1)
find "$C" -type f -mmin +240 -delete
echo "trimmed ${before}MB -> $(du -sm "$C" | cut -f1)MB"
- name: Save Go build cache
if: ${{ !cancelled() && (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main') }}
uses: actions/cache/save@v6
with:
path: ~/.cache/go-build
key: gobuild-${{ github.job }}-${{ github.run_id }}
+1
View File
@@ -41,3 +41,4 @@ Thumbs.db
# Test reports
*-report.txt
/rds-agent
+1
View File
@@ -107,6 +107,7 @@ linters:
- G116 # bidi chars — bidichk is more thorough
- G117 # use of net/http serve functions (we handle TLS separately)
- G703 # path traversal taint analysis — false positives on config file loading
- G704 # SSRF taint analysis — false positives on resolver-derived endpoint URLs
- G705 # XSS taint analysis — false positives on internal error responses
govet:
+16 -7
View File
@@ -79,7 +79,7 @@ build-ecs-agent: ## Build the ecs-agent (ships in the ECS-AMI guest; not in host
build-loadgen: ## Build spx-loadgen (drives the stress harness's measured load; not shipped to nodes)
@echo -e "\n....Building spx-loadgen"
GOFIPS140=v1.0.0 go build -ldflags "-s -w" -o ./bin/spx-loadgen ./cmd/spx-loadgen
go build -ldflags "-s -w" -o ./bin/spx-loadgen ./cmd/spx-loadgen
build-system-image: ## Build a system image from manifest (use IMAGE=lb or IMAGE=eks-node)
ifndef IMAGE
@@ -103,6 +103,12 @@ import-eks-node-image: ## Build + register the eks-node AMI (requires a running
publish-eks-node-image: ## Build + publish the eks-node AMI to Cloudflare R2 (needs R2_ENDPOINT + AWS_* env)
./scripts/publish-system-image.sh scripts/images/eks-node/manifest.conf --build
# The Ubuntu/systemd EKS image, as opposed to build-eks-node-image's Alpine one.
# Composition lives in mkosi-build.sh's image_profiles table, so the image name
# is the whole interface here.
build-eks-node-mkosi-image: ## Build the Ubuntu mkosi EKS node image (server+agent; role at first boot)
./scripts/mkosi-build.sh --image spinifex-eks-node
build-ecs-node-image: ## Build the spinifex-ecs-node AMI (Alpine + containerd + ecs-agent; IMPORT=1 to register)
$(MAKE) build-system-image IMAGE=ecs-agent
@@ -163,15 +169,19 @@ test-build-scripts:
# E2E harness unit tests. Build-tagged `e2e` so they're skipped by the
# default `go test ./spinifex/...`. Runs with mocked AWS clients — no
# infrastructure required, safe to run in CI without a cluster.
#
# diskperf/ is here for the same reason: its baseline comparison and probe
# accounting decide whether the disk-performance gate passes, and that
# arithmetic has to be covered off-cluster or the gate is trusted untested.
test-harness:
@echo -e "\n....Running e2e harness unit tests...."
$(_Q)LOG_IGNORE=1 go test -tags=e2e -timeout 60s ./tests/e2e/harness/... $(_RACEQ)
$(_Q)LOG_IGNORE=1 go test -tags=e2e -timeout 60s ./tests/e2e/harness/... ./tests/e2e/diskperf/... $(_RACEQ)
# In-process integration tier: the real gateway router against embedded NATS
# JetStream, with only the daemon-side NATS subjects stubbed.
AWS_MODEL_CONFORMANCE_REPORT ?= $(CURDIR)/.cache/aws-model-conformance-report.txt
AWS_MODEL_CONFORMANCE_MODE ?= fail
AWS_MODEL_OPERATION_COVERAGE_REPORT ?= $(CURDIR)/docs/aws-model-operation-coverage.md
AWS_MODEL_OPERATION_COVERAGE_DIR ?= $(CURDIR)/docs/coverage
# -count=1 is load-bearing: the conformance report is written by the test binary,
# so a cached pass skips the run, leaves no report, and the cat below fails the
# target. It also keeps the conformance gate honest — a cached result would mean
@@ -181,14 +191,13 @@ test-integration:
$(_Q)LOG_IGNORE=1 AWS_MODEL_CONFORMANCE_MODE=$(AWS_MODEL_CONFORMANCE_MODE) AWS_MODEL_CONFORMANCE_REPORT=$(AWS_MODEL_CONFORMANCE_REPORT) go test -count=1 -tags=integration -timeout 60s ./tests/integration/... $(_RACEQ)
@cat $(AWS_MODEL_CONFORMANCE_REPORT)
@$(MAKE) --no-print-directory generate-aws-model-coverage
@echo "AWS operation coverage: $(AWS_MODEL_OPERATION_COVERAGE_REPORT)"
@echo "AWS operation coverage: $(AWS_MODEL_OPERATION_COVERAGE_DIR)"
generate-aws-model-coverage:
@mkdir -p $(dir $(AWS_MODEL_OPERATION_COVERAGE_REPORT))
@go run ./cmd/aws-model-coverage > $(AWS_MODEL_OPERATION_COVERAGE_REPORT)
@go run ./cmd/aws-model-coverage -out $(AWS_MODEL_OPERATION_COVERAGE_DIR)
aws-model-coverage: generate-aws-model-coverage
@cat $(AWS_MODEL_OPERATION_COVERAGE_REPORT)
@go run ./cmd/aws-model-coverage
# Segscan storage oracle: needs the mulga umbrella repo's scripts/segscan
# checked out alongside spinifex (see spinifex/testutil/segscanoracle), which
+2 -1
View File
@@ -6,6 +6,7 @@
<a href="https://go.dev"><img src="https://img.shields.io/badge/Go-1.27+-00ADD8?style=flat-square&logo=go&logoColor=white" alt="Go"></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/License-AGPL--3.0-3fb950?style=flat-square" alt="License"></a>
<a href="https://mulgadc.com"><img src="https://img.shields.io/badge/home-mulga-orange?style=flat-square&logo=data:image/svg%2bxml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGZpbGw9IiNmZmYiIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTE2LjcxOCA4Ljg5MWMtMS4yODUgMS4zNy0zLjE3OCAyLjMxNy00LjY5NSAzLjQ0LS44NTQuNjMtMy4wODkgMi4yNy0xLjYxIDMuMzQ0IDEuNzg4IDEuMjk4IDYuMjQzLjE2NCA3Ljc4NS0xLjI2Ljg0LS43NzUuODE0LTEuODIyLS4zMjgtMi4yNTktMS4yMzUtLjQ3Mi0yLjY2LS4xMTEtMy45MTMuMDg3LS4wNDIuMDA3LS4xMzEuMDU1LS4xMjMtLjAyLjUyMy0uMzM2Ljk5My0uNzUgMS41MDMtMS4xMDIuNDkyLS4zNCAxLjA5Ny0uODI3IDEuNy0uODM4IDEuODcxLS4wMzQgMy43OTkuODkgNC4yODcgMi44MTUuODExIDMuMjAzLTMuMDA2IDUuNzE1LTUuNzg0IDUuOTQybDEuNjE0LS43NDdjLjYwNS0uMzYgMS4yMTctLjczNCAxLjc1Mi0xLjE5Ni4xMzMtLjExNS4yMy0uMjYzLjM0Mi0uMzczLjAyNy0uMDI2LjMwMi0uMTQ0LjE0NC0uMTUtMS40NDEuOTQ1LTMuMTI3IDEuNTcyLTQuODEzIDEuOTMyLS41OC4xMjMtMS4xOTUuMjQyLTEuNzg1LjI1Ny4wMTUuMDguMDg3LjA2NC4xNC4wOC40NzYuMTU0IDEuMDIuMjQ1IDEuNTE2LjMyLjA2OC4wNTItLjAwNi4wNzQtLjA2LjA4MS0uNDQ4LjA1OC0uOTIzLjE0My0xLjM3LjE2Ni0xLjI3LjA2NC0yLjU1LS4wNjgtMy43NzctLjM4bC0uNjktMS45NTdjLS4wNi4wMDYtLjA2My4wNi0uMDc5LjEwMy0uMDYuMTctLjMwNSAxLjQxNy0uMzg3IDEuNDM1LS42Ni0uMi0xLjI1MS0uNjQ3LTEuNzM2LTEuMTMybDEuMTUtMi4wNi0xLjgzNSAxLjAxMWMtLjI1OC0uNTgzLS4zMDUtMS4yNDMtLjIxOS0xLjg3bDIuMzc1LTEuMThjLS43MzktLjA0OS0xLjQ4Ni4wNjgtMi4yMi4xNDUuMTI1LS4zNi4yNzctLjcxOC40NjItMS4wNTEuMDY3LS4xMjIuNDI2LS43MjEuNTItLjcyNC44Ni4xNjQgMS43ODYuMjQxIDIuNjQ4LjA3NGwtMS44Ny0uNzcxLS4wNjktLjA5M2MuMzgyLS4zNi43Ny0uNzE3IDEuMTk5LTEuMDI0LjgwNC4yMzQgMS42My40MjIgMi40NzMuNDMzTDkuMzUgOS4zNDJhNyA3IDAgMCAxIC41NjctLjQwMWMuMTYtLjEwMy42MDktLjQwMi43NzItLjM4LjY1LjIyMSAxLjMyNC4zNzggMi4wMS40MzMtLjMxMy0uMzI1LS45MDktLjU2NC0xLjIxLS44NjgtLjAzMy0uMDM0LS4wNTgtLjAzNi0uMDQzLS4wOTguNzg3LS40NTIgMS42NjItLjkwMSAyLjM0LTEuNTE3LjY5LS42MjkgMS40MjEtMS42NTYuOTI0LTIuNjA1LjU3My4xODIgMS4wNjcuOTA2IDEuMDU0IDEuNTEyLS4wMzQgMS42NzYtMS44MjIgMy4xNy0yLjk0MyA0LjIyMiAxLjczMi0uNzI4IDMuNzE0LTIuMjMgMy43MS00LjMwNS0uMDAzLTEuNjg4LTEuNTQtMi4zNjUtMi45OTMtMi41MThhMy4yIDMuMiAwIDAgMS0uMzg1IDEuMDg3Yy0uNDI4LjcxOS0xLjMwMiAxLjE2OC0xLjc1OCAxLjkxNS0uMzExLjUxLS4zNyAxLjE5NS0xLjAzMSAxLjM5LS4yNy4wOC0uNjE3LjA5My0uODk3LjA5NGwuNjE1LS4zMjVjLjY4OS0uNTA2LjY3Ny0xLjQyIDEuMDk5LTIuMS0uMDUtLjA3LS41NDUtLjItLjY2LS4yMjktLjUxLS4xMjUtMS4zNzUtLjI4NS0xLjg4Ni0uMjUtLjE1Ny4wMS0uODY5LjEyMS0uOTMuMjQyLS4wODguMTc3LjM0MS45My40NjggMS4xMDEuMDI1LjAzNS4wNzcuMDI2LjA4LjAzLjAyNC4wMzEuMDA3LjEwNS0uMDYuMDhhNSA1IDAgMCAxLS40MDQtLjI4M2MtLjUwMy0uMzg4LTEuMDc4LS45NzQtMS40OTktMS40NDctLjA2OC0uMTY2LS4xNi0uMzUuMDAyLS40ODcuODg4LS41NSAxLjc2OC0xLjEyNiAyLjY3LTEuNjUxIDIuNjcxLTEuNTU4IDUuNzExLTMuMzE4IDguMjY3LS40NjUgMi4xMTkgMi4zNjYgMS41MTQgNS4yMTItLjUxMiA3LjM3MnptLTguMjI0LTUuMzhjLjY5OS0uMTIyIDIuMDE4LjU3MiAyLjIzNS0uNDU2LjAyMS0uMS0uMDM2LS4xNTcuMDItLjI1NS4wNDMtLjA3My4yODYtLjI1LjM3LS4zMTguMjctLjIxNy41NzctLjM4Ny44NDItLjYxMi0xLjAyOS4xNjYtMi4wNjUuNzU2LTIuOTY0IDEuMjc3LS4xNDkuMDg2LS4zMS4xNzYtLjQ1MS4yNzMtLjAzNy4wMjUtLjA3NS0uMDA2LS4wNTMuMDltLTQuODMgMTAuODQ0Yy0xLjcxNSAxLjg4MS0xLjMyNSA0LjU3LjQ5NCA2LjIxNyAxLjgxMSAxLjY0MSA0LjY2IDIuMjIzIDcuMDQ4IDIuMTA4IDIuMTUyLS4xMDMgNC4zMzctLjgxMiA2LjQ2LS4wOTEuODI1LjI4IDEuNTQ2LjgwNSAyLjE2IDEuNDExLS4wODMtLjQtLjMyNC0uODE5LS41NTgtMS4xNTktMi4xMy0zLjA5NS02LjI3LTEuOTM1LTkuNDI2LTIuNTUzLTEuNzExLS4zMzUtMy40OTEtMS4xMTgtNC41MzMtMi41NjctLjkwMS0xLjI1My0xLjA0Mi0yLjczLS41OTUtNC4xOTQtLjA5MS0uMDktLjk1Mi43MjEtMS4wNS44MjhtNi4zNjYtOC42NjdjLS4zODIuMzM5LS43ODcuNjYtMS4yMTIuOTQ4bC0xLjIwNy41OWMxLjAyMi4wMzkgMi4wOC0uNTQ2IDIuNDItMS41MzciLz48L3N2Zz4=" alt="mulgadc.com"></a>
<a href="https://docs.mulgadc.com"><img src="https://img.shields.io/badge/docs-spinifex-0969da?style=flat-square&logo=data:image/svg%2bxml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGZpbGw9IiNmZmYiIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTYuNSAySDIwYTEgMSAwIDAgMSAxIDF2MTQuNWExIDEgMCAwIDEtMSAxSDYuNWExLjUgMS41IDAgMCAwIDAgM0gyMWExIDEgMCAwIDEgMCAySDYuNUEzLjUgMy41IDAgMCAxIDMgMjBWNS41QTMuNSAzLjUgMCAwIDEgNi41IDJ6bTEgNGExIDEgMCAwIDAgMCAyaDlhMSAxIDAgMCAwIDAtMmgtOXptMCA0YTEgMSAwIDAgMCAwIDJoOWExIDEgMCAwIDAgMC0yaC05eiIvPjwvc3ZnPg==" alt="docs.mulgadc.com"></a>
</p>
<p align="center">
@@ -79,7 +80,7 @@ Spinifex speaks the AWS API surface. The AWS SDKs, AWS CLI, and Terraform all wo
| **ECR** | Container Registry | Available |
| **ECS** | Container Service | Available |
| **RDS** | Databases | Available |
| **Bedrock** | AI Deployment | Q3 2026 |
| **Bedrock** | AI Deployment | Q4 2026 |
Roadmap items ship under the same AWS API surface. Code written for AWS today keeps working the moment they land. [Track what's shipped in the release notes](https://github.com/mulgadc/spinifex/releases).
+24
View File
@@ -0,0 +1,24 @@
# Security Policy
## Reporting a Vulnerability
Please do not open a public issue for a security vulnerability.
Report it privately, either way works:
- [Open a private security advisory](https://github.com/mulgadc/spinifex/security/advisories/new) on this repository.
- Email <security@mulgadc.com>.
Please include enough for us to reproduce it: the version or commit, the configuration involved, and the steps that trigger it. If you have a proof of concept, include that too.
## What Happens Next
We aim to acknowledge a report within three business days. We will confirm whether we can reproduce it, agree a fix and a disclosure timeline with you, and credit you in the advisory unless you would rather we did not.
## Scope
Spinifex is one part of the [Mulga](https://mulgadc.com) stack. If a vulnerability spans more than one component, report it once against whichever repository you found it in and we will handle the rest.
## Supported Versions
Fixes land on the latest release and on `main`. Older releases are not patched.
+2 -1
View File
@@ -1,4 +1,4 @@
# spinifex-unit-version: 1
# spinifex-unit-version: 2
[Unit]
Description=Spinifex Web UI
PartOf=spinifex.target
@@ -24,6 +24,7 @@ MemoryMax=20%
Environment=SPINIFEX_UI_TLS_CERT=/etc/spinifex/server.pem
Environment=SPINIFEX_UI_TLS_KEY=/etc/spinifex/server.key
Environment=SPINIFEX_UI_BASE_DIR=/var/lib/spinifex/spinifex-ui/
Environment=SPINIFEX_UI_OCHRE_ENABLED=false
# Filesystem access
NoNewPrivileges=yes
+32 -10
View File
@@ -1,14 +1,40 @@
package main
import (
"flag"
"fmt"
"os"
"github.com/mulgadc/predastore/s3api"
"github.com/mulgadc/spinifex/internal/awsmodel"
"github.com/mulgadc/spinifex/spinifex/gateway"
_ "github.com/mulgadc/bluebottle/pkg/fipsboot"
)
func main() {
outputDir := flag.String("out", "", "write one publishable page per service into this directory")
flag.Parse()
coverages, err := compareAll()
if err != nil {
fail(err)
}
if *outputDir == "" {
fmt.Print(awsmodel.RenderCoverageSummary(coverages))
return
}
if err := awsmodel.WritePages(*outputDir, coverages); err != nil {
fail(err)
}
}
func fail(err error) {
fmt.Fprintf(os.Stderr, "aws-model-coverage: %v\n", err)
os.Exit(1)
}
func compareAll() ([]awsmodel.OperationCoverage, error) {
dispatch := gateway.AWSOperationInventory()
coverages := make([]awsmodel.OperationCoverage, 0, len(awsmodel.Services()))
for _, service := range awsmodel.Services() {
@@ -21,23 +47,19 @@ func main() {
Stubbed: inventory.Stubbed,
Unsupported: inventory.Unsupported,
}
// Predastore serves the S3 REST surface, and names the operations each
// of its routes answers, so the comparison is the same one.
case service == awsmodel.S3:
modelInventory = awsmodel.DispatchInventory{
Opaque: true,
Note: "Spinifex delegates the S3 REST surface to Predastore, which has no operation-name dispatch table to compare mechanically.",
}
modelInventory = awsmodel.DispatchInventory{Registered: s3api.Operations()}
default:
fmt.Fprintf(os.Stderr, "aws-model-coverage: no gateway inventory for %s\n", service)
os.Exit(1)
return nil, fmt.Errorf("no gateway inventory for %s", service)
}
coverage, err := awsmodel.CompareOperations(service, modelInventory)
if err != nil {
fmt.Fprintf(os.Stderr, "aws-model-coverage: %v\n", err)
os.Exit(1)
return nil, err
}
coverages = append(coverages, coverage)
}
fmt.Print(awsmodel.RenderCoverageMarkdown(coverages))
return coverages, nil
}
+2
View File
@@ -24,6 +24,8 @@ import (
"github.com/insomniacslk/dhcp/dhcpv4"
"github.com/insomniacslk/dhcp/dhcpv4/nclient4"
"github.com/mulgadc/spinifex/spinifex/network/external/dhcp"
_ "github.com/mulgadc/bluebottle/pkg/fipsboot"
)
func main() {
+1 -1
View File
@@ -24,7 +24,7 @@ import (
"github.com/mulgadc/spinifex/internal/guestenv"
"github.com/mulgadc/spinifex/internal/imdscreds"
_ "github.com/mulgadc/spinifex/internal/fipsboot"
_ "github.com/mulgadc/bluebottle/pkg/fipsboot"
)
const (
+3 -3
View File
@@ -9,9 +9,9 @@ import (
)
// IMDSProvider fetches instance-role credentials from IMDS via the AWS SDK's
// EC2 IMDS client and ec2rolecreds provider, cached with aws.CredentialsCache
// (which refetches once the real expiry passes; ec2rolecreds also caps
// Expires to at most an hour out). Safe for concurrent use.
// EC2 IMDS client and ec2rolecreds provider, cached with aws.CredentialsCache,
// which refetches once the real expiry passes. A failed refresh is an error
// here, not an extended expiry. Safe for concurrent use.
type IMDSProvider struct {
cache *aws.CredentialsCache
}
+57
View File
@@ -88,6 +88,63 @@ func TestRetrieve_RefetchesWhenExpired(t *testing.T) {
}
}
// imdsStubFailAfterFirst serves creds on the first credential request and 500s
// on every one after it, so a test can drive a refresh that fails against a
// credential the cache has already stored.
func imdsStubFailAfterFirst(t *testing.T, creds map[string]string) *httptest.Server {
t.Helper()
var hits atomic.Int32
mux := http.NewServeMux()
mux.HandleFunc("/latest/api/token", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Aws-Ec2-Metadata-Token-Ttl-Seconds", "21600")
_, _ = w.Write([]byte("v2-token"))
})
mux.HandleFunc("/latest/meta-data/iam/security-credentials/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/latest/meta-data/iam/security-credentials/" {
_, _ = w.Write([]byte("node-role"))
return
}
if hits.Add(1) > 1 {
w.WriteHeader(http.StatusInternalServerError)
return
}
_ = json.NewEncoder(w).Encode(creds)
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
return srv
}
// A refresh that fails must surface as an error. Left to itself the SDK's
// ec2rolecreds implements HandleFailToRefresh, which hands back the same dead
// key with Expires pushed 5-15 minutes out, so the agent would sign requests
// with a credential the control plane has already rejected — indefinitely,
// since every subsequent refresh fails the same way.
func TestRetrieve_FailedRefreshDoesNotExtendExpiry(t *testing.T) {
srv := imdsStubFailAfterFirst(t, map[string]string{
"Code": "Success",
"AccessKeyId": "AKIA",
"SecretAccessKey": "secret",
"Token": "session",
// Already expired, so the next Retrieve must go back to IMDS.
"Expiration": time.Now().Add(-time.Minute).UTC().Format(time.RFC3339),
})
p := NewIMDSProvider(srv.Client(), srv.URL+"/latest")
first, err := p.Retrieve(context.Background())
if err != nil {
t.Fatalf("first Retrieve: %v", err)
}
got, err := p.Retrieve(context.Background())
if err == nil {
t.Fatalf("refresh failure was reported as success: %+v", got)
}
if got.Expiration.After(first.Expiration) {
t.Errorf("expiry extended on refresh failure: %v -> %v", first.Expiration, got.Expiration)
}
}
func TestRetrieve_CancelledContext(t *testing.T) {
srv := imdsStub(t, nil, map[string]string{"Code": "Success", "AccessKeyId": "A", "SecretAccessKey": "B"})
p := NewIMDSProvider(srv.Client(), srv.URL+"/latest")
+1 -1
View File
@@ -17,7 +17,7 @@ import (
"os/signal"
"syscall"
_ "github.com/mulgadc/spinifex/internal/fipsboot"
_ "github.com/mulgadc/bluebottle/pkg/fipsboot"
)
func main() {
+1 -1
View File
@@ -40,7 +40,7 @@ import (
"github.com/mulgadc/spinifex/internal/eksgw"
_ "github.com/mulgadc/spinifex/internal/fipsboot"
_ "github.com/mulgadc/bluebottle/pkg/fipsboot"
)
// stagedAddon mirrors the gateway's StagedAddonManifest wire shape; duplicated
+1 -1
View File
@@ -31,7 +31,7 @@ import (
"github.com/mulgadc/spinifex/internal/eksgw"
_ "github.com/mulgadc/spinifex/internal/fipsboot"
_ "github.com/mulgadc/bluebottle/pkg/fipsboot"
)
const (
+2
View File
@@ -38,6 +38,8 @@ import (
"path/filepath"
"strings"
"time"
_ "github.com/mulgadc/bluebottle/pkg/fipsboot"
)
func main() {
+1 -1
View File
@@ -15,7 +15,7 @@ import (
"os"
"time"
"github.com/mulgadc/spinifex/internal/tlsconfig"
"github.com/mulgadc/bluebottle/pkg/tlsconfig"
)
// serverTLSConfig is the webhook's loopback serving TLS config.
+2
View File
@@ -27,6 +27,8 @@ import (
"github.com/mulgadc/spinifex/internal/eksgw"
handlers_eks "github.com/mulgadc/spinifex/spinifex/handlers/eks"
_ "github.com/mulgadc/bluebottle/pkg/fipsboot"
)
// config is the webhook's runtime configuration, sourced from the first-boot
+2
View File
@@ -32,6 +32,8 @@ import (
"path/filepath"
"strings"
"time"
_ "github.com/mulgadc/bluebottle/pkg/fipsboot"
)
func main() {
+1 -1
View File
@@ -5,10 +5,10 @@ import (
"os"
"strings"
_ "github.com/mulgadc/bluebottle/pkg/fipsboot"
"github.com/mulgadc/spinifex/cmd/installer/autoinstall"
"github.com/mulgadc/spinifex/cmd/installer/install"
"github.com/mulgadc/spinifex/cmd/installer/ui"
_ "github.com/mulgadc/spinifex/internal/fipsboot"
)
func main() {
+896
View File
@@ -0,0 +1,896 @@
//test:in-package — the wizard model, its screen constants and the key handlers
//under test are all unexported, and the tests read model fields directly to
//assert which screen a key sequence landed on.
package ui
import (
"slices"
"strings"
"testing"
tea "github.com/charmbracelet/bubbletea"
"github.com/mulgadc/spinifex/cmd/installer/install"
)
// namedKeys maps the key names the handlers switch on to their tea.KeyType.
// Anything not listed is sent as runes.
var namedKeys = map[string]tea.KeyType{
"enter": tea.KeyEnter,
"esc": tea.KeyEsc,
"tab": tea.KeyTab,
"shift+tab": tea.KeyShiftTab,
"up": tea.KeyUp,
"down": tea.KeyDown,
"left": tea.KeyLeft,
"right": tea.KeyRight,
" ": tea.KeySpace,
"backspace": tea.KeyBackspace,
"ctrl+c": tea.KeyCtrlC,
}
// keyMsg builds the tea.KeyMsg for a key name and checks it round-trips, so a
// mis-built message fails here rather than silently exercising the wrong branch.
func keyMsg(t *testing.T, key string) tea.KeyMsg {
t.Helper()
msg := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(key)}
if kt, ok := namedKeys[key]; ok {
msg = tea.KeyMsg{Type: kt}
}
if got := msg.String(); got != key {
t.Fatalf("built key %q, which the handlers see as %q", key, got)
}
return msg
}
// press feeds keys through Update, which is the only entry point the bubbletea
// runtime uses, and returns the resulting model.
func press(t *testing.T, m model, keys ...string) model {
t.Helper()
for _, k := range keys {
next, _ := m.Update(keyMsg(t, k))
typed, ok := next.(model)
if !ok {
t.Fatalf("Update returned %T, not a model", next)
}
m = typed
}
return m
}
// typeText sends a string one rune at a time, the way a terminal does.
func typeText(t *testing.T, m model, s string) model {
t.Helper()
for _, r := range s {
m = press(t, m, string(r))
}
return m
}
func wizardModel(t *testing.T, diskCount, nicCount int) model {
t.Helper()
disks := make([]install.Disk, 0, diskCount)
for i := range diskCount {
disks = append(disks, install.Disk{
Path: "/dev/sd" + string(rune('a'+i)),
Bytes: 200 << 30,
LogicalBlockSize: 512,
})
}
m := newModel(disks, nics(nicCount))
m.width, m.height = 100, 40
return m
}
// The wizard has to be completable from the welcome screen using only the keys
// the help lines advertise, and produce the config the operator entered.
func TestWizardWalkthroughProducesTheEnteredConfig(t *testing.T) {
m := wizardModel(t, 1, 2)
m = press(t, m, "enter") // welcome → disk
if m.screen != screenDisk {
t.Fatalf("screen = %v, want the disk screen", m.screen)
}
m = press(t, m, "enter") // preselected disk is valid → erase confirmation
if m.screen != screenDiskConfirm {
t.Fatalf("screen = %v, want the erase confirmation", m.screen)
}
m = typeText(t, m, "yes")
m = press(t, m, "enter") // → network roles
if m.screen != screenNetworkRoles {
t.Fatalf("screen = %v, want the network roles screen", m.screen)
}
m = press(t, m, "down", "down", "down", "enter") // Continue → identity
if m.screen != screenIdentity {
t.Fatalf("screen = %v, want the identity screen", m.screen)
}
m = typeText(t, m, "node1")
m = press(t, m, "enter") // → credentials
if m.screen != screenPassword {
t.Fatalf("screen = %v, want the credentials screen", m.screen)
}
m = typeText(t, m, "admin@example.com")
m = press(t, m, "enter") // email accepted → password
m = typeText(t, m, "hunter2hunter2")
m = press(t, m, "enter") // → confirm password
m = typeText(t, m, "hunter2hunter2")
m = press(t, m, "enter") // → final confirmation
if m.screen != screenConfirm {
t.Fatalf("screen = %v, want the final confirmation (err: %q)", m.screen, m.validationErr)
}
m = press(t, m, "y")
if m.screen != screenDone {
t.Fatalf("screen = %v, want done", m.screen)
}
if m.result == nil {
t.Fatal("result is nil after confirming")
}
cfg := m.result
if cfg.Hostname != "node1" {
t.Errorf("Hostname = %q, want node1", cfg.Hostname)
}
if cfg.Email != "admin@example.com" {
t.Errorf("Email = %q, want admin@example.com", cfg.Email)
}
if cfg.RootPassword != "hunter2hunter2" {
t.Errorf("RootPassword = %q, want what was typed", cfg.RootPassword)
}
if !slices.Equal(cfg.Storage.Paths(), []string{"/dev/sda"}) {
t.Errorf("Storage.Paths() = %v, want the preselected disk", cfg.Storage.Paths())
}
if cfg.WAN.Interface != "eno1" {
t.Errorf("WAN interface = %q, want the first NIC", cfg.WAN.Interface)
}
if err := cfg.Validate(); err != nil {
t.Errorf("the wizard produced a config it would refuse to install: %v", err)
}
}
// Esc has to walk back the way it came, or an operator who overshoots is stuck
// with a wizard they can only cancel out of.
func TestEscapeWalksBackThroughTheScreens(t *testing.T) {
m := wizardModel(t, 1, 2)
m = press(t, m, "enter") // disk
m = press(t, m, "enter") // erase confirmation
for _, step := range []struct {
key string
want screen
}{
{"esc", screenDisk},
{"esc", screenWelcome},
} {
m = press(t, m, step.key)
if m.screen != step.want {
t.Fatalf("after esc: screen = %v, want %v", m.screen, step.want)
}
}
}
// Ctrl+C is handled before any screen sees the key, so it works everywhere.
func TestCtrlCCancelsWithAnError(t *testing.T) {
m := wizardModel(t, 1, 1)
next, cmd := m.Update(keyMsg(t, "ctrl+c"))
final, ok := next.(model)
if !ok {
t.Fatalf("Update returned %T", next)
}
if final.err == nil || !strings.Contains(final.err.Error(), "cancelled") {
t.Fatalf("err = %v, want a cancellation", final.err)
}
if cmd == nil {
t.Error("ctrl+c must return tea.Quit")
}
}
// A window resize has to reach the model, since every view lays out against it.
func TestWindowResizeIsStored(t *testing.T) {
m := model{}
next, _ := m.Update(tea.WindowSizeMsg{Width: 120, Height: 50})
resized, ok := next.(model)
if !ok {
t.Fatalf("Update returned %T", next)
}
if resized.width != 120 || resized.height != 50 {
t.Errorf("size = %dx%d, want 120x50", resized.width, resized.height)
}
}
// The erase prompt is the last point of no return, so anything other than an
// explicit "yes" has to keep the operator on it.
func TestEraseConfirmationRequiresYes(t *testing.T) {
m := wizardModel(t, 1, 1)
m = press(t, m, "enter", "enter") // → erase confirmation
m = typeText(t, m, "no")
m = press(t, m, "enter")
if m.screen != screenDiskConfirm {
t.Fatalf("screen = %v, want to stay on the confirmation", m.screen)
}
if !strings.Contains(m.validationErr, "yes") {
t.Errorf("validationErr = %q, want it to say what to type", m.validationErr)
}
if !strings.Contains(m.viewDiskConfirm(100), m.validationErr) {
t.Error("the confirmation screen must show the error it set")
}
}
// The filesystem selector and the disk list share one screen, so the cursor has
// to move between them and stop at both ends.
func TestDiskScreenCursorStaysInRange(t *testing.T) {
m := wizardModel(t, 2, 1)
m.screen = screenDisk
m = press(t, m, "up", "up")
if m.storageCursor != fsRow {
t.Errorf("storageCursor = %d, want it clamped to the filesystem row", m.storageCursor)
}
m = press(t, m, "down", "down", "down", "down")
if m.storageCursor != len(m.disks) {
t.Errorf("storageCursor = %d, want it clamped to the last disk", m.storageCursor)
}
}
// Left/right only cycle the filesystem while the cursor is on that row, and
// they wrap, so one key reaches every topology.
func TestFilesystemCyclesOnlyOnItsOwnRow(t *testing.T) {
m := wizardModel(t, 2, 1)
m.screen = screenDisk
m = press(t, m, "left")
if got := m.fsType(); got != install.AllFSTypes[len(install.AllFSTypes)-1] {
t.Errorf("fsType = %v, want the cycle to wrap backwards to the last", got)
}
m = press(t, m, "right")
if got := m.fsType(); got != install.AllFSTypes[0] {
t.Errorf("fsType = %v, want the cycle to wrap forwards to the first", got)
}
m = press(t, m, "down") // onto the first disk
before := m.fsType()
m = press(t, m, "left", "right")
if m.fsType() != before {
t.Errorf("fsType changed to %v while the cursor was on a disk", m.fsType())
}
}
// Space toggles the disk under the cursor, and r cycles its role — the two keys
// the storage screen exists for.
func TestDiskScreenSelectionKeys(t *testing.T) {
m := wizardModel(t, 2, 1)
m.screen = screenDisk
// The second disk is unselected; the first arrives preselected as os.
m = press(t, m, "down", "down", " ")
if !slices.Contains(m.selected, 1) {
t.Fatalf("selected = %v, want the second disk added", m.selected)
}
roleBefore := m.roleOf[slices.Index(m.selected, 1)]
m = press(t, m, "r")
if got := m.roleOf[slices.Index(m.selected, 1)]; got == roleBefore {
t.Errorf("role stayed %q after r", got)
}
m = press(t, m, " ")
if slices.Contains(m.selected, 1) {
t.Fatalf("selected = %v, want the second disk removed again", m.selected)
}
if len(m.roleOf) != len(m.selected) {
t.Errorf("roleOf (%d) and selected (%d) drifted", len(m.roleOf), len(m.selected))
}
}
// A selection that cannot be installed has to be reported on the screen the
// operator can still fix it on, not part-way through the install.
func TestDiskScreenRefusesAnInvalidSelection(t *testing.T) {
m := wizardModel(t, 2, 1)
m.screen = screenDisk
m.fsCursor = slices.Index(install.AllFSTypes, install.FSZFSRAID1) // needs 2 disks
m = press(t, m, "enter")
if m.screen != screenDisk {
t.Fatalf("screen = %v, want to stay on the disk screen", m.screen)
}
if m.validationErr == "" {
t.Fatal("an invalid selection must set a validation error")
}
if !strings.Contains(m.viewDisk(100), m.validationErr) {
t.Error("the disk screen must render the error it set")
}
// Selecting the second disk satisfies the mirror and clears the way.
m = press(t, m, "down", "down", " ", "enter")
if m.screen != screenDiskConfirm {
t.Fatalf("screen = %v, want to advance once the mirror has two disks", m.screen)
}
}
// The ZFS options screen is only reachable on a pool, since its settings have
// no meaning on ext4.
func TestZFSOptionsScreenIsZFSOnly(t *testing.T) {
m := wizardModel(t, 2, 1)
m.screen = screenDisk
m = press(t, m, "a")
if m.screen != screenDisk {
t.Fatalf("screen = %v, want a to do nothing on ext4", m.screen)
}
m.fsCursor = slices.Index(install.AllFSTypes, install.FSZFSRAID1)
m = press(t, m, "a")
if m.screen != screenZFSOptions {
t.Fatalf("screen = %v, want the ZFS options screen", m.screen)
}
}
// Every option defaults to auto and cycles through its own choice list, with
// the cursor clamped to the rows that exist.
func TestZFSOptionsCycleTheirValues(t *testing.T) {
m := wizardModel(t, 2, 1)
m.screen = screenZFSOptions
if got := zfsOptions[0].value(&m.zfs); got != "auto" {
t.Errorf("ashift starts at %q, want auto", got)
}
m = press(t, m, "right")
if got := zfsOptions[0].value(&m.zfs); got == "auto" {
t.Error("right must move ashift off auto")
}
m = press(t, m, "left")
if got := zfsOptions[0].value(&m.zfs); got != "auto" {
t.Errorf("ashift = %q after cycling back, want auto", got)
}
m = press(t, m, "up", "up")
if m.zfsCursor != 0 {
t.Errorf("zfsCursor = %d, want it clamped to the first row", m.zfsCursor)
}
for range len(zfsOptions) + 2 {
m = press(t, m, "down")
}
if m.zfsCursor != len(zfsOptions)-1 {
t.Errorf("zfsCursor = %d, want it clamped to the last row", m.zfsCursor)
}
// The compression choices are strings rather than ints, so they exercise
// the other half of cycle.
m.zfsCursor = 1
m = press(t, m, "right")
if m.zfs.Compress == "" {
t.Error("right must set a compression algorithm")
}
m = press(t, m, "enter")
if m.screen != screenDisk {
t.Fatalf("screen = %v, want enter to return to the disk screen", m.screen)
}
}
// The screen has to show what each option is currently set to, since that is
// the only place the pool's geometry is stated before it is created.
func TestZFSOptionsViewShowsValuesAndTheFocusedNote(t *testing.T) {
m := wizardModel(t, 2, 1)
m.screen = screenZFSOptions
m.zfs.Compress = "zstd"
got := m.viewZFSOptions(100)
for _, want := range []string{"ashift", "auto", "compression", "zstd", zfsOptions[0].note} {
if !strings.Contains(got, want) {
t.Errorf("ZFS options screen is missing %q:\n%s", want, got)
}
}
if strings.Contains(got, zfsOptions[1].note) {
t.Error("only the focused row's note should be shown")
}
}
// Credentials are three fields on one screen, so focus has to cycle both ways
// and land somewhere sensible after a validation failure.
func TestCredentialsFocusCyclesBothWays(t *testing.T) {
m := wizardModel(t, 1, 1)
m.screen = screenPassword
m = m.setCredsFocus(0)
m = press(t, m, "tab")
if m.credsFocus != 1 || !m.passwordInput.Focused() {
t.Fatalf("credsFocus = %d, want the password field focused", m.credsFocus)
}
m = press(t, m, "tab", "tab")
if m.credsFocus != 0 || !m.emailInput.Focused() {
t.Fatalf("credsFocus = %d, want the cycle to wrap to email", m.credsFocus)
}
m = press(t, m, "shift+tab")
if m.credsFocus != 2 || !m.passwordConfirmInput.Focused() {
t.Fatalf("credsFocus = %d, want shift+tab to wrap backwards to confirm", m.credsFocus)
}
}
// Each credential rule has to stop the wizard on the screen and put the cursor
// on the field at fault, not just refuse to advance.
func TestCredentialValidation(t *testing.T) {
tests := []struct {
name string
email string
password string
confirm string
wantErr string
wantFocus int
wantScreen screen
}{
{
name: "rejects a malformed email", email: "not-an-email",
password: "hunter2hunter2", confirm: "hunter2hunter2",
wantErr: "email", wantFocus: 0, wantScreen: screenPassword,
},
{
name: "rejects an empty password", email: "admin@example.com",
wantErr: "Password is required", wantFocus: 1, wantScreen: screenPassword,
},
{
name: "rejects a mismatch", email: "admin@example.com",
password: "hunter2hunter2", confirm: "hunter2hunter3",
wantErr: "do not match", wantFocus: 2, wantScreen: screenPassword,
},
{
name: "accepts a matching pair", email: "admin@example.com",
password: "hunter2hunter2", confirm: "hunter2hunter2",
wantScreen: screenConfirm,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
m := wizardModel(t, 1, 1)
m.screen = screenPassword
m.emailInput.SetValue(tt.email)
m.passwordInput.SetValue(tt.password)
m.passwordConfirmInput.SetValue(tt.confirm)
m = m.setCredsFocus(2) // enter on confirm runs every rule
m = press(t, m, "enter")
if m.screen != tt.wantScreen {
t.Fatalf("screen = %v, want %v (err %q)", m.screen, tt.wantScreen, m.validationErr)
}
if tt.wantErr == "" {
if m.validationErr != "" {
t.Fatalf("validationErr = %q, want none", m.validationErr)
}
return
}
if !strings.Contains(strings.ToLower(m.validationErr), strings.ToLower(tt.wantErr)) {
t.Errorf("validationErr = %q, want it to mention %q", m.validationErr, tt.wantErr)
}
if m.credsFocus != tt.wantFocus {
t.Errorf("credsFocus = %d, want the cursor on field %d", m.credsFocus, tt.wantFocus)
}
if !strings.Contains(m.viewPassword(100), m.validationErr) {
t.Error("the credentials screen must render the error it set")
}
})
}
}
// The hostname ends up in the config and in DNS, so an empty one has to be
// caught on the screen rather than installed.
func TestHostnameIsRequired(t *testing.T) {
m := wizardModel(t, 1, 1)
m.screen = screenIdentity
m.hostnameInput.Focus()
m = press(t, m, "enter")
if m.screen != screenIdentity {
t.Fatalf("screen = %v, want to stay on identity", m.screen)
}
if !strings.Contains(m.validationErr, "Hostname") {
t.Errorf("validationErr = %q, want it to name the field", m.validationErr)
}
if !m.hostnameInput.Focused() {
t.Error("the hostname field must keep focus after a failed submit")
}
if !strings.Contains(m.viewIdentity(100), m.validationErr) {
t.Error("the identity screen must render the error it set")
}
}
// Tab order follows the visible fields, which change with the addressing mode,
// so it has to be derived from them rather than fixed.
func TestRoleEditorTabsThroughVisibleFieldsOnly(t *testing.T) {
m := wizardModel(t, 1, 2)
m.screen = screenNetworkRole
m.roleCursor = 0 // wan, DHCP by default
m = press(t, m, "tab")
if m.roles[0].focus != roleFieldMethod {
t.Fatalf("focus = %v, want the IP method field", m.roles[0].focus)
}
m = press(t, m, "tab")
if m.roles[0].focus != roleFieldNIC {
t.Fatalf("focus = %v, want the DHCP form to wrap after two fields", m.roles[0].focus)
}
// Switching to static reveals the address fields, and tab must reach them.
m = press(t, m, "tab", "right")
if m.roles[0].dhcp {
t.Fatal("right on the method field must select static addressing")
}
m = press(t, m, "tab")
if m.roles[0].focus != roleFieldIP || !m.roles[0].ip.Focused() {
t.Fatalf("focus = %v, want the IP field focused", m.roles[0].focus)
}
m = press(t, m, "shift+tab")
if m.roles[0].focus != roleFieldMethod {
t.Fatalf("focus = %v, want shift+tab to go back", m.roles[0].focus)
}
}
// A focused text field has to receive typed runes, since that is the only way
// an address is entered.
func TestRoleEditorTypesIntoTheFocusedField(t *testing.T) {
m := wizardModel(t, 1, 2)
m.screen = screenNetworkRole
m.roles[0].dhcp = false
m.roles[0].focus = roleFieldIP
m.roles[0].focusCurrent()
m = typeText(t, m, "192.168.1.10")
if got := m.roles[0].ip.Value(); got != "192.168.1.10" {
t.Fatalf("ip = %q, want what was typed", got)
}
if got := m.roles[0].mask.Value(); got != "" {
t.Errorf("mask = %q, want the keys to reach only the focused field", got)
}
}
// The interface selector wraps and offers the fold option on lan and vpc only,
// so a single-NIC node stays configurable with one key.
func TestNICSelectorWrapsAndFoldsWhereAllowed(t *testing.T) {
m := wizardModel(t, 1, 2)
m.screen = screenNetworkRole
// wan cannot fold, so it wraps across the real NICs only.
m.roleCursor = 0
m = press(t, m, "left")
if m.roles[0].nic != len(m.nics)-1 {
t.Fatalf("wan nic = %d, want it to wrap to the last NIC", m.roles[0].nic)
}
m = press(t, m, "right")
if m.roles[0].nic != 0 {
t.Fatalf("wan nic = %d, want it to wrap back to the first", m.roles[0].nic)
}
// lan may fold, so the cycle includes the unbound position.
m.roleCursor = 1
m.roles[1].nic = 0
m = press(t, m, "left")
if m.roles[1].nic != foldedNIC {
t.Fatalf("lan nic = %d, want the fold position", m.roles[1].nic)
}
if !m.roles[1].folded() {
t.Error("the lan role should read as folded")
}
}
// Per-role validation runs on Enter and keeps the operator on the editor, since
// an invalid address here fails the install after the disk is partitioned.
func TestRoleEditorValidatesBeforeLeaving(t *testing.T) {
m := wizardModel(t, 1, 2)
m.screen = screenNetworkRole
m.roleCursor = 0
m.roles[0].dhcp = false
m.roles[0].ip.SetValue("not-an-ip")
m = press(t, m, "enter")
if m.screen != screenNetworkRole {
t.Fatalf("screen = %v, want to stay on the role editor", m.screen)
}
if !strings.Contains(m.validationErr, "IP address") {
t.Errorf("validationErr = %q, want it to name the field", m.validationErr)
}
if !strings.Contains(m.viewNetworkRole(100), m.validationErr) {
t.Error("the role editor must render the error it set")
}
// A prefix length is rejected here because netgen parses the mask with
// net.ParseIP and would fail the install itself.
m.roles[0].ip.SetValue("192.168.1.10")
m.roles[0].mask.SetValue("24")
m = press(t, m, "enter")
if !strings.Contains(m.validationErr, "dotted-decimal") {
t.Errorf("validationErr = %q, want the mask rule", m.validationErr)
}
m.roles[0].mask.SetValue("255.255.255.0")
m.roles[0].gateway.SetValue("192.168.1.1")
m = press(t, m, "enter")
if m.screen != screenNetworkRoles {
t.Fatalf("screen = %v, want a valid role to return to the overview (err %q)", m.screen, m.validationErr)
}
}
// Esc abandons the editor without validating, so a half-typed address is not a
// trap the operator has to finish before backing out.
func TestRoleEditorEscapeSkipsValidation(t *testing.T) {
m := wizardModel(t, 1, 2)
m.screen = screenNetworkRole
m.roles[0].dhcp = false
m.roles[0].ip.SetValue("192.")
m = press(t, m, "esc")
if m.screen != screenNetworkRoles {
t.Fatalf("screen = %v, want the overview", m.screen)
}
if m.validationErr != "" {
t.Errorf("validationErr = %q, want esc to clear it", m.validationErr)
}
}
// The editor shows only the fields that apply, so the DHCP form stays two lines
// and Advanced is what reveals VLAN and MTU.
func TestRoleEditorViewShowsTheApplicableFields(t *testing.T) {
m := wizardModel(t, 1, 3)
m.screen = screenNetworkRole
m.roleCursor = 0
dhcpView := m.viewNetworkRole(100)
if !strings.Contains(dhcpView, "DHCP") || !strings.Contains(dhcpView, "WAN") {
t.Errorf("DHCP view is missing the plane or the method:\n%s", dhcpView)
}
for _, hidden := range []string{"IP address", "Subnet mask", "VLAN id"} {
if strings.Contains(dhcpView, hidden) {
t.Errorf("DHCP view should not show %q:\n%s", hidden, dhcpView)
}
}
m.roles[0].dhcp = false
m.advanced = true
staticView := m.viewNetworkRole(100)
for _, want := range []string{"IP address", "Subnet mask", "Gateway", "DNS", "VLAN id", "MTU"} {
if !strings.Contains(staticView, want) {
t.Errorf("static advanced view is missing %q:\n%s", want, staticView)
}
}
// Gateway and DNS belong to the plane holding the default route, so a
// static lan must not offer them.
m.roleCursor = 1
m.roles[1].nic = 1
m.roles[1].dhcp = false
lanView := m.viewNetworkRole(100)
for _, hidden := range []string{"Gateway", "DNS"} {
if strings.Contains(lanView, hidden) {
t.Errorf("static lan view should not show %q:\n%s", hidden, lanView)
}
}
}
// A folded role names the plane it landed on, on both screens, so the operator
// can see where the traffic actually goes before committing.
func TestFoldedRolesAreNamedNotBlank(t *testing.T) {
m := wizardModel(t, 1, 1) // one NIC folds lan and vpc onto wan
overview := m.viewNetworkRoles(100)
if !strings.Contains(overview, "folds onto") {
t.Errorf("overview does not state where the folded roles land:\n%s", overview)
}
m.screen = screenNetworkRole
m.roleCursor = 2
if got := m.viewNetworkRole(100); !strings.Contains(got, "fold onto") {
t.Errorf("role editor does not offer the fold plainly:\n%s", got)
}
m.screen = screenConfirm
if got := m.viewConfirm(100); !strings.Contains(got, "folded onto") {
t.Errorf("confirmation does not state the folded planes:\n%s", got)
}
}
// Continue is refused while the config would fail, and the message is the one
// install.Config.Validate produced.
func TestRolesOverviewRefusesAnInvalidConfig(t *testing.T) {
m := wizardModel(t, 1, 2)
m.screen = screenNetworkRoles
m.roles[1].nic = 0 // lan on the same NIC as wan, no VLAN to separate them
m.roleCursor = continueRow
m = press(t, m, "enter")
if m.screen != screenNetworkRoles {
t.Fatalf("screen = %v, want to stay on the overview", m.screen)
}
if !strings.Contains(m.validationErr, "VLAN") {
t.Errorf("validationErr = %q, want the shared-NIC rule", m.validationErr)
}
// The message is wrapped to the box, so match its opening rather than
// the whole line.
if !strings.Contains(m.viewNetworkRoles(100), "both bind eno1") {
t.Error("the overview must render the error it set")
}
// a toggles Advanced for the whole node rather than per role.
m = press(t, m, "a")
if !m.advanced {
t.Error("a must turn Advanced on")
}
if !strings.Contains(m.viewNetworkRoles(100), "Advanced: on") {
t.Error("the overview must show Advanced is on")
}
}
// The final screen is the last chance to catch a wrong disk or address, so the
// summary has to carry what was entered.
func TestConfirmScreenSummarisesTheEnteredConfig(t *testing.T) {
m := wizardModel(t, 1, 3)
m.screen = screenConfirm
m.hostnameInput.SetValue("node7")
m.roles[1].dhcp = false
m.roles[1].ip.SetValue("10.0.0.3")
m.roles[1].mask.SetValue("255.255.255.0")
got := m.viewConfirm(100)
for _, want := range []string{"node7", "/dev/sda", "ext4", "eno1", "10.0.0.3/255.255.255.0", "br-lan", "will erase"} {
if !strings.Contains(got, want) {
t.Errorf("confirmation is missing %q:\n%s", want, got)
}
}
}
// n on the confirmation cancels rather than installing.
func TestConfirmScreenCancelsOnN(t *testing.T) {
m := wizardModel(t, 1, 1)
m.screen = screenConfirm
m = press(t, m, "n")
if m.err == nil {
t.Fatal("n must set the cancellation error")
}
if m.result != nil {
t.Error("a cancelled install must produce no config")
}
}
// View dispatches on the screen, so each one has to render its own content
// rather than falling through to an empty frame.
func TestViewRendersEveryScreen(t *testing.T) {
tests := []struct {
screen screen
want string
}{
{screenWelcome, "Enter to begin"},
{screenDisk, "Select Installation Disk"},
{screenZFSOptions, "ZFS Options"},
{screenDiskConfirm, "Confirm Disk Erasure"},
{screenNetworkRoles, "Network Planes"},
{screenNetworkRole, "Configure WAN plane"},
{screenIdentity, "Node Identity"},
{screenPassword, "Administrator Credentials"},
{screenConfirm, "Confirm Installation"},
{screenDone, "Installation complete"},
}
for _, tt := range tests {
m := wizardModel(t, 1, 2)
m.screen = tt.screen
if got := m.View(); !strings.Contains(got, tt.want) {
t.Errorf("screen %v does not render %q:\n%s", tt.screen, tt.want, got)
}
}
}
// The disk screen has to state what the selection actually builds, since the
// cost of the redundancy choice is otherwise invisible until after the install.
func TestDiskViewStatesTheResultingGeometry(t *testing.T) {
m := wizardModel(t, 3, 1)
m.screen = screenDisk
// ext4 with a role per drive: the mountpoints are the summary that matters.
m = press(t, m, "down", "down", " ", "down", " ")
if got := m.viewDisk(100); !strings.Contains(got, "/var/lib/spinifex") {
t.Errorf("ext4 view does not state the role layout:\n%s", got)
}
// Matched spares are suggested, never applied.
if got := m.viewDisk(100); !strings.Contains(got, "survive a disk failure") {
t.Errorf("view does not suggest the pool the matched disks allow:\n%s", got)
}
// A mirror with too few disks quotes the requirement, not a capacity.
m = wizardModel(t, 3, 1)
m.screen = screenDisk
m.fsCursor = slices.Index(install.AllFSTypes, install.FSZFSRAID1)
if got := m.viewDisk(100); !strings.Contains(got, "1 selected") {
t.Errorf("view does not state what the mirror is missing:\n%s", got)
}
// Once buildable it quotes usable capacity and the failures tolerated.
m = press(t, m, "down", "down", " ")
got := m.viewDisk(100)
if !strings.Contains(got, "usable") || !strings.Contains(got, "survives 1 disk failure") {
t.Errorf("view does not quote the pool geometry:\n%s", got)
}
if !strings.Contains(got, "A for ZFS options") {
t.Errorf("the ZFS help line should replace the role key:\n%s", got)
}
}
// The overview names the card behind each port, which is what distinguishes the
// 25gbe uplink from the onboard 1gbe.
func TestRolesOverviewNamesTheHardware(t *testing.T) {
m := wizardModel(t, 1, 2)
m.nics[0].AltName = "enp1s0f0"
m.advanced = true
got := m.viewNetworkRoles(100)
for _, want := range []string{"eno1", "25Gbps", "Mellanox", "enp1s0f0", "VLAN", "MTU"} {
if !strings.Contains(got, want) {
t.Errorf("overview is missing %q:\n%s", want, got)
}
}
}
func TestHumanBytes(t *testing.T) {
tests := []struct {
bytes int64
want string
}{
{512, "512B"},
{2 << 20, "2.0M"},
{3 << 30, "3.0G"},
{2 << 40, "2.0T"},
}
for _, tt := range tests {
if got := humanBytes(tt.bytes); got != tt.want {
t.Errorf("humanBytes(%d) = %q, want %q", tt.bytes, got, tt.want)
}
}
}
// The overview writes an address the way an operator does, and falls back to
// the raw mask rather than dropping it when it is not dotted-decimal.
func TestFormatCIDR(t *testing.T) {
tests := []struct {
name string
addr, mask string
want string
}{
{"dotted mask becomes a prefix", "10.0.0.3", "255.255.255.0", "10.0.0.3/24"},
{"short mask keeps its prefix", "10.0.0.3", "255.255.0.0", "10.0.0.3/16"},
{"no mask yet", "10.0.0.3", "", "10.0.0.3"},
{"no address is blank", "", "255.255.255.0", ""},
{"unparseable mask is shown as typed", "10.0.0.3", "24", "10.0.0.3/24"},
{"whitespace is trimmed", " 10.0.0.3 ", " 255.255.255.0 ", "10.0.0.3/24"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := formatCIDR(tt.addr, tt.mask); got != tt.want {
t.Errorf("formatCIDR(%q, %q) = %q, want %q", tt.addr, tt.mask, got, tt.want)
}
})
}
}
// Messages that are not keys reach the focused input, which is what keeps the
// cursor blinking on the two single-field screens.
func TestNonKeyMessagesReachTheActiveInput(t *testing.T) {
m := wizardModel(t, 1, 1)
m.screen = screenIdentity
m.hostnameInput.Focus()
next, _ := m.Update(struct{ tea.Msg }{})
if _, ok := next.(model); !ok {
t.Fatalf("Update returned %T", next)
}
m.screen = screenWelcome
if _, cmd := m.Update(struct{ tea.Msg }{}); cmd != nil {
t.Error("a screen with no input must issue no command")
}
}
+1 -1
View File
@@ -21,7 +21,7 @@ import (
"github.com/mulgadc/bluebottle/pkg/otelsetup"
"github.com/mulgadc/spinifex/spinifex/lbagent"
_ "github.com/mulgadc/spinifex/internal/fipsboot"
_ "github.com/mulgadc/bluebottle/pkg/fipsboot"
)
func main() {
+7 -1
View File
@@ -50,7 +50,13 @@ func newCommandRegistry(engine engineOps, storage storageOps) commandRegistry {
if err != nil {
return "", err
}
return "", engine.Quiesce(ctx, params[handlers_rds.CommandParamQuiesceLabel], hold)
if err := engine.Quiesce(ctx, params[handlers_rds.CommandParamQuiesceLabel], hold); err != nil {
return "", err
}
// After the hold, so what is flushed is the engine's checkpointed
// state. Its own files are not the risk — those it repairs from the
// WAL — but the parameter file beside them it never opens.
return "", storage.SyncDataMount(ctx)
},
handlers_rds.CommandUnquiesce: func(ctx context.Context, cmd handlers_rds.Command) (string, error) {
return "", engine.Unquiesce(ctx)
+60
View File
@@ -8,6 +8,7 @@ import (
"os/exec"
"os/user"
"path/filepath"
"slices"
"strconv"
"strings"
"syscall"
@@ -107,6 +108,10 @@ var engineLayouts = map[string]engineLayout{
service: "postgresql",
dataMount: "/var/lib/postgresql",
port: 5432,
// logfile in the image's /etc/conf.d/postgresql. The postmaster states
// which setting it refused there and nowhere else — the console shows only
// that pg_ctl could not start it, on a guest with no SSH.
errorLog: "/var/log/postgresql/postmaster.log",
newProbe: newPostgresProbe,
newEngine: newPostgresEngineFromCatalog,
},
@@ -131,6 +136,61 @@ var engineLayouts = map[string]engineLayout{
},
}
// How much of the engine's error log a probe reason carries. The server names
// what it refused on in its last few lines, and the reason reaches a customer
// through StatusInfos, so this quotes the tail rather than the whole file.
const (
engineErrorLogTailBytes = 4096
engineErrorLogTailLines = 4
)
// Appends the last few lines of the engine's error log to reason. An unset path,
// an unreadable file or an empty one leaves reason alone: the probe's own
// statement is still true, and a guessed cause would be worse than none.
func withEngineLogTail(reason, path string) string {
tail := engineLogTail(path)
if tail == "" {
return reason
}
return reason + "; the engine's log ends: " + tail
}
func engineLogTail(path string) string {
if path == "" {
return ""
}
f, err := os.Open(path)
if err != nil {
return ""
}
defer func() { _ = f.Close() }()
info, err := f.Stat()
if err != nil {
return ""
}
offset := max(info.Size()-engineErrorLogTailBytes, 0)
buf := make([]byte, info.Size()-offset)
if _, err := f.ReadAt(buf, offset); err != nil {
return ""
}
lines := strings.Split(string(buf), "\n")
// A read that started mid-file almost certainly started mid-line, and half a
// line is more misleading than one fewer.
if offset > 0 && len(lines) > 0 {
lines = lines[1:]
}
kept := make([]string, 0, engineErrorLogTailLines)
for i := len(lines) - 1; i >= 0 && len(kept) < engineErrorLogTailLines; i-- {
if line := strings.TrimSpace(lines[i]); line != "" {
kept = append(kept, line)
}
}
slices.Reverse(kept)
return strings.Join(kept, " | ")
}
// Builds the health probe the image's engine supplies.
func newProbe(cfg config, run probeRunner) (*engineProbe, error) {
layout, err := layoutFor(cfg)
@@ -148,7 +148,7 @@ func TestEngineLogTail(t *testing.T) {
},
{
name: "drops a line the byte cap cut in half",
body: strings.Repeat("x", mariadbErrorLogTailBytes) + "\nlast\n",
body: strings.Repeat("x", engineErrorLogTailBytes) + "\nlast\n",
want: "last",
wantMissing: "x",
},
@@ -209,3 +209,54 @@ func TestWithEngineLogTailLeavesReasonAloneWithoutALog(t *testing.T) {
t.Errorf("withEngineLogTail() = %q, want the reason unchanged", got)
}
}
const postgresConfD = "../../scripts/images/rds-postgres/postgresql.confd"
// "engine did not respond" is where a postmaster that refused a setting lands,
// and it is the same message as an engine that never got as far as trying. Only
// the server's own log separates them, and the guest has no other way out.
func TestPostgresProbe_QuotesTheEngineLogWhenTheEngineIsAbsent(t *testing.T) {
log := writeEngineLog(t, strings.Join([]string{
`2026-09-06 10:20:33 UTC [712] LOG: starting PostgreSQL 18.0`,
`2026-09-06 10:20:33 UTC [712] FATAL: could not map anonymous shared memory`,
"",
}, "\n"))
state, message := postgresProbeState("/run/postgresql", log,
func(context.Context, string, ...string) (int, string, error) { return 2, "", nil })(t.Context(), 5432)
if state != engineAbsent {
t.Errorf("state = %v, want absent", state)
}
if !strings.Contains(message, "could not map anonymous shared memory") {
t.Errorf("message = %q, want the postmaster's own refusal quoted", message)
}
}
func TestPostgresProbe_SurvivesAnAbsentEngineLog(t *testing.T) {
state, message := postgresProbeState("/run/postgresql", filepath.Join(t.TempDir(), "nope.log"),
func(context.Context, string, ...string) (int, string, error) { return 2, "", nil })(t.Context(), 5432)
if state != engineAbsent {
t.Errorf("state = %v, want absent", state)
}
if message != "engine did not respond on /run/postgresql:5432" {
t.Errorf("message = %q, want the bare reason when there is no log to quote", message)
}
}
// The layout names a path the image has to be directing the postmaster to, or
// the tail above quotes nothing on the one failure it exists for.
func TestPostgresErrorLogPathMatchesTheImage(t *testing.T) {
raw, err := os.ReadFile(postgresConfD)
if err != nil {
t.Fatalf("read postgresql.confd: %v", err)
}
m := regexp.MustCompile(`(?m)^logfile="(.*)"$`).FindStringSubmatch(string(raw))
if m == nil {
t.Fatal("postgresql.confd has no logfile assignment")
}
if got := engineLayouts[enginePostgres].errorLog; got != m[1] {
t.Errorf("layout errorLog = %q, but the image sets logfile = %q", got, m[1])
}
}
-52
View File
@@ -100,11 +100,6 @@ const (
// rds-init keeps for the platform and never hands to the customer.
mariadbSuperuser = "root"
mariadbProbeConnectTimeoutSeconds = 3
// How much of the engine's error log a probe reason carries. The server names
// what it refused on in its last few lines, and the reason reaches a customer
// through StatusInfos, so this quotes the tail rather than the whole file.
mariadbErrorLogTailBytes = 4096
mariadbErrorLogTailLines = 4
// How much of the probe client's stderr a reason carries, on the same grounds
// as the log tail above.
mariadbProbeStderrMaxBytes = 512
@@ -280,53 +275,6 @@ func collapseProbeStderr(stderr string) string {
return line
}
// Appends the last few lines of the engine's error log to reason. An unset path,
// an unreadable file or an empty one leaves reason alone: the probe's own
// statement is still true, and a guessed cause would be worse than none.
func withEngineLogTail(reason, path string) string {
tail := engineLogTail(path)
if tail == "" {
return reason
}
return reason + "; the engine's log ends: " + tail
}
func engineLogTail(path string) string {
if path == "" {
return ""
}
f, err := os.Open(path)
if err != nil {
return ""
}
defer func() { _ = f.Close() }()
info, err := f.Stat()
if err != nil {
return ""
}
offset := max(info.Size()-mariadbErrorLogTailBytes, 0)
buf := make([]byte, info.Size()-offset)
if _, err := f.ReadAt(buf, offset); err != nil {
return ""
}
lines := strings.Split(string(buf), "\n")
// A read that started mid-file almost certainly started mid-line, and half a
// line is more misleading than one fewer.
if offset > 0 && len(lines) > 0 {
lines = lines[1:]
}
kept := make([]string, 0, mariadbErrorLogTailLines)
for i := len(lines) - 1; i >= 0 && len(kept) < mariadbErrorLogTailLines; i-- {
if line := strings.TrimSpace(lines[i]); line != "" {
kept = append(kept, line)
}
}
slices.Reverse(kept)
return strings.Join(kept, " | ")
}
func readPidFile(path string) (int, error) {
raw, err := os.ReadFile(path)
if err != nil {
+9 -4
View File
@@ -47,10 +47,10 @@ const postgresProbeBinary = "pg_isready"
// enforcement rule — libpq reads a host beginning with / as a socket directory,
// and the generated `local ... peer` line covers it.
func newPostgresProbe(cfg config, run probeRunner) *engineProbe {
return newEngineProbe(cfg.EnginePort, postgresProbeState(cfg.SocketDir, run))
return newEngineProbe(cfg.EnginePort, postgresProbeState(cfg.SocketDir, cfg.EngineErrorLog, run))
}
func postgresProbeState(host string, run probeRunner) probeStateFn {
func postgresProbeState(host, errorLog string, run probeRunner) probeStateFn {
return func(ctx context.Context, port int64) (engineState, string) {
portArg := strconv.FormatInt(port, 10)
code, _, err := run(ctx, postgresProbeBinary, "-h", host, "-p", portArg, "-q")
@@ -63,9 +63,14 @@ func postgresProbeState(host string, run probeRunner) probeStateFn {
case code == 0:
return engineServing, ""
case code == 1:
return engineRecovering, "engine is rejecting connections (startup or recovery)"
return engineRecovering, withEngineLogTail(
"engine is rejecting connections (startup or recovery)", errorLog)
default:
return engineAbsent, fmt.Sprintf("engine did not respond on %s:%s", host, portArg)
// A postmaster that refused a setting never opens the socket, so this is
// the state a bad parameter lands in. The reason for it is only in the
// server's own log, and it reaches the customer through StatusInfos.
return engineAbsent, withEngineLogTail(
fmt.Sprintf("engine did not respond on %s:%s", host, portArg), errorLog)
}
}
}
+19 -3
View File
@@ -90,10 +90,26 @@ func (h *heartbeater) beat(ctx context.Context) {
h.setInterval(time.Duration(out.HeartbeatIntervalSeconds) * time.Second)
}
// How often a not-yet-serving engine is reported. The control plane owns the
// steady-state cadence, but while the engine has never answered it is actively
// waiting on this instance — a restart is not finished until a healthy beat
// lands — and a full interval of silence there is pure added downtime.
const startupHeartbeatInterval = 5 * time.Second
func (h *heartbeater) nextInterval() time.Duration {
if h.probe != nil && !h.probe.seenHealthy && startupHeartbeatInterval < h.interval {
return startupHeartbeatInterval
}
return h.interval
}
// A timer rather than a ticker, so a cadence change takes effect on the next
// beat, not at a restart.
// beat, not at a restart. The first beat is not waited for: a just-registered
// agent has told the control plane nothing about the engine yet, and holding
// that back for an interval makes every restart look longer than it was.
func (h *heartbeater) Run(ctx context.Context) {
timer := time.NewTimer(h.interval)
h.beat(ctx)
timer := time.NewTimer(h.nextInterval())
defer timer.Stop()
for {
select {
@@ -101,7 +117,7 @@ func (h *heartbeater) Run(ctx context.Context) {
return
case <-timer.C:
h.beat(ctx)
timer.Reset(h.interval)
timer.Reset(h.nextInterval())
}
}
}
+31
View File
@@ -88,3 +88,34 @@ func (r *countingServingRecorder) RecordServingParameters(context.Context) error
r.calls++
return nil
}
// A restart is not finished until a healthy beat lands, so the two cadence
// rules that shorten it are worth pinning: report at once rather than after a
// full interval, and keep reporting quickly while the engine has never served.
func TestHeartbeater_ReportsPromptlyUntilTheEngineHasServed(t *testing.T) {
code := 2
probe := newPostgresProbe(testProbeConfig(), func(context.Context, string, ...string) (int, string, error) {
return code, "", nil
})
cp := newFakeControlPlane()
h := newHeartbeater(cp, probe, nil, 0)
if got := h.nextInterval(); got != startupHeartbeatInterval {
t.Errorf("interval before the engine has served = %s, want %s", got, startupHeartbeatInterval)
}
ctx, cancel := context.WithCancel(context.Background())
go h.Run(ctx)
waitFor(t, func() bool { return len(cp.snapshotStates()) > 0 }, "the first heartbeat")
cancel()
if states := cp.snapshotStates(); states[0].health != handlers_rds.EngineHealthStarting {
t.Errorf("first beat health = %q, want %q", states[0].health, handlers_rds.EngineHealthStarting)
}
code = 0
h.beat(context.Background())
if got := h.nextInterval(); got != h.interval {
t.Errorf("interval after the engine served = %s, want the control plane's %s", got, h.interval)
}
}
+16 -5
View File
@@ -16,21 +16,32 @@ import (
"os/signal"
"syscall"
_ "github.com/mulgadc/spinifex/internal/fipsboot"
_ "github.com/mulgadc/bluebottle/pkg/fipsboot"
)
func main() {
cfg := loadConfig(defaultEnvFile)
agent, err := New(cfg)
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
// Retried rather than fatal: construction resolves the IMDS credential chain,
// so a metadata service that is not answering yet would otherwise end the
// process with the engine still down and nothing left to bring it back.
var agent *Agent
err := retry(ctx, "startup", func(context.Context) error {
var err error
agent, err = New(cfg)
return err
})
if err != nil {
if errors.Is(err, context.Canceled) {
return
}
slog.Error("rds-agent: startup failed", "err", err)
os.Exit(1)
}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
// A shutdown signal cancels whatever boot retry loop was running; that is a
// clean stop, not a failure.
if err := agent.Run(ctx); err != nil && !errors.Is(err, context.Canceled) {
+45
View File
@@ -2,6 +2,7 @@ package main
import (
"context"
"errors"
"slices"
"strings"
"sync"
@@ -132,6 +133,50 @@ func TestCommandRegistry_QuiesceCarriesTheLabelAndDeadline(t *testing.T) {
}
}
// The engine's own hold settles only the files it owns. Everything else on that
// volume — the parameter file the platform writes beside the datadir — is dirty
// page cache until this, and a snapshot of it restores an engine that will not
// start.
func TestCommandRegistry_QuiesceFlushesTheDataMount(t *testing.T) {
storage := &fakeStorage{}
reply := newCommander(nil, newCommandRegistry(&fakeEngine{}, storage), 0).execute(context.Background(),
quiesceCommand("cmd-5a", "360"))
if reply.Status != handlers_rds.CommandStatusSucceeded {
t.Fatalf("reply = %+v, want succeeded", reply)
}
if !storage.synced {
t.Error("the data mount was not flushed, so the snapshot may not hold what the guest wrote")
}
}
// A snapshot taken after a flush that did not happen is the failure this whole
// path exists to prevent, so it is reported rather than swallowed. The hold is
// left on the engine: it expires on its own deadline.
func TestCommandRegistry_QuiesceFailsWhenTheDataMountCannotBeFlushed(t *testing.T) {
storage := &fakeStorage{syncErr: errors.New("input/output error")}
reply := newCommander(nil, newCommandRegistry(&fakeEngine{}, storage), 0).execute(context.Background(),
quiesceCommand("cmd-5b", "360"))
if reply.Status != handlers_rds.CommandStatusFailed {
t.Fatalf("reply = %+v, want failed", reply)
}
if !strings.Contains(reply.Message, "input/output error") {
t.Errorf("reply message = %q, want the flush error", reply.Message)
}
}
func quiesceCommand(id, deadline string) handlers_rds.Command {
return handlers_rds.Command{
CommandID: id,
Type: handlers_rds.CommandQuiesce,
Parameters: []handlers_rds.Parameter{
{Name: handlers_rds.CommandParamQuiesceLabel, Value: "orders-db-pre-upgrade"},
{Name: handlers_rds.CommandParamQuiesceDeadlineSeconds, Value: deadline},
},
}
}
// A hold the guest chose the length of would not be the bound the control plane
// is relying on, so a missing or unreadable deadline is refused.
func TestCommandRegistry_QuiesceRejectsAnUnusableDeadline(t *testing.T) {
+22
View File
@@ -8,6 +8,8 @@ import (
"os"
"path/filepath"
"strings"
"golang.org/x/sys/unix"
)
// The in-guest half of a storage grow: the control plane has already grown the
@@ -17,6 +19,9 @@ type storageOps interface {
// Extends the filesystem at the data mount onto its whole device, and
// reports what it did for the command reply.
GrowFilesystem(ctx context.Context) (string, error)
// Flushes every dirty page on the data mount to its device. A backup hold
// settles the engine's own files and nothing else on that filesystem.
SyncDataMount(ctx context.Context) error
}
// Tool names rather than paths: the image installs them from
@@ -50,6 +55,23 @@ func newGuestStorage(cfg config, run commandRunner) *guestStorage {
}
}
// syncfs rather than fsync: the whole filesystem, not one file. Config the
// engine never opens lives on this volume too, and a snapshot that catches it
// half-written leaves a restored instance the engine cannot start from.
func (g *guestStorage) SyncDataMount(ctx context.Context) error {
f, err := os.Open(g.dataMount)
if err != nil {
return fmt.Errorf("open the data mount %s to flush it: %w", g.dataMount, err)
}
defer func() { _ = f.Close() }()
if err := unix.Syncfs(int(f.Fd())); err != nil {
return fmt.Errorf("flush the data mount %s: %w", g.dataMount, err)
}
slog.InfoContext(ctx, "rds-agent: data mount flushed for backup", "dataMount", g.dataMount)
return nil
}
// The device and filesystem behind the data mount, resolved from the kernel's
// own mount table rather than from anything the control plane asserted.
type dataMount struct {
+29
View File
@@ -15,8 +15,10 @@ import (
// is that the command reaches it rather than what it does to a filesystem.
type fakeStorage struct {
grown bool
synced bool
message string
err error
syncErr error
}
var _ storageOps = (*fakeStorage)(nil)
@@ -26,6 +28,11 @@ func (f *fakeStorage) GrowFilesystem(context.Context) (string, error) {
return f.message, f.err
}
func (f *fakeStorage) SyncDataMount(context.Context) error {
f.synced = true
return f.syncErr
}
// runLog records what a grow shelled out to, in order, so the test asserts on
// the tools invoked rather than on a filesystem it would have to create.
type runLog struct {
@@ -227,3 +234,25 @@ func TestCommandRegistry_GrowFilesystemReachesTheGuestStorage(t *testing.T) {
t.Errorf("reply = %+v, want succeeded carrying what the grow reported", reply)
}
}
func TestSyncDataMount_FlushesTheMountItWasGiven(t *testing.T) {
g := &guestStorage{dataMount: t.TempDir()}
if err := g.SyncDataMount(context.Background()); err != nil {
t.Fatalf("SyncDataMount: %v", err)
}
}
// The control plane snapshots on the strength of this reply, so a mount that is
// not there has to be an error rather than a flush of nothing.
func TestSyncDataMount_FailsWhenTheMountIsNotThere(t *testing.T) {
g := &guestStorage{dataMount: filepath.Join(t.TempDir(), "absent")}
err := g.SyncDataMount(context.Background())
if err == nil {
t.Fatal("SyncDataMount succeeded on a mount that does not exist")
}
if !strings.Contains(err.Error(), "absent") {
t.Errorf("error = %v, want the mount it could not open", err)
}
}
+151 -26
View File
@@ -27,18 +27,20 @@ import (
"time"
"uuid"
"github.com/mulgadc/bluebottle/pkg/masterkey"
"github.com/mulgadc/bluebottle/pkg/safecast"
"github.com/mulgadc/spinifex/spinifex/admin"
"github.com/mulgadc/spinifex/spinifex/config"
"github.com/mulgadc/spinifex/spinifex/ebsmetadata"
"github.com/mulgadc/spinifex/spinifex/ebsprovider"
"github.com/mulgadc/spinifex/spinifex/formation"
"github.com/mulgadc/spinifex/spinifex/gpu"
handlers_ec2_snapshot "github.com/mulgadc/spinifex/spinifex/handlers/ec2/snapshot"
handlers_ec2_vpc "github.com/mulgadc/spinifex/spinifex/handlers/ec2/vpc"
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
"github.com/mulgadc/spinifex/spinifex/hostdns"
"github.com/mulgadc/spinifex/spinifex/network/host"
"github.com/mulgadc/spinifex/spinifex/objectstore"
"github.com/mulgadc/spinifex/spinifex/services/viperblockd/vbwire"
"github.com/mulgadc/spinifex/spinifex/utils"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
@@ -217,6 +219,18 @@ decision made per volume.`,
Run: runVolumesOrphansCmd,
}
var volumesUnsealedCmd = &cobra.Command{
Use: "unsealed",
Short: "Report volumes whose writes have not been confirmed to the backend",
Long: `List volumes a node has open, or had open, without a confirmed seal to the
backend. The node named holds writes that predastore may not have.
Starting such a volume elsewhere is allowed and is preferred over leaving the
instance unable to run, but it opens from the last checkpoint that did reach
the backend. This is the list of volumes where that would cost something.`,
Run: runVolumesUnsealedCmd,
}
var accountCmd = &cobra.Command{
Use: "account",
Short: "Manage Spinifex accounts",
@@ -317,6 +331,7 @@ func init() {
adminCmd.AddCommand(volumesCmd)
volumesCmd.AddCommand(volumesOrphansCmd)
volumesCmd.AddCommand(volumesUnsealedCmd)
adminCmd.AddCommand(accountCmd)
accountCmd.AddCommand(accountCreateCmd)
@@ -343,6 +358,7 @@ func init() {
// Flags for admin init
adminInitCmd.Flags().Bool("force", false, "Force re-initialization (overwrites existing config)")
adminInitCmd.Flags().Bool("discard-jetstream", true, "With --nodes >= 2, remove this node's existing JetStream streams once every node has joined; =false refuses to form over a non-empty store instead. Ignored with --nodes 1")
adminInitCmd.Flags().String("region", "ap-southeast-2", "Mulga region to create")
adminInitCmd.Flags().String("az", "ap-southeast-2a", "Mulga AZ to create")
adminInitCmd.Flags().String("node", "node1", "Node name, increment for additional nodes (default, node1)")
@@ -445,7 +461,7 @@ func amiVolumeSizeGiB(sizeBytes int64) uint64 {
if sizeBytes <= 0 {
return 0
}
return utils.SafeInt64ToUint64((sizeBytes + bytesPerGiB - 1) / bytesPerGiB)
return safecast.Int64ToUint64((sizeBytes + bytesPerGiB - 1) / bytesPerGiB)
}
func runimagesImportCmd(cmd *cobra.Command, args []string) {
@@ -693,7 +709,7 @@ func runimagesImportCmd(cmd *cobra.Command, args []string) {
err = admin.ImportImage(context.Background(), provider, admin.ImportOpts{
VolumeID: volumeId,
NodeID: appConfig.Node,
SizeBytes: utils.SafeUint64ToInt64(ami.VolumeSizeGiB * bytesPerGiB),
SizeBytes: safecast.Uint64ToInt64(ami.VolumeSizeGiB * bytesPerGiB),
AvailabilityZone: node.AZ,
SourcePath: extractedImagePath,
Snapshot: true,
@@ -731,10 +747,10 @@ func runimagesImportCmd(cmd *cobra.Command, args []string) {
// DescribeSnapshots call cannot resolve it. The volume ID matches ami.ImageID:
// admin.ImportImage creates the volume under the AMI's own ID.
func registerImportedAMISnapshot(store objectstore.ObjectStore, bucket string, ami ebsmetadata.AMI, az string, encrypted bool) error {
cfg := &handlers_ec2_snapshot.SnapshotConfig{
cfg := ebsmetadata.Snapshot{
SnapshotID: ami.SnapshotID,
VolumeID: ami.ImageID,
VolumeSize: utils.SafeUint64ToInt64(ami.VolumeSizeGiB),
VolumeSize: safecast.Uint64ToInt64(ami.VolumeSizeGiB),
State: "completed",
Progress: "100%",
StartTime: time.Now(),
@@ -743,7 +759,7 @@ func registerImportedAMISnapshot(store objectstore.ObjectStore, bucket string, a
OwnerID: utils.GlobalAccountID,
AvailabilityZone: az,
}
if err := handlers_ec2_snapshot.WriteSnapshotConfig(store, bucket, ami.SnapshotID, cfg); err != nil {
if err := ebsmetadata.NewStore(store, bucket).PutSnapshot(context.Background(), cfg); err != nil {
return fmt.Errorf("register snapshot metadata: %w", err)
}
return nil
@@ -842,9 +858,9 @@ func runimagesRemoveCmd(cmd *cobra.Command, args []string) {
}
}
fmt.Printf(" Backing storage: %s/ (%d objects, %s)\n",
preview.ImageID, preview.AMIObjectCount, utils.HumanBytes(utils.SafeInt64ToUint64(preview.AMIBytesTotal)))
preview.ImageID, preview.AMIObjectCount, utils.HumanBytes(safecast.Int64ToUint64(preview.AMIBytesTotal)))
fmt.Printf(" %s/ (%d objects, %s)\n",
admin.SnapPrefix(preview.ImageID), preview.SnapObjectCount, utils.HumanBytes(utils.SafeInt64ToUint64(preview.SnapBytesTotal)))
admin.SnapPrefix(preview.ImageID), preview.SnapObjectCount, utils.HumanBytes(safecast.Int64ToUint64(preview.SnapBytesTotal)))
fmt.Println()
// Account-owned guard before salvage / dependents — the AWS-flow hint is
@@ -900,7 +916,7 @@ func runimagesRemoveCmd(cmd *cobra.Command, args []string) {
// BytesDeleted is logical: predastore reclaims the underlying disk space
// asynchronously via background compaction, not at delete time.
fmt.Printf("✅ Removed AMI %s (%d objects, %s marked for deletion; disk space is reclaimed by background compaction).\n",
imageID, res.ObjectsDeleted, utils.HumanBytes(utils.SafeInt64ToUint64(res.BytesDeleted)))
imageID, res.ObjectsDeleted, utils.HumanBytes(safecast.Int64ToUint64(res.BytesDeleted)))
}
func printDependents(w io.Writer, d admin.Dependents) {
@@ -1298,6 +1314,15 @@ func runAdminInit(cmd *cobra.Command, args []string) {
os.Exit(0)
}
if spxRoot == "" {
spxRoot = DefaultDataDir()
}
discardJetStream, _ := cmd.Flags().GetBool("discard-jetstream")
if err := checkInitJetStreamStore(jetStreamStoreDir(spxRoot), nodes, discardJetStream); err != nil {
fmt.Fprintf(os.Stderr, "❌ Error: %v\n", err)
os.Exit(1)
}
// Preserve the previously-captured operator email across --force re-inits
// when --email is omitted (e.g. reset-dev-env.sh workflows). Without this
// a reset would silently blank the address.
@@ -1599,6 +1624,7 @@ func runAdminInit(cmd *cobra.Command, args []string) {
ClusterName: clusterName,
PredastoreHostID: predastoreHostID,
PredastoreAdminPort: admin.PredastoreAdminPort,
CompactionIntervalSeconds: compactionInterval,
Services: services,
@@ -1829,6 +1855,17 @@ func runAdminInitMultiNode(cmd *cobra.Command, accessKey, secretKey, accountID,
fmt.Printf("✅ All %d nodes joined!\n", expectedNodes)
// The pre-start check refused a store with streams unless discard was asked
// for, so anything left here is removed now that the cluster is committed.
if discard, _ := cmd.Flags().GetBool("discard-jetstream"); discard {
storeDir := jetStreamStoreDir(spxRoot)
if err := discardJetStreamStore(storeDir); err != nil {
fmt.Fprintf(os.Stderr, "❌ Error: %v\n", err)
os.Exit(1)
}
fmt.Printf("✅ Discarded this node's pre-formation JetStream store: %s\n", storeDir)
}
// Build cluster topology from formation data
allNodes := fs.Nodes()
clusterRoutes := formation.BuildClusterRoutes(allNodes)
@@ -1900,6 +1937,7 @@ func runAdminInitMultiNode(cmd *cobra.Command, accessKey, secretKey, accountID,
ClusterName: clusterName,
PredastoreHostID: predastoreHostID,
PredastoreAdminPort: admin.PredastoreAdminPort,
CompactionIntervalSeconds: compactionInterval,
Services: services,
RemoteNodes: buildRemoteNodes(allNodes, node, northstarConfigPath),
@@ -1985,19 +2023,27 @@ func joinRetryable(err error, statusCode int) bool {
}
// checkJoinPreconditions rejects a join that would silently destroy this node's
// existing cluster identity. Joining adopts the primary's CA and master key,
// overwriting whatever is here: correct on a freshly installed node, and on one
// that has been in service it orphans every fragment and volume sealed under the
// old key. runAdminInit guards the same way before re-initializing.
func checkJoinPreconditions(configDir string, force bool) error {
// identity or JetStream data; --force accepts that loss. A running NATS service
// is refused even with --force, because the store cannot be removed under it.
func checkJoinPreconditions(configDir, storeDir string, force bool) error {
if err := checkStoreDiscardable(); err != nil {
return err
}
if force {
return nil
}
tomlPath := filepath.Join(configDir, "spinifex.toml")
if !admin.FileExists(tomlPath) {
return nil
if admin.FileExists(tomlPath) {
return fmt.Errorf("this node is already initialized: %s", tomlPath)
}
return fmt.Errorf("this node is already initialized: %s", tomlPath)
streams, err := localStreams(storeDir)
if err != nil {
return err
}
if len(streams) > 0 {
return fmt.Errorf("this node's JetStream store %s holds %d stream(s)", storeDir, len(streams))
}
return nil
}
// joinDiscardsIdentityMsg spells out what a forced join throws away. Kept out of
@@ -2006,6 +2052,8 @@ const joinDiscardsIdentityMsg = `Joining will discard this node's own cluster id
- CA certificate and key
- master key, and any data sealed under it
- viperblock key, and any volumes encrypted under it
- JetStream store (<data-dir>/nats/jetstream): every stream and KV bucket
this node holds, so it takes the cluster's copy instead of diverging from it
That is safe on a freshly installed node — an ISO install initializes a
single-node cluster at first boot, and nothing has been sealed under these keys
@@ -2086,7 +2134,11 @@ func runAdminJoin(cmd *cobra.Command, args []string) {
// Checked before any network call so a node that will not join says so
// immediately. Unlike init this exits non-zero: a node that did not join
// must not look like success to a provisioning script.
if err := checkJoinPreconditions(configDir, force); err != nil {
if dataDir == "" {
dataDir = DefaultDataDir()
}
storeDir := jetStreamStoreDir(dataDir)
if err := checkJoinPreconditions(configDir, storeDir, force); err != nil {
fmt.Fprintf(os.Stderr, "⚠️ %v\n\n%s\n", err, joinDiscardsIdentityMsg)
os.Exit(1)
}
@@ -2096,11 +2148,6 @@ func runAdminJoin(cmd *cobra.Command, args []string) {
clusterBind = bindIP
}
// Set default data directory
if dataDir == "" {
dataDir = DefaultDataDir()
}
fmt.Println("🚀 Joining Spinifex cluster...")
fmt.Printf("Node: %s\n", node)
fmt.Printf("Leader: %s\n", leaderHost)
@@ -2281,6 +2328,14 @@ func runAdminJoin(cmd *cobra.Command, args []string) {
os.Exit(1)
}
// Removed only once formation has succeeded, so a failed join leaves the
// node's single-node cluster intact.
if err := discardJetStreamStore(storeDir); err != nil {
fmt.Fprintf(os.Stderr, "❌ Error: %v\n", err)
os.Exit(1)
}
fmt.Printf("✅ Discarded this node's pre-formation JetStream store: %s\n", storeDir)
// Set up config directory
if configDir == "" {
configDir = DefaultConfigDir()
@@ -2459,6 +2514,7 @@ func runAdminJoin(cmd *cobra.Command, args []string) {
ClusterName: creds.ClusterName,
PredastoreHostID: predastoreHostID,
PredastoreAdminPort: admin.PredastoreAdminPort,
CompactionIntervalSeconds: compactionInterval,
Services: services,
RemoteNodes: buildRemoteNodes(statusResp.Nodes, node, northstarConfigPath),
@@ -2578,7 +2634,7 @@ func initIAMServiceFromConfig() (*handlers_iam.IAMServiceImpl, *config.ClusterCo
}
masterKeyPath := filepath.Join(cfg.NodeBaseDir(), "config", "master.key")
masterKey, err := handlers_iam.LoadMasterKey(masterKeyPath)
masterKey, err := masterkey.ReadShared(masterKeyPath)
if err != nil {
nc.Close()
return nil, nil, nil, nil, fmt.Errorf("load master key: %w", err)
@@ -2840,9 +2896,44 @@ func northstarFromFormation(creds *formation.SharedCredentials, dirs configDirs)
}, filepath.Join(dirs.Northstar, "northstar.toml")
}
// resolverAllowFrom returns the addresses permitted to recurse against this
// node's northstar: every cluster node, on whichever address its peers dial.
//
// Guests are covered without appearing here. vpcd's per-tap DNS shim relays
// guest queries to a node's :5300 from a node address, so trusting the nodes
// trusts every guest behind them, and no guest CIDR is needed.
func resolverAllowFrom(settings admin.ConfigSettings) []string {
seen := make(map[string]bool)
var addrs []string
add := func(ip string) {
ip = strings.TrimSpace(ip)
if ip == "" || ip == "0.0.0.0" || seen[ip] {
return
}
seen[ip] = true
addrs = append(addrs, ip)
}
// Both local addresses: a node resolving through its own advertised address
// is seen arriving from that address over lo, not from 127.0.0.1.
add(settings.AdvertiseIP)
add(settings.BindIP)
add(settings.ClusterBindIP)
for _, peer := range settings.RemoteNodes {
host := peer.Host
if h, _, err := net.SplitHostPort(host); err == nil {
host = h
}
add(host)
}
return addrs
}
// generateAndWriteConfigs renders the standard config files (spinifex.toml,
// awsgw.toml, nats.conf, and optionally predastore.toml) from templates.
func generateAndWriteConfigs(dirs configDirs, spinifexTomlPath string, settings admin.ConfigSettings, skipPredastore bool) error {
settings.ResolverAllowFrom = resolverAllowFrom(settings)
// A one-sided pair must disable Northstar wholesale. Rendering only the
// public stanza or only the secret file advertises a resolver that cannot run.
northstarEnabled := settings.NorthstarAccessKey != "" && settings.NorthstarSecretKey != ""
@@ -2984,7 +3075,7 @@ type writeBootstrapResult struct {
func ensureMasterKey(configDir string) (key []byte, existed bool, err error) {
keyPath := filepath.Join(configDir, "master.key")
if admin.FileExists(keyPath) {
key, err = handlers_iam.LoadMasterKey(keyPath)
key, err = masterkey.ReadShared(keyPath)
if err != nil {
return nil, false, fmt.Errorf("load master key: %w", err)
}
@@ -3077,7 +3168,7 @@ func ensureViperblockEncryptionKey(configDir string) ([]byte, string, error) {
}
keyPath := filepath.Join(viperblockDir, "encryption.key")
if admin.FileExists(keyPath) {
key, err := handlers_iam.LoadMasterKey(keyPath)
key, err := masterkey.ReadShared(keyPath)
if err != nil {
return nil, "", fmt.Errorf("load viperblock encryption key: %w", err)
}
@@ -3658,3 +3749,37 @@ func printChecksumError(w io.Writer, imageFile, imageName string, image utils.Im
fmt.Fprintln(w, "The cached file was left in place. To re-download and retry:")
fmt.Fprintf(w, " spx admin images import --name %s --force\n", imageName)
}
func runVolumesUnsealedCmd(_ *cobra.Command, _ []string) {
_, nc, err := loadConfigAndConnect()
if err != nil {
fmt.Fprintf(os.Stderr, "Could not connect to the cluster: %v\n", err)
os.Exit(1)
}
defer nc.Close()
unsealed, err := vbwire.ListUnsealedVolumes(context.Background(), nc)
if err != nil {
fmt.Fprintln(os.Stderr, "Could not read the unsealed volumes:", err)
os.Exit(1)
}
if len(unsealed) == 0 {
fmt.Println("No unsealed volumes: every volume's writes are confirmed to the backend.")
return
}
sort.Slice(unsealed, func(i, j int) bool { return unsealed[i].VolumeID < unsealed[j].VolumeID })
fmt.Printf("%d volume(s) with writes the backend may not have:\n\n", len(unsealed))
for _, volume := range unsealed {
fmt.Printf(" %s\n", volume.VolumeID)
fmt.Printf(" held by: %s\n", volume.Owner)
if !volume.Since.IsZero() {
fmt.Printf(" since: %s\n", volume.Since.Format(time.RFC3339))
}
fmt.Printf(" reason: %s\n", volume.Reason)
}
fmt.Println()
fmt.Println("A volume open right now is listed here and that is normal — the entry")
fmt.Println("clears on its next successful seal. One naming a node that is gone is")
fmt.Println("the case worth acting on: starting it elsewhere loses writes made since.")
}
+451
View File
@@ -0,0 +1,451 @@
package cmd
import (
"bytes"
"context"
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"slices"
"sort"
"strconv"
"strings"
"time"
"github.com/minio/highwayhash"
"github.com/nats-io/nats-server/v2/server"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
"github.com/spf13/cobra"
)
var kvCmd = &cobra.Command{
Use: "kv",
Short: "Inspect JetStream KV state",
}
var kvDigestCmd = &cobra.Command{
Use: "digest",
Short: "Print a digest of every stream held by this node's JetStream replica",
Long: `Read this node's own copy of every JetStream stream and print one line per
stream: name, first and last sequence, message count, and a SHA-256 over every
stored message (sequence, subject, timestamp, headers, data).
Run it on every node and compare the output. Replicas of one stream must
produce identical lines; any difference means the replicas have diverged, and
--seqs prints a hash per sequence to find where. For a check that tells a
diverged replica from one that is merely behind, run it with --json --seqs on
every node and pass the results to "spx admin kv compare".
It never touches the live store. A direct get over NATS may be answered by any
replica, so instead the streams tree is copied to --work-dir and read by a
private in-process server with no listener, which is removed afterwards. The
copy's stream config is set to one replica with no expiry so it can be loaded.
Writes landing during the copy, or applied but not yet flushed to disk, can
make a replica's tail differ, so gate API writes and re-run before calling a
tail difference divergence.`,
Run: runKVDigest,
}
func init() {
adminCmd.AddCommand(kvCmd)
kvCmd.AddCommand(kvDigestCmd)
kvDigestCmd.Flags().String("store-dir", "", "JetStream store to read (default <spinifex-dir>/nats/jetstream)")
kvDigestCmd.Flags().String("work-dir", os.TempDir(), "Directory to hold the temporary copy")
kvDigestCmd.Flags().StringSlice("stream", nil, "Only these streams (repeatable)")
kvDigestCmd.Flags().Bool("seqs", false, "Also print a hash per sequence")
kvDigestCmd.Flags().Bool("json", false, "Print one JSON document, the input to \"spx admin kv compare\"")
}
// nodeDigest is one node's digest report, the unit "kv compare" reads.
type nodeDigest struct {
Host string `json:"host"`
Store string `json:"store"`
CopiedAt time.Time `json:"copied_at"`
Skipped []string `json:"skipped,omitempty"`
Streams []streamDigest `json:"streams"`
}
// streamDigest is one stream's content as held by one replica.
type streamDigest struct {
streamMeta
Name string `json:"name"`
FirstSeq uint64 `json:"first_seq"`
LastSeq uint64 `json:"last_seq"`
Msgs uint64 `json:"msgs"`
Digest string `json:"sha256"`
Seqs []seqDigest `json:"seqs,omitempty"`
}
// streamMeta is the part of the live stream config that decides which replica
// differences are legitimate. It is read before the copy is rewritten.
type streamMeta struct {
Replicas int `json:"replicas,omitempty"`
Retention string `json:"retention,omitempty"`
MaxAge time.Duration `json:"max_age_ns,omitempty"`
AllowMsgTTL bool `json:"allow_msg_ttl,omitempty"`
MaxMsgs int64 `json:"max_msgs,omitempty"`
MaxBytes int64 `json:"max_bytes,omitempty"`
}
// seqDigest is the hash of one stored sequence, or "deleted" when the replica
// has no message at that sequence. Subject, time and TTL are empty then.
type seqDigest struct {
Seq uint64 `json:"seq"`
Hash string `json:"hash"`
Subject string `json:"subject,omitempty"`
Time time.Time `json:"time,omitzero"`
TTL time.Duration `json:"ttl_ns,omitempty"`
}
// deletedHash marks a sequence the replica holds no message for.
const deletedHash = "deleted"
func runKVDigest(cmd *cobra.Command, _ []string) {
storeDir, _ := cmd.Flags().GetString("store-dir")
if storeDir == "" {
spxRoot, _ := cmd.Flags().GetString("spinifex-dir")
if spxRoot == "" {
spxRoot = DefaultDataDir()
}
storeDir = jetStreamStoreDir(spxRoot)
}
workParent, _ := cmd.Flags().GetString("work-dir")
only, _ := cmd.Flags().GetStringSlice("stream")
withSeqs, _ := cmd.Flags().GetBool("seqs")
asJSON, _ := cmd.Flags().GetBool("json")
work, err := os.MkdirTemp(workParent, "spx-kv-digest-")
if err != nil {
fmt.Fprintf(os.Stderr, "kv digest: %v\n", err)
os.Exit(1)
}
defer os.RemoveAll(work)
copiedAt := time.Now().UTC()
skipped, metas, err := copyStreamTree(storeDir, filepath.Join(work, "jetstream"))
if err != nil {
fmt.Fprintf(os.Stderr, "kv digest: %v\n", err)
os.RemoveAll(work)
os.Exit(1)
}
ctx, cancel := context.WithTimeout(cmd.Context(), 10*time.Minute)
defer cancel()
digests, err := digestStore(ctx, work, only, withSeqs, metas)
if err != nil {
fmt.Fprintf(os.Stderr, "kv digest: %v\n", err)
cancel()
os.RemoveAll(work)
os.Exit(1)
}
host, _ := os.Hostname()
if asJSON {
enc := json.NewEncoder(cmd.OutOrStdout())
if err := enc.Encode(nodeDigest{Host: host, Store: storeDir, CopiedAt: copiedAt, Skipped: skipped, Streams: digests}); err != nil {
fmt.Fprintf(os.Stderr, "kv digest: %v\n", err)
os.Exit(1)
}
return
}
writeDigests(cmd.OutOrStdout(), host, storeDir, copiedAt, skipped, digests)
}
// writeDigests prints the report. Only the "#" lines carry node-specific
// values, so `grep -v '^#'` output from two replicas diffs cleanly.
func writeDigests(w io.Writer, host, storeDir string, copiedAt time.Time, skipped []string, digests []streamDigest) {
fmt.Fprintf(w, "# host=%s store=%s copied_at=%s\n", host, storeDir, copiedAt.Format(time.RFC3339))
for _, s := range skipped {
fmt.Fprintf(w, "# skipped account stream (not read): %s\n", s)
}
for _, d := range digests {
fmt.Fprintf(w, "%s\tfirst=%d\tlast=%d\tmsgs=%d\tsha256=%s\n", d.Name, d.FirstSeq, d.LastSeq, d.Msgs, d.Digest)
for _, s := range d.Seqs {
fmt.Fprintf(w, "%s\tseq=%d\t%s\n", d.Name, s.Seq, s.Hash)
}
}
}
// globalAccountDir is the account Spinifex's token-authenticated clients use.
// Streams under any other account are listed but not read.
const globalAccountDir = "$G"
// copyStreamTree copies <storeDir>/$G/streams to <dst>/$G/streams, leaving out
// consumer state (obs). It returns streams found under other accounts and each
// copied stream's config as the live server held it, keyed by stream name.
func copyStreamTree(storeDir, dst string) (skipped []string, metas map[string]streamMeta, err error) {
streams, err := localStreams(storeDir)
if err != nil {
return nil, nil, err
}
if len(streams) == 0 {
return nil, nil, fmt.Errorf("no streams found under %s", storeDir)
}
for _, s := range streams {
if !strings.HasPrefix(s, globalAccountDir+"/") {
skipped = append(skipped, s)
}
}
src := filepath.Join(storeDir, globalAccountDir, "streams")
out := filepath.Join(dst, globalAccountDir, "streams")
err = filepath.WalkDir(src, func(path string, d fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
rel, err := filepath.Rel(src, path)
if err != nil {
return err
}
// <stream>/obs holds consumers, which are not stream content.
if parts := strings.Split(rel, string(filepath.Separator)); d.IsDir() && len(parts) == 2 && parts[1] == "obs" {
return filepath.SkipDir
}
target := filepath.Join(out, rel)
if d.IsDir() {
return os.MkdirAll(target, 0o700)
}
if !d.Type().IsRegular() {
return nil
}
return copyFile(path, target)
})
if err != nil {
return nil, nil, fmt.Errorf("copy streams from %s: %w", src, err)
}
entries, err := os.ReadDir(out)
if err != nil {
return nil, nil, err
}
metas = make(map[string]streamMeta, len(entries))
for _, e := range entries {
if e.IsDir() {
meta, err := rewriteStreamMetaStandalone(filepath.Join(out, e.Name()))
if err != nil {
return nil, nil, err
}
metas[e.Name()] = meta
}
}
return skipped, metas, nil
}
// rewriteStreamMetaStandalone makes a copied stream loadable by a standalone
// server, which refuses replicas > 1 and would expire aged messages on
// recovery. meta.sum is highwayhash64 keyed by sha256 of the directory name.
// It returns the settings as they were before the rewrite.
func rewriteStreamMetaStandalone(streamDir string) (streamMeta, error) {
metaPath := filepath.Join(streamDir, "meta.inf")
raw, err := os.ReadFile(metaPath)
if errors.Is(err, os.ErrNotExist) {
return streamMeta{}, nil
}
if err != nil {
return streamMeta{}, err
}
var orig struct {
Replicas int `json:"num_replicas"`
Retention string `json:"retention"`
MaxAge time.Duration `json:"max_age"`
AllowMsgTTL bool `json:"allow_msg_ttl"`
MaxMsgs int64 `json:"max_msgs"`
MaxBytes int64 `json:"max_bytes"`
}
if err := json.Unmarshal(raw, &orig); err != nil {
return streamMeta{}, fmt.Errorf("parse %s: %w", metaPath, err)
}
kept := streamMeta{
Replicas: orig.Replicas, Retention: orig.Retention, MaxAge: orig.MaxAge,
AllowMsgTTL: orig.AllowMsgTTL, MaxMsgs: orig.MaxMsgs, MaxBytes: orig.MaxBytes,
}
dec := json.NewDecoder(bytes.NewReader(raw))
dec.UseNumber()
var meta map[string]any
if err := dec.Decode(&meta); err != nil {
return streamMeta{}, fmt.Errorf("parse %s: %w", metaPath, err)
}
meta["num_replicas"] = 1
meta["max_age"] = 0
delete(meta, "allow_msg_ttl")
delete(meta, "subject_delete_marker_ttl")
delete(meta, "placement")
out, err := json.Marshal(meta)
if err != nil {
return streamMeta{}, err
}
key := sha256.Sum256([]byte(filepath.Base(streamDir)))
hh, err := highwayhash.New64(key[:])
if err != nil {
return streamMeta{}, err
}
hh.Write(out)
if err := os.WriteFile(metaPath, out, 0o600); err != nil {
return streamMeta{}, err
}
return kept, os.WriteFile(filepath.Join(streamDir, "meta.sum"), []byte(hex.EncodeToString(hh.Sum(nil))), 0o600)
}
func copyFile(src, dst string) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
out, err := os.OpenFile(dst, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600)
if err != nil {
return err
}
if _, err := io.Copy(out, in); err != nil {
out.Close()
return err
}
return out.Close()
}
// digestStore opens storeParent (holding jetstream/) in a standalone server
// that accepts in-process connections only, and digests each stream.
func digestStore(ctx context.Context, storeParent string, only []string, withSeqs bool, metas map[string]streamMeta) ([]streamDigest, error) {
ns, err := server.NewServer(&server.Options{
ServerName: "spx-kv-digest",
JetStream: true,
StoreDir: storeParent,
DontListen: true,
NoLog: true,
NoSigs: true,
})
if err != nil {
return nil, fmt.Errorf("create reader server: %w", err)
}
ns.Start()
defer func() {
ns.Shutdown()
ns.WaitForShutdown()
}()
if !ns.ReadyForConnections(30 * time.Second) {
return nil, errors.New("reader server did not become ready")
}
nc, err := nats.Connect("", nats.InProcessServer(ns))
if err != nil {
return nil, fmt.Errorf("connect to reader server: %w", err)
}
defer nc.Close()
js, err := jetstream.New(nc)
if err != nil {
return nil, err
}
var names []string
lister := js.StreamNames(ctx)
for name := range lister.Name() {
if len(only) == 0 || slices.Contains(only, name) {
names = append(names, name)
}
}
if err := lister.Err(); err != nil {
return nil, fmt.Errorf("list streams: %w", err)
}
sort.Strings(names)
digests := make([]streamDigest, 0, len(names))
for _, name := range names {
d, err := digestStream(ctx, js, name, withSeqs)
if err != nil {
return nil, err
}
d.streamMeta = metas[name]
digests = append(digests, d)
}
return digests, nil
}
func digestStream(ctx context.Context, js jetstream.JetStream, name string, withSeqs bool) (streamDigest, error) {
s, err := js.Stream(ctx, name)
if err != nil {
return streamDigest{}, fmt.Errorf("open stream %s: %w", name, err)
}
info, err := s.Info(ctx)
if err != nil {
return streamDigest{}, fmt.Errorf("stream info %s: %w", name, err)
}
st := info.State
d := streamDigest{Name: name, FirstSeq: st.FirstSeq, LastSeq: st.LastSeq, Msgs: st.Msgs}
whole := sha256.New()
for seq := st.FirstSeq; st.Msgs > 0 && seq <= st.LastSeq; seq++ {
msg, err := s.GetMsg(ctx, seq)
sd := seqDigest{Seq: seq}
switch {
case errors.Is(err, jetstream.ErrMsgNotFound):
sd.Hash = deletedHash
case err != nil:
return streamDigest{}, fmt.Errorf("read %s seq %d: %w", name, seq, err)
default:
sd.Hash = hashStoredMsg(msg)
sd.Subject = msg.Subject
sd.Time = msg.Time.UTC()
sd.TTL = msgTTL(msg.Header.Get(jetstream.MsgTTLHeader))
}
fmt.Fprintf(whole, "%d:%s\n", seq, sd.Hash)
if withSeqs {
d.Seqs = append(d.Seqs, sd)
}
}
d.Digest = hex.EncodeToString(whole.Sum(nil))
return d, nil
}
// hashStoredMsg hashes everything a replica stores for one sequence. Header
// keys are sorted so the hash does not depend on map iteration order.
func hashStoredMsg(m *jetstream.RawStreamMsg) string {
h := sha256.New()
var n [8]byte
field := func(b []byte) {
binary.BigEndian.PutUint64(n[:], uint64(len(b)))
h.Write(n[:])
h.Write(b)
}
binary.BigEndian.PutUint64(n[:], m.Sequence)
h.Write(n[:])
field([]byte(m.Subject))
binary.BigEndian.PutUint64(n[:], uint64(m.Time.UnixNano()))
h.Write(n[:])
keys := make([]string, 0, len(m.Header))
for k := range m.Header {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
field([]byte(k))
for _, v := range m.Header[k] {
field([]byte(v))
}
}
field(m.Data)
return hex.EncodeToString(h.Sum(nil))[:16]
}
// msgTTL parses a Nats-TTL header: a Go duration or whole seconds. "never", an
// absent header or an unparseable one yield zero, meaning no expiry.
func msgTTL(v string) time.Duration {
if v == "" {
return 0
}
if d, err := time.ParseDuration(v); err == nil && d > 0 {
return d
}
if n, err := strconv.ParseInt(v, 10, 64); err == nil && n > 0 {
return time.Duration(n) * time.Second
}
return 0
}
+360
View File
@@ -0,0 +1,360 @@
package cmd
import (
"encoding/json"
"errors"
"fmt"
"io"
"os"
"slices"
"sort"
"strings"
"time"
"github.com/spf13/cobra"
)
var kvCompareCmd = &cobra.Command{
Use: "compare <digest.json>...",
Short: "Compare the kv digests of every node and report replicas that have diverged",
Long: `Read the "spx admin kv digest --json --seqs" output of every node in the
cluster, one file per node ("-" reads any number of them from stdin), and
check that every stream's replicas hold the same content.
A replica that is merely behind is not divergent, so only these fail:
- the same sequence holds different content on two replicas;
- a message held by one replica is missing from another, and nothing on
that replica explains it: no later write to the same subject, no expiry
under max_age or a message TTL, and no stream limit dropping its head;
- a stream is held by fewer nodes than its replica count, or by more;
- replicas disagree on the replica count;
- no sequence range is common to every replica, so nothing can be checked.
Sequences past the slowest replica's last one are not compared. Digests are
taken from live stores a few seconds apart, so re-run before trusting a
failure that could be a write landing between two copies.
Exits 0 when every stream is consistent, 1 on divergence, 2 on bad input.`,
Args: cobra.MinimumNArgs(1),
Run: runKVCompare,
}
func init() {
kvCmd.AddCommand(kvCompareCmd)
}
func runKVCompare(cmd *cobra.Command, args []string) {
var nodes []nodeDigest
for _, arg := range args {
var r io.Reader
if arg == "-" {
r = cmd.InOrStdin()
} else {
f, err := os.Open(arg)
if err != nil {
fmt.Fprintf(os.Stderr, "kv compare: %v\n", err)
os.Exit(2)
}
defer f.Close()
r = f
}
got, err := loadNodeDigests(r, arg)
if err != nil {
fmt.Fprintf(os.Stderr, "kv compare: %v\n", err)
os.Exit(2)
}
nodes = append(nodes, got...)
}
res, err := compareNodeDigests(nodes)
if err != nil {
fmt.Fprintf(os.Stderr, "kv compare: %v\n", err)
os.Exit(2)
}
writeCompareResult(cmd.OutOrStdout(), nodes, res)
if res.divergent() {
os.Exit(1)
}
}
// loadNodeDigests reads every JSON digest document in r.
func loadNodeDigests(r io.Reader, source string) ([]nodeDigest, error) {
dec := json.NewDecoder(r)
var out []nodeDigest
for {
var d nodeDigest
err := dec.Decode(&d)
if errors.Is(err, io.EOF) {
break
}
if err != nil {
return nil, fmt.Errorf("%s: document %d: %w", source, len(out)+1, err)
}
if d.Host == "" {
return nil, fmt.Errorf("%s: document %d has no host; is it \"kv digest --json\" output?", source, len(out)+1)
}
out = append(out, d)
}
if len(out) == 0 {
return nil, fmt.Errorf("%s: no digest documents", source)
}
return out, nil
}
// maxFindingsPerStream caps what one stream prints; the rest are counted.
const maxFindingsPerStream = 5
type streamVerdict struct {
Name string
Holders []string
Problems []string
Notes []string
}
type kvCompareResult struct {
Streams []streamVerdict
}
func (r kvCompareResult) divergent() bool {
return slices.ContainsFunc(r.Streams, func(v streamVerdict) bool { return len(v.Problems) > 0 })
}
// replicaView is one node's copy of one stream, indexed for the per-sequence rules.
type replicaView struct {
host string
copiedAt time.Time
d *streamDigest
bySeq map[uint64]seqDigest
lastBySubject map[string]uint64
}
func compareNodeDigests(nodes []nodeDigest) (kvCompareResult, error) {
if len(nodes) < 2 {
return kvCompareResult{}, fmt.Errorf("need the digests of at least two nodes, got %d", len(nodes))
}
hosts := make([]string, 0, len(nodes))
byName := map[string][]*replicaView{}
for i := range nodes {
n := &nodes[i]
if slices.Contains(hosts, n.Host) {
return kvCompareResult{}, fmt.Errorf("two digests are from host %s", n.Host)
}
hosts = append(hosts, n.Host)
for j := range n.Streams {
d := &n.Streams[j]
v := &replicaView{host: n.Host, copiedAt: n.CopiedAt, d: d,
bySeq: make(map[uint64]seqDigest, len(d.Seqs)), lastBySubject: map[string]uint64{}}
for _, s := range d.Seqs {
v.bySeq[s.Seq] = s
if s.Hash != deletedHash && s.Seq > v.lastBySubject[s.Subject] {
v.lastBySubject[s.Subject] = s.Seq
}
}
byName[d.Name] = append(byName[d.Name], v)
}
}
names := make([]string, 0, len(byName))
for name := range byName {
names = append(names, name)
}
sort.Strings(names)
var res kvCompareResult
for _, name := range names {
res.Streams = append(res.Streams, compareStream(name, hosts, byName[name]))
}
return res, nil
}
func compareStream(name string, hosts []string, views []*replicaView) streamVerdict {
v := streamVerdict{Name: name}
for _, r := range views {
v.Holders = append(v.Holders, r.host)
}
counts := map[int][]string{}
replicas := 0
for _, r := range views {
if n := r.d.Replicas; n > 0 {
counts[n] = append(counts[n], r.host)
replicas = max(replicas, n)
}
}
if len(counts) > 1 {
var parts []string
for n, hs := range counts {
parts = append(parts, fmt.Sprintf("%d on %s", n, strings.Join(hs, ",")))
}
sort.Strings(parts)
v.Problems = append(v.Problems, "replicas disagree on the replica count: "+strings.Join(parts, "; "))
}
switch {
case replicas > 0 && len(views) < replicas:
missing := slices.DeleteFunc(slices.Clone(hosts), func(h string) bool { return slices.Contains(v.Holders, h) })
msg := fmt.Sprintf("held by %d of %d replicas; missing from %s", len(views), replicas, strings.Join(missing, ","))
if len(hosts) < replicas {
msg += fmt.Sprintf(" (only %d digests given)", len(hosts))
}
v.Problems = append(v.Problems, msg)
case replicas > 0 && len(views) > replicas:
v.Problems = append(v.Problems, fmt.Sprintf("held by %d nodes but configured for %d replicas; a node outside the stream's group still holds a copy", len(views), replicas))
}
if len(views) < 2 {
v.Notes = append(v.Notes, "single copy, nothing to compare")
return v
}
identical := true
for _, r := range views[1:] {
if r.d.Digest != views[0].d.Digest || r.d.FirstSeq != views[0].d.FirstSeq || r.d.LastSeq != views[0].d.LastSeq {
identical = false
break
}
}
if identical {
v.Notes = append(v.Notes, fmt.Sprintf("identical, seqs %d-%d", views[0].d.FirstSeq, views[0].d.LastSeq))
return v
}
for _, r := range views {
if r.d.Msgs > 0 && len(r.d.Seqs) == 0 {
v.Problems = append(v.Problems, "contents differ and "+r.host+"'s digest has no per-sequence detail; re-run digest with --seqs")
return v
}
}
lo, hi := views[0].d.FirstSeq, views[0].d.LastSeq
seqSet := map[uint64]struct{}{}
for _, r := range views {
lo, hi = max(lo, r.d.FirstSeq), min(hi, r.d.LastSeq)
for s := range r.bySeq {
seqSet[s] = struct{}{}
}
}
if lo > hi {
v.Problems = append(v.Problems, "no sequence range is common to every replica, so nothing could be compared")
return v
}
seqs := make([]uint64, 0, len(seqSet))
for s := range seqSet {
if s <= hi {
seqs = append(seqs, s)
}
}
slices.Sort(seqs)
var problems []string
explained := 0
for _, seq := range seqs {
var present, absent []*replicaView
for _, r := range views {
if e, ok := r.bySeq[seq]; ok && e.Hash != deletedHash {
present = append(present, r)
} else {
absent = append(absent, r)
}
}
if len(present) == 0 {
continue
}
byHash := map[string][]string{}
for _, r := range present {
h := r.bySeq[seq].Hash
byHash[h] = append(byHash[h], r.host)
}
if len(byHash) > 1 {
var parts []string
for h, hs := range byHash {
parts = append(parts, strings.Join(hs, ",")+"="+h)
}
sort.Strings(parts)
problems = append(problems, fmt.Sprintf("seq %d %s holds different content: %s", seq, present[0].bySeq[seq].Subject, strings.Join(parts, " ")))
continue
}
msg := present[0].bySeq[seq]
for _, r := range absent {
if absenceExplained(r, msg) {
explained++
continue
}
problems = append(problems, fmt.Sprintf("seq %d %s is on %s but missing from %s, with no later write to that subject, expiry or limit to explain it",
seq, msg.Subject, hostsOf(present), r.host))
}
}
if len(problems) > maxFindingsPerStream {
problems = append(problems[:maxFindingsPerStream], fmt.Sprintf("... and %d more", len(problems)-maxFindingsPerStream))
}
v.Problems = append(v.Problems, problems...)
note := fmt.Sprintf("seqs %d-%d compared", lo, hi)
if explained > 0 {
note += fmt.Sprintf(", %d superseded or expired messages missing from some replicas", explained)
}
var ahead []string
for _, r := range views {
if r.d.LastSeq > hi {
ahead = append(ahead, fmt.Sprintf("%s +%d", r.host, r.d.LastSeq-hi))
}
}
if len(ahead) > 0 {
note += ", tail not compared: " + strings.Join(ahead, " ")
}
v.Notes = append(v.Notes, note)
return v
}
// absenceExplained reports whether a replica lacking msg removed it legitimately:
// the subject was written again later, the message aged out before that replica
// was copied, or a stream limit dropped it from a head the replica has moved past.
func absenceExplained(r *replicaView, msg seqDigest) bool {
if msg.Subject == "" {
return false
}
if r.lastBySubject[msg.Subject] > msg.Seq {
return true
}
meta := r.d.streamMeta
if meta.Retention != "" && meta.Retention != "limits" {
return true
}
if !msg.Time.IsZero() {
if meta.MaxAge > 0 && msg.Time.Add(meta.MaxAge).Before(r.copiedAt) {
return true
}
if msg.TTL > 0 && msg.Time.Add(msg.TTL).Before(r.copiedAt) {
return true
}
}
return msg.Seq < r.d.FirstSeq && (meta.MaxMsgs > 0 || meta.MaxBytes > 0)
}
func hostsOf(views []*replicaView) string {
hs := make([]string, len(views))
for i, r := range views {
hs[i] = r.host
}
return strings.Join(hs, ",")
}
func writeCompareResult(w io.Writer, nodes []nodeDigest, res kvCompareResult) {
fmt.Fprintf(w, "compared %d digests:", len(nodes))
for _, n := range nodes {
fmt.Fprintf(w, " %s@%s", n.Host, n.CopiedAt.Format(time.TimeOnly))
}
fmt.Fprintln(w)
bad := 0
for _, v := range res.Streams {
status := "ok"
if len(v.Problems) > 0 {
status = "DIVERGED"
bad++
}
fmt.Fprintf(w, "%-9s %s [%s]\n", status, v.Name, strings.Join(v.Holders, ","))
for _, p := range v.Problems {
fmt.Fprintf(w, " ! %s\n", p)
}
for _, n := range v.Notes {
fmt.Fprintf(w, " %s\n", n)
}
}
fmt.Fprintf(w, "%d streams: %d consistent, %d diverged\n", len(res.Streams), len(res.Streams)-bad, bad)
}
+233
View File
@@ -0,0 +1,233 @@
package cmd_test
import (
"encoding/json"
"strings"
"testing"
"time"
"github.com/mulgadc/spinifex/cmd/spinifex/cmd"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
var t0 = time.Date(2026, 9, 15, 10, 0, 0, 0, time.UTC)
type msg struct {
seq uint64
subject string
hash string // "deleted" for a gap
at time.Time
}
type stream struct {
name string
replicas int
maxAge time.Duration
maxMsgs int64
msgs []msg
noSeqs bool
}
// doc renders one node's digest the way "kv digest --json --seqs" prints it.
func doc(t *testing.T, host string, copiedAt time.Time, streams ...stream) string {
t.Helper()
var out []map[string]any
for _, s := range streams {
var seqs []map[string]any
var first, last, count uint64
digest := host + ":" // only equality matters to compare
for _, m := range s.msgs {
if first == 0 {
first = m.seq
}
last = m.seq
e := map[string]any{"seq": m.seq, "hash": m.hash}
if m.hash != "deleted" {
count++
e["subject"] = m.subject
at := m.at
if at.IsZero() {
at = t0
}
e["time"] = at
}
digest += m.hash + ","
seqs = append(seqs, e)
}
sd := map[string]any{"name": s.name, "first_seq": first, "last_seq": last, "msgs": count, "sha256": digest}
if s.replicas > 0 {
sd["replicas"] = s.replicas
}
if s.maxAge > 0 {
sd["max_age_ns"] = s.maxAge
}
if s.maxMsgs > 0 {
sd["max_msgs"] = s.maxMsgs
}
if !s.noSeqs {
sd["seqs"] = seqs
}
out = append(out, sd)
}
raw, err := json.Marshal(map[string]any{"host": host, "store": "/s", "copied_at": copiedAt, "streams": out})
require.NoError(t, err)
return string(raw)
}
func kvs(msgs ...msg) []msg { return msgs }
func TestKVCompareIdentical(t *testing.T) {
s := stream{name: "KV_a", replicas: 3, msgs: kvs(msg{1, "k", "h1", t0}, msg{2, "k", "h2", t0})}
// The digest field embeds the host, so this exercises the per-seq path too.
divergent, report, err := cmd.CompareKVDigests(doc(t, "n1", t0, s), doc(t, "n2", t0, s), doc(t, "n3", t0, s))
require.NoError(t, err)
assert.False(t, divergent, report)
assert.Contains(t, report, "1 streams: 1 consistent, 0 diverged")
}
// The prod failure: each node wrote its own copy before formation, so the same
// sequence holds different content on different replicas.
func TestKVCompareSameSeqDifferentContent(t *testing.T) {
a := stream{name: "KV_acct", replicas: 2, msgs: kvs(msg{1, "$KV.acct.000000000002", "aaaa", t0})}
b := stream{name: "KV_acct", replicas: 2, msgs: kvs(msg{1, "$KV.acct.000000000002", "bbbb", t0})}
divergent, report, err := cmd.CompareKVDigests(doc(t, "node1", t0, a), doc(t, "node2", t0, b))
require.NoError(t, err)
assert.True(t, divergent)
assert.Contains(t, report, "DIVERGED KV_acct")
assert.Contains(t, report, "seq 1 $KV.acct.000000000002 holds different content: node1=aaaa node2=bbbb")
}
func TestKVCompareMissingMessage(t *testing.T) {
full := kvs(msg{1, "k", "h1", t0}, msg{2, "j", "h2", t0}, msg{3, "k", "h3", t0})
t.Run("superseded by a later write to the subject", func(t *testing.T) {
lagging := stream{name: "KV_a", replicas: 2, msgs: full}
purged := stream{name: "KV_a", replicas: 2, msgs: kvs(msg{1, "", "deleted", time.Time{}}, full[1], full[2])}
divergent, report, err := cmd.CompareKVDigests(doc(t, "n1", t0, lagging), doc(t, "n2", t0, purged))
require.NoError(t, err)
assert.False(t, divergent, report)
assert.Contains(t, report, "1 superseded or expired")
})
t.Run("nothing explains it", func(t *testing.T) {
have := stream{name: "KV_a", replicas: 2, msgs: full}
lost := stream{name: "KV_a", replicas: 2, msgs: kvs(full[0], msg{2, "", "deleted", time.Time{}}, full[2])}
divergent, report, err := cmd.CompareKVDigests(doc(t, "n1", t0, have), doc(t, "n2", t0, lost))
require.NoError(t, err)
assert.True(t, divergent)
assert.Contains(t, report, "seq 2 j is on n1 but missing from n2")
})
t.Run("aged out under max_age before the replica was copied", func(t *testing.T) {
old := t0.Add(-time.Hour)
have := stream{name: "KV_lease", replicas: 2, maxAge: time.Minute, msgs: kvs(msg{1, "l", "h1", old}, msg{2, "m", "h2", t0})}
gone := stream{name: "KV_lease", replicas: 2, maxAge: time.Minute, msgs: kvs(msg{2, "m", "h2", t0})}
divergent, report, err := cmd.CompareKVDigests(doc(t, "n1", t0, have), doc(t, "n2", t0, gone))
require.NoError(t, err)
assert.False(t, divergent, report)
have.maxAge, gone.maxAge = 0, 0
divergent, _, err = cmd.CompareKVDigests(doc(t, "n1", t0, have), doc(t, "n2", t0, gone))
require.NoError(t, err)
assert.True(t, divergent, "without max_age the same gap is unexplained")
})
t.Run("head dropped by a stream limit", func(t *testing.T) {
behind := stream{name: "S", replicas: 2, maxMsgs: 2, msgs: kvs(msg{1, "a", "h1", t0}, msg{2, "b", "h2", t0})}
ahead := stream{name: "S", replicas: 2, maxMsgs: 2, msgs: kvs(msg{2, "b", "h2", t0}, msg{3, "c", "h3", t0})}
divergent, report, err := cmd.CompareKVDigests(doc(t, "n1", t0, behind), doc(t, "n2", t0, ahead))
require.NoError(t, err)
assert.False(t, divergent, report)
})
}
func TestKVCompareTailLag(t *testing.T) {
behind := stream{name: "KV_a", replicas: 2, msgs: kvs(msg{1, "k", "h1", t0}, msg{2, "k", "h2", t0})}
ahead := stream{name: "KV_a", replicas: 2, msgs: kvs(msg{1, "k", "h1", t0}, msg{2, "k", "h2", t0}, msg{3, "k", "h3", t0}, msg{4, "k", "h4", t0})}
divergent, report, err := cmd.CompareKVDigests(doc(t, "n1", t0, behind), doc(t, "n2", t0, ahead))
require.NoError(t, err)
assert.False(t, divergent, report)
assert.Contains(t, report, "tail not compared: n2 +2")
}
func TestKVCompareNoCommonRange(t *testing.T) {
a := stream{name: "KV_a", replicas: 2, msgs: kvs(msg{1, "k", "h1", t0})}
b := stream{name: "KV_a", replicas: 2, msgs: kvs(msg{5, "k", "h5", t0})}
divergent, report, err := cmd.CompareKVDigests(doc(t, "n1", t0, a), doc(t, "n2", t0, b))
require.NoError(t, err)
assert.True(t, divergent)
assert.Contains(t, report, "no sequence range is common")
}
func TestKVCompareReplicaPlacement(t *testing.T) {
s := stream{name: "KV_a", replicas: 3, msgs: kvs(msg{1, "k", "h1", t0})}
other := stream{name: "KV_b", replicas: 1, msgs: kvs(msg{1, "k", "h1", t0})}
t.Run("fewer holders than replicas", func(t *testing.T) {
divergent, report, err := cmd.CompareKVDigests(doc(t, "n1", t0, s), doc(t, "n2", t0, s), doc(t, "n3", t0, other))
require.NoError(t, err)
assert.True(t, divergent)
assert.Contains(t, report, "held by 2 of 3 replicas; missing from n3")
})
t.Run("more holders than replicas", func(t *testing.T) {
divergent, report, err := cmd.CompareKVDigests(doc(t, "n1", t0, other), doc(t, "n2", t0, other))
require.NoError(t, err)
assert.True(t, divergent)
assert.Contains(t, report, "held by 2 nodes but configured for 1 replicas")
})
t.Run("replica counts disagree", func(t *testing.T) {
r1 := s
r1.replicas = 1
divergent, report, err := cmd.CompareKVDigests(doc(t, "n1", t0, s), doc(t, "n2", t0, r1))
require.NoError(t, err)
assert.True(t, divergent)
assert.Contains(t, report, "replicas disagree on the replica count")
})
t.Run("single replica stream on one node", func(t *testing.T) {
divergent, report, err := cmd.CompareKVDigests(doc(t, "n1", t0, other), doc(t, "n2", t0))
require.NoError(t, err)
assert.False(t, divergent, report)
})
}
func TestKVCompareNeedsSeqs(t *testing.T) {
a := stream{name: "KV_a", replicas: 2, noSeqs: true, msgs: kvs(msg{1, "k", "h1", t0})}
b := stream{name: "KV_a", replicas: 2, noSeqs: true, msgs: kvs(msg{1, "k", "h2", t0})}
divergent, report, err := cmd.CompareKVDigests(doc(t, "n1", t0, a), doc(t, "n2", t0, b))
require.NoError(t, err)
assert.True(t, divergent)
assert.Contains(t, report, "re-run digest with --seqs")
}
func TestKVCompareInput(t *testing.T) {
s := stream{name: "KV_a", replicas: 2, msgs: kvs(msg{1, "k", "h1", t0})}
// Several documents in one stream, as "-" reads them from stdin.
divergent, _, err := cmd.CompareKVDigests(doc(t, "n1", t0, s) + "\n" + doc(t, "n2", t0, s))
require.NoError(t, err)
assert.False(t, divergent)
_, _, err = cmd.CompareKVDigests(doc(t, "n1", t0, s))
assert.ErrorContains(t, err, "at least two nodes")
_, _, err = cmd.CompareKVDigests(doc(t, "n1", t0, s), doc(t, "n1", t0, s))
assert.ErrorContains(t, err, "two digests are from host n1")
_, _, err = cmd.CompareKVDigests(`KV_a first=1 last=1`)
assert.Error(t, err, "text digest output is rejected")
_, _, err = cmd.CompareKVDigests(`{"streams":[]}`)
assert.ErrorContains(t, err, "no host")
_, _, err = cmd.CompareKVDigests(" ")
assert.ErrorContains(t, err, "no digest documents")
}
func TestAdminInitDiscardsJetStreamByDefault(t *testing.T) {
assert.Equal(t, "true", cmd.AdminInitFlagDefault("discard-jetstream"))
assert.True(t, strings.HasPrefix(cmd.AdminInitFlagDefault("ipsec"), "true"))
}
+319
View File
@@ -0,0 +1,319 @@
//test:in-package: exercises the unexported digest helpers against a real clustered
// store, which the cobra command only reaches through a live node.
package cmd
import (
"bytes"
"context"
"fmt"
"net"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/nats-io/nats-server/v2/server"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// kvServer runs a standalone JetStream server on storeParent and returns a KV
// bucket on it plus a stop function.
func kvServer(t *testing.T, storeParent, bucket string) (jetstream.KeyValue, func()) {
t.Helper()
ns, err := server.NewServer(&server.Options{
ServerName: "kv-test", JetStream: true, StoreDir: storeParent,
DontListen: true, NoLog: true, NoSigs: true,
})
require.NoError(t, err)
ns.Start()
require.True(t, ns.ReadyForConnections(10*time.Second))
nc, err := nats.Connect("", nats.InProcessServer(ns))
require.NoError(t, err)
js, err := jetstream.New(nc)
require.NoError(t, err)
kv, err := js.CreateKeyValue(context.Background(), jetstream.KeyValueConfig{Bucket: bucket, History: 5})
require.NoError(t, err)
return kv, func() {
nc.Close()
ns.Shutdown()
ns.WaitForShutdown()
}
}
func digestOf(t *testing.T, storeDir string, withSeqs bool) []streamDigest {
t.Helper()
work := t.TempDir()
_, metas, err := copyStreamTree(storeDir, filepath.Join(work, "jetstream"))
require.NoError(t, err)
d, err := digestStore(context.Background(), work, nil, withSeqs, metas)
require.NoError(t, err)
return d
}
func TestKVDigest(t *testing.T) {
ctx := context.Background()
parent := t.TempDir()
kv, stop := kvServer(t, parent, "test")
for _, kvp := range [][2]string{{"a", "1"}, {"b", "2"}, {"a", "3"}} {
_, err := kv.PutString(ctx, kvp[0], kvp[1])
require.NoError(t, err)
}
require.NoError(t, kv.Delete(ctx, "b"))
store := filepath.Join(parent, "jetstream")
// Copied while the server is live, exactly as it runs on a node.
live := digestOf(t, store, true)
stop()
require.Len(t, live, 1)
assert.Equal(t, "KV_test", live[0].Name)
assert.Equal(t, uint64(1), live[0].FirstSeq)
assert.Equal(t, uint64(4), live[0].LastSeq)
assert.Equal(t, uint64(4), live[0].Msgs)
require.Len(t, live[0].Seqs, 4)
t.Run("stable across reads and a stopped server", func(t *testing.T) {
again := digestOf(t, store, false)
assert.Equal(t, live[0].Digest, again[0].Digest)
})
t.Run("source store is left untouched", func(t *testing.T) {
_, err := os.Stat(filepath.Join(store, "$G", "streams", "KV_test"))
assert.NoError(t, err)
})
// Same keys and values written separately differ in timestamps, which is
// exactly what separates two nodes' pre-formation writes.
t.Run("independently written store differs", func(t *testing.T) {
other := t.TempDir()
kv2, stop2 := kvServer(t, other, "test")
for _, kvp := range [][2]string{{"a", "1"}, {"b", "2"}, {"a", "3"}} {
_, err := kv2.PutString(ctx, kvp[0], kvp[1])
require.NoError(t, err)
}
require.NoError(t, kv2.Delete(ctx, "b"))
stop2()
d := digestOf(t, filepath.Join(other, "jetstream"), true)
assert.NotEqual(t, live[0].Digest, d[0].Digest)
// Two stores adopted as replicas of one stream: same subjects and
// sequences, different content, which compare must call divergent.
res, err := compareNodeDigests([]nodeDigest{
{Host: "node1", CopiedAt: time.Now(), Streams: live},
{Host: "node2", CopiedAt: time.Now(), Streams: d},
})
require.NoError(t, err)
require.True(t, res.divergent())
require.Len(t, res.Streams, 1)
assert.Contains(t, strings.Join(res.Streams[0].Problems, "\n"), "seq 1 $KV.test.a holds different content")
})
t.Run("report lines", func(t *testing.T) {
var buf bytes.Buffer
writeDigests(&buf, "node1", store, time.Unix(0, 0), []string{"ACC/S"}, live)
out := buf.String()
assert.Contains(t, out, "# host=node1 ")
assert.Contains(t, out, "# skipped account stream (not read): ACC/S")
assert.Contains(t, out, "KV_test\tfirst=1\tlast=4\tmsgs=4\tsha256="+live[0].Digest)
assert.Equal(t, 4, strings.Count(out, "KV_test\tseq="))
})
}
func TestKVDigestStreamFilterAndDeletedSeq(t *testing.T) {
ctx := context.Background()
parent := t.TempDir()
// The KV bucket is only there to be excluded by the stream filter.
_, stop := kvServer(t, parent, "other")
stop()
// KV streams deny message deletes, so an interior gap needs a plain stream.
ns, err := server.NewServer(&server.Options{JetStream: true, StoreDir: parent, DontListen: true, NoLog: true, NoSigs: true})
require.NoError(t, err)
ns.Start()
require.True(t, ns.ReadyForConnections(10*time.Second))
nc, err := nats.Connect("", nats.InProcessServer(ns))
require.NoError(t, err)
js, err := jetstream.New(nc)
require.NoError(t, err)
s, err := js.CreateStream(ctx, jetstream.StreamConfig{Name: "ONE", Subjects: []string{"one.>"}})
require.NoError(t, err)
for _, v := range []string{"v1", "v2", "v3"} {
_, err = js.Publish(ctx, "one.k", []byte(v))
require.NoError(t, err)
}
require.NoError(t, s.DeleteMsg(ctx, 2))
nc.Close()
ns.Shutdown()
ns.WaitForShutdown()
work := t.TempDir()
_, metas, err := copyStreamTree(filepath.Join(parent, "jetstream"), filepath.Join(work, "jetstream"))
require.NoError(t, err)
d, err := digestStore(ctx, work, []string{"ONE"}, true, metas)
require.NoError(t, err)
require.Len(t, d, 1)
assert.Equal(t, "ONE", d[0].Name)
assert.Equal(t, uint64(2), d[0].Msgs)
require.Len(t, d[0].Seqs, 3)
assert.Equal(t, seqDigest{Seq: 2, Hash: "deleted"}, d[0].Seqs[1])
for _, i := range []int{0, 2} {
assert.Equal(t, "one.k", d[0].Seqs[i].Subject)
assert.False(t, d[0].Seqs[i].Time.IsZero())
}
assert.Equal(t, "limits", d[0].Retention)
assert.Equal(t, 1, d[0].Replicas)
}
func freePort(t *testing.T) int {
t.Helper()
l, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
defer l.Close()
return l.Addr().(*net.TCPAddr).Port
}
// A replicated stream's meta.inf carries num_replicas > 1, which a standalone
// server refuses to load; this is the case every production node presents.
func TestKVDigestClusteredReplicas(t *testing.T) {
ctx := context.Background()
const n = 3
ports := make([]int, n)
routes := make([]string, n)
for i := range ports {
ports[i] = freePort(t)
routes[i] = fmt.Sprintf("nats://127.0.0.1:%d", ports[i])
}
stores := make([]string, n)
servers := make([]*server.Server, n)
for i := range servers {
stores[i] = t.TempDir()
ns, err := server.NewServer(&server.Options{
ServerName: fmt.Sprintf("s%d", i+1), JetStream: true, StoreDir: stores[i],
Host: "127.0.0.1", Port: -1, NoLog: true, NoSigs: true,
Cluster: server.ClusterOpts{Name: "digest", Host: "127.0.0.1", Port: ports[i]},
Routes: server.RoutesFromStr(strings.Join(routes, ",")),
})
require.NoError(t, err)
ns.Start()
servers[i] = ns
}
t.Cleanup(func() {
for _, ns := range servers {
ns.Shutdown()
ns.WaitForShutdown()
}
})
nc, err := nats.Connect("", nats.InProcessServer(servers[0]))
require.NoError(t, err)
defer nc.Close()
js, err := jetstream.New(nc)
require.NoError(t, err)
var kv jetstream.KeyValue
require.Eventually(t, func() bool {
kv, err = js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: "rep", History: 3, Replicas: n, TTL: time.Hour})
return err == nil
}, 30*time.Second, 250*time.Millisecond, "clustered KV create: %v", err)
for i := range 5 {
_, err := kv.PutString(ctx, fmt.Sprintf("k%d", i%2), fmt.Sprintf("v%d", i))
require.NoError(t, err)
}
s, err := js.Stream(ctx, "KV_rep")
require.NoError(t, err)
require.Eventually(t, func() bool {
info, err := s.Info(ctx)
if err != nil || info.Cluster == nil || len(info.Cluster.Replicas) != n-1 {
return false
}
for _, r := range info.Cluster.Replicas {
if !r.Current || r.Lag != 0 {
return false
}
}
return true
}, 30*time.Second, 250*time.Millisecond)
// Current means applied, not flushed; shutdown flushes every block to disk.
nc.Close()
for _, ns := range servers {
ns.Shutdown()
ns.WaitForShutdown()
}
var first string
var nodes []nodeDigest
for i, store := range stores {
d := digestOf(t, filepath.Join(store, "jetstream"), true)
require.Len(t, d, 1, "replica %d", i+1)
assert.Equal(t, uint64(5), d[0].Msgs, "replica %d", i+1)
assert.Equal(t, n, d[0].Replicas, "live replica count survives the standalone rewrite")
assert.Equal(t, time.Hour, d[0].MaxAge)
nodes = append(nodes, nodeDigest{Host: fmt.Sprintf("s%d", i+1), CopiedAt: time.Now(), Streams: d})
if i == 0 {
first = d[0].Digest
continue
}
assert.Equal(t, first, d[0].Digest, "replica %d matches replica 1", i+1)
}
res, err := compareNodeDigests(nodes)
require.NoError(t, err)
assert.False(t, res.divergent(), "%+v", res)
}
// Recovery applies MaxAge, so without the rewrite an expired bucket copy
// would read as empty and mask whatever the replica holds.
func TestKVDigestIgnoresExpiry(t *testing.T) {
ctx := context.Background()
parent := t.TempDir()
ns, err := server.NewServer(&server.Options{JetStream: true, StoreDir: parent, DontListen: true, NoLog: true, NoSigs: true})
require.NoError(t, err)
ns.Start()
require.True(t, ns.ReadyForConnections(10*time.Second))
nc, err := nats.Connect("", nats.InProcessServer(ns))
require.NoError(t, err)
js, err := jetstream.New(nc)
require.NoError(t, err)
kv, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: "ttl", TTL: time.Second})
require.NoError(t, err)
_, err = kv.PutString(ctx, "k", "v")
require.NoError(t, err)
work := t.TempDir()
_, metas, err := copyStreamTree(filepath.Join(parent, "jetstream"), filepath.Join(work, "jetstream"))
require.NoError(t, err)
nc.Close()
ns.Shutdown()
ns.WaitForShutdown()
time.Sleep(1500 * time.Millisecond)
d, err := digestStore(ctx, work, nil, false, metas)
require.NoError(t, err)
require.Len(t, d, 1)
assert.Equal(t, uint64(1), d[0].Msgs)
}
func TestCopyStreamTree(t *testing.T) {
store := seedStore(t, "KV_a")
require.NoError(t, os.MkdirAll(filepath.Join(store, "$G", "streams", "KV_a", "obs", "c1"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(store, "$G", "streams", "KV_a", "obs", "c1", "o.dat"), []byte("x"), 0o600))
require.NoError(t, os.MkdirAll(filepath.Join(store, "ACME", "streams", "ORDERS"), 0o755))
dst := filepath.Join(t.TempDir(), "jetstream")
skipped, _, err := copyStreamTree(store, dst)
require.NoError(t, err)
assert.Equal(t, []string{"ACME/ORDERS"}, skipped)
_, err = os.Stat(filepath.Join(dst, "$G", "streams", "KV_a", "msgs", "1.blk"))
assert.NoError(t, err, "message blocks are copied")
_, err = os.Stat(filepath.Join(dst, "$G", "streams", "KV_a", "obs"))
assert.True(t, os.IsNotExist(err), "consumer state is left out")
_, _, err = copyStreamTree(filepath.Join(t.TempDir(), "absent"), dst)
assert.Error(t, err)
}
+28 -21
View File
@@ -16,13 +16,14 @@ import (
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/s3"
"github.com/mulgadc/bluebottle/pkg/masterkey"
"github.com/mulgadc/bluebottle/pkg/safecast"
"github.com/mulgadc/spinifex/spinifex/admin"
"github.com/mulgadc/spinifex/spinifex/config"
"github.com/mulgadc/spinifex/spinifex/ebsmetadata"
"github.com/mulgadc/spinifex/spinifex/ebsprovider"
gateway_bedrock "github.com/mulgadc/spinifex/spinifex/gateway/bedrock"
"github.com/mulgadc/spinifex/spinifex/gateway/bedrock/hfhub"
handlers_ec2_snapshot "github.com/mulgadc/spinifex/spinifex/handlers/ec2/snapshot"
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
"github.com/mulgadc/spinifex/spinifex/objectstore"
"github.com/mulgadc/spinifex/spinifex/utils"
"github.com/nats-io/nats.go"
@@ -350,6 +351,17 @@ func pullFilesToPrefix(ctx context.Context, hf *hfhub.Client, store objectstore.
return uploaded, nil
}
// weightsStagingRefusal builds the model-ID refusal error weights staging and
// pulling share. Unknown IDs and provider-served IDs get distinct wording so
// a caller can tell which one it hit; taking found/selfHost as plain bools
// keeps this testable without a real catalog entry for the provider case.
func weightsStagingRefusal(modelID, operation string, found, _ bool) error {
if !found {
return fmt.Errorf("unknown model ID %q: not present in the Ochre catalog", modelID)
}
return fmt.Errorf("%q is a provider-served model, not self-host; weights %s does not apply", modelID, operation)
}
// runPullWeights holds 'ochre weights pull' decision and side-effect logic:
// catalog validation, ref resolution to an immutable commit SHA (D3), tree
// listing and safetensors-only filtering (D4), streaming each selected file
@@ -359,10 +371,7 @@ func pullFilesToPrefix(ctx context.Context, hf *hfhub.Client, store objectstore.
func runPullWeights(ctx context.Context, hf *hfhub.Client, store objectstore.ObjectStore, modelID, hfRepo, revision string, revisionExplicit bool, s3URIFlag string) (string, error) {
spec, found, selfHost := gateway_bedrock.LookupServingSpec(modelID)
if !found || !selfHost {
if !found {
return "", fmt.Errorf("unknown model ID %q: not present in the Ochre catalog", modelID)
}
return "", fmt.Errorf("%q is a provider-served model, not self-host; weights pull does not apply", modelID)
return "", weightsStagingRefusal(modelID, "pull", found, selfHost)
}
// The catalog carries the canonical repo and a pinned revision so a bare
@@ -624,12 +633,12 @@ func buildWeightsImage(srcDir, imagePath string, contentBytes int64) error {
// metadata.json sitting alongside them, so without this the endpoint launcher
// cannot see the snapshot at all and fails with InvalidSnapshot.NotFound.
func registerWeightsSnapshot(store objectstore.ObjectStore, bucket, snapshotID, volumeID string, volumeSize uint64, az string, encrypted bool) error {
cfg := &handlers_ec2_snapshot.SnapshotConfig{
cfg := ebsmetadata.Snapshot{
SnapshotID: snapshotID,
VolumeID: volumeID,
// GiB, not bytes: CreateVolume compares this against a requested Size
// already in GiB, and would reject every clone if handed raw bytes.
VolumeSize: utils.SafeUint64ToInt64(volumeSize / bytesPerGiB),
VolumeSize: safecast.Uint64ToInt64(volumeSize / bytesPerGiB),
State: "completed",
Progress: "100%",
StartTime: time.Now(),
@@ -638,7 +647,7 @@ func registerWeightsSnapshot(store objectstore.ObjectStore, bucket, snapshotID,
OwnerID: utils.GlobalAccountID,
AvailabilityZone: az,
}
if err := handlers_ec2_snapshot.WriteSnapshotConfig(store, bucket, snapshotID, cfg); err != nil {
if err := ebsmetadata.NewStore(store, bucket).PutSnapshot(context.Background(), cfg); err != nil {
return fmt.Errorf("register snapshot metadata: %w", err)
}
return nil
@@ -666,10 +675,7 @@ type weightsSnapshotChecker func(ctx context.Context, snapshotID string) (bool,
// fake materializer.
func runStageWeights(ctx context.Context, store objectstore.ObjectStore, weightsStore *gateway_bedrock.WeightsStore, tmpDirFlag, modelID, s3URI string, materialize weightsMaterializer, checkSnapshotLive weightsSnapshotChecker) (string, error) {
if _, found, selfHost := gateway_bedrock.LookupServingSpec(modelID); !found || !selfHost {
if !found {
return "", fmt.Errorf("unknown model ID %q: not present in the Ochre catalog", modelID)
}
return "", fmt.Errorf("%q is a provider-served model, not self-host; weights staging does not apply", modelID)
return "", weightsStagingRefusal(modelID, "staging", found, selfHost)
}
bucket, prefix, err := parseWeightsS3URI(s3URI)
@@ -779,7 +785,7 @@ func materializeWeightsVolume(ctx context.Context, provider ebsprovider.EBSProvi
if err := admin.ImportImage(ctx, provider, admin.ImportOpts{
VolumeID: volumeId,
NodeID: nodeID,
SizeBytes: utils.SafeUint64ToInt64(volumeBytes),
SizeBytes: safecast.Uint64ToInt64(volumeBytes),
AvailabilityZone: node.AZ,
SourcePath: imagePath,
Snapshot: true,
@@ -799,13 +805,14 @@ func materializeWeightsVolume(ctx context.Context, provider ebsprovider.EBSProvi
}
// newWeightsSnapshotChecker is the real weightsSnapshotChecker: it reads the
// EC2 control plane's metadata.json for snapshotID from the node's
// predastore bucket -- the same record registerWeightsSnapshot writes at
// stage time -- and treats a missing object as gone. Any other read failure
// is a real error, not a signal to guess the snapshot's existence.
// EC2 control plane's snapshot document for snapshotID under the global
// account -- the same record registerWeightsSnapshot writes at stage time --
// and treats a missing object as gone. Any other read failure is a real
// error, not a signal to guess the snapshot's existence.
func newWeightsSnapshotChecker(store objectstore.ObjectStore, bucket string) weightsSnapshotChecker {
metadata := ebsmetadata.NewStore(store, bucket)
return func(ctx context.Context, snapshotID string) (bool, error) {
if _, err := handlers_ec2_snapshot.ReadSnapshotConfig(ctx, store, bucket, snapshotID); err != nil {
if _, err := metadata.GetSnapshot(ctx, utils.GlobalAccountID, snapshotID); err != nil {
if objectstore.IsNoSuchKeyError(err) {
return false, nil
}
@@ -999,7 +1006,7 @@ func resolveHFToken(ctx context.Context, cmd *cobra.Command, cfg *config.Cluster
}
masterKeyPath := filepath.Join(cfg.NodeBaseDir(), "config", "master.key")
masterKey, err := handlers_iam.LoadMasterKey(masterKeyPath)
masterKey, err := masterkey.ReadShared(masterKeyPath)
if err != nil {
return ""
}
@@ -1071,7 +1078,7 @@ func ochreCredentialsStore() (*gateway_bedrock.CredentialStore, func(), error) {
return nil, nil, fmt.Errorf("connect to cluster: %w", err)
}
masterKeyPath := filepath.Join(cfg.NodeBaseDir(), "config", "master.key")
masterKey, err := handlers_iam.LoadMasterKey(masterKeyPath)
masterKey, err := masterkey.ReadShared(masterKeyPath)
if err != nil {
nc.Close()
return nil, nil, fmt.Errorf("load master key: %w", err)
+6 -4
View File
@@ -235,10 +235,12 @@ func TestRunPullWeights_UnknownModelRefused(t *testing.T) {
assert.Contains(t, err.Error(), "not present in the Ochre catalog")
}
// TestRunPullWeights_ProviderModelRefused covers the self-host gate: a
// provider-served model must refuse before any network work, same as stage.
func TestRunPullWeights_ProviderModelRefused(t *testing.T) {
_, err := runPullWeights(context.Background(), deadHFClient(), explodingObjectStore{t: t}, providerModelID, testHFRepo, "main", true, "")
// TestWeightsStagingRefusal_PullOperationWording covers the self-host gate's
// message for the 'pull' operation. v1 ships no provider-tier catalog entry,
// so this drives weightsStagingRefusal directly rather than through a real
// LookupServingSpec lookup.
func TestWeightsStagingRefusal_PullOperationWording(t *testing.T) {
err := weightsStagingRefusal(providerModelID, "pull", true, false)
require.Error(t, err)
assert.Contains(t, err.Error(), "not self-host")
}
+16 -21
View File
@@ -15,20 +15,22 @@ import (
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/s3"
"github.com/mulgadc/spinifex/spinifex/config"
"github.com/mulgadc/spinifex/spinifex/ebsmetadata"
gateway_bedrock "github.com/mulgadc/spinifex/spinifex/gateway/bedrock"
handlers_ec2_snapshot "github.com/mulgadc/spinifex/spinifex/handlers/ec2/snapshot"
"github.com/mulgadc/spinifex/spinifex/objectstore"
"github.com/mulgadc/spinifex/spinifex/testutil"
"github.com/mulgadc/spinifex/spinifex/utils"
"github.com/nats-io/nats.go"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// selfHostModelID and providerModelID are real catalog entries: the former
// is self-host (weights staging applies), the latter is provider-served
// (weights staging must refuse it), so tests exercise LookupServingSpec's
// found/selfHost distinction against the real catalog rather than a stub.
// selfHostModelID is a real catalog entry (weights staging applies).
// providerModelID is an arbitrary ID not present in the catalog at all: v1
// ships no provider-tier entry, so the found-but-not-selfHost case is
// exercised directly against weightsStagingRefusal instead, not through a
// real LookupServingSpec lookup.
const (
selfHostModelID = "meta.llama3-2-1b-instruct-v1:0"
providerModelID = "anthropic.claude-3-5-sonnet-20240620-v1:0"
@@ -404,24 +406,17 @@ func TestRunStageWeights_UnknownModelIDRefused(t *testing.T) {
assert.Empty(t, entries, "refusal must not write a KV entry")
}
// TestRunStageWeights_ProviderModelIDRefused covers refusal on a model ID
// that exists in the catalog but is provider-served, not self-host. The
// error must be distinguishable from the unknown-model-ID case, since
// LookupServingSpec returns found and selfHost separately for exactly this
// reason.
func TestRunStageWeights_ProviderModelIDRefused(t *testing.T) {
weightsStore := newWeightsStoreForTest(t)
_, err := runStageWeights(context.Background(), explodingObjectStore{t: t}, weightsStore, t.TempDir(),
providerModelID, "s3://models/claude/", explodingMaterializer(t), explodingSnapshotChecker(t))
// TestWeightsStagingRefusal_ProviderServedDistinguishesFromUnknown covers
// refusal for a model ID that is found in the catalog but is provider-served,
// not self-host. The error must be distinguishable from the unknown-model-ID
// case; v1 ships no provider-tier entry, so this drives weightsStagingRefusal
// directly with explicit found/selfHost values rather than a real lookup.
func TestWeightsStagingRefusal_ProviderServedDistinguishesFromUnknown(t *testing.T) {
err := weightsStagingRefusal(providerModelID, "staging", true, false)
require.Error(t, err)
assert.ErrorContains(t, err, "provider-served")
assert.NotContains(t, err.Error(), "unknown model ID", "must be distinguishable from the unknown-model-ID refusal")
entries, listErr := weightsStore.ListWeights(context.Background())
require.NoError(t, listErr)
assert.Empty(t, entries, "refusal must not write a KV entry")
}
// TestRunStageWeights_MissingRequiredFilesRefusedBeforeMaterializing covers
@@ -1100,7 +1095,7 @@ func TestRegisterWeightsSnapshot_WritesEC2ReadableMetadata(t *testing.T) {
require.NoError(t, registerWeightsSnapshot(store, bucket,
"snap-vol-abc", "vol-abc", 12*bytesPerGiB, "ap-southeast-2a", true))
cfg, err := handlers_ec2_snapshot.ReadSnapshotConfig(context.Background(), store, bucket, "snap-vol-abc")
cfg, err := ebsmetadata.NewStore(store, bucket).GetSnapshot(context.Background(), utils.GlobalAccountID, "snap-vol-abc")
require.NoError(t, err)
assert.Equal(t, "snap-vol-abc", cfg.SnapshotID)
assert.Equal(t, "vol-abc", cfg.VolumeID)
@@ -1117,7 +1112,7 @@ func TestRegisterWeightsSnapshot_UnencryptedVolume(t *testing.T) {
require.NoError(t, registerWeightsSnapshot(store, "predastore",
"snap-vol-plain", "vol-plain", bytesPerGiB, "ap-southeast-2a", false))
cfg, err := handlers_ec2_snapshot.ReadSnapshotConfig(context.Background(), store, "predastore", "snap-vol-plain")
cfg, err := ebsmetadata.NewStore(store, "predastore").GetSnapshot(context.Background(), utils.GlobalAccountID, "snap-vol-plain")
require.NoError(t, err)
assert.False(t, cfg.Encrypted)
}
@@ -30,6 +30,8 @@ type fakeVectorService struct {
queryErr error
listJobs []handlers_ochrevector.JobRecord
listJobsErr error
stopJob handlers_ochrevector.JobRecord
stopJobErr error
createIndexInputs []*handlers_ochrevector.CreateIndexRequest
deleteIndexInputs []*handlers_ochrevector.DeleteIndexRequest
@@ -94,6 +96,17 @@ func (f *fakeVectorService) ListJobs(_ context.Context, _ *handlers_ochrevector.
return &handlers_ochrevector.ListJobsResponse{Jobs: f.listJobs}, nil
}
func (f *fakeVectorService) StopJob(_ context.Context, req *handlers_ochrevector.StopJobRequest, _ string) (*handlers_ochrevector.StopJobResponse, error) {
if f.stopJobErr != nil {
return nil, f.stopJobErr
}
job := f.stopJob
if job.ID == "" {
job.ID = req.JobID
}
return &handlers_ochrevector.StopJobResponse{Job: job}, nil
}
var _ handlers_ochrevector.VectorService = (*fakeVectorService)(nil)
const testIndexID = "idx-0123456789abcdef0"
+82 -4
View File
@@ -4,11 +4,14 @@ import (
"bytes"
"errors"
"fmt"
"net/netip"
"os"
"path/filepath"
"strings"
"testing"
"github.com/mulgadc/northstar/pkg/backend"
nsconfig "github.com/mulgadc/northstar/pkg/config"
"github.com/mulgadc/spinifex/spinifex/admin"
"github.com/mulgadc/spinifex/spinifex/config"
"github.com/mulgadc/spinifex/spinifex/formation"
@@ -1286,6 +1289,9 @@ func TestAMIVolumeSizeGiB(t *testing.T) {
}
func TestCheckJoinPreconditions(t *testing.T) {
withFakeProc(t, nil)
noStore := filepath.Join(t.TempDir(), "nats", "jetstream")
writeToml := func(t *testing.T) string {
t.Helper()
dir := t.TempDir()
@@ -1296,13 +1302,13 @@ func TestCheckJoinPreconditions(t *testing.T) {
}
t.Run("uninitialized node joins", func(t *testing.T) {
if err := checkJoinPreconditions(t.TempDir(), false); err != nil {
if err := checkJoinPreconditions(t.TempDir(), noStore, false); err != nil {
t.Fatalf("want nil, got %v", err)
}
})
t.Run("initialized node is refused", func(t *testing.T) {
err := checkJoinPreconditions(writeToml(t), false)
err := checkJoinPreconditions(writeToml(t), noStore, false)
if err == nil {
t.Fatal("want refusal, got nil")
}
@@ -1314,7 +1320,7 @@ func TestCheckJoinPreconditions(t *testing.T) {
// The guidance is the whole point of the guard: an operator must be able to
// tell what a forced join would destroy without reading the source.
t.Run("guidance names what is discarded", func(t *testing.T) {
for _, want := range []string{"CA certificate", "master key", "viperblock key", "--force"} {
for _, want := range []string{"CA certificate", "master key", "viperblock key", "JetStream store", "--force"} {
if !strings.Contains(joinDiscardsIdentityMsg, want) {
t.Errorf("guidance missing %q", want)
}
@@ -1322,10 +1328,27 @@ func TestCheckJoinPreconditions(t *testing.T) {
})
t.Run("force overrides", func(t *testing.T) {
if err := checkJoinPreconditions(writeToml(t), true); err != nil {
if err := checkJoinPreconditions(writeToml(t), noStore, true); err != nil {
t.Fatalf("want nil with force, got %v", err)
}
})
// A store with streams and no config is still data the join would destroy.
t.Run("uninitialized node with streams is refused", func(t *testing.T) {
err := checkJoinPreconditions(t.TempDir(), seedStore(t, "KV_a"), false)
if err == nil || !strings.Contains(err.Error(), "JetStream store") {
t.Fatalf("want JetStream refusal, got %v", err)
}
})
// --force accepts the loss of data, never a wipe under a live server.
t.Run("running NATS is refused even with force", func(t *testing.T) {
withFakeProc(t, map[string]string{"4242": "/usr/local/bin/spx\x00service\x00nats\x00start\x00"})
err := checkJoinPreconditions(writeToml(t), seedStore(t, "KV_a"), true)
if !errors.Is(err, errNATSRunning) {
t.Fatalf("want errNATSRunning, got %v", err)
}
})
}
func TestJoinRetryable(t *testing.T) {
@@ -1358,3 +1381,58 @@ func TestJoinRetryable(t *testing.T) {
})
}
}
// The rendered resolver must refuse the internet and still serve the cluster.
// Both halves matter: an open resolver is the bug, and a resolver that refuses
// its own nodes silently stops internal names resolving.
func TestGenerateAndWriteConfigs_NorthstarRecursionIsClosedButServesTheCluster(t *testing.T) {
dirs, err := createConfigSubdirs(t.TempDir())
require.NoError(t, err)
nsPath := filepath.Join(dirs.Northstar, "northstar.toml")
settings := multinodeNorthstarSettings(nsPath)
settings.BindIP = "10.2.0.2"
settings.AdvertiseIP = "72.52.77.228"
settings.RemoteNodes = []admin.RemoteNode{
{Name: "node2", Host: "72.52.77.229", NorthstarConfigPath: nsPath},
{Name: "node3", Host: "72.52.77.230:4432", NorthstarConfigPath: nsPath},
}
require.NoError(t, generateAndWriteConfigs(dirs, filepath.Join(t.TempDir(), "spinifex.toml"), settings, true))
raw, err := os.ReadFile(nsPath)
require.NoError(t, err)
rendered := string(raw)
assert.Contains(t, rendered, "allow_recursion = false",
"a node bound to a public address must not be an open resolver")
// The node's own advertised address: resolv.conf points there, so the query
// arrives from it over lo rather than from 127.0.0.1.
assert.Contains(t, rendered, `"72.52.77.228"`)
assert.Contains(t, rendered, `"10.2.0.2"`)
// Peers, with any port stripped.
assert.Contains(t, rendered, `"72.52.77.229"`)
assert.Contains(t, rendered, `"72.52.77.230"`)
assert.NotContains(t, rendered, "72.52.77.230:4432")
// It must parse as the config northstar will actually load.
cfg, err := nsconfig.LoadServerConfig(nsPath)
require.NoError(t, err)
assert.False(t, cfg.Upstream.AllowRecursion)
prefixes, err := cfg.Upstream.ParseAllowRecursionFrom()
require.NoError(t, err)
policy := backend.NewRecursionPolicy(cfg.Upstream.AllowRecursion, prefixes)
// The cluster resolves.
assert.True(t, policy.Allows(netip.MustParseAddr("72.52.77.228")), "node must resolve through itself")
assert.True(t, policy.Allows(netip.MustParseAddr("72.52.77.229")), "peer node must resolve")
assert.True(t, policy.Allows(netip.MustParseAddr("127.0.0.1")), "loopback is always allowed")
// Guests reach northstar via vpcd's shim, which relays from a node address,
// so a guest query is seen as one of the addresses asserted above.
// The internet does not.
assert.False(t, policy.Allows(netip.MustParseAddr("198.51.100.7")))
assert.False(t, policy.Allows(netip.MustParseAddr("8.8.8.8")))
}
+27
View File
@@ -4,8 +4,10 @@
package cmd
import (
"bytes"
"errors"
"strconv"
"strings"
"time"
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
@@ -146,3 +148,28 @@ var PrintAccountTable = printAccountTable
// AccountSummaries exposes accountSummaries for testing.
var AccountSummaries = accountSummaries
// CompareKVDigests runs "kv compare" over digest JSON documents and returns
// whether any stream diverged, plus the printed report.
func CompareKVDigests(docs ...string) (bool, string, error) {
var nodes []nodeDigest
for i, doc := range docs {
got, err := loadNodeDigests(strings.NewReader(doc), "doc"+strconv.Itoa(i))
if err != nil {
return false, "", err
}
nodes = append(nodes, got...)
}
res, err := compareNodeDigests(nodes)
if err != nil {
return false, "", err
}
var buf bytes.Buffer
writeCompareResult(&buf, nodes, res)
return res.divergent(), buf.String(), nil
}
// AdminInitFlagDefault returns the registered default of an admin init flag.
func AdminInitFlagDefault(name string) string {
return adminInitCmd.Flags().Lookup(name).DefValue
}
+139
View File
@@ -0,0 +1,139 @@
package cmd
import (
"bytes"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
)
// jetStreamStoreDir is where the NATS service keeps its JetStream data under a
// node's data directory. The nats.conf template sets store_dir to
// <data_dir>/nats/, and nats-server appends "jetstream" itself.
func jetStreamStoreDir(dataDir string) string {
return filepath.Join(dataDir, "nats", "jetstream")
}
// localStreams lists the streams a JetStream store holds on disk, as
// "<account>/<stream>". A missing store holds nothing.
func localStreams(storeDir string) ([]string, error) {
accounts, err := os.ReadDir(storeDir)
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("read JetStream store %s: %w", storeDir, err)
}
var streams []string
for _, acc := range accounts {
if !acc.IsDir() {
continue
}
entries, err := os.ReadDir(filepath.Join(storeDir, acc.Name(), "streams"))
if errors.Is(err, os.ErrNotExist) {
continue
}
if err != nil {
return nil, fmt.Errorf("read streams for account %s: %w", acc.Name(), err)
}
for _, e := range entries {
if e.IsDir() {
streams = append(streams, acc.Name()+"/"+e.Name())
}
}
}
sort.Strings(streams)
return streams, nil
}
// procRoot is overridable in tests so the process scan can run against a fake
// /proc tree.
var procRoot = "/proc"
// natsRunningLocally reports whether this host runs the Spinifex NATS service,
// found as a process whose command line is `spx service nats ...`. Removing a
// store under a live server races JetStream, which rewrites its index files.
func natsRunningLocally() (bool, error) {
entries, err := os.ReadDir(procRoot)
if err != nil {
return false, fmt.Errorf("scan processes: %w", err)
}
for _, e := range entries {
if !e.IsDir() || strings.TrimLeft(e.Name(), "0123456789") != "" {
continue
}
raw, err := os.ReadFile(filepath.Join(procRoot, e.Name(), "cmdline"))
if err != nil || len(raw) == 0 {
continue
}
if isSpxNATSService(bytes.Split(bytes.TrimRight(raw, "\x00"), []byte{0})) {
return true, nil
}
}
return false, nil
}
// isSpxNATSService matches `spx service nats` with any leading path on spx.
func isSpxNATSService(argv [][]byte) bool {
return len(argv) >= 3 &&
filepath.Base(string(argv[0])) == "spx" &&
string(argv[1]) == "service" &&
string(argv[2]) == "nats"
}
// errNATSRunning is returned whenever a store would be discarded with the NATS
// service still up. No flag overrides it: the wipe would race the live server.
var errNATSRunning = errors.New("the NATS service is still running on this node; stop it first (sudo systemctl stop spinifex.target, then confirm `pgrep -af 'spx service'` prints nothing)")
// checkStoreDiscardable fails if the NATS service is running locally.
func checkStoreDiscardable() error {
running, err := natsRunningLocally()
if err != nil {
return err
}
if running {
return errNATSRunning
}
return nil
}
// discardJetStreamStore removes a node's JetStream store before it enters a
// multi-node cluster. nats-server adopts a same-named local stream and raft
// catch-up never repairs the prefix it already holds.
func discardJetStreamStore(storeDir string) error {
if err := checkStoreDiscardable(); err != nil {
return err
}
if err := os.RemoveAll(storeDir); err != nil {
return fmt.Errorf("remove JetStream store %s: %w", storeDir, err)
}
if _, err := os.Stat(storeDir); !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("JetStream store %s still present after removal", storeDir)
}
return nil
}
// checkInitJetStreamStore refuses a multi-node init over a store holding streams
// when discard was turned off. Keeping it as a seed is not safe: leadership
// ignores who holds data, and unassigned local streams are deleted as orphans.
func checkInitJetStreamStore(storeDir string, nodes int, discard bool) error {
if nodes < 2 {
return nil
}
streams, err := localStreams(storeDir)
if err != nil {
return err
}
if len(streams) == 0 {
return nil
}
if !discard {
return fmt.Errorf("this node's JetStream store %s holds %d stream(s) from before formation; "+
"a multi-node cluster cannot adopt them consistently. "+
"re-run without --discard-jetstream=false to remove them once every node has joined", storeDir, len(streams))
}
return checkStoreDiscardable()
}
+140
View File
@@ -0,0 +1,140 @@
//test:in-package: swaps the unexported procRoot for a fake /proc tree and asserts on
// the store helpers the init and join commands call.
package cmd
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// withFakeProc points the process scan at a temp tree holding the given
// pid -> cmdline entries (NUL-separated argv) for the duration of the test.
func withFakeProc(t *testing.T, procs map[string]string) {
t.Helper()
root := t.TempDir()
for pid, cmdline := range procs {
dir := filepath.Join(root, pid)
require.NoError(t, os.MkdirAll(dir, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dir, "cmdline"), []byte(cmdline), 0o644))
}
prev := procRoot
procRoot = root
t.Cleanup(func() { procRoot = prev })
}
// seedStore builds a JetStream store with the named streams under account $G,
// laid out the way nats-server writes it, and returns the store path.
func seedStore(t *testing.T, streams ...string) string {
t.Helper()
store := filepath.Join(t.TempDir(), "nats", "jetstream")
for _, s := range streams {
dir := filepath.Join(store, "$G", "streams", s, "msgs")
require.NoError(t, os.MkdirAll(dir, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(dir, "1.blk"), []byte("x"), 0o600))
}
return store
}
func TestJetStreamStoreDir(t *testing.T) {
assert.Equal(t, "/var/lib/spinifex/nats/jetstream", jetStreamStoreDir("/var/lib/spinifex"))
}
func TestLocalStreams(t *testing.T) {
t.Run("missing store holds nothing", func(t *testing.T) {
got, err := localStreams(filepath.Join(t.TempDir(), "absent"))
require.NoError(t, err)
assert.Empty(t, got)
})
t.Run("lists streams across accounts, sorted", func(t *testing.T) {
store := seedStore(t, "KV_b", "KV_a")
require.NoError(t, os.MkdirAll(filepath.Join(store, "$SYS", "_js_", "_meta_"), 0o755))
require.NoError(t, os.WriteFile(filepath.Join(store, "$G", "streams", "stray-file"), nil, 0o600))
got, err := localStreams(store)
require.NoError(t, err)
assert.Equal(t, []string{"$G/KV_a", "$G/KV_b"}, got)
})
}
func TestNATSRunningLocally(t *testing.T) {
tests := []struct {
name string
procs map[string]string
want bool
}{
{"no processes", nil, false},
{"installed service", map[string]string{"10": "/usr/local/bin/spx\x00service\x00nats\x00start\x00"}, true},
{"bare spx on PATH", map[string]string{"10": "spx\x00service\x00nats\x00start\x00"}, true},
// The join itself is an spx process and must not count.
{"spx admin join", map[string]string{"10": "spx\x00admin\x00join\x00--force\x00"}, false},
{"other spx service", map[string]string{"10": "spx\x00service\x00predastore\x00start\x00"}, false},
{"nats-server binary", map[string]string{"10": "nats-server\x00service\x00nats\x00"}, false},
{"kernel thread, empty cmdline", map[string]string{"2": ""}, false},
{"non-pid entry ignored", map[string]string{"self": "spx\x00service\x00nats\x00"}, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
withFakeProc(t, tt.procs)
got, err := natsRunningLocally()
require.NoError(t, err)
assert.Equal(t, tt.want, got)
})
}
}
func TestDiscardJetStreamStore(t *testing.T) {
t.Run("removes the store", func(t *testing.T) {
withFakeProc(t, nil)
store := seedStore(t, "KV_a")
require.NoError(t, discardJetStreamStore(store))
_, err := os.Stat(store)
assert.ErrorIs(t, err, os.ErrNotExist)
})
t.Run("missing store is not an error", func(t *testing.T) {
withFakeProc(t, nil)
assert.NoError(t, discardJetStreamStore(filepath.Join(t.TempDir(), "absent")))
})
t.Run("refuses under a running NATS and leaves the store", func(t *testing.T) {
withFakeProc(t, map[string]string{"7": "spx\x00service\x00nats\x00start\x00"})
store := seedStore(t, "KV_a")
assert.ErrorIs(t, discardJetStreamStore(store), errNATSRunning)
_, err := os.Stat(store)
assert.NoError(t, err)
})
}
func TestCheckInitJetStreamStore(t *testing.T) {
withFakeProc(t, nil)
t.Run("single-node init keeps its store", func(t *testing.T) {
assert.NoError(t, checkInitJetStreamStore(seedStore(t, "KV_a"), 1, false))
})
t.Run("multi-node init over an empty store", func(t *testing.T) {
assert.NoError(t, checkInitJetStreamStore(filepath.Join(t.TempDir(), "absent"), 3, false))
})
t.Run("multi-node init over streams is refused without the flag", func(t *testing.T) {
err := checkInitJetStreamStore(seedStore(t, "KV_a", "KV_b"), 4, false)
require.Error(t, err)
assert.Contains(t, err.Error(), "2 stream(s)")
assert.Contains(t, err.Error(), "--discard-jetstream")
})
t.Run("multi-node init over streams proceeds with the flag", func(t *testing.T) {
assert.NoError(t, checkInitJetStreamStore(seedStore(t, "KV_a"), 4, true))
})
t.Run("flag does not override a running NATS", func(t *testing.T) {
withFakeProc(t, map[string]string{"7": "spx\x00service\x00nats\x00start\x00"})
assert.ErrorIs(t, checkInitJetStreamStore(seedStore(t, "KV_a"), 4, true), errNATSRunning)
})
}
@@ -5,7 +5,6 @@ import (
"testing"
"github.com/mulgadc/spinifex/spinifex/ebsmetadata"
handlers_ec2_snapshot "github.com/mulgadc/spinifex/spinifex/handlers/ec2/snapshot"
"github.com/mulgadc/spinifex/spinifex/objectstore"
"github.com/mulgadc/spinifex/spinifex/utils"
"github.com/stretchr/testify/assert"
@@ -29,7 +28,7 @@ func TestRegisterImportedAMISnapshot_WritesEC2ReadableMetadata(t *testing.T) {
require.NoError(t, registerImportedAMISnapshot(store, bucket, ami, "ap-southeast-2a", true))
cfg, err := handlers_ec2_snapshot.ReadSnapshotConfig(context.Background(), store, bucket, "snap-ami-import01")
cfg, err := ebsmetadata.NewStore(store, bucket).GetSnapshot(context.Background(), utils.GlobalAccountID, "snap-ami-import01")
require.NoError(t, err)
assert.Equal(t, "snap-ami-import01", cfg.SnapshotID)
assert.Equal(t, "ami-import01", cfg.VolumeID, "the import path creates the volume under the AMI's own ID")
@@ -50,7 +49,7 @@ func TestRegisterImportedAMISnapshot_UnencryptedVolume(t *testing.T) {
require.NoError(t, registerImportedAMISnapshot(store, "predastore", ami, "ap-southeast-2a", false))
cfg, err := handlers_ec2_snapshot.ReadSnapshotConfig(context.Background(), store, "predastore", "snap-ami-plain01")
cfg, err := ebsmetadata.NewStore(store, "predastore").GetSnapshot(context.Background(), utils.GlobalAccountID, "snap-ami-plain01")
require.NoError(t, err)
assert.False(t, cfg.Encrypted)
}
+16 -6
View File
@@ -386,6 +386,7 @@ var viperblockStartCmd = &cobra.Command{
NodeName: clusterConfig.Node,
ShardWAL: shardWAL,
GCEnabled: gcEnabled,
WALBaseDir: nodeConfig.Viperblock.WALBaseDir,
EncryptionKeyFile: encryptionKeyFile,
Threads: nodeConfig.EBS.ResolvedThreads(),
CacheSizeMB: nodeConfig.EBS.ResolvedCacheSizeMB(),
@@ -840,6 +841,7 @@ var spinifexUIStartCmd = &cobra.Command{
tlsCert := viper.GetString("spinifex-ui-tls-cert")
tlsKey := viper.GetString("spinifex-ui-tls-key")
baseDir := viper.GetString("spinifex-ui-base-dir")
ochreEnabled := viper.GetBool("spinifex-ui-ochre-enabled")
cfg, err := loadLocalConfig()
if err != nil {
@@ -853,12 +855,13 @@ var spinifexUIStartCmd = &cobra.Command{
defer initTelemetry("spinifex-ui", false)()
svc, err := service.New("spinifex-ui", &spinifexui.Config{
Port: port,
Host: host,
TLSCert: tlsCert,
TLSKey: tlsKey,
BaseDir: baseDir,
Region: region,
Port: port,
Host: host,
TLSCert: tlsCert,
TLSKey: tlsKey,
BaseDir: baseDir,
Region: region,
OchreEnabled: ochreEnabled,
})
if err != nil {
@@ -1012,6 +1015,7 @@ var vpcdStartCmd = &cobra.Command{
OVNNBAddr: nodeConfig.VPCD.OVNNBAddr,
OVNSBAddr: nodeConfig.VPCD.OVNSBAddr,
BaseDir: nodeConfig.BaseDir,
DataDir: nodeConfig.DataDir,
Debug: false,
ExternalMode: clusterConfig.Network.ExternalMode,
ExternalPools: extPools,
@@ -1025,6 +1029,8 @@ var vpcdStartCmd = &cobra.Command{
NATExemptCIDRs: clusterConfig.Network.NATExemptCIDRs,
IPSecEnabled: clusterConfig.Network.IPSecEnabled,
UnderlayMTU: clusterConfig.Network.UnderlayMTU,
BlockedWANPorts: clusterConfig.Network.ResolvedBlockedWANPorts(),
EgressBlockExemptVPCs: clusterConfig.Network.EgressBlockExemptVPCs,
})
if err != nil {
return fmt.Errorf("create vpcd service: %w", err)
@@ -1535,6 +1541,10 @@ func init() {
viper.BindEnv("spinifex-ui-base-dir", "SPINIFEX_UI_BASE_DIR")
viper.BindPFlag("spinifex-ui-base-dir", spinifexUICmd.PersistentFlags().Lookup("base-dir"))
spinifexUICmd.PersistentFlags().Bool("ochre-enabled", false, "enable the Ochre console")
viper.BindEnv("spinifex-ui-ochre-enabled", "SPINIFEX_UI_OCHRE_ENABLED")
viper.BindPFlag("spinifex-ui-ochre-enabled", spinifexUICmd.PersistentFlags().Lookup("ochre-enabled"))
spinifexUICmd.AddCommand(spinifexUIStartCmd)
spinifexUICmd.AddCommand(spinifexUIStopCmd)
spinifexUICmd.AddCommand(spinifexUIStatusCmd)
+22
View File
@@ -38,3 +38,25 @@ secret_key = "{{.NorthstarSecretKey}}"
# Forwarders for non-authoritative (recursive) queries. Empty => REFUSED.
[upstream]
nameservers = [{{range $i, $s := .PoolDNSServers}}{{if $i}}, {{end}}"{{$s}}:53"{{end}}]
# Who may have a query forwarded to those nameservers. Authoritative answers are
# never gated by this — a public authoritative server must answer everyone.
#
# allow_recursion = true serves recursion to ANY client, including the whole
# internet, because :53 above is bound to the node's public address. Do not set
# it on a node reachable from the internet. An open resolver is found by scanners
# within hours and is then used as a DDoS reflector, which gets the node's whole
# address range blocklisted — an outage for every tenant on it, and slow to undo.
# It is here for an isolated or lab deployment where the DNS port is not exposed.
#
# Trusted clients do not need it. List them in allow_recursion_from instead,
# which is unaffected by allow_recursion and is how the cluster resolves:
# - Cluster nodes resolve through northstar (resolv.conf points at the node's
# own advertised address, so the query arrives from that public IP over lo,
# not from 127.0.0.1).
# - EC2 instances reach vpcd's per-tap DNS shim on 169.254.169.253, which
# relays to northstar's :5300 from a node address — so listing the nodes
# covers every guest, and guest addresses never appear here.
# Loopback is always allowed and does not need listing.
allow_recursion = false
allow_recursion_from = [{{range $i, $s := .ResolverAllowFrom}}{{if $i}}, {{end}}"{{$s}}"{{end}}]
@@ -36,6 +36,7 @@ data_dir = "{{$.PredastoreDataDir}}"
tls_cert = "{{$.ConfigDir}}/server.pem"
tls_key = "{{$.ConfigDir}}/server.key"
encryption_key = "{{$.ConfigDir}}/predastore/encryption.key"
admin_port = {{$.AdminPort}}
{{- range $.ClusterNodes}}
{{- if eq .HostID $host.ID}}
@@ -32,6 +32,7 @@ data_dir = "{{.PredastoreDataDir}}"
tls_cert = "{{.ConfigDir}}/server.pem"
tls_key = "{{.ConfigDir}}/server.key"
encryption_key = "{{.ConfigDir}}/predastore/encryption.key"
admin_port = {{.PredastoreAdminPort}}
# The gate serves the S3 API. Without one the host answers no S3 request.
# S3 is the public plane, so it binds every interface while the host's
+15
View File
@@ -14,6 +14,13 @@ internal_suffix = "spinifex.internal"
[network]
ipsec_enabled = {{.IPSecEnabled}}
# Outbound TCP ports blocked for guest egress to public destinations, like AWS's
# default outbound-SMTP block. Omit the key for the default [25, 465, 587]; set
# [] to disable. Private destinations are always exempt.
# blocked_ports_wan = [25, 465, 587]
# VPC IDs exempted from the block — the operator workaround for a tenant that
# legitimately needs to send mail, until per-account exceptions exist.
# egress_block_exempt_vpcs = ["vpc-..."]
{{- if .ExternalMode}}
external_mode = "{{.ExternalMode}}"
{{- range .Pools}}
@@ -111,6 +118,14 @@ token = "{{.NatsToken}}"
[nodes.{{.Node}}.viperblock]
shardwal = false
gc_enabled = true
#
# wal_base_dir: put every volume's WAL under this directory instead of the
# node's base_dir. A WAL fsync shares the device queue with predastore, the
# chunk cache and every other volume on the node, so a busy node lengthens the
# fsync tail that a datastore guest measures as its commit latency. Pointing
# this at its own device keeps that traffic out of the WAL's queue. Unset
# keeps the WAL under base_dir.
#wal_base_dir = "/var/lib/spinifex-wal"
{{- if .EncryptionKeyFile}}
encryption_key_file = "{{.EncryptionKeyFile}}"
{{- end}}
+1 -1
View File
@@ -1,8 +1,8 @@
package main
import (
_ "github.com/mulgadc/bluebottle/pkg/fipsboot"
"github.com/mulgadc/spinifex/cmd/spinifex/cmd"
_ "github.com/mulgadc/spinifex/internal/fipsboot"
)
func main() {
+2
View File
@@ -38,6 +38,8 @@ import (
"time"
"github.com/mulgadc/spinifex/spinifex/loadgen"
_ "github.com/mulgadc/bluebottle/pkg/fipsboot"
)
func main() {
+96 -1025
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -2,7 +2,9 @@
This directory contains the Spinifex documentation. This is available in individual markdown files, and a generated website to aid navigation and browsing documentation at: [https://docs.mulgadc.com](https://docs.mulgadc.com/)
The [AWS model operation coverage](aws-model-operation-coverage.md) report is generated from Spinifex's dispatch tables and the pinned AWS API models. Run `make aws-model-coverage` to regenerate and print it.
The [AWS API coverage](coverage/README.md) pages are generated from Spinifex's dispatch tables and the pinned AWS API models, one page per service. Run `make aws-model-coverage` to regenerate them and print a summary. Only `pages.json` and the `intro.md` files under `coverage/` are hand-written; every `README.md` there is generated and will be overwritten.
A published page names the operations Spinifex serves, and nothing else. It carries no percentage, no unimplemented operation and no operation the platform will never offer: the same run writes those to [`coverage/internal-report.md`](coverage/internal-report.md), which no slug in the docs site's configuration points at, so it stays internal. A `notApplicable` declaration in `pages.json` therefore publishes nothing; it keeps a permanent absence out of the report's candidate work.
## Frontmatter
+8 -7
View File
@@ -314,15 +314,16 @@ On first load, your browser will show a TLS warning — Spinifex generates a sel
1. Accept the warning to reach the login page (exact wording varies by browser — e.g. Chrome: *Advanced → Proceed to ...*, Firefox: *Advanced → Accept the Risk and Continue*).
2. On the login page, click **Download Certificate** and save `spinifex-ca.pem` to your machine.
3. Install the certificate as a **trusted root** on your workstation:
- **macOS:** open `spinifex-ca.pem` in Keychain Access → *System* keychain → set *Trust* to **Always Trust**.
- **Linux:** `sudo cp spinifex-ca.pem /usr/local/share/ca-certificates/spinifex-ca.crt && sudo update-ca-certificates`
- **Windows:** double-click the file → *Install Certificate* → *Local Machine* → *Trusted Root Certification Authorities*.
- **Browser-only (Firefox):** *Settings → Privacy & Security → Certificates → View Certificates → Authorities → Import* and tick *Trust this CA to identify websites*.
3. Install the certificate as a **trusted root** on your workstation, following the steps for your platform below.
4. Restart your browser and reload `https://YOUR_NODE_IP:3000`. The padlock should now show a valid certificate.
Step 3, by platform:
- **macOS:** open `spinifex-ca.pem` in Keychain Access → *System* keychain → set *Trust* to **Always Trust**.
- **Linux:** `sudo cp spinifex-ca.pem /usr/local/share/ca-certificates/spinifex-ca.crt && sudo update-ca-certificates`
- **Windows:** double-click the file → *Install Certificate* → *Local Machine* → *Trusted Root Certification Authorities*.
- **Browser-only (Firefox):** *Settings → Privacy & Security → Certificates → View Certificates → Authorities → Import* and tick *Trust this CA to identify websites*.
> **Why this is required:** the UI logs in by reading your AWS credentials through a trusted TLS channel. Browsers refuse to send credentials over an untrusted connection, so the certificate must be installed as trusted — temporary "Proceed anyway" exceptions won't work for login.
### Log In with AWS Credentials
-14
View File
@@ -37,20 +37,6 @@ For operators who want to review migrations before they are applied, a manual up
> [!WARNING]
> **Swapping the `spx` binary alone is not an upgrade.** Systemd unit files (`KillMode`, `TimeoutStopSec`, drain ordering, and similar) are written once at install time and never re-asserted just because a new binary is in place — a node "upgraded" by replacing `/usr/local/bin/spx` directly keeps running whatever units it was first installed with, which can silently disagree with the new binary's behaviour. Re-running the installer always reinstalls units unconditionally, so it is unaffected. `spx admin upgrade` now reconciles units too, so it closes this gap for operators who update the binary by hand. See [Checking for Unit Drift](#checking-for-unit-drift).
> [!WARNING]
> **Object storage is exempt when upgrading from v1.15.0 or earlier to v1.16.0.** Predastore's configuration schema and on-disk layout changed with the object storage cutover in v1.16.0, and no migration converts an installation from before it. `spx admin upgrade` will not report anything pending for `predastore.toml`, and updating the binary over such an installation leaves Predastore unable to start. Those clusters have to be re-initialised from scratch, which discards their stored objects — export anything you need first. Upgrades between v1.16.0 and later releases are unaffected.
> [!WARNING]
> **AMI metadata is exempt when upgrading from v1.16.0 or earlier to the release carrying the EBS-provider decoupling.** AMI metadata moved to `ebsmetadata` documents, and the legacy path that read it from `ami-<id>/config.json` was removed rather than migrated. There is no prefix scan, no fallback and no backfill at daemon start, so an AMI imported before the change becomes invisible to the control plane afterwards: `describe-images --image-ids` answers `InvalidAMIID.NotFound` and launches fail with `AMI has no snapshot ID, cannot perform zero-copy clone`. As with object storage above, `spx admin upgrade` reports nothing pending, because the gap is in stored data rather than in a config file.
>
> **Re-import affected AMIs after upgrading**, using [`spx admin images import`](/docs/spinifex-admin-cli), which writes metadata in the new location. If the source images are no longer available, the installation has to be re-initialised. Verify before you rely on it — an AMI is only healthy if it resolves by ID, not merely if it appears in the list:
>
> ```bash
> aws ec2 describe-images --image-ids <ami-id>
> ```
>
> This warning covers AMI metadata specifically, which is what has been observed. Check any other imported state you depend on before upgrading a cluster you cannot rebuild.
## Instructions
## Step 1. Re-run the Installer
-577
View File
@@ -1,577 +0,0 @@
# AWS model operation coverage
Generated from the cached `aws-sdk-go v1.55.8` `api-2.json` models and Spinifex's authoritative gateway dispatch tables.
Implemented means a modelled operation is registered to a real handler. Stub and deliberately unsupported handlers are reported separately. Shape conformance does not imply behavioural conformance.
| Service | API version | Modelled | Registered | Implemented | Stubbed | Unsupported | Missing | Extra |
|---|---:|---:|---:|---:|---:|---:|---:|---:|
| acm | 2015-12-08 | 15 | 9 | 9 | 0 | 0 | 6 | 0 |
| ec2 | 2016-11-15 | 625 | 119 | 119 | 0 | 0 | 506 | 0 |
| ecr | 2015-09-21 | 47 | 39 | 19 | 13 | 7 | 11 | 3 |
| ecs | 2014-11-13 | 56 | 39 | 31 | 5 | 0 | 20 | 3 |
| elasticloadbalancingv2 | 2015-12-01 | 46 | 37 | 33 | 0 | 0 | 13 | 4 |
| iam | 2010-05-08 | 159 | 75 | 75 | 0 | 0 | 84 | 0 |
| rds | 2014-10-31 | 162 | 43 | 26 | 0 | 12 | 124 | 5 |
| s3 | 2006-03-01 | 99 | — | — | — | — | — | — |
| sts | 2011-06-15 | 8 | 4 | 4 | 0 | 0 | 4 | 0 |
## acm
Implements **9 of 15** modelled operations (60.0%).
<details><summary>Implemented (9)</summary>
`AddTagsToCertificate`, `DeleteCertificate`, `DescribeCertificate`, `GetCertificate`, `ImportCertificate`, `ListCertificates`, `ListTagsForCertificate`, `RemoveTagsFromCertificate`
`RequestCertificate`
</details>
<details><summary>Missing from dispatch (6)</summary>
`ExportCertificate`, `GetAccountConfiguration`, `PutAccountConfiguration`, `RenewCertificate`, `ResendValidationEmail`, `UpdateCertificateOptions`
</details>
<details><summary>Registered stubs (0)</summary>
None.
</details>
<details><summary>Deliberately unsupported (0)</summary>
None.
</details>
<details><summary>Registered outside the pinned model (0)</summary>
None.
</details>
## ec2
Implements **119 of 625** modelled operations (19.0%).
<details><summary>Implemented (119)</summary>
`AllocateAddress`, `AssociateAddress`, `AssociateIamInstanceProfile`, `AssociateRouteTable`, `AttachInternetGateway`, `AttachNetworkInterface`, `AttachVolume`, `AuthorizeSecurityGroupEgress`
`AuthorizeSecurityGroupIngress`, `CancelCapacityReservation`, `CancelSpotInstanceRequests`, `CopyImage`, `CopySnapshot`, `CreateCapacityReservation`, `CreateEgressOnlyInternetGateway`, `CreateImage`
`CreateInternetGateway`, `CreateKeyPair`, `CreateLaunchTemplate`, `CreateLaunchTemplateVersion`, `CreateNatGateway`, `CreateNetworkInterface`, `CreatePlacementGroup`, `CreateRoute`
`CreateRouteTable`, `CreateSecurityGroup`, `CreateSnapshot`, `CreateSubnet`, `CreateTags`, `CreateVolume`, `CreateVpc`, `DeleteEgressOnlyInternetGateway`
`DeleteInternetGateway`, `DeleteKeyPair`, `DeleteLaunchTemplate`, `DeleteLaunchTemplateVersions`, `DeleteNatGateway`, `DeleteNetworkInterface`, `DeletePlacementGroup`, `DeleteRoute`
`DeleteRouteTable`, `DeleteSecurityGroup`, `DeleteSnapshot`, `DeleteSubnet`, `DeleteTags`, `DeleteVolume`, `DeleteVpc`, `DeregisterImage`
`DescribeAccountAttributes`, `DescribeAddresses`, `DescribeAddressesAttribute`, `DescribeAvailabilityZones`, `DescribeCapacityReservations`, `DescribeEgressOnlyInternetGateways`, `DescribeIamInstanceProfileAssociations`, `DescribeImageAttribute`
`DescribeImages`, `DescribeInstanceAttribute`, `DescribeInstanceCreditSpecifications`, `DescribeInstanceStatus`, `DescribeInstanceTypes`, `DescribeInstances`, `DescribeInternetGateways`, `DescribeKeyPairs`
`DescribeLaunchTemplateVersions`, `DescribeLaunchTemplates`, `DescribeNatGateways`, `DescribeNetworkInterfaces`, `DescribePlacementGroups`, `DescribeRegions`, `DescribeRouteTables`, `DescribeSecurityGroupRules`
`DescribeSecurityGroups`, `DescribeSnapshots`, `DescribeSpotInstanceRequests`, `DescribeSubnets`, `DescribeTags`, `DescribeVolumeStatus`, `DescribeVolumes`, `DescribeVolumesModifications`
`DescribeVpcAttribute`, `DescribeVpcs`, `DetachInternetGateway`, `DetachNetworkInterface`, `DetachVolume`, `DisableEbsEncryptionByDefault`, `DisableSerialConsoleAccess`, `DisassociateAddress`
`DisassociateIamInstanceProfile`, `DisassociateRouteTable`, `EnableEbsEncryptionByDefault`, `EnableSerialConsoleAccess`, `GetConsoleOutput`, `GetEbsEncryptionByDefault`, `GetPasswordData`, `GetSerialConsoleAccessStatus`
`ImportKeyPair`, `ModifyImageAttribute`, `ModifyInstanceAttribute`, `ModifyInstanceMetadataOptions`, `ModifyLaunchTemplate`, `ModifyNetworkInterfaceAttribute`, `ModifySubnetAttribute`, `ModifyVolume`
`ModifyVpcAttribute`, `RebootInstances`, `RegisterImage`, `ReleaseAddress`, `ReplaceIamInstanceProfileAssociation`, `ReplaceRoute`, `ReplaceRouteTableAssociation`, `RequestSpotInstances`
`ResetImageAttribute`, `RevokeSecurityGroupEgress`, `RevokeSecurityGroupIngress`, `RunInstances`, `StartInstances`, `StopInstances`, `TerminateInstances`
</details>
<details><summary>Missing from dispatch (506)</summary>
`AcceptAddressTransfer`, `AcceptReservedInstancesExchangeQuote`, `AcceptTransitGatewayMulticastDomainAssociations`, `AcceptTransitGatewayPeeringAttachment`, `AcceptTransitGatewayVpcAttachment`, `AcceptVpcEndpointConnections`, `AcceptVpcPeeringConnection`, `AdvertiseByoipCidr`
`AllocateHosts`, `AllocateIpamPoolCidr`, `ApplySecurityGroupsToClientVpnTargetNetwork`, `AssignIpv6Addresses`, `AssignPrivateIpAddresses`, `AssignPrivateNatGatewayAddress`, `AssociateClientVpnTargetNetwork`, `AssociateDhcpOptions`
`AssociateEnclaveCertificateIamRole`, `AssociateInstanceEventWindow`, `AssociateIpamByoasn`, `AssociateIpamResourceDiscovery`, `AssociateNatGatewayAddress`, `AssociateSubnetCidrBlock`, `AssociateTransitGatewayMulticastDomain`, `AssociateTransitGatewayPolicyTable`
`AssociateTransitGatewayRouteTable`, `AssociateTrunkInterface`, `AssociateVpcCidrBlock`, `AttachClassicLinkVpc`, `AttachVerifiedAccessTrustProvider`, `AttachVpnGateway`, `AuthorizeClientVpnIngress`, `BundleInstance`
`CancelBundleTask`, `CancelCapacityReservationFleets`, `CancelConversionTask`, `CancelExportTask`, `CancelImageLaunchPermission`, `CancelImportTask`, `CancelReservedInstancesListing`, `CancelSpotFleetRequests`
`ConfirmProductInstance`, `CopyFpgaImage`, `CreateCapacityReservationFleet`, `CreateCarrierGateway`, `CreateClientVpnEndpoint`, `CreateClientVpnRoute`, `CreateCoipCidr`, `CreateCoipPool`
`CreateCustomerGateway`, `CreateDefaultSubnet`, `CreateDefaultVpc`, `CreateDhcpOptions`, `CreateFleet`, `CreateFlowLogs`, `CreateFpgaImage`, `CreateInstanceConnectEndpoint`
`CreateInstanceEventWindow`, `CreateInstanceExportTask`, `CreateIpam`, `CreateIpamExternalResourceVerificationToken`, `CreateIpamPool`, `CreateIpamResourceDiscovery`, `CreateIpamScope`, `CreateLocalGatewayRoute`
`CreateLocalGatewayRouteTable`, `CreateLocalGatewayRouteTableVirtualInterfaceGroupAssociation`, `CreateLocalGatewayRouteTableVpcAssociation`, `CreateManagedPrefixList`, `CreateNetworkAcl`, `CreateNetworkAclEntry`, `CreateNetworkInsightsAccessScope`, `CreateNetworkInsightsPath`
`CreateNetworkInterfacePermission`, `CreatePublicIpv4Pool`, `CreateReplaceRootVolumeTask`, `CreateReservedInstancesListing`, `CreateRestoreImageTask`, `CreateSnapshots`, `CreateSpotDatafeedSubscription`, `CreateStoreImageTask`
`CreateSubnetCidrReservation`, `CreateTrafficMirrorFilter`, `CreateTrafficMirrorFilterRule`, `CreateTrafficMirrorSession`, `CreateTrafficMirrorTarget`, `CreateTransitGateway`, `CreateTransitGatewayConnect`, `CreateTransitGatewayConnectPeer`
`CreateTransitGatewayMulticastDomain`, `CreateTransitGatewayPeeringAttachment`, `CreateTransitGatewayPolicyTable`, `CreateTransitGatewayPrefixListReference`, `CreateTransitGatewayRoute`, `CreateTransitGatewayRouteTable`, `CreateTransitGatewayRouteTableAnnouncement`, `CreateTransitGatewayVpcAttachment`
`CreateVerifiedAccessEndpoint`, `CreateVerifiedAccessGroup`, `CreateVerifiedAccessInstance`, `CreateVerifiedAccessTrustProvider`, `CreateVpcEndpoint`, `CreateVpcEndpointConnectionNotification`, `CreateVpcEndpointServiceConfiguration`, `CreateVpcPeeringConnection`
`CreateVpnConnection`, `CreateVpnConnectionRoute`, `CreateVpnGateway`, `DeleteCarrierGateway`, `DeleteClientVpnEndpoint`, `DeleteClientVpnRoute`, `DeleteCoipCidr`, `DeleteCoipPool`
`DeleteCustomerGateway`, `DeleteDhcpOptions`, `DeleteFleets`, `DeleteFlowLogs`, `DeleteFpgaImage`, `DeleteInstanceConnectEndpoint`, `DeleteInstanceEventWindow`, `DeleteIpam`
`DeleteIpamExternalResourceVerificationToken`, `DeleteIpamPool`, `DeleteIpamResourceDiscovery`, `DeleteIpamScope`, `DeleteLocalGatewayRoute`, `DeleteLocalGatewayRouteTable`, `DeleteLocalGatewayRouteTableVirtualInterfaceGroupAssociation`, `DeleteLocalGatewayRouteTableVpcAssociation`
`DeleteManagedPrefixList`, `DeleteNetworkAcl`, `DeleteNetworkAclEntry`, `DeleteNetworkInsightsAccessScope`, `DeleteNetworkInsightsAccessScopeAnalysis`, `DeleteNetworkInsightsAnalysis`, `DeleteNetworkInsightsPath`, `DeleteNetworkInterfacePermission`
`DeletePublicIpv4Pool`, `DeleteQueuedReservedInstances`, `DeleteSpotDatafeedSubscription`, `DeleteSubnetCidrReservation`, `DeleteTrafficMirrorFilter`, `DeleteTrafficMirrorFilterRule`, `DeleteTrafficMirrorSession`, `DeleteTrafficMirrorTarget`
`DeleteTransitGateway`, `DeleteTransitGatewayConnect`, `DeleteTransitGatewayConnectPeer`, `DeleteTransitGatewayMulticastDomain`, `DeleteTransitGatewayPeeringAttachment`, `DeleteTransitGatewayPolicyTable`, `DeleteTransitGatewayPrefixListReference`, `DeleteTransitGatewayRoute`
`DeleteTransitGatewayRouteTable`, `DeleteTransitGatewayRouteTableAnnouncement`, `DeleteTransitGatewayVpcAttachment`, `DeleteVerifiedAccessEndpoint`, `DeleteVerifiedAccessGroup`, `DeleteVerifiedAccessInstance`, `DeleteVerifiedAccessTrustProvider`, `DeleteVpcEndpointConnectionNotifications`
`DeleteVpcEndpointServiceConfigurations`, `DeleteVpcEndpoints`, `DeleteVpcPeeringConnection`, `DeleteVpnConnection`, `DeleteVpnConnectionRoute`, `DeleteVpnGateway`, `DeprovisionByoipCidr`, `DeprovisionIpamByoasn`
`DeprovisionIpamPoolCidr`, `DeprovisionPublicIpv4PoolCidr`, `DeregisterInstanceEventNotificationAttributes`, `DeregisterTransitGatewayMulticastGroupMembers`, `DeregisterTransitGatewayMulticastGroupSources`, `DescribeAddressTransfers`, `DescribeAggregateIdFormat`, `DescribeAwsNetworkPerformanceMetricSubscriptions`
`DescribeBundleTasks`, `DescribeByoipCidrs`, `DescribeCapacityBlockOfferings`, `DescribeCapacityReservationFleets`, `DescribeCarrierGateways`, `DescribeClassicLinkInstances`, `DescribeClientVpnAuthorizationRules`, `DescribeClientVpnConnections`
`DescribeClientVpnEndpoints`, `DescribeClientVpnRoutes`, `DescribeClientVpnTargetNetworks`, `DescribeCoipPools`, `DescribeConversionTasks`, `DescribeCustomerGateways`, `DescribeDhcpOptions`, `DescribeElasticGpus`
`DescribeExportImageTasks`, `DescribeExportTasks`, `DescribeFastLaunchImages`, `DescribeFastSnapshotRestores`, `DescribeFleetHistory`, `DescribeFleetInstances`, `DescribeFleets`, `DescribeFlowLogs`
`DescribeFpgaImageAttribute`, `DescribeFpgaImages`, `DescribeHostReservationOfferings`, `DescribeHostReservations`, `DescribeHosts`, `DescribeIdFormat`, `DescribeIdentityIdFormat`, `DescribeImportImageTasks`
`DescribeImportSnapshotTasks`, `DescribeInstanceConnectEndpoints`, `DescribeInstanceEventNotificationAttributes`, `DescribeInstanceEventWindows`, `DescribeInstanceTopology`, `DescribeInstanceTypeOfferings`, `DescribeIpamByoasn`, `DescribeIpamExternalResourceVerificationTokens`
`DescribeIpamPools`, `DescribeIpamResourceDiscoveries`, `DescribeIpamResourceDiscoveryAssociations`, `DescribeIpamScopes`, `DescribeIpams`, `DescribeIpv6Pools`, `DescribeLocalGatewayRouteTableVirtualInterfaceGroupAssociations`, `DescribeLocalGatewayRouteTableVpcAssociations`
`DescribeLocalGatewayRouteTables`, `DescribeLocalGatewayVirtualInterfaceGroups`, `DescribeLocalGatewayVirtualInterfaces`, `DescribeLocalGateways`, `DescribeLockedSnapshots`, `DescribeMacHosts`, `DescribeManagedPrefixLists`, `DescribeMovingAddresses`
`DescribeNetworkAcls`, `DescribeNetworkInsightsAccessScopeAnalyses`, `DescribeNetworkInsightsAccessScopes`, `DescribeNetworkInsightsAnalyses`, `DescribeNetworkInsightsPaths`, `DescribeNetworkInterfaceAttribute`, `DescribeNetworkInterfacePermissions`, `DescribePrefixLists`
`DescribePrincipalIdFormat`, `DescribePublicIpv4Pools`, `DescribeReplaceRootVolumeTasks`, `DescribeReservedInstances`, `DescribeReservedInstancesListings`, `DescribeReservedInstancesModifications`, `DescribeReservedInstancesOfferings`, `DescribeScheduledInstanceAvailability`
`DescribeScheduledInstances`, `DescribeSecurityGroupReferences`, `DescribeSnapshotAttribute`, `DescribeSnapshotTierStatus`, `DescribeSpotDatafeedSubscription`, `DescribeSpotFleetInstances`, `DescribeSpotFleetRequestHistory`, `DescribeSpotFleetRequests`
`DescribeSpotPriceHistory`, `DescribeStaleSecurityGroups`, `DescribeStoreImageTasks`, `DescribeTrafficMirrorFilterRules`, `DescribeTrafficMirrorFilters`, `DescribeTrafficMirrorSessions`, `DescribeTrafficMirrorTargets`, `DescribeTransitGatewayAttachments`
`DescribeTransitGatewayConnectPeers`, `DescribeTransitGatewayConnects`, `DescribeTransitGatewayMulticastDomains`, `DescribeTransitGatewayPeeringAttachments`, `DescribeTransitGatewayPolicyTables`, `DescribeTransitGatewayRouteTableAnnouncements`, `DescribeTransitGatewayRouteTables`, `DescribeTransitGatewayVpcAttachments`
`DescribeTransitGateways`, `DescribeTrunkInterfaceAssociations`, `DescribeVerifiedAccessEndpoints`, `DescribeVerifiedAccessGroups`, `DescribeVerifiedAccessInstanceLoggingConfigurations`, `DescribeVerifiedAccessInstances`, `DescribeVerifiedAccessTrustProviders`, `DescribeVolumeAttribute`
`DescribeVpcClassicLink`, `DescribeVpcClassicLinkDnsSupport`, `DescribeVpcEndpointConnectionNotifications`, `DescribeVpcEndpointConnections`, `DescribeVpcEndpointServiceConfigurations`, `DescribeVpcEndpointServicePermissions`, `DescribeVpcEndpointServices`, `DescribeVpcEndpoints`
`DescribeVpcPeeringConnections`, `DescribeVpnConnections`, `DescribeVpnGateways`, `DetachClassicLinkVpc`, `DetachVerifiedAccessTrustProvider`, `DetachVpnGateway`, `DisableAddressTransfer`, `DisableAwsNetworkPerformanceMetricSubscription`
`DisableFastLaunch`, `DisableFastSnapshotRestores`, `DisableImage`, `DisableImageBlockPublicAccess`, `DisableImageDeprecation`, `DisableImageDeregistrationProtection`, `DisableIpamOrganizationAdminAccount`, `DisableSnapshotBlockPublicAccess`
`DisableTransitGatewayRouteTablePropagation`, `DisableVgwRoutePropagation`, `DisableVpcClassicLink`, `DisableVpcClassicLinkDnsSupport`, `DisassociateClientVpnTargetNetwork`, `DisassociateEnclaveCertificateIamRole`, `DisassociateInstanceEventWindow`, `DisassociateIpamByoasn`
`DisassociateIpamResourceDiscovery`, `DisassociateNatGatewayAddress`, `DisassociateSubnetCidrBlock`, `DisassociateTransitGatewayMulticastDomain`, `DisassociateTransitGatewayPolicyTable`, `DisassociateTransitGatewayRouteTable`, `DisassociateTrunkInterface`, `DisassociateVpcCidrBlock`
`EnableAddressTransfer`, `EnableAwsNetworkPerformanceMetricSubscription`, `EnableFastLaunch`, `EnableFastSnapshotRestores`, `EnableImage`, `EnableImageBlockPublicAccess`, `EnableImageDeprecation`, `EnableImageDeregistrationProtection`
`EnableIpamOrganizationAdminAccount`, `EnableReachabilityAnalyzerOrganizationSharing`, `EnableSnapshotBlockPublicAccess`, `EnableTransitGatewayRouteTablePropagation`, `EnableVgwRoutePropagation`, `EnableVolumeIO`, `EnableVpcClassicLink`, `EnableVpcClassicLinkDnsSupport`
`ExportClientVpnClientCertificateRevocationList`, `ExportClientVpnClientConfiguration`, `ExportImage`, `ExportTransitGatewayRoutes`, `GetAssociatedEnclaveCertificateIamRoles`, `GetAssociatedIpv6PoolCidrs`, `GetAwsNetworkPerformanceData`, `GetCapacityReservationUsage`
`GetCoipPoolUsage`, `GetConsoleScreenshot`, `GetDefaultCreditSpecification`, `GetEbsDefaultKmsKeyId`, `GetFlowLogsIntegrationTemplate`, `GetGroupsForCapacityReservation`, `GetHostReservationPurchasePreview`, `GetImageBlockPublicAccessState`
`GetInstanceMetadataDefaults`, `GetInstanceTpmEkPub`, `GetInstanceTypesFromInstanceRequirements`, `GetInstanceUefiData`, `GetIpamAddressHistory`, `GetIpamDiscoveredAccounts`, `GetIpamDiscoveredPublicAddresses`, `GetIpamDiscoveredResourceCidrs`
`GetIpamPoolAllocations`, `GetIpamPoolCidrs`, `GetIpamResourceCidrs`, `GetLaunchTemplateData`, `GetManagedPrefixListAssociations`, `GetManagedPrefixListEntries`, `GetNetworkInsightsAccessScopeAnalysisFindings`, `GetNetworkInsightsAccessScopeContent`
`GetReservedInstancesExchangeQuote`, `GetSecurityGroupsForVpc`, `GetSnapshotBlockPublicAccessState`, `GetSpotPlacementScores`, `GetSubnetCidrReservations`, `GetTransitGatewayAttachmentPropagations`, `GetTransitGatewayMulticastDomainAssociations`, `GetTransitGatewayPolicyTableAssociations`
`GetTransitGatewayPolicyTableEntries`, `GetTransitGatewayPrefixListReferences`, `GetTransitGatewayRouteTableAssociations`, `GetTransitGatewayRouteTablePropagations`, `GetVerifiedAccessEndpointPolicy`, `GetVerifiedAccessGroupPolicy`, `GetVpnConnectionDeviceSampleConfiguration`, `GetVpnConnectionDeviceTypes`
`GetVpnTunnelReplacementStatus`, `ImportClientVpnClientCertificateRevocationList`, `ImportImage`, `ImportInstance`, `ImportSnapshot`, `ImportVolume`, `ListImagesInRecycleBin`, `ListSnapshotsInRecycleBin`
`LockSnapshot`, `ModifyAddressAttribute`, `ModifyAvailabilityZoneGroup`, `ModifyCapacityReservation`, `ModifyCapacityReservationFleet`, `ModifyClientVpnEndpoint`, `ModifyDefaultCreditSpecification`, `ModifyEbsDefaultKmsKeyId`
`ModifyFleet`, `ModifyFpgaImageAttribute`, `ModifyHosts`, `ModifyIdFormat`, `ModifyIdentityIdFormat`, `ModifyInstanceCapacityReservationAttributes`, `ModifyInstanceCreditSpecification`, `ModifyInstanceEventStartTime`
`ModifyInstanceEventWindow`, `ModifyInstanceMaintenanceOptions`, `ModifyInstanceMetadataDefaults`, `ModifyInstancePlacement`, `ModifyIpam`, `ModifyIpamPool`, `ModifyIpamResourceCidr`, `ModifyIpamResourceDiscovery`
`ModifyIpamScope`, `ModifyLocalGatewayRoute`, `ModifyManagedPrefixList`, `ModifyPrivateDnsNameOptions`, `ModifyReservedInstances`, `ModifySecurityGroupRules`, `ModifySnapshotAttribute`, `ModifySnapshotTier`
`ModifySpotFleetRequest`, `ModifyTrafficMirrorFilterNetworkServices`, `ModifyTrafficMirrorFilterRule`, `ModifyTrafficMirrorSession`, `ModifyTransitGateway`, `ModifyTransitGatewayPrefixListReference`, `ModifyTransitGatewayVpcAttachment`, `ModifyVerifiedAccessEndpoint`
`ModifyVerifiedAccessEndpointPolicy`, `ModifyVerifiedAccessGroup`, `ModifyVerifiedAccessGroupPolicy`, `ModifyVerifiedAccessInstance`, `ModifyVerifiedAccessInstanceLoggingConfiguration`, `ModifyVerifiedAccessTrustProvider`, `ModifyVolumeAttribute`, `ModifyVpcEndpoint`
`ModifyVpcEndpointConnectionNotification`, `ModifyVpcEndpointServiceConfiguration`, `ModifyVpcEndpointServicePayerResponsibility`, `ModifyVpcEndpointServicePermissions`, `ModifyVpcPeeringConnectionOptions`, `ModifyVpcTenancy`, `ModifyVpnConnection`, `ModifyVpnConnectionOptions`
`ModifyVpnTunnelCertificate`, `ModifyVpnTunnelOptions`, `MonitorInstances`, `MoveAddressToVpc`, `MoveByoipCidrToIpam`, `ProvisionByoipCidr`, `ProvisionIpamByoasn`, `ProvisionIpamPoolCidr`
`ProvisionPublicIpv4PoolCidr`, `PurchaseCapacityBlock`, `PurchaseHostReservation`, `PurchaseReservedInstancesOffering`, `PurchaseScheduledInstances`, `RegisterInstanceEventNotificationAttributes`, `RegisterTransitGatewayMulticastGroupMembers`, `RegisterTransitGatewayMulticastGroupSources`
`RejectTransitGatewayMulticastDomainAssociations`, `RejectTransitGatewayPeeringAttachment`, `RejectTransitGatewayVpcAttachment`, `RejectVpcEndpointConnections`, `RejectVpcPeeringConnection`, `ReleaseHosts`, `ReleaseIpamPoolAllocation`, `ReplaceNetworkAclAssociation`
`ReplaceNetworkAclEntry`, `ReplaceTransitGatewayRoute`, `ReplaceVpnTunnel`, `ReportInstanceStatus`, `RequestSpotFleet`, `ResetAddressAttribute`, `ResetEbsDefaultKmsKeyId`, `ResetFpgaImageAttribute`
`ResetInstanceAttribute`, `ResetNetworkInterfaceAttribute`, `ResetSnapshotAttribute`, `RestoreAddressToClassic`, `RestoreImageFromRecycleBin`, `RestoreManagedPrefixListVersion`, `RestoreSnapshotFromRecycleBin`, `RestoreSnapshotTier`
`RevokeClientVpnIngress`, `RunScheduledInstances`, `SearchLocalGatewayRoutes`, `SearchTransitGatewayMulticastGroups`, `SearchTransitGatewayRoutes`, `SendDiagnosticInterrupt`, `StartNetworkInsightsAccessScopeAnalysis`, `StartNetworkInsightsAnalysis`
`StartVpcEndpointServicePrivateDnsVerification`, `TerminateClientVpnConnections`, `UnassignIpv6Addresses`, `UnassignPrivateIpAddresses`, `UnassignPrivateNatGatewayAddress`, `UnlockSnapshot`, `UnmonitorInstances`, `UpdateSecurityGroupRuleDescriptionsEgress`
`UpdateSecurityGroupRuleDescriptionsIngress`, `WithdrawByoipCidr`
</details>
<details><summary>Registered stubs (0)</summary>
None.
</details>
<details><summary>Deliberately unsupported (0)</summary>
None.
</details>
<details><summary>Registered outside the pinned model (0)</summary>
None.
</details>
## ecr
Implements **19 of 47** modelled operations (40.4%).
<details><summary>Implemented (19)</summary>
`BatchDeleteImage`, `BatchGetImage`, `CreateRepository`, `DeleteLifecyclePolicy`, `DeleteRepository`, `DeleteRepositoryPolicy`, `DescribeImages`, `DescribeRepositories`
`GetAuthorizationToken`, `GetLifecyclePolicy`, `GetLifecyclePolicyPreview`, `GetRepositoryPolicy`, `ListImages`, `ListTagsForResource`, `PutImage`, `PutImageTagMutability`
`PutLifecyclePolicy`, `SetRepositoryPolicy`, `StartLifecyclePolicyPreview`
</details>
<details><summary>Missing from dispatch (11)</summary>
`CreatePullThroughCacheRule`, `CreateRepositoryCreationTemplate`, `DeletePullThroughCacheRule`, `DeleteRegistryPolicy`, `DeleteRepositoryCreationTemplate`, `DescribeImageReplicationStatus`, `DescribePullThroughCacheRules`, `DescribeRepositoryCreationTemplates`
`UpdatePullThroughCacheRule`, `UpdateRepositoryCreationTemplate`, `ValidatePullThroughCacheRule`
</details>
<details><summary>Registered stubs (13)</summary>
`BatchCheckLayerAvailability`, `CompleteLayerUpload`, `DescribeRegistry`, `GetDownloadUrlForLayer`, `GetRegistryPolicy`, `InitiateLayerUpload`, `ListRepositories`, `PutRegistryPolicy`
`PutReplicationConfiguration`, `ReplicateImage`, `TagResource`, `UntagResource`, `UploadLayerPart`
</details>
<details><summary>Deliberately unsupported (7)</summary>
`BatchGetRepositoryScanningConfiguration`, `DescribeImageScanFindings`, `GetImageScanningConfiguration`, `GetRegistryScanningConfiguration`, `PutImageScanningConfiguration`, `PutRegistryScanningConfiguration`, `StartImageScan`
</details>
<details><summary>Registered outside the pinned model (3)</summary>
`GetImageScanningConfiguration`, `ListRepositories`, `ReplicateImage`
</details>
## ecs
Implements **31 of 56** modelled operations (55.4%).
<details><summary>Implemented (31)</summary>
`CreateCapacityProvider`, `CreateCluster`, `CreateService`, `DeleteCapacityProvider`, `DeleteCluster`, `DeleteService`, `DeregisterContainerInstance`, `DeregisterTaskDefinition`
`DescribeCapacityProviders`, `DescribeClusters`, `DescribeContainerInstances`, `DescribeServices`, `DescribeTaskDefinition`, `DescribeTasks`, `ListClusters`, `ListContainerInstances`
`ListServices`, `ListTagsForResource`, `ListTaskDefinitions`, `ListTasks`, `PutClusterCapacityProviders`, `RegisterContainerInstance`, `RegisterTaskDefinition`, `RunTask`
`StartTask`, `StopTask`, `SubmitTaskStateChange`, `TagResource`, `UntagResource`, `UpdateContainerInstancesState`, `UpdateService`
</details>
<details><summary>Missing from dispatch (20)</summary>
`CreateTaskSet`, `DeleteAccountSetting`, `DeleteAttributes`, `DeleteTaskDefinitions`, `DeleteTaskSet`, `DescribeTaskSets`, `DiscoverPollEndpoint`, `ExecuteCommand`
`GetTaskProtection`, `ListAttributes`, `PutAccountSettingDefault`, `PutAttributes`, `SubmitAttachmentStateChanges`, `SubmitContainerStateChange`, `UpdateCapacityProvider`, `UpdateClusterSettings`
`UpdateContainerAgent`, `UpdateServicePrimaryTaskSet`, `UpdateTaskProtection`, `UpdateTaskSet`
</details>
<details><summary>Registered stubs (5)</summary>
`ListAccountSettings`, `ListServicesByNamespace`, `ListTaskDefinitionFamilies`, `PutAccountSetting`, `UpdateCluster`
</details>
<details><summary>Deliberately unsupported (0)</summary>
None.
</details>
<details><summary>Registered outside the pinned model (3)</summary>
`PollAssignments`, `ProvisionCapacity`, `ReportTaskGPU`
</details>
## elasticloadbalancingv2
Implements **33 of 46** modelled operations (71.7%).
<details><summary>Implemented (33)</summary>
`AddListenerCertificates`, `AddTags`, `CreateListener`, `CreateLoadBalancer`, `CreateRule`, `CreateTargetGroup`, `DeleteListener`, `DeleteLoadBalancer`
`DeleteRule`, `DeleteTargetGroup`, `DeregisterTargets`, `DescribeListenerCertificates`, `DescribeListeners`, `DescribeLoadBalancerAttributes`, `DescribeLoadBalancers`, `DescribeRules`
`DescribeSSLPolicies`, `DescribeTags`, `DescribeTargetGroupAttributes`, `DescribeTargetGroups`, `DescribeTargetHealth`, `ModifyListener`, `ModifyLoadBalancerAttributes`, `ModifyRule`
`ModifyTargetGroup`, `ModifyTargetGroupAttributes`, `RegisterTargets`, `RemoveListenerCertificates`, `RemoveTags`, `SetIpAddressType`, `SetRulePriorities`, `SetSecurityGroups`
`SetSubnets`
</details>
<details><summary>Missing from dispatch (13)</summary>
`AddTrustStoreRevocations`, `CreateTrustStore`, `DeleteSharedTrustStoreAssociation`, `DeleteTrustStore`, `DescribeAccountLimits`, `DescribeTrustStoreAssociations`, `DescribeTrustStoreRevocations`, `DescribeTrustStores`
`GetResourcePolicy`, `GetTrustStoreCaCertificatesBundle`, `GetTrustStoreRevocationContent`, `ModifyTrustStore`, `RemoveTrustStoreRevocations`
</details>
<details><summary>Registered stubs (0)</summary>
None.
</details>
<details><summary>Deliberately unsupported (0)</summary>
None.
</details>
<details><summary>Registered outside the pinned model (4)</summary>
`DescribeListenerAttributes`, `GetLBConfig`, `LBAgentHeartbeat`, `ModifyListenerAttributes`
</details>
## iam
Implements **75 of 159** modelled operations (47.2%).
<details><summary>Implemented (75)</summary>
`AddRoleToInstanceProfile`, `AddUserToGroup`, `AttachGroupPolicy`, `AttachRolePolicy`, `AttachUserPolicy`, `CreateAccessKey`, `CreateGroup`, `CreateInstanceProfile`
`CreateOpenIDConnectProvider`, `CreatePolicy`, `CreateRole`, `CreateUser`, `DeleteAccessKey`, `DeleteGroup`, `DeleteGroupPolicy`, `DeleteInstanceProfile`
`DeleteOpenIDConnectProvider`, `DeletePolicy`, `DeleteRole`, `DeleteRolePolicy`, `DeleteUser`, `DeleteUserPolicy`, `DetachGroupPolicy`, `DetachRolePolicy`
`DetachUserPolicy`, `GetAccountSummary`, `GetGroup`, `GetGroupPolicy`, `GetInstanceProfile`, `GetOpenIDConnectProvider`, `GetPolicy`, `GetPolicyVersion`
`GetRole`, `GetRolePolicy`, `GetUser`, `GetUserPolicy`, `ListAccessKeys`, `ListAttachedGroupPolicies`, `ListAttachedRolePolicies`, `ListAttachedUserPolicies`
`ListGroupPolicies`, `ListGroups`, `ListGroupsForUser`, `ListInstanceProfileTags`, `ListInstanceProfiles`, `ListInstanceProfilesForRole`, `ListOpenIDConnectProviderTags`, `ListOpenIDConnectProviders`
`ListPolicies`, `ListPolicyTags`, `ListPolicyVersions`, `ListRolePolicies`, `ListRoleTags`, `ListRoles`, `ListUserPolicies`, `ListUserTags`
`ListUsers`, `PutGroupPolicy`, `PutRolePolicy`, `PutUserPolicy`, `RemoveRoleFromInstanceProfile`, `RemoveUserFromGroup`, `TagInstanceProfile`, `TagOpenIDConnectProvider`
`TagPolicy`, `TagRole`, `TagUser`, `UntagInstanceProfile`, `UntagOpenIDConnectProvider`, `UntagPolicy`, `UntagRole`, `UntagUser`
`UpdateAccessKey`, `UpdateAssumeRolePolicy`, `UpdateRole`
</details>
<details><summary>Missing from dispatch (84)</summary>
`AddClientIDToOpenIDConnectProvider`, `ChangePassword`, `CreateAccountAlias`, `CreateLoginProfile`, `CreatePolicyVersion`, `CreateSAMLProvider`, `CreateServiceLinkedRole`, `CreateServiceSpecificCredential`
`CreateVirtualMFADevice`, `DeactivateMFADevice`, `DeleteAccountAlias`, `DeleteAccountPasswordPolicy`, `DeleteLoginProfile`, `DeletePolicyVersion`, `DeleteRolePermissionsBoundary`, `DeleteSAMLProvider`
`DeleteSSHPublicKey`, `DeleteServerCertificate`, `DeleteServiceLinkedRole`, `DeleteServiceSpecificCredential`, `DeleteSigningCertificate`, `DeleteUserPermissionsBoundary`, `DeleteVirtualMFADevice`, `EnableMFADevice`
`GenerateCredentialReport`, `GenerateOrganizationsAccessReport`, `GenerateServiceLastAccessedDetails`, `GetAccessKeyLastUsed`, `GetAccountAuthorizationDetails`, `GetAccountPasswordPolicy`, `GetContextKeysForCustomPolicy`, `GetContextKeysForPrincipalPolicy`
`GetCredentialReport`, `GetLoginProfile`, `GetMFADevice`, `GetOrganizationsAccessReport`, `GetSAMLProvider`, `GetSSHPublicKey`, `GetServerCertificate`, `GetServiceLastAccessedDetails`
`GetServiceLastAccessedDetailsWithEntities`, `GetServiceLinkedRoleDeletionStatus`, `ListAccountAliases`, `ListEntitiesForPolicy`, `ListMFADeviceTags`, `ListMFADevices`, `ListPoliciesGrantingServiceAccess`, `ListSAMLProviderTags`
`ListSAMLProviders`, `ListSSHPublicKeys`, `ListServerCertificateTags`, `ListServerCertificates`, `ListServiceSpecificCredentials`, `ListSigningCertificates`, `ListVirtualMFADevices`, `PutRolePermissionsBoundary`
`PutUserPermissionsBoundary`, `RemoveClientIDFromOpenIDConnectProvider`, `ResetServiceSpecificCredential`, `ResyncMFADevice`, `SetDefaultPolicyVersion`, `SetSecurityTokenServicePreferences`, `SimulateCustomPolicy`, `SimulatePrincipalPolicy`
`TagMFADevice`, `TagSAMLProvider`, `TagServerCertificate`, `UntagMFADevice`, `UntagSAMLProvider`, `UntagServerCertificate`, `UpdateAccountPasswordPolicy`, `UpdateGroup`
`UpdateLoginProfile`, `UpdateOpenIDConnectProviderThumbprint`, `UpdateRoleDescription`, `UpdateSAMLProvider`, `UpdateSSHPublicKey`, `UpdateServerCertificate`, `UpdateServiceSpecificCredential`, `UpdateSigningCertificate`
`UpdateUser`, `UploadSSHPublicKey`, `UploadServerCertificate`, `UploadSigningCertificate`
</details>
<details><summary>Registered stubs (0)</summary>
None.
</details>
<details><summary>Deliberately unsupported (0)</summary>
None.
</details>
<details><summary>Registered outside the pinned model (0)</summary>
None.
</details>
## rds
Implements **26 of 162** modelled operations (16.0%).
<details><summary>Implemented (26)</summary>
`AddTagsToResource`, `CreateDBInstance`, `CreateDBParameterGroup`, `CreateDBSnapshot`, `CreateDBSubnetGroup`, `DeleteDBInstance`, `DeleteDBParameterGroup`, `DeleteDBSnapshot`
`DeleteDBSubnetGroup`, `DescribeDBEngineVersions`, `DescribeDBInstanceAutomatedBackups`, `DescribeDBInstances`, `DescribeDBParameterGroups`, `DescribeDBParameters`, `DescribeDBSnapshots`, `DescribeDBSubnetGroups`
`DescribeEvents`, `DescribeOrderableDBInstanceOptions`, `ListTagsForResource`, `ModifyDBInstance`, `ModifyDBParameterGroup`, `RebootDBInstance`, `RemoveTagsFromResource`, `RestoreDBInstanceFromDBSnapshot`
`StartDBInstance`, `StopDBInstance`
</details>
<details><summary>Missing from dispatch (124)</summary>
`AddRoleToDBCluster`, `AddRoleToDBInstance`, `AddSourceIdentifierToSubscription`, `ApplyPendingMaintenanceAction`, `AuthorizeDBSecurityGroupIngress`, `BacktrackDBCluster`, `CancelExportTask`, `CopyDBClusterParameterGroup`
`CopyDBClusterSnapshot`, `CopyDBParameterGroup`, `CopyDBSnapshot`, `CopyOptionGroup`, `CreateBlueGreenDeployment`, `CreateCustomDBEngineVersion`, `CreateDBClusterEndpoint`, `CreateDBClusterParameterGroup`
`CreateDBClusterSnapshot`, `CreateDBProxy`, `CreateDBProxyEndpoint`, `CreateDBSecurityGroup`, `CreateDBShardGroup`, `CreateEventSubscription`, `CreateGlobalCluster`, `CreateIntegration`
`CreateTenantDatabase`, `DeleteBlueGreenDeployment`, `DeleteCustomDBEngineVersion`, `DeleteDBClusterAutomatedBackup`, `DeleteDBClusterEndpoint`, `DeleteDBClusterParameterGroup`, `DeleteDBClusterSnapshot`, `DeleteDBInstanceAutomatedBackup`
`DeleteDBProxy`, `DeleteDBProxyEndpoint`, `DeleteDBSecurityGroup`, `DeleteDBShardGroup`, `DeleteEventSubscription`, `DeleteGlobalCluster`, `DeleteIntegration`, `DeleteTenantDatabase`
`DeregisterDBProxyTargets`, `DescribeAccountAttributes`, `DescribeBlueGreenDeployments`, `DescribeCertificates`, `DescribeDBClusterAutomatedBackups`, `DescribeDBClusterBacktracks`, `DescribeDBClusterEndpoints`, `DescribeDBClusterParameterGroups`
`DescribeDBClusterParameters`, `DescribeDBClusterSnapshotAttributes`, `DescribeDBClusterSnapshots`, `DescribeDBLogFiles`, `DescribeDBProxies`, `DescribeDBProxyEndpoints`, `DescribeDBProxyTargetGroups`, `DescribeDBProxyTargets`
`DescribeDBRecommendations`, `DescribeDBSecurityGroups`, `DescribeDBShardGroups`, `DescribeDBSnapshotAttributes`, `DescribeDBSnapshotTenantDatabases`, `DescribeEngineDefaultClusterParameters`, `DescribeEngineDefaultParameters`, `DescribeEventCategories`
`DescribeEventSubscriptions`, `DescribeExportTasks`, `DescribeGlobalClusters`, `DescribeIntegrations`, `DescribeOptionGroupOptions`, `DescribePendingMaintenanceActions`, `DescribeReservedDBInstances`, `DescribeReservedDBInstancesOfferings`
`DescribeSourceRegions`, `DescribeTenantDatabases`, `DescribeValidDBInstanceModifications`, `DisableHttpEndpoint`, `DownloadDBLogFilePortion`, `EnableHttpEndpoint`, `FailoverGlobalCluster`, `ModifyActivityStream`
`ModifyCertificates`, `ModifyCurrentDBClusterCapacity`, `ModifyCustomDBEngineVersion`, `ModifyDBClusterEndpoint`, `ModifyDBClusterParameterGroup`, `ModifyDBClusterSnapshotAttribute`, `ModifyDBProxy`, `ModifyDBProxyEndpoint`
`ModifyDBProxyTargetGroup`, `ModifyDBRecommendation`, `ModifyDBShardGroup`, `ModifyDBSnapshot`, `ModifyDBSnapshotAttribute`, `ModifyDBSubnetGroup`, `ModifyEventSubscription`, `ModifyGlobalCluster`
`ModifyIntegration`, `ModifyTenantDatabase`, `PromoteReadReplicaDBCluster`, `PurchaseReservedDBInstancesOffering`, `RebootDBCluster`, `RebootDBShardGroup`, `RegisterDBProxyTargets`, `RemoveFromGlobalCluster`
`RemoveRoleFromDBCluster`, `RemoveRoleFromDBInstance`, `RemoveSourceIdentifierFromSubscription`, `ResetDBClusterParameterGroup`, `ResetDBParameterGroup`, `RestoreDBClusterFromS3`, `RestoreDBClusterFromSnapshot`, `RestoreDBClusterToPointInTime`
`RestoreDBInstanceFromS3`, `RevokeDBSecurityGroupIngress`, `StartActivityStream`, `StartDBCluster`, `StartDBInstanceAutomatedBackupsReplication`, `StartExportTask`, `StopActivityStream`, `StopDBCluster`
`StopDBInstanceAutomatedBackupsReplication`, `SwitchoverBlueGreenDeployment`, `SwitchoverGlobalCluster`, `SwitchoverReadReplica`
</details>
<details><summary>Registered stubs (0)</summary>
None.
</details>
<details><summary>Deliberately unsupported (12)</summary>
`CreateDBCluster`, `CreateDBInstanceReadReplica`, `CreateOptionGroup`, `DeleteDBCluster`, `DeleteOptionGroup`, `DescribeDBClusters`, `DescribeOptionGroups`, `FailoverDBCluster`
`ModifyDBCluster`, `ModifyOptionGroup`, `PromoteReadReplica`, `RestoreDBInstanceToPointInTime`
</details>
<details><summary>Registered outside the pinned model (5)</summary>
`AcknowledgeDBBootstrap`, `GetDBBootstrapConfig`, `PollDBCommands`, `RegisterDBInstance`, `SubmitDBStateChange`
</details>
## s3
Operation coverage is not enumerable: Spinifex delegates the S3 REST surface to Predastore, which has no operation-name dispatch table to compare mechanically.
## sts
Implements **4 of 8** modelled operations (50.0%).
<details><summary>Implemented (4)</summary>
`AssumeRole`, `AssumeRoleWithWebIdentity`, `GetCallerIdentity`, `GetSessionToken`
</details>
<details><summary>Missing from dispatch (4)</summary>
`AssumeRoleWithSAML`, `DecodeAuthorizationMessage`, `GetAccessKeyInfo`, `GetFederationToken`
</details>
<details><summary>Registered stubs (0)</summary>
None.
</details>
<details><summary>Deliberately unsupported (0)</summary>
None.
</details>
<details><summary>Registered outside the pinned model (0)</summary>
None.
</details>
+152
View File
@@ -795,6 +795,158 @@ aws ec2 run-instances --image-id $AMI --instance-type t3.small \
Rule changes take effect immediately — no instance restart needed.
## Platform Default Egress Restrictions (Outbound SMTP)
Like AWS, Spinifex blocks outbound SMTP from instances **by default** so a
compromised guest cannot turn a fresh account into a spam relay. Connections to
the mail ports are dropped at the OVS datapath before they reach the wire:
| Port | Protocol | Purpose |
| --- | --- | --- |
| 25 | TCP | SMTP relay |
| 465 | TCP | SMTP over implicit TLS |
| 587 | TCP | SMTP submission |
This is a **platform default, not a security-group rule**. It is enforced as an
OVN egress ACL on every guest's port group at a priority **above** tenant SG
allows, so a tenant **cannot** open these ports by adding a security-group rule —
matching AWS, where lifting the block is an operator action, not a tenant one.
Only **public** destinations are blocked; mail to private ranges (`10/8`,
`172.16/12`, `192.168/16`, `100.64/10`, link-local) is exempt, so an in-VPC or
on-prem relay still works. Dropped attempts are logged for abuse triage.
It sits alongside the security-group ACLs and the host firewall (the `nft`
policy that scopes the node's own ports) as a third datapath control — this one
applies to guest egress specifically.
**Operator controls** (cluster-wide, in `spinifex.toml`):
```toml
[network]
# Omit for the default [25, 465, 587]; set [] to disable entirely.
blocked_ports_wan = [25, 465, 587]
# Workaround to let a specific tenant/VPC send mail, until per-account
# exceptions exist: list the VPC IDs to exempt from the block.
egress_block_exempt_vpcs = ["vpc-0abc123..."]
```
Exempting a VPC removes the block for **all** guests in that VPC, so scope it
narrowly. Add every VPC ID you want exempt to the one list —
`egress_block_exempt_vpcs = ["vpc-a", "vpc-b", ...]`; the match is by VPC ID, so
one entry covers every security group in that VPC.
### Multi-node clusters — keep the value identical on every node, and restart vpcd
`[network]` is a cluster-wide *setting*, but it physically lives in each node's
own `spinifex.toml`, and there is no live distribution of it. Two properties of
the current implementation make the operator responsible for consistency:
- **One node writes the ACLs.** SG reconcile runs on a single CAS-elected vpcd leader, which programs the shared OVN northbound DB. Whichever node holds the lease is the one whose `blocked_ports_wan` / `egress_block_exempt_vpcs` is in force. Leadership moves on restart or crash, so if the node configs disagree, the effective policy **changes when the leader changes** and the drift pass flaps the ACLs between the two states. Edit the value **identically on every node**.
- **It is read at vpcd startup, not hot-reloaded.** The policy is built once when vpcd starts; editing the TOML does nothing until vpcd restarts. Deploy the config change to all nodes and restart vpcd cluster-wide (take the target down and confirm no `spx` process survives — a selective single-service restart is not reliable on the shared binary).
This is the current implementation and is deliberately minimal. A future revision
will move the exemption into shared cluster state (so a single edit propagates
and cannot drift between nodes) and make it a per-account control rather than a
per-VPC operator edit; until then, the two rules above are load-bearing.
## The Instance-to-Host Plane (Metadata and VPC DNS)
Security groups govern traffic **between instances** and **to the outside**. There
is a third path they do not touch, and it is the one most easily left exposed:
traffic from an instance to the **host it runs on**.
Every instance reaches two link-local addresses served by the hypervisor:
| Address | Service | Port |
| --- | --- | --- |
| `169.254.169.254` | Instance metadata (IMDS) — cloud-init, instance-role credentials | TCP 80 |
| `169.254.169.253` | VPC DNS resolver | UDP/TCP 53 |
These are not guest addresses and not OVN routed. Each instance's tap has a
capture rule on the hypervisor that intercepts any packet addressed to `.254` or
`.253` and delivers it to a per-ENI internal port in the host network namespace
(named `ime-*`). That interception is **by destination address only — it does not
match the port.** A packet to `169.254.169.254:22` is delivered to the host just
as readily as one to `169.254.169.254:80`.
That matters because host services — SSH, the AWS gateway on 9999, the console on
3000, the DNS server — bind the wildcard address, so they also answer on these
link-local addresses. Without a control on the `ime-*` path, an instance can
reach every one of them, with none of the source scoping that protects the same
services on the network. A resolver query sent straight to the host's DNS port
this way also bypasses the per-instance DNS rate limit.
### This plane is invisible to a network firewall
The critical point for anyone securing a deployment: **this traffic never crosses
the physical NIC.** It is intra-host, on the OVS bridge between the instance's tap
and the `ime-*` port. A cloud security group, an upstream firewall, `ufw`, or any
rule written against the WAN interface is not in this path and cannot filter it.
The only place to enforce it is on the `ime-*` interface in the host itself.
### How it is protected
Spinifex's host firewall carries a rule that scopes the `ime-*` path to exactly
the two legitimate endpoints and ports — IMDS on `.254:80`, DNS on `.253:53` —
and drops everything else. Whether you have that rule depends entirely on whether
the host firewall is on:
| Install path | Host firewall | This plane |
| --- | --- | --- |
| **From the ISO** | on | protected |
| **Binary installer** (`curl \| bash`) or `setup.sh` | **off** | **exposed** |
| **Binary installer with `--firewall=on`** | on | protected |
The ISO ships the firewall armed, so ISO deployments are protected out of the box.
**The binary installer ships it off** — deliberately, because it runs on servers
that may already have services the installer knows nothing about, and a
default-deny policy could cut them off. The consequence is that a binary install
left at its default has this plane wide open, and so does every host service on
the WAN besides.
If you install any way other than the ISO, turn the host firewall on. At install
time:
```bash
curl -fsSL https://install.mulgadc.com | bash -s -- --firewall=on
# or, from a source checkout:
sudo /usr/local/share/spinifex/setup.sh --firewall=on
```
or afterwards, in `/etc/spinifex/spinifex.toml`, followed by a daemon restart:
```toml
[network]
firewall_enabled = true
```
Before enabling it, check what else the machine is serving — anything listening
outside the public port group stops accepting new connections. See
[Firewall and Cluster Membership](/docs/install-multi-node#firewall-and-cluster-membership)
for the full port policy and the cluster-formation steps.
### Verifying it
On the hypervisor, the `ime-*` accept should be scoped, not blanket:
```bash
sudo nft list chain inet spinifex_filter input | grep ime
# Expect three rules naming 169.254.169.254 tcp dport 80 and 169.254.169.253
# udp/tcp dport 53 — not a bare `iifname "ime-*" accept`.
```
From an instance, the two service ports work and nothing else does:
```bash
curl -s -o /dev/null -w '%{http_code}\n' http://169.254.169.254/latest/meta-data/ # 401 (IMDSv2)
dig +short @169.254.169.253 example.com A # resolves
nc -vz 169.254.169.254 22 # must fail
```
A missing `spinifex_filter` table means the host firewall is off and this plane
is unprotected regardless of the rule above.
## Elastic IPs
Elastic IPs are static public IPs that persist across instance stop/start cycles. Unlike auto-assigned public IPs (which change on stop/start), an Elastic IP stays with your instance.
+6 -7
View File
@@ -82,12 +82,11 @@ Before creating a cluster you need the supporting infrastructure in place. The T
- **Spinifex running**, with the AWS CLI configured for the `spinifex` profile (see [Installing Spinifex](/docs/install)) and `kubectl` installed.
- **The `eks-node` image** registered on the cluster. The console blocks cluster creation until it is present.
- **A VPC with at least one subnet.** The control plane launches in your first subnet; workers run in the subnets you pass to the node group.
- **Egress for the workers.** Workers must reach the internet to pull container images:
- **Public workers** — an **Internet Gateway** with a default route (`0.0.0.0/0 → IGW`) and public IPs.
- **Private workers** — a **NAT Gateway** in a public subnet with a default route (`0.0.0.0/0 → NAT`) from the private subnets.
- **Two IAM roles:**
- A **cluster role** trusted by `eks.amazonaws.com` with `AmazonEKSClusterPolicy` attached.
- A **node role** trusted by `ec2.amazonaws.com` with `AmazonEKSWorkerNodePolicy`, `AmazonEKS_CNI_Policy`, and `AmazonEC2ContainerRegistryReadOnly` attached (the last lets workers pull images from [ECR](/docs/ecr)).
- **Egress for the workers.** Workers must reach the internet to pull container images.
- **Public workers** need an **Internet Gateway** with a default route (`0.0.0.0/0 → IGW`) and public IPs.
- **Private workers** need a **NAT Gateway** in a public subnet with a default route (`0.0.0.0/0 → NAT`) from the private subnets.
- **A cluster role** trusted by `eks.amazonaws.com` with `AmazonEKSClusterPolicy` attached.
- **A node role** trusted by `ec2.amazonaws.com` with `AmazonEKSWorkerNodePolicy`, `AmazonEKS_CNI_Policy`, and `AmazonEC2ContainerRegistryReadOnly` attached (the last lets workers pull images from [ECR](/docs/ecr)).
## Instructions
@@ -273,4 +272,4 @@ The workbooks form a ladder. **eks-quickstart** is a NodePort demo; **eks-https-
A complete end-to-end example of Spinifex EKS in production: a GPU-accelerated AI inference platform running on a Supermicro X14 2U with two NVIDIA RTX Pro 6000 Blackwell GPUs. The guide provisions an EKS cluster with two GPU worker nodes via VFIO PCIe passthrough, deploys an OpenAI-compatible LLM API (llama.cpp / Llama 3.2 3B) and a real-time YOLO object-detection stream on separate GPU nodes, and wires them together behind an ALB using IAM, ECR, ACM, and EBS — all with standard AWS tooling and a single `AWS_PROFILE` swap.
→ [EKS AI Platform Reference Architecture](/docs/RTX-Pro-6000-EKS)
→ [EKS AI Platform Reference Architecture](/hardware/supermicro/rtx-pro-6000)
+34
View File
@@ -0,0 +1,34 @@
---
title: "AWS API Coverage"
seoTitle: "Which AWS API Operations Spinifex Implements — Spinifex Docs"
description: "The AWS API operations Spinifex serves, generated from its gateway dispatch tables and the pinned AWS SDK service models on each build, service by service."
category: "Coverage"
sections:
- overview
tags:
- aws
- compatibility
- coverage
- api
- operations
---
# AWS API Coverage
## Overview
The platform serves **381 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 |
|---|---:|
| [ACM](/coverage/acm) | 9 |
| [EC2](/coverage/ec2) | 125 |
| [ECR](/coverage/ecr) | 22 |
| [ECS](/coverage/ecs) | 31 |
| [EKS](/coverage/eks) | 34 |
| [ELBv2](/coverage/elbv2) | 34 |
| [IAM](/coverage/iam) | 76 |
| [RDS](/coverage/rds) | 26 |
| [S3](/coverage/s3) | 19 |
| [STS](/coverage/sts) | 5 |
| **Total** | **381** |
+43
View File
@@ -0,0 +1,43 @@
---
title: "ACM API Coverage"
seoTitle: "AWS Certificate Manager API Coverage — Spinifex Docs"
description: "The AWS Certificate Manager API operations Spinifex implements, generated from the gateway dispatch tables and the pinned AWS service model on each build."
category: "Coverage"
sections:
- overview
tags:
- aws
- compatibility
- coverage
- acm
- certificates
- tls
---
# ACM API Coverage
## Overview
Spinifex implements **9 operations** in the ACM `2015-12-08` API model.
### Import and issuance
Spinifex both stores externally-issued certificates and issues its own for load balancer listener references. Certificates are account-scoped, and a delete is refused while any listener still references the ARN — there is no force flag, matching AWS.
`RequestCertificate` mints an ARN immediately and returns `PENDING_VALIDATION`; it never issues inline except against a tenant private CA, which has no domain to validate. The validation mode is derived from deployment state rather than configured: the DNS provider API where a credential exists, a manual TXT record where the platform hosts the zone, and a private CA otherwise — the only option for a deployment with no publicly delegated domain.
Terraform's canonical certificate, DNS record and validation flow works unmodified in every mode. Where Spinifex owns the record write it emits no `ResourceRecord`, so iterating the validation options yields zero records and the validation resource still blocks correctly by polling until the certificate is issued.
### Operations
| Operation |
|---|
| `AddTagsToCertificate` |
| `DeleteCertificate` |
| `DescribeCertificate` |
| `GetCertificate` |
| `ImportCertificate` |
| `ListCertificates` |
| `ListTagsForCertificate` |
| `RemoveTagsFromCertificate` |
| `RequestCertificate` |
+7
View File
@@ -0,0 +1,7 @@
### Import and issuance
Spinifex both stores externally-issued certificates and issues its own for load balancer listener references. Certificates are account-scoped, and a delete is refused while any listener still references the ARN — there is no force flag, matching AWS.
`RequestCertificate` mints an ARN immediately and returns `PENDING_VALIDATION`; it never issues inline except against a tenant private CA, which has no domain to validate. The validation mode is derived from deployment state rather than configured: the DNS provider API where a credential exists, a manual TXT record where the platform hosts the zone, and a private CA otherwise — the only option for a deployment with no publicly delegated domain.
Terraform's canonical certificate, DNS record and validation flow works unmodified in every mode. Where Spinifex owns the record write it emits no `ResourceRecord`, so iterating the validation options yields zero records and the validation resource still blocks correctly by polling until the certificate is issued.
+155
View File
@@ -0,0 +1,155 @@
---
title: "EC2 API Coverage"
seoTitle: "Amazon EC2 API Coverage on Spinifex — Spinifex Docs"
description: "The Amazon EC2 API operations Spinifex implements, covering instances, EBS volumes, VPC networking, tags, security groups and the rest of the compute surface."
category: "Coverage"
sections:
- overview
tags:
- aws
- compatibility
- coverage
- ec2
- compute
- vpc
---
# EC2 API Coverage
## Overview
Spinifex implements **125 operations** in the EC2 `2016-11-15` API model.
### Spot Instances Are a Mock
Spot Instance Requests are a mock over the on-demand `RunInstances` path. A request synchronously launches real VMs on the operator's own compute and is then reported `active` and `fulfilled`. There is no spot market: no bidding, no price rejection, no interruption and no reclamation, and instances are never taken back.
### Operations
| Operation |
|---|
| `AllocateAddress` |
| `AssociateAddress` |
| `AssociateIamInstanceProfile` |
| `AssociateRouteTable` |
| `AttachInternetGateway` |
| `AttachNetworkInterface` |
| `AttachVolume` |
| `AuthorizeSecurityGroupEgress` |
| `AuthorizeSecurityGroupIngress` |
| `CancelCapacityReservation` |
| `CancelSpotInstanceRequests` |
| `CopyImage` |
| `CopySnapshot` |
| `CreateCapacityReservation` |
| `CreateEgressOnlyInternetGateway` |
| `CreateImage` |
| `CreateInternetGateway` |
| `CreateKeyPair` |
| `CreateLaunchTemplate` |
| `CreateLaunchTemplateVersion` |
| `CreateNatGateway` |
| `CreateNetworkInterface` |
| `CreatePlacementGroup` |
| `CreateRoute` |
| `CreateRouteTable` |
| `CreateSecurityGroup` |
| `CreateSnapshot` |
| `CreateSubnet` |
| `CreateTags` |
| `CreateVolume` |
| `CreateVpc` |
| `DeleteEgressOnlyInternetGateway` |
| `DeleteInternetGateway` |
| `DeleteKeyPair` |
| `DeleteLaunchTemplate` |
| `DeleteLaunchTemplateVersions` |
| `DeleteNatGateway` |
| `DeleteNetworkInterface` |
| `DeletePlacementGroup` |
| `DeleteRoute` |
| `DeleteRouteTable` |
| `DeleteSecurityGroup` |
| `DeleteSnapshot` |
| `DeleteSubnet` |
| `DeleteTags` |
| `DeleteVolume` |
| `DeleteVpc` |
| `DeregisterImage` |
| `DescribeAccountAttributes` |
| `DescribeAddresses` |
| `DescribeAddressesAttribute` |
| `DescribeAvailabilityZones` |
| `DescribeCapacityReservations` |
| `DescribeEgressOnlyInternetGateways` |
| `DescribeIamInstanceProfileAssociations` |
| `DescribeImageAttribute` |
| `DescribeImages` |
| `DescribeInstanceAttribute` |
| `DescribeInstanceCreditSpecifications` |
| `DescribeInstanceStatus` |
| `DescribeInstanceTypeOfferings` |
| `DescribeInstanceTypes` |
| `DescribeInstances` |
| `DescribeInternetGateways` |
| `DescribeKeyPairs` |
| `DescribeLaunchTemplateVersions` |
| `DescribeLaunchTemplates` |
| `DescribeNatGateways` |
| `DescribeNetworkInterfaces` |
| `DescribePlacementGroups` |
| `DescribeRegions` |
| `DescribeRouteTables` |
| `DescribeSecurityGroupRules` |
| `DescribeSecurityGroups` |
| `DescribeSnapshots` |
| `DescribeSpotInstanceRequests` |
| `DescribeSubnets` |
| `DescribeTags` |
| `DescribeVolumeStatus` |
| `DescribeVolumes` |
| `DescribeVolumesModifications` |
| `DescribeVpcAttribute` |
| `DescribeVpcs` |
| `DetachInternetGateway` |
| `DetachNetworkInterface` |
| `DetachVolume` |
| `DisableEbsEncryptionByDefault` |
| `DisableSerialConsoleAccess` |
| `DisassociateAddress` |
| `DisassociateIamInstanceProfile` |
| `DisassociateRouteTable` |
| `EnableEbsEncryptionByDefault` |
| `EnableSerialConsoleAccess` |
| `GetConsoleOutput` |
| `GetEbsEncryptionByDefault` |
| `GetPasswordData` |
| `GetSecurityGroupsForVpc` |
| `GetSerialConsoleAccessStatus` |
| `ImportKeyPair` |
| `ModifyImageAttribute` |
| `ModifyInstanceAttribute` |
| `ModifyInstanceMetadataOptions` |
| `ModifyLaunchTemplate` |
| `ModifyNetworkInterfaceAttribute` |
| `ModifySubnetAttribute` |
| `ModifyVolume` |
| `ModifyVpcAttribute` |
| `MonitorInstances` |
| `RebootInstances` |
| `RegisterImage` |
| `ReleaseAddress` |
| `ReplaceIamInstanceProfileAssociation` |
| `ReplaceRoute` |
| `ReplaceRouteTableAssociation` |
| `RequestSpotInstances` |
| `ResetImageAttribute` |
| `RevokeSecurityGroupEgress` |
| `RevokeSecurityGroupIngress` |
| `RunInstances` |
| `StartInstances` |
| `StopInstances` |
| `TerminateInstances` |
| `UnmonitorInstances` |
| `UpdateSecurityGroupRuleDescriptionsEgress` |
| `UpdateSecurityGroupRuleDescriptionsIngress` |
+3
View File
@@ -0,0 +1,3 @@
### Spot Instances Are a Mock
Spot Instance Requests are a mock over the on-demand `RunInstances` path. A request synchronously launches real VMs on the operator's own compute and is then reported `active` and `fulfilled`. There is no spot market: no bidding, no price rejection, no interruption and no reclamation, and instances are never taken back.
+56
View File
@@ -0,0 +1,56 @@
---
title: "ECR API Coverage"
seoTitle: "Elastic Container Registry API Coverage — Spinifex Docs"
description: "The Amazon ECR API operations Spinifex implements, alongside the OCI distribution endpoint that carries the image layers for every repository it serves."
category: "Coverage"
sections:
- overview
tags:
- aws
- compatibility
- coverage
- ecr
- containers
- registry
---
# ECR API Coverage
## Overview
Spinifex implements **22 operations** in the ECR `2015-09-21` API model.
### Two endpoints, one registry
Repository metadata is served over the AWS API on the gateway endpoint. Image data moves over the OCI Distribution `/v2/` endpoint on that same host, authenticated by the bearer token `GetAuthorizationToken` mints for `docker login`.
The split explains the stubs below. The layer-transfer operations — `BatchCheckLayerAvailability`, `InitiateLayerUpload`, `UploadLayerPart`, `CompleteLayerUpload` and `GetDownloadUrlForLayer` — are registered stubs because the `/v2/` endpoint carries that traffic instead. A client using `docker` or any OCI-compatible tool never calls them.
Registry replication is a stub for the same kind of reason: a deployment is a single registry, with no cross-region peer to replicate to.
### Operations
| Operation |
|---|
| `BatchDeleteImage` |
| `BatchGetImage` |
| `CreateRepository` |
| `DeleteLifecyclePolicy` |
| `DeleteRepository` |
| `DeleteRepositoryPolicy` |
| `DescribeImages` |
| `DescribeRepositories` |
| `GetAuthorizationToken` |
| `GetLifecyclePolicy` |
| `GetLifecyclePolicyPreview` |
| `GetRepositoryPolicy` |
| `ListImages` |
| `ListTagsForResource` |
| `PutImage` |
| `PutImageScanningConfiguration` |
| `PutImageTagMutability` |
| `PutLifecyclePolicy` |
| `SetRepositoryPolicy` |
| `StartLifecyclePolicyPreview` |
| `TagResource` |
| `UntagResource` |
+7
View File
@@ -0,0 +1,7 @@
### Two endpoints, one registry
Repository metadata is served over the AWS API on the gateway endpoint. Image data moves over the OCI Distribution `/v2/` endpoint on that same host, authenticated by the bearer token `GetAuthorizationToken` mints for `docker login`.
The split explains the stubs below. The layer-transfer operations — `BatchCheckLayerAvailability`, `InitiateLayerUpload`, `UploadLayerPart`, `CompleteLayerUpload` and `GetDownloadUrlForLayer` — are registered stubs because the `/v2/` endpoint carries that traffic instead. A client using `docker` or any OCI-compatible tool never calls them.
Registry replication is a stub for the same kind of reason: a deployment is a single registry, with no cross-region peer to replicate to.
+61
View File
@@ -0,0 +1,61 @@
---
title: "ECS API Coverage"
seoTitle: "Elastic Container Service API Coverage — Spinifex Docs"
description: "The Amazon ECS API operations Spinifex implements, covering clusters, services, tasks, container instances and the task definitions they are launched from."
category: "Coverage"
sections:
- overview
tags:
- aws
- compatibility
- coverage
- ecs
- containers
- orchestration
---
# ECS API Coverage
## Overview
Spinifex implements **31 operations** in the ECS `2014-11-13` API model.
### No Fargate
Clusters, services and tasks run on EC2 container instances. There is no Fargate launch type, and no operation below provides one.
### Operations
| Operation |
|---|
| `CreateCapacityProvider` |
| `CreateCluster` |
| `CreateService` |
| `DeleteCapacityProvider` |
| `DeleteCluster` |
| `DeleteService` |
| `DeregisterContainerInstance` |
| `DeregisterTaskDefinition` |
| `DescribeCapacityProviders` |
| `DescribeClusters` |
| `DescribeContainerInstances` |
| `DescribeServices` |
| `DescribeTaskDefinition` |
| `DescribeTasks` |
| `ListClusters` |
| `ListContainerInstances` |
| `ListServices` |
| `ListTagsForResource` |
| `ListTaskDefinitions` |
| `ListTasks` |
| `PutClusterCapacityProviders` |
| `RegisterContainerInstance` |
| `RegisterTaskDefinition` |
| `RunTask` |
| `StartTask` |
| `StopTask` |
| `SubmitTaskStateChange` |
| `TagResource` |
| `UntagResource` |
| `UpdateContainerInstancesState` |
| `UpdateService` |
+3
View File
@@ -0,0 +1,3 @@
### No Fargate
Clusters, services and tasks run on EC2 container instances. There is no Fargate launch type, and no operation below provides one.
+60
View File
@@ -0,0 +1,60 @@
---
title: "EKS API Coverage"
seoTitle: "Elastic Kubernetes Service API Coverage — Spinifex Docs"
description: "The Amazon EKS API operations Spinifex implements, covering clusters, nodegroups, add-ons, access entries and the identity providers they authenticate."
category: "Coverage"
sections:
- overview
tags:
- aws
- compatibility
- coverage
- eks
- kubernetes
- containers
---
# EKS API Coverage
## Overview
Spinifex implements **34 operations** in the EKS `2017-11-01` API model.
### Operations
| Operation |
|---|
| `AssociateAccessPolicy` |
| `AssociateIdentityProviderConfig` |
| `CreateAccessEntry` |
| `CreateAddon` |
| `CreateCluster` |
| `CreateNodegroup` |
| `DeleteAccessEntry` |
| `DeleteAddon` |
| `DeleteCluster` |
| `DeleteNodegroup` |
| `DescribeAccessEntry` |
| `DescribeAddon` |
| `DescribeAddonVersions` |
| `DescribeCluster` |
| `DescribeIdentityProviderConfig` |
| `DescribeNodegroup` |
| `DisassociateAccessPolicy` |
| `DisassociateIdentityProviderConfig` |
| `ListAccessEntries` |
| `ListAccessPolicies` |
| `ListAddons` |
| `ListAssociatedAccessPolicies` |
| `ListClusters` |
| `ListIdentityProviderConfigs` |
| `ListNodegroups` |
| `ListTagsForResource` |
| `TagResource` |
| `UntagResource` |
| `UpdateAccessEntry` |
| `UpdateAddon` |
| `UpdateClusterConfig` |
| `UpdateClusterVersion` |
| `UpdateNodegroupConfig` |
| `UpdateNodegroupVersion` |
+64
View File
@@ -0,0 +1,64 @@
---
title: "ELBv2 API Coverage"
seoTitle: "Elastic Load Balancing v2 API Coverage — Spinifex Docs"
description: "The Elastic Load Balancing v2 API operations Spinifex implements, for both the Application and Network Load Balancers it serves, listeners and target groups."
category: "Coverage"
sections:
- overview
tags:
- aws
- compatibility
- coverage
- elbv2
- load balancing
- networking
---
# ELBv2 API Coverage
## Overview
Spinifex implements **34 operations** in the ELBv2 `2015-12-01` API model.
### Two data planes
The data plane is a system-managed load balancer VM, launched automatically during `CreateLoadBalancer`. Application Load Balancers run HAProxy for the L7 surface — rules, fixed responses and redirects over HTTP and HTTPS. Network Load Balancers run nginx `stream` for the L4 surface — TCP, UDP, TLS and TCP_UDP — because HAProxy cannot load-balance UDP. The agent selects the engine from the configuration the control plane delivers, so the choice follows the load balancer type and is not separately configurable.
### Operations
| Operation |
|---|
| `AddListenerCertificates` |
| `AddTags` |
| `CreateListener` |
| `CreateLoadBalancer` |
| `CreateRule` |
| `CreateTargetGroup` |
| `DeleteListener` |
| `DeleteLoadBalancer` |
| `DeleteRule` |
| `DeleteTargetGroup` |
| `DeregisterTargets` |
| `DescribeAccountLimits` |
| `DescribeListenerCertificates` |
| `DescribeListeners` |
| `DescribeLoadBalancerAttributes` |
| `DescribeLoadBalancers` |
| `DescribeRules` |
| `DescribeSSLPolicies` |
| `DescribeTags` |
| `DescribeTargetGroupAttributes` |
| `DescribeTargetGroups` |
| `DescribeTargetHealth` |
| `ModifyListener` |
| `ModifyLoadBalancerAttributes` |
| `ModifyRule` |
| `ModifyTargetGroup` |
| `ModifyTargetGroupAttributes` |
| `RegisterTargets` |
| `RemoveListenerCertificates` |
| `RemoveTags` |
| `SetIpAddressType` |
| `SetRulePriorities` |
| `SetSecurityGroups` |
| `SetSubnets` |
+3
View File
@@ -0,0 +1,3 @@
### Two data planes
The data plane is a system-managed load balancer VM, launched automatically during `CreateLoadBalancer`. Application Load Balancers run HAProxy for the L7 surface — rules, fixed responses and redirects over HTTP and HTTPS. Network Load Balancers run nginx `stream` for the L4 surface — TCP, UDP, TLS and TCP_UDP — because HAProxy cannot load-balance UDP. The agent selects the engine from the configuration the control plane delivers, so the choice follows the load balancer type and is not separately configurable.
+106
View File
@@ -0,0 +1,106 @@
---
title: "IAM API Coverage"
seoTitle: "AWS IAM API Operation Coverage on Spinifex — Spinifex Docs"
description: "The AWS IAM API operations Spinifex implements, covering users, roles, policies, groups, instance profiles and the access keys that authenticate them."
category: "Coverage"
sections:
- overview
tags:
- aws
- compatibility
- coverage
- iam
- identity
- policies
---
# IAM API Coverage
## Overview
Spinifex implements **76 operations** in the IAM `2010-05-08` API model.
### Scope
All IAM operations are account-scoped. The root user of an account bypasses policy evaluation entirely, as it does on AWS.
### Operations
| Operation |
|---|
| `AddRoleToInstanceProfile` |
| `AddUserToGroup` |
| `AttachGroupPolicy` |
| `AttachRolePolicy` |
| `AttachUserPolicy` |
| `CreateAccessKey` |
| `CreateGroup` |
| `CreateInstanceProfile` |
| `CreateOpenIDConnectProvider` |
| `CreatePolicy` |
| `CreateRole` |
| `CreateUser` |
| `DeleteAccessKey` |
| `DeleteGroup` |
| `DeleteGroupPolicy` |
| `DeleteInstanceProfile` |
| `DeleteOpenIDConnectProvider` |
| `DeletePolicy` |
| `DeleteRole` |
| `DeleteRolePolicy` |
| `DeleteUser` |
| `DeleteUserPolicy` |
| `DetachGroupPolicy` |
| `DetachRolePolicy` |
| `DetachUserPolicy` |
| `GetAccountSummary` |
| `GetGroup` |
| `GetGroupPolicy` |
| `GetInstanceProfile` |
| `GetOpenIDConnectProvider` |
| `GetPolicy` |
| `GetPolicyVersion` |
| `GetRole` |
| `GetRolePolicy` |
| `GetUser` |
| `GetUserPolicy` |
| `ListAccessKeys` |
| `ListAttachedGroupPolicies` |
| `ListAttachedRolePolicies` |
| `ListAttachedUserPolicies` |
| `ListEntitiesForPolicy` |
| `ListGroupPolicies` |
| `ListGroups` |
| `ListGroupsForUser` |
| `ListInstanceProfileTags` |
| `ListInstanceProfiles` |
| `ListInstanceProfilesForRole` |
| `ListOpenIDConnectProviderTags` |
| `ListOpenIDConnectProviders` |
| `ListPolicies` |
| `ListPolicyTags` |
| `ListPolicyVersions` |
| `ListRolePolicies` |
| `ListRoleTags` |
| `ListRoles` |
| `ListUserPolicies` |
| `ListUserTags` |
| `ListUsers` |
| `PutGroupPolicy` |
| `PutRolePolicy` |
| `PutUserPolicy` |
| `RemoveRoleFromInstanceProfile` |
| `RemoveUserFromGroup` |
| `TagInstanceProfile` |
| `TagOpenIDConnectProvider` |
| `TagPolicy` |
| `TagRole` |
| `TagUser` |
| `UntagInstanceProfile` |
| `UntagOpenIDConnectProvider` |
| `UntagPolicy` |
| `UntagRole` |
| `UntagUser` |
| `UpdateAccessKey` |
| `UpdateAssumeRolePolicy` |
| `UpdateRole` |
+3
View File
@@ -0,0 +1,3 @@
### Scope
All IAM operations are account-scoped. The root user of an account bypasses policy evaluation entirely, as it does on AWS.
File diff suppressed because it is too large Load Diff
+296
View File
@@ -0,0 +1,296 @@
{
"category": "Coverage",
"basePath": "/coverage",
"index": {
"name": "All services",
"title": "AWS API Coverage",
"seoTitle": "Which AWS API Operations Spinifex Implements — Spinifex Docs",
"description": "The AWS API operations Spinifex serves, generated from its gateway dispatch tables and the pinned AWS SDK service models on each build, service by service.",
"tags": [
"aws",
"compatibility",
"coverage",
"api",
"operations"
]
},
"services": {
"acm": {
"slug": "acm",
"name": "ACM",
"title": "ACM API Coverage",
"seoTitle": "AWS Certificate Manager API Coverage — Spinifex Docs",
"description": "The AWS Certificate Manager API operations Spinifex implements, generated from the gateway dispatch tables and the pinned AWS service model on each build.",
"tags": [
"aws",
"compatibility",
"coverage",
"acm",
"certificates",
"tls"
]
},
"ec2": {
"slug": "ec2",
"name": "EC2",
"title": "EC2 API Coverage",
"seoTitle": "Amazon EC2 API Coverage on Spinifex — Spinifex Docs",
"description": "The Amazon EC2 API operations Spinifex implements, covering instances, EBS volumes, VPC networking, tags, security groups and the rest of the compute surface.",
"tags": [
"aws",
"compatibility",
"coverage",
"ec2",
"compute",
"vpc"
],
"notApplicable": {
"AttachClassicLinkVpc": "ec2-classic",
"CancelReservedInstancesListing": "ri-marketplace",
"ConfirmProductInstance": "marketplace",
"CopyFpgaImage": "fpga",
"CreateCarrierGateway": "aws-edge",
"CreateFpgaImage": "fpga",
"CreateReservedInstancesListing": "ri-marketplace",
"DeleteCarrierGateway": "aws-edge",
"DeleteFpgaImage": "fpga",
"DescribeAggregateIdFormat": "id-format",
"DescribeAwsNetworkPerformanceMetricSubscriptions": "aws-backbone",
"DescribeCarrierGateways": "aws-edge",
"DescribeClassicLinkInstances": "ec2-classic",
"DescribeElasticGpus": "retired-accelerators",
"DescribeFpgaImageAttribute": "fpga",
"DescribeFpgaImages": "fpga",
"DescribeIdFormat": "id-format",
"DescribeIdentityIdFormat": "id-format",
"DescribeMovingAddresses": "ec2-classic",
"DescribePrincipalIdFormat": "id-format",
"DescribeReservedInstancesListings": "ri-marketplace",
"DescribeSpotPriceHistory": "spot-price",
"DescribeVpcClassicLink": "ec2-classic",
"DescribeVpcClassicLinkDnsSupport": "ec2-classic",
"DetachClassicLinkVpc": "ec2-classic",
"DisableAwsNetworkPerformanceMetricSubscription": "aws-backbone",
"DisableVpcClassicLink": "ec2-classic",
"DisableVpcClassicLinkDnsSupport": "ec2-classic",
"EnableAwsNetworkPerformanceMetricSubscription": "aws-backbone",
"EnableVpcClassicLink": "ec2-classic",
"EnableVpcClassicLinkDnsSupport": "ec2-classic",
"GetAwsNetworkPerformanceData": "aws-backbone",
"ModifyFpgaImageAttribute": "fpga",
"ModifyIdFormat": "id-format",
"ModifyIdentityIdFormat": "id-format",
"MoveAddressToVpc": "ec2-classic",
"ReportInstanceStatus": "aws-support",
"ResetFpgaImageAttribute": "fpga",
"RestoreAddressToClassic": "ec2-classic"
},
"notes": {
"aws-backbone": "These report performance across AWS's own global network between its regions and zones.",
"aws-edge": "Carrier gateways route to a telecommunications carrier's 5G network in an AWS Wavelength Zone.",
"aws-support": "Reporting an instance as impaired files a report with AWS support about AWS hardware.",
"ec2-classic": "EC2-Classic is the pre-VPC network AWS retired in 2022. Every instance here is in a VPC.",
"fpga": "FPGA images are built and ingested through AWS's own AFI pipeline for its F1 instances.",
"id-format": "These switched resource IDs between the short and long forms during AWS's 2016 migration, which has long since finished.",
"marketplace": "Product codes belong to AWS Marketplace, which has no equivalent here.",
"retired-accelerators": "Elastic Graphics was retired by AWS, and attached nothing but an AWS-hosted accelerator while it existed.",
"ri-marketplace": "The Reserved Instance Marketplace resells a reservation to another AWS customer, so AWS is the counterparty.",
"spot-price": "On owned hardware there is no spot-to-on-demand price differential, so any figure would be invented."
}
},
"ecr": {
"slug": "ecr",
"name": "ECR",
"title": "ECR API Coverage",
"seoTitle": "Elastic Container Registry API Coverage — Spinifex Docs",
"description": "The Amazon ECR API operations Spinifex implements, alongside the OCI distribution endpoint that carries the image layers for every repository it serves.",
"tags": [
"aws",
"compatibility",
"coverage",
"ecr",
"containers",
"registry"
]
},
"ecs": {
"slug": "ecs",
"name": "ECS",
"title": "ECS API Coverage",
"seoTitle": "Elastic Container Service API Coverage — Spinifex Docs",
"description": "The Amazon ECS API operations Spinifex implements, covering clusters, services, tasks, container instances and the task definitions they are launched from.",
"tags": [
"aws",
"compatibility",
"coverage",
"ecs",
"containers",
"orchestration"
],
"internal": [
"PollAssignments",
"ProvisionCapacity",
"ReportTaskGPU"
]
},
"eks": {
"slug": "eks",
"name": "EKS",
"title": "EKS API Coverage",
"seoTitle": "Elastic Kubernetes Service API Coverage — Spinifex Docs",
"description": "The Amazon EKS API operations Spinifex implements, covering clusters, nodegroups, add-ons, access entries and the identity providers they authenticate.",
"tags": [
"aws",
"compatibility",
"coverage",
"eks",
"kubernetes",
"containers"
],
"internal": [
"GetRecoveryDirective",
"ListInternalAddons",
"PublishInternal",
"WebhookTokenReview"
]
},
"elasticloadbalancingv2": {
"slug": "elbv2",
"name": "ELBv2",
"title": "ELBv2 API Coverage",
"seoTitle": "Elastic Load Balancing v2 API Coverage — Spinifex Docs",
"description": "The Elastic Load Balancing v2 API operations Spinifex implements, for both the Application and Network Load Balancers it serves, listeners and target groups.",
"tags": [
"aws",
"compatibility",
"coverage",
"elbv2",
"load balancing",
"networking"
],
"internal": [
"GetLBConfig",
"LBAgentHeartbeat"
]
},
"iam": {
"slug": "iam",
"name": "IAM",
"title": "IAM API Coverage",
"seoTitle": "AWS IAM API Operation Coverage on Spinifex — Spinifex Docs",
"description": "The AWS IAM API operations Spinifex implements, covering users, roles, policies, groups, instance profiles and the access keys that authenticate them.",
"tags": [
"aws",
"compatibility",
"coverage",
"iam",
"identity",
"policies"
],
"notApplicable": {
"CreateServiceLinkedRole": "service-linked-roles",
"CreateServiceSpecificCredential": "codecommit",
"DeleteSSHPublicKey": "codecommit",
"DeleteServiceLinkedRole": "service-linked-roles",
"DeleteServiceSpecificCredential": "codecommit",
"DeleteSigningCertificate": "signing-certificates",
"GenerateOrganizationsAccessReport": "organizations",
"GetOrganizationsAccessReport": "organizations",
"GetSSHPublicKey": "codecommit",
"GetServiceLinkedRoleDeletionStatus": "service-linked-roles",
"ListSSHPublicKeys": "codecommit",
"ListServiceSpecificCredentials": "codecommit",
"ListSigningCertificates": "signing-certificates",
"ResetServiceSpecificCredential": "codecommit",
"UpdateSSHPublicKey": "codecommit",
"UpdateServiceSpecificCredential": "codecommit",
"UpdateSigningCertificate": "signing-certificates",
"UploadSSHPublicKey": "codecommit",
"UploadSigningCertificate": "signing-certificates"
},
"notes": {
"codecommit": "CodeCommit credentials and SSH keys; the service does not exist here.",
"organizations": "AWS Organizations is not offered, so there is no organization to report on.",
"service-linked-roles": "Service-linked roles exist for AWS service principals, which this platform has none of.",
"signing-certificates": "X.509 signing certificates are an EC2-Classic SOAP credential, retired by AWS."
}
},
"rds": {
"slug": "rds",
"name": "RDS",
"title": "RDS API Coverage",
"seoTitle": "Relational Database Service API Coverage — Spinifex Docs",
"description": "The Amazon RDS API operations Spinifex implements, for the managed PostgreSQL and MariaDB engines it offers, covering instances, snapshots and parameters.",
"tags": [
"aws",
"compatibility",
"coverage",
"rds",
"databases",
"postgresql",
"mariadb"
],
"internal": [
"AcknowledgeDBBootstrap",
"GetDBBootstrapConfig",
"PollDBCommands",
"RegisterDBInstance",
"SubmitDBStateChange"
],
"notApplicable": {
"CreateOptionGroup": "option-groups",
"DeleteOptionGroup": "option-groups",
"DescribeOptionGroups": "option-groups",
"ModifyOptionGroup": "option-groups"
},
"notes": {
"option-groups": "Option groups configure engine add-ons for engines this platform does not offer, such as Oracle and SQL Server."
}
},
"s3": {
"slug": "s3",
"name": "S3",
"title": "S3 API Coverage",
"seoTitle": "Amazon S3 API Coverage on Spinifex — Spinifex Docs",
"description": "The Amazon S3 API operations Predastore serves on the platform's S3 endpoint, covering buckets, objects, multipart uploads and the policies that guard them.",
"implementedBy": "Predastore",
"tags": [
"aws",
"compatibility",
"coverage",
"s3",
"storage",
"objects"
],
"notApplicable": {
"CreateSession": "express-one-zone",
"GetBucketAccelerateConfiguration": "accelerate",
"GetBucketRequestPayment": "request-payment",
"PutBucketAccelerateConfiguration": "accelerate",
"PutBucketRequestPayment": "request-payment",
"WriteGetObjectResponse": "object-lambda"
},
"notes": {
"accelerate": "Transfer acceleration routes uploads over AWS edge locations, which an on-premise deployment has none of.",
"express-one-zone": "Sessions authenticate S3 Express One Zone directory buckets, a storage class this platform does not offer.",
"object-lambda": "S3 Object Lambda rewrites a response from a Lambda function, and Lambda is not offered.",
"request-payment": "Requester Pays shifts transfer charges to the caller, which needs AWS billing behind it."
}
},
"sts": {
"slug": "sts",
"name": "STS",
"title": "STS API Coverage",
"seoTitle": "AWS Security Token Service API Coverage — Spinifex Docs",
"description": "The AWS STS API operations Spinifex implements, covering role assumption, session tokens and web identity federation (IRSA), with those it does not offer.",
"tags": [
"aws",
"compatibility",
"coverage",
"sts",
"identity",
"credentials"
]
}
}
}
+100
View File
@@ -0,0 +1,100 @@
---
title: "RDS API Coverage"
seoTitle: "Relational Database Service API Coverage — Spinifex Docs"
description: "The Amazon RDS API operations Spinifex implements, for the managed PostgreSQL and MariaDB engines it offers, covering instances, snapshots and parameters."
category: "Coverage"
sections:
- overview
tags:
- aws
- compatibility
- coverage
- rds
- databases
- postgresql
- mariadb
---
# RDS API Coverage
## Overview
Spinifex implements **26 operations** in the RDS `2014-10-31` API model.
### Engines
Spinifex offers PostgreSQL and MariaDB. Each DB instance is one dedicated system-owned VM running the engine directly, launched from a platform AMI and hidden from the customer's EC2 API. `Engine` is fixed at create: there is no in-place engine change, no cross-engine snapshot restore and no migration between the two.
`mysql` is not an accepted engine and is not an alias for `mariadb`. MariaDB is offered under its own AWS engine name, exactly as AWS RDS offers it, so a client — including Terraform's `aws_db_instance` — must set `engine = "mariadb"`.
Engine versions are pinned per engine. An `EngineVersion` naming anything but the pin is rejected, including a narrower minor version, because the AMI makes no promise about which minor it carries.
### The endpoint is private
The engine is reached over a customer-account ENI injected into a subnet of the DB subnet group, so the endpoint is reachable from inside the VPC only. The DB VM has other NICs that no customer security group governs, and the engine binds none of them — the port is not open there at all, rather than open and gated.
### TLS is required by default
Both engines enforce encrypted connections by default: `rds.force_ssl` on PostgreSQL and `require_secure_transport` on MariaDB. For MariaDB this is a deliberate divergence from AWS, which leaves it off. Both are boolean, modifiable and dynamic, so setting either to `0` in a parameter group restores plaintext without a reboot.
### Rejected parameters
A parameter whose omission would create a false safety, security or availability guarantee is rejected with `InvalidParameterValue` rather than silently dropped.
| Parameter | Why it is rejected |
|-----------|--------------------|
| `MultiAZ=true` | Single-AZ platform; a standby would not exist |
| `PubliclyAccessible=true` | The endpoint is a private VPC address |
| `StorageEncrypted=false` | Unencrypted storage is not offered |
| `EnableIAMDatabaseAuthentication` | IAM database authentication is not implemented |
| `Iops`, `StorageThroughput`, `StorageType` ≠ `gp3` | Provisioned performance classes are not implemented |
| `KmsKeyId`, `TdeCredentialArn` | Storage is encrypted with the cluster key, not a customer-managed one |
| `AvailabilityZone` | The platform exposes a single zone |
| `AvailabilityZoneGroup` (orderable options) | It selects a zone or local-zone group, and naming a zone is already refused |
| `DBSecurityGroups` | EC2-Classic security groups — use `VpcSecurityGroupIds` |
| `DBClusterIdentifier`, `DBClusterSnapshotIdentifier` | Clustered engines are not offered |
| `EnableCloudwatchLogsExports` | Log export is not implemented |
| `EngineVersion` other than the engine's pin, `Engine` on modify | No in-place engine or version change |
| `Engine=mysql` (and Aurora engines) | Oracle MySQL is not offered; `mariadb` is a distinct engine, not an alias for it |
| `NewDBInstanceIdentifier` | The identifier is the DNS label and the KV key |
| `DBPortNumber`, `DBSubnetGroupName` on modify | Both would move the endpoint |
| `MaxAllocatedStorage` | Storage autoscaling is not implemented |
| `ManageMasterUserPassword`, `RotateMasterUserPassword` | Secrets Manager integration is not offered |
| `CACertificateIdentifier` | The serving certificate is minted from the cluster CA |
| `Domain`, `DomainFqdn` | Active Directory domain join is not offered |
| `OptionGroupName` | Option groups are not offered |
| `CustomIamInstanceProfile` | The DB VM's instance profile is platform-owned |
| `EnableCustomerOwnedIp` | An Outposts feature |
| `ForceFailover` (reboot) | No standby to fail over to |
| `DBSnapshotIdentifier` (stop) | Snapshot-on-stop is not implemented |
### Operations
| Operation |
|---|
| `AddTagsToResource` |
| `CreateDBInstance` |
| `CreateDBParameterGroup` |
| `CreateDBSnapshot` |
| `CreateDBSubnetGroup` |
| `DeleteDBInstance` |
| `DeleteDBParameterGroup` |
| `DeleteDBSnapshot` |
| `DeleteDBSubnetGroup` |
| `DescribeDBEngineVersions` |
| `DescribeDBInstanceAutomatedBackups` |
| `DescribeDBInstances` |
| `DescribeDBParameterGroups` |
| `DescribeDBParameters` |
| `DescribeDBSnapshots` |
| `DescribeDBSubnetGroups` |
| `DescribeEvents` |
| `DescribeOrderableDBInstanceOptions` |
| `ListTagsForResource` |
| `ModifyDBInstance` |
| `ModifyDBParameterGroup` |
| `RebootDBInstance` |
| `RemoveTagsFromResource` |
| `RestoreDBInstanceFromDBSnapshot` |
| `StartDBInstance` |
| `StopDBInstance` |

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