Compare commits

..

119 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
1124 changed files with 27949 additions and 7550 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

+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
+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
+3 -4
View File
@@ -1,5 +1,8 @@
version: "2"
run:
allow-serial-runners: true
issues:
max-same-issues: 10
@@ -142,14 +145,10 @@ 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 catches strictly more of these
- -SA9005 # marshaling structs with no exported fields — intended {} for empty API-response types
+27 -4
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)
@@ -153,8 +153,9 @@ install-microvm: $(MICROVM_ARTIFACTS) ## Install microVM artifacts to /usr/share
# -j2 overlaps the only two heavy gates, lint and test-cover: alone each leaves
# most of the machine idle, so pairing them cuts a cold run by about a quarter.
# -Otarget keeps each gate's output in one block instead of interleaving them.
# manifest-check manifest-lint test-package-check removed, can add back later
preflight:
@$(MAKE) --no-print-directory -j2 -Otarget QUIET=1 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
@@ -166,7 +167,7 @@ preflight:
# 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/node-reset.sh scripts/uninstall-spx.sh
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...."
@@ -261,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)...."
@@ -317,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
@@ -452,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>
+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)
+54 -36
View File
@@ -425,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).")
@@ -585,7 +585,6 @@ 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")
@@ -753,25 +752,59 @@ func runimagesImportCmd(cmd *cobra.Command, args []string) {
os.Exit(1)
}
// 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-*")
// 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, "Could not create temp dir: %v\n", err)
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
extractedImagePath, err := utils.ExtractDiskImageFromFile(imageFile, imagePath)
// 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, "Could not extract image: %v\n", err)
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
imageStat, err = os.Stat(extractedImagePath)
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 {
fmt.Fprintf(os.Stderr, "Could not stat image: %v\n", err)
os.Exit(1)
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)
}
}
extracted, err := utils.ExtractDiskImageFromFile(imageFile, scratchDir)
if err != nil {
cleanup()
return "", nil, fmt.Errorf("extract image: %w", err)
}
return extracted, cleanup, nil
}
// 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 {
return "", fmt.Errorf("stat image: %w", err)
}
// Describe the image/AMI as the control-plane document it will become.
@@ -810,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]
@@ -844,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
@@ -853,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:
@@ -862,12 +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)
fmt.Printf("✅ Source digest: %s:%s (%s)\n", sourceDigest.Algorithm, sourceDigest.Value, sourceDigest.Verification)
return volumeId, nil
}
// registerImportedAMISnapshot writes the EC2 control plane's snapshot document
@@ -2970,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")
@@ -2985,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)
}
@@ -3499,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)
}
+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 {
+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)
+1 -5
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 += "/"
}
+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"))
+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
+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])
}
}
-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.
@@ -1047,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",
@@ -1198,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",
@@ -1270,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.
@@ -1427,8 +1106,6 @@ func init() {
bindPredastoreCollisionEnv()
predastoreCmd.AddCommand(predastoreStartCmd)
predastoreCmd.AddCommand(predastoreStopCmd)
predastoreCmd.AddCommand(predastoreStatusCmd)
serviceCmd.AddCommand(viperblockCmd)
@@ -1454,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)
@@ -1464,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")
@@ -1494,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")
@@ -1518,8 +1187,6 @@ func init() {
bindAwsgwCollisionEnv()
awsgwCmd.AddCommand(awsgwStartCmd)
awsgwCmd.AddCommand(awsgwStopCmd)
awsgwCmd.AddCommand(awsgwStatusCmd)
// spinifex-ui
serviceCmd.AddCommand(spinifexUICmd)
@@ -1549,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)
@@ -1567,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
@@ -166,7 +166,7 @@ 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`, `--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: `/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 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. 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. |
+17 -11
View File
@@ -71,6 +71,12 @@ 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
@@ -95,12 +101,12 @@ for img in spinifex-rds-postgres spinifex-rds-mariadb spinifex-ecs-node spinifex
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 |
| 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.
@@ -168,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 \
@@ -349,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.
+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.
+6 -6
View File
@@ -17,18 +17,18 @@ tags:
## Overview
The platform serves **397 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) | 127 |
| [ECR](/coverage/ecr) | 22 |
| [ECR](/coverage/ecr) | 23 |
| [ECS](/coverage/ecs) | 31 |
| [EKS](/coverage/eks) | 36 |
| [EKS](/coverage/eks) | 27 |
| [ELBv2](/coverage/elbv2) | 36 |
| [IAM](/coverage/iam) | 79 |
| [RDS](/coverage/rds) | 26 |
| [IAM](/coverage/iam) | 81 |
| [RDS](/coverage/rds) | 27 |
| [S3](/coverage/s3) | 26 |
| [STS](/coverage/sts) | 5 |
| **Total** | **397** |
| **Total** | **392** |
+19 -25
View File
@@ -1983,6 +1983,7 @@
"DeleteRepository",
"DeleteRepositoryPolicy",
"DescribeImages",
"DescribeRegistry",
"DescribeRepositories",
"GetAuthorizationToken",
"GetLifecyclePolicy",
@@ -2002,7 +2003,6 @@
"stubbed": [
"BatchCheckLayerAvailability",
"CompleteLayerUpload",
"DescribeRegistry",
"GetDownloadUrlForLayer",
"GetRegistryPolicy",
"InitiateLayerUpload",
@@ -2348,7 +2348,6 @@
],
"registered": [
"AssociateAccessPolicy",
"AssociateIdentityProviderConfig",
"CreateAccessEntry",
"CreateAddon",
"CreateCluster",
@@ -2361,36 +2360,27 @@
"DescribeAddon",
"DescribeAddonVersions",
"DescribeCluster",
"DescribeIdentityProviderConfig",
"DescribeNodegroup",
"DescribeUpdate",
"DisassociateAccessPolicy",
"DisassociateIdentityProviderConfig",
"GetRecoveryDirective",
"ListAccessEntries",
"ListAccessPolicies",
"ListAddons",
"ListAssociatedAccessPolicies",
"ListClusters",
"ListIdentityProviderConfigs",
"ListInternalAddons",
"ListNodegroups",
"ListTagsForResource",
"ListUpdates",
"PublishInternal",
"TagResource",
"UntagResource",
"UpdateAccessEntry",
"UpdateAddon",
"UpdateClusterConfig",
"UpdateClusterVersion",
"UpdateNodegroupConfig",
"UpdateNodegroupVersion",
"WebhookTokenReview"
],
"implemented": [
"AssociateAccessPolicy",
"AssociateIdentityProviderConfig",
"CreateAccessEntry",
"CreateAddon",
"CreateCluster",
@@ -2403,34 +2393,27 @@
"DescribeAddon",
"DescribeAddonVersions",
"DescribeCluster",
"DescribeIdentityProviderConfig",
"DescribeNodegroup",
"DescribeUpdate",
"DisassociateAccessPolicy",
"DisassociateIdentityProviderConfig",
"ListAccessEntries",
"ListAccessPolicies",
"ListAddons",
"ListAssociatedAccessPolicies",
"ListClusters",
"ListIdentityProviderConfigs",
"ListNodegroups",
"ListTagsForResource",
"ListUpdates",
"TagResource",
"UntagResource",
"UpdateAccessEntry",
"UpdateAddon",
"UpdateClusterConfig",
"UpdateClusterVersion",
"UpdateNodegroupConfig",
"UpdateNodegroupVersion"
"UpdateNodegroupConfig"
],
"stubbed": [],
"unsupported": [],
"missing": [
"ActivateCertificateAuthority",
"AssociateEncryptionConfig",
"AssociateIdentityProviderConfig",
"CancelUpdate",
"CreateCapability",
"CreateCertificateAuthority",
@@ -2449,19 +2432,27 @@
"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"
],
"extra": [
@@ -2862,6 +2853,7 @@
"GetUser",
"GetUserPolicy",
"ListAccessKeys",
"ListAccountAliases",
"ListAttachedGroupPolicies",
"ListAttachedRolePolicies",
"ListAttachedUserPolicies",
@@ -2901,7 +2893,8 @@
"UntagUser",
"UpdateAccessKey",
"UpdateAssumeRolePolicy",
"UpdateRole"
"UpdateRole",
"UpdateRoleDescription"
],
"implemented": [
"AddRoleToInstanceProfile",
@@ -2943,6 +2936,7 @@
"GetUser",
"GetUserPolicy",
"ListAccessKeys",
"ListAccountAliases",
"ListAttachedGroupPolicies",
"ListAttachedRolePolicies",
"ListAttachedUserPolicies",
@@ -2982,7 +2976,8 @@
"UntagUser",
"UpdateAccessKey",
"UpdateAssumeRolePolicy",
"UpdateRole"
"UpdateRole",
"UpdateRoleDescription"
],
"stubbed": [],
"unsupported": [],
@@ -3042,7 +3037,6 @@
"GetServiceLastAccessedDetails",
"GetServiceLastAccessedDetailsWithEntities",
"GetServiceLinkedRoleDeletionStatus",
"ListAccountAliases",
"ListDelegationRequests",
"ListMFADeviceTags",
"ListMFADevices",
@@ -3078,7 +3072,6 @@
"UpdateGroup",
"UpdateLoginProfile",
"UpdateOpenIDConnectProviderThumbprint",
"UpdateRoleDescription",
"UpdateSAMLProvider",
"UpdateSSHPublicKey",
"UpdateServerCertificate",
@@ -3297,6 +3290,7 @@
"ModifyDBCluster",
"ModifyDBInstance",
"ModifyDBParameterGroup",
"ModifyDBSubnetGroup",
"ModifyOptionGroup",
"PollDBCommands",
"PromoteReadReplica",
@@ -3331,6 +3325,7 @@
"ListTagsForResource",
"ModifyDBInstance",
"ModifyDBParameterGroup",
"ModifyDBSubnetGroup",
"RebootDBInstance",
"RemoveTagsFromResource",
"RestoreDBInstanceFromDBSnapshot",
@@ -3448,7 +3443,6 @@
"ModifyDBShardGroup",
"ModifyDBSnapshot",
"ModifyDBSnapshotAttribute",
"ModifyDBSubnetGroup",
"ModifyEventSubscription",
"ModifyGlobalCluster",
"ModifyIntegration",
+4
View File
@@ -24,6 +24,10 @@ Spinifex implements **127 operations** in the EC2 `2016-11-15` API model.
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 |
+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 -10
View File
@@ -18,14 +18,13 @@ tags:
## Overview
Spinifex implements **36 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,25 +37,17 @@ Spinifex implements **36 operations** in the EKS `2017-11-01` API model.
| `DescribeAddon` |
| `DescribeAddonVersions` |
| `DescribeCluster` |
| `DescribeIdentityProviderConfig` |
| `DescribeNodegroup` |
| `DescribeUpdate` |
| `DisassociateAccessPolicy` |
| `DisassociateIdentityProviderConfig` |
| `ListAccessEntries` |
| `ListAccessPolicies` |
| `ListAddons` |
| `ListAssociatedAccessPolicies` |
| `ListClusters` |
| `ListIdentityProviderConfigs` |
| `ListNodegroups` |
| `ListTagsForResource` |
| `ListUpdates` |
| `TagResource` |
| `UntagResource` |
| `UpdateAccessEntry` |
| `UpdateAddon` |
| `UpdateClusterConfig` |
| `UpdateClusterVersion` |
| `UpdateNodegroupConfig` |
| `UpdateNodegroupVersion` |
+3 -1
View File
@@ -18,7 +18,7 @@ tags:
## Overview
Spinifex implements **79 operations** in the IAM `2010-05-08` API model.
Spinifex implements **81 operations** in the IAM `2010-05-08` API model.
### Scope
@@ -67,6 +67,7 @@ All IAM operations are account-scoped. The root user of an account bypasses poli
| `GetUser` |
| `GetUserPolicy` |
| `ListAccessKeys` |
| `ListAccountAliases` |
| `ListAttachedGroupPolicies` |
| `ListAttachedRolePolicies` |
| `ListAttachedUserPolicies` |
@@ -107,3 +108,4 @@ All IAM operations are account-scoped. The root user of an account bypasses poli
| `UpdateAccessKey` |
| `UpdateAssumeRolePolicy` |
| `UpdateRole` |
| `UpdateRoleDescription` |
+13 -8
View File
@@ -762,7 +762,7 @@ spot-price: On owned hardware there is no spot-to-on-demand price differential,
## ecr
`2015-09-21` — 22 of 58 modelled operations implemented (37.9%), 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
@@ -793,7 +793,6 @@ spot-price: On owned hardware there is no spot-to-on-demand price differential,
- `BatchCheckLayerAvailability`
- `CompleteLayerUpload`
- `DescribeRegistry`
- `GetDownloadUrlForLayer`
- `GetRegistryPolicy`
- `InitiateLayerUpload`
@@ -882,12 +881,13 @@ spot-price: On owned hardware there is no spot-to-on-demand price differential,
## eks
`2017-11-01` — 36 of 70 modelled operations implemented (51.4%), 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`
@@ -906,19 +906,27 @@ spot-price: On owned hardware there is no spot-to-on-demand price differential,
- `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
@@ -957,7 +965,7 @@ spot-price: On owned hardware there is no spot-to-on-demand price differential,
## iam
`2010-05-08` — 79 of 180 modelled operations implemented (43.9%), 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
@@ -1006,7 +1014,6 @@ spot-price: On owned hardware there is no spot-to-on-demand price differential,
- `GetServerCertificate`
- `GetServiceLastAccessedDetails`
- `GetServiceLastAccessedDetailsWithEntities`
- `ListAccountAliases`
- `ListDelegationRequests`
- `ListMFADeviceTags`
- `ListMFADevices`
@@ -1038,7 +1045,6 @@ spot-price: On owned hardware there is no spot-to-on-demand price differential,
- `UpdateGroup`
- `UpdateLoginProfile`
- `UpdateOpenIDConnectProviderThumbprint`
- `UpdateRoleDescription`
- `UpdateSAMLProvider`
- `UpdateServerCertificate`
- `UpdateUser`
@@ -1079,7 +1085,7 @@ signing-certificates: X.509 signing certificates are an EC2-Classic SOAP credent
## rds
`2014-10-31` — 26 of 164 modelled operations implemented (15.9%), 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
@@ -1178,7 +1184,6 @@ signing-certificates: X.509 signing certificates are an EC2-Classic SOAP credent
- `ModifyDBShardGroup`
- `ModifyDBSnapshot`
- `ModifyDBSnapshotAttribute`
- `ModifyDBSubnetGroup`
- `ModifyEventSubscription`
- `ModifyGlobalCluster`
- `ModifyIntegration`
+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 |
+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.
@@ -213,6 +213,13 @@ Choose one method and apply it to **every** server in the cluster.
curl -fsSL https://install.mulgadc.com | bash
```
This installs the latest release. To try the newest development build instead, set `INSTALL_SPINIFEX_CHANNEL=dev` on **every** node — a cluster must not mix versions. It carries unreleased work and has not been through release testing.
```bash
curl -fsSL https://install.mulgadc.com -o /tmp/spx-install.sh
sudo INSTALL_SPINIFEX_CHANNEL=dev bash /tmp/spx-install.sh
```
**Option B — bare metal, from the ISO.** Boot each server from the Spinifex ISO and follow [Bootable USB Install](/docs/install-usb). This installs the operating system, partitions the disks, and configures the hostname and network interfaces alongside Spinifex. The ISO installer does not form a cluster — that is what the remaining steps do.
Complete this step on all three servers before continuing. Step 4 requires every node to be installed, reachable, and available at the same time.
+7
View File
@@ -75,6 +75,13 @@ curl -fsSL https://install.mulgadc.com | bash
The installer downloads the Spinifex binary and bootstraps all dependencies (QEMU, OVN/OVS, AWS CLI).
This installs the latest release. To try the newest development build instead, set the channel — it carries unreleased work and has not been through release testing, so use it to evaluate a fix or a new feature rather than for anything you depend on.
```bash
curl -fsSL https://install.mulgadc.com -o /tmp/spx-install.sh
sudo INSTALL_SPINIFEX_CHANNEL=dev bash /tmp/spx-install.sh
```
## Step 2. Setup OVN Networking
If your WAN interface is already a bridge (e.g. `br-wan`), setup-ovn.sh auto-detects it:
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,483 @@
---
title: "OCI Architecture"
seoTitle: "Spinifex on OCI: Architecture and Operations — Spinifex Docs"
description: "How Spinifex is wired on OCI: both VNICs, the iSCSI data volume and the public-address allocator, with the IAM policy, the quotas and the troubleshooting."
category: "Cloud Install"
sections:
- overview
- troubleshooting
tags:
- install
- oci
- oracle cloud
- terraform
- architecture
- troubleshooting
resources:
- title: "Spinifex on Oracle Cloud"
url: "/docs/oci-integration"
- title: "Host Firewall"
url: "/docs/host-firewall"
- title: "VPC Networking"
url: "/docs/vpc-networking"
- title: "Multi-Node Install"
url: "/docs/install-multi-node"
---
# Spinifex on OCI: Architecture and Operations
> The reference half of the OCI integration. [Spinifex on Oracle Cloud](/docs/oci-integration) is the step-by-step deployment; everything here is what sits underneath it, plus the troubleshooting.
## Table of Contents
- [Why run Spinifex on OCI](#why-run-spinifex-on-oci)
- [Sizing](#sizing)
- [How it fits together](#how-it-fits-together)
- [Credentials: an API key, or an instance principal](#credentials-an-api-key-or-an-instance-principal)
- [The IAM policy](#the-iam-policy)
- [Quotas](#quotas)
- [Firewalls, in all three layers](#firewalls-in-all-three-layers)
- [What Terraform builds, per node](#what-terraform-builds-per-node)
- [Every Terraform variable](#every-terraform-variable)
- [The `oci` CLI](#the-oci-cli)
- [Deploying without Terraform](#deploying-without-terraform)
- [Troubleshooting](#troubleshooting)
---
## Overview
## Why run Spinifex on OCI
**Lower cost for the same shape of workload.** OCI prices compute and block storage well below the large US clouds for equivalent OCPU and IOPS, and Spinifex turns one large instance into a whole EC2/EBS/S3 surface. A tenant's twenty small instances become twenty QEMU guests on hardware you are billed for once, instead of twenty billable cloud instances.
**Egress stops being the line item that decides the architecture.** Oracle publishes a large monthly outbound-transfer allowance per tenancy, then charges per GB beyond it at roughly an order of magnitude below the majors. Check the current pricing page rather than a number in a document, but that gap is the reason this integration exists. Traffic _between_ guests never leaves the host, or on a cluster never leaves the VCN: it is Geneve over the private plane, which is not billed as internet egress at all.
**The workload stops being AWS-shaped and becomes yours.** Your tenants code against Spinifex's API, so one set of Terraform, SDK calls and AMIs runs on OCI today and on bare metal in your own rack whenever you decide to move it. Leaving AWS becomes a deployment decision rather than a rewrite, and the second move costs nothing extra.
**You keep the parts of OCI worth keeping.** Bare-metal shapes, fault domains, block volumes with a chosen performance tier, and real public addresses out of OCI's own pool. Spinifex registers each guest's address with OCI rather than hiding the cloud underneath it, so the guest is genuinely reachable on the internet instead of sitting behind a shared NAT.
## Sizing
**Bare metal is the recommendation.** `BM.Standard.E2.64` or similar gives you the hardware directly, with no nested virtualisation, full NIC performance, and nothing between your guests and the wire. Where bare metal is out of scope, a single `VM.Standard.E6.Flex` suits testing and development, or three of them production.
| | Minimum | Recommended |
| --------------- | --------------------- | ----------------------------------------------------------------- |
| **Shape** | `VM.Standard.E6.Flex` | Bare metal, e.g. `BM.Standard.E2.64` |
| **OCPU** | 8 (16 vCPU) | 32+ |
| **RAM** | 32 GB | 128 GB+ |
| **Data volume** | 256 GiB block volume | 1 TB+ NVMe-backed |
| **VNICs** | 2 | 2 |
| **Nodes** | 1 | 3, see [Cluster sizing](/docs/install-multi-node#cluster-sizing) |
## How it fits together
Everything in [Single-Node Install](/docs/install) and [Multi-Node Install](/docs/install-multi-node) still applies: formation, storage and OVN behave exactly as they do on hardware you own. The layer underneath is what changes. An OCI VCN is not an Ethernet segment, so the external datapath is wired differently, and public addresses come from OCI's API rather than from a range you write in a config file.
### Single node: the whole AWS surface inside one OCI instance
One instance runs the control plane and every tenant guest. Customers reach the AWS APIs on the node's own address, while their instances reach the internet through addresses registered on the node's second VNIC.
<p align="center">
<img src="../../../.github/assets/diagrams/oci-single-node.svg" alt="Single node on OCI: the instance inside the VCN public subnet, both VNICs, br-wan, the routed-NAT transit veth, the control plane, tenant VPCs and the iSCSI data volume" width="900">
</p>
Two things in that picture are the whole integration. The guest's private address (`10.0.1.4`) never leaves the host, because OVN and the host route both hold it. The address OCI delivers to is a **secondary private IP registered on VNIC 1**, appearing on `br-wan`, and the customer's public address (`150.230.13.131`) is a reserved public IP that OCI 1:1-NATs onto it upstream. `describe-instances` reports the public one while every datapath object holds the private one, which is why the two never appear together on the host.
The third is storage. Every guest disk, every S3 object and the JetStream state all live under `/var/lib/spinifex`, which is an **OCI block volume attached over iSCSI**, not the boot disk. Size it for the guests you intend to run. A boot volume that quietly ends up carrying the stack is the most common way an install on OCI fails later rather than immediately.
### Three nodes: the same surface, distributed
Each node registers the addresses for **its own** guests on **its own** second VNIC, and the allocator runs per node, so nothing has to know another node's OCIDs. When a guest moves, whether through a stop/start that lands elsewhere or a host failure, the node it lands on claims the address's private half from the old VNIC with a single OCI call. The OCID and the public half are untouched, so the customer's address never changes.
<p align="center">
<img src="../../../.github/assets/diagrams/oci-three-node.svg" alt="Three nodes on OCI: three instances inside the VCN public subnet, one per fault domain, each with its own VNICs, addresses and iSCSI volume, joined east-west by Geneve, NATS, predastore and the OVN raft" width="900">
</p>
The overlay, the object shards and the OVN databases all cross **VNIC 0**, inside the VCN. Size that plane rather than the external one: guest-to-guest traffic between nodes is Geneve over the VCN, and it is the link every distributed layer shares.
> [!NOTE]
> **The Spinifex region is your own naming and has nothing to do with the OCI region.** `ap-southeast-2` in `spinifex.toml` beside `ap-sydney-1` in OCI is correct and expected. Do not read one as evidence about the other.
## Credentials: an API key, or an instance principal
The allocator needs OCI credentials at runtime. **A node with none forms, passes every health check, and then refuses every launch that wants a public address** with `InsufficientAddressCapacity`, naming no cause outside its own journal.
### An API key, the documented path
An API key needs nothing from a tenancy admin, which also makes it the only option when the compartment belongs to someone else's tenancy. The cost is key material on disk, with a rotation obligation, on every node:
```bash
# On your workstation, not the node.
openssl genrsa -out ~/.oci/oci_api_key.pem 2048
chmod 600 ~/.oci/oci_api_key.pem
openssl rsa -pubout -in ~/.oci/oci_api_key.pem -out ~/.oci/oci_api_key_public.pem
openssl rsa -pubout -outform DER -in ~/.oci/oci_api_key.pem \
| openssl md5 -c # this is the fingerprint
```
Upload the public key to the user under **Identity → Users → API Keys**, and record the user OCID, the fingerprint and the tenancy OCID.
Each node then needs **two** files, and a node holding only the key is the silent failure described above.
| Path | Mode | Contents |
| ----------------------------------- | -------------------- | ------------------------------------------------------------------------ |
| `/etc/spinifex/oci/oci_api_key.pem` | `0640 root:spinifex` | The private key |
| `/etc/spinifex/oci/config` | `0640 root:spinifex` | An ordinary OCI SDK config, profile `[spinifex]`, naming the key by path |
The profile name must be `spinifex` to match `oci_config_profile` in the pool:
```ini
[spinifex]
user=ocid1.user.oc1..aaaa...
fingerprint=12:34:56:78:90:ab:cd:ef:12:34:56:78:90:ab:cd:ef
tenancy=ocid1.tenancy.oc1..aaaa...
region=ap-sydney-1
key_file=/etc/spinifex/oci/oci_api_key.pem
```
**`spx-oci-config.sh` writes both files on every node, and the deploy calls it for you.** It runs after formation and before the pool is configured, which is the only window where a node has `/etc/spinifex` but has not started the allocator, and it reads the credential from your `[spinifex]` profile, a dedicated key, a set of `OCI_SPX_*` environment variables, or failing all three the credential Terraform itself used — resolved through the same `scripts/oci_env.py` as the apply, so the two cannot disagree.
**The key reaches the nodes over SSH and goes nowhere else.** It is never rendered into user-data or Terraform state, where it would be readable from instance metadata for the life of the instance, and it is carried in the remote shell's stdin rather than its arguments, where `ps` would expose it to every other tenant on the host. `--credential-hook PATH` still takes any executable called as `hook <ssh-key> <host>...`, so a tenancy that keeps credentials in a vault can substitute its own.
### An instance principal: no key material, but it needs a tenancy admin
The instance certificate is served at `/opc/v2/identity/cert.pem` and the Go SDK authenticates as the instance with no key on disk. It must also be _authorised_, by a dynamic group and a policy, and **both are tenancy-root resources**: `oci_identity_dynamic_group` is created with `compartment_id` set to the tenancy OCID. A compartment-scoped user cannot create them, and the attempt fails with `404-NotAuthorizedOrNotFound` on `CreateDynamicGroup`, which reads like a misconfiguration and is not one.
```bash
cd scripts/terraform/oci-spx
./setup-identity.sh --dry-run # always first
./setup-identity.sh # needs a tenancy-admin OCI profile
```
Then set `instance_principal = "adopt"` in your tfvars and pass `--instance-principal` to the deploy instead of `--credential-hook`. The dynamic group matches on `instance.compartment.id`, so it covers every node you ever build in that compartment and nothing needs updating when nodes are replaced.
## The IAM policy
Spinifex calls exactly ten operations, all of them in `spinifex/cloud/oci/client.go`. Grant no more than these:
| Operation | Why |
| ------------------------------------------ | -------------------------------------------------------------------------------- |
| `CreatePrivateIp` | Register the secondary private IP the datapath carries |
| `DeletePrivateIp` | Release it |
| `GetPrivateIp` | Poll for `AVAILABLE` after an asynchronous assign |
| `ListPrivateIps` | The startup reconcile, and the affinity pass that moves an address between nodes |
| `UpdatePrivateIp` | Reassign a private IP to this node's VNIC when a guest moves here |
| `CreatePublicIp` | Create the RESERVED public IP |
| `DeletePublicIp` | Release it |
| `GetPublicIp` / `GetPublicIpByPrivateIpId` | Poll lifecycle state, and reconcile a private IP back to its public half |
| `UpdatePublicIp` | Attach and detach the public IP from a private IP |
The matching policy, scoped to the one compartment Spinifex allocates in:
```text
Allow group SpinifexOperators to use vnics in compartment <compartment-name>
Allow group SpinifexOperators to manage private-ips in compartment <compartment-name>
Allow group SpinifexOperators to manage public-ips in compartment <compartment-name>
Allow group SpinifexOperators to use subnets in compartment <compartment-name>
```
`instance-principal.tf` grants the dynamic group these same four statements, so an instance principal can allocate a public address exactly as an IAM user can. The two credential routes differ in who is authorised, never in what.
> [!IMPORTANT]
> **Allocating one address needs permissions on three resource types, and a 404 names none of them.** `CreatePrivateIp` is checked against `PRIVATE_IP_CREATE`, `PRIVATE_IP_ASSIGN`, `VNIC_ASSIGN` and `SUBNET_ATTACH`, and `DeletePrivateIp` against `SUBNET_DETACH`. `VNIC_ASSIGN` lives in the vnics `use` verb, so `read vnics` is not enough; `SUBNET_ATTACH` and `SUBNET_DETACH` live in the subnets `use` verb, so a policy naming only vnics and IPs authorises nothing. Either omission fails every allocation with `NotAuthorizedOrNotFound` on a node that looks entirely healthy, and OCI uses that one error for "you may not" and "it is not there" alike, so it reads as a wrong compartment OCID rather than a missing verb.
To tell the two apart, make one call per permission class and see which is the first to fail. `GetVnic` tests `VNIC_READ`, `ListPrivateIps` tests `PRIVATE_IP_READ`, and `GetSubnet` tests `SUBNET_READ`. If the first two succeed and the third does not, the credential is authentic and in the right compartment, and the policy is missing its subnet statement.
**Scope it to a compartment, not the tenancy.** `manage public-ips` at tenancy level lets a compromised node consume the whole regional reserved-public-IP quota.
**Do not grant `manage vnics` or `manage instances`.** Spinifex never creates, attaches or deletes a VNIC, and never touches an instance. It only adds addresses to a VNIC that already exists, which `use` permits. `manage` also permits VNIC lifecycle, which is strictly more than the integration can use.
## Quotas
Two limits bound how many public addresses a deployment can hand out, and they do not scale the same way:
| Quota | Default | Scope | Notes |
| ---------------------------------- | ------- | ------------------------- | ---------------------------------------------------------------------------------------------- |
| **Reserved public IPs** | 50 | Per region, whole tenancy | Shared by every node, so it does **not** grow with node count. This is the wall you hit first. |
| **Secondary private IPs per VNIC** | 64 | Per VNIC, so per node | Not raisable. Three nodes gives you 192, so this is the limit that does scale. |
**On a new tenancy, check the compute limits too.** A fresh account commonly carries a service limit of zero for bare-metal shapes and a low one for flex VMs, which can leave `BM.Standard.E2.64` unavailable until you request it. That refusal arrives at apply time, not at plan time.
A raise is a support request, not a setting: Console → Governance & Administration → Limits, Quotas and Usage → "Request a service limit increase". Cite the exact limit name from `oci limits definition list`, not a name from documentation.
```bash
oci limits definition list --service-name vcn --compartment-id <tenancy-ocid>
oci limits value list --service-name vcn --compartment-id <tenancy-ocid>
oci limits resource-availability get --service-name vcn \
--limit-name <name-from-above> --compartment-id <tenancy-ocid>
```
**These calls need a grant the node does not have, and must not be given.** Limits and usage are tenancy-level, which cannot be compartment-scoped the way the policy above insists the node's is. Create a **second, read-only principal** for an operator or a reporting job, and keep it off every node:
```text
Allow group SpinifexReporting to read limits in tenancy
Allow group SpinifexReporting to read usage-reports in tenancy
```
The second verb is what Cost Analysis and `oci usage-api usage-summary request-summarized-usages` read. Adding either to `SpinifexOperators` would hand every node tenancy-wide read, which is exactly what the node policy refuses.
## Firewalls, in all three layers
Three independent things filter traffic to an OCI deployment, and they are easy to mistake for one. Nearly every "my port is closed" report on OCI turns out to be the wrong layer being adjusted.
| Layer | What it guards | Who configures it |
| --- | --- | --- |
| **OCI security list** | The whole public subnet, including the addresses guests are reached on | `node_client_cidr_allow_list` |
| **The host firewall** | The node's own listeners: console, S3 gate, AWS gateway, SSH, the cluster plane | `node_service_ports`, then the Spinifex nft policy |
| **OVN security groups** | Each guest, per AWS security-group rules | Your tenants, through the EC2 API |
### The OCI security list is deliberately open, and should stay that way
```hcl
# network.tf, security_list.public
ingress_security_rules { protocol = "all", source = var.vcn_cidr } # intra-VCN
dynamic "ingress_security_rules" { ... source = each allow-list CIDR } # protocol = "all"
```
A guest's public address is a **secondary private IP on VNIC 1, in the public subnet**, so inbound traffic to a guest crosses this security list. Narrowing `node_client_cidr_allow_list` to your own office `/32` therefore cuts internet ingress to **every guest**, not just to the node. A tenant who then opens 443 on their own security group finds it does nothing, with no error anywhere naming the cause.
That is also why the rule is `protocol = "all"` rather than the three service ports. Enumerating ports here would mean a Terraform change for every customer port.
**So leave it at `0.0.0.0/0` for any deployment that serves guests**, and do the restricting in the host firewall, which is the layer that can tell the node's own listeners apart from a guest's. Narrow the allow list only for a private lab with no public guest services.
The separate intra-VCN rule exists so that narrowing the allow list can never cut Geneve, OVN, NATS or predastore between nodes.
### The host firewall is what you harden
Terraform's cloud-init runs `open-service-ports.sh`, which opens all protocols from the peer CIDR plus `node_service_ports` (`3000 8443 9999`) to any source, in the Ubuntu image's **iptables**. SSH is already open to the world from the image's own INPUT rule, and nothing in that script narrows it.
The Spinifex nft policy is installed but **not armed** on this path, because the installer arms it only when asked. Arming it is what gives you the proper policy: public plane, peer-scoped cluster plane, and an SSH scope you own.
```bash
# On each node. Arms the policy the ISO installer arms by default.
sudo env SETUP_STAGES=firewall /usr/local/share/spinifex/setup.sh --firewall on
```
Then narrow SSH in the one file `setup.sh` creates and never overwrites:
```bash
# /etc/spinifex/firewall/custom.nft is yours, and upgrades leave it alone.
define trusted_ssh_peers = { 203.0.113.10/32 }
```
```bash
sudo nft -c -f /etc/spinifex/firewall/spinifex.nft # check before applying
sudo /usr/local/lib/spinifex/spinifex-firewall-apply
```
What the armed policy allows, and why each port is where it is, is in [Host Firewall](/docs/host-firewall), along with [Narrowing SSH access](/docs/host-firewall#narrowing-ssh-access) and the rule that **forming or expanding a cluster starts by turning the policy off**, because nodes mid-formation are not yet each other's peers.
Two OCI-specific notes on top of that guide:
- **Northstar's DNS on 53 is in the armed policy's public plane, but `node_service_ports` does not include it.** Until the nft policy is armed, a node serving a public zone does not answer on 53 from the internet.
- **Never flush iptables on an OCI node.** The image's `InstanceServices` chain in OUTPUT is what permits iSCSI to `169.254.2.0/24:3260` and the metadata service, so clearing it takes the data volume and IMDS with it.
## What Terraform builds, per node
| Resource | Detail |
| ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- |
| `oci_core_instance` | One per `node_count`, round-robined across **fault domains** so three nodes land on three sets of racks rather than wherever placement puts them |
| Primary VNIC | Public IP, `hostname_label`, the host plane: SSH, the advertise address, the Geneve endpoint |
| `oci_core_vnic_attachment` | A **second VNIC** in the same subnet. Every Spinifex external address becomes a secondary private IP here at runtime |
| `oci_core_volume` | A block volume per node, `data_volume_size_in_gbs` at `data_volume_vpus_per_gb` |
| `oci_core_volume_attachment` | **`attachment_type = "iscsi"`**, with the Oracle Cloud Agent's Block Volume Management plugin enabled so the node logs the iSCSI session in itself |
| cloud-init | Partitions, formats, mounts and `fstab`s the volume; builds `br-wan` over the second VNIC; opens the service ports |
The resource-by-resource architecture is in [`scripts/terraform/oci-spx`](https://github.com/mulgadc/spinifex/tree/dev/scripts/terraform/oci-spx).
**The data volume is iSCSI, and that is the detail that bites.** OCI presents the volume as an iSCSI target reachable at `169.254.2.2:3260`, so attaching it in the API does not put a block device on the host. `is_agent_auto_iscsi_login_enabled` makes the Oracle Cloud Agent do the login, and it does that _asynchronously_, so the device is not there when cloud-init first runs. The mount script waits up to ten minutes for it, formats it **only if `blkid` reports no filesystem** so that a re-run cannot erase a populated volume, and writes an fstab entry by UUID:
```text
UUID=<uuid> /var/lib/spinifex ext4 defaults,_netdev,nofail 0 2
```
`_netdev` orders the mount after the network, and therefore after `iscsid` has a session. `nofail` keeps a missing volume from wedging the boot. Mounting `/var/lib/spinifex` _itself_ rather than somewhere else and symlinking is deliberate: viperblock, predastore and JetStream all land on the volume with no configuration pointing anywhere unusual.
## Every Terraform variable
All of these have defaults that build a working single node, except the first:
| Variable | Default | What it decides |
| ----------------------------- | --------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `compartment_ocid` | none | **Required.** An OCID, not a name: this deploys into a compartment that already exists, because creating one needs tenancy-root rights the API user must not have |
| `deployment_name` | `spinifex` | Prefixes every resource and becomes the VCN `dns_label`, so 1–15 lowercase alphanumerics starting with a letter |
| `node_count` | `1` | Nodes, 1–25. `1` for a single node, `3` for a cluster. Each gets its own volume, its own second VNIC and its own fault domain |
| `compute_shape` | `VM.Standard.E6.Flex` | **`BM.*` for bare metal, `VM.*.Flex` for a VM.** A fixed bare-metal shape carries its own size, so `shape_config` is emitted only when the name contains `Flex`. Set a BM shape and the two sizing variables below are ignored rather than rejected |
| `compute_ocpus` | `8` | OCPUs on a flex shape. Validated at ≥ 8 (16 vCPU), the minimum recommended for a node |
| `compute_memory_in_gbs` | `32` | GiB on a flex shape. Validated at ≥ 32 |
| `data_volume_size_in_gbs` | `256` | Size of each node's data volume. Guest disks, S3 objects and JetStream all live here, so size it for the guests rather than for the control plane |
| `data_volume_vpus_per_gb` | `120` | Performance tier, 0–120 VPUs/GB. 120 is Ultra High Performance; 10 is Balanced. Higher is more IOPS and more cost |
| `data_mountpoint` | `/var/lib/spinifex` | Where cloud-init mounts it. Change only if you know why |
| `vcn_cidr` | `10.200.0.0/22` | VCN address space. A `/22` leaves room for the secondary private IPs Spinifex registers per node |
| `public_subnet_cidr` | `10.200.0.0/23` | The subnet every node sits in, **on both VNICs**. Nodes serve the UI, the S3 gate and the AWS gateway directly, so they cannot live in a private subnet |
| `node_client_cidr_allow_list` | `["0.0.0.0/0"]` | OCI security-list ingress, **all protocols**, for the whole public subnet. Guest public addresses cross it too, so read [Firewalls](#firewalls-in-all-three-layers) before narrowing it |
| `node_service_ports` | `[3000, 8443, 9999]` | Console, predastore gate, AWS gateway, opened in the host's iptables by cloud-init. SSH comes from the image's own rule |
| `instance_principal` | unset | `"adopt"` to authenticate the allocator as the instance rather than with a key file. Needs the tenancy dynamic group and policy to exist |
| `ubuntu_version` | `26.04` | Canonical platform image version. The newest image for the shape and region is resolved at plan time rather than pinned to a regional OCID |
| `wan_bridge_name` | `br-wan` | The Linux bridge built over the second VNIC |
| `wan_bridge_mtu` | `9000` | The OCI VCN carries 9000 |
**Six more exist and are not yours to set**: `tenancy_ocid`, `user_ocid`, `fingerprint`, `private_key`, `private_key_path` and `region`. `scripts/oci_env.py` fills them as `TF_VAR_*` from the profile in `~/.oci/config` on every `terraform` call the driver makes, so **the region you deploy into is the profile's region** and the credential never reaches a `.tfvars` file. Setting them by hand works, and is how a run with no config file at all would have to work, but then two sources disagree about which tenancy this is.
The profile is resolved as `--oci-profile`, then `$OCI_CLI_PROFILE`, then the reference tenancy, then `DEFAULT`. A name passed explicitly and absent from the config is an error that lists the profiles present; only an unasked-for name falls through. CI takes a different route entirely, reading `OCI_TENANCY_OCID`, `OCI_USER_OCID`, `OCI_FINGERPRINT`, `OCI_PRIVATE_KEY` and `OCI_REGION` from the environment so a runner writes no key to disk. A partial set there is an error rather than a fallback, because half a credential means a secret failed to reach the job.
## The `oci` CLI
**The `oci` CLI is not on the Ubuntu image**, and `apt` has no `python3-oci-cli` package. Nothing in the deployment needs it; install it when you want to inspect allocations by hand:
```bash
sudo apt-get update && sudo apt-get install -y python3-venv
python3 -m venv ~/.oci-cli-venv
~/.oci-cli-venv/bin/pip install --upgrade pip oci-cli
sudo ln -sf ~/.oci-cli-venv/bin/oci /usr/local/bin/oci
oci --version
```
**What the CLI is and is not for.** Spinifex's daemon uses the OCI **Go SDK** for every allocation, so the CLI is not on that path at all. You need it for bootstrap and for operational inspection. Do not build automation that shells out to it during an allocation: allocations run inside a request budget that a forked Python process will not respect.
---
## Deploying without Terraform
**Terraform automates every step of a normal install, and that is the point of it.** The `oci-spx` configuration builds the VCN, the subnets, the gateways, the block volumes and both VNICs per node, then renders the cloud-init that installs Spinifex and sets up OVN. The sequence below happens without anyone typing it, in the same order every time, on one node or three.
If you do need to install by hand, whether for an existing OCI tenancy you cannot run Terraform against, an unsupported shape, or a stage you are debugging, the install itself is not OCI-specific. Follow the standard guides:
- [Installing Spinifex](../../install/install/README.md) for a single node.
- [Multi-Node Installation](../../install/install-multi-node/README.md) for a cluster, which covers `spx admin init` on the leader and `spx admin join` on the rest.
Four things about OCI are not in those guides, and each has its own section above:
- **The external pool** is `source = "oci"`, which needs the API key or instance principal from [Credentials](#credentials-an-api-key-or-an-instance-principal) and the [IAM policy](#the-iam-policy). Omit it and you get the silent failure that section opens with: a healthy node that cannot hand out an address.
- **`br-wan` stays a Linux bridge** owned by netplan, linked to OVS `br-ext` by a veth pair. Pass `setup-ovn.sh --wan-bridge=br-wan` and it detects this itself. The NIC never becomes an OVS port, so the VNIC keeps its MAC identity. See [How it fits together](#how-it-fits-together).
- **The block volume must be mounted with `_netdev`**, because it arrives over iSCSI and the mount is otherwise attempted before a session exists.
- **IMDS needs the 169.254 remap** described under [Guests cannot reach instance metadata](#guests-cannot-reach-instance-metadata), since the guest's metadata address collides with OCI's own.
---
## Troubleshooting
### If a deploy stage fails
The driver stops at the first failure, names the log, and leaves the rest alone. Logs are all under `.validate-<topology>/`:
| Stage | Log |
| ----------------------------------------------- | ---------------------------------------- |
| Building the infrastructure | `apply.log` |
| Installing Spinifex | `install-<host>.log` |
| Forming the cluster | `form.log` |
| Installing the credential, configuring the pool | `credential-hook.log`, `pool-<host>.log` |
| Verifying public addressing | `allocator-<host>.log` |
| The Terraform workbooks | `workbooks.log`, `workbooks.tsv` |
The allocator log is the one worth knowing about, because public addressing is the part with the most moving pieces. If the allocator never comes up, the driver captures the daemon journal, the pool as configured, the bridge and OCI MAC addresses, and whether each credential file exists and is readable, never the credentials themselves, before anything is torn down.
Add `--keep-on-fail` to leave a failed deployment running so you can log in and look. **It keeps billing** until you run `--destroy-only`.
### Addresses and quotas
**Spinifex enforces no limit of its own.** It attempts the allocation and reports what OCI says, so the ceiling you hit is always your tenancy's and never a number baked into the software. A quota is raised with Oracle, not reconfigured here, and there is nothing to restart afterwards.
**`InsufficientAddressCapacity` on `allocate-address` is what exhaustion looks like.** It is the AWS error code for "out of addresses", and Spinifex returns it for either quota, mapped from OCI's own refusal. On a cluster the regional one is the likelier of the two.
**A detached reserved public IP still bills.** `disassociate-address` returns the address to `AVAILABLE` without deleting it, exactly as an unassociated AWS Elastic IP behaves, and it keeps consuming your regional quota as well as your bill. `release-address` frees both.
**A stopped instance costs nothing for its auto-assigned address.** Stopping an instance returns its auto-assigned address to the pool and deletes the reserved public IP object behind it, as AWS does, and the start that follows creates a new one. An **Elastic IP** is yours and survives the stop untouched. That is the whole distinction between the two, and on OCI it is also the difference between a stopped fleet that bills for addresses and one that does not.
### A guest's public address is unreachable
**Check where you are probing from first.** A node is a poor vantage point for a public address, for two independent reasons: the security list may not admit the node's own public address as a source, and a VCN may or may not reflect its public addresses back into itself. Either one fails the probe on a perfectly healthy cluster. Test from outside the VCN, which is where a customer is.
Then, in order:
1. `sudo ovn-nbctl lr-nat-list <router>` for a `dnat_and_snat` row, holding the _private_ address.
2. `ip route get <private-addr>` to confirm it routes via the transit veth.
3. `sudo iptables -S FORWARD | grep spinifex-eip-ingress` for both directions.
4. `oci network private-ip list --vnic-id <vnic>` to confirm the private address is registered with OCI, **and on this node's VNIC**. If it is not, nothing else matters.
**Egress works from one address and not another on the same NIC.** OCI enforces the source address per VNIC, so that means the address is not a registered private-IP object on it. It is never a routing problem on the host.
**An address went dark after a guest moved node.** The affinity pass should have claimed it within 15 seconds. Check the new node's daemon journal for `ocinet affinity`, then use step 4 above to confirm which VNIC OCI thinks holds the private half.
### A node that was fine and then was not
Terraform's cloud-init does the host jobs on each node: the iSCSI volume, `br-wan` and its source routing, the firewall. When one of them did not take, the symptom arrives much later and rarely looks like a networking or storage problem. This one command answers all of it:
```bash
ssh -i ~/.ssh/oci-spx ubuntu@<node-public-ip> '
cloud-init status # done
sudo iscsiadm -m session # a session to 169.254.2.2:3260
findmnt /var/lib/spinifex # mounted by UUID, with _netdev
ip -br addr show br-wan # UP, second VNIC address as a /32
ip -br addr show enp1s0 # the bridge MEMBER holds NO address
ip route show default # still on the PRIMARY interface
ls -l /dev/kvm # present
'
```
Three answers are worth knowing in advance, because each looks wrong and is not:
- **`br-wan` holds a `/32` and the default route is on the other interface.** That is how two VNICs share one subnet without the second stealing the first one's traffic, and it is what satisfies OCI's per-VNIC source check. `br-wan`'s address appears in `ip rule` instead of in the default route.
- **`br-wan`'s address is not one you chose.** It is the second VNIC's own private IP, assigned by OCI, and not predictable before the apply.
- **`enp1s0` carrying an address is the one real fault here.** The kernel derives an on-link route from it that beats the primary VNIC's, so every packet to another node leaves the wrong VNIC and OCI drops it. The node still forms a cluster and still reports `Ready`, because formation uses outbound connections, and it surfaces later as `predastore … the stripe is short one holder` on the first AMI import. `sudo /usr/local/sbin/spinifex-setup-wan-bridge` repairs it in place.
### The allocator
**Confirm it came up**, which `spx get nodes` cannot tell you:
```bash
sudo journalctl -u spinifex-vpcd --since -5m | grep -i ocinet
```
```text
"msg":"ocinet resolved the external VNIC from its interface","pool":"oci-public","iface":"br-wan",
"vnic_id":"ocid1.vnic.oc1.ap-sydney-1.abzxsljrjwlf...kimuq","private_ip":"10.200.1.31","subnet":"10.200.0.0/23"
"msg":"ocinet credential authorised to allocate","pool":"oci-public",
"vnic_id":"ocid1.vnic.oc1.ap-sydney-1.abzxsljrjwlf...kimuq","compartment":"ocid1.compartment.oc1..aaaa"
"msg":"OCI allocator ready","pool":"oci-public","collected":0,"stale_bindings":0,"skipped":1
```
**`credential authorised to allocate` is the line that matters, and `resolved the external VNIC` is not.** The authorisation line is logged after the node has made one real read in each permission class an allocation needs, so it proves the credential is accepted, the compartment is right and every verb in the policy is held. The resolution line above it comes from the instance metadata service and needs no IAM policy at all, so a node with a policy missing a verb logs it and still refuses every allocation.
The refusal has its own line, which names the verb:
```text
"msg":"ocinet credential cannot allocate an external address","pool":"oci-public",
"error":"reading subnet ocid1.subnet... failed, which needs `use subnets` in compartment ocid1...;
AssignPrivateIP requires SUBNET_ATTACH from that same verb: ... NotAuthorizedOrNotFound"
```
On a cold cluster start you may instead see `OCI allocator reconcile failed … nats: no responders available for request` on some nodes. That is vpcd racing JetStream's KV at boot, where the startup reconcile is skipped and not retried. It is harmless on a new cluster where nothing has been allocated, and it is tracked; restart `spinifex-vpcd` on that node to run it.
### Host
**`RunInstances` returns `ServerInternal`.** Check the daemon journal for the real error with `journalctl -u spinifex-daemon --since -10m`. An OCI allocation failure surfaces this way.
**The stack ends up on the boot disk and the node fills.** The data volume was mounted without `_netdev` and lost the race with `iscsid`. `findmnt /var/lib/spinifex` is the check, and `ls` cannot tell the difference.
### Guests cannot reach instance metadata
**Guests boot but cloud-init hangs on metadata**, or the host stops answering its own. OCI uses `169.254.169.254` for its instance metadata, and so does every AWS-compatible guest, so the host and the guests want the same address.
Spinifex resolves this by moving the _host_ side of the per-guest metadata link to a different link-local pair, leaving guests to ask `169.254.169.254` exactly as they do on AWS. The deploy sets `imds_host_meta_ip` and `imds_host_dns_ip` for you. **Set both or neither**, because a half-configured pair is ignored and the node behaves as if the remap were off, which is what this symptom looks like.
Check it after your first guest launches:
```bash
ip route get 169.254.169.254 # your real NIC, not lo
curl -sH 'Authorization: Bearer Oracle' -o /dev/null -w '%{http_code}\n' \
http://169.254.169.254/opc/v2/instance/ # 200
ip -br addr show | grep ime- # holds 169.254.42.x, not .169.x
```
`ip route get 169.254.169.254` returning `dev lo` is the signature of a missing remap. The same signature can appear after a guest terminates, from a leftover `ime-` endpoint still holding the address; with the remap configured that is harmless, and removing the stale OVS port clears it.
### An OVS problem, when `ovs-vsctl` says nothing is wrong
`ovs-vsctl` exits 0 even when a bridge could not be instantiated. Read the error column and the log, never the exit status:
```bash
sudo ovs-vsctl --columns=name,error list Interface
sudo journalctl -u ovs-vswitchd --since -10m
```
@@ -256,7 +256,7 @@ kubectl -n kube-system logs deploy/aws-load-balancer-controller --tail=50
Confirm the cluster carries `spinifex.io/managed-ingress = "false"` so the LBC owns ingress.
If the controller logs show `FailedBuildModel ... DescribeAvailabilityZones ... 403 ... AccessDenied`, the node role is missing the LBC permissions. The controller runs with the node instance-profile credentials, so the `${var.cluster_name}-node-lbc` policy (`aws_iam_policy.node_lbc`) must be attached to the node role — re-run `tofu apply` on the parent module if it was provisioned before that policy existed.
If the controller logs show `FailedBuildModel ... DescribeAvailabilityZones ... 403 ... UnauthorizedOperation`, the node role is missing the LBC permissions. The controller runs with the node instance-profile credentials, so the `${var.cluster_name}-node-lbc` policy (`aws_iam_policy.node_lbc`) must be attached to the node role — re-run `tofu apply` on the parent module if it was provisioned before that policy existed.
### Provider Connection Refused
@@ -66,7 +66,7 @@ Under the hood it keeps things minimal: a VPC with two public subnets, an IAM ro
- The cluster's security groups are **auto-managed** — `vpc_config.security_group_ids` is ignored. To reach a NodePort, this workbook looks the worker SG up by its deterministic name (`eks-cluster-<name>-nodegroup-sg`) and adds a single ingress rule.
- The worker AMI is always Spinifex's `eks-node` image; `ami_type` is recorded but does **not** select the image.
- `authentication_mode` must be `"API"` (the `API_AND_CONFIG_MAP` mode is rejected).
- `authentication_mode` can be `"API"` or `"API_AND_CONFIG_MAP"`, and bare `"CONFIG_MAP"` is rejected. Access is granted through access entries, as there is no `aws-auth` ConfigMap path.
- The workers need outbound internet (here via the IGW) to pull the demo container image.
**Prerequisites:**
+24 -24
View File
@@ -4,33 +4,33 @@ go 1.27.0
require (
github.com/aws/aws-sdk-go v1.55.8
github.com/aws/aws-sdk-go-v2 v1.47.0
github.com/aws/aws-sdk-go-v2 v1.47.1
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20
github.com/aws/aws-sdk-go-v2/config v1.33.5
github.com/aws/aws-sdk-go-v2/credentials v1.20.5
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.0
github.com/aws/aws-sdk-go-v2/config v1.33.6
github.com/aws/aws-sdk-go-v2/credentials v1.20.6
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.1
github.com/cenkalti/backoff/v4 v4.3.0
github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/bubbletea v1.3.10
github.com/charmbracelet/lipgloss v1.1.0
github.com/containerd/containerd/v2 v2.4.0
github.com/containerd/containerd/v2 v2.4.1
github.com/distribution/reference v0.6.0
github.com/go-chi/chi/v5 v5.3.2
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/insomniacslk/dhcp v0.0.0-20260719225207-c76316d4aa82
github.com/jackc/pgx/v5 v5.11.0
github.com/klauspost/compress v1.20.0
github.com/klauspost/compress v1.20.1
github.com/klauspost/cpuid/v2 v2.4.0
github.com/miekg/dns v1.1.73
github.com/minio/highwayhash v1.0.4
github.com/mulgadc/bluebottle v1.21.0
github.com/mulgadc/northstar v1.21.0
github.com/mulgadc/predastore v1.21.0
github.com/mulgadc/viperblock v1.21.0
github.com/mulgadc/bluebottle v1.22.0
github.com/mulgadc/northstar v1.22.0
github.com/mulgadc/predastore v1.22.0
github.com/mulgadc/viperblock v1.22.0
github.com/nats-io/nats-server/v2 v2.15.0
github.com/nats-io/nats.go v1.54.0
github.com/opencontainers/runtime-spec v1.3.0
github.com/oracle/oci-go-sdk/v65 v65.126.0
github.com/oracle/oci-go-sdk/v65 v65.126.1
github.com/ovn-kubernetes/libovsdb v0.8.1
github.com/pelletier/go-toml/v2 v2.4.3
github.com/pterm/pterm v0.12.83
@@ -48,6 +48,7 @@ require (
golang.org/x/crypto v0.57.0
golang.org/x/sync v0.23.0
golang.org/x/sys v0.48.0
golang.org/x/term v0.46.0
golang.org/x/time v0.16.0
golang.org/x/tools v0.50.0
gopkg.in/ini.v1 v1.67.3
@@ -61,18 +62,18 @@ require (
github.com/Microsoft/hcsshim v0.15.0-rc.4 // indirect
github.com/antithesishq/antithesis-sdk-go v0.8.0-default-no-op // indirect
github.com/atotto/clipboard v0.1.4 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.3 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.3 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.3 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.4 // indirect
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.4 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.4 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.4 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.3 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.3 // indirect
github.com/aws/aws-sdk-go-v2/service/s3 v1.113.3 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.10.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.38.0 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.0 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.51.0 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.5 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.4 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.4 // indirect
github.com/aws/aws-sdk-go-v2/service/s3 v1.114.0 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.10.1 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.38.1 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.1 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.51.1 // indirect
github.com/aws/smithy-go v1.28.2 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/beorn7/perks v1.0.1 // indirect
@@ -177,7 +178,7 @@ require (
github.com/spf13/cast v1.10.0 // indirect
github.com/spf13/pflag v1.0.10 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/tidwall/btree v1.8.1 // indirect
github.com/tidwall/btree v1.8.2 // indirect
github.com/u-root/uio v0.0.0-20230220225925-ffce2a382923 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
@@ -197,7 +198,6 @@ require (
golang.org/x/mod v0.41.0 // indirect
golang.org/x/net v0.59.0 // indirect
golang.org/x/telemetry v0.0.0-20260908163034-4bcc4b2ee518 // indirect
golang.org/x/term v0.46.0 // indirect
golang.org/x/text v0.42.0 // indirect
golang.org/x/vuln v1.6.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260825221802-da73d73af1c5 // indirect
+46 -46
View File
@@ -30,40 +30,40 @@ github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aws/aws-sdk-go v1.55.8 h1:JRmEUbU52aJQZ2AjX4q4Wu7t4uZjOu71uyNmaWlUkJQ=
github.com/aws/aws-sdk-go v1.55.8/go.mod h1:ZkViS9AqA6otK+JBBNH2++sx1sgxrPKcSzPPvQkUtXk=
github.com/aws/aws-sdk-go-v2 v1.47.0 h1:0jsHallhJCeaU0Ko48c/3FK1ctOQ7NpzggxriJOQ8MQ=
github.com/aws/aws-sdk-go-v2 v1.47.0/go.mod h1:bttEH6JqnUL8LepvDVfdrds/fZ5bCIxzpe3abyUrhDU=
github.com/aws/aws-sdk-go-v2 v1.47.1 h1:uOIZnp4PK3ZhKI0dNrJrhTEsLxbpXHTAJlwoS1pvAtw=
github.com/aws/aws-sdk-go-v2 v1.47.1/go.mod h1:bttEH6JqnUL8LepvDVfdrds/fZ5bCIxzpe3abyUrhDU=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20 h1:GPRlPwz40I2B2VrBEASOA3Bi77NyeqejNLkifosX0rs=
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20/go.mod h1:g7PNzKcsOKWb4fkSRBA7BZVAS6Y8IcxzN+nRohhQ1Q8=
github.com/aws/aws-sdk-go-v2/config v1.33.5 h1:UA1dmokBFOLFoOyVBhO6HjM6edy0MIk5AZSkJVcksQw=
github.com/aws/aws-sdk-go-v2/config v1.33.5/go.mod h1:Dop8axzz0xx38GExIYWXdeyc8QQ7Cr+nPsxpD/LYy4U=
github.com/aws/aws-sdk-go-v2/credentials v1.20.5 h1:wklUVvHMc9xTQ3rcp49/ISpiMnhbCicJcA6n6S8m7J8=
github.com/aws/aws-sdk-go-v2/credentials v1.20.5/go.mod h1:fyEdrn6ccLFOkoK84j5bQyGTxp9zPt5l2XMhxf4DVZs=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.0 h1:AM4hHjww+PSFtt6E+UrBrPlZkWsePCLEt9AjkfQX+yM=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.0/go.mod h1:3x/yXezeQjpOvBb4jEMxrS8SXvpdvJ5abv6l5c1gWM8=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.3 h1:Hp/VgjP0BysR3OgLlR057Vz2LcbbVnoWeJ+3qWiS/fY=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.3/go.mod h1:nwGV5qw7F1IZPgxCvA/ph8N2TAuz+BkRG/bXn808qMA=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.3 h1:MUaM4f+kj1ZIBPZfUS8cxP1GKXXZtHJjAthy93AN7SM=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.3/go.mod h1:6YmVmEVRI5ZZzRjCSsb9SryKH0hAlMRdgA7kG9aDvBU=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.3 h1:fuSCw4Z2qfRCztMPO3GXJNSiEp6Wee+WOLwrHHUMy9c=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.3/go.mod h1:6SxcHheD1pPR5+kWm1wGvjlL/YqUsh267sAfEmN4K7A=
github.com/aws/aws-sdk-go-v2/config v1.33.6 h1:MBjkSTLczek/UgiK+EYPIoRTqE7gP8vtW3OFbFo7Nug=
github.com/aws/aws-sdk-go-v2/config v1.33.6/go.mod h1:grRAFzdAZJrwcbasJRg2MPvIrVjtlfXllHssN6+E1JE=
github.com/aws/aws-sdk-go-v2/credentials v1.20.6 h1:NpAFXCU7NzXNkdGK3zQTtsRJ+3v9tZQV0xcdRw8uBdw=
github.com/aws/aws-sdk-go-v2/credentials v1.20.6/go.mod h1:mcZCoiPnyMvP8VMNbygNX5lLqSlkYJIMPODylQMurOk=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.1 h1:8gALAAmacnIXh+z6VkdDanv4/IkG5APdg4DZLDTmLog=
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.1/go.mod h1:Z7IJhJU+poOdJjUR2wpyY21ossQ1XS/R3Lk9Msq5kM4=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.4 h1:CLq4+8UHCI+ZZYl/EuJxXovaIVN2xeeT8JV+dsApQ5E=
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.4/go.mod h1:Wv4q5sAM04xAMkoOedxLx2inVf6K5FdxYp+A61L+q/0=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.4 h1:dD4MR81I7YkpEBRk6UP9rocC2QnT3qVuXwzlYTtfGEs=
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.4/go.mod h1:EcXV1kAFd5XwSkDHlj94gnF3q5CkJyYiIJfH8N0VmrE=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.4 h1:7Wo47d/xn/7KttCSBd8EGYeZ7ULRFRkUHr6vkZPBzVQ=
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.4/go.mod h1:tDB2IVC1xC3vX8o+6uRlzhTxP3g1b77CZXFX/oD2FnQ=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19 h1:bAdDl/HkGCcGPoe25ToSHEw23VIxt6CT5fLcg111BKg=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19/go.mod h1:KaUzbLxv4CeSxh6ZCl9B4m7CuFenS8kUEaDs+f/DQr4=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.4 h1:7TXEkbDzy4BkYYTfTVjKcADTfkDnvHxDwcF1pJG1yF0=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.4/go.mod h1:GqWeeKfYfezihA2KfFL9l7ohEdZWe1tuFWh3GfyNSnE=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.3 h1:bON1rJf67TSTDCKg816AAIE4xSTtoo9tl0XRkO72R+I=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.3/go.mod h1:c5BBpjJcQXpfeq9iASyVKA3T6vX6B6LEXY4mL/gklDY=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.3 h1:L8vIOxylma91TcR96NFTEC07G3JDwSl+CvK2b+IODms=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.3/go.mod h1:fmPIZQzTExYuBNWFyi1P7IoDjvskgphXqK1yObMzusM=
github.com/aws/aws-sdk-go-v2/service/s3 v1.113.3 h1:IDIchqG5C/o/JdprtYn1NirCi7iUx7XQ8+WwZ6odFX8=
github.com/aws/aws-sdk-go-v2/service/s3 v1.113.3/go.mod h1:iJ69H4lkK6a9zQ+L5i9pDERMok5Jvts0iZaMjWEi/78=
github.com/aws/aws-sdk-go-v2/service/signin v1.10.0 h1:ZD5qFpWcaOKdTuhBi431pIDkCgrMkMlMT6jlpSPoIRI=
github.com/aws/aws-sdk-go-v2/service/signin v1.10.0/go.mod h1:8Nuuf+tR346PjJ3MvZPh9pekbLiLQFWJhzMXfwy7alA=
github.com/aws/aws-sdk-go-v2/service/sso v1.38.0 h1:JGeeBcMlhg1xtOXYpeCaTQBZObtXMPQCUqBcmr65NRA=
github.com/aws/aws-sdk-go-v2/service/sso v1.38.0/go.mod h1:XwteswG9EOMRFm73UT0t+MbTwyLxMrEXkU6e+v92Lzo=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.0 h1:obhahQXDEdVEv8y5bTKXR30LVaxYe1kyYM0L7l2Iq+k=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.0/go.mod h1:6twZZ/aXHNy1vXUO8koUbp++MYzMASkOgEBdkbJYmO0=
github.com/aws/aws-sdk-go-v2/service/sts v1.51.0 h1:Zpnqa6XtrNzXZnwbdCqHOXpXhMsa01ql/pcRQ1sb4hk=
github.com/aws/aws-sdk-go-v2/service/sts v1.51.0/go.mod h1:/8JRcdTt//hG0Q4BTmGbuOplT7ABe+5rdtqUHqXvYIM=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.5 h1:/TYsZXdA8UTa+WCtCYSAJIr1vwl0+eho6TUgJGwFFO8=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.5/go.mod h1:qPqp1Uwd/BqdhPufv6oem9j5J7HNsgc2V22dUiDPn+s=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.4 h1:29SvnfGhXjTl8ONxFwbj2rs6lbhiFXD2CgFQmbT/bXY=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.4/go.mod h1:wm04I5DMuNVvZHFe/dHnUxincvNbbK7AiNBbYsQivek=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.4 h1:pPiWfgeNxqluKEph7hvU88kuGKBPOWzO+Dk9t2zqqNs=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.4/go.mod h1:YlwGoIUDG/3kBQbdNOVs/xKZ9J01G8e/6D1mRBj9uTk=
github.com/aws/aws-sdk-go-v2/service/s3 v1.114.0 h1:VMAdYqr4Jn/8ATs9BHC5riwrs0d6m1Z2ohFriSwZwm0=
github.com/aws/aws-sdk-go-v2/service/s3 v1.114.0/go.mod h1:9APRWGLFITKD+xzWSIyT9V7QV4bNlEuIieWlzXgGFlI=
github.com/aws/aws-sdk-go-v2/service/signin v1.10.1 h1:DzCCWLzcIRQ77F3DEUljud7bEjTgFOIKXP52NmVRyhU=
github.com/aws/aws-sdk-go-v2/service/signin v1.10.1/go.mod h1:xpo/geVldu8payT375WekctUzopG/hBU7miiqItMUlw=
github.com/aws/aws-sdk-go-v2/service/sso v1.38.1 h1:Umtl/0YZhng4xndfW3lKJrYYP7NLEjI6bGXVomwLcs0=
github.com/aws/aws-sdk-go-v2/service/sso v1.38.1/go.mod h1:rRD/dnm7q0HYE/I5TMaPgkWyyUGLcwuxHLABsLnQ3e0=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.1 h1:orIWdNiLgzrhu/11RcPPKO/SBzUUymbUQuZbSPImghg=
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.1/go.mod h1:skwM/xsbR/1ReUTesv9BhpJp1VjajR7DWQnuVLwiXsQ=
github.com/aws/aws-sdk-go-v2/service/sts v1.51.1 h1:0HOqZXRvMytH6bFHVIc0oJX07sZjfhz0zXtjs6gdE8s=
github.com/aws/aws-sdk-go-v2/service/sts v1.51.1/go.mod h1:26zA0GhDrLo+yiLI2yXWxqB1PdsShfLikoI7GOEgugM=
github.com/aws/smithy-go v1.28.2 h1:myhcykQcatTul2B/zITjDk203G7t0awUAs1hVry5Bvg=
github.com/aws/smithy-go v1.28.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
@@ -111,8 +111,8 @@ github.com/containerd/console v1.0.5 h1:R0ymNeydRqH2DmakFNdmjR2k0t7UPuiOV/N/27/q
github.com/containerd/console v1.0.5/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk=
github.com/containerd/containerd/api v1.12.0 h1:kuQm82SbDrCuO4n7hf2L8zsBtZLuympyq5X/VotfX2A=
github.com/containerd/containerd/api v1.12.0/go.mod h1:EBcSzoi9Vl18cdODaXUCskf3D2NT8lsSXeZJnU5jIUc=
github.com/containerd/containerd/v2 v2.4.0 h1:mZLYWkPAgF4tfTjinClrRfvUxmxHxg1PQL7cjrrpTtk=
github.com/containerd/containerd/v2 v2.4.0/go.mod h1:gHZz+v5y8mGt9grF3ynuaa3r6bXLmghBZWTAftHAUtg=
github.com/containerd/containerd/v2 v2.4.1 h1:DUx/ZJN7cEu0WuzHClDB+68H/bqMEH5pWoEjf0ae4hc=
github.com/containerd/containerd/v2 v2.4.1/go.mod h1:vgLdtvl3prFk1d3ZVqQcsDD5Q9IKM+vAIdU54U85w+I=
github.com/containerd/continuity v0.5.0 h1:7a85HZpCSs+1Zps0Ee3DPSuAWY+0SJM1JNM51nlEVDg=
github.com/containerd/continuity v0.5.0/go.mod h1:/lNJvtJKUQStBzpVQ1+rasXO1LAWtUQssk28EZvJ3nE=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
@@ -251,8 +251,8 @@ github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfC
github.com/josharian/native v1.0.1-0.20221213033349-c1e37c09b531/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w=
github.com/josharian/native v1.1.0 h1:uuaP0hAbW7Y4l0ZRQ6C9zfb7Mg1mbFKry/xzDAfmtLA=
github.com/josharian/native v1.1.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w=
github.com/klauspost/compress v1.20.0 h1:a3C1ke2ohxFymNlb2HWAHjDeKCI90scRskErZkR0ezA=
github.com/klauspost/compress v1.20.0/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/klauspost/compress v1.20.1 h1:T7kKElXUMXrUJ2E9QhQhxFtcK5rPyLdsGZvdbLMPdiQ=
github.com/klauspost/compress v1.20.1/go.mod h1:LUdAzn7YLVvxLpc7y3V1m40wESHTgc1422pwwBSKYuI=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.0.10/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c=
github.com/klauspost/cpuid/v2 v2.0.12/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c=
@@ -312,14 +312,14 @@ github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELU
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/mulgadc/bluebottle v1.21.0 h1:TH2/B2fVlqZ2SFgr2lebbjkCH9R/4vIVWjXDznNu72A=
github.com/mulgadc/bluebottle v1.21.0/go.mod h1:LH0MuQcFxet0dDEISlZsuULIYj592eCN2Fv39A6EH5o=
github.com/mulgadc/northstar v1.21.0 h1:LE/5kiT3kBelCg02cXa3b7awhaYkGHsOXeJ4nHRLNaQ=
github.com/mulgadc/northstar v1.21.0/go.mod h1:O446cApNMB6qebqieBhDZfCIGVWX++v3QdOz+wIGhUk=
github.com/mulgadc/predastore v1.21.0 h1:pAm4fFWf0vnlwRgnoL2oJ+KSCw8mtjT2rQoBHFmnjzk=
github.com/mulgadc/predastore v1.21.0/go.mod h1:icCfKGnyqDaJ/Jlbp2aYeo2qwrKHZfxmPHDYK1MM7wc=
github.com/mulgadc/viperblock v1.21.0 h1:RbQ/GpHCg/uoPffYioSKqzqyoz22Uc7wpQ7XY7etLg0=
github.com/mulgadc/viperblock v1.21.0/go.mod h1:n0wCOGV58Lt2IVPXEvpbHDkTITlXtFw+2CMrQXgbJ7s=
github.com/mulgadc/bluebottle v1.22.0 h1:pGZ5/Ly3CYem62pPu2zKBRpvMpAG0LHMIMw9Yt/lkIg=
github.com/mulgadc/bluebottle v1.22.0/go.mod h1:DwT91bJyUsrpg1gCJmcCOTG+tgw5J124LloHYvGAQu0=
github.com/mulgadc/northstar v1.22.0 h1:fTndlk47hK76fcNr2MqsyeKhBNVEDt8+BC6Z30ohokA=
github.com/mulgadc/northstar v1.22.0/go.mod h1:yLH8waqfHZx177Ae6dGQGTDt/02rPE9alnUKyxDNhVs=
github.com/mulgadc/predastore v1.22.0 h1:mUQJMTxMiRDVvemMP+34BP/+r1U0iCptc9B++u4NJ8o=
github.com/mulgadc/predastore v1.22.0/go.mod h1:/cMIoKtnQG/DWyeX/0matEQrG2/abY60tNHWcBKS9Kc=
github.com/mulgadc/viperblock v1.22.0 h1:8vS2AtpIDkIhGNOazQPicKXV2y/8PAy8BNrVWIOCP6I=
github.com/mulgadc/viperblock v1.22.0/go.mod h1:Hq8yMagAX8c4wZ68oUkwZPyxTNMwvRwB685eHFewisc=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/nats-io/jwt/v2 v2.8.2 h1:XXRgB60MSTnqsRwejQurVDs/hcv2dkt+86GjI+I/bMc=
@@ -338,8 +338,8 @@ github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJw
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/opencontainers/runtime-spec v1.3.0 h1:YZupQUdctfhpZy3TM39nN9Ika5CBWT5diQ8ibYCRkxg=
github.com/opencontainers/runtime-spec v1.3.0/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0=
github.com/oracle/oci-go-sdk/v65 v65.126.0 h1:RuV0MEcLOOgNOBadYbbkUQriCK4Gm5348F/GdWvYPcI=
github.com/oracle/oci-go-sdk/v65 v65.126.0/go.mod h1:Pzy+BpgkDesvGZXEHgslwhIYobHCPHg6wRta1mWnlqQ=
github.com/oracle/oci-go-sdk/v65 v65.126.1 h1:WmQ2Igq7/L/cHlR2jZnJkBC2UI51Ams4DJZW3CZGVBo=
github.com/oracle/oci-go-sdk/v65 v65.126.1/go.mod h1:YmvgbsnUfrsiJ410XAGQ1BRsrUYJJ7W3O5wUikmeI8w=
github.com/ovn-kubernetes/libovsdb v0.8.1 h1:M2J8bcJt5mXCom0HqzfEtuHkT80CTSQRcYG7acT8gf4=
github.com/ovn-kubernetes/libovsdb v0.8.1/go.mod h1:ZlnHLzagmLOSvyd9qfxBIZp6wOSOw0IsRsc+6lNUGbU=
github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY=
@@ -417,8 +417,8 @@ github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWD
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/tidwall/btree v1.8.1 h1:27ehoXvm5AG/g+1VxLS1SD3vRhp/H7LuEfwNvddEdmA=
github.com/tidwall/btree v1.8.1/go.mod h1:jBbTdUWhSZClZWoDg54VnvV7/54modSOzDN7VXftj1A=
github.com/tidwall/btree v1.8.2 h1:mTY5iM4UTh2e80TG7RHDQQmnAY3xGNQA/DbU0olkhkA=
github.com/tidwall/btree v1.8.2/go.mod h1:jBbTdUWhSZClZWoDg54VnvV7/54modSOzDN7VXftj1A=
github.com/u-root/uio v0.0.0-20230220225925-ffce2a382923 h1:tHNk7XK9GkmKUR6Gh8gVBKXc2MVSZ4G/NnWLtzw4gNA=
github.com/u-root/uio v0.0.0-20230220225925-ffce2a382923/go.mod h1:eLL9Nub3yfAho7qB0MzZizFhTU2QkLeoVsWdHtDW264=
github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778/go.mod h1:2MuV+tbUrU1zIOPMxZ5EncGwgmMJsa+9ucAQZXxsObs=
+2 -2
View File
@@ -77,8 +77,8 @@ func TestEC2ErrorCatalogMetadataAndClasses(t *testing.T) {
if catalog.metadata.VerifiedOn == "" {
t.Fatal("verifiedOn is empty")
}
if len(catalog.codes) != 112 {
t.Fatalf("catalog code count = %d, want 112", len(catalog.codes))
if len(catalog.codes) != 114 {
t.Fatalf("catalog code count = %d, want 114", len(catalog.codes))
}
if catalog.codes["InvalidParameterValue"] != ec2ErrorClassClient {
t.Fatal("InvalidParameterValue is not classified as client")
+9 -4
View File
@@ -48,7 +48,14 @@ func ValidateErrorResponse(service Service, operationName string, body []byte) (
// they are not treated as proof that an operation-specific code conforms.
func isUnmodelledCommonError(service Service, code string) bool {
switch service {
case IAM, ElasticLoadBalancingV2:
case IAM:
return isUnmodelledQueryError(code)
case ElasticLoadBalancingV2:
// ELBv2's common errors list the parameter validation codes too.
switch code {
case "InvalidParameterCombination", "InvalidParameterValue", "MissingParameter", "ValidationError":
return true
}
return isUnmodelledQueryError(code)
case STS:
// STS also uses these two runtime input errors even though the model
@@ -107,9 +114,7 @@ func (m *Model) decodeErrorCode(body []byte) (string, error) {
if separator := strings.LastIndex(code, "#"); separator >= 0 {
code = code[separator+1:]
}
if separator := strings.Index(code, ":"); separator >= 0 {
code = code[:separator]
}
code, _, _ = strings.Cut(code, ":")
if code == "" {
return "", fmt.Errorf("JSON error envelope has no code")
}
+6
View File
@@ -97,6 +97,12 @@ func TestValidateErrorResponseClassifiesCommonErrorsAsUnmodelled(t *testing.T) {
operation: "AssumeRole",
body: `<ErrorResponse><Error><Code>ValidationError</Code></Error></ErrorResponse>`,
},
{
name: "ELBv2 parameter validation",
service: ElasticLoadBalancingV2,
operation: "AddTags",
body: `<ErrorResponse><Error><Code>InvalidParameterValue</Code></Error></ErrorResponse>`,
},
{
name: "JSON authorization",
service: ECS,
+1 -6
View File
@@ -131,12 +131,7 @@ var modelCache sync.Map
// Services returns the supported service identifiers in stable order.
func Services() []Service {
services := make([]Service, 0, len(modelFiles))
for service := range modelFiles {
services = append(services, service)
}
slices.Sort(services)
return services
return slices.Sorted(maps.Keys(modelFiles))
}
// Load parses and indexes the embedded model for service. Each service is
@@ -68,6 +68,7 @@
"InvalidLaunchTemplateName.AlreadyExistsException",
"InvalidLaunchTemplateName.MalformedException",
"InvalidLaunchTemplateName.NotFoundException",
"InvalidMaxResults",
"InvalidNatGatewayID.NotFound",
"InvalidNetworkInterface.InUse",
"InvalidNetworkInterface.NotFound",
@@ -77,6 +78,7 @@
"InvalidPlacementGroup.Duplicate",
"InvalidPlacementGroup.InUse",
"InvalidPlacementGroup.Unknown",
"InvalidRequest",
"InvalidRoute.NotFound",
"InvalidRouteTableID.NotFound",
"InvalidSecurityGroupRuleId.Malformed",
+3 -7
View File
@@ -4,8 +4,9 @@ import (
"bytes"
"encoding/json"
"fmt"
"maps"
"regexp"
"sort"
"slices"
"strconv"
"strings"
"unicode/utf8"
@@ -253,12 +254,7 @@ func validateFrontmatter(owner string, page PageMetadata) error {
// key rather than by first use, so adding an operation cannot renumber a note
// that is already published.
func noteOrder(notes map[string]string) []string {
keys := make([]string, 0, len(notes))
for key := range notes {
keys = append(keys, key)
}
sort.Strings(keys)
return keys
return slices.Sorted(maps.Keys(notes))
}
// checkNotes rejects a note nothing references and a reference to a note that
+17 -1
View File
@@ -51,8 +51,24 @@ func newPatternSampler(pattern string) (*patternSampler, error) {
return sampler, err
}
// re2Equivalents rewrite model patterns that use lookahead, which Go regexp
// lacks, as patterns matching the same strings.
var re2Equivalents = map[string]string{
// ACM DomainNameString: labels start and end alphanumeric, the last has two
// or more characters, with an optional leading wildcard label.
`^(\*\.)?(((?!-)[A-Za-z0-9-]{0,62}[A-Za-z0-9])\.)+((?!-)[A-Za-z0-9-]{1,62}[A-Za-z0-9])$`: `^(\*\.)?([A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?\.)+[A-Za-z0-9][A-Za-z0-9-]{0,61}[A-Za-z0-9]$`,
}
// re2Pattern spells a model pattern in Go regexp syntax.
func re2Pattern(pattern string) string {
if equivalent, ok := re2Equivalents[pattern]; ok {
return equivalent
}
return javaEscape.ReplaceAllString(pattern, `\x{$1}`)
}
func compilePatternSampler(pattern string) (*patternSampler, error) {
pattern = javaEscape.ReplaceAllString(pattern, `\x{$1}`)
pattern = re2Pattern(pattern)
re, err := regexp.Compile(pattern)
if err != nil {
return nil, err
+31 -10
View File
@@ -65,22 +65,33 @@ func GenerateRequests(service Service, operationName string, options RequestOpti
if !ok {
return RequestPlan{}, fmt.Errorf("awsmodel: %s operation %q is not modelled", service, operationName)
}
if service == ACM {
if _, err := importMaterial(); err != nil {
return RequestPlan{}, err
}
}
declared := model.operationErrorCodes(operation)
if operation.Input == nil {
return RequestPlan{Cases: []RequestCase{{Operation: operationName, Input: map[string]any{}}}, DeclaredErrors: declared}, nil
}
first := model.newRequestGenerator(0, options)
// A create or delete names a resource of its own, so it neither collides
// with a fixture nor removes one.
if strings.HasPrefix(operationName, "Create") || strings.HasPrefix(operationName, "Delete") {
options.Fixtures = nil
}
first := model.newRequestGenerator(operationName, 0, options)
full, ok := first.structure(operation.Input.Shape, nil, 0)
plan := RequestPlan{Skipped: first.skipped, Unbroken: first.unbroken, DeclaredErrors: declared}
if !ok {
plan.Skipped = append(plan.Skipped, Skip{Path: "$", Reason: "a required member cannot be generated"})
return plan, nil
}
input := model.shapes[operation.Input.Shape]
// Conditionally required members join every request but are never omitted.
required := append(slices.Clone(model.shapes[operation.Input.Shape].Required), conditionallyRequired[service][operationName]...)
optional := make([]string, 0, len(full))
for _, member := range slices.Sorted(maps.Keys(full)) {
if !slices.Contains(input.Required, member) {
if !slices.Contains(required, member) {
optional = append(optional, member)
}
}
@@ -90,7 +101,7 @@ func GenerateRequests(service Service, operationName string, options RequestOpti
// what an earlier case created.
salt := 0
generate := func() (map[string]any, []constraintSite, error) {
generator := model.newRequestGenerator(salt, options)
generator := model.newRequestGenerator(operationName, salt, options)
salt++
value, _ := generator.structure(operation.Input.Shape, nil, 0)
if len(generator.sites) != len(first.sites) {
@@ -111,7 +122,7 @@ func GenerateRequests(service Service, operationName string, options RequestOpti
Operation: operationName,
Member: member,
Path: memberPathOf(member),
Input: keepMembers(value, input.Required, member),
Input: keepMembers(value, required, member),
})
}
for index := range first.sites {
@@ -121,10 +132,10 @@ func GenerateRequests(service Service, operationName string, options RequestOpti
}
site := sites[index]
member, _ := site.path[0].(string)
if slices.Contains(input.Required, member) {
if slices.Contains(required, member) {
member = ""
}
broken, err := site.apply(keepMembers(value, input.Required, member))
broken, err := site.apply(keepMembers(value, required, member))
if err != nil {
return RequestPlan{}, err
}
@@ -269,6 +280,7 @@ var generatedTimestamp = time.Date(2026, time.January, 1, 0, 0, 0, 0, time.UTC)
type requestGenerator struct {
model *Model
operation string
options RequestOptions
salt string
saltNumber int
@@ -278,8 +290,8 @@ type requestGenerator struct {
visiting map[string]bool
}
func (m *Model) newRequestGenerator(salt int, options RequestOptions) *requestGenerator {
return &requestGenerator{model: m, options: options, salt: saltLetters(salt), saltNumber: salt, visiting: map[string]bool{}}
func (m *Model) newRequestGenerator(operation string, salt int, options RequestOptions) *requestGenerator {
return &requestGenerator{model: m, operation: operation, options: options, salt: saltLetters(salt), saltNumber: salt, visiting: map[string]bool{}}
}
// saltLetters spells n in base 26 with lower-case letters, which fit more
@@ -462,6 +474,9 @@ func (g *requestGenerator) mapValue(shape *Shape, path []pathStep, depth int) (a
func (g *requestGenerator) stringValue(shape *Shape, ref ShapeRef, path []pathStep) (any, bool) {
if len(shape.Enum) > 0 {
g.site(path, constraintSite{constraint: ConstraintEnum, detail: "value not in enum", replacement: invalidEnumValue(shape.Enum)})
if value, ok := g.enumHint(path); ok && slices.Contains(shape.Enum, value) {
return value, true
}
return shape.Enum[0], true
}
@@ -583,7 +598,10 @@ func invalidEnumValue(values []string) string {
}
func (g *requestGenerator) integer(shape *Shape, path []pathStep) int64 {
value := int64(1)
value, ok := g.integerHint(path)
if !ok {
value = 1
}
if shape.Min != nil {
value = max(value, int64(math.Ceil(*shape.Min)))
}
@@ -630,5 +648,8 @@ func (g *requestGenerator) blob(shape *Shape, path []pathStep) []byte {
g.leaveUnbroken(path, fmt.Sprintf("max length %g bytes is above the generated limit", *shape.Max))
}
}
if value, ok := g.blobHint(path); ok && len(value) >= minLength && len(value) <= maxLength {
return value
}
return []byte(fitLength("spx"+g.salt, minLength, maxLength))
}
+216 -5
View File
@@ -1,17 +1,29 @@
package awsmodel
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"fmt"
"math/big"
"regexp"
"slices"
"strings"
"sync"
"time"
)
// RequestOptions places generated ARNs in the account and region the
// requests are sent as.
// requests are sent as. AccessKeyID is the key the requests are signed with.
// Fixtures maps a member to an existing resource it can name.
type RequestOptions struct {
AccountID string
Region string
AccountID string
Region string
AccessKeyID string
Fixtures map[string]string
}
// Models constrain most identifiers only by length, so "spxa" is a model-valid
@@ -56,11 +68,71 @@ var ec2IDPrefixes = map[string]string{
var ec2IDMember = regexp.MustCompile(`^(.*?)Ids?$`)
// conditionallyRequired names members the model marks optional that the
// operation still needs in its base request. The services refuse their absence
// with a code the judge cannot tell from a real finding.
var conditionallyRequired = map[Service]map[string][]string{
EC2: {"CreateCapacityReservation": {"AvailabilityZone"}},
ECS: {
"CreateCapacityProvider": {"autoScalingGroupProvider"},
"CreateService": {"taskDefinition"},
},
RDS: {
"CreateDBInstance": {"AllocatedStorage", "MasterUsername", "MasterUserPassword"},
"RestoreDBInstanceFromDBSnapshot": {"DBSnapshotIdentifier"},
},
}
const (
// imageManifest is well formed but names blobs no repository holds.
imageManifest = `{"schemaVersion":2,"mediaType":"application/vnd.docker.distribution.manifest.v2+json","config":{"mediaType":"application/vnd.docker.container.image.v1+json","size":2,"digest":"sha256:0000000000000000000000000000000000000000000000000000000000000000"},"layers":[]}`
lifecyclePolicy = `{"rules":[{"rulePriority":1,"description":"expire untagged","selection":{"tagStatus":"untagged","countType":"sinceImagePushed","countUnit":"days","countNumber":14},"action":{"type":"expire"}}]}`
repositoryPolicy = `{"Version":"2012-10-17","Statement":[{"Sid":"pull","Effect":"Allow","Principal":{"AWS":"arn:aws:iam::%s:root"},"Action":"ecr:BatchGetImage"}]}`
policyDocument = `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":"s3:GetObject","Resource":"*"}]}`
trustPolicyDocument = `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"ec2.amazonaws.com"},"Action":"sts:AssumeRole"}]}`
)
// ec2TaggedTypes is the resource type each EC2 create tags through its
// TagSpecifications. AWS refuses any other type, so the enum's first value
// would make an otherwise valid request invalid.
var ec2TaggedTypes = map[string]string{
"AllocateAddress": "elastic-ip",
"AuthorizeSecurityGroupEgress": "security-group-rule",
"AuthorizeSecurityGroupIngress": "security-group-rule",
"CopyImage": "image",
"CopySnapshot": "snapshot",
"CreateCapacityReservation": "capacity-reservation",
"CreateEgressOnlyInternetGateway": "egress-only-internet-gateway",
"CreateImage": "image",
"CreateInternetGateway": "internet-gateway",
"CreateKeyPair": "key-pair",
"CreateLaunchTemplate": "launch-template",
"CreateNatGateway": "natgateway",
"CreateNetworkInterface": "network-interface",
"CreatePlacementGroup": "placement-group",
"CreateRouteTable": "route-table",
"CreateSecurityGroup": "security-group",
"CreateSnapshot": "snapshot",
"CreateSubnet": "subnet",
"CreateVolume": "volume",
"CreateVpc": "vpc",
"ImportKeyPair": "key-pair",
"RegisterImage": "image",
"RequestSpotInstances": "spot-instances-request",
"RunInstances": "instance",
}
// enumHint returns the enum value a valid request needs at path, where the
// first value would not do.
func (g *requestGenerator) enumHint(path []pathStep) (string, bool) {
if g.model.service != EC2 || len(path) == 0 || path[0] != "TagSpecifications" || memberName(path) != "ResourceType" {
return "", false
}
value, ok := ec2TaggedTypes[g.operation]
return value, ok
}
// hint returns a value of the right form for the member at path, if one is
// known. salt keeps names distinct between request cases.
func (g *requestGenerator) hint(path []pathStep) (string, bool) {
@@ -69,11 +141,20 @@ func (g *requestGenerator) hint(path []pathStep) (string, bool) {
return "", false
}
options := g.options
if fixture, ok := options.Fixtures[member]; ok {
return fixture, true
}
suffix := fmt.Sprintf("%017x", g.saltNumber)
name := "spx" + g.salt
switch g.model.service {
case EC2:
switch member {
case "AvailabilityZone":
return options.Region + "a", true
case "SourceRegion":
return options.Region, true
}
if match := ec2IDMember.FindStringSubmatch(capitalize(member)); match != nil {
if prefix, ok := ec2IDPrefixes[match[1]]; ok {
return prefix + "-" + suffix, true
@@ -90,6 +171,10 @@ func (g *requestGenerator) hint(path []pathStep) (string, bool) {
return base + "listener/app/" + name + "/" + suffix[1:] + "/" + suffix[1:], true
case "RuleArn", "RuleArns":
return base + "listener-rule/app/" + name + "/" + suffix[1:] + "/" + suffix[1:] + "/" + suffix[1:], true
case "ProtocolVersion":
return "HTTP1", true
case "HttpCode":
return "200", true
}
case RDS:
switch member {
@@ -101,6 +186,8 @@ func (g *requestGenerator) hint(path []pathStep) (string, bool) {
return "db.t3.medium", true
case "DBParameterGroupFamily":
return "postgres18", true
case "MasterUserPassword":
return "spx-conformance-" + g.salt, true
case "SnapshotType":
return "manual", true
case "Source":
@@ -113,13 +200,40 @@ func (g *requestGenerator) hint(path []pathStep) (string, bool) {
case "taskDefinition":
return name + ":1", true
}
case ACM:
// The integration tenant CA permits example.com.
switch member {
case "DomainName", "SubjectAlternativeNames", "ValidationDomain":
return name + ".example.com", true
}
case ECR:
if member == "resourceArn" {
switch member {
case "resourceArn":
return fmt.Sprintf("arn:aws:ecr:%s:%s:repository/%s", options.Region, options.AccountID, name), true
case "imageManifest":
return imageManifest, true
case "lifecyclePolicyText":
return lifecyclePolicy, true
case "policyText":
return fmt.Sprintf(repositoryPolicy, options.AccountID), true
}
case EKS:
if member == "resourceArn" {
switch member {
case "resourceArn":
return fmt.Sprintf("arn:aws:eks:%s:%s:cluster/%s", options.Region, options.AccountID, name), true
case "policyArn":
return "arn:aws:eks::aws:cluster-access-policy/AmazonEKSViewPolicy", true
case "addonName":
return "aws-ebs-csi-driver", true
}
// An access entry's type; a nested type names an identity provider's.
if member == "type" && len(path) == 1 {
return "STANDARD", true
}
case STS:
// GetAccessKeyInfo answers only for a key that exists.
if member == "AccessKeyId" && options.AccessKeyID != "" {
return options.AccessKeyID, true
}
}
@@ -142,6 +256,103 @@ func (g *requestGenerator) hint(path []pathStep) (string, bool) {
return "", false
}
// integerHint returns a value AWS accepts for an integer member whose range
// the model leaves out, if one is known.
func (g *requestGenerator) integerHint(path []pathStep) (int64, bool) {
if g.model.service == RDS && memberName(path) == "AllocatedStorage" {
// The smallest gp2/gp3 allocation PostgreSQL, the Engine hint, accepts.
return 20, true
}
return 0, false
}
// blobHint returns a value AWS accepts for a blob member, if one is known.
func (g *requestGenerator) blobHint(path []pathStep) ([]byte, bool) {
if g.model.service != ACM {
return nil, false
}
// GenerateRequests has already surfaced a generation error.
material, err := importMaterial()
if err != nil {
return nil, false
}
switch memberName(path) {
case "Certificate":
return material.certificate, true
case "PrivateKey":
return material.privateKey, true
case "CertificateChain":
return material.chain, true
}
return nil, false
}
// certificateMaterial is a PEM leaf certificate, its key and the CA that
// signed it, as ImportCertificate takes them.
type certificateMaterial struct {
certificate, privateKey, chain []byte
}
// importMaterial is generated once per run; minting a key per request would
// dominate the ACM cases.
var importMaterial = sync.OnceValues(func() (certificateMaterial, error) {
now := time.Now().UTC()
caKey, caDER, err := newCertificate(&x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "Spinifex Conformance CA"},
NotBefore: now.Add(-time.Hour),
NotAfter: now.AddDate(1, 0, 0),
KeyUsage: x509.KeyUsageCertSign,
BasicConstraintsValid: true,
IsCA: true,
}, nil, nil)
if err != nil {
return certificateMaterial{}, err
}
ca, err := x509.ParseCertificate(caDER)
if err != nil {
return certificateMaterial{}, fmt.Errorf("awsmodel: parse conformance CA: %w", err)
}
leafKey, leafDER, err := newCertificate(&x509.Certificate{
SerialNumber: big.NewInt(2),
Subject: pkix.Name{CommonName: "spx.example.com"},
DNSNames: []string{"spx.example.com"},
NotBefore: now.Add(-time.Hour),
NotAfter: now.AddDate(1, 0, 0),
KeyUsage: x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
}, ca, caKey)
if err != nil {
return certificateMaterial{}, err
}
keyDER, err := x509.MarshalPKCS8PrivateKey(leafKey)
if err != nil {
return certificateMaterial{}, fmt.Errorf("awsmodel: marshal conformance key: %w", err)
}
return certificateMaterial{
certificate: pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: leafDER}),
privateKey: pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: keyDER}),
chain: pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: caDER}),
}, nil
})
// newCertificate creates a P-256 key and a certificate for it from template,
// signed by parent's key, or self-signed when parent is nil.
func newCertificate(template, parent *x509.Certificate, parentKey *ecdsa.PrivateKey) (*ecdsa.PrivateKey, []byte, error) {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, nil, fmt.Errorf("awsmodel: generate conformance key: %w", err)
}
if parent == nil {
parent, parentKey = template, key
}
der, err := x509.CreateCertificate(rand.Reader, template, parent, &key.PublicKey, parentKey)
if err != nil {
return nil, nil, fmt.Errorf("awsmodel: create conformance certificate: %w", err)
}
return key, der, nil
}
// memberName is the structure member a value at path belongs to, looking
// through list indices.
func memberName(path []pathStep) string {
+145 -3
View File
@@ -4,6 +4,8 @@ package awsmodel
//shapes, and the unexported pattern sampler and salt helpers.
import (
"crypto/tls"
"crypto/x509"
"fmt"
"maps"
"regexp"
@@ -17,7 +19,7 @@ import (
"github.com/stretchr/testify/require"
)
var testRequestOptions = RequestOptions{AccountID: "123456789012", Region: "ap-southeast-2"}
var testRequestOptions = RequestOptions{AccountID: "123456789012", Region: "ap-southeast-2", AccessKeyID: "AKIAIOSFODNN7EXAMPLE"}
// constraintViolation is one model constraint an input breaks, found by
// walking the input against the model independently of the generator.
@@ -99,7 +101,7 @@ func testPattern(pattern string) *regexp.Regexp {
if re, ok := testPatterns.Load(pattern); ok {
return re.(*regexp.Regexp)
}
re := regexp.MustCompile(javaEscape.ReplaceAllString(pattern, `\x{$1}`))
re := regexp.MustCompile(re2Pattern(pattern))
testPatterns.Store(pattern, re)
return re
}
@@ -136,7 +138,7 @@ func TestGeneratedRequestsBreakOnlyTheirConstraint(t *testing.T) {
var violations []constraintViolation
model.inputViolations(operation.Input.Shape, "$", request.Input, &violations)
for member := range request.Input {
if !slices.Contains(input.Required, member) {
if !slices.Contains(input.Required, member) && !slices.Contains(conditionallyRequired[service][operationName], member) {
require.Equal(t, request.Member, member, "%s %s %s carries an unrelated optional member", operationName, request.Constraint, request.Path)
}
}
@@ -180,6 +182,65 @@ func TestGenerateRequestsIsolatesEachOptionalMember(t *testing.T) {
}
}
func TestGenerateRequestsSeedsConditionallyRequiredMembers(t *testing.T) {
for service, operations := range conditionallyRequired {
model, err := Load(service)
require.NoError(t, err)
for operationName, members := range operations {
operation, ok := model.Operation(operationName)
require.True(t, ok, "%s %s is not modelled", service, operationName)
input := model.shapes[operation.Input.Shape]
for _, member := range members {
require.Contains(t, input.Members, member, "%s %s", service, operationName)
require.NotContains(t, input.Required, member, "%s %s now models %s as required", service, operationName, member)
}
plan, err := GenerateRequests(service, operationName, testRequestOptions)
require.NoError(t, err)
for _, request := range plan.Cases {
for _, member := range members {
require.NotEqual(t, member, request.Member, "%s %s", service, operationName)
require.Contains(t, request.Input, member, "%s %s %s %s", service, operationName, request.Constraint, request.Path)
}
}
}
}
plan, err := GenerateRequests(RDS, "CreateDBInstance", testRequestOptions)
require.NoError(t, err)
require.Equal(t, int64(20), plan.Cases[0].Input["AllocatedStorage"])
plan, err = GenerateRequests(EC2, "CreateCapacityReservation", testRequestOptions)
require.NoError(t, err)
require.Equal(t, "ap-southeast-2a", plan.Cases[0].Input["AvailabilityZone"])
// A constraint inside a seeded member is judged against the base request.
plan, err = GenerateRequests(ECS, "CreateCapacityProvider", testRequestOptions)
require.NoError(t, err)
nested := 0
for _, request := range plan.Cases {
if strings.HasPrefix(request.Path, "$.autoScalingGroupProvider.") {
nested++
require.Empty(t, request.Member, request.Path)
}
}
require.NotZero(t, nested)
}
func TestGenerateRequestsTargetsFixturesOutsideCreateAndDelete(t *testing.T) {
options := testRequestOptions
options.Fixtures = map[string]string{"RoleName": "fixture"}
for operation, wantFixture := range map[string]bool{"TagRole": true, "CreateRole": false, "DeleteRole": false} {
plan, err := GenerateRequests(IAM, operation, options)
require.NoError(t, err)
for _, request := range plan.Cases {
if request.Path == "$.RoleName" {
continue
}
require.Equal(t, wantFixture, request.Input["RoleName"] == "fixture", "%s %s %s", operation, request.Constraint, request.Path)
}
}
}
func TestGenerateRequestsAttributesRejections(t *testing.T) {
plan, err := GenerateRequests(IAM, "CreateRole", testRequestOptions)
require.NoError(t, err)
@@ -278,6 +339,13 @@ func TestGenerateRequestsUsesResourceHints(t *testing.T) {
{RDS, "CreateDBInstance", "Engine", regexp.MustCompile(`^postgres$`)},
{ECR, "TagResource", "resourceArn", regexp.MustCompile(`^arn:aws:ecr:ap-southeast-2:123456789012:repository/`)},
{EKS, "TagResource", "resourceArn", regexp.MustCompile(`^arn:aws:eks:ap-southeast-2:123456789012:cluster/`)},
{EKS, "AssociateAccessPolicy", "policyArn", regexp.MustCompile(`^arn:aws:eks::aws:cluster-access-policy/AmazonEKSViewPolicy$`)},
{EKS, "CreateAddon", "addonName", regexp.MustCompile(`^aws-ebs-csi-driver$`)},
{EKS, "CreateAccessEntry", "type", regexp.MustCompile(`^STANDARD$`)},
{ElasticLoadBalancingV2, "CreateTargetGroup", "ProtocolVersion", regexp.MustCompile(`^HTTP1$`)},
{EC2, "CopyImage", "SourceRegion", regexp.MustCompile(`^ap-southeast-2$`)},
{STS, "GetAccessKeyInfo", "AccessKeyId", regexp.MustCompile(`^AKIAIOSFODNN7EXAMPLE$`)},
{IAM, "DeleteAccessKey", "AccessKeyId", regexp.MustCompile(`^AKIA[0-9A-F]{16}$`)},
}
for _, test := range tests {
t.Run(test.operation+"."+test.member, func(t *testing.T) {
@@ -302,6 +370,80 @@ func TestGenerateRequestsUsesResourceHints(t *testing.T) {
}
}
func TestGenerateRequestsHintsNestedMembers(t *testing.T) {
plan, err := GenerateRequests(ElasticLoadBalancingV2, "CreateTargetGroup", testRequestOptions)
require.NoError(t, err)
for _, request := range plan.Cases {
if request.Acceptance() && request.Member == "Matcher" {
require.Equal(t, "200", request.Input["Matcher"].(map[string]any)["HttpCode"])
return
}
}
t.Fatal("no acceptance request sets Matcher")
}
// AWS refuses a tag resource type the operation does not create, so a valid
// request tags the operation's own type rather than the enum's first value.
func TestGenerateRequestsTagsTheOperationsOwnResourceType(t *testing.T) {
for operation, want := range map[string]string{"CreateVpc": "vpc", "CreateLaunchTemplate": "launch-template"} {
t.Run(operation, func(t *testing.T) {
plan, err := GenerateRequests(EC2, operation, testRequestOptions)
require.NoError(t, err)
for _, request := range plan.Cases {
if request.Acceptance() && request.Member == "TagSpecifications" {
spec := request.Input["TagSpecifications"].([]any)[0].(map[string]any)
require.Equal(t, want, spec["ResourceType"])
return
}
}
t.Fatal("no acceptance request sets TagSpecifications")
})
}
}
func TestGenerateRequestsImportsAVerifiableCertificate(t *testing.T) {
plan, err := GenerateRequests(ACM, "ImportCertificate", testRequestOptions)
require.NoError(t, err)
var input map[string]any
for _, request := range plan.Cases {
if request.Acceptance() && request.Member == "CertificateChain" {
input = request.Input
}
}
require.NotNil(t, input, "no acceptance request sets CertificateChain")
pair, err := tls.X509KeyPair(input["Certificate"].([]byte), input["PrivateKey"].([]byte))
require.NoError(t, err)
leaf, err := x509.ParseCertificate(pair.Certificate[0])
require.NoError(t, err)
roots := x509.NewCertPool()
require.True(t, roots.AppendCertsFromPEM(input["CertificateChain"].([]byte)))
_, err = leaf.Verify(x509.VerifyOptions{Roots: roots, DNSName: "spx.example.com"})
require.NoError(t, err)
}
func TestRE2EquivalentsMatchTheModelledSemantics(t *testing.T) {
modelled := map[string]bool{}
for _, service := range Services() {
model, err := Load(service)
require.NoError(t, err)
for _, shape := range model.shapes {
modelled[shape.Pattern] = true
}
}
for pattern := range re2Equivalents {
require.True(t, modelled[pattern], "no model uses %s", pattern)
}
domain := testPattern(`^(\*\.)?(((?!-)[A-Za-z0-9-]{0,62}[A-Za-z0-9])\.)+((?!-)[A-Za-z0-9-]{1,62}[A-Za-z0-9])$`)
for _, name := range []string{"example.com", "*.example.com", "a.b.co", "x-y.example.com", "a." + strings.Repeat("b", 63)} {
require.True(t, domain.MatchString(name), name)
}
for _, name := range []string{"example", "-a.com", "a-.com", "a.c", "a.-com", "a.com-", "a..com", "*.com.*", strings.Repeat("a", 64) + ".com"} {
require.False(t, domain.MatchString(name), name)
}
}
func TestPatternSamplerMatchesWholeValue(t *testing.T) {
tests := []struct {
pattern string
+1 -3
View File
@@ -135,9 +135,7 @@ func DecodeRequestResult(service Service, status int, header http.Header, body [
}
func trimErrorCode(code string) string {
if separator := strings.Index(code, ":"); separator >= 0 {
code = code[:separator]
}
code, _, _ = strings.Cut(code, ":")
if separator := strings.LastIndex(code, "#"); separator >= 0 {
code = code[separator+1:]
}
+4 -6
View File
@@ -109,11 +109,9 @@ func GatewayHTTPClient(caPath string) (*http.Client, error) {
// HostFromImage returns the host[:port] portion of an image ref or URL. It strips
// any scheme and trims at the first path separator.
func HostFromImage(image string) string {
if i := strings.Index(image, "://"); i >= 0 {
image = image[i+3:]
if _, rest, ok := strings.Cut(image, "://"); ok {
image = rest
}
if i := strings.IndexByte(image, '/'); i >= 0 {
image = image[:i]
}
return image
host, _, _ := strings.Cut(image, "/")
return host
}
+8 -5
View File
@@ -10,11 +10,13 @@ import (
"strings"
)
// Loader holds the KEY=value settings read from an agent.env file, keyed by
// variable name. Read values through Get so the process environment applies.
type Loader map[string]string
// A missing or unreadable file yields an empty Loader rather than an error: the
// settings are all optional, and a guest whose file never arrived must still
// start far enough to report why.
// Load parses the KEY=value file at path, skipping blanks and # comments. A
// missing or unreadable file yields an empty Loader rather than an error: the
// settings are optional, and a guest without its file must still report why.
func Load(path string) Loader {
out := Loader{}
f, err := os.Open(path)
@@ -38,8 +40,9 @@ func Load(path string) Loader {
return out
}
// A real environment variable wins, so an operator or test can override what
// cloud-init wrote. An empty value is treated as unset rather than as a mask.
// Get returns the setting for key. A real environment variable wins, so an
// operator or test can override what cloud-init wrote. An empty value is
// treated as unset rather than as a mask.
func (l Loader) Get(key string) string {
if v := os.Getenv(key); v != "" {
return v
+9 -6
View File
@@ -37,7 +37,8 @@ const (
defaultTimeout = 40 * time.Second
)
// One client is reused for register/heartbeat/bootstrap/poll.
// Client sends SigV4-signed RDS Query calls to the gateway. One client is
// reused for register/heartbeat/bootstrap/poll.
type Client struct {
baseURL string
signer *gwsign.Signer
@@ -45,8 +46,9 @@ type Client struct {
httpClient *http.Client
}
// caPath optionally pins the gateway TLS CA; empty relies on the system trust
// store. region defaults to us-east-1, since SigV4 requires a non-empty one.
// New builds a TLS 1.3 client for baseURL. caPath optionally pins the gateway
// TLS CA; empty relies on the system trust store. region defaults to us-east-1,
// since SigV4 requires a non-empty one.
func New(baseURL, caPath string, signer *gwsign.Signer, region string, timeout time.Duration) (*Client, error) {
if baseURL == "" {
return nil, fmt.Errorf("rdsgw: baseURL is required")
@@ -88,8 +90,8 @@ func New(baseURL, caPath string, signer *gwsign.Signer, region string, timeout t
}, nil
}
// Code is the AWS error code, so a caller branches on the failure class rather
// than matching message text.
// APIError is a non-2xx gateway response. Code is the AWS error code, so a
// caller branches on the failure class rather than matching message text.
type APIError struct {
Action string
StatusCode int
@@ -113,8 +115,9 @@ type errorResponse struct {
} `xml:"Error"`
}
// Call POSTs action and decodes its <ActionResult> into out, which may be nil.
// params carries the action's own arguments; Action and Version are set here.
// out may be nil. A non-2xx yields an *APIError. No retry; callers wrap.
// A non-2xx yields an *APIError. No retry; callers wrap.
func (c *Client) Call(ctx context.Context, action string, params url.Values, out any) error {
form := make(url.Values, len(params)+2)
maps.Copy(form, params)
+23
View File
@@ -1,6 +1,7 @@
#!/bin/bash
# Spinifex binary installer
# Usage: curl -sfL https://install.mulgadc.com | bash
# curl -sfL https://install.mulgadc.com | sudo bash -s -- --channel dev
#
# Environment variables:
# INSTALL_SPINIFEX_CHANNEL Release channel: latest (default), dev
@@ -897,6 +898,17 @@ install_files() {
info " /usr/local/share/spinifex/setup.sh"
fi
# Formation. Shipping these means a multi-node cluster can be formed from a
# `curl | bash` install with no checkout, which is what a cloud operator has.
# smoke-test.sh comes too: install-node.sh scp's it from its own directory for
# the post-formation verification, so without it formation fails at the last step.
for formation in install-node.sh smoke-test.sh; do
if [ -f "$EXTRACT_DIR/$formation" ]; then
$SUDO install -m 0755 "$EXTRACT_DIR/$formation" "/usr/local/share/spinifex/$formation"
info " /usr/local/share/spinifex/$formation"
fi
done
# Teardown scripts. An install delivered by `curl | bash` with no way to
# undo it is a gap in the product, not just in our tooling, and node-reset.sh
# ships alongside because uninstall-spx.sh delegates its state teardown to it
@@ -1390,11 +1402,22 @@ main() {
case "$1" in
--firewall=*) INSTALL_SPINIFEX_FIREWALL="${1#*=}" ;;
--firewall) INSTALL_SPINIFEX_FIREWALL="${2:-}"; shift ;;
--channel=*) INSTALL_SPINIFEX_CHANNEL="${1#*=}" ;;
--channel) INSTALL_SPINIFEX_CHANNEL="${2:-}"; shift ;;
--version=*) INSTALL_SPINIFEX_VERSION="${1#*=}" ;;
--version) INSTALL_SPINIFEX_VERSION="${2:-}"; shift ;;
*) fatal "unknown option: $1" ;;
esac
shift
done
case "$INSTALL_SPINIFEX_CHANNEL" in
latest|dev) ;;
# Anything else is passed to the install endpoint as a literal tag, so a
# typo would 404 on the download rather than here. Name the two channels.
*) fatal "--channel must be 'latest' or 'dev', got: ${INSTALL_SPINIFEX_CHANNEL} (use --version for a specific tag)" ;;
esac
case "${INSTALL_SPINIFEX_FIREWALL}" in
on|off) ;;
# Unset resolves by install path: the ISO arms, everything else does not.
+5
View File
@@ -8,3 +8,8 @@ terraform.tfstate.*
crash.log
terraform.tfvars
*.pem
*.log
.validate-*/
# Per-run artifacts and the identity state, which is not a topology's to destroy.
.e2e-oci-*/
.identity/
+73 -8
View File
@@ -31,7 +31,7 @@ Every resource is prefixed with `deployment_name` (default `spinifex`), so nothi
## Quick Start
Prerequisites: Terraform, Python 3, OCI credentials in `~/.oci/config`, and the public and private halves of the SSH key. The Python helper creates and uses this repository's isolated `.venv` and installs [requirements.txt](requirements.txt).
Prerequisites: Terraform, Python 3, OCI credentials in `~/.oci/config` or the environment, and the public and private halves of the SSH key. The Python helper is standard library only, so there is nothing to install: it once used the OCI SDK, which meant a virtualenv and therefore `python3-venv` on every host that runs it.
```bash
cd scripts/terraform/oci-spx
@@ -53,13 +53,11 @@ python3 scripts/oci_env.py --ssh-public-key-path <path_to_public_ssh_key> -- ter
| Item | Purpose |
| --- | --- |
| `scripts/oci_env.py` | Loads OCI profile inputs, discovers the tenancy home region, exports Terraform variables, and runs Terraform in the repository `.venv`. |
| `requirements.txt` | Python dependency set for the environment helper. |
| `scripts/oci_env.py` | Loads the credential from a profile or the environment, checks its shape, exports Terraform variables, and runs Terraform. |
| `terraform.tfvars.example` | Example non-secret Terraform inputs. Copy it to untracked `terraform.auto.tfvars` for local overrides. |
| `.venv/` | Repository-local virtual environment; generated locally and not committed. |
| `terraform.tfstate` | Terraform state; generated locally unless a remote backend is configured. Do not commit it. |
The helper defaults to OCI profile `apacanzset03child03`. If it is absent and `apacanzset03child3` is locally configured, the helper explicitly falls back to that profile. Override either setting when needed:
The helper reads the `DEFAULT` profile, which is what `oci setup config` writes. Set `OCI_CLI_PROFILE` or pass `--profile` to read a different one; a named profile the config does not hold is an error rather than a fallback, so a deployment cannot land in a tenancy nobody chose.
```bash
python3 scripts/oci_env.py --profile my-profile --region ap-sydney-1 -- terraform plan
@@ -125,7 +123,6 @@ flowchart TB
├── outputs.tf # OCIDs and policy outputs
├── versions.tf # Terraform and provider version constraints
├── terraform.tfvars.example
├── requirements.txt
└── scripts/oci_env.py # Python environment and Terraform command helper
```
@@ -208,9 +205,77 @@ The private subnet is **unused today** and kept only so it exists: Spinifex gues
node_client_cidr_allow_list = ["203.0.113.10/32"]
```
## The API user this seeds
## How each node authenticates to OCI
The OCI provider integration needs credentials on each node so Spinifex can allocate addresses at runtime. **Grant that user only the operations Spinifex performs on addresses** — create/get/list/delete private IPs, create/get/update/delete public IPs — and nothing else. It is not a tenancy admin. Broader rights widen the blast radius of a node compromise for no benefit.
The allocator needs OCI credentials at runtime, and there are two ways to give it them. A node with neither forms, passes every health check, and then refuses every launch that wants a public address with `InsufficientAddressCapacity` — the cause appears only in the node's journal, so this is worth getting right before first start.
**Instance principal is the better one.** Each node authenticates with the certificate its own metadata service serves, so no key material exists on any node, there is nothing to rotate, and nothing sensitive reaches Terraform state. The cost is a dynamic group and a policy, which are tenancy-root resources — so creating them needs a tenancy-admin principal, which is why the rest of this configuration deliberately creates nothing at tenancy root.
`instance_principal` has three values, because using an instance principal and being allowed to create one are different rights:
| Value | Pool auth | Creates the dynamic group and policy | Credential needed |
| --- | --- | --- | --- |
| `off` (default) | API key file | No | Compartment-scoped |
| `adopt` | `instance_principal` | No — assumes they exist | Compartment-scoped |
| `create` | `instance_principal` | Yes | **Tenancy admin** |
Create them once per tenancy, then every deployment and rebuild afterwards uses `adopt`:
```bash
./setup-identity.sh --dry-run # always first
./setup-identity.sh
```
`setup-identity.sh` targets only those two resources and keeps them in `.identity/terraform.tfstate`, separate from every topology's state. That separation is load-bearing: `validate-topology.sh` destroys its own state at the end of each run, so holding tenancy resources there would let a nightly teardown delete the tenancy's policy.
**`adopt` references the dynamic group by nothing at all.** Its matching rule is `instance.compartment.id`, so it covers every instance in the compartment and names no OCID — which is why adopting needs no read on an identity resource and no tenancy rights. The cost is that a missing policy is invisible at apply time: the node forms, passes every health check, and then refuses every launch wanting a public address. The allocator gate in `validate-topology.sh` is what catches that, by requiring `ocinet credential authorised to allocate` in each node's `spinifex-daemon` journal.
The policy grants four verbs in one compartment: `use vnics`, `manage private-ips`, `manage public-ips` and `use subnets`.
That is exactly what allocating an external address does and nothing more.
`use subnets` looks unrelated and is not: `CreatePrivateIp` is checked against `SUBNET_ATTACH` and `CreatePrivateIp` is how an address is registered, so a policy without it authorises nothing and every allocation returns a 404.
**An API key is the fallback**, and the default because it needs nothing from a tenancy admin. Grant that user only the operations Spinifex performs on addresses and nothing else; it is not a tenancy admin, and broader rights widen the blast radius of a node compromise for no benefit.
`spx-oci-config.sh` installs it on **every** node, as `/etc/spinifex/oci/{oci_api_key.pem,config}` — not a home directory, because the daemon's unit sets `ProtectHome=yes`. Both files are needed: `oci_config_file` names an ordinary OCI SDK config, so a node holding only the PEM forms and then fails every allocation. The profile it writes is `spinifex`, matching `oci_config_profile`:
```ini
[spinifex]
user=ocid1.user.oc1..<yours>
fingerprint=<yours>
tenancy=ocid1.tenancy.oc1..<yours>
region=ap-sydney-1
key_file=/etc/spinifex/oci/oci_api_key.pem
```
The script takes `validate-topology.sh`'s hook contract, `hook <ssh-key> <host>...`, so it is both the default thing to pass to `--credential-hook` and runnable on its own against an existing cluster. `--dry-run` resolves and validates the credential without touching a host.
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 needed. Steps 2 to 4 go through `scripts/oci_env.py`, the same resolver the apply uses, so the node credential 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.
Credential material reaches the nodes over SSH, in the remote shell's stdin rather than its arguments, and goes into neither user-data nor Terraform state. `--credential-hook` still accepts any executable with that argument shape, so a tenancy holding credentials in a vault can substitute its own. Instance principal needs no hook at all, which is the reason to prefer it wherever a dynamic group can be created.
Either way, Terraform stages the matching pool block at `/etc/spinifex/oci/external-pool.toml` on each node, with `oci_auth` set to match. Append it to `/etc/spinifex/spinifex.toml` after `spx admin init` and restart `spinifex.target`. Under instance principal that file holds no secret at all, which is the point of it.
## Validating a topology end to end
`validate-topology.sh` builds one topology from nothing, installs the published Spinifex release, forms the cluster, runs a Terraform workbook against it, proves the workbook 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.
```bash
./validate-topology.sh --topology bm --dry-run
./validate-topology.sh --topology vm-single --instance-principal
./validate-topology.sh --topology vm-multi --instance-principal
```
`--topology` has no default on purpose: a command aimed at the wrong one is the easiest expensive mistake here. Each topology keeps its own state under `.validate-<topology>/`, so two can be built from one checkout without either destroying the other's instances, and every log from the run lands there.
**A topology's shape and node count are defaults, not settings.** They go to Terraform as `TF_VAR_compute_shape` and `TF_VAR_node_count`, which is the weakest source Terraform reads, so a `terraform.auto.tfvars` in the checkout outranks them and a deployment sizes itself in that file with no flag to pass. CI has no such file — `*.auto.tfvars` is gitignored — so a named topology stays the same every run. `instance_principal` is the exception and remains 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.
The count that later gates read is the number of addresses the `hosts_file` output names, not the number asked for, and the run logs the shape and count it built. A tfvars that changes either is reported rather than silently diverging from the topology's name.
**The teardown decides the verdict.** A topology or workbook that cannot be destroyed is half proved, and has been a real defect before, so `destroy` runs from an `EXIT` trap even on failure and a teardown failure fails the run. `--keep` leaves everything up and records no verdict. Other flags: `--skip-workload` (form and verify, launch no guests), `--workbook NAME`, `--ssh-public-key` / `--ssh-private-key`.
**A clean teardown can still leave public IPs behind, and they count against the tenancy.** The addresses Spinifex allocates for guests are created by the running node, so they are in no Terraform state and `destroy` neither sees nor removes them. The node's own reconcile collects detached ones every ten minutes, which is exactly the sweep a destroy takes away. Twenty accumulated in `spxbm` over four runs and exhausted the tenancy's 50 reserved-public-IP limit, at which point every later launch in every compartment failed. [Check for leftover public IPs afterwards](../../../docs/oci-integration/README.md#check-for-leftover-public-ips-afterwards) has the query and the rule for reading it.
The workbook runs **on the node** against `127.0.0.1`, because the node certificate carries no SAN for its public address — a workbook driven from outside the VCN is still blocked.
## Common Commands
@@ -18,18 +18,39 @@ die() {
exit 1
}
MULTIPATHD_UNIT=/usr/lib/systemd/system/multipathd.service
MULTIPATH_ALIAS=/etc/systemd/system/multipath-tools.service
# OCI makes a bare-metal attachment multipath, and the agent refuses to log one in
# unless multipathd runs. It probes "multipath-tools", the name Debian retired, so
# on Ubuntu 26.04 it gets exit 5 forever while multipathd is active all along. A
# symlink in /etc/systemd/system is a systemd alias, which is all the agent needs.
#
# Asserted on every poll rather than written once: on the first bare-metal run the
# symlink was created and then vanished, cause unknown, and the volume only logged
# in once it was back. Converging on each pass beats trusting a single write.
assert_multipath_alias() {
[ -e "$MULTIPATHD_UNIT" ] || return 0
[ "$(readlink -f "$MULTIPATH_ALIAS" 2>/dev/null)" = "$MULTIPATHD_UNIT" ] && return 0
ln -sfn "$MULTIPATHD_UNIT" "$MULTIPATH_ALIAS"
systemctl daemon-reload
log "aliased multipath-tools.service to multipathd.service for the Oracle agent"
}
# The Oracle Cloud Agent logs the iSCSI session in asynchronously, so the device
# is not present when cloud-init first runs. Wait for it instead of racing it.
wait_for_device() {
local i
for i in $(seq 1 120); do
assert_multipath_alias
if [ -b "$DEVICE" ]; then
log "$DEVICE appeared after $((i * 5))s"
return 0
fi
sleep 5
done
die "$DEVICE did not appear within 600s; check the iSCSI session with 'iscsiadm -m session'"
die "$DEVICE did not appear within 600s; check 'iscsiadm -m session' and the agent log at /var/log/oracle-cloud-agent/plugins/oci-blockautoconfig/"
}
# blkid reporting a TYPE is the only thing standing between a re-run and an erased
@@ -28,41 +28,56 @@ primary_mac() {
cat "/sys/class/net/$dev/address"
}
# Prints shell assignments for the first VNIC that is not the primary, or exits 1.
imds_secondary() {
# One tab-separated record per VNIC that is not the primary.
imds_secondaries() {
curl -sf --max-time 10 -H 'Authorization: Bearer Oracle' "$IMDS/vnics/" |
python3 -c '
import ipaddress, json, sys
primary = sys.argv[1].lower()
for vnic in json.load(sys.stdin):
if vnic["macAddr"].lower() == primary:
mac = vnic["macAddr"].lower()
if mac == primary:
continue
net = ipaddress.ip_network(vnic["subnetCidrBlock"])
print("WAN_MAC=%s" % vnic["macAddr"].lower())
print("WAN_IP=%s" % vnic["privateIp"])
print("WAN_PREFIX=%d" % net.prefixlen)
print("WAN_CIDR=%s" % vnic["subnetCidrBlock"])
print("WAN_ROUTER=%s" % vnic["virtualRouterIp"])
sys.exit(0)
sys.exit(1)
print("\t".join([mac, vnic["privateIp"], str(net.prefixlen),
vnic["subnetCidrBlock"], vnic["virtualRouterIp"]]))
' "$1"
}
# Picks the secondary VNIC the kernel actually has an interface for, and sets the
# WAN_* variables from it. Deliberately not "the first non-primary": IMDS order is
# not documented, and on bare metal a VNIC sharing a port has no interface at all.
select_secondary() {
local primary="$1" mac ip prefix cidr router iface
while IFS=$'\t' read -r mac ip prefix cidr router; do
[ -n "$mac" ] || continue
if iface="$(iface_for_mac "$mac")"; then
WAN_MAC="$mac"
WAN_IP="$ip"
WAN_PREFIX="$prefix"
WAN_CIDR="$cidr"
WAN_ROUTER="$router"
WAN_IFACE="$iface"
return 0
fi
done < <(imds_secondaries "$primary")
return 1
}
wait_for_secondary() {
local primary i out
local primary i
primary="$(primary_mac)"
log "primary VNIC MAC is $primary"
for i in $(seq 1 60); do
if out="$(imds_secondary "$primary")" && [ -n "$out" ]; then
eval "$out"
log "secondary VNIC after $((i * 5))s: $WAN_MAC $WAN_IP/$WAN_PREFIX via $WAN_ROUTER"
if select_secondary "$primary"; then
log "secondary VNIC after $((i * 5))s: $WAN_MAC on $WAN_IFACE, $WAN_IP/$WAN_PREFIX via $WAN_ROUTER"
return 0
fi
sleep 5
done
die "no secondary VNIC appeared within 300s; check the VNIC attachment in OCI"
die "no secondary VNIC with an interface appeared within 300s; on bare metal check it is attached at nic_index 1, because a VNIC sharing the primary's port never becomes an interface"
}
# The physical interface for a MAC. Matching on the address rather than trusting a
@@ -184,16 +199,13 @@ PY
main() {
wait_for_secondary
local iface
iface="$(iface_for_mac "$WAN_MAC")" || die "no interface carries $WAN_MAC"
log "bridging $iface into $BRIDGE"
log "bridging $WAN_IFACE into $BRIDGE"
strip_cloud_init_secondary
local previous=""
[ -f "$NETPLAN_FILE" ] && previous="$(cat "$NETPLAN_FILE")"
write_netplan "$iface"
write_netplan "$WAN_IFACE"
if [ "$STRIPPED" = 0 ] && [ "$previous" = "$(cat "$NETPLAN_FILE")" ] && ip link show "$BRIDGE" >/dev/null 2>&1; then
log "$BRIDGE is already configured and unchanged"
@@ -21,15 +21,68 @@ write_files:
content: |
${indent(6, service_ports_script)}
# The secondary VNIC is attached after the instance reaches RUNNING, so it can
# Staged rather than applied: spinifex.toml does not exist until spx admin init
# has run. Holds no secret under instance principal, which is the point of it --
# the instance's own certificate is the credential.
- path: /etc/spinifex/oci/external-pool.toml
owner: root:root
permissions: "0644"
content: |
${indent(6, oci_pool_fragment)}
# A unit rather than a runcmd so a boot that raced the iSCSI login retries on the
# next one, and so the ports below can declare a dependency on it. The script
# polls for the device itself and is safe to re-run.
- path: /etc/systemd/system/spinifex-data-volume.service
owner: root:root
permissions: "0644"
content: |
[Unit]
Description=Spinifex data volume on the OCI block volume
Wants=network-online.target
After=network-online.target remote-fs.target
ConditionPathIsMountPoint=!${data_mountpoint}
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/local/sbin/spinifex-mount-data-volume ${data_device} ${data_mountpoint}
[Install]
WantedBy=multi-user.target
# Requires= is the point: a node with no data volume has nothing behind these
# ports, so it must not publish them. systemd enforces that ordering, which a
# list of runcmd entries cannot -- runcmd continues past a failed step.
- path: /etc/systemd/system/spinifex-service-ports.service
owner: root:root
permissions: "0644"
content: |
[Unit]
Description=Spinifex node service ports
Requires=spinifex-data-volume.service
After=spinifex-data-volume.service
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/local/sbin/spinifex-open-service-ports ${peer_cidr} ${service_ports}
[Install]
WantedBy=multi-user.target
# The external VNIC is attached after the instance reaches RUNNING, so it can
# arrive late. This unit retries on the next boot if the first attempt missed it,
# and stops running once the netplan file it writes exists.
#
# Deliberately independent of the data volume: losing the external datapath
# because a disk is late would make the node more broken, not safer.
- path: /etc/systemd/system/spinifex-wan-bridge.service
owner: root:root
permissions: "0644"
content: |
[Unit]
Description=Spinifex WAN bridge over the secondary OCI VNIC
Description=Spinifex WAN bridge over the external OCI VNIC
Wants=network-online.target
After=network-online.target
ConditionPathExists=!/etc/netplan/60-spinifex-wan.yaml
@@ -45,6 +98,6 @@ write_files:
WantedBy=multi-user.target
runcmd:
- [/usr/local/sbin/spinifex-mount-data-volume, "${data_device}", "${data_mountpoint}"]
- [/usr/local/sbin/spinifex-open-service-ports, "${peer_cidr}", ${service_ports}]
- [systemctl, enable, --now, spinifex-data-volume.service]
- [systemctl, enable, --now, spinifex-service-ports.service]
- [systemctl, enable, --now, spinifex-wan-bridge.service]
+36 -1
View File
@@ -30,6 +30,10 @@ locals {
# one, so it is emitted only when the shape name says Flex.
shape_is_flex = strcontains(var.compute_shape, "Flex")
# Bare metal presents VNICs differently from a VM, which decides where the
# external VNIC is attached. See the nic_index comment below.
shape_is_bare_metal = startswith(var.compute_shape, "BM.")
# Round-robin, so three nodes land on three fault domains rather than wherever
# OCI's placement happens to put them. Left to itself OCI spreads on a
# best-effort basis and says nothing about the result, which is not something
@@ -41,12 +45,38 @@ locals {
# after the boot disk (mulga-poc has oraclevda1 pointing at sdb1).
data_device = "/dev/oracleoci/oraclevdb"
# adopt and create differ only in who creates the dynamic group and policy; a node
# authenticates identically either way, because the matching rule covers the whole
# compartment and so names no instance.
use_instance_principal = var.instance_principal != "off"
# The allocator's pool, staged on each node for spx admin init to pick up. Under
# instance principal there is no oci_config_file key, because naming one beside a
# certificate credential is a second source of truth that config load rejects.
oci_pool_fragment = join("\n", concat([
"# Generated by the oci-spx Terraform. Append to /etc/spinifex/spinifex.toml",
"# after spx admin init, then restart spinifex.target.",
"[[network.external_pools]]",
"name = \"oci-public\"",
"source = \"oci\"",
"oci_compartment_id = \"${var.compartment_ocid}\"",
"oci_vnic_iface = \"${var.wan_bridge_name}\"",
], local.use_instance_principal ? [
"oci_auth = \"instance_principal\"",
] : [
"oci_config_file = \"/etc/spinifex/oci/config\"",
"oci_config_profile = \"spinifex\"",
], [
"dns_servers = [\"169.254.169.253\"]",
]))
# Identical on every node, so a change to it replaces all of them together.
user_data = templatefile("${path.module}/cloud-init/user-data.yaml.tftpl", {
data_device = local.data_device
data_mountpoint = var.data_mountpoint
peer_cidr = var.vcn_cidr
service_ports = join(", ", [for p in var.node_service_ports : "\"${p}\""])
service_ports = join(" ", var.node_service_ports)
oci_pool_fragment = local.oci_pool_fragment
wan_bridge_name = var.wan_bridge_name
wan_bridge_mtu = var.wan_bridge_mtu
mount_script = file("${path.module}/cloud-init/mount-data-volume.sh")
@@ -105,6 +135,11 @@ resource "oci_core_vnic_attachment" "mulgadc_external" {
count = var.node_count
instance_id = oci_core_instance.mulgadc[count.index].id
# On bare metal only the first VNIC on a physical port becomes a Linux device;
# a second one is a tagged overlay with no device. The second port keeps this
# VNIC first, so it is untagged and present at boot exactly as it is on a VM.
nic_index = local.shape_is_bare_metal ? 1 : 0
create_vnic_details {
subnet_id = oci_core_subnet.public.id
display_name = format("%s-node-%02d-external", var.deployment_name, count.index + 1)
+464
View File
@@ -0,0 +1,464 @@
#!/bin/bash
# e2e-oci.sh — prove a ref works on OCI, across every topology, and leave nothing
# running. One entry point for a workstation, a Cloudflare-dispatched nightly and
# the GitHub workflow, because three ways of starting it would drift into three
# different answers about whether OCI works.
#
# Configured entirely by environment variables so a dispatch needs no arguments:
#
# SPX_REF Ref to prove. Default: the spinifex checkout's current branch.
# OCI_TOPOLOGIES Space-separated. Default: "vm-single vm-multi bm
# vm-single-principal". Set one name to test one thing: a
# single VM topology is roughly a quarter of the full run.
# OCI_SOURCE tree (build SPX_REF) or release (the published installer).
# OCI_VERSION With OCI_SOURCE=release, install this exact release tag and
# ignore OCI_CHANNEL. Preferred for anything repeatable: a tag
# resolves by redirect, while the dev channel resolves through a
# rate-limited GitHub API call that 404s when it trips.
# OCI_CHANNEL With OCI_SOURCE=release, which published channel to install:
# latest (default) or dev, the newest prerelease. dev is the
# path our cloud contacts are given, so it is worth testing.
# Default tree: a nightly exists to judge a ref, and the
# published installer says nothing about one.
# WORKBOOKS Workbooks to run on each topology. Default: the driver's own
# list. Set to "" to skip the workbook phase entirely.
# OCI_INSTANCE_PRINCIPAL Authenticate the node's allocator as the instance
# principal. Default 1, and it needs the tenancy's dynamic
# group to exist. The vm-single-principal topology ignores it
# and always authenticates that way, which is its purpose.
#
# A tenancy where setup-identity.sh cannot run has no such
# group, and then no principal run can pass: the node forms,
# authenticates, and is refused every allocation. That now
# fails at the allocator gate within a minute of the daemon
# starting rather than an hour later inside a workbook, but
# it still costs a full provision, so drop the topology from
# OCI_TOPOLOGIES until the group exists.
# OCI_NO_EXTERNAL_POOL 1 to form with no allocator at all, for a tenancy whose
# dynamic group does not exist yet. The allocator and every
# workbook are then recorded SKIPPED, never PASS, so a green
# run with this set is not a claim about guest networking.
# Not applied to vm-single-principal, which the allocator is
# the entire test of; that pairing is refused rather than run.
# OCI_SSH_PUBLIC_KEY / OCI_SSH_PRIVATE_KEY Paths. Default ~/.ssh/oci-spx[.pub].
# OCI_CREDENTIAL_HOOK Executable run on each topology after formation and
# before the pool, as "hook <ssh-key> <host>...". An API-key
# deployment installs its credential here; instance principal
# needs none, so it is skipped unless the pool is key-based.
# OCI_PRINCIPAL_TENANCY_OCID, OCI_PRINCIPAL_USER_OCID,
# OCI_PRINCIPAL_FINGERPRINT, OCI_PRINCIPAL_PRIVATE_KEY, OCI_PRINCIPAL_REGION,
# OCI_PRINCIPAL_COMPARTMENT_OCID
# The credential vm-single-principal builds with, replacing the
# run's own for that one topology. The dynamic group is a
# tenancy-root resource, so a tenancy that will not let
# setup-identity.sh create one refuses every allocation however
# correct the code is, and the principal route has to be proved
# in a tenancy we administer while the rest of the run stays
# where it is.
#
# All six or none. A partial set builds one tenancy's instances
# against another's compartment, which is refused rather than
# reported; with none set the topology is recorded SKIPPED
# rather than costing a provision to fail. Running it in the
# run's own tenancy is still available by pointing all six
# there, which says so explicitly instead of by default.
# OCI_PRINCIPAL_SHAPE Compute shape for vm-single-principal. Its tenancy
# carries its own service limits, so the shape the other
# topologies use can have zero quota there and fail the apply
# before anything about the principal is exercised. Default
# VM.Standard.E6.Flex, as the others.
# OCI_ARTIFACT_DIR Where logs and the verdict land. Default ./.e2e-oci-<stamp>.
# OCI_KEEP_ON_FAIL 1 to leave a failed topology up for inspection. Off by
# default: an OCI bare-metal host left overnight is expensive.
#
# The credential comes from OCI_TENANCY_OCID, OCI_USER_OCID, OCI_FINGERPRINT,
# OCI_PRIVATE_KEY and OCI_REGION when they are set, and from ~/.oci/config when
# they are not. scripts/oci_env.py owns that choice; nothing here reads a key.
set -uo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SPINIFEX_ROOT="$(cd "$HERE/../../.." && pwd)"
# On a workstation spinifex is a submodule of mulga; in CI the two are siblings
# under the workspace. Deriving one from the other is only right in the first case.
MULGA_ROOT="${MULGA_ROOT:-$SPINIFEX_ROOT/..}"
TOPOLOGIES="${OCI_TOPOLOGIES:-vm-single vm-multi bm vm-single-principal}"
SOURCE="${OCI_SOURCE:-tree}"
CHANNEL="${OCI_CHANNEL:-latest}"
INSTALL_VERSION="${OCI_VERSION:-}"
REF="${SPX_REF:-$(git -C "$SPINIFEX_ROOT" rev-parse --abbrev-ref HEAD)}"
SSH_PUBLIC_KEY="${OCI_SSH_PUBLIC_KEY:-$HOME/.ssh/oci-spx.pub}"
SSH_PRIVATE_KEY="${OCI_SSH_PRIVATE_KEY:-$HOME/.ssh/oci-spx}"
ARTIFACT_DIR="${OCI_ARTIFACT_DIR:-$HERE/.e2e-oci-$(date -u +%Y%m%dT%H%M%SZ)}"
# Exported so validate-topology.sh agrees with the sweep about where state lives.
# On a persistent runner this belongs outside the checkout: actions/checkout runs
# git clean -ffdx, which would delete the state of a killed run before the sweep.
export OCI_STATE_ROOT="${OCI_STATE_ROOT:-$HERE}"
STATE_ROOT="$OCI_STATE_ROOT"
SWEEP_ONLY=0
SUMMARY_ONLY=0
DRY_RUN=0
log() { printf '[e2e-oci] %s\n' "$*"; }
die() {
printf '[e2e-oci] ERROR: %s\n' "$*" >&2
exit 1
}
usage() {
cat >&2 <<EOF
usage: ${0##*/} [--sweep-only] [--summary-only] [--dry-run]
Everything else is an environment variable; see the header of this script.
--sweep-only Destroy anything an earlier run left behind, then stop.
--summary-only Write the result tables from the state files, then stop. For
a job that was cancelled or killed before it got there.
--dry-run Print what each topology would do, build nothing.
EOF
exit 2
}
while [ $# -gt 0 ]; do
case "$1" in
--sweep-only) SWEEP_ONLY=1; shift ;;
--summary-only) SUMMARY_ONLY=1; shift ;;
--dry-run) DRY_RUN=1; shift ;;
-h | --help) usage ;;
*) die "unknown option $1" ;;
esac
done
case "$SOURCE" in
tree | release) ;;
*) die "OCI_SOURCE must be tree or release, got '$SOURCE'" ;;
esac
case "$CHANNEL" in
latest | dev) ;;
*) die "OCI_CHANNEL must be latest or dev, got '$CHANNEL'" ;;
esac
# Saying release+dev and tree at once is two different artifacts in one verdict.
[ "$SOURCE" = tree ] && [ -n "$INSTALL_VERSION" ] \
&& die "OCI_VERSION needs OCI_SOURCE=release: a tree build installs the ref, not a published tag"
[ "$SOURCE" = tree ] && [ "$CHANNEL" != latest ] \
&& die "OCI_CHANNEL=$CHANNEL needs OCI_SOURCE=release: a tree build installs the ref, not a channel"
mkdir -p "$ARTIFACT_DIR"
VERDICT="$ARTIFACT_DIR/verdict.txt"
: > "$VERDICT"
# vm-single-principal is the one topology that cannot run wherever the others do.
# It needs the tenancy's dynamic group, and creating that is a tenancy-root write,
# so the tenancy a shared test account gives us can never pass it. These let it
# build somewhere else without moving the rest of the run.
PRINCIPAL_CRED_VARS="TENANCY_OCID USER_OCID FINGERPRINT PRIVATE_KEY REGION COMPARTMENT_OCID"
# principal_cred_state is none, all or partial. Partial is the one worth a stop:
# one tenancy's OCIDs with another's compartment builds in a compartment nobody
# chose, and the apply reports success doing it.
principal_cred_state() {
local name var have=0 miss=0
for name in $PRINCIPAL_CRED_VARS; do
var="OCI_PRINCIPAL_$name"
if [ -n "${!var:-}" ]; then
have=$((have + 1))
else
miss=$((miss + 1))
fi
done
if [ "$have" = 0 ]; then
printf 'none\n'
elif [ "$miss" = 0 ]; then
printf 'all\n'
else
printf 'partial\n'
fi
}
# Into an array rather than a rendered string, because the private key is a
# multi-line PEM and any line-based plumbing silently truncates it to its header.
PRINCIPAL_ENV=()
build_principal_env() {
local name var
PRINCIPAL_ENV=()
for name in $PRINCIPAL_CRED_VARS; do
var="OCI_PRINCIPAL_$name"
case "$name" in
COMPARTMENT_OCID) PRINCIPAL_ENV+=("TF_VAR_compartment_ocid=${!var}") ;;
*) PRINCIPAL_ENV+=("OCI_$name=${!var}") ;;
esac
done
}
# Here rather than at the topology, because the build runs first and takes minutes,
# and a credential that cannot work is knowable before any of it. The loop checks
# again, since it is what reads the variables.
case " $TOPOLOGIES " in
*" vm-single-principal "*)
[ "$(principal_cred_state)" != partial ] \
|| die "the OCI_PRINCIPAL_* credential is incomplete: set all of $PRINCIPAL_CRED_VARS or none of them"
;;
esac
# The run page is the evidence, and its readers are not us: someone assessing
# whether Spinifex works on their cloud should get the answer without opening a
# log or knowing what this harness is. Markdown on stdout as well as to the step
# summary, so a workstation run shows the same thing CI publishes.
mark() {
case "$1" in
PASS) printf '✅ PASS' ;;
FAIL) printf '❌ FAIL' ;;
SKIPPED) printf '⏭️ SKIPPED' ;;
*) printf 'ℹ️ %s' "$1" ;;
esac
}
summary() {
printf '## Spinifex on OCI — %s\n\n' "$REF"
# Said, not implied. A tree build and a published release answer different
# questions, and a table headed "published release" over a build of somebody's
# branch is the one error here that would mislead a reader who trusted it.
if [ "$SOURCE" = tree ]; then
printf 'Built from this ref, region `%s`.\n\n' "${OCI_REGION:-ap-sydney-1}"
else
printf 'Published release `%s`, region `%s`.\n\n' \
"${INSTALL_VERSION:-$CHANNEL channel}" "${OCI_REGION:-ap-sydney-1}"
fi
# In the header, not left to a SKIPPED row further down. A run with no allocator
# is not evidence about public addressing, and that is precisely the claim a
# reader of a green table would otherwise take from it.
# Said per topology rather than once for the run: vm-single-principal pins its
# own credential, so a single claim over a mixed run is wrong for one of them.
if [ "${OCI_NO_EXTERNAL_POOL:-0}" = 1 ]; then
printf '> **No external address pool.** Public-address allocation and every workbook needing a public address were skipped, so nothing here speaks to guest ingress. `vm-single-principal`, if it ran, is the exception and did build one.\n\n'
elif [ "${OCI_INSTANCE_PRINCIPAL:-1}" = 1 ]; then
printf 'Allocator authenticated as the instance principal.\n\n'
else
printf 'Allocator authenticated with an API key, except `vm-single-principal` which authenticates as the instance.\n\n'
fi
# Said in the summary because the row is otherwise read as a result about the
# same tenancy as every row above it, which is the one thing it is not.
if [ "$(principal_cred_state)" = all ]; then
printf '`vm-single-principal` built in its own tenancy in `%s`, from the `OCI_PRINCIPAL_*` credential.\n\n' \
"$OCI_PRINCIPAL_REGION"
fi
local topology results workbooks gate status detail name secs
for topology in $TOPOLOGIES; do
results="$STATE_ROOT/.validate-$topology/results.tsv"
workbooks="$STATE_ROOT/.validate-$topology/workbooks.tsv"
printf '### `%s`\n\n' "$topology"
# A topology the loop never reached is said so rather than omitted: a
# reader takes an absent row for a passing one.
if [ ! -s "$results" ]; then
printf 'Did not run.\n\n'
continue
fi
printf '| Gate | Result | Detail |\n| --- | --- | --- |\n'
while IFS=$'\t' read -r gate status detail; do
[ -n "$gate" ] || continue
printf '| %s | %s | %s |\n' "$gate" "$(mark "$status")" "$detail"
done < "$results"
printf '\n'
if [ ! -s "$workbooks" ]; then
printf 'No published workbooks ran.\n\n'
continue
fi
printf '| Terraform workbook | Result | Duration |\n| --- | --- | --- |\n'
while IFS=$'\t' read -r name status secs; do
[ -n "$name" ] || continue
printf '| `%s` | %s | %ss |\n' "$name" "$(mark "$status")" "$secs"
done < "$workbooks"
printf '\n'
done
}
# GITHUB_STEP_SUMMARY is per step, so appending from both the run and the dedicated
# publish step would put two copies of the tables on the run page. One owner: the run
# writes the file and prints it, and only --summary-only publishes it.
emit_summary() {
summary | tee "$ARTIFACT_DIR/summary.md"
}
publish_summary() {
emit_summary
if [ -n "${GITHUB_STEP_SUMMARY:-}" ]; then
cat "$ARTIFACT_DIR/summary.md" >> "$GITHUB_STEP_SUMMARY"
fi
}
# A topology destroys itself from its own EXIT trap, so this only catches a run
# the trap never reached: a killed process, a runner reclaimed mid-job, a reboot.
# It works from the state files rather than by name filter, so it can only ever
# destroy what one of these runs created.
sweep() {
local found=0 dir topology
local sweep_env=(env)
for dir in "$STATE_ROOT"/.validate-*; do
[ -s "$dir/terraform.tfstate" ] || continue
python3 -c '
import json, sys
state = json.load(open(sys.argv[1]))
sys.exit(0 if any(r["instances"] for r in state.get("resources", [])) else 1)
' "$dir/terraform.tfstate" 2>/dev/null || continue
topology="${dir##*/.validate-}"
# The child reads OCI_STATE_ROOT from the environment, so it looks in the
# same place this loop found the state rather than beside its own script.
found=1
# The same credential the topology built with, or the destroy authenticates
# against a tenancy that never held these resources and reports a clean
# sweep over a deployment that is still billing.
sweep_env=(env)
if [ "$topology" = vm-single-principal ] && [ "$(principal_cred_state)" = all ]; then
build_principal_env
sweep_env=(env "${PRINCIPAL_ENV[@]}")
fi
log "sweep: $topology still holds resources, destroying"
if "${sweep_env[@]}" "$HERE/validate-topology.sh" --topology "$topology" --destroy-only \
>> "$ARTIFACT_DIR/sweep.log" 2>&1; then
log "sweep: $topology destroyed"
else
log "sweep: $topology WOULD NOT DESTROY -- it is still billing. See $ARTIFACT_DIR/sweep.log"
echo "sweep $topology: FAIL" >> "$VERDICT"
fi
done
[ "$found" = 0 ] && log "sweep: nothing left behind"
}
# Before the sweep, because the sweep is what destroys the state the tables are
# read from. This is the path a cancelled job takes, so it has to work on whatever
# the run got through rather than assuming a complete set.
if [ "$SUMMARY_ONLY" = 1 ]; then
publish_summary
exit 0
fi
# Run the sweep before the topologies as well as after: a leftover cluster from a
# killed run is both a bill and a second set of resources with our names on it.
log "artifacts in $ARTIFACT_DIR"
sweep
if [ "$SWEEP_ONLY" = 1 ]; then
log "--sweep-only: done"
exit 0
fi
# Built once for every topology. A per-topology build would prove three different
# sets of bytes and could not say which one failed.
DISTRO_TARBALL=""
if [ "$SOURCE" = tree ]; then
log "building $REF"
DISTRO_TARBALL="$ARTIFACT_DIR/spinifex-distro.tar.gz"
BUILDER="$MULGA_ROOT/scripts/tofu-cluster/build-install-artifacts.sh"
[ -x "$BUILDER" ] || die "no artifact builder at $BUILDER (set MULGA_ROOT)"
# It clones each repo from GitHub rather than using the checkout beside it, so a
# ref that exists only on this workstation silently falls back to dev and the
# run would report a verdict about the wrong code. Fail on that instead.
if ! git -C "$SPINIFEX_ROOT" rev-parse --verify --quiet "refs/remotes/origin/$REF" >/dev/null \
&& ! git -C "$SPINIFEX_ROOT" ls-remote --exit-code --tags origin "$REF" >/dev/null 2>&1; then
die "$REF is not on origin: the builder clones from GitHub, so an unpushed ref would fall back to dev"
fi
if [ "$DRY_RUN" = 1 ]; then
log "dry run: would build $REF into $DISTRO_TARBALL"
else
# --skip-e2e-build: the Go suites are not run here. The workbook driver is a
# shell script, so building their binaries would add minutes and prove nothing.
"$BUILDER" "$REF" "$DISTRO_TARBALL" "$ARTIFACT_DIR/setup.sh" \
"$ARTIFACT_DIR/spinifex-tests.tar" --skip-e2e-build \
> "$ARTIFACT_DIR/build.log" 2>&1 \
|| die "building $REF failed; see $ARTIFACT_DIR/build.log"
log "built $(du -h "$DISTRO_TARBALL" | cut -f1) from $REF"
fi
fi
RUN_RC=0
for topology in $TOPOLOGIES; do
# The credential swap happens here and nowhere else, so everything downstream
# of the one validate call is unaware the topology built somewhere different.
# Always a bare `env` otherwise, because an empty array is an unbound variable
# under set -u on the bash 3.2 operators run this with.
run_env=(env)
if [ "$topology" = vm-single-principal ]; then
case "$(principal_cred_state)" in
none)
log "$topology: SKIPPED -- no OCI_PRINCIPAL_* credential, and the run's own tenancy cannot hold the dynamic group this topology needs"
echo "$topology: SKIPPED (no principal credential)" >> "$VERDICT"
continue
;;
partial)
die "the OCI_PRINCIPAL_* credential is incomplete: set all of $PRINCIPAL_CRED_VARS or none of them"
;;
esac
build_principal_env
run_env=(env "${PRINCIPAL_ENV[@]}")
fi
args=(--topology "$topology" --ssh-public-key "$SSH_PUBLIC_KEY" --ssh-private-key "$SSH_PRIVATE_KEY")
[ "$DRY_RUN" = 1 ] && args+=(--dry-run)
# Two separate choices. --no-external-pool is the one that forms without any
# allocator, for a tenancy whose dynamic group does not exist yet; it records
# the allocator and the workbooks as SKIPPED rather than passing them.
#
# vm-single-principal opts out of both: it sets its own credential, and a run
# of it without an allocator would exercise nothing. validate-topology.sh
# refuses that pairing, so skipping it here keeps a mixed run from aborting.
if [ "$topology" = vm-single-principal ]; then
:
elif [ "${OCI_NO_EXTERNAL_POOL:-0}" = 1 ]; then
args+=(--no-external-pool)
elif [ "${OCI_INSTANCE_PRINCIPAL:-1}" = 1 ]; then
args+=(--instance-principal)
fi
# Not under --dry-run, where the build was skipped and naming the tarball it
# would have produced fails the readable check, so every topology reported FAIL
# and the flag operators are told to run first could never pass. Still
# unconditional in a real run, so a build that produced nothing stays loud
# rather than quietly installing a release instead.
if [ -n "$DISTRO_TARBALL" ] && [ "$DRY_RUN" != 1 ]; then
args+=(--distro "$DISTRO_TARBALL" --setup-sh "$ARTIFACT_DIR/setup.sh")
fi
if [ "$SOURCE" = release ]; then
if [ -n "$INSTALL_VERSION" ]; then
args+=(--version "$INSTALL_VERSION")
else
args+=(--channel "$CHANNEL")
fi
fi
[ -n "${OCI_CREDENTIAL_HOOK:-}" ] && args+=(--credential-hook "$OCI_CREDENTIAL_HOOK")
[ "${OCI_KEEP_ON_FAIL:-0}" = 1 ] && args+=(--keep-on-fail)
[ -n "${WORKBOOKS+x}" ] && args+=(--workbooks "$WORKBOOKS")
# go test -v markers, so the same log feeds go-junit-report and the RCA bundle
# the Go suites produce. One testcase per topology.
tname="TestOCITopology_${topology//-/_}"
start=$SECONDS
echo "=== RUN $tname"
if "${run_env[@]}" "$HERE/validate-topology.sh" "${args[@]}" 2>&1 | tee "$ARTIFACT_DIR/$topology.log"; then
printf -- '--- PASS: %s (%d.00s)\n' "$tname" "$((SECONDS - start))"
echo "$topology: PASS" >> "$VERDICT"
else
printf -- '--- FAIL: %s (%d.00s)\n' "$tname" "$((SECONDS - start))"
echo "$topology: FAIL" >> "$VERDICT"
RUN_RC=1
fi
done
# Again at the end, because a topology that failed its own teardown is exactly the
# case that costs money, and the verdict should say so rather than the log.
sweep
emit_summary
echo
log "=== $REF on OCI ($SOURCE build$([ "$SOURCE" = release ] && { [ -n "$INSTALL_VERSION" ] && echo ", $INSTALL_VERSION" || echo ", $CHANNEL channel"; })) ==="
cat "$VERDICT"
if [ "$RUN_RC" = 0 ] && ! grep -q FAIL "$VERDICT"; then
log "PASS"
printf 'ok \te2e-oci\t%d.000s\n' "$SECONDS"
else
log "FAIL"
printf 'FAIL\te2e-oci\t%d.000s\n' "$SECONDS"
fi
exit "$RUN_RC"
+1 -1
View File
@@ -17,7 +17,7 @@ set -euo pipefail
KEY=${KEY:-$HOME/.ssh/oci-poc.pem}
OCI_BIN=${OCI_BIN:-$HOME/bin/oci}
OCI_PROFILE=${OCI_PROFILE:-apacanzset03child03}
OCI_PROFILE=${OCI_PROFILE:-DEFAULT}
SUBNET=${SUBNET:-ocid1.subnet.oc1.ap-sydney-1.aaaaaaaaimrhjgsqgrfg7bnka2d3nbx2n5ss7kurcwneon22rkclqf3ypfqa}
# Public address -> node name. The control plane is reached through node01.
@@ -0,0 +1,44 @@
# Lets each node authenticate to the OCI API as itself, so the external-address
# allocator needs no API key on disk. Without one of these two credential routes a
# node forms and passes every health check, then refuses every launch that wants a
# public address with InsufficientAddressCapacity and no stated cause.
#
# Only instance_principal = "create" builds these, and deliberately: a dynamic
# group and a policy are tenancy-root resources, so creating them needs rights the
# compartment-scoped user the rest of this configuration runs as does not have and
# should not be given. Create them once with a tenancy admin, then every later
# deployment uses "adopt", which references nothing and so needs no such rights.
resource "oci_identity_dynamic_group" "nodes" {
provider = oci.home
count = var.instance_principal == "create" ? 1 : 0
compartment_id = var.tenancy_ocid
name = "${var.deployment_name}-nodes"
description = "Spinifex nodes in the ${var.deployment_name} deployment, authenticating as instance principals."
# Scoped to the compartment, not to instance OCIDs: nodes are replaced, and a
# rule listing them by OCID goes stale the first time one is.
matching_rule = "ALL {instance.compartment.id = '${var.compartment_ocid}'}"
}
# Narrower than it looks. The allocator creates, moves and deletes secondary
# private IPs on a node's own VNIC and attaches public IPs to them, which is what
# these four verbs cover; nothing here grants compute, storage or identity.
resource "oci_identity_policy" "nodes" {
provider = oci.home
count = var.instance_principal == "create" ? 1 : 0
compartment_id = var.compartment_ocid
name = "${var.deployment_name}-nodes-network"
description = "Allows Spinifex nodes to manage the private and public IPs backing external addresses."
statements = [
"Allow dynamic-group ${oci_identity_dynamic_group.nodes[0].name} to use vnics in compartment id ${var.compartment_ocid}",
"Allow dynamic-group ${oci_identity_dynamic_group.nodes[0].name} to manage private-ips in compartment id ${var.compartment_ocid}",
"Allow dynamic-group ${oci_identity_dynamic_group.nodes[0].name} to manage public-ips in compartment id ${var.compartment_ocid}",
# CreatePrivateIp needs SUBNET_ATTACH and DeletePrivateIp needs SUBNET_DETACH,
# both of which only the subnets `use` verb carries. Without it every allocation
# is refused as a 404, which reads as a missing resource rather than a missing grant.
"Allow dynamic-group ${oci_identity_dynamic_group.nodes[0].name} to use subnets in compartment id ${var.compartment_ocid}",
]
}
+7
View File
@@ -8,6 +8,13 @@ output "security_list_ocids" { value = { public = oci_core_security_list.public.
output "compute_ocids" { value = [for instance in oci_core_instance.mulgadc : instance.id] }
output "data_volume_ocids" { value = [for volume in oci_core_volume.mulgadc_data : volume.id] }
# Read back rather than assumed: both can come from a tfvars file, so this is the
# only place that knows what was actually built. The deploy driver skips the
# credential handoff on instance_principal, so a wrong guess leaves a node with no
# credential at all.
output "compute_shape" { value = var.compute_shape }
output "instance_principal" { value = var.instance_principal }
# What the Spinifex deploy path consumes. The primary VNIC is the host plane, so
# these are the addresses update-nodes.sh and install-node.sh connect to.
output "nodes" {
+18 -3
View File
@@ -1,10 +1,25 @@
# A runner gets the key as a value, a workstation as a path. Passing the value
# means CI never writes key material to disk, so there is nothing to leave behind
# on a shared runner and nothing to clean up after a cancelled job.
provider "oci" {
tenancy_ocid = var.tenancy_ocid
user_ocid = var.user_ocid
fingerprint = var.fingerprint
private_key_path = var.private_key_path
private_key = var.private_key != "" ? var.private_key : null
private_key_path = var.private_key == "" ? var.private_key_path : null
region = var.region
}
# The aliased home-region provider is gone with the identity resources it served.
# Everything here now lives in one region, in an existing compartment.
# OCI serves IAM writes from the tenancy's home region, so the dynamic group and
# policy go through this one. Defaults to var.region, which makes it identical to
# the provider above until OCI_HOME_REGION says otherwise -- so a tenancy whose
# home region is where we build is unaffected, and ours is not.
provider "oci" {
alias = "home"
tenancy_ocid = var.tenancy_ocid
user_ocid = var.user_ocid
fingerprint = var.fingerprint
private_key = var.private_key != "" ? var.private_key : null
private_key_path = var.private_key == "" ? var.private_key_path : null
region = var.home_region != null && var.home_region != "" ? var.home_region : var.region
}
@@ -1,2 +0,0 @@
# Dependencies used only by scripts/oci_env.py in its isolated .venv.
oci>=2.160.0,<3.0.0
+158 -46
View File
@@ -1,5 +1,9 @@
#!/usr/bin/env python3
"""Prepare OCI-profile Terraform environment variables in this repo's .venv.
"""Prepare OCI Terraform environment variables from a profile or the environment.
Standard library only, deliberately. It once used the OCI SDK, which meant a
virtualenv, which meant python3-venv on every host that runs it -- and banksia
did not have it. Terraform's own provider makes the OCI calls.
Examples:
eval "$(python3 scripts/oci_env.py --shell)"
@@ -12,63 +16,144 @@ import argparse
import configparser
import os
from pathlib import Path
import re
import shlex
import subprocess
import sys
import venv
REPOSITORY = Path(__file__).resolve().parent.parent
VENV_PYTHON = REPOSITORY / ".venv" / "bin" / "python"
REQUIREMENTS = REPOSITORY / "requirements.txt"
REQUESTED_PROFILE = "apacanzset03child03"
FALLBACK_PROFILE = "apacanzset03child3"
# The OCI CLI's own name for "the profile when none is named", so a config written
# by `oci setup config` works with no flag.
DEFAULT_PROFILE = "DEFAULT"
def ensure_venv() -> None:
"""Create, populate, then re-execute from this repository's virtualenv."""
if Path(sys.executable).resolve() == VENV_PYTHON.resolve():
return
if not VENV_PYTHON.exists():
venv.EnvBuilder(with_pip=True).create(VENV_PYTHON.parent.parent)
subprocess.check_call([str(VENV_PYTHON), "-m", "pip", "install", "--quiet", "-r", str(REQUIREMENTS)])
os.execv(str(VENV_PYTHON), [str(VENV_PYTHON), str(Path(__file__).resolve()), *sys.argv[1:]])
def default_profile() -> str:
"""The profile to read when the caller named none.
OCI_CLI_PROFILE is the OCI CLI's own variable, so an operator who has already
selected a profile for `oci` does not select it twice.
"""
return os.environ.get("OCI_CLI_PROFILE") or DEFAULT_PROFILE
def has_profile(parser: configparser.RawConfigParser, name: str) -> bool:
"""Whether the config holds this profile.
DEFAULT needs its own test: configparser treats it as the fallback for every
other section rather than as a section, so has_section and sections() both
deny it exists even when `oci setup config` wrote nothing else.
"""
if name == DEFAULT_PROFILE:
return bool(parser.defaults())
return parser.has_section(name)
def profile_names(parser: configparser.RawConfigParser) -> list[str]:
names = list(parser.sections())
if parser.defaults():
names.insert(0, DEFAULT_PROFILE)
return names
def load_profile(config_path: Path, requested_profile: str) -> tuple[str, dict[str, str]]:
parser = configparser.RawConfigParser()
parser.read(config_path)
profile = requested_profile
if not parser.has_section(profile):
if profile == REQUESTED_PROFILE and parser.has_section(FALLBACK_PROFILE):
print(
f"Profile {REQUESTED_PROFILE} not found; using {FALLBACK_PROFILE}.",
file=sys.stderr,
)
profile = FALLBACK_PROFILE
else:
raise ValueError(f"OCI profile {profile!r} was not found in {config_path}")
# No fallback to another profile. A missing one is an error because the only
# alternative is building a cluster in whichever tenancy a different profile
# happens to point at.
if not has_profile(parser, profile):
raise ValueError(
f"OCI profile {profile!r} was not found in {config_path}; "
f"profiles present: {', '.join(profile_names(parser)) or 'none'}. "
"Name one with --profile or OCI_CLI_PROFILE."
)
# Validate through the OCI SDK installed in the dedicated virtualenv.
import oci
values = oci.config.from_file(str(config_path), profile)
values = {key: value.strip() for key, value in parser.items(profile)}
required = ("tenancy", "user", "fingerprint", "key_file", "region")
missing = [key for key in required if not values.get(key)]
if missing:
raise ValueError(f"Missing OCI profile values: {', '.join(missing)}")
# ~ in key_file is the OCI CLI's own convention and configparser does not expand it.
values["key_file"] = str(Path(values["key_file"]).expanduser())
if not Path(values["key_file"]).is_file():
raise FileNotFoundError(f"key_file in profile {profile} does not exist: {values['key_file']}")
check_credential_shape(values, source=str(config_path), names=CONFIG_SOURCE)
return profile, values
def tenancy_home_region(values: dict[str, str]) -> str:
"""Read the home region from OCI rather than assuming the profile region."""
import oci
ENVIRONMENT_KEYS = ("OCI_TENANCY_OCID", "OCI_USER_OCID", "OCI_FINGERPRINT", "OCI_PRIVATE_KEY", "OCI_REGION")
subscriptions = oci.identity.IdentityClient(values).list_region_subscriptions(values["tenancy"]).data
home = next((item.region_name for item in subscriptions if item.is_home_region), None)
if not home:
raise ValueError("OCI did not return a tenancy home region")
return home
def load_environment() -> dict[str, str] | None:
"""Read the credential from the environment, the way a CI runner holds it.
Returns None when none of the keys are set, so a workstation still falls
through to ~/.oci/config. A partial set is an error rather than a fallback:
half a credential in the environment means a secret failed to reach the job,
and silently using a different one would validate the wrong tenancy.
"""
present = [key for key in ENVIRONMENT_KEYS if os.environ.get(key)]
if not present:
return None
missing = [key for key in ENVIRONMENT_KEYS if not os.environ.get(key)]
if missing:
raise ValueError(
f"OCI credential in the environment is incomplete: {', '.join(present)} set, {', '.join(missing)} missing"
)
key = os.environ["OCI_PRIVATE_KEY"]
# Base64 is accepted because a PEM's newlines do not survive every secret
# store. Detected by shape rather than by a second variable to set wrongly.
if "-----BEGIN" not in key:
import base64
key = base64.b64decode(key).decode()
if "-----BEGIN" not in key:
raise ValueError("OCI_PRIVATE_KEY is neither a PEM nor base64-encoded PEM")
# Stripped because `echo` into a secret store appends a newline, and the SDK
# then calls the value malformed without saying which one or why.
values = {
"tenancy": os.environ["OCI_TENANCY_OCID"].strip(),
"user": os.environ["OCI_USER_OCID"].strip(),
"fingerprint": os.environ["OCI_FINGERPRINT"].strip(),
"key_content": key.strip() + "\n",
"region": os.environ["OCI_REGION"].strip(),
}
check_credential_shape(values, source="the environment", names=ENVIRONMENT_SOURCE)
return values
# Which input to go and fix, named the way the person reading the failure set it.
ENVIRONMENT_SOURCE = {
"tenancy": "OCI_TENANCY_OCID",
"user": "OCI_USER_OCID",
"fingerprint": "OCI_FINGERPRINT",
"key_content": "OCI_PRIVATE_KEY",
"region": "OCI_REGION",
}
CONFIG_SOURCE = {key: key for key in ENVIRONMENT_SOURCE} | {"key_content": "key_file"}
FINGERPRINT = re.compile(r"^[0-9a-f]{2}(:[0-9a-f]{2}){15}$")
def check_credential_shape(values: dict[str, str], source: str, names: dict[str, str]) -> None:
"""Reject a malformed credential here rather than as an OCI 401 later.
Every fault names the input the reader set, because the alternative is a
401 forty seconds into a Terraform run, which says nothing about which of
five values is wrong. A newline from `echo` into a secret store is the
common one, and it is stripped before this runs.
"""
faults = []
for field in ("tenancy", "user"):
if not values.get(field, "").startswith("ocid1."):
faults.append(f"{names[field]} is not an OCID")
if not FINGERPRINT.match(values.get("fingerprint", "")):
faults.append(f"{names['fingerprint']} is not a 16-byte hex fingerprint")
if not values.get("region"):
faults.append(f"{names['region']} is empty")
if faults:
raise ValueError(f"the OCI credential in {source} is not usable: {', '.join(faults)}")
def terraform_environment(
@@ -79,14 +164,22 @@ def terraform_environment(
ssh_private_key_path: str | None,
) -> dict[str, str]:
environment = {
"OCI_CLI_PROFILE": profile,
"TF_VAR_tenancy_ocid": values["tenancy"],
"TF_VAR_user_ocid": values["user"],
"TF_VAR_fingerprint": values["fingerprint"],
"TF_VAR_private_key_path": values["key_file"],
"TF_VAR_region": region_override or values["region"],
"TF_VAR_home_region": tenancy_home_region(values),
# A tenancy's home region is a fixed property of the tenancy, so it is an
# input rather than something to rediscover on every run. IAM resources
# have to be created against it; see the phase 12 note in the plan.
"TF_VAR_home_region": os.environ.get("OCI_HOME_REGION") or region_override or values["region"],
}
# One of the two, never both: the provider rejects a key given twice, and the
# variable validation in variables.tf says so before OCI is called at all.
if values.get("key_content"):
environment["TF_VAR_private_key"] = values["key_content"]
else:
environment["OCI_CLI_PROFILE"] = profile
environment["TF_VAR_private_key_path"] = values["key_file"]
if ssh_public_key_path:
key_path = Path(ssh_public_key_path).expanduser().resolve()
if not key_path.is_file():
@@ -102,7 +195,11 @@ def terraform_environment(
def main() -> int:
arg_parser = argparse.ArgumentParser()
arg_parser.add_argument("--profile", default=REQUESTED_PROFILE)
arg_parser.add_argument(
"--profile",
default=default_profile(),
help="Profile in ~/.oci/config. Defaults to $OCI_CLI_PROFILE, else DEFAULT.",
)
arg_parser.add_argument("--region")
arg_parser.add_argument(
"--ssh-public-key-path",
@@ -118,11 +215,21 @@ def main() -> int:
if args.shell and args.command:
arg_parser.error("--shell and a command cannot be used together")
ensure_venv()
config_path = Path(os.environ.get("OCI_CONFIG_FILE", Path.home() / ".oci" / "config"))
if not config_path.is_file():
raise FileNotFoundError(f"OCI configuration was not found at {config_path}")
profile, values = load_profile(config_path, args.profile)
values = load_environment()
if values is not None:
profile = "environment"
# Checked here, before anything calls OCI: --shell prints to stdout, which
# in CI is the job log, and this route holds the PEM itself.
if args.shell:
raise ValueError("--shell cannot be used with a credential from the environment: it would print the key")
else:
config_path = Path(os.environ.get("OCI_CONFIG_FILE", Path.home() / ".oci" / "config"))
if not config_path.is_file():
raise FileNotFoundError(
f"OCI configuration was not found at {config_path} and the environment holds no credential"
f" (set {', '.join(ENVIRONMENT_KEYS)})"
)
profile, values = load_profile(config_path, args.profile)
environment = terraform_environment(
profile,
values,
@@ -137,7 +244,7 @@ def main() -> int:
return 0
if not args.command:
print(f"Loaded OCI profile {profile} for region {environment['TF_VAR_region']}; virtualenv: {VENV_PYTHON.parent.parent}")
print(f"Loaded OCI profile {profile} for region {environment['TF_VAR_region']}")
return 0
command = args.command[1:] if args.command[0] == "--" else args.command
@@ -145,7 +252,12 @@ def main() -> int:
raise ValueError("A command is required after --")
child_environment = os.environ.copy()
child_environment.update(environment)
print(f"Running with OCI profile {profile} in {environment['TF_VAR_region']}: {' '.join(map(shlex.quote, command))}")
# Stderr, because stdout belongs to the wrapped command: a banner there is
# indistinguishable from output and corrupts anything that captures it.
print(
f"Running with OCI profile {profile} in {environment['TF_VAR_region']}: {' '.join(map(shlex.quote, command))}",
file=sys.stderr,
)
return subprocess.call(command, cwd=REPOSITORY, env=child_environment)
+90
View File
@@ -0,0 +1,90 @@
#!/bin/bash
# setup-identity.sh -- create the dynamic group and policy that let Spinifex nodes
# authenticate to the OCI API as themselves, so no API key is installed on any node.
#
# Run once per tenancy, with a tenancy-admin principal. Every deployment afterwards
# passes instance_principal = "adopt", which references these by nothing at all: the
# matching rule covers the whole compartment, so it names no instance and needs no
# OCID from here.
#
# Its own state file, deliberately. validate-topology.sh destroys its state at the
# end of every run, and these two resources outlive every topology -- holding them
# in a per-topology state would let a nightly teardown delete the tenancy's policy.
#
# MUST MAINTAIN BASH 3.X COMPATABILITY. Operators run this on macOS, which ships
# bash 3.2: no declare -A, no mapfile, and under set -u an empty "${arr[@]}" is an
# unbound variable, so guard every such expansion with ${#arr[@]}.
set -uo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
STATE_DIR="${OCI_STATE_ROOT:-$HERE}/.identity"
SSH_PUBLIC_KEY="${OCI_SSH_PUBLIC_KEY:-$HOME/.ssh/oci-spx.pub}"
DESTROY=0
DRY_RUN=0
log() { printf '[setup-identity] %s\n' "$*"; }
die() {
printf '[setup-identity] ERROR: %s\n' "$*" >&2
exit 1
}
usage() {
cat >&2 <<EOF
usage: ${0##*/} [--dry-run] [--destroy]
Creates the OCI dynamic group and policy for instance-principal authentication.
Needs a tenancy-admin OCI credential; the compartment-scoped user the rest of this
configuration runs as cannot create either resource.
--dry-run Show the plan, change nothing.
--destroy Remove them again. Every node using "adopt" loses its credential.
EOF
exit 2
}
while [ $# -gt 0 ]; do
case "$1" in
--dry-run) DRY_RUN=1; shift ;;
--destroy) DESTROY=1; shift ;;
-h | --help) usage ;;
*) die "unknown option $1" ;;
esac
done
[ -r "$SSH_PUBLIC_KEY" ] || die "no readable SSH public key at $SSH_PUBLIC_KEY (set OCI_SSH_PUBLIC_KEY)"
mkdir -p "$STATE_DIR"
# The instance and network resources are not targeted, so nothing else in this
# configuration is created or read. A tenancy admin running this does not need to
# hold the compartment rights the rest of it uses.
targets=(-target oci_identity_dynamic_group.nodes -target oci_identity_policy.nodes)
tf() {
python3 "$HERE/scripts/oci_env.py" --ssh-public-key-path "$SSH_PUBLIC_KEY" -- \
terraform -chdir="$HERE" "$@"
}
tf init -input=false -upgrade >/dev/null || die "terraform init failed"
if [ "$DESTROY" = 1 ]; then
log "destroying the dynamic group and policy"
tf destroy -auto-approve -var 'instance_principal=create' \
-state "$STATE_DIR/terraform.tfstate" "${targets[@]}" \
|| die "destroy failed"
log "removed. Any node configured for instance principal can no longer allocate an address."
exit 0
fi
args=(-var 'instance_principal=create' -state "$STATE_DIR/terraform.tfstate" "${targets[@]}")
if [ "$DRY_RUN" = 1 ]; then
log "dry run: planning the dynamic group and policy only"
tf plan "${args[@]}" || die "plan failed"
exit 0
fi
log "creating the dynamic group and policy (tenancy-root resources)"
tf apply -auto-approve "${args[@]}" || die "apply failed -- a tenancy-admin principal is required"
log "done. State in $STATE_DIR/terraform.tfstate -- keep it, it is the only record."
log "Deployments can now use instance_principal = \"adopt\" with a compartment-scoped user."
+268
View File
@@ -0,0 +1,268 @@
#!/usr/bin/env bash
# Installs the OCI credential the external-address allocator authenticates with
# on every node, as /etc/spinifex/oci/{config,oci_api_key.pem}.
#
# Usage: spx-oci-config.sh [--dry-run] <ssh-private-key> <host>...
#
# The argument shape is validate-topology.sh's credential-hook contract, so this
# drops straight into --credential-hook or $OCI_CREDENTIAL_HOOK.
#
# MUST MAINTAIN BASH 3.X COMPATABILITY. Operators run this on macOS, which ships
# bash 3.2: no declare -A, no mapfile, and under set -u an empty "${arr[@]}" is an
# unbound variable, so guard every such expansion with ${#arr[@]}.
set -euo pipefail
HERE=$(CDPATH='' cd -- "$(dirname -- "$0")" && pwd)
SELF="$HERE/$(basename -- "$0")"
# What the allocator reads, from the pool fragment Terraform stages on each node.
# The config path and the profile name are the contract; the key filename is only
# ever named by key_file inside that config.
REMOTE_DIR=/etc/spinifex/oci
REMOTE_CONFIG="$REMOTE_DIR/config"
REMOTE_PEM="$REMOTE_DIR/oci_api_key.pem"
REMOTE_PROFILE=spinifex
SPX_KEY="${OCI_SPX_KEY:-$HOME/.oci/oci_api_key_spx.pem}"
SPX_PROFILE="${OCI_SPX_PROFILE:-spinifex}"
SSH_OPTS=(-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null
-o LogLevel=ERROR -o BatchMode=yes -o ConnectTimeout=15)
DRY_RUN=0
die() { echo "$(basename -- "$0"): $*" >&2; exit 1; }
log() { echo "[spx-oci-config] $*"; }
usage() {
sed -n '2,8p' "$SELF" | sed 's/^# \{0,1\}//'
cat <<'EOF'
Options:
--dry-run Resolve and validate the credential, print what would be written,
and touch no host.
-h, --help This text.
The credential is resolved in this order, and the first one that is complete wins:
1. $OCI_SPX_PRIVATE_KEY with $OCI_SPX_USER_OCID, $OCI_SPX_FINGERPRINT,
$OCI_SPX_TENANCY_OCID and $OCI_SPX_REGION. A dedicated narrow credential,
which is the shape a CI secret store holds. A partial set is an error.
2. The [spinifex] profile in ~/.oci/config, or $OCI_SPX_PROFILE.
3. ~/.oci/oci_api_key_spx.pem, or $OCI_SPX_KEY, with the user, tenancy and
region of the profile Terraform itself uses. The fingerprint is derived
from the key rather than read from the config.
4. The credential Terraform builds with. This works and is wider than the
allocator needs, so it warns.
Rules 2 to 4 resolve the Terraform profile through scripts/oci_env.py, so the
precedence is the same one the apply used and the two cannot disagree.
EOF
}
while [ "$#" -gt 0 ]; do
case "$1" in
--dry-run) DRY_RUN=1; shift ;;
-h|--help) usage; exit 0 ;;
--) shift; break ;;
-*) die "unknown option: $1" ;;
*) break ;;
esac
done
# oci_env.py owns credential resolution for this directory: the environment route
# a runner uses, ~/.oci/config otherwise, a partial set treated as an error, and
# the shape checks. Re-exec under it rather than reimplement any of that here.
if [ -z "${SPX_OCI_ENV_READY:-}" ]; then
[ -x "$HERE/scripts/oci_env.py" ] || [ -r "$HERE/scripts/oci_env.py" ] \
|| die "scripts/oci_env.py was not found next to $SELF"
if [ "$DRY_RUN" = 1 ]; then
exec env SPX_OCI_ENV_READY=1 python3 "$HERE/scripts/oci_env.py" -- "$SELF" --dry-run "$@"
fi
exec env SPX_OCI_ENV_READY=1 python3 "$HERE/scripts/oci_env.py" -- "$SELF" "$@"
fi
# The same argument shape in both modes, so a --dry-run rehearsal and the real run
# differ by one flag. Only the checks that need a host are skipped.
ssh_key="${1:?usage: $(basename -- "$0") [--dry-run] <ssh-private-key> <host>...}"
shift
if [ "$DRY_RUN" != 1 ]; then
[ "$#" -gt 0 ] || die "no hosts given"
[ -r "$ssh_key" ] || die "SSH private key is not readable: $ssh_key"
fi
# A key's fingerprint is a property of the key. Deriving it beats trusting a
# config field, because a stale fingerprint beside a good key is a 401 that names
# neither.
fingerprint_of() {
openssl rsa -pubout -outform DER -in "$1" 2>/dev/null \
| openssl md5 -c 2>/dev/null | awk '{print $NF}'
}
require_complete() {
local source="$1" missing="" name
shift
for name in "$@"; do
[ -n "${!name:-}" ] || missing="$missing $name"
done
[ -z "$missing" ] || die "$source is incomplete, missing:$missing"
}
key_pem="" # the PEM itself, never a path, so nothing is read twice
oci_user=""
oci_fingerprint=""
oci_tenancy=""
oci_region=""
source_name=""
if [ -n "${OCI_SPX_PRIVATE_KEY:-}${OCI_SPX_USER_OCID:-}${OCI_SPX_FINGERPRINT:-}${OCI_SPX_TENANCY_OCID:-}${OCI_SPX_REGION:-}" ]; then
require_complete "the OCI_SPX_* credential in the environment" \
OCI_SPX_PRIVATE_KEY OCI_SPX_USER_OCID OCI_SPX_FINGERPRINT \
OCI_SPX_TENANCY_OCID OCI_SPX_REGION
# Base64 accepted by shape, because a PEM's newlines do not survive every
# secret store, and detected rather than selected by a second variable.
case "$OCI_SPX_PRIVATE_KEY" in
*-----BEGIN*) key_pem="$OCI_SPX_PRIVATE_KEY" ;;
*) key_pem=$(printf '%s' "$OCI_SPX_PRIVATE_KEY" | tr -d ' \n' | base64 -d 2>/dev/null) || true ;;
esac
case "$key_pem" in
*-----BEGIN*) ;;
*) die "OCI_SPX_PRIVATE_KEY is neither a PEM nor a base64-encoded PEM" ;;
esac
oci_user="$OCI_SPX_USER_OCID"
oci_fingerprint="$OCI_SPX_FINGERPRINT"
oci_tenancy="$OCI_SPX_TENANCY_OCID"
oci_region="$OCI_SPX_REGION"
source_name="the OCI_SPX_* environment credential"
elif [ -r "$HOME/.oci/config" ] && python3 - "$SPX_PROFILE" <<'PY'
import configparser, pathlib, sys
cfg = configparser.RawConfigParser()
cfg.read(pathlib.Path.home() / ".oci" / "config")
sys.exit(0 if cfg.has_section(sys.argv[1]) else 1)
PY
then
# One pipe-separated line read into named variables, rather than eval of
# generated shell: none of these fields can contain a pipe.
profile_fields=$(python3 - "$SPX_PROFILE" <<'PY'
import configparser, pathlib, sys
cfg = configparser.RawConfigParser()
cfg.read(pathlib.Path.home() / ".oci" / "config")
section = dict(cfg[sys.argv[1]])
fields = ("user", "fingerprint", "tenancy", "region", "key_file")
missing = [f for f in fields if not section.get(f)]
if missing:
sys.exit(f"profile {sys.argv[1]} has no {', '.join(missing)}")
print("|".join(section[f] for f in fields))
PY
) || die "could not read the [$SPX_PROFILE] profile from ~/.oci/config"
IFS='|' read -r profile_user profile_fingerprint profile_tenancy \
profile_region profile_key_file <<EOF
$profile_fields
EOF
profile_key=$(printf '%s' "$profile_key_file" | sed "s|^~|$HOME|")
[ -r "$profile_key" ] || die "key_file in [$SPX_PROFILE] is not readable: $profile_key"
key_pem=$(cat "$profile_key")
oci_user="$profile_user"
oci_fingerprint="$profile_fingerprint"
oci_tenancy="$profile_tenancy"
oci_region="$profile_region"
source_name="the [$SPX_PROFILE] profile in ~/.oci/config"
elif [ -r "$SPX_KEY" ]; then
require_complete "the credential oci_env.py resolved" \
TF_VAR_user_ocid TF_VAR_tenancy_ocid TF_VAR_region
key_pem=$(cat "$SPX_KEY")
oci_user="${TF_VAR_user_ocid:-}"
oci_fingerprint=$(fingerprint_of "$SPX_KEY")
oci_tenancy="${TF_VAR_tenancy_ocid:-}"
oci_region="${TF_VAR_region:-}"
source_name="$SPX_KEY with the user and tenancy of the Terraform credential"
[ -n "$oci_fingerprint" ] || die "could not read an RSA public key out of $SPX_KEY"
else
require_complete "the credential oci_env.py resolved" \
TF_VAR_user_ocid TF_VAR_fingerprint TF_VAR_tenancy_ocid TF_VAR_region
if [ -n "${TF_VAR_private_key:-}" ]; then
key_pem="$TF_VAR_private_key"
elif [ -n "${TF_VAR_private_key_path:-}" ] && [ -r "$TF_VAR_private_key_path" ]; then
key_pem=$(cat "$TF_VAR_private_key_path")
else
die "no node credential found: set OCI_SPX_PRIVATE_KEY, add a [$SPX_PROFILE] profile to ~/.oci/config, or create $SPX_KEY"
fi
oci_user="${TF_VAR_user_ocid:-}"
oci_fingerprint="${TF_VAR_fingerprint:-}"
oci_tenancy="${TF_VAR_tenancy_ocid:-}"
oci_region="${TF_VAR_region:-}"
source_name="the credential Terraform builds with"
log "WARNING: falling back to $source_name, which is wider than the allocator needs."
log "WARNING: see the OCI guide for a user scoped to vnics, private-ips and public-ips."
fi
case "$oci_user$oci_tenancy" in
ocid1.*) ;;
*) die "$source_name did not yield OCIDs for the user and tenancy" ;;
esac
echo "$oci_fingerprint" | grep -Eq '^[0-9a-f]{2}(:[0-9a-f]{2}){15}$' \
|| die "$source_name has no 16-byte hex fingerprint: '$oci_fingerprint'"
# The fingerprint and the key have to be the same key, and nothing upstream checks
# that they are. Unchecked it is an OCI 401 at the first allocation, long after the
# deploy reports success.
pem_tmp=$(mktemp); trap 'rm -f "$pem_tmp"' EXIT
( umask 077; printf '%s\n' "$key_pem" > "$pem_tmp" )
derived=$(fingerprint_of "$pem_tmp")
[ -n "$derived" ] || die "$source_name does not hold a usable RSA private key"
[ "$derived" = "$oci_fingerprint" ] \
|| die "the key from $source_name has fingerprint $derived, but the credential names $oci_fingerprint"
log "credential: $source_name"
log "user: $oci_user"
log "fingerprint: $oci_fingerprint"
log "region: $oci_region"
if [ "$DRY_RUN" = 1 ]; then
log "would write $REMOTE_CONFIG and $REMOTE_PEM as 0640 root:spinifex on:${*:-" (no hosts given)"}"
exit 0
fi
# base64 so the PEM survives two shells, and carried in the stdin stream rather
# than in the command: an argument is readable from ps on the node for the length
# of the call, on a host that runs other people's guests.
key_b64=$(printf '%s\n' "$key_pem" | base64 | tr -d '\n')
install_on() {
# Unquoted heredoc on purpose: the key and the four credential fields are
# expanded here, so nothing in it is expanded on the node.
# shellcheck disable=SC2087
ssh -i "$ssh_key" "${SSH_OPTS[@]}" "ubuntu@$1" bash -s <<REMOTE
set -eu
getent group spinifex >/dev/null \
|| { echo "no spinifex group yet, so this ran before the install" >&2; exit 3; }
sudo install -d -m 0750 -o root -g spinifex $REMOTE_DIR
# install /dev/null first, then write: the file never exists at a wider mode, and
# sudo's umask is not the caller's.
sudo install -m 0640 -o root -g spinifex /dev/null $REMOTE_PEM
base64 -d <<'PEM_B64' | sudo tee $REMOTE_PEM >/dev/null
$key_b64
PEM_B64
sudo install -m 0640 -o root -g spinifex /dev/null $REMOTE_CONFIG
sudo tee $REMOTE_CONFIG >/dev/null <<'CONFIG'
[$REMOTE_PROFILE]
user=$oci_user
fingerprint=$oci_fingerprint
tenancy=$oci_tenancy
region=$oci_region
key_file=$REMOTE_PEM
CONFIG
REMOTE
}
# Every host is attempted and the failures are named. Stopping at the first leaves
# a half-credentialled cluster and a log that does not say which half.
failed=""
for host in "$@"; do
log "installing the OCI credential on $host"
install_on "$host" || failed="$failed $host"
done
[ -z "$failed" ] || die "failed on:$failed"
log "credential installed on $# host(s)"
+853
View File
@@ -0,0 +1,853 @@
#!/bin/bash
# Builds one OCI topology from nothing, proves Spinifex works on it, and destroys
# it again. 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.
#
# Every run is build-prove-destroy. A topology that cannot be torn down is half
# proved, so the teardown decides the verdict and runs even on failure.
#
# MUST MAINTAIN BASH 3.X COMPATABILITY. Operators run this on macOS, which ships
# bash 3.2: no declare -A, no mapfile, and under set -u an empty "${arr[@]}" is an
# unbound variable, so guard every such expansion with ${#arr[@]}.
set -euo pipefail
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$HERE/../../.." && pwd)"
TOPOLOGY=""
SSH_PUBLIC_KEY="$HOME/.ssh/oci-spx.pub"
SSH_PRIVATE_KEY="$HOME/.ssh/oci-spx"
KEEP=0
KEEP_ON_FAIL=0
SKIP_WORKLOAD=0
SKIP_POOL=0
DRY_RUN=0
DESTROY_ONLY=0
INSTANCE_PRINCIPAL=0
CREDENTIAL_HOOK="${OCI_CREDENTIAL_HOOK:-}"
# Empty means oci_env.py chooses, which is $OCI_CLI_PROFILE, then the reference
# tenancy, then DEFAULT. Named here so a deployment can say which tenancy it is in.
OCI_PROFILE="${OCI_PROFILE:-}"
DISTRO=""
SETUP_SH=""
# Empty means the driver's own default list. Unset is distinguishable from empty,
# so --workbooks "" can deliberately mean "run none".
WORKBOOKS_SET=0
CHANNEL=latest
INSTALL_VERSION=""
WORKBOOKS=""
# Declared here, not at the apply that fills it: the EXIT trap reads it, and under
# set -u an unset array is an error rather than an empty one.
HOSTS=()
TOPOLOGIES="bm vm-single vm-multi vm-single-principal"
# What a topology is worth when nothing says otherwise, so a CI run of a named
# topology is the same every time. Supplied as TF_VAR_, which terraform.auto.tfvars
# outranks, so a deployment sizes itself in that file and no flag has to carry it.
#
# TOPO_FORCE_PRINCIPAL is the exception: vm-single-principal exists to exercise
# instance-principal authentication, so the credential is the topology rather than
# a separate switch that could be left at the other value.
#
# A case rather than an associative array: macOS ships bash 3.2, where declare -A
# is an indexed assignment and the key is read as an arithmetic variable.
topo_defaults() {
TOPO_FORCE_PRINCIPAL=0
case "$1" in
bm) TOPO_DEFAULT_SHAPE="BM.Standard.E2.64"; TOPO_DEFAULT_NODES=1 ;;
vm-single) TOPO_DEFAULT_SHAPE="VM.Standard.E6.Flex"; TOPO_DEFAULT_NODES=1 ;;
vm-multi) TOPO_DEFAULT_SHAPE="VM.Standard.E6.Flex"; TOPO_DEFAULT_NODES=3 ;;
vm-single-principal)
# Its own tenancy carries its own service limits, so the shape every
# other topology uses can have zero quota there and fail the apply
# before a single thing about the principal has been proved.
TOPO_DEFAULT_SHAPE="${OCI_PRINCIPAL_SHAPE:-VM.Standard.E6.Flex}"
TOPO_DEFAULT_NODES=1
TOPO_FORCE_PRINCIPAL=1 ;;
*) return 1 ;;
esac
}
log() { printf '[validate-%s] %s\n' "${TOPOLOGY:-?}" "$*"; }
die() {
printf '[validate-%s] ERROR: %s\n' "${TOPOLOGY:-?}" "$*" >&2
exit 1
}
usage() {
cat >&2 <<EOF
usage: ${0##*/} --topology <bm|vm-single|vm-multi|vm-single-principal> [options]
Builds the topology, installs Spinifex, forms the cluster, runs a Terraform
workbook against it, then destroys everything.
--topology <name> Required. No default: a command aimed at the wrong
topology is the easiest expensive mistake here.
vm-single-principal is vm-single authenticating as the
instance, and implies --instance-principal.
--ssh-public-key PATH Public half installed on each node. Default $SSH_PUBLIC_KEY
--ssh-private-key PATH Private half used to reach them. Default $SSH_PRIVATE_KEY
--instance-principal Configure the pool with oci_auth="instance_principal"
instead of a key file. Needs the dynamic group and
policy to exist already; see instance-principal.tf.
Not needed when instance_principal is set in your
tfvars, which this reads; the flag outranks it.
--credential-hook PATH Executable run after formation, before the pool, as
"hook <ssh-key> <host>...". Where an API-key deployment
installs its credential. Default \$OCI_CREDENTIAL_HOOK.
--oci-profile NAME Profile in ~/.oci/config that Terraform builds with.
Default \$OCI_PROFILE, else \$OCI_CLI_PROFILE, else the
reference tenancy, else DEFAULT. A name that is passed
and absent is an error, never a fallback.
--workbooks LIST Space-separated workbooks for the published driver to
run on the cluster. Empty means run none; omitted means
the driver's own default list.
--version TAG Install this exact release tag. Preferred over
--channel dev for anything repeatable: a tag resolves
by redirect, while the dev channel resolves through a
rate-limited GitHub API call that 404s when it trips.
--distro PATH Install this distro tarball instead of the published
release, so what is proved is the ref it was built from.
--setup-sh PATH setup.sh to pair with --distro. Both or neither.
--no-external-pool Form without an OCI credential: no external pool and no
allocator gate. Implies --skip-workload, because a guest
with no public address proves nothing a customer wants.
--skip-workload Form and verify only; launch no guests.
--keep Leave the infrastructure up. Implies no verdict.
--keep-on-fail Destroy on success, leave a failure up to inspect.
--destroy-only Destroy whatever this topology's state holds, then stop.
--dry-run Print the plan and the per-node steps, change nothing.
EOF
exit 2
}
while [ $# -gt 0 ]; do
case "$1" in
--topology) TOPOLOGY="${2:?}"; shift 2 ;;
--ssh-public-key) SSH_PUBLIC_KEY="${2:?}"; shift 2 ;;
--ssh-private-key) SSH_PRIVATE_KEY="${2:?}"; shift 2 ;;
--workbooks) WORKBOOKS_SET=1; WORKBOOKS="${2-}"; shift 2 ;;
--channel) CHANNEL="${2-}"; shift 2 ;;
--version) INSTALL_VERSION="${2:?}"; shift 2 ;;
--distro) DISTRO="${2:?}"; shift 2 ;;
--setup-sh) SETUP_SH="${2:?}"; shift 2 ;;
--instance-principal) INSTANCE_PRINCIPAL=1; shift ;;
--credential-hook) CREDENTIAL_HOOK="${2:?}"; shift 2 ;;
--oci-profile) OCI_PROFILE="${2:?}"; shift 2 ;;
--no-external-pool) SKIP_POOL=1; SKIP_WORKLOAD=1; shift ;;
--skip-workload) SKIP_WORKLOAD=1; shift ;;
--keep) KEEP=1; shift ;;
--keep-on-fail) KEEP_ON_FAIL=1; shift ;;
--destroy-only) DESTROY_ONLY=1; shift ;;
--dry-run) DRY_RUN=1; shift ;;
-h | --help) usage ;;
*) die "unknown option $1" ;;
esac
done
[ -n "$TOPOLOGY" ] || usage
topo_defaults "$TOPOLOGY" || die "unknown topology $TOPOLOGY; one of: $TOPOLOGIES"
[ -r "$SSH_PUBLIC_KEY" ] || die "no readable SSH public key at $SSH_PUBLIC_KEY"
[ -r "$SSH_PRIVATE_KEY" ] || die "no readable SSH private key at $SSH_PRIVATE_KEY"
# Both or neither: a distro with the published setup.sh installs one ref's bytes
# with another's layout, and that is a cluster nobody can reason about.
if [ -n "$DISTRO" ] || [ -n "$SETUP_SH" ]; then
[ -r "$DISTRO" ] || die "--distro is not readable: '$DISTRO'"
[ -r "$SETUP_SH" ] || die "--setup-sh is not readable: '$SETUP_SH'"
fi
if [ "$TOPO_FORCE_PRINCIPAL" = 1 ]; then
INSTANCE_PRINCIPAL=1
# The pool is the whole point of this topology: with no allocator built, the
# credential is never used and a green run says nothing about it. That is
# exactly how the auth branch stayed broken through passing nightlies.
[ "$SKIP_POOL" != 1 ] \
|| die "--no-external-pool makes $TOPOLOGY meaningless: the allocator is what exercises the instance principal, so a run without it proves nothing"
fi
# Terraform's own order, as far as this script needs it: the last assignment across
# terraform.tfvars then *.auto.tfvars alphabetically is the one that applies. Read
# because the credential-hook gate has to run before the apply spends anything; the
# apply's own output is checked against this afterwards.
tfvars_instance_principal() {
local file mode="" found=""
for file in "$HERE/terraform.tfvars" "$HERE"/*.auto.tfvars; do
[ -r "$file" ] || continue
mode=$(sed -n 's/^[[:space:]]*instance_principal[[:space:]]*=[[:space:]]*"\([a-z]*\)".*/\1/p' \
"$file" | tail -1)
[ -n "$mode" ] && found="$mode"
done
[ -n "$found" ] || return 1
printf '%s\n' "$found"
}
# adopt, never create: this script's state is destroyed at the end of every run, and
# the dynamic group and policy outlive every topology. setup-identity.sh owns them.
#
# A flag goes in as -var and outranks the tfvars, because it is a choice about this
# run. Without one the deployment's own tfvars decides, so a guide can set the
# credential in the file it already writes and no flag has to carry it.
tf_principal_var=()
if [ "$INSTANCE_PRINCIPAL" = 1 ]; then
PRINCIPAL_MODE=adopt
tf_principal_var=(-var "instance_principal=$PRINCIPAL_MODE")
else
PRINCIPAL_MODE="$(tfvars_instance_principal || echo off)"
fi
# What the apply actually built, filled in from the hosts_file output once it has.
# Until then these are what the topology asked for, which is all there is to say.
SHAPE="$TOPO_DEFAULT_SHAPE"
NODES="$TOPO_DEFAULT_NODES"
# Outside the checkout on a persistent runner, because actions/checkout runs
# git clean -ffdx: it would delete the Terraform state of a killed run before the
# sweep could read it, leaving instances running that nothing can find.
STATE_ROOT="${OCI_STATE_ROOT:-$HERE}"
mkdir -p "$STATE_ROOT"
STATE_DIR="$STATE_ROOT/.validate-$TOPOLOGY"
RESULTS="$STATE_DIR/results.txt"
# Tab-separated siblings of the human block, for the run-page tables. Written here
# rather than parsed out of the log later: the status of a gate is then a field
# this script sets, not a regex over prose that reads fine and matches wrong.
RESULTS_TSV="$STATE_DIR/results.tsv"
WORKBOOKS_TSV="$STATE_DIR/workbooks.tsv"
# A separate state directory per topology, so two topologies can be built from one
# checkout without one destroying the other's instances.
tf() {
# The topology's shape and count go in as TF_VAR_, which is the weakest source
# Terraform reads: a terraform.auto.tfvars in the checkout outranks it, so a
# deployment sizes itself in that file and the topology only supplies the
# default a CI run wants.
TF_VAR_compute_shape="$TOPO_DEFAULT_SHAPE" TF_VAR_node_count="$TOPO_DEFAULT_NODES" \
python3 "$HERE/scripts/oci_env.py" --ssh-public-key-path "$SSH_PUBLIC_KEY" \
${OCI_PROFILE:+--profile "$OCI_PROFILE"} -- \
terraform -chdir="$HERE" "$@"
}
# terraform output takes -state but not -var, so the two sets are kept apart. They
# were one set once, and the -var made output fail into the default state file.
tf_state=(-state "$STATE_DIR/terraform.tfstate")
tf_vars=("${tf_state[@]}")
# Expanded only when it holds something. bash before 4.4, which is what macOS
# ships, calls "${arr[@]}" on an empty array an unbound variable under set -u, and
# tf_principal_var is empty on every run that did not pass --instance-principal.
if [ "${#tf_principal_var[@]}" -gt 0 ]; then
tf_vars=("${tf_principal_var[@]}" "${tf_vars[@]}")
fi
ssh_node() {
local host="$1"
shift
# LogLevel=ERROR: the host-key warning is unavoidable with a throwaway known-hosts
# file, and it lands in the middle of whatever the remote command reported.
ssh -i "$SSH_PRIVATE_KEY" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
-o LogLevel=ERROR -o BatchMode=yes -o ConnectTimeout=15 "ubuntu@$host" "$@"
}
# record <gate> <PASS|FAIL|SKIPPED|INFO> [detail]
# The node's own account of a failed run. A workbook's log says which API call
# failed; only the journal says why, and the nodes are destroyed minutes later.
# Warnings first because that is where a swallowed error surfaces, then a bounded
# tail for context -- unbounded, a multi-hour suite's journal dwarfs the artifact.
capture_journals() {
local host
# A failure before the apply leaves no hosts, and on bash before 4.4 iterating
# the empty array is an unbound-variable error rather than zero passes.
[ "${#HOSTS[@]}" -gt 0 ] || return 0
for host in "${HOSTS[@]}"; do
ssh_node "$host" '
echo "=== spinifex, warning and above ==="
sudo journalctl -u "spinifex-*" --since -4h --priority=warning --no-pager | tail -2000
echo "=== spinifex, all priorities, last 2000 lines ==="
sudo journalctl -u "spinifex-*" --since -4h --no-pager | tail -2000
' > "$STATE_DIR/journal-$host.log" 2>&1 || log "could not collect the journal from $host"
done
}
record() {
local gate="$1" status="$2" detail="${3-}"
printf '%s: %s%s\n' "$gate" "$status" "${detail:+ $detail}" >> "$RESULTS"
printf '%s\t%s\t%s\n' "$gate" "$status" "$detail" >> "$RESULTS_TSV"
}
# The driver's per-workbook lines, which are go test's own shape because
# go-junit-report consumes them downstream. A workbook the driver never reached
# is absent from its log and so absent here, which the summary renders as a row
# rather than dropping — a missing row reads as a pass.
record_workbooks() {
local log="$1"
[ -r "$log" ] || return 0
sed -n 's/^--- \(PASS\|FAIL\): TestTofuWorkbook_\([A-Za-z0-9_]*\) (\([0-9]*\)\.[0-9]*s)$/\2\t\1\t\3/p' \
"$log" | tr '_' '-' > "$WORKBOOKS_TSV"
}
# Teardown is the last assertion, not cleanup: a workbook or a topology that
# cannot be destroyed is a defect, and it has been one before.
DESTROY_RC=""
destroy_topology() {
log "destroying"
if tf destroy -auto-approve -no-color "${tf_vars[@]}" > "$STATE_DIR/destroy.log" 2>&1; then
DESTROY_RC=0
record destroy PASS
else
DESTROY_RC=1
record destroy FAIL "see $STATE_DIR/destroy.log"
log "TEARDOWN FAILED — resources may still be billing. $STATE_DIR/destroy.log"
fi
}
cleanup() {
local rc=$?
if [ "$KEEP" = 1 ]; then
log "--keep: leaving the infrastructure up, no verdict recorded"
# The command to get in, printed rather than left to be assembled from an
# address elsewhere in the log. HOSTS is empty if we never got as far as
# the apply, which is why this is guarded rather than assumed.
if [ "${#HOSTS[@]}" -gt 0 ]; then
# ~ rather than the expanded path, so the line is the same on every
# machine and reads as something a person would type.
local key="$SSH_PRIVATE_KEY"
case "$key" in "$HOME"/*) key="~${key#"$HOME"}" ;; esac
log "ssh in with: ssh -i $key ubuntu@${HOSTS[0]}"
log "destroy it with: $0 --topology $TOPOLOGY --destroy-only"
fi
return
fi
# Only on a failure, and said out loud: the cost of forgetting is an OCI bare
# metal host running overnight.
if [ "$KEEP_ON_FAIL" = 1 ] && [ "$rc" != 0 ]; then
log "--keep-on-fail: $TOPOLOGY FAILED and is being left up. IT IS STILL BILLING."
log "destroy it with: $0 --topology $TOPOLOGY --destroy-only"
verdict "$rc"
return
fi
[ "$DRY_RUN" = 1 ] && return
destroy_topology
verdict "$rc"
}
verdict() {
local run_rc="$1"
echo
log "=== $TOPOLOGY ($SHAPE, $NODES node(s)) ==="
[ -r "$RESULTS" ] && cat "$RESULTS"
if [ "$run_rc" = 0 ] && [ "$DESTROY_RC" = 0 ]; then
log "PASS"
else
log "FAIL"
fi
}
mkdir -p "$STATE_DIR"
if [ "$DESTROY_ONLY" = 1 ]; then
log "destroy only: whatever $STATE_DIR/terraform.tfstate holds"
destroy_topology
[ "$DESTROY_RC" = 0 ] || exit 1
exit 0
fi
: > "$RESULTS"
: > "$RESULTS_TSV"
: > "$WORKBOOKS_TSV"
# Last run's logs go before this one's first line, so what is left in here always
# describes the run that is starting. Without this a topology keeps logs that read
# as current, and anything collecting the directory publishes them as this run's.
# Named globs rather than a find: the tfstate is how the sweep finds hosts to
# destroy, so what is removed here has to be readable at a glance.
# :? so an unset STATE_DIR stops the shell rather than expanding to /*.log.
rm -f "${STATE_DIR:?}"/*.log "${STATE_DIR:?}"/nodes.txt "${STATE_DIR:?}"/cloudinit-*.txt
log "$SHAPE, $NODES node(s), state in $STATE_DIR"
# Checked here, not where the hook is run: nothing about it depends on the apply, and
# the run is otherwise forty minutes and a bare-metal bill from discovering that the
# path in a CI variable does not exist on this runner.
if [ "$SKIP_POOL" != 1 ] && [ "$PRINCIPAL_MODE" = off ]; then
[ -n "$CREDENTIAL_HOOK" ] \
|| die "this deployment authenticates with an API key and no credential hook is set, so no node could reach the OCI API; set --credential-hook or OCI_CREDENTIAL_HOOK, or use an instance principal"
[ -x "$CREDENTIAL_HOOK" ] || die "credential hook is not executable: $CREDENTIAL_HOOK"
fi
# The directory's own terraform.tfstate belongs to hand-driven runs, not to a
# topology, and an instance left in it is both a bill and a name that collides
# with ours in the console. Say so rather than letting it be a surprise.
if [ -s "$HERE/terraform.tfstate" ]; then
stray="$(python3 -c '
import json, sys
state = json.load(open(sys.argv[1]))
for res in state.get("resources", []):
if res["type"] != "oci_core_instance":
continue
for inst in res["instances"]:
attrs = inst["attributes"]
if attrs.get("state") not in ("TERMINATED", None):
print(attrs.get("display_name"), attrs.get("shape"), attrs.get("state"), attrs.get("public_ip"))
' "$HERE/terraform.tfstate" 2>/dev/null || true)"
[ -n "$stray" ] && log "NOTE: the hand-driven state in $HERE still holds: $stray"
fi
# Terraform ranks a *.auto.tfvars file above TF_VAR_*, so a leftover one wins
# silently over an operator targeting a different tenancy by environment. The
# guide tells every operator to write one, which is what makes this likely.
assert_no_tfvars_override() {
local f want got
want="${TF_VAR_compartment_ocid:-}"
[ -n "$want" ] || return 0
for f in "$HERE"/*.auto.tfvars "$HERE"/terraform.tfvars; do
[ -f "$f" ] || continue
got=$(sed -n 's/^[[:space:]]*compartment_ocid[[:space:]]*=[[:space:]]*"\([^"]*\)".*/\1/p' "$f" | tail -1)
[ -n "$got" ] || continue
[ "$got" = "$want" ] && continue
die "${f##*/} sets compartment_ocid=$got, which Terraform ranks above the TF_VAR_compartment_ocid=$want this run was given, so it would build in a compartment nobody chose. Remove that file or make it agree."
done
}
assert_no_tfvars_override
if [ "$DRY_RUN" = 1 ]; then
# Whole plan to a file, summary to the terminal. A dry run is here to catch a
# plan aimed at the wrong compartment, and `tail` dropped the resource count
# and every compartment and shape line along with it, leaving only the outputs
# block -- so the one check it exists for could not be made from its output.
mkdir -p "$STATE_DIR"
tf plan -no-color "${tf_vars[@]}" > "$STATE_DIR/plan.txt" 2>&1 \
|| { tail -30 "$STATE_DIR/plan.txt"; die "plan failed; see $STATE_DIR/plan.txt"; }
grep -E '^Plan: |^(No changes)' "$STATE_DIR/plan.txt" || true
printf 'compartment and shape as planned:\n'
# Terraform aligns the = to the widest key in each block, so the same value
# appears several times at several indents unless the spacing is normalised.
grep -hoE '(compartment_id|shape|availability_domain) += +"[^"]*"' "$STATE_DIR/plan.txt" \
| sed -E 's/ +=/ =/' | sort -u | sed 's/^/ /'
log "full plan in $STATE_DIR/plan.txt"
plan="install Spinifex on $NODES node(s), form the cluster"
[ "$SKIP_POOL" = 1 ] && plan="$plan, configure no external pool"
if [ "$SKIP_WORKLOAD" = 1 ]; then
plan="$plan, launch no guests"
elif [ "$WORKBOOKS_SET" = 1 ]; then
plan="$plan, run the workbooks '$WORKBOOKS'"
else
plan="$plan, run the driver's default workbooks"
fi
log "dry run: would $plan, then destroy"
exit 0
fi
trap cleanup EXIT
tf init -input=false > "$STATE_DIR/init.log" 2>&1 || die "terraform init failed; see $STATE_DIR/init.log"
log "building"
tf apply -auto-approve -no-color "${tf_vars[@]}" > "$STATE_DIR/apply.log" 2>&1 \
|| die "terraform apply failed; see $STATE_DIR/apply.log"
record build PASS
# A read loop rather than mapfile, which bash 3.2 on macOS does not have. The
# output is captured first so a failed terraform fails here, not silently as an
# empty host list.
hosts_raw=$(tf output -raw "${tf_state[@]}" hosts_file) \
|| die "could not read the hosts_file output from $STATE_DIR/terraform.tfstate"
HOSTS=()
while IFS= read -r line; do
if [ -n "$line" ]; then HOSTS+=("$line"); fi
done <<EOF
$hosts_raw
EOF
[ "${#HOSTS[@]}" -ge 1 ] || die "the hosts_file output named no hosts, so the apply built nothing to install on"
for host in "${HOSTS[@]}"; do
[[ "$host" =~ ^[0-9]+(\.[0-9]+){3}$ ]] \
|| die "the hosts_file output is not an address: '$host' -- something on the wrapper's stdout is in the capture"
done
# Written for every topology, not just the multi-node one that passes it to
# install-node.sh: it is also how an operator gets the addresses afterwards
# without a terraform invocation and the right -state path.
printf '%s\n' "${HOSTS[@]}" > "$STATE_DIR/hosts"
# node_count can come from a tfvars file, so the hosts the apply produced are the
# authority on how many there are. Every later count check reads this.
if [ "${#HOSTS[@]}" != "$NODES" ]; then
log "note: $NODES node(s) is the $TOPOLOGY default, and the apply built ${#HOSTS[@]}; taking the apply's"
NODES="${#HOSTS[@]}"
fi
SHAPE=$(tf output -raw "${tf_state[@]}" compute_shape) \
|| die "could not read the compute_shape output from $STATE_DIR/terraform.tfstate"
# Terraform is the authority on which credential was built, and the reader above is
# a guess made before the apply. They have to agree: the hook is skipped on the
# strength of that guess, and a node left with no credential at all would otherwise
# fail much later as an allocator that cannot resolve its VNIC.
built_principal=$(tf output -raw "${tf_state[@]}" instance_principal) \
|| die "could not read the instance_principal output from $STATE_DIR/terraform.tfstate"
[ "$built_principal" = "$PRINCIPAL_MODE" ] \
|| die "read instance_principal='$PRINCIPAL_MODE' from the tfvars and the apply built '$built_principal'; set it in one place"
log "hosts: ${HOSTS[*]}"
log "$SHAPE, $NODES node(s), oci_auth $([ "$PRINCIPAL_MODE" = off ] && echo "from a key file" || echo "as the instance principal")"
# cloud-init owns the volume, the ports and br-wan, and all three are units now, so
# the question is whether they converged rather than whether a runcmd happened to
# run. This is the gate the bare-metal findings were about.
log "waiting for cloud-init on each node"
for host in "${HOSTS[@]}"; do
for _ in $(seq 1 120); do
if ssh_node "$host" 'cloud-init status --wait >/dev/null 2>&1 || true; test -f /var/lib/cloud/instance/boot-finished' 2>/dev/null; then
break
fi
sleep 10
done
ssh_node "$host" '
set -e
for u in spinifex-data-volume spinifex-service-ports spinifex-wan-bridge; do
printf "%s=%s " "$u" "$(systemctl is-active "$u" 2>&1)"
done
echo
findmnt --noheadings --output SOURCE,TARGET /var/lib/spinifex
ip -br addr show br-wan
' > "$STATE_DIR/cloudinit-$host.txt" 2>&1 \
|| die "cloud-init did not converge on $host; see $STATE_DIR/cloudinit-$host.txt"
log "$host cloud-init: $(head -1 "$STATE_DIR/cloudinit-$host.txt")"
done
record "cloud-init units" PASS
# With --distro the bytes are the ref under test; without it they are the published
# release, which answers a different question -- what a customer following the
# guide gets today. A nightly judging a branch must pass --distro.
log "installing Spinifex on each node ($([ -n "$DISTRO" ] && echo "$(basename "$DISTRO")" || echo "published release"))"
for host in "${HOSTS[@]}"; do
if [ -n "$DISTRO" ]; then
scp -i "$SSH_PRIVATE_KEY" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
-o LogLevel=ERROR -q "$DISTRO" "$SETUP_SH" "ubuntu@$host:/tmp/" \
|| die "could not copy the distro to $host"
fi
# cloud-init being finished does not mean apt is: the apt-daily timers and
# unattended-upgrades run on their own schedule and hold the dpkg lock, which
# the installer then fails on. Wait for the lock rather than fight it.
ssh_node "$host" "DISTRO_NAME='$(basename "${DISTRO:-}")' SETUP_NAME='$(basename "${SETUP_SH:-}")' CHANNEL='$CHANNEL' INSTALL_VERSION='$INSTALL_VERSION' bash -s" \
> "$STATE_DIR/install-$host.log" 2>&1 <<'REMOTE' \
|| die "install failed on $host; see $STATE_DIR/install-$host.log"
set -e
for _ in $(seq 1 120); do
sudo fuser /var/lib/dpkg/lock-frontend /var/lib/apt/lists/lock >/dev/null 2>&1 || break
sleep 5
done
sudo fuser /var/lib/dpkg/lock-frontend >/dev/null 2>&1 \
&& { echo "the dpkg lock was still held after 10 minutes"; exit 1; }
if [ -n "${DISTRO_NAME:-}" ]; then
# The production setup.sh against the ref's own tarball, which is what the
# release and the ISO both run, so there is no separate dev install layout.
# No INSTALL_SPINIFEX_SKIP_APT/SKIP_AWS here, unlike the CI hypervisors: an OCI
# stock image is not pre-baked, so the dependency stages have to run.
sudo env INSTALL_SPINIFEX_TARBALL="/tmp/$DISTRO_NAME" bash "/tmp/$SETUP_NAME"
else
# The real customer path, including the checksum step a local tarball skips.
# The environment variable rather than --channel: the installer served by
# install.mulgadc.com is the latest release's, so it predates the flag.
#
# A tag goes in as VERSION, not CHANNEL. Only the dev channel resolves through
# an unauthenticated GitHub API call, which is rate limited per source address
# and returns 404 once it trips -- indistinguishable from a missing asset. A
# tagged path is a plain redirect, so it does not have that failure mode.
if [ -n "$INSTALL_VERSION" ]; then
curl -sfL https://install.mulgadc.com | sudo env INSTALL_SPINIFEX_VERSION="$INSTALL_VERSION" bash
else
curl -sfL https://install.mulgadc.com | sudo env INSTALL_SPINIFEX_CHANNEL="$CHANNEL" bash
fi
fi
sudo /usr/local/share/spinifex/setup-ovn.sh --management --nat-uplink
REMOTE
INSTALLED_VERSION="$(ssh_node "$host" 'spx version' 2>&1 | head -1)"
log "$host installed: $INSTALLED_VERSION"
done
record install PASS "$INSTALLED_VERSION"
# A single node is its own documented path: install-node.sh refuses fewer than two
# hosts, because there is nothing to join. Both branches use the flags the guide
# names, so what is validated is what the guide tells a customer to type.
if [ "$NODES" = 1 ]; then
log "initializing the single node"
ssh_node "${HOSTS[0]}" '
set -e
sudo spx admin init --node "$(hostname -s)" --nodes 1 \
--region ap-southeast-2 --az ap-southeast-2a \
--external-mode=nat --ipsec=false
sudo systemctl start spinifex.target
' > "$STATE_DIR/form.log" 2>&1 \
|| die "single-node init failed; see $STATE_DIR/form.log"
else
log "forming the cluster"
"$REPO_ROOT/scripts/install-node.sh" \
--hosts-file "$STATE_DIR/hosts" \
--user ubuntu \
--identity "$SSH_PRIVATE_KEY" \
--external-mode nat \
--ipsec off \
--yes > "$STATE_DIR/form.log" 2>&1 \
|| die "formation failed; see $STATE_DIR/form.log"
fi
record formation PASS
# Runs after formation and before the pool is configured, which is the only window
# where a node has /etc/spinifex but has not yet started the allocator. Nothing in
# this repository knows what it does: an API-key deployment needs a credential on
# each node, and a credential belongs to the operator, not to a checked-in script.
# Skipped under instance principal, which needs no handoff at all.
if [ "$SKIP_POOL" != 1 ] && [ "$PRINCIPAL_MODE" = off ]; then
log "running the credential hook"
# Arguments, not a file: the hook is told where the nodes are and how to reach
# them, and decides for itself what to put there.
"$CREDENTIAL_HOOK" "$SSH_PRIVATE_KEY" "${HOSTS[@]}" > "$STATE_DIR/credential-hook.log" 2>&1 \
|| die "the credential hook failed; see $STATE_DIR/credential-hook.log"
log "credential hook ok"
fi
# The IMDS remap and the pool are both set-before-first-start, and the remap is the
# half that is invisible when missing: without it the cloud's metadata service is
# shadowed by Spinifex's own endpoints and the allocator cannot resolve its VNIC.
log "configuring the IMDS remap$([ "$SKIP_POOL" = 1 ] && echo " (no external pool)" || echo " and the external pool")"
for host in "${HOSTS[@]}"; do
ssh_node "$host" "SKIP_POOL=$SKIP_POOL bash -s" > "$STATE_DIR/pool-$host.log" 2>&1 <<'REMOTE' \
|| die "pool configuration failed on $host; see $STATE_DIR/pool-$host.log"
set -e
sudo cp /etc/spinifex/spinifex.toml /etc/spinifex/spinifex.toml.bak-prepool
# subn, not sub: with no [network] section to match, sub rewrites the file unchanged
# and exits 0, leaving the remap silently unapplied. That is the half of this that is
# invisible when missing, so the substitution count is checked rather than assumed.
grep -q imds_host_meta_ip /etc/spinifex/spinifex.toml || sudo python3 - <<"PY"
import re, sys
path = "/etc/spinifex/spinifex.toml"
text, n = re.subn(r"(?m)^\[network\]$",
"[network]\nimds_host_meta_ip = \"169.254.42.254\"\nimds_host_dns_ip = \"169.254.42.253\"",
open(path).read(), count=1)
if n != 1:
sys.exit("no [network] section in %s, so the IMDS remap was not applied" % path)
open(path, "w").write(text)
PY
if [ "${SKIP_POOL:-0}" != 1 ]; then
grep -q 'name = "oci-public"' /etc/spinifex/spinifex.toml \
|| sudo tee -a /etc/spinifex/spinifex.toml < /etc/spinifex/oci/external-pool.toml >/dev/null
fi
# spx has no config subcommand, so the `spx config validate` that used to be here
# could only ever fail, and was swallowed. Parsing the file is the check that was
# wanted: both edits above are textual, and a broken result would otherwise surface
# as every service failing to start with nothing saying why.
sudo python3 -c 'import sys, tomllib; tomllib.load(open(sys.argv[1], "rb"))' \
/etc/spinifex/spinifex.toml \
|| { echo "spinifex.toml is not valid TOML after the IMDS remap and the pool append"; exit 1; }
sudo systemctl restart spinifex.target
REMOTE
done
# Everything needed to tell the three allocator faults apart, and deliberately no
# credential: the config file and the PEM are reported as mode and size only, which
# distinguishes absent from unreadable without copying either into a log that is
# attached to a CI run.
allocator_diagnostics() {
local host="$1"
ssh_node "$host" '
echo "=== spinifex-daemon: the allocator (ocinet) lives here ==="
sudo journalctl -u spinifex-daemon --since -20min --no-pager | grep -i "ocinet\|external VNIC\|allocator\|oci" | tail -40
echo "=== spinifex-vpcd: consumes the addresses, does not allocate them ==="
sudo journalctl -u spinifex-vpcd --since -20min --no-pager | tail -40
echo "=== external_pools as configured ==="
sudo sed -n "/\[\[network.external_pools\]\]/,\$p" /etc/spinifex/spinifex.toml
echo "=== credential files (mode and size, never content) ==="
sudo stat -c "%n %A %s bytes owner=%U:%G" \
/etc/spinifex/oci/config /etc/spinifex/oci/oci_api_key.pem 2>&1
echo "=== the bridge MAC the allocator matches on ==="
ip -br link show
echo "=== MACs IMDS reports for this instance VNICs ==="
curl -sf -H "Authorization: Bearer Oracle" \
http://169.254.169.254/opc/v2/vnics/ | tr "," "\n" | grep -i "macAddr\|privateIp" || \
echo "IMDS unreachable -- check the remap, Spinifex claims 169.254.169.254 itself"
' 2>&1
}
# credential authorised to allocate is the line that proves the credential is
# accepted, in the right compartment, and holds every permission an allocation
# needs. A node missing it accepts allocate-address and then fails it.
#
# "resolved the external VNIC" is not enough and was what this used to wait for.
# That line comes from the metadata service and needs no IAM policy at all, so a
# node with an unauthorised credential passed here and failed hours later inside
# a workbook, reported as capacity.
ALLOCATOR_READY='ocinet credential authorised to allocate'
ALLOCATOR_DENIED='ocinet credential cannot allocate an external address'
if [ "$SKIP_POOL" = 1 ]; then
log "--no-external-pool: no allocator to check"
record "oci allocator" SKIPPED "no external pool configured"
else
log "checking the allocator came up"
for host in "${HOSTS[@]}"; do
found=0
for _ in $(seq 1 30); do
# spinifex-daemon, not spinifex-vpcd: the allocator is built in the
# daemon, and vpcd only consumes the addresses it hands out. Gating on
# vpcd's journal failed a node whose allocator was working.
if ssh_node "$host" "sudo journalctl -u spinifex-daemon --since -10min --no-pager | grep -q '$ALLOCATOR_READY'" 2>/dev/null; then
found=1
break
fi
# A refusal is final, so waiting out the remaining five minutes only
# delays a verdict the node has already reached.
if denial=$(ssh_node "$host" "sudo journalctl -u spinifex-daemon --since -10min --no-pager | grep -m1 '$ALLOCATOR_DENIED'" 2>/dev/null) && [ -n "$denial" ]; then
allocator_diagnostics "$host" > "$STATE_DIR/allocator-$host.log" 2>&1 || true
die "$host refused its own OCI credential: ${denial#*$ALLOCATOR_DENIED}. Diagnostics: $STATE_DIR/allocator-$host.log"
fi
sleep 10
done
if [ "$found" != 1 ]; then
# Before the teardown, because the teardown is what destroys the only
# copy. Three unrelated faults land here -- a credential the SDK would
# not load, a bridge MAC matching no VNIC, and a pool vpcd never read --
# and they are indistinguishable from the missing log line alone.
allocator_diagnostics "$host" > "$STATE_DIR/allocator-$host.log" 2>&1 || true
die "$host never logged '$ALLOCATOR_READY'; the OCI allocator is not up, so no guest can get a public address. Diagnostics: $STATE_DIR/allocator-$host.log"
fi
log "$host allocator ready"
done
record "oci allocator" PASS
fi
# Parse the STATUS column, never grep for Ready: NotReady contains it, and that
# read passed a single-node run whose only node was NotReady on 0.0.0.0. The table
# is colourised, so the escapes come off first.
count_ready() {
sed 's/\x1b\[[0-9;]*m//g' "$STATE_DIR/nodes.txt" \
| awk -F'|' 'NR > 1 { gsub(/^[ \t]+|[ \t]+$/, "", $2); if ($2 == "Ready") n++ } END { print n + 0 }'
}
log "cluster membership"
ready=0
for _ in $(seq 1 30); do
ssh_node "${HOSTS[0]}" 'sudo spx get nodes' > "$STATE_DIR/nodes.txt" 2>&1 || true
ready="$(count_ready)"
[ "$ready" = "$NODES" ] && break
sleep 10
done
cat "$STATE_DIR/nodes.txt"
[ "$ready" = "$NODES" ] || die "expected $NODES Ready node(s), got $ready; see $STATE_DIR/nodes.txt"
record membership PASS "$ready Ready"
if [ "$SKIP_WORKLOAD" = 1 ]; then
log "--skip-workload: stopping before the workbook"
record workbooks SKIPPED "--skip-workload"
exit 0
fi
# The same driver the GitHub nightly runs (cell 17), on the same published
# workbooks, so a workbook that passes on a hypervisor and fails on OCI is a
# difference in OCI and not in the test. It owns its own per-workbook assertions
# and destroys each one it builds.
# The suite used to run with stdout redirected to a file, so a run wedged inside a
# workbook printed nothing at all until it finished. Streamed now, and each of the
# driver's own RUN/PASS/FAIL lines is stamped with elapsed seconds and the workbook.
stream_workbooks() {
local start=$SECONDS line current="(starting)"
while IFS= read -r line; do
case "$line" in
'=== RUN'*)
current="${line##*RUN }"
printf '[validate-%s] %5ds workbook START %s\n' "$TOPOLOGY" "$((SECONDS - start))" "$current" ;;
'--- PASS'*|'--- FAIL'*|'--- SKIP'*)
printf '[validate-%s] %5ds workbook %s\n' "$TOPOLOGY" "$((SECONDS - start))" "$line" ;;
*)
printf '%s\n' "$line" ;;
esac
done
}
log "running the published workbooks"
DRIVER="$REPO_ROOT/tests/e2e/run-tofu-examples-e2e.sh"
[ -r "$DRIVER" ] || die "no workbook driver at $DRIVER"
scp -i "$SSH_PRIVATE_KEY" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
-o LogLevel=ERROR -q "$DRIVER" "ubuntu@${HOSTS[0]}:~/run-tofu-examples-e2e.sh" \
|| die "could not copy the workbook driver"
scp -i "$SSH_PRIVATE_KEY" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
-o LogLevel=ERROR -qr "$REPO_ROOT/docs/terraform-workbooks" "ubuntu@${HOSTS[0]}:~/workbooks" \
|| die "could not copy the workbooks"
# Every AMI the workbooks need, imported one at a time so predastore is not asked
# to absorb parallel uploads. Three are appliances rather than distros: RDS, ECS
# and EKS each boot their own, found by a spinifex:managed-by tag and never by name.
log "importing the images the workbooks need"
ssh_node "${HOSTS[0]}" '
set -e
for img in ubuntu-26.04-x86_64 spinifex-rds-postgres spinifex-ecs-node spinifex-eks-node; do
sudo spx admin images import --name "$img" --config /etc/spinifex/spinifex.toml >/dev/null
done
' > "$STATE_DIR/images.log" 2>&1 || die "image import failed; see $STATE_DIR/images.log"
# Every workbook that is a workbook. The shared driver's own default is five, which
# leaves ECS and all three EKS variants untested on every platform -- their
# assertions exist and nothing was running them.
#
# demo-app is absent because it is not a workbook: it has no .tf at all, being the
# container image the EKS workbooks' nested workloads/ modules deploy. Listing it
# here would fail on a missing root module rather than test anything.
OCI_WORKBOOKS="nginx-alb bastion-private-subnet nginx-webserver s3-webapp rds-quickstart"
OCI_WORKBOOKS="$OCI_WORKBOOKS ecs-quickstart eks-quickstart eks-https-ingress eks-gitops-argocd"
[ "$WORKBOOKS_SET" = 1 ] || WORKBOOKS="$OCI_WORKBOOKS"
workbook_env="WORKBOOK_DIR=\$HOME/workbooks $(printf 'WORKBOOKS=%q' "$WORKBOOKS")"
# Whether a node can reach a public address inside its own VCN. Informational: the
# remedy below is right either way, because a customer reaches a guest from outside.
#
# Needs two hosts. With one, HOSTS[0] and HOSTS[-1] are the same address and the node
# connects to itself, which always succeeds and measures nothing — it reported
# "present" on vm-single and bm for exactly that reason before this guard.
#
# A negative result is only interpretable when the security list admits the source,
# so the record says which it was: narrowing node_client_cidr_allow_list makes a
# node's own public address a non-permitted source, and the refusal that follows
# looks identical to a VCN that does not hairpin.
if [ "${#HOSTS[@]}" -lt 2 ]; then
record "vcn hairpin" SKIPPED "needs two nodes; one node can only probe itself"
elif ssh_node "${HOSTS[0]}" \
"bash -c 'exec 3<>/dev/tcp/${HOSTS[${#HOSTS[@]}-1]}/22' 2>/dev/null"; then
record "vcn hairpin" INFO "present: a node reached another node's public address"
else
record "vcn hairpin" INFO "not reached, which is a VCN without hairpin or a security list that excludes the source"
fi
# Remote dynamic forward: the node gets a SOCKS5 proxy on this port whose egress
# is this host, outside the VCN. It gives the driver the customer's vantage point
# for public addresses without moving the driver off the node, where its ovn-nbctl,
# journalctl and spx diagnostics have to run. The subnet's own allow list does not
# need a new entry: ssh from here already works, and its rules are per-CIDR for
# every protocol.
PUBLIC_PROXY_PORT="${PUBLIC_PROXY_PORT:-1080}"
workbook_env="$workbook_env E2E_PUBLIC_PROXY=127.0.0.1:$PUBLIC_PROXY_PORT"
# ExitOnForwardFailure, so a port already in use stops the run here instead of
# handing the driver a proxy that is not there. Keepalives because this one
# connection carries the proxy for the whole suite, and a dropped forward would
# read as every remaining public address going dark at once.
#
# if !, not a $? read after the fact: under set -e a failing ssh never reaches the
# next line, which is the one that prints the driver's own diagnostics.
if ssh -i "$SSH_PRIVATE_KEY" -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
-o LogLevel=ERROR -o BatchMode=yes -o ConnectTimeout=15 \
-o ServerAliveInterval=30 -o ServerAliveCountMax=6 \
-o ExitOnForwardFailure=yes -R "$PUBLIC_PROXY_PORT" "ubuntu@${HOSTS[0]}" \
"chmod +x ~/run-tofu-examples-e2e.sh; $workbook_env ~/run-tofu-examples-e2e.sh" \
2>&1 | tee "$STATE_DIR/workbooks.log" | stream_workbooks; then
record_workbooks "$STATE_DIR/workbooks.log"
passed="$(grep -c '^--- PASS' "$STATE_DIR/workbooks.log" || true)"
ran="$(grep -c '^=== RUN' "$STATE_DIR/workbooks.log" || true)"
record workbooks PASS "$passed of $ran"
else
# Before die, so a failed suite still gets its per-workbook table: which four
# passed is most of what the fifth failing means.
record_workbooks "$STATE_DIR/workbooks.log"
failed="$(grep -c '^--- FAIL' "$STATE_DIR/workbooks.log" || true)"
record workbooks FAIL "$failed failed, see $STATE_DIR/workbooks.log"
capture_journals
tail -60 "$STATE_DIR/workbooks.log"
grep -E '^--- (PASS|FAIL)' "$STATE_DIR/workbooks.log" | sed 's/^/ /' || true
die "the published workbooks failed on $TOPOLOGY; see $STATE_DIR/workbooks.log"
fi
+35 -4
View File
@@ -1,17 +1,34 @@
variable "tenancy_ocid" { type = string }
variable "user_ocid" { type = string }
variable "fingerprint" { type = string }
variable "private_key_path" { type = string }
variable "private_key_path" {
type = string
default = ""
}
# The PEM itself, for a CI runner that holds it as a secret in the environment.
# Exactly one of this and private_key_path is set; the check lives here so a run
# fails on the input rather than on an OCI 401 forty seconds later.
variable "private_key" {
type = string
default = ""
sensitive = true
validation {
condition = (var.private_key == "") != (var.private_key_path == "")
error_message = "Set exactly one of private_key (the PEM, for CI) or private_key_path (a file, for a workstation)."
}
}
# OCI's own region name. Spinifex's region is a separate setting that follows AWS
# naming, so ap-sydney-1 here beside ap-southeast-2 in spinifex.toml is correct.
variable "region" {
type = string
default = "ap-sydney-1"
}
# Unused. Kept declared because scripts/oci_env.py discovers and exports it, and
# there are no identity resources left that need the home-region endpoint.
# OCI serves IAM writes from the tenancy home region, which is not necessarily
# where we build: the dynamic group and policy in instance-principal.tf go through
# the oci.home provider. Unset means the two are the same region.
variable "home_region" {
description = "Unused. The tenancy home region, exported by the Python environment tool."
description = "Tenancy home region, for IAM writes. Defaults to var.region; set OCI_HOME_REGION when they differ."
type = string
default = null
nullable = true
@@ -37,6 +54,20 @@ variable "deployment_name" {
error_message = "deployment_name must be 1-15 lowercase alphanumeric characters starting with a letter, because it is used as the VCN dns_label."
}
}
# Three values rather than a bool, because using an instance principal and being
# allowed to create one are different rights. See instance-principal.tf for what
# the policy grants and why it is narrow.
variable "instance_principal" {
description = "off: nodes use an API key. adopt: nodes authenticate as themselves against a dynamic group and policy that already exist. create: also create those two, which needs a tenancy-admin principal."
type = string
default = "off"
validation {
condition = contains(["off", "adopt", "create"], var.instance_principal)
error_message = "instance_principal must be off, adopt or create."
}
}
variable "vcn_cidr" {
description = "VCN address space. A /22 leaves room for the per-node secondary private IPs Spinifex allocates for external addresses."
type = string
+4 -6
View File
@@ -5,6 +5,7 @@ import (
"errors"
"fmt"
"log/slog"
"slices"
"sort"
"time"
)
@@ -270,12 +271,9 @@ func (e *Engine) drainPoll() time.Duration {
}
func containsResource(resources []Resource, want Resource) bool {
for _, resource := range resources {
if resource.Kind == want.Kind && resource.ID == want.ID {
return true
}
}
return false
return slices.ContainsFunc(resources, func(resource Resource) bool {
return resource.Kind == want.Kind && resource.ID == want.ID
})
}
// SortReapers orders reapers by stage while preserving the caller's order
@@ -96,6 +96,7 @@ type IAMAccounts struct{ svc handlers_iam.IAMService }
var _ AccountStore = (*IAMAccounts)(nil)
// NewIAMAccounts wraps svc as the engine's AccountStore.
func NewIAMAccounts(svc handlers_iam.IAMService) *IAMAccounts {
return &IAMAccounts{svc: svc}
}
+4 -6
View File
@@ -3,6 +3,7 @@ package accountteardown
import (
"context"
"fmt"
"slices"
"strings"
"time"
@@ -664,12 +665,9 @@ func isAlreadyGone(err error) bool {
return true
}
message := err.Error()
for _, marker := range notFoundMarkers {
if strings.Contains(message, marker) {
return true
}
}
return false
return slices.ContainsFunc(notFoundMarkers, func(marker string) bool {
return strings.Contains(message, marker)
})
}
// isNotAssociated reports the benign case of disassociating something that was
+124 -30
View File
@@ -1,3 +1,6 @@
// Package admin holds the node administration helpers behind spx admin: config
// and certificate generation, credential bootstrap, image import and host
// network discovery.
package admin
import (
@@ -19,6 +22,7 @@ import (
"regexp"
"strconv"
"strings"
"testing"
"text/template"
"time"
@@ -42,6 +46,8 @@ type RemoteNode struct {
NorthstarConfigPath string
}
// ConfigSettings is the template data rendered into every generated config file
// (spinifex.toml, predastore.toml, NATS and the rest) by GenerateConfigFile.
type ConfigSettings struct {
AccessKey string
SecretKey string
@@ -173,12 +179,16 @@ type PredastoreNodeConfig struct {
Host string
}
// ConfigFile names one config file to render: Template is the text/template
// source and Path the destination it is written to.
type ConfigFile struct {
Name string
Path string
Template string
}
// GenerateConfigFiles renders each of configs with configSettings, printing a
// line per file and stopping at the first failure.
func GenerateConfigFiles(configs []ConfigFile, configSettings ConfigSettings) error {
for _, cfg := range configs {
if err := GenerateConfigFile(cfg.Path, cfg.Template, configSettings); err != nil {
@@ -309,6 +319,8 @@ func TenantCACertPath(configDir string) string {
return filepath.Join(configDir, tenantCACertFilename)
}
// TenantCAKeyPath returns the tenant private CA key path under configDir; see
// TenantCACertPath.
func TenantCAKeyPath(configDir string) string {
return filepath.Join(configDir, tenantCAKeyFilename)
}
@@ -341,6 +353,8 @@ func PredastoreDataDir(spxRoot string) string {
return filepath.Join(spxRoot, "predastore", "cluster")
}
// CreateServiceDirectories creates the per-service directories under spxRoot.
// A directory that cannot be created is warned about on stderr, not returned.
func CreateServiceDirectories(spxRoot string) {
dirs := []string{
filepath.Join(spxRoot, "images"),
@@ -367,6 +381,8 @@ func CreateServiceDirectories(spxRoot string) {
fmt.Printf("✅ Directory structure created in %s\n", spxRoot)
}
// FileExists reports whether path can be stat'd. Any stat error, including
// permission denied, reads as false.
func FileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
@@ -516,33 +532,111 @@ func SetServiceOwnership() error {
return ownershipErr
}
// awsINIOptions keeps AWS nested settings (an empty key followed by indented
// lines) intact across a rewrite; without it they are flattened into the
// enclosing section and lost.
var awsINIOptions = ini.LoadOptions{AllowNestedValues: true}
// UpdateAWSINIFile updates or creates an AWS INI file section with the given key-value pairs.
func UpdateAWSINIFile(path, section string, values map[string]string) error {
var cfg *ini.File
var err error
if FileExists(path) {
cfg, err = ini.Load(path)
if err != nil {
return fmt.Errorf("failed to load INI file: %w", err)
}
} else {
cfg = ini.Empty()
}
sec, err := cfg.NewSection(section)
cfg, err := loadAWSINIFile(path)
if err != nil {
// Section already exists, get it.
sec, err = cfg.GetSection(section)
if err != nil {
return fmt.Errorf("failed to get section: %w", err)
}
return err
}
sec, err := awsINISection(cfg, section)
if err != nil {
return err
}
for key, value := range values {
sec.Key(key).SetValue(value)
}
return saveAWSINIFile(cfg, path)
}
// WriteAWSProfileConfig points an AWS CLI profile at this cluster. One profile
// endpoint_url cannot send S3 to the predastore gate and everything else to the
// gateway, so S3 goes in the profile's services section, which takes precedence.
func WriteAWSProfileConfig(path, profileName, region, certPath, endpointHost string) error {
cfg, err := loadAWSINIFile(path)
if err != nil {
return err
}
profileSection := profileName
if profileName != "default" {
profileSection = "profile " + profileName
}
profile, err := awsINISection(cfg, profileSection)
if err != nil {
return err
}
for key, value := range map[string]string{
"region": region,
"endpoint_url": "https://" + net.JoinHostPort(endpointHost, "9999"),
"ca_bundle": certPath,
"output": "json",
"services": profileName,
} {
profile.Key(key).SetValue(value)
}
services, err := awsINISection(cfg, "services "+profileName)
if err != nil {
return err
}
s3Endpoint := "https://" + net.JoinHostPort(endpointHost, strconv.Itoa(predastoreGatePort))
if err := setNestedAWSSetting(services, "s3", "endpoint_url", s3Endpoint); err != nil {
return err
}
return saveAWSINIFile(cfg, path)
}
// setNestedAWSSetting sets name inside the nested block under key, keeping any
// other settings an operator nested there.
func setNestedAWSSetting(sec *ini.Section, key, name, value string) error {
var lines []string
if sec.HasKey(key) {
for _, line := range sec.Key(key).NestedValues() {
if setting, _, _ := strings.Cut(line, "="); strings.TrimSpace(setting) != name {
lines = append(lines, line)
}
}
sec.DeleteKey(key)
}
nested, err := sec.NewKey(key, "")
if err != nil {
return fmt.Errorf("failed to create key %s: %w", key, err)
}
for _, line := range append(lines, name+" = "+value) {
if err := nested.AddNestedValue(line); err != nil {
return fmt.Errorf("failed to set %s.%s: %w", key, name, err)
}
}
return nil
}
func loadAWSINIFile(path string) (*ini.File, error) {
if !FileExists(path) {
return ini.Empty(awsINIOptions), nil
}
cfg, err := ini.LoadSources(awsINIOptions, path)
if err != nil {
return nil, fmt.Errorf("failed to load INI file: %w", err)
}
return cfg, nil
}
func awsINISection(cfg *ini.File, name string) (*ini.Section, error) {
sec, err := cfg.NewSection(name)
if err != nil {
// Section already exists, get it.
sec, err = cfg.GetSection(name)
if err != nil {
return nil, fmt.Errorf("failed to get section: %w", err)
}
}
return sec, nil
}
func saveAWSINIFile(cfg *ini.File, path string) error {
// Write atomically: ini.SaveTo uses os.Create (world-readable); render to a
// sibling temp file (0600) and rename to avoid briefly exposing secrets.
dir := filepath.Dir(path)
@@ -647,6 +741,16 @@ func GenerateNATSToken() (string, error) {
// keeps the 4096-bit default.
var certKeyBits = 4096
// ShortenCertKeyBitsForTest generates certificate keys at bits for the rest of
// one test and restores the previous size afterwards. There is one size per
// process, so the test must not run in parallel with anything generating keys.
func ShortenCertKeyBitsForTest(tb testing.TB, bits int) {
tb.Helper()
prev := certKeyBits
certKeyBits = bits
tb.Cleanup(func() { certKeyBits = prev })
}
// GenerateCACert generates a Certificate Authority certificate and key.
func GenerateCACert(caCertPath, caKeyPath string) error {
caPrivateKey, err := rsa.GenerateKey(rand.Reader, certKeyBits)
@@ -936,22 +1040,12 @@ func SetupAWSCredentials(accessKey, secretKey, region, certPath, bindIP string)
}
}
configSection := profileName
if profileName != "default" {
configSection = "profile " + profileName
}
endpointHost := bindIP
if endpointHost == "" || endpointHost == "0.0.0.0" {
endpointHost = "localhost"
}
if err := UpdateAWSINIFile(configPath, configSection, map[string]string{
"region": region,
"endpoint_url": "https://" + net.JoinHostPort(endpointHost, "9999"),
"ca_bundle": certPath,
"output": "json",
}); err != nil {
if err := WriteAWSProfileConfig(configPath, profileName, region, certPath, endpointHost); err != nil {
return err
}
+77 -3
View File
@@ -11,6 +11,7 @@ import (
"strings"
"testing"
awsconfig "github.com/aws/aws-sdk-go-v2/config"
"github.com/mulgadc/spinifex/spinifex/config"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -234,6 +235,79 @@ func TestUpdateAWSINIFile_AddNewSection(t *testing.T) {
assert.Contains(t, content, "spinifex-val")
}
// --- WriteAWSProfileConfig ---
// resolveProfileEndpoint resolves sdkID's endpoint through the Go SDK's own
// shared-config loader, the way a client does: the service's entry in the
// profile's services section, else the profile-wide endpoint_url.
func resolveProfileEndpoint(t *testing.T, configPath, profile, sdkID string) string {
t.Helper()
shared, err := awsconfig.LoadSharedConfigProfile(t.Context(), profile, func(o *awsconfig.LoadSharedConfigOptions) {
o.ConfigFiles = []string{configPath}
o.CredentialsFiles = []string{}
})
require.NoError(t, err)
endpoint, found, err := shared.GetServiceBaseEndpoint(t.Context(), sdkID)
require.NoError(t, err)
if found {
return endpoint
}
return shared.BaseEndpoint
}
func TestWriteAWSProfileConfig_SendsS3ToPredastoreAndTheRestToTheGateway(t *testing.T) {
path := filepath.Join(t.TempDir(), "config")
require.NoError(t, WriteAWSProfileConfig(path, "spinifex", "ap-southeast-2", "/ca.pem", "10.11.12.1"))
assert.Equal(t, "https://10.11.12.1:8443", resolveProfileEndpoint(t, path, "spinifex", "S3"))
assert.Equal(t, "https://10.11.12.1:9999", resolveProfileEndpoint(t, path, "spinifex", "EC2"))
assert.Equal(t, "https://10.11.12.1:9999", resolveProfileEndpoint(t, path, "spinifex", "STS"))
}
// A rewrite must not flatten nested settings an operator added, which is how a
// hand-written s3 block used to be lost on every re-init.
func TestWriteAWSProfileConfig_KeepsNestedSettingsAcrossARewrite(t *testing.T) {
path := filepath.Join(t.TempDir(), "config")
require.NoError(t, os.WriteFile(path, []byte(`[profile spinifex]
s3 =
max_concurrent_requests = 20
services = spinifex
[services spinifex]
s3 =
addressing_style = path
endpoint_url = https://stale:8443
`), 0600))
require.NoError(t, WriteAWSProfileConfig(path, "spinifex", "ap-southeast-2", "/ca.pem", "localhost"))
require.NoError(t, WriteAWSProfileConfig(path, "spinifex", "ap-southeast-2", "/ca.pem", "localhost"))
assert.Equal(t, "https://localhost:8443", resolveProfileEndpoint(t, path, "spinifex", "S3"))
shared, err := awsconfig.LoadSharedConfigProfile(t.Context(), "spinifex", func(o *awsconfig.LoadSharedConfigOptions) {
o.ConfigFiles = []string{path}
o.CredentialsFiles = []string{}
})
require.NoError(t, err)
assert.Equal(t, map[string]string{"addressing_style": "path", "endpoint_url": "https://localhost:8443"},
shared.Services.ServiceValues["s3"])
data, err := os.ReadFile(path)
require.NoError(t, err)
assert.Contains(t, string(data), "max_concurrent_requests = 20")
assert.NotContains(t, string(data), "stale")
}
// Account profiles each get their own services section, so a tenant profile
// resolves S3 the same way as the operator's without sharing its section.
func TestWriteAWSProfileConfig_ProfilesDoNotShareAServicesSection(t *testing.T) {
path := filepath.Join(t.TempDir(), "config")
require.NoError(t, WriteAWSProfileConfig(path, "spinifex", "ap-southeast-2", "/ca.pem", "localhost"))
require.NoError(t, WriteAWSProfileConfig(path, "spinifex-acme", "ap-southeast-2", "/ca.pem", "10.0.0.5"))
assert.Equal(t, "https://localhost:8443", resolveProfileEndpoint(t, path, "spinifex", "S3"))
assert.Equal(t, "https://10.0.0.5:8443", resolveProfileEndpoint(t, path, "spinifex-acme", "S3"))
}
// --- SetupAWSCredentials ---
func TestSetupAWSCredentials_CreatesFiles(t *testing.T) {
@@ -279,9 +353,9 @@ func TestSetupAWSCredentials_UsesBindIP(t *testing.T) {
err := SetupAWSCredentials("AKIATEST123", "secret123", "us-east-1", "/ca.pem", "10.11.12.1")
require.NoError(t, err)
configData, _ := os.ReadFile(filepath.Join(dir, ".aws", "config"))
assert.Contains(t, string(configData), "https://10.11.12.1:9999")
assert.NotContains(t, string(configData), "localhost")
configPath := filepath.Join(dir, ".aws", "config")
assert.Equal(t, "https://10.11.12.1:9999", resolveProfileEndpoint(t, configPath, "spinifex", "EC2"))
assert.Equal(t, "https://10.11.12.1:8443", resolveProfileEndpoint(t, configPath, "spinifex", "S3"))
}
func TestSetupAWSCredentials_FallsBackToLocalhostForWildcard(t *testing.T) {
+3 -2
View File
@@ -162,8 +162,9 @@ var charonRereadCAs = func() error {
return nil
}
// Exported so runtime services that mint from the cluster CA share one loader
// rather than each re-implementing the PEM/PKCS8 handling.
// LoadCAKeyPair reads the cluster CA certificate and its PKCS8 RSA key from PEM
// files. Exported so runtime services that mint from the cluster CA share one
// loader rather than each re-implementing the PEM/PKCS8 handling.
func LoadCAKeyPair(caCertPath, caKeyPath string) (*x509.Certificate, *rsa.PrivateKey, error) {
certPEM, err := os.ReadFile(caCertPath)
if err != nil {
+4 -6
View File
@@ -4,6 +4,7 @@ import (
"fmt"
"net"
"os/exec"
"slices"
"strings"
"github.com/mulgadc/bluebottle/pkg/safecast"
@@ -190,10 +191,7 @@ func isVirtualInterface(name string, defaultRouteDev string) bool {
"docker", "br-", "veth", "ovs", "virbr",
"tun", "tap", "tailscale", "wg", "lo",
}
for _, p := range prefixes {
if strings.HasPrefix(name, p) {
return true
}
}
return false
return slices.ContainsFunc(prefixes, func(p string) bool {
return strings.HasPrefix(name, p)
})
}
+3 -3
View File
@@ -23,11 +23,11 @@ func FormatACMCertificate(region, accountID, id string) string {
// The id keeps everything after the first "/". Truncating at the last one would
// let a grant on certificate/<a> authorize a request naming certificate/<a>/<b>.
func ParseACMCertificateID(certARN string) (string, bool) {
parts := strings.SplitN(certARN, ":", 6)
if len(parts) != 6 || parts[0] != "arn" || parts[2] != "acm" {
service, _, _, resource, ok := Split(certARN)
if !ok || service != "acm" {
return "", false
}
kind, id, found := strings.Cut(parts[5], "/")
kind, id, found := strings.Cut(resource, "/")
if !found || ACMResourceType(kind) != ACMCertificate || id == "" {
return "", false
}
+14
View File
@@ -0,0 +1,14 @@
package arn
import "strings"
// Split breaks an arn:aws ARN into its service, region, account and resource
// components. The resource keeps any further colons or slashes verbatim. ok is
// false for anything outside the aws partition, the only one this stack builds.
func Split(s string) (service, region, account, resource string, ok bool) {
parts := strings.SplitN(s, ":", 6)
if len(parts) != 6 || parts[0] != "arn" || parts[1] != "aws" {
return "", "", "", "", false
}
return parts[2], parts[3], parts[4], parts[5], true
}
+81
View File
@@ -0,0 +1,81 @@
package arn_test
import (
"testing"
"github.com/mulgadc/spinifex/spinifex/arn"
"github.com/stretchr/testify/assert"
)
func TestSplit(t *testing.T) {
tests := []struct {
name string
value string
service string
region string
account string
resource string
wantOK bool
}{
{
name: "regional ARN",
value: "arn:aws:ecs:ap-southeast-2:123456789012:cluster/web",
service: "ecs",
region: "ap-southeast-2",
account: "123456789012",
resource: "cluster/web",
wantOK: true,
},
{
name: "global ARN has an empty region",
value: "arn:aws:iam::123456789012:root",
service: "iam",
account: "123456789012",
resource: "root",
wantOK: true,
},
{
name: "resource keeps its own colons",
value: "arn:aws:rds:ap-southeast-2:123456789012:snapshot:rds:orders-2026-09-30",
service: "rds",
region: "ap-southeast-2",
account: "123456789012",
resource: "snapshot:rds:orders-2026-09-30",
wantOK: true,
},
{
name: "empty resource",
value: "arn:aws:ecs:ap-southeast-2:123456789012:",
service: "ecs",
region: "ap-southeast-2",
account: "123456789012",
wantOK: true,
},
{
name: "another partition",
value: "arn:aws-cn:ecs:cn-north-1:123456789012:cluster/web",
},
{
name: "not an ARN",
value: "urn:aws:ecs:ap-southeast-2:123456789012:cluster/web",
},
{
name: "too few components",
value: "arn:aws:ecs:ap-southeast-2:cluster/web",
},
{
name: "empty",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
service, region, account, resource, ok := arn.Split(tt.value)
assert.Equal(t, tt.wantOK, ok)
assert.Equal(t, tt.service, service)
assert.Equal(t, tt.region, region)
assert.Equal(t, tt.account, account)
assert.Equal(t, tt.resource, resource)
})
}
}
+4 -4
View File
@@ -70,18 +70,18 @@ type ParsedELBv2 struct {
// arn:aws:elasticloadbalancing ARN naming one of the four resource types: an
// ARN this stack never builds has no resource it can correctly stand for.
func ParseELBv2(value string) (ParsedELBv2, bool) {
parts := strings.SplitN(value, ":", 6)
if len(parts) != 6 || parts[0] != "arn" || parts[1] != "aws" || parts[2] != "elasticloadbalancing" {
service, region, account, rest, ok := Split(value)
if !ok || service != "elasticloadbalancing" {
return ParsedELBv2{}, false
}
rawKind, resource, found := strings.Cut(parts[5], "/")
rawKind, resource, found := strings.Cut(rest, "/")
if !found || rawKind == "" {
return ParsedELBv2{}, false
}
kind := ELBv2ResourceType(rawKind)
switch kind {
case ELBv2LoadBalancer, ELBv2TargetGroup, ELBv2Listener, ELBv2ListenerRule:
return ParsedELBv2{Region: parts[3], AccountID: parts[4], Kind: kind, Resource: resource}, true
return ParsedELBv2{Region: region, AccountID: account, Kind: kind, Resource: resource}, true
default:
return ParsedELBv2{}, false
}
+5
View File
@@ -1,3 +1,5 @@
// Package awsec2query decodes AWS EC2 Query-protocol request parameters into
// aws-sdk-go input structs.
package awsec2query
import (
@@ -14,6 +16,9 @@ import (
// Callers should map this to AWS's MalformedQueryString error code.
var ErrSliceTooLarge = errors.New("list parameter exceeds maximum entries")
// QueryParamsToStruct fills the struct out points to from flattened EC2 Query
// params, matching each field by name or locationName tag and recursing into
// dotted prefixes for nested structs and lists. out must be a struct pointer.
func QueryParamsToStruct(params map[string]string, out any) error {
v := reflect.ValueOf(out)
+151
View File
@@ -0,0 +1,151 @@
package awsec2query
import (
"fmt"
"net/url"
"reflect"
"testing"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/aws/aws-sdk-go/service/elbv2"
"github.com/aws/aws-sdk-go/service/rds"
)
// Input shapes covering each list serialisation: EC2 Prefix.N, ELBv2
// Prefix.member.N, RDS locationNameList, and deep nesting under a struct.
var fuzzTargets = []func() any{
func() any { return &ec2.DescribeInstancesInput{} },
func() any { return &ec2.RunInstancesInput{} },
func() any { return &ec2.CreateLaunchTemplateInput{} },
func() any { return &elbv2.RegisterTargetsInput{} },
func() any { return &elbv2.CreateListenerInput{} },
func() any { return &rds.CreateDBInstanceInput{} },
}
// queryParams builds the params map the way the gateway does from a request
// body: url.ParseQuery, keeping the last value of a repeated key.
func queryParams(body string) (map[string]string, bool) {
values, err := url.ParseQuery(body)
if err != nil {
return nil, false
}
params := make(map[string]string, len(values))
for key, vs := range values {
params[key] = vs[len(vs)-1]
}
return params, true
}
// countSliceElems tallies materialised slice elements by nesting depth,
// failing on any slice past MaxSliceLen.
func countSliceElems(t *testing.T, v reflect.Value, depth int, counts map[int]int) {
switch v.Kind() {
case reflect.Pointer, reflect.Interface:
if !v.IsNil() {
countSliceElems(t, v.Elem(), depth, counts)
}
case reflect.Struct:
for i := range v.NumField() {
if v.Type().Field(i).IsExported() {
countSliceElems(t, v.Field(i), depth, counts)
}
}
case reflect.Slice:
if v.Type().Elem().Kind() == reflect.Uint8 {
return
}
if v.Len() > MaxSliceLen {
t.Fatalf("slice of %d entries exceeds MaxSliceLen %d", v.Len(), MaxSliceLen)
}
counts[depth] += v.Len()
for i := range v.Len() {
countSliceElems(t, v.Index(i), depth+1, counts)
}
}
}
// FuzzQueryParamsToStruct pins the properties the parser owes untrusted input:
// it never panics, and on success every list level holds no more entries than
// the request had parameters, so allocation is bounded by the input.
func FuzzQueryParamsToStruct(f *testing.F) {
for _, seed := range []string{
"Filter.1.Name=instance-type&Filter.1.Value.1=t2.micro&Filter.3.Name=x&Filter.3.Value.1=y",
"Filter.2.Name=skipped&Filter.2.Value.1=x",
"Filter.999999.Name=ignored",
"Filter.01.Name=a&Filter.+1.Value.1=b&Filter.-1.Name=c",
"Filter.1.Value.item.1=a&Filter.1.Value.2=b",
"InstanceId.1=i-1&InstanceId.2=i-2&MaxResults=5&DryRun=true",
"MaxResults=99999999999999999999&DryRun=maybe",
"ImageId=ami-1&MinCount=1&MaxCount=1&UserData=aGVsbG8=&BlockDeviceMapping.1.DeviceName=/dev/sda1&BlockDeviceMapping.1.Ebs.VolumeSize=20",
"TagSpecification.1.ResourceType=instance&TagSpecification.1.Tag.1.Key=k&TagSpecification.1.Tag.1.Value=v",
"LaunchTemplateData.NetworkInterface.1.PrivateIpAddresses.1.PrivateIpAddress=10.0.0.1&LaunchTemplateData.InstanceMarketOptions.SpotOptions.ValidUntil=2026-07-10T12:00:00Z",
"TargetGroupArn=arn&Targets.member.1.Id=i-1&Targets.member.1.Port=80&Targets.member.2.Id=i-2",
"DefaultActions.member.1.Type=forward&DefaultActions.member.1.ForwardConfig.TargetGroups.member.1.Weight=1",
"DBInstanceIdentifier=db&Tags.Tag.1.Key=k&Tags.Tag.1.Value=v&Tags.member.2.Key=x",
} {
f.Add(seed)
}
f.Fuzz(func(t *testing.T, body string) {
params, ok := queryParams(body)
if !ok {
return
}
for _, target := range fuzzTargets {
out := target()
if err := QueryParamsToStruct(params, out); err != nil {
continue
}
counts := map[int]int{}
countSliceElems(t, reflect.ValueOf(out), 0, counts)
for depth, n := range counts {
if n > len(params) {
t.Fatalf("%T: %d list entries at depth %d from %d params: %q", out, n, depth, len(params), body)
}
}
}
})
}
// FuzzQueryParamsToStruct_EntryPastGapIsIgnored pins AWS's gap rule on
// arbitrary input: adding an entry one past the first gap in any list leaves
// the parsed request unchanged.
func FuzzQueryParamsToStruct_EntryPastGapIsIgnored(f *testing.F) {
for _, seed := range []string{
"Filter.1.Name=a&Filter.1.Value.1=b&Filter.3.Name=c",
"Filter.2.Name=a",
"Filter.1.Name=a&Filter.1.Value.2=b",
"InstanceId.1=i-1&InstanceId.3=i-3",
"Filter.01.Name=a&Filter.1.Value.1=b",
"Filter.1.Value.item.1=a",
} {
f.Add(seed)
}
f.Fuzz(func(t *testing.T, body string) {
params, ok := queryParams(body)
if !ok {
return
}
want := &ec2.DescribeInstancesInput{}
if err := QueryParamsToStruct(params, want); err != nil {
return
}
pastGap := len(want.Filters) + 2
params[fmt.Sprintf("Filter.%d.Name", pastGap)] = "past-gap"
params[fmt.Sprintf("Filter.%d.Value.1", pastGap)] = "past-gap"
params[fmt.Sprintf("InstanceId.%d", len(want.InstanceIds)+2)] = "past-gap"
for i, filter := range want.Filters {
params[fmt.Sprintf("Filter.%d.Value.%d", i+1, len(filter.Values)+2)] = "past-gap"
}
got := &ec2.DescribeInstancesInput{}
if err := QueryParamsToStruct(params, got); err != nil {
t.Fatalf("entry past the gap turned success into %v: %q", err, body)
}
if !reflect.DeepEqual(want, got) {
t.Fatalf("entry past the gap changed the parse of %q:\nwant %s\ngot %s", body, want.String(), got.String())
}
})
}
+31 -15
View File
@@ -1,3 +1,6 @@
// Package awserrors defines the AWS error codes Spinifex returns, their HTTP
// status and default message, and helpers to attach a code to an error and
// resolve it again at the API boundary.
package awserrors
import (
@@ -8,6 +11,8 @@ import (
"time"
)
// ErrorMessage is the HTTP status and default message returned for an AWS
// error code, as resolved by LookupErrorMessage.
type ErrorMessage struct {
HTTPCode int
Message string
@@ -314,7 +319,7 @@ var (
ErrorInvalidVpcPeeringConnectionIDNotFound = "InvalidVpcPeeringConnectionID.NotFound"
ErrorInvalidVpcPeeringConnectionIdMalformed = "InvalidVpcPeeringConnectionId.Malformed"
ErrorInvalidVpcPeeringConnectionStateDnsHostnamesDisabled = "InvalidVpcPeeringConnectionState.DnsHostnamesDisabled"
ErrorInvalidVpcRange = "InvalidVpcRange"
ErrorInvalidVpcRange = "InvalidVpc.Range"
ErrorInvalidVpcState = "InvalidVpcState"
ErrorInvalidVpnConnectionInvalidState = "InvalidVpnConnection.InvalidState"
ErrorInvalidVpnConnectionInvalidType = "InvalidVpnConnection.InvalidType"
@@ -388,6 +393,9 @@ var (
// real ECS both use the restJson1 wire code "InvalidParameterException",
// so this gives EKS call sites their own name for the same registered code.
ErrorEKSInvalidParameter = ErrorECSInvalidParameter
// ErrorECRInvalidParameter aliases ErrorECSInvalidParameter: every ECR
// operation declares the AWS JSON 1.1 wire code "InvalidParameterException".
ErrorECRInvalidParameter = ErrorECSInvalidParameter
// ErrorACMResourceInUse aliases ErrorEKSResourceInUse: real ACM's
// DeleteCertificate and real EKS's CreateCluster both legitimately use the
// wire code "ResourceInUseException", so this is a distinct, self-documenting
@@ -517,6 +525,7 @@ var (
ErrorLayersNotFound = "LayersNotFoundException"
ErrorReferencedImagesNotFound = "ReferencedImagesNotFoundException"
ErrorTooManyTags = "TooManyTagsException"
ErrorInvalidTagParameter = "InvalidTagParameterException"
ErrorOperationNotSupported = "OperationNotSupportedException"
// ELBv2-specific error codes.
@@ -577,7 +586,7 @@ func ValidErrorCode(code string) string {
// ResolveErrorCode returns the first registered AWS error code in err's unwrap tree.
func ResolveErrorCode(err error) (string, bool) {
code, _, ok := resolveErrorDetail(err)
code, _, ok := ResolveErrorDetail(err)
return code, ok
}
@@ -585,12 +594,6 @@ func ResolveErrorCode(err error) (string, bool) {
// plus the message the producing call site attached via Errorf, if any. A
// generic %w wrapper added purely for internal context carries no message.
func ResolveErrorDetail(err error) (code, message string, ok bool) {
return resolveErrorDetail(err)
}
// resolveErrorDetail is the shared unwrap-tree walk behind ResolveErrorCode
// and ResolveErrorDetail.
func resolveErrorDetail(err error) (code, message string, ok bool) {
if err == nil {
return "", "", false
}
@@ -604,14 +607,14 @@ func resolveErrorDetail(err error) (code, message string, ok bool) {
if joined, isJoined := err.(interface{ Unwrap() []error }); isJoined {
for _, inner := range joined.Unwrap() {
if c, m, found := resolveErrorDetail(inner); found {
if c, m, found := ResolveErrorDetail(inner); found {
return c, m, true
}
}
return "", "", false
}
if wrapped, isWrapped := err.(interface{ Unwrap() error }); isWrapped {
return resolveErrorDetail(wrapped.Unwrap())
return ResolveErrorDetail(wrapped.Unwrap())
}
return "", "", false
}
@@ -640,6 +643,18 @@ func Errorf(code, format string, args ...any) error {
return outer
}
// IDNotFound returns code with EC2's not-found message for one resource ID,
// "The <kind> ID '<id>' does not exist".
func IDNotFound(code, kind, id string) error {
return Errorf(code, "The %s ID '%s' does not exist", kind, id)
}
// HasDependencies returns DependencyViolation with EC2's message for a
// resource that cannot be deleted while others depend on it.
func HasDependencies(kind, id string) error {
return Errorf(ErrorDependencyViolation, "The %s '%s' has dependencies and cannot be deleted.", kind, id)
}
// retryableError wraps a registered code with a suggested Retry-After
// duration, for a 503 that a client can expect to clear on its own within a
// bounded window (e.g. a warm-up race) rather than an open-ended outage.
@@ -658,7 +673,7 @@ func RetryAfter(code string, d time.Duration) error {
}
// ResolveRetryAfter returns the Retry-After duration attached to err via
// RetryAfter, if any, walking the same unwrap tree resolveErrorDetail does.
// RetryAfter, if any, walking the same unwrap tree ResolveErrorDetail does.
func ResolveRetryAfter(err error) (time.Duration, bool) {
if err == nil {
return 0, false
@@ -1012,7 +1027,7 @@ var ErrorLookup = map[string]ErrorMessage{
ErrorInvalidNetworkLoadBalancerArnNotFound: {HTTPCode: 404, Message: "The specified Network Load Balancer ARN does not exist."},
ErrorInvalidNextToken: {HTTPCode: 400, Message: "The specified NextToken is not valid."},
ErrorInvalidOptionConflict: {HTTPCode: 409, Message: "A VPN connection between the virtual private gateway and the customer gateway already exists."},
ErrorInvalidPaginationToken: {HTTPCode: 403, Message: "The specified pagination token is not valid or is expired."},
ErrorInvalidPaginationToken: {HTTPCode: 400, Message: "The specified pagination token is not valid or is expired."},
ErrorInvalidParameter: {HTTPCode: 400, Message: "A parameter specified in a request is not valid, is unsupported, or cannot be used. The returned message provides an explanation of the error value. For example, if you are launching an instance, you can't specify a security group and subnet that are in different VPCs."},
ErrorInvalidParameterCombination: {HTTPCode: 400, Message: "Indicates an incorrect combination of parameters, or a missing parameter. For example, trying to terminate an instance without specifying the instance ID."},
ErrorInvalidParameterDependency: {HTTPCode: 400, Message: "Indicates an incorrect combination of parameters, or a missing parameter. For example, trying to terminate an instance without specifying the instance ID."},
@@ -1069,7 +1084,7 @@ var ErrorLookup = map[string]ErrorMessage{
ErrorInvalidSubnetConflict: {HTTPCode: 409, Message: "The specified CIDR block conflicts with that of another subnet in your VPC."},
ErrorInvalidSubnetRange: {HTTPCode: 400, Message: "The CIDR block you've specified for the subnet is not valid. The allowed block size is between a /28 netmask and /16 netmask."},
ErrorInvalidSubnetIDMalformed: {HTTPCode: 400, Message: "The specified subnet ID is malformed. Ensure that you specify the ID in the form subnet-xxxxxxxxxxxxxxxxx"},
ErrorInvalidSubnetIDNotFound: {HTTPCode: 404, Message: "or InvalidSubnetId.NotFound \tThe specified subnet does not exist."},
ErrorInvalidSubnetIDNotFound: {HTTPCode: 404, Message: "The specified subnet does not exist."},
ErrorInvalidTagKeyMalformed: {HTTPCode: 400, Message: "The specified tag key is not valid. Tag keys cannot be empty or null, and cannot start with aws:."},
ErrorInvalidTargetArnUnknown: {HTTPCode: 404, Message: "The specified ARN for the specified user or role is not valid or does not exist."},
ErrorInvalidTargetException: {HTTPCode: 404, Message: "The specified TargetId is not valid, does not exist, or is in another organization. You can only generate an account status report for declarative policies in your own organization. Ensure that you specify the TargetId in one of the following forms: r-xxxx, ou-xxxx-xxxxxxxx, or a 12-digit account ID in the form xxxxxxxxxxxx."},
@@ -1097,7 +1112,7 @@ var ErrorLookup = map[string]ErrorMessage{
ErrorInvalidVpcPeeringConnectionIDNotFound: {HTTPCode: 404, Message: "The specified VPC peering connection ID does not exist."},
ErrorInvalidVpcPeeringConnectionIdMalformed: {HTTPCode: 400, Message: "The specified VPC peering connection ID is malformed. Ensure that you provide the ID in the form pcx-xxxxxxxxxxxxxxxxx."},
ErrorInvalidVpcPeeringConnectionStateDnsHostnamesDisabled: {HTTPCode: 400, Message: "To enable DNS hostname resolution for the VPC peering connection, DNS hostname support must be enabled for the VPCs."},
ErrorInvalidVpcRange: {HTTPCode: 400, Message: "The specified CIDR block range is not valid. The block range must be between a /28 netmask and /16 netmask. For more information, see VPC CIDR blocks."},
ErrorInvalidVpcRange: {HTTPCode: 400, Message: "The CIDR block is invalid."},
ErrorInvalidVpcState: {HTTPCode: 400, Message: "The specified VPC already has a virtual private gateway attached to it."},
ErrorInvalidVpnConnectionInvalidState: {HTTPCode: 400, Message: "The VPN connection must be in the available state to complete the request."},
ErrorInvalidVpnConnectionInvalidType: {HTTPCode: 400, Message: "The specified VPN connection does not support static routes."},
@@ -1210,7 +1225,7 @@ var ErrorLookup = map[string]ErrorMessage{
ErrorUnfulfillableCapacity: {HTTPCode: 400, Message: "At this time there isn't enough spare capacity to fulfill your request for Spot Instances. You can wait a few minutes to see whether capacity becomes available for your request. Alternatively, create a more flexible request. For example, include additional instance types, include additional Availability Zones, or use the capacity-optimized allocation strategy."},
ErrorUnknownParameter: {HTTPCode: 404, Message: "An unknown or unrecognized parameter was supplied. Requests that could cause this error include supplying a misspelled parameter or a parameter that is not supported for the specified API version."},
ErrorUnknownPrincipalTypeUnsupported: {HTTPCode: 400, Message: "The principal type is not supported."},
ErrorUnknownVolumeType: {HTTPCode: 400, Message: "The specified volume type is unsupported. The supported volume types are gp2, io1, st1, sc1, and standard."},
ErrorUnknownVolumeType: {HTTPCode: 400, Message: "Unsupported volume type for volume creation."},
ErrorUnsupported: {HTTPCode: 400, Message: "The specified request is unsupported. For example, you might be trying to launch an instance in an Availability Zone that currently has constraints on that instance type. The returned message provides details of the unsupported request."},
ErrorUnsupportedException: {HTTPCode: 400, Message: "Capacity Reservations are not supported for this Region."},
ErrorUnsupportedHibernationConfiguration: {HTTPCode: 400, Message: "The instance could not be launched because one or more parameter values do not meet the prerequisites for enabling hibernation. For more information, see Hibernation Prerequisites. Alternatively, the instance could not be hibernated because it is not enabled for hibernation."},
@@ -1277,6 +1292,7 @@ var ErrorLookup = map[string]ErrorMessage{
ErrorImageDigestDoesNotMatch: {HTTPCode: 400, Message: "The specified image digest does not match the digest of the supplied image manifest."},
ErrorLayersNotFound: {HTTPCode: 400, Message: "The specified layers could not be found, or the specified layer is not valid for this repository."},
ErrorReferencedImagesNotFound: {HTTPCode: 400, Message: "The manifest list is referencing an image that does not exist."},
ErrorInvalidTagParameter: {HTTPCode: 400, Message: "Tag parameters are invalid"},
ErrorTooManyTags: {HTTPCode: 400, Message: "The list of tags on the repository is over the limit. The maximum number of tags that can be applied to a repository is 50."},
ErrorOperationNotSupported: {HTTPCode: 400, Message: "The specified operation is not supported in this registry."},

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