Compare commits

..

386 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
Julian Sommer a865ffc48e release: update go.mod for v1.18.0 2026-08-26 12:32:26 +10:00
Julian Sommer 2eef7b2b42 fix: use the sigv4 helper to redact session tokens from mismatch logs
The local helper masked x-amz-security-token by line prefix, which matched the
header form and missed the presigned query form entirely, leaking the token into
the exported logs on every signature mismatch. Defer to bluebottle's
RedactedCanonicalRequest, which knows the structure and masks both forms.
2026-08-26 12:17:43 +10:00
Julian Sommer 521e547c5e chore: bump cov req 2026-08-26 12:04:07 +10:00
Julian Sommer 9637978177 fix: pin the ISO cells' spinifex checkout to GIT_BRANCH
Cells 21 and 22 compile the checked-out spinifex tree, but their checkout
had no ref, so a scheduled run built main against siblings pinned to dev.
2026-08-26 11:43:28 +10:00
Julian Sommer 3196c0c993 test: move gateway authz tests to external test packages
Both files only ever called exported identifiers, so binding them to the
exported API needs just an import alias and qualified call sites. Clears
the test-package-check failure that surfaced once dev's base ref moved to
origin/main.
2026-08-26 11:33:55 +10:00
Julian Sommer e3d394864d fix: propagate KV read errors when releasing an ECS reservation
releaseReservation returned nil for every kv.Get error, so a NATS
timeout or connection drop was reported as a successful release and the
stopped task's CPU/memory/GPU capacity leaked on that instance. Narrow
the nil return to jetstream.ErrKeyNotFound, matching the pattern already
used in service.go and task_stop.go, and return anything else.
2026-08-26 11:33:51 +10:00
Josh Siviour 78a0dc7661 fix: stop reporting a placement group read failure as a successful (#885)
removal
2026-08-26 11:19:05 +10:00
Julian Sommer 8a300431a6 fix: support '?' and backslash escapes in EC2 filter wildcard matching (#884) 2026-08-26 11:08:19 +10:00
Josh Siviour 474dff05d8 refactor: label kvutil CAS stages and convert routetable (#883) 2026-08-26 10:26:31 +10:00
Josh Siviour ef30b28b49 refactor: change EKS casUpdateMeta to use kvutil (#882) 2026-08-26 08:52:25 +10:00
Ben Duncan 92c2de0017 feat(firewall): scope ssh to an operator allowlist in custom.nft (#881)
The generated policy accepted ssh from any source and setup.sh rewrote
spinifex.nft on every run, so there was nowhere to record an allowlist that
survived an upgrade.

custom.nft is created once, never rewritten, and kept across a node reset.
It holds a define rather than rules, so an operator supplies a value and
cannot write a rule that fails the ruleset. The default accepts every source,
which is what the policy already did.
2026-08-25 14:37:43 -05:00
Josh Siviour 29e6d3c918 refactor: change ACM updateInUseByCAS to use kvutil (#880) 2026-08-25 21:57:38 +10:00
Josh Siviour a123cbe24c refactor: change IAM updateRoleCAS to use kvutil (#879) 2026-08-25 21:24:18 +10:00
Josh Siviour 602247af80 refactor: promote the daemon CAS primitives into kvutil (#878)
* refactor: promote the daemon CAS primitives into kvutil

* fix: do not warn about a lost lease when the node never held one
2026-08-25 15:37:07 +10:00
Julian Sommer 992de89df0 fix: scope ACM policy checks to the certificate a request names (#877) 2026-08-25 15:23:48 +10:00
Julian Sommer 535bed21f5 fix: scope policy checks for the JSON-body services (#876) 2026-08-25 15:00:11 +10:00
Julian Sommer f9373abb4b fix: lint issues 2026-08-25 14:13:09 +10:00
Julian Sommer b9c3d8a0d9 fix: scope ELBv2 policy checks to the resources a request names (#875) 2026-08-25 14:09:07 +10:00
Josh Siviour 51f09e43be refactor: move EKS cluster reconciler and teardown leases onto kvlease (#874)
* fix: carry per-instance fields on every kvlease log line

* refactor: move EKS cluster reconciler and teardown leases onto kvlease
2026-08-25 13:22:17 +10:00
Julian Sommer d39b17c6ad fix: scope EKS policy checks to real resource ARNs (#873) 2026-08-25 11:55:34 +10:00
Ben Duncan 24892e29e3 feat(console): accept credential handoff from mulgadc.com signup (#872) 2026-08-25 11:46:12 +10:00
Josh Siviour f733fed177 fix: preserve ochre teardown metadata for auto-reconcile (#863)
Teardown no longer purges the index registry or KB/DataSource records by
default, mirroring how they already survive a restart; a --purge-metadata
flag restores the old full wipe for an intentional destroy. On the next
appliance connect, a new reconcile pass re-creates any registry index whose
pg table is missing and re-enqueues ingest from its persisted SourceSpecs,
so a torn-down appliance heals without operator action and keeps its KB id.
2026-08-25 11:18:13 +10:00
Julian Sommer 596865e59c docs: README tweak 2026-08-25 11:15:32 +10:00
Julian Sommer 458f1167c4 docs: README rewrite 2026-08-25 11:04:51 +10:00
Julian Sommer e26fcdc7ac refactor: shared SelectNewestImage function 2026-08-25 10:43:16 +10:00
Julian Sommer 0d6676ecc8 chore: used shared iam formatter 2026-08-25 10:35:39 +10:00
Julian Sommer b62a630989 chore: use shared arn parser 2026-08-25 10:31:52 +10:00
Julian Sommer 53ccdd6af3 chore: more rds simplifications 2026-08-25 09:55:51 +10:00
Julian Sommer 874304b7dc chore: delete one line wrapper function 2026-08-25 09:50:51 +10:00
Julian Sommer 804c1e822b fix: enforce resource-scoped IAM policies (#871) 2026-08-25 09:43:20 +10:00
Josh Siviour 8692871d15 refactor: convert the RDS reconciler to the shared kvlease package (#870) 2026-08-25 09:30:58 +10:00
Josh Siviour 578566b801 refactor: convert the bedrock reaper to the shared kvlease package (#869) 2026-08-25 08:58:02 +10:00
Josh Siviour 5675a2b37c refactor: convert the ECS scheduler to the shared kvlease package (#867)
* refactor: ECS scheduler to used shared kvlease package

* fix: wait for the ECS lease release before Run returns
2026-08-25 08:05:17 +10:00
Julian Sommer 4869f3b021 fix: evaluate EC2 policies against the request's resource ARNs (#866) 2026-08-24 15:49:07 +10:00
Josh Siviour 4bbc96f1e0 refactor: extract the KV leader lease into one package (#865) 2026-08-24 14:38:54 +10:00
Julian Sommer 6fc7bdfab8 refactor: share EC2 ARN construction in a new arn leaf package
getResourceType and ec2ARN both encoded the same resource-type knowledge on
opposite sides of the gateway. The unknown sentinel becomes ok == false, since a
sentinel that still looks like a valid ARN segment is how a wrong ARN gets built;
DescribeTags keeps emitting "unknown" on the wire.
2026-08-24 14:36:17 +10:00
Julian Sommer 82654ae5e4 chore: enable prefer-destructing in test files 2026-08-24 14:29:59 +10:00
Julian Sommer 0c6606247a chore: enable no-misused-promises 2026-08-24 14:23:09 +10:00
Julian Sommer 36d17c6a95 chore: enable jsx-a11y/prefer-tag-over-role 2026-08-24 14:15:31 +10:00
Julian Sommer d80542ac80 fix: pin predastore dev version 2026-08-24 14:14:20 +10:00
Julian Sommer f58fc9c00a chore: enable no-confusing-void-expression 2026-08-24 14:07:35 +10:00
Julian Sommer 58e1bd2c5a fix: reject and enforce IAM Condition blocks on identity policies (#864) 2026-08-24 14:04:32 +10:00
Julian Sommer 0c19132949 fix: enable unicorn/prefer-single-call 2026-08-24 12:34:36 +10:00
Julian Sommer 71b94cd3c2 chore: enable more vitest lint rules 2026-08-24 12:31:51 +10:00
Julian Sommer c8aafa5de8 chore: delete stale migrations since we forced clean re init anyway 2026-08-24 12:02:21 +10:00
Julian Sommer 4fbde4daa8 chore: fix test 2026-08-24 11:52:04 +10:00
Julian Sommer 32f2d2f7f6 feat(ui): enable ultracite's anti-slop oxlint preset (#862) 2026-08-24 11:24:13 +10:00
Josh Siviour f0ccf0d0c3 Fix/ecs scheduler leader cas (#861)
* fix(ecs): guard scheduler leader refresh and release with a revision CAS

* Ignore .cache

* update comment
2026-08-24 11:15:58 +10:00
Julian Sommer 52ae70ca33 refactor(ui): type the form values the CLI panels read (#860) 2026-08-24 10:59:33 +10:00
Julian Sommer ae112a5a49 test(ui): type the query and SDK mock test helpers (#859) 2026-08-24 10:58:32 +10:00
Julian Sommer dc92be8b7e refactor(ui): validate route search params with zod schemas (#858) 2026-08-24 10:55:22 +10:00
Julian Sommer 461cdc5593 fix: improve tests 2026-08-24 10:11:32 +10:00
Julian Sommer 4122ef6b56 chore: delete duplicate tests 2026-08-24 09:59:06 +10:00
Julian Sommer da0c5cf27f chore: delete useless mocks 2026-08-24 09:52:16 +10:00
Julian Sommer 56d8792fa0 fix: more test cleanup 2026-08-24 09:47:07 +10:00
Julian Sommer 8e59b6c34e chore: remove useless tests 2026-08-24 09:43:30 +10:00
dependabot[bot] 97ed54445a chore: bump the npm-dependencies group across 1 directory with 23 updates (#857) 2026-08-24 09:29:59 +10:00
Julian Sommer 2d925190dc fix: disable react compiler in tests 2026-08-24 09:26:41 +10:00
Julian Sommer 1d7f2f066a fix: disable now unneeded lint rules 2026-08-24 09:19:58 +10:00
Julian Sommer 3eb3ffdc9a feat: use oxlint native react compiler 2026-08-24 09:18:05 +10:00
dependabot[bot] 9b5acc738c chore: bump the go-dependencies group with 8 updates (#855) 2026-08-24 09:03:18 +10:00
Josh Siviour a80ffbc0a0 fix: suppress OVN DHCP on a statically-addressed customer ENI port (#854)
The RDS customer endpoint ENI is configured statically in the guest, but OVN still attached dhcpv4_options to its logical switch port. The guest's cloud-init dhcpcd then took an OVN DHCP lease on that NIC and flushed the static address at the 3600s lease expiry, roughly an hour after every boot. Thread a SuppressDHCP flag from the customer-ENI creation through the ENI record, port event, and PortSpec, and gate the dhcpv4_options assignment at both create sites on it. The primary NIC keeps DHCP for IMDS; a delete and recreate never re-adds DHCP because the reconciler intent carries the flag too.
2026-08-23 23:18:13 +10:00
Ben Duncan 8b3c706ea3 docs: rework the multi-node install guide, fix cross-doc links
Multi-node install
- Lead with the fact that the guide builds a three-node cluster, and say
  how to go beyond three. The callout sits inside ## Overview because the
  docs site discards everything above the first section heading.
- Replace the OVN-only sizing prose with a table covering all three
  distributed layers, and move the OVN control-plane detail into the VPC
  networking guide, linked from here.
- Add minimum and recommended hardware.
- Move the object storage layout to the end as background; it was the
  second thing a reader hit.
- Collapse the ISO conversion notes from a section into one skippable
  box, now that --force is unconditional in Step 4 and the formation
  port needs no manual intervention.
- Expand verification with spx get nodes and spx top nodes, including
  what to look for in each.
- Drop the install-node.sh shortcut (internal tooling) and the
  "adding a node later" section (no good story yet).

Corrections found while writing
- Servers 4+ were described as running no database. They run predastore
  and viperblock like every other node; only the OVN database cluster is
  capped at three. Fixed in four places.
- Step 4 never needed the firewall down: spx admin init opens the
  formation port itself for the formation window. Only Step 3's
  peer-scoped OVN ports are affected.
- `nft list table inet spinifex_filter | grep spinifex_peers` could never
  match — the peer list is an nft define, expanded at load time. Anyone
  following it would have seen empty output and assumed the worst.
- Disambiguate "database": with predastore, NATS and OVN all in play it
  needed qualifying throughout.
- Align the example output region with the AWS_REGION the guide exports.

Cross-doc links
- Rewrite relative and category-prefixed links to the canonical
  /docs/<slug> form. The site publishes docs flat, so ../foo/README.md
  and /docs/category/foo both 404.
- Point the six links to unpublished docs at the source repo instead.
- Fix four fragments written against GitHub's anchor algorithm.
2026-08-21 14:14:28 -05:00
Ben Duncan b834910e64 UCS tweaks 2026-08-21 09:53:04 -05:00
Josh Siviour b482b41e96 feat: implement EC2 MonitorInstances and UnmonitorInstances (#853)
* feat: implement EC2 MonitorInstances and UnmonitorInstances

The telemetry collector already supports both EC2 monitoring tiers and
already switches a running poller between them when its discovery file
changes, but nothing could make that change: Monitoring.Enabled was only
ever written at launch, and both API actions returned InvalidAction.

Route through the owning daemon over ec2.cmd.<id> rather than the
spinifex-workers queue group, so an instance running on any node is
reachable; no responders falls back to the shared stopped store, where the
write is a CAS so a racing start-claim cannot resurrect a deleted record.

Also report Monitoring on DescribeInstances. It is derived in
ProjectInstance from the same field the collector's period reads, so a
describe cannot disagree with the tier actually being collected and
instances launched before this change report disabled without a backfill.

* fix: wake the telemetry poller when its monitoring tier changes

poller.run re-read its period inside the tick loop, so a reset waited out
the interval being replaced: moving a 300s instance to the detailed tier
took up to five minutes to have any effect, which reads as the API call
not working. Found verifying monitor-instances on env13.

updateMeta now signals a buffered retier channel when PeriodSeconds moves
and run selects on it alongside the ticker. The signal only resets the
ticker rather than forcing a tick, so a published delta still spans the
period its batch declares. The send is non-blocking and the channel
coalesces, so a slow poller cannot stall the reconcile loop.
2026-08-21 15:36:40 +10:00
Josh Siviour bed994859c fix: durable Ochre/RDS appliance lifecycle (static ENI, teardown GC, stale LSP) (#852)
* fix: join the daemon host-port ENI to the appliance security group

The customer ENI's self-referencing SG drops all ingress except its own
members, so the daemon's host-port ENI could open TCP but never complete a
postgres handshake. Carry the appliance's security groups onto the daemon ENI
at create, and re-associate an already-created one in place.

* fix: complete teardown GC when a volume's metadata doc is already gone

DeleteVolumeOnTerminate and DetachVolumeOnTerminate propagated the
NoSuchKey error from a missing volume metadata doc as a hard failure, so
the teardown reaper backed off forever and never purged the terminated
record. A missing metadata doc means the volume is already gone, so treat
it as success via objectstore.IsNoSuchKeyError at both call sites, with a
matching tolerance in the instance-cleaner adapter.

* fix: statically address the RDS customer ENI so it survives the DHCP lease

The customer ENI was configured via a one-shot udhcpc with no renewal
against OVN's 3600s lease, so the DB endpoint went address-less an hour
after every boot. Resolve the customer subnet's prefix and gateway at
launch and emit the ENI as a static NIC (CIDR + gateway, never the
default route), mirroring the mgmt NIC, so it no longer depends on the
lease being renewed. The guest static branch now installs the same
source-based return-path policy a non-default DHCP NIC gets.

* fix: delete the OVN logical switch port when an ENI is deleted

An ENI delete published vpc.delete-port fire-and-forget, so a lost or slow
message let the ENI record and its IP free through with the logical switch
port still in OVN. A recreate onto the same IP then had two ports carrying
it and the router latched the stale MAC, blackholing the datapath. Publish
the delete synchronously so the port is confirmed gone before reuse, and
make DeletePort idempotent so an already-absent port is a no-op.
2026-08-21 15:11:02 +10:00
tomnewton-mulga e22db02d24 fix: Rewrite Cisco and OnLogic docs 2026-08-21 14:06:00 +10:00
Josh Siviour 3bb35deea2 fix: dial the ochre appliance customer ENI, not the management NIC (#851)
Both the endpoint ENI and the management NIC carry the rds-db-instance tag,
so a first-match pick non-deterministically dialed the unreachable management
NIC. Select the endpoint ENI by its RDS description, falling back to any usable
tagged NIC.
2026-08-21 14:03:59 +10:00
Josh Siviour 7bfeaf885c fix: give LB microVM initramfs headroom by bumping sys.micro to 256 MiB (#850)
The ELBv2 LB microVM boots with -m 128 (sys.micro). After kernel
reservations the 37.7 MiB initramfs — 45% of it the Go 1.27 lb-agent —
leaves under 512 KiB of headroom, so the guest panics with 'Initramfs
unpacking failed' before init and lb-agent never heartbeats.

Bump sys.micro to 256 MiB so the initramfs unpacks with margin, and add
an uncompressed-initramfs gate to build-microvm-image.sh (the compressed
gate was blind to the guest-RAM footprint) so a future bump fails the
build loudly instead of panicking in production.
2026-08-20 23:14:20 +10:00
Josh Siviour 3e4148cf15 fix: let a class-change modify recover a failed RDS instance whose agent is dead (#849)
A class-change ModifyDBInstance is the intended remedy for a failed DB
instance, but applyPendingModifications applied the parameter group through the
live instance agent first. A dead agent returned ErrCommandUnreachable and
aborted the modify before replaceInstanceVM could run, so the one recovery
lever the API offers could not run for the case that needs it most.

The replacement VM's fresh agent applies the resolved set itself on boot, so
the pre-replace apply against the old dead agent is redundant for a class
change. Tolerate ErrCommandUnreachable there: resolve the class-correct set
(a KV read, no agent), persist it for the fresh agent, and skip only the dead
agent's apply. Parameter-group-only modifies still fail on a dead agent.
2026-08-20 23:10:53 +10:00
Josh Siviour c941830664 feat: gate self-host inference behind capacity-based ThrottlingException (#848)
* feat: gate self-host inference behind capacity-based ThrottlingException

Add a process-scoped concurrency limiter keyed by (servingAccountID,
modelID) with capacity = catalog MaxConcurrency x max(ModelUnits, 1),
and apply it to every self-host Converse/ConverseStream/InvokeModel/
InvokeModelWithResponseStream path. Over-capacity requests reject
immediately with ThrottlingException instead of queuing inside vLLM,
matching real Bedrock. Managed (Anthropic) providers are untouched.

* test: mark admission_test as in-package for the unexported limiter

* test: cover capacity admission gate and throttling
2026-08-20 22:28:52 +10:00
Josh Siviour c918f6834a feat: back up the ochre pgvector schema to object storage (#847)
* feat: back up the ochre pgvector schema to object storage

Adds account-scoped pg_dump/restore of the per-account kb_<accountID>
schema to predastore, gzip-compressed and keyed under the account's
own prefix. Backup shells out to pg_dump (--no-owner --no-privileges
--clean --if-exists) behind a PgDumper seam; restore runs psql then
repairs the account role's grants via EnsureAccount/RegrantAccount
since the dump carries no ownership. Exposed as
`spx admin ochre vector backup/restore --account <id>`.

* test: cover ochre vector backup dump/restore and error paths
2026-08-20 22:28:25 +10:00
Julian Sommer f10c9434d1 fix: detect KV CAS conflicts on replicated buckets (#843) 2026-08-20 16:16:22 +10:00
Josh Siviour f9c84919ef feat: co-served GPU model bundles (vLLM + TEI) with family-aware reaper (#845)
* feat: co-served GPU model bundles (catalog, lifecycle, embed/rerank routing)

Serve the generative LLM, embedder and reranker as one co-resident GPU
bundle: EMBEDDING/RERANK catalog modalities + co-serve group, bundle-aware
admission (one GPU claim sized to summed member VRAM), single-flight one-VM
launch with a member->port EndpointRecord, DynamicEndpointResolver member
routing. Ochrevector embed/rerank/token-introspection resolve through the
resolver (static single-endpoint map retired); rerank degrades to plain KNN on
absent/failure. Public InvokeModel-embeddings and top-level Rerank deferred.

* feat: co-serve vLLM+TEI bundle image with per-engine readiness

Replace the single-model vllm-serve unit with a generic bedrock-serve@
systemd template driven by per-member BEDROCK_* env files, dispatching to
vLLM or text-embeddings-router by engine and binding each member to its own
port and weights device. TEI serves directly on its serve port; readiness
probes each member on the route its engine answers (vLLM /v1/models, TEI
/health) with the engine family carried end-to-end from the catalog.

* fix: serve nomic embedder correctly in the co-serve bundle

Bound embeddings requests to the TEI client batch limit so a large
document's chunks are split across requests instead of 422ing. Serve
nomic with explicit mean pooling, since the flat weights layout omits
1_Pooling/config.json and TEI would otherwise default to CLS. Add
bge-base-en-v1.5 as a standalone alternative embedder.

* fix: make the bedrock reaper family-aware and pin-safe

A pinned endpoint is now exempt from terminate-and-relaunch on scrape
failure, mirroring how it is already exempt from idle reclaim, so a
transient unreachability can no longer tear down operator- or
commitment-managed serving.

A bundle with no generative vLLM member exposes no vLLM load metrics, so
it is liveness-probed on /health and kept warm rather than scraped for
idleness on a route it does not serve and reaped in a loop.

* test: lock in co-serve group-member convergence to one VM

* fix: pin self-host HF repo/revision in catalog and stop nested config flattening

Carry HFRepo/HFRevision per self-host catalog entry; weights pull defaults
--hf-repo and --revision from them so a bare pull cannot silently grab a
regressed upstream main (the nomic v5 config.json duplicate-field break).
Restrict fixed config filenames to the repo root so a nested config.json
(sentence-transformers 1_Pooling/) is dropped instead of flattening onto the
model config, and refuse any two source paths that collide at one dest key.

* fix: let endpoint delete clear an account-scoped pinned record and report honestly

A bare 'endpoint delete' resolved the Global key, which never held a pinned,
account-scoped record, then printed 'torn down' over the idempotent no-op while
the record survived. Add --account to the delete command so an operator can
target the account 'endpoint list' shows, and return DeleteEndpointOutput.Removed
so the CLI names a real teardown versus a lookup that found nothing and points
at --account. A gone VM already tolerates NotFound, so a scoped delete clears a
record whose instance was terminated out of band.
2026-08-20 16:08:55 +10:00
Josh Siviour 8eb3e4f879 fix: stop emitting an empty contentBlockStart union for text in ConverseStream (#844)
Real Bedrock emits contentBlockStart only for toolUse blocks; text flows
messageStart -> contentBlockDelta -> contentBlockStop -> messageStop. An
empty start:{} union violates Smithy's exactly-one-member rule and breaks
strict SDK stream consumers (boto3 errors, the AWS Go SDK stalls).
2026-08-20 15:50:38 +10:00
Julian Sommer d4d8fefae6 fix: join the drift loop goroutine before restoring the test timing vars
The drift loop tests launched DriftLoop with a bare go statement and never
joined it, so shrinkDriftTiming's cleanup wrote DriftInterval and
driftBackoffBase while the loop was still reading them in nextDriftBackoff.
Cleanups run LIFO, so registering the join after shrinkDriftTiming makes it
run first and closes the window.
2026-08-20 14:36:49 +10:00
Julian Sommer f765119515 fix: make instance-profile role and tag writes atomic (#842) 2026-08-20 14:32:38 +10:00
Julian Sommer 46f953698f fix: requeue an incomplete reconcile pass instead of waiting a drift interval (#840) 2026-08-20 13:34:39 +10:00
Julian Sommer 7b1890eb8a refactor: use the stdlib uuid package instead of github.com/google/uuid (#841) 2026-08-20 11:57:23 +10:00
Julian Sommer adb60963c3 fix: back QMP test mocks with a unix socket instead of net.Pipe
Go 1.27's encoding/json v2 decoder stops at the closing brace and leaves
Encoder.Encode's trailing newline unread. net.Pipe is unbuffered and
synchronous, so the writer blocks on that undelivered byte while the peer
blocks writing its reply, deadlocking on the first QMP command.

TestTryBlockdevDel hung until the 180s package timeout. A real unix socket
is kernel-buffered and matches production QMP transport, which is already
the pattern newMockQMPClientWithEvents uses.
2026-08-20 11:43:58 +10:00
Julian Sommer 74117e5b26 fix: remove the broken golangci-lint cache path 2026-08-20 11:37:10 +10:00
Julian Sommer 8e01e76dc0 fix: disable embedlit 2026-08-20 10:52:03 +10:00
Julian Sommer e36c8f6ba8 chore: update to go 1.27 2026-08-20 10:37:52 +10:00
Julian Sommer 98365d5554 fix: wait for the upstream release in the stale-lease DHCP test
The replacement lease is persisted before releaseStaleUpstream runs, so
the KV predicate did not prove the release had completed and the count
assertion could observe zero under race-detector scheduling.
2026-08-20 10:01:11 +10:00
Julian Sommer c84200e519 feat: probe real qemu state (#839) 2026-08-20 09:46:53 +10:00
Josh Siviour c5a4bdab75 fix: attach to leader KV buckets before creating them (#837)
The RDS reconciler and the ECS scheduler call InitLeaderBucket on every
tick, and it created before attaching. CreateKeyValue against a bucket that
already exists is only a no-op when the stored config matches the requested
one; otherwise JetStream answers 10058 and the caller silently falls back to
an attach.

On a multi-node cluster it never matches. The stored buckets are replicated
while the RDS and ECS calls ask for the default replica count, so every tick
billed an error. That is the whole of the 14-18% JetStream API error rate
seen on env1 and env19, and it landed entirely on the meta leader because
that is the node that serves STREAM.CREATE. Single-node clusters matched and
so sat at 0%, which is why this never showed up in CI.

Attaching first removes the per-tick create outright, so it no longer matters
whether the configs agree. EKS already passed a replica count and so did not
error, but it made the same wasted call every tick and is changed with them;
its replica count now applies to a genuine first creation, which is the only
time it was ever read.

The regression test asserts on the API traffic rather than the return value.
Both orders reach the same handle, so idempotency cannot tell them apart, and
on a single-node test server the old order raised no error at all - it just
issued the create. Watching for the STREAM.CREATE advisory catches it there.
2026-08-20 09:33:34 +10:00
Julian Sommer 7f6557d55d fix: make state retry policy injectable (#836) 2026-08-20 09:25:17 +10:00
Julian Sommer 8f680abcde fix: don't show system owned images in run instances ui 2026-08-20 09:08:24 +10:00
Ben Duncan f32123b6c9 Sandbox env reference 2026-08-19 14:45:52 -05:00
Ben Duncan aaac8fbe1c feat: per-account service quotas with an admin override surface
Adds per-account resolution of the [quota] limits from a KV override record,
an admin surface and CLI to set one, RDS and load-balancer dimensions, and an
e2e suite that drives every gate on single and multi node.

Also fixes external IPAM initialising without retry: a node that lost a race
against JetStream convergence silently served the disabled EIP stub for the
life of the process, answering cluster-wide with an empty address list.
2026-08-20 04:07:40 +10:00
Ben Duncan 4dadce8ed5 test: cover the external pool projection fed to IPAM 2026-08-20 04:07:19 +10:00
Ben Duncan ae2a1830ef Merge remote-tracking branch 'origin/dev' into account-quota 2026-08-20 04:01:14 +10:00
Ben Duncan cfb0abc8a8 chore: drop a local settings temp file committed by mistake 2026-08-20 04:01:11 +10:00
Ben Duncan 4508193180 fix: fail daemon start when a node with public pools cannot build external IPAM 2026-08-20 03:48:45 +10:00
Ben Duncan 0d8425ef51 test: skip the EIP quota leg where a cell does not serve EIPs from every node 2026-08-20 03:31:40 +10:00
Ben Duncan 19ec127e2a test: charge a multi-instance launch for every instance it asks for 2026-08-20 02:43:44 +10:00
Ben Duncan f41f5530a0 test: add an e2e suite proving quotas gate every capped resource 2026-08-20 02:26:27 +10:00
Ben Duncan 0f8471062f feat: add an admin surface for per-account quota overrides 2026-08-20 02:05:17 +10:00
Ben Duncan dca7a38356 feat: resolve service quotas per account from a KV override record 2026-08-20 01:52:07 +10:00
Julian Sommer fcde6b8de3 fix: authorize RDS snapshot source and target (#835) 2026-08-19 15:36:20 +10:00
Julian Sommer f21db26816 chore: block panic 2026-08-19 15:03:47 +10:00
Julian Sommer 60c9dfa89d fix: remove rds panics 2026-08-19 14:58:35 +10:00
Julian Sommer 941affcf3b fix: validate addon catalogue without panicking 2026-08-19 14:41:23 +10:00
Julian Sommer 6e1e2f3fbb fix: don't panic on managed policy json unmarshal failure 2026-08-19 14:39:39 +10:00
Julian Sommer 28330cb318 fix: dont panic on missing flags 2026-08-19 14:37:07 +10:00
Julian Sommer dede6c1158 fix: mock race 2026-08-19 14:13:21 +10:00
Julian Sommer cf2e259a30 fix: use 1024 bit certs in rds tests to speedup 30% 2026-08-19 14:01:13 +10:00
Julian Sommer 9f74594cb5 fix: invalidate backup queries after modify 2026-08-19 13:54:45 +10:00
Julian Sommer 6ef7dd8692 fix: name the missing RDS engine image instead of answering ServerInternal 2026-08-19 13:39:54 +10:00
Josh Siviour 52f12586f5 fix: let the tail sampler shed predastore traces instead of a head ratio (#834)
predastore pinned MULGA_ROOT_TRACE_RATIO=0.05 so guest block I/O, which is
locally rooted and has no caller trace, would not drown request traces. That
worked, but TraceIDRatioBased decides when the root span is created, before
any error exists, so it discarded 95% of predastore failures along with the
successes. An RCA on a rare locally-rooted failure had a 1-in-20 chance of
having the span at all.

The sink now runs a tail-sampling tier in front of APM Server that keeps every
errored trace, keeps every trace slower than 2s, and samples the rest at 10%.
It judges a trace once it is whole, so it can shed the same volume while
keeping the traces worth having. Dropping the head ratio is what lets it see
them: at 0.05 the tail sampler only ever saw 5% of predastore roots, so its
error rule could keep at most 5% of the failures.

Sized before changing: predastore's true local root rate is ~434 spans/s
across six active CI environments, against 21.7/s reaching the sink today.
Driving the tier at 508 spans/s held it at 88 MiB of a 1.5 GiB cap and 1.8%
CPU with zero drops, and trace storage goes from 14.9 to 22.5 GB per 7 days
on a volume with 735 GB free.
2026-08-19 13:25:45 +10:00
Josh Siviour 67425c41a0 docs: document the frontmatter length rules for the docs site (#832)
* docs: bring doc titles and descriptions into SEO length limits

Rewrite the frontmatter title and description of every published doc so the
rendered <title> lands in 50-60 characters (including the site suffix) and the
meta description lands in 150-160 characters.

* docs: move SEO titles to a seoTitle field and restore display titles

The frontmatter title renders as the page H1 and the sidebar label, so tuning it
for the title tag shortened nothing and lengthened the visible navigation. Put
the search-facing string in seoTitle and give title back its original value.

* docs: document the frontmatter length rules for the docs site
2026-08-19 12:24:15 +10:00
Julian Sommer 3ee412ca02 fix: keep the RDS endpoint ENI alive across a DB instance replace (#833) 2026-08-19 12:18:04 +10:00
Julian Sommer 894fe84255 test: dump OVN MAC_Binding and route state when natuplink egress fails
The artifact bundle carried the guest console and the spinifex journals but
nothing from OVN, so a phase 6 egress failure could not be attributed to an
unresolved gateway nexthop MAC versus a return-path or upstream problem.

Register an OnFailure at the top of phase 6 that writes
static-mac-binding-list, the SB MAC_Binding table and the VPC router's
lr-route-list into the bundle.
2026-08-19 12:08:38 +10:00
Julian Sommer 7eff416c25 fix: collect IPsec and OVN datapath logs in the nightly e2e bundle
The nightly collected only spinifex-* journal units, so a cross-chassis
black-hole could not be attributed to IPsec SA establishment versus SB
port binding. ovn-controller, ovn-northd and ovs-vswitchd log to files
rather than journald, so they were absent from the bundle entirely.

Adds the openvswitch-ipsec.service journal per node, mirroring e2e.yml,
and pulls /var/log/ovn and /var/log/openvswitch off each node.
2026-08-19 12:06:35 +10:00
Josh Siviour e84753f9b9 docs: bring doc titles and descriptions into SEO length limits (#831)
* docs: bring doc titles and descriptions into SEO length limits

Rewrite the frontmatter title and description of every published doc so the
rendered <title> lands in 50-60 characters (including the site suffix) and the
meta description lands in 150-160 characters.

* docs: move SEO titles to a seoTitle field and restore display titles

The frontmatter title renders as the page H1 and the sidebar label, so tuning it
for the title tag shortened nothing and lengthened the visible navigation. Put
the search-facing string in seoTitle and give title back its original value.
2026-08-19 12:05:59 +10:00
Julian Sommer a8d0ba185e fix: bound the natuplink guest egress loop to the console verdict budget
The guest fail-loop ran 36 iterations at ~28 s each (two 10 s curl timeouts,
a 3 s ping, a 5 s sleep), so NAT-E2E-EGRESS-FAIL first printed at ~16m50s
while the console wait gave up at 8m. The marker was structurally
unreachable and an egress failure reported only a byte count.

Derive the wait budget from the loop instead of hard-coding both ends: 10
iterations at a 30 s per-iteration allowance plus 3m for boot and cloud-init,
keeping the same 8m ceiling. The user-data script is now generated from those
constants and the existing marker consts. Dump the last console read via
OnFailure so a budget overrun also leaves the console in artifacts.
2026-08-19 12:02:31 +10:00
Julian Sommer 17cea6cd4b fix: gate the SG datapath probe on the target's HTTP listener
TestSGPolicyDatapath/AllowedTraffic waited 60s for curl to reach the
target's HTTP server but nothing ever waited for that server to start.
Its cloud-init runs later than the client's sshd, which is what the
preceding stage actually gates on, so the budget was measured from the
wrong baseline and the gap between the two is unbounded on a loaded
node. The failures reported curl exit 7, a refused connection: a
missing SG allow is a drop ACL here, which would time out as exit 28.

The target guest now reports its listener on the serial console, the
only channel back to a guest whose SG has no SSH ingress, and a new
TargetHTTPReady stage waits for that marker before AllowedTraffic runs
at all. AllowedTraffic keeps its 60s budget, which now only has to
cover chassis flow install, and dumps VPC flow diagnostics when it does
fail so a genuine propagation failure is attributable from CI artifacts.
2026-08-19 11:59:02 +10:00
Julian Sommer 0a9e197f41 chore: mark the guest-port backoff test as an in-package test
test-package-check fails on dev without it: the test mutates the
unexported guestPortBackoffBase / guestPortBackoffMax and drives
ensureGuestPortDatapath and recordPortFailure directly.
2026-08-19 11:58:53 +10:00
Julian Sommer cc21f7f9e8 chore: enable lint rule 2026-08-19 11:48:00 +10:00
Julian Sommer 4e36b4ee6c fix: more vitest speedup 2026-08-19 11:46:17 +10:00
Julian Sommer bc2da9cdf0 fix: disable react compiler in tests for 20% speedup 2026-08-19 11:41:39 +10:00
Julian Sommer 4672541b38 fix: fail a public-IP launch closed when a node has no external IPAM
A node whose NewExternalIPAM call lost the race with the JetStream meta
leader logs a WARN and carries on with d.externalIPAM nil. That nil
concrete pointer was handed straight to the PublicIPAllocator interface,
where it reads as non-nil, so the guard in PrepareRunInstances passed and
findPool derefed a nil receiver. RunInstances into a subnet with
MapPublicIpOnLaunch took the daemon down with SIGSEGV, and because the ENI
was already persisted and the gateway saw only a NATS timeout, nothing
rolled it back: the orphan ENI later rejected DeleteSecurityGroup with
DependencyViolation.

The concrete value now crosses into PublicIPAllocator and PublicIPReleaser
only once init succeeded, and AllocateIP/ReleaseIP tolerate a nil receiver
so another caller repeating the mistake degrades instead of panicking.

Making the interface genuinely nil is not enough on its own: the existing
nil check would then have skipped assignment altogether and booted an
instance with no public address on a subnet that promised one. The
wantPublic decision now runs unconditionally and a missing allocator takes
the allocation-failure path, which detaches and deletes the ENI, returns
capacity and reports InsufficientAddressCapacity.

Launches that do not ask for a public IP are untouched: subnets default to
MapPublicIpOnLaunch only when the cluster has public pools, which is the
same condition that initialises the IPAM.
2026-08-19 11:16:41 +10:00
Julian Sommer bdc79108e9 fix: make the DB endpoint the only path to the engine (#830) 2026-08-19 11:04:21 +10:00
Josh Siviour 0e08b9d256 feat: ship per-test JUnit results to the Elasticsearch sink (#829)
* feat: ship per-test JUnit results to the Elasticsearch sink

Every e2e workflow already converts its test log to JUnit XML and renders
it into the job summary, then discards it with the artifact. mulga-ci-runs
records which suite failed; nothing records which test failed, so
root-causing a failure means reading raw Actions logs across historical
runs.

The junit-to-es action bulk-indexes one doc per leaf test case into
mulga-ci-test-results: name, subtest, status, duration, failure text, run
id, cell, suite, branch and sha. Parent t.Run wrappers are dropped so a
failing subtest counts once, matching the job summary renderer. The doc id
is deterministic, so a replayed post overwrites rather than
double-counting.

Called from every workflow that produces JUnit XML, and tolerated on
failure: an unreachable sink must not turn a green test run red.

* feat: classify unparsed JUnit cases as unknown, not error

go-junit-report emits <error message="No test result found"> when a
=== RUN line has no matching result line, which means the log it was fed
was truncated or filtered. Recording that as a failure would put 78
phantom failures per green bare-metal run into the pass rate.
2026-08-18 22:54:54 +10:00
Josh Siviour c11bec9d86 fix: back off a guest port that cannot converge, and name its ENI (#828)
Two ports on env19 have no tap behind them anywhere in the cluster:
their NB Logical_Switch_Port reads up: false and the SB Port_Binding
has chassis: [], so nothing can ever claim them. ensureGuestPortDatapath
is already bounded, but the outer reconcile loop calls it again every
cycle, so each dead port pays the full 45s nudge sequence and files
another ERROR roughly twelve times an hour, which is 1500 of vpcd's 1691
daily WARN lines and all of its ERRORs.

A port that burns its convergence deadline now backs off, doubling from
a minute to a thirty-minute cap, and clears the moment it binds. The
failure and recovery lines also carry the ENI, the EIP and the guest IP:
they previously named only the VPC and the LSP, so the guest behind a
stuck port could not be identified from telemetry.
2026-08-18 21:12:13 +10:00
Josh Siviour ea4e7e3fb0 fix: log every duration as integer milliseconds under a _ms key (#827)
slog renders a bare time.Duration as an int64 nanosecond count and names
the unit nowhere, so every duration field in the fleet read like
"elapsed":135000000000. The repo idiom of .Round(time.Second) changed
the value without changing the shape and truncated every sub-second
measurement to zero.

otelsetup.Millis converts a duration for logging under a snake_case key
ending in _ms, and TestSlogDurationConvention type-checks ./spinifex/...
and ./cmd/... to fail the build when a raw Duration reaches an slog call
or a Millis value is logged under a key without the suffix. It found 55
sites across 34 files, well past the sites a grep for time.Since shows,
because most durations come from a config field or a package constant.

A handler-level ReplaceAttr would fix every site at once but can only
emit d.String(), which Elasticsearch maps as a keyword and nothing can
aggregate over.
2026-08-18 20:38:13 +10:00
2176 changed files with 118315 additions and 24026 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
+43
View File
@@ -0,0 +1,43 @@
name: JUnit to Elasticsearch
description: >-
Bulk-index one doc per JUnit test case into the mulga Elasticsearch sink,
so per-test pass rates outlive the 90-day artifact retention. Call it once
per job right after the JUnit XML is produced (with `if: always()`), next to
the junit-report action that renders the same files into the job summary.
inputs:
junit-glob:
description: Shell glob matching the JUnit XML files to ship.
required: true
cell:
description: >-
Cell label stored as labels.ci_cell — the same value the job puts in
ci.cell for its OTel attributes (single, multi, gpu, ddil, 20, ...).
Without it, results from different topologies collapse into one bucket.
required: true
es-sink:
description: host:port of the Elasticsearch sink.
required: false
default: "192.168.1.13:9200"
index:
description: Target index.
required: false
default: mulga-ci-test-results
runs:
using: composite
steps:
- shell: bash
env:
JUNIT_GLOB: ${{ inputs.junit-glob }}
JUNIT_CELL: ${{ inputs.cell }}
MULGA_ES_SINK: ${{ inputs.es-sink }}
ES_INDEX: ${{ inputs.index }}
RUN_ID: ${{ github.run_id }}
RUN_ATTEMPT: ${{ github.run_attempt }}
WORKFLOW: ${{ github.workflow }}
BRANCH: ${{ github.head_ref || github.ref_name }}
SHA: ${{ github.sha }}
ARTIFACT_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
python3 "${{ github.action_path }}/ship.py"
+225
View File
@@ -0,0 +1,225 @@
#!/usr/bin/env python3
"""Ship per-test JUnit results to the Elasticsearch sink.
One doc per leaf test case, bulk-indexed into mulga-ci-test-results. The
mulga-ci-runs summary says which suite failed; this says which test, with the
failure text, so a per-test pass-rate trend outlives artifact retention.
Inputs (env):
JUNIT_GLOB Glob matching JUnit XML files. No matches is not an error:
a leg that never ran produces none.
JUNIT_CELL Cell label for these results (single, multi, gpu, 20, ...).
MULGA_ES_SINK host:port of the sink. Anonymous requests are superuser
there, so no credentials are involved.
ES_INDEX Target index; defaults to mulga-ci-test-results.
RUN_ID, RUN_ATTEMPT, WORKFLOW, BRANCH, SHA, ARTIFACT_URL
"""
from __future__ import annotations
import glob
import json
import os
import re
import sys
import urllib.error
import urllib.request
import xml.etree.ElementTree as ET
from datetime import datetime, timezone
# Go test output carries colour codes into the JUnit CDATA (harness.Phase
# emits them), which are noise in a stored failure message.
_ANSI_RE = re.compile(r"\x1b\[[0-9;]*m")
# Enough failure text to recognise a failure; the artifact log stays the full
# record.
_MAX_MESSAGE = 4000
# go-junit-report emits this when a `=== RUN` line has no matching result line,
# which means the log it was fed was truncated or filtered. It is a parse gap,
# not a test outcome, so it is stored under its own status and left out of every
# pass rate rather than counted as a failure.
_NO_RESULT = "No test result found"
def _strip_ansi(s: str) -> str:
return _ANSI_RE.sub("", s)
def _truncate(s: str) -> str:
s = s.strip()
return s if len(s) <= _MAX_MESSAGE else s[: _MAX_MESSAGE - 1] + "…"
def _suite_from_path(path: str) -> str:
base = os.path.basename(path)
if base.endswith(".xml"):
base = base[:-4]
if base.startswith("junit-"):
base = base[len("junit-") :]
return base
def _cases(path: str) -> list[dict]:
"""Parse one JUnit file into leaf test cases."""
try:
root = ET.parse(path).getroot()
except ET.ParseError as exc:
print(f"::warning::junit-to-es: {path} is not parseable ({exc}), skipped")
return []
# JUnit XML has two shapes: <testsuite> as root, or <testsuites> wrapping
# several. .iter() handles both.
out: list[dict] = []
for s in root.iter("testsuite"):
for tc in s.findall("testcase"):
status = "pass"
fail_type = ""
message = ""
for tag in ("failure", "error"):
node = tc.find(tag)
if node is not None:
status = "fail" if tag == "failure" else "error"
fail_type = tag
message = _strip_ansi(
((node.get("message") or "") + "\n" + (node.text or "")).strip()
)
if (node.get("message") or "").strip() == _NO_RESULT:
status = "unknown"
break
if status == "pass" and tc.find("skipped") is not None:
status = "skip"
out.append(
{
"name": tc.get("name", ""),
"classname": tc.get("classname", ""),
"time": float(tc.get("time", 0) or 0),
"status": status,
"fail_type": fail_type,
"message": message,
}
)
# Drop container cases: Go reports a parent t.Run as a rollup of its
# children, so keeping both double-counts one outcome. Same rule the job
# summary renderer applies.
names = {c["name"] for c in out}
containers = {n.rsplit("/", 1)[0] for n in names if "/" in n}
return [c for c in out if c["name"] not in containers]
def _doc(cell: str, suite: str, case: dict, ts: str, ci: dict) -> dict:
full = case["name"]
top, _, sub = full.partition("/")
doc = {
"@timestamp": ts,
"suite": suite,
"status": case["status"],
"duration_seconds": case["time"],
"artifact_url": ci["artifact_url"],
"labels": {"ci_run_id": ci["run_id"], "ci_cell": cell},
"test": {
"name": top,
"subtest": sub,
"full_name": full,
"classname": case["classname"],
},
"ci": {
"workflow": ci["workflow"],
"branch": ci["branch"],
"sha": ci["sha"],
"run_attempt": ci["run_attempt"],
},
}
if case["fail_type"]:
doc["failure"] = {
"type": case["fail_type"],
"message": _truncate(case["message"]),
}
return doc
def _doc_id(ci: dict, cell: str, suite: str, full_name: str) -> str:
# Deterministic so a replayed post overwrites instead of double-counting.
# run_attempt is part of it because a re-run is a distinct result.
return f"{ci['run_id']}-{ci['run_attempt']}-{cell}-{suite}-{full_name}"
def _bulk(sink: str, index: str, body: str) -> int:
req = urllib.request.Request(
f"http://{sink}/{index}/_bulk",
data=body.encode("utf-8"),
headers={"Content-Type": "application/x-ndjson"},
method="POST",
)
try:
with urllib.request.urlopen(req, timeout=60) as resp:
payload = json.loads(resp.read().decode("utf-8"))
except (urllib.error.URLError, OSError) as exc:
print(f"::error::junit-to-es: bulk POST to {sink}/{index} failed: {exc}")
return 1
if payload.get("errors"):
for item in payload.get("items", []):
err = next(iter(item.values())).get("error")
if err:
print(f"::error::junit-to-es: bulk rejected a doc: {json.dumps(err)}")
return 1
return 0
def main() -> int:
pattern = os.environ.get("JUNIT_GLOB", "").strip()
if not pattern:
print("::error::junit-to-es: JUNIT_GLOB is empty")
return 1
cell = os.environ.get("JUNIT_CELL", "").strip()
if not cell:
print("::error::junit-to-es: JUNIT_CELL is empty")
return 1
sink = os.environ.get("MULGA_ES_SINK", "").strip()
if not sink:
print("::error::junit-to-es: MULGA_ES_SINK is empty")
return 1
index = os.environ.get("ES_INDEX", "").strip() or "mulga-ci-test-results"
ci = {
"run_id": os.environ.get("RUN_ID", ""),
"run_attempt": os.environ.get("RUN_ATTEMPT", ""),
"workflow": os.environ.get("WORKFLOW", ""),
"branch": os.environ.get("BRANCH", ""),
"sha": os.environ.get("SHA", ""),
"artifact_url": os.environ.get("ARTIFACT_URL", ""),
}
ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
lines: list[str] = []
counts: dict[str, int] = {"pass": 0, "fail": 0, "error": 0, "skip": 0, "unknown": 0}
for path in sorted(glob.glob(pattern)):
suite = _suite_from_path(path)
for case in _cases(path):
doc = _doc(cell, suite, case, ts, ci)
counts[case["status"]] = counts.get(case["status"], 0) + 1
meta = {"index": {"_id": _doc_id(ci, cell, suite, doc["test"]["full_name"])}}
lines.append(json.dumps(meta))
lines.append(json.dumps(doc))
if not lines:
# Not an error on its own: the leg may have been skipped, or died
# before any test ran. The run summary doc records that separately.
print(f"junit-to-es: no JUnit files matched {pattern}, nothing to post")
return 0
rc = _bulk(sink, index, "\n".join(lines) + "\n")
if rc == 0:
print(
f"junit-to-es: posted {sum(counts.values())} test docs "
f"(cell {cell}) to {sink}/{index} — {counts['pass']} pass, "
f"{counts['fail']} fail, {counts['error']} error, "
f"{counts['skip']} skip, {counts['unknown']} unknown"
)
return rc
if __name__ == "__main__":
sys.exit(main())
Binary file not shown.

After

Width:  |  Height:  |  Size: 350 KiB

+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
+73 -8
View File
@@ -24,23 +24,88 @@
# behaviour, so a second node adds no assertion. Its DB VMs are capped at four
# concurrent by the suite's own semaphore, which is what keeps it inside the
# job's 50-minute budget alongside the suites above.
E2E_SUITES_SINGLE="single iam cert eks ecs storagegrowth partialblock rds"
# quota belongs to both topologies because the gate it asserts is gateway code
# 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.
# 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"
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
+95 -12
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,22 +204,40 @@ 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
# Tolerated on failure: the sink being unreachable must not turn a
# green test run red.
- name: Ship test results to Elasticsearch
if: always()
continue-on-error: true
uses: ./.github/actions/junit-to-es
with:
junit-glob: ${{ env.ARTIFACT_DIR }}/junit-*.xml
cell: ${{ env.BM_CELL }}
- name: Analyze e2e failures
if: always()
uses: ./.github/actions/e2e-analyze
with:
junit-glob: ${{ env.ARTIFACT_DIR }}/junit-*.xml
log-dir: ${{ env.ARTIFACT_DIR }}
title: "E2E failure analysis (cell 20)"
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
+22 -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
@@ -180,6 +180,16 @@ jobs:
junit-glob: ${{ runner.temp }}/ddil-results/junit-*.xml
title: "DDIL scenario results"
# Tolerated on failure: the sink being unreachable must not turn a
# green test run red.
- name: Ship test results to Elasticsearch
if: always()
continue-on-error: true
uses: ./spinifex/.github/actions/junit-to-es
with:
junit-glob: ${{ runner.temp }}/ddil-results/junit-*.xml
cell: ddil
- name: Collect Cluster Logs
if: failure()
working-directory: mulga/scripts/tofu-cluster
@@ -218,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"
+604 -87
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
@@ -342,20 +658,93 @@ jobs:
| tar -C "${ARTIFACT_DIR}/vm-console-node${idx}" -xf - 2>/dev/null
ssh "${SSH_OPTS[@]}" tf-user@${IP} 'sudo journalctl -u "spinifex-*" --no-pager --output=short-iso --since "60 min ago"' \
> "${ARTIFACT_DIR}/spinifex-journal-node${idx}.log" 2>/dev/null
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.
mkdir -p "${ARTIFACT_DIR}/ovs-logs-node${idx}"
ssh "${SSH_OPTS[@]}" tf-user@${IP} 'sudo tar -C /var/log -cf - ovn openvswitch 2>/dev/null || true' \
| tar -C "${ARTIFACT_DIR}/ovs-logs-node${idx}" -xf - 2>/dev/null
done
cat "${ARTIFACT_DIR}"/spinifex-journal-node*.log \
> "${ARTIFACT_DIR}/spinifex-journal.log" 2>/dev/null || true
echo "collected: $(ls "${ARTIFACT_DIR}" 2>/dev/null | wc -l) entries"
# 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
@@ -363,8 +752,18 @@ jobs:
go-junit-report -in "$LOG" -out "${ARTIFACT_DIR}/junit-${SUITE}.xml" || true
done
# Tolerated on failure: the sink being unreachable must not turn a
# green test run red.
- name: Ship test results to Elasticsearch
if: always()
continue-on-error: true
uses: ./spinifex/.github/actions/junit-to-es
with:
junit-glob: ${{ env.ARTIFACT_DIR }}/junit-*.xml
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
@@ -372,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 }}
@@ -394,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 }}
@@ -410,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)
@@ -430,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.
@@ -496,12 +936,13 @@ jobs:
- uses: actions/checkout@v7
with:
ref: ${{ env.GIT_BRANCH }}
path: mulga/spinifex
- name: Resolve sibling refs
id: siblings
env:
BRANCH: ${{ github.event_name == 'schedule' && 'dev' || github.ref_name }}
BRANCH: ${{ env.GIT_BRANCH }}
run: bash .github/resolve-sibling-refs.sh
working-directory: mulga/spinifex
@@ -615,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: |
@@ -678,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,') )
@@ -727,12 +1184,13 @@ jobs:
- uses: actions/checkout@v7
with:
ref: ${{ env.GIT_BRANCH }}
path: mulga/spinifex
- name: Resolve sibling refs
id: siblings
env:
BRANCH: ${{ github.event_name == 'schedule' && 'dev' || github.ref_name }}
BRANCH: ${{ env.GIT_BRANCH }}
run: bash .github/resolve-sibling-refs.sh
working-directory: mulga/spinifex
@@ -832,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,') )
@@ -950,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
@@ -968,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
@@ -1001,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.
@@ -1039,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
+54 -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]}"
@@ -394,6 +395,16 @@ jobs:
junit-glob: ${{ env.ARTIFACT_DIR }}/junit-*.xml
title: "E2E ${{ matrix.suite }} results"
# Tolerated on failure: the sink being unreachable must not turn a
# green test run red.
- name: Ship test results to Elasticsearch
if: always()
continue-on-error: true
uses: ./spinifex/.github/actions/junit-to-es
with:
junit-glob: ${{ env.ARTIFACT_DIR }}/junit-*.xml
cell: single
- name: Analyze e2e failures
if: always()
uses: ./spinifex/.github/actions/e2e-analyze
@@ -506,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"
@@ -617,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
@@ -659,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
@@ -712,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 }}
@@ -745,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}"
@@ -763,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]}"
@@ -796,6 +809,16 @@ jobs:
junit-glob: ${{ env.ARTIFACT_DIR }}/junit-*.xml
title: "Multi-Node E2E ${{ matrix.suite }} results"
# Tolerated on failure: the sink being unreachable must not turn a
# green test run red.
- name: Ship test results to Elasticsearch
if: always()
continue-on-error: true
uses: ./spinifex/.github/actions/junit-to-es
with:
junit-glob: ${{ env.ARTIFACT_DIR }}/junit-*.xml
cell: multi
- name: Analyze e2e failures
if: always()
uses: ./spinifex/.github/actions/e2e-analyze
@@ -879,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"
+89 -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} \
@@ -464,6 +517,16 @@ jobs:
junit-glob: ${{ env.ARTIFACT_DIR }}/junit-*.xml
title: "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()
continue-on-error: true
uses: ./spinifex/.github/actions/junit-to-es
with:
junit-glob: ${{ env.ARTIFACT_DIR }}/junit-*.xml
cell: single
- name: Analyze e2e failures
if: always()
uses: ./spinifex/.github/actions/e2e-analyze
@@ -510,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"
@@ -539,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 }}
@@ -636,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
@@ -762,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"
@@ -793,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]}; \
@@ -903,6 +970,16 @@ jobs:
junit-glob: ${{ env.ARTIFACT_DIR }}/junit-*.xml
title: "Multi-Node 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()
continue-on-error: true
uses: ./spinifex/.github/actions/junit-to-es
with:
junit-glob: ${{ env.ARTIFACT_DIR }}/junit-*.xml
cell: multi
- name: Analyze e2e failures
if: always()
uses: ./spinifex/.github/actions/e2e-analyze
@@ -952,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"
+8 -47
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,6 +248,14 @@ jobs:
junit-glob: ${{ env.ARTIFACT_DIR }}/junit-*.xml
title: "GPU E2E results"
- name: Ship test results to Elasticsearch
if: always() && steps.gate.outputs.paused != 'true'
continue-on-error: true
uses: ./.github/actions/junit-to-es
with:
junit-glob: ${{ env.ARTIFACT_DIR }}/junit-*.xml
cell: gpu
- name: Analyze e2e failures
if: always() && steps.gate.outputs.paused != 'true'
uses: ./.github/actions/e2e-analyze
@@ -303,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 }}
+19 -3
View File
@@ -46,7 +46,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v7
with:
go-version: "1.26.6"
go-version: "1.27.0"
- name: Build microVM artifacts (lb-agent + kernel + initramfs)
run: cd spinifex && make build-microvm-image
@@ -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:
+74 -89
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 }}
@@ -377,8 +340,30 @@ jobs:
- name: Lint
uses: golangci/golangci-lint-action@v9
with:
version: v2.12.2
version: v2.13.0
working-directory: spinifex
- 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 }}
+5 -3
View File
@@ -34,9 +34,11 @@ Thumbs.db
# Environment
.env
.venv/
.cache/
# VScode
.vscode/
# Test reports
*-report.txt
# Per-worktree golangci-lint cache
/.cache/
/rds-agent
+6 -1
View File
@@ -26,7 +26,7 @@ linters:
- errorlint # catches ==/!= on errors, type assertions on errors, and %v instead of %w — all break on wrapped errors
- exptostd # detects golang.org/x/exp/ functions replaceable by stdlib
- fatcontext # detects nested contexts in loops
- forbidigo # blocks the legacy JetStream/KV symbols
- forbidigo # blocks forbidden identifiers
- gocheckcompilerdirectives # validates go compiler directive comments (//go:)
- godot # checks that declaration comments end in a period
- goprintffuncname # checks that printf-like functions are named with f at the end
@@ -92,6 +92,8 @@ linters:
forbidigo:
analyze-types: true
forbid:
- pattern: '^panic$'
msg: panic terminates the process; return an error or explicitly justify the panic.
- pattern: '\.JetStream$'
pkg: '^github\.com/nats-io/nats\.go$'
msg: legacy nc.JetStream() is superseded; construct with jetstream.New(nc).
@@ -105,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:
@@ -116,6 +119,7 @@ linters:
modernize:
disable:
- embedlit # its fix promotes embedded fields into the outer literal, which does not compile
- newexpr # don't replace aws.String() with new() — AWS SDK idiom
- stringsbuilder # don't replace small string concatenations with strings.Builder
@@ -166,6 +170,7 @@ linters:
- bodyclose
- errcheck
- errchkjson
- forbidigo
- gosec
# spinifex-specific: gosec exclusions
+18 -14
View File
@@ -1,11 +1,6 @@
GO_PROJECT_NAME := spx
SHELL := /bin/bash
# golangci-lint's default cache (~/.cache/golangci-lint) is keyed by module
# path, not worktree path, so parallel worktrees of this module collide and
# surface each other's issues. Scope it per worktree.
export GOLANGCI_LINT_CACHE := $(CURDIR)/.cache/golangci-lint
# GOFIPS140 is a global build-config change: it alters every package's action ID,
# stdlib included, so a target that omits it shares no build-cache entry with one
# that sets it. Exported once here rather than per-recipe so the two can't drift.
@@ -84,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
@@ -108,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
@@ -168,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
@@ -186,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
@@ -344,9 +348,9 @@ install-system:
ovn-central ovn-host openvswitch-switch dhcpcd-base
install-go:
@echo -e "\n....Installing Go 1.26.6 for $(ARCH) ($(GO_ARCH))...."
@echo -e "\n....Installing Go 1.27.0 for $(ARCH) ($(GO_ARCH))...."
@if [ ! -d "/usr/local/go" ]; then \
curl -L https://go.dev/dl/go1.26.6.linux-$(GO_ARCH).tar.gz | tar -C /usr/local -xz; \
curl -L https://go.dev/dl/go1.27.0.linux-$(GO_ARCH).tar.gz | tar -C /usr/local -xz; \
else \
echo "Go already installed in /usr/local/go"; \
fi
+47 -57
View File
@@ -3,9 +3,10 @@
</p>
<p align="center">
<a href="https://go.dev"><img src="https://img.shields.io/badge/Go-1.26+-00ADD8?style=flat-square&logo=go&logoColor=white" alt="Go"></a>
<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">
@@ -13,9 +14,10 @@
<a href="#the-platform">Platform</a> ·
<a href="#three-ways-to-deploy">Deploy</a> ·
<a href="#aws-compatibility">AWS Compatibility</a> ·
<a href="#live-demo">Live Demo</a> ·
<a href="#core-components">Components</a> ·
<a href="#architecture-at-a-glance">Architecture</a> ·
<a href="#installation">Installation</a> ·
<a href="#self-hosted-installation">Installation</a> ·
<a href="https://docs.mulgadc.com">Docs</a>
</p>
@@ -29,11 +31,9 @@ Spinifex, built by [Mulga](https://mulgadc.com), recreates the AWS services your
Spinifex is an open-source infrastructure platform that recreates the AWS service surface (EC2, EBS, S3, VPC, IAM) on bare-metal, edge, and on-premises environments. Run cloud-native software without a hyperscaler.
Most AWS alternatives wrap an API and call it a cloud. We rebuilt the engineering underneath: distributed object storage with erasure coding, block storage built to survive failure, and compute on bare metal. That depth is the reason your software runs unchanged, instead of rewritten.
Built for teams that need:
It's built for teams that need:
- The AWS tooling they already use (AWS CLI, SDKs, Terraform), with nothing to rewrite
- The AWS tooling you already use (AWS CLI, SDKs, Terraform), with nothing to rewrite
- Full control of the stack: your hardware, your network, your data, your keys
- A cloud that keeps running offline, through disconnection, with no external control plane
- An open core (AGPL-3.0), auditable and yours to keep, with no lock-in
@@ -50,15 +50,15 @@ From commodity hardware up to unmodified AWS tooling, every layer is replaceable
## Three Ways To Deploy
One AWS-compatible surface, three deployment shapes. Pick the one that matches your reality; the platform on top is the same.
The same AWS-compatible platform runs in three shapes. Pick the one that matches your hardware.
### Neocloud
Lift and shift onto a partner Neocloud. Move workloads off the hyperscalers and onto our Neocloud partner ecosystem without rewriting them. GPU capacity (H100 / H200 / B200), cheaper, available now. Change an endpoint, keep the software.
Lift and shift onto a partner Neocloud without rewriting workloads. GPU capacity (H100 / H200 / B200), cheaper, available now. Change an endpoint, keep the software.
### On-premise
Bring your own hardware and host Spinifex in your own data centre. A real multi-node HA cluster integrated with your storage and networking, with full control of stack, data, and jurisdiction. A predictable bill, no egress surprises, an auditable open-source core.
Host Spinifex on your own hardware in your own data centre. You get a multi-node HA cluster integrated with your storage and networking, so the stack, the data, and the jurisdiction stay yours.
### Edge
@@ -66,7 +66,7 @@ Cloud where the cloud can't reach. Air-gapped sites, vehicles, vessels, factorie
## AWS Compatibility
Speak the AWS API surface, natively. The AWS SDKs, AWS CLI, and Terraform: everything you deploy on AWS deploys on Spinifex unchanged. At the edge, on-premise, or on a partner Neocloud.
Spinifex speaks the AWS API surface. The AWS SDKs, AWS CLI, and Terraform all work unchanged, whether you deploy at the edge, on-premise, or on a partner Neocloud.
| Service | What it is | Status |
| --- | --- | --- |
@@ -80,19 +80,37 @@ Speak the AWS API surface, natively. The AWS SDKs, AWS CLI, and Terraform: every
| **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).
## Live Demo
Create a live sandbox in under a minute. The fastest way to experience the stack. Try Spinifex against one of your own workloads on infrastructure we run for you.
* Live in under a minute. No card, no install.
* Yours for 72 hours to test and experiment.
* Real AWS-compatible endpoint, access keys and console.
* EC2, EBS, S3, VPC, IAM, EKS and RDS, ready to call.
* Point your existing AWS CLI or Terraform straight at it.
Sign up at [https://mulgadc.com/signup](https://mulgadc.com/signup)
## Core Components
### Spinifex (Compute Service – EC2 Alternative)
### Spinifex (Compute and Platform Services – EC2, EKS, ECS, RDS)
Spinifex is a minimal VM orchestration layer built on top of QEMU, exposing APIs similar to EC2. It manages lifecycle operations like start, stop, and terminate, using QEMU's QMP interface. Designed to be straightforward and scriptable, Spinifex lets you launch VMs using the AWS CLI, SDKs, or Terraform—without needing Kubernetes or heavyweight orchestrators. Keep in mind, you can also setup a Kubernetes environment using Spinifex with underlying instances.
Spinifex is a minimal VM orchestration layer built on top of QEMU, exposing an EC2 compatible API. It manages lifecycle operations like start, stop, and terminate through QEMU's QMP interface. Designed to be straightforward and scriptable, Spinifex lets you launch VMs with the AWS CLI, SDKs, or Terraform, without Kubernetes or a heavyweight orchestrator.
- EC2-like VM management on bare metal
- Launches with cloud-init metadata support
- Works with standard AWS tooling
The managed services build on that same instance layer. An EKS node group, an ECS container instance, and an RDS database are Spinifex VMs on your own hardware, each driven through its own AWS API.
- **EC2** – VM lifecycle on bare metal, cloud-init and IMDS metadata, key pairs, tags, spot instances, capacity reservations, and GPU passthrough
- **EKS** – Managed Kubernetes on k3s, with HA control planes, managed node groups, cluster add-ons, an OIDC provider, and IAM access entries
- **ECS** – Task and service scheduling across container instances, with awsvpc ENIs, capacity providers, rolling deployments, and GPU-aware placement
- **ECR** – Private container registry backed by Predastore, with content-addressed blobs deduplicated per account and lifecycle policies
- **RDS** – Managed PostgreSQL and MariaDB, with parameter groups, automated backups, and DNS endpoints served by Northstar
- **ALB / NLB** – Layer 7 load balancing on HAProxy and layer 4 on nginx `stream`, with health checks and ACM-issued listener certificates
- **VPC and IAM** – OVN-backed networking and security groups, with roles, policies, and SigV4 auth applied across every service
### Viperblock (Block Storage – EBS Alternative)
@@ -101,12 +119,12 @@ Spinifex is a minimal VM orchestration layer built on top of QEMU, exposing APIs
- Fast, durable virtual disks
- Replication for resilience
- Exposed over NBD or embedded in VMs
- Supports high performance WAL logs using local NVMe drives to reduce IO traffic to S3.
- In memory read/write block cache for blazing performance.
- High performance WAL logs on local NVMe drives, reducing IO traffic to S3
- In-memory read/write block cache
### Predastore (Object Storage – S3-Compatible)
[Predastore](https://github.com/mulgadc/predastore) is a fully S3-compatible object storage system. It supports the AWS S3 API, including Signature V4 authentication, multipart uploads, and Terraform provisioning. Data is chunked and distributed across nodes using Reed-Solomon erasure coding, making it fault-tolerant and ideal for large-scale or low-bandwidth scenarios.
[Predastore](https://github.com/mulgadc/predastore) is a fully S3-compatible object storage system. It supports the AWS S3 API, including Signature V4 authentication, multipart uploads, and Terraform provisioning. Predastore chunks data and distributes it across nodes using Reed-Solomon erasure coding, so it survives node loss and suits large-scale or low-bandwidth sites.
- S3-compatible API and auth
- Multipart uploads, streaming reads/writes
@@ -128,16 +146,7 @@ Spinifex is a minimal VM orchestration layer built on top of QEMU, exposing APIs
Every AWS API call is authenticated at the gateway, published to a NATS subject, and answered by whichever daemon claims it. Daemons are stateless: scale horizontally by starting more. No etcd, no Kubernetes, no external control plane. Just systemd units, a NATS cluster, and your hardware. Deep dive: **[`docs/DESIGN.md`](docs/DESIGN.md)**.
## Key Features
- **AWS-compatible APIs.** Use the AWS CLI, SDKs, and Terraform you already know. Repoint your endpoint and ship; nothing to rewrite.
- **Zero cloud dependency.** Runs entirely on your hardware. No phone-home, no control plane, no external authority. Works fully offline.
- **Bare-metal compute.** QEMU-based with direct hardware access. No hypervisor tax, no abstraction overhead.
- **Built-in storage.** Block and object storage included: NVMe caching, Reed-Solomon erasure coding, and replication out of the box.
- **Edge-first architecture.** Designed for disconnected, contested, and resource-constrained environments from day one.
- **Open core, no lock-in.** AGPL-3.0 with a commercial option. Inspect it, modify it, deploy it. The platform is yours to keep.
## Installation
## Self-hosted Installation
Installation requires an Ubuntu 26.04 or Debian 13 system. See the detailed documentation at [docs.mulgadc.com](https://docs.mulgadc.com) for installing Spinifex.
@@ -173,31 +182,21 @@ aws ec2 describe-instance-types
For a complete development environment see the [Source Install](https://docs.mulgadc.com/docs/install-source) documentation.
### Component Repositories
Spinifex coordinates these independent components:
- **[Predastore](https://github.com/mulgadc/predastore)** - S3-compatible object storage
- **[Viperblock](https://github.com/mulgadc/viperblock)** - EBS-compatible block storage
- **[Northstar](https://github.com/mulgadc/northstar)** - Authoritative DNS server
Each component can be developed independently. See component-specific documentation for focused development guides.
## Spinifex UI
Spinifex ships with a built-in web console — an optional alternative to the AWS CLI, SDKs, and Terraform. If you're familiar with the AWS Management Console, the Spinifex UI fills the same role: a browser-based view of your instances, volumes, buckets, VPCs, and IAM resources, without leaving your own network.
Spinifex ships with a built-in web console, an optional alternative to the AWS CLI, SDKs, and Terraform. If you know the AWS Management Console, the Spinifex UI fills the same role: a browser-based view of your instances, volumes, buckets, VPCs, and IAM resources, without leaving your own network.
<p align="center">
<img src=".github/assets/spinifex-ui.jpg" alt="Spinifex web console — dashboard view" width="900">
<img src=".github/assets/spinifex-ui.jpg" alt="Spinifex web console dashboard" width="900">
</p>
The console is served by each node on port `3000` over TLS, and becomes available as soon as `spinifex.target` is up:
Each node serves the console on port `3000` over TLS, and it comes up with `spinifex.target`:
- **Same API, different surface.** Every action in the UI is the same AWS SigV4 call the CLI makes — so RBAC, audit trails, and IAM policies apply uniformly.
- **Single sign-on against your AWS credentials.** Log in with the access keys from `~/.aws/credentials` on the node where Spinifex is installed — no separate user database.
- **Self-hosted, works offline.** The UI is embedded in the Spinifex binary and served from the node itself. No external CDN, no analytics calls, no cloud dependency.
- **Same API, different surface.** The UI issues the same AWS SigV4 calls the CLI does, so RBAC, audit trails, and IAM policies apply the same way.
- **Single sign-on against your AWS credentials.** Log in with the access keys from `~/.aws/credentials` on the node where Spinifex is installed. There is no separate user database.
- **Self-hosted, works offline.** The Spinifex binary embeds the UI and each node serves it directly, with no external CDN or analytics calls.
For the full walkthrough — first-time TLS certificate trust, login, and feature tour — see [**Launching the Web UI**](https://docs.mulgadc.com/docs/setting-up-your-cluster#7-launching-the-web-ui) in the cluster setup guide.
For the full walkthrough, covering first-time TLS certificate trust, login, and a feature tour, see [**Launching the Web UI**](https://docs.mulgadc.com/docs/setting-up-your-cluster#7-launching-the-web-ui) in the cluster setup guide.
## Development Philosophy
@@ -205,16 +204,9 @@ For the full walkthrough — first-time TLS certificate trust, login, and featur
Spinifex is developed by experienced infrastructure engineers with deep AWS expertise, including former AWS team members who understand the intricacies of building production-grade cloud services. Our team brings decades of combined experience from AWS, enterprise infrastructure, and edge computing environments.
**Real-World Experience:**
- Production AWS service development and operations
- Large-scale infrastructure deployment and management
- Edge computing and resource-constrained environments
- Enterprise security and compliance requirements
### AI-Assisted Development
While Spinifex is architected and implemented by experienced engineers, we leverage **Claude Code** (Anthropic's AI coding assistant) to accelerate certain development tasks. This approach combines human expertise with AI efficiency:
Spinifex is architected and implemented by experienced engineers, with **Claude Code** accelerating certain development tasks. The split keeps the quality and reliability standards that production infrastructure demands:
**How We Use Claude Code:**
@@ -230,12 +222,10 @@ While Spinifex is architected and implemented by experienced engineers, we lever
- **Performance Optimization**: Real-world performance tuning and benchmarking
- **Production Operations**: Deployment strategies and operational procedures
This hybrid approach ensures Spinifex benefits from both proven engineering expertise and modern development acceleration, while maintaining the quality and reliability standards required for production infrastructure.
## Trademarks
"AWS", "Amazon Web Services", and all related service names (EC2, EBS, S3, VPC, IAM, and others) are trademarks of Amazon.com, Inc. or its affiliates. Mulga Defense Corporation and Spinifex are independent and not affiliated with, endorsed by, or sponsored by Amazon.com, Inc. or Amazon Web Services, Inc. References to AWS services describe interoperability and compatibility only.
## License
Spinifex is open source under the [GNU Affero General Public License v3.0](LICENSE). You're free to use, modify, and deploy it anywhere you need reliable infrastructure without depending on centralized cloud platforms.
Spinifex is open source under the [GNU Affero General Public License v3.0](LICENSE), with a commercial license available. You're free to use, modify, and deploy it anywhere you need reliable infrastructure without depending on centralized cloud platforms.
+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.
+1 -1
View File
@@ -1,6 +1,6 @@
ARG VERSION=dev
FROM golang:1.26-bookworm AS builder
FROM golang:1.27-bookworm AS builder
ARG VERSION
+4 -4
View File
@@ -1,4 +1,4 @@
# spinifex-unit-version: 2
# spinifex-unit-version: 3
[Unit]
Description=Spinifex Predastore (S3-compatible Object Storage)
PartOf=spinifex.target
@@ -7,9 +7,9 @@ Wants=spinifex-nats.service
[Service]
EnvironmentFile=-/etc/spinifex/telemetry.env
# Guest block I/O arrives with no caller trace; sample those roots at 5% so
# they don't drown request traces. Request-linked spans are always kept.
Environment=MULGA_ROOT_TRACE_RATIO=0.05
# No MULGA_ROOT_TRACE_RATIO here: the sink's tail sampler sheds guest block I/O
# instead, and it can see whether a trace errored. A head ratio decides before
# the error exists, so it discarded failures at the same rate as successes.
Type=simple
Slice=spinifex-system.slice
User=spinifex-storage
+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() {
-62
View File
@@ -1,71 +1,9 @@
package runtime
import (
"context"
"errors"
"testing"
)
// staticResolver is a test Resolver returning fixed creds or an error.
type staticResolver struct {
user, pass, endpoint string
err error
}
func (s staticResolver) Authorize(_ context.Context, _ string) (string, string, string, error) {
return s.user, s.pass, s.endpoint, s.err
}
func TestFakePuller_RecordsAndDefaultsRef(t *testing.T) {
f := &FakePuller{}
img, err := f.Pull(context.Background(), PullSpec{Ref: "repo/app:1"}, staticResolver{user: "AWS"})
if err != nil {
t.Fatalf("Pull: %v", err)
}
if img.Ref != "repo/app:1" {
t.Errorf("Ref defaulted wrong: %q", img.Ref)
}
if len(f.Pulls) != 1 || f.Pulls[0].Ref != "repo/app:1" {
t.Errorf("Pulls = %+v", f.Pulls)
}
if len(f.Authzd) != 1 || f.Authzd[0] != "AWS" {
t.Errorf("Authzd = %+v", f.Authzd)
}
}
func TestFakePuller_ResolverError(t *testing.T) {
f := &FakePuller{}
_, err := f.Pull(context.Background(), PullSpec{Ref: "r"}, staticResolver{err: errors.New("boom")})
if err == nil {
t.Fatal("expected resolver error to propagate")
}
}
func TestFakePuller_ProgrammedError(t *testing.T) {
f := &FakePuller{Err: errors.New("pull failed")}
if _, err := f.Pull(context.Background(), PullSpec{Ref: "r"}, nil); err == nil {
t.Fatal("expected programmed error")
}
}
func TestFakePuller_OnPullOverrides(t *testing.T) {
want := Image{Ref: "x", Digest: "sha256:abc", Size: 42}
f := &FakePuller{Err: errors.New("ignored"), OnPull: func(PullSpec) (Image, error) {
return want, nil
}}
got, err := f.Pull(context.Background(), PullSpec{Ref: "r"}, nil)
if err != nil || got != want {
t.Fatalf("got %+v, %v; want %+v", got, err, want)
}
}
func TestFakePuller_Close(t *testing.T) {
f := &FakePuller{}
if err := f.Close(); err != nil || !f.Closed {
t.Fatalf("Close: err=%v closed=%v", err, f.Closed)
}
}
func TestNormalizeRef(t *testing.T) {
cases := map[string]string{
"nginx:alpine": "docker.io/library/nginx:alpine",
+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() {
-15
View File
@@ -887,13 +887,6 @@ func TestInstallBootToolZFSStopsWhenADiskFails(t *testing.T) {
f.mustNotRun(t, "SPINIFEX_BOOT_TOOL_NVRAM=1")
}
func TestRefreshBootToolNoteOnlyAppliesToZFS(t *testing.T) {
// Log-only, but it is the operator's only record of the disk-replacement
// command, so both branches are walked.
refreshBootToolNote(DiskConfig{FS: FSZFSRAID1, Disks: disks(2, 40)})
refreshBootToolNote(DiskConfig{FS: FSExt4, Disks: disks(1, 40)})
}
func TestMaskSystemdUnitIsIdempotent(t *testing.T) {
root := t.TempDir()
if err := os.MkdirAll(filepath.Join(root, "etc/systemd/system"), 0o755); err != nil {
@@ -913,14 +906,6 @@ func TestMaskSystemdUnitIsIdempotent(t *testing.T) {
}
}
func TestCopyAssetsAreBestEffort(t *testing.T) {
root := t.TempDir()
// Neither asset exists outside the live ISO; a missing one must not fail
// the install, only the branding.
copyGrubFont(root)
copySplashImage(root)
}
func TestWriteFstabOnZFSHoldsNothingButSwap(t *testing.T) {
root := withMountRoot(t)
targetSkeleton(t, root)
+1 -2
View File
@@ -182,8 +182,7 @@ func ImportablePools() ([]string, error) {
// zpool exits non-zero when there is nothing to import, which is the
// normal case. Anything else — no binary, no /dev/zfs — must surface, or
// a machine with an existing pool looks empty to the caller.
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
if _, ok := errors.AsType[*exec.ExitError](err); ok {
return nil, nil
}
return nil, fmt.Errorf("zpool import: %w", err)
+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() {
+3 -2
View File
@@ -15,6 +15,7 @@ import (
"github.com/mulgadc/spinifex/internal/gwsign"
"github.com/mulgadc/spinifex/internal/rdsgw"
handlers_rds "github.com/mulgadc/spinifex/spinifex/handlers/rds"
"github.com/mulgadc/spinifex/spinifex/otelsetup"
)
// Overridable via -ldflags "-X main.version=...".
@@ -406,10 +407,10 @@ func retryObserved(ctx context.Context, what string, fn func(context.Context) er
}
if attempt >= retryErrorAttempt {
slog.ErrorContext(ctx, "rds-agent: "+what+" still failing, retrying",
"attempt", attempt, "retryIn", delay, "err", err)
"attempt", attempt, "retry_in_ms", otelsetup.Millis(delay), "err", err)
} else {
slog.WarnContext(ctx, "rds-agent: "+what+" failed, retrying",
"attempt", attempt, "retryIn", delay, "err", err)
"attempt", attempt, "retry_in_ms", otelsetup.Millis(delay), "err", err)
}
select {
+9
View File
@@ -207,6 +207,8 @@ func bootstrapOutput(password *string) *handlers_rds.GetDBBootstrapConfigOutput
MasterUsername: "master",
MasterUserPassword: password,
Port: 5432,
ListenAddress: testListenAddress,
ClientCIDR: testClientCIDR,
DataVolumeID: "vol-data-01",
DataVolumeSerial: "voldata01",
VMGeneration: 1,
@@ -217,6 +219,13 @@ func bootstrapOutput(password *string) *handlers_rds.GetDBBootstrapConfigOutput
const testPayloadID = "bp-0123456789abcdef"
// The customer ENI address the engine binds, and the VPC range its client
// authentication admits. writeHandoff refuses a config without either.
const (
testListenAddress = "10.0.1.20"
testClientCIDR = "10.0.0.0/16"
)
// Writes the completion receipt rds-init leaves behind, so the acknowledgement
// step finds what a finished initialization would have produced.
func writeReceipt(t *testing.T, a *Agent, payloadID, dbInstanceIdentifier string) {
+11
View File
@@ -96,6 +96,15 @@ func writeHandoff(dir string, cfg *handlers_rds.GetDBBootstrapConfigOutput) erro
if cfg.MasterUsername == "" {
return fmt.Errorf("bootstrap config carries no master username")
}
// Half a handoff would leave rds-init deciding what to bind and who to admit,
// and the only answers it could reach on its own are the wildcards this
// contract exists to remove.
if cfg.ListenAddress == "" {
return fmt.Errorf("bootstrap config carries no listen address for the engine to bind")
}
if cfg.ClientCIDR == "" {
return fmt.Errorf("bootstrap config carries no client CIDR to scope client authentication to")
}
if err := os.MkdirAll(dir, handoffDirMode); err != nil {
return fmt.Errorf("create handoff dir %s: %w", dir, err)
}
@@ -134,6 +143,8 @@ func renderBootstrapEnv(cfg *handlers_rds.GetDBBootstrapConfigOutput) string {
writeEnvLine(&b, "RDS_MODE", cfg.Mode)
writeEnvLine(&b, "RDS_DB_INSTANCE_IDENTIFIER", cfg.DBInstanceIdentifier)
writeEnvLine(&b, "RDS_MASTER_USERNAME", cfg.MasterUsername)
writeEnvLine(&b, "RDS_LISTEN_ADDRESS", cfg.ListenAddress)
writeEnvLine(&b, "RDS_CLIENT_CIDR", cfg.ClientCIDR)
writeEnvLine(&b, "RDS_DATA_VOLUME_ID", cfg.DataVolumeID)
writeEnvLine(&b, "RDS_DATA_VOLUME_SERIAL", cfg.DataVolumeSerial)
writeEnvLine(&b, "RDS_VM_GENERATION", strconv.FormatInt(cfg.VMGeneration, 10))
+10
View File
@@ -18,6 +18,8 @@ func TestWriteHandoff_WritesEveryFileRootOnly(t *testing.T) {
Mode: handlers_rds.BootstrapModeInitialize,
Engine: "postgres",
MasterUsername: "master",
ListenAddress: "10.0.1.20",
ClientCIDR: "10.0.0.0/16",
MasterUserPassword: &password,
DBName: "appdb",
Port: 5433,
@@ -85,6 +87,8 @@ func TestWriteHandoff_AttachOmitsPassword(t *testing.T) {
Mode: handlers_rds.BootstrapModeAttach,
Engine: "postgres",
MasterUsername: "master",
ListenAddress: "10.0.1.20",
ClientCIDR: "10.0.0.0/16",
Port: 5432,
}); err != nil {
t.Fatalf("writeHandoff: %v", err)
@@ -116,6 +120,8 @@ func TestWriteHandoff_QuotesShellMetacharacters(t *testing.T) {
Mode: handlers_rds.BootstrapModeInitialize,
Engine: "postgres",
MasterUsername: "master",
ListenAddress: "10.0.1.20",
ClientCIDR: "10.0.0.0/16",
MasterUserPassword: &password,
Port: 5432,
}); err != nil {
@@ -137,6 +143,8 @@ func TestWriteHandoff_SkipsHalfATLSPair(t *testing.T) {
Mode: handlers_rds.BootstrapModeAttach,
Engine: "postgres",
MasterUsername: "master",
ListenAddress: "10.0.1.20",
ClientCIDR: "10.0.0.0/16",
Port: 5432,
ServingCertificate: "CERT",
}); err != nil {
@@ -177,6 +185,8 @@ func TestWriteHandoff_TightensExistingDirectory(t *testing.T) {
Mode: handlers_rds.BootstrapModeAttach,
Engine: "postgres",
MasterUsername: "master",
ListenAddress: "10.0.1.20",
ClientCIDR: "10.0.0.0/16",
Port: 5432,
}); err != nil {
t.Fatalf("writeHandoff: %v", err)
+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)
-6
View File
@@ -13,7 +13,6 @@ const (
defaultEnvFile = "/etc/spinifex-rds/agent.env"
defaultGatewayCA = "/etc/spinifex-rds/gateway-ca.pem"
defaultHandoffDir = "/run/spinifex-rds"
defaultEngineHost = "127.0.0.1"
// Where setup.sh stamps the engine the image bakes. The agent builds its
// engine implementation from this file rather than from anything delivered.
defaultEngineFile = "/etc/spinifex-rds/engine"
@@ -38,7 +37,6 @@ type config struct {
// version alone rather than clearing it.
EngineVersion string
HandoffDir string
EngineHost string
EnginePort int
PollWait time.Duration
@@ -89,7 +87,6 @@ func loadConfig(envFile string) config {
EngineFile: get("RDS_ENGINE_FILE"),
EngineVersion: get("RDS_ENGINE_VERSION"),
HandoffDir: get("RDS_HANDOFF_DIR"),
EngineHost: get("RDS_ENGINE_HOST"),
EngineBinDir: get("RDS_ENGINE_BIN"),
EngineDataDir: get("RDS_ENGINE_DATA"),
SocketDir: get("RDS_SOCKET_DIR"),
@@ -108,9 +105,6 @@ func loadConfig(envFile string) config {
if cfg.HandoffDir == "" {
cfg.HandoffDir = defaultHandoffDir
}
if cfg.EngineHost == "" {
cfg.EngineHost = defaultEngineHost
}
if cfg.EngineFile == "" {
cfg.EngineFile = defaultEngineFile
}
+2 -2
View File
@@ -26,8 +26,8 @@ func TestLoadConfig_Defaults(t *testing.T) {
if cfg.GatewayCA != defaultGatewayCA || cfg.HandoffDir != defaultHandoffDir {
t.Errorf("CA/handoff = %q/%q, want the built-in defaults", cfg.GatewayCA, cfg.HandoffDir)
}
if cfg.EngineHost != defaultEngineHost || cfg.PollWait != defaultPollWait {
t.Errorf("engine host/pollWait = %q/%v, want the built-in defaults", cfg.EngineHost, cfg.PollWait)
if cfg.PollWait != defaultPollWait {
t.Errorf("pollWait = %v, want the built-in default", cfg.PollWait)
}
if cfg.EngineProbeTimeout != defaultEngineProbeTimeout {
t.Errorf("engine probe timeout = %v, want %v", cfg.EngineProbeTimeout, defaultEngineProbeTimeout)
+61 -3
View File
@@ -8,6 +8,7 @@ import (
"os/exec"
"os/user"
"path/filepath"
"slices"
"strconv"
"strings"
"syscall"
@@ -107,10 +108,12 @@ 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: func(cfg config, run commandRunner, startSess sessionRunner, probe *engineProbe) (engine, error) {
return newPostgresEngine(cfg, run, startSess, probe), nil
},
newEngine: newPostgresEngineFromCatalog,
},
engineMariaDB: {
binDir: "/usr/bin",
@@ -133,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 {
+3 -2
View File
@@ -129,10 +129,11 @@ func newScriptedTestMariaDBEngine(t *testing.T, run commandRunner) *mariadbEngin
// that is registered: what a spec says about a parameter is what the guest must
// see, and an unlisted name must read as static rather than as dynamic.
func TestControlPlaneRulesFrom_TakesTheClassificationFromTheCatalog(t *testing.T) {
rules := controlPlaneRulesFrom(postgresEngineMeta)
meta := testPostgresEngineMeta(t)
rules := controlPlaneRulesFrom(meta)
for name, want := range map[string]bool{"shared_buffers": true, "work_mem": false} {
spec, ok := postgresEngineMeta.LookupParameter(name)
spec, ok := meta.LookupParameter(name)
if !ok {
t.Fatalf("the catalog no longer carries %s", name)
}
+25 -19
View File
@@ -41,11 +41,16 @@ var _ engine = (*postgresEngine)(nil)
// is not there. Resolved on PATH, where the client package puts it.
const postgresProbeBinary = "pg_isready"
// The socket directory rather than loopback: the engine binds the customer ENI
// and nothing else, so there is no TCP path for the probe to take. It also keeps
// liveness independent of the network path, of the pg_hba scope and of the TLS
// 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.EngineHost, 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")
@@ -58,26 +63,18 @@ 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)
}
}
}
// Resolved once, under the name the control plane knows this implementation by.
// A lookup failure is a mismatch between the agent and the control plane it was
// built against, so it fails at startup rather than at the first rotation.
var postgresEngineMeta = mustLookupEngine(enginePostgres)
func mustLookupEngine(name string) handlers_rds.Engine {
engine, err := handlers_rds.LookupEngine(name)
if err != nil {
panic("rds-agent: " + err.Error())
}
return engine
}
// The include the resolved set is rendered to, and the copy of the last one the
// engine accepted. Both live beside the data rather than in /etc, matching
// rds-init: a class change boots a fresh root volume and would revert them.
@@ -109,9 +106,18 @@ hostnossl all all ::/0 reject
postgresForceSSLRuleCount = 2
)
func newPostgresEngine(cfg config, run commandRunner, startSess sessionRunner, probe *engineProbe) *postgresEngine {
// The layout's factory resolves the control plane metadata during startup.
func newPostgresEngineFromCatalog(cfg config, run commandRunner, startSess sessionRunner, probe *engineProbe) (engine, error) {
meta, err := handlers_rds.LookupEngine(enginePostgres)
if err != nil {
return nil, fmt.Errorf("this image bakes %s, which this build's control plane does not offer: %w", enginePostgres, err)
}
return newPostgresEngine(cfg, meta, run, startSess, probe), nil
}
func newPostgresEngine(cfg config, meta handlers_rds.Engine, run commandRunner, startSess sessionRunner, probe *engineProbe) *postgresEngine {
return &postgresEngine{
meta: postgresEngineMeta,
meta: meta,
run: run,
startSess: startSess,
psql: filepath.Join(cfg.EngineBinDir, "psql"),
+23 -13
View File
@@ -140,10 +140,19 @@ func (r *recordingRunner) run(_ context.Context, c command) (string, error) {
return r.out, r.err
}
func testPostgresEngineMeta(t *testing.T) handlers_rds.Engine {
t.Helper()
meta, err := handlers_rds.LookupEngine(enginePostgres)
if err != nil {
t.Fatalf("LookupEngine(%s): %v", enginePostgres, err)
}
return meta
}
func newTestEngine(t *testing.T, run commandRunner) *postgresEngine {
t.Helper()
cfg := testEngineConfig(t)
return newPostgresEngine(cfg, withPostgresReadBacks(cfg.EngineDataDir, run), nil, newPostgresProbe(cfg, staticProbe(0)))
return newPostgresEngine(cfg, testPostgresEngineMeta(t), withPostgresReadBacks(cfg.EngineDataDir, run), nil, newPostgresProbe(cfg, staticProbe(0)))
}
// The same engine with the read-backs left to the runner, for the cases that are
@@ -152,7 +161,7 @@ func newTestEngine(t *testing.T, run commandRunner) *postgresEngine {
func newScriptedTestEngine(t *testing.T, run commandRunner) *postgresEngine {
t.Helper()
cfg := testEngineConfig(t)
return newPostgresEngine(cfg, run, nil, newPostgresProbe(cfg, staticProbe(0)))
return newPostgresEngine(cfg, testPostgresEngineMeta(t), run, nil, newPostgresProbe(cfg, staticProbe(0)))
}
func testEngineConfig(t *testing.T) config {
@@ -479,10 +488,11 @@ func (r *tlsReadBackRunner) run(ctx context.Context, c command) (string, error)
return r.recordingRunner.run(ctx, c)
}
func forceSSLParameter(value string) []handlers_rds.Parameter {
func forceSSLParameter(t *testing.T, value string) []handlers_rds.Parameter {
t.Helper()
return []handlers_rds.Parameter{
{Name: "work_mem", Value: "4096"},
{Name: postgresEngineMeta.TLSEnforcementParameter(), Value: value},
{Name: testPostgresEngineMeta(t).TLSEnforcementParameter(), Value: value},
}
}
@@ -492,7 +502,7 @@ func TestPostgresEngine_ApplyParametersWritesTheEnforcementRule(t *testing.T) {
runner := &tlsReadBackRunner{ssl: "on", ruleRows: postgresForceSSLRuleCount}
engine := newScriptedTestEngine(t, runner.run)
if _, err := engine.ApplyParameters(t.Context(), forceSSLParameter("1")); err != nil {
if _, err := engine.ApplyParameters(t.Context(), forceSSLParameter(t, "1")); err != nil {
t.Fatalf("ApplyParameters: %v", err)
}
rule, err := os.ReadFile(engine.forceSSLRulePath())
@@ -516,7 +526,7 @@ func TestPostgresEngine_ApplyParametersRemovesAStaleEnforcementRule(t *testing.T
t.Fatalf("write the rule the restored volume carried: %v", err)
}
if _, err := engine.ApplyParameters(t.Context(), forceSSLParameter("0")); err != nil {
if _, err := engine.ApplyParameters(t.Context(), forceSSLParameter(t, "0")); err != nil {
t.Fatalf("ApplyParameters: %v", err)
}
if _, err := os.Stat(engine.forceSSLRulePath()); !os.IsNotExist(err) {
@@ -547,7 +557,7 @@ func TestPostgresEngine_ApplyParametersRefusesAnUnparsableEnforcementValue(t *te
runner := &tlsReadBackRunner{ssl: "on"}
engine := newScriptedTestEngine(t, runner.run)
_, err := engine.ApplyParameters(t.Context(), forceSSLParameter("yes"))
_, err := engine.ApplyParameters(t.Context(), forceSSLParameter(t, "yes"))
if err == nil || !strings.Contains(err.Error(), "neither 1 nor 0") {
t.Fatalf("ApplyParameters error = %v, want a refusal naming the unreadable value", err)
}
@@ -562,7 +572,7 @@ func TestPostgresEngine_ApplyParametersRefusesToEnforceWithoutTLS(t *testing.T)
runner := &tlsReadBackRunner{ssl: "off"}
engine := newScriptedTestEngine(t, runner.run)
_, err := engine.ApplyParameters(t.Context(), forceSSLParameter("1"))
_, err := engine.ApplyParameters(t.Context(), forceSSLParameter(t, "1"))
if err == nil || !strings.Contains(err.Error(), "serving without TLS") {
t.Fatalf("ApplyParameters error = %v, want a refusal naming the engine's own TLS state", err)
}
@@ -592,7 +602,7 @@ func TestPostgresEngine_ApplyParametersFailsAnUnverifiableEnforcement(t *testing
runner := &tlsReadBackRunner{ssl: "on", ruleRows: tc.ruleRows, brokenRows: tc.brokenRows}
engine := newScriptedTestEngine(t, runner.run)
_, err := engine.ApplyParameters(t.Context(), forceSSLParameter(tc.value))
_, err := engine.ApplyParameters(t.Context(), forceSSLParameter(t, tc.value))
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("ApplyParameters error = %v, want %q", err, tc.want)
}
@@ -606,13 +616,13 @@ func TestPostgresEngine_ApplyParametersFailsAnUnverifiableEnforcement(t *testing
func TestPostgresEngine_ApplyParametersWithdrawsBothFilesTogether(t *testing.T) {
runner := &tlsReadBackRunner{ssl: "on", ruleRows: postgresForceSSLRuleCount}
engine := newScriptedTestEngine(t, runner.run)
if _, err := engine.ApplyParameters(t.Context(), forceSSLParameter("1")); err != nil {
if _, err := engine.ApplyParameters(t.Context(), forceSSLParameter(t, "1")); err != nil {
t.Fatalf("first ApplyParameters: %v", err)
}
// Turning enforcement off, verified against a pg_hba that still carries the
// rule — so the apply fails after both files have already been replaced.
if _, err := engine.ApplyParameters(t.Context(), forceSSLParameter("0")); err == nil {
if _, err := engine.ApplyParameters(t.Context(), forceSSLParameter(t, "0")); err == nil {
t.Fatal("ApplyParameters succeeded against an unverifiable enforcement")
}
installed, err := os.ReadFile(engine.params.installedPath())
@@ -639,14 +649,14 @@ func TestPostgresEngine_RestoreLastKnownGoodReDerivesEnforcement(t *testing.T) {
runner := &tlsReadBackRunner{ssl: "on", ruleRows: postgresForceSSLRuleCount}
engine := newScriptedTestEngine(t, runner.run)
if _, err := engine.ApplyParameters(t.Context(), forceSSLParameter("1")); err != nil {
if _, err := engine.ApplyParameters(t.Context(), forceSSLParameter(t, "1")); err != nil {
t.Fatalf("ApplyParameters(enforcing): %v", err)
}
if err := engine.RecordServingParameters(t.Context()); err != nil {
t.Fatalf("RecordServingParameters: %v", err)
}
runner.ruleRows = 0
if _, err := engine.ApplyParameters(t.Context(), forceSSLParameter("0")); err != nil {
if _, err := engine.ApplyParameters(t.Context(), forceSSLParameter(t, "0")); err != nil {
t.Fatalf("ApplyParameters(not enforcing): %v", err)
}
if _, err := os.Stat(engine.forceSSLRulePath()); !os.IsNotExist(err) {
+1 -2
View File
@@ -28,8 +28,7 @@ func execProbeRunner(ctx context.Context, name string, args ...string) (int, str
if ctxErr := ctx.Err(); ctxErr != nil {
return -1, out, ctxErr
}
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok {
return exitErr.ExitCode(), out, nil
}
return -1, out, err
+33 -2
View File
@@ -110,6 +110,37 @@ func TestExecProbeRunner_CapturesTheClientsStderr(t *testing.T) {
}
}
func testProbeConfig() config {
return config{EngineHost: defaultEngineHost, EnginePort: engineLayouts[enginePostgres].port}
// The engine binds the customer ENI and nothing else, so a loopback probe would
// report a serving engine as absent. libpq reads a host beginning with / as a
// socket directory, which is also what keeps liveness off the network path.
func TestPostgresProbe_ProbesTheUnixSocket(t *testing.T) {
cfg := testProbeConfig()
var probed []string
probe := newPostgresProbe(cfg, func(_ context.Context, name string, args ...string) (int, string, error) {
probed = append([]string{name}, args...)
return 0, "", nil
})
if state, _ := probe.state(context.Background()); state != engineServing {
t.Fatalf("probe state = %v, want engineServing", state)
}
host := ""
for i, arg := range probed {
if arg == "-h" && i+1 < len(probed) {
host = probed[i+1]
}
}
if host != cfg.SocketDir {
t.Errorf("probe host = %q, want the socket directory %q", host, cfg.SocketDir)
}
if !strings.HasPrefix(host, "/") {
t.Errorf("probe host = %q, which libpq reads as a TCP host rather than a socket directory", host)
}
}
func testProbeConfig() config {
return config{
SocketDir: engineLayouts[enginePostgres].socketDir,
EnginePort: engineLayouts[enginePostgres].port,
}
}
+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) {
+2 -1
View File
@@ -2,6 +2,7 @@ package main
import (
"context"
"github.com/mulgadc/spinifex/spinifex/otelsetup"
"log/slog"
"time"
@@ -66,7 +67,7 @@ func (g *paramGuard) Run(ctx context.Context) {
}
message := handlers_rds.ParameterRollbackMessage
slog.Error("rds-agent: "+message, "after", g.after)
slog.Error("rds-agent: "+message, "after_ms", otelsetup.Millis(g.after))
if g.cp != nil {
if _, err := g.cp.SubmitState(ctx, g.id, handlers_rds.EngineHealthUnhealthy, message); err != nil {
slog.Error("rds-agent: reporting the parameter rollback failed", "err", err)
+2 -1
View File
@@ -6,6 +6,7 @@ import (
"context"
"errors"
"fmt"
"github.com/mulgadc/spinifex/spinifex/otelsetup"
"io"
"log/slog"
"os/exec"
@@ -72,7 +73,7 @@ func (q *quiesceState) beginHoldLocked(label string, session engineSession, hold
session: session,
expiry: time.AfterFunc(hold, func() { q.expire(label) }),
}
slog.Info("rds-agent: engine quiesced for backup", "label", label, "hold", hold)
slog.Info("rds-agent: engine quiesced for backup", "label", label, "hold_ms", otelsetup.Millis(hold))
}
// Takes the hold off the engine, if one is still there, and disarms its
+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) {
+2 -1
View File
@@ -2,6 +2,7 @@ package main
import (
"context"
"github.com/mulgadc/spinifex/spinifex/otelsetup"
"log/slog"
"time"
)
@@ -20,7 +21,7 @@ func (a *Agent) register(ctx context.Context) error {
a.hb.setInterval(time.Duration(out.HeartbeatIntervalSeconds) * time.Second)
slog.Info("rds-agent: registered",
"dbInstanceIdentifier", a.id.DBInstanceIdentifier, "heartbeat", a.hb.interval)
"dbInstanceIdentifier", a.id.DBInstanceIdentifier, "heartbeat_ms", otelsetup.Millis(a.hb.interval))
return nil
})
}
+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)
}
}
+155 -34
View File
@@ -25,20 +25,22 @@ import (
"strings"
"sync"
"time"
"uuid"
"github.com/google/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)")
@@ -415,15 +431,11 @@ func init() {
imagesRemoveCmd.Flags().String("image-id", "", "AMI ID to remove (required)")
imagesRemoveCmd.Flags().Bool("force", false, "Bypass dependency, ownership and config-corrupt checks (salvage mode)")
imagesRemoveCmd.Flags().Bool("yes", false, "Skip interactive confirmation prompt")
if err := imagesRemoveCmd.MarkFlagRequired("image-id"); err != nil {
panic(err)
}
_ = imagesRemoveCmd.MarkFlagRequired("image-id")
imagesPromoteCmd.Flags().String("image-id", "", "AMI ID to promote to system image (required)")
imagesPromoteCmd.Flags().Bool("yes", false, "Skip interactive confirmation prompt")
if err := imagesPromoteCmd.MarkFlagRequired("image-id"); err != nil {
panic(err)
}
_ = imagesPromoteCmd.MarkFlagRequired("image-id")
}
const bytesPerGiB = 1024 * 1024 * 1024
@@ -449,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) {
@@ -697,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,
@@ -735,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(),
@@ -747,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
@@ -846,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
@@ -904,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) {
@@ -1302,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.
@@ -1603,6 +1624,7 @@ func runAdminInit(cmd *cobra.Command, args []string) {
ClusterName: clusterName,
PredastoreHostID: predastoreHostID,
PredastoreAdminPort: admin.PredastoreAdminPort,
CompactionIntervalSeconds: compactionInterval,
Services: services,
@@ -1833,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)
@@ -1904,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),
@@ -1989,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
@@ -2010,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
@@ -2090,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)
}
@@ -2100,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)
@@ -2285,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()
@@ -2463,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),
@@ -2582,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)
@@ -2649,7 +2701,7 @@ func runAccountCreate(cmd *cobra.Command, args []string) {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
reservationOwner := "spx-cli-" + uuid.NewString()
reservationOwner := "spx-cli-" + uuid.NewV4().String()
switch err := names.Reserve(ctx, name, reservationOwner); {
case errors.Is(err, handlers_iam.ErrNameTaken):
fmt.Fprintf(os.Stderr, "Error: account %q already exists\n", name)
@@ -2844,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 != ""
@@ -2988,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)
}
@@ -3081,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)
}
@@ -3662,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.")
}
+185
View File
@@ -0,0 +1,185 @@
package cmd
import (
"context"
"fmt"
"sort"
"strconv"
"github.com/mulgadc/spinifex/spinifex/gateway"
handlers_quota "github.com/mulgadc/spinifex/spinifex/handlers/quota"
"github.com/spf13/cobra"
)
// quotaDimensions maps each CLI flag to the override field it sets. One table
// drives the flag registration and the parse, so a new dimension cannot be
// added to one and forgotten in the other.
var quotaDimensions = []struct {
flag string
usage string
field func(*handlers_quota.Overrides) **int
}{
{"vcpus", "Total vCPUs across running and stopped instances", func(o *handlers_quota.Overrides) **int { return &o.VCPUs }},
{"vpcs", "VPCs", func(o *handlers_quota.Overrides) **int { return &o.VPCs }},
{"subnets", "Subnets, counted per account rather than per VPC", func(o *handlers_quota.Overrides) **int { return &o.Subnets }},
{"eips", "Elastic IP addresses", func(o *handlers_quota.Overrides) **int { return &o.EIPs }},
{"volumes", "EBS volumes", func(o *handlers_quota.Overrides) **int { return &o.Volumes }},
{"volumes-gib", "Total EBS capacity in GiB", func(o *handlers_quota.Overrides) **int { return &o.VolumesGiB }},
{"rds-instances", "RDS database instances", func(o *handlers_quota.Overrides) **int { return &o.RDSInstances }},
{"load-balancers", "Load balancers, ALB and NLB together", func(o *handlers_quota.Overrides) **int { return &o.LoadBalancers }},
}
var accountQuotaCmd = &cobra.Command{
Use: "quota",
Short: "Inspect and adjust an account's service quotas",
Long: `Inspect and adjust the service quotas applied to one account.
Every account inherits the [quota] limits from awsgw.toml. An override raises or
lowers individual dimensions for one account without affecting any other, so a
customer can be given more vCPUs while every other tenant stays on the baseline.
Only the dimensions passed as flags are changed; the rest keep inheriting. Use
-1 for no limit on a dimension, and --clear to drop every override.`,
}
var accountQuotaGetCmd = &cobra.Command{
Use: "get <account-id>",
Short: "Show the limits in force for an account",
Long: `Show the limits in force for an account and where each came from:
"config" for an inherited baseline, "override" for one set on this account.`,
Args: cobra.ExactArgs(1),
RunE: runAccountQuotaGet,
}
var accountQuotaSetCmd = &cobra.Command{
Use: "set <account-id>",
Short: "Override one or more limits for an account",
Long: `Override one or more limits for an account. Dimensions not named keep
inheriting the configured baseline.
spx admin account quota set 000000000005 --vcpus 32
spx admin account quota set 000000000005 --clear`,
Args: cobra.ExactArgs(1),
RunE: runAccountQuotaSet,
}
// Both subcommands are served only by the /admin surface, so unlike the account
// commands there is no NATS path and no --remote to opt into.
func init() {
accountCmd.AddCommand(accountQuotaCmd)
accountQuotaCmd.AddCommand(accountQuotaGetCmd)
accountQuotaCmd.AddCommand(accountQuotaSetCmd)
for _, c := range []*cobra.Command{accountQuotaGetCmd, accountQuotaSetCmd} {
c.Flags().String("endpoint", "", "Gateway endpoint (default: this node's AWS gateway)")
c.Flags().String("region", "", "SigV4 region (default: this node's region)")
c.Flags().String("ca-bundle", "", "CA certificate (default: this node's CA)")
}
for _, d := range quotaDimensions {
accountQuotaSetCmd.Flags().Int(d.flag, 0, d.usage+" (-1 for no limit)")
}
accountQuotaSetCmd.Flags().Bool("clear", false, "Remove every override, returning the account to the configured limits")
}
func runAccountQuotaGet(cmd *cobra.Command, args []string) error {
target, err := resolveAdminTarget(cmd)
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(cmd.Context(), adminRequestTimeout)
defer cancel()
var out gateway.AccountQuotaResponse
req := gateway.AccountQuotaRequest{AccountID: args[0]}
if err := callAdmin(ctx, target, "GetAccountQuota", req, &out); err != nil {
return err
}
printAccountQuota(&out)
return nil
}
func runAccountQuotaSet(cmd *cobra.Command, args []string) error {
target, err := resolveAdminTarget(cmd)
if err != nil {
return err
}
over, changed, err := quotaOverridesFromFlags(cmd)
if err != nil {
return err
}
clearAll, err := cmd.Flags().GetBool("clear")
if err != nil {
return err
}
// Refusing both is not pedantry: --clear silently winning would discard
// limits the caller believed it was setting in the same breath.
if clearAll && changed {
return fmt.Errorf("--clear cannot be combined with a dimension flag")
}
if !clearAll && !changed {
return fmt.Errorf("no dimension given; pass at least one limit flag, or --clear to remove every override")
}
if clearAll {
over = handlers_quota.Overrides{}
}
ctx, cancel := context.WithTimeout(cmd.Context(), adminRequestTimeout)
defer cancel()
var out gateway.AccountQuotaResponse
req := gateway.AccountQuotaRequest{AccountID: args[0], Overrides: over}
if err := callAdmin(ctx, target, "PutAccountQuota", req, &out); err != nil {
return err
}
printAccountQuota(&out)
return nil
}
// quotaOverridesFromFlags builds the override set from the flags the caller
// actually passed. An unset flag is left nil so the dimension keeps inheriting,
// which is why Changed is consulted rather than the flag's value.
func quotaOverridesFromFlags(cmd *cobra.Command) (handlers_quota.Overrides, bool, error) {
var over handlers_quota.Overrides
changed := false
for _, d := range quotaDimensions {
if !cmd.Flags().Changed(d.flag) {
continue
}
value, err := cmd.Flags().GetInt(d.flag)
if err != nil {
return over, false, err
}
if value < handlers_quota.Unlimited {
return over, false, fmt.Errorf("--%s must be 0 or greater, or %d for no limit", d.flag, handlers_quota.Unlimited)
}
*d.field(&over) = &value
changed = true
}
return over, changed, nil
}
func printAccountQuota(resp *gateway.AccountQuotaResponse) {
fmt.Printf("Account %s\n\n", resp.AccountID)
fmt.Printf("%-16s %10s %s\n", "DIMENSION", "LIMIT", "SOURCE")
fmt.Printf("%-16s %10s %s\n", "---------", "-----", "------")
names := make([]string, 0, len(resp.Limits))
for name := range resp.Limits {
names = append(names, name)
}
sort.Strings(names)
for _, name := range names {
limit := strconv.Itoa(resp.Limits[name])
if resp.Limits[name] == handlers_quota.Unlimited {
limit = "unlimited"
}
fmt.Printf("%-16s %10s %s\n", name, limit, resp.Source[name])
}
if resp.Overrides.UpdatedAt != "" {
fmt.Printf("\nOverride set by %s at %s\n", resp.Overrides.UpdatedBy, resp.Overrides.UpdatedAt)
}
}
@@ -0,0 +1,140 @@
//test:in-package — drives the unexported flag table, parser and printer the quota subcommands are built from.
package cmd
import (
"testing"
"github.com/mulgadc/spinifex/spinifex/gateway"
handlers_quota "github.com/mulgadc/spinifex/spinifex/handlers/quota"
"github.com/spf13/cobra"
"github.com/stretchr/testify/require"
)
// newQuotaSetCmd builds a command carrying the same flags as the real one, so
// the parse is tested without mutating the package-level command between tests.
func newQuotaSetCmd() *cobra.Command {
cmd := &cobra.Command{Use: "set"}
for _, d := range quotaDimensions {
cmd.Flags().Int(d.flag, 0, d.usage)
}
cmd.Flags().Bool("clear", false, "")
return cmd
}
// Only the flags actually passed become overrides. Every other dimension must
// stay nil so it keeps inheriting, which is the whole point of a sparse record.
func TestQuotaOverridesFromFlagsIsSparse(t *testing.T) {
cmd := newQuotaSetCmd()
require.NoError(t, cmd.ParseFlags([]string{"--vcpus", "32", "--rds-instances", "8"}))
over, changed, err := quotaOverridesFromFlags(cmd)
require.NoError(t, err)
require.True(t, changed)
require.NotNil(t, over.VCPUs)
require.Equal(t, 32, *over.VCPUs)
require.NotNil(t, over.RDSInstances)
require.Equal(t, 8, *over.RDSInstances)
require.Nil(t, over.VPCs)
require.Nil(t, over.EIPs)
require.Nil(t, over.Volumes)
require.Nil(t, over.VolumesGiB)
require.Nil(t, over.Subnets)
require.Nil(t, over.LoadBalancers)
}
// A flag left unset must not be read as an explicit 0, which would deny every
// request on that dimension instead of inheriting it.
func TestQuotaOverridesFromFlagsUnsetIsNotZero(t *testing.T) {
cmd := newQuotaSetCmd()
require.NoError(t, cmd.ParseFlags([]string{"--vpcs", "8"}))
over, changed, err := quotaOverridesFromFlags(cmd)
require.NoError(t, err)
require.True(t, changed)
require.Nil(t, over.VCPUs, "an unset flag must not become an explicit zero")
}
// Passing 0 deliberately is a real limit and must be preserved.
func TestQuotaOverridesFromFlagsExplicitZero(t *testing.T) {
cmd := newQuotaSetCmd()
require.NoError(t, cmd.ParseFlags([]string{"--eips", "0"}))
over, changed, err := quotaOverridesFromFlags(cmd)
require.NoError(t, err)
require.True(t, changed)
require.NotNil(t, over.EIPs)
require.Equal(t, 0, *over.EIPs)
}
func TestQuotaOverridesFromFlagsUnlimited(t *testing.T) {
cmd := newQuotaSetCmd()
require.NoError(t, cmd.ParseFlags([]string{"--vcpus", "-1"}))
over, _, err := quotaOverridesFromFlags(cmd)
require.NoError(t, err)
require.NotNil(t, over.VCPUs)
require.Equal(t, handlers_quota.Unlimited, *over.VCPUs)
}
// Anything below the Unlimited sentinel is a typo, not a smaller limit.
func TestQuotaOverridesFromFlagsRejectsBelowUnlimited(t *testing.T) {
cmd := newQuotaSetCmd()
require.NoError(t, cmd.ParseFlags([]string{"--vcpus", "-2"}))
_, _, err := quotaOverridesFromFlags(cmd)
require.Error(t, err)
}
// No flags at all means nothing changed, which the caller turns into an error
// rather than a write that silently does nothing.
func TestQuotaOverridesFromFlagsNoneChanged(t *testing.T) {
cmd := newQuotaSetCmd()
require.NoError(t, cmd.ParseFlags(nil))
over, changed, err := quotaOverridesFromFlags(cmd)
require.NoError(t, err)
require.False(t, changed)
require.True(t, over.Empty())
}
// The table must name every dimension, label its source, and render the
// Unlimited sentinel as a word rather than as -1, which reads like a bug.
func TestPrintAccountQuota(t *testing.T) {
resp := &gateway.AccountQuotaResponse{
AccountID: "000000000042",
Limits: map[string]int{"vcpus": 32, "vpcs": handlers_quota.Unlimited, "eips": 4},
Source: map[string]string{"vcpus": "override", "vpcs": "override", "eips": "config"},
Overrides: handlers_quota.Overrides{UpdatedBy: "operator", UpdatedAt: "2026-08-20T01:00:00Z"},
}
out := captureStdout(t, func() { printAccountQuota(resp) })
require.Contains(t, out, "000000000042")
require.Contains(t, out, "vcpus")
require.Contains(t, out, "32")
require.Contains(t, out, "override")
require.Contains(t, out, "config")
require.Contains(t, out, "unlimited")
require.NotContains(t, out, "-1")
require.Contains(t, out, "operator")
}
// Every dimension the override record carries must be reachable from the CLI,
// or a limit exists that an operator cannot set.
func TestQuotaDimensionsCoverEveryOverrideField(t *testing.T) {
var over handlers_quota.Overrides
for _, d := range quotaDimensions {
v := 1
*d.field(&over) = &v
}
require.False(t, over.Empty())
seen := map[string]bool{}
for _, d := range quotaDimensions {
require.False(t, seen[d.flag], "duplicate flag %q", d.flag)
seen[d.flag] = true
}
require.Len(t, quotaDimensions, 8)
}
+2 -6
View File
@@ -60,16 +60,12 @@ func init() {
certCreateTenantCACmd.Flags().StringSlice("domain", nil, "Domain the tenant CA is permitted to sign for (repeatable)")
certCreateTenantCACmd.Flags().Bool("regenerate", false, "Replace an existing tenant CA with a fresh root (invalidates every device's trust store — requires confirmation)")
certCreateTenantCACmd.Flags().Bool("yes", false, "Skip the --regenerate confirmation prompt")
if err := certCreateTenantCACmd.MarkFlagRequired("domain"); err != nil {
panic(err)
}
_ = certCreateTenantCACmd.MarkFlagRequired("domain")
certCmd.AddCommand(certForceRenewCmd)
certForceRenewCmd.Flags().String("arn", "", "Certificate ARN to force-renew (required)")
certForceRenewCmd.Flags().String("account", "", "Account ID that owns the certificate; defaults to the bootstrap account")
if err := certForceRenewCmd.MarkFlagRequired("arn"); err != nil {
panic(err)
}
_ = certForceRenewCmd.MarkFlagRequired("arn")
}
func runCertForceRenew(cmd *cobra.Command, _ []string) {
+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)
}
+76 -42
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"
@@ -134,12 +135,11 @@ func init() {
_ = ochreWeightsRemoveCmd.MarkFlagRequired("model-id")
ochreWeightsPullCmd.Flags().String("model-id", "", "Self-host catalog model ID this pull is for (required)")
ochreWeightsPullCmd.Flags().String("hf-repo", "", "Hugging Face repo, e.g. meta-llama/Llama-3.2-1B-Instruct (required)")
ochreWeightsPullCmd.Flags().String("revision", "main", "Hugging Face branch, tag or commit SHA to resolve and pin")
ochreWeightsPullCmd.Flags().String("hf-repo", "", "Hugging Face repo, e.g. meta-llama/Llama-3.2-1B-Instruct (default: the catalog entry's repo)")
ochreWeightsPullCmd.Flags().String("revision", "main", "Hugging Face branch, tag or commit SHA to resolve and pin (default: the catalog entry's pinned revision)")
ochreWeightsPullCmd.Flags().String("s3-uri", "", "predastore destination prefix, e.g. s3://bucket/prefix/ (default: s3://ochre-weights/<repo>/<sha>/)")
ochreWeightsPullCmd.Flags().String("hf-token", "", "Hugging Face token for a gated repo (falls back to HF_TOKEN, then a stored platform credential)")
_ = ochreWeightsPullCmd.MarkFlagRequired("model-id")
_ = ochreWeightsPullCmd.MarkFlagRequired("hf-repo")
ochreCredentialsSetCmd.Flags().String("vendor", "", "Vendor to store a credential for, e.g. huggingface (required)")
ochreCredentialsSetCmd.Flags().String("account", "", "Account ID to store the credential under (default: platform account)")
@@ -180,20 +180,25 @@ type ochrePullManifest struct {
PulledAt time.Time `json:"pulled_at"`
}
// allowedPullFileNames are the fixed-name Hugging Face artefacts pull fetches
// alongside safetensors shards -- config and tokenizer files needed to serve,
// never weights themselves. Matched by basename so a nested path (e.g.
// Llama's original/tokenizer.model) still qualifies.
var allowedPullFileNames = map[string]bool{
// rootOnlyPullFileNames are the fixed-name config artefacts pull fetches only
// at the repo root. A same-named file in a subdir is a component config, not
// the model's -- e.g. sentence-transformers' 1_Pooling/config.json -- and must
// never flatten onto the root config.json, so nested matches are dropped.
var rootOnlyPullFileNames = map[string]bool{
"config.json": true,
"tokenizer_config.json": true,
"tokenizer.json": true,
"tokenizer.model": true,
"generation_config.json": true,
"special_tokens_map.json": true,
"model.safetensors.index.json": true,
}
// nestablePullFileNames are the tokenizer artefacts matched by basename at any
// depth: some repos keep them in a subdir (e.g. Llama's original/tokenizer.model).
var nestablePullFileNames = map[string]bool{
"tokenizer.json": true,
"tokenizer.model": true,
}
// selectPullFiles filters a Hugging Face tree listing down to the
// safetensors-only set pull downloads (D4): every *.safetensors file plus
// the fixed config/tokenizer set. Anything else -- notably .bin/.pt pickle
@@ -205,7 +210,14 @@ func selectPullFiles(entries []hfhub.TreeEntry) []hfhub.TreeEntry {
if e.Type != "file" {
continue
}
if strings.HasSuffix(e.Path, ".safetensors") || allowedPullFileNames[path.Base(e.Path)] {
base := path.Base(e.Path)
nested := strings.Contains(e.Path, "/")
switch {
case strings.HasSuffix(e.Path, ".safetensors"):
selected = append(selected, e)
case nestablePullFileNames[base]:
selected = append(selected, e)
case rootOnlyPullFileNames[base] && !nested:
selected = append(selected, e)
}
}
@@ -324,8 +336,13 @@ func pullFilesToPrefix(ctx context.Context, hf *hfhub.Client, store objectstore.
defer os.RemoveAll(tmpDir)
var uploaded []string
seen := make(map[string]string, len(files))
for _, f := range files {
key := prefix + path.Base(f.Path)
if prev, dup := seen[key]; dup {
return uploaded, fmt.Errorf("refusing to overwrite %s: both %s and %s flatten to it", key, prev, f.Path)
}
seen[key] = f.Path
if err := pullOneFile(ctx, hf, store, hfRepo, sha, f.Path, bucket, key, tmpDir); err != nil {
return uploaded, fmt.Errorf("pull %s: %w", f.Path, err)
}
@@ -334,18 +351,40 @@ 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
// into predastore (D6), and finally the ochre-pull.json manifest. It never
// touches the weights KV store; 'stage' consumes the printed s3:// URI
// separately. A failure removes every object already written (D5).
func runPullWeights(ctx context.Context, hf *hfhub.Client, store objectstore.ObjectStore, modelID, hfRepo, revision, s3URIFlag string) (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 pull does not apply", modelID)
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 {
return "", weightsStagingRefusal(modelID, "pull", found, selfHost)
}
// The catalog carries the canonical repo and a pinned revision so a bare
// pull cannot silently grab a regressed upstream default; an explicit flag
// still overrides either.
if hfRepo == "" {
hfRepo = spec.HFRepo
}
if hfRepo == "" {
return "", fmt.Errorf("no --hf-repo given and %q has no catalog HF repo", modelID)
}
if !revisionExplicit && spec.HFRevision != "" {
revision = spec.HFRevision
}
fmt.Printf("Resolving %s@%s ...\n", hfRepo, revision)
@@ -594,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(),
@@ -608,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
@@ -636,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)
@@ -749,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,
@@ -769,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
}
@@ -969,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 ""
}
@@ -989,6 +1026,7 @@ func runOchreWeightsPull(cmd *cobra.Command, _ []string) {
modelID, _ := cmd.Flags().GetString("model-id")
hfRepo, _ := cmd.Flags().GetString("hf-repo")
revision, _ := cmd.Flags().GetString("revision")
revisionExplicit := cmd.Flags().Changed("revision")
s3URI, _ := cmd.Flags().GetString("s3-uri")
appConfig, nc, err := loadConfigAndConnectFn()
@@ -1006,13 +1044,13 @@ func runOchreWeightsPull(cmd *cobra.Command, _ []string) {
hf := hfhub.NewClient(token)
store := objectstore.NewS3ObjectStoreFromConfig(node.Predastore.Host, node.Predastore.Region, node.Predastore.AccessKey, node.Predastore.SecretKey)
finalURI, err := runPullWeights(ctx, hf, store, modelID, hfRepo, revision, s3URI)
finalURI, err := runPullWeights(ctx, hf, store, modelID, hfRepo, revision, revisionExplicit, s3URI)
if err != nil {
fmt.Fprintf(os.Stderr, "%v\n", err)
ochreExit(1)
return
}
fmt.Printf("✅ Pulled %s@%s into %s\n", hfRepo, revision, finalURI)
fmt.Printf("✅ Pulled into %s\n", finalURI)
fmt.Println(finalURI)
}
@@ -1040,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)
@@ -1138,15 +1176,11 @@ func init() {
c.Flags().String("account-id", "", "12-digit account ID to change (required)")
c.Flags().String("model-id", "", "Model ID to change (e.g. meta.llama3-2-1b-instruct-v1:0)")
c.Flags().Bool("all-models", false, "Apply to every model in the platform catalog")
if err := c.MarkFlagRequired("account-id"); err != nil {
panic(err)
}
_ = c.MarkFlagRequired("account-id")
}
adminOchreAccessListCmd.Flags().String("account-id", "", "12-digit account ID to inspect (required)")
if err := adminOchreAccessListCmd.MarkFlagRequired("account-id"); err != nil {
panic(err)
}
_ = adminOchreAccessListCmd.MarkFlagRequired("account-id")
}
// ochreAccessStore connects to the cluster and returns the grant store along
+19 -9
View File
@@ -29,10 +29,13 @@ var ochreApplianceTeardownCmd = &cobra.Command{
instance, this node's VPC host port, and its KV record.
This is a destructive, cluster-wide operator action -- every account's vector
index data stored in the appliance is lost with it, and it is not
automatically rebuilt. A future daemon startup re-provisions a fresh
appliance (a new instance, a new password) only if Ochre remains enabled;
existing index registry records are not restored.
index data stored in the appliance is lost with it. By default the index
registry and KB/DataSource metadata survive, exactly as they survive a
restart: a future daemon startup re-provisions a fresh appliance (a new
instance, a new password) and reconciles each surviving index by re-creating
it and re-ingesting from its recorded source, with no operator action and no
change to its id. --purge-metadata additionally wipes that metadata for an
intentional full destroy, after which nothing is auto-restored.
Requires --confirm.`,
Run: runOchreApplianceTeardown,
@@ -44,40 +47,47 @@ func init() {
ochreApplianceTeardownCmd.Flags().Bool("confirm", false,
"Required: confirms you intend to destroy the shared platform appliance and every account's vector index data with it")
ochreApplianceTeardownCmd.Flags().Bool("purge-metadata", false,
"Also wipe the index registry and KB/DataSource records, disabling auto-restore on the next re-provision")
}
// applianceTeardownFn indirects the NATS call so runApplianceTeardownGuarded
// is testable without a live daemon, mirroring vectorServiceFn/
// endpointServiceFn.
var applianceTeardownFn = func(ctx context.Context) error {
var applianceTeardownFn = func(ctx context.Context, purgeMetadata bool) error {
_, nc, err := loadConfigAndConnectFn()
if err != nil {
return err
}
defer nc.Close()
_, err = utils.NATSRequest[handlers_ochrevector.TeardownApplianceResponse](ctx, nc,
handlers_ochrevector.SubjectTeardownAppliance, &handlers_ochrevector.TeardownApplianceRequest{}, applianceTeardownTimeout, utils.GlobalAccountID)
handlers_ochrevector.SubjectTeardownAppliance, &handlers_ochrevector.TeardownApplianceRequest{PurgeMetadata: purgeMetadata},
applianceTeardownTimeout, utils.GlobalAccountID)
return err
}
// runApplianceTeardownGuarded is the testable core of 'ochre appliance
// teardown': it refuses without --confirm before making any NATS call, so a
// missing flag never even opens a connection to the cluster.
func runApplianceTeardownGuarded(ctx context.Context, confirmed bool, teardown func(context.Context) error) (string, error) {
func runApplianceTeardownGuarded(ctx context.Context, confirmed, purgeMetadata bool, teardown func(context.Context, bool) error) (string, error) {
if !confirmed {
return "", errors.New("refusing to tear down the shared platform appliance without --confirm: " +
"this destroys every account's vector index data and is not automatically rebuilt")
}
if err := teardown(ctx); err != nil {
if err := teardown(ctx, purgeMetadata); err != nil {
return "", err
}
if purgeMetadata {
return "✅ Platform appliance torn down, index/KB metadata purged.", nil
}
return "✅ Platform appliance torn down.", nil
}
func runOchreApplianceTeardown(cmd *cobra.Command, _ []string) {
confirmed, _ := cmd.Flags().GetBool("confirm")
purgeMetadata, _ := cmd.Flags().GetBool("purge-metadata")
msg, err := runApplianceTeardownGuarded(context.Background(), confirmed, applianceTeardownFn)
msg, err := runApplianceTeardownGuarded(context.Background(), confirmed, purgeMetadata, applianceTeardownFn)
if err != nil {
fmt.Fprintf(os.Stderr, "%v\n", err)
ochreExit(1)
+59 -10
View File
@@ -9,6 +9,7 @@ import (
"errors"
"testing"
"github.com/mulgadc/spinifex/spinifex/testutil"
"github.com/stretchr/testify/require"
)
@@ -17,12 +18,12 @@ import (
// reach a NATS call, let alone destroy the appliance.
func TestRunApplianceTeardownGuarded_RefusesWithoutConfirm(t *testing.T) {
called := false
teardown := func(context.Context) error {
teardown := func(context.Context, bool) error {
called = true
return nil
}
_, err := runApplianceTeardownGuarded(context.Background(), false, teardown)
_, err := runApplianceTeardownGuarded(context.Background(), false, false, teardown)
require.Error(t, err)
require.ErrorContains(t, err, "--confirm")
require.False(t, called, "teardown must not run without --confirm")
@@ -32,12 +33,12 @@ func TestRunApplianceTeardownGuarded_RefusesWithoutConfirm(t *testing.T) {
// drives the teardown call through and reports success.
func TestRunApplianceTeardownGuarded_ConfirmedInvokesTeardown(t *testing.T) {
called := false
teardown := func(context.Context) error {
teardown := func(context.Context, bool) error {
called = true
return nil
}
msg, err := runApplianceTeardownGuarded(context.Background(), true, teardown)
msg, err := runApplianceTeardownGuarded(context.Background(), true, false, teardown)
require.NoError(t, err)
require.True(t, called)
require.Contains(t, msg, "torn down")
@@ -47,19 +48,49 @@ func TestRunApplianceTeardownGuarded_ConfirmedInvokesTeardown(t *testing.T) {
// failure (e.g. the daemon reporting no appliance up) surfaces to the caller
// rather than being swallowed behind a generic success message.
func TestRunApplianceTeardownGuarded_TeardownErrorSurfaces(t *testing.T) {
teardown := func(context.Context) error {
teardown := func(context.Context, bool) error {
return errors.New("ochrevector: platform appliance is not enabled or not up on this node")
}
_, err := runApplianceTeardownGuarded(context.Background(), true, teardown)
_, err := runApplianceTeardownGuarded(context.Background(), true, false, teardown)
require.Error(t, err)
require.ErrorContains(t, err, "not enabled or not up")
}
// TestRunApplianceTeardownGuarded_PurgeMetadataThreadsThrough proves the
// --purge-metadata flag reaches the teardown call and is reflected in the
// success message, so an operator can tell which mode ran.
func TestRunApplianceTeardownGuarded_PurgeMetadataThreadsThrough(t *testing.T) {
var gotPurge bool
teardown := func(_ context.Context, purgeMetadata bool) error {
gotPurge = purgeMetadata
return nil
}
msg, err := runApplianceTeardownGuarded(context.Background(), true, true, teardown)
require.NoError(t, err)
require.True(t, gotPurge, "purgeMetadata must reach the teardown call")
require.Contains(t, msg, "metadata purged")
}
// TestApplianceTeardownFn_ThreadsPurgeMetadataOverNATS drives the real
// (unstubbed) applianceTeardownFn through runApplianceTeardownGuarded, so the
// actual NATSRequest call -- including the PurgeMetadata field on the wire
// request -- executes rather than a test double standing in for it. No
// daemon is subscribed to the teardown subject in this test, so the call
// fails fast; the point is that it's reached and built with PurgeMetadata set.
func TestApplianceTeardownFn_ThreadsPurgeMetadataOverNATS(t *testing.T) {
_, nc, _ := testutil.StartTestJetStream(t)
stubConnect(t, fakeClusterConfig(), nc, nil)
_, err := runApplianceTeardownGuarded(context.Background(), true, true, applianceTeardownFn)
require.Error(t, err, "no daemon is subscribed to the teardown subject in this test")
}
// withApplianceTeardown swaps applianceTeardownFn for one that never dials
// NATS, so the Run wrapper's real flag/exit control flow is exercised
// without a live daemon.
func withApplianceTeardown(t *testing.T, fn func(context.Context) error) {
func withApplianceTeardown(t *testing.T, fn func(context.Context, bool) error) {
t.Helper()
orig := applianceTeardownFn
t.Cleanup(func() { applianceTeardownFn = orig })
@@ -67,7 +98,7 @@ func withApplianceTeardown(t *testing.T, fn func(context.Context) error) {
}
func TestRunOchreApplianceTeardown_MissingConfirmExits1(t *testing.T) {
withApplianceTeardown(t, func(context.Context) error {
withApplianceTeardown(t, func(context.Context, bool) error {
t.Fatal("teardown must not be called without --confirm")
return nil
})
@@ -78,7 +109,7 @@ func TestRunOchreApplianceTeardown_MissingConfirmExits1(t *testing.T) {
}
func TestRunOchreApplianceTeardown_ConfirmedPrintsSuccess(t *testing.T) {
withApplianceTeardown(t, func(context.Context) error { return nil })
withApplianceTeardown(t, func(context.Context, bool) error { return nil })
cmd := *ochreApplianceTeardownCmd
require.NoError(t, cmd.Flags().Set("confirm", "true"))
@@ -92,7 +123,7 @@ func TestRunOchreApplianceTeardown_ConfirmedPrintsSuccess(t *testing.T) {
}
func TestRunOchreApplianceTeardown_TeardownErrorExits1(t *testing.T) {
withApplianceTeardown(t, func(context.Context) error {
withApplianceTeardown(t, func(context.Context, bool) error {
return errors.New("ochrevector: platform appliance is not enabled or not up on this node")
})
@@ -102,3 +133,21 @@ func TestRunOchreApplianceTeardown_TeardownErrorExits1(t *testing.T) {
code := withOchreExitCapture(t, func() { runOchreApplianceTeardown(&cmd, nil) })
require.Equal(t, 1, code)
}
// TestRunOchreApplianceTeardown_PurgeMetadataFlagReachesTeardownFn proves
// the --purge-metadata flag is read and passed through the Run wrapper.
func TestRunOchreApplianceTeardown_PurgeMetadataFlagReachesTeardownFn(t *testing.T) {
var gotPurge bool
withApplianceTeardown(t, func(_ context.Context, purgeMetadata bool) error {
gotPurge = purgeMetadata
return nil
})
cmd := *ochreApplianceTeardownCmd
require.NoError(t, cmd.Flags().Set("confirm", "true"))
require.NoError(t, cmd.Flags().Set("purge-metadata", "true"))
code := withOchreExitCapture(t, func() { runOchreApplianceTeardown(&cmd, nil) })
require.Equal(t, -1, code)
require.True(t, gotPurge, "--purge-metadata must reach applianceTeardownFn")
}
+31 -2
View File
@@ -79,6 +79,7 @@ func init() {
_ = ochreEndpointDescribeCmd.MarkFlagRequired("model-id")
ochreEndpointDeleteCmd.Flags().String("model-id", "", "Model ID whose endpoint to tear down (required)")
ochreEndpointDeleteCmd.Flags().String("account", "", "Account ID scoping the target (defaults to the shared platform account; set to tear down a pinned provisioned-throughput endpoint)")
_ = ochreEndpointDeleteCmd.MarkFlagRequired("model-id")
}
@@ -331,8 +332,35 @@ func runOchreEndpointList(_ *cobra.Command, _ []string) {
fmt.Println(msg)
}
// deleteEndpointOutput is the testable core of 'ochre endpoint delete': it
// tears the endpoint down and returns an honest message — a no-op that found
// no record must not claim a teardown, and it points the operator at --account
// so a pinned, account-scoped record they can see in 'list' is reachable.
func deleteEndpointOutput(ctx context.Context, svc handlers_bedrock.EndpointService, modelID, accountID string) (string, error) {
out, err := svc.Delete(ctx, &handlers_bedrock.DeleteEndpointInput{ModelID: modelID, AccountID: accountID}, utils.GlobalAccountID)
if err != nil {
return "", err
}
if !out.Removed {
return fmt.Sprintf("No serving endpoint for %s under account %s; nothing to tear down. "+
"If 'endpoint list' shows one under a different account, pass --account.\n",
modelID, resolveAccountLabel(accountID)), nil
}
return fmt.Sprintf("Endpoint for %s torn down.\n", modelID), nil
}
// resolveAccountLabel renders the account an empty flag resolves to, so the
// no-op message names the shared platform account rather than an empty string.
func resolveAccountLabel(accountID string) string {
if accountID == "" {
return utils.GlobalAccountID + " (shared platform)"
}
return accountID
}
func runOchreEndpointDelete(cmd *cobra.Command, _ []string) {
modelID, _ := cmd.Flags().GetString("model-id")
accountID, _ := cmd.Flags().GetString("account")
svc, closeFn, err := endpointServiceFn()
if err != nil {
@@ -342,10 +370,11 @@ func runOchreEndpointDelete(cmd *cobra.Command, _ []string) {
}
defer closeFn()
if _, err := svc.Delete(context.Background(), &handlers_bedrock.DeleteEndpointInput{ModelID: modelID}, utils.GlobalAccountID); err != nil {
msg, err := deleteEndpointOutput(context.Background(), svc, modelID, accountID)
if err != nil {
fmt.Fprintf(os.Stderr, "%v\n", err)
ochreExit(1)
return
}
fmt.Printf("Endpoint for %s torn down.\n", modelID)
fmt.Print(msg)
}
+52 -10
View File
@@ -14,17 +14,19 @@ import (
// fakeEndpointService serves scripted Describe responses so the wait loop can
// be exercised without a daemon, a VM or a real cold start.
type fakeEndpointService struct {
ensure handlers_bedrock.EndpointRecord
ensureErr error
describes []handlers_bedrock.EndpointRecord
describeErr error
list []handlers_bedrock.EndpointRecord
listErr error
deleteErr error
ensure handlers_bedrock.EndpointRecord
ensureErr error
describes []handlers_bedrock.EndpointRecord
describeErr error
list []handlers_bedrock.EndpointRecord
listErr error
deleteErr error
deleteRemoved bool
describeCalls int
deleteCalls int
describeInputs []*handlers_bedrock.DescribeEndpointInput
deleteInputs []*handlers_bedrock.DeleteEndpointInput
}
func (f *fakeEndpointService) Ensure(_ context.Context, _ *handlers_bedrock.EnsureEndpointInput, _ string) (*handlers_bedrock.EnsureEndpointOutput, error) {
@@ -53,12 +55,13 @@ func (f *fakeEndpointService) List(_ context.Context, _ *handlers_bedrock.ListEn
return &handlers_bedrock.ListEndpointsOutput{Endpoints: f.list}, nil
}
func (f *fakeEndpointService) Delete(_ context.Context, _ *handlers_bedrock.DeleteEndpointInput, _ string) (*handlers_bedrock.DeleteEndpointOutput, error) {
func (f *fakeEndpointService) Delete(_ context.Context, in *handlers_bedrock.DeleteEndpointInput, _ string) (*handlers_bedrock.DeleteEndpointOutput, error) {
f.deleteCalls++
f.deleteInputs = append(f.deleteInputs, in)
if f.deleteErr != nil {
return nil, f.deleteErr
}
return &handlers_bedrock.DeleteEndpointOutput{}, nil
return &handlers_bedrock.DeleteEndpointOutput{Removed: f.deleteRemoved}, nil
}
var _ handlers_bedrock.EndpointService = (*fakeEndpointService)(nil)
@@ -409,7 +412,7 @@ func TestRunOchreEndpointList_ConnectFailureExits1(t *testing.T) {
}
func TestRunOchreEndpointDelete_ReportsTeardown(t *testing.T) {
svc := &fakeEndpointService{}
svc := &fakeEndpointService{deleteRemoved: true}
withEndpointService(t, svc, nil)
cmd := *ochreEndpointDeleteCmd
@@ -424,6 +427,45 @@ func TestRunOchreEndpointDelete_ReportsTeardown(t *testing.T) {
require.Contains(t, out, "torn down")
}
// TestRunOchreEndpointDelete_NoRecordDoesNotClaimTeardown is 6z9vg's honesty
// guard: a delete that removed nothing must not print "torn down", and must
// point the operator at --account so a pinned, account-scoped record they can
// see in 'list' is reachable.
func TestRunOchreEndpointDelete_NoRecordDoesNotClaimTeardown(t *testing.T) {
svc := &fakeEndpointService{deleteRemoved: false}
withEndpointService(t, svc, nil)
cmd := *ochreEndpointDeleteCmd
require.NoError(t, cmd.Flags().Set("model-id", testModelID))
var out string
code := withOchreExitCapture(t, func() {
out = captureStdout(t, func() { runOchreEndpointDelete(&cmd, nil) })
})
require.Equal(t, -1, code)
require.NotContains(t, out, "torn down")
require.Contains(t, out, "--account")
}
// TestRunOchreEndpointDelete_AccountFlagReachesInput is 6z9vg's core seam: the
// --account flag must reach DeleteEndpointInput.AccountID, which is how an
// operator tears down a pinned endpoint the bare GlobalAccountID key misses.
func TestRunOchreEndpointDelete_AccountFlagReachesInput(t *testing.T) {
svc := &fakeEndpointService{deleteRemoved: true}
withEndpointService(t, svc, nil)
cmd := *ochreEndpointDeleteCmd
require.NoError(t, cmd.Flags().Set("model-id", testModelID))
require.NoError(t, cmd.Flags().Set("account", testAccountID))
code := withOchreExitCapture(t, func() {
_ = captureStdout(t, func() { runOchreEndpointDelete(&cmd, nil) })
})
require.Equal(t, -1, code)
require.Len(t, svc.deleteInputs, 1)
require.Equal(t, testAccountID, svc.deleteInputs[0].AccountID)
}
func TestRunOchreEndpointDelete_ErrorExits1(t *testing.T) {
withEndpointService(t, &fakeEndpointService{deleteErr: errors.New("ModelNotReadyException")}, nil)
+65 -14
View File
@@ -32,10 +32,11 @@ import (
// --- selectPullFiles / anySafetensors (D4) ---
// TestSelectPullFiles_SafetensorsAndConfigOnly covers D4's filter: every
// *.safetensors file, the fixed config/tokenizer set, and the shard index are
// kept (matched by basename, so a nested path like Llama's
// original/tokenizer.model still qualifies); a pickle .bin checkpoint and
// directory entries are dropped.
// *.safetensors file, the fixed config set (root only), tokenizer files (at
// any depth, so Llama's original/tokenizer.model qualifies), and the shard
// index are kept; a pickle .bin, a directory entry, and a nested config.json
// (a sentence-transformers component config that must never flatten onto the
// model's own config.json) are dropped.
func TestSelectPullFiles_SafetensorsAndConfigOnly(t *testing.T) {
entries := []hfhub.TreeEntry{
{Type: "file", Path: "config.json"},
@@ -47,6 +48,7 @@ func TestSelectPullFiles_SafetensorsAndConfigOnly(t *testing.T) {
{Type: "file", Path: "model.safetensors.index.json"},
{Type: "directory", Path: "original"},
{Type: "file", Path: "original/tokenizer.model"},
{Type: "file", Path: "1_Pooling/config.json"},
}
selected := selectPullFiles(entries)
@@ -64,6 +66,7 @@ func TestSelectPullFiles_SafetensorsAndConfigOnly(t *testing.T) {
"model.safetensors.index.json",
"original/tokenizer.model",
}, paths)
assert.NotContains(t, paths, "1_Pooling/config.json", "a nested config.json must be dropped, never flattened onto the model config")
}
// TestAnySafetensors_TrueWhenPresent covers the pass case: at least one
@@ -227,15 +230,17 @@ const (
// TestRunPullWeights_UnknownModelRefused covers the catalog gate: an
// unrecognised model ID must refuse before any Hugging Face or S3 work.
func TestRunPullWeights_UnknownModelRefused(t *testing.T) {
_, err := runPullWeights(context.Background(), deadHFClient(), explodingObjectStore{t: t}, "not-a-real-model", testHFRepo, "main", "")
_, err := runPullWeights(context.Background(), deadHFClient(), explodingObjectStore{t: t}, "not-a-real-model", testHFRepo, "main", true, "")
require.Error(t, err)
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", "")
// 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")
}
@@ -256,7 +261,7 @@ func TestRunPullWeights_ResolvesUploadsAndWritesManifest(t *testing.T) {
hf := &hfhub.Client{BaseURL: srv.URL, HTTP: srv.Client()}
store := objectstore.NewMemoryObjectStore()
finalURI, err := runPullWeights(context.Background(), hf, store, selfHostModelID, testHFRepo, "main", "")
finalURI, err := runPullWeights(context.Background(), hf, store, selfHostModelID, testHFRepo, "main", true, "")
require.NoError(t, err)
assert.Equal(t, defaultPullPrefix(testHFRepo, testHFSHA), finalURI)
@@ -287,7 +292,7 @@ func TestRunPullWeights_ExplicitS3URIOverridesDefault(t *testing.T) {
hf := &hfhub.Client{BaseURL: srv.URL, HTTP: srv.Client()}
store := objectstore.NewMemoryObjectStore()
finalURI, err := runPullWeights(context.Background(), hf, store, selfHostModelID, testHFRepo, "main", "s3://custom-bucket/my-prefix")
finalURI, err := runPullWeights(context.Background(), hf, store, selfHostModelID, testHFRepo, "main", true, "s3://custom-bucket/my-prefix")
require.NoError(t, err)
assert.Equal(t, "s3://custom-bucket/my-prefix/", finalURI)
}
@@ -300,7 +305,7 @@ func TestRunPullWeights_NoSafetensorsAborts(t *testing.T) {
hf := &hfhub.Client{BaseURL: srv.URL, HTTP: srv.Client()}
store := objectstore.NewMemoryObjectStore()
_, err := runPullWeights(context.Background(), hf, store, selfHostModelID, testHFRepo, "main", "")
_, err := runPullWeights(context.Background(), hf, store, selfHostModelID, testHFRepo, "main", true, "")
require.Error(t, err)
assert.Contains(t, err.Error(), "no *.safetensors")
assert.Equal(t, 0, store.Count(), "a refused pull must land nothing")
@@ -315,7 +320,7 @@ func TestRunPullWeights_GatedRepoAbortsCleanBeforeAnyUpload(t *testing.T) {
hf := &hfhub.Client{BaseURL: srv.URL, HTTP: srv.Client()}
store := objectstore.NewMemoryObjectStore()
_, err := runPullWeights(context.Background(), hf, store, selfHostModelID, testHFRepo, "main", "")
_, err := runPullWeights(context.Background(), hf, store, selfHostModelID, testHFRepo, "main", true, "")
require.Error(t, err)
assert.True(t, awserrors.IsErrorCode(err, awserrors.ErrorAccessDeniedException), "status %d: got %v", status, err)
assert.Equal(t, 0, store.Count(), "a gated pull must land nothing")
@@ -356,11 +361,57 @@ func TestRunPullWeights_MidStreamFailureCleansUpPartialPrefix(t *testing.T) {
hf := &hfhub.Client{BaseURL: srv.URL, HTTP: srv.Client()}
store := &failAfterNPutsStore{MemoryObjectStore: objectstore.NewMemoryObjectStore(), n: 2}
_, err := runPullWeights(context.Background(), hf, store, selfHostModelID, testHFRepo, "main", "")
_, err := runPullWeights(context.Background(), hf, store, selfHostModelID, testHFRepo, "main", true, "")
require.Error(t, err)
assert.Equal(t, 0, store.Count(), "objects uploaded before the failure must be cleaned up")
}
// TestRunPullWeights_DefaultsRepoFromCatalog covers the catalog repo default:
// with no --hf-repo, pull uses the self-host entry's canonical HFRepo rather
// than refusing, so an operator need not restate a repo the catalog knows.
func TestRunPullWeights_DefaultsRepoFromCatalog(t *testing.T) {
files := []fakeHFFile{{"config.json", "{}"}, {"tokenizer_config.json", "{}"}, {"tokenizer.json", "{}"}, {"model.safetensors", "x"}}
srv := newFakeHFServer(t, testHFRepo, "main", testHFSHA, files)
hf := &hfhub.Client{BaseURL: srv.URL, HTTP: srv.Client()}
store := objectstore.NewMemoryObjectStore()
finalURI, err := runPullWeights(context.Background(), hf, store, selfHostModelID, "", "main", false, "")
require.NoError(t, err)
assert.Equal(t, defaultPullPrefix(testHFRepo, testHFSHA), finalURI)
}
// TestRunPullWeights_DefaultsRevisionFromCatalogPin covers the pinned-revision
// default: a bare pull of an entry carrying HFRevision resolves that pin, not
// the "main" flag default, so a regressed upstream main cannot be picked up.
func TestRunPullWeights_DefaultsRevisionFromCatalogPin(t *testing.T) {
const nomicModelID = "nomic-embed-text-v1.5"
const nomicRepo = "nomic-ai/nomic-embed-text-v1.5"
const pinnedRevision = "e5cf08a"
files := []fakeHFFile{{"config.json", "{}"}, {"tokenizer_config.json", "{}"}, {"tokenizer.json", "{}"}, {"model.safetensors", "x"}}
srv := newFakeHFServer(t, nomicRepo, pinnedRevision, testHFSHA, files)
hf := &hfhub.Client{BaseURL: srv.URL, HTTP: srv.Client()}
store := objectstore.NewMemoryObjectStore()
finalURI, err := runPullWeights(context.Background(), hf, store, nomicModelID, "", "main", false, "")
require.NoError(t, err)
assert.Equal(t, defaultPullPrefix(nomicRepo, testHFSHA), finalURI)
}
// TestPullFilesToPrefix_RefusesDuplicateFlattenedKey covers the flatten guard:
// two source paths sharing a basename would collide at one destination key, so
// the pull aborts rather than silently overwriting one with the other.
func TestPullFilesToPrefix_RefusesDuplicateFlattenedKey(t *testing.T) {
files := []fakeHFFile{{"a/tokenizer.model", "first"}, {"b/tokenizer.model", "second"}}
srv := newFakeHFServer(t, testHFRepo, "main", testHFSHA, files)
hf := &hfhub.Client{BaseURL: srv.URL, HTTP: srv.Client()}
store := objectstore.NewMemoryObjectStore()
entries := []hfhub.TreeEntry{{Type: "file", Path: "a/tokenizer.model"}, {Type: "file", Path: "b/tokenizer.model"}}
_, err := pullFilesToPrefix(context.Background(), hf, store, testHFRepo, testHFSHA, "ochre-weights", "prefix/", entries)
require.Error(t, err)
assert.Contains(t, err.Error(), "flatten to it")
}
// --- resolveHFToken (D2) ---
func newOchreWeightsPullTestCmd(t *testing.T, modelID, hfRepo, revision, s3URI, hfToken string) *cobra.Command {
+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)
}
+108
View File
@@ -96,6 +96,26 @@ truncated in the default table; pass --json for the full response.`,
Run: runOchreVectorQuery,
}
var ochreVectorBackupCmd = &cobra.Command{
Use: "backup",
Short: "Back up an account's vector-store schema to predastore",
Long: `backup runs pg_dump against the platform appliance, scoped to --account's own
kb_<account> schema, gzips the output, and uploads it to predastore under an
account-scoped, timestamped object key. Backup is an explicit operator
action -- it never runs automatically on appliance teardown.`,
Run: runOchreVectorBackup,
}
var ochreVectorRestoreCmd = &cobra.Command{
Use: "restore",
Short: "Restore an account's vector-store schema from a predastore backup",
Long: `restore imports --from (an object key a prior backup wrote) back into
--account's schema, replacing its existing tables cleanly, then repairs the
account role's grants so query/ingest access resumes exactly as before the
backup. Safe to run against an existing schema.`,
Run: runOchreVectorRestore,
}
func init() {
ochreCmd.AddCommand(ochreVectorCmd)
ochreVectorCmd.AddCommand(ochreVectorIndexCmd)
@@ -106,6 +126,8 @@ func init() {
ochreVectorCmd.AddCommand(ochreVectorJobCmd)
ochreVectorJobCmd.AddCommand(ochreVectorJobDescribeCmd)
ochreVectorCmd.AddCommand(ochreVectorQueryCmd)
ochreVectorCmd.AddCommand(ochreVectorBackupCmd)
ochreVectorCmd.AddCommand(ochreVectorRestoreCmd)
ochreVectorIndexCreateCmd.Flags().String("name", "", "Human-readable index name (required)")
ochreVectorIndexCreateCmd.Flags().Int("dimension", 0, "Embedding vector dimension (required)")
@@ -129,6 +151,14 @@ func init() {
ochreVectorQueryCmd.Flags().Bool("json", false, "Print full, untruncated results as JSON")
_ = ochreVectorQueryCmd.MarkFlagRequired("index")
_ = ochreVectorQueryCmd.MarkFlagRequired("text")
ochreVectorBackupCmd.Flags().String("account", "", "Account ID to back up (required)")
_ = ochreVectorBackupCmd.MarkFlagRequired("account")
ochreVectorRestoreCmd.Flags().String("account", "", "Account ID to restore into (required)")
ochreVectorRestoreCmd.Flags().String("from", "", "Backup object key to restore from, as printed by 'backup' (required)")
_ = ochreVectorRestoreCmd.MarkFlagRequired("account")
_ = ochreVectorRestoreCmd.MarkFlagRequired("from")
}
// vectorServiceFn indirects the NATS-backed client so the Run functions'
@@ -142,6 +172,59 @@ var vectorServiceFn = func() (handlers_ochrevector.VectorService, func(), error)
return handlers_ochrevector.NewNATSVectorService(nc), nc.Close, nil
}
// vectorBackupRestoreTimeout bounds the backup/restore NATS round trip: it
// covers the appliance's own pg_dump/psql run, not just a KV read/write, so
// it is far more generous than the other vector subjects' timeouts.
const vectorBackupRestoreTimeout = 10 * time.Minute
// backupAccountFn indirects the NATS call so runBackupAccount is testable
// without a live daemon, mirroring vectorServiceFn/applianceTeardownFn.
// Backup/restore are operator-only (unlike VectorService's methods): the
// target account is named in the request payload, not derived from the
// caller's own header identity, since the operator CLI always connects as
// the global account but must be able to target any tenant's schema.
var backupAccountFn = func(ctx context.Context, accountID string) (*handlers_ochrevector.BackupAccountResponse, error) {
_, nc, err := loadConfigAndConnectFn()
if err != nil {
return nil, err
}
defer nc.Close()
return utils.NATSRequest[handlers_ochrevector.BackupAccountResponse](ctx, nc,
handlers_ochrevector.SubjectBackupAccount, &handlers_ochrevector.BackupAccountRequest{AccountID: accountID},
vectorBackupRestoreTimeout, utils.GlobalAccountID)
}
// restoreAccountFn indirects the NATS call so runRestoreAccount is testable
// without a live daemon, mirroring backupAccountFn.
var restoreAccountFn = func(ctx context.Context, accountID, objectKey string) error {
_, nc, err := loadConfigAndConnectFn()
if err != nil {
return err
}
defer nc.Close()
_, err = utils.NATSRequest[handlers_ochrevector.RestoreAccountResponse](ctx, nc,
handlers_ochrevector.SubjectRestoreAccount, &handlers_ochrevector.RestoreAccountRequest{AccountID: accountID, ObjectKey: objectKey},
vectorBackupRestoreTimeout, utils.GlobalAccountID)
return err
}
// runBackupAccount is the testable core of 'ochre vector backup'.
func runBackupAccount(ctx context.Context, accountID string, backup func(context.Context, string) (*handlers_ochrevector.BackupAccountResponse, error)) (string, error) {
out, err := backup(ctx, accountID)
if err != nil {
return "", err
}
return fmt.Sprintf("✅ Backed up account %s to %s (%d bytes).", accountID, out.ObjectKey, out.SizeBytes), nil
}
// runRestoreAccount is the testable core of 'ochre vector restore'.
func runRestoreAccount(ctx context.Context, accountID, objectKey string, restore func(context.Context, string, string) error) (string, error) {
if err := restore(ctx, accountID, objectKey); err != nil {
return "", err
}
return fmt.Sprintf("✅ Restored account %s from %s.", accountID, objectKey), nil
}
// formatIndexRecord renders one index record as aligned key/value lines.
func formatIndexRecord(rec handlers_ochrevector.Record) string {
rows := [][2]string{
@@ -448,3 +531,28 @@ func runOchreVectorQuery(cmd *cobra.Command, _ []string) {
}
fmt.Println(out)
}
func runOchreVectorBackup(cmd *cobra.Command, _ []string) {
accountID, _ := cmd.Flags().GetString("account")
msg, err := runBackupAccount(context.Background(), accountID, backupAccountFn)
if err != nil {
fmt.Fprintf(os.Stderr, "%v\n", err)
ochreExit(1)
return
}
fmt.Println(msg)
}
func runOchreVectorRestore(cmd *cobra.Command, _ []string) {
accountID, _ := cmd.Flags().GetString("account")
objectKey, _ := cmd.Flags().GetString("from")
msg, err := runRestoreAccount(context.Background(), accountID, objectKey, restoreAccountFn)
if err != nil {
fmt.Fprintf(os.Stderr, "%v\n", err)
ochreExit(1)
return
}
fmt.Println(msg)
}
+133
View File
@@ -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"
@@ -382,3 +395,123 @@ func TestRunOchreVectorQuery_ConnectFailureExits1(t *testing.T) {
code := withOchreExitCapture(t, func() { runOchreVectorQuery(&cmd, nil) })
require.Equal(t, 1, code)
}
const testBackupAccountID = "123456789012"
func TestRunBackupAccount_PrintsObjectKeyAndSize(t *testing.T) {
backup := func(_ context.Context, accountID string) (*handlers_ochrevector.BackupAccountResponse, error) {
require.Equal(t, testBackupAccountID, accountID)
return &handlers_ochrevector.BackupAccountResponse{ObjectKey: testBackupAccountID + "/backup.sql.gz", SizeBytes: 4096}, nil
}
msg, err := runBackupAccount(context.Background(), testBackupAccountID, backup)
require.NoError(t, err)
require.Contains(t, msg, testBackupAccountID+"/backup.sql.gz")
require.Contains(t, msg, "4096")
}
func TestRunBackupAccount_ErrorSurfaces(t *testing.T) {
backup := func(context.Context, string) (*handlers_ochrevector.BackupAccountResponse, error) {
return nil, errors.New("ochrevector: appliance not available")
}
_, err := runBackupAccount(context.Background(), testBackupAccountID, backup)
require.ErrorContains(t, err, "not available")
}
func TestRunRestoreAccount_ReportsSuccess(t *testing.T) {
var gotAccount, gotKey string
restore := func(_ context.Context, accountID, objectKey string) error {
gotAccount, gotKey = accountID, objectKey
return nil
}
msg, err := runRestoreAccount(context.Background(), testBackupAccountID, testBackupAccountID+"/backup.sql.gz", restore)
require.NoError(t, err)
require.Equal(t, testBackupAccountID, gotAccount)
require.Equal(t, testBackupAccountID+"/backup.sql.gz", gotKey)
require.Contains(t, msg, testBackupAccountID)
}
func TestRunRestoreAccount_ErrorSurfaces(t *testing.T) {
restore := func(context.Context, string, string) error {
return errors.New("ochrevector: object key does not belong to account")
}
_, err := runRestoreAccount(context.Background(), testBackupAccountID, "wrong/backup.sql.gz", restore)
require.ErrorContains(t, err, "does not belong to account")
}
// withBackupAccount swaps backupAccountFn for one that never dials NATS, so
// the Run wrapper's flag/exit control flow is exercised without a live
// daemon, mirroring withApplianceTeardown.
func withBackupAccount(t *testing.T, fn func(context.Context, string) (*handlers_ochrevector.BackupAccountResponse, error)) {
t.Helper()
orig := backupAccountFn
t.Cleanup(func() { backupAccountFn = orig })
backupAccountFn = fn
}
// withRestoreAccount mirrors withBackupAccount for restoreAccountFn.
func withRestoreAccount(t *testing.T, fn func(context.Context, string, string) error) {
t.Helper()
orig := restoreAccountFn
t.Cleanup(func() { restoreAccountFn = orig })
restoreAccountFn = fn
}
func TestRunOchreVectorBackup_PrintsSuccess(t *testing.T) {
withBackupAccount(t, func(context.Context, string) (*handlers_ochrevector.BackupAccountResponse, error) {
return &handlers_ochrevector.BackupAccountResponse{ObjectKey: testBackupAccountID + "/backup.sql.gz", SizeBytes: 1024}, nil
})
cmd := *ochreVectorBackupCmd
require.NoError(t, cmd.Flags().Set("account", testBackupAccountID))
var out string
code := withOchreExitCapture(t, func() {
out = captureStdout(t, func() { runOchreVectorBackup(&cmd, nil) })
})
require.Equal(t, -1, code)
require.Contains(t, out, testBackupAccountID+"/backup.sql.gz")
}
func TestRunOchreVectorBackup_ErrorExits1(t *testing.T) {
withBackupAccount(t, func(context.Context, string) (*handlers_ochrevector.BackupAccountResponse, error) {
return nil, errors.New("ochrevector: appliance not available")
})
cmd := *ochreVectorBackupCmd
require.NoError(t, cmd.Flags().Set("account", testBackupAccountID))
code := withOchreExitCapture(t, func() { runOchreVectorBackup(&cmd, nil) })
require.Equal(t, 1, code)
}
func TestRunOchreVectorRestore_PrintsSuccess(t *testing.T) {
withRestoreAccount(t, func(context.Context, string, string) error { return nil })
cmd := *ochreVectorRestoreCmd
require.NoError(t, cmd.Flags().Set("account", testBackupAccountID))
require.NoError(t, cmd.Flags().Set("from", testBackupAccountID+"/backup.sql.gz"))
var out string
code := withOchreExitCapture(t, func() {
out = captureStdout(t, func() { runOchreVectorRestore(&cmd, nil) })
})
require.Equal(t, -1, code)
require.Contains(t, out, testBackupAccountID)
}
func TestRunOchreVectorRestore_ErrorExits1(t *testing.T) {
withRestoreAccount(t, func(context.Context, string, string) error {
return errors.New("ochrevector: restore requires an object key")
})
cmd := *ochreVectorRestoreCmd
require.NoError(t, cmd.Flags().Set("account", testBackupAccountID))
require.NoError(t, cmd.Flags().Set("from", testBackupAccountID+"/backup.sql.gz"))
code := withOchreExitCapture(t, func() { runOchreVectorRestore(&cmd, nil) })
require.Equal(t, 1, code)
}
+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")))
}
+11 -56
View File
@@ -15,32 +15,27 @@ import (
)
// TestUpgradeSeesEveryConfigTarget pins the registrations `spx admin upgrade`
// depends on. A migration whose package is no longer linked here would leave
// the command reporting nothing pending on an install that needs migrating.
// depends on. A target whose package is no longer linked here would leave the
// command reporting nothing at all for a config file it installs.
//
// spinifex.toml is the only config target: predastore.toml is not migrated at
// all, so a predastore config on disk must contribute neither a version nor a
// pending step.
// pending step. No migrations are registered, so nothing is ever pending.
func TestUpgradeSeesEveryConfigTarget(t *testing.T) {
configDir := t.TempDir()
// A target must exist on disk to be reported at all.
require.NoError(t, os.MkdirAll(filepath.Join(configDir, "predastore"), 0750))
require.NoError(t, os.WriteFile(filepath.Join(configDir, "spinifex.toml"), []byte("version = \"3\"\n"), 0640))
require.NoError(t, os.WriteFile(filepath.Join(configDir, "spinifex.toml"), []byte("version = \"4\"\n"), 0640))
require.NoError(t, os.WriteFile(filepath.Join(configDir, "predastore", "predastore.toml"), []byte("version = 1\n"), 0640))
versions, err := migrate.DefaultRegistry.ConfigVersions(configDir)
require.NoError(t, err)
assert.Equal(t, map[string]int{"spinifex.toml": 3}, versions)
assert.Equal(t, map[string]int{"spinifex.toml": 4}, versions)
pending, err := migrate.DefaultRegistry.PendingConfig(configDir)
require.NoError(t, err)
targets := make(map[string][2]int, len(pending))
for _, p := range pending {
targets[p.Target] = [2]int{p.FromVersion, p.ToVersion}
}
assert.Equal(t, map[string][2]int{"spinifex.toml": {3, 4}}, targets)
assert.Empty(t, pending)
}
func TestValidateUpgradeFlags(t *testing.T) {
@@ -140,19 +135,6 @@ func TestReportConfigStatus_NoPendingMigrations(t *testing.T) {
assert.Contains(t, out, "spinifex.toml:")
}
func TestReportConfigStatus_PendingMigrationIsListed(t *testing.T) {
configDir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(configDir, "spinifex.toml"), []byte("version = \"3\"\n"), 0640))
var pending []migrate.PendingMigration
out := captureStdout(t, func() { pending = reportConfigStatus(configDir) })
require.Len(t, pending, 1)
assert.Equal(t, "spinifex.toml", pending[0].Target)
assert.Contains(t, out, "Pending config migrations:")
assert.Contains(t, out, "spinifex.toml 3 → 4")
}
// captureStderr redirects os.Stderr for the duration of fn and returns what
// was written. runAdminUpgrade's error paths print to stderr, not stdout.
func captureStderr(t *testing.T, fn func()) string {
@@ -293,9 +275,9 @@ func TestRunAdminUpgrade_DryRunNothingPending(t *testing.T) {
assert.Contains(t, out, "Nothing pending — config and units are current.")
}
func TestRunAdminUpgrade_DryRunReportsBothPending(t *testing.T) {
func TestRunAdminUpgrade_DryRunReportsUnitsPending(t *testing.T) {
configDir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(configDir, "spinifex.toml"), []byte("version = \"3\"\n"), 0640))
require.NoError(t, os.WriteFile(filepath.Join(configDir, "spinifex.toml"), []byte("version = \"4\"\n"), 0640))
withUnitRoot(t, t.TempDir()) // empty: every unit reports missing
cmd := newUpgradeTestCmd(t, configDir, t.TempDir())
@@ -307,15 +289,12 @@ func TestRunAdminUpgrade_DryRunReportsBothPending(t *testing.T) {
})
assert.Equal(t, -1, code)
assert.NotContains(t, out, "Nothing pending")
assert.Contains(t, out, "Pending config migrations:")
assert.Contains(t, out, "will install")
}
func TestRunAdminUpgrade_UnitsOnlySkipsConfigReporting(t *testing.T) {
configDir := t.TempDir()
// Version 3 would normally report a pending config migration; --units-only
// must never look at it.
require.NoError(t, os.WriteFile(filepath.Join(configDir, "spinifex.toml"), []byte("version = \"3\"\n"), 0640))
require.NoError(t, os.WriteFile(filepath.Join(configDir, "spinifex.toml"), []byte("version = \"4\"\n"), 0640))
withUnitRoot(t, t.TempDir())
cmd := newUpgradeTestCmd(t, configDir, t.TempDir())
@@ -332,7 +311,7 @@ func TestRunAdminUpgrade_UnitsOnlySkipsConfigReporting(t *testing.T) {
func TestRunAdminUpgrade_SkipUnitsSkipsUnitReporting(t *testing.T) {
configDir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(configDir, "spinifex.toml"), []byte("version = \"3\"\n"), 0640))
require.NoError(t, os.WriteFile(filepath.Join(configDir, "spinifex.toml"), []byte("version = \"4\"\n"), 0640))
// Point systemdUnitRoot at a path Reconcile must never touch: if
// --skip-units leaked a call through, a nonexistent root would still
// "work" for a dry run, so the real proof is that its output never
@@ -348,31 +327,7 @@ func TestRunAdminUpgrade_SkipUnitsSkipsUnitReporting(t *testing.T) {
})
assert.Equal(t, -1, code)
assert.NotContains(t, out, "Reading installed systemd unit versions", "--skip-units must skip unit reporting")
assert.Contains(t, out, "Pending config migrations:")
}
func TestRunAdminUpgrade_ApplyRunsConfigMigrationAndUnitReconcile(t *testing.T) {
configDir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(configDir, "spinifex.toml"), []byte("version = \"3\"\n"), 0640))
// Every unit already current: the real (non-dry-run) Reconcile call
// below finds nothing to change, so it never touches daemon-reload.
unitRoot := t.TempDir()
seedUnitRoot(t, unitRoot)
withUnitRoot(t, unitRoot)
cmd := newUpgradeTestCmd(t, configDir, t.TempDir())
setUpgradeFlags(t, cmd, map[string]string{"yes": "true"})
var code int
out := captureStdout(t, func() {
code = withUpgradeExitCapture(t, func() { runAdminUpgrade(cmd, nil) })
})
assert.Equal(t, -1, code, "the apply must succeed without exiting")
assert.Contains(t, out, "Done.")
migrated, err := os.ReadFile(filepath.Join(configDir, "spinifex.toml"))
require.NoError(t, err)
assert.Contains(t, string(migrated), `version = "4"`, "spinifex.toml must have been migrated to the latest version")
assert.Contains(t, out, "Reading current config versions")
}
func TestRunAdminUpgrade_ApplyWithNonWritableUnitRootExitsWithRootHint(t *testing.T) {
+2 -1
View File
@@ -12,6 +12,7 @@ import (
"github.com/mulgadc/spinifex/spinifex/daemon"
"github.com/mulgadc/spinifex/spinifex/network/external/dhcp"
"github.com/mulgadc/spinifex/spinifex/otelsetup"
"github.com/mulgadc/spinifex/spinifex/types"
"github.com/nats-io/nats.go"
"github.com/spf13/cobra"
@@ -333,7 +334,7 @@ func runNodeDrainLocal(cmd *cobra.Command, args []string) {
// `systemctl --failed`) without blocking the target's teardown;
// exiting 0 would hide guests left running as storage vanishes.
slog.Error("drain phase failed: no ACK from local daemon before timeout; guests may be left running while storage is about to be torn down",
"phase", phase, "node", node, "timeout", timeout, "error", err)
"phase", phase, "node", node, "timeout_ms", otelsetup.Millis(timeout), "error", err)
reportGuestsLeftRunning(true, fmt.Sprintf("[%s] drain failed: guests left running with storage about to be torn down", strings.ToUpper(phase)))
os.Exit(1)
}
+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)
}

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