Compare commits

..

220 Commits

Author SHA1 Message Date
Julian Sommer 5529572761 release: update go.mod for v1.22.0 2026-10-07 15:32:32 +10:00
Julian Sommer 5770a10090 chore: regen coverage image 2026-10-07 15:03:29 +10:00
Julian Sommer 382b9c2619 chore: enable ST1000 2026-10-07 14:12:05 +10:00
Julian Sommer 40a46c54cf test: raise spinifex/daemon unit coverage to 80% (#1187) 2026-10-07 15:07:32 +11:00
Ben Duncan f99c861810 docs: render the OCI guide's console screenshot (#1186) 2026-10-07 14:27:10 +11:00
Julian Sommer ba4b8df554 chore: enable ST1022 2026-10-07 13:25:33 +10:00
Julian Sommer b3b066562e chore: add comment to all exported types 2026-10-07 13:23:42 +10:00
Julian Sommer 47fa76e09d chore: coverage badge 2026-10-07 13:06:41 +10:00
Ben Duncan c4f92608e1 fix: let the OCI principal topology pick a shape its tenancy has quota for (#1185)
Service limits are per tenancy. vm-single-principal now builds in its own, so
the shape the other topologies share can have zero quota there, and the apply
then fails before anything about the instance principal is exercised.

Measured in ours: standard-e6-core-count is 0 available, against 13 for e5 and
10 for standard3. The first principal run died on 400-LimitExceeded for Core
Instance, which reads as a capacity problem and is a shape with no quota.

OCI_PRINCIPAL_SHAPE overrides it for that topology alone and defaults to the
shape every other topology uses, so nothing changes where quota already exists.
2026-10-06 21:11:23 -05:00
Ben Duncan 8409799cbf fix: default the OCI helper to the DEFAULT profile (#1174)
* fix: default the OCI helper to the DEFAULT profile

oci_env.py shipped our own internal Oracle compartment profile as the default for
every reader, with a second internal name as a fallback.
A fresh tenancy's config holds only `[DEFAULT]`, so the shipped default was a name
nobody else can have, and the fallback chain could land a deployment in whichever
tenancy a different profile happened to point at.

The profile is now `DEFAULT`, overridable by `OCI_CLI_PROFILE` or `--profile`, and a
named profile the config does not hold is an error rather than a fallback.
`DEFAULT` needs its own existence test, because configparser treats it as the
fallback for every other section and `has_section` denies it exists even when
`oci setup config` wrote nothing else.

`eip-move-test.sh` and the config table in the README carried the same name.
CI reads its credential from the environment rather than from a profile, so nothing
there referenced it.

* docs: look the OCI compartment OCID up by name

The guide asked the reader to paste `ocid1.compartment.oc1..xxx` three times, and
the `oci` CLI it already requires can resolve the name instead.

The lookup needs its own check. `oci iam compartment list --query ... | [0]` exits 0
and prints nothing when the name matches nothing, so an unset `$CID` would otherwise
reach Terraform and fail with an OCI error naming neither the compartment nor the
cause.

Two compartments in one tenancy can share a name, which the listing command shows
and the lookup cannot resolve, so the paste form stays as the answer for that case.
Records why the tenancy root is the wrong value: the step 2.1 policy is scoped to
whatever `$CID` holds.

* Revert "docs: look the OCI compartment OCID up by name"

This reverts commit 5443149cce.

* Reapply "docs: look the OCI compartment OCID up by name"

This reverts commit 1535d94c8a.

* fix: stop validate-topology.sh failing on macOS bash

`tf_principal_var` is empty on every run that does not pass `--instance-principal`,
and bash before 4.4 treats `"${arr[@]}"` on an empty array as an unbound variable
under `set -u`.
macOS ships bash 3.2, so the documented deployment command aborted at the tf_vars
assembly with `tf_principal_var[@]: unbound variable` before building anything.

Assigning the array was not enough, which is why the earlier `HOSTS=()` fix did not
cover this: the error is in the expansion, not the reference.
Both sites now expand only behind a length test, which is valid on every version.

`capture_journals` had the same shape for `HOSTS`, reachable when a run fails before
the apply.

Reproduced and fixed against bash 3.2.57 in a container, which emits the identical
message, and the script now runs to the terraform call on 3.2 for vm-single,
vm-single-principal and `--instance-principal`, including the `--keep` cleanup path
with no hosts.

* docs: record the bash 3.x requirement in the OCI scripts

These three are the scripts the OCI guide tells an operator to run, and macOS ships
bash 3.2, so a bash 4 construct in any of them breaks a deployment rather than a
test.
The constraint has now been rediscovered twice, through declare -A and mapfile, and
then through an empty array expanded under set -u.

Names the three traps at the top of each file so the next change does not have to
find them by failing on someone's Mac.

* fix: stream the workbook suite instead of hiding it in a file

The nine workbooks ran with stdout redirected to `workbooks.log`, so a run wedged
inside one printed nothing until the whole suite finished.
A CI job then sat silent for hours with no way to tell which workbook it was in, or
whether it was working at all.

The output is now teed, so the file the per-workbook table is parsed from is
unchanged while the run is visible live.
Each of the driver's own RUN/PASS/FAIL lines is stamped with elapsed seconds and the
workbook name, which is what identifies the slow one without reading the log.

`pipefail` carries the ssh exit status through the pipe, so a failed suite still
takes the branch that records the table and captures the journals.

* fix: grant the OCI policy the subnet verb AssignPrivateIP requires

An OCI node with a pool-mode external interface formed, reported healthy, and
then refused every launch wanting a public address with a 404
NotAuthorizedOrNotFound from AssignPrivateIP.

AssignPrivateIP calls CreatePrivateIp, which Oracle documents as needing
PRIVATE_IP_CREATE, PRIVATE_IP_ASSIGN, VNIC_ASSIGN and SUBNET_ATTACH, and
DeletePrivateIp as needing SUBNET_DETACH.
The policy granted use vnics, manage private-ips and manage public-ips, and no
subnet permission at all, so SUBNET_ATTACH was never held.
Only the subnets use verb carries it.

Measured on a live node as the instance principal: GetVnic and ListPrivateIps
succeed, GetSubnet and CreatePrivateIp both 404.
Authentication, the dynamic group's matching rule and the compartment were all
correct, so the credential was authentic and unauthorised.
The documented operator policy for the API-key route carried the same three
statements, so neither credential route could allocate.

A node now proves its own credential at pool start by making one read in each
permission class, and logs the missing verb if one fails.
The topology gate waited for "resolved the external VNIC", which comes from the
metadata service and needs no IAM policy, so an unauthorised node passed the
gate and failed an hour later inside a workbook as capacity.
It now waits for the authorisation line and fails immediately on a refusal.

Reads rather than a real assign-and-release round trip: a round trip also fails
on a full subnet, and reporting that as an authorisation fault sends an operator
to IAM for a capacity problem.

* docs: say what a principal run costs in a tenancy with no dynamic group

No principal topology can pass there, and the only previous hint was one clause
about OCI_INSTANCE_PRINCIPAL needing the group to exist.

* test: cover the subnet read and the authorisation verdict

Diff coverage was 39.1% against a 60% gate. Three of the four gaps were real.

GetSubnet on the SDK-backed client had no test, so the request shape was
unverified: addressing the collection rather than one subnet by OCID would
answer from a different permission and report a credential as working when it is
not. Also pins that a sparse body is not an error and that the unauthorised 404
arrives as ErrNotFound, which is what stops the check retrying a verdict that
will never change.

The fake's GetSubnet was exercised only from the ocinet tests, and coverage is
recorded per package under test, so it read as zero-covered.

reportAuthorisation now has a test asserting the exact log text, because
validate-topology.sh greps for it and a rename would quietly turn the gate into
one that never fires.

The six lines still uncovered are inside FromPoolConfig, which needs a live
JetStream to reach. Diff coverage 87.0%.

* fix: refuse a run whose tfvars file contradicts the compartment it was given

Terraform ranks *.auto.tfvars above TF_VAR_*, so a leftover file wins silently
over an operator targeting a different tenancy by environment, and the guide
tells every operator to write one.

Hit for real: a stale terraform.auto.tfvars naming a different tenancy's
compartment and a bare-metal shape overrode TF_VAR_compartment_ocid, and the
plan was for 11 resources in a compartment nobody had chosen. Only a dry run
caught it.

The run now stops and names both values rather than building.

* fix: collect orphaned OCI public IPs and name the limit that was hit

A reserved public IP attached to nothing is on no VNIC, so the reconcile
that exists to find leaked addresses could not see the ones that matter.
It also ran only at startup, while an address is leaked on release and on
teardown. Twenty accumulated in one compartment and exhausted the
tenancy's limit of 50, after which every launch needing a public address
aborted.

Reconcile now lists reserved public IPs in the compartment and collects
those that carry our prefix, are attached to nothing, are named by no
binding and are older than a grace period. All four are needed: an
allocated but unassociated Elastic IP is detached too and is the
customer's to keep, and a young detached address is what another node's
CreatePublicIP looks like until it attaches. A pool with no binding
record is not swept at all, since that is indistinguishable from a store
that cannot be read.

The pass now repeats on a ticker rather than running once at startup.

Both OCI ceilings also resolved to InsufficientAddressCapacity, which
tells an operator the region is out of addresses when the fix is to
release addresses they already hold. AWS separates them, so a tenancy out
of reserved public IPs is now AddressLimitExceeded and a full VNIC is
PrivateIpAddressLimitExceeded.

* feat: run the OCI principal e2e in a tenancy that can hold its dynamic group

vm-single-principal needs the tenancy's dynamic group, and creating one is a
tenancy-root write that a shared test tenancy does not grant us. The topology
could never pass there however correct the code was, so it now takes its own
credential from OCI_PRINCIPAL_*, all six or none. A partial set is refused
before the build rather than at the apply, because it would build one tenancy's
instances against another's compartment. With none set the topology is recorded
SKIPPED instead of spending a provision to fail.

The sweep takes the same credential, or a leftover principal deployment is
destroyed against a tenancy that never held it and reported clean.

Two defects found while proving it:

--dry-run reported FAIL on every topology with a tree source, because the dry
run skips the build and then refuses the tarball it would have produced. The
flag operators are told to run first could not pass.

The dry run truncated its plan to the last 30 lines, which is the outputs block.
That dropped the resource count and every compartment and shape line, so the
one check a dry run exists for could not be made from its output. It now writes
the whole plan and prints the summary.

Removing a deployment is documented as leaving public IPs behind. Spinifex
allocates them on the running node, so they are in no Terraform state and
destroy cannot see them; twenty accumulated and exhausted a tenancy's limit,
which then refused every launch in every compartment.

* fix: canonicalise the Opc-Next-Page header in the pagination test

CI's linter rejects a non-canonical header name and the local preflight does
not, so this passed here and failed there.
2026-10-06 20:56:16 -05:00
Julian Sommer db97a5a101 fix: answer ECR constraint breaks with AWS's messages and InvalidTagParameterException 2026-10-07 11:34:15 +10:00
Julian Sommer 076949430c fix: return ECR responses and error messages in AWS's shape (#1183) 2026-10-07 12:13:06 +11:00
Julian Sommer 98a3f2b721 fix: answer ECR validation refusals with InvalidParameterException (#1184) 2026-10-07 12:10:08 +11:00
Julian Sommer d7f095fd39 feat: answer ECR DescribeRegistry with the caller's registry and an empty replication configuration (#1182) 2026-10-07 12:06:17 +11:00
Julian Sommer ff87f65a43 chore: allow serial lint runners 2026-10-07 10:07:58 +10:00
Julian Sommer 25143ed61a fix: describe imported ACM certificates with the fields and formats AWS returns (#1181) 2026-10-07 10:53:50 +11:00
Julian Sommer 4de0195bda fix: answer a malformed ACM RequestCertificate domain with AWS's ValidationException (#1179) 2026-10-07 10:30:11 +11:00
Julian Sommer 99357ad52b fix: return the certificate as GetCertificate's chain when none was imported, as AWS does (#1180) 2026-10-07 10:27:16 +11:00
Julian Sommer 857fbf8a3f fix: reject Tags on ACM certificate re-import and keep the stored tags (#1177) 2026-10-07 10:26:35 +11:00
Julian Sommer 265f6fad19 fix: report ACM RSA key algorithms as RSA-<bits>, as AWS does (#1178) 2026-10-07 10:25:01 +11:00
Julian Sommer 261229b654 fix: reject security group rules without a protocol or tcp/udp ports, and classify rule IDs as AWS does (#1176) 2026-10-07 10:07:20 +11:00
Julian Sommer b244de318c fix: log the proxied client IP on auth failures and clean nbdkit exits at info (#1175) 2026-10-07 09:52:28 +11:00
Ben Duncan 7c56855526 fix(oci): both credential routes in the guide, and a nightly topology that proves the principal one (#1173)
* docs(oci): offer both credential routes, and default the guide to the release

Step 2 was one route only, the scoped API key, with the instance principal
mentioned in a TIP at the end of it. It is now "2. OCI Credentials for
Spinifex" with the two routes as 2.1 and 2.2, a table to choose between them,
and the instance principal first because it leaves no key on any node.

The guide also assumed the dev branch throughout. `git switch dev` and
`--channel dev` are now an optional note for installing a pre-release, and the
documented path is the published release.

* test(oci): add a vm-single-principal topology, and cover the credential branch

Instance-principal authentication was broken for a release while the OCI
nightly stayed green, because the run's two axes never crossed: every topology
that built the address allocator authenticated with an API key, and every run
that authenticated as the instance was dispatched with no external pool, so the
allocator was never constructed and the credential never used.

vm-single-principal closes that. It is vm-single with the credential fixed by
the topology rather than by a separate switch, so the name alone decides it, and
pairing it with --no-external-pool is refused rather than run: without the
allocator there is nothing for it to exercise. It joins the nightly's default
list, and naming it alone is the way to test this one thing in a quarter of the
full run.

A unit test covers the same branch without a tenancy. newClient now reaches its
two constructors through package variables a test can swap, which is the only
way to observe the choice off an OCI instance - both real constructors need
either an instance identity or a key file on disk.

* fix(oci): let the tfvars choose the credential, and stop the guide hardcoding paths

The deploy driver passed instance_principal as a -var, which outranks a
tfvars file, so a deployment that set it there had it silently replaced with
off and the nodes went looking for a key. Same defect class as the shape and
node count, and the same fix: the flag is a choice about one run and still
outranks everything, and without it the deployment's own tfvars decides.

The driver now reads instance_principal from terraform.tfvars and
*.auto.tfvars for its pre-apply gates, taking the last assignment as Terraform
does, and checks that guess against a new Terraform output once the apply has
run. They have to agree, because the credential handoff is skipped on the
strength of it and a wrong answer leaves a node with no credential at all.

The guide now carries one export rather than two more steps. SPX_PRINCIPAL is
set in step 2.1, expanded into the tfvars step 4 already writes, and read back
from there in step 5, so an instance-principal deployment runs the same
command as a key-based one.

Getting into the node needed an address copied out of the log. Every topology
now writes its hosts file, not only the multi-node one that hands it to
install-node.sh, and the run prints the ssh and destroy commands when --keep
leaves the infrastructure up. The guide exports TOPOLOGY once, so the paths
read the same for one VM, three, or bare metal.

* fix(oci): print the ssh key path as ~ rather than expanded

The --keep line read ssh -i /home/you/.ssh/oci-spx, which is the invoking
user's home and differs on every machine. Collapsed to ~ when the key is under
$HOME, left alone when it is not.
2026-10-06 13:15:56 -05:00
Julian Sommer cfc7b76cca refactor: unregister EKS NotImplemented stubs so coverage counts only served operations (#1172) 2026-10-06 15:57:04 +11:00
Julian Sommer 144a449b82 docs(eks): API_AND_CONFIG_MAP is accepted, only CONFIG_MAP is rejected 2026-10-06 14:03:08 +10:00
Julian Sommer d6bfb4cf49 fix: pin daemon tests off the host's live MemAvailable
Admission clamps on live MemAvailable, so on a loaded box running tests in
parallel any daemon test that allocates could see no capacity. TestMain now
disables the live-memory gate; its own tests inject a reader.
2026-10-06 13:23:52 +10:00
Julian Sommer 4a6ce6cbbc fix: send S3 to predastore from the spinifex AWS CLI profiles
admin init and account create wrote a single profile endpoint_url, so aws s3
reached the gateway on :9999, which does not serve S3. Profiles now carry a
services section pointing s3 at the predastore gate, and the INI rewrite keeps
nested settings instead of flattening them.
2026-10-06 13:08:43 +10:00
Julian Sommer 414a60697a fix: wait for the server span in provider tracing tests
Servers reply before ending their span, so the client can return before
the span reaches the recorder. SpansByKind now polls until a server span
has ended and fails the test if none does within 5s.
2026-10-06 11:57:06 +10:00
Julian Sommer e6dcf8159c fix: refuse an omitted DBParameterGroupFamily and use AWS's unknown-family message 2026-10-06 11:19:42 +10:00
Julian Sommer 4e8e854fc0 fix(ci): stop dev release tags nesting the previous dev tag
The dev release tagged itself with git describe plus -dev, and describe
resolved to the previous dev tag, so every tag appended to the last until
the tarball name passed the 255-byte filename limit. Describe against
release tags only, here and in the Makefile and bare-metal VERSION.
2026-10-06 11:16:25 +10:00
Julian Sommer 15b712f8f5 fix: name the accepted volume types in CreateVolume's UnknownVolumeType error 2026-10-06 11:11:05 +10:00
Julian Sommer c395722344 fix: answer ModifyInstanceAttribute with no or several attributes with InvalidParameterCombination 2026-10-06 11:05:43 +10:00
Julian Sommer a69ed27dc4 fix(spx): extract image imports into a disk-backed scratch dir (#1171) 2026-10-06 12:01:13 +11:00
Julian Sommer bcbe415288 fix(spx): plain output when stdout is not a terminal, NO_COLOR or TERM=dumb (#1170) 2026-10-06 11:50:53 +11:00
Julian Sommer 33b9dbec40 test: speed up the slowest spinifex unit test packages (#1169) 2026-10-06 11:25:30 +11:00
Ben Duncan 9a8b63b127 fix(oci): run the deploy driver on the bash macOS ships, and finish the guide at the handoff (#1168)
* fix(oci): run the deploy driver on the bash macOS ships

`validate-topology.sh` died on line 41 with `bm: unbound variable` on a Mac. macOS
ships bash 3.2, where `declare -A` is an indexed assignment and the subscript is
read as an arithmetic expression, so `[bm]` dereferenced an unset variable and
`set -u` ended the run. `mapfile` further down would have failed next.

The guide names macOS as a supported workstation, so the driver now uses neither:
the per-topology defaults are a `case` in a function, and the host list is a read
loop over a captured string. The output is captured before the loop so a failed
`terraform output` fails there rather than silently producing an empty host list.

Verified: all three topologies resolve their shape and node count, and an unknown
topology still names the valid set.

* docs(oci): end the OCI deploy where the install guides end

The deploy command now carries --skip-workload, so the guide stops at a formed
cluster and a working AWS call rather than at a workbook run, and hands off to
Setting Up Your Cluster the way every other install guide does. A step to SSH to
the node was missing between the deploy and the verification call.

Three things this corrects while the section was open. The expected output block
now matches the command it documents, including the --skip-workload line that
ends the run. The --channel dev callout said OCI support was missing from the
deploy driver, when what is missing is an OCI address allocator in a published
release. The verification output was fenced as json while holding a shell
session, which is neither valid json nor the thing being shown.

The note on probing a guest's public address from outside the VCN moves to the
handoff section, since that is now where a guest first gets launched.
2026-10-05 18:25:39 -05:00
Julian Sommer 9d7407a1f5 fix: CopyImage carries Distro and DistroFamily from the source AMI
The copy was built field by field and dropped both, so cloud-init family detection was lost on copied images.
2026-10-06 08:44:41 +10:00
Julian Sommer 8fec37c313 chore: rebuild frontend 2026-10-06 08:27:07 +10:00
dependabot[bot] e276ccd4a1 chore: bump the npm-dependencies group (#1161) 2026-10-06 09:25:42 +11:00
dependabot[bot] 11fdd89dbf chore: bump the go-dependencies group across 1 directory with 6 updates (#1162) 2026-10-06 09:15:35 +11:00
dependabot[bot] 2a0805b264 chore: bump the github-actions group across 1 directory with 2 updates (#1159) 2026-10-06 09:13:31 +11:00
Ben Duncan 7d190fc1f1 fix(oci): let terraform.auto.tfvars decide the shape and the node count (#1167)
`validate-topology.sh` passed the topology's shape and node count as `-var`,
which is the strongest source Terraform reads, so a `compute_shape` or
`node_count` in `terraform.auto.tfvars` was silently discarded. The OCI guide
told the reader to write both into that file and then to run the driver, so a
deployment asking for `VM.Standard.E5.Flex` got `VM.Standard.E6.Flex` and said
nothing about it. Three of the five shapes the guide recommends were unreachable
by the command it gives, including both bare-metal ones other than the shape CI
happens to use.

They now go in as `TF_VAR_compute_shape` and `TF_VAR_node_count`, the weakest
source, so the tfvars file wins and a deployment needs no flag to size itself.
CI is unaffected: `*.auto.tfvars` is gitignored, so a runner's checkout holds no
such file and a named topology still resolves to the same values every run.
`instance_principal` stays a `-var`, because `--instance-principal` is a choice
about the run rather than about the infrastructure's size and a stale tfvars must
not quietly contradict it.

Because the count can now come from the file, the driver no longer presumes it.
The number of addresses in the `hosts_file` output is the authority for the
single-node init path and the readiness gate, a divergence from the topology's
default is logged rather than fatal, and a new `compute_shape` output lets the run
report the shape it built instead of the one it asked for.

In the guide: the tfvars heredoc is unquoted so `$CID` expands, with a `grep` to
confirm it did, and `node_client_cidr_allow_list` is bracketed, since it is a
`list(string)` and the bare CIDR was an HCL syntax error.
2026-10-05 17:06:03 -05:00
Ben Duncan 4fbde4a665 docs(oci): ship the credential installer, and rewrite both OCI guides in the house voice (#1166)
* docs(oci): rewrite both OCI guides in the house voice

Seventy-five em dashes across the two guides, with four of them in headings where GitHub and the docs site slugify them differently and a cross-link resolves on neither. Recast as commas, colons and separate sentences.

The silent-credential failure was stated five times as the same triple, "forms, passes every health check, and then refuses every launch". It now reads in full once, under Credentials, and the other four refer back to it instead of echoing the skeleton.

Also broken up: the parallel stack in "Why run Spinifex on OCI", several five-sentence paragraphs, and a handful of clauses that leaned on a dash where a full stop was clearer.

No technical content changed. Every inline-code token is byte-identical before and after, every fenced block is unchanged apart from one comment line, and the four reworded headings carry no inbound anchor links. The docs build reports zero warnings for either page and both render at the same size as before.

* docs(oci): open the install guide with what Spinifex is, as the other install guides do

* docs(oci): ship the credential installer instead of asking for a hook

The guide told the reader to write their own executable to put the OCI API key
on each node, which is work nobody should have to do to install a product, and
the step was unclear about where the scoping actually happens.

`scripts/terraform/oci-spx/spx-oci-config.sh` now does it. It takes
`validate-topology.sh`'s hook contract, `hook <ssh-key> <host>...`, so it drops
straight into `--credential-hook` and is also runnable on its own against an
existing cluster.

It resolves the credential in four steps and takes the first complete one: the
`OCI_SPX_*` environment variables, the `[spinifex]` profile in `~/.oci/config`,
`~/.oci/oci_api_key_spx.pem` with the Terraform profile's identifiers, then the
Terraform credential itself with a warning that it is wider than the allocator
needs. Steps 2 to 4 go through `scripts/oci_env.py`, the same resolver the apply
uses, so the credential on the nodes and the one that built the infrastructure
cannot come from different profiles. The fingerprint is checked against the key
before anything is written, because a mismatched pair is otherwise an OCI 401 at
the first allocation, long after the deploy has reported success.

The node's filename stays `oci_api_key.pem` while the workstation's is
`oci_api_key_spx.pem`: the suffix exists only to keep the Spinifex key off
whatever `[DEFAULT]` uses, and a node has no other OCI key. Nothing reads the
name anyway, since the daemon learns the path from `key_file` inside
`/etc/spinifex/oci/config`.

Key material reaches the nodes over SSH, in the remote shell's stdin rather than
its arguments where `ps` would expose it, and goes into neither user-data nor
Terraform state. `--credential-hook` still accepts any executable with that
argument shape, so a tenancy that keeps credentials in a vault can substitute
its own.

Also in the guide: the policy statements name the compartment by `compartment
id` rather than by name, which is the form that resolves from a compartment the
policy is not attached at; `$CID` is consistently a compartment OCID; and the
table of contents matches the headings again.
2026-10-05 16:18:00 -05:00
Ben Duncan cf17866fed docs(oci): publish both OCI guides on the docs site (#1165)
* docs(oci): publish both OCI guides on the docs site

The site extracts only four H2 sections and drops everything else, so both guides
would have published nearly empty: the deployment guide matched none of the four,
and the reference doc only its troubleshooting. Insert the section markers the
other published docs already use, keeping every heading at the level it had so no
anchor moves.

The architecture reference becomes oci-architecture/README.md, because a page is
always <slug>/README.md and a flat URL takes the last path segment -- plain
"architecture" would have published at /docs/architecture.

Two headings lose an em dash. GitHub renders it as a double hyphen in the
fragment and the site's slugify drops it entirely, so the two disagreed and the
cross-links resolved on neither.

* docs(oci): the OCI guides sit in their own Cloud Install section

Their category frontmatter named Install, which is the section they are moving out of.
2026-10-04 15:18:02 -05:00
Ben Duncan 6af550fe52 fix(iam): fail ListAttached*Policies rather than drop a policy it cannot read (#1164)
attachedPolicies resolved each attached ARN and, on any error, logged a warning
and skipped it. A transport failure is indistinguishable from a missing record
there, so one timed-out read reported the policy as not attached.

Two paths then read the same field and disagreed. ListAttachedRolePolicies
returned the filtered list, so a client concluded the attachment was gone and
dropped it from its own state without calling DetachRolePolicy, while DeleteRole
checked the unfiltered field and refused for as long as the ARN was recorded.
Nothing could clear it through the API. This is what wedged the ecs-quickstart
workbook's teardown on DeleteRole(ecsInstanceRole) with a 409.

The state being guarded is also unreachable: DeletePolicy refuses while any
principal attaches the policy and fails closed if the attachment scan errors, so
no API sequence produces a principal holding an ARN with no policy record. AWS
cannot return a short list here either -- an attached policy always exists, and
an internal failure is a 5xx.

AWS-managed ARNs are unaffected. They are deliberately stored without a record
and are still reported from the ARN itself, above the lookup.

Closes mulga-tmk5e
2026-10-04 14:29:59 -05:00
Ben Duncan 46c0c44cc2 feat: run every Terraform workbook on OCI, and split the guide in two (#1163)
* feat: run every Terraform workbook on OCI, and split the guide in two

The OCI topology driver ran the shared driver's default five workbooks, which left ECS and all three EKS variants untested on every platform even though their assertions already existed. It now passes an explicit nine and imports the three appliance images they need — RDS, ECS and EKS each boot their own, found by a spinifex:managed-by tag and never by name.

demo-app stays out because it is not a workbook: it has no .tf at all, being the container image the EKS workbooks' nested workloads modules deploy, so listing it would fail on a missing root module rather than test anything.

The guide is now two documents. oci-integration/README.md is the end-user path — six steps, deploy first, and it closes the three prerequisites nothing documented before: the clone, the ref, and the SSH keypair the driver refuses to start without. architecture.md holds the sizing, the IAM policy, every variable, the three filtering layers and the troubleshooting.

Its firewall advice was also backwards. node_client_cidr_allow_list is an OCI security-list rule over the whole public subnet, and a guest's public address is a secondary private IP on the node's own VNIC, so narrowing it to the operator's address cuts internet ingress to every guest rather than protecting the node. The hardening section now arms the nft policy and narrows SSH through custom.nft, which is the layer that can tell a node's listener from a guest's.

The deploy step names --channel dev rather than a pinned tag. The dev tag string grows with every cut, so a pinned example goes stale within the week.

* fix(oci): the guide could not work on a fresh tenancy, because the profile name was ours

oci_env.py bridges ~/.oci/config into TF_VAR_* — the provider takes the tenancy, user, fingerprint, key and region as Terraform variables and reads no config file itself — but the profile it looked for was the reference tenancy's, and validate-topology.sh passed no way to change it. A fresh account's config holds [DEFAULT], so every deploy would have stopped at "OCI profile 'apacanzset03child03' was not found" before building anything.

The profile is now resolved as --oci-profile, then $OCI_CLI_PROFILE, then the reference tenancy, then DEFAULT. A name passed explicitly and absent is an error listing the profiles present, never a fallback: the failure worth preventing is building a cluster in whichever tenancy happened to be first. The reference tenancy still outranks DEFAULT, so existing runs are unchanged, and CI is untouched because it takes the environment route rather than a profile.

has_profile exists because configparser treats DEFAULT as the fallback for every other section rather than as a section, so has_section denies it exists even when it is the only thing in the file — which made the first version of this fallback do nothing for exactly the case it was written for.

Three documentation corrections found while checking the guide against the code. The IAM policy is ten operations and needs "use vnics", not "read vnics": registering a secondary private IP is an operation on a VNIC, so OCI checks VNIC_ASSIGN, which lives in the use verb, and with read the node forms and looks healthy and every allocation fails NotAuthorizedOrNotFound. These are now the same three statements instance-principal.tf grants, which is the only version of the policy that has been exercised.

The guide also promised a worked example of the credential hook that does not exist and should not: nothing here distributes a credential. It now states the contract instead — the argument shape, the two files, and that a non-zero exit stops the deploy before anything is built. And the dev channel's rate limit is documented with the tag as its remedy, rather than left in the driver's help where a reader will not see it.

* fix(ci): a failing workbook's log never reached the artifact, so only the runner had it

run-tofu-examples-e2e.sh writes its assertion output to the per-topology workbooks.log in the state directory. "Sweep anything left behind" deletes those directories and runs before "Upload the run", so the upload's three oci-state/.validate-* patterns matched nothing and said so to nobody. The first nine-workbook run lost the one thing it was dispatched to find: ecs-quickstart failed on vm-single and the reason existed only on the runner, which does not resolve from a workstation.

A collect step now copies *.log, *.txt and *.tsv into the artifact directory before the sweep, prefixed by topology so two topologies cannot overwrite each other. The workflow already records that "Publish the result tables" sits ahead of the sweep for this reason; the logs were simply never given the same treatment.

The dead patterns are removed rather than left. A path that cannot match is worse than no path, because it reads as protection that is present — which applies to the credential-hook exclusion too: it now lives in the collect step, which is the only thing that writes into the artifact tree, so the file cannot reach an artifact rather than being filtered out of one.

* fix(ci): a state directory kept the last run's logs, and the artifact published them as this run's

Collecting the per-topology logs surfaced the next defect immediately: a vm-single-only run produced an artifact holding bm-workbooks.log and nine vm-multi node logs, because those state directories still held the previous run's output. Anyone reading that artifact would conclude three topologies ran when one did, which is worse than the missing log it replaced.

Two causes, one each side. validate-topology.sh never cleared its own logs, so a topology's directory accumulated them; it now removes *.log, nodes.txt and cloudinit-*.txt as it starts, before the first line of the new run, and never the tfstate, which is how the sweep finds hosts to destroy. The globs are named rather than a find, so what goes is readable at a glance, and :? stops the shell instead of expanding to /*.log if STATE_DIR is ever unset.

That alone does not cover a topology the run skipped, whose directory is never entered. So the collect step now takes only the topologies this run drove, read back from the per-topology log the harness writes rather than from the dispatch input -- a run that names no topology gets the harness's default, and reading that here would be a second copy of the list to disagree with the first.

Also proved by this: ecs-quickstart passes on a freshly built vm-single, 172s, where it failed at 293s as the seventh of nine. The failure is order or load dependent on one 32 GB node, not a property of the workbook.

* fix(ci): a failed workbook run left no node journal, so the cause was unreadable

The workbook log says which API call failed; only the journal says why, and the
nodes are destroyed minutes later. An IAM teardown failure on the nightly came
down to one swallowed warning that existed nowhere else, and by the time the
artifact was read the host it was logged on had been torn down.

Warnings and above first, since that is where a swallowed error surfaces, then a
bounded tail for context. Unbounded, a multi-hour suite's journal would dwarf the
rest of the artifact. The existing state-directory clear keeps these from going
stale between runs, and the collect step already globs *.log.
2026-10-04 14:29:37 -05:00
Ben Duncan c6841ce1f0 feat(oci): separate using an instance principal from creating one (#1157)
* feat(oci): separate using an instance principal from creating one

The allocator needs OCI credentials at runtime, and a node with none forms,
passes every health check, then refuses every launch wanting a public address.
Instance principal is the credential route that puts no key material on a node,
but it needs a dynamic group and a policy, and those are tenancy-root resources
the compartment-scoped user the rest of the configuration runs as cannot create.

enable_instance_principal conflated the two, so the better credential route was
only reachable by a tenancy admin. instance_principal now takes off, adopt or
create: adopt selects the credential and creates nothing, because the matching
rule covers the whole compartment and so names no instance to reference.

setup-identity.sh owns the create case, with its own state. validate-topology.sh
destroys its state at the end of every run, so holding tenancy resources there
would let a teardown delete the tenancy's policy.

Also package install-node.sh and smoke-test.sh, so a cluster can be formed from
a curl | bash install with no checkout. smoke-test.sh is not optional: formation
scp's it from its own directory for the post-formation verification.

* feat(oci): take the credential handoff from an operator hook, and widen the workflow name

An API-key deployment needs a credential on each node, and nothing in this
repository can supply one: a key in user-data is readable from instance metadata
for the life of the instance, and this host runs other people's guests. A manual
install does not survive either, because a topology destroys itself on success.

So the harness calls an operator-owned executable after formation and before the
pool, the only window where a node has /etc/spinifex and has not started the
allocator. Instance principal needs no handoff, so the hook is skipped there.

The workflow is renamed for the vendors it will cover rather than the first one.
Nothing is orphaned in the Actions UI: it has never been on the default branch
and has never run, so there is no history attached to the old filename.

* docs(oci): the API-key path needs the config file, not just the PEM

oci_config_file names an ordinary OCI SDK config and nothing creates it, so a node
holding only the key forms and then fails every allocation with no stated cause.

* fix(ci): the runner context is not available in a job-level env block

A workflow referencing it there is rejected before any job starts, which is why
the rename produced a failed run attributed to the push. The reference had never
been validated: a dispatch-only workflow that is never dispatched is never parsed.

* fix(deps): bump containerd to v2.4.1 for GO-2026-6597

The image-pull DoS via crafted OCI index graph amplification is reachable from the ECS agent's containerd puller, so the vulnerability scan fails preflight on v2.4.0. A patch release with no API change.

* fix(e2e): probe guest public addresses from outside the cloud's own network

A VCN does not reflect its own public addresses back into itself, so a node asking for one gets nothing however well the datapath works. The workbook driver runs on the first node, so every assertion that curls or SSHes a guest's public address failed on OCI against a cluster that was serving those addresses correctly to the internet.

The driver now takes E2E_PUBLIC_PROXY, a SOCKS5 proxy used for public addresses only, and validate-topology.sh supplies one with a remote dynamic forward on the connection that launches the driver. Egress is then the host running the validation, which is outside the VCN and is where a customer sits. Unset leaves every other platform on a direct probe, which is already correct there.

Also adds --version TAG for a repeatable install, separates the workflow's auth choice from its external-pool choice so an API key can run with the allocator live, and captures allocator diagnostics before the teardown destroys them.

* ci: read the Go version from go.mod in the release workflows

Both pinned 1.27.0 by hand while every other workflow reads the file, so the toolchain they build with drifts from the one the module declares the day go.mod moves. release.yml was already inconsistent with itself: its second setup-go step reads spinifex/go.mod.

* feat: publish the OCI topology results as tables on the run page

A green tick on one job with 4,000 lines of log behind it is confidence for
whoever wrote the harness and nothing to anybody else. Someone assessing whether
Spinifex works on their cloud should be able to see that it formed and served
guests on a single VM, on three VMs and on bare metal without opening a log.

record now takes <gate> <status> [detail] and writes results.tsv beside the
human block, so a gate's status is a field the script sets rather than a regex
over prose that reads fine and matches wrong. record_workbooks turns the
driver's own go-test-shaped lines into workbooks.tsv, and runs on the failing
branch too: which four passed is most of what the fifth failing means.

e2e-oci.sh --summary-only renders both tables to stdout and to
GITHUB_STEP_SUMMARY, and the workflow calls it under always() before the sweep,
because the sweep destroys the state directories the tables are read from. A
skipped gate gets its own mark, and a topology that never ran says so: the one
way a results table can do real harm is a reader taking an absent row for a
passing one.

* docs(oci): cut the deploy-by-hand appendix, and validate the config for real

The appendix was 444 of 1014 lines restating an install the Terraform already
does, which made the document read as though deploying by hand were a normal
route. It is not: the configuration builds the VCN, the volumes and both VNICs
per node and renders the cloud-init, in the same order every time. What is
genuinely OCI-specific is four things, each of which already has a section, so
the replacement names those and sends everyone else to the single-node and
multi-node install guides.

Verify end to end told the reader to ping a guest's public address from the
host, which cannot work: a VCN does not route its own public addresses back into
itself. That is the finding this branch is built on and the document was
asserting the opposite. It now says where to probe from and why, and carries the
allocator's `resolved the external VNIC` check, which existed only in the cut
appendix and is the one line proving the credentials and compartment are right.

`spx config validate` is not a command -- spx has admin, get, service, top and
version -- so that line could only ever fail and its output and status were both
discarded. It is replaced by parsing the TOML, which is what was wanted: the
remap and the pool append are textual edits, and a broken result would otherwise
surface as every service failing to start with nothing saying why. The remap now
checks its own substitution count for the same reason, since re.sub with no
match rewrites the file unchanged and exits 0.

Publication of the run-page tables moves to one owner. GITHUB_STEP_SUMMARY is
per step, so appending from the run as well as the publish step would have put
two copies of every table on the run page.

* fix(oci): check the credential hook before building anything

The hook is a path from a CI variable, and whether it exists on the runner is
knowable before Terraform runs. Checked where it was used, a wrong path cost the
apply, the install and the formation first -- forty minutes and a bare-metal bill
to be told a file is missing. Nothing about the check depends on the apply, so it
moves to the top, where --dry-run reaches it too.

* fix(oci): the summary must say what was built and what was not tested

The first CI run headed its tables "Published release `latest channel`" over a
build of a feature branch, which is the one error in a results table that would
mislead a reader who trusted it. The header now states a tree build as a tree
build, and names how the allocator authenticated.

A run with no external pool says so in the header rather than leaving it to a
SKIPPED row further down. Public-address allocation is the claim a green table is
most likely to be read as making, and it is exactly the claim such a run cannot
support. Both flags are passed to the publish step too, so the summary rendered
after a failure describes the same run as the one that failed.

Raise the dev prerelease prune from five to twenty. A pinned dev tag is how an
external evaluator installs a specific build, and five is about a week of them --
short enough that the tag someone was handed stops resolving while they are still
using it.

* docs(oci): the workbook table is a snapshot, and the nightly is the current answer

It is dated against a build five releases of work ago, and the nightly now runs
the same workbooks on all three topologies and publishes its own table. Say which
one to read rather than letting a stale table be taken as current.

* ci: rename to e2e-cloudvendor.yml so the sidebar entry is registered from a valid file

A workflow's displayed name is captured when GitHub first registers it and is not
refreshed afterwards. Both OCI entries were registered from a run that could not
be parsed -- a job-level env block referencing runner.temp -- so both list as
their path, which reads as a missing name: key and is not one. The 2026-10-02 run
was green on all three topologies and the entry still read as a path, with
updated_at unchanged from the broken first run, so fixing the file is necessary
and not sufficient.

A new path is a new registration, and its first run reads a valid file. Dropping
-nightly is also more honest: there is no schedule: here, because GitHub
registers one only from the default branch, so a cron could never run dev and a
feature branch as separate nightlies.

* Revert "ci: rename to e2e-cloudvendor.yml so the sidebar entry is registered from a valid file"

The rename makes the workflow undispatchable. `gh workflow run` resolves a
filename against the default branch, and the raw dispatch endpoint returns 404
for a path GitHub has not registered -- checked twice, several minutes apart, so
it is not an indexing delay. The old name works only because it is already
registered, and an unrunnable workflow is a worse problem than a mislabelled one.

The sidebar label needs a fresh registration, which a rename cannot provide from
a feature branch. It corrects itself when the file reaches main, where GitHub
re-reads it; the alternative is deleting the dead registrations' runs, which is an
operator's call.

* fix(ci): never publish the credential hook's output as an artifact

The hook is the operator's own executable, and the one thing known about it is
that it handles a private key. Its stdout and stderr are captured to
credential-hook.log, which the artifact's *.log glob was collecting into a
downloadable archive with a fourteen-day retention.

Excluded rather than trusted to have been quiet. A hook that prints its key once
while someone is debugging it should not thereby publish it.

* fix(oci): the hairpin probe measured nothing on one node, and claimed too much

With a single host, HOSTS[0] and HOSTS[-1] are the same address, so the node
connected to itself -- which always succeeds. It reported "present" on vm-single
and bm for that reason and not as a measurement. It now needs two nodes and says
so otherwise.

The claim it carried was also wrong. "A VCN does not reflect its own public
addresses back into itself" was read off a workstation run whose
node_client_cidr_allow_list was narrowed to one office address, which makes a
node's own public address a non-permitted source: the refusal that follows is
identical to an absent hairpin. With the committed 0.0.0.0/0 default the probe
reports the hairpin present between two nodes, so the original reading was
confounded and is retracted.

None of this changes the remedy. A customer reaches a guest from outside the VCN,
which is where the proxy's egress is, and probing from there is correct whichever
of the two causes is in play -- 15 of 15 workbooks pass through it on all three
topologies. What changes is what the documentation may assert: the guide now names
both causes as indistinguishable rather than stating one as fact.

* docs(oci): a reader could not run the first command, because nothing told them to clone

The guide's first code block was `cd scripts/terraform/oci-spx`, into a directory
no step had obtained. It also never mentioned the SSH keypair the driver refuses to
start without, nor that the configuration is not on main or in v1.21.0 yet, so all
three had to be inferred from the deploy failing.

Restructured so the deploy comes first and reads as four steps and one command:
get the configuration, choose one node or three, write the inputs, run it. The
credential decision stays before step 4, because it changes an argument there and a
node without one forms, looks healthy, and then refuses every launch that wants a
public address. Everything else -- why OCI, sizing, the architecture, the IAM
policy, quotas, the oci CLI, what Terraform builds, every variable -- moves under
Reference, unchanged in substance.

Adds what a successful run prints, naming `allocator ready` as the line to look for:
every line above it appears on a node that cannot allocate, and that is the one
failure the run does not announce.

Also drops an empty verdict.txt committed from a local run. The directory the guide
now tells customers to work in should not ship one run's artifacts.
2026-10-04 09:04:55 -05:00
Ben Duncan 70007405c1 fix(oci): install the dev channel by environment variable, not --channel (#1155)
The installer served by install.mulgadc.com is the latest release's, so it
predates the --channel flag the harness was passing and would have died with an
unknown option. Document the same form in both install guides.
2026-10-02 15:55:51 -05:00
Ben Duncan 66bc117203 fix: size the volume lease against the outage the host produces, and stop relaunching fenced instances (#1156)
* fix: size the volume lease against the stall the host actually produces

A guest lost its disk whenever JetStream writes failed for fifteen seconds,
which is what one NVMe I/O timeout on the node holding the JetStream leader
produces. The budget composed badly rather than any single number being wrong:
a confirmation could already be volumeLeaseRenewInterval old when an outage
began, and validity expired volumeLeaseValidity after it, so an outage covering
[15s, 30s) surrendered a volume no peer had taken. Measured outages are 10-15s,
so the lease was sized at the edge of a routine event. On one cluster thirty
eight renewal timeouts produced six surrenders and four destroyed guests.

Validity rises to 40s and the renewal interval falls to 10s, taking the
tolerated outage from fifteen seconds to thirty. The validity-to-TTL gap is now
named by volumeLeaseAcquireMargin instead of being implied by two constants:
nothing may acquire until the server-side bucket TTL ages the entry out, so the
margin only has to cover scheduling and request latency on this side.

The renewal timeout rises above the stall bound, so a renewal that begins inside
a stall is answered when it clears rather than being certain to fail. That is
not a constant on its own. renewLoop was one goroutine, so a blocking Update
delayed the validity check by the whole timeout: measured on the old loop with
the new timeout, a holder surrendered 51s after its last confirmation, eleven
seconds past validity and six past the server TTL, which is a window where a
peer may already hold the volume. The renewal now runs on its own goroutine with
one call in flight at a time, the loop keeps testing validity underneath it, and
it waits for the renewal before closing done so release still reads a settled
revision.

deviceStallBound documents nvme_core.io_timeout as a property of the host. It is
not configured or applied here, only asserted against, because it describes the
machines rather than the product.

The sizing is a probability, not a guarantee. An outage longer than validity
still surrenders, and the fence still destroys the guest instead of revoking the
export and leaving it paused, which is the change that would make outage length
stop mattering.

* fix: keep a fenced instance out of every automatic relaunch

A fence does not seal, so the volumes it leaves behind are a state nothing has
shown is mountable. Two paths relaunched one anyway. The restore path consults
the restart window, which forgives on a clock, so a fence days old reports a
full budget and the next daemon start remounts it. The recovery reconciler
excludes a storage fault but not a fence, and a fence kills the export instead
of pausing the guest, so the record publishes no I/O error and reads as an
ordinary desired-running instance on a node that has died.

Both now read vm.VolumeFenced off the StateReason the fence already writes. The
refusal is narrower than StateError on purpose: a recovery that failed for its
own reasons is what the restart budget exists for.

Also separates the two storage quantities the lease was sized against. One
stalled I/O lasts deviceStallBound, which is what a renewal timeout has to
exceed; what the lease has to survive is a run of them, now named
jetstreamOutageBound. They were conflated, so raising the device constant would
have licensed a validity past the server TTL.

A surrender can only ever report validity, so the length of an outage was
unobservable. Renewals now count consecutively and report outage_ms once they
recover, which measures the windows the lease survives.
2026-10-01 15:32:49 -05:00
Ben Duncan 1743b60246 feat: publish a dev channel, so cloud contacts can install dev without a release (#1154)
install.mulgadc.com has served a dev channel since it was written -- setup.sh
understands INSTALL_SPINIFEX_CHANNEL=dev and the Worker resolves it to the newest
GitHub prerelease -- but nothing ever produced one, so /download/dev/amd64
returned 404. This is the producer.

A separate workflow rather than conditionals in release.yml. A dev build is a
different artifact, not a release with flags off: amd64 only, no ISO, no system
images, no bare-metal gate, and nothing on a self-hosted runner. The sibling
repositories are taken at dev rather than their default branch, because dev of
spinifex against main of the others is a pairing nobody runs.

The tag is the describe string plus -dev, which release.yml's own convention
already treats as a prerelease, and which makes the commit recoverable from the
version alone. The flag is set at creation and then the published state is
asserted: a prerelease must never become the latest release, because latest is
what an unqualified curl | bash installs.

setup.sh gains --channel and --version so the instruction is a flag rather than
an environment variable, and the OCI harness gains OCI_CHANNEL so a nightly can
test the path the contacts are actually given.
2026-10-01 13:51:57 -05:00
Ben Duncan ade060f384 fix(oci): make bare metal work, remove the per-node API key, and validate all three topologies (#1146)
* fix(oci): make a bare-metal node form and mount its data volume

Bare metal presents VNICs differently from a VM: the first VNIC on a physical
port is the port, and a second one sharing that port is a tagged overlay with no
Linux device at all. The external VNIC was landing in that second slot, so no
interface ever carried it and the WAN bridge could not be built. Attach it at
nic_index 1 on a BM shape, which makes it the first VNIC on the second port and
therefore untagged and present at boot, exactly as it is on a VM.

Two consequences of that followed. The bridge script picked the first non-primary
VNIC from instance metadata, which on bare metal can be one with no interface, so
it now selects the VNIC the kernel actually has a device for and waits for that.
And OCI makes a bare-metal volume attachment multipath, where the Oracle agent
refuses to log in unless multipathd is running -- probing the unit name Debian
retired, so on Ubuntu 26.04 it never sees it. Alias the retired name and assert
the alias on every poll rather than once, because the first run had it created and
then vanish.

Finally, the three cloud-init steps become ordered systemd units. runcmd
continues past a failed step, so a node whose volume never appeared still opened
its service ports with nothing behind them; Requires= makes that impossible and
lets a boot that raced the iSCSI login retry on the next one.

* feat(oci): authenticate the address allocator as the instance principal

The OCI allocator could only authenticate from an API-key config file, so every
node needed a private key copied to it and a config file hand-written beside it.
A node without them forms, passes every health check, and then refuses every
launch that wants a public address with InsufficientAddressCapacity -- the cause
appears only in the journal.

NewInstancePrincipalClient already existed and was called from nowhere. Wire it
in behind a new oci_auth key on the pool, so a node can authenticate with the
certificate its own metadata service serves and hold no key material at all.
Nothing to copy, nothing to rotate, and nothing in Terraform state.

The reason recorded for preferring the config file was that it reads no instance
metadata, because Spinifex's own IMDS endpoints claim 169.254.169.254 and take
the cloud's metadata service with them. That no longer distinguishes the two:
oci_vnic_iface is what the guide recommends and it resolves the VNIC through a
metadata lookup already, so such a node depends on the IMDS remap either way.
The two constructors' comments also contradicted each other on which was
preferred; both now say what they cost.

config_file stays the default, because instance principal needs a dynamic group
and a policy only a tenancy admin can write. The Terraform creates both behind
enable_instance_principal, off by default for the same reason the module creates
no other tenancy-root resource, and the policy grants three verbs on vnics,
private-ips and public-ips in one compartment rather than anything broader.

Naming the method explicitly rather than inferring it is deliberate: a node meant
to use its own identity now fails at config load instead of falling back to a key
file nobody installed. Pairing oci_auth="instance_principal" with a config file
is refused for the same reason, since the certificate is already the credential
and the file would be the half silently ignored.

Terraform also stages the pool fragment on each node, which under instance
principal contains no secret at all.

* feat(oci): validate all three topologies from one script, and document both auth routes

validate-topology.sh builds one topology from nothing, installs the published
release, forms the cluster, configures the pool and the IMDS remap, runs a
workbook against it, proves the workbook actually serves traffic, and destroys
everything. Three topologies because each breaks differently: bare metal presents
VNICs unlike a VM, a single node has no Geneve underlay to get wrong, and only a
cluster exercises RAFT, the gateway chassis and cross-node allocation.

--topology has no default, deliberately. A command aimed at the wrong topology is
the easiest expensive mistake available here, and each keeps its own Terraform
state so two can be built from one checkout without either destroying the other's
instances.

The teardown decides the verdict rather than tidying up after it. A topology or a
workbook that cannot be destroyed is half proved, so destroy runs from an EXIT
trap and a teardown failure fails the run. It also checks the two things a green
apply does not: that vpcd logged resolving the external VNIC, which is the only
evidence the credentials work and br-wan matched a real VNIC, and that the load
balancer returns 200 -- a workbook can create cleanly and carry no traffic.

The guide gains the instance-principal route beside the key file, and loses its
claim that bare metal changes nothing else in the document. That claim was wrong
in exactly two places, and both are now handled rather than left to the reader:
the external VNIC's nic_index and the Oracle agent's multipath unit name. It now
also says plainly that the external-address path on bare metal is unproven.

* test(oci): let a topology be validated without an OCI credential

The external pool is the one phase that needs a key or a dynamic group, and
the VM topologies have to be regression-tested regardless. --no-external-pool
forms and verifies without it, and implies --skip-workload so the run cannot
claim a guest path it never exercised.

* test(oci): one entry point that proves a ref works on OCI, and cleans up after itself

e2e-oci.sh drives every topology from environment variables alone, so a
workstation, a Worker dispatch and e2e-oci-nightly.yml all take the same path
rather than drifting into three answers about whether OCI works.

Three things it deliberately does not do. It does not test the published
release: --distro installs the tarball built from the ref under test, because a
nightly that cannot fail on a bad commit is decoration. It does not carry its
own workbook assertions, running tests/e2e/run-tofu-examples-e2e.sh over the
published workbooks instead, so a workbook that passes on a hypervisor and fails
here is a difference in OCI. And it cannot quietly leave cloud resources
running: --sweep-only destroys whatever an earlier run's state still holds, and
it runs before the topologies, after them, and from an always() step.

The credential now reaches Terraform as a value through OCI_PRIVATE_KEY, so no
runner writes key material to disk, and oci_env.py refuses --shell on that route
because in CI stdout is the job log.

Four harness defects the first real runs exposed, each of which had been giving
a wrong answer:

  - oci_env.py announced itself on stdout, corrupting every captured
    terraform output. That is also why a single node came back as two hosts.
  - terraform output was passed -var, which it rejects, and a silent fallback
    then read the default state file instead of the topology's.
  - grep -c Ready matches NotReady, so a single-node run whose only node was
    NotReady on 0.0.0.0 reported PASS. The status column is parsed now, and
    membership is waited for rather than sampled once.
  - install-node.sh refuses fewer than two hosts, so a single node forms with
    spx admin init the way the guide documents.

Also waits for the dpkg lock: cloud-init being finished does not mean apt is.

* fix(oci): resolve mulga's path explicitly, and refuse an unpushed ref

Two assumptions in the new harness that a run would not have caught quickly.

Deriving mulga's root from spinifex's is right for a submodule checkout and
wrong for CI, where the two are siblings under the workspace, so MULGA_ROOT is
explicit and the workflow sets it.

build-install-artifacts.sh clones each repo from GitHub rather than using the
checkout beside it, and falls back to dev when the ref is not there. A nightly
on an unpushed branch would then pass or fail on dev's code while reporting the
branch. Check the ref is on origin first, and skip the Go suite build: the
workbook driver is a shell script, so those binaries prove nothing here.

* fix(oci): do not read $? after a command when set -e is on

The workbook phase read the driver's exit status on the line after the ssh that
produced it, which under set -e is never reached: a failing run aborted before
printing the driver's own per-workbook diagnostics, losing them exactly when
they are the thing you need. An `if !` keeps them, and names which workbooks
passed and which did not.

* feat: let an OCI e2e dispatch form without the external allocator

A tenancy without the nodes' dynamic group cannot authenticate the
allocator, so every topology failed at it rather than reporting what it
had proved. The no_external_pool input forms with no allocator and
records the allocator and the workbooks as SKIPPED, never PASS.

Also rebuilds the dry-run plan message imperatively: it still referenced
a variable renamed to WORKBOOKS, and under set -u the substitution died
and the text silently vanished from the line.

* fix: name the offending input when an OCI credential is unusable

A credential from the environment reached the SDK unvalidated, so a
secret with a trailing newline failed as an uncaught InvalidConfig
traceback naming an SDK config field that nobody set by that name.

Strip the scalar values, since a secret store written with echo carries
a newline, and translate both InvalidConfig and InvalidPrivateKey into
the variable or config key to go and fix.

* fix: make the OCI nightly able to build, and stop it leaking a dispatch input into the shell

Four defects, found reviewing the workflow rather than running it.

The builder clones all five sub-repos from GitHub over https and they are
all private, so a hosted runner had no credential and the tree build
could never have worked. The app token now covers six repositories and
git carries it for our organisation only.

A dispatch input was interpolated into a run block, where a quote ends
the string and the remainder executes. It goes through the environment
now, which is the only place a caller-supplied value belongs.

The artifact upload took the whole directory, which meant the distro
tarball and the test archive, and would have taken terraform.tfstate had
the state directories sat under it. It is a named list now, and it also
collects the per-topology logs that were never uploaded at all.

* fix: make the OCI nightly's runner a setting, and stop widening the app token

The five sub-repos are public, so build-install-artifacts.sh clones them
without a credential and the token has no reason to reach them. Only
mulga is private, which is what it was scoped to before.

The runner is now inputs.runner, then the OCI_E2E_RUNNER variable, then
hosted. A per-provider self-hosted agent is a reasonable thing to want --
it lifts the six-hour cap a hosted job carries and keeps a long cloud
wait off the hypervisor labels -- and it should not need a workflow edit.

Removing the SSH key is load bearing on that path: a self-hosted runner
keeps $HOME between jobs, so the key would sit there between nightlies.

* fix: install the OCI SDK the harness needs, and keep state out of the checkout

Nothing installed the oci Python module, on any runner: validate-topology.sh
called bare python3 and oci_env.py imports oci, so every topology would have
failed at the first terraform call. The harness now uses a system python3 that
has it and otherwise builds its own virtualenv from requirements.txt, which is
the same behaviour on a workstation, a hosted runner and a self-hosted one.

The state directories move under OCI_STATE_ROOT, defaulting to the script
directory. A persistent runner needs them outside the checkout, because
actions/checkout wipes untracked files there and would remove the state of a
killed run before the sweep could read it, leaving OCI instances running that
nothing is able to find.

* fix: drop the OCI SDK dependency, so the harness runs on any host

python3-venv is not installed on banksia, and the harness needed it: the
environment helper re-executed itself from a virtualenv to import the OCI
SDK. It used the SDK for two things -- parsing an INI file, which
configparser already does, and discovering the tenancy home region, which
no Terraform file consumed. Neither justifies a dependency on every host
that runs this, so the helper is standard library only now.

The credential is checked for shape instead, locally and before any OCI
call, which is a better failure than a 401 forty seconds into a run.

Two things the removal uncovered. The home region is a fixed property of
a tenancy rather than something to rediscover every run, so it is
OCI_HOME_REGION. And OCI serves IAM writes from the home region while the
dynamic group and policy were using the default provider for the build
region, which would have failed when the tenancy applied them: they go
through an aliased provider that defaults to the build region, so a
tenancy whose home region is where it builds sees no change.
2026-10-01 13:37:05 -05:00
Julian Sommer 92f1f97ad6 fix: revoke a deleted IAM user's or role's STS sessions at deletion (#1153) 2026-10-01 15:54:30 +10:00
Julian Sommer f29f19a32f fix(iam): revision-guard DeleteInstanceProfile and skip dangling roles in ListInstanceProfiles
DeleteInstanceProfile now deletes via kvutil.DeleteIf at the revision it
checked, so a role attached after the RoleName check sends the delete back
through the DeleteConflict guard instead of removing the profile.

ListInstanceProfiles logs and skips a profile whose attached role no longer
exists rather than failing the whole account listing.
2026-10-01 15:19:20 +10:00
Julian Sommer 847a93fe3d feat(rds): implement ModifyDBSubnetGroup (#1152) 2026-10-01 15:00:56 +10:00
Julian Sommer 687d390379 feat: implement IAM UpdateRoleDescription (#1151) 2026-10-01 14:39:47 +10:00
Julian Sommer 1ab714b125 feat: ListAccountAliases 2026-10-01 14:28:14 +10:00
Julian Sommer 6a873d3c3c feat(iam): enforce per-type policy size quotas and accept Version 2008-10-17 (#1150) 2026-10-01 14:19:16 +10:00
Julian Sommer 10247a958c fix(iam): report a pathed IAM user's stored ARN as its caller ARN (#1149) 2026-10-01 13:50:22 +10:00
Julian Sommer 6f2035821f feat(iam): accept the numeric condition operators on s3:max-keys and aws:EpochTime (#1148) 2026-10-01 13:34:48 +10:00
Julian Sommer 04c80f3897 feat(iam): supply aws:CurrentTime and aws:EpochTime and accept the date operators (#1147) 2026-10-01 12:11:35 +10:00
Julian Sommer 211e2b8cfc feat(iam): accept Null and IfExists condition operators at the write path (#1145) 2026-10-01 11:28:44 +10:00
Julian Sommer 74243730fa fix(iam): resolve policy and instance-profile ARNs against the stored ARN (#1144) 2026-10-01 11:13:30 +10:00
Julian Sommer 2230e92ec2 feat(iam): accept negated, case-insensitive and NotIpAddress condition operators (#1143) 2026-10-01 10:49:51 +10:00
Julian Sommer c7513c0394 fix: flaky test 2026-10-01 10:18:04 +10:00
Julian Sommer c782585352 feat(iam): accept NotAction and NotResource on identity policies (#1142) 2026-10-01 10:09:39 +10:00
Julian Sommer c98715f7a3 feat: supply iam:PassedToService on PassRole checks (#1141) 2026-10-01 09:24:46 +10:00
Ben Duncan 14ba518186 feat: source=exoscale external pool allocating Elastic IPs through the exo CLI (#1140)
* docs: add work-in-progress Exoscale integration guide with stack diagrams

* feat: source=exoscale external pool allocating Elastic IPs through the exo CLI

* docs: rewrite the Exoscale guide as a short end-user install, and test the exo fake, store and wiring
2026-09-30 15:08:26 -05:00
Julian Sommer 36d5788923 fix: require iam:PassRole for EKS cluster and node roles (#1139) 2026-09-30 16:22:16 +10:00
Julian Sommer dc636cac63 fix: cut fixed waits from viperblockd and cmd tests 2026-09-30 15:42:48 +10:00
Julian Sommer 8a1045b4e7 fix: ack vpc.delete-nat in tests so teardown doesn't wait out the 15 s request timeout 2026-09-30 15:37:40 +10:00
Julian Sommer 51d855aa80 refactor: remove spx service stop/status and narrow service.Service to Start (#1138) 2026-09-30 15:18:49 +10:00
Julian Sommer a06eccfdab test: fuzz awsec2query parser and add make fuzz
Fuzz QueryParamsToStruct over query bodies decoded into EC2, ELBv2 and RDS
inputs: no panic, list entries bounded by MaxSliceLen and the input size, and
an entry past the first gap never changes the parse. make fuzz runs every
Fuzz* target for FUZZTIME each.
2026-09-30 14:35:48 +10:00
Julian Sommer 520b2d1689 fix: refuse request bodies the authz gate and handler would read differently
bodyscope lower-cased names with strings.ToLower and decoded into a map, while
handlers decode with encoding/json. A non-ASCII name that encoding/json folds
onto an ASCII field (repoſitoryName), or a repeated name whose later value is
null or an object, let the gate authorize one resource while the handler acted
on another. Parse now decodes with encoding/json/v2, which refuses repeated
names, and refuses non-ASCII names; both yield ErrAmbiguousBody.

FuzzGateMatchesHandler checks, for every identifier the ECR, ECS, EKS, ACM and
Bedrock resolvers read, that the gate and a handler-shaped decode agree.
2026-09-30 14:17:46 +10:00
Julian Sommer 7bbf78df78 chore: cleanup preflight 2026-09-30 13:52:47 +10:00
Julian Sommer 2b4ddad78a chore: drop dead and redundant nolint directives
Removes directives for linters that are not enabled (noctx, exhaustive,
deadcode) and gosec suppressions already covered by config exclusions.
Tags ochrevector QueryResult and Filter with their existing field names
so musttag passes without changing the NATS wire format, and bounds the
RDS window slot conversion instead of suppressing G115.
2026-09-30 13:46:11 +10:00
Julian Sommer 39fdd132ee fix: verify cluster CA on peer and predastore readiness probes
Peer /health and predastore readiness probes skipped TLS verification.
Both now trust the cluster CA (nats.cacert) that issues every node's server
cert. A node without the CA stops peer probing, leaving peers reachable,
and reports predastore not ready.
2026-09-30 13:43:22 +10:00
Julian Sommer 524a7a519f chore: centralise arn splitting 2026-09-30 12:30:25 +10:00
Julian Sommer a3ea962d1e chore: shared e2e ssh harness 2026-09-30 12:21:57 +10:00
Julian Sommer 5f21fd27e3 chore: inline more 2026-09-30 12:01:21 +10:00
Julian Sommer 1344a8e3ca chore: inline function wrappers 2026-09-30 11:55:40 +10:00
Julian Sommer 810aa019bf chore: use strings.Cut 2026-09-30 11:48:27 +10:00
Julian Sommer 610ee9bab0 chore: use slices.Sorted 2026-09-30 11:44:17 +10:00
Julian Sommer 04c000e7b9 chore: use slices.ContainsFunc instead of manual loops 2026-09-30 11:37:43 +10:00
Julian Sommer f4fe9cecb7 fix: match AWS's CreateRoute check order and messages, icmp duplicate-rule text and untagged default subnets 2026-09-30 11:24:02 +10:00
Julian Sommer e4b7e520ca fix: return AWS's messages for network not-found, dependency, duplicate and range errors 2026-09-30 11:15:44 +10:00
Julian Sommer 45fdbc0444 fix: apply tag:<key> filters to DescribeTags rows 2026-09-30 11:05:44 +10:00
Julian Sommer 5b2ca9373b fix: add and drop network response fields to match AWS, as far as the SDK's shapes allow 2026-09-30 10:58:45 +10:00
Julian Sommer c4b284b8c9 fix: treat a repeated identical CreateRoute as success, as AWS does 2026-09-30 10:48:34 +10:00
Julian Sommer 363aa1c4ab fix: match AWS's error codes and messages for bad CIDRs, missing gateways and routes, the default group name and malformed IDs 2026-09-30 10:42:33 +10:00
Julian Sommer 485b828dde fix: reject sg- group names, aws: tag keys and multi-attribute Modify*Attribute calls as AWS does 2026-09-30 10:28:04 +10:00
Julian Sommer 62b83e10fe fix: omit ports on all-protocol permissions in DescribeSecurityGroups and match such rules regardless of ports 2026-09-30 10:20:18 +10:00
Julian Sommer f67a326b97 fix: clean the subnet not-found message and name the subnet ID in DeleteSubnet and DescribeSubnets 2026-09-30 10:14:58 +10:00
Julian Sommer 5f60b2c7c7 fix: return InvalidGatewayID.NotFound with AWS's message when deleting a missing egress-only gateway 2026-09-30 10:06:12 +10:00
Julian Sommer 666ea4bb4b fix: return OwnerId from CreateInternetGateway and DescribeInternetGateways 2026-09-30 10:04:02 +10:00
Julian Sommer b3d1198d52 test: move paging tests to the external package and mark the VPC paging test in-package 2026-09-30 10:04:02 +10:00
Julian Sommer 9a57fa77f9 feat: page and validate MaxResults and NextToken on network Describe calls 2026-09-30 09:55:12 +10:00
Julian Sommer 8ddec0921f fix: reject inverted TCP/UDP port ranges when creating security group rules 2026-09-30 09:38:13 +10:00
Julian Sommer ba0e96082a fix: return SubnetArn and AWS's default subnet fields from CreateSubnet and DescribeSubnets 2026-09-30 09:29:57 +10:00
Julian Sommer a15084afd1 fix: accept tag-key and tag-value filters on network Describe calls and DescribeTags 2026-09-30 09:26:09 +10:00
Julian Sommer e3fe9a634e fix: return AWS's error messages for placement group, key pair, launch template and address errors 2026-09-29 16:12:56 +10:00
Julian Sommer 69b717b790 fix: report the account's default VPC from DescribeAccountAttributes 2026-09-29 16:06:50 +10:00
Julian Sommer 6a91a8419b fix: match AWS key pair and image response fields 2026-09-29 16:03:35 +10:00
Julian Sommer 72ee50f6f3 fix: fail DeleteLaunchTemplateVersions on the default version and report missing versions as AWS does 2026-09-29 15:47:32 +10:00
Julian Sommer 957a5eac2a fix: apply and validate DescribeRegions and DescribeAvailabilityZones filters and names 2026-09-29 15:41:09 +10:00
Julian Sommer c85376c5a2 fix: return not-found for named public IPs and placement group IDs that do not exist 2026-09-29 15:34:24 +10:00
Julian Sommer 143229ef6b fix: validate DescribeInstances and DescribeInstanceTypes MaxResults as AWS does 2026-09-29 15:29:49 +10:00
Julian Sommer 95e51008af fix: page and filter DescribeInstanceTypes and reject unknown named types as AWS does 2026-09-29 15:24:30 +10:00
Julian Sommer 2b56d4e675 fix: reject KeyFormat=ppk on CreateKeyPair rather than silently returning a PEM 2026-09-29 15:19:41 +10:00
Julian Sommer e0f9a11987 test: move the key-pair DescribeTags test to an external test package 2026-09-29 15:19:09 +10:00
Julian Sommer 9045f133c5 fix: project key-pair creation tags into the central tag store so DescribeTags lists them 2026-09-29 15:16:05 +10:00
Julian Sommer 0465bd0a41 fix: return PublicKey from DescribeKeyPairs when IncludePublicKey is set, named as AWS names it 2026-09-29 15:10:48 +10:00
Julian Sommer df48c21ce1 fix: return GroupArn on placement groups, built from the group name as AWS does 2026-09-29 15:08:03 +10:00
Julian Sommer 43e8d65247 fix: STS DurationSeconds and MFA member validation as AWS answers them (#1137) 2026-09-29 15:03:41 +10:00
Julian Sommer e9b9600bbc fix: RDS Describe Filters and DbiResourceId refusals as AWS answers them (#1136) 2026-09-29 14:46:34 +10:00
Julian Sommer 9fe8c8b232 fix: IAM request validation as AWS answers it (#1135) 2026-09-29 14:06:03 +10:00
Julian Sommer 410faf3c83 fix: EC2 UnauthorizedOperation, MaxResults ranges and tag resource types as AWS answers them (#1134) 2026-09-29 12:06:48 +10:00
Julian Sommer ba2621ded5 docs: record the 2026-09-29 conformance baseline
Update request and EC2 error-code counts after the suite artefacts were removed, describe how the sweep now reaches its targets, and point at the plan that traces each finding.
2026-09-29 11:16:26 +10:00
Julian Sommer bfad1dc004 test: target fixture resources in generated conformance requests
Salts restart per operation, so tag, untag and update cases only reached a resource when the same-numbered create case had made one. Create an IAM, ECS and ECR fixture per run and point every operation but a create or delete at it, and give ECR's policy and manifest documents values AWS accepts.
2026-09-29 11:14:37 +10:00
Julian Sommer b3be052311 test: remove integration conformance suite artefacts
Clear the warnings the conformance suite caused itself, so what remains is a real divergence from AWS:

- Seed conditionally required members into the base request of RDS CreateDBInstance and RestoreDBInstanceFromDBSnapshot, ECS CreateCapacityProvider and CreateService, and EC2 CreateCapacityReservation.
- Give loosely modelled members values AWS accepts: ELBv2 ProtocolVersion and Matcher.HttpCode, EKS policyArn, addonName and access-entry type, EC2 SourceRegion and STS AccessKeyId.
- Import a CA-signed certificate, key and chain in ACM ImportCertificate.
- Rewrite ACM's lookahead domain pattern as RE2 so RequestCertificate is generated, and give the integration ACM service a tenant CA permitting example.com.
- Run the service daemon-lite behind the cross-tenant boundary test.
- Accept ELBv2's common parameter validation errors in responses.
2026-09-29 11:05:22 +10:00
Julian Sommer dd9c9666f2 release: update go.mod for v1.21.0 2026-09-29 10:10:36 +10:00
Julian Sommer ae2fbef021 fix: fail the capabilities benchmark on a nil response instead of panicking
benchCapabilities read resp.Capabilities after checking only err, so a
provider returning (nil, nil) panicked the benchmark rather than failing it.
2026-09-29 09:50:54 +10:00
Ben Duncan fe4ce999ed fix: wait for the capabilities handlers instead of reading the count once (#1127)
TestServeQueueGroupBalancesSharedSubjects read both workers' handler counts
immediately after waiting for the mount to land on both of them, on the
argument that ordering on the shared connection meant the earlier capabilities
message must already have been delivered to everyone getting it.

That ordering does not exist. Each async subscription dispatches on its own
goroutine, so a worker's mount handler and its capabilities handler race, and
the mount having landed says nothing about whether the capabilities message has
been counted yet. Race Detection caught it: publishes reached 2 while
capabilities was still 1.

Wait for the expected count and then confirm it does not move. Both halves
matter — how many handlers a message reaches is the server's decision, so the
count converges from below and cannot overshoot, but a broken queue group
delivering to both members would pass through the right value on its way to the
wrong one.
2026-09-28 18:48:27 -05:00
Julian Sommer 39759e1ed2 fix: report SupportedNetworkTypes and SubnetOutpost on RDS subnet groups (#1132) 2026-09-29 09:35:11 +10:00
Ben Duncan 8dad0ab9ab fix: wait for JetStream quorum before deciding viperblock has no storage (#1129)
* fix: wait for JetStream quorum before deciding viperblock has no storage

On a cold multi-node start every service comes up at once against a cluster
that has not elected a meta leader yet. IAM retries that window; viperblock did
not. Its lease and dirty bucket opens are both on the path to the daemon
running at all, so the first refusal took the process down with "volume lease
bucket: context deadline exceeded" and systemd crash-looped it into a failed
bootstrap.

The start path now opens both through kvstore.OpenWithRetry, which is what vpcd
and IMDS already use for the same window. The plain constructors are untouched:
they are what the replica-count and cluster-size tests assert fail closed, and
an operator tool reading a refusal as a hang would be worse than the crash.

OpenWithRetry now stops on an undeclared cluster size, matching the IAM and
kvlease loops. Waiting cannot declare one, so retrying it would turn a clear
configuration fault into ninety seconds of silence followed by the same error.

* test: mark the viperblock bucket startup test as in-package

test-package-check requires new test files to be external unless the file says
why it cannot be. The start-path openers and the constructors beneath them are
both unexported, and which of the two waits is the whole subject.
2026-09-28 18:34:09 -05:00
Ben Duncan b73ab7cfb3 fix: assert the takeover against the volume that was actually left unsealed (#1130)
* fix: assert the takeover against the volume that was actually left unsealed

TestCrossNodeStartAfterFailedSealTakesOverLoudly picked its subject in advance
— the attached pattern volume — and treated the stop returning an error as
proof that volume's seal had failed. It is not. The unmount is a NATS request,
and the caller giving up says nothing about the seal behind it; local state
kept with no seal receipt is equally what an in-flight seal looks like.

In the nightly the test then thawed predastore, the pattern volume's seal
finished against a backend that was answering again, and the marker cleared. By
the time the volume moved there were no unsealed writes, so viperblock was
right to say nothing and the test failed it as a silent loss.

The product was loud where it mattered. The instance's root volume has to read
its AMI snapshot to seal, which the freeze does break, so its seal genuinely
failed and the node it landed on logged the takeover naming the node that had
been holding the writes. A blank attached volume has no such read and can seal
straight through the outage.

So the test now reads which volumes the cluster records as unsealed, after the
thaw has settled rather than before, and asserts the takeover against those. If
nothing was left behind it skips: the fault injection did not reproduce, and
asserting a warning there would be asserting a wrong one.

* fix: publish the instance cache resync mark with its readiness, not after it

installSync set ready under c.mu and markResynced ran after the unlock, so
there was a window where the cache was ready and serving records while
resyncAge reported that no sync had ever succeeded. A caller that observes a
ready cache and then asks how stale it is got "never", which reads as a cache
that cannot be trusted, and the resync-age metric was skipped for as long as
the window lasted.

The mark now goes up under the same lock that publishes readiness, so any
reader that takes the lock and sees a ready cache sees the mark too. Both
callers dropped their trailing markResynced, which installSync now does.

Widened under the race detector, which is where TestPeriodicResync_Undecodable
RecordMarksDegraded had been failing intermittently: it reads resyncAge
straight after waiting for readiness.
2026-09-28 18:33:54 -05:00
Julian Sommer 8746b9e79d fix: page every RDS Describe call by MaxRecords and Marker (#1131) 2026-09-29 09:16:39 +10:00
Ben Duncan 8ab96b29a1 fix: replicate every JetStream KV bucket across the cluster (#1117)
* fix: replicate every JetStream KV bucket across the cluster

A KV bucket created without a replica count gets one replica, and one
replica lives on one JetStream-chosen server recorded in no config.
Losing, restarting or partitioning that server makes the bucket return
"no responders available" from every node at once, so a single-node event
becomes a cluster-wide outage. The reconciler leader-lease buckets were
the worst case: acquiring a lease 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.

The cluster's node count is now declared once, in config.LoadConfig, and
read by the one helper every bucket creation goes through. Every process
that creates a bucket loads the cluster config first, so no service can
forget to pass it and no signature has to carry it. An undeclared size is
an error at creation rather than a silent fall back to one replica.

Existing buckets are raised rather than left behind: a service opening one
raises it, the daemon sweeps them all after formation, and
"spx admin kv replicas --repair" does it on demand. Raising reads the live
stream config and changes only the replica count, and never lowers one.

Removes the ~30 replicas/clusterSize parameters that only forwarded a
number, and JetStreamManager.UpdateReplicas, which set len(nodes) with no
clamp and would have failed outright on a cluster of six or more.

Proved by a repo-wide invariant test pinning bucket creation to the one
helper, a whole-cluster sweep in every multi-node nightly cell, and a new
kvquorum suite (cell 33) that takes away the node leading the most buckets
and requires the survivors to keep answering for all of them.

* chore: name cell 33 in the nightly dispatch input

* fix: a KV bucket that cannot be raised must still open

The first version of the replica fix treated a raise it could not place as a
hard error, and on a real three-node cluster that turned the fix into the
outage it prevents. A node whose config named a fourth server wanted four
replicas, NATS answered "no suitable peers for placement" for three, and the
daemon retried for its full five-minute budget without finishing bootstrap.
The bucket was present, quorate and serving on three nodes throughout. A
config naming a node before that node serves is the ordinary middle of
growing a cluster, so that is a state to tolerate rather than refuse.

The line is now drawn between the two cases instead of around both. Creating
a bucket below the cluster's replica count still fails, because that is the
defect and a fallback there reintroduces it where it is hardest to notice.
Failing to raise an existing bucket warns and hands the bucket back, and the
raise is retried on every open besides the daemon sweep and --repair. This
matters most in kvlease, which runs on every reconcile tick: refusing there
would stop every reconciler sharing a lease bucket that was working.

getOrCreateBucket now attaches before it creates. A create asking for more
replicas than the cluster can place is refused before the server looks at the
name, so with create-first "this bucket already exists" was an inference from
which error came back, and the wrong one comes back on a single-node server.

RaiseAllBucketReplicas attempts every bucket and joins the failures, and
--repair keeps going too. Stopping at the first would leave the rest of a
cluster short because one bucket could not be placed.

Also adds nats.ServerName/NodeFromServerName beside the template that renders
server_name, with a test tying them together. The kvquorum suite had mapped
stream peers to nodes by comparing them to node names, which they are not:
they are the node name behind a "spinifex-nats-" prefix, so nothing matched
and the node-loss test could not choose a victim.

* test: wait for the right NATS peer count after a node rejoins

/routez reports a node's routes to its peers, not its own membership, so a
fully formed three-node cluster is two peers per node. The kvquorum suite
waited for three and spent its whole five-minute rejoin budget on a cluster
that had already reformed. Every other caller passes len(Nodes)-1; the
helper's doc comment now says so.

The assertion the test exists for had already passed: node1 was leading 18 of
47 buckets when it was taken away, and node2 and node3 each still answered for
all 47.

* fix: replicate KV buckets across the largest odd count a cluster can place

A bucket was replicated across min(nodes, 5) servers, which stores a fourth
full copy on a four-node cluster and still survives exactly one loss — the
same tolerance R3 gives for three quarters of the write amplification. A Raft
majority needs an odd count, so the rule becomes the largest odd count the
cluster can place, capped at five: 1, 3, 3, 5, 5. It is one expression with two
entry points, so no caller can hold a stale copy of the policy and keep passing
after the policy changes.

Three retry loops could not tell a configuration fault from a cluster that had
yet to form. An undeclared cluster size never becomes declared by waiting, so
each of them spent its whole budget on one and then reported quorum as the
cause. They now stop on a permanent error and say what is actually wrong. This
is what the integration suite was hitting: it builds its gateway by hand and
never loads a cluster config, so the size was never declared and the IAM retry
sat in its sleep until the test timed out. Both ends are fixed.

The audit reported configuration alone. A bucket at three replicas with one
peer offline and one lagging is one failure from unavailable and read as ok.
BucketReport now carries the leader and a count of the peers JetStream itself
calls current and reachable, quorum is measured against that count, and
spx admin kv replicas exits 0, 1 or 2 so a deployment gate can tell a problem
--repair fixes from one needing a human.

Declaration is write-once: daemon.Start refuses a conflicting redeclaration
because it is about to create buckets, and it declares before JetStream init
rather than after it. config.LoadConfig warns instead, because it also reads
config files that are not this process's own cluster.

spx admin init --nodes 2 is refused — no replica count on two nodes both
survives a loss and accepts writes — and single-node replication is asserted in
the single e2e suite, so it runs in every nightly host permutation.

* fix: withhold the vLLM serving image until its artifact is published

The catalog offers ubuntu-26.04-vllm-serving-x86_64, and the qcow2 it names
404s on iso.mulgadc.com, so spx admin images import would fail for every
operator who tried it. Its GPU siblings are published; this one never was.

The entry moves to WithheldImages rather than being deleted, because the entry
is the definition — the URL, the checksum and the two tags resolveServingAMI
filters on. Publishing the artifact is then a move back, not a retype.
Bedrock already handles the image not being registered: resolveServingAMI
returns ErrServingAMINotFound, which is the honest answer while there is
nothing to launch.

The bedrock tag contract is still pinned, now against the map the definition
lives in, and it additionally asserts the entry is not offered for import. The
e2e reachability walk covers AvailableImages alone, so a withheld entry is out
of it by construction.

* fix: drop the cluster size from the call sites dev added

Two constructors on dev still pass a replica count that no longer exists as a
parameter: the cluster size is a declared process fact, so EKSServiceDeps has
no ClusterSize field and NewIAMServiceImpl takes three arguments. The merge is
textually clean and breaks the build, which is the shape of collision a
signature removal always has.

* docs: explain what an operator sees when a cluster server fails

The replication section said how many copies a bucket has and stopped there,
which leaves the question an operator actually has during an incident: one
server is gone, what still works. It now covers the majority side carrying on,
an isolated server refusing changes on purpose and why that is the behaviour
you want, guests continuing to run while anything that has to be recorded does
not, the risk of reading state from a server that is cut off, and a returning
server catching up by itself.

The surrounding prose reads from the operator's side rather than the
implementation's: what you see and what to do, not which consensus protocol is
underneath.
2026-09-28 12:34:55 -05:00
Julian Sommer ecef5e4d2f feat: CONTRIBUTING.md 2026-09-28 16:27:01 +10:00
Julian Sommer 772c7657d8 fix: return AWS's per-action field sets for IAM roles, policies, users and account summary
CreateRole, GetRole, ListRoles and instance-profile roles each get their own projection; GetRole carries an empty RoleLastUsed. Policies carry UpdateDate and PermissionsBoundaryUsageCount, CreatePolicy drops Description and ListPolicies drops Tags. CreateUser echoes Tags, list instance-profile actions drop profile Tags, and GetAccountSummary gains AWS's seven missing keys and loses SigningCertificatesPerUser.
2026-09-28 16:12:16 +10:00
Julian Sommer 2898181837 fix: test packages 2026-09-28 16:01:49 +10:00
Julian Sommer b784483a03 fix: return ValidationError for IAM name, path and enum violations
AWS answers a bad name charset, a path not bounded by slashes and an
enum violation such as UpdateAccessKey's status with ValidationError.
Length and tag violations keep InvalidInput until AWS's code for them
is captured.
2026-09-28 15:59:51 +10:00
Julian Sommer 866392e680 fix: honour MaxItems and Marker on IAM list actions (#1125) 2026-09-28 15:53:41 +10:00
Julian Sommer 0fd0e2ba15 fix: apply PathPrefix, Scope and OnlyAttached in IAM ListPolicies 2026-09-28 15:36:22 +10:00
Julian Sommer 377c573bf6 feat: add IAM managed policy versions (#1124) 2026-09-28 15:28:20 +10:00
Julian Sommer 1426cdd072 fix: URL-encode IAM policy documents in Query API responses (#1123)
AWS percent-encodes every policy document it returns. Spinifex returned
raw JSON, so a client decoding once read '+' as a space and '%41' as 'A',
giving perpetual Terraform drift, and a lone '%' failed the read.

Encode in the gateway wrappers for CreateRole, GetRole, ListRoles,
GetRolePolicy, GetUserPolicy, GetGroupPolicy, GetPolicyVersion and the
instance-profile reads. The service layer keeps raw JSON because STS and
spx admin parse it in-process. The web UI now decodes role inline
policies like every other document.
2026-09-28 14:31:16 +10:00
Julian Sommer 2d2d2f27ed feat: model-driven request conformance in the integration suite (#1122) 2026-09-28 14:14:15 +10:00
Julian Sommer 0c3403889c feat: load AWS conformance models from api-models-aws (#1121) 2026-09-28 12:21:05 +10:00
Julian Sommer d8cf17bf6e fix: return validation messages that name the failing parameter (#1120) 2026-09-28 12:05:22 +10:00
Julian Sommer 7b3ebb2ab4 fix(gateway): answer DryRunOperation for every EC2 action that models DryRun (#1119) 2026-09-28 11:30:51 +10:00
Julian Sommer 83f351bd6d fix: return AWS's messages for AssumeRole and GetAccessKeyInfo errors (#1118) 2026-09-28 11:18:17 +10:00
Julian Sommer c473365f1a refactor: move new test files to external test packages
Tests added since main used in-package test packages, failing the
test-package check. Convert them to <pkg>_test with export_test.go
hooks; the ecs-agent main package keeps an in-package marker.
2026-09-28 10:54:58 +10:00
Julian Sommer 94488dc399 fix(gateway): don't count body-read failures toward the auth lockout
A request whose body cannot be read to hash failed through the catch-all
branch, logging a malformed envelope, returning IncompleteSignature and
recording an anonymous lockout strike, so ten dropped connections locked
out an IP. Match sigv4.ErrReadingBody and answer InternalError without
recording a failure. Bumps bluebottle for the new sentinel.
2026-09-28 10:49:28 +10:00
Julian Sommer 9bd1860622 chore: update pnpm 2026-09-28 09:45:16 +10:00
Julian Sommer 4abd2fd984 fix: delete crap tests 2026-09-28 09:41:00 +10:00
dependabot[bot] b3d1e859c4 chore: bump the npm-dependencies group (#1113) 2026-09-28 09:03:05 +10:00
Ben Duncan 021f439bab feat: recover instances automatically from a node that stopped heartbeating (#1111)
* feat: recover instances automatically from a node that stopped heartbeating

A node that dies hard takes its guests with it, and nothing brings them back
until an operator brings that node back. Every daemon now runs the same small
reconciler: it reads the instance records, selects desired-running instances
whose owner is stale, and relaunches them here.

The claim is a CAS on Status.LastNode and that is the whole of the
coordination. Several survivors race one instance, exactly one revision wins,
and the losers find a record naming a live owner and stop considering it. A
claimant that dies mid-launch leaves the record naming a node that is itself
stale, which is the same candidate the next pass derives again, so there is no
lease to expire and nothing to reconcile by hand.

Ownership is not permission to write. The volume leases still decide whether the
guest can move, and VolumeMounter.Mount runs before startQEMU, so an attempt
that loses the volume race has touched no tap, no OVS port and no OVN binding.

A storage failure is not a host failure and wants the opposite response. A guest
paused by werror=stop publishes Health.IOErrorSince, and a record carrying it is
refused: the volumes are the same objects in the same store from anywhere, so
relaunching moves the pause rather than ending it. A node whose own object store
is not answering stands down entirely, which separates a store broken here from
a store broken everywhere without either having to be diagnosed. A launch that
does not complete backs this node off that instance, doubling to a cap, because
nothing on the mount path can yet say which of the two it hit.

Recovery is off by default and inert below three nodes. It also waits until it
has seen its peers live, or five minutes, so the first node back from a
coordinated shutdown does not recover guests from nodes that are still booting.

* test: prove instance recovery moves a host failure and refuses a storage failure

The e2e half of automatic instance recovery, asserted from both sides because a reconciler that cannot tell the two failures apart passes one and fails the other. InstanceAutoRecovery takes a node down and requires the guest back on exactly one survivor, identified by the qemu process rather than by what the API reports. InstanceRecoveryRefusesStorageFault stops predastore cluster-wide first, so the guest is paused by werror=stop when its node goes away, and requires that nothing moves.

Also fixes the settle window, which re-evaluated every pass and so held a daemon back for its first five minutes over exactly the failure the reconciler exists to handle. It now latches once every peer has been seen live.

harness.InstanceHostingNodes replaces the single-node lookup so a caller can assert there is exactly one host rather than assume it, and skip a node it has deliberately taken down.

* fix: make automatic instance recovery a cluster-wide setting

Recovery was read from the per-node Config, so it had to be written three times on a three-node cluster, once inside each [nodes.<name>] table. A top-level [recovery] table — the obvious thing to write, and what the e2e suite writes — was parsed into nothing and the daemon reported recovery disabled on every node with the setting plainly in the file.

Cluster-wide is also the honest shape. A node with recovery off is not opting out of anything: recovery is what survivors do to a dead node's instances, so the setting belongs where there is one of it.

* test: drive real I/O into the storage-fault case, and freeze the store rather than stop it

Measured on a three-node cluster: predastore stopped cluster-wide for twelve minutes against idle guests paused none of them. Viperblock serves what it has cached and an idle guest issues nothing that reaches the object store, so the test asserted that nothing moved in a run where nothing was ever faulted.

The victim now launches with cloud-init user-data that writes to its root volume continuously. User-data rather than SSH because a routed-NAT cell gives guests no public address, so a test that had to log in could not run on half of them.

The fault is SIGSTOP rather than a unit stop, for the reason the storagefault suite already gives: a frozen process holds its connections open and answers nothing. Under that fault and that load the first BLOCK_IO_ERROR arrived 4m47s later, so the pause budget is now ten minutes rather than three.

* test: give instance recovery its own suite and its own nightly cell

Both tests take a node away and one of them freezes predastore cluster-wide, so anything sharing the environment reports that outage as its own failure — the same argument storagefault already makes for being its own package. They are also slow by nature: a dead owner has to be seen stale twice, a guest has to boot, and the storage case waits for a pause measured at 4m47s and then holds four minutes to prove nothing moved. Roughly twenty minutes on top of a permutation cell budgeted at thirty-five.

Cell 31 is three-node and static, matching storagefault's, with a 75-minute job budget over a 60-minute suite timeout. The suite is eligible on multi-node environments and deliberately not in the nightly permutation set.

* fix: give a guest long enough to open a volume the object store is slow to serve

QEMU opens every drive before its monitor answers a greeting, so the QMP
greeting deadline is really a deadline on opening the guest's volumes. At 30s
it was shorter than a cold viperblock open costs on a cluster that has lost a
node, where every object read is served degraded: measured at 35s with one node
of three away. The launch killed the guest five seconds short of ready and then
did it again on every retry, which is a recovery that can never complete.

A network-backed drive now gets its own deadline for the same reason VFIO
already had one — synchronous work before the monitor answers — and the
instancerecovery suite is what found it.

Also make the suite's predastore thaw best effort per node. It runs after a
node has been taken away, where there is no process to signal, and a node it
cannot reach must not stop it restoring the rest.

* docs: name the two newest cells in the dispatch input

The cells input is the operator's index of what a number means, and it stopped
at 29 while 30 and 31 were added.

* test: prove the guest is writing before freezing the store in the recovery suite

The storage-fault test froze the object store as soon as RunInstances reported
running, which is when QEMU has launched rather than when the guest has booted.
On the nightly cluster the freeze landed while the guest was still in UEFI, so
the only drive it could reach was the EFI varstore — and pflash has no
werror=stop to pause on, so the guest never paused and the test reported a
product failure it had caused itself.

The load driver now prints to the serial console after each pass it has written
and synced, and the test waits for that before freezing. The console is read
through GetConsoleOutput, so this still needs no login and still runs on cells
whose guests have no public address.

* feat: recover instances automatically with nothing to configure

The switch is gone. An instance whose host has failed is down whether or
not recovery is enabled, so the only outcome a disabled reconciler can
produce is leaving it down — which is the state recovery exists to end,
not a safer one to fall back to.

Against that, the switch cost a cluster that can be configured into
never recovering anything, with no symptom until a node dies. This
feature already hit that once: a top-level table parsed into nothing and
every node reported recovery off with the setting plainly in the file.

What makes recovery inert is now only facts about the cluster — no
cluster config, no JetStream, or fewer than three nodes — and each says
in the log which one applied. The e2e no longer writes config or
restarts daemons: it waits for every node to report the reconciler
active on a cluster nothing configured, so reintroducing a switch fails
there as well as in the unit tests.

* feat: stop retrying a recovery that is never going to work

Every failed attempt used to go to the same exponential backoff and stay
there. An instance nothing could launch was retried every fifteen
minutes forever, still observed-running on a node that is gone, with no
account of it anywhere the customer or an operator would look.

A failure is now classified and counted. Two classes are read from what
the launch actually reports: the node it was taken from still holds a
volume lease, which clears on that lease's clock and is retried on a
faster one, and this node has no room, which another survivor may. There
is no terminal class, because nothing on this path can prove one — the
launch collapses everything else into ServerInternal, so a verdict of
"never going to work" would be a guess, and the guess that abandons a
recoverable instance is the expensive one.

The budget bounds the unknown case instead. Every class counts against
it, since a class that did not would be the old unbounded retry under a
narrower condition. Exhausting it leaves the instance where AWS leaves
one whose host failed and could not be brought back: stopped, carrying a
StateReason that names the cause and the last error, one StartInstances
from running again. Writing the desired state down is also what makes
one node's decision cluster-wide, because selection reads desired state
— the same property the claim already has.

A record another node has since claimed is never touched, so a node
exhausting its attempts cannot stop a guest that is now running
elsewhere.

* fix: give a guest the time for every cost it pays before its monitor answers

A GPU instance booting from a network-backed root volume pays both
costs: QEMU opens every drive across the object store and then pins the
whole of guest RAM, one after the other, all before the monitor answers.
The deadline took whichever was larger, so such a guest was given less
time than it needs.

That the two floors are both 180s is why nothing has been seen yet — the
VFIO branch has always returned at least the network-drive deadline. It
is the same defect either way, and the symptom when it lands is a GPU
instance SIGKILLed on every attempt while its plain sibling launches,
which reads as a GPU fault rather than as a deadline.

The costs are now added, the plain QEMU startup is counted once rather
than replaced, and the cap covers both floors together so it cannot clip
a guest before either cost has been waited out.

* test: assert the AWS-visible arc of a host failure end to end

A customer cannot see which host an instance is on, and AWS never tells
them. What they can see is DescribeInstanceStatus, so that is where a
recovery has to be provable, and the suite never looked at it.

Three frames are now required, in order: running with system-status ok
before anything is broken, system-status impaired while the owning node
is gone, and ok again once a survivor is running it. The middle frame is
the one worth having — the window opens when the owner's heartbeat goes
stale, which is strictly before any survivor may claim, and closes when
the relaunch registers, so it is bounded rather than lucky. Reporting ok
throughout would be the more comfortable lie.

The availability zone is compared across the first and last frames as
well. A recovery that crossed one would break every placement promise
made to the customer, and nothing else in the suite would have noticed.

* test: prove a half-healthy node fences itself and is never a second writer

The two existing recovery cases take a node away by stopping its services,
which is the failure this feature handles least interestingly: a node that is
gone has no opinion about who owns its volumes. The one that matters is the node
that is still up, still running QEMU, still answering its own local clients, and
simply cannot reach consensus. That is the state in which two nodes can each
believe they own a volume, and the only thing between that and a corrupted disk
is that the partitioned node gives up first, on a clock it can evaluate without
reaching anything.

So this drops NATS on 4222 and 4248 between one node and its peers and nothing
else, then asserts the ordering the safety argument rests on rather than the
outcome it produces. A watch samples every node every three seconds for the
whole run, including the heal, and fails on any single sample naming two hosts.
Two guests writing one volume through one object store is data loss already
under way, not a race that resolves, and no point-in-time assertion can see one
that appeared between its own samples.

The injector takes its own nft table rather than touching inet spinifex_filter,
because a test that flushed the node's real firewall would leave it either
unfirewalled or misfirewalled with no sign of which, and a separate table at
priority -300 heals in one atomic delete. A transient systemd timer removes it
independently, so a cancelled workflow does not leave a node cut off from its
cluster until somebody notices. It proves SSH still works and proves a peer port
is genuinely unreachable before anything is asserted, since a rule set that
installed cleanly and dropped nothing would read as the system tolerating a
partition.

The suite budget goes to 90m and cell 31 to 105m to fit the third case.

* fix: move an instance's boot state, volumes, logical port and address with it

A record-driven launch assumes the node reading the record is the node that wrote
it. On a daemon restart that holds. On a recovery onto a survivor it does not, and
five things follow from it — one that stops a moved instance booting at all, and
four that stop it running anywhere.

A load balancer never comes back from a host failure. Its boot configuration is a
set of files under the runtime directory and the record holds their paths, so the
survivor restores paths that exist only on the node that died. The hook that
rebuilds them was reachable from Restore alone, and the cross-node path goes
straight to vmMgr.Run — so QEMU is handed a netcfg, an agent env and a CA cert
that are not there. The gateway URL baked into that env names the creating node's
management bridge as well, which the rebuild corrects because it rebuilds from
whichever node is running it. Firing the hook on both paths fixes both, and a
rebuild that fails now refuses the launch: a guest that boots without its network
configuration is worse than one that does not boot, because the records would say
it recovered.

The export outlives the guest and holds the volume lease. A returning node is
healthy, so it renews that lease indefinitely and the new owner never acquires:
the guest is stopped here and refused there, recovery spends its attempt budget on
a volume that was never going to be free, and gives up on an instance that was
never unrecoverable. So the export is given up too, over a new node-addressed
request that tears it down exactly as the fence does. Deliberately not an unmount
— an unmount seals, this node's block map is the stale one, and sealing publishes
it over the winner's. Unlike the fence it deletes the lease key rather than
leaving it to expire, because the lease is still this node's to hand back.

The tap outlives the guest, and the tap is what claims the instance's OVN logical
port. The binding names one chassis, so two nodes offering the same iface-id do
not split the traffic — they contend, and the address answers from whichever won
last. It also blocks the layer above: the external IP's host ingress is pruned on
the evidence that the owning guest sits on another chassis, and that stays false
while this node is still claiming the port.

The management address outlives the guest as a cluster-wide reservation, and the
release was unconditional. br-mgmt is one flat L2 segment, so the node an instance
left was freeing an address still in use and offering it to the next instance
anywhere. Only the node an entry names may release it now, with the takeover
recorded on arrival so the node actually running the instance can still give the
address back. An entry written before the node field existed names nobody and
stays releasable, because there is no other holder to collide with.

The fence path already cleaned up the tap, so the release fix repairs that path
too.

* test: prove a load balancer keeps its name, its address and its traffic when its host dies

Takes away the node running an internet-facing ALB and asserts the four things a
customer of one actually holds: the name keeps resolving, the address behind it
does not move, requests through the name are answered again without anybody being
called, and the backends are healthy again from the recovered load balancer's
point of view.

The backends are placed around the load balancer rather than beside it. A load
balancer lands wherever a node answers its queue group, so its node is discovered
and the backends are relaunched until both are elsewhere; one on the node about to
be taken away would be recovered too, and the run could not then tell an ALB that
came back serving from one that came back with nothing to serve.

DNS is asked of a node's own northstar over the wire, and the HTTP probes resolve
through the same server, so the claim under test is what this cluster answers for
a name rather than what the machine running the tests was configured to forward
to. A watch samples every surviving node every five seconds for the whole run,
because an answer that went missing or pointed elsewhere for one reconcile pass is
the outage, and no single sample reaches it.

The returning node then has to give up what it no longer owns: no second copy of
the guest, and no tap still claiming the load balancer's logical port, since OVN
binds a port to one chassis and a second claimant contends for it rather than
splitting the traffic. The last assertion is that the name still serves with that
node back in the cluster.

* docs: name the new cell in the nightly's cell list

* test: stop the disk-load guest from filling the console it is judged by

The load generator announced every write, once a second, for as long as the
test ran. The console is a ring buffer this suite reads back, so an hour of
that pushes out everything explaining a failure, including the kernel's own
account of the I/O error the test exists to produce. Announce twenty times,
then keep writing quietly.

* docs: say what the OCI guide has been proven on, and what a host failure does

The guide read as a support statement for OCI rather than as an account of one
tested path, promised seven steps and then gave eight, and said nothing about
what happens to a guest or its public address when the node under it fails.
State the evidence, state that the nightly guards the datapath and not the
cloud, and give the failure behaviour as a table with the reason codes a
customer will actually see.

Also say plainly what the volume lease is: one running guest and a bounded,
logged window, not a write barrier the object store enforces. Someone sizing a
workload against this should read that before they read the table.

* docs: say why the mount classifier refuses to retry a lease another node holds

It reads as an omission — a lease held elsewhere is the most transient refusal
there is. It is not. This classifier drives the restore path, where the holder
is a node that is running the guest, so retrying would relaunch against a live
owner. Recovery wants the opposite and reads the refusal itself.

* feat: claim a recovered guest's public address in line, and verify it arrived

A recovered instance was reported running before anything moved its public
address off the node that died, so for up to fifteen seconds the records said
recovered and the address answered nothing. The recovery path now runs the
affinity pass itself, which is idempotent and is the same pass the ticker runs.

Two things the pass could not do before. It abandoned every remaining address
on the first API failure, though a throttle or a denial is per-object and the
guests behind the others are just as dark. And it trusted the move's own
response, which is OCI agreeing to the request rather than OCI having done it,
so an accepted move that never landed read as success. It now re-reads what OCI
delivers here and reports the difference.

An address that will not arrive makes the guest a system-status failure with the
time it went dark, the same shape AWS uses for a host-level fault. The guest
passes every check it can be asked about itself, which is exactly why reporting
it healthy was the worst available answer.

* feat: give a recovery attempt one trace across all three of its phases

The volumes opening, QEMU starting and its monitor answering already trace
themselves, but nothing tied them to the attempt that asked for them: three
orphan traces per recovery, no way to ask which phase a slow one spent its time
in, and nothing naming the node the guest came from. A span over the attempt
makes them its children, and the reconciler's own log lines carry its trace ID
because the handler stamps it from the context.

A failed attempt records the fault and the attempt count on the span, so the
budget running out is readable as a sequence rather than as twelve unrelated
warnings.

* fix: finish a fence once its writer finally exits

A kill that did not take effect was logged, counted and dropped. The export
stayed registered and the lease stayed held, which is correct while the writer
lives and wrong forever after it dies: a lease a healthy node keeps renewing
never expires, so the volume could not start anywhere.

The failure is now re-reported every 30 s until it resolves, and the teardown
completes as soon as the writer is confirmed gone. Only a delivered kill is
waited on — a refused one says nothing about what became of the writer.

* test: stop judging a detached stop or terminate on a transient state

Both tests read the instance state immediately after the ack, but the transition
runs on its own goroutine, so the read races it in both directions: too early
sees running, too late sees the final state. The race gate caught the first of
those on a loaded runner.

* fix: replicate the volume lease and dirty buckets across the cluster

Both were created with the default single replica, so both lived on one node.
That node holds the answer to "who may write this volume" and the marker every
mount reads, and losing it takes volume leasing and mounting down cluster-wide.
A partitioned node was fenced correctly and then no survivor could relaunch its
guest: every attempt failed on "read dirty mark: no responders available", for
ten minutes, until the budget ran out.

The replica count is now the cluster's node count, as the gateway's own buckets
already do. Existing buckets are raised in place rather than requiring a wipe.

The e2e diagnostics that should have said this are fixed too. The fence and the
surrender are logged by the storage service, not the daemon, so the assertions
read the wrong journal; and Eventuallyf evaluates its message at the call site,
so a ten-minute wait was explained by a log that stopped before it began.
2026-09-27 15:56:09 -05:00
dependabot[bot] b7b1ae562a chore: bump the go-dependencies group with 2 updates (#1112)
Bumps the go-dependencies group with 2 updates: [github.com/nats-io/nats-server/v2](https://github.com/nats-io/nats-server) and [github.com/nats-io/nats.go](https://github.com/nats-io/nats.go).


Updates `github.com/nats-io/nats-server/v2` from 2.14.7 to 2.15.0
- [Release notes](https://github.com/nats-io/nats-server/releases)
- [Changelog](https://github.com/nats-io/nats-server/blob/main/RELEASES.md)
- [Commits](https://github.com/nats-io/nats-server/compare/v2.14.7...v2.15.0)

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

---
updated-dependencies:
- dependency-name: github.com/nats-io/nats-server/v2
  dependency-version: 2.15.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-dependencies
- dependency-name: github.com/nats-io/nats.go
  dependency-version: 1.54.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-27 10:38:42 -05:00
Ben Duncan 20b3eae728 fix: record the OCI SDK's own dependencies in go.sum (#1115)
spinifex/cloud/oci imports oci-go-sdk directly, but go.mod listed it as indirect
and go.sum was missing three of the modules it needs — sony/gobreaker/v2,
gofrs/flock and youmark/pkcs8. The module therefore only type-checked inside the
go.work workspace, whose go.work.sum happened to carry them, so a standalone
checkout failed to build and the linter reported it as a typecheck error against
an unrelated file.
2026-09-27 10:38:00 -05:00
Ben Duncan fcda031117 test(e2e): make the workbook teardown a gate, not a warning (#1110)
* test(e2e): make the workbook teardown a gate, not a warning

A customer who cannot remove what they created is as stuck as one who could
not create it, so the teardown is part of what a workbook proves. It logged
WARN and passed the suite regardless, and a bucket that could never be emptied
went unnoticed for weeks behind exactly that line.

The failure it hides is a wedge rather than an error: the AWS provider retries
DeleteBucket against a bucket it cannot empty indefinitely. So the run is
bounded by DESTROY_TIMEOUT, defaulting to 900s, and the last forty lines of its
output are printed when it fails. The cleanup after a failed apply is bounded
the same way for the same reason.

* test(e2e): print a workbook's PASS after its teardown, not before

The verdict now depends on the teardown, so a workbook that built everything
correctly and then could not delete it printed PASS and FAIL for the same run.
2026-09-27 10:37:19 -05:00
Ben Duncan cacd2c2c85 fix: re-read a rejected volume lease renewal before fencing the guest (#1116)
A renewal that times out is treated as transient and keeps the lease, correctly,
but the server may have applied it anyway. It is then a revision ahead of what
the holder recorded, so every later conditional update is refused for that
reason alone — and a refusal was read as a peer having taken the volume. The
export was fenced, nbdkit killed and the guest lost its disk, with the log
naming a winner of "unknown" because no peer ever existed. A ten-second
JetStream stall destroyed four running guests this way.

A rejected update now re-reads the entry. One that still carries this lease's
owner and generation is this node's own write coming back, so the revision is
adopted and the lease continues; anything else, or an entry that has gone, is a
real loss. A re-read that cannot be answered keeps the lease without extending
it, because local validity is the bound that depends on reading nothing.

The rule is stated in its own test: a revision mismatch may not fence before a
re-read says the entry is no longer ours, and lapsed local validity always
fences whether or not a winner can be named. The weaker rule — that an
unnameable winner never fences — would leave a partitioned node writing for as
long as it stayed partitioned.
2026-09-27 10:31:20 -05:00
Ben Duncan adb3d9876d fix: read a tagged bucket's tags through S3 instead of S3 Control (#1109)
The AWS provider reads a bucket's tags with `ListTagsForResource` on S3 Control when it knows the account ID. S3 Control puts the account in the hostname — `https://{account}.{endpoint}` — so against an IP-addressed endpoint the request fails a DNS lookup before a packet leaves, and no endpoint configuration can help. A tagged bucket therefore fails its first read after being created.

`skip_requesting_account_id` alone does not fix it: `aws-sdk-go-base` takes the account ID from the STS `GetCallerIdentity` credentials check before it consults that flag, so `skip_credentials_validation` is needed alongside it. With no account ID the provider falls back to `GetBucketTagging`, which predastore serves.

`s3-webapp` is the only workbook that creates a bucket, and it uses no `aws_caller_identity` and references no other security group, so a blank account ID costs it nothing.

The bucket also gets `force_destroy`, since the webapp uploads into it and a destroy of a used bucket fails `BucketNotEmpty` without it.

The e2e harness now asserts the bucket carries the tags Terraform set, which is what catches this regressing, and no longer aborts the remaining workbooks on a failure — `s3-webapp` failing says nothing about whether RDS works, and four nights of `rds-quickstart` coverage were lost that way. `STOP_ON_FAIL=1` restores the early abort.

Every workbook's provider versions are pinned, so a run reproduces rather than picking up whatever the registry published that morning.
2026-09-26 16:11:00 -05:00
Ben Duncan 7163f7d62d feat(oci): run Spinifex on Oracle Cloud, and fix the public-address and NAT gateway lifecycle (#1108)
* feat: ship uninstall-spx.sh and fix the OCI bring-up path

Adds scripts/uninstall-spx.sh, the inverse of setup.sh: it removes the
software where node-reset.sh clears state and leaves the node installed. An
operator who installed with `curl ... | bash` had no teardown path at all.
State teardown is delegated to node-reset.sh rather than reimplemented, and
setup.sh now ships both to /usr/local/share/spinifex so they are reachable
without a checkout.

It refuses on a multi-node cluster and --yes does not override that: a flag
meaning "don't ask me" must not also mean "ignore a safety property".
/var/lib/spinifex and every apt package are kept unless asked for, and both
opt-ins confirm separately even under --yes.

Proved by a full uninstall/reinstall round trip on dev-oci, which found two
bugs neither prod nor dev-prod can reach, because both are ISO-installed:

node-reset.sh exited 2, silently, at its last step and after the wipe.
setup.sh creates the spinifex *group* and the spinifex-* service users but
never a *user* named spinifex, so `getent passwd spinifex` exits 2 and
pipefail carried it into set -e.

The uninstaller broke openvswitch-switch on its first run by removing the
socket-perms helper while leaving the drop-in referencing it, failing the
unit at step EXEC with status 203. Fixed on both sides: the uninstaller
removes drop-ins it wrote into other units' namespaces and restarts anything
it failed, and setup-ovn.sh writes ExecStartPost=- so a missing helper can
never stop another package's daemon.

setup-ovn.sh also learns the shape OCI needs: when --wan-bridge names a Linux
bridge it links it to OVS with a veth pair instead of enslaving the NIC, so
the VNIC keeps its MAC identity and netplan keeps owning the addresses. It
now fails on OVS datapath errors instead of reporting success, and the
--management demotion guard keys on /etc/default/ovn-central rather than on
database presence, which the ovn-central package creates on every node.

Tests: scripts/uninstall-spx_test.sh, wired into make test-build-scripts
along with shellcheck over the four scripts that ship to every node.

* feat: allocate external IPs from the Oracle Cloud API

An OCI VNIC drops any packet whose source is not a registered private IP
object on it, so an external pool cannot be range math there — the address
has to be created through the provider before it exists at all. Add
source = "oci" and the allocator behind it.

One allocation is two OCI objects: a secondary private IP that rides the
wire, and a RESERVED public IP attached to it that the internet reaches.
Allocate returns the public address because that is what AWS semantics are
about, and records the pair so the host plumbing and release path can find
the private half.

The OCI objects are created before the binding is written, and dropped
after it on release — both orders chosen so a crash leaves a discoverable
leak rather than a record that lies. Reconcile squares the two on startup
and is required, not optional: a leaked reserved public IP bills and holds
one of the 50 per-region slots with nothing referencing it. It never
touches an object without our display-name prefix, because the reference
host carries operator-created secondaries on the same VNIC.

Resolve the VNIC from an interface name by MAC through IMDS rather than by
Oracle's VNIC ordering, which is not a documented contract.

Generalise ExternalIPAM.EnableDHCP into InstallAllocator so the OCI wiring
is not a second single-purpose installer.

* fix: refuse source = "oci" outside routed mode, and cover the SDK path

An OCI VNIC accepts exactly one MAC, its own, and delivers every inbound
frame to it regardless of which of its addresses the packet is for.
Measured on the reference host: an internet source reaches 10.200.0.144 as
router-MAC to VNIC-MAC, the public address never appears on the wire at
all, and every off-subnet destination resolves to the same next hop.

Pool mode puts a per-VPC gateway LRP MAC and a per-rule external NAT MAC on
the uplink and ARPs for the address, which policy.go:50 justifies with "the
Linux bridge learns the per-rule external MAC on the veth port like any
other MAC". An OCI VNIC does not learn MACs; it accepts one. So a pool-mode
guest's egress is dropped and its ingress never reaches OVN. Routed mode
keeps the address on the host, which already wears the VNIC's MAC.

Pinning every LRP to that MAC does not rescue pool mode: they share one
external logical switch and OVN resolves inbound frames by an L2 lookup on
it, so duplicate MACs work for one VPC and silently misroute past that.

The refusal names the MAC reason, because a refusal that only says "use nat
mode" reads as arbitrary and gets worked around.

Also test the SDK request shapes against a local endpoint. Two of them are
wrong in ways reading does not catch: pagination stopping early reports a
partial VNIC to reconcile, which then drops live bindings, and a nil
privateIpId on detach is omitted from the body entirely, so OCI reads the
update as a no-op, returns 200, and leaves the address attached and billing.

* fix: install host firewall rules where a distro's catch-all reject cannot precede them

Oracle's Ubuntu cloud image ships a catch-all REJECT in both INPUT and FORWARD.
Two Spinifex datapaths were silently dead behind it on any such host.

EnsureNATEgressRules probed with -C and appended with -A, so its ACCEPT rules
landed after the REJECT and never matched: 24113 packets on the reject, 0 on
both accepts, and guests had no egress. The comment named the case it did not
handle, anticipating a FORWARD policy of DROP — which an append does survive —
rather than an explicit REJECT rule, which it cannot. Insert at the head, and
drain existing copies first, because a rule an earlier build appended still
satisfies -C and would otherwise stay stranded across an upgrade.

Nothing opened INPUT for the IMDS endpoints at all. The responder binds .254
and .253 per instance on the ime- devices, so guest requests are locally
delivered and meet INPUT. Requests arrived and no reply ever left; cloud-init
spent 490s timing out and reported the endpoint as disabled. With one wildcarded
accept the same image boots in 18.6s and finds its datasource.

The natuplink e2e check had the same blind spot the code did, asserting -C and
therefore presence but never position. It now asserts the accepts precede the
first reject.

* feat: OCI address pairs, and an IMDS remap so the host keeps its own metadata

An OCI external address is two objects, not one: a RESERVED public IP that
OCI 1:1-NATs upstream and never puts on the wire, and a secondary private IP
on the VNIC that the wire actually carries. Every AWS-facing record holds the
public half; every datapath object must hold the private one.

The mapping goes in policy.natManager and nowhere else. AddEIP, DeleteEIP,
PruneOrphanEIPs and the HostEIPBinder all converge there, and reconcile
rebuilds the rule set from AWS-facing intent on every pass — so a correctly
mapped rule installed further upstream is swept on the next reconcile and
replaced with one matching an address that never arrives. The seam is a
DatapathResolver defaulting to identity, so every non-OCI deployment is
unchanged. A resolve failure is fatal: falling back to the public address
would install a rule that reads correctly in ovn-nbctl and blackholes.

ocinet.Lookup is the OCI side — read-only, KV-backed, no client and no
credentials, because vpcd is a different process from the daemon that owns
the allocator and the bindings record is all either would consult.

Two live bugs the in-memory test store was hiding, both found by making the
fake report ErrNotFound the way the real store does:

  - KVStore.Mutate could not create, so every pool failed its own first
    allocation after OCI had already created and billed the pair.
  - Reconcile treated a missing record as a failure, aborting the one pass
    that exists to find an object created before its binding was written.

Guest IMDS must stay on 169.254.169.254, which is also where a cloud host's
own metadata service lives — the first guest launch made it a local address
and took the host's metadata and DNS with it. The datapath is OpenFlow
steered, so the guest-facing addresses stay put and only the host-side
endpoint address moves, with a PREROUTING DNAT on ime-+ translating and
conntrack un-NATing the reply. Off by default; identity when unset.

The IMDS firewall rules move to vpcd. They are wildcarded over every
endpoint rather than per-launch work, and spinifex-daemon holds no iptables
grant and no CAP_NET_ADMIN by design, so attempting them aborted every
launch.

* fix: name the real constructor in the IMDS bind-address error, and mark the NAT datapath test in-package

The error string pointed at DefaultHostBindAddrs, which does not exist — a
caller following it would have looked for a symbol that was never written.

nat_datapath_test.go reuses nat_test.go's in-package fixtures, so it carries
the //test:in-package marker the package check asks for.

* docs: index what each e2e suite proves

Written because a datapath comment was read as an end-to-end behavioural claim
and the suite that already answered it was found only after the wrong answer
had been given. TestVPCEgressPaths/NATBaseline is the authority on private
subnet egress, and nothing pointed at it.

* fix: source-route an EIP's reply out the VNIC that holds it

An EIP's reply carries the EIP as its source. On a host that pins its
uplink with a policy rule, the reply took the main table instead and left
by the wrong interface — which a cloud VNIC drops, because the address is
registered on a different one. Ingress reached the guest and nothing came
back.

Every bind now mirrors the uplink's rule onto the EIP. The selector is the
rule rather than the interface: both OCI VNICs sit in one subnet, so "the
interface owning the EIP's subnet" names two of them, and two candidate
rules is a refusal rather than a guess. A host with no source routing gets
no rule and no change.

* docs: name the setup-ovn recipe routed mode actually needs on OCI

The OCI guide told operators to run `--wan-bridge=br-wan`, which is
mutually exclusive with `--nat-uplink` and deletes the spx-nat transit
veth routed mode runs on. The node then refuses to bring the uplink up,
with an error naming the flag the guide should have used.

Several messages also claimed routed mode is outbound-only with no
public IPs. That stopped being true when the routed EIP path landed, and
it reads as "what you are about to do cannot work" during the one init
the OCI path documents.

* feat: let routed NAT form a multi-node cluster, and carry every pool through formation

Lifts the --nodes >= 2 refusal on --external-mode=nat. The refusal carried no
technical justification -- it was a v1 scope decision -- but routed NAT is the
only datapath a cloud VNIC can carry, so "routed NAT is single-node" had become
"Spinifex on a cloud host is single-node".

It is not fixed by lifting it. Measured on three OCI nodes: the cluster forms,
all chassis register and guest-to-guest traffic runs at 7.1-7.7 Gbit/s, but
every node claims every EIP, nodes disagree on the VPC's gateway LRP, and only
the gateway chassis reaches its own guests. init now says so on stderr rather
than refusing, because the refusal made the defects unreachable and therefore
unfixable. Distributing the EIP datapath is the actual fix.

Lifting it exposed a second defect that stops formation outright. NetworkConfig
held nine flattened Pool* scalars and the init node set PoolName = "wan"
unconditionally; routed NAT renders two pools and the one that matters --
nat-transit, carrying the gateway-LRP range -- was dropped. Each joiner got a
single pool named "wan" holding the transit gateway and the detected WAN prefix,
and the leader re-rendered its own config from the same struct, so all three
nodes were wrong. Pools is now []admin.PoolData in render order, and PoolData
gains json tags because it is on the wire now rather than a template input.

* feat(install-node): add --external-mode so a cluster can be formed in routed NAT

install-node.sh could not express routed NAT at all. It required
--external-pool/--external-gateway/--external-prefix-len and passed
--external-mode=pool to every spx admin init unconditionally, so the only
supported way to form a routed-NAT cluster was by hand.

--external-mode nat refuses the three pool flags rather than ignoring them, and
threads --nat-uplink into setup-ovn.sh -- without it the WAN interface check
rejects a physical NIC during formation, which is correct in pool mode and
wrong in routed mode, where nothing is bridged.

* fix(ocinet): stop each node deleting its peers' OCI addresses on startup

Reconcile compares the binding record against the private IPs on one VNIC. The
record is one key per pool and therefore cluster-wide, so on more than one node
every peer's binding looks exactly like a binding whose objects OCI has lost.
Each node dropped them, and direction 1 on the owning node then collected the
now-unclaimed objects as leaks -- deleting the reserved public IP and private IP
of a running instance.

Observed on the three-node OCI cluster: node2's startup pass logged "collected a
leaked address" for an address it had allocated ten minutes earlier and "dropped
a stale binding" for one living on node3's VNIC.

The damage did not stop at the OCI objects. With the binding gone,
Lookup.DatapathIP returns the public address unchanged, so the OVN
dnat_and_snat row carries the public address instead of the private half. Guest
egress then leaves the VNIC sourced from a reserved public IP, which OCI drops
because it enforces the registered source per VNIC -- and EnsureEIPSourceRoute
no-ops too, since a public address shares no subnet with any host address. One
destroyed binding takes out both directions.

Binding.VNICID already exists for exactly this ("how a node that comes back from
the dead tells mine from moved on without me") and Reconcile never consulted it.
A binding is now only dropped when it names this node's VNIC; one naming no VNIC
cannot be attributed and is left alone rather than guessed at.

* fix(host): find the EIP's source-routing table when the uplink is held as a /32

uplinkSourceRuleFor identified the interface owning an EIP's subnet by finding
a host address configured with a prefix that contains it. On OCI both VNICs sit
in one subnet, so br-wan has to hold its address as a /32 -- a /23 there puts
the subnet's on-link route in the main table at metric 0 and takes the whole
subnet off the primary VNIC. A /32 contains nothing but itself, so no rule was
ever found, no source rule was installed, and the reply left by the primary
VNIC where OCI dropped it for carrying an unregistered source.

The address is not the only record of who owns the subnet. The rule names a
table, and that table holds the on-link route. Consulted as a fallback, only
when the address prefixes matched nobody, so a host that already resolved this
the direct way keeps its existing answer -- prod and dev-prod are unaffected.

Only scope-link routes count. A default route covers every address and would
make every source-routing table an equally good answer.

* docs(oci): correct the multi-node story and name the oci_vnic_id constraint

The nat-mode warning said EIPs and default egress were both broken on more
than one node. Default egress is fine: delete a guest's dnat_and_snat row and
it reaches 1.1.1.1 in 2.3 ms from a guest on any of three nodes. Only the
public-IP path was broken, and the three defects behind it are now fixed.

What remains is not a bug but the shape of the datapath: routed NAT centralises
the EIP path on the VPC's gateway chassis, ext-shared is a localnet so the other
nodes cannot reach it, and OCI drops any frame whose source is not registered on
the VNIC it leaves by. So every node's pool must pin oci_vnic_id to the gateway
chassis's VNIC rather than resolving its own. Say so in §5.3, where an operator
writing the pool will read it, and in §9, where the cost is that a gateway
change kills every public IP until the pools are repointed.

The single-node cap itself is gone from the doc: a three-node cluster has been
formed and measured, and the warning proceeds rather than refusing.

* feat(nat): distribute the routed-NAT EIP path so it works on more than one node

Routed NAT processed every EIP on the VPC's gateway chassis, and ext-shared is
a localnet, so the transit segment a /32 route pointed into existed only on
that one node. Measured on three nodes: all three claimed all three EIPs, the
nodes disagreed on the gateway LRP, and only guests on the gateway chassis were
reachable.

The mechanism to fix it is not new. Pool mode already distributes EIPs by
putting external_mac and logical_port on the dnat_and_snat row and letting
OVN's ARP responder answer for the external IP on the external segment. Routed
mode has an external segment too, the spx-nat veth pair, and it being per node
is the distribution: only the node running the instance can answer ARP on it.

So a routed rule is now distributed on the same terms as a pool one, and its
host route becomes on-link on the transit veth instead of via a gateway LRP
that may be on another chassis. bindHostEIP stops demanding that address for a
distributed rule, which is what previously failed the bind on every node but
one. vpcd plumbs only the ENIs local OVS reports as its own, asked of OVS
rather than the Southbound DB because the question is about this host.

The gateway LRP keeps its transit-pool address. Default egress routes through
it and works across three nodes today, so making it link-local as pool mode
does would break a working path to fix one distribution has already fixed.

Pool and static are unmoved, and a test asserts that against the same input
rather than leaving it to inspection. A rule with no MAC stays centralised, so
a cluster part-way through an upgrade is never in a state neither shape handles.

Beads: mulga-opyl7, mulga-la0pe

* fix(nat): make routed-EIP host plumbing work on every node

Three guests on three OCI nodes, one guest per node: only one was
reachable. Two defects, both invisible on a single node.

The transit veth took a random MAC per node, but OVN holds one MAC
binding for the transit nexthop across the whole cluster — so every
chassis addressed its egress to whichever node seeded the binding, and
the other kernels dropped it at L2. The pair is node-local, so one
locally-administered address everywhere is unambiguous; setup-ovn.sh
pins it at creation and in the netdev unit, and EnsureUplinkPort repairs
a veth made before that.

Host routes and proxy-ARP also rode the leader-gated reconcile pass, so
a node that never won the lease never plumbed its own guests' EIPs.
BindHostEIPs does that half on its own loop on every node; Bind already
leaves alone an EIP whose ENI is elsewhere, so two nodes never contend.
An unresolvable address skips the sweep rather than shrinking it.

Pool and static are untouched: the new pass is a no-op outside routed
mode, and a test asserts it.

Also adds --teardown and --dry-run to setup-ovn.sh, for re-running it on
a node that is already tangled. It deliberately spares the NB/SB
databases, /etc/spinifex, the system-id and the packages, and refuses to
del-br a Linux bridge.

* fix(nat): assert the transit MAC where vpcd actually runs it

EnsureUplinkPort is never called in production — host.Wiring is
constructed nowhere — so the repair landed in a path the deployed
daemon does not take, and three nodes came back with three different
MACs. vpcd re-asserts it beside the NAT egress rules instead, which is
the routed-NAT branch that does run on every start.

* fix(vpcd): make uplink-mode detection say what it saw, and check it

detectBridgeMode logged only the branch it took, so a node detected as
veth when it was wired for nat looked identical to one that was right.
It now reports both probes every time, errors when a node carries both
uplink veths (set up twice, and --teardown is the way out), and errors
when external_mode and the wiring disagree — the datapath follows the
wiring, so the config silently loses that argument. An explicitly
configured bridge_mode gets the same cross-check, since nothing else
ever compared it against the wiring.

The vpc-networking doc now opens nat with "start with pool unless one
of these applies" and gains a section on nat across more than one node;
the admin warning for --external-mode=nat on two or more nodes says
what was verified by hand and what is still uncovered.

* test(nat): prove routed NAT delivers each node's own guests, on a cluster

natuplink ran single-node only, which is exactly where the two defects
that broke three nodes are invisible. It now admits ModeMultinode, and
phase 9 takes a second shape there: a guest per node in a spread
placement group, and for each one — its public IP plumbed by the node
running it and by no other node, the route on-link with proxy-ARP
answered there, a dnat_and_snat row carrying external_mac and
logical_port, a TCP handshake in from another node, and the guest's own
console verdict for egress. Every node's transit veth is checked
against the one cluster-wide MAC before any guest boots.

Single-node phase 9 gains the same row and on-link assertions, so the
shape is held on both topologies.

Two gates come from what three OCI nodes actually do: every node holds
a gateway_chassis row for every VPC (the active one is the highest
priority) and every node installs the VPC ingress route, so only phase
7 needs gating — it runs where the gateway port and the guest are both
local, and logs which one is missing otherwise.

Cell 20 is real-multi x nat with its own nightly step, leaving the
permutation cells' suite set alone.

* test(nat): run the VPC egress paths in nat mode, not pool mode only

The single suite gated TestVPCEgressPaths and TestWANEgressSMTPBlock on
external_mode = "pool", with a comment saying nat clusters have no
EIP/NAT-GW support. They have had EIPs since the routed path landed, so
the gate now asks whether the cluster can allocate public addresses at
all — pool, or nat with a non-transit pool configured.

The nat-single cell runs TestVPCEgressPaths beside natuplink, which is
the only nightly coverage of a private subnet's isolation and its NAT
gateway in routed mode. natuplink never creates a private subnet.

* docs(config): list nat among external_mode values

* fix(ci): renumber the multi-node nat cell off the bare-metal job

Cell 20 is already the bare-metal single-node job, gated on the same
cells input but declared in the workflow rather than e2e-cells.json.
A matrix entry with the same number runs two unrelated jobs under one
name, and naming 20 in a dispatch selects both.

* feat(images): add Oracle Linux 10.1 and 9.8, and pin their catalog digests

Oracle publishes its qcow2 checksums inline on an HTML page and ships no
sums file, so the four new entries carry the expected digest in the catalog
rather than a URL to fetch one from. Images gains ChecksumDigest for that,
and the catalog import path verifies against it and says which of the two
sources it used, so a recorded digest never reads as a fetched sums file.

Pinning is safe here and only here: each URL names an immutable build
(b291, b293, b178, b182) and never moves. aarch64 has a -kvm-cloud- build
alongside the plain -kvm- one; the cloud build is the cloud-init image and
is the one that matches x86_64's -kvm-.

TestImageCatalogReachable HEADs every URL in the catalog from the general
single-node suite. These artifacts live on hosts nobody here controls, so
an upstream that retires a build breaks `spx admin images import` for a
customer and nothing else in the suite would notice. It also asserts that
every entry sets exactly one checksum source, and that no pinned entry
points at a "latest" URL whose bytes would change under it.

* fix(formation): carry bridge_mode onto the leader and every joiner

applyNetworkConfig copied external_mode, external_interface and the pools
out of the formation NetworkConfig but never bridge_mode, so a multi-node
cluster wrote a spinifex.toml without the key while single-node init wrote
it. Every node then fell back to autodetecting a mode that init had pinned.

Nightly cell 30 caught it: TestNATUplink failed at the config phase on a
spinifex.toml that did not contain bridge_mode = "nat". Deriving it from
the mode rather than adding a NetworkConfig field keeps the leader and the
joiners on the same rule single-node init already uses.

* feat(ui): show how to ssh into an instance, and which user to use

An instance detail page gave the addresses and the key pair name but never
the login user, which is the one part of the command nobody can derive. It
belongs to the AMI, not to Spinifex: nothing in the launch path creates or
renames an account, so stock cloud-init applies the distro's own
default_user. Oracle Linux is cloud-user, Debian is debian, Ubuntu is
ubuntu, and guessing from the distro name gets two of those three wrong.

The card resolves the user from the AMI's name and description, and an
unrecognised image yields a placeholder rather than a guess, because a
wrong username in a copy-paste command is worse than an honest gap. A
private-only instance is called out as reachable from inside the VPC only,
rather than presented as a command that will simply time out.

* fix(nat): plumb host state for a NAT gateway's public address

AddNATGateway installed the OVN snat row and stopped there. On a routed-NAT
node the host is what delivers a public address, so an egressing private guest
left with a source the host held no route back to, and the masquerade rule —
which matches the transit 100.127.0.0/24 alone — never saw the packet. The
gateway's address needs the same /32 route into OVN, proxy-ARP and FORWARD
accepts an EIP gets.

hostBindSpecs unions floatingIPSpecs with one deduped spec per NAT gateway
address and feeds BindHostEIPs. It is kept apart from floatingIPSpecs
deliberately: pruneOrphanEIPs and addLive read that set as the complete list of
addresses allowed to hold a dnat_and_snat, and a gateway holds an snat, so
widening it would stop the sweep reclaiming a stale row on an address later
reused as a gateway's.

AddNATGateway also binds directly, on both the create and idempotent-skip
paths, so a fresh gateway is not dark for up to 30s waiting on the host EIP
pass — and so a reboot that keeps the OVN row still replays the volatile half.

Documents NAT gateways in nat mode in vpc-networking, and says at the head of
that section that nat is a supported mode covered by nightly cells 19 and 30.

* docs(oci): document Oracle Linux as a guest image, and that its user is cloud-user

Four catalog entries, all UEFI. The login user is the one that catches
people: opc is the default on the images Oracle publishes into OCI itself,
while these are the generic KVM cloud images from yum.oracle.com, whose
cloud-init default_user is cloud-user. Verified on 10.1 and 9.8 — opc, oracle
and ec2-user are all refused.

Also records why these four pin a checksum digest inline (Oracle ships no sums
file, and each URL names an immutable build), why arm64 takes the -kvm-cloud-
build, and that SELinux ships enforcing.

* fix(reconcile): stop reading an unreadable intent as an empty one

A wedged JetStream KV returned no keys for the ENI bucket while writes were
still landing. loadPorts mapped that to an empty port map, pruneOrphanPorts
read every guest LSP as an orphan against it, and every guest on the node lost
DHCP and IMDS while the API kept answering and the instances stayed "running".
The existing re-read guard did not help: it re-reads through the same path.

Three defences, because no single one covers every shape of the fault.

A failed kv.Get now fails the pass instead of dropping the record. Dropping one
VPC narrows localVPCs, which filters out every ENI in it, which the sweep reads
as permission to delete every guest port in that VPC — one failed read, same
outcome, and it needs no exotic KV state. Unmarshal failures stay skips: that
is a poison record rather than an unhealthy store, and failing on it would
wedge reconcile forever.

The port sweep now asks local OVS which taps are plugged in here and never
deletes a port that has one. A running guest's port cannot be an orphan
whatever intent says, and OVS is the one liveness signal that does not come
through the KV read path the sweep is otherwise entirely driven by. Wired from
vpcd; unwired callers fall back to the guard below. An unreadable OVS skips the
sweep rather than deciding on intent alone.

Each sweep also refuses outright when the keep set it would delete against
holds nothing at all — no ENIs, no SGs, or no ports and no external IPs. That
is the case local taps cannot cover, because the drift sweep is leader-gated
and lists NB cluster-wide, so the leader holds no tap for another node's guest.
The cost is real: a genuinely orphaned row now survives until one live record
of its class exists. Leaking a stale row is the recoverable half.

Three existing prune tests drove their fixtures to a wholly empty intent, which
is now the refusal case; they keep one live record of the class so they still
prove the prune. The Get-failure path has no unit test — there is no
jetstream.JetStream fake in the tree and an embedded server cannot be made to
list a key it will then refuse.

* docs(oci): separate the two address quotas, and the principal that can read them

The regional reserved-public-IP ceiling is tenancy-wide and does not grow with
node count, so a cluster hits it before the per-VNIC 64, which does. Records
the commands that read both, and that a detached reserved public IP still
bills.

Reading limits or usage needs a tenancy-level grant, which cannot be
compartment-scoped the way the node's policy is. That is a second, read-only
principal kept off every node, not an addition to SpinifexOperators.

* docs(oci): give quotas their own troubleshooting section, and say we enforce none

Spinifex hardcodes no address ceiling: it attempts the allocation and maps
OCI's own refusal to InsufficientAddressCapacity. So a quota is raised with
Oracle and nothing here is reconfigured or restarted — which is what an
operator needs to know before it bites, not after.

Separates the two quotas by how they scale: the regional reserved-public-IP
default is tenancy-wide and raisable by ticket, the per-VNIC 64 is per node and
is not.

* fix(oci): follow a guest to its new node when its EIP moves

OCI delivers a public address to whichever VNIC holds the private IP behind
it, and nothing ever moved that private IP. Binding.VNICID was written once at
allocate time, as the VNIC of whichever queue-group daemon happened to serve
the AWS request, and never updated — so an address landed on the right node
only by chance, and a guest that stopped and started elsewhere went dark while
every OVN row, host route and firewall rule on the new node was correct.

ClaimLocalAddresses closes the gap from the node that should hold the address.
Local OVS decides ownership, the same authority the host EIP binder already
trusts for the same question, and UpdatePrivateIp with a new VnicId does the
move in one server-side call. The private IP OCID survives it, so the reserved
public IP attached to it follows without being touched and the customer's
address never changes.

A loop per node, not leader-gated: the question is about this host's guests,
and a node that never wins the reconcile lease would otherwise never claim the
addresses of the instances it is running.

* fix(oci): record which ENI an EIP is associated with

An EIP is allocated bare and attached by a later AssociateAddress, so the OCI
binding written at allocate time named no ENI and nothing ever filled it in.
The address then stayed on whichever node happened to serve the allocation:
the affinity pass could follow an auto-assigned address, which is allocated
with its ENI, but never an EIP — the case customers actually use.

BindOwner is on an optional OwnerTracker interface rather than Allocator,
because only OCI has a notion of an address being delivered to one node. A
static or DHCP address is on the wire everywhere its node can reach, those
allocators do not implement it, and ExternalIPAM treats a pool that does not
as a success with nothing to do.

The same gap made the release owner guard inert for EIPs: it compares against
a binding ENI that was always empty, so a stale teardown could free an address
already handed to another instance.

* fix(ec2): report the address an instance is actually reachable on

Three faults kept DescribeInstances answering with the address launch assigned,
whatever happened afterwards.

The launch-time stamp lives on the stored ec2.Instance, and the stopped path
only declined to add an address rather than removing one, so a stopped instance
went on advertising an address it had released and that may already belong to
someone else. The projection now clears it, and the NIC association with it,
working on its copy so a restart can still report the address.

The running path filled the public address only when it was nil, which the
stamp never is, so an EIP could not be reported however correctly it was
recorded. The runtime address now wins.

And nothing told the rest of the system about an association: AssociateAddress
wrote neither the ENI record, which is what IMDS and the network reconcile
read, nor the instance record, which is what a describe answers from. The ENI
write is direct; the instance write is a fan-out, because the queue-group
worker that serves AssociateAddress is rarely the node running the instance.

* fix(net): take the host EIP plumbing off a node its guest has left

Bind refuses an EIP whose ENI is on another chassis; the prune beside it kept
anything still wanted cluster-wide. A guest that moved therefore left the old
node holding the /32 route into OVN and the proxy-ARP entry on the uplink, with
nothing that would ever remove them. On a shared segment that node goes on
answering ARP for an address another node is now serving.

The two halves now ask the same question by the same authority, in the loop
that already runs per node. An unreadable local OVS is not a yes: tearing down
a live address on a guess would fire everywhere at once, so it keeps the
binding and the next pass tries again. A binder with no chassis test behaves
exactly as before, and a centralised EIP is exempt because it hairpins through
the gateway chassis and every node plumbs it legitimately.

* fix(net): refuse to free an Elastic IP on behalf of a departing interface

Releasing an instance's public address on teardown releases it by address,
scoped to the ENI holding it. Since an association now writes the EIP onto the
instance record, that address is the EIP — so terminating an instance gave a
customer's Elastic IP back to the pool while describe-addresses still reported
it as theirs, free to be handed to another guest.

The existing ownership guard cannot catch this. It only fires when the stored
allocation already names an ENI, and an EIP is allocated bare and associated
later, so the stored ENI is empty and the guard passes straight through.

The distinction that holds is who is asking. ReleaseAddress is the address's own
owner and names no interface; instance teardown names the interface it is tearing
down. So a release that names an owner may free an auto-assigned address and
never an EIP, which is enforced in each of the three allocators.

DHCP needed the scope carried on the wire, since it releases by address and
resolves the lease afterwards. Absent on an older caller, which reads as false
and keeps the previous behaviour.

Worst on the two providers where it is not recoverable: on DHCP the lease is the
only thing holding the address and the server promises nothing about handing it
back, and on OCI the address is a reserved public IP object that the release
destroys outright.

* test(e2e): prove an Elastic IP survives a stop and start

Instance teardown releases the public address by value, and since the
association writes the EIP onto the instance record, that release aims
straight at the customer's Elastic IP. The allocator guards now refuse it,
but nothing end to end proved the address comes back on a restart.

The step goes inside EIPFlip, which already runs last on the shared public
guest for the same reason a stop is safe there: the address the guest
returns on is the allocation's, not the pool's. It runs on the static
cells and the DHCP ones alike, and on DHCP it doubles as the only outside
view of the lease still renewing while the guest is down.

* feat: release an auto-assigned public address on stop, take a new one on start

AWS lends an instance a public address for as long as it runs and gives it a
different one on the next start. Spinifex kept the address across a stop, which
holds a pool slot for a stopped instance and, on a DHCP pool, keeps renewing a
lease nothing is using.

A stop now offers the address back through the instance cleaner, which refuses
anything an Elastic IP is behind — the EIP association record answers that, the
same question an ENI delete already asks — and records on the VM that the start
owes it a new one. A host drain keeps the address: Restore relaunches the guest
on the next boot, and changing its address under it is an outage nobody asked
for.

The start allocates before the launch, so an exhausted pool refuses the start
rather than booting a guest the customer cannot reach, adopts an Elastic IP
associated while the instance was stopped instead of assigning a second address
beside it, and hands the address back if the launch then fails.

e2e: a StopStartAddress stage on the public guest asserts a stopped instance
reports no address and comes back on a different one from a static pool, and a
StopStartKeepsTheEIP step asserts the opposite for an Elastic IP.

* docs: restructure the OCI guide around two deployment paths, with diagrams

The guide had grown into a numbered wall with no shape: no frontmatter, no
contents, and a reader with an OCI account had no way to tell where to start.
It now follows the multi-node install template — overview, prerequisites, then
the deployment path — and splits that path in two: Terraform for one node or
three, which is the preferred route, and a manual single-node walkthrough for
an instance that already exists. Both meet at one shared configuration section
rather than repeating it.

Two ASCII diagrams carry what the prose could not: one OCI instance with the
whole AWS surface inside it, and three nodes with the same surface distributed,
showing which plane each VNIC carries and how an address follows a guest that
moves.

The limits section was materially out of date and said so in the places that
mattered most. Multi-node is no longer experimental, public addresses are no
longer pinned to one node — each allocates on its own VNIC and the affinity
pass moves the private half to wherever the guest is — and an auto-assigned
address is now returned to OCI on stop, so a stopped fleet stops paying for
addresses. What changed is recorded rather than quietly edited.

The Terraform configuration moves in from the monorepo as
scripts/terraform/oci-spx, so the guide points at something in this repository.

Also marks the two new lifecycle test files in-package, with the reason each
one needs to be: both substitute unexported dependencies.

* docs: rework the OCI guide — SVG diagrams, Terraform variables, iSCSI and br-wan

Replaces the two ASCII diagrams with SVGs in the house style, one for a
single node and one for three. Renames the two deployment sections to
Terraform deployment and Manual deployment, documents what the Terraform
builds per node and every variable worth setting, and explains that the
data volume is an iSCSI attachment rather than a disk.

The manual path now covers the iSCSI login, the fstab entry and the
netplan source routing that Terraform writes, and states what br-wan is
for on a routed-NAT node: it names the VNIC for the OCI allocator and
holds the registered addresses, but is never bridged into OVS.

Opens with what Spinifex is and why OCI is worth running it on.

* fix: make gateway convergence work on a multi-node routed-NAT cluster

Three defects each stopped a multi-node cluster from ever attaching an
internet gateway, and all three produced the same uninformative symptom:
`terraform apply` sitting on `aws_internet_gateway.igw: Still creating...`
forever while every service reported healthy.

ovn-northd was given only its local OVSDB socket as a client connection.
A RAFT follower does not forward writes -- it answers "not cluster leader,
trying another server" -- so with one entry in the list, northd stops
translating Northbound intent into Southbound flows the moment database
leadership moves to another node. Measured on the OCI cluster:
SB_Global.nb_cfg seventeen transactions behind NB_Global.nb_cfg, with no
chassisredirect port for any VPC created after leadership moved.
setup-ovn.sh wrote the member list only when the caller passed
--ovn-remote, which only install-node.sh did; it now derives the list from
the live RAFT membership otherwise, and says so when it finds a single
member, because that node needs a second pass once the others join.

The gateway datapath probe ran wherever the reconcile lease landed rather
than on the chassis owning the port. Both probes leave from the local
uplink, and under routed NAT the transit subnet exists identically on every
node, so a remote probe reported a healthy gateway as dead and aimed a
repair at a datapath the node does not own. GatewayPortLocal gates it.

On OCI the probe then ARPed the tenant-visible public IP, which the cloud
NATs and which appears on no interface anywhere. The reconciler now maps
the address through the same DatapathResolver the NAT rules are built from.

Also adds DescribeVpcEndpoints, which the AWS provider calls on every VPC
delete to sweep GuardDuty-managed endpoints; an InvalidAction there failed
the destroy and stranded the VPC.

The OCI guide gains a Terraform section recording which shipped workbooks
were validated against a three-node OCI cluster and which are still open.

* docs: record the validated Terraform workbook results for the three-node OCI cluster

Six of the seven workbooks run against the OCI cluster pass, including the
three the section exists to prove: RDS, ECS and EKS. Records nginx-alb and the
clean eks-quickstart re-run, and names the two lifecycle gaps that bit during
the run so an operator recognises them rather than reading them as a broken
workbook.

* fix: route the NAT gateway's SNAT source through the datapath resolver

AddNATGateway was the one NAT path that did not map its external address to
the one that rides the wire. Where the public half of an allocation is NAT'd by
the cloud it is on no interface anywhere, so every private-subnet guest egressed
with a source that was dropped, while the gateway reported available and DNS
kept working because the VPC resolver is host-local.

The unmapped address also failed the host EIP sweep, which works in resolved
addresses, so the bind was pruned as foreign on the next pass and reinstalled on
the one after. The public half now survives as an external ID instead.

Also restructures the OCI guide around numbered steps through to running
Terraform, and moves the cloud-init and metadata sections into troubleshooting
where they are read when something is wrong rather than followed when it is not.

* fix: place a NAT gateway's OCI address on the node that forwards for it

OCI drops a packet whose source is not a registered private IP on the VNIC it
leaves from, so a NAT gateway's SNAT source has to be on the gateway chassis.
Two independent placements decided those and nothing reconciled them: the
address was created by whichever worker served AllocateAddress, while the
chassis is a fixed ordering the reconcile pass asserts. On a three-node cluster
that made private-subnet egress a coin flip, and the same Terraform passed or
died depending on which node answered.

The affinity pass already solves this for guests, by asking local OVS where the
tap is. A NAT gateway has no tap, and it skipped anything with no ENI — which
is exactly and only this address. The equivalent local fact is the VPC's
chassisredirect binding, so ownership now has two answers rather than one, and
an address that is neither a guest's nor a gateway's is still left alone.

The VPC is recorded from the reconcile pass rather than from CreateNatGateway,
because the answer is the gateway chassis and reconcile is what decides it.

* fix: plumb a NAT gateway's host ingress only on its gateway chassis

A NAT gateway's address has no ENI, so neither the bind's locality test nor
the prune's could ever fire for it and every node installed its /32 route into
OVN. Latent on bare metal, where pool addresses are disjoint from the node
subnet; on a cloud that hands out the address from the nodes' own subnet it
outranks the connected route and swallows node-to-node traffic.

Give that address the same second authority the ocinet affinity pass has: the
VPC's chassisredirect binding, read from the local ovn-controller. An
unanswerable question still leaves the binding alone, so a single node whose
gateway port is not yet claimed behaves exactly as before.

The orphan EIP prune built its live set from ENI and user EIPs alone while also
sweeping host ingress, so every drift pass tore down the ingress of every live
NAT gateway and left it dark until the next host EIP tick.

* fix: ask the gateway chassis only about a NAT gateway's address

gatewayElsewhere skipped the NAT gateway marker it was named for and answered
for every spec, so on a node that is not the VPC's gateway chassis every EIP —
including a guest's, whose locality is its own port — was left unplumbed. Two
of three nodes bound nothing.

* test: assert the two private-subnet EKS workbooks in the tofu harness

eks-https-ingress and eks-gitops-argocd put their workers in private subnets
behind a NAT gateway, so a worker only registers Ready if its egress to the
cluster endpoint and to ECR survives that path. That makes them the workbooks
that catch a NAT gateway fault, and neither had an assertion, so neither could
be run at all — run_workbook calls assert_<name> by construction.

Both share eks-quickstart's readiness check, extracted unchanged. They also
name the worker size node_instance_type rather than instance_type, and tofu
rejects a -var for an undeclared variable outright, so the name is read from
the config instead of kept in a list here.

* test: count agents, not every node, when asserting EKS workers Ready

The control plane is three K3s servers, so an unfiltered node count reaches a
one-worker cluster's desired size before any worker joins — the assertion
passed on exactly the failure it exists to catch, reporting "4/1 workers
Ready". Filtering on the control-plane role label makes it 1/1, which is the
number that says a node-group worker registered.

Matters most for the two private-subnet workbooks, where the worker joining is
the whole proof that egress through the NAT gateway works.

* docs: record the two private-subnet EKS workbooks passing on OCI
2026-09-26 14:31:07 -05:00
Julian Sommer 6d4a66be90 fix(sts): reject session policies and tags as unsupported, and echo SourceIdentity (#1105) 2026-09-24 16:31:03 +10:00
Julian Sommer cb83f9b193 fix(bedrock): GetFoundationModel answers an unknown model with ValidationException (#1104) 2026-09-24 16:22:33 +10:00
Julian Sommer 372991c304 feat: implement EC2 ModifySecurityGroupRules (#1103) 2026-09-24 15:20:57 +10:00
Julian Sommer 28963e28af feat: verify local image imports with --checksum and record AMI source digests (#1102) 2026-09-23 15:15:23 +10:00
Julian Sommer 85dd1dcd75 docs: stop skipping the account ID lookup in Terraform provider blocks (#1101) 2026-09-23 14:13:04 +10:00
Julian Sommer da2e6c5c7b chore: update coverage 2026-09-23 14:10:35 +10:00
Julian Sommer 71059834e7 feat(gpu): multi-GPU instance types (#1099) 2026-09-23 09:02:09 +10:00
Josh Siviour 5c5a2bc0b7 fix: omit the user data attribute value when an instance has none (#1097)
DescribeInstanceAttribute always returned a Value for userData, an empty string when the instance was launched without any. AWS returns the attribute with no Value at all in that case.

The Terraform provider reads user_data only when Value is non-nil, so an empty string made it record the hash of the empty string in state and propose a change on every plan for an instance whose configuration names no user data.
2026-09-21 15:15:27 +10:00
Josh Siviour 259b737f7a fix: size each EBS provider deadline to the work its subject names (#1096)
One 30s timeout bounded every ebs.provider.v1.* request. capabilities, volume.describe and volume.list answer from state already held; volume.create clones an AMI snapshot. The cheap calls set the budget and the clone inherited it.

On a freshly restarted cluster the first clone of an AMI does not finish inside that budget, so RunInstances took a context deadline for a preparation failure and terminated the instance, taking its ENI and volumes with it. A retry of the identical request succeeded once the snapshot had been read once.

The subjects that move or reference data now carry 180s, matching unmountSealTimeout, the existing budget here for an EBS operation the caller cannot bound. The classification lives with the subject constants, so a caller constructing a provider cannot reintroduce one flat value, and a new subject stays fast until someone classifies it.

Retrying a timed-out create is deliberately not attempted: the responder may still be running one, and that needs idempotency this change does not establish.
2026-09-21 14:16:51 +10:00
Julian Sommer 0ae2826cca chore: update pnpm 2026-09-21 10:54:11 +10:00
Josh Siviour 2dee58d749 fix: default the root block device to the name the AMI declares (#1095)
RunInstances defaulted the root device to /dev/vda, the path the guest sees, so an instance launched without a block device mapping reported a DeviceName that contradicted its own RootDeviceName of /dev/sda1.

ECS capacity and EKS nodegroups named the same guest path explicitly, so every internally launched instance carried it too. All three now name ebsmetadata.RootDeviceName, which is what AWS returns and what the guest already resolves through its own device links.
2026-09-21 10:12:45 +10:00
Julian Sommer b53ac2d148 chore: frontend deps 2026-09-21 09:50:41 +10:00
dependabot[bot] 7cc8f59a7d chore: bump the go-dependencies group with 5 updates (#1094) 2026-09-21 09:01:03 +10:00
Josh Siviour 4fc99a5e3e fix: report the block device name the caller asked for (#1093)
BlockDeviceMappings[].DeviceName was overwritten with the path the guest kernel assigns, on every launch, start and hot-attach. The name is ForceNew on aws_instance.ebs_block_device and root_block_device, so a value that never reads back as it was sent is not drift to reconcile: every plan destroyed and recreated the instance and everything on its volumes, and a stock terraform-aws-modules/ec2-instance configuration never converged.

The convention it served — lsblk inside the guest showing the same names as DescribeInstances — is not one AWS keeps either. On Nitro the API reports /dev/sda1 while the guest shows /dev/nvme0n1p1, and IMDS block-device-mapping reports the API name too. The API name is a label, and the wire contract owes the caller the label it supplied.

UpdateGuestDeviceNames becomes LogGuestDeviceMap: the guest paths are still discovered and logged for an operator correlating a volume against what the guest sees, and no longer recorded as the device the caller asked for. AttachVolume records the API name it already returns and already persists to volume metadata. IMDS projects the same field and so becomes AWS-shaped with it.
2026-09-21 08:37:34 +10:00
Josh Siviour 8b5e23da70 fix: return the OIDC provider URL with the scheme it was created with (#1092)
The Terraform AWS provider compares the stored url against the configured one through suppressOpenIDURL, which parses the stored value and forces the https scheme onto it. An issuer carrying a port is not parseable without a scheme — Go reads the host as one and rejects the rest — so the parse fails, the suppressor returns false, and the ForceNew url attribute replaces the provider on every plan. Every issuer this platform mints carries a port, so the AWS-shaped stripped form is the one that cannot round-trip here. The ARN keeps the stripped host-and-path form, which is what AWS uses and what issuerFromOIDCProviderARN reverses.
2026-09-21 08:37:14 +10:00
Josh Siviour c2332746f3 fix: retry a recovery-failed instance and stop calling a dead record running (#1091)
An instance whose relaunch fails during daemon-startup recovery goes to
StateError, and nothing ever retried it: every later restart read the same
StateError and skipped it again, permanently. The usual cause is a dependency
that was not ready at boot, which is over seconds later, so the instance stayed
down for a condition that had already cleared.

The manager already bounds restarts for a crashing instance, through
Health.CrashCount and Health.FirstCrashTime against MaxRestartsInWindow. A
recovery failure wrote none of it, so it was invisible to the only budget there
is. MarkRecoveryFailed now counts one, the window's two rules move into
recordCrash and rollRestartWindow, and MaybeRestart is rewritten over them with
the same constants and comparisons it had.

With a failure counted, restore can retry: an instance in StateError with budget
left returns to Pending and goes through the existing relaunch, including its
mount-not-ready backoff. With the budget spent it stays in StateError for the
operator and is only announced to the recovering hook, as before. The window
expires, so a later restart tries again rather than writing the instance off.

StartStoppedInstance is the operator's remedy and it was refusing to work. The
shared stopped store never holds a StateError record, so the load missed, and
the fallback treated the presence of the dead local record as proof the instance
was already running. It reads the record's state now. StateError is started
locally through StartInstance, which already accepts it; terminated answers
InvalidInstanceID.NotFound; everything else keeps IncorrectInstanceState,
because a claim inserts the instance before it launches, so a local record in a
startable state is a start another caller is part-way through.
2026-09-19 13:02:26 +10:00
Josh Siviour a74052fd6f fix: keep the ecs-agent dialling containerd and brake a deployment that cannot start (#1090)
The agent resolved the container runtime once, in New. A containerd socket that was not accepting during the first second of boot left the agent runtime-less for the life of the process, and nothing retried. It registered and heartbeated normally, so the scheduler saw a healthy instance and kept placing tasks that died immediately at the nil runtime check.

The runtime now lives behind a gate that a watch loop keeps current: it dials while the gate is empty, and probes with a container list while it is full, clearing the gate when the probe fails so the next tick re-dials. That covers a socket that is not ready yet at boot and a containerd restarted underneath a running agent alike, and everything that used the puller or the runner reads through the gate, so a runtime arriving late is picked up without a restart.

An agent with no runtime advertises no capacity. Registration and the heartbeat report zero CPU and zero memory while the gate is empty, so placement skips the host, and the control plane reads that pair of zeros as an involuntary drain: the instance becomes DRAINING with agentConnected false and zero registered resources, which is visible through DescribeContainerInstances rather than only in the node's log. Capacity returning clears it through the branch a reaped instance already used. A host with no CPU cannot exist, so the zero needs no new wire field. An operator drain is no longer cleared by a bus registration either, which it previously was.

A deployment whose tasks keep failing to start now backs off. Deployment gains NextLaunchAt, and past the failure count the circuit breaker already tracks, the reconciler widens the gap between launch passes up to a cap. It is keyed on that counter but applies whether or not the breaker is enabled, because AWS leaves it off by default and an unbraked service relaunches a task that cannot start on every pass, forever.
2026-09-19 12:47:55 +10:00
Josh Siviour 32bcada227 fix: make an error-state instance terminable and stop reporting a refusal as terminated (#1089)
An instance left in StateError could not be terminated through any path, and the gateway reported success anyway, so a Terraform destroy waited on a record the platform had already claimed was gone.

classifyRestoredInstances skipped a StateError instance with the comment that the operator must retry or terminate it, but continued before firing OnInstanceRecovering, which is what binds ec2.cmd.<id>. The topic the terminate travels on therefore had no responder and the stated remedy was unreachable.

With no responder there, TerminateInstances fell back to the ec2.terminate topic, whose handler reads the stopped KV bucket only and answered InvalidInstanceID.NotFound for a record held in the active registry. The gateway discarded that payload without reading its code and took the idempotent-absence branch, returning terminated for a record it had not removed.

The StateError branch now announces the instance to the recovering hook before skipping relaunch, and the gateway reads the refusal: only a genuine absence falls through to the idempotent branch, and any other code is logged and returned.
2026-09-19 12:47:34 +10:00
Josh Siviour db5e446338 feat: create the volumes a launch names beyond the root device (#1088)
RunInstances created exactly one volume, the root, plus the EFI variable store, and refused a request naming any other EBS device. An instance could not be launched with a data volume attached, so terraform-aws-modules/ec2-instance with an ebs_block_device failed the apply and the workaround was CreateVolume plus AttachVolume afterwards.

The launcher was never the constraint: buildDrives already has a data-volume branch and already allocates a hot-plug port for a volume attached at RunInstances time. The control plane now creates a volume for every EBS mapping through the volume service, which is where CreateVolume's validation and metadata write already live, and records each as a non-boot request at the device name the caller named.

A data volume is created empty, so a mapping naming a snapshot or naming no size is still refused by name rather than served as something it did not ask for. A launch that fails partway deletes the volumes it created.
2026-09-19 12:47:14 +10:00
Josh Siviour 2ee22e0f66 fix: honour the status filter on ListContainerInstances (#1087)
The filter was never read, so ACTIVE, DRAINING and no filter at all returned every registration in the cluster. A caller could not ask which container instances can take work without describing every one of them, and a cluster carrying a drained node read as if every node were live.

The status a record holds is the one DescribeContainerInstances reports, so a filtered list and a describe of what it returns agree. REGISTERING, DEREGISTERING and REGISTRATION_FAILED are accepted and match nothing, because no record here ever holds them; a status outside the enum is refused.
2026-09-19 12:46:05 +10:00
Josh Siviour eb365eca7b fix: pin predastore to the object sub-resource guard (#1086)
An unserved object sub-resource fell through to PutObject, GetObject or DeleteObject, so a write to one stored the object itself. The pin also carries the meta leader health check fix and the router's single query parse.
2026-09-19 08:41:14 +10:00
Ben Duncan 2ba8f5e4ab docs: separate the install guides from the reference material they had absorbed
Both install guides had grown sections that were not install steps, which is what made the multi-node one hard to follow. Each now ends on a step that hands off to Setting Up Your Cluster, and the reference material moves under Administration where it can be read on its own.

- Single-node and multi-node both end on a "Set Up Your Cluster" step rather than a trailing sentence.
- The multi-node cluster-state check via `spx admin kv digest` is gone. Verification is now every node Ready, capacity pooled, and the AWS API answering.
- "How Multi-Node Storage Works" becomes docs/admin/predastore-distributed-storage, expanded with the host and node model, the write and read paths, degraded writes, hinted handoff, repair, encryption at rest, and what each class of failure costs.
- "Firewall and Cluster Membership" becomes docs/admin/host-firewall, expanded with the full public, cluster and encap port groups, how to narrow SSH, and which files under /etc/spinifex/firewall survive an upgrade.
- Setting Up Your Cluster gains a closing step that arms the firewall and narrows SSH, so the securing work lands after a cluster that works rather than during the install.

install-usb and vpc-networking followed the two moved sections to their new paths.
2026-09-18 10:58:25 -05:00
Josh Siviour e035842503 fix: report the root volume as it was requested (#1082)
Three omissions left the launched root volume reading back as something the caller never asked for.

DescribeInstances returned a null RootDeviceName and RootDeviceType while DescribeImages populated both for the same AMI, so the two answers disagreed about the same image. The launch path now projects both onto the instance.

The root volume's EBS request carried no device name, so the launcher wrote an empty string to the volume's metadata and DescribeVolumes reported an attached volume with an empty Attachments[].Device. The name parseVolumeParams already resolved now travels with the request.

A volume-scoped TagSpecification was discarded: only instance-scoped tags were read, and the comment claimed volume specs were handled elsewhere, which nothing did. They are now written to the root volume's metadata document.

BlockDeviceMappings[].DeviceName still reports the guest path rather than the requested name. That rewrite is deliberate and is left alone here.
2026-09-18 17:11:04 +10:00
Josh Siviour 0b93b1a957 fix: route the EKS update reads so they refuse instead of reading as a typo (#1084)
* fix: route the EKS update reads so they refuse instead of reading as a typo

UpdateClusterVersion was registered at POST /clusters/{name}/update-version, which is the nodegroup pattern and not a path the SDK ever calls; AWS puts it at POST /clusters/{name}/updates. The refusal written for it was therefore unreachable, and the router answered InvalidAction, which tells a caller they typed something that does not exist.

ListUpdates and DescribeUpdate had no route at all and answered the same way. Both now refuse with NotImplemented at the gateway rather than through a service method: the update surface they would read is absent, so a round-trip to reach a constant refusal is surface for its own sake.

The absence of the cluster-level update surface is unchanged. This only stops the platform blaming the caller's spelling for its own routing.

* test: account for the two EKS update reads in the cross-tenant table

Both name their cluster by a path segment that carries no account segment, so
neither can name another tenant's resource and neither needs a case of its own.
2026-09-18 17:10:48 +10:00
Josh Siviour 664db686e1 fix: ack an ECS stop directive only after the reap reports STOPPED (#1085)
The agent acked a stop in the same loop iteration that launched the reap, so
the directive was deleted before the reap had done anything. When the reap
could not run — no container runtime, or a failing List — nothing re-posted it,
and the task held its ENI, its capacity reservation and its target-group
registrations forever. The in-memory `stopping` map made that permanent: it was
set at dispatch and never cleared, so the agent would not re-dispatch even if
the directive did come back.

A stop ledger now separates a reap in flight from a reap that reported STOPPED.
Only the second is acked, so a failed reap leaves the directive in the inbox to
be redelivered and retried, while the assign loop keeps reading both halves and
so still refuses to start a task that is stopping or stopped.
2026-09-18 15:47:33 +10:00
Josh Siviour e2db95d746 fix: persist security group rule tags and report -1 ports for all-protocol rules (#1083)
* fix: persist security group rule tags and report -1 ports for all-protocol rules

A security group rule accepted a security-group-rule TagSpecification and kept
none of it, so DescribeSecurityGroupRules answered with no tags and every
subsequent plan offered to add them again. SGRule now carries a Tags map,
populated on both authorize paths and projected into the central tag store, and
DescribeSecurityGroupRules evaluates tag: and tag-key filters against it instead
of excluding every rule.

CreateTags and DeleteTags on an sgr- id were a silent no-op because
updateRecordTags routed only subnet-, vpc-, sg- and eni-. It now finds the rule
inside its parent group's record and mutates it there, and a revoked rule's
central entry is cleared with it.

An all-protocol rule reported the ports it was stored with, 0 and 0, where AWS
reports -1 and -1, which is the same never-converging plan by another route.

* fix: recognise sgr- as a security-group-rule resource id

DescribeTags reported ResourceType "unknown" for a security group rule, so a
resource-type filter naming security-group-rule matched none of them, and the
authorizer could build no ARN for a rule id and fell back to the any-resource
wildcard. Both read the same prefix table, which had no entry for sgr-.
2026-09-18 15:47:16 +10:00
Josh Siviour ccc7478f19 fix: select the root block device mapping by name and refuse the rest (#1081)
RunInstances read BlockDeviceMappings[0] and nothing else, so a request carrying a root mapping and a data mapping created one volume sized from whichever happened to be first, and the first mapping became the root whatever device it named. A module emitting the data mapping first produced a root volume of the data volume's size and no data volume at all, with no error.

The root is now the mapping naming the AMI root device, with position deciding only when nothing names it, so the single /dev/vda mapping ECS and EKS launch with still works. Any further EBS mapping is refused in ValidateRunInstancesInput with InvalidParameterValue naming BlockDeviceMappings and the devices it cannot attach, rather than being discarded silently. Ephemeral mappings carry no volume and are ignored.

Creating the additional volumes at launch remains unimplemented; this makes its absence visible.
2026-09-18 15:47:05 +10:00
Josh Siviour f536b1a0cb chore: pin predastore to the revision that serves bucket versioning (#1080)
The deploy builds with GOWORK=off, so spx links the pin rather than the sibling
checkout, and the versioning handlers are absent from every deployed binary
until it moves.
2026-09-18 09:28:35 +10:00
Ben Duncan deff3a1e1e chore: dispatch the E2E Nightly from Cloudflare instead of a GitHub schedule (#1076)
GitHub registers a schedule: only from a repository's default branch, so this cron could drive exactly one branch and always executed main's copy of this file whatever it tested. main was never exercised by the nightly, and a fix to the workflow itself could not be proved until it had already shipped — which is how cell-24's unguarded pkill stayed live on the schedule after its fix merged to dev.

A Cloudflare Worker now dispatches this workflow once on dev and once on main. A dispatch takes both the workflow file and the source under test from its ref, so GIT_BRANCH is simply github.ref_name and every schedule-only expression goes with the cron.
2026-09-17 15:17:29 -05:00
Ben Duncan 2039c05c17 fix(ci): take the host benchmark lock before killing predastore nodes (#1065)
cell-24 clears leftover predastore nodes with `pkill -KILL -f '/bin/s3d
-config'`, which matches every predastore node on the runner and not only the
ones this run left behind. The predastore repository runs the same harnesses on
the same machine, so "still running" does not mean "stale" — on 2026-09-16 this
step killed a predastore benchmark 32 seconds into its workload, and the run
failed with 61 connection-refused errors that look nothing like their cause.

Both kills now run under the host benchmark lock the harnesses themselves take,
so anything alive while it is held really is a leftover. The second one needs it
as much as the first: by the time cleanup runs the harness has released the
lock, so another repository's benchmark can already be up.
2026-09-17 10:59:39 -05:00
Josh Siviour 451df1a8cd docs: publish AWS operation coverage inventory (#1075) 2026-09-17 21:33:53 +10:00
Josh Siviour b26478bd06 chore: pin predastore to the bucket sub-resource dispatch fix (#1074)
Every deployed binary links whatever go.mod pins, so the routing fix for bucket sub-resources is absent from the gateway until this moves. Without it a provider-level default_tags block still leaves no bucket creatable by Terraform.
2026-09-17 20:55:08 +10:00
Josh Siviour c9460628fb fix(ec2): clear a resource's central tags when it is deleted (#1073)
DescribeTags kept answering for resources that no longer exist: the central tag store's DeleteAllTags had one production caller, the instance terminate path, so every other resource type left its entry behind forever.

CentralTagWriter becomes CentralTagStore and gains DeleteAllTags, which TagsServiceImpl already implemented, so the daemon wiring is a rename rather than new plumbing. clearRecordTags mirrors projectRecordTags: a nil store is a no-op and a failure is logged rather than returned.

The clear runs after the record delete has succeeded, never before, so a delete that fails leaves a live resource with its tags intact. deleteNetworkInterface covers both the public delete and the forced instance teardown, and deleteSecurityGroupInternal covers the default group that only the DeleteVpc cascade ever removes.
2026-09-17 15:18:31 +10:00
Josh Siviour e88691e64d feat(imds): serve the cluster services domain, not amazonaws.com (#1072)
An AWS SDK's default endpoint resolution builds
{service}.{region}.{services/domain}. IMDS previously answered that
leaf with the literal amazonaws.com, so a workload that forgot its
endpoint override silently reached real AWS instead of failing.

Now that service-endpoint DNS resolves cluster-side, serve the
cluster's configured AWS.ServicesDomain instead, threaded from
config through vpcd.Config into NewIMDSServiceImpl. An empty value
fails closed with a 404 rather than ever falling back to
amazonaws.com. services/partition is unchanged: it still returns
aws, since AWS SDKs carry hardcoded partition tables.
2026-09-17 14:33:11 +10:00
Josh Siviour c674e4b5ce fix(ec2): project creation-time tags into the central tag store (#1063)
A tag set at creation time through TagSpecifications was written only onto the resource's own KV record. DescribeTags reads a separate central index at tags/{accountID}/{resourceID}.json, which only CreateTags writes, so a resource born with tags described as having none until something tagged it again.

Terraform finds this immediately: terraform-aws-modules/eks reads a cluster security group's tags back after creating it, and aws_ec2_tag fails against a resource whose tags the tag API cannot see.

The four VPC create paths now project their stored tags into the central store through an optional CentralTagWriter, injected after construction like the external IPAM. A nil writer leaves create behaviour unchanged, and a write failure is logged rather than failing the create — the resource exists either way, and failing a create over a tag index would be worse than the divergence it repairs.

Only vpc, subnet, security group and network interface are covered here. Image, volume, snapshot, route table, internet gateway, egress-only internet gateway, elastic IP, NAT gateway, key pair and launch template have the same split and are follow-up work.
2026-09-17 14:28:29 +10:00
Josh Siviour bef0780b10 fix(ecs): make awsvpc ENI slots a placement resource (#1064)
* fix(ecs): make awsvpc ENI slots a placement resource

A container instance carries a finite number of awsvpc task ENIs — the hot-plug PCIe root ports the daemon pre-allocates from the instance type's cap — and nothing outside the daemon knew the limit existed. The scheduler's capacity model was CPU, memory and GPU, so every over-capacity task was placed, created an ENI, failed to hot-plug it and rolled it back. A service asking for more tasks than the cluster has slots churned ENI create/delete indefinitely instead of reporting a placement failure once. On a t3.micro the third task and everything after it failed with "attach task ENI eni-… -> i-…: ServerInternal".

The instance record now carries the instance type, reported the way AWS carries it, as the ecs.instance-type container-instance attribute; the ENI cap is derived from it rather than stored, so a corrected limit table applies on the next read. An instance whose type has not been reported is not gated at all: a recognised type always has at least one slot, so zero can only mean unknown, and refusing every awsvpc placement against an older agent would turn a mixed-version cluster into an outage.

Both placement paths gate on it. A task declined for want of an interface is reported as RESOURCE:ENI, spelled as AWS spells it, and separately from a plain capacity refusal. The caller-visible detail is a fixed sentence; the wrapped error keeps its identifiers and server error codes in the log, where someone can act on them.

DescribeInstanceTypes now reports NetworkInfo.MaximumNetworkInterfaces, from the same table the slot allocator enforces, so a caller sizing an autoscaling group has something to size against. Fields this stack does not implement are left unset rather than guessed.

Finally, the daemon records the slot it actually allocated as the ENI's device index. The requested index was stored verbatim, so every task ENI on an instance reported index 1 while landing on a different PCIe slot; on AWS a device index is unique per instance.

* fix(ecs): send an ENI capacity refusal as the reason alone

AWS returns RESOURCE:* placement failures with no detail. Carrying a sentence
there gives a caller a second string to parse that says nothing the reason does
not already say.
2026-09-17 13:54:11 +10:00
Josh Siviour 86014ac08a fix(ec2): serialise tag writes across the cluster (#1070)
CreateTags is a read-modify-write of one resource's whole tag map. It was
serialised by a sync.Mutex on the service struct, which exists once per
process, while the handler answers on the spinifex-workers queue group and so
runs on whichever node NATS picks. Two nodes read the same map, each added its
own key, and the later write won. Every call returned success.

Six parallel writers against one security group on a three-node cluster left
four tags. On a single node the mutex hides it entirely, which is why no test
caught it.

The tag index moves from one S3 object per resource to a JetStream KV bucket,
where the merge runs inside kvutil.Update and commits at the revision it read.
A writer that loses the race re-reads the winner's keys and adds its own on
top. S3 could not host the fix: predastore's PutObject has no If-Match, so
there is nothing to compare and swap against.

Tags written before the move are not migrated in a batch. A batch that
half-failed would leave a resource looking untagged, and the next write would
commit that as the truth. Instead the CAS seeds a missing entry from the old
object inside the attempt, so a resource migrates on its first write and a
failure to read the old object fails that one call. DescribeTags reads the
union of both key sets, preferring the bucket, and DeleteAllTags clears both so
old tags cannot reappear through the fallback.

The process-local mutex is removed rather than left beside the CAS.

The regression test drives six separate service values over one bucket,
because that is what makes it able to fail: with the old code every caller
inside one process was already serialised, and only a second node could lose an
update.
2026-09-17 13:53:47 +10:00
Josh Siviour fd22c518bf fix: bound the KV bucket listing so a stuck DNS reconcile cannot wedge the cluster (#1071)
kvutil.BucketNames ranged the KeyValueStoreNames lister channel using whatever context the caller supplied, and the EKS desired-set builder supplies one that lives as long as the daemon. A lost stream-names reply therefore left the channel open with nothing ever sent on it and parked the caller for the life of the process. kvutil.Keys, immediately below it, already bounds exactly this case and says why; BucketNames never got the same treatment.

The DNS reconcile made that hang cluster-wide. A pass takes the reconcile lease before computing its batch and releases it on a deferred call, and the lease renews on a background timer for as long as the holder lives, so a pass that never returns holds it forever and the other nodes never take over. Observed on a three-node cluster: the zone object stopped being written after a rolling deploy, and every name published afterwards stayed NXDOMAIN until the holder was restarted.

The listing now carries its own deadline and reports a timeout as an error, never as a short list, because callers prune on absence. The pass bounds the batch computation as well, so any other deadline-free call reached from the desired-set builders costs a cycle rather than the cluster. Only the read side is bounded: it has no side effects, so abandoning it is safe, while the apply stays inline under the lease.
2026-09-17 13:53:23 +10:00
Josh Siviour 4a9dda0b99 fix(ecs): honour ListTasks filters and count instance tasks by status (#1062)
ListTasks read only the cluster and returned every task record under it, discarding DesiredStatus, Family, ServiceName, StartedBy and ContainerInstance. On a cluster with one running task and a hundred stopped ones, both status filters returned the same hundred-and-one ARNs.

A taskFilter now carries the input's matchable fields and is applied per record. An omitted desiredStatus means RUNNING, as it does on AWS, so an unfiltered call no longer hands back everything the cluster has ever run.

recordTaskState moves a self-exited task's desired status to STOPPED alongside its last status. Without that the filter would be honoured and still wrong: a task that died on its own would answer the RUNNING filter for as long as its record survived.

Container-instance counts came from len(PlacedTasks), which is the set of tasks still owing capacity rather than a status tally, so a PENDING task counted as running and a task whose release did not complete counted as running forever. PendingTasksCount was never set at all. Both are now tallied from the task records, the way the cluster-level counts already were. DeregisterContainerInstance passes zero deliberately — the instance is gone, and counting there would report the tasks it just force-stopped.

launchType is not filtered because TaskRecord has no launch-type field to match on, and maxResults/nextToken are left alone because no List call in this package paginates; adding a token scheme to one would leave the other four inconsistent.
2026-09-17 13:48:59 +10:00
Josh Siviour c10b6732d5 feat: publish AWS service endpoint names in DNS and match gateway cert SANs (#1068)
* feat: publish AWS service endpoint names in DNS and match gateway cert SANs

ec2.<region>.spinifex.internal and its sibling AWS service names (sts,
elasticloadbalancing, ecs, eks, acm) NXDOMAINed because the internal
suffix was only ever a cert SAN and Host-header match, never a
northstar zone. BootstrapBaseZone now seeds it as a third zone, guarded
three-way so a suffix colliding with the default or internal domain
seeds no duplicate.

The reconciler publishes each name as a set-valued A record spanning
every cluster node's own gateway address, since a service endpoint's
natural target is every node rather than one resource address that
answers the same from anywhere. ConfigArr.SetRecordSet backs this in
northstar; UpsertRecord's single-record semantics are untouched for
EC2/ELB/EKS/RDS. A non-IP gateway host is skipped rather than written
as a malformed record, and prune authority for the new class is scoped
to its own zone so it can never act on the base or private zone.

admin.AWSGWServiceDNSNames and handlers/dns.ServiceEndpointNames both
consume config.AWSGWServiceNames, so the cert SANs and the DNS records
they must match can never drift apart.

Depends on github.com/mulgadc/northstar#31 (ConfigArr.SetRecordSet);
go.mod pins that branch's commit until it merges and tags.

* refactor(config): rename AWS.InternalSuffix to AWS.ServicesDomain

ServicesDomain names the value IMDS serves at
/latest/meta-data/services/domain, the slot an AWS SDK substitutes
into {service}.{region}.{domain} for default endpoint resolution.
The old name read as a sibling of Northstar's compute.internal while
meaning something entirely different, which invited confusion.

The default changes from spinifex.internal to services.internal, role-
named like compute.internal (per-instance hostnames) now that
services.internal holds service endpoints instead.

Deployed clusters have internal_suffix baked into spinifex.toml and
their gateway cert SANs built from it, so LoadConfig now reads
services_domain, falls back to the legacy internal_suffix key when
that is unset, and only then applies the new default. Every consumer
of the field and the renamed DefaultAWSServicesDomain constant is
updated accordingly, including the spx admin init template and the
e2e test harness's own config loader.

* chore: pin northstar to the merged set-valued write path
2026-09-17 13:46:33 +10:00
Tom Newton c4b380b2ed chore: update cisco docs 2026-09-17 13:28:30 +10:00
Josh Siviour ef9bfb4c1d fix(gateway): resolve DescribeRegions endpoint from the gateway's advertised host (#1066)
* fix(gateway): resolve DescribeRegions endpoint from the gateway's advertised host

DescribeRegions hardcoded https://localhost:9999 as the Region's endpoint
regardless of where the gateway actually runs, so a workload doing endpoint
discovery through this API was pointed at its own loopback instead of a
reachable address. The endpoint is now resolved by the caller from
GatewayConfig.RegistryHost/RegistryPort, the same advertised host:port ECR
already uses (concrete AWSGW bind host, else AdvertiseIP), and passed into
DescribeRegions rather than invented there. Falls back to localhost:9999
only when no concrete host is configured, which is also the only case where
that value is actually correct.

Closes mulga-0bqih.

* style: trim DescribeRegions endpoint comments
2026-09-17 13:28:08 +10:00
Josh Siviour 969163c114 feat(imds): serve block-device-mapping from instance block device data (#1067)
* feat(imds): serve block-device-mapping from instance block device data

The aws-ebs-csi-driver's IMDS client fails outright on this 404,
aborting IMDSInstanceInfo before it reaches the Kubernetes fallback the
driver otherwise uses. Serve ami, root, and one ebsN per attached EBS
volume from the instance's existing BlockDeviceMappings, root device
first, matching how the driver counts "ebs" occurrences in the listing.

* style: trim block-device-mapping comments
2026-09-17 13:27:46 +10:00
Julian Sommer ff41bcf369 fix: read only the named documents on explicit-ID describes (#1069) 2026-09-17 12:29:35 +10:00
Julian Sommer 5991b92b06 chore: concurrent preflight 2026-09-17 11:31:42 +10:00
Julian Sommer 5ff54ea110 refactor: route the last hand-rolled KV codecs and CAS loops through kvstore (#1061) 2026-09-17 10:45:11 +10:00
Julian Sommer 3a51646e1c chore: disable wastedassign linter 2026-09-17 10:42:18 +10:00
Josh Siviour 809ec5f1fe fix(vpc): accept security-group rules carrying an IPv6 range (#1060)
AuthorizeSecurityGroupIngress and AuthorizeSecurityGroupEgress rejected any
IpPermission carrying an Ipv6Ranges entry. The security-group module defaults
egress_ipv6_cidr_blocks to ::/0 and the provider revokes the group's own
default egress before authorizing the replacement, so the rejection left the
group with no egress rules at all, which is treated as deny.

SGRule gains CidrIpv6. The rule is stored, deduped, revoked and projected like
any other, and the two policy.Rule adapters drop it before the ACL builder,
which is IPv4-only. No interface here has an IPv6 address, so the rule matches
nothing - which is what an IPv6 rule does on AWS in a VPC with no IPv6 CIDR.

Revoking an IPv6 rule that does not exist now returns InvalidPermission.NotFound
rather than succeeding as a no-op. The no-op existed only because authorize
refused to store the rule, so a caller's state could never match.

Closes mulga-6bu36.
2026-09-17 10:33:43 +10:00
Josh Siviour ead2f2fe6c fix(ecs): keep a stopped task's ENI attachment after its release (#1059)
DescribeTasks returned an empty attachments array for every STOPPED awsvpc task. On env19, 134 of 135 tasks in a cluster reported attachments: [], and only the one PENDING task carried an ElasticNetworkInterface block. On AWS a stopped task keeps its attachment for the retention window, with status DELETED and the networkInterfaceId still readable, because it is part of the forensic record of a task that has stopped.

taskToAWS gates the attachment on ENIID being set, and the ENI release retry had made a successful release clear that field so a non-empty ENIID would mean "still owed" and the sweep could retry from it. The signal was right; carrying it in the emptiness of ENIID was not, because the same field is the projection's only source.

The state moves to an ENIReleased flag of its own. ENIID stays put, so the attachment survives as long as the record does, and the sweep's owed test, its refusal to prune, and the placement rollback all read the flag instead. A released record ages out on retention exactly as it did before the retry landed. The attachment reports DELETED once released rather than echoing the task's own status.

This also restores the evidence path the ENI-leak probe depends on: it follows a stopped task to its ENI, and with no attachment to follow it had been reporting skipped on every run.

Closes mulga-5bnx3.
2026-09-17 07:56:45 +10:00
Josh Siviour 0fd6848fbd fix(ecs): retry an owed awsvpc task ENI release (#1058)
Releasing an awsvpc task ENI was best effort: reclaimTaskENI logged a failure and returned, and nothing ever tried again. Its own comment said the ENI fields stayed on the record for a later retry, but no later retry existed, and the stopped-task sweep pruned the record an hour later without looking at those fields — deleting the only pointer to the leaked interface. An orphaned task ENI still references its security group, so DeleteSecurityGroup fails with DependencyViolation and no awsvpc stack can be destroyed without deleting the ENIs by hand.

A non-empty ENIID on a stopped record now means exactly one thing: this ENI is still owed. reclaimTaskENI clears the identity when Release succeeds and leaves it when it does not, forceStopTask reclaims before its single write so the cleared fields land in it, and the stopped-task sweep gains the job of paying the debt off — retrying with a backoff that doubles from 30s to a 15m ceiling, capped at twenty attempts per pass because Release is a 30s NATS round trip and a bucket full of them would stall the sweep for the whole fleet.

A record that still owes an ENI is never pruned, however far past retention it sits. It is the only pointer to the interface, so dropping it converts a recoverable leak into a permanent one.

The placement rollback had the same hole from the other end: an attach failure cleared the ENI fields unconditionally and RunTask never persisted the record, so a failed release there leaked with no trace at all. It now clears only when the release succeeded, and otherwise persists the record as STOPPED so the sweep owns it like any other.

Closes mulga-1ysjx.
2026-09-16 23:46:21 +10:00
Josh Siviour 208a108c27 fix(ec2): render image CreationDate as UTC (#1057)
An AMI imported on a node whose timezone is not UTC was stamped with local wall-clock time carrying a Z suffix, so on a +1000 node an 11:42Z import reported 21:42Z — ten hours in the future. The layout "2006-01-02T15:04:05.000Z" treats the trailing Z as a literal character rather than a zone directive, so Format printed whatever zone the value carried and then asserted UTC regardless.

DescribeImages now converts before formatting, which also corrects records already stored, since the stored time.Time still carries its original zone. The four sites that stamp a new image now store time.Now().UTC() as well, so the value is right on both sides of the round trip.

Terraform's most_recent sorts on this field, so before this an AMI imported on a +1000 node outranked a genuinely newer one imported on a UTC node for ten hours.

Closes mulga-il7w1.
2026-09-16 23:45:40 +10:00
Josh Siviour e2ca21a18d feat: report availabilityZoneRebalancing on ECS services (#1055)
DescribeServices returned no availabilityZoneRebalancing field, so the terraform provider read the computed attribute as empty and planned an in-place update to DISABLED on every plan, including one taken immediately after a clean apply.

The pinned aws-sdk-go v1.55.8 is the last v1 release and its ECS shapes predate the field, so it can be neither parsed from an input nor set on an output through the SDK types. Embedding ecs.Service in a wrapper does not work either: jsonutil nests an embedded struct rather than flattening it, so the service arrives under its own key.

The field is read off the raw request body and written onto the encoded response instead. checkAZRebalancing refuses anything but DISABLED with InvalidParameterException, because nothing here rebalances tasks across zones and a silently dropped ENABLED would report a service as rebalancing when nothing does. addSDKGapFields runs from WriteJSONResponse so every service-carrying action is covered from one place, decodes with UseNumber to keep the epoch-second times jsonutil emitted, and leaves a value already present alone.

Closes mulga-t239t.
2026-09-16 23:44:52 +10:00
Josh Siviour ca5ff7695e fix(ecs-agent): allocate a console for a pseudoTerminal container (#1054)
oci.WithTTY put Terminal in the OCI spec while the task was created with a cio
creator that asked for no terminal, so the shim made no console socket and runc
refused to start the container at all. The terminal flag now reaches the FIFO
set, and a terminal carries no separate stderr stream.
2026-09-16 23:44:31 +10:00
Josh Siviour 1ab61b0dd5 fix(ecs): enforce or refuse container runtime fields (TFC-0040) (#1053)
* fix(ecs): enforce or refuse container runtime fields (TFC-0040)

user, mountPoints, volumesFrom, readonlyRootFilesystem, privileged,
pseudoTerminal, interactive, linuxParameters, systemControls,
startTimeout and stopTimeout registered successfully but were never
stored, sent to the agent, enforced, or echoed back on describe.

Store and echo the fields the agent can enforce (user, readonly root
filesystem, privileged, pseudo terminal, interactive, systemControls,
capability add/drop, start/stop timeout), apply them through containerd
via OCI spec options and a graceful Stop, and refuse at
RegisterTaskDefinition the fields the agent cannot honor (mountPoints,
volumesFrom, linuxParameters.devices/initProcessEnabled/
sharedMemorySize/tmpfs). Refusal only triggers on a value that asks
for the restriction; empty lists and false/unset flags are accepted
and echoed as before.

* test: move the bus wire-contract test to an external package

* fix(ecs-agent): release an interactive container's stdin so its copier exits

* fix(ecs): return a caller-supplied empty collection instead of omitting it
2026-09-16 21:23:06 +10:00
Josh Siviour 6c86dd5f34 fix(ec2): validate and honour a requested private IP address (#1050)
CreateNetworkInterface accepted a caller-supplied PrivateIpAddress
verbatim, with no subnet-range check and no IPAM reservation, so a
direct call could collide with a concurrent AllocateIP handing out the
same address. Add IPAM.ClaimIP, a CAS-guarded sibling of AllocateIP
that validates the address against the subnet and rejects it if
already allocated, and wire it into CreateNetworkInterface with the
matching AWS error codes.

RunInstances --private-ip-address silently returned a different,
pool-assigned address instead of honouring the request. Forward
PrivateIpAddress (including the NetworkInterfaces[].PrivateIpAddresses
primary entry) into the auto-created ENI's CreateNetworkInterfaceInput,
and reject a pinned address on a launch with MaxCount > 1 since AWS
only allows it for a single instance.
2026-09-16 21:22:26 +10:00
Josh Siviour d632b607ff fix: decompress single-file zst/bz2/xz/gz disk images (#1048)
* fix: decompress single-file zst/bz2/xz/gz disk images into tmpdir

ExtractDiskImageFromFile only handled tar archives and a broken plain
.xz branch that ran xz -dk beside the source and then tried to parse
tar chatter for a path, so it silently returned an empty string. Talos
(.raw.zst, .raw.xz), Fedora CoreOS (.qcow2.xz, .raw.xz) and Flatcar
(.img.bz2) publish only single-file compressed artifacts, so none of
them could be imported.

Add streaming decompression for .zst, .bz2, .xz and .gz that writes
into the caller's tmpdir and returns that path directly, instead of
inferring it from tar output. A decompressed .qcow2 payload now flows
through the existing qcow2-to-raw qemu-img conversion via a shared
finalizeDiskImage helper. zstd uses klauspost/compress, already a
transitive dependency via containerd, for a pure-Go streaming decoder.
bz2 and gzip use the standard library. xz shells out to the xz binary
since no pure-Go xz decoder is already a dependency, and the existing
.tar.xz branch already requires that binary on the host via tar's -J
flag, so this adds no new host requirement.

* fix: write sparse holes when decompressing single-file disk images

Verified live on env13: a Talos raw.zst imported successfully but the
decompressed output was written literally rather than sparsely, an
11 GiB file on disk for content that decompresses to 183 MiB with
xz -dc | dd conv=sparse. This is a regression relative to the
qcow2-to-raw path beside it, which already writes sparse via
qemu-img convert.

decompressToTemp now reads in 1 MiB blocks, seeks past all-zero
blocks instead of writing them, and truncates to the final size so a
trailing zero run is still recorded as a hole. 1 MiB amortises the
read/seek syscall overhead over a multi-GiB image while still being
small enough to catch the large contiguous zero runs that dominate a
sparse raw disk; a zero run shorter than one block is written
literally, which is the safe direction for a false negative to fail
in.

zstd, bzip2 and gzip all route through decompressToTemp, so all three
gain sparse output from this change. The plain-.xz path shells out to
the xz binary with output piped straight to a file and does not go
through decompressToTemp, so it is unaffected by this fix and remains
a known follow-up.

* fix: make plain-.xz decompression sparse too

decompressXz shelled its output straight to a file, bypassing the
sparse-hole handling just added to decompressToTemp for zstd, bzip2
and gzip. .raw.xz is the format Talos and Flatcar actually publish
alongside .zst, so this path is the one most likely hit in practice,
and left dense the exact disk-size regression this fix targets still
fires for it.

exec.Cmd already runs its own copier goroutine when Stdout is not an
*os.File, and Wait joins it. decompressXz now hands the xz process an
io.Pipe write end and reuses decompressToTemp on the read end, so
plain .xz gets identical sparse-hole handling to the other codecs. A
separate goroutine closes the pipe with the process's exit error once
Wait returns, so a read past the end of a failed decompression
surfaces that error, stderr included, instead of a bare EOF; the
error path also closes the read end explicitly so a stuck copier
cannot wedge Wait forever if decompressToTemp gives up early.
2026-09-16 16:54:51 +10:00
Josh Siviour 68eeff069e feat: project EKS access policies into Kubernetes RBAC (#1049)
AssociateAccessPolicy recorded a policy association but never granted
it in the cluster. Bake static ClusterRoleBindings for the mulga:
groups into the eks-node image, project a principal's cluster-scoped
associated policies onto those groups in the TokenReview webhook, and
reject namespace scope at association time since there is no
namespace-scoped RBAC writer.
2026-09-16 16:45:24 +10:00
Julian Sommer 0c83f7f954 refactor: use strings.CutPrefix/CutSuffix for guard-then-trim pairs 2026-09-16 16:18:33 +10:00
Josh Siviour 4b35de1409 feat(iam): evaluate trust-policy conditions, accept aws:SourceAccount (#1051)
* feat(iam): evaluate trust-policy conditions and accept aws:SourceAccount

evalTrustPolicy read Effect, Action and Principal but never Condition, so a
stored aws:SourceAccount clause was a control nothing enforced. Give it a
condition arm evaluated in both the Deny and Allow passes, following the
StringEquals-only, fail-closed-on-unknown-key shape
evalTrustPolicyForWebIdentity already uses. The value is the caller ARN's own
account, derived server-side rather than caller-asserted.

Relax validateWebIdentityCondition from a single-action gate to a per-action
allowlist: sts:AssumeRoleWithWebIdentity keeps its existing IRSA keys, and
sts:AssumeRole now accepts aws:SourceAccount under StringEquals. Everything
else still fails at write time, naming the operator, key and action rather
than a bare MalformedPolicyDocument, and CreateRole/UpdateAssumeRolePolicy now
carry that reason to the caller instead of swallowing it into slog.Debug.

Extend the identity-policy refusal at validateStatementRestrictions with the
reason for aws:SourceArn and aws:RequestTag/*, the two keys a companion change
records as divergences in rfc0-7_aws-divergence.md.

* test(iam): fix trust-policy reason tests after aws:SourceAccount now valid

PR #1046 merged into dev with fixtures asserting aws:SourceAccount plus
StringEquals on sts:AssumeRole is refused. Part B of mulga-yxd9p makes
that combination valid, so the fixtures now exercise an unsupported
action and an unsupported key instead.

* docs: drop bead and plan-decision references from test comments
2026-09-16 15:34:23 +10:00
Julian Sommer 7474fa2073 chore: use native loadx509KeyPair 2026-09-16 15:33:57 +10:00
Julian Sommer 95035a1754 chore: use go-toml instead of handparsed 2026-09-16 15:31:58 +10:00
Julian Sommer 9bc3ea8658 refactor: use crypto/ssh for key gen 2026-09-16 15:24:45 +10:00
Julian Sommer 2dfc9e1efb refactor: route the REST-JSON gateways through chi instead of regex tables 2026-09-16 15:15:16 +10:00
Josh Siviour 578185f533 feat: supply the aws:PrincipalType condition key at the AWS gateway door (#1052)
Bump bluebottle for the new key. requestConditionKeys now maps this
door's internal principal-type spelling to the canonical AWS value
through the bluebottle constants, and omits the key for an
unrecognized type rather than supplying an empty string.
2026-09-16 14:36:46 +10:00
Josh Siviour 4eee02fcfd fix: carry the trust-policy validation reason to the caller (#1046)
ValidateTrustPolicyDocument builds precise errors naming the statement index
and the offending element, and both callers discarded them into slog.Debug
and returned a bare MalformedPolicyDocument. Debug is below the default
level, so the reason reached nobody: not the caller, and not the operator
reading the gateway log.

The contrast with the identity-policy path was stark. CreatePolicy names the
operator and the key exactly; CreateRole answered only 'The policy document
is malformed.', so an operator could not tell whether Principal, Action, a
size limit or a Condition block was at fault. The trust gate is the one the
terraform ECS module hits, which is what made this worth fixing on its own.

Both sites now use awserrors.Errorf, the same mechanism the identity path
uses. The separate Debug line is dropped rather than duplicated: the gateway
already logs a returned error at Error level, so one message now serves both
the client and the operator.
2026-09-16 14:12:09 +10:00
Josh Siviour 062880687d feat: return ECS service events, and project the service and deployment timestamps (#1047)
* feat(ecs): populate service event ring on DescribeServices

Events was always projected as null since ServiceRecord carried no event
history. Add a capped, newest-first event ring populated at the rollout
transitions the reconciler already computes: steady state reached, a
circuit-breaker trip, and a placement shortfall it cannot resolve this pass.
Emission is edge-triggered on the state change already guarding each site, so
the repeated reconcile loop does not refill the ring with one repeated
message every pass.

* feat(ecs): project createdAt on services and deployments

CreatedAt was already computed and persisted on ServiceRecord and Deployment
at creation time, but serviceToAWS never echoed it, so DescribeServices could
never answer which build wrote a given record. Project ServiceRecord.CreatedAt
and Deployment.CreatedAt/UpdatedAt, omitting the key entirely for a legacy
record whose timestamp predates the field rather than emitting the 1970 epoch.
2026-09-16 14:01:53 +10:00
Josh Siviour bb8c639c82 fix(eks): accept API_AND_CONFIG_MAP and always return nodegroup health (#1045)
CreateCluster refused accessConfig.authenticationMode = API_AND_CONFIG_MAP,
which is both AWS's default since 1.23 and the terraform-aws-eks module
default, and named no parameter in the refusal. The mode is now stored on
the cluster record and echoed by DescribeCluster, so a caller does not see
a permanent access_config diff on every subsequent plan; bare CONFIG_MAP
still fails, since Spinifex has no aws-auth ConfigMap path. The refusal
now names accessConfig.authenticationMode via awserrors.Errorf.

nodegroupRecordToAWS also only set Health when a node group had a status
reason, so a healthy node group described back with health: null instead
of the empty-issues object AWS always returns. Health is now always
constructed with a non-nil issues slice.
2026-09-16 14:01:29 +10:00
Josh Siviour de295b3f15 fix: return InvalidAction and the correct envelope for an unserved service (#1044)
The unserved-service gate ran before signature verification but answered
SignatureDoesNotMatch, falsely blaming credentials that were never checked.
It now returns InvalidAction, the same code an unimplemented action on a
served service already gets, so the behaviour is consistent rather than
novel.

The envelope was also always XML, because jsonErrorService only lists
scopes the gateway serves and an unserved scope is by definition absent
from it. A JSON-protocol caller such as sqs or dynamodb therefore could
not deserialize the body and never saw the true error. writeSigV4Error now
falls back to the request's own X-Amz-Target and Content-Type headers to
pick JSON when the scope is not in that list, leaving every served scope's
envelope selection unchanged.
2026-09-16 14:01:13 +10:00
1666 changed files with 80924 additions and 15155 deletions
@@ -0,0 +1,79 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1020" height="836" viewBox="0 0 1020 836" role="img" aria-label="Spinifex on a single Exoscale instance: the instance sits on the zone&#x27;s public network with one interface, eth0, holding its own address and the default route. Manual Elastic IPs are delivered to eth0 and routed by the host into OVN through the spx-nat transit veth, and an Exoscale block storage volume backs /var/lib/spinifex.">
<rect x="0" y="0" width="1020" height="836" rx="20" fill="#14181D" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
<text x="28" y="42" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11.5" fill="#EE4B12" font-weight="600" text-anchor="start" letter-spacing="2.5">SPINIFEX ON EXOSCALE — SINGLE NODE</text>
<text x="992" y="42" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11.5" fill="#9AA1A8" font-weight="400" text-anchor="end">one instance · routed NAT · Elastic IPs</text>
<text x="28" y="72" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="23" fill="#F5F3EE" font-weight="800" text-anchor="start">One Exoscale instance, single Spinifex node</text>
<rect x="28" y="92" width="964" height="76" rx="13" fill="#181D23" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
<text x="48" y="122" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="12" fill="#9AA1A8" font-weight="700" text-anchor="start" letter-spacing="1.5">INTERNET</text>
<text x="972" y="122" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#5B636C" font-weight="400" text-anchor="end">AWS SDKs, SSH and guest egress all arrive here</text>
<rect x="46" y="126" width="260.0" height="22" rx="7" fill="none" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
<text x="176.0" y="140.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#9AA1A8" font-weight="400" text-anchor="middle">aws-cli / Terraform / boto3 → :9999</text>
<rect x="315.0" y="126" width="171.6" height="22" rx="7" fill="none" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
<text x="400.8" y="140.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#9AA1A8" font-weight="400" text-anchor="middle">ssh → guest Elastic IP</text>
<rect x="495.6" y="126" width="205.6" height="22" rx="7" fill="none" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
<text x="598.4" y="140.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#9AA1A8" font-weight="400" text-anchor="middle">guest egress ← same address</text>
<line x1="510" y1="170" x2="510" y2="186" stroke="#EE4B12" stroke-width="1.3" stroke-opacity="0.55" stroke-dasharray="2 4"/>
<rect x="28" y="188" width="964" height="582" rx="16" fill="#EE4B12" fill-opacity="0.022" stroke="#EE4B12" stroke-width="1.4" stroke-opacity="0.45" stroke-dasharray="6 5"/>
<text x="50" y="218" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11.5" fill="#EE4B12" font-weight="600" text-anchor="start" letter-spacing="2">EXOSCALE ZONE DE-FRA-1 · PUBLIC NETWORK 91.92.116.0/23</text>
<text x="970" y="218" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#9AA1A8" font-weight="400" text-anchor="end">gateway 91.92.116.1 · security group open to all</text>
<rect x="48" y="232" width="924" height="520" rx="14" fill="#EE4B12" fill-opacity="0.05" stroke="#EE4B12" stroke-width="1.4" stroke-opacity="0.5"/>
<text x="68" y="266" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="16" fill="#F5F3EE" font-weight="800" text-anchor="start">standard.huge</text>
<text x="212" y="266" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="12" fill="#9AA1A8" font-weight="400" text-anchor="start">spinifex-node-01 · 8 vCPU / 32 GiB · Ubuntu 26.04</text>
<text x="952" y="266" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#5B636C" font-weight="400" text-anchor="end">one host = one Spinifex region</text>
<line x1="68" y1="282" x2="952" y2="282" stroke="#EE4B12" stroke-opacity="0.25" stroke-width="1"/>
<rect x="68" y="298" width="434" height="108" rx="13" fill="#181D23" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
<text x="88" y="328" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="12" fill="#9AA1A8" font-weight="700" text-anchor="start" letter-spacing="1.5">eth0 — PUBLIC INTERFACE</text>
<text x="482" y="328" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#5B636C" font-weight="400" text-anchor="end">the only NIC</text>
<text x="88" y="356" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="12" fill="#F3F1EC" font-weight="400" text-anchor="start">91.92.116.250/23 · holds the default route</text>
<text x="88" y="378" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="12" fill="#5B636C" font-weight="400" text-anchor="start">advertise IP · API :9999 · SSH · host masquerade</text>
<rect x="518" y="298" width="434" height="108" rx="13" fill="#181D23" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
<text x="538" y="328" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="12" fill="#9AA1A8" font-weight="700" text-anchor="start" letter-spacing="1.5">ELASTIC IPs — MANUAL</text>
<text x="932" y="328" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#5B636C" font-weight="400" text-anchor="end">attached to the instance</text>
<text x="538" y="356" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="12" fill="#F3F1EC" font-weight="400" text-anchor="start">89.145.163.1/32 → delivered to eth0</text>
<text x="538" y="378" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="12" fill="#5B636C" font-weight="400" text-anchor="start">the host routes each /32 into OVN, never owns it</text>
<line x1="735" y1="406" x2="735" y2="422" stroke="#EE4B12" stroke-width="1.3" stroke-opacity="0.55" stroke-dasharray="2 4"/>
<rect x="68" y="424" width="884" height="40" rx="11" fill="#1E242B" fill-opacity="1.0" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.45"/>
<text x="88" y="449" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="12" fill="#EE4B12" font-weight="600" text-anchor="start">spx-nat-host ⇄ spx-nat-ovs</text>
<text x="296" y="449" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="12" fill="#9AA1A8" font-weight="400" text-anchor="start">transit 100.127.0.0/24 · EIP/32 routed in · masquerade out eth0 for the rest</text>
<line x1="510" y1="464" x2="510" y2="480" stroke="#EE4B12" stroke-width="1.3" stroke-opacity="0.55" stroke-dasharray="2 4"/>
<rect x="68" y="482" width="884" height="100" rx="13" fill="#181D23" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
<text x="88" y="512" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="12" fill="#9AA1A8" font-weight="700" text-anchor="start" letter-spacing="1.5">SPINIFEX CONTROL PLANE</text>
<text x="932" y="512" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#5B636C" font-weight="400" text-anchor="end">every service is one binary: spx service &lt;name&gt;</text>
<rect x="86" y="530" width="93.0" height="22" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
<text x="132.5" y="544.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#F3F1EC" font-weight="400" text-anchor="middle">awsgw :9999</text>
<rect x="188.0" y="530" width="58.0" height="22" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
<text x="217.0" y="544.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#F3F1EC" font-weight="400" text-anchor="middle">daemon</text>
<rect x="255.0" y="530" width="44.0" height="22" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
<text x="277.0" y="544.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#F3F1EC" font-weight="400" text-anchor="middle">vpcd</text>
<rect x="308.0" y="530" width="44.0" height="22" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
<text x="330.0" y="544.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#F3F1EC" font-weight="400" text-anchor="middle">NATS</text>
<rect x="361.0" y="530" width="86.0" height="22" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
<text x="404.0" y="544.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#F3F1EC" font-weight="400" text-anchor="middle">predastore</text>
<rect x="456.0" y="530" width="86.0" height="22" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
<text x="499.0" y="544.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#F3F1EC" font-weight="400" text-anchor="middle">viperblock</text>
<rect x="551.0" y="530" width="79.0" height="22" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
<text x="590.5" y="544.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#F3F1EC" font-weight="400" text-anchor="middle">northstar</text>
<rect x="639.0" y="530" width="72.0" height="22" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
<text x="675.0" y="544.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#F3F1EC" font-weight="400" text-anchor="middle">ui :3000</text>
<text x="86" y="568" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="12" fill="#5B636C" font-weight="400" text-anchor="start">SigV4 in, NATS in the middle, QEMU/KVM (nested) · OVN/OVS · Predastore · Viperblock underneath</text>
<line x1="248" y1="582" x2="248" y2="598" stroke="#EE4B12" stroke-width="1.3" stroke-opacity="0.4" stroke-dasharray="2 4"/>
<line x1="772" y1="582" x2="772" y2="598" stroke="#EE4B12" stroke-width="1.3" stroke-opacity="0.4" stroke-dasharray="2 4"/>
<rect x="68" y="600" width="532" height="130" rx="13" fill="#181D23" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
<text x="88" y="630" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="12" fill="#9AA1A8" font-weight="700" text-anchor="start" letter-spacing="1.5">TENANT VPCs — OVN br-int</text>
<text x="580" y="630" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#5B636C" font-weight="400" text-anchor="end">geneve-local</text>
<rect x="86" y="644" width="496" height="34" rx="9" fill="none" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
<text x="100" y="666" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11.5" fill="#F3F1EC" font-weight="400" text-anchor="start">vpc-a 172.31.0.0/16</text>
<text x="300" y="666" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11.5" fill="#9AA1A8" font-weight="400" text-anchor="start">i-… 172.31.0.4 → 89.145.163.1</text>
<rect x="86" y="686" width="496" height="34" rx="9" fill="none" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
<text x="100" y="708" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11.5" fill="#F3F1EC" font-weight="400" text-anchor="start">vpc-b 10.0.0.0/16</text>
<text x="300" y="708" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11.5" fill="#9AA1A8" font-weight="400" text-anchor="start">i-… 10.0.1.9 · private, egress only</text>
<rect x="616" y="600" width="336" height="130" rx="13" fill="#181D23" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
<text x="636" y="630" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="12" fill="#9AA1A8" font-weight="700" text-anchor="start" letter-spacing="1.5">BLOCK STORAGE VOLUME</text>
<text x="932" y="630" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#5B636C" font-weight="400" text-anchor="end">256 GiB+</text>
<text x="636" y="658" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11.5" fill="#F3F1EC" font-weight="400" text-anchor="start">virtio-blk → /dev/vdb</text>
<text x="636" y="680" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11.5" fill="#F3F1EC" font-weight="400" text-anchor="start">/var/lib/spinifex</text>
<text x="636" y="700" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="11.5" fill="#5B636C" font-weight="400" text-anchor="start">found by-id, defaults,nofail</text>
<text x="636" y="718" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="11.5" fill="#5B636C" font-weight="400" text-anchor="start">viperblock · predastore · JetStream</text>
<text x="28" y="796" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="12" fill="#5B636C" font-weight="400" text-anchor="start">A guest&#x27;s public address is a manual Exoscale Elastic IP, delivered to eth0 addressed to itself — no private/public pair to translate.</text>
<text x="28" y="814" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="12" fill="#5B636C" font-weight="400" text-anchor="start">The host routes it into OVN, so inbound and outbound are the same address and the guest sees its private IP only.</text>
</svg>

After

Width:  |  Height:  |  Size: 14 KiB

@@ -0,0 +1,123 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1020" height="796" viewBox="0 0 1020 796" role="img" aria-label="Spinifex across three Exoscale instances in one zone, spread by an anti-affinity group. Each has a public eth0 carrying its own Elastic IPs, a private-network eth1 for the cluster mesh, its own block storage volume and its share of the guests. Geneve, NATS JetStream, predastore erasure coding and the OVN raft join them east-west over the private network. This topology is not yet tested.">
<rect x="0" y="0" width="1020" height="796" rx="20" fill="#14181D" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
<text x="28" y="42" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11.5" fill="#EE4B12" font-weight="600" text-anchor="start" letter-spacing="2.5">SPINIFEX ON EXOSCALE — THREE NODES</text>
<text x="992" y="42" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11.5" fill="#9AA1A8" font-weight="400" text-anchor="end">anti-affinity group · one zone · not yet tested</text>
<text x="28" y="72" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="23" fill="#F5F3EE" font-weight="800" text-anchor="start">Three Exoscale instances, redundant multi-node install</text>
<rect x="28" y="92" width="964" height="76" rx="13" fill="#181D23" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
<text x="48" y="122" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="12" fill="#9AA1A8" font-weight="700" text-anchor="start" letter-spacing="1.5">INTERNET</text>
<text x="972" y="122" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#5B636C" font-weight="400" text-anchor="end">one endpoint, whichever node answers</text>
<rect x="46" y="126" width="260.0" height="22" rx="7" fill="none" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
<text x="176.0" y="140.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#9AA1A8" font-weight="400" text-anchor="middle">aws-cli / Terraform / boto3 → :9999</text>
<rect x="315.0" y="126" width="171.6" height="22" rx="7" fill="none" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
<text x="400.8" y="140.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#9AA1A8" font-weight="400" text-anchor="middle">ssh → guest Elastic IP</text>
<rect x="495.6" y="126" width="205.6" height="22" rx="7" fill="none" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
<text x="598.4" y="140.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#9AA1A8" font-weight="400" text-anchor="middle">guest egress ← same address</text>
<line x1="510" y1="170" x2="510" y2="186" stroke="#EE4B12" stroke-width="1.3" stroke-opacity="0.55" stroke-dasharray="2 4"/>
<rect x="28" y="188" width="964" height="440" rx="16" fill="#EE4B12" fill-opacity="0.022" stroke="#EE4B12" stroke-width="1.4" stroke-opacity="0.45" stroke-dasharray="6 5"/>
<text x="50" y="218" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11.5" fill="#EE4B12" font-weight="600" text-anchor="start" letter-spacing="2">EXOSCALE ZONE DE-FRA-1 · PUBLIC NETWORK + PRIVATE NETWORK</text>
<text x="970" y="218" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#9AA1A8" font-weight="400" text-anchor="end">one security group · one anti-affinity group</text>
<rect x="48" y="232" width="300" height="380" rx="14" fill="#EE4B12" fill-opacity="0.05" stroke="#EE4B12" stroke-width="1.4" stroke-opacity="0.5"/>
<text x="64" y="260" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="14" fill="#F5F3EE" font-weight="800" text-anchor="start">spinifex-node-01</text>
<text x="332" y="260" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#9AA1A8" font-weight="400" text-anchor="end">standard.huge</text>
<line x1="64" y1="272" x2="332" y2="272" stroke="#EE4B12" stroke-opacity="0.25" stroke-width="1"/>
<rect x="64" y="284" width="268" height="50" rx="10" fill="#181D23" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
<text x="78" y="305" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11.5" fill="#F3F1EC" font-weight="400" text-anchor="start">eth0 91.92.116.250</text>
<text x="78" y="323" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="10.5" fill="#5B636C" font-weight="400" text-anchor="start">public · default route · API</text>
<rect x="64" y="342" width="268" height="50" rx="10" fill="#181D23" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
<text x="78" y="363" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11.5" fill="#F3F1EC" font-weight="400" text-anchor="start">eth1 10.10.0.11</text>
<text x="78" y="381" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="10.5" fill="#5B636C" font-weight="400" text-anchor="start">private network · mesh · advertise · encap</text>
<rect x="64" y="400" width="268" height="50" rx="10" fill="#1E242B" fill-opacity="1.0" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.45"/>
<text x="78" y="421" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11.5" fill="#EE4B12" font-weight="600" text-anchor="start">EIP 89.145.163.1</text>
<text x="78" y="439" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="10.5" fill="#9AA1A8" font-weight="400" text-anchor="start">→ eth0 · /32 via spx-nat-host</text>
<rect x="64" y="462" width="51.0" height="22" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
<text x="89.5" y="476.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#F3F1EC" font-weight="400" text-anchor="middle">awsgw</text>
<rect x="124.0" y="462" width="57.6" height="22" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
<text x="152.8" y="476.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#F3F1EC" font-weight="400" text-anchor="middle">daemon</text>
<rect x="190.6" y="462" width="44.4" height="22" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
<text x="212.8" y="476.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#F3F1EC" font-weight="400" text-anchor="middle">vpcd</text>
<rect x="64" y="492" width="44.4" height="22" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
<text x="86.2" y="506.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#F3F1EC" font-weight="400" text-anchor="middle">nats</text>
<rect x="117.4" y="492" width="84.0" height="22" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
<text x="159.4" y="506.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#F3F1EC" font-weight="400" text-anchor="middle">predastore</text>
<rect x="210.4" y="492" width="84.0" height="22" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
<text x="252.4" y="506.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#F3F1EC" font-weight="400" text-anchor="middle">viperblock</text>
<rect x="64" y="526" width="268" height="34" rx="9" fill="none" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
<text x="76" y="548" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#9AA1A8" font-weight="400" text-anchor="start">i-23d2… 172.31.0.4 → 89.145.163.1</text>
<rect x="64" y="568" width="268" height="34" rx="9" fill="none" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
<text x="76" y="590" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#9AA1A8" font-weight="400" text-anchor="start">volume /dev/vdb → /var/lib/spinifex</text>
<rect x="360" y="232" width="300" height="380" rx="14" fill="#EE4B12" fill-opacity="0.05" stroke="#EE4B12" stroke-width="1.4" stroke-opacity="0.5"/>
<text x="376" y="260" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="14" fill="#F5F3EE" font-weight="800" text-anchor="start">spinifex-node-02</text>
<text x="644" y="260" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#9AA1A8" font-weight="400" text-anchor="end">standard.huge</text>
<line x1="376" y1="272" x2="644" y2="272" stroke="#EE4B12" stroke-opacity="0.25" stroke-width="1"/>
<rect x="376" y="284" width="268" height="50" rx="10" fill="#181D23" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
<text x="390" y="305" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11.5" fill="#F3F1EC" font-weight="400" text-anchor="start">eth0 91.92.117.14</text>
<text x="390" y="323" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="10.5" fill="#5B636C" font-weight="400" text-anchor="start">public · default route · API</text>
<rect x="376" y="342" width="268" height="50" rx="10" fill="#181D23" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
<text x="390" y="363" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11.5" fill="#F3F1EC" font-weight="400" text-anchor="start">eth1 10.10.0.12</text>
<text x="390" y="381" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="10.5" fill="#5B636C" font-weight="400" text-anchor="start">private network · mesh · advertise · encap</text>
<rect x="376" y="400" width="268" height="50" rx="10" fill="#1E242B" fill-opacity="1.0" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.45"/>
<text x="390" y="421" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11.5" fill="#EE4B12" font-weight="600" text-anchor="start">EIP 89.145.160.107</text>
<text x="390" y="439" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="10.5" fill="#9AA1A8" font-weight="400" text-anchor="start">→ eth0 · /32 via spx-nat-host</text>
<rect x="376" y="462" width="51.0" height="22" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
<text x="401.5" y="476.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#F3F1EC" font-weight="400" text-anchor="middle">awsgw</text>
<rect x="436.0" y="462" width="57.6" height="22" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
<text x="464.8" y="476.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#F3F1EC" font-weight="400" text-anchor="middle">daemon</text>
<rect x="502.6" y="462" width="44.4" height="22" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
<text x="524.8" y="476.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#F3F1EC" font-weight="400" text-anchor="middle">vpcd</text>
<rect x="376" y="492" width="44.4" height="22" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
<text x="398.2" y="506.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#F3F1EC" font-weight="400" text-anchor="middle">nats</text>
<rect x="429.4" y="492" width="84.0" height="22" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
<text x="471.4" y="506.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#F3F1EC" font-weight="400" text-anchor="middle">predastore</text>
<rect x="522.4" y="492" width="84.0" height="22" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
<text x="564.4" y="506.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#F3F1EC" font-weight="400" text-anchor="middle">viperblock</text>
<rect x="376" y="526" width="268" height="34" rx="9" fill="none" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
<text x="388" y="548" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#9AA1A8" font-weight="400" text-anchor="start">i-6b91… 172.31.0.9 → 89.145.160.107</text>
<rect x="376" y="568" width="268" height="34" rx="9" fill="none" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
<text x="388" y="590" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#9AA1A8" font-weight="400" text-anchor="start">volume /dev/vdb → /var/lib/spinifex</text>
<rect x="672" y="232" width="300" height="380" rx="14" fill="#EE4B12" fill-opacity="0.05" stroke="#EE4B12" stroke-width="1.4" stroke-opacity="0.5"/>
<text x="688" y="260" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="14" fill="#F5F3EE" font-weight="800" text-anchor="start">spinifex-node-03</text>
<text x="956" y="260" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#9AA1A8" font-weight="400" text-anchor="end">standard.huge</text>
<line x1="688" y1="272" x2="956" y2="272" stroke="#EE4B12" stroke-opacity="0.25" stroke-width="1"/>
<rect x="688" y="284" width="268" height="50" rx="10" fill="#181D23" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
<text x="702" y="305" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11.5" fill="#F3F1EC" font-weight="400" text-anchor="start">eth0 91.92.116.88</text>
<text x="702" y="323" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="10.5" fill="#5B636C" font-weight="400" text-anchor="start">public · default route · API</text>
<rect x="688" y="342" width="268" height="50" rx="10" fill="#181D23" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
<text x="702" y="363" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11.5" fill="#F3F1EC" font-weight="400" text-anchor="start">eth1 10.10.0.13</text>
<text x="702" y="381" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="10.5" fill="#5B636C" font-weight="400" text-anchor="start">private network · mesh · advertise · encap</text>
<rect x="688" y="400" width="268" height="50" rx="10" fill="#1E242B" fill-opacity="1.0" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.45"/>
<text x="702" y="421" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11.5" fill="#EE4B12" font-weight="600" text-anchor="start">EIP 89.145.162.131</text>
<text x="702" y="439" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="10.5" fill="#9AA1A8" font-weight="400" text-anchor="start">→ eth0 · /32 via spx-nat-host</text>
<rect x="688" y="462" width="51.0" height="22" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
<text x="713.5" y="476.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#F3F1EC" font-weight="400" text-anchor="middle">awsgw</text>
<rect x="748.0" y="462" width="57.6" height="22" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
<text x="776.8" y="476.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#F3F1EC" font-weight="400" text-anchor="middle">daemon</text>
<rect x="814.6" y="462" width="44.4" height="22" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
<text x="836.8" y="476.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#F3F1EC" font-weight="400" text-anchor="middle">vpcd</text>
<rect x="688" y="492" width="44.4" height="22" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
<text x="710.2" y="506.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#F3F1EC" font-weight="400" text-anchor="middle">nats</text>
<rect x="741.4" y="492" width="84.0" height="22" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
<text x="783.4" y="506.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#F3F1EC" font-weight="400" text-anchor="middle">predastore</text>
<rect x="834.4" y="492" width="84.0" height="22" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
<text x="876.4" y="506.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#F3F1EC" font-weight="400" text-anchor="middle">viperblock</text>
<rect x="688" y="526" width="268" height="34" rx="9" fill="none" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
<text x="700" y="548" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#9AA1A8" font-weight="400" text-anchor="start">i-0ae4… 172.31.1.6 → 89.145.162.131</text>
<rect x="688" y="568" width="268" height="34" rx="9" fill="none" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
<text x="700" y="590" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#9AA1A8" font-weight="400" text-anchor="start">volume /dev/vdb → /var/lib/spinifex</text>
<line x1="510" y1="628" x2="510" y2="644" stroke="#EE4B12" stroke-width="1.3" stroke-opacity="0.55" stroke-dasharray="2 4"/>
<rect x="28" y="646" width="964" height="70" rx="13" fill="#181D23" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
<text x="48" y="676" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="12" fill="#9AA1A8" font-weight="700" text-anchor="start" letter-spacing="1.5">EAST–WEST, OVER THE EXOSCALE PRIVATE NETWORK</text>
<text x="972" y="676" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#5B636C" font-weight="400" text-anchor="end">no Elastic IP is involved · MTU 1500</text>
<rect x="46" y="684" width="124.0" height="22" rx="7" fill="none" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
<text x="108.0" y="698.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#9AA1A8" font-weight="400" text-anchor="middle">Geneve UDP 6081</text>
<rect x="179.0" y="684" width="137.6" height="22" rx="7" fill="none" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
<text x="247.8" y="698.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#9AA1A8" font-weight="400" text-anchor="middle">NATS JetStream R3</text>
<rect x="325.6" y="684" width="144.4" height="22" rx="7" fill="none" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
<text x="397.8" y="698.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#9AA1A8" font-weight="400" text-anchor="middle">predastore RS(2,1)</text>
<rect x="479.0" y="684" width="151.2" height="22" rx="7" fill="none" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
<text x="554.6" y="698.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#9AA1A8" font-weight="400" text-anchor="middle">OVN Northbound raft</text>
<rect x="639.2" y="684" width="56.0" height="22" rx="7" fill="none" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
<text x="667.2" y="698.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#9AA1A8" font-weight="400" text-anchor="middle">OVSDB</text>
<text x="28" y="744" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="12" fill="#5B636C" font-weight="400" text-anchor="start">Guest-to-guest traffic rides Geneve between the nodes&#x27; private-network interfaces — it never touches eth0 or an Elastic IP.</text>
<text x="28" y="762" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="12" fill="#5B636C" font-weight="400" text-anchor="start">Each node carries the Elastic IPs of its own guests. Moving one between nodes is an Exoscale API call, not yet automated.</text>
<text x="28" y="780" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="12" fill="#5B636C" font-weight="400" text-anchor="start">Three nodes is the smallest cluster that tolerates losing one; the anti-affinity group keeps them on different hypervisors.</text>
</svg>

After

Width:  |  Height:  |  Size: 22 KiB

@@ -0,0 +1,80 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1020" height="836" viewBox="0 0 1020 836" role="img" aria-label="Spinifex on a single OCI instance: the instance sits inside the VCN public subnet 10.200.0.0/23; its primary VNIC carries the default route and the cluster mesh, its secondary VNIC is the br-wan bridge holding registered secondary private IPs paired with reserved public IPs; guests reach them through the spx-nat transit veth, and an iSCSI block volume backs /var/lib/spinifex.">
<defs><linearGradient id="nats" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="#F5652E"/><stop offset="1" stop-color="#C63C0E"/></linearGradient></defs>
<rect x="0" y="0" width="1020" height="836" rx="20" fill="#14181D" stroke="#2A2F36" stroke-width="1"/>
<text x="28" y="42" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11.5" fill="#EE4B12" font-weight="600" text-anchor="start" letter-spacing="2.5">SPINIFEX ON OCI — SINGLE NODE</text>
<text x="992" y="42" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11.5" fill="#9AA1A8" font-weight="400" text-anchor="end">one instance · routed NAT · real public addresses</text>
<text x="28" y="72" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="23" fill="#F5F3EE" font-weight="800" text-anchor="start">One OCI instance, single Spinifex node</text>
<rect x="28" y="92" width="964" height="76" rx="13" fill="#181D23" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
<text x="48" y="122" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="12" fill="#9AA1A8" font-weight="700" text-anchor="start" letter-spacing="1.5">INTERNET</text>
<text x="972" y="122" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#5B636C" font-weight="400" text-anchor="end">AWS SDKs, SSH and guest egress all arrive here</text>
<rect x="46" y="126" width="272.7" height="22" rx="7" fill="none" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
<text x="182.3" y="140.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#9AA1A8" font-weight="400" text-anchor="middle">aws-cli / Terraform / boto3 → :9999</text>
<rect x="327.7" y="126" width="177.2" height="22" rx="7" fill="none" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
<text x="416.3" y="140.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#9AA1A8" font-weight="400" text-anchor="middle">ssh → guest public IP</text>
<rect x="513.92" y="126" width="218.1" height="22" rx="7" fill="none" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
<text x="623.0" y="140.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#9AA1A8" font-weight="400" text-anchor="middle">guest egress ← same address</text>
<line x1="510.0" y1="170" x2="510.0" y2="186" stroke="#EE4B12" stroke-width="1.3" stroke-opacity="0.55" stroke-dasharray="2 4"/>
<rect x="28" y="188" width="964" height="582" rx="16" fill="#EE4B12" fill-opacity="0.022" stroke="#EE4B12" stroke-width="1.4" stroke-opacity="0.45" stroke-dasharray="6 5"/>
<text x="50" y="218" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11.5" fill="#EE4B12" font-weight="600" text-anchor="start" letter-spacing="2">OCI VCN 10.200.0.0/16 · PUBLIC SUBNET 10.200.0.0/23</text>
<text x="970" y="218" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#9AA1A8" font-weight="400" text-anchor="end">virtual router 10.200.0.1 · internet gateway</text>
<rect x="48" y="232" width="924" height="520" rx="14" fill="#EE4B12" fill-opacity="0.05" stroke="#EE4B12" stroke-width="1.4" stroke-opacity="0.5"/>
<text x="68" y="266" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="16" fill="#F5F3EE" font-weight="800" text-anchor="start">BM.Standard.E2.64</text>
<text x="260" y="266" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="12" fill="#9AA1A8" font-weight="400" text-anchor="start">spinifex-node-01 · FAULT-DOMAIN-1</text>
<text x="952" y="266" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#5B636C" font-weight="400" text-anchor="end">one host = one Spinifex region</text>
<line x1="68" y1="282" x2="952" y2="282" stroke="#EE4B12" stroke-opacity="0.25" stroke-width="1"/>
<rect x="68" y="298" width="434.0" height="108" rx="13" fill="#181D23" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
<text x="88" y="328" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="12" fill="#9AA1A8" font-weight="700" text-anchor="start" letter-spacing="1.5">VNIC 0 — PRIMARY</text>
<text x="482.0" y="328" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#5B636C" font-weight="400" text-anchor="end">enp0s9</text>
<text x="88" y="356" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="12" fill="#F3F1EC" font-weight="400" text-anchor="start">10.200.0.252/23 · holds the default route</text>
<text x="88" y="378" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="12" fill="#5B636C" font-weight="400" text-anchor="start">advertise IP · ovn-encap-ip · NATS · API :9999</text>
<rect x="518.0" y="298" width="434.0" height="108" rx="13" fill="#181D23" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
<text x="538.0" y="328" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="12" fill="#9AA1A8" font-weight="700" text-anchor="start" letter-spacing="1.5">VNIC 1 — SECONDARY</text>
<text x="932.0" y="328" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#5B636C" font-weight="400" text-anchor="end">br-wan (Linux bridge)</text>
<text x="538.0" y="356" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="12" fill="#F3F1EC" font-weight="400" text-anchor="start">10.200.1.233/32 + ip rule → table 200</text>
<text x="538.0" y="378" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="12" fill="#5B636C" font-weight="400" text-anchor="start">secondary private IPs ⇄ reserved public IPs</text>
<line x1="735.0" y1="406" x2="735.0" y2="422" stroke="#EE4B12" stroke-width="1.3" stroke-opacity="0.55" stroke-dasharray="2 4"/>
<rect x="68" y="424" width="884" height="40" rx="11" fill="#1E242B" fill-opacity="1.0" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.45"/>
<text x="88" y="449" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="12" fill="#EE4B12" font-weight="600" text-anchor="start">spx-nat-host ⇄ spx-nat-ovs</text>
<text x="296" y="449" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="12" fill="#9AA1A8" font-weight="400" text-anchor="start">transit 100.127.0.0/24 · nft chain spinifex-nat-egress masquerades out br-wan</text>
<line x1="510.0" y1="464" x2="510.0" y2="480" stroke="#EE4B12" stroke-width="1.3" stroke-opacity="0.55" stroke-dasharray="2 4"/>
<rect x="68" y="482" width="884" height="100" rx="13" fill="#181D23" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
<text x="88" y="512" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="12" fill="#9AA1A8" font-weight="700" text-anchor="start" letter-spacing="1.5">SPINIFEX CONTROL PLANE</text>
<text x="932" y="512" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#5B636C" font-weight="400" text-anchor="end">every service is one binary: spx service &lt;name&gt;</text>
<rect x="86" y="530" width="109.0" height="22" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
<text x="140.5" y="544.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#F3F1EC" font-weight="400" text-anchor="middle" fill-opacity="0.9">awsgw :9999</text>
<rect x="204.01999999999998" y="530" width="74.9" height="22" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
<text x="241.5" y="544.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#F3F1EC" font-weight="400" text-anchor="middle" fill-opacity="0.9">daemon</text>
<rect x="287.94" y="530" width="61.3" height="22" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
<text x="318.6" y="544.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#F3F1EC" font-weight="400" text-anchor="middle" fill-opacity="0.9">vpcd</text>
<rect x="358.22" y="530" width="61.3" height="22" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
<text x="388.9" y="544.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#F3F1EC" font-weight="400" text-anchor="middle" fill-opacity="0.9">NATS</text>
<rect x="428.5" y="530" width="102.2" height="22" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
<text x="479.6" y="544.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#F3F1EC" font-weight="400" text-anchor="middle" fill-opacity="0.9">predastore</text>
<rect x="539.7" y="530" width="102.2" height="22" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
<text x="590.8" y="544.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#F3F1EC" font-weight="400" text-anchor="middle" fill-opacity="0.9">viperblock</text>
<rect x="650.9000000000001" y="530" width="95.4" height="22" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
<text x="698.6" y="544.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#F3F1EC" font-weight="400" text-anchor="middle" fill-opacity="0.9">northstar</text>
<rect x="755.2800000000001" y="530" width="88.6" height="22" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
<text x="799.6" y="544.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#F3F1EC" font-weight="400" text-anchor="middle" fill-opacity="0.9">ui :3000</text>
<text x="86" y="568" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="12" fill="#5B636C" font-weight="400" text-anchor="start">SigV4 in, NATS in the middle, QEMU/KVM · OVN/OVS · Predastore · Viperblock underneath</text>
<line x1="248" y1="582" x2="248" y2="598" stroke="#EE4B12" stroke-width="1.3" stroke-opacity="0.4" stroke-dasharray="2 4"/>
<line x1="772" y1="582" x2="772" y2="598" stroke="#EE4B12" stroke-width="1.3" stroke-opacity="0.4" stroke-dasharray="2 4"/>
<rect x="68" y="600" width="532" height="130" rx="13" fill="#181D23" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
<text x="88" y="630" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="12" fill="#9AA1A8" font-weight="700" text-anchor="start" letter-spacing="1.5">TENANT VPCs — OVN br-int</text>
<text x="580" y="630" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#5B636C" font-weight="400" text-anchor="end">geneve-local</text>
<rect x="86" y="644" width="496" height="34" rx="9" fill="none" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
<text x="100" y="666" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11.5" fill="#F3F1EC" font-weight="400" text-anchor="start">vpc-a 10.0.0.0/16</text>
<text x="310" y="666" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11.5" fill="#9AA1A8" font-weight="400" text-anchor="start">i-… 10.0.1.4 → 150.230.13.131</text>
<rect x="86" y="686" width="496" height="34" rx="9" fill="none" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
<text x="100" y="708" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11.5" fill="#F3F1EC" font-weight="400" text-anchor="start">vpc-b 172.31.0.0/16</text>
<text x="310" y="708" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11.5" fill="#9AA1A8" font-weight="400" text-anchor="start">i-… 172.31.4.9 · private, egress only</text>
<rect x="616" y="600" width="336" height="130" rx="13" fill="#181D23" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
<text x="636" y="630" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="12" fill="#9AA1A8" font-weight="700" text-anchor="start" letter-spacing="1.5">BLOCK VOLUME — iSCSI</text>
<text x="932" y="630" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#5B636C" font-weight="400" text-anchor="end">256 GB+</text>
<text x="636" y="658" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11.5" fill="#F3F1EC" font-weight="400" text-anchor="start">169.254.2.2:3260 → /dev/sdb</text>
<text x="636" y="680" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11.5" fill="#F3F1EC" font-weight="400" text-anchor="start">/var/lib/spinifex</text>
<text x="636" y="700" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="11.5" fill="#5B636C" font-weight="400" text-anchor="start">mounted by UUID, defaults,_netdev,nofail</text>
<text x="636" y="718" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="11.5" fill="#5B636C" font-weight="400" text-anchor="start">viperblock · predastore · JetStream</text>
<text x="28" y="796" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="12" fill="#5B636C" font-weight="400" text-anchor="start">A guest's public address is a real OCI reserved public IP, attached to a secondary private IP on br-wan.</text>
<text x="28" y="814" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="12" fill="#5B636C" font-weight="400" text-anchor="start">Inbound and outbound are therefore the same address, and OCI's per-VNIC source check passes.</text>
</svg>

After

Width:  |  Height:  |  Size: 15 KiB

+116
View File
@@ -0,0 +1,116 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1020" height="796" viewBox="0 0 1020 796" role="img" aria-label="Spinifex across three OCI instances: all three sit inside the VCN public subnet 10.200.0.0/23, one per fault domain. Each carries a primary VNIC for the cluster mesh and a br-wan secondary VNIC holding its reserved public addresses, its own iSCSI block volume, and its share of the guests. Geneve, NATS JetStream, predastore erasure coding and the OVN raft join them east-west.">
<defs><linearGradient id="nats" x1="0" y1="0" x2="1" y2="0"><stop offset="0" stop-color="#F5652E"/><stop offset="1" stop-color="#C63C0E"/></linearGradient></defs>
<rect x="0" y="0" width="1020" height="796" rx="20" fill="#14181D" stroke="#2A2F36" stroke-width="1"/>
<text x="28" y="42" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11.5" fill="#EE4B12" font-weight="600" text-anchor="start" letter-spacing="2.5">SPINIFEX ON OCI — THREE NODES</text>
<text x="992" y="42" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11.5" fill="#9AA1A8" font-weight="400" text-anchor="end">three fault domains · one region · one API endpoint</text>
<text x="28" y="72" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="23" fill="#F5F3EE" font-weight="800" text-anchor="start">Three OCI instances, redundant multi-node install</text>
<rect x="28" y="92" width="964" height="76" rx="13" fill="#181D23" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
<text x="48" y="122" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="12" fill="#9AA1A8" font-weight="700" text-anchor="start" letter-spacing="1.5">INTERNET</text>
<text x="972" y="122" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#5B636C" font-weight="400" text-anchor="end">one endpoint, whichever node answers</text>
<rect x="46" y="126" width="272.7" height="22" rx="7" fill="none" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
<text x="182.3" y="140.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#9AA1A8" font-weight="400" text-anchor="middle">aws-cli / Terraform / boto3 → :9999</text>
<rect x="327.7" y="126" width="177.2" height="22" rx="7" fill="none" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
<text x="416.3" y="140.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#9AA1A8" font-weight="400" text-anchor="middle">ssh → guest public IP</text>
<rect x="513.92" y="126" width="218.1" height="22" rx="7" fill="none" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
<text x="623.0" y="140.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#9AA1A8" font-weight="400" text-anchor="middle">guest egress ← same address</text>
<line x1="510.0" y1="170" x2="510.0" y2="186" stroke="#EE4B12" stroke-width="1.3" stroke-opacity="0.55" stroke-dasharray="2 4"/>
<rect x="28" y="188" width="964" height="464" rx="16" fill="#EE4B12" fill-opacity="0.022" stroke="#EE4B12" stroke-width="1.4" stroke-opacity="0.45" stroke-dasharray="6 5"/>
<text x="50" y="218" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11.5" fill="#EE4B12" font-weight="600" text-anchor="start" letter-spacing="2">OCI VCN 10.200.0.0/16 · PUBLIC SUBNET 10.200.0.0/23</text>
<text x="970" y="218" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#9AA1A8" font-weight="400" text-anchor="end">virtual router 10.200.0.1 · internet gateway</text>
<rect x="48.0" y="232" width="297.3" height="272" rx="13" fill="#EE4B12" fill-opacity="0.05" stroke="#EE4B12" stroke-width="1.4" stroke-opacity="0.5"/>
<text x="66.0" y="262" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="14" fill="#F5F3EE" font-weight="800" text-anchor="start">spinifex-node-01</text>
<text x="327.3333333333333" y="262" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10" fill="#EE4B12" font-weight="400" text-anchor="end" fill-opacity="0.85">FAULT-DOMAIN-1</text>
<text x="66.0" y="280" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#5B636C" font-weight="400" text-anchor="start">BM.Standard.E2.64</text>
<line x1="66.0" y1="292" x2="327.3333333333333" y2="292" stroke="#EE4B12" stroke-opacity="0.25" stroke-width="1"/>
<text x="66.0" y="314" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11.5" fill="#F3F1EC" font-weight="400" text-anchor="start">VNIC 0 10.200.0.252</text>
<text x="66.0" y="330" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="11" fill="#5B636C" font-weight="400" text-anchor="start">mesh · advertise · encap</text>
<text x="66.0" y="352" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11.5" fill="#F3F1EC" font-weight="400" text-anchor="start">VNIC 1 br-wan 10.200.1.233/32</text>
<text x="66.0" y="368" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="11" fill="#5B636C" font-weight="400" text-anchor="start">public IP pairs · table 200</text>
<rect x="66.0" y="382" width="51.0" height="20" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
<text x="91.5" y="394.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10" fill="#F3F1EC" font-weight="400" text-anchor="middle" fill-opacity="0.9">awsgw</text>
<rect x="123.0" y="382" width="57.2" height="20" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
<text x="151.6" y="394.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10" fill="#F3F1EC" font-weight="400" text-anchor="middle" fill-opacity="0.9">daemon</text>
<rect x="186.2" y="382" width="44.8" height="20" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
<text x="208.6" y="394.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10" fill="#F3F1EC" font-weight="400" text-anchor="middle" fill-opacity="0.9">vpcd</text>
<rect x="66.0" y="408" width="44.8" height="20" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
<text x="88.4" y="420.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10" fill="#F3F1EC" font-weight="400" text-anchor="middle" fill-opacity="0.9">nats</text>
<rect x="116.8" y="408" width="82.0" height="20" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
<text x="157.8" y="420.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10" fill="#F3F1EC" font-weight="400" text-anchor="middle" fill-opacity="0.9">predastore</text>
<rect x="204.8" y="408" width="82.0" height="20" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
<text x="245.8" y="420.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10" fill="#F3F1EC" font-weight="400" text-anchor="middle" fill-opacity="0.9">viperblock</text>
<rect x="66.0" y="438" width="261.3" height="24" rx="8" fill="none" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
<text x="76.0" y="454" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#9AA1A8" font-weight="400" text-anchor="start">i-23d2… 10.0.1.4 → 150.230.13.131</text>
<rect x="66.0" y="466" width="261.3" height="24" rx="8" fill="none" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
<text x="76.0" y="482" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#9AA1A8" font-weight="400" text-anchor="start">iSCSI /dev/sdb → /var/lib/spinifex</text>
<line x1="345.3333333333333" y1="364" x2="361.3333333333333" y2="364" stroke="#EE4B12" stroke-width="1.3" stroke-opacity="0.5" stroke-dasharray="2 4"/>
<rect x="361.3333333333333" y="232" width="297.3" height="272" rx="13" fill="#EE4B12" fill-opacity="0.05" stroke="#EE4B12" stroke-width="1.4" stroke-opacity="0.5"/>
<text x="379.3333333333333" y="262" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="14" fill="#F5F3EE" font-weight="800" text-anchor="start">spinifex-node-02</text>
<text x="640.6666666666666" y="262" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10" fill="#EE4B12" font-weight="400" text-anchor="end" fill-opacity="0.85">FAULT-DOMAIN-2</text>
<text x="379.3333333333333" y="280" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#5B636C" font-weight="400" text-anchor="start">BM.Standard.E2.64</text>
<line x1="379.3333333333333" y1="292" x2="640.6666666666666" y2="292" stroke="#EE4B12" stroke-opacity="0.25" stroke-width="1"/>
<text x="379.3333333333333" y="314" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11.5" fill="#F3F1EC" font-weight="400" text-anchor="start">VNIC 0 10.200.0.118</text>
<text x="379.3333333333333" y="330" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="11" fill="#5B636C" font-weight="400" text-anchor="start">mesh · advertise · encap</text>
<text x="379.3333333333333" y="352" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11.5" fill="#F3F1EC" font-weight="400" text-anchor="start">VNIC 1 br-wan 10.200.1.96/32</text>
<text x="379.3333333333333" y="368" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="11" fill="#5B636C" font-weight="400" text-anchor="start">public IP pairs · table 200</text>
<rect x="379.3333333333333" y="382" width="51.0" height="20" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
<text x="404.8" y="394.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10" fill="#F3F1EC" font-weight="400" text-anchor="middle" fill-opacity="0.9">awsgw</text>
<rect x="436.3333333333333" y="382" width="57.2" height="20" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
<text x="464.9" y="394.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10" fill="#F3F1EC" font-weight="400" text-anchor="middle" fill-opacity="0.9">daemon</text>
<rect x="499.5333333333333" y="382" width="44.8" height="20" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
<text x="521.9" y="394.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10" fill="#F3F1EC" font-weight="400" text-anchor="middle" fill-opacity="0.9">vpcd</text>
<rect x="379.3333333333333" y="408" width="44.8" height="20" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
<text x="401.7" y="420.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10" fill="#F3F1EC" font-weight="400" text-anchor="middle" fill-opacity="0.9">nats</text>
<rect x="430.1333333333333" y="408" width="82.0" height="20" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
<text x="471.1" y="420.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10" fill="#F3F1EC" font-weight="400" text-anchor="middle" fill-opacity="0.9">predastore</text>
<rect x="518.1333333333333" y="408" width="82.0" height="20" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
<text x="559.1" y="420.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10" fill="#F3F1EC" font-weight="400" text-anchor="middle" fill-opacity="0.9">viperblock</text>
<rect x="379.3333333333333" y="438" width="261.3" height="24" rx="8" fill="none" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
<text x="389.3333333333333" y="454" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#9AA1A8" font-weight="400" text-anchor="start">i-6b91… 10.0.1.9 → 137.23.9.125</text>
<rect x="379.3333333333333" y="466" width="261.3" height="24" rx="8" fill="none" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
<text x="389.3333333333333" y="482" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#9AA1A8" font-weight="400" text-anchor="start">iSCSI /dev/sdb → /var/lib/spinifex</text>
<line x1="658.6666666666666" y1="364" x2="674.6666666666666" y2="364" stroke="#EE4B12" stroke-width="1.3" stroke-opacity="0.5" stroke-dasharray="2 4"/>
<rect x="674.6666666666666" y="232" width="297.3" height="272" rx="13" fill="#EE4B12" fill-opacity="0.05" stroke="#EE4B12" stroke-width="1.4" stroke-opacity="0.5"/>
<text x="692.6666666666666" y="262" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="14" fill="#F5F3EE" font-weight="800" text-anchor="start">spinifex-node-03</text>
<text x="954.0" y="262" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10" fill="#EE4B12" font-weight="400" text-anchor="end" fill-opacity="0.85">FAULT-DOMAIN-3</text>
<text x="692.6666666666666" y="280" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#5B636C" font-weight="400" text-anchor="start">BM.Standard.E2.64</text>
<line x1="692.6666666666666" y1="292" x2="954.0" y2="292" stroke="#EE4B12" stroke-opacity="0.25" stroke-width="1"/>
<text x="692.6666666666666" y="314" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11.5" fill="#F3F1EC" font-weight="400" text-anchor="start">VNIC 0 10.200.0.41</text>
<text x="692.6666666666666" y="330" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="11" fill="#5B636C" font-weight="400" text-anchor="start">mesh · advertise · encap</text>
<text x="692.6666666666666" y="352" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11.5" fill="#F3F1EC" font-weight="400" text-anchor="start">VNIC 1 br-wan 10.200.1.12/32</text>
<text x="692.6666666666666" y="368" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="11" fill="#5B636C" font-weight="400" text-anchor="start">public IP pairs · table 200</text>
<rect x="692.6666666666666" y="382" width="51.0" height="20" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
<text x="718.2" y="394.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10" fill="#F3F1EC" font-weight="400" text-anchor="middle" fill-opacity="0.9">awsgw</text>
<rect x="749.6666666666666" y="382" width="57.2" height="20" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
<text x="778.3" y="394.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10" fill="#F3F1EC" font-weight="400" text-anchor="middle" fill-opacity="0.9">daemon</text>
<rect x="812.8666666666667" y="382" width="44.8" height="20" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
<text x="835.3" y="394.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10" fill="#F3F1EC" font-weight="400" text-anchor="middle" fill-opacity="0.9">vpcd</text>
<rect x="692.6666666666666" y="408" width="44.8" height="20" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
<text x="715.1" y="420.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10" fill="#F3F1EC" font-weight="400" text-anchor="middle" fill-opacity="0.9">nats</text>
<rect x="743.4666666666666" y="408" width="82.0" height="20" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
<text x="784.5" y="420.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10" fill="#F3F1EC" font-weight="400" text-anchor="middle" fill-opacity="0.9">predastore</text>
<rect x="831.4666666666666" y="408" width="82.0" height="20" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
<text x="872.5" y="420.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10" fill="#F3F1EC" font-weight="400" text-anchor="middle" fill-opacity="0.9">viperblock</text>
<rect x="692.6666666666666" y="438" width="261.3" height="24" rx="8" fill="none" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
<text x="702.6666666666666" y="454" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#9AA1A8" font-weight="400" text-anchor="start">i-0ae4… 10.0.2.6 → 150.230.4.88</text>
<rect x="692.6666666666666" y="466" width="261.3" height="24" rx="8" fill="none" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
<text x="702.6666666666666" y="482" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#9AA1A8" font-weight="400" text-anchor="start">iSCSI /dev/sdb → /var/lib/spinifex</text>
<rect x="48" y="524" width="924" height="104" rx="13" fill="#181D23" fill-opacity="1.0" stroke="#2A2F36" stroke-width="1" stroke-opacity="1.0"/>
<text x="68" y="554" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="12" fill="#9AA1A8" font-weight="700" text-anchor="start" letter-spacing="1.5">EAST–WEST, INSIDE THE VCN</text>
<text x="952" y="554" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="10.5" fill="#5B636C" font-weight="400" text-anchor="end">no public address is involved</text>
<rect x="66" y="574" width="136.3" height="22" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
<text x="134.2" y="588.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#F3F1EC" font-weight="400" text-anchor="middle" fill-opacity="0.9">Geneve UDP 6081</text>
<rect x="211.3" y="574" width="149.9" height="22" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
<text x="286.3" y="588.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#F3F1EC" font-weight="400" text-anchor="middle" fill-opacity="0.9">NATS JetStream R3</text>
<rect x="370.24" y="574" width="156.8" height="22" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
<text x="448.6" y="588.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#F3F1EC" font-weight="400" text-anchor="middle" fill-opacity="0.9">predastore RS(2,1)</text>
<rect x="536.0" y="574" width="163.6" height="22" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
<text x="617.8" y="588.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#F3F1EC" font-weight="400" text-anchor="middle" fill-opacity="0.9">OVN Northbound raft</text>
<rect x="708.58" y="574" width="68.1" height="22" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
<text x="742.6" y="588.8" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#F3F1EC" font-weight="400" text-anchor="middle" fill-opacity="0.9">OVSDB</text>
<text x="66" y="612" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="12" fill="#5B636C" font-weight="400" text-anchor="start">Guest-to-guest traffic rides Geneve between the nodes' primary VNICs — it never leaves the subnet and never touches br-wan.</text>
<rect x="28" y="668" width="964" height="46" rx="12" fill="#EE4B12" fill-opacity="0.07" stroke="#EE4B12" stroke-width="1.4" stroke-opacity="0.55"/>
<text x="48" y="688" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="13" fill="#F5F3EE" font-weight="700" text-anchor="start">A guest that moves takes its address with it.</text>
<text x="48" y="705" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="12" fill="#9AA1A8" font-weight="400" text-anchor="start">The reserved public IP follows the secondary private IP to whichever node's br-wan the guest lands on — the tenant sees no change.</text>
<text x="28" y="756" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="12" fill="#5B636C" font-weight="400" text-anchor="start">Three nodes is the smallest cluster that tolerates losing one: NATS JetStream keeps quorum, predastore RS(2,1)</text>
<text x="28" y="774" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="12" fill="#5B636C" font-weight="400" text-anchor="start">keeps the data readable, and the OVN Northbound raft keeps a leader. One node per fault domain is what makes that real.</text>
</svg>

After

Width:  |  Height:  |  Size: 21 KiB

@@ -0,0 +1,44 @@
<svg xmlns="http://www.w3.org/2000/svg" width="760" height="470" viewBox="0 0 760 470" font-family="-apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif">
<rect width="760" height="470" fill="#ffffff"/>
<text x="74" y="30" font-size="19" font-weight="700" fill="#1f2937">Guest block storage throughput (Viperblock)</text>
<text x="74" y="49" font-size="12.5" fill="#6b7280">fio, 4 jobs, iodepth 32, direct I/O, per-guest sequential runs — Spinifex v1.20.0</text>
<line x1="74" y1="386.0" x2="734" y2="386.0" stroke="#e5e7eb" stroke-width="1"/>
<text x="64" y="390.0" font-size="12" text-anchor="end" fill="#6b7280">0</text>
<line x1="74" y1="305.0" x2="734" y2="305.0" stroke="#e5e7eb" stroke-width="1"/>
<text x="64" y="309.0" font-size="12" text-anchor="end" fill="#6b7280">100</text>
<line x1="74" y1="224.0" x2="734" y2="224.0" stroke="#e5e7eb" stroke-width="1"/>
<text x="64" y="228.0" font-size="12" text-anchor="end" fill="#6b7280">200</text>
<line x1="74" y1="143.0" x2="734" y2="143.0" stroke="#e5e7eb" stroke-width="1"/>
<text x="64" y="147.0" font-size="12" text-anchor="end" fill="#6b7280">300</text>
<line x1="74" y1="62.0" x2="734" y2="62.0" stroke="#e5e7eb" stroke-width="1"/>
<text x="64" y="66.0" font-size="12" text-anchor="end" fill="#6b7280">400</text>
<text x="22" y="224.0" font-size="12.5" fill="#6b7280" transform="rotate(-90 22 224.0)" text-anchor="middle">MiB/s</text>
<rect x="111.4" y="81.4" width="45.4" height="304.6" rx="2.5" fill="#2563eb"/>
<text x="134.1" y="75.4" font-size="11" text-anchor="middle" fill="#1f2937">376</text>
<rect x="159.8" y="198.1" width="45.4" height="187.9" rx="2.5" fill="#0d9488"/>
<text x="182.5" y="192.1" font-size="11" text-anchor="middle" fill="#1f2937">232</text>
<rect x="208.2" y="299.3" width="45.4" height="86.7" rx="2.5" fill="#f59e0b"/>
<text x="230.9" y="293.3" font-size="11" text-anchor="middle" fill="#1f2937">107</text>
<text x="184.0" y="408.0" font-size="13" text-anchor="middle" font-weight="600" fill="#1f2937">GPU</text>
<rect x="331.4" y="85.5" width="45.4" height="300.5" rx="2.5" fill="#2563eb"/>
<text x="354.1" y="79.5" font-size="11" text-anchor="middle" fill="#1f2937">371</text>
<rect x="379.8" y="202.9" width="45.4" height="183.1" rx="2.5" fill="#0d9488"/>
<text x="402.5" y="196.9" font-size="11" text-anchor="middle" fill="#1f2937">226</text>
<rect x="428.2" y="321.2" width="45.4" height="64.8" rx="2.5" fill="#f59e0b"/>
<text x="450.9" y="315.2" font-size="11" text-anchor="middle" fill="#1f2937">80</text>
<text x="404.0" y="408.0" font-size="13" text-anchor="middle" font-weight="600" fill="#1f2937">CPU1</text>
<rect x="551.4" y="84.7" width="45.4" height="301.3" rx="2.5" fill="#2563eb"/>
<text x="574.1" y="78.7" font-size="11" text-anchor="middle" fill="#1f2937">372</text>
<rect x="599.8" y="201.3" width="45.4" height="184.7" rx="2.5" fill="#0d9488"/>
<text x="622.5" y="195.3" font-size="11" text-anchor="middle" fill="#1f2937">228</text>
<rect x="648.2" y="321.2" width="45.4" height="64.8" rx="2.5" fill="#f59e0b"/>
<text x="670.9" y="315.2" font-size="11" text-anchor="middle" fill="#1f2937">80</text>
<text x="624.0" y="408.0" font-size="13" text-anchor="middle" font-weight="600" fill="#1f2937">CPU2</text>
<line x1="74" y1="386" x2="734" y2="386" stroke="#1f2937" stroke-width="1.5"/>
<rect x="74" y="444" width="13" height="13" rx="2.5" fill="#2563eb"/>
<text x="93" y="455" font-size="12.5" fill="#1f2937">128K random read</text>
<rect x="216.8" y="444" width="13" height="13" rx="2.5" fill="#0d9488"/>
<text x="235.8" y="455" font-size="12.5" fill="#1f2937">128K mixed read</text>
<rect x="352.3" y="444" width="13" height="13" rx="2.5" fill="#f59e0b"/>
<text x="371.3" y="455" font-size="12.5" fill="#1f2937">16K random read</text>
</svg>

After

Width:  |  Height:  |  Size: 3.7 KiB

@@ -0,0 +1,35 @@
<svg xmlns="http://www.w3.org/2000/svg" width="760" height="470" viewBox="0 0 760 470" font-family="-apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif">
<rect width="760" height="470" fill="#ffffff"/>
<text x="74" y="30" font-size="19" font-weight="700" fill="#1f2937">Network throughput: overlay vs physical fabric</text>
<text x="74" y="49" font-size="12.5" fill="#6b7280">iperf3 TCP receiver goodput, median across node pairs — Spinifex v1.20.0</text>
<line x1="74" y1="386.0" x2="734" y2="386.0" stroke="#e5e7eb" stroke-width="1"/>
<text x="64" y="390.0" font-size="12" text-anchor="end" fill="#6b7280">20</text>
<line x1="74" y1="62.0" x2="734" y2="62.0" stroke="#e5e7eb" stroke-width="1"/>
<text x="64" y="66.0" font-size="12" text-anchor="end" fill="#6b7280">25</text>
<text x="22" y="224.0" font-size="12.5" fill="#6b7280" transform="rotate(-90 22 224.0)" text-anchor="middle">Gbit/s</text>
<text x="140.0" y="408.0" font-size="13" text-anchor="middle" font-weight="600" fill="#1f2937">1</text>
<text x="272.0" y="408.0" font-size="13" text-anchor="middle" font-weight="600" fill="#1f2937">2</text>
<text x="404.0" y="408.0" font-size="13" text-anchor="middle" font-weight="600" fill="#1f2937">4</text>
<text x="536.0" y="408.0" font-size="13" text-anchor="middle" font-weight="600" fill="#1f2937">8</text>
<text x="668.0" y="408.0" font-size="13" text-anchor="middle" font-weight="600" fill="#1f2937">16</text>
<path d="M140.0,164.4 L272.0,160.5 L404.0,160.5 L536.0,160.5 L668.0,160.5" fill="none" stroke="#2563eb" stroke-width="3"/>
<circle cx="140.0" cy="164.4" r="4.5" fill="#fff" stroke="#2563eb" stroke-width="2.5"/>
<circle cx="272.0" cy="160.5" r="4.5" fill="#fff" stroke="#2563eb" stroke-width="2.5"/>
<circle cx="404.0" cy="160.5" r="4.5" fill="#fff" stroke="#2563eb" stroke-width="2.5"/>
<circle cx="536.0" cy="160.5" r="4.5" fill="#fff" stroke="#2563eb" stroke-width="2.5"/>
<circle cx="668.0" cy="160.5" r="4.5" fill="#fff" stroke="#2563eb" stroke-width="2.5"/>
<text x="664.0" y="149.5" font-size="11.5" text-anchor="end" font-weight="600" fill="#2563eb">23.48</text>
<path d="M140.0,255.1 L272.0,222.1 L404.0,222.1 L536.0,222.1 L668.0,228.5" fill="none" stroke="#0d9488" stroke-width="3"/>
<circle cx="140.0" cy="255.1" r="4.5" fill="#fff" stroke="#0d9488" stroke-width="2.5"/>
<circle cx="272.0" cy="222.1" r="4.5" fill="#fff" stroke="#0d9488" stroke-width="2.5"/>
<circle cx="404.0" cy="222.1" r="4.5" fill="#fff" stroke="#0d9488" stroke-width="2.5"/>
<circle cx="536.0" cy="222.1" r="4.5" fill="#fff" stroke="#0d9488" stroke-width="2.5"/>
<circle cx="668.0" cy="228.5" r="4.5" fill="#fff" stroke="#0d9488" stroke-width="2.5"/>
<text x="664.0" y="217.5" font-size="11.5" text-anchor="end" font-weight="600" fill="#0d9488">22.43</text>
<line x1="74" y1="386" x2="734" y2="386" stroke="#1f2937" stroke-width="1.5"/>
<text x="404.0" y="430.0" font-size="12.5" text-anchor="middle" fill="#6b7280">parallel TCP streams</text>
<rect x="74" y="444" width="13" height="13" rx="2.5" fill="#2563eb"/>
<text x="93" y="455" font-size="12.5" fill="#1f2937">Host physical fabric (25GbE)</text>
<rect x="304.4" y="444" width="13" height="13" rx="2.5" fill="#0d9488"/>
<text x="323.4" y="455" font-size="12.5" fill="#1f2937">Guest overlay (OVN)</text>
</svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

@@ -0,0 +1,39 @@
<svg xmlns="http://www.w3.org/2000/svg" width="760" height="470" viewBox="0 0 760 470" font-family="-apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif">
<rect width="760" height="470" fill="#ffffff"/>
<text x="74" y="30" font-size="19" font-weight="700" fill="#1f2937">Predastore S3 read throughput by client</text>
<text x="74" y="49" font-size="12.5" fill="#6b7280">1 GiB objects, same presigned-curl client on every path — Spinifex v1.20.0</text>
<line x1="74" y1="386.0" x2="734" y2="386.0" stroke="#e5e7eb" stroke-width="1"/>
<text x="64" y="390.0" font-size="12" text-anchor="end" fill="#6b7280">0</text>
<line x1="74" y1="278.0" x2="734" y2="278.0" stroke="#e5e7eb" stroke-width="1"/>
<text x="64" y="282.0" font-size="12" text-anchor="end" fill="#6b7280">200</text>
<line x1="74" y1="170.0" x2="734" y2="170.0" stroke="#e5e7eb" stroke-width="1"/>
<text x="64" y="174.0" font-size="12" text-anchor="end" fill="#6b7280">400</text>
<line x1="74" y1="62.0" x2="734" y2="62.0" stroke="#e5e7eb" stroke-width="1"/>
<text x="64" y="66.0" font-size="12" text-anchor="end" fill="#6b7280">600</text>
<text x="22" y="224.0" font-size="12.5" fill="#6b7280" transform="rotate(-90 22 224.0)" text-anchor="middle">MiB/s</text>
<rect x="102.0" y="202.4" width="51.5" height="183.6" rx="2.5" fill="#2563eb"/>
<text x="127.8" y="196.4" font-size="11" text-anchor="middle" fill="#1f2937">340</text>
<rect x="156.5" y="136.5" width="51.5" height="249.5" rx="2.5" fill="#f59e0b"/>
<text x="182.2" y="130.5" font-size="11" text-anchor="middle" fill="#1f2937">462</text>
<text x="156.5" y="408.0" font-size="13" text-anchor="middle" font-weight="600" fill="#1f2937">Host</text>
<rect x="267.1" y="170.5" width="51.5" height="215.5" rx="2.5" fill="#2563eb"/>
<text x="292.8" y="164.5" font-size="11" text-anchor="middle" fill="#1f2937">399</text>
<rect x="321.5" y="174.9" width="51.5" height="211.1" rx="2.5" fill="#f59e0b"/>
<text x="347.2" y="168.9" font-size="11" text-anchor="middle" fill="#1f2937">391</text>
<text x="321.5" y="408.0" font-size="13" text-anchor="middle" font-weight="600" fill="#1f2937">GPU guest</text>
<rect x="432.1" y="102.5" width="51.5" height="283.5" rx="2.5" fill="#2563eb"/>
<text x="457.8" y="96.5" font-size="11" text-anchor="middle" fill="#1f2937">525</text>
<rect x="486.5" y="197.0" width="51.5" height="189.0" rx="2.5" fill="#f59e0b"/>
<text x="512.2" y="191.0" font-size="11" text-anchor="middle" fill="#1f2937">350</text>
<text x="486.5" y="408.0" font-size="13" text-anchor="middle" font-weight="600" fill="#1f2937">CPU1 guest</text>
<rect x="597.0" y="106.8" width="51.5" height="279.2" rx="2.5" fill="#2563eb"/>
<text x="622.8" y="100.8" font-size="11" text-anchor="middle" fill="#1f2937">517</text>
<rect x="651.5" y="196.5" width="51.5" height="189.5" rx="2.5" fill="#f59e0b"/>
<text x="677.2" y="190.5" font-size="11" text-anchor="middle" fill="#1f2937">351</text>
<text x="651.5" y="408.0" font-size="13" text-anchor="middle" font-weight="600" fill="#1f2937">CPU2 guest</text>
<line x1="74" y1="386" x2="734" y2="386" stroke="#1f2937" stroke-width="1.5"/>
<rect x="74" y="444" width="13" height="13" rx="2.5" fill="#2563eb"/>
<text x="93" y="455" font-size="12.5" fill="#1f2937">Single-stream GET</text>
<rect x="224.1" y="444" width="13" height="13" rx="2.5" fill="#f59e0b"/>
<text x="243.1" y="455" font-size="12.5" fill="#1f2937">Chunked/concurrent GET</text>
</svg>

After

Width:  |  Height:  |  Size: 3.3 KiB

+47
View File
@@ -143,5 +143,52 @@
"extra": "",
"suite": "storagefault",
"timeout_minutes": 100
},
{
"cell": 30,
"install": "binary",
"topo": "real-multi",
"net": "nat",
"host": "debian-13",
"arch": "x86_64",
"runner": "ci-multi",
"extra": "nat-multi",
"suite": ""
},
{
"cell": 31,
"install": "binary",
"topo": "real-multi",
"net": "static",
"host": "debian-13",
"arch": "x86_64",
"runner": "ci-multi",
"extra": "",
"suite": "instancerecovery",
"timeout_minutes": 105
},
{
"cell": 32,
"install": "binary",
"topo": "real-multi",
"net": "static",
"host": "debian-13",
"arch": "x86_64",
"runner": "ci-multi",
"extra": "",
"suite": "lbrecovery",
"timeout_minutes": 90
},
{
"cell": 33,
"install": "binary",
"topo": "real-multi",
"net": "static",
"host": "debian-13",
"arch": "x86_64",
"runner": "ci-multi",
"extra": "",
"suite": "kvquorum",
"timeout_minutes": 55
}
]
+39 -1
View File
@@ -40,7 +40,24 @@
E2E_SUITES_SINGLE="single iam cert eks ecs storagegrowth partialblock rds quota storagefault"
# Suites runnable against a multi-node environment.
E2E_SUITES_MULTI="multinode lb cert quota storagefault"
#
# instancerecovery is multi-only and needs three: recovery is inert below that,
# because there is no survivor to move a guest to that is not the node that
# failed. Like storagefault it takes a node away and freezes a cluster-wide
# service, so it runs last and owns its environment.
#
# lbrecovery is multi-only and needs three for two reasons rather than one: a
# survivor to move the load balancer to, and a node for its backends that is not
# the load balancer's, so taking that node away does not take the backends with
# it. It runs beside instancerecovery at the end because it also takes a node
# away and owns its environment while it does.
#
# kvquorum is multi-only and needs three, for the same reason as those two: a
# surviving majority. It is what proves the control plane's own state is
# replicated rather than sitting on one node, so it runs before the pair that
# take a node away — a cluster whose buckets are already wrong should report
# that, not report a recovery failing.
E2E_SUITES_MULTI="multinode lb cert quota kvquorum lbrecovery instancerecovery storagefault"
# grep -xE alternation form, for narrowing a requested set to the eligible one.
E2E_SUITES_SINGLE_RE="$(printf '%s' "${E2E_SUITES_SINGLE}" | tr ' ' '|')"
@@ -64,6 +81,10 @@ E2E_SUITES_MULTI_RE="$(printf '%s' "${E2E_SUITES_MULTI}" | tr ' ' '|')"
# after a failed seal do not exist on one node, and they are the assertions
# worth a nightly.
E2E_SUITES_NIGHTLY_SINGLE="single cert iam"
#
# The multi-node nightly subset carries kvquorum's read-only half through
# `multinode`, which sweeps every KV bucket rather than the three the daemon
# owns. The node-loss half is too slow for a 35-minute cell and has its own.
E2E_SUITES_NIGHTLY_MULTI="multinode cert lb"
# How long a suite gets, for the same reason the lists above live here: the
@@ -88,6 +109,23 @@ e2e_suite_timeout() {
# SIGSTOPped for seven minutes to prove a guest survives it. A full pass on
# dev-prod is ~63 minutes, so the 30m default truncated it mid-suite.
storagefault) echo "90m" ;;
# instancerecovery is almost entirely waiting, and none of it is avoidable:
# a dead owner has to be seen stale twice, a guest has to boot, and the
# storage case waits for a pause measured at 4m47s and then holds four
# minutes to prove nothing moved. Three tests, each taking a node away and
# bringing it back, and the partition case adds a lease validity, a lease
# TTL and a boot on top of everything the other two wait for.
instancerecovery) echo "90m" ;;
# lbrecovery pays for a load balancer twice: once to stand one up with two
# backends placed around it, and once for the recovery itself — a stale
# owner, a lease, a boot, HAProxy's own start, and its health checks against
# the backends before it forwards anything. Then the node comes back and has
# to be shown not to answer for an address it no longer holds.
lbrecovery) echo "75m" ;;
# kvquorum audits every bucket in seconds and then spends the rest of its
# budget on a node stopping and starting: seven units down, a raft election,
# and the whole service stack back up with every stream caught up.
kvquorum) echo "40m" ;;
*) echo "30m" ;;
esac
}
+188
View File
@@ -0,0 +1,188 @@
name: Dev Prerelease
# Publishes the dev channel that install.mulgadc.com already serves. setup.sh has
# understood INSTALL_SPINIFEX_CHANNEL=dev since it was written and the install
# Worker resolves that channel to the newest GitHub prerelease, but nothing has
# ever produced one -- so /download/dev/amd64 returned 404. This is the producer.
#
# A separate workflow rather than conditionals in release.yml, because a dev build
# is a different artifact and not a release with flags off: amd64 only, no ISO, no
# AMI refresh, no bare-metal gate, and nothing that touches a self-hosted runner.
on:
push:
branches: [dev]
# Nothing here changes the binary, and a prerelease per docs commit would
# churn the channel our contacts are installing from.
paths-ignore:
- "docs/**"
- "**/*.md"
workflow_dispatch:
permissions:
contents: write
# Supersede rather than queue: on a run of merges to dev only the newest commit is
# worth publishing, and the older build would land after it and become the channel.
concurrency:
group: dev-release
cancel-in-progress: true
jobs:
prerelease:
runs-on: ubuntu-26.04
steps:
# Full history and tags, because the tag name is git describe and a shallow
# clone has no tag to describe against.
- name: Checkout Spinifex
uses: actions/checkout@v7
with:
path: spinifex
fetch-depth: 0
# dev, not each repository's default branch, which is what release.yml takes.
# A dev build of spinifex against main of the others is a pairing nobody runs.
- name: Checkout Predastore
uses: actions/checkout@v7
with:
repository: mulgadc/predastore
ref: dev
path: predastore
- name: Checkout Viperblock
uses: actions/checkout@v7
with:
repository: mulgadc/viperblock
ref: dev
path: viperblock
- name: Checkout Northstar
uses: actions/checkout@v7
with:
repository: mulgadc/northstar
ref: dev
path: northstar
- name: Checkout Bluebottle
uses: actions/checkout@v7
with:
repository: mulgadc/bluebottle
ref: dev
path: bluebottle
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
- name: Set up Go
uses: actions/setup-go@v7
with:
go-version-file: spinifex/go.mod
# The tag carries the describe string so the commit is recoverable from the
# version alone, and the -dev suffix is what release.yml's own convention
# already treats as a prerelease. VERSION must equal the tag: the install
# Worker derives the asset name from the tag it resolves.
- name: Resolve the version
id: version
working-directory: spinifex
run: |
set -euo pipefail
# Describing against a dev tag would nest it, growing the name every release.
TAG="$(git describe --tags --always --exclude '*-dev')-dev"
case "$TAG" in
v*) ;;
*) echo "::error::git describe gave '$TAG', which is not a v-prefixed version"; exit 1 ;;
esac
if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
echo "::notice::$TAG already published; nothing to do"
echo "exists=true" >> "$GITHUB_OUTPUT"
fi
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
echo "Publishing $TAG"
env:
GH_TOKEN: ${{ github.token }}
- name: Build microVM artifacts (lb-agent + kernel + initramfs)
if: steps.version.outputs.exists != 'true'
run: cd spinifex && make build-microvm-image
- name: Build amd64 Tarball
if: steps.version.outputs.exists != 'true'
env:
VERSION: ${{ steps.version.outputs.tag }}
run: cd spinifex && make distro-amd64 VERSION="$VERSION"
# --prerelease on create, never a later promote step. release.yml sets it in
# a job that runs after its verification gates, which leaves a window where
# the release is not flagged; GitHub's releases/latest excludes prereleases
# only, so in that window a dev build is what every customer curl installs.
- name: Publish the prerelease
if: steps.version.outputs.exists != 'true'
working-directory: spinifex
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ steps.version.outputs.tag }}
run: |
set -euo pipefail
# A file, not an inline string: the notes are Markdown, and leading
# whitespace from indenting a shell literal turns them into code blocks.
cat > "$RUNNER_TEMP/notes.md" <<EOF
Automated dev-channel build of \`$GITHUB_SHA\`.
**Not a release.** amd64 only, no ISO and no system images, and it has not been through the release verification gates. Install it with:
\`\`\`
curl -sfL https://install.mulgadc.com | sudo bash -s -- --channel dev
\`\`\`
EOF
sed -i 's/^ //' "$RUNNER_TEMP/notes.md"
gh release create "$TAG" \
--repo "$GITHUB_REPOSITORY" \
--target "$GITHUB_SHA" \
--prerelease \
--title "$TAG" \
--notes-file "$RUNNER_TEMP/notes.md" \
"dist/spinifex-$TAG-linux-amd64.tar.gz" \
"dist/spinifex-$TAG-linux-amd64.tar.gz.sha256" \
"scripts/setup.sh"
# The assertion that matters. A prerelease must never become the latest
# release, because latest is what an unqualified curl | bash installs -- so
# this checks the published state rather than trusting the flag we passed.
- name: Assert the stable channel is untouched
if: steps.version.outputs.exists != 'true'
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ steps.version.outputs.tag }}
run: |
set -euo pipefail
latest="$(gh api "repos/$GITHUB_REPOSITORY/releases/latest" --jq .tag_name)"
if [ "$latest" = "$TAG" ]; then
echo "::error::$TAG became the latest release: every customer curl | bash would install it"
gh release edit "$TAG" --repo "$GITHUB_REPOSITORY" --prerelease
exit 1
fi
echo "latest is still $latest"
dev="$(gh api "repos/$GITHUB_REPOSITORY/releases?per_page=20" --jq 'map(select(.prerelease))[0].tag_name')"
[ "$dev" = "$TAG" ] || { echo "::error::the dev channel resolves to $dev, not $TAG"; exit 1; }
# Kept rather than unbounded: the Worker reads the first page of releases to
# resolve the channel, so enough prereleases would eventually push the real
# ones off it, and each build carries two assets.
#
# Twenty, not five. A pinned dev tag is how an external evaluator installs a
# specific build, and five is about a week of them -- short enough that the tag
# someone was given stops resolving while they are still using it.
- name: Prune old dev prereleases
if: steps.version.outputs.exists != 'true'
env:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
gh api "repos/$GITHUB_REPOSITORY/releases?per_page=100" \
--jq 'map(select(.prerelease and (.tag_name | endswith("-dev")))) | .[20:] | .[].tag_name' \
> stale.txt
while read -r tag; do
[ -n "$tag" ] || continue
echo "deleting $tag"
gh release delete "$tag" --repo "$GITHUB_REPOSITORY" --yes --cleanup-tag
done < stale.txt
+1 -1
View File
@@ -123,7 +123,7 @@ jobs:
INSTALLER_BIN="${RUNNER_TEMP}/bm-bins/spinifex-installer" \
SPINIFEX_SRC="${GITHUB_WORKSPACE}" \
VIPERBLOCK_SRC="${RUNNER_TEMP}/bm-bins/viperblock" \
VERSION="$(cd "$GITHUB_WORKSPACE" && git describe --tags --always 2>/dev/null || echo dev)"
VERSION="$(cd "$GITHUB_WORKSPACE" && git describe --tags --always --exclude '*-dev' 2>/dev/null || echo dev)"
- name: Write ePDU credentials
run: |
@@ -0,0 +1,287 @@
name: E2E Cloud Vendor Nightly
# Dispatched by the same Cloudflare Worker that drives e2e-nightly.yml, once per
# ref, rather than by a `schedule:` here. GitHub registers a schedule only from
# the default branch, so a cron in this file could never run `dev` and a feature
# branch as separate nightlies, and it would always execute the default branch's
# copy of this file -- leaving a fix to the workflow itself untestable until it
# had already shipped. A dispatch takes both the file and the source from its ref.
on:
workflow_dispatch:
inputs:
topologies:
description: "Space-separated topologies (blank = vm-single vm-multi bm vm-single-principal). One name runs one topology."
required: false
workbooks:
description: "Space-separated workbooks (blank = the driver's default list)"
required: false
source:
description: "tree (build this ref) or release (the published installer)"
required: false
default: tree
channel:
description: "With source=release: latest, or dev for the newest prerelease"
required: false
default: latest
keep_on_fail:
description: "Leave a failed topology up to inspect. It keeps billing."
required: false
type: boolean
default: false
auth:
description: "How nodes reach the OCI API: instance_principal (needs the tenancy dynamic group) or api_key (needs the credential hook). The vm-single-principal topology always uses instance_principal."
required: false
default: instance_principal
type: choice
options: [instance_principal, api_key]
no_external_pool:
description: "Form without the OCI allocator. Skips every workbook needing a public address. Not applied to vm-single-principal."
required: false
type: boolean
default: false
runner:
description: "Runner label (blank = the OCI_E2E_RUNNER variable, else ubuntu-26.04)"
required: false
permissions:
contents: read
# Every topology is built in one OCI compartment under one set of resource names,
# so two runs at once would each see the other's instances and the sweep in
# e2e-oci.sh could destroy a cluster the other run was still testing. Queue the
# second rather than cancelling it: a dispatch is usually a verification run.
concurrency:
group: e2e-oci
cancel-in-progress: false
jobs:
oci:
# Hosted by default: this job only drives Terraform and SSH, and the work
# happens on OCI, so nothing here needs a hypervisor. Overridable because the
# argument for a per-provider self-hosted runner is control, not capacity --
# a dedicated label keeps a two-hour cloud wait off the hypervisor labels and
# lifts the six-hour cap a hosted job carries.
runs-on: ${{ inputs.runner || vars.OCI_E2E_RUNNER || 'ubuntu-26.04' }}
# Three topologies including a bare-metal build, each with its own install,
# formation and workbook pass. The sweep still runs if this fires.
timeout-minutes: 300
steps:
# Set here rather than in a job-level env block: the runner context is not
# available at job level, only from a step onwards, and a workflow that
# references it there is rejected as invalid before any job starts.
#
# Outside github.workspace on purpose. The checkout wipes untracked files, so
# on a persistent runner it would delete the Terraform state of a killed run
# before the next sweep could read it, leaving instances nothing can find.
- name: Put the Terraform state outside the checkout
run: echo "OCI_STATE_ROOT=$RUNNER_WORKSPACE/oci-state" >> "$GITHUB_ENV"
- uses: actions/checkout@v7
with:
path: spinifex
- uses: actions/create-github-app-token@v3
id: app-token
with:
client-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
# mulga alone. It is the only private repository of the six, and the
# five that build-install-artifacts.sh clones are public, so the builder
# needs no credential and this token is not widened to reach them.
repositories: mulga
# build-install-artifacts.sh lives in mulga. The workbooks and their driver
# are in spinifex, so only the one directory is needed here.
- uses: actions/checkout@v7
with:
repository: mulgadc/mulga
path: mulga
token: ${{ steps.app-token.outputs.token }}
sparse-checkout: scripts/tofu-cluster
- uses: hashicorp/setup-terraform@v4
with:
terraform_wrapper: false
# Written to the runner's own filesystem and nowhere else. The OCI API
# credential is not here at all: it reaches Terraform as OCI_PRIVATE_KEY in
# the environment, so no key file is ever created.
- name: Install the throwaway SSH key
env:
OCI_E2E_SSH_PRIVATE_KEY: ${{ secrets.OCI_E2E_SSH_PRIVATE_KEY }}
OCI_E2E_SSH_PUBLIC_KEY: ${{ secrets.OCI_E2E_SSH_PUBLIC_KEY }}
run: |
set -euo pipefail
mkdir -p ~/.ssh
printf '%s\n' "$OCI_E2E_SSH_PRIVATE_KEY" > ~/.ssh/oci-spx
printf '%s\n' "$OCI_E2E_SSH_PUBLIC_KEY" > ~/.ssh/oci-spx.pub
chmod 600 ~/.ssh/oci-spx
ssh-keygen -y -f ~/.ssh/oci-spx > /dev/null
- name: Run the OCI topologies
working-directory: spinifex/scripts/terraform/oci-spx
env:
OCI_TENANCY_OCID: ${{ secrets.OCI_TENANCY_OCID }}
OCI_USER_OCID: ${{ secrets.OCI_USER_OCID }}
OCI_FINGERPRINT: ${{ secrets.OCI_FINGERPRINT }}
OCI_PRIVATE_KEY: ${{ secrets.OCI_PRIVATE_KEY }}
OCI_REGION: ${{ vars.OCI_REGION || 'ap-sydney-1' }}
TF_VAR_compartment_ocid: ${{ secrets.OCI_COMPARTMENT_OCID }}
MULGA_ROOT: ${{ github.workspace }}/mulga
SPX_REF: ${{ github.ref_name }}
OCI_TOPOLOGIES: ${{ inputs.topologies }}
OCI_SOURCE: ${{ inputs.source }}
OCI_CHANNEL: ${{ inputs.channel }}
# Independent of the pool, deliberately. These were one switch, which made
# the combination we actually need -- an API key with the allocator running
# -- impossible to ask for, so public-IP allocation went untested.
OCI_INSTANCE_PRINCIPAL: ${{ inputs.auth == 'api_key' && '0' || '1' }}
# Skipping the pool records the allocator and every public-address workbook
# as SKIPPED, so a green run with this set is not a claim about guest
# networking. It exists for a formation-only check, not as a way past a
# missing credential.
OCI_NO_EXTERNAL_POOL: ${{ inputs.no_external_pool && '1' || '0' }}
# Only an API-key run needs one: an instance principal is the node's own
# certificate, so there is nothing to hand over. Unset is not an error, and
# the allocator gate then fails rather than passing a node that cannot
# authenticate.
OCI_CREDENTIAL_HOOK: ${{ inputs.auth == 'api_key' && vars.OCI_CREDENTIAL_HOOK || '' }}
# vm-single-principal builds in its own tenancy. The dynamic group it needs
# is a tenancy-root resource, and the shared test tenancy grants us only
# compartment rights, so that topology can never pass there however correct
# the code is. Unset skips it rather than spending a provision to fail.
OCI_PRINCIPAL_TENANCY_OCID: ${{ secrets.OCI_PRINCIPAL_TENANCY_OCID }}
OCI_PRINCIPAL_USER_OCID: ${{ secrets.OCI_PRINCIPAL_USER_OCID }}
OCI_PRINCIPAL_FINGERPRINT: ${{ secrets.OCI_PRINCIPAL_FINGERPRINT }}
OCI_PRINCIPAL_PRIVATE_KEY: ${{ secrets.OCI_PRINCIPAL_PRIVATE_KEY }}
OCI_PRINCIPAL_COMPARTMENT_OCID: ${{ secrets.OCI_PRINCIPAL_COMPARTMENT_OCID }}
OCI_PRINCIPAL_REGION: ${{ vars.OCI_PRINCIPAL_REGION }}
# Service limits are per tenancy, so the shape the other topologies use
# can have zero quota in this one. Ours has no E6 cores at all, and the
# apply then fails before anything about the principal is exercised.
OCI_PRINCIPAL_SHAPE: ${{ vars.OCI_PRINCIPAL_SHAPE }}
OCI_ARTIFACT_DIR: ${{ runner.temp }}/e2e-oci
OCI_KEEP_ON_FAIL: ${{ inputs.keep_on_fail && '1' || '0' }}
# Through the environment, never interpolated into the script below: a
# dispatch input is attacker-controlled text as far as the shell is
# concerned, and a quote in it would otherwise end the string.
WORKBOOKS_INPUT: ${{ inputs.workbooks }}
run: |
set -uo pipefail
# WORKBOOKS is exported only when the dispatch named some: the harness
# distinguishes unset (the driver's default list) from empty (run none).
if [ -n "$WORKBOOKS_INPUT" ]; then export WORKBOOKS="$WORKBOOKS_INPUT"; fi
./e2e-oci.sh 2>&1 | tee "$RUNNER_TEMP/e2e-oci.log"
exit "${PIPESTATUS[0]}"
# Before the sweep, which destroys the state the tables are read from, and
# always(): a run whose verdict is a failure is exactly the one somebody
# wants a table for. The successful path writes the same tables itself, so
# this step is for the run that did not get there.
#
# The markdown lives in the script rather than here. A heredoc in a YAML
# string is the one place in this repository nothing can lint or run.
- name: Publish the result tables
if: always()
working-directory: spinifex/scripts/terraform/oci-spx
env:
OCI_REGION: ${{ vars.OCI_REGION || 'ap-sydney-1' }}
OCI_ARTIFACT_DIR: ${{ runner.temp }}/e2e-oci
OCI_TOPOLOGIES: ${{ inputs.topologies }}
OCI_SOURCE: ${{ inputs.source }}
OCI_CHANNEL: ${{ inputs.channel }}
SPX_REF: ${{ github.ref_name }}
# The same two the run step passes. The header states what was and was not
# tested, so a summary rendered without them would claim more than the run.
OCI_INSTANCE_PRINCIPAL: ${{ inputs.auth == 'api_key' && '0' || '1' }}
OCI_NO_EXTERNAL_POOL: ${{ inputs.no_external_pool && '1' || '0' }}
run: ./e2e-oci.sh --summary-only
# The sweep below deletes the per-topology state directories, so anything
# still only in one of them at that point is unrecoverable -- and a failing
# workbook writes its assertion output to exactly there. Copying into the
# artifact directory first is what makes a failure diagnosable by whoever
# reads the run page, rather than only on the runner that produced it.
- name: Collect the per-topology logs
if: always()
run: |
set -uo pipefail
dest="$RUNNER_TEMP/e2e-oci"
mkdir -p "$dest"
# The topologies this run drove, taken from the per-topology log the
# harness writes rather than from the dispatch input: a run that names
# none gets the harness's own default, and reading that back here would
# mean a second copy of the list to disagree with the first. A state
# directory left by an earlier run is then not collected, so the artifact
# cannot show a topology this run never attempted.
topologies=""
for log in "$dest"/*.log; do
[ -f "$log" ] || continue
name="${log##*/}"; name="${name%.log}"
[ -d "$OCI_STATE_ROOT/.validate-$name" ] && topologies="$topologies $name"
done
echo "collecting:${topologies:- nothing}"
for topology in $topologies; do
for file in "$OCI_STATE_ROOT/.validate-$topology"/*.log \
"$OCI_STATE_ROOT/.validate-$topology"/*.txt \
"$OCI_STATE_ROOT/.validate-$topology"/*.tsv; do
[ -f "$file" ] || continue
# The hook handles a private key and the one thing known about an
# operator's executable is that it is not ours to publish.
case "${file##*/}" in credential-hook.log) continue ;; esac
cp -- "$file" "$dest/$topology-${file##*/}"
done
done
ls -l "$dest"
# always(), and before the artifact upload: an OCI bare-metal host left
# running by a cancelled or timed-out job is the most expensive way this
# workflow can fail. The sweep works from the Terraform state files, so it
# can only destroy what this run created.
- name: Sweep anything left behind
if: always() && !inputs.keep_on_fail
working-directory: spinifex/scripts/terraform/oci-spx
env:
OCI_TENANCY_OCID: ${{ secrets.OCI_TENANCY_OCID }}
OCI_USER_OCID: ${{ secrets.OCI_USER_OCID }}
OCI_FINGERPRINT: ${{ secrets.OCI_FINGERPRINT }}
OCI_PRIVATE_KEY: ${{ secrets.OCI_PRIVATE_KEY }}
OCI_REGION: ${{ vars.OCI_REGION || 'ap-sydney-1' }}
TF_VAR_compartment_ocid: ${{ secrets.OCI_COMPARTMENT_OCID }}
# The sweep destroys by state file, so it needs whichever tenancy each
# topology built in. Without these a leftover principal deployment is
# swept against a tenancy that never held it and reported clean.
OCI_PRINCIPAL_TENANCY_OCID: ${{ secrets.OCI_PRINCIPAL_TENANCY_OCID }}
OCI_PRINCIPAL_USER_OCID: ${{ secrets.OCI_PRINCIPAL_USER_OCID }}
OCI_PRINCIPAL_FINGERPRINT: ${{ secrets.OCI_PRINCIPAL_FINGERPRINT }}
OCI_PRINCIPAL_PRIVATE_KEY: ${{ secrets.OCI_PRINCIPAL_PRIVATE_KEY }}
OCI_PRINCIPAL_COMPARTMENT_OCID: ${{ secrets.OCI_PRINCIPAL_COMPARTMENT_OCID }}
OCI_PRINCIPAL_REGION: ${{ vars.OCI_PRINCIPAL_REGION }}
OCI_ARTIFACT_DIR: ${{ runner.temp }}/e2e-oci-sweep
run: ./e2e-oci.sh --sweep-only
# Explicit, not left to the runner's own cleanup: a hosted runner is
# discarded after the job, but a self-hosted one keeps $HOME, so the key
# would outlive the run that needed it and sit there between nightlies.
- name: Remove the throwaway SSH key
if: always()
run: rm -f ~/.ssh/oci-spx ~/.ssh/oci-spx.pub
# Named extensions rather than a directory glob: the artifact directory also
# holds the distro tarball and the test archive, which are hundreds of
# megabytes and reproducible from the ref. Everything here is a file the
# collect step above chose, so terraform.tfstate cannot reach an artifact.
- name: Upload the run
if: always()
uses: actions/upload-artifact@v7
with:
name: e2e-oci-${{ github.run_id }}
path: |
${{ runner.temp }}/e2e-oci.log
${{ runner.temp }}/e2e-oci/*.log
${{ runner.temp }}/e2e-oci/verdict.txt
${{ runner.temp }}/e2e-oci/summary.md
${{ runner.temp }}/e2e-oci/*.txt
${{ runner.temp }}/e2e-oci/*.tsv
${{ runner.temp }}/e2e-oci-sweep/*.log
retention-days: 14
+86 -25
View File
@@ -1,18 +1,16 @@
name: E2E Nightly
# Triggered nightly by a Cloudflare Worker that dispatches this workflow once on
# `dev` and once on `main`, rather than by a `schedule:` here. GitHub registers a
# schedule only from the default branch, so a cron in this file can never run
# `main` and `dev` as separate nightlies, and it would always execute `main`'s
# copy of this file — leaving a fix to the workflow itself untestable until it
# had already shipped. A dispatch takes both the file and the source from its ref.
on:
schedule:
# 14:00 UTC is midnight AEST (01:00 AEDT — cron has no DST), when the
# hypervisors are idle. Every day, not weeknights: a weekend gap is two runs
# of evidence lost on a suite whose remaining failures are a rate, not a bug.
# :17 rather than :00 because GitHub queues scheduled runs behind every other
# top-of-hour cron and drops them under load, which is why 2026-09-14 never
# fired and why every run in that window started 3h15m-3h45m late.
- cron: "17 14 * * *"
workflow_dispatch:
inputs:
cells:
description: "Comma-separated cell numbers to run, no spaces (blank = all). 1-3, 8-10, 17-19 tofu permutation matrix, 20 baremetal, 21-22 ISO, 23 predastore throughput, 24 predastore fault injection, 25-27 service suites (eks, ecs, rds), 28 guest disk performance, 29 storage fault injection"
description: "Comma-separated cell numbers to run, no spaces (blank = all). 1-3, 8-10, 17-19 tofu permutation matrix, 20 baremetal, 21-22 ISO, 23 predastore throughput, 24 predastore fault injection, 25-27 service suites (eks, ecs, rds), 28 guest disk performance, 29 storage fault injection, 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:
@@ -33,7 +31,7 @@ concurrency:
cancel-in-progress: false
env:
GIT_BRANCH: ${{ github.event_name == 'schedule' && 'dev' || github.ref_name }}
GIT_BRANCH: ${{ github.ref_name }}
UBUNTU_GOLD_IMAGE: /mnt/disk1/gold-images/spinifex-image-builder-ubuntu-26.04.qcow2
SPINIFEX_OTLP_ENDPOINT: ${{ vars.SPINIFEX_OTLP_ENDPOINT || 'http://192.168.1.13:8200' }}
SPINIFEX_OTEL_ATTRS: ci.run_id=${{ github.run_id }},ci.workflow=${{ github.workflow }}
@@ -280,8 +278,31 @@ jobs:
rc=\${PIPESTATUS[0]}; echo '::endgroup::'; \
if [ \$rc -ne 0 ]; then echo 'natuplink: FAILED' >> '${ARTIFACT_DIR}/suite-status.txt'; fi; exit \$rc"
# natuplink proves a guest with a public IP reaches the internet. The two
# surfaces it never touches are a private subnet's isolation and its NAT
# gateway, and TestVPCEgressPaths is the authority on both — it skipped in
# nat mode until the fixture learned that routed NAT carries EIPs too.
- name: Run VPC Egress Paths in NAT mode
if: matrix.extra == 'nat-single'
working-directory: mulga/scripts/tofu-cluster
run: |
INVENTORY=$(tofu output -json inventory)
WAN_IP=$(echo "$INVENTORY" | jq -r '.nodes[0].wan_ip')
SSH_KEY=$(echo "$INVENTORY" | jq -r '.ssh_key_path')
SSH_KEY="${SSH_KEY/#\~/$HOME}"
ssh -tt -o StrictHostKeyChecking=no -i "$SSH_KEY" tf-user@${WAN_IP} \
"set -u; mkdir -p '${ARTIFACT_DIR}'; cd \$HOME/e2e-tests/_bin; \
echo '::group::suite single/TestVPCEgressPaths'; \
date -u +%Y-%m-%dT%H:%M:%SZ > '${ARTIFACT_DIR}/test-vpcegress-nat.start'; \
GITHUB_ACTIONS=true FORCE_COLOR=1 SPINIFEX_E2E=1 SPINIFEX_MODE=single \
ARTIFACT_DIR='${ARTIFACT_DIR}' \
./single.test -test.v -test.timeout=30m -test.run '^TestVPCEgressPaths\$' \
2>&1 | tee '${ARTIFACT_DIR}/test-vpcegress-nat.log'; \
rc=\${PIPESTATUS[0]}; echo '::endgroup::'; \
if [ \$rc -ne 0 ]; then echo 'vpcegress-nat: FAILED' >> '${ARTIFACT_DIR}/suite-status.txt'; fi; exit \$rc"
- name: Run Multi Node E2E
if: matrix.topo == 'real-multi' && matrix.suite == ''
if: matrix.topo == 'real-multi' && matrix.suite == '' && matrix.extra == ''
working-directory: mulga/scripts/tofu-cluster
run: |
set -euo pipefail
@@ -316,6 +337,40 @@ jobs:
if [ \$rc -ne 0 ]; then echo \"\$SUITE: FAILED\" >> '${ARTIFACT_DIR}/suite-status.txt'; FAIL=1; fi; \
done; exit \$FAIL"
# The same suite as the nat-single cell, run against three nodes: there it
# proves routed NAT works at all, here that each node delivers its own
# guests' public IPs and no others — which one node cannot show.
- name: Run NAT Uplink Multi E2E
if: matrix.extra == 'nat-multi'
working-directory: mulga/scripts/tofu-cluster
run: |
set -euo pipefail
INVENTORY=$(tofu output -json inventory)
NODE1_IP=$(echo "$INVENTORY" | jq -r '.nodes[0].wan_ip')
SSH_KEY=$(echo "$INVENTORY" | jq -r '.ssh_key_path')
SSH_KEY="${SSH_KEY/#\~/$HOME}"
NODES_CSV=""
NODE_COUNT=$(echo "$INVENTORY" | jq '.nodes | length')
for i in $(seq 0 $((NODE_COUNT - 1))); do
ip=$(echo "$INVENTORY" | jq -r ".nodes[$i].wan_ip")
NODES_CSV="${NODES_CSV:+$NODES_CSV,}$ip"
done
ssh -tt -o StrictHostKeyChecking=no -i "$SSH_KEY" tf-user@${NODE1_IP} \
"set -u; mkdir -p '${ARTIFACT_DIR}'; cd \$HOME/e2e-tests/_bin; \
if [ ! -f natuplink.test ]; then echo 'natuplink.test not in tests tar (branch predates suite) — skipping'; exit 0; fi; \
echo '::group::suite natuplink'; \
date -u +%Y-%m-%dT%H:%M:%SZ > '${ARTIFACT_DIR}/test-natuplink.start'; \
GITHUB_ACTIONS=true FORCE_COLOR=1 SPINIFEX_E2E=1 SPINIFEX_MODE=multinode \
SPINIFEX_NODES='${NODES_CSV}' SPINIFEX_NODE_IPS='${NODES_CSV}' \
SPINIFEX_SSH_USER=tf-user SPINIFEX_SSH_KEY=\$HOME/.ssh/tf-user-ap-southeast-2 \
ARTIFACT_DIR='${ARTIFACT_DIR}' \
./natuplink.test -test.v -test.timeout=30m \
2>&1 | tee '${ARTIFACT_DIR}/test-natuplink.log'; \
rc=\${PIPESTATUS[0]}; echo '::endgroup::'; \
if [ \$rc -ne 0 ]; then echo 'natuplink: FAILED' >> '${ARTIFACT_DIR}/suite-status.txt'; fi; exit \$rc"
# The dedicated suite cells only. Resolved once here rather than in each
# step below, which would repeat the same three lines five times.
- name: Resolve VM SSH details
@@ -822,12 +877,11 @@ jobs:
# Bare-metal single-node suite. Doesn't fit the tofu matrix shape, so it
# runs as a sibling job via the reusable e2e-baremetal workflow. Gated like a
# matrix cell ("cell 20"): every nightly cron, or dispatch with 20 selected
# (blank cells = all). Tests `dev` on schedule to match GIT_BRANCH above.
# matrix cell ("cell 20"): blank cells is the whole nightly, or name 20 to run
# it alone. A reusable call needs its own ref, since it cannot read GIT_BRANCH.
baremetal:
name: cell-20 (baremetal/pxe/real-hw)
if: >-
github.event_name == 'schedule' ||
inputs.cells == '' ||
contains(format(',{0},', inputs.cells), ',20,')
permissions:
@@ -835,7 +889,7 @@ jobs:
statuses: write
uses: ./.github/workflows/e2e-baremetal.yml
with:
ref: ${{ github.event_name == 'schedule' && 'dev' || github.ref_name }}
ref: ${{ github.ref_name }}
# A reusable-workflow call gets none of this workflow's context (env:
# above, matrix.cell) for free — cell-20 needs its own attrs string.
# There's no matrix leg here, just the one fixed baremetal cell.
@@ -855,7 +909,6 @@ jobs:
needs: baremetal
if: >-
!cancelled() && (
github.event_name == 'schedule' ||
inputs.cells == '' ||
contains(format(',{0},', inputs.cells), ',28,')
)
@@ -864,7 +917,7 @@ jobs:
statuses: write
uses: ./.github/workflows/e2e-baremetal.yml
with:
ref: ${{ github.event_name == 'schedule' && 'dev' || github.ref_name }}
ref: ${{ github.ref_name }}
cell: "28"
suites: diskperf
otel-attrs: ci.run_id=${{ github.run_id }},ci.workflow=${{ github.workflow }},ci.cell=28,ci.matrix=baremetal/pxe/diskperf
@@ -881,7 +934,6 @@ jobs:
iso-single:
name: cell-21 (iso/dhcp/single)
if: >-
github.event_name == 'schedule' ||
inputs.cells == '' ||
contains(format(',{0},', inputs.cells), ',21,')
runs-on: [self-hosted, ci-single]
@@ -1136,7 +1188,6 @@ jobs:
needs: [iso-single]
if: >-
!cancelled() && (
github.event_name == 'schedule' ||
inputs.cells == '' ||
contains(format(',{0},', inputs.cells), ',22,') )
runs-on: [self-hosted, ci-multi]
@@ -1298,7 +1349,6 @@ jobs:
needs: [matrix, baremetal, iso-single, iso-multi]
if: >-
!cancelled() && (
github.event_name == 'schedule' ||
inputs.cells == '' ||
contains(format(',{0},', inputs.cells), ',23,') )
runs-on: [self-hosted, ci-single]
@@ -1329,7 +1379,7 @@ jobs:
- name: Resolve sibling refs
id: siblings
env:
BRANCH: ${{ github.event_name == 'schedule' && 'dev' || github.ref_name }}
BRANCH: ${{ github.ref_name }}
run: bash .github/resolve-sibling-refs.sh
working-directory: spinifex
@@ -1410,7 +1460,6 @@ jobs:
needs: [matrix, baremetal, iso-single, iso-multi, performance]
if: >-
!cancelled() && (
github.event_name == 'schedule' ||
inputs.cells == '' ||
contains(format(',{0},', inputs.cells), ',24,') )
runs-on: [self-hosted, ci-single]
@@ -1454,7 +1503,7 @@ jobs:
- name: Resolve sibling refs
id: siblings
env:
BRANCH: ${{ github.event_name == 'schedule' && 'dev' || github.ref_name }}
BRANCH: ${{ github.ref_name }}
run: bash .github/resolve-sibling-refs.sh
working-directory: spinifex
@@ -1493,8 +1542,16 @@ jobs:
# Leftovers from a run killed before its cleanup trap: a surviving node
# holds its ports and fails this run somewhere unrelated.
#
# Under the host benchmark lock, because this pattern matches every
# predastore node on the runner and the predastore repository runs the
# same harnesses on the same machine. Holding the lock is what makes
# "still running" mean "stale" rather than "somebody else's benchmark":
# on 2026-09-16 this step killed a predastore run 32s into its workload.
- name: Clear stale nodes
run: pkill -KILL -f '/bin/s3d -config' || true
run: |
flock -w 3600 "${PREDA_BENCH_LOCK:-/var/lock/predastore-bench.lock}" \
pkill -KILL -f '/bin/s3d -config' || true
- name: Run fault injection
working-directory: predastore
@@ -1514,10 +1571,14 @@ jobs:
if-no-files-found: warn
retention-days: 90
# Under the lock for the same reason as "Clear stale nodes": by the time
# this runs the harness has released it, so another repository's benchmark
# can already have a cluster of its own up.
- name: Clean up
if: always()
run: |
pkill -KILL -f '/bin/s3d -config' || true
flock -w 3600 "${PREDA_BENCH_LOCK:-/var/lock/predastore-bench.lock}" \
pkill -KILL -f '/bin/s3d -config' || true
rm -rf "$STRESS_RESULTS_ROOT"
# Reading a nightly meant opening every cell job, and the Actions UI does not
+1 -1
View File
@@ -46,7 +46,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v7
with:
go-version: "1.27.0"
go-version-file: spinifex/go.mod
- name: Build microVM artifacts (lb-agent + kernel + initramfs)
run: cd spinifex && make build-microvm-image
+4 -6
View File
@@ -1,5 +1,8 @@
version: "2"
run:
allow-serial-runners: true
issues:
max-same-issues: 10
@@ -56,7 +59,6 @@ linters:
- unconvert # removes unnecessary type conversions
- usestdlibvars # detects hardcoded values that should use stdlib constants
- usetesting # catches os.TempDir() etc. in tests — should use t.TempDir()
- wastedassign # finds assignments to variables that are never subsequently read
- whitespace # detects leading and trailing whitespace in functions
settings:
@@ -143,15 +145,11 @@ linters:
staticcheck:
checks:
- all
- -ST1000 # package comment
- -ST1003 # naming conventions (Id→ID etc.) — too noisy to retrofit
- -ST1012 # error var naming — errname covers this + error type suffixes
- -ST1013 # HTTP status constants — usestdlibvars is broader
- -ST1016 # receiver name consistency (too noisy to retrofit)
- -ST1020 # godoc on exported types — not enforcing yet
- -ST1021 # godoc on exported type groups — not enforcing yet
- -ST1022 # godoc on exported vars/consts — not enforcing yet
- -SA4006 # value overwritten — ineffassign + wastedassign cover this better
- -SA4006 # value overwritten — ineffassign catches strictly more of these
- -SA9005 # marshaling structs with no exported fields — intended {} for empty API-response types
testifylint:
+96
View File
@@ -0,0 +1,96 @@
# How to Contribute
We welcome contributions to Spinifex!
Report security vulnerabilities privately through [SECURITY.md](SECURITY.md), not as a public issue.
**AWS parity is the design rule.** When Spinifex's behaviour is in question, the answer is what AWS does. If you believe Spinifex diverges from AWS, state what AWS does and show it with a test or a reproduction against a running cluster.
## Understanding the Codebase
Every AWS call enters through a SigV4-authenticated gateway, is published to NATS, and is answered by the daemon that claims it. Start with these:
| Doc | Covers |
|---|---|
| [`docs/DESIGN.md`](docs/DESIGN.md) | Architecture, request flow, daemons, storage integration, file layout. |
| [`docs/COMMANDS.md`](docs/COMMANDS.md) | The `spx admin` CLI and the systemd units it manages. |
| [`docs/coverage/`](docs/coverage/README.md) | Which AWS API operations each service implements. |
| [`scripts/README.md`](scripts/README.md) | Install, dev-environment, and image-build scripts. |
User-facing docs are published at [docs.mulgadc.com](https://docs.mulgadc.com).
## Development Setup
These steps build Spinifex from source and run it as a single-node cluster, following the [Source Install](https://docs.mulgadc.com/docs/install-source) guide.
### Prerequisites
- **Ubuntu 26.04** or **Debian 13**, on x86_64 or aarch64
- A host with hardware virtualisation (KVM) and `sudo` access
- The host's WAN interface enslaved to a Linux bridge named `br-wan`, with the host IP, default route and DHCP on the bridge rather than the bare NIC (see [VPC Networking → Bridge Setup](https://docs.mulgadc.com/docs/vpc-networking#bridge-setup-physical-network-wiring))
Check the bridge before continuing:
```bash
ip -br link show br-wan # bridge exists and is UP
ip route # default route's dev is br-wan
```
### Install and Run
```bash
mkdir -p ~/Development/mulga && cd ~/Development/mulga
git clone https://github.com/mulgadc/spinifex.git
sudo make -C spinifex quickinstall # system packages, Go 1.27, AWS CLI v2
export PATH=$PATH:/usr/local/go/bin
cd spinifex
./scripts/clone-deps.sh # sibling repos: viperblock, predastore, northstar, bluebottle
./scripts/dev-install.sh # build, install, init a one-node cluster, start services
AWS_PROFILE=spinifex aws ec2 describe-instance-types
```
A list of instance types means the cluster is up.
`make preflight` also needs `shellcheck` and `golangci-lint` (CI pins v2.13.0):
```bash
sudo apt-get install -y shellcheck
curl -sSfL https://golangci-lint.run/install.sh | sh -s -- -b "$(go env GOPATH)/bin" v2.13.0
```
The web console additionally needs Node.js 24 and `pnpm`.
## Development Workflow
| Command | Use it to |
|---|---|
| `make deploy` | Rebuild, install `spx`, and restart `spinifex.target` after a code change. |
| `make reinstall` | Re-run `dev-install.sh` after changing systemd units, helper scripts, or logrotate config. |
| `./scripts/reset-dev-env.sh` | Wipe all cluster state and reinstall from scratch, keeping your network topology. |
| `make test` | Run unit tests (`make test-race` for the race detector). |
| `make test-integration` | Run the real gateway router against embedded NATS JetStream. |
| `make preflight` | Run the pre-commit gate: lint, `govulncheck`, coverage, and manifest and script checks. Must pass. |
Follow the services with `journalctl -u 'spinifex-*' -f`.
The web console lives in `spinifex/services/spinifexui/frontend/` (`pnpm install`, `pnpm dev`, `pnpm lint`, `pnpm test`). `spx` embeds its checked-in `dist/`, so run `make build-ui` and commit the rebuilt `dist/` with your change.
## Creating a Pull Request
1. Fork [mulgadc/spinifex](https://github.com/mulgadc/spinifex) and clone your fork into the same parent directory as the sibling repositories, branching off `dev`:
```bash
cd ~/Development/mulga
git clone git@github.com:YOUR_USERNAME/spinifex.git && cd spinifex
git remote add upstream git@github.com:mulgadc/spinifex.git
git fetch upstream && git checkout -b feat/my-feature upstream/dev
```
2. Make your change, then verify it:
- `make preflight` passes.
- `go mod tidy` leaves only intended changes in `go.mod` and `go.sum`, with none of the `clone-deps.sh` `replace` lines.
- Runtime changes are exercised on your dev cluster with `make deploy`.
3. Commit using [Conventional Commits](https://www.conventionalcommits.org/) (`feat:`, `fix:`, `refactor:`, `docs:`, `chore:`), push, and open a pull request against `dev`, filling in the template.
+50 -8
View File
@@ -57,7 +57,7 @@ build-ui:
cd spinifex/services/spinifexui/frontend && pnpm build
# GO commands
VERSION ?= $(shell git describe --tags --always --dirty)
VERSION ?= $(shell git describe --tags --always --dirty --exclude '*-dev')
COMMIT ?= $(shell git rev-parse --short HEAD)
LDFLAGS := -s -w -X github.com/mulgadc/spinifex/cmd/spinifex/cmd.Version=$(VERSION) -X github.com/mulgadc/spinifex/cmd/spinifex/cmd.Commit=$(COMMIT)
@@ -149,21 +149,39 @@ install-microvm: $(MICROVM_ARTIFACTS) ## Install microVM artifacts to /usr/share
# the pre-commit gate: manifest checks, 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 QUIET=1 manifest-check manifest-lint lint govulncheck test-cover diff-coverage test-package-check test-harness test-build-scripts
@$(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
# ship on every node (unlike scripts/images/, kept in preflight: a wrong
# restart decision here is an outage, not asset churn).
#
# NODE_SCRIPTS are the four that setup.sh installs into
# /usr/local/share/spinifex on every node. They are covered here for the same
# reason: between them they install the software, wire OVN, erase the state and
# remove the install, so a shellcheck warning in one is an outage or a wrong
# deletion, not a style note.
NODE_SCRIPTS := scripts/setup.sh scripts/setup-ovn.sh scripts/install-node.sh scripts/smoke-test.sh scripts/node-reset.sh scripts/uninstall-spx.sh
test-build-scripts:
@echo -e "\n....Running build/scripts/**/*_test.sh...."
@for t in $$(find build/scripts -name '*_test.sh' | sort); do \
echo "-- $$t"; \
sh "$$t" || exit 1; \
done
@echo -e "\n....Running shellcheck over build/scripts/**/*.sh...."
shellcheck -S warning $$(find build/scripts -name '*.sh' | sort)
@echo -e "\n....Running scripts/*_test.sh...."
@for t in $$(find scripts -maxdepth 1 -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)
@echo " test-build-scripts ok"
# E2E harness unit tests. Build-tagged `e2e` so they're skipped by the
@@ -185,16 +203,18 @@ AWS_MODEL_OPERATION_COVERAGE_DIR ?= $(CURDIR)/docs/coverage
# -count=1 is load-bearing: the conformance report is written by the test binary,
# so a cached pass skips the run, leaves no report, and the cat below fails the
# target. It also keeps the conformance gate honest — a cached result would mean
# the check never ran against this commit.
# the check never ran against this commit. The generated request sweep adds
# about 15s, most of it EC2 fan-outs waiting out their window for instances
# no daemon-lite owns.
test-integration:
@echo -e "\n....Running in-process integration tests...."
$(_Q)LOG_IGNORE=1 AWS_MODEL_CONFORMANCE_MODE=$(AWS_MODEL_CONFORMANCE_MODE) AWS_MODEL_CONFORMANCE_REPORT=$(AWS_MODEL_CONFORMANCE_REPORT) go test -count=1 -tags=integration -timeout 60s ./tests/integration/... $(_RACEQ)
$(_Q)LOG_IGNORE=1 AWS_MODEL_CONFORMANCE_MODE=$(AWS_MODEL_CONFORMANCE_MODE) AWS_MODEL_CONFORMANCE_REPORT=$(AWS_MODEL_CONFORMANCE_REPORT) go test -count=1 -tags=integration -timeout 120s ./tests/integration/... $(_RACEQ)
@cat $(AWS_MODEL_CONFORMANCE_REPORT)
@$(MAKE) --no-print-directory generate-aws-model-coverage
@echo "AWS operation coverage: $(AWS_MODEL_OPERATION_COVERAGE_DIR)"
generate-aws-model-coverage:
@go run ./cmd/aws-model-coverage -out $(AWS_MODEL_OPERATION_COVERAGE_DIR)
@go run ./cmd/aws-model-coverage -out $(AWS_MODEL_OPERATION_COVERAGE_DIR) -json $(AWS_MODEL_OPERATION_COVERAGE_DIR)/coverage.json
aws-model-coverage: generate-aws-model-coverage
@go run ./cmd/aws-model-coverage
@@ -242,6 +262,17 @@ test-cover:
$(_Q)LOG_IGNORE=1 go test $(GOTESTFLAGS) -timeout 180s -coverprofile=$(COVERPROFILE) -covermode=atomic ./spinifex/... ./cmd/... ./internal/... $(_COVQ)
@scripts/check-coverage.sh $(COVERPROFILE) $(QUIET)
# Refresh the README coverage badge. Measured as awesome-go documents
# (-coverpkg=./... over ./...), with cmd/ entrypoints left out of the total.
coverage-badge:
@echo -e "\n....Measuring whole-module coverage for the README badge...."
LOG_IGNORE=1 go test $(GOTESTFLAGS) -timeout 600s -covermode=atomic -coverpkg=./... -coverprofile=coverage-badge.out ./...
@grep -v '/cmd/' coverage-badge.out > coverage-badge-nocmd.out
@pct=$$(go tool cover -func=coverage-badge-nocmd.out | tail -1 | awk '{print $$NF}' | tr -d '%'); \
color=$$(awk -v p="$$pct" 'BEGIN { print (p >= 80) ? "brightgreen" : (p >= 70) ? "yellow" : "red" }'); \
sed -i -E "s|badge/coverage-[0-9.]+%25-[a-z]+|badge/coverage-$${pct}%25-$${color}|" README.md; \
echo "Coverage $${pct}% ($${color}); README badge updated"
# Run unit tests with race detector
test-race:
@echo -e "\n....Running tests with race detector for $(GO_PROJECT_NAME)...."
@@ -298,6 +329,17 @@ bench:
@echo -e "\n....Running benchmarks for $(GO_PROJECT_NAME)...."
LOG_IGNORE=1 go test -benchmem -run=. -bench=. ./...
# Fuzz every Fuzz* target for FUZZTIME each; -fuzz takes one target per run.
# Plain `go test` already replays the seeds and testdata/fuzz on preflight.
FUZZTIME ?= 30s
fuzz:
@set -e; grep -rlE '^func Fuzz\w+\(' --include='*_test.go' spinifex cmd internal | while read -r file; do \
for name in $$(grep -oE '^func Fuzz\w+' "$$file" | cut -d' ' -f2); do \
echo -e "\n....Fuzzing $$name ($$(dirname $$file)) for $(FUZZTIME)...."; \
LOG_IGNORE=1 go test -run='^$$' -fuzz="^$$name$$" -fuzztime=$(FUZZTIME) ./$$(dirname $$file); \
done; \
done
# Fast iteration: build + install binary + restart all services.
# Microvm artifacts are reinstalled when they already exist on disk — the rule's
# input timestamps drive a rebuild only if anything actually changed. On a fresh
@@ -433,7 +475,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 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 coverage-badge 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 \
deploy reinstall clean \
install-system install-go install-aws quickinstall \
lint fix govulncheck nilaway \
+2
View File
@@ -4,6 +4,8 @@
<p align="center">
<a href="https://go.dev"><img src="https://img.shields.io/badge/Go-1.27+-00ADD8?style=flat-square&logo=go&logoColor=white" alt="Go"></a>
<a href="https://pkg.go.dev/github.com/mulgadc/spinifex"><img src="https://img.shields.io/badge/pkg.go.dev-reference-007D9C?style=flat-square&logo=go&logoColor=white" alt="Go Reference"></a>
<a href="https://github.com/mulgadc/spinifex/blob/main/Makefile"><img src="https://img.shields.io/badge/coverage-82.9%25-brightgreen?style=flat-square" alt="Coverage"></a>
<a href="LICENSE"><img src="https://img.shields.io/badge/License-AGPL--3.0-3fb950?style=flat-square" alt="License"></a>
<a href="https://mulgadc.com"><img src="https://img.shields.io/badge/home-mulga-orange?style=flat-square&logo=data:image/svg%2bxml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGZpbGw9IiNmZmYiIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTE2LjcxOCA4Ljg5MWMtMS4yODUgMS4zNy0zLjE3OCAyLjMxNy00LjY5NSAzLjQ0LS44NTQuNjMtMy4wODkgMi4yNy0xLjYxIDMuMzQ0IDEuNzg4IDEuMjk4IDYuMjQzLjE2NCA3Ljc4NS0xLjI2Ljg0LS43NzUuODE0LTEuODIyLS4zMjgtMi4yNTktMS4yMzUtLjQ3Mi0yLjY2LS4xMTEtMy45MTMuMDg3LS4wNDIuMDA3LS4xMzEuMDU1LS4xMjMtLjAyLjUyMy0uMzM2Ljk5My0uNzUgMS41MDMtMS4xMDIuNDkyLS4zNCAxLjA5Ny0uODI3IDEuNy0uODM4IDEuODcxLS4wMzQgMy43OTkuODkgNC4yODcgMi44MTUuODExIDMuMjAzLTMuMDA2IDUuNzE1LTUuNzg0IDUuOTQybDEuNjE0LS43NDdjLjYwNS0uMzYgMS4yMTctLjczNCAxLjc1Mi0xLjE5Ni4xMzMtLjExNS4yMy0uMjYzLjM0Mi0uMzczLjAyNy0uMDI2LjMwMi0uMTQ0LjE0NC0uMTUtMS40NDEuOTQ1LTMuMTI3IDEuNTcyLTQuODEzIDEuOTMyLS41OC4xMjMtMS4xOTUuMjQyLTEuNzg1LjI1Ny4wMTUuMDguMDg3LjA2NC4xNC4wOC40NzYuMTU0IDEuMDIuMjQ1IDEuNTE2LjMyLjA2OC4wNTItLjAwNi4wNzQtLjA2LjA4MS0uNDQ4LjA1OC0uOTIzLjE0My0xLjM3LjE2Ni0xLjI3LjA2NC0yLjU1LS4wNjgtMy43NzctLjM4bC0uNjktMS45NTdjLS4wNi4wMDYtLjA2My4wNi0uMDc5LjEwMy0uMDYuMTctLjMwNSAxLjQxNy0uMzg3IDEuNDM1LS42Ni0uMi0xLjI1MS0uNjQ3LTEuNzM2LTEuMTMybDEuMTUtMi4wNi0xLjgzNSAxLjAxMWMtLjI1OC0uNTgzLS4zMDUtMS4yNDMtLjIxOS0xLjg3bDIuMzc1LTEuMThjLS43MzktLjA0OS0xLjQ4Ni4wNjgtMi4yMi4xNDUuMTI1LS4zNi4yNzctLjcxOC40NjItMS4wNTEuMDY3LS4xMjIuNDI2LS43MjEuNTItLjcyNC44Ni4xNjQgMS43ODYuMjQxIDIuNjQ4LjA3NGwtMS44Ny0uNzcxLS4wNjktLjA5M2MuMzgyLS4zNi43Ny0uNzE3IDEuMTk5LTEuMDI0LjgwNC4yMzQgMS42My40MjIgMi40NzMuNDMzTDkuMzUgOS4zNDJhNyA3IDAgMCAxIC41NjctLjQwMWMuMTYtLjEwMy42MDktLjQwMi43NzItLjM4LjY1LjIyMSAxLjMyNC4zNzggMi4wMS40MzMtLjMxMy0uMzI1LS45MDktLjU2NC0xLjIxLS44NjgtLjAzMy0uMDM0LS4wNTgtLjAzNi0uMDQzLS4wOTguNzg3LS40NTIgMS42NjItLjkwMSAyLjM0LTEuNTE3LjY5LS42MjkgMS40MjEtMS42NTYuOTI0LTIuNjA1LjU3My4xODIgMS4wNjcuOTA2IDEuMDU0IDEuNTEyLS4wMzQgMS42NzYtMS44MjIgMy4xNy0yLjk0MyA0LjIyMiAxLjczMi0uNzI4IDMuNzE0LTIuMjMgMy43MS00LjMwNS0uMDAzLTEuNjg4LTEuNTQtMi4zNjUtMi45OTMtMi41MThhMy4yIDMuMiAwIDAgMS0uMzg1IDEuMDg3Yy0uNDI4LjcxOS0xLjMwMiAxLjE2OC0xLjc1OCAxLjkxNS0uMzExLjUxLS4zNyAxLjE5NS0xLjAzMSAxLjM5LS4yNy4wOC0uNjE3LjA5My0uODk3LjA5NGwuNjE1LS4zMjVjLjY4OS0uNTA2LjY3Ny0xLjQyIDEuMDk5LTIuMS0uMDUtLjA3LS41NDUtLjItLjY2LS4yMjktLjUxLS4xMjUtMS4zNzUtLjI4NS0xLjg4Ni0uMjUtLjE1Ny4wMS0uODY5LjEyMS0uOTMuMjQyLS4wODguMTc3LjM0MS45My40NjggMS4xMDEuMDI1LjAzNS4wNzcuMDI2LjA4LjAzLjAyNC4wMzEuMDA3LjEwNS0uMDYuMDhhNSA1IDAgMCAxLS40MDQtLjI4M2MtLjUwMy0uMzg4LTEuMDc4LS45NzQtMS40OTktMS40NDctLjA2OC0uMTY2LS4xNi0uMzUuMDAyLS40ODcuODg4LS41NSAxLjc2OC0xLjEyNiAyLjY3LTEuNjUxIDIuNjcxLTEuNTU4IDUuNzExLTMuMzE4IDguMjY3LS40NjUgMi4xMTkgMi4zNjYgMS41MTQgNS4yMTItLjUxMiA3LjM3MnptLTguMjI0LTUuMzhjLjY5OS0uMTIyIDIuMDE4LjU3MiAyLjIzNS0uNDU2LjAyMS0uMS0uMDM2LS4xNTcuMDItLjI1NS4wNDMtLjA3My4yODYtLjI1LjM3LS4zMTguMjctLjIxNy41NzctLjM4Ny44NDItLjYxMi0xLjAyOS4xNjYtMi4wNjUuNzU2LTIuOTY0IDEuMjc3LS4xNDkuMDg2LS4zMS4xNzYtLjQ1MS4yNzMtLjAzNy4wMjUtLjA3NS0uMDA2LS4wNTMuMDltLTQuODMgMTAuODQ0Yy0xLjcxNSAxLjg4MS0xLjMyNSA0LjU3LjQ5NCA2LjIxNyAxLjgxMSAxLjY0MSA0LjY2IDIuMjIzIDcuMDQ4IDIuMTA4IDIuMTUyLS4xMDMgNC4zMzctLjgxMiA2LjQ2LS4wOTEuODI1LjI4IDEuNTQ2LjgwNSAyLjE2IDEuNDExLS4wODMtLjQtLjMyNC0uODE5LS41NTgtMS4xNTktMi4xMy0zLjA5NS02LjI3LTEuOTM1LTkuNDI2LTIuNTUzLTEuNzExLS4zMzUtMy40OTEtMS4xMTgtNC41MzMtMi41NjctLjkwMS0xLjI1My0xLjA0Mi0yLjczLS41OTUtNC4xOTQtLjA5MS0uMDktLjk1Mi43MjEtMS4wNS44MjhtNi4zNjYtOC42NjdjLS4zODIuMzM5LS43ODcuNjYtMS4yMTIuOTQ4bC0xLjIwNy41OWMxLjAyMi4wMzkgMi4wOC0uNTQ2IDIuNDItMS41MzciLz48L3N2Zz4=" alt="mulgadc.com"></a>
<a href="https://docs.mulgadc.com"><img src="https://img.shields.io/badge/docs-spinifex-0969da?style=flat-square&logo=data:image/svg%2bxml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGZpbGw9IiNmZmYiIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTYuNSAySDIwYTEgMSAwIDAgMSAxIDF2MTQuNWExIDEgMCAwIDEtMSAxSDYuNWExLjUgMS41IDAgMCAwIDAgM0gyMWExIDEgMCAwIDEgMCAySDYuNUEzLjUgMy41IDAgMCAxIDMgMjBWNS41QTMuNSAzLjUgMCAwIDEgNi41IDJ6bTEgNGExIDEgMCAwIDAgMCAyaDlhMSAxIDAgMCAwIDAtMmgtOXptMCA0YTEgMSAwIDAgMCAwIDJoOWExIDEgMCAwIDAgMC0yaC05eiIvPjwvc3ZnPg==" alt="docs.mulgadc.com"></a>
+11 -3
View File
@@ -14,18 +14,26 @@ import (
func main() {
outputDir := flag.String("out", "", "write one publishable page per service into this directory")
jsonOutput := flag.String("json", "", "write a machine-readable coverage inventory to this file")
flag.Parse()
coverages, err := compareAll()
if err != nil {
fail(err)
}
if *outputDir == "" {
if *outputDir == "" && *jsonOutput == "" {
fmt.Print(awsmodel.RenderCoverageSummary(coverages))
return
}
if err := awsmodel.WritePages(*outputDir, coverages); err != nil {
fail(err)
if *outputDir != "" {
if err := awsmodel.WritePages(*outputDir, coverages); err != nil {
fail(err)
}
}
if *jsonOutput != "" {
if err := awsmodel.WriteCoverageJSON(*jsonOutput, coverages); err != nil {
fail(err)
}
}
}
+38 -22
View File
@@ -30,10 +30,13 @@ type Agent struct {
cfg config
id identity
cp controlPlane
puller ctrruntime.ImagePuller
runner ctrruntime.Runner
resolver ctrruntime.Resolver
// rt holds the container runtime, which is resolved by a loop rather than
// once at boot. Empty until the containerd socket answers, and emptied again
// if it stops answering.
rt *runtimeGate
reg *registrar
hb *heartbeater
@@ -48,26 +51,34 @@ type Agent struct {
// nvidia-smi discovery.
gpu *gpuLedger
// dialRuntime connects to the container runtime. nil in unit tests, which
// drive the gate directly.
dialRuntime func() (ctrruntime.Runtime, error)
closers []func() error
}
// newAgent assembles an Agent from already-built seams. Tests use this directly
// with fakes; New builds the production seams and delegates here. runner may be
// nil when containerd is unavailable; the assign path then reports the task
// STOPPED rather than crashing.
func newAgent(cfg config, id identity, cp controlPlane, puller ctrruntime.ImagePuller, runner ctrruntime.Runner, resolver ctrruntime.Resolver) *Agent {
return &Agent{
// with fakes; New builds the production seams and delegates here. rt may be nil
// when containerd is unavailable; the assign path then reports the task STOPPED
// rather than crashing, and the watch loop keeps dialling for it.
func newAgent(cfg config, id identity, cp controlPlane, rt ctrruntime.Runtime, resolver ctrruntime.Resolver) *Agent {
gate := newRuntimeGate(rt)
a := &Agent{
cfg: cfg,
id: id,
cp: cp,
puller: puller,
runner: runner,
resolver: resolver,
reg: newRegistrar(cp, id),
hb: newHeartbeater(cp, id, cfg.Heartbeat),
rt: gate,
netns: newTaskNetns(execNetRunner{}),
gpu: newGPULedger(id.Capacity.GPUIDs),
}
// Registration reports the host's capacity only while a runtime is there to
// use it, so an agent that cannot run a container advertises none and takes
// no placement.
a.reg = newRegistrar(cp, id, gate.available)
a.hb = newHeartbeater(cp, id, cfg.Heartbeat, gate.available)
return a
}
// New builds an Agent from config: it resolves the host identity from IMDS,
@@ -89,6 +100,7 @@ func New(cfg config) (*Agent, error) {
ClusterName: cfg.ClusterName,
InstanceID: meta.InstanceID,
AZ: meta.AZ,
InstanceType: meta.InstanceType,
Hostname: host,
Capacity: detectCapacity(discoverNvidiaGPUs(execCommandRunner)),
AgentVersion: version,
@@ -97,13 +109,12 @@ func New(cfg config) (*Agent, error) {
creds := credentials.NewIMDSProvider(imdsClient, cfg.IMDSBase)
resolver := newLazyECRResolver(creds, cfg.Region, cfg.GatewayURL, cfg.GatewayCA)
var puller ctrruntime.ImagePuller
var runner ctrruntime.Runner
if rt, perr := ctrruntime.New(cfg.ContainerdSocket); perr != nil {
slog.Warn("ecs-agent: containerd unavailable at boot, image pulls disabled", "err", perr)
} else {
puller = rt
runner = rt
// A dial that fails here is not fatal and not final: the node starts the
// container daemon and the agent back to back, so the socket is often not
// accepting yet. Run's watch loop keeps dialling.
rt, perr := ctrruntime.New(cfg.ContainerdSocket)
if perr != nil {
slog.Warn("ecs-agent: containerd unavailable at boot, will keep retrying", "err", perr)
}
cp, err := newGatewayControlPlane(cfg, creds)
@@ -111,12 +122,10 @@ func New(cfg config) (*Agent, error) {
return nil, fmt.Errorf("build gateway control-plane: %w", err)
}
a := newAgent(cfg, id, cp, puller, runner, resolver)
a := newAgent(cfg, id, cp, rt, resolver)
a.cred = newCredEndpoint(creds, cfg.Region, cfg.GatewayURL, cfg.GatewayCA,
cfg.CredEndpointIP, cfg.CredEndpointPort, execNetRunner{})
if puller != nil {
a.closers = append(a.closers, puller.Close)
}
a.dialRuntime = func() (ctrruntime.Runtime, error) { return ctrruntime.New(cfg.ContainerdSocket) }
return a, nil
}
@@ -137,6 +146,12 @@ func (a *Agent) Run(ctx context.Context) error {
go a.hb.Run(ctx)
// Keeps the runtime current for the life of the process: dials while there
// is none, and drops one that stops answering.
if a.dialRuntime != nil {
go a.rt.watch(ctx, a.dialRuntime, a.cfg.RuntimeRetry)
}
// Re-adopt containers still running from before this restart, then poll with
// their tasks pre-seeded so re-delivered assignments are acked, not re-run.
go a.pollAssignments(ctx, a.reconcile(ctx))
@@ -156,6 +171,7 @@ func (a *Agent) Stop() error {
firstErr = err
}
}
a.rt.clear()
return firstErr
}
+1 -1
View File
@@ -12,7 +12,7 @@ func TestAgent_RunRegistersThenStopsOnContext(t *testing.T) {
cp := &fakeCP{}
cfg := config{Heartbeat: 5 * time.Millisecond, PollInterval: 5 * time.Millisecond}
puller := &ctrruntime.FakePuller{}
a := newAgent(cfg, testIdentity(), cp, puller, puller, nil)
a := newAgent(cfg, testIdentity(), cp, puller, nil)
a.closers = append(a.closers, puller.Close)
ctx, cancel := context.WithCancel(context.Background())
+7
View File
@@ -33,6 +33,7 @@ type config struct {
ContainerdSocket string
Heartbeat time.Duration
PollInterval time.Duration
RuntimeRetry time.Duration
CredEndpointIP string
CredEndpointPort int
}
@@ -75,6 +76,12 @@ func loadConfig(envFile string) config {
cfg.PollInterval = d
}
}
cfg.RuntimeRetry = defaultRuntimeRetry
if v := get("ECS_RUNTIME_RETRY"); v != "" {
if d, err := time.ParseDuration(v); err == nil && d > 0 {
cfg.RuntimeRetry = d
}
}
cfg.CredEndpointIP = get("ECS_CRED_ENDPOINT_IP")
if cfg.CredEndpointIP == "" {
cfg.CredEndpointIP = defaultCredEndpointIP
+4
View File
@@ -76,6 +76,10 @@ func (g *gatewayControlPlane) Register(id identity) error {
{Name: aws.String("MEMORY"), Type: aws.String("INTEGER"), IntegerValue: aws.Int64(int64(id.Capacity.MemoryMiB))},
},
VersionInfo: &ecs.VersionInfo{AgentVersion: aws.String(id.AgentVersion)},
// The built-in container-instance attributes AWS's own agent reports.
// The scheduler sizes the host's awsvpc ENI capacity from the instance
// type, so a registration without one is not ENI-gated.
Attributes: instanceAttributes(id),
}
if len(id.Capacity.GPUIDs) > 0 {
in.TotalResources = append(in.TotalResources, &ecs.Resource{
+8 -3
View File
@@ -14,18 +14,23 @@ type heartbeater struct {
cp controlPlane
id identity
interval time.Duration
// runtimeReady reports whether a container runtime is resolved, so a beat
// advertises capacity only while there is something to run it with. nil
// means always ready.
runtimeReady func() bool
}
func newHeartbeater(cp controlPlane, id identity, interval time.Duration) *heartbeater {
func newHeartbeater(cp controlPlane, id identity, interval time.Duration, runtimeReady func() bool) *heartbeater {
if interval <= 0 {
interval = defaultHeartbeat
}
return &heartbeater{cp: cp, id: id, interval: interval}
return &heartbeater{cp: cp, id: id, interval: interval, runtimeReady: runtimeReady}
}
// beat re-registers the instance once, refreshing its LastSeen.
func (h *heartbeater) beat() error {
return h.cp.Register(h.id)
ready := h.runtimeReady == nil || h.runtimeReady()
return h.cp.Register(advertisedIdentity(h.id, ready))
}
// Run beats every interval until ctx is cancelled. A failed beat is logged and
+11 -6
View File
@@ -13,9 +13,10 @@ import (
// instanceMetadata is the IMDS-derived identity of the host the agent runs on.
type instanceMetadata struct {
AccountID string
InstanceID string
AZ string
AccountID string
InstanceID string
AZ string
InstanceType string
}
// fetchInstanceMetadata reads the instance-id, AZ and account-id from IMDSv2
@@ -39,10 +40,14 @@ func fetchInstanceMetadata(client *http.Client, base string) (instanceMetadata,
return instanceMetadata{}, fmt.Errorf("identity document: %w", err)
}
// The instance type comes off the identity document already in hand rather
// than a further meta-data read; the scheduler sizes the host's awsvpc ENI
// capacity from it.
return instanceMetadata{
AccountID: doc.AccountID,
InstanceID: strings.TrimSpace(instanceID),
AZ: strings.TrimSpace(az),
AccountID: doc.AccountID,
InstanceID: strings.TrimSpace(instanceID),
AZ: strings.TrimSpace(az),
InstanceType: strings.TrimSpace(doc.InstanceType),
}, nil
}
+8 -3
View File
@@ -24,10 +24,11 @@ import (
// agent falls back to its pre-reconcile behaviour (poll + run).
func (a *Agent) reconcile(ctx context.Context) map[string]bool {
adopted := map[string]bool{}
if a.runner == nil {
runner := a.rt.get()
if runner == nil {
return adopted
}
containers, err := a.runner.List(ctx)
containers, err := runner.List(ctx)
if err != nil {
slog.Warn("ecs-agent: reconcile list failed", "err", err)
return adopted
@@ -96,9 +97,13 @@ func (a *Agent) reconcile(ctx context.Context) map[string]bool {
// is STOPPED, so the scheduler stops counting a task this instance is not
// running and its service can place a replacement.
func (a *Agent) reportStoppedTask(ctx context.Context, as *bus.Assign, dead []ctrruntime.Container) {
runner := a.rt.get()
statuses := make([]bus.ContainerStatus, 0, len(dead))
for _, c := range dead {
if err := a.runner.Remove(ctx, c.ID); err != nil {
if runner == nil {
break
}
if err := runner.Remove(ctx, c.ID); err != nil {
slog.Warn("ecs-agent: reconcile remove failed", "task", as.TaskID, "container", c.ID, "err", err)
}
statuses = append(statuses, bus.ContainerStatus{
+7 -7
View File
@@ -55,7 +55,7 @@ func TestReconcile_AdoptsRunningLabeledContainer(t *testing.T) {
adoptedContainer("t-001", "web", "default", "t-001", testTaskRole, "52:54:00:de:ad:01", true),
},
}
a := newAgent(config{}, testIdentity(), cp, rt, rt, nil)
a := newAgent(config{}, testIdentity(), cp, rt, nil)
a.cred = newCredEndpoint(nil, "us-east-1", "https://gw", "", "127.0.0.1", 0, nil)
adopted := a.reconcile(context.Background())
@@ -84,7 +84,7 @@ func TestReconcile_ReportsTaskWhoseContainerDied(t *testing.T) {
adoptedContainer("t-dead", "web", "default", "t-dead", testTaskRole, "52:54:00:de:ad:02", false),
},
}
a := newAgent(config{}, testIdentity(), cp, rt, rt, nil)
a := newAgent(config{}, testIdentity(), cp, rt, nil)
a.cred = newCredEndpoint(nil, "us-east-1", "https://gw", "", "127.0.0.1", 0, nil)
adopted := a.reconcile(context.Background())
@@ -116,7 +116,7 @@ func TestReconcile_PartiallyDeadTaskStaysRunning(t *testing.T) {
adoptedContainer("t-mixed", "sidecar", "default", "t-mixed", testTaskRole, "52:54:00:de:ad:03", false),
},
}
a := newAgent(config{}, testIdentity(), cp, rt, rt, nil)
a := newAgent(config{}, testIdentity(), cp, rt, nil)
a.cred = newCredEndpoint(nil, "us-east-1", "https://gw", "", "127.0.0.1", 0, nil)
a.reconcile(context.Background())
@@ -145,7 +145,7 @@ func TestPollAssignments_SeededTaskNotRerun(t *testing.T) {
nil,
}}
rt := &ctrruntime.FakePuller{WaitErr: errors.New("blocked")}
a := newAgent(config{PollInterval: 5 * time.Millisecond}, testIdentity(), cp, rt, rt, nil)
a := newAgent(config{PollInterval: 5 * time.Millisecond}, testIdentity(), cp, rt, nil)
ctx, cancel := context.WithCancel(context.Background())
go a.pollAssignments(ctx, map[string]bool{"t-001": true})
@@ -197,7 +197,7 @@ func TestReconcile_IgnoresOtherClusterContainers(t *testing.T) {
adoptedContainer("t-ok", "web", "default", "", "", "", true),
},
}
a := newAgent(config{}, testIdentity(), cp, rt, rt, nil)
a := newAgent(config{}, testIdentity(), cp, rt, nil)
adopted := a.reconcile(context.Background())
@@ -213,13 +213,13 @@ func TestReconcile_IgnoresOtherClusterContainers(t *testing.T) {
// A nil runner or a List error degrades to an empty set without panicking.
func TestReconcile_DegradesGracefully(t *testing.T) {
noRunner := newAgent(config{}, testIdentity(), &fakeCP{}, nil, nil, nil)
noRunner := newAgent(config{}, testIdentity(), &fakeCP{}, nil, nil)
if got := noRunner.reconcile(context.Background()); len(got) != 0 {
t.Errorf("nil runner: adopted = %v, want empty", got)
}
rt := &ctrruntime.FakePuller{ListErr: errors.New("boom")}
listErr := newAgent(config{}, testIdentity(), &fakeCP{}, rt, rt, nil)
listErr := newAgent(config{}, testIdentity(), &fakeCP{}, rt, nil)
if got := listErr.reconcile(context.Background()); len(got) != 0 {
t.Errorf("list error: adopted = %v, want empty", got)
}
+42 -3
View File
@@ -3,6 +3,9 @@ package main
import (
"fmt"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ecs"
handlers_ecs "github.com/mulgadc/spinifex/spinifex/handlers/ecs"
"github.com/mulgadc/spinifex/spinifex/handlers/ecs/bus"
)
@@ -14,25 +17,61 @@ type identity struct {
ClusterName string
InstanceID string
AZ string
InstanceType string
Hostname string
Capacity bus.InstanceCapacity
AgentVersion string
}
// instanceAttributes builds the built-in container-instance attributes from the
// host's identity. Only attributes whose value was actually discovered are
// reported, so an empty one is absent rather than present and blank.
func instanceAttributes(id identity) []*ecs.Attribute {
var attrs []*ecs.Attribute
add := func(name, value string) {
if value == "" {
return
}
attrs = append(attrs, &ecs.Attribute{Name: aws.String(name), Value: aws.String(value)})
}
add(handlers_ecs.AttrInstanceType, id.InstanceType)
add(handlers_ecs.AttrAvailabilityZone, id.AZ)
return attrs
}
// advertisedIdentity is the identity to register with, given whether a
// container runtime is available. A host with no runtime can run nothing, so it
// advertises no capacity: the scheduler places nothing on it, and the control
// plane reads the zero as an involuntary drain. The identity is otherwise
// unchanged, so the instance stays visible rather than disappearing.
func advertisedIdentity(id identity, runtimeReady bool) identity {
if runtimeReady {
return id
}
id.Capacity = bus.InstanceCapacity{}
return id
}
// registrar registers the host as a container instance through the gateway.
type registrar struct {
cp controlPlane
id identity
// runtimeReady reports whether a container runtime is resolved. nil means
// always ready, which is what a test with no gate wants.
runtimeReady func() bool
}
func newRegistrar(cp controlPlane, id identity) *registrar {
return &registrar{cp: cp, id: id}
func newRegistrar(cp controlPlane, id identity, runtimeReady func() bool) *registrar {
return &registrar{cp: cp, id: id, runtimeReady: runtimeReady}
}
// ready reports the runtime state, defaulting to ready when no gate is wired.
func (r *registrar) ready() bool { return r.runtimeReady == nil || r.runtimeReady() }
// Register calls the gateway's RegisterContainerInstance. The scheduler records
// (or refreshes) the container instance on receipt.
func (r *registrar) Register() error {
if err := r.cp.Register(r.id); err != nil {
if err := r.cp.Register(advertisedIdentity(r.id, r.ready())); err != nil {
return fmt.Errorf("register instance %s: %w", r.id.InstanceID, err)
}
return nil
+76 -14
View File
@@ -3,6 +3,7 @@ package main
import (
"context"
"errors"
"slices"
"sync"
"testing"
"time"
@@ -17,14 +18,16 @@ import (
type fakeCP struct {
mu sync.Mutex
registers int
states []bus.TaskState
gpuReports []gpuReport
pollAcks [][]string
pollStopAcks [][]string
pollReplies [][]bus.Assign
stopReplies [][]bus.StopDirective
pollCalls int
registers int
lastRegistered identity
states []bus.TaskState
gpuReports []gpuReport
pollAcks [][]string
pollStopAcks [][]string
pollReplies [][]bus.Assign
stopReplies [][]bus.StopDirective
stopPending []bus.StopDirective
pollCalls int
registerErr bool
submitErr bool
@@ -39,16 +42,24 @@ type gpuReport struct {
var _ controlPlane = (*fakeCP)(nil)
func (f *fakeCP) Register(_ identity) error {
func (f *fakeCP) Register(id identity) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.registerErr {
return errors.New("register boom")
}
f.registers++
f.lastRegistered = id
return nil
}
// registered returns the identity of the most recent Register call.
func (f *fakeCP) registered() identity {
f.mu.Lock()
defer f.mu.Unlock()
return f.lastRegistered
}
func (f *fakeCP) SubmitTaskState(st bus.TaskState) error {
f.mu.Lock()
defer f.mu.Unlock()
@@ -72,6 +83,12 @@ func (f *fakeCP) PollAssignments(_, _ string, ackAssigns, ackStops []string) ([]
if f.pollCalls < len(f.stopReplies) {
stops = f.stopReplies[f.pollCalls]
}
// stopPending is the durable inbox the gateway keeps: an acked directive is
// deleted, and everything still in it is redelivered on every poll.
f.stopPending = slices.DeleteFunc(f.stopPending, func(sd bus.StopDirective) bool {
return slices.Contains(ackStops, sd.TaskID)
})
stops = slices.Concat(stops, f.stopPending)
f.pollCalls++
return out, stops, nil
}
@@ -142,7 +159,7 @@ func testIdentityWithGPUs(uuids ...string) identity {
func TestRegistrar_Register(t *testing.T) {
cp := &fakeCP{}
if err := newRegistrar(cp, testIdentity()).Register(); err != nil {
if err := newRegistrar(cp, testIdentity(), nil).Register(); err != nil {
t.Fatalf("Register: %v", err)
}
if cp.registerCount() != 1 {
@@ -152,14 +169,14 @@ func TestRegistrar_Register(t *testing.T) {
func TestRegistrar_RegisterError(t *testing.T) {
cp := &fakeCP{registerErr: true}
if err := newRegistrar(cp, testIdentity()).Register(); err == nil {
if err := newRegistrar(cp, testIdentity(), nil).Register(); err == nil {
t.Fatal("expected register error")
}
}
func TestHeartbeater_Beat(t *testing.T) {
cp := &fakeCP{}
if err := newHeartbeater(cp, testIdentity(), time.Second).beat(); err != nil {
if err := newHeartbeater(cp, testIdentity(), time.Second, nil).beat(); err != nil {
t.Fatalf("beat: %v", err)
}
if cp.registerCount() != 1 {
@@ -168,7 +185,7 @@ func TestHeartbeater_Beat(t *testing.T) {
}
func TestHeartbeater_DefaultInterval(t *testing.T) {
h := newHeartbeater(&fakeCP{}, testIdentity(), 0)
h := newHeartbeater(&fakeCP{}, testIdentity(), 0, nil)
if h.interval != defaultHeartbeat {
t.Errorf("interval = %v, want default", h.interval)
}
@@ -176,7 +193,7 @@ func TestHeartbeater_DefaultInterval(t *testing.T) {
func TestHeartbeater_RunStopsOnContext(t *testing.T) {
cp := &fakeCP{}
h := newHeartbeater(cp, testIdentity(), 5*time.Millisecond)
h := newHeartbeater(cp, testIdentity(), 5*time.Millisecond, nil)
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
@@ -193,3 +210,48 @@ func TestHeartbeater_RunStopsOnContext(t *testing.T) {
t.Error("expected at least one re-register before cancel")
}
}
// TestRegistrar_NoRuntimeAdvertisesNoCapacity is the placement half of the boot
// bug: an agent with no container runtime kept registering its full CPU and
// memory, so the scheduler placed tasks it could only fail to start. Capacity is
// advertised only while a runtime is there to use it.
func TestRegistrar_NoRuntimeAdvertisesNoCapacity(t *testing.T) {
cp := &fakeCP{}
ready := false
r := newRegistrar(cp, testIdentity(), func() bool { return ready })
if err := r.Register(); err != nil {
t.Fatalf("register: %v", err)
}
if got := cp.registered().Capacity; got.CPU != 0 || got.MemoryMiB != 0 {
t.Fatalf("advertised capacity with no runtime: cpu=%d memory=%d", got.CPU, got.MemoryMiB)
}
ready = true
if err := r.Register(); err != nil {
t.Fatalf("register: %v", err)
}
want := testIdentity().Capacity
if got := cp.registered().Capacity; got.CPU != want.CPU || got.MemoryMiB != want.MemoryMiB {
t.Fatalf("capacity not restored once the runtime resolved: cpu=%d memory=%d", got.CPU, got.MemoryMiB)
}
}
// TestHeartbeater_NoRuntimeAdvertisesNoCapacity is the same rule on the beat,
// which is how a runtime lost after boot reaches the control plane.
func TestHeartbeater_NoRuntimeAdvertisesNoCapacity(t *testing.T) {
cp := &fakeCP{}
h := newHeartbeater(cp, testIdentity(), time.Second, func() bool { return false })
if err := h.beat(); err != nil {
t.Fatalf("beat: %v", err)
}
if got := cp.registered().Capacity; got.CPU != 0 || got.MemoryMiB != 0 {
t.Fatalf("beat advertised capacity with no runtime: cpu=%d memory=%d", got.CPU, got.MemoryMiB)
}
// The instance still identifies itself, so it stays visible as drained rather
// than vanishing from the cluster.
if cp.registered().InstanceID != testIdentity().InstanceID {
t.Error("beat dropped the instance identity")
}
}
+6
View File
@@ -3,6 +3,7 @@ package runtime
import (
"context"
"fmt"
"sync"
"time"
containerd "github.com/containerd/containerd/v2/client"
@@ -22,6 +23,11 @@ const servingProbeTimeout = 3 * time.Second
// containerdPuller drives a real containerd daemon over its unix socket.
type containerdPuller struct {
client *containerd.Client
// stdinDone holds one channel per interactive container, closed when that
// container is stopped or removed so its stdin reader returns EOF.
mu sync.Mutex
stdinDone map[string]chan struct{}
}
var _ ImagePuller = (*containerdPuller)(nil)
+155 -1
View File
@@ -3,9 +3,14 @@ package runtime
import (
"context"
"fmt"
"io"
"sort"
"strings"
"syscall"
"time"
containerd "github.com/containerd/containerd/v2/client"
"github.com/containerd/containerd/v2/core/containers"
"github.com/containerd/containerd/v2/pkg/cdi"
"github.com/containerd/containerd/v2/pkg/cio"
"github.com/containerd/containerd/v2/pkg/namespaces"
@@ -41,6 +46,106 @@ func cdiSpecOpts(gpuIDs []string) []oci.SpecOpts {
return []oci.SpecOpts{cdi.WithCDIDevices(devices...)}
}
// ociCapName renders an ECS Linux capability (Docker's bare name, e.g.
// "SYS_ADMIN") as the OCI runtime-spec name ("CAP_SYS_ADMIN") capabilities.Add/
// Drop expect.
func ociCapName(name string) string {
if strings.HasPrefix(name, "CAP_") {
return name
}
return "CAP_" + name
}
// ociCapNames maps a list of ECS capability names to their OCI form.
func ociCapNames(names []string) []string {
if len(names) == 0 {
return nil
}
out := make([]string, len(names))
for i, n := range names {
out[i] = ociCapName(n)
}
return out
}
// withSysctls sets Linux.Sysctl on the OCI spec from systemControls namespace/
// value pairs. containerd has no built-in SpecOpts for sysctls.
func withSysctls(ctls []SystemControl) oci.SpecOpts {
return func(_ context.Context, _ oci.Client, _ *containers.Container, s *oci.Spec) error {
if len(ctls) == 0 {
return nil
}
if s.Linux == nil {
s.Linux = &specs.Linux{}
}
if s.Linux.Sysctl == nil {
s.Linux.Sysctl = map[string]string{}
}
for _, c := range ctls {
s.Linux.Sysctl[c.Namespace] = c.Value
}
return nil
}
}
// heldStdin backs an interactive container's stdin: reads block instead of
// hitting an immediate EOF, which is the "stdin held open" behaviour
// interactive=true asks for, until done closes and the read reports EOF.
type heldStdin struct{ done <-chan struct{} }
func (h heldStdin) Read([]byte) (int, error) {
<-h.done
return 0, io.EOF
}
// ioStreamOpts describes the task IO for a container. A terminal container needs
// a terminal FIFO set: oci.WithTTY alone puts Terminal in the OCI spec while the
// shim creates no console socket, and runc refuses that pair outright. A
// terminal carries no separate stderr, so that stream stays nil and the shim
// merges it into the console.
func ioStreamOpts(stdin io.Reader, terminal bool) []cio.Opt {
if terminal {
return []cio.Opt{cio.WithStreams(stdin, io.Discard, nil), cio.WithTerminal}
}
return []cio.Opt{cio.WithStreams(stdin, io.Discard, io.Discard)}
}
// containerIOCreator returns cio.NullIO for a container needing neither stdin
// nor a terminal, else a creator carrying whichever it asked for. An interactive
// container's stdin is held open until releaseStdin; containerd copies stdin on
// a goroutine that exits only once the read returns, so a reader that never
// returned would park it for the life of the agent. A terminal container that is
// not interactive gets no stdin stream at all, which is what a nil reader means.
func (p *containerdPuller) containerIOCreator(containerID string, interactive, terminal bool) cio.Creator {
if !interactive && !terminal {
return cio.NullIO
}
var stdin io.Reader
if interactive {
done := make(chan struct{})
p.mu.Lock()
if p.stdinDone == nil {
p.stdinDone = make(map[string]chan struct{})
}
p.stdinDone[containerID] = done
p.mu.Unlock()
stdin = heldStdin{done: done}
}
return cio.NewCreator(ioStreamOpts(stdin, terminal)...)
}
// releaseStdin closes a held stdin so containerd's copier goroutine exits. Safe
// for a container that never held one, and safe to call more than once.
func (p *containerdPuller) releaseStdin(containerID string) {
p.mu.Lock()
done, ok := p.stdinDone[containerID]
delete(p.stdinDone, containerID)
p.mu.Unlock()
if ok {
close(done)
}
}
var _ Runner = (*containerdPuller)(nil)
// Run creates a container from an already-pulled image and starts its task. The
@@ -81,6 +186,26 @@ func (p *containerdPuller) Run(ctx context.Context, id string, spec RunSpec) (st
specOpts = append(specOpts, oci.WithEnv(envSlice(spec.Env)))
}
specOpts = append(specOpts, cdiSpecOpts(spec.GPUIDs)...)
if spec.User != "" {
specOpts = append(specOpts, oci.WithUser(spec.User))
}
if spec.ReadonlyRootFilesystem != nil && *spec.ReadonlyRootFilesystem {
specOpts = append(specOpts, oci.WithRootFSReadonly())
}
if spec.Privileged != nil && *spec.Privileged {
specOpts = append(specOpts, oci.WithPrivileged)
}
terminal := spec.PseudoTerminal != nil && *spec.PseudoTerminal
if terminal {
specOpts = append(specOpts, oci.WithTTY)
}
specOpts = append(specOpts, withSysctls(spec.SystemControls))
if len(spec.CapAdd) > 0 {
specOpts = append(specOpts, oci.WithAddedCapabilities(ociCapNames(spec.CapAdd)))
}
if len(spec.CapDrop) > 0 {
specOpts = append(specOpts, oci.WithDroppedCapabilities(ociCapNames(spec.CapDrop)))
}
container, err := p.client.NewContainer(ctx, id,
containerd.WithNewSnapshot(id+"-snapshot", image),
@@ -91,12 +216,15 @@ func (p *containerdPuller) Run(ctx context.Context, id string, spec RunSpec) (st
return "", fmt.Errorf("create container %s: %w", id, err)
}
task, err := container.NewTask(ctx, cio.NullIO)
interactive := spec.Interactive != nil && *spec.Interactive
task, err := container.NewTask(ctx, p.containerIOCreator(id, interactive, terminal))
if err != nil {
p.releaseStdin(id)
_ = container.Delete(ctx, containerd.WithSnapshotCleanup)
return "", fmt.Errorf("create task %s: %w", id, err)
}
if err := task.Start(ctx); err != nil {
p.releaseStdin(id)
_, _ = task.Delete(ctx)
_ = container.Delete(ctx, containerd.WithSnapshotCleanup)
return "", fmt.Errorf("start task %s: %w", id, err)
@@ -148,9 +276,35 @@ func (p *containerdPuller) List(ctx context.Context) ([]Container, error) {
return out, nil
}
// Stop enforces a container's stopTimeout: SIGTERM the task, wait up to
// timeout for it to exit on its own, then fall through to Remove's forced
// kill+delete regardless of whether it exited in time. A container with no
// live task (already gone) or that fails to accept the signal goes straight to
// Remove, matching Remove's own already-gone tolerance.
func (p *containerdPuller) Stop(ctx context.Context, containerID string, timeout time.Duration) error {
ctx = namespaces.WithNamespace(ctx, ecsNamespace)
container, err := p.client.LoadContainer(ctx, containerID)
if err != nil {
return nil //nolint:nilerr // load failure means the container is already gone
}
task, terr := container.Task(ctx, nil)
if terr != nil {
return p.Remove(ctx, containerID)
}
statusC, werr := task.Wait(ctx)
if werr == nil && task.Kill(ctx, syscall.SIGTERM) == nil {
select {
case <-statusC:
case <-time.After(timeout):
}
}
return p.Remove(ctx, containerID)
}
// Remove kills and deletes the container's task, then the container + snapshot.
func (p *containerdPuller) Remove(ctx context.Context, containerID string) error {
ctx = namespaces.WithNamespace(ctx, ecsNamespace)
p.releaseStdin(containerID)
container, err := p.client.LoadContainer(ctx, containerID)
if err != nil {
return nil //nolint:nilerr // load failure means the container is already gone
@@ -1,8 +1,15 @@
package runtime
import (
"context"
"errors"
"io"
"reflect"
"testing"
"time"
"github.com/containerd/containerd/v2/pkg/cio"
specs "github.com/opencontainers/runtime-spec/specs-go"
)
// cdiDeviceNames maps a container's pinned GPU UUIDs to CDI device names
@@ -45,3 +52,166 @@ func TestCDISpecOpts_Wiring(t *testing.T) {
t.Error("GPU container: spec opt is nil")
}
}
// TestOCICapNames verifies ECS's bare capability names (Docker convention,
// e.g. "SYS_PTRACE") are prefixed to the OCI runtime-spec form the process
// capability sets expect, and a name already carrying the prefix passes
// through unchanged.
func TestOCICapNames(t *testing.T) {
cases := []struct {
name string
in []string
want []string
}{
{name: "empty", in: nil, want: nil},
{name: "bare", in: []string{"SYS_PTRACE"}, want: []string{"CAP_SYS_PTRACE"}},
{name: "already prefixed", in: []string{"CAP_NET_ADMIN"}, want: []string{"CAP_NET_ADMIN"}},
{name: "mixed", in: []string{"SYS_PTRACE", "CAP_NET_ADMIN"}, want: []string{"CAP_SYS_PTRACE", "CAP_NET_ADMIN"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := ociCapNames(tc.in)
if !reflect.DeepEqual(got, tc.want) {
t.Errorf("ociCapNames(%v) = %v, want %v", tc.in, got, tc.want)
}
})
}
}
// TestWithSysctls verifies systemControls namespace/value pairs land on
// Linux.Sysctl, and an empty list leaves the spec's Linux section untouched
// rather than allocating an empty map (the "requests nothing" case).
func TestWithSysctls(t *testing.T) {
s := &specs.Spec{}
if err := withSysctls(nil)(context.Background(), nil, nil, s); err != nil {
t.Fatalf("empty ctls: %v", err)
}
if s.Linux != nil {
t.Errorf("empty ctls: want no Linux section, got %+v", s.Linux)
}
ctls := []SystemControl{{Namespace: "net.core.somaxconn", Value: "1024"}}
if err := withSysctls(ctls)(context.Background(), nil, nil, s); err != nil {
t.Fatalf("with ctls: %v", err)
}
if s.Linux == nil || s.Linux.Sysctl["net.core.somaxconn"] != "1024" {
t.Errorf("want sysctl net.core.somaxconn=1024, got %+v", s.Linux)
}
}
// An interactive container's stdin read blocks until the container is released,
// then reports EOF so containerd's copier goroutine exits instead of parking
// for the life of the agent.
func TestHeldStdin_ReadsEOFOnceReleased(t *testing.T) {
p := &containerdPuller{}
p.containerIOCreator("c1", true, false)
p.mu.Lock()
done := p.stdinDone["c1"]
p.mu.Unlock()
if done == nil {
t.Fatal("interactive container registered no stdin channel")
}
read := make(chan error, 1)
go func() {
_, err := heldStdin{done: done}.Read(make([]byte, 1))
read <- err
}()
select {
case err := <-read:
t.Fatalf("read returned before release: %v", err)
case <-time.After(50 * time.Millisecond):
}
p.releaseStdin("c1")
select {
case err := <-read:
if !errors.Is(err, io.EOF) {
t.Fatalf("want io.EOF after release, got %v", err)
}
case <-time.After(time.Second):
t.Fatal("read did not return after release")
}
}
// A non-interactive container uses cio.NullIO and registers nothing to release.
func TestContainerIOCreator_NonInteractiveRegistersNothing(t *testing.T) {
p := &containerdPuller{}
p.containerIOCreator("c1", false, false)
p.mu.Lock()
defer p.mu.Unlock()
if len(p.stdinDone) != 0 {
t.Fatalf("want no registered stdin channels, got %d", len(p.stdinDone))
}
}
// applyIOOpts resolves the cio options onto a Streams the way cio.NewCreator
// does, which is the only way to observe what the creator was built with.
func applyIOOpts(opts []cio.Opt) cio.Streams {
var s cio.Streams
for _, o := range opts {
o(&s)
}
return s
}
// A terminal container needs the terminal flag set on its FIFO set, else the
// shim creates no console socket and runc refuses to start the container at all.
// The terminal carries no separate stderr stream.
func TestIOStreamOpts_TerminalRequestsAConsole(t *testing.T) {
s := applyIOOpts(ioStreamOpts(nil, true))
if !s.Terminal {
t.Error("terminal container: want Terminal set")
}
if s.Stderr != nil {
t.Errorf("terminal container: want no stderr stream, got %#v", s.Stderr)
}
if s.Stdout == nil {
t.Error("terminal container: want a stdout stream")
}
}
// A container with stdin but no terminal keeps both output streams and asks for
// no console.
func TestIOStreamOpts_NonTerminalKeepsStderr(t *testing.T) {
s := applyIOOpts(ioStreamOpts(heldStdin{}, false))
if s.Terminal {
t.Error("non-terminal container: want Terminal unset")
}
if s.Stderr == nil {
t.Error("non-terminal container: want a stderr stream")
}
if s.Stdin == nil {
t.Error("non-terminal interactive container: want a stdin stream")
}
}
// A terminal container that is not interactive still gets a creator rather than
// cio.NullIO, and registers no stdin to release.
func TestContainerIOCreator_TerminalWithoutInteractive(t *testing.T) {
p := &containerdPuller{}
if c := p.containerIOCreator("c1", false, true); c == nil {
t.Fatal("terminal container: want a creator")
}
p.mu.Lock()
defer p.mu.Unlock()
if len(p.stdinDone) != 0 {
t.Fatalf("want no registered stdin channels, got %d", len(p.stdinDone))
}
}
// Remove calls releaseStdin unconditionally and Stop falls through to Remove,
// so an unknown container and a second release must both be no-ops.
func TestReleaseStdin_UnknownAndRepeatedAreSafe(t *testing.T) {
p := &containerdPuller{}
p.releaseStdin("never-registered")
p.containerIOCreator("c1", true, false)
p.releaseStdin("c1")
p.releaseStdin("c1")
}
+33 -4
View File
@@ -4,7 +4,10 @@
// scheduler-only build run against a fake.
package runtime
import "context"
import (
"context"
"time"
)
// PullSpec describes one image to pull.
type PullSpec struct {
@@ -35,6 +38,12 @@ type ImagePuller interface {
Close() error
}
// SystemControl is a sysctl namespace/value pair applied to the OCI spec.
type SystemControl struct {
Namespace string
Value string
}
// RunSpec describes a single container the agent must create and start. v1 runs
// the container in the host network namespace (bridge/CNI task networking lands
// in a later sprint); Labels carry the mulga.ecs.* task identity for the reboot
@@ -54,6 +63,22 @@ type RunSpec struct {
// per GPU requested. Empty for non-GPU containers or if pinning fell short.
// The runner injects these as CDI devices into the OCI spec.
GPUIDs []string
// User is the OCI process user (oci.WithUser). Empty runs as the image
// default.
User string
// ReadonlyRootFilesystem, Privileged, PseudoTerminal and Interactive are
// pointers so the runner can tell "not requested" (nil) from an explicit
// false, which still needs echoing but applies nothing.
ReadonlyRootFilesystem *bool
Privileged *bool
PseudoTerminal *bool
Interactive *bool
// SystemControls are sysctls set on the OCI spec's Linux.Sysctl map.
SystemControls []SystemControl
// CapAdd / CapDrop are Linux capabilities added/dropped from the OCI
// spec's process capability sets (linuxParameters.capabilities).
CapAdd []string
CapDrop []string
}
// RunStatus is a finished container's outcome.
@@ -71,12 +96,16 @@ type Container struct {
}
// Runner creates and starts containers from already-pulled images. id is a
// caller-unique container ID; Wait blocks until the container exits; Remove
// tears down the container + its task; List enumerates known containers so the
// reboot reconciler can re-adopt the ones still running.
// caller-unique container ID; Wait blocks until the container exits; Stop
// requests a graceful exit (SIGTERM, then a forced kill+remove once timeout
// elapses) — the agent's enforcement of a container's stopTimeout; Remove
// tears down the container + its task immediately, with no grace period; List
// enumerates known containers so the reboot reconciler can re-adopt the ones
// still running.
type Runner interface {
Run(ctx context.Context, id string, spec RunSpec) (containerID string, err error)
Wait(ctx context.Context, containerID string) (RunStatus, error)
Stop(ctx context.Context, containerID string, timeout time.Duration) error
Remove(ctx context.Context, containerID string) error
List(ctx context.Context) ([]Container, error)
}
+46 -3
View File
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"sync"
"time"
)
// FakePuller is an in-memory ImagePuller for tests and the scheduler-only build.
@@ -26,11 +27,21 @@ type FakePuller struct {
Removed []string
OnRun func(string, RunSpec) (string, error)
// List bookkeeping: Containers is replayed by List; ListErr forces a failure;
// Listed records that List was called.
// Stop bookkeeping: StopTimeouts pairs the container ID with the timeout it
// was stopped with, in call order, so a test can assert both the target and
// the enforced stopTimeout without a fake containerd.
Stopped []string
StopTimeouts []time.Duration
StopErr error
// List bookkeeping: Containers is replayed by List; ListErr forces a failure,
// limited to the first ListErrFor calls when that is non-zero, so a test can
// model a runtime that recovers. Listed and ListCalls record the calls.
Containers []Container
ListErr error
ListErrFor int
Listed bool
ListCalls int
}
var (
@@ -98,6 +109,15 @@ func (f *FakePuller) Wait(_ context.Context, containerID string) (RunStatus, err
return RunStatus{ExitCode: f.WaitCode}, f.WaitErr
}
// Stop records the container ID and the timeout it was stopped with.
func (f *FakePuller) Stop(_ context.Context, containerID string, timeout time.Duration) error {
f.mu.Lock()
defer f.mu.Unlock()
f.Stopped = append(f.Stopped, containerID)
f.StopTimeouts = append(f.StopTimeouts, timeout)
return f.StopErr
}
// Remove records the container ID.
func (f *FakePuller) Remove(_ context.Context, containerID string) error {
f.mu.Lock()
@@ -113,13 +133,36 @@ func (f *FakePuller) Waits() []string {
return append([]string(nil), f.Waited...)
}
// RunCalls returns a copy of the specs passed to Run, for assertions made while
// the agent's goroutines may still be live.
func (f *FakePuller) RunCalls() []RunSpec {
f.mu.Lock()
defer f.mu.Unlock()
return append([]RunSpec(nil), f.Runs...)
}
// Reaped returns a copy of the container IDs passed to Stop.
func (f *FakePuller) Reaped() []string {
f.mu.Lock()
defer f.mu.Unlock()
return append([]string(nil), f.Stopped...)
}
// List replays the programmed container set (or error).
func (f *FakePuller) List(_ context.Context) ([]Container, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.Listed = true
if f.ListErr != nil {
f.ListCalls++
if f.ListErr != nil && (f.ListErrFor == 0 || f.ListCalls <= f.ListErrFor) {
return nil, f.ListErr
}
return f.Containers, nil
}
// Lists reports how many times List was called.
func (f *FakePuller) Lists() int {
f.mu.Lock()
defer f.mu.Unlock()
return f.ListCalls
}
+116
View File
@@ -0,0 +1,116 @@
package main
import (
"context"
"log/slog"
"sync"
"time"
ctrruntime "github.com/mulgadc/spinifex/cmd/ecs-agent/runtime"
)
// runtimeProbeTimeout bounds the liveness probe against a runtime the gate is
// already holding, so a wedged socket cannot stall the watch loop.
const runtimeProbeTimeout = 5 * time.Second
// defaultRuntimeRetry is how often the gate dials a runtime it does not have,
// and probes one it does. The node starts containerd and the agent back to
// back, so the first few dials are expected to fail.
const defaultRuntimeRetry = 10 * time.Second
// runtimeGate holds the container runtime the agent has resolved, or nothing
// when the socket has not answered. The runtime is resolved by a loop rather
// than once at boot: the node's service manager reports the container daemon
// started when the process spawned, not when its socket began accepting, so a
// dial that fails in the first second of boot is a transient condition.
//
// Every read of the runtime goes through the gate, so a runtime that arrives
// after boot is picked up on the next task without restarting the agent.
type runtimeGate struct {
mu sync.RWMutex
rt ctrruntime.Runtime
}
// newRuntimeGate returns a gate holding rt, which may be nil.
func newRuntimeGate(rt ctrruntime.Runtime) *runtimeGate {
return &runtimeGate{rt: rt}
}
// get returns the current runtime, or nil when none is resolved.
func (g *runtimeGate) get() ctrruntime.Runtime {
g.mu.RLock()
defer g.mu.RUnlock()
return g.rt
}
// available reports whether a runtime is resolved. It is what registration
// consults to decide whether the host has any capacity to advertise.
func (g *runtimeGate) available() bool { return g.get() != nil }
// set replaces the held runtime, closing the one it displaces.
func (g *runtimeGate) set(rt ctrruntime.Runtime) {
g.mu.Lock()
old := g.rt
g.rt = rt
g.mu.Unlock()
if old != nil && old != rt {
_ = old.Close()
}
}
// clear drops and closes the held runtime, returning whether it held one.
func (g *runtimeGate) clear() bool {
g.mu.Lock()
old := g.rt
g.rt = nil
g.mu.Unlock()
if old == nil {
return false
}
_ = old.Close()
return true
}
// watch keeps the gate current until ctx is cancelled: it dials while the gate
// is empty, and probes while it is full, clearing the gate when the probe fails
// so the next tick re-dials. A containerd stopped and restarted underneath a
// running agent is therefore picked up the same way a slow boot is.
func (g *runtimeGate) watch(ctx context.Context, dial func() (ctrruntime.Runtime, error), interval time.Duration) {
if interval <= 0 {
interval = defaultRuntimeRetry
}
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
g.step(ctx, dial)
}
}
}
// step performs one pass of the watch loop: dial when empty, probe when full.
func (g *runtimeGate) step(ctx context.Context, dial func() (ctrruntime.Runtime, error)) {
if rt := g.get(); rt != nil {
probeCtx, cancel := context.WithTimeout(ctx, runtimeProbeTimeout)
// A container list is what reconcile already asks the runtime at boot and
// the cheapest thing it answers, so it doubles as the liveness probe.
_, err := rt.List(probeCtx)
cancel()
if err != nil && ctx.Err() == nil {
slog.Warn("ecs-agent: container runtime stopped answering, will re-dial", "err", err)
g.clear()
}
return
}
rt, err := dial()
if err != nil {
slog.Debug("ecs-agent: container runtime still unavailable", "err", err)
return
}
slog.Info("ecs-agent: container runtime available")
g.set(rt)
}
+124
View File
@@ -0,0 +1,124 @@
//test:in-package — package main cannot be imported by an external test package.
package main
import (
"context"
"errors"
"testing"
ctrruntime "github.com/mulgadc/spinifex/cmd/ecs-agent/runtime"
)
// TestRuntimeGate_DialsUntilTheSocketAnswers is the boot case the agent used to
// lose: the node starts containerd and the agent together, so the first dials
// fail. Resolving once left the agent runtime-less for the life of the process.
func TestRuntimeGate_DialsUntilTheSocketAnswers(t *testing.T) {
g := newRuntimeGate(nil)
if g.available() {
t.Fatal("empty gate reported a runtime")
}
rt := &ctrruntime.FakePuller{}
dials := 0
dial := func() (ctrruntime.Runtime, error) {
dials++
if dials < 3 {
return nil, errors.New("dial containerd: no such file or directory")
}
return rt, nil
}
for range 2 {
g.step(t.Context(), dial)
if g.available() {
t.Fatalf("gate resolved a runtime after %d failed dials", dials)
}
}
g.step(t.Context(), dial)
if g.get() != rt {
t.Fatalf("gate did not pick up the runtime once the socket answered (dials=%d)", dials)
}
}
// TestRuntimeGate_DropsARuntimeThatStopsAnswering covers a containerd restarted
// underneath a live agent: the held runtime stops answering, so the gate clears
// it and the next pass re-dials rather than handing out a dead client forever.
func TestRuntimeGate_DropsARuntimeThatStopsAnswering(t *testing.T) {
dead := &ctrruntime.FakePuller{ListErr: errors.New("connection refused")}
fresh := &ctrruntime.FakePuller{}
g := newRuntimeGate(dead)
dial := func() (ctrruntime.Runtime, error) { return fresh, nil }
g.step(t.Context(), dial)
if g.available() {
t.Fatal("gate kept a runtime whose probe failed")
}
if !dead.Closed {
t.Error("displaced runtime was not closed")
}
g.step(t.Context(), dial)
if g.get() != fresh {
t.Fatal("gate did not re-dial after clearing")
}
}
// TestRuntimeGate_KeepsAHealthyRuntime guards the other direction: a probe that
// succeeds must not churn the runtime, or every pass would drop a working client.
func TestRuntimeGate_KeepsAHealthyRuntime(t *testing.T) {
rt := &ctrruntime.FakePuller{}
g := newRuntimeGate(rt)
dial := func() (ctrruntime.Runtime, error) {
t.Error("dialled while holding a healthy runtime")
return nil, errors.New("unexpected dial")
}
g.step(t.Context(), dial)
if g.get() != rt {
t.Fatal("gate dropped a runtime whose probe succeeded")
}
if rt.Closed {
t.Error("healthy runtime was closed")
}
}
// TestRuntimeGate_ClearIsIdempotent keeps Stop safe to call on an agent that
// never resolved a runtime.
func TestRuntimeGate_ClearIsIdempotent(t *testing.T) {
g := newRuntimeGate(nil)
if g.clear() {
t.Error("clear reported dropping a runtime the gate never held")
}
rt := &ctrruntime.FakePuller{}
g.set(rt)
if !g.clear() {
t.Error("clear did not report dropping the held runtime")
}
if !rt.Closed {
t.Error("cleared runtime was not closed")
}
if g.clear() {
t.Error("second clear reported dropping a runtime")
}
}
// TestRuntimeGate_WatchStopsWithContext keeps the watch loop from outliving the
// agent it belongs to.
func TestRuntimeGate_WatchStopsWithContext(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context())
g := newRuntimeGate(nil)
done := make(chan struct{})
go func() {
g.watch(ctx, func() (ctrruntime.Runtime, error) { return nil, errors.New("no socket") }, 0)
close(done)
}()
cancel()
<-done
}
+159 -30
View File
@@ -5,6 +5,8 @@ import (
"fmt"
"log/slog"
"maps"
"strconv"
"sync"
"time"
ctrruntime "github.com/mulgadc/spinifex/cmd/ecs-agent/runtime"
@@ -12,6 +14,10 @@ import (
"github.com/mulgadc/spinifex/spinifex/handlers/ecs/bus"
)
// defaultStopTimeout is applied when a container carries no explicit
// stopTimeout, matching the EC2 launch-type default ECS itself uses.
const defaultStopTimeout = 30 * time.Second
// pollAssignments drains the instance's assignment inbox on a ticker until ctx is
// cancelled. Each assign is dispatched to runTask exactly once; the taskIDs seen
// on a poll are acked on the next poll so the gateway can drop them — a crash
@@ -23,7 +29,7 @@ func (a *Agent) pollAssignments(ctx context.Context, dispatched map[string]bool)
ticker := time.NewTicker(a.cfg.PollInterval)
defer ticker.Stop()
stopping := map[string]bool{}
reaps := newStopLedger()
var ackAssigns, ackStops []string
for {
select {
@@ -40,17 +46,18 @@ func (a *Agent) pollAssignments(ctx context.Context, dispatched map[string]bool)
ackStops = ackStops[:0]
for i := range stops {
sd := stops[i]
if !stopping[sd.TaskID] {
stopping[sd.TaskID] = true
switch {
case reaps.done(sd.TaskID):
ackStops = append(ackStops, sd.TaskID)
case reaps.begin(sd.TaskID):
slog.Info("ecs-agent: task stop requested", "task", sd.TaskID, "reason", sd.Reason)
go a.stopTask(ctx, sd)
go func() { reaps.end(sd.TaskID, a.stopTask(ctx, sd)) }()
}
ackStops = append(ackStops, sd.TaskID)
}
ackAssigns = ackAssigns[:0]
for i := range assigns {
as := assigns[i]
if !dispatched[as.TaskID] && !stopping[as.TaskID] {
if !dispatched[as.TaskID] && !reaps.seen(as.TaskID) {
dispatched[as.TaskID] = true
slog.Info("ecs-agent: task assigned", "task", as.TaskID, "containers", len(as.Containers))
go a.runTask(ctx, &as)
@@ -61,27 +68,83 @@ func (a *Agent) pollAssignments(ctx context.Context, dispatched map[string]bool)
}
}
// stopTask reaps a task's containers on a control-plane stop directive: it lists
// the runtime's containers, removes the ones labeled with this task (kill +
// delete, releasing host ports/netns), then reports the task STOPPED with the
// directive's reason. Idempotent — a task with no live containers still reports
// STOPPED so the scheduler releases its capacity.
func (a *Agent) stopTask(ctx context.Context, sd bus.StopDirective) {
if a.runner == nil {
return
// stopLedger tracks reaps in flight and reaps that reported STOPPED. The two
// are separate because only the second may be acked: acking the first deletes a
// directive that was never performed, and nothing re-posts it.
type stopLedger struct {
mu sync.Mutex
inflight map[string]bool
stopped map[string]bool
}
func newStopLedger() *stopLedger {
return &stopLedger{inflight: map[string]bool{}, stopped: map[string]bool{}}
}
// begin claims a task for a reap, reporting false when one is already running.
func (l *stopLedger) begin(taskID string) bool {
l.mu.Lock()
defer l.mu.Unlock()
if l.inflight[taskID] {
return false
}
containers, err := a.runner.List(ctx)
l.inflight[taskID] = true
return true
}
// end releases the claim, recording the task as stopped only if the reap got
// that far. A failed reap leaves nothing behind, so the next poll redelivers
// the directive and dispatches it again.
func (l *stopLedger) end(taskID string, reported bool) {
l.mu.Lock()
defer l.mu.Unlock()
delete(l.inflight, taskID)
if reported {
l.stopped[taskID] = true
}
}
// done reports whether the task's reap reported STOPPED, which is the only
// condition under which its directive may be acked.
func (l *stopLedger) done(taskID string) bool {
l.mu.Lock()
defer l.mu.Unlock()
return l.stopped[taskID]
}
// seen reports whether a stop for the task is in flight or complete, so an
// assignment arriving alongside its own stop is never started.
func (l *stopLedger) seen(taskID string) bool {
l.mu.Lock()
defer l.mu.Unlock()
return l.inflight[taskID] || l.stopped[taskID]
}
// stopTask reaps a task's containers on a control-plane stop directive: it
// lists the runtime's containers, gracefully stops (SIGTERM, wait up to the
// container's stopTimeout, then force kill + delete) the ones labeled with
// this task, then reports the task STOPPED with the directive's reason.
// Idempotent — a task with no live containers still reports STOPPED so the
// scheduler releases its capacity. Reports whether it got as far as saying
// STOPPED: a caller may only ack the directive when it did.
func (a *Agent) stopTask(ctx context.Context, sd bus.StopDirective) bool {
runner := a.rt.get()
if runner == nil {
slog.Warn("ecs-agent: stop deferred, no container runtime", "task", sd.TaskID)
return false
}
containers, err := runner.List(ctx)
if err != nil {
slog.Warn("ecs-agent: stop list failed", "task", sd.TaskID, "err", err)
return
return false
}
statuses := make([]bus.ContainerStatus, 0)
for _, c := range containers {
if c.Labels[labelTaskID] != sd.TaskID {
continue
}
if rerr := a.runner.Remove(ctx, c.ID); rerr != nil {
slog.Warn("ecs-agent: stop remove failed", "task", sd.TaskID, "container", c.ID, "err", rerr)
if rerr := runner.Stop(ctx, c.ID, containerStopTimeout(c.Labels)); rerr != nil {
slog.Warn("ecs-agent: stop failed", "task", sd.TaskID, "container", c.ID, "err", rerr)
}
statuses = append(statuses, bus.ContainerStatus{
Name: c.Labels[labelContainerName], Status: bus.TaskStatusStopped, ContainerID: c.ID,
@@ -93,13 +156,15 @@ func (a *Agent) stopTask(ctx context.Context, sd bus.StopDirective) {
}
a.reportTaskState(&bus.Assign{TaskID: sd.TaskID}, bus.TaskStatusStopped, reason, statuses)
slog.Info("ecs-agent: task reaped", "task", sd.TaskID, "containers", len(statuses))
return true
}
// runTask pulls each container image, starts the containers, and reports task
// state on the bus. A missing runtime or any per-container failure reports the
// task STOPPED with a reason; success reports RUNNING and waits for exit.
func (a *Agent) runTask(ctx context.Context, as *bus.Assign) {
if a.puller == nil || a.runner == nil {
rt := a.rt.get()
if rt == nil {
a.reportTaskState(as, bus.TaskStatusStopped, "containerd unavailable on agent", nil)
return
}
@@ -117,7 +182,12 @@ func (a *Agent) runTask(ctx context.Context, as *bus.Assign) {
statuses := make([]bus.ContainerStatus, 0, len(as.Containers))
for _, c := range as.Containers {
if _, err := a.puller.Pull(ctx, ctrruntime.PullSpec{Ref: c.Image}, resolver); err != nil {
// startTimeout bounds pull-through-start as a unit: real ECS measures a
// container's time-to-RUNNING from the same starting point.
runCtx, cancel := containerStartCtx(ctx, c.StartTimeout)
if _, err := rt.Pull(runCtx, ctrruntime.PullSpec{Ref: c.Image}, resolver); err != nil {
cancel()
slog.Error("ecs-agent: pull failed", "task", as.TaskID, "image", c.Image, "err", err)
a.teardownTaskNetns(as)
a.reportTaskState(as, bus.TaskStatusStopped, "image pull failed: "+err.Error(), statuses)
@@ -126,16 +196,29 @@ func (a *Agent) runTask(ctx context.Context, as *bus.Assign) {
cid := containerID(as.TaskID, c.Name)
gpuIDs := a.pinContainerGPUs(as.TaskID, c.Name, c.GPU)
spec := ctrruntime.RunSpec{
Image: c.Image,
Command: c.Command,
Env: withCredEnv(c.Environment, credID),
Labels: taskLabels(as, c.Name),
NetnsPath: netnsPath,
GPU: c.GPU,
GPUIDs: gpuIDs,
labels := taskLabels(as, c.Name)
if c.StopTimeout != nil {
labels[labelStopTimeout] = strconv.FormatInt(*c.StopTimeout, 10)
}
id, err := a.runner.Run(ctx, cid, spec)
spec := ctrruntime.RunSpec{
Image: c.Image,
Command: c.Command,
Env: withCredEnv(c.Environment, credID),
Labels: labels,
NetnsPath: netnsPath,
GPU: c.GPU,
GPUIDs: gpuIDs,
User: c.User,
ReadonlyRootFilesystem: c.ReadonlyRootFilesystem,
Privileged: c.Privileged,
PseudoTerminal: c.PseudoTerminal,
Interactive: c.Interactive,
SystemControls: toRuntimeSystemControls(c.SystemControls),
CapAdd: c.CapAdd,
CapDrop: c.CapDrop,
}
id, err := rt.Run(runCtx, cid, spec)
cancel()
if err != nil {
slog.Error("ecs-agent: run failed", "task", as.TaskID, "container", c.Name, "err", err)
a.teardownTaskNetns(as)
@@ -155,7 +238,12 @@ func (a *Agent) runTask(ctx context.Context, as *bus.Assign) {
// waitContainer blocks until a container exits, then reports the task STOPPED
// with the exit code. v1 stops the whole task when its first container exits.
func (a *Agent) waitContainer(ctx context.Context, as *bus.Assign, name, containerID string) {
status, err := a.runner.Wait(ctx, containerID)
runner := a.rt.get()
if runner == nil {
slog.Warn("ecs-agent: cannot wait on container, no container runtime", "task", as.TaskID, "container", name)
return
}
status, err := runner.Wait(ctx, containerID)
if err != nil {
slog.Warn("ecs-agent: wait container failed", "task", as.TaskID, "container", name, "err", err)
return
@@ -240,6 +328,44 @@ func withCredEnv(env map[string]string, credID string) map[string]string {
return out
}
// containerStartCtx bounds pull+run by a container's startTimeout, if set. A
// nil startTimeout (the common case) returns ctx unchanged with a no-op
// cancel, matching today's unbounded behaviour.
func containerStartCtx(ctx context.Context, startTimeout *int64) (context.Context, context.CancelFunc) {
if startTimeout == nil {
return ctx, func() {}
}
return context.WithTimeout(ctx, time.Duration(*startTimeout)*time.Second)
}
// containerStopTimeout reads the stopTimeout stamped on a container's labels
// by runTask, falling back to defaultStopTimeout when absent or unparseable.
func containerStopTimeout(labels map[string]string) time.Duration {
v, ok := labels[labelStopTimeout]
if !ok {
return defaultStopTimeout
}
secs, err := strconv.ParseInt(v, 10, 64)
if err != nil {
return defaultStopTimeout
}
return time.Duration(secs) * time.Second
}
// toRuntimeSystemControls maps the assign's sysctl pairs to the runtime
// package's own type, keeping cmd/ecs-agent/runtime decoupled from the ecs
// handlers' bus package.
func toRuntimeSystemControls(in []bus.SystemControl) []ctrruntime.SystemControl {
if len(in) == 0 {
return nil
}
out := make([]ctrruntime.SystemControl, len(in))
for i, sc := range in {
out[i] = ctrruntime.SystemControl{Namespace: sc.Namespace, Value: sc.Value}
}
return out
}
// pinContainerGPUs reserves n device UUIDs for a container from the local
// ledger, before the container is started so the runner can inject them as
// CDI devices. A short ledger (fewer free devices than requested, e.g.
@@ -317,6 +443,9 @@ const (
labelCredID = "mulga.ecs.credID"
labelTaskRoleARN = "mulga.ecs.taskRoleArn"
labelENIMac = "mulga.ecs.eniMac"
// labelStopTimeout carries a container's stopTimeout (seconds) so stopTask
// can read it back from List's labels; it has no assign to consult there.
labelStopTimeout = "mulga.ecs.stopTimeout"
)
// taskLabels are the mulga.ecs.* labels stamped on a container. The cred/role/MAC
+282 -14
View File
@@ -3,6 +3,7 @@ package main
import (
"context"
"errors"
"slices"
"testing"
"time"
@@ -24,13 +25,13 @@ func testAssign() *bus.Assign {
}
func newRunAgent(cp controlPlane, rt *ctrruntime.FakePuller) *Agent {
return newAgent(config{}, testIdentity(), cp, rt, rt, nil)
return newAgent(config{}, testIdentity(), cp, rt, nil)
}
// runTask with no runtime reports the task STOPPED instead of crashing.
func TestRunTask_NoRuntimeReportsStopped(t *testing.T) {
cp := &fakeCP{}
a := newAgent(config{}, testIdentity(), cp, nil, nil, nil)
a := newAgent(config{}, testIdentity(), cp, nil, nil)
a.runTask(context.Background(), testAssign())
st := cp.taskStates()
@@ -70,6 +71,157 @@ func TestRunTask_PullRunReportsRunning(t *testing.T) {
}
}
// Each enforced field maps onto RunSpec exactly, asserted through the fake
// runtime — the mapping this change exists to wire, without a real containerd.
func TestRunTask_RuntimeFieldsCarriedToRunSpec(t *testing.T) {
cp := &fakeCP{}
rt := &ctrruntime.FakePuller{WaitErr: errors.New("blocked")}
a := newRunAgent(cp, rt)
as := testAssign()
ro := true
priv := false
tty := true
interactive := true
as.Containers[0].User = "1000:1000"
as.Containers[0].ReadonlyRootFilesystem = &ro
as.Containers[0].Privileged = &priv
as.Containers[0].PseudoTerminal = &tty
as.Containers[0].Interactive = &interactive
as.Containers[0].SystemControls = []bus.SystemControl{{Namespace: "net.core.somaxconn", Value: "1024"}}
as.Containers[0].CapAdd = []string{"SYS_PTRACE"}
as.Containers[0].CapDrop = []string{"NET_RAW"}
a.runTask(context.Background(), as)
if len(rt.Runs) != 1 {
t.Fatalf("expected one run, got %+v", rt.Runs)
}
got := rt.Runs[0]
if got.User != "1000:1000" {
t.Errorf("User = %q, want 1000:1000", got.User)
}
if got.ReadonlyRootFilesystem == nil || !*got.ReadonlyRootFilesystem {
t.Errorf("ReadonlyRootFilesystem = %v, want true", got.ReadonlyRootFilesystem)
}
if got.Privileged == nil || *got.Privileged {
t.Errorf("Privileged = %v, want false (present, not applied)", got.Privileged)
}
if got.PseudoTerminal == nil || !*got.PseudoTerminal {
t.Errorf("PseudoTerminal = %v, want true", got.PseudoTerminal)
}
if got.Interactive == nil || !*got.Interactive {
t.Errorf("Interactive = %v, want true", got.Interactive)
}
if len(got.SystemControls) != 1 || got.SystemControls[0].Namespace != "net.core.somaxconn" {
t.Errorf("SystemControls = %+v, want one net.core.somaxconn entry", got.SystemControls)
}
if len(got.CapAdd) != 1 || got.CapAdd[0] != "SYS_PTRACE" {
t.Errorf("CapAdd = %+v, want [SYS_PTRACE]", got.CapAdd)
}
if len(got.CapDrop) != 1 || got.CapDrop[0] != "NET_RAW" {
t.Errorf("CapDrop = %+v, want [NET_RAW]", got.CapDrop)
}
}
// An assign carrying none of the new fields leaves RunSpec's pointer fields
// nil rather than defaulting to a false indistinguishable from "explicitly
// not requested".
func TestRunTask_NilRuntimeFieldsLeaveRunSpecUnset(t *testing.T) {
cp := &fakeCP{}
rt := &ctrruntime.FakePuller{WaitErr: errors.New("blocked")}
a := newRunAgent(cp, rt)
a.runTask(context.Background(), testAssign())
if len(rt.Runs) != 1 {
t.Fatalf("expected one run, got %+v", rt.Runs)
}
got := rt.Runs[0]
if got.ReadonlyRootFilesystem != nil || got.Privileged != nil || got.PseudoTerminal != nil || got.Interactive != nil {
t.Errorf("want all runtime-field pointers nil, got %+v", got)
}
if len(got.SystemControls) != 0 || len(got.CapAdd) != 0 || len(got.CapDrop) != 0 {
t.Errorf("want no sysctls/capabilities, got %+v", got)
}
}
// TestRunTask_StampsStopTimeoutLabel verifies runTask stamps a container's
// stopTimeout as a label — the only way stopTask (which only has List's
// labels, not the original assign) can recover it later.
func TestRunTask_StampsStopTimeoutLabel(t *testing.T) {
cp := &fakeCP{}
rt := &ctrruntime.FakePuller{WaitErr: errors.New("blocked")}
a := newRunAgent(cp, rt)
as := testAssign()
stop := int64(45)
as.Containers[0].StopTimeout = &stop
a.runTask(context.Background(), as)
if len(rt.Runs) != 1 {
t.Fatalf("expected one run, got %+v", rt.Runs)
}
if got := rt.Runs[0].Labels[labelStopTimeout]; got != "45" {
t.Errorf("stopTimeout label = %q, want 45", got)
}
}
// TestRunTask_NoStopTimeoutLabelWhenUnset verifies a container with no
// stopTimeout carries no label, so stopTask falls back to the default rather
// than reading a stray "0".
func TestRunTask_NoStopTimeoutLabelWhenUnset(t *testing.T) {
cp := &fakeCP{}
rt := &ctrruntime.FakePuller{WaitErr: errors.New("blocked")}
a := newRunAgent(cp, rt)
a.runTask(context.Background(), testAssign())
if len(rt.Runs) != 1 {
t.Fatalf("expected one run, got %+v", rt.Runs)
}
if _, ok := rt.Runs[0].Labels[labelStopTimeout]; ok {
t.Errorf("want no stopTimeout label, got %+v", rt.Runs[0].Labels)
}
}
// TestStopTask_UsesContainerStopTimeout verifies that stopTask reads a
// container's stopTimeout label and calls the runner's graceful Stop (SIGTERM
// + bounded wait) with it, instead of the hard kill+delete used before this
// fix.
func TestStopTask_UsesContainerStopTimeout(t *testing.T) {
cp := &fakeCP{}
rt := &ctrruntime.FakePuller{Containers: []ctrruntime.Container{{
ID: "t-001-web",
Labels: map[string]string{
labelTaskID: "t-001", labelContainerName: "web", labelStopTimeout: "45",
},
}}}
a := newRunAgent(cp, rt)
a.stopTask(context.Background(), bus.StopDirective{TaskID: "t-001", Reason: "bye"})
if len(rt.Stopped) != 1 || rt.Stopped[0] != "t-001-web" {
t.Fatalf("want t-001-web stopped, got %+v", rt.Stopped)
}
if len(rt.StopTimeouts) != 1 || rt.StopTimeouts[0] != 45*time.Second {
t.Fatalf("want stopTimeout 45s, got %+v", rt.StopTimeouts)
}
}
// TestStopTask_DefaultStopTimeoutWhenUnset verifies a container with no
// stopTimeout label gets the EC2 launch-type default rather than an unbounded
// or zero grace period.
func TestStopTask_DefaultStopTimeoutWhenUnset(t *testing.T) {
cp := &fakeCP{}
rt := &ctrruntime.FakePuller{Containers: []ctrruntime.Container{{
ID: "t-001-web",
Labels: map[string]string{labelTaskID: "t-001", labelContainerName: "web"},
}}}
a := newRunAgent(cp, rt)
a.stopTask(context.Background(), bus.StopDirective{TaskID: "t-001", Reason: "bye"})
if len(rt.StopTimeouts) != 1 || rt.StopTimeouts[0] != defaultStopTimeout {
t.Fatalf("want default stopTimeout %v, got %+v", defaultStopTimeout, rt.StopTimeouts)
}
}
// TestRunTask_GPUCarriedToRunSpec verifies that the AssignContainer's GPU count
// reaches the runtime RunSpec unchanged; a
// container with no GPU request keeps RunSpec.GPU at zero (regression).
@@ -100,7 +252,7 @@ func TestRunTask_GPUCarriedToRunSpec(t *testing.T) {
func TestRunTask_GPUPinsAndReportsUUIDs(t *testing.T) {
cp := &fakeCP{}
rt := &ctrruntime.FakePuller{WaitErr: errors.New("blocked")}
a := newAgent(config{}, testIdentityWithGPUs("GPU-aaa", "GPU-bbb"), cp, rt, rt, nil)
a := newAgent(config{}, testIdentityWithGPUs("GPU-aaa", "GPU-bbb"), cp, rt, nil)
as := testAssign()
as.Containers = append(as.Containers, bus.AssignContainer{
Name: "trainer", Image: "registry/trainer:1", GPU: 1,
@@ -144,7 +296,7 @@ func TestRunTask_GPUPinsAndReportsUUIDs(t *testing.T) {
func TestRunTask_GPUIDsReachRunSpec(t *testing.T) {
cp := &fakeCP{}
rt := &ctrruntime.FakePuller{WaitErr: errors.New("blocked")}
a := newAgent(config{}, testIdentityWithGPUs("GPU-aaa", "GPU-bbb"), cp, rt, rt, nil)
a := newAgent(config{}, testIdentityWithGPUs("GPU-aaa", "GPU-bbb"), cp, rt, nil)
as := testAssign()
as.Containers = append(as.Containers, bus.AssignContainer{
Name: "trainer", Image: "registry/trainer:1", GPU: 1,
@@ -209,7 +361,7 @@ func TestStopTask_ReleasesGPUsBackToLedger(t *testing.T) {
"mulga.ecs.taskID": "t-001", "mulga.ecs.containerName": "trainer",
}},
}}
a := newAgent(config{}, testIdentityWithGPUs("GPU-aaa"), cp, rt, rt, nil)
a := newAgent(config{}, testIdentityWithGPUs("GPU-aaa"), cp, rt, nil)
if _, err := a.gpu.Pin(gpuKey("t-001", "trainer"), 1); err != nil {
t.Fatalf("seed pin: %v", err)
}
@@ -287,7 +439,7 @@ func TestPollAssignments_DispatchesOnceAndAcks(t *testing.T) {
nil, // poll 3: gateway dropped it after the ack
}}
rt := &ctrruntime.FakePuller{WaitErr: errors.New("blocked")}
a := newAgent(config{PollInterval: 5 * time.Millisecond}, testIdentity(), cp, rt, rt, nil)
a := newAgent(config{PollInterval: 5 * time.Millisecond}, testIdentity(), cp, rt, nil)
ctx, cancel := context.WithCancel(context.Background())
go a.pollAssignments(ctx, map[string]bool{})
@@ -342,8 +494,8 @@ func TestStopTask_ReapsLabeledContainersAndReportsStopped(t *testing.T) {
a.stopTask(context.Background(), bus.StopDirective{TaskID: "t-001", Reason: "bye"})
if len(rt.Removed) != 1 || rt.Removed[0] != "t-001-web" {
t.Fatalf("want only t-001-web removed, got %+v", rt.Removed)
if len(rt.Stopped) != 1 || rt.Stopped[0] != "t-001-web" {
t.Fatalf("want only t-001-web stopped, got %+v", rt.Stopped)
}
st := cp.taskStates()
if len(st) != 1 || st[0].LastStatus != bus.TaskStatusStopped {
@@ -381,12 +533,12 @@ func TestPollAssignments_StopReapsAndSuppressesAssign(t *testing.T) {
as := testAssign()
cp := &fakeCP{
pollReplies: [][]bus.Assign{{*as}, {*as}, nil},
stopReplies: [][]bus.StopDirective{{{TaskID: as.TaskID, Reason: "bye"}}},
stopPending: []bus.StopDirective{{TaskID: as.TaskID, Reason: "bye"}},
}
rt := &ctrruntime.FakePuller{Containers: []ctrruntime.Container{
{ID: "t-001-web", Running: true, Labels: map[string]string{"mulga.ecs.taskID": "t-001"}},
}}
a := newAgent(config{PollInterval: 5 * time.Millisecond}, testIdentity(), cp, rt, rt, nil)
a := newAgent(config{PollInterval: 5 * time.Millisecond}, testIdentity(), cp, rt, nil)
ctx, cancel := context.WithCancel(context.Background())
go a.pollAssignments(ctx, map[string]bool{})
@@ -425,10 +577,126 @@ func TestPollAssignments_StopReapsAndSuppressesAssign(t *testing.T) {
}
}
// countStopAcks reports how many polls carried taskID in their stop-ack list.
func countStopAcks(acks [][]string, taskID string) int {
n := 0
for _, ack := range acks {
if slices.Contains(ack, taskID) {
n++
}
}
return n
}
// A reap that cannot run is never acked, so the directive stays in the gateway's
// inbox and is redelivered and retried until the runtime comes back.
func TestPollAssignments_UnperformableStopIsNotAckedAndRetried(t *testing.T) {
cp := &fakeCP{stopPending: []bus.StopDirective{{TaskID: "t-001", Reason: "bye"}}}
rt := &ctrruntime.FakePuller{ListErr: errors.New("containerd is down")}
a := newAgent(config{PollInterval: 5 * time.Millisecond}, testIdentity(), cp, rt, nil)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go a.pollAssignments(ctx, map[string]bool{})
deadline := time.After(time.Second)
for rt.Lists() < 3 {
select {
case <-deadline:
t.Fatalf("failed reap was not retried; List calls=%d", rt.Lists())
case <-time.After(2 * time.Millisecond):
}
}
cancel()
if n := countStopAcks(cp.stopAcks(), "t-001"); n != 0 {
t.Errorf("a reap that never ran was acked %d times; stopAcks=%v", n, cp.stopAcks())
}
if stopped := countStatus(cp.taskStates(), "t-001", bus.TaskStatusStopped); stopped != 0 {
t.Errorf("a reap that never ran reported STOPPED %d times", stopped)
}
}
// Once the runtime recovers, the redelivered directive is reaped, reported
// STOPPED and acked exactly once, which deletes it from the inbox.
func TestPollAssignments_RecoveredStopIsAckedExactlyOnce(t *testing.T) {
cp := &fakeCP{stopPending: []bus.StopDirective{{TaskID: "t-001", Reason: "bye"}}}
rt := &ctrruntime.FakePuller{
ListErr: errors.New("containerd is down"),
ListErrFor: 1,
Containers: []ctrruntime.Container{
{ID: "t-001-web", Running: true, Labels: map[string]string{"mulga.ecs.taskID": "t-001"}},
},
}
a := newAgent(config{PollInterval: 5 * time.Millisecond}, testIdentity(), cp, rt, nil)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go a.pollAssignments(ctx, map[string]bool{})
deadline := time.After(2 * time.Second)
for countStopAcks(cp.stopAcks(), "t-001") == 0 {
select {
case <-deadline:
t.Fatalf("recovered reap was never acked; states=%+v stopAcks=%v", cp.taskStates(), cp.stopAcks())
case <-time.After(2 * time.Millisecond):
}
}
time.Sleep(30 * time.Millisecond)
cancel()
if n := countStopAcks(cp.stopAcks(), "t-001"); n != 1 {
t.Errorf("stop acked %d times, want exactly 1; stopAcks=%v", n, cp.stopAcks())
}
if stopped := countStatus(cp.taskStates(), "t-001", bus.TaskStatusStopped); stopped != 1 {
t.Errorf("task reported STOPPED %d times, want exactly 1", stopped)
}
if reaped := rt.Reaped(); len(reaped) != 1 || reaped[0] != "t-001-web" {
t.Errorf("want t-001-web reaped once, got %+v", reaped)
}
}
// A task whose reap completed is never started again by an assign the gateway
// is still redelivering.
func TestPollAssignments_StoppedTaskIsNotRerunByRedeliveredAssign(t *testing.T) {
as := testAssign()
cp := &fakeCP{
pollReplies: [][]bus.Assign{{*as}, {*as}, {*as}, {*as}, {*as}, {*as}},
stopPending: []bus.StopDirective{{TaskID: as.TaskID, Reason: "bye"}},
}
rt := &ctrruntime.FakePuller{Containers: []ctrruntime.Container{
{ID: "t-001-web", Running: true, Labels: map[string]string{"mulga.ecs.taskID": "t-001"}},
}}
a := newAgent(config{PollInterval: 5 * time.Millisecond}, testIdentity(), cp, rt, nil)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go a.pollAssignments(ctx, map[string]bool{})
deadline := time.After(time.Second)
for countStopAcks(cp.stopAcks(), as.TaskID) == 0 {
select {
case <-deadline:
t.Fatalf("stop never completed; states=%+v", cp.taskStates())
case <-time.After(2 * time.Millisecond):
}
}
// Let the remaining scripted assigns arrive after the reap has finished.
time.Sleep(40 * time.Millisecond)
cancel()
if runs := rt.RunCalls(); len(runs) != 0 {
t.Errorf("stopped task was started by a redelivered assign: %+v", runs)
}
if running := countStatus(cp.taskStates(), as.TaskID, bus.TaskStatusRunning); running != 0 {
t.Errorf("stopped task reported RUNNING %d times", running)
}
}
// stopTask with no runtime is a no-op: nothing to reap, no state reported.
func TestStopTask_NilRunnerNoOp(t *testing.T) {
cp := &fakeCP{}
a := newAgent(config{}, testIdentity(), cp, nil, nil, nil)
a := newAgent(config{}, testIdentity(), cp, nil, nil)
a.stopTask(context.Background(), bus.StopDirective{TaskID: "t-001", Reason: "x"})
if len(cp.taskStates()) != 0 {
t.Fatalf("nil runner should report no state, got %+v", cp.taskStates())
@@ -459,8 +727,8 @@ func TestStopTask_EmptyReasonReapsWithDefault(t *testing.T) {
a := newRunAgent(cp, rt)
a.stopTask(context.Background(), bus.StopDirective{TaskID: "t-001"})
if len(rt.Removed) != 1 || rt.Removed[0] != "t-001-web" {
t.Fatalf("want container t-001-web reaped, got %+v", rt.Removed)
if len(rt.Stopped) != 1 || rt.Stopped[0] != "t-001-web" {
t.Fatalf("want container t-001-web reaped, got %+v", rt.Stopped)
}
st := cp.taskStates()
if len(st) != 1 || st[0].LastStatus != bus.TaskStatusStopped {
@@ -539,7 +807,7 @@ func (sentinelResolver) Authorize(context.Context, string) (string, string, stri
// exec-role resolver is built.
func TestPullResolver(t *testing.T) {
fake := sentinelResolver{}
a := newAgent(config{Region: "us-east-1"}, testIdentity(), &fakeCP{}, nil, nil, fake)
a := newAgent(config{Region: "us-east-1"}, testIdentity(), &fakeCP{}, nil, fake)
if got := a.pullResolver(&bus.Assign{TaskID: "t1"}); got != fake {
t.Fatalf("no execution role: want instance resolver fallback")
+4 -16
View File
@@ -33,12 +33,12 @@ func serverTLSConfig(cert tls.Certificate) *tls.Config {
// valid even if the webhook process is restarted. Returns the parsed cert and
// the certificate PEM (for embedding as the kubeconfig CA bundle).
func ensureServingCert(certPath, keyPath string) (tls.Certificate, []byte, error) {
if certPEM, keyPEM, ok := readCertPair(certPath, keyPath); ok {
cert, err := tls.X509KeyPair(certPEM, keyPEM)
if err == nil {
// Fall through to regenerate on a missing/corrupt/incompatible persisted
// pair. The cert PEM is re-read because tls.Certificate keeps only DER.
if cert, err := tls.LoadX509KeyPair(certPath, keyPath); err == nil {
if certPEM, err := os.ReadFile(certPath); err == nil {
return cert, certPEM, nil
}
// Fall through to regenerate on a corrupt/incompatible persisted pair.
}
certPEM, keyPEM, err := generateSelfSigned()
@@ -58,18 +58,6 @@ func ensureServingCert(certPath, keyPath string) (tls.Certificate, []byte, error
return cert, certPEM, nil
}
func readCertPair(certPath, keyPath string) (certPEM, keyPEM []byte, ok bool) {
c, err := os.ReadFile(certPath)
if err != nil {
return nil, nil, false
}
k, err := os.ReadFile(keyPath)
if err != nil {
return nil, nil, false
}
return c, k, true
}
func generateSelfSigned() (certPEM, keyPEM []byte, err error) {
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
+1 -1
View File
@@ -870,7 +870,7 @@ func fireInstallCallback() {
return
}
client := &http.Client{Timeout: 10 * time.Second}
resp, err := client.Get(url) //nolint:noctx // installer has no context; best-effort fire-and-forget
resp, err := client.Get(url)
if err != nil {
slog.Warn("fireInstallCallback: request failed", "url", url, "err", err)
return
+8 -7
View File
@@ -220,7 +220,8 @@ func newAgent(cfg config, cp controlPlane, probe *engineProbe) (*Agent, error) {
return a, nil
}
// Does not wait for IMDS: the register loop rides out a datapath still coming up.
// New builds the agent from cfg, failing if no gateway URL is set. It does not
// wait for IMDS: the register loop rides out a datapath still coming up.
func New(cfg config) (*Agent, error) {
if cfg.GatewayURL == "" {
return nil, fmt.Errorf("no gateway URL configured (RDS_GATEWAY_URL)")
@@ -365,16 +366,16 @@ func unescapeMountField(field string) string {
// Boot-retry bounds. Tight to start, since the usual cause is a control plane
// still creating this instance's record, and capped so an outage does not turn
// a booting fleet into a retry storm.
const (
// a booting fleet into a retry storm. Vars so tests can shorten the wait.
var (
retryMin = 1 * time.Second
retryMax = 30 * time.Second
// With the production backoff, attempt five is about 15 seconds into the
// failure and still precedes the first 30-second heartbeat.
retryErrorAttempt = 5
)
// With the production backoff, attempt five is about 15 seconds into the
// failure and still precedes the first 30-second heartbeat.
const retryErrorAttempt = 5
// Wrapping an error in this stops retryObserved. For the failures the control
// plane decides from its own record, no retry can change the answer, and looping
// on one only keeps the caller blocked.
+2 -2
View File
@@ -948,8 +948,8 @@ func TestMariaDBProbe_LiveButUnreachableEngineResetsTheRollbackDeadline(t *testi
recovery := &fakeRecovery{restored: true}
guard := newTestGuard(recovery, probe)
// Bounded so the deadline-resetting loop cannot run forever if it stops
// resetting; the assertion is that the rollback was never reached.
ctx, cancel := context.WithTimeout(t.Context(), 200*time.Millisecond)
// resetting; four rollback budgets, so a missed reset would roll back.
ctx, cancel := context.WithTimeout(t.Context(), 80*time.Millisecond)
defer cancel()
guard.Run(ctx)
+14
View File
@@ -0,0 +1,14 @@
package main
import (
"os"
"testing"
"time"
)
// TestMain shortens the boot-retry backoff for the whole package: the tests
// assert which calls are retried, not how long the agent waits between them.
func TestMain(m *testing.M) {
retryMin, retryMax = time.Millisecond, time.Millisecond
os.Exit(m.Run())
}
+2 -2
View File
@@ -460,8 +460,8 @@ func TestParamGuard_LeavesARecoveringEngineAlone(t *testing.T) {
engine := &fakeRecovery{restored: true}
guard := newTestGuard(engine, stubProbe(t, 1, nil))
// Bounded so the deadline-resetting loop cannot run forever if it stops
// resetting; the assertion is that the rollback was never reached.
ctx, cancel := context.WithTimeout(t.Context(), 200*time.Millisecond)
// resetting; four rollback budgets, so a missed reset would roll back.
ctx, cancel := context.WithTimeout(t.Context(), 80*time.Millisecond)
defer cancel()
guard.Run(ctx)
+319 -91
View File
@@ -30,6 +30,7 @@ import (
"github.com/mulgadc/bluebottle/pkg/masterkey"
"github.com/mulgadc/bluebottle/pkg/safecast"
"github.com/mulgadc/spinifex/spinifex/admin"
"github.com/mulgadc/spinifex/spinifex/clustersize"
"github.com/mulgadc/spinifex/spinifex/config"
"github.com/mulgadc/spinifex/spinifex/ebsmetadata"
"github.com/mulgadc/spinifex/spinifex/ebsprovider"
@@ -200,6 +201,15 @@ takes effect immediately. Prompts for confirmation unless --yes is passed.`,
Run: runimagesPromoteCmd,
}
var imagesDescribeCmd = &cobra.Command{
Use: "describe",
Short: "Show an AMI's metadata and source digest",
Long: `Print an AMI's control-plane metadata, including the digest of the
artifact it was imported from and who verified it. The digest describes the
imported source file, not the volume's current contents.`,
Run: runimagesDescribeCmd,
}
var volumesCmd = &cobra.Command{
Use: "volumes",
Short: "Inspect block storage volumes",
@@ -294,7 +304,7 @@ spx admin images import --name debian-13-x86_64
spx admin images list
// Manually import a path
spx admin images import --file /path/to/image --distro debian --version 13 --arch x86_64
spx admin images import --file /path/to/image --checksum /path/to/SHA512SUMS --distro debian --version 13 --arch x86_64
-> x <-
*/
@@ -328,6 +338,7 @@ func init() {
imagesCmd.AddCommand(imagesListCmd)
imagesCmd.AddCommand(imagesRemoveCmd)
imagesCmd.AddCommand(imagesPromoteCmd)
imagesCmd.AddCommand(imagesDescribeCmd)
adminCmd.AddCommand(volumesCmd)
volumesCmd.AddCommand(volumesOrphansCmd)
@@ -362,7 +373,7 @@ func init() {
adminInitCmd.Flags().String("region", "ap-southeast-2", "Mulga region to create")
adminInitCmd.Flags().String("az", "ap-southeast-2a", "Mulga AZ to create")
adminInitCmd.Flags().String("node", "node1", "Node name, increment for additional nodes (default, node1)")
adminInitCmd.Flags().Int("nodes", 3, "Number of nodes to expect for cluster")
adminInitCmd.Flags().Int("nodes", 3, "Number of nodes to expect for cluster (1, or 3 and up)")
adminInitCmd.Flags().String("host", "", "Leader node to join (if not specified, tries multicast discovery)")
adminInitCmd.Flags().Int("port", 4432, "Port to bind cluster services on")
adminInitCmd.Flags().String("bind", "0.0.0.0", "IP address to bind services to (e.g., 10.11.12.1 for multi-node). Default 0.0.0.0 listens on all interfaces.")
@@ -414,7 +425,7 @@ func init() {
adminJoinCmd.MarkFlagRequired("host")
adminJoinCmd.MarkFlagRequired("token")
imagesImportCmd.Flags().String("tmp-dir", os.TempDir(), "Temporary directory for image import processing")
imagesImportCmd.Flags().String("tmp-dir", "", "Scratch directory for extracting the image (default: <spinifex-dir>/images); must be disk-backed, not tmpfs")
imagesImportCmd.Flags().String("name", "", "Import specified image by name")
imagesImportCmd.Flags().String("ami-name", "", "Override the registered AMI name (DescribeImages name). Defaults to ami-{distro}-{version}-{arch}. Use for locally-built appliances (e.g. --ami-name spinifex-eks-node).")
@@ -427,6 +438,7 @@ func init() {
imagesImportCmd.Flags().StringSlice("tag", nil, "Tag to apply to the imported AMI as key=value (repeatable; e.g. --tag spinifex:managed-by=elbv2)")
imagesImportCmd.Flags().Bool("force", false, "Force command execution (overwrites existing files)")
imagesImportCmd.Flags().Bool("skip-verify", false, "Skip catalog-image checksum verification (INSECURE; operator assumes integrity responsibility)")
imagesImportCmd.Flags().String("checksum", "", "Sums file to verify a --file image against (SHA256SUMS, SHA512SUMS, <image>.sha256/.sha512)")
imagesRemoveCmd.Flags().String("image-id", "", "AMI ID to remove (required)")
imagesRemoveCmd.Flags().Bool("force", false, "Bypass dependency, ownership and config-corrupt checks (salvage mode)")
@@ -436,6 +448,9 @@ func init() {
imagesPromoteCmd.Flags().String("image-id", "", "AMI ID to promote to system image (required)")
imagesPromoteCmd.Flags().Bool("yes", false, "Skip interactive confirmation prompt")
_ = imagesPromoteCmd.MarkFlagRequired("image-id")
imagesDescribeCmd.Flags().String("image-id", "", "AMI ID to describe (required)")
_ = imagesDescribeCmd.MarkFlagRequired("image-id")
}
const bytesPerGiB = 1024 * 1024 * 1024
@@ -464,11 +479,112 @@ func amiVolumeSizeGiB(sizeBytes int64) uint64 {
return safecast.Int64ToUint64((sizeBytes + bytesPerGiB - 1) / bytesPerGiB)
}
// validateChecksumFlags rejects --checksum combinations that would otherwise be silently ignored.
// An explicitly empty --checksum (e.g. an unset shell variable) must not fall through to an unverified import.
func validateChecksumFlags(checksumPath string, checksumSet bool, localFile string, skipVerify bool) error {
if !checksumSet {
return nil
}
if strings.TrimSpace(checksumPath) == "" {
return errors.New("--checksum requires a sums file path")
}
if localFile == "" {
return errors.New("--checksum requires --file")
}
if skipVerify {
return errors.New("--checksum and --skip-verify are mutually exclusive")
}
return nil
}
// catalogPinnedDigestSource records that the expected digest came from this
// binary's catalog, so a recorded digest never reads as a fetched sums file.
const catalogPinnedDigestSource = "catalog:pinned"
// importSource is the file an import is about to extract, and what it is to be verified against.
type importSource struct {
imageFile, imageName, localFile string
image utils.Images
checksumPath, checksumAlgo, expectedDigest string
skipVerify bool
}
// resolveDigest verifies or hashes the source before extract and returns the digest to record.
// Upstream digests are of the artifact as supplied. Failures are reported to errOut before
// returning; the file is left in place, and a catalog download recovers with --force.
func (src importSource) resolveDigest(out, errOut io.Writer) (ebsmetadata.ImageDigest, error) {
digest := ebsmetadata.ImageDigest{Filename: filepath.Base(src.imageFile)}
switch sourceDigestMode(src.localFile, src.checksumPath, src.skipVerify) {
case ebsmetadata.DigestCatalog:
if src.image.ChecksumType == "" || (src.image.Checksum == "" && src.image.ChecksumDigest == "") {
fmt.Fprintf(errOut, "Catalog entry %q is missing Checksum/ChecksumDigest/ChecksumType; refusing import.\n", src.imageName)
return digest, errors.New("catalog entry has no checksum")
}
// A pinned digest is checked against this file rather than a sums file
// fetched from the same host that served the image.
if src.image.ChecksumDigest != "" {
actual, err := utils.VerifyImageDigest(src.imageFile, src.image.ChecksumType, src.image.ChecksumDigest)
if err != nil {
printChecksumError(errOut, src.imageFile, src.imageName, src.image, err)
return digest, err
}
fmt.Fprintf(out, "✅ Verified image checksum (%s) against the pinned catalog digest\n", src.image.ChecksumType)
digest.Algorithm, digest.Value, digest.Source = src.image.ChecksumType, actual, catalogPinnedDigestSource
digest.Verification = ebsmetadata.DigestCatalog
return digest, nil
}
actual, err := utils.VerifyImageChecksum(src.imageFile, src.image.Checksum, src.image.ChecksumType)
if err != nil {
printChecksumError(errOut, src.imageFile, src.imageName, src.image, err)
return digest, err
}
fmt.Fprintf(out, "✅ Verified image checksum (%s)\n", src.image.ChecksumType)
digest.Algorithm, digest.Value, digest.Source = src.image.ChecksumType, actual, src.image.Checksum
digest.Verification = ebsmetadata.DigestCatalog
case ebsmetadata.DigestOperator:
actual, err := utils.VerifyImageDigest(src.localFile, src.checksumAlgo, src.expectedDigest)
if err != nil {
fmt.Fprintf(errOut, "Image integrity verification failed: %v\n", err)
fmt.Fprintf(errOut, " file: %s\n", src.localFile)
fmt.Fprintf(errOut, " checksum: %s\n", src.checksumPath)
return digest, err
}
fmt.Fprintf(out, "✅ Verified image checksum (%s) against %s\n", src.checksumAlgo, src.checksumPath)
digest.Algorithm, digest.Value, digest.Source = src.checksumAlgo, actual, filepath.Base(src.checksumPath)
digest.Verification = ebsmetadata.DigestOperator
default:
if src.localFile != "" {
fmt.Fprintf(errOut, "Importing %s without checksum validation (pass --checksum <sums-file> to verify)\n", src.localFile)
} else {
fmt.Fprintf(errOut, "⚠️ --skip-verify set: checksum verification skipped for %s\n", src.imageName)
}
actual, err := utils.HashImageFile(src.imageFile, "sha256")
if err != nil {
fmt.Fprintf(errOut, "Could not hash image: %v\n", err)
return digest, err
}
digest.Algorithm, digest.Value = "sha256", actual
digest.Verification = ebsmetadata.DigestUnverified
}
return digest, nil
}
// sourceDigestMode picks how an import's source digest is obtained.
func sourceDigestMode(localFile, checksumPath string, skipVerify bool) string {
switch {
case localFile != "" && checksumPath != "":
return ebsmetadata.DigestOperator
case localFile != "" || skipVerify:
return ebsmetadata.DigestUnverified
default:
return ebsmetadata.DigestCatalog
}
}
func runimagesImportCmd(cmd *cobra.Command, args []string) {
var image utils.Images
var imageFile string
var imageStat os.FileInfo
var err error
forceCmd, _ := cmd.Flags().GetBool("force")
@@ -503,6 +619,20 @@ func runimagesImportCmd(cmd *cobra.Command, args []string) {
os.Exit(1)
}
checksumPath, _ := cmd.Flags().GetString("checksum")
if err := validateChecksumFlags(checksumPath, cmd.Flags().Changed("checksum"), localFile, skipVerify); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
var checksumAlgo, expectedDigest string
if checksumPath != "" {
checksumAlgo, expectedDigest, err = utils.ReadExpectedDigest(checksumPath, filepath.Base(localFile))
if err != nil {
fmt.Fprintf(os.Stderr, "Could not read checksum from %s: %v\n", checksumPath, err)
os.Exit(1)
}
}
if imageName != "" {
var exists bool
image, exists = utils.AvailableImages[imageName]
@@ -589,8 +719,8 @@ func runimagesImportCmd(cmd *cobra.Command, args []string) {
os.Exit(1)
}
// Next, if the file is selected to download, fetch it, extract disk image, and save to path
if imageName != "" && localFile == "" {
// Next, if the file is selected to download, fetch it and save to path
if localFile == "" {
// Download the file to the image path
filename := path.Base(image.URL)
imageFile = fmt.Sprintf("%s/%s", imagePath, filename)
@@ -606,45 +736,75 @@ func runimagesImportCmd(cmd *cobra.Command, args []string) {
os.Exit(1)
}
}
}
// Verify before extract: the catalog digest is of the artifact as it
// sits on the mirror (.tar.xz/.img/.raw). Also catches a poisoned
// cache on the re-run path — failure leaves the file on disk so the
// operator can inspect; recover with --force.
if skipVerify {
fmt.Fprintf(os.Stderr, "⚠️ --skip-verify set: checksum verification skipped for %s\n", imageName)
} else {
if image.Checksum == "" || image.ChecksumType == "" {
fmt.Fprintf(os.Stderr, "Catalog entry %q is missing Checksum/ChecksumType; refusing import.\n", imageName)
os.Exit(1)
}
if err := utils.VerifyImageChecksum(imageFile, image.Checksum, image.ChecksumType); err != nil {
printChecksumError(os.Stderr, imageFile, imageName, image, err)
os.Exit(1)
}
fmt.Printf("✅ Verified image checksum (%s)\n", image.ChecksumType)
sourceDigest, err := importSource{
imageFile: imageFile,
imageName: imageName,
localFile: localFile,
image: image,
checksumPath: checksumPath,
checksumAlgo: checksumAlgo,
expectedDigest: expectedDigest,
skipVerify: skipVerify,
}.resolveDigest(os.Stdout, os.Stderr)
if err != nil {
os.Exit(1)
}
// An empty --tmp-dir scratches beside the catalog: disk-backed, unlike a
// tmpfs /tmp, and already root:spinifex 0770.
scratchRoot := ostmpDir
if scratchRoot == "" {
scratchRoot = imageDir
}
extractedImagePath, cleanupScratch, err := extractToScratch(imageFile, scratchRoot)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
// os.Exit skips deferred calls, so scratch is removed before any exit.
volumeId, err := registerExtractedImage(image, amiNameOverride, imagePath, extractedImagePath, sourceDigest)
cleanupScratch()
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
fmt.Printf("✅ Image import complete. Image-ID (AMI): %s\n", volumeId)
fmt.Printf("✅ Source digest: %s:%s (%s)\n", sourceDigest.Algorithm, sourceDigest.Value, sourceDigest.Verification)
}
// extractToScratch extracts imageFile into a fresh hidden directory under
// scratchRoot and returns the extracted path with a cleanup that removes that
// directory. On error the directory is already removed.
func extractToScratch(imageFile, scratchRoot string) (string, func(), error) {
scratchDir, err := os.MkdirTemp(scratchRoot, ".import-*")
if err != nil {
return "", nil, fmt.Errorf("create scratch dir under %s: %w", scratchRoot, err)
}
cleanup := func() {
if err := os.RemoveAll(scratchDir); err != nil {
fmt.Fprintf(os.Stderr, "Warning: could not remove scratch dir %s: %v\n", scratchDir, err)
}
}
// Next, validate if the image is raw, tar, gz, xv, etc. We need to upload the raw image
tmpDir, err := os.MkdirTemp(ostmpDir, "spinifex-image-tmp-*")
extracted, err := utils.ExtractDiskImageFromFile(imageFile, scratchDir)
if err != nil {
fmt.Fprintf(os.Stderr, "Could not create temp dir: %v\n", err)
os.Exit(1)
cleanup()
return "", nil, fmt.Errorf("extract image: %w", err)
}
return extracted, cleanup, nil
}
extractedImagePath, err := utils.ExtractDiskImageFromFile(imageFile, imagePath)
// registerExtractedImage writes the AMI manifest into imagePath, imports
// extractedImagePath into storage and registers the AMI, returning its ID.
func registerExtractedImage(image utils.Images, amiNameOverride, imagePath, extractedImagePath string, sourceDigest ebsmetadata.ImageDigest) (string, error) {
imageStat, err := os.Stat(extractedImagePath)
if err != nil {
fmt.Fprintf(os.Stderr, "Could not extract image: %v\n", err)
os.Exit(1)
}
imageStat, err = os.Stat(extractedImagePath)
if err != nil {
fmt.Fprintf(os.Stderr, "Could not stat image: %v\n", err)
os.Exit(1)
return "", fmt.Errorf("stat image: %w", err)
}
// Describe the image/AMI as the control-plane document it will become.
@@ -660,7 +820,7 @@ func runimagesImportCmd(cmd *cobra.Command, args []string) {
Description: fmt.Sprintf("%s cloud image prepared for Spinifex", amiName),
Architecture: image.Arch,
PlatformDetails: image.Platform,
CreationDate: time.Now(),
CreationDate: time.Now().UTC(),
RootDeviceType: "ebs",
Virtualization: "hvm",
ImageOwnerAlias: "system",
@@ -669,6 +829,7 @@ func runimagesImportCmd(cmd *cobra.Command, args []string) {
BootMode: image.BootMode,
Distro: image.Distro,
DistroFamily: utils.DistroFamily(image.Distro),
SourceDigest: &sourceDigest,
}
// Copy catalog-provided tags (e.g. spinifex:managed-by for system AMIs)
@@ -682,24 +843,19 @@ func runimagesImportCmd(cmd *cobra.Command, args []string) {
// Save as JSON
jsonData, err := json.Marshal(ami)
if err != nil {
fmt.Fprintf(os.Stderr, "Could not marshal manifest: %v\n", err)
os.Exit(1)
return "", fmt.Errorf("marshal manifest: %w", err)
}
manifestFilename := fmt.Sprintf("%s/%s.json", imagePath, ami.Name)
// Write to file
err = os.WriteFile(manifestFilename, jsonData, 0600)
if err != nil {
fmt.Fprintf(os.Stderr, "Could not write manifest: %v\n", err)
os.Exit(1)
return "", fmt.Errorf("write manifest: %w", err)
}
defer os.RemoveAll(tmpDir)
appConfig, nc, err := loadConfigAndConnect()
if err != nil {
fmt.Fprintf(os.Stderr, "Could not connect to the cluster: %v\n", err)
os.Exit(1)
return "", fmt.Errorf("connect to the cluster: %w", err)
}
defer nc.Close()
node := appConfig.Nodes[appConfig.Node]
@@ -716,8 +872,7 @@ func runimagesImportCmd(cmd *cobra.Command, args []string) {
Progress: os.Stdout,
})
if err != nil {
fmt.Fprintf(os.Stderr, "Could not import image: %v\n", err)
os.Exit(1)
return "", fmt.Errorf("import image: %w", err)
}
// admin.ImportImage only wrote the provider's half of the snapshot (its
@@ -725,8 +880,7 @@ func runimagesImportCmd(cmd *cobra.Command, args []string) {
// DescribeSnapshots and GetAMISourceVolumeID cannot resolve the import.
metaStore := objectstore.NewS3ObjectStoreFromConfig(node.Predastore.Host, node.Predastore.Region, node.Predastore.AccessKey, node.Predastore.SecretKey)
if err := registerImportedAMISnapshot(metaStore, node.Predastore.Bucket, ami, node.AZ, node.Viperblock.EncryptionKeyFile != ""); err != nil {
fmt.Fprintf(os.Stderr, "Imported %s but could not register its snapshot: %v\n", volumeId, err)
os.Exit(1)
return "", fmt.Errorf("imported %s but could not register its snapshot: %w", volumeId, err)
}
// The document is what DescribeImages enumerates, so it is written last:
@@ -734,11 +888,10 @@ func runimagesImportCmd(cmd *cobra.Command, args []string) {
// partway leaves no half-registered image behind.
ami.State = "available"
if err := ebsmetadata.NewStore(metaStore, node.Predastore.Bucket).PutAMI(context.Background(), ami); err != nil {
fmt.Fprintf(os.Stderr, "Imported %s but could not register it: %v\n", volumeId, err)
os.Exit(1)
return "", fmt.Errorf("imported %s but could not register it: %w", volumeId, err)
}
fmt.Printf("✅ Image import complete. Image-ID (AMI): %s\n", volumeId)
return volumeId, nil
}
// registerImportedAMISnapshot writes the EC2 control plane's snapshot document
@@ -1001,6 +1154,62 @@ func runimagesPromoteCmd(cmd *cobra.Command, args []string) {
fmt.Printf("✅ Promoted %s to system image (owner: %s).\n", imageID, admin.SystemOwnerAlias)
}
func runimagesDescribeCmd(cmd *cobra.Command, args []string) {
imageID, _ := cmd.Flags().GetString("image-id")
cfgFile, _ := cmd.Flags().GetString("config")
if cfgFile == "" {
cfgFile = DefaultConfigFile()
}
appConfig, err := config.LoadConfig(cfgFile)
if err != nil {
fmt.Fprintln(os.Stderr, "Error loading config file:", err)
os.Exit(1)
}
node := appConfig.Nodes[appConfig.Node]
store := objectstore.NewS3ObjectStoreFromConfig(
node.Predastore.Host,
node.Predastore.Region,
node.Predastore.AccessKey,
node.Predastore.SecretKey,
)
meta, err := admin.GetAMIMetadata(store, node.Predastore.Bucket, imageID)
if err != nil {
fmt.Fprintln(os.Stderr, "Failed to describe AMI:", err)
os.Exit(1)
}
printAMIDescription(os.Stdout, meta)
}
// printAMIDescription writes an AMI's metadata and source digest for an operator.
func printAMIDescription(w io.Writer, meta ebsmetadata.AMI) {
fmt.Fprintf(w, "Image ID: %s\n", meta.ImageID)
fmt.Fprintf(w, "Name: %s\n", meta.Name)
fmt.Fprintf(w, "Owner: %s\n", meta.ImageOwnerAlias)
if !meta.CreationDate.IsZero() {
fmt.Fprintf(w, "Created: %s\n", meta.CreationDate.UTC().Format("2006-01-02T15:04:05Z"))
}
fmt.Fprintf(w, "Boot mode: %s\n", meta.BootMode)
fmt.Fprintf(w, "Architecture: %s\n", meta.Architecture)
d := meta.SourceDigest
if d == nil {
fmt.Fprintln(w, "Source digest: not recorded (imported before digest recording)")
return
}
fmt.Fprintf(w, "Source digest: %s:%s\n", d.Algorithm, d.Value)
if d.Source != "" {
fmt.Fprintf(w, "Verification: %s (%s)\n", d.Verification, d.Source)
} else {
fmt.Fprintf(w, "Verification: %s\n", d.Verification)
}
fmt.Fprintf(w, "Source file: %s\n", d.Filename)
}
// List remote images available.
func runimagesListCmd(cmd *cobra.Command, args []string) {
//fmt.Println(availableImages)
@@ -1028,6 +1237,26 @@ func runimagesListCmd(cmd *cobra.Command, args []string) {
pterm.Println("spx admin images import --name <image-name>")
}
// validateNodeCount accepts the cluster sizes Spinifex supports: one server, or
// three and up.
//
// Two is rejected rather than allowed and warned about. On two servers OVN runs
// standalone, the storage metadata quorum has no majority to lose, and the
// control plane's own KV state has no replica count that works — one replica
// disappears with its node, and two stop accepting writes when either goes. A
// cluster that cannot survive the event it was built for is worth refusing at the
// point someone asks for it.
func validateNodeCount(nodes int) error {
if nodes < 1 {
return fmt.Errorf("a cluster needs at least one node, got %d", nodes)
}
if nodes > 1 && nodes < clustersize.MinHANodes {
return fmt.Errorf("%d nodes is not a supported cluster size: use 1 for a single server, or %d and up for a cluster that can survive losing one",
nodes, clustersize.MinHANodes)
}
return nil
}
// TODO: Move all logic to a module, use minimal application logic in viper commands.
func runAdminInit(cmd *cobra.Command, args []string) {
if os.Getuid() != 0 {
@@ -1059,6 +1288,15 @@ func runAdminInit(cmd *cobra.Command, args []string) {
clusterName, _ := cmd.Flags().GetString("cluster-name")
services, _ := cmd.Flags().GetStringSlice("services")
// Rejected before anything is written, because a two-node cluster has no
// correct answer rather than a worse one: no layer of the stack has a
// majority to lose, and no replica count for its state both survives a node
// going away and still accepts writes.
if err := validateNodeCount(nodes); err != nil {
fmt.Fprintf(os.Stderr, "--nodes: %v\n", err)
os.Exit(1)
}
// Optional operator email — validated up-front so a bad address fails
// before we touch any config state. Empty is allowed here; reset / repeat
// inits on a box that already has an email in /etc/spinifex/spinifex.toml
@@ -1116,7 +1354,7 @@ func runAdminInit(cmd *cobra.Command, args []string) {
detected, err := admin.DetectNetwork()
if err != nil {
fmt.Fprintf(os.Stderr, "⚠️ Network auto-detection failed: %v\n", err)
fmt.Fprintf(os.Stderr, " Use --external-mode=nat for outbound-only VMs on a non-bridgeable uplink, or specify --external-* flags manually.\n")
fmt.Fprintf(os.Stderr, " Use --external-mode=nat on a non-bridgeable uplink, or specify --external-* flags manually.\n")
} else {
detectedNet = detected
@@ -1154,7 +1392,7 @@ func runAdminInit(cmd *cobra.Command, args []string) {
if externalMode == "" && !cmd.Flags().Changed("external-mode") {
if isNonBridgeableUplink(detected.WAN.Name) {
fmt.Fprintf(os.Stderr, "\n❌ Detected WAN interface %s cannot be bridged (WiFi/cellular/PPP).\n", detected.WAN.Name)
fmt.Fprintf(os.Stderr, " Use routed NAT mode instead (outbound-only VM networking):\n")
fmt.Fprintf(os.Stderr, " Use routed NAT mode instead:\n")
fmt.Fprintf(os.Stderr, " ./scripts/setup-ovn.sh --management --nat-uplink\n")
fmt.Fprintf(os.Stderr, " spx admin init --external-mode=nat\n")
os.Exit(1)
@@ -1176,9 +1414,14 @@ func runAdminInit(cmd *cobra.Command, args []string) {
// the transit segment claims externalGateway below.
natPublicGateway := externalGateway
if externalMode == "nat" {
// Verified on three OCI nodes 2026-09-26: each node delivers its own
// instances' public IPs, inbound and outbound. Still flagged because no
// nightly cell covers multi-node nat yet.
if nodes >= 2 {
fmt.Fprintf(os.Stderr, "❌ Error: --external-mode=nat is single-node only (v1); use --nodes=1\n")
os.Exit(1)
fmt.Fprintf(os.Stderr, "⚠️ --external-mode=nat on %d nodes is NEW.\n", nodes)
fmt.Fprintf(os.Stderr, " Public IPs are delivered by the node running the instance; default egress stays on the VPC's gateway chassis.\n")
fmt.Fprintf(os.Stderr, " Verified by hand on three nodes, but not yet covered by a nightly cell.\n")
fmt.Fprintf(os.Stderr, " Check with: ip route show dev spx-nat-host — exactly one node should hold each public address.\n")
}
if !natPublicPool && (externalBindBridge != "" || gatewayIP != "") {
fmt.Fprintf(os.Stderr, "❌ Error: --external-bind-bridge/--gateway-ip require --external-pool or --external-source in --external-mode=nat\n")
@@ -1439,7 +1682,7 @@ func runAdminInit(cmd *cobra.Command, args []string) {
}
// Generate SSL certificates (with bind IP in SANs for multi-node support)
certPath := admin.GenerateCertificatesIfNeeded(configDir, force, bindIP, region, config.DefaultAWSInternalSuffix)
certPath := admin.GenerateCertificatesIfNeeded(configDir, force, bindIP, region, config.DefaultAWSServicesDomain)
// Generate per-node IPsec peer cert when cluster-wide IPsec is enabled
// (default true). Reuses the cluster CA — no intermediate strongSwan PKI.
@@ -1508,14 +1751,7 @@ func runAdminInit(cmd *cobra.Command, args []string) {
bootstrapSubnetId := utils.GenerateResourceID("subnet")
bootstrapIgwId := utils.GenerateResourceID("igw")
networkConfig.ExternalMode = externalMode
networkConfig.PoolName = "wan"
networkConfig.PoolSource = externalSource
networkConfig.PoolBindBridge = externalBindBridge
networkConfig.PoolStart = poolStart
networkConfig.PoolEnd = poolEnd
networkConfig.PoolGateway = externalGateway
networkConfig.PoolGatewayIP = gatewayIP
networkConfig.PoolPrefixLen = externalPrefixLen
networkConfig.Pools = externalPools
networkConfig.PoolDNSServers = dnsServers
networkConfig.BootstrapAccountId = admin.DefaultAccountID()
networkConfig.BootstrapVpcId = bootstrapVpcId
@@ -1668,7 +1904,7 @@ func runAdminInit(cmd *cobra.Command, args []string) {
fmt.Printf(" Public pool: dhcp via %s\n", externalBindBridge)
}
} else {
fmt.Printf("\n📡 External networking: nat (routed, outbound-only — no public IPs/EIPs)\n")
fmt.Printf("\n📡 External networking: nat (routed; no public pool configured yet)\n")
}
fmt.Printf(" Transit: %s via %s (host masquerades out any uplink)\n", host.NATTransitCIDR, host.NATTransitHostEnd)
fmt.Printf(" Host setup: ./scripts/setup-ovn.sh --nat-uplink (run before starting services)\n")
@@ -2423,7 +2659,7 @@ func runAdminJoin(cmd *cobra.Command, args []string) {
}
// Generate server cert signed by CA with this node's bind IP
if err := admin.GenerateServerCertOnly(configDir, bindIP, region, config.DefaultAWSInternalSuffix); err != nil {
if err := admin.GenerateServerCertOnly(configDir, bindIP, region, config.DefaultAWSServicesDomain); err != nil {
fmt.Fprintf(os.Stderr, "Error generating server certificate: %v\n", err)
os.Exit(1)
}
@@ -2642,7 +2878,7 @@ func initIAMServiceFromConfig() (*handlers_iam.IAMServiceImpl, *config.ClusterCo
// Background: this runs at CLI top level, where there is no request to
// inherit a deadline from and the process exits after the one command.
svc, err := handlers_iam.NewIAMServiceImpl(context.Background(), nc, masterKey, len(cfg.Nodes))
svc, err := handlers_iam.NewIAMServiceImpl(context.Background(), nc, masterKey)
if err != nil {
nc.Close()
return nil, nil, nil, nil, fmt.Errorf("init IAM service: %w", err)
@@ -2758,7 +2994,6 @@ func runAccountCreate(cmd *cobra.Command, args []string) {
endpointHost = h
}
}
endpointURL := "https://" + net.JoinHostPort(endpointHost, "9999")
credPath := filepath.Join(homeDir, ".aws", "credentials")
configPath := filepath.Join(homeDir, ".aws", "config")
@@ -2773,12 +3008,7 @@ func runAccountCreate(cmd *cobra.Command, args []string) {
if region == "" {
region = "ap-southeast-2"
}
if err := admin.UpdateAWSINIFile(configPath, "profile "+profileName, map[string]string{
"region": region,
"endpoint_url": endpointURL,
"ca_bundle": certPath,
"output": "json",
}); err != nil {
if err := admin.WriteAWSProfileConfig(configPath, profileName, region, certPath, endpointHost); err != nil {
fmt.Fprintf(os.Stderr, "Warning: could not update AWS config: %v\n", err)
}
@@ -3032,18 +3262,11 @@ func applyNetworkConfig(settings *admin.ConfigSettings, nc *formation.NetworkCon
settings.IPSecEnabled = nc.IPSecEnabled
settings.ExternalMode = nc.ExternalMode
settings.PoolDNSServers = nc.PoolDNSServers
// Derived from the mode rather than carried in NetworkConfig, so the leader
// and every joiner pin the same value from the same rule as single-node init.
settings.BridgeMode = bridgeModeFor(nc.ExternalMode)
if nc.ExternalMode != "" {
settings.Pools = []admin.PoolData{{
Name: nc.PoolName,
Source: nc.PoolSource,
BindBridge: nc.PoolBindBridge,
Start: nc.PoolStart,
End: nc.PoolEnd,
Gateway: nc.PoolGateway,
GatewayIP: nc.PoolGatewayIP,
PrefixLen: nc.PoolPrefixLen,
DNSServers: nc.PoolDNSServers,
}}
settings.Pools = nc.Pools
}
settings.BootstrapAccountId = nc.BootstrapAccountId
@@ -3294,11 +3517,11 @@ func resolvePublicPoolFlags(source, poolRange, bindBridge, gateway, defaultBindB
if gateway == "" {
return "", "", "", "", fmt.Errorf("--external-gateway is required with --external-source=static")
}
parts := strings.SplitN(poolRange, "-", 2)
if len(parts) != 2 || net.ParseIP(parts[0]) == nil || net.ParseIP(parts[1]) == nil {
var ok bool
start, end, ok = strings.Cut(poolRange, "-")
if !ok || net.ParseIP(start) == nil || net.ParseIP(end) == nil {
return "", "", "", "", fmt.Errorf("--external-pool must be start-end IPs (e.g., 192.168.1.150-192.168.1.250), got: %s", poolRange)
}
start, end = parts[0], parts[1]
default:
return "", "", "", "", fmt.Errorf("--external-source must be 'static' or 'dhcp', got: %s", source)
}
@@ -3744,7 +3967,12 @@ func resolveIfaceIP(iface string) string {
func printChecksumError(w io.Writer, imageFile, imageName string, image utils.Images, err error) {
fmt.Fprintf(w, "Image integrity verification failed: %v\n", err)
fmt.Fprintf(w, " file: %s\n", imageFile)
fmt.Fprintf(w, " source: %s\n", image.Checksum)
if image.ChecksumDigest != "" {
fmt.Fprintf(w, " source: %s (%s)\n", catalogPinnedDigestSource, image.ChecksumDigest)
fmt.Fprintf(w, " url: %s\n", image.URL)
} else {
fmt.Fprintf(w, " source: %s\n", image.Checksum)
}
fmt.Fprintln(w)
fmt.Fprintln(w, "The cached file was left in place. To re-download and retry:")
fmt.Fprintf(w, " spx admin images import --name %s --force\n", imageName)
+3 -6
View File
@@ -3,7 +3,8 @@ package cmd
import (
"context"
"fmt"
"sort"
"maps"
"slices"
"strconv"
"github.com/mulgadc/spinifex/spinifex/gateway"
@@ -166,11 +167,7 @@ func printAccountQuota(resp *gateway.AccountQuotaResponse) {
fmt.Printf("%-16s %10s %s\n", "DIMENSION", "LIMIT", "SOURCE")
fmt.Printf("%-16s %10s %s\n", "---------", "-----", "------")
names := make([]string, 0, len(resp.Limits))
for name := range resp.Limits {
names = append(names, name)
}
sort.Strings(names)
names := slices.Sorted(maps.Keys(resp.Limits))
for _, name := range names {
limit := strconv.Itoa(resp.Limits[name])
if resp.Limits[name] == handlers_quota.Unlimited {
@@ -0,0 +1,117 @@
package cmd
import (
"bytes"
"compress/gzip"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func readRawDiskFixture(t *testing.T) []byte {
t.Helper()
data, err := os.ReadFile(filepath.Join("..", "..", "..", "tests", "unit-test-disk-image.raw"))
require.NoError(t, err)
return data
}
func gzipData(t *testing.T, data []byte) []byte {
t.Helper()
var buf bytes.Buffer
gw := gzip.NewWriter(&buf)
_, err := gw.Write(data)
require.NoError(t, err)
require.NoError(t, gw.Close())
return buf.Bytes()
}
func qcow2Data(t *testing.T, raw []byte) []byte {
t.Helper()
if _, err := exec.LookPath("qemu-img"); err != nil {
t.Skip("qemu-img not found")
}
dir := t.TempDir()
rawPath := filepath.Join(dir, "src.raw")
qcowPath := filepath.Join(dir, "src.qcow2")
require.NoError(t, os.WriteFile(rawPath, raw, 0o600))
out, err := exec.Command("qemu-img", "convert", "-f", "raw", "-O", "qcow2", rawPath, qcowPath).CombinedOutput()
require.NoError(t, err, string(out))
data, err := os.ReadFile(qcowPath)
require.NoError(t, err)
return data
}
func dirNames(t *testing.T, dir string) []string {
t.Helper()
entries, err := os.ReadDir(dir)
require.NoError(t, err)
names := make([]string, 0, len(entries))
for _, e := range entries {
names = append(names, e.Name())
}
sort.Strings(names)
return names
}
// The catalog dir doubles as the download cache, so the source lives there
// as it does for a --name import, with the images dir as the scratch root.
func TestExtractToScratch_LeavesNoIntermediateInCatalog(t *testing.T) {
raw := readRawDiskFixture(t)
cases := []struct {
name string
source string
payload func(t *testing.T) []byte
wantErr string
}{
{
name: "gzip raw is decompressed into scratch",
source: "disk.raw.gz",
payload: func(t *testing.T) []byte { return gzipData(t, raw) },
},
{
name: "qcow2 is converted into scratch",
source: "disk.qcow2",
payload: func(t *testing.T) []byte { return qcow2Data(t, raw) },
},
{
name: "intermediate that fails the disk sniff is removed",
source: "disk.raw.gz",
payload: func(t *testing.T) []byte { return gzipData(t, []byte("not a disk image")) },
wantErr: "extract image",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
imagesDir := t.TempDir()
catalogDir := filepath.Join(imagesDir, "debian", "13", "x86_64")
require.NoError(t, os.MkdirAll(catalogDir, 0o770))
source := filepath.Join(catalogDir, tc.source)
require.NoError(t, os.WriteFile(source, tc.payload(t), 0o600))
extracted, cleanup, err := extractToScratch(source, imagesDir)
if tc.wantErr != "" {
require.ErrorContains(t, err, tc.wantErr)
assert.Nil(t, cleanup)
} else {
require.NoError(t, err)
scratchDir := filepath.Dir(extracted)
assert.Equal(t, imagesDir, filepath.Dir(scratchDir))
assert.True(t, strings.HasPrefix(filepath.Base(scratchDir), ".import-"), "extracted into %s", extracted)
assert.FileExists(t, extracted)
cleanup()
}
assert.Equal(t, []string{"debian"}, dirNames(t, imagesDir), "scratch dir must be removed")
assert.Equal(t, []string{tc.source}, dirNames(t, catalogDir), "catalog must hold only the source")
})
}
}
+1
View File
@@ -61,6 +61,7 @@ func TestCanaryRoundTrip_WritesADistinctValueEachRun(t *testing.T) {
// A deleted bucket stands in for the unreachable-store case: the probe must
// report an error rather than treating a failed write as success.
func TestCanaryRoundTrip_FailsWhenBucketIsGone(t *testing.T) {
t.Parallel()
kv, js := newCanaryBucket(t)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+2 -5
View File
@@ -11,6 +11,7 @@ import (
"fmt"
"io"
"io/fs"
"maps"
"os"
"path/filepath"
"slices"
@@ -420,11 +421,7 @@ func hashStoredMsg(m *jetstream.RawStreamMsg) string {
field([]byte(m.Subject))
binary.BigEndian.PutUint64(n[:], uint64(m.Time.UnixNano()))
h.Write(n[:])
keys := make([]string, 0, len(m.Header))
for k := range m.Header {
keys = append(keys, k)
}
sort.Strings(keys)
keys := slices.Sorted(maps.Keys(m.Header))
for _, k := range keys {
field([]byte(k))
for _, v := range m.Header[k] {
+2 -5
View File
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"io"
"maps"
"os"
"slices"
"sort"
@@ -153,11 +154,7 @@ func compareNodeDigests(nodes []nodeDigest) (kvCompareResult, error) {
byName[d.Name] = append(byName[d.Name], v)
}
}
names := make([]string, 0, len(byName))
for name := range byName {
names = append(names, name)
}
sort.Strings(names)
names := slices.Sorted(maps.Keys(byName))
var res kvCompareResult
for _, name := range names {
+201
View File
@@ -0,0 +1,201 @@
package cmd
import (
"context"
"encoding/json"
"fmt"
"io"
"os"
"strings"
"text/tabwriter"
"github.com/mulgadc/spinifex/spinifex/kvutil"
"github.com/nats-io/nats.go/jetstream"
"github.com/spf13/cobra"
)
var kvReplicasCmd = &cobra.Command{
Use: "replicas",
Short: "Report how many nodes each KV bucket is replicated across",
Long: `Print one line per JetStream KV bucket: how many replicas it has, how many the
cluster's size entitles it to, and which nodes currently hold it.
A bucket with fewer replicas than the cluster has nodes lives on fewer nodes
than exist, and every node depends on those. Losing one of them makes the
bucket unreadable and unwritable from every node at once, which is a
cluster-wide outage caused by a single-node event.
A bucket also has to be able to serve, which is a different question from how
many replicas it was configured for. ONLINE counts the replicas that are caught
up and reachable; a bucket whose ONLINE is not a majority of REPLICAS cannot
accept a write no matter what its replica count says.
--repair raises any under-replicated bucket to the cluster's replica count,
capped at JetStream's maximum of five. It changes nothing else about a bucket
and never lowers a replica count, so it is safe to run on a healthy cluster and
safe to run twice.
Exit status separates the two problems, so this can gate a deployment without
parsing the output:
0 every bucket is at the cluster's replica count and can serve
1 at least one bucket is under-replicated — --repair is the answer
2 at least one bucket has no quorum, or the report could not be produced —
a node needs looking at, and --repair will not help`,
Run: runKVReplicas,
}
func init() {
kvCmd.AddCommand(kvReplicasCmd)
kvReplicasCmd.Flags().Bool("repair", false, "raise every under-replicated bucket to the cluster's node count")
kvReplicasCmd.Flags().Bool("json", false, "emit the report as JSON")
}
func runKVReplicas(cmd *cobra.Command, _ []string) {
repair, _ := cmd.Flags().GetBool("repair")
asJSON, _ := cmd.Flags().GetBool("json")
// Anything that stops the report being produced exits unreachable rather
// than under-replicated: an empty answer is not a cluster with no buckets,
// and --repair is not the response to it.
_, nc, err := loadConfigAndConnect()
if err != nil {
fmt.Fprintf(os.Stderr, "connect to cluster: %v\n", err)
os.Exit(exitUnreachable)
}
defer nc.Close()
js, err := jetstream.New(nc)
if err != nil {
fmt.Fprintf(os.Stderr, "get JetStream context: %v\n", err)
os.Exit(exitUnreachable)
}
// Background: this runs at CLI top level, where there is no request to
// inherit a deadline from and the process exits after the one command.
ctx := context.Background()
reports, err := kvutil.AuditBucketReplicas(ctx, js)
if err != nil {
fmt.Fprintf(os.Stderr, "audit KV bucket replicas: %v\n", err)
os.Exit(exitUnreachable)
}
if repair {
repairReplicaReports(ctx, js, reports, os.Stderr)
}
if err := writeReplicaReport(os.Stdout, reports, asJSON); err != nil {
fmt.Fprintf(os.Stderr, "write report: %v\n", err)
os.Exit(exitUnreachable)
}
os.Exit(replicaReportExit(reports))
}
// Exit statuses, documented in the command's own help because a deployment gate
// reads them rather than the output.
const (
exitHealthy = 0
// exitUnderReplicated is the repairable problem: the buckets serve, but on
// fewer nodes than the cluster has.
exitUnderReplicated = 1
// exitUnreachable is the problem repair cannot touch — a bucket that cannot
// accept a write, or a report that could not be produced at all.
exitUnreachable = 2
)
// replicaReportExit is the exit status for a set of reports.
//
// No quorum outranks under-replicated because they need different responses: one
// is fixed by raising a count, the other by finding out why a node is not
// answering. A bucket that is both should send the operator to the node.
func replicaReportExit(reports []kvutil.BucketReport) int {
status := exitHealthy
for _, r := range reports {
if !r.HasQuorum() {
return exitUnreachable
}
if r.UnderReplicated() {
status = exitUnderReplicated
}
}
return status
}
// repairReplicaReports raises every under-replicated bucket and updates reports
// in place for the ones that were raised.
//
// One bucket that cannot be placed must not stop the others being repaired, so a
// failure is named on errOut and the sweep continues. The bucket it failed on
// stays UNDER-REPLICATED in the report, which is already what makes the exit
// status non-zero, so nothing is lost by not returning an error here.
func repairReplicaReports(ctx context.Context, js jetstream.JetStream, reports []kvutil.BucketReport, errOut io.Writer) {
for i, r := range reports {
if !r.UnderReplicated() {
continue
}
if err := kvutil.RaiseBucketReplicas(ctx, js, r.Bucket, r.Want); err != nil {
fmt.Fprintf(errOut, "repair %s: %v\n", r.Bucket, err)
continue
}
reports[i].Replicas = r.Want
}
}
// underReplicatedCount is how many buckets are below the cluster's replica count.
func underReplicatedCount(reports []kvutil.BucketReport) int {
under := 0
for _, r := range reports {
if r.UnderReplicated() {
under++
}
}
return under
}
// noQuorumCount is how many buckets cannot currently accept a write.
func noQuorumCount(reports []kvutil.BucketReport) int {
stuck := 0
for _, r := range reports {
if !r.HasQuorum() {
stuck++
}
}
return stuck
}
// replicaStatus is the one word for a bucket's condition.
//
// No quorum wins over under-replicated when both are true, matching the exit
// status: a bucket that cannot serve is the more urgent of the two and is not
// what --repair is for.
func replicaStatus(r kvutil.BucketReport) string {
switch {
case !r.HasQuorum():
return "NO-QUORUM"
case r.UnderReplicated():
return "UNDER-REPLICATED"
default:
return "ok"
}
}
// writeReplicaReport renders reports as a table, or as JSON when asJSON.
func writeReplicaReport(out io.Writer, reports []kvutil.BucketReport, asJSON bool) error {
if asJSON {
return json.NewEncoder(out).Encode(reports)
}
w := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0)
fmt.Fprintln(w, "BUCKET\tREPLICAS\tWANT\tONLINE\tSTATUS\tLEADER\tHELD BY")
for _, r := range reports {
fmt.Fprintf(w, "%s\t%d\t%d\t%d\t%s\t%s\t%s\n",
r.Bucket, r.Replicas, r.Want, r.Online, replicaStatus(r), r.Leader, strings.Join(r.Peers, ","))
}
if err := w.Flush(); err != nil {
return err
}
_, err := fmt.Fprintf(out, "\n%d buckets, %d under-replicated, %d without quorum\n",
len(reports), underReplicatedCount(reports), noQuorumCount(reports))
return err
}
+133
View File
@@ -0,0 +1,133 @@
//test:in-package — it tests this command's unexported rendering and repair
// helpers, which exist only to be exercised without a live cluster.
package cmd
import (
"bytes"
"encoding/json"
"strings"
"testing"
"github.com/mulgadc/spinifex/spinifex/clustersize"
"github.com/mulgadc/spinifex/spinifex/kvutil"
"github.com/mulgadc/spinifex/spinifex/testutil"
"github.com/nats-io/nats.go/jetstream"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestWriteReplicaReport_NamesEveryUnderReplicatedBucket(t *testing.T) {
reports := []kvutil.BucketReport{
{Bucket: "healthy", Replicas: 3, Want: 3, Online: 3, Leader: "a", Peers: []string{"a", "b", "c"}},
{Bucket: "short", Replicas: 1, Want: 3, Online: 1, Leader: "a", Peers: []string{"a"}},
{Bucket: "stuck", Replicas: 3, Want: 3, Online: 1, Peers: []string{"a", "b", "c"}},
}
var out bytes.Buffer
require.NoError(t, writeReplicaReport(&out, reports, false))
got := out.String()
assert.Contains(t, got, "BUCKET")
assert.Contains(t, got, "HELD BY")
assert.Regexp(t, `healthy\s+3\s+3\s+3\s+ok\s+a\s+a,b,c`, got)
assert.Regexp(t, `short\s+1\s+3\s+1\s+UNDER-REPLICATED\s+a\s+a`, got)
// A bucket that cannot serve reads as NO-QUORUM even though its configured
// count is right, because raising a count is not the response to it.
assert.Regexp(t, `stuck\s+3\s+3\s+1\s+NO-QUORUM`, got)
assert.Contains(t, got, "3 buckets, 1 under-replicated, 1 without quorum")
}
// TestReplicaReportExit pins the three exit classes a deployment gate reads. They
// are separate because the responses are: a raise fixes an under-replicated
// bucket, and nothing the command can do fixes one that will not answer.
func TestReplicaReportExit(t *testing.T) {
healthy := kvutil.BucketReport{Bucket: "a", Replicas: 3, Want: 3, Online: 3}
short := kvutil.BucketReport{Bucket: "b", Replicas: 1, Want: 3, Online: 1}
stuck := kvutil.BucketReport{Bucket: "c", Replicas: 3, Want: 3, Online: 1}
assert.Equal(t, exitHealthy, replicaReportExit(nil))
assert.Equal(t, exitHealthy, replicaReportExit([]kvutil.BucketReport{healthy}))
assert.Equal(t, exitUnderReplicated, replicaReportExit([]kvutil.BucketReport{healthy, short}))
assert.Equal(t, exitUnreachable, replicaReportExit([]kvutil.BucketReport{healthy, stuck}))
// Both problems at once sends the operator to the node rather than to --repair.
assert.Equal(t, exitUnreachable, replicaReportExit([]kvutil.BucketReport{short, stuck}))
}
// TestWriteReplicaReport_JSONIsWhatTheE2ESuiteDecodes keeps the machine-readable
// form honest: the e2e suite and anything gating a deployment parse these field
// names, so a rename here breaks them silently.
func TestWriteReplicaReport_JSONIsWhatTheE2ESuiteDecodes(t *testing.T) {
var out bytes.Buffer
require.NoError(t, writeReplicaReport(&out, []kvutil.BucketReport{
{Bucket: "short", Replicas: 1, Want: 3, Cluster: "spinifex", Online: 1, Leader: "a", Peers: []string{"a"}},
}, true))
assert.Contains(t, out.String(), `"bucket":"short"`)
var decoded []kvutil.BucketReport
require.NoError(t, json.Unmarshal(out.Bytes(), &decoded))
require.Len(t, decoded, 1)
assert.Equal(t, "short", decoded[0].Bucket)
assert.Equal(t, 1, decoded[0].Replicas)
assert.Equal(t, 3, decoded[0].Want)
assert.Equal(t, 1, decoded[0].Online)
assert.Equal(t, "a", decoded[0].Leader)
assert.True(t, decoded[0].UnderReplicated())
assert.True(t, decoded[0].HasQuorum())
}
func TestUnderReplicatedCount(t *testing.T) {
assert.Equal(t, 0, underReplicatedCount(nil))
assert.Equal(t, 2, underReplicatedCount([]kvutil.BucketReport{
{Bucket: "a", Replicas: 1, Want: 3},
{Bucket: "b", Replicas: 3, Want: 3},
{Bucket: "c", Replicas: 2, Want: 3},
}))
}
// TestRepairReplicaReports_KeepsGoingPastABucketItCannotPlace is the behaviour
// that makes --repair usable mid-growth: the buckets it can raise are raised,
// and the one it cannot is named rather than aborting the run.
func TestRepairReplicaReports_KeepsGoingPastABucketItCannotPlace(t *testing.T) {
_, nc, js := testutil.StartTestJetStream(t)
defer nc.Close()
for _, b := range []string{"repair-alpha", "repair-beta"} {
_, err := js.CreateKeyValue(t.Context(), jetstream.KeyValueConfig{Bucket: b, History: 1})
require.NoError(t, err)
}
// One embedded server cannot hold two replicas, so every raise fails — and
// every bucket must still have been attempted and named.
reports := []kvutil.BucketReport{
{Bucket: "repair-alpha", Replicas: 1, Want: 2},
{Bucket: "repair-beta", Replicas: 1, Want: 2},
}
var errOut bytes.Buffer
repairReplicaReports(t.Context(), js, reports, &errOut)
assert.Contains(t, errOut.String(), "repair repair-alpha")
assert.Contains(t, errOut.String(), "repair repair-beta")
assert.Equal(t, 2, underReplicatedCount(reports), "a failed raise must not be reported as repaired")
}
// TestRepairReplicaReports_RaisesWhatItCan covers the success side against a real
// server, so the in-place report update is proved rather than assumed.
func TestRepairReplicaReports_RaisesWhatItCan(t *testing.T) {
_, nc, js := testutil.StartTestJetStream(t)
defer nc.Close()
clustersize.DeclareForTest(t, 1)
_, err := js.CreateKeyValue(t.Context(), jetstream.KeyValueConfig{Bucket: "repair-ok", History: 1})
require.NoError(t, err)
// Want 1 against a bucket already at 1: the raise is a no-op that must still
// leave the report reading as healthy rather than as repaired-and-wrong.
reports := []kvutil.BucketReport{{Bucket: "repair-ok", Replicas: 1, Want: 1}}
var errOut bytes.Buffer
repairReplicaReports(t.Context(), js, reports, &errOut)
assert.Empty(t, strings.TrimSpace(errOut.String()))
assert.Equal(t, 0, underReplicatedCount(reports))
}
+12 -4
View File
@@ -215,11 +215,15 @@ func TestKVDigestClusteredReplicas(t *testing.T) {
js, err := jetstream.New(nc)
require.NoError(t, err)
// Each attempt gets its own deadline: before a meta leader is elected a
// create blocks for the client's default timeout instead of failing fast.
var kv jetstream.KeyValue
require.Eventually(t, func() bool {
kv, err = js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: "rep", History: 3, Replicas: n, TTL: time.Hour})
attemptCtx, cancel := context.WithTimeout(ctx, 250*time.Millisecond)
defer cancel()
kv, err = js.CreateKeyValue(attemptCtx, jetstream.KeyValueConfig{Bucket: "rep", History: 3, Replicas: n, TTL: time.Hour})
return err == nil
}, 30*time.Second, 250*time.Millisecond, "clustered KV create: %v", err)
}, 30*time.Second, 20*time.Millisecond, "clustered KV create: %v", err)
for i := range 5 {
_, err := kv.PutString(ctx, fmt.Sprintf("k%d", i%2), fmt.Sprintf("v%d", i))
require.NoError(t, err)
@@ -279,10 +283,12 @@ func TestKVDigestIgnoresExpiry(t *testing.T) {
require.NoError(t, err)
js, err := jetstream.New(nc)
require.NoError(t, err)
kv, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: "ttl", TTL: time.Second})
const ttl = 200 * time.Millisecond
kv, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: "ttl", TTL: ttl})
require.NoError(t, err)
_, err = kv.PutString(ctx, "k", "v")
require.NoError(t, err)
putAt := time.Now()
work := t.TempDir()
_, metas, err := copyStreamTree(filepath.Join(parent, "jetstream"), filepath.Join(work, "jetstream"))
@@ -291,7 +297,9 @@ func TestKVDigestIgnoresExpiry(t *testing.T) {
ns.Shutdown()
ns.WaitForShutdown()
time.Sleep(1500 * time.Millisecond)
// The server stamped the message before the put returned, so it is now
// older than the TTL.
time.Sleep(time.Until(putAt.Add(ttl + 50*time.Millisecond)))
d, err := digestStore(ctx, work, nil, false, metas)
require.NoError(t, err)
require.Len(t, d, 1)
+39
View File
@@ -0,0 +1,39 @@
//test:in-package — validateNodeCount is an unexported guard on this command's
// flags, and exporting it would widen the package's API for a test's convenience.
package cmd
import (
"strings"
"testing"
)
// TestValidateNodeCount pins the supported cluster sizes. Two is rejected rather
// than warned about: on two servers no layer of the stack has a majority to lose,
// and the control plane's own state has no replica count that both survives a node
// going away and still accepts writes.
func TestValidateNodeCount(t *testing.T) {
for _, nodes := range []int{1, 3, 4, 5, 9} {
if err := validateNodeCount(nodes); err != nil {
t.Errorf("validateNodeCount(%d) = %v, want nil", nodes, err)
}
}
for _, nodes := range []int{0, -1} {
if err := validateNodeCount(nodes); err == nil {
t.Errorf("validateNodeCount(%d) returned no error", nodes)
}
}
err := validateNodeCount(2)
if err == nil {
t.Fatal("validateNodeCount(2) returned no error")
}
// The message has to say what to do instead, because the operator typed a
// number and needs to know which one to type next.
for _, want := range []string{"2", "1", "3"} {
if !strings.Contains(err.Error(), want) {
t.Errorf("error %q does not mention %s", err, want)
}
}
}
+9 -13
View File
@@ -438,14 +438,10 @@ func parseWeightsS3URI(uri string) (bucket, prefix string, err error) {
if trimmed == uri {
return "", "", fmt.Errorf("invalid --s3-uri %q: expected s3://bucket/prefix", uri)
}
parts := strings.SplitN(trimmed, "/", 2)
bucket = parts[0]
bucket, prefix, _ = strings.Cut(trimmed, "/")
if bucket == "" {
return "", "", fmt.Errorf("invalid --s3-uri %q: missing bucket", uri)
}
if len(parts) == 2 {
prefix = parts[1]
}
if prefix != "" && !strings.HasSuffix(prefix, "/") {
prefix += "/"
}
@@ -854,7 +850,7 @@ func runOchreWeightsStage(cmd *cobra.Command, _ []string) {
ochreExit(1)
return
}
weightsStore := gateway_bedrock.NewWeightsStore(js, len(appConfig.Nodes))
weightsStore := gateway_bedrock.NewWeightsStore(js)
store := objectstore.NewS3ObjectStoreFromConfig(node.Predastore.Host, node.Predastore.Region, node.Predastore.AccessKey, node.Predastore.SecretKey)
provider := ebsprovider.NewNATSProvider(nc, imageImportTimeout)
@@ -930,7 +926,7 @@ func runOchreWeightsList(_ *cobra.Command, _ []string) {
ochreExit(1)
return
}
weightsStore := gateway_bedrock.NewWeightsStore(js, len(appConfig.Nodes))
weightsStore := gateway_bedrock.NewWeightsStore(js)
store := objectstore.NewS3ObjectStoreFromConfig(node.Predastore.Host, node.Predastore.Region, node.Predastore.AccessKey, node.Predastore.SecretKey)
checkSnapshotLive := newWeightsSnapshotChecker(store, node.Predastore.Bucket)
@@ -967,7 +963,7 @@ func removeWeights(ctx context.Context, weightsStore *gateway_bedrock.WeightsSto
func runOchreWeightsRemove(cmd *cobra.Command, _ []string) {
modelID, _ := cmd.Flags().GetString("model-id")
appConfig, nc, err := loadConfigAndConnectFn()
_, nc, err := loadConfigAndConnectFn()
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
ochreExit(1)
@@ -981,7 +977,7 @@ func runOchreWeightsRemove(cmd *cobra.Command, _ []string) {
ochreExit(1)
return
}
weightsStore := gateway_bedrock.NewWeightsStore(js, len(appConfig.Nodes))
weightsStore := gateway_bedrock.NewWeightsStore(js)
entry, err := removeWeights(context.Background(), weightsStore, modelID)
if err != nil {
@@ -1014,7 +1010,7 @@ func resolveHFToken(ctx context.Context, cmd *cobra.Command, cfg *config.Cluster
if err != nil {
return ""
}
credStore := gateway_bedrock.NewCredentialStore(js, masterKey, len(cfg.Nodes), nil)
credStore := gateway_bedrock.NewCredentialStore(js, masterKey, nil)
token, ok, err := credStore.Resolve(ctx, utils.GlobalAccountID, vendorHuggingFace)
if err != nil || !ok {
return ""
@@ -1088,7 +1084,7 @@ func ochreCredentialsStore() (*gateway_bedrock.CredentialStore, func(), error) {
nc.Close()
return nil, nil, fmt.Errorf("jetstream context: %w", err)
}
return gateway_bedrock.NewCredentialStore(js, masterKey, len(cfg.Nodes), nil), func() { nc.Close() }, nil
return gateway_bedrock.NewCredentialStore(js, masterKey, nil), func() { nc.Close() }, nil
}
func runOchreCredentialsSet(cmd *cobra.Command, _ []string) {
@@ -1186,7 +1182,7 @@ func init() {
// ochreAccessStore connects to the cluster and returns the grant store along
// with a cleanup that closes the connection.
func ochreAccessStore() (*gateway_bedrock.ModelAccessStore, func(), error) {
cfg, nc, err := loadConfigAndConnectFn()
_, nc, err := loadConfigAndConnectFn()
if err != nil {
return nil, nil, fmt.Errorf("connect to cluster: %w", err)
}
@@ -1195,7 +1191,7 @@ func ochreAccessStore() (*gateway_bedrock.ModelAccessStore, func(), error) {
nc.Close()
return nil, nil, fmt.Errorf("jetstream context: %w", err)
}
return gateway_bedrock.NewModelAccessStore(js, len(cfg.Nodes)), func() { nc.Close() }, nil
return gateway_bedrock.NewModelAccessStore(js), func() { nc.Close() }, nil
}
// ochreTargetModels resolves the --model-id / --all-models pair into the model
+2 -2
View File
@@ -470,7 +470,7 @@ func TestResolveHFToken_FallsBackToStoredPlatformCredential(t *testing.T) {
require.NoError(t, os.MkdirAll(filepath.Join(baseDir, "config"), 0750))
require.NoError(t, handlers_iam.SaveMasterKey(filepath.Join(baseDir, "config", "master.key"), masterKey))
credStore := gateway_bedrock.NewCredentialStore(js, masterKey, 1, nil)
credStore := gateway_bedrock.NewCredentialStore(js, masterKey, nil)
require.NoError(t, credStore.PutCredential(context.Background(), utils.GlobalAccountID, vendorHuggingFace, "hf_stored_token"))
cmd := newOchreWeightsPullTestCmd(t, selfHostModelID, testHFRepo, "main", "", "")
@@ -621,7 +621,7 @@ func TestRunOchreCredentialsSet_RoundTripsAndDefaultsToPlatformAccount(t *testin
defer verifyConn.Close()
verifyJS := testutil.NewJetStream(t, verifyConn)
credStore := gateway_bedrock.NewCredentialStore(verifyJS, masterKey, 1, nil)
credStore := gateway_bedrock.NewCredentialStore(verifyJS, masterKey, nil)
token, ok, err := credStore.Resolve(context.Background(), utils.GlobalAccountID, vendorHuggingFace)
require.NoError(t, err)
require.True(t, ok)
+4 -4
View File
@@ -42,7 +42,7 @@ const (
func newWeightsStoreForTest(t *testing.T) *gateway_bedrock.WeightsStore {
t.Helper()
_, nc, _ := testutil.StartTestJetStream(t)
return gateway_bedrock.NewWeightsStore(testutil.NewJetStream(t, nc), 1)
return gateway_bedrock.NewWeightsStore(testutil.NewJetStream(t, nc))
}
// explodingObjectStore fails the test immediately if any of its methods is
@@ -775,7 +775,7 @@ func TestRunOchreWeightsList_PrintsStagedModels(t *testing.T) {
}
stubConnect(t, cfg, nc, nil)
seedStore := gateway_bedrock.NewWeightsStore(js, 1)
seedStore := gateway_bedrock.NewWeightsStore(js)
require.NoError(t, seedStore.PutWeights(context.Background(), selfHostModelID, "s3://models/llama-3.2-1b/", "snap-0001"))
out := captureStdout(t, func() { runOchreWeightsList(nil, nil) })
@@ -822,7 +822,7 @@ func TestRunOchreWeightsRemove_RoundTripPrintsSnapshotAndDropsEntry(t *testing.T
ns, nc, js := testutil.StartTestJetStream(t)
stubConnect(t, fakeClusterConfig(), nc, nil)
seedStore := gateway_bedrock.NewWeightsStore(js, 1)
seedStore := gateway_bedrock.NewWeightsStore(js)
require.NoError(t, seedStore.PutWeights(context.Background(), selfHostModelID, "s3://models/llama-3.2-1b/", "snap-0001"))
cmd := newOchreWeightsRemoveTestCmd(t, selfHostModelID)
@@ -835,7 +835,7 @@ func TestRunOchreWeightsRemove_RoundTripPrintsSnapshotAndDropsEntry(t *testing.T
verifyNC, err := nats.Connect(ns.ClientURL())
require.NoError(t, err)
t.Cleanup(func() { verifyNC.Close() })
verifyStore := gateway_bedrock.NewWeightsStore(testutil.NewJetStream(t, verifyNC), 1)
verifyStore := gateway_bedrock.NewWeightsStore(testutil.NewJetStream(t, verifyNC))
_, ok, err := verifyStore.GetWeights(context.Background(), selfHostModelID)
require.NoError(t, err)
+1 -4
View File
@@ -283,11 +283,9 @@ func truncateForTable(s string, maxRunes int) string {
}
// formatQueryResults renders 'ochre vector query' output: a truncated table
// by default, or the full response as indented JSON when asJSON is set
// (D10's "full text via source ref" -- here, via --json).
// by default, or the full response as indented JSON when asJSON is set.
func formatQueryResults(results []handlers_ochrevector.QueryResult, asJSON bool) (string, error) {
if asJSON {
//nolint:musttag // QueryResult is an internal, non-wire Go type (predates this CLI); it round-trips fine on its default field names.
data, err := json.MarshalIndent(results, "", " ")
if err != nil {
return "", fmt.Errorf("encode results as JSON: %w", err)
@@ -317,7 +315,6 @@ func parseFilterFlag(raw string) (*handlers_ochrevector.Filter, error) {
return nil, nil
}
var f handlers_ochrevector.Filter
//nolint:musttag // Filter is the existing filter-AST type (D9); it round-trips fine on its default field names.
if err := json.Unmarshal([]byte(raw), &f); err != nil {
return nil, fmt.Errorf("decode --filter: %w", err)
}
+12 -4
View File
@@ -188,13 +188,14 @@ func describePrincipal(svc handlers_iam.IAMService, accountID, userName string)
}
// principalPolicyActions returns the actions an inline policy allows, shortened
// to the method name. An undecodable document reports nothing rather than
// guessing, and the listing shows the user with no grants.
// to the method name. A NotAction grant is one entry naming what it excludes.
// An undecodable document reports nothing rather than guessing.
func principalPolicyActions(document string) []string {
var doc struct {
Statement []struct {
Effect string `json:"Effect"`
Action handlers_iam.StringOrArr `json:"Action"`
Effect string `json:"Effect"`
Action handlers_iam.StringOrArr `json:"Action"`
NotAction handlers_iam.StringOrArr `json:"NotAction"`
} `json:"Statement"`
}
if err := json.Unmarshal([]byte(document), &doc); err != nil {
@@ -209,6 +210,13 @@ func principalPolicyActions(document string) []string {
for _, action := range stmt.Action {
actions = append(actions, strings.TrimPrefix(action, adminPrincipalService+":"))
}
if len(stmt.NotAction) > 0 {
excluded := make([]string, 0, len(stmt.NotAction))
for _, action := range stmt.NotAction {
excluded = append(excluded, strings.TrimPrefix(action, adminPrincipalService+":"))
}
actions = append(actions, "NotAction("+strings.Join(excluded, ", ")+")")
}
}
sort.Strings(actions)
return actions
+12
View File
@@ -102,6 +102,18 @@ func TestPrincipalPolicyActionsRoundTrip(t *testing.T) {
cmd.PrincipalPolicyActions(cmd.PrincipalPolicyDocument(grants)))
}
// A NotAction grant covers everything but its list, so rendering only Action
// would show the principal as holding nothing.
func TestPrincipalPolicyActionsRenderNotAction(t *testing.T) {
document := `{"Version":"2012-10-17","Statement":[
{"Effect":"Allow","NotAction":["spinifex:DeleteAccount","spinifex:CreateAccount"],"Resource":"*"},
{"Effect":"Allow","Action":"spinifex:ListAccounts","Resource":"*"},
{"Effect":"Deny","NotAction":"spinifex:ListAccounts","Resource":"*"}]}`
assert.Equal(t, []string{"ListAccounts", "NotAction(DeleteAccount, CreateAccount)"},
cmd.PrincipalPolicyActions(document))
}
// A document nobody can parse is reported as no grants rather than as a guess.
func TestPrincipalPolicyActionsIgnoreAnUndecodableDocument(t *testing.T) {
assert.Empty(t, cmd.PrincipalPolicyActions("{not json"))
+242 -6
View File
@@ -2,6 +2,8 @@ package cmd
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"net/netip"
@@ -9,11 +11,13 @@ import (
"path/filepath"
"strings"
"testing"
"time"
"github.com/mulgadc/northstar/pkg/backend"
nsconfig "github.com/mulgadc/northstar/pkg/config"
"github.com/mulgadc/spinifex/spinifex/admin"
"github.com/mulgadc/spinifex/spinifex/config"
"github.com/mulgadc/spinifex/spinifex/ebsmetadata"
"github.com/mulgadc/spinifex/spinifex/formation"
handlers_dns "github.com/mulgadc/spinifex/spinifex/handlers/dns"
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
@@ -734,21 +738,45 @@ func TestSpinifexTomlTemplate_ExternalPoolStaticSource(t *testing.T) {
assert.Contains(t, content, `gateway = "192.168.1.1"`)
}
// Formation joiners must receive PoolBindBridge from the init node so the
// Formation joiners must receive BindBridge from the init node so the
// cluster-wide config reaches every chassis intact.
func TestApplyNetworkConfig_PropagatesPoolBindBridge(t *testing.T) {
settings := &admin.ConfigSettings{}
nc := &formation.NetworkConfig{
ExternalMode: "pool",
PoolName: "wan",
PoolSource: "dhcp",
PoolBindBridge: "br-wan",
PoolPrefixLen: 24,
ExternalMode: "pool",
Pools: []admin.PoolData{{
Name: "wan", Source: "dhcp", BindBridge: "br-wan", PrefixLen: 24,
}},
}
applyNetworkConfig(settings, nc)
require.Len(t, settings.Pools, 1)
assert.Equal(t, "dhcp", settings.Pools[0].Source)
assert.Equal(t, "br-wan", settings.Pools[0].BindBridge)
assert.Empty(t, settings.BridgeMode, "bridged modes stay auto-detected, only nat is pinned")
}
// Routed NAT renders two pools, and the transit one carries a gateway-LRP range
// that pool mode has no equivalent for. A joiner that received only the first,
// or lost the range, would allocate VPC gateway addresses from nowhere.
func TestApplyNetworkConfig_PropagatesEveryPool(t *testing.T) {
settings := &admin.ConfigSettings{}
nc := &formation.NetworkConfig{
ExternalMode: "nat",
Pools: []admin.PoolData{
{Name: "nat-transit", Gateway: "100.127.0.1", PrefixLen: 24,
GwLrpRangeStart: "100.127.0.16", GwLrpRangeEnd: "100.127.0.254"},
{Name: "wan", Source: "static", Start: "192.168.1.150", End: "192.168.1.200",
Gateway: "192.168.1.1", PrefixLen: 24},
},
}
applyNetworkConfig(settings, nc)
require.Len(t, settings.Pools, 2)
assert.Equal(t, "nat", settings.BridgeMode,
"a joiner that loses bridge_mode falls back to autodetection for a mode init pinned")
assert.Equal(t, "nat-transit", settings.Pools[0].Name)
assert.Equal(t, "100.127.0.16", settings.Pools[0].GwLrpRangeStart)
assert.Equal(t, "100.127.0.254", settings.Pools[0].GwLrpRangeEnd)
assert.Equal(t, "wan", settings.Pools[1].Name)
}
func renderSingleNodePredastore(t *testing.T, settings admin.ConfigSettings) string {
@@ -1288,6 +1316,214 @@ func TestAMIVolumeSizeGiB(t *testing.T) {
}
}
func TestValidateChecksumFlags(t *testing.T) {
tests := []struct {
name string
checksum string
unset bool
file string
skipVerify bool
wantErr string
}{
{name: "no checksum", unset: true, file: "img.raw"},
{name: "checksum with file", checksum: "SHA256SUMS", file: "img.raw"},
{name: "empty checksum value", checksum: "", file: "img.raw", wantErr: "--checksum requires a sums file path"},
{name: "blank checksum value", checksum: " ", file: "img.raw", wantErr: "--checksum requires a sums file path"},
{name: "checksum without file", checksum: "SHA256SUMS", wantErr: "--checksum requires --file"},
{name: "checksum with skip-verify", checksum: "SHA256SUMS", file: "img.raw", skipVerify: true,
wantErr: "mutually exclusive"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateChecksumFlags(tt.checksum, !tt.unset, tt.file, tt.skipVerify)
if tt.wantErr == "" {
assert.NoError(t, err)
return
}
require.Error(t, err)
assert.Contains(t, err.Error(), tt.wantErr)
})
}
}
func TestSourceDigestMode(t *testing.T) {
tests := []struct {
name string
localFile string
checksum string
skipVerify bool
want string
}{
{name: "file with checksum", localFile: "img.raw", checksum: "SHA256SUMS", want: ebsmetadata.DigestOperator},
{name: "catalog download verified", want: ebsmetadata.DigestCatalog},
{name: "catalog download skip-verify", skipVerify: true, want: ebsmetadata.DigestUnverified},
{name: "file without checksum", localFile: "img.raw", want: ebsmetadata.DigestUnverified},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.Equal(t, tt.want, sourceDigestMode(tt.localFile, tt.checksum, tt.skipVerify))
})
}
}
func TestImportSourceResolveDigest(t *testing.T) {
imgBytes := []byte("import-source-fixture")
sum := sha256.Sum256(imgBytes)
imgHex := hex.EncodeToString(sum[:])
dir := t.TempDir()
img := filepath.Join(dir, "image.raw")
require.NoError(t, os.WriteFile(img, imgBytes, 0o600))
sums := filepath.Join(dir, "SHA256SUMS")
t.Run("operator verified", func(t *testing.T) {
var out, errOut bytes.Buffer
got, err := importSource{
imageFile: img, localFile: img,
checksumPath: sums, checksumAlgo: "sha256", expectedDigest: imgHex,
}.resolveDigest(&out, &errOut)
require.NoError(t, err)
assert.Equal(t, ebsmetadata.ImageDigest{
Algorithm: "sha256", Value: imgHex, Verification: ebsmetadata.DigestOperator,
Source: "SHA256SUMS", Filename: "image.raw",
}, got)
assert.Contains(t, out.String(), "Verified image checksum (sha256)")
assert.Empty(t, errOut.String())
})
t.Run("operator mismatch names the file and sums file", func(t *testing.T) {
var out, errOut bytes.Buffer
_, err := importSource{
imageFile: img, localFile: img,
checksumPath: sums, checksumAlgo: "sha256", expectedDigest: strings.Repeat("0", 64),
}.resolveDigest(&out, &errOut)
require.ErrorIs(t, err, utils.ErrChecksumMismatch)
assert.Contains(t, errOut.String(), "file: "+img)
assert.Contains(t, errOut.String(), "checksum: "+sums)
assert.Contains(t, errOut.String(), imgHex)
assert.FileExists(t, img, "the operator's file must be left in place")
})
t.Run("local file without checksum is recorded unverified", func(t *testing.T) {
var out, errOut bytes.Buffer
got, err := importSource{imageFile: img, localFile: img}.resolveDigest(&out, &errOut)
require.NoError(t, err)
assert.Equal(t, ebsmetadata.ImageDigest{
Algorithm: "sha256", Value: imgHex, Verification: ebsmetadata.DigestUnverified, Filename: "image.raw",
}, got)
assert.Equal(t, "Importing "+img+" without checksum validation (pass --checksum <sums-file> to verify)\n",
errOut.String())
})
t.Run("catalog skip-verify is recorded unverified", func(t *testing.T) {
var out, errOut bytes.Buffer
got, err := importSource{imageFile: img, imageName: "debian-13-x86_64", skipVerify: true}.resolveDigest(&out, &errOut)
require.NoError(t, err)
assert.Equal(t, ebsmetadata.DigestUnverified, got.Verification)
assert.Equal(t, imgHex, got.Value)
assert.Contains(t, errOut.String(), "--skip-verify set")
})
t.Run("catalog entry without checksum is refused", func(t *testing.T) {
var out, errOut bytes.Buffer
_, err := importSource{imageFile: img, imageName: "debian-13-x86_64"}.resolveDigest(&out, &errOut)
require.Error(t, err)
assert.Contains(t, errOut.String(), "missing Checksum/ChecksumDigest/ChecksumType")
})
// Oracle publishes its digests inline on a web page and ships no sums file,
// so a pinned digest is the only way its entries can import verified.
t.Run("catalog pinned digest verifies without fetching a sums file", func(t *testing.T) {
var out, errOut bytes.Buffer
got, err := importSource{
imageFile: img, imageName: "oracle-10.1-x86_64",
image: utils.Images{ChecksumType: "sha256", ChecksumDigest: imgHex},
}.resolveDigest(&out, &errOut)
require.NoError(t, err)
assert.Equal(t, ebsmetadata.ImageDigest{
Algorithm: "sha256", Value: imgHex, Source: catalogPinnedDigestSource,
Verification: ebsmetadata.DigestCatalog, Filename: "image.raw",
}, got)
assert.Contains(t, out.String(), "pinned catalog digest")
})
t.Run("catalog pinned digest mismatch is refused and names the url", func(t *testing.T) {
var out, errOut bytes.Buffer
wrong := strings.Repeat("a", 64)
_, err := importSource{
imageFile: img, imageName: "oracle-10.1-x86_64",
image: utils.Images{ChecksumType: "sha256", ChecksumDigest: wrong, URL: "https://example.invalid/ol.qcow2"},
}.resolveDigest(&out, &errOut)
require.ErrorIs(t, err, utils.ErrChecksumMismatch)
assert.Contains(t, errOut.String(), catalogPinnedDigestSource)
assert.Contains(t, errOut.String(), "https://example.invalid/ol.qcow2")
assert.FileExists(t, img, "the downloaded file must be left in place")
})
t.Run("unreadable image fails the unverified hash", func(t *testing.T) {
var out, errOut bytes.Buffer
missing := filepath.Join(dir, "missing.raw")
_, err := importSource{imageFile: missing, localFile: missing}.resolveDigest(&out, &errOut)
require.Error(t, err)
assert.Contains(t, errOut.String(), "Could not hash image")
})
}
func TestImagesDescribeCmd_FlagSchema(t *testing.T) {
imageIDFlag := imagesDescribeCmd.Flags().Lookup("image-id")
require.NotNil(t, imageIDFlag, "--image-id must be defined")
assert.Equal(t, []string{"true"}, imageIDFlag.Annotations[cobraRequiredAnnotation],
"--image-id must be marked required")
}
func TestPrintAMIDescription(t *testing.T) {
base := ebsmetadata.AMI{
ImageID: "ami-1",
Name: "ami-debian-13-x86_64",
ImageOwnerAlias: "system",
CreationDate: time.Date(2026, 9, 23, 1, 2, 3, 0, time.UTC),
BootMode: "uefi",
Architecture: "x86_64",
}
t.Run("digest recorded", func(t *testing.T) {
meta := base
meta.SourceDigest = &ebsmetadata.ImageDigest{
Algorithm: "sha512", Value: "abc123", Verification: ebsmetadata.DigestOperator,
Source: "SHA512SUMS", Filename: "debian-13-generic-amd64.tar.xz",
}
var buf bytes.Buffer
printAMIDescription(&buf, meta)
out := buf.String()
assert.Contains(t, out, "Image ID: ami-1")
assert.Contains(t, out, "Created: 2026-09-23T01:02:03Z")
assert.Contains(t, out, "Boot mode: uefi")
assert.Contains(t, out, "Source digest: sha512:abc123")
assert.Contains(t, out, "Verification: operator (SHA512SUMS)")
assert.Contains(t, out, "Source file: debian-13-generic-amd64.tar.xz")
})
t.Run("unverified digest has no source", func(t *testing.T) {
meta := base
meta.SourceDigest = &ebsmetadata.ImageDigest{
Algorithm: "sha256", Value: "def456", Verification: ebsmetadata.DigestUnverified,
Filename: "image.raw",
}
var buf bytes.Buffer
printAMIDescription(&buf, meta)
assert.Contains(t, buf.String(), "Verification: unverified\n")
})
t.Run("digest not recorded", func(t *testing.T) {
var buf bytes.Buffer
printAMIDescription(&buf, base)
out := buf.String()
assert.Contains(t, out, "Source digest: not recorded (imported before digest recording)")
assert.NotContains(t, out, "Verification:")
})
}
func TestCheckJoinPreconditions(t *testing.T) {
withFakeProc(t, nil)
noStore := filepath.Join(t.TempDir(), "nats", "jetstream")
+1 -1
View File
@@ -167,7 +167,7 @@ func runClusterShutdown(cmd *cobra.Command, args []string) {
fmt.Printf("Timeout per phase: %s\n\n", timeout)
// Write cluster shutdown marker to KV
jsm, err := daemon.NewJetStreamManager(nc, 1)
jsm, err := daemon.NewJetStreamManager(nc)
if err != nil {
fmt.Fprintf(os.Stderr, "Warning: failed to create JetStream manager: %v\n", err)
} else {
+8 -5
View File
@@ -35,26 +35,29 @@ func fakeDrainResponder(t *testing.T, nc *nats.Conn, released int, errMsg string
}
func TestDrainDHCPLeasesSumsReleased(t *testing.T) {
t.Parallel()
_, nc, _ := testutil.StartTestJetStream(t)
fakeDrainResponder(t, nc, 7, "")
released, responders := drainDHCPLeases(nc, 2*time.Second)
released, responders := drainDHCPLeases(nc, 250*time.Millisecond)
assert.Equal(t, 7, released)
assert.Equal(t, 1, responders)
}
func TestDrainDHCPLeasesReportsResponderError(t *testing.T) {
t.Parallel()
_, nc, _ := testutil.StartTestJetStream(t)
fakeDrainResponder(t, nc, 0, "list leases for drain: boom")
// An erroring responder still counts as a responder but contributes
// zero released leases.
released, responders := drainDHCPLeases(nc, 2*time.Second)
released, responders := drainDHCPLeases(nc, 250*time.Millisecond)
assert.Equal(t, 0, released)
assert.Equal(t, 1, responders)
}
func TestDrainDHCPLeasesNoResponders(t *testing.T) {
t.Parallel()
_, nc, _ := testutil.StartTestJetStream(t)
// No vpcd subscribed: the collection window elapses with zero replies.
@@ -281,7 +284,7 @@ func captureSlog(t *testing.T, fn func()) string {
// at WARN level, and names only the running instance, not the stopped one.
func TestWarnIfGuestsLeftRunningNamesRunningGuests(t *testing.T) {
origTimeout := guestEnumerationTimeout
guestEnumerationTimeout = 200 * time.Millisecond
guestEnumerationTimeout = 50 * time.Millisecond
t.Cleanup(func() { guestEnumerationTimeout = origTimeout })
_, nc, _ := testutil.StartTestJetStream(t)
@@ -312,7 +315,7 @@ func TestWarnIfGuestsLeftRunningNamesRunningGuests(t *testing.T) {
// running guests produces no warning.
func TestWarnIfGuestsLeftRunningNoGuestsIsSilent(t *testing.T) {
origTimeout := guestEnumerationTimeout
guestEnumerationTimeout = 200 * time.Millisecond
guestEnumerationTimeout = 50 * time.Millisecond
t.Cleanup(func() { guestEnumerationTimeout = origTimeout })
_, nc, _ := testutil.StartTestJetStream(t)
@@ -510,7 +513,7 @@ func TestCollectLocalShutdownACKExhaustedReturnsWithinBudget(t *testing.T) {
// must log at ERROR, not WARN, and still name only the running instance.
func TestReportGuestsLeftRunningSevereLogsErrorAndNamesGuests(t *testing.T) {
origTimeout := guestEnumerationTimeout
guestEnumerationTimeout = 200 * time.Millisecond
guestEnumerationTimeout = 50 * time.Millisecond
t.Cleanup(func() { guestEnumerationTimeout = origTimeout })
_, nc, _ := testutil.StartTestJetStream(t)
+7
View File
@@ -29,6 +29,13 @@ func SetFirewallApplyHelper(path string) func() {
// InitCLILogger exposes initCLILogger for testing.
var InitCLILogger = initCLILogger
// OutputStylingEnabled, IsTerminal and SetOutputStyling expose the spx colour guard.
var (
OutputStylingEnabled = outputStylingEnabled
IsTerminal = isTerminal
SetOutputStyling = setOutputStyling
)
// CLILogLevel exposes the CLI logger's level var for testing.
var CLILogLevel = cliLogLevel
+44
View File
@@ -0,0 +1,44 @@
//test:in-package — the embedded nats.conf template it checks is unexported,
// and exporting it would widen the package's API for a test's convenience.
package cmd
import (
"strings"
"testing"
natssvc "github.com/mulgadc/spinifex/spinifex/services/nats"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// TestNATSConfServerNameMatchesTheHelper ties the rendered server_name to
// nats.ServerName. JetStream reports stream peers by server name, so anything
// mapping a replica back to the node holding it reads the prefix from that
// helper — and a template that drifted from it would break the mapping silently,
// reporting a healthy cluster as one whose buckets name no node at all.
func TestNATSConfServerNameMatchesTheHelper(t *testing.T) {
var line string
for l := range strings.SplitSeq(natsConfTemplate, "\n") {
if strings.HasPrefix(strings.TrimSpace(l), "server_name:") {
line = strings.TrimSpace(l)
break
}
}
require.NotEmpty(t, line, "nats.conf no longer sets server_name")
got := strings.TrimSpace(strings.TrimPrefix(line, "server_name:"))
assert.Equal(t, natssvc.ServerName("{{.Node}}"), got)
node, ok := natssvc.NodeFromServerName(natssvc.ServerName("node2"))
assert.True(t, ok)
assert.Equal(t, "node2", node)
_, ok = natssvc.NodeFromServerName("node2")
assert.False(t, ok, "a bare node name is not a server name")
// The bare prefix is a name we could not have generated, and mapping it to the
// empty node would put a phantom peer into anything counting nodes.
_, ok = natssvc.NodeFromServerName(natssvc.ServerNamePrefix)
assert.False(t, ok, "the prefix with no node after it is not a server name")
}
+29
View File
@@ -1,13 +1,18 @@
// Package cmd defines the spx command-line interface: the cobra commands that
// install, configure, start and administer a Spinifex node and its cluster.
package cmd
import (
"fmt"
"io"
"log/slog"
"os"
"github.com/mulgadc/spinifex/spinifex/config"
"github.com/pterm/pterm"
"github.com/spf13/cobra"
"github.com/spf13/viper"
"golang.org/x/term"
)
var (
@@ -50,6 +55,8 @@ func Execute() {
cliLogLevel.Set(slog.LevelError)
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: cliLogLevel})))
setOutputStyling(outputStylingEnabled(os.Getenv("NO_COLOR"), os.Getenv("TERM"), isTerminal(os.Stdout)))
err := rootCmd.Execute()
if err != nil {
fmt.Fprintln(os.Stderr, err)
@@ -57,6 +64,28 @@ func Execute() {
}
}
// outputStylingEnabled reports whether spx may emit ANSI colour and text styling:
// only on a terminal that is not TERM=dumb, with NO_COLOR unset or empty.
func outputStylingEnabled(noColor, termName string, stdoutIsTerminal bool) bool {
return noColor == "" && termName != "dumb" && stdoutIsTerminal
}
// isTerminal reports whether w is a file attached to a terminal.
func isTerminal(w io.Writer) bool {
f, ok := w.(*os.File)
return ok && term.IsTerminal(int(f.Fd()))
}
// setOutputStyling switches pterm's colour and bold/underline styling for every
// printer at once. pterm forces colour on at init regardless of the terminal.
func setOutputStyling(enabled bool) {
if enabled {
pterm.EnableStyling()
} else {
pterm.DisableStyling()
}
}
// initCLILogger retunes the CLI logger from SPX_LOG_LEVEL, then --verbose so an
// explicit flag wins over the environment. There is no root --debug flag: four
// service subcommands already define one with a service-specific meaning, and a
+149
View File
@@ -0,0 +1,149 @@
package cmd_test
import (
"bytes"
"io"
"os"
"strings"
"testing"
"github.com/mulgadc/spinifex/cmd/spinifex/cmd"
"github.com/pterm/pterm"
)
func TestOutputStylingEnabled(t *testing.T) {
tests := []struct {
name string
noColor string
term string
terminal bool
want bool
}{
{name: "terminal keeps colour", term: "xterm-256color", terminal: true, want: true},
{name: "unset TERM on a terminal keeps colour", terminal: true, want: true},
{name: "TERM=dumb drops colour on a terminal", term: "dumb", terminal: true, want: false},
{name: "pipe drops colour", terminal: false, want: false},
{name: "NO_COLOR drops colour on a terminal", noColor: "1", terminal: true, want: false},
{name: "any non-empty NO_COLOR counts, even 0", noColor: "0", terminal: true, want: false},
{name: "NO_COLOR on a pipe", noColor: "1", terminal: false, want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := cmd.OutputStylingEnabled(tt.noColor, tt.term, tt.terminal); got != tt.want {
t.Fatalf("OutputStylingEnabled(%q, %q, %v) = %v, want %v", tt.noColor, tt.term, tt.terminal, got, tt.want)
}
})
}
}
func TestIsTerminalFalseForNonTTYWriters(t *testing.T) {
r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
defer r.Close()
defer w.Close()
f, err := os.CreateTemp(t.TempDir(), "out")
if err != nil {
t.Fatal(err)
}
defer f.Close()
for name, wr := range map[string]io.Writer{"pipe": w, "regular file": f, "buffer": &bytes.Buffer{}} {
if cmd.IsTerminal(wr) {
t.Errorf("IsTerminal(%s) = true, want false", name)
}
}
}
// renderNodesTable renders a table shaped like `spx get nodes` through the same
// pterm.DefaultTable the CLI uses, after applying the styling decision.
func renderNodesTable(t *testing.T, noColor, termName string, terminal bool) string {
t.Helper()
t.Cleanup(func() { cmd.SetOutputStyling(true) })
cmd.SetOutputStyling(cmd.OutputStylingEnabled(noColor, termName, terminal))
var buf bytes.Buffer
data := pterm.TableData{
{"NAME", "STATUS", "IP"},
{"node1", "Ready", "10.0.0.1"},
{"node-long-name", "NotReady", "10.0.0.22"},
}
if err := pterm.DefaultTable.WithHasHeader().WithLeftAlignment().WithData(data).WithWriter(&buf).Render(); err != nil {
t.Fatalf("render: %v", err)
}
return buf.String()
}
func TestTableOutputPlainWhenNotTerminal(t *testing.T) {
r, w, err := os.Pipe()
if err != nil {
t.Fatal(err)
}
defer r.Close()
defer w.Close()
out := renderNodesTable(t, "", "xterm", cmd.IsTerminal(w))
assertPlainAlignedTable(t, out)
}
func TestTableOutputPlainWithNoColorOnTerminal(t *testing.T) {
out := renderNodesTable(t, "1", "xterm", true)
assertPlainAlignedTable(t, out)
}
func TestTableOutputPlainOnDumbTerminal(t *testing.T) {
out := renderNodesTable(t, "", "dumb", true)
assertPlainAlignedTable(t, out)
}
func TestTableOutputStyledOnTerminal(t *testing.T) {
out := renderNodesTable(t, "", "xterm", true)
if !strings.Contains(out, "\x1b[") {
t.Fatalf("table on a terminal carries no ANSI styling, so the plain-output tests prove nothing:\n%q", out)
}
}
func assertPlainAlignedTable(t *testing.T, out string) {
t.Helper()
if strings.ContainsRune(out, '\x1b') {
t.Fatalf("output contains an escape byte:\n%q", out)
}
lines := strings.Split(strings.TrimRight(out, "\n"), "\n")
var rows []string
for _, l := range lines {
if strings.Contains(l, "node") || strings.HasPrefix(l, "NAME") {
rows = append(rows, l)
}
}
if len(rows) != 3 {
t.Fatalf("want header and two node rows, got %d:\n%s", len(rows), out)
}
// Columns line up only if the width calculation saw the same bytes it printed.
statusCol := strings.Index(rows[0], "STATUS")
ipCol := strings.Index(rows[0], "IP")
for _, row := range rows[1:] {
fields := strings.Fields(row)
var status string
for _, f := range fields {
if strings.Contains(f, "Ready") {
status = f
}
}
if got := strings.Index(row, status); got != statusCol {
t.Errorf("STATUS column at %d in %q, header at %d", got, row, statusCol)
}
if got := strings.Index(row, "10.0.0."); got != ipCol {
t.Errorf("IP column at %d in %q, header at %d", got, row, ipCol)
}
}
// grep -w Ready must match: the byte before the word is not a word character.
ready := strings.Index(rows[1], "Ready")
if ready <= 0 || rows[1][ready-1] != ' ' {
t.Errorf("Ready is not preceded by a space in %q", rows[1])
}
}
+3 -341
View File
@@ -225,42 +225,6 @@ var predastoreStartCmd = &cobra.Command{
},
}
var predastoreStopCmd = &cobra.Command{
Use: "stop",
Short: "Stop the predastore service",
SilenceErrors: true,
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
fmt.Println("Stopping predastore service...")
// Only the pid directory matters here: Stop finds the running process
// through it, and start wrote the file there.
service, err := service.New("predastore", &predastore.Config{
BasePath: viper.GetString("predastore-base-path"),
})
if err != nil {
return fmt.Errorf("create predastore service: %w", err)
}
if err = service.Stop(); err != nil {
return fmt.Errorf("stop predastore service: %w", err)
}
fmt.Println("Predastore service stopped")
return nil
},
}
var predastoreStatusCmd = &cobra.Command{
Use: "status",
Short: "Get status of the predastore service",
Run: func(cmd *cobra.Command, args []string) {
// Add your status logic here
fmt.Println("Predastore service status: ...")
},
}
// Repeat for viperblock.
var viperblockStartCmd = &cobra.Command{
Use: "start",
@@ -413,37 +377,6 @@ var viperblockStartCmd = &cobra.Command{
},
}
var viperblockStopCmd = &cobra.Command{
Use: "stop",
Short: "Stop the viperblock service",
SilenceErrors: true,
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
fmt.Println("Stopping viperblock service...")
service, err := service.New("viperblock", &viperblockd.Config{})
if err != nil {
return fmt.Errorf("create viperblock service: %w", err)
}
if err = service.Stop(); err != nil {
return fmt.Errorf("stop viperblock service: %w", err)
}
fmt.Println("Viperblock service stopped")
return nil
},
}
var viperblockStatusCmd = &cobra.Command{
Use: "status",
Short: "Get status of the viperblock service",
Run: func(cmd *cobra.Command, args []string) {
fmt.Println("Viperblock service status: ...")
},
}
var qemunbdCmd = &cobra.Command{
Use: "qemunbd",
Short: "Manage the qemunbd service",
@@ -518,37 +451,6 @@ var qemunbdStartCmd = &cobra.Command{
},
}
var qemunbdStopCmd = &cobra.Command{
Use: "stop",
Short: "Stop the qemunbd service",
SilenceErrors: true,
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
fmt.Println("Stopping qemunbd service...")
service, err := service.New("qemunbd", &qemunbdd.Config{})
if err != nil {
return fmt.Errorf("create qemunbd service: %w", err)
}
if err = service.Stop(); err != nil {
return fmt.Errorf("stop qemunbd service: %w", err)
}
fmt.Println("qemunbd service stopped")
return nil
},
}
var qemunbdStatusCmd = &cobra.Command{
Use: "status",
Short: "Get status of the qemunbd service",
Run: func(cmd *cobra.Command, args []string) {
fmt.Println("qemunbd service status: ...")
},
}
// Repeat for nats.
var natsStartCmd = &cobra.Command{
Use: "start",
@@ -589,37 +491,6 @@ var natsStartCmd = &cobra.Command{
},
}
var natsStopCmd = &cobra.Command{
Use: "stop",
Short: "Stop the nats service",
SilenceErrors: true,
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
fmt.Println("Stopping nats service...")
service, err := service.New("nats", &nats.Config{})
if err != nil {
return fmt.Errorf("create nats service: %w", err)
}
if err = service.Stop(); err != nil {
return fmt.Errorf("stop nats service: %w", err)
}
fmt.Println("Nats service stopped")
return nil
},
}
var natsStatusCmd = &cobra.Command{
Use: "status",
Short: "Get status of the nats service",
Run: func(cmd *cobra.Command, args []string) {
fmt.Println("Nats service status: ...")
},
}
// Repeat for spinifex.
var spinifexStartCmd = &cobra.Command{
Use: "start",
@@ -682,37 +553,6 @@ var spinifexStartCmd = &cobra.Command{
},
}
var spinifexStopCmd = &cobra.Command{
Use: "stop",
Short: "Stop the spinifex service",
SilenceErrors: true,
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
fmt.Println("Stopping spinifex service...")
service, err := service.New("spinifex", &config.ClusterConfig{})
if err != nil {
return fmt.Errorf("create spinifex service: %w", err)
}
if err = service.Stop(); err != nil {
return fmt.Errorf("stop spinifex service: %w", err)
}
fmt.Println("Spinifex service stopped")
return nil
},
}
var spinifexStatusCmd = &cobra.Command{
Use: "status",
Short: "Get status of the spinifex service",
Run: func(cmd *cobra.Command, args []string) {
fmt.Println("Spinifex service status: ...")
},
}
// AWS GW
var awsgwStartCmd = &cobra.Command{
@@ -785,37 +625,6 @@ var awsgwStartCmd = &cobra.Command{
},
}
var awsgwStopCmd = &cobra.Command{
Use: "stop",
Short: "Stop the awsgw service",
SilenceErrors: true,
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
fmt.Println("Stopping awsgw service...")
service, err := service.New("awsgw", &config.ClusterConfig{})
if err != nil {
return fmt.Errorf("create awsgw service: %w", err)
}
if err = service.Stop(); err != nil {
return fmt.Errorf("stop awsgw service: %w", err)
}
fmt.Println("AWSGW service stopped")
return nil
},
}
var awsgwStatusCmd = &cobra.Command{
Use: "status",
Short: "Get status of the awsgw service",
Run: func(cmd *cobra.Command, args []string) {
fmt.Println("AWSGW service status: ...")
},
}
// consoleRegion is the region the console signs with. awsgw pins signature
// verification to this same node field, so reading it from anywhere else —
// including the cluster-wide [aws] region — would let the two drift and fail
@@ -876,50 +685,6 @@ var spinifexUIStartCmd = &cobra.Command{
},
}
var spinifexUIStopCmd = &cobra.Command{
Use: "stop",
Short: "Stop the spinifex-ui service",
SilenceErrors: true,
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
fmt.Println("Stopping spinifex-ui service...")
svc, err := service.New("spinifex-ui", &spinifexui.Config{})
if err != nil {
return fmt.Errorf("create spinifex-ui service: %w", err)
}
if err = svc.Stop(); err != nil {
return fmt.Errorf("stop spinifex-ui service: %w", err)
}
fmt.Println("spinifex-ui service stopped")
return nil
},
}
var spinifexUIStatusCmd = &cobra.Command{
Use: "status",
Short: "Get status of the spinifex-ui service",
SilenceErrors: true,
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
svc, err := service.New("spinifex-ui", &spinifexui.Config{})
if err != nil {
return fmt.Errorf("create spinifex-ui service: %w", err)
}
status, err := svc.Status()
if err != nil {
return fmt.Errorf("get spinifex-ui service status: %w", err)
}
fmt.Println("spinifex-ui service status:", status)
return nil
},
}
// checkLegacyWanBridgeKey fails vpcd startup if the deprecated `wan_bridge`
// TOML key or `SPINIFEX_VPCD_WAN_BRIDGE` env-var is present. There is no silent
// alias or auto-rewrite; the operator must remove the key before vpcd will start.
@@ -1025,12 +790,15 @@ var vpcdStartCmd = &cobra.Command{
AZ: nodeConfig.AZ,
NorthstarBaseDomain: handlers_dns.ResolveBaseDomain(&nodeConfig),
NorthstarInternalDomain: handlers_dns.ResolveInternalDomain(&nodeConfig),
ServicesDomain: clusterConfig.AWS.ServicesDomain,
ResolverNameservers: handlers_dns.ResolverNameserverIPs(clusterConfig),
NATExemptCIDRs: clusterConfig.Network.NATExemptCIDRs,
IPSecEnabled: clusterConfig.Network.IPSecEnabled,
UnderlayMTU: clusterConfig.Network.UnderlayMTU,
BlockedWANPorts: clusterConfig.Network.ResolvedBlockedWANPorts(),
EgressBlockExemptVPCs: clusterConfig.Network.EgressBlockExemptVPCs,
IMDSHostMetaIP: clusterConfig.Network.IMDSHostMetaIP,
IMDSHostDNSIP: clusterConfig.Network.IMDSHostDNSIP,
})
if err != nil {
return fmt.Errorf("create vpcd service: %w", err)
@@ -1044,35 +812,6 @@ var vpcdStartCmd = &cobra.Command{
},
}
var vpcdStopCmd = &cobra.Command{
Use: "stop",
Short: "Stop the vpcd service",
SilenceErrors: true,
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
fmt.Println("Stopping vpcd service...")
svc, err := service.New("vpcd", &vpcd.Config{})
if err != nil {
return fmt.Errorf("create vpcd service: %w", err)
}
if err = svc.Stop(); err != nil {
return fmt.Errorf("stop vpcd service: %w", err)
}
fmt.Println("vpcd service stopped")
return nil
},
}
var vpcdStatusCmd = &cobra.Command{
Use: "status",
Short: "Get status of the vpcd service",
Run: func(cmd *cobra.Command, args []string) {
fmt.Println("vpcd service status: ...")
},
}
var northstarCmd = &cobra.Command{
Use: "northstar",
Short: "Manage the northstar (DNS) service",
@@ -1195,35 +934,6 @@ func resolveNorthstarConfigPath(nodePath, override string) string {
return nodePath
}
var northstarStopCmd = &cobra.Command{
Use: "stop",
Short: "Stop the northstar service",
SilenceErrors: true,
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
fmt.Println("Stopping northstar service...")
svc, err := service.New("northstar", &northstar.Config{})
if err != nil {
return fmt.Errorf("create northstar service: %w", err)
}
if err = svc.Stop(); err != nil {
return fmt.Errorf("stop northstar service: %w", err)
}
fmt.Println("northstar service stopped")
return nil
},
}
var northstarStatusCmd = &cobra.Command{
Use: "status",
Short: "Get status of the northstar service",
Run: func(cmd *cobra.Command, args []string) {
fmt.Println("northstar service status: ...")
},
}
var qmpCollectorCmd = &cobra.Command{
Use: "qmp-collector",
Short: "Manage the qmp-collector (guest metrics) service",
@@ -1267,34 +977,6 @@ var qmpCollectorStartCmd = &cobra.Command{
},
}
var qmpCollectorStopCmd = &cobra.Command{
Use: "stop",
Short: "Stop the qmp-collector service",
SilenceErrors: true,
SilenceUsage: true,
RunE: func(cmd *cobra.Command, args []string) error {
fmt.Println("Stopping qmp-collector service...")
svc, err := service.New("qmp-collector", &qmpcollector.Config{})
if err != nil {
return fmt.Errorf("create qmp-collector service: %w", err)
}
if err = svc.Stop(); err != nil {
return fmt.Errorf("stop qmp-collector service: %w", err)
}
fmt.Println("qmp-collector service stopped")
return nil
},
}
var qmpCollectorStatusCmd = &cobra.Command{
Use: "status",
Short: "Get status of the qmp-collector service",
Run: func(cmd *cobra.Command, args []string) {
fmt.Println("qmp-collector service status: ...")
},
}
// registerPredastoreNamespacedFlags defines predastore's host, base-path and
// config-path flags and binds them to viper. It must only run once (from
// init()); pflag panics on a redefined flag.
@@ -1424,8 +1106,6 @@ func init() {
bindPredastoreCollisionEnv()
predastoreCmd.AddCommand(predastoreStartCmd)
predastoreCmd.AddCommand(predastoreStopCmd)
predastoreCmd.AddCommand(predastoreStatusCmd)
serviceCmd.AddCommand(viperblockCmd)
@@ -1451,8 +1131,6 @@ func init() {
viper.BindPFlag("viperblock-debug", viperblockCmd.PersistentFlags().Lookup("debug"))
viperblockCmd.AddCommand(viperblockStartCmd)
viperblockCmd.AddCommand(viperblockStopCmd)
viperblockCmd.AddCommand(viperblockStatusCmd)
serviceCmd.AddCommand(qemunbdCmd)
@@ -1461,15 +1139,11 @@ func init() {
viper.BindPFlag("qemunbd-debug", qemunbdCmd.PersistentFlags().Lookup("debug"))
qemunbdCmd.AddCommand(qemunbdStartCmd)
qemunbdCmd.AddCommand(qemunbdStopCmd)
qemunbdCmd.AddCommand(qemunbdStatusCmd)
// Nats
serviceCmd.AddCommand(natsCmd)
natsCmd.AddCommand(natsStartCmd)
natsCmd.AddCommand(natsStopCmd)
natsCmd.AddCommand(natsStatusCmd)
// Add NATS flags
natsCmd.PersistentFlags().Int("port", 4222, "NATS server port")
@@ -1491,8 +1165,6 @@ func init() {
serviceCmd.AddCommand(spinifexCmd)
spinifexCmd.AddCommand(spinifexStartCmd)
spinifexCmd.AddCommand(spinifexStopCmd)
spinifexCmd.AddCommand(spinifexStatusCmd)
spinifexCmd.PersistentFlags().String("wal-dir", "", "Write-ahead log (WAL) directory. Place on high-speed NVMe disk, or tmpfs for development.")
viper.BindEnv("wal-dir", "SPINIFEX_WAL_DIR")
@@ -1515,8 +1187,6 @@ func init() {
bindAwsgwCollisionEnv()
awsgwCmd.AddCommand(awsgwStartCmd)
awsgwCmd.AddCommand(awsgwStopCmd)
awsgwCmd.AddCommand(awsgwStatusCmd)
// spinifex-ui
serviceCmd.AddCommand(spinifexUICmd)
@@ -1546,15 +1216,11 @@ func init() {
viper.BindPFlag("spinifex-ui-ochre-enabled", spinifexUICmd.PersistentFlags().Lookup("ochre-enabled"))
spinifexUICmd.AddCommand(spinifexUIStartCmd)
spinifexUICmd.AddCommand(spinifexUIStopCmd)
spinifexUICmd.AddCommand(spinifexUIStatusCmd)
// vpcd
serviceCmd.AddCommand(vpcdCmd)
vpcdCmd.AddCommand(vpcdStartCmd)
vpcdCmd.AddCommand(vpcdStopCmd)
vpcdCmd.AddCommand(vpcdStatusCmd)
// northstar
serviceCmd.AddCommand(northstarCmd)
@@ -1564,13 +1230,9 @@ func init() {
viper.BindPFlag("northstar-config", northstarCmd.PersistentFlags().Lookup("northstar-config"))
northstarCmd.AddCommand(northstarStartCmd)
northstarCmd.AddCommand(northstarStopCmd)
northstarCmd.AddCommand(northstarStatusCmd)
// qmp-collector
serviceCmd.AddCommand(qmpCollectorCmd)
qmpCollectorCmd.AddCommand(qmpCollectorStartCmd)
qmpCollectorCmd.AddCommand(qmpCollectorStopCmd)
qmpCollectorCmd.AddCommand(qmpCollectorStatusCmd)
}
+1 -132
View File
@@ -62,16 +62,8 @@ func writeMalformedToml(t *testing.T) string {
return writeSpinifexToml(t, "[section\nkey = value without quotes\n")
}
// isolateRuntimeDir redirects the pid-file fallback (utils.pidPath, via
// XDG_RUNTIME_DIR) to an empty temp dir, so a stop command with no explicit
// directory override can't resolve against a real, shared, ambient pid dir.
func isolateRuntimeDir(t *testing.T) {
t.Helper()
t.Setenv("XDG_RUNTIME_DIR", t.TempDir())
}
// TestServiceStartCmdsReturnErrorOnMissingConfig exercises each service
// start/stop command's RunE directly and asserts a fatal precondition
// start command's RunE directly and asserts a fatal precondition
// failure surfaces as a returned error, not a silent zero-exit return.
// Cases with a setup func drive further into RunE toward a specific branch.
func TestServiceStartCmdsReturnErrorOnMissingConfig(t *testing.T) {
@@ -339,99 +331,6 @@ func TestServiceStartCmdsReturnErrorOnMissingConfig(t *testing.T) {
},
wantErr: "start qmp-collector service",
},
// The stop commands below resolve their pid directory to an isolated,
// always-empty temp dir, so Stop() safely fails to find a pid file
// rather than risking a real running process via an ambient path.
{
name: "predastore stop (no running process)",
cmd: predastoreStopCmd,
setup: func(t *testing.T) {
resetGlobalViper(t)
viper.Set("predastore-base-path", t.TempDir())
},
wantErr: "stop predastore service",
},
{
name: "viperblock stop (no running process)",
cmd: viperblockStopCmd,
setup: func(t *testing.T) {
resetGlobalViper(t)
isolateRuntimeDir(t)
},
wantErr: "stop viperblock service",
},
{
name: "qemunbd stop (no running process)",
cmd: qemunbdStopCmd,
setup: func(t *testing.T) {
resetGlobalViper(t)
isolateRuntimeDir(t)
},
wantErr: "stop qemunbd service",
},
{
name: "nats stop (no running process)",
cmd: natsStopCmd,
setup: func(t *testing.T) {
resetGlobalViper(t)
isolateRuntimeDir(t)
},
wantErr: "stop nats service",
},
{
name: "spinifex stop (no running process)",
cmd: spinifexStopCmd,
setup: func(t *testing.T) {
resetGlobalViper(t)
isolateRuntimeDir(t)
},
wantErr: "stop spinifex service",
},
{
name: "awsgw stop (no running process)",
cmd: awsgwStopCmd,
setup: func(t *testing.T) {
resetGlobalViper(t)
isolateRuntimeDir(t)
},
wantErr: "stop awsgw service",
},
{
name: "spinifex-ui stop (no running process)",
cmd: spinifexUIStopCmd,
setup: func(t *testing.T) {
resetGlobalViper(t)
isolateRuntimeDir(t)
},
wantErr: "stop spinifex-ui service",
},
{
name: "vpcd stop (no running process)",
cmd: vpcdStopCmd,
setup: func(t *testing.T) {
resetGlobalViper(t)
isolateRuntimeDir(t)
},
wantErr: "stop vpcd service",
},
{
name: "northstar stop (no running process)",
cmd: northstarStopCmd,
setup: func(t *testing.T) {
resetGlobalViper(t)
isolateRuntimeDir(t)
},
wantErr: "stop northstar service",
},
{
name: "qmp-collector stop (no running process)",
cmd: qmpCollectorStopCmd,
setup: func(t *testing.T) {
resetGlobalViper(t)
isolateRuntimeDir(t)
},
wantErr: "stop qmp-collector service",
},
}
for _, tt := range tests {
@@ -451,36 +350,6 @@ func TestServiceStartCmdsReturnErrorOnMissingConfig(t *testing.T) {
}
}
// TestSpinifexUIStatusCmdReportsStoppedWithNoRunningProcess drives the
// status command's success path: with no pid file at an isolated
// XDG_RUNTIME_DIR, Status reports "stopped" and RunE must return nil.
func TestSpinifexUIStatusCmdReportsStoppedWithNoRunningProcess(t *testing.T) {
isolateRuntimeDir(t)
require.NotNil(t, spinifexUIStatusCmd.RunE)
err := spinifexUIStatusCmd.RunE(spinifexUIStatusCmd, nil)
require.NoError(t, err)
}
// TestSpinifexUIStatusCmdReturnsErrorOnUnreadablePidDir drives the status
// command's other branch: a genuine read failure (permission denied, not
// ENOENT) must surface as an error rather than the benign "stopped" case.
func TestSpinifexUIStatusCmdReturnsErrorOnUnreadablePidDir(t *testing.T) {
if os.Geteuid() == 0 {
t.Skip("root bypasses directory permission checks")
}
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "spinifex-ui.pid"), []byte("123"), 0o600))
require.NoError(t, os.Chmod(dir, 0o000))
t.Cleanup(func() { _ = os.Chmod(dir, 0o755) })
t.Setenv("XDG_RUNTIME_DIR", dir)
require.NotNil(t, spinifexUIStatusCmd.RunE)
err := spinifexUIStatusCmd.RunE(spinifexUIStatusCmd, nil)
require.ErrorContains(t, err, "get spinifex-ui service status")
}
// TestServiceStartExitsNonZeroOnFailure proves a fatal service-start error
// terminates the process non-zero (systemd's Restart=on-failure depends on
// it). os.Exit can't be observed in-process, so this re-execs the test
+1 -1
View File
@@ -10,7 +10,7 @@ email = "{{.OperatorEmail}}"
[aws]
region = "{{.Region}}"
internal_suffix = "spinifex.internal"
services_domain = "services.internal"
[network]
ipsec_enabled = {{.IPSecEnabled}}
+2 -1
View File
@@ -166,8 +166,9 @@ Operational commands for inspecting cluster state. These fan out NATS requests t
| Command | Flags | Description |
|---------|-------|-------------|
| `spx admin images import` | `--name`, `--file`, `--distro`, `--version`, `--arch`, `--platform`, `--boot-mode` (bios/uefi/uefi-preferred), `--tag`, `--force`, `--skip-verify`, `--ami-name` (override the registered `DescribeImages` name, which otherwise defaults to `ami-{distro}-{version}-{arch}`; used for locally-built appliances such as `--ami-name spinifex-eks-node`), `--tmp-dir` (default: `/tmp`) | Catalog imports (`--name`) download the image, fetch the catalog `Checksum` URL, verify the SHA-256/SHA-512 digest, and inherit `BootMode` from the catalog entry. `--boot-mode` overrides the catalog value when set. Mismatch fails closed; the cached file is left on disk and `--force` re-downloads. `--file` imports skip checksum verification (operator-supplied media is outside Spinifex's trust boundary, the skip is logged at INFO for audit) and require an explicit `--boot-mode` because there is no catalog metadata to inherit from. `--skip-verify` bypasses verification for catalog imports and emits a WARN slog + stderr notice; use only for debugging or when upstream mirrors are confirmed-broken. Import never modifies the image: the bytes written to storage are the bytes that were verified. A guest that needs to trust the deployment CA for in-guest SDK-over-TLS calls fetches it at runtime from `http://169.254.169.254/spinifex/ca.pem` (see the IMDS section), so a CA rotation reaches guests without re-importing anything. |
| `spx admin images import` | `--name`, `--file`, `--distro`, `--version`, `--arch`, `--platform`, `--boot-mode` (bios/uefi/uefi-preferred), `--tag`, `--force`, `--skip-verify`, `--checksum`, `--ami-name` (override the registered `DescribeImages` name, which otherwise defaults to `ami-{distro}-{version}-{arch}`; used for locally-built appliances such as `--ami-name spinifex-eks-node`), `--tmp-dir` (default: `<spinifex-dir>/images`) | Catalog imports (`--name`) download the image, fetch the catalog `Checksum` URL, verify the SHA-256/SHA-512 digest, and inherit `BootMode` from the catalog entry. `--boot-mode` overrides the catalog value when set. Mismatch fails closed; the cached file is left on disk and `--force` re-downloads. `--file` imports require an explicit `--boot-mode` because there is no catalog metadata to inherit from, and are verified only when `--checksum <sums-file>` is passed. `--checksum` takes the sums file the image was published with (`SHA256SUMS`, `SHA512SUMS`, `<image>.sha256`/`.sha512`, coreutils, BSD or bare-digest format), looks up the entry for the image's basename, and infers sha256 or sha512 from the digest length. A mismatch exits 1 before anything is written to storage and leaves the file in place. `--checksum` proves only that the image matches the sums file supplied with it, not that either is authentic; verify the sums file's vendor signature separately. `--checksum` is refused without `--file` or with `--skip-verify`. A `--file` import without `--checksum` prints `Importing <file> without checksum validation` to stderr and proceeds; `--name --file` does not fall back to the catalog digest. Every import records the source artifact's digest on the AMI with who verified it (`operator`, `catalog` or `unverified`, the unverified cases hashed as sha256), prints it on success, and shows it in `spx admin images describe`. The digest is of the file as supplied, not of the stored volume. `--skip-verify` bypasses verification for catalog imports and prints a warning to stderr; use only for debugging or when upstream mirrors are confirmed-broken. Import never modifies the image: the bytes written to storage are the bytes that were verified. Compressed and qcow2 images are extracted into a hidden `.import-*` scratch directory under `--tmp-dir`, which is removed when the import succeeds or fails, so the catalog directory keeps only the downloaded source (for `--name`) and the AMI manifest. Point `--tmp-dir` at a disk-backed filesystem with room for the full raw image, never at a tmpfs such as `/tmp`. A guest that needs to trust the deployment CA for in-guest SDK-over-TLS calls fetches it at runtime from `http://169.254.169.254/spinifex/ca.pem` (see the IMDS section), so a CA rotation reaches guests without re-importing anything. |
| `spx admin images list` | — | Lists available OS images that can be imported or downloaded |
| `spx admin images describe` | `--image-id` (required) | Read-only. Prints the AMI's name, owner, creation date, boot mode and architecture, then its source digest: algorithm and value, verification (`operator` with the sums file name, `catalog` with the sums URL, or `unverified`), and the source file name. AMIs imported before digest recording print `Source digest: not recorded`. The digest is not exposed through `DescribeImages`. |
| `spx admin images promote` | `--image-id` (required), `--yes` | Reads `ami-<id>/config.json`, validates the AMI is account-owned, then rewrites `ImageOwnerAlias` to `"system"` in-place. No block data is copied. The change takes effect immediately — the AMI becomes visible to all accounts via `DescribeImages`. Prompts for confirmation (skipped with `--yes`). Already-system AMIs are refused. |
| `spx admin images remove` | `--image-id` (required), `--force`, `--yes` | Loads `ami-<id>/config.json`, walks transitive dependents — copied snapshots whose `VolumeID == imageID`, volumes whose `SnapshotID` references the internal `snap-ami-<id>` or any derived snap, and account AMIs created via `CopyImage` whose `SnapshotID` is a derived snap — then prompts (skipped with `--yes`) before deleting `ami-<id>/config.json` (the DescribeImages barrier) followed by the rest of `ami-<id>/` and `snap-ami-<id>/`. Account-owned AMIs are refused with a hint pointing at `aws ec2 deregister-image` + `aws ec2 delete-snapshot`. `--force` bypasses the dependency, ownership and config-corrupt checks for salvage of orphaned blocks. |
+18 -24
View File
@@ -289,31 +289,25 @@ Configs are generated by `spx admin init` (leader) or `spx admin join` (follower
```
spinifex/
├── cmd/
│ ├── spinifex/ # spx CLI: service entry points and `spx admin`
│ ├── installer/ # Platform installer
│ └── ... # In-guest agents (lb, ecs, rds, eks helpers) and dev tools
├── spinifex/
│ ├── services/ # Long-running services (awsgw, spinifex, viperblockd,
│ │ # predastore, nats, spinifexui)
│ ├── gateway/ # AWS API surface
│ │ ├── gateway.go # chi router, SigV4 auth, throttling
│ │ ├── auth.go # SigV4 verification + IAM lookup
│ │ ├── ec2.go # EC2 action dispatcher
│ │ ├── ec2/ # Per-resource handlers (instance, volume, vpc, ...)
│ │ ├── elbv2/, iam/, policy/
│ ├── handlers/ # NATS-side service implementations (ec2, elbv2, iam, ...)
│ ├── daemon/
│ │ ├── daemon.go # Daemon entry, NATS subscription table
│ │ └── daemon_handlers_*.go # One file per resource family
│ ├── admin/ # Cluster bootstrap (init/join, master.key, CA, tokens)
│ ├── config/ # Configuration structures
│ ├── instancetypes/ # t3.*, m5.*, sys.* definitions
│ ├── lbagent/ # In-VM agent for ELB target health
│ ├── vm/ # VM instance representation
│ ├── vpcd/ # VPC daemon (OVN/OVS network programming)
│ └── qmp/ # QEMU Machine Protocol client
└── cmd/
├── spinifex/ # spx CLI
├── lb-agent/ # ELB target agent binary
├── installer/ # Platform installer
└── ... # ECS agent, ECR credential provider, EKS helper binaries
│ ├── services/ # Long-running services (awsgw, spinifex, nats, predastore, ...)
│ ├── gateway/ # AWS API surface: routing, SigV4 auth, per-service dispatch
│ ├── handlers/ # NATS-side service implementations (ec2, iam, eks, rds, ...)
│ ├── daemon/ # Per-node daemon and its NATS subscription table
│ ├── vm/, qmp/ # VM lifecycle and the QEMU Machine Protocol client
│ ├── vpcd/, network/ # VPC daemon and the layered OVN/OVS network stack
│ ├── admin/ # Cluster bootstrap (init/join, master.key, CA, tokens)
│ └── config/ # Configuration structures
├── internal/ # In-guest SigV4 clients and the AWS Smithy models
├── build/ # systemd units, node helper scripts, microVM build
├── scripts/ # Install, dev-environment, image-build scripts
└── tests/
├── integration/ # Gateway router against embedded NATS JetStream
└── e2e/ # Live-cluster suites (see docs/testing/e2e-coverage.md)
```
## Development
+2
View File
@@ -4,6 +4,8 @@ This directory contains the Spinifex documentation. This is available in individ
The [AWS API coverage](coverage/README.md) pages are generated from Spinifex's dispatch tables and the pinned AWS API models, one page per service. Run `make aws-model-coverage` to regenerate them and print a summary. Only `pages.json` and the `intro.md` files under `coverage/` are hand-written; every `README.md` there is generated and will be overwritten.
The same run writes [`coverage/coverage.json`](coverage/coverage.json), a versioned machine-readable model-versus-dispatch inventory. It is generated and must not be edited. Its operation states are structural: `implemented` means a registered action has neither a stub nor an explicit refusal. It is not a behavioural compatibility claim; conformance evidence and capability profiles establish that separately.
A published page names the operations Spinifex serves, and nothing else. It carries no percentage, no unimplemented operation and no operation the platform will never offer: the same run writes those to [`coverage/internal-report.md`](coverage/internal-report.md), which no slug in the docs site's configuration points at, so it stays internal. A `notApplicable` declaration in `pages.json` therefore publishes nothing; it keeps a permanent absence out of the report's candidate work.
## Frontmatter
+233
View File
@@ -0,0 +1,233 @@
---
title: "Host Firewall"
seoTitle: "Host Firewall and Cluster Membership — Spinifex Docs"
description: "Arm the Spinifex host firewall, narrow SSH to your management networks, and learn how the peer-scoped cluster plane tracks membership changes on its own."
category: "Admin"
tags:
- security
- firewall
- nftables
- hardening
resources:
- title: "Network Connections"
url: "/docs/network-connections"
- title: "Multi-Node Install"
url: "/docs/install-multi-node"
- title: "Setting Up Your Cluster"
url: "/docs/setting-up-your-cluster"
- title: "Spinifex Repository"
url: "https://github.com/mulgadc/spinifex"
---
# Host Firewall
> Arm the host firewall, narrow SSH, and keep the cluster plane off the public internet.
## Table of Contents
- [Overview](#overview)
- [What the policy allows](#what-the-policy-allows)
- [Cluster membership](#cluster-membership)
- [Is it on?](#is-it-on)
- [Narrowing SSH access](#narrowing-ssh-access)
- [Turning it off and on around cluster changes](#turning-it-off-and-on-around-cluster-changes)
- [The files](#the-files)
- [Checking it](#checking-it)
- [Troubleshooting](#troubleshooting)
---
## Overview
Spinifex ships an optional host firewall — an nftables input policy that defaults to drop and divides the node's ports into two groups:
| Group | Ports | Who can reach them |
|---|---|---|
| **Public** | SSH, 443, 3000 (console), 8443 (S3), 9999 (AWS gateway), 53 (DNS) | anyone |
| **Internal** | OVN, NATS, formation, Geneve and the rest of the cluster plane | **cluster members only** |
The internal group is the point. Before this existed, OVN and NATS were reachable from the public internet on a WAN-facing node.
**"Cluster members" is not a list you maintain.** Each node works it out from the cluster it belongs to and rewrites its own rules whenever membership changes — you never edit the peer list by hand.
The policy governs inbound traffic only. Outbound is untouched, because the metadata reply path egresses under per-tap policy routing and a filter there would break it.
## Instructions
## What the Policy Allows
The public plane is open to any source, IPv4 only — an accept with no source scope would otherwise also serve on any global IPv6 address the node picks up from an upstream router advertisement:
| Ports | Serving |
|---|---|
| TCP 443 | Held for the TLS edge; no listener yet, so the rollout needs no firewall change |
| TCP 3000 | Web console |
| TCP 8443 | Predastore S3 gate |
| TCP 9999 | AWS gateway — also how every guest agent (load balancer, EKS, ECS, RDS) reaches its control plane, since they speak SigV4 rather than NATS |
| TCP and UDP 53 | Northstar authoritative DNS. Open on every node deliberately: a node serving no public zone still answers here, which is the accepted cost of not templating the policy per node |
SSH is public too, but scoped by a variable you own — see [Narrowing SSH access](#narrowing-ssh-access).
The cluster plane is peer-scoped. On a single-NIC node the planes collapse onto one address, which is why these rules match peer addresses rather than an interface or a CIDR:
| Ports | Serving |
|---|---|
| TCP 4222, 4248 | NATS 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 |
| TCP 6643, 6644 | OVN database clustering |
| TCP 8660 | Predastore's admin listener |
| UDP 6660, 7660 | Object shards and metadata consensus |
| UDP 6081, 500, 4500, and ESP | Geneve, IKE, NAT-T and IPsec |
Two of those are peer-scoped for reasons worth knowing. Outside a formation window, 4432 is the daemon cluster manager, whose routes report node topology, service inventory and running instances to anyone who asks. And 8660 is Predastore's unauthenticated `/healthz` and `/readyz`, which name the peers a gate cannot reach.
**The formation port needs nothing from you.** `spx admin init` opens 4432 to any source for the length of the formation window and closes it again afterwards, because a node dialling in to join is not a peer yet. It is one slot rather than a list, so it cannot be grown into a general-purpose hole. The handshake behind it is TLS 1.3 with a bearer token.
Beyond those groups the policy accepts what the platform cannot work without: established and related connections, loopback, the ICMP types PMTUD needs under a Geneve overlay, broadcast DHCP replies for the uplink and external-pool leases, and guest traffic to the metadata and VPC DNS endpoints on `169.254.169.254` and `169.254.169.253`. The guest rules are scoped to those two addresses and their ports rather than accepting everything arriving on a guest port, which would expose every wildcard-bound host service to any tenant.
## Cluster Membership
The daemon resolves who the node's peers are from the cluster it belongs to, writes them into `peers.nft`, and reloads the policy. A node that has not resolved membership yet is left reachable rather than cut off from a cluster it cannot name: the apply helper fails without touching the ruleset when the peer file is missing.
One consequence matters in practice. **A node only recognises the members of the cluster it currently belongs to.** During formation the nodes do not yet know each other, so internal traffic between them is dropped — which is why forming or expanding a cluster starts by turning the policy off.
## Is It On?
| How the node was installed | Firewall |
|---|---|
| From the ISO | **on** |
| Binary installer (`curl \| bash`) or `setup.sh` | **off** |
| `setup.sh --firewall=on` | **on** |
The binary installer defaults to off deliberately: it runs on servers that already have an operating system and services on them, and switching on a default-deny policy uninvited could cut off something Spinifex knows nothing about. The ISO is an appliance on hardware we define, so there the policy is the product.
**For production, turn it on** — either at install time:
```bash
curl -fsSL https://install.mulgadc.com | bash -s -- --firewall=on
```
or afterwards, by setting it in `/etc/spinifex/spinifex.toml` and restarting the daemon:
```toml
[network]
firewall_enabled = true
```
Before you do, check what else the machine is serving. Anything listening on a port outside the public group above stops accepting new connections.
## Narrowing SSH Access
SSH is accepted from **every source** by default. That is deliberate — arming the policy should not lock an operator out of the node they are arming it from — but it is the first thing to change on a production cluster.
The sources live in `/etc/spinifex/firewall/custom.nft`, which the installer creates once and never rewrites, so an edit here survives upgrades, re-installs and node resets:
```
define trusted_ssh_peers = { 10.0.0.0/8, 192.168.1.0/24 }
```
Check the change before applying it, and check the policy file rather than this one — on its own `custom.nft` parses as a bare define and proves nothing, because the rule that uses the variable lives elsewhere:
```bash
sudo nft -c -f /etc/spinifex/firewall/spinifex.nft
sudo nft -f /etc/spinifex/firewall/spinifex.nft
```
Never leave the variable empty. An undefined or empty set fails the whole ruleset, not just its own rule.
The ports come from sshd's own configuration, re-read on every `setup.sh` run, so moving sshd to a non-standard port is picked up by an upgrade rather than needing an edit here. IPv4 only: sshd reached over IPv6 is not matched by this rule.
## Turning It Off and On Around Cluster Changes
Turn the firewall off while you form or expand a cluster, and on again once it is up:
```bash
# Off — before forming or expanding a cluster. Run on every node.
sudo /usr/local/lib/spinifex/spinifex-firewall-apply disable
# On — once the cluster is formed and verified. Run on every node.
sudo systemctl restart spinifex-daemon
```
Restarting the daemon is what re-arms it: the node rebuilds its peer list from the cluster it is now part of, reloads the rules, and re-enables the boot-time unit so the policy survives a reboot. It also happens on its own within five minutes if you would rather wait.
`disable` removes Spinifex's own table and the peer file and leaves every other nftables table alone. It is idempotent, and it is the recovery path — it runs even on a node with no policy installed, so it cannot leave you with a node you can neither arm nor disarm.
> [!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`.
## The Files
Everything lives under `/etc/spinifex/firewall/`. Which files are safe to edit follows from who writes them:
| File | Written by | Survives an upgrade |
|---|---|---|
| `spinifex.nft` | `setup.sh` — the policy itself | No. Edits are overwritten |
| `local.nft` | `setup.sh` — the sshd ports it detected | No. Refreshed on every run |
| `peers.nft` | The daemon — cluster and encap peer addresses | No. Rewritten on every membership change |
| `open-ports.nft` | The apply helper — the formation window only | No. Reset closed on install and upgrade |
| `custom.nft` | **You** — `trusted_ssh_peers` and anything else you add | **Yes.** Created once, never rewritten |
| `mode` | `setup.sh` — whether the install path armed the policy | No |
The daemon cannot write `/etc` itself, and a blanket `nft` grant would be root-equivalent, so changes go through `/usr/local/lib/spinifex/spinifex-firewall-apply` — a root helper that accepts a fixed set of verbs and re-validates every address handed to it.
## Checking It
```bash
sudo nft list table inet spinifex_filter
```
The peer list is an nft variable, expanded when the rules load, so it does not appear under a name of its own. Look instead at the `ip saddr { ... }` addresses on the cluster-plane rules — the ones accepting 4222, 6641, 6642 and the rest.
Every node's addresses should be there. On a multi-NIC node that means its WAN, LAN and VPC addresses, so expect several entries per node. A missing node means its cluster traffic is being dropped.
Dropped packets are logged, rate-limited to five a minute, so this tells you whether a connection problem is the firewall or something else:
```bash
sudo journalctl -k | grep 'spinifex-fw drop'
```
An empty log with a connection still failing means the policy is not the cause. A node with no table loaded at all is not armed:
```bash
systemctl is-enabled spinifex-firewall.service
```
## Troubleshooting
### A Join Hangs Instead of Failing
A hang rather than a quick refusal means packets are being dropped. Check the drop log on the node running init, and confirm the formation port opened — `spx admin init` prints `⚠️ Could not open port 4432 in the host firewall` when it could not:
```bash
sudo journalctl -k | grep 'spinifex-fw drop'
```
On ISO-installed nodes the usual cause is that each node's policy still trusts only itself, because that is the whole cluster as far as it knows. Turn the firewall off on **every** node, retry, and re-arm once the cluster is verified.
### A Node Is Missing from the Peer List
The daemon rewrites `peers.nft` from the cluster it belongs to, so a missing node means that node is not in the membership this one resolved. Confirm what the cluster thinks first, then restart the daemon to force a rebuild:
```bash
spx get nodes
sudo systemctl restart spinifex-daemon
```
### One of My Own Services Stopped Answering
Arming the policy closes every port outside the public group. Add what you need to `custom.nft`, which is the only file an upgrade leaves alone, and check the policy before applying it:
```bash
sudo nft -c -f /etc/spinifex/firewall/spinifex.nft
```
### Elastic IPs Stopped Working
Something flushed the whole nftables ruleset, taking the VPC daemon's SNAT and forward rules with it. They are reinstalled on start:
```bash
sudo systemctl restart spinifex-vpcd
```
@@ -0,0 +1,250 @@
---
title: "Predastore Distributed Storage"
seoTitle: "How Predastore Distributed Storage Works — Spinifex Docs"
description: "How Spinifex stores objects across a cluster: gate, blob and meta nodes, Reed-Solomon erasure coding, placement, degraded writes, repair and encryption at rest."
category: "Admin"
tags:
- storage
- s3
- predastore
- erasure coding
- durability
resources:
- title: "Predastore Repository"
url: "https://github.com/mulgadc/predastore"
- title: "Viperblock (EBS)"
url: "https://github.com/mulgadc/viperblock"
- title: "Multi-Node Install"
url: "/docs/install-multi-node"
- title: "Network Connections"
url: "/docs/network-connections"
---
# Predastore Distributed Storage
> How Spinifex stores objects across a cluster, and what a server failure costs.
## Table of Contents
- [Overview](#overview)
- [Hosts and nodes](#hosts-and-nodes)
- [The configuration file](#the-configuration-file)
- [Erasure coding and durability](#erasure-coding-and-durability)
- [Placement](#placement)
- [The write path](#the-write-path)
- [The read path](#the-read-path)
- [Repair](#repair)
- [Encryption at rest](#encryption-at-rest)
- [What a server failure costs](#what-a-server-failure-costs)
- [Checking it](#checking-it)
- [Troubleshooting](#troubleshooting)
---
## Overview
Predastore is the S3-compatible object store underneath a Spinifex cluster, and almost everything durable ends up in it:
- **Machine images** — the AMIs instances launch from
- **Block storage** — EBS volume data and snapshots, written through [Viperblock](https://github.com/mulgadc/viperblock), which uses Predastore as its S3 backend
- **User data** — cloud-init configuration and system artifacts
- **DNS zone files** — the zones Northstar serves
- **Anything your own workloads put in a bucket** through the S3 API
This is background reading. You do not configure any of it by hand: `spx admin init` and `spx admin join` build the topology from the servers that actually form the cluster, and every machine gets the same file with its own host ID recorded in `spinifex.toml`.
## Instructions
## Hosts and Nodes
A cluster is two levels.
A **host** is one machine: one Predastore process owning that machine's data directory, TLS identity and encryption key. A **node** is a role pinned to a host, running inside that process on its own port. Every server in a Spinifex cluster is one host carrying three nodes:
| Role | Port | Purpose |
|---|---|---|
| `gate` | TCP 8443 | Serves the S3 API — SigV4, IAM, erasure coding, placement. Every server runs one, so any of them answers an S3 request. |
| `blob` | UDP 6660 | Holds erasure-coded object shards. One per machine. |
| `meta` | UDP 7660 | Member of the Raft quorum over global state — buckets and the object index. |
Ports have to be unique within a host but not across the cluster, so every machine uses the same three. That is why the port surface does not widen as the cluster grows — see [Network requirements](/docs/install-multi-node#network-requirements).
Which transport a pair of nodes uses follows from where they sit, not from a mode flag. Nodes on the same machine talk over an in-process pipe and open no socket at all; nodes on different machines talk over QUIC, TLS 1.3, authenticated by the cluster CA. A single-server install is therefore one process listening on 8443 and nothing else.
Nothing dials a gate, so a gate binds no cluster-plane listener. Its port is the S3 port and nothing more.
## The Configuration File
The whole cluster's storage topology lives in `/etc/spinifex/predastore/predastore.toml`, identical on every server apart from which host the local process is told to be.
Each server is one `[[host]]` block carrying three `[[host.node]]` blocks:
```toml
[[host]]
id = 1
bind_addr = "10.2.0.2" # cluster plane: raft and blob
addr = "10.2.0.2" # what the other hosts dial
data_dir = "/var/lib/spinifex/predastore"
tls_cert = "/etc/spinifex/server.pem"
tls_key = "/etc/spinifex/server.key"
encryption_key = "/etc/spinifex/predastore/encryption.key"
admin_port = 8660 # /healthz, /readyz, /repair
[[host.node]]
id = 1
role = "gate"
port = 8443
bind_addr = "0.0.0.0" # public plane; only a gate may set this
```
The two bind addresses are the point. `bind_addr` on the host is the cluster plane — Raft consensus and shard traffic listen there and nowhere else, at the address peers dial rather than a wildcard, so on a multi-homed machine replication never reaches the public interface. The gate binds the public plane separately, because S3 is meant to be reachable.
## Erasure Coding and Durability
The erasure code is cluster configuration, not a per-request choice, and it is chosen at formation from the number of servers — each machine contributes exactly one blob node:
| Servers | Code | What it survives |
|---|---|---|
| 1 | `RS(1,0)` | Nothing at this layer. One shard on one node; redundancy is the pool's job — ZFS raidz or a mirror under the data directory. |
| 2 | `RS(1,1)` | One server's shards, but the metadata quorum has no majority to lose. |
| 3 or more | `RS(2,1)` | Any one server's shards, with the metadata quorum intact. |
An object is encoded end to end into `K` data and `M` parity shards with no intermediate chunking. Each shard is roughly `⌈size / K⌉` bytes, and any `K` of the `K+M` shards rebuild the object.
`data + parity` must not exceed the number of blob nodes, because a stripe is spread over distinct nodes and a narrower cluster would fail every write. `RS(2,1)` surviving the loss of any one server is the reason [three servers is the recommended minimum](/docs/install-multi-node).
## Placement
Which nodes hold an object's shards comes from a consistent hash ring over the blob nodes, keyed by the object hash. `Nodes(hash, K+M)` returns the placement in shard order — shard `i` goes to the node at position `i` — so every gate in the cluster derives the same placement from the same object without consulting anything.
The gate then **records** that placement in the metadata plane rather than trusting the ring at read time. A ring that has since changed shape does not lose objects placed under the old one.
## The Write Path
The gate authenticates the request, resolves the bucket, hashes the object name, places it on the ring, encodes the body into data and parity shards, streams each shard to its blob node in parallel, and records the placement.
Three properties matter operationally:
- **The write is two-phase.** Each shard put makes the bytes durable and invisible. The placement record landing in Raft is the commit point, and the shards are published only afterwards — so an interrupted writer costs a round trip rather than the object. Shards are always durable before metadata: a crash between the two leaves shards nothing points at, rather than metadata pointing at data that was never written.
- **Every write mints an epoch**, stamped on every shard of the stripe and into the record. The record names one generation, and a shard either belongs to it or does not. That is what makes a half-completed overwrite a reconstruction rather than an object spliced from two generations.
- **One server down does not refuse writes.** The write is acknowledged once `K` shards are durable, and the response carries `X-Spx-Degraded-Write: <n>`. `K` is the floor rather than `K+1` because any `K` of the `K+M` reconstruct — at `RS(2,1)` one more buys no redundancy and would refuse three quarters of writes with a single server down.
A shard its owner will not take is written to the next node along the ring instead of being given up on — **hinted handoff** — and the response carries `X-Spx-Handoff: <n>`. The stripe is then complete and the object is as redundant as a full-width write; what is outstanding is only that the shards are not all where the record says. The holder is derived rather than recorded, so there is no hint to store, replicate or lose. Handoff is skipped when the cluster has no node to spare, since a second shard of one object on a node that already holds one is not redundancy.
If any node reported storage pressure the response also carries `X-Predastore-Pool-Pressure: nearfull`; a node that refused outright surfaces as `507 InsufficientStorage`.
## The Read Path
The gate reads the recorded placement, fetches the data shards, and joins them. Parity is never touched on the happy path. Only if the join fails does it refetch including parity and reconstruct, and a read that had to reconstruct says so in `X-Spx-Degraded: <n>` — a cost report, not an error.
Every shard read names the record's epoch, and a node holding another generation answers `epoch-mismatch`. **The gate counts that exactly as a missing shard**, so the read either rebuilds the object the record names or fails. It never returns plausible nonsense.
A shard its owner cannot serve is looked for on the handoff node before it is counted as failed, since a write that could not reach the owner will have put it there.
A `Range` request that lands entirely inside one data shard takes a fast path — a single ranged shard read — because Reed-Solomon splits data sequentially. Anything wider reconstructs the object and slices it.
## Repair
Degraded writes trade redundancy for availability, and repair is what pays that back. It restores shards a blob node owns but does not hold at the generation its object's record names, and it runs on every server with a local blob node.
Two things are worth knowing about it:
- **It carries no correctness weight.** A read compares each shard's epoch against the record it has already loaded and discards a stale one whether or not repair has ever run. What repair restores is redundancy — the number of further losses an object survives — and never the object.
- **It rebuilds onto the owner**, so it closes the gap only once the missing server returns. At `RS(2,1)` an object written while a server is down holds no parity for the duration of the outage. That is the accepted position: refusing writes meanwhile is the worse failure.
Every pass scans the placement records from the beginning, asks each holder which generation it has, and rebuilds the ones that disagree. A shard that was handed off is returned rather than recomputed — cheaper by a decode — and the holder's copy is released only after the owner's is published. If fewer than `K` peers hold the record's generation the rebuild fails and says so, rather than inventing a shard from a mixture of generations.
## Encryption at Rest
Every fragment is sealed independently under AES-256-GCM, with a deterministic nonce and additional data reconstructed at read time from the on-disk header plus per-data-directory state. There is no unencrypted code path: the process refuses to start without an encryption key.
| Property | What it means |
|---|---|
| **Confidentiality** | Disk-level access yields ciphertext and tags. Plaintext is never written. |
| **Authenticated integrity** | GCM is the sole integrity authority; there is no separate checksum. Corruption, tampering and a wrong key all surface as one integrity error. |
| **Position binding** | The additional data binds each fragment to its slot, so swapping fragments between objects fails the tag. |
| **Cross-directory defence** | A per-data-directory store ID enters the nonce, so splicing a fragment from one data directory into another fails the tag. |
The key is one 32-byte AES-256 key per cluster, and **every server must be given the same file** — fragments sealed under one key cannot be opened under another. `spx admin init` generates it and `spx admin join` distributes it, which is also why joining a server that has already been in service discards its own key and orphans everything sealed under it. The loader is fail-closed on permissions: a group- or other-readable key file is rejected outright, with no override.
## What a Server Failure Costs
| What fails | Reads | Writes |
|---|---|---|
| One gate (of three or more) | Unaffected — any gate answers for the cluster | Unaffected |
| One blob node, `RS(2,1)` | Served by reconstruction, reported in `X-Spx-Degraded` | Accepted at the `K` floor, reported in `X-Spx-Degraded-Write`, or placed complete via handoff |
| More than `M` blob nodes | Objects placed on them are lost | Refused |
| Metadata quorum | Serve whatever the surviving replicas last applied | Stopped entirely |
Reads try the cached Raft leader first, then every replica, and report not-found only once every replica has answered not-found — a replica that has not applied a key yet must not be mistaken for absence. Writes go through the leader, and a replica that cannot commit redirects the client to the one that can.
Replacing a server restores capacity, not the shards that were on the old one: repair rebuilds onto the owner it expects, so plan a replacement as a return of that node rather than as re-replication onto a new one.
## Checking It
Each host runs an unauthenticated admin listener on port 8660, bound to the cluster plane rather than the S3 address. It serves counters and readiness, never addresses or keys:
```bash
curl -s http://$(hostname -I | awk '{print $1}'):8660/readyz
curl -s http://$(hostname -I | awk '{print $1}'):8660/repair
```
`/readyz` reports the most recent probe cycle for the nodes in that process, and answers `503` until one has completed — reporting ready on no information would send traffic to a process that has never answered the question. `/healthz` answers for the process itself: reaching the handler is the whole check.
That the gate is serving is an S3 request like any other:
```bash
export AWS_PROFILE=spinifex
aws s3 ls
```
To see whether a write or read was degraded, ask for the response headers:
```bash
aws s3api put-object --bucket predastore --key probe --body /etc/hostname --debug 2>&1 | grep -i 'x-spx-\|x-predastore-'
```
And `spx get nodes` should list `predastore` in `SERVICES` on every node — a short service list on one server means something failed to start there.
## Troubleshooting
### S3 Answers but Every Write Fails
The gate is up and the metadata quorum is not. Check that a majority of servers are running `spinifex-predastore`:
```bash
spx get nodes
sudo systemctl is-active spinifex-predastore
```
Reads keep serving what the surviving replicas last applied, which is why this presents as a write-only outage.
### 507 InsufficientStorage, or `nearfull` on Every Response
A blob node is out of space, or close enough that compaction has been kicked. Free space in the data directory on each server:
```bash
df -h /var/lib/spinifex/predastore
```
Capacity here is per server, not pooled: a stripe needs its shard on a specific node, so one full server refuses writes for the objects the ring places on it.
### Reads Fail with an Integrity Error
GCM is the only integrity authority, so corruption, tampering and a wrong encryption key are indistinguishable. Confirm the key first, since it is by far the most likely of the three — every server must hold the identical file:
```bash
sudo sha256sum /etc/spinifex/predastore/encryption.key
```
Compare the digest across servers. A mismatch means that server was initialized separately rather than joined, and the fragments it sealed cannot be opened by the cluster.
### Degraded Reads That Never Clear
`X-Spx-Degraded` on reads long after an outage means repair has not restored the shards. Repair rebuilds onto the node that owns the shard, so it cannot finish while that node is absent:
```bash
curl -s http://$(hostname -I | awk '{print $1}'):8660/repair
```
`"enabled": false` means the sweep is off on that host. Otherwise bring the missing server back and let a pass complete — the sweep runs every five minutes by default.
+68 -9
View File
@@ -1,7 +1,7 @@
---
title: "Setting Up Your Cluster"
seoTitle: "Set Up Your First Spinifex Cluster — Spinifex Docs"
description: "Import an AMI, create an SSH key pair and a VPC with a public subnet, then launch your first EC2 instance and connect to it on a fresh Spinifex cluster."
description: "Import an AMI and the RDS, ECS and EKS base images, create an SSH key pair and a VPC with a public subnet, then launch your first EC2 instance on a fresh Spinifex cluster."
category: "Admin"
tags:
- setup
@@ -17,24 +17,26 @@ resources:
# Setting Up Your Cluster
> Import an AMI, create a VPC with a public subnet, and launch your first instance.
> Import an AMI and the system base images, create a VPC with a public subnet, and launch your first instance.
## Table of Contents
- [1. Import an AMI](#1-import-an-ami)
- [Option C: Import the system base images](#option-c-import-the-system-base-images)
- [2. Create an SSH Key](#2-create-an-ssh-key)
- [3. Create a VPC and Public Subnet](#3-create-a-vpc-and-public-subnet)
- [4. Launch an Instance](#4-launch-an-instance)
- [5. Connect via SSH](#5-connect-via-ssh)
- [6. Managing Instances](#6-managing-instances)
- [7. Launching the Web UI](#7-launching-the-web-ui)
- [8. Secure the Install](#8-secure-the-install)
- [Troubleshooting](#troubleshooting)
---
## Overview
This guide walks through the first steps on a freshly installed cluster: importing an AMI, creating an SSH key and a VPC with a public subnet, launching an instance, and connecting to it.
This guide walks through the first steps on a freshly installed cluster: importing an AMI and the system base images RDS, ECS and EKS need, creating an SSH key and a VPC with a public subnet, launching an instance, and connecting to it.
## Instructions
@@ -69,10 +71,16 @@ ubuntu-26.04-x86_64 | ubuntu | 26.04 | x86_64 | uefi
spx admin images import --name ubuntu-26.04-x86_64
```
Verify the import and note the AMI ID:
```bash
AMI_ID=$(aws ec2 describe-images --query 'Images[0].ImageId' --output text)
```
### Option B: Import a local image file
```bash
spx admin images import --file ~/images/ubuntu-26.04.img --distro ubuntu --version 26.04 --arch x86_64 --boot-mode uefi
spx admin images import --file ~/images/ubuntu-26.04-server-cloudimg-amd64.img --checksum ~/images/SHA256SUMS --distro ubuntu --version 26.04 --arch x86_64 --boot-mode uefi
```
Verify the import and note the AMI ID:
@@ -81,6 +89,41 @@ Verify the import and note the AMI ID:
AMI_ID=$(aws ec2 describe-images --query 'Images[0].ImageId' --output text)
```
### Option C: Import the system base images
**RDS, ECS and EKS each launch from an image of their own, and none of them is imported for you.** Until these are in the catalog, `CreateDBInstance`, `CreateCluster` and `CreateNodegroup` all fail at the point they go looking for a node image — so a cluster without them has those three services present in the API and unusable in practice. Import them once, now, and the install is functional from the start.
Run these one at a time rather than in parallel, so predastore is not taking four concurrent uploads:
```bash
for img in spinifex-rds-postgres spinifex-rds-mariadb spinifex-ecs-node spinifex-eks-node; do
sudo spx admin images import --name "$img" --config /etc/spinifex/spinifex.toml
done
```
| Image | Serves | Contents |
| ----------------------- | -------------------------------- | ----------------------------------------------- |
| `spinifex-rds-postgres` | RDS, `--engine postgres` | Alpine 3.24.1 + PostgreSQL 18 + `rds-init` |
| `spinifex-rds-mariadb` | RDS, `--engine mariadb` | Alpine 3.24.1 + MariaDB 11.8 + `rds-init` |
| `spinifex-ecs-node` | ECS container instances | Alpine 3.21.7 + containerd + ecs-agent |
| `spinifex-eks-node` | EKS control plane and nodegroups | Alpine 3.21.7 + K3s v1.32.5 + eks-token-webhook |
All four boot **BIOS**, unlike the distro images, which boot UEFI. That is a property of how they are built and nothing you need to pass.
If you run GPU workloads, add `spinifex-ecs-node-gpu` and `spinifex-eks-node-gpu` as well — Ubuntu 26.04 with the NVIDIA driver, selected automatically for a GPU instance type. They boot UEFI and they are large, so import them only if you have GPUs.
> [!IMPORTANT]
> **These register under generic names, and that is correct.** `describe-images` shows `spinifex-eks-node` and `spinifex-ecs-node` both as `ami-alpine-3.21.7-x86_64`, and the two RDS images both as `ami-alpine-3.24.1-x86_64`. Nothing resolves them by name: ECS and EKS find theirs by the `spinifex:managed-by=ecs|eks` tag, and RDS by `spinifex:managed-by=rds` plus the `engine` and `engine-version` tags. **Verify the tags, not the names** — four rows with duplicate-looking names is what success looks like:
>
> ```bash
> aws ec2 describe-images \
> --filters 'Name=tag-key,Values=spinifex:managed-by' \
> --query 'Images[].[ImageId,State,Tags[?Key==`spinifex:managed-by`].Value|[0],Tags[?Key==`engine`].Value|[0]]' \
> --output text
> ```
`spx admin images list --config /etc/spinifex/spinifex.toml` on a node shows the whole catalog, including the images not imported here.
## 2. Create an SSH Key
### Option A: Import an existing key
@@ -131,7 +174,7 @@ aws ec2 attach-internet-gateway \
### Create a Subnet
Create the subnet your instances will launch into. The routing and public-IP steps below are what make it a *public* subnet.
Create the subnet your instances will launch into. The routing and public-IP steps below are what make it a _public_ subnet.
```bash
SUBNET_ID=$(aws ec2 create-subnet \
@@ -312,17 +355,17 @@ https://YOUR_NODE_IP:3000
On first load, your browser will show a TLS warning — Spinifex generates a self-signed certificate at install time. This is expected.
1. Accept the warning to reach the login page (exact wording varies by browser — e.g. Chrome: *Advanced → Proceed to ...*, Firefox: *Advanced → Accept the Risk and Continue*).
1. Accept the warning to reach the login page (exact wording varies by browser — e.g. Chrome: _Advanced → Proceed to ..._, Firefox: _Advanced → Accept the Risk and Continue_).
2. On the login page, click **Download Certificate** and save `spinifex-ca.pem` to your machine.
3. Install the certificate as a **trusted root** on your workstation, following the steps for your platform below.
4. Restart your browser and reload `https://YOUR_NODE_IP:3000`. The padlock should now show a valid certificate.
Step 3, by platform:
- **macOS:** open `spinifex-ca.pem` in Keychain Access → *System* keychain → set *Trust* to **Always Trust**.
- **macOS:** open `spinifex-ca.pem` in Keychain Access → _System_ keychain → set _Trust_ to **Always Trust**.
- **Linux:** `sudo cp spinifex-ca.pem /usr/local/share/ca-certificates/spinifex-ca.crt && sudo update-ca-certificates`
- **Windows:** double-click the file → *Install Certificate* → *Local Machine* → *Trusted Root Certification Authorities*.
- **Browser-only (Firefox):** *Settings → Privacy & Security → Certificates → View Certificates → Authorities → Import* and tick *Trust this CA to identify websites*.
- **Windows:** double-click the file → _Install Certificate_ → _Local Machine_ → _Trusted Root Certification Authorities_.
- **Browser-only (Firefox):** _Settings → Privacy & Security → Certificates → View Certificates → Authorities → Import_ and tick _Trust this CA to identify websites_.
> **Why this is required:** the UI logs in by reading your AWS credentials through a trusted TLS channel. Browsers refuse to send credentials over an untrusted connection, so the certificate must be installed as trusted — temporary "Proceed anyway" exceptions won't work for login.
@@ -340,6 +383,22 @@ At the Spinifex login screen, paste the **Access Key ID** and **Secret Access Ke
Once logged in, you have browser-based access to every Spinifex feature: launch and manage instances, attach EBS volumes, browse S3 buckets, configure VPCs and security groups, and manage IAM users and keys — all backed by the same AWS-compatible control plane the CLI uses.
## 8. Secure the Install
Your cluster works. Before it carries anything real, close the host down.
**Arm the host firewall.** A binary install leaves it off, because it lands on a machine that was already doing something. Armed, it keeps OVN, NATS and the rest of the cluster plane reachable by cluster members only, and everything outside the public group closed:
```toml
# /etc/spinifex/spinifex.toml
[network]
firewall_enabled = true
```
**Narrow SSH.** It is accepted from every source by default, so that arming the policy cannot lock you out of the node you are arming it from. Replace `0.0.0.0/0` with your management networks in `/etc/spinifex/firewall/custom.nft` — the one file an upgrade never overwrites.
[Host Firewall](/docs/host-firewall) covers both, what the policy allows, and how each node tracks cluster membership without a peer list you maintain.
## Additional Options
### Private Subnets (No Public IP)
+1 -1
View File
@@ -109,7 +109,7 @@ spx admin images list
spx admin images import --name debian-13-arm64
```
Catalog imports verify the image against the catalog-declared SHA-256/SHA-512 digest before extraction. Use `--file` to import operator-supplied media (verification skipped — operator is responsible for integrity), or `--force` to re-download after a checksum mismatch.
Catalog imports verify the image against the catalog-declared SHA-256/SHA-512 digest before extraction. Use `--file` with `--checksum <sums-file>` to import operator-supplied media verified against the sums file it was published with, or `--force` to re-download after a checksum mismatch. A `--file` import without `--checksum` proceeds unverified and prints a notice. Every import records the source file's digest on the AMI; `spx admin images describe --image-id <ami>` shows it.
### EKS node image
+15
View File
@@ -136,6 +136,21 @@ aws ec2 run-instances \
--key-name spinifex-key
```
### Multi-GPU Instances
The GPU count comes from the instance type, exactly as it does in EC2. AWS multi-GPU types pair eight GPUs with a very large host — `p4d.24xlarge` is 96 vCPU and 1152 GiB, — so an edge rig with eight cards and 256 GiB cannot admit them even though it has the GPUs. For those hosts Spinifex offers its own `gpu.*` family, which is not an EC2 family:
```bash
aws ec2 run-instances \
--image-id $GPU_AMI \
--instance-type gpu.8x4c \
--key-name spinifex-key
```
`gpu.<count>x<vcpu>c` is that many GPUs at that many vCPU each, so `gpu.8x4c` is eight GPUs on 32 vCPU and 128 GiB. Counts run in three tiers — `2c`, `4c` and `8c` — all at 4 GiB of memory per vCPU. The GPU model and VRAM reported in `DescribeInstanceTypes` are whatever the node discovered, so a `gpu.8x4c` on an 8×RTX 3090 box advertises eight RTX 3090s at 24 GiB each.
A node holding more than one distinct GPU model offers single-GPU types only, and logs each withheld type by name. Two devices count as the same model when their reported name and VRAM match, which keeps a mixed-SKU H100 node fully usable.
To verify the GPU is visible from inside the instance, SSH in and run:
```bash
+30 -3
View File
@@ -161,6 +161,12 @@ Both support the same AWS features: public subnets, Elastic IPs, security groups
## `nat` — Shared SNAT (Simple)
**`nat` is a supported mode, not a fallback.** It is covered by the nightly e2e suite (cells 19 and 30, single- and multi-node) and it is the only mode a cloud-hosted node can run, so the OCI deployment depends on it. Read "specialised" below as "fewer deployments need it", never as "less finished".
**Start with `pool` unless one of the reasons below applies.** Pool mode is the default, is what most of this document describes, and is what the AWS feature set was built against. `nat` is the specialised mode: it exists for uplinks that cannot be bridged, and it reaches AWS parity through host-side plumbing rather than by putting VM addresses on the wire.
Reach for `nat` when the uplink refuses to carry anything but its own MAC and its own addresses — WiFi and WWAN, and **any cloud-hosted node**, where the VNIC drops a frame whose source address is not registered to it. On such a host `nat` is not a preference, it is the only mode that works at all; pool mode's localnet needs to announce VM addresses by ARP and a cloud VNIC will not carry them. See [oci-integration](../../oci-integration/README.md).
All VMs share a single external IP for outbound SNAT. By default there are no public IPs, no Elastic IPs, and no inbound from WAN — all subnets behave as private subnets with internet access. On routed-NAT nodes, adding a public pool restores full public IP parity (see below).
> **Limitation (routed-NAT v1):** System instances (ECS/EKS/load-balancer
@@ -244,6 +250,24 @@ gateway = "192.168.1.1"
prefix_len = 24
```
### NAT gateways in NAT mode
`CreateNatGateway` works here too, and draws its public address from the same public pool an EIP does. On the OVN side it is an `snat` row on the VPC router rewriting the private subnet's CIDR to that address — not the `dnat_and_snat` an EIP gets — so it is centralised on the VPC's gateway chassis by construction and there is no port or MAC to bind it to.
**That address needs the same host plumbing as an EIP**, and on a routed-NAT node it is the host that delivers it: the `/32` route steering it into OVN, the proxy-ARP entry answering for it on the uplink, and the FORWARD accepts. Without them an egressing private instance leaves with a source address the host holds no route back to, while the masquerade rule — which matches the transit `100.127.0.0/24` alone — never sees the packet. Spinifex binds it when the gateway is created and re-asserts it on the host EIP pass, so no operator step is involved; `ip route show dev spx-nat-host` should list the gateway's public address alongside any EIPs.
### NAT mode on more than one node
`spx admin init --external-mode=nat --nodes N` works for N > 1, and each public IP is delivered by the node running its instance rather than by one gateway node. Three things make that true, and all three are automatic:
- The `dnat_and_snat` row carries `external_mac` and `logical_port`, so OVN processes it on the instance's own chassis — the same mechanism pool mode has always used. It is safe here because `ext-shared` is a localnet, so the transit veth is node-local and only the node running the instance can answer for the address on it.
- Each node installs a host `/32` for its own instances' addresses and leaves the rest alone, asking local OVS which ENIs are plugged in here. Run `ip route show dev spx-nat-host` on each node: exactly one node should hold each public address.
- Every node's `spx-nat-host` carries the same MAC, `02:00:64:7f:00:01`. OVN keeps one MAC binding for the transit next hop across the whole cluster, so a per-node address would send every chassis's return traffic to whichever node wrote the binding last. `setup-ovn.sh` sets it and vpcd re-asserts it on every start.
Default egress — an instance with no public IP — is still centralised on the VPC's gateway chassis, which is unchanged and is what the gateway LRP's transit address is for.
**On a cloud host the address must also be registered on that node's VNIC.** The allocator runs per node and registers on the local VNIC, which is the right one exactly because the traffic now leaves there. `oci_vnic_iface` is the setting; `oci_vnic_id` is only needed to pin a pool to a specific VNIC and should not be set for this.
**Caveat — reaching an EIP from the spinifex host itself.** Host-sourced traffic enters OVN from the transit net, which is exempt from NAT (that is what makes the jumpbox pattern work) — so a host connection to an EIP that carries the transit source IP would skip DNAT. Spinifex stamps the EIP route with the uplink's LAN IP as source to avoid this, but if no uplink address can be determined, connect to the instance's **private IP** from the host instead. Other machines on the LAN are unaffected.
## Disabled (Empty/Omitted)
@@ -263,6 +287,9 @@ VPC networking is overlay-only. No external connectivity. Instances can only com
| DescribeInstances shows public IP | Yes | Yes | With public pool | No |
| Admin must reserve IP range | Yes | No | Only static pool | No |
| Needs router DHCP | No | Yes | Optional | No |
| Works on a non-bridgeable uplink | No | No | Yes | n/a |
| NAT gateways | Yes | Yes | With public pool | No |
| More than one node | Yes | Yes | Yes | Yes |
If you start with `nat` and later need public subnets: on a bridgeable uplink switch to `pool` and define a range (or use `source = "dhcp"`); on a routed-NAT node just add a public pool alongside `nat-transit` — no data migration needed.
@@ -460,8 +487,8 @@ external_mode = "pool" # "pool", "nat", or "" (disabled)
| Value | Behavior |
| -------------- | ----------------------------------------------------------------- |
| `"pool"` | Full public networking — public subnets, auto-assign, Elastic IPs |
| `"nat"` | Outbound-only SNAT — all VMs share one external IP |
| `"pool"` | **Default.** Full public networking — public subnets, auto-assign, Elastic IPs |
| `"nat"` | Outbound-only SNAT — all VMs share one external IP. Add a public pool for full parity. For uplinks that cannot be bridged, including every cloud host |
| `""` / omitted | Overlay-only — no external connectivity |
## IP Pools: network.external_pools
@@ -923,7 +950,7 @@ firewall_enabled = true
Before enabling it, check what else the machine is serving — anything listening
outside the public port group stops accepting new connections. See
[Firewall and Cluster Membership](/docs/install-multi-node#firewall-and-cluster-membership)
[Host Firewall](/docs/host-firewall)
for the full port policy and the cluster-formation steps.
### Verifying it
+2 -3
View File
@@ -146,12 +146,11 @@ Repositories can be managed as code with the standard `aws_ecr_repository` resou
```hcl
provider "aws" {
region = "ap-southeast-2"
skip_credentials_validation = true
skip_requesting_account_id = true
region = "ap-southeast-2"
endpoints {
ecr = "https://<your-spinifex-gateway>"
sts = "https://<your-spinifex-gateway>"
}
}
+2 -2
View File
@@ -55,7 +55,7 @@ Authentication uses **EKS access entries** (the API authentication mode) — IAM
**Spinifex specifics**
- **Auth mode is API-only.** `authentication_mode` must be `API`; the legacy `CONFIG_MAP` and `API_AND_CONFIG_MAP` modes are rejected. Grant access with access entries, not an `aws-auth` ConfigMap.
- **Access entries are the only auth path.** `authentication_mode` accepts `API` (the default when unset) and `API_AND_CONFIG_MAP`; bare `CONFIG_MAP` is rejected. Spinifex has no `aws-auth` ConfigMap path, so `API_AND_CONFIG_MAP` behaves as `API`: grant access with access entries.
- **Security groups are auto-managed.** Spinifex creates the cluster and node-group security groups deterministically (e.g. `eks-cluster-<name>-nodegroup-sg`); `vpc_config.security_group_ids` is ignored. To expose a workload you add an ingress rule to the auto-managed node-group SG.
- **The worker image is fixed.** Node groups always boot Spinifex's `eks-node` image. `ami_type` is recorded but does not select the image, and the image must be registered on the cluster before you create a node group.
- **Default Kubernetes version is `1.32`.**
@@ -262,7 +262,7 @@ The workbooks form a ladder. **eks-quickstart** is a NodePort demo; **eks-https-
**Workload unreachable from outside the cluster.** The node-group security group only allows intra-cluster traffic by default. Add an ingress rule for your NodePort to `eks-cluster-<name>-nodegroup-sg`, as shown in the CLI tab.
**`create-cluster` rejected with an authentication-mode error.** `authentication_mode` must be `API`. Remove any `CONFIG_MAP` / `API_AND_CONFIG_MAP` setting and grant access with access entries instead.
**`create-cluster` rejected with an authentication-mode error.** `CONFIG_MAP` is the one mode Spinifex rejects, because it has no `aws-auth` ConfigMap path. Set `authentication_mode` to `API` or `API_AND_CONFIG_MAP`, or leave it unset, and grant access with access entries.
**Addon install fails.** Only addons bundled into the registered `eks-node` image install successfully. If you pin an addon version in Terraform, leave `addon_version` unset so Spinifex selects the catalog default.
+9 -9
View File
@@ -17,18 +17,18 @@ tags:
## Overview
The platform serves **381 operations** across the AWS APIs below. Every page names the operations its service implements from the pinned model, generated from the dispatch tables on each build rather than written by hand.
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.
| Service | Operations |
|---|---:|
| [ACM](/coverage/acm) | 9 |
| [EC2](/coverage/ec2) | 125 |
| [ECR](/coverage/ecr) | 22 |
| [EC2](/coverage/ec2) | 127 |
| [ECR](/coverage/ecr) | 23 |
| [ECS](/coverage/ecs) | 31 |
| [EKS](/coverage/eks) | 34 |
| [ELBv2](/coverage/elbv2) | 34 |
| [IAM](/coverage/iam) | 76 |
| [RDS](/coverage/rds) | 26 |
| [S3](/coverage/s3) | 19 |
| [EKS](/coverage/eks) | 27 |
| [ELBv2](/coverage/elbv2) | 36 |
| [IAM](/coverage/iam) | 81 |
| [RDS](/coverage/rds) | 27 |
| [S3](/coverage/s3) | 26 |
| [STS](/coverage/sts) | 5 |
| **Total** | **381** |
| **Total** | **392** |
File diff suppressed because it is too large Load Diff
+7 -1
View File
@@ -18,12 +18,16 @@ tags:
## Overview
Spinifex implements **125 operations** in the EC2 `2016-11-15` API model.
Spinifex implements **127 operations** in the EC2 `2016-11-15` API model.
### Spot Instances Are a Mock
Spot Instance Requests are a mock over the on-demand `RunInstances` path. A request synchronously launches real VMs on the operator's own compute and is then reported `active` and `fulfilled`. There is no spot market: no bidding, no price rejection, no interruption and no reclamation, and instances are never taken back.
### 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.
### Operations
| Operation |
@@ -110,6 +114,7 @@ Spot Instance Requests are a mock over the on-demand `RunInstances` path. A requ
| `DescribeVolumes` |
| `DescribeVolumesModifications` |
| `DescribeVpcAttribute` |
| `DescribeVpcEndpoints` |
| `DescribeVpcs` |
| `DetachInternetGateway` |
| `DetachNetworkInterface` |
@@ -132,6 +137,7 @@ Spot Instance Requests are a mock over the on-demand `RunInstances` path. A requ
| `ModifyInstanceMetadataOptions` |
| `ModifyLaunchTemplate` |
| `ModifyNetworkInterfaceAttribute` |
| `ModifySecurityGroupRules` |
| `ModifySubnetAttribute` |
| `ModifyVolume` |
| `ModifyVpcAttribute` |
+4
View File
@@ -1,3 +1,7 @@
### Spot Instances Are a Mock
Spot Instance Requests are a mock over the on-demand `RunInstances` path. A request synchronously launches real VMs on the operator's own compute and is then reported `active` and `fulfilled`. There is no spot market: no bidding, no price rejection, no interruption and no reclamation, and instances are never taken back.
### 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.
+3 -2
View File
@@ -18,7 +18,7 @@ tags:
## Overview
Spinifex implements **22 operations** in the ECR `2015-09-21` API model.
Spinifex implements **23 operations** in the ECR `2015-09-21` API model.
### Two endpoints, one registry
@@ -26,7 +26,7 @@ Repository metadata is served over the AWS API on the gateway endpoint. Image da
The split explains the stubs below. The layer-transfer operations — `BatchCheckLayerAvailability`, `InitiateLayerUpload`, `UploadLayerPart`, `CompleteLayerUpload` and `GetDownloadUrlForLayer` — are registered stubs because the `/v2/` endpoint carries that traffic instead. A client using `docker` or any OCI-compatible tool never calls them.
Registry replication is a stub for the same kind of reason: a deployment is a single registry, with no cross-region peer to replicate to.
Registry replication is a stub for the same kind of reason: a deployment is a single registry, with no cross-region peer to replicate to. `DescribeRegistry` reports this as a replication configuration with no rules.
### Operations
@@ -39,6 +39,7 @@ Registry replication is a stub for the same kind of reason: a deployment is a si
| `DeleteRepository` |
| `DeleteRepositoryPolicy` |
| `DescribeImages` |
| `DescribeRegistry` |
| `DescribeRepositories` |
| `GetAuthorizationToken` |
| `GetLifecyclePolicy` |
+1 -1
View File
@@ -4,4 +4,4 @@ Repository metadata is served over the AWS API on the gateway endpoint. Image da
The split explains the stubs below. The layer-transfer operations — `BatchCheckLayerAvailability`, `InitiateLayerUpload`, `UploadLayerPart`, `CompleteLayerUpload` and `GetDownloadUrlForLayer` — are registered stubs because the `/v2/` endpoint carries that traffic instead. A client using `docker` or any OCI-compatible tool never calls them.
Registry replication is a stub for the same kind of reason: a deployment is a single registry, with no cross-region peer to replicate to.
Registry replication is a stub for the same kind of reason: a deployment is a single registry, with no cross-region peer to replicate to. `DescribeRegistry` reports this as a replication configuration with no rules.
+1 -8
View File
@@ -18,14 +18,13 @@ tags:
## Overview
Spinifex implements **34 operations** in the EKS `2017-11-01` API model.
Spinifex implements **27 operations** in the EKS `2017-11-01` API model.
### Operations
| Operation |
|---|
| `AssociateAccessPolicy` |
| `AssociateIdentityProviderConfig` |
| `CreateAccessEntry` |
| `CreateAddon` |
| `CreateCluster` |
@@ -38,23 +37,17 @@ Spinifex implements **34 operations** in the EKS `2017-11-01` API model.
| `DescribeAddon` |
| `DescribeAddonVersions` |
| `DescribeCluster` |
| `DescribeIdentityProviderConfig` |
| `DescribeNodegroup` |
| `DisassociateAccessPolicy` |
| `DisassociateIdentityProviderConfig` |
| `ListAccessEntries` |
| `ListAccessPolicies` |
| `ListAddons` |
| `ListAssociatedAccessPolicies` |
| `ListClusters` |
| `ListIdentityProviderConfigs` |
| `ListNodegroups` |
| `ListTagsForResource` |
| `TagResource` |
| `UntagResource` |
| `UpdateAccessEntry` |
| `UpdateAddon` |
| `UpdateClusterConfig` |
| `UpdateClusterVersion` |
| `UpdateNodegroupConfig` |
| `UpdateNodegroupVersion` |
+3 -1
View File
@@ -18,7 +18,7 @@ tags:
## Overview
Spinifex implements **34 operations** in the ELBv2 `2015-12-01` API model.
Spinifex implements **36 operations** in the ELBv2 `2015-12-01` API model.
### Two data planes
@@ -40,6 +40,7 @@ The data plane is a system-managed load balancer VM, launched automatically duri
| `DeleteTargetGroup` |
| `DeregisterTargets` |
| `DescribeAccountLimits` |
| `DescribeListenerAttributes` |
| `DescribeListenerCertificates` |
| `DescribeListeners` |
| `DescribeLoadBalancerAttributes` |
@@ -51,6 +52,7 @@ The data plane is a system-managed load balancer VM, launched automatically duri
| `DescribeTargetGroups` |
| `DescribeTargetHealth` |
| `ModifyListener` |
| `ModifyListenerAttributes` |
| `ModifyLoadBalancerAttributes` |
| `ModifyRule` |
| `ModifyTargetGroup` |
+6 -1
View File
@@ -18,7 +18,7 @@ tags:
## Overview
Spinifex implements **76 operations** in the IAM `2010-05-08` API model.
Spinifex implements **81 operations** in the IAM `2010-05-08` API model.
### Scope
@@ -38,6 +38,7 @@ All IAM operations are account-scoped. The root user of an account bypasses poli
| `CreateInstanceProfile` |
| `CreateOpenIDConnectProvider` |
| `CreatePolicy` |
| `CreatePolicyVersion` |
| `CreateRole` |
| `CreateUser` |
| `DeleteAccessKey` |
@@ -46,6 +47,7 @@ All IAM operations are account-scoped. The root user of an account bypasses poli
| `DeleteInstanceProfile` |
| `DeleteOpenIDConnectProvider` |
| `DeletePolicy` |
| `DeletePolicyVersion` |
| `DeleteRole` |
| `DeleteRolePolicy` |
| `DeleteUser` |
@@ -65,6 +67,7 @@ All IAM operations are account-scoped. The root user of an account bypasses poli
| `GetUser` |
| `GetUserPolicy` |
| `ListAccessKeys` |
| `ListAccountAliases` |
| `ListAttachedGroupPolicies` |
| `ListAttachedRolePolicies` |
| `ListAttachedUserPolicies` |
@@ -91,6 +94,7 @@ All IAM operations are account-scoped. The root user of an account bypasses poli
| `PutUserPolicy` |
| `RemoveRoleFromInstanceProfile` |
| `RemoveUserFromGroup` |
| `SetDefaultPolicyVersion` |
| `TagInstanceProfile` |
| `TagOpenIDConnectProvider` |
| `TagPolicy` |
@@ -104,3 +108,4 @@ All IAM operations are account-scoped. The root user of an account bypasses poli
| `UpdateAccessKey` |
| `UpdateAssumeRolePolicy` |
| `UpdateRole` |
| `UpdateRoleDescription` |
+314 -33
View File
@@ -4,29 +4,56 @@ 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: `aws-sdk-go v1.55.8`.
Pinned model source: `github.com/aws/api-models-aws@9b1a49292744, 2026-09-25`.
## acm
`2015-12-08` — 9 of 15 modelled operations implemented (60.0%), 0 stubbed, 0 not applicable, 0 registered outside the pinned model.
`2015-12-08` — 9 of 40 modelled operations implemented (22.5%), 0 stubbed, 0 not applicable, 0 registered outside the pinned model.
### Not implemented
- `CreateAcmeDomainValidation`
- `CreateAcmeEndpoint`
- `CreateAcmeExternalAccountBinding`
- `DeleteAcmeDomainValidation`
- `DeleteAcmeEndpoint`
- `DeleteAcmeExternalAccountBinding`
- `DescribeAcmeAccount`
- `DescribeAcmeDomainValidation`
- `DescribeAcmeEndpoint`
- `DescribeAcmeExternalAccountBinding`
- `ExportCertificate`
- `GetAccountConfiguration`
- `GetAcmeExternalAccountBindingCredentials`
- `ListAcmeAccounts`
- `ListAcmeDomainValidations`
- `ListAcmeEndpoints`
- `ListAcmeExternalAccountBindings`
- `ListCertificateDomainValidations`
- `ListTagsForResource`
- `PutAccountConfiguration`
- `RenewCertificate`
- `ResendValidationEmail`
- `RevokeAcmeAccount`
- `RevokeAcmeExternalAccountBinding`
- `RevokeCertificate`
- `SearchCertificates`
- `TagResource`
- `UntagResource`
- `UpdateAcmeDomainValidation`
- `UpdateAcmeEndpoint`
- `UpdateCertificateOptions`
## ec2
`2016-11-15` — 125 of 625 modelled operations implemented (20.0%), 0 stubbed, 39 not applicable, 0 registered outside the pinned model.
`2016-11-15` — 127 of 804 modelled operations implemented (15.8%), 0 stubbed, 39 not applicable, 0 registered outside the pinned model.
### Not implemented
- `AcceptAddressTransfer`
- `AcceptCapacityReservationBillingOwnership`
- `AcceptReservedInstancesExchangeQuote`
- `AcceptTransitGatewayClientVpnAttachment`
- `AcceptTransitGatewayMulticastDomainAssociations`
- `AcceptTransitGatewayPeeringAttachment`
- `AcceptTransitGatewayVpcAttachment`
@@ -39,6 +66,8 @@ Pinned model source: `aws-sdk-go v1.55.8`.
- `AssignIpv6Addresses`
- `AssignPrivateIpAddresses`
- `AssignPrivateNatGatewayAddress`
- `AssociateApplicationStatusCheck`
- `AssociateCapacityReservationBillingOwner`
- `AssociateClientVpnTargetNetwork`
- `AssociateDhcpOptions`
- `AssociateEnclaveCertificateIamRole`
@@ -46,23 +75,34 @@ Pinned model source: `aws-sdk-go v1.55.8`.
- `AssociateIpamByoasn`
- `AssociateIpamResourceDiscovery`
- `AssociateNatGatewayAddress`
- `AssociateRouteServer`
- `AssociateSecurityGroupVpc`
- `AssociateSubnetCidrBlock`
- `AssociateTransitGatewayMulticastDomain`
- `AssociateTransitGatewayPolicyTable`
- `AssociateTransitGatewayRouteTable`
- `AssociateTrunkInterface`
- `AssociateVpcCidrBlock`
- `AttachImageWatermark`
- `AttachVerifiedAccessTrustProvider`
- `AttachVpnGateway`
- `AuthorizeClientVpnIngress`
- `BatchModifyIpamRoutingPolicyRegistrations`
- `BundleInstance`
- `CancelBundleTask`
- `CancelCapacityReservationFleets`
- `CancelConversionTask`
- `CancelDeclarativePoliciesReport`
- `CancelExportTask`
- `CancelImageLaunchPermission`
- `CancelImportTask`
- `CancelSpotFleetRequests`
- `CopyVolumes`
- `CreateApplicationStatusCheck`
- `CreateCapacityManagerDataExport`
- `CreateCapacityReservationBySplitting`
- `CreateCapacityReservationCancellationQuote`
- `CreateCapacityReservationDateChangeQuote`
- `CreateCapacityReservationFleet`
- `CreateClientVpnEndpoint`
- `CreateClientVpnRoute`
@@ -71,21 +111,32 @@ Pinned model source: `aws-sdk-go v1.55.8`.
- `CreateCustomerGateway`
- `CreateDefaultSubnet`
- `CreateDefaultVpc`
- `CreateDelegateMacVolumeOwnershipTask`
- `CreateDhcpOptions`
- `CreateFleet`
- `CreateFlowLogs`
- `CreateImageUsageReport`
- `CreateInstanceConnectEndpoint`
- `CreateInstanceEventWindow`
- `CreateInstanceExportTask`
- `CreateInterruptibleCapacityReservationAllocation`
- `CreateIpam`
- `CreateIpamExternalResourceVerificationToken`
- `CreateIpamInternetRegistryAssociation`
- `CreateIpamPolicy`
- `CreateIpamPool`
- `CreateIpamPrefixListResolver`
- `CreateIpamPrefixListResolverTarget`
- `CreateIpamResourceDiscovery`
- `CreateIpamRoutingPolicyRegistration`
- `CreateIpamScope`
- `CreateLocalGatewayRoute`
- `CreateLocalGatewayRouteTable`
- `CreateLocalGatewayRouteTableVirtualInterfaceGroupAssociation`
- `CreateLocalGatewayRouteTableVpcAssociation`
- `CreateLocalGatewayVirtualInterface`
- `CreateLocalGatewayVirtualInterfaceGroup`
- `CreateMacSystemIntegrityProtectionModificationTask`
- `CreateManagedPrefixList`
- `CreateNetworkAcl`
- `CreateNetworkAclEntry`
@@ -95,6 +146,11 @@ Pinned model source: `aws-sdk-go v1.55.8`.
- `CreatePublicIpv4Pool`
- `CreateReplaceRootVolumeTask`
- `CreateRestoreImageTask`
- `CreateRouteServer`
- `CreateRouteServerEndpoint`
- `CreateRouteServerPeer`
- `CreateSecondaryNetwork`
- `CreateSecondarySubnet`
- `CreateSnapshots`
- `CreateSpotDatafeedSubscription`
- `CreateStoreImageTask`
@@ -106,9 +162,12 @@ Pinned model source: `aws-sdk-go v1.55.8`.
- `CreateTransitGateway`
- `CreateTransitGatewayConnect`
- `CreateTransitGatewayConnectPeer`
- `CreateTransitGatewayMeteringPolicy`
- `CreateTransitGatewayMeteringPolicyEntry`
- `CreateTransitGatewayMulticastDomain`
- `CreateTransitGatewayPeeringAttachment`
- `CreateTransitGatewayPolicyTable`
- `CreateTransitGatewayPolicyTableEntry`
- `CreateTransitGatewayPrefixListReference`
- `CreateTransitGatewayRoute`
- `CreateTransitGatewayRouteTable`
@@ -118,13 +177,18 @@ Pinned model source: `aws-sdk-go v1.55.8`.
- `CreateVerifiedAccessGroup`
- `CreateVerifiedAccessInstance`
- `CreateVerifiedAccessTrustProvider`
- `CreateVpcBlockPublicAccessExclusion`
- `CreateVpcEncryptionControl`
- `CreateVpcEndpoint`
- `CreateVpcEndpointConnectionNotification`
- `CreateVpcEndpointServiceConfiguration`
- `CreateVpcPeeringConnection`
- `CreateVpnConcentrator`
- `CreateVpnConnection`
- `CreateVpnConnectionRoute`
- `CreateVpnGateway`
- `DeleteApplicationStatusCheck`
- `DeleteCapacityManagerDataExport`
- `DeleteClientVpnEndpoint`
- `DeleteClientVpnRoute`
- `DeleteCoipCidr`
@@ -133,17 +197,25 @@ Pinned model source: `aws-sdk-go v1.55.8`.
- `DeleteDhcpOptions`
- `DeleteFleets`
- `DeleteFlowLogs`
- `DeleteImageUsageReport`
- `DeleteInstanceConnectEndpoint`
- `DeleteInstanceEventWindow`
- `DeleteIpam`
- `DeleteIpamExternalResourceVerificationToken`
- `DeleteIpamInternetRegistryAssociation`
- `DeleteIpamPolicy`
- `DeleteIpamPool`
- `DeleteIpamPrefixListResolver`
- `DeleteIpamPrefixListResolverTarget`
- `DeleteIpamResourceDiscovery`
- `DeleteIpamRoutingPolicyRegistration`
- `DeleteIpamScope`
- `DeleteLocalGatewayRoute`
- `DeleteLocalGatewayRouteTable`
- `DeleteLocalGatewayRouteTableVirtualInterfaceGroupAssociation`
- `DeleteLocalGatewayRouteTableVpcAssociation`
- `DeleteLocalGatewayVirtualInterface`
- `DeleteLocalGatewayVirtualInterfaceGroup`
- `DeleteManagedPrefixList`
- `DeleteNetworkAcl`
- `DeleteNetworkAclEntry`
@@ -154,6 +226,11 @@ Pinned model source: `aws-sdk-go v1.55.8`.
- `DeleteNetworkInterfacePermission`
- `DeletePublicIpv4Pool`
- `DeleteQueuedReservedInstances`
- `DeleteRouteServer`
- `DeleteRouteServerEndpoint`
- `DeleteRouteServerPeer`
- `DeleteSecondaryNetwork`
- `DeleteSecondarySubnet`
- `DeleteSpotDatafeedSubscription`
- `DeleteSubnetCidrReservation`
- `DeleteTrafficMirrorFilter`
@@ -161,11 +238,15 @@ Pinned model source: `aws-sdk-go v1.55.8`.
- `DeleteTrafficMirrorSession`
- `DeleteTrafficMirrorTarget`
- `DeleteTransitGateway`
- `DeleteTransitGatewayClientVpnAttachment`
- `DeleteTransitGatewayConnect`
- `DeleteTransitGatewayConnectPeer`
- `DeleteTransitGatewayMeteringPolicy`
- `DeleteTransitGatewayMeteringPolicyEntry`
- `DeleteTransitGatewayMulticastDomain`
- `DeleteTransitGatewayPeeringAttachment`
- `DeleteTransitGatewayPolicyTable`
- `DeleteTransitGatewayPolicyTableEntry`
- `DeleteTransitGatewayPrefixListReference`
- `DeleteTransitGatewayRoute`
- `DeleteTransitGatewayRouteTable`
@@ -175,10 +256,13 @@ Pinned model source: `aws-sdk-go v1.55.8`.
- `DeleteVerifiedAccessGroup`
- `DeleteVerifiedAccessInstance`
- `DeleteVerifiedAccessTrustProvider`
- `DeleteVpcBlockPublicAccessExclusion`
- `DeleteVpcEncryptionControl`
- `DeleteVpcEndpointConnectionNotifications`
- `DeleteVpcEndpointServiceConfigurations`
- `DeleteVpcEndpoints`
- `DeleteVpcPeeringConnection`
- `DeleteVpnConcentrator`
- `DeleteVpnConnection`
- `DeleteVpnConnectionRoute`
- `DeleteVpnGateway`
@@ -189,11 +273,24 @@ Pinned model source: `aws-sdk-go v1.55.8`.
- `DeregisterInstanceEventNotificationAttributes`
- `DeregisterTransitGatewayMulticastGroupMembers`
- `DeregisterTransitGatewayMulticastGroupSources`
- `DescribeAccountVpcEncryptionControl`
- `DescribeAddressTransfers`
- `DescribeApplicationStatus`
- `DescribeApplicationStatusCheckAssociations`
- `DescribeApplicationStatusChecks`
- `DescribeBundleTasks`
- `DescribeByoipCidrs`
- `DescribeCapacityBlockExtensionHistory`
- `DescribeCapacityBlockExtensionOfferings`
- `DescribeCapacityBlockOfferings`
- `DescribeCapacityBlockStatus`
- `DescribeCapacityBlocks`
- `DescribeCapacityManagerDataExports`
- `DescribeCapacityReservationBillingRequests`
- `DescribeCapacityReservationCancellationQuotes`
- `DescribeCapacityReservationDateChangeQuotes`
- `DescribeCapacityReservationFleets`
- `DescribeCapacityReservationTopology`
- `DescribeClientVpnAuthorizationRules`
- `DescribeClientVpnConnections`
- `DescribeClientVpnEndpoints`
@@ -202,6 +299,7 @@ Pinned model source: `aws-sdk-go v1.55.8`.
- `DescribeCoipPools`
- `DescribeConversionTasks`
- `DescribeCustomerGateways`
- `DescribeDeclarativePoliciesReports`
- `DescribeDhcpOptions`
- `DescribeExportImageTasks`
- `DescribeExportTasks`
@@ -214,15 +312,26 @@ Pinned model source: `aws-sdk-go v1.55.8`.
- `DescribeHostReservationOfferings`
- `DescribeHostReservations`
- `DescribeHosts`
- `DescribeImageReferences`
- `DescribeImageUsageReportEntries`
- `DescribeImageUsageReports`
- `DescribeImportImageTasks`
- `DescribeImportSnapshotTasks`
- `DescribeInstanceConnectEndpoints`
- `DescribeInstanceEventNotificationAttributes`
- `DescribeInstanceEventWindows`
- `DescribeInstanceImageMetadata`
- `DescribeInstanceSqlHaHistoryStates`
- `DescribeInstanceSqlHaStates`
- `DescribeInstanceTopology`
- `DescribeIpamByoasn`
- `DescribeIpamExternalResourceVerificationTokens`
- `DescribeIpamInternetRegistryAssociations`
- `DescribeIpamPolicies`
- `DescribeIpamPoolAllocations`
- `DescribeIpamPools`
- `DescribeIpamPrefixListResolverTargets`
- `DescribeIpamPrefixListResolvers`
- `DescribeIpamResourceDiscoveries`
- `DescribeIpamResourceDiscoveryAssociations`
- `DescribeIpamScopes`
@@ -236,6 +345,7 @@ Pinned model source: `aws-sdk-go v1.55.8`.
- `DescribeLocalGateways`
- `DescribeLockedSnapshots`
- `DescribeMacHosts`
- `DescribeMacModificationTasks`
- `DescribeManagedPrefixLists`
- `DescribeNetworkAcls`
- `DescribeNetworkInsightsAccessScopeAnalyses`
@@ -244,15 +354,24 @@ Pinned model source: `aws-sdk-go v1.55.8`.
- `DescribeNetworkInsightsPaths`
- `DescribeNetworkInterfaceAttribute`
- `DescribeNetworkInterfacePermissions`
- `DescribeOutpostLags`
- `DescribePrefixLists`
- `DescribePublicIpv4Pools`
- `DescribeReplaceRootVolumeTasks`
- `DescribeReservedInstances`
- `DescribeReservedInstancesModifications`
- `DescribeReservedInstancesOfferings`
- `DescribeRouteServerEndpoints`
- `DescribeRouteServerPeers`
- `DescribeRouteServers`
- `DescribeScheduledInstanceAvailability`
- `DescribeScheduledInstances`
- `DescribeSecondaryInterfaces`
- `DescribeSecondaryNetworks`
- `DescribeSecondarySubnets`
- `DescribeSecurityGroupReferences`
- `DescribeSecurityGroupVpcAssociations`
- `DescribeServiceLinkVirtualInterfaces`
- `DescribeSnapshotAttribute`
- `DescribeSnapshotTierStatus`
- `DescribeSpotDatafeedSubscription`
@@ -268,6 +387,7 @@ Pinned model source: `aws-sdk-go v1.55.8`.
- `DescribeTransitGatewayAttachments`
- `DescribeTransitGatewayConnectPeers`
- `DescribeTransitGatewayConnects`
- `DescribeTransitGatewayMeteringPolicies`
- `DescribeTransitGatewayMulticastDomains`
- `DescribeTransitGatewayPeeringAttachments`
- `DescribeTransitGatewayPolicyTables`
@@ -282,34 +402,49 @@ Pinned model source: `aws-sdk-go v1.55.8`.
- `DescribeVerifiedAccessInstances`
- `DescribeVerifiedAccessTrustProviders`
- `DescribeVolumeAttribute`
- `DescribeVpcBlockPublicAccessExclusions`
- `DescribeVpcBlockPublicAccessOptions`
- `DescribeVpcEncryptionControls`
- `DescribeVpcEndpointAssociations`
- `DescribeVpcEndpointConnectionNotifications`
- `DescribeVpcEndpointConnections`
- `DescribeVpcEndpointServiceConfigurations`
- `DescribeVpcEndpointServicePermissions`
- `DescribeVpcEndpointServices`
- `DescribeVpcEndpoints`
- `DescribeVpcPeeringConnections`
- `DescribeVpnConcentrators`
- `DescribeVpnConnections`
- `DescribeVpnGateways`
- `DetachImageWatermark`
- `DetachVerifiedAccessTrustProvider`
- `DetachVpnGateway`
- `DisableAddressTransfer`
- `DisableAllowedImagesSettings`
- `DisableApplicationStatusCheckSuppression`
- `DisableCapacityManager`
- `DisableFastLaunch`
- `DisableFastSnapshotRestores`
- `DisableImage`
- `DisableImageBlockPublicAccess`
- `DisableImageDeprecation`
- `DisableImageDeregistrationProtection`
- `DisableInstanceSqlHaStandbyDetections`
- `DisableIpamOrganizationAdminAccount`
- `DisableIpamPolicy`
- `DisableRouteServerPropagation`
- `DisableSnapshotBlockPublicAccess`
- `DisableTransitGatewayRouteTablePropagation`
- `DisableVgwRoutePropagation`
- `DisassociateApplicationStatusCheck`
- `DisassociateCapacityReservationBillingOwner`
- `DisassociateClientVpnTargetNetwork`
- `DisassociateEnclaveCertificateIamRole`
- `DisassociateInstanceEventWindow`
- `DisassociateIpamByoasn`
- `DisassociateIpamResourceDiscovery`
- `DisassociateNatGatewayAddress`
- `DisassociateRouteServer`
- `DisassociateSecurityGroupVpc`
- `DisassociateSubnetCidrBlock`
- `DisassociateTransitGatewayMulticastDomain`
- `DisassociateTransitGatewayPolicyTable`
@@ -317,14 +452,21 @@ Pinned model source: `aws-sdk-go v1.55.8`.
- `DisassociateTrunkInterface`
- `DisassociateVpcCidrBlock`
- `EnableAddressTransfer`
- `EnableAllowedImagesSettings`
- `EnableApplicationStatusCheckSuppression`
- `EnableCapacityManager`
- `EnableFastLaunch`
- `EnableFastSnapshotRestores`
- `EnableImage`
- `EnableImageBlockPublicAccess`
- `EnableImageDeprecation`
- `EnableImageDeregistrationProtection`
- `EnableInstanceSqlHaStandbyDetections`
- `EnableIpamInternetRegistryAssociation`
- `EnableIpamOrganizationAdminAccount`
- `EnableIpamPolicy`
- `EnableReachabilityAnalyzerOrganizationSharing`
- `EnableRouteServerPropagation`
- `EnableSnapshotBlockPublicAccess`
- `EnableTransitGatewayRouteTablePropagation`
- `EnableVgwRoutePropagation`
@@ -333,16 +475,26 @@ Pinned model source: `aws-sdk-go v1.55.8`.
- `ExportClientVpnClientConfiguration`
- `ExportImage`
- `ExportTransitGatewayRoutes`
- `ExportVerifiedAccessInstanceClientConfiguration`
- `GetActiveVpnTunnelStatus`
- `GetAllowedImagesSettings`
- `GetAssociatedEnclaveCertificateIamRoles`
- `GetAssociatedIpv6PoolCidrs`
- `GetCapacityManagerAttributes`
- `GetCapacityManagerMetricData`
- `GetCapacityManagerMetricDimensions`
- `GetCapacityManagerMonitoredTagKeys`
- `GetCapacityReservationUsage`
- `GetCoipPoolUsage`
- `GetConsoleScreenshot`
- `GetDeclarativePoliciesReportSummary`
- `GetDefaultCreditSpecification`
- `GetEbsDefaultKmsKeyId`
- `GetEnabledIpamPolicy`
- `GetFlowLogsIntegrationTemplate`
- `GetGroupsForCapacityReservation`
- `GetHostReservationPurchasePreview`
- `GetImageAncestry`
- `GetImageBlockPublicAccessState`
- `GetInstanceMetadataDefaults`
- `GetInstanceTpmEkPub`
@@ -352,19 +504,36 @@ Pinned model source: `aws-sdk-go v1.55.8`.
- `GetIpamDiscoveredAccounts`
- `GetIpamDiscoveredPublicAddresses`
- `GetIpamDiscoveredResourceCidrs`
- `GetIpamDiscoveredRoutes`
- `GetIpamInternetRegistryAssociationAsns`
- `GetIpamInternetRegistryAssociationCidrs`
- `GetIpamPolicyAllocationRules`
- `GetIpamPolicyOrganizationTargets`
- `GetIpamPoolAllocations`
- `GetIpamPoolCidrs`
- `GetIpamPrefixListResolverRules`
- `GetIpamPrefixListResolverVersionEntries`
- `GetIpamPrefixListResolverVersions`
- `GetIpamResourceCidrs`
- `GetIpamRouteOriginAuthorizations`
- `GetIpamRouteProtectionFindings`
- `GetIpamRoutingPolicyRegistrationDeltas`
- `GetIpamRoutingPolicyRegistrations`
- `GetLaunchTemplateData`
- `GetManagedPrefixListAssociations`
- `GetManagedPrefixListEntries`
- `GetManagedResourceVisibility`
- `GetNetworkInsightsAccessScopeAnalysisFindings`
- `GetNetworkInsightsAccessScopeContent`
- `GetReservedInstancesExchangeQuote`
- `GetRouteServerAssociations`
- `GetRouteServerPropagations`
- `GetRouteServerRoutingDatabase`
- `GetSnapshotBlockPublicAccessState`
- `GetSpotPlacementScores`
- `GetSubnetCidrReservations`
- `GetTransitGatewayAttachmentPropagations`
- `GetTransitGatewayMeteringPolicyEntries`
- `GetTransitGatewayMulticastDomainAssociations`
- `GetTransitGatewayPolicyTableAssociations`
- `GetTransitGatewayPolicyTableEntries`
@@ -372,7 +541,9 @@ Pinned model source: `aws-sdk-go v1.55.8`.
- `GetTransitGatewayRouteTableAssociations`
- `GetTransitGatewayRouteTablePropagations`
- `GetVerifiedAccessEndpointPolicy`
- `GetVerifiedAccessEndpointTargets`
- `GetVerifiedAccessGroupPolicy`
- `GetVpcResourcesBlockingEncryptionEnforcement`
- `GetVpnConnectionDeviceSampleConfiguration`
- `GetVpnConnectionDeviceTypes`
- `GetVpnTunnelReplacementStatus`
@@ -383,8 +554,11 @@ Pinned model source: `aws-sdk-go v1.55.8`.
- `ImportVolume`
- `ListImagesInRecycleBin`
- `ListSnapshotsInRecycleBin`
- `ListVolumesInRecycleBin`
- `LockSnapshot`
- `ModifyAccountVpcEncryptionControl`
- `ModifyAddressAttribute`
- `ModifyApplicationStatusCheck`
- `ModifyAvailabilityZoneGroup`
- `ModifyCapacityReservation`
- `ModifyCapacityReservationFleet`
@@ -394,22 +568,32 @@ Pinned model source: `aws-sdk-go v1.55.8`.
- `ModifyFleet`
- `ModifyHosts`
- `ModifyInstanceCapacityReservationAttributes`
- `ModifyInstanceConnectEndpoint`
- `ModifyInstanceCpuOptions`
- `ModifyInstanceCreditSpecification`
- `ModifyInstanceEventStartTime`
- `ModifyInstanceEventWindow`
- `ModifyInstanceMaintenanceOptions`
- `ModifyInstanceMetadataDefaults`
- `ModifyInstanceNetworkPerformanceOptions`
- `ModifyInstancePlacement`
- `ModifyIpam`
- `ModifyIpamPolicyAllocationRules`
- `ModifyIpamPool`
- `ModifyIpamPoolAllocation`
- `ModifyIpamPrefixListResolver`
- `ModifyIpamPrefixListResolverTarget`
- `ModifyIpamResourceCidr`
- `ModifyIpamResourceDiscovery`
- `ModifyIpamRoutingPolicyRegistration`
- `ModifyIpamScope`
- `ModifyLocalGatewayRoute`
- `ModifyManagedPrefixList`
- `ModifyManagedResourceVisibility`
- `ModifyPrivateDnsNameOptions`
- `ModifyPublicIpDnsNameOptions`
- `ModifyReservedInstances`
- `ModifySecurityGroupRules`
- `ModifyRouteServer`
- `ModifySnapshotAttribute`
- `ModifySnapshotTier`
- `ModifySpotFleetRequest`
@@ -417,6 +601,8 @@ Pinned model source: `aws-sdk-go v1.55.8`.
- `ModifyTrafficMirrorFilterRule`
- `ModifyTrafficMirrorSession`
- `ModifyTransitGateway`
- `ModifyTransitGatewayMeteringPolicy`
- `ModifyTransitGatewayPolicyTableEntry`
- `ModifyTransitGatewayPrefixListReference`
- `ModifyTransitGatewayVpcAttachment`
- `ModifyVerifiedAccessEndpoint`
@@ -427,8 +613,12 @@ Pinned model source: `aws-sdk-go v1.55.8`.
- `ModifyVerifiedAccessInstanceLoggingConfiguration`
- `ModifyVerifiedAccessTrustProvider`
- `ModifyVolumeAttribute`
- `ModifyVpcBlockPublicAccessExclusion`
- `ModifyVpcBlockPublicAccessOptions`
- `ModifyVpcEncryptionControl`
- `ModifyVpcEndpoint`
- `ModifyVpcEndpointConnectionNotification`
- `ModifyVpcEndpointPayerResponsibility`
- `ModifyVpcEndpointServiceConfiguration`
- `ModifyVpcEndpointServicePayerResponsibility`
- `ModifyVpcEndpointServicePermissions`
@@ -439,17 +629,21 @@ Pinned model source: `aws-sdk-go v1.55.8`.
- `ModifyVpnTunnelCertificate`
- `ModifyVpnTunnelOptions`
- `MoveByoipCidrToIpam`
- `MoveCapacityReservationInstances`
- `ProvisionByoipCidr`
- `ProvisionIpamByoasn`
- `ProvisionIpamPoolCidr`
- `ProvisionPublicIpv4PoolCidr`
- `PurchaseCapacityBlock`
- `PurchaseCapacityBlockExtension`
- `PurchaseHostReservation`
- `PurchaseReservedInstancesOffering`
- `PurchaseScheduledInstances`
- `RegisterInstanceEventNotificationAttributes`
- `RegisterTransitGatewayMulticastGroupMembers`
- `RegisterTransitGatewayMulticastGroupSources`
- `RejectCapacityReservationBillingOwnership`
- `RejectTransitGatewayClientVpnAttachment`
- `RejectTransitGatewayMulticastDomainAssociations`
- `RejectTransitGatewayPeeringAttachment`
- `RejectTransitGatewayVpcAttachment`
@@ -457,6 +651,8 @@ Pinned model source: `aws-sdk-go v1.55.8`.
- `RejectVpcPeeringConnection`
- `ReleaseHosts`
- `ReleaseIpamPoolAllocation`
- `ReplaceImageCriteriaInAllowedImagesSettings`
- `ReplaceImageInstanceTypeSpecification`
- `ReplaceNetworkAclAssociation`
- `ReplaceNetworkAclEntry`
- `ReplaceTransitGatewayRoute`
@@ -471,12 +667,14 @@ Pinned model source: `aws-sdk-go v1.55.8`.
- `RestoreManagedPrefixListVersion`
- `RestoreSnapshotFromRecycleBin`
- `RestoreSnapshotTier`
- `RestoreVolumeFromRecycleBin`
- `RevokeClientVpnIngress`
- `RunScheduledInstances`
- `SearchLocalGatewayRoutes`
- `SearchTransitGatewayMulticastGroups`
- `SearchTransitGatewayRoutes`
- `SendDiagnosticInterrupt`
- `StartDeclarativePoliciesReport`
- `StartNetworkInsightsAccessScopeAnalysis`
- `StartNetworkInsightsAnalysis`
- `StartVpcEndpointServicePrivateDnsVerification`
@@ -485,6 +683,10 @@ Pinned model source: `aws-sdk-go v1.55.8`.
- `UnassignPrivateIpAddresses`
- `UnassignPrivateNatGatewayAddress`
- `UnlockSnapshot`
- `UpdateCapacityManagerMonitoredTagKeys`
- `UpdateCapacityManagerOrganizationsAccess`
- `UpdateInterruptibleCapacityReservationAllocation`
- `ValidateSecurityGroupQuotasForInterface`
- `WithdrawByoipCidr`
### Not applicable — do not build
@@ -560,7 +762,7 @@ spot-price: On owned hardware there is no spot-to-on-demand price differential,
## ecr
`2015-09-21` — 22 of 47 modelled operations implemented (46.8%), 11 stubbed, 0 not applicable, 3 registered outside the pinned model.
`2015-09-21` — 23 of 58 modelled operations implemented (39.7%), 10 stubbed, 0 not applicable, 3 registered outside the pinned model.
### Not implemented
@@ -569,9 +771,20 @@ spot-price: On owned hardware there is no spot-to-on-demand price differential,
- `DeletePullThroughCacheRule`
- `DeleteRegistryPolicy`
- `DeleteRepositoryCreationTemplate`
- `DeleteSigningConfiguration`
- `DeregisterPullTimeUpdateExclusion`
- `DescribeImageReplicationStatus`
- `DescribeImageSigningStatus`
- `DescribePullThroughCacheRules`
- `DescribeRepositoryCreationTemplates`
- `GetAccountSetting`
- `GetSigningConfiguration`
- `ListImageReferrers`
- `ListPullTimeUpdateExclusions`
- `PutAccountSetting`
- `PutSigningConfiguration`
- `RegisterPullTimeUpdateExclusion`
- `UpdateImageStorageClass`
- `UpdatePullThroughCacheRule`
- `UpdateRepositoryCreationTemplate`
- `ValidatePullThroughCacheRule`
@@ -580,7 +793,6 @@ spot-price: On owned hardware there is no spot-to-on-demand price differential,
- `BatchCheckLayerAvailability`
- `CompleteLayerUpload`
- `DescribeRegistry`
- `GetDownloadUrlForLayer`
- `GetRegistryPolicy`
- `InitiateLayerUpload`
@@ -607,27 +819,48 @@ spot-price: On owned hardware there is no spot-to-on-demand price differential,
## ecs
`2014-11-13` — 31 of 56 modelled operations implemented (55.4%), 5 stubbed, 0 not applicable, 3 registered outside the pinned model.
`2014-11-13` — 31 of 77 modelled operations implemented (40.3%), 5 stubbed, 0 not applicable, 3 registered outside the pinned model.
### Not implemented
- `ContinueServiceDeployment`
- `CreateDaemon`
- `CreateExpressGatewayService`
- `CreateTaskSet`
- `DeleteAccountSetting`
- `DeleteAttributes`
- `DeleteDaemon`
- `DeleteDaemonTaskDefinition`
- `DeleteExpressGatewayService`
- `DeleteTaskDefinitions`
- `DeleteTaskSet`
- `DescribeDaemon`
- `DescribeDaemonDeployments`
- `DescribeDaemonRevisions`
- `DescribeDaemonTaskDefinition`
- `DescribeExpressGatewayService`
- `DescribeServiceDeployments`
- `DescribeServiceRevisions`
- `DescribeTaskSets`
- `DiscoverPollEndpoint`
- `ExecuteCommand`
- `GetTaskProtection`
- `ListAttributes`
- `ListDaemonDeployments`
- `ListDaemonTaskDefinitions`
- `ListDaemons`
- `ListServiceDeployments`
- `PutAccountSettingDefault`
- `PutAttributes`
- `RegisterDaemonTaskDefinition`
- `StopServiceDeployment`
- `SubmitAttachmentStateChanges`
- `SubmitContainerStateChange`
- `UpdateCapacityProvider`
- `UpdateClusterSettings`
- `UpdateContainerAgent`
- `UpdateDaemon`
- `UpdateExpressGatewayService`
- `UpdateServicePrimaryTaskSet`
- `UpdateTaskProtection`
- `UpdateTaskSet`
@@ -648,31 +881,52 @@ spot-price: On owned hardware there is no spot-to-on-demand price differential,
## eks
`2017-11-01` — 34 of 56 modelled operations implemented (60.7%), 0 stubbed, 0 not applicable, 4 registered outside the pinned model.
`2017-11-01` — 27 of 70 modelled operations implemented (38.6%), 0 stubbed, 0 not applicable, 4 registered outside the pinned model.
### Not implemented
- `ActivateCertificateAuthority`
- `AssociateEncryptionConfig`
- `AssociateIdentityProviderConfig`
- `CancelUpdate`
- `CreateCapability`
- `CreateCertificateAuthority`
- `CreateEksAnywhereSubscription`
- `CreateFargateProfile`
- `CreatePodIdentityAssociation`
- `DeleteCapability`
- `DeleteCertificateAuthority`
- `DeleteEksAnywhereSubscription`
- `DeleteFargateProfile`
- `DeletePodIdentityAssociation`
- `DeregisterCluster`
- `DescribeAddonConfiguration`
- `DescribeCapability`
- `DescribeCertificateAuthority`
- `DescribeClusterVersions`
- `DescribeEksAnywhereSubscription`
- `DescribeFargateProfile`
- `DescribeIdentityProviderConfig`
- `DescribeInsight`
- `DescribeInsightsRefresh`
- `DescribePodIdentityAssociation`
- `DescribeUpdate`
- `DisassociateIdentityProviderConfig`
- `ListCapabilities`
- `ListCertificateAuthorities`
- `ListEksAnywhereSubscriptions`
- `ListFargateProfiles`
- `ListIdentityProviderConfigs`
- `ListInsights`
- `ListPodIdentityAssociations`
- `ListUpdates`
- `RegisterCluster`
- `StartInsightsRefresh`
- `UpdateCapability`
- `UpdateClusterConfig`
- `UpdateClusterVersion`
- `UpdateEksAnywhereSubscription`
- `UpdateNodegroupVersion`
- `UpdatePodIdentityAssociation`
### Registered outside the pinned model
@@ -684,7 +938,7 @@ spot-price: On owned hardware there is no spot-to-on-demand price differential,
## elasticloadbalancingv2
`2015-12-01` — 34 of 46 modelled operations implemented (73.9%), 0 stubbed, 0 not applicable, 4 registered outside the pinned model.
`2015-12-01` — 36 of 51 modelled operations implemented (70.6%), 0 stubbed, 0 not applicable, 2 registered outside the pinned model.
### Not implemented
@@ -692,74 +946,91 @@ spot-price: On owned hardware there is no spot-to-on-demand price differential,
- `CreateTrustStore`
- `DeleteSharedTrustStoreAssociation`
- `DeleteTrustStore`
- `DescribeCapacityReservation`
- `DescribeTrustStoreAssociations`
- `DescribeTrustStoreRevocations`
- `DescribeTrustStores`
- `GetResourcePolicy`
- `GetTrustStoreCaCertificatesBundle`
- `GetTrustStoreRevocationContent`
- `ModifyCapacityReservation`
- `ModifyIpPools`
- `ModifyTrustStore`
- `RemoveTrustStoreRevocations`
### Registered outside the pinned model
- `DescribeListenerAttributes`
- `GetLBConfig`
- `LBAgentHeartbeat`
- `ModifyListenerAttributes`
## iam
`2010-05-08` — 76 of 159 modelled operations implemented (47.8%), 0 stubbed, 19 not applicable, 0 registered outside the pinned model.
`2010-05-08` — 81 of 180 modelled operations implemented (45.0%), 0 stubbed, 19 not applicable, 0 registered outside the pinned model.
### Not implemented
- `AcceptDelegationRequest`
- `AcquireRole`
- `AddClientIDToOpenIDConnectProvider`
- `AssociateDelegationRequest`
- `ChangePassword`
- `CreateAccountAlias`
- `CreateDelegationRequest`
- `CreateLoginProfile`
- `CreatePolicyVersion`
- `CreateSAMLProvider`
- `CreateVirtualMFADevice`
- `DeactivateMFADevice`
- `DeleteAccountAlias`
- `DeleteAccountPasswordPolicy`
- `DeleteLoginProfile`
- `DeletePolicyVersion`
- `DeleteRolePermissionsBoundary`
- `DeleteSAMLProvider`
- `DeleteServerCertificate`
- `DeleteUserPermissionsBoundary`
- `DeleteVirtualMFADevice`
- `DisableOrganizationsRootCredentialsManagement`
- `DisableOrganizationsRootSessions`
- `DisableOutboundWebIdentityFederation`
- `EnableMFADevice`
- `EnableOrganizationsRootCredentialsManagement`
- `EnableOrganizationsRootSessions`
- `EnableOutboundWebIdentityFederation`
- `GenerateCredentialReport`
- `GenerateServiceLastAccessedDetails`
- `GetAccessKeyLastUsed`
- `GetAccountAuthorizationDetails`
- `GetAccountPasswordPolicy`
- `GetAccountProperties`
- `GetContextKeysForCustomPolicy`
- `GetContextKeysForPrincipalPolicy`
- `GetCredentialReport`
- `GetDelegationRequest`
- `GetHumanReadableSummary`
- `GetLoginProfile`
- `GetMFADevice`
- `GetOutboundWebIdentityFederationInfo`
- `GetRoleTemplateVersion`
- `GetSAMLProvider`
- `GetServerCertificate`
- `GetServiceLastAccessedDetails`
- `GetServiceLastAccessedDetailsWithEntities`
- `ListAccountAliases`
- `ListDelegationRequests`
- `ListMFADeviceTags`
- `ListMFADevices`
- `ListOrganizationsFeatures`
- `ListPoliciesGrantingServiceAccess`
- `ListSAMLProviderTags`
- `ListSAMLProviders`
- `ListServerCertificateTags`
- `ListServerCertificates`
- `ListVirtualMFADevices`
- `PutAccountProperties`
- `PutRolePermissionsBoundary`
- `PutUserPermissionsBoundary`
- `RejectDelegationRequest`
- `RemoveClientIDFromOpenIDConnectProvider`
- `ResyncMFADevice`
- `SetDefaultPolicyVersion`
- `SendDelegationToken`
- `SetSecurityTokenServicePreferences`
- `SimulateCustomPolicy`
- `SimulatePrincipalPolicy`
@@ -770,10 +1041,10 @@ spot-price: On owned hardware there is no spot-to-on-demand price differential,
- `UntagSAMLProvider`
- `UntagServerCertificate`
- `UpdateAccountPasswordPolicy`
- `UpdateDelegationRequest`
- `UpdateGroup`
- `UpdateLoginProfile`
- `UpdateOpenIDConnectProviderThumbprint`
- `UpdateRoleDescription`
- `UpdateSAMLProvider`
- `UpdateServerCertificate`
- `UpdateUser`
@@ -814,7 +1085,7 @@ signing-certificates: X.509 signing certificates are an EC2-Classic SOAP credent
## rds
`2014-10-31` — 26 of 162 modelled operations implemented (16.0%), 0 stubbed, 4 not applicable, 5 registered outside the pinned model.
`2014-10-31` — 27 of 164 modelled operations implemented (16.5%), 0 stubbed, 4 not applicable, 5 registered outside the pinned model.
### Not implemented
@@ -870,6 +1141,7 @@ signing-certificates: X.509 signing certificates are an EC2-Classic SOAP credent
- `DescribeDBClusterSnapshotAttributes`
- `DescribeDBClusterSnapshots`
- `DescribeDBLogFiles`
- `DescribeDBMajorEngineVersions`
- `DescribeDBProxies`
- `DescribeDBProxyEndpoints`
- `DescribeDBProxyTargetGroups`
@@ -890,6 +1162,7 @@ signing-certificates: X.509 signing certificates are an EC2-Classic SOAP credent
- `DescribePendingMaintenanceActions`
- `DescribeReservedDBInstances`
- `DescribeReservedDBInstancesOfferings`
- `DescribeServerlessV2PlatformVersions`
- `DescribeSourceRegions`
- `DescribeTenantDatabases`
- `DescribeValidDBInstanceModifications`
@@ -911,7 +1184,6 @@ signing-certificates: X.509 signing certificates are an EC2-Classic SOAP credent
- `ModifyDBShardGroup`
- `ModifyDBSnapshot`
- `ModifyDBSnapshotAttribute`
- `ModifyDBSubnetGroup`
- `ModifyEventSubscription`
- `ModifyGlobalCluster`
- `ModifyIntegration`
@@ -973,45 +1245,48 @@ option-groups: Option groups configure engine add-ons for engines this platform
## s3
`2006-03-01` — 19 of 99 modelled operations implemented (19.2%), 0 stubbed, 6 not applicable, 0 registered outside the pinned model.
`2006-03-01` — 26 of 112 modelled operations implemented (23.2%), 0 stubbed, 6 not applicable, 0 registered outside the pinned model.
### Not implemented
- `CreateBucketMetadataConfiguration`
- `CreateBucketMetadataTableConfiguration`
- `DeleteBucketAnalyticsConfiguration`
- `DeleteBucketCors`
- `DeleteBucketEncryption`
- `DeleteBucketIntelligentTieringConfiguration`
- `DeleteBucketInventoryConfiguration`
- `DeleteBucketLifecycle`
- `DeleteBucketMetadataConfiguration`
- `DeleteBucketMetadataTableConfiguration`
- `DeleteBucketMetricsConfiguration`
- `DeleteBucketOwnershipControls`
- `DeleteBucketPolicy`
- `DeleteBucketReplication`
- `DeleteBucketTagging`
- `DeleteBucketWebsite`
- `DeleteObjectAnnotation`
- `DeleteObjectTagging`
- `DeletePublicAccessBlock`
- `GetBucketAbac`
- `GetBucketAcl`
- `GetBucketAnalyticsConfiguration`
- `GetBucketCors`
- `GetBucketEncryption`
- `GetBucketIntelligentTieringConfiguration`
- `GetBucketInventoryConfiguration`
- `GetBucketLifecycle`
- `GetBucketLifecycleConfiguration`
- `GetBucketLocation`
- `GetBucketLogging`
- `GetBucketMetadataConfiguration`
- `GetBucketMetadataTableConfiguration`
- `GetBucketMetricsConfiguration`
- `GetBucketNotification`
- `GetBucketNotificationConfiguration`
- `GetBucketOwnershipControls`
- `GetBucketPolicy`
- `GetBucketPolicyStatus`
- `GetBucketReplication`
- `GetBucketTagging`
- `GetBucketVersioning`
- `GetBucketWebsite`
- `GetObjectAcl`
- `GetObjectAnnotation`
- `GetObjectAttributes`
- `GetObjectLegalHold`
- `GetObjectLockConfiguration`
@@ -1024,33 +1299,36 @@ option-groups: Option groups configure engine add-ons for engines this platform
- `ListBucketInventoryConfigurations`
- `ListBucketMetricsConfigurations`
- `ListDirectoryBuckets`
- `ListObjectVersions`
- `ListObjectAnnotations`
- `PutBucketAbac`
- `PutBucketAcl`
- `PutBucketAnalyticsConfiguration`
- `PutBucketCors`
- `PutBucketEncryption`
- `PutBucketIntelligentTieringConfiguration`
- `PutBucketInventoryConfiguration`
- `PutBucketLifecycle`
- `PutBucketLifecycleConfiguration`
- `PutBucketLogging`
- `PutBucketMetricsConfiguration`
- `PutBucketNotification`
- `PutBucketNotificationConfiguration`
- `PutBucketOwnershipControls`
- `PutBucketPolicy`
- `PutBucketReplication`
- `PutBucketTagging`
- `PutBucketVersioning`
- `PutBucketWebsite`
- `PutObjectAcl`
- `PutObjectAnnotation`
- `PutObjectLegalHold`
- `PutObjectLockConfiguration`
- `PutObjectRetention`
- `PutObjectTagging`
- `PutPublicAccessBlock`
- `RenameObject`
- `RestoreObject`
- `SelectObjectContent`
- `UpdateBucketMetadataAnnotationTableConfiguration`
- `UpdateBucketMetadataInventoryTableConfiguration`
- `UpdateBucketMetadataJournalTableConfiguration`
- `UpdateObjectEncryption`
### Not applicable — do not build
@@ -1074,11 +1352,14 @@ request-payment: Requester Pays shifts transfer charges to the caller, which nee
## sts
`2011-06-15` — 5 of 8 modelled operations implemented (62.5%), 0 stubbed, 0 not applicable, 0 registered outside the pinned model.
`2011-06-15` — 5 of 11 modelled operations implemented (45.5%), 0 stubbed, 0 not applicable, 0 registered outside the pinned model.
### Not implemented
- `AssumeRoleWithSAML`
- `AssumeRoot`
- `DecodeAuthorizationMessage`
- `GetDelegatedAccessToken`
- `GetFederationToken`
- `GetWebIdentityToken`
+5 -2
View File
@@ -19,7 +19,7 @@ tags:
## Overview
Spinifex implements **26 operations** in the RDS `2014-10-31` API model.
Spinifex implements **27 operations** in the RDS `2014-10-31` API model.
### Engines
@@ -54,7 +54,7 @@ A parameter whose omission would create a false safety, security or availability
| `DBSecurityGroups` | EC2-Classic security groups — use `VpcSecurityGroupIds` |
| `DBClusterIdentifier`, `DBClusterSnapshotIdentifier` | Clustered engines are not offered |
| `EnableCloudwatchLogsExports` | Log export is not implemented |
| `EngineVersion` other than the engine's pin, `Engine` on modify | No in-place engine or version change |
| `EngineVersion` other than the engine's pin; on modify, any `EngineVersion` and `Engine` | No in-place engine or version change |
| `Engine=mysql` (and Aurora engines) | Oracle MySQL is not offered; `mariadb` is a distinct engine, not an alias for it |
| `NewDBInstanceIdentifier` | The identifier is the DNS label and the KV key |
| `DBPortNumber`, `DBSubnetGroupName` on modify | Both would move the endpoint |
@@ -67,6 +67,8 @@ A parameter whose omission would create a false safety, security or availability
| `EnableCustomerOwnedIp` | An Outposts feature |
| `ForceFailover` (reboot) | No standby to fail over to |
| `DBSnapshotIdentifier` (stop) | Snapshot-on-stop is not implemented |
| `DBInstanceAutomatedBackupsArn` (DescribeDBInstanceAutomatedBackups) | Cross-region automated backup replication is not offered, so no replicated backup has an ARN |
| `dbi-resource-id` filter (DescribeDBSnapshots, DescribeDBInstanceAutomatedBackups) | Snapshots and automated backups do not record their source instance's resource ID |
### Operations
@@ -93,6 +95,7 @@ A parameter whose omission would create a false safety, security or availability
| `ListTagsForResource` |
| `ModifyDBInstance` |
| `ModifyDBParameterGroup` |
| `ModifyDBSubnetGroup` |
| `RebootDBInstance` |
| `RemoveTagsFromResource` |
| `RestoreDBInstanceFromDBSnapshot` |
+3 -1
View File
@@ -31,7 +31,7 @@ A parameter whose omission would create a false safety, security or availability
| `DBSecurityGroups` | EC2-Classic security groups — use `VpcSecurityGroupIds` |
| `DBClusterIdentifier`, `DBClusterSnapshotIdentifier` | Clustered engines are not offered |
| `EnableCloudwatchLogsExports` | Log export is not implemented |
| `EngineVersion` other than the engine's pin, `Engine` on modify | No in-place engine or version change |
| `EngineVersion` other than the engine's pin; on modify, any `EngineVersion` and `Engine` | No in-place engine or version change |
| `Engine=mysql` (and Aurora engines) | Oracle MySQL is not offered; `mariadb` is a distinct engine, not an alias for it |
| `NewDBInstanceIdentifier` | The identifier is the DNS label and the KV key |
| `DBPortNumber`, `DBSubnetGroupName` on modify | Both would move the endpoint |
@@ -44,3 +44,5 @@ A parameter whose omission would create a false safety, security or availability
| `EnableCustomerOwnedIp` | An Outposts feature |
| `ForceFailover` (reboot) | No standby to fail over to |
| `DBSnapshotIdentifier` (stop) | Snapshot-on-stop is not implemented |
| `DBInstanceAutomatedBackupsArn` (DescribeDBInstanceAutomatedBackups) | Cross-region automated backup replication is not offered, so no replicated backup has an ARN |
| `dbi-resource-id` filter (DescribeDBSnapshots, DescribeDBInstanceAutomatedBackups) | Snapshots and automated backups do not record their source instance's resource ID |
+8 -1
View File
@@ -18,7 +18,7 @@ tags:
## Overview
Predastore implements **19 operations** in the S3 `2006-03-01` API model.
Predastore implements **26 operations** in the S3 `2006-03-01` API model.
### Predastore serves this endpoint
@@ -40,16 +40,23 @@ That behaviour is measured separately, against the `ceph/s3-tests` suite Ceph RG
| `CreateBucket` |
| `CreateMultipartUpload` |
| `DeleteBucket` |
| `DeleteBucketTagging` |
| `DeleteObject` |
| `DeleteObjects` |
| `GetBucketLocation` |
| `GetBucketTagging` |
| `GetBucketVersioning` |
| `GetObject` |
| `HeadBucket` |
| `HeadObject` |
| `ListBuckets` |
| `ListMultipartUploads` |
| `ListObjectVersions` |
| `ListObjects` |
| `ListObjectsV2` |
| `ListParts` |
| `PutBucketTagging` |
| `PutBucketVersioning` |
| `PutObject` |
| `UploadPart` |
| `UploadPartCopy` |
+258
View File
@@ -0,0 +1,258 @@
---
title: "Spinifex on Exoscale"
seoTitle: "Run Spinifex on Exoscale with Elastic IPs — Spinifex Docs"
description: "Install Spinifex on one Exoscale instance and run EC2, load balancers and EKS on real Exoscale Elastic IPs."
category: "Install"
tags:
- install
- exoscale
- cloud
- elastic ip
resources:
- title: "Single-Node Install"
url: "/docs/install"
- title: "Spinifex on Oracle Cloud"
url: "/docs/oci-integration"
- title: "VPC Networking"
url: "/docs/vpc-networking"
- title: "Spinifex Repository"
url: "https://github.com/mulgadc/spinifex"
---
# Running Spinifex on Exoscale
> [!WARNING]
> **Work in progress.** This page is not yet published on docs.mulgadc.com. Do not add it to `scripts/docs-config.json` in `mulga-docs` until the [Limits](#limits) section is settled.
> One Exoscale instance, the AWS APIs on its address, and guests reachable on real Exoscale Elastic IPs that Spinifex creates and deletes for you.
## Table of Contents
- [What you get](#what-you-get)
- [Before you start](#before-you-start)
- [Install](#install)
- [Use it from your laptop](#use-it-from-your-laptop)
- [Troubleshooting](#troubleshooting)
- [Limits](#limits)
---
## What you get
A single Exoscale instance runs the whole Spinifex stack: EC2, EBS, S3, VPC, load balancers and EKS, behind an endpoint the ordinary AWS CLI, SDKs and Terraform provider talk to unmodified.
Whenever something needs a public address, such as an instance in a public subnet, an Elastic IP, an internet-facing load balancer or a NAT gateway, Spinifex creates an Exoscale Elastic IP, attaches it to your instance, and deletes it again when the resource goes away. You never manage the addresses by hand.
<p align="center">
<img src="../../.github/assets/diagrams/exoscale-single-node.svg" alt="Single node on Exoscale — the instance on the zone's public network with one interface, Elastic IPs routed into OVN through the routed-NAT transit veth, the control plane, tenant VPCs and the block storage volume" width="900">
</p>
---
## Before you start
You need:
- An Exoscale organisation and an **API key** that can create, attach, detach and delete Elastic IPs.
- An SSH key pair.
- The AWS CLI on your laptop.
Then create these in one zone, from the Exoscale console or the `exo` CLI. This guide uses `de-fra-1`.
| Resource | Setting |
| --- | --- |
| Compute instance | `standard.huge` (8 vCPU / 32 GiB) or larger, template `Linux Ubuntu 26.04 LTS 64-bit`, 100 GiB disk, your SSH key |
| Block storage volume | 256 GiB or more, attached to the instance |
| Security group | Allow all inbound TCP, UDP and ICMP from `0.0.0.0/0`, attached to the instance |
Note the instance's **ID** (a UUID, shown on its page in the console) and the volume's **ID**. You do not need to create any Elastic IPs.
> [!NOTE]
> The security group is open on purpose. Guests are filtered by their own Spinifex security groups, and the node by the Spinifex host firewall you turn on in step 5.
---
## Install
Everything below runs on the instance, over SSH as `ubuntu`.
### 1. Check that the instance can run VMs
```bash
ls -l /dev/kvm && cat /sys/module/kvm_intel/parameters/nested
```
`/dev/kvm` must exist and the second line must print `Y`. If either fails, pick a different instance type before going further.
### 2. Put the data volume at `/var/lib/spinifex`
Every guest disk and S3 object lives here, so it belongs on the volume and not on the 100 GiB root disk.
```bash
VOL_ID=<volume ID>
DEV=/dev/disk/by-id/virtio-${VOL_ID:0:20}
sudo mkfs.ext4 -L spinifex-data "$DEV" # only on a new, empty volume
sudo mkdir -p /var/lib/spinifex
echo "UUID=$(sudo blkid -o value -s UUID "$DEV") /var/lib/spinifex ext4 defaults,nofail 0 2" | sudo tee -a /etc/fstab
sudo mount /var/lib/spinifex
findmnt /var/lib/spinifex # must print a line
```
### 3. Install the `exo` CLI and give it your key
Spinifex manages Elastic IPs by running `exo`, so it needs the CLI and a key file.
```bash
curl -fsSLO https://github.com/exoscale/cli/releases/download/v1.101.0/exoscale-cli_1.101.0_linux_amd64.deb
sudo dpkg -i exoscale-cli_1.101.0_linux_amd64.deb
sudo mkdir -p /etc/spinifex/exoscale
sudo tee /etc/spinifex/exoscale/exoscale.toml >/dev/null <<'EOF'
defaultaccount = "spinifex"
[[accounts]]
name = "spinifex"
key = "EXO..."
secret = "..."
defaultZone = "de-fra-1"
EOF
sudo chmod 700 /etc/spinifex/exoscale && sudo chmod 600 /etc/spinifex/exoscale/exoscale.toml
```
Check the key works: `sudo exo --config /etc/spinifex/exoscale/exoscale.toml compute elastic-ip list -z de-fra-1`.
### 4. Install Spinifex
```bash
curl -sfL https://install.mulgadc.com | sudo bash
sudo /usr/local/share/spinifex/setup-ovn.sh --management --nat-uplink
sudo spx admin init --node node1 --nodes 1 --region eu-central-1 --az eu-central-1a \
--ipsec=false --external-mode=nat
sudo chown -R spinifex-daemon: /etc/spinifex/exoscale
```
Now tell Spinifex where to get public addresses. Add this to the end of `/etc/spinifex/spinifex.toml`, just above the `[bootstrap]` section, using your instance ID:
```toml
[[network.external_pools]]
name = "exo"
source = "exoscale"
exoscale_zone = "de-fra-1"
exoscale_instance_id = "<instance ID>"
```
Leave the `nat-transit` pool that `init` wrote where it is.
> [!NOTE]
> `eu-central-1` is the region name your AWS tools will see. It is your choice and does not have to match the Exoscale zone.
### 5. Turn on the firewall, then start
```bash
sudo env SETUP_STAGES=firewall /usr/local/share/spinifex/setup.sh --firewall on
sudo systemctl start spinifex.target
systemctl list-units 'spinifex-*' --no-pager
```
Every `spinifex-*` unit should be `active`. The daemon log confirms the Elastic IP integration is working:
```bash
sudo journalctl -u spinifex-daemon | grep -E 'exo CLI|Exoscale allocator ready'
```
### 6. Import an image
```bash
sudo spx admin images import --name ubuntu-26.04-x86_64 --config /etc/spinifex/spinifex.toml
```
For EKS, also import `spinifex-eks-node`. `sudo spx admin images list --config /etc/spinifex/spinifex.toml` shows the full catalogue.
---
## Use it from your laptop
Copy the cluster's CA and the admin credentials from the instance:
```bash
NODE=<instance public IP>
mkdir -p ~/.spinifex
ssh ubuntu@$NODE 'sudo cat /etc/spinifex/ca.pem' > ~/.spinifex/exo-ca.pem
```
Copy the `[spinifex]` section of `~/.aws/credentials` on the instance into your own `~/.aws/credentials` as `[spinifex-exo]`, then:
```bash
aws configure set --profile spinifex-exo region eu-central-1
export AWS_PROFILE=spinifex-exo AWS_CA_BUNDLE=~/.spinifex/exo-ca.pem
export AWS_ENDPOINT_URL=https://$NODE:9999
aws ec2 describe-regions # proves the connection
```
### Launch an instance
```bash
aws ec2 import-key-pair --key-name demo --public-key-material fileb://~/.ssh/id_ed25519.pub
SG=$(aws ec2 describe-security-groups --filters Name=group-name,Values=default --query 'SecurityGroups[0].GroupId' --output text)
aws ec2 authorize-security-group-ingress --group-id "$SG" --protocol tcp --port 22 --cidr 0.0.0.0/0
AMI=$(aws ec2 describe-images --filters Name=name,Values='*ubuntu-26.04*' --query 'Images[0].ImageId' --output text)
aws ec2 run-instances --image-id "$AMI" --instance-type t3.small --key-name demo \
--query 'Instances[0].InstanceId' --output text
```
After about a minute, `describe-instances` shows a public IP. That is a new Exoscale Elastic IP, and it appears in the Exoscale console with a description beginning `spinifex:`.
```bash
ssh ubuntu@<public IP> 'curl -s ifconfig.me' # prints the same address
```
Terminating the instance deletes the Elastic IP at Exoscale.
### Load balancers and EKS
The [Terraform workbooks](https://github.com/mulgadc/spinifex/tree/main/docs/terraform-workbooks) run unchanged. Pass the region and endpoint:
```bash
cd nginx-alb
terraform init
terraform apply -var region=eu-central-1 -var spinifex_endpoint=https://$NODE:9999
```
`nginx-alb` builds two web servers behind an internet-facing load balancer. `eks-quickstart` builds a Kubernetes cluster with one worker. Run `terraform destroy` before starting the next one, because of the quota below.
> [!IMPORTANT]
> **Exoscale allows five Elastic IPs per organisation by default**, and every public address Spinifex hands out uses one. An instance with a public IP uses one, an internet-facing load balancer one, a NAT gateway one, and `eks-quickstart` three. Past the quota, `allocate-address` returns `AddressLimitExceeded` and a launch returns `InsufficientAddressCapacity`. Ask Exoscale support to raise it.
>
> **Exoscale keeps counting a deleted Elastic IP for a while.** After a `terraform destroy`, `exo limits` still shows the old usage even though `exo compute elastic-ip list` no longer shows the addresses, so an `apply` straight afterwards can hit the quota. Wait until `exo limits` agrees with the list before starting the next workbook.
---
## Troubleshooting
**A guest's public IP does not answer from the instance itself.** That is expected: traffic from the host to a guest's Elastic IP is dropped. Test from your laptop.
**SSH or HTTP is refused straight after launch.** Guests take 30 to 60 seconds to boot. Wait and retry.
**An EKS cluster goes to `FAILED` within seconds, or a load balancer never becomes active.** Run `exo limits`. If Elastic IP usage is at the maximum, the cluster could not get its public endpoint; destroy it, wait for usage to drop, and apply again.
**The daemon logs an `exo` error at start.** Run the check at the end of step 3. A wrong key, secret or zone is the usual cause.
**An Elastic IP you created yourself.** Spinifex only ever touches Elastic IPs whose description starts with `spinifex:<instance ID>:`. Anything else in the organisation is left alone, but it still counts against the quota.
---
## Limits
Proven on one `standard.huge` instance in `de-fra-1` on 2026-09-30: EC2 with a public IP, Elastic IP allocate and release, the `nginx-alb` workbook and the `eks-quickstart` workbook with one worker. Each was reached from the internet and left no Elastic IP behind at Exoscale.
Not yet tested:
- **More than one node.** Each node would carry its own guests' Elastic IPs, with cluster traffic over an Exoscale private network.
- **Moving an Elastic IP between nodes** with its guest.
- **A narrowly scoped API key.** The proof used an unrestricted key.
- **Other zones, bare metal and GPU instances.**
- **Guest disk performance** on Exoscale block storage.
@@ -22,8 +22,6 @@ resources:
url: "/hardware/cisco/platform-benchmark"
- title: "Vision pipeline (companion document)"
url: "/hardware/cisco/vision-pipeline"
- title: "Benchmark and workload materials (CISCO-refarch)"
url: "https://github.com/tomnewton-mulga/CISCO-refarch"
- title: "vLLM"
url: "https://docs.vllm.ai"
- title: "Qwen2.5-7B-Instruct"
@@ -22,8 +22,6 @@ resources:
url: "/docs/launching-instances"
- title: "GPU Passthrough"
url: "/docs/gpu-passthrough"
- title: "cisco-ucs-platform-benchmark (scripts and raw results)"
url: "https://github.com/tomnewton-mulga/CISCO-refarch"
sections:
- overview
- prerequisites
@@ -239,42 +237,46 @@ sequential repetitions per guest.
| Guest | 16K mixed read/write | 16K random read | 128K mixed read/write | 128K random read |
|---|---:|---:|---:|---:|
| GPU | 75 / 32 MiB/s | 75 MiB/s | 210 / 91 MiB/s | 332 MiB/s |
| CPU1 | 74 / 32 MiB/s | 76 MiB/s | 218 / 94 MiB/s | 333 MiB/s |
| CPU2 | 69 / 30 MiB/s | 107 MiB/s | 226 / 98 MiB/s | 405 MiB/s |
| GPU | 107 / 46 MiB/s | 107 MiB/s | 232 / 101 MiB/s | 376 MiB/s |
| CPU1 | 80 / 35 MiB/s | 80 MiB/s | 226 / 98 MiB/s | 371 MiB/s |
| CPU2 | 79 / 34 MiB/s | 80 MiB/s | 228 / 98 MiB/s | 372 MiB/s |
The 128K guest random-read numbers (332–405 MiB/s) sit between the [AWS EBS gp3](https://docs.aws.amazon.com/ebs/latest/userguide/general-purpose.html)
baseline (125 MiB/s) and its provisioned maximum (1,000 MiB/s) — a meaningful result
given that gp3 baseline is what most AWS operators treat as the default floor for
general-purpose block storage. The 16K numbers (75–107 MiB/s) land near that baseline.
The gap to the raw NVMe host performance (the [KIOXIA CD8P](https://americas.kioxia.com/en-us/business/ssd/data-center-ssd/cd8p-r.html) delivers over 4,000 MiB/s
host-side at comparable queue depths) is not architectural — it is the current
single-queue virtio-blk attach path and unthreaded NBD backend, both of which have
a clear source-level fix. These numbers are the baseline to improve against in future
releases, not the ceiling.
<img src="../../../../.github/assets/images/cisco-ucs-platform-benchmark/guest-storage-throughput.svg" alt="Guest block storage throughput by block size and access pattern">
The 128K guest random-read numbers (371–376 MiB/s) sit between the [AWS EBS gp3](https://docs.aws.amazon.com/ebs/latest/userguide/general-purpose.html)
baseline (125 MiB/s) and its provisioned maximum (1,000 MiB/s). The 16K numbers (79–107 MiB/s) land near that baseline.
128K mixed I/O improves throughput but carries a high p99 latency envelope (roughly
0.47–0.75 s); size queues and write patterns accordingly for latency-sensitive
0.45–0.80 s); size queues and write patterns accordingly for latency-sensitive
applications.
Predastore S3 validation used 1 GiB objects with three sequential and three
distributed-concurrent repetitions per host:
Predastore S3 validation used 1 GiB objects. The AWS CLI splits any download above 8 MiB into concurrent ranged GETs while a plain client issues a single streaming GET, so the sweep measures both patterns with the same client (presigned `curl`) on the host and on each guest — a single-stream PUT/GET, and a chunked/concurrent Range GET (8 MiB parts, 10 in flight) that mirrors the AWS CLI's default download path — with three repetitions each:
| Workload | Write, median | Read, median |
| Client | Single-stream read | Chunked/concurrent read |
|---|---:|---:|
| One host client | 121.9 MiB/s | 213.2 MiB/s |
| Three-host aggregate | 184.2 MiB/s | 381.7 MiB/s |
| Host | 340 MiB/s | 462 MiB/s |
| GPU guest | 399 MiB/s | 391 MiB/s |
| CPU1 guest | 525 MiB/s | 350 MiB/s |
| CPU2 guest | 517 MiB/s | 351 MiB/s |
The single-host sequential read (213 MiB/s) is a credible result for a single-node
S3-compatible store at low-to-moderate concurrency with 1 GiB objects — throughput at
this scale is typically network- and per-request-latency-bound rather than disk-bound,
and 213 MiB/s uses roughly 17% of the 25GbE storage fabric. The three-node distributed read (381.7 MiB/s, 1.79× single-host) confirms
Predastore is distributing reads across the cluster. The write scaling ratio (184.2 vs
121.9 MiB/s, 1.51×) is narrower than read, so worth tracking as a leading indicator of
backend contention as the cluster grows and write load increases.
<img src="../../../../.github/assets/images/cisco-ucs-platform-benchmark/predastore-s3-read-by-client.svg" alt="Predastore S3 read throughput by client, single-stream versus chunked/concurrent GET">
All sequential S3 checksum validations passed. Full methodology, raw fio JSON and S3
metrics are in the [benchmark repository](https://github.com/tomnewton-mulga/CISCO-refarch).
Single-stream writes land at 282–304 MiB/s across the host and all three guests. Under three simultaneous clients — one per physical node — the cluster sustains 640 MiB/s aggregate read and 538 MiB/s aggregate write from the host, and 665 / 515 MiB/s from the guests.
With the client held constant, single-stream host and guest reads fall in the same 340–525 MiB/s band, and aggregate read reaches 640–665 MiB/s across three concurrent clients. The chunked/concurrent Range-GET results vary by individual client rather than tracking host-versus-guest; that variation has no confirmed explanation yet and remains under investigation. All sequential S3 checksum validations passed.
### 6. Network benchmark
Network throughput was measured with iperf3 over TCP, with a 10-second run after a 3-second warmup, five repetitions at 1, 2, 4, 8 and 16 parallel streams, on two paths: host-to-host across the dedicated 25GbE storage fabric (VLAN 1336), and guest-to-guest across the OVN overlay that connects instances.
| Path | Single stream | 2–16 streams |
|---|---:|---:|
| Host physical fabric (25GbE) | 23.4 Gbit/s | 23.5 Gbit/s |
| Guest overlay (OVN) | 22.0 Gbit/s | 22.5 Gbit/s |
<img src="../../../../.github/assets/images/cisco-ucs-platform-benchmark/network-goodput-vs-streams.svg" alt="Network TCP goodput versus parallel stream count, host physical fabric versus guest overlay">
TCP goodput on a 25GbE link settles near 23.5 Gbit/s once framing and protocol overhead are removed, so the host path runs at effectively line rate with zero retransmissions. The guest overlay sustains 22.0 Gbit/s on a single stream, rising to 22.5 Gbit/s at two streams and holding flat through sixteen — 95–96% of the physical fabric across the full range.
## What this architecture unlocks
@@ -14,8 +14,6 @@ tags:
- predastore
- terraform
resources:
- title: "Benchmark and workload materials (CISCO-refarch)"
url: "https://github.com/tomnewton-mulga/CISCO-refarch"
- title: "Multi-Node Install"
url: "/docs/install-multi-node"
- title: "VPC Networking"
+5 -2
View File
@@ -130,15 +130,18 @@ Commonly used paths under `/latest/meta-data/`:
| `security-groups` | Security group names, one per line |
| `placement/availability-zone` | Availability zone |
| `placement/region` | Region (AZ with trailing letter stripped) |
| `services/domain`, `services/partition` | `amazonaws.com` / `aws` |
| `services/domain`, `services/partition` | The cluster's configured services domain (default `services.internal`) / `aws` |
| `public-keys/0/openssh-key` | Launch key pair's SSH public key; 404 if no key pair or the key was deleted |
| `iam/info` | Instance profile ARN and ID; 404 if no profile |
| `iam/security-credentials/<role>` | Temporary role credentials (see below) |
| `network/interfaces/macs/<mac>/...` | Primary interface subtree: `interface-id`, `owner-id`, `subnet-id`, `vpc-id`, `local-ipv4s`, `security-group-ids`, `subnet-ipv4-cidr-block`, `vpc-ipv4-cidr-block`, and more |
| `block-device-mapping/` | `ami` and `root` (both name the boot device) plus one `ebsN` per additional attached EBS volume, numbered from 1 in attachment order |
`services/domain` is the slot an AWS SDK's default endpoint resolution substitutes into `{service}.{region}.{domain}`. Real AWS varies this per partition: standard AWS returns `amazonaws.com`, China returns `amazonaws.com.cn`, and GovCloud differs again. Spinifex is effectively its own partition, so it returns its own configured domain here rather than `amazonaws.com` — an SDK left on default endpoint resolution now resolves a Spinifex-served name instead of silently reaching real AWS. `services/partition` still reports `aws`, since AWS SDKs carry hardcoded partition tables and a novel partition name would not match any of them. Note that this fixes **addressing**, not **trust**: a stock guest image does not trust the Spinifex CA, so a discovered HTTPS endpoint still needs `AWS_CA_BUNDLE` set or the CA installed.
The `network/interfaces/macs/` subtree covers the **primary interface only**; querying another MAC returns 404 (multi-ENI metadata is deferred).
Paths that intentionally return **404**: `tags/instance/*`, `block-device-mapping/*`, `placement/{group-name,partition-number,availability-zone-id,host-id}`, `instance-action`, and `spot/{instance-action,termination-time}` (404 is the correct "no interruption scheduled" answer for spot pollers).
Paths that intentionally return **404**: `tags/instance/*`, `placement/{group-name,partition-number,availability-zone-id,host-id}`, `instance-action`, and `spot/{instance-action,termination-time}` (404 is the correct "no interruption scheduled" answer for spot pollers).
## User Data
+15 -7
View File
@@ -72,14 +72,19 @@ curl -fsSL "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" \
-o awscliv2.zip
```
Mirror the cloud image you intend to run as guest VMs:
Mirror the cloud image you intend to run as guest VMs, together with the sums file it was published with. Keep the upstream filename, because the import looks the image up in the sums file by name:
```bash
mkdir -p images
curl -fsSL "https://cloud.debian.org/images/cloud/trixie/latest/debian-13-genericcloud-amd64.qcow2" \
-o images/debian-13-amd64.qcow2
cd images
curl -fsSLO "https://cloud.debian.org/images/cloud/trixie/latest/debian-13-genericcloud-amd64.qcow2"
curl -fsSLO "https://cloud.debian.org/images/cloud/trixie/latest/SHA512SUMS"
sha512sum -c --ignore-missing SHA512SUMS
cd ..
```
The sums file only proves the image matches it; a sums file fetched over the same channel as the image can be tampered with alongside it. Where the vendor publishes a signature for the sums file (for example Ubuntu's `SHA256SUMS.gpg`), download it too and check it with `gpg --verify` against the vendor's signing key on this connected machine.
## Step 3. Assemble Transfer Media
```bash
@@ -88,7 +93,7 @@ cp spinifex-${TAG}-linux-${ARCH}.tar.gz /media/spinifex-deploy/tarball/
cp setup.sh /media/spinifex-deploy/
cp /var/cache/apt/archives/*.deb /media/spinifex-deploy/apt-packages/
cp awscliv2.zip /media/spinifex-deploy/aws/
cp images/*.qcow2 /media/spinifex-deploy/images/
cp images/*.qcow2 images/SHA512SUMS /media/spinifex-deploy/images/
```
## Step 4. Install on the Air-Gapped Target
@@ -139,13 +144,16 @@ sudo systemctl start spinifex.target
## Step 8. Import Cloud Images
Register the pre-staged image with Spinifex:
Register the pre-staged image with Spinifex, verifying it against the sums file carried across with it:
```bash
sudo spx admin images import --file /mnt/usb/images/debian-13-amd64.qcow2 \
sudo spx admin images import --file /mnt/usb/images/debian-13-genericcloud-amd64.qcow2 \
--checksum /mnt/usb/images/SHA512SUMS \
--distro debian --version 13 --arch x86_64 --boot-mode uefi
```
A digest mismatch exits before anything is written to storage. On success the import prints the verified digest, which `spx admin images describe --image-id <ami>` shows again later.
## Step 9. Verify
```bash
@@ -185,4 +193,4 @@ sudo INSTALL_SPINIFEX_TARBALL=/mnt/usb/tarball/spinifex-...tar.gz \
### Image Import Fails
`spx admin images import --file` requires the distro/version/arch/boot-mode flags so the image registers in the catalogue. If the import succeeds but `aws ec2 describe-images` returns empty, check `journalctl -u spinifex-daemon -f` for predastore upload errors.
`spx admin images import --file` requires the distro/version/arch/boot-mode flags so the image registers in the catalogue. A `checksum entry for image filename not found` error means the image was renamed after download; restore the upstream filename so it matches its line in the sums file. If the import succeeds but `aws ec2 describe-images` returns empty, check `journalctl -u spinifex-daemon -f` for predastore upload errors.

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