Compare commits

...

311 Commits

Author SHA1 Message Date
Julian Sommer e773b1c906 fix: keep surviving flows on a failed IMDS repair and repair guests concurrently
Clear a tap's flows only once its endpoint is set up, so a repair that fails
after a kernel-module reload no longer takes down a guest that still had
connectivity. Repair up to 8 datapaths at once, since an ovs-vswitchd restart
drops every guest together. Test that teardown holds the ENI lock across
detach and tap cleanup, and count only the test guest's flows in the e2e.
2026-10-21 10:41:51 +10:00
Julian Sommer 2a61e3dafb fix: re-install br-imds flows lost on an ovs-vswitchd restart
ovs-vswitchd keeps OpenFlow rules only in memory, and nothing re-pushed
br-imds after a restart. br-imds is fail-mode=secure, so every guest on
the node lost all networking until its tap was set up again.

The daemon now checks each running guest's IMDS datapath every 10s: one
br-imds flow dump, plus the endpoint's accept_local, which a kernel-module
reload resets along with the reply rule and route. A guest missing any of
it goes through the existing install path; an intact one is never touched,
since the install starts by clearing the tap's flows.

A per-ENI lock in vm.Manager serialises attach, repair, and the
detach-plus-CleanupTap teardown, and a repair refuses a tap that has left
br-imds, so it cannot re-create ports for a guest being torn down.

TestIMDSDatapathSurvivesVswitchdRestart restarts ovs-vswitchd through
systemd and with SIGKILL under a guest with an Elastic IP.
2026-10-21 10:14:54 +10:00
Julian Sommer d01e9b400d test: assert a TTL kvstore bucket is created at the cluster's replica count on a three-node cluster (#1236) 2026-10-21 10:10:00 +11:00
Julian Sommer 77b46481f1 fix: report InsufficientInstanceCapacity instead of a raw NATS no-responders error when RunInstances finds no launch subscriber on a node (#1235) 2026-10-21 09:56:49 +11:00
Julian Sommer a16b667cbb fix: log the local node as node, not holder, in reconcile/lock and kvlease lease lines (#1234)
A node that lost the election logged holder=<itself>, which reads as though it holds the lock. Lease lines now carry the local node as node, and a refused claim logs the node that owns the key, read from the Get the claim already makes, as holder at debug level.
2026-10-21 09:31:57 +11:00
Julian Sommer 7f634496b6 fix: count only the first shutdown ACK from each node so a duplicate cannot stand in for a silent node (#1233) 2026-10-21 09:30:36 +11:00
Julian Sommer 3a849c4c18 fix: name the blocking volume in DeleteSnapshot's InUse error instead of blaming AMIs (#1232) 2026-10-21 09:20:37 +11:00
Julian Sommer 2f609efa14 fix: hide platform-managed instances from DescribeInstanceStatus (#1230) 2026-10-20 13:03:18 +11:00
Julian Sommer eccbf48273 test: stub sudo so the uninstall multi-node refusal check runs on any host 2026-10-20 10:21:23 +10:00
Julian Sommer 84df82c26f fix: accept the snapshot-id filter on DescribeVolumes and match it against the volume's source snapshot (#1229) 2026-10-20 11:21:02 +11:00
Julian Sommer 29770dc798 test: stub ip and ovs-vsctl so the setup-ovn teardown order check runs on any host 2026-10-20 10:09:28 +10:00
Julian Sommer 09392d18a1 test: wait for the consumer span to end before asserting ebs trace linkage (#1228)
TestEbsRequestWithTrace_ProducerConsumerLinked read the span recorder as
soon as UnmountOne returned, but the handler replies before its deferred
span.End runs, so under -race the recorder could hold only the producer
span. The handler now signals after its span ends and the test waits for
that signal instead of racing it.
2026-10-20 10:57:52 +11:00
Josh Siviour 142ca6eec3 fix: refuse DeleteSnapshot while a registered AMI uses the snapshot (#1227)
DeleteSnapshot checked only volume documents, so a snapshot backing a
registered AMI deleted anyway and left the AMI pointing at nothing.
Add a strict, whole-cluster AMI read next to the existing volume-clone
check and refuse with InvalidSnapshot.InUse when a registered AMI
names the snapshot; an unreadable or undecodable AMI document fails
the delete closed with ServerInternal.

The e2e teardown sweep now deregisters images before deleting
snapshots, since a snapshot sweep that runs first would now be
blocked by its AMI.

Known residual, out of scope here: RegisterImage reading the snapshot
and DeleteSnapshot's clone/AMI checks are not atomic, so a concurrent
RegisterImage can still race a DeleteSnapshot that passed its checks.
2026-10-20 09:54:35 +10:00
Josh Siviour 36949804e8 fix: unreadable volume records are never NotFound in DeleteVolume or ForceDetachVolume (#1226)
* fix: report an unreadable volume document as InternalError, not NotFound

* fix: ForceDetachVolume refuses an unreadable volume record

ForceDetachVolume mapped every metadata read error to InvalidVolume.NotFound.
An unreadable (present but undecodable or unreconstructable) document now
returns InternalError and leaves the attachment untouched; a genuinely
absent document or another account's volume still returns NotFound.

This is the call spx admin account delete --force uses, so an unreadable
volume no longer reads as already detached.
2026-10-20 09:41:28 +10:00
Josh Siviour a43febc187 test: characterise EC2 image, snapshot and volume deletion safety blockers (#1225)
Pin current behaviour only; no production change. The tests document why
owner cleanup of images and snapshots is not yet safe:
- a CreateImage snapshot does not pin its source volume;
- DeleteSnapshot ignores a registered AMI;
- a snapshot deleted inside the clone window strands the new volume;
- CreateImage of a retained instance needs its launch AMI;
- a snapshotted DeleteOnTermination volume survives terminate.
2026-10-20 09:37:20 +10:00
Josh Siviour e7cb02dedf feat: add EC2-owned platform cleanup capability for the proven-safe subset (#1224)
Add domains/ec2/accountcleanup, an unwired EC2-owned capability that
inventories key pairs, placement groups and launch templates for one
account, applies their owner delete rules, and proves each disposition
(deleted, blocked, failed, residue) by owner re-scan, never by Describe.

Cleanup deletes only the proven-safe subset: placement groups with no
members and key pairs that no non-terminated instance, open spot request
or live launch-template version references, plus unreachable
launch-template residue. Live launch templates are always blocked while
the EKS nodegroup reference is unresolved. Unknown or unreadable
reference state fails closed. Reference readers are injected; no action
adapter, NATS subject, IAM or accountteardown wiring is added.

Owner delete corrections shared by the public Delete* actions:
- DeleteKeyPair keeps metadata when the public-key delete fails, removes
  leftover public-key objects by name, and fails closed on unreadable or
  undecodable metadata.
- DeleteLaunchTemplate removes all version bodies and the name index
  before the revision-guarded header delete, and returns InternalError
  when a step fails.
- CreateLaunchTemplateVersion commits through the header revision, so a
  concurrent delete leaves no headerless body.
- DeletePlacementGroup is revision-guarded against concurrent
  reservations and maps store read errors to InternalError.
- Placement-group and launch-template deletes clear their tag-index
  entries.
2026-10-19 15:59:15 +10:00
Julian Sommer be84b73abe fix: rebuild the initramfs after upgrading the kernel in system AMIs
On Alpine 3.21 apk does not run the mkinitfs trigger inside the libguestfs
appliance, so apk upgrade left the old initramfs beside the new kernel. The
guest could not load virtio_blk and dropped to the emergency shell, which
stalled EKS cluster creation. Run mkinitfs for the installed kernel and fail
the build if the initramfs lacks its virtio_blk module.
2026-10-19 13:11:03 +10:00
Julian Sommer 3dbe352d1b fix: ship patched guest kernels in system AMIs and the LB microVM (#1222)
build-system-image.sh never upgraded the base cloud image, so every Alpine
system AMI kept the kernel its base image was released with. Run apk upgrade
before installing manifest packages so rebuilt AMIs carry the branch's current
linux-virt (6.18.57-r0 on 3.24, 6.12.113-r0 on 3.21; CVE-2026-99999).

The ELBv2 microVM defaulted to Alpine 3.20, which is EOL and will never get a
fixed kernel. Default to 3.24.
2026-10-19 14:04:27 +11:00
Josh Siviour fcfcc1dcff test: characterise AWSGW and ECR account admission (#1223) 2026-10-19 12:28:12 +10:00
Julian Sommer cd4363ba53 refactor: adopt shared helpers and remove local duplication in spinifexui (#1221)
Sidebar built from a typed NAV array with links rendered by the menu
button; one dashboard ResourceCard; signOut owns the clear-everything rule.
SystemImageRequired rechecks itself, wizards read mutation state, and
dialogs reset by unmounting. IAM policy hooks take a principal kind; ECS,
RDS, Nodes, S3 and ELBv2 share their duplicated tables and fields; EC2 CLI
builders use the flag helpers; CIDR fields validate with isValidCidr and
AWS prefix ranges.
2026-10-19 11:49:50 +11:00
Julian Sommer 90739c889b refactor: add shared helpers to spinifexui (#1220)
Signed fetch: signedAdminFetch wraps signedProxyFetch, which resolves
credentials itself, removing 11 per-caller checks.

Tags: one tagSchema, toSdkTags and diffTags; the tags editor moves to
components/ on useForm and TagsFieldArray.

Utilities: arnResourceId, byName, sameSet and shellSingleQuote replace
22 local copies.

Components: DeleteResourceButton (13 EC2 detail pages), ResourceList
(23 list pages), ResourceDeleteDialog (RDS, ECR), SelectField,
TextField, VpcSelectField, WizardFailureBanner, EditorShell and a
mode-driven ListenerDialog.

Tests: test/render.tsx with renderMutationHook, mockRouter,
createFakeSdk and mockAwsClients; the RDS and ELBv2 hook flows move to
mutations/*-flow.test.tsx.
2026-10-19 11:28:20 +11:00
Julian Sommer a4e5494e66 refactor: remove dead code and tautological tests from spinifexui (#1219)
Deletes unreferenced components, hook fields, types and query options,
drops exports used only in their own file and redundant Outlet route
components, and removes tests that assert their own fakes, restate query
key literals or repeat vitest's clearMocks. Query-key tests become
invalidation-prefix behaviour tests, and the Go compression tests now
drive the real handler chain.
2026-10-19 10:37:37 +11:00
Julian Sommer efdd255435 chore: drop the temporary vuln-scan acceptance list now Go is current 2026-10-19 09:27:09 +10:00
Julian Sommer ed8a6f2fb2 fix: Bedrock badges, VPC wizard tags, SG labels and LB protocol defaults in the UI (#1218) 2026-10-19 10:23:31 +11:00
Julian Sommer 0fb5c83a26 chore: fix spinifexui script 2026-10-19 08:56:54 +10:00
Julian Sommer 07bda82f1e chore: rebuild ui 2026-10-19 08:56:26 +10:00
Josh Siviour 49b94444ba feat: compare the EC2 platform inventory with legacy listings on explicit account-deletion dry runs (#1216) 2026-10-19 08:49:39 +10:00
Julian Sommer 1c21123c68 chore: fix lint issues 2026-10-19 08:46:10 +10:00
dependabot[bot] 15c9308e41 chore: bump the npm-dependencies group (#1217)
Bumps the npm-dependencies group in /spinifex/runtime/roles/spinifexui/frontend with 36 updates:

| Package | From | To |
| --- | --- | --- |
| [@aws-sdk/client-acm](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-acm) | `3.1147.0` | `3.1152.0` |
| [@aws-sdk/client-bedrock](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-bedrock) | `3.1147.0` | `3.1152.0` |
| [@aws-sdk/client-bedrock-agent](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-bedrock-agent) | `3.1147.0` | `3.1152.0` |
| [@aws-sdk/client-bedrock-agent-runtime](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-bedrock-agent-runtime) | `3.1147.0` | `3.1152.0` |
| [@aws-sdk/client-bedrock-runtime](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-bedrock-runtime) | `3.1147.0` | `3.1152.0` |
| [@aws-sdk/client-ec2](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-ec2) | `3.1147.0` | `3.1152.0` |
| [@aws-sdk/client-ecr](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-ecr) | `3.1147.0` | `3.1152.0` |
| [@aws-sdk/client-ecs](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-ecs) | `3.1147.0` | `3.1152.0` |
| [@aws-sdk/client-eks](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-eks) | `3.1147.0` | `3.1152.0` |
| [@aws-sdk/client-elastic-load-balancing-v2](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-elastic-load-balancing-v2) | `3.1147.0` | `3.1152.0` |
| [@aws-sdk/client-iam](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-iam) | `3.1147.0` | `3.1152.0` |
| [@aws-sdk/client-rds](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-rds) | `3.1147.0` | `3.1152.0` |
| [@aws-sdk/client-s3](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-s3) | `3.1147.0` | `3.1152.0` |
| [@aws-sdk/client-sts](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-sts) | `3.1147.0` | `3.1152.0` |
| [@aws-sdk/lib-storage](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/lib/lib-storage) | `3.1147.0` | `3.1152.0` |
| [@base-ui/react](https://github.com/mui/base-ui/tree/HEAD/packages/react) | `1.8.0` | `1.9.0` |
| [@tanstack/react-query](https://github.com/TanStack/query/tree/HEAD/packages/react-query) | `5.104.1` | `5.104.3` |
| [@tanstack/react-router](https://github.com/TanStack/router/tree/HEAD/packages/react-router) | `1.170.41` | `1.170.44` |
| [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) | `1.52.0` | `1.57.0` |
| [react](https://github.com/react/react/tree/HEAD/packages/react) | `19.3.0` | `19.3.1` |
| [react-dom](https://github.com/react/react/tree/HEAD/packages/react-dom) | `19.3.0` | `19.3.1` |
| [react-hook-form](https://github.com/react-hook-form/react-hook-form) | `7.89.0` | `7.90.0` |
| [zod](https://github.com/colinhacks/zod) | `4.6.5` | `4.6.7` |
| [@tanstack/eslint-plugin-query](https://github.com/TanStack/query/tree/HEAD/packages/eslint-plugin-query) | `5.104.1` | `5.104.3` |
| [@tanstack/router-plugin](https://github.com/TanStack/router/tree/HEAD/packages/router-plugin) | `1.168.42` | `1.168.46` |
| [@testing-library/user-event](https://github.com/testing-library/user-event) | `14.6.7` | `14.6.8` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `24.19.1` | `24.19.2` |
| [@vitest/coverage-v8](https://github.com/vitest-dev/vitest/tree/HEAD/packages/coverage-v8) | `5.0.3` | `5.0.5` |
| [happy-dom](https://github.com/capricorn86/happy-dom) | `20.14.5` | `21.0.1` |
| [oxc-transform-react](https://github.com/oxc-project/oxc/tree/HEAD/napi/transform-react) | `0.153.0` | `0.154.0` |
| [oxfmt](https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt) | `0.72.0` | `0.73.0` |
| [oxlint](https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint) | `1.87.0` | `1.88.0` |
| [oxlint-tsgolint](https://github.com/oxc-project/tsgolint) | `7.0.2003` | `7.0.2004` |
| [ultracite](https://github.com/haydenbleasel/ultracite) | `7.12.4` | `8.0.1` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `8.3.3` | `8.3.4` |
| [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) | `5.0.3` | `5.0.5` |


Updates `@aws-sdk/client-acm` from 3.1147.0 to 3.1152.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-acm/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1152.0/clients/client-acm)

Updates `@aws-sdk/client-bedrock` from 3.1147.0 to 3.1152.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-bedrock/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1152.0/clients/client-bedrock)

Updates `@aws-sdk/client-bedrock-agent` from 3.1147.0 to 3.1152.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-bedrock-agent/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1152.0/clients/client-bedrock-agent)

Updates `@aws-sdk/client-bedrock-agent-runtime` from 3.1147.0 to 3.1152.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-bedrock-agent-runtime/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1152.0/clients/client-bedrock-agent-runtime)

Updates `@aws-sdk/client-bedrock-runtime` from 3.1147.0 to 3.1152.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-bedrock-runtime/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1152.0/clients/client-bedrock-runtime)

Updates `@aws-sdk/client-ec2` from 3.1147.0 to 3.1152.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-ec2/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1152.0/clients/client-ec2)

Updates `@aws-sdk/client-ecr` from 3.1147.0 to 3.1152.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-ecr/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1152.0/clients/client-ecr)

Updates `@aws-sdk/client-ecs` from 3.1147.0 to 3.1152.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-ecs/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1152.0/clients/client-ecs)

Updates `@aws-sdk/client-eks` from 3.1147.0 to 3.1152.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-eks/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1152.0/clients/client-eks)

Updates `@aws-sdk/client-elastic-load-balancing-v2` from 3.1147.0 to 3.1152.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-elastic-load-balancing-v2/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1152.0/clients/client-elastic-load-balancing-v2)

Updates `@aws-sdk/client-iam` from 3.1147.0 to 3.1152.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-iam/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1152.0/clients/client-iam)

Updates `@aws-sdk/client-rds` from 3.1147.0 to 3.1152.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-rds/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1152.0/clients/client-rds)

Updates `@aws-sdk/client-s3` from 3.1147.0 to 3.1152.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-s3/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1152.0/clients/client-s3)

Updates `@aws-sdk/client-sts` from 3.1147.0 to 3.1152.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-sts/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1152.0/clients/client-sts)

Updates `@aws-sdk/lib-storage` from 3.1147.0 to 3.1152.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/lib/lib-storage/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1152.0/lib/lib-storage)

Updates `@base-ui/react` from 1.8.0 to 1.9.0
- [Release notes](https://github.com/mui/base-ui/releases)
- [Changelog](https://github.com/mui/base-ui/blob/master/CHANGELOG.md)
- [Commits](https://github.com/mui/base-ui/commits/v1.9.0/packages/react)

Updates `@tanstack/react-query` from 5.104.1 to 5.104.3
- [Release notes](https://github.com/TanStack/query/releases)
- [Changelog](https://github.com/TanStack/query/blob/main/packages/react-query/CHANGELOG.md)
- [Commits](https://github.com/TanStack/query/commits/@tanstack/react-query@5.104.3/packages/react-query)

Updates `@tanstack/react-router` from 1.170.41 to 1.170.44
- [Release notes](https://github.com/TanStack/router/releases)
- [Changelog](https://github.com/TanStack/router/blob/main/packages/react-router/CHANGELOG.md)
- [Commits](https://github.com/TanStack/router/commits/@tanstack/react-router@1.170.44/packages/react-router)

Updates `lucide-react` from 1.52.0 to 1.57.0
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.57.0/packages/lucide-react)

Updates `react` from 19.3.0 to 19.3.1
- [Release notes](https://github.com/react/react/releases)
- [Changelog](https://github.com/react/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/react/react/commits/v19.3.1/packages/react)

Updates `react-dom` from 19.3.0 to 19.3.1
- [Release notes](https://github.com/react/react/releases)
- [Changelog](https://github.com/react/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/react/react/commits/v19.3.1/packages/react-dom)

Updates `react-hook-form` from 7.89.0 to 7.90.0
- [Release notes](https://github.com/react-hook-form/react-hook-form/releases)
- [Changelog](https://github.com/react-hook-form/react-hook-form/blob/master/CHANGELOG.md)
- [Commits](https://github.com/react-hook-form/react-hook-form/compare/v7.89.0...v7.90.0)

Updates `zod` from 4.6.5 to 4.6.7
- [Release notes](https://github.com/colinhacks/zod/releases)
- [Commits](https://github.com/colinhacks/zod/compare/v4.6.5...v4.6.7)

Updates `@tanstack/eslint-plugin-query` from 5.104.1 to 5.104.3
- [Release notes](https://github.com/TanStack/query/releases)
- [Changelog](https://github.com/TanStack/query/blob/main/packages/eslint-plugin-query/CHANGELOG.md)
- [Commits](https://github.com/TanStack/query/commits/@tanstack/eslint-plugin-query@5.104.3/packages/eslint-plugin-query)

Updates `@tanstack/router-plugin` from 1.168.42 to 1.168.46
- [Release notes](https://github.com/TanStack/router/releases)
- [Changelog](https://github.com/TanStack/router/blob/main/packages/router-plugin/CHANGELOG.md)
- [Commits](https://github.com/TanStack/router/commits/@tanstack/router-plugin@1.168.46/packages/router-plugin)

Updates `@testing-library/user-event` from 14.6.7 to 14.6.8
- [Release notes](https://github.com/testing-library/user-event/releases)
- [Changelog](https://github.com/testing-library/user-event/blob/main/CHANGELOG.md)
- [Commits](https://github.com/testing-library/user-event/compare/v14.6.7...v14.6.8)

Updates `@types/node` from 24.19.1 to 24.19.2
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `@vitest/coverage-v8` from 5.0.3 to 5.0.5
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v5.0.5/packages/coverage-v8)

Updates `happy-dom` from 20.14.5 to 21.0.1
- [Release notes](https://github.com/capricorn86/happy-dom/releases)
- [Commits](https://github.com/capricorn86/happy-dom/compare/v20.14.5...v21.0.1)

Updates `oxc-transform-react` from 0.153.0 to 0.154.0
- [Release notes](https://github.com/oxc-project/oxc/releases)
- [Changelog](https://github.com/oxc-project/oxc/blob/main/napi/transform-react/CHANGELOG.md)
- [Commits](https://github.com/oxc-project/oxc/commits/crates_v0.154.0/napi/transform-react)

Updates `oxfmt` from 0.72.0 to 0.73.0
- [Release notes](https://github.com/oxc-project/oxc/releases)
- [Changelog](https://github.com/oxc-project/oxc/blob/main/npm/oxfmt/CHANGELOG.md)
- [Commits](https://github.com/oxc-project/oxc/commits/oxfmt_v0.73.0/npm/oxfmt)

Updates `oxlint` from 1.87.0 to 1.88.0
- [Release notes](https://github.com/oxc-project/oxc/releases)
- [Changelog](https://github.com/oxc-project/oxc/blob/main/npm/oxlint/CHANGELOG.md)
- [Commits](https://github.com/oxc-project/oxc/commits/oxlint_v1.88.0/npm/oxlint)

Updates `oxlint-tsgolint` from 7.0.2003 to 7.0.2004
- [Release notes](https://github.com/oxc-project/tsgolint/releases)
- [Commits](https://github.com/oxc-project/tsgolint/compare/v7.0.2003...v7.0.2004)

Updates `ultracite` from 7.12.4 to 8.0.1
- [Release notes](https://github.com/haydenbleasel/ultracite/releases)
- [Commits](https://github.com/haydenbleasel/ultracite/compare/ultracite@7.12.4...ultracite@8.0.1)

Updates `vite` from 8.3.3 to 8.3.4
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.3.4/packages/vite)

Updates `vitest` from 5.0.3 to 5.0.5
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v5.0.5/packages/vitest)

---
updated-dependencies:
- dependency-name: "@aws-sdk/client-acm"
  dependency-version: 3.1152.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@aws-sdk/client-bedrock"
  dependency-version: 3.1152.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@aws-sdk/client-bedrock-agent"
  dependency-version: 3.1152.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@aws-sdk/client-bedrock-agent-runtime"
  dependency-version: 3.1152.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@aws-sdk/client-bedrock-runtime"
  dependency-version: 3.1152.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@aws-sdk/client-ec2"
  dependency-version: 3.1152.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@aws-sdk/client-ecr"
  dependency-version: 3.1152.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@aws-sdk/client-ecs"
  dependency-version: 3.1152.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@aws-sdk/client-eks"
  dependency-version: 3.1152.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@aws-sdk/client-elastic-load-balancing-v2"
  dependency-version: 3.1152.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@aws-sdk/client-iam"
  dependency-version: 3.1152.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@aws-sdk/client-rds"
  dependency-version: 3.1152.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@aws-sdk/client-s3"
  dependency-version: 3.1152.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@aws-sdk/client-sts"
  dependency-version: 3.1152.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@aws-sdk/lib-storage"
  dependency-version: 3.1152.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@base-ui/react"
  dependency-version: 1.9.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@tanstack/react-query"
  dependency-version: 5.104.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: "@tanstack/react-router"
  dependency-version: 1.170.44
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: lucide-react
  dependency-version: 1.57.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: react
  dependency-version: 19.3.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: react-dom
  dependency-version: 19.3.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: react-hook-form
  dependency-version: 7.90.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: zod
  dependency-version: 4.6.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: "@tanstack/eslint-plugin-query"
  dependency-version: 5.104.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: "@tanstack/router-plugin"
  dependency-version: 1.168.46
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: "@testing-library/user-event"
  dependency-version: 14.6.8
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: "@types/node"
  dependency-version: 24.19.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: "@vitest/coverage-v8"
  dependency-version: 5.0.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: happy-dom
  dependency-version: 21.0.1
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: npm-dependencies
- dependency-name: oxc-transform-react
  dependency-version: 0.154.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: oxfmt
  dependency-version: 0.73.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: oxlint
  dependency-version: 1.88.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: oxlint-tsgolint
  dependency-version: 7.0.2004
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: ultracite
  dependency-version: 8.0.1
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: npm-dependencies
- dependency-name: vite
  dependency-version: 8.3.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: vitest
  dependency-version: 5.0.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-19 09:43:30 +11:00
dependabot[bot] 3da9d9e60b chore: bump pnpm/action-setup (#1215)
Bumps the github-actions group with 1 update: [pnpm/action-setup](https://github.com/pnpm/action-setup).


Updates `pnpm/action-setup` from 6.0.10 to 6.1.0
- [Release notes](https://github.com/pnpm/action-setup/releases)
- [Commits](https://github.com/pnpm/action-setup/compare/v6.0.10...v6.1.0)

---
updated-dependencies:
- dependency-name: pnpm/action-setup
  dependency-version: 6.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-19 09:43:04 +11:00
dependabot[bot] 04644bfbc9 chore: bump the go-dependencies group with 13 updates (#1214)
Bumps the go-dependencies group with 13 updates:

| Package | From | To |
| --- | --- | --- |
| [github.com/aws/aws-sdk-go-v2](https://github.com/aws/aws-sdk-go-v2) | `1.47.1` | `1.47.3` |
| [github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream](https://github.com/aws/aws-sdk-go-v2) | `1.7.20` | `1.7.22` |
| [github.com/aws/aws-sdk-go-v2/config](https://github.com/aws/aws-sdk-go-v2) | `1.33.7` | `1.33.9` |
| [github.com/aws/aws-sdk-go-v2/credentials](https://github.com/aws/aws-sdk-go-v2) | `1.20.7` | `1.20.9` |
| [github.com/aws/aws-sdk-go-v2/feature/ec2/imds](https://github.com/aws/aws-sdk-go-v2) | `1.20.1` | `1.20.3` |
| [github.com/containerd/containerd/v2](https://github.com/containerd/containerd) | `2.4.1` | `2.4.2` |
| [github.com/go-chi/chi/v5](https://github.com/go-chi/chi) | `5.3.2` | `5.5.0` |
| [github.com/klauspost/compress](https://github.com/klauspost/compress) | `1.20.1` | `1.20.3` |
| [github.com/klauspost/cpuid/v2](https://github.com/klauspost/cpuid) | `2.4.0` | `2.4.1` |
| [github.com/nats-io/nats-server/v2](https://github.com/nats-io/nats-server) | `2.15.0` | `2.15.1` |
| [github.com/oracle/oci-go-sdk/v65](https://github.com/oracle/oci-go-sdk) | `65.126.2` | `65.127.0` |
| [github.com/pterm/pterm](https://github.com/pterm/pterm) | `0.12.83` | `0.12.84` |
| [golang.org/x/tools](https://github.com/golang/tools) | `0.51.0` | `0.52.0` |


Updates `github.com/aws/aws-sdk-go-v2` from 1.47.1 to 1.47.3
- [Release notes](https://github.com/aws/aws-sdk-go-v2/releases)
- [Commits](https://github.com/aws/aws-sdk-go-v2/compare/v1.47.1...v1.47.3)

Updates `github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream` from 1.7.20 to 1.7.22
- [Release notes](https://github.com/aws/aws-sdk-go-v2/releases)
- [Commits](https://github.com/aws/aws-sdk-go-v2/compare/service/notifications/v1.7.20...service/notifications/v1.7.22)

Updates `github.com/aws/aws-sdk-go-v2/config` from 1.33.7 to 1.33.9
- [Release notes](https://github.com/aws/aws-sdk-go-v2/releases)
- [Commits](https://github.com/aws/aws-sdk-go-v2/compare/config/v1.33.7...config/v1.33.9)

Updates `github.com/aws/aws-sdk-go-v2/credentials` from 1.20.7 to 1.20.9
- [Release notes](https://github.com/aws/aws-sdk-go-v2/releases)
- [Commits](https://github.com/aws/aws-sdk-go-v2/compare/service/mq/v1.20.7...service/kms/v1.20.9)

Updates `github.com/aws/aws-sdk-go-v2/feature/ec2/imds` from 1.20.1 to 1.20.3
- [Release notes](https://github.com/aws/aws-sdk-go-v2/releases)
- [Commits](https://github.com/aws/aws-sdk-go-v2/compare/v1.20.1...v1.20.3)

Updates `github.com/containerd/containerd/v2` from 2.4.1 to 2.4.2
- [Release notes](https://github.com/containerd/containerd/releases)
- [Changelog](https://github.com/containerd/containerd/blob/main/RELEASES.md)
- [Commits](https://github.com/containerd/containerd/compare/v2.4.1...v2.4.2)

Updates `github.com/go-chi/chi/v5` from 5.3.2 to 5.5.0
- [Release notes](https://github.com/go-chi/chi/releases)
- [Changelog](https://github.com/go-chi/chi/blob/master/CHANGELOG.md)
- [Commits](https://github.com/go-chi/chi/compare/v5.3.2...v5.5.0)

Updates `github.com/klauspost/compress` from 1.20.1 to 1.20.3
- [Release notes](https://github.com/klauspost/compress/releases)
- [Commits](https://github.com/klauspost/compress/compare/v1.20.1...v1.20.3)

Updates `github.com/klauspost/cpuid/v2` from 2.4.0 to 2.4.1
- [Release notes](https://github.com/klauspost/cpuid/releases)
- [Commits](https://github.com/klauspost/cpuid/compare/v2.4.0...v2.4.1)

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

Updates `github.com/oracle/oci-go-sdk/v65` from 65.126.2 to 65.127.0
- [Release notes](https://github.com/oracle/oci-go-sdk/releases)
- [Changelog](https://github.com/oracle/oci-go-sdk/blob/master/CHANGELOG.md)
- [Commits](https://github.com/oracle/oci-go-sdk/compare/v65.126.2...v65.127.0)

Updates `github.com/pterm/pterm` from 0.12.83 to 0.12.84
- [Release notes](https://github.com/pterm/pterm/releases)
- [Commits](https://github.com/pterm/pterm/compare/v0.12.83...v0.12.84)

Updates `golang.org/x/tools` from 0.51.0 to 0.52.0
- [Release notes](https://github.com/golang/tools/releases)
- [Commits](https://github.com/golang/tools/compare/v0.51.0...v0.52.0)

---
updated-dependencies:
- dependency-name: github.com/aws/aws-sdk-go-v2
  dependency-version: 1.47.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go-dependencies
- dependency-name: github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream
  dependency-version: 1.7.22
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go-dependencies
- dependency-name: github.com/aws/aws-sdk-go-v2/config
  dependency-version: 1.33.9
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go-dependencies
- dependency-name: github.com/aws/aws-sdk-go-v2/credentials
  dependency-version: 1.20.9
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go-dependencies
- dependency-name: github.com/aws/aws-sdk-go-v2/feature/ec2/imds
  dependency-version: 1.20.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go-dependencies
- dependency-name: github.com/containerd/containerd/v2
  dependency-version: 2.4.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go-dependencies
- dependency-name: github.com/go-chi/chi/v5
  dependency-version: 5.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-dependencies
- dependency-name: github.com/klauspost/compress
  dependency-version: 1.20.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go-dependencies
- dependency-name: github.com/klauspost/cpuid/v2
  dependency-version: 2.4.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go-dependencies
- dependency-name: github.com/nats-io/nats-server/v2
  dependency-version: 2.15.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go-dependencies
- dependency-name: github.com/oracle/oci-go-sdk/v65
  dependency-version: 65.127.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-dependencies
- dependency-name: github.com/pterm/pterm
  dependency-version: 0.12.84
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go-dependencies
- dependency-name: golang.org/x/tools
  dependency-version: 0.52.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-19 09:01:24 +11:00
Josh Siviour 71301983bd feat: add read-only EC2 platform inventory for account termination (#1212)
* feat: add read-only EC2 platform inventory for account termination

* fix: drop placement-group member instance IDs from the platform inventory reply
2026-10-16 12:05:32 +10:00
Josh Siviour 359eb7a386 docs: add account lifecycle PR checklist (#1213) 2026-10-16 12:01:14 +10:00
Josh Siviour ef782a2758 test: characterise EC2 platform resource residue and direct-NATS disclosure for account termination (#1211) 2026-10-16 11:25:57 +10:00
Josh Siviour a7a774a7fc docs: post-ADR-0001 source-tree doc fixes and move planning material to Mulga (#1210)
* docs: update source-tree docs and comments after the ADR-0001 package moves

* docs: move package-boundary planning material to Mulga
2026-10-15 16:47:03 +10:00
Josh Siviour bd14e2d67d refactor: move read-only GetResources aggregation to domains/tagging and retire gateway/tagging (#1209)
* test: characterise current tagging read, write and aggregation behaviour

* docs: record tagging capability discovery and narrowed ADR-0001 cut

* test: update ACM tagging characterisation after key preservation

* feat: add read-only TaggedLister capability in domains/tagging

* feat: add AWSGW tag index and ELBv2 TaggedLister adapters

* refactor: move GetResources aggregation to domains/tagging

* docs: close out the ADR-0001 tagging boundary

* docs: repoint tagging migration citations to the rebased commits
2026-10-15 14:58:44 +10:00
Josh Siviour b5879f44ff ADR-0001 package-boundary migration checkpoint (#1190)
* refactor: move telemetry file naming to contract

* docs: record telemetry contract move

* refactor: move aws xml response helpers

* docs: record aws xml helper move

* refactor: move aws tag projections to foundation

* docs: record aws tag projection move

* refactor: move account key to state foundation

* docs: record account key state move

* refactor: move host privilege escalation to runtime

* docs: record host privilege move

* chore: resolve lint findings from package-boundary moves

* refactor: move process supervision helpers to runtime host

* docs: record process supervision move

* refactor: move nbd endpoint helpers to compute runtime

* docs: record nbd endpoint move

* refactor: move image catalogue to operator

* docs: record image catalogue move

* refactor: move resource id generation to aws foundation

* docs: record resource id move

* refactor: move predastore role to runtime roles

* docs: point service manifest at acm domain

* docs: record predastore role move

* refactor: move northstar role to runtime roles

* docs: record northstar role move

* refactor: move awsgw role to runtime roles

* docs: record awsgw role move

* refactor: move error payload helpers to aws errors

* docs: record error payload move

* refactor: move account identifiers to aws identifiers

* docs: record account identifier move

* refactor: move NATS transport to messaging foundation

* docs: record NATS transport move

* refactor: move system instance contract to EC2 domain

* docs: record system instance move

* refactor: move system VPC builder to network domain

* docs: record system VPC move

* refactor: move instance metadata service to EC2 domain

* docs: record guest metadata move and dependency-rule debt

* refactor: move DNS control plane to DNS domain

* docs: record DNS domain move

* refactor: move base zone bootstrap to DNS domain

* docs: record base zone bootstrap move

* refactor: replace SystemAccountID with GlobalAccountID

* docs: record SystemAccountID removal

* refactor: extract CA key-pair loader to foundation pki

* docs: record foundation pki extraction

* refactor: move tenant CA paths to ACM domain

* docs: record tenant CA path move

* refactor: move IPsec certificate handling to network host

* docs: record IPsec certificate move

* refactor: move Ochre serving lifecycle to the Ochre domain

handlers/bedrock becomes domains/ochre. Ochre is the platform service;
Bedrock names only its AWS API surface. Persisted and observable
identifiers keep their bedrock names.

* docs: record Ochre domain move

* refactor: move Ochre vector store to the Ochre domain

handlers/ochrevector becomes domains/ochre/vector. Callers import it as
ochrevector because vector is a common local name in the gateway.

* docs: record Ochre vector store move

* refactor: move Hugging Face Hub client to the Ochre domain

gateway/bedrock/hfhub becomes domains/ochre/hfhub. Its only caller is
the spx admin ochre weights pull.

* docs: record Hugging Face Hub client move and Ochre gateway debt

* refactor: move EC2 tag mutation helpers to the EC2 domain

utils/tagmirror.go becomes domains/ec2/tagmirror. It is EC2-wide tag
mutation behaviour, and the tags resource package cannot host it
without a resource-to-resource import cycle.

* docs: record EC2 tag mutation move and migrate split finding

* refactor: move network packages under the network domain

Move spinifex/network/* to spinifex/domains/network/* as one enclosing
move so the L0-L5 layer invariants keep classifying every package from
a single root. Package names and dependency direction are unchanged;
invariant scan roots, the RG-10 sudo allow-list path, CODEOWNERS and
the repo-root walk in the IMDS netns unit test follow the new path.

* docs: record network domain move

* refactor: move standalone EC2 resource packages to the EC2 domain

Move handlers/ec2/{account,key,launchtemplate,placementgroup,
spotinstance,volumestate} to domains/ec2/<resource>. Each package is
named after its directory and callers import it as ec2<resource>;
volumestate already carried its directory name.

* docs: record standalone EC2 resource moves

* refactor: move EC2 VPC networking packages to the EC2 domain

Move handlers/ec2/{vpc,eigw,eip,igw,natgw,routetable} to
domains/ec2/<resource>, each named after its directory and imported as
ec2<resource>. The reconcile drift-watch source scan now matches the
ec2<resource> bucket qualifiers; its rule is unchanged.

* docs: record EC2 VPC networking moves

* refactor: move EC2 compute and storage packages to the EC2 domain

Move handlers/ec2/{instance,volume,snapshot,image,tags} to
domains/ec2/<resource>, each named after its directory and imported as
ec2<resource>, which empties handlers/ec2. The daemon's e2e suite
selection now lists the moved EC2 resource paths in place of
handlers/ec2, so EC2-only changes select the daemon suites again.

* docs: record EC2 compute and storage moves

* refactor: move ECR credential provider to the EKS agent tree

Move the kubelet exec credential provider implementation from
cmd/ecr-credential-provider to agents/eks/credentialprovider, exposing
one Main(in, out) entry point. The binary keeps its name and FIPS boot
import as a thin main. Moving out of cmd/ brings the code under gosec;
the one G101 hit on the kubelet API version constant is annotated.

* docs: record ECR credential provider move

* refactor: move EKS gateway publish helper to the EKS agent tree

Move the eks-gateway-publish implementation to agents/eks/gatewaypublish
behind Run(ctx, Config) error. The binary keeps flag definitions,
environment defaults, parsing, its log prefix and exit code; each former
fatal site returns an error with the same text. The retry wait now also
honours context cancellation, which the binary never triggers.

* docs: record EKS gateway publish move

* refactor: move EKS gateway fetch helper to the EKS agent tree

Move the eks-gateway-fetch implementation to agents/eks/gatewayfetch
behind Run(ctx, Config) error, writing its TSV to Config.Out. The binary
keeps flag definitions, environment defaults, parsing, its log prefix and
exit code; each former fatal site returns an error with the same text.

* docs: record EKS gateway fetch move

* refactor: move EKS webhook cert helper to the EKS agent tree

cmd/eks-webhook-cert keeps flag parsing, the log prefix and the exit;
agents/eks/webhookcert owns Config and Run. Output, error text, file
modes and exit codes are unchanged.

* docs: record EKS webhook cert move

* refactor: move EKS konnectivity cert helper to the EKS agent tree

cmd/eks-konnectivity-cert keeps flag parsing, the log prefix and the
exit; agents/eks/konnectivitycert owns Config and Run. Output, error
text, file modes and exit codes are unchanged.

* docs: record EKS konnectivity cert move

* refactor: move EKS token webhook to the EKS agent tree

cmd/eks-token-webhook keeps the -addr flag, environment-derived
config, logging setup and exits; agents/eks/tokenwebhook owns Config
and Run. The handlers/eks TokenReview result type stays as recorded
temporary debt.

* docs: record EKS token webhook move

* refactor: move ECS agent runtime and credentials packages to the ECS agent tree

* docs: record ECS agent subpackage move

* refactor: move ECS agent implementation to the ECS agent tree

cmd/ecs-agent keeps process lifecycle: signal context, the separate New
and Run calls, both failure log lines, the exit and the ldflags-set
build version, now passed in through Config.AgentVersion.
agents/ecs/agent owns Config, LoadConfig and the agent.

* docs: record ECS agent move

* refactor: move RDS guest agent to agents/rds/agent

* docs: record RDS guest agent move

* refactor: move installer packages to operator/installer

* docs: record installer move

* refactor: move spx command tree to operator/cli

Moving the command tree out of cmd/ brings it under errcheck and gosec.
Flag and config wiring now fails loudly instead of silently dropping a
binding, listing commands fail when they cannot write their table, and
the formation server's unauthenticated TLS is annotated as a known gap.

* docs: record spx command tree move

* chore: accept known vulnerabilities with an expiry

The vulnerability scan failed preflight on a containerd advisory that dev
already carries and that is deferred to a separate dependency update. The
gate now passes only reachable findings listed with an expiry date, fails
on any new one or on an expired acceptance, and warns when an acceptance
is no longer needed.

* refactor: move EC2 account action adapters to domains/ec2/awsapi

* refactor: move EC2 capacityreservation action adapters to domains/ec2/awsapi

* refactor: move EC2 key action adapters to domains/ec2/awsapi

* refactor: move EC2 launchtemplate action adapters to domains/ec2/awsapi

* refactor: move EC2 placementgroup action adapters to domains/ec2/awsapi

* docs: record EC2 standalone action adapter moves

* refactor: move EC2 zone action adapters to domains/ec2/awsapi

* refactor: move EC2 vpc action adapters to domains/ec2/awsapi

* refactor: move EC2 eigw action adapters to domains/ec2/awsapi

* refactor: move EC2 eip action adapters to domains/ec2/awsapi

* refactor: move EC2 igw action adapters to domains/ec2/awsapi

* refactor: move EC2 natgw action adapters to domains/ec2/awsapi

* refactor: move EC2 routetable action adapters to domains/ec2/awsapi

* docs: record EC2 VPC networking action adapter moves

* refactor: move EC2 image action adapters to domains/ec2/awsapi

* refactor: move EC2 snapshot action adapters to domains/ec2/awsapi

* refactor: move EC2 volume action adapters to domains/ec2/awsapi

* refactor: move EC2 tags action adapters to domains/ec2/awsapi

* docs: record EC2 storage and tag action adapter moves

* refactor: move EC2 resource authorization scopes to domains/ec2/awsapi

* refactor: move EC2 idempotency adapter to domains/ec2/awsapi

* refactor: move EC2 instance action adapters to domains/ec2/awsapi

* refactor: move EC2 spotinstance action adapters to domains/ec2/awsapi

* docs: record EC2 instance action adapter moves

* refactor: move AWS REST-JSON router to ingress/aws/rest

* refactor: move SigV4 auth-failure rate limiter to ingress/aws/ratelimit

* docs: record AWS ingress helper moves and tooling conformance

* docs: correct historical lifecycle references

* test: characterize ECR control-plane dispatch before registration seam

* feat: add AWS ingress service registration contract

* docs: record ingress registration phases 0 and 1

* refactor: route registered AWS services through the ingress dispatch registry

The gateway now consumes ingress/aws/dispatch. A registered service is
accepted by the auth gate, resolves its own action label, renders errors in
its declared envelope and is dispatched with an authenticated Invocation
(account, region, policy authorisation and a lazy verified actor). Every
other service keeps the existing switch, now named as the legacy path.

The awsgw role wires an empty registry and refuses to start if a registered
name is also served by the legacy table. No service is registered yet, so
behaviour for every existing service is unchanged.

* docs: record ingress dispatch registry consumption

* refactor: register the ECR control plane from its domain

domains/ecr/awsapi now registers the "ecr" AWS JSON 1.1 surface with the
ingress dispatch registry: action resolution, resource scope, authorisation
through the invocation, the composed capabilities and the success writer all
live with the domain. The awsgw role is the only serving composition point;
the gateway no longer imports the ECR API package and ECR leaves the legacy
service table, JSON error table and dispatch switch. The OCI /v2 route is
unchanged.

Operation coverage reads ECR's declared inventory, which the registration
uses verbatim; coverage.json is byte-identical.

* docs: record ECR control-plane registration

* fix: compose the ECR control plane in the integration harness

The harness stopped wiring ECR's composed capabilities when the
authorization-token action was composed, so every integration test that
creates a repository or mints a token got ServerInternal. It now composes
ECR as the awsgw role does, sharing the OCI registry, meta store and token
issuer the /v2 data plane uses.

* docs: record ingress pipeline deferral

* refactor: extract AWS error envelope rendering to ingress

ingress/aws/envelope owns the EC2, IAM-style and S3 XML error envelopes, the
AWS JSON 1.1 error body and X-Amzn-Errortype value, the JSON-protocol header
detector and the two error content types. Each shape keeps its own builder.

The gateway error writers, ErrorHandler and the legacy per-service envelope
selection stay in gateway and call the new package; gateway/eks renders its
JSON errors through it directly. A 140-case status/header/body golden
comparison across services and error writers was identical before and after.

* docs: record error envelope extraction

* refactor: move AWS service selection into ingress dispatch

Selector answers served, action, error protocol, registered entry and
startup overlap. The gateway supplies its legacy tables through the
dispatch.Legacy interface; legacy dispatch and invocation building stay
in gateway.

* docs: record ingress service selection move

* test: characterize ACM control-plane dispatch before registration

Pins full-pipeline error envelopes, a successful action, target parsing
agreement between dispatch and telemetry, authorization before the NATS
call, 413 and 503 paths, throttle/trace/audit action and the inventory.

* refactor: register ACM control plane from its domain

domains/acm/awsapi registers the acm JSON 1.1 surface through the
ingress dispatch registry, wired by the awsgw role. The gateway drops
ACM_Request, the ACM action table and every legacy acm entry; the
import invariant now covers each registered domain.

* docs: record ACM registration

* docs: record ECS, EKS and RDS source inventory and first-resource ranking

* docs: record EKS control-plane secrets persisted in the cluster record

* chore: drop containerd advisory acceptance now that dev ships v2.4.1

* test: characterize RDS DB subnet group lifecycle before extraction

* docs: record RDS DB subnet group lifecycle contract

* refactor: extract the RDS DB subnet group lifecycle owner

domains/rds/subnetgroup owns the db-subnet-groups/{name} record, member
subnet validation and the delete in-use guard. handlers/rds keeps AWS
validation, projection and paging and binds two consumer-owned
capabilities: the owner's Dependants (instances naming the group) and the
instance side's subnetGroupPlacement. Stored shape and behaviour are
unchanged.

* docs: record RDS DB subnet group owner extraction

* docs: correct RDS subnet group tagging debt and conformance scope

* test: characterize ECS task definition lifecycle before extraction

* docs: record ECS task definition lifecycle contract

* refactor: extract ECS task definition owner into domains/ecs/taskdefinition

Move the task definition record, container types, keys, reference parsing,
ARN helpers and the register/deregister/resolve/list logic into a resource
owner package. Stored JSON field names and KV keys are unchanged, and
revision allocation keeps its existing non-atomic read-then-write.

The owner declares its own PortMapping and SystemControl persisted types so
it no longer depends on handlers/ecs/bus; the handler translates them onto
the assign payload. Task and service launches reach task definitions only
through a launch-side taskDefinitionResolver. AWS conversion, the guest
projection and tags stay in handlers/ecs, and gateway authz only switches to
the moved reference/ARN helpers.

List handlers/ecs and the new package under spinifex_daemon so ECS-only
changes select e2e-ecs.

* docs: record ECS task definition owner extraction

* test: characterize EKS access entry lifecycle before extraction

* docs: record EKS access entry lifecycle contract

* refactor: extract EKS access entry owner into domains/eks/access

Move the access entry record, associated policy and scope types, keys,
PrincipalARNHash, the policy catalogue and group mapping into a resource
owner package. Stored JSON field names and KV keys are unchanged.

Owner is the only writer: Create, SeedCreatorAdmin, Update, Associate,
Disassociate and Delete carry the existing logic verbatim, and the put,
compare-and-set and delete primitives are private. The handler keeps AWS
validation, the cluster existence check, error mapping and projection.
Token review still reads records through the exported Get, and the cluster
prefix purge still sweeps entry keys.

List handlers/eks and the new package under spinifex_daemon so EKS-only
changes select the EKS suites.

* docs: record EKS access entry owner extraction

* test: characterize RDS engine catalogue before extraction

* fix: return InvalidParameterValue for a missing RDS master password

ValidateMasterUserPassword built the empty-password error with a bare
errors.New carrying the code only in its text, which awserrors does not
resolve, so CreateDBInstance without MasterUserPassword returned HTTP 500
ServerInternal. Build it with awserrors.Errorf like the other branches so
the gateway returns 400 InvalidParameterValue, and cover the full gateway
to RDS service path with a regression test.

* docs: record RDS engine catalogue boundary and evidence

* refactor: extract RDS engine catalogue into domains/rds/engine

Move engine identity and validation, the per-engine parameter catalogues,
effective-parameter resolution, db.* instance classes and engine/orderable
selection into an engine package. Behaviour, error codes and ordering are
unchanged.

The package imports no EC2 package: instance-class memory comes from an
immutable Sizing value built from an engine-declared InstanceTypes
interface, backed by the fixed instance-type table in handlers/rds.
Effective parameters leave as an engine-owned Setting, converted to
Parameter in handlers/rds. AWS output building, paging, apply-method
mapping, storage limits and the gateway runnable check stay in the
adapter path.

The RDS agent declares its own EngineCatalog interface and types and
receives an engine-backed implementation from cmd/rds-agent, so agent
code does not import the catalogue. Cross-engine tests use the two real
engines instead of a registered fake.

* docs: record RDS engine catalogue owner move

* test: characterize RDS DB parameter group lifecycle before extraction

* docs: record RDS DB parameter group lifecycle contract

* refactor: extract RDS DB parameter group owner

Move the parameter group and override records, keys and listing into
domains/rds/parametergroup behind an Owner. Delete asks an owner-declared
Dependants interface; instance-side resolution reads through its own
parameterGroupValues interface. SetOverrides refuses an unknown group so
no override key is written without its group. Stored shape, keys, public
errors and propagation are unchanged.

* docs: record RDS DB parameter group owner extraction

* test: characterize EKS add-on lifecycle and guest wire

Pin the persisted record and manifest layout, create/update/delete and
status-report transitions, the ignored client token and report version,
the missing serviceAccountRoleArn PassRole check, and the exact internal
add-ons HTTP body and agent TSV output as they stand today.

* docs: record EKS add-on lifecycle contract

* fix: require iam:PassRole for EKS add-on service account role

CreateAddon and UpdateAddon now pass serviceAccountRoleArn through the
existing EKS PassedRoleARNs path, so the caller needs iam:PassRole on the
role for eks.amazonaws.com, matching CreateCluster and CreateNodegroup.

* docs: record EKS add-on PassRole check

* refactor: extract EKS add-on owner

Move the add-on catalogue, record and manifest keys, CAS, status
transitions and staging into domains/eks/addon behind an Owner that is
the sole writer. The handler keeps AWS validation, error mapping and
projection; the cluster reconciler delegates reports through the narrow
addonReports interface; node groups call EnsureGPUDevicePlugin. Stored
keys, JSON shape, wire types and lifecycle behaviour are unchanged.

* docs: record EKS add-on owner extraction

* refactor: extract EKS add-on guest wire contract

Move the add-on status subject, delivery phase, status report, staged
manifest and internal add-ons response into contracts/eks/v1 and use it
from the handler, gateway and gatewayfetch, replacing the agent's
duplicate. Wire bytes are unchanged, including the internal add-ons body
that the SDK renderer emits with Go field names; compatibility tests pin
the JSON, the guest printf form and the TSV.

* docs: record EKS add-on wire contract extraction

* docs: update EKS add-on inventory and remaining edges

* docs: draft EKS add-on lifecycle correction design for review

* chore: run contracts tests in the default test, coverage and race targets

* docs: apply review decisions to EKS add-on lifecycle design

* test: characterize EKS add-on v1 compatibility with added fields

Pin which owner writes drop unknown record and manifest fields, that
reads and deletes ignore or erase them, that one undecodable entry fails
a listing, and that v1 wire consumers ignore extra fields.

* docs: add EKS add-on executor, bundle, v1 compatibility and release inventories

* docs: draft EKS add-on v2 guest contract design for review

* docs: qualify historical architecture references

* docs: apply review decisions and ADR-0006 to EKS add-on v2 design

* fix(eks): bind internal report publishing to the caller's cluster

PublishInternal was authorised by IAM policy alone, with its resource
built from the body accountId and the URL cluster. The control-plane
role grants it on "*", so any control-plane VM session, any tenant
holding eks:*, or static system credentials could publish cluster
state, bootstrap and add-on reports for another cluster or account.

PublishInternal now passes the existing internal-route gate: the caller
must be a control-plane instance-role session that is a member of the
URL cluster in the body account. The gate and the handler decode the
body through one shared decoder, so the account checked is the account
the subject is built from. Every unauthorised target returns the same
AccessDenied, and invalid account or cluster names are treated as not
found. Valid publishes keep their subjects and payload bytes.

* fix(eks): bind webhook token review to the caller's cluster

WebhookTokenReview took its account from the request body and was
authorised by IAM policy alone, with the control-plane role granting it
on "*". A control-plane VM, any tenant holding eks:*, or static system
credentials could resolve a token against another cluster or account
and learn the Kubernetes groups it maps to there. Error responses also
distinguished absent accounts from absent clusters.

WebhookTokenReview now passes the same internal-route gate as
PublishInternal: a control-plane instance-role session that is a member
of the URL cluster in the body account. The gate and the handler share
one body decoder, each body-scoped internal route must declare its
reader, and every unauthorised target returns the same AccessDenied
without reaching STS verification. Same-cluster responses are unchanged.

* fix(eks): validate add-on serviceAccountRoleArn syntax

CreateAddon and UpdateAddon stored serviceAccountRoleArn without
checking it was an ARN, and the guest renders the value into the
add-on's manifests. A non-empty value must now be syntactically an IAM
role ARN (12-digit account, no region, role resource, IAM name and path
rules); anything else returns InvalidParameterException before any
record is written or manifest staged.

The syntax check is foundation/aws/arn.ValidateRoleARN. iam:PassRole at
the gateway remains the permission and existence check. Stored values
are not rewritten, and an UpdateAddon that omits the field still
succeeds on a record holding a legacy value.

* docs: record EKS internal-route and role-ARN security corrections

* docs: add EKS add-on release membership and activation case study

* fix: point UI build, CI and lint rules at moved package paths

* docs: record EKS add-on activation review decisions

* refactor: move quota admission to domains/admission/quota

Relocate the quota service, live counters and vCPU records from
handlers/quota to domains/admission/quota. Package renamed from
handlers_quota to quota; callers with a local quota identifier import it
as admissionquota. KV buckets, subjects and error codes are unchanged, and
the package's existing debt edges move with it unchanged.

* docs: reconcile package-boundary record for ADR-0001 close-out

Add the missing slice rows (the commits that dissolved spinifex/types, the
ECR repository projection, the stale-path fix and the quota move), correct
stale entries, record the dependency-rule breaks found in the import graph
with removal conditions, add the close-out table of remaining legacy paths
with their decided target owners, recount the mulga-side follow-ups, and
fix stale old-path references in docs and comments.

* test: move KV migration engine tests to foundation/state/migrate

The Registry and RunKV tests exercise foundation/state/migrate exclusively
but lived in spinifex/migrate's test file. Relocate them, with their NATS
helpers, to the package that owns the engine. No test name, assertion or
behaviour changes.

* refactor: move config and object-store upgrade workflow to operator/upgrade

Relocate spinifex/migrate, the operator-driven config-file and object-store
upgrade workflow behind spx admin upgrade, to operator/upgrade. Package
renamed to upgrade (imported as opupgrade where a local upgrade identifier
exists). It stays distinct from foundation/state/migrate, the generic KV
migration engine; no registry types, migration names, versions, file
formats or command names change.

* docs: record migrate move to operator/upgrade

* test: enforce ADR-0001 layering with a blocking ratchet

Add spinifex/architecture/layering, a test-only package that parses the
module's import graph, maps every production package to an ADR-0001
layer and checks each dependency rule as its own subtest. Existing
violations are allow-listed against their recorded debt; a new forbidden
edge, an unmapped package, a stale allowlist entry or a new package
under a frozen legacy root fails local preflight and CI.

Record the nine edges the test found missing from the migration record
(quota -> EC2 instance types, STS/Ochre/ELBv2 -> IAM, ELBv2
cross-domain, installer UI -> admin), name the importers of the utils
residuals and gateway/ec2_idempotency.go, and correct the handlers/iam
consumer list.

* refactor: drop the legacy handlers_ecr import alias

Import domains/ecr under its package name, or as ecrdomain where the
file also imports the AWS SDK ecr package, matching acmdomain. Also fix
a stale daemon file reference in the integration ECR daemon comment.

* chore: mark spf13/pflag as a direct dependency

operator/cli/wiring.go imports pflag directly. Same version; no upgrade.

* docs: record test-package-check and nilaway as unverified gates

* refactor: move the layering test to module-root architecture/

The ADR-0001 layering ratchet is repository governance tooling, not
Spinifex product code, so it sits beside tests/ rather than under the
spinifex/ product tree. make test, test-cover and test-race now include
./architecture/..., and the graph walk excludes architecture/.

* fix: update paths left stale by the package moves

The MariaDB image stamp test read the RDS engine from
handlers/rds/engine_mariadb.go, so test-images failed in CI; it now reads
domains/rds/engine/mariadb.go. Comments citing moved files now name
their new locations.

* refactor: move the default-VPC subject into contracts/ec2/v1

utils.SubjectEnsureDefaultVpc becomes ec2v1.EnsureDefaultVpcSubject, the
request/reply route between gateway account creation and the daemon.
Only the subject moves; the request and reply bodies are still anonymous
structs on each side and are recorded as such in the contract README.
The layering freeze now allows two utils files.

* docs: qualify the migrate split and record admin and Viperblock findings

The migrate row is a split, not an exact ADR-0001 map row, and needs a map
clarification. Correct the stale admin consumers, record the operator-only
admin files as movable, the vbwire prerequisite for the Viperblock adapter
and the capability decision encryption.go needs.

* refactor: move operator-only admin code to operator/admin

Config generation, certificate issuance, host discovery, telemetry,
email validation and image import/removal/orphan-volume tooling move
to operator/admin. Legacy admin keeps only the symbols with
non-operator consumers: default account identifiers, AWS credential
generators and system image promotion.

foundation/pki and the tenant ACM CA are unchanged. The readAMI
helper and the removal test fixtures are duplicated privately in
legacy admin rather than shared across the split. The now-stale
operator/installer/ui -> admin layering allowlist entry is removed.

* docs: record operator/admin split in package-boundary migration

* test: pin viperblockd fence, abandon and dirty-marker wire shapes

Characterization tests against the current vbwire package, before it
moves to contracts/viperblockd/legacy/v1: both subject forms for the
fenced and abandon routes, golden JSON for VolumeFencedEvent,
VolumeAbandonRequest, VolumeAbandonResponse and DirtyRecord, the dirty
bucket name, and the volume-key pattern shared by the lease and dirty
stores.

* refactor: move viperblockd fence, abandon and dirty-marker vocabulary to contracts

Move the fence and abandon subjects/DTOs, the dirty bucket name,
DirtyRecord and the shared volume-key pattern out of vbwire and into
contracts/viperblockd/legacy/v1, alongside the package's existing
mount/unmount/delete/sync/config-update routes. vbwire keeps only the
I/O helpers (BindDirtyBucket, ListUnsealedVolumes), now built on the
contract vocabulary instead of defining it.

Record the approved dirty-marker ownership boundary in the contract
README: the Viperblock adapter is the sole producer, viperblockd was
only its transitional custodian. Document the fence and abandon routes
and the dirty bucket in docs/service-interfaces.yaml.

* refactor: split the Viperblock EBS adapter out of services/viperblockd

Move the EBS provider adapter (volume lease, fence, dirty marker,
nbdkit recovery, provider handlers, encryption key loading) to
providers/ebs/viperblock. services/viperblockd keeps only process
lifecycle and the NATS subscription wiring that starts and stops it.

The dirty-bucket read path moves to providers/ebs/viperblock/dirty as
a read-only ListUnsealedVolumes capability; the adapter's write, clear
and purge logic stays unexported in the adapter package. Delete
services/viperblockd/vbwire now that its vocabulary lives in
contracts/viperblockd/legacy/v1 and its I/O helpers live in dirty.

Update operator/cli, the runtime service registry, qemunbdd's config
comment and the integration test harness to the new import paths, and
drop the now-stale architecture/layering allowlist and frozen-package
entries for the deleted package. Behaviour, persisted keys and NATS
subjects are unchanged.

* refactor: move the viperblockd role to runtime/roles/viperblockd

With the EBS adapter in providers/ebs/viperblock, services/viperblockd
holds only role supervision and the opt-in live data-path tests, so it
joins the other runtime roles. It was the last package under services/;
the frozen legacy list drops it.

* docs: record viperblockd contract, adapter and role moves

* test: characterise vpc.* network-realisation routes

Pin every vpc.* subject, JSON payload shape and the request/reply
{success,error} ack envelope exactly as they exist today, ahead of
introducing contracts/network/v1. Covers the 22 subjects registered
by domains/network/subscribers, the vpc.igw-attach/detach subjects
and InternetGatewayEvent already owned by contracts/ec2/v1, the
utils.AddNAT/PublishNATEvent client wire behaviour including the
delete-nat retry-on-ErrNoResponders budget, and the EC2-side NATEvent
and SGEvent/SGRule duplicates in domains/ec2/vpc.

Also adds the six vpc.* route-table and system-egress subjects that
were missing from service-interfaces.yaml despite being live
production routes (vpc.add/delete-igw-route, vpc.gate/ungate-subnet-
egress, vpc.add/delete-system-egress).

* feat: add contracts/network/v1 for vpc.* realisation routes

Declare the versioned wire vocabulary for the vpc.* control-plane-to-
network-realisation boundary: subjects, payload types, the shared
{success,error} ack envelope and the vpcd-workers queue group, one
file per route family. No NATS I/O, no consumer switched over yet;
the package coexists with the fragmented originals pinned in the
prior characterization commit until slice 3 migrates call sites.

* refactor: use contracts/network/v1 for vpc.* producers and consumers

Switch every vpc.* producer (domains/ec2/vpc, eip, instance, natgw,
routetable, igw, handlers/elbv2, handlers/eks, daemon, systemvpc) and the
network/subscribers consumer onto contracts/network/v1 subjects, event
types and the shared AckEnvelope. contracts/network/v1 becomes the sole
owner of vpc.igw-attach/detach, replacing the duplicate definitions in
contracts/ec2/v1.

Deletes the duplicated local subject constants and event structs:
subscribers' Topic* consts and event structs, vpc.NATEvent and SGEvent,
eip's natEvent, natgw's natGatewayEvent, and eks's systemEgressEvent.
domains/ec2/vpc.SGRule survives because EC2 persists it directly in
SecurityGroupRecord; contracts/network/v1.SecurityGroupRule was expanded
in slice 2 to match it field-for-field so wire bytes are unchanged.

No lifecycle or reliability changes: the NAT add barrier, delete
retry-on-ErrNoResponders, and the inconsistent per-call-site NAT
delivery modes (EIP AssociateAddress and DeleteNetworkInterface both stay
fire-and-forget with no ack) are preserved exactly, as are the golden
wire literals pinned in the characterization tests.

* refactor: replace utils/vpcd_event.go with a typed network projection client

Every vpc.* call site now goes through domains/network/projection's
operation-specific methods instead of utils.AddNAT, utils.PublishNATEvent
or utils.RequestEvent, so no caller passes a bare subject string. Each
method states today's delivery mode by name: AddNAT stays strict,
AddNATBestEffort and AnnounceNATAdd/AnnounceNATDelete keep the gaps those
call sites already had, and RemoveNAT keeps the ErrNoResponders retry.

utils/vpcd_event.go and its tests are deleted; the behaviour they pinned
(wire shape, ack envelope, retry-on-no-responders) moved into the
projection package's own golden and nat tests, unchanged. utils/ now
holds only encryption.go, which this does not touch.

* refactor: route the remaining fire-and-forget vpc.* producers through projection

CreateVPC/DeleteVPC, CreateSubnet/DeleteSubnet, AttachInternetGateway/
DetachInternetGateway, AddNATGateway/RemoveNATGateway, AddIGWRoute/
RemoveIGWRoute, GateSubnetEgress/UngateSubnetEgress and RemoveSystemEgress
were still publishing through natsmsg.PublishEvent or a raw nc.Publish
call with a bare subject string, left over from the mechanical slice 3
pass. Each now goes through its named projection.Client method instead,
so every vpc.* producer in the tree uses the typed client.

* refactor: move EBS encryption posture to domains/ec2/ebs/encryption and delete utils

The master-key loader was used only by EC2 image, instance and volume to
derive the AWS-visible Encrypted flag. It becomes ebsencryption.Enabled,
EC2's temporary reading of the provider's key-file setting; the cache,
empty-path meaning, error text and each caller's error handling are
unchanged (image still reports unencrypted when the key will not load).
Its successor is a providers/ebs capability reporting volume encryption
posture.

utils/ is now empty and deleted. The layering test drops utils and
services from the legacy roots, removes their allowlist entries and fails
if either retired root regains files. The S7 subject scan follows the
vpc.* literals from utils to contracts/network so its gap list stays
complete.

* docs: record the network contract, projection client and utils removal

* test: characterise instance-state persistence, recovery and generation handling

Pin the current local-file bytes and schema failure modes, the instance
bucket configuration, record key and wire form, the bucket version gate,
generation preservation across every KV writer and the VM conversion, and
what a returning node does for a missing, foreign, terminal or unreadable
canonical record. These describe current behaviour, not the ADR-0007 target.

* test: freeze direct instance-state consumers

Add an AST ratchet over production code that fails when a new file names
the daemon's instance-state bucket, record keys, local-file API, the raw
vm.InstanceRecord or its conversion, or a JetStreamManager instance-state
method. Today's 73 uses form a shrink-only allowlist keyed to the slice 0
inventory; stale entries fail, and a synthetic-source self-test proves the
matcher detects each kind of use and ignores declarations.

* docs: record ADR-0007 slice 0 instance-state inventory

Record the instance-state persistence formats, the nineteen direct
consumers by process and access kind, the characterised recovery and
schema outcomes, the generation-preservation matrix and the ratchet that
freezes the consumer set. Nothing conforms to ADR-0007 yet and vpcd stays
blocked.

* test: narrow the instance-state ratchet to instance-state access

Stop freezing the JetStreamManager type and constructor, which also serve
cluster state; flag only instance-state methods, bucket and key identifiers,
record codecs and local-state access. The cluster-state-only consumers in
daemon_mgmt_ip.go and operator/cli/cluster.go leave the allowlist (73 to 67).
The self-test now proves a cluster-state use is ignored and an instance-state
method call is caught. Record the owner's disposition of the KV-unavailable
restore result in the migration record.

* docs: record the narrowed instance-state ratchet commit

* docs: design the instance-state transition record and activation gate

* docs: record the instance-state transition-record design commit

* docs: record slice 1a activation-gate decisions

* feat: add instance transition-record codec with format marker

Adds DecodeTransitionRecord and TransitionRecord.Encode in the record's owner
package. A top-level format member selects the shape: absent or 1 decodes the
legacy record and is never given a marker on write; 2 also decodes the new
assignment and observation sections; an integer above 2 decodes its legacy
fields read-only and Encode refuses it; anything else is refused on decode.

Encode applies only the typed paths that differ from the decode-time snapshot
onto the input, so unknown members at every object level and unchanged known
fields keep their read form. A changed array is replaced whole. Decode refuses
malformed JSON, duplicate keys at any depth, keys that alias a known field by
case, trailing data and a non-object record, each with its own sentinel.

No production reader or writer uses the codec yet; the new types are not
part of vm.InstanceRecord, so every existing record encodes unchanged. The
codec file is registered as an owner file of the instance-state ratchet.

* docs: record slice 1b step 1 codec and awsgw rollout qualification

Records the codec added in e62f921d2 as design Section 10 (C1 to C7): its
surface, preservation at every object level, the changed-array limitation,
marker handling, the rejection classes and its capable-writers-only scope. C2
supersedes P1's top-level-only preservation for the codec. G19 records that
old read-only gateway consumers remain safe but DescribeInstanceStatus gains
unconfirmed/degraded visibility only after awsgw upgrades. The migration record
gains the commit row and states that no production path uses the codec yet.

* refactor: move the instance transition-record codec to domains/ec2/instance/state

The codec defines how the canonical persisted instance record evolves, with
its assignment and observation sections, which is EC2 instance ownership rather
than node-local VM supervision. It moves unchanged into its own package and
keeps wrapping the legacy vm.InstanceRecord alias and its spec and status
types, which stay in runtime/compute/vm for now.

The domain-to-runtime import is recorded as new dependency-rule debt with its
removal condition and allow-listed against that entry. The instance-state
ratchet now names the new file as an owner file; its allowlist is unchanged.

* fix: refuse array changes that would drop element extensions

A changed array is replaced whole by the writer's value, which silently lost
any member a newer writer had added inside its elements. Encode now inspects
the array as read: if any element carries a member its typed schema does not
know, at any depth, it returns ErrRecordExtensionConflict naming the array and
the first such member. Arrays without such members are still replaced whole,
unchanged arrays are still kept verbatim, and arrays inside unknown members are
never inspected.

* docs: record the codec move and the array extension-conflict rule

Records 8db9771fb and c617914a1. T1 and Section 10 now name
domains/ec2/instance/state as the eventual owner of the persisted instance
record and its evolution, with vm.InstanceRecord as the temporary legacy type
it wraps. C3 replaces the changed-array limitation with the refusal: a changed
array with element extensions fails with ErrRecordExtensionConflict and is never
silently dropped. P2 stays a conservative rule until finalisation.

* fix: refuse removals that would drop record extension fields

A capable writer may no longer remove or replace a known object, map entry or
array when its stored value carries members the writer's schema does not know.
The check that guarded changed arrays now runs wherever the merge drops or
overwrites a stored value instead of merging into it: a known member deleted or
set to null, a map entry removed or stored under a key the typed decode
rewrote, a value of another JSON kind, and a changed array. Encode returns
ErrRecordExtensionConflict naming the changed container and the first unknown
member. Removal stays allowed when the stored value has no extensions, and a
change merged member by member still keeps unknown members.

* docs: record the uniform extension-conflict rule

Adds design clause C8 for 0d7b4266d: a capable writer may not remove or
replace a known object, map entry or array whose stored value carries unknown
structured extension fields; removal without extensions stays allowed. C3 and
C5 point at it. The migration record gains the commit row, updated subsection
wording and a pointer-bump follow-up deferring make docs-portal to the Mulga
gitlink PR so the portal evaluates the proposed Spinifex revision.

* refactor: inject vpcd's IMDS instance-state readers from the instance state boundary

* docs: cite the vpcd reader injection commit in the migration record

* refactor: move vpcd to runtime/roles/vpcd

* docs: cite the vpcd move commit in the migration record

* refactor: move the ECS agent wire vocabulary to contracts/ecs/v1

Move handlers/ecs/bus to contracts/ecs/v1 (package ecsv1) with no legacy
shim. Subject strings, JSON fields and types are byte-identical; the old-agent
compatibility test moves unchanged and golden pins cover the remaining
message types. The README route table records that only service-reconcile
has a live producer today; the agent reuses these payload types through the
gateway actions. service-interfaces.yaml lists ecs.bus.* under the daemon.

* refactor: move system-image promotion to domains/ec2/image

Move images_promote.go and its tests from admin to domains/ec2/image:
SystemOwnerAlias, PromoteImageOpts/PromoteImageResult, PromoteSystemImage,
GetAMIMetadata and the private snapshot reader. Storage, ordering, errors and
promotion behaviour are unchanged; the daemon, gateway/spx and operator CLI
callers import ec2image. admin now holds only account IDs and key generators.

* docs: record the ECS contract and image-promotion moves and the remaining-roots matrix

* refactor: move the bootstrap account identifiers to foundation/aws/identifiers

DefaultAccountID and DefaultAccountName join GlobalAccountID as the fixed,
platform-reserved identity of the first human-facing bootstrap account.
Names and values are unchanged and stay fixed. Callers in daemon, gateway,
awsgw, accountteardown and the operator CLI use awsidentifiers; admin now
holds only the IAM credential generators.

* docs: cite the bootstrap account identifier move in the migration record

* refactor: inject the STS cluster JWKS lookup from handlers/eks

handlers/sts declares ClusterJWKSResolver and keeps JWKS decoding and
InvalidIdentityToken mapping; handlers/eks implements it as
OIDCJWKSReader beside the bucket and key layout helpers. awsgw and the
integration harness inject the reader. STS no longer imports any EKS
package, including tests, and the handlers/sts -> handlers/eks layering
exception is removed.

* docs: cite the STS JWKS resolver cut in the migration record

* refactor: move the AWS credential-format generators to foundation/aws/credentials and retire admin

GenerateAWSAccessKey and GenerateAWSSecretKey move unchanged to
foundation/aws/credentials as a stateless format primitive. handlers/iam
and operator/cli import it directly. The admin legacy root is deleted
and joins the retired roots so it cannot return.

* docs: cite the admin retirement in the migration record

* revert: remove the uncalled ADR-0007 codec and design stack from the ADR-0001 checkpoint

The instance transition-record codec, its tests and the slice 1a design
had no production caller. They leave this package-boundary checkpoint
and will be reapplied on a dedicated ADR-0007 branch. Slice 0
characterisation and the vpcd IMDS readers in
domains/ec2/instance/state stay.

* docs: cite the ADR-0007 codec revert in the migration record

* fix: declare the ECS action subjects in service-interfaces.yaml

Declaring ecs.bus.* made ecs a managed prefix for manifest-lint, which then
failed on the 34 ECS action subjects the daemon registers. Declare them as
ecs.*, the same wildcard convention as rds.*.

* chore: run manifest-check and manifest-lint in preflight

CI runs both, but preflight did not, so manifest drift could pass local
checks and still fail Build and Test.

* docs: record resumed preflight enforcement and dev merge ports

* fix: restore dev changes dropped by earlier merges

Reapplies the slices.ContainsFunc rewrite in nodeFitsCustomerInstance and the DeleteDBSubnetGroup doc comment, both lost when their files were refactored.

* docs: record accountteardown as a frozen legacy root pending cleanup capabilities

---------

Co-authored-by: Julian Sommer <115516918+juliansommer@users.noreply.github.com>
2026-10-15 14:42:53 +10:00
Julian Sommer 0f025e8332 chore: add lambda to roadmap 2026-10-15 14:10:53 +10:00
Julian Sommer 4c4ca253b4 fix(ci): grant contents: write only to the release jobs that edit the release
The release workflow granted contents: write to every job, including the distro builds and the self-hosted verify job, which only read. Only release, promote, mark-failed and iso create or edit the GitHub release, so they alone get write and the rest default to read.
2026-10-15 13:18:16 +10:00
Julian Sommer 3a3807f08e fix(ci): build releases without the shared Go module cache
setup-go caches by default, and a release or dev prerelease run restores caches that any dev or main run saved. A poisoned module cache from one of those runs would then flow into the shipped binaries, so both release builds now start from a clean cache.
2026-10-15 13:16:59 +10:00
Josh Siviour 26f12e7107 fix: prove volume holder absent by node identity before teardown force-delete (#1208)
Account teardown's force path cleared a volume's attachment and deleted it
without proving the holding instance gone, and the delete guard judged its
instance view complete by counting replies, so one node answering twice
could stand in for the silent node hosting the guest.

The holder must now be terminated or definitively InvalidInstanceID.NotFound
on a describe naming it, confirmed on a listing every configured node
answered by identity. Anything else retains the volume, reports it stuck
naming the volume and instance, and keeps the account TERMINATING.

DeleteVolume's in-use guard takes the configured node set and uses the same
identity-checked listing, so public DeleteVolume also refuses while any
configured node is unanswered. That refusal keeps the listing's retryable
ServiceUnavailable and Retry-After; other guard failures stay InternalError.
2026-10-15 13:13:36 +10:00
Julian Sommer 5865fc418e chore(ci): lock workflow action dependencies with actions.lock (#1207) 2026-10-15 13:55:16 +11:00
Julian Sommer 02c02f6632 chore: pin base images by digest and let dependabot refresh them 2026-10-15 11:42:56 +10:00
Julian Sommer 9075b13fe8 fix(ci): pin go-junit-report through go.mod instead of installing @latest 2026-10-15 11:42:52 +10:00
Julian Sommer 600fb41012 fix(ci): fail UI CI when the committed dist differs from a clean build 2026-10-15 11:21:35 +10:00
Julian Sommer 236dd2e87d fix(ci): build releases and system AMIs against the sibling tags go.mod requires 2026-10-15 11:19:37 +10:00
Julian Sommer 96bcb4746d fix(ci): never run the mkosi image job for fork PRs on self-hosted runners 2026-10-15 11:09:44 +10:00
Julian Sommer 316a1b7c03 refactor(spinifexui): replace clsx and tailwind-merge with cn (#1206) 2026-10-15 11:50:21 +11:00
Julian Sommer 7fdabe1207 refactor(spinifexui): enforce shadcn no-restyle with component variants (#1205) 2026-10-15 11:37:03 +11:00
Julian Sommer 9498481161 refactor(spinifexui): remove dead popup animation classes (#1204) 2026-10-15 11:28:46 +11:00
Josh Siviour f3bebacbe5 fix: fail closed on incomplete instance inventory during account teardown (#1200)
Account teardown listed instances account-wide through a path that returns
whatever nodes answered. A silent or erroring node, a node answering twice,
or a failed instance-bucket query gave a short list with no error, which
teardown counted as drained.

Add DescribeAccountInstancesComplete, which requires a clean reply from
every configured node by node ID plus both instance buckets, and otherwise
returns a retryable ServiceUnavailable. Thread the configured node set into
the teardown engine from the admin API and the CLI. The engine already
aborts on a listing error, so the account stays TERMINATING and storage
teardown does not run.
2026-10-15 10:23:22 +10:00
Julian Sommer 2a19384fe1 feat(spinifexui): enable @shadcn/lint design-system rules (#1203) 2026-10-15 11:17:27 +11:00
Julian Sommer b6e50c488c fix(spinifexui): guard admin routes in beforeLoad so a full page load renders (#1202) 2026-10-15 10:50:58 +11:00
Julian Sommer 5ad2346b3b chore: add contribution license checkbox to PR template 2026-10-15 09:12:25 +10:00
Julian Sommer 4981828497 docs: private-CA trust for containerised tooling pushing to ECR 2026-10-15 09:03:20 +10:00
Julian Sommer 838de354c2 fix: keep the bootstrap 403 visible to heartbeats in rds-agent test
The fake control plane returned the scripted 403 once, then failed every
retry with "returned no config", overwriting the failure the heartbeat
reports. The test only passed if a beat landed within one 1ms retry
interval. A persistent fake error keeps the 403 on every retry.
2026-10-15 09:00:04 +10:00
Julian Sommer 29aef59dae fix: enforce HttpPutResponseHopLimit on the IMDSv2 token response (#1201) 2026-10-15 09:52:10 +11:00
Julian Sommer 6644e6dc9f test: isolate JetStream recovery tests and parallelise slow daemon tests 2026-10-14 14:03:33 +10:00
Julian Sommer a0b4d840e2 test: use 1024-bit keys and parallelise ACM handler tests 2026-10-14 13:54:15 +10:00
Julian Sommer bec3a78167 test: use 1024-bit signing key in OCI api client tests 2026-10-14 13:51:48 +10:00
Julian Sommer 966a28f546 fix: reject key-pair names that traverse out of the caller's keys (#1199) 2026-10-14 14:15:35 +11:00
Julian Sommer c861642d44 fix: require iam:PassRole on the role in AddRoleToInstanceProfile (#1198) 2026-10-14 14:00:29 +11:00
Julian Sommer 72d234e95a fix: bound RunInstances counts and make vCPU quota arithmetic overflow-safe (#1197) 2026-10-14 12:46:44 +11:00
Julian Sommer c09f0b5f11 fix: bound per-tap IMDS server timeouts and connections (#1196)
Set read, write and idle timeouts and a header cap on each per-tap IMDS
http.Server, and cap its listener at 32 connections, so one guest cannot
hold unbounded vpcd descriptors and goroutines with idle keep-alives.
2026-10-14 12:39:34 +11:00
Julian Sommer bc51816ad8 docs: rename site badge and drop Docs index link 2026-10-14 10:53:18 +10:00
Julian Sommer ebc36d6f0c chore: cleanup stale scripts 2026-10-14 10:39:38 +10:00
Ben Duncan 261b695e53 docs: document disabling overlay IPsec for trusted fabrics
The VPC networking guide had no mention of IPsec, so an operator reading it
had no way to discover that the geneve overlay is encrypted by default or
what turning that off is worth. Add a section covering the default-on
posture, the topologies where disabling is and is not appropriate, the
measured cost on the ConnectX-5 reference cluster, and the full procedure.

Correct the performance-tuning guide's claim that a manual
"ovn-nbctl set NB_Global . ipsec=false" is transient because the daemon
reconciles it. That holds for the enable direction only: ReconcileOVNIPSec
returns early when IPsec is disabled, so it stops requesting encryption but
never clears NB_Global.ipsec or other_config:ipsec_encapsulation. Both are
left reading true, which makes the control plane disagree with the config.
Document the manual steps as a required complement to the config change.
2026-10-14 08:09:51 +10:00
Julian Sommer e4d90b30d3 fix(spinifexui): end the session in every tab on sign-out or sign-in 2026-10-14 08:09:51 +10:00
Julian Sommer bf3b830152 fix(spinifexui): refresh the role page after instance profile membership changes
Adding a role to an instance profile, removing it, or deleting the profile
only refreshed the profile list, so the role page kept its cached profiles
for up to five minutes.
2026-10-14 08:09:51 +10:00
Julian Sommer b087d5d469 fix(spinifexui): confirm destructive route, rule, gateway, EIP and version actions
Deleting a route, disassociating a subnet or Elastic IP, revoking a
security-group rule, detaching an internet gateway and deleting a launch
template version each ran on one click. They now open a confirmation
dialog, and a failed launch template version delete is reported instead of
silently leaving the row.
2026-10-14 08:09:51 +10:00
Julian Sommer fd9d69f05b fix(spinifexui): delete the launch-wizard security group when a launch fails
A failed RunInstances or ingress authorize left launch-wizard-N behind, so
the retry, which reuses the same default name, failed with
InvalidGroup.Duplicate. The security group list now also refreshes after a
failed launch.
2026-10-14 08:09:51 +10:00
Julian Sommer 75471404b6 fix(spinifexui): validate the VPC wizard's real subnet plan and report every created subnet
The wizard validated only custom CIDRs, so a default plan that overran a small
VPC, or a custom CIDR overlapping a default, passed the form and left a
half-built VPC. Validation, the mutation, the preview and the CLI panel now
share one resolved CIDR list, and subnet errors are shown.

Subnets are created with allSettled so a failure still reports every subnet
that was made, and the calculator packs private subnets after the public ones
when the VPC midpoint leaves no room.
2026-10-14 08:09:51 +10:00
Julian Sommer 848a662aac fix(spinifexui): accept /0 to /32 for the launch wizard's custom SSH source
The custom CIDR used isValidCidr's VPC defaults of /16 to /28, so a single-IP
source such as 203.0.113.5/32, 10.0.0.0/8 and 0.0.0.0/0 were all rejected.
AWS accepts any prefix for a security-group rule source.

Rebuilds frontend/dist, which also picks up the signature-error logout fix.
2026-10-14 08:09:51 +10:00
Julian Sommer ab45ea84ad fix(spinifexui): stop logging out on signature errors
SignatureDoesNotMatch and IncompleteSignature no longer clear the session:
clock skew and path rewrites also produce them, so the user was logged out
repeatedly over a request problem.
2026-10-14 08:09:51 +10:00
Julian Sommer f50630ed4b fix(spinifexui): stop silent connections blocking the TLS split listener
Accept read the protocol-detection byte inline with a 5s deadline, so each
client that sent nothing stalled every new visitor. Accept now returns the
TLS-wrapped conn immediately and the first byte is sniffed on the first
handshake Read, in the connection's own goroutine.
2026-10-14 08:09:51 +10:00
Julian Sommer abb3da8af2 chore: keep only the newest 5 dev prereleases 2026-10-14 08:09:51 +10:00
Julian Sommer 7e008c4689 fix: read single-host predastore health from /readyz in Service Metrics (#1195) 2026-10-14 08:09:51 +10:00
Julian Sommer d91312d2f9 fix: return NotFound/Malformed for unknown instance IDs on lifecycle verbs (#1194) 2026-10-14 08:09:51 +10:00
Josh Siviour c5d96ee56c fix: preserve ACM private key on certificate tag changes (#1193) 2026-10-14 08:09:51 +10:00
Julian Sommer 788483807f fix: golangci-lint version in CONTRIBUTING.md 2026-10-14 08:09:51 +10:00
Julian Sommer 429c5f13e5 fix: README 2026-10-12 10:31:44 +10:00
Julian Sommer 49d3990252 fix: update README 2026-10-12 10:13:11 +10:00
Julian Sommer dbcda95b3c chore: remove docs dotpoint 2026-10-12 09:24:11 +10:00
Julian Sommer 562e19aeca fix: remove coverage badge 2026-10-12 09:23:11 +10:00
Julian Sommer 19c35326a3 chore: fix lint issues 2026-10-12 08:58:04 +10:00
Julian Sommer 68b1e18c72 chore: bump golangci-lint to v2.14.0 for Go 1.27.2 export data 2026-10-12 08:32:43 +10:00
dependabot[bot] 9f9b7be9f7 chore: bump the go-dependencies group across 1 directory with 12 updates (#1191)
Bumps the go-dependencies group with 12 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [github.com/aws/aws-sdk-go-v2/config](https://github.com/aws/aws-sdk-go-v2) | `1.33.6` | `1.33.7` |
| [github.com/aws/aws-sdk-go-v2/credentials](https://github.com/aws/aws-sdk-go-v2) | `1.20.6` | `1.20.7` |
| [github.com/oracle/oci-go-sdk/v65](https://github.com/oracle/oci-go-sdk) | `65.126.1` | `65.126.2` |
| [go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp](https://github.com/open-telemetry/opentelemetry-go-contrib) | `0.71.0` | `0.72.0` |
| [go.opentelemetry.io/otel](https://github.com/open-telemetry/opentelemetry-go) | `1.46.0` | `1.47.0` |
| [go.opentelemetry.io/otel/metric](https://github.com/open-telemetry/opentelemetry-go) | `1.46.0` | `1.47.0` |
| [go.opentelemetry.io/otel/sdk](https://github.com/open-telemetry/opentelemetry-go) | `1.46.0` | `1.47.0` |
| [go.opentelemetry.io/otel/sdk/metric](https://github.com/open-telemetry/opentelemetry-go) | `1.46.0` | `1.47.0` |
| [go.opentelemetry.io/otel/trace](https://github.com/open-telemetry/opentelemetry-go) | `1.46.0` | `1.47.0` |
| [golang.org/x/sync](https://github.com/golang/sync) | `0.23.0` | `0.24.0` |
| [golang.org/x/sys](https://github.com/golang/sys) | `0.48.0` | `0.49.0` |
| [golang.org/x/tools](https://github.com/golang/tools) | `0.50.0` | `0.51.0` |



Updates `github.com/aws/aws-sdk-go-v2/config` from 1.33.6 to 1.33.7
- [Release notes](https://github.com/aws/aws-sdk-go-v2/releases)
- [Commits](https://github.com/aws/aws-sdk-go-v2/compare/config/v1.33.6...config/v1.33.7)

Updates `github.com/aws/aws-sdk-go-v2/credentials` from 1.20.6 to 1.20.7
- [Release notes](https://github.com/aws/aws-sdk-go-v2/releases)
- [Commits](https://github.com/aws/aws-sdk-go-v2/compare/service/mq/v1.20.6...service/mq/v1.20.7)

Updates `github.com/oracle/oci-go-sdk/v65` from 65.126.1 to 65.126.2
- [Release notes](https://github.com/oracle/oci-go-sdk/releases)
- [Changelog](https://github.com/oracle/oci-go-sdk/blob/master/CHANGELOG.md)
- [Commits](https://github.com/oracle/oci-go-sdk/compare/v65.126.1...v65.126.2)

Updates `go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp` from 0.71.0 to 0.72.0
- [Release notes](https://github.com/open-telemetry/opentelemetry-go-contrib/releases)
- [Changelog](https://github.com/open-telemetry/opentelemetry-go-contrib/blob/main/CHANGELOG.md)
- [Commits](https://github.com/open-telemetry/opentelemetry-go-contrib/compare/zpages/v0.71.0...zpages/v0.72.0)

Updates `go.opentelemetry.io/otel` from 1.46.0 to 1.47.0
- [Release notes](https://github.com/open-telemetry/opentelemetry-go/releases)
- [Changelog](https://github.com/open-telemetry/opentelemetry-go/blob/main/CHANGELOG.md)
- [Commits](https://github.com/open-telemetry/opentelemetry-go/compare/v1.46.0...v1.47.0)

Updates `go.opentelemetry.io/otel/metric` from 1.46.0 to 1.47.0
- [Release notes](https://github.com/open-telemetry/opentelemetry-go/releases)
- [Changelog](https://github.com/open-telemetry/opentelemetry-go/blob/main/CHANGELOG.md)
- [Commits](https://github.com/open-telemetry/opentelemetry-go/compare/v1.46.0...v1.47.0)

Updates `go.opentelemetry.io/otel/sdk` from 1.46.0 to 1.47.0
- [Release notes](https://github.com/open-telemetry/opentelemetry-go/releases)
- [Changelog](https://github.com/open-telemetry/opentelemetry-go/blob/main/CHANGELOG.md)
- [Commits](https://github.com/open-telemetry/opentelemetry-go/compare/v1.46.0...v1.47.0)

Updates `go.opentelemetry.io/otel/sdk/metric` from 1.46.0 to 1.47.0
- [Release notes](https://github.com/open-telemetry/opentelemetry-go/releases)
- [Changelog](https://github.com/open-telemetry/opentelemetry-go/blob/main/CHANGELOG.md)
- [Commits](https://github.com/open-telemetry/opentelemetry-go/compare/v1.46.0...v1.47.0)

Updates `go.opentelemetry.io/otel/trace` from 1.46.0 to 1.47.0
- [Release notes](https://github.com/open-telemetry/opentelemetry-go/releases)
- [Changelog](https://github.com/open-telemetry/opentelemetry-go/blob/main/CHANGELOG.md)
- [Commits](https://github.com/open-telemetry/opentelemetry-go/compare/v1.46.0...v1.47.0)

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

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

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

---
updated-dependencies:
- dependency-name: github.com/aws/aws-sdk-go-v2/config
  dependency-version: 1.33.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go-dependencies
- dependency-name: github.com/aws/aws-sdk-go-v2/credentials
  dependency-version: 1.20.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go-dependencies
- dependency-name: github.com/oracle/oci-go-sdk/v65
  dependency-version: 65.126.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: go-dependencies
- dependency-name: go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp
  dependency-version: 0.72.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-dependencies
- dependency-name: go.opentelemetry.io/otel
  dependency-version: 1.47.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-dependencies
- dependency-name: go.opentelemetry.io/otel/metric
  dependency-version: 1.47.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-dependencies
- dependency-name: go.opentelemetry.io/otel/sdk
  dependency-version: 1.47.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-dependencies
- dependency-name: go.opentelemetry.io/otel/sdk/metric
  dependency-version: 1.47.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-dependencies
- dependency-name: go.opentelemetry.io/otel/trace
  dependency-version: 1.47.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-dependencies
- dependency-name: golang.org/x/sync
  dependency-version: 0.24.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-dependencies
- dependency-name: golang.org/x/sys
  dependency-version: 0.49.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-dependencies
- dependency-name: golang.org/x/tools
  dependency-version: 0.51.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: go-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Julian Sommer <115516918+juliansommer@users.noreply.github.com>
2026-10-12 09:22:17 +11:00
Julian Sommer 68d3018b3e chore: update golang.org/x/net 2026-10-12 08:15:03 +10:00
Julian Sommer 1a24217b2c chore: rebuild frontend 2026-10-12 08:08:49 +10:00
dependabot[bot] c09f15b50f chore: bump the npm-dependencies group (#1192)
Bumps the npm-dependencies group in /spinifex/services/spinifexui/frontend with 25 updates:

| Package | From | To |
| --- | --- | --- |
| [@aws-sdk/client-acm](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-acm) | `3.1144.0` | `3.1147.0` |
| [@aws-sdk/client-bedrock](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-bedrock) | `3.1144.0` | `3.1147.0` |
| [@aws-sdk/client-bedrock-agent](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-bedrock-agent) | `3.1144.0` | `3.1147.0` |
| [@aws-sdk/client-bedrock-agent-runtime](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-bedrock-agent-runtime) | `3.1144.0` | `3.1147.0` |
| [@aws-sdk/client-bedrock-runtime](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-bedrock-runtime) | `3.1144.0` | `3.1147.0` |
| [@aws-sdk/client-ec2](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-ec2) | `3.1144.0` | `3.1147.0` |
| [@aws-sdk/client-ecr](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-ecr) | `3.1144.0` | `3.1147.0` |
| [@aws-sdk/client-ecs](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-ecs) | `3.1144.0` | `3.1147.0` |
| [@aws-sdk/client-eks](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-eks) | `3.1144.0` | `3.1147.0` |
| [@aws-sdk/client-elastic-load-balancing-v2](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-elastic-load-balancing-v2) | `3.1144.0` | `3.1147.0` |
| [@aws-sdk/client-iam](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-iam) | `3.1144.0` | `3.1147.0` |
| [@aws-sdk/client-rds](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-rds) | `3.1144.0` | `3.1147.0` |
| [@aws-sdk/client-s3](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-s3) | `3.1144.0` | `3.1147.0` |
| [@aws-sdk/client-sts](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/clients/client-sts) | `3.1144.0` | `3.1147.0` |
| [@aws-sdk/lib-storage](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/lib/lib-storage) | `3.1144.0` | `3.1147.0` |
| [@tanstack/react-query](https://github.com/TanStack/query/tree/HEAD/packages/react-query) | `5.104.0` | `5.104.1` |
| [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) | `1.49.0` | `1.52.0` |
| [@tanstack/eslint-plugin-query](https://github.com/TanStack/query/tree/HEAD/packages/eslint-plugin-query) | `5.104.0` | `5.104.1` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `24.19.0` | `24.19.1` |
| [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) | `6.1.1` | `6.1.2` |
| [oxc-transform-react](https://github.com/oxc-project/oxc/tree/HEAD/napi/transform-react) | `0.152.0` | `0.153.0` |
| [oxfmt](https://github.com/oxc-project/oxc/tree/HEAD/npm/oxfmt) | `0.71.0` | `0.72.0` |
| [oxlint](https://github.com/oxc-project/oxc/tree/HEAD/npm/oxlint) | `1.86.0` | `1.87.0` |
| [ultracite](https://github.com/haydenbleasel/ultracite) | `7.12.2` | `7.12.4` |
| [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) | `8.3.1` | `8.3.3` |


Updates `@aws-sdk/client-acm` from 3.1144.0 to 3.1147.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-acm/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1147.0/clients/client-acm)

Updates `@aws-sdk/client-bedrock` from 3.1144.0 to 3.1147.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-bedrock/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1147.0/clients/client-bedrock)

Updates `@aws-sdk/client-bedrock-agent` from 3.1144.0 to 3.1147.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-bedrock-agent/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1147.0/clients/client-bedrock-agent)

Updates `@aws-sdk/client-bedrock-agent-runtime` from 3.1144.0 to 3.1147.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-bedrock-agent-runtime/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1147.0/clients/client-bedrock-agent-runtime)

Updates `@aws-sdk/client-bedrock-runtime` from 3.1144.0 to 3.1147.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-bedrock-runtime/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1147.0/clients/client-bedrock-runtime)

Updates `@aws-sdk/client-ec2` from 3.1144.0 to 3.1147.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-ec2/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1147.0/clients/client-ec2)

Updates `@aws-sdk/client-ecr` from 3.1144.0 to 3.1147.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-ecr/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1147.0/clients/client-ecr)

Updates `@aws-sdk/client-ecs` from 3.1144.0 to 3.1147.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-ecs/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1147.0/clients/client-ecs)

Updates `@aws-sdk/client-eks` from 3.1144.0 to 3.1147.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-eks/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1147.0/clients/client-eks)

Updates `@aws-sdk/client-elastic-load-balancing-v2` from 3.1144.0 to 3.1147.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-elastic-load-balancing-v2/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1147.0/clients/client-elastic-load-balancing-v2)

Updates `@aws-sdk/client-iam` from 3.1144.0 to 3.1147.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-iam/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1147.0/clients/client-iam)

Updates `@aws-sdk/client-rds` from 3.1144.0 to 3.1147.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-rds/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1147.0/clients/client-rds)

Updates `@aws-sdk/client-s3` from 3.1144.0 to 3.1147.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-s3/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1147.0/clients/client-s3)

Updates `@aws-sdk/client-sts` from 3.1144.0 to 3.1147.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/clients/client-sts/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1147.0/clients/client-sts)

Updates `@aws-sdk/lib-storage` from 3.1144.0 to 3.1147.0
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/lib/lib-storage/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1147.0/lib/lib-storage)

Updates `@tanstack/react-query` from 5.104.0 to 5.104.1
- [Release notes](https://github.com/TanStack/query/releases)
- [Changelog](https://github.com/TanStack/query/blob/main/packages/react-query/CHANGELOG.md)
- [Commits](https://github.com/TanStack/query/commits/@tanstack/react-query@5.104.1/packages/react-query)

Updates `lucide-react` from 1.49.0 to 1.52.0
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.52.0/packages/lucide-react)

Updates `@tanstack/eslint-plugin-query` from 5.104.0 to 5.104.1
- [Release notes](https://github.com/TanStack/query/releases)
- [Changelog](https://github.com/TanStack/query/blob/main/packages/eslint-plugin-query/CHANGELOG.md)
- [Commits](https://github.com/TanStack/query/commits/@tanstack/eslint-plugin-query@5.104.1/packages/eslint-plugin-query)

Updates `@types/node` from 24.19.0 to 24.19.1
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `@vitejs/plugin-react` from 6.1.1 to 6.1.2
- [Release notes](https://github.com/vitejs/vite-plugin-react/releases)
- [Changelog](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite-plugin-react/commits/plugin-react@6.1.2/packages/plugin-react)

Updates `oxc-transform-react` from 0.152.0 to 0.153.0
- [Release notes](https://github.com/oxc-project/oxc/releases)
- [Changelog](https://github.com/oxc-project/oxc/blob/main/napi/transform-react/CHANGELOG.md)
- [Commits](https://github.com/oxc-project/oxc/commits/crates_v0.153.0/napi/transform-react)

Updates `oxfmt` from 0.71.0 to 0.72.0
- [Release notes](https://github.com/oxc-project/oxc/releases)
- [Changelog](https://github.com/oxc-project/oxc/blob/main/npm/oxfmt/CHANGELOG.md)
- [Commits](https://github.com/oxc-project/oxc/commits/oxfmt_v0.72.0/npm/oxfmt)

Updates `oxlint` from 1.86.0 to 1.87.0
- [Release notes](https://github.com/oxc-project/oxc/releases)
- [Changelog](https://github.com/oxc-project/oxc/blob/main/npm/oxlint/CHANGELOG.md)
- [Commits](https://github.com/oxc-project/oxc/commits/oxlint_v1.87.0/npm/oxlint)

Updates `ultracite` from 7.12.2 to 7.12.4
- [Release notes](https://github.com/haydenbleasel/ultracite/releases)
- [Commits](https://github.com/haydenbleasel/ultracite/compare/ultracite@7.12.2...ultracite@7.12.4)

Updates `vite` from 8.3.1 to 8.3.3
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/main/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v8.3.3/packages/vite)

---
updated-dependencies:
- dependency-name: "@aws-sdk/client-acm"
  dependency-version: 3.1147.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@aws-sdk/client-bedrock"
  dependency-version: 3.1147.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@aws-sdk/client-bedrock-agent"
  dependency-version: 3.1147.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@aws-sdk/client-bedrock-agent-runtime"
  dependency-version: 3.1147.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@aws-sdk/client-bedrock-runtime"
  dependency-version: 3.1147.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@aws-sdk/client-ec2"
  dependency-version: 3.1147.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@aws-sdk/client-ecr"
  dependency-version: 3.1147.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@aws-sdk/client-ecs"
  dependency-version: 3.1147.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@aws-sdk/client-eks"
  dependency-version: 3.1147.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@aws-sdk/client-elastic-load-balancing-v2"
  dependency-version: 3.1147.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@aws-sdk/client-iam"
  dependency-version: 3.1147.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@aws-sdk/client-rds"
  dependency-version: 3.1147.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@aws-sdk/client-s3"
  dependency-version: 3.1147.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@aws-sdk/client-sts"
  dependency-version: 3.1147.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@aws-sdk/lib-storage"
  dependency-version: 3.1147.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@tanstack/react-query"
  dependency-version: 5.104.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: lucide-react
  dependency-version: 1.52.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: "@tanstack/eslint-plugin-query"
  dependency-version: 5.104.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: "@types/node"
  dependency-version: 24.19.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: "@vitejs/plugin-react"
  dependency-version: 6.1.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: oxc-transform-react
  dependency-version: 0.153.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: oxfmt
  dependency-version: 0.72.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: oxlint
  dependency-version: 1.87.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: npm-dependencies
- dependency-name: ultracite
  dependency-version: 7.12.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
- dependency-name: vite
  dependency-version: 8.3.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: npm-dependencies
...

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

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

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

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

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

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

* docs: look the OCI compartment OCID up by name

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

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

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

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

This reverts commit 5443149cce.

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

This reverts commit 1535d94c8a.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* test: cover the subnet read and the authorisation verdict

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Two defects found while proving it:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

A KV bucket created without a replica count gets one replica, and one
replica lives on one JetStream-chosen server recorded in no config.
Losing, restarting or partitioning that server makes the bucket return
"no responders available" from every node at once, so a single-node event
becomes a cluster-wide outage. The reconciler leader-lease buckets were
the worst case: acquiring a lease is a write, so a lease bucket without a
quorum cannot be acquired by anyone, and every reconciler sharing it stops
cluster-wide at exactly the moment a node has failed.

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

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

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

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

* chore: name cell 33 in the nightly dispatch input

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* feat: recover instances automatically with nothing to configure

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: finish a fence once its writer finally exits

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

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

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

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

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

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

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

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


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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* feat: allocate external IPs from the Oracle Cloud API

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* docs: index what each e2e suite proves

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Beads: mulga-opyl7, mulga-la0pe

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* docs(config): list nat among external_mode values

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Closes mulga-0bqih.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* fix: make plain-.xz decompression sparse too

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

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

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

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

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

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

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

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

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

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

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

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

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

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

The envelope was also always XML, because jsonErrorService only lists
scopes the gateway serves and an unserved scope is by definition absent
from it. A JSON-protocol caller such as sqs or dynamodb therefore could
not deserialize the body and never saw the true error. writeSigV4Error now
falls back to the request's own X-Amz-Target and Content-Type headers to
pick JSON when the scope is not in that list, leaving every served scope's
envelope selection unchanged.
2026-09-16 14:01:13 +10:00
4630 changed files with 314873 additions and 217416 deletions
+6 -6
View File
@@ -6,10 +6,10 @@
# Set the matching branch protection rule on `dev` and `main` to
# "Require review from Code Owners" or this file is advisory only.
# Network layer is governed by ADR-0006 invariant tests.
# Network layer is governed by its invariant tests.
# Changes here must respect those clauses
# see spinifex/network/invariants/
# docs/development/proposals/az-local-architecture/0006-spinifex-network-layer-contract.md
/spinifex/network/ @joshsiv-mulga
/spinifex/vpcd/ @joshsiv-mulga
/spinifex/network/invariants/ @joshsiv-mulga
# see spinifex/domains/network/invariants/
# mulga docs/development/proposals/region-local-architecture/0006-spinifex-network-layer-contract.md (proposal, not accepted)
/spinifex/domains/network/ @joshsiv-mulga
/spinifex/runtime/roles/vpcd/ @joshsiv-mulga
/spinifex/domains/network/invariants/ @joshsiv-mulga
+1 -1
View File
@@ -8,7 +8,7 @@ Actions job summary and emits one `::error` annotation per failed test case.
```yaml
- name: Publish test summary
if: always()
uses: ./spinifex/.github/actions/junit-report # or your own checkout path
uses: $/.github/actions/junit-report
with:
junit-glob: ${{ env.ARTIFACT_DIR }}/junit-*.xml
title: "Go E2E results"
+1 -1
View File
@@ -27,7 +27,7 @@
<line x1="510.0" y1="212.0" x2="510.0" y2="220.0" stroke="#EE4B12" stroke-width="1.3" stroke-opacity="0.55" stroke-dasharray="2 4"/>
<rect x="28.0" y="224.0" width="964.0" height="94.0" rx="13" fill="#EE4B12" fill-opacity="0.05" stroke="#EE4B12" stroke-width="1.4" stroke-opacity="0.5"/>
<text x="48.0" y="251.0" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="12" fill="#EE4B12" font-weight="700" text-anchor="start" letter-spacing="1.5">AWS GATEWAY</text>
<text x="48.0" y="272.0" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="12" fill="#5B636C" font-weight="400" text-anchor="start">spinifex/services/awsgw · TLS termination · SigV4 verification · IAM policy eval · NATS routing</text>
<text x="48.0" y="272.0" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, system-ui, sans-serif" font-size="12" fill="#5B636C" font-weight="400" text-anchor="start">spinifex/runtime/roles/awsgw · TLS termination · SigV4 verification · IAM policy eval · NATS routing</text>
<rect x="46.0" y="287.0" width="68.2" height="22.0" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>
<text x="80.1" y="301.9" font-family="ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace" font-size="11" fill="#F3F1EC" font-weight="400" text-anchor="middle" fill-opacity="0.9">TLS 1.3</text>
<rect x="123.2" y="287.0" width="88.0" height="22.0" rx="7" fill="#EE4B12" fill-opacity="0.06" stroke="#EE4B12" stroke-width="1" stroke-opacity="0.5"/>

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 20 KiB

+1 -1
View File
@@ -51,7 +51,7 @@
<!-- ═══ AWS GATEWAY ════════════════════════════════════ -->
<rect x="16" y="154" width="868" height="104" rx="8" fill="url(#gw-grad)" stroke="#DC2626" stroke-width="1.3" stroke-opacity="0.75"/>
<text x="32" y="176" font-family="'SF Mono', ui-monospace, monospace" font-size="12" fill="#F87171" font-weight="700" letter-spacing="1">AWS GATEWAY · port 9999</text>
<text x="876" y="176" font-family="'SF Mono', ui-monospace, monospace" font-size="9" fill="#71717a" text-anchor="end">spinifex/services/awsgw/awsgw.go</text>
<text x="876" y="176" font-family="'SF Mono', ui-monospace, monospace" font-size="9" fill="#71717a" text-anchor="end">spinifex/runtime/roles/awsgw/awsgw.go</text>
<!-- Two-column feature bullets -->
<g font-family="'Segoe UI', sans-serif" font-size="10.5" fill="#f5f5f5">

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

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

After

Width:  |  Height:  |  Size: 14 KiB

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

After

Width:  |  Height:  |  Size: 22 KiB

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

After

Width:  |  Height:  |  Size: 15 KiB

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

After

Width:  |  Height:  |  Size: 21 KiB

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

After

Width:  |  Height:  |  Size: 3.7 KiB

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

After

Width:  |  Height:  |  Size: 3.2 KiB

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

After

Width:  |  Height:  |  Size: 3.3 KiB

+26 -6
View File
@@ -23,11 +23,6 @@ updates:
timezone: Australia/Sydney
cooldown:
default-days: 3
ignore:
- dependency-name: "*"
update-types:
- "version-update:semver-minor"
- "version-update:semver-patch"
groups:
github-actions:
patterns:
@@ -35,7 +30,7 @@ updates:
- package-ecosystem: npm
target-branch: dev
directory: /spinifex/services/spinifexui/frontend
directory: /spinifex/runtime/roles/spinifexui/frontend
schedule:
interval: weekly
day: sunday
@@ -50,3 +45,28 @@ updates:
npm-dependencies:
patterns:
- "*"
# Base images are pinned by digest, so this is what moves them. The golang
# version is deliberate and changes with go.mod; only its digest moves here.
- package-ecosystem: docker
target-branch: dev
directories:
- /build
- /scripts
- /docs/terraform-workbooks/demo-app
schedule:
interval: weekly
day: sunday
timezone: Australia/Sydney
cooldown:
default-days: 3
ignore:
- dependency-name: golang
update-types:
- "version-update:semver-major"
- "version-update:semver-minor"
- "version-update:semver-patch"
groups:
docker-images:
patterns:
- "*"
+47
View File
@@ -143,5 +143,52 @@
"extra": "",
"suite": "storagefault",
"timeout_minutes": 100
},
{
"cell": 30,
"install": "binary",
"topo": "real-multi",
"net": "nat",
"host": "debian-13",
"arch": "x86_64",
"runner": "ci-multi",
"extra": "nat-multi",
"suite": ""
},
{
"cell": 31,
"install": "binary",
"topo": "real-multi",
"net": "static",
"host": "debian-13",
"arch": "x86_64",
"runner": "ci-multi",
"extra": "",
"suite": "instancerecovery",
"timeout_minutes": 105
},
{
"cell": 32,
"install": "binary",
"topo": "real-multi",
"net": "static",
"host": "debian-13",
"arch": "x86_64",
"runner": "ci-multi",
"extra": "",
"suite": "lbrecovery",
"timeout_minutes": 90
},
{
"cell": 33,
"install": "binary",
"topo": "real-multi",
"net": "static",
"host": "debian-13",
"arch": "x86_64",
"runner": "ci-multi",
"extra": "",
"suite": "kvquorum",
"timeout_minutes": 55
}
]
+14
View File
@@ -5,3 +5,17 @@
## Changes
<!-- The notable changes, as bullets. -->
## Account-scoped lifecycle impact
<!-- Complete this section only when the PR changes account-scoped resource lifecycle, account termination, resource cleanup, or a lifecycle boundary. Otherwise write: N/A. -->
- Lifecycle owner and resource family:
- Owner-local ordering, recovery, retained-state, or force impact:
- Cross-domain dependency or ownership impact:
- Evidence added or rechecked:
- ADR-0008 lifecycle register / ADR-0001 migration-record / bead updates:
## Community Contribution License
- [ ] All community contributions in this pull request are licensed to Mulga under the terms of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). By creating this pull request I represent that I have the right to license these contributions to Mulga under the Apache License 2.0.
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
# Resolve the tag of each sibling repo (viperblock, predastore, northstar,
# bluebottle) a release build checks out. A release ships what go.mod requires,
# so each sibling is built at the version go.mod names, never a branch tip. A
# pseudo-version has no tag to check out, so it fails the release rather than
# silently building something else.
#
# Takes the path to spinifex's go.mod and writes viperblock=<tag>,
# predastore=<tag>, northstar=<tag> and bluebottle=<tag> to $GITHUB_OUTPUT for
# the checkout steps to consume.
set -euo pipefail
gomod="${1:?usage: $0 <path to spinifex go.mod>}"
: "${GITHUB_OUTPUT:?GITHUB_OUTPUT must be set}"
for repo in viperblock predastore northstar bluebottle; do
module="github.com/mulgadc/${repo}"
version=$(awk -v m="$module" '$1 == m { print $2; exit }' "$gomod")
if [[ -z "$version" ]]; then
echo "::error::${gomod} does not require ${module}"
exit 1
fi
if [[ ! "$version" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$ || "$version" =~ [0-9]{14}-[0-9a-f]{12}$ ]]; then
echo "::error::${module} is required at ${version}, which is not a release tag; tag ${repo} and bump go.mod before releasing"
exit 1
fi
echo "${repo}: building against ${version}"
echo "${repo}=${version}" >>"$GITHUB_OUTPUT"
done
+39 -1
View File
@@ -40,7 +40,24 @@
E2E_SUITES_SINGLE="single iam cert eks ecs storagegrowth partialblock rds quota storagefault"
# Suites runnable against a multi-node environment.
E2E_SUITES_MULTI="multinode lb cert quota storagefault"
#
# instancerecovery is multi-only and needs three: recovery is inert below that,
# because there is no survivor to move a guest to that is not the node that
# failed. Like storagefault it takes a node away and freezes a cluster-wide
# service, so it runs last and owns its environment.
#
# lbrecovery is multi-only and needs three for two reasons rather than one: a
# survivor to move the load balancer to, and a node for its backends that is not
# the load balancer's, so taking that node away does not take the backends with
# it. It runs beside instancerecovery at the end because it also takes a node
# away and owns its environment while it does.
#
# kvquorum is multi-only and needs three, for the same reason as those two: a
# surviving majority. It is what proves the control plane's own state is
# replicated rather than sitting on one node, so it runs before the pair that
# take a node away — a cluster whose buckets are already wrong should report
# that, not report a recovery failing.
E2E_SUITES_MULTI="multinode lb cert quota kvquorum lbrecovery instancerecovery storagefault"
# grep -xE alternation form, for narrowing a requested set to the eligible one.
E2E_SUITES_SINGLE_RE="$(printf '%s' "${E2E_SUITES_SINGLE}" | tr ' ' '|')"
@@ -64,6 +81,10 @@ E2E_SUITES_MULTI_RE="$(printf '%s' "${E2E_SUITES_MULTI}" | tr ' ' '|')"
# after a failed seal do not exist on one node, and they are the assertions
# worth a nightly.
E2E_SUITES_NIGHTLY_SINGLE="single cert iam"
#
# The multi-node nightly subset carries kvquorum's read-only half through
# `multinode`, which sweeps every KV bucket rather than the three the daemon
# owns. The node-loss half is too slow for a 35-minute cell and has its own.
E2E_SUITES_NIGHTLY_MULTI="multinode cert lb"
# How long a suite gets, for the same reason the lists above live here: the
@@ -88,6 +109,23 @@ e2e_suite_timeout() {
# SIGSTOPped for seven minutes to prove a guest survives it. A full pass on
# dev-prod is ~63 minutes, so the 30m default truncated it mid-suite.
storagefault) echo "90m" ;;
# instancerecovery is almost entirely waiting, and none of it is avoidable:
# a dead owner has to be seen stale twice, a guest has to boot, and the
# storage case waits for a pause measured at 4m47s and then holds four
# minutes to prove nothing moved. Three tests, each taking a node away and
# bringing it back, and the partition case adds a lease validity, a lease
# TTL and a boot on top of everything the other two wait for.
instancerecovery) echo "90m" ;;
# lbrecovery pays for a load balancer twice: once to stand one up with two
# backends placed around it, and once for the recovery itself — a stale
# owner, a lease, a boot, HAProxy's own start, and its health checks against
# the backends before it forwards anything. Then the node comes back and has
# to be shown not to answer for an address it no longer holds.
lbrecovery) echo "75m" ;;
# kvquorum audits every bucket in seconds and then spends the rest of its
# budget on a node stopping and starting: seven units down, a raft election,
# and the whole service stack back up with every stream caught up.
kvquorum) echo "40m" ;;
*) echo "30m" ;;
esac
}
+137
View File
@@ -0,0 +1,137 @@
# This file is machine-generated by `gh actions-lock`.
# Do not edit by hand; run `gh actions-lock` to update.
# Docs: https://gh.io/actions-lockfile
version: 'v0.0.3'
workflows:
'.github/workflows/apt-packages.yml':
- 'actions/checkout@v7.0.1'
'.github/workflows/dev-release.yml':
- 'actions/checkout@v7.0.1'
- 'actions/setup-go@v7.0.0'
- 'docker/setup-buildx-action@v4.4.1'
'.github/workflows/e2e-baremetal.yml':
- 'actions/checkout@v7.0.1'
- 'actions/create-github-app-token@v3.3.0'
- 'actions/upload-artifact@v7.0.2'
'.github/workflows/e2e-cloudvendor-nightly.yml':
- 'actions/checkout@v7.0.1'
- 'actions/create-github-app-token@v3.3.0'
- 'actions/upload-artifact@v7.0.2'
- 'hashicorp/setup-terraform@v4.0.1'
'.github/workflows/e2e-ddil.yml':
- 'actions/checkout@v7.0.1'
- 'actions/create-github-app-token@v3.3.0'
- 'actions/upload-artifact@v7.0.2'
'.github/workflows/e2e-nightly.yml':
- 'actions/checkout@v7.0.1'
- 'actions/create-github-app-token@v3.3.0'
- 'actions/download-artifact@v8.0.2'
- 'actions/upload-artifact@v7.0.2'
'.github/workflows/e2e-suites.yml':
- 'actions/checkout@v7.0.1'
- 'actions/create-github-app-token@v3.3.0'
- 'actions/setup-go@v7.0.0'
- 'actions/upload-artifact@v7.0.2'
'.github/workflows/e2e.yml':
- 'actions/checkout@v7.0.1'
- 'actions/create-github-app-token@v3.3.0'
- 'actions/download-artifact@v8.0.2'
- 'actions/setup-go@v7.0.0'
- 'actions/upload-artifact@v7.0.2'
'.github/workflows/gpu-e2e.yml':
- 'actions/checkout@v7.0.1'
- 'actions/setup-go@v7.0.0'
- 'actions/upload-artifact@v7.0.2'
'.github/workflows/image-scripts.yml':
- 'actions/checkout@v7.0.1'
- 'actions/setup-go@v7.0.0'
'.github/workflows/perf-3node.yml':
- 'actions/checkout@v7.0.1'
- 'actions/create-github-app-token@v3.3.0'
- 'actions/setup-go@v7.0.0'
- 'actions/upload-artifact@v7.0.2'
'.github/workflows/publish-system-ami.yml':
- 'actions/checkout@v7.0.1'
- 'actions/setup-go@v7.0.0'
'.github/workflows/release.yml':
- 'actions/checkout@v7.0.1'
- 'actions/create-github-app-token@v3.3.0'
- 'actions/download-artifact@v8.0.2'
- 'actions/setup-go@v7.0.0'
- 'actions/upload-artifact@v7.0.2'
- 'docker/setup-buildx-action@v4.4.1'
- 'softprops/action-gh-release@v3.0.3'
'.github/workflows/segscan-oracle.yml':
- 'actions/checkout@v7.0.1'
- 'actions/create-github-app-token@v3.3.0'
- 'actions/setup-go@v7.0.0'
'.github/workflows/spinifex.yml':
- 'actions/cache@v6.1.0'
- 'actions/checkout@v7.0.1'
- 'actions/setup-go@v7.0.0'
- 'golangci/golangci-lint-action@v9.3.0'
'.github/workflows/ui.yml':
- 'actions/checkout@v7.0.1'
- 'actions/setup-node@v7.1.0'
- 'pnpm/action-setup@v6.1.0'
dependencies:
'actions/cache@v6.1.0':
ref: 'v6.1.0'
commit: 'sha1-55cc8345863c7cc4c66a329aec7e433d2d1c52a9'
owner_id: 44036562
repo_id: 215566462
'actions/checkout@v7.0.1':
ref: 'v7.0.1'
commit: 'sha1-3d3c42e5aac5ba805825da76410c181273ba90b1'
owner_id: 44036562
repo_id: 197814629
'actions/create-github-app-token@v3.3.0':
ref: 'v3.3.0'
commit: 'sha1-64ab729a6294000f5aa4a4bc0b73d1f32bc45510'
owner_id: 44036562
repo_id: 642580244
'actions/download-artifact@v8.0.2':
ref: 'v8.0.2'
commit: 'sha1-9000827ccba6bdab643e8b6fd33ac0654aef8333'
owner_id: 44036562
repo_id: 192626254
'actions/setup-go@v7.0.0':
ref: 'v7.0.0'
commit: 'sha1-b7ad1dad31e06c5925ef5d2fc7ad053ef454303e'
owner_id: 44036562
repo_id: 192624594
'actions/setup-node@v7.1.0':
ref: 'v7.1.0'
commit: 'sha1-949feb2413d6458794dcd2491c4babbbce0c15c1'
owner_id: 44036562
repo_id: 189476904
'actions/upload-artifact@v7.0.2':
ref: 'v7.0.2'
commit: 'sha1-cf430e030ddbb5b0abf93d22962f4752f3646cd9'
owner_id: 44036562
repo_id: 192625955
'docker/setup-buildx-action@v4.4.1':
ref: 'v4.4.1'
commit: 'sha1-f87e5991a6d7451dcb8d9637bfbc97413f497069'
owner_id: 5429470
repo_id: 288485773
'golangci/golangci-lint-action@v9.3.0':
ref: 'v9.3.0'
commit: 'sha1-ba0d7d2ec06a0ea1cb5fa41b2e4a3ab91d21278a'
owner_id: 35628013
repo_id: 232539879
'hashicorp/setup-terraform@v4.0.1':
ref: 'v4.0.1'
commit: 'sha1-dfe3c3f87815947d99a8997f908cb6525fc44e9e'
owner_id: 761456
repo_id: 258271226
'pnpm/action-setup@v6.1.0':
ref: 'v6.1.0'
commit: 'sha1-ea17c68df8912ef543352723c149a84f56e3d413'
owner_id: 21320719
repo_id: 262289747
'softprops/action-gh-release@v3.0.3':
ref: 'v3.0.3'
commit: 'sha1-efb35369e0ad2afab669f228072c1b0d510eae64'
owner_id: 2242
repo_id: 204253808
+1 -1
View File
@@ -40,7 +40,7 @@ jobs:
steps:
- name: Checkout Code
uses: actions/checkout@v7
uses: actions/checkout@v7.0.1
- name: Resolve runtime package list
run: ./scripts/lint-apt-packages.sh ${{ matrix.image }}
+185
View File
@@ -0,0 +1,185 @@
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.0.1
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.0.1
with:
repository: mulgadc/predastore
ref: dev
path: predastore
- name: Checkout Viperblock
uses: actions/checkout@v7.0.1
with:
repository: mulgadc/viperblock
ref: dev
path: viperblock
- name: Checkout Northstar
uses: actions/checkout@v7.0.1
with:
repository: mulgadc/northstar
ref: dev
path: northstar
- name: Checkout Bluebottle
uses: actions/checkout@v7.0.1
with:
repository: mulgadc/bluebottle
ref: dev
path: bluebottle
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4.4.1
- name: Set up Go
uses: actions/setup-go@v7.0.0
with:
go-version-file: spinifex/go.mod
cache: false
# 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. Five leaves a few recent builds to roll back to.
- 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")))) | .[5:] | .[].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
+9 -8
View File
@@ -77,18 +77,18 @@ jobs:
if [ ! -c /dev/null ]; then sudo rm -f /dev/null; sudo mknod -m 666 /dev/null c 1 3; fi
sudo rm -rf --one-file-system "$GITHUB_WORKSPACE"/*
- uses: actions/checkout@v7
- uses: actions/checkout@v7.0.1
with:
ref: ${{ inputs.ref || github.ref }}
- uses: actions/create-github-app-token@v3
- uses: actions/create-github-app-token@v3.3.0
id: app-token
with:
client-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
repositories: mulga
- uses: actions/checkout@v7
- uses: actions/checkout@v7.0.1
with:
repository: mulgadc/mulga
token: ${{ steps.app-token.outputs.token }}
@@ -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: |
@@ -151,7 +151,8 @@ jobs:
fi
- name: Install go-junit-report on runner
run: go install github.com/jstemmer/go-junit-report/v2@latest
# Version from go.mod's tool directive, checked against go.sum.
run: GOWORK=off go install github.com/jstemmer/go-junit-report/v2
# The eks suite's CreateCluster boots a control-plane VM from the eks-node
# AMI. Built here because libguestfs needs source and network, which the
@@ -221,20 +222,20 @@ jobs:
- name: Ship test results to Elasticsearch
if: always()
continue-on-error: true
uses: ./.github/actions/junit-to-es
uses: $/.github/actions/junit-to-es
with:
junit-glob: ${{ env.ARTIFACT_DIR }}/junit-*.xml
cell: ${{ env.BM_CELL }}
- name: Analyze e2e failures
if: always()
uses: ./.github/actions/e2e-analyze
uses: $/.github/actions/e2e-analyze
with:
junit-glob: ${{ env.ARTIFACT_DIR }}/junit-*.xml
log-dir: ${{ env.ARTIFACT_DIR }}
title: "E2E failure analysis (cell ${{ env.BM_CELL }})"
- uses: actions/upload-artifact@v7
- uses: actions/upload-artifact@v7.0.2
if: always()
with:
name: e2e-artifacts-cell-${{ env.BM_CELL }}-${{ github.run_id }}
@@ -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.0.1
with:
path: spinifex
- uses: actions/create-github-app-token@v3.3.0
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.0.1
with:
repository: mulgadc/mulga
path: mulga
token: ${{ steps.app-token.outputs.token }}
sparse-checkout: scripts/tofu-cluster
- uses: hashicorp/setup-terraform@v4.0.1
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.0.2
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
+8 -8
View File
@@ -38,19 +38,19 @@ jobs:
- name: Clean Workspace
run: sudo rm -rf "$GITHUB_WORKSPACE"/*
- uses: actions/create-github-app-token@v3
- uses: actions/create-github-app-token@v3.3.0
id: app-token
with:
client-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
repositories: mulga
- uses: actions/checkout@v7
- uses: actions/checkout@v7.0.1
name: Checkout Spinifex
with:
path: spinifex
- uses: actions/checkout@v7
- uses: actions/checkout@v7.0.1
name: Checkout Mulga (tofu-cluster)
with:
repository: mulgadc/mulga
@@ -81,7 +81,7 @@ jobs:
# Adds ci.job_id alongside ci.cell, before Bootstrap bakes the attrs into
# the VMs, so a telemetry doc points back at this job's log directly.
- name: Resolve CI job id
uses: ./spinifex/.github/actions/otel-job-id
uses: $/.github/actions/otel-job-id
with:
job-name: DDIL Real Multi-Node
@@ -175,7 +175,7 @@ jobs:
- name: Publish test summary
if: always()
uses: ./spinifex/.github/actions/junit-report
uses: $/.github/actions/junit-report
with:
junit-glob: ${{ runner.temp }}/ddil-results/junit-*.xml
title: "DDIL scenario results"
@@ -185,7 +185,7 @@ jobs:
- name: Ship test results to Elasticsearch
if: always()
continue-on-error: true
uses: ./spinifex/.github/actions/junit-to-es
uses: $/.github/actions/junit-to-es
with:
junit-glob: ${{ runner.temp }}/ddil-results/junit-*.xml
cell: ddil
@@ -214,7 +214,7 @@ jobs:
- name: Upload DDIL Results
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v7.0.2
with:
name: ddil-results-${{ github.run_id }}
path: ${{ runner.temp }}/ddil-results
@@ -234,7 +234,7 @@ jobs:
- name: Upload tofu log
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v7.0.2
with:
name: tofu-log-ddil-${{ github.run_id }}
path: ${{ env.TOFU_LOG }}
+122 -60
View File
@@ -1,18 +1,16 @@
name: E2E Nightly
# Triggered nightly by a Cloudflare Worker that dispatches this workflow once on
# `dev` and once on `main`, rather than by a `schedule:` here. GitHub registers a
# schedule only from the default branch, so a cron in this file can never run
# `main` and `dev` as separate nightlies, and it would always execute `main`'s
# copy of this file — leaving a fix to the workflow itself untestable until it
# had already shipped. A dispatch takes both the file and the source from its ref.
on:
schedule:
# 14:00 UTC is midnight AEST (01:00 AEDT — cron has no DST), when the
# hypervisors are idle. Every day, not weeknights: a weekend gap is two runs
# of evidence lost on a suite whose remaining failures are a rate, not a bug.
# :17 rather than :00 because GitHub queues scheduled runs behind every other
# top-of-hour cron and drops them under load, which is why 2026-09-14 never
# fired and why every run in that window started 3h15m-3h45m late.
- cron: "17 14 * * *"
workflow_dispatch:
inputs:
cells:
description: "Comma-separated cell numbers to run, no spaces (blank = all). 1-3, 8-10, 17-19 tofu permutation matrix, 20 baremetal, 21-22 ISO, 23 predastore throughput, 24 predastore fault injection, 25-27 service suites (eks, ecs, rds), 28 guest disk performance, 29 storage fault injection"
description: "Comma-separated cell numbers to run, no spaces (blank = all). 1-3, 8-10, 17-19 tofu permutation matrix, 20 baremetal, 21-22 ISO, 23 predastore throughput, 24 predastore fault injection, 25-27 service suites (eks, ecs, rds), 28 guest disk performance, 29 storage fault injection, 30 routed NAT on three nodes, 31 instance recovery on three nodes, 32 load balancer recovery on three nodes, 33 KV bucket quorum under node loss on three nodes"
required: false
permissions:
@@ -33,7 +31,7 @@ concurrency:
cancel-in-progress: false
env:
GIT_BRANCH: ${{ github.event_name == 'schedule' && 'dev' || github.ref_name }}
GIT_BRANCH: ${{ github.ref_name }}
UBUNTU_GOLD_IMAGE: /mnt/disk1/gold-images/spinifex-image-builder-ubuntu-26.04.qcow2
SPINIFEX_OTLP_ENDPOINT: ${{ vars.SPINIFEX_OTLP_ENDPOINT || 'http://192.168.1.13:8200' }}
SPINIFEX_OTEL_ATTRS: ci.run_id=${{ github.run_id }},ci.workflow=${{ github.workflow }}
@@ -45,7 +43,7 @@ jobs:
cells: ${{ steps.pick.outputs.cells }}
any: ${{ steps.pick.outputs.any }}
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v7.0.1
with:
sparse-checkout: |
.github/e2e-cells.json
@@ -130,21 +128,21 @@ jobs:
if [ ! -c /dev/null ]; then sudo rm -f /dev/null; sudo mknod -m 666 /dev/null c 1 3; fi
sudo rm -rf --one-file-system "$GITHUB_WORKSPACE"/*
- uses: actions/create-github-app-token@v3
- uses: actions/create-github-app-token@v3.3.0
id: app-token
with:
client-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
repositories: mulga
- uses: actions/checkout@v7
- uses: actions/checkout@v7.0.1
with:
repository: mulgadc/mulga
token: ${{ steps.app-token.outputs.token }}
sparse-checkout: scripts/tofu-cluster
path: mulga
- uses: actions/checkout@v7
- uses: actions/checkout@v7.0.1
with:
path: spinifex
@@ -153,7 +151,7 @@ jobs:
# links each cell's telemetry to the job log that produced it. Runs
# before Bootstrap bakes SPINIFEX_OTEL_ATTRS into the VM's telemetry.env.
- name: Resolve CI job id
uses: ./spinifex/.github/actions/otel-job-id
uses: $/.github/actions/otel-job-id
with:
job-name: "cell-${{ matrix.cell }} (${{ matrix.topo }}/${{ matrix.net }}/${{ matrix.host }}${{ matrix.extra && format('/{0}', matrix.extra) || '' }})"
@@ -280,8 +278,31 @@ jobs:
rc=\${PIPESTATUS[0]}; echo '::endgroup::'; \
if [ \$rc -ne 0 ]; then echo 'natuplink: FAILED' >> '${ARTIFACT_DIR}/suite-status.txt'; fi; exit \$rc"
# natuplink proves a guest with a public IP reaches the internet. The two
# surfaces it never touches are a private subnet's isolation and its NAT
# gateway, and TestVPCEgressPaths is the authority on both — it skipped in
# nat mode until the fixture learned that routed NAT carries EIPs too.
- name: Run VPC Egress Paths in NAT mode
if: matrix.extra == 'nat-single'
working-directory: mulga/scripts/tofu-cluster
run: |
INVENTORY=$(tofu output -json inventory)
WAN_IP=$(echo "$INVENTORY" | jq -r '.nodes[0].wan_ip')
SSH_KEY=$(echo "$INVENTORY" | jq -r '.ssh_key_path')
SSH_KEY="${SSH_KEY/#\~/$HOME}"
ssh -tt -o StrictHostKeyChecking=no -i "$SSH_KEY" tf-user@${WAN_IP} \
"set -u; mkdir -p '${ARTIFACT_DIR}'; cd \$HOME/e2e-tests/_bin; \
echo '::group::suite single/TestVPCEgressPaths'; \
date -u +%Y-%m-%dT%H:%M:%SZ > '${ARTIFACT_DIR}/test-vpcegress-nat.start'; \
GITHUB_ACTIONS=true FORCE_COLOR=1 SPINIFEX_E2E=1 SPINIFEX_MODE=single \
ARTIFACT_DIR='${ARTIFACT_DIR}' \
./single.test -test.v -test.timeout=30m -test.run '^TestVPCEgressPaths\$' \
2>&1 | tee '${ARTIFACT_DIR}/test-vpcegress-nat.log'; \
rc=\${PIPESTATUS[0]}; echo '::endgroup::'; \
if [ \$rc -ne 0 ]; then echo 'vpcegress-nat: FAILED' >> '${ARTIFACT_DIR}/suite-status.txt'; fi; exit \$rc"
- name: Run Multi Node E2E
if: matrix.topo == 'real-multi' && matrix.suite == ''
if: matrix.topo == 'real-multi' && matrix.suite == '' && matrix.extra == ''
working-directory: mulga/scripts/tofu-cluster
run: |
set -euo pipefail
@@ -316,6 +337,40 @@ jobs:
if [ \$rc -ne 0 ]; then echo \"\$SUITE: FAILED\" >> '${ARTIFACT_DIR}/suite-status.txt'; FAIL=1; fi; \
done; exit \$FAIL"
# The same suite as the nat-single cell, run against three nodes: there it
# proves routed NAT works at all, here that each node delivers its own
# guests' public IPs and no others — which one node cannot show.
- name: Run NAT Uplink Multi E2E
if: matrix.extra == 'nat-multi'
working-directory: mulga/scripts/tofu-cluster
run: |
set -euo pipefail
INVENTORY=$(tofu output -json inventory)
NODE1_IP=$(echo "$INVENTORY" | jq -r '.nodes[0].wan_ip')
SSH_KEY=$(echo "$INVENTORY" | jq -r '.ssh_key_path')
SSH_KEY="${SSH_KEY/#\~/$HOME}"
NODES_CSV=""
NODE_COUNT=$(echo "$INVENTORY" | jq '.nodes | length')
for i in $(seq 0 $((NODE_COUNT - 1))); do
ip=$(echo "$INVENTORY" | jq -r ".nodes[$i].wan_ip")
NODES_CSV="${NODES_CSV:+$NODES_CSV,}$ip"
done
ssh -tt -o StrictHostKeyChecking=no -i "$SSH_KEY" tf-user@${NODE1_IP} \
"set -u; mkdir -p '${ARTIFACT_DIR}'; cd \$HOME/e2e-tests/_bin; \
if [ ! -f natuplink.test ]; then echo 'natuplink.test not in tests tar (branch predates suite) — skipping'; exit 0; fi; \
echo '::group::suite natuplink'; \
date -u +%Y-%m-%dT%H:%M:%SZ > '${ARTIFACT_DIR}/test-natuplink.start'; \
GITHUB_ACTIONS=true FORCE_COLOR=1 SPINIFEX_E2E=1 SPINIFEX_MODE=multinode \
SPINIFEX_NODES='${NODES_CSV}' SPINIFEX_NODE_IPS='${NODES_CSV}' \
SPINIFEX_SSH_USER=tf-user SPINIFEX_SSH_KEY=\$HOME/.ssh/tf-user-ap-southeast-2 \
ARTIFACT_DIR='${ARTIFACT_DIR}' \
./natuplink.test -test.v -test.timeout=30m \
2>&1 | tee '${ARTIFACT_DIR}/test-natuplink.log'; \
rc=\${PIPESTATUS[0]}; echo '::endgroup::'; \
if [ \$rc -ne 0 ]; then echo 'natuplink: FAILED' >> '${ARTIFACT_DIR}/suite-status.txt'; fi; exit \$rc"
# The dedicated suite cells only. Resolved once here rather than in each
# step below, which would repeat the same three lines five times.
- name: Resolve VM SSH details
@@ -740,7 +795,8 @@ jobs:
- name: Install go-junit-report
if: always()
run: |
go install github.com/jstemmer/go-junit-report/v2@latest
# Version from go.mod's tool directive, checked against go.sum.
GOWORK=off go -C spinifex install github.com/jstemmer/go-junit-report/v2
echo "$HOME/go/bin" >> "$GITHUB_PATH"
- name: Convert test logs to JUnit XML
@@ -757,20 +813,20 @@ jobs:
- name: Ship test results to Elasticsearch
if: always()
continue-on-error: true
uses: ./spinifex/.github/actions/junit-to-es
uses: $/.github/actions/junit-to-es
with:
junit-glob: ${{ env.ARTIFACT_DIR }}/junit-*.xml
cell: ${{ matrix.cell }}
- name: Analyze e2e failures
if: always()
uses: ./spinifex/.github/actions/e2e-analyze
uses: $/.github/actions/e2e-analyze
with:
junit-glob: ${{ env.ARTIFACT_DIR }}/junit-*.xml
log-dir: ${{ env.ARTIFACT_DIR }}
title: "E2E failure analysis (cell ${{ matrix.cell }})"
- uses: actions/upload-artifact@v7
- uses: actions/upload-artifact@v7.0.2
if: always()
with:
name: e2e-artifacts-cell-${{ matrix.cell }}-${{ github.run_id }}
@@ -782,7 +838,7 @@ jobs:
# pulling nineteen journals it has no use for.
- name: Upload junit only
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v7.0.2
with:
name: junit-cell-${{ matrix.cell }}-${{ github.run_id }}
path: ${{ env.ARTIFACT_DIR }}/junit-*.xml
@@ -813,7 +869,7 @@ jobs:
- name: Upload tofu log
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v7.0.2
with:
name: tofu-log-cell-${{ matrix.cell }}-${{ github.run_id }}
path: ${{ env.TOFU_LOG }}
@@ -822,12 +878,11 @@ jobs:
# Bare-metal single-node suite. Doesn't fit the tofu matrix shape, so it
# runs as a sibling job via the reusable e2e-baremetal workflow. Gated like a
# matrix cell ("cell 20"): every nightly cron, or dispatch with 20 selected
# (blank cells = all). Tests `dev` on schedule to match GIT_BRANCH above.
# matrix cell ("cell 20"): blank cells is the whole nightly, or name 20 to run
# it alone. A reusable call needs its own ref, since it cannot read GIT_BRANCH.
baremetal:
name: cell-20 (baremetal/pxe/real-hw)
if: >-
github.event_name == 'schedule' ||
inputs.cells == '' ||
contains(format(',{0},', inputs.cells), ',20,')
permissions:
@@ -835,7 +890,7 @@ jobs:
statuses: write
uses: ./.github/workflows/e2e-baremetal.yml
with:
ref: ${{ github.event_name == 'schedule' && 'dev' || github.ref_name }}
ref: ${{ github.ref_name }}
# A reusable-workflow call gets none of this workflow's context (env:
# above, matrix.cell) for free — cell-20 needs its own attrs string.
# There's no matrix leg here, just the one fixed baremetal cell.
@@ -855,7 +910,6 @@ jobs:
needs: baremetal
if: >-
!cancelled() && (
github.event_name == 'schedule' ||
inputs.cells == '' ||
contains(format(',{0},', inputs.cells), ',28,')
)
@@ -864,7 +918,7 @@ jobs:
statuses: write
uses: ./.github/workflows/e2e-baremetal.yml
with:
ref: ${{ github.event_name == 'schedule' && 'dev' || github.ref_name }}
ref: ${{ github.ref_name }}
cell: "28"
suites: diskperf
otel-attrs: ci.run_id=${{ github.run_id }},ci.workflow=${{ github.workflow }},ci.cell=28,ci.matrix=baremetal/pxe/diskperf
@@ -881,7 +935,6 @@ jobs:
iso-single:
name: cell-21 (iso/dhcp/single)
if: >-
github.event_name == 'schedule' ||
inputs.cells == '' ||
contains(format(',{0},', inputs.cells), ',21,')
runs-on: [self-hosted, ci-single]
@@ -921,20 +974,20 @@ jobs:
{ sudo mkdir -p "$E2E_ROOT/work" && sudo chown "$(id -u):$(id -g)" "$E2E_ROOT" "$E2E_ROOT/work"; }
echo "TMPDIR=$E2E_ROOT/work" >> "$GITHUB_ENV"
- uses: actions/create-github-app-token@v3
- uses: actions/create-github-app-token@v3.3.0
id: app-token
with:
client-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
repositories: mulga
- uses: actions/checkout@v7
- uses: actions/checkout@v7.0.1
with:
repository: mulgadc/mulga
token: ${{ steps.app-token.outputs.token }}
path: mulga
- uses: actions/checkout@v7
- uses: actions/checkout@v7.0.1
with:
ref: ${{ env.GIT_BRANCH }}
path: mulga/spinifex
@@ -946,25 +999,25 @@ jobs:
run: bash .github/resolve-sibling-refs.sh
working-directory: mulga/spinifex
- uses: actions/checkout@v7
- uses: actions/checkout@v7.0.1
with:
repository: mulgadc/viperblock
ref: ${{ steps.siblings.outputs.viperblock }}
path: mulga/viperblock
- uses: actions/checkout@v7
- uses: actions/checkout@v7.0.1
with:
repository: mulgadc/predastore
ref: ${{ steps.siblings.outputs.predastore }}
path: mulga/predastore
- uses: actions/checkout@v7
- uses: actions/checkout@v7.0.1
with:
repository: mulgadc/northstar
ref: ${{ steps.siblings.outputs.northstar }}
path: mulga/northstar
- uses: actions/checkout@v7
- uses: actions/checkout@v7.0.1
with:
repository: mulgadc/bluebottle
ref: ${{ steps.siblings.outputs.bluebottle }}
@@ -1099,7 +1152,7 @@ jobs:
/tmp/smoke-$GITHUB_RUN_ID.log | tail -1)
[ -n "$qemu" ] && cp "$qemu" /tmp/cell21-artifacts-$GITHUB_RUN_ID/qemu.log || true
- uses: actions/upload-artifact@v7
- uses: actions/upload-artifact@v7.0.2
if: always()
with:
name: e2e-artifacts-cell-21-${{ github.run_id }}
@@ -1136,7 +1189,6 @@ jobs:
needs: [iso-single]
if: >-
!cancelled() && (
github.event_name == 'schedule' ||
inputs.cells == '' ||
contains(format(',{0},', inputs.cells), ',22,') )
runs-on: [self-hosted, ci-multi]
@@ -1169,20 +1221,20 @@ jobs:
# disks, which is what the tmpfs assertion below is about.
echo "WORK_DIR=$E2E_ROOT/work/cell22-$GITHUB_RUN_ID" >> "$GITHUB_ENV"
- uses: actions/create-github-app-token@v3
- uses: actions/create-github-app-token@v3.3.0
id: app-token
with:
client-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
repositories: mulga
- uses: actions/checkout@v7
- uses: actions/checkout@v7.0.1
with:
repository: mulgadc/mulga
token: ${{ steps.app-token.outputs.token }}
path: mulga
- uses: actions/checkout@v7
- uses: actions/checkout@v7.0.1
with:
ref: ${{ env.GIT_BRANCH }}
path: mulga/spinifex
@@ -1194,25 +1246,25 @@ jobs:
run: bash .github/resolve-sibling-refs.sh
working-directory: mulga/spinifex
- uses: actions/checkout@v7
- uses: actions/checkout@v7.0.1
with:
repository: mulgadc/viperblock
ref: ${{ steps.siblings.outputs.viperblock }}
path: mulga/viperblock
- uses: actions/checkout@v7
- uses: actions/checkout@v7.0.1
with:
repository: mulgadc/predastore
ref: ${{ steps.siblings.outputs.predastore }}
path: mulga/predastore
- uses: actions/checkout@v7
- uses: actions/checkout@v7.0.1
with:
repository: mulgadc/northstar
ref: ${{ steps.siblings.outputs.northstar }}
path: mulga/northstar
- uses: actions/checkout@v7
- uses: actions/checkout@v7.0.1
with:
repository: mulgadc/bluebottle
ref: ${{ steps.siblings.outputs.bluebottle }}
@@ -1266,7 +1318,7 @@ jobs:
cp "$WORK_DIR"/*-serial.log /tmp/cell22-artifacts-$GITHUB_RUN_ID/ 2>/dev/null ||
echo "no per-node serial logs — the run passed and cleaned up after itself"
- uses: actions/upload-artifact@v7
- uses: actions/upload-artifact@v7.0.2
if: always()
with:
name: e2e-artifacts-cell-22-${{ github.run_id }}
@@ -1298,7 +1350,6 @@ jobs:
needs: [matrix, baremetal, iso-single, iso-multi]
if: >-
!cancelled() && (
github.event_name == 'schedule' ||
inputs.cells == '' ||
contains(format(',{0},', inputs.cells), ',23,') )
runs-on: [self-hosted, ci-single]
@@ -1319,7 +1370,7 @@ jobs:
echo "PERF_RESULTS_ROOT=$E2E_ROOT/predastore-perf-$GITHUB_RUN_ID" >> "$GITHUB_ENV"
- name: Checkout Spinifex
uses: actions/checkout@v7
uses: actions/checkout@v7.0.1
with:
path: spinifex
@@ -1329,12 +1380,12 @@ jobs:
- name: Resolve sibling refs
id: siblings
env:
BRANCH: ${{ github.event_name == 'schedule' && 'dev' || github.ref_name }}
BRANCH: ${{ github.ref_name }}
run: bash .github/resolve-sibling-refs.sh
working-directory: spinifex
- name: Checkout Predastore
uses: actions/checkout@v7
uses: actions/checkout@v7.0.1
with:
repository: mulgadc/predastore
ref: ${{ steps.siblings.outputs.predastore }}
@@ -1387,7 +1438,7 @@ jobs:
# Report-only. The baseline comparison and the -10% gate are mulga-wpjsx;
# until the run-to-run spread is known from real nights, a threshold would
# be a guess, and a gate that cries wolf gets muted.
- uses: actions/upload-artifact@v7
- uses: actions/upload-artifact@v7.0.2
if: always()
with:
name: predastore-perf-${{ github.run_id }}
@@ -1410,7 +1461,6 @@ jobs:
needs: [matrix, baremetal, iso-single, iso-multi, performance]
if: >-
!cancelled() && (
github.event_name == 'schedule' ||
inputs.cells == '' ||
contains(format(',{0},', inputs.cells), ',24,') )
runs-on: [self-hosted, ci-single]
@@ -1447,19 +1497,19 @@ jobs:
echo "STRESS_RESULTS_ROOT=$E2E_ROOT/predastore-stress-$GITHUB_RUN_ID" >> "$GITHUB_ENV"
- name: Checkout Spinifex
uses: actions/checkout@v7
uses: actions/checkout@v7.0.1
with:
path: spinifex
- name: Resolve sibling refs
id: siblings
env:
BRANCH: ${{ github.event_name == 'schedule' && 'dev' || github.ref_name }}
BRANCH: ${{ github.ref_name }}
run: bash .github/resolve-sibling-refs.sh
working-directory: spinifex
- name: Checkout Predastore
uses: actions/checkout@v7
uses: actions/checkout@v7.0.1
with:
repository: mulgadc/predastore
ref: ${{ steps.siblings.outputs.predastore }}
@@ -1493,8 +1543,16 @@ jobs:
# Leftovers from a run killed before its cleanup trap: a surviving node
# holds its ports and fails this run somewhere unrelated.
#
# Under the host benchmark lock, because this pattern matches every
# predastore node on the runner and the predastore repository runs the
# same harnesses on the same machine. Holding the lock is what makes
# "still running" mean "stale" rather than "somebody else's benchmark":
# on 2026-09-16 this step killed a predastore run 32s into its workload.
- name: Clear stale nodes
run: pkill -KILL -f '/bin/s3d -config' || true
run: |
flock -w 3600 "${PREDA_BENCH_LOCK:-/var/lock/predastore-bench.lock}" \
pkill -KILL -f '/bin/s3d -config' || true
- name: Run fault injection
working-directory: predastore
@@ -1506,7 +1564,7 @@ jobs:
mkdir -p "$STRESS_RESULTS_ROOT"
make e2e-stress 2>&1 | tee "$STRESS_RESULTS_ROOT/e2e-stress.log"
- uses: actions/upload-artifact@v7
- uses: actions/upload-artifact@v7.0.2
if: always()
with:
name: predastore-stress-${{ github.run_id }}
@@ -1514,10 +1572,14 @@ jobs:
if-no-files-found: warn
retention-days: 90
# Under the lock for the same reason as "Clear stale nodes": by the time
# this runs the harness has released it, so another repository's benchmark
# can already have a cluster of its own up.
- name: Clean up
if: always()
run: |
pkill -KILL -f '/bin/s3d -config' || true
flock -w 3600 "${PREDA_BENCH_LOCK:-/var/lock/predastore-bench.lock}" \
pkill -KILL -f '/bin/s3d -config' || true
rm -rf "$STRESS_RESULTS_ROOT"
# Reading a nightly meant opening every cell job, and the Actions UI does not
@@ -1539,13 +1601,13 @@ jobs:
if: always()
runs-on: ubuntu-26.04
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v7.0.1
with:
sparse-checkout: .github/scripts/e2e-run-summary.py
sparse-checkout-cone-mode: false
- name: Collect junit
uses: actions/download-artifact@v8
uses: actions/download-artifact@v8.0.2
with:
pattern: junit-cell-*-${{ github.run_id }}
path: junit
+33 -31
View File
@@ -103,25 +103,25 @@ jobs:
| sort -r | xargs -r -I{} sudo umount -l -R {} || true
sudo rm -rf --one-file-system "$GITHUB_WORKSPACE"/*
- uses: actions/create-github-app-token@v3
- uses: actions/create-github-app-token@v3.3.0
id: app-token
with:
client-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
repositories: mulga
- uses: actions/checkout@v7
- uses: actions/checkout@v7.0.1
with:
repository: mulgadc/mulga
token: ${{ steps.app-token.outputs.token }}
sparse-checkout: scripts/tofu-cluster
path: mulga
- uses: actions/checkout@v7
- uses: actions/checkout@v7.0.1
with:
path: spinifex
- uses: actions/setup-go@v7
- uses: actions/setup-go@v7.0.0
with:
go-version-file: spinifex/go.mod
cache: false
@@ -157,7 +157,7 @@ jobs:
# the env: the suite legs share this env and its baked telemetry.env, so
# the provisioner is the only job the daemon telemetry can belong to.
- name: Resolve CI job id
uses: ./spinifex/.github/actions/otel-job-id
uses: $/.github/actions/otel-job-id
with:
job-name: Single env · provision
@@ -300,7 +300,7 @@ jobs:
- name: Upload tofu log
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v7.0.2
with:
name: tofu-log-single-${{ github.run_id }}
path: ${{ env.TOFU_LOG }}
@@ -323,18 +323,19 @@ jobs:
SSH_KEY: ${{ needs.single_provision.outputs.ssh_key }}
ARTIFACT_DIR: /tmp/spinifex-e2e-single-${{ github.run_id }}-${{ matrix.suite }}
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v7.0.1
with:
path: spinifex
- uses: actions/setup-go@v7
- uses: actions/setup-go@v7.0.0
with:
go-version-file: spinifex/go.mod
cache: false
- name: Install go-junit-report
run: |
go install github.com/jstemmer/go-junit-report/v2@latest
# Version from go.mod's tool directive, checked against go.sum.
GOWORK=off go -C spinifex install github.com/jstemmer/go-junit-report/v2
echo "$HOME/go/bin" >> "$GITHUB_PATH"
- name: Run ${{ matrix.suite }}
@@ -390,7 +391,7 @@ jobs:
- name: Publish test summary
if: always()
uses: ./spinifex/.github/actions/junit-report
uses: $/.github/actions/junit-report
with:
junit-glob: ${{ env.ARTIFACT_DIR }}/junit-*.xml
title: "E2E ${{ matrix.suite }} results"
@@ -400,20 +401,20 @@ jobs:
- name: Ship test results to Elasticsearch
if: always()
continue-on-error: true
uses: ./spinifex/.github/actions/junit-to-es
uses: $/.github/actions/junit-to-es
with:
junit-glob: ${{ env.ARTIFACT_DIR }}/junit-*.xml
cell: single
- name: Analyze e2e failures
if: always()
uses: ./spinifex/.github/actions/e2e-analyze
uses: $/.github/actions/e2e-analyze
with:
junit-glob: ${{ env.ARTIFACT_DIR }}/junit-*.xml
log-dir: ${{ env.ARTIFACT_DIR }}
title: "E2E ${{ matrix.suite }} failure analysis"
- uses: actions/upload-artifact@v7
- uses: actions/upload-artifact@v7.0.2
if: always()
with:
name: e2e-single-${{ matrix.suite }}-${{ github.run_id }}
@@ -444,7 +445,7 @@ jobs:
# Reuse the persisted mulga/ tofu-cluster workspace from single_provision.
# Re-checkout only spinifex (for ci-fmt + diag scripts); checking out mulga
# again would wipe the local tofu state and orphan the VM.
- uses: actions/checkout@v7
- uses: actions/checkout@v7.0.1
with:
path: spinifex
@@ -467,7 +468,7 @@ jobs:
| tar -C "${ARTIFACT_DIR}/lb-console" -xf - 2>/dev/null
true
- uses: actions/upload-artifact@v7
- uses: actions/upload-artifact@v7.0.2
if: always()
with:
name: e2e-single-diag-${{ github.run_id }}
@@ -523,7 +524,7 @@ jobs:
- name: Upload tofu log
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v7.0.2
with:
name: tofu-log-single-td-${{ github.run_id }}
path: ${{ env.TOFU_LOG }}
@@ -571,25 +572,25 @@ jobs:
| sort -r | xargs -r -I{} sudo umount -l -R {} || true
sudo rm -rf --one-file-system "$GITHUB_WORKSPACE"/*
- uses: actions/create-github-app-token@v3
- uses: actions/create-github-app-token@v3.3.0
id: app-token
with:
client-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
repositories: mulga
- uses: actions/checkout@v7
- uses: actions/checkout@v7.0.1
with:
repository: mulgadc/mulga
token: ${{ steps.app-token.outputs.token }}
sparse-checkout: scripts/tofu-cluster
path: mulga
- uses: actions/checkout@v7
- uses: actions/checkout@v7.0.1
with:
path: spinifex
- uses: actions/setup-go@v7
- uses: actions/setup-go@v7.0.0
with:
go-version-file: spinifex/go.mod
cache: false
@@ -606,7 +607,7 @@ jobs:
# See the matching note on single_provision.
- name: Resolve CI job id
uses: ./spinifex/.github/actions/otel-job-id
uses: $/.github/actions/otel-job-id
with:
job-name: Multi env · provision
@@ -714,7 +715,7 @@ jobs:
- name: Upload tofu log
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v7.0.2
with:
name: tofu-log-multi-${{ github.run_id }}
path: ${{ env.TOFU_LOG }}
@@ -737,18 +738,19 @@ jobs:
NODES_CSV: ${{ needs.multi_provision.outputs.nodes_csv }}
ARTIFACT_DIR: /tmp/spinifex-e2e-multi-${{ github.run_id }}-${{ matrix.suite }}
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v7.0.1
with:
path: spinifex
- uses: actions/setup-go@v7
- uses: actions/setup-go@v7.0.0
with:
go-version-file: spinifex/go.mod
cache: false
- name: Install go-junit-report
run: |
go install github.com/jstemmer/go-junit-report/v2@latest
# Version from go.mod's tool directive, checked against go.sum.
GOWORK=off go -C spinifex install github.com/jstemmer/go-junit-report/v2
echo "$HOME/go/bin" >> "$GITHUB_PATH"
- name: Run ${{ matrix.suite }}
@@ -804,7 +806,7 @@ jobs:
- name: Publish test summary
if: always()
uses: ./spinifex/.github/actions/junit-report
uses: $/.github/actions/junit-report
with:
junit-glob: ${{ env.ARTIFACT_DIR }}/junit-*.xml
title: "Multi-Node E2E ${{ matrix.suite }} results"
@@ -814,20 +816,20 @@ jobs:
- name: Ship test results to Elasticsearch
if: always()
continue-on-error: true
uses: ./spinifex/.github/actions/junit-to-es
uses: $/.github/actions/junit-to-es
with:
junit-glob: ${{ env.ARTIFACT_DIR }}/junit-*.xml
cell: multi
- name: Analyze e2e failures
if: always()
uses: ./spinifex/.github/actions/e2e-analyze
uses: $/.github/actions/e2e-analyze
with:
junit-glob: ${{ env.ARTIFACT_DIR }}/junit-*.xml
log-dir: ${{ env.ARTIFACT_DIR }}
title: "Multi-Node E2E ${{ matrix.suite }} failure analysis"
- uses: actions/upload-artifact@v7
- uses: actions/upload-artifact@v7.0.2
if: always()
with:
name: e2e-multi-${{ matrix.suite }}-${{ github.run_id }}
@@ -854,7 +856,7 @@ jobs:
SSH_KEY: ${{ needs.multi_provision.outputs.ssh_key }}
NODES_CSV: ${{ needs.multi_provision.outputs.nodes_csv }}
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v7.0.1
with:
path: spinifex
@@ -908,7 +910,7 @@ jobs:
- name: Upload tofu log
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v7.0.2
with:
name: tofu-log-multi-td-${{ github.run_id }}
path: ${{ env.TOFU_LOG }}
+27 -25
View File
@@ -54,7 +54,7 @@ jobs:
single_timeout: ${{ steps.budgets.outputs.single_timeout }}
multi_timeout: ${{ steps.budgets.outputs.multi_timeout }}
steps:
- uses: actions/checkout@v7
- uses: actions/checkout@v7.0.1
with:
path: spinifex
sparse-checkout: .github/scripts
@@ -142,25 +142,25 @@ jobs:
# stale suite-status.txt / analysis dirs can't leak into this attempt.
rm -rf "${ARTIFACT_DIR}"
- uses: actions/create-github-app-token@v3
- uses: actions/create-github-app-token@v3.3.0
id: app-token
with:
client-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
repositories: mulga
- uses: actions/checkout@v7
- uses: actions/checkout@v7.0.1
with:
repository: mulgadc/mulga
token: ${{ steps.app-token.outputs.token }}
sparse-checkout: scripts/tofu-cluster
path: mulga
- uses: actions/checkout@v7
- uses: actions/checkout@v7.0.1
with:
path: spinifex
- uses: actions/setup-go@v7
- uses: actions/setup-go@v7.0.0
with:
go-version-file: spinifex/go.mod
cache: false
@@ -178,7 +178,7 @@ jobs:
# Adds ci.job_id alongside ci.cell, before Bootstrap bakes the attrs into
# the VM, so a telemetry doc points back at this job's log directly.
- name: Resolve CI job id
uses: ./spinifex/.github/actions/otel-job-id
uses: $/.github/actions/otel-job-id
with:
job-name: Single-Node E2E
@@ -372,7 +372,8 @@ jobs:
- name: Install go-junit-report on runner
run: |
go install github.com/jstemmer/go-junit-report/v2@latest
# Version from go.mod's tool directive, checked against go.sum.
GOWORK=off go -C spinifex install github.com/jstemmer/go-junit-report/v2
echo "$HOME/go/bin" >> "$GITHUB_PATH"
- name: Ship test binaries to VM
@@ -512,7 +513,7 @@ jobs:
- name: Publish test summary
if: always()
uses: ./spinifex/.github/actions/junit-report
uses: $/.github/actions/junit-report
with:
junit-glob: ${{ env.ARTIFACT_DIR }}/junit-*.xml
title: "E2E results"
@@ -522,20 +523,20 @@ jobs:
- name: Ship test results to Elasticsearch
if: always()
continue-on-error: true
uses: ./spinifex/.github/actions/junit-to-es
uses: $/.github/actions/junit-to-es
with:
junit-glob: ${{ env.ARTIFACT_DIR }}/junit-*.xml
cell: single
- name: Analyze e2e failures
if: always()
uses: ./spinifex/.github/actions/e2e-analyze
uses: $/.github/actions/e2e-analyze
with:
junit-glob: ${{ env.ARTIFACT_DIR }}/junit-*.xml
log-dir: ${{ env.ARTIFACT_DIR }}
title: "E2E failure analysis"
- uses: actions/upload-artifact@v7
- uses: actions/upload-artifact@v7.0.2
if: always()
with:
name: e2e-artifacts-${{ github.run_id }}
@@ -579,7 +580,7 @@ jobs:
- name: Upload tofu log
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v7.0.2
with:
name: tofu-log-single-${{ github.run_id }}
path: ${{ env.TOFU_LOG }}
@@ -642,25 +643,25 @@ jobs:
# stale suite-status.txt / analysis dirs can't leak into this attempt.
rm -rf "${ARTIFACT_DIR}"
- uses: actions/create-github-app-token@v3
- uses: actions/create-github-app-token@v3.3.0
id: app-token
with:
client-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
repositories: mulga
- uses: actions/checkout@v7
- uses: actions/checkout@v7.0.1
with:
repository: mulgadc/mulga
token: ${{ steps.app-token.outputs.token }}
sparse-checkout: scripts/tofu-cluster
path: mulga
- uses: actions/checkout@v7
- uses: actions/checkout@v7.0.1
with:
path: spinifex
- uses: actions/setup-go@v7
- uses: actions/setup-go@v7.0.0
with:
go-version-file: spinifex/go.mod
cache: false
@@ -677,7 +678,7 @@ jobs:
# See the matching note on e2e_single.
- name: Resolve CI job id
uses: ./spinifex/.github/actions/otel-job-id
uses: $/.github/actions/otel-job-id
with:
job-name: Multi-Node E2E
@@ -758,7 +759,8 @@ jobs:
- name: Install go-junit-report on runner
run: |
go install github.com/jstemmer/go-junit-report/v2@latest
# Version from go.mod's tool directive, checked against go.sum.
GOWORK=off go -C spinifex install github.com/jstemmer/go-junit-report/v2
echo "$HOME/go/bin" >> "$GITHUB_PATH"
- name: Ship test binaries to node1
@@ -965,7 +967,7 @@ jobs:
- name: Publish test summary
if: always()
uses: ./spinifex/.github/actions/junit-report
uses: $/.github/actions/junit-report
with:
junit-glob: ${{ env.ARTIFACT_DIR }}/junit-*.xml
title: "Multi-Node E2E results"
@@ -975,20 +977,20 @@ jobs:
- name: Ship test results to Elasticsearch
if: always()
continue-on-error: true
uses: ./spinifex/.github/actions/junit-to-es
uses: $/.github/actions/junit-to-es
with:
junit-glob: ${{ env.ARTIFACT_DIR }}/junit-*.xml
cell: multi
- name: Analyze e2e failures
if: always()
uses: ./spinifex/.github/actions/e2e-analyze
uses: $/.github/actions/e2e-analyze
with:
junit-glob: ${{ env.ARTIFACT_DIR }}/junit-*.xml
log-dir: ${{ env.ARTIFACT_DIR }}
title: "Multi-Node E2E failure analysis"
- uses: actions/upload-artifact@v7
- uses: actions/upload-artifact@v7.0.2
if: always()
with:
name: e2e-multi-artifacts-${{ github.run_id }}
@@ -1035,7 +1037,7 @@ jobs:
- name: Upload tofu log
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v7.0.2
with:
name: tofu-log-multi-${{ github.run_id }}
path: ${{ env.TOFU_LOG }}
@@ -1105,14 +1107,14 @@ jobs:
# the aggregation script below treats an absent suite-status.txt as
# "no failures from that leg".
- name: Download single-node artifacts
uses: actions/download-artifact@v8
uses: actions/download-artifact@v8.0.2
continue-on-error: true
with:
name: e2e-artifacts-${{ github.run_id }}
path: ${{ github.workspace }}/es-summary-single
- name: Download multi-node artifacts
uses: actions/download-artifact@v8
uses: actions/download-artifact@v8.0.2
continue-on-error: true
with:
name: e2e-multi-artifacts-${{ github.run_id }}
+8 -7
View File
@@ -95,10 +95,10 @@ jobs:
if: steps.gate.outputs.paused != 'true'
run: sudo rm -rf --one-file-system "$GITHUB_WORKSPACE"/*
- uses: actions/checkout@v7
- uses: actions/checkout@v7.0.1
if: steps.gate.outputs.paused != 'true'
- uses: actions/setup-go@v7
- uses: actions/setup-go@v7.0.0
if: steps.gate.outputs.paused != 'true'
with:
go-version-file: go.mod
@@ -187,7 +187,8 @@ jobs:
- name: Install go-junit-report on runner
if: steps.gate.outputs.paused != 'true'
run: |
go install github.com/jstemmer/go-junit-report/v2@latest
# Version from go.mod's tool directive, checked against go.sum.
GOWORK=off go install github.com/jstemmer/go-junit-report/v2
echo "$HOME/go/bin" >> "$GITHUB_PATH"
- name: Ship GPU e2e test binary to wattle
@@ -243,7 +244,7 @@ jobs:
- name: Publish test summary
if: always() && steps.gate.outputs.paused != 'true'
uses: ./.github/actions/junit-report
uses: $/.github/actions/junit-report
with:
junit-glob: ${{ env.ARTIFACT_DIR }}/junit-*.xml
title: "GPU E2E results"
@@ -251,20 +252,20 @@ jobs:
- name: Ship test results to Elasticsearch
if: always() && steps.gate.outputs.paused != 'true'
continue-on-error: true
uses: ./.github/actions/junit-to-es
uses: $/.github/actions/junit-to-es
with:
junit-glob: ${{ env.ARTIFACT_DIR }}/junit-*.xml
cell: gpu
- name: Analyze e2e failures
if: always() && steps.gate.outputs.paused != 'true'
uses: ./.github/actions/e2e-analyze
uses: $/.github/actions/e2e-analyze
with:
junit-glob: ${{ env.ARTIFACT_DIR }}/junit-*.xml
log-dir: ${{ env.ARTIFACT_DIR }}
title: "GPU E2E failure analysis"
- uses: actions/upload-artifact@v7
- uses: actions/upload-artifact@v7.0.2
if: always() && steps.gate.outputs.paused != 'true'
with:
name: gpu-e2e-artifacts-${{ github.run_id }}
+6 -3
View File
@@ -29,13 +29,16 @@ jobs:
steps:
- name: Checkout Code
uses: actions/checkout@v7
uses: actions/checkout@v7.0.1
- name: Run image script tests + shellcheck
run: make test-images
eks_mkosi_image:
name: Build mkosi EKS image
# A fork PR's code must never run on a persistent self-hosted runner: it
# shares disk, caches and credentials with the release jobs.
if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository
runs-on: [self-hosted, ci-single]
concurrency:
group: eks-mkosi-image-${{ github.ref }}
@@ -43,10 +46,10 @@ jobs:
steps:
- name: Checkout Code
uses: actions/checkout@v7
uses: actions/checkout@v7.0.1
- name: Set up Go
uses: actions/setup-go@v7
uses: actions/setup-go@v7.0.0
with:
go-version-file: "go.mod"
# Self-hosted: the module + build cache already persists on local
+7 -7
View File
@@ -80,25 +80,25 @@ jobs:
echo "TMPDIR=$E2E_ROOT/work" >> "$GITHUB_ENV"
echo "PERF_RESULTS_ROOT=$E2E_ROOT/predastore-perf-3node-$GITHUB_RUN_ID" >> "$GITHUB_ENV"
- uses: actions/create-github-app-token@v3
- uses: actions/create-github-app-token@v3.3.0
id: app-token
with:
client-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
repositories: mulga
- uses: actions/checkout@v7
- uses: actions/checkout@v7.0.1
with:
repository: mulgadc/mulga
token: ${{ steps.app-token.outputs.token }}
sparse-checkout: scripts/tofu-cluster
path: mulga
- uses: actions/checkout@v7
- uses: actions/checkout@v7.0.1
with:
path: spinifex
- uses: actions/setup-go@v7
- uses: actions/setup-go@v7.0.0
with:
go-version-file: spinifex/go.mod
cache: false
@@ -116,7 +116,7 @@ jobs:
run: bash .github/resolve-sibling-refs.sh
working-directory: spinifex
- uses: actions/checkout@v7
- uses: actions/checkout@v7.0.1
id: predastore
with:
repository: mulgadc/predastore
@@ -366,7 +366,7 @@ jobs:
# Report-only, matching cell-23: the baseline comparison and any
# regression gate are future work once run-to-run spread is known from
# real bare-metal runs.
- uses: actions/upload-artifact@v7
- uses: actions/upload-artifact@v7.0.2
if: always()
with:
name: predastore-perf-3node-${{ github.run_id }}
@@ -407,7 +407,7 @@ jobs:
- name: Upload tofu log
if: always()
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v7.0.2
with:
name: tofu-log-perf3node-${{ github.run_id }}
path: ${{ env.TOFU_LOG }}
+18 -30
View File
@@ -17,28 +17,12 @@ name: Publish System AMIs
# spx admin images import --name spinifex-rds-postgres
# spx admin images import --name spinifex-rds-mariadb
#
# Publishing overwrites the mirror artifact, so it is a deliberate action, not
# something every push should trigger: it runs manually, or from release.yml
# once a tag has passed its gates. The `variant` input scopes which images
# build — the Ubuntu GPU legs are 16G and much slower (NVIDIA driver download +
# DKMS build), so publishing only the CPU AMIs stays cheap.
# Publishing overwrites the mirror artifact, so it runs only from release.yml
# once a tag has passed its gates, with siblings at the tags go.mod requires.
# The `variant` input scopes which images build — the Ubuntu GPU legs are 16G
# and much slower (NVIDIA driver download + DKMS build).
on:
workflow_dispatch:
inputs:
variant:
description: "Which images to build"
type: choice
default: all
options:
- all
- cpu
- gpu
- rds
dry_run:
description: "Build + convert only; skip the R2 upload"
type: boolean
default: false
workflow_call:
inputs:
variant:
@@ -94,40 +78,44 @@ jobs:
steps:
- name: Checkout Spinifex
uses: actions/checkout@v7
uses: actions/checkout@v7.0.1
with:
path: spinifex
- name: Resolve sibling release tags from go.mod
id: siblings
run: bash spinifex/.github/resolve-release-sibling-refs.sh spinifex/go.mod
- name: Checkout Viperblock
uses: actions/checkout@v7
uses: actions/checkout@v7.0.1
with:
repository: mulgadc/viperblock
ref: dev
ref: ${{ steps.siblings.outputs.viperblock }}
path: viperblock
- name: Checkout Predastore
uses: actions/checkout@v7
uses: actions/checkout@v7.0.1
with:
repository: mulgadc/predastore
ref: dev
ref: ${{ steps.siblings.outputs.predastore }}
path: predastore
- name: Checkout Northstar
uses: actions/checkout@v7
uses: actions/checkout@v7.0.1
with:
repository: mulgadc/northstar
ref: dev
ref: ${{ steps.siblings.outputs.northstar }}
path: northstar
- name: Checkout Bluebottle
uses: actions/checkout@v7
uses: actions/checkout@v7.0.1
with:
repository: mulgadc/bluebottle
ref: dev
ref: ${{ steps.siblings.outputs.bluebottle }}
path: bluebottle
- name: Setup Go
uses: actions/setup-go@v7
uses: actions/setup-go@v7.0.0
with:
go-version-file: "spinifex/go.mod"
# Self-hosted runner: the Go module + build cache already persists on
+66 -33
View File
@@ -5,48 +5,57 @@ on:
tags: [ "v*" ]
permissions:
contents: write
contents: read
jobs:
distro-amd64:
runs-on: ubuntu-26.04
steps:
- name: Checkout Spinifex
uses: actions/checkout@v7
uses: actions/checkout@v7.0.1
with:
path: spinifex
- name: Resolve sibling release tags from go.mod
id: siblings
run: bash spinifex/.github/resolve-release-sibling-refs.sh spinifex/go.mod
- name: Checkout Predastore
uses: actions/checkout@v7
uses: actions/checkout@v7.0.1
with:
repository: mulgadc/predastore
ref: ${{ steps.siblings.outputs.predastore }}
path: predastore
- name: Checkout Viperblock
uses: actions/checkout@v7
uses: actions/checkout@v7.0.1
with:
repository: mulgadc/viperblock
ref: ${{ steps.siblings.outputs.viperblock }}
path: viperblock
- name: Checkout Northstar
uses: actions/checkout@v7
uses: actions/checkout@v7.0.1
with:
repository: mulgadc/northstar
ref: ${{ steps.siblings.outputs.northstar }}
path: northstar
- name: Checkout Bluebottle
uses: actions/checkout@v7
uses: actions/checkout@v7.0.1
with:
repository: mulgadc/bluebottle
ref: ${{ steps.siblings.outputs.bluebottle }}
path: bluebottle
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@v4.4.1
- name: Set up Go
uses: actions/setup-go@v7
uses: actions/setup-go@v7.0.0
with:
go-version: "1.27.0"
go-version-file: spinifex/go.mod
cache: false
- name: Build microVM artifacts (lb-agent + kernel + initramfs)
run: cd spinifex && make build-microvm-image
@@ -57,7 +66,7 @@ jobs:
run: cd spinifex && make distro-amd64 VERSION="$VERSION"
- name: Upload amd64 Artifacts
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v7.0.2
with:
name: distro-amd64
path: |
@@ -69,36 +78,44 @@ jobs:
runs-on: ubuntu-26.04-arm
steps:
- name: Checkout Spinifex
uses: actions/checkout@v7
uses: actions/checkout@v7.0.1
with:
path: spinifex
- name: Resolve sibling release tags from go.mod
id: siblings
run: bash spinifex/.github/resolve-release-sibling-refs.sh spinifex/go.mod
- name: Checkout Predastore
uses: actions/checkout@v7
uses: actions/checkout@v7.0.1
with:
repository: mulgadc/predastore
ref: ${{ steps.siblings.outputs.predastore }}
path: predastore
- name: Checkout Viperblock
uses: actions/checkout@v7
uses: actions/checkout@v7.0.1
with:
repository: mulgadc/viperblock
ref: ${{ steps.siblings.outputs.viperblock }}
path: viperblock
- name: Checkout Northstar
uses: actions/checkout@v7
uses: actions/checkout@v7.0.1
with:
repository: mulgadc/northstar
ref: ${{ steps.siblings.outputs.northstar }}
path: northstar
- name: Checkout Bluebottle
uses: actions/checkout@v7
uses: actions/checkout@v7.0.1
with:
repository: mulgadc/bluebottle
ref: ${{ steps.siblings.outputs.bluebottle }}
path: bluebottle
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v4
uses: docker/setup-buildx-action@v4.4.1
- name: Build arm64 Tarball
env:
@@ -106,7 +123,7 @@ jobs:
run: cd spinifex && make distro-arm64 VERSION="$VERSION"
- name: Upload arm64 Artifacts
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v7.0.2
with:
name: distro-arm64
path: |
@@ -116,21 +133,23 @@ jobs:
release:
needs: [ distro-amd64, distro-arm64 ]
runs-on: ubuntu-26.04
permissions:
contents: write
steps:
- name: Download amd64 Artifacts
uses: actions/download-artifact@v8
uses: actions/download-artifact@v8.0.2
with:
name: distro-amd64
path: artifacts
- name: Download arm64 Artifacts
uses: actions/download-artifact@v8
uses: actions/download-artifact@v8.0.2
with:
name: distro-arm64
path: artifacts
- name: Create Draft GitHub Release
uses: softprops/action-gh-release@v3
uses: softprops/action-gh-release@v3.0.3
with:
draft: true
files: |
@@ -140,7 +159,7 @@ jobs:
generate_release_notes: true
- name: Upload Tarball for Verify Job
uses: actions/upload-artifact@v7
uses: actions/upload-artifact@v7.0.2
with:
name: release-assets
path: artifacts/
@@ -167,14 +186,14 @@ jobs:
| sort -r | xargs -r -I{} sudo umount -l -R {} || true
sudo rm -rf --one-file-system "$GITHUB_WORKSPACE"/*
- uses: actions/create-github-app-token@v3
- uses: actions/create-github-app-token@v3.3.0
id: app-token
with:
client-id: ${{ secrets.APP_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
repositories: mulga
- uses: actions/checkout@v7
- uses: actions/checkout@v7.0.1
with:
repository: mulgadc/mulga
token: ${{ steps.app-token.outputs.token }}
@@ -183,13 +202,13 @@ jobs:
# Only the shared shell helpers: this job builds nothing from source, but
# it drives the same libvirt provider the e2e cells retry around.
- uses: actions/checkout@v7
- uses: actions/checkout@v7.0.1
with:
sparse-checkout: .github/scripts
path: spinifex
- name: Download Release Assets
uses: actions/download-artifact@v8
uses: actions/download-artifact@v8.0.2
with:
name: release-assets
path: release-assets
@@ -231,6 +250,8 @@ jobs:
promote:
needs: [ verify, baremetal ]
runs-on: ubuntu-26.04
permissions:
contents: write
env:
TAG: ${{ github.ref_name }}
REPO: ${{ github.repository }}
@@ -263,6 +284,8 @@ jobs:
needs: [ verify, baremetal ]
if: failure()
runs-on: ubuntu-26.04
permissions:
contents: write
env:
TAG: ${{ github.ref_name }}
REPO: ${{ github.repository }}
@@ -280,6 +303,8 @@ jobs:
iso:
needs: release
runs-on: [ self-hosted, ci-single ]
permissions:
contents: write
# ISO build (~60-90m) overlaps the parallel baremetal run (~120m); the
# upload gate then waits for baremetal to finish, so the job must outlast it.
timeout-minutes: 180
@@ -294,7 +319,7 @@ jobs:
sudo umount -l "${ROOTFS}/proc" 2>/dev/null || true
sudo rm -rf --one-file-system "$GITHUB_WORKSPACE"/*
- uses: actions/create-github-app-token@v3
- uses: actions/create-github-app-token@v3.3.0
id: app-token
with:
client-id: ${{ secrets.APP_ID }}
@@ -302,37 +327,45 @@ jobs:
repositories: mulga
- name: Checkout Spinifex
uses: actions/checkout@v7
uses: actions/checkout@v7.0.1
with:
path: spinifex
ref: ${{ github.ref }}
- name: Resolve sibling release tags from go.mod
id: siblings
run: bash spinifex/.github/resolve-release-sibling-refs.sh spinifex/go.mod
- name: Checkout Predastore
uses: actions/checkout@v7
uses: actions/checkout@v7.0.1
with:
repository: mulgadc/predastore
ref: ${{ steps.siblings.outputs.predastore }}
path: predastore
- name: Checkout Viperblock
uses: actions/checkout@v7
uses: actions/checkout@v7.0.1
with:
repository: mulgadc/viperblock
ref: ${{ steps.siblings.outputs.viperblock }}
path: viperblock
- name: Checkout Northstar
uses: actions/checkout@v7
uses: actions/checkout@v7.0.1
with:
repository: mulgadc/northstar
ref: ${{ steps.siblings.outputs.northstar }}
path: northstar
- name: Checkout Bluebottle
uses: actions/checkout@v7
uses: actions/checkout@v7.0.1
with:
repository: mulgadc/bluebottle
ref: ${{ steps.siblings.outputs.bluebottle }}
path: bluebottle
- name: Checkout Mulga iso-builder
uses: actions/checkout@v7
uses: actions/checkout@v7.0.1
with:
repository: mulgadc/mulga
token: ${{ steps.app-token.outputs.token }}
@@ -343,7 +376,7 @@ jobs:
run: sudo make -C mulga/scripts/iso-builder clean
- name: Set up Go
uses: actions/setup-go@v7
uses: actions/setup-go@v7.0.0
with:
go-version-file: spinifex/go.mod
cache: false
+9 -9
View File
@@ -3,7 +3,7 @@
#
# tests/integration/segscan_oracle_test.go needs the mulga umbrella repo's
# scripts/segscan checked out alongside spinifex (it is a separate Go module,
# `package main`, and cannot be imported — see spinifex/testutil/segscanoracle).
# `package main`, and cannot be imported — see internal/testkit/segscanoracle).
# spinifex.yml's jobs check out only spinifex/viperblock/predastore and run on
# every PR; adding a second-repo checkout there would make every spinifex PR
# depend on mulga main being healthy and pay that checkout cost on every run,
@@ -25,7 +25,7 @@ jobs:
steps:
- name: Checkout Spinifex
uses: actions/checkout@v7
uses: actions/checkout@v7.0.1
with:
path: spinifex
fetch-depth: 2
@@ -38,34 +38,34 @@ jobs:
working-directory: spinifex
- name: Checkout Viperblock
uses: actions/checkout@v7
uses: actions/checkout@v7.0.1
with:
repository: mulgadc/viperblock
ref: ${{ steps.siblings.outputs.viperblock }}
path: viperblock
- name: Checkout Predastore
uses: actions/checkout@v7
uses: actions/checkout@v7.0.1
with:
repository: mulgadc/predastore
ref: ${{ steps.siblings.outputs.predastore }}
path: predastore
- name: Checkout Northstar
uses: actions/checkout@v7
uses: actions/checkout@v7.0.1
with:
repository: mulgadc/northstar
ref: ${{ steps.siblings.outputs.northstar }}
path: northstar
- name: Checkout Bluebottle
uses: actions/checkout@v7
uses: actions/checkout@v7.0.1
with:
repository: mulgadc/bluebottle
ref: ${{ steps.siblings.outputs.bluebottle }}
path: bluebottle
- uses: actions/create-github-app-token@v3
- uses: actions/create-github-app-token@v3.3.0
id: app-token
with:
client-id: ${{ secrets.APP_ID }}
@@ -73,7 +73,7 @@ jobs:
repositories: mulga
- name: Checkout Mulga (scripts/segscan only)
uses: actions/checkout@v7
uses: actions/checkout@v7.0.1
with:
repository: mulgadc/mulga
token: ${{ steps.app-token.outputs.token }}
@@ -81,7 +81,7 @@ jobs:
path: mulga
- name: Setup Go
uses: actions/setup-go@v7
uses: actions/setup-go@v7.0.0
with:
cache-dependency-path: "spinifex/go.sum"
go-version-file: "spinifex/go.mod"
+29 -29
View File
@@ -27,7 +27,7 @@ jobs:
steps:
- name: Checkout Spinifex
uses: actions/checkout@v7
uses: actions/checkout@v7.0.1
with:
path: spinifex
fetch-depth: 2
@@ -40,28 +40,28 @@ jobs:
working-directory: spinifex
- name: Checkout Viperblock
uses: actions/checkout@v7
uses: actions/checkout@v7.0.1
with:
repository: mulgadc/viperblock
ref: ${{ steps.siblings.outputs.viperblock }}
path: viperblock
- name: Checkout Predastore
uses: actions/checkout@v7
uses: actions/checkout@v7.0.1
with:
repository: mulgadc/predastore
ref: ${{ steps.siblings.outputs.predastore }}
path: predastore
- name: Checkout Northstar
uses: actions/checkout@v7
uses: actions/checkout@v7.0.1
with:
repository: mulgadc/northstar
ref: ${{ steps.siblings.outputs.northstar }}
path: northstar
- name: Checkout Bluebottle
uses: actions/checkout@v7
uses: actions/checkout@v7.0.1
with:
repository: mulgadc/bluebottle
ref: ${{ steps.siblings.outputs.bluebottle }}
@@ -71,7 +71,7 @@ jobs:
# the last go.sum change and never refreshed it. Both caches are managed
# below instead.
- name: Setup Go
uses: actions/setup-go@v7
uses: actions/setup-go@v7.0.0
with:
go-version-file: "spinifex/go.mod"
cache: false
@@ -80,7 +80,7 @@ jobs:
# setup-go hashed spinifex/go.sum alone, which left the sibling modules'
# dependencies out of the key and re-downloading on every run.
- name: Cache Go modules
uses: actions/cache@v6
uses: actions/cache@v6.1.0
with:
path: ~/go/pkg/mod
key: gomod-${{ hashFiles('spinifex/go.sum', 'viperblock/go.sum', 'predastore/go.sum', 'northstar/go.sum', 'bluebottle/go.sum') }}
@@ -93,7 +93,7 @@ jobs:
# a PR reads the base branch's entry without spending a minute uploading
# one only that PR would ever read.
- name: Restore Go build cache
uses: actions/cache/restore@v6
uses: actions/cache/restore@v6.1.0
with:
path: ~/.cache/go-build
key: gobuild-${{ github.job }}-${{ github.run_id }}
@@ -150,7 +150,7 @@ jobs:
- name: Save Go build cache
if: ${{ !cancelled() && (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main') }}
uses: actions/cache/save@v6
uses: actions/cache/save@v6.1.0
with:
path: ~/.cache/go-build
key: gobuild-${{ github.job }}-${{ github.run_id }}
@@ -161,7 +161,7 @@ jobs:
steps:
- name: Checkout Spinifex
uses: actions/checkout@v7
uses: actions/checkout@v7.0.1
with:
path: spinifex
@@ -173,28 +173,28 @@ jobs:
working-directory: spinifex
- name: Checkout Viperblock
uses: actions/checkout@v7
uses: actions/checkout@v7.0.1
with:
repository: mulgadc/viperblock
ref: ${{ steps.siblings.outputs.viperblock }}
path: viperblock
- name: Checkout Predastore
uses: actions/checkout@v7
uses: actions/checkout@v7.0.1
with:
repository: mulgadc/predastore
ref: ${{ steps.siblings.outputs.predastore }}
path: predastore
- name: Checkout Northstar
uses: actions/checkout@v7
uses: actions/checkout@v7.0.1
with:
repository: mulgadc/northstar
ref: ${{ steps.siblings.outputs.northstar }}
path: northstar
- name: Checkout Bluebottle
uses: actions/checkout@v7
uses: actions/checkout@v7.0.1
with:
repository: mulgadc/bluebottle
ref: ${{ steps.siblings.outputs.bluebottle }}
@@ -204,7 +204,7 @@ jobs:
# the last go.sum change and never refreshed it. Both caches are managed
# below instead.
- name: Setup Go
uses: actions/setup-go@v7
uses: actions/setup-go@v7.0.0
with:
go-version-file: "spinifex/go.mod"
cache: false
@@ -213,7 +213,7 @@ jobs:
# setup-go hashed spinifex/go.sum alone, which left the sibling modules'
# dependencies out of the key and re-downloading on every run.
- name: Cache Go modules
uses: actions/cache@v6
uses: actions/cache@v6.1.0
with:
path: ~/go/pkg/mod
key: gomod-${{ hashFiles('spinifex/go.sum', 'viperblock/go.sum', 'predastore/go.sum', 'northstar/go.sum', 'bluebottle/go.sum') }}
@@ -226,7 +226,7 @@ jobs:
# a PR reads the base branch's entry without spending a minute uploading
# one only that PR would ever read.
- name: Restore Go build cache
uses: actions/cache/restore@v6
uses: actions/cache/restore@v6.1.0
with:
path: ~/.cache/go-build
key: gobuild-${{ github.job }}-${{ github.run_id }}
@@ -251,7 +251,7 @@ jobs:
- name: Save Go build cache
if: ${{ !cancelled() && (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main') }}
uses: actions/cache/save@v6
uses: actions/cache/save@v6.1.0
with:
path: ~/.cache/go-build
key: gobuild-${{ github.job }}-${{ github.run_id }}
@@ -262,7 +262,7 @@ jobs:
steps:
- name: Checkout Spinifex
uses: actions/checkout@v7
uses: actions/checkout@v7.0.1
with:
path: spinifex
fetch-depth: 2
@@ -275,28 +275,28 @@ jobs:
working-directory: spinifex
- name: Checkout Viperblock
uses: actions/checkout@v7
uses: actions/checkout@v7.0.1
with:
repository: mulgadc/viperblock
ref: ${{ steps.siblings.outputs.viperblock }}
path: viperblock
- name: Checkout Predastore
uses: actions/checkout@v7
uses: actions/checkout@v7.0.1
with:
repository: mulgadc/predastore
ref: ${{ steps.siblings.outputs.predastore }}
path: predastore
- name: Checkout Northstar
uses: actions/checkout@v7
uses: actions/checkout@v7.0.1
with:
repository: mulgadc/northstar
ref: ${{ steps.siblings.outputs.northstar }}
path: northstar
- name: Checkout Bluebottle
uses: actions/checkout@v7
uses: actions/checkout@v7.0.1
with:
repository: mulgadc/bluebottle
ref: ${{ steps.siblings.outputs.bluebottle }}
@@ -306,7 +306,7 @@ jobs:
# the last go.sum change and never refreshed it. Both caches are managed
# below instead.
- name: Setup Go
uses: actions/setup-go@v7
uses: actions/setup-go@v7.0.0
with:
go-version-file: "spinifex/go.mod"
cache: false
@@ -315,7 +315,7 @@ jobs:
# setup-go hashed spinifex/go.sum alone, which left the sibling modules'
# dependencies out of the key and re-downloading on every run.
- name: Cache Go modules
uses: actions/cache@v6
uses: actions/cache@v6.1.0
with:
path: ~/go/pkg/mod
key: gomod-${{ hashFiles('spinifex/go.sum', 'viperblock/go.sum', 'predastore/go.sum', 'northstar/go.sum', 'bluebottle/go.sum') }}
@@ -328,7 +328,7 @@ jobs:
# a PR reads the base branch's entry without spending a minute uploading
# one only that PR would ever read.
- name: Restore Go build cache
uses: actions/cache/restore@v6
uses: actions/cache/restore@v6.1.0
with:
path: ~/.cache/go-build
key: gobuild-${{ github.job }}-${{ github.run_id }}
@@ -338,9 +338,9 @@ jobs:
run: go work init ./spinifex ./viperblock ./predastore ./northstar ./bluebottle
- name: Lint
uses: golangci/golangci-lint-action@v9
uses: golangci/golangci-lint-action@v9.3.0
with:
version: v2.13.0
version: v2.14.0
working-directory: spinifex
- name: Govulncheck
@@ -363,7 +363,7 @@ jobs:
- name: Save Go build cache
if: ${{ !cancelled() && (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main') }}
uses: actions/cache/save@v6
uses: actions/cache/save@v6.1.0
with:
path: ~/.cache/go-build
key: gobuild-${{ github.job }}-${{ github.run_id }}
+21 -12
View File
@@ -6,14 +6,14 @@ on:
- main
- dev
paths:
- spinifex/services/spinifexui/frontend/**
- spinifex/runtime/roles/spinifexui/frontend/**
- .github/workflows/ui.yml
pull_request:
branches:
- main
- dev
paths:
- spinifex/services/spinifexui/frontend/**
- spinifex/runtime/roles/spinifexui/frontend/**
- .github/workflows/ui.yml
workflow_dispatch:
@@ -26,25 +26,25 @@ jobs:
runs-on: ubuntu-26.04
defaults:
run:
working-directory: spinifex/services/spinifexui/frontend
working-directory: spinifex/runtime/roles/spinifexui/frontend
steps:
- name: Checkout Code
uses: actions/checkout@v7
uses: actions/checkout@v7.0.1
with:
sparse-checkout: spinifex/services/spinifexui/frontend
sparse-checkout: spinifex/runtime/roles/spinifexui/frontend
- name: Setup pnpm
uses: pnpm/action-setup@v6
uses: pnpm/action-setup@v6.1.0
with:
package_json_file: spinifex/services/spinifexui/frontend/package.json
package_json_file: spinifex/runtime/roles/spinifexui/frontend/package.json
- name: Setup Node.js
uses: actions/setup-node@v7
uses: actions/setup-node@v7.1.0
with:
cache: "pnpm"
cache-dependency-path: spinifex/services/spinifexui/frontend/pnpm-lock.yaml
node-version-file: spinifex/services/spinifexui/frontend/.nvmrc
cache-dependency-path: spinifex/runtime/roles/spinifexui/frontend/pnpm-lock.yaml
node-version-file: spinifex/runtime/roles/spinifexui/frontend/.nvmrc
- name: Install Dependencies
run: pnpm install --frozen-lockfile
@@ -58,5 +58,14 @@ jobs:
- name: Build Frontend
run: pnpm build
- name: Check for generated route tree drift
run: git diff --exit-code -- src/routeTree.gen.ts
# spx embeds the committed dist/, so a bundle that differs from a clean
# build of this source would ship JavaScript CI never built. Porcelain
# rather than git diff, so added and deleted chunks count too.
- name: Check for generated file drift
run: |
drift=$(git status --porcelain -- src/routeTree.gen.ts src/lib/cn-tables.gen.ts dist)
if [ -n "$drift" ]; then
echo "::error::Generated files differ from a clean build. Run pnpm build and commit the result."
echo "$drift"
exit 1
fi
+1 -1
View File
@@ -19,7 +19,7 @@ bin/
dist/
build/microvm/vmlinuz
build/microvm/initramfs.cpio.gz
!spinifex/services/spinifexui/frontend/dist/
!spinifex/runtime/roles/spinifexui/frontend/dist/
tmp/
*.test
*.out
+8 -9
View File
@@ -1,5 +1,8 @@
version: "2"
run:
allow-serial-runners: true
issues:
max-same-issues: 10
@@ -56,7 +59,6 @@ linters:
- unconvert # removes unnecessary type conversions
- usestdlibvars # detects hardcoded values that should use stdlib constants
- usetesting # catches os.TempDir() etc. in tests — should use t.TempDir()
- wastedassign # finds assignments to variables that are never subsequently read
- whitespace # detects leading and trailing whitespace in functions
settings:
@@ -69,17 +71,18 @@ linters:
- pkg: math/rand$
desc: Use math/rand/v2 instead, see https://go.dev/blog/randv2
# The EC2 control plane reaches block storage through ebsprovider, not
# The control plane reaches block storage through the EBS provider, not
# by importing the engine. viperblock.New starts a chunk uploader, so
# every construction is a writer against a volume viperblockd serves.
# Nothing under handlers/ may import viperblock, in any form, tests
# No domain or legacy handler may import viperblock, in any form, tests
# included. A types-only import counts: one became an engine import.
ec2-control-plane:
files:
- '**/spinifex/domains/**'
- '**/spinifex/handlers/**'
deny:
- pkg: github.com/mulgadc/viperblock
desc: Use the ebsprovider.EBSProvider interface; the control plane must not construct or name viperblock types.
desc: Use the providers/ebs EBSProvider interface; the control plane must not construct or name viperblock types.
embeddedstructfieldcheck:
forbid-mutex: true
@@ -143,15 +146,11 @@ linters:
staticcheck:
checks:
- all
- -ST1000 # package comment
- -ST1003 # naming conventions (Id→ID etc.) — too noisy to retrofit
- -ST1012 # error var naming — errname covers this + error type suffixes
- -ST1013 # HTTP status constants — usestdlibvars is broader
- -ST1016 # receiver name consistency (too noisy to retrofit)
- -ST1020 # godoc on exported types — not enforcing yet
- -ST1021 # godoc on exported type groups — not enforcing yet
- -ST1022 # godoc on exported vars/consts — not enforcing yet
- -SA4006 # value overwritten — ineffassign + wastedassign cover this better
- -SA4006 # value overwritten — ineffassign catches strictly more of these
- -SA9005 # marshaling structs with no exported fields — intended {} for empty API-response types
testifylint:
+97
View File
@@ -0,0 +1,97 @@
# How to Contribute
We welcome contributions to Spinifex!
Report security vulnerabilities privately through [SECURITY.md](SECURITY.md), not as a public issue.
**AWS parity is the design rule.** When Spinifex's behaviour is in question, the answer is what AWS does. If you believe Spinifex diverges from AWS, state what AWS does and show it with a test or a reproduction against a running cluster.
## Understanding the Codebase
Every AWS call enters through a SigV4-authenticated gateway, is published to NATS, and is answered by the daemon that claims it. Start with these:
| Doc | Covers |
|---|---|
| [`docs/DESIGN.md`](docs/DESIGN.md) | Architecture, request flow, daemons, storage integration, file layout. |
| [`docs/COMMANDS.md`](docs/COMMANDS.md) | The `spx admin` CLI and the systemd units it manages. |
| [`docs/coverage/`](docs/coverage/README.md) | Which AWS API operations each service implements. |
| [`scripts/README.md`](scripts/README.md) | Install, dev-environment, and image-build scripts. |
| [`docs/PACKAGE_BOUNDARY_MIGRATION.md`](docs/PACKAGE_BOUNDARY_MIGRATION.md) | ADR-0001 implementation inventory and incremental source moves. |
User-facing docs are published at [docs.mulgadc.com](https://docs.mulgadc.com).
## Development Setup
These steps build Spinifex from source and run it as a single-node cluster, following the [Source Install](https://docs.mulgadc.com/docs/install-source) guide.
### Prerequisites
- **Ubuntu 26.04** or **Debian 13**, on x86_64 or aarch64
- A host with hardware virtualisation (KVM) and `sudo` access
- The host's WAN interface enslaved to a Linux bridge named `br-wan`, with the host IP, default route and DHCP on the bridge rather than the bare NIC (see [VPC Networking → Bridge Setup](https://docs.mulgadc.com/docs/vpc-networking#bridge-setup-physical-network-wiring))
Check the bridge before continuing:
```bash
ip -br link show br-wan # bridge exists and is UP
ip route # default route's dev is br-wan
```
### Install and Run
```bash
mkdir -p ~/Development/mulga && cd ~/Development/mulga
git clone https://github.com/mulgadc/spinifex.git
sudo make -C spinifex quickinstall # system packages, Go 1.27, AWS CLI v2
export PATH=$PATH:/usr/local/go/bin
cd spinifex
./scripts/clone-deps.sh # sibling repos: viperblock, predastore, northstar, bluebottle
./scripts/dev-install.sh # build, install, init a one-node cluster, start services
AWS_PROFILE=spinifex aws ec2 describe-instance-types
```
A list of instance types means the cluster is up.
`make preflight` also needs `shellcheck` and `golangci-lint`
```bash
sudo apt-get install -y shellcheck
curl -sSfL https://golangci-lint.run/install.sh | sh -s -- -b "$(go env GOPATH)/bin" v2.14.0
```
The web console additionally needs Node.js 24 and `pnpm`.
## Development Workflow
| Command | Use it to |
|---|---|
| `make deploy` | Rebuild, install `spx`, and restart `spinifex.target` after a code change. |
| `make reinstall` | Re-run `dev-install.sh` after changing systemd units, helper scripts, or logrotate config. |
| `./scripts/reset-dev-env.sh` | Wipe all cluster state and reinstall from scratch, keeping your network topology. |
| `make test` | Run unit tests (`make test-race` for the race detector). |
| `make test-integration` | Run the real gateway router against embedded NATS JetStream. |
| `make preflight` | Run the pre-commit gate: lint, `govulncheck`, coverage, and manifest and script checks. Must pass. |
Follow the services with `journalctl -u 'spinifex-*' -f`.
The web console lives in `spinifex/runtime/roles/spinifexui/frontend/` (`pnpm install`, `pnpm dev`, `pnpm lint`, `pnpm test`). `spx` embeds its checked-in `dist/`, so run `make build-ui` and commit the rebuilt `dist/` with your change.
## Creating a Pull Request
1. Fork [mulgadc/spinifex](https://github.com/mulgadc/spinifex) and clone your fork into the same parent directory as the sibling repositories, branching off `dev`:
```bash
cd ~/Development/mulga
git clone git@github.com:YOUR_USERNAME/spinifex.git && cd spinifex
git remote add upstream git@github.com:mulgadc/spinifex.git
git fetch upstream && git checkout -b feat/my-feature upstream/dev
```
2. Make your change, then verify it:
- `make preflight` passes.
- `go mod tidy` leaves only intended changes in `go.mod` and `go.sum`, with none of the `clone-deps.sh` `replace` lines.
- Runtime changes are exercised on your dev cluster with `make deploy`.
3. Commit using [Conventional Commits](https://www.conventionalcommits.org/) (`feat:`, `fix:`, `refactor:`, `docs:`, `chore:`), push, and open a pull request against `dev`, filling in the template.
+48 -17
View File
@@ -54,12 +54,12 @@ build: go_build build-installer build-lb-agent generate-aws-model-coverage
# Build spinifex-ui frontend (requires pnpm)
build-ui:
@echo -e "\n....Building spinifex-ui frontend...."
cd spinifex/services/spinifexui/frontend && pnpm build
cd spinifex/runtime/roles/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)
LDFLAGS := -s -w -X github.com/mulgadc/spinifex/spinifex/operator/cli.Version=$(VERSION) -X github.com/mulgadc/spinifex/spinifex/operator/cli.Commit=$(COMMIT)
go_build:
@echo -e "\n....Building $(GO_PROJECT_NAME)"
@@ -149,21 +149,39 @@ install-microvm: $(MICROVM_ARTIFACTS) ## Install microVM artifacts to /usr/share
# the pre-commit gate: manifest checks, lint, vuln, and the unit and e2e-harness tiers.
# integration and race tests skipped to keep quick, they run in CI.
#
# -j2 overlaps the only two heavy gates, lint and test-cover: alone each leaves
# most of the machine idle, so pairing them cuts a cold run by about a quarter.
# -Otarget keeps each gate's output in one block instead of interleaving them.
# manifest-check manifest-lint test-package-check removed, can add back later
preflight:
@$(MAKE) --no-print-directory QUIET=1 manifest-check manifest-lint lint govulncheck test-cover diff-coverage test-package-check test-harness test-build-scripts
@$(MAKE) --no-print-directory -j2 -Otarget QUIET=1 lint govulncheck test-cover diff-coverage test-harness test-build-scripts manifest-check manifest-lint
@echo -e "\n ✅ Preflight passed — safe to commit."
# Shell suites + shellcheck for build/scripts/, the systemd-unit helpers that
# ship on every node (unlike scripts/images/, kept in preflight: a wrong
# restart decision here is an outage, not asset churn).
#
# NODE_SCRIPTS are the four that setup.sh installs into
# /usr/local/share/spinifex on every node. They are covered here for the same
# reason: between them they install the software, wire OVN, erase the state and
# remove the install, so a shellcheck warning in one is an outage or a wrong
# deletion, not a style note.
NODE_SCRIPTS := scripts/setup.sh scripts/setup-ovn.sh scripts/install-node.sh scripts/smoke-test.sh scripts/node-reset.sh scripts/uninstall-spx.sh
test-build-scripts:
@echo -e "\n....Running build/scripts/**/*_test.sh...."
@for t in $$(find build/scripts -name '*_test.sh' | sort); do \
echo "-- $$t"; \
sh "$$t" || exit 1; \
done
@echo -e "\n....Running shellcheck over build/scripts/**/*.sh...."
shellcheck -S warning $$(find build/scripts -name '*.sh' | sort)
@echo -e "\n....Running scripts/*_test.sh...."
@for t in $$(find scripts -maxdepth 1 -name '*_test.sh' | sort); do \
echo "-- $$t"; \
sh "$$t" || exit 1; \
done
@echo -e "\n....Running shellcheck over build/scripts/**/*.sh + the node scripts...."
shellcheck -S warning $$(find build/scripts -name '*.sh' | sort) $(NODE_SCRIPTS)
@echo " test-build-scripts ok"
# E2E harness unit tests. Build-tagged `e2e` so they're skipped by the
@@ -185,22 +203,24 @@ AWS_MODEL_OPERATION_COVERAGE_DIR ?= $(CURDIR)/docs/coverage
# -count=1 is load-bearing: the conformance report is written by the test binary,
# so a cached pass skips the run, leaves no report, and the cat below fails the
# target. It also keeps the conformance gate honest — a cached result would mean
# the check never ran against this commit.
# the check never ran against this commit. The generated request sweep adds
# about 15s, most of it EC2 fan-outs waiting out their window for instances
# no daemon-lite owns.
test-integration:
@echo -e "\n....Running in-process integration tests...."
$(_Q)LOG_IGNORE=1 AWS_MODEL_CONFORMANCE_MODE=$(AWS_MODEL_CONFORMANCE_MODE) AWS_MODEL_CONFORMANCE_REPORT=$(AWS_MODEL_CONFORMANCE_REPORT) go test -count=1 -tags=integration -timeout 60s ./tests/integration/... $(_RACEQ)
$(_Q)LOG_IGNORE=1 AWS_MODEL_CONFORMANCE_MODE=$(AWS_MODEL_CONFORMANCE_MODE) AWS_MODEL_CONFORMANCE_REPORT=$(AWS_MODEL_CONFORMANCE_REPORT) go test -count=1 -tags=integration -timeout 120s ./tests/integration/... $(_RACEQ)
@cat $(AWS_MODEL_CONFORMANCE_REPORT)
@$(MAKE) --no-print-directory generate-aws-model-coverage
@echo "AWS operation coverage: $(AWS_MODEL_OPERATION_COVERAGE_DIR)"
generate-aws-model-coverage:
@go run ./cmd/aws-model-coverage -out $(AWS_MODEL_OPERATION_COVERAGE_DIR)
@go run ./cmd/aws-model-coverage -out $(AWS_MODEL_OPERATION_COVERAGE_DIR) -json $(AWS_MODEL_OPERATION_COVERAGE_DIR)/coverage.json
aws-model-coverage: generate-aws-model-coverage
@go run ./cmd/aws-model-coverage
# Segscan storage oracle: needs the mulga umbrella repo's scripts/segscan
# checked out alongside spinifex (see spinifex/testutil/segscanoracle), which
# checked out alongside spinifex (see internal/testkit/segscanoracle), which
# is not the default local or CI layout, so this is a separate target from
# test-integration rather than folded into it. Skips itself when segscan's
# source isn't found.
@@ -228,7 +248,7 @@ manifest-lint-update:
# Run unit tests
test:
@echo -e "\n....Running tests for $(GO_PROJECT_NAME)...."
LOG_IGNORE=1 go test -timeout 180s ./spinifex/... ./cmd/... ./internal/...
LOG_IGNORE=1 go test -timeout 180s ./spinifex/... ./cmd/... ./internal/... ./contracts/... ./architecture/...
# Empty locally, where reusing a cached result between runs is the point.
# CI passes -count=1 so a green run means the suite executed against that commit
@@ -239,13 +259,13 @@ GOTESTFLAGS ?=
COVERPROFILE ?= coverage.out
test-cover:
@echo -e "\n....Running tests with coverage for $(GO_PROJECT_NAME)...."
$(_Q)LOG_IGNORE=1 go test $(GOTESTFLAGS) -timeout 180s -coverprofile=$(COVERPROFILE) -covermode=atomic ./spinifex/... ./cmd/... ./internal/... $(_COVQ)
$(_Q)LOG_IGNORE=1 go test $(GOTESTFLAGS) -timeout 180s -coverprofile=$(COVERPROFILE) -covermode=atomic ./spinifex/... ./cmd/... ./internal/... ./contracts/... ./architecture/... $(_COVQ)
@scripts/check-coverage.sh $(COVERPROFILE) $(QUIET)
# Run unit tests with race detector
test-race:
@echo -e "\n....Running tests with race detector for $(GO_PROJECT_NAME)...."
$(_Q)LOG_IGNORE=1 go test $(GOTESTFLAGS) -race -timeout 300s ./spinifex/... ./cmd/... ./internal/... $(_RACEQ)
$(_Q)LOG_IGNORE=1 go test $(GOTESTFLAGS) -race -timeout 300s ./spinifex/... ./cmd/... ./internal/... ./contracts/... ./architecture/... $(_RACEQ)
# Unit tests for in-repo GitHub Actions (e.g. .github/actions/e2e-analyze).
# Kept out of `test-cover` so coverage % isn't diluted by CI-only tooling.
@@ -298,6 +318,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
@@ -334,7 +365,7 @@ reinstall:
clean:
rm -f ./bin/$(GO_PROJECT_NAME)
rm -rf spinifex/services/spinifexui/frontend/dist
rm -rf spinifex/runtime/roles/spinifexui/frontend/dist
install-system:
@echo -e "\n....Installing system dependencies for $(ARCH)...."
@@ -348,9 +379,9 @@ install-system:
ovn-central ovn-host openvswitch-switch dhcpcd-base
install-go:
@echo -e "\n....Installing Go 1.27.0 for $(ARCH) ($(GO_ARCH))...."
@echo -e "\n....Installing Go 1.27.2 for $(ARCH) ($(GO_ARCH))...."
@if [ ! -d "/usr/local/go" ]; then \
curl -L https://go.dev/dl/go1.27.0.linux-$(GO_ARCH).tar.gz | tar -C /usr/local -xz; \
curl -L https://go.dev/dl/go1.27.2.linux-$(GO_ARCH).tar.gz | tar -C /usr/local -xz; \
else \
echo "Go already installed in /usr/local/go"; \
fi
@@ -433,7 +464,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 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 \
+81 -168
View File
@@ -4,223 +4,136 @@
<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="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>
<a href="https://mulgadc.com"><img src="https://img.shields.io/badge/Site-mulga-orange?style=flat-square&logo=data:image/svg%2bxml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGZpbGw9IiNmZmYiIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTE2LjcxOCA4Ljg5MWMtMS4yODUgMS4zNy0zLjE3OCAyLjMxNy00LjY5NSAzLjQ0LS44NTQuNjMtMy4wODkgMi4yNy0xLjYxIDMuMzQ0IDEuNzg4IDEuMjk4IDYuMjQzLjE2NCA3Ljc4NS0xLjI2Ljg0LS43NzUuODE0LTEuODIyLS4zMjgtMi4yNTktMS4yMzUtLjQ3Mi0yLjY2LS4xMTEtMy45MTMuMDg3LS4wNDIuMDA3LS4xMzEuMDU1LS4xMjMtLjAyLjUyMy0uMzM2Ljk5My0uNzUgMS41MDMtMS4xMDIuNDkyLS4zNCAxLjA5Ny0uODI3IDEuNy0uODM4IDEuODcxLS4wMzQgMy43OTkuODkgNC4yODcgMi44MTUuODExIDMuMjAzLTMuMDA2IDUuNzE1LTUuNzg0IDUuOTQybDEuNjE0LS43NDdjLjYwNS0uMzYgMS4yMTctLjczNCAxLjc1Mi0xLjE5Ni4xMzMtLjExNS4yMy0uMjYzLjM0Mi0uMzczLjAyNy0uMDI2LjMwMi0uMTQ0LjE0NC0uMTUtMS40NDEuOTQ1LTMuMTI3IDEuNTcyLTQuODEzIDEuOTMyLS41OC4xMjMtMS4xOTUuMjQyLTEuNzg1LjI1Ny4wMTUuMDguMDg3LjA2NC4xNC4wOC40NzYuMTU0IDEuMDIuMjQ1IDEuNTE2LjMyLjA2OC4wNTItLjAwNi4wNzQtLjA2LjA4MS0uNDQ4LjA1OC0uOTIzLjE0My0xLjM3LjE2Ni0xLjI3LjA2NC0yLjU1LS4wNjgtMy43NzctLjM4bC0uNjktMS45NTdjLS4wNi4wMDYtLjA2My4wNi0uMDc5LjEwMy0uMDYuMTctLjMwNSAxLjQxNy0uMzg3IDEuNDM1LS42Ni0uMi0xLjI1MS0uNjQ3LTEuNzM2LTEuMTMybDEuMTUtMi4wNi0xLjgzNSAxLjAxMWMtLjI1OC0uNTgzLS4zMDUtMS4yNDMtLjIxOS0xLjg3bDIuMzc1LTEuMThjLS43MzktLjA0OS0xLjQ4Ni4wNjgtMi4yMi4xNDUuMTI1LS4zNi4yNzctLjcxOC40NjItMS4wNTEuMDY3LS4xMjIuNDI2LS43MjEuNTItLjcyNC44Ni4xNjQgMS43ODYuMjQxIDIuNjQ4LjA3NGwtMS44Ny0uNzcxLS4wNjktLjA5M2MuMzgyLS4zNi43Ny0uNzE3IDEuMTk5LTEuMDI0LjgwNC4yMzQgMS42My40MjIgMi40NzMuNDMzTDkuMzUgOS4zNDJhNyA3IDAgMCAxIC41NjctLjQwMWMuMTYtLjEwMy42MDktLjQwMi43NzItLjM4LjY1LjIyMSAxLjMyNC4zNzggMi4wMS40MzMtLjMxMy0uMzI1LS45MDktLjU2NC0xLjIxLS44NjgtLjAzMy0uMDM0LS4wNTgtLjAzNi0uMDQzLS4wOTguNzg3LS40NTIgMS42NjItLjkwMSAyLjM0LTEuNTE3LjY5LS42MjkgMS40MjEtMS42NTYuOTI0LTIuNjA1LjU3My4xODIgMS4wNjcuOTA2IDEuMDU0IDEuNTEyLS4wMzQgMS42NzYtMS44MjIgMy4xNy0yLjk0MyA0LjIyMiAxLjczMi0uNzI4IDMuNzE0LTIuMjMgMy43MS00LjMwNS0uMDAzLTEuNjg4LTEuNTQtMi4zNjUtMi45OTMtMi41MThhMy4yIDMuMiAwIDAgMS0uMzg1IDEuMDg3Yy0uNDI4LjcxOS0xLjMwMiAxLjE2OC0xLjc1OCAxLjkxNS0uMzExLjUxLS4zNyAxLjE5NS0xLjAzMSAxLjM5LS4yNy4wOC0uNjE3LjA5My0uODk3LjA5NGwuNjE1LS4zMjVjLjY4OS0uNTA2LjY3Ny0xLjQyIDEuMDk5LTIuMS0uMDUtLjA3LS41NDUtLjItLjY2LS4yMjktLjUxLS4xMjUtMS4zNzUtLjI4NS0xLjg4Ni0uMjUtLjE1Ny4wMS0uODY5LjEyMS0uOTMuMjQyLS4wODguMTc3LjM0MS45My40NjggMS4xMDEuMDI1LjAzNS4wNzcuMDI2LjA4LjAzLjAyNC4wMzEuMDA3LjEwNS0uMDYuMDhhNSA1IDAgMCAxLS40MDQtLjI4M2MtLjUwMy0uMzg4LTEuMDc4LS45NzQtMS40OTktMS40NDctLjA2OC0uMTY2LS4xNi0uMzUuMDAyLS40ODcuODg4LS41NSAxLjc2OC0xLjEyNiAyLjY3LTEuNjUxIDIuNjcxLTEuNTU4IDUuNzExLTMuMzE4IDguMjY3LS40NjUgMi4xMTkgMi4zNjYgMS41MTQgNS4yMTItLjUxMiA3LjM3MnptLTguMjI0LTUuMzhjLjY5OS0uMTIyIDIuMDE4LjU3MiAyLjIzNS0uNDU2LjAyMS0uMS0uMDM2LS4xNTcuMDItLjI1NS4wNDMtLjA3My4yODYtLjI1LjM3LS4zMTguMjctLjIxNy41NzctLjM4Ny44NDItLjYxMi0xLjAyOS4xNjYtMi4wNjUuNzU2LTIuOTY0IDEuMjc3LS4xNDkuMDg2LS4zMS4xNzYtLjQ1MS4yNzMtLjAzNy4wMjUtLjA3NS0uMDA2LS4wNTMuMDltLTQuODMgMTAuODQ0Yy0xLjcxNSAxLjg4MS0xLjMyNSA0LjU3LjQ5NCA2LjIxNyAxLjgxMSAxLjY0MSA0LjY2IDIuMjIzIDcuMDQ4IDIuMTA4IDIuMTUyLS4xMDMgNC4zMzctLjgxMiA2LjQ2LS4wOTEuODI1LjI4IDEuNTQ2LjgwNSAyLjE2IDEuNDExLS4wODMtLjQtLjMyNC0uODE5LS41NTgtMS4xNTktMi4xMy0zLjA5NS02LjI3LTEuOTM1LTkuNDI2LTIuNTUzLTEuNzExLS4zMzUtMy40OTEtMS4xMTgtNC41MzMtMi41NjctLjkwMS0xLjI1My0xLjA0Mi0yLjczLS41OTUtNC4xOTQtLjA5MS0uMDktLjk1Mi43MjEtMS4wNS44MjhtNi4zNjYtOC42NjdjLS4zODIuMzM5LS43ODcuNjYtMS4yMTIuOTQ4bC0xLjIwNy41OWMxLjAyMi4wMzkgMi4wOC0uNTQ2IDIuNDItMS41MzciLz48L3N2Zz4=" alt="mulgadc.com"></a>
<a href="https://docs.mulgadc.com"><img src="https://img.shields.io/badge/Docs-spinifex-0969da?style=flat-square&logo=data:image/svg%2bxml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGZpbGw9IiNmZmYiIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTYuNSAySDIwYTEgMSAwIDAgMSAxIDF2MTQuNWExIDEgMCAwIDEtMSAxSDYuNWExLjUgMS41IDAgMCAwIDAgM0gyMWExIDEgMCAwIDEgMCAySDYuNUEzLjUgMy41IDAgMCAxIDMgMjBWNS41QTMuNSAzLjUgMCAwIDEgNi41IDJ6bTEgNGExIDEgMCAwIDAgMCAyaDlhMSAxIDAgMCAwIDAtMmgtOXptMCA0YTEgMSAwIDAgMCAwIDJoOWExIDEgMCAwIDAgMC0yaC05eiIvPjwvc3ZnPg==" alt="docs.mulgadc.com"></a>
</p>
<p align="center">
<a href="#what-is-spinifex">What is Spinifex?</a> ·
<a href="#the-platform">Platform</a> ·
<a href="#three-ways-to-deploy">Deploy</a> ·
<a href="#aws-compatibility">AWS Compatibility</a> ·
<a href="#live-demo">Live Demo</a> ·
<a href="#core-components">Components</a> ·
<a href="#architecture-at-a-glance">Architecture</a> ·
<a href="#self-hosted-installation">Installation</a> ·
<a href="https://docs.mulgadc.com">Docs</a>
<a href="#try-it-live">Live Demo</a> ·
<a href="#services">Services</a> ·
<a href="#deploy-anywhere">Deploy</a> ·
<a href="#install">Install</a> ·
<a href="#architecture">Architecture</a>
</p>
---
# Spinifex: the open, AWS-compatible cloud you run yourself
Spinifex, built by [Mulga](https://mulgadc.com), recreates the AWS services your software already uses (EC2, EBS, S3, VPC, IAM) on infrastructure you control. Move workloads off the hyperscalers, run AI at a fraction of the cost, or take the cloud to the edge, without rewriting a line. The exact build you ship to AWS, Spinifex serves on your own hardware: same APIs, same Terraform, same SDKs.
Spinifex, built by [Mulga](https://mulgadc.com), recreates the AWS services your software already uses on infrastructure you control. Move workloads off the hyperscalers, run AI at a fraction of the cost, or take the cloud to the edge, without rewriting a line.
## What is Spinifex?
Spinifex is an open-source infrastructure platform that recreates the AWS service surface (EC2, EBS, S3, VPC, IAM) on bare-metal, edge, and on-premises environments. Run cloud-native software without a hyperscaler.
Built for teams that need:
- The AWS tooling you already use (AWS CLI, SDKs, Terraform), with nothing to rewrite
- Full control of the stack: your hardware, your network, your data, your keys
- A cloud that keeps running offline, through disconnection, with no external control plane
- An open core (AGPL-3.0), auditable and yours to keep, with no lock-in
- **Your AWS tooling, unchanged.** The AWS CLI, SDKs, and Terraform work as they are.
- **Full control of the stack.** Your hardware, data, and keys, with no external control plane, running through disconnection.
- **Open core under AGPL-3.0.** Auditable, yours to keep, with no lock-in.
You change *where* your software runs, not *what* it is.
## The Platform
## Try It Live
Get a real Spinifex endpoint in under a minute, with no card and no install. The sandbox is yours for 72 hours, with access keys, the web console, and EC2, EBS, S3, VPC, IAM, EKS, and RDS ready to call. Point your existing AWS CLI or Terraform straight at it.
**[Create your sandbox →](https://mulgadc.com/signup)**
## Same Commands, Your Cloud
```bash
export AWS_PROFILE=spinifex
aws ec2 describe-images
aws ec2 run-instances --image-id ami-xxxxxxxx --instance-type t3.micro
aws s3 mb s3://my-bucket
```
For Terraform, start from the [example workbooks](https://docs.mulgadc.com/docs/nginx-webserver).
## Why Not LocalStack or Floci?
[LocalStack](https://github.com/localstack/localstack) and [Floci](https://github.com/floci-io/floci) are emulators for local development and CI testing. An emulator returns a plausible response; Spinifex boots the machine. A call launches a real KVM guest, attaches a replicated Viperblock volume, or writes an erasure-coded object into Predastore, so Spinifex can run your production workloads on hardware you own. Test against an emulator, then run in production on Spinifex.
## Services
The platform serves **392 AWS API operations** across the services below. See exactly which ones in the [coverage report](https://docs.mulgadc.com/coverage).
| Service | What it is | Built on | Status |
| --- | --- | --- | --- |
| **EC2** | Compute | QEMU/KVM, with spot instances and GPU passthrough | Available |
| **EBS** | Block storage | [Viperblock](https://github.com/mulgadc/viperblock): replicated, with a WAL on local NVMe | Available |
| **S3** | Object storage | [Predastore](https://github.com/mulgadc/predastore): Reed-Solomon erasure coding | Available |
| **VPC** | Networking | OVN, with security groups | Available |
| **IAM** | Identity & auth | SigV4 across every service | Available |
| **ALB / NLB** | Load balancers | HAProxy (L7), nginx `stream` (L4), with ACM certificates | Available |
| **EKS** | Kubernetes | k3s, with HA control planes and managed node groups | Available |
| **ECR** | Container registry | Predastore, deduplicated per account | Available |
| **ECS** | Container service | Spinifex instances | Available |
| **RDS** | Databases | PostgreSQL and MariaDB, with automated backups | Available |
| **Bedrock** | AI deployment | vLLM for text generation and Hugging Face TEI for embeddings and reranking | Q4 2026 |
| **Lambda** | Serverless functions | | Q1 2027 |
Roadmap services ship under the same AWS API surface, so code written for AWS today works the moment they land. [Track what's shipped in the release notes](https://github.com/mulgadc/spinifex/releases).
## Deploy Anywhere
<p align="center">
<img src=".github/assets/platform.svg" alt="The Spinifex stack: your apps and AWS tooling on top, the Spinifex AWS-compatible service layer, running on standard Linux on CPU and GPU hardware, deployable to Neocloud, on-premise, or the edge." width="900">
</p>
From commodity hardware up to unmodified AWS tooling, every layer is replaceable and yours to own.
- **Neocloud:** lift and shift onto partner GPU capacity (H100 / H200 / B200). Change an endpoint, keep the software.
- **On-premise:** a multi-node HA cluster in your own data centre, so the stack, the data, and the jurisdiction stay yours.
- **Edge:** air-gapped sites, vehicles, vessels, factories, and clinics, with compute next to the data.
## Three Ways To Deploy
## Install
The same AWS-compatible platform runs in three shapes. Pick the one that matches your hardware.
Spinifex runs on Ubuntu 26.04 or Debian 13. The full guides are at [docs.mulgadc.com](https://docs.mulgadc.com).
### Neocloud
>*Prerequisite:* Spinifex requires a Linux bridge configured on the host for VM networking. See the [single-node install guide](https://docs.mulgadc.com/docs/install#prerequisites) for setup details.
Lift and shift onto a partner Neocloud without rewriting workloads. GPU capacity (H100 / H200 / B200), cheaper, available now. Change an endpoint, keep the software.
### On-premise
Host Spinifex on your own hardware in your own data centre. You get a multi-node HA cluster integrated with your storage and networking, so the stack, the data, and the jurisdiction stay yours.
### Edge
Cloud where the cloud can't reach. Air-gapped sites, vehicles, vessels, factories, and clinics. Compute next to the data, running through disconnection, on hardware you own. The same AWS APIs your software already uses.
## AWS Compatibility
Spinifex speaks the AWS API surface. The AWS SDKs, AWS CLI, and Terraform all work unchanged, whether you deploy at the edge, on-premise, or on a partner Neocloud.
| Service | What it is | Status |
| --- | --- | --- |
| **EC2** | Compute | Available |
| **EBS** | Block Storage | Available |
| **S3** | Object Storage | Available |
| **VPC** | Networking | Available |
| **IAM** | Identity & Auth | Available |
| **ALB / NLB** | Load Balancers | Available |
| **EKS** | Kubernetes | Available |
| **ECR** | Container Registry | Available |
| **ECS** | Container Service | Available |
| **RDS** | Databases | Available |
| **Bedrock** | AI Deployment | Q4 2026 |
Roadmap items ship under the same AWS API surface. Code written for AWS today keeps working the moment they land. [Track what's shipped in the release notes](https://github.com/mulgadc/spinifex/releases).
## Live Demo
Create a live sandbox in under a minute. The fastest way to experience the stack. Try Spinifex against one of your own workloads on infrastructure we run for you.
* Live in under a minute. No card, no install.
* Yours for 72 hours to test and experiment.
* Real AWS-compatible endpoint, access keys and console.
* EC2, EBS, S3, VPC, IAM, EKS and RDS, ready to call.
* Point your existing AWS CLI or Terraform straight at it.
Sign up at [https://mulgadc.com/signup](https://mulgadc.com/signup)
## Core Components
### Spinifex (Compute and Platform Services – EC2, EKS, ECS, RDS)
Spinifex is a minimal VM orchestration layer built on top of QEMU, exposing an EC2 compatible API. It manages lifecycle operations like start, stop, and terminate through QEMU's QMP interface. Designed to be straightforward and scriptable, Spinifex lets you launch VMs with the AWS CLI, SDKs, or Terraform, without Kubernetes or a heavyweight orchestrator.
The managed services build on that same instance layer. An EKS node group, an ECS container instance, and an RDS database are Spinifex VMs on your own hardware, each driven through its own AWS API.
- **EC2** – VM lifecycle on bare metal, cloud-init and IMDS metadata, key pairs, tags, spot instances, capacity reservations, and GPU passthrough
- **EKS** – Managed Kubernetes on k3s, with HA control planes, managed node groups, cluster add-ons, an OIDC provider, and IAM access entries
- **ECS** – Task and service scheduling across container instances, with awsvpc ENIs, capacity providers, rolling deployments, and GPU-aware placement
- **ECR** – Private container registry backed by Predastore, with content-addressed blobs deduplicated per account and lifecycle policies
- **RDS** – Managed PostgreSQL and MariaDB, with parameter groups, automated backups, and DNS endpoints served by Northstar
- **ALB / NLB** – Layer 7 load balancing on HAProxy and layer 4 on nginx `stream`, with health checks and ACM-issued listener certificates
- **VPC and IAM** – OVN-backed networking and security groups, with roles, policies, and SigV4 auth applied across every service
### Viperblock (Block Storage – EBS Alternative)
[Viperblock](https://github.com/mulgadc/viperblock) is a high-performance, WAL-backed block storage service that replicates volumes across multiple nodes. It's built for reliability and speed, with support for snapshots, recovery, and direct connection to QEMU instances using NBD or virtio-blk.
- Fast, durable virtual disks
- Replication for resilience
- Exposed over NBD or embedded in VMs
- High performance WAL logs on local NVMe drives, reducing IO traffic to S3
- In-memory read/write block cache
### Predastore (Object Storage – S3-Compatible)
[Predastore](https://github.com/mulgadc/predastore) is a fully S3-compatible object storage system. It supports the AWS S3 API, including Signature V4 authentication, multipart uploads, and Terraform provisioning. Predastore chunks data and distributes it across nodes using Reed-Solomon erasure coding, so it survives node loss and suits large-scale or low-bandwidth sites.
- S3-compatible API and auth
- Multipart uploads, streaming reads/writes
- Data redundancy with Reed-Solomon encoding
### Northstar (Authoritative DNS)
[Northstar](https://github.com/mulgadc/northstar) is a lightweight authoritative DNS server that handles both internal service discovery and public-facing DNS for a Spinifex cluster. Zones are human-readable TOML files, served from the local filesystem or synced from Predastore, keeping DNS configuration in the same object storage as the rest of your infrastructure.
- Authoritative DNS over UDP, TCP, and DNS-over-TLS
- Service discovery for cluster components via SRV records
- Zone files stored locally or in S3-compatible storage
## Architecture at a Glance
<p align="center">
<img src=".github/assets/architecture.svg" alt="Spinifex message-driven architecture: standard AWS clients call the AWS Gateway over HTTPS with SigV4, which publishes to a NATS bus answered by stateless ec2/ebs/s3/vpc/iam daemons over local backends." width="900">
</p>
Every AWS API call is authenticated at the gateway, published to a NATS subject, and answered by whichever daemon claims it. Daemons are stateless: scale horizontally by starting more. No etcd, no Kubernetes, no external control plane. Just systemd units, a NATS cluster, and your hardware. Deep dive: **[`docs/DESIGN.md`](docs/DESIGN.md)**.
## Self-hosted Installation
Installation requires an Ubuntu 26.04 or Debian 13 system. See the detailed documentation at [docs.mulgadc.com](https://docs.mulgadc.com) for installing Spinifex.
### Bare Metal ISO
The recommended installation is a [bootable x86 installer](https://iso.mulgadc.com/spinifex.iso) for bare-metal hardware.
**Bare metal (recommended):** download the [bootable x86 installer](https://iso.mulgadc.com/spinifex.iso) and follow the [USB install guide](https://docs.mulgadc.com/docs/install-usb).
```bash
curl -fLO https://iso.mulgadc.com/spinifex.iso
```
Follow the [USB install guide](https://docs.mulgadc.com/docs/install-usb) to write the ISO to USB and install on your hardware.
### Single Node Install
>*Prerequisite:* Spinifex requires a Linux bridge configured on the host for VM networking. See the [single-node install guide](https://docs.mulgadc.com/docs/install#prerequisites) for setup details.
**Single node:**
```bash
curl -fsSL https://install.mulgadc.com | bash
sudo /usr/local/share/spinifex/setup-ovn.sh --management
sudo spx admin init --node node1 --nodes 1
sudo systemctl start spinifex.target
export AWS_PROFILE=spinifex
aws ec2 describe-instance-types
```
### Development Setup
**From source:** see the [Source Install](https://docs.mulgadc.com/docs/install-source) guide.
For a complete development environment see the [Source Install](https://docs.mulgadc.com/docs/install-source) documentation.
## Spinifex UI
Spinifex ships with a built-in web console, an optional alternative to the AWS CLI, SDKs, and Terraform. If you know the AWS Management Console, the Spinifex UI fills the same role: a browser-based view of your instances, volumes, buckets, VPCs, and IAM resources, without leaving your own network.
## Web Console
<p align="center">
<img src=".github/assets/spinifex-ui.jpg" alt="Spinifex web console dashboard" width="900">
</p>
Each node serves the console on port `3000` over TLS, and it comes up with `spinifex.target`:
Spinifex ships with a built-in web console for your instances, volumes, buckets, networks, and IAM. See [Launching the Web UI](https://docs.mulgadc.com/docs/setting-up-your-cluster#7-launching-the-web-ui).
- **Same API, different surface.** The UI issues the same AWS SigV4 calls the CLI does, so RBAC, audit trails, and IAM policies apply the same way.
- **Single sign-on against your AWS credentials.** Log in with the access keys from `~/.aws/credentials` on the node where Spinifex is installed. There is no separate user database.
- **Self-hosted, works offline.** The Spinifex binary embeds the UI and each node serves it directly, with no external CDN or analytics calls.
## Architecture
For the full walkthrough, covering first-time TLS certificate trust, login, and a feature tour, see [**Launching the Web UI**](https://docs.mulgadc.com/docs/setting-up-your-cluster#7-launching-the-web-ui) in the cluster setup guide.
<p align="center">
<img src=".github/assets/architecture.svg" alt="Spinifex message-driven architecture: standard AWS clients call the AWS Gateway over HTTPS with SigV4, which publishes to a NATS bus answered by stateless ec2/ebs/s3/vpc/iam daemons over local backends." width="900">
</p>
## Development Philosophy
Every AWS API call is authenticated at the gateway, published to a NATS subject, and answered by whichever daemon claims it. Daemons are stateless, so you scale horizontally by starting more. Deep dive: **[`docs/DESIGN.md`](docs/DESIGN.md)**.
### Built by Engineers, For Engineers
## How We Build Spinifex
Spinifex is developed by experienced infrastructure engineers with deep AWS expertise, including former AWS team members who understand the intricacies of building production-grade cloud services. Our team brings decades of combined experience from AWS, enterprise infrastructure, and edge computing environments.
Spinifex is built by infrastructure engineers with deep AWS experience, including former AWS team members, drawing on decades of combined work across AWS, enterprise infrastructure, and edge computing. Engineers architect and implement it, with **Claude Code** accelerating parts of the work.
### AI-Assisted Development
Spinifex is architected and implemented by experienced engineers, with **Claude Code** accelerating certain development tasks. The split keeps the quality and reliability standards that production infrastructure demands:
**How We Use Claude Code:**
- **Code Generation**: Boilerplate AWS API structures and handlers
- **Documentation**: Comprehensive development guides and API documentation
- **Testing**: Test case generation and validation scenarios
- **Refactoring**: Large-scale code restructuring and optimization
**What Remains Human-Driven:**
- **Architecture Decisions**: Core system design and scalability choices
- **Security Implementation**: Authentication, encryption, and threat modeling
- **Performance Optimization**: Real-world performance tuning and benchmarking
- **Production Operations**: Deployment strategies and operational procedures
| Claude Code assists with | Engineers own |
| --- | --- |
| Boilerplate AWS API structures and handlers | Architecture and scalability decisions |
| Development guides and API documentation | Authentication, encryption, and threat modelling |
| Test case generation and validation scenarios | Real-world performance tuning and benchmarking |
| Large-scale restructuring and refactoring | Deployment strategy and production operations |
## Trademarks
@@ -228,4 +141,4 @@ Spinifex is architected and implemented by experienced engineers, with **Claude
## License
Spinifex is open source under the [GNU Affero General Public License v3.0](LICENSE), with a commercial license available. You're free to use, modify, and deploy it anywhere you need reliable infrastructure without depending on centralized cloud platforms.
Spinifex is open source under the [GNU Affero General Public License v3.0](LICENSE), with a commercial license available.
@@ -0,0 +1,92 @@
package instancestate
// allowance is one recorded production consumer of raw instance state.
type allowance struct {
File, Symbol string
Inv string // inventory item in docs/PACKAGE_BOUNDARY_MIGRATION.md
}
// allowlist mirrors the ADR-0007 slice 0 inventory in
// docs/PACKAGE_BOUNDARY_MIGRATION.md. It only shrinks: delete an entry as soon
// as its use is gone, and never add one to make a new consumer pass.
var allowlist = []allowance{
{File: "spinifex/daemon/daemon.go", Symbol: "daemon.LocalStatePath", Inv: "INV-01"},
{File: "spinifex/daemon/daemon.go", Symbol: "daemon.ReadLocalState", Inv: "INV-01"},
{File: "spinifex/daemon/daemon.go", Symbol: "daemon.MarshalLocalState", Inv: "INV-01"},
{File: "spinifex/daemon/daemon.go", Symbol: "daemon.WriteLocalStateBytes", Inv: "INV-01"},
{File: "spinifex/daemon/daemon.go", Symbol: "JetStreamManager.InitKVBucket", Inv: "INV-02"},
{File: "spinifex/daemon/daemon.go", Symbol: "JetStreamManager.InitTerminatedInstanceBucket", Inv: "INV-02"},
{File: "spinifex/daemon/daemon.go", Symbol: "JetStreamManager.WriteNodeMarkerBestEffort", Inv: "INV-03"},
{File: "spinifex/daemon/daemon.go", Symbol: "JetStreamManager.WriteRunningSet", Inv: "INV-03"},
{File: "spinifex/daemon/instance_record_repair.go", Symbol: "JetStreamManager.WriteRunningSet", Inv: "INV-03"},
{File: "spinifex/daemon/daemon.go", Symbol: "JetStreamManager.ListTerminatedInstances", Inv: "INV-04"},
{File: "spinifex/daemon/vm_adapters.go", Symbol: "JetStreamManager.LoadState", Inv: "INV-05"},
{File: "spinifex/daemon/vm_adapters.go", Symbol: "JetStreamManager.WriteStoppedInstance", Inv: "INV-05"},
{File: "spinifex/daemon/vm_adapters.go", Symbol: "JetStreamManager.LoadStoppedInstance", Inv: "INV-05"},
{File: "spinifex/daemon/vm_adapters.go", Symbol: "JetStreamManager.DeleteStoppedInstance", Inv: "INV-05"},
{File: "spinifex/daemon/vm_adapters.go", Symbol: "JetStreamManager.UpdateStoppedInstance", Inv: "INV-05"},
{File: "spinifex/daemon/vm_adapters.go", Symbol: "JetStreamManager.ClaimStoppedInstance", Inv: "INV-05"},
{File: "spinifex/daemon/vm_adapters.go", Symbol: "JetStreamManager.ListStoppedInstances", Inv: "INV-05"},
{File: "spinifex/daemon/vm_adapters.go", Symbol: "JetStreamManager.WriteTerminatedInstance", Inv: "INV-05"},
{File: "spinifex/daemon/vm_adapters.go", Symbol: "JetStreamManager.UpdateTerminatedInstance", Inv: "INV-05"},
{File: "spinifex/daemon/vm_adapters.go", Symbol: "JetStreamManager.ListTerminatedInstances", Inv: "INV-05"},
{File: "spinifex/daemon/vm_adapters.go", Symbol: "JetStreamManager.DeleteTerminatedInstance", Inv: "INV-05"},
{File: "spinifex/runtime/compute/vm/migrate.go", Symbol: "JetStreamManager.WriteStoppedInstance", Inv: "INV-06"},
{File: "spinifex/runtime/compute/vm/migrate.go", Symbol: "JetStreamManager.WriteTerminatedInstance", Inv: "INV-06"},
{File: "spinifex/runtime/compute/vm/shutdown.go", Symbol: "JetStreamManager.WriteTerminatedInstance", Inv: "INV-06"},
{File: "spinifex/runtime/compute/vm/orphan_qemu_reaper.go", Symbol: "JetStreamManager.ListTerminatedInstances", Inv: "INV-06"},
{File: "spinifex/runtime/compute/vm/teardown_reaper.go", Symbol: "JetStreamManager.ListTerminatedInstances", Inv: "INV-06"},
{File: "spinifex/runtime/compute/vm/teardown_reaper.go", Symbol: "JetStreamManager.UpdateTerminatedInstance", Inv: "INV-06"},
{File: "spinifex/runtime/compute/vm/teardown_reaper.go", Symbol: "JetStreamManager.DeleteTerminatedInstance", Inv: "INV-06"},
{File: "spinifex/domains/ec2/instance/service_impl.go", Symbol: "JetStreamManager.LoadStoppedInstance", Inv: "INV-07"},
{File: "spinifex/domains/ec2/instance/service_impl.go", Symbol: "JetStreamManager.UpdateStoppedInstance", Inv: "INV-07"},
{File: "spinifex/domains/ec2/instance/service_impl.go", Symbol: "JetStreamManager.ListStoppedInstances", Inv: "INV-07"},
{File: "spinifex/domains/ec2/instance/service_impl.go", Symbol: "JetStreamManager.ListTerminatedInstances", Inv: "INV-07"},
{File: "spinifex/domains/ec2/instance/service_impl.go", Symbol: "JetStreamManager.WriteStoppedInstance", Inv: "INV-07"},
{File: "spinifex/domains/ec2/instance/service_impl.go", Symbol: "JetStreamManager.ClaimStoppedInstance", Inv: "INV-07"},
{File: "spinifex/domains/ec2/instance/service_impl.go", Symbol: "JetStreamManager.WriteTerminatedInstance", Inv: "INV-07"},
{File: "spinifex/domains/ec2/instance/service_impl.go", Symbol: "JetStreamManager.DeleteStoppedInstance", Inv: "INV-07"},
{File: "spinifex/daemon/daemon_handlers_image.go", Symbol: "JetStreamManager.LoadStoppedInstance", Inv: "INV-08"},
{File: "spinifex/daemon/daemon_handlers_image.go", Symbol: "JetStreamManager.UpdateStoppedInstance", Inv: "INV-08"},
{File: "spinifex/daemon/instance_recovery.go", Symbol: "vm.InstanceRecord", Inv: "INV-10"},
{File: "spinifex/daemon/instance_recovery.go", Symbol: "JetStreamManager.ListInstanceRecords", Inv: "INV-10"},
{File: "spinifex/daemon/instance_recovery.go", Symbol: "JetStreamManager.ClaimRecoverableInstance", Inv: "INV-10"},
{File: "spinifex/daemon/instance_recovery.go", Symbol: "JetStreamManager.ReleaseRecoveredInstance", Inv: "INV-10"},
{File: "spinifex/daemon/instance_recovery.go", Symbol: "JetStreamManager.AbandonRecovery", Inv: "INV-10"},
{File: "spinifex/daemon/instance_recovery.go", Symbol: "JetStreamManager.LoadInstanceRecord", Inv: "INV-10"},
{File: "spinifex/daemon/dns_reconcile.go", Symbol: "daemon.InstanceRecordPrefix", Inv: "INV-11"},
{File: "spinifex/daemon/dns_reconcile.go", Symbol: "daemon.InstanceStateBucket", Inv: "INV-11"},
{File: "spinifex/daemon/dns_reconcile.go", Symbol: "JetStreamManager.ListInstanceRecords", Inv: "INV-11"},
{File: "spinifex/daemon/eni_orphan_reaper.go", Symbol: "vm.InstanceRecord", Inv: "INV-12"},
{File: "spinifex/daemon/eni_orphan_reaper.go", Symbol: "JetStreamManager.ListInstanceRecords", Inv: "INV-12"},
{File: "spinifex/daemon/eni_orphan_reaper.go", Symbol: "JetStreamManager.ListTerminatedInstanceRecords", Inv: "INV-12"},
{File: "spinifex/domains/ec2/instance/state/imds_readers.go", Symbol: "daemon.LocalState", Inv: "INV-13"},
{File: "spinifex/domains/ec2/instance/state/imds_readers.go", Symbol: "daemon.LocalStatePath", Inv: "INV-13"},
{File: "spinifex/domains/ec2/instance/state/imds_readers.go", Symbol: "daemon.ReadLocalState", Inv: "INV-13"},
{File: "spinifex/domains/ec2/instance/state/imds_readers.go", Symbol: "daemon.InstanceStateBucket", Inv: "INV-13"},
{File: "spinifex/domains/ec2/instance/state/imds_readers.go", Symbol: "JetStreamManager.InitKVBucket", Inv: "INV-13"},
{File: "spinifex/domains/ec2/guestmetadata/instance_lookup.go", Symbol: "vm.InstanceRecord", Inv: "INV-14"},
{File: "spinifex/domains/ec2/guestmetadata/instance_lookup.go", Symbol: "vm.VMFromRecord", Inv: "INV-14"},
{File: "spinifex/domains/ec2/guestmetadata/instance_lookup.go", Symbol: "JetStreamManager.LoadInstanceRecord", Inv: "INV-14"},
{File: "spinifex/domains/network/reconcile/intent.go", Symbol: `literal "spinifex-instance-state"`, Inv: "INV-15"},
{File: "spinifex/domains/network/reconcile/intent.go", Symbol: "vm.InstanceRecord", Inv: "INV-15"},
{File: "spinifex/runtime/roles/awsgw/awsgw.go", Symbol: "daemon.InstanceStateBucket", Inv: "INV-16"},
{File: "spinifex/runtime/roles/awsgw/awsgw.go", Symbol: "daemon.InstanceRecordPrefix", Inv: "INV-16"},
{File: "spinifex/runtime/roles/awsgw/awsgw.go", Symbol: "vm.InstanceRecord", Inv: "INV-16"},
{File: "spinifex/runtime/compute/cache/cache.go", Symbol: "vm.InstanceRecord", Inv: "INV-17"},
{File: "spinifex/runtime/compute/cache/cache.go", Symbol: "vm.VMFromRecord", Inv: "INV-17"},
{File: "spinifex/domains/admission/quota/records.go", Symbol: "vm.InstanceRecord", Inv: "INV-18"},
}
@@ -0,0 +1,200 @@
package instancestate
import (
"fmt"
"go/parser"
"go/token"
"os"
"path/filepath"
"testing"
)
type report struct {
unrecorded []use
stale []allowance
malformed []string
}
// check compares the scanned uses with allow.
func check(s scan, allow []allowance) report {
var rep report
allowed := map[use]bool{}
for _, a := range allow {
u := use{File: a.File, Symbol: a.Symbol}
switch {
case allowed[u]:
rep.malformed = append(rep.malformed, "duplicate entry "+u.String())
case a.Inv == "":
rep.malformed = append(rep.malformed, "entry "+u.String()+" cites no inventory item")
}
allowed[u] = true
}
for _, u := range s.sorted() {
if !allowed[u] {
rep.unrecorded = append(rep.unrecorded, u)
}
}
for _, a := range allow {
if _, ok := s[use{File: a.File, Symbol: a.Symbol}]; !ok {
rep.stale = append(rep.stale, a)
}
}
return rep
}
// TestADR0007_S6_NoNewRawInstanceStateConsumers is the blocking ratchet: no
// production file may start naming the daemon's instance-state bucket, keys,
// local file, raw record or JetStreamManager instance methods.
func TestADR0007_S6_NoNewRawInstanceStateConsumers(t *testing.T) {
root, err := moduleRoot()
if err != nil {
t.Fatal(err)
}
s, files, err := scanModule(root)
if err != nil {
t.Fatal(err)
}
// Guards against a walk that silently finds nothing and passes vacuously.
if files < 500 || len(s) < 20 {
t.Fatalf("scanned %d files and found %d uses under %s; the walk has lost the tree", files, len(s), root)
}
if len(allowlist) == 0 {
t.Fatal("the allowlist is empty, so the stale check would pass vacuously")
}
rep := check(s, allowlist)
t.Run("NoUnrecordedConsumer", func(t *testing.T) {
for _, u := range rep.unrecorded {
t.Errorf("ADR-0007 S6 (consumers receive narrow state capabilities rather than raw stores): %s at %s; "+
"consume an instance-domain capability instead of the daemon's raw instance state", u, s[u])
}
})
t.Run("Allowlist_NoStaleEntries", func(t *testing.T) {
for _, a := range rep.stale {
t.Errorf("allowlist entry %s uses %s (%s) no longer matches; delete it and update the inventory",
a.File, a.Symbol, a.Inv)
}
})
t.Run("Allowlist_WellFormed", func(t *testing.T) {
for _, m := range rep.malformed {
t.Errorf("allowlist_test.go: %s", m)
}
t.Logf("%d allow-listed uses", len(allowlist))
})
t.Run("OwnerFilesExist", func(t *testing.T) {
for _, f := range ownerFiles {
if _, err := os.Stat(filepath.Join(root, f)); err != nil {
t.Errorf("owner file %s is gone; remove it from ownerFiles and re-scan its consumers: %v", f, err)
}
}
})
}
const faultSource = `package %s
import (
"encoding/json"
spxd "github.com/mulgadc/spinifex/spinifex/daemon"
"github.com/mulgadc/spinifex/spinifex/runtime/compute/vm"
)
type holder struct {
LoadInstanceRecord func() // a field name, not a use
m *spxd.JetStreamManager
}
func (h holder) WriteRunningSet() {} // a declaration, not a use
func use(h holder, raw []byte, jsManager *spxd.JetStreamManager, other interface{ LoadState() }) {
_ = spxd.InstanceStateBucket
_ = spxd.LocalStatePath("")
var r vm.InstanceRecord
_ = json.Unmarshal(raw, &r)
_ = vm.VMFromRecord(&r)
_ = (&vm.VM{}).Record()
_, _ = h.m.LoadInstanceRecord("i-1")
_, _, _ = jsManager.LoadState("node")
other.LoadState()
_ = "spinifex-instance-state"
cluster, _ := spxd.NewJetStreamManager(nil)
_ = cluster.InitClusterStateBucket()
_ = cluster.WriteRunningSet("node", nil)
}
`
func parseFault(t *testing.T, rel, pkg string) scan {
t.Helper()
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, rel, fmt.Sprintf(faultSource, pkg), parser.SkipObjectResolution)
if err != nil {
t.Fatal(err)
}
out := scan{}
scanFile(fset, rel, f, out)
return out
}
// TestADR0007_S6_RatchetDetectsFaults injects a new consumer, a stale entry
// and a malformed entry, and checks the matcher's declaration exclusions.
func TestADR0007_S6_RatchetDetectsFaults(t *testing.T) {
const rel = "spinifex/domains/widget/w.go"
s := parseFault(t, rel, "widget")
want := []string{
"daemon.InstanceStateBucket", "daemon.LocalStatePath",
"vm.InstanceRecord", "vm.VMFromRecord", "VM.Record()",
"JetStreamManager.LoadInstanceRecord", "JetStreamManager.LoadState",
"JetStreamManager.WriteRunningSet",
`literal "spinifex-instance-state"`,
}
for _, sym := range want {
if _, ok := s[use{File: rel, Symbol: sym}]; !ok {
t.Errorf("injected use of %s was not detected; got %v", sym, s.sorted())
}
}
if len(s) != len(want) {
t.Errorf("want exactly %d uses (the manager type, its constructor, cluster-state methods, field names, method declarations and an unrelated LoadState excluded), got %v",
len(want), s.sorted())
}
stale := allowance{File: "spinifex/domains/gone/g.go", Symbol: "vm.InstanceRecord", Inv: "INV-00"}
recorded := allowance{File: rel, Symbol: "vm.InstanceRecord", Inv: "INV-00"}
rep := check(s, []allowance{recorded, stale, {File: rel, Symbol: "vm.VMFromRecord"}, recorded})
if len(rep.unrecorded) != len(want)-2 {
t.Errorf("want %d unrecorded uses, got %v", len(want)-2, rep.unrecorded)
}
if len(rep.stale) != 1 || rep.stale[0] != stale {
t.Errorf("want stale entry %+v, got %+v", stale, rep.stale)
}
if len(rep.malformed) != 2 {
t.Errorf("want a missing-inventory and a duplicate entry reported, got %v", rep.malformed)
}
}
// Inside the owning packages the symbols are bare identifiers, and the scan
// must see those too.
func TestADR0007_S6_RatchetSeesBareIdentifiersInOwnerPackages(t *testing.T) {
src := map[string]string{
"spinifex/daemon/new_consumer.go": "package daemon\n\nfunc f() string { return InstanceStateBucket + LocalStatePath(\"\") }\n",
"spinifex/runtime/compute/vm/new_reader.go": "package vm\n\nfunc g(r *InstanceRecord) *VM { return VMFromRecord(r) }\n",
}
want := map[string][]string{
"spinifex/daemon/new_consumer.go": {"daemon.InstanceStateBucket", "daemon.LocalStatePath"},
"spinifex/runtime/compute/vm/new_reader.go": {"vm.InstanceRecord", "vm.VMFromRecord"},
}
for rel, code := range src {
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, rel, code, parser.SkipObjectResolution)
if err != nil {
t.Fatal(err)
}
out := scan{}
scanFile(fset, rel, f, out)
for _, sym := range want[rel] {
if _, ok := out[use{File: rel, Symbol: sym}]; !ok {
t.Errorf("%s: bare use of %s not detected; got %v", rel, sym, out.sorted())
}
}
}
}
+302
View File
@@ -0,0 +1,302 @@
// Package instancestate freezes the production consumers of the daemon's raw
// instance-state persistence, so ADR-0007 S6 and S7 start from a known set
// that can only shrink.
package instancestate
import (
"errors"
"fmt"
"go/ast"
"go/build/constraint"
"go/parser"
"go/token"
"io/fs"
"os"
"path/filepath"
"runtime"
"sort"
"strconv"
"strings"
)
const (
daemonPath = "github.com/mulgadc/spinifex/spinifex/daemon"
vmPath = "github.com/mulgadc/spinifex/spinifex/runtime/compute/vm"
daemonDir = "spinifex/daemon"
vmDir = "spinifex/runtime/compute/vm"
)
// daemonSymbols are the daemon's instance-state bucket, key and local-file
// identifiers. The JetStreamManager type and constructor are not listed: they
// also serve cluster state, so only the instance-state methods below count.
var daemonSymbols = set(
"InstanceStateBucket", "InstanceStateBucketVersion",
"TerminatedInstanceBucket", "TerminatedInstanceBucketVersion",
"InstanceRecordPrefix", "InstanceStatePrefix", "NodePresencePrefix",
"StoppedInstancePrefix", "TerminatedInstancePrefix",
"LocalState", "LocalStateSchemaVersion", "LocalStatePath", "ReadLocalState",
"MarshalLocalState", "WriteLocalStateBytes", "LocalStateFileName",
"LocalStateFileMode", "DefaultLocalStateDir",
)
// vmSymbols are the raw persisted record and its conversion.
var vmSymbols = set("InstanceRecord", "InstanceSpec", "InstanceStatus", "VMFromRecord")
// jsmMethods are JetStreamManager's instance-state methods. Matched by name, so
// a call through an interface that mirrors them counts as a consumer too.
var jsmMethods = set(
"WriteInstanceRecord", "LoadInstanceRecord", "UpdateInstanceRecord",
"DeleteInstanceRecord", "ListInstanceRecords",
"WriteTerminatedInstanceRecord", "LoadTerminatedInstanceRecord",
"UpdateTerminatedInstanceRecord", "DeleteTerminatedInstanceRecord",
"ListTerminatedInstanceRecords",
"WriteNodeMarker", "WriteNodeMarkerBestEffort", "WriteRunningSet",
"WriteStoppedInstance", "LoadStoppedInstance", "DeleteStoppedInstance",
"ClaimStoppedInstance", "ClaimRecoverableInstance", "ReleaseRecoveredInstance",
"AbandonRecovery", "UpdateStoppedInstance", "ListStoppedInstances",
"WriteTerminatedInstance", "UpdateTerminatedInstance", "ListTerminatedInstances",
"DeleteTerminatedInstance", "LoadTerminatedInstance",
"InitKVBucket", "InitTerminatedInstanceBucket",
)
// ambiguousJSMMethods share a name with unrelated methods, so they count only
// when called on a field or variable named like a JetStreamManager handle.
var ambiguousJSMMethods = set("LoadState", "DeleteState")
// literals duplicate a bucket or file name instead of naming the owner's symbol.
var literals = set(`"spinifex-instance-state"`, `"spinifex-terminated-instances"`, `"instance-state.json"`)
// ownerFiles define the symbols above. They are the implementation being
// migrated, not consumers of it, and are listed so a deleted one is noticed.
var ownerFiles = []string{
"spinifex/daemon/instance_membership.go",
"spinifex/daemon/instance_records.go",
"spinifex/daemon/instance_records_migrate.go",
"spinifex/daemon/instance_running_set.go",
"spinifex/daemon/jetstream.go",
"spinifex/daemon/local_state.go",
"spinifex/runtime/compute/vm/record.go",
}
func set(names ...string) map[string]bool {
m := make(map[string]bool, len(names))
for _, n := range names {
m[n] = true
}
return m
}
// use is one consumer of one symbol, keyed by file so a new file is a new use.
type use struct {
File, Symbol string
}
func (u use) String() string { return u.File + " uses " + u.Symbol }
// scan maps each use to the first file:line it appears at.
type scan map[use]string
func moduleRoot() (string, error) {
_, file, _, ok := runtime.Caller(0)
if !ok {
return "", errors.New("cannot locate test source")
}
return filepath.Abs(filepath.Join(filepath.Dir(file), "..", ".."))
}
var excludedTopDirs = map[string]bool{"tests": true, "docs": true, "architecture": true}
// scanModule parses every non-test production Go file under root.
func scanModule(root string) (scan, int, error) {
out := scan{}
files := 0
fset := token.NewFileSet()
owners := set(ownerFiles...)
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel(root, path)
if err != nil {
return err
}
rel = filepath.ToSlash(rel)
if d.IsDir() {
if rel == "." {
return nil
}
name := d.Name()
if strings.HasPrefix(name, ".") || strings.HasPrefix(name, "_") ||
name == "testdata" || name == "vendor" || name == "node_modules" || excludedTopDirs[rel] {
return filepath.SkipDir
}
if _, err := os.Stat(filepath.Join(path, "go.mod")); err == nil {
return filepath.SkipDir
}
return nil
}
if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
return nil
}
src, err := os.ReadFile(path)
if err != nil {
return err
}
f, err := parser.ParseFile(fset, path, src, parser.ParseComments|parser.SkipObjectResolution)
if err != nil {
return err
}
if ignoredByBuildTag(f.Comments, f.Package) {
return nil
}
files++
if owners[rel] {
return nil
}
scanFile(fset, rel, f, out)
return nil
})
return out, files, err
}
// scanFile records every use in f, which lives at module-relative path rel.
func scanFile(fset *token.FileSet, rel string, f *ast.File, out scan) {
dir := filepath.ToSlash(filepath.Dir(rel))
daemonName, vmName := importName(f, daemonPath, "daemon"), importName(f, vmPath, "vm")
inDaemon, inVM := dir == daemonDir, dir == vmDir
seesVM := inDaemon || inVM || daemonName != "" || vmName != ""
record := func(symbol string, pos token.Pos) {
u := use{File: rel, Symbol: symbol}
if _, ok := out[u]; !ok {
out[u] = fmt.Sprintf("%s:%d", rel, fset.Position(pos).Line)
}
}
var visit func(n ast.Node) bool
visit = func(n ast.Node) bool {
switch n := n.(type) {
case *ast.ImportSpec:
return false
case *ast.BasicLit:
if n.Kind == token.STRING && literals[n.Value] {
record("literal "+n.Value, n.Pos())
}
case *ast.CallExpr:
if sel, ok := n.Fun.(*ast.SelectorExpr); ok && sel.Sel.Name == "Record" && len(n.Args) == 0 && seesVM {
record("VM.Record()", sel.Sel.Pos())
}
case *ast.SelectorExpr:
if x, ok := n.X.(*ast.Ident); ok {
switch {
case daemonName != "" && x.Name == daemonName && daemonSymbols[n.Sel.Name]:
record("daemon."+n.Sel.Name, n.Sel.Pos())
case vmName != "" && x.Name == vmName && vmSymbols[n.Sel.Name]:
record("vm."+n.Sel.Name, n.Sel.Pos())
}
}
if jsmMethods[n.Sel.Name] || (ambiguousJSMMethods[n.Sel.Name] && jsmReceivers[receiverName(n.X)]) {
record("JetStreamManager."+n.Sel.Name, n.Sel.Pos())
}
// The selected name is a field or method, never a bare package symbol.
ast.Inspect(n.X, visit)
return false
case *ast.FuncDecl:
// A method declared with one of these names is an implementation,
// not a use; its body is still scanned.
if n.Recv != nil {
ast.Inspect(n.Recv, visit)
}
if n.Body != nil {
ast.Inspect(n.Body, visit)
}
if n.Type != nil {
ast.Inspect(n.Type, visit)
}
return false
case *ast.Field:
// Interface method and struct field names are declarations.
if n.Type != nil {
ast.Inspect(n.Type, visit)
}
if n.Tag != nil {
ast.Inspect(n.Tag, visit)
}
return false
case *ast.Ident:
if inDaemon && daemonSymbols[n.Name] {
record("daemon."+n.Name, n.Pos())
}
if inVM && vmSymbols[n.Name] {
record("vm."+n.Name, n.Pos())
}
}
return true
}
ast.Inspect(f, visit)
}
var jsmReceivers = set("jsManager", "jsm", "js")
// receiverName is the last name in x: "js" for both js and a.js.
func receiverName(x ast.Expr) string {
switch x := x.(type) {
case *ast.Ident:
return x.Name
case *ast.SelectorExpr:
return x.Sel.Name
}
return ""
}
// importName returns the name f refers to path by, or "" if f does not import it.
func importName(f *ast.File, path, def string) string {
for _, imp := range f.Imports {
p, err := strconv.Unquote(imp.Path.Value)
if err != nil || p != path {
continue
}
if imp.Name != nil {
if imp.Name.Name == "_" || imp.Name.Name == "." {
return ""
}
return imp.Name.Name
}
return def
}
return ""
}
// ignoredByBuildTag reports a `//go:build ignore` file, which is a standalone
// program rather than part of its directory's package.
func ignoredByBuildTag(groups []*ast.CommentGroup, pkgPos token.Pos) bool {
for _, cg := range groups {
if cg.Pos() >= pkgPos {
break
}
for _, c := range cg.List {
if !constraint.IsGoBuild(c.Text) {
continue
}
expr, err := constraint.Parse(c.Text)
if err == nil && expr.String() == "ignore" {
return true
}
}
}
return false
}
func (s scan) sorted() []use {
out := make([]use, 0, len(s))
for u := range s {
out = append(out, u)
}
sort.Slice(out, func(i, j int) bool {
if out[i].File != out[j].File {
return out[i].File < out[j].File
}
return out[i].Symbol < out[j].Symbol
})
return out
}
+164
View File
@@ -0,0 +1,164 @@
package layering
// allowance is one recorded forbidden import, allowed until its debt clears.
type allowance struct {
From, To string
Debt string // subject of the migration record's debt entry or close-out row
Reason string
}
// allowlist mirrors the dependency-rule debt and close-out table in
// docs/PACKAGE_BOUNDARY_MIGRATION.md. It only shrinks: add an edge only with
// recorded debt, and delete an entry as soon as its import is gone.
var allowlist = []allowance{
{From: "accountteardown", To: "domains/ec2/awsapi/eip", Debt: "accountteardown (dependency)", Reason: "EC2 reapers call the awsapi action adapters; needs domain-owned cleanup capabilities"},
{From: "accountteardown", To: "domains/ec2/awsapi/igw", Debt: "accountteardown (dependency)", Reason: "EC2 reapers call the awsapi action adapters; needs domain-owned cleanup capabilities"},
{From: "accountteardown", To: "domains/ec2/awsapi/image", Debt: "accountteardown (dependency)", Reason: "EC2 reapers call the awsapi action adapters; needs domain-owned cleanup capabilities"},
{From: "accountteardown", To: "domains/ec2/awsapi/instance", Debt: "accountteardown (dependency)", Reason: "EC2 reapers call the awsapi action adapters; needs domain-owned cleanup capabilities"},
{From: "accountteardown", To: "domains/ec2/awsapi/key", Debt: "accountteardown (dependency)", Reason: "EC2 reapers call the awsapi action adapters; needs domain-owned cleanup capabilities"},
{From: "accountteardown", To: "domains/ec2/awsapi/launchtemplate", Debt: "accountteardown (dependency)", Reason: "EC2 reapers call the awsapi action adapters; needs domain-owned cleanup capabilities"},
{From: "accountteardown", To: "domains/ec2/awsapi/placementgroup", Debt: "accountteardown (dependency)", Reason: "EC2 reapers call the awsapi action adapters; needs domain-owned cleanup capabilities"},
{From: "accountteardown", To: "domains/ec2/awsapi/routetable", Debt: "accountteardown (dependency)", Reason: "EC2 reapers call the awsapi action adapters; needs domain-owned cleanup capabilities"},
{From: "accountteardown", To: "domains/ec2/awsapi/snapshot", Debt: "accountteardown (dependency)", Reason: "EC2 reapers call the awsapi action adapters; needs domain-owned cleanup capabilities"},
{From: "accountteardown", To: "domains/ec2/awsapi/volume", Debt: "accountteardown (dependency)", Reason: "EC2 reapers call the awsapi action adapters; needs domain-owned cleanup capabilities"},
{From: "accountteardown", To: "domains/ec2/awsapi/vpc", Debt: "accountteardown (dependency)", Reason: "EC2 reapers call the awsapi action adapters; needs domain-owned cleanup capabilities"},
{From: "agents/ecs/agent", To: "handlers/ecs", Debt: "agents/ecs/agent", Reason: "assignment/state wire types from the ECS implementation; needs a guest/controller contract"},
{From: "agents/eks/tokenwebhook", To: "handlers/eks", Debt: "agents/eks/tokenwebhook", Reason: "WebhookTokenReviewResult from the EKS implementation; needs an EKS contract or projection"},
{From: "agents/rds/agent", To: "handlers/rds", Debt: "agents/rds/agent (dependency, narrowed)", Reason: "command protocol, wire and record types; needs a guest/controller wire contract"},
{From: "cmd/aws-model-coverage", To: "domains/acm/awsapi", Debt: "cmd/aws-model-coverage", Reason: "reads declared ECR/ACM inventories; named exception, must not construct a registration"},
{From: "cmd/aws-model-coverage", To: "domains/ecr/awsapi", Debt: "cmd/aws-model-coverage", Reason: "reads declared ECR/ACM inventories; named exception, must not construct a registration"},
{From: "daemon", To: "domains/ec2/awsapi/instance", Debt: "Instance adapter consumers outside EC2", Reason: "daemon/eks_cp_control.go and rds_deps.go call the instance adapter; needs an EC2 capability"},
{From: "domains/acm", To: "handlers/iam", Debt: "ACM: the IAM AES-GCM helper import (ACM relocation residual)", Reason: "IAM AES-GCM helpers; needs a shared crypto boundary"},
{From: "domains/admission/quota", To: "domains/ec2/awsapi/eip", Debt: "domains/admission/quota (dependency, moved from handlers/quota unchanged)", Reason: "describe adapter used as a counter; needs a reader capability"},
{From: "domains/admission/quota", To: "domains/ec2/awsapi/instance", Debt: "domains/admission/quota (dependency, moved from handlers/quota unchanged)", Reason: "vcpu.go counts instances through the instance adapter; needs an EC2 reader capability"},
{From: "domains/admission/quota", To: "domains/ec2/awsapi/volume", Debt: "domains/admission/quota (dependency, moved from handlers/quota unchanged)", Reason: "describe adapter used as a counter; needs a reader capability"},
{From: "domains/admission/quota", To: "domains/ec2/awsapi/vpc", Debt: "domains/admission/quota (dependency, moved from handlers/quota unchanged)", Reason: "describe adapter used as a counter; needs a reader capability"},
{From: "domains/admission/quota", To: "domains/ec2/instancetypes", Debt: "domains/admission/quota → domains/ec2/instancetypes", Reason: "DefaultVCPUs sizing; needs a consumer-owned capability or EC2 projection"},
{From: "domains/admission/quota", To: "gateway/elbv2", Debt: "domains/admission/quota (dependency, moved from handlers/quota unchanged)", Reason: "live ELBv2 counter; needs a reader capability"},
{From: "domains/admission/quota", To: "handlers/rds", Debt: "domains/admission/quota (dependency, moved from handlers/quota unchanged)", Reason: "live RDS counter; needs a reader capability"},
{From: "domains/admission/quota", To: "runtime/compute/vm", Debt: "domains/admission/quota (dependency, moved from handlers/quota unchanged)", Reason: "instance-record vCPU sweep; needs a reader capability"},
{From: "domains/dns", To: "domains/network/reconcile", Debt: "domains/dns", Reason: "takes the reconcile lock from network's implementation"},
{From: "domains/dns", To: "runtime/compute/vm", Debt: "domains/dns", Reason: "domain-to-runtime import of VM state"},
{From: "domains/ec2/awsapi/instance", To: "handlers/iam", Debt: "domains/ec2/awsapi/instance (dependency)", Reason: "IAM edge needs an IAM capability"},
{From: "domains/ec2/awsapi/instance", To: "runtime/compute/cache", Debt: "domains/ec2/awsapi/instance (dependency)", Reason: "node liveness and VM state; needs an EC2-owned projection"},
{From: "domains/ec2/awsapi/instance", To: "runtime/compute/vm", Debt: "domains/ec2/awsapi/instance (dependency)", Reason: "node liveness and VM state; needs an EC2-owned projection"},
{From: "domains/ec2/awsapi/spotinstance", To: "domains/admission/quota", Debt: "domains/ec2/awsapi/spotinstance (dependency)", Reason: "RequestSpotInstances.go uses IAMService and quota.Service"},
{From: "domains/ec2/awsapi/spotinstance", To: "handlers/iam", Debt: "domains/ec2/awsapi/spotinstance (dependency)", Reason: "RequestSpotInstances.go uses IAMService and quota.Service"},
{From: "domains/ec2/eip", To: "domains/network/topology", Debt: "EC2 → network implementation", Reason: "EC2 reads network implementation; clears with the EC2-owned projection"},
{From: "domains/ec2/guestmetadata", To: "domains/dns", Debt: "Guest metadata: the cross-domain and runtime imports above", Reason: "guest-metadata relocation residual; needs capabilities, projections or contracts"},
{From: "domains/ec2/guestmetadata", To: "handlers/iam", Debt: "Guest metadata: the cross-domain and runtime imports above", Reason: "guest-metadata relocation residual; needs capabilities, projections or contracts"},
{From: "domains/ec2/guestmetadata", To: "handlers/sts", Debt: "Guest metadata: the cross-domain and runtime imports above", Reason: "guest-metadata relocation residual; needs capabilities, projections or contracts"},
{From: "domains/ec2/guestmetadata", To: "runtime/compute/vm", Debt: "Guest metadata: the cross-domain and runtime imports above", Reason: "guest-metadata relocation residual; needs capabilities, projections or contracts"},
{From: "domains/ec2/instance", To: "domains/dns", Debt: "domains/ec2/instance", Reason: "cross-domain implementation import of DNS"},
{From: "domains/ec2/instance", To: "domains/network/topology", Debt: "EC2 → network implementation", Reason: "EC2 reads network implementation; clears with the EC2-owned projection"},
{From: "domains/ec2/instance", To: "runtime/compute/gpu", Debt: "domains/ec2/instance", Reason: "VM/GPU runtime state read directly; needs a domain-owned projection"},
{From: "domains/ec2/instance", To: "runtime/compute/vm", Debt: "domains/ec2/instance", Reason: "VM/GPU runtime state read directly; needs a domain-owned projection"},
{From: "domains/ec2/instance/state", To: "daemon", Debt: "domains/ec2/instance/state (dependency, IMDS readers)", Reason: "wraps the daemon's local-file reader and JetStreamManager bucket open unchanged for vpcd's IMDS lookup"},
{From: "domains/ec2/instance/state", To: "runtime/compute/vm", Debt: "domains/ec2/instance/state (dependency)", Reason: "the vpcd IMDS local reader returns vm.VM"},
{From: "domains/ec2/volume", To: "runtime/compute/vm", Debt: "Domain → runtime", Reason: "VM runtime state read directly; needs a domain-owned projection"},
{From: "domains/ec2/vpc", To: "domains/network/external", Debt: "EC2 → network implementation", Reason: "EC2 reads network implementation; clears with the EC2-owned projection"},
{From: "domains/ec2/vpc", To: "domains/network/external/dhcp", Debt: "EC2 → network implementation", Reason: "EC2 reads network implementation; clears with the EC2-owned projection"},
{From: "domains/ec2/vpc", To: "domains/network/identifiers", Debt: "EC2 → network implementation", Reason: "EC2 reads network implementation; clears with the EC2-owned projection"},
{From: "domains/ec2/vpc", To: "domains/network/topology", Debt: "EC2 → network implementation", Reason: "EC2 reads network implementation; clears with the EC2-owned projection"},
{From: "domains/ecr/auth", To: "handlers/iam", Debt: "Cross-domain", Reason: "IAM implementation import; needs an IAM capability"},
{From: "domains/network/host", To: "runtime/compute/vm", Debt: "domains/network", Reason: "domain-to-runtime import"},
{From: "domains/network/host", To: "runtime/host/command", Debt: "domains/network", Reason: "domain-to-runtime import"},
{From: "domains/network/reconcile", To: "domains/ec2/eip", Debt: "domains/network", Reason: "reconcile reads EC2 private records; clears with the EC2-owned projection"},
{From: "domains/network/reconcile", To: "domains/ec2/igw", Debt: "domains/network", Reason: "reconcile reads EC2 private records; clears with the EC2-owned projection"},
{From: "domains/network/reconcile", To: "domains/ec2/natgw", Debt: "domains/network", Reason: "reconcile reads EC2 private records; clears with the EC2-owned projection"},
{From: "domains/network/reconcile", To: "domains/ec2/routetable", Debt: "domains/network", Reason: "reconcile reads EC2 private records; clears with the EC2-owned projection"},
{From: "domains/network/reconcile", To: "domains/ec2/vpc", Debt: "domains/network", Reason: "reconcile reads EC2 private records; clears with the EC2-owned projection"},
{From: "domains/network/reconcile", To: "runtime/compute/vm", Debt: "domains/network", Reason: "domain-to-runtime import"},
{From: "domains/ochre", To: "domains/ec2/systeminstance", Debt: "Cross-domain", Reason: "consumes another domain's implementation rather than a capability"},
{From: "domains/ochre", To: "domains/network/systemvpc", Debt: "Cross-domain", Reason: "consumes another domain's implementation rather than a capability"},
{From: "domains/ochre", To: "gateway/bedrock", Debt: "gateway/bedrock", Reason: "Ochre domain core still lives in gateway/bedrock; needs the recorded split"},
{From: "domains/ochre", To: "runtime/compute/gpu", Debt: "Domain → runtime", Reason: "GPU runtime state read directly; needs a domain-owned projection"},
{From: "domains/ochre/vector", To: "handlers/iam", Debt: "Cross-domain", Reason: "consumes another domain's implementation rather than a capability"},
{From: "gateway", To: "domains/ec2/awsapi", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
{From: "gateway", To: "domains/ec2/awsapi/account", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
{From: "gateway", To: "domains/ec2/awsapi/capacityreservation", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
{From: "gateway", To: "domains/ec2/awsapi/eigw", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
{From: "gateway", To: "domains/ec2/awsapi/eip", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
{From: "gateway", To: "domains/ec2/awsapi/idem", Debt: "gateway/ec2.go (registration; ec2_idempotency.go)", Reason: "dispatch-time idempotency wrapper; moves with the EC2 dispatch map"},
{From: "gateway", To: "domains/ec2/awsapi/igw", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
{From: "gateway", To: "domains/ec2/awsapi/image", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
{From: "gateway", To: "domains/ec2/awsapi/instance", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
{From: "gateway", To: "domains/ec2/awsapi/key", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
{From: "gateway", To: "domains/ec2/awsapi/launchtemplate", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
{From: "gateway", To: "domains/ec2/awsapi/natgw", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
{From: "gateway", To: "domains/ec2/awsapi/placementgroup", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
{From: "gateway", To: "domains/ec2/awsapi/routetable", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
{From: "gateway", To: "domains/ec2/awsapi/snapshot", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
{From: "gateway", To: "domains/ec2/awsapi/spotinstance", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
{From: "gateway", To: "domains/ec2/awsapi/tags", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
{From: "gateway", To: "domains/ec2/awsapi/volume", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
{From: "gateway", To: "domains/ec2/awsapi/vpc", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
{From: "gateway", To: "domains/ec2/awsapi/zone", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
{From: "gateway/bedrock", To: "handlers/iam", Debt: "IAM implementation consumers in STS, Ochre and ELBv2", Reason: "secret encrypt/decrypt helpers; needs a shared crypto boundary"},
{From: "gateway/rds", To: "domains/ec2/awsapi/instance", Debt: "Instance adapter consumers outside EC2", Reason: "enginecatalog.go calls the instance adapter; needs an EC2 capability"},
{From: "gateway/sts", To: "handlers/iam", Debt: "IAM implementation consumers in STS, Ochre and ELBv2", Reason: "GetCallerIdentity takes IAMService; needs an IAM capability"},
{From: "handlers/ecs", To: "domains/ec2/ebs/metadata", Debt: "close-out: handlers/ecs (ADR-0004 blocked-dependency list; inventory-ecs boundary imports)", Reason: "legacy ECS calls another domain's implementation as an internal API"},
{From: "handlers/ecs", To: "domains/ec2/eip", Debt: "close-out: handlers/ecs (ADR-0004 blocked-dependency list; inventory-ecs boundary imports)", Reason: "legacy ECS calls another domain's implementation as an internal API"},
{From: "handlers/ecs", To: "domains/ec2/instancetypes", Debt: "close-out: handlers/ecs (ADR-0004 blocked-dependency list; inventory-ecs boundary imports)", Reason: "legacy ECS calls another domain's implementation as an internal API"},
{From: "handlers/ecs", To: "handlers/elbv2", Debt: "close-out: handlers/ecs (ADR-0004 blocked-dependency list; inventory-ecs boundary imports)", Reason: "legacy ECS calls another domain's implementation as an internal API"},
{From: "handlers/ecs", To: "handlers/iam", Debt: "close-out: handlers/ecs (ADR-0004 blocked-dependency list; inventory-ecs boundary imports)", Reason: "legacy ECS calls another domain's implementation as an internal API"},
{From: "handlers/eks", To: "domains/dns", Debt: "close-out: handlers/eks (ADR-0004 blocked-dependency list; inventory-eks boundary imports)", Reason: "legacy EKS calls another domain's or runtime implementation directly"},
{From: "handlers/eks", To: "domains/ec2/ebs/metadata", Debt: "close-out: handlers/eks (ADR-0004 blocked-dependency list; inventory-eks boundary imports)", Reason: "legacy EKS calls another domain's or runtime implementation directly"},
{From: "handlers/eks", To: "domains/ec2/instancetypes", Debt: "close-out: handlers/eks (ADR-0004 blocked-dependency list; inventory-eks boundary imports)", Reason: "legacy EKS calls another domain's or runtime implementation directly"},
{From: "handlers/eks", To: "domains/ec2/placementgroup", Debt: "close-out: handlers/eks (ADR-0004 blocked-dependency list; inventory-eks boundary imports)", Reason: "legacy EKS calls another domain's or runtime implementation directly"},
{From: "handlers/eks", To: "domains/ec2/systeminstance", Debt: "close-out: handlers/eks (ADR-0004 blocked-dependency list; inventory-eks boundary imports)", Reason: "legacy EKS calls another domain's or runtime implementation directly"},
{From: "handlers/eks", To: "domains/network/systemvpc", Debt: "close-out: handlers/eks (ADR-0004 blocked-dependency list; inventory-eks boundary imports)", Reason: "legacy EKS calls another domain's or runtime implementation directly"},
{From: "handlers/eks", To: "handlers/iam", Debt: "close-out: handlers/eks (ADR-0004 blocked-dependency list; inventory-eks boundary imports)", Reason: "legacy EKS calls another domain's or runtime implementation directly"},
{From: "handlers/eks", To: "runtime/compute/vm", Debt: "close-out: handlers/eks (ADR-0004 blocked-dependency list; inventory-eks boundary imports)", Reason: "legacy EKS calls another domain's or runtime implementation directly"},
{From: "handlers/elbv2", To: "domains/acm", Debt: "handlers/elbv2 (cross-domain)", Reason: "legacy ELBv2 cross-domain import; clears when ELBv2 is extracted behind consumer-owned capabilities"},
{From: "handlers/elbv2", To: "domains/dns", Debt: "handlers/elbv2 (cross-domain)", Reason: "legacy ELBv2 cross-domain import; clears when ELBv2 is extracted behind consumer-owned capabilities"},
{From: "handlers/elbv2", To: "domains/ec2/systeminstance", Debt: "handlers/elbv2 (cross-domain)", Reason: "legacy ELBv2 cross-domain import; clears when ELBv2 is extracted behind consumer-owned capabilities"},
{From: "handlers/elbv2", To: "domains/ec2/vpc", Debt: "handlers/elbv2 (cross-domain)", Reason: "legacy ELBv2 cross-domain import; clears when ELBv2 is extracted behind consumer-owned capabilities"},
{From: "handlers/elbv2", To: "domains/network/topology", Debt: "handlers/elbv2 (cross-domain)", Reason: "legacy ELBv2 cross-domain import; clears when ELBv2 is extracted behind consumer-owned capabilities"},
{From: "handlers/elbv2", To: "handlers/iam", Debt: "IAM implementation consumers in STS, Ochre and ELBv2", Reason: "system-instance role ensurer; needs an IAM capability"},
{From: "handlers/elbv2", To: "lbagent", Debt: "handlers/elbv2/{agent_types,haproxy,health_checker,nginx}.go import lbagent", Reason: "health-report and cert/PID-path vocabulary; needs a guest/controller contract"},
{From: "handlers/rds", To: "domains/dns", Debt: "close-out: handlers/rds (ADR-0004 blocked-dependency list; inventory-rds boundary imports)", Reason: "legacy RDS calls another domain's or runtime implementation directly"},
{From: "handlers/rds", To: "domains/ec2/instance", Debt: "close-out: handlers/rds (ADR-0004 blocked-dependency list; inventory-rds boundary imports)", Reason: "legacy RDS calls another domain's or runtime implementation directly"},
{From: "handlers/rds", To: "domains/ec2/instancetypes", Debt: "close-out: handlers/rds (ADR-0004 blocked-dependency list; inventory-rds boundary imports)", Reason: "legacy RDS calls another domain's or runtime implementation directly"},
{From: "handlers/rds", To: "domains/ec2/systeminstance", Debt: "close-out: handlers/rds (ADR-0004 blocked-dependency list; inventory-rds boundary imports)", Reason: "legacy RDS calls another domain's or runtime implementation directly"},
{From: "handlers/rds", To: "domains/network/systemvpc", Debt: "close-out: handlers/rds (ADR-0004 blocked-dependency list; inventory-rds boundary imports)", Reason: "legacy RDS calls another domain's or runtime implementation directly"},
{From: "handlers/rds", To: "handlers/iam", Debt: "close-out: handlers/rds (ADR-0004 blocked-dependency list; inventory-rds boundary imports)", Reason: "legacy RDS calls another domain's or runtime implementation directly"},
{From: "handlers/rds", To: "runtime/compute/vm", Debt: "close-out: handlers/rds (ADR-0004 blocked-dependency list; inventory-rds boundary imports)", Reason: "legacy RDS calls another domain's or runtime implementation directly"},
{From: "handlers/sts", To: "handlers/iam", Debt: "IAM implementation consumers in STS, Ochre and ELBv2", Reason: "STS reads IAM service, trust-policy and OIDC-provider state; needs an IAM capability"},
{From: "operator/cli", To: "accountteardown", Debt: "operator/cli (dependency)", Reason: "command tree imports implementations; needs public capabilities"},
{From: "operator/cli", To: "daemon", Debt: "operator/cli (dependency)", Reason: "command tree imports implementations; needs public capabilities"},
{From: "operator/cli", To: "gateway", Debt: "operator/cli (dependency)", Reason: "command tree imports implementations; needs public capabilities"},
{From: "operator/cli", To: "gateway/bedrock", Debt: "operator/cli (dependency)", Reason: "command tree imports implementations; needs public capabilities"},
{From: "operator/cli", To: "handlers/eks", Debt: "operator/cli (dependency)", Reason: "command tree imports implementations; needs public capabilities"},
{From: "operator/cli", To: "handlers/iam", Debt: "operator/cli (dependency)", Reason: "command tree imports implementations; needs public capabilities"},
}
+163
View File
@@ -0,0 +1,163 @@
// Package layering enforces ADR-0001's package-boundary dependency rules over
// the module's production import graph, ratcheted by a recorded-debt allowlist.
package layering
import (
"errors"
"fmt"
"go/ast"
"go/build/constraint"
"go/parser"
"go/token"
"io/fs"
"os"
"path/filepath"
"runtime"
"sort"
"strconv"
"strings"
)
const modulePath = "github.com/mulgadc/spinifex"
// edge is one package-to-package import, both sides named as in the allowlist:
// application packages relative to spinifex/, module-root ones (cmd/,
// contracts/, internal/) relative to the module root.
type edge struct {
From, To string
}
func (e edge) String() string { return e.From + " -> " + e.To }
// graph is the production import graph restricted to this module.
type graph struct {
// pkgs maps a package name to its module-relative directory.
pkgs map[string]string
// imports records the first file:line at which each edge appears.
imports map[edge]string
}
func newGraph() *graph {
return &graph{pkgs: map[string]string{}, imports: map[edge]string{}}
}
// shortName drops the application-root prefix so names read like the ADR and
// the migration record; classify rejects app-root dirs that would collide.
func shortName(full string) string {
if rest, ok := strings.CutPrefix(full, "spinifex/"); ok {
return rest
}
return full
}
func moduleRoot() (string, error) {
_, file, _, ok := runtime.Caller(0)
if !ok {
return "", errors.New("cannot locate test source")
}
// architecture/layering/graph_test.go -> module root.
return filepath.Abs(filepath.Join(filepath.Dir(file), "..", ".."))
}
// excludedTopDirs hold no production code: tests/ is test-only, docs/ only
// embeds documentation and architecture/ is repository governance tooling.
var excludedTopDirs = map[string]bool{"tests": true, "docs": true, "architecture": true}
// loadGraph parses the imports of every non-test Go file under root, so it
// sees every build-tag variant and needs no Go toolchain or workspace.
func loadGraph(root string) (*graph, error) {
g := newGraph()
fset := token.NewFileSet()
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
rel, err := filepath.Rel(root, path)
if err != nil {
return err
}
rel = filepath.ToSlash(rel)
if d.IsDir() {
if rel == "." {
return nil
}
name := d.Name()
if strings.HasPrefix(name, ".") || strings.HasPrefix(name, "_") ||
name == "testdata" || name == "vendor" || name == "node_modules" || excludedTopDirs[rel] {
return filepath.SkipDir
}
if _, err := os.Stat(filepath.Join(path, "go.mod")); err == nil {
return filepath.SkipDir
}
return nil
}
if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
return nil
}
f, err := parser.ParseFile(fset, path, nil, parser.ImportsOnly|parser.ParseComments)
if err != nil {
return err
}
if ignoredByBuildTag(f.Comments, f.Package) {
return nil
}
dir := filepath.ToSlash(filepath.Dir(rel))
if dir == "." {
dir = ""
}
from := shortName(dir)
g.pkgs[from] = dir
for _, imp := range f.Imports {
p, err := strconv.Unquote(imp.Path.Value)
if err != nil {
return err
}
target, ok := strings.CutPrefix(p, modulePath+"/")
if !ok {
continue
}
e := edge{From: from, To: shortName(target)}
if _, seen := g.imports[e]; !seen {
pos := fset.Position(imp.Pos())
g.imports[e] = fmt.Sprintf("%s:%d", rel, pos.Line)
}
}
return nil
})
return g, err
}
// ignoredByBuildTag reports a `//go:build ignore` file, which is a standalone
// program (a generator) rather than part of its directory's package.
func ignoredByBuildTag(groups []*ast.CommentGroup, pkgPos token.Pos) bool {
for _, cg := range groups {
if cg.Pos() >= pkgPos {
break
}
for _, c := range cg.List {
if !constraint.IsGoBuild(c.Text) {
continue
}
expr, err := constraint.Parse(c.Text)
if err == nil && expr.String() == "ignore" {
return true
}
}
}
return false
}
// sortedEdges returns the graph's edges in a stable order.
func (g *graph) sortedEdges() []edge {
out := make([]edge, 0, len(g.imports))
for e := range g.imports {
out = append(out, e)
}
sort.Slice(out, func(i, j int) bool {
if out[i].From != out[j].From {
return out[i].From < out[j].From
}
return out[i].To < out[j].To
})
return out
}
+193
View File
@@ -0,0 +1,193 @@
package layering
import (
"fmt"
"sort"
"strings"
"testing"
)
type violation struct {
rule string
edge edge
pos string
}
type report struct {
unmapped []string
violations []violation // every rule hit, allowlisted or not
unrecorded []violation // hits whose edge has no allowlist entry
stale []allowance // entries whose edge no rule rejects any more
malformed []string
}
// check classifies g, applies every rule and compares the result with allow.
func check(g *graph, allow []allowance) report {
var rep report
classes := map[string]class{}
for name, dir := range g.pkgs {
c, ok := classify(dir)
if !ok || c.layer == layerTests {
rep.unmapped = append(rep.unmapped, fmt.Sprintf("%s (directory %s)", name, dir))
continue
}
classes[name] = c
}
allowed := map[edge]bool{}
for _, a := range allow {
e := edge{From: a.From, To: a.To}
switch {
case allowed[e]:
rep.malformed = append(rep.malformed, "duplicate entry "+e.String())
case a.Debt == "" || a.Reason == "":
rep.malformed = append(rep.malformed, "entry "+e.String()+" needs both Debt and Reason")
}
allowed[e] = true
}
hit := map[edge]bool{}
for _, e := range g.sortedEdges() {
from, ok := classes[e.From]
if !ok {
continue
}
to, ok := classify(fullPath(e.To))
if !ok {
rep.unmapped = append(rep.unmapped, fmt.Sprintf("%s (imported by %s at %s)", e.To, e.From, g.imports[e]))
continue
}
for _, r := range rules {
if !r.subject(from, e.From) || !r.forbids(from, to, e.From) {
continue
}
v := violation{rule: r.id, edge: e, pos: g.imports[e]}
rep.violations = append(rep.violations, v)
hit[e] = true
if !allowed[e] {
rep.unrecorded = append(rep.unrecorded, v)
}
}
}
for _, a := range allow {
if !hit[edge{From: a.From, To: a.To}] {
rep.stale = append(rep.stale, a)
}
}
sort.Strings(rep.unmapped)
return rep
}
func ruleByID(id string) rule {
for _, r := range rules {
if r.id == id {
return r
}
}
panic("unknown rule " + id)
}
// TestADR0001_Layering is the blocking ratchet over the real import graph.
func TestADR0001_Layering(t *testing.T) {
root, err := moduleRoot()
if err != nil {
t.Fatal(err)
}
g, err := loadGraph(root)
if err != nil {
t.Fatal(err)
}
// Guards against a walk that silently finds nothing and passes vacuously.
if len(g.pkgs) < 100 || len(g.imports) < 500 {
t.Fatalf("scanned %d packages and %d module imports under %s; the walk has lost the tree and every rule would pass vacuously",
len(g.pkgs), len(g.imports), root)
}
rep := check(g, allowlist)
t.Run("Classification_EveryPackageMapped", func(t *testing.T) {
for _, u := range rep.unmapped {
t.Errorf("ADR-0001 current-to-target map: package %s has no layer; place it under an ADR-0001 home or classify it deliberately in layers_test.go", u)
}
})
for _, r := range rules {
t.Run(r.id, func(t *testing.T) {
subjects := 0
for name, dir := range g.pkgs {
if c, ok := classify(dir); ok && r.subject(c, name) {
subjects++
}
}
if subjects == 0 {
t.Fatalf("no scanned package is governed by %s; the rule would pass vacuously", r.id)
}
var snippets []string
for _, v := range rep.unrecorded {
if v.rule != r.id {
continue
}
t.Errorf("ADR-0001 %s: %s imports %s (%s); %s",
r.clause, v.edge.From, v.edge.To, v.pos, r.hint)
snippets = append(snippets, fmt.Sprintf("{From: %q, To: %q, Debt: \"<migration-record debt subject>\", Reason: \"<why>\"},", v.edge.From, v.edge.To))
}
if len(snippets) > 0 {
t.Logf("fix the import; only debt already recorded in docs/PACKAGE_BOUNDARY_MIGRATION.md may be allow-listed:\n%s",
strings.Join(snippets, "\n"))
}
})
}
t.Run("Allowlist_NoStaleEntries", func(t *testing.T) {
for _, a := range rep.stale {
t.Errorf("allowlist entry %s -> %s (%s) no longer matches a forbidden import; the debt is paid, so delete the entry from allowlist_test.go",
a.From, a.To, a.Debt)
}
})
t.Run("Allowlist_WellFormed", func(t *testing.T) {
for _, m := range rep.malformed {
t.Errorf("allowlist_test.go: %s", m)
}
t.Logf("%d allow-listed edges", len(allowlist))
})
}
// TestADR0001_LayeringCheckerDetectsFaults injects a forbidden edge, a stale
// allowlist entry and an unmapped package into a synthetic graph.
func TestADR0001_LayeringCheckerDetectsFaults(t *testing.T) {
g := newGraph()
for _, dir := range []string{
"spinifex/foundation/widget", "spinifex/domains/alpha/thing",
"spinifex/domains/beta/store", "spinifex/misc",
} {
g.pkgs[shortName(dir)] = dir
}
forbidden := edge{From: "foundation/widget", To: "domains/alpha/thing"}
g.imports[forbidden] = "spinifex/foundation/widget/w.go:7"
allowedEdge := edge{From: "domains/alpha/thing", To: "domains/beta/store"}
g.imports[allowedEdge] = "spinifex/domains/alpha/thing/t.go:9"
staleEntry := allowance{From: "domains/beta/store", To: "domains/alpha/thing", Debt: "synthetic", Reason: "stale"}
rep := check(g, []allowance{
{From: allowedEdge.From, To: allowedEdge.To, Debt: "synthetic", Reason: "recorded"},
staleEntry,
})
if len(rep.unrecorded) != 1 || rep.unrecorded[0].edge != forbidden ||
rep.unrecorded[0].rule != ruleByID("R1_FoundationImportsOnlyFoundation").id {
t.Errorf("want only %s reported under R1, got %+v", forbidden, rep.unrecorded)
}
if len(rep.stale) != 1 || rep.stale[0] != staleEntry {
t.Errorf("want stale entry %+v, got %+v", staleEntry, rep.stale)
}
if len(rep.unmapped) != 1 || !strings.HasPrefix(rep.unmapped[0], "misc ") {
t.Errorf("want unmapped package misc, got %v", rep.unmapped)
}
var allowedHit bool
for _, v := range rep.violations {
allowedHit = allowedHit || v.edge == allowedEdge
}
if !allowedHit {
t.Errorf("allow-listed cross-domain edge %s was not detected by R3, so the allowlist entry would read as stale", allowedEdge)
}
}
+164
View File
@@ -0,0 +1,164 @@
package layering
import (
"slices"
"strings"
)
type layer string
const (
layerFoundation layer = "foundation"
layerContracts layer = "contracts"
layerDomain layer = "domains"
layerIngress layer = "ingress"
layerRuntime layer = "runtime"
layerOperator layer = "operator"
layerAgents layer = "agents"
layerProviders layer = "providers"
layerBootstrap layer = "bootstrap"
layerCmd layer = "cmd"
layerInternal layer = "internal"
layerTestkit layer = "testkit"
layerLegacy layer = "legacy"
// layerTests is valid only as an import target; tests/ is never scanned.
layerTests layer = "tests"
)
// domainKind distinguishes the parts of a domain the rules treat differently.
type domainKind int
const (
notDomain domainKind = iota
domainImpl // domains/<d>/... resource implementation
domainAWSAPI // domains/<d>/awsapi/... AWS action adapters
domainProj // domains/<d>/projection/... authorised view
legacyImpl // handlers/<d>/... legacy domain implementation
legacyAdapter // gateway/<d>/... legacy AWS action adapters
)
type class struct {
layer layer
legacy string // legacy root name when layer is layerLegacy
domain string // owning domain for domain-shaped and agent packages
kind domainKind
}
func (c class) domainShaped() bool { return c.kind != notDomain }
// isLegacy reports whether c sits under one of the named legacy roots.
func (c class) isLegacy(roots ...string) bool {
if c.layer != layerLegacy {
return false
}
if len(roots) == 0 {
return true
}
return slices.Contains(roots, c.legacy)
}
// legacyRoots are pre-ADR-0001 application roots still standing.
var legacyRoots = map[string]bool{
"handlers": true, "gateway": true, "daemon": true,
"accountteardown": true, "lbagent": true,
}
// gatewayDomains names the domain each gateway/<service> adapter belongs to;
// Bedrock is the AWS surface of the Ochre domain.
var gatewayDomains = map[string]string{
"bedrock": "ochre", "ecs": "ecs", "eks": "eks", "elbv2": "elbv2", "iam": "iam",
"rds": "rds", "sts": "sts",
}
// runtimeLegacy and operatorLegacy are the legacy roots whose ADR-0001 target
// is runtime/ and operator/ respectively.
var (
runtimeLegacy = []string{"daemon"}
operatorLegacy = []string{"accountteardown"}
)
// classify maps a module-relative package directory to its layer. ok is false
// for a package that has no deliberate classification.
func classify(full string) (class, bool) {
parts := strings.Split(full, "/")
switch parts[0] {
case "cmd":
return class{layer: layerCmd}, len(parts) >= 2
case "contracts":
return class{layer: layerContracts}, len(parts) >= 2
case "internal":
if len(parts) < 2 {
return class{}, false
}
if parts[1] == "testkit" || parts[1] == "tooling" {
return class{layer: layerTestkit}, true
}
return class{layer: layerInternal}, true
case "tests":
return class{layer: layerTests}, true
case "spinifex":
return classifyApp(parts[1:])
}
return class{}, false
}
func classifyApp(app []string) (class, bool) {
if len(app) == 0 {
return class{}, false
}
switch app[0] {
case "foundation":
return class{layer: layerFoundation}, true
case "ingress":
return class{layer: layerIngress}, true
case "runtime":
return class{layer: layerRuntime}, true
case "operator":
return class{layer: layerOperator}, true
case "providers":
return class{layer: layerProviders}, true
case "bootstrap":
return class{layer: layerBootstrap}, true
case "agents":
c := class{layer: layerAgents}
if len(app) >= 2 {
c.domain = app[1]
}
return c, true
case "domains":
if len(app) < 2 {
return class{}, false
}
c := class{layer: layerDomain, domain: app[1], kind: domainImpl}
if len(app) >= 3 {
switch app[2] {
case "awsapi":
c.kind = domainAWSAPI
case "projection":
c.kind = domainProj
}
}
return c, true
}
if !legacyRoots[app[0]] {
return class{}, false
}
c := class{layer: layerLegacy, legacy: app[0]}
switch {
case app[0] == "handlers" && len(app) >= 2:
c.domain, c.kind = app[1], legacyImpl
case app[0] == "gateway" && len(app) >= 2 && gatewayDomains[app[1]] != "":
c.domain, c.kind = gatewayDomains[app[1]], legacyAdapter
}
return c, true
}
// fullPath reverses shortName for classification.
func fullPath(name string) string {
top, _, _ := strings.Cut(name, "/")
switch top {
case "cmd", "contracts", "internal", "tests", "docs":
return name
}
return "spinifex/" + name
}
+81
View File
@@ -0,0 +1,81 @@
package layering
import (
"os"
"path/filepath"
"testing"
)
// frozenLegacyPackages is every production package still under a legacy root.
// It only shrinks: a new entry is a new legacy package, a missing one is a
// completed move whose line must be deleted.
var frozenLegacyPackages = []string{
"accountteardown",
"daemon",
"gateway",
"gateway/bedrock",
"gateway/ecs",
"gateway/eks",
"gateway/elbv2",
"gateway/iam",
"gateway/rds",
"gateway/spx",
"gateway/sts",
"handlers/ecs",
"handlers/eks",
"handlers/elbv2",
"handlers/iam",
"handlers/iam/mock",
"handlers/rds",
"handlers/sts",
"lbagent",
}
// retiredLegacyRoots were legacy roots whose every package has moved to its
// ADR-0001 target; recreating one would reopen a closed migration row.
var retiredLegacyRoots = []string{"admin", "services", "utils", "vpcd"}
// TestADR0001_LegacyRootsFrozen: the current-to-target map gives every legacy
// root a destination, so none may gain a package and no retired root may return.
func TestADR0001_LegacyRootsFrozen(t *testing.T) {
root, err := moduleRoot()
if err != nil {
t.Fatal(err)
}
g, err := loadGraph(root)
if err != nil {
t.Fatal(err)
}
present := map[string]bool{}
for name, dir := range g.pkgs {
if c, ok := classify(dir); ok && c.layer == layerLegacy {
present[name] = true
}
}
if len(present) == 0 {
t.Fatal("found no legacy packages; the freeze would pass vacuously")
}
frozen := map[string]bool{}
for _, p := range frozenLegacyPackages {
frozen[p] = true
if !present[p] {
t.Errorf("legacy package %s no longer exists; delete it from frozenLegacyPackages", p)
}
}
for p := range present {
if !frozen[p] {
t.Errorf("ADR-0001 current-to-target map: %s is a new package under a legacy root; create it at its ADR-0001 target home instead", p)
}
}
for _, r := range retiredLegacyRoots {
entries, err := os.ReadDir(filepath.Join(root, "spinifex", r))
if err != nil && !os.IsNotExist(err) {
t.Fatal(err)
}
if len(entries) > 0 {
t.Errorf("ADR-0001 current-to-target map: spinifex/%s is a retired legacy root; give the code its target home instead", r)
}
}
}
+170
View File
@@ -0,0 +1,170 @@
package layering
import "slices"
// rule is one ADR-0001 dependency rule over a classified import edge.
type rule struct {
id string // subtest name, led by the ADR-0001 clause it encodes
clause string // the ADR text the rule enforces, quoted in failures
hint string // one-line fix direction
// subject reports whether an importer is governed by the rule; the
// non-empty guard requires at least one governed package per rule.
subject func(from class, fromName string) bool
// forbids reports whether the rule rejects the edge.
forbids func(from, to class, fromName string) bool
}
// awsgwRole is the composition root ADR-0002 lets wire domain registrations.
const awsgwRole = "runtime/roles/awsgw"
// qualificationBinaries are the developer/qualification mains ADR-0001 lets
// keep reusable source in internal/testkit or internal/tooling.
var qualificationBinaries = map[string]bool{
"cmd/aws-model-coverage": true, "cmd/dhcptest": true, "cmd/spx-loadgen": true,
}
func isLayer(c class, layers ...layer) bool {
return slices.Contains(layers, c.layer)
}
// importsDomainImplementation is any domain-owned package other than a named
// projection, in its target or legacy home.
func importsDomainImplementation(to class) bool {
return to.domainShaped() && to.kind != domainProj
}
var rules = []rule{
{
id: "R1_FoundationImportsOnlyFoundation",
clause: "rule 1: foundation imports neither domains, ingress, runtime nor operator (enforced conservatively: foundation imports no module package above foundation)",
hint: "move the dependency's needed primitive into foundation or invert it through a parameter",
subject: func(f class, _ string) bool { return f.layer == layerFoundation },
forbids: func(_, t class, _ string) bool { return t.layer != layerFoundation },
},
{
id: "R2_IngressImportsNoImplementation",
clause: "rule 2: ingress/aws imports no domain implementation; domains supply their own action registration through the ingress registration contract",
hint: "take the capability through the registration contract wired by runtime/roles/awsgw",
subject: func(f class, _ string) bool { return f.layer == layerIngress },
forbids: func(_, t class, _ string) bool {
return !isLayer(t, layerFoundation, layerContracts, layerIngress)
},
},
{
id: "R3_DomainImportsNoOtherDomainImplementation",
clause: "rule 3: a domain does not read another domain's private records, migrations or repository; it uses a consumer-owned capability, a named projection, or a versioned contracts/<domain>/v1 contract",
hint: "declare a narrow capability in the consumer, or import the owner's projection or contracts/<domain>/v1",
subject: func(f class, _ string) bool { return f.domainShaped() },
forbids: func(f, t class, _ string) bool {
return f.domainShaped() && importsDomainImplementation(t) && t.domain != f.domain
},
},
{
id: "R4_ProviderImportsNoDomainImplementation",
clause: "rule 4: a provider adapter implements a provider contract and cannot acquire AWS-visible resource authority by reading a domain's private state",
hint: "pass the needed values into the provider contract instead of importing the domain",
subject: func(f class, _ string) bool { return f.layer == layerProviders },
forbids: func(_, t class, _ string) bool { return t.domainShaped() || t.isLegacy() },
},
{
id: "R5_DomainImportsNoRuntimeOrOperator",
clause: "rule 5: operator and runtime are composition consumers; they wire domain modules, so a domain does not import them",
hint: "expose the runtime or operator state through a domain-owned projection or a capability wired at the composition root",
subject: func(f class, _ string) bool { return f.domainShaped() },
forbids: func(f, t class, _ string) bool {
return f.domainShaped() && (isLayer(t, layerRuntime, layerOperator) ||
t.isLegacy(runtimeLegacy...) || t.isLegacy(operatorLegacy...))
},
},
{
id: "R5_AWSAPIAdaptersImportedOnlyByOwnerOrComposition",
clause: "rule 5: operator and runtime call public use cases; a domain's awsapi action adapters are owned by that domain and wired only by the composition root",
hint: "call an owner-provided capability instead of the AWS action adapter",
subject: func(_ class, _ string) bool { return true },
forbids: func(f, t class, fromName string) bool {
if t.kind != domainAWSAPI || fromName == awsgwRole {
return false
}
return !f.domainShaped() || f.domain != t.domain
},
},
{
id: "R5_OperatorImportsNoLegacyImplementation",
clause: "rule 5 and the cmd/spinifex map row: operator use cases reach domains through public capabilities and do not read domain-private state",
hint: "reach the domain through its public capability; do not import a legacy implementation root",
subject: func(f class, _ string) bool { return f.layer == layerOperator },
forbids: func(f, t class, _ string) bool { return f.layer == layerOperator && t.isLegacy() },
},
{
id: "R6_ProductionImportsNoTestkit",
clause: "rule 6: test support is under internal/testkit; production packages do not import it",
hint: "move the shared code to its production owner or keep the import in a _test.go file",
subject: func(f class, n string) bool { return f.layer != layerTestkit && !qualificationBinaries[n] },
forbids: func(f, t class, n string) bool {
return f.layer != layerTestkit && !qualificationBinaries[n] && isLayer(t, layerTestkit, layerTests)
},
},
{
id: "Contracts_ImportOnlyFoundationAndContracts",
clause: "decision item 1 and the area table: contracts own cross-process vocabulary, not application interfaces, domain persistence or transport implementation",
hint: "keep wire types self-contained; depend only on foundation or another contract",
subject: func(f class, _ string) bool { return f.layer == layerContracts },
forbids: func(_, t class, _ string) bool { return !isLayer(t, layerFoundation, layerContracts) },
},
{
id: "Domains_ImportNoAgentsIngressOrLegacy",
clause: "area table and domain module shape: a domain owns no other domain's implementation; only its awsapi adapters may use the generic ingress/aws machinery",
hint: "depend on a contract or capability; leave ingress use to the domain's awsapi package",
subject: func(f class, _ string) bool { return f.domainShaped() },
forbids: func(f, t class, _ string) bool {
if !f.domainShaped() {
return false
}
switch {
case t.layer == layerAgents || t.isLegacy("lbagent"):
return true
case t.layer == layerIngress:
return f.kind != domainAWSAPI && f.kind != legacyAdapter
case t.isLegacy("gateway") && !t.domainShaped():
return true
case t.isLegacy("handlers", "gateway"):
return f.layer == layerDomain
}
return false
},
},
{
id: "Agents_ImportNoDomainOrLegacyImplementation",
clause: "area table: agents/<domain> own in-guest and service-role behaviour, not tenant API authority or another domain's private state",
hint: "move the shared wire vocabulary behind a guest/controller contract under contracts/",
subject: func(f class, _ string) bool { return f.layer == layerAgents || f.isLegacy("lbagent") },
forbids: func(f, t class, _ string) bool {
return (f.layer == layerAgents || f.isLegacy("lbagent")) && (t.domainShaped() || t.isLegacy())
},
},
{
id: "Q22_STSImportsNoEKSImplementation",
clause: "Q-22 prohibits the STS-to-EKS dependency in its present form; cut it through an identity-owned contract or an EKS projection",
hint: "consume an EKS projection or an identity-owned contract instead of the EKS implementation",
subject: func(f class, _ string) bool { return f.domainShaped() && f.domain == "sts" },
forbids: func(f, t class, _ string) bool {
return f.domainShaped() && f.domain == "sts" && t.domain == "eks" &&
(importsDomainImplementation(t) || t.layer == layerAgents)
},
},
{
id: "Interim_DaemonGatewayHandlersDirection",
clause: "during migration the existing daemon -> gateway -> handlers rule remains the guardrail (Q-20, Q-29)",
hint: "keep the dependency pointing daemon -> gateway -> handlers; invert it through a callback or capability",
subject: func(f class, _ string) bool { return f.isLegacy("handlers", "gateway") },
forbids: func(f, t class, _ string) bool {
switch {
case f.isLegacy("handlers"):
return t.isLegacy("gateway", "daemon")
case f.isLegacy("gateway"):
return t.isLegacy("daemon")
}
return false
},
},
}
+3 -3
View File
@@ -1,6 +1,6 @@
ARG VERSION=dev
FROM golang:1.27-bookworm AS builder
FROM golang:1.27.2-bookworm@sha256:5cf287a799e6b94384bad13d16b14904c531f51ba65792237e122ce42b392f61 AS builder
ARG VERSION
@@ -35,8 +35,8 @@ COPY bluebottle/ ./bluebottle/
# Build spinifex binary (spx)
RUN COMMIT=$(cd spinifex && git rev-parse --short HEAD 2>/dev/null || echo "unknown") && \
cd spinifex && GOFIPS140=v1.0.0 go build -ldflags "-s -w \
-X github.com/mulgadc/spinifex/cmd/spinifex/cmd.Version=${VERSION} \
-X github.com/mulgadc/spinifex/cmd/spinifex/cmd.Commit=${COMMIT}" \
-X github.com/mulgadc/spinifex/spinifex/operator/cli.Version=${VERSION} \
-X github.com/mulgadc/spinifex/spinifex/operator/cli.Commit=${COMMIT}" \
-o /out/spx cmd/spinifex/main.go
# Build nbdkit viperblock plugin (.so)
+21 -5
View File
@@ -7,25 +7,36 @@ import (
"github.com/mulgadc/predastore/s3api"
"github.com/mulgadc/spinifex/internal/awsmodel"
acmawsapi "github.com/mulgadc/spinifex/spinifex/domains/acm/awsapi"
awsapi "github.com/mulgadc/spinifex/spinifex/domains/ecr/awsapi"
"github.com/mulgadc/spinifex/spinifex/gateway"
"github.com/mulgadc/spinifex/spinifex/ingress/aws/dispatch"
_ "github.com/mulgadc/bluebottle/pkg/fipsboot"
)
func main() {
outputDir := flag.String("out", "", "write one publishable page per service into this directory")
jsonOutput := flag.String("json", "", "write a machine-readable coverage inventory to this file")
flag.Parse()
coverages, err := compareAll()
if err != nil {
fail(err)
}
if *outputDir == "" {
if *outputDir == "" && *jsonOutput == "" {
fmt.Print(awsmodel.RenderCoverageSummary(coverages))
return
}
if err := awsmodel.WritePages(*outputDir, coverages); err != nil {
fail(err)
if *outputDir != "" {
if err := awsmodel.WritePages(*outputDir, coverages); err != nil {
fail(err)
}
}
if *jsonOutput != "" {
if err := awsmodel.WriteCoverageJSON(*jsonOutput, coverages); err != nil {
fail(err)
}
}
}
@@ -35,10 +46,15 @@ func fail(err error) {
}
func compareAll() ([]awsmodel.OperationCoverage, error) {
dispatch := gateway.AWSOperationInventory()
// Registered services contribute their declared inventory, which needs no
// live dependencies; nothing here serves a request.
inventories := gateway.AWSOperationInventory(map[string]dispatch.Inventory{
awsapi.ServiceName: awsapi.OperationInventory(),
acmawsapi.ServiceName: acmawsapi.OperationInventory(),
})
coverages := make([]awsmodel.OperationCoverage, 0, len(awsmodel.Services()))
for _, service := range awsmodel.Services() {
inventory, ok := dispatch[string(service)]
inventory, ok := inventories[string(service)]
var modelInventory awsmodel.DispatchInventory
switch {
case ok:
+1 -1
View File
@@ -23,7 +23,7 @@ import (
"github.com/insomniacslk/dhcp/dhcpv4"
"github.com/insomniacslk/dhcp/dhcpv4/nclient4"
"github.com/mulgadc/spinifex/spinifex/network/external/dhcp"
"github.com/mulgadc/spinifex/spinifex/domains/network/external/dhcp"
_ "github.com/mulgadc/bluebottle/pkg/fipsboot"
)
+5 -165
View File
@@ -1,175 +1,15 @@
// ecr-credential-provider is a kubelet exec credential provider that lets a
// Spinifex EKS worker node pull images from the internal Spinifex ECR registry.
//
// kubelet invokes it per-pull, feeding a CredentialProviderRequest on STDIN and
// reading a CredentialProviderResponse on STDOUT. The provider fetches the
// node IAM-role credentials from IMDS, SigV4-signs GetAuthorizationToken against
// the AWS gateway's ECR endpoint, and returns the minted AWS:<jwt> as Basic auth
// for the requested registry host.
//
// Static node config (gateway URL, CA, region) is read from the cloud-init env
// file /etc/spinifex-eks/agent.env (KEY=value); real env vars override it.
// ecr-credential-provider is the kubelet exec credential provider for Spinifex
// EKS worker nodes; the implementation is in agents/eks/credentialprovider.
package main
import (
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"time"
"github.com/mulgadc/spinifex/internal/ecrauth"
"github.com/mulgadc/spinifex/internal/guestenv"
"github.com/mulgadc/spinifex/internal/imdscreds"
_ "github.com/mulgadc/bluebottle/pkg/fipsboot"
"github.com/mulgadc/spinifex/spinifex/agents/eks/credentialprovider"
)
const (
credProviderAPIVersion = "credentialprovider.kubelet.k8s.io/v1"
defaultCacheDuration = "10h"
// expiryMargin trims the token's real TTL so kubelet re-invokes the provider
// before the JWT actually expires mid-run.
expiryMargin = 30 * time.Minute
defaultEnvFile = "/etc/spinifex-eks/agent.env"
defaultGatewayCA = "/etc/spinifex-eks/gateway-ca.pem"
defaultIMDSBase = "http://169.254.169.254/latest"
)
// CredentialProviderRequest is the kubelet exec-provider request on STDIN.
type CredentialProviderRequest struct {
APIVersion string `json:"apiVersion"`
Kind string `json:"kind"`
Image string `json:"image"`
}
// AuthConfig is a single registry credential entry.
type AuthConfig struct {
Username string `json:"username"`
Password string `json:"password"`
}
// CredentialProviderResponse is the kubelet exec-provider response on STDOUT.
type CredentialProviderResponse struct {
APIVersion string `json:"apiVersion"`
Kind string `json:"kind"`
CacheKeyType string `json:"cacheKeyType"`
CacheDuration string `json:"cacheDuration"`
Auth map[string]AuthConfig `json:"auth"`
}
// config holds the static node settings the provider needs.
type config struct {
GatewayURL string
GatewayCA string
Region string
IMDSBase string
}
func main() {
cfg := loadConfig(defaultEnvFile)
httpClient, err := ecrauth.GatewayHTTPClient(cfg.GatewayCA)
if err != nil {
slog.Error("ecr-credential-provider: build gateway client", "err", err)
emitEmpty(os.Stdout)
os.Exit(1)
}
if err := run(os.Stdin, os.Stdout, cfg, httpClient); err != nil {
slog.Error("ecr-credential-provider: run failed", "err", err)
// Still emit a valid empty-auth response so kubelet has a parseable reply.
emitEmpty(os.Stdout)
os.Exit(1)
}
}
// run is the testable core: it reads a CredentialProviderRequest from in,
// resolves credentials via IMDS + the gateway, and writes a
// CredentialProviderResponse to out. The httpClient must trust the gateway CA.
func run(in io.Reader, out io.Writer, cfg config, httpClient *http.Client) error {
var req CredentialProviderRequest
if err := json.NewDecoder(in).Decode(&req); err != nil {
return fmt.Errorf("decode request: %w", err)
}
if req.Image == "" {
return fmt.Errorf("request image is empty")
}
imageHost := ecrauth.HostFromImage(req.Image)
creds, err := imdscreds.Fetch(httpClient, cfg.IMDSBase)
if err != nil {
return fmt.Errorf("fetch IMDS credentials: %w", err)
}
tok, err := ecrauth.GetAuthorizationToken(cfg.Region, cfg.GatewayURL, httpClient,
creds.AccessKeyID, creds.SecretAccessKey, creds.SessionToken)
if err != nil {
return fmt.Errorf("get authorization token: %w", err)
}
auth := AuthConfig{Username: tok.Username, Password: tok.Password}
authMap := map[string]AuthConfig{imageHost: auth}
if tok.ProxyHost != "" && tok.ProxyHost != imageHost {
authMap[tok.ProxyHost] = auth
}
resp := CredentialProviderResponse{
APIVersion: credProviderAPIVersion,
Kind: "CredentialProviderResponse",
CacheKeyType: "Registry",
CacheDuration: cacheDurationFrom(tok.ExpiresAt),
Auth: authMap,
}
return json.NewEncoder(out).Encode(&resp)
}
// cacheDurationFrom derives a Go-duration string from the token expiry minus a
// safety margin, falling back to the default when expiry is unknown or too near.
func cacheDurationFrom(expiresAt time.Time) string {
if expiresAt.IsZero() {
return defaultCacheDuration
}
d := time.Until(expiresAt) - expiryMargin
if d <= 0 {
return defaultCacheDuration
}
return d.Round(time.Second).String()
}
// loadConfig reads the cloud-init env file then lets real env vars override. The
// IMDS base is fixed (not in the env file) but overridable for tests.
func loadConfig(envFile string) config {
get := guestenv.Load(envFile).Get
cfg := config{
GatewayURL: get("EKS_GATEWAY_URL"),
GatewayCA: get("EKS_GATEWAY_CA"),
Region: get("EKS_REGION"),
IMDSBase: get("EKS_IMDS_BASE"),
}
if cfg.GatewayCA == "" {
cfg.GatewayCA = defaultGatewayCA
}
if cfg.IMDSBase == "" {
cfg.IMDSBase = defaultIMDSBase
}
return cfg
}
// emitEmpty writes a valid CredentialProviderResponse with no auth entries.
func emitEmpty(out io.Writer) {
resp := CredentialProviderResponse{
APIVersion: credProviderAPIVersion,
Kind: "CredentialProviderResponse",
CacheKeyType: "Registry",
CacheDuration: defaultCacheDuration,
Auth: map[string]AuthConfig{},
}
if err := json.NewEncoder(out).Encode(&resp); err != nil {
slog.Error("ecr-credential-provider: encode empty response", "err", err)
}
os.Exit(credentialprovider.Main(os.Stdin, os.Stdout))
}
-126
View File
@@ -1,126 +0,0 @@
package main
import (
"bytes"
"crypto/tls"
"crypto/x509"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// fakeBackend serves both the IMDS role/credentials endpoints and the gateway
// ECR GetAuthorizationToken on one TLS server, routed by request path.
func fakeBackend(t *testing.T) *httptest.Server {
t.Helper()
mux := http.NewServeMux()
// IMDSv2 token. Real IMDS echoes the requested TTL back as a response
// header; the SDK client parses it from there, not the body.
mux.HandleFunc("/latest/api/token", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
w.Header().Set("X-Aws-Ec2-Metadata-Token-Ttl-Seconds", "21600")
_, _ = w.Write([]byte("imds-v2-token"))
})
// IMDS role name.
mux.HandleFunc("/latest/meta-data/iam/security-credentials/", func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/latest/meta-data/iam/security-credentials/" {
if r.Header.Get("X-Aws-Ec2-Metadata-Token") != "imds-v2-token" {
w.WriteHeader(http.StatusUnauthorized)
return
}
_, _ = w.Write([]byte("node-role"))
return
}
// Role credentials.
_ = json.NewEncoder(w).Encode(map[string]string{
"Code": "Success",
"Type": "AWS-HMAC",
"AccessKeyId": "AKIATEST",
"SecretAccessKey": "secrettest",
"Token": "sessiontoken",
"AccountId": "123456789012",
})
})
// Gateway ECR GetAuthorizationToken (X-Amz-Target dispatch).
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
if !strings.Contains(r.Header.Get("X-Amz-Target"), "GetAuthorizationToken") {
w.WriteHeader(http.StatusBadRequest)
return
}
authToken := base64.StdEncoding.EncodeToString([]byte("AWS:testjwt"))
w.Header().Set("Content-Type", "application/x-amz-json-1.1")
_ = json.NewEncoder(w).Encode(map[string]any{
"authorizationData": []map[string]any{{
"authorizationToken": authToken,
"proxyEndpoint": "https://123456789012.dkr.ecr.us-east-1.spinifex.internal",
}},
})
})
srv := httptest.NewTLSServer(mux)
t.Cleanup(srv.Close)
return srv
}
func clientTrusting(srv *httptest.Server) *http.Client {
pool := x509.NewCertPool()
pool.AddCert(srv.Certificate())
return &http.Client{Transport: &http.Transport{TLSClientConfig: &tls.Config{RootCAs: pool, MinVersion: tls.VersionTLS12}}}
}
func TestRun_ReturnsBasicAuth(t *testing.T) {
srv := fakeBackend(t)
client := clientTrusting(srv)
cfg := config{
GatewayURL: srv.URL,
Region: "us-east-1",
IMDSBase: srv.URL + "/latest",
}
const image = "123456789012.dkr.ecr.us-east-1.spinifex.internal/spinifex-demo:latest"
reqBody, err := json.Marshal(CredentialProviderRequest{
APIVersion: credProviderAPIVersion,
Kind: "CredentialProviderRequest",
Image: image,
})
if err != nil {
t.Fatalf("marshal request: %v", err)
}
var out bytes.Buffer
if err := run(bytes.NewReader(reqBody), &out, cfg, client); err != nil {
t.Fatalf("run: %v", err)
}
var resp CredentialProviderResponse
if err := json.Unmarshal(out.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal response: %v\n%s", err, out.String())
}
if resp.Kind != "CredentialProviderResponse" {
t.Errorf("Kind = %q, want CredentialProviderResponse", resp.Kind)
}
if resp.CacheKeyType != "Registry" {
t.Errorf("CacheKeyType = %q, want Registry", resp.CacheKeyType)
}
host := "123456789012.dkr.ecr.us-east-1.spinifex.internal"
auth, ok := resp.Auth[host]
if !ok {
t.Fatalf("auth map missing host %q; got %v", host, resp.Auth)
}
if auth.Username != "AWS" {
t.Errorf("username = %q, want AWS", auth.Username)
}
if auth.Password != "testjwt" {
t.Errorf("password = %q, want testjwt", auth.Password)
}
}
-198
View File
@@ -1,198 +0,0 @@
package main
import (
"bufio"
"context"
"fmt"
"log/slog"
"net/http"
"os"
goruntime "runtime"
"strconv"
"strings"
"time"
"github.com/mulgadc/spinifex/cmd/ecs-agent/credentials"
ctrruntime "github.com/mulgadc/spinifex/cmd/ecs-agent/runtime"
"github.com/mulgadc/spinifex/spinifex/handlers/ecs/bus"
)
// version is the agent build version, reported in the register message.
// Overridable via -ldflags "-X main.version=...".
var version = "dev"
// Agent wires the ecs-agent's runtime seams: a gateway control-plane client, a
// container runtime, and an ECR resolver. It registers the host as a container
// instance at boot, heartbeats (re-registers) while alive, polls the gateway for
// task assignments, and runs them through containerd, reporting state back over
// the gateway. It never connects to NATS — the bus stays host-internal.
type Agent struct {
cfg config
id identity
cp controlPlane
puller ctrruntime.ImagePuller
runner ctrruntime.Runner
resolver ctrruntime.Resolver
reg *registrar
hb *heartbeater
// netns builds awsvpc task network namespaces (nil-safe; bridge/host skip it).
netns *taskNetns
// cred serves task IAM role credentials at 169.254.170.2 (nil in unit tests).
cred *credEndpoint
// gpu is the local pinning ledger over the host's discovered GPU UUIDs.
// Always non-nil (newAgent seeds an empty one); New() reseeds it from
// nvidia-smi discovery.
gpu *gpuLedger
closers []func() error
}
// newAgent assembles an Agent from already-built seams. Tests use this directly
// with fakes; New builds the production seams and delegates here. runner may be
// nil when containerd is unavailable; the assign path then reports the task
// STOPPED rather than crashing.
func newAgent(cfg config, id identity, cp controlPlane, puller ctrruntime.ImagePuller, runner ctrruntime.Runner, resolver ctrruntime.Resolver) *Agent {
return &Agent{
cfg: cfg,
id: id,
cp: cp,
puller: puller,
runner: runner,
resolver: resolver,
reg: newRegistrar(cp, id),
hb: newHeartbeater(cp, id, cfg.Heartbeat),
netns: newTaskNetns(execNetRunner{}),
gpu: newGPULedger(id.Capacity.GPUIDs),
}
}
// New builds an Agent from config: it resolves the host identity from IMDS,
// builds the gateway control-plane client, the ECR resolver and (best-effort)
// the containerd runtime. A containerd connect failure is logged, not fatal —
// registration and heartbeat still run so the instance is visible while the
// runtime recovers. The ECR gateway client is built lazily on first image pull
// (not here), so a missing or malformed gateway CA does not stop registration.
func New(cfg config) (*Agent, error) {
imdsClient := &http.Client{Timeout: 5 * time.Second}
meta, err := fetchInstanceMetadata(imdsClient, cfg.IMDSBase)
if err != nil {
return nil, fmt.Errorf("instance metadata: %w", err)
}
host, _ := os.Hostname()
id := identity{
AccountID: meta.AccountID,
ClusterName: cfg.ClusterName,
InstanceID: meta.InstanceID,
AZ: meta.AZ,
Hostname: host,
Capacity: detectCapacity(discoverNvidiaGPUs(execCommandRunner)),
AgentVersion: version,
}
creds := credentials.NewIMDSProvider(imdsClient, cfg.IMDSBase)
resolver := newLazyECRResolver(creds, cfg.Region, cfg.GatewayURL, cfg.GatewayCA)
var puller ctrruntime.ImagePuller
var runner ctrruntime.Runner
if rt, perr := ctrruntime.New(cfg.ContainerdSocket); perr != nil {
slog.Warn("ecs-agent: containerd unavailable at boot, image pulls disabled", "err", perr)
} else {
puller = rt
runner = rt
}
cp, err := newGatewayControlPlane(cfg, creds)
if err != nil {
return nil, fmt.Errorf("build gateway control-plane: %w", err)
}
a := newAgent(cfg, id, cp, puller, runner, resolver)
a.cred = newCredEndpoint(creds, cfg.Region, cfg.GatewayURL, cfg.GatewayCA,
cfg.CredEndpointIP, cfg.CredEndpointPort, execNetRunner{})
if puller != nil {
a.closers = append(a.closers, puller.Close)
}
return a, nil
}
// Run registers the instance, starts the heartbeat and assignment-poll loops,
// and blocks until ctx is cancelled, then tears down.
func (a *Agent) Run(ctx context.Context) error {
if err := a.reg.Register(); err != nil {
slog.Error("ecs-agent: initial registration failed", "err", err)
} else {
slog.Info("ecs-agent: registered", "cluster", a.id.ClusterName, "instance", a.id.InstanceID)
}
if a.cred != nil {
if err := a.cred.Start(); err != nil {
slog.Error("ecs-agent: credential endpoint start failed", "err", err)
}
}
go a.hb.Run(ctx)
// Re-adopt containers still running from before this restart, then poll with
// their tasks pre-seeded so re-delivered assignments are acked, not re-run.
go a.pollAssignments(ctx, a.reconcile(ctx))
<-ctx.Done()
return a.Stop()
}
// Stop closes the runtime. Safe to call once.
func (a *Agent) Stop() error {
var firstErr error
if a.cred != nil {
_ = a.cred.Stop()
}
for _, c := range a.closers {
if err := c(); err != nil && firstErr == nil {
firstErr = err
}
}
return firstErr
}
// detectCapacity reports the host's total CPU units (1 vCPU = 1024), memory,
// and GPU count/UUIDs from the caller's nvidia-smi discovery (nil on a
// non-GPU host).
func detectCapacity(gpuUUIDs []string) bus.InstanceCapacity {
return bus.InstanceCapacity{
CPU: goruntime.NumCPU() * 1024,
MemoryMiB: memTotalMiB(),
GPU: len(gpuUUIDs),
GPUIDs: gpuUUIDs,
}
}
// memTotalMiB reads MemTotal from /proc/meminfo, returning 0 if unavailable.
func memTotalMiB() int {
f, err := os.Open("/proc/meminfo")
if err != nil {
return 0
}
defer f.Close()
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := scanner.Text()
if !strings.HasPrefix(line, "MemTotal:") {
continue
}
fields := strings.Fields(line)
if len(fields) < 2 {
return 0
}
kb, err := strconv.Atoi(fields[1])
if err != nil {
return 0
}
return kb / 1024
}
return 0
}
-66
View File
@@ -1,66 +0,0 @@
package main
import (
"context"
"testing"
"time"
ctrruntime "github.com/mulgadc/spinifex/cmd/ecs-agent/runtime"
)
func TestAgent_RunRegistersThenStopsOnContext(t *testing.T) {
cp := &fakeCP{}
cfg := config{Heartbeat: 5 * time.Millisecond, PollInterval: 5 * time.Millisecond}
puller := &ctrruntime.FakePuller{}
a := newAgent(cfg, testIdentity(), cp, puller, puller, nil)
a.closers = append(a.closers, puller.Close)
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() { done <- a.Run(ctx) }()
// Give it time to register + re-register (heartbeat) a few times.
time.Sleep(30 * time.Millisecond)
cancel()
select {
case err := <-done:
if err != nil {
t.Fatalf("Run returned error: %v", err)
}
case <-time.After(time.Second):
t.Fatal("Run did not return after cancel")
}
// One boot register + at least one heartbeat re-register.
if cp.registerCount() < 2 {
t.Errorf("registers = %d, want >= 2 (boot + heartbeat)", cp.registerCount())
}
if !puller.Closed {
t.Error("Stop did not close the puller")
}
}
func TestDetectCapacity_Positive(t *testing.T) {
c := detectCapacity(nil)
if c.CPU <= 0 {
t.Errorf("CPU = %d, want > 0", c.CPU)
}
// MemoryMiB may be 0 on platforms without /proc/meminfo; just assert non-negative.
if c.MemoryMiB < 0 {
t.Errorf("MemoryMiB = %d, want >= 0", c.MemoryMiB)
}
if c.GPU != 0 || c.GPUIDs != nil {
t.Errorf("no GPU UUIDs given: want GPU=0/GPUIDs=nil, got %d/%v", c.GPU, c.GPUIDs)
}
}
func TestDetectCapacity_WithGPUUUIDs(t *testing.T) {
c := detectCapacity([]string{"GPU-aaa", "GPU-bbb"})
if c.GPU != 2 {
t.Errorf("GPU = %d, want 2", c.GPU)
}
if len(c.GPUIDs) != 2 {
t.Errorf("GPUIDs = %v, want 2 entries", c.GPUIDs)
}
}
-89
View File
@@ -1,89 +0,0 @@
package main
import (
"strconv"
"time"
"github.com/mulgadc/spinifex/internal/guestenv"
)
const (
defaultEnvFile = "/etc/spinifex-ecs/agent.env"
defaultGatewayCA = "/etc/spinifex-ecs/gateway-ca.pem"
defaultIMDSBase = "http://169.254.169.254/latest"
defaultContainerdSocket = "/run/containerd/containerd.sock"
defaultHeartbeat = 30 * time.Second
defaultPollInterval = 5 * time.Second
)
// config holds the static settings the agent reads at boot. Cluster identity
// (account ID, instance ID) is discovered at runtime from IMDS, not configured;
// only the cluster *name* the instance was launched into is static. AccessKey /
// SecretKey are legacy seeded creds, optional now that the agent SigV4-signs
// gateway calls with instance-role credentials from IMDS; the agent never holds
// a NATS token.
type config struct {
GatewayURL string
GatewayCA string
Region string
ClusterName string
AccessKey string
SecretKey string
IMDSBase string
ContainerdSocket string
Heartbeat time.Duration
PollInterval time.Duration
CredEndpointIP string
CredEndpointPort int
}
// loadConfig reads the cloud-init env file then lets real env vars override.
func loadConfig(envFile string) config {
get := guestenv.Load(envFile).Get
cfg := config{
GatewayURL: get("ECS_GATEWAY_URL"),
GatewayCA: get("ECS_GATEWAY_CA"),
Region: get("ECS_REGION"),
ClusterName: get("ECS_CLUSTER"),
AccessKey: get("ECS_ACCESS_KEY"),
SecretKey: get("ECS_SECRET_KEY"),
IMDSBase: get("ECS_IMDS_BASE"),
ContainerdSocket: get("ECS_CONTAINERD_SOCKET"),
}
if cfg.GatewayCA == "" {
cfg.GatewayCA = defaultGatewayCA
}
if cfg.IMDSBase == "" {
cfg.IMDSBase = defaultIMDSBase
}
if cfg.ContainerdSocket == "" {
cfg.ContainerdSocket = defaultContainerdSocket
}
if cfg.ClusterName == "" {
cfg.ClusterName = "default"
}
cfg.Heartbeat = defaultHeartbeat
if v := get("ECS_HEARTBEAT_INTERVAL"); v != "" {
if d, err := time.ParseDuration(v); err == nil && d > 0 {
cfg.Heartbeat = d
}
}
cfg.PollInterval = defaultPollInterval
if v := get("ECS_POLL_INTERVAL"); v != "" {
if d, err := time.ParseDuration(v); err == nil && d > 0 {
cfg.PollInterval = d
}
}
cfg.CredEndpointIP = get("ECS_CRED_ENDPOINT_IP")
if cfg.CredEndpointIP == "" {
cfg.CredEndpointIP = defaultCredEndpointIP
}
cfg.CredEndpointPort = defaultCredEndpointPort
if v := get("ECS_CRED_ENDPOINT_PORT"); v != "" {
if p, err := strconv.Atoi(v); err == nil && p > 0 {
cfg.CredEndpointPort = p
}
}
return cfg
}
-73
View File
@@ -1,73 +0,0 @@
package main
import (
"os"
"path/filepath"
"testing"
"time"
)
func TestLoadConfig_Defaults(t *testing.T) {
// Ensure env vars don't leak in from the host.
for _, k := range []string{"ECS_GATEWAY_URL", "ECS_GATEWAY_CA", "ECS_REGION",
"ECS_CLUSTER", "ECS_ACCESS_KEY", "ECS_SECRET_KEY", "ECS_IMDS_BASE",
"ECS_CONTAINERD_SOCKET", "ECS_HEARTBEAT_INTERVAL", "ECS_POLL_INTERVAL"} {
t.Setenv(k, "")
}
cfg := loadConfig(filepath.Join(t.TempDir(), "absent.env"))
if cfg.GatewayCA != defaultGatewayCA {
t.Errorf("GatewayCA = %q, want default", cfg.GatewayCA)
}
if cfg.IMDSBase != defaultIMDSBase {
t.Errorf("IMDSBase = %q, want default", cfg.IMDSBase)
}
if cfg.ContainerdSocket != defaultContainerdSocket {
t.Errorf("ContainerdSocket = %q, want default", cfg.ContainerdSocket)
}
if cfg.ClusterName != "default" {
t.Errorf("ClusterName = %q, want default", cfg.ClusterName)
}
if cfg.Heartbeat != defaultHeartbeat {
t.Errorf("Heartbeat = %v, want %v", cfg.Heartbeat, defaultHeartbeat)
}
if cfg.PollInterval != defaultPollInterval {
t.Errorf("PollInterval = %v, want %v", cfg.PollInterval, defaultPollInterval)
}
}
func TestLoadConfig_FileThenEnvOverride(t *testing.T) {
dir := t.TempDir()
envFile := filepath.Join(dir, "agent.env")
body := "# comment\nECS_GATEWAY_URL=https://gw.file\nECS_REGION=us-west-2\nECS_CLUSTER=prod\n"
if err := os.WriteFile(envFile, []byte(body), 0o600); err != nil {
t.Fatal(err)
}
t.Setenv("ECS_GATEWAY_URL", "https://gw.env")
t.Setenv("ECS_ACCESS_KEY", "AKIAENV")
cfg := loadConfig(envFile)
if cfg.GatewayURL != "https://gw.env" {
t.Errorf("GatewayURL = %q, want env override", cfg.GatewayURL)
}
if cfg.Region != "us-west-2" {
t.Errorf("Region = %q, want from file", cfg.Region)
}
if cfg.ClusterName != "prod" {
t.Errorf("ClusterName = %q, want from file", cfg.ClusterName)
}
if cfg.AccessKey != "AKIAENV" {
t.Errorf("AccessKey = %q, want env", cfg.AccessKey)
}
}
func TestLoadConfig_HeartbeatOverride(t *testing.T) {
t.Setenv("ECS_HEARTBEAT_INTERVAL", "5s")
if cfg := loadConfig(filepath.Join(t.TempDir(), "absent.env")); cfg.Heartbeat != 5*time.Second {
t.Errorf("Heartbeat = %v, want 5s", cfg.Heartbeat)
}
// Garbage value falls back to the default, not zero.
t.Setenv("ECS_HEARTBEAT_INTERVAL", "not-a-duration")
if cfg := loadConfig(filepath.Join(t.TempDir(), "absent.env")); cfg.Heartbeat != defaultHeartbeat {
t.Errorf("Heartbeat = %v, want default on bad value", cfg.Heartbeat)
}
}
-46
View File
@@ -1,46 +0,0 @@
package main
import (
"context"
"log/slog"
"time"
)
// heartbeater keeps the container instance alive by periodically re-registering
// it through the gateway. Real ECS folds liveness into ACS; v1 folds it into an
// idempotent RegisterContainerInstance, which refreshes the instance's LastSeen
// so the scheduler can tell live instances from dead ones (no bus.Heartbeat).
type heartbeater struct {
cp controlPlane
id identity
interval time.Duration
}
func newHeartbeater(cp controlPlane, id identity, interval time.Duration) *heartbeater {
if interval <= 0 {
interval = defaultHeartbeat
}
return &heartbeater{cp: cp, id: id, interval: interval}
}
// beat re-registers the instance once, refreshing its LastSeen.
func (h *heartbeater) beat() error {
return h.cp.Register(h.id)
}
// Run beats every interval until ctx is cancelled. A failed beat is logged and
// retried on the next tick rather than killing the loop.
func (h *heartbeater) Run(ctx context.Context) {
ticker := time.NewTicker(h.interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
if err := h.beat(); err != nil {
slog.Warn("ecs-agent: heartbeat (re-register) failed", "err", err)
}
}
}
}
+13 -13
View File
@@ -1,13 +1,6 @@
// ecs-agent runs inside a Spinifex ECS container instance (a guest VM booted from
// the ECS-AMI, with containerd baked in). It registers the host with the ECS
// scheduler through the AWS gateway over TLS+SigV4 (never NATS), heartbeats by
// re-registering, polls the gateway for task assignments, runs them through
// containerd, and reports state back over the gateway.
//
// Static config (gateway URL, CA, region, cluster, seeded IAM creds, containerd
// socket) is read from the cloud-init env file /etc/spinifex-ecs/agent.env
// (KEY=value); real env vars override it. Host identity (account, instance, AZ)
// comes from IMDS at boot.
// Command ecs-agent runs inside a Spinifex ECS container instance; the
// implementation is in agents/ecs/agent. This binary owns process lifecycle:
// signal handling, startup and run failure logging, and the exit code.
package main
import (
@@ -18,12 +11,19 @@ import (
"syscall"
_ "github.com/mulgadc/bluebottle/pkg/fipsboot"
ecsagent "github.com/mulgadc/spinifex/spinifex/agents/ecs/agent"
)
func main() {
cfg := loadConfig(defaultEnvFile)
// version is the agent build version, reported in the register message.
// Overridable via -ldflags "-X main.version=...".
var version = "dev"
agent, err := New(cfg)
func main() {
cfg := ecsagent.LoadConfig(ecsagent.DefaultEnvFile)
cfg.AgentVersion = version
agent, err := ecsagent.New(cfg)
if err != nil {
slog.Error("ecs-agent: startup failed", "err", err)
os.Exit(1)
-61
View File
@@ -1,61 +0,0 @@
package main
import (
"context"
"fmt"
"io"
"net/http"
"strings"
"github.com/aws/aws-sdk-go-v2/feature/ec2/imds"
"github.com/mulgadc/spinifex/internal/imdscreds"
)
// instanceMetadata is the IMDS-derived identity of the host the agent runs on.
type instanceMetadata struct {
AccountID string
InstanceID string
AZ string
}
// fetchInstanceMetadata reads the instance-id, AZ and account-id from IMDSv2
// via the SDK IMDS client. base is the IMDS root, e.g.
// "http://169.254.169.254/latest".
func fetchInstanceMetadata(client *http.Client, base string) (instanceMetadata, error) {
imdsClient := imdscreds.NewClient(client, base)
ctx := context.Background()
instanceID, err := getMetadata(ctx, imdsClient, "instance-id")
if err != nil {
return instanceMetadata{}, fmt.Errorf("instance-id: %w", err)
}
az, err := getMetadata(ctx, imdsClient, "placement/availability-zone")
if err != nil {
return instanceMetadata{}, fmt.Errorf("availability-zone: %w", err)
}
doc, err := imdsClient.GetInstanceIdentityDocument(ctx, &imds.GetInstanceIdentityDocumentInput{})
if err != nil {
return instanceMetadata{}, fmt.Errorf("identity document: %w", err)
}
return instanceMetadata{
AccountID: doc.AccountID,
InstanceID: strings.TrimSpace(instanceID),
AZ: strings.TrimSpace(az),
}, nil
}
// getMetadata reads a single meta-data path and returns its body as a string.
func getMetadata(ctx context.Context, imdsClient *imds.Client, path string) (string, error) {
out, err := imdsClient.GetMetadata(ctx, &imds.GetMetadataInput{Path: path})
if err != nil {
return "", err
}
defer out.Content.Close()
body, err := io.ReadAll(out.Content)
if err != nil {
return "", err
}
return string(body), nil
}
-82
View File
@@ -1,82 +0,0 @@
package main
import (
"net/http"
"net/http/httptest"
"testing"
)
func metadataStub(t *testing.T, enforceV2 bool) *httptest.Server {
t.Helper()
mux := http.NewServeMux()
mux.HandleFunc("/latest/api/token", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPut {
w.WriteHeader(http.StatusMethodNotAllowed)
return
}
// Real IMDS echoes the requested TTL back as a response header; the SDK
// client parses it from there, not the body.
w.Header().Set("X-Aws-Ec2-Metadata-Token-Ttl-Seconds", "21600")
_, _ = w.Write([]byte("v2-token"))
})
guard := func(w http.ResponseWriter, r *http.Request) bool {
if enforceV2 && r.Header.Get("X-Aws-Ec2-Metadata-Token") != "v2-token" {
w.WriteHeader(http.StatusUnauthorized)
return false
}
return true
}
mux.HandleFunc("/latest/meta-data/instance-id", func(w http.ResponseWriter, r *http.Request) {
if guard(w, r) {
_, _ = w.Write([]byte("i-0abc123"))
}
})
mux.HandleFunc("/latest/meta-data/placement/availability-zone", func(w http.ResponseWriter, r *http.Request) {
if guard(w, r) {
_, _ = w.Write([]byte("us-east-1a"))
}
})
mux.HandleFunc("/latest/dynamic/instance-identity/document", func(w http.ResponseWriter, r *http.Request) {
if guard(w, r) {
_, _ = w.Write([]byte(`{"accountId":"123456789012","region":"us-east-1"}`))
}
})
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
return srv
}
func TestFetchInstanceMetadata_OK(t *testing.T) {
srv := metadataStub(t, true)
got, err := fetchInstanceMetadata(srv.Client(), srv.URL+"/latest")
if err != nil {
t.Fatalf("fetch: %v", err)
}
if got.InstanceID != "i-0abc123" || got.AZ != "us-east-1a" || got.AccountID != "123456789012" {
t.Errorf("metadata mismatch: %+v", got)
}
}
func TestFetchInstanceMetadata_TokenRequired(t *testing.T) {
// Server returns 405 for non-PUT token; a GET-only client would 401 downstream.
srv := metadataStub(t, true)
if _, err := fetchInstanceMetadata(srv.Client(), srv.URL+"/latest"); err != nil {
t.Fatalf("v2 path should succeed: %v", err)
}
}
func TestFetchInstanceMetadata_BadDocument(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/latest/api/token", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Aws-Ec2-Metadata-Token-Ttl-Seconds", "21600")
_, _ = w.Write([]byte("t"))
})
mux.HandleFunc("/latest/meta-data/instance-id", func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("i-1")) })
mux.HandleFunc("/latest/meta-data/placement/availability-zone", func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("z")) })
mux.HandleFunc("/latest/dynamic/instance-identity/document", func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("not json")) })
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
if _, err := fetchInstanceMetadata(srv.Client(), srv.URL+"/latest"); err == nil {
t.Fatal("expected parse error on bad identity document")
}
}
-110
View File
@@ -1,110 +0,0 @@
package main
import (
"context"
"log/slog"
ctrruntime "github.com/mulgadc/spinifex/cmd/ecs-agent/runtime"
"github.com/mulgadc/spinifex/spinifex/handlers/ecs/bus"
)
// reconcile re-syncs the gateway with what this instance is actually running
// after an agent restart. It lists the runtime's containers and, for each task
// in this cluster, either re-adopts it (still running: re-register IAM
// credentials, re-attach the exit-wait, refresh RUNNING) or reports it STOPPED
// and clears its leftovers. The returned set seeds pollAssignments so the
// gateway's re-delivered assignments for these tasks are acked but not re-run.
//
// Reporting the dead ones is the load-bearing half. The exit-wait is cancelled
// when the agent shuts down, so a container that dies while the agent is down
// has nobody to report its exit; the gateway holds the task RUNNING forever,
// its service never replaces it, and the target never recovers.
//
// It is best-effort: a nil runner or a List error returns an empty set and the
// agent falls back to its pre-reconcile behaviour (poll + run).
func (a *Agent) reconcile(ctx context.Context) map[string]bool {
adopted := map[string]bool{}
if a.runner == nil {
return adopted
}
containers, err := a.runner.List(ctx)
if err != nil {
slog.Warn("ecs-agent: reconcile list failed", "err", err)
return adopted
}
// Group this cluster's containers back into their tasks, running or not. The
// Assign is reconstructed from labels — enough to re-register creds and tear
// down netns.
type task struct {
as *bus.Assign
running []ctrruntime.Container
dead []ctrruntime.Container
}
tasks := map[string]*task{}
for _, c := range containers {
if c.Labels[labelClusterName] != a.id.ClusterName {
continue
}
taskID := c.Labels[labelTaskID]
if taskID == "" {
continue
}
t := tasks[taskID]
if t == nil {
t = &task{as: &bus.Assign{
TaskID: taskID,
ClusterName: a.id.ClusterName,
CredID: c.Labels[labelCredID],
TaskRoleARN: c.Labels[labelTaskRoleARN],
ENIMacAddress: c.Labels[labelENIMac],
}}
tasks[taskID] = t
}
if c.Running {
t.running = append(t.running, c)
} else {
t.dead = append(t.dead, c)
}
}
for taskID, t := range tasks {
// A task with nothing running is over, whatever the gateway still believes.
// Mixed running/dead is left to the running path: the exit-wait re-attached
// below reports the individual container when it is reaped.
if len(t.running) == 0 {
a.reportStoppedTask(ctx, t.as, t.dead)
adopted[taskID] = true
continue
}
a.registerTaskCreds(t.as)
statuses := make([]bus.ContainerStatus, 0, len(t.running))
for _, c := range t.running {
name := c.Labels[labelContainerName]
go a.waitContainer(ctx, t.as, name, c.ID)
statuses = append(statuses, bus.ContainerStatus{Name: name, Status: bus.TaskStatusRunning, ContainerID: c.ID})
}
a.reportTaskState(t.as, bus.TaskStatusRunning, "", statuses)
adopted[taskID] = true
slog.Info("ecs-agent: re-adopted running task", "task", taskID, "containers", len(t.running))
}
return adopted
}
// reportStoppedTask clears a task's leftover containers and tells the gateway it
// is STOPPED, so the scheduler stops counting a task this instance is not
// running and its service can place a replacement.
func (a *Agent) reportStoppedTask(ctx context.Context, as *bus.Assign, dead []ctrruntime.Container) {
statuses := make([]bus.ContainerStatus, 0, len(dead))
for _, c := range dead {
if err := a.runner.Remove(ctx, c.ID); err != nil {
slog.Warn("ecs-agent: reconcile remove failed", "task", as.TaskID, "container", c.ID, "err", err)
}
statuses = append(statuses, bus.ContainerStatus{
Name: c.Labels[labelContainerName], Status: bus.TaskStatusStopped, ContainerID: c.ID,
})
}
a.reportTaskState(as, bus.TaskStatusStopped, "Container exited while the agent was not running", statuses)
slog.Info("ecs-agent: reported stopped task", "task", as.TaskID, "containers", len(dead))
}
-250
View File
@@ -1,250 +0,0 @@
package main
import (
"context"
"errors"
"slices"
"testing"
"time"
ctrruntime "github.com/mulgadc/spinifex/cmd/ecs-agent/runtime"
"github.com/mulgadc/spinifex/spinifex/handlers/ecs/bus"
)
const testTaskRole = "arn:aws:iam::111122223333:role/task"
// adoptedContainer builds a runtime.Container as the reconciler would discover it,
// stamping only the mulga.ecs.* labels that are non-empty.
func adoptedContainer(taskID, name, cluster, credID, role, mac string, running bool) ctrruntime.Container {
labels := map[string]string{
labelTaskID: taskID,
labelContainerName: name,
labelClusterName: cluster,
}
if credID != "" {
labels[labelCredID] = credID
}
if role != "" {
labels[labelTaskRoleARN] = role
}
if mac != "" {
labels[labelENIMac] = mac
}
return ctrruntime.Container{ID: containerID(taskID, name), Labels: labels, Running: running}
}
func waitedFor(t *testing.T, rt *ctrruntime.FakePuller, id string) {
t.Helper()
deadline := time.After(time.Second)
for !slices.Contains(rt.Waits(), id) {
select {
case <-deadline:
t.Fatalf("exit-wait never re-attached for %s; waited=%v", id, rt.Waits())
case <-time.After(2 * time.Millisecond):
}
}
}
// A running, correctly-labeled container is re-adopted: its credentials are
// re-registered, its RUNNING state refreshed, and its exit-wait re-attached.
func TestReconcile_AdoptsRunningLabeledContainer(t *testing.T) {
cp := &fakeCP{}
rt := &ctrruntime.FakePuller{
WaitErr: errors.New("blocked"),
Containers: []ctrruntime.Container{
adoptedContainer("t-001", "web", "default", "t-001", testTaskRole, "52:54:00:de:ad:01", true),
},
}
a := newAgent(config{}, testIdentity(), cp, rt, rt, nil)
a.cred = newCredEndpoint(nil, "us-east-1", "https://gw", "", "127.0.0.1", 0, nil)
adopted := a.reconcile(context.Background())
if !adopted["t-001"] {
t.Fatalf("t-001 not adopted; got %v", adopted)
}
if got := a.cred.roles["t-001"]; got != testTaskRole {
t.Errorf("cred not re-registered: roles[t-001] = %q, want %q", got, testTaskRole)
}
if n := countStatus(cp.taskStates(), "t-001", bus.TaskStatusRunning); n != 1 {
t.Errorf("RUNNING reports for t-001 = %d, want 1", n)
}
waitedFor(t, rt, containerID("t-001", "web"))
}
// A task whose container died while the agent was down must be reported STOPPED,
// not silently skipped. The exit-wait is cancelled on agent shutdown, so nothing
// else ever reports that exit: skipping it leaves the gateway holding the task
// RUNNING forever, its service never places a replacement, and the load
// balancer target stays unhealthy with no path back.
func TestReconcile_ReportsTaskWhoseContainerDied(t *testing.T) {
cp := &fakeCP{}
rt := &ctrruntime.FakePuller{
Containers: []ctrruntime.Container{
adoptedContainer("t-dead", "web", "default", "t-dead", testTaskRole, "52:54:00:de:ad:02", false),
},
}
a := newAgent(config{}, testIdentity(), cp, rt, rt, nil)
a.cred = newCredEndpoint(nil, "us-east-1", "https://gw", "", "127.0.0.1", 0, nil)
adopted := a.reconcile(context.Background())
if n := countStatus(cp.taskStates(), "t-dead", bus.TaskStatusStopped); n != 1 {
t.Errorf("STOPPED reports for t-dead = %d, want 1", n)
}
if n := countStatus(cp.taskStates(), "t-dead", bus.TaskStatusRunning); n != 0 {
t.Errorf("t-dead must not be reported RUNNING, got %d such reports", n)
}
// Seeded so a re-delivered assignment for the dead task is acked, not re-run.
if !adopted["t-dead"] {
t.Errorf("t-dead not seeded into the adopted set; got %v", adopted)
}
if want := containerID("t-dead", "web"); !slices.Contains(rt.Removed, want) {
t.Errorf("leftover container %s not removed; removed=%v", want, rt.Removed)
}
}
// A task with one live and one dead container is still running: the live half is
// re-adopted and the re-attached exit-wait reports the dead one when it is
// reaped, so reconcile must not declare the whole task STOPPED.
func TestReconcile_PartiallyDeadTaskStaysRunning(t *testing.T) {
cp := &fakeCP{}
rt := &ctrruntime.FakePuller{
WaitErr: errors.New("blocked"),
Containers: []ctrruntime.Container{
adoptedContainer("t-mixed", "web", "default", "t-mixed", testTaskRole, "52:54:00:de:ad:03", true),
adoptedContainer("t-mixed", "sidecar", "default", "t-mixed", testTaskRole, "52:54:00:de:ad:03", false),
},
}
a := newAgent(config{}, testIdentity(), cp, rt, rt, nil)
a.cred = newCredEndpoint(nil, "us-east-1", "https://gw", "", "127.0.0.1", 0, nil)
a.reconcile(context.Background())
if n := countStatus(cp.taskStates(), "t-mixed", bus.TaskStatusRunning); n != 1 {
t.Errorf("RUNNING reports for t-mixed = %d, want 1", n)
}
if n := countStatus(cp.taskStates(), "t-mixed", bus.TaskStatusStopped); n != 0 {
t.Errorf("t-mixed must not be reported STOPPED while a container still runs, got %d", n)
}
}
// pollAssignments seeded with an adopted task acks its re-delivered assignment
// but does not re-run it, while a genuinely new task still dispatches.
func TestPollAssignments_SeededTaskNotRerun(t *testing.T) {
mk := func(id string) bus.Assign {
return bus.Assign{
AccountID: "123456789012", ClusterName: "default", InstanceID: "i-abc123", TaskID: id,
Containers: []bus.AssignContainer{{Name: "web", Image: "registry/web:1", Command: []string{"/bin/true"}}},
}
}
adopted, fresh := mk("t-001"), mk("t-002")
cp := &fakeCP{pollReplies: [][]bus.Assign{
{adopted, fresh},
{adopted, fresh},
nil,
}}
rt := &ctrruntime.FakePuller{WaitErr: errors.New("blocked")}
a := newAgent(config{PollInterval: 5 * time.Millisecond}, testIdentity(), cp, rt, rt, nil)
ctx, cancel := context.WithCancel(context.Background())
go a.pollAssignments(ctx, map[string]bool{"t-001": true})
deadline := time.After(time.Second)
for countStatus(cp.taskStates(), "t-002", bus.TaskStatusRunning) < 1 {
select {
case <-deadline:
t.Fatal("fresh task t-002 was never dispatched")
case <-time.After(2 * time.Millisecond):
}
}
time.Sleep(20 * time.Millisecond)
cancel()
if n := countStatus(cp.taskStates(), "t-001", bus.TaskStatusRunning); n != 0 {
t.Errorf("adopted task t-001 was re-run %d times, want 0", n)
}
if n := countStatus(cp.taskStates(), "t-002", bus.TaskStatusRunning); n != 1 {
t.Errorf("fresh task t-002 dispatched %d times, want 1", n)
}
var t1Acked bool
for _, ack := range cp.acks() {
for _, id := range ack {
if id == "t-001" {
t1Acked = true
}
}
}
if !t1Acked {
t.Errorf("adopted task t-001 was never acked; acks=%v", cp.acks())
}
}
// Containers belonging to another cluster are ignored entirely: this agent
// speaks for its own cluster only, and must neither adopt nor report them.
//
// A stopped container of this cluster IS handled — see
// TestReconcile_ReportsTaskWhoseContainerDied. It lands in the adopted set so
// its re-delivered assignment is acked rather than re-run, after reconcile has
// reported the task STOPPED.
func TestReconcile_IgnoresOtherClusterContainers(t *testing.T) {
cp := &fakeCP{}
rt := &ctrruntime.FakePuller{
WaitErr: errors.New("blocked"),
Containers: []ctrruntime.Container{
adoptedContainer("t-other", "web", "other", "", "", "", true),
adoptedContainer("t-other-dead", "web", "other", "", "", "", false),
adoptedContainer("t-ok", "web", "default", "", "", "", true),
},
}
a := newAgent(config{}, testIdentity(), cp, rt, rt, nil)
adopted := a.reconcile(context.Background())
if len(adopted) != 1 || !adopted["t-ok"] {
t.Fatalf("adopted = %v, want only t-ok", adopted)
}
for _, id := range []string{"t-other", "t-other-dead"} {
if n := countStatus(cp.taskStates(), id, bus.TaskStatusStopped); n != 0 {
t.Errorf("reported %d STOPPED states for another cluster's task %s, want 0", n, id)
}
}
}
// A nil runner or a List error degrades to an empty set without panicking.
func TestReconcile_DegradesGracefully(t *testing.T) {
noRunner := newAgent(config{}, testIdentity(), &fakeCP{}, nil, nil, nil)
if got := noRunner.reconcile(context.Background()); len(got) != 0 {
t.Errorf("nil runner: adopted = %v, want empty", got)
}
rt := &ctrruntime.FakePuller{ListErr: errors.New("boom")}
listErr := newAgent(config{}, testIdentity(), &fakeCP{}, rt, rt, nil)
if got := listErr.reconcile(context.Background()); len(got) != 0 {
t.Errorf("list error: adopted = %v, want empty", got)
}
}
// taskLabels emits the optional cred/role/MAC labels only when set.
func TestTaskLabels_OmitsEmptyOptionalLabels(t *testing.T) {
full := taskLabels(&bus.Assign{
TaskID: "t1", ClusterName: "c", CredID: "x", TaskRoleARN: testTaskRole, ENIMacAddress: "mac",
}, "web")
for k, want := range map[string]string{
labelCredID: "x", labelTaskRoleARN: testTaskRole, labelENIMac: "mac",
} {
if full[k] != want {
t.Errorf("full labels[%q] = %q, want %q", k, full[k], want)
}
}
bare := taskLabels(&bus.Assign{TaskID: "t1", ClusterName: "c"}, "web")
if len(bare) != 3 {
t.Errorf("bare labels = %v, want only taskID/containerName/clusterName", bare)
}
for _, k := range []string{labelCredID, labelTaskRoleARN, labelENIMac} {
if _, ok := bare[k]; ok {
t.Errorf("bare labels unexpectedly carry %q", k)
}
}
}
-39
View File
@@ -1,39 +0,0 @@
package main
import (
"fmt"
"github.com/mulgadc/spinifex/spinifex/handlers/ecs/bus"
)
// identity is the container instance's stable identity, assembled at boot from
// IMDS (account, instance, AZ) and config (cluster). It is the join key the
// scheduler uses to track this instance.
type identity struct {
AccountID string
ClusterName string
InstanceID string
AZ string
Hostname string
Capacity bus.InstanceCapacity
AgentVersion string
}
// registrar registers the host as a container instance through the gateway.
type registrar struct {
cp controlPlane
id identity
}
func newRegistrar(cp controlPlane, id identity) *registrar {
return &registrar{cp: cp, id: id}
}
// Register calls the gateway's RegisterContainerInstance. The scheduler records
// (or refreshes) the container instance on receipt.
func (r *registrar) Register() error {
if err := r.cp.Register(r.id); err != nil {
return fmt.Errorf("register instance %s: %w", r.id.InstanceID, err)
}
return nil
}
-195
View File
@@ -1,195 +0,0 @@
package main
import (
"context"
"errors"
"sync"
"testing"
"time"
handlers_ecs "github.com/mulgadc/spinifex/spinifex/handlers/ecs"
"github.com/mulgadc/spinifex/spinifex/handlers/ecs/bus"
)
// fakeCP is a test double for the gateway control-plane. It records registers,
// task-state reports and GPU reports, and replays a scripted queue of poll
// responses.
type fakeCP struct {
mu sync.Mutex
registers int
states []bus.TaskState
gpuReports []gpuReport
pollAcks [][]string
pollStopAcks [][]string
pollReplies [][]bus.Assign
stopReplies [][]bus.StopDirective
pollCalls int
registerErr bool
submitErr bool
}
// gpuReport is one recorded ReportTaskGPU call.
type gpuReport struct {
cluster string
task string
containers []handlers_ecs.ContainerGPUReport
}
var _ controlPlane = (*fakeCP)(nil)
func (f *fakeCP) Register(_ identity) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.registerErr {
return errors.New("register boom")
}
f.registers++
return nil
}
func (f *fakeCP) SubmitTaskState(st bus.TaskState) error {
f.mu.Lock()
defer f.mu.Unlock()
if f.submitErr {
return errors.New("submit boom")
}
f.states = append(f.states, st)
return nil
}
func (f *fakeCP) PollAssignments(_, _ string, ackAssigns, ackStops []string) ([]bus.Assign, []bus.StopDirective, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.pollAcks = append(f.pollAcks, append([]string(nil), ackAssigns...))
f.pollStopAcks = append(f.pollStopAcks, append([]string(nil), ackStops...))
var out []bus.Assign
if f.pollCalls < len(f.pollReplies) {
out = f.pollReplies[f.pollCalls]
}
var stops []bus.StopDirective
if f.pollCalls < len(f.stopReplies) {
stops = f.stopReplies[f.pollCalls]
}
f.pollCalls++
return out, stops, nil
}
func (f *fakeCP) ReportTaskGPU(cluster, task string, containers []handlers_ecs.ContainerGPUReport) error {
f.mu.Lock()
defer f.mu.Unlock()
f.gpuReports = append(f.gpuReports, gpuReport{cluster: cluster, task: task, containers: containers})
return nil
}
func (f *fakeCP) gpuReportsFor(taskID string) []gpuReport {
f.mu.Lock()
defer f.mu.Unlock()
var out []gpuReport
for _, r := range f.gpuReports {
if r.task == taskID {
out = append(out, r)
}
}
return out
}
func (f *fakeCP) registerCount() int {
f.mu.Lock()
defer f.mu.Unlock()
return f.registers
}
func (f *fakeCP) taskStates() []bus.TaskState {
f.mu.Lock()
defer f.mu.Unlock()
return append([]bus.TaskState(nil), f.states...)
}
func (f *fakeCP) acks() [][]string {
f.mu.Lock()
defer f.mu.Unlock()
return append([][]string(nil), f.pollAcks...)
}
func (f *fakeCP) stopAcks() [][]string {
f.mu.Lock()
defer f.mu.Unlock()
return append([][]string(nil), f.pollStopAcks...)
}
func testIdentity() identity {
return identity{
AccountID: "123456789012",
ClusterName: "default",
InstanceID: "i-abc123",
AZ: "us-east-1a",
Hostname: "ecs-host",
Capacity: bus.InstanceCapacity{CPU: 2048, MemoryMiB: 4096},
AgentVersion: "test",
}
}
// testIdentityWithGPUs is testIdentity seeded with discovered GPU UUIDs, so
// newAgent's ledger has real devices to pin from.
func testIdentityWithGPUs(uuids ...string) identity {
id := testIdentity()
id.Capacity.GPU = len(uuids)
id.Capacity.GPUIDs = uuids
return id
}
func TestRegistrar_Register(t *testing.T) {
cp := &fakeCP{}
if err := newRegistrar(cp, testIdentity()).Register(); err != nil {
t.Fatalf("Register: %v", err)
}
if cp.registerCount() != 1 {
t.Errorf("registers = %d, want 1", cp.registerCount())
}
}
func TestRegistrar_RegisterError(t *testing.T) {
cp := &fakeCP{registerErr: true}
if err := newRegistrar(cp, testIdentity()).Register(); err == nil {
t.Fatal("expected register error")
}
}
func TestHeartbeater_Beat(t *testing.T) {
cp := &fakeCP{}
if err := newHeartbeater(cp, testIdentity(), time.Second).beat(); err != nil {
t.Fatalf("beat: %v", err)
}
if cp.registerCount() != 1 {
t.Errorf("beat should re-register once, got %d", cp.registerCount())
}
}
func TestHeartbeater_DefaultInterval(t *testing.T) {
h := newHeartbeater(&fakeCP{}, testIdentity(), 0)
if h.interval != defaultHeartbeat {
t.Errorf("interval = %v, want default", h.interval)
}
}
func TestHeartbeater_RunStopsOnContext(t *testing.T) {
cp := &fakeCP{}
h := newHeartbeater(cp, testIdentity(), 5*time.Millisecond)
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() { h.Run(ctx); close(done) }()
time.Sleep(25 * time.Millisecond)
cancel()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("Run did not stop on context cancel")
}
if cp.registerCount() == 0 {
t.Error("expected at least one re-register before cancel")
}
}
-172
View File
@@ -1,172 +0,0 @@
package runtime
import (
"context"
"fmt"
"sort"
containerd "github.com/containerd/containerd/v2/client"
"github.com/containerd/containerd/v2/pkg/cdi"
"github.com/containerd/containerd/v2/pkg/cio"
"github.com/containerd/containerd/v2/pkg/namespaces"
"github.com/containerd/containerd/v2/pkg/oci"
specs "github.com/opencontainers/runtime-spec/specs-go"
)
// cdiVendorGPU is the NVIDIA CDI vendor/class prefix for whole-GPU devices, per
// the NVIDIA CDI naming convention (nvidia.com/gpu=<uuid>).
const cdiVendorGPU = "nvidia.com/gpu"
// cdiDeviceNames maps a container's pinned GPU device UUIDs to CDI device
// names. Empty input (non-GPU container) returns nil so no CDI opt is added.
func cdiDeviceNames(uuids []string) []string {
if len(uuids) == 0 {
return nil
}
names := make([]string, len(uuids))
for i, id := range uuids {
names[i] = cdiVendorGPU + "=" + id
}
return names
}
// cdiSpecOpts returns the oci.SpecOpts needed to inject gpuIDs (via
// containerd's CDI machinery) as devices into a container's OCI spec. It
// returns nil for a non-GPU container so Run adds nothing to the opts list.
func cdiSpecOpts(gpuIDs []string) []oci.SpecOpts {
devices := cdiDeviceNames(gpuIDs)
if len(devices) == 0 {
return nil
}
return []oci.SpecOpts{cdi.WithCDIDevices(devices...)}
}
var _ Runner = (*containerdPuller)(nil)
// Run creates a container from an already-pulled image and starts its task. The
// container joins the host network namespace (bridge/CNI task networking is a
// later sprint) and carries spec.Labels for the reboot reconciler. id must be
// unique within the "ecs" namespace.
func (p *containerdPuller) Run(ctx context.Context, id string, spec RunSpec) (string, error) {
ctx = namespaces.WithNamespace(ctx, ecsNamespace)
ref, err := normalizeRef(spec.Image)
if err != nil {
return "", err
}
image, err := p.client.GetImage(ctx, ref)
if err != nil {
return "", fmt.Errorf("get image %s: %w", ref, err)
}
specOpts := []oci.SpecOpts{
oci.WithImageConfig(image),
oci.WithHostHostsFile,
oci.WithHostResolvconf,
}
if spec.NetnsPath != "" {
// awsvpc: join the task ENI netns built by the agent.
specOpts = append(specOpts, oci.WithLinuxNamespace(specs.LinuxNamespace{
Type: specs.NetworkNamespace,
Path: spec.NetnsPath,
}))
} else {
// bridge/host: share the VM (host) netns.
specOpts = append(specOpts, oci.WithHostNamespace(specs.NetworkNamespace))
}
if len(spec.Command) > 0 {
specOpts = append(specOpts, oci.WithProcessArgs(spec.Command...))
}
if len(spec.Env) > 0 {
specOpts = append(specOpts, oci.WithEnv(envSlice(spec.Env)))
}
specOpts = append(specOpts, cdiSpecOpts(spec.GPUIDs)...)
container, err := p.client.NewContainer(ctx, id,
containerd.WithNewSnapshot(id+"-snapshot", image),
containerd.WithNewSpec(specOpts...),
containerd.WithContainerLabels(spec.Labels),
)
if err != nil {
return "", fmt.Errorf("create container %s: %w", id, err)
}
task, err := container.NewTask(ctx, cio.NullIO)
if err != nil {
_ = container.Delete(ctx, containerd.WithSnapshotCleanup)
return "", fmt.Errorf("create task %s: %w", id, err)
}
if err := task.Start(ctx); err != nil {
_, _ = task.Delete(ctx)
_ = container.Delete(ctx, containerd.WithSnapshotCleanup)
return "", fmt.Errorf("start task %s: %w", id, err)
}
return id, nil
}
// Wait blocks until the container's task exits and returns its exit code.
func (p *containerdPuller) Wait(ctx context.Context, containerID string) (RunStatus, error) {
ctx = namespaces.WithNamespace(ctx, ecsNamespace)
container, err := p.client.LoadContainer(ctx, containerID)
if err != nil {
return RunStatus{}, fmt.Errorf("load container %s: %w", containerID, err)
}
task, err := container.Task(ctx, nil)
if err != nil {
return RunStatus{}, fmt.Errorf("attach task %s: %w", containerID, err)
}
statusC, err := task.Wait(ctx)
if err != nil {
return RunStatus{}, fmt.Errorf("wait task %s: %w", containerID, err)
}
status := <-statusC
code, _, err := status.Result()
return RunStatus{ExitCode: int(code)}, err
}
// List enumerates every container in the ecs namespace with its labels and
// whether its task is running, so the agent can re-adopt live containers after a
// restart. Per-container label/task errors are treated as "not running" rather
// than failing the whole list.
func (p *containerdPuller) List(ctx context.Context) ([]Container, error) {
ctx = namespaces.WithNamespace(ctx, ecsNamespace)
containers, err := p.client.Containers(ctx)
if err != nil {
return nil, fmt.Errorf("list containers: %w", err)
}
out := make([]Container, 0, len(containers))
for _, c := range containers {
labels, _ := c.Labels(ctx)
running := false
if task, terr := c.Task(ctx, nil); terr == nil {
if status, serr := task.Status(ctx); serr == nil {
running = status.Status == containerd.Running
}
}
out = append(out, Container{ID: c.ID(), Labels: labels, Running: running})
}
return out, nil
}
// Remove kills and deletes the container's task, then the container + snapshot.
func (p *containerdPuller) Remove(ctx context.Context, containerID string) error {
ctx = namespaces.WithNamespace(ctx, ecsNamespace)
container, err := p.client.LoadContainer(ctx, containerID)
if err != nil {
return nil //nolint:nilerr // load failure means the container is already gone
}
if task, terr := container.Task(ctx, nil); terr == nil {
_, _ = task.Delete(ctx, containerd.WithProcessKill)
}
return container.Delete(ctx, containerd.WithSnapshotCleanup)
}
// envSlice renders an env map as sorted "K=V" entries for deterministic specs.
func envSlice(env map[string]string) []string {
out := make([]string, 0, len(env))
for k, v := range env {
out = append(out, k+"="+v)
}
sort.Strings(out)
return out
}
@@ -1,47 +0,0 @@
package runtime
import (
"reflect"
"testing"
)
// cdiDeviceNames maps a container's pinned GPU UUIDs to CDI device names
// (nvidia.com/gpu=<uuid>); Run wires the result into oci.WithCDIDevices when
// non-empty, so this is the injection logic's unit-testable core.
func TestCDIDeviceNames(t *testing.T) {
cases := []struct {
name string
uuids []string
want []string
}{
{name: "empty", uuids: nil, want: nil},
{name: "single", uuids: []string{"GPU-aaaaaaaa-1111-2222-3333-444444444444"},
want: []string{"nvidia.com/gpu=GPU-aaaaaaaa-1111-2222-3333-444444444444"}},
{name: "multiple preserves order", uuids: []string{"GPU-aaa", "GPU-bbb"},
want: []string{"nvidia.com/gpu=GPU-aaa", "nvidia.com/gpu=GPU-bbb"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := cdiDeviceNames(tc.uuids)
if !reflect.DeepEqual(got, tc.want) {
t.Errorf("cdiDeviceNames(%v) = %v, want %v", tc.uuids, got, tc.want)
}
})
}
}
// TestCDISpecOpts_Wiring confirms Run's specOpts list picks up exactly one CDI
// opt when GPUIDs is non-empty, and none for a non-GPU container — the wiring
// Run relies on (a live oci.Spec is needed to unit test the opt's effect).
func TestCDISpecOpts_Wiring(t *testing.T) {
if opts := cdiSpecOpts(nil); len(opts) != 0 {
t.Errorf("non-GPU container: want no spec opts, got %d", len(opts))
}
opts := cdiSpecOpts([]string{"GPU-aaa", "GPU-bbb"})
if len(opts) != 1 {
t.Fatalf("GPU container: want exactly one CDI spec opt, got %d", len(opts))
}
if opts[0] == nil {
t.Error("GPU container: spec opt is nil")
}
}
-88
View File
@@ -1,88 +0,0 @@
// Package runtime is the ecs-agent's container-runtime seam. The agent pulls
// task images and (from Sprint 4e) runs containers through an ImagePuller; the
// real implementation drives containerd, but the interface lets tests and the
// scheduler-only build run against a fake.
package runtime
import "context"
// PullSpec describes one image to pull.
type PullSpec struct {
// Ref is the full image reference, e.g.
// "123456789012.dkr.ecr.us-east-1.spinifex.internal/app:latest".
Ref string
}
// Image identifies a pulled image in the runtime's content store.
type Image struct {
Ref string
Digest string
Size int64
}
// Resolver supplies registry credentials for a given image reference. The
// ecs-agent backs this with ECR GetAuthorizationToken (see ecrresolver.go).
type Resolver interface {
// Authorize returns the username, password and registry endpoint to use for
// ref. An empty user/pass means anonymous pull.
Authorize(ctx context.Context, ref string) (user, pass, endpoint string, err error)
}
// ImagePuller pulls images into the local runtime. Close releases the runtime
// client connection.
type ImagePuller interface {
Pull(ctx context.Context, spec PullSpec, r Resolver) (Image, error)
Close() error
}
// RunSpec describes a single container the agent must create and start. v1 runs
// the container in the host network namespace (bridge/CNI task networking lands
// in a later sprint); Labels carry the mulga.ecs.* task identity for the reboot
// reconciler.
type RunSpec struct {
Image string
Command []string
Env map[string]string
Labels map[string]string
// NetnsPath, when set, joins the container to a pre-built network namespace
// (awsvpc task ENI). Empty keeps the container in the host (VM) netns —
// bridge/host mode behaviour.
NetnsPath string
// GPU is the whole-GPU count requested for this container.
GPU int
// GPUIDs are the ledger-pinned device UUIDs assigned to this container, one
// per GPU requested. Empty for non-GPU containers or if pinning fell short.
// The runner injects these as CDI devices into the OCI spec.
GPUIDs []string
}
// RunStatus is a finished container's outcome.
type RunStatus struct {
ExitCode int
}
// Container is a container the runtime already knows about, discovered by List.
// Labels carry the mulga.ecs.* task identity so the agent can re-adopt running
// containers after a restart; Running reports whether its task is live.
type Container struct {
ID string
Labels map[string]string
Running bool
}
// Runner creates and starts containers from already-pulled images. id is a
// caller-unique container ID; Wait blocks until the container exits; Remove
// tears down the container + its task; List enumerates known containers so the
// reboot reconciler can re-adopt the ones still running.
type Runner interface {
Run(ctx context.Context, id string, spec RunSpec) (containerID string, err error)
Wait(ctx context.Context, containerID string) (RunStatus, error)
Remove(ctx context.Context, containerID string) error
List(ctx context.Context) ([]Container, error)
}
// Runtime is the full container runtime the ecs-agent drives: pull + run.
type Runtime interface {
ImagePuller
Runner
}
-125
View File
@@ -1,125 +0,0 @@
package runtime
import (
"context"
"fmt"
"sync"
)
// FakePuller is an in-memory ImagePuller for tests and the scheduler-only build.
// It records pull requests and replays a programmed result or error.
type FakePuller struct {
mu sync.Mutex
Pulls []PullSpec
Authzd []string
Result Image
Err error
Closed bool
OnPull func(PullSpec) (Image, error)
// Run bookkeeping.
Runs []RunSpec
RunErr error
WaitCode int
WaitErr error
Waited []string
Removed []string
OnRun func(string, RunSpec) (string, error)
// List bookkeeping: Containers is replayed by List; ListErr forces a failure;
// Listed records that List was called.
Containers []Container
ListErr error
Listed bool
}
var (
_ ImagePuller = (*FakePuller)(nil)
_ Runner = (*FakePuller)(nil)
)
// Pull records the spec, exercises the resolver, and returns the programmed
// result. If OnPull is set it takes precedence over Result/Err.
func (f *FakePuller) Pull(ctx context.Context, spec PullSpec, r Resolver) (Image, error) {
f.mu.Lock()
f.Pulls = append(f.Pulls, spec)
f.mu.Unlock()
if r != nil {
user, _, _, err := r.Authorize(ctx, spec.Ref)
if err != nil {
return Image{}, fmt.Errorf("authorize %s: %w", spec.Ref, err)
}
f.mu.Lock()
f.Authzd = append(f.Authzd, user)
f.mu.Unlock()
}
if f.OnPull != nil {
return f.OnPull(spec)
}
if f.Err != nil {
return Image{}, f.Err
}
res := f.Result
if res.Ref == "" {
res.Ref = spec.Ref
}
return res, nil
}
// Close marks the puller closed.
func (f *FakePuller) Close() error {
f.mu.Lock()
defer f.mu.Unlock()
f.Closed = true
return nil
}
// Run records the spec and returns id (or the programmed result/error).
func (f *FakePuller) Run(_ context.Context, id string, spec RunSpec) (string, error) {
f.mu.Lock()
f.Runs = append(f.Runs, spec)
f.mu.Unlock()
if f.OnRun != nil {
return f.OnRun(id, spec)
}
if f.RunErr != nil {
return "", f.RunErr
}
return id, nil
}
// Wait records the container ID and returns the programmed exit code/error.
func (f *FakePuller) Wait(_ context.Context, containerID string) (RunStatus, error) {
f.mu.Lock()
f.Waited = append(f.Waited, containerID)
f.mu.Unlock()
return RunStatus{ExitCode: f.WaitCode}, f.WaitErr
}
// Remove records the container ID.
func (f *FakePuller) Remove(_ context.Context, containerID string) error {
f.mu.Lock()
defer f.mu.Unlock()
f.Removed = append(f.Removed, containerID)
return nil
}
// Waits returns a copy of the container IDs passed to Wait, for assertions.
func (f *FakePuller) Waits() []string {
f.mu.Lock()
defer f.mu.Unlock()
return append([]string(nil), f.Waited...)
}
// List replays the programmed container set (or error).
func (f *FakePuller) List(_ context.Context) ([]Container, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.Listed = true
if f.ListErr != nil {
return nil, f.ListErr
}
return f.Containers, nil
}
-340
View File
@@ -1,340 +0,0 @@
package main
import (
"context"
"fmt"
"log/slog"
"maps"
"time"
ctrruntime "github.com/mulgadc/spinifex/cmd/ecs-agent/runtime"
handlers_ecs "github.com/mulgadc/spinifex/spinifex/handlers/ecs"
"github.com/mulgadc/spinifex/spinifex/handlers/ecs/bus"
)
// pollAssignments drains the instance's assignment inbox on a ticker until ctx is
// cancelled. Each assign is dispatched to runTask exactly once; the taskIDs seen
// on a poll are acked on the next poll so the gateway can drop them — a crash
// before ack re-delivers (at-least-once), matching ACS. A failed poll is logged
// and retried on the next tick rather than killing the loop. dispatched is seeded
// by the startup reconcile with tasks already adopted from running containers, so
// their re-delivered assignments are acked but not re-run.
func (a *Agent) pollAssignments(ctx context.Context, dispatched map[string]bool) {
ticker := time.NewTicker(a.cfg.PollInterval)
defer ticker.Stop()
stopping := map[string]bool{}
var ackAssigns, ackStops []string
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
assigns, stops, err := a.cp.PollAssignments(a.id.ClusterName, a.id.InstanceID, ackAssigns, ackStops)
if err != nil {
slog.Warn("ecs-agent: poll assignments failed", "err", err)
continue
}
// Dispatch stops first so a task stopped in the same poll it was assigned
// is never started.
ackStops = ackStops[:0]
for i := range stops {
sd := stops[i]
if !stopping[sd.TaskID] {
stopping[sd.TaskID] = true
slog.Info("ecs-agent: task stop requested", "task", sd.TaskID, "reason", sd.Reason)
go a.stopTask(ctx, sd)
}
ackStops = append(ackStops, sd.TaskID)
}
ackAssigns = ackAssigns[:0]
for i := range assigns {
as := assigns[i]
if !dispatched[as.TaskID] && !stopping[as.TaskID] {
dispatched[as.TaskID] = true
slog.Info("ecs-agent: task assigned", "task", as.TaskID, "containers", len(as.Containers))
go a.runTask(ctx, &as)
}
ackAssigns = append(ackAssigns, as.TaskID)
}
}
}
}
// stopTask reaps a task's containers on a control-plane stop directive: it lists
// the runtime's containers, removes the ones labeled with this task (kill +
// delete, releasing host ports/netns), then reports the task STOPPED with the
// directive's reason. Idempotent — a task with no live containers still reports
// STOPPED so the scheduler releases its capacity.
func (a *Agent) stopTask(ctx context.Context, sd bus.StopDirective) {
if a.runner == nil {
return
}
containers, err := a.runner.List(ctx)
if err != nil {
slog.Warn("ecs-agent: stop list failed", "task", sd.TaskID, "err", err)
return
}
statuses := make([]bus.ContainerStatus, 0)
for _, c := range containers {
if c.Labels[labelTaskID] != sd.TaskID {
continue
}
if rerr := a.runner.Remove(ctx, c.ID); rerr != nil {
slog.Warn("ecs-agent: stop remove failed", "task", sd.TaskID, "container", c.ID, "err", rerr)
}
statuses = append(statuses, bus.ContainerStatus{
Name: c.Labels[labelContainerName], Status: bus.TaskStatusStopped, ContainerID: c.ID,
})
}
reason := sd.Reason
if reason == "" {
reason = "Task stopped"
}
a.reportTaskState(&bus.Assign{TaskID: sd.TaskID}, bus.TaskStatusStopped, reason, statuses)
slog.Info("ecs-agent: task reaped", "task", sd.TaskID, "containers", len(statuses))
}
// runTask pulls each container image, starts the containers, and reports task
// state on the bus. A missing runtime or any per-container failure reports the
// task STOPPED with a reason; success reports RUNNING and waits for exit.
func (a *Agent) runTask(ctx context.Context, as *bus.Assign) {
if a.puller == nil || a.runner == nil {
a.reportTaskState(as, bus.TaskStatusStopped, "containerd unavailable on agent", nil)
return
}
// awsvpc: build the task netns from the hot-plugged ENI before any container.
netnsPath, err := a.setupTaskNetns(as)
if err != nil {
slog.Error("ecs-agent: task netns setup failed", "task", as.TaskID, "err", err)
a.reportTaskState(as, bus.TaskStatusStopped, "network setup failed: "+err.Error(), nil)
return
}
credID := a.registerTaskCreds(as)
resolver := a.pullResolver(as)
statuses := make([]bus.ContainerStatus, 0, len(as.Containers))
for _, c := range as.Containers {
if _, err := a.puller.Pull(ctx, ctrruntime.PullSpec{Ref: c.Image}, resolver); err != nil {
slog.Error("ecs-agent: pull failed", "task", as.TaskID, "image", c.Image, "err", err)
a.teardownTaskNetns(as)
a.reportTaskState(as, bus.TaskStatusStopped, "image pull failed: "+err.Error(), statuses)
return
}
cid := containerID(as.TaskID, c.Name)
gpuIDs := a.pinContainerGPUs(as.TaskID, c.Name, c.GPU)
spec := ctrruntime.RunSpec{
Image: c.Image,
Command: c.Command,
Env: withCredEnv(c.Environment, credID),
Labels: taskLabels(as, c.Name),
NetnsPath: netnsPath,
GPU: c.GPU,
GPUIDs: gpuIDs,
}
id, err := a.runner.Run(ctx, cid, spec)
if err != nil {
slog.Error("ecs-agent: run failed", "task", as.TaskID, "container", c.Name, "err", err)
a.teardownTaskNetns(as)
a.reportTaskState(as, bus.TaskStatusStopped, "container start failed: "+err.Error(), statuses)
return
}
statuses = append(statuses, bus.ContainerStatus{
Name: c.Name, Status: bus.TaskStatusRunning, ContainerID: id, GPUIDs: gpuIDs,
})
go a.waitContainer(ctx, as, c.Name, id)
}
a.reportTaskState(as, bus.TaskStatusRunning, "", statuses)
slog.Info("ecs-agent: task running", "task", as.TaskID, "containers", len(statuses))
}
// waitContainer blocks until a container exits, then reports the task STOPPED
// with the exit code. v1 stops the whole task when its first container exits.
func (a *Agent) waitContainer(ctx context.Context, as *bus.Assign, name, containerID string) {
status, err := a.runner.Wait(ctx, containerID)
if err != nil {
slog.Warn("ecs-agent: wait container failed", "task", as.TaskID, "container", name, "err", err)
return
}
exit := status.ExitCode
a.teardownTaskNetns(as)
statuses := []bus.ContainerStatus{{Name: name, Status: bus.TaskStatusStopped, ContainerID: containerID, ExitCode: &exit}}
a.reportTaskState(as, bus.TaskStatusStopped, fmt.Sprintf("container %s exited (%d)", name, exit), statuses)
slog.Info("ecs-agent: task stopped", "task", as.TaskID, "container", name, "exitCode", exit)
}
// setupTaskNetns builds the awsvpc task netns from the hot-plugged ENI. Bridge/
// host tasks (no ENI MAC) and a nil controller are no-ops returning an empty path.
func (a *Agent) setupTaskNetns(as *bus.Assign) (string, error) {
if as.ENIMacAddress == "" || a.netns == nil {
return "", nil
}
return a.netns.Setup(as.TaskID, as.ENIMacAddress)
}
// teardownTaskNetns releases the task's IAM credential registration, then removes
// the awsvpc task netns (the latter is a no-op for bridge/host tasks). It runs at
// every task-stop path so credentials never outlive the task that owns them.
func (a *Agent) teardownTaskNetns(as *bus.Assign) {
if a.cred != nil {
a.cred.Deregister(taskCredID(as))
}
if as.ENIMacAddress == "" || a.netns == nil {
return
}
if err := a.netns.Teardown(as.TaskID); err != nil {
slog.Warn("ecs-agent: task netns teardown", "task", as.TaskID, "err", err)
}
}
// pullResolver returns the ECR resolver used for a task's image pulls. When the
// assign carries an execution role, it authorizes pulls as that role (assumed
// over the gateway); an empty role or a nil credential endpoint (unit tests)
// falls back to the instance-role resolver, so a pull is never worse off.
func (a *Agent) pullResolver(as *bus.Assign) ctrruntime.Resolver {
if as.ExecutionRoleARN == "" || a.cred == nil {
return a.resolver
}
prov := a.cred.AssumeProvider(as.ExecutionRoleARN, sessionName(as.TaskID))
return newLazyECRResolver(prov, a.cfg.Region, a.cfg.GatewayURL, a.cfg.GatewayCA)
}
// registerTaskCreds registers the task's credID -> role mapping with the
// credential endpoint and returns the credID (empty when the task has no role).
func (a *Agent) registerTaskCreds(as *bus.Assign) string {
credID := taskCredID(as)
if credID == "" || a.cred == nil {
return credID
}
a.cred.Register(credID, as.TaskRoleARN)
return credID
}
// taskCredID is the credential ID a task's containers fetch credentials under:
// the scheduler-assigned CredID, falling back to the taskID. Empty when the task
// carries no IAM role.
func taskCredID(as *bus.Assign) string {
if as.TaskRoleARN == "" {
return ""
}
if as.CredID != "" {
return as.CredID
}
return as.TaskID
}
// withCredEnv returns env with AWS_CONTAINER_CREDENTIALS_RELATIVE_URI set for
// credID, copying the map so the assign's container env is left untouched. A
// blank credID (no task role) returns env unchanged.
func withCredEnv(env map[string]string, credID string) map[string]string {
if credID == "" {
return env
}
out := make(map[string]string, len(env)+1)
maps.Copy(out, env)
out["AWS_CONTAINER_CREDENTIALS_RELATIVE_URI"] = credRelativeURI(credID)
return out
}
// pinContainerGPUs reserves n device UUIDs for a container from the local
// ledger, before the container is started so the runner can inject them as
// CDI devices. A short ledger (fewer free devices than requested, e.g.
// discovery found none) is logged and treated as best-effort: the container
// still runs without pinned UUIDs (and without GPU access) rather than
// failing the task.
func (a *Agent) pinContainerGPUs(taskID, containerName string, n int) []string {
if n <= 0 || a.gpu == nil {
return nil
}
uuids, err := a.gpu.Pin(gpuKey(taskID, containerName), n)
if err != nil {
slog.Warn("ecs-agent: gpu pin short", "task", taskID, "container", containerName, "err", err)
}
return uuids
}
// reportTaskState reports a task transition through the gateway's
// SubmitTaskStateChange action, then (RUNNING with pinned GPUs) reports the
// per-container device UUIDs, and (STOPPED) releases them back to the ledger.
func (a *Agent) reportTaskState(as *bus.Assign, status, reason string, containers []bus.ContainerStatus) {
if a.cp == nil {
return
}
msg := bus.TaskState{
AccountID: a.id.AccountID,
ClusterName: a.id.ClusterName,
InstanceID: a.id.InstanceID,
TaskID: as.TaskID,
LastStatus: status,
Containers: containers,
Reason: reason,
ReportedAt: time.Now().UTC(),
}
if err := a.cp.SubmitTaskState(msg); err != nil {
slog.Error("ecs-agent: report task-state failed", "task", as.TaskID, "err", err)
}
a.reportTaskGPUs(as.TaskID, containers)
if status == bus.TaskStatusStopped && a.gpu != nil {
a.gpu.ReleaseTask(as.TaskID)
}
}
// reportTaskGPUs sends the control plane the pinned device UUIDs for any
// container in the report that has them, so DescribeTasks can surface gpuIds.
// A no-op when nothing was pinned (the common non-GPU-task case).
func (a *Agent) reportTaskGPUs(taskID string, containers []bus.ContainerStatus) {
reports := make([]handlers_ecs.ContainerGPUReport, 0, len(containers))
for _, c := range containers {
if len(c.GPUIDs) > 0 {
reports = append(reports, handlers_ecs.ContainerGPUReport{Name: c.Name, GPUIDs: c.GPUIDs})
}
}
if len(reports) == 0 {
return
}
if err := a.cp.ReportTaskGPU(a.id.ClusterName, taskID, reports); err != nil {
slog.Error("ecs-agent: report task-gpu failed", "task", taskID, "err", err)
}
}
// containerID composes a containerd-valid container ID from a task + container.
func containerID(taskID, name string) string {
return fmt.Sprintf("%s-%s", taskID, name)
}
// mulga.ecs.* label keys stamped on every container. The reboot reconciler reads
// them back to re-associate running containers with their task on restart; the
// cred/role/MAC labels make a container self-describing enough to re-register its
// credentials and tear down its netns without the original assignment.
const (
labelTaskID = "mulga.ecs.taskID"
labelContainerName = "mulga.ecs.containerName"
labelClusterName = "mulga.ecs.clusterName"
labelCredID = "mulga.ecs.credID"
labelTaskRoleARN = "mulga.ecs.taskRoleArn"
labelENIMac = "mulga.ecs.eniMac"
)
// taskLabels are the mulga.ecs.* labels stamped on a container. The cred/role/MAC
// labels are omitted when empty so a container carries only what it needs.
func taskLabels(as *bus.Assign, name string) map[string]string {
labels := map[string]string{
labelTaskID: as.TaskID,
labelContainerName: name,
labelClusterName: as.ClusterName,
}
if credID := taskCredID(as); credID != "" {
labels[labelCredID] = credID
}
if as.TaskRoleARN != "" {
labels[labelTaskRoleARN] = as.TaskRoleARN
}
if as.ENIMacAddress != "" {
labels[labelENIMac] = as.ENIMacAddress
}
return labels
}
-557
View File
@@ -1,557 +0,0 @@
package main
import (
"context"
"errors"
"testing"
"time"
ctrruntime "github.com/mulgadc/spinifex/cmd/ecs-agent/runtime"
"github.com/mulgadc/spinifex/spinifex/handlers/ecs/bus"
)
func testAssign() *bus.Assign {
return &bus.Assign{
AccountID: "123456789012",
ClusterName: "default",
InstanceID: "i-abc123",
TaskID: "t-001",
Containers: []bus.AssignContainer{
{Name: "web", Image: "registry/web:1", Command: []string{"/bin/true"},
Environment: map[string]string{"FOO": "bar"}},
},
}
}
func newRunAgent(cp controlPlane, rt *ctrruntime.FakePuller) *Agent {
return newAgent(config{}, testIdentity(), cp, rt, rt, nil)
}
// runTask with no runtime reports the task STOPPED instead of crashing.
func TestRunTask_NoRuntimeReportsStopped(t *testing.T) {
cp := &fakeCP{}
a := newAgent(config{}, testIdentity(), cp, nil, nil, nil)
a.runTask(context.Background(), testAssign())
st := cp.taskStates()
if len(st) != 1 || st[0].LastStatus != bus.TaskStatusStopped {
t.Fatalf("want one STOPPED state, got %+v", st)
}
if st[0].Reason == "" {
t.Error("STOPPED state missing reason")
}
}
// Happy path: pull + run reports RUNNING with the container ID; Wait blocking
// (forced error) means no STOPPED overwrite, so we can assert the RUNNING frame.
func TestRunTask_PullRunReportsRunning(t *testing.T) {
cp := &fakeCP{}
rt := &ctrruntime.FakePuller{WaitErr: errors.New("blocked")}
a := newRunAgent(cp, rt)
as := testAssign()
a.runTask(context.Background(), as)
if len(rt.Pulls) != 1 || rt.Pulls[0].Ref != "registry/web:1" {
t.Fatalf("expected one pull of registry/web:1, got %+v", rt.Pulls)
}
if len(rt.Runs) != 1 || rt.Runs[0].Image != "registry/web:1" {
t.Fatalf("expected one run, got %+v", rt.Runs)
}
if rt.Runs[0].Labels["mulga.ecs.taskID"] != "t-001" {
t.Errorf("missing task label: %+v", rt.Runs[0].Labels)
}
st := cp.taskStates()
if len(st) == 0 || st[len(st)-1].LastStatus != bus.TaskStatusRunning {
t.Fatalf("want RUNNING final state, got %+v", st)
}
if len(st[len(st)-1].Containers) != 1 || st[len(st)-1].Containers[0].ContainerID != "t-001-web" {
t.Errorf("RUNNING state container wrong: %+v", st[len(st)-1].Containers)
}
}
// TestRunTask_GPUCarriedToRunSpec verifies that the AssignContainer's GPU count
// reaches the runtime RunSpec unchanged; a
// container with no GPU request keeps RunSpec.GPU at zero (regression).
func TestRunTask_GPUCarriedToRunSpec(t *testing.T) {
cp := &fakeCP{}
rt := &ctrruntime.FakePuller{WaitErr: errors.New("blocked")}
a := newRunAgent(cp, rt)
as := testAssign()
as.Containers = append(as.Containers, bus.AssignContainer{
Name: "trainer", Image: "registry/trainer:1", GPU: 2,
})
a.runTask(context.Background(), as)
if len(rt.Runs) != 2 {
t.Fatalf("expected two runs, got %+v", rt.Runs)
}
if rt.Runs[0].GPU != 0 {
t.Errorf("web container: want GPU=0, got %d", rt.Runs[0].GPU)
}
if rt.Runs[1].GPU != 2 {
t.Errorf("trainer container: want GPU=2, got %d", rt.Runs[1].GPU)
}
}
// TestRunTask_GPUPinsAndReportsUUIDs covers C3: a container's GPU request is
// pinned from the agent's local ledger, carried on the RUNNING report's
// ContainerStatus, and forwarded to the control plane via ReportTaskGPU.
func TestRunTask_GPUPinsAndReportsUUIDs(t *testing.T) {
cp := &fakeCP{}
rt := &ctrruntime.FakePuller{WaitErr: errors.New("blocked")}
a := newAgent(config{}, testIdentityWithGPUs("GPU-aaa", "GPU-bbb"), cp, rt, rt, nil)
as := testAssign()
as.Containers = append(as.Containers, bus.AssignContainer{
Name: "trainer", Image: "registry/trainer:1", GPU: 1,
})
a.runTask(context.Background(), as)
st := cp.taskStates()
if len(st) == 0 || st[len(st)-1].LastStatus != bus.TaskStatusRunning {
t.Fatalf("want RUNNING final state, got %+v", st)
}
last := st[len(st)-1]
var trainer *bus.ContainerStatus
for i := range last.Containers {
if last.Containers[i].Name == "trainer" {
trainer = &last.Containers[i]
}
}
if trainer == nil || len(trainer.GPUIDs) != 1 {
t.Fatalf("trainer container missing pinned GPU UUID: %+v", last.Containers)
}
if trainer.GPUIDs[0] != "GPU-aaa" && trainer.GPUIDs[0] != "GPU-bbb" {
t.Errorf("unexpected pinned UUID %q", trainer.GPUIDs[0])
}
reports := cp.gpuReportsFor(as.TaskID)
if len(reports) != 1 {
t.Fatalf("want one ReportTaskGPU call, got %d", len(reports))
}
if reports[0].cluster != as.ClusterName {
t.Errorf("report cluster = %q, want %q", reports[0].cluster, as.ClusterName)
}
if len(reports[0].containers) != 1 || reports[0].containers[0].Name != "trainer" {
t.Errorf("report containers = %+v, want one trainer entry", reports[0].containers)
}
}
// TestRunTask_GPUIDsReachRunSpec covers C4: the ledger's pinned UUIDs for a
// container reach RunSpec.GPUIDs (not just the RunSpec.GPU count), so the
// runner can inject them as CDI devices; a non-GPU container's RunSpec
// carries no GPUIDs.
func TestRunTask_GPUIDsReachRunSpec(t *testing.T) {
cp := &fakeCP{}
rt := &ctrruntime.FakePuller{WaitErr: errors.New("blocked")}
a := newAgent(config{}, testIdentityWithGPUs("GPU-aaa", "GPU-bbb"), cp, rt, rt, nil)
as := testAssign()
as.Containers = append(as.Containers, bus.AssignContainer{
Name: "trainer", Image: "registry/trainer:1", GPU: 1,
})
a.runTask(context.Background(), as)
if len(rt.Runs) != 2 {
t.Fatalf("expected two runs, got %+v", rt.Runs)
}
if len(rt.Runs[0].GPUIDs) != 0 {
t.Errorf("web container: want no GPUIDs, got %+v", rt.Runs[0].GPUIDs)
}
trainer := rt.Runs[1]
if len(trainer.GPUIDs) != 1 {
t.Fatalf("trainer container: want one pinned GPUID, got %+v", trainer.GPUIDs)
}
if trainer.GPUIDs[0] != "GPU-aaa" && trainer.GPUIDs[0] != "GPU-bbb" {
t.Errorf("unexpected pinned UUID %q", trainer.GPUIDs[0])
}
}
// A container with no GPU request never triggers a ReportTaskGPU call.
func TestRunTask_NoGPUSkipsReport(t *testing.T) {
cp := &fakeCP{}
rt := &ctrruntime.FakePuller{WaitErr: errors.New("blocked")}
a := newRunAgent(cp, rt)
as := testAssign()
a.runTask(context.Background(), as)
if reports := cp.gpuReportsFor(as.TaskID); len(reports) != 0 {
t.Errorf("want no GPU report for a non-GPU task, got %+v", reports)
}
}
// A GPU request exceeding the ledger's free devices (discovery found none, or
// fewer than requested) is best-effort: the container still runs, it just
// carries no pinned UUIDs and so no CDI devices are injected.
func TestRunTask_GPUShortLedgerStillRuns(t *testing.T) {
cp := &fakeCP{}
rt := &ctrruntime.FakePuller{WaitErr: errors.New("blocked")}
a := newRunAgent(cp, rt) // testIdentity() has no discovered GPU UUIDs
as := testAssign()
as.Containers = append(as.Containers, bus.AssignContainer{
Name: "trainer", Image: "registry/trainer:1", GPU: 2,
})
a.runTask(context.Background(), as)
if len(rt.Runs) != 2 {
t.Fatalf("expected both containers to run despite the short ledger, got %+v", rt.Runs)
}
if reports := cp.gpuReportsFor(as.TaskID); len(reports) != 0 {
t.Errorf("want no GPU report when nothing was pinned, got %+v", reports)
}
}
// stopTask releases the task's pinned GPU UUIDs back to the ledger so a later
// task on the same instance can reuse them.
func TestStopTask_ReleasesGPUsBackToLedger(t *testing.T) {
cp := &fakeCP{}
rt := &ctrruntime.FakePuller{Containers: []ctrruntime.Container{
{ID: "t-001-trainer", Running: true, Labels: map[string]string{
"mulga.ecs.taskID": "t-001", "mulga.ecs.containerName": "trainer",
}},
}}
a := newAgent(config{}, testIdentityWithGPUs("GPU-aaa"), cp, rt, rt, nil)
if _, err := a.gpu.Pin(gpuKey("t-001", "trainer"), 1); err != nil {
t.Fatalf("seed pin: %v", err)
}
if len(a.gpu.free) != 0 {
t.Fatalf("setup: want ledger fully pinned, free = %v", a.gpu.free)
}
a.stopTask(context.Background(), bus.StopDirective{TaskID: "t-001", Reason: "bye"})
if len(a.gpu.free) != 1 {
t.Errorf("want the GPU released back to the ledger, free = %v", a.gpu.free)
}
}
// A pull failure reports STOPPED and never starts the container.
func TestRunTask_PullFailureReportsStopped(t *testing.T) {
cp := &fakeCP{}
rt := &ctrruntime.FakePuller{Err: errors.New("pull denied")}
a := newRunAgent(cp, rt)
a.runTask(context.Background(), testAssign())
if len(rt.Runs) != 0 {
t.Errorf("container should not run after pull failure, got %+v", rt.Runs)
}
st := cp.taskStates()
if len(st) != 1 || st[0].LastStatus != bus.TaskStatusStopped {
t.Fatalf("want STOPPED, got %+v", st)
}
}
// A run failure reports STOPPED.
func TestRunTask_RunFailureReportsStopped(t *testing.T) {
cp := &fakeCP{}
rt := &ctrruntime.FakePuller{RunErr: errors.New("start boom")}
a := newRunAgent(cp, rt)
a.runTask(context.Background(), testAssign())
st := cp.taskStates()
if len(st) != 1 || st[0].LastStatus != bus.TaskStatusStopped {
t.Fatalf("want STOPPED, got %+v", st)
}
}
// On container exit, waitContainer reports STOPPED with the exit code.
func TestRunTask_ExitReportsStoppedWithCode(t *testing.T) {
cp := &fakeCP{}
rt := &ctrruntime.FakePuller{WaitCode: 7}
a := newRunAgent(cp, rt)
a.runTask(context.Background(), testAssign())
deadline := time.After(time.Second)
for {
st := cp.taskStates()
if len(st) > 0 && st[len(st)-1].LastStatus == bus.TaskStatusStopped {
last := st[len(st)-1]
if len(last.Containers) != 1 || last.Containers[0].ExitCode == nil || *last.Containers[0].ExitCode != 7 {
t.Fatalf("want exit code 7, got %+v", last.Containers)
}
return
}
select {
case <-deadline:
t.Fatalf("no STOPPED state after exit; got %+v", cp.taskStates())
case <-time.After(2 * time.Millisecond):
}
}
}
// pollAssignments dispatches each assign once and acks it on the next poll.
func TestPollAssignments_DispatchesOnceAndAcks(t *testing.T) {
as := testAssign()
cp := &fakeCP{pollReplies: [][]bus.Assign{
{*as}, // poll 1: one pending assign
{*as}, // poll 2: still present (not yet acked when poll 2 was issued)
nil, // poll 3: gateway dropped it after the ack
}}
rt := &ctrruntime.FakePuller{WaitErr: errors.New("blocked")}
a := newAgent(config{PollInterval: 5 * time.Millisecond}, testIdentity(), cp, rt, rt, nil)
ctx, cancel := context.WithCancel(context.Background())
go a.pollAssignments(ctx, map[string]bool{})
// runTask reports RUNNING through the (mutex-guarded) control plane. With the
// assign returned on two consecutive polls, dedup means exactly one dispatch →
// exactly one RUNNING report.
deadline := time.After(time.Second)
for {
if running := countStatus(cp.taskStates(), as.TaskID, bus.TaskStatusRunning); running >= 1 {
break
}
select {
case <-deadline:
t.Fatal("assign was never dispatched (no RUNNING report)")
case <-time.After(2 * time.Millisecond):
}
}
time.Sleep(20 * time.Millisecond)
cancel()
if running := countStatus(cp.taskStates(), as.TaskID, bus.TaskStatusRunning); running != 1 {
t.Errorf("task dispatched %d times, want exactly 1 (dedup on taskID)", running)
}
// The taskID must appear in a later poll's ack list.
acked := false
for _, ack := range cp.acks() {
for _, id := range ack {
if id == as.TaskID {
acked = true
}
}
}
if !acked {
t.Errorf("task %s was never acked; acks=%v", as.TaskID, cp.acks())
}
}
// stopTask reaps the task's labeled containers and reports STOPPED with the
// directive reason and container status.
func TestStopTask_ReapsLabeledContainersAndReportsStopped(t *testing.T) {
cp := &fakeCP{}
rt := &ctrruntime.FakePuller{Containers: []ctrruntime.Container{
{ID: "t-001-web", Running: true, Labels: map[string]string{
"mulga.ecs.taskID": "t-001", "mulga.ecs.containerName": "web",
}},
{ID: "t-999-other", Running: true, Labels: map[string]string{
"mulga.ecs.taskID": "t-999", "mulga.ecs.containerName": "web",
}},
}}
a := newRunAgent(cp, rt)
a.stopTask(context.Background(), bus.StopDirective{TaskID: "t-001", Reason: "bye"})
if len(rt.Removed) != 1 || rt.Removed[0] != "t-001-web" {
t.Fatalf("want only t-001-web removed, got %+v", rt.Removed)
}
st := cp.taskStates()
if len(st) != 1 || st[0].LastStatus != bus.TaskStatusStopped {
t.Fatalf("want one STOPPED report, got %+v", st)
}
if st[0].Reason != "bye" {
t.Errorf("want reason %q, got %q", "bye", st[0].Reason)
}
if len(st[0].Containers) != 1 || st[0].Containers[0].Status != bus.TaskStatusStopped {
t.Errorf("want one STOPPED container, got %+v", st[0].Containers)
}
}
// A stop for a task with no live containers still reports STOPPED so the
// scheduler releases its capacity.
func TestStopTask_NoContainersStillReportsStopped(t *testing.T) {
cp := &fakeCP{}
rt := &ctrruntime.FakePuller{}
a := newRunAgent(cp, rt)
a.stopTask(context.Background(), bus.StopDirective{TaskID: "t-001", Reason: "gone"})
if len(rt.Removed) != 0 {
t.Errorf("nothing to remove, got %+v", rt.Removed)
}
st := cp.taskStates()
if len(st) != 1 || st[0].LastStatus != bus.TaskStatusStopped {
t.Fatalf("want one STOPPED report, got %+v", st)
}
}
// A stop delivered by poll is dispatched once, acked in the stop-ack list, and
// suppresses a same-poll assign for the same task.
func TestPollAssignments_StopReapsAndSuppressesAssign(t *testing.T) {
as := testAssign()
cp := &fakeCP{
pollReplies: [][]bus.Assign{{*as}, {*as}, nil},
stopReplies: [][]bus.StopDirective{{{TaskID: as.TaskID, Reason: "bye"}}},
}
rt := &ctrruntime.FakePuller{Containers: []ctrruntime.Container{
{ID: "t-001-web", Running: true, Labels: map[string]string{"mulga.ecs.taskID": "t-001"}},
}}
a := newAgent(config{PollInterval: 5 * time.Millisecond}, testIdentity(), cp, rt, rt, nil)
ctx, cancel := context.WithCancel(context.Background())
go a.pollAssignments(ctx, map[string]bool{})
deadline := time.After(time.Second)
for {
if stopped := countStatus(cp.taskStates(), as.TaskID, bus.TaskStatusStopped); stopped >= 1 {
break
}
select {
case <-deadline:
t.Fatalf("stop never reaped; states=%+v", cp.taskStates())
case <-time.After(2 * time.Millisecond):
}
}
time.Sleep(20 * time.Millisecond)
cancel()
// The assign for the stopping task must never have started a container.
if len(rt.Runs) != 0 {
t.Errorf("assign for a stopping task should not run, got %+v", rt.Runs)
}
if running := countStatus(cp.taskStates(), as.TaskID, bus.TaskStatusRunning); running != 0 {
t.Errorf("stopping task should never report RUNNING, got %d", running)
}
acked := false
for _, ack := range cp.stopAcks() {
for _, id := range ack {
if id == as.TaskID {
acked = true
}
}
}
if !acked {
t.Errorf("stop %s was never acked; stopAcks=%v", as.TaskID, cp.stopAcks())
}
}
// stopTask with no runtime is a no-op: nothing to reap, no state reported.
func TestStopTask_NilRunnerNoOp(t *testing.T) {
cp := &fakeCP{}
a := newAgent(config{}, testIdentity(), cp, nil, nil, nil)
a.stopTask(context.Background(), bus.StopDirective{TaskID: "t-001", Reason: "x"})
if len(cp.taskStates()) != 0 {
t.Fatalf("nil runner should report no state, got %+v", cp.taskStates())
}
}
// A List failure aborts the reap without reporting a (false) STOPPED state.
func TestStopTask_ListErrorNoReport(t *testing.T) {
cp := &fakeCP{}
rt := &ctrruntime.FakePuller{ListErr: errors.New("list boom")}
a := newRunAgent(cp, rt)
a.stopTask(context.Background(), bus.StopDirective{TaskID: "t-001", Reason: "x"})
if len(cp.taskStates()) != 0 {
t.Fatalf("list error should report no state, got %+v", cp.taskStates())
}
}
// A directive with no reason reaps the matching container and reports STOPPED with
// the default reason.
func TestStopTask_EmptyReasonReapsWithDefault(t *testing.T) {
cp := &fakeCP{}
rt := &ctrruntime.FakePuller{Containers: []ctrruntime.Container{{
ID: "t-001-web",
Labels: map[string]string{
"mulga.ecs.taskID": "t-001", "mulga.ecs.containerName": "web",
},
}}}
a := newRunAgent(cp, rt)
a.stopTask(context.Background(), bus.StopDirective{TaskID: "t-001"})
if len(rt.Removed) != 1 || rt.Removed[0] != "t-001-web" {
t.Fatalf("want container t-001-web reaped, got %+v", rt.Removed)
}
st := cp.taskStates()
if len(st) != 1 || st[0].LastStatus != bus.TaskStatusStopped {
t.Fatalf("want one STOPPED report, got %+v", st)
}
if st[0].Reason != "Task stopped" {
t.Errorf("want default reason %q, got %q", "Task stopped", st[0].Reason)
}
}
func countStatus(states []bus.TaskState, taskID, status string) int {
n := 0
for _, st := range states {
if st.TaskID == taskID && st.LastStatus == status {
n++
}
}
return n
}
// awsvpc: an assign carrying an ENI MAC builds the task netns and passes its
// path to the container RunSpec.
func TestRunTask_AwsvpcBuildsNetns(t *testing.T) {
cp := &fakeCP{}
rt := &ctrruntime.FakePuller{WaitErr: errors.New("blocked")}
a := newRunAgent(cp, rt)
f := &fakeNetRunner{linkOut: linkWithENI}
a.netns = newTestNetns(f)
as := testAssign()
as.ENIMacAddress = "52:54:00:de:ad:01"
a.runTask(context.Background(), as)
if !f.sawAny("ip netns add ecs-t-001") {
t.Fatalf("expected task netns build, got %v", f.joined())
}
if len(rt.Runs) != 1 || rt.Runs[0].NetnsPath != netnsPathFor("t-001") {
t.Fatalf("want NetnsPath %q on RunSpec, got %+v", netnsPathFor("t-001"), rt.Runs)
}
}
// bridge/host: no ENI MAC means no netns is built and the container runs in the
// host (VM) netns (empty NetnsPath).
func TestRunTask_NoMacSkipsNetns(t *testing.T) {
cp := &fakeCP{}
rt := &ctrruntime.FakePuller{WaitErr: errors.New("blocked")}
a := newRunAgent(cp, rt)
f := &fakeNetRunner{linkOut: linkWithENI}
a.netns = newTestNetns(f)
a.runTask(context.Background(), testAssign())
if len(f.joined()) != 0 {
t.Fatalf("expected no netns commands, got %v", f.joined())
}
if len(rt.Runs) != 1 || rt.Runs[0].NetnsPath != "" {
t.Fatalf("want empty NetnsPath, got %+v", rt.Runs)
}
}
func TestContainerID(t *testing.T) {
if got := containerID("t-1", "web"); got != "t-1-web" {
t.Errorf("containerID = %q, want t-1-web", got)
}
}
// sentinelResolver is an identity-comparable Resolver for pullResolver tests.
type sentinelResolver struct{}
func (sentinelResolver) Authorize(context.Context, string) (string, string, string, error) {
return "", "", "", nil
}
// TestPullResolver verifies that an assign with no execution role (or no
// credential endpoint) uses the instance resolver; with both, a distinct
// exec-role resolver is built.
func TestPullResolver(t *testing.T) {
fake := sentinelResolver{}
a := newAgent(config{Region: "us-east-1"}, testIdentity(), &fakeCP{}, nil, nil, fake)
if got := a.pullResolver(&bus.Assign{TaskID: "t1"}); got != fake {
t.Fatalf("no execution role: want instance resolver fallback")
}
execAssign := &bus.Assign{TaskID: "t1", ExecutionRoleARN: "arn:aws:iam::1:role/e"}
if got := a.pullResolver(execAssign); got != fake {
t.Fatalf("nil cred endpoint: want instance resolver fallback")
}
a.cred = &credEndpoint{}
got := a.pullResolver(execAssign)
if got == nil || got == fake {
t.Fatalf("execution role + cred endpoint: want distinct exec-role resolver, got %v", got)
}
}
+17 -170
View File
@@ -1,196 +1,43 @@
// eks-gateway-fetch runs inside the EKS K3s control-plane VM and fetches a
// control-plane resource from the host through the AWS gateway, instead of
// dialing core NATS directly. It is the read-side companion to
// eks-gateway-publish.
//
// It SigV4-signs (service "eks") an HTTPS GET to the gateway, retrying with
// backoff until the gateway returns 2xx or the attempt budget is exhausted (so
// a degraded link surfaces as a non-zero exit), then emits the staged add-ons
// as tab-separated lines the on-VM addon-sync shell agent consumes without a
// JSON parser:
//
// <addonName>\t<addonVersion>\t<serviceAccountRoleArn>\t<base64(configurationValues)>
//
// The configuration values are base64-encoded so embedded tabs/newlines cannot
// break the line framing.
// control-plane resource from the host through the AWS gateway, writing it to
// stdout as TSV; the implementation is in agents/eks/gatewayfetch.
//
// Usage:
//
// eks-gateway-fetch -resource addons # GET /clusters/{cluster}/internal-addons/{accountId}
// eks-gateway-fetch -resource recovery -instance-id i-… # GET /clusters/{cluster}/internal-recovery/{accountId}/{instanceId}
//
// The recovery resource returns a single epoch\taction\tsnapshot line the on-VM
// k3s-recovery agent applies before k3s starts (etcd cluster-reset / wipe-rejoin).
//
// Flags default to environment variables seeded by cloud-init:
// EKS_GATEWAY_URL, EKS_GATEWAY_CA, EKS_ACCESS_KEY, EKS_SECRET_KEY, EKS_REGION,
// EKS_ACCOUNT_ID, EKS_CLUSTER_NAME, EKS_INSTANCE_ID.
package main
import (
"bufio"
"encoding/base64"
"encoding/json"
"context"
"flag"
"fmt"
"io"
"log/slog"
"os"
"time"
"github.com/mulgadc/spinifex/internal/eksgw"
_ "github.com/mulgadc/bluebottle/pkg/fipsboot"
)
// stagedAddon mirrors the gateway's StagedAddonManifest wire shape; duplicated
// here to keep this tiny VM binary free of the handler package's dependency
// graph.
type stagedAddon struct {
AddonName string `json:"addonName"`
AddonVersion string `json:"addonVersion"`
ServiceAccountRoleArn string `json:"serviceAccountRoleArn"`
ConfigurationValues string `json:"configurationValues"`
}
type internalAddonsResponse struct {
Addons []stagedAddon `json:"addons"`
}
const (
maxAttempts = 30
retryDelay = 5 * time.Second
"github.com/mulgadc/spinifex/spinifex/agents/eks/gatewayfetch"
)
func main() {
var (
gatewayURL string
gatewayCA string
accessKey string
secretKey string
region string
accountID string
clusterName string
resource string
instanceID string
)
flag.StringVar(&gatewayURL, "gateway", os.Getenv("EKS_GATEWAY_URL"), "Gateway URL (e.g. https://10.15.8.1:9999)")
flag.StringVar(&gatewayCA, "gateway-ca", os.Getenv("EKS_GATEWAY_CA"), "Path to gateway TLS CA PEM (optional; falls back to system trust)")
flag.StringVar(&accessKey, "access-key", os.Getenv("EKS_ACCESS_KEY"), "AWS access key ID")
flag.StringVar(&secretKey, "secret-key", os.Getenv("EKS_SECRET_KEY"), "AWS secret access key")
flag.StringVar(&region, "region", os.Getenv("EKS_REGION"), "AWS region for SigV4 signing")
flag.StringVar(&accountID, "account-id", os.Getenv("EKS_ACCOUNT_ID"), "Cluster account ID")
flag.StringVar(&clusterName, "cluster", os.Getenv("EKS_CLUSTER_NAME"), "Cluster name")
flag.StringVar(&instanceID, "instance-id", os.Getenv("EKS_INSTANCE_ID"), "This member's EC2 instance ID (recovery resource)")
flag.StringVar(&resource, "resource", "addons", "Resource to fetch: addons | recovery")
cfg := gatewayfetch.Config{Out: os.Stdout}
flag.StringVar(&cfg.GatewayURL, "gateway", os.Getenv("EKS_GATEWAY_URL"), "Gateway URL (e.g. https://10.15.8.1:9999)")
flag.StringVar(&cfg.GatewayCA, "gateway-ca", os.Getenv("EKS_GATEWAY_CA"), "Path to gateway TLS CA PEM (optional; falls back to system trust)")
flag.StringVar(&cfg.AccessKey, "access-key", os.Getenv("EKS_ACCESS_KEY"), "AWS access key ID")
flag.StringVar(&cfg.SecretKey, "secret-key", os.Getenv("EKS_SECRET_KEY"), "AWS secret access key")
flag.StringVar(&cfg.Region, "region", os.Getenv("EKS_REGION"), "AWS region for SigV4 signing")
flag.StringVar(&cfg.AccountID, "account-id", os.Getenv("EKS_ACCOUNT_ID"), "Cluster account ID")
flag.StringVar(&cfg.ClusterName, "cluster", os.Getenv("EKS_CLUSTER_NAME"), "Cluster name")
flag.StringVar(&cfg.InstanceID, "instance-id", os.Getenv("EKS_INSTANCE_ID"), "This member's EC2 instance ID (recovery resource)")
flag.StringVar(&cfg.Resource, "resource", "addons", "Resource to fetch: addons | recovery")
flag.Parse()
switch {
case accountID == "":
fatal("--account-id is required (or set EKS_ACCOUNT_ID)")
case clusterName == "":
fatal("--cluster is required (or set EKS_CLUSTER_NAME)")
case resource != "addons" && resource != "recovery":
fatal("--resource must be addons or recovery")
case resource == "recovery" && instanceID == "":
fatal("--instance-id is required for --resource recovery (or set EKS_INSTANCE_ID)")
if err := gatewayfetch.Run(context.Background(), cfg); err != nil {
slog.Error("eks-gateway-fetch: " + err.Error())
os.Exit(1)
}
client, err := eksgw.New(gatewayURL, gatewayCA, accessKey, secretKey, region)
if err != nil {
fatal(fmt.Sprintf("build gateway client: %v", err))
}
var (
path string
emit func(io.Writer, []byte) error
)
switch resource {
case "recovery":
path = "/clusters/" + clusterName + "/internal-recovery/" + accountID + "/" + instanceID
emit = emitRecoveryTSV
default:
path = "/clusters/" + clusterName + "/internal-addons/" + accountID
emit = emitAddonsTSV
}
var lastErr error
for attempt := 1; attempt <= maxAttempts; attempt++ {
body, err := client.Get(path)
if err != nil {
lastErr = err
slog.Warn("eks-gateway-fetch: attempt failed", "resource", resource, "attempt", attempt, "err", err)
if attempt < maxAttempts {
time.Sleep(retryDelay)
}
continue
}
if werr := emit(os.Stdout, body); werr != nil {
fatal(fmt.Sprintf("render response: %v", werr))
}
return
}
fatal(fmt.Sprintf("fetch failed after %d attempts: %v", maxAttempts, lastErr))
}
// emitAddonsTSV decodes the internal-addons response and writes one TSV line per
// staged add-on to w. An empty add-on set writes nothing (the agent then GCs
// every locally-rendered manifest), which is the correct steady state for a
// cluster with no managed add-ons.
func emitAddonsTSV(out io.Writer, body []byte) error {
var resp internalAddonsResponse
if err := json.Unmarshal(body, &resp); err != nil {
return fmt.Errorf("unmarshal internal-addons response: %w", err)
}
w := bufio.NewWriter(out)
for _, a := range resp.Addons {
cfg := base64.StdEncoding.EncodeToString([]byte(a.ConfigurationValues))
if _, err := fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", a.AddonName, a.AddonVersion, a.ServiceAccountRoleArn, cfg); err != nil {
return err
}
}
return w.Flush()
}
// recoveryDirective mirrors the gateway's RecoveryDirective wire shape; duplicated
// here to keep this tiny VM binary free of the handler package's dependency graph.
type recoveryDirective struct {
Epoch int64 `json:"epoch"`
Action string `json:"action"`
Snapshot string `json:"snapshot"`
SnapshotRequired bool `json:"snapshotRequired"`
}
type internalRecoveryResponse struct {
Directive recoveryDirective `json:"directive"`
}
// emitRecoveryTSV decodes the recovery directive and writes a single
// epoch\taction\tsnapshot\tsnapshotRequired line the on-VM k3s-recovery agent
// consumes without a JSON parser. An unset action defaults to "none" (steady
// state); snapshotRequired is 1 when the snapshot MUST restore (fresh DR seed
// aborts boot on fetch failure) else 0.
func emitRecoveryTSV(out io.Writer, body []byte) error {
var resp internalRecoveryResponse
if err := json.Unmarshal(body, &resp); err != nil {
return fmt.Errorf("unmarshal internal-recovery response: %w", err)
}
d := resp.Directive
if d.Action == "" {
d.Action = "none"
}
required := "0"
if d.SnapshotRequired {
required = "1"
}
if _, err := fmt.Fprintf(out, "%d\t%s\t%s\t%s\n", d.Epoch, d.Action, d.Snapshot, required); err != nil {
return err
}
return nil
}
func fatal(msg string) {
slog.Error("eks-gateway-fetch: " + msg)
os.Exit(1)
}
-44
View File
@@ -1,44 +0,0 @@
package main
import (
"bytes"
"encoding/base64"
"testing"
)
func TestEmitAddonsTSV(t *testing.T) {
body := []byte(`{"addons":[
{"addonName":"spinifex-noop","addonVersion":"0.1.0"},
{"addonName":"aws-load-balancer-controller","addonVersion":"2.8.1",
"serviceAccountRoleArn":"arn:aws:iam::111122223333:role/alb",
"configurationValues":"{\"replicaCount\":2}"}
]}`)
var buf bytes.Buffer
if err := emitAddonsTSV(&buf, body); err != nil {
t.Fatalf("emitAddonsTSV: %v", err)
}
cfg := base64.StdEncoding.EncodeToString([]byte(`{"replicaCount":2}`))
want := "spinifex-noop\t0.1.0\t\t\n" +
"aws-load-balancer-controller\t2.8.1\tarn:aws:iam::111122223333:role/alb\t" + cfg + "\n"
if buf.String() != want {
t.Fatalf("TSV mismatch:\n got: %q\nwant: %q", buf.String(), want)
}
}
func TestEmitAddonsTSV_Empty(t *testing.T) {
var buf bytes.Buffer
if err := emitAddonsTSV(&buf, []byte(`{"addons":[]}`)); err != nil {
t.Fatalf("emitAddonsTSV: %v", err)
}
if buf.Len() != 0 {
t.Fatalf("empty addon set must emit nothing, got %q", buf.String())
}
}
func TestEmitAddonsTSV_BadJSON(t *testing.T) {
var buf bytes.Buffer
if err := emitAddonsTSV(&buf, []byte(`{not-json`)); err == nil {
t.Fatal("expected error on malformed JSON")
}
}
+17 -108
View File
@@ -1,13 +1,6 @@
// eks-gateway-publish runs inside the EKS K3s control-plane VM and relays a
// single publication to the host through the AWS gateway, instead of dialing
// core NATS directly.
//
// It reads a JSON payload on stdin, wraps it as
// {accountId, channel, kind, payload}, SigV4-signs (service "eks") an HTTPS
// POST to {gateway}/clusters/{cluster}/internal-publish, and retries with
// backoff until the gateway returns 2xx or the attempt budget is exhausted —
// so a degraded link surfaces as a non-zero exit rather than a silently
// dropped message (the failure mode of fire-and-forget `nats pub`).
// single JSON publication from stdin to the host through the AWS gateway; the
// implementation is in agents/eks/gatewaypublish.
//
// Usage:
//
@@ -20,115 +13,31 @@
package main
import (
"bytes"
"encoding/json"
"context"
"flag"
"fmt"
"io"
"log/slog"
"os"
"time"
"github.com/mulgadc/spinifex/internal/eksgw"
_ "github.com/mulgadc/bluebottle/pkg/fipsboot"
)
const (
// Retry budget: the control plane reaches readiness before this runs, so a
// failing POST means a degraded link, not a cold start. Bounded so a stuck
// boot still terminates the OpenRC service.
maxAttempts = 30
retryDelay = 5 * time.Second
"github.com/mulgadc/spinifex/spinifex/agents/eks/gatewaypublish"
)
type publishBody struct {
AccountID string `json:"accountId"`
Channel string `json:"channel"`
Kind string `json:"kind"`
Payload json.RawMessage `json:"payload"`
}
func main() {
var (
gatewayURL string
gatewayCA string
accessKey string
secretKey string
region string
accountID string
clusterName string
channel string
kind string
)
flag.StringVar(&gatewayURL, "gateway", os.Getenv("EKS_GATEWAY_URL"), "Gateway URL (e.g. https://10.15.8.1:9999)")
flag.StringVar(&gatewayCA, "gateway-ca", os.Getenv("EKS_GATEWAY_CA"), "Path to gateway TLS CA PEM (optional; falls back to system trust)")
flag.StringVar(&accessKey, "access-key", os.Getenv("EKS_ACCESS_KEY"), "AWS access key ID")
flag.StringVar(&secretKey, "secret-key", os.Getenv("EKS_SECRET_KEY"), "AWS secret access key")
flag.StringVar(&region, "region", os.Getenv("EKS_REGION"), "AWS region for SigV4 signing")
flag.StringVar(&accountID, "account-id", os.Getenv("EKS_ACCOUNT_ID"), "Cluster account ID")
flag.StringVar(&clusterName, "cluster", os.Getenv("EKS_CLUSTER_NAME"), "Cluster name")
flag.StringVar(&channel, "channel", "", "Publish channel: bootstrap|state|addon")
flag.StringVar(&kind, "kind", "", "Bootstrap subject kind (bootstrap channel only)")
cfg := gatewaypublish.Config{Payload: os.Stdin}
flag.StringVar(&cfg.GatewayURL, "gateway", os.Getenv("EKS_GATEWAY_URL"), "Gateway URL (e.g. https://10.15.8.1:9999)")
flag.StringVar(&cfg.GatewayCA, "gateway-ca", os.Getenv("EKS_GATEWAY_CA"), "Path to gateway TLS CA PEM (optional; falls back to system trust)")
flag.StringVar(&cfg.AccessKey, "access-key", os.Getenv("EKS_ACCESS_KEY"), "AWS access key ID")
flag.StringVar(&cfg.SecretKey, "secret-key", os.Getenv("EKS_SECRET_KEY"), "AWS secret access key")
flag.StringVar(&cfg.Region, "region", os.Getenv("EKS_REGION"), "AWS region for SigV4 signing")
flag.StringVar(&cfg.AccountID, "account-id", os.Getenv("EKS_ACCOUNT_ID"), "Cluster account ID")
flag.StringVar(&cfg.ClusterName, "cluster", os.Getenv("EKS_CLUSTER_NAME"), "Cluster name")
flag.StringVar(&cfg.Channel, "channel", "", "Publish channel: bootstrap|state|addon")
flag.StringVar(&cfg.Kind, "kind", "", "Bootstrap subject kind (bootstrap channel only)")
flag.Parse()
switch {
case accountID == "":
fatal("--account-id is required (or set EKS_ACCOUNT_ID)")
case clusterName == "":
fatal("--cluster is required (or set EKS_CLUSTER_NAME)")
case channel != "bootstrap" && channel != "state" && channel != "addon":
fatal("--channel must be bootstrap, state or addon")
case channel == "bootstrap" && kind == "":
fatal("--kind is required for the bootstrap channel")
if err := gatewaypublish.Run(context.Background(), cfg); err != nil {
slog.Error("eks-gateway-publish: " + err.Error())
os.Exit(1)
}
payload, err := io.ReadAll(os.Stdin)
if err != nil {
fatal(fmt.Sprintf("read stdin payload: %v", err))
}
if len(bytes.TrimSpace(payload)) == 0 {
fatal("empty stdin payload")
}
if !json.Valid(payload) {
fatal("stdin payload is not valid JSON")
}
body, err := json.Marshal(publishBody{
AccountID: accountID,
Channel: channel,
Kind: kind,
Payload: json.RawMessage(payload),
})
if err != nil {
fatal(fmt.Sprintf("marshal request body: %v", err))
}
client, err := eksgw.New(gatewayURL, gatewayCA, accessKey, secretKey, region)
if err != nil {
fatal(fmt.Sprintf("build gateway client: %v", err))
}
path := "/clusters/" + clusterName + "/internal-publish"
var lastErr error
for attempt := 1; attempt <= maxAttempts; attempt++ {
if _, err := client.Post(path, body); err != nil {
lastErr = err
slog.Warn("eks-gateway-publish: attempt failed",
"channel", channel, "kind", kind, "attempt", attempt, "err", err)
if attempt < maxAttempts {
time.Sleep(retryDelay)
}
continue
}
slog.Info("eks-gateway-publish: published", "channel", channel, "kind", kind)
return
}
fatal(fmt.Sprintf("publish failed after %d attempts: %v", maxAttempts, lastErr))
}
func fatal(msg string) {
slog.Error("eks-gateway-publish: " + msg)
os.Exit(1)
}
+15 -223
View File
@@ -1,16 +1,6 @@
// Command eks-konnectivity-cert mints, then reuses, an ECDSA-P256 serving
// certificate for the on-node konnectivity-server, signed by the K3s cluster CA.
//
// The konnectivity-agent (DaemonSet on workers) verifies the server it dials
// using the in-pod ServiceAccount CA (/var/run/secrets/.../ca.crt), which in
// K3s is the same server-ca that signs the apiserver. Signing the konnectivity
// serving cert with that CA is therefore what lets the agent validate the
// reverse-tunnel endpoint with no separate CA distribution.
//
// The eks-node AMI carries no openssl, so this is the Go equivalent: load the
// K3s server CA cert+key, issue a leaf (ServerAuth) cert carrying the cluster's
// konnectivity SANs (the NLB private-endpoint IP, public endpoint IP, NLB DNS),
// and cache it under -dir so the OpenRC service is byte-stable across restarts.
// Command eks-konnectivity-cert mints, then reuses, a K3s-CA-signed serving
// certificate for the on-node konnectivity-server; the implementation is in
// agents/eks/konnectivitycert.
//
// Usage:
//
@@ -23,225 +13,27 @@
package main
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"context"
"flag"
"fmt"
"log/slog"
"math/big"
"net"
"os"
"path/filepath"
"strings"
"time"
_ "github.com/mulgadc/bluebottle/pkg/fipsboot"
"github.com/mulgadc/spinifex/spinifex/agents/eks/konnectivitycert"
)
func main() {
dir := flag.String("dir", "", "directory to cache tls.crt/tls.key in")
caCert := flag.String("ca-cert", "", "PEM path of the signing (K3s server) CA cert")
caKey := flag.String("ca-key", "", "PEM path of the signing CA private key")
cn := flag.String("cn", "konnectivity-server", "certificate common name")
sansCSV := flag.String("sans", "", "comma-separated SANs (IP or DNS)")
cfg := konnectivitycert.Config{Out: os.Stdout}
flag.StringVar(&cfg.Dir, "dir", "", "directory to cache tls.crt/tls.key in")
flag.StringVar(&cfg.CACert, "ca-cert", "", "PEM path of the signing (K3s server) CA cert")
flag.StringVar(&cfg.CAKey, "ca-key", "", "PEM path of the signing CA private key")
flag.StringVar(&cfg.CN, "cn", "konnectivity-server", "certificate common name")
flag.StringVar(&cfg.SANs, "sans", "", "comma-separated SANs (IP or DNS)")
flag.Parse()
if *dir == "" || *caCert == "" || *caKey == "" || *sansCSV == "" {
fatal("eks-konnectivity-cert: -dir, -ca-cert, -ca-key and -sans are required")
if err := konnectivitycert.Run(context.Background(), cfg); err != nil {
slog.Error("eks-konnectivity-cert: " + err.Error())
os.Exit(1)
}
dns, ips := splitSANs(*sansCSV)
if len(dns) == 0 && len(ips) == 0 {
fatal("eks-konnectivity-cert: -sans lists no usable names")
}
certPath, keyPath, err := ensureCert(*dir, *caCert, *caKey, *cn, dns, ips)
if err != nil {
fatal("eks-konnectivity-cert: " + err.Error())
}
fmt.Printf("%s\t%s\n", certPath, keyPath)
}
// ensureCert returns the cached cert/key paths under dir, minting and persisting
// a fresh CA-signed pair when none is present, readable, and still SAN-current.
func ensureCert(dir, caCertPath, caKeyPath, cn string, dns []string, ips []net.IP) (string, string, error) {
certPath := filepath.Join(dir, "tls.crt")
keyPath := filepath.Join(dir, "tls.key")
if certCoversSANs(certPath, keyPath, dns, ips) {
return certPath, keyPath, nil
}
caCert, caKey, err := loadCA(caCertPath, caKeyPath)
if err != nil {
return "", "", err
}
certPEM, keyPEM, err := issueLeaf(caCert, caKey, cn, dns, ips)
if err != nil {
return "", "", err
}
if err = os.MkdirAll(dir, 0o700); err != nil {
return "", "", fmt.Errorf("mkdir %s: %w", dir, err)
}
if err = os.WriteFile(certPath, certPEM, 0o644); err != nil {
return "", "", fmt.Errorf("write %s: %w", certPath, err)
}
if err = os.WriteFile(keyPath, keyPEM, 0o600); err != nil {
return "", "", fmt.Errorf("write %s: %w", keyPath, err)
}
return certPath, keyPath, nil
}
// certCoversSANs reports whether the cached cert exists, parses, has a key, and
// already carries every requested SAN — so a cert minted before the endpoint
// set changed is re-issued rather than served stale.
func certCoversSANs(certPath, keyPath string, dns []string, ips []net.IP) bool {
c, err := os.ReadFile(certPath)
if err != nil {
return false
}
if k, kerr := os.ReadFile(keyPath); kerr != nil || len(k) == 0 {
return false
}
der := decodeFirst(c)
if der == nil {
return false
}
crt, err := x509.ParseCertificate(der)
if err != nil {
return false
}
have := make(map[string]struct{}, len(crt.DNSNames)+len(crt.IPAddresses))
for _, n := range crt.DNSNames {
have[n] = struct{}{}
}
for _, ip := range crt.IPAddresses {
have[ip.String()] = struct{}{}
}
for _, n := range dns {
if _, ok := have[n]; !ok {
return false
}
}
for _, ip := range ips {
if _, ok := have[ip.String()]; !ok {
return false
}
}
return true
}
// loadCA parses the PEM CA cert + EC/PKCS8 private key used to sign the leaf.
func loadCA(certPath, keyPath string) (*x509.Certificate, *ecdsa.PrivateKey, error) {
certPEM, err := os.ReadFile(certPath)
if err != nil {
return nil, nil, fmt.Errorf("read ca cert %s: %w", certPath, err)
}
der := decodeFirst(certPEM)
if der == nil {
return nil, nil, fmt.Errorf("ca cert %s: no PEM block", certPath)
}
caCert, err := x509.ParseCertificate(der)
if err != nil {
return nil, nil, fmt.Errorf("parse ca cert: %w", err)
}
keyPEM, err := os.ReadFile(keyPath)
if err != nil {
return nil, nil, fmt.Errorf("read ca key %s: %w", keyPath, err)
}
keyDER := decodeFirst(keyPEM)
if keyDER == nil {
return nil, nil, fmt.Errorf("ca key %s: no PEM block", keyPath)
}
caKey, err := parseECKey(keyDER)
if err != nil {
return nil, nil, err
}
return caCert, caKey, nil
}
// parseECKey accepts the EC SEC1 or PKCS8 encodings K3s may write for the CA key.
func parseECKey(der []byte) (*ecdsa.PrivateKey, error) {
if k, err := x509.ParseECPrivateKey(der); err == nil {
return k, nil
}
k8, err := x509.ParsePKCS8PrivateKey(der)
if err != nil {
return nil, fmt.Errorf("parse ca key (sec1 and pkcs8 failed): %w", err)
}
ec, ok := k8.(*ecdsa.PrivateKey)
if !ok {
return nil, fmt.Errorf("ca key is %T, want ecdsa", k8)
}
return ec, nil
}
// issueLeaf mints an ECDSA-P256 ServerAuth leaf for cn + SANs, signed by caCert/caKey.
func issueLeaf(caCert *x509.Certificate, caKey *ecdsa.PrivateKey, cn string, dns []string, ips []net.IP) (certPEM, keyPEM []byte, err error) {
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, nil, fmt.Errorf("generate key: %w", err)
}
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
if err != nil {
return nil, nil, fmt.Errorf("serial: %w", err)
}
now := time.Now().UTC()
tmpl := x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{CommonName: cn},
NotBefore: now.Add(-time.Hour),
NotAfter: now.AddDate(10, 0, 0),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
DNSNames: dns,
IPAddresses: ips,
}
der, err := x509.CreateCertificate(rand.Reader, &tmpl, caCert, &priv.PublicKey, caKey)
if err != nil {
return nil, nil, fmt.Errorf("create cert: %w", err)
}
keyDER, err := x509.MarshalECPrivateKey(priv)
if err != nil {
return nil, nil, fmt.Errorf("marshal key: %w", err)
}
certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
keyPEM = pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
return certPEM, keyPEM, nil
}
// splitSANs partitions the CSV into DNS names and IPs (an entry that parses as an
// IP goes to IPAddresses, else DNSNames), matching how SAN matching is typed.
func splitSANs(s string) (dns []string, ips []net.IP) {
for p := range strings.SplitSeq(s, ",") {
p = strings.TrimSpace(p)
if p == "" {
continue
}
if ip := net.ParseIP(p); ip != nil {
ips = append(ips, ip)
continue
}
dns = append(dns, p)
}
return dns, ips
}
// decodeFirst returns the DER bytes of the first PEM block, or nil.
func decodeFirst(pemBytes []byte) []byte {
blk, _ := pem.Decode(pemBytes)
if blk == nil {
return nil
}
return blk.Bytes
}
func fatal(msg string) {
slog.Error(msg)
os.Exit(1)
}
-153
View File
@@ -1,153 +0,0 @@
package main
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"net"
"os"
"path/filepath"
"testing"
"time"
)
// writeTestCA mints a self-signed EC CA (cert + SEC1 key PEM) into dir, returning
// the two paths — a stand-in for K3s' /var/lib/rancher/k3s/server/tls/server-ca.*.
func writeTestCA(t *testing.T, dir string) (certPath, keyPath string) {
t.Helper()
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
t.Fatalf("gen ca key: %v", err)
}
tmpl := x509.Certificate{
SerialNumber: big.NewInt(1),
Subject: pkix.Name{CommonName: "k3s-server-ca"},
NotBefore: time.Now().Add(-time.Hour),
NotAfter: time.Now().AddDate(10, 0, 0),
KeyUsage: x509.KeyUsageCertSign | x509.KeyUsageDigitalSignature,
BasicConstraintsValid: true,
IsCA: true,
}
der, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &priv.PublicKey, priv)
if err != nil {
t.Fatalf("create ca cert: %v", err)
}
keyDER, err := x509.MarshalECPrivateKey(priv)
if err != nil {
t.Fatalf("marshal ca key: %v", err)
}
certPath = filepath.Join(dir, "server-ca.crt")
keyPath = filepath.Join(dir, "server-ca.key")
if err := os.WriteFile(certPath, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0o644); err != nil {
t.Fatalf("write ca cert: %v", err)
}
if err := os.WriteFile(keyPath, pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER}), 0o600); err != nil {
t.Fatalf("write ca key: %v", err)
}
return certPath, keyPath
}
func leafFrom(t *testing.T, certPath string) *x509.Certificate {
t.Helper()
b, err := os.ReadFile(certPath)
if err != nil {
t.Fatalf("read leaf: %v", err)
}
crt, err := x509.ParseCertificate(decodeFirst(b))
if err != nil {
t.Fatalf("parse leaf: %v", err)
}
return crt
}
func TestEnsureCertSignsLeafWithSANsThatChainsToCA(t *testing.T) {
dir := t.TempDir()
caCert, caKey := writeTestCA(t, dir)
dns, ips := splitSANs("10.32.100.4,203.0.113.9,eks-toc.example")
certPath, keyPath, err := ensureCert(dir, caCert, caKey, "konnectivity-server", dns, ips)
if err != nil {
t.Fatalf("ensureCert: %v", err)
}
if _, err := os.Stat(keyPath); err != nil {
t.Fatalf("key not written: %v", err)
}
leaf := leafFrom(t, certPath)
if leaf.IsCA {
t.Error("konnectivity serving cert must be a leaf, not a CA")
}
// The agent dials the NLB private-endpoint IP and verifies via the in-pod CA,
// so the IP must be a SAN or the tunnel TLS handshake fails.
if err := leaf.VerifyHostname("10.32.100.4"); err != nil {
t.Errorf("IP SAN 10.32.100.4 missing: %v", err)
}
// The leaf must chain to the K3s server CA — that CA is the in-pod ca.crt the
// agent trusts, so no separate CA distribution is needed.
caPEM, _ := os.ReadFile(caCert)
roots := x509.NewCertPool()
if !roots.AppendCertsFromPEM(caPEM) {
t.Fatal("append CA failed")
}
if _, err := leaf.Verify(x509.VerifyOptions{
Roots: roots,
DNSName: "eks-toc.example",
KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
}); err != nil {
t.Errorf("leaf does not chain to the K3s server CA: %v", err)
}
}
func TestEnsureCertReusesThenReissuesOnSANChange(t *testing.T) {
dir := t.TempDir()
caCert, caKey := writeTestCA(t, dir)
dns, ips := splitSANs("10.32.100.4,eks-toc.example")
c1, _, err := ensureCert(dir, caCert, caKey, "konnectivity-server", dns, ips)
if err != nil {
t.Fatalf("first ensureCert: %v", err)
}
first, _ := os.ReadFile(c1)
// Same SANs → cached pair reused byte-for-byte (a churning cert would bounce
// the konnectivity-server on every restart).
if _, _, err := ensureCert(dir, caCert, caKey, "konnectivity-server", dns, ips); err != nil {
t.Fatalf("second ensureCert: %v", err)
}
if second, _ := os.ReadFile(c1); string(first) != string(second) {
t.Error("ensureCert re-minted instead of reusing the SAN-current cached cert")
}
// A new endpoint in the SAN set must force a re-issue.
dns2, ips2 := splitSANs("10.32.100.4,eks-toc.example,10.32.100.99")
if _, _, err := ensureCert(dir, caCert, caKey, "konnectivity-server", dns2, ips2); err != nil {
t.Fatalf("third ensureCert: %v", err)
}
if third, _ := os.ReadFile(c1); string(first) == string(third) {
t.Error("ensureCert served a stale cert that does not cover the new SAN")
}
if err := leafFrom(t, c1).VerifyHostname("10.32.100.99"); err != nil {
t.Errorf("re-issued cert missing new SAN: %v", err)
}
}
func TestSplitSANsPartitionsIPsAndDNS(t *testing.T) {
dns, ips := splitSANs(" 10.0.0.1 , host.example , , 2001:db8::1 ")
if len(dns) != 1 || dns[0] != "host.example" {
t.Errorf("dns = %v, want [host.example]", dns)
}
wantIPs := []net.IP{net.ParseIP("10.0.0.1"), net.ParseIP("2001:db8::1")}
if len(ips) != len(wantIPs) {
t.Fatalf("ips = %v, want %v", ips, wantIPs)
}
for i, ip := range ips {
if !ip.Equal(wantIPs[i]) {
t.Errorf("ips[%d] = %v, want %v", i, ip, wantIPs[i])
}
}
}
-133
View File
@@ -1,133 +0,0 @@
package main
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/base64"
"encoding/pem"
"fmt"
"math/big"
"net"
"os"
"time"
"github.com/mulgadc/bluebottle/pkg/tlsconfig"
)
// serverTLSConfig is the webhook's loopback serving TLS config.
func serverTLSConfig(cert tls.Certificate) *tls.Config {
return &tls.Config{
Certificates: []tls.Certificate{cert},
MinVersion: tls.VersionTLS13,
CurvePreferences: tlsconfig.Curves,
}
}
// ensureServingCert loads the persisted self-signed serving cert/key, or mints
// a fresh ECDSA-P256 pair (SAN 127.0.0.1 / localhost) and persists it. Reusing
// across restarts keeps the CA the apiserver loaded into its webhook kubeconfig
// valid even if the webhook process is restarted. Returns the parsed cert and
// the certificate PEM (for embedding as the kubeconfig CA bundle).
func ensureServingCert(certPath, keyPath string) (tls.Certificate, []byte, error) {
if certPEM, keyPEM, ok := readCertPair(certPath, keyPath); ok {
cert, err := tls.X509KeyPair(certPEM, keyPEM)
if err == nil {
return cert, certPEM, nil
}
// Fall through to regenerate on a corrupt/incompatible persisted pair.
}
certPEM, keyPEM, err := generateSelfSigned()
if err != nil {
return tls.Certificate{}, nil, err
}
if err := os.WriteFile(certPath, certPEM, 0o644); err != nil {
return tls.Certificate{}, nil, fmt.Errorf("write cert %s: %w", certPath, err)
}
if err := os.WriteFile(keyPath, keyPEM, 0o600); err != nil {
return tls.Certificate{}, nil, fmt.Errorf("write key %s: %w", keyPath, err)
}
cert, err := tls.X509KeyPair(certPEM, keyPEM)
if err != nil {
return tls.Certificate{}, nil, fmt.Errorf("parse minted keypair: %w", err)
}
return cert, certPEM, nil
}
func readCertPair(certPath, keyPath string) (certPEM, keyPEM []byte, ok bool) {
c, err := os.ReadFile(certPath)
if err != nil {
return nil, nil, false
}
k, err := os.ReadFile(keyPath)
if err != nil {
return nil, nil, false
}
return c, k, true
}
func generateSelfSigned() (certPEM, keyPEM []byte, err error) {
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, nil, fmt.Errorf("generate key: %w", err)
}
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
if err != nil {
return nil, nil, fmt.Errorf("serial: %w", err)
}
now := time.Now().UTC()
tmpl := x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{CommonName: "eks-token-webhook"},
NotBefore: now.Add(-time.Hour),
NotAfter: now.AddDate(10, 0, 0),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment | x509.KeyUsageCertSign,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
IsCA: true,
IPAddresses: []net.IP{net.IPv4(127, 0, 0, 1), net.IPv6loopback},
DNSNames: []string{"localhost"},
}
der, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &priv.PublicKey, priv)
if err != nil {
return nil, nil, fmt.Errorf("create cert: %w", err)
}
keyDER, err := x509.MarshalECPrivateKey(priv)
if err != nil {
return nil, nil, fmt.Errorf("marshal key: %w", err)
}
certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
keyPEM = pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
return certPEM, keyPEM, nil
}
// writeAPIServerKubeconfig writes the kubeconfig kube-apiserver loads via
// --authentication-token-webhook-config-file. It points the apiserver at the
// loopback webhook and trusts its self-signed serving cert as the CA bundle.
func writeAPIServerKubeconfig(path, addr string, certPEM []byte) error {
caB64 := base64.StdEncoding.EncodeToString(certPEM)
kubeconfig := fmt.Sprintf(`apiVersion: v1
kind: Config
clusters:
- name: mulga-eks-token-webhook
cluster:
certificate-authority-data: %s
server: https://%s/authenticate
users:
- name: kube-apiserver
contexts:
- name: webhook
context:
cluster: mulga-eks-token-webhook
user: kube-apiserver
current-context: webhook
`, caB64, addr)
if err := os.WriteFile(path, []byte(kubeconfig), 0o600); err != nil {
return fmt.Errorf("write %s: %w", path, err)
}
return nil
}
+24 -179
View File
@@ -1,76 +1,45 @@
// eks-token-webhook is the K8s TokenReview webhook authenticator baked into
// the eks-server AMI. Per kubectl call the kube-apiserver POSTs the bearer
// token (produced by `aws eks get-token`); the webhook relays it through the
// AWS gateway broker (SigV4 HTTPS POST to /clusters/{name}/token-review), which
// host-side runs the STS verify (cross-cluster x-k8s-aws-id pin) + AccessEntry
// KV lookup and returns the resolved identity. The webhook never speaks core
// NATS — same gateway-broker model as ELBv2's lb-agent and eks-gateway-publish.
//
// It binds 127.0.0.1 only; the kube-apiserver is the sole client (loopback).
// On startup it writes the apiserver webhook kubeconfig (with its self-signed
// serving CA) so k3s can be pointed at it via
// --authentication-token-webhook-config-file.
//
// SigV4 creds come from the AWS SDK chain (IMDS instance-role) when static
// EKS_ACCESS_KEY/EKS_SECRET_KEY are absent, matching the sibling helpers.
// Command eks-token-webhook is the K8s TokenReview webhook authenticator baked
// into the eks-server AMI; the implementation is in agents/eks/tokenwebhook.
// It reads its configuration from the first-boot env cloud-init seeds onto the
// VM and binds loopback only.
package main
import (
"encoding/json"
"context"
"errors"
"flag"
"fmt"
"log/slog"
"net/http"
"os"
"time"
"github.com/mulgadc/spinifex/internal/eksgw"
handlers_eks "github.com/mulgadc/spinifex/spinifex/handlers/eks"
_ "github.com/mulgadc/bluebottle/pkg/fipsboot"
"github.com/mulgadc/spinifex/spinifex/agents/eks/tokenwebhook"
)
// config is the webhook's runtime configuration, sourced from the first-boot
// env (/etc/spinifex-eks/first-boot.env) cloud-init seeds onto the VM.
type config struct {
addr string
gatewayURL string
gatewayCA string
accessKey string
secretKey string
region string
accountID string
clusterName string
certPath string
keyPath string
kubeconfig string
}
func loadConfig() (config, error) {
c := config{
addr: flagAddr,
gatewayURL: os.Getenv("EKS_GATEWAY_URL"),
gatewayCA: os.Getenv("EKS_GATEWAY_CA"),
accessKey: os.Getenv("EKS_ACCESS_KEY"),
secretKey: os.Getenv("EKS_SECRET_KEY"),
region: os.Getenv("EKS_REGION"),
accountID: os.Getenv("EKS_ACCOUNT_ID"),
clusterName: os.Getenv("EKS_CLUSTER_NAME"),
certPath: envOr("EKS_WEBHOOK_CERT", "/etc/spinifex-eks/token-webhook.crt"),
keyPath: envOr("EKS_WEBHOOK_KEY", "/etc/spinifex-eks/token-webhook.key"),
kubeconfig: envOr("EKS_WEBHOOK_KUBECONFIG", "/etc/spinifex-eks/token-webhook.kubeconfig"),
func loadConfig() (tokenwebhook.Config, error) {
c := tokenwebhook.Config{
Addr: flagAddr,
GatewayURL: os.Getenv("EKS_GATEWAY_URL"),
GatewayCA: os.Getenv("EKS_GATEWAY_CA"),
AccessKey: os.Getenv("EKS_ACCESS_KEY"),
SecretKey: os.Getenv("EKS_SECRET_KEY"),
Region: os.Getenv("EKS_REGION"),
AccountID: os.Getenv("EKS_ACCOUNT_ID"),
ClusterName: os.Getenv("EKS_CLUSTER_NAME"),
CertPath: envOr("EKS_WEBHOOK_CERT", "/etc/spinifex-eks/token-webhook.crt"),
KeyPath: envOr("EKS_WEBHOOK_KEY", "/etc/spinifex-eks/token-webhook.key"),
Kubeconfig: envOr("EKS_WEBHOOK_KUBECONFIG", "/etc/spinifex-eks/token-webhook.kubeconfig"),
}
// Static SigV4 creds are optional: when absent, eksgw.New signs with the
// AWS SDK chain (IMDS instance-role creds), the same path the CP VM's
// sibling helpers use. The CP VM launches on an instance profile, so
// buildK3sUserData omits EKS_ACCESS_KEY/EKS_SECRET_KEY by design.
switch {
case c.gatewayURL == "":
case c.GatewayURL == "":
return c, errors.New("EKS_GATEWAY_URL not set")
case c.accountID == "":
case c.AccountID == "":
return c, errors.New("EKS_ACCOUNT_ID not set")
case c.clusterName == "":
case c.ClusterName == "":
return c, errors.New("EKS_CLUSTER_NAME not set")
}
return c, nil
@@ -97,132 +66,8 @@ func main() {
os.Exit(1)
}
if err := run(cfg); err != nil {
if err := tokenwebhook.Run(context.Background(), cfg); err != nil {
slog.Error("eks-token-webhook fatal", "err", err)
os.Exit(1)
}
}
func run(cfg config) error {
// Serving cert + apiserver kubeconfig. Persisted and reused across restarts
// so a webhook crash does not invalidate the CA the apiserver already loaded.
tlsCert, certPEM, err := ensureServingCert(cfg.certPath, cfg.keyPath)
if err != nil {
return fmt.Errorf("serving cert: %w", err)
}
if err := writeAPIServerKubeconfig(cfg.kubeconfig, cfg.addr, certPEM); err != nil {
return fmt.Errorf("write apiserver kubeconfig: %w", err)
}
client, err := eksgw.New(cfg.gatewayURL, cfg.gatewayCA, cfg.accessKey, cfg.secretKey, cfg.region)
if err != nil {
return fmt.Errorf("build gateway client: %w", err)
}
authr := &authenticator{
accountID: cfg.accountID,
clusterName: cfg.clusterName,
review: gatewayReviewer(client, cfg.clusterName, cfg.accountID),
}
mux := http.NewServeMux()
mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte("ok"))
})
mux.HandleFunc("/authenticate", authr.handle)
server := &http.Server{
Addr: cfg.addr,
Handler: mux,
ReadHeaderTimeout: 5 * time.Second,
TLSConfig: serverTLSConfig(tlsCert),
}
slog.Info("eks-token-webhook listening", "addr", cfg.addr, "cluster", cfg.clusterName)
if err := server.ListenAndServeTLS("", ""); err != nil && !errors.Is(err, http.ErrServerClosed) {
return fmt.Errorf("serve: %w", err)
}
return nil
}
// tokenReviewRequest is the body POSTed to /clusters/{name}/token-review. The
// webhook holds system SigV4 creds (system account, not the cluster account),
// so accountId names the cluster account explicitly — same as PublishInternal.
type tokenReviewRequest struct {
AccountID string `json:"accountId"`
Token string `json:"token"`
}
// gatewayReviewer returns the per-call relay: SigV4-POST {accountId,token} to
// the gateway and decode the resolved identity. A non-2xx / transport error is
// surfaced (handle() turns it into a 5xx so the apiserver retries) rather than a
// silent deny, distinguishing "webhook broker down" from "token rejected".
func gatewayReviewer(client *eksgw.Client, clusterName, accountID string) func(string) (handlers_eks.WebhookTokenReviewResult, error) {
path := "/clusters/" + clusterName + "/token-review"
return func(token string) (handlers_eks.WebhookTokenReviewResult, error) {
body, err := json.Marshal(tokenReviewRequest{AccountID: accountID, Token: token})
if err != nil {
return handlers_eks.WebhookTokenReviewResult{}, fmt.Errorf("marshal request: %w", err)
}
respBody, err := client.Post(path, body)
if err != nil {
return handlers_eks.WebhookTokenReviewResult{}, err
}
var res handlers_eks.WebhookTokenReviewResult
if err := json.Unmarshal(respBody, &res); err != nil {
return handlers_eks.WebhookTokenReviewResult{}, fmt.Errorf("decode response: %w", err)
}
return res, nil
}
}
// authenticator resolves a TokenReview by relaying to the gateway broker.
type authenticator struct {
accountID string
clusterName string
review func(token string) (handlers_eks.WebhookTokenReviewResult, error)
}
func (a *authenticator) handle(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var review tokenReview
if err := json.NewDecoder(r.Body).Decode(&review); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
res, err := a.review(review.Spec.Token)
if err != nil {
// Broker fault (gateway down, transport error): a 5xx tells the apiserver
// to retry rather than treating a transient outage as a hard deny.
slog.Error("TokenReview relay failed", "cluster", a.clusterName, "err", err)
http.Error(w, "token review unavailable", http.StatusServiceUnavailable)
return
}
status := tokenReviewStatus{Authenticated: res.Authenticated}
if res.Authenticated {
status.User = userInfo{
Username: res.Username,
UID: res.UID,
Groups: res.Groups,
}
// Confirm the token for the audiences the apiserver asked about; an empty
// status.audiences is rejected when --api-audiences is configured.
status.Audiences = review.Spec.Audiences
}
slog.Info("TokenReview decision", "authenticated", status.Authenticated, "username", status.User.Username, "audiences", status.Audiences)
resp := tokenReview{
APIVersion: "authentication.k8s.io/v1",
Kind: "TokenReview",
Status: status,
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(resp); err != nil {
slog.Error("encode TokenReview response", "err", err)
}
}
+2 -117
View File
@@ -1,127 +1,12 @@
package main
import (
"encoding/base64"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
handlers_eks "github.com/mulgadc/spinifex/spinifex/handlers/eks"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// postReview drives authenticator.handle with a TokenReview carrying token and
// audiences, returning the decoded response status and HTTP code.
func postReview(t *testing.T, a *authenticator, token string, audiences []string) (tokenReviewStatus, int) {
t.Helper()
body, err := json.Marshal(tokenReview{
APIVersion: "authentication.k8s.io/v1",
Kind: "TokenReview",
Spec: tokenReviewSpec{Token: token, Audiences: audiences},
})
require.NoError(t, err)
req := httptest.NewRequest(http.MethodPost, "/authenticate", strings.NewReader(string(body)))
w := httptest.NewRecorder()
a.handle(w, req)
if w.Code != http.StatusOK {
return tokenReviewStatus{}, w.Code
}
var resp tokenReview
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
return resp.Status, w.Code
}
func TestHandle_GrantsAndStampsAudiences(t *testing.T) {
a := &authenticator{
accountID: "111122223333",
clusterName: "alpha",
review: func(string) (handlers_eks.WebhookTokenReviewResult, error) {
return handlers_eks.WebhookTokenReviewResult{
Authenticated: true,
Username: "arn:aws:iam::111122223333:role/admin",
UID: "AROAEXAMPLE:session",
Groups: []string{"system:masters"},
}, nil
},
}
status, code := postReview(t, a, "tok", []string{"https://kubernetes.default.svc"})
require.Equal(t, http.StatusOK, code)
require.True(t, status.Authenticated)
assert.Equal(t, "arn:aws:iam::111122223333:role/admin", status.User.Username)
assert.Equal(t, "AROAEXAMPLE:session", status.User.UID)
assert.Equal(t, []string{"system:masters"}, status.User.Groups)
assert.Equal(t, []string{"https://kubernetes.default.svc"}, status.Audiences)
}
func TestHandle_DeniesWithoutStampingAudiences(t *testing.T) {
a := &authenticator{
clusterName: "alpha",
review: func(string) (handlers_eks.WebhookTokenReviewResult, error) {
return handlers_eks.WebhookTokenReviewResult{Authenticated: false}, nil
},
}
status, code := postReview(t, a, "tok", []string{"https://kubernetes.default.svc"})
require.Equal(t, http.StatusOK, code)
assert.False(t, status.Authenticated)
assert.Empty(t, status.User.Username)
assert.Empty(t, status.Audiences, "unauthenticated review must not stamp audiences")
}
// A broker fault is a 5xx (retryable), not a silent deny — a transient gateway
// outage must not look identical to a rejected token.
func TestHandle_BrokerFaultReturns5xx(t *testing.T) {
a := &authenticator{
clusterName: "alpha",
review: func(string) (handlers_eks.WebhookTokenReviewResult, error) {
return handlers_eks.WebhookTokenReviewResult{}, errors.New("gateway returned 503")
},
}
_, code := postReview(t, a, "tok", nil)
assert.Equal(t, http.StatusServiceUnavailable, code)
}
func TestEnsureServingCert_PersistsAndReuses(t *testing.T) {
dir := t.TempDir()
certPath := filepath.Join(dir, "wh.crt")
keyPath := filepath.Join(dir, "wh.key")
c1, pem1, err := ensureServingCert(certPath, keyPath)
require.NoError(t, err)
require.NotEmpty(t, pem1)
require.NotNil(t, serverTLSConfig(c1))
// Second call reuses the persisted pair (same cert bytes).
_, pem2, err := ensureServingCert(certPath, keyPath)
require.NoError(t, err)
assert.Equal(t, pem1, pem2)
}
func TestWriteAPIServerKubeconfig(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "wh.kubeconfig")
certPEM := []byte("-----BEGIN CERTIFICATE-----\nMIIB\n-----END CERTIFICATE-----\n")
require.NoError(t, writeAPIServerKubeconfig(path, "127.0.0.1:8443", certPEM))
data, err := os.ReadFile(path)
require.NoError(t, err)
body := string(data)
assert.Contains(t, body, "server: https://127.0.0.1:8443/authenticate")
assert.Contains(t, body, base64.StdEncoding.EncodeToString(certPEM))
assert.Contains(t, body, "current-context: webhook")
}
// The CP VM launches on an instance profile, so cloud-init omits the static
// EKS_ACCESS_KEY/EKS_SECRET_KEY and eksgw.New signs with IMDS creds. loadConfig
// must accept absent static keys; gating on them aborts every IMDS-mode boot,
@@ -148,8 +33,8 @@ func TestLoadConfig_AcceptsAbsentStaticCreds(t *testing.T) {
})
cfg, err := loadConfig()
require.NoError(t, err)
assert.Empty(t, cfg.accessKey)
assert.Empty(t, cfg.secretKey)
assert.Empty(t, cfg.AccessKey)
assert.Empty(t, cfg.SecretKey)
// Genuinely-required vars are still enforced.
setEnv(t, map[string]string{
+13 -127
View File
@@ -1,12 +1,6 @@
// Command eks-webhook-cert mints, then reuses, a self-signed ECDSA-P256 serving
// certificate for an in-cluster admission webhook and prints its base64 PEM so
// the addon renderer can embed it as the webhook Secret + each caBundle.
//
// The eks-node AMI carries no openssl, so this is the Go equivalent of the
// upstream Helm chart's install-time genSelfSignedCert: one cert per cluster,
// cached on disk under -dir, stable across addon-sync ticks. The single cert is
// its own CA (IsCA + ServerAuth), so the same PEM serves as both the TLS leaf
// and the caBundle the apiserver trusts.
// Command eks-webhook-cert mints, then reuses, a self-signed serving certificate
// for an in-cluster admission webhook and prints its base64 PEM; the
// implementation is in agents/eks/webhookcert.
//
// Usage:
//
@@ -17,133 +11,25 @@
package main
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/x509"
"crypto/x509/pkix"
"encoding/base64"
"encoding/pem"
"context"
"flag"
"fmt"
"log/slog"
"math/big"
"os"
"path/filepath"
"strings"
"time"
_ "github.com/mulgadc/bluebottle/pkg/fipsboot"
"github.com/mulgadc/spinifex/spinifex/agents/eks/webhookcert"
)
func main() {
dir := flag.String("dir", "", "directory to cache tls.crt/tls.key in")
cn := flag.String("cn", "", "certificate common name")
dnsCSV := flag.String("dns", "", "comma-separated DNS SANs")
cfg := webhookcert.Config{Out: os.Stdout}
flag.StringVar(&cfg.Dir, "dir", "", "directory to cache tls.crt/tls.key in")
flag.StringVar(&cfg.CN, "cn", "", "certificate common name")
flag.StringVar(&cfg.DNS, "dns", "", "comma-separated DNS SANs")
flag.Parse()
if *dir == "" || *cn == "" || *dnsCSV == "" {
fatal("eks-webhook-cert: -dir, -cn and -dns are required")
if err := webhookcert.Run(context.Background(), cfg); err != nil {
slog.Error("eks-webhook-cert: " + err.Error())
os.Exit(1)
}
dnsNames := splitCSV(*dnsCSV)
if len(dnsNames) == 0 {
fatal("eks-webhook-cert: -dns lists no names")
}
certPEM, keyPEM, err := ensureCert(*dir, *cn, dnsNames)
if err != nil {
fatal("eks-webhook-cert: " + err.Error())
}
b := base64.StdEncoding.EncodeToString
fmt.Printf("%s\t%s\t%s\n", b(certPEM), b(certPEM), b(keyPEM))
}
// ensureCert returns the cached cert/key under dir, minting and persisting a
// fresh self-signed pair (for cn + dnsNames) when none is present or readable.
func ensureCert(dir, cn string, dnsNames []string) (certPEM, keyPEM []byte, err error) {
certPath := filepath.Join(dir, "tls.crt")
keyPath := filepath.Join(dir, "tls.key")
if c, cerr := os.ReadFile(certPath); cerr == nil {
if k, kerr := os.ReadFile(keyPath); kerr == nil {
if _, perr := x509.ParseCertificate(decodeFirst(c)); perr == nil && len(k) > 0 {
return c, k, nil
}
}
}
certPEM, keyPEM, err = generateSelfSigned(cn, dnsNames)
if err != nil {
return nil, nil, err
}
if err = os.MkdirAll(dir, 0o700); err != nil {
return nil, nil, fmt.Errorf("mkdir %s: %w", dir, err)
}
if err = os.WriteFile(certPath, certPEM, 0o644); err != nil {
return nil, nil, fmt.Errorf("write %s: %w", certPath, err)
}
if err = os.WriteFile(keyPath, keyPEM, 0o600); err != nil {
return nil, nil, fmt.Errorf("write %s: %w", keyPath, err)
}
return certPEM, keyPEM, nil
}
// generateSelfSigned mints an ECDSA-P256 self-signed cert valid as both a CA and
// a server cert, with the given common name and DNS SANs.
func generateSelfSigned(cn string, dnsNames []string) (certPEM, keyPEM []byte, err error) {
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return nil, nil, fmt.Errorf("generate key: %w", err)
}
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
if err != nil {
return nil, nil, fmt.Errorf("serial: %w", err)
}
now := time.Now().UTC()
tmpl := x509.Certificate{
SerialNumber: serial,
Subject: pkix.Name{CommonName: cn},
NotBefore: now.Add(-time.Hour),
NotAfter: now.AddDate(10, 0, 0),
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment | x509.KeyUsageCertSign,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
IsCA: true,
DNSNames: dnsNames,
}
der, err := x509.CreateCertificate(rand.Reader, &tmpl, &tmpl, &priv.PublicKey, priv)
if err != nil {
return nil, nil, fmt.Errorf("create cert: %w", err)
}
keyDER, err := x509.MarshalECPrivateKey(priv)
if err != nil {
return nil, nil, fmt.Errorf("marshal key: %w", err)
}
certPEM = pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
keyPEM = pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
return certPEM, keyPEM, nil
}
// decodeFirst returns the DER bytes of the first PEM block, or nil.
func decodeFirst(pemBytes []byte) []byte {
blk, _ := pem.Decode(pemBytes)
if blk == nil {
return nil
}
return blk.Bytes
}
func splitCSV(s string) []string {
var out []string
for p := range strings.SplitSeq(s, ",") {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
func fatal(msg string) {
slog.Error(msg)
os.Exit(1)
}
-81
View File
@@ -1,81 +0,0 @@
package main
import (
"crypto/x509"
"os"
"path/filepath"
"testing"
)
const (
testCN = "aws-load-balancer-webhook-service.kube-system.svc"
testDNS = "aws-load-balancer-webhook-service.kube-system.svc.cluster.local"
)
func parseLeaf(t *testing.T, certPEM []byte) *x509.Certificate {
t.Helper()
leaf, err := x509.ParseCertificate(decodeFirst(certPEM))
if err != nil {
t.Fatalf("parse cert: %v", err)
}
return leaf
}
func TestEnsureCertMintsServingCert(t *testing.T) {
dir := t.TempDir()
certPEM, keyPEM, err := ensureCert(dir, testCN, []string{testCN, testDNS})
if err != nil {
t.Fatalf("ensureCert: %v", err)
}
if len(keyPEM) == 0 {
t.Fatal("empty key PEM")
}
leaf := parseLeaf(t, certPEM)
// Both webhook service DNS names must be present, or the apiserver's TLS
// dial to the webhook fails SAN verification.
for _, want := range []string{testCN, testDNS} {
if err := leaf.VerifyHostname(want); err != nil {
t.Errorf("SAN %q missing: %v", want, err)
}
}
if !leaf.IsCA {
t.Error("cert must be a CA so the same PEM serves as the webhook caBundle")
}
// The single self-signed cert must verify against itself as a root — this is
// exactly what the apiserver does with caBundle == the serving cert.
roots := x509.NewCertPool()
roots.AddCert(leaf)
if _, err := leaf.Verify(x509.VerifyOptions{
Roots: roots,
DNSName: testCN,
KeyUsages: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
}); err != nil {
t.Errorf("self-signed cert does not verify as its own CA: %v", err)
}
}
func TestEnsureCertReusesCachedPair(t *testing.T) {
dir := t.TempDir()
cert1, key1, err := ensureCert(dir, testCN, []string{testCN})
if err != nil {
t.Fatalf("first ensureCert: %v", err)
}
cert2, key2, err := ensureCert(dir, testCN, []string{testCN})
if err != nil {
t.Fatalf("second ensureCert: %v", err)
}
if string(cert1) != string(cert2) || string(key1) != string(key2) {
t.Error("ensureCert regenerated instead of reusing the cached pair; a churning cert re-applies the addon every tick")
}
// The private key must not be world-readable on disk.
info, err := os.Stat(filepath.Join(dir, "tls.key"))
if err != nil {
t.Fatalf("stat key: %v", err)
}
if info.Mode().Perm()&0o077 != 0 {
t.Errorf("tls.key mode %o is group/other-accessible", info.Mode().Perm())
}
}
+3 -3
View File
@@ -6,9 +6,9 @@ import (
"strings"
_ "github.com/mulgadc/bluebottle/pkg/fipsboot"
"github.com/mulgadc/spinifex/cmd/installer/autoinstall"
"github.com/mulgadc/spinifex/cmd/installer/install"
"github.com/mulgadc/spinifex/cmd/installer/ui"
"github.com/mulgadc/spinifex/spinifex/operator/installer/autoinstall"
"github.com/mulgadc/spinifex/spinifex/operator/installer/install"
"github.com/mulgadc/spinifex/spinifex/operator/installer/ui"
)
func main() {
-139
View File
@@ -1,139 +0,0 @@
package ui
import (
"slices"
"strings"
"testing"
"github.com/mulgadc/spinifex/cmd/installer/install"
)
// diskModel is a storage screen with n unselected disks and ext4 chosen, which
// is where the wizard starts.
func diskModel(n int) model {
m := model{fsCursor: slices.Index(install.AllFSTypes, install.FSExt4)}
for i := range n {
m.disks = append(m.disks, install.Disk{
Path: string(rune('a'+i)) + "-disk",
Bytes: 200 << 30,
LogicalBlockSize: 512,
PhysicalBlockSize: 512,
})
}
return m
}
func rolePaths(cfg install.DiskConfig) map[install.DiskRole]string {
out := map[install.DiskRole]string{}
for _, rm := range cfg.Roles {
out[rm.Role] = rm.Disk.Path
}
return out
}
// A single-disk machine should need no storage input at all, so the disk that
// newModel preselects has to arrive already holding the os role.
func TestPreselectedDiskIsAValidConfiguration(t *testing.T) {
m := newModel([]install.Disk{{Path: "/dev/sda", Bytes: 200 << 30, LogicalBlockSize: 512}}, nil)
cfg := m.storage()
if err := cfg.Validate(); err != nil {
t.Fatalf("the preselected disk must be installable as-is: %v", err)
}
if cfg.Primary().Path != "/dev/sda" {
t.Errorf("Primary() = %q, want the preselected disk", cfg.Primary().Path)
}
}
// Picking disks in order gives the layout an operator almost always wants, so
// the role key is only needed for the exceptions.
func TestSelectingDisksAssignsRolesInOrder(t *testing.T) {
m := diskModel(3)
for i := range 3 {
m = m.toggleDisk(i)
}
got := rolePaths(m.storage())
for role, want := range map[install.DiskRole]string{
install.RoleOS: "a-disk", install.RoleSpinifex: "b-disk", install.RolePredastore: "c-disk",
} {
if got[role] != want {
t.Errorf("%s = %q, want %q", role, got[role], want)
}
}
if err := m.storage().Validate(); err != nil {
t.Errorf("the default assignment must be valid: %v", err)
}
}
// Deselecting has to drop the role with the disk, or every later role shifts
// onto the wrong drive.
func TestDeselectingADiskReleasesItsRole(t *testing.T) {
m := diskModel(3)
for i := range 3 {
m = m.toggleDisk(i)
}
m = m.toggleDisk(1) // drop the spinifex drive
got := rolePaths(m.storage())
if got[install.RoleOS] != "a-disk" || got[install.RolePredastore] != "c-disk" {
t.Fatalf("remaining roles moved: %v", got)
}
if _, ok := got[install.RoleSpinifex]; ok {
t.Errorf("the spinifex role should be free again: %v", got)
}
}
// The two-drive predastore layout is only reachable by reassigning, so cycling
// swaps rather than refusing a role another disk already holds.
func TestCycleRoleSwapsWithTheDiskThatHoldsIt(t *testing.T) {
m := diskModel(2)
m = m.toggleDisk(0).toggleDisk(1)
// b-disk: spinifex → predastore, which no disk holds, so no swap.
m = m.cycleRole(1)
got := rolePaths(m.storage())
if got[install.RoleOS] != "a-disk" || got[install.RolePredastore] != "b-disk" {
t.Fatalf("roles = %v, want os on a-disk and predastore on b-disk", got)
}
// Cycling past the end wraps onto os, which a-disk holds, so they trade.
m = m.cycleRole(1)
got = rolePaths(m.storage())
if got[install.RoleOS] != "b-disk" || got[install.RolePredastore] != "a-disk" {
t.Fatalf("roles = %v, want the two disks to have swapped", got)
}
if err := m.storage().Validate(); err != nil {
t.Errorf("a swap must always leave a valid assignment: %v", err)
}
}
// ZFS pairs mirrors by selection order and has no roles to assign.
func TestStorageLeavesRolesUnsetOnZFS(t *testing.T) {
m := diskModel(2)
m.fsCursor = slices.Index(install.AllFSTypes, install.FSZFSRAID1)
m = m.toggleDisk(0).toggleDisk(1)
cfg := m.storage()
if len(cfg.Roles) != 0 {
t.Errorf("Roles = %v, want none on a pool", cfg.Roles)
}
if !slices.Equal(cfg.Paths(), []string{"a-disk", "b-disk"}) {
t.Errorf("Paths() = %v, want the selection order preserved", cfg.Paths())
}
}
// A three-drive selection looks like an array and is not one, so the mapping
// and the absence of redundancy both have to be on screen before the erase.
func TestConfirmScreenStatesTheLayoutAndTheRisk(t *testing.T) {
m := diskModel(3)
m.height = 40
for i := range 3 {
m = m.toggleDisk(i)
}
got := m.viewDiskConfirm(100)
for _, want := range []string{"/var/lib/spinifex/predastore", "c-disk", "losing one loses"} {
if !strings.Contains(got, want) {
t.Errorf("confirm screen is missing %q:\n%s", want, got)
}
}
}
-423
View File
@@ -1,423 +0,0 @@
package main
import (
"bufio"
"context"
"errors"
"fmt"
"log/slog"
"net/url"
"os"
"strconv"
"strings"
"time"
"github.com/mulgadc/spinifex/internal/gwsign"
"github.com/mulgadc/spinifex/internal/rdsgw"
handlers_rds "github.com/mulgadc/spinifex/spinifex/handlers/rds"
"github.com/mulgadc/spinifex/spinifex/otelsetup"
)
// Overridable via -ldflags "-X main.version=...".
var version = "dev"
// The gateway resolves the authoritative DB instance from the request's
// credentials, so DBInstanceIdentifier is an assertion to be checked, not
// trusted.
type identity struct {
DBInstanceIdentifier string
AgentVersion string
EngineVersion string
}
// Every method is a SigV4-signed Query request, so the NATS bus stays
// host-internal.
type controlPlane interface {
Register(ctx context.Context, id identity) (*handlers_rds.RegisterDBInstanceOutput, error)
SubmitState(ctx context.Context, id identity, health handlers_rds.EngineHealth, message string) (*handlers_rds.SubmitDBStateChangeOutput, error)
// Returns Mode=initialize with the master password for as long as a payload
// staged for this VM generation is unacknowledged, and Mode=attach after.
GetBootstrapConfig(ctx context.Context, id identity) (*handlers_rds.GetDBBootstrapConfigOutput, error)
// Reports that PostgreSQL durably applied the staged password, which is what
// lets the control plane destroy it.
AcknowledgeBootstrap(ctx context.Context, id identity, pending *pendingBootstrap) error
PollCommands(ctx context.Context, id identity, replies []handlers_rds.CommandReply, wait time.Duration) ([]handlers_rds.Command, error)
}
// Bounds a register, heartbeat or bootstrap request. The long poll sets its own
// deadline from the wait it asked the gateway to hold.
const callTimeout = 15 * time.Second
// Keeps the poll's deadline past the requested wait, so a gateway answering at
// the end of its window is not cut off by the client.
const pollSlack = 10 * time.Second
type gatewayControlPlane struct {
client *rdsgw.Client
}
var _ controlPlane = (*gatewayControlPlane)(nil)
// Signs with the instance-role credentials the SDK chain resolves from IMDS,
// against the pinned gateway CA.
func newGatewayControlPlane(cfg config) (*gatewayControlPlane, error) {
signer, err := gwsign.NewIMDS(context.Background(), cfg.Region)
if err != nil {
return nil, fmt.Errorf("build IMDS signer: %w", err)
}
// The client timeout is the ceiling for the longest call, the long poll;
// shorter calls carry their own deadline.
client, err := rdsgw.New(cfg.GatewayURL, cfg.GatewayCA, signer, cfg.Region, cfg.PollWait+pollSlack)
if err != nil {
return nil, err
}
return &gatewayControlPlane{client: client}, nil
}
func (g *gatewayControlPlane) Register(ctx context.Context, id identity) (*handlers_rds.RegisterDBInstanceOutput, error) {
params := url.Values{}
setIfPresent(params, "DBInstanceIdentifier", id.DBInstanceIdentifier)
setIfPresent(params, "AgentVersion", id.AgentVersion)
setIfPresent(params, "EngineVersion", id.EngineVersion)
ctx, cancel := context.WithTimeout(ctx, callTimeout)
defer cancel()
var out handlers_rds.RegisterDBInstanceOutput
if err := g.client.Call(ctx, "RegisterDBInstance", params, &out); err != nil {
return nil, err
}
return &out, nil
}
func (g *gatewayControlPlane) SubmitState(ctx context.Context, id identity, health handlers_rds.EngineHealth, message string) (*handlers_rds.SubmitDBStateChangeOutput, error) {
params := url.Values{"EngineHealth": {string(health)}}
setIfPresent(params, "DBInstanceIdentifier", id.DBInstanceIdentifier)
setIfPresent(params, "EngineVersion", id.EngineVersion)
setIfPresent(params, "Message", message)
ctx, cancel := context.WithTimeout(ctx, callTimeout)
defer cancel()
var out handlers_rds.SubmitDBStateChangeOutput
if err := g.client.Call(ctx, "SubmitDBStateChange", params, &out); err != nil {
return nil, err
}
return &out, nil
}
func (g *gatewayControlPlane) GetBootstrapConfig(ctx context.Context, id identity) (*handlers_rds.GetDBBootstrapConfigOutput, error) {
params := url.Values{}
setIfPresent(params, "DBInstanceIdentifier", id.DBInstanceIdentifier)
ctx, cancel := context.WithTimeout(ctx, callTimeout)
defer cancel()
var out handlers_rds.GetDBBootstrapConfigOutput
if err := g.client.Call(ctx, "GetDBBootstrapConfig", params, &out); err != nil {
return nil, err
}
return &out, nil
}
func (g *gatewayControlPlane) AcknowledgeBootstrap(ctx context.Context, id identity, pending *pendingBootstrap) error {
params := url.Values{
"PayloadId": {pending.payloadID},
"VMGeneration": {strconv.FormatInt(pending.vmGeneration, 10)},
}
setIfPresent(params, "DBInstanceIdentifier", id.DBInstanceIdentifier)
setIfPresent(params, "DataVolumeId", pending.dataVolumeID)
ctx, cancel := context.WithTimeout(ctx, callTimeout)
defer cancel()
var out handlers_rds.AcknowledgeDBBootstrapOutput
if err := g.client.Call(ctx, "AcknowledgeDBBootstrap", params, &out); err != nil {
return err
}
if !out.Acknowledged {
return fmt.Errorf("the control plane did not acknowledge bootstrap payload %s", pending.payloadID)
}
return nil
}
// Declared here so the in-guest binary does not link the server side of the API.
type pollDBCommandsOutput struct {
Commands []handlers_rds.Command `xml:"Commands>member"`
}
func (g *gatewayControlPlane) PollCommands(ctx context.Context, id identity, replies []handlers_rds.CommandReply, wait time.Duration) ([]handlers_rds.Command, error) {
params := url.Values{"WaitTimeSeconds": {strconv.FormatInt(int64(wait.Seconds()), 10)}}
setIfPresent(params, "DBInstanceIdentifier", id.DBInstanceIdentifier)
// Replies ride the poll rather than a separate action, so replying and
// asking for the next command are one round trip.
for i, reply := range replies {
member := fmt.Sprintf("Replies.member.%d.", i+1)
params.Set(member+"CommandId", reply.CommandID)
params.Set(member+"Status", reply.Status)
setIfPresent(params, member+"Message", reply.Message)
}
ctx, cancel := context.WithTimeout(ctx, wait+pollSlack)
defer cancel()
var out pollDBCommandsOutput
if err := g.client.Call(ctx, "PollDBCommands", params, &out); err != nil {
return nil, err
}
return out.Commands, nil
}
// Only sets a parameter that has a value, so the gateway can tell "no
// assertion" from "asserted as blank".
func setIfPresent(params url.Values, key, value string) {
if value != "" {
params.Set(key, value)
}
}
type Agent struct {
cfg config
id identity
cp controlPlane
probe *engineProbe
engine engine
handoffWriter func(string, *handlers_rds.GetDBBootstrapConfigOutput) error
dataMountWaiter func(context.Context, string, string) error
// Set by bootstrap when the fetch replayed a staged payload; nil leaves the
// acknowledgement step a no-op, which is every attach boot.
pending *pendingBootstrap
hb *heartbeater
cmd *commander
guard *paramGuard
}
// Assembles from already-built parts, so tests can pass fakes; New builds the
// production control plane and delegates here.
func newAgent(cfg config, cp controlPlane, probe *engineProbe) (*Agent, error) {
if cfg.MountsFile == "" {
cfg.MountsFile = defaultMountsFile
}
id := identity{
DBInstanceIdentifier: cfg.DBInstanceIdentifier,
AgentVersion: version,
EngineVersion: cfg.EngineVersion,
}
a := &Agent{
cfg: cfg, id: id, cp: cp, probe: probe,
handoffWriter: writeHandoff, dataMountWaiter: waitForDataMount,
}
eng, err := newEngine(cfg, execCommandRunner, execSessionRunner, probe)
if err != nil {
return nil, err
}
a.engine = eng
a.hb = newHeartbeater(cp, probe, a.engine, handlers_rds.HeartbeatInterval)
a.cmd = newCommander(cp, newCommandRegistry(a.engine, newGuestStorage(cfg, execCommandRunner)), cfg.PollWait)
a.guard = newParamGuard(a.engine, probe, cp)
return a, nil
}
// 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)")
}
cp, err := newGatewayControlPlane(cfg)
if err != nil {
return nil, err
}
probe, err := newProbe(cfg, execProbeRunner)
if err != nil {
return nil, err
}
return newAgent(cfg, cp, probe)
}
func (a *Agent) Run(ctx context.Context) error {
// Named before the first dial: when register cannot reach the gateway, the
// address it is dialing is what separates a mgmt NIC that came up without one
// from a control plane that is genuinely down.
slog.Info("rds-agent: starting",
"gatewayURL", a.cfg.GatewayURL, "dbInstanceIdentifier", a.id.DBInstanceIdentifier,
"agentVersion", a.id.AgentVersion)
// Registration comes first: its response is the agent's identity.
if err := a.register(ctx); err != nil {
return err
}
a.hb.id, a.cmd.id, a.guard.id = a.id, a.id, a.id
// Ahead of the handoff, so rds-init never unblocks and no datadir is touched.
// An agent running on the wrong image would initialise one engine over
// another's data, coming up healthy and empty beside data nothing references.
if err := a.refuseEngineMismatch(ctx); err != nil {
return err
}
// Beating before the bootstrap keeps a stuck boot visible as a live VM with
// a down engine rather than as silence.
go a.hb.Run(ctx)
if err := a.bootstrap(ctx); err != nil {
return err
}
// The handoff must land before this wait: rds-datadir needs it to identify
// and authorize the volume. Commands and the parameter guard wait because
// their durable state belongs on that mount, never on the disposable boot disk.
if err := a.dataMountWaiter(ctx, a.cfg.MountsFile, a.cfg.DataMount); err != nil {
// A shutdown cancels this wait; main treats the wrapped context.Canceled
// as a clean stop, exactly as it does for register and bootstrap above.
return fmt.Errorf("wait for data mount %s: %w", a.cfg.DataMount, err)
}
// The receipt it waits on lives on that mount, so this cannot run any earlier.
// A cancelled wait is a clean stop here too.
if err := a.acknowledgeBootstrap(ctx); err != nil {
return err
}
// Directives are only meaningful against a bootstrapped, mounted engine.
go a.cmd.Run(ctx)
// Started after the handoff, so the window it measures covers the engine's
// own start rather than the time rds-init spent waiting for this agent.
go a.guard.Run(ctx)
<-ctx.Done()
return nil
}
// The engine cloud-init says this VM was launched as, against the engine the
// image bakes. An empty expectation is no assertion rather than a disagreement:
// a VM launched before the control plane emitted RDS_ENGINE carries none.
func (a *Agent) refuseEngineMismatch(ctx context.Context) error {
expected := strings.ToLower(strings.TrimSpace(a.cfg.Engine))
if expected == "" || expected == a.cfg.BakedEngine {
return nil
}
message := fmt.Sprintf("this VM was launched as engine %q but its image bakes %q; refusing to bootstrap",
expected, a.cfg.BakedEngine)
slog.ErrorContext(ctx, "rds-agent: "+message)
// Reported before returning, so the control plane learns why the instance
// never came up rather than only that it never did.
if _, err := a.cp.SubmitState(ctx, a.id, handlers_rds.EngineHealthUnhealthy, message); err != nil {
slog.ErrorContext(ctx, "rds-agent: reporting the engine mismatch failed", "err", err)
}
return errors.New(message)
}
const dataMountPollInterval = time.Second
// waitForDataMount blocks the stateful agent loops until the kernel reports the
// configured data mount. Registration, heartbeat, and bootstrap run before it.
func waitForDataMount(ctx context.Context, mountsFile, target string) error {
ticker := time.NewTicker(dataMountPollInterval)
defer ticker.Stop()
for {
mounted, err := mountTableContains(mountsFile, target)
if err != nil {
slog.WarnContext(ctx, "rds-agent: could not read mount table while waiting for data volume",
"mountsFile", mountsFile, "dataMount", target, "err", err)
} else if mounted {
slog.InfoContext(ctx, "rds-agent: data volume mount is ready", "dataMount", target)
return nil
}
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
}
}
}
func mountTableContains(path, target string) (bool, error) {
f, err := os.Open(path)
if err != nil {
return false, err
}
defer f.Close()
scanner := bufio.NewScanner(f)
for scanner.Scan() {
fields := strings.Fields(scanner.Text())
if len(fields) >= 2 && unescapeMountField(fields[1]) == target {
return true, nil
}
}
if err := scanner.Err(); err != nil {
return false, err
}
return false, nil
}
// Linux mount tables encode whitespace and backslashes as octal escapes.
func unescapeMountField(field string) string {
replacer := strings.NewReplacer(`\134`, `\`, `\040`, " ", `\011`, "\t", `\012`, "\n")
return replacer.Replace(field)
}
// 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 (
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
)
// 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.
var errRetryTerminal = errors.New("terminal failure")
// Never gives up on its own: an agent that stopped trying would leave the
// control plane with no signal at all.
func retry(ctx context.Context, what string, fn func(context.Context) error) error {
return retryObserved(ctx, what, fn, nil)
}
// onFailure lets boot-critical callers publish the latest error while the retry
// remains blocked. It must not block because it runs on the retry path itself.
func retryObserved(ctx context.Context, what string, fn func(context.Context) error, onFailure func(error)) error {
delay := retryMin
for attempt := 1; ; attempt++ {
err := fn(ctx)
if err == nil {
return nil
}
if ctx.Err() != nil {
return fmt.Errorf("%s: %w", what, ctx.Err())
}
if onFailure != nil {
onFailure(err)
}
if errors.Is(err, errRetryTerminal) {
slog.ErrorContext(ctx, "rds-agent: "+what+" failed terminally, not retrying", "err", err)
return fmt.Errorf("%s: %w", what, err)
}
if attempt >= retryErrorAttempt {
slog.ErrorContext(ctx, "rds-agent: "+what+" still failing, retrying",
"attempt", attempt, "retry_in_ms", otelsetup.Millis(delay), "err", err)
} else {
slog.WarnContext(ctx, "rds-agent: "+what+" failed, retrying",
"attempt", attempt, "retry_in_ms", otelsetup.Millis(delay), "err", err)
}
select {
case <-ctx.Done():
return fmt.Errorf("%s: %w", what, ctx.Err())
case <-time.After(delay):
}
delay = min(delay*2, retryMax)
}
}
-873
View File
@@ -1,873 +0,0 @@
package main
import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"slices"
"strings"
"sync"
"testing"
"time"
"github.com/mulgadc/spinifex/internal/gwsign"
"github.com/mulgadc/spinifex/internal/rdsgw"
"github.com/mulgadc/spinifex/spinifex/awserrors"
gateway_rds "github.com/mulgadc/spinifex/spinifex/gateway/rds"
handlers_rds "github.com/mulgadc/spinifex/spinifex/handlers/rds"
"github.com/mulgadc/spinifex/spinifex/utils"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// fakeControlPlane is a scriptable controlPlane. Every call records what the
// agent sent, so a test asserts on the request as well as the reaction.
type fakeControlPlane struct {
mu sync.Mutex
registerOut *handlers_rds.RegisterDBInstanceOutput
registerErrs []error
registerReqs []identity
bootstrapOut *handlers_rds.GetDBBootstrapConfigOutput
bootstrapErrs []error
bootstrapReqs []identity
// Scripted responses ahead of bootstrapOut, so a test can answer the first
// fetch differently from the ones the agent retries with.
bootstrapQueue []*handlers_rds.GetDBBootstrapConfigOutput
ackErrs []error
ackReqs []pendingBootstrap
states []submittedState
pollReplies [][]handlers_rds.CommandReply
pollQueue [][]handlers_rds.Command
pollErr error
polled chan struct{}
}
type submittedState struct {
id identity
health handlers_rds.EngineHealth
message string
}
var _ controlPlane = (*fakeControlPlane)(nil)
func newFakeControlPlane() *fakeControlPlane {
return &fakeControlPlane{
registerOut: &handlers_rds.RegisterDBInstanceOutput{
DBInstanceIdentifier: "db-resolved", HeartbeatIntervalSeconds: 30,
},
polled: make(chan struct{}, 64),
}
}
// nextErr pops the next scripted error, so a test can fail a call n times and
// then let it succeed.
func nextErr(errs *[]error) error {
if len(*errs) == 0 {
return nil
}
err := (*errs)[0]
*errs = (*errs)[1:]
return err
}
func (f *fakeControlPlane) Register(_ context.Context, id identity) (*handlers_rds.RegisterDBInstanceOutput, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.registerReqs = append(f.registerReqs, id)
if err := nextErr(&f.registerErrs); err != nil {
return nil, err
}
return f.registerOut, nil
}
func (f *fakeControlPlane) SubmitState(_ context.Context, id identity, health handlers_rds.EngineHealth, message string) (*handlers_rds.SubmitDBStateChangeOutput, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.states = append(f.states, submittedState{id: id, health: health, message: message})
// Zero means "keep the cadence you have", so a test's fast interval is not
// reset by every beat.
return &handlers_rds.SubmitDBStateChangeOutput{Acknowledged: true}, nil
}
func (f *fakeControlPlane) GetBootstrapConfig(_ context.Context, id identity) (*handlers_rds.GetDBBootstrapConfigOutput, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.bootstrapReqs = append(f.bootstrapReqs, id)
if err := nextErr(&f.bootstrapErrs); err != nil {
return nil, err
}
if len(f.bootstrapQueue) > 0 {
out := f.bootstrapQueue[0]
f.bootstrapQueue = f.bootstrapQueue[1:]
return out, nil
}
return f.bootstrapOut, nil
}
func (f *fakeControlPlane) AcknowledgeBootstrap(_ context.Context, _ identity, pending *pendingBootstrap) error {
f.mu.Lock()
defer f.mu.Unlock()
f.ackReqs = append(f.ackReqs, *pending)
return nextErr(&f.ackErrs)
}
func (f *fakeControlPlane) PollCommands(ctx context.Context, _ identity, replies []handlers_rds.CommandReply, _ time.Duration) ([]handlers_rds.Command, error) {
f.mu.Lock()
f.pollReplies = append(f.pollReplies, slices.Clone(replies))
err := f.pollErr
var out []handlers_rds.Command
if len(f.pollQueue) > 0 {
out, f.pollQueue = f.pollQueue[0], f.pollQueue[1:]
}
f.mu.Unlock()
select {
case f.polled <- struct{}{}:
default:
}
if err != nil {
return nil, err
}
if len(out) > 0 {
return out, nil
}
// An idle window: block until the agent hangs up, as the gateway's long poll
// does, rather than spinning the caller's loop.
<-ctx.Done()
return nil, ctx.Err()
}
func (f *fakeControlPlane) ackRequests() []pendingBootstrap {
f.mu.Lock()
defer f.mu.Unlock()
return slices.Clone(f.ackReqs)
}
func (f *fakeControlPlane) snapshotStates() []submittedState {
f.mu.Lock()
defer f.mu.Unlock()
return append([]submittedState(nil), f.states...)
}
func (f *fakeControlPlane) snapshotReplies() [][]handlers_rds.CommandReply {
f.mu.Lock()
defer f.mu.Unlock()
out := make([][]handlers_rds.CommandReply, len(f.pollReplies))
for i := range f.pollReplies {
out[i] = slices.Clone(f.pollReplies[i])
}
return out
}
// testConfig stamps the engine a real rds-postgres image bakes, so the agent
// resolves the same guest layout it would in a guest, and points its file output
// at a temp dir.
func testConfig(t *testing.T) config {
t.Helper()
cfg := testLoadConfig(t, enginePostgres)
cfg.GatewayURL = "https://gw.test"
cfg.HandoffDir = filepath.Join(t.TempDir(), "spinifex-rds")
cfg.PollWait = time.Second
return cfg
}
// newTestAgent builds the agent over a probe the test drives directly.
func newTestAgent(t *testing.T, cfg config, cp controlPlane, run probeRunner) *Agent {
t.Helper()
a, err := newAgent(cfg, cp, newPostgresProbe(cfg, run))
if err != nil {
t.Fatalf("newAgent: %v", err)
}
return a
}
// staticProbe is a probeRunner that always reports the same exit code.
func staticProbe(code int) probeRunner {
return func(context.Context, string, ...string) (int, string, error) { return code, "", nil }
}
func bootstrapOutput(password *string) *handlers_rds.GetDBBootstrapConfigOutput {
mode := handlers_rds.BootstrapModeAttach
if password != nil {
mode = handlers_rds.BootstrapModeInitialize
}
return &handlers_rds.GetDBBootstrapConfigOutput{
Mode: mode,
DBInstanceIdentifier: "db-resolved",
Engine: "postgres",
MasterUsername: "master",
MasterUserPassword: password,
Port: 5432,
ListenAddress: testListenAddress,
ClientCIDR: testClientCIDR,
DataVolumeID: "vol-data-01",
DataVolumeSerial: "voldata01",
VMGeneration: 1,
PayloadID: testPayloadID,
BootstrapPending: password != nil,
}
}
const testPayloadID = "bp-0123456789abcdef"
// The customer ENI address the engine binds, and the VPC range its client
// authentication admits. writeHandoff refuses a config without either.
const (
testListenAddress = "10.0.1.20"
testClientCIDR = "10.0.0.0/16"
)
// Writes the completion receipt rds-init leaves behind, so the acknowledgement
// step finds what a finished initialization would have produced.
func writeReceipt(t *testing.T, a *Agent, payloadID, dbInstanceIdentifier string) {
t.Helper()
path := a.receiptPath()
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
t.Fatalf("mkdir receipt dir: %v", err)
}
body := fmt.Sprintf("%s=%s\n%s=%s\n", receiptPayload, payloadID, receiptDBID, dbInstanceIdentifier)
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatalf("write receipt: %v", err)
}
}
// runAgent runs the agent until its bootstrap has landed, then cancels. It
// returns once Run has exited so no goroutine outlives the test.
func runAgent(t *testing.T, a *Agent) error {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
done := make(chan error, 1)
go func() { done <- a.Run(ctx) }()
waitFor(t, func() bool {
_, err := os.Stat(filepath.Join(a.cfg.HandoffDir, handoffEnvFile))
return err == nil
}, "handoff file to be written")
cancel()
select {
case err := <-done:
// The cancellation above is this helper's own, so Run reporting it is the
// expected clean stop rather than a failure, exactly as main treats it.
if errors.Is(err, context.Canceled) {
return nil
}
return err
case <-time.After(5 * time.Second):
t.Fatal("Run did not exit after cancellation")
return nil
}
}
// waitFor polls cond until it holds or the test times out.
func waitFor(t *testing.T, cond func() bool, what string) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
if cond() {
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatalf("timed out waiting for %s", what)
}
func TestRun_RegistersThenDeliversBootstrap(t *testing.T) {
password := "s3cret"
cp := newFakeControlPlane()
cp.bootstrapOut = bootstrapOutput(&password)
cfg := testConfig(t)
a := newTestAgent(t, cfg, cp, staticProbe(0))
if err := runAgent(t, a); err != nil {
t.Fatalf("Run: %v", err)
}
if len(cp.registerReqs) != 1 {
t.Fatalf("registered %d times, want 1", len(cp.registerReqs))
}
// The agent had no configured identifier, so the gateway's resolution is
// what every later call must carry.
if got := cp.bootstrapReqs[0].DBInstanceIdentifier; got != "db-resolved" {
t.Errorf("bootstrap sent identifier %q, want the registered db-resolved", got)
}
env := readFile(t, filepath.Join(cfg.HandoffDir, handoffEnvFile))
if !strings.Contains(env, "RDS_MODE='initialize'") || !strings.Contains(env, "RDS_MASTER_PASSWORD='s3cret'") {
t.Errorf("handoff env = %q, want initialize mode carrying the password", env)
}
// The probe follows the port the control plane assigned, not the default.
if got := a.probe.port.Load(); got != 5432 {
t.Errorf("probe port = %d, want 5432", got)
}
}
// A reboot re-fetches in attach mode. The agent must write that payload as it
// stands rather than refusing it or inventing a password.
func TestRun_AttachModeNeedsNoPassword(t *testing.T) {
cp := newFakeControlPlane()
cp.bootstrapOut = bootstrapOutput(nil)
cfg := testConfig(t)
if err := runAgent(t, newTestAgent(t, cfg, cp, staticProbe(0))); err != nil {
t.Fatalf("Run: %v", err)
}
env := readFile(t, filepath.Join(cfg.HandoffDir, handoffEnvFile))
if !strings.Contains(env, "RDS_MODE='attach'") {
t.Errorf("handoff env = %q, want attach mode", env)
}
if strings.Contains(env, "RDS_MASTER_PASSWORD") {
t.Errorf("handoff env = %q, want no password key at all in attach mode", env)
}
}
// The record may not exist yet when a freshly launched VM first calls, so both
// boot-critical calls have to ride out failures instead of exiting.
func TestRun_RetriesRegisterAndBootstrap(t *testing.T) {
cp := newFakeControlPlane()
cp.bootstrapOut = bootstrapOutput(nil)
cp.registerErrs = []error{errors.New("DBInstanceNotFound")}
cp.bootstrapErrs = []error{errors.New("ServerInternal")}
cfg := testConfig(t)
if err := runAgent(t, newTestAgent(t, cfg, cp, staticProbe(0))); err != nil {
t.Fatalf("Run: %v", err)
}
if len(cp.registerReqs) != 2 {
t.Errorf("register attempts = %d, want 2 (one failure, one success)", len(cp.registerReqs))
}
if len(cp.bootstrapReqs) != 2 {
t.Errorf("bootstrap attempts = %d, want 2 (one failure, one success)", len(cp.bootstrapReqs))
}
}
// The fetch mutates nothing, so a handoff that cannot be written is retried
// against the payload already in hand rather than costing a second fetch.
func TestBootstrap_RetriesHandoffWithoutRefetching(t *testing.T) {
cp := newFakeControlPlane()
password := "staged-secret"
cp.bootstrapOut = bootstrapOutput(&password)
cfg := testConfig(t)
a := newTestAgent(t, cfg, cp, staticProbe(0))
writes := 0
a.handoffWriter = func(_ string, got *handlers_rds.GetDBBootstrapConfigOutput) error {
writes++
if got.MasterUserPassword == nil || *got.MasterUserPassword != password {
t.Fatalf("handoff attempt %d lost the initialize password", writes)
}
if writes == 1 {
return errors.New("temporary tmpfs write error")
}
return nil
}
if err := a.bootstrap(t.Context()); err != nil {
t.Fatalf("bootstrap: %v", err)
}
if len(cp.bootstrapReqs) != 1 {
t.Errorf("bootstrap fetches = %d, want 1", len(cp.bootstrapReqs))
}
if writes != 2 {
t.Errorf("handoff writes = %d, want 2", writes)
}
if a.pending == nil || a.pending.payloadID != testPayloadID {
t.Errorf("pending bootstrap = %+v, want the staged payload %s", a.pending, testPayloadID)
}
}
// A daemon that predates the encrypted payload answers initialize with nothing
// to initialise with. Writing that handoff would let rds-init run initdb with an
// empty master password, so it is retried until an upgraded node answers.
func TestBootstrap_RetriesInitializeWithNoPassword(t *testing.T) {
cp := newFakeControlPlane()
empty := ""
password := "staged-secret"
cp.bootstrapQueue = []*handlers_rds.GetDBBootstrapConfigOutput{{
Mode: handlers_rds.BootstrapModeInitialize, DBInstanceIdentifier: "db-resolved",
MasterUsername: "master", MasterUserPassword: &empty,
}}
cp.bootstrapOut = bootstrapOutput(&password)
cfg := testConfig(t)
a := newTestAgent(t, cfg, cp, staticProbe(0))
var handed []*handlers_rds.GetDBBootstrapConfigOutput
a.handoffWriter = func(_ string, got *handlers_rds.GetDBBootstrapConfigOutput) error {
handed = append(handed, got)
return nil
}
if err := a.bootstrap(t.Context()); err != nil {
t.Fatalf("bootstrap: %v", err)
}
if len(handed) != 1 {
t.Fatalf("handoff writes = %d, want 1 once a usable payload arrived", len(handed))
}
if handed[0].MasterUserPassword == nil || *handed[0].MasterUserPassword != password {
t.Error("the agent wrote a handoff for a fetch that carried no master password")
}
}
func TestAcknowledgeBootstrap_WaitsForTheReceiptThenRetiresIt(t *testing.T) {
cp := newFakeControlPlane()
cfg := testConfig(t)
cfg.DataMount = t.TempDir()
a := newTestAgent(t, cfg, cp, staticProbe(0))
a.id = identity{DBInstanceIdentifier: "db-resolved"}
a.pending = &pendingBootstrap{payloadID: testPayloadID, vmGeneration: 1, dataVolumeID: "vol-data-01"}
// rds-init runs after the agent registers, so the receipt is absent when the
// acknowledgement step first looks and appears some way into the boot.
if err := a.checkBootstrapReceipt(a.pending); err == nil {
t.Fatal("a missing receipt must not read as a completed bootstrap")
}
writeReceipt(t, a, testPayloadID, "db-resolved")
if err := a.acknowledgeBootstrap(t.Context()); err != nil {
t.Fatalf("acknowledgeBootstrap: %v", err)
}
if len(cp.ackRequests()) != 1 {
t.Fatalf("acknowledgements = %d, want 1", len(cp.ackRequests()))
}
if got := cp.ackRequests()[0]; got != *a.pending {
t.Errorf("acknowledged %+v, want %+v", got, *a.pending)
}
if _, err := os.Stat(a.receiptPath()); !os.IsNotExist(err) {
t.Error("the receipt must be removed once the record holds the audit trail")
}
}
// The receipt rides along in every snapshot of the data volume, so a restored
// instance's volume carries the source instance's receipt. Acknowledging on one
// would destroy the ciphertext this instance still needs.
func TestAcknowledgeBootstrap_IgnoresAForeignReceipt(t *testing.T) {
tests := map[string]struct{ payloadID, dbID string }{
"another payload": {payloadID: "bp-somebody-else", dbID: "db-resolved"},
"another instance": {payloadID: testPayloadID, dbID: "db-the-snapshot-source"},
}
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
cp := newFakeControlPlane()
cfg := testConfig(t)
cfg.DataMount = t.TempDir()
a := newTestAgent(t, cfg, cp, staticProbe(0))
a.id = identity{DBInstanceIdentifier: "db-resolved"}
a.pending = &pendingBootstrap{payloadID: testPayloadID, vmGeneration: 1}
writeReceipt(t, a, tc.payloadID, tc.dbID)
ctx, cancel := context.WithTimeout(t.Context(), 50*time.Millisecond)
defer cancel()
if err := a.acknowledgeBootstrap(ctx); err == nil {
t.Fatal("acknowledgeBootstrap returned nil, want it still waiting on a real receipt")
}
if n := len(cp.ackRequests()); n != 0 {
t.Errorf("acknowledgements = %d, want 0", n)
}
})
}
}
// An attach boot has nothing staged, so it must not go looking for a receipt the
// datadir will never grow.
func TestAcknowledgeBootstrap_IsANoOpOnAttach(t *testing.T) {
cp := newFakeControlPlane()
cfg := testConfig(t)
cfg.DataMount = t.TempDir()
a := newTestAgent(t, cfg, cp, staticProbe(0))
if err := a.acknowledgeBootstrap(t.Context()); err != nil {
t.Fatalf("acknowledgeBootstrap: %v", err)
}
if n := len(cp.ackRequests()); n != 0 {
t.Errorf("acknowledgements = %d, want 0 when nothing was staged", n)
}
}
// The image is the authority on which engine the agent runs. A VM launched as
// another engine has to stop ahead of the handoff: rds-init would otherwise
// initialise one engine over the other's datadir and come up healthy and empty
// beside data nothing references.
func TestRun_RefusesAnEngineTheImageDoesNotBake(t *testing.T) {
cp := newFakeControlPlane()
cp.bootstrapOut = bootstrapOutput(nil)
cfg := testConfig(t)
cfg.Engine = "mariadb"
a := newTestAgent(t, cfg, cp, staticProbe(0))
err := a.Run(t.Context())
if err == nil {
t.Fatal("Run returned nil for a VM launched as an engine this image does not bake")
}
if !strings.Contains(err.Error(), "mariadb") {
t.Errorf("error = %v, want it to name the engine the VM was launched as", err)
}
if _, statErr := os.Stat(filepath.Join(cfg.HandoffDir, handoffEnvFile)); !os.IsNotExist(statErr) {
t.Error("the agent wrote a bootstrap handoff for an engine it refused to run")
}
if n := len(cp.bootstrapReqs); n != 0 {
t.Errorf("bootstrap fetches = %d, want none before the refusal", n)
}
// Reported rather than only logged, so the control plane learns why the
// instance never came up rather than only that it never did.
states := cp.snapshotStates()
if len(states) != 1 || states[0].health != handlers_rds.EngineHealthUnhealthy {
t.Fatalf("submitted states = %+v, want a single unhealthy report", states)
}
}
// The same assertion when it agrees is not a gate: the boot goes on exactly as
// it does for a VM launched before the control plane sent one at all.
func TestRun_ProceedsWhenTheDeliveredEngineMatchesTheImage(t *testing.T) {
cp := newFakeControlPlane()
cp.bootstrapOut = bootstrapOutput(nil)
cfg := testConfig(t)
cfg.Engine = enginePostgres
if err := runAgent(t, newTestAgent(t, cfg, cp, staticProbe(0))); err != nil {
t.Fatalf("Run: %v", err)
}
}
// A configured identifier is asserted on the wire so a mis-provisioned VM is
// rejected by the gateway instead of adopting whatever it resolves to.
func TestRun_SendsConfiguredIdentifier(t *testing.T) {
cp := newFakeControlPlane()
cp.bootstrapOut = bootstrapOutput(nil)
cfg := testConfig(t)
cfg.DBInstanceIdentifier = "db-configured"
cfg.EngineVersion = "18.1"
if err := runAgent(t, newTestAgent(t, cfg, cp, staticProbe(0))); err != nil {
t.Fatalf("Run: %v", err)
}
sent := cp.registerReqs[0]
if sent.DBInstanceIdentifier != "db-configured" || sent.EngineVersion != "18.1" {
t.Errorf("register sent %+v, want the configured identifier and engine version", sent)
}
}
func TestRun_WaitsForDataMountBeforePollingCommands(t *testing.T) {
cp := newFakeControlPlane()
cp.bootstrapOut = bootstrapOutput(nil)
cfg := testConfig(t)
a := newTestAgent(t, cfg, cp, staticProbe(2))
waiting := make(chan struct{})
release := make(chan struct{})
a.dataMountWaiter = func(ctx context.Context, mountsFile, target string) error {
if mountsFile != defaultMountsFile || target != cfg.DataMount {
t.Errorf("mount waiter got %q/%q, want configured defaults", mountsFile, target)
}
close(waiting)
select {
case <-ctx.Done():
return ctx.Err()
case <-release:
return nil
}
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
done := make(chan error, 1)
go func() { done <- a.Run(ctx) }()
select {
case <-waiting:
case <-ctx.Done():
t.Fatal("agent did not reach the data-mount wait after writing the handoff")
}
if _, err := os.Stat(filepath.Join(cfg.HandoffDir, handoffEnvFile)); err != nil {
t.Fatalf("handoff was not present before mount wait: %v", err)
}
select {
case <-cp.polled:
t.Fatal("command poll started before the data volume was mounted")
case <-time.After(50 * time.Millisecond):
}
close(release)
select {
case <-cp.polled:
case <-ctx.Done():
t.Fatal("command poll did not start after the data mount became ready")
}
cancel()
if err := <-done; err != nil {
t.Fatalf("Run: %v", err)
}
}
func TestMountTableContains(t *testing.T) {
mounts := filepath.Join(t.TempDir(), "mounts")
require.NoError(t, os.WriteFile(mounts, []byte(
"/dev/vda1 / ext4 rw 0 0\n/dev/vdb /var/lib/postgresql ext4 rw 0 0\n"+
"/dev/vdc /path\\040with\\040spaces xfs rw 0 0\nmalformed\n"), 0o600))
tests := []struct {
target string
want bool
}{
{target: "/var/lib/postgresql", want: true},
{target: "/path with spaces", want: true},
{target: "/missing", want: false},
}
for _, tc := range tests {
t.Run(tc.target, func(t *testing.T) {
got, err := mountTableContains(mounts, tc.target)
require.NoError(t, err)
assert.Equal(t, tc.want, got)
})
}
}
func TestRun_HeartbeatIncludesBootstrapFailure(t *testing.T) {
cp := newFakeControlPlane()
cp.bootstrapErrs = []error{errors.New("403 AccessDenied: instance profile is not authorised")}
cp.registerOut.HeartbeatIntervalSeconds = 0
cfg := testConfig(t)
a := newTestAgent(t, cfg, cp, staticProbe(2))
a.hb.interval = 10 * time.Millisecond
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
done := make(chan error, 1)
go func() { done <- a.Run(ctx) }()
waitFor(t, func() bool {
for _, state := range cp.snapshotStates() {
if strings.Contains(state.message, "bootstrap fetch failed") &&
strings.Contains(state.message, "403 AccessDenied") {
return true
}
}
return false
}, "the bootstrap failure to reach a heartbeat")
cancel()
<-done
}
func TestRun_HeartbeatsEngineHealth(t *testing.T) {
cp := newFakeControlPlane()
cp.bootstrapOut = bootstrapOutput(nil)
// Declining to set a cadence leaves the agent's own, which the test shortens
// rather than waiting out the 30s production interval.
cp.registerOut.HeartbeatIntervalSeconds = 0
cfg := testConfig(t)
a := newTestAgent(t, cfg, cp, staticProbe(0))
a.hb.interval = 10 * time.Millisecond
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
done := make(chan error, 1)
go func() { done <- a.Run(ctx) }()
waitFor(t, func() bool { return len(cp.snapshotStates()) > 0 }, "a heartbeat")
cancel()
<-done
beat := cp.snapshotStates()[0]
if beat.health != handlers_rds.EngineHealthHealthy {
t.Errorf("heartbeat health = %q, want healthy", beat.health)
}
if beat.id.DBInstanceIdentifier != "db-resolved" {
t.Errorf("heartbeat identifier = %q, want db-resolved", beat.id.DBInstanceIdentifier)
}
}
// The cadence is the control plane's to set: it feeds the staleness window the
// recovery reconciler judges an instance against, so the guest adopts it rather
// than keeping its own.
func TestRegister_AdoptsControlPlaneCadence(t *testing.T) {
cp := newFakeControlPlane()
cp.bootstrapOut = bootstrapOutput(nil)
cp.registerOut.HeartbeatIntervalSeconds = 45
cfg := testConfig(t)
a := newTestAgent(t, cfg, cp, staticProbe(0))
if err := runAgent(t, a); err != nil {
t.Fatalf("Run: %v", err)
}
if a.hb.interval != 45*time.Second {
t.Errorf("heartbeat interval = %v, want the 45s the control plane returned", a.hb.interval)
}
}
// The agent being up says nothing about the engine. A down engine has to reach
// the control plane as such, or auto-recovery has nothing to act on.
func TestRun_HeartbeatReportsDownEngine(t *testing.T) {
cp := newFakeControlPlane()
cp.bootstrapOut = bootstrapOutput(nil)
cp.registerOut.HeartbeatIntervalSeconds = 0
cfg := testConfig(t)
probe := newPostgresProbe(cfg, staticProbe(2))
// The engine has served before, so a silent one now is a failure rather
// than a boot still in progress.
probe.seenHealthy = true
a, err := newAgent(cfg, cp, probe)
if err != nil {
t.Fatalf("newAgent: %v", err)
}
a.hb.interval = 10 * time.Millisecond
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
done := make(chan error, 1)
go func() { done <- a.Run(ctx) }()
waitFor(t, func() bool { return len(cp.snapshotStates()) > 0 }, "a heartbeat")
cancel()
<-done
beat := cp.snapshotStates()[0]
if beat.health != handlers_rds.EngineHealthUnhealthy {
t.Errorf("heartbeat health = %q, want unhealthy while the agent is up", beat.health)
}
if beat.message == "" {
t.Error("unhealthy heartbeat carried no message explaining the probe result")
}
}
// The mirror the agent decodes poll responses into has to stay aligned with the
// gateway type that produces them; nothing else in the build ties the two.
func TestPollDBCommandsOutput_MatchesGatewayShape(t *testing.T) {
issued := time.Date(2026, 7, 28, 1, 2, 3, 0, time.UTC)
// Rendered exactly as the gateway's typed adapter renders it, so a change to
// either side's tags fails here.
payload := utils.GenerateIAMXMLPayload("PollDBCommands", &gateway_rds.PollDBCommandsOutput{
Commands: []handlers_rds.Command{{
CommandID: "cmd-1",
Type: "reload-parameters",
Parameters: []handlers_rds.Parameter{{Name: "work_mem", Value: "8MB"}},
IssuedAt: &issued,
}},
})
body, err := utils.MarshalToXML(payload)
if err != nil {
t.Fatalf("marshal poll result: %v", err)
}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/xml")
_, _ = w.Write(body)
}))
defer srv.Close()
client, err := rdsgw.New(srv.URL, "", gwsign.NewStatic("AKIATEST", "secret"), "us-east-1", time.Second)
if err != nil {
t.Fatalf("rdsgw.New: %v", err)
}
var got pollDBCommandsOutput
if err := client.Call(context.Background(), "PollDBCommands", nil, &got); err != nil {
t.Fatalf("Call: %v", err)
}
if len(got.Commands) != 1 {
t.Fatalf("decoded %d commands, want 1", len(got.Commands))
}
cmd := got.Commands[0]
if cmd.CommandID != "cmd-1" || cmd.Type != "reload-parameters" {
t.Errorf("command = %+v, want cmd-1/reload-parameters", cmd)
}
if len(cmd.Parameters) != 1 || cmd.Parameters[0].Value != "8MB" {
t.Errorf("Parameters = %+v, want the seeded member", cmd.Parameters)
}
if cmd.IssuedAt == nil || !cmd.IssuedAt.Equal(issued) {
t.Errorf("IssuedAt = %v, want %v", cmd.IssuedAt, issued)
}
}
func readFile(t *testing.T, path string) string {
t.Helper()
body, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
return string(body)
}
// AccessDenied is decided by the control plane's own record, so no retry can
// change it. Looping on one would leave the command poller and the parameter
// guard unstarted for the life of a VM whose engine is serving normally.
func TestAcknowledgeBootstrap_StopsOnATerminalDenial(t *testing.T) {
cp := newFakeControlPlane()
cp.ackErrs = []error{&rdsgw.APIError{
Action: "AcknowledgeDBBootstrap", StatusCode: 403,
Code: awserrors.ErrorAccessDenied,
Message: "bootstrap payload bp-x at generation 1 does not match the current state",
}}
cfg := testConfig(t)
cfg.DataMount = t.TempDir()
a := newTestAgent(t, cfg, cp, staticProbe(0))
a.id = identity{DBInstanceIdentifier: "db-resolved"}
a.pending = &pendingBootstrap{payloadID: testPayloadID, vmGeneration: 1}
writeReceipt(t, a, testPayloadID, "db-resolved")
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
defer cancel()
if err := a.acknowledgeBootstrap(ctx); err != nil {
t.Fatalf("acknowledgeBootstrap = %v, want nil so the stateful loops still start", err)
}
if n := len(cp.ackRequests()); n != 1 {
t.Errorf("acknowledgements = %d, want 1 attempt and no retry", n)
}
if a.hb.bootstrapFailure.Load() == nil {
t.Error("a terminal denial must still reach the operator through the heartbeat")
}
if _, err := os.Stat(a.receiptPath()); err != nil {
t.Error("a denied acknowledgement must leave the receipt for a later attempt")
}
}
// rds-init runs after this agent, so the receipt is absent for the whole initdb
// window. Reporting that would put a failure on every healthy create's beat, and
// then into the reason a create that timed out for some other cause records.
func TestAcknowledgeBootstrap_DoesNotReportAnAbsentReceiptAsAFailure(t *testing.T) {
cp := newFakeControlPlane()
cfg := testConfig(t)
cfg.DataMount = t.TempDir()
a := newTestAgent(t, cfg, cp, staticProbe(0))
a.id = identity{DBInstanceIdentifier: "db-resolved"}
a.pending = &pendingBootstrap{payloadID: testPayloadID, vmGeneration: 1}
ctx, cancel := context.WithTimeout(t.Context(), 50*time.Millisecond)
defer cancel()
if err := a.acknowledgeBootstrap(ctx); err == nil {
t.Fatal("acknowledgeBootstrap returned nil, want it still waiting for rds-init")
}
if got := a.hb.bootstrapFailure.Load(); got != nil {
t.Errorf("bootstrap failure = %q, want none while the receipt has simply not been written", *got)
}
// A receipt that exists but names another instance is a real mismatch and is
// reported, which is what separates the two cases.
writeReceipt(t, a, testPayloadID, "db-the-snapshot-source")
mismatch, cancelMismatch := context.WithTimeout(t.Context(), 50*time.Millisecond)
defer cancelMismatch()
if err := a.acknowledgeBootstrap(mismatch); err == nil {
t.Fatal("a foreign receipt must not be acknowledged")
}
if a.hb.bootstrapFailure.Load() == nil {
t.Error("a receipt naming another instance must be reported")
}
}
-237
View File
@@ -1,237 +0,0 @@
package main
import (
"context"
"fmt"
"log/slog"
"os"
"path/filepath"
"strconv"
"strings"
handlers_rds "github.com/mulgadc/spinifex/spinifex/handlers/rds"
)
// The staged payload this boot has to acknowledge once PostgreSQL has applied
// it. Nil on an attach boot, which makes the acknowledgement step a no-op.
type pendingBootstrap struct {
payloadID string
vmGeneration int64
dataVolumeID string
}
// Fetches the boot material and writes the handoff rds-init is blocked waiting
// for, then points the health probe at the assigned port.
func (a *Agent) bootstrap(ctx context.Context) error {
var cfg *handlers_rds.GetDBBootstrapConfigOutput
if err := retryObserved(ctx, "bootstrap fetch", func(ctx context.Context) error {
fetched, err := a.cp.GetBootstrapConfig(ctx, a.id)
if err != nil {
return err
}
if fetched == nil {
return fmt.Errorf("bootstrap fetch returned no config")
}
// A control plane predating the encrypted payload answers initialize with
// nothing to initialise with. Retrying re-dials the queue group until an
// upgraded node answers, so a mixed-version fleet heals within one boot.
if fetched.Mode == handlers_rds.BootstrapModeInitialize &&
(fetched.MasterUserPassword == nil || *fetched.MasterUserPassword == "") {
return fmt.Errorf("bootstrap fetch returned mode=%s with no master password", fetched.Mode)
}
cfg = fetched
return nil
}, func(err error) {
a.hb.setBootstrapFailure("bootstrap fetch", err)
}); err != nil {
return err
}
a.hb.clearBootstrapFailure()
// The fetch mutates nothing, so a handoff that cannot be written is retried
// against the same staged payload rather than against an attach response.
if err := retryObserved(ctx, "bootstrap handoff", func(context.Context) error {
return a.handoffWriter(a.cfg.HandoffDir, cfg)
}, func(err error) {
a.hb.setBootstrapFailure("bootstrap handoff", err)
}); err != nil {
return err
}
a.hb.clearBootstrapFailure()
if cfg.Port > 0 {
a.probe.setPort(int(cfg.Port))
}
if cfg.BootstrapPending && cfg.PayloadID != "" {
a.pending = &pendingBootstrap{
payloadID: cfg.PayloadID,
vmGeneration: cfg.VMGeneration,
dataVolumeID: cfg.DataVolumeID,
}
}
// Mode is logged but never branched on: rds-init decides whether to initdb
// from the state of the datadir.
slog.Info("rds-agent: bootstrap config delivered",
"mode", cfg.Mode, "port", cfg.Port, "parameters", len(cfg.Parameters),
"bootstrapPending", cfg.BootstrapPending)
return nil
}
// The handoff files rds-init reads. They live on tmpfs, so nothing survives a
// reboot and the next boot re-fetches rather than reusing a stale password or a
// re-minted cert.
const (
handoffEnvFile = "bootstrap.env"
handoffParamsFile = "parameters.conf"
handoffCertFile = "server.crt"
handoffKeyFile = "server.key"
// Root-only: only rds-init reads these, and one holds the master password.
handoffMode = 0o600
handoffDirMode = 0o700
)
// bootstrap.env is written last and renamed into place, so its appearance —
// which rds-init waits on — means the whole handoff is complete.
func writeHandoff(dir string, cfg *handlers_rds.GetDBBootstrapConfigOutput) error {
if cfg.MasterUsername == "" {
return fmt.Errorf("bootstrap config carries no master username")
}
// Half a handoff would leave rds-init deciding what to bind and who to admit,
// and the only answers it could reach on its own are the wildcards this
// contract exists to remove.
if cfg.ListenAddress == "" {
return fmt.Errorf("bootstrap config carries no listen address for the engine to bind")
}
if cfg.ClientCIDR == "" {
return fmt.Errorf("bootstrap config carries no client CIDR to scope client authentication to")
}
if err := os.MkdirAll(dir, handoffDirMode); err != nil {
return fmt.Errorf("create handoff dir %s: %w", dir, err)
}
// MkdirAll leaves an existing directory's mode alone, and this one may have
// been created more permissively earlier in the boot.
if err := os.Chmod(dir, handoffDirMode); err != nil {
return fmt.Errorf("secure handoff dir %s: %w", dir, err)
}
params, err := renderParameters(cfg.Engine, cfg.Parameters)
if err != nil {
return err
}
if err := writeHandoffFile(dir, handoffParamsFile, params); err != nil {
return err
}
// Cert and key are written as a pair; half of one would start the engine
// with TLS configured against a key it does not have.
if cfg.ServingCertificate != "" && cfg.ServingPrivateKey != "" {
if err := writeHandoffFile(dir, handoffCertFile, cfg.ServingCertificate); err != nil {
return err
}
if err := writeHandoffFile(dir, handoffKeyFile, cfg.ServingPrivateKey); err != nil {
return err
}
}
return writeHandoffFile(dir, handoffEnvFile, renderBootstrapEnv(cfg))
}
// Every value is single-quoted because rds-init sources this with `.`: an
// unquoted password holding a space, `$` or `;` would be word-split or executed.
func renderBootstrapEnv(cfg *handlers_rds.GetDBBootstrapConfigOutput) string {
var b strings.Builder
b.WriteString("# Written by rds-agent. Regenerated on every boot; edits are lost.\n")
writeEnvLine(&b, "RDS_MODE", cfg.Mode)
writeEnvLine(&b, "RDS_DB_INSTANCE_IDENTIFIER", cfg.DBInstanceIdentifier)
writeEnvLine(&b, "RDS_MASTER_USERNAME", cfg.MasterUsername)
writeEnvLine(&b, "RDS_LISTEN_ADDRESS", cfg.ListenAddress)
writeEnvLine(&b, "RDS_CLIENT_CIDR", cfg.ClientCIDR)
writeEnvLine(&b, "RDS_DATA_VOLUME_ID", cfg.DataVolumeID)
writeEnvLine(&b, "RDS_DATA_VOLUME_SERIAL", cfg.DataVolumeSerial)
writeEnvLine(&b, "RDS_VM_GENERATION", strconv.FormatInt(cfg.VMGeneration, 10))
writeEnvLine(&b, "RDS_FORMAT_AUTHORIZED", strconv.FormatBool(cfg.FormatAuthorized))
// Neither needs volume access, which is why the assertion is made here: the
// agent runs before rds-datadir mounts the volume, so rds-init is the first
// component that can compare it against the completion receipt on disk.
writeEnvLine(&b, "RDS_BOOTSTRAP_PENDING", boolFlag(cfg.BootstrapPending))
writeEnvLine(&b, "RDS_PAYLOAD_ID", cfg.PayloadID)
// Present only in initialize mode: an attach fetch has no password to write
// and rds-init must not find a stale one.
if cfg.MasterUserPassword != nil {
writeEnvLine(&b, "RDS_MASTER_PASSWORD", *cfg.MasterUserPassword)
}
if cfg.DBName != "" {
writeEnvLine(&b, "RDS_DB_NAME", cfg.DBName)
}
if cfg.Port > 0 {
writeEnvLine(&b, "RDS_PORT", strconv.FormatInt(cfg.Port, 10))
}
return b.String()
}
// rds-init compares against 1 rather than sourcing a Go boolean, so the two
// halves of the guard cannot drift on spelling.
func boolFlag(v bool) string {
if v {
return "1"
}
return "0"
}
func writeEnvLine(b *strings.Builder, key, value string) {
b.WriteString(key)
b.WriteString("=")
b.WriteString(shellQuote(value))
b.WriteString("\n")
}
// Single quotes suppress every shell expansion; an embedded quote is closed,
// escaped and reopened.
func shellQuote(s string) string {
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
}
// postgresql.conf syntax, which MariaDB's option files also parse. Values are
// quoted so a space or unit suffix survives. Names are the engine's startup
// spellings, not the customer's: the wrong one leaves the engine unable to boot.
func renderParameters(engineName string, params []handlers_rds.Parameter) (string, error) {
engine, err := handlers_rds.LookupEngine(engineName)
if err != nil {
return "", fmt.Errorf("render the resolved parameters: %w", err)
}
var b strings.Builder
b.WriteString("# Resolved parameter group, written by rds-agent.\n")
for _, p := range params {
if p.Name == "" {
continue
}
fmt.Fprintf(&b, "%s = '%s'\n", engine.OptionFileName(p.Name), strings.ReplaceAll(p.Value, "'", "''"))
}
return b.String(), nil
}
// Atomic: a temp file in the same directory, renamed over the target. The mode
// is set at creation, so content is never briefly readable at the process umask.
func writeHandoffFile(dir, name, content string) error {
path := filepath.Join(dir, name)
tmp := path + ".new"
f, err := os.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, handoffMode)
if err != nil {
return fmt.Errorf("create %s: %w", tmp, err)
}
// A failed write must not leave the temp file behind holding a password.
if _, err := f.WriteString(content); err != nil {
f.Close()
os.Remove(tmp)
return fmt.Errorf("write %s: %w", tmp, err)
}
if err := f.Close(); err != nil {
os.Remove(tmp)
return fmt.Errorf("close %s: %w", tmp, err)
}
if err := os.Rename(tmp, path); err != nil {
os.Remove(tmp)
return fmt.Errorf("install %s: %w", path, err)
}
return nil
}
-280
View File
@@ -1,280 +0,0 @@
package main
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
handlers_rds "github.com/mulgadc/spinifex/spinifex/handlers/rds"
)
func TestWriteHandoff_WritesEveryFileRootOnly(t *testing.T) {
dir := filepath.Join(t.TempDir(), "handoff")
password := "s3cret"
err := writeHandoff(dir, &handlers_rds.GetDBBootstrapConfigOutput{
Mode: handlers_rds.BootstrapModeInitialize,
Engine: "postgres",
MasterUsername: "master",
ListenAddress: "10.0.1.20",
ClientCIDR: "10.0.0.0/16",
MasterUserPassword: &password,
DBName: "appdb",
Port: 5433,
DataVolumeID: "vol-data-01",
DataVolumeSerial: "voldata01",
VMGeneration: 4,
FormatAuthorized: true,
Parameters: []handlers_rds.Parameter{
{Name: "max_connections", Value: "200"},
{Name: "shared_buffers", Value: "256MB"},
},
ServingCertificate: "CERT",
ServingPrivateKey: "KEY",
})
if err != nil {
t.Fatalf("writeHandoff: %v", err)
}
env := readFile(t, filepath.Join(dir, handoffEnvFile))
for _, want := range []string{
"RDS_MODE='initialize'",
"RDS_MASTER_USERNAME='master'",
"RDS_MASTER_PASSWORD='s3cret'",
"RDS_DB_NAME='appdb'",
"RDS_PORT='5433'",
"RDS_DATA_VOLUME_ID='vol-data-01'",
"RDS_DATA_VOLUME_SERIAL='voldata01'",
"RDS_VM_GENERATION='4'",
"RDS_FORMAT_AUTHORIZED='true'",
} {
if !strings.Contains(env, want) {
t.Errorf("handoff env missing %s:\n%s", want, env)
}
}
params := readFile(t, filepath.Join(dir, handoffParamsFile))
if !strings.Contains(params, "max_connections = '200'") || !strings.Contains(params, "shared_buffers = '256MB'") {
t.Errorf("parameters.conf = %q, want both resolved settings", params)
}
if got := readFile(t, filepath.Join(dir, handoffCertFile)); got != "CERT" {
t.Errorf("server.crt = %q, want CERT", got)
}
if got := readFile(t, filepath.Join(dir, handoffKeyFile)); got != "KEY" {
t.Errorf("server.key = %q, want KEY", got)
}
// The handoff holds a master password and a private key; anything readable
// beyond root is a disclosure to every process in the guest.
if mode := statMode(t, dir); mode != handoffDirMode {
t.Errorf("handoff dir mode = %#o, want %#o", mode, handoffDirMode)
}
for _, name := range []string{handoffEnvFile, handoffParamsFile, handoffCertFile, handoffKeyFile} {
if mode := statMode(t, filepath.Join(dir, name)); mode != handoffMode {
t.Errorf("%s mode = %#o, want %#o", name, mode, handoffMode)
}
}
}
// Attach mode carries no password, and the key must be absent rather than
// present and empty — rds-init reads an empty value as "no password" only by
// accident, and a later boot would inherit it.
func TestWriteHandoff_AttachOmitsPassword(t *testing.T) {
dir := filepath.Join(t.TempDir(), "handoff")
if err := writeHandoff(dir, &handlers_rds.GetDBBootstrapConfigOutput{
Mode: handlers_rds.BootstrapModeAttach,
Engine: "postgres",
MasterUsername: "master",
ListenAddress: "10.0.1.20",
ClientCIDR: "10.0.0.0/16",
Port: 5432,
}); err != nil {
t.Fatalf("writeHandoff: %v", err)
}
env := readFile(t, filepath.Join(dir, handoffEnvFile))
if strings.Contains(env, "RDS_MASTER_PASSWORD") {
t.Errorf("handoff env = %q, want no RDS_MASTER_PASSWORD key", env)
}
for _, want := range []string{
"RDS_DATA_VOLUME_ID=''",
"RDS_DATA_VOLUME_SERIAL=''",
"RDS_VM_GENERATION='0'",
"RDS_FORMAT_AUTHORIZED='false'",
} {
if !strings.Contains(env, want) {
t.Errorf("handoff env missing fail-closed field %s: %q", want, env)
}
}
}
// The env file is sourced by a shell, so a password holding shell metacharacters
// must survive as data rather than being split, expanded or executed.
func TestWriteHandoff_QuotesShellMetacharacters(t *testing.T) {
dir := filepath.Join(t.TempDir(), "handoff")
password := `a b$(id);'"\` + "`whoami`"
if err := writeHandoff(dir, &handlers_rds.GetDBBootstrapConfigOutput{
Mode: handlers_rds.BootstrapModeInitialize,
Engine: "postgres",
MasterUsername: "master",
ListenAddress: "10.0.1.20",
ClientCIDR: "10.0.0.0/16",
MasterUserPassword: &password,
Port: 5432,
}); err != nil {
t.Fatalf("writeHandoff: %v", err)
}
env := readFile(t, filepath.Join(dir, handoffEnvFile))
line := findLine(t, env, "RDS_MASTER_PASSWORD=")
if got := shellSource(t, line, "RDS_MASTER_PASSWORD"); got != password {
t.Errorf("sourced password = %q, want %q (line was %s)", got, password, line)
}
}
// A serving cert with no key would have rds-init configure TLS against a key
// that is not there; the pair is delivered together or not at all.
func TestWriteHandoff_SkipsHalfATLSPair(t *testing.T) {
dir := filepath.Join(t.TempDir(), "handoff")
if err := writeHandoff(dir, &handlers_rds.GetDBBootstrapConfigOutput{
Mode: handlers_rds.BootstrapModeAttach,
Engine: "postgres",
MasterUsername: "master",
ListenAddress: "10.0.1.20",
ClientCIDR: "10.0.0.0/16",
Port: 5432,
ServingCertificate: "CERT",
}); err != nil {
t.Fatalf("writeHandoff: %v", err)
}
if _, err := os.Stat(filepath.Join(dir, handoffCertFile)); !os.IsNotExist(err) {
t.Errorf("server.crt exists without a key; stat err = %v", err)
}
}
// A config with no master username cannot bootstrap anything, and writing it
// would only move the failure into rds-init after the boot has committed to it.
func TestWriteHandoff_RejectsConfigWithNoMasterUser(t *testing.T) {
dir := filepath.Join(t.TempDir(), "handoff")
err := writeHandoff(dir, &handlers_rds.GetDBBootstrapConfigOutput{
Mode: handlers_rds.BootstrapModeAttach,
Engine: "postgres",
Port: 5432,
})
if err == nil {
t.Fatal("writeHandoff accepted a config with no master username, want an error")
}
if _, statErr := os.Stat(filepath.Join(dir, handoffEnvFile)); !os.IsNotExist(statErr) {
t.Errorf("handoff env was written despite the rejection; stat err = %v", statErr)
}
}
// A directory left over from an earlier boot at a looser mode must be tightened,
// not accepted as it is found.
func TestWriteHandoff_TightensExistingDirectory(t *testing.T) {
dir := filepath.Join(t.TempDir(), "handoff")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatalf("seed dir: %v", err)
}
if err := writeHandoff(dir, &handlers_rds.GetDBBootstrapConfigOutput{
Mode: handlers_rds.BootstrapModeAttach,
Engine: "postgres",
MasterUsername: "master",
ListenAddress: "10.0.1.20",
ClientCIDR: "10.0.0.0/16",
Port: 5432,
}); err != nil {
t.Fatalf("writeHandoff: %v", err)
}
if mode := statMode(t, dir); mode != handoffDirMode {
t.Errorf("handoff dir mode = %#o, want it tightened to %#o", mode, handoffDirMode)
}
}
func TestShellQuote_EscapesEmbeddedQuotes(t *testing.T) {
for _, in := range []string{"", "plain", "it's", `''`, "new\nline", `$HOME`} {
if got := shellSource(t, "V="+shellQuote(in), "V"); got != in {
t.Errorf("shellQuote(%q) sourced back as %q", in, got)
}
}
}
// shellSource evaluates one KEY=value assignment in a real shell and returns
// what the variable ends up holding. The quoting is only correct if a shell
// agrees, so the test asks one instead of re-implementing its rules.
func shellSource(t *testing.T, line, name string) string {
t.Helper()
out, err := exec.Command("/bin/sh", "-c", line+"\nprintf %s \"$"+name+"\"\n").Output()
if err != nil {
t.Fatalf("sourcing %q: %v", line, err)
}
return string(out)
}
func statMode(t *testing.T, path string) os.FileMode {
t.Helper()
info, err := os.Stat(path)
if err != nil {
t.Fatalf("stat %s: %v", path, err)
}
return info.Mode().Perm()
}
func findLine(t *testing.T, body, prefix string) string {
t.Helper()
for line := range strings.SplitSeq(body, "\n") {
if strings.HasPrefix(line, prefix) {
return line
}
}
t.Fatalf("no line starting %q in:\n%s", prefix, body)
return ""
}
// The include rds-init installs is what the engine starts on, so the spelling
// written here is the one that has to boot.
func TestRenderParameters_UsesTheEngineStartupSpelling(t *testing.T) {
params := []handlers_rds.Parameter{
{Name: "time_zone", Value: "SYSTEM"},
{Name: "max_connections", Value: "85"},
}
rendered, err := renderParameters("mariadb", params)
if err != nil {
t.Fatalf("renderParameters: %v", err)
}
if !strings.Contains(rendered, "default_time_zone = 'SYSTEM'\n") {
t.Errorf("time_zone was not written under its startup spelling:\n%s", rendered)
}
if strings.Contains(rendered, "\ntime_zone = ") {
t.Errorf("time_zone reached the option file, which mariadbd refuses:\n%s", rendered)
}
if !strings.Contains(rendered, "max_connections = '85'\n") {
t.Errorf("an unmapped parameter was not written unchanged:\n%s", rendered)
}
}
func TestRenderParameters_PostgresKeepsEveryName(t *testing.T) {
rendered, err := renderParameters("postgres", []handlers_rds.Parameter{
{Name: "shared_buffers", Value: "262144"},
})
if err != nil {
t.Fatalf("renderParameters: %v", err)
}
if !strings.Contains(rendered, "shared_buffers = '262144'\n") {
t.Errorf("postgres name was rewritten:\n%s", rendered)
}
}
// Failing closed beats writing a file the engine may refuse: the bad include
// would already be on the data volume by the time the server rejected it.
func TestRenderParameters_RefusesAnUnknownEngine(t *testing.T) {
if _, err := renderParameters("", nil); err == nil {
t.Fatal("renderParameters accepted a config naming no engine, want an error")
}
}
-166
View File
@@ -1,166 +0,0 @@
package main
import (
"context"
"fmt"
"log/slog"
"strconv"
"strings"
"time"
handlers_rds "github.com/mulgadc/spinifex/spinifex/handlers/rds"
)
// Executes one control-plane directive inside the guest and returns the message
// reported back to the issuer.
type commandHandler func(ctx context.Context, cmd handlers_rds.Command) (string, error)
// A registry rather than a dispatch switch, so guest operations — password
// apply, parameter reload, storage grow, snapshot quiesce — add an entry each.
type commandRegistry map[string]commandHandler
// An unregistered type is replied to as unsupported, so a control plane ahead
// of the guest gets a clear answer rather than a timeout.
func newCommandRegistry(engine engineOps, storage storageOps) commandRegistry {
return commandRegistry{
handlers_rds.CommandSetPassword: func(ctx context.Context, cmd handlers_rds.Command) (string, error) {
params := commandParams(cmd)
return "", engine.SetPassword(ctx,
params[handlers_rds.CommandParamMasterUsername],
params[handlers_rds.CommandParamMasterUserPassword])
},
handlers_rds.CommandApplyParams: func(ctx context.Context, cmd handlers_rds.Command) (string, error) {
pending, err := engine.ApplyParameters(ctx, cmd.Parameters)
if err != nil {
return "", err
}
// The reply carries no structured payload, so the settings still
// awaiting a restart come back as the message itself.
return strings.Join(pending, ","), nil
},
handlers_rds.CommandStopEngine: func(ctx context.Context, cmd handlers_rds.Command) (string, error) {
return "", engine.Stop(ctx)
},
handlers_rds.CommandGrowFilesystem: func(ctx context.Context, cmd handlers_rds.Command) (string, error) {
return storage.GrowFilesystem(ctx)
},
handlers_rds.CommandQuiesce: func(ctx context.Context, cmd handlers_rds.Command) (string, error) {
params := commandParams(cmd)
hold, err := quiesceHoldFrom(params[handlers_rds.CommandParamQuiesceDeadlineSeconds])
if err != nil {
return "", err
}
if err := engine.Quiesce(ctx, params[handlers_rds.CommandParamQuiesceLabel], hold); err != nil {
return "", err
}
// After the hold, so what is flushed is the engine's checkpointed
// state. Its own files are not the risk — those it repairs from the
// WAL — but the parameter file beside them it never opens.
return "", storage.SyncDataMount(ctx)
},
handlers_rds.CommandUnquiesce: func(ctx context.Context, cmd handlers_rds.Command) (string, error) {
return "", engine.Unquiesce(ctx)
},
}
}
// The control plane's hold deadline. A missing or unreadable one is refused
// rather than defaulted: a hold the guest chose the length of would not be the
// bound the control plane is relying on.
func quiesceHoldFrom(value string) (time.Duration, error) {
seconds, err := strconv.Atoi(strings.TrimSpace(value))
if err != nil || seconds <= 0 {
return 0, fmt.Errorf("%s must be a positive number of seconds, got %q",
handlers_rds.CommandParamQuiesceDeadlineSeconds, value)
}
return time.Duration(seconds) * time.Second, nil
}
// A later value wins, so a malformed command carrying a parameter twice cannot
// leave the handler acting on the first of two conflicting values.
func commandParams(cmd handlers_rds.Command) map[string]string {
params := make(map[string]string, len(cmd.Parameters))
for _, p := range cmd.Parameters {
params[p.Name] = p.Value
}
return params
}
// Spaces retries after a failed poll, so a broken channel is not re-polled at
// line rate for as long as it stays broken.
const pollErrorBackoff = 5 * time.Second
// The agent's command channel: a long poll that carries back the replies for
// whatever the previous poll delivered.
type commander struct {
cp controlPlane
id identity
registry commandRegistry
wait time.Duration
// Cleared only once a poll has carried them, so a failed poll re-delivers
// rather than dropping the result of work the guest actually did.
pending []handlers_rds.CommandReply
}
func newCommander(cp controlPlane, registry commandRegistry, wait time.Duration) *commander {
if wait <= 0 {
wait = defaultPollWait
}
return &commander{cp: cp, registry: registry, wait: wait}
}
func (c *commander) Run(ctx context.Context) {
for {
if ctx.Err() != nil {
return
}
commands, err := c.cp.PollCommands(ctx, c.id, c.pending, c.wait)
if err != nil {
if ctx.Err() != nil {
return
}
slog.Warn("rds-agent: command poll failed", "err", err)
select {
case <-ctx.Done():
return
case <-time.After(pollErrorBackoff):
}
continue
}
c.pending = nil
for _, cmd := range commands {
c.pending = append(c.pending, c.execute(ctx, cmd))
}
}
}
// Every command gets a reply: the issuer is blocked on this command ID, so a
// silent drop costs it a full timeout.
func (c *commander) execute(ctx context.Context, cmd handlers_rds.Command) handlers_rds.CommandReply {
handler, ok := c.registry[cmd.Type]
if !ok {
slog.Warn("rds-agent: unsupported command", "commandID", cmd.CommandID, "type", cmd.Type)
return failedReply(cmd, fmt.Sprintf("unsupported command type %q", cmd.Type))
}
slog.Info("rds-agent: executing command", "commandID", cmd.CommandID, "type", cmd.Type)
message, err := handler(ctx, cmd)
if err != nil {
slog.Error("rds-agent: command failed", "commandID", cmd.CommandID, "type", cmd.Type, "err", err)
return failedReply(cmd, err.Error())
}
return handlers_rds.CommandReply{
CommandID: cmd.CommandID,
Status: handlers_rds.CommandStatusSucceeded,
Message: message,
}
}
func failedReply(cmd handlers_rds.Command, message string) handlers_rds.CommandReply {
return handlers_rds.CommandReply{
CommandID: cmd.CommandID,
Status: handlers_rds.CommandStatusFailed,
Message: message,
}
}
-173
View File
@@ -1,173 +0,0 @@
package main
import (
"context"
"errors"
"testing"
"time"
handlers_rds "github.com/mulgadc/spinifex/spinifex/handlers/rds"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestCommander_DispatchesByNameAndReplies(t *testing.T) {
var ran handlers_rds.Command
registry := commandRegistry{
"reload-parameters": func(_ context.Context, cmd handlers_rds.Command) (string, error) {
ran = cmd
return "reloaded", nil
},
}
reply := newCommander(nil, registry, time.Second).execute(context.Background(), handlers_rds.Command{
CommandID: "cmd-1",
Type: "reload-parameters",
Parameters: []handlers_rds.Parameter{{Name: "work_mem", Value: "8MB"}},
})
if ran.CommandID != "cmd-1" || len(ran.Parameters) != 1 {
t.Errorf("handler received %+v, want the command with its parameters", ran)
}
if reply.Status != handlers_rds.CommandStatusSucceeded || reply.Message != "reloaded" {
t.Errorf("reply = %+v, want succeeded/reloaded", reply)
}
if reply.CommandID != "cmd-1" {
t.Errorf("reply command ID = %q, want cmd-1", reply.CommandID)
}
}
func TestCommander_FailedHandlerRepliesWithItsError(t *testing.T) {
registry := commandRegistry{
"grow-storage": func(context.Context, handlers_rds.Command) (string, error) {
return "", errors.New("no space left on device")
},
}
reply := newCommander(nil, registry, time.Second).execute(context.Background(),
handlers_rds.Command{CommandID: "cmd-2", Type: "grow-storage"})
if reply.Status != handlers_rds.CommandStatusFailed {
t.Errorf("reply status = %q, want failed", reply.Status)
}
if reply.Message != "no space left on device" {
t.Errorf("reply message = %q, want the handler's error", reply.Message)
}
}
// A control plane ahead of the guest issues types this build has no handler
// for. The issuer is blocked on the command ID, so it gets an answer rather
// than a timeout.
func TestCommander_UnknownTypeRepliesFailedNotDropped(t *testing.T) {
reply := newCommander(nil, newCommandRegistry(&fakeEngine{}, &fakeStorage{}), time.Second).execute(context.Background(),
handlers_rds.Command{CommandID: "cmd-3", Type: "quiesce"})
if reply.CommandID != "cmd-3" || reply.Status != handlers_rds.CommandStatusFailed {
t.Errorf("reply = %+v, want a failed reply for cmd-3", reply)
}
if reply.Message == "" {
t.Error("unsupported-command reply carried no message")
}
}
// The reply for work the guest actually did rides the next poll. A poll that
// fails must not consume it, or the issuer never learns the outcome.
func TestCommander_RunCarriesRepliesOnTheNextPoll(t *testing.T) {
cp := newFakeControlPlane()
cp.pollQueue = [][]handlers_rds.Command{
{{CommandID: "cmd-1", Type: "reload-parameters"}},
}
registry := commandRegistry{
"reload-parameters": func(context.Context, handlers_rds.Command) (string, error) { return "done", nil },
}
c := newCommander(cp, registry, 10*time.Millisecond)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
done := make(chan struct{})
go func() { c.Run(ctx); close(done) }()
waitFor(t, func() bool {
replies := cp.snapshotReplies()
return len(replies) >= 2 && len(replies[1]) == 1
}, "the reply to ride the second poll")
cancel()
<-done
replies := cp.snapshotReplies()
if len(replies[0]) != 0 {
t.Errorf("first poll carried replies %+v, want none", replies[0])
}
if got := replies[1][0]; got.CommandID != "cmd-1" || got.Status != handlers_rds.CommandStatusSucceeded {
t.Errorf("second poll carried %+v, want the succeeded reply for cmd-1", got)
}
}
func TestCommander_RetainsAndRetriesTheCompletePendingBatch(t *testing.T) {
cp := newFakeControlPlane()
pending := []handlers_rds.CommandReply{
{CommandID: "cmd-1", Status: handlers_rds.CommandStatusSucceeded},
{CommandID: "cmd-2", Status: handlers_rds.CommandStatusFailed, Message: "disk full"},
}
c := newCommander(cp, commandRegistry{
"reload-parameters": func(context.Context, handlers_rds.Command) (string, error) {
return "reloaded", nil
},
}, 10*time.Millisecond)
c.pending = append([]handlers_rds.CommandReply(nil), pending...)
cp.pollErr = errors.New("reply publication failed")
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() { c.Run(ctx); close(done) }()
<-cp.polled
cancel()
<-done
assert.Equal(t, pending, c.pending)
replies := cp.snapshotReplies()
require.Len(t, replies, 1)
assert.Equal(t, pending, replies[0])
cp.mu.Lock()
cp.pollErr = nil
cp.pollQueue = [][]handlers_rds.Command{{{
CommandID: "cmd-3",
Type: "reload-parameters",
}}}
cp.mu.Unlock()
ctx, cancel = context.WithCancel(context.Background())
done = make(chan struct{})
go func() { c.Run(ctx); close(done) }()
waitFor(t, func() bool {
return len(cp.snapshotReplies()) >= 3
}, "the retained batch to succeed and the new reply to reach the following poll")
cancel()
<-done
replies = cp.snapshotReplies()
assert.Equal(t, pending, replies[1], "the complete failed batch must be retried in order")
require.Len(t, replies[2], 1)
assert.Equal(t, "cmd-3", replies[2][0].CommandID)
assert.Equal(t, handlers_rds.CommandStatusSucceeded, replies[2][0].Status)
}
// A poll error must not spin the loop: without a backoff a gateway that is down
// would be re-polled at line rate for as long as it stayed down.
func TestCommander_BacksOffAfterAFailedPoll(t *testing.T) {
cp := newFakeControlPlane()
cp.pollErr = errors.New("gateway unreachable")
c := newCommander(cp, newCommandRegistry(&fakeEngine{}, &fakeStorage{}), 10*time.Millisecond)
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
done := make(chan struct{})
go func() { c.Run(ctx); close(done) }()
<-done
// pollErrorBackoff is 5s, so the window admits the first poll and no more.
if polls := len(cp.snapshotReplies()); polls > 1 {
t.Errorf("polled %d times in 200ms against a broken channel, want 1", polls)
}
}
-185
View File
@@ -1,185 +0,0 @@
package main
import (
"os"
"strconv"
"strings"
"time"
"github.com/mulgadc/spinifex/internal/guestenv"
)
const (
defaultEnvFile = "/etc/spinifex-rds/agent.env"
defaultGatewayCA = "/etc/spinifex-rds/gateway-ca.pem"
defaultHandoffDir = "/run/spinifex-rds"
// Where setup.sh stamps the engine the image bakes. The agent builds its
// engine implementation from this file rather than from anything delivered.
defaultEngineFile = "/etc/spinifex-rds/engine"
defaultRCService = "rc-service"
defaultEngineProbeTimeout = 5 * time.Second
// The long-poll window the agent asks the gateway to hold a request open
// for. The gateway caps it at 20s.
defaultPollWait = 20 * time.Second
)
// Static settings delivered per-instance by cloud-init. It carries no secrets:
// IMDS is readable by anything in the guest, so the master password only
// arrives via GetDBBootstrapConfig.
type config struct {
GatewayURL string
GatewayCA string
Region string
// Optional — the gateway resolves the instance from the caller's
// credentials. When set it is sent, so a mis-provisioned VM is rejected.
DBInstanceIdentifier string
// What this image actually ships. Empty leaves the control plane's recorded
// version alone rather than clearing it.
EngineVersion string
HandoffDir string
EnginePort int
PollWait time.Duration
// The engine the control plane launched this VM as, delivered by cloud-init,
// and the engine the image itself bakes. Two independent statements of the
// same fact: the agent refuses to bootstrap when they disagree.
Engine string
EngineFile string
BakedEngine string
// Where the command handlers reach the engine: the client binaries, the
// datadir the parameter file is installed into, the socket they connect
// over, the OS user they drop to, and the service the stop goes through.
EngineBinDir string
EngineDataDir string
SocketDir string
EngineUser string
RCService string
EngineService string
// Where the engine records the pid its probe checks for liveness, for an
// engine whose own client cannot tell a server still recovering from one that
// is not running at all.
EnginePidFile string
EngineProbeTimeout time.Duration
// Where the engine records why it would not start, which the probe quotes
// when the server is up but not serving. Overridable so a test can point it
// at a fixture.
EngineErrorLog string
// Where the data volume is mounted, and the two kernel surfaces a storage
// grow resolves its device from. Overridable so a test can point them at
// fixtures rather than at the running host's.
DataMount string
MountsFile string
SysBlock string
}
// Reads the cloud-init env file, then lets real env vars override.
func loadConfig(envFile string) config {
get := guestenv.Load(envFile).Get
cfg := config{
GatewayURL: get("RDS_GATEWAY_URL"),
GatewayCA: get("RDS_GATEWAY_CA"),
Region: get("RDS_REGION"),
DBInstanceIdentifier: get("RDS_DB_INSTANCE_IDENTIFIER"),
Engine: get("RDS_ENGINE"),
EngineFile: get("RDS_ENGINE_FILE"),
EngineVersion: get("RDS_ENGINE_VERSION"),
HandoffDir: get("RDS_HANDOFF_DIR"),
EngineBinDir: get("RDS_ENGINE_BIN"),
EngineDataDir: get("RDS_ENGINE_DATA"),
SocketDir: get("RDS_SOCKET_DIR"),
EngineUser: get("RDS_ENGINE_USER"),
RCService: get("RDS_RC_SERVICE"),
EngineService: get("RDS_ENGINE_SERVICE"),
EnginePidFile: get("RDS_ENGINE_PIDFILE"),
EngineErrorLog: get("RDS_ENGINE_LOG"),
DataMount: get("RDS_DATA_MOUNT"),
PollWait: defaultPollWait,
EngineProbeTimeout: defaultEngineProbeTimeout,
}
if cfg.GatewayCA == "" {
cfg.GatewayCA = defaultGatewayCA
}
if cfg.HandoffDir == "" {
cfg.HandoffDir = defaultHandoffDir
}
if cfg.EngineFile == "" {
cfg.EngineFile = defaultEngineFile
}
if cfg.RCService == "" {
cfg.RCService = defaultRCService
}
// The image's own stamp rather than the delivered engine: an agent that took
// its layout from the payload could run one engine's implementation against
// another engine's datadir, which is the failure the stamp exists to prevent.
cfg.BakedEngine = readBakedEngine(cfg.EngineFile)
cfg.applyLayout(engineLayouts[cfg.BakedEngine])
// rds-datadir reads the same RDS_DATA_MOUNT, so the two agree on where the
// volume landed without either asserting it to the other.
cfg.MountsFile = defaultMountsFile
cfg.SysBlock = defaultSysBlock
// The authoritative port comes from the bootstrap config; this is only what
// the probe uses until that first fetch lands.
if v := get("RDS_ENGINE_PORT"); v != "" {
if p, err := strconv.Atoi(v); err == nil && p > 0 {
cfg.EnginePort = p
}
}
if v := get("RDS_POLL_WAIT"); v != "" {
if d, err := time.ParseDuration(v); err == nil && d > 0 {
cfg.PollWait = d
}
}
if v := get("RDS_ENGINE_PROBE_TIMEOUT"); v != "" {
if d, err := time.ParseDuration(v); err == nil && d > 0 {
cfg.EngineProbeTimeout = d
}
}
return cfg
}
// Fills in whatever the delivered configuration left unset. An override always
// wins, so a test can point any of these at a fixture. An unrecognised engine
// leaves the layout empty, and New refuses rather than guessing at one.
func (c *config) applyLayout(layout engineLayout) {
if c.EngineBinDir == "" {
c.EngineBinDir = layout.binDir
}
if c.EngineDataDir == "" {
c.EngineDataDir = layout.dataDir
}
if c.SocketDir == "" {
c.SocketDir = layout.socketDir
}
if c.EngineUser == "" {
c.EngineUser = layout.osUser
}
if c.EngineService == "" {
c.EngineService = layout.service
}
if c.EnginePidFile == "" {
c.EnginePidFile = layout.pidFile
}
if c.EngineErrorLog == "" {
c.EngineErrorLog = layout.errorLog
}
if c.DataMount == "" {
c.DataMount = layout.dataMount
}
if c.EnginePort == 0 {
c.EnginePort = layout.port
}
}
// The engine the image bakes. An absent or unreadable stamp reads as empty,
// which New refuses: an agent that guessed would defeat the stamp.
func readBakedEngine(path string) string {
raw, err := os.ReadFile(path)
if err != nil {
return ""
}
return strings.ToLower(strings.TrimSpace(string(raw)))
}
-162
View File
@@ -1,162 +0,0 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
)
// Stamps the engine a real image bakes and loads the configuration against it,
// which is what selects the guest layout the agent's defaults come from.
func testLoadConfig(t *testing.T, engine string) config {
t.Helper()
stamp := filepath.Join(t.TempDir(), "engine")
if err := os.WriteFile(stamp, []byte(engine+"\n"), 0o444); err != nil {
t.Fatalf("write the engine stamp: %v", err)
}
t.Setenv("RDS_ENGINE_FILE", stamp)
return loadConfig(filepath.Join(t.TempDir(), "absent.env"))
}
func TestLoadConfig_Defaults(t *testing.T) {
cfg := testLoadConfig(t, enginePostgres)
if cfg.GatewayCA != defaultGatewayCA || cfg.HandoffDir != defaultHandoffDir {
t.Errorf("CA/handoff = %q/%q, want the built-in defaults", cfg.GatewayCA, cfg.HandoffDir)
}
if cfg.PollWait != defaultPollWait {
t.Errorf("pollWait = %v, want the built-in default", cfg.PollWait)
}
if cfg.EngineProbeTimeout != defaultEngineProbeTimeout {
t.Errorf("engine probe timeout = %v, want %v", cfg.EngineProbeTimeout, defaultEngineProbeTimeout)
}
// Identity is optional: the gateway resolves it from the caller's
// credentials, so an agent with no configured identifier is normal.
if cfg.DBInstanceIdentifier != "" {
t.Errorf("DBInstanceIdentifier = %q, want empty when unconfigured", cfg.DBInstanceIdentifier)
}
}
// The guest layout is the image's to state, not the control plane's: everything
// the agent reaches the engine through comes from the stamp it was built with.
func TestLoadConfig_TakesTheLayoutFromTheBakedEngine(t *testing.T) {
cfg := testLoadConfig(t, enginePostgres)
if cfg.BakedEngine != enginePostgres {
t.Fatalf("baked engine = %q, want %q", cfg.BakedEngine, enginePostgres)
}
layout := engineLayouts[enginePostgres]
if cfg.EngineBinDir != layout.binDir || cfg.EngineDataDir != layout.dataDir {
t.Errorf("bin/data = %q/%q, want the PostgreSQL layout", cfg.EngineBinDir, cfg.EngineDataDir)
}
if cfg.EngineUser != layout.osUser || cfg.EngineService != layout.service {
t.Errorf("user/service = %q/%q, want the PostgreSQL layout", cfg.EngineUser, cfg.EngineService)
}
if cfg.SocketDir != layout.socketDir || cfg.DataMount != layout.dataMount {
t.Errorf("socket/mount = %q/%q, want the PostgreSQL layout", cfg.SocketDir, cfg.DataMount)
}
if cfg.EnginePort != layout.port {
t.Errorf("port = %d, want the PostgreSQL default %d", cfg.EnginePort, layout.port)
}
}
// An image with no stamp cannot say which engine it is, and an agent that
// guessed would be the failure the stamp exists to prevent.
func TestNewEngine_RefusesAnImageWithNoEngineStamp(t *testing.T) {
t.Setenv("RDS_ENGINE_FILE", filepath.Join(t.TempDir(), "absent-engine"))
cfg := loadConfig(filepath.Join(t.TempDir(), "absent.env"))
if _, err := newProbe(cfg, staticProbe(0)); err == nil {
t.Error("newProbe built a probe for an image carrying no engine stamp")
}
if _, err := newEngine(cfg, nil, nil, nil); err == nil {
t.Error("newEngine built an engine for an image carrying no engine stamp")
}
}
func TestNewEngine_RefusesAnEngineItDoesNotImplement(t *testing.T) {
cfg := testLoadConfig(t, "oracle")
_, err := newEngine(cfg, nil, nil, nil)
if err == nil {
t.Fatal("newEngine built an engine this agent does not implement")
}
if !strings.Contains(err.Error(), "oracle") {
t.Errorf("error = %v, want it to name the engine the image bakes", err)
}
}
// The implementation is the agent's, but the definition it validates names and
// classifies parameters against is the control plane's, which now offers MariaDB.
func TestNewEngine_BuildsMariaDBForAMariaDBImage(t *testing.T) {
cfg := testLoadConfig(t, engineMariaDB)
probe, err := newProbe(cfg, staticProbe(0))
if err != nil {
t.Fatalf("newProbe: %v", err)
}
built, err := newEngine(cfg, nil, nil, probe)
if err != nil {
t.Fatalf("newEngine: %v", err)
}
if _, ok := built.(*mariadbEngine); !ok {
t.Errorf("newEngine built %T for a MariaDB image", built)
}
}
// The probe reads the pidfile Alpine's packaged service starts mariadbd with,
// because nothing in the generated configuration can move it: the service passes
// --pid-file on the command line, which beats an option file.
func TestLoadConfig_TakesTheMariaDBPidfileFromTheLayout(t *testing.T) {
cfg := testLoadConfig(t, engineMariaDB)
if cfg.EnginePidFile != engineLayouts[engineMariaDB].pidFile {
t.Errorf("pidfile = %q, want the MariaDB layout's", cfg.EnginePidFile)
}
if cfg.EnginePidFile == "" {
t.Error("an engine whose probe needs a pidfile was given none")
}
}
func TestLoadConfig_ReadsEnvFile(t *testing.T) {
t.Setenv("RDS_ENGINE_FILE", filepath.Join(t.TempDir(), "absent-engine"))
path := filepath.Join(t.TempDir(), "agent.env")
body := "RDS_GATEWAY_URL=https://gw.internal:9999\n" +
"RDS_REGION=ap-southeast-2\n" +
"RDS_DB_INSTANCE_IDENTIFIER=db-1\n" +
"RDS_ENGINE=postgres\n" +
"RDS_ENGINE_VERSION=18.1\n" +
"RDS_ENGINE_PORT=6543\n" +
"RDS_POLL_WAIT=5s\n"
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
t.Fatalf("write env file: %v", err)
}
cfg := loadConfig(path)
if cfg.GatewayURL != "https://gw.internal:9999" || cfg.Region != "ap-southeast-2" {
t.Errorf("gateway/region = %q/%q, want the delivered values", cfg.GatewayURL, cfg.Region)
}
if cfg.DBInstanceIdentifier != "db-1" || cfg.EngineVersion != "18.1" {
t.Errorf("identity = %q/%q, want db-1/18.1", cfg.DBInstanceIdentifier, cfg.EngineVersion)
}
if cfg.Engine != enginePostgres {
t.Errorf("delivered engine = %q, want the one cloud-init carried", cfg.Engine)
}
if cfg.EnginePort != 6543 || cfg.PollWait != 5*time.Second {
t.Errorf("port/pollWait = %d/%v, want 6543/5s", cfg.EnginePort, cfg.PollWait)
}
}
// An unparseable override falls back to the layout's own default rather than
// leaving the agent with a zero port or a zero-length long poll.
func TestLoadConfig_IgnoresUnusableOverrides(t *testing.T) {
t.Setenv("RDS_ENGINE_PORT", "not-a-port")
t.Setenv("RDS_POLL_WAIT", "forever")
cfg := testLoadConfig(t, enginePostgres)
if cfg.EnginePort != engineLayouts[enginePostgres].port || cfg.PollWait != defaultPollWait {
t.Errorf("port/pollWait = %d/%v, want the defaults", cfg.EnginePort, cfg.PollWait)
}
}
-336
View File
@@ -1,336 +0,0 @@
package main
import (
"bytes"
"context"
"fmt"
"os"
"os/exec"
"os/user"
"path/filepath"
"slices"
"strconv"
"strings"
"syscall"
"time"
handlers_rds "github.com/mulgadc/spinifex/spinifex/handlers/rds"
)
// The in-guest half of the engine seam. Everything engine-shaped that a
// control-plane command needs lives behind this, so the command registry stays
// engine-agnostic.
type engineOps interface {
// Rotates the master role's password live. Never persisted anywhere in the
// guest: it exists only for the length of this call.
SetPassword(ctx context.Context, username, password string) error
// Installs the resolved parameter set and reloads. Returns the settings the
// engine accepted but will not honour until it restarts.
ApplyParameters(ctx context.Context, params []handlers_rds.Parameter) ([]string, error)
// Shuts the engine down cleanly, so the data volume is checkpointed before
// the VM is stopped or snapshotted.
Stop(ctx context.Context) error
// Puts the engine into backup mode so the datadir can be snapshotted at a
// checkpoint. Releases itself after hold, whatever happens to the caller.
Quiesce(ctx context.Context, label string, hold time.Duration) error
// Takes the engine back out of backup mode. Fails when no hold is active,
// which is how a hold that expired mid-snapshot becomes visible.
Unquiesce(ctx context.Context) error
}
// The rollback half of the parameter path, kept off engineOps because it is not
// a control-plane directive: nothing issues it, the agent runs it when the
// engine fails to come up after a parameter change.
type parameterRecovery interface {
// Reports whether the installed set differed and was replaced. True with a
// non-nil error is a set already on disk whose re-derived TLS enforcement did
// not land: the caller still has to restart and report.
RestoreLastKnownGoodParameters(ctx context.Context) (bool, error)
Restart(ctx context.Context) error
}
// Records the parameter file only after the probe observes the engine serving
// it. This is separate from apply, where static values are installed but not
// adopted until a later restart.
type servingParameterRecorder interface {
RecordServingParameters(ctx context.Context) error
}
// Everything one engine implementation owns. The narrow interfaces above are
// what the heartbeat, the commander and the rollback guard each hold; this is
// only what the agent has to construct.
type engine interface {
engineOps
parameterRecovery
servingParameterRecorder
}
// The guest layout an engine preset lays down, keyed by the engine the image
// bakes. Every default that belongs to one engine lives here, so nothing
// outside an implementation names its paths, service, port or probe.
type engineLayout struct {
binDir string
dataDir string
socketDir string
osUser string
service string
dataMount string
port int
// Where the engine records the pid the probe checks for liveness. Empty for
// an engine whose probe answers without one.
pidFile string
// Where the engine records why it would not start. Empty for an engine whose
// image does not direct one, which leaves the probe reporting its own reason
// alone rather than guessing at a path.
errorLog string
// The engine's own liveness signal, which is the only part of engineProbe
// that is not shared.
newProbe func(cfg config, run probeRunner) *engineProbe
// The implementation every control-plane directive is served by. It fails
// when the control plane this agent was built against does not offer the
// engine the image bakes, rather than serving one it has no definition for.
newEngine func(cfg config, run commandRunner, startSess sessionRunner, probe *engineProbe) (engine, error)
}
// The names the control plane knows these implementations by, which are also
// what setup.sh stamps into each image.
const (
enginePostgres = "postgres"
engineMariaDB = "mariadb"
)
var engineLayouts = map[string]engineLayout{
enginePostgres: {
binDir: "/usr/libexec/postgresql18",
dataDir: "/var/lib/postgresql/18/data",
socketDir: "/run/postgresql",
osUser: "postgres",
service: "postgresql",
dataMount: "/var/lib/postgresql",
port: 5432,
// logfile in the image's /etc/conf.d/postgresql. The postmaster states
// which setting it refused there and nowhere else — the console shows only
// that pg_ctl could not start it, on a guest with no SSH.
errorLog: "/var/log/postgresql/postmaster.log",
newProbe: newPostgresProbe,
newEngine: newPostgresEngineFromCatalog,
},
engineMariaDB: {
binDir: "/usr/bin",
dataDir: "/var/lib/mysql/data",
socketDir: "/run/mysqld",
osUser: "mysql",
service: "mariadb",
dataMount: "/var/lib/mysql",
port: 3306,
// Alpine's service passes --pid-file=/run/mysqld/$RC_SVCNAME.pid, and the
// command line beats the option file, so this is the path rather than
// anything the drop-in names. setup.sh asserts it at build time.
pidFile: "/run/mysqld/mariadb.pid",
// log_error in the platform drop-in rds-init writes. Without it
// mysqld_safe would send the server's errors to syslog, which nothing
// collects off a guest that has no SSH.
errorLog: "/var/log/mysql/error.log",
newProbe: newMariaDBProbe,
newEngine: newMariaDBEngineFromCatalog,
},
}
// How much of the engine's error log a probe reason carries. The server names
// what it refused on in its last few lines, and the reason reaches a customer
// through StatusInfos, so this quotes the tail rather than the whole file.
const (
engineErrorLogTailBytes = 4096
engineErrorLogTailLines = 4
)
// Appends the last few lines of the engine's error log to reason. An unset path,
// an unreadable file or an empty one leaves reason alone: the probe's own
// statement is still true, and a guessed cause would be worse than none.
func withEngineLogTail(reason, path string) string {
tail := engineLogTail(path)
if tail == "" {
return reason
}
return reason + "; the engine's log ends: " + tail
}
func engineLogTail(path string) string {
if path == "" {
return ""
}
f, err := os.Open(path)
if err != nil {
return ""
}
defer func() { _ = f.Close() }()
info, err := f.Stat()
if err != nil {
return ""
}
offset := max(info.Size()-engineErrorLogTailBytes, 0)
buf := make([]byte, info.Size()-offset)
if _, err := f.ReadAt(buf, offset); err != nil {
return ""
}
lines := strings.Split(string(buf), "\n")
// A read that started mid-file almost certainly started mid-line, and half a
// line is more misleading than one fewer.
if offset > 0 && len(lines) > 0 {
lines = lines[1:]
}
kept := make([]string, 0, engineErrorLogTailLines)
for i := len(lines) - 1; i >= 0 && len(kept) < engineErrorLogTailLines; i-- {
if line := strings.TrimSpace(lines[i]); line != "" {
kept = append(kept, line)
}
}
slices.Reverse(kept)
return strings.Join(kept, " | ")
}
// Builds the health probe the image's engine supplies.
func newProbe(cfg config, run probeRunner) (*engineProbe, error) {
layout, err := layoutFor(cfg)
if err != nil {
return nil, err
}
return layout.newProbe(cfg, run), nil
}
// Builds the implementation the image bakes. Nothing here consults the control
// plane for which engine to be: the agent runs the engine it is made of, and the
// delivered engine is only ever checked against it.
func newEngine(cfg config, run commandRunner, startSess sessionRunner, probe *engineProbe) (engine, error) {
layout, err := layoutFor(cfg)
if err != nil {
return nil, err
}
return layout.newEngine(cfg, run, startSess, probe)
}
func layoutFor(cfg config) (engineLayout, error) {
if cfg.BakedEngine == "" {
return engineLayout{}, fmt.Errorf("this image carries no engine stamp at %s", cfg.EngineFile)
}
layout, ok := engineLayouts[cfg.BakedEngine]
if !ok {
return engineLayout{}, fmt.Errorf("this image bakes engine %q, which this agent does not implement", cfg.BakedEngine)
}
return layout, nil
}
// One child process. Env replaces the agent's own environment rather than
// extending it, so a secret placed here reaches only the process that needs it.
type command struct {
Name string
Args []string
Env []string
Stdin string
// The OS user to drop to before exec. Empty runs as the agent itself.
User string
// The statement that makes this client print the session sentinel. Read only
// when the command is started as a held session, whose reader has to know
// when the engine has finished each script it was fed.
SentinelStatement string
}
// Returns stdout. stderr is folded into the error, because both clients report
// the actual SQL failure there while exiting with a bare status.
type commandRunner func(ctx context.Context, c command) (string, error)
func execCommandRunner(ctx context.Context, c command) (string, error) {
cmd := exec.CommandContext(ctx, c.Name, c.Args...)
cmd.Env = c.Env
if c.Stdin != "" {
cmd.Stdin = strings.NewReader(c.Stdin)
}
if c.User != "" {
credential, err := lookupCredential(c.User)
if err != nil {
return "", err
}
cmd.SysProcAttr = &syscall.SysProcAttr{Credential: credential}
}
var stdout, stderr bytes.Buffer
cmd.Stdout, cmd.Stderr = &stdout, &stderr
if err := cmd.Run(); err != nil {
message := strings.TrimSpace(stderr.String())
if message == "" {
message = err.Error()
}
return stdout.String(), fmt.Errorf("%s: %s", filepath.Base(c.Name), message)
}
return stdout.String(), nil
}
func lookupCredential(username string) (*syscall.Credential, error) {
u, err := user.Lookup(username)
if err != nil {
return nil, fmt.Errorf("resolve user %s: %w", username, err)
}
uid, err := strconv.ParseUint(u.Uid, 10, 32)
if err != nil {
return nil, fmt.Errorf("parse uid of %s: %w", username, err)
}
gid, err := strconv.ParseUint(u.Gid, 10, 32)
if err != nil {
return nil, fmt.Errorf("parse gid of %s: %w", username, err)
}
return &syscall.Credential{Uid: uint32(uid), Gid: uint32(gid)}, nil
}
// Through the service manager rather than the engine's own control tool, so the
// supervisor records the state and does not restart the engine underneath a VM
// that is going down.
func serviceAction(ctx context.Context, run commandRunner, rcService, service, action string) error {
if _, err := run(ctx, command{
Name: rcService,
Args: []string{service, action},
Env: []string{"PATH=" + defaultGuestPath},
}); err != nil {
return fmt.Errorf("%s the %s service: %w", action, service, err)
}
return nil
}
// Whether the installed set requires TLS of client connections. Read back from
// the file rather than taken from the set being applied, because the restore and
// repair paths put a file in place without holding one. Both engines' generated
// parameter files are written in the same syntax, so one reader serves them.
func installedTLSEnforcement(name, installedPath string) (bool, error) {
values, err := readOptionFile(installedPath)
if err != nil && !os.IsNotExist(err) {
return false, fmt.Errorf("read the installed parameters: %w", err)
}
value, ok := values[name]
if !ok {
// The ordinary state of an instance whose resolved set predates the
// parameter, and the whole of the migration story: it begins enforcing at
// its next boot with no control-plane work at all.
return true, nil
}
switch value {
case "1":
return true, nil
case "0":
return false, nil
}
// The resolver canonicalises every boolean, so anything else means the file
// was written by something other than the platform. The permissive reading is
// the one that must not be chosen for a setting that turns TLS off.
return false, fmt.Errorf("the installed parameters set %s to %q, which is neither 1 nor 0", name, value)
}
// A minimal environment for a child that is not carrying secrets.
const defaultGuestPath = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
func redact(s, secret string) string {
if secret == "" {
return s
}
return strings.ReplaceAll(s, secret, "[REDACTED]")
}
-762
View File
@@ -1,762 +0,0 @@
package main
import (
"context"
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"slices"
"strconv"
"strings"
"syscall"
"time"
handlers_rds "github.com/mulgadc/spinifex/spinifex/handlers/rds"
)
// The control plane's own statements about this engine, resolved once at
// startup. Held as functions rather than as the Engine value so nothing here
// re-derives a rule the API already owns.
type controlPlaneRules struct {
// The whole master-username rule rather than only the reserved set. MariaDB's
// client has no identifier quoting, so the name is interpolated into SQL by
// this process and every part of the rule is load-bearing here.
validateUsername func(username string) error
// Whether a setting takes effect only at a restart. Already customer-facing
// and authoritative, since the API refuses ApplyMethod=immediate on one.
isStatic func(name string) bool
// The catalog name behind a spelling read back out of an option file. Only a
// setting whose startup spelling differs from the customer's moves.
catalogName func(optionFileName string) string
// The catalog's data type for a setting, which decides how its value is
// rendered into SQL. Empty for a name the catalog does not carry.
dataType func(name string) string
// The engine's own name for the setting that requires TLS of a client
// connection. Held rather than spelled out here, so the guest derives
// enforcement from the same key the control plane resolves it under.
tlsEnforcementParameter string
}
func controlPlaneRulesFrom(meta handlers_rds.Engine) controlPlaneRules {
// Built once per engine: everything read back out of a generated file has to
// return to the catalog's namespace before it is classified or reported, or a
// startup spelling would read as an unknown name.
catalogNames := map[string]string{}
for _, name := range meta.CatalogParameterNames() {
if optionFileName := meta.OptionFileName(name); optionFileName != name {
catalogNames[optionFileName] = name
}
}
return controlPlaneRules{
validateUsername: meta.ValidateMasterUsername,
isStatic: func(name string) bool {
spec, ok := meta.LookupParameter(name)
// A name the catalog does not carry cannot be shown to have been adopted
// without a restart, and is never issued as a live SET GLOBAL either.
return !ok || spec.ApplyType == handlers_rds.ApplyTypeStatic
},
catalogName: func(optionFileName string) string {
if name, ok := catalogNames[optionFileName]; ok {
return name
}
return optionFileName
},
dataType: func(name string) string {
spec, ok := meta.LookupParameter(name)
if !ok {
return ""
}
return spec.DataType
},
tlsEnforcementParameter: meta.TLSEnforcementParameter(),
}
}
// Reached over its unix socket as root, which the datadir's unix_socket plugin
// authenticates from the connecting process' own uid. That is why the agent
// holds no password of its own.
type mariadbEngine struct {
quiesceState
parameterManager
rules controlPlaneRules
run commandRunner
startSess sessionRunner
client string
socket string
rcService string
service string
}
var _ engine = (*mariadbEngine)(nil)
const (
mariadbClientBinary = "mariadb"
mariadbAdminBinary = "mariadb-admin"
// The account mariadb-install-db creates for the unix_socket plugin, which
// rds-init keeps for the platform and never hands to the customer.
mariadbSuperuser = "root"
mariadbProbeConnectTimeoutSeconds = 3
// How much of the probe client's stderr a reason carries, on the same grounds
// as the log tail above.
mariadbProbeStderrMaxBytes = 512
// Named by the platform drop-in rds-init writes, so both halves reach the
// same socket without either asserting it to the other.
mariadbSocketFile = "mysqld.sock"
// The customer reaches the instance over its customer ENI, so the master is
// created and rotated on the wildcard host rather than on localhost.
mariadbMasterHost = "%"
)
// The generated drop-ins on the data volume. The rollback and serving copies
// deliberately do not end in .cnf: !includedir reads *.cnf, and neither is a
// second set of settings.
const (
mariadbParametersFile = "10-rds-parameters.cnf"
mariadbLastGoodFile = "10-rds-parameters.last-good"
mariadbServingFile = "10-rds-parameters.serving"
// MariaDB treats a setting before any group as a fatal parsing error, so
// every generated file carries the header rds-init writes.
mariadbParametersHeader = "[mysqld]\n"
)
// The layout's factory. The rules come from the control plane's own definition
// of this engine, so a build whose control plane does not offer MariaDB refuses
// to run this implementation rather than inventing a definition for it.
func newMariaDBEngineFromCatalog(cfg config, run commandRunner, startSess sessionRunner, probe *engineProbe) (engine, error) {
meta, err := handlers_rds.LookupEngine(engineMariaDB)
if err != nil {
return nil, fmt.Errorf("this image bakes %s, which this build's control plane does not offer: %w", engineMariaDB, err)
}
return newMariaDBEngine(cfg, controlPlaneRulesFrom(meta), run, startSess, probe), nil
}
func newMariaDBEngine(cfg config, rules controlPlaneRules, run commandRunner, startSess sessionRunner, probe *engineProbe) *mariadbEngine {
return &mariadbEngine{
rules: rules,
run: run,
startSess: startSess,
client: filepath.Join(cfg.EngineBinDir, mariadbClientBinary),
socket: mariadbSocketPath(cfg),
rcService: cfg.RCService,
service: cfg.EngineService,
parameterManager: parameterManager{
probe: probe,
params: parameterStore{
// The mount point rather than the datadir one level inside it: the
// include directory has to outlive the sweep a failed bootstrap runs.
dir: filepath.Join(cfg.DataMount, "conf.d"),
installed: mariadbParametersFile,
lastGood: mariadbLastGoodFile,
serving: mariadbServingFile,
header: mariadbParametersHeader,
osUser: cfg.EngineUser,
engine: engineMariaDB,
},
repairTimeout: parameterRepairTimeout,
repairPoll: parameterRepairPoll,
},
}
}
func mariadbSocketPath(cfg config) string {
return filepath.Join(cfg.SocketDir, mariadbSocketFile)
}
// Every local connection, from the probe's ping to a held quiesce session.
// --no-defaults so no option file can move it; --skip-ssl because the serving
// cert names the endpoint rather than this socket, so a verifying client fails.
func mariadbSocketConnectArgs(socket string) []string {
return []string{
"--no-defaults", "--protocol=socket", "--socket=" + socket,
"--user=" + mariadbSuperuser, "--skip-ssl",
}
}
// Reports whether a pid is a live process. EPERM is a process this agent may not
// signal rather than one that is gone, which matters because a stale pidfile
// read as "alive" is far less dangerous than a live engine read as "absent".
type processLivenessFn func(pid int) bool
func processAlive(pid int) bool {
if pid <= 0 {
return false
}
err := syscall.Kill(pid, 0)
return err == nil || errors.Is(err, syscall.EPERM)
}
func newMariaDBProbe(cfg config, run probeRunner) *engineProbe {
return newEngineProbe(cfg.EnginePort, mariadbProbeState(cfg, run, processAlive))
}
// Three stages: during InnoDB crash recovery mariadbd opens neither socket nor
// port, so a ping cannot tell a recovering engine from an absent one — and
// reading recovery as absent has the rollback guard restart a server mid-replay.
func mariadbProbeState(cfg config, run probeRunner, alive processLivenessFn) probeStateFn {
pidFile, socket := cfg.EnginePidFile, mariadbSocketPath(cfg)
admin, client := filepath.Join(cfg.EngineBinDir, mariadbAdminBinary), filepath.Join(cfg.EngineBinDir, mariadbClientBinary)
// The probe alone bounds the connect: it must come back and report, where a
// statement the agent issues is allowed to take as long as the engine needs.
connect := append(mariadbSocketConnectArgs(socket),
fmt.Sprintf("--connect-timeout=%d", mariadbProbeConnectTimeoutSeconds))
probeTimeout := cfg.EngineProbeTimeout
if probeTimeout <= 0 {
probeTimeout = defaultEngineProbeTimeout
}
runBounded := func(ctx context.Context, name string, args ...string) (int, string, error) {
probeCtx, cancel := context.WithTimeout(ctx, probeTimeout)
defer cancel()
return run(probeCtx, name, args...)
}
return func(ctx context.Context, _ int64) (engineState, string) {
pid, err := readPidFile(pidFile)
switch {
case os.IsNotExist(err):
return engineAbsent, fmt.Sprintf("the engine has written no pidfile at %s", pidFile)
case err != nil:
return engineAbsent, fmt.Sprintf("the engine pidfile %s could not be read: %v", pidFile, err)
case !alive(pid):
return engineAbsent, fmt.Sprintf("the engine pidfile %s names pid %d, which is not running", pidFile, pid)
}
// The process is up from here on, so every remaining failure is an engine
// not serving yet rather than one that is gone. Reporting absent against a
// live server would have the rollback guard restart one making progress.
switch code, stderr, err := runBounded(ctx, admin, append(slices.Clone(connect), "ping")...); {
case err != nil:
return engineRecovering, fmt.Sprintf("engine probe could not run: %v", err)
case code != 0:
// A server that is genuinely recovering and one that refused the client
// are indistinguishable from the socket. The server states its side in
// its own log, and the client states its side on stderr.
return engineRecovering, withProbeStderr(withEngineLogTail(
"engine is not answering on its socket yet (startup or crash recovery)",
cfg.EngineErrorLog), stderr)
}
// ping answers even on ER_ACCESS_DENIED, so on its own it certifies a server
// that may be unable to execute anything. The statement rides argv rather
// than stdin: it carries nothing secret and a probe reads no result back.
query := append(slices.Clone(connect), "--batch", "--skip-column-names", "--execute=SELECT 1")
switch code, stderr, err := runBounded(ctx, client, query...); {
case err != nil:
return engineRecovering, fmt.Sprintf("engine probe could not run: %v", err)
case code != 0:
return engineRecovering, withProbeStderr(
"engine answered its socket but would not execute a statement", stderr)
}
return engineServing, ""
}
}
// Appends what the probe client said on stderr. A client that fails during the
// handshake is refused by the server without the server ever logging why, so
// this is the only account of that half of the exchange.
func withProbeStderr(reason, stderr string) string {
stderr = strings.TrimSpace(stderr)
if stderr == "" {
return reason
}
return reason + "; the probe client reported: " + collapseProbeStderr(stderr)
}
// One line, bounded: the reason reaches a customer through StatusInfos, and a
// client that repeats itself per attempt would otherwise crowd out the rest.
func collapseProbeStderr(stderr string) string {
line := strings.Join(strings.Fields(strings.ReplaceAll(stderr, "\n", " ")), " ")
if len(line) > mariadbProbeStderrMaxBytes {
return line[:mariadbProbeStderrMaxBytes] + "..."
}
return line
}
func readPidFile(path string) (int, error) {
raw, err := os.ReadFile(path)
if err != nil {
return 0, err
}
pid, err := strconv.Atoi(strings.TrimSpace(string(raw)))
if err != nil {
return 0, fmt.Errorf("%s does not hold a pid: %w", path, err)
}
return pid, nil
}
func (e *mariadbEngine) Stop(ctx context.Context) error {
return serviceAction(ctx, e.run, e.rcService, e.service, "stop")
}
// Only the parameter rollback and a failed apply call this: a restart the
// control plane wants goes through RebootDBInstance, which cycles the VM. The
// serving copy is refreshed first, since the server starts on what is installed.
func (e *mariadbEngine) Restart(ctx context.Context) error {
if err := e.recordServingCopy(); err != nil {
return err
}
return serviceAction(ctx, e.run, e.rcService, e.service, "restart")
}
// The installed set copied to the name the pending-restart comparison reads. A
// live SET GLOBAL apply deliberately does not touch it: that value was adopted
// without a restart, which is what "not pending" means.
func (e *mariadbEngine) recordServingCopy() error {
content, err := os.ReadFile(e.params.installedPath())
if err != nil {
if os.IsNotExist(err) {
return nil
}
return fmt.Errorf("read the installed parameters: %w", err)
}
return e.params.write(e.params.servingPath(), content)
}
// A value safe to interpolate into a single-quoted SQL literal. The quote is
// doubled, which holds in every sql_mode, and the backslash is doubled, which is
// why every statement built here pins a mode with backslash escapes on.
func sqlLiteral(value string) string {
return strings.ReplaceAll(strings.ReplaceAll(value, `\`, `\\`), `'`, `''`)
}
// The spellings MariaDB parses for a boolean system variable, and the only ones
// the catalog's own enum narrows a boolean to.
var mariadbBooleanValues = map[string]string{
"on": "ON", "off": "OFF", "true": "ON", "false": "OFF", "1": "ON", "0": "OFF",
}
// One SET GLOBAL right-hand side. MariaDB refuses a quoted literal for a numeric
// or boolean with ER_WRONG_TYPE_FOR_VAR, so the catalog's data type picks the
// form; re-rendering from the parsed value is what keeps it safe to interpolate.
func mariadbSetValue(dataType, value string) (string, error) {
switch dataType {
case handlers_rds.ParamTypeInteger:
n, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
if err != nil {
return "", fmt.Errorf("value %q is not an integer", value)
}
return strconv.FormatInt(n, 10), nil
case handlers_rds.ParamTypeReal:
f, err := strconv.ParseFloat(strings.TrimSpace(value), 64)
if err != nil {
return "", fmt.Errorf("value %q is not a real number", value)
}
return strconv.FormatFloat(f, 'g', -1, 64), nil
case handlers_rds.ParamTypeBoolean:
if literal, ok := mariadbBooleanValues[strings.ToLower(strings.TrimSpace(value))]; ok {
return literal, nil
}
return "", fmt.Errorf("value %q is not a boolean", value)
default:
return "'" + sqlLiteral(value) + "'", nil
}
}
// Turns off the two logs that would otherwise copy a statement's text, and pins
// the mode the escaping above assumes. All three are session-scoped: a rotation
// must not leave the customer's own general log switched off behind it.
const mariadbSessionGuard = `SET SESSION sql_log_off = 1;
SET SESSION slow_query_log = 0;
SET SESSION sql_mode = 'NO_ENGINE_SUBSTITUTION';
`
// Rotates the master's password. The statement is built here rather than by the
// client, which has neither psql's identifier interpolation nor its parameter
// quoting, and it rides stdin so it is never visible in the process table.
func (e *mariadbEngine) SetPassword(ctx context.Context, username, password string) error {
if username == "" || password == "" {
return fmt.Errorf("set-password requires both %s and %s",
handlers_rds.CommandParamMasterUsername, handlers_rds.CommandParamMasterUserPassword)
}
// This runs as the server's own superuser, so a reserved name in the payload
// would rotate the platform's account. The whole rule matters here, not just
// the reserved set: the name is interpolated below rather than client-quoted.
if err := e.rules.validateUsername(username); err != nil {
return fmt.Errorf("refusing to set the password of role %q: %w", username, err)
}
escaped := sqlLiteral(password)
sql := fmt.Sprintf("%sALTER USER '%s'@'%s' IDENTIFIED BY '%s';\n",
mariadbSessionGuard, username, mariadbMasterHost, escaped)
if _, err := e.clientRun(ctx, sql); err != nil {
// The client echoes the failing statement, which carries the escaped form
// of the password rather than the raw one — so redacting only the raw one
// would leak exactly the passwords that needed escaping.
return fmt.Errorf("apply the master password: %s", redact(redact(err.Error(), password), escaped))
}
return nil
}
// Installs the resolved set and applies the half a running server can adopt.
// MariaDB re-reads no file while up, so SET GLOBAL is the only immediate path.
// It has no offline parser, so the safety net is the boot-time rollback.
func (e *mariadbEngine) ApplyParameters(ctx context.Context, params []handlers_rds.Parameter) ([]string, error) {
e.paramMu.Lock()
defer e.paramMu.Unlock()
initialState, _ := e.probe.state(ctx)
if initialState == engineRecovering {
return nil, errors.New("apply parameters while the engine is still starting or recovering")
}
if initialState == engineServing {
// A command can arrive before the first heartbeat. Seed the configuration
// currently being served before replacing its file in that window.
if err := e.recordServingParametersLocked(ctx); err != nil {
return nil, fmt.Errorf("record the parameters serving before the apply: %w", err)
}
}
restore, err := e.params.install(params)
if err != nil {
return nil, err
}
state, _ := e.probe.state(ctx)
switch state {
case engineAbsent:
return e.restartOnRepairSetLocked(ctx)
case engineRecovering:
return nil, errors.Join(errors.New("the engine entered startup or recovery during the parameter apply"), restore())
}
// Before the set reaches the server: enforcement is one of the values below,
// and a server told to require a transport it cannot offer would refuse every
// client the statement after.
if err := e.requireServingTLSForEnforcement(ctx); err != nil {
return nil, errors.Join(err, restore())
}
if err := e.applyDynamicParameters(ctx, params); err != nil {
// The engine may have gone down after the first probe. In that case start it
// on the newly installed set rather than restoring one that already left it
// unable to serve.
if state, _ := e.probe.state(ctx); state == engineAbsent {
return e.restartOnRepairSetLocked(ctx)
}
return nil, errors.Join(err, restore())
}
// Last, and on its own: the batch above is not a transaction, so moving the
// TLS posture inside it would leave enforcement changed on a set the server
// went on to refuse, with only the parameter file rolled back.
if err := e.applyTLSEnforcement(ctx); err != nil {
if state, _ := e.probe.state(ctx); state == engineAbsent {
return e.restartOnRepairSetLocked(ctx)
}
return nil, errors.Join(err, restore())
}
return e.pendingRestartParameters(ctx)
}
// The dynamic half, in one invocation that stops at the first refusal so the
// server names the setting it would not take. Only catalog names are emitted,
// which is what makes the identifier safe to build into the statement.
//
// TLS enforcement is excluded: applyTLSEnforcement issues it after this batch
// has succeeded.
func (e *mariadbEngine) applyDynamicParameters(ctx context.Context, params []handlers_rds.Parameter) error {
var b strings.Builder
b.WriteString("SET SESSION sql_mode = 'NO_ENGINE_SUBSTITUTION';\n")
applied := 0
for _, p := range params {
if p.Name == "" || p.Name == e.rules.tlsEnforcementParameter || e.rules.isStatic(p.Name) {
continue
}
value, err := mariadbSetValue(e.rules.dataType(p.Name), p.Value)
if err != nil {
return fmt.Errorf("apply the dynamic parameters: %s: %w", p.Name, err)
}
fmt.Fprintf(&b, "SET GLOBAL %s = %s;\n", p.Name, value)
applied++
}
if applied == 0 {
return nil
}
if _, err := e.clientRun(ctx, b.String()); err != nil {
return fmt.Errorf("apply the dynamic parameters: %w", err)
}
return nil
}
// Puts the installed set's enforcement on the running server. The value comes
// from the installed file rather than the set in hand, so a set that predates
// the parameter enforces here rather than leaving the server on mariadbd's own
// default of off while the API reports enforcement.
func (e *mariadbEngine) applyTLSEnforcement(ctx context.Context) error {
enforce, err := installedTLSEnforcement(e.rules.tlsEnforcementParameter, e.params.installedPath())
if err != nil {
return err
}
previous, err := e.readTLSEnforcement(ctx)
if err != nil {
return err
}
if previous == enforce {
return nil
}
applyErr := e.setTLSEnforcement(ctx, enforce)
if applyErr == nil {
return nil
}
// The client can fail without saying whether the server ran the statement, so
// the posture it left behind is read rather than assumed unchanged: the one
// outcome that must not survive a failed apply is plaintext nobody asked for.
live, readErr := e.readTLSEnforcement(ctx)
if readErr != nil {
return errors.Join(applyErr, readErr)
}
if live == previous {
return applyErr
}
return errors.Join(applyErr, e.setTLSEnforcement(ctx, previous))
}
func (e *mariadbEngine) readTLSEnforcement(ctx context.Context) (bool, error) {
name := e.rules.tlsEnforcementParameter
out, err := e.clientRun(ctx, "SELECT @@global."+name+";\n")
if err != nil {
return false, fmt.Errorf("read whether the engine requires TLS of clients: %w", err)
}
switch value := strings.TrimSpace(out); value {
case "1":
return true, nil
case "0":
return false, nil
default:
return false, fmt.Errorf("the engine reports %s as %q, which is neither 1 nor 0", name, value)
}
}
func (e *mariadbEngine) setTLSEnforcement(ctx context.Context, enforce bool) error {
name, value := e.rules.tlsEnforcementParameter, "0"
if enforce {
value = "1"
}
// Rendered the way the batch renders every other boolean, so the statement the
// server sees does not depend on which path issued it.
literal, err := mariadbSetValue(handlers_rds.ParamTypeBoolean, value)
if err != nil {
return fmt.Errorf("render %s: %w", name, err)
}
sql := fmt.Sprintf("SET SESSION sql_mode = 'NO_ENGINE_SUBSTITUTION';\nSET GLOBAL %s = %s;\n", name, literal)
if _, err := e.clientRun(ctx, sql); err != nil {
return fmt.Errorf("set %s to %s: %w", name, literal, err)
}
return nil
}
// Refuses to require TLS of clients from a server that is not serving it, which
// would reject every TCP connection while the parameter path reported success.
// Read back from the installed file rather than taken from the set in hand, so
// what is checked is also what the server reads at its next start.
func (e *mariadbEngine) requireServingTLSForEnforcement(ctx context.Context) error {
enforce, err := installedTLSEnforcement(e.rules.tlsEnforcementParameter, e.params.installedPath())
if err != nil {
return err
}
if !enforce {
return nil
}
// DISABLED is a server built with TLS and started without a certificate,
// which is what a boot that was handed none leaves behind. The agent's own
// connections are unaffected either way: MariaDB counts a unix socket as a
// secure transport.
out, err := e.clientRun(ctx, "SELECT @@global.have_ssl;\n")
if err != nil {
return fmt.Errorf("read whether the engine is serving TLS: %w", err)
}
if serving := strings.TrimSpace(out); serving != "YES" {
return fmt.Errorf("the engine is serving without TLS (have_ssl = %q), so requiring it of clients would reject every connection", serving)
}
return nil
}
func (e *mariadbEngine) restartOnRepairSetLocked(ctx context.Context) ([]string, error) {
return awaitRepairedEngine(ctx, e.probe, e.Restart, e.pendingRestartParameters, e.repairTimeout, e.repairPoll)
}
// MariaDB has no pending_restart, so this is computed from the files: the
// catalog-static keys whose installed value differs from the one the server
// started on. Live values are not compared — mysqld silently rewrites them.
func (e *mariadbEngine) pendingRestartParameters(context.Context) ([]string, error) {
installed, err := readOptionFile(e.params.installedPath())
if err != nil {
if os.IsNotExist(err) {
// Nothing is installed, so nothing is waiting to be adopted.
return nil, nil
}
return nil, fmt.Errorf("read the installed parameters: %w", err)
}
serving, err := readOptionFile(e.params.servingPath())
if err != nil && !os.IsNotExist(err) {
return nil, fmt.Errorf("read the parameters the engine started on: %w", err)
}
// Back to the catalog's names before anything is classified or reported: the
// files are written in the engine's startup spellings, and the answer is a
// customer-facing list the API refuses ApplyMethod=immediate against.
installed = e.catalogKeyed(installed)
serving = e.catalogKeyed(serving)
// An absent serving copy is a set the engine has not started on: rds-init
// writes the two together on every boot, so the whole static half counts as
// pending rather than being promoted on the strength of a missing file.
var pending []string
for name, value := range installed {
if !e.rules.isStatic(name) {
continue
}
if served, ok := serving[name]; !ok || served != value {
pending = append(pending, name)
}
}
// A static setting the group stopped naming reverts to its default at the next
// start, which the running server has not adopted either.
for name := range serving {
if _, ok := installed[name]; !ok && e.rules.isStatic(name) {
pending = append(pending, name)
}
}
slices.Sort(pending)
return pending, nil
}
// Snapshots the installed set as the rollback target, but only when nothing in
// it is still waiting for a restart. The parameter mutex keeps an apply from
// replacing the file between that check and the copy.
func (e *mariadbEngine) RecordServingParameters(ctx context.Context) error {
e.paramMu.Lock()
defer e.paramMu.Unlock()
return e.recordServingParametersLocked(ctx)
}
func (e *mariadbEngine) recordServingParametersLocked(ctx context.Context) error {
return recordLastGood(ctx, e.params, e.pendingRestartParameters)
}
// Puts the last set the engine accepted back in place, for a start that failed
// after a parameter change.
func (e *mariadbEngine) RestoreLastKnownGoodParameters(ctx context.Context) (bool, error) {
e.paramMu.Lock()
defer e.paramMu.Unlock()
return restoreLastGood(ctx, e.params, e.probe)
}
// The same settings under the names the catalog and the API know them by, since
// a file is written in the engine's startup spellings and those are not always
// the customer's.
func (e *mariadbEngine) catalogKeyed(values map[string]string) map[string]string {
out := make(map[string]string, len(values))
for name, value := range values {
out[e.rules.catalogName(name)] = value
}
return out
}
// The subset of MariaDB's option-file syntax the generated drop-ins are written
// in: a group header, comments, and one `name = value` per line. Nothing else
// reaches these two files, because only rds-init and this agent write them.
func readOptionFile(path string) (map[string]string, error) {
raw, err := os.ReadFile(path)
if err != nil {
return nil, err
}
values := make(map[string]string)
for line := range strings.SplitSeq(string(raw), "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") || strings.HasPrefix(line, "[") {
continue
}
// A bare option such as skip-log-bin carries no value; it is still a setting
// whose presence or absence differs.
name, value, _ := strings.Cut(line, "=")
values[normaliseOptionName(name)] = strings.Trim(strings.TrimSpace(value), `"'`)
}
return values, nil
}
// MariaDB reads - and _ as the same character in an option name, so the two
// spellings of one setting must not compare as two settings.
func normaliseOptionName(name string) string {
return strings.ReplaceAll(strings.TrimSpace(name), "-", "_")
}
// How long any one BACKUP STAGE waits for its metadata locks. Well under the
// control plane's quiesce timeout, so a stage that cannot take its lock is
// reported rather than left queued in front of live traffic.
const mariadbQuiesceLockWait = 20 * time.Second
// MariaDB's own backup API rather than FLUSH TABLES WITH READ LOCK: FTWRL makes
// the whole database read-only for the full hold, and its acquisition phase is
// unbounded. BACKUP STAGE blocks commits instead, and covers Aria and MyISAM.
func (e *mariadbEngine) Quiesce(ctx context.Context, label string, hold time.Duration) error {
if err := validateQuiesceRequest(label, hold); err != nil {
return err
}
// Held across the whole start, so a second quiesce waits and then finds the
// first one's hold rather than opening a concurrent backup alongside it.
e.mu.Lock()
defer e.mu.Unlock()
if e.held != nil {
return fmt.Errorf("the engine is already quiesced for backup %s", e.held.label)
}
// Deliberately not the command's context: the session has to outlive the call
// that started it, and its own deadline is what bounds it instead.
session, err := e.startSess(context.WithoutCancel(ctx), command{
Name: e.client,
Args: e.clientArgs(),
Env: []string{"PATH=" + defaultGuestPath},
SentinelStatement: "SELECT '" + sessionSentinel + "';\n",
})
if err != nil {
return fmt.Errorf("open a backup session: %w", err)
}
// In order and on one connection: MariaDB releases the hold with the session
// that took it, which bounds a control plane that dies mid-snapshot. Fed one
// at a time so a stage that cannot take its locks is named.
stages := []string{
fmt.Sprintf("SET SESSION lock_wait_timeout = %d;\n", int(mariadbQuiesceLockWait.Seconds())),
"BACKUP STAGE START;\n",
"BACKUP STAGE FLUSH;\n",
"BACKUP STAGE BLOCK_DDL;\n",
"BACKUP STAGE BLOCK_COMMIT;\n",
}
for _, sql := range stages {
if err := session.Exec(ctx, sql); err != nil {
if closeErr := session.Close(); closeErr != nil {
slog.Warn("rds-agent: closing a failed backup session", "err", closeErr)
}
return fmt.Errorf("put the engine into backup mode at %q: %w", strings.TrimSpace(sql), err)
}
}
e.beginHoldLocked(label, session, hold)
return nil
}
func (e *mariadbEngine) Unquiesce(ctx context.Context) error {
return e.releaseHold(ctx, "BACKUP STAGE END;\n")
}
// Feeds sql on stdin rather than as an argument, so a statement is never visible
// in the process table.
func (e *mariadbEngine) clientRun(ctx context.Context, sql string) (string, error) {
return e.run(ctx, command{
Name: e.client,
Args: e.clientArgs(),
Env: []string{"PATH=" + defaultGuestPath},
Stdin: sql,
})
}
// --unbuffered so a held session's output reaches the reader as each statement
// finishes rather than when a pipe buffer fills. No User on the command: the
// connection args carry it, and the agent runs as the account they name.
func (e *mariadbEngine) clientArgs() []string {
return append(mariadbSocketConnectArgs(e.socket),
"--batch", "--skip-column-names", "--unbuffered")
}
-553
View File
@@ -1,553 +0,0 @@
package main
import (
"context"
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"strconv"
"strings"
"time"
handlers_rds "github.com/mulgadc/spinifex/spinifex/handlers/rds"
)
// The engine is reached over its unix socket under peer authentication, so the
// agent drops to the postgres OS user rather than holding a password of its own.
type postgresEngine struct {
quiesceState
parameterManager
// The control plane's own metadata for this engine, resolved once at
// startup: the live password apply runs as the cluster superuser, so it
// re-checks the role name against the same reserved set the API validates.
meta handlers_rds.Engine
run commandRunner
startSess sessionRunner
psql string
rcService string
service string
pgData string
socketDir string
osUser string
}
var _ engine = (*postgresEngine)(nil)
// pg_isready gives PostgreSQL three states for free: exit 0 is serving, exit 1
// is a postmaster up and rejecting connections, anything else is an engine that
// is not there. Resolved on PATH, where the client package puts it.
const postgresProbeBinary = "pg_isready"
// The socket directory rather than loopback: the engine binds the customer ENI
// and nothing else, so there is no TCP path for the probe to take. It also keeps
// liveness independent of the network path, of the pg_hba scope and of the TLS
// enforcement rule — libpq reads a host beginning with / as a socket directory,
// and the generated `local ... peer` line covers it.
func newPostgresProbe(cfg config, run probeRunner) *engineProbe {
return newEngineProbe(cfg.EnginePort, postgresProbeState(cfg.SocketDir, cfg.EngineErrorLog, run))
}
func postgresProbeState(host, errorLog string, run probeRunner) probeStateFn {
return func(ctx context.Context, port int64) (engineState, string) {
portArg := strconv.FormatInt(port, 10)
code, _, err := run(ctx, postgresProbeBinary, "-h", host, "-p", portArg, "-q")
switch {
case err != nil:
// A missing binary or broken image. Reporting healthy on the strength of
// nothing would hide it, so this reads as absent like an engine that did
// not answer.
return engineAbsent, fmt.Sprintf("engine probe could not run: %v", err)
case code == 0:
return engineServing, ""
case code == 1:
return engineRecovering, withEngineLogTail(
"engine is rejecting connections (startup or recovery)", errorLog)
default:
// A postmaster that refused a setting never opens the socket, so this is
// the state a bad parameter lands in. The reason for it is only in the
// server's own log, and it reaches the customer through StatusInfos.
return engineAbsent, withEngineLogTail(
fmt.Sprintf("engine did not respond on %s:%s", host, portArg), errorLog)
}
}
}
// The include the resolved set is rendered to, and the copy of the last one the
// engine accepted. Both live beside the data rather than in /etc, matching
// rds-init: a class change boots a fresh root volume and would revert them.
const (
postgresParametersFile = "10-rds-parameters.conf"
// Deliberately not a .conf name: include_dir globs *.conf, so the rollback
// copy must not be read as a second set of settings.
postgresLastGoodFile = "10-rds-parameters.last-good"
)
// The pg_hba rule that implements TLS enforcement, and the directory the
// generated pg_hba includes it from. Its content is a constant, so the copy
// rds-init writes in shell and the one written here have no template to drift
// apart on: enforcement is the presence of the file and nothing more.
const (
postgresHBADir = "hba.d"
postgresForceSSLRuleFile = "20-rds-force-ssl.conf"
// An explicit reject rather than an omitted permit: both refuse the
// connection, and only this one puts the reason in the server log and in the
// client's error.
postgresForceSSLRules = `# Managed by the platform: the rule that requires TLS of client connections.
# Written by rds-init at boot and by rds-agent on a parameter apply.
hostnossl all all 0.0.0.0/0 reject
hostnossl all all ::/0 reject
`
// What the read-back must count once the file is in place. Asserting the shape
// rather than only the absence of parse errors is what catches the failure
// that will actually happen: the derivation above writing the wrong thing.
postgresForceSSLRuleCount = 2
)
// The layout's factory resolves the control plane metadata during startup.
func newPostgresEngineFromCatalog(cfg config, run commandRunner, startSess sessionRunner, probe *engineProbe) (engine, error) {
meta, err := handlers_rds.LookupEngine(enginePostgres)
if err != nil {
return nil, fmt.Errorf("this image bakes %s, which this build's control plane does not offer: %w", enginePostgres, err)
}
return newPostgresEngine(cfg, meta, run, startSess, probe), nil
}
func newPostgresEngine(cfg config, meta handlers_rds.Engine, run commandRunner, startSess sessionRunner, probe *engineProbe) *postgresEngine {
return &postgresEngine{
meta: meta,
run: run,
startSess: startSess,
psql: filepath.Join(cfg.EngineBinDir, "psql"),
rcService: cfg.RCService,
service: cfg.EngineService,
pgData: cfg.EngineDataDir,
socketDir: cfg.SocketDir,
osUser: cfg.EngineUser,
parameterManager: parameterManager{
probe: probe,
params: parameterStore{
dir: filepath.Join(cfg.EngineDataDir, "conf.d"),
installed: postgresParametersFile,
lastGood: postgresLastGoodFile,
osUser: cfg.EngineUser,
engine: enginePostgres,
},
repairTimeout: parameterRepairTimeout,
repairPoll: parameterRepairPoll,
},
}
}
// The role name and the password ride the environment and are re-quoted by
// psql, so neither reaches a shell word or an argv another process can read.
func (e *postgresEngine) SetPassword(ctx context.Context, username, password string) error {
if username == "" || password == "" {
return fmt.Errorf("set-password requires both %s and %s",
handlers_rds.CommandParamMasterUsername, handlers_rds.CommandParamMasterUserPassword)
}
// The statement below runs as the cluster superuser over the socket under
// peer auth, so a reserved name in the command payload would hand the
// customer the bootstrap superuser rather than rotate their own role.
if err := e.meta.ValidateUsernameNotReserved(username); err != nil {
return fmt.Errorf("refusing to set the password of a role the engine reserves: %w", err)
}
// psql interpolates the password into the ALTER ROLE before the server sees
// it, and these three are what would write it to the log — the last on any
// failure at its own default. SUSET, so the parameter group cannot win.
const sql = `SET log_statement = 'none';
SET log_min_duration_statement = -1;
SET log_min_error_statement = 'panic';
\getenv master RDS_MASTER_USERNAME
\getenv password RDS_MASTER_PASSWORD
ALTER ROLE :"master" WITH LOGIN PASSWORD :'password';
`
_, err := e.psqlRun(ctx, sql,
"RDS_MASTER_USERNAME="+username,
"RDS_MASTER_PASSWORD="+password,
)
if err != nil {
// psql echoes the failing statement, which here would carry the new
// password back to the control plane and into the event ring.
return fmt.Errorf("apply the master password: %s", redact(err.Error(), password))
}
return nil
}
// Installs the resolved set, validates it with the engine's own parser before
// the engine adopts it, and reloads. A refused value is rolled back here rather
// than left on the data volume, where it would survive every VM replace.
func (e *postgresEngine) ApplyParameters(ctx context.Context, params []handlers_rds.Parameter) ([]string, error) {
e.paramMu.Lock()
defer e.paramMu.Unlock()
initialState, _ := e.probe.state(ctx)
if initialState == engineRecovering {
return nil, errors.New("apply parameters while the engine is still starting or recovering")
}
if initialState == engineServing {
// A command can arrive before the first heartbeat. Seed the configuration
// currently being served before replacing its file in that window.
if err := e.recordServingParametersLocked(ctx); err != nil {
return nil, fmt.Errorf("record the parameters serving before the apply: %w", err)
}
}
// The check runs against the file in place, because the engine parses the
// datadir's own include_dir. The window that leaves is closed by the rollback
// below and the last-known-good restore the agent runs at boot.
restoreParameters, err := e.params.install(params)
if err != nil {
return nil, err
}
if err := e.checkConfig(ctx); err != nil {
return nil, errors.Join(fmt.Errorf("the engine rejected the parameter set: %w", err), restoreParameters())
}
state, _ := e.probe.state(ctx)
switch state {
case engineAbsent:
return e.restartOnRepairSetLocked(ctx)
case engineRecovering:
return nil, errors.Join(errors.New("the engine entered startup or recovery during the parameter apply"), restoreParameters())
}
// After the engine's own parser has passed the set and before the reload that
// adopts both files at once.
enforce, restoreEnforcement, err := e.syncTLSEnforcement(ctx, state)
if err != nil {
return nil, errors.Join(fmt.Errorf("apply the TLS enforcement of the parameter set: %w", err), restoreParameters())
}
// The two files carry one apply from here: a rejected set withdrawn without
// its enforcement rule would leave the instance serving the old parameters
// under the new set's TLS posture.
restore := func() error { return errors.Join(restoreEnforcement(), restoreParameters()) }
if err := e.reload(ctx); err != nil {
// The engine may have gone down after the first probe. In that case start
// it on the new, parser-checked repair set rather than restoring the set
// that already left it unable to serve.
if state, _ := e.probe.state(ctx); state == engineAbsent {
return e.restartOnRepairSetLocked(ctx)
}
return nil, errors.Join(err, restore())
}
if err := e.verifyTLSEnforcement(ctx, enforce); err != nil {
// The reload already happened, so putting the files back is not enough: a
// pg_hba that parsed but says the wrong thing has been adopted, and only a
// second reload takes the engine off it.
return nil, errors.Join(err, restore(), e.reload(ctx))
}
return e.pendingRestartParameters(ctx)
}
// Re-derived before the restart: the engine reads pg_hba when it starts, so a
// repair set started under the previous set's enforcement would serve one TLS
// posture while the API reported the other.
func (e *postgresEngine) restartOnRepairSetLocked(ctx context.Context) ([]string, error) {
if _, _, err := e.syncTLSEnforcement(ctx, engineAbsent); err != nil {
return nil, fmt.Errorf("apply the TLS enforcement of the repair set: %w", err)
}
return awaitRepairedEngine(ctx, e.probe, e.Restart, e.pendingRestartParameters, e.repairTimeout, e.repairPoll)
}
// The function's own answer is the reload, not psql's exit status: it returns f
// when it could not signal the postmaster, and the read-back that follows parses
// pg_hba from disk, so an unsignalled reload would otherwise verify clean.
func (e *postgresEngine) reload(ctx context.Context) error {
out, err := e.psqlRun(ctx, "SELECT pg_reload_conf();\n")
if err != nil {
return fmt.Errorf("reload the engine configuration: %w", err)
}
if result := strings.TrimSpace(out); result != "t" {
return fmt.Errorf("reload the engine configuration: pg_reload_conf() returned %q", result)
}
return nil
}
// The postmaster's own answer: a static setting it has stored but not adopted is
// what it reports here, so nothing in the guest has to classify one.
func (e *postgresEngine) pendingRestartParameters(ctx context.Context) ([]string, error) {
out, err := e.psqlRun(ctx, "SELECT name FROM pg_settings WHERE pending_restart ORDER BY name;\n")
if err != nil {
return nil, fmt.Errorf("read the settings pending a restart: %w", err)
}
var pending []string
for line := range strings.SplitSeq(out, "\n") {
if name := strings.TrimSpace(line); name != "" {
pending = append(pending, name)
}
}
return pending, nil
}
// Snapshots the include only when the running postmaster has no settings still
// pending a restart. The parameter mutex keeps an apply from replacing the file
// between that check and the copy.
func (e *postgresEngine) RecordServingParameters(ctx context.Context) error {
e.paramMu.Lock()
defer e.paramMu.Unlock()
return e.recordServingParametersLocked(ctx)
}
func (e *postgresEngine) recordServingParametersLocked(ctx context.Context) error {
return recordLastGood(ctx, e.params, e.pendingRestartParameters)
}
// Puts the last set the engine accepted back in place, for a restart that failed
// after a parameter change.
func (e *postgresEngine) RestoreLastKnownGoodParameters(ctx context.Context) (bool, error) {
e.paramMu.Lock()
defer e.paramMu.Unlock()
restored, err := restoreLastGood(ctx, e.params, e.probe)
if err != nil || !restored {
return restored, err
}
// A service restart does not re-run rds-init, so a restore that stopped at the
// parameter file would bring the last known good set up under the enforcement
// of the set that was just rejected. restoreLastGood only replaces the file of
// an engine that is down.
if _, _, err := e.syncTLSEnforcement(ctx, engineAbsent); err != nil {
return true, fmt.Errorf("apply the TLS enforcement of the restored parameter set: %w", err)
}
return true, nil
}
// Turns the installed enforcement value into the pg_hba fact that implements it.
// PostgreSQL has no server setting for this, so the parameter file on its own
// would report the change applied and enforce nothing.
//
// The serving-TLS guard runs only against an engine that is up. The restore and
// repair paths reach this with the engine down, where the rule is read by the
// start that follows rather than by a reload.
func (e *postgresEngine) syncTLSEnforcement(ctx context.Context, state engineState) (enforce bool, restore func() error, err error) {
enforce, err = installedTLSEnforcement(e.meta.TLSEnforcementParameter(), e.params.installedPath())
if err != nil {
return false, nil, err
}
if enforce && state == engineServing {
if err := e.requireServingTLS(ctx); err != nil {
return false, nil, err
}
}
restore, err = e.writeTLSEnforcement(enforce)
if err != nil {
return false, nil, err
}
return enforce, restore, nil
}
// Writes or removes the enforcement rule, returning a func putting back whatever
// was there. Removing matters as much as writing: a restored data volume carries
// hba.d with it, so a stale rule would keep enforcing under a parameter group
// that turns enforcement off.
func (e *postgresEngine) writeTLSEnforcement(enforce bool) (func() error, error) {
path := e.forceSSLRulePath()
previous, readErr := os.ReadFile(path)
if readErr != nil && !os.IsNotExist(readErr) {
return nil, fmt.Errorf("read the TLS enforcement rule at %s: %w", path, readErr)
}
existed := readErr == nil
restore := func() error {
if !existed {
return e.removeTLSEnforcement()
}
return e.params.write(path, previous)
}
if !enforce {
if err := e.removeTLSEnforcement(); err != nil {
return nil, err
}
return restore, nil
}
// rds-init lays the directory down on every boot, so this covers an agent that
// reached an apply before one ran rather than the ordinary case.
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o700); err != nil {
return nil, fmt.Errorf("create %s: %w", dir, err)
}
if err := e.params.chownToEngine(dir); err != nil {
return nil, err
}
if err := e.params.write(path, []byte(postgresForceSSLRules)); err != nil {
return nil, err
}
return restore, nil
}
func (e *postgresEngine) removeTLSEnforcement() error {
path := e.forceSSLRulePath()
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("withdraw the TLS enforcement rule at %s: %w", path, err)
}
return nil
}
func (e *postgresEngine) forceSSLRulePath() string {
return filepath.Join(e.pgData, postgresHBADir, postgresForceSSLRuleFile)
}
// Refuses to require TLS of clients from an engine that is not serving it, which
// would reject every connection while the parameter path reported success.
func (e *postgresEngine) requireServingTLS(ctx context.Context) error {
out, err := e.psqlRun(ctx, "SHOW ssl;\n")
if err != nil {
return fmt.Errorf("read whether the engine is serving TLS: %w", err)
}
if serving := strings.TrimSpace(out); serving != "on" {
return fmt.Errorf("the engine is serving without TLS (ssl = %q), so requiring it of clients would reject every connection", serving)
}
return nil
}
// PostgreSQL discards a pg_hba it cannot parse on reload and keeps the rules it
// is already serving, which presents as an apply that succeeded and enforces
// nothing. So the file is read back.
//
// pg_hba_file_rules re-parses from disk, so this proves the file would be
// adopted rather than that the postmaster has adopted it. The inference holds
// because a reloaded pg_hba is discarded only on a parse error.
const postgresHBAReadBack = `SELECT count(*) FILTER (WHERE type = 'hostnossl' AND auth_method = 'reject'),
count(*) FILTER (WHERE error IS NOT NULL) FROM pg_hba_file_rules;
`
func (e *postgresEngine) verifyTLSEnforcement(ctx context.Context, enforce bool) error {
out, err := e.psqlRun(ctx, postgresHBAReadBack)
if err != nil {
return fmt.Errorf("read back the client authentication rules: %w", err)
}
rules, unparsable, err := parseHBAReadBack(out)
if err != nil {
return err
}
if unparsable > 0 {
return fmt.Errorf("the client authentication file carries %d rules the engine could not parse, so it would keep the rules it is already serving", unparsable)
}
want := 0
if enforce {
want = postgresForceSSLRuleCount
}
if rules != want {
return fmt.Errorf("the client authentication file carries %d TLS enforcement rules, want %d", rules, want)
}
return nil
}
func parseHBAReadBack(out string) (rules, unparsable int, err error) {
answer := strings.TrimSpace(out)
malformed := fmt.Errorf("the client authentication read-back returned %q, want two counts", answer)
counts := strings.Split(answer, "|")
if len(counts) != 2 {
return 0, 0, malformed
}
parsed := make([]int, len(counts))
for i, count := range counts {
if parsed[i], err = strconv.Atoi(strings.TrimSpace(count)); err != nil {
return 0, 0, malformed
}
}
return parsed[0], parsed[1], nil
}
// The engine's own parser, run offline against the datadir. Reading one setting
// back is enough: postgres parses every include first and exits non-zero naming
// the file and line of an unknown parameter or an out-of-range value.
func (e *postgresEngine) checkConfig(ctx context.Context) error {
if _, err := e.run(ctx, command{
Name: filepath.Join(filepath.Dir(e.psql), "postgres"),
Args: []string{"-D", e.pgData, "-C", "shared_buffers"},
Env: []string{"PATH=" + defaultGuestPath},
User: e.osUser,
}); err != nil {
return err
}
return nil
}
func (e *postgresEngine) Stop(ctx context.Context) error {
return serviceAction(ctx, e.run, e.rcService, e.service, "stop")
}
// Only the parameter rollback calls this: a restart the control plane wants goes
// through RebootDBInstance, which cycles the VM.
func (e *postgresEngine) Restart(ctx context.Context) error {
return serviceAction(ctx, e.run, e.rcService, e.service, "restart")
}
// Feeds sql on stdin rather than as an argument, so a statement is never
// visible in the process table.
func (e *postgresEngine) psqlRun(ctx context.Context, sql string, env ...string) (string, error) {
return e.run(ctx, command{
Name: e.psql,
Args: e.psqlArgs(),
Env: append([]string{"PATH=" + defaultGuestPath}, env...),
Stdin: sql,
User: e.osUser,
})
}
// Reading the script from stdin is what lets one invocation serve both a
// one-shot run and a session held open across several statements.
func (e *postgresEngine) psqlArgs() []string {
return []string{
"--no-psqlrc", "--quiet", "--no-align", "--tuples-only",
"-v", "ON_ERROR_STOP=1",
"-h", e.socketDir,
"-p", strconv.FormatInt(e.probe.port.Load(), 10),
"-U", e.osUser,
"-d", "postgres",
"-f", "-",
}
}
// Puts the engine into backup mode: the datadir is checkpointed and the engine
// stops writing over the pages a snapshot is about to read. The hold is released
// by Unquiesce, or by its own deadline, whichever comes first.
func (e *postgresEngine) Quiesce(ctx context.Context, label string, hold time.Duration) error {
if err := validateQuiesceRequest(label, hold); err != nil {
return err
}
// Held across the whole start, so a second quiesce waits and then finds the
// first one's hold rather than opening a concurrent backup alongside it.
e.mu.Lock()
defer e.mu.Unlock()
if e.held != nil {
return fmt.Errorf("the engine is already quiesced for backup %s", e.held.label)
}
// Deliberately not the command's context: the session has to outlive the call
// that started it, and its own deadline is what bounds it instead.
session, err := e.startSess(context.WithoutCancel(ctx), command{
Name: e.psql,
Args: e.psqlArgs(),
Env: []string{"PATH=" + defaultGuestPath, "RDS_BACKUP_LABEL=" + label},
User: e.osUser,
SentinelStatement: `\echo ` + sessionSentinel + "\n",
})
if err != nil {
return fmt.Errorf("open a backup session: %w", err)
}
// fast forces an immediate checkpoint rather than spreading it over the
// checkpoint interval, which would hold the snapshot open for minutes.
const sql = `\getenv label RDS_BACKUP_LABEL
SELECT pg_backup_start(:'label', fast => true);
`
if err := session.Exec(ctx, sql); err != nil {
if closeErr := session.Close(); closeErr != nil {
slog.Warn("rds-agent: closing a failed backup session", "err", closeErr)
}
return fmt.Errorf("put the engine into backup mode: %w", err)
}
e.beginHoldLocked(label, session, hold)
return nil
}
func (e *postgresEngine) Unquiesce(ctx context.Context) error {
return e.releaseHold(ctx, "SELECT pg_backup_stop();\n")
}
-697
View File
@@ -1,697 +0,0 @@
package main
import (
"context"
"errors"
"fmt"
"os"
"os/user"
"path/filepath"
"slices"
"strings"
"testing"
"time"
handlers_rds "github.com/mulgadc/spinifex/spinifex/handlers/rds"
)
// Stands in for the engine in the registry tests, where what matters is that a
// command reaches the right method with the right arguments.
type fakeEngine struct {
username string
password string
params []handlers_rds.Parameter
pending []string
stopped bool
label string
hold time.Duration
released bool
err error
}
var _ engineOps = (*fakeEngine)(nil)
func (f *fakeEngine) SetPassword(_ context.Context, username, password string) error {
f.username, f.password = username, password
return f.err
}
func (f *fakeEngine) ApplyParameters(_ context.Context, params []handlers_rds.Parameter) ([]string, error) {
f.params = params
return f.pending, f.err
}
func (f *fakeEngine) Stop(context.Context) error {
f.stopped = true
return f.err
}
func (f *fakeEngine) Quiesce(_ context.Context, label string, hold time.Duration) error {
f.label, f.hold = label, hold
return f.err
}
func (f *fakeEngine) Unquiesce(context.Context) error {
f.released = true
return f.err
}
func TestCommandRegistry_SetPasswordCarriesBothParameters(t *testing.T) {
engine := &fakeEngine{}
reply := newCommander(nil, newCommandRegistry(engine, &fakeStorage{}), 0).execute(context.Background(), handlers_rds.Command{
CommandID: "cmd-1",
Type: handlers_rds.CommandSetPassword,
Parameters: []handlers_rds.Parameter{
{Name: handlers_rds.CommandParamMasterUsername, Value: "mulgamaster"},
{Name: handlers_rds.CommandParamMasterUserPassword, Value: "n3w-pw"},
},
})
if reply.Status != handlers_rds.CommandStatusSucceeded {
t.Fatalf("reply = %+v, want succeeded", reply)
}
if engine.username != "mulgamaster" || engine.password != "n3w-pw" {
t.Errorf("engine got %q/%q, want mulgamaster/n3w-pw", engine.username, engine.password)
}
// The reply is recorded by the control plane; the new password must not be
// in it.
if strings.Contains(reply.Message, "n3w-pw") {
t.Errorf("reply message %q carries the new password", reply.Message)
}
}
// CommandReply has no structured payload, so the settings still awaiting a
// restart come back as the message the issuer parses.
func TestCommandRegistry_ApplyParamsReportsPendingRestart(t *testing.T) {
engine := &fakeEngine{pending: []string{"shared_buffers", "max_connections"}}
reply := newCommander(nil, newCommandRegistry(engine, &fakeStorage{}), 0).execute(context.Background(), handlers_rds.Command{
CommandID: "cmd-2",
Type: handlers_rds.CommandApplyParams,
Parameters: []handlers_rds.Parameter{{Name: "shared_buffers", Value: "256MB"}},
})
if reply.Status != handlers_rds.CommandStatusSucceeded {
t.Fatalf("reply = %+v, want succeeded", reply)
}
if reply.Message != "shared_buffers,max_connections" {
t.Errorf("reply message = %q, want the pending-restart list", reply.Message)
}
if len(engine.params) != 1 || engine.params[0].Name != "shared_buffers" {
t.Errorf("engine got %+v, want the command's parameters", engine.params)
}
}
func TestCommandRegistry_StopEngineReachesTheEngine(t *testing.T) {
engine := &fakeEngine{}
reply := newCommander(nil, newCommandRegistry(engine, &fakeStorage{}), 0).execute(context.Background(),
handlers_rds.Command{CommandID: "cmd-3", Type: handlers_rds.CommandStopEngine})
if reply.Status != handlers_rds.CommandStatusSucceeded {
t.Fatalf("reply = %+v, want succeeded", reply)
}
if !engine.stopped {
t.Error("stop-engine did not reach the engine")
}
}
func TestCommandRegistry_FailureRepliesFailed(t *testing.T) {
engine := &fakeEngine{err: errors.New("the engine did not stop")}
reply := newCommander(nil, newCommandRegistry(engine, &fakeStorage{}), 0).execute(context.Background(),
handlers_rds.Command{CommandID: "cmd-4", Type: handlers_rds.CommandStopEngine})
if reply.Status != handlers_rds.CommandStatusFailed {
t.Fatalf("reply = %+v, want failed", reply)
}
if !strings.Contains(reply.Message, "the engine did not stop") {
t.Errorf("reply message = %q, want the engine's error", reply.Message)
}
}
// Records what a postgresEngine would have run, so the SQL and the argv are
// asserted without a live cluster.
type recordingRunner struct {
calls []command
out string
err error
}
func (r *recordingRunner) run(_ context.Context, c command) (string, error) {
r.calls = append(r.calls, c)
return r.out, r.err
}
func testPostgresEngineMeta(t *testing.T) handlers_rds.Engine {
t.Helper()
meta, err := handlers_rds.LookupEngine(enginePostgres)
if err != nil {
t.Fatalf("LookupEngine(%s): %v", enginePostgres, err)
}
return meta
}
func newTestEngine(t *testing.T, run commandRunner) *postgresEngine {
t.Helper()
cfg := testEngineConfig(t)
return newPostgresEngine(cfg, testPostgresEngineMeta(t), withPostgresReadBacks(cfg.EngineDataDir, run), nil, newPostgresProbe(cfg, staticProbe(0)))
}
// The same engine with the read-backs left to the runner, for the cases that are
// about what the engine does with each answer rather than about the rest of an
// apply.
func newScriptedTestEngine(t *testing.T, run commandRunner) *postgresEngine {
t.Helper()
cfg := testEngineConfig(t)
return newPostgresEngine(cfg, testPostgresEngineMeta(t), run, nil, newPostgresProbe(cfg, staticProbe(0)))
}
func testEngineConfig(t *testing.T) config {
t.Helper()
cfg := testLoadConfig(t, enginePostgres)
cfg.EngineDataDir = t.TempDir()
if err := os.MkdirAll(filepath.Join(cfg.EngineDataDir, "conf.d"), 0o700); err != nil {
t.Fatalf("create conf.d: %v", err)
}
// The guest's postgres user does not exist here, and the chown of the
// installed parameter file has to resolve to something real.
current, err := user.Current()
if err != nil {
t.Fatalf("resolve the current user: %v", err)
}
cfg.EngineUser = current.Username
return cfg
}
// Answers the read-backs every apply runs against a live postmaster, from the
// datadir the case is working in: an engine serving TLS, a reload the postmaster
// took, and a pg_hba carrying whatever the agent last wrote. The call still
// reaches the runner, so a case counting commands sees them; one that is about
// the read-backs themselves builds its own engine.
func withPostgresReadBacks(dataDir string, run commandRunner) commandRunner {
rule := filepath.Join(dataDir, postgresHBADir, postgresForceSSLRuleFile)
return func(ctx context.Context, c command) (string, error) {
out, err := run(ctx, c)
if err != nil {
return out, err
}
switch {
case strings.Contains(c.Stdin, "SHOW ssl"):
return "on\n", nil
case strings.Contains(c.Stdin, "pg_reload_conf"):
return "t\n", nil
case strings.Contains(c.Stdin, "pg_hba_file_rules"):
rules := 0
if _, statErr := os.Stat(rule); statErr == nil {
rules = postgresForceSSLRuleCount
}
return fmt.Sprintf("%d|0\n", rules), nil
}
return out, err
}
}
// The password reaches psql through the environment and is re-quoted there,
// so it is never a shell word or an argv another process can read.
func TestPostgresEngine_SetPasswordKeepsTheSecretOutOfArgv(t *testing.T) {
runner := &recordingRunner{}
engine := newTestEngine(t, runner.run)
if err := engine.SetPassword(context.Background(), "mulgamaster", "n3w-pw"); err != nil {
t.Fatalf("SetPassword: %v", err)
}
if len(runner.calls) != 1 {
t.Fatalf("ran %d commands, want 1", len(runner.calls))
}
call := runner.calls[0]
for _, arg := range call.Args {
if strings.Contains(arg, "n3w-pw") {
t.Fatalf("argv %v carries the password", call.Args)
}
}
if strings.Contains(call.Stdin, "n3w-pw") {
t.Errorf("SQL %q carries the password literally, want it read from the environment", call.Stdin)
}
if !slices.Contains(call.Env, "RDS_MASTER_PASSWORD=n3w-pw") {
t.Errorf("env %v does not carry the password", call.Env)
}
if !strings.Contains(call.Stdin, "ALTER ROLE") {
t.Errorf("SQL %q does not alter the role", call.Stdin)
}
}
// psql interpolates the password into the ALTER ROLE before the server sees it,
// so a parameter group that turns statement logging on would write the rotated
// password into a postmaster.log every snapshot then carries.
func TestPostgresEngine_SetPasswordSilencesStatementLogging(t *testing.T) {
runner := &recordingRunner{}
engine := newTestEngine(t, runner.run)
if err := engine.SetPassword(context.Background(), "mulgamaster", "n3w-pw"); err != nil {
t.Fatalf("SetPassword: %v", err)
}
if len(runner.calls) != 1 {
t.Fatalf("ran %d commands, want 1", len(runner.calls))
}
sql := runner.calls[0].Stdin
alter := strings.Index(sql, "ALTER ROLE")
if alter < 0 {
t.Fatalf("SQL %q does not alter the role", sql)
}
for _, guard := range []string{
"SET log_statement = 'none';",
"SET log_min_duration_statement = -1;",
"SET log_min_error_statement = 'panic';",
} {
at := strings.Index(sql, guard)
if at < 0 {
t.Errorf("SQL %q is missing %q", sql, guard)
continue
}
if at > alter {
t.Errorf("SQL %q sets %q only after the ALTER ROLE has already been logged", sql, guard)
}
}
}
// psql echoes the failing statement, which here would carry the new password
// back into a reply the control plane records.
func TestPostgresEngine_SetPasswordRedactsTheSecretFromAFailure(t *testing.T) {
runner := &recordingRunner{err: errors.New(`ERROR: syntax error at or near "n3w-pw"`)}
engine := newTestEngine(t, runner.run)
err := engine.SetPassword(context.Background(), "mulgamaster", "n3w-pw")
if err == nil {
t.Fatal("SetPassword succeeded against a failing psql")
}
if strings.Contains(err.Error(), "n3w-pw") {
t.Errorf("error %q leaks the password", err)
}
if !strings.Contains(err.Error(), "[REDACTED]") {
t.Errorf("error %q does not mark the redaction", err)
}
}
func TestPostgresEngine_SetPasswordRejectsAnIncompleteCommand(t *testing.T) {
runner := &recordingRunner{}
engine := newTestEngine(t, runner.run)
if err := engine.SetPassword(context.Background(), "mulgamaster", ""); err == nil {
t.Error("SetPassword accepted an empty password")
}
if len(runner.calls) != 0 {
t.Errorf("ran %d commands for an incomplete request, want 0", len(runner.calls))
}
}
// The apply runs as the cluster superuser under peer auth, so a reserved
// name arriving in a command payload would rotate the bootstrap superuser's
// password rather than the customer's own role.
func TestPostgresEngine_SetPasswordRefusesReservedRoles(t *testing.T) {
for _, username := range []string{"postgres", "rds_superuser", "rdsadmin", "pg_toast_owner", "PostGres"} {
t.Run(username, func(t *testing.T) {
runner := &recordingRunner{}
engine := newTestEngine(t, runner.run)
err := engine.SetPassword(context.Background(), username, "n3w-pw")
if err == nil {
t.Fatalf("SetPassword accepted the reserved role %q", username)
}
if len(runner.calls) != 0 {
t.Errorf("ran %d commands for a reserved role, want 0", len(runner.calls))
}
})
}
}
// The parameters land where rds-init installs them, so the next boot overwrites
// the file rather than shadowing it with a second copy.
func TestPostgresEngine_ApplyParametersInstallsAndReloads(t *testing.T) {
runner := &recordingRunner{out: "shared_buffers\nmax_connections\n"}
engine := newTestEngine(t, runner.run)
pending, err := engine.ApplyParameters(context.Background(),
[]handlers_rds.Parameter{{Name: "shared_buffers", Value: "256MB"}})
if err != nil {
t.Fatalf("ApplyParameters: %v", err)
}
installed, err := os.ReadFile(filepath.Join(engine.pgData, "conf.d", "10-rds-parameters.conf"))
if err != nil {
t.Fatalf("read the installed parameters: %v", err)
}
if !strings.Contains(string(installed), "shared_buffers = '256MB'") {
t.Errorf("installed parameters = %q, want the resolved value", installed)
}
if len(runner.calls) != 5 {
t.Fatalf("ran %d commands, want a config check, the TLS guard, a reload, the rule read-back and a pending-restart read", len(runner.calls))
}
// The check and the TLS guard both run before the reload, so neither a value
// the engine would refuse nor a rule rejecting every client reaches a running
// cluster. The read-back of the rules comes after, since the reload is what
// puts them in force.
if !slices.Contains(runner.calls[0].Args, "-C") {
t.Errorf("first command = %v, want the offline config check", runner.calls[0].Args)
}
if !strings.Contains(runner.calls[1].Stdin, "SHOW ssl") {
t.Errorf("second statement = %q, want the serving-TLS guard", runner.calls[1].Stdin)
}
if !strings.Contains(runner.calls[2].Stdin, "pg_reload_conf") {
t.Errorf("third statement = %q, want the reload", runner.calls[2].Stdin)
}
if !strings.Contains(runner.calls[3].Stdin, "pg_hba_file_rules") {
t.Errorf("fourth statement = %q, want the client authentication read-back", runner.calls[3].Stdin)
}
if !strings.Contains(runner.calls[4].Stdin, "pending_restart") {
t.Errorf("fifth statement = %q, want the pending-restart read", runner.calls[4].Stdin)
}
if len(pending) != 2 || pending[0] != "shared_buffers" || pending[1] != "max_connections" {
t.Errorf("pending = %v, want both settings the engine reported", pending)
}
}
// A reload that failed leaves the engine on its old values, so reporting the
// apply as successful would tell the customer a change took effect that did not.
func TestPostgresEngine_ApplyParametersSurfacesAFailedReload(t *testing.T) {
runner := &reloadFailingRunner{reloadErr: errors.New("reload was rejected")}
engine := newTestEngine(t, runner.run)
if _, err := engine.ApplyParameters(context.Background(),
[]handlers_rds.Parameter{{Name: "work_mem", Value: "8MB"}}); err == nil {
t.Fatal("ApplyParameters succeeded against a failing reload")
}
}
// pg_reload_conf() returns f when it could not signal the postmaster, and psql
// exits 0 all the same. Every read-back after it parses files from disk, so an
// apply that trusted the exit status would verify clean against a server still
// serving the old rules.
func TestPostgresEngine_ApplyParametersSurfacesAnUnsignalledReload(t *testing.T) {
runner := &tlsReadBackRunner{ssl: "on", ruleRows: postgresForceSSLRuleCount, reload: "f"}
engine := newScriptedTestEngine(t, runner.run)
_, err := engine.ApplyParameters(context.Background(),
[]handlers_rds.Parameter{{Name: "work_mem", Value: "8MB"}})
if err == nil || !strings.Contains(err.Error(), `pg_reload_conf() returned "f"`) {
t.Fatalf("ApplyParameters error = %v, want a refusal naming the answer the postmaster gave", err)
}
}
type reloadFailingRunner struct {
recordingRunner
reloadErr error
}
func (r *reloadFailingRunner) run(ctx context.Context, c command) (string, error) {
if strings.Contains(c.Stdin, "pg_reload_conf") {
r.calls = append(r.calls, c)
return "", r.reloadErr
}
return r.recordingRunner.run(ctx, c)
}
// A failed instance cannot reload through psql. Its corrected, parser-checked
// include must start the service and remain installed as the serving set.
func TestPostgresEngine_ApplyParametersRestartsOnARepairSetWhileEngineIsDown(t *testing.T) {
runner := &recordingRunner{}
engine := newTestEngine(t, runner.run)
codes := []int{2, 2, 0}
probeCall := 0
cfg := testProbeConfig()
engine.probe = newPostgresProbe(cfg, func(context.Context, string, ...string) (int, string, error) {
code := codes[min(probeCall, len(codes)-1)]
probeCall++
return code, "", nil
})
engine.repairTimeout = 100 * time.Millisecond
engine.repairPoll = time.Millisecond
params := []handlers_rds.Parameter{
{Name: "shared_buffers", Value: "32768"},
{Name: "work_mem", Value: "8192"},
}
pending, err := engine.ApplyParameters(t.Context(), params)
if err != nil {
t.Fatalf("ApplyParameters: %v", err)
}
if len(pending) != 0 {
t.Errorf("pending = %v, want the restarted engine's actual pending set", pending)
}
if len(runner.calls) != 3 {
t.Fatalf("calls = %+v, want config check, service restart and pending read", runner.calls)
}
if !slices.Contains(runner.calls[0].Args, "-C") {
t.Errorf("first call = %+v, want the offline config check", runner.calls[0])
}
if !slices.Equal(runner.calls[1].Args, []string{"postgresql", "restart"}) {
t.Errorf("second call args = %v, want the service restart", runner.calls[1].Args)
}
if !strings.Contains(runner.calls[2].Stdin, "pending_restart") {
t.Errorf("third call = %+v, want the pending-restart read", runner.calls[2])
}
installed, err := os.ReadFile(engine.params.installedPath())
if err != nil {
t.Fatalf("read installed parameters: %v", err)
}
if !strings.Contains(string(installed), "work_mem = '8192'") {
t.Errorf("installed parameters = %q, want the repair set retained", installed)
}
}
// Answers the two read-backs an apply runs from fields the case sets, so what
// the engine does with each answer is what is under test.
type tlsReadBackRunner struct {
recordingRunner
ssl string
ruleRows int
brokenRows int
// What pg_reload_conf() answered. Empty is the postmaster having taken the
// signal, which is every case but the one about it not having.
reload string
}
func (r *tlsReadBackRunner) run(ctx context.Context, c command) (string, error) {
switch {
case strings.Contains(c.Stdin, "SHOW ssl"):
r.calls = append(r.calls, c)
return r.ssl + "\n", nil
case strings.Contains(c.Stdin, "pg_reload_conf"):
r.calls = append(r.calls, c)
if r.reload == "" {
return "t\n", nil
}
return r.reload + "\n", nil
case strings.Contains(c.Stdin, "pg_hba_file_rules"):
r.calls = append(r.calls, c)
return fmt.Sprintf("%d|%d\n", r.ruleRows, r.brokenRows), nil
}
return r.recordingRunner.run(ctx, c)
}
func forceSSLParameter(t *testing.T, value string) []handlers_rds.Parameter {
t.Helper()
return []handlers_rds.Parameter{
{Name: "work_mem", Value: "4096"},
{Name: testPostgresEngineMeta(t).TLSEnforcementParameter(), Value: value},
}
}
// PostgreSQL has no server setting for this, so the value in the parameter file
// is inert: the rule the generated pg_hba includes is the whole of it.
func TestPostgresEngine_ApplyParametersWritesTheEnforcementRule(t *testing.T) {
runner := &tlsReadBackRunner{ssl: "on", ruleRows: postgresForceSSLRuleCount}
engine := newScriptedTestEngine(t, runner.run)
if _, err := engine.ApplyParameters(t.Context(), forceSSLParameter(t, "1")); err != nil {
t.Fatalf("ApplyParameters: %v", err)
}
rule, err := os.ReadFile(engine.forceSSLRulePath())
if err != nil {
t.Fatalf("read the enforcement rule: %v", err)
}
if string(rule) != postgresForceSSLRules {
t.Errorf("enforcement rule = %q, want the constant rds-init also writes", rule)
}
}
// A snapshot carries hba.d with it, so restoring one taken while enforcing into
// a group that turns enforcement off has to take the rule with it.
func TestPostgresEngine_ApplyParametersRemovesAStaleEnforcementRule(t *testing.T) {
runner := &tlsReadBackRunner{ssl: "on"}
engine := newScriptedTestEngine(t, runner.run)
if err := os.MkdirAll(filepath.Dir(engine.forceSSLRulePath()), 0o700); err != nil {
t.Fatalf("create hba.d: %v", err)
}
if err := os.WriteFile(engine.forceSSLRulePath(), []byte(postgresForceSSLRules), 0o600); err != nil {
t.Fatalf("write the rule the restored volume carried: %v", err)
}
if _, err := engine.ApplyParameters(t.Context(), forceSSLParameter(t, "0")); err != nil {
t.Fatalf("ApplyParameters: %v", err)
}
if _, err := os.Stat(engine.forceSSLRulePath()); !os.IsNotExist(err) {
t.Errorf("the stale enforcement rule is still in place (stat err = %v)", err)
}
}
// The ordinary state of an instance whose resolved set predates the parameter,
// and the whole of the migration: it begins requiring TLS with no control-plane
// work at all.
func TestPostgresEngine_ApplyParametersEnforcesOnAnAbsentKey(t *testing.T) {
runner := &tlsReadBackRunner{ssl: "on", ruleRows: postgresForceSSLRuleCount}
engine := newScriptedTestEngine(t, runner.run)
if _, err := engine.ApplyParameters(t.Context(),
[]handlers_rds.Parameter{{Name: "work_mem", Value: "4096"}}); err != nil {
t.Fatalf("ApplyParameters: %v", err)
}
if _, err := os.Stat(engine.forceSSLRulePath()); err != nil {
t.Errorf("a set predating the parameter did not enforce (stat err = %v)", err)
}
}
// The resolver canonicalises every boolean, so a value that is neither means the
// file was written by something other than the platform. Reading an unparsable
// security setting as off is the one choice not open here.
func TestPostgresEngine_ApplyParametersRefusesAnUnparsableEnforcementValue(t *testing.T) {
runner := &tlsReadBackRunner{ssl: "on"}
engine := newScriptedTestEngine(t, runner.run)
_, err := engine.ApplyParameters(t.Context(), forceSSLParameter(t, "yes"))
if err == nil || !strings.Contains(err.Error(), "neither 1 nor 0") {
t.Fatalf("ApplyParameters error = %v, want a refusal naming the unreadable value", err)
}
if _, statErr := os.Stat(engine.params.installedPath()); !os.IsNotExist(statErr) {
t.Errorf("the refused set is still installed (stat err = %v)", statErr)
}
}
// Without this, a live enable against an engine started with no certificate
// would reject every subsequent connection and report the parameter applied.
func TestPostgresEngine_ApplyParametersRefusesToEnforceWithoutTLS(t *testing.T) {
runner := &tlsReadBackRunner{ssl: "off"}
engine := newScriptedTestEngine(t, runner.run)
_, err := engine.ApplyParameters(t.Context(), forceSSLParameter(t, "1"))
if err == nil || !strings.Contains(err.Error(), "serving without TLS") {
t.Fatalf("ApplyParameters error = %v, want a refusal naming the engine's own TLS state", err)
}
if _, statErr := os.Stat(engine.forceSSLRulePath()); !os.IsNotExist(statErr) {
t.Errorf("the enforcement rule was written anyway (stat err = %v)", statErr)
}
}
// PostgreSQL discards a pg_hba it cannot parse and keeps the rules it is already
// serving, which presents as an apply that succeeded and enforces nothing. The
// shape is asserted as well as the errors, because our own derivation writing
// the wrong thing is the failure that will actually happen.
func TestPostgresEngine_ApplyParametersFailsAnUnverifiableEnforcement(t *testing.T) {
tests := []struct {
name string
value string
ruleRows int
brokenRows int
want string
}{
{name: "rule missing", value: "1", ruleRows: 0, want: "carries 0 TLS enforcement rules, want 2"},
{name: "rule present when it should not be", value: "0", ruleRows: 2, want: "carries 2 TLS enforcement rules, want 0"},
{name: "a rule the engine could not parse", value: "1", ruleRows: 2, brokenRows: 1, want: "could not parse"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
runner := &tlsReadBackRunner{ssl: "on", ruleRows: tc.ruleRows, brokenRows: tc.brokenRows}
engine := newScriptedTestEngine(t, runner.run)
_, err := engine.ApplyParameters(t.Context(), forceSSLParameter(t, tc.value))
if err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("ApplyParameters error = %v, want %q", err, tc.want)
}
})
}
}
// A rejected set has to take its enforcement with it. Withdrawing one without
// the other would leave the instance serving the previous parameters under the
// rejected set's TLS posture.
func TestPostgresEngine_ApplyParametersWithdrawsBothFilesTogether(t *testing.T) {
runner := &tlsReadBackRunner{ssl: "on", ruleRows: postgresForceSSLRuleCount}
engine := newScriptedTestEngine(t, runner.run)
if _, err := engine.ApplyParameters(t.Context(), forceSSLParameter(t, "1")); err != nil {
t.Fatalf("first ApplyParameters: %v", err)
}
// Turning enforcement off, verified against a pg_hba that still carries the
// rule — so the apply fails after both files have already been replaced.
if _, err := engine.ApplyParameters(t.Context(), forceSSLParameter(t, "0")); err == nil {
t.Fatal("ApplyParameters succeeded against an unverifiable enforcement")
}
installed, err := os.ReadFile(engine.params.installedPath())
if err != nil {
t.Fatalf("read the installed parameters: %v", err)
}
if !strings.Contains(string(installed), "rds.force_ssl = '1'") {
t.Errorf("installed parameters = %q, want the previous accepted set restored", installed)
}
if _, err := os.Stat(engine.forceSSLRulePath()); err != nil {
t.Errorf("the enforcement of the previous set was not restored with it (stat err = %v)", err)
}
// The reload already adopted the rejected files, so putting them back is not
// enough on its own.
if !strings.Contains(runner.calls[len(runner.calls)-1].Stdin, "pg_reload_conf") {
t.Errorf("last statement = %q, want a reload taking the engine off the withdrawn set", runner.calls[len(runner.calls)-1].Stdin)
}
}
// A service restart does not re-run rds-init, so a restore stopping at the
// parameter file would bring the last known good set up under the enforcement of
// the set that was just rejected.
func TestPostgresEngine_RestoreLastKnownGoodReDerivesEnforcement(t *testing.T) {
runner := &tlsReadBackRunner{ssl: "on", ruleRows: postgresForceSSLRuleCount}
engine := newScriptedTestEngine(t, runner.run)
if _, err := engine.ApplyParameters(t.Context(), forceSSLParameter(t, "1")); err != nil {
t.Fatalf("ApplyParameters(enforcing): %v", err)
}
if err := engine.RecordServingParameters(t.Context()); err != nil {
t.Fatalf("RecordServingParameters: %v", err)
}
runner.ruleRows = 0
if _, err := engine.ApplyParameters(t.Context(), forceSSLParameter(t, "0")); err != nil {
t.Fatalf("ApplyParameters(not enforcing): %v", err)
}
if _, err := os.Stat(engine.forceSSLRulePath()); !os.IsNotExist(err) {
t.Fatalf("the enforcement rule survived the set that turned it off (stat err = %v)", err)
}
engine.probe = stubProbe(t, 2, nil)
restored, err := engine.RestoreLastKnownGoodParameters(t.Context())
if err != nil {
t.Fatalf("RestoreLastKnownGoodParameters: %v", err)
}
if !restored {
t.Fatal("did not restore a set that differs from the last known good")
}
if _, err := os.Stat(engine.forceSSLRulePath()); err != nil {
t.Errorf("the restored set's enforcement was not re-derived (stat err = %v)", err)
}
}
func TestPostgresEngine_ApplyParametersKeepsRepairSetWhenRestartTimesOut(t *testing.T) {
runner := &recordingRunner{}
engine := newTestEngine(t, runner.run)
engine.probe = stubProbe(t, 2, nil)
engine.repairTimeout = 10 * time.Millisecond
engine.repairPoll = time.Millisecond
_, err := engine.ApplyParameters(t.Context(), []handlers_rds.Parameter{{Name: "work_mem", Value: "8192"}})
if err == nil || !strings.Contains(err.Error(), "wait for the engine") {
t.Fatalf("ApplyParameters error = %v, want the repair wait failure", err)
}
installed, readErr := os.ReadFile(engine.params.installedPath())
if readErr != nil {
t.Fatalf("read installed parameters: %v", readErr)
}
if !strings.Contains(string(installed), "work_mem = '8192'") {
t.Errorf("installed parameters = %q, want the checked repair set retained", installed)
}
}
-105
View File
@@ -1,105 +0,0 @@
package main
import (
"bytes"
"context"
"errors"
"os/exec"
"strings"
"sync/atomic"
handlers_rds "github.com/mulgadc/spinifex/spinifex/handlers/rds"
)
// A non-zero exit is a result, not a fault, so it comes back as a code; err is
// reserved for the probe failing to run at all. The client's stderr comes back
// with it: on a refusal it is the only place the reason is ever stated.
type probeRunner func(ctx context.Context, name string, args ...string) (int, string, error)
func execProbeRunner(ctx context.Context, name string, args ...string) (int, string, error) {
var stderr bytes.Buffer
cmd := exec.CommandContext(ctx, name, args...)
cmd.Stderr = &stderr
err := cmd.Run()
out := strings.TrimSpace(stderr.String())
if err == nil {
return 0, out, nil
}
if ctxErr := ctx.Err(); ctxErr != nil {
return -1, out, ctxErr
}
if exitErr, ok := errors.AsType[*exec.ExitError](err); ok {
return exitErr.ExitCode(), out, nil
}
return -1, out, err
}
// What the engine reports about itself, which is the only part of the probe
// that differs between engines. The port is passed in rather than read from the
// engine, so the atomic below stays the one place it is followed from.
type probeStateFn func(ctx context.Context, port int64) (engineState, string)
// Engine-neutral: the latch, the port, the degraded mapping and the translation
// to a reported health are shared, and only the state function is supplied by
// the engine the image bakes.
type engineProbe struct {
stateFn probeStateFn
// Set from the bootstrap config once it lands, from a different goroutine
// than the heartbeat that reads it.
port atomic.Int64
// Latches once the engine has answered. The agent is up well before initdb
// finishes, so a down engine is "starting" until it has served once.
seenHealthy bool
}
func newEngineProbe(port int, stateFn probeStateFn) *engineProbe {
p := &engineProbe{stateFn: stateFn}
p.port.Store(int64(port))
return p
}
func (p *engineProbe) setPort(port int) {
p.port.Store(int64(port))
}
// What the engine is doing, before the seenHealthy latch collapses "never up"
// and "went away" into one health. The rollback needs them apart: an engine
// replaying WAL comes back on its own, one that is not running will not.
type engineState int
const (
// Not answering at all, or the probe could not run.
engineAbsent engineState = iota
// The process is up but not yet serving: startup or crash recovery.
engineRecovering
engineServing
)
// Only the heartbeat calls this, so the seenHealthy latch is single-goroutine
// state.
func (p *engineProbe) Check(ctx context.Context) (handlers_rds.EngineHealth, string) {
state, message := p.state(ctx)
switch state {
case engineServing:
p.seenHealthy = true
return handlers_rds.EngineHealthHealthy, ""
case engineRecovering:
return handlers_rds.EngineHealthStarting, message
default:
return p.degraded(), message
}
}
// The raw probe result. Safe to call from a goroutine other than the
// heartbeat's: it touches no latched state.
func (p *engineProbe) state(ctx context.Context) (engineState, string) {
return p.stateFn(ctx, p.port.Load())
}
// A non-answering engine: starting until it has answered once, unhealthy after.
func (p *engineProbe) degraded() handlers_rds.EngineHealth {
if p.seenHealthy {
return handlers_rds.EngineHealthUnhealthy
}
return handlers_rds.EngineHealthStarting
}
-146
View File
@@ -1,146 +0,0 @@
package main
import (
"context"
"errors"
"strings"
"testing"
"time"
handlers_rds "github.com/mulgadc/spinifex/spinifex/handlers/rds"
)
func TestEngineProbe_MapsExitCodesToHealth(t *testing.T) {
tests := []struct {
name string
code int
runErr error
seenHealthy bool
want handlers_rds.EngineHealth
}{
{name: "accepting connections", code: 0, want: handlers_rds.EngineHealthHealthy},
{name: "rejecting during startup", code: 1, want: handlers_rds.EngineHealthStarting},
{name: "silent before first success", code: 2, want: handlers_rds.EngineHealthStarting},
{name: "silent after first success", code: 2, seenHealthy: true, want: handlers_rds.EngineHealthUnhealthy},
{name: "no attempt made", code: 3, want: handlers_rds.EngineHealthStarting},
{name: "probe missing before first success", runErr: errors.New("executable not found"), want: handlers_rds.EngineHealthStarting},
{name: "probe missing after first success", runErr: errors.New("executable not found"), seenHealthy: true, want: handlers_rds.EngineHealthUnhealthy},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
probe := newPostgresProbe(testProbeConfig(), func(context.Context, string, ...string) (int, string, error) {
return tt.code, "", tt.runErr
})
probe.seenHealthy = tt.seenHealthy
got, message := probe.Check(context.Background())
if got != tt.want {
t.Errorf("health = %q, want %q", got, tt.want)
}
if got != handlers_rds.EngineHealthHealthy && message == "" {
t.Error("a non-healthy result carried no message explaining it")
}
if got == handlers_rds.EngineHealthHealthy && message != "" {
t.Errorf("a healthy result carried a message: %q", message)
}
})
}
}
// The latch is what separates a boot still in progress from a failure, and it
// has to flip the first time the engine answers.
func TestEngineProbe_LatchesAfterFirstHealthy(t *testing.T) {
code := 0
probe := newPostgresProbe(testProbeConfig(), func(context.Context, string, ...string) (int, string, error) {
return code, "", nil
})
if got, _ := probe.Check(context.Background()); got != handlers_rds.EngineHealthHealthy {
t.Fatalf("first check = %q, want healthy", got)
}
code = 2
if got, _ := probe.Check(context.Background()); got != handlers_rds.EngineHealthUnhealthy {
t.Errorf("check after the engine went away = %q, want unhealthy", got)
}
}
// The port the engine listens on comes from the bootstrap config, so the probe
// must follow it rather than keep asking the default.
func TestEngineProbe_ProbesTheAssignedPort(t *testing.T) {
var gotArgs []string
probe := newPostgresProbe(testProbeConfig(), func(_ context.Context, _ string, args ...string) (int, string, error) {
gotArgs = args
return 0, "", nil
})
probe.setPort(6543)
probe.Check(context.Background())
if !strings.Contains(strings.Join(gotArgs, " "), "-p 6543") {
t.Errorf("probe args = %v, want the assigned port 6543", gotArgs)
}
}
func TestExecProbeRunner_PreservesTheProbeDeadline(t *testing.T) {
ctx, cancel := context.WithTimeout(t.Context(), 20*time.Millisecond)
defer cancel()
code, _, err := execProbeRunner(ctx, "sh", "-c", "while :; do :; done")
if code != -1 {
t.Errorf("exit code = %d, want -1 for a timed-out probe", code)
}
if !errors.Is(err, context.DeadlineExceeded) {
t.Errorf("error = %v, want the context deadline", err)
}
}
// The client's account of a refusal is the reason a probe failure is legible at
// all, so a runner that dropped it would leave the caller nothing to report.
func TestExecProbeRunner_CapturesTheClientsStderr(t *testing.T) {
code, stderr, err := execProbeRunner(t.Context(), "sh", "-c", "echo 'TLS handshake failed' >&2; exit 1")
if err != nil {
t.Fatalf("execProbeRunner: %v", err)
}
if code != 1 {
t.Errorf("exit code = %d, want 1", code)
}
if stderr != "TLS handshake failed" {
t.Errorf("stderr = %q, want the client's own message", stderr)
}
}
// The engine binds the customer ENI and nothing else, so a loopback probe would
// report a serving engine as absent. libpq reads a host beginning with / as a
// socket directory, which is also what keeps liveness off the network path.
func TestPostgresProbe_ProbesTheUnixSocket(t *testing.T) {
cfg := testProbeConfig()
var probed []string
probe := newPostgresProbe(cfg, func(_ context.Context, name string, args ...string) (int, string, error) {
probed = append([]string{name}, args...)
return 0, "", nil
})
if state, _ := probe.state(context.Background()); state != engineServing {
t.Fatalf("probe state = %v, want engineServing", state)
}
host := ""
for i, arg := range probed {
if arg == "-h" && i+1 < len(probed) {
host = probed[i+1]
}
}
if host != cfg.SocketDir {
t.Errorf("probe host = %q, want the socket directory %q", host, cfg.SocketDir)
}
if !strings.HasPrefix(host, "/") {
t.Errorf("probe host = %q, which libpq reads as a TCP host rather than a socket directory", host)
}
}
func testProbeConfig() config {
return config{
SocketDir: engineLayouts[enginePostgres].socketDir,
EnginePort: engineLayouts[enginePostgres].port,
}
}
-123
View File
@@ -1,123 +0,0 @@
package main
import (
"context"
"log/slog"
"sync/atomic"
"time"
"unicode/utf8"
handlers_rds "github.com/mulgadc/spinifex/spinifex/handlers/rds"
)
const (
servingParameterRecordTimeout = 15 * time.Second
maxBootstrapFailureBytes = 512
)
// The beat carries the probe's result, not the agent's own liveness: an agent
// up while the engine is down is what the recovery reconciler must see.
type heartbeater struct {
cp controlPlane
probe *engineProbe
recorder servingParameterRecorder
id identity
interval time.Duration
// Bootstrap and heartbeat run concurrently. Keeping the latest failure here
// makes a VM stuck before engine startup explain why through its next beat.
bootstrapFailure atomic.Pointer[string]
}
func newHeartbeater(cp controlPlane, probe *engineProbe, recorder servingParameterRecorder, interval time.Duration) *heartbeater {
if interval <= 0 {
interval = handlers_rds.HeartbeatInterval
}
return &heartbeater{cp: cp, probe: probe, recorder: recorder, interval: interval}
}
// Called before Run starts and from Run's own goroutine after, so the interval
// is never written concurrently with the loop reading it.
func (h *heartbeater) setInterval(d time.Duration) {
if d > 0 {
h.interval = d
}
}
func (h *heartbeater) setBootstrapFailure(what string, err error) {
message := what + " failed: " + err.Error()
if len(message) > maxBootstrapFailureBytes {
limit := maxBootstrapFailureBytes - len("...")
for limit > 0 && !utf8.RuneStart(message[limit]) {
limit--
}
message = message[:limit] + "..."
}
h.bootstrapFailure.Store(&message)
}
func (h *heartbeater) clearBootstrapFailure() {
h.bootstrapFailure.Store(nil)
}
func (h *heartbeater) beat(ctx context.Context) {
health, message := h.probe.Check(ctx)
if failure := h.bootstrapFailure.Load(); failure != nil {
if message != "" {
message += "; "
}
message += *failure
}
if health == handlers_rds.EngineHealthHealthy && h.recorder != nil {
// Checking every healthy beat also observes a restart that completed
// between probes. The recorder skips unchanged and pending-restart sets.
recordCtx, cancel := context.WithTimeout(ctx, servingParameterRecordTimeout)
err := h.recorder.RecordServingParameters(recordCtx)
cancel()
if err != nil {
slog.Warn("rds-agent: recording the serving parameters failed", "err", err)
}
}
out, err := h.cp.SubmitState(ctx, h.id, health, message)
if err != nil {
// Not escalated: the control plane already treats a missing heartbeat as
// staleness, and the next tick retries.
slog.Warn("rds-agent: heartbeat failed", "health", health, "err", err)
return
}
slog.Debug("rds-agent: heartbeat", "health", health, "persisted", out.Persisted)
h.setInterval(time.Duration(out.HeartbeatIntervalSeconds) * time.Second)
}
// How often a not-yet-serving engine is reported. The control plane owns the
// steady-state cadence, but while the engine has never answered it is actively
// waiting on this instance — a restart is not finished until a healthy beat
// lands — and a full interval of silence there is pure added downtime.
const startupHeartbeatInterval = 5 * time.Second
func (h *heartbeater) nextInterval() time.Duration {
if h.probe != nil && !h.probe.seenHealthy && startupHeartbeatInterval < h.interval {
return startupHeartbeatInterval
}
return h.interval
}
// A timer rather than a ticker, so a cadence change takes effect on the next
// beat, not at a restart. The first beat is not waited for: a just-registered
// agent has told the control plane nothing about the engine yet, and holding
// that back for an interval makes every restart look longer than it was.
func (h *heartbeater) Run(ctx context.Context) {
h.beat(ctx)
timer := time.NewTimer(h.nextInterval())
defer timer.Stop()
for {
select {
case <-ctx.Done():
return
case <-timer.C:
h.beat(ctx)
timer.Reset(h.nextInterval())
}
}
}
+60 -18
View File
@@ -1,11 +1,7 @@
// rds-agent runs inside a Spinifex RDS instance. It registers the VM with the
// control plane over TLS+SigV4 (never NATS), writes the bootstrap handoff the
// engine's first-boot script consumes, then heartbeats health and polls for
// directives.
//
// It is the only path by which a secret reaches the VM: the master password is
// served once, to an authenticated caller. Static config is read from the
// cloud-init env file /etc/spinifex-rds/agent.env; real env vars override it.
// Command rds-agent runs inside a Spinifex RDS instance; the implementation is
// in agents/rds/agent. This binary owns process lifecycle: signal handling,
// treating a cancelled startup as a clean stop, failure logging and the exit
// code.
package main
import (
@@ -17,23 +13,69 @@ import (
"syscall"
_ "github.com/mulgadc/bluebottle/pkg/fipsboot"
rdsagent "github.com/mulgadc/spinifex/spinifex/agents/rds/agent"
rdsengine "github.com/mulgadc/spinifex/spinifex/domains/rds/engine"
)
// version is the agent build version, reported at registration.
// Overridable via -ldflags "-X main.version=...".
var version = "dev"
// engineCatalogAdapter composes the agent's narrow EngineCatalog over the
// control plane's own engine package. This binary is the only place in the
// agent's dependency graph that imports it.
type engineCatalogAdapter struct {
engine rdsengine.Engine
}
var _ rdsagent.EngineCatalog = engineCatalogAdapter{}
func (a engineCatalogAdapter) OptionFileName(name string) string {
return a.engine.OptionFileName(name)
}
func (a engineCatalogAdapter) LookupParameter(name string) (rdsagent.CatalogParameter, bool) {
spec, ok := a.engine.LookupParameter(name)
if !ok {
return rdsagent.CatalogParameter{}, false
}
return rdsagent.CatalogParameter{DataType: spec.DataType, Static: spec.ApplyType == rdsengine.ApplyTypeStatic}, true
}
func (a engineCatalogAdapter) CatalogParameterNames() []string {
return a.engine.CatalogParameterNames()
}
func (a engineCatalogAdapter) TLSEnforcementParameter() string {
return a.engine.TLSEnforcementParameter()
}
func (a engineCatalogAdapter) ValidateMasterUsername(username string) error {
return a.engine.ValidateMasterUsername(username)
}
func (a engineCatalogAdapter) ValidateUsernameNotReserved(username string) error {
return a.engine.ValidateUsernameNotReserved(username)
}
func lookupEngineCatalog(name string) (rdsagent.EngineCatalog, error) {
e, err := rdsengine.LookupEngine(name)
if err != nil {
return nil, err
}
return engineCatalogAdapter{engine: e}, nil
}
func main() {
cfg := loadConfig(defaultEnvFile)
cfg := rdsagent.LoadConfig(rdsagent.DefaultEnvFile)
cfg.AgentVersion = version
cfg.EngineCatalog = lookupEngineCatalog
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
// Retried rather than fatal: construction resolves the IMDS credential chain,
// so a metadata service that is not answering yet would otherwise end the
// process with the engine still down and nothing left to bring it back.
var agent *Agent
err := retry(ctx, "startup", func(context.Context) error {
var err error
agent, err = New(cfg)
return err
})
agent, err := rdsagent.Start(ctx, cfg)
if err != nil {
if errors.Is(err, context.Canceled) {
return
-233
View File
@@ -1,233 +0,0 @@
package main
import (
"bytes"
"context"
"fmt"
"os"
"path/filepath"
"sync"
"time"
handlers_rds "github.com/mulgadc/spinifex/spinifex/handlers/rds"
)
// The generated parameter files and what it takes to install one. They live on
// the data volume at the paths rds-init writes: a class change replaces the VM,
// so a set under /etc would revert to defaults while the API reported otherwise.
type parameterStore struct {
dir string
// The include the resolved set is rendered to.
installed string
// The copy of the last set the engine accepted, which a boot loop is rolled
// back to. Deliberately not a name the engine's own include glob reads, so it
// is never parsed as a second set of settings.
lastGood string
// What the engine actually started on, for an engine that cannot be asked
// which of its settings are still pending a restart. Empty for one that can.
serving string
// Prepended to every file written here, for a configuration format that has
// no setting outside a group.
header string
// The engine parses its own configuration, so a root-owned file would be
// unreadable to it.
osUser string
// Names the engine whose option-file spellings these files are written in.
engine string
}
func (s parameterStore) installedPath() string { return filepath.Join(s.dir, s.installed) }
func (s parameterStore) lastGoodPath() string { return filepath.Join(s.dir, s.lastGood) }
func (s parameterStore) servingPath() string { return filepath.Join(s.dir, s.serving) }
// Byte-for-byte what rds-init writes for the same set, so the copies compared
// against it are comparing values rather than formatting.
func (s parameterStore) render(params []handlers_rds.Parameter) ([]byte, error) {
body, err := renderParameters(s.engine, params)
if err != nil {
return nil, err
}
return []byte(s.header + body), nil
}
// Installs the resolved set at the path rds-init uses, so a later boot
// overwrites rather than shadows it. Returns a func putting back whatever was
// there, so the caller can undo the install without re-rendering anything.
func (s parameterStore) install(params []handlers_rds.Parameter) (restore func() error, err error) {
path := s.installedPath()
// A missing file is the first-ever apply, and its rollback is a removal.
previous, readErr := os.ReadFile(path)
if readErr != nil && !os.IsNotExist(readErr) {
return nil, fmt.Errorf("read the installed parameters at %s: %w", path, readErr)
}
existed := readErr == nil
rendered, err := s.render(params)
if err != nil {
return nil, err
}
if err := s.write(path, rendered); err != nil {
return nil, err
}
return func() error {
if !existed {
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
return fmt.Errorf("withdraw the rejected parameters at %s: %w", path, err)
}
return nil
}
return s.write(path, previous)
}, nil
}
// Atomic: a temp file in the same directory, renamed over the target. The temp
// name deliberately does not end in the engine's include suffix, so a crash
// between write and rename cannot leave the engine reading a half-written file.
func (s parameterStore) write(path string, content []byte) error {
tmp := path + ".new"
if err := os.WriteFile(tmp, content, 0o600); err != nil {
return fmt.Errorf("write %s: %w", tmp, err)
}
if err := s.chownToEngine(tmp); err != nil {
os.Remove(tmp)
return err
}
if err := os.Rename(tmp, path); err != nil {
os.Remove(tmp)
return fmt.Errorf("install %s: %w", path, err)
}
return nil
}
// A guest with no engine user is a broken image, not something to work around.
func (s parameterStore) chownToEngine(path string) error {
credential, err := lookupCredential(s.osUser)
if err != nil {
return err
}
if err := os.Chown(path, int(credential.Uid), int(credential.Gid)); err != nil {
return fmt.Errorf("hand %s to %s: %w", path, s.osUser, err)
}
return nil
}
// Snapshots the installed set as the rollback target, but only once the engine
// is running all of it. A set still pending a restart has not been shown to
// serve, so promoting it would point the rollback at a config about to fail.
func recordLastGood(ctx context.Context, store parameterStore, pending pendingRestartFn) error {
if _, err := os.Stat(store.installedPath()); err != nil {
if os.IsNotExist(err) {
return nil
}
return fmt.Errorf("inspect the serving parameters: %w", err)
}
names, err := pending(ctx)
if err != nil {
return err
}
if len(names) > 0 {
return nil
}
content, err := os.ReadFile(store.installedPath())
if err != nil {
return fmt.Errorf("read the serving parameters: %w", err)
}
lastGood, err := os.ReadFile(store.lastGoodPath())
if err == nil && bytes.Equal(content, lastGood) {
return nil
}
if err != nil && !os.IsNotExist(err) {
return fmt.Errorf("read the last known good parameters: %w", err)
}
return store.write(store.lastGoodPath(), content)
}
// Puts the last set the engine accepted back in place, for a restart that failed
// after a parameter change. The probe is checked again under the caller's
// parameter lock, so a repair that just brought the engine back is not reversed.
func restoreLastGood(ctx context.Context, store parameterStore, probe *engineProbe) (bool, error) {
if state, _ := probe.state(ctx); state != engineAbsent {
return false, nil
}
lastGood, err := os.ReadFile(store.lastGoodPath())
if err != nil {
if os.IsNotExist(err) {
return false, nil
}
return false, fmt.Errorf("read the last known good parameters: %w", err)
}
current, err := os.ReadFile(store.installedPath())
if err != nil && !os.IsNotExist(err) {
return false, fmt.Errorf("read the installed parameters: %w", err)
}
if bytes.Equal(current, lastGood) {
return false, nil
}
if err := store.write(store.installedPath(), lastGood); err != nil {
return false, err
}
return true, nil
}
// The settings the engine has accepted but will not honour until it restarts.
// Each implementation answers it its own way, and both the apply and the serving
// snapshot are gated on the same answer.
type pendingRestartFn func(ctx context.Context) ([]string, error)
// The parameter-file state every engine keeps. The operations over it are free
// functions above rather than methods here, so each implementation names its own
// restart and pending answers at the call site where the compiler checks them.
type parameterManager struct {
params parameterStore
probe *engineProbe
// Serializes parameter installs, serving snapshots and rollback restores so
// none can copy or replace an intermediate configuration.
paramMu sync.Mutex
repairTimeout time.Duration
repairPoll time.Duration
}
const (
parameterRepairTimeout = 90 * time.Second
parameterRepairPoll = time.Second
)
// Starts a down engine on the set just installed and waits for it to serve.
// Reached when the engine went away during an apply: the installed set is the
// newer, so starting on it beats restoring one that already failed.
func awaitRepairedEngine(ctx context.Context, probe *engineProbe, restart func(context.Context) error,
pending pendingRestartFn, timeout, poll time.Duration) ([]string, error) {
repairCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
if err := restart(repairCtx); err != nil {
return nil, fmt.Errorf("start the engine on the repaired parameter set: %w", err)
}
timer := time.NewTimer(0)
defer timer.Stop()
lastMessage := "engine did not respond"
for {
select {
case <-repairCtx.Done():
if ctx.Err() != nil {
return nil, fmt.Errorf("wait for the engine on the repaired parameter set: %w", ctx.Err())
}
return nil, fmt.Errorf("wait for the engine on the repaired parameter set: %s: %w", lastMessage, repairCtx.Err())
case <-timer.C:
}
state, message := probe.state(repairCtx)
if state == engineServing {
names, err := pending(repairCtx)
if err == nil {
return names, nil
}
lastMessage = err.Error()
} else if message != "" {
lastMessage = message
}
timer.Reset(poll)
}
}
-194
View File
@@ -1,194 +0,0 @@
package main
import (
"bufio"
"context"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"golang.org/x/sys/unix"
)
// The in-guest half of a storage grow: the control plane has already grown the
// block device, so all that is left is the filesystem. Both ext4 and XFS grow
// while mounted, so this needs no ordering against the engine start.
type storageOps interface {
// Extends the filesystem at the data mount onto its whole device, and
// reports what it did for the command reply.
GrowFilesystem(ctx context.Context) (string, error)
// Flushes every dirty page on the data mount to its device. A backup hold
// settles the engine's own files and nothing else on that filesystem.
SyncDataMount(ctx context.Context) error
}
// Tool names rather than paths: the image installs them from
// cloud-utils-growpart, e2fsprogs-extra and xfsprogs, all on PATH.
const (
defaultGrowpart = "growpart"
defaultResize2fs = "resize2fs"
defaultXFSGrowfs = "xfs_growfs"
defaultMountsFile = "/proc/mounts"
defaultSysBlock = "/sys/class/block"
// growpart says this when the partition already fills the disk, which a
// resumed grow is the normal way to reach.
growpartNoChange = "NOCHANGE"
)
type guestStorage struct {
run commandRunner
dataMount string
mountsFile string
sysBlock string
}
var _ storageOps = (*guestStorage)(nil)
func newGuestStorage(cfg config, run commandRunner) *guestStorage {
return &guestStorage{
run: run,
dataMount: cfg.DataMount,
mountsFile: cfg.MountsFile,
sysBlock: cfg.SysBlock,
}
}
// syncfs rather than fsync: the whole filesystem, not one file. Config the
// engine never opens lives on this volume too, and a snapshot that catches it
// half-written leaves a restored instance the engine cannot start from.
func (g *guestStorage) SyncDataMount(ctx context.Context) error {
f, err := os.Open(g.dataMount)
if err != nil {
return fmt.Errorf("open the data mount %s to flush it: %w", g.dataMount, err)
}
defer func() { _ = f.Close() }()
if err := unix.Syncfs(int(f.Fd())); err != nil {
return fmt.Errorf("flush the data mount %s: %w", g.dataMount, err)
}
slog.InfoContext(ctx, "rds-agent: data mount flushed for backup", "dataMount", g.dataMount)
return nil
}
// The device and filesystem behind the data mount, resolved from the kernel's
// own mount table rather than from anything the control plane asserted.
type dataMount struct {
device string
fstype string
}
func (g *guestStorage) GrowFilesystem(ctx context.Context) (string, error) {
mount, err := g.resolveDataMount()
if err != nil {
return "", err
}
// rds-datadir formats the whole device and refuses a disk that already
// carries a partition table, so the data volume is normally unpartitioned
// and there is no partition to push out first.
if err := g.growPartition(ctx, mount.device); err != nil {
return "", err
}
switch {
case strings.HasPrefix(mount.fstype, "ext"):
// resize2fs grows a mounted ext filesystem online, and takes the device.
if _, err := g.run(ctx, command{
Name: defaultResize2fs,
Args: []string{mount.device},
Env: []string{"PATH=" + defaultGuestPath},
}); err != nil {
return "", fmt.Errorf("grow the %s filesystem on %s: %w", mount.fstype, mount.device, err)
}
case mount.fstype == "xfs":
// xfs_growfs is addressed by mount point, and XFS can only grow mounted.
if _, err := g.run(ctx, command{
Name: defaultXFSGrowfs,
Args: []string{g.dataMount},
Env: []string{"PATH=" + defaultGuestPath},
}); err != nil {
return "", fmt.Errorf("grow the xfs filesystem at %s: %w", g.dataMount, err)
}
default:
// Guessing a tool here would either do nothing or corrupt a filesystem
// this image was never meant to be holding.
return "", fmt.Errorf("%s holds an unsupported filesystem %q; refusing to grow it", mount.device, mount.fstype)
}
slog.Info("rds-agent: data filesystem grown", "device", mount.device, "fstype", mount.fstype, "mount", g.dataMount)
return fmt.Sprintf("grew %s (%s) at %s", mount.device, mount.fstype, g.dataMount), nil
}
// Pushes the partition out to the end of its disk when the data mount happens
// to sit on one. A device that is not a partition needs nothing, and a
// partition that already fills its disk is the normal state of a resumed grow.
func (g *guestStorage) growPartition(ctx context.Context, device string) error {
disk, number, ok := g.partitionOf(device)
if !ok {
return nil
}
out, err := g.run(ctx, command{
Name: defaultGrowpart,
Args: []string{disk, number},
Env: []string{"PATH=" + defaultGuestPath},
})
if err == nil {
return nil
}
if strings.Contains(out, growpartNoChange) || strings.Contains(err.Error(), growpartNoChange) {
return nil
}
return fmt.Errorf("grow partition %s of %s: %w", number, disk, err)
}
// Whether device is a partition, and if so its disk and number. Read from
// sysfs, which states it directly rather than inferring it from a trailing
// digit — nvme0n1 is a disk and vdb1 is a partition on the same rule.
func (g *guestStorage) partitionOf(device string) (disk, number string, ok bool) {
name := filepath.Base(device)
raw, err := os.ReadFile(filepath.Join(g.sysBlock, name, "partition"))
if err != nil {
return "", "", false
}
number = strings.TrimSpace(string(raw))
if number == "" {
return "", "", false
}
// A partition's sysfs entry hangs off its disk's, so the parent directory
// names the disk without any parsing of the device name.
target, err := os.Readlink(filepath.Join(g.sysBlock, name))
if err != nil {
return "", "", false
}
return filepath.Join(filepath.Dir(device), filepath.Base(filepath.Dir(target))), number, true
}
// The mount table entry for the data mount. An absent one means the data volume
// is not mounted, which is a broken guest rather than something to grow.
func (g *guestStorage) resolveDataMount() (dataMount, error) {
f, err := os.Open(g.mountsFile)
if err != nil {
return dataMount{}, fmt.Errorf("read %s: %w", g.mountsFile, err)
}
defer f.Close()
// Later entries shadow earlier ones at the same point, so the last match is
// the filesystem actually visible there.
found := dataMount{}
scanner := bufio.NewScanner(f)
for scanner.Scan() {
fields := strings.Fields(scanner.Text())
if len(fields) < 3 || fields[1] != g.dataMount {
continue
}
found = dataMount{device: fields[0], fstype: fields[2]}
}
if err := scanner.Err(); err != nil {
return dataMount{}, fmt.Errorf("read %s: %w", g.mountsFile, err)
}
if found.device == "" {
return dataMount{}, fmt.Errorf("no filesystem is mounted at %s; the data volume is not attached", g.dataMount)
}
return found, nil
}

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