Compare commits

...

50 Commits

Author SHA1 Message Date
Josh Siviour 24281cb860 fix: count volume lease validity from the write, not its reply (#1340)
* fix: count volume lease validity from the write, not its reply

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* refactor: register Resource Groups Tagging through the ingress seam

* test: characterize ELBv2 through the full gateway pipeline

* refactor: register ELBv2 through the ingress seam

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

* test: characterize RDS through the full gateway pipeline

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

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

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

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

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

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

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

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

* feat: set EnaSupport on images created from an instance

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

* docs: record RegisterImage EnaSupport support and the refused parameters

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

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

* refactor: remove host SQS store config provisioning

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

* refactor: remove host SQS store service command and role

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

* refactor: remove host SQS store provisioning from install scripts

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: enumerate complete SQS namespaces and backstop state buckets

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

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

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

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

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

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

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

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

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

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

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

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

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

* test: bar legacy NATS acknowledgement helpers from SQS source

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: read SQS message state from the stream leader

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

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

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

* fix: enumerate SQS state keys from the stream leader

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

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

* test: keep SQS API recorder deliveries in arrival order

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

* fix: read SQS body enumerations from the stream leader

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

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

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

* fix: recover a stalled SQS feed from its cursor

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

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

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

* fix: define SQS feed close in the store contract

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

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

* fix: track SQS feed consumer cleanup until Close succeeds

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

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

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

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

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

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

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

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

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

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

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

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

* feat: serve the OCI referrers API for ECR repositories

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

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

* fix: satisfy nilerr in the referrers manifest parser

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

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

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

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

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

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

* style: order the strconv import

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

* docs: regenerate the ELB coverage README

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: return UnsupportedOperation for microvm volume hot-attach

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

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

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

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

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

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

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

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

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

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

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

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

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

* feat: run the host MSS clamp loop in vpcd

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

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

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

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

The generator now fails when a served service has no embedded model, so
the next one cannot drop out of the report silently. Request encoding
skips rest-xml by protocol, which covers Route 53 as well as S3.
2026-10-28 08:12:55 +10:00
Julian Sommer 919d083bbf chore: name the Route 53 inventory in the aws-model-coverage layering exception
cmd/aws-model-coverage reads route53awsapi.OperationInventory() as it does
ECR's and ACM's, under the same recorded ADR-0001 rule 5 exception.
2026-10-28 08:12:55 +10:00
Julian Sommer 816b3168b7 docs: use --node-role in the EKS guide's create-nodegroup example (#1304) 2026-10-28 09:10:21 +11:00
Julian Sommer a33fbcc696 fix: serve the node's configured region in IMDS instead of deriving it from the zone name (#1303) 2026-10-28 09:09:23 +11:00
547 changed files with 26732 additions and 9355 deletions
+22
View File
@@ -223,5 +223,27 @@
"runner": "ci-single",
"extra": "nat-single",
"suite": ""
},
{
"cell": 37,
"install": "binary",
"topo": "real-multi",
"net": "dhcp",
"host": "oracle-10",
"arch": "x86_64",
"runner": "ci-multi",
"extra": "",
"suite": ""
},
{
"cell": 38,
"install": "binary",
"topo": "real-multi",
"net": "nat",
"host": "oracle-10",
"arch": "x86_64",
"runner": "ci-multi",
"extra": "nat-multi",
"suite": ""
}
]
+1 -1
View File
@@ -10,7 +10,7 @@ on:
workflow_dispatch:
inputs:
cells:
description: "Comma-separated cell numbers to run, no spaces (blank = all). 1-3, 8-10, 17-19 tofu permutation matrix, 34-36 Oracle Linux single node (basic, reboot, routed NAT), 20 baremetal, 21-22 ISO, 23 predastore throughput, 24 predastore fault injection, 25-27 service suites (eks, ecs, rds), 28 guest disk performance, 29 storage fault injection, 30 routed NAT on three nodes, 31 instance recovery on three nodes, 32 load balancer recovery on three nodes, 33 KV bucket quorum under node loss on three nodes"
description: "Comma-separated cell numbers to run, no spaces (blank = all). 1-3, 8-10, 17-19 tofu permutation matrix, 34-36 Oracle Linux single node (basic, reboot, routed NAT), 37-38 Oracle Linux on three nodes (multi-node subset with IPsec, routed NAT), 20 baremetal, 21-22 ISO, 23 predastore throughput, 24 predastore fault injection, 25-27 service suites (eks, ecs, rds), 28 guest disk performance, 29 storage fault injection, 30 routed NAT on three nodes, 31 instance recovery on three nodes, 32 load balancer recovery on three nodes, 33 KV bucket quorum under node loss on three nodes"
required: false
permissions:
+14
View File
@@ -536,6 +536,13 @@ jobs:
log-dir: ${{ env.ARTIFACT_DIR }}
title: "E2E failure analysis"
# The WAN capture is most of the artifact and only matters when a run
# failed. Anything short of a clean pass (suite rc, a failed setup or
# collect step, a missing rc) keeps it.
- name: Drop the packet capture from a passing single-node run
if: always() && steps.e2e.outputs.rc == '0' && job.status == 'success'
run: rm -f "${ARTIFACT_DIR}"/net-diag/wan.pcap
- uses: actions/upload-artifact@v7.0.2
if: always()
with:
@@ -990,6 +997,13 @@ jobs:
log-dir: ${{ env.ARTIFACT_DIR }}
title: "Multi-Node E2E failure analysis"
# The WAN captures are 90% of the artifact and only matter when a run
# failed. Anything short of a clean pass (suite rc, a failed setup or
# collect step, a missing rc) keeps them.
- name: Drop the packet captures from a passing multi-node run
if: always() && steps.e2e.outputs.rc == '0' && job.status == 'success'
run: rm -f "${ARTIFACT_DIR}"/net-diag-node*/wan.pcap
- uses: actions/upload-artifact@v7.0.2
if: always()
with:
-3
View File
@@ -119,9 +119,6 @@ jobs:
- name: Build Spinifex
run: make -C spinifex build
- name: Manifest check + drift lint
run: make -C spinifex manifest-check manifest-lint
- name: Run Tests with Coverage
env:
SPINIFEX_REQUIRE_CONFORMANCE_TOOLS: "1"
+6 -23
View File
@@ -158,15 +158,14 @@ install-microvm: $(MICROVM_ARTIFACTS) $(QBOOT_ARTIFACTS) ## Install microVM arti
sudo install -m 0644 $(MICROVM_OUT_DIR)/qboot.rom /usr/share/spinifex/microvm/qboot.rom
sudo install -m 0644 $(MICROVM_OUT_DIR)/qboot.LICENSE /usr/share/spinifex/microvm/qboot.LICENSE
# the pre-commit gate: manifest checks, lint, vuln, and the unit and e2e-harness tiers.
# the pre-commit gate: lint, vuln, and the unit and e2e-harness tiers.
# integration and race tests skipped to keep quick, they run in CI.
#
# -j2 overlaps the only two heavy gates, lint and test-cover: alone each leaves
# most of the machine idle, so pairing them cuts a cold run by about a quarter.
# -Otarget keeps each gate's output in one block instead of interleaving them.
# manifest-check manifest-lint test-package-check removed, can add back later
preflight:
@$(MAKE) --no-print-directory -j2 -Otarget QUIET=1 lint govulncheck test-cover diff-coverage test-harness test-build-scripts manifest-check manifest-lint
@$(MAKE) --no-print-directory -j2 -Otarget QUIET=1 lint govulncheck test-cover diff-coverage test-harness test-build-scripts
@echo -e "\n ✅ Preflight passed — safe to commit."
# Shell suites + shellcheck for build/scripts/, the systemd-unit helpers that
@@ -186,13 +185,14 @@ test-build-scripts:
echo "-- $$t"; \
sh "$$t" || exit 1; \
done
@echo -e "\n....Running scripts/*_test.sh and scripts/lib/*_test.sh...."
@for t in $$( (find scripts -maxdepth 1 -name '*_test.sh'; find scripts/lib -name '*_test.sh') | sort); do \
@echo -e "\n....Running scripts/*_test.sh, scripts/lib/*_test.sh and scripts/appliance/*_test.sh...."
@for t in $$( (find scripts -maxdepth 1 -name '*_test.sh'; find scripts/lib scripts/appliance -name '*_test.sh') | sort); do \
echo "-- $$t"; \
sh "$$t" || exit 1; \
done
@echo -e "\n....Running shellcheck over build/scripts/**/*.sh + the node scripts...."
shellcheck -S warning $$(find build/scripts -name '*.sh' | sort) $(NODE_SCRIPTS)
shellcheck -S warning -x scripts/build-oracle-appliance.sh $$(find scripts/appliance -name '*.sh' | sort)
@echo " test-build-scripts ok"
# E2E harness unit tests. Build-tagged `e2e` so they're skipped by the
@@ -239,23 +239,6 @@ test-segscan-oracle:
@echo -e "\n....Running segscan storage oracle test...."
$(_Q)LOG_IGNORE=1 go test -tags=integration,segscanoracle -timeout 120s ./tests/integration/... -run TestSegscanOracle $(_RACEQ)
# Validate docs/service-interfaces.yaml. Schema check + cross-reference
# of services/suites/fixtures + on-disk path existence.
manifest-check:
@echo -e "\n....Checking service-interfaces.yaml...."
@go run ./tests/e2e/manifest-check/cmd/manifest-check -repo-root . -manifest docs/service-interfaces.yaml
# Drift guards: direct-create fixture lint + NATS subject lint,
# ratcheted against tests/e2e/manifest-lint/baseline.txt. Fails only on NEW
# drift beyond the baseline.
manifest-lint:
@echo -e "\n....Linting manifest drift (fixtures + subjects)...."
@go run ./tests/e2e/manifest-lint/cmd/manifest-lint -repo-root .
# Accept current drift into the baseline. Run after an intentional change.
manifest-lint-update:
@go run ./tests/e2e/manifest-lint/cmd/manifest-lint -repo-root . -update
# Run unit tests
test:
@echo -e "\n....Running tests for $(GO_PROJECT_NAME)...."
@@ -486,7 +469,7 @@ distro-arm64:
distro-clean:
rm -rf dist/
.PHONY: test-package-check build build-ui build-installer build-lb-agent build-ecs-agent build-system-image build-eks-node-image import-eks-node-image publish-eks-node-image build-ecs-node-image import-ecs-node-image build-rds-postgres-image import-rds-postgres-image build-rds-mariadb-image import-rds-mariadb-image build-microvm-image install-microvm go_build preflight test test-cover test-race diff-coverage bench fuzz test-actions test-images test-build-scripts test-harness test-integration generate-aws-model-coverage aws-model-coverage test-segscan-oracle manifest-check manifest-lint manifest-lint-update \
.PHONY: test-package-check build build-ui build-installer build-lb-agent build-ecs-agent build-system-image build-eks-node-image import-eks-node-image publish-eks-node-image build-ecs-node-image import-ecs-node-image build-rds-postgres-image import-rds-postgres-image build-rds-mariadb-image import-rds-mariadb-image build-microvm-image install-microvm go_build preflight test test-cover test-race diff-coverage bench fuzz test-actions test-images test-build-scripts test-harness test-integration generate-aws-model-coverage aws-model-coverage test-segscan-oracle \
deploy reinstall clean \
install-system install-go install-aws quickinstall \
lint fix govulncheck nilaway \
+3 -1
View File
@@ -54,7 +54,7 @@ For Terraform, start from the [example workbooks](https://docs.mulgadc.com/docs/
## Services
The platform serves **392 AWS API operations** across the services below. See exactly which ones in the [coverage report](https://docs.mulgadc.com/coverage).
The platform serves **405 AWS API operations** across the services below. See exactly which ones in the [coverage report](https://docs.mulgadc.com/coverage).
| Service | What it is | Built on | Status |
| --- | --- | --- | --- |
@@ -68,8 +68,10 @@ The platform serves **392 AWS API operations** across the services below. See ex
| **ECR** | Container registry | Predastore, deduplicated per account | Available |
| **ECS** | Container service | Spinifex instances | Available |
| **RDS** | Databases | PostgreSQL and MariaDB, with automated backups | Available |
| **Route 53** | DNS | [Northstar](https://github.com/mulgadc/northstar): authoritative DNS with hosted zones | Available |
| **Bedrock** | AI deployment | vLLM for text generation and Hugging Face TEI for embeddings and reranking | Q4 2026 |
| **Lambda** | Serverless functions | | Q1 2027 |
| **SQS** | Message queues | | Q1 2027 |
Roadmap services ship under the same AWS API surface, so code written for AWS today works the moment they land. [Track what's shipped in the release notes](https://github.com/mulgadc/spinifex/releases).
+4 -3
View File
@@ -29,8 +29,10 @@ var allowlist = []allowance{
{From: "agents/rds/agent", To: "domains/rds/instance", Debt: "agents/rds/agent (dependency, narrowed)", Reason: "command protocol, wire and record types; needs a guest/controller wire contract"},
{From: "cmd/aws-model-coverage", To: "domains/acm/awsapi", Debt: "cmd/aws-model-coverage", Reason: "reads declared ECR/ACM inventories; named exception, must not construct a registration"},
{From: "cmd/aws-model-coverage", To: "domains/ecr/awsapi", Debt: "cmd/aws-model-coverage", Reason: "reads declared ECR/ACM inventories; named exception, must not construct a registration"},
{From: "cmd/aws-model-coverage", To: "domains/acm/awsapi", Debt: "cmd/aws-model-coverage", Reason: "reads declared service inventories; named exception, must not construct a registration"},
{From: "cmd/aws-model-coverage", To: "domains/ecr/awsapi", Debt: "cmd/aws-model-coverage", Reason: "reads declared service inventories; named exception, must not construct a registration"},
{From: "cmd/aws-model-coverage", To: "domains/route53/awsapi", Debt: "cmd/aws-model-coverage", Reason: "reads declared service inventories; named exception, must not construct a registration"},
{From: "cmd/aws-model-coverage", To: "domains/elbv2/awsapi", Debt: "cmd/aws-model-coverage", Reason: "reads declared service inventories; named exception, must not construct a registration"},
{From: "runtime/daemon", To: "domains/ec2/awsapi/instance", Debt: "Instance adapter consumers outside EC2", Reason: "runtime/daemon/eks_cp_control.go and rds_deps.go call the instance adapter; needs an EC2 capability"},
@@ -126,7 +128,6 @@ var allowlist = []allowance{
{From: "gateway", To: "domains/rds/awsapi", Debt: "gateway (service registration)", Reason: "IAM, STS, EKS and RDS dispatch stays in gateway until the ingress registration seam"},
{From: "gateway", To: "domains/sts/awsapi", Debt: "gateway (service registration)", Reason: "IAM, STS, EKS and RDS dispatch stays in gateway until the ingress registration seam"},
{From: "gateway", To: "domains/ecs/awsapi", Debt: "gateway (service registration)", Reason: "ECS and ELBv2 dispatch stays in gateway until the ingress registration seam"},
{From: "gateway", To: "domains/elbv2/awsapi", Debt: "gateway (service registration)", Reason: "ECS and ELBv2 dispatch stays in gateway until the ingress registration seam"},
{From: "domains/ecs", To: "domains/ec2/ebs/metadata", Debt: "close-out: handlers/ecs (ADR-0004 blocked-dependency list; inventory-ecs boundary imports)", Reason: "legacy ECS calls another domain's implementation as an internal API"},
{From: "domains/ecs", To: "domains/ec2/eip", Debt: "close-out: handlers/ecs (ADR-0004 blocked-dependency list; inventory-ecs boundary imports)", Reason: "legacy ECS calls another domain's implementation as an internal API"},
{From: "domains/ecs", To: "domains/ec2/instancetypes", Debt: "close-out: handlers/ecs (ADR-0004 blocked-dependency list; inventory-ecs boundary imports)", Reason: "legacy ECS calls another domain's implementation as an internal API"},
-1
View File
@@ -11,7 +11,6 @@ import (
// completed move whose line must be deleted.
var frozenLegacyPackages = []string{
"gateway",
"gateway/spx",
}
// retiredLegacyRoots were legacy roots whose every package has moved to its
-57
View File
@@ -1,57 +0,0 @@
# spinifex-unit-version: 1
[Unit]
Description=Spinifex SQS Message Store (dedicated JetStream)
PartOf=spinifex.target
ConditionPathExists=/etc/spinifex/sqs-store/sqs-store.conf
[Service]
EnvironmentFile=-/etc/spinifex/telemetry.env
Type=simple
Slice=spinifex-system.slice
User=spinifex-sqsstore
Group=spinifex
ExecStart=/usr/local/bin/spx service sqs-store start
Restart=on-failure
RestartSec=5
# JetStream store shutdown and raft stepdown have no internal deadline.
TimeoutStopSec=20
OOMScoreAdjust=-500
# Soft Go heap limit for the store; MemoryHigh/Max bound a runaway on top.
Environment=GOMEMLIMIT=512MiB
MemoryHigh=10%
MemoryMax=20%
Environment=SPINIFEX_CONFIG_PATH=/etc/spinifex/sqs-store/sqs-store.conf
# Filesystem access
NoNewPrivileges=yes
ProtectSystem=strict
PrivateDevices=yes
ReadOnlyPaths=/etc/spinifex/sqs-store /etc/spinifex/server.pem /etc/spinifex/server.key /etc/spinifex/ca.pem
ReadWritePaths=/var/lib/spinifex/sqs-store /var/log/spinifex
# Hardening
ProtectHome=yes
PrivateTmp=yes
RestrictSUIDSGID=yes
LockPersonality=yes
RestrictRealtime=yes
RestrictNamespaces=yes
ProtectClock=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectProc=invisible
SystemCallArchitectures=native
MemoryDenyWriteExecute=yes
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
CapabilityBoundingSet=
ProtectHostname=yes
KeyringMode=private
RemoveIPC=yes
LimitCORE=0
LimitNOFILE=65536
LimitNPROC=4096
[Install]
WantedBy=spinifex.target
+2 -2
View File
@@ -1,9 +1,9 @@
# spinifex-unit-version: 3
# spinifex-unit-version: 4
[Unit]
Description=Spinifex Infrastructure Platform
After=network-online.target
Wants=network-online.target
Wants=spinifex-nats.service spinifex-sqs-store.service spinifex-predastore.service spinifex-northstar.service spinifex-viperblock.service spinifex-daemon.service spinifex-awsgw.service spinifex-vpcd.service spinifex-ui.service spinifex-qmp-collector.service spinifex-shutdown.service spinifex-nats-watchdog.timer ovsdb-server.service ovs-vswitchd.service ovn-controller.service
Wants=spinifex-nats.service spinifex-predastore.service spinifex-northstar.service spinifex-viperblock.service spinifex-daemon.service spinifex-awsgw.service spinifex-vpcd.service spinifex-ui.service spinifex-qmp-collector.service spinifex-shutdown.service spinifex-nats-watchdog.timer ovsdb-server.service ovs-vswitchd.service ovn-controller.service
[Install]
WantedBy=multi-user.target
+14 -2
View File
@@ -4,11 +4,14 @@ import (
"flag"
"fmt"
"os"
"slices"
"github.com/mulgadc/predastore/s3api"
"github.com/mulgadc/spinifex/internal/awsmodel"
acmawsapi "github.com/mulgadc/spinifex/spinifex/domains/acm/awsapi"
awsapi "github.com/mulgadc/spinifex/spinifex/domains/ecr/awsapi"
elbv2awsapi "github.com/mulgadc/spinifex/spinifex/domains/elbv2/awsapi"
route53awsapi "github.com/mulgadc/spinifex/spinifex/domains/route53/awsapi"
"github.com/mulgadc/spinifex/spinifex/gateway"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/dispatch"
@@ -49,9 +52,18 @@ func compareAll() ([]awsmodel.OperationCoverage, error) {
// Registered services contribute their declared inventory, which needs no
// live dependencies; nothing here serves a request.
inventories := gateway.AWSOperationInventory(map[string]dispatch.Inventory{
awsapi.ServiceName: awsapi.OperationInventory(),
acmawsapi.ServiceName: acmawsapi.OperationInventory(),
awsapi.ServiceName: awsapi.OperationInventory(),
acmawsapi.ServiceName: acmawsapi.OperationInventory(),
route53awsapi.ServiceName: route53awsapi.OperationInventory(),
elbv2awsapi.InventoryName: elbv2awsapi.OperationInventory(),
})
// The loop below walks the embedded models, so a served service with no
// model would otherwise drop out of the report without a word.
for name := range inventories {
if !slices.Contains(awsmodel.Services(), awsmodel.Service(name)) {
return nil, fmt.Errorf("no embedded AWS model for served service %s", name)
}
}
coverages := make([]awsmodel.OperationCoverage, 0, len(awsmodel.Services()))
for _, service := range awsmodel.Services() {
inventory, ok := inventories[string(service)]
+1
View File
@@ -29,6 +29,7 @@ func loadConfig() (tokenwebhook.Config, error) {
CertPath: envOr("EKS_WEBHOOK_CERT", "/etc/spinifex-eks/token-webhook.crt"),
KeyPath: envOr("EKS_WEBHOOK_KEY", "/etc/spinifex-eks/token-webhook.key"),
Kubeconfig: envOr("EKS_WEBHOOK_KUBECONFIG", "/etc/spinifex-eks/token-webhook.kubeconfig"),
ReadyAddr: os.Getenv("EKS_WEBHOOK_READY_ADDR"),
}
// Static SigV4 creds are optional: when absent, eksgw.New signs with the
// AWS SDK chain (IMDS instance-role creds), the same path the CP VM's
+5
View File
@@ -8,3 +8,8 @@ administration. It is separate from AWS tenant-facing service contracts.
roles and Reed-Solomon configuration. It is an operational status contract,
not a generic provider abstraction; do not change its subject or JSON shape
incompatibly. Add a new version when that is required.
`admin.go` holds the `/admin/<Method>` request and response bodies the private admin surface shares with the CLI.
`AdminMethodNames` is the list of callable methods, and the gateway router builds its accept set from it.
`AccountQuotaRequest`, `AccountQuotaResponse` and `DeleteAccountResponse` stay in the gateway until the `/admin` handlers move, because they embed types the contracts layer may not import.
Do not change a field name, JSON tag or omitempty incompatibly; add a new version when that is required.
+76
View File
@@ -0,0 +1,76 @@
package operatorv1
import "slices"
// adminMethodNames is the callable /admin/<Method> list. The gateway router
// builds its accept set from it, so a name cannot be served without being listed.
var adminMethodNames = []string{
"CreateAccount",
"DeleteAccount",
"DescribeAccountDeletion",
"GetAccountQuota",
"ListAccounts",
"PutAccountQuota",
}
// AdminMethodNames returns the callable /admin/<Method> names in sorted order.
// Tooling that mints a credential grants these by name, so the list has to be
// the one the router serves rather than a copy that can drift out of step.
func AdminMethodNames() []string {
return slices.Sorted(slices.Values(adminMethodNames))
}
// Deletion job states.
const (
DeletionStateRunning = "RUNNING"
DeletionStateCompleted = "COMPLETED"
DeletionStateFailed = "FAILED"
)
// CreateAccountRequest is the /admin/CreateAccount request body.
type CreateAccountRequest struct {
Name string `json:"name"`
ClientToken string `json:"clientToken"`
Source string `json:"source,omitempty"`
}
// CreateAccountResponse is the /admin/CreateAccount success body. It is stored
// verbatim (with the secret encrypted) so a replay returns an identical answer.
type CreateAccountResponse struct {
AccountID string `json:"accountId"`
AccountName string `json:"accountName"`
AdminUser string `json:"adminUser"`
AccessKeyID string `json:"accessKeyId"`
SecretAccessKey string `json:"secretAccessKey"`
DefaultVpcID string `json:"defaultVpcId"`
}
// DeleteAccountRequest is the /admin/DeleteAccount request body.
type DeleteAccountRequest struct {
AccountID string `json:"accountId"`
AccountName string `json:"accountName"`
ClientToken string `json:"clientToken"`
Force bool `json:"force,omitempty"`
DryRun bool `json:"dryRun,omitempty"`
}
// DescribeAccountDeletionRequest is the /admin/DescribeAccountDeletion body.
type DescribeAccountDeletionRequest struct {
AccountID string `json:"accountId"`
}
// AccountSummary is one row of /admin/ListAccounts. It carries no credential
// material: the caller can already create and delete accounts, but that is no
// reason for a listing to hand out keys.
type AccountSummary struct {
AccountID string `json:"accountId"`
AccountName string `json:"accountName"`
Status string `json:"status"`
CreatedAt string `json:"createdAt,omitempty"`
}
// ListAccountsResponse is the /admin/ListAccounts success body.
type ListAccountsResponse struct {
Accounts []AccountSummary `json:"accounts"`
Count int `json:"count"`
}
+65
View File
@@ -0,0 +1,65 @@
package operatorv1
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/require"
)
func goldenJSON(t *testing.T, v any, want string) {
t.Helper()
data, err := json.Marshal(v)
require.NoError(t, err)
require.JSONEq(t, want, string(data))
}
func TestAdminWire_CreateAccountRequest(t *testing.T) {
goldenJSON(t, CreateAccountRequest{Name: "acme", ClientToken: "tok-1", Source: "cli"},
`{"name":"acme","clientToken":"tok-1","source":"cli"}`)
goldenJSON(t, CreateAccountRequest{Name: "acme", ClientToken: "tok-1"},
`{"name":"acme","clientToken":"tok-1"}`)
}
func TestAdminWire_CreateAccountResponse(t *testing.T) {
goldenJSON(t, CreateAccountResponse{
AccountID: "000000000042", AccountName: "acme", AdminUser: "admin",
AccessKeyID: "AKIAEXAMPLE", SecretAccessKey: "secret", DefaultVpcID: "vpc-1",
}, `{"accountId":"000000000042","accountName":"acme","adminUser":"admin","accessKeyId":"AKIAEXAMPLE","secretAccessKey":"secret","defaultVpcId":"vpc-1"}`)
}
func TestAdminWire_DeleteAccountRequest(t *testing.T) {
goldenJSON(t, DeleteAccountRequest{AccountID: "000000000042", AccountName: "acme", ClientToken: "tok-2", Force: true, DryRun: true},
`{"accountId":"000000000042","accountName":"acme","clientToken":"tok-2","force":true,"dryRun":true}`)
goldenJSON(t, DeleteAccountRequest{AccountID: "000000000042", AccountName: "acme", ClientToken: "tok-2"},
`{"accountId":"000000000042","accountName":"acme","clientToken":"tok-2"}`)
}
func TestAdminWire_DescribeAccountDeletionRequest(t *testing.T) {
goldenJSON(t, DescribeAccountDeletionRequest{AccountID: "000000000042"}, `{"accountId":"000000000042"}`)
}
func TestAdminWire_DeletionStates(t *testing.T) {
require.Equal(t, "RUNNING", DeletionStateRunning)
require.Equal(t, "COMPLETED", DeletionStateCompleted)
require.Equal(t, "FAILED", DeletionStateFailed)
}
func TestAdminWire_ListAccountsResponse(t *testing.T) {
goldenJSON(t, ListAccountsResponse{
Accounts: []AccountSummary{
{AccountID: "000000000042", AccountName: "acme", Status: "ACTIVE", CreatedAt: "2026-01-02T03:04:05Z"},
{AccountID: "000000000043", AccountName: "bolt", Status: "TERMINATING"},
},
Count: 2,
}, `{"accounts":[
{"accountId":"000000000042","accountName":"acme","status":"ACTIVE","createdAt":"2026-01-02T03:04:05Z"},
{"accountId":"000000000043","accountName":"bolt","status":"TERMINATING"}],"count":2}`)
}
func TestAdminWire_AdminMethodNames(t *testing.T) {
require.Equal(t, []string{
"CreateAccount", "DeleteAccount", "DescribeAccountDeletion",
"GetAccountQuota", "ListAccounts", "PutAccountQuota",
}, AdminMethodNames())
}
+53
View File
@@ -229,6 +229,36 @@ type EBSRequest struct {
Viperblock responds with an NBD URI that QEMU uses to access the block device.
#### Direct-boot microVM data volumes
A direct-boot microVM keeps the launcher's saved `vm.Config` as a clean base, and `startQEMU` adds data volumes only to a launch-only copy of it (`runtime/compute/vm/lifecycle.go`).
At each start the daemon takes one snapshot of the instance's already-mounted data requests, probes each NBD or host block export from that snapshot, and builds the command from the same snapshot, so the launched endpoint is the one that was checked and is never written back into the saved base.
Each data volume becomes a `virtio-blk-device` on the MMIO bus with the same id, node name, I/O thread, serial and `werror=stop,rerror=stop` policy as the PCI device, and takes no PCIe hot-plug port.
Boot and EFI requests are rejected on this path, and a direct-boot instance with no data requests launches its base unchanged.
Disk-boot (q35) instances keep the existing PCI hot-plug path.
Hot-attaching a volume to a running direct-boot microVM is not supported, because the MMIO bus has no PCIe root port to plug into.
`AttachVolume` refuses it before mounting the volume or changing any instance or volume state, and the EC2 API returns `UnsupportedOperation`.
This covers only requests that are already mounted when the instance starts; nothing yet supplies data volumes to a system instance, and the path is verified with fake-QEMU unit tests, not with a real EBS volume in a guest.
#### Volume leases
Before opening an engine on a volume, the Viperblock worker takes a lease: one entry per volume in the `VIPERBLOCK_VOLUME_LEASES` JetStream KV bucket, which expires 45 seconds after its last write (`providers/ebs/viperblock/volume_lease.go`).
A renewal becomes eligible once the last confirmed write is 10 seconds old, and the holder checks for that periodically, so the rewrite lands some time after that point.
Each rewrite is conditioned on the revision the holder last wrote, and a refused write that finds the entry gone or naming another owner or generation fences the export as a takeover.
It may keep writing for 40 seconds after the last write the store acknowledged, measured from when that write was sent, because the server starts the entry's expiry when it applies the write and never earlier.
A slow acknowledgement therefore shortens the remaining validity rather than extending it, and an acquisition acknowledged after its validity has already run out is refused as retryable.
For a holder that cannot renew, a timer is armed for the validity deadline, leaving a nominal 5 seconds before the entry can expire; when it fires, the export's nbdkit is fenced with SIGKILL (`volume_fence.go`).
The fence runs on its own goroutine, so Go scheduling, that goroutine and process termination all delay the kill, and enough delay can use up the whole margin; a kill held in uninterruptible sleep, which the fence waits up to 30 seconds for, is the extreme case.
Once a lease is lost or has lapsed it stays lost: a renewal that succeeds afterwards is ignored, and another open on the same node cannot share it while the fence runs.
These rules bound only the holder's own clock.
The storage backend does not reject a stale writer, so a paused or partitioned host that resumes after its validity can still write; generation enforcement at the write boundary is required by Q-24 in the umbrella repository's `docs/specs/viperblock/rfc3-1_overview.md` and is not implemented.
An export that survives a daemon restart writes without a renewed lease until recovery adopts it, and a survivor that recovery cannot adopt while a guest still uses it is left running without a lease or a fence.
### Predastore (S3)
Object storage used for:
@@ -256,6 +286,29 @@ file. It does not connect to Predastore to do so, and no Predastore node
exposes a status endpoint. This is an operator status interface, not a generic
storage-provider contract.
### SQS message store (internal)
`domains/sqs/message` defines the store-neutral interface the SQS domain reaches its message store through (ADR-0011:S6), with a JetStream adapter in `domains/sqs/message/jetstream` and a shared conformance suite in `domains/sqs/message/conformance`.
It is an internal primitive: nothing outside the SQS domain uses it, no daemon or service is wired to it, and it does not itself deliver an SQS API.
The dedicated JetStream server ADR-0011 describes is not deployed by this code, and Spinifex ships no host service, unit, user or firewall rule for it.
The interface covers durable and idempotent append, body and metadata reads, conditional state writes and deletes, per-namespace counts, namespace enumeration, an ordered change feed, a bounded paginated scan, namespace deletion and a sweep of tombstoned bodies.
A namespace carries an immutable message-shard and state-shard allocation chosen when the queue is created, so a later change in shard counts never moves an existing queue.
The adapter provisions a fixed set of shared message streams (`SQS_M_<shard>`, one subject per queue) and shared state buckets (`sqs_state_<shard>`), keeps historical ones, and applies a 14-day-plus-one-hour retention backstop to both.
It supports one node (R1) and three nodes (R3) only and refuses any other declared cluster size before provisioning.
Reads that must observe a completed write or delete go to the stream leader: `GetState`, the state-key pass behind `Count` and `Namespaces`, and the body pass behind `Namespaces` and `Scan`.
This avoids the follower-served direct gets and follower-placed consumers that returned purged data in local three-node fault-injection tests; it is not a general linearizability guarantee.
Each leader read is one request per record, and the body pass transfers each full body internally even though `Scan` returns metadata only, which is a material cost for a large backlog.
A state-key pass is bounded but is not a snapshot when a key is overwritten during it.
The feed reads through an in-memory, headers-only ordered consumer.
If that consumer delivers nothing for about two seconds while the stream holds a change after the feed's cursor, the feed replaces it from the cursor, so a stored change does not wait on a stalled consumer; the cause of those stalls is not established.
`Close` is terminal, waits for consumer opens and deletes in progress and retries failed deletes, but cleanup is best effort for a create whose outcome is unknown: a consumer that lands after its last delete is removed by the server's one-minute inactive threshold.
Coverage is unit and conformance tests on in-process R1 and R3 servers; there is no end-to-end or multi-host validation of this code, and earlier multi-host evidence predates the final feed.
## Configuration
Cluster configuration (`spinifex/bootstrap/config/config.go`):
+11 -5
View File
@@ -351,8 +351,14 @@ while the branch is in flight; they do not describe the umbrella repository's
| `spinifex#1269` | `spinifex/handlers/elbv2` | `spinifex/domains/elbv2` (package `elbv2`) | A mechanical whole-package relocation under the 2026-10-26 ADR-0001 amendment: the ELBv2 control plane keeps its files, tests and current dependencies, and importers alias it `elbv2domain`. No identifier, API, NATS subject, KV bucket, metric, error string or behaviour changes. Its allowlist entries are now sourced from `domains/elbv2`. Q-30's data-plane boundary stays deferred, recorded as `agents/elbv2` (Q-30 data-plane boundary) debt. |
| `spinifex#1271` | `spinifex/handlers/ecs` | `spinifex/domains/ecs` (package `ecs`) | A mechanical whole-package relocation: ADR-0001's `handlers/ecs` row targets `domains/ecs`, the files are already organised by resource, and resource packages are introduced only where callers need a compiler boundary. The ECS control plane keeps its files, tests and current dependencies alongside the already-extracted `domains/ecs/taskdefinition`, and importers alias it `ecsdomain`. No identifier, API, NATS subject, KV bucket, metric, error string or behaviour changes. Its allowlist entries are now sourced from `domains/ecs`. `handlers` has no package left and is a retired legacy root; R5 holds `domains/ecs` to the interim rule's ban on importing the daemon. |
| `spinifex#1273` | `spinifex/gateway/{ecs,elbv2}` | `spinifex/domains/{ecs,elbv2}/awsapi` (`awsapi`, imported as `ecsawsapi`, `elbv2awsapi`) | A mechanical relocation of the ECS and ELBv2 AWS action adapters to their ADR-0001 domain homes, as `spinifex#1265` did for IAM, STS, EKS and RDS; APIs, NATS subjects, KV buckets and behaviour are unchanged. The root `gateway` dispatch still imports each package, recorded as `gateway` (service registration) debt, and `domains/admission/quota` keeps its live ELBv2 counter import, now of `domains/elbv2/awsapi`. |
| `spinifex#1336` | `spinifex/gateway/tagging.go` (`Tagging_Request`, `taggingActions`, `taggingActionFromTarget`) and the `GatewayConfig.TaggingResources` field | `spinifex/domains/tagging/awsapi` (`NewRegistration`, `Deps`, `Actions`, `OperationInventory`), composed by `spinifex/runtime/roles/awsgw` | Resource Groups Tagging registers through the ADR-0002 seam like ACM and Route 53. The check order is kept: action, scope, authorization, account, body, aggregator, so a denied caller still gets `AccessDenied` before any body error and an unwired aggregator answers `ServerInternal`. The `checkPolicyResources` stand-in of `*` for every action is unchanged. The legacy switch case, the `supportedServices` entry and the `jsonErrorService` case are removed, and the registration declares the JSON 1.1 envelope. No allowlist row existed for this path. The nil-NATS check moves to the gateway-wide gate, which answers `ServiceUnavailableException` before dispatch as it did for every service. `cmd/aws-model-coverage` output is byte-identical because there is no embedded Tagging model. The characterization tests that pin the pipeline behaviour were committed first and pass unchanged against the registration; only the `withTagging` test helper changed. |
| `spinifex#1336` | `spinifex/gateway/elbv2.go` (`ELBv2_Request`, `elbv2Actions`, `elbv2Handler`, `accountLoadBalancerLimit`) | `spinifex/domains/elbv2/awsapi` (`NewRegistration`, `Deps`, `Quota`, `ActionNames`, `OperationInventory`), `spinifex/domains/admission/quota` (`Service.LoadBalancerLimit`), `spinifex/ingress/aws/query` (`ParseArgs`), composed by `spinifex/runtime/roles/awsgw` | ELBv2 registers through the ADR-0002 seam as the first AWS Query service. The check order is kept: query parse, action, account, input parse, scope, authorization, NATS, dispatch, so a denied caller still gets `AccessDenied` before the handler runs. The gateway members ELBv2 read are now supplied as `Deps`: `NATSConn` as `Deps.NATS`, `Quota` as the `Deps.Quota` interface declared in `awsapi` because `domains/admission/quota` imports `awsapi`, `Region` as `Invocation.Region`, and the policy gate as `Invocation.Authorize`. The `DescribeAccountLimits` cap resolution moves to `Service.LoadBalancerLimit` unchanged, and a nil `Deps.Quota` enforces nothing and reports the AWS default. AWSGW now wires the registry after it builds the quota service, because the registration captures it. The registration declares the XML envelope and the inventory name `elasticloadbalancingv2`, so `cmd/aws-model-coverage` keys it explicitly and its output is byte-identical. `gateway.ParseAWSQueryArgs` delegates to `query.ParseArgs`. The legacy switch case, the `supportedServices` entry, the inventory entry and the `gateway -> domains/elbv2/awsapi` allowlist row are removed. `cmd/aws-model-coverage` imports `domains/elbv2/awsapi` for the inventory, under a fourth named-exception allowlist row whose reason is now generic for all four. Finding, not fixed here: the XML for `DescribeAccountLimits` and `DescribeLoadBalancers` lists member fields in a different order from run to run because the marshal is map-ordered, so the characterization tests pin decoded content rather than bytes. The characterization tests that pin the pipeline behaviour were committed first and pass unchanged against the registration; only the `withELBv2` and `serveELBv2` test helpers and the setup of two older gateway tests changed. |
| `spinifex#1337` | `spinifex/gateway/spinifex.go` (`Spinifex_Request`, `spinifexAdminActions`) | `spinifex/operator/admin` (`NewRegistration`, `Deps`, `ModelAccess`, `ActionNames`), composed by `spinifex/runtime/roles/awsgw` | The `spinifex` Query service registers through the ADR-0002 seam with the XML error envelope it had. The check order is kept: query parse, action, authorization with the wildcard resource, account, the super-admin account gate, then the action, so a denied caller learns nothing about which actions exist. The gateway members the actions read are now `Deps`: `Version` and `Commit` strings, `NATSConn` as `Deps.NATS`, `RootCAs`, `DiscoverActiveNodes` as `Deps.ActiveNodes`, `BedrockAccessAdmin` as the `ModelAccess` interface, and the credential and access resolvers as `Deps.Credentials` and `Deps.Access`. The query actions need only the caller account, so the actor is not widened. The cluster-unavailable answer stays in the shared request path ahead of authorization. `/admin/{method}` stays on its chi route in `gateway/admin*.go` until the actor carries `UserName`. |
| `#1277` | `spinifex/accountteardown` | `spinifex/operator/accountlifecycle` (package `accountlifecycle`, imported as `accountteardown`) | A mechanical whole-package relocation under the 2026-10-27 ADR-0001 amendment: the account deletion coordinator keeps its APIs, NATS subjects, KV access and behaviour; importers keep the `accountteardown` name as an alias, so only their import paths change. Replacing its direct domain and adapter cleanup calls with owner capabilities stays deferred debt. |
| `#1285` | `spinifex/gateway/requestaudit.go` and `slogRequestLogger` from `spinifex/gateway/gateway.go` | `spinifex/ingress/aws/audit` (package `audit`) | The first generic-ingress extraction from the root gateway: the request-audit middleware and access-log middleware move unchanged in behaviour. The gateway's authentication flow and dispatch still populate the record, now through the exported `audit.From` and `Record` setters at the same call sites; the package imports only `foundation/telemetry` and `ingress/http`. Its unit tests moved with it; the tests that run it behind SigV4 stay in `gateway`. |
| `spinifex#1334` | `spinifex/gateway/spx` (`spx.go`, `storage.go`, `promote_image.go` and their tests) | `spinifex/operator/admin` (package `admin`) | The version, node, VM and storage-status gather helpers and the image-promote NATS request move unchanged with no shim; the `spinifex` service dispatch that calls them stays in `gateway/spinifex.go` until the `spinifex` registration slice. `operator/admin` already imports `domains/ec2/image` and `contracts/operator/v1`, so the move adds no edge and the layering ratchet needed no allowlist row. The `gateway/spx` frozen-legacy entry is removed. |
| `spinifex#1334` | `spinifex/gateway` (`CreateAccountRequest`/`Response`, `DeleteAccountRequest`, `DescribeAccountDeletionRequest`, `DeletionState*`, `AccountSummary`, `ListAccountsResponse`, `AdminMethodNames`) | `contracts/operator/v1` (`admin.go`) | The `/admin` bodies the CLI shares with the gateway move unchanged in field names, JSON tags and omitempty, pinned by golden JSON written against the gateway types first and moved with them. `AdminMethodNames` is now owned by the contract and the gateway builds its `adminMethods` accept set from it, with a test that the router accepts exactly that list. `AccountQuotaRequest`/`AccountQuotaResponse` (embed `quota.Overrides` from `domains/admission/quota`) and `DeleteAccountResponse` (embeds `accountlifecycle.Result`) stay in `gateway`: `contracts` may import only `foundation` and `contracts`, so they are settled when the `/admin` handlers move to `operator/admin`. `accountlifecycle.Stage` serialises as an integer, not its name; the golden test pins that. |
| `spinifex#1334` | `spinifex/gateway/oidc_discovery.go` (`eksAccountKV`, the discovery-document and JWKS read and construction) | `spinifex/domains/eks/cluster` (`OIDCPublisher`, `OIDCDiscoveryDocument`), composed by `spinifex/runtime/roles/awsgw` | The EKS domain now reads its own account bucket and builds the per-cluster discovery document and JWKS responses (ADR-0005:S4), and the gateway only maps the unauthenticated `/oidc/eks/...` routes onto an `OIDCPublisher` interface it is handed. Responses are byte-identical, pinned by `oidc_discovery_current_behaviour_test.go`, whose assertions are unchanged. The gateway no longer imports `domains/eks/cluster` in production code. `STS` keeps its own `ClusterJWKSResolver` (`OIDCJWKSReader`), untouched. Recorded debt, not repaired here: a stored JWKS is served verbatim with 200 even when it is not valid JSON. |
The EC2 contract row is intentionally different from the directory moves: it creates
a compatibility boundary. `ec2.cmd.*` retains its deployed one-token NATS
subscription and permission shape, while callers use
@@ -438,7 +444,7 @@ These residuals were recorded by individual slices and must be resolved once the
- `runtime/roles/vpcd` (placement): the role still mixes process/role supervision with network-controller responsibilities. `lease_reaper.go` (orphaned external-pool DHCP lease sweep), `ovn_controller_watchdog.go` (OVN controller liveness and restart), `host_eip_loop.go` (host-EIP reconcile loop) and `lease_datapath_reconcile.go` (lease datapath reconcile) are periodic network-control loops, not role wiring; `vpcd.go` itself also carries bridge-mode detection and verification, chassis-list parsing, external-CIDR resolution, DHCP manager startup and lease/IP-change handling, and gateway/host-EIP allocator and neighbour flush/prime construction alongside `Service.Start`. The future placement question is whether these belong in `domains/network`. Removal condition: an accepted decision on network-controller placement, and extraction of those loops and helpers behind the role.
- ECR control-plane registration: `awsapi.Deps.NATS` is a raw `*nats.Conn`, not a narrow capability. Every NATS-backed `Handler` passes it to `domains/ecr.NewNATSMetaStore`, which calls `natsmsg.NATSRequest`, so narrowing it means changing the exported `Handler` type, every handler and the meta store; it must not be broadened further meanwhile.
- ACM control-plane registration: `domains/acm/awsapi.Deps.NATS` (`registration.go`) is likewise a raw `*nats.Conn` carried by every `Handler`, for the same reason; it must not be broadened further meanwhile.
- `cmd/aws-model-coverage` imports `ingress/aws/dispatch`, `domains/ecr/awsapi`, `domains/acm/awsapi` and `gateway` to read ECR's and ACM's declared inventories. It is a non-serving tool and the only named exception to the ADR-0002 composition invariant; it must not construct a registration.
- `cmd/aws-model-coverage` imports `ingress/aws/dispatch`, `gateway` and the `awsapi` package of each registered service (`domains/ecr/awsapi`, `domains/acm/awsapi`, `domains/route53/awsapi`, `domains/elbv2/awsapi`) to read their declared inventories. It is a non-serving tool and the only named exception to the ADR-0002 composition invariant; it must not construct a registration.
- Ingress pipeline (ADR-0002 phase 4): the request-audit record, the trace action enricher, the throttle key functions and the gateway request-context keys stay in `gateway` until the auth/IAM and request-context boundary is designed. SigV4 authentication writes the audit record and context keys from four places, so moving them now would export exactly the setters ADR-0002 says must not be exported solely to make a directory move possible.
- NAT projection delivery is inconsistent and is preserved as found: EIP `AssociateAddress` sends `vpc.add-nat` unacknowledged (`AnnounceNATAdd`), skipping the 45s OVN barrier every other add path waits on, and `DeleteNetworkInterface` sends `vpc.delete-nat` unacknowledged (`AnnounceNATDelete`), skipping the ack and the ErrNoResponders retry. The reconciler's orphan sweep is the only backstop for both. Clears when those call sites use `AddNAT`/`AddNATBestEffort` and `RemoveNAT`, a reliability change of its own.
- `vpc.add-system-egress` has a `vpcd` subscriber and a contract entry but no producer (its former `domains/ec2/eip` publisher is gone); `domains/eks/cluster` still publishes the delete. Either a producer is missing or the add route is dead; it needs an owner decision.
@@ -459,7 +465,7 @@ These residuals were recorded by individual slices and must be resolved once the
- `operator/cli` (dependency): the command tree still imports domain and service implementations directly (`handlers/{iam,eks}`, `domains/admission/quota`, `gateway`, `domains/ochre`, `vpcd`, the Viperblock adapter and runtime roles). The ADR's `cmd/spinifex` current-to-target map row requires command packages to reach domains through public capabilities; splitting supported use cases into `operator/*` packages is later work.
- `providers/ebs/viperblock` → `runtime/compute/nbd` and `runtime/host/process` (dependency, moved unchanged from `services/viperblockd`): the adapter formats NBD endpoints and manages nbdkit processes through runtime packages. ADR-0001 does not list providers → runtime as forbidden and the layering test does not enforce it (same category as `providers/ebs/conformance` → `runtime/compute/nbd`), but a provider should not depend on node runtime. Clears when NBD endpoint naming has a provider-facing home (a candidate for `contracts/ebs/v1`) and process supervision is injected by the role. The adapter also imports `providers/objectstore`, a provider-to-provider edge that is allowed.
- `gateway/ec2.go` (registration): the EC2 dispatch map stays a `GatewayConfig` method in `gateway` and imports every `domains/ec2/awsapi/*` package; `gateway/ec2_idempotency.go` also imports `domains/ec2/awsapi/idem` (`KVBucket`, `TTL`, `TokenAndParams`) for the dispatch-time idempotency wrapper. ADR-0001 makes the domain own its action registration; moving the map waits on the generic ingress registration seam and must not be done as a package move.
- `gateway` (service registration): the root `gateway` dispatch for IAM, STS, EKS, RDS, ECS and ELBv2 imports `domains/{iam,sts,eks,rds,ecs,elbv2}/awsapi`, which only AWSGW may wire. It clears when each service registers through the ADR-0002 ingress registration seam; as with EC2, moving the dispatch must not be done as a package move.
- `gateway` (service registration): the root `gateway` dispatch for IAM, STS, EKS, RDS and ECS imports `domains/{iam,sts,eks,rds,ecs}/awsapi`, which only AWSGW may wire. It clears when each service registers through the ADR-0002 ingress registration seam; as with EC2, moving the dispatch must not be done as a package move.
- `operator/accountlifecycle` (dependency): its EC2 reapers call `domains/ec2/awsapi/*` action adapters directly, as they did under `gateway/ec2`. The amended ADR-0001 `accountteardown` row keeps replacing these with domain-owned cleanup capabilities as deferred debt; it was not a relocation prerequisite. It also holds a read-only consumer of the EC2 platform-inventory contract (`contracts/ec2/v1`, `ec2.v1.PlatformInventory`), served by `domains/ec2/accountinventory`; only the explicit dry run (`Engine.DryRun`) calls it, to log a shadow comparison, and the key-pair, placement-group and launch-template reapers still use the `awsapi` adapters. A domain-owned cleanup capability for the proven-safe subset of those three kinds (key pairs and empty placement groups no instance, spot request or live launch-template version names, and launch-template residue; never live launch templates) exists as `domains/ec2/accountcleanup` (a Go interface with injected owners and reference readers, no NATS subject); nothing calls it yet. It also holds the StageAccount settings-seal reaper, a consumer of `contracts/ec2/v1`'s `ec2.v1.SealAccountSettings` and per-node `ec2.v1.AccountSettingsSealCapability.<node>` subjects, served by `domains/ec2/account` through the daemon; the reaper reads no KV and imports no domain package, reaching both subjects only through NATS and the configured node set `NewClusterEngine` already receives.
- `domains/admission/quota` (dependency, moved from `handlers/quota` unchanged): `live.go` imports `domains/elbv2/awsapi` and `domains/rds/instance` for its live ELBv2 and RDS counters; `records.go` imports `runtime/compute/vm` for the instance-record vCPU sweep; `live.go`, `vcpu.go` and `storage.go` call `domains/ec2/awsapi/{vpc,eip,volume}` describe adapters to count VPCs, subnets, addresses and volumes, as they did under `gateway/ec2`. The ADR's `handlers/quota` current-to-target map row replaces these with reader capabilities once their final locations exist.
- `domains/ec2/awsapi/instance` (dependency): imports `domains/iam` (`IAMService` in `DescribeInstances.go`, `RunInstances.go` and `IamInstanceProfileAssociation.go`; `InstanceProfile` in the latter two), `runtime/compute/cache` (`NodeState`, `NodeUnknown`, `NodeLive`) and `runtime/compute/vm` (`VM`), both in `status_synthesis.go`. The IAM edge needs an IAM capability; the runtime edges are domain-to-runtime and need the instance record and node liveness exposed through an EC2-owned projection.
@@ -676,8 +682,8 @@ series above; "needs decision" is open.
| `gateway/bedrock` | — | Done. The whole package moved into `domains/ochre` in `spinifex#1266` with its package-private core and tests intact and no behaviour change. Separating the action adapters into `domains/ochre/awsapi` remains deferred, recorded as `domains/ochre` (action adapters) debt above. |
| `gateway/{iam,sts,eks,rds}` | — | Done. The action adapters moved to `domains/{iam,sts,eks,rds}/awsapi` in `spinifex#1265` with no API, NATS subject, storage or behaviour change. ADR-0001 does not make ADR-0002 registration a prerequisite for relocating a domain's adapters, as the EC2 adapter moves already showed; the root `gateway` dispatch that still imports them is recorded `gateway` (service registration) debt. |
| `gateway/{ecs,elbv2}` | — | Done. The action adapters moved to `domains/{ecs,elbv2}/awsapi` in `spinifex#1273` with no API, NATS subject, storage or behaviour change; the root `gateway` dispatch that still imports them is recorded `gateway` (service registration) debt. |
| `gateway/spx` | `promote_image.go` and `spx.go` dispatch (`storage.go` already consumes `contracts/operator/v1`). | Needs decision: which package owns operator-plane action dispatch registration — an `ingress/aws`-style seam for the operator plane, or `gateway` stays its home. |
| `gateway/tagging` | — | Done. `domains/tagging` owns the read-only cross-resource `GetResources` aggregation and its API semantics (`0d830e901`, `aedd0b2c4`); AWSGW composes the owner listers, the EC2 tag index and ELBv2 (`f3545e3ea`); the root `gateway` keeps only the legacy dispatch in `gateway/tagging.go`. The package is removed, and its two allowlist rows and frozen-legacy entry are gone. Owner tag mutation capabilities remain future direction, so the RDS and ECS tag-writing edges above stay open. |
| `gateway/spx` | — | Done. The gather helpers, storage-status config and image-promote request moved to `operator/admin` with no behaviour change and the package is removed. The `spinifex` Query dispatch that calls them now lives in `operator/admin` as a seam registration; `/admin/{method}` stays in the root `gateway` until the actor carries `UserName`. |
| `gateway/tagging` | — | Done. `domains/tagging` owns the read-only cross-resource `GetResources` aggregation and its API semantics (`0d830e901`, `aedd0b2c4`); AWSGW composes the owner listers, the EC2 tag index and ELBv2 (`f3545e3ea`); the dispatch moved to `domains/tagging/awsapi` and the root `gateway` no longer serves Tagging. The package is removed, and its two allowlist rows and frozen-legacy entry are gone. Owner tag mutation capabilities remain future direction, so the RDS and ECS tag-writing edges above stay open. |
| `services/viperblockd` | — | Done. The adapter is `providers/ebs/viperblock` (with the read-only `dirty` reader), the shared vocabulary is `contracts/viperblockd/legacy/v1`, and the role is `runtime/roles/viperblockd`. `services/` is empty. |
| `utils` | — | Done and deleted. `ec2_subject.go` went to `contracts/ec2/v1`, the vpcd protocol to `contracts/network/v1` with the typed client in `domains/network/projection`, and the master-key loader to `domains/ec2/ebs/encryption`. |
| `vpcd` | — | Done. The IMDS local-file reader and record-space opener moved to `domains/ec2/instance/state` and are injected through `guestmetadata.LocalStateReader`/`RecordLoader` (recorded above), so `vpcd` no longer imports `daemon` directly; its inter-process boundary now exists (`contracts/network/v1`, with `domains/network/subscribers` as the consumer and `domains/network/projection` as the producer client). The package moved unchanged to `runtime/roles/vpcd` (recorded below), and the legacy directory is removed. |
@@ -696,7 +702,7 @@ ADR-0001 completion is structural, so none of these is cleared by remediating se
| Class | Paths | Status |
|---|---|---|
| No missing boundary (candidate moves) | `handlers/ecs/bus` → `contracts/ecs/v1` (`a130f6b8e`); `admin/images_promote.go` → `domains/ec2/image` (`263c58167`); `admin` bootstrap account identifiers → `foundation/aws/identifiers` (`3fdaa54d9`); `admin` credential-format generators → `foundation/aws/credentials` `0368e861a`, retiring `admin` | Done. |
| One narrow missing boundary | Done: `handlers/sts` EKS JWKS lookup → `handlers/eks` `ClusterJWKSResolver` (`254d898a3`); `gateway/tagging` → `domains/tagging` with AWSGW-composed listers (`0d830e901`, `f3545e3ea`, `aedd0b2c4`). Open: `gateway/spx` (owner of operator-plane dispatch registration) | Each needs one decision or one capability, then a mechanical move. |
| One narrow missing boundary | Done: `handlers/sts` EKS JWKS lookup → `handlers/eks` `ClusterJWKSResolver` (`254d898a3`); `gateway/tagging` → `domains/tagging` with AWSGW-composed listers (`0d830e901`, `f3545e3ea`, `aedd0b2c4`); `gateway/spx` → `operator/admin`. Open: the owner of the `spinifex` service and `/admin` dispatch registration | Each needs one decision or one capability, then a mechanical move. |
| Broader boundary or follows another package | `gateway` root (ADR-0002 auth and request-context phase) | Recorded debt with removal conditions above. |
Per-consumer IAM capabilities are the widest unblocker: they gate STS's IAM reads, Ochre's IAM reads, the ELBv2 role path and the ECS, EKS and RDS ensurers.
+1 -1
View File
@@ -73,7 +73,6 @@ The cluster plane is peer-scoped. On a single-NIC node the planes collapse onto
| Ports | Serving |
|---|---|
| TCP 4222, 4248 | NATS client and cluster routes |
| TCP 4223, 4249 | SQS message store client and cluster routes |
| TCP 4432 | Formation, then the daemon cluster manager |
| TCP and UDP 5300 | Internal DNS |
| TCP 6641, 6642 | OVN northbound and southbound |
@@ -158,6 +157,7 @@ Restarting the daemon is what re-arms it: the node rebuilds its peer list from t
> [!WARNING]
> Do not flush the whole nftables ruleset to clear the policy. The VPC daemon writes SNAT and per-elastic-IP forward rules into the `ip nat` and `ip filter` tables and reinstalls them only when it starts, so `nft flush ruleset` breaks elastic IPs silently until the next `systemctl restart spinifex-vpcd`. Use `disable`, which only ever touches `inet spinifex_filter`.
> On Oracle Linux with IPsec on, a flush also removes `inet spinifex_ipsec_guard`, so Geneve to a peer without an SA goes out in clear until the daemon puts the table back, within 60 seconds.
## The Files
+21
View File
@@ -144,6 +144,27 @@ The guest MTU moves with the setting, but a running guest holds its DHCP lease f
**Expect launches to fail for a minute or two after re-enabling.** `RunInstances` returns `ServerInternal` with `vpc.add-nat` timing out, because the OVN flows-ready barrier cannot confirm while `ovn-controller` reprograms every tunnel for the new SAs. It clears once `ip xfrm state` settles. The error is reported as a capacity problem, which it is not.
### Containers and nested overlays inside a guest
A Docker bridge, Kubernetes pod network or other overlay inside a guest defaults to an MTU of 1500, which is larger than the guest's 1408 or 1442.
Downloads from the internet still work for TCP on the veth and routed uplinks, because vpcd clamps the TCP MSS on the host hop (see [Performance Tuning → MTU](/docs/performance-tuning#4-mtu)).
That was measured on a single node (env12) with a Docker container; it was not measured on a physical uplink or on a multi-node cluster.
On a physical uplink there is no host hop to clamp, and UDP is never clamped, so set the inner MTU to the guest MTU.
For Docker, in `/etc/docker/daemon.json`:
```json
{
"mtu": 1408,
"default-network-opts": {
"bridge": { "com.docker.network.driver.mtu": "1408" }
}
}
```
Use 1442 when `ipsec_enabled = false`, and restart the Docker daemon so the default bridge is recreated.
The gateway MTU follows `ipsec_enabled` and converges on the next reconcile pass, so change the inner MTU together with the guest MTU.
## Private vs Public Subnets
A subnet's behavior depends on three things: whether the VPC has an Internet Gateway, whether the subnet's route table has a default route to that IGW, and whether the subnet has `MapPublicIpOnLaunch` enabled.
+66
View File
@@ -45,6 +45,7 @@ Each repository holds image manifests and their layers. Images are stored in obj
- Repository lifecycle — `CreateRepository`, `DeleteRepository`, `DescribeRepositories`.
- OCI push and pull — blobs, manifests, and tags, including cross-repository blob mounts.
- OCI 1.1 referrers — the referrers API, so cosign v3 signatures and other artifacts attached to an image can be listed (see [OCI referrers and image signing](#oci-referrers-and-image-signing)).
- Token auth — `GetAuthorizationToken` plus bearer/basic auth on `/v2/`.
- Repository policies — set, get, and delete a repository policy document.
@@ -176,6 +177,71 @@ Then authenticate Docker and push using the `repository_url` output, following s
:::
## EKS workers and multiple gateways
Each gateway puts its own address in the `repositoryUri` it returns, so repositories created through different gateways can carry different hosts.
When an EKS worker launches, the daemon that launches it writes every cluster gateway into the worker's registry mirrors, TLS trust and credential-provider config.
A pod can then pull from any `repositoryUri` host in the cluster.
The worker tries the gateway named in the image reference first and falls back to the others in cluster order.
Limits:
- The gateway list comes from the cluster config the launching daemon loaded at start.
A gateway added later is not trusted by existing workers; restart the launching daemon and replace the worker.
- Peer gateways are assumed to listen on port 9999.
A hand-edited `awsgw` bind on a peer is not covered.
- A gateway address that does not answer costs about 30 seconds on each cold pull while the worker's connection attempt gives up.
A stopped gateway on a host that is still up refuses the connection, and the worker moves to the next gateway at once.
Measured on a single-node environment: pulls by `repositoryUri` and by the account's ECR DNS name, a pull rejected with `x509: certificate signed by unknown authority` when the gateway host was not in the worker config, and a 31 second pull through an unreachable first endpoint.
Measured on a 3-node environment: a pod pulling each of the three gateways' `repositoryUri` on a worker launched with this change (two of the three failed with `x509` on a worker launched without it), and a pull of the stopped gateway's own `repositoryUri` that succeeded through another gateway within one retry (the pull itself took 147 ms after the first endpoint refused).
## OCI referrers and image signing
A manifest can name another manifest as its `subject`, which is how tools such as cosign v3 attach signatures and SBOMs to an image.
The registry records the subject when the manifest is pushed and answers with an `OCI-Subject` header on the `201`.
The `subject` does not have to exist in the repository when the referrer is pushed.
`GET /v2/<name>/referrers/<digest>` lists the manifests that name `<digest>` as their subject.
The request is authorised as `ecr:BatchGetImage` on the repository.
The response is `200` with `Content-Type: application/vnd.oci.image.index.v1+json` and an index whose `manifests` hold each referrer's `mediaType`, `digest`, `size`, `artifactType` and annotations.
A digest with no referrers, or one that is not stored, returns an empty index.
A malformed digest returns `400` with `DIGEST_INVALID`.
Adding `?artifactType=<type>` returns only referrers of that type and sets `OCI-Filters-Applied: artifactType`.
The header is also set when nothing matches.
An image manifest without an `artifactType` is listed under its config `mediaType`; an index without one has none.
Deleting an image by digest (`DELETE /v2/<name>/manifests/<digest>`, `aws ecr batch-delete-image` with `imageDigest`) also deletes its referrers, and their referrers in turn.
Deleting by tag removes only the tag and leaves the image and its referrers.
cosign v3 sign and verify work against the registry, including on a child manifest of an image index copied with `cosign copy`.
cosign v2 keeps writing its `sha256-<digest>.sig` tags and is unaffected.
`GET` on a manifest still returns `406` when the request's `Accept` header does not include the stored `mediaType`.
Limits:
- `cosign copy` does not copy v3 signatures to the destination repository.
This is client behaviour.
To keep the original signature, copy with `oras cp -r <source> <mirror>`, which copies the image and its referrers in one command, or `crane copy` the signature manifest by digest after the image.
Otherwise, sign the copy again.
- Referrers pushed before this version are not listed.
Push them again to add them to the index.
- A crash between deleting an image and deleting its referrers can leave referrers behind that nothing lists.
- The referrers listing is not paginated.
- `HEAD` on the referrers route is not served.
Measured on a 3-node environment with cosign v3.1.4 and v2.6.5:
- cosign v3.1.4 signed a fresh image index, with one `GET /referrers` answered `200`, no `sha256-<digest>` tag written and no `406`.
- Verify passed.
- A copied index's child manifest signed and verified, which returned `406` before this change.
- The listing returned the signature with its `artifactType`, the filter returned one entry for the signature's type and none for another type.
- `aws ecr batch-delete-image` of the signed image left an empty listing and `404` for both the signature and the image.
- cosign v2.6.5 signed and verified a fresh image.
Not measured: `oras`, `notation`, a listing with many referrers, and a crash in the middle of a delete.
## Troubleshooting
**`docker login` fails with an authorization error.** The token is account-scoped and short-lived. Re-run `aws ecr get-login-password` to mint a fresh token, and confirm the registry host in `docker login` matches your account's endpoint (`aws ecr describe-repositories` returns it).
+35 -1
View File
@@ -60,6 +60,36 @@ Authentication uses **EKS access entries** (the API authentication mode) — IAM
- **The worker image is fixed.** Node groups always boot Spinifex's `eks-node` image. `ami_type` is recorded but does not select the image, and the image must be registered on the cluster before you create a node group.
- **Default Kubernetes version is `1.32`.**
### Control plane readiness and kubectl authentication
Each control-plane VM authenticates `kubectl` tokens through a webhook that calls the AWS gateway on the host the VM runs on.
When that gateway is stopped, the webhook cannot validate tokens and `kubectl` reports `Unauthorized` for the share of requests that the load balancer sends to that control plane.
The webhook serves `/readyz` on port 8444 of each control-plane VM.
It returns 200 only when the host gateway answers a TLS handshake and the local API server accepts a TCP connection, and 503 otherwise.
The API server target group health-checks `HTTP :8444/readyz` every 5 seconds with a healthy and unhealthy threshold of 2.
The target group still forwards TCP on 6443.
A control plane that cannot authenticate leaves rotation, and returns when its gateway is back.
Measured on a three-node cluster, one control plane per host, with `kubectl get ns` run continuously after stopping one gateway:
| Gateway stopped | Result |
| --- | --- |
| Host without the cluster load balancer VM, first run | 4 of 20 calls failed in the first round, 0 of 20 in the next round 54 s after the stop |
| Host without the cluster load balancer VM, second run | 2 failures in 100 s, at 3 s and 8 s after the stop |
| Host running the cluster load balancer VM | Not fixed: 20 of 60 calls failed over 100 s |
Before this change the same test failed 3, 4 and 3 calls of 10 per stopped gateway.
Limits:
- Only clusters created with the current `eks-node` image get the readiness listener, and only clusters created from now on get the health check on the target group.
Older clusters keep the TCP check on 6443 and are not changed.
- A cluster created with an older image gets the health check on its target group but no listener on 8444, so every control plane fails the check and the load balancer fails open to all of them.
- A stopped gateway on the host that runs the cluster load balancer VM is not covered.
The load balancer VM reports target health through its own host's gateway, so while that gateway is down the unhealthy control plane stays in rotation.
- Not measured: more than one control plane per host, a physical-node failure instead of a stopped gateway, and the target health state as `DescribeTargetHealth` reports it, because the load balancer and control-plane VMs belong to the system account.
## Prerequisites
> [!IMPORTANT]
@@ -123,7 +153,7 @@ aws eks describe-cluster --name demo --query 'cluster.status'
aws eks create-nodegroup \
--cluster-name demo \
--nodegroup-name default \
--node-role-arn arn:aws:iam::000000000000:role/eks-node-role \
--node-role arn:aws:iam::000000000000:role/eks-node-role \
--subnets subnet-aaaa subnet-bbbb \
--scaling-config minSize=1,maxSize=2,desiredSize=1 \
--instance-types t3.medium
@@ -256,6 +286,10 @@ The workbooks form a ladder. **eks-quickstart** is a NodePort demo; **eks-https-
**`kubectl` cannot connect.** Re-run `aws eks update-kubeconfig --name <name>` to refresh the endpoint and CA, and confirm the cluster is `ACTIVE`. `aws sts get-caller-identity` verifies your credentials reach the Spinifex endpoint.
**`kubectl` returns `Unauthorized` for some requests and works for others.**
One control plane cannot reach its host's AWS gateway, so check `systemctl status spinifex-awsgw` on each node.
Control plane readiness and kubectl authentication above lists which clusters recover on their own.
**Workers stay `NotReady`.** Almost always an egress or IAM problem:
- Confirm the workers can pull images — a default route to an Internet Gateway (public) or NAT Gateway (private).
- Confirm the node role has `AmazonEKSWorkerNodePolicy`, `AmazonEKS_CNI_Policy`, and `AmazonEC2ContainerRegistryReadOnly`.
+3 -2
View File
@@ -17,7 +17,7 @@ tags:
## Overview
The platform serves **392 operations** across the AWS APIs below. Every page names the operations its service implements from the pinned model, generated from the dispatch tables on each build rather than written by hand.
The platform serves **405 operations** across the AWS APIs below. Every page names the operations its service implements from the pinned model, generated from the dispatch tables on each build rather than written by hand.
| Service | Operations |
|---|---:|
@@ -29,6 +29,7 @@ The platform serves **392 operations** across the AWS APIs below. Every page nam
| [ELBv2](/coverage/elbv2) | 36 |
| [IAM](/coverage/iam) | 81 |
| [RDS](/coverage/rds) | 27 |
| [Route 53](/coverage/route53) | 13 |
| [S3](/coverage/s3) | 26 |
| [STS](/coverage/sts) | 5 |
| **Total** | **392** |
| **Total** | **405** |
+228 -2
View File
@@ -2,8 +2,8 @@
"schema_version": 1,
"model_source": {
"repository": "github.com/aws/api-models-aws",
"commit": "9b1a4929274443d8671ea8acfda7afbe856e29e9",
"commit_date": "2026-09-25"
"commit": "e10d6a1c5ac36af7c0c61d1f67b3c3ebbe41de49",
"commit_date": "2026-10-28"
},
"services": [
{
@@ -300,6 +300,7 @@
"DeleteCapacityManagerDataExport",
"DeleteCarrierGateway",
"DeleteClientVpnEndpoint",
"DeleteClientVpnEndpointAuthorizationPolicy",
"DeleteClientVpnRoute",
"DeleteCoipCidr",
"DeleteCoipPool",
@@ -681,7 +682,9 @@
"GetCapacityManagerMetricData",
"GetCapacityManagerMetricDimensions",
"GetCapacityManagerMonitoredTagKeys",
"GetCapacityManagerSupportabilityScores",
"GetCapacityReservationUsage",
"GetClientVpnEndpointAuthorizationPolicy",
"GetCoipPoolUsage",
"GetConsoleOutput",
"GetConsoleScreenshot",
@@ -695,6 +698,7 @@
"GetHostReservationPurchasePreview",
"GetImageAncestry",
"GetImageBlockPublicAccessState",
"GetImageFindings",
"GetInstanceMetadataDefaults",
"GetInstanceTpmEkPub",
"GetInstanceTypesFromInstanceRequirements",
@@ -734,6 +738,7 @@
"GetSnapshotBlockPublicAccessState",
"GetSpotPlacementScores",
"GetSubnetCidrReservations",
"GetTransitGatewayAttachmentNatConfiguration",
"GetTransitGatewayAttachmentPropagations",
"GetTransitGatewayMeteringPolicyEntries",
"GetTransitGatewayMulticastDomainAssociations",
@@ -766,6 +771,7 @@
"ModifyCapacityReservation",
"ModifyCapacityReservationFleet",
"ModifyClientVpnEndpoint",
"ModifyClientVpnEndpointAuthorizationPolicy",
"ModifyDefaultCreditSpecification",
"ModifyEbsDefaultKmsKeyId",
"ModifyFleet",
@@ -814,6 +820,7 @@
"ModifyTrafficMirrorFilterRule",
"ModifyTrafficMirrorSession",
"ModifyTransitGateway",
"ModifyTransitGatewayAttachmentNatConfiguration",
"ModifyTransitGatewayMeteringPolicy",
"ModifyTransitGatewayPolicyTableEntry",
"ModifyTransitGatewayPrefixListReference",
@@ -1335,6 +1342,7 @@
"DeleteCapacityManagerDataExport",
"DeleteCarrierGateway",
"DeleteClientVpnEndpoint",
"DeleteClientVpnEndpointAuthorizationPolicy",
"DeleteClientVpnRoute",
"DeleteCoipCidr",
"DeleteCoipPool",
@@ -1653,7 +1661,9 @@
"GetCapacityManagerMetricData",
"GetCapacityManagerMetricDimensions",
"GetCapacityManagerMonitoredTagKeys",
"GetCapacityManagerSupportabilityScores",
"GetCapacityReservationUsage",
"GetClientVpnEndpointAuthorizationPolicy",
"GetCoipPoolUsage",
"GetConsoleScreenshot",
"GetDeclarativePoliciesReportSummary",
@@ -1665,6 +1675,7 @@
"GetHostReservationPurchasePreview",
"GetImageAncestry",
"GetImageBlockPublicAccessState",
"GetImageFindings",
"GetInstanceMetadataDefaults",
"GetInstanceTpmEkPub",
"GetInstanceTypesFromInstanceRequirements",
@@ -1701,6 +1712,7 @@
"GetSnapshotBlockPublicAccessState",
"GetSpotPlacementScores",
"GetSubnetCidrReservations",
"GetTransitGatewayAttachmentNatConfiguration",
"GetTransitGatewayAttachmentPropagations",
"GetTransitGatewayMeteringPolicyEntries",
"GetTransitGatewayMulticastDomainAssociations",
@@ -1732,6 +1744,7 @@
"ModifyCapacityReservation",
"ModifyCapacityReservationFleet",
"ModifyClientVpnEndpoint",
"ModifyClientVpnEndpointAuthorizationPolicy",
"ModifyDefaultCreditSpecification",
"ModifyEbsDefaultKmsKeyId",
"ModifyFleet",
@@ -1773,6 +1786,7 @@
"ModifyTrafficMirrorFilterRule",
"ModifyTrafficMirrorSession",
"ModifyTransitGateway",
"ModifyTransitGatewayAttachmentNatConfiguration",
"ModifyTransitGatewayMeteringPolicy",
"ModifyTransitGatewayPolicyTableEntry",
"ModifyTransitGatewayPrefixListReference",
@@ -1897,6 +1911,7 @@
"DescribeImages",
"DescribePullThroughCacheRules",
"DescribeRegistry",
"DescribeReplicationBackfillResults",
"DescribeRepositories",
"DescribeRepositoryCreationTemplates",
"GetAccountSetting",
@@ -1912,6 +1927,7 @@
"ListImageReferrers",
"ListImages",
"ListPullTimeUpdateExclusions",
"ListReplicationBackfills",
"ListTagsForResource",
"PutAccountSetting",
"PutImage",
@@ -1926,6 +1942,8 @@
"SetRepositoryPolicy",
"StartImageScan",
"StartLifecyclePolicyPreview",
"StartReplicationBackfill",
"StopReplicationBackfill",
"TagResource",
"UntagResource",
"UpdateImageStorageClass",
@@ -2031,14 +2049,18 @@
"DescribeImageReplicationStatus",
"DescribeImageSigningStatus",
"DescribePullThroughCacheRules",
"DescribeReplicationBackfillResults",
"DescribeRepositoryCreationTemplates",
"GetAccountSetting",
"GetSigningConfiguration",
"ListImageReferrers",
"ListPullTimeUpdateExclusions",
"ListReplicationBackfills",
"PutAccountSetting",
"PutSigningConfiguration",
"RegisterPullTimeUpdateExclusion",
"StartReplicationBackfill",
"StopReplicationBackfill",
"UpdateImageStorageClass",
"UpdatePullThroughCacheRule",
"UpdateRepositoryCreationTemplate",
@@ -2058,11 +2080,13 @@
"note": "",
"operations": {
"modelled": [
"AssociateGatewayWithCluster",
"ContinueServiceDeployment",
"CreateCapacityProvider",
"CreateCluster",
"CreateDaemon",
"CreateExpressGatewayService",
"CreateGateway",
"CreateService",
"CreateTaskSet",
"DeleteAccountSetting",
@@ -2072,6 +2096,7 @@
"DeleteDaemon",
"DeleteDaemonTaskDefinition",
"DeleteExpressGatewayService",
"DeleteGateway",
"DeleteService",
"DeleteTaskDefinitions",
"DeleteTaskSet",
@@ -2085,12 +2110,16 @@
"DescribeDaemonRevisions",
"DescribeDaemonTaskDefinition",
"DescribeExpressGatewayService",
"DescribeGateway",
"DescribeGatewayResources",
"DescribeGatewayRoute",
"DescribeServiceDeployments",
"DescribeServiceRevisions",
"DescribeServices",
"DescribeTaskDefinition",
"DescribeTaskSets",
"DescribeTasks",
"DisassociateGatewayFromCluster",
"DiscoverPollEndpoint",
"ExecuteCommand",
"GetTaskProtection",
@@ -2101,6 +2130,9 @@
"ListDaemonDeployments",
"ListDaemonTaskDefinitions",
"ListDaemons",
"ListGatewayClusterAssociations",
"ListGatewayRoutes",
"ListGateways",
"ListServiceDeployments",
"ListServices",
"ListServicesByNamespace",
@@ -2131,6 +2163,7 @@
"UpdateContainerInstancesState",
"UpdateDaemon",
"UpdateExpressGatewayService",
"UpdateGateway",
"UpdateService",
"UpdateServicePrimaryTaskSet",
"UpdateTaskProtection",
@@ -2219,15 +2252,18 @@
],
"unsupported": [],
"missing": [
"AssociateGatewayWithCluster",
"ContinueServiceDeployment",
"CreateDaemon",
"CreateExpressGatewayService",
"CreateGateway",
"CreateTaskSet",
"DeleteAccountSetting",
"DeleteAttributes",
"DeleteDaemon",
"DeleteDaemonTaskDefinition",
"DeleteExpressGatewayService",
"DeleteGateway",
"DeleteTaskDefinitions",
"DeleteTaskSet",
"DescribeDaemon",
@@ -2235,9 +2271,13 @@
"DescribeDaemonRevisions",
"DescribeDaemonTaskDefinition",
"DescribeExpressGatewayService",
"DescribeGateway",
"DescribeGatewayResources",
"DescribeGatewayRoute",
"DescribeServiceDeployments",
"DescribeServiceRevisions",
"DescribeTaskSets",
"DisassociateGatewayFromCluster",
"DiscoverPollEndpoint",
"ExecuteCommand",
"GetTaskProtection",
@@ -2245,6 +2285,9 @@
"ListDaemonDeployments",
"ListDaemonTaskDefinitions",
"ListDaemons",
"ListGatewayClusterAssociations",
"ListGatewayRoutes",
"ListGateways",
"ListServiceDeployments",
"PutAccountSettingDefault",
"PutAttributes",
@@ -2257,6 +2300,7 @@
"UpdateContainerAgent",
"UpdateDaemon",
"UpdateExpressGatewayService",
"UpdateGateway",
"UpdateServicePrimaryTaskSet",
"UpdateTaskProtection",
"UpdateTaskSet"
@@ -3099,6 +3143,7 @@
"ApplyPendingMaintenanceAction",
"AuthorizeDBSecurityGroupIngress",
"BacktrackDBCluster",
"CancelDBCapacityReservation",
"CancelExportTask",
"CopyDBClusterParameterGroup",
"CopyDBClusterSnapshot",
@@ -3107,6 +3152,7 @@
"CopyOptionGroup",
"CreateBlueGreenDeployment",
"CreateCustomDBEngineVersion",
"CreateDBCapacityReservation",
"CreateDBCluster",
"CreateDBClusterEndpoint",
"CreateDBClusterParameterGroup",
@@ -3150,6 +3196,7 @@
"DescribeAccountAttributes",
"DescribeBlueGreenDeployments",
"DescribeCertificates",
"DescribeDBCapacityReservations",
"DescribeDBClusterAutomatedBackups",
"DescribeDBClusterBacktracks",
"DescribeDBClusterEndpoints",
@@ -3204,6 +3251,7 @@
"ModifyCertificates",
"ModifyCurrentDBClusterCapacity",
"ModifyCustomDBEngineVersion",
"ModifyDBCapacityReservation",
"ModifyDBCluster",
"ModifyDBClusterEndpoint",
"ModifyDBClusterParameterGroup",
@@ -3354,6 +3402,7 @@
"ApplyPendingMaintenanceAction",
"AuthorizeDBSecurityGroupIngress",
"BacktrackDBCluster",
"CancelDBCapacityReservation",
"CancelExportTask",
"CopyDBClusterParameterGroup",
"CopyDBClusterSnapshot",
@@ -3362,6 +3411,7 @@
"CopyOptionGroup",
"CreateBlueGreenDeployment",
"CreateCustomDBEngineVersion",
"CreateDBCapacityReservation",
"CreateDBClusterEndpoint",
"CreateDBClusterParameterGroup",
"CreateDBClusterSnapshot",
@@ -3392,6 +3442,7 @@
"DescribeAccountAttributes",
"DescribeBlueGreenDeployments",
"DescribeCertificates",
"DescribeDBCapacityReservations",
"DescribeDBClusterAutomatedBackups",
"DescribeDBClusterBacktracks",
"DescribeDBClusterEndpoints",
@@ -3433,6 +3484,7 @@
"ModifyCertificates",
"ModifyCurrentDBClusterCapacity",
"ModifyCustomDBEngineVersion",
"ModifyDBCapacityReservation",
"ModifyDBClusterEndpoint",
"ModifyDBClusterParameterGroup",
"ModifyDBClusterSnapshotAttribute",
@@ -3483,6 +3535,180 @@
]
}
},
{
"service": "route53",
"api_version": "2013-04-01",
"opaque": false,
"note": "",
"operations": {
"modelled": [
"ActivateKeySigningKey",
"AssociateVPCWithHostedZone",
"ChangeCidrCollection",
"ChangeResourceRecordSets",
"ChangeTagsForResource",
"CreateCidrCollection",
"CreateHealthCheck",
"CreateHostedZone",
"CreateKeySigningKey",
"CreateQueryLoggingConfig",
"CreateReusableDelegationSet",
"CreateTrafficPolicy",
"CreateTrafficPolicyInstance",
"CreateTrafficPolicyVersion",
"CreateVPCAssociationAuthorization",
"DeactivateKeySigningKey",
"DeleteCidrCollection",
"DeleteHealthCheck",
"DeleteHostedZone",
"DeleteKeySigningKey",
"DeleteQueryLoggingConfig",
"DeleteReusableDelegationSet",
"DeleteTrafficPolicy",
"DeleteTrafficPolicyInstance",
"DeleteVPCAssociationAuthorization",
"DisableHostedZoneDNSSEC",
"DisassociateVPCFromHostedZone",
"EnableHostedZoneDNSSEC",
"GetAccountLimit",
"GetChange",
"GetCheckerIpRanges",
"GetDNSSEC",
"GetGeoLocation",
"GetHealthCheck",
"GetHealthCheckCount",
"GetHealthCheckLastFailureReason",
"GetHealthCheckStatus",
"GetHostedZone",
"GetHostedZoneCount",
"GetHostedZoneLimit",
"GetQueryLoggingConfig",
"GetReusableDelegationSet",
"GetReusableDelegationSetLimit",
"GetTrafficPolicy",
"GetTrafficPolicyInstance",
"GetTrafficPolicyInstanceCount",
"ListCidrBlocks",
"ListCidrCollections",
"ListCidrLocations",
"ListGeoLocations",
"ListHealthChecks",
"ListHostedZones",
"ListHostedZonesByName",
"ListHostedZonesByVPC",
"ListQueryLoggingConfigs",
"ListResourceRecordSets",
"ListReusableDelegationSets",
"ListTagsForResource",
"ListTagsForResources",
"ListTrafficPolicies",
"ListTrafficPolicyInstances",
"ListTrafficPolicyInstancesByHostedZone",
"ListTrafficPolicyInstancesByPolicy",
"ListTrafficPolicyVersions",
"ListVPCAssociationAuthorizations",
"TestDNSAnswer",
"UpdateHealthCheck",
"UpdateHostedZoneComment",
"UpdateHostedZoneFeatures",
"UpdateTrafficPolicyComment",
"UpdateTrafficPolicyInstance"
],
"registered": [
"ChangeResourceRecordSets",
"ChangeTagsForResource",
"CreateHostedZone",
"DeleteHostedZone",
"GetChange",
"GetHostedZone",
"GetHostedZoneCount",
"ListHostedZones",
"ListHostedZonesByName",
"ListResourceRecordSets",
"ListTagsForResource",
"ListTagsForResources",
"UpdateHostedZoneComment"
],
"implemented": [
"ChangeResourceRecordSets",
"ChangeTagsForResource",
"CreateHostedZone",
"DeleteHostedZone",
"GetChange",
"GetHostedZone",
"GetHostedZoneCount",
"ListHostedZones",
"ListHostedZonesByName",
"ListResourceRecordSets",
"ListTagsForResource",
"ListTagsForResources",
"UpdateHostedZoneComment"
],
"stubbed": [],
"unsupported": [],
"missing": [
"ActivateKeySigningKey",
"AssociateVPCWithHostedZone",
"ChangeCidrCollection",
"CreateCidrCollection",
"CreateHealthCheck",
"CreateKeySigningKey",
"CreateQueryLoggingConfig",
"CreateReusableDelegationSet",
"CreateTrafficPolicy",
"CreateTrafficPolicyInstance",
"CreateTrafficPolicyVersion",
"CreateVPCAssociationAuthorization",
"DeactivateKeySigningKey",
"DeleteCidrCollection",
"DeleteHealthCheck",
"DeleteKeySigningKey",
"DeleteQueryLoggingConfig",
"DeleteReusableDelegationSet",
"DeleteTrafficPolicy",
"DeleteTrafficPolicyInstance",
"DeleteVPCAssociationAuthorization",
"DisableHostedZoneDNSSEC",
"DisassociateVPCFromHostedZone",
"EnableHostedZoneDNSSEC",
"GetAccountLimit",
"GetCheckerIpRanges",
"GetDNSSEC",
"GetGeoLocation",
"GetHealthCheck",
"GetHealthCheckCount",
"GetHealthCheckLastFailureReason",
"GetHealthCheckStatus",
"GetHostedZoneLimit",
"GetQueryLoggingConfig",
"GetReusableDelegationSet",
"GetReusableDelegationSetLimit",
"GetTrafficPolicy",
"GetTrafficPolicyInstance",
"GetTrafficPolicyInstanceCount",
"ListCidrBlocks",
"ListCidrCollections",
"ListCidrLocations",
"ListGeoLocations",
"ListHealthChecks",
"ListHostedZonesByVPC",
"ListQueryLoggingConfigs",
"ListReusableDelegationSets",
"ListTrafficPolicies",
"ListTrafficPolicyInstances",
"ListTrafficPolicyInstancesByHostedZone",
"ListTrafficPolicyInstancesByPolicy",
"ListTrafficPolicyVersions",
"ListVPCAssociationAuthorizations",
"TestDNSAnswer",
"UpdateHealthCheck",
"UpdateHostedZoneFeatures",
"UpdateTrafficPolicyComment",
"UpdateTrafficPolicyInstance"
],
"extra": []
}
},
{
"service": "s3",
"api_version": "2006-03-01",
+15
View File
@@ -28,6 +28,21 @@ Spot Instance Requests are a mock over the on-demand `RunInstances` path. A requ
An AMI cannot be shared with another account or made public. `ModifyImageAttribute` refuses `LaunchPermission`, `UserIds`, `UserGroups`, `OrganizationArns`, `OrganizationalUnitArns` and `OperationType`, and `ResetImageAttribute` resets only `description`, not `launchPermission`. Each refusal is `InvalidParameterValue`. Terraform's `aws_ami_launch_permission` therefore cannot be created. `description` is the only image attribute that can be modified or reset.
### RegisterImage Registers From a Snapshot
`RegisterImage` takes a root EBS snapshot in `BlockDeviceMappings` and writes an image that points at it.
`EnaSupport` is supported, with `true` and `false` both accepted and stored on the image.
`DescribeImages` returns `EnaSupport` when the image has one and leaves the member out when it was never set, as AWS does.
`CopyImage` carries the value to the copy, and `CreateImage` sets it to `true` because every instance reports ENA support.
`RunInstances` does not read the flag, so an image registered with `EnaSupport=false` is not held back from instance types that need ENA on AWS, since guests use virtio networking either way.
`RegisterImage` refuses `ImageLocation`, `KernelId`, `RamdiskId`, `TpmSupport`, `ImdsSupport` and `SriovNetSupport`, and any `VirtualizationType` other than `hvm`.
Each refusal is `InvalidParameterValue`, and the message names the parameter and the value, for example `Value (aki-123) for parameter KernelId is invalid. KernelId is not supported.`
The same form is used for a bad `Architecture`, `BootMode` or `Name` length.
A missing `Name`, `BlockDeviceMappings` or root snapshot answers `MissingParameter` and names what is missing.
Registering with `--ena-support`, `--no-ena-support` and neither, copying the image, and three refusals were run on a three-node cluster with the AWS CLI.
Launching an instance from an `EnaSupport` image was not run, so the launch path is unchanged by construction and not by test.
### Operations
| Operation |
+15
View File
@@ -5,3 +5,18 @@ Spot Instance Requests are a mock over the on-demand `RunInstances` path. A requ
### AMI Launch Permissions Are Refused
An AMI cannot be shared with another account or made public. `ModifyImageAttribute` refuses `LaunchPermission`, `UserIds`, `UserGroups`, `OrganizationArns`, `OrganizationalUnitArns` and `OperationType`, and `ResetImageAttribute` resets only `description`, not `launchPermission`. Each refusal is `InvalidParameterValue`. Terraform's `aws_ami_launch_permission` therefore cannot be created. `description` is the only image attribute that can be modified or reset.
### RegisterImage Registers From a Snapshot
`RegisterImage` takes a root EBS snapshot in `BlockDeviceMappings` and writes an image that points at it.
`EnaSupport` is supported, with `true` and `false` both accepted and stored on the image.
`DescribeImages` returns `EnaSupport` when the image has one and leaves the member out when it was never set, as AWS does.
`CopyImage` carries the value to the copy, and `CreateImage` sets it to `true` because every instance reports ENA support.
`RunInstances` does not read the flag, so an image registered with `EnaSupport=false` is not held back from instance types that need ENA on AWS, since guests use virtio networking either way.
`RegisterImage` refuses `ImageLocation`, `KernelId`, `RamdiskId`, `TpmSupport`, `ImdsSupport` and `SriovNetSupport`, and any `VirtualizationType` other than `hvm`.
Each refusal is `InvalidParameterValue`, and the message names the parameter and the value, for example `Value (aki-123) for parameter KernelId is invalid. KernelId is not supported.`
The same form is used for a bad `Architecture`, `BootMode` or `Name` length.
A missing `Name`, `BlockDeviceMappings` or root snapshot answers `MissingParameter` and names what is missing.
Registering with `--ena-support`, `--no-ena-support` and neither, copying the image, and three refusals were run on a three-node cluster with the AWS CLI.
Launching an instance from an `EnaSupport` image was not run, so the launch path is unchanged by construction and not by test.
+68
View File
@@ -24,6 +24,74 @@ Spinifex implements **36 operations** in the ELBv2 `2015-12-01` API model.
The data plane is a system-managed load balancer VM, launched automatically during `CreateLoadBalancer`. Application Load Balancers run HAProxy for the L7 surface — rules, fixed responses and redirects over HTTP and HTTPS. Network Load Balancers run nginx `stream` for the L4 surface — TCP, UDP, TLS and TCP_UDP — because HAProxy cannot load-balance UDP. The agent selects the engine from the configuration the control plane delivers, so the choice follows the load balancer type and is not separately configurable.
### Target health and routing
Traffic goes only to targets that are healthy or still `initial`.
A target that has failed its health check is removed from the data plane on the next render and put back when it passes again.
When every target in a group is unhealthy, the load balancer fails open and sends traffic to all of them rather than refusing it.
A Network Load Balancer renders every registered server in that case, and an Application Load Balancer falls back to unchecked backup servers that HAProxy uses only when every checked server is down.
`DescribeTargetHealth` keeps reporting the targets as unhealthy while this happens.
Draining targets and targets without an address never receive traffic.
An `initial` target receives traffic before its first successful check.
This differs from AWS, where a target takes no traffic until it passes.
Health state is reset to `initial` for every target when the control plane restarts, so excluding `initial` targets would drop all traffic after a restart.
Changing the health check of a target group with `ModifyTargetGroup` takes effect on the next render of every load balancer that uses it.
The time from a backend failing to traffic stopping is the health check interval times the unhealthy threshold, plus the agent poll of about 5 seconds and the heartbeat that reports the result.
Recovery takes the interval times the healthy threshold, plus the same delays.
Measured on a three-node cluster with a 5 second interval and thresholds of 2:
| Event | Time |
| --- | --- |
| Backend starts failing to reported `unhealthy` | 21 s |
| Backend recovers to reported `healthy` | 15 s, serving again 6 s later |
| Every backend failing to reported `unhealthy` | 12 s, fail open immediate |
A TCP connection held through a Network Load Balancer survived four configuration reloads while the other backend changed state, because the agent applies nginx with a graceful reload and HAProxy with a graceful restart.
The HAProxy configuration with the backup servers was accepted by a live Application Load Balancer, but its rendered text was not dumped and checked by hand.
### Forwarding headers
An Application Load Balancer sets three headers on every HTTP and HTTPS request it forwards.
`X-Forwarded-For` carries the client address, `X-Forwarded-Proto` is `http` or `https` for the listener that accepted the request, and `X-Forwarded-Port` is that listener's port.
`X-Forwarded-Proto` and `X-Forwarded-Port` are always overwritten, so a value sent by the client never reaches the target.
Network Load Balancers do not touch the stream.
`routing.http.xff_header_processing.mode` decides what happens to `X-Forwarded-For`:
| Mode | Behaviour |
| --- | --- |
| `append` (default) | Keeps what the client sent and adds the client address last, as one comma-separated line such as `203.0.113.9, 192.0.2.10`. |
| `preserve` | Forwards the client's value unchanged and adds nothing. The header is absent when the client sent none. |
| `remove` | Drops the header, including any client value. |
`routing.http.xff_client_port.enabled` (default `false`) appends the client's source port in `append` mode, as `192.0.2.10:54321`.
An IPv6 client is written `[2001:db8::1]:54321`.
The setting has no effect in `preserve` and `remove` modes.
`ModifyLoadBalancerAttributes` accepts only `append`, `preserve` or `remove` for the mode and `true` or `false` for the client port, and rejects anything else with `ValidationError`.
A change re-renders the load balancer configuration, and a call that leaves both values as they were does not.
In a live test each change reached the target within one 3 second poll.
A client that sends `X-Forwarded-For` more than once has its lines joined into the single comma-separated value.
At most eight client lines are joined, and any beyond the eighth are dropped, while the client address is still added last.
Every other `routing.http.*` attribute is stored and returned by `DescribeLoadBalancerAttributes` but has no effect on the data plane.
Measured on a three-node cluster, with the caller on the first node:
| Listener | `X-Forwarded-For` the target saw |
| --- | --- |
| Internet-facing HTTP | The node's real address, `192.168.0.120` |
| Internet-facing HTTPS | The same address, with `https` and port 443 |
| Internal HTTP | The calling instance's private address |
| Internet-facing HTTP with a client value of `203.0.113.9` | `203.0.113.9, 192.168.0.120` on one line |
A packet capture on the load balancer's interface showed HAProxy receiving the real client address, so nothing translates it first.
Behaviour against AWS was not compared, and the cases above were not run with an IPv6 client.
### Operations
| Operation |
+68
View File
@@ -1,3 +1,71 @@
### Two data planes
The data plane is a system-managed load balancer VM, launched automatically during `CreateLoadBalancer`. Application Load Balancers run HAProxy for the L7 surface — rules, fixed responses and redirects over HTTP and HTTPS. Network Load Balancers run nginx `stream` for the L4 surface — TCP, UDP, TLS and TCP_UDP — because HAProxy cannot load-balance UDP. The agent selects the engine from the configuration the control plane delivers, so the choice follows the load balancer type and is not separately configurable.
### Target health and routing
Traffic goes only to targets that are healthy or still `initial`.
A target that has failed its health check is removed from the data plane on the next render and put back when it passes again.
When every target in a group is unhealthy, the load balancer fails open and sends traffic to all of them rather than refusing it.
A Network Load Balancer renders every registered server in that case, and an Application Load Balancer falls back to unchecked backup servers that HAProxy uses only when every checked server is down.
`DescribeTargetHealth` keeps reporting the targets as unhealthy while this happens.
Draining targets and targets without an address never receive traffic.
An `initial` target receives traffic before its first successful check.
This differs from AWS, where a target takes no traffic until it passes.
Health state is reset to `initial` for every target when the control plane restarts, so excluding `initial` targets would drop all traffic after a restart.
Changing the health check of a target group with `ModifyTargetGroup` takes effect on the next render of every load balancer that uses it.
The time from a backend failing to traffic stopping is the health check interval times the unhealthy threshold, plus the agent poll of about 5 seconds and the heartbeat that reports the result.
Recovery takes the interval times the healthy threshold, plus the same delays.
Measured on a three-node cluster with a 5 second interval and thresholds of 2:
| Event | Time |
| --- | --- |
| Backend starts failing to reported `unhealthy` | 21 s |
| Backend recovers to reported `healthy` | 15 s, serving again 6 s later |
| Every backend failing to reported `unhealthy` | 12 s, fail open immediate |
A TCP connection held through a Network Load Balancer survived four configuration reloads while the other backend changed state, because the agent applies nginx with a graceful reload and HAProxy with a graceful restart.
The HAProxy configuration with the backup servers was accepted by a live Application Load Balancer, but its rendered text was not dumped and checked by hand.
### Forwarding headers
An Application Load Balancer sets three headers on every HTTP and HTTPS request it forwards.
`X-Forwarded-For` carries the client address, `X-Forwarded-Proto` is `http` or `https` for the listener that accepted the request, and `X-Forwarded-Port` is that listener's port.
`X-Forwarded-Proto` and `X-Forwarded-Port` are always overwritten, so a value sent by the client never reaches the target.
Network Load Balancers do not touch the stream.
`routing.http.xff_header_processing.mode` decides what happens to `X-Forwarded-For`:
| Mode | Behaviour |
| --- | --- |
| `append` (default) | Keeps what the client sent and adds the client address last, as one comma-separated line such as `203.0.113.9, 192.0.2.10`. |
| `preserve` | Forwards the client's value unchanged and adds nothing. The header is absent when the client sent none. |
| `remove` | Drops the header, including any client value. |
`routing.http.xff_client_port.enabled` (default `false`) appends the client's source port in `append` mode, as `192.0.2.10:54321`.
An IPv6 client is written `[2001:db8::1]:54321`.
The setting has no effect in `preserve` and `remove` modes.
`ModifyLoadBalancerAttributes` accepts only `append`, `preserve` or `remove` for the mode and `true` or `false` for the client port, and rejects anything else with `ValidationError`.
A change re-renders the load balancer configuration, and a call that leaves both values as they were does not.
In a live test each change reached the target within one 3 second poll.
A client that sends `X-Forwarded-For` more than once has its lines joined into the single comma-separated value.
At most eight client lines are joined, and any beyond the eighth are dropped, while the client address is still added last.
Every other `routing.http.*` attribute is stored and returned by `DescribeLoadBalancerAttributes` but has no effect on the data plane.
Measured on a three-node cluster, with the caller on the first node:
| Listener | `X-Forwarded-For` the target saw |
| --- | --- |
| Internet-facing HTTP | The node's real address, `192.168.0.120` |
| Internet-facing HTTPS | The same address, with `https` and port 443 |
| Internal HTTP | The calling instance's private address |
| Internet-facing HTTP with a client value of `203.0.113.9` | `203.0.113.9, 192.168.0.120` on one line |
A packet capture on the load balancer's interface showed HAProxy receiving the real client address, so nothing translates it first.
Behaviour against AWS was not compared, and the cases above were not run with an IPv6 client.
+96 -5
View File
@@ -4,7 +4,7 @@ Generated by `make aws-model-coverage`. This file is not published: it carries t
**Not implemented** is the candidate work. **Not applicable** is declared in `pages.json` and is not work: those operations describe something this platform will not have.
Pinned model source: `github.com/aws/api-models-aws@9b1a49292744, 2026-09-25`.
Pinned model source: `github.com/aws/api-models-aws@e10d6a1c5ac3, 2026-10-28`.
## acm
@@ -46,7 +46,7 @@ Pinned model source: `github.com/aws/api-models-aws@9b1a49292744, 2026-09-25`.
## ec2
`2016-11-15` — 127 of 804 modelled operations implemented (15.8%), 0 stubbed, 39 not applicable, 0 registered outside the pinned model.
`2016-11-15` — 127 of 811 modelled operations implemented (15.7%), 0 stubbed, 39 not applicable, 0 registered outside the pinned model.
### Not implemented
@@ -190,6 +190,7 @@ Pinned model source: `github.com/aws/api-models-aws@9b1a49292744, 2026-09-25`.
- `DeleteApplicationStatusCheck`
- `DeleteCapacityManagerDataExport`
- `DeleteClientVpnEndpoint`
- `DeleteClientVpnEndpointAuthorizationPolicy`
- `DeleteClientVpnRoute`
- `DeleteCoipCidr`
- `DeleteCoipPool`
@@ -484,7 +485,9 @@ Pinned model source: `github.com/aws/api-models-aws@9b1a49292744, 2026-09-25`.
- `GetCapacityManagerMetricData`
- `GetCapacityManagerMetricDimensions`
- `GetCapacityManagerMonitoredTagKeys`
- `GetCapacityManagerSupportabilityScores`
- `GetCapacityReservationUsage`
- `GetClientVpnEndpointAuthorizationPolicy`
- `GetCoipPoolUsage`
- `GetConsoleScreenshot`
- `GetDeclarativePoliciesReportSummary`
@@ -496,6 +499,7 @@ Pinned model source: `github.com/aws/api-models-aws@9b1a49292744, 2026-09-25`.
- `GetHostReservationPurchasePreview`
- `GetImageAncestry`
- `GetImageBlockPublicAccessState`
- `GetImageFindings`
- `GetInstanceMetadataDefaults`
- `GetInstanceTpmEkPub`
- `GetInstanceTypesFromInstanceRequirements`
@@ -532,6 +536,7 @@ Pinned model source: `github.com/aws/api-models-aws@9b1a49292744, 2026-09-25`.
- `GetSnapshotBlockPublicAccessState`
- `GetSpotPlacementScores`
- `GetSubnetCidrReservations`
- `GetTransitGatewayAttachmentNatConfiguration`
- `GetTransitGatewayAttachmentPropagations`
- `GetTransitGatewayMeteringPolicyEntries`
- `GetTransitGatewayMulticastDomainAssociations`
@@ -563,6 +568,7 @@ Pinned model source: `github.com/aws/api-models-aws@9b1a49292744, 2026-09-25`.
- `ModifyCapacityReservation`
- `ModifyCapacityReservationFleet`
- `ModifyClientVpnEndpoint`
- `ModifyClientVpnEndpointAuthorizationPolicy`
- `ModifyDefaultCreditSpecification`
- `ModifyEbsDefaultKmsKeyId`
- `ModifyFleet`
@@ -601,6 +607,7 @@ Pinned model source: `github.com/aws/api-models-aws@9b1a49292744, 2026-09-25`.
- `ModifyTrafficMirrorFilterRule`
- `ModifyTrafficMirrorSession`
- `ModifyTransitGateway`
- `ModifyTransitGatewayAttachmentNatConfiguration`
- `ModifyTransitGatewayMeteringPolicy`
- `ModifyTransitGatewayPolicyTableEntry`
- `ModifyTransitGatewayPrefixListReference`
@@ -762,7 +769,7 @@ spot-price: On owned hardware there is no spot-to-on-demand price differential,
## ecr
`2015-09-21` — 23 of 58 modelled operations implemented (39.7%), 10 stubbed, 0 not applicable, 3 registered outside the pinned model.
`2015-09-21` — 23 of 62 modelled operations implemented (37.1%), 10 stubbed, 0 not applicable, 3 registered outside the pinned model.
### Not implemented
@@ -776,14 +783,18 @@ spot-price: On owned hardware there is no spot-to-on-demand price differential,
- `DescribeImageReplicationStatus`
- `DescribeImageSigningStatus`
- `DescribePullThroughCacheRules`
- `DescribeReplicationBackfillResults`
- `DescribeRepositoryCreationTemplates`
- `GetAccountSetting`
- `GetSigningConfiguration`
- `ListImageReferrers`
- `ListPullTimeUpdateExclusions`
- `ListReplicationBackfills`
- `PutAccountSetting`
- `PutSigningConfiguration`
- `RegisterPullTimeUpdateExclusion`
- `StartReplicationBackfill`
- `StopReplicationBackfill`
- `UpdateImageStorageClass`
- `UpdatePullThroughCacheRule`
- `UpdateRepositoryCreationTemplate`
@@ -819,19 +830,22 @@ spot-price: On owned hardware there is no spot-to-on-demand price differential,
## ecs
`2014-11-13` — 31 of 77 modelled operations implemented (40.3%), 5 stubbed, 0 not applicable, 3 registered outside the pinned model.
`2014-11-13` — 31 of 88 modelled operations implemented (35.2%), 5 stubbed, 0 not applicable, 3 registered outside the pinned model.
### Not implemented
- `AssociateGatewayWithCluster`
- `ContinueServiceDeployment`
- `CreateDaemon`
- `CreateExpressGatewayService`
- `CreateGateway`
- `CreateTaskSet`
- `DeleteAccountSetting`
- `DeleteAttributes`
- `DeleteDaemon`
- `DeleteDaemonTaskDefinition`
- `DeleteExpressGatewayService`
- `DeleteGateway`
- `DeleteTaskDefinitions`
- `DeleteTaskSet`
- `DescribeDaemon`
@@ -839,9 +853,13 @@ spot-price: On owned hardware there is no spot-to-on-demand price differential,
- `DescribeDaemonRevisions`
- `DescribeDaemonTaskDefinition`
- `DescribeExpressGatewayService`
- `DescribeGateway`
- `DescribeGatewayResources`
- `DescribeGatewayRoute`
- `DescribeServiceDeployments`
- `DescribeServiceRevisions`
- `DescribeTaskSets`
- `DisassociateGatewayFromCluster`
- `DiscoverPollEndpoint`
- `ExecuteCommand`
- `GetTaskProtection`
@@ -849,6 +867,9 @@ spot-price: On owned hardware there is no spot-to-on-demand price differential,
- `ListDaemonDeployments`
- `ListDaemonTaskDefinitions`
- `ListDaemons`
- `ListGatewayClusterAssociations`
- `ListGatewayRoutes`
- `ListGateways`
- `ListServiceDeployments`
- `PutAccountSettingDefault`
- `PutAttributes`
@@ -861,6 +882,7 @@ spot-price: On owned hardware there is no spot-to-on-demand price differential,
- `UpdateContainerAgent`
- `UpdateDaemon`
- `UpdateExpressGatewayService`
- `UpdateGateway`
- `UpdateServicePrimaryTaskSet`
- `UpdateTaskProtection`
- `UpdateTaskSet`
@@ -1085,7 +1107,7 @@ signing-certificates: X.509 signing certificates are an EC2-Classic SOAP credent
## rds
`2014-10-31` — 27 of 164 modelled operations implemented (16.5%), 0 stubbed, 4 not applicable, 5 registered outside the pinned model.
`2014-10-31` — 27 of 168 modelled operations implemented (16.1%), 0 stubbed, 4 not applicable, 5 registered outside the pinned model.
### Not implemented
@@ -1095,6 +1117,7 @@ signing-certificates: X.509 signing certificates are an EC2-Classic SOAP credent
- `ApplyPendingMaintenanceAction`
- `AuthorizeDBSecurityGroupIngress`
- `BacktrackDBCluster`
- `CancelDBCapacityReservation`
- `CancelExportTask`
- `CopyDBClusterParameterGroup`
- `CopyDBClusterSnapshot`
@@ -1103,6 +1126,7 @@ signing-certificates: X.509 signing certificates are an EC2-Classic SOAP credent
- `CopyOptionGroup`
- `CreateBlueGreenDeployment`
- `CreateCustomDBEngineVersion`
- `CreateDBCapacityReservation`
- `CreateDBClusterEndpoint`
- `CreateDBClusterParameterGroup`
- `CreateDBClusterSnapshot`
@@ -1133,6 +1157,7 @@ signing-certificates: X.509 signing certificates are an EC2-Classic SOAP credent
- `DescribeAccountAttributes`
- `DescribeBlueGreenDeployments`
- `DescribeCertificates`
- `DescribeDBCapacityReservations`
- `DescribeDBClusterAutomatedBackups`
- `DescribeDBClusterBacktracks`
- `DescribeDBClusterEndpoints`
@@ -1174,6 +1199,7 @@ signing-certificates: X.509 signing certificates are an EC2-Classic SOAP credent
- `ModifyCertificates`
- `ModifyCurrentDBClusterCapacity`
- `ModifyCustomDBEngineVersion`
- `ModifyDBCapacityReservation`
- `ModifyDBClusterEndpoint`
- `ModifyDBClusterParameterGroup`
- `ModifyDBClusterSnapshotAttribute`
@@ -1243,6 +1269,71 @@ option-groups: Option groups configure engine add-ons for engines this platform
- `DescribeOptionGroups`
- `ModifyOptionGroup`
## route53
`2013-04-01` — 13 of 71 modelled operations implemented (18.3%), 0 stubbed, 0 not applicable, 0 registered outside the pinned model.
### Not implemented
- `ActivateKeySigningKey`
- `AssociateVPCWithHostedZone`
- `ChangeCidrCollection`
- `CreateCidrCollection`
- `CreateHealthCheck`
- `CreateKeySigningKey`
- `CreateQueryLoggingConfig`
- `CreateReusableDelegationSet`
- `CreateTrafficPolicy`
- `CreateTrafficPolicyInstance`
- `CreateTrafficPolicyVersion`
- `CreateVPCAssociationAuthorization`
- `DeactivateKeySigningKey`
- `DeleteCidrCollection`
- `DeleteHealthCheck`
- `DeleteKeySigningKey`
- `DeleteQueryLoggingConfig`
- `DeleteReusableDelegationSet`
- `DeleteTrafficPolicy`
- `DeleteTrafficPolicyInstance`
- `DeleteVPCAssociationAuthorization`
- `DisableHostedZoneDNSSEC`
- `DisassociateVPCFromHostedZone`
- `EnableHostedZoneDNSSEC`
- `GetAccountLimit`
- `GetCheckerIpRanges`
- `GetDNSSEC`
- `GetGeoLocation`
- `GetHealthCheck`
- `GetHealthCheckCount`
- `GetHealthCheckLastFailureReason`
- `GetHealthCheckStatus`
- `GetHostedZoneLimit`
- `GetQueryLoggingConfig`
- `GetReusableDelegationSet`
- `GetReusableDelegationSetLimit`
- `GetTrafficPolicy`
- `GetTrafficPolicyInstance`
- `GetTrafficPolicyInstanceCount`
- `ListCidrBlocks`
- `ListCidrCollections`
- `ListCidrLocations`
- `ListGeoLocations`
- `ListHealthChecks`
- `ListHostedZonesByVPC`
- `ListQueryLoggingConfigs`
- `ListReusableDelegationSets`
- `ListTrafficPolicies`
- `ListTrafficPolicyInstances`
- `ListTrafficPolicyInstancesByHostedZone`
- `ListTrafficPolicyInstancesByPolicy`
- `ListTrafficPolicyVersions`
- `ListVPCAssociationAuthorizations`
- `TestDNSAnswer`
- `UpdateHealthCheck`
- `UpdateHostedZoneFeatures`
- `UpdateTrafficPolicyComment`
- `UpdateTrafficPolicyInstance`
## s3
`2006-03-01` — 26 of 112 modelled operations implemented (23.2%), 0 stubbed, 6 not applicable, 0 registered outside the pinned model.
+14
View File
@@ -247,6 +247,20 @@
"option-groups": "Option groups configure engine add-ons for engines this platform does not offer, such as Oracle and SQL Server."
}
},
"route53": {
"slug": "route53",
"name": "Route 53",
"title": "Route 53 API Coverage",
"seoTitle": "Amazon Route 53 API Coverage on Spinifex — Spinifex Docs",
"description": "The Amazon Route 53 API operations Spinifex implements, covering the hosted zones it serves, their resource record sets, change status and tags on each zone.",
"tags": [
"aws",
"compatibility",
"coverage",
"route53",
"dns"
]
},
"s3": {
"slug": "s3",
"name": "S3",
+38
View File
@@ -0,0 +1,38 @@
---
title: "Route 53 API Coverage"
seoTitle: "Amazon Route 53 API Coverage on Spinifex — Spinifex Docs"
description: "The Amazon Route 53 API operations Spinifex implements, covering the hosted zones it serves, their resource record sets, change status and tags on each zone."
category: "Coverage"
sections:
- overview
tags:
- aws
- compatibility
- coverage
- route53
- dns
---
# Route 53 API Coverage
## Overview
Spinifex implements **13 operations** in the Route 53 `2013-04-01` API model.
### Operations
| Operation |
|---|
| `ChangeResourceRecordSets` |
| `ChangeTagsForResource` |
| `CreateHostedZone` |
| `DeleteHostedZone` |
| `GetChange` |
| `GetHostedZone` |
| `GetHostedZoneCount` |
| `ListHostedZones` |
| `ListHostedZonesByName` |
| `ListResourceRecordSets` |
| `ListTagsForResource` |
| `ListTagsForResources` |
| `UpdateHostedZoneComment` |
+7 -1
View File
@@ -163,11 +163,14 @@ Open between all hosts:
| Protocol and port | Used by |
|---|---|
| UDP 6081 | Geneve tunnels |
| UDP 500, 4500 and IP protocol 50 (ESP) | IPsec for the Geneve tunnels, while IPsec is on |
| TCP 4222, 4248 | NATS |
| TCP 6641, 6642 | OVN northbound and southbound |
| TCP 8443 | Predastore S3 gate |
| UDP 6660, 7660 | Object shards, metadata consensus |
UDP 4500 carries the tunnel traffic only where IKE detects NAT between two nodes; otherwise it travels as ESP, on Oracle Linux as on Debian.
Open between the three OVN database servers (servers 1 to 3) only:
| Protocol and port | Used by |
@@ -207,7 +210,10 @@ Spinifex's own **host** firewall is separate, and already carries this policy
Choose one method and apply it to **every** server in the cluster.
**Option A — existing OS.** On a server already running Ubuntu 26.04 or Debian 13 (Oracle Linux runs a single node only for now, see [Oracle Linux Hosts](/docs/install-oracle-linux)):
**Option A — existing OS.** Use this on servers already running Ubuntu 26.04, Debian 13 or Oracle Linux 10, and prepare an Oracle Linux server with [Oracle Linux Hosts](/docs/install-oracle-linux) first.
Every server in a cluster must run the same OS: Oracle Linux never shares a cluster with Debian or Ubuntu, and `spx admin join` does not check this for you.
Run the installer on each server:
```bash
curl -fsSL https://install.mulgadc.com | bash
+43 -7
View File
@@ -1,12 +1,13 @@
---
title: "Oracle Linux Hosts"
seoTitle: "Install Spinifex on Oracle Linux 10 — Spinifex Docs"
description: "Prepare an Oracle Linux 10 host for Spinifex: UEK R8, SELinux, a NetworkManager br-wan bridge, the CentOS NFV SIG repository, firewalld handover and updates."
description: "Prepare Oracle Linux 10 hosts for Spinifex: UEK R8, SELinux, a NetworkManager br-wan bridge, the NFV SIG repository, IPsec on libreswan and host updates."
category: "Install"
tags:
- install
- oracle linux
- single node
- multi node
resources:
- title: "Oracle Linux 10 Documentation"
url: "https://docs.oracle.com/en/operating-systems/oracle-linux/10/"
@@ -35,13 +36,12 @@ Follow it alongside [Single-Node Install](/docs/install).
**Supported on Oracle Linux:**
- A single node.
- A single node, or a cluster in which every node runs Oracle Linux; servers beyond the third join as compute nodes, as on Debian.
- Guests, VPCs, routed NAT, Elastic IPs, and Application and Network Load Balancers.
- Whole-GPU passthrough, on x86_64 GPU hosts (see [GPU Passthrough](/docs/gpu-passthrough)).
**Not supported on Oracle Linux yet:**
- Clusters of more than one node. They wait for IPsec on libreswan, so `spx admin init` turns IPsec off on Oracle Linux and refuses `--ipsec=true`.
- Clusters that mix Oracle Linux with Debian or Ubuntu nodes, on any release.
- SELinux enforcing, aarch64, Oracle Linux 9 or earlier, the Red Hat Compatible Kernel, a ZFS root, and NVIDIA MIG.
@@ -51,7 +51,7 @@ Spinifex installs `openvswitch3.5` and `ovn25.03`, the versions Debian 13 ships,
QEMU comes from Oracle's KVM Utilities repository, because only that build has the `microvm` machine type Spinifex load balancers boot on.
An Oracle Linux node must be a dedicated Spinifex host.
Spinifex claims every `tap*` device and stops libreswan's `ipsec.service`, so VPNs, hand-run QEMU guests and libreswan tunnels of your own cannot share the host.
Spinifex claims every `tap*` device and owns libreswan's `ipsec.service`, which it keeps stopped on a single node and runs for its own tunnels on a cluster node, so VPNs, hand-run QEMU guests and libreswan tunnels of your own cannot share the host.
Crypto policies other than `DEFAULT` and `FIPS` are untested, and so is Secure Boot.
## Prerequisites
@@ -134,8 +134,28 @@ Run the installer as a user with `sudo`, exactly as on Debian:
curl -fsSL https://install.mulgadc.com | bash
```
Then continue with [Single-Node Install](/docs/install) from Step 2: `setup-ovn.sh --management`, `spx admin init` and `systemctl start spinifex.target`.
`spx admin init` prints that IPsec is off on this host; on a single node there is no peer to encrypt traffic to.
For a single node, continue with [Single-Node Install](/docs/install) from Step 2: `setup-ovn.sh --management`, `spx admin init` and `systemctl start spinifex.target`.
IPsec stays off on a single node, because there is no peer to encrypt traffic to.
For a cluster, prepare and install every node this way, then continue with [Multi-Node Install](/docs/install-multi-node) from Step 2.
### Clusters
Every node in an Oracle Linux cluster runs Oracle Linux.
`install-node.sh` refuses a host list that mixes Oracle Linux with Debian or Ubuntu, but `spx admin join` does not check, so keep to one OS yourself when joining by hand.
IPsec encrypts the Geneve tunnels between nodes and is on by default, as on Debian.
On Oracle Linux it runs on libreswan, the IKE daemon Oracle ships, rather than strongSwan.
Once the node's IPsec certificate exists, the daemon unmasks and starts `ipsec.service` and `openvswitch-ipsec.service`.
`ovs-monitor-ipsec` then loads the node certificate, its key and the cluster CA into libreswan's NSS database at `/var/lib/ipsec/nss`, under nicknames that start with `ovs_cert`.
The tunnels are plain ESP (IP protocol 50), as on Debian, negotiated over UDP 500.
Open UDP 500, UDP 4500 and ESP between nodes: libreswan moves to UDP 4500 if it detects NAT between two nodes.
The Spinifex host firewall opens all three to cluster peers; a firewall between the nodes must allow them too.
While IKE to a peer fails, the node drops its Geneve traffic to that peer rather than send it in clear.
The nft table `inet spinifex_ipsec_guard` drops outbound UDP 6081 from the encap address whose route carries no SA, which also covers the moments pluto flushes the xfrm policy table as it starts and stops.
It is not persisted: `ipsec.service` puts it back at boot before ovn-controller builds the tunnels, and the daemon re-syncs it every 60 seconds.
`spx admin init --ipsec=false` leaves the tunnels unencrypted, as on any OS.
## Step 2. Know What the Installer Changed
@@ -182,7 +202,9 @@ Those advisories do not cover the NFV SIG packages, so follow Open vSwitch and O
## Step 5. Uninstall
`/usr/local/share/spinifex/uninstall-spx.sh` removes Spinifex as on Debian, and lists what it leaves in place on Oracle Linux: the repository changes and the SIG key, `OVS_USER_ID` and `OVN_USER_ID`, the OVS and OVN logrotate edits, the chrony include, `/etc/modules-load.d/spinifex.conf`, the SELinux mode, a masked `ipsec.service`, firewalld disabled, and any GPU passthrough configuration.
`/usr/local/share/spinifex/uninstall-spx.sh` removes Spinifex as on Debian.
On a cluster node it also stops `openvswitch-ipsec.service` and deletes the `ovs_cert*` entries from libreswan's NSS database, so the node can join a different cluster; other NSS identities and your connection files in `/etc/ipsec.d` stay.
It lists what it leaves in place on Oracle Linux: the repository changes and the SIG key, `OVS_USER_ID` and `OVN_USER_ID`, the OVS and OVN logrotate edits, the chrony include, `/etc/modules-load.d/spinifex.conf`, the SELinux mode, a masked `ipsec.service`, firewalld disabled, and any GPU passthrough configuration.
`--purge-deps` is refused on Oracle Linux, because dnf cannot tell the packages Spinifex installed from the host's own; remove them with `dnf remove` yourself.
## Troubleshooting
@@ -224,6 +246,20 @@ If it prints `yes`, run `sudo nmcli connection modify br-wan bridge.stp no` and
Check that `/usr/bin/spx` links to `/usr/local/bin/spx`; run the installer again if it does not, or call `sudo /usr/local/bin/spx` meanwhile.
### Cluster nodes form no IPsec tunnels
Check that both IPsec services run, and that libreswan holds the node's certificate and the cluster CA:
```bash
systemctl is-active ipsec.service openvswitch-ipsec.service
sudo certutil -L -d sql:/var/lib/ipsec/nss # ovs_certkey_<node>, ovs_cert_cacert
sudo ipsec trafficstatus # one line per peer tunnel
```
If the services are inactive, check the daemon log with `journalctl -u spinifex-daemon` for the IPsec reconcile.
If the certificates are present but no tunnel is listed, check that UDP 500 and ESP pass between the nodes in both directions, and look for `SEC_ERROR_UNKNOWN_ISSUER` in `journalctl -u ipsec`, which means a peer's certificate is from another CA.
Until the tunnel forms, guest traffic between those two nodes is dropped, which is the guard in [Clusters](#clusters) working.
### CA certificate not trusted
`sudo spx admin init` installs the CA automatically. To install it again by hand on Oracle Linux:
+1 -1
View File
@@ -39,7 +39,7 @@ This guide installs Spinifex on a single server using the binary installer. For
- Ubuntu 26.04
- Debian 13
- Oracle Linux 10.2 or a later 10.x, on UEK R8, x86_64 only, as a single node. Open vSwitch and OVN come from the CentOS NFV SIG, a community repository that Oracle does not support. Prepare the host with [Oracle Linux Hosts](/docs/install-oracle-linux) first.
- Oracle Linux 10.2 or a later 10.x, on UEK R8, x86_64 only, as a single node or, with [Multi-Node Install](/docs/install-multi-node), a cluster. Open vSwitch and OVN come from the CentOS NFV SIG, a community repository that Oracle does not support. Prepare the host with [Oracle Linux Hosts](/docs/install-oracle-linux) first.
**What Gets Installed:**
+29
View File
@@ -269,6 +269,35 @@ ip link set <dev> mtu 9000 # both ends
ping -M do -s 8972 -c 3 <peer> # must succeed
```
### The internet path
A guest MTU of 1408 or 1442 sits below the 1500 bytes the internet carries, so a server can send the guest segments it cannot receive.
The VPC gateway port carries `gateway_mtu = min(guest MTU, 1500)` and answers an oversized packet with ICMP fragmentation-needed (IPv4) or Packet Too Big (IPv6).
It follows `ipsec_enabled` and `network.underlay_mtu` and converges on the next reconcile pass, with no VPC recreate.
Path MTU discovery depends on that ICMP reaching the server, and an upstream NAT router or a CDN often drops it.
vpcd therefore also clamps the TCP MSS on the host hop to the gateway MTU minus 40 (IPv4) or minus 60 (IPv6), and re-asserts the rule every 30 seconds so a firewall reload cannot remove it for long.
| Uplink | Clamp | Table |
| --- | --- | --- |
| veth (distributed NAT) | yes | `bridge spinifex_mss`, matching the `veth-wan-br` port |
| routed NAT | yes | `inet spinifex_mss`, matching `spx-nat-host` |
| physical | no host hop exists | path MTU discovery only |
On a physical uplink the only cover is path MTU discovery, which fails when the upstream drops the ICMP or the peer ignores it.
Measured on one node (env12), IPsec on, Ubuntu 26.04 guest, 16 MB HTTPS download inside a Docker container at the default bridge MTU of 1500:
| Uplink | Without the clamp | With the clamp |
| --- | --- | --- |
| veth, instance with a public IP | stalled 3/3 | completed 3/3 |
| routed NAT | stalled 3/3, no ICMP seen on the host | completed 3/3 |
An instance behind a NAT gateway on the veth uplink also completed 3/3 with the clamp; its unclamped internet run was not measured.
The guest's own `curl` completed in every case.
Without the clamp, `gateway_mtu` alone fixed the download only against a server on the same L2 with no NAT in between (completed 3/3), and not against the internet through the lab's upstream NAT router.
Not measured: a physical uplink, a multi-node cluster, IPv6, and a Debian guest.
## 5. Measurement notes
Throughput on this stack is **noisy** — repeated identical 20s runs varied between
+6 -8
View File
@@ -99,9 +99,6 @@ behavior in other words.
| 4432 | Formation server | HTTPS | Cluster (bootstrap only) | Cluster join coordination; active only while a join token is valid. Binds the node's `--bind` (lan) address. See *Formation port lifecycle* below. | Short-lived bearer token + TLS¹ |
| 4222 | spinifex-nats (client) | NATS + TLS | Cluster | Internal service bus for EC2/EBS/VPC/S3 handlers | Token + mutual TLS (cluster CA) |
| 4248 | spinifex-nats (cluster) | NATS + TLS | Cluster | Inter-node NATS federation | Token + mutual TLS (cluster CA) |
| 4223 | spinifex-sqs-store (client) | NATS + TLS | Cluster | SQS message data, separate from the control-plane bus | Store-only token + TLS |
| 4249 | spinifex-sqs-store (cluster) | NATS + TLS | Cluster | Inter-node SQS store replication | Store-only token + mutual TLS (cluster CA) |
| 8223 | spinifex-sqs-store (monitor) | HTTP | Loopback | varz and jsz for the store | Loopback only |
| 5300 | northstar | DNS (UDP + TCP) | Cluster | Forward target for every node's per-instance DNS shim, dialled cross-node. Binds the wildcard by design. | None — restrict by firewall |
| 6660 | predastore (blob node) | QUIC / UDP | Cluster | Erasure-coded object shard transport between hosts. Multi-node clusters only — see *Predastore ports* below. | TLS 1.3, server certificate verified against the cluster CA |
| 7660 | predastore (meta node) | QUIC / UDP | Cluster | Raft consensus over global state — buckets and the object index — between hosts. Multi-node clusters only. | TLS 1.3, server certificate verified against the cluster CA |
@@ -111,8 +108,8 @@ behavior in other words.
| 6643 | OVN Northbound DB (RAFT) | OVSDB/TCP | Cluster | NB database RAFT replication between the 3 quorum nodes | Cluster network only; TLS planned |
| 6644 | OVN Southbound DB (RAFT) | OVSDB/TCP | Cluster | SB database RAFT replication between the 3 quorum nodes | Cluster network only; TLS planned |
| 6081 | OVN (Geneve) | UDP | Encap | Tenant traffic overlay between chassis. A kernel UDP-tunnel socket, so packets are delivered locally and traverse the host's netfilter input hook before OVS sees them — a host firewall must accept them explicitly. The socket is opened by the kernel tunnel driver and takes no bind address, so it binds the wildcard by design and reach must be restricted by firewall. | None — see 500/4500 |
| 500, 4500 | strongSwan `charon` | IKEv2 / UDP | Encap | IKE and NAT-T for the IPsec protecting Geneve, managed entirely by `ovs-monitor-ipsec`. Binds the wildcard by design (the upstream strongSwan default, accepted rather than overridden), so reach must be restricted by firewall. | Certificate-based, against the cluster CA |
| — | ESP | IP proto 50 | Encap | The IPsec payload itself, once IKE has negotiated an SA | Cluster CA |
| 500, 4500 | strongSwan `charon`, or libreswan `pluto` on Oracle Linux | IKEv2 / UDP | Encap | IKE and NAT-T for the IPsec protecting Geneve, managed entirely by `ovs-monitor-ipsec`; 4500 carries the ESP payload only where IKE detects NAT between two nodes. `charon` binds the wildcard by design (the upstream strongSwan default, accepted rather than overridden); `pluto` binds each host address separately, the WAN address included, which is no narrower. Either way reach must be restricted by firewall. | Certificate-based, against the cluster CA |
| — | ESP | IP proto 50 | Encap | The IPsec payload itself, once IKE has negotiated an SA, on Oracle Linux as on Debian. | Cluster CA |
| 8222 | spinifex-nats (monitoring) | HTTP | Localhost | `varz`/`subsz` metrics consumed by the daemon | Loopback only |
| 323 | chronyd | NTP | Localhost | Time sync client control socket | Loopback only |
| 169.254.169.254:80 | spinifex-vpcd (IMDS) | HTTP | Guest | Instance metadata service. One socket per instance, bound to that instance's `ime-*` interface. Terminates on the host, so it traverses the netfilter input hook. | Instance identity by interface; IMDSv2 tokens |
@@ -177,7 +174,7 @@ Control-plane and data-plane traffic between Spinifex nodes, for completeness an
| OVN NB/SB (client) | 6641 / 6642 | Cluster network only (TLS planned) | Network control plane; vpcd and ovn-controller dial the quorum |
| OVN NB/SB (RAFT) | 6643 / 6644 | Cluster network only (TLS planned) | NB/SB database replication across the 3 quorum nodes |
| OVN tunnels (Geneve) | UDP 6081 | Encapsulated by IPsec when `network.ipsec_enabled` is true, which is the default on multi-node clusters | Tenant traffic overlay between chassis, on the `vpc` plane |
| OVN IPsec (IKE / NAT-T / ESP) | UDP 500, UDP 4500, IP proto 50 | Certificate-based against the cluster CA, negotiated by `ovs-monitor-ipsec` | Protects the Geneve tunnels above. OVN-native only — no layer manages strongSwan directly. |
| OVN IPsec (IKE / NAT-T / ESP) | UDP 500, UDP 4500, IP proto 50 | Certificate-based against the cluster CA, negotiated by `ovs-monitor-ipsec` | Protects the Geneve tunnels above. OVN-native only: no layer configures strongSwan or libreswan directly. On Oracle Linux the daemon also blocks outbound Geneve that no SA covers, so a peer failing IKE gets no plaintext. |
| Instance DNS forwarding | UDP/TCP 5300 | None | Each node's per-instance DNS shim forwards guest queries to peer nodes' northstar `:5300` |
Nodes **must** sit on a network segment that is not routed to tenant/guest VMs or to the internet. The Predastore blob and meta transports and the OVN DBs are cluster-internal and must not be reachable from anywhere else.
@@ -224,7 +221,7 @@ table inet spinifex_filter {
# Cluster, from peer nodes only. Replace with your nodes' lan-plane
# addresses — a CIDR does not generalise to nodes on different subnets.
ip saddr { 10.0.1.1, 10.0.1.2, 10.0.1.3 } tcp dport { 4222, 4223, 4248, 4249, 4432, 5300, 6641, 6642, 6643, 6644, 8660 } accept
ip saddr { 10.0.1.1, 10.0.1.2, 10.0.1.3 } tcp dport { 4222, 4248, 4432, 5300, 6641, 6642, 6643, 6644, 8660 } accept
ip saddr { 10.0.1.1, 10.0.1.2, 10.0.1.3 } udp dport { 5300, 6660, 7660 } accept
# Encap, from peer chassis only. Replace with your nodes' vpc-plane
@@ -239,6 +236,7 @@ Notes that make the difference between this working and locking you out:
- **Use a dedicated table and never flush the others.** `vpcd` writes MASQUERADE and per-EIP FORWARD rules into the `ip filter` and `ip nat` tables, and only reinstalls them when the service starts. `iptables -F`, `nft flush ruleset`, `ufw enable` and `firewalld` all destroy them silently, and instance networking stays broken until the next restart.
- **Do not add a `forward` hook.** nftables evaluates every table registered on a hook and any `drop` is final, so a default-deny forward chain here would override `vpcd`'s accepts in the other table and break every routed-NAT instance and every EIP. Filtering forwarded guest traffic is the security group's job, not the host firewall's.
- **`vpcd` owns a second table named `spinifex_mss`, separate from `inet spinifex_filter`.** It is a `bridge` table on the veth uplink and an `inet` table on the routed uplink, it has a `forward` hook with policy `accept`, and it only rewrites the MSS option of TCP SYN packets on the host hop to the gateway. It never drops or accepts traffic and does not replace the forward-hook advice above. `vpcd` re-installs it every 30 seconds, so a flush is repaired within that interval, and there is none on a physical uplink.
- **`output` is deliberately untouched.** The metadata service's reply path egresses through per-instance policy routing; filtering `output` breaks it in ways that are hard to attribute.
- **On a single-NIC node the cluster and encap sets are the node's public addresses**, because the planes collapse. The rules are still correct, but they are no longer a boundary — an upstream ACL is the only real control there.
@@ -250,7 +248,7 @@ Every listener and outbound destination is controlled by one of these files. Cha
| File | Keys | Controls |
|------|------|----------|
| `/etc/spinifex/spinifex.toml` | `nodes.<node>.{awsgw,nats,predastore,daemon}.host`, `nodes.<node>.vpcd.ovn_{nb,sb}_addr`, `nodes.<node>.daemon.dev_networking`, `network.ipsec_enabled` | Per-service bind addresses/ports; dev-mode QEMU port forwarding. `network.ipsec_enabled` (default `true`) decides whether OVN-native IPsec protects the Geneve tunnels, and therefore whether `charon` listens on 500/4500 — single-node clusters never enable it. |
| `/etc/spinifex/spinifex.toml` | `nodes.<node>.{awsgw,nats,predastore,daemon}.host`, `nodes.<node>.vpcd.ovn_{nb,sb}_addr`, `nodes.<node>.daemon.dev_networking`, `network.ipsec_enabled` | Per-service bind addresses/ports; dev-mode QEMU port forwarding. `network.ipsec_enabled` (default `true`) decides whether OVN-native IPsec protects the Geneve tunnels, and therefore whether the IKE daemon (`charon`, or `pluto` on Oracle Linux) listens on 500/4500 — single-node clusters never enable it. |
| `/etc/spinifex/nats.conf` | `listen`, `cluster.listen`, `cluster.routes`, `http`, `tls`, `cluster.authorization` | NATS client/cluster/monitoring listeners, peer routes, TLS, cluster token. |
| `/etc/spinifex/predastore/predastore.toml` | `[[host]].bind_addr`, `[[host]].addr`, `[[host]].tls_cert`, `[[host]].tls_key`, `[[host.node]].role`, `[[host.node]].port` | Predastore host and node layout: `bind_addr` is the address the host's sockets bind, `addr` is the address peer hosts dial it on, and both carry no port — the nodes pinned to the host supply their own. The service's `--host`/`--port` override the bind address and the gate's S3 port. |
| `/etc/spinifex/northstar/northstar.toml` | `listen`, `forwarders` | DNS listen addresses (`:53` on the advertise address, `:5300` wildcard) and upstream resolvers. |
-521
View File
@@ -1,521 +0,0 @@
# Service interface manifest.
#
# Source of truth for "which services produce/consume which NATS subjects"
# and "which e2e suites cover which services". Consumed by
# tests/e2e/select-suites (Bead 4) to derive targeted suite selection from
# a PR diff.
#
# See docs/development/improvements/e2e-targeted-suite-selection.md.
#
# Editing rules
# -------------
# - subscribes / publishes: subjects this service receives / sends. Both
# lists use the literal subject string, or a "<prefix>.*" wildcard for
# pattern subscriptions (e.g. "ebs.snapshot.*").
# - depends_on: subjects this service requires from peers. Used by the
# selector to compute "if X changes, which suites might break".
# - Subjects must match what is actually referenced in source. The lint
# in Bead 5 greps `"(ec2|vpc|ebs|s3|iam|elbv2|spinifex)\.[a-zA-Z._-]+"`
# and fails if any subject is absent from this manifest.
version: 1
services:
awsgw:
path: spinifex/runtime/roles/awsgw
# TLS frontend, SigV4 auth. Pure router: HTTP → NATS request/response.
# No subscribes; only publishes (via NATSRequest).
subscribes: []
publishes:
- "ec2.*"
- "elbv2.*"
- "acm.*"
- "route53.*"
- "iam.*"
- "rds.*"
- "ecs.*"
- "ochre.vector.*"
depends_on:
- "ec2.*"
- "elbv2.*"
- "acm.*"
- "route53.*"
- "iam.*"
- "rds.*"
- "ecs.*"
- "ochre.vector.*"
spinifex_daemon:
path: spinifex/runtime/roles/spinifex
# Orchestrator. Owns most ec2.* + all elbv2.* + iam event publication.
# Translates VPC / EBS / S3 operations into vpc.* / ebs.* / s3.* and
# forwards to vpcd / viperblockd / predastore.
#
# Source layout: spinifex/runtime/roles/spinifex (entry), spinifex/runtime/daemon
# (subscribe registration), spinifex/domains/<domain> and the legacy
# spinifex/handlers/{ecs,eks,elbv2,iam,rds} packages (per-subject handler
# bodies). Selector treats these paths as belonging to this service via
# the `additional_paths` field.
additional_paths:
- spinifex/runtime/daemon
- spinifex/domains/ec2/account
- spinifex/domains/ec2/eigw
- spinifex/domains/ec2/eip
- spinifex/domains/ec2/igw
- spinifex/domains/ec2/image
- spinifex/domains/ec2/instance
- spinifex/domains/ec2/key
- spinifex/domains/ec2/launchtemplate
- spinifex/domains/ec2/natgw
- spinifex/domains/ec2/placementgroup
- spinifex/domains/ec2/routetable
- spinifex/domains/ec2/snapshot
- spinifex/domains/ec2/spotinstance
- spinifex/domains/ec2/tags
- spinifex/domains/ec2/volume
- spinifex/domains/ec2/volumestate
- spinifex/domains/ec2/vpc
- spinifex/domains/elbv2
- spinifex/domains/acm
- spinifex/domains/route53
- spinifex/domains/iam
- spinifex/domains/ecs
- spinifex/domains/rds/instance
- spinifex/domains/rds/subnetgroup
- spinifex/domains/rds/parametergroup
- spinifex/domains/rds/engine
- spinifex/domains/ecs/taskdefinition
- spinifex/domains/eks/cluster
- spinifex/domains/eks/access
- spinifex/domains/eks/addon
# Guest add-on wire contract (subject, report, staged-manifest body).
- contracts/eks/v1
# ECS agent/scheduler Layer-2 bus wire contract (subjects, message types).
- contracts/ecs/v1
- spinifex/domains/ochre/vector
subscribes:
# EC2 instance lifecycle.
- "ec2.RunInstances"
- "ec2.DescribeInstances"
- "ec2.DescribeStoppedInstances"
- "ec2.DescribeTerminatedInstances"
- "ec2.DescribeInstanceAttribute"
- "ec2.DescribeInstanceStatus"
- "ec2.DescribeInstanceTypes"
- "ec2.ModifyInstanceAttribute"
- "ec2.ModifyVolume"
- "ec2.start"
- "ec2.terminate"
- "ec2.launch"
# contracts/ec2/v1 owns the command payload and this subject vocabulary.
# The wildcard is intentionally one final instance-ID token, not `>`.
- "ec2.cmd.*"
# EC2 VPC / network ops handled by daemon before delegation to vpcd.
- "ec2.CreateVpc"
- "ec2.DescribeVpcs"
- "ec2.CreateSubnet"
- "ec2.CreateInternetGateway"
- "ec2.CreateKeyPair"
- "ec2.CreateImage"
- "ec2.DescribePlacementGroups"
# Cluster coordination subjects.
- "ec2.ReserveClusterNode"
- "ec2.FinalizeClusterInstances"
# An EIP association reaches whichever node holds the instance, which is
# rarely the node that served the request (fan-out, no queue group).
- "ec2.eni.public-ip-changed"
# contracts/cluster/v1 owns the node status/capacity payload and subject.
- "spinifex.node.status"
# contracts/cluster/v1 owns the VM inventory payload and subject.
- "spinifex.node.vms"
- "spinifex.nodes.discover"
- "spinifex.cluster.shutdown.progress"
# Coordinated cluster-shutdown phases (fan-out, no queue group).
- "spinifex.cluster.shutdown.gate"
- "spinifex.cluster.shutdown.drain"
- "spinifex.cluster.shutdown.storage"
- "spinifex.cluster.shutdown.persist"
- "spinifex.cluster.shutdown.infra"
# Operator-plane Predastore topology contract (contracts/operator/v1).
- "spinifex.storage.config"
# ELBv2 control plane (domains/elbv2/service_nats.go topic map).
- "elbv2.CreateLoadBalancer"
- "elbv2.DeleteLoadBalancer"
- "elbv2.DescribeLoadBalancers"
- "elbv2.CreateTargetGroup"
- "elbv2.DeleteTargetGroup"
- "elbv2.DescribeTargetGroups"
- "elbv2.RegisterTargets"
- "elbv2.DeregisterTargets"
- "elbv2.DescribeTargetHealth"
- "elbv2.CreateListener"
- "elbv2.DeleteListener"
- "elbv2.DescribeListeners"
# ACM-alike certificate store (domains/acm; subscribed in daemon.go).
- "acm.ImportCertificate"
- "acm.DescribeCertificate"
- "acm.ListCertificates"
- "acm.DeleteCertificate"
# Route 53 hosted zones (domains/route53; subscribed in daemon.go).
- "route53.*"
# Operator-only zone activation and delegation, sent by spx admin dns.
- "route53.admin.*"
- "elbv2.AddListenerCertificates"
- "elbv2.RemoveListenerCertificates"
- "elbv2.DescribeListenerCertificates"
- "elbv2.DescribeSSLPolicies"
# RDS control plane (domains/rds/instance/subjects.go). One wildcard rather than
# 30 literals: every action subject is registered from a constant, so a
# per-subject list would be a hand-mirror of that file with nothing
# checking it.
- "rds.*"
# ECS control plane (domains/ecs/service_nats.go, registered in
# daemon.go). One wildcard, as for RDS: every action subject is a literal
# in those two files, so a per-subject list would only mirror them.
- "ecs.*"
# Layer-2 agent bus: register and health are wildcard subscriptions over
# {account}.{db-instance}, so the subject is built at runtime and only the
# prefix is declarable.
- "rds.bus.*"
# ECS Layer-2 scheduler/agent bus (contracts/ecs/v1): account, cluster and
# instance/task IDs are runtime-built, so only the prefix is declarable.
# Only the leader holds these subscriptions, so there is no queue group.
- "ecs.bus.*"
# Ochre vector store tenant surface (domains/ochre/vector/api.go),
# gated behind config.OchreVectorConfig.Enabled (default off).
- "ochre.vector.createIndex"
- "ochre.vector.deleteIndex"
- "ochre.vector.listIndexes"
- "ochre.vector.ingest"
- "ochre.vector.describeJob"
- "ochre.vector.query"
- "ochre.vector.listJobs"
- "ochre.vector.stopJob"
# Fence route owned by contracts/viperblockd/legacy/v1: viperblockd
# announces a torn-down export so this node stops the guest that was
# using it. "ebs.fenced" is the single-node form; a named node is
# addressed as "ebs.<node>.fenced".
- "ebs.fenced"
publishes:
# → vpcd
- "vpc.create"
- "vpc.delete"
- "vpc.create-subnet"
- "vpc.delete-subnet"
- "vpc.create-sg"
- "vpc.delete-sg"
- "vpc.update-sg"
- "vpc.create-port"
- "vpc.delete-port"
- "vpc.update-port-sgs"
# EC2-to-network projection owned by contracts/ec2/v1. The network
# subscriber realizes it in OVN without reading EC2 private state.
- "vpc.igw-attach"
- "vpc.igw-detach"
- "vpc.add-nat-gateway"
- "vpc.delete-nat-gateway"
- "vpc.add-nat"
- "vpc.delete-nat"
- "vpc.dhcp.acquire"
- "vpc.dhcp.release"
# Per-subnet route-table projections owned by domains/ec2/routetable:
# IGW default-route reachability and the egress gate/ungate decision it
# implies, published fire-and-forget on every route-table mutation.
- "vpc.add-igw-route"
- "vpc.delete-igw-route"
- "vpc.gate-subnet-egress"
- "vpc.ungate-subnet-egress"
# Egress-only reroute removal for a single system instance
# (domains/eks/cluster). Fire-and-forget. vpc.add-system-egress has no
# current producer.
- "vpc.delete-system-egress"
# → viperblockd
# The migrated legacy ebs.config and ebs.sync payloads and routes are
# owned by contracts/viperblockd/legacy/v1. They are transitional and
# distinct from the generic providers/ebs protocol; other legacy ebs.*
# routes remain to be migrated separately.
- "ebs.mount"
- "ebs.unmount"
- "ebs.delete"
- "ebs.sync"
- "ebs.snapshot.*"
- "ebs.config"
- "ebs.config.*"
# Abandon route owned by contracts/viperblockd/legacy/v1: the daemon
# asks viperblockd to tear an export down without sealing. "ebs.abandon"
# is the single-node form; a named node addresses "ebs.<node>.abandon".
- "ebs.abandon"
# → predastore (S3 / AMI / keypair store)
- "s3.*"
# → every daemon, so the one holding the instance reports the new address
- "ec2.eni.public-ip-changed"
# IAM lifecycle event
- "iam.account.created"
# → the in-guest rds-agent: set-password and the like, published on the
# per-instance command subject and answered on its reply subject.
- "rds.bus.*"
# ECS service-reconcile self-wake (contracts/ecs/v1): the worker that
# recorded a task-state change is rarely the leader, so this fans the
# wake out and the leader's own "ecs.bus.*" subscription picks it up.
- "ecs.bus.*"
depends_on:
- "vpc.*"
- "ebs.*"
- "s3.*"
vpcd:
path: spinifex/runtime/roles/vpcd
# OVN-backed VPC / subnet / SG / NAT / IGW data plane.
subscribes:
- "vpc.create"
- "vpc.delete"
- "vpc.create-subnet"
- "vpc.delete-subnet"
- "vpc.create-sg"
- "vpc.delete-sg"
- "vpc.update-sg"
- "vpc.create-port"
- "vpc.delete-port"
- "vpc.update-port-sgs"
- "vpc.igw-attach"
- "vpc.igw-detach"
- "vpc.add-nat-gateway"
- "vpc.delete-nat-gateway"
- "vpc.add-nat"
- "vpc.delete-nat"
- "vpc.dhcp.acquire"
- "vpc.dhcp.release"
- "vpc.add-igw-route"
- "vpc.delete-igw-route"
- "vpc.gate-subnet-egress"
- "vpc.ungate-subnet-egress"
- "vpc.add-system-egress"
- "vpc.delete-system-egress"
publishes:
- "vpc.port-status"
- "vpc.dhcp.lease-changed"
- "vpc.dhcp.owner-check"
depends_on: []
viperblockd:
path: spinifex/runtime/roles/viperblockd
# EBS data plane. Backed by the viperblock storage engine in the
# mulga/viperblock submodule. Submodule pointer bump → selector forces
# "run all" (contract changes can be invisible at the spinifex source
# level).
# The migrated legacy ebs.config and ebs.sync routes are versioned by
# contracts/viperblockd/legacy/v1, not by providers/ebs. Other legacy
# ebs.* routes remain to be migrated separately.
# The dirty-marker JetStream KV bucket VIPERBLOCK_VOLUME_DIRTY is owned
# by the same contract; not a NATS subject, so not listed below.
#
# Source layout: spinifex/runtime/roles/viperblockd (role: process lifecycle,
# NATS subscription wiring) and spinifex/providers/ebs/viperblock (the
# Viperblock EBS provider adapter the role wires up). Selector treats the
# adapter path as belonging to this service via `additional_paths`.
additional_paths:
- spinifex/providers/ebs/viperblock
subscribes:
- "ebs.mount"
- "ebs.unmount"
- "ebs.delete"
- "ebs.sync"
- "ebs.snapshot.*"
- "ebs.config"
- "ebs.config.*"
# Abandon route (contracts/viperblockd/legacy/v1): request/reply from
# the daemon, answered by the Viperblock adapter on the named node.
- "ebs.abandon"
publishes:
- "ebs.mount.response"
# Fence route (contracts/viperblockd/legacy/v1): announced after the
# Viperblock adapter tears an export down because its lease moved.
- "ebs.fenced"
- "ebs.unmount.response"
depends_on: []
predastore:
path: spinifex/runtime/roles/predastore
# S3 / AMI / keypair storage. External submodule under mulga/predastore;
# this dir contains only the spinifex-side adapter. Submodule pointer
# bump → selector forces "run all".
subscribes:
- "s3.*"
publishes: []
depends_on: []
nats:
path: spinifex/runtime/roles/nats
# NATS broker bootstrap / cluster config. Not a NATS pub/sub
# participant. Any change here is infra-path; selector forces
# "run all".
subscribes: []
publishes: []
depends_on: []
spinifexui:
path: spinifex/runtime/roles/spinifexui
# Web UI. Talks to awsgw over HTTP, never NATS. Diffs limited to this
# dir do not need any e2e suite to run.
subscribes: []
publishes: []
depends_on: []
covers_no_suite: true
# Fixture → service mapping. Each Ensure* helper in
# tests/e2e/harness/fixtures.go exercises this set of services; the
# Bead 4 selector AST-scans test files for harness.Ensure* call sites
# and resolves coverage via this block.
fixtures:
EnsureKeyPair:
services: [spinifex_daemon, predastore]
EnsureAMI:
services: [spinifex_daemon, predastore, viperblockd]
EnsureDefaultVPC:
services: [vpcd, spinifex_daemon]
EnsureSubnet:
services: [vpcd, spinifex_daemon]
EnsureSG:
services: [vpcd, spinifex_daemon]
EnsureInstance:
services: [spinifex_daemon, viperblockd, vpcd]
EnsureVolume:
services: [spinifex_daemon, viperblockd]
EnsureSnapshot:
services: [spinifex_daemon, viperblockd, predastore]
EnsureNATGateway:
services: [vpcd, spinifex_daemon]
EnsureLoadBalancer:
services: [spinifex_daemon, awsgw]
suites:
e2e-cert:
path: tests/e2e/cert
covers: [awsgw, spinifex_daemon, vpcd, predastore]
e2e-lb:
path: tests/e2e/lb
covers: [awsgw, spinifex_daemon, vpcd, viperblockd, predastore]
# Exercises full ELBv2 control plane (domains/elbv2) + data plane
# (LB agent on the system VM). ELBv2 is part of spinifex_daemon in
# this manifest; revisit if it splits into a standalone service.
e2e-single:
path: tests/e2e/single
covers: [awsgw, spinifex_daemon, vpcd, viperblockd, predastore]
# Full single-node lifecycle: VPC, AMI, keypair, instance, volume,
# snapshot, IAM. Currently runs all phases sequentially under one
# `TestSingleNode`. Bead 3 (single-fixture-migration) splits this
# into per-test fixture-driven coverage so the selector can pick
# subtests, not just whole suites.
e2e-iam:
path: tests/e2e/iam
covers: [awsgw, spinifex_daemon, vpcd, viperblockd, predastore]
# "Everything IAM": the IAM/STS control plane plus the instance-identity
# tests (instance-profile association + IMDS), which boot guest VMs — OVN
# via vpcd, AMI via predastore, root volume via viperblockd, lifecycle via
# spinifex_daemon. Single-mode, VM-booting leg.
e2e-ecr:
path: tests/e2e/ecr
covers: [awsgw, spinifex_daemon, predastore]
# ECR control plane (domains/ecr, gateway/ecr*) + OCI data plane: blobs and
# manifests in predastore, control + auth bridge in awsgw, KV via
# spinifex_daemon. docker/crane/skopeo data-plane subtests skip when the
# client binary or registry-host resolution is absent.
e2e-bedrock:
path: tests/e2e/bedrock
covers: [awsgw]
# Ochre (AWS-Bedrock) control plane: static catalog list/get served
# straight from awsgw (gateway/bedrock.go, domains/ochre/) — no NATS
# hop to spinifex_daemon. Converse (Anthropic-direct / self-host vLLM)
# subtests are gated on backend availability and skip by default.
e2e-ecs:
path: tests/e2e/ecs
covers: [awsgw, spinifex_daemon, vpcd, viperblockd, predastore]
# ECS data plane end-to-end: cluster + a real container instance launched
# from the spinifex-ecs-node AMI (boots, registers over the gateway, runs
# tasks through containerd), standalone tasks in awsvpc/bridge/host modes,
# and an awsvpc service fronted by an ELBv2 target group. Boots a guest VM,
# so vpcd (OVN + awsvpc ENI), viperblockd (root volume), predastore (AMI)
# are all in the path.
e2e-rds:
path: tests/e2e/rds
covers: [awsgw, spinifex_daemon, vpcd, viperblockd, predastore]
# RDS create/describe/connect: CreateDBInstance boots a dual-NIC DB VM from
# the engine AMI (predastore) with a Viperblock data volume (viperblockd) and
# a customer ENI (vpcd), and the reconciler flips it to available on the
# in-guest agent's first healthy heartbeat over awsgw. Every test deletes the
# instances it creates, so the suite needs no gate beyond SPINIFEX_E2E.
e2e-ddil:
path: tests/e2e/ddil
covers: [awsgw, spinifex_daemon, vpcd, viperblockd, predastore]
# DDIL = degraded / disconnected / intermittent / low-bandwidth.
# Exercises the full daemon stack under network failure injection;
# any service touching the wire path is in scope.
e2e-route53:
path: tests/e2e/route53
covers: [awsgw, spinifex_daemon, predastore]
# Hosted zones end to end: delegation check, publish to the northstar zone
# store in predastore, and every node answering before a change is INSYNC.
# Suites below are bash-only today. Entries activate as the Go ports
# land (Bead 2 fixtures unblock new-shape ports for multinode etc.).
#
# e2e-multinode:
# path: spinifex/tests/e2e/multinode
# covers: [awsgw, spinifex_daemon, vpcd, viperblockd, predastore]
# e2e-reboot:
# path: spinifex/tests/e2e/reboot
# covers: [spinifex_daemon, viperblockd]
# e2e-tofu:
# path: spinifex/tests/e2e/tofu
# covers: [awsgw, spinifex_daemon, vpcd]
# e2e-bm:
# path: spinifex/tests/e2e/baremetal
# covers: [awsgw, spinifex_daemon, vpcd, viperblockd, predastore]
#
# e2e-natuplink activates once CI can provision an external_mode=nat
# node (setup-ovn.sh --nat-uplink + spx admin init --external-mode=nat).
# e2e-natuplink:
# path: spinifex/tests/e2e/natuplink
# covers: [awsgw, spinifex_daemon, vpcd, viperblockd, predastore]
# Special-case rules consumed by the selector (Bead 4). Hard-coded in
# the tool, listed here for visibility.
#
# infra_paths_force_all:
# - spinifex/runtime/roles/nats/**
# - spinifex/tests/e2e/harness/**
# - spinifex/tests/e2e/lib/**
# - spinifex/.github/workflows/e2e-go.yml
# - spinifex/.github/scripts/**
# - spinifex/Makefile
# - mulga/scripts/tofu-cluster/**
#
# submodule_force_all:
# - mulga/predastore (submodule pointer)
# - mulga/viperblock (submodule pointer)
#
# many_services_force_all_threshold: 3 # ≥ N services touched → run all
+15 -2
View File
@@ -40,7 +40,7 @@ Runs **on** a node, shelling out to `ip`/`iptables`/`ovn-nbctl` locally. Skips u
Phases 7 and 8 are gated on a cluster by what the datapath can actually do: the transit segment is a localnet, so host-to-guest Tier 1 ingress only exists where the VPC's gateway router port and the guest are both on this host. The VPC ingress route itself is installed on every node, so phase 8 checks it unconditionally.
Three nightly cells run it: `nat-single` (cell 19), its Oracle Linux twin (cell 36) and `nat-multi` (cell 30). Cell 20 is the bare-metal job and is not in the permutation matrix at all, which is why the multi-node NAT cell is numbered past the end rather than filling a gap.
Four nightly cells run it: `nat-single` (cell 19) and `nat-multi` (cell 30), and their Oracle Linux twins (cells 36 and 38). Cell 20 is the bare-metal job and is not in the permutation matrix at all, which is why the multi-node NAT cell is numbered past the end rather than filling a gap.
### `multinode` — behaviour that only exists on more than one node
@@ -50,6 +50,17 @@ Three nightly cells run it: `nat-single` (cell 19), its Oracle Linux twin (cell
**`TestMultinodeDNS` convergence budgets are env-tunable.** `SPINIFEX_MULTINODE_DNS_TOPOLOGY_TIMEOUT` (default `90s`) bounds every node agreeing on the NS topology, `SPINIFEX_MULTINODE_DNS_TIMEOUT` (default `2m`) bounds a record becoming answerable from a guest or a node, and `SPINIFEX_MULTINODE_DNS_POLL` (default `3s`) is the poll interval for both. Values are Go durations; a malformed or non-positive value panics rather than falling back to the default.
**`TestMultinodeOVNRaft` restarts each node's OVN central in turn before it kills the NB leader.**
A `setup-ovn.sh` rerun restarts a node's NB and SB members together, on EL through the one `ovn-northd.service`, so the test does the same on every database node in turn, waits for both members to be back as cluster members under a known leader, and requires an NB write to land after each step and the full quorum with one leader at the end.
**`TestMultinodeIPSec` proves guest traffic is encrypted on the wire, not only that IPsec is configured.**
It first requires every node to carry the OVS certificate, key and CA pointers, `NB_Global.ipsec=true` never asserted over an unconfigured chassis, and an AES-GCM SA in `ip xfrm state`.
None of those fails when SAs exist but traffic bypasses them, so it then launches two instances on distinct nodes and sends 200 pings of 1000 bytes between them.
For each direction of that pair it fails unless the outbound SAs to the peer's `ovn-encap-ip` carried at least the payload sent, an outbound-only capture on the interface routing to the peer saw ESP (raw or UDP/4500-encapsulated), and that capture saw no plaintext Geneve (UDP/6081).
The capture is outbound-only because inbound ESP is decrypted and seen again as Geneve on the same interface, which would read as plaintext on a healthy node.
It proves the node pair the guests landed on; the SA check covers every node.
It skips when the node config sets `network.ipsec_enabled=false`, or when the scheduler places all four launches on one node.
### `kvquorum` — the cluster's own state survives losing a node (multi-node, needs three)
**The control plane keeps its state in JetStream KV buckets, and this is the suite that proves those buckets are actually replicated.** A bucket on one replica lives on one JetStream-chosen server recorded in no config, so losing that node makes it return `nats: no responders available for request` from *every* node at once — a cluster-wide outage caused by a single-node event. Leader leases are the worst case: acquiring one is a write, so a lease bucket without a quorum cannot be acquired by anyone, and every reconciler sharing it stops cluster-wide at exactly the moment a node has failed and there is repair work to do.
@@ -104,6 +115,9 @@ CA installed and downloadable, fingerprint matches config, hostnames present in
Internal and internet-facing ALB and NLB, HTTPS listeners, listener rules, `ModifyListener`, and NLB UDP.
Unhealthy-target routing: an NLB with an HTTP health check on a TCP target group stops sending traffic to a target that fails it while still accepting TCP, sends traffic again once it recovers, and fails open when every target is unhealthy.
An ALB whose targets all fail their health check keeps serving from them instead of answering 503.
### `eks`, `ecs`, `ecr` — container services
`eks`: cluster create/delete, access entries, addon delivery, `GetToken`, kubeconfig artifacts, the EBS CSI volume path, and an IRSA pod wired with a projected SA token. `ecs`: clusters, container instances, the three network modes (`awsvpc`, `bridge`, `host`), task-role credentials fetched from the container credentials endpoint, a service behind an ELB, and ENI release on terminate. `ecr`: control-plane CRUD plus a real OCI data plane — docker login/build/push/pull round trips, multi-layer images, and raw blob HTTP.
@@ -129,7 +143,6 @@ The create/describe/connect path against a live cluster: a DB VM that actually b
- **`ddil`** — Denied, Disrupted, Intermittent, Limited network scenarios.
- **`teardown`** — sweeps leftovers.
- **`harness`** — shared primitives, not a suite. `harness/vpc_egress.go` carries the VPC/subnet/IGW/NAT-gateway fixtures the egress scenarios build on.
- **`manifest-check`, `manifest-lint`** — manifest validation, not cluster behaviour.
## Keeping this honest
+2 -2
View File
@@ -77,8 +77,8 @@ func TestEC2ErrorCatalogMetadataAndClasses(t *testing.T) {
if catalog.metadata.VerifiedOn == "" {
t.Fatal("verifiedOn is empty")
}
if len(catalog.codes) != 114 {
t.Fatalf("catalog code count = %d, want 114", len(catalog.codes))
if len(catalog.codes) != 115 {
t.Fatalf("catalog code count = %d, want 115", len(catalog.codes))
}
if catalog.codes["InvalidParameterValue"] != ec2ErrorClassClient {
t.Fatal("InvalidParameterValue is not classified as client")
+1
View File
@@ -17,6 +17,7 @@ var modelFiles = map[Service]string{
ElasticLoadBalancingV2: "models/elastic-load-balancing-v2-2015-12-01.json.gz",
IAM: "models/iam-2010-05-08.json.gz",
RDS: "models/rds-2014-10-31.json.gz",
Route53: "models/route-53-2013-04-01.json.gz",
S3: "models/s3-2006-03-01.json.gz",
STS: "models/sts-2011-06-15.json.gz",
}
+247 -30
View File
@@ -1,8 +1,10 @@
package awsmodel
import (
"bytes"
"encoding/base64"
"encoding/json"
"encoding/xml"
"fmt"
"maps"
"net/http"
@@ -39,7 +41,7 @@ func (r *EncodedRequest) URL(endpoint string) string {
}
// EncodeRequest serializes input, as produced by GenerateRequests, for the
// service's protocol. S3's rest-xml is not supported.
// service's protocol: query, ec2, json, rest-json or rest-xml.
func EncodeRequest(service Service, operationName string, input map[string]any) (*EncodedRequest, error) {
model, err := Load(service)
if err != nil {
@@ -57,6 +59,8 @@ func EncodeRequest(service Service, operationName string, input map[string]any)
return encoder.json(operation, input)
case "rest-json":
return encoder.restJSON(operation, input)
case "rest-xml":
return encoder.restXML(operation, input)
default:
return nil, fmt.Errorf("awsmodel: encoding %s requests is not implemented", model.metadata.Protocol)
}
@@ -282,18 +286,50 @@ func (e requestEncoder) jsonValue(ref ShapeRef, value any) (any, error) {
}
func (e requestEncoder) restJSON(operation *Operation, input map[string]any) (*EncodedRequest, error) {
request, members, err := e.restBindings(operation, input)
if err != nil || len(members) == 0 {
return request, err
}
shape := e.model.shapes[operation.Input.Shape]
var document any
if payload, ok := members[shape.Payload]; ok && shape.Payload != "" {
if document, err = e.jsonValue(shape.Members[shape.Payload], payload); err != nil {
return nil, err
}
} else {
body := make(map[string]any, len(members))
for member, value := range members {
ref := shape.Members[member]
encoded, err := e.jsonValue(ref, value)
if err != nil {
return nil, err
}
body[elementName(member, ref)] = encoded
}
document = body
}
if request.Body, err = json.Marshal(document); err != nil {
return nil, err
}
request.Header.Set("Content-Type", "application/json")
return request, nil
}
// restBindings places the URI, query string and header members of input on a
// new request, and returns the members left for the body.
func (e requestEncoder) restBindings(operation *Operation, input map[string]any) (*EncodedRequest, map[string]any, error) {
request := &EncodedRequest{Method: operation.HTTP.Method, Query: url.Values{}, Header: http.Header{}}
path := operation.HTTP.RequestURI
if operation.Input == nil {
request.Path = path
return request, nil
return request, nil, nil
}
shape := e.model.shapes[operation.Input.Shape]
body := map[string]any{}
for _, member := range slices.Sorted(maps.Keys(input)) {
ref, ok := shape.Members[member]
if !ok {
return nil, fmt.Errorf("%s has no member %q", operation.Input.Shape, member)
return nil, nil, fmt.Errorf("%s has no member %q", operation.Input.Shape, member)
}
value := input[member]
target := e.model.shapes[ref.Shape]
@@ -301,18 +337,18 @@ func (e requestEncoder) restJSON(operation *Operation, input map[string]any) (*E
case "uri":
text, err := scalarString(value, ref, target, "iso8601")
if err != nil {
return nil, fmt.Errorf("%s: %w", member, err)
return nil, nil, fmt.Errorf("%s: %w", member, err)
}
path = strings.Replace(path, "{"+member+"+}", escapeGreedyLabel(text), 1)
path = strings.Replace(path, "{"+member+"}", url.PathEscape(text), 1)
case "querystring":
if err := e.restQuery(request.Query, ref, target, value); err != nil {
return nil, fmt.Errorf("%s: %w", member, err)
return nil, nil, fmt.Errorf("%s: %w", member, err)
}
case "header":
text, err := e.headerValue(ref, target, value)
if err != nil {
return nil, fmt.Errorf("%s: %w", member, err)
return nil, nil, fmt.Errorf("%s: %w", member, err)
}
request.Header.Set(ref.LocationName, text)
case "headers":
@@ -321,41 +357,222 @@ func (e requestEncoder) restJSON(operation *Operation, input map[string]any) (*E
request.Header.Set(ref.LocationName+key, fmt.Sprint(entry))
}
default:
encoded, err := e.jsonValue(ref, value)
if err != nil {
return nil, err
}
name := member
if ref.LocationName != "" {
name = ref.LocationName
}
body[name] = encoded
body[member] = value
}
}
request.Path = path
if payload := shape.Payload; payload != "" {
if document, ok := body[payload]; ok {
body = nil
encoded, err := json.Marshal(document)
if err != nil {
return nil, err
}
request.Body = encoded
}
return request, body, nil
}
// restXML encodes the body as AWS SDKs do: a root element named for the input
// shape, or for the payload member, in the service's namespace. An input with
// body members sends its root element even when none of them is set.
func (e requestEncoder) restXML(operation *Operation, input map[string]any) (*EncodedRequest, error) {
request, members, err := e.restBindings(operation, input)
if err != nil || operation.Input == nil {
return request, err
}
if len(body) > 0 {
encoded, err := json.Marshal(body)
if err != nil {
shape := e.model.shapes[operation.Input.Shape]
var root *xmlElement
switch {
case shape.Payload != "":
value, ok := members[shape.Payload]
if !ok {
return request, nil
}
ref := shape.Members[shape.Payload]
target := e.model.shapes[ref.Shape]
if target.Type != "structure" {
switch value := value.(type) {
case []byte:
request.Body = value
case string:
request.Body = []byte(value)
default:
return nil, fmt.Errorf("%s: want a blob or string payload, got %T", shape.Payload, value)
}
return request, nil
}
name := ref.LocationName
if name == "" {
name = elementName(ref.Shape, ShapeRef{LocationName: target.LocationName})
}
if root, err = e.xmlRoot(name, ref, value); err != nil {
return nil, err
}
request.Body = encoded
case hasBodyMembers(shape):
name := elementName(operation.Input.Shape, ShapeRef{LocationName: shape.LocationName})
if root, err = e.xmlRoot(name, *operation.Input, members); err != nil {
return nil, err
}
default:
return request, nil
}
if request.Body != nil {
request.Header.Set("Content-Type", "application/json")
var body bytes.Buffer
encoder := xml.NewEncoder(&body)
if err := root.encode(encoder); err != nil {
return nil, err
}
if err := encoder.Flush(); err != nil {
return nil, err
}
request.Body = body.Bytes()
request.Header.Set("Content-Type", "application/xml")
return request, nil
}
func hasBodyMembers(shape *Shape) bool {
for _, ref := range shape.Members {
if ref.Location == "" {
return true
}
}
return false
}
// xmlRoot is the structure value encoded under name, in its shape's namespace
// or else the service's.
func (e requestEncoder) xmlRoot(name string, ref ShapeRef, value any) (*xmlElement, error) {
holder := &xmlElement{}
if err := e.xmlValue(holder, name, ref, value); err != nil {
return nil, err
}
root := holder.children[0]
if namespace := e.model.metadata.XMLNamespace; namespace.URI != "" && e.model.shapes[ref.Shape].XMLNamespace.URI == "" {
root.attrs = append([]xml.Attr{namespaceAttr(namespace)}, root.attrs...)
}
return root, nil
}
func (e requestEncoder) xmlStructure(parent *xmlElement, shapeName string, fields map[string]any) error {
shape := e.model.shapes[shapeName]
for _, member := range slices.Sorted(maps.Keys(fields)) {
ref, ok := shape.Members[member]
if !ok {
return fmt.Errorf("%s has no member %q", shapeName, member)
}
name := elementName(member, ref)
if ref.XMLAttribute {
text, err := scalarString(fields[member], ref, e.model.shapes[ref.Shape], "iso8601")
if err != nil {
return fmt.Errorf("%s: %w", name, err)
}
parent.attrs = append(parent.attrs, xml.Attr{Name: xml.Name{Local: name}, Value: text})
continue
}
if err := e.xmlValue(parent, name, ref, fields[member]); err != nil {
return err
}
}
return nil
}
// xmlValue appends the elements value is encoded as to parent. A list or map
// is wrapped in an element named name unless it is flattened.
func (e requestEncoder) xmlValue(parent *xmlElement, name string, ref ShapeRef, value any) error {
shape := e.model.shapes[ref.Shape]
switch shape.Type {
case "structure":
fields, ok := value.(map[string]any)
if !ok {
return fmt.Errorf("%s: want a structure, got %T", name, value)
}
element := parent.add(name)
if shape.XMLNamespace.URI != "" {
element.attrs = append(element.attrs, namespaceAttr(shape.XMLNamespace))
}
return e.xmlStructure(element, ref.Shape, fields)
case "list":
items, ok := value.([]any)
if !ok {
return fmt.Errorf("%s: want a list, got %T", name, value)
}
container, itemName := parent, name
if !ref.Flattened {
container, itemName = parent.add(name), elementName("member", *shape.Member)
}
for _, item := range items {
if err := e.xmlValue(container, itemName, *shape.Member, item); err != nil {
return err
}
}
return nil
case "map":
entries, ok := value.(map[string]any)
if !ok {
return fmt.Errorf("%s: want a map, got %T", name, value)
}
container, entryName := parent, name
if !ref.Flattened {
container, entryName = parent.add(name), "entry"
}
keyName, valueName := elementName("key", *shape.Key), elementName("value", *shape.Value)
for _, key := range slices.Sorted(maps.Keys(entries)) {
entry := container.add(entryName)
entry.add(keyName).text = key
if err := e.xmlValue(entry, valueName, *shape.Value, entries[key]); err != nil {
return err
}
}
return nil
default:
text, err := scalarString(value, ref, shape, "iso8601")
if err != nil {
return fmt.Errorf("%s: %w", name, err)
}
parent.add(name).text = text
return nil
}
}
// elementName is a member's wire name: its xmlName or jsonName, else its own.
func elementName(member string, ref ShapeRef) string {
if ref.LocationName != "" {
return ref.LocationName
}
return member
}
func namespaceAttr(namespace XMLNamespace) xml.Attr {
name := "xmlns"
if namespace.Prefix != "" {
name += ":" + namespace.Prefix
}
return xml.Attr{Name: xml.Name{Local: name}, Value: namespace.URI}
}
// xmlElement is one element of an encoded rest-xml body.
type xmlElement struct {
name string
attrs []xml.Attr
text string
children []*xmlElement
}
func (x *xmlElement) add(name string) *xmlElement {
child := &xmlElement{name: name}
x.children = append(x.children, child)
return child
}
func (x *xmlElement) encode(encoder *xml.Encoder) error {
start := xml.StartElement{Name: xml.Name{Local: x.name}, Attr: x.attrs}
if err := encoder.EncodeToken(start); err != nil {
return err
}
if x.text != "" {
if err := encoder.EncodeToken(xml.CharData(x.text)); err != nil {
return err
}
}
for _, child := range x.children {
if err := child.encode(encoder); err != nil {
return err
}
}
return encoder.EncodeToken(start.End())
}
func (e requestEncoder) restQuery(query url.Values, ref ShapeRef, shape *Shape, value any) error {
switch shape.Type {
case "list":
+212
View File
@@ -0,0 +1,212 @@
package awsmodel_test
import (
"cmp"
"encoding/xml"
"io"
"net/url"
"slices"
"strings"
"testing"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/credentials"
"github.com/aws/aws-sdk-go/aws/request"
"github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/route53"
"github.com/aws/aws-sdk-go/service/s3"
"github.com/mulgadc/spinifex/internal/awsmodel"
"github.com/stretchr/testify/require"
)
const sdkEndpoint = "https://aws.example"
var sdkSession = session.Must(session.NewSession(&aws.Config{
Region: aws.String("us-east-1"),
Endpoint: aws.String(sdkEndpoint),
Credentials: credentials.AnonymousCredentials,
S3ForcePathStyle: aws.Bool(true),
}))
// TestEncodeRequestRestXMLMatchesSDK checks the encoder against aws-sdk-go's
// restxml marshaller for the same input, so the expected wire form is the
// SDK's rather than one written by hand.
func TestEncodeRequestRestXMLMatchesSDK(t *testing.T) {
r53 := route53.New(sdkSession)
tests := []struct {
name string
service awsmodel.Service
operation string
input map[string]any
sdk *request.Request
}{
{
name: "CreateHostedZone",
service: awsmodel.Route53,
operation: "CreateHostedZone",
input: map[string]any{
"Name": "example.com",
"CallerReference": "ref-1",
"HostedZoneConfig": map[string]any{"Comment": "a & b", "PrivateZone": true},
"VPC": map[string]any{"VPCRegion": "us-east-1", "VPCId": "vpc-1"},
},
sdk: requestOf(r53.CreateHostedZoneRequest(&route53.CreateHostedZoneInput{
Name: aws.String("example.com"),
CallerReference: aws.String("ref-1"),
HostedZoneConfig: &route53.HostedZoneConfig{Comment: aws.String("a & b"), PrivateZone: aws.Bool(true)},
VPC: &route53.VPC{VPCRegion: aws.String("us-east-1"), VPCId: aws.String("vpc-1")},
})),
},
{
name: "ChangeResourceRecordSets",
service: awsmodel.Route53,
operation: "ChangeResourceRecordSets",
input: map[string]any{
"HostedZoneId": "Z0123456789",
"ChangeBatch": map[string]any{
"Comment": "two changes",
"Changes": []any{
map[string]any{"Action": "UPSERT", "ResourceRecordSet": map[string]any{
"Name": "www.example.com", "Type": "A", "TTL": int64(300),
"ResourceRecords": []any{map[string]any{"Value": "192.0.2.1"}, map[string]any{"Value": "192.0.2.2"}},
}},
map[string]any{"Action": "DELETE", "ResourceRecordSet": map[string]any{
"Name": "old.example.com", "Type": "TXT", "TTL": int64(60),
"ResourceRecords": []any{map[string]any{"Value": `"v=1"`}},
}},
},
},
},
sdk: requestOf(r53.ChangeResourceRecordSetsRequest(&route53.ChangeResourceRecordSetsInput{
HostedZoneId: aws.String("Z0123456789"),
ChangeBatch: &route53.ChangeBatch{
Comment: aws.String("two changes"),
Changes: []*route53.Change{
{Action: aws.String("UPSERT"), ResourceRecordSet: &route53.ResourceRecordSet{
Name: aws.String("www.example.com"), Type: aws.String("A"), TTL: aws.Int64(300),
ResourceRecords: []*route53.ResourceRecord{{Value: aws.String("192.0.2.1")}, {Value: aws.String("192.0.2.2")}},
}},
{Action: aws.String("DELETE"), ResourceRecordSet: &route53.ResourceRecordSet{
Name: aws.String("old.example.com"), Type: aws.String("TXT"), TTL: aws.Int64(60),
ResourceRecords: []*route53.ResourceRecord{{Value: aws.String(`"v=1"`)}},
}},
},
},
})),
},
{
name: "ChangeTagsForResource",
service: awsmodel.Route53,
operation: "ChangeTagsForResource",
input: map[string]any{
"ResourceType": "hostedzone",
"ResourceId": "Z0123456789",
"AddTags": []any{map[string]any{"Key": "team", "Value": "dns"}, map[string]any{"Key": "env", "Value": "prod"}},
"RemoveTagKeys": []any{"old", "stale"},
},
sdk: requestOf(r53.ChangeTagsForResourceRequest(&route53.ChangeTagsForResourceInput{
ResourceType: aws.String("hostedzone"),
ResourceId: aws.String("Z0123456789"),
AddTags: []*route53.Tag{{Key: aws.String("team"), Value: aws.String("dns")}, {Key: aws.String("env"), Value: aws.String("prod")}},
RemoveTagKeys: []*string{aws.String("old"), aws.String("stale")},
})),
},
{
name: "UpdateHostedZoneComment without a comment",
service: awsmodel.Route53,
operation: "UpdateHostedZoneComment",
input: map[string]any{"Id": "Z0123456789"},
sdk: requestOf(r53.UpdateHostedZoneCommentRequest(&route53.UpdateHostedZoneCommentInput{Id: aws.String("Z0123456789")})),
},
{
name: "ListResourceRecordSets",
service: awsmodel.Route53,
operation: "ListResourceRecordSets",
input: map[string]any{"HostedZoneId": "Z0123456789", "StartRecordName": "www.example.com", "StartRecordType": "A", "MaxItems": int64(5)},
sdk: requestOf(r53.ListResourceRecordSetsRequest(&route53.ListResourceRecordSetsInput{
HostedZoneId: aws.String("Z0123456789"), StartRecordName: aws.String("www.example.com"), StartRecordType: aws.String("A"), MaxItems: aws.String("5"),
})),
},
{
name: "DeleteHostedZone",
service: awsmodel.Route53,
operation: "DeleteHostedZone",
input: map[string]any{"Id": "Z0123456789"},
sdk: requestOf(r53.DeleteHostedZoneRequest(&route53.DeleteHostedZoneInput{Id: aws.String("Z0123456789")})),
},
{
name: "S3 structure payload",
service: awsmodel.S3,
operation: "PutBucketTagging",
input: map[string]any{
"Bucket": "bucket",
"Tagging": map[string]any{"TagSet": []any{map[string]any{"Key": "team", "Value": "storage"}}},
},
sdk: requestOf(s3.New(sdkSession).PutBucketTaggingRequest(&s3.PutBucketTaggingInput{
Bucket: aws.String("bucket"),
Tagging: &s3.Tagging{TagSet: []*s3.Tag{{Key: aws.String("team"), Value: aws.String("storage")}}},
})),
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
require.NoError(t, test.sdk.Build())
want := test.sdk.HTTPRequest
wantBody, err := io.ReadAll(test.sdk.GetBody())
require.NoError(t, err)
got, err := awsmodel.EncodeRequest(test.service, test.operation, test.input)
require.NoError(t, err)
require.Equal(t, want.Method, got.Method)
gotURL, err := url.Parse(got.URL(sdkEndpoint))
require.NoError(t, err)
// The SDK's api-2.json ends ChangeResourceRecordSets' path in a
// slash that the Smithy model drops; the gateway accepts both.
require.Equal(t, strings.TrimSuffix(want.URL.Path, "/"), gotURL.Path)
require.Equal(t, want.URL.Query(), gotURL.Query())
if len(wantBody) == 0 {
require.Empty(t, got.Body)
require.Empty(t, got.Header.Get("Content-Type"))
return
}
require.Equal(t, canonicalXML(t, wantBody), canonicalXML(t, got.Body))
require.Equal(t, "application/xml", got.Header.Get("Content-Type"))
})
}
}
func requestOf[O any](r *request.Request, _ O) *request.Request { return r }
// xmlTree is a parsed XML element. The SDK emits sibling elements in map
// order, so canonicalXML sorts them by name, keeping list items in order.
type xmlTree struct {
XMLName xml.Name `xml:""`
Attrs []xml.Attr `xml:",any,attr"`
Text string `xml:",chardata"`
Children []xmlTree `xml:",any"`
}
func canonicalXML(t *testing.T, body []byte) xmlTree {
t.Helper()
var tree xmlTree
require.NoError(t, xml.Unmarshal(body, &tree), string(body))
var canonicalise func(*xmlTree)
canonicalise = func(node *xmlTree) {
node.Text = strings.TrimSpace(node.Text)
slices.SortFunc(node.Attrs, func(a, b xml.Attr) int { return cmp.Compare(a.Name.Space+a.Name.Local, b.Name.Space+b.Name.Local) })
slices.SortStableFunc(node.Children, func(a, b xmlTree) int { return cmp.Compare(a.XMLName.Local, b.XMLName.Local) })
for i := range node.Children {
canonicalise(&node.Children[i])
}
}
canonicalise(&tree)
return tree
}
func TestEncodeRequestRestXMLPutsRootInServiceNamespace(t *testing.T) {
request, err := awsmodel.EncodeRequest(awsmodel.Route53, "CreateHostedZone", map[string]any{"Name": "example.com", "CallerReference": "ref"})
require.NoError(t, err)
tree := canonicalXML(t, request.Body)
require.Equal(t, xml.Name{Space: "https://route53.amazonaws.com/doc/2013-04-01/", Local: "CreateHostedZoneRequest"}, tree.XMLName)
require.Equal(t, "/2013-04-01/hostedzone", request.Path)
}
-5
View File
@@ -117,8 +117,3 @@ func TestEncodeRequestRejectsUnmodelledMember(t *testing.T) {
_, err := awsmodel.EncodeRequest(awsmodel.IAM, "GetRole", map[string]any{"RoleName": "app", "Bogus": "x"})
require.ErrorContains(t, err, `no member "Bogus"`)
}
func TestEncodeRequestS3IsNotSupported(t *testing.T) {
_, err := awsmodel.EncodeRequest(awsmodel.S3, "ListBuckets", map[string]any{})
require.ErrorContains(t, err, "not implemented")
}
+1 -1
View File
@@ -119,7 +119,7 @@ func (m *Model) decodeErrorCode(body []byte) (string, error) {
return "", fmt.Errorf("JSON error envelope has no code")
}
return code, nil
case "ec2", "query":
case "ec2", "query", "rest-xml":
root, err := parseXML(body)
if err != nil {
return "", err
+13
View File
@@ -123,3 +123,16 @@ func TestValidateErrorResponseClassifiesCommonErrorsAsUnmodelled(t *testing.T) {
})
}
}
func TestDecodeRequestResultReadsRestXMLErrorEnvelope(t *testing.T) {
body := []byte(`<?xml version="1.0"?>
<ErrorResponse xmlns="https://route53.amazonaws.com/doc/2013-04-01/"><Error><Type>Sender</Type><Code>NoSuchHostedZone</Code><Message>No hosted zone found with ID: Z1</Message></Error><RequestId>r-1</RequestId></ErrorResponse>`)
result := DecodeRequestResult(Route53, 404, nil, body)
if result.Code != "NoSuchHostedZone" || result.Message != "No hosted zone found with ID: Z1" {
t.Fatalf("result = %+v", result)
}
violations, modelled, err := ValidateErrorResponse(Route53, "GetHostedZone", body)
if err != nil || !modelled || len(violations) != 0 {
t.Fatalf("ValidateErrorResponse = %v, %v, %v; want a declared code", violations, modelled, err)
}
}
+10
View File
@@ -34,6 +34,7 @@ const (
ElasticLoadBalancingV2 Service = "elasticloadbalancingv2"
IAM Service = "iam"
RDS Service = "rds"
Route53 Service = "route53"
S3 Service = "s3"
STS Service = "sts"
)
@@ -50,6 +51,14 @@ type Metadata struct {
ServiceID string
SigningName string
TargetPrefix string
XMLNamespace XMLNamespace
}
// XMLNamespace is the namespace an XML element is serialized in. URI is empty
// when none is declared.
type XMLNamespace struct {
URI string `json:"uri"`
Prefix string `json:"prefix"`
}
// Operation describes an AWS API operation and the shapes used by its input,
@@ -84,6 +93,7 @@ type Shape struct {
Pattern string
LocationName string
TimestampFormat string
XMLNamespace XMLNamespace
Payload string
Flattened bool
Sensitive bool
+2 -2
View File
@@ -5,6 +5,6 @@ package awsmodel
// ModelCommit is the github.com/aws/api-models-aws commit the embedded
// models were taken from, and ModelCommitDate the day it was committed.
const (
ModelCommit = "9b1a4929274443d8671ea8acfda7afbe856e29e9"
ModelCommitDate = "2026-09-25"
ModelCommit = "e10d6a1c5ac36af7c0c61d1f67b3c3ebbe41de49"
ModelCommitDate = "2026-10-28"
)
+7 -6
View File
@@ -8,7 +8,7 @@ import (
)
func TestServices(t *testing.T) {
want := []Service{ACM, EC2, ECR, ECS, EKS, ElasticLoadBalancingV2, IAM, RDS, S3, STS}
want := []Service{ACM, EC2, ECR, ECS, EKS, ElasticLoadBalancingV2, IAM, RDS, Route53, S3, STS}
if got := Services(); !reflect.DeepEqual(got, want) {
t.Fatalf("Services() = %v, want %v", got, want)
}
@@ -21,13 +21,14 @@ func TestLoadAllModels(t *testing.T) {
shapes int
}{
ACM: {"2015-12-08", 40, 239},
EC2: {"2016-11-15", 804, 4245},
ECR: {"2015-09-21", 58, 404},
ECS: {"2014-11-13", 77, 643},
EKS: {"2017-11-01", 70, 450},
EC2: {"2016-11-15", 811, 4297},
ECR: {"2015-09-21", 62, 426},
ECS: {"2014-11-13", 88, 734},
EKS: {"2017-11-01", 70, 458},
ElasticLoadBalancingV2: {"2015-12-01", 51, 418},
IAM: {"2010-05-08", 180, 624},
RDS: {"2014-10-31", 164, 791},
RDS: {"2014-10-31", 168, 816},
Route53: {"2013-04-01", 71, 409},
S3: {"2006-03-01", 112, 710},
STS: {"2011-06-15", 11, 101},
}
+1 -1
View File
@@ -1,6 +1,6 @@
# AWS models
The `*.json.gz` files are the Smithy JSON AST service models from [`aws/api-models-aws`](https://github.com/aws/api-models-aws), gzipped and embedded into the binary, so loading them needs no network or Go module cache. All ten come from the one commit recorded in `../model_source.go`.
The `*.json.gz` files are the Smithy JSON AST service models from [`aws/api-models-aws`](https://github.com/aws/api-models-aws), gzipped and embedded into the binary, so loading them needs no network or Go module cache. All eleven come from the one commit recorded in `../model_source.go`.
Do not edit them by hand. To move to a newer commit, run `scripts/sync-aws-models.sh <commit-sha>`, then `make generate-aws-model-coverage` and review the regenerated `docs/coverage` and the `awsmodel` test expectations: a newer model can add operations, required members and enum values, and each resulting finding is a real gap to triage rather than a reason to stay on an older commit.
Binary file not shown.
Binary file not shown.
@@ -50,6 +50,7 @@
"InvalidCapacityReservationId.Malformed",
"InvalidCapacityReservationId.NotFound",
"InvalidEgressOnlyInternetGatewayId.NotFound",
"InvalidGatewayID.NotFound",
"InvalidGroup.Duplicate",
"InvalidGroup.NotFound",
"InvalidGroup.Reserved",
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+7 -2
View File
@@ -111,7 +111,7 @@ func GenerateRequests(service Service, operationName string, options RequestOpti
}
for _, member := range append([]string{""}, optional...) {
if unguessable[member] {
if unguessable[member] && options.Fixtures[operationName+"."+member] == "" && options.Fixtures[member] == "" {
continue
}
value, _, err := generate()
@@ -157,7 +157,8 @@ func GenerateRequests(service Service, operationName string, options RequestOpti
// unguessable members take values the model cannot describe: a pagination
// token the service issued, or a filter name only its documentation lists.
// They get rejection requests but no acceptance request.
// They get rejection requests, and an acceptance request only when a fixture
// supplies a value.
var unguessable = map[string]bool{
"Filter": true,
"Filters": true,
@@ -374,6 +375,10 @@ func (g *requestGenerator) structure(name string, path []pathStep, depth int) (m
for _, member := range slices.Sorted(maps.Keys(shape.Members)) {
ref := shape.Members[member]
memberPath := append(slices.Clone(path), member)
if !required[member] && slices.Contains(exclusiveMembers[g.model.service][name], member) {
g.skip(memberPath, "AWS refuses it alongside the members a valid request carries")
continue
}
memberValue, ok := g.value(ref, memberPath, depth)
if !ok {
if required[member] {
+57 -6
View File
@@ -18,7 +18,8 @@ import (
// RequestOptions places generated ARNs in the account and region the
// requests are sent as. AccessKeyID is the key the requests are signed with.
// Fixtures maps a member to an existing resource it can name.
// Fixtures maps a member, or an "Operation.Member" that overrides it for one
// operation, to an existing resource it can name.
type RequestOptions struct {
AccountID string
Region string
@@ -73,6 +74,12 @@ var ec2IDMember = regexp.MustCompile(`^(.*?)Ids?$`)
// with a code the judge cannot tell from a real finding.
var conditionallyRequired = map[Service]map[string][]string{
EC2: {"CreateCapacityReservation": {"AvailabilityZone"}},
// A listing that starts at a zone ID needs the name it starts at, and
// one that starts at a record type or identifier needs a name and type.
Route53: {
"ListHostedZonesByName": {"DNSName"},
"ListResourceRecordSets": {"StartRecordName", "StartRecordType"},
},
ECS: {
"CreateCapacityProvider": {"autoScalingGroupProvider"},
"CreateService": {"taskDefinition"},
@@ -123,14 +130,47 @@ var ec2TaggedTypes = map[string]string{
"RunInstances": "instance",
}
// exclusiveMembers are optional members a valid request leaves out of the
// structure that holds them, because AWS refuses them in combination with
// the rest. Generation skips them, so their constraints are not broken.
var exclusiveMembers = map[Service]map[string][]string{
// A record set takes one routing policy and either records or an alias.
Route53: {"ResourceRecordSet": {
"AliasTarget", "CidrRoutingConfig", "Failover", "GeoLocation", "GeoProximityLocation", "HealthCheckId",
"MultiValueAnswer", "Region", "SetIdentifier", "TrafficPolicyInstanceId", "Weight",
}},
}
// fixture returns the fixture the member at path names, preferring one scoped
// to the operation.
func (g *requestGenerator) fixture(member string) (string, bool) {
if fixture, ok := g.options.Fixtures[g.operation+"."+member]; ok {
return fixture, true
}
fixture, ok := g.options.Fixtures[member]
return fixture, ok
}
// enumHint returns the enum value a valid request needs at path, where the
// first value would not do.
func (g *requestGenerator) enumHint(path []pathStep) (string, bool) {
if g.model.service != EC2 || len(path) == 0 || path[0] != "TagSpecifications" || memberName(path) != "ResourceType" {
return "", false
member := memberName(path)
if fixture, ok := g.fixture(member); ok {
return fixture, true
}
value, ok := ec2TaggedTypes[g.operation]
return value, ok
switch g.model.service {
case EC2:
if len(path) > 0 && path[0] == "TagSpecifications" && member == "ResourceType" {
value, ok := ec2TaggedTypes[g.operation]
return value, ok
}
case Route53:
// The first RRType, SOA, is one a zone already has and cannot gain.
if member == "Type" {
return "A", true
}
}
return "", false
}
// hint returns a value of the right form for the member at path, if one is
@@ -141,7 +181,7 @@ func (g *requestGenerator) hint(path []pathStep) (string, bool) {
return "", false
}
options := g.options
if fixture, ok := options.Fixtures[member]; ok {
if fixture, ok := g.fixture(member); ok {
return fixture, true
}
suffix := fmt.Sprintf("%017x", g.saltNumber)
@@ -230,6 +270,17 @@ func (g *requestGenerator) hint(path []pathStep) (string, bool) {
if member == "type" && len(path) == 1 {
return "STANDARD", true
}
case Route53:
// A generated name is a top-level domain, which cannot be a zone.
// Record names fall inside the fixture zone, example.com.
switch member {
case "Name", "DNSName", "StartRecordName":
return name + ".example.com", true
case "Value":
if slices.Contains(path, pathStep("ResourceRecords")) {
return "192.0.2.1", true
}
}
case STS:
// GetAccessKeyInfo answers only for a key that exists.
if member == "AccessKeyId" && options.AccessKeyID != "" {
+26
View File
@@ -131,6 +131,7 @@ func TestGeneratedRequestsBreakOnlyTheirConstraint(t *testing.T) {
for _, request := range plan.Cases {
require.False(t, rejecting && request.Acceptance(), "%s acceptance %s follows a rejection", operationName, request.Path)
rejecting = !request.Acceptance()
// Predastore serves S3, so its requests are never sent.
if service != S3 {
_, err := EncodeRequest(service, operationName, request.Input)
require.NoError(t, err, "%s %s %s encodes", operationName, request.Constraint, request.Path)
@@ -241,6 +242,17 @@ func TestGenerateRequestsTargetsFixturesOutsideCreateAndDelete(t *testing.T) {
}
}
func TestGenerateRequestsPrefersOperationScopedFixtures(t *testing.T) {
options := testRequestOptions
options.Fixtures = map[string]string{"Id": "Z0FIXTURE", "GetChange.Id": "C0FIXTURE"}
for operation, want := range map[string]string{"GetChange": "C0FIXTURE", "GetHostedZone": "Z0FIXTURE"} {
plan, err := GenerateRequests(Route53, operation, options)
require.NoError(t, err)
require.True(t, plan.Cases[0].Acceptance())
require.Equal(t, want, plan.Cases[0].Input["Id"], operation)
}
}
func TestGenerateRequestsAttributesRejections(t *testing.T) {
plan, err := GenerateRequests(IAM, "CreateRole", testRequestOptions)
require.NoError(t, err)
@@ -325,6 +337,20 @@ func TestGenerateRequestsSkipsAcceptanceForUnguessableMembers(t *testing.T) {
require.NotEmpty(t, markerRejections, "a pagination token still gets rejection requests")
}
func TestGenerateRequestsAcceptsAFixtureForAnUnguessableMember(t *testing.T) {
options := testRequestOptions
options.Fixtures = map[string]string{"ListRoles.Marker": "fixture-marker"}
plan, err := GenerateRequests(IAM, "ListRoles", options)
require.NoError(t, err)
var markers []any
for _, request := range plan.Cases {
if request.Acceptance() && request.Member == "Marker" {
markers = append(markers, request.Input["Marker"])
}
}
require.Equal(t, []any{"fixture-marker"}, markers)
}
func TestGenerateRequestsUsesResourceHints(t *testing.T) {
tests := []struct {
service Service
+6
View File
@@ -281,6 +281,9 @@ func (t *smithyTranslator) metadata() (Metadata, error) {
if _, err := service.Traits.decode("aws.auth#sigv4", &sigv4); err != nil {
return Metadata{}, err
}
if _, err := service.Traits.decode("smithy.api#xmlNamespace", &metadata.XMLNamespace); err != nil {
return Metadata{}, err
}
metadata.EndpointPrefix = awsService.EndpointPrefix
metadata.ServiceID = awsService.SDKID
metadata.ServiceFullName = title
@@ -443,6 +446,9 @@ func (t *smithyTranslator) applyShapeTraits(shape *Shape, traits smithyTraits) e
if shape.LocationName, err = traits.string("smithy.api#xmlName"); err != nil {
return err
}
if _, err := traits.decode("smithy.api#xmlNamespace", &shape.XMLNamespace); err != nil {
return err
}
if shape.TimestampFormat, err = timestampFormat(traits); err != nil {
return err
}
+3 -2
View File
@@ -7,9 +7,10 @@ Node installation, dev-environment lifecycle, guest image builds, and verificati
| Script | What it does |
|---|---|
| `setup.sh` | The production binary installer, served from `install.mulgadc.com`. Installs deps, users, sudoers, the host firewall policy, files, systemd units, logrotate and udev rules, and loads `openvswitch`, `geneve`, `vhost_net` and `tun`, listing them in `/etc/modules-load.d/spinifex.conf` for later boots. It masks firewalld wherever it is installed, since the Spinifex nftables policy is the only supported node firewall. An unset `--firewall` keeps the mode recorded in `/etc/spinifex/firewall/mode`, and otherwise arms the policy when firewalld was active. Where systemd-resolved is installed but not running, resolvconf is absent and NetworkManager runs, it hands host DNS to resolved: it saves `/etc/resolv.conf` once to `/var/lib/spinifex-reset/resolv.conf.orig`, links `/etc/resolv.conf` to the resolved stub, and stops unless resolved has upstream servers and the package repository host still resolves. It links `/usr/bin/spx` to `/usr/local/bin/spx` where sudo's PATH leaves out `/usr/local/bin`, and takes the nbdkit plugin directory from `nbdkit --dump-config`, stopping when nbdkit cannot say. `SETUP_STAGES` runs a subset; `ISO_BUILD=1` runs it inside the ISO builder's chroot. |
| `setup-ovn.sh` | Bootstraps a compute node for OVN VPC networking: installs OVN/OVS, creates `br-int`, configures the WAN bridge (bridged, DHCP, or routed-NAT uplink), sets the chassis identity, and applies overlay sysctls. It runs OVS and OVN as root, setting `OVS_USER_ID` and `OVN_USER_ID` where `/etc/sysconfig` holds them, and hooks the socket-permission helper onto `ovs-vswitchd`, `ovn-controller`, `openvswitch-ipsec` and, on EL, `ovn-northd`. `--management` also starts the OVN central services, writes their `ovn-ctl` flags to `/etc/default/ovn-central` (Debian) or `/etc/sysconfig/ovn-northd` (EL), and records the role in `/etc/spinifex/ovn-role`. A later run without `--management` refuses while that file, or the `OVN_CTL_OPTS` line older nodes carry in `/etc/default/ovn-central`, records the role. It adds `maxsize 100M` to the OVS and OVN logrotate files, plus `rotate 5` where the file sets no count, and rotates as root, matching the daemons. On a host whose IPsec stack is libreswan it stops and masks `openvswitch-ipsec.service` and `ipsec.service`, because OVN IPsec does not run on libreswan yet; elsewhere it enables and starts `openvswitch-ipsec.service`, unless the daemon has masked it to keep IPsec off. The links it creates persist through systemd-networkd where that is enabled, and otherwise through `/etc/spinifex/hostnet.json`, which `spinifex-hostnet.service` (`spx admin hostnet apply`) replays at boot and on every OVS start, starting `ovn-controller` again when an OVS stop took it down; the first run records the choice in `/etc/spinifex/hostnet-backend`. Where NetworkManager runs, it leaves the links Spinifex owns unmanaged. |
| `setup-ovn.sh` | Bootstraps a compute node for OVN VPC networking: installs OVN/OVS, creates `br-int`, configures the WAN bridge (bridged, DHCP, or routed-NAT uplink), sets the chassis identity, and applies overlay sysctls. It runs OVS and OVN as root, setting `OVS_USER_ID` and `OVN_USER_ID` where `/etc/sysconfig` holds them, and hooks the socket-permission helper onto `ovs-vswitchd`, `ovn-controller`, `openvswitch-ipsec` and, on EL, `ovn-northd`. `--management` also starts the OVN central services, writes their `ovn-ctl` flags to `/etc/default/ovn-central` (Debian) or `/etc/sysconfig/ovn-northd` (EL), and records the role in `/etc/spinifex/ovn-role`. With clustered databases it returns only once this node's NB and SB members are back in their RAFT clusters under a known leader, and fails after `OVN_RAFT_REJOIN_WAIT` seconds (default 60) otherwise, so reruns one node at a time never take two members of a cluster down. A later run without `--management` refuses while that file, or the `OVN_CTL_OPTS` line older nodes carry in `/etc/default/ovn-central`, records the role. It adds `maxsize 100M` to the OVS and OVN logrotate files, plus `rotate 5` where the file sets no count, and rotates as root, matching the daemons. On a host whose IPsec stack is libreswan it enables, stops and masks `openvswitch-ipsec.service` and `ipsec.service` for the daemon to unmask when IPsec is on, unless `openvswitch-ipsec.service` is already running; elsewhere it enables and starts `openvswitch-ipsec.service`, unless the daemon has masked it to keep IPsec off. The links it creates persist through systemd-networkd where that is enabled, and otherwise through `/etc/spinifex/hostnet.json`, which `spinifex-hostnet.service` (`spx admin hostnet apply`) replays at boot and on every OVS start, starting `ovn-controller` again when an OVS stop took it down; the first run records the choice in `/etc/spinifex/hostnet-backend`. Where NetworkManager runs, it leaves the links Spinifex owns unmanaged. |
| `install-node.sh` | Forms a multi-node cluster across hosts that are already running Spinifex: rebuilds the OVN database in clustered form, runs `spx admin init` on the first host and `spx admin join` on the rest, then verifies. Before touching any host it refuses hosts of different OS families (`distro_os_family`) or `spx` versions, and `--ipsec on` when any host uses libreswan. The scripted form of [install-multi-node](../docs/install/install-multi-node/README.md). Destroys the joining nodes' logical network state and crypto — see its `--help`. |
| `dev-install.sh` | Full local dev setup through the production installer: builds from source, packs a tarball, runs `setup.sh`, initialises the cluster, and starts services under systemd. |
| `build-oracle-appliance.sh` | Turns an Oracle Linux 10.2 system reachable over SSH with sudo into a sealed gold image, then powers it off. It updates OL and reboots into the newest UEK R8, sets SELinux permissive, installs Spinifex without initialising it, and leaves `spinifex.target` and the NATS watchdog disabled. It creates the `spinifex` login with a default password that must be changed at first login, installs `appliance/firstboot.sh` as `spinifex-appliance-firstboot.service`, and wipes the system's identity (machine-id, SSH host keys, OVS chassis, NetworkManager profiles, logs). On each unit's first boot that service puts the first physical NIC with carrier on a DHCP `br-wan`, names the host `spinifex-<mac>`, runs `setup-ovn.sh --management` and `spx admin init --nodes 1`, and shows the IP and login on the console and in `/etc/motd`. `--channel`, `--version` and `--tarball` pick what to install. |
| `clone-deps.sh` | Clones or updates the viperblock and predastore checkouts alongside spinifex for cross-repo development. |
### `lib/distro.sh`: host-OS capability probes
@@ -58,7 +59,7 @@ Both ship to `/usr/local/share/spinifex/` on every node, so they are available o
| Script | What it does |
|---|---|
| `node-reset.sh` | Clears **state** and leaves the node installed: stops services, destroys guests, wipes `/etc/spinifex`, `/var/lib/spinifex`, `/var/log/spinifex` and the OVN databases, and removes the OVS bridges. It stops OVS and OVN central by the unit names the host's packages use. It leaves the NetworkManager unmanaged-device config, which the next `setup-ovn.sh` run rewrites, so NetworkManager cannot take a NIC back in between. The node is then ready for a fresh `spx admin init`. `--keep-data` preserves `/var/lib/spinifex`; `SPX_RESET_CONFIRM=reset` runs it unattended. |
| `node-reset.sh` | Clears **state** and leaves the node installed: stops services, destroys guests, wipes `/etc/spinifex`, `/var/lib/spinifex`, `/var/log/spinifex` and the OVN databases, and removes the OVS bridges. On a libreswan host it deletes the `ovs_cert_*` and `ovs_certkey_*` entries `ovs-monitor-ipsec` imported into libreswan's NSS database, leaving other identities and `/etc/ipsec.d/*.conf` alone. It stops OVS and OVN central by the unit names the host's packages use. It leaves the NetworkManager unmanaged-device config, which the next `setup-ovn.sh` run rewrites, so NetworkManager cannot take a NIC back in between. The node is then ready for a fresh `spx admin init`. `--keep-data` preserves `/var/lib/spinifex`; `SPX_RESET_CONFIRM=reset` runs it unattended. |
| `uninstall-spx.sh` | Removes the **software** — the inverse of `setup.sh`. Delegates the state teardown above to `node-reset.sh` rather than reimplementing it, then removes the binary, the nbdkit plugin, the systemd units and slices, the service users, the sudoers grant, the sysctl/logrotate/chrony/udev drop-ins, the `systemd-networkd` units, the host firewall policy and its loaded nft table, the Spinifex systemd-resolved drop-in, and the Spinifex CA from the host trust store. It unmasks firewalld and leaves it disabled. Where `setup.sh` handed DNS to systemd-resolved, it restores the saved `/etc/resolv.conf` and turns resolved off. Verifies afterwards and exits non-zero naming anything that survived, then lists the host settings `setup.sh` changed that it leaves in place, such as the dnf repositories, `OVS_USER_ID`, masked IPsec units and the SELinux mode. |
`node-reset.sh` finds guests by process name, `qemu-system-*` or `qemu-kvm`, owned by the daemon's service user, and waits for them to exit before it deletes any state.
+146
View File
@@ -0,0 +1,146 @@
#!/bin/bash
# Spinifex appliance first boot, for Oracle Linux units cloned from the gold
# image that build-oracle-appliance.sh seals. The OL counterpart of the ISO's
# spinifex-firstboot.sh: it also does the per-unit work the ISO installer does
# before first boot (hostname, br-wan on the NIC that is present), then sets
# up a single node. On success it writes $DONE_MARKER and the unit's
# ConditionPathExists=! stops it running again. A failed run leaves no marker,
# so the next boot retries from the top; every step below is safe to repeat.
set -euo pipefail
DONE_MARKER=/var/lib/spinifex/.appliance-firstboot-done
SHARE=/usr/local/share/spinifex
# shellcheck source=lib.sh
. "$SHARE/appliance/lib.sh"
# shellcheck source=../lib/distro.sh
. "$SHARE/lib/distro.sh"
log() { echo "[appliance-firstboot] $*"; }
if [ -f "$DONE_MARKER" ]; then
log "already complete, skipping"
exit 0
fi
# ── Primary NIC ─────────────────────────────────────────────────────────────
# Links start down when NetworkManager has no profile for them, so bring every
# physical NIC up to read its carrier, and give a cable up to 60s to come up.
nics=$(appliance_physical_nics)
if [ -z "$nics" ]; then
log "no physical Ethernet device found"
exit 1
fi
for nic in $nics; do
ip link set "$nic" up || true
done
for _i in $(seq 1 60); do
for nic in $nics; do
[ "$(cat "/sys/class/net/$nic/carrier" 2>/dev/null)" = 1 ] && break 2
done
sleep 1
done
NIC=$(appliance_pick_nic)
MAC=$(cat "/sys/class/net/$NIC/address")
log "primary NIC $NIC ($MAC), carrier $(cat "/sys/class/net/$NIC/carrier" 2>/dev/null || echo 0)"
HOSTNAME_NEW=$(appliance_hostname "$MAC")
hostnamectl set-hostname "$HOSTNAME_NEW"
log "hostname $HOSTNAME_NEW"
# ── br-wan on the primary NIC, DHCP ─────────────────────────────────────────
# The bridge takes the NIC's MAC so a DHCP reservation made for the unit's
# port keeps working. bridge.stp no: with STP on, the port sits in its
# forwarding delay long enough for DHCP on the bridge to time out.
for con in $(nmcli -g UUID,DEVICE connection show | awk -F: -v d="$NIC" '$2 == d {print $1}'); do
nmcli connection delete "$con" || true
done
nmcli connection delete br-wan br-wan-port >/dev/null 2>&1 || true
nmcli connection add type bridge ifname br-wan con-name br-wan \
bridge.stp no bridge.mac-address "$MAC" \
ipv4.method auto ipv6.method disabled connection.autoconnect yes
nmcli connection add type bridge-slave ifname "$NIC" master br-wan con-name br-wan-port \
connection.autoconnect yes
nmcli connection up br-wan || true
log "waiting for a DHCP address on br-wan"
WAN_IP=""
for _i in $(seq 1 120); do
WAN_IP=$(ip -4 -o addr show br-wan scope global 2>/dev/null | awk '{print $4}' | cut -d/ -f1 | head -1)
if [ -n "$WAN_IP" ] && ip -4 route show default dev br-wan | grep -q .; then
break
fi
WAN_IP=""
sleep 1
done
if [ -z "$WAN_IP" ]; then
log "br-wan got no DHCP address and default route in 120s; check the cable on $NIC and the site's DHCP"
exit 1
fi
log "br-wan $WAN_IP"
# ── Clock ───────────────────────────────────────────────────────────────────
# Credentials and TLS certificates are minted below, and a certificate issued
# against a wrong RTC carries a NotBefore that outlives the correction.
# Bounded and non-fatal, as on the ISO.
chronyc -a makestep >/dev/null 2>&1 || true
if chronyc waitsync 12 0.1 0 5 >/dev/null 2>&1; then
log "clock synced: $(date -uIs)"
else
log "WARNING: clock not synced, continuing with $(date -uIs)"
fi
# ── OVS and OVN ─────────────────────────────────────────────────────────────
# Start them before setup-ovn.sh so their databases exist when it runs: on
# physical hardware first-boot DB initialisation outlasts its own timeout.
OVS_UNIT=$(distro_ovs_unit)
OVN_LAYOUT=$(distro_ovn_central_layout)
case $OVN_LAYOUT in
split) OVN_CENTRAL_UNIT=ovn-central.service ;;
lone-northd) OVN_CENTRAL_UNIT=ovn-northd.service ;;
esac
systemctl start "$OVS_UNIT" "$OVN_CENTRAL_UNIT"
for _i in $(seq 1 120); do
if ovn-nbctl --timeout=2 get-connection >/dev/null 2>&1; then
log "OVN NB DB ready (${_i}s)"
break
fi
sleep 1
done
"$SHARE/setup-ovn.sh" --management
# ── Single-node cluster ─────────────────────────────────────────────────────
# Same arguments as the ISO's single-NIC DHCP path. spx writes credentials to
# disk and never prints them.
spx admin init --node "$HOSTNAME_NEW" --nodes 1 2>&1
# spx ran as root, so the files it wrote are root:root. setup.sh's fixown
# stage knows each file's owner and mode.
SETUP_STAGES=fixown "$SHARE/setup.sh"
# ── spinifex login ──────────────────────────────────────────────────────────
if [ ! -f /home/spinifex/.ssh/id_ed25519 ]; then
install -d -m 700 -o spinifex -g spinifex /home/spinifex/.ssh
ssh-keygen -q -t ed25519 -f /home/spinifex/.ssh/id_ed25519 -N "" -C "spinifex@$HOSTNAME_NEW"
chown spinifex:spinifex /home/spinifex/.ssh/id_ed25519 /home/spinifex/.ssh/id_ed25519.pub
fi
# init wrote the AWS profile under HOME=/root; the operator logs in as spinifex.
if [ -f /root/.aws/credentials ]; then
install -d -m 700 -o spinifex -g spinifex /home/spinifex/.aws
install -m 600 -o spinifex -g spinifex /root/.aws/credentials /home/spinifex/.aws/credentials
[ -f /root/.aws/config ] && install -m 600 -o spinifex -g spinifex /root/.aws/config /home/spinifex/.aws/config
fi
# ── Services ────────────────────────────────────────────────────────────────
# Enabled here, not in the image, so a clone runs nothing of Spinifex before
# init has written its config.
systemctl enable spinifex.target spinifex-banner.service spinifex-firewall.service
systemctl enable --now spinifex-nats-watchdog.timer
mkdir -p "$(dirname "$DONE_MARKER")"
touch "$DONE_MARKER"
systemctl start spinifex.target
/usr/local/bin/spx admin banner --boot-check
log "complete: https://$WAN_IP:3000, login spinifex"
+4
View File
@@ -0,0 +1,4 @@
# Holds the console login prompt until first boot and the banner have written
# /etc/issue, so the unit's IP shows before anyone logs in.
[Unit]
After=spinifex-appliance-firstboot.service spinifex-banner.service
+37
View File
@@ -0,0 +1,37 @@
#!/bin/bash
# Helpers shared by the appliance first-boot script and its test. Sourced, not run.
# appliance_physical_nics — physical Ethernet devices, one per line, in PCI
# order. Skips anything without a backing device (bridges, veths, OVS ports,
# loopback), non-Ethernet link types and wireless. SYSFS overrides /sys.
appliance_physical_nics() {
local sys="${SYSFS:-/sys}/class/net" dev
for dev in "$sys"/*; do
[ -e "$dev/device" ] || continue
[ "$(cat "$dev/type" 2>/dev/null)" = 1 ] || continue
[ -d "$dev/wireless" ] && continue
printf '%s %s\n' "$(readlink -f "$dev/device")" "$(basename "$dev")"
done | sort | awk '{print $2}'
}
# appliance_pick_nic — the first physical NIC with carrier, else the first
# physical NIC. Prints nothing when the host has none.
appliance_pick_nic() {
local sys="${SYSFS:-/sys}/class/net" nic first=""
for nic in $(appliance_physical_nics); do
[ -n "$first" ] || first=$nic
if [ "$(cat "$sys/$nic/carrier" 2>/dev/null)" = 1 ]; then
echo "$nic"
return 0
fi
done
[ -n "$first" ] && echo "$first"
return 0
}
# appliance_hostname <mac> — spinifex-<last 6 hex digits of the MAC>.
appliance_hostname() {
local hex
hex=$(echo "$1" | tr -d ':' | tr '[:upper:]' '[:lower:]')
echo "spinifex-${hex: -6}"
}
+63
View File
@@ -0,0 +1,63 @@
#!/bin/sh
# lib_test.sh — tests for the appliance first-boot NIC and hostname helpers,
# run against a fixture sysfs tree (SYSFS) so the host's own NICs never count.
set -eu
DIR=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd)
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
fails=0
pass() { echo " ok - $1"; }
fail() { echo " FAIL - $1"; fails=$((fails + 1)); }
echo "-- appliance/lib.sh"
# nic <name> <pci-slot> <carrier> [type] — a physical NIC behind a PCI device.
nic() {
mkdir -p "$SYS/devices/pci0000:00/$2" "$SYS/class/net/$1"
ln -s "../../../devices/pci0000:00/$2" "$SYS/class/net/$1/device"
echo "${4:-1}" > "$SYS/class/net/$1/type"
echo "$3" > "$SYS/class/net/$1/carrier"
}
# virt <name> — a link with no backing device (bridge, veth, OVS port).
virt() {
mkdir -p "$SYS/class/net/$1"
echo 1 > "$SYS/class/net/$1/type"
echo 1 > "$SYS/class/net/$1/carrier"
}
fixture() { SYS="$TMP/$1"; mkdir -p "$SYS/class/net"; }
run() { SYSFS="$SYS" bash -c ". '$DIR/lib.sh'; $1"; }
check() {
if [ "$2" = "$3" ]; then pass "$1"; else fail "$1: want '$2', got '$3'"; fi
}
fixture order
nic enp3s0 0000:00:03.0 0
nic enp1s0 0000:00:01.0 0
virt br-wan
virt lo
check "physical NICs in PCI order, virtual links skipped" "enp1s0 enp3s0" "$(run appliance_physical_nics | tr '\n' ' ' | sed 's/ $//')"
check "no carrier anywhere picks the first by PCI order" enp1s0 "$(run appliance_pick_nic)"
fixture carrier
nic eno1 0000:00:01.0 0
nic eno2 0000:00:02.0 1
nic eno3 0000:00:03.0 1
check "the first NIC with carrier wins over PCI order" eno2 "$(run appliance_pick_nic)"
fixture wireless
nic wlp1s0 0000:00:01.0 1
mkdir -p "$SYS/class/net/wlp1s0/wireless"
nic ib0 0000:00:02.0 1 32
nic enp4s0 0000:00:04.0 0
check "wireless and non-Ethernet links are skipped" enp4s0 "$(run appliance_pick_nic)"
fixture none
virt br-wan
check "no physical NIC prints nothing" "" "$(run appliance_pick_nic)"
check "hostname from the MAC's last 6 hex digits" spinifex-c0ffee "$(run 'appliance_hostname 52:54:00:C0:FF:EE')"
[ "$fails" -eq 0 ] || { echo " $fails failed"; exit 1; }
+191
View File
@@ -0,0 +1,191 @@
#!/bin/bash
# Runs as root on the Oracle Linux build system, one stage per call, driven by
# build-oracle-appliance.sh from the payload directory it copied over.
# prepare.sh check refuse anything but OL 10.2+ on UEK R8, x86_64
# prepare.sh update dnf update, SELinux permissive; prints PREPARE_REBOOT=1 if
# the newest kernel is not the running one
# prepare.sh install install Spinifex, uninitialised and not enabled
# prepare.sh appliance spinifex login, first-boot service, console placeholder
# prepare.sh seal wipe this system's identity, then power off
# INSTALL_SPINIFEX_CHANNEL, INSTALL_SPINIFEX_VERSION and INSTALL_SPINIFEX_TARBALL
# pick the release tarball to install. BUILD_USER is the SSH user to lock at seal.
set -euo pipefail
# Shared by every unit built from this image; first login forces a change.
DEFAULT_PASSWORD=spinifex
HERE=$(cd "$(dirname "$0")" && pwd)
SHARE=/usr/local/share/spinifex
log() { echo "[prepare] $*"; }
die() { echo "[prepare] ERROR: $*" >&2; exit 1; }
[ "$(id -u)" = 0 ] || die "run as root"
stage_check() {
# shellcheck source=/dev/null
. /etc/os-release
[ "${ID:-}" = ol ] || die "not Oracle Linux (ID=${ID:-unset})"
local major=${VERSION_ID%%.*} minor=${VERSION_ID#*.}
[ "$minor" = "$VERSION_ID" ] && minor=0
if [ "$major" -lt 10 ] || { [ "$major" -eq 10 ] && [ "$minor" -lt 2 ]; }; then
# A 10.0 or 10.1 system reaches 10.2 through the update stage, which
# calls this again with "strict" once it has run.
if [ "$major" -ne 10 ] || [ "${1:-}" = strict ]; then
die "Oracle Linux $VERSION_ID; 10.2 or later is required"
fi
log "Oracle Linux $VERSION_ID; the update stage brings it to the current minor"
fi
[ "$(uname -m)" = x86_64 ] || die "$(uname -m); only x86_64 is supported"
case $(uname -r) in
6.12.0-*uek*) ;;
*) die "running kernel $(uname -r) is not UEK R8; install kernel-uek and boot it first" ;;
esac
command -v nmcli >/dev/null || die "NetworkManager (nmcli) is required"
log "OK: $PRETTY_NAME, kernel $(uname -r)"
}
stage_update() {
setenforce 0 2>/dev/null || true
sed -i 's/^SELINUX=.*/SELINUX=permissive/' /etc/selinux/config
[ "$(getenforce)" != Enforcing ] || die "SELinux is still enforcing"
dnf update -y
stage_check strict >/dev/null
local newest
newest=$(grubby --default-kernel | sed 's|^/boot/vmlinuz-||')
case $newest in
6.12.0-*uek*) ;;
*) die "the default boot kernel is $newest, not UEK R8; set a kernel-uek default with grubby" ;;
esac
if [ "$newest" != "$(uname -r)" ]; then
echo PREPARE_REBOOT=1
fi
}
stage_install() {
[ ! -e /etc/spinifex/spinifex.toml ] || die "/etc/spinifex/spinifex.toml exists; this system has been initialised, start from a clean OL install"
# install.mulgadc.com serves the latest stable setup.sh, which may predate
# Oracle Linux support, so run the setup.sh inside the release tarball.
local dir tarball
dir=$(mktemp -d)
if [ -n "${INSTALL_SPINIFEX_TARBALL:-}" ]; then
tarball=$INSTALL_SPINIFEX_TARBALL
else
local url="https://install.mulgadc.com/download/${INSTALL_SPINIFEX_VERSION:-${INSTALL_SPINIFEX_CHANNEL:-latest}}/amd64"
tarball=$dir/spinifex.tar.gz
log "downloading $url"
curl -fsSL -o "$tarball" "$url"
curl -fsSL -o "$dir/checksum.sha256" "$url.sha256"
[ "$(awk '{print $1}' "$dir/checksum.sha256")" = "$(sha256sum "$tarball" | awk '{print $1}')" ] \
|| die "checksum mismatch for $url"
fi
tar -xzf "$tarball" -C "$dir" ./setup.sh
INSTALL_SPINIFEX_TARBALL=$tarball INSTALL_SPINIFEX_SKIP_NEWGRP=1 bash "$dir/setup.sh"
rm -rf "$dir"
# The installer enables these on a live host. The ISO image ships them off
# and first boot enables them after init; a clone that started them first
# would run every service with no config.
systemctl disable --now spinifex.target spinifex-nats-watchdog.timer
log "Spinifex $(spx version 2>/dev/null | head -1) installed, not initialised"
}
stage_appliance() {
# As on the ISO: the existing spinifex system group as primary, so the
# login can reach the sockets setup.sh group-owns, and passwordless sudo.
if ! id spinifex >/dev/null 2>&1; then
useradd --create-home --shell /bin/bash --gid spinifex --groups wheel spinifex
fi
echo "spinifex:$DEFAULT_PASSWORD" | chpasswd
chage -d 0 spinifex
echo 'spinifex ALL=(ALL) NOPASSWD:ALL' > /etc/sudoers.d/spinifex-admin
chmod 0440 /etc/sudoers.d/spinifex-admin
visudo -cf /etc/sudoers.d/spinifex-admin >/dev/null
# OL's pwquality gives one try per login, then ends the session, so say the
# rules up front. spx admin banner keeps /etc/motd content above its own
# section, and agetty shows /etc/issue.d beside the banner in /etc/issue.
local hint="First login: the new password needs 8+ characters and must not contain 'spinifex'."
printf '%s\n' "$hint" > /etc/motd
install -d /etc/issue.d
printf '%s\n\n' "$hint" > /etc/issue.d/spinifex-password.issue
# sshd takes the first value it reads, so this sorts ahead of the
# 50-cloud-init.conf that templates ship with passwords off.
printf 'PasswordAuthentication yes\n' > /etc/ssh/sshd_config.d/10-spinifex-appliance.conf
# No DHCP on every port: first boot puts the primary NIC on br-wan and
# leaves the rest alone, so no second port competes for the default route.
printf '[main]\nno-auto-default=*\n' > /etc/NetworkManager/conf.d/90-spinifex-appliance.conf
install -D -m 0644 "$HERE/lib.sh" "$SHARE/appliance/lib.sh"
install -D -m 0755 "$HERE/firstboot.sh" "$SHARE/appliance/firstboot.sh"
install -m 0644 "$HERE/spinifex-appliance-firstboot.service" "$HERE/spinifex-banner.service" /etc/systemd/system/
local getty
for getty in getty@tty1 serial-getty@ttyS0; do
install -D -m 0644 "$HERE/getty-wait.conf" "/etc/systemd/system/$getty.service.d/spinifex-wait.conf"
done
systemctl daemon-reload
systemctl enable spinifex-appliance-firstboot.service
# Cloud-init would re-run on each unit with no datasource and rewrite the
# network and users first boot owns.
if [ -d /etc/cloud ]; then
touch /etc/cloud/cloud-init.disabled
fi
log "spinifex login (default password '$DEFAULT_PASSWORD', change forced) and first-boot service installed"
}
stage_seal() {
dnf clean all
systemctl stop "$(. "$SHARE/lib/distro.sh" && distro_ovs_unit)" ovn-northd.service ovn-controller.service 2>/dev/null || true
# OVS writes the chassis UUID on first start; a shared one makes every unit
# the same chassis in OVN.
rm -f /etc/openvswitch/system-id.conf /etc/openvswitch/conf.db /etc/openvswitch/.conf.db.~lock~
rm -f /var/lib/ovn/*.db /etc/ovn/*.db
rm -f /etc/spinifex/node.conf
# Profiles for the build system's NICs, its DHCP leases and NM's
# per-machine secret. First boot writes br-wan for the unit's own NIC.
rm -f /etc/NetworkManager/system-connections/*
rm -f /var/lib/NetworkManager/*.lease /var/lib/NetworkManager/secret_key \
/var/lib/NetworkManager/timestamps /var/lib/NetworkManager/seen-bssids
if command -v cloud-init >/dev/null; then
cloud-init clean --logs || true
fi
# sshd-keygen regenerates host keys before sshd on the next boot; systemd
# generates a machine-id when the file is empty.
rm -f /etc/ssh/ssh_host_*
truncate -s 0 /etc/machine-id
rm -f /var/lib/dbus/machine-id /var/lib/systemd/random-seed
echo localhost > /etc/hostname
# The build user's access goes; the spinifex login is the way in.
if [ -n "${BUILD_USER:-}" ] && [ "$BUILD_USER" != spinifex ] && [ "$BUILD_USER" != root ]; then
local home
home=$(getent passwd "$BUILD_USER" | cut -d: -f6)
rm -f "$home/.ssh/authorized_keys" "$home/.bash_history"
passwd -l "$BUILD_USER" >/dev/null
rm -f /etc/sudoers.d/90-cloud-init-users /etc/sudoers.d/90-spinifex-build
fi
rm -f /root/.ssh/authorized_keys /root/.bash_history /home/spinifex/.bash_history
printf '\nSpinifex first boot is in progress. This screen updates when it finishes.\n\n' > /etc/issue
sed -i '/^# --- Spinifex ---$/,$d' /etc/motd 2>/dev/null || true
find /var/log -type f -exec truncate -s 0 {} + 2>/dev/null || true
rm -rf /var/log/journal/* /tmp/* /var/tmp/*
sync
log "sealed; powering off"
systemctl poweroff --no-block
}
case "${1:-}" in
check) stage_check ;;
update) stage_update ;;
install) stage_install ;;
appliance) stage_appliance ;;
seal) stage_seal ;;
*) die "usage: $0 check|update|install|appliance|seal" ;;
esac
@@ -0,0 +1,22 @@
[Unit]
Description=Spinifex appliance first-boot provisioning
After=NetworkManager.service network-online.target chronyd.service
Wants=NetworkManager.service network-online.target
ConditionPathExists=!/var/lib/spinifex/.appliance-firstboot-done
[Service]
Type=oneshot
Environment=HOME=/root
ExecStart=/usr/local/share/spinifex/appliance/firstboot.sh
RemainAfterExit=yes
# The banner is never enabled when first boot fails, so without this the
# console would keep the "first boot in progress" placeholder.
ExecStopPost=/bin/sh -c 'if [ "$EXIT_STATUS" != "0" ]; then printf "\n*** Spinifex first boot FAILED ***\nLog in as spinifex, then run:\n sudo journalctl -u spinifex-appliance-firstboot.service\nCheck the network cable and DHCP, then reboot to retry.\n\n" > /etc/issue; fi'
# setup-ovn.sh and spx admin init must not wedge multi-user.target and keep
# getty from ever showing a login prompt.
TimeoutStartSec=600s
StandardOutput=journal+console
StandardError=journal+console
[Install]
WantedBy=multi-user.target
+15
View File
@@ -0,0 +1,15 @@
[Unit]
Description=Spinifex console banner and boot health check
After=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/bin/spx admin banner --boot-check
RemainAfterExit=yes
# Cap it so a stuck boot-check cannot hold getty behind the console drop-in.
TimeoutStartSec=30s
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
+113
View File
@@ -0,0 +1,113 @@
#!/bin/bash
# build-oracle-appliance.sh — turn an Oracle Linux 10.2 system reachable over
# SSH with sudo into a sealed Spinifex gold image: installed, not initialised,
# with a first-boot service that sets each cloned unit up as a single node.
# The system powers off at the end; capture its disk as the image.
#
# Usage: build-oracle-appliance.sh [options] <user>@<host>
# --channel latest|dev install channel (default: latest)
# --version <tag> install a specific release
# --tarball <path> install a local release tarball instead
#
# Ports, keys and jump hosts come from ~/.ssh/config. <user> needs sudo;
# without passwordless sudo, sudo asks for its password once.
set -euo pipefail
HERE=$(cd "$(dirname "$0")" && pwd)
PAYLOAD_SRC="$HERE/appliance"
REMOTE_DIR=/tmp/spinifex-appliance
CHANNEL="" VERSION="" TARBALL="" TARGET=""
usage() { sed -n '2,13p' "$0" | sed 's/^# \{0,1\}//'; exit "${1:-0}"; }
log() { echo "==> $*"; }
die() { echo "ERROR: $*" >&2; exit 1; }
while [ $# -gt 0 ]; do
case "$1" in
--channel) CHANNEL="${2:-}"; shift ;;
--version) VERSION="${2:-}"; shift ;;
--tarball) TARBALL="${2:-}"; shift ;;
-h | --help) usage ;;
-*) die "unknown option: $1" ;;
*) [ -z "$TARGET" ] || die "one target only"; TARGET=$1 ;;
esac
shift
done
[ -n "$TARGET" ] || usage 1
case $TARGET in *@*) ;; *) die "target must be <user>@<host>" ;; esac
BUILD_USER=${TARGET%@*}
[ -z "$TARBALL" ] || [ -f "$TARBALL" ] || die "no such tarball: $TARBALL"
# One multiplexed connection; host key checking is relaxed because the build
# system is usually a fresh install whose key nobody has recorded yet.
CTL_DIR=$(mktemp -d)
trap 'ssh -o ControlPath="$CTL_DIR/c" -O exit "$TARGET" >/dev/null 2>&1 || true; rm -rf "$CTL_DIR"' EXIT
SSH_OPTS=(-o ControlMaster=auto -o ControlPath="$CTL_DIR/c" -o ControlPersist=60
-o StrictHostKeyChecking=accept-new -o ServerAliveInterval=15 -o ConnectTimeout=10)
rsh() { ssh "${SSH_OPTS[@]}" "$TARGET" "$@"; }
# prepare <stage> — run one prepare.sh stage as root on the build system.
prepare() {
rsh "sudo env INSTALL_SPINIFEX_CHANNEL='$CHANNEL' INSTALL_SPINIFEX_VERSION='$VERSION' \
INSTALL_SPINIFEX_TARBALL='${TARBALL:+$REMOTE_DIR/$(basename "$TARBALL")}' \
BUILD_USER='$BUILD_USER' bash $REMOTE_DIR/prepare.sh $1"
}
push_payload() {
rsh "rm -rf $REMOTE_DIR && mkdir -p $REMOTE_DIR"
tar -C "$PAYLOAD_SRC" -cf - . | rsh "tar -C $REMOTE_DIR -xf -"
if [ -n "$TARBALL" ]; then
scp "${SSH_OPTS[@]}" -q "$TARBALL" "$TARGET:$REMOTE_DIR/"
fi
}
wait_for_ssh() {
local _i
ssh "${SSH_OPTS[@]}" -O exit "$TARGET" >/dev/null 2>&1 || true
sleep 10
for _i in $(seq 1 60); do
if rsh true 2>/dev/null; then return 0; fi
sleep 5
done
die "$TARGET did not come back over SSH after the reboot"
}
rsh true || die "cannot SSH to $TARGET; set its key in ~/.ssh/config (IdentityFile) or ssh-add it"
# The stages run sudo with no terminal, so grant passwordless sudo for the
# build when the user lacks it. Seal removes the grant.
if ! rsh "sudo -n true" 2>/dev/null; then
log "Granting $BUILD_USER passwordless sudo for the build; sudo asks for its password once"
ssh "${SSH_OPTS[@]}" -t "$TARGET" "echo '$BUILD_USER ALL=(ALL) NOPASSWD:ALL' | sudo tee /etc/sudoers.d/90-spinifex-build >/dev/null && sudo chmod 0440 /etc/sudoers.d/90-spinifex-build" \
|| die "could not grant $BUILD_USER passwordless sudo on the build system"
rsh "sudo -n true" || die "$BUILD_USER still has no passwordless sudo on the build system"
fi
# A minimal OL install has no tar, which the payload copy and the installer need.
rsh "command -v tar >/dev/null || sudo dnf install -y -q tar" || die "cannot install tar on the build system"
log "Copying the payload to $TARGET"
push_payload
prepare check
log "Updating Oracle Linux"
prepare update | tee "$CTL_DIR/update.log"
if grep -qx 'PREPARE_REBOOT=1' "$CTL_DIR/update.log"; then
log "Rebooting into the updated kernel"
rsh "sudo systemctl reboot" || true
wait_for_ssh
# /tmp is cleared on boot.
push_payload
prepare check
fi
log "Installing Spinifex (not initialising it)"
prepare install
log "Installing the spinifex login and the first-boot service"
prepare appliance
log "Sealing; the system powers off when done"
prepare seal || true
log "Done. Capture $TARGET's disk once it has powered off; each unit set up from it initialises itself on first boot."
@@ -25,7 +25,7 @@ error_log="/dev/console"
if [ -f /etc/spinifex-eks/first-boot.env ]; then
# shellcheck disable=SC1091
. /etc/spinifex-eks/first-boot.env
export EKS_GATEWAY_URL EKS_GATEWAY_CA EKS_ACCESS_KEY EKS_SECRET_KEY EKS_REGION EKS_ACCOUNT_ID EKS_CLUSTER_NAME
export EKS_GATEWAY_URL EKS_GATEWAY_CA EKS_ACCESS_KEY EKS_SECRET_KEY EKS_REGION EKS_ACCOUNT_ID EKS_CLUSTER_NAME EKS_WEBHOOK_READY_ADDR
fi
depend() {
+1 -14
View File
@@ -298,7 +298,6 @@ log "preflight on $N host(s)"
versions=""
families=""
libreswan_hosts=""
for i in $(seq 0 $((N - 1))); do
host="${HOSTS[$i]}"
@@ -322,12 +321,6 @@ for i in $(seq 0 $((N - 1))); do
fail "$host: cannot tell its OS from /etc/os-release"
families+="${host}: ${family}"$'\n'
# libreswan's pluto: Spinifex cannot run OVN IPsec on it yet, and spx admin
# init refuses --ipsec=true there, which would stop formation part way.
if [ "$(out "$host" "test -x /usr/libexec/ipsec/pluto && echo yes")" = "yes" ]; then
libreswan_hosts+=" $host"
fi
# A node that is already in a multi-node cluster is one whose keys and
# logical network state are load-bearing. Refusing here is deliberate:
# forming a cluster must never be the command that destroys a live one.
@@ -386,12 +379,6 @@ $(sed '/^$/d; s/^/ /' <<<"$families")
A cluster must run one OS on every host."
fi
if $IPSEC_ENABLED && [ -n "$libreswan_hosts" ]; then
fail "--ipsec on, but these hosts use libreswan for IPsec:$libreswan_hosts
Spinifex cannot run OVN IPsec on libreswan yet. Re-run with --ipsec off
only if the nodes share a trusted private link."
fi
# --- Confirm ---------------------------------------------------------------
echo ""
@@ -460,7 +447,7 @@ done
# Matched on the service argv so the ssh shell running this cannot match itself.
for host in "${HOSTS[@]}"; do
$DRY_RUN && { echo " [dry-run] $host: wait for every 'spx service' process to exit"; continue; }
on "$host" "for u in spinifex-predastore spinifex-viperblock spinifex-sqs-store spinifex-nats; do
on "$host" "for u in spinifex-predastore spinifex-viperblock spinifex-nats; do
systemctl is-active --quiet \$u && sudo systemctl stop \$u
done
for _ in \$(seq 1 45); do
+158
View File
@@ -0,0 +1,158 @@
#!/bin/sh
# ipsec_state_helper_test.sh — tests for the plaintext Geneve guard in the
# IPsec state helper that setup.sh installs.
#
# The helper is cut out of setup.sh's heredoc and run against stub nft,
# ovs-vsctl and systemctl commands, with pluto's path pointed at a fixture.
set -eu
SCRIPT_DIR=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd)
SETUP="$SCRIPT_DIR/setup.sh"
TMP=$(mktemp -d)
trap 'rm -rf "$TMP"' EXIT
fails=0
pass() { echo " ok - $1"; }
fail() { echo " FAIL - $1"; fails=$((fails + 1)); }
echo "-- IPsec state helper: plaintext Geneve guard"
HELPER="$TMP/helper"
awk '/tee "\$\{IPSEC_STATE_HELPER\}"/ { on = 1; next } on && /^HELPER$/ { exit } on { print }' "$SETUP" |
sed "s#/usr/libexec/ipsec/pluto#$TMP/pluto#g" > "$HELPER"
chmod +x "$HELPER"
grep -q '^ guard)' "$HELPER" || { echo "could not extract the helper from setup.sh" >&2; exit 1; }
mkdir -p "$TMP/bin"
cat > "$TMP/bin/nft" <<EOF
#!/bin/sh
[ "\$*" = "-f -" ] || { echo "nft \$*" >> "$TMP/nft.log"; exit 0; }
cat >> "$TMP/nft.log"
echo "--" >> "$TMP/nft.log"
EOF
cat > "$TMP/bin/ovs-vsctl" <<EOF
#!/bin/sh
[ -e "$TMP/ovs-down" ] && { echo "ovs-vsctl: database connection failed" >&2; exit 1; }
case "\$*" in
*" find Interface type=geneve") cat "$TMP/ifaces" ;;
*" get Interface "*)
for last; do :; done
f="$TMP/opt.\$5.\${last#options:}"
if [ -e "\$f" ]; then cat "\$f"; else echo; fi
;;
*" get Open_vSwitch . external_ids:ovn-encap-ip")
if [ -e "$TMP/encap" ]; then cat "$TMP/encap"; else echo; fi
;;
*) exit 1 ;;
esac
EOF
printf '#!/bin/sh\nexit 0\n' > "$TMP/bin/systemctl"
chmod +x "$TMP/bin/nft" "$TMP/bin/ovs-vsctl" "$TMP/bin/systemctl"
# reset — no tunnels, OVS up, no pluto.
reset() {
rm -f "$TMP"/opt.* "$TMP/ovs-down" "$TMP/nft.log" "$TMP/pluto" "$TMP/encap"
: > "$TMP/ifaces"
touch "$TMP/nft.log"
}
# tunnel <name> <local_ip|-> [remote_name] — a geneve interface as ovn-controller writes it.
tunnel() {
echo "$1" >> "$TMP/ifaces"
[ "$2" = - ] || printf '"%s"\n' "$2" > "$TMP/opt.$1.local_ip"
[ -z "${3:-}" ] || echo "$3" > "$TMP/opt.$1.remote_name"
}
run() {
RC=0
OUT=$(PATH="$TMP/bin:$PATH" "$HELPER" "$1" 2>&1) || RC=$?
NFTLOG=$(cat "$TMP/nft.log")
}
LIFTED="table inet spinifex_ipsec_guard
delete table inet spinifex_ipsec_guard
--"
# expect_guarded <desc> <sources> — one transaction that swaps in a table
# dropping Geneve without an SA from exactly those sources.
expect_guarded() {
if [ "$RC" = 0 ] &&
[ "$(grep -c '^--$' "$TMP/nft.log")" = 1 ] &&
[ "$(sed -n 2p "$TMP/nft.log")" = "delete table inet spinifex_ipsec_guard" ] &&
grep -q "^ elements = { $2 }$" "$TMP/nft.log" &&
grep -q '^ ip saddr @srcs udp dport 6081 rt ipsec missing drop$' "$TMP/nft.log"; then
pass "$1"
else
fail "$1 (rc=$RC): got [$NFTLOG] want sources [$2] $OUT"
fi
}
expect_lifted() {
if [ "$RC" = 0 ] && [ "$NFTLOG" = "$LIFTED" ]; then pass "$1"; else fail "$1 (rc=$RC): got [$NFTLOG] $OUT"; fi
}
reset
tunnel ovn-node2-0 10.26.7.1 node2
tunnel ovn-node3-0 10.26.7.1 node3
run guard
expect_guarded "guards the encap address once OVN wants the tunnels encrypted" "10.26.7.1/32"
reset
tunnel ovn-node2-0 10.26.7.1
tunnel ovn-node3-0 10.26.7.1
run guard
expect_lifted "lifts the guard while OVN runs the tunnels in plaintext"
reset
tunnel ovn-node2-0 10.26.7.1 node2
tunnel ovn-node3-0 10.26.8.1
run guard
expect_guarded "guards only the addresses of tunnels OVN encrypts" "10.26.7.1/32"
reset
tunnel ovn-node2-0 - node2
run guard
expect_guarded "guards a tunnel without a local_ip from every source" "0.0.0.0/0"
reset
echo '"10.26.7.1"' > "$TMP/encap"
run guard
expect_guarded "guards the encap address before ovn-controller has built the tunnels" "10.26.7.1/32"
reset
echo '"10.26.7.1,10.26.8.1"' > "$TMP/encap"
run guard
expect_guarded "guards each encap address of a multi-address host before the tunnels exist" "10.26.7.1/32,10.26.8.1/32"
reset
run guard
expect_guarded "guards every source before the tunnels exist when no encap address is set" "0.0.0.0/0"
reset
tunnel ovn-node2-0 10.26.7.1 node2
touch "$TMP/ovs-down"
run guard
if [ "$RC" != 0 ] && [ -z "$NFTLOG" ]; then
pass "keeps the guard and fails when OVS cannot be read"
else
fail "with OVS down: rc=$RC nft calls [$NFTLOG]"
fi
reset
touch "$TMP/pluto"
chmod +x "$TMP/pluto"
run off
expect_lifted "off lifts the guard on a libreswan host"
reset
run off
if [ "$RC" = 0 ] && [ -z "$NFTLOG" ]; then pass "off leaves nft alone without libreswan"; else fail "off without pluto: rc=$RC [$NFTLOG]"; fi
reset
run unguard
expect_lifted "unguard lifts the guard"
reset
run bogus
if [ "$RC" = 2 ] && [ -z "$NFTLOG" ]; then pass "refuses an unknown verb"; else fail "unknown verb: rc=$RC [$NFTLOG]"; fi
if [ "$fails" -gt 0 ]; then
echo "$fails failure(s)"
exit 1
fi
echo "all passed"
+11 -2
View File
@@ -30,10 +30,19 @@ distro_pkg_manager() {
}
# The OVS umbrella unit: openvswitch-switch.service on Debian, openvswitch.service on EL.
# Ubuntu's openvswitch-switch declares Alias=openvswitch.service; the symlink that
# enabling it leaves points back at openvswitch-switch.service and is not the EL unit.
distro_ovs_unit() {
local deb=false el=false
local deb=false el=false dir
distro_unit_exists openvswitch-switch.service && deb=true
distro_unit_exists openvswitch.service && el=true
for dir in /etc/systemd/system /run/systemd/system /usr/lib/systemd/system /lib/systemd/system; do
if [ -e "${DISTRO_ROOT:-}$dir/openvswitch.service" ] || [ -L "${DISTRO_ROOT:-}$dir/openvswitch.service" ]; then
case $(readlink "${DISTRO_ROOT:-}$dir/openvswitch.service" || true) in
openvswitch-switch.service | */openvswitch-switch.service) ;;
*) el=true ;;
esac
fi
done
if $deb && $el; then
echo "distro.sh: OVS unit: found both openvswitch-switch.service and openvswitch.service; cannot tell which one owns OVS" >&2
return 1
+12 -1
View File
@@ -26,6 +26,8 @@ fixture() {
unit() { mkdir -p "$ROOT/usr/lib/systemd/system" && : > "$ROOT/usr/lib/systemd/system/$1"; }
file() { mkdir -p "$ROOT$(dirname "$1")" && : > "$ROOT$1"; }
cmd() { printf '#!/bin/sh\nexit 0\n' > "$BIN/$1" && chmod +x "$BIN/$1"; }
# realcmd <name> — the host's real command, for probes that read the fixture with it.
realcmd() { ln -s "$(command -v "$1")" "$BIN/$1"; }
# probe <fn> [arg...] — runs one probe in bash against the current fixture,
# with only the stub dir on PATH. Sets OUT, ERR and RC.
@@ -73,13 +75,22 @@ expect_ok "OVS unit: Debian name" openvswitch-switch.service
fixture ovs-el; unit openvswitch.service; probe distro_ovs_unit
expect_ok "OVS unit: EL name" openvswitch.service
fixture ovs-masked; mkdir -p "$ROOT/etc/systemd/system"
ln -s /dev/null "$ROOT/etc/systemd/system/openvswitch.service"; probe distro_ovs_unit
ln -s /dev/null "$ROOT/etc/systemd/system/openvswitch.service"; realcmd readlink; probe distro_ovs_unit
expect_ok "OVS unit: a masked unit in /etc counts" openvswitch.service
fixture ovs-lib; mkdir -p "$ROOT/lib/systemd/system"
: > "$ROOT/lib/systemd/system/openvswitch-switch.service"; probe distro_ovs_unit
expect_ok "OVS unit: found under /lib on an unmerged-/usr host" openvswitch-switch.service
fixture ovs-both; unit openvswitch-switch.service; unit openvswitch.service; probe distro_ovs_unit
expect_fail "OVS unit: both fails naming both" openvswitch-switch.service openvswitch.service
fixture ovs-ubuntu-alias; unit openvswitch-switch.service; mkdir -p "$ROOT/etc/systemd/system"
ln -s /usr/lib/systemd/system/openvswitch-switch.service "$ROOT/etc/systemd/system/openvswitch.service"; realcmd readlink; probe distro_ovs_unit
expect_ok "OVS unit: Ubuntu's openvswitch.service alias is the Debian unit" openvswitch-switch.service
fixture ovs-relative-alias; unit openvswitch-switch.service; mkdir -p "$ROOT/etc/systemd/system"
ln -s openvswitch-switch.service "$ROOT/etc/systemd/system/openvswitch.service"; realcmd readlink; probe distro_ovs_unit
expect_ok "OVS unit: a relative alias symlink is the Debian unit" openvswitch-switch.service
fixture ovs-alias-and-el; unit openvswitch-switch.service; unit openvswitch.service; mkdir -p "$ROOT/etc/systemd/system"
ln -s /usr/lib/systemd/system/openvswitch-switch.service "$ROOT/etc/systemd/system/openvswitch.service"; realcmd readlink; probe distro_ovs_unit
expect_fail "OVS unit: an alias beside a real EL unit still fails naming both" openvswitch-switch.service openvswitch.service
fixture ovs-none; probe distro_ovs_unit
expect_fail "OVS unit: neither fails naming both" openvswitch-switch.service openvswitch.service
+37 -7
View File
@@ -71,9 +71,16 @@ distro_unit_exists() {
}
distro_ovs_unit() {
local deb=false el=false
local deb=false el=false dir
distro_unit_exists openvswitch-switch.service && deb=true
distro_unit_exists openvswitch.service && el=true
for dir in /etc/systemd/system /run/systemd/system /usr/lib/systemd/system /lib/systemd/system; do
if [ -e "${DISTRO_ROOT:-}$dir/openvswitch.service" ] || [ -L "${DISTRO_ROOT:-}$dir/openvswitch.service" ]; then
case $(readlink "${DISTRO_ROOT:-}$dir/openvswitch.service" || true) in
openvswitch-switch.service | */openvswitch-switch.service) ;;
*) el=true ;;
esac
fi
done
if $deb && $el; then
echo "distro.sh: OVS unit: found both openvswitch-switch.service and openvswitch.service; cannot tell which one owns OVS" >&2
return 1
@@ -183,7 +190,7 @@ run sudo systemctl reset-failed 'spinifex-*' 2>/dev/null || true
# the wipe and before the verification, failing the reset on a file the script
# had already deleted. update-nodes.sh carries the same wait.
if ! $DRY_RUN; then
for unit in spinifex-predastore spinifex-viperblock spinifex-sqs-store spinifex-nats; do
for unit in spinifex-predastore spinifex-viperblock spinifex-nats; do
if systemctl is-active --quiet "$unit"; then
sudo systemctl stop "$unit" || true
fi
@@ -266,6 +273,33 @@ if [ -n "$OVS_UNIT" ]; then
fi
run sudo rm -f /etc/openvswitch/system-id.conf
# --- BEGIN ovs nss nicknames ---
# ovs_nss_nicknames — the entries ovs-monitor-ipsec imported, read from
# `certutil -L` on stdin: ovs_certkey_* is this node's certificate and key,
# ovs_cert_* the CA and any peer certificates. Everything else is someone else's.
ovs_nss_nicknames() {
awk '$1 ~ /^ovs_cert(key)?_/ {print $1}' | LC_ALL=C sort -u
}
# --- END ovs nss nicknames ---
# A node rejoining another cluster must not keep trusting this one's CA. Only
# the NSS entries go: other identities and /etc/ipsec.d/*.conf are left alone.
if [ -x /usr/libexec/ipsec/pluto ] && command -v certutil >/dev/null 2>&1; then
log "removing Spinifex IPsec identities from libreswan's NSS database"
# A running ovs-monitor-ipsec imports them again from other_config.
run sudo systemctl stop openvswitch-ipsec.service 2>/dev/null || true
for nss in /var/lib/ipsec/nss /etc/ipsec.d; do
sudo test -e "$nss/cert9.db" || continue
for nick in $(sudo certutil -L -d "sql:$nss" 2>/dev/null | ovs_nss_nicknames); do
log " deleting $nick from $nss"
delete=-D
case "$nick" in ovs_certkey_*) delete=-F ;; esac
run sudo certutil "$delete" -d "sql:$nss" -n "$nick" ||
log " NOTE: could not delete $nick from $nss; remove it with certutil by hand"
done
done
fi
# Delete the OVN DBs outright — a caller re-running setup-ovn.sh gets fresh
# empty ones. This is what clears stale chassis rows and port bindings, which
# otherwise accumulate across resets and wedge ovn-controller in a commit loop.
@@ -394,10 +428,6 @@ if ! $KEEP_DATA && [ -d "$DATA_DIR/nats/jetstream" ]; then
log "removing the JetStream store"
run sudo rm -rf "$DATA_DIR/nats/jetstream"
fi
if ! $KEEP_DATA && [ -d "$DATA_DIR/sqs-store/jetstream" ]; then
log "removing the SQS store JetStream data"
run sudo rm -rf "$DATA_DIR/sqs-store/jetstream"
fi
# Directories may survive — a mountpoint cannot be removed, and neither can the
# path leading down to one. Files may not: a surviving file is state that would
+28 -1
View File
@@ -1,7 +1,8 @@
#!/bin/sh
# node-reset_test.sh — tests the QEMU process match node-reset.sh uses to stop
# guests before it deletes Viperblock state, against real processes, and the
# instance and mounted-volume counts it and uninstall-spx.sh report before deleting.
# instance and mounted-volume counts it and uninstall-spx.sh report before deleting,
# and the libreswan NSS entries it deletes.
#
# Each fake guest is a copy of sleep named like a QEMU binary, so the kernel
# gives it the same comm, including the 15-character truncation.
@@ -129,6 +130,32 @@ mkdir -p "$empty"
expect "counts no instances without a state file" "$(count count_instances "$empty")" 0
expect "counts no mounted volumes without a viperblock directory" "$(count count_mounted_volumes "$empty")" 0
echo "-- node-reset.sh libreswan NSS nicknames"
sed -n '/^# --- BEGIN ovs nss nicknames ---$/,/^# --- END ovs nss nicknames ---$/p' \
"$SCRIPT_DIR/node-reset.sh" > "$TMP/nss.sh"
# `certutil -L` as it prints a database holding an operator's own identity and
# CA beside what ovs-monitor-ipsec imported, one peer listed twice.
cat > "$TMP/certutil-L" <<'EOF'
Certificate Nickname Trust Attributes
SSL,S/MIME,JAR/XPI
ovs_certkey_node1 u,u,u
ovs_cert_cacert CT,,
ovs_cert_node2 P,P,P
ovs_cert_node2 P,P,P
site-vpn u,u,u
corp-ca CT,C,C
ovs_certificate_store ,,
EOF
if [ ! -s "$TMP/nss.sh" ]; then
fail "no ovs nss nicknames block in node-reset.sh"
else
got=$(bash -c '. "$1"; ovs_nss_nicknames < "$2"' _ "$TMP/nss.sh" "$TMP/certutil-L" | tr '\n' ' ')
expect "selects only ovs-monitor-ipsec's entries, once each" "$got" "ovs_cert_cacert ovs_cert_node2 ovs_certkey_node1 "
fi
if [ "$fails" -gt 0 ]; then
echo "$fails failure(s)"
exit 1
+72 -31
View File
@@ -172,6 +172,8 @@ OVN_DBDIR="${OVN_DBDIR:-/var/lib/ovn}"
# Seconds to wait for the database servers to exit, then for a SIGKILL to land.
OVN_DB_STOP_WAIT="${OVN_DB_STOP_WAIT:-30}"
OVN_DB_KILL_WAIT="${OVN_DB_KILL_WAIT:-10}"
# Seconds to wait for a restarted clustered NB or SB member to rejoin its cluster.
OVN_RAFT_REJOIN_WAIT="${OVN_RAFT_REJOIN_WAIT:-60}"
# Records that --management set this node up. Nodes set up before it existed
# carry only the legacy marker, OVN_CTL_OPTS in /etc/default/ovn-central.
OVN_ROLE_FILE=/etc/spinifex/ovn-role
@@ -757,6 +759,42 @@ stop_ovn_databases() {
exit 1
}
# db_clients — sets NBCTL/SBCTL. A clustered DB has no unix socket to fall back
# on, and a follower refuses to answer at all unless leader-only is turned off;
# writes through a follower are forwarded to the leader.
db_clients() {
NBCTL=(ovn-nbctl)
SBCTL=(ovn-sbctl)
if [ -n "$DB_CLUSTER_LOCAL_ADDR" ]; then
NBCTL=(ovn-nbctl --db="tcp:$DB_CLUSTER_LOCAL_ADDR:6641" --no-leader-only)
SBCTL=(ovn-sbctl --db="tcp:$DB_CLUSTER_LOCAL_ADDR:6642" --no-leader-only)
fi
}
# set_client_connections — the loopback client listeners. A rerun's restart can
# hand the leadership away, so this goes through NBCTL/SBCTL, not the socket.
set_client_connections() {
sudo "${NBCTL[@]}" set-connection ptcp:6641:127.0.0.1
sudo "${SBCTL[@]}" set-connection ptcp:6642:127.0.0.1
}
# wait_for_raft_member <ctl> <schema> — waits until this server is a cluster
# member that knows its leader, which a restarted member is once it rejoins.
# Returns 1 on timeout.
wait_for_raft_member() {
local ctl=$1 schema=$2 status i
for i in $(seq 1 "$OVN_RAFT_REJOIN_WAIT"); do
status=$(sudo ovs-appctl -t "$ctl" cluster/status "$schema" 2>/dev/null || true)
if grep -qx 'Status: cluster member' <<<"$status" &&
grep -q '^Leader: ' <<<"$status" && ! grep -qx 'Leader: unknown' <<<"$status"; then
return 0
fi
echo " Waiting for $schema to rejoin its RAFT cluster... ($i/$OVN_RAFT_REJOIN_WAIT)"
sleep 1
done
return 1
}
# --- Step 2: Enable services ---
# ovn-ctl consults the RAFT flags only when it creates a database, so any
# existing DB file is served as it stands and the cluster flags are ignored.
@@ -996,16 +1034,9 @@ EOF
fi
fi
# A clustered DB has no unix socket to fall back on, and a follower refuses
# to answer at all unless leader-only is turned off. Without both of these
# the waits below can never succeed on a clustered node: they burn their
# full timeout, gate nothing, and the run still continues.
NBCTL=(ovn-nbctl)
SBCTL=(ovn-sbctl)
if [ -n "$DB_CLUSTER_LOCAL_ADDR" ]; then
NBCTL=(ovn-nbctl --db="tcp:$DB_CLUSTER_LOCAL_ADDR:6641" --no-leader-only)
SBCTL=(ovn-sbctl --db="tcp:$DB_CLUSTER_LOCAL_ADDR:6642" --no-leader-only)
fi
# Without these the waits below can never succeed on a clustered node:
# they burn their full timeout, gate nothing, and the run still continues.
db_clients
# Wait for OVN NB DB socket to become available
for i in $(seq 1 15); do
@@ -1067,29 +1098,34 @@ EOF
# with no join step to absorb the window — attaching before the leader is
# elected (and before northd writes the gateway logical flows) leaves it with
# a partial datapath that never reconverges. Gate on SB reachable and, when
# clustered, an elected leader.
# clustered, both members back in their clusters under an elected leader.
for i in $(seq 1 30); do
if sudo "${SBCTL[@]}" --timeout=2 show >/dev/null 2>&1; then
if [ -z "$DB_CLUSTER_LOCAL_ADDR" ]; then
break
fi
SB_LEADER=$(sudo ovs-appctl -t /var/run/ovn/ovnsb_db.ctl \
cluster/status OVN_Southbound 2>/dev/null \
| awk '/^Leader:/{print $2; exit}')
if [ -n "$SB_LEADER" ] && [ "$SB_LEADER" != "unknown" ]; then
break
fi
fi
echo " Waiting for OVN SB DB leader... ($i/30)"
sudo "${SBCTL[@]}" --timeout=2 show >/dev/null 2>&1 && break
echo " Waiting for OVN SB DB... ($i/30)"
sleep 1
done
# The restart above took both of this node's members down at once. Returning
# before they rejoin lets a rerun on the next database node take a second
# member of each cluster down, and one of three left is below quorum.
if [ -n "$DB_CLUSTER_LOCAL_ADDR" ]; then
for member in "/var/run/ovn/ovnnb_db.ctl OVN_Northbound" "/var/run/ovn/ovnsb_db.ctl OVN_Southbound"; do
# shellcheck disable=SC2086 # two words: the ctl socket and the schema
if ! wait_for_raft_member $member; then
echo "ERROR: this node's ${member#* } server is not back in its RAFT cluster after ${OVN_RAFT_REJOIN_WAIT}s." >&2
echo " Restarting OVN central on another database node now would leave the cluster" >&2
echo " without quorum. Check: sudo ovs-appctl -t ${member% *} cluster/status ${member#* }" >&2
exit 1
fi
done
echo " RAFT: NB and SB members rejoined, leader elected"
fi
# set-connection replicates through RAFT, so it only runs on the create
# node. Scoped to loopback, not a wildcard ptcp:6641, because 127.0.0.1
# is valid on every node; the differing LAN address is set above instead.
if [ -z "$DB_CLUSTER_REMOTE_ADDR" ]; then
sudo ovn-nbctl set-connection ptcp:6641:127.0.0.1
sudo ovn-sbctl set-connection ptcp:6642:127.0.0.1
set_client_connections
if [ -n "$LAN_ADDR" ]; then
echo " OVN NB DB listening on tcp:127.0.0.1:6641, tcp:$LAN_ADDR:6641"
echo " OVN SB DB listening on tcp:127.0.0.1:6642, tcp:$LAN_ADDR:6642"
@@ -1958,16 +1994,21 @@ echo "Step 10: Enabling OVN auto-start on boot..."
sudo systemctl enable "$OVS_UNIT" 2>/dev/null || true
sudo systemctl enable ovn-controller 2>/dev/null || true
printf ' %-21s enabled on boot\n' "${OVS_UNIT%.service}:" ovn-controller:
if distro_has_libreswan; then
# OVN IPsec does not run on libreswan yet, so it stays off here. Starting
# openvswitch-ipsec would also start pluto, which listens on UDP 500/4500.
if distro_has_libreswan && [ "$(systemctl is-active openvswitch-ipsec.service 2>/dev/null)" = active ]; then
# The daemon turned IPsec on; stopping it here would drop live tunnels.
printf ' %-21s %s\n' openvswitch-ipsec: "active: IPsec is on, left to the daemon" ipsec: "active: IPsec is on, left to the daemon"
elif distro_has_libreswan; then
# Off until the daemon asks: pluto listens on UDP 500/4500 once started.
# Enabled under the mask, so the daemon's unmask also brings both back on boot.
IPSEC_UNITS=(ipsec.service)
distro_unit_exists openvswitch-ipsec.service && IPSEC_UNITS+=(openvswitch-ipsec.service)
if ! sudo systemctl mask --now "${IPSEC_UNITS[@]}" >/dev/null; then
echo "ERROR: could not stop and mask ${IPSEC_UNITS[*]}; IPsec must stay off on libreswan" >&2
if ! sudo systemctl unmask "${IPSEC_UNITS[@]}" >/dev/null 2>&1 ||
! sudo systemctl enable "${IPSEC_UNITS[@]}" >/dev/null 2>&1 ||
! sudo systemctl mask --now "${IPSEC_UNITS[@]}" >/dev/null; then
echo "ERROR: could not enable, stop and mask ${IPSEC_UNITS[*]}; IPsec must stay off until the daemon turns it on" >&2
exit 1
fi
printf ' %-21s %s\n' openvswitch-ipsec: "masked: IPsec stays off on libreswan" ipsec: "masked: IPsec stays off on libreswan"
printf ' %-21s %s\n' openvswitch-ipsec: "masked: IPsec is off, left to the daemon" ipsec: "masked: IPsec is off, left to the daemon"
elif [ "$(systemctl is-enabled openvswitch-ipsec.service 2>/dev/null)" = masked ]; then
# The daemon masks it while IPsec is off and unmasks it to turn IPsec on.
printf ' %-21s %s\n' openvswitch-ipsec: "masked: IPsec is off, left to the daemon"
+68
View File
@@ -454,6 +454,47 @@ case "$RC:$OUT" in
*) fail "no recreate-db: standalone DB rc=$RC out=$OUT" ;;
esac
# --------------------------------------------------------------------------
# A rerun returns only once both of this node's RAFT members have rejoined.
# --------------------------------------------------------------------------
# A rerun restarts the node's NB and SB members together, so returning early
# lets the next database node's rerun take a second member of each down.
awk '$0 == "wait_for_raft_member() {" { on = 1 } on { print } on && $0 == "}" { exit }' "$SETUP" >"$TMP/raft_fn.sh"
mkdir -p "$TMP/raftbin"
printf '#!/bin/sh\nexit 0\n' >"$TMP/raftbin/sleep"
printf '#!/bin/sh\n[ -f "%s" ] || exit 1\ncat "%s"\n' "$TMP/cluster-status" "$TMP/cluster-status" >"$TMP/raftbin/ovs-appctl"
chmod +x "$TMP/raftbin/sleep" "$TMP/raftbin/ovs-appctl"
# raft_member <Status:> <Leader:> — runs wait_for_raft_member against a
# cluster/status in that state, or none when Status is "absent"; sets RC.
raft_member() {
rm -f "$TMP/cluster-status"
if [ "$1" != absent ]; then
printf 'a1b2\nName: OVN_Northbound\nStatus: %s\nRole: follower\nTerm: 4\nLeader: %s\nVote: c3d4\n\nServers:\n a1b2 (a1b2 at tcp:10.0.0.1:6643) (self)\n c3d4 (c3d4 at tcp:10.0.0.2:6643)\n' \
"$1" "$2" >"$TMP/cluster-status"
fi
RC=0
PATH="$TMP/raftbin:$PATH" bash -c 'sudo() { "$@"; }; OVN_RAFT_REJOIN_WAIT=2; . "$1"
wait_for_raft_member /var/run/ovn/ovnnb_db.ctl OVN_Northbound' _ "$TMP/raft_fn.sh" >/dev/null 2>&1 || RC=$?
}
if [ ! -s "$TMP/raft_fn.sh" ]; then
fail "raft: no wait_for_raft_member in setup-ovn.sh"
else
raft_member "cluster member" c3d4
if [ "$RC" = 0 ]; then pass "raft: a member following a known leader has rejoined"; else fail "raft: follower with leader rc=$RC"; fi
raft_member "cluster member" self
if [ "$RC" = 0 ]; then pass "raft: a member that is the leader has rejoined"; else fail "raft: leader rc=$RC"; fi
raft_member "cluster member" unknown
if [ "$RC" != 0 ]; then pass "raft: a member that has not found its leader has not rejoined"; else fail "raft: unknown leader accepted"; fi
raft_member "disconnected from the cluster (election timeout)" c3d4
if [ "$RC" != 0 ]; then pass "raft: a disconnected member that remembers a leader has not rejoined"; else fail "raft: disconnected member accepted"; fi
raft_member "joining cluster" c3d4
if [ "$RC" != 0 ]; then pass "raft: a member still joining has not rejoined"; else fail "raft: joining member accepted"; fi
raft_member absent ""
if [ "$RC" != 0 ]; then pass "raft: a server that does not answer has not rejoined"; else fail "raft: unanswered cluster/status accepted"; fi
fi
rm -rf "$TMP/dbdir"
mkdir -p "$TMP/dbdir"
ensure_db true
@@ -478,6 +519,33 @@ else
fi
printf '#!/bin/sh\nexit 1\n' >"$TMP/bin/pgrep"
# A rerun restarts this node's members, which can hand the leadership to a
# peer, so the create node's set-connection must still land as a follower.
for fn in db_clients set_client_connections; do
awk -v f="$fn() {" '$0 == f { on = 1 } on { print } on && $0 == "}" { exit }' "$SETUP" >>"$TMP/conn_fn.sh"
done
mkdir -p "$TMP/followerbin"
for ctl in ovn-nbctl ovn-sbctl; do
cat >"$TMP/followerbin/$ctl" <<EOF
#!/bin/sh
case " \$* " in
*" --no-leader-only "*) echo "$ctl \$*" >>"$TMP/conn.log" ;;
*) echo "$ctl: unix:/var/run/ovn/db.sock: database connection failed ()" >&2; exit 1 ;;
esac
EOF
chmod +x "$TMP/followerbin/$ctl"
done
: >"$TMP/conn.log"
RC=0
PATH="$TMP/followerbin:$PATH" bash -c 'set -e; sudo() { "$@"; }; DB_CLUSTER_LOCAL_ADDR=10.0.0.1; . "$1"
db_clients; set_client_connections' _ "$TMP/conn_fn.sh" >/dev/null 2>&1 || RC=$?
if [ "$RC" = 0 ] && [ "$(cat "$TMP/conn.log")" = "ovn-nbctl --db=tcp:10.0.0.1:6641 --no-leader-only set-connection ptcp:6641:127.0.0.1
ovn-sbctl --db=tcp:10.0.0.1:6642 --no-leader-only set-connection ptcp:6642:127.0.0.1" ]; then
pass "connections: the create node sets the client listeners while a follower"
else
fail "connections: as a follower rc=$RC calls [$(cat "$TMP/conn.log")]"
fi
if [ "$fails" -ne 0 ]; then
echo " setup-ovn.sh: $fails failure(s)"
exit 1
+96 -24
View File
@@ -283,7 +283,6 @@ create_service_users() {
# Create per-service users with correct home directories
declare -A SERVICE_HOMES=(
[nats]="/var/lib/spinifex/nats"
[sqsstore]="/var/lib/spinifex/sqs-store"
[gw]="/var/lib/spinifex/awsgw"
[daemon]="/var/lib/spinifex/spinifex"
[storage]="/var/lib/spinifex/predastore"
@@ -292,7 +291,7 @@ create_service_users() {
[vpcd]="/var/lib/spinifex"
[ui]="/var/lib/spinifex"
)
for svc in nats sqsstore gw daemon storage northstar viperblock vpcd ui; do
for svc in nats gw daemon storage northstar viperblock vpcd ui; do
local user="spinifex-${svc}"
if ! id "$user" > /dev/null 2>&1; then
$SUDO useradd --system --no-create-home \
@@ -324,7 +323,7 @@ create_service_users() {
# Daemon consumes viperblock's NBD socket — join the producer-typed group.
$SUDO usermod -aG spinifex-viperblock spinifex-daemon
info "Service users created (spinifex-{nats,sqsstore,gw,daemon,storage,northstar,viperblock,vpcd,ui})"
info "Service users created (spinifex-{nats,gw,daemon,storage,northstar,viperblock,vpcd,ui})"
}
# --- Install scoped sudoers rules ---
@@ -390,10 +389,68 @@ install_ipsec_state_helper() {
set -eu
if [ "$#" -ne 1 ]; then
echo "usage: $0 <on|off>" >&2
echo "usage: $0 <on|off|guard|unguard>" >&2
exit 2
fi
# libreswan drops a tunnel's xfrm policies while IKE fails, and pluto flushes
# the whole xfrm table when it starts or stops, so Geneve from the encap address
# is dropped in nft unless the route carries an SA.
GUARD_TABLE="inet spinifex_ipsec_guard"
unguard() {
printf 'table %s\ndelete table %s\n' "$GUARD_TABLE" "$GUARD_TABLE" | nft -f -
}
want=""
want_src() {
case "$1" in
*.*.*.*) case "$1" in *[!0-9.]*) src=0.0.0.0/0 ;; *) src="$1/32" ;; esac ;;
*) src=0.0.0.0/0 ;;
esac
case " $want " in *" $src "*) ;; *) want="$want $src" ;; esac
}
# One source per local address of a tunnel with a remote_name, which
# ovn-controller sets only while NB_Global.ipsec is on. A tunnel without a
# usable local_ip is guarded from every source rather than left open.
guard() {
ifaces=$(ovs-vsctl --timeout=5 --bare --columns=name find Interface type=geneve)
for iface in $ifaces; do
peer=$(ovs-vsctl --timeout=5 --if-exists get Interface "$iface" options:remote_name)
[ -n "$peer" ] || continue
want_src "$(ovs-vsctl --timeout=5 --if-exists get Interface "$iface" options:local_ip | tr -d '"')"
done
# At boot pluto is up before ovn-controller builds the tunnels, and its
# callers guard only while encryption is wanted, so guard the encap address
# until the tunnels say otherwise.
if [ -z "$ifaces" ]; then
encap=$(ovs-vsctl --timeout=5 --if-exists get Open_vSwitch . external_ids:ovn-encap-ip | tr -d '"')
for ip in $(echo "$encap" | tr ',' ' '); do want_src "$ip"; done
[ -n "$want" ] || want_src ""
fi
if [ -z "$want" ]; then
unguard
return
fi
# One transaction, so the old set is never gone before the new one is in.
nft -f - << NFT
table $GUARD_TABLE
delete table $GUARD_TABLE
table $GUARD_TABLE {
set srcs {
type ipv4_addr
flags interval
elements = { $(echo $want | tr ' ' ',') }
}
chain out {
type filter hook output priority 0; policy accept;
ip saddr @srcs udp dport 6081 rt ipsec missing drop
}
}
NFT
}
# mask/unmask rather than disable/enable. The daemon's unit sets
# ProtectSystem=full, so /etc is read-only in the namespace its children inherit,
# and disabling a unit that also ships a SysV script shells out to update-rc.d
@@ -401,12 +458,24 @@ fi
# ovs-monitor-ipsec execs the strongSwan starter itself rather than going through
# this unit, so leaving it enabled only contends for UDP 500/4500. Off in both
# states. Absent on a host without strongswan, which is not an error.
systemctl mask --now strongswan-starter.service >/dev/null 2>&1 || true
# states. Masking a unit no package ships leaves a stray /dev/null link, so a
# host without strongSwan gets none, and loses one an older helper left.
if [ -e /usr/lib/systemd/system/strongswan-starter.service ] || [ -e /lib/systemd/system/strongswan-starter.service ]; then
systemctl mask --now strongswan-starter.service >/dev/null 2>&1 || true
elif [ "$(systemctl is-enabled strongswan-starter.service 2>/dev/null)" = masked ]; then
systemctl unmask strongswan-starter.service >/dev/null 2>&1 || true
fi
case "$1" in
on)
systemctl unmask openvswitch-ipsec.service
if [ -x /usr/libexec/ipsec/pluto ]; then
# ovs-monitor-ipsec drives pluto over its control socket but does
# not start it, so pluto comes up first under its own unit.
systemctl unmask ipsec.service openvswitch-ipsec.service
systemctl start ipsec.service
else
systemctl unmask openvswitch-ipsec.service
fi
exec systemctl start openvswitch-ipsec.service
;;
off)
@@ -415,8 +484,15 @@ case "$1" in
# On Debian that name is strongswan-starter's alias, masked above.
if [ -x /usr/libexec/ipsec/pluto ]; then
systemctl mask --now ipsec.service
unguard
fi
;;
guard)
guard
;;
unguard)
unguard
;;
*)
echo "state not permitted: $1" >&2
exit 2
@@ -624,7 +700,7 @@ table inet spinifex_filter {
# inventory and running instances to anyone who asks. 8660 is
# predastore's unauthenticated /healthz and /readyz, which name the
# peers a gate cannot reach, so it belongs here for the same reason.
ip saddr $spinifex_peers tcp dport { 4222, 4223, 4248, 4249, 4432, 5300, 6641, 6642, 6643, 6644, 8660 } accept
ip saddr $spinifex_peers tcp dport { 4222, 4248, 4432, 5300, 6641, 6642, 6643, 6644, 8660 } accept
ip saddr $spinifex_peers udp dport { 5300, 6660, 7660 } accept
# Encap plane, peer-scoped: Geneve, IKE, NAT-T and ESP. Geneve is a
@@ -1521,12 +1597,6 @@ TMPEOF
$SUDO chown "spinifex-nats:$SPINIFEX_GROUP" /etc/spinifex/nats
$SUDO chmod 0750 /etc/spinifex/nats
# The SQS store holds its own token: 0700 keeps every other spinifex-*
# service in the shared group out.
$SUDO mkdir -p /etc/spinifex/sqs-store
$SUDO chown "spinifex-sqsstore:$SPINIFEX_GROUP" /etc/spinifex/sqs-store
$SUDO chmod 0700 /etc/spinifex/sqs-store
$SUDO mkdir -p /etc/spinifex/predastore
$SUDO chown "spinifex-storage:$SPINIFEX_GROUP" /etc/spinifex/predastore
$SUDO chmod 0750 /etc/spinifex/predastore
@@ -1554,10 +1624,6 @@ TMPEOF
$SUDO chown "spinifex-nats:$SPINIFEX_GROUP" /var/lib/spinifex/nats
$SUDO chmod 0700 /var/lib/spinifex/nats
$SUDO mkdir -p /var/lib/spinifex/sqs-store
$SUDO chown "spinifex-sqsstore:$SPINIFEX_GROUP" /var/lib/spinifex/sqs-store
$SUDO chmod 0700 /var/lib/spinifex/sqs-store
$SUDO mkdir -p /var/lib/spinifex/spinifex
$SUDO chown "spinifex-daemon:$SPINIFEX_GROUP" /var/lib/spinifex/spinifex
$SUDO chmod 0700 /var/lib/spinifex/spinifex
@@ -1639,7 +1705,6 @@ fix_file_ownership() {
# Per-service data dirs — recursive chown so existing files are accessible
for entry in \
nats:spinifex-nats \
sqs-store:spinifex-sqsstore \
predastore:spinifex-storage \
northstar:spinifex-northstar \
spinifex:spinifex-daemon \
@@ -1658,11 +1723,6 @@ fix_file_ownership() {
$SUDO chown -R "spinifex-nats:$SPINIFEX_GROUP" /etc/spinifex/nats \
|| fatal "Failed to set ownership on /etc/spinifex/nats"
fi
if [ -d /etc/spinifex/sqs-store ]; then
$SUDO chown -R "spinifex-sqsstore:$SPINIFEX_GROUP" /etc/spinifex/sqs-store \
|| fatal "Failed to set ownership on /etc/spinifex/sqs-store"
$SUDO chmod 0700 /etc/spinifex/sqs-store
fi
if [ -d /etc/spinifex/predastore ]; then
$SUDO chown -R "spinifex-storage:$SPINIFEX_GROUP" /etc/spinifex/predastore \
|| fatal "Failed to set ownership on /etc/spinifex/predastore"
@@ -1769,6 +1829,18 @@ CPUWeight=300
EOF
info " /etc/systemd/system/system.slice.d/spinifex-reserve.conf"
# The plaintext Geneve guard is not persisted, so it goes in when pluto
# starts at boot, before ovn-controller builds the tunnels. `-`: the guard
# must never fail the unit, and the daemon re-asserts it each pass.
if [ -x /usr/libexec/ipsec/pluto ]; then
$SUDO mkdir -p /etc/systemd/system/ipsec.service.d
$SUDO tee /etc/systemd/system/ipsec.service.d/spinifex-guard.conf > /dev/null << EOF
[Service]
ExecStartPost=-${IPSEC_STATE_HELPER} guard
EOF
info " /etc/systemd/system/ipsec.service.d/spinifex-guard.conf"
fi
# daemon-reload / enable require a running systemd — skip inside the ISO
# chroot. Unit files are still dropped into place; firstboot enables them.
if [ "${ISO_BUILD:-0}" = "1" ]; then
+2 -1
View File
@@ -27,6 +27,7 @@ MODELS=(
elastic-load-balancing-v2/2015-12-01
iam/2010-05-08
rds/2014-10-31
route-53/2013-04-01
s3/2006-03-01
sts/2011-06-15
)
@@ -34,7 +35,7 @@ MODELS=(
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
# A blobless shallow fetch pulls only the ten files read below.
# A blobless shallow fetch pulls only the files read below.
git -C "$TMP" init --quiet repo
git -C "$TMP/repo" fetch --quiet --depth=1 --filter=blob:none "$REPO" "$COMMIT"
COMMITTED="$(git -C "$TMP/repo" log -1 --format=%cs "$COMMIT")"
+12 -5
View File
@@ -237,7 +237,7 @@ fi
log "stopping services"
run sudo systemctl stop spinifex.target 2>/dev/null || true
if ! $DRY_RUN; then
for unit in spinifex-predastore spinifex-viperblock spinifex-sqs-store spinifex-nats; do
for unit in spinifex-predastore spinifex-viperblock spinifex-nats; do
if systemctl is-active --quiet "$unit"; then
sudo systemctl stop "$unit" || true
fi
@@ -362,9 +362,10 @@ rm_path /etc/systemd/system/openvswitch-switch.service.d/spinifex-perms.conf \
/etc/systemd/system/ovn-controller.service.d/log-level.conf \
/etc/systemd/system/openvswitch-ipsec.service.d/spinifex-perms.conf \
/etc/systemd/system/ovn-northd.service.d/spinifex-perms.conf \
/etc/systemd/system/ovn-northd.service.d/no-manage-ovsdb.conf
/etc/systemd/system/ovn-northd.service.d/no-manage-ovsdb.conf \
/etc/systemd/system/ipsec.service.d/spinifex-guard.conf
# Only if we emptied them — another package may own the same override dir.
for d in openvswitch-switch ovs-vswitchd ovn-controller openvswitch-ipsec ovn-northd; do
for d in openvswitch-switch ovs-vswitchd ovn-controller openvswitch-ipsec ovn-northd ipsec; do
dir="/etc/systemd/system/${d}.service.d"
if sudo test -d "$dir" && [ -z "$(sudo ls -A "$dir" 2>/dev/null)" ]; then
rm_path "$dir"
@@ -431,6 +432,11 @@ if [ -z "$PLUGINDIR" ]; then
fi
[ -n "$PLUGINDIR" ] && rm_path "$PLUGINDIR/nbdkit-viperblock-plugin.so"
# A leftover guard table drops all Geneve from this host's encap address until
# reboot, and the helper is the one place that names it.
if [ -x /usr/local/lib/spinifex/spinifex-set-ipsec-state ]; then
run sudo /usr/local/lib/spinifex/spinifex-set-ipsec-state unguard || true
fi
rm_path /usr/local/bin/spx \
/usr/local/share/spinifex \
/usr/local/lib/spinifex \
@@ -493,7 +499,7 @@ fi
# userdel warns about a missing home. Order here is cosmetic, not correctness.
# ---------------------------------------------------------------------------
log "removing service users and groups"
for svc in nats sqsstore gw daemon storage northstar viperblock vpcd ui; do
for svc in nats gw daemon storage northstar viperblock vpcd ui; do
user="spinifex-${svc}"
if id -u "$user" >/dev/null 2>&1; then
run sudo userdel "$user" 2>/dev/null || log " NOTE: could not remove user $user"
@@ -619,6 +625,7 @@ if ! $DRY_RUN; then
/etc/NetworkManager/conf.d/*spinifex* \
/etc/systemd/system/*.service.d/spinifex-perms.conf \
/etc/systemd/system/ovn-northd.service.d/no-manage-ovsdb.conf \
/etc/systemd/system/ipsec.service.d/spinifex-guard.conf \
/etc/systemd/system/ovn-controller.service.d/log-level.conf; do
exists "$p" && LEFT+=("$p")
done
@@ -628,7 +635,7 @@ if ! $DRY_RUN; then
for u in "${RESTART_UNITS[@]}"; do
systemctl is-failed --quiet "$u" 2>/dev/null && LEFT+=("${u%.service}.service is failed")
done
for svc in nats sqsstore gw daemon storage northstar viperblock vpcd ui; do
for svc in nats gw daemon storage northstar viperblock vpcd ui; do
id -u "spinifex-${svc}" >/dev/null 2>&1 && LEFT+=("user spinifex-${svc}")
done
if command -v nft >/dev/null 2>&1 && sudo nft list table inet spinifex_filter >/dev/null 2>&1; then
@@ -0,0 +1,128 @@
package tokenwebhook
import (
"context"
"crypto/tls"
"crypto/x509"
"fmt"
"net"
"net/http"
"net/url"
"os"
"sync"
"time"
"github.com/mulgadc/bluebottle/pkg/tlsconfig"
)
const (
readinessTimeout = 2 * time.Second
readinessTTL = 2 * time.Second
)
// defaultAPIServerAddr is the local k3s apiserver the readiness check dials.
const defaultAPIServerAddr = "127.0.0.1:6443"
// gatewayProbe reports whether this control plane can serve: the gateway it
// relays to accepts a TLS handshake and the local apiserver accepts a TCP
// connection. It sends no credentials and caches the result so frequent load
// balancer probes never reach either more than once per ttl.
type gatewayProbe struct {
addr string
apiAddr string
tlsCfg *tls.Config
timeout time.Duration
ttl time.Duration
now func() time.Time
mu sync.Mutex
checked time.Time
ok bool
}
func newGatewayProbe(gatewayURL, caPath, apiAddr string) (*gatewayProbe, error) {
u, err := url.Parse(gatewayURL)
if err != nil {
return nil, fmt.Errorf("parse gateway URL %q: %w", gatewayURL, err)
}
host, port := u.Hostname(), u.Port()
if host == "" {
return nil, fmt.Errorf("gateway URL %q has no host", gatewayURL)
}
if port == "" {
port = "443"
}
cfg := &tls.Config{MinVersion: tls.VersionTLS13, CurvePreferences: tlsconfig.Curves}
if caPath != "" {
pem, err := os.ReadFile(caPath)
if err != nil {
return nil, fmt.Errorf("read gateway CA %q: %w", caPath, err)
}
pool := x509.NewCertPool()
if !pool.AppendCertsFromPEM(pem) {
return nil, fmt.Errorf("gateway CA %q has no usable certificates", caPath)
}
cfg.RootCAs = pool
}
if apiAddr == "" {
apiAddr = defaultAPIServerAddr
}
return &gatewayProbe{
addr: net.JoinHostPort(host, port),
apiAddr: apiAddr,
tlsCfg: cfg,
timeout: readinessTimeout,
ttl: readinessTTL,
now: time.Now,
}, nil
}
// ready returns the cached result while it is younger than ttl, else probes.
// Concurrent callers wait for one probe and share its result.
func (p *gatewayProbe) ready() bool {
p.mu.Lock()
defer p.mu.Unlock()
if !p.checked.IsZero() && p.now().Sub(p.checked) < p.ttl {
return p.ok
}
p.ok = p.probe()
p.checked = p.now()
return p.ok
}
func (p *gatewayProbe) probe() bool {
ctx, cancel := context.WithTimeout(context.Background(), p.timeout)
defer cancel()
d := tls.Dialer{NetDialer: &net.Dialer{}, Config: p.tlsCfg}
conn, err := d.DialContext(ctx, "tcp", p.addr)
if err != nil {
return false
}
_ = conn.Close()
// A control plane whose apiserver is down must leave rotation too.
api, err := (&net.Dialer{}).DialContext(ctx, "tcp", p.apiAddr)
if err != nil {
return false
}
_ = api.Close()
return true
}
// handle serves 200 when the gateway and apiserver are reachable, else 503.
func (p *gatewayProbe) handle(w http.ResponseWriter, _ *http.Request) {
if p.ready() {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
return
}
http.Error(w, "not ready", http.StatusServiceUnavailable)
}
// readinessHandler serves only /readyz; the listener it backs is plain HTTP and
// reachable beyond loopback, so it must never expose /authenticate.
func readinessHandler(p *gatewayProbe) http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("/readyz", p.handle)
return mux
}
@@ -0,0 +1,251 @@
package tokenwebhook
import (
"crypto/tls"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// tlsGateway is a loopback TLS listener standing in for the gateway. It counts
// accepted connections and can be stopped and restarted on the same address.
type tlsGateway struct {
t *testing.T
api *fakeAPIServer
addr string
cert tls.Certificate
caPath string
ln net.Listener
accepts atomic.Int32
}
func newTLSGateway(t *testing.T) *tlsGateway {
t.Helper()
dir := t.TempDir()
cert, certPEM, err := ensureServingCert(filepath.Join(dir, "c.pem"), filepath.Join(dir, "k.pem"))
require.NoError(t, err)
caPath := filepath.Join(dir, "ca.pem")
require.NoError(t, os.WriteFile(caPath, certPEM, 0o600))
g := &tlsGateway{t: t, cert: cert, caPath: caPath, api: newFakeAPIServer(t)}
g.start("127.0.0.1:0")
t.Cleanup(g.stop)
return g
}
func (g *tlsGateway) start(addr string) {
g.t.Helper()
ln, err := tls.Listen("tcp", addr, serverTLSConfig(g.cert))
require.NoError(g.t, err)
g.ln = ln
g.addr = ln.Addr().String()
go func() {
for {
c, err := ln.Accept()
if err != nil {
return
}
g.accepts.Add(1)
go func() {
if tc, ok := c.(*tls.Conn); ok {
_ = tc.Handshake()
}
_ = c.Close()
}()
}
}()
}
func (g *tlsGateway) stop() {
if g.ln != nil {
_ = g.ln.Close()
g.ln = nil
}
}
// fakeAPIServer is a plain TCP listener standing in for the local apiserver.
type fakeAPIServer struct {
addr string
ln net.Listener
}
func newFakeAPIServer(t *testing.T) *fakeAPIServer {
t.Helper()
a := &fakeAPIServer{}
a.start("127.0.0.1:0", t)
t.Cleanup(a.stop)
return a
}
func (a *fakeAPIServer) start(addr string, t *testing.T) {
t.Helper()
ln, err := net.Listen("tcp", addr)
require.NoError(t, err)
a.ln = ln
a.addr = ln.Addr().String()
go func() {
for {
c, err := ln.Accept()
if err != nil {
return
}
_ = c.Close()
}
}()
}
func (a *fakeAPIServer) stop() {
if a.ln != nil {
_ = a.ln.Close()
a.ln = nil
}
}
func (g *tlsGateway) probe(t *testing.T) *gatewayProbe {
t.Helper()
p, err := newGatewayProbe("https://"+g.addr, g.caPath, g.api.addr)
require.NoError(t, err)
return p
}
func TestGatewayProbe_UpAndRefused(t *testing.T) {
g := newTLSGateway(t)
p := g.probe(t)
p.ttl = 0
assert.True(t, p.ready())
g.stop()
assert.False(t, p.ready(), "a closed gateway port is not ready")
}
func TestGatewayProbe_RecoversAfterDown(t *testing.T) {
g := newTLSGateway(t)
p := g.probe(t)
p.ttl = 0
addr := g.addr
g.stop()
require.False(t, p.ready())
g.start(addr)
assert.True(t, p.ready(), "ready again once the gateway is back")
}
func TestGatewayProbe_TimesOutOnStalledHandshake(t *testing.T) {
g := newTLSGateway(t)
// A plain listener that accepts and never answers the TLS handshake.
ln, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
t.Cleanup(func() { _ = ln.Close() })
go func() {
for {
c, err := ln.Accept()
if err != nil {
return
}
t.Cleanup(func() { _ = c.Close() })
}
}()
p, err := newGatewayProbe("https://"+ln.Addr().String(), g.caPath, g.api.addr)
require.NoError(t, err)
p.timeout = 200 * time.Millisecond
p.ttl = 0
start := time.Now()
assert.False(t, p.ready())
assert.Less(t, time.Since(start), 2*time.Second, "the probe gives up at its timeout")
}
func TestGatewayProbe_RejectsUntrustedCertificate(t *testing.T) {
g := newTLSGateway(t)
other := newTLSGateway(t)
p, err := newGatewayProbe("https://"+g.addr, other.caPath, g.api.addr)
require.NoError(t, err)
p.ttl = 0
assert.False(t, p.ready(), "a certificate not signed by the configured CA is not ready")
}
func TestGatewayProbe_CachesWithinTTL(t *testing.T) {
g := newTLSGateway(t)
p := g.probe(t)
clock := time.Unix(1_000_000, 0)
p.now = func() time.Time { return clock }
p.ttl = 2 * time.Second
require.True(t, p.ready())
require.True(t, p.ready())
require.True(t, p.ready())
assert.Equal(t, int32(1), g.accepts.Load(), "calls inside the ttl share one probe")
g.stop()
assert.True(t, p.ready(), "a cached answer is served while the ttl holds")
clock = clock.Add(3 * time.Second)
assert.False(t, p.ready(), "after the ttl the gateway is probed again")
}
func TestGatewayProbe_HandlerStatus(t *testing.T) {
g := newTLSGateway(t)
p := g.probe(t)
p.ttl = 0
w := httptest.NewRecorder()
p.handle(w, httptest.NewRequest(http.MethodGet, "/readyz", nil))
assert.Equal(t, http.StatusOK, w.Code)
g.stop()
w = httptest.NewRecorder()
p.handle(w, httptest.NewRequest(http.MethodGet, "/readyz", nil))
assert.Equal(t, http.StatusServiceUnavailable, w.Code)
}
func TestNewGatewayProbe_DefaultsPortAndRejectsBadInput(t *testing.T) {
p, err := newGatewayProbe("https://gw.example", "", "")
require.NoError(t, err)
assert.Equal(t, "gw.example:443", p.addr)
assert.Equal(t, defaultAPIServerAddr, p.apiAddr)
_, err = newGatewayProbe("https://", "", "")
require.Error(t, err)
_, err = newGatewayProbe("https://gw.example:9999", "/no/such/ca.pem", "")
require.Error(t, err)
}
func TestGatewayProbe_NotReadyWhenAPIServerDown(t *testing.T) {
g := newTLSGateway(t)
p := g.probe(t)
p.ttl = 0
require.True(t, p.ready())
g.api.stop()
assert.False(t, p.ready(), "a dead apiserver must not look ready while the gateway is up")
w := httptest.NewRecorder()
p.handle(w, httptest.NewRequest(http.MethodGet, "/readyz", nil))
assert.Equal(t, http.StatusServiceUnavailable, w.Code)
}
func TestReadinessHandler_ServesOnlyReadyz(t *testing.T) {
g := newTLSGateway(t)
h := readinessHandler(g.probe(t))
for _, path := range []string{"/authenticate", "/healthz", "/"} {
w := httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest(http.MethodPost, path, nil))
assert.Equalf(t, http.StatusNotFound, w.Code, "%s must not be served on the readiness listener", path)
}
w := httptest.NewRecorder()
h.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/readyz", nil))
assert.Equal(t, http.StatusOK, w.Code)
}
@@ -128,3 +128,58 @@ func TestRunServesUntilCancelled(t *testing.T) {
t.Fatal("Run did not return after cancel")
}
}
func TestRunServesReadinessOnSeparateListener(t *testing.T) {
g := newTLSGateway(t)
cfg := runConfig(t, freeAddr(t))
cfg.GatewayURL = "https://" + g.addr
cfg.GatewayCA = g.caPath
cfg.APIServerAddr = g.api.addr
cfg.ReadyAddr = freeAddr(t)
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() { done <- Run(ctx, cfg) }()
client := &http.Client{Timeout: 3 * time.Second}
require.Eventually(t, func() bool {
resp, err := client.Get("http://" + cfg.ReadyAddr + "/readyz")
if err != nil {
return false
}
_ = resp.Body.Close()
return resp.StatusCode == http.StatusOK
}, 10*time.Second, 50*time.Millisecond, "readiness listener never reported ready")
resp, err := client.Post("http://"+cfg.ReadyAddr+"/authenticate", "application/json", strings.NewReader("{}"))
require.NoError(t, err)
_ = resp.Body.Close()
assert.Equal(t, http.StatusNotFound, resp.StatusCode, "the readiness listener must not serve /authenticate")
cancel()
select {
case err := <-done:
require.NoError(t, err)
case <-time.After(5 * time.Second):
t.Fatal("Run did not return after cancel")
}
}
func TestRunReportsReadinessListenFailure(t *testing.T) {
l, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
defer l.Close()
cfg := runConfig(t, freeAddr(t))
cfg.ReadyAddr = l.Addr().String()
done := make(chan error, 1)
go func() { done <- Run(context.Background(), cfg) }()
select {
case err := <-done:
require.Error(t, err)
assert.True(t, strings.HasPrefix(err.Error(), "serve readiness: "), err.Error())
case <-time.After(10 * time.Second):
t.Fatal("Run kept serving after the readiness listener failed")
}
}
+42 -6
View File
@@ -43,6 +43,10 @@ type Config struct {
CertPath string
KeyPath string
Kubeconfig string
// ReadyAddr, when set, is a plain-HTTP listener serving only /readyz for
// load balancer health checks. APIServerAddr defaults to the local k3s.
ReadyAddr string
APIServerAddr string
}
// Run writes the serving certificate and apiserver kubeconfig, then serves
@@ -63,6 +67,11 @@ func Run(ctx context.Context, cfg Config) error {
return fmt.Errorf("build gateway client: %w", err)
}
probe, err := newGatewayProbe(cfg.GatewayURL, cfg.GatewayCA, cfg.APIServerAddr)
if err != nil {
return fmt.Errorf("build readiness probe: %w", err)
}
authr := &authenticator{
accountID: cfg.AccountID,
clusterName: cfg.ClusterName,
@@ -74,6 +83,7 @@ func Run(ctx context.Context, cfg Config) error {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
})
mux.HandleFunc("/readyz", probe.handle)
mux.HandleFunc("/authenticate", authr.handle)
server := &http.Server{
@@ -82,12 +92,38 @@ func Run(ctx context.Context, cfg Config) error {
ReadHeaderTimeout: 5 * time.Second,
TLSConfig: serverTLSConfig(tlsCert),
}
stop := context.AfterFunc(ctx, func() { _ = server.Close() })
readyErr := make(chan error, 1)
var readySrv *http.Server
if cfg.ReadyAddr != "" {
readySrv = &http.Server{
Addr: cfg.ReadyAddr,
Handler: readinessHandler(probe),
ReadHeaderTimeout: 5 * time.Second,
}
go func() {
if err := readySrv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
readyErr <- fmt.Errorf("serve readiness: %w", err)
_ = server.Close()
}
}()
defer func() { _ = readySrv.Close() }()
}
stop := context.AfterFunc(ctx, func() {
_ = server.Close()
if readySrv != nil {
_ = readySrv.Close()
}
})
defer stop()
slog.Info("eks-token-webhook listening", "addr", cfg.Addr, "cluster", cfg.ClusterName)
slog.Info("eks-token-webhook listening", "addr", cfg.Addr, "ready_addr", cfg.ReadyAddr, "cluster", cfg.ClusterName)
if err := server.ListenAndServeTLS("", ""); err != nil && !errors.Is(err, http.ErrServerClosed) {
return fmt.Errorf("serve: %w", err)
}
select {
case err := <-readyErr:
return err
default:
}
return nil
}
@@ -101,8 +137,8 @@ type tokenReviewRequest struct {
// gatewayReviewer returns the per-call relay: SigV4-POST {accountId,token} to
// the gateway and decode the resolved identity. A non-2xx / transport error is
// surfaced (handle() turns it into a 5xx so the apiserver retries) rather than a
// silent deny, distinguishing "webhook broker down" from "token rejected".
// surfaced (handle() turns it into a 5xx) rather than a silent deny, which
// distinguishes "webhook broker down" from "token rejected" in the logs.
func gatewayReviewer(client *eksgw.Client, clusterName, accountID string) func(string) (ekscluster.WebhookTokenReviewResult, error) {
path := "/clusters/" + clusterName + "/token-review"
return func(token string) (ekscluster.WebhookTokenReviewResult, error) {
@@ -142,8 +178,8 @@ func (a *authenticator) handle(w http.ResponseWriter, r *http.Request) {
res, err := a.review(review.Spec.Token)
if err != nil {
// Broker fault (gateway down, transport error): a 5xx tells the apiserver
// to retry rather than treating a transient outage as a hard deny.
// Broker fault (gateway down, transport error). The apiserver treats any
// webhook error as an authentication failure and does not retry it.
slog.Error("TokenReview relay failed", "cluster", a.clusterName, "err", err)
http.Error(w, "token review unavailable", http.StatusServiceUnavailable)
return
+6 -1
View File
@@ -22,6 +22,11 @@ type HealthReport struct {
Servers []ServerStatus `json:"servers"`
}
// FailoverServerSuffix marks the unchecked backup copy of a server that HAProxy
// uses only when every checked server is down. '.' cannot appear in a sanitized
// target name, so a copy never collides with a real server.
const FailoverServerSuffix = ".fo"
// queryHAProxyStats reads backend server health from the HAProxy stats socket.
// Parses CSV "show stat" output; cols 0/1/17 = pxname/svname/status.
func queryHAProxyStats(socketPath string) ([]ServerStatus, error) {
@@ -53,7 +58,7 @@ func queryHAProxyStats(socketPath string) ([]ServerStatus, error) {
}
svname := fields[1]
if svname == "FRONTEND" || svname == "BACKEND" {
if svname == "FRONTEND" || svname == "BACKEND" || strings.HasSuffix(svname, FailoverServerSuffix) {
continue
}
+35
View File
@@ -58,3 +58,38 @@ func TestQueryHAProxyStats_SocketNotFound(t *testing.T) {
t.Error("expected error for non-existent socket")
}
}
func TestQueryHAProxyStats_IgnoresFailoverCopies(t *testing.T) {
dir := t.TempDir()
sock := filepath.Join(dir, "haproxy.sock")
ln, err := net.Listen("unix", sock)
if err != nil {
t.Fatalf("listen: %v", err)
}
defer ln.Close()
go func() {
conn, err := ln.Accept()
if err != nil {
return
}
defer conn.Close()
buf := make([]byte, 256)
conn.Read(buf)
csv := "# pxname,svname,qcur,qmax,scur,smax,slim,stot,bin,bout,dreq,dresp,ereq,econ,eresp,wretr,wredis,status,weight\n"
csv += "bk_tg1,srv_i-aaa111,0,0,0,0,0,5,0,0,0,0,0,0,0,0,0,DOWN,1\n"
csv += "bk_tg1,srv_i-aaa111" + FailoverServerSuffix + ",0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,UP,1\n"
fmt.Fprint(conn, csv)
}()
servers, err := queryHAProxyStats(sock)
if err != nil {
t.Fatalf("queryHAProxyStats: %v", err)
}
if len(servers) != 1 || servers[0].Server != "srv_i-aaa111" || servers[0].Status != "DOWN" {
t.Fatalf("got %+v, want only srv_i-aaa111/DOWN", servers)
}
}
+21
View File
@@ -3,6 +3,7 @@ package quota
import (
"context"
"errors"
"log/slog"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/aws/aws-sdk-go/service/elbv2"
@@ -113,3 +114,23 @@ func (s *Service) EnforceRDSInstances(ctx context.Context, natsConn *nats.Conn,
}
return exceeds(len(out.DBInstances), want, limits.RDSInstances)
}
// LoadBalancerLimit is the load balancer cap DescribeAccountLimits reports.
func (s *Service) LoadBalancerLimit(ctx context.Context, accountID string) (int, error) {
// An exempt account has no cap enforced against it, so the AWS default is a
// truer answer than a configured limit nothing consults.
if s.Exempt(accountID) {
return elbv2awsapi.DefaultLoadBalancerLimit, nil
}
_, limits, err := s.GetAccountQuota(ctx, accountID)
if err != nil {
if awserrors.IsErrorCode(err, awserrors.ErrorBlocked) {
return 0, err
}
slog.ErrorContext(ctx, "DescribeAccountLimits: failed to read account quota", "accountID", accountID, "err", err)
return 0, errors.New(awserrors.ErrorInternalError)
}
return limits.LoadBalancers, nil
}
var _ elbv2awsapi.Quota = (*Service)(nil)
@@ -6,6 +6,7 @@ import (
"math"
"testing"
elbv2awsapi "github.com/mulgadc/spinifex/spinifex/domains/elbv2/awsapi"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
)
@@ -82,3 +83,25 @@ func TestEnforceLiveExemptShortCircuits(t *testing.T) {
})
}
}
func TestLoadBalancerLimit_ExemptAccountReportsAWSDefault(t *testing.T) {
var disabled *Service
got, err := disabled.LoadBalancerLimit(t.Context(), "123456789012")
if err != nil {
t.Fatalf("LoadBalancerLimit: %v", err)
}
if got != elbv2awsapi.DefaultLoadBalancerLimit {
t.Errorf("limit = %d, want the AWS default %d", got, elbv2awsapi.DefaultLoadBalancerLimit)
}
}
func TestLoadBalancerLimit_EnabledAccountReportsConfiguredLimit(t *testing.T) {
svc := New(Limits{Enabled: true, LoadBalancers: 7}, nil)
got, err := svc.LoadBalancerLimit(t.Context(), "123456789012")
if err != nil {
t.Fatalf("LoadBalancerLimit: %v", err)
}
if got != 7 {
t.Errorf("limit = %d, want 7", got)
}
}
@@ -5,6 +5,7 @@ import (
"testing"
"github.com/mulgadc/spinifex/internal/testkit"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/nats-io/nats.go/jetstream"
"github.com/stretchr/testify/require"
)
@@ -134,3 +135,36 @@ func TestPutAccountQuotaWithoutBucket(t *testing.T) {
svc := New(baseLimits(), nil)
require.Error(t, svc.PutAccountQuota(t.Context(), "000000000002", Overrides{VCPUs: ptr(32)}, "operator"))
}
// The cap DescribeAccountLimits reports follows the account's stored override
// and falls back to the configured baseline for an account without one.
func TestLoadBalancerLimit_FollowsPerAccountOverride(t *testing.T) {
svc, _ := newOverrideService(t)
const upgraded, plain = "000000000002", "000000000003"
require.NoError(t, svc.PutAccountQuota(t.Context(), upgraded, Overrides{LoadBalancers: ptr(31)}, "operator"))
got, err := svc.LoadBalancerLimit(t.Context(), upgraded)
require.NoError(t, err)
require.Equal(t, 31, got)
got, err = svc.LoadBalancerLimit(t.Context(), plain)
require.NoError(t, err)
require.Equal(t, baseLimits().LoadBalancers, got)
}
// A sealed account is refused with Blocked, and an unreadable record is an
// InternalError, never a made-up number.
func TestLoadBalancerLimit_RefusesSealedAndUnreadableRecords(t *testing.T) {
svc, kv := newOverrideService(t)
const sealed, corrupt = "000000000004", "000000000005"
_, err := kv.Put(t.Context(), sealed, []byte(`{"state":"sealed"}`))
require.NoError(t, err)
_, err = kv.Put(t.Context(), corrupt, []byte(`not json`))
require.NoError(t, err)
_, err = svc.LoadBalancerLimit(t.Context(), sealed)
require.EqualError(t, err, awserrors.ErrorBlocked)
_, err = svc.LoadBalancerLimit(t.Context(), corrupt)
require.EqualError(t, err, awserrors.ErrorInternalError)
}
+4 -1
View File
@@ -12,6 +12,7 @@ import (
"strings"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -120,7 +121,9 @@ func TestZonePublisherReloadCountsEveryNorthstarNode(t *testing.T) {
return acks
})
served, complete, err := pub.Reload(context.Background(), "example.com")
ctx, cancel := context.WithTimeout(t.Context(), 200*time.Millisecond)
defer cancel()
served, complete, err := pub.Reload(ctx, "example.com")
require.NoError(t, err)
assert.Equal(t, tc.complete, complete)
assert.ElementsMatch(t, tc.served, served)
@@ -2,9 +2,9 @@ package image
import (
"context"
"errors"
"strings"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
ec2image "github.com/mulgadc/spinifex/spinifex/domains/ec2/image"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
@@ -27,74 +27,86 @@ var validRegisterImageBootModes = map[string]bool{
}
// ValidateRegisterImageInput rejects inputs that ask for behaviour spinifex
// doesn't have (PV, S3 bundles, kernel/ramdisk, IMDS/TPM/ENA hints) rather
// than silently discarding them. Registration is pointer-only: caller supplies
// a snapshot and we write a config.json that references it.
// doesn't have (PV, S3 bundles, kernel/ramdisk, IMDS/TPM hints) rather than
// silently discarding them. Registration is pointer-only: caller supplies a
// snapshot and we write a config.json that references it.
func ValidateRegisterImageInput(input *ec2.RegisterImageInput) error {
if input == nil {
return errors.New(awserrors.ErrorMissingParameter)
return awserrors.Errorf(awserrors.ErrorMissingParameter, "The request must contain the parameter Name")
}
if input.Name == nil || *input.Name == "" {
return errors.New(awserrors.ErrorMissingParameter)
return missingParam("Name")
}
if n := len(*input.Name); n < 3 || n > 128 {
return errors.New(awserrors.ErrorInvalidAMINameMalformed)
return invalidParam(awserrors.ErrorInvalidAMINameMalformed, "Name", *input.Name, "AMI names must be between 3 and 128 characters long.")
}
if input.ImageLocation != nil && *input.ImageLocation != "" {
return errors.New(awserrors.ErrorInvalidParameterValue)
if aws.StringValue(input.ImageLocation) != "" {
return unsupportedParam("ImageLocation", *input.ImageLocation)
}
if input.Architecture != nil && *input.Architecture != "" {
if !validRegisterImageArchitectures[*input.Architecture] {
return errors.New(awserrors.ErrorInvalidParameterValue)
}
if arch := aws.StringValue(input.Architecture); arch != "" && !validRegisterImageArchitectures[arch] {
return invalidParam(awserrors.ErrorInvalidParameterValue, "Architecture", arch, "Supported values are x86_64, arm64 and i386.")
}
if input.VirtualizationType != nil && *input.VirtualizationType != "" && *input.VirtualizationType != "hvm" {
return errors.New(awserrors.ErrorInvalidParameterValue)
if virt := aws.StringValue(input.VirtualizationType); virt != "" && virt != "hvm" {
return invalidParam(awserrors.ErrorInvalidParameterValue, "VirtualizationType", virt, "Only hvm is supported.")
}
if input.BootMode != nil && *input.BootMode != "" {
if !validRegisterImageBootModes[*input.BootMode] {
return errors.New(awserrors.ErrorInvalidParameterValue)
}
if mode := aws.StringValue(input.BootMode); mode != "" && !validRegisterImageBootModes[mode] {
return invalidParam(awserrors.ErrorInvalidParameterValue, "BootMode", mode, "Supported values are bios, uefi and uefi-preferred.")
}
if input.KernelId != nil && *input.KernelId != "" {
return errors.New(awserrors.ErrorInvalidParameterValue)
if aws.StringValue(input.KernelId) != "" {
return unsupportedParam("KernelId", *input.KernelId)
}
if input.RamdiskId != nil && *input.RamdiskId != "" {
return errors.New(awserrors.ErrorInvalidParameterValue)
if aws.StringValue(input.RamdiskId) != "" {
return unsupportedParam("RamdiskId", *input.RamdiskId)
}
if input.TpmSupport != nil && *input.TpmSupport != "" {
return errors.New(awserrors.ErrorInvalidParameterValue)
if aws.StringValue(input.TpmSupport) != "" {
return unsupportedParam("TpmSupport", *input.TpmSupport)
}
if input.ImdsSupport != nil && *input.ImdsSupport != "" {
return errors.New(awserrors.ErrorInvalidParameterValue)
if aws.StringValue(input.ImdsSupport) != "" {
return unsupportedParam("ImdsSupport", *input.ImdsSupport)
}
if input.EnaSupport != nil {
return errors.New(awserrors.ErrorInvalidParameterValue)
}
if input.SriovNetSupport != nil && *input.SriovNetSupport != "" {
return errors.New(awserrors.ErrorInvalidParameterValue)
if aws.StringValue(input.SriovNetSupport) != "" {
return unsupportedParam("SriovNetSupport", *input.SriovNetSupport)
}
if len(input.BlockDeviceMappings) == 0 {
return errors.New(awserrors.ErrorMissingParameter)
return missingParam("BlockDeviceMapping")
}
root := selectRootBlockDeviceMapping(input.BlockDeviceMappings, input.RootDeviceName)
if root == nil || root.Ebs == nil || root.Ebs.SnapshotId == nil || *root.Ebs.SnapshotId == "" {
return errors.New(awserrors.ErrorMissingParameter)
if name := aws.StringValue(input.RootDeviceName); name != "" && root == nil {
return awserrors.Errorf(awserrors.ErrorMissingParameter,
"Value (%s) for parameter RootDeviceName is invalid. No block device mapping has that DeviceName.", name)
}
return missingParam("SnapshotId in the root block device mapping")
}
if !strings.HasPrefix(*root.Ebs.SnapshotId, "snap-") {
return errors.New(awserrors.ErrorInvalidSnapshotIDMalformed)
return invalidParam(awserrors.ErrorInvalidSnapshotIDMalformed, "SnapshotId", *root.Ebs.SnapshotId, "Expected: 'snap-...'.")
}
return nil
}
// missingParam is EC2's wording for an absent required parameter.
func missingParam(name string) error {
return awserrors.Errorf(awserrors.ErrorMissingParameter, "The request must contain the parameter %s", name)
}
// invalidParam is EC2's wording for a parameter value it refuses.
func invalidParam(code, name, value, reason string) error {
return awserrors.Errorf(code, "Value (%s) for parameter %s is invalid. %s", value, name, reason)
}
// unsupportedParam refuses a parameter spinifex has no behaviour for.
func unsupportedParam(name, value string) error {
return invalidParam(awserrors.ErrorInvalidParameterValue, name, value, name+" is not supported.")
}
// selectRootBlockDeviceMapping returns the BDM matching RootDeviceName if set,
// else the first BDM carrying an EBS snapshot reference.
func selectRootBlockDeviceMapping(mappings []*ec2.BlockDeviceMapping, rootDeviceName *string) *ec2.BlockDeviceMapping {
@@ -8,6 +8,7 @@ import (
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func validRegisterImageInput() *ec2.RegisterImageInput {
@@ -33,30 +34,37 @@ func TestValidateRegisterImageInput(t *testing.T) {
input *ec2.RegisterImageInput
wantErr bool
errMsg string
// wantIn is the text the message must carry, so the caller can see which
// parameter was refused.
wantIn string
}{
{
name: "NilInput",
input: nil,
wantErr: true,
errMsg: awserrors.ErrorMissingParameter,
wantIn: "parameter Name",
},
{
name: "MissingName",
mutate: func(i *ec2.RegisterImageInput) { i.Name = nil },
wantErr: true,
errMsg: awserrors.ErrorMissingParameter,
wantIn: "parameter Name",
},
{
name: "EmptyName",
mutate: func(i *ec2.RegisterImageInput) { i.Name = aws.String("") },
wantErr: true,
errMsg: awserrors.ErrorMissingParameter,
wantIn: "parameter Name",
},
{
name: "ShortName",
mutate: func(i *ec2.RegisterImageInput) { i.Name = aws.String("ab") },
wantErr: true,
errMsg: awserrors.ErrorInvalidAMINameMalformed,
wantIn: "for parameter Name is invalid",
},
{
name: "LongName",
@@ -69,12 +77,14 @@ func TestValidateRegisterImageInput(t *testing.T) {
},
wantErr: true,
errMsg: awserrors.ErrorInvalidAMINameMalformed,
wantIn: "for parameter Name is invalid",
},
{
name: "MissingBlockDeviceMappings",
mutate: func(i *ec2.RegisterImageInput) { i.BlockDeviceMappings = nil },
wantErr: true,
errMsg: awserrors.ErrorMissingParameter,
wantIn: "parameter BlockDeviceMapping",
},
{
name: "MissingSnapshotIdInRootBDM",
@@ -83,6 +93,7 @@ func TestValidateRegisterImageInput(t *testing.T) {
},
wantErr: true,
errMsg: awserrors.ErrorMissingParameter,
wantIn: "parameter SnapshotId",
},
{
name: "MalformedSnapshotId",
@@ -91,6 +102,7 @@ func TestValidateRegisterImageInput(t *testing.T) {
},
wantErr: true,
errMsg: awserrors.ErrorInvalidSnapshotIDMalformed,
wantIn: "Value (not-a-snapshot) for parameter SnapshotId is invalid",
},
{
name: "RootDeviceNameWithNoMatchingBDM",
@@ -99,6 +111,7 @@ func TestValidateRegisterImageInput(t *testing.T) {
},
wantErr: true,
errMsg: awserrors.ErrorMissingParameter,
wantIn: "Value (/dev/xvda) for parameter RootDeviceName is invalid",
},
{
name: "InvalidArchitecture",
@@ -107,6 +120,7 @@ func TestValidateRegisterImageInput(t *testing.T) {
},
wantErr: true,
errMsg: awserrors.ErrorInvalidParameterValue,
wantIn: "Value (riscv) for parameter Architecture is invalid",
},
{
name: "ParavirtualVirtualizationRejected",
@@ -115,6 +129,7 @@ func TestValidateRegisterImageInput(t *testing.T) {
},
wantErr: true,
errMsg: awserrors.ErrorInvalidParameterValue,
wantIn: "Value (paravirtual) for parameter VirtualizationType is invalid",
},
{
name: "ImageLocationRejected",
@@ -123,6 +138,7 @@ func TestValidateRegisterImageInput(t *testing.T) {
},
wantErr: true,
errMsg: awserrors.ErrorInvalidParameterValue,
wantIn: "for parameter ImageLocation is invalid. ImageLocation is not supported",
},
{
name: "BootModeAcceptedBios",
@@ -144,42 +160,52 @@ func TestValidateRegisterImageInput(t *testing.T) {
mutate: func(i *ec2.RegisterImageInput) { i.BootMode = aws.String("legacy-bios") },
wantErr: true,
errMsg: awserrors.ErrorInvalidParameterValue,
wantIn: "Value (legacy-bios) for parameter BootMode is invalid",
},
{
name: "KernelIdRejected",
mutate: func(i *ec2.RegisterImageInput) { i.KernelId = aws.String("aki-123") },
wantErr: true,
errMsg: awserrors.ErrorInvalidParameterValue,
wantIn: "Value (aki-123) for parameter KernelId is invalid. KernelId is not supported",
},
{
name: "RamdiskIdRejected",
mutate: func(i *ec2.RegisterImageInput) { i.RamdiskId = aws.String("ari-123") },
wantErr: true,
errMsg: awserrors.ErrorInvalidParameterValue,
wantIn: "Value (ari-123) for parameter RamdiskId is invalid. RamdiskId is not supported",
},
{
name: "TpmSupportRejected",
mutate: func(i *ec2.RegisterImageInput) { i.TpmSupport = aws.String("v2.0") },
wantErr: true,
errMsg: awserrors.ErrorInvalidParameterValue,
wantIn: "Value (v2.0) for parameter TpmSupport is invalid. TpmSupport is not supported",
},
{
name: "ImdsSupportRejected",
mutate: func(i *ec2.RegisterImageInput) { i.ImdsSupport = aws.String("v2.0") },
wantErr: true,
errMsg: awserrors.ErrorInvalidParameterValue,
wantIn: "Value (v2.0) for parameter ImdsSupport is invalid. ImdsSupport is not supported",
},
{
name: "EnaSupportRejected",
name: "EnaSupportAcceptedTrue",
mutate: func(i *ec2.RegisterImageInput) { i.EnaSupport = aws.Bool(true) },
wantErr: true,
errMsg: awserrors.ErrorInvalidParameterValue,
wantErr: false,
},
{
name: "EnaSupportAcceptedFalse",
mutate: func(i *ec2.RegisterImageInput) { i.EnaSupport = aws.Bool(false) },
wantErr: false,
},
{
name: "SriovNetSupportRejected",
mutate: func(i *ec2.RegisterImageInput) { i.SriovNetSupport = aws.String("simple") },
wantErr: true,
errMsg: awserrors.ErrorInvalidParameterValue,
wantIn: "Value (simple) for parameter SriovNetSupport is invalid. SriovNetSupport is not supported",
},
{
name: "ValidWithExplicitArch",
@@ -230,8 +256,12 @@ func TestValidateRegisterImageInput(t *testing.T) {
err := ValidateRegisterImageInput(input)
if tt.wantErr {
assert.Error(t, err)
assert.Equal(t, tt.errMsg, err.Error())
require.Error(t, err)
code, msg, ok := awserrors.ResolveErrorDetail(err)
require.True(t, ok, "the error must carry a registered code")
assert.Equal(t, tt.errMsg, code)
assert.NotEmpty(t, msg, "the response must say what was refused")
assert.Contains(t, msg, tt.wantIn)
} else {
assert.NoError(t, err)
}
@@ -243,8 +273,10 @@ func TestRegisterImage_GatewayValidationFailureReturnsEarly(t *testing.T) {
// nil natsConn is fine here: validation rejects the input before any
// NATS round-trip is attempted, so we exercise the early-exit path.
_, err := RegisterImage(context.Background(), &ec2.RegisterImageInput{}, nil, "000000000001")
assert.Error(t, err)
assert.Equal(t, awserrors.ErrorMissingParameter, err.Error())
require.Error(t, err)
code, ok := awserrors.ResolveErrorCode(err)
require.True(t, ok)
assert.Equal(t, awserrors.ErrorMissingParameter, code)
}
func TestSelectRootBlockDeviceMapping_NoRootDeviceName(t *testing.T) {
@@ -104,6 +104,7 @@ type AMI struct {
VolumeSizeGiB uint64 `json:"volume_size_gib"`
SnapshotID string `json:"snapshot_id,omitempty"`
BootMode string `json:"boot_mode,omitempty"`
EnaSupport *bool `json:"ena_support,omitempty"`
Distro string `json:"distro,omitempty"`
DistroFamily string `json:"distro_family,omitempty"`
Tags map[string]string `json:"tags,omitempty"`
+92 -11
View File
@@ -8,6 +8,7 @@ import (
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
"io"
"log/slog"
"strings"
"sync"
"time"
@@ -99,6 +100,16 @@ func (s *Store) ListAllVolumesStrict(ctx context.Context) ([]Volume, error) {
return listDocuments(ctx, s, volumePrefix, "volume", UnmarshalVolume, false)
}
// ListAllVolumesPartial is ListAllVolumes that hands back what it skipped
// instead of logging it, for a caller that must decide what an unreadable
// document means for its own answer. A document deleted mid-listing is absent.
func (s *Store) ListAllVolumesPartial(ctx context.Context) ([]Volume, []UnreadableDocument, error) {
if s == nil || s.objects == nil {
return nil, nil, errors.New("metadata store is not configured")
}
return collectDocuments(ctx, s, volumePrefix, UnmarshalVolume, listPartial)
}
func (s *Store) listVolumeDocuments(ctx context.Context, accountID string, skipCorrupt bool) ([]Volume, error) {
if s == nil || s.objects == nil {
return nil, errors.New("metadata store is not configured")
@@ -219,6 +230,14 @@ func (s *Store) ListAMIsStrict(ctx context.Context) ([]AMI, error) {
return s.listAMIDocuments(ctx, false)
}
// ListAMIsPartial is ListAllVolumesPartial for AMI documents.
func (s *Store) ListAMIsPartial(ctx context.Context) ([]AMI, []UnreadableDocument, error) {
if s == nil || s.objects == nil {
return nil, nil, errors.New("metadata store is not configured")
}
return collectDocuments(ctx, s, amiPrefix, UnmarshalAMI, listPartial)
}
func (s *Store) listAMIDocuments(ctx context.Context, skipCorrupt bool) ([]AMI, error) {
return listDocuments(ctx, s, amiPrefix, "AMI", UnmarshalAMI, skipCorrupt)
}
@@ -285,15 +304,43 @@ func listKeys(ctx context.Context, objects objectstore.ObjectStore, bucket, pref
return nil, fmt.Errorf("listing %s did not finish within %d pages", prefix, maxListPages)
}
// UnreadableDocument is a document a partial listing could not use. AccountID
// is empty for an unpartitioned kind. Err wraps ErrCorruptDocument when the
// bytes arrived but would not decode.
type UnreadableDocument struct {
Key string
AccountID string
ID string
Err error
}
// documentIdentity recovers the account and ID a document key was built from.
func documentIdentity(prefix, key string) (accountID, id string) {
rest := strings.TrimSuffix(strings.TrimPrefix(key, prefix), ".json")
if account, docID, found := strings.Cut(rest, "/"); found {
return account, docID
}
return "", rest
}
// listMode says what a listing does with a document it cannot use.
type listMode int
const (
// listStrict fails the listing.
listStrict listMode = iota
// listTolerant skips it and logs the skip.
listTolerant
// listPartial skips it and reports it, treating a deleted one as absent.
listPartial
)
// listDocuments reads and decodes every document under prefix.
//
// skipCorrupt tolerates a document that cannot be fetched as well as one that
// cannot be decoded: an object whose shards no longer join is exactly as
// unusable as one whose bytes will not parse, and either kind read wholesale
// as a failure turns a single bad document into a cluster-wide outage.
//
// Fetches run concurrently but results are assembled in key order, so a strict
// listing fails on the same document it would have failed on serially.
func listDocuments[T any](
ctx context.Context,
s *Store,
@@ -301,9 +348,35 @@ func listDocuments[T any](
unmarshal func([]byte) (T, error),
skipCorrupt bool,
) ([]T, error) {
if !skipCorrupt {
documents, _, err := collectDocuments(ctx, s, prefix, unmarshal, listStrict)
return documents, err
}
documents, skipped, err := collectDocuments(ctx, s, prefix, unmarshal, listTolerant)
for _, doc := range skipped {
if errors.Is(doc.Err, ErrCorruptDocument) {
slog.WarnContext(ctx, "skipping undecodable "+kind+" document", "key", doc.Key, "err", doc.Err)
} else {
slog.WarnContext(ctx, "skipping unreadable "+kind+" document", "key", doc.Key, "err", doc.Err)
}
}
return documents, err
}
// collectDocuments is listDocuments returning the documents it skipped.
//
// Fetches run concurrently but results are assembled in key order, so a strict
// listing fails on the same document it would have failed on serially.
func collectDocuments[T any](
ctx context.Context,
s *Store,
prefix string,
unmarshal func([]byte) (T, error),
mode listMode,
) ([]T, []UnreadableDocument, error) {
keys, err := listKeys(ctx, s.objects, s.bucket, prefix)
if err != nil {
return nil, err
return nil, nil, err
}
// fetchErr and decodeErr are kept apart because they are tolerated on
@@ -341,23 +414,31 @@ func listDocuments[T any](
wg.Wait()
documents := make([]T, 0, len(results))
var skipped []UnreadableDocument
skip := func(key string, err error) {
accountID, id := documentIdentity(prefix, key)
skipped = append(skipped, UnreadableDocument{Key: key, AccountID: accountID, ID: id, Err: err})
}
for _, r := range results {
switch {
case r.fetchErr != nil:
if !skipCorrupt {
return nil, r.fetchErr
if mode == listStrict {
return nil, nil, r.fetchErr
}
slog.WarnContext(ctx, "skipping unreadable "+kind+" document", "key", r.key, "err", r.fetchErr)
if mode == listPartial && objectstore.IsNoSuchKeyError(r.fetchErr) {
continue
}
skip(r.key, r.fetchErr)
case r.decodeErr != nil:
if !skipCorrupt || !errors.Is(r.decodeErr, ErrCorruptDocument) {
return nil, r.decodeErr
if mode == listStrict || !errors.Is(r.decodeErr, ErrCorruptDocument) {
return nil, nil, r.decodeErr
}
slog.WarnContext(ctx, "skipping undecodable "+kind+" document", "key", r.key, "err", r.decodeErr)
skip(r.key, r.decodeErr)
default:
documents = append(documents, r.document)
}
}
return documents, nil
return documents, skipped, nil
}
// FetchResult is one document from an explicit-ID fan-out. Err is that ID's
@@ -706,3 +706,74 @@ func (s *truncatedNoTokenStore) ListObjectsV2(_ context.Context, _ *s3.ListObjec
}
return &s3.ListObjectsV2Output{Contents: contents, IsTruncated: aws.Bool(true)}, nil
}
// A partial listing hands back each document it could not use, identified by
// the account and ID its key was built from, and leaves out one deleted
// between listing and read.
func TestListPartial_ReportsUnreadableAndDropsDeleted(t *testing.T) {
objects := objectstore.NewMemoryObjectStore()
store := NewStore(objects, "control-plane")
ctx := context.Background()
require.NoError(t, store.PutVolume(ctx, Volume{VolumeID: "vol-good", TenantID: "000000000001", CapacityGiB: 1}))
require.NoError(t, store.PutVolume(ctx, Volume{VolumeID: "vol-unreadable", TenantID: "000000000002", CapacityGiB: 1}))
unreadableKey, err := VolumeKey("000000000002", "vol-unreadable")
require.NoError(t, err)
corruptKey, err := VolumeKey("000000000001", "vol-corrupt")
require.NoError(t, err)
writeRaw(t, objects, corruptKey, []byte("{not json"))
badAMIKey, err := AMIKey("ami-bad")
require.NoError(t, err)
writeRaw(t, objects, badAMIKey, []byte("{not json"))
require.NoError(t, store.PutAMI(ctx, AMI{ImageID: "ami-good"}))
deletedKey, err := AMIKey("ami-deleted")
require.NoError(t, err)
writeRaw(t, objects, deletedKey, []byte("{}"))
broken := NewStore(&deletedAndFailingStore{
ObjectStore: objects, failKey: unreadableKey, deletedKey: deletedKey,
}, "control-plane")
volumes, unreadable, err := broken.ListAllVolumesPartial(ctx)
require.NoError(t, err)
assert.Equal(t, []string{"vol-good"}, volumeIDs(volumes))
require.Len(t, unreadable, 2)
byID := map[string]UnreadableDocument{}
for _, doc := range unreadable {
byID[doc.ID] = doc
}
assert.Equal(t, "000000000001", byID["vol-corrupt"].AccountID)
assert.ErrorIs(t, byID["vol-corrupt"].Err, ErrCorruptDocument)
assert.Equal(t, "000000000002", byID["vol-unreadable"].AccountID)
assert.NotErrorIs(t, byID["vol-unreadable"].Err, ErrCorruptDocument)
amis, unreadable, err := broken.ListAMIsPartial(ctx)
require.NoError(t, err)
require.Len(t, amis, 1)
assert.Equal(t, "ami-good", amis[0].ImageID)
require.Len(t, unreadable, 1, "a deleted document is absent, not unreadable")
assert.Equal(t, UnreadableDocument{Key: badAMIKey, ID: "ami-bad", Err: unreadable[0].Err}, unreadable[0])
_, _, err = (&Store{}).ListAllVolumesPartial(ctx)
require.Error(t, err)
_, _, err = (&Store{}).ListAMIsPartial(ctx)
require.Error(t, err)
}
// deletedAndFailingStore fails one key's read and reports another as already
// deleted, while still listing both.
type deletedAndFailingStore struct {
objectstore.ObjectStore
failKey, deletedKey string
}
func (s *deletedAndFailingStore) GetObject(ctx context.Context, in *s3.GetObjectInput) (*s3.GetObjectOutput, error) {
switch aws.StringValue(in.Key) {
case s.failKey:
return nil, errors.New("reconstruction failed")
case s.deletedKey:
return nil, &objectstore.NoSuchKeyError{Key: s.deletedKey}
}
return s.ObjectStore.GetObject(ctx, in)
}
+1 -1
View File
@@ -181,7 +181,7 @@ func (s *EIPServiceImpl) ReleaseAddress(ctx context.Context, input *ec2.ReleaseA
// Cannot release an EIP that is still associated.
if record.State == "associated" {
return nil, errors.New(awserrors.ErrorInvalidAddressLocked)
return nil, awserrors.Errorf(awserrors.ErrorInvalidIPAddressInUse, "Address %s is in use.", record.PublicIp)
}
// Release IP back to IPAM pool. User-driven release of an already-detached
@@ -169,8 +169,11 @@ func TestEIP_ReleaseWhileAssociated(t *testing.T) {
_, err = svc.ReleaseAddress(context.Background(), &ec2.ReleaseAddressInput{
AllocationId: aws.String(allocID),
}, testAccountID)
assert.Error(t, err)
assert.Contains(t, err.Error(), "InvalidAddress.Locked")
require.Error(t, err)
code, message, ok := awserrors.ResolveErrorDetail(err)
require.True(t, ok, "error must carry a registered code: %v", err)
assert.Equal(t, awserrors.ErrorInvalidIPAddressInUse, code)
assert.Equal(t, "Address "+record.PublicIp+" is in use.", message)
}
func TestEIP_ReleaseByInstanceID_ReclaimsAssociatedEIP(t *testing.T) {
+5 -17
View File
@@ -246,13 +246,13 @@ func (s *IMDSServiceImpl) dispatch(w http.ResponseWriter, r *http.Request, eni *
case prefixMetaData + "security-groups":
writeText(w, strings.Join(s.resolver.resolveSGNames(ctx, eni.accountID, eni.securityGroupIDs), "\n"))
case prefixMetaData + "hostname", prefixMetaData + "local-hostname":
writeText(w, s.privateHostname(eni.privateIP, regionFromAZ(eni.availabilityZone)))
writeText(w, s.privateHostname(eni.privateIP, s.region))
case prefixMetaData + "placement", prefixMetaData + "placement/":
writeText(w, "availability-zone\nregion")
case prefixMetaData + "placement/availability-zone":
writeText(w, eni.availabilityZone)
case prefixMetaData + "placement/region":
writeText(w, regionFromAZ(eni.availabilityZone))
writeText(w, s.region)
case prefixMetaData + "instance-type":
s.serveInstanceField(ctx, w, eni, func(i *instanceFacts) string { return i.instanceType })
case prefixMetaData + "ami-id":
@@ -419,7 +419,7 @@ func (s *IMDSServiceImpl) serveInstanceIdentityDocument(ctx context.Context, w h
InstanceType: inst.instanceType,
PendingTime: inst.pendingTime.UTC().Format("2006-01-02T15:04:05Z"),
PrivateIP: eni.privateIP,
Region: regionFromAZ(eni.availabilityZone),
Region: s.region,
Version: identityDocSchemaVersion,
}
writeIdentityDocument(w, doc)
@@ -587,7 +587,7 @@ func (s *IMDSServiceImpl) serveNetworkInterface(ctx context.Context, w http.Resp
case "local-ipv4s":
writeText(w, eni.privateIP)
case "local-hostname":
writeText(w, s.privateHostname(eni.privateIP, regionFromAZ(eni.availabilityZone)))
writeText(w, s.privateHostname(eni.privateIP, s.region))
case "security-group-ids":
writeText(w, strings.Join(eni.securityGroupIDs, "\n"))
case "security-groups":
@@ -697,7 +697,7 @@ func (s *IMDSServiceImpl) servePublicHostname(w http.ResponseWriter, eni *eniFac
w.WriteHeader(http.StatusNotFound) // no public IP → no public hostname
return
}
if h := s.publicHostname(eni.publicIP, regionFromAZ(eni.availabilityZone)); h != "" {
if h := s.publicHostname(eni.publicIP, s.region); h != "" {
writeText(w, h)
return
}
@@ -772,18 +772,6 @@ func parseTokenTTL(raw string) (time.Duration, bool) {
return time.Duration(n) * time.Second, true
}
// regionFromAZ strips the trailing AZ letter from an AZ name to get the region.
func regionFromAZ(az string) string {
if az == "" {
return ""
}
last := az[len(az)-1]
if last >= 'a' && last <= 'z' {
return az[:len(az)-1]
}
return az
}
// privateHostname is the AWS-parity local hostname (ip-<dashed>.<region>.<internal
// domain>) honoring the configured internal_domain, so it matches the record the
// DNS writer publishes. Empty when the IP or region is missing.

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