Compare commits

..

44 Commits

Author SHA1 Message Date
Ben Duncan defb27054a feat: activate Route 53 hosted zones once their parent delegates them to the cluster
A zone stays PENDING_NAMESERVERS until its parent's NS set is exactly the
cluster's name servers, read from an active enclosing zone, the platform zone,
or a non-recursive walk from the roots. The first account to verify a name wins.
Adds the activation extension (GET/POST /hostedzone/{Id}/activation), the
spx admin dns zone and delegate commands, and an e2e that delegates a cluster
subdomain and checks every node serves it.
2026-10-24 09:16:08 +10:00
Ben Duncan 60ae6c6856 feat: publish active Route 53 hosted zones to northstar and report INSYNC once every server serves them 2026-10-24 08:56:56 +10:00
Ben Duncan 39ad1f777f feat: serve Route 53 hosted zones and record sets through the gateway
Public hosted zones, record-set changes, GetChange and tags, stored in
JetStream KV and served by any daemon. Zones are created pending their
nameserver delegation; nothing is published to northstar yet.

Platform names (the base and internal domains, managed labels and nsN)
are refused as zone names. AmazonRoute53FullAccess and ReadOnlyAccess
resolve as managed policies.
2026-10-24 08:40:22 +10:00
Ben Duncan 98fc696379 feat: acknowledge northstar zone reloads with the served version
A reload sent with a reply subject now gets one ack per northstar server,
carrying the node name and the ETag of the zone it serves, so a writer can
tell when every server has its change. RequestZoneReload collects them.
2026-10-24 08:06:48 +10:00
Ben Duncan b6a18cebd4 feat: accept route53 requests signed for us-east-1
Route 53 is global, so the AWS CLI and SDKs sign it for us-east-1 against
the default endpoint and for the configured region against an override. The
gateway accepts either for route53; every other service stays pinned to the
gateway's region.
2026-10-24 07:58:55 +10:00
Ben Duncan 0665bd4f13 feat: cap published nameservers at four and add spx admin dns nameservers
A cluster-wide [northstar] block sets nameserver_count (1-4) and pins
nameserver_nodes in order. Without it, one nsN is published per northstar
node up to four; every northstar node still serves every zone and remains a
resolver backend. "spx admin dns nameservers --check" compares the published
set against the parent zone's delegation and glue.
2026-10-24 07:56:57 +10:00
Ben Duncan 772f91efc1 test: e2e proof that northstar serves zones past the first S3 page
Pins northstar to the fix that pages ListObjectsV2 before purging, and adds
a route53 suite, run in both topologies, that writes 1,100 zone objects,
requires every node to serve the last one while the base zone survives, and
requires the deleted zones to stop resolving.
2026-10-24 07:47:08 +10:00
Josh Siviour 6daa340044 feat(quota): seal per-account quota overrides behind a revision-guarded owner action (#1245)
Store the override record with a state field kept separate from the admin
wire type. Every set and clear is revision-guarded and refuses a sealed
record with Blocked, and every read of a sealed record, including
admission, returns Blocked. The read-failure fallback to configured limits
is unchanged.

Add Service.SealAccountOverrides, accepted only when IAM already holds the
account as TERMINATING. It creates the override bucket if needed after that
check, creates or updates the record as sealed, and succeeds only on a
leader-accepted write, returning its revision. awsgw serves it on
admission.v1.SealAccountQuotaOverride. Nothing calls it yet.

Replace the unwired delete-based CleanupAccount.
2026-10-23 18:22:54 +10:00
Josh Siviour b4d4fa65f8 fix(awserrors): attach Retry-After without duplicating the error text (#1244)
Joining a coded error with RetryAfter rendered the bare code on a second
line, so logged and stored reasons read "...: ServiceUnavailable
ServiceUnavailable". Add WithRetryAfter, which wraps the error and keeps
its text while ResolveRetryAfter and ResolveErrorDetail still resolve the
hint and the code. Use it for the seal-capability refusal and the
incomplete DescribeInstances and DescribeInstanceTypes fan-out errors.
2026-10-23 16:08:26 +10:00
Josh Siviour 2e2465dd25 feat(accountteardown): seal EC2 account settings during termination (#1242)
Wire the EC2 account-settings seal into account termination as a
StageAccount reaper. Immediately before every seal attempt the reaper
probes each configured node's new read-only, per-node seal-capability
subject and requires exactly one identified reply per node at capability
version 1 or later. Missing, duplicate, wrong-identity, malformed, error,
older-version or unreachable responders send no seal and return a
retryable ServiceUnavailable, so the account stays TERMINATING until the
cluster is compatible.

The reaper completes only on a sealed reply for the account with a
non-zero revision; a fresh termination run seals again. Daemons validate
their node ID before subscribing, so an invalid ID serves no capability
and reads as incompatible.
2026-10-23 15:20:33 +10:00
Josh Siviour 5d2c3e749f feat(ec2): seal account settings with revision-guarded writes (#1241)
Replace the unwired delete-based CleanupAccount with an owner-local sealed
state in the existing account-settings KV value. All settings mutations now
use revision-guarded create/update; a sealed record rejects every read and
mutation with Blocked and cannot be recreated. Records without a state
field remain active.

SealAccount CAS-writes the sealed value whether the key is present, absent
or tombstoned, and succeeds only on a leader-accepted write, returning its
revision. It first checks, through a read-only IAM account-status reader,
that the account is already TERMINATING; otherwise it refuses with
IncorrectState and writes nothing.

The daemon serves the internal subject ec2.v1.SealAccountSettings. Nothing
calls it yet.
2026-10-23 13:34:53 +10:00
Josh Siviour fb0f8e9291 feat(quota): add unwired owner-side quota override cleanup (#1240)
Pin that account teardown removes the usage counter but leaves the
per-account quota override, so a reused account ID inherits raised or
Unlimited limits. Add an unwired, idempotent CleanupAccount on the quota
service: a present override key is removed by a revision-guarded delete
and reported deleted with its revision; a missing key is reported only as
observed-absent-at-scan.
2026-10-23 11:56:08 +10:00
Josh Siviour bdfa01459c feat: add unwired EC2 account-settings owner cleanup (#1239)
Characterise that EC2 account settings survive today's account teardown,
and add an owner-side CleanupAccount in domains/ec2/account that deletes
one account's settings key.

A present key is removed with a revision-guarded delete, which the stream
leader orders regardless of which replica served the preceding read, and
is reported deleted with that revision. A key that a direct get does not
find is reported only as observed absent at scan time, because KV direct
gets can be answered by a lagging follower. Store errors and exhausted
revision conflicts are failed, never absent. The capability refuses empty,
malformed and global account IDs and never falls back to the global key.

Nothing calls it yet: no NATS subject, no accountteardown wiring.
2026-10-23 11:03:38 +10:00
Julian Sommer f707df92e7 chore: go 1.27.3 2026-10-21 10:57:02 +10:00
Julian Sommer f1ebb74acc fix: flush daemon subscriptions before platform inventory test requests
The inventory and disclosure test helpers subscribed on one connection and
requested on another, so a request could reach the server before its SUBs
and fail with no responders under load.
2026-10-21 10:49:16 +10:00
Julian Sommer bde54ffd2f fix: report a max_age stream whose replicas turned over between digest copies as skipped, not DIVERGED, in spx admin kv compare (#1238) 2026-10-21 11:46:22 +11: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
1139 changed files with 29277 additions and 13417 deletions
+5 -2
View File
@@ -28,6 +28,9 @@
# that runs identically on either, and because a quota left switched on would
# cap every suite sharing the environment — so it must be proved to restore
# itself wherever it runs, not only on one node.
# route53 belongs to both topologies because every node serves every hosted
# zone from one shared bucket: on one node it proves the zone loads at all, on
# three that each node's sync reaches the same set.
# storagefault runs last in both lists and belongs to both topologies, for
# different reasons. On one node it is RS(1,0), so freezing the only predastore
# is a total outage and the guest-survival assertion is unambiguous. On three it
@@ -37,7 +40,7 @@
# received. It is last because it SIGSTOPs a cluster-wide
# service: anything sharing the environment would see the same outage and report
# a failure that says nothing about itself.
E2E_SUITES_SINGLE="single iam cert eks ecs storagegrowth partialblock rds quota storagefault"
E2E_SUITES_SINGLE="single iam cert eks ecs storagegrowth partialblock rds quota route53 storagefault"
# Suites runnable against a multi-node environment.
#
@@ -57,7 +60,7 @@ E2E_SUITES_SINGLE="single iam cert eks ecs storagegrowth partialblock rds quota
# 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"
E2E_SUITES_MULTI="multinode lb cert quota route53 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 ' ' '|')"
+4 -4
View File
@@ -73,7 +73,7 @@ workflows:
'.github/workflows/ui.yml':
- 'actions/checkout@v7.0.1'
- 'actions/setup-node@v7.1.0'
- 'pnpm/action-setup@v6.0.10'
- 'pnpm/action-setup@v6.1.0'
dependencies:
'actions/cache@v6.1.0':
ref: 'v6.1.0'
@@ -125,9 +125,9 @@ dependencies:
commit: 'sha1-dfe3c3f87815947d99a8997f908cb6525fc44e9e'
owner_id: 761456
repo_id: 258271226
'pnpm/action-setup@v6.0.10':
ref: 'v6.0.10'
commit: 'sha1-0977fd99725f1db4007ccb2928dbb4e90d06cc86'
'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':
+1 -1
View File
@@ -35,7 +35,7 @@ jobs:
sparse-checkout: spinifex/runtime/roles/spinifexui/frontend
- name: Setup pnpm
uses: pnpm/action-setup@v6.0.10
uses: pnpm/action-setup@v6.1.0
with:
package_json_file: spinifex/runtime/roles/spinifexui/frontend/package.json
+3 -3
View File
@@ -379,9 +379,9 @@ install-system:
ovn-central ovn-host openvswitch-switch dhcpcd-base
install-go:
@echo -e "\n....Installing Go 1.27.2 for $(ARCH) ($(GO_ARCH))...."
@echo -e "\n....Installing Go 1.27.3 for $(ARCH) ($(GO_ARCH))...."
@if [ ! -d "/usr/local/go" ]; then \
curl -L https://go.dev/dl/go1.27.2.linux-$(GO_ARCH).tar.gz | tar -C /usr/local -xz; \
curl -L https://go.dev/dl/go1.27.3.linux-$(GO_ARCH).tar.gz | tar -C /usr/local -xz; \
else \
echo "Go already installed in /usr/local/go"; \
fi
@@ -412,7 +412,7 @@ fix:
govulncheck:
@echo "Running govulncheck..."
$(_Q)scripts/run-gate.sh govulncheck scripts/govulncheck-gate.sh
$(_Q)scripts/run-gate.sh govulncheck go tool govulncheck ./...
@echo " govulncheck ok"
# NilAway — advisory nil-panic analysis. Not in preflight due to false positives
+1 -1
View File
@@ -1,6 +1,6 @@
ARG VERSION=dev
FROM golang:1.27.2-bookworm@sha256:5cf287a799e6b94384bad13d16b14904c531f51ba65792237e122ce42b392f61 AS builder
FROM golang:1.27.3-bookworm@sha256:5c3e9b43cfd3bb95c7937462e29d87ada57b481a96fa23ad0eb61778d7a00110 AS builder
ARG VERSION
+42
View File
@@ -0,0 +1,42 @@
# Admission contract, version 1
This package owns the cross-process contract for the admission domain's
quota override. It is not an in-process service interface or the owner of
quota state.
## Seal account quota override
`SealAccountQuotaOverrideSubject` (`admission.v1.SealAccountQuotaOverride`) is
a request/reply route on which a gateway, in the `awsgw-quota-seal` queue
group, irreversibly seals one account's quota override record.
`domains/admission/quota` owns it: `awsgw` serves it.
`accountteardown` is the consumer: it calls this subject to fence every
override writer at the quota owner before the account record is removed.
The request names the account in its body (`account_id`) and may carry an
opaque `correlation_ref` that is echoed for logs.
`awsgw` ignores `X-Account-ID` for this subject: the account sealed is the one
named in the body.
A seal is refused with `IncorrectState` unless IAM already holds the account
as `TERMINATING`; a precondition read failure, or no termination checker
wired, answers `InternalError`.
Both refusals write nothing and never open the override bucket.
A reply is sent only on success: `disposition` is always `sealed` and
`revision` is the stream leader's accepted write for that seal.
A failure is the standard awserrors error payload, not this reply shape.
Sealing is idempotent: a seal on an already-sealed record re-writes the same
value and returns a fresh, higher revision.
Any current gateway may answer; the leader-accepted revision is the evidence
whichever one does, and the override bucket is opened at seal time even on a
gateway whose quotas were disabled at startup.
### Compatibility boundary
The subject name carries the contract major; `account_quota_seal_test.go`
preserves the version-1 JSON shape.
Producer and consumer are in the coordinated Spinifex release, so the window
is N and N-1.
A gateway that predates the subject has no subscription, so the request gets
no responder.
An incompatible change to the subject or fields needs a new major.
@@ -0,0 +1,31 @@
// Package admissionv1 holds the admission domain's cross-process contract:
// the subjects, request/reply shapes and wire vocabulary the quota owner and
// its consumers share.
package admissionv1
// SealAccountQuotaOverrideSubject is the request/reply subject on which a
// gateway seals one account's quota override record. Sealing is irreversible
// and refused unless IAM already holds the account as TERMINATING.
const SealAccountQuotaOverrideSubject = "admission.v1.SealAccountQuotaOverride"
// AccountQuotaOverrideDispositionSealed is the only disposition a successful
// reply carries: a reply is sent only on success, so there is no other value.
const AccountQuotaOverrideDispositionSealed = "sealed"
// SealAccountQuotaOverrideRequest asks for one account's quota override to be
// sealed. CorrelationRef is an opaque caller reference echoed for logs; it
// grants nothing and is never a fence or authority to write.
type SealAccountQuotaOverrideRequest struct {
AccountID string `json:"account_id"`
CorrelationRef string `json:"correlation_ref,omitempty"`
}
// SealAccountQuotaOverrideReply confirms accountID's quota override record
// was sealed at Revision, the stream leader's accepted write for that seal.
// A failure is the standard awserrors error payload, not this type.
type SealAccountQuotaOverrideReply struct {
AccountID string `json:"account_id"`
CorrelationRef string `json:"correlation_ref,omitempty"`
Disposition string `json:"disposition"`
Revision uint64 `json:"revision"`
}
@@ -0,0 +1,43 @@
package admissionv1
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/require"
)
func TestSealAccountQuotaOverrideSubject(t *testing.T) {
require.Equal(t, "admission.v1.SealAccountQuotaOverride", SealAccountQuotaOverrideSubject)
}
func TestSealAccountQuotaOverrideRequestJSONContract(t *testing.T) {
data, err := json.Marshal(SealAccountQuotaOverrideRequest{AccountID: "111122223333", CorrelationRef: "req-1"})
require.NoError(t, err)
require.JSONEq(t, `{"account_id":"111122223333","correlation_ref":"req-1"}`, string(data))
data, err = json.Marshal(SealAccountQuotaOverrideRequest{AccountID: "111122223333"})
require.NoError(t, err)
require.JSONEq(t, `{"account_id":"111122223333"}`, string(data))
}
func TestSealAccountQuotaOverrideReplyJSONContract(t *testing.T) {
reply := SealAccountQuotaOverrideReply{
AccountID: "111122223333",
CorrelationRef: "req-1",
Disposition: AccountQuotaOverrideDispositionSealed,
Revision: 42,
}
data, err := json.Marshal(reply)
require.NoError(t, err)
require.JSONEq(t, `{
"account_id": "111122223333",
"correlation_ref": "req-1",
"disposition": "sealed",
"revision": 42
}`, string(data))
data, err = json.Marshal(SealAccountQuotaOverrideReply{AccountID: "111122223333", Disposition: AccountQuotaOverrideDispositionSealed})
require.NoError(t, err)
require.JSONEq(t, `{"account_id":"111122223333","disposition":"sealed","revision":0}`, string(data))
}
+40 -1
View File
@@ -40,7 +40,7 @@ The request (`account_id`) and reply (`vpc_id`, `error`) bodies are still declar
## Platform inventory
`PlatformInventorySubject` (`ec2.v1.PlatformInventory`) is a request/reply route on which a daemon in the `spinifex-workers` queue group reports one account's key pairs, placement groups, launch templates and their `ec2-tags` entries from the EC2 owner stores.
EC2 owns it: `domains/ec2/accountinventory` produces the reply and the daemon serves it; the only consumer today is `accountteardown`, which binds an adapter but does not call it.
EC2 owns it: `domains/ec2/accountinventory` produces the reply and the daemon serves it; the only consumer today is `accountteardown`, which calls it only on an explicit operator dry run (`spx admin account delete --dry-run` or the admin API `DryRun`) and only logs a comparison with its legacy listings; no teardown calls it.
The request names the account in its body (`account_id`, a 12-digit ID) and may carry an opaque `correlation_ref` that is echoed for logs.
The daemon ignores `X-Account-ID` for this subject.
@@ -60,3 +60,42 @@ The subject name carries the contract major; `platform_inventory_test.go` preser
Producer and consumer are in the coordinated Spinifex release, so the window is N and N-1.
A daemon that predates the subject has no subscription, so the request gets no responder; the consumer treats no responder, a timeout, an error reply, an undecodable reply, a reply for another account, and a missing, repeated or unknown-status kind as `scan-incomplete`, never clean.
An incompatible change to the subject, fields or vocabulary needs a new major.
## Seal account settings
`SealAccountSettingsSubject` (`ec2.v1.SealAccountSettings`) is a request/reply route on which a daemon in the `spinifex-workers` queue group irreversibly seals one account's EC2 settings record.
EC2 owns it: `domains/ec2/account` serves it through the daemon.
`accountteardown`'s StageAccount seal reaper is the consumer: it calls this subject only after the seal-capability probe below reports every configured node compatible, and completes only once it holds a successful sealed reply.
The request names the account in its body (`account_id`) and may carry an opaque `correlation_ref` that is echoed for logs.
The daemon ignores `X-Account-ID` for this subject: the account sealed is the one named in the body.
A seal is refused with `IncorrectState` unless IAM already holds the account as `TERMINATING`; a precondition read failure answers `InternalError`.
Both refusals write nothing.
A reply is sent only on success: `disposition` is always `sealed` and `revision` is the stream leader's accepted write for that seal.
A failure is the standard awserrors error payload, not this reply shape.
Sealing is idempotent: a seal on an already-sealed record re-writes the same value and returns a fresh, higher revision.
### Compatibility boundary
The subject name carries the contract major; `account_settings_seal_test.go` preserves the version-1 JSON shape.
Producer and consumer are in the coordinated Spinifex release, so the window is N and N-1.
A daemon that predates the subject has no subscription, so the request gets no responder.
An incompatible change to the subject or fields needs a new major.
## Seal-account-settings capability
`AccountSettingsSealCapabilitySubject(node)` (`ec2.v1.AccountSettingsSealCapability.<node>`) is a per-node, read-only request/reply route on which `accountteardown` asks one configured daemon whether it supports the account-settings seal, immediately before every seal attempt.
EC2 owns it: the daemon serves it with no queue group, so a request addressed to one node reaches only subscribers configured as that node, and two daemons sharing a node ID both answer.
`accountteardown` is the consumer: its StageAccount seal reaper probes every configured node and sends no seal request unless every one answers compatible.
`AccountSettingsSealCapabilityVersion` is a claim about the whole binary's writers, not about the responder: version 1 means every EC2 account-settings writer in the binary is revision-guarded and refuses a sealed record, and the binary serves the seal subject.
A daemon binary without this responder — predating the capability, or carrying only the seal subject without it — has no subscriber, so the probe for that node reads as incomplete (`no-reply`), the same as a stopped or partitioned node.
The reply proves nothing beyond the moment it was sent: a daemon downgraded or replaced after a successful probe is outside the guarantee.
### Compatibility boundary
The subject name carries the contract major; `account_settings_seal_capability_test.go` preserves the version-1 JSON shape and the subject builder.
Producer and consumer are in the coordinated Spinifex release, so the window is N and N-1.
A daemon that predates the capability has no subscription, so the probe gets no responder for that node.
An incompatible change to the subject or fields needs a new major.
+28
View File
@@ -0,0 +1,28 @@
package ec2v1
// SealAccountSettingsSubject is the request/reply subject on which a daemon
// seals one account's EC2 settings record. Sealing is irreversible and refused
// unless IAM already holds the account as TERMINATING.
const SealAccountSettingsSubject = "ec2.v1.SealAccountSettings"
// AccountSettingsDispositionSealed is the only disposition a successful reply
// carries: a reply is sent only on success, so there is no other value.
const AccountSettingsDispositionSealed = "sealed"
// SealAccountSettingsRequest asks for one account's EC2 settings to be
// sealed. CorrelationRef is an opaque caller reference echoed for logs; it
// grants nothing and is never a fence or authority to write.
type SealAccountSettingsRequest struct {
AccountID string `json:"account_id"`
CorrelationRef string `json:"correlation_ref,omitempty"`
}
// SealAccountSettingsReply confirms accountID's settings record was sealed at
// Revision, the stream leader's accepted write for that seal. A failure is
// the standard awserrors error payload, not this type.
type SealAccountSettingsReply struct {
AccountID string `json:"account_id"`
CorrelationRef string `json:"correlation_ref,omitempty"`
Disposition string `json:"disposition"`
Revision uint64 `json:"revision"`
}
@@ -0,0 +1,39 @@
package ec2v1
import "strings"
// AccountSettingsSealCapabilityVersion is the version this binary reports.
// Version 1: every EC2 account-settings writer in the binary is
// revision-guarded and refuses a sealed record, and the seal subject is served.
const AccountSettingsSealCapabilityVersion = 1
// AccountSettingsSealCapabilitySubject is one daemon's read-only
// seal-capability route, addressed per node so a probe reaches exactly the
// node it asks about.
func AccountSettingsSealCapabilitySubject(node string) string {
return "ec2.v1.AccountSettingsSealCapability." + node
}
// ValidSealCapabilityNode reports whether node is exactly one NATS subject
// token. Responder and probe both check it before building a subject, so
// neither can address a wildcard or another node's route.
func ValidSealCapabilityNode(node string) bool {
return node != "" && !strings.ContainsAny(node, ".*>\t\n\r ")
}
// AccountSettingsSealCapabilityRequest asks node whether it supports the
// account-settings seal. CorrelationRef is an opaque caller reference echoed
// for logs; it grants nothing and is never a fence or authority to write.
type AccountSettingsSealCapabilityRequest struct {
Node string `json:"node"`
CorrelationRef string `json:"correlation_ref,omitempty"`
}
// AccountSettingsSealCapabilityReply reports the responding node's capability
// version. It carries no account data and proves nothing beyond the moment it
// was sent.
type AccountSettingsSealCapabilityReply struct {
Node string `json:"node"`
CapabilityVersion int `json:"capability_version"`
CorrelationRef string `json:"correlation_ref,omitempty"`
}
@@ -0,0 +1,43 @@
package ec2v1
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/require"
)
func TestAccountSettingsSealCapabilitySubject(t *testing.T) {
require.Equal(t, "ec2.v1.AccountSettingsSealCapability.node-1", AccountSettingsSealCapabilitySubject("node-1"))
require.Equal(t, "ec2.v1.AccountSettingsSealCapability.node-2", AccountSettingsSealCapabilitySubject("node-2"))
}
func TestAccountSettingsSealCapabilityRequestJSONContract(t *testing.T) {
data, err := json.Marshal(AccountSettingsSealCapabilityRequest{Node: "node-1", CorrelationRef: "req-1"})
require.NoError(t, err)
require.JSONEq(t, `{"node":"node-1","correlation_ref":"req-1"}`, string(data))
data, err = json.Marshal(AccountSettingsSealCapabilityRequest{Node: "node-1"})
require.NoError(t, err)
require.JSONEq(t, `{"node":"node-1"}`, string(data))
}
func TestAccountSettingsSealCapabilityReplyJSONContract(t *testing.T) {
reply := AccountSettingsSealCapabilityReply{Node: "node-1", CapabilityVersion: 1, CorrelationRef: "req-1"}
data, err := json.Marshal(reply)
require.NoError(t, err)
require.JSONEq(t, `{"node":"node-1","capability_version":1,"correlation_ref":"req-1"}`, string(data))
data, err = json.Marshal(AccountSettingsSealCapabilityReply{Node: "node-1", CapabilityVersion: 1})
require.NoError(t, err)
require.JSONEq(t, `{"node":"node-1","capability_version":1}`, string(data))
}
func TestValidSealCapabilityNode(t *testing.T) {
for _, node := range []string{"node-1", "node_2", "n3"} {
require.True(t, ValidSealCapabilityNode(node), node)
}
for _, node := range []string{"", "a.b", "*", ">", "node-*", "node >", "node\t1", "node\n1"} {
require.False(t, ValidSealCapabilityNode(node), "%q", node)
}
}
@@ -0,0 +1,43 @@
package ec2v1
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/require"
)
func TestSealAccountSettingsSubject(t *testing.T) {
require.Equal(t, "ec2.v1.SealAccountSettings", SealAccountSettingsSubject)
}
func TestSealAccountSettingsRequestJSONContract(t *testing.T) {
data, err := json.Marshal(SealAccountSettingsRequest{AccountID: "111122223333", CorrelationRef: "req-1"})
require.NoError(t, err)
require.JSONEq(t, `{"account_id":"111122223333","correlation_ref":"req-1"}`, string(data))
data, err = json.Marshal(SealAccountSettingsRequest{AccountID: "111122223333"})
require.NoError(t, err)
require.JSONEq(t, `{"account_id":"111122223333"}`, string(data))
}
func TestSealAccountSettingsReplyJSONContract(t *testing.T) {
reply := SealAccountSettingsReply{
AccountID: "111122223333",
CorrelationRef: "req-1",
Disposition: AccountSettingsDispositionSealed,
Revision: 42,
}
data, err := json.Marshal(reply)
require.NoError(t, err)
require.JSONEq(t, `{
"account_id": "111122223333",
"correlation_ref": "req-1",
"disposition": "sealed",
"revision": 42
}`, string(data))
data, err = json.Marshal(SealAccountSettingsReply{AccountID: "111122223333", Disposition: AccountSettingsDispositionSealed})
require.NoError(t, err)
require.JSONEq(t, `{"account_id":"111122223333","disposition":"sealed","revision":0}`, string(data))
}
+2 -1
View File
@@ -109,7 +109,8 @@ Operational commands for inspecting cluster state. These fan out NATS requests t
| Command | Flags | Description |
|---------|-------|-------------|
| `spx admin kv digest` | `--store-dir` (default `<spinifex-dir>/nats/jetstream`), `--work-dir` (holds the temporary copy, default the system temp dir), `--stream` (repeatable filter), `--seqs` (also print a hash per sequence), `--json` (print one JSON document, the input `spx admin kv compare` reads) | Read-only per-replica consistency check. Copies this node's `$G` streams tree (without consumer state) to a temporary directory, opens the copy in a private in-process NATS server with no listener, and prints one line per stream with first/last sequence, message count and a SHA-256 over every stored message (sequence, subject, timestamp, headers, data). Run it on every node with `sudo` and diff the output with the `#` lines removed: replicas of one stream must match. A direct get over NATS is not used because any replica may answer it. Writes landing during the copy can change a replica's tail, so gate API writes or re-run before calling a tail difference divergence. Prefer `--json --seqs` on every node followed by `spx admin kv compare`, which tells a diverged replica from one that is merely behind. |
| `spx admin kv compare <digest.json>...` | — (`-` reads any number of digests from stdin) | Reads the `spx admin kv digest --json --seqs` output of every node and checks that each stream's replicas hold the same content. A replica that is merely behind is not divergence; these are: the same sequence holding different content on two replicas, a message missing from one replica that nothing on it explains (no later write to the same subject, no expiry under `max_age` or a message TTL, no stream limit dropping its head), a stream held by fewer or more nodes than its replica count, replicas disagreeing on that count, or no sequence range common to every replica. Sequences past the slowest replica's last are not compared. Exits 0 when every stream is consistent, 1 on divergence, 2 on bad input. Digests are taken from live stores seconds apart, so re-run before trusting a failure that a write landing between two copies could explain. `install-node.sh` gates cluster formation on it. |
| `spx admin kv compare <digest.json>...` | — (`-` reads any number of digests from stdin) | Reads the `spx admin kv digest --json --seqs` output of every node and checks that each stream's replicas hold the same content. A replica that is merely behind is not divergence; these are: the same sequence holding different content on two replicas, a message missing from one replica that nothing on it explains (no later write to the same subject, no expiry under `max_age` or a message TTL, no stream limit dropping its head), a stream held by fewer or more nodes than its replica count, replicas disagreeing on that count, or no sequence range common to every replica. A stream with a `max_age` whose replicas were copied at different times and share no sequence range is reported as `skipped` (volatile) rather than diverged, since a short-lived bucket such as a leader lease turns over between copies; any sequence two of its replicas share must still match, and each message a replica lacks must be superseded, expired, or written after that replica was copied. Sequences past the slowest replica's last are not compared. Exits 0 when every stream is consistent, 1 on divergence, 2 on bad input. Digests are taken from live stores seconds apart, so re-run before trusting a failure that a write landing between two copies could explain. `install-node.sh` gates cluster formation on it. |
| `spx admin dns nameservers` | `--check` (compare against the parent zone's delegation), `--timeout` (per DNS query, default 5s) | Prints the `nsN` nameservers this cluster publishes for its base domain, with the node and address behind each. At most four are published however many nodes run northstar; `[northstar] nameserver_count` (1–4) and `nameserver_nodes` (ordered pin) in `spinifex.toml` set the count and order, and every northstar node keeps serving every zone either way. `--check` asks a server of the parent zone (a `.net` server for `spx3.net`) for the delegation without recursion and reports each name as `ok`, `not delegated`, `no glue`, `glue mismatch` or `not published here`, exiting non-zero unless all are `ok`. |
| `spx admin cluster shutdown` | `--force` (shutdown even if nodes don't respond), `--timeout` (max wait per phase, default 120s), `--dry-run` (print phase plan without executing) | Performs coordinated, phased shutdown of entire cluster. Phases execute in order: GATE (stop API/UI) → DRAIN (stop VMs) → STORAGE (stop viperblock) → PERSIST (stop predastore) → INFRA (stop NATS/daemon). Each phase waits for all nodes to ACK before proceeding. Uses JetStream state tracking. |
| `spx admin cluster drain-dhcp` | `--timeout` (reply-collection window, default 30s) | Asks each vpcd to DHCPRELEASE every external-pool DHCP lease it currently holds, returning them to the upstream DHCP server. Run on teardown before stopping services — an env reset otherwise strands held leases upstream until their TTL expires, eventually exhausting the upstream scope. Best-effort: warns and exits 0 if the cluster is already down. |
| `spx admin node drain --local` | `--local` (drain the local node only, required), `--timeout` (max wait per phase, default 120s), `--unless-restarting` (drain on a real host shutdown or a plain target stop, and skip only when a restart of the stack is already queued; unset, it always drains) | Runs the GATE and DRAIN phases against the local node only: powers down its guests via QMP and unmounts their volumes (flushing the viperblock WAL) while every service is still up. STORAGE/PERSIST/INFRA are left to systemd's ordered teardown. Wired as the `ExecStop` of `spinifex-shutdown.service`, so `systemctl stop spinifex.target` and host reboot/poweroff drain guests before any storage service stops. |
+1 -1
View File
@@ -446,7 +446,7 @@ These residuals were recorded by individual slices and must be resolved once the
- `providers/ebs/viperblock` → `runtime/compute/nbd` and `runtime/host/process` (dependency, moved unchanged from `services/viperblockd`): the adapter formats NBD endpoints and manages nbdkit processes through runtime packages. ADR-0001 does not list providers → runtime as forbidden and the layering test does not enforce it (same category as `providers/ebs/conformance` → `runtime/compute/nbd`), but a provider should not depend on node runtime. Clears when NBD endpoint naming has a provider-facing home (a candidate for `contracts/ebs/v1`) and process supervision is injected by the role. The adapter also imports `providers/objectstore`, a provider-to-provider edge that is allowed.
- `gateway/ec2.go` (registration): the EC2 dispatch map stays a `GatewayConfig` method in `gateway` and imports every `domains/ec2/awsapi/*` package; `gateway/ec2_idempotency.go` also imports `domains/ec2/awsapi/idem` (`KVBucket`, `TTL`, `TokenAndParams`) for the dispatch-time idempotency wrapper. ADR-0001 makes the domain own its action registration; moving the map waits on the generic ingress registration seam and must not be done as a package move.
- `gateway/requestaudit.go`: the outermost request-audit middleware records AWS fields and is updated from four places in the gateway's authentication flow through the gateway's own context key. Moving it would export about seven names used only by that flow, so it stays in `gateway` until the generic ingress registration seam defines how ingress middleware receives authentication results.
- `accountteardown` (dependency): its EC2 reapers call `domains/ec2/awsapi/*` action adapters directly, as they did under `gateway/ec2`. The ADR's `accountteardown` current-to-target map row replaces this with domain-owned cleanup capabilities before the package moves. It also holds a read-only consumer of the EC2 platform-inventory contract (`contracts/ec2/v1`, `ec2.v1.PlatformInventory`), served by `domains/ec2/accountinventory`; no engine path calls it yet, and the key-pair, placement-group and launch-template reapers still use the `awsapi` adapters.
- `accountteardown` (dependency): its EC2 reapers call `domains/ec2/awsapi/*` action adapters directly, as they did under `gateway/ec2`. The ADR's `accountteardown` current-to-target map row replaces this with domain-owned cleanup capabilities before the package moves. It also holds a read-only consumer of the EC2 platform-inventory contract (`contracts/ec2/v1`, `ec2.v1.PlatformInventory`), served by `domains/ec2/accountinventory`; only the explicit dry run (`Engine.DryRun`) calls it, to log a shadow comparison, and the key-pair, placement-group and launch-template reapers still use the `awsapi` adapters. A domain-owned cleanup capability for the proven-safe subset of those three kinds (key pairs and empty placement groups no instance, spot request or live launch-template version names, and launch-template residue; never live launch templates) exists as `domains/ec2/accountcleanup` (a Go interface with injected owners and reference readers, no NATS subject); nothing calls it yet. It also holds the StageAccount settings-seal reaper, a consumer of `contracts/ec2/v1`'s `ec2.v1.SealAccountSettings` and per-node `ec2.v1.AccountSettingsSealCapability.<node>` subjects, served by `domains/ec2/account` through the daemon; the reaper reads no KV and imports no domain package, reaching both subjects only through NATS and the configured node set `NewClusterEngine` already receives.
- `domains/admission/quota` (dependency, moved from `handlers/quota` unchanged): `live.go` imports `gateway/elbv2` and `handlers/rds` for its live ELBv2 and RDS counters; `records.go` imports `runtime/compute/vm` for the instance-record vCPU sweep; `live.go`, `vcpu.go` and `storage.go` call `domains/ec2/awsapi/{vpc,eip,volume}` describe adapters to count VPCs, subnets, addresses and volumes, as they did under `gateway/ec2`. The ADR's `handlers/quota` current-to-target map row replaces these with reader capabilities once their final locations exist.
- `domains/ec2/awsapi/instance` (dependency): imports `handlers/iam` (`IAMService` in `DescribeInstances.go`, `RunInstances.go` and `IamInstanceProfileAssociation.go`; `InstanceProfile` in the latter two), `runtime/compute/cache` (`NodeState`, `NodeUnknown`, `NodeLive`) and `runtime/compute/vm` (`VM`), both in `status_synthesis.go`. The IAM edge needs an IAM capability; the runtime edges are domain-to-runtime and need the instance record and node liveness exposed through an EC2-owned projection.
- `domains/ec2/awsapi/spotinstance` (dependency): imports `handlers/iam.IAMService` and `domains/admission/quota.Service` in `RequestSpotInstances.go`.
+13
View File
@@ -31,6 +31,7 @@ services:
- "ec2.*"
- "elbv2.*"
- "acm.*"
- "route53.*"
- "iam.*"
- "rds.*"
- "ecs.*"
@@ -39,6 +40,7 @@ services:
- "ec2.*"
- "elbv2.*"
- "acm.*"
- "route53.*"
- "iam.*"
- "rds.*"
- "ecs.*"
@@ -76,6 +78,7 @@ services:
- spinifex/domains/ec2/vpc
- spinifex/handlers/elbv2
- spinifex/domains/acm
- spinifex/domains/route53
- spinifex/handlers/iam
- spinifex/handlers/ecs
- spinifex/handlers/rds
@@ -154,6 +157,10 @@ services:
- "acm.DescribeCertificate"
- "acm.ListCertificates"
- "acm.DeleteCertificate"
# Route 53 hosted zones (domains/route53; subscribed in daemon.go).
- "route53.*"
# Operator-only zone activation and delegation, sent by spx admin dns.
- "route53.admin.*"
- "elbv2.AddListenerCertificates"
- "elbv2.RemoveListenerCertificates"
- "elbv2.DescribeListenerCertificates"
@@ -466,6 +473,12 @@ suites:
# Exercises the full daemon stack under network failure injection;
# any service touching the wire path is in scope.
e2e-route53:
path: tests/e2e/route53
covers: [awsgw, spinifex_daemon, predastore]
# Hosted zones end to end: delegation check, publish to the northstar zone
# store in predastore, and every node answering before a change is INSYNC.
# Suites below are bash-only today. Entries activate as the Go ports
# land (Bead 2 fixtures unblock new-shape ports for multinode etc.).
#
+1 -1
View File
@@ -4,7 +4,7 @@
# distroless base (no shell, no package manager) for a small, production-shaped
# image suitable for pushing to ECR and pulling onto EKS workers.
FROM golang:1.27.2-alpine@sha256:f92b6ef800e499660581efdabdf25d9d817a9d124eaf900924f0504e7e27e12d AS build
FROM golang:1.27.3-alpine@sha256:f2c872bcee949b7be9c490efd02b48a8a2fbf947cdf533f1c58525ec9d8170fd AS build
WORKDIR /src
COPY go.mod ./
COPY main.go ./
+1 -1
View File
@@ -1,3 +1,3 @@
module github.com/mulgadc/spinifex-demo
go 1.27.2
go 1.27.3
+3 -2
View File
@@ -6,8 +6,8 @@ Suites live in `tests/e2e/<name>/` and build to `tests/e2e/_bin/<name>.test`. Ev
| Set | Suites |
| --- | --- |
| `E2E_SUITES_SINGLE` | `single iam cert eks ecs storagegrowth partialblock rds quota storagefault` |
| `E2E_SUITES_MULTI` | `multinode lb cert quota kvquorum lbrecovery instancerecovery storagefault` |
| `E2E_SUITES_SINGLE` | `single iam cert eks ecs storagegrowth partialblock rds quota route53 storagefault` |
| `E2E_SUITES_MULTI` | `multinode lb cert quota route53 kvquorum lbrecovery instancerecovery storagefault` |
| `E2E_SUITES_NIGHTLY_SINGLE` | `single cert iam` |
| `E2E_SUITES_NIGHTLY_MULTI` | `multinode cert lb` |
@@ -120,6 +120,7 @@ The create/describe/connect path against a live cluster: a DB VM that actually b
### Other
- **`quota`** — every enforced dimension refuses with the same `quotaExceeded` code; a launch is charged for every instance it asks for; one instance wider than the cap is refused.
- **`route53`** — hosted zones as northstar serves them, on every node. `TestNorthstarServesZonesBeyondFirstListPage` writes 1,100 zone objects (more than one `ListObjectsV2` page), requires the last one to resolve on every node and the platform base zone to survive the sync, then requires the deleted zones to stop resolving.
- **`gpu`** — device visible in the guest, driver access, launch.
- **`bedrock`** — `ListFoundationModels`/`GetFoundationModel` against a running cluster. Wire-format parity is unit-tested, not here.
- **`ddil`** — Denied, Disrupted, Intermittent, Limited network scenarios.
+5 -5
View File
@@ -1,6 +1,6 @@
module github.com/mulgadc/spinifex
go 1.27.2
go 1.27.3
require (
github.com/aws/aws-sdk-go v1.55.8
@@ -9,6 +9,7 @@ require (
github.com/aws/aws-sdk-go-v2/config v1.33.9
github.com/aws/aws-sdk-go-v2/credentials v1.20.9
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.3
github.com/aws/aws-sdk-go-v2/service/s3 v1.114.3
github.com/cenkalti/backoff/v4 v4.3.0
github.com/charmbracelet/bubbles v1.0.0
github.com/charmbracelet/bubbletea v1.3.10
@@ -24,7 +25,7 @@ require (
github.com/miekg/dns v1.1.73
github.com/minio/highwayhash v1.0.4
github.com/mulgadc/bluebottle v1.22.0
github.com/mulgadc/northstar v1.22.0
github.com/mulgadc/northstar v1.22.1-0.20261023224547-8c5555ef1d1d
github.com/mulgadc/predastore v1.22.0
github.com/mulgadc/viperblock v1.22.0
github.com/nats-io/nats-server/v2 v2.15.1
@@ -67,10 +68,9 @@ require (
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.6 // indirect
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.6 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.21 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.5 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.7 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.6 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.4 // indirect
github.com/aws/aws-sdk-go-v2/service/s3 v1.114.0 // indirect
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.6 // indirect
github.com/aws/aws-sdk-go-v2/service/signin v1.10.4 // indirect
github.com/aws/aws-sdk-go-v2/service/sso v1.38.4 // indirect
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.4 // indirect
+12 -8
View File
@@ -40,14 +40,14 @@ github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.6 h1:Qmn60RWuk9+bd2ddvJJqwf3Cpac3
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.6/go.mod h1:h/YJhe4AVyQxEUMluph10iqV7mjajH7A9L+BHF5ppis=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.21 h1:e6YW5uME30TkrElR+4/xcY7NB6CCj2dcv7vtmGXhfhQ=
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.21/go.mod h1:obpHAMP693+f3/A8PkLhIqfK1C+NWWn8zXzqD3OH520=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.5 h1:/TYsZXdA8UTa+WCtCYSAJIr1vwl0+eho6TUgJGwFFO8=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.5/go.mod h1:qPqp1Uwd/BqdhPufv6oem9j5J7HNsgc2V22dUiDPn+s=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.7 h1:CwvFlKw0uUScfgOMflXSRZa+ayXgOf6wk7lhyMemQYU=
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.7/go.mod h1:4SbD8BB+p+QEnG0aX6DoWwrtk9SufyYXRsUv0nve8LE=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.6 h1:LHHdobjo5le1sjJdolitV7uVvYg3wkaeM1Ju2fDgeZQ=
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.6/go.mod h1:CBGB0evAiaMD3Lk4wPO7LQCcCZLuhzL5Qf/wfEUHQ5w=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.4 h1:pPiWfgeNxqluKEph7hvU88kuGKBPOWzO+Dk9t2zqqNs=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.4/go.mod h1:YlwGoIUDG/3kBQbdNOVs/xKZ9J01G8e/6D1mRBj9uTk=
github.com/aws/aws-sdk-go-v2/service/s3 v1.114.0 h1:VMAdYqr4Jn/8ATs9BHC5riwrs0d6m1Z2ohFriSwZwm0=
github.com/aws/aws-sdk-go-v2/service/s3 v1.114.0/go.mod h1:9APRWGLFITKD+xzWSIyT9V7QV4bNlEuIieWlzXgGFlI=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.6 h1:mYViDr2azcgxOpRrObZOl631bvavqILoE/L+Kxgbwys=
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.6/go.mod h1:OXVdAo9jNhggB+hJRGOyy7WolGTKB6Rg7IIulDeSzPM=
github.com/aws/aws-sdk-go-v2/service/s3 v1.114.3 h1:NyeuyXkZL476uoAowNActm4XJO1QPKf7aassgXHpiqA=
github.com/aws/aws-sdk-go-v2/service/s3 v1.114.3/go.mod h1:GfoBeKMDJecgRUsGf/0RGdx0NJAh81ObTd922h8pQys=
github.com/aws/aws-sdk-go-v2/service/signin v1.10.4 h1:tvpDjuUvgKURf3Yk4hOaGbZ4QdY+070OWcA5d6LUv2A=
github.com/aws/aws-sdk-go-v2/service/signin v1.10.4/go.mod h1:3ibVKg5Ng82Zby8++jELTD2nHxiPk2DJRLEeW/LKp8M=
github.com/aws/aws-sdk-go-v2/service/sso v1.38.4 h1:ChCtagixSFOxltv0o6E3NT8F7ksvZtZGPwUBynO5Nmk=
@@ -295,8 +295,12 @@ github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/mulgadc/bluebottle v1.22.0 h1:pGZ5/Ly3CYem62pPu2zKBRpvMpAG0LHMIMw9Yt/lkIg=
github.com/mulgadc/bluebottle v1.22.0/go.mod h1:DwT91bJyUsrpg1gCJmcCOTG+tgw5J124LloHYvGAQu0=
github.com/mulgadc/northstar v1.22.0 h1:fTndlk47hK76fcNr2MqsyeKhBNVEDt8+BC6Z30ohokA=
github.com/mulgadc/northstar v1.22.0/go.mod h1:yLH8waqfHZx177Ae6dGQGTDt/02rPE9alnUKyxDNhVs=
github.com/mulgadc/northstar v1.22.1-0.20261023220040-11ff69dd571a h1:juPJ0jubDYy6Sdk14BGnYB4QF7N0PhAmV522FdBA2yI=
github.com/mulgadc/northstar v1.22.1-0.20261023220040-11ff69dd571a/go.mod h1:Halhinpgo5rzUhYemGN8uYTWu1pJsL5e/7l1eEKc7nM=
github.com/mulgadc/northstar v1.22.1-0.20261023224401-31cf7e48e4db h1:OrXatiBIUEv6InRzh0zHIPnfp+CrIxf2+rX4T3fo7cI=
github.com/mulgadc/northstar v1.22.1-0.20261023224401-31cf7e48e4db/go.mod h1:Halhinpgo5rzUhYemGN8uYTWu1pJsL5e/7l1eEKc7nM=
github.com/mulgadc/northstar v1.22.1-0.20261023224547-8c5555ef1d1d h1:FTch26kEKKv+c4X3L0cQKMXwBJ4D1SCFlV4Mfgh7nEU=
github.com/mulgadc/northstar v1.22.1-0.20261023224547-8c5555ef1d1d/go.mod h1:Halhinpgo5rzUhYemGN8uYTWu1pJsL5e/7l1eEKc7nM=
github.com/mulgadc/predastore v1.22.0 h1:mUQJMTxMiRDVvemMP+34BP/+r1U0iCptc9B++u4NJ8o=
github.com/mulgadc/predastore v1.22.0/go.mod h1:/cMIoKtnQG/DWyeX/0matEQrG2/abY60tNHWcBKS9Kc=
github.com/mulgadc/viperblock v1.22.0 h1:8vS2AtpIDkIhGNOazQPicKXV2y/8PAy8BNrVWIOCP6I=
-3
View File
@@ -1,3 +0,0 @@
# Reachable vulnerabilities accepted for a bounded time, read by scripts/govulncheck-gate.sh.
# Format: <OSV-ID> <expiry YYYY-MM-DD> <reason>
# The gate fails once the expiry passes: fix the dependency or renew deliberately.
+1 -1
View File
@@ -17,7 +17,7 @@ BUILD_DIR="$REPO_ROOT/build/microvm"
INIT_SH="$BUILD_DIR/init.sh"
INITTAB="$BUILD_DIR/inittab"
ALPINE_VERSION="${ALPINE_VERSION:-3.20}"
ALPINE_VERSION="${ALPINE_VERSION:-3.24}"
ALPINE_MIRROR="${ALPINE_MIRROR:-https://dl-cdn.alpinelinux.org/alpine}"
# haproxy: ALB (L7) data plane. nginx + nginx-mod-stream: NLB (L4) data plane —
# the `stream` module (a separate Alpine package) load-balances TCP/UDP/TLS,
+6
View File
@@ -284,6 +284,12 @@ if [[ "$DISTRO" == "alpine" ]] && [[ -n "${APK_PACKAGES:-}" ]]; then
CUST+=(--run-command 'sed -i "s|^#\(.*community\)|\1|" /etc/apk/repositories 2>/dev/null || true')
CUST+=(--run-command 'grep -q community /etc/apk/repositories || grep main /etc/apk/repositories | head -1 | sed "s|/main|/community|" >> /etc/apk/repositories')
CUST+=(--run-command 'apk update')
# The base cloud image's kernel is only as fresh as its release; upgrade so
# the AMI ships the branch's current linux-virt and security fixes.
CUST+=(--run-command 'apk upgrade')
# apk's mkinitfs trigger does not always fire in the appliance; a new kernel
# with the old initramfs cannot load virtio_blk and never mounts root.
CUST+=(--run-command 'k=$(ls /lib/modules | sort -V | tail -1) && mkinitfs "$k" && zcat /boot/initramfs-virt | cpio -t | grep -qE "^(usr/)?lib/modules/$k/.*/virtio_blk\.ko"')
CUST+=(--run-command "apk add ${APK_PACKAGES}")
elif [[ "$DISTRO" == "ubuntu" ]] && [[ -n "${APT_PACKAGES:-}" ]]; then
echo "Will install packages: ${APT_PACKAGES}"
-50
View File
@@ -1,50 +0,0 @@
#!/usr/bin/env bash
# Fails on any reachable vulnerability that is not listed in
# govulncheck-accepted.txt, and on any acceptance whose expiry has passed.
# Each line is "<OSV-ID> <YYYY-MM-DD expiry> <reason>"; '#' starts a comment.
# GOVULNCHECK_JSON substitutes a saved -format json report, for tests.
set -euo pipefail
accepted_file=${GOVULNCHECK_ACCEPTED:-govulncheck-accepted.txt}
today=${GOVULNCHECK_TODAY:-$(date -u +%F)}
if [ -n "${GOVULNCHECK_JSON:-}" ]; then
report=$(cat "$GOVULNCHECK_JSON")
else
report=$(go tool govulncheck -format json ./...)
fi
# A finding whose first trace frame names a function is reachable from our
# code; text mode reports only these, so module- and package-level ones pass.
mapfile -t called < <(printf '%s\n' "$report" | jq -rs \
'[.[] | select(.finding) | .finding | select(.trace[0].function) | .osv] | unique | .[]')
declare -A expiry reason
if [ -f "$accepted_file" ]; then
while read -r id until why; do
[[ -z "$id" || "$id" == \#* ]] && continue
expiry[$id]=$until
reason[$id]=$why
done <"$accepted_file"
fi
fail=0
for id in "${called[@]}"; do
if [ -z "${expiry[$id]:-}" ]; then
echo " $id is reachable and not accepted; run 'go tool govulncheck ./...' for the trace"
fail=1
elif [[ "$today" > "${expiry[$id]}" ]]; then
echo " $id acceptance expired on ${expiry[$id]}: ${reason[$id]}"
fail=1
else
echo " $id accepted until ${expiry[$id]}: ${reason[$id]}"
fi
done
for id in "${!expiry[@]}"; do
if ! printf '%s\n' "${called[@]}" | grep -qx "$id"; then
echo " $id is accepted but no longer reported; remove it from $accepted_file"
fi
done
exit "$fail"
-53
View File
@@ -1,53 +0,0 @@
#!/bin/sh
# Tests the accepted-vulnerability gate against saved reports, so the logic is
# checked without running the scanner.
set -u
here=$(cd "$(dirname "$0")" && pwd)
gate="$here/govulncheck-gate.sh"
tmp=$(mktemp -d)
trap 'rm -rf "$tmp"' EXIT
cat >"$tmp/accepted.txt" <<'EOF'
# comment
GO-2026-6597 2026-11-07 accepted for the test
EOF
cat >"$tmp/reachable.json" <<'EOF'
{"config":{}}
{"finding":{"osv":"GO-2026-6597","trace":[{"module":"m","package":"p","function":"Pull"}]}}
{"finding":{"osv":"GO-2026-0001","trace":[{"module":"m"}]}}
EOF
cat >"$tmp/new.json" <<'EOF'
{"finding":{"osv":"GO-2026-6597","trace":[{"module":"m","package":"p","function":"Pull"}]}}
{"finding":{"osv":"GO-2026-7777","trace":[{"module":"m","package":"p","function":"F"}]}}
EOF
echo '{"config":{}}' >"$tmp/clean.json"
fails=0
check() {
want=$1 name=$2 report=$3 today=$4 accepted=$5 expect=$6
out=$(GOVULNCHECK_JSON="$tmp/$report" GOVULNCHECK_TODAY=$today GOVULNCHECK_ACCEPTED=$accepted bash "$gate" 2>&1)
rc=$?
if [ "$rc" != "$want" ] || ! printf '%s' "$out" | grep -q "$expect"; then
echo "FAIL [$name] rc=$rc want=$want output: $out"
fails=$((fails + 1))
else
echo "ok [$name]"
fi
}
check 0 "accepted reachable finding passes" reachable.json 2026-10-07 "$tmp/accepted.txt" "GO-2026-6597 accepted until 2026-11-07"
check 0 "expiry day itself still passes" reachable.json 2026-11-07 "$tmp/accepted.txt" "accepted until"
check 1 "expired acceptance fails" reachable.json 2026-11-08 "$tmp/accepted.txt" "acceptance expired on 2026-11-07"
check 1 "new reachable finding fails" new.json 2026-10-07 "$tmp/accepted.txt" "GO-2026-7777 is reachable and not accepted"
out=$(GOVULNCHECK_JSON="$tmp/reachable.json" GOVULNCHECK_TODAY=2026-10-07 GOVULNCHECK_ACCEPTED="$tmp/accepted.txt" bash "$gate" 2>&1)
if printf '%s' "$out" | grep -q "GO-2026-0001"; then
echo "FAIL [module-level finding is not reachable] output: $out"
fails=$((fails + 1))
else
echo "ok [module-level finding is not reachable]"
fi
check 0 "fixed finding warns that acceptance is stale" clean.json 2026-10-07 "$tmp/accepted.txt" "no longer reported"
check 1 "missing acceptance file fails reachable finding" reachable.json 2026-10-07 "$tmp/none" "not accepted"
[ "$fails" -eq 0 ] || exit 1
+7 -1
View File
@@ -43,7 +43,13 @@ esac
# Teardown, dry run. Every mutation must go through run(), so a dry run can
# report the whole plan without performing any of it.
# --------------------------------------------------------------------------
out=$("$SETUP" --teardown --dry-run 2>&1) && rc=0 || rc=$?
# The teardown plans the veth steps only when ovs-vsctl exists and the link is
# present, so stub both or the order check depends on the machine running it.
mkdir "$TMP/bin"
printf '#!/bin/sh\nexit 0\n' > "$TMP/bin/ip"
cp "$TMP/bin/ip" "$TMP/bin/ovs-vsctl"
chmod +x "$TMP/bin/ip" "$TMP/bin/ovs-vsctl"
out=$(PATH="$TMP/bin:$PATH" "$SETUP" --teardown --dry-run 2>&1) && rc=0 || rc=$?
[ "$rc" = 0 ] && pass "--teardown --dry-run exits 0" || fail "--teardown --dry-run exited $rc, want 0"
case "$out" in
+7 -1
View File
@@ -81,9 +81,15 @@ mkcfg() { # mkcfg <dir> <node>...
mkcfg "$TMP/three" node1 node2 node3
mkcfg "$TMP/one" node1
# The node count is read through sudo, so a pass-through stub keeps the refusal
# from depending on whether the machine running this has sudo.
mkdir "$TMP/bin"
printf '#!/bin/sh\nexec "$@"\n' > "$TMP/bin/sudo"
chmod +x "$TMP/bin/sudo"
for args in "" "--yes" "--yes --dry-run"; do
# shellcheck disable=SC2086
SPX_ETC_DIR="$TMP/three" "$UNINSTALL" $args >"$TMP/out" 2>"$TMP/err" && rc=0 || rc=$?
PATH="$TMP/bin:$PATH" SPX_ETC_DIR="$TMP/three" "$UNINSTALL" $args >"$TMP/out" 2>"$TMP/err" && rc=0 || rc=$?
label=${args:-"(no flags)"}
[ "$rc" = 1 ] \
&& pass "three-node config refuses with $label" \
+5
View File
@@ -70,6 +70,11 @@ func NewClusterEngine(
reapers = append(reapers, S3Reapers(buckets)...)
reapers = append(reapers, ACMReapers(nc)...)
reapers = append(reapers, IAMReapers(svc)...)
// Before the account counters and reservations: the seal must still be
// able to find the account IAM holds as TERMINATING, which the account
// record reaper does not remove.
reapers = append(reapers, EC2AccountSettingsReapers(nc, nodeIDs)...)
reapers = append(reapers, AccountReapers(svc, names, usage)...)
engine := NewEngine(NewIAMAccounts(svc), reapers...)
+21
View File
@@ -41,6 +41,27 @@ func TestNewClusterEngineCoversEveryStage(t *testing.T) {
}
}
// The EC2 settings seal reaper must be registered in StageAccount on every
// surface that builds a cluster engine, or teardown would finalise accounts
// without ever sealing their settings.
func TestNewClusterEngineRegistersTheEC2SettingsSealReaper(t *testing.T) {
_, nc, _ := testutil.StartTestJetStream(t)
masterKey, err := handlers_iam.GenerateMasterKey()
require.NoError(t, err)
svc, err := handlers_iam.NewIAMServiceImpl(t.Context(), nc, masterKey)
require.NoError(t, err)
engine, err := NewClusterEngine(t.Context(), nc, 1, []string{"node-1"}, svc, newFakeBuckets())
require.NoError(t, err)
var kinds []string
for _, reaper := range engine.reapersFor(StageAccount) {
kinds = append(kinds, reaper.Kind())
}
assert.Contains(t, kinds, "ec2-account-settings")
}
// A teardown must refuse the accounts no credential may delete before it reads
// anything, and refuse a name confirmation that does not match the record.
func TestClusterEnginePrechecks(t *testing.T) {
@@ -0,0 +1,125 @@
package accountteardown
import (
"context"
"errors"
"fmt"
"log/slog"
"sync"
"time"
ec2v1 "github.com/mulgadc/spinifex/contracts/ec2/v1"
natsmsg "github.com/mulgadc/spinifex/spinifex/foundation/messaging/nats"
"github.com/nats-io/nats.go"
)
// ec2AccountSettingsSealTimeout bounds one seal request; an earlier caller
// deadline wins.
const ec2AccountSettingsSealTimeout = 30 * time.Second
// ec2AccountSettingsSealer is EC2's seal of one account's settings record.
type ec2AccountSettingsSealer interface {
SealAccountSettings(ctx context.Context, accountID string) (revision uint64, err error)
}
// ec2AccountSettingsSealClient reaches the EC2 settings-seal contract over
// NATS. Only a reply for this account, disposition sealed and a non-zero
// revision is a seal; anything else, including no responder, is an error.
type ec2AccountSettingsSealClient struct {
nc *nats.Conn
timeout time.Duration
}
var _ ec2AccountSettingsSealer = (*ec2AccountSettingsSealClient)(nil)
func newEC2AccountSettingsSealClient(nc *nats.Conn) *ec2AccountSettingsSealClient {
return &ec2AccountSettingsSealClient{nc: nc, timeout: ec2AccountSettingsSealTimeout}
}
func (c *ec2AccountSettingsSealClient) SealAccountSettings(ctx context.Context, accountID string) (uint64, error) {
timeout := c.timeout
if deadline, ok := ctx.Deadline(); ok {
timeout = min(timeout, time.Until(deadline))
}
if ctx.Err() != nil || timeout <= 0 {
return 0, fmt.Errorf("seal account settings: %w", context.DeadlineExceeded)
}
reply, err := natsmsg.NATSRequest[ec2v1.SealAccountSettingsReply](ctx, c.nc, ec2v1.SealAccountSettingsSubject,
&ec2v1.SealAccountSettingsRequest{AccountID: accountID}, timeout, accountID)
if err != nil {
return 0, fmt.Errorf("seal account settings: %w", err)
}
if reply.AccountID != accountID {
return 0, fmt.Errorf("seal account settings: reply named account %q, not %q", reply.AccountID, accountID)
}
if reply.Disposition != ec2v1.AccountSettingsDispositionSealed {
return 0, fmt.Errorf("seal account settings: unexpected disposition %q", reply.Disposition)
}
if reply.Revision == 0 {
return 0, errors.New("seal account settings: reply carried no revision")
}
return reply.Revision, nil
}
// accountSettingsSealReaper seals EC2's settings record in StageAccount. List
// does no I/O and stays pending until this instance has sealed, so DryRun never
// seals; state is per engine, so every fresh teardown run seals again.
type accountSettingsSealReaper struct {
sealer ec2AccountSettingsSealer
capability sealCapabilityChecker
mu sync.Mutex
sealed map[string]uint64 // account ID -> leader revision, this run only
}
func (r *accountSettingsSealReaper) Kind() string { return "ec2-account-settings" }
func (r *accountSettingsSealReaper) Stage() Stage { return StageAccount }
func (r *accountSettingsSealReaper) alreadySealed(accountID string) bool {
r.mu.Lock()
defer r.mu.Unlock()
_, ok := r.sealed[accountID]
return ok
}
func (r *accountSettingsSealReaper) List(_ context.Context, accountID string) ([]Resource, error) {
if r.alreadySealed(accountID) {
return nil, nil
}
return []Resource{{Kind: r.Kind(), ID: accountID, Detail: "seal"}}, nil
}
// Delete probes every configured daemon immediately before each seal attempt,
// never reusing an earlier probe. Incomplete capability sends no seal and is
// retryable; force is ignored, since nothing may force past the probe.
func (r *accountSettingsSealReaper) Delete(ctx context.Context, accountID string, _ Resource, _ bool) error {
report := r.capability.CheckSealCapability(ctx, accountID)
if !report.Complete {
return errSealCapabilityIncomplete(report)
}
revision, err := r.sealer.SealAccountSettings(ctx, accountID)
if err != nil {
return err
}
r.mu.Lock()
if r.sealed == nil {
r.sealed = map[string]uint64{}
}
r.sealed[accountID] = revision
r.mu.Unlock()
slog.InfoContext(ctx, "Account teardown sealed EC2 account settings", "accountID", accountID, "revision", revision)
return nil
}
// EC2AccountSettingsReapers returns the StageAccount reaper that seals EC2's
// settings record. nodeIDs is the configured node set the capability probe
// treats as "every relevant writer"; an empty set is unknown, never complete.
func EC2AccountSettingsReapers(nc *nats.Conn, nodeIDs []string) []Reaper {
return []Reaper{&accountSettingsSealReaper{
sealer: newEC2AccountSettingsSealClient(nc),
capability: newSealCapabilityClient(nc, nodeIDs),
}}
}
@@ -0,0 +1,190 @@
package accountteardown
import (
"context"
"encoding/json"
"sort"
"strings"
"sync"
"time"
ec2v1 "github.com/mulgadc/spinifex/contracts/ec2/v1"
awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
natsmsg "github.com/mulgadc/spinifex/spinifex/foundation/messaging/nats"
"github.com/nats-io/nats.go"
)
// sealCapabilityWait bounds the whole probe; shortened by the caller's
// deadline. Every configured node is probed in parallel against this one
// wait, so N nodes cost one wait, not N.
const sealCapabilityWait = 3 * time.Second
// sealCapabilityGrace is how long a node's probe keeps listening after its
// one expected responder has answered, long enough for a genuine second
// subscriber on the same node to be caught as a duplicate.
const sealCapabilityGrace = 150 * time.Millisecond
// Seal capability reasons, one per failing node. Each names a category, never
// wire error text.
const (
sealCapabilityReasonNoReply = "no-reply"
sealCapabilityReasonDuplicate = "duplicate"
sealCapabilityReasonWrongIdentity = "wrong-identity"
sealCapabilityReasonErrorReply = "error-reply"
sealCapabilityReasonMalformed = "malformed"
sealCapabilityReasonOldVersion = "old-version"
sealCapabilityReasonTransport = "transport"
sealCapabilityReasonInvalidNodeID = "invalid-node-id"
sealCapabilityReasonSetUnknown = "configured-node-set-unknown"
)
// sealCapabilityChecker reports whether every configured daemon supports the
// seal.
type sealCapabilityChecker interface {
CheckSealCapability(ctx context.Context, correlationRef string) sealCapabilityReport
}
// sealCapabilityReport is one probe's result, one entry per configured node.
type sealCapabilityReport struct {
Complete bool
Nodes []nodeSealCapability // one per configured node, sorted by node
}
// nodeSealCapability is one configured node's compatibility. Reason is ""
// when the node is compatible.
type nodeSealCapability struct {
Node string
Version int // 0 unless a valid reply arrived
Reason string // "" when compatible
}
// sealCapabilityClient probes every configured node's seal-capability subject
// in parallel. "Configured" is the same node set NewClusterEngine already
// receives; there is no separate liveness or membership source.
type sealCapabilityClient struct {
nc *nats.Conn
nodeIDs []string
wait time.Duration // 3 s, shortened by the caller's deadline
grace time.Duration // 150 ms ResponderGrace
}
var _ sealCapabilityChecker = (*sealCapabilityClient)(nil)
func newSealCapabilityClient(nc *nats.Conn, nodeIDs []string) *sealCapabilityClient {
return &sealCapabilityClient{nc: nc, nodeIDs: nodeIDs, wait: sealCapabilityWait, grace: sealCapabilityGrace}
}
// CheckSealCapability probes every configured node in parallel, bounded by
// one shared deadline, run immediately before each seal attempt. A cached or
// earlier result is never reused.
func (c *sealCapabilityClient) CheckSealCapability(ctx context.Context, correlationRef string) sealCapabilityReport {
if len(c.nodeIDs) == 0 {
return sealCapabilityReport{Complete: false}
}
wait := c.wait
if deadline, ok := ctx.Deadline(); ok {
wait = min(wait, time.Until(deadline))
}
nodes := append([]string(nil), c.nodeIDs...)
sort.Strings(nodes)
results := make([]nodeSealCapability, len(nodes))
if ctx.Err() != nil || wait <= 0 {
for i, node := range nodes {
results[i] = nodeSealCapability{Node: node, Reason: sealCapabilityReasonNoReply}
}
return sealCapabilityReport{Complete: false, Nodes: results}
}
var wg sync.WaitGroup
for i, node := range nodes {
wg.Add(1)
go func(i int, node string) {
defer wg.Done()
results[i] = c.checkNode(ctx, node, correlationRef, wait)
}(i, node)
}
wg.Wait()
complete := true
for _, r := range results {
if r.Reason != "" {
complete = false
break
}
}
return sealCapabilityReport{Complete: complete, Nodes: results}
}
// checkNode probes one node and classifies the result. A node is compatible
// only when exactly one identified, decodable, matching, version >= 1 frame
// arrived, with no duplicate, conflict or cap hit.
func (c *sealCapabilityClient) checkNode(ctx context.Context, node, correlationRef string, wait time.Duration) nodeSealCapability {
if !ec2v1.ValidSealCapabilityNode(node) {
return nodeSealCapability{Node: node, Reason: sealCapabilityReasonInvalidNodeID}
}
data, err := json.Marshal(ec2v1.AccountSettingsSealCapabilityRequest{Node: node, CorrelationRef: correlationRef})
if err != nil {
return nodeSealCapability{Node: node, Reason: sealCapabilityReasonMalformed}
}
frames, sum, err := natsmsg.Gather(ctx, c.nc, ec2v1.AccountSettingsSealCapabilitySubject(node), data, natsmsg.GatherOpts{
Timeout: wait,
ExpectedResponders: 1,
Mode: natsmsg.CollectUntilDeadline,
ResponderGrace: c.grace,
})
if err != nil {
return nodeSealCapability{Node: node, Reason: sealCapabilityReasonTransport}
}
switch {
case sum.CapHit, len(sum.ConflictNodes) > 0, sum.DuplicateFrames > 0:
return nodeSealCapability{Node: node, Reason: sealCapabilityReasonDuplicate}
case sum.Unidentified > 0:
return nodeSealCapability{Node: node, Reason: sealCapabilityReasonWrongIdentity}
case len(sum.ErrorResponders) > 0:
return nodeSealCapability{Node: node, Reason: sealCapabilityReasonErrorReply}
case sum.Received == 0, len(frames) == 0:
return nodeSealCapability{Node: node, Reason: sealCapabilityReasonNoReply}
}
frame := frames[0]
if frame.NodeID != node {
return nodeSealCapability{Node: node, Reason: sealCapabilityReasonWrongIdentity}
}
var reply ec2v1.AccountSettingsSealCapabilityReply
if json.Unmarshal(frame.Data, &reply) != nil {
return nodeSealCapability{Node: node, Reason: sealCapabilityReasonMalformed}
}
if reply.Node != node {
return nodeSealCapability{Node: node, Reason: sealCapabilityReasonWrongIdentity}
}
if reply.CapabilityVersion < 1 {
return nodeSealCapability{Node: node, Version: reply.CapabilityVersion, Reason: sealCapabilityReasonOldVersion}
}
return nodeSealCapability{Node: node, Version: reply.CapabilityVersion}
}
// errSealCapabilityIncomplete builds the retryable refusal from report. It
// names node IDs and reason categories only, never wire error text.
func errSealCapabilityIncomplete(report sealCapabilityReport) error {
detail := sealCapabilityReasonSetUnknown
var parts []string
for _, n := range report.Nodes {
if n.Reason != "" {
parts = append(parts, n.Node+"="+n.Reason)
}
}
if len(parts) > 0 {
detail = strings.Join(parts, ", ")
}
return awserrors.WithRetryAfter(
awserrors.Errorf(awserrors.ErrorServiceUnavailable, "EC2 account-settings seal capability incomplete: %s", detail),
5*time.Second,
)
}
@@ -0,0 +1,311 @@
package accountteardown
//test:in-package — the probe client, its report types and reason constants
// are unexported, and what they guarantee (incomplete never seals, complete
// does) is only observable from inside the package.
import (
"encoding/json"
"sync/atomic"
"testing"
"time"
ec2v1 "github.com/mulgadc/spinifex/contracts/ec2/v1"
"github.com/mulgadc/spinifex/internal/testkit"
awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
natsmsg "github.com/mulgadc/spinifex/spinifex/foundation/messaging/nats"
"github.com/nats-io/nats.go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// testCapabilityClient builds a probe client with test-sized timeouts so the
// incomplete cases below do not each pay the production 3 s wait.
func testCapabilityClient(nc *nats.Conn, nodeIDs []string) *sealCapabilityClient {
c := newSealCapabilityClient(nc, nodeIDs)
c.wait = 200 * time.Millisecond
c.grace = 30 * time.Millisecond
return c
}
// serveCapability subscribes reply on subject with no queue group, which is
// how the real responder is addressed: per node, so two subscribers on the
// same node's subject both answer.
func serveCapability(t *testing.T, nc *nats.Conn, subject string, reply nats.MsgHandler) *nats.Subscription {
t.Helper()
sub, err := nc.Subscribe(subject, reply)
require.NoError(t, err)
t.Cleanup(func() { _ = sub.Unsubscribe() })
require.NoError(t, nc.Flush())
return sub
}
// capabilityReply answers with a well-formed reply naming replyNode (which
// may deliberately differ from the subject's node) and headerNode as the
// X-Node-ID ("" omits the header).
func capabilityReply(replyNode, headerNode string, version int) nats.MsgHandler {
return func(msg *nats.Msg) {
data, _ := json.Marshal(ec2v1.AccountSettingsSealCapabilityReply{Node: replyNode, CapabilityVersion: version})
reply := nats.NewMsg(msg.Reply)
reply.Data = data
if headerNode != "" {
reply.Header.Set(natsmsg.NodeIDHeader, headerNode)
}
_ = msg.RespondMsg(reply)
}
}
func nodeResult(t *testing.T, report sealCapabilityReport, node string) nodeSealCapability {
t.Helper()
for _, n := range report.Nodes {
if n.Node == node {
return n
}
}
t.Fatalf("no result for node %q in %+v", node, report)
return nodeSealCapability{}
}
// Each sub-case names exactly one way a node fails to prove capability
// version 1, and that failure alone makes the whole probe incomplete.
func TestSealCapabilityIncomplete(t *testing.T) {
t.Run("missing", func(t *testing.T) {
_, nc, _ := testutil.StartTestJetStream(t)
report := testCapabilityClient(nc, []string{"node-1"}).CheckSealCapability(testCtx(t), "req")
assert.False(t, report.Complete)
assert.Equal(t, sealCapabilityReasonNoReply, nodeResult(t, report, "node-1").Reason)
})
t.Run("duplicate", func(t *testing.T) {
_, nc, _ := testutil.StartTestJetStream(t)
serveCapability(t, nc, ec2v1.AccountSettingsSealCapabilitySubject("node-1"), capabilityReply("node-1", "node-1", 1))
serveCapability(t, nc, ec2v1.AccountSettingsSealCapabilitySubject("node-1"), capabilityReply("node-1", "node-1", 1))
report := testCapabilityClient(nc, []string{"node-1"}).CheckSealCapability(testCtx(t), "req")
assert.False(t, report.Complete)
assert.Equal(t, sealCapabilityReasonDuplicate, nodeResult(t, report, "node-1").Reason)
})
t.Run("unreachable timeout", func(t *testing.T) {
_, nc, _ := testutil.StartTestJetStream(t)
serveCapability(t, nc, ec2v1.AccountSettingsSealCapabilitySubject("node-1"), func(msg *nats.Msg) {
time.Sleep(time.Second)
_ = msg.Respond(nil)
})
client := testCapabilityClient(nc, []string{"node-1"})
report := client.CheckSealCapability(testCtx(t), "req")
assert.False(t, report.Complete)
assert.Equal(t, sealCapabilityReasonNoReply, nodeResult(t, report, "node-1").Reason)
})
t.Run("transport closed connection", func(t *testing.T) {
_, nc, _ := testutil.StartTestJetStream(t)
nc.Close()
report := testCapabilityClient(nc, []string{"node-1"}).CheckSealCapability(testCtx(t), "req")
assert.False(t, report.Complete)
assert.Equal(t, sealCapabilityReasonTransport, nodeResult(t, report, "node-1").Reason)
})
t.Run("malformed body", func(t *testing.T) {
_, nc, _ := testutil.StartTestJetStream(t)
serveCapability(t, nc, ec2v1.AccountSettingsSealCapabilitySubject("node-1"), func(msg *nats.Msg) {
reply := nats.NewMsg(msg.Reply)
reply.Data = []byte("not json")
reply.Header.Set(natsmsg.NodeIDHeader, "node-1")
_ = msg.RespondMsg(reply)
})
report := testCapabilityClient(nc, []string{"node-1"}).CheckSealCapability(testCtx(t), "req")
assert.False(t, report.Complete)
assert.Equal(t, sealCapabilityReasonMalformed, nodeResult(t, report, "node-1").Reason)
})
t.Run("error reply", func(t *testing.T) {
_, nc, _ := testutil.StartTestJetStream(t)
serveCapability(t, nc, ec2v1.AccountSettingsSealCapabilitySubject("node-1"), func(msg *nats.Msg) {
reply := nats.NewMsg(msg.Reply)
reply.Data = awserrors.GenerateErrorPayload(awserrors.ErrorServerInternal)
reply.Header.Set(natsmsg.NodeIDHeader, "node-1")
_ = msg.RespondMsg(reply)
})
report := testCapabilityClient(nc, []string{"node-1"}).CheckSealCapability(testCtx(t), "req")
assert.False(t, report.Complete)
assert.Equal(t, sealCapabilityReasonErrorReply, nodeResult(t, report, "node-1").Reason)
})
t.Run("wrong identity no header", func(t *testing.T) {
_, nc, _ := testutil.StartTestJetStream(t)
serveCapability(t, nc, ec2v1.AccountSettingsSealCapabilitySubject("node-1"), capabilityReply("node-1", "", 1))
report := testCapabilityClient(nc, []string{"node-1"}).CheckSealCapability(testCtx(t), "req")
assert.False(t, report.Complete)
assert.Equal(t, sealCapabilityReasonWrongIdentity, nodeResult(t, report, "node-1").Reason)
})
t.Run("wrong identity another node header", func(t *testing.T) {
_, nc, _ := testutil.StartTestJetStream(t)
serveCapability(t, nc, ec2v1.AccountSettingsSealCapabilitySubject("node-1"), capabilityReply("node-1", "node-9", 1))
report := testCapabilityClient(nc, []string{"node-1"}).CheckSealCapability(testCtx(t), "req")
assert.False(t, report.Complete)
assert.Equal(t, sealCapabilityReasonWrongIdentity, nodeResult(t, report, "node-1").Reason)
})
t.Run("wrong identity body node differs", func(t *testing.T) {
_, nc, _ := testutil.StartTestJetStream(t)
serveCapability(t, nc, ec2v1.AccountSettingsSealCapabilitySubject("node-1"), capabilityReply("node-9", "node-1", 1))
report := testCapabilityClient(nc, []string{"node-1"}).CheckSealCapability(testCtx(t), "req")
assert.False(t, report.Complete)
assert.Equal(t, sealCapabilityReasonWrongIdentity, nodeResult(t, report, "node-1").Reason)
})
t.Run("lower version", func(t *testing.T) {
_, nc, _ := testutil.StartTestJetStream(t)
serveCapability(t, nc, ec2v1.AccountSettingsSealCapabilitySubject("node-1"), capabilityReply("node-1", "node-1", 0))
report := testCapabilityClient(nc, []string{"node-1"}).CheckSealCapability(testCtx(t), "req")
assert.False(t, report.Complete)
assert.Equal(t, sealCapabilityReasonOldVersion, nodeResult(t, report, "node-1").Reason)
})
t.Run("configured set empty", func(t *testing.T) {
_, nc, _ := testutil.StartTestJetStream(t)
report := testCapabilityClient(nc, nil).CheckSealCapability(testCtx(t), "req")
assert.False(t, report.Complete)
assert.Empty(t, report.Nodes)
})
t.Run("invalid node id", func(t *testing.T) {
_, nc, _ := testutil.StartTestJetStream(t)
report := testCapabilityClient(nc, []string{"bad.node"}).CheckSealCapability(testCtx(t), "req")
assert.False(t, report.Complete)
assert.Equal(t, sealCapabilityReasonInvalidNodeID, nodeResult(t, report, "bad.node").Reason)
})
}
// Every configured node answering version 1 or later makes the probe
// complete, and the reaper sends exactly one seal request.
func TestSealCapabilityCompleteAllowsSeal(t *testing.T) {
_, nc, _ := testutil.StartTestJetStream(t)
serveCapability(t, nc, ec2v1.AccountSettingsSealCapabilitySubject("node-1"), capabilityReply("node-1", "node-1", 1))
serveCapability(t, nc, ec2v1.AccountSettingsSealCapabilitySubject("node-2"), capabilityReply("node-2", "node-2", 2))
client := testCapabilityClient(nc, []string{"node-1", "node-2"})
report := client.CheckSealCapability(testCtx(t), "req")
require.True(t, report.Complete)
assert.Equal(t, 1, nodeResult(t, report, "node-1").Version)
assert.Equal(t, 2, nodeResult(t, report, "node-2").Version)
sealer := &fakeSealer{}
reaper := &accountSettingsSealReaper{sealer: sealer, capability: client}
require.NoError(t, reaper.Delete(testCtx(t), testAccountID, Resource{Kind: reaper.Kind(), ID: testAccountID}, false))
assert.Equal(t, []string{testAccountID}, sealer.calls)
}
// An incomplete probe keeps the account TERMINATING through the full engine,
// names the silent node and its reason, sends no seal request, and leaves the
// settings record untouched (the fake sealer records no call).
func TestSealCapabilityIncompleteKeepsAccountTerminating(t *testing.T) {
_, nc, _ := testutil.StartTestJetStream(t)
serveCapability(t, nc, ec2v1.AccountSettingsSealCapabilitySubject("node-1"), capabilityReply("node-1", "node-1", 1))
// node-2 has no subscriber.
sealer := &fakeSealer{}
reaper := &accountSettingsSealReaper{sealer: sealer, capability: testCapabilityClient(nc, []string{"node-1", "node-2"})}
accounts := newFakeAccounts()
engine := quietEngine(accounts, reaper)
result, err := engine.Teardown(testCtx(t), request())
require.ErrorIs(t, err, ErrResourcesStuck)
assert.False(t, result.AccountDeleted)
assert.Equal(t, AccountStatusTerminating, accounts.account.Status)
assert.Empty(t, sealer.calls)
var reason string
for _, stage := range result.Stages {
for _, stuck := range stage.Stuck {
if stuck.Resource.Kind == "ec2-account-settings" {
reason = stuck.Reason
}
}
}
assert.Contains(t, reason, "node-2="+sealCapabilityReasonNoReply)
}
// The silent node starting to answer during the drain makes the very next
// Delete re-probe and succeed, which proves the result is retryable and the
// probe is never cached across attempts.
func TestSealCapabilityRetriesThenSeals(t *testing.T) {
_, nc, _ := testutil.StartTestJetStream(t)
serveCapability(t, nc, ec2v1.AccountSettingsSealCapabilitySubject("node-1"), capabilityReply("node-1", "node-1", 1))
var node2Up atomic.Bool
sub, err := nc.Subscribe(ec2v1.AccountSettingsSealCapabilitySubject("node-2"), func(msg *nats.Msg) {
if !node2Up.Load() {
return
}
capabilityReply("node-2", "node-2", 1)(msg)
})
require.NoError(t, err)
t.Cleanup(func() { _ = sub.Unsubscribe() })
require.NoError(t, nc.Flush())
sealer := &fakeSealer{}
reaper := &accountSettingsSealReaper{sealer: sealer, capability: testCapabilityClient(nc, []string{"node-1", "node-2"})}
accounts := newFakeAccounts()
engine := quietEngine(accounts, reaper)
engine.Timeouts = Timeouts{StageDrain: time.Second, DrainPoll: 5 * time.Millisecond}
go func() {
time.Sleep(80 * time.Millisecond)
node2Up.Store(true)
}()
result, err := engine.Teardown(testCtx(t), request())
require.NoError(t, err)
assert.True(t, result.AccountDeleted)
assert.Equal(t, []string{testAccountID}, sealer.calls)
}
// Two Delete calls on one reaper probe twice: the probe runs in Delete
// itself, never cached from a previous attempt.
func TestSealCapabilityProbedBeforeEverySeal(t *testing.T) {
_, nc, _ := testutil.StartTestJetStream(t)
var probes int
serveCapability(t, nc, ec2v1.AccountSettingsSealCapabilitySubject("node-1"), func(msg *nats.Msg) {
probes++
capabilityReply("node-1", "node-1", 1)(msg)
})
sealer := &fakeSealer{}
client := testCapabilityClient(nc, []string{"node-1"})
reaper := &accountSettingsSealReaper{sealer: sealer, capability: client}
require.NoError(t, reaper.Delete(testCtx(t), testAccountID, Resource{Kind: reaper.Kind(), ID: testAccountID}, false))
// alreadySealed now makes List empty, but Delete itself does not consult
// it, so a second direct call still probes and seals again.
require.NoError(t, reaper.Delete(testCtx(t), testAccountID, Resource{Kind: reaper.Kind(), ID: testAccountID}, false))
assert.Equal(t, 2, probes)
assert.Len(t, sealer.calls, 2)
}
// Five silent nodes still return within one deadline, not five: every node
// is probed in parallel against the same wait.
func TestSealCapabilityBoundedByOneDeadline(t *testing.T) {
_, nc, _ := testutil.StartTestJetStream(t)
client := testCapabilityClient(nc, []string{"node-1", "node-2", "node-3", "node-4", "node-5"})
started := time.Now()
report := client.CheckSealCapability(testCtx(t), "req")
elapsed := time.Since(started)
assert.False(t, report.Complete)
assert.Less(t, elapsed, client.wait+time.Second, "five silent nodes must not cost five deadlines")
}
// The stuck reason is logged and stored as one line naming each failing node;
// the retry hint and the ServiceUnavailable code travel with it.
func TestSealCapabilityIncompleteErrorIsOneLine(t *testing.T) {
err := errSealCapabilityIncomplete(sealCapabilityReport{Nodes: []nodeSealCapability{{Node: "node-1", Reason: sealCapabilityReasonDuplicate}}})
assert.Equal(t, "EC2 account-settings seal capability incomplete: node-1=duplicate: ServiceUnavailable", err.Error())
d, ok := awserrors.ResolveRetryAfter(err)
assert.True(t, ok)
assert.Equal(t, 5*time.Second, d)
assert.True(t, awserrors.IsErrorCode(err, awserrors.ErrorServiceUnavailable))
}
@@ -0,0 +1,273 @@
package accountteardown
//test:in-package — the reaper, sealer and capability checker are unexported;
// no seal without a complete probe, completion only after a sealed reply and
// idempotent fresh runs are only observable from inside the package.
import (
"context"
"encoding/json"
"errors"
"sync"
"testing"
"time"
"github.com/aws/aws-sdk-go/service/ec2"
ec2v1 "github.com/mulgadc/spinifex/contracts/ec2/v1"
"github.com/mulgadc/spinifex/internal/testkit"
ec2account "github.com/mulgadc/spinifex/spinifex/domains/ec2/account"
awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
natsmsg "github.com/mulgadc/spinifex/spinifex/foundation/messaging/nats"
"github.com/nats-io/nats.go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// fakeSealer is a recording ec2AccountSettingsSealer. Each call beyond
// failFor succeeds with a fresh, strictly increasing revision, unless err is
// set, in which case every call after failFor fails with it.
type fakeSealer struct {
mu sync.Mutex
calls []string
failFor int
err error
revision uint64
}
func (f *fakeSealer) SealAccountSettings(_ context.Context, accountID string) (uint64, error) {
f.mu.Lock()
defer f.mu.Unlock()
f.calls = append(f.calls, accountID)
if len(f.calls) <= f.failFor {
return 0, errors.New("account settings owner temporarily unavailable")
}
if f.err != nil {
return 0, f.err
}
f.revision++
return f.revision, nil
}
// fakeSealCapability is a recording sealCapabilityChecker returning a fixed report.
type fakeSealCapability struct {
calls int
report sealCapabilityReport
}
func (f *fakeSealCapability) CheckSealCapability(context.Context, string) sealCapabilityReport {
f.calls++
return f.report
}
// completeReport builds a sealCapabilityReport where every named node answers
// compatible at version 1.
func completeReport(nodeIDs ...string) sealCapabilityReport {
var nodes []nodeSealCapability
for _, node := range nodeIDs {
nodes = append(nodes, nodeSealCapability{Node: node, Version: 1})
}
return sealCapabilityReport{Complete: true, Nodes: nodes}
}
// alwaysTerminatingChecker stands in for IAM in the end-to-end test: the
// account under test is always TERMINATING.
type alwaysTerminatingChecker struct{}
func (alwaysTerminatingChecker) AccountTerminating(context.Context, string) (bool, error) {
return true, nil
}
// No subscriber on either subject makes the capability probe incomplete, so
// no seal request is ever sent and the account stays TERMINATING with one
// stuck ec2-account-settings item.
func TestSealReaperNoResponderKeepsAccountTerminating(t *testing.T) {
_, nc, _ := testutil.StartTestJetStream(t)
capability := newSealCapabilityClient(nc, []string{"node-1"})
capability.wait = 50 * time.Millisecond
capability.grace = 10 * time.Millisecond
sealer := newEC2AccountSettingsSealClient(nc)
sealer.timeout = 50 * time.Millisecond
reaper := &accountSettingsSealReaper{sealer: sealer, capability: capability}
accounts := newFakeAccounts()
engine := quietEngine(accounts, reaper)
result, err := engine.Teardown(testCtx(t), request())
require.ErrorIs(t, err, ErrResourcesStuck)
assert.False(t, result.AccountDeleted)
assert.Equal(t, AccountStatusTerminating, accounts.account.Status)
var stuck []Stuck
for _, stage := range result.Stages {
stuck = append(stuck, stage.Stuck...)
}
require.Len(t, stuck, 1)
assert.Equal(t, "ec2-account-settings", stuck[0].Resource.Kind)
}
// Whatever the owner refuses with, the account stays TERMINATING: an owner
// refusal, a timeout, and each reply shape the client rejects (wrong account,
// wrong disposition, no revision).
func TestSealReaperOwnerFailureKeepsAccountTerminating(t *testing.T) {
cases := []struct {
name string
err error
}{
{"incorrect-state", errors.New(awserrors.ErrorIncorrectState)},
{"internal-error", errors.New(awserrors.ErrorInternalError)},
{"timeout", context.DeadlineExceeded},
{"wrong-account", errors.New(`seal account settings: reply named account "999999999999", not "000000000042"`)},
{"wrong-disposition", errors.New(`seal account settings: unexpected disposition "pending"`)},
{"zero-revision", errors.New("seal account settings: reply carried no revision")},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
sealer := &fakeSealer{err: tc.err}
reaper := &accountSettingsSealReaper{sealer: sealer, capability: &fakeSealCapability{report: completeReport("node-1")}}
accounts := newFakeAccounts()
engine := quietEngine(accounts, reaper)
result, err := engine.Teardown(testCtx(t), request())
require.ErrorIs(t, err, ErrResourcesStuck)
assert.False(t, result.AccountDeleted)
assert.Equal(t, AccountStatusTerminating, accounts.account.Status)
assert.NotEmpty(t, sealer.calls)
})
}
}
// The reaper retries through the engine's own drain mechanics: it fails
// twice, then succeeds, and only the success lets the account be deleted.
// List is non-empty before the success and empty only after it.
func TestSealReaperCompletesOnlyAfterSealed(t *testing.T) {
sealer := &fakeSealer{failFor: 2}
reaper := &accountSettingsSealReaper{sealer: sealer, capability: &fakeSealCapability{report: completeReport("node-1")}}
accounts := newFakeAccounts()
engine := quietEngine(accounts, reaper)
before, err := reaper.List(testCtx(t), testAccountID)
require.NoError(t, err)
require.Len(t, before, 1)
result, err := engine.Teardown(testCtx(t), request())
require.NoError(t, err)
assert.True(t, result.AccountDeleted)
assert.GreaterOrEqual(t, len(sealer.calls), 3)
after, err := reaper.List(testCtx(t), testAccountID)
require.NoError(t, err)
assert.Empty(t, after)
}
// Each teardown builds a fresh engine and a fresh reaper, so a retried or
// taken-over run seals again: two engines against the same owner both
// succeed, each with a higher revision than the last.
func TestSealReaperFreshRunSealsAgain(t *testing.T) {
sealer := &fakeSealer{}
capability := &fakeSealCapability{report: completeReport("node-1")}
firstEngine := quietEngine(newFakeAccounts(), &accountSettingsSealReaper{sealer: sealer, capability: capability})
firstResult, err := firstEngine.Teardown(testCtx(t), request())
require.NoError(t, err)
assert.True(t, firstResult.AccountDeleted)
secondEngine := quietEngine(newFakeAccounts(), &accountSettingsSealReaper{sealer: sealer, capability: capability})
secondResult, err := secondEngine.Teardown(testCtx(t), request())
require.NoError(t, err)
assert.True(t, secondResult.AccountDeleted)
require.Len(t, sealer.calls, 2)
assert.Equal(t, uint64(2), sealer.revision)
}
// Inventory and the explicit dry run, and a Teardown request marked dry run,
// all list the pending seal and send no seal request: only a real teardown
// may seal.
func TestSealReaperInventoryDoesNotSeal(t *testing.T) {
sealer := &fakeSealer{}
capability := &fakeSealCapability{report: completeReport("node-1")}
reaper := &accountSettingsSealReaper{sealer: sealer, capability: capability}
accounts := newFakeAccounts()
engine := quietEngine(accounts, reaper)
inventory, err := engine.Inventory(testCtx(t), testAccountID)
require.NoError(t, err)
assert.Empty(t, sealer.calls)
assert.Zero(t, capability.calls)
found := false
for _, stage := range inventory.Stages {
for _, resource := range stage.Deleted {
if resource.Kind == "ec2-account-settings" {
found = true
}
}
}
assert.True(t, found, "inventory must still list the pending seal")
dryRun := request()
dryRun.DryRun = true
_, err = engine.Teardown(testCtx(t), dryRun)
require.NoError(t, err)
assert.Empty(t, sealer.calls)
assert.Zero(t, capability.calls)
}
// A real settings service subscribed on the real seal subject, behind a real
// engine: teardown seals the record, replacing the characterisation test that
// showed it used to survive.
func TestTeardownSealsAccountSettings(t *testing.T) {
_, nc, js := testutil.StartTestJetStream(t)
ctx := testCtx(t)
settings, err := ec2account.NewAccountSettingsServiceImplWithNATS(ctx, nil, nc)
require.NoError(t, err)
settings.SetAccountTerminationChecker(alwaysTerminatingChecker{})
_, err = settings.EnableEbsEncryptionByDefault(ctx, &ec2.EnableEbsEncryptionByDefaultInput{}, testAccountID)
require.NoError(t, err)
sealSub, err := nc.QueueSubscribe(ec2v1.SealAccountSettingsSubject, "spinifex-workers", func(msg *nats.Msg) {
natsmsg.ServeNATSRequestCtx(msg, func(ctx context.Context, req *ec2v1.SealAccountSettingsRequest) (*ec2v1.SealAccountSettingsReply, error) {
seal, err := settings.SealAccount(ctx, req.AccountID)
if err != nil {
return nil, err
}
return &ec2v1.SealAccountSettingsReply{
AccountID: req.AccountID, Disposition: ec2v1.AccountSettingsDispositionSealed, Revision: seal.Revision,
}, nil
})
})
require.NoError(t, err)
t.Cleanup(func() { _ = sealSub.Unsubscribe() })
capSub, err := nc.Subscribe(ec2v1.AccountSettingsSealCapabilitySubject("node-1"), func(msg *nats.Msg) {
reply, err := json.Marshal(ec2v1.AccountSettingsSealCapabilityReply{Node: "node-1", CapabilityVersion: 1})
require.NoError(t, err)
out := nats.NewMsg("")
out.Data = reply
out.Header.Set(natsmsg.NodeIDHeader, "node-1")
require.NoError(t, msg.RespondMsg(out))
})
require.NoError(t, err)
t.Cleanup(func() { _ = capSub.Unsubscribe() })
require.NoError(t, nc.Flush())
accounts := newFakeAccounts()
engine := quietEngine(accounts, EC2AccountSettingsReapers(nc, []string{"node-1"})...)
// The real capability probe holds CollectUntilDeadline's full ResponderGrace
// (150 ms) even when the one expected node answers instantly, so the quiet
// engine's default 200 ms StageDrain leaves no room for the sweep after it.
engine.Timeouts = Timeouts{StageDrain: 2 * time.Second, DrainPoll: 10 * time.Millisecond}
result, err := engine.Teardown(ctx, request())
require.NoError(t, err)
assert.True(t, result.AccountDeleted)
bucket, err := js.KeyValue(ctx, ec2account.KVBucketAccountSettings)
require.NoError(t, err)
entry, err := bucket.Get(ctx, testAccountID)
require.NoError(t, err)
var rec ec2account.AccountSettingsRecord
require.NoError(t, json.Unmarshal(entry.Value(), &rec))
assert.Equal(t, ec2account.AccountSettingsStateSealed, rec.State)
}
@@ -12,8 +12,8 @@ import (
"github.com/nats-io/nats.go"
)
// ec2PlatformInventoryTimeout bounds one inventory request. The scan reads
// four stores for one account; a reply later than this is scan-incomplete.
// ec2PlatformInventoryTimeout bounds one inventory request; an earlier caller
// deadline wins. A reply later than either is scan-incomplete.
const ec2PlatformInventoryTimeout = 30 * time.Second
// ec2PlatformInventory is EC2's read-only report of an account's key pairs,
@@ -39,8 +39,17 @@ func newEC2PlatformInventoryClient(nc *nats.Conn) *ec2PlatformInventoryClient {
// responder, which is also how a daemon predating the subject answers, a
// timeout, an error reply or a malformed reply each make every kind incomplete.
func (c *ec2PlatformInventoryClient) PlatformInventory(ctx context.Context, accountID, correlationRef string) ec2v1.PlatformInventoryReply {
// The NATS request takes a timeout, not a context, so the caller's deadline
// shortens it here; a context already done is a timeout without a request.
timeout := c.timeout
if deadline, ok := ctx.Deadline(); ok {
timeout = min(timeout, time.Until(deadline))
}
if ctx.Err() != nil || timeout <= 0 {
return ec2v1.IncompletePlatformInventory(accountID, correlationRef, ec2v1.PlatformReasonTimeout)
}
reply, err := natsmsg.NATSRequest[ec2v1.PlatformInventoryReply](ctx, c.nc, ec2v1.PlatformInventorySubject,
&ec2v1.PlatformInventoryRequest{AccountID: accountID, CorrelationRef: correlationRef}, c.timeout, accountID)
&ec2v1.PlatformInventoryRequest{AccountID: accountID, CorrelationRef: correlationRef}, timeout, accountID)
if err != nil {
return ec2v1.IncompletePlatformInventory(accountID, correlationRef, inventoryFailureReason(err))
}
@@ -6,11 +6,14 @@ package accountteardown
import (
"context"
"encoding/json"
"strings"
"sync/atomic"
"testing"
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/aws/aws-sdk-go/service/s3"
ec2v1 "github.com/mulgadc/spinifex/contracts/ec2/v1"
"github.com/mulgadc/spinifex/internal/testkit"
"github.com/mulgadc/spinifex/spinifex/domains/ec2/accountinventory"
@@ -67,9 +70,13 @@ func TestEC2PlatformInventoryClientReturnsEC2sReport(t *testing.T) {
KeyName: aws.String("deploy"), PublicKeyMaterial: []byte("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl"),
}, testAccountID)
require.NoError(t, err)
o.store.failDelete = func(key string) bool { return key == "keys/"+testAccountID+"/deploy" }
_, err = o.keys.DeleteKeyPair(ctx, &ec2.DeleteKeyPairInput{KeyName: aws.String("deploy")}, testAccountID)
require.NoError(t, err)
// A public key without metadata, as an older release's failed delete left.
_, err = o.store.PutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(platformBucket), Key: aws.String("keys/" + testAccountID + "/deploy"), Body: strings.NewReader("ssh-ed25519 AAAA"),
})
require.NoError(t, err)
reply := newEC2PlatformInventoryClient(o.nc).PlatformInventory(ctx, testAccountID, "req-1")
@@ -153,19 +160,10 @@ func TestEC2PlatformInventoryClientDistrustsAMalformedReply(t *testing.T) {
}, reply.Kinds)
}
// failingInventory fails the test if anything consults it.
type failingInventory struct {
t *testing.T
}
func (f failingInventory) PlatformInventory(context.Context, string, string) ec2v1.PlatformInventoryReply {
f.t.Error("an engine path called the EC2 platform inventory")
return ec2v1.PlatformInventoryReply{}
}
// The cluster wiring binds the NATS adapter, and no engine path consults it:
// precheck, inventory, dry run and a full teardown all leave it uncalled.
func TestNoEnginePathCallsTheEC2PlatformInventory(t *testing.T) {
// The cluster wiring binds the NATS adapter, and only the explicit dry run
// consults it: precheck, the inventory the CLI prints before a real teardown,
// a Teardown request marked dry run and a full teardown all leave it uncalled.
func TestOnlyTheExplicitDryRunCallsTheEC2PlatformInventory(t *testing.T) {
_, nc, _ := testutil.StartTestJetStream(t)
masterKey, err := handlers_iam.GenerateMasterKey()
require.NoError(t, err)
@@ -175,9 +173,10 @@ func TestNoEnginePathCallsTheEC2PlatformInventory(t *testing.T) {
require.NoError(t, err)
assert.IsType(t, &ec2PlatformInventoryClient{}, cluster.ec2Inventory)
inventory := &fakeInventory{reply: ec2v1.IncompletePlatformInventory(testAccountID, "req-1", ec2v1.PlatformReasonNoResponder)}
reaper := &fakeReaper{kind: "key-pair", stage: StagePlatform, resources: []Resource{{Kind: "key-pair", ID: "deploy"}}}
engine := quietEngine(newFakeAccounts(), reaper)
engine.ec2Inventory = failingInventory{t: t}
engine.ec2Inventory = inventory
require.NoError(t, engine.Precheck(testAccountID, testAccountName))
_, err = engine.Inventory(testCtx(t), testAccountID)
@@ -186,7 +185,49 @@ func TestNoEnginePathCallsTheEC2PlatformInventory(t *testing.T) {
dryRun.DryRun = true
_, err = engine.Teardown(testCtx(t), dryRun)
require.NoError(t, err)
assert.Empty(t, inventory.calls())
_, err = engine.DryRun(testCtx(t), testAccountID, "req-1")
require.NoError(t, err)
assert.Equal(t, []string{"req-1"}, inventory.calls())
result, err := engine.Teardown(testCtx(t), request())
require.NoError(t, err)
assert.True(t, result.AccountDeleted)
assert.Equal(t, []string{"req-1"}, inventory.calls(), "a real teardown never consults the inventory")
}
// The shadow matches legacy listings to inventory kinds by the reapers' kind
// names, so the registered platform reapers must use the contract's names.
func TestPlatformReaperKindsAreTheInventoryKinds(t *testing.T) {
var kinds []string
for _, reaper := range EC2Reapers(nil, 1, nil) {
if reaper.Stage() == StagePlatform {
kinds = append(kinds, reaper.Kind())
}
}
for _, kind := range ec2ShadowKinds {
assert.Contains(t, kinds, kind.kind)
}
}
// A caller deadline shorter than the adapter's own timeout ends the request
// at that deadline, and a context already done sends nothing.
func TestEC2PlatformInventoryClientHonoursTheCallersDeadline(t *testing.T) {
_, nc, _ := testutil.StartTestJetStream(t)
var requests atomic.Int32
serveInventory(t, nc, func() []byte { requests.Add(1); return nil })
client := newEC2PlatformInventoryClient(nc)
ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond)
defer cancel()
started := time.Now()
assertAllIncomplete(t, client.PlatformInventory(ctx, testAccountID, ""), ec2v1.PlatformReasonTimeout)
assert.Less(t, time.Since(started), 5*time.Second)
assert.Equal(t, int32(1), requests.Load())
done, cancelDone := context.WithCancel(t.Context())
cancelDone()
assertAllIncomplete(t, client.PlatformInventory(done, testAccountID, ""), ec2v1.PlatformReasonTimeout)
assert.Equal(t, int32(1), requests.Load())
}
@@ -0,0 +1,286 @@
package accountteardown
import (
"context"
"log/slog"
"regexp"
"slices"
"time"
ec2v1 "github.com/mulgadc/spinifex/contracts/ec2/v1"
otelsetup "github.com/mulgadc/spinifex/spinifex/foundation/telemetry"
)
// ec2PlatformShadowBudget caps how long a dry run waits for EC2's platform
// inventory after its own listings, never past the caller's deadline. A later
// answer is scan-incomplete and the dry run returns exactly as it would without.
const ec2PlatformShadowBudget = 10 * time.Second
// ec2PlatformShadowMessage is the one operator audit record a dry run writes.
const ec2PlatformShadowMessage = "Account teardown dry-run EC2 platform shadow comparison"
// Shadow outcomes, per kind and for the whole record. Only agree means the
// legacy listing and the inventory matched and nothing hidden was reported.
const (
shadowAgree = "agree"
shadowDiffer = "differ"
shadowScanIncomplete = "scan-incomplete"
)
// Bases a kind's visible resources are compared on.
const (
shadowBasisResourceID = "resource-id"
shadowBasisCount = "count"
)
// shadowKind is one compared kind. The key-pair and placement-group reapers
// list by name, which the inventory never carries, so those compare by count.
type shadowKind struct {
kind string
field string
byName bool
}
var ec2ShadowKinds = []shadowKind{
{kind: ec2v1.PlatformKindKeyPair, field: "keyPair", byName: true},
{kind: ec2v1.PlatformKindPlacementGroup, field: "placementGroup", byName: true},
{kind: ec2v1.PlatformKindLaunchTemplate, field: "launchTemplate"},
}
// loggableResourceID admits only the AWS ID shape of the three kinds, so a
// reply that put stored content where an ID belongs never reaches the log.
var loggableResourceID = regexp.MustCompile(`^(key|pg|lt)-[0-9a-f]{1,32}$`)
var knownShadowReasons = []string{
ec2v1.PlatformReasonStoreListFailed, ec2v1.PlatformReasonRecordReadFailed,
ec2v1.PlatformReasonOwnerRecordsUnresolved, ec2v1.PlatformReasonNoResponder,
ec2v1.PlatformReasonTimeout, ec2v1.PlatformReasonTransportFailed,
ec2v1.PlatformReasonErrorReply, ec2v1.PlatformReasonInvalidReply,
ec2v1.PlatformReasonKindNotReported,
}
var knownShadowClasses = []string{
ec2v1.PlatformClassPublicKeyWithoutMetadata, ec2v1.PlatformClassPublicKeyUnresolved,
ec2v1.PlatformClassUnreadableRecord, ec2v1.PlatformClassVersionBodyWithoutHeader,
ec2v1.PlatformClassNameIndexWithoutHeader, ec2v1.PlatformClassUnrecognisedTemplateEntry,
}
// shadowUnknownClass stands in for a count class this consumer does not know.
const shadowUnknownClass = "unrecognised-class"
func (e *Engine) ec2ShadowBudget() time.Duration {
if e.ec2PlatformShadowBudget > 0 {
return e.ec2PlatformShadowBudget
}
return ec2PlatformShadowBudget
}
// shadowEC2Platform compares EC2's platform inventory with the legacy listings
// in result and logs the comparison. It reads result and never changes it.
func (e *Engine) shadowEC2Platform(ctx context.Context, result *Result, correlationRef string) {
start := time.Now()
reply := e.awaitEC2PlatformInventory(ctx, result.AccountID, correlationRef)
reports := make(map[string]ec2v1.PlatformKindReport, len(reply.Kinds))
for _, report := range reply.Kinds {
reports[report.Kind] = report
}
outcome := shadowAgree
attrs := []slog.Attr{
slog.String("accountID", result.AccountID),
slog.String("correlationRef", correlationRef),
}
for _, kind := range ec2ShadowKinds {
attr, kindOutcome := compareShadowKind(kind, legacyIDs(result, kind.kind), reports[kind.kind], reply)
attrs = append(attrs, attr)
outcome = worseShadowOutcome(outcome, kindOutcome)
}
tagAttr, tagOutcome := shadowTagIndex(reports[ec2v1.PlatformKindTagIndex], reply)
attrs = append(attrs, tagAttr)
outcome = worseShadowOutcome(outcome, tagOutcome)
attrs = append(attrs, slog.String("outcome", outcome), slog.Int64("duration_ms", otelsetup.Millis(time.Since(start))))
e.logger().LogAttrs(ctx, slog.LevelInfo, ec2PlatformShadowMessage, attrs...)
}
// awaitEC2PlatformInventory bounds the inventory call by the shadow budget even
// if the implementation ignores its context, and distrusts the reply's shape.
func (e *Engine) awaitEC2PlatformInventory(ctx context.Context, accountID, correlationRef string) ec2v1.PlatformInventoryReply {
ctx, cancel := context.WithTimeout(ctx, e.ec2ShadowBudget())
defer cancel()
replies := make(chan ec2v1.PlatformInventoryReply, 1)
go func() { replies <- e.ec2Inventory.PlatformInventory(ctx, accountID, correlationRef) }()
select {
case reply := <-replies:
return normalisePlatformInventory(reply, accountID, correlationRef)
case <-ctx.Done():
return ec2v1.IncompletePlatformInventory(accountID, correlationRef, ec2v1.PlatformReasonTimeout)
}
}
func legacyIDs(result *Result, kind string) []string {
var ids []string
for _, stage := range result.Stages {
for _, resource := range stage.Deleted {
if resource.Kind == kind {
ids = append(ids, resource.ID)
}
}
}
return ids
}
// compareShadowKind compares one kind's legacy listing with the inventory's
// live and dependency-held items, and reports its hidden records as counts.
func compareShadowKind(kind shadowKind, legacy []string, report ec2v1.PlatformKindReport, reply ec2v1.PlatformInventoryReply) (slog.Attr, string) {
var visible, held []string
hidden := map[string]int{}
hiddenTotal := 0
for _, item := range reply.Items {
if item.Kind != kind.kind {
continue
}
switch item.Disposition {
case ec2v1.PlatformDispositionLive:
visible = append(visible, item.ResourceID)
case ec2v1.PlatformDispositionDependencyHeld:
visible = append(visible, item.ResourceID)
held = append(held, item.ResourceID)
default:
hidden[shadowUnknownClass]++
hiddenTotal++
}
}
for _, count := range reply.Counts {
if count.Kind != kind.kind || count.Count <= 0 {
continue
}
class := count.Class
if !slices.Contains(knownShadowClasses, class) {
class = shadowUnknownClass
}
hidden[class] += count.Count
hiddenTotal += count.Count
}
basis := shadowBasisResourceID
var onlyLegacy, onlyInventory []string
agree := len(legacy) == len(visible)
if kind.byName {
basis = shadowBasisCount
} else {
onlyLegacy, onlyInventory = setDifference(legacy, visible), setDifference(visible, legacy)
agree = len(onlyLegacy) == 0 && len(onlyInventory) == 0
}
comparison := shadowDiffer
if agree {
comparison = shadowAgree
}
outcome := comparison
if hiddenTotal > 0 {
outcome = shadowDiffer
}
if report.Status != ec2v1.PlatformStatusObservedClean && report.Status != ec2v1.PlatformStatusRecordsFound {
comparison, outcome = shadowScanIncomplete, shadowScanIncomplete
}
loggableOnlyLegacy, droppedLegacy := loggableIDs(onlyLegacy)
loggableOnlyInventory, droppedInventory := loggableIDs(onlyInventory)
loggableHeld, droppedHeld := loggableIDs(held)
return slog.Group(kind.field,
slog.String("status", report.Status),
slog.Any("incompleteReasons", fixedReasons(report.IncompleteReasons)),
slog.Time("scanStartedAt", report.ScanStartedAt),
slog.Time("scanEndedAt", report.ScanEndedAt),
slog.String("comparison", comparison),
slog.String("matchBasis", basis),
slog.Int("legacyCount", len(legacy)),
slog.Int("inventoryVisibleCount", len(visible)),
slog.Any("onlyInLegacy", loggableOnlyLegacy),
slog.Any("onlyInInventory", loggableOnlyInventory),
slog.Any("dependencyHeld", loggableHeld),
slog.Int("hiddenRecordCount", hiddenTotal),
slog.Any("hiddenRecords", hidden),
slog.Int("unloggableIDCount", droppedLegacy+droppedInventory+droppedHeld),
), outcome
}
// shadowTagIndex reports tag-index entries whose resource has no owner record,
// which no legacy reaper lists.
func shadowTagIndex(report ec2v1.PlatformKindReport, reply ec2v1.PlatformInventoryReply) (slog.Attr, string) {
var residue []string
for _, item := range reply.Items {
if item.Kind == ec2v1.PlatformKindTagIndex {
residue = append(residue, item.ResourceID)
}
}
outcome := shadowAgree
if len(residue) > 0 {
outcome = shadowDiffer
}
if report.Status != ec2v1.PlatformStatusObservedClean && report.Status != ec2v1.PlatformStatusRecordsFound {
outcome = shadowScanIncomplete
}
loggable, dropped := loggableIDs(residue)
return slog.Group("tagIndex",
slog.String("status", report.Status),
slog.Any("incompleteReasons", fixedReasons(report.IncompleteReasons)),
slog.Time("scanStartedAt", report.ScanStartedAt),
slog.Time("scanEndedAt", report.ScanEndedAt),
slog.Int("residueCount", len(residue)),
slog.Any("residue", loggable),
slog.Int("unloggableIDCount", dropped),
), outcome
}
func worseShadowOutcome(a, b string) string {
rank := map[string]int{shadowAgree: 0, shadowDiffer: 1, shadowScanIncomplete: 2}
if rank[b] > rank[a] {
return b
}
return a
}
// setDifference returns the sorted members of a absent from b.
func setDifference(a, b []string) []string {
out := []string{}
for _, id := range a {
if !slices.Contains(b, id) && !slices.Contains(out, id) {
out = append(out, id)
}
}
slices.Sort(out)
return out
}
func loggableIDs(ids []string) ([]string, int) {
out := []string{}
dropped := 0
for _, id := range ids {
if loggableResourceID.MatchString(id) {
out = append(out, id)
continue
}
dropped++
}
slices.Sort(out)
return out, dropped
}
// fixedReasons keeps only the contract's reason categories; anything else a
// reply carried is reported as an invalid reply rather than logged.
func fixedReasons(reasons []string) []string {
out := []string{}
for _, reason := range reasons {
if !slices.Contains(knownShadowReasons, reason) {
reason = ec2v1.PlatformReasonInvalidReply
}
if !slices.Contains(out, reason) {
out = append(out, reason)
}
}
return out
}
@@ -0,0 +1,513 @@
package accountteardown
//test:in-package — the shadow comparison, the inventory binding it reads and
// its budget are unexported.
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"log/slog"
"strings"
"sync"
"testing"
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/aws/aws-sdk-go/service/s3"
ec2v1 "github.com/mulgadc/spinifex/contracts/ec2/v1"
"github.com/mulgadc/spinifex/internal/testkit"
"github.com/mulgadc/spinifex/spinifex/domains/ec2/accountinventory"
natsmsg "github.com/mulgadc/spinifex/spinifex/foundation/messaging/nats"
"github.com/nats-io/nats.go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
var shadowScanTime = time.Date(2026, 10, 16, 9, 0, 0, 0, time.UTC)
// fakeInventory answers with reply, or never when blocked, and records the
// correlation reference of every call.
type fakeInventory struct {
reply ec2v1.PlatformInventoryReply
blocked chan struct{}
mu sync.Mutex
refs []string
}
func (f *fakeInventory) PlatformInventory(_ context.Context, _, correlationRef string) ec2v1.PlatformInventoryReply {
f.mu.Lock()
f.refs = append(f.refs, correlationRef)
f.mu.Unlock()
if f.blocked != nil {
<-f.blocked
}
return f.reply
}
func (f *fakeInventory) calls() []string {
f.mu.Lock()
defer f.mu.Unlock()
return append([]string(nil), f.refs...)
}
// countingReaper counts listings, so a dry run can be shown to list once.
type countingReaper struct {
Reaper
lists int
}
func (r *countingReaper) List(ctx context.Context, accountID string) ([]Resource, error) {
r.lists++
return r.Reaper.List(ctx, accountID)
}
func platformKinds(status string) []ec2v1.PlatformKindReport {
var kinds []ec2v1.PlatformKindReport
for _, kind := range ec2v1.PlatformKinds() {
kinds = append(kinds, ec2v1.PlatformKindReport{
Kind: kind, Status: status, ScanStartedAt: shadowScanTime, ScanEndedAt: shadowScanTime.Add(time.Second),
})
}
return kinds
}
func setKind(kinds []ec2v1.PlatformKindReport, kind, status string, reasons ...string) {
for i := range kinds {
if kinds[i].Kind == kind {
kinds[i].Status, kinds[i].IncompleteReasons = status, reasons
}
}
}
func item(kind, id, disposition string) ec2v1.PlatformItem {
return ec2v1.PlatformItem{ResourceID: id, Kind: kind, Disposition: disposition}
}
// legacyPlatform is a key pair, a placement group and a launch template as the
// legacy reapers list them: the first two by name, the third by ID.
func legacyPlatform() []Reaper {
return []Reaper{
&fakeReaper{kind: "instance", stage: StageCompute, resources: []Resource{{Kind: "instance", ID: "i-0123456789abcdef0"}}},
&fakeReaper{kind: "key-pair", stage: StagePlatform, resources: []Resource{{Kind: "key-pair", ID: "deploy"}}},
&fakeReaper{kind: "placement-group", stage: StagePlatform, resources: []Resource{{Kind: "placement-group", ID: "spread"}}},
&fakeReaper{kind: "launch-template", stage: StagePlatform, resources: []Resource{{Kind: "launch-template", ID: "lt-0aaaaaaaaaaaaaaaa"}}},
}
}
// matchingInventory reports exactly what legacyPlatform lists.
func matchingInventory() ec2v1.PlatformInventoryReply {
return ec2v1.PlatformInventoryReply{
AccountID: testAccountID,
Kinds: platformKinds(ec2v1.PlatformStatusRecordsFound),
Items: []ec2v1.PlatformItem{
item(ec2v1.PlatformKindKeyPair, "key-0aaaaaaaaaaaaaaaa", ec2v1.PlatformDispositionLive),
item(ec2v1.PlatformKindPlacementGroup, "pg-0aaaaaaaaaaaaaaaa", ec2v1.PlatformDispositionLive),
item(ec2v1.PlatformKindLaunchTemplate, "lt-0aaaaaaaaaaaaaaaa", ec2v1.PlatformDispositionLive),
},
}
}
// shadowEngine is a quiet engine whose log is captured as JSON in logs.
func shadowEngine(inventory ec2PlatformInventory, reapers ...Reaper) (*Engine, *bytes.Buffer) {
engine := quietEngine(newFakeAccounts(), reapers...)
logs := &bytes.Buffer{}
engine.Logger = slog.New(slog.NewJSONHandler(logs, nil))
engine.ec2Inventory = inventory
return engine, logs
}
// shadowRecords returns every shadow comparison record in logs.
func shadowRecords(t *testing.T, logs *bytes.Buffer) []map[string]any {
t.Helper()
var records []map[string]any
for line := range strings.SplitSeq(strings.TrimSpace(logs.String()), "\n") {
if line == "" {
continue
}
var record map[string]any
require.NoError(t, json.Unmarshal([]byte(line), &record))
if record["msg"] == ec2PlatformShadowMessage {
records = append(records, record)
}
}
return records
}
func shadowRecord(t *testing.T, logs *bytes.Buffer) map[string]any {
t.Helper()
records := shadowRecords(t, logs)
require.Len(t, records, 1, "one shadow record per dry run")
return records[0]
}
func group(t *testing.T, record map[string]any, name string) map[string]any {
t.Helper()
g, ok := record[name].(map[string]any)
require.True(t, ok, "record has a %s group", name)
return g
}
func strs(values ...string) []any {
out := []any{}
for _, v := range values {
out = append(out, v)
}
return out
}
func resultJSON(t *testing.T, result *Result) string {
t.Helper()
data, err := json.Marshal(result)
require.NoError(t, err)
return string(data)
}
// noShadowResult is what the same dry run returns with no inventory bound.
func noShadowResult(t *testing.T, reapers ...Reaper) string {
t.Helper()
result, err := quietEngine(newFakeAccounts(), reapers...).Inventory(testCtx(t), testAccountID)
require.NoError(t, err)
return resultJSON(t, result)
}
// Visible resources that match on both sides agree. The legacy key-pair and
// placement-group reapers list names, so those kinds compare by count.
func TestDryRunShadowAgreesWhenBothSidesListTheSameResources(t *testing.T) {
reapers := legacyPlatform()
counted := make([]Reaper, 0, len(reapers))
for _, reaper := range reapers {
counted = append(counted, &countingReaper{Reaper: reaper})
}
inventory := &fakeInventory{reply: matchingInventory()}
engine, logs := shadowEngine(inventory, counted...)
result, err := engine.DryRun(testCtx(t), testAccountID, "req-agree")
require.NoError(t, err)
assert.Equal(t, noShadowResult(t, legacyPlatform()...), resultJSON(t, result))
assert.Equal(t, []string{"req-agree"}, inventory.calls())
for _, reaper := range counted {
assert.Equal(t, 1, reaper.(*countingReaper).lists, "%s listed once: the shadow reuses the dry run's listing", reaper.Kind())
}
record := shadowRecord(t, logs)
assert.Equal(t, "INFO", record["level"])
assert.Equal(t, testAccountID, record["accountID"])
assert.Equal(t, "req-agree", record["correlationRef"])
assert.Equal(t, shadowAgree, record["outcome"])
assert.Contains(t, record, "duration_ms")
keyPair := group(t, record, "keyPair")
assert.Equal(t, ec2v1.PlatformStatusRecordsFound, keyPair["status"])
assert.Equal(t, shadowAgree, keyPair["comparison"])
assert.Equal(t, shadowBasisCount, keyPair["matchBasis"])
assert.InDelta(t, 1, keyPair["legacyCount"], 0)
assert.InDelta(t, 1, keyPair["inventoryVisibleCount"], 0)
assert.Equal(t, shadowScanTime.Format(time.RFC3339), keyPair["scanStartedAt"])
launchTemplate := group(t, record, "launchTemplate")
assert.Equal(t, shadowAgree, launchTemplate["comparison"])
assert.Equal(t, shadowBasisResourceID, launchTemplate["matchBasis"])
assert.Equal(t, strs(), launchTemplate["onlyInLegacy"])
assert.Equal(t, strs(), launchTemplate["onlyInInventory"])
tagIndex := group(t, record, "tagIndex")
assert.Equal(t, ec2v1.PlatformStatusRecordsFound, tagIndex["status"])
assert.InDelta(t, 0, tagIndex["residueCount"], 0)
}
// Visible resources one side has and the other does not are named by ID where
// the legacy reaper lists IDs, and counted where it lists names.
func TestDryRunShadowReportsVisibleResourcesOnlyOneSideSees(t *testing.T) {
reapers := legacyPlatform()
reapers[1].(*fakeReaper).resources = append(reapers[1].(*fakeReaper).resources, Resource{Kind: "key-pair", ID: "ci"})
reapers[3].(*fakeReaper).resources = []Resource{
{Kind: "launch-template", ID: "lt-0aaaaaaaaaaaaaaaa"}, {Kind: "launch-template", ID: "lt-0bbbbbbbbbbbbbbbb"},
}
reply := matchingInventory()
reply.Items = append(reply.Items, item(ec2v1.PlatformKindLaunchTemplate, "lt-0cccccccccccccccc", ec2v1.PlatformDispositionLive))
engine, logs := shadowEngine(&fakeInventory{reply: reply}, reapers...)
_, err := engine.DryRun(testCtx(t), testAccountID, "req-differ")
require.NoError(t, err)
record := shadowRecord(t, logs)
assert.Equal(t, shadowDiffer, record["outcome"])
keyPair := group(t, record, "keyPair")
assert.Equal(t, shadowDiffer, keyPair["comparison"])
assert.InDelta(t, 2, keyPair["legacyCount"], 0)
assert.InDelta(t, 1, keyPair["inventoryVisibleCount"], 0)
assert.Equal(t, strs(), keyPair["onlyInLegacy"], "legacy key-pair names are never logged")
launchTemplate := group(t, record, "launchTemplate")
assert.Equal(t, shadowDiffer, launchTemplate["comparison"])
assert.Equal(t, strs("lt-0bbbbbbbbbbbbbbbb"), launchTemplate["onlyInLegacy"])
assert.Equal(t, strs("lt-0cccccccccccccccc"), launchTemplate["onlyInInventory"])
assert.Equal(t, shadowAgree, group(t, record, "placementGroup")["comparison"])
}
// Residue the legacy reapers cannot see makes the record differ even when the
// visible resources agree: as counts per class, and tag-index entries by ID.
func TestDryRunShadowReportsHiddenResidueTheLegacyReapersMiss(t *testing.T) {
reply := matchingInventory()
reply.Counts = []ec2v1.PlatformRecordCount{
{Kind: ec2v1.PlatformKindKeyPair, Disposition: ec2v1.PlatformDispositionResidue, Class: ec2v1.PlatformClassPublicKeyWithoutMetadata, Count: 1},
{Kind: ec2v1.PlatformKindLaunchTemplate, Disposition: ec2v1.PlatformDispositionResidue, Class: ec2v1.PlatformClassVersionBodyWithoutHeader, Count: 2},
{Kind: ec2v1.PlatformKindLaunchTemplate, Disposition: ec2v1.PlatformDispositionUnreadable, Class: ec2v1.PlatformClassUnreadableRecord, Count: 1},
}
reply.Items = append(reply.Items, item(ec2v1.PlatformKindTagIndex, "pg-0dddddddddddddddd", ec2v1.PlatformDispositionResidue))
engine, logs := shadowEngine(&fakeInventory{reply: reply}, legacyPlatform()...)
_, err := engine.DryRun(testCtx(t), testAccountID, "req-residue")
require.NoError(t, err)
record := shadowRecord(t, logs)
assert.Equal(t, shadowDiffer, record["outcome"])
keyPair := group(t, record, "keyPair")
assert.Equal(t, shadowAgree, keyPair["comparison"], "the visible key pairs still agree")
assert.InDelta(t, 1, keyPair["hiddenRecordCount"], 0)
assert.Equal(t, map[string]any{ec2v1.PlatformClassPublicKeyWithoutMetadata: float64(1)}, keyPair["hiddenRecords"])
launchTemplate := group(t, record, "launchTemplate")
assert.InDelta(t, 3, launchTemplate["hiddenRecordCount"], 0)
assert.Equal(t, map[string]any{
ec2v1.PlatformClassVersionBodyWithoutHeader: float64(2),
ec2v1.PlatformClassUnreadableRecord: float64(1),
}, launchTemplate["hiddenRecords"])
tagIndex := group(t, record, "tagIndex")
assert.InDelta(t, 1, tagIndex["residueCount"], 0)
assert.Equal(t, strs("pg-0dddddddddddddddd"), tagIndex["residue"])
}
// A placement group still holding instances is a plain listed resource to the
// legacy dry run, which learns otherwise only when its delete fails.
func TestDryRunShadowReportsADependencyHeldPlacementGroup(t *testing.T) {
reply := matchingInventory()
reply.Items[1].Disposition = ec2v1.PlatformDispositionDependencyHeld
engine, logs := shadowEngine(&fakeInventory{reply: reply}, legacyPlatform()...)
_, err := engine.DryRun(testCtx(t), testAccountID, "req-held")
require.NoError(t, err)
placementGroup := group(t, shadowRecord(t, logs), "placementGroup")
assert.Equal(t, shadowAgree, placementGroup["comparison"])
assert.InDelta(t, 1, placementGroup["inventoryVisibleCount"], 0)
assert.Equal(t, strs("pg-0aaaaaaaaaaaaaaaa"), placementGroup["dependencyHeld"])
}
// An owner store that could not be read is scan-incomplete, never agreement,
// even where the IDs it did report match.
func TestDryRunShadowNeverReportsAnIncompleteScanAsAgreement(t *testing.T) {
reply := matchingInventory()
setKind(reply.Kinds, ec2v1.PlatformKindLaunchTemplate, ec2v1.PlatformStatusScanIncomplete, ec2v1.PlatformReasonRecordReadFailed)
engine, logs := shadowEngine(&fakeInventory{reply: reply}, legacyPlatform()...)
result, err := engine.DryRun(testCtx(t), testAccountID, "req-store")
require.NoError(t, err)
assert.Equal(t, noShadowResult(t, legacyPlatform()...), resultJSON(t, result))
record := shadowRecord(t, logs)
assert.Equal(t, shadowScanIncomplete, record["outcome"])
launchTemplate := group(t, record, "launchTemplate")
assert.Equal(t, shadowScanIncomplete, launchTemplate["comparison"])
assert.Equal(t, strs(ec2v1.PlatformReasonRecordReadFailed), launchTemplate["incompleteReasons"])
assert.Equal(t, shadowAgree, group(t, record, "keyPair")["comparison"])
}
// However the inventory fails, the dry run returns what it returns without it
// and the record says scan-incomplete with the fixed reason.
func TestDryRunShadowFailureLeavesTheDryRunUnchanged(t *testing.T) {
want := noShadowResult(t, legacyPlatform()...)
for _, reason := range []string{
ec2v1.PlatformReasonNoResponder, ec2v1.PlatformReasonTimeout,
ec2v1.PlatformReasonTransportFailed, ec2v1.PlatformReasonErrorReply,
} {
t.Run(reason, func(t *testing.T) {
engine, logs := shadowEngine(&fakeInventory{reply: ec2v1.IncompletePlatformInventory(testAccountID, "req-fail", reason)}, legacyPlatform()...)
result, err := engine.DryRun(testCtx(t), testAccountID, "req-fail")
require.NoError(t, err)
assert.Equal(t, want, resultJSON(t, result))
record := shadowRecord(t, logs)
assert.Equal(t, shadowScanIncomplete, record["outcome"])
for _, name := range []string{"keyPair", "placementGroup", "launchTemplate", "tagIndex"} {
g := group(t, record, name)
assert.Equal(t, ec2v1.PlatformStatusScanIncomplete, g["status"], name)
assert.Equal(t, strs(reason), g["incompleteReasons"], name)
}
})
}
t.Run("reply for another account", func(t *testing.T) {
reply := matchingInventory()
reply.AccountID = "000000000099"
engine, logs := shadowEngine(&fakeInventory{reply: reply}, legacyPlatform()...)
result, err := engine.DryRun(testCtx(t), testAccountID, "req-fail")
require.NoError(t, err)
assert.Equal(t, want, resultJSON(t, result))
record := shadowRecord(t, logs)
assert.Equal(t, shadowScanIncomplete, record["outcome"])
assert.Equal(t, strs(ec2v1.PlatformReasonInvalidReply), group(t, record, "keyPair")["incompleteReasons"])
})
t.Run("no daemon serves the subject", func(t *testing.T) {
_, nc, _ := testutil.StartTestJetStream(t)
engine, logs := shadowEngine(newEC2PlatformInventoryClient(nc), legacyPlatform()...)
result, err := engine.DryRun(testCtx(t), testAccountID, "req-fail")
require.NoError(t, err)
assert.Equal(t, want, resultJSON(t, result))
assert.Equal(t, strs(ec2v1.PlatformReasonNoResponder), group(t, shadowRecord(t, logs), "tagIndex")["incompleteReasons"])
})
}
// An inventory that never answers, even one ignoring its context, holds the
// dry run for the shadow budget at most and is logged as a timeout.
func TestDryRunShadowIsBoundedByItsBudget(t *testing.T) {
inventory := &fakeInventory{reply: matchingInventory(), blocked: make(chan struct{})}
t.Cleanup(func() { close(inventory.blocked) })
engine, logs := shadowEngine(inventory, legacyPlatform()...)
engine.ec2PlatformShadowBudget = 20 * time.Millisecond
started := time.Now()
result, err := engine.DryRun(testCtx(t), testAccountID, "req-slow")
require.NoError(t, err)
assert.Less(t, time.Since(started), 5*time.Second)
assert.Equal(t, noShadowResult(t, legacyPlatform()...), resultJSON(t, result))
assert.Equal(t, strs(ec2v1.PlatformReasonTimeout), group(t, shadowRecord(t, logs), "launchTemplate")["incompleteReasons"])
}
// The caller's deadline wins over the budget, so a dry run never outlasts the
// bound its caller already set.
func TestDryRunShadowStopsAtTheCallersDeadline(t *testing.T) {
_, nc, _ := testutil.StartTestJetStream(t)
serveInventory(t, nc, func() []byte { return nil })
engine, logs := shadowEngine(newEC2PlatformInventoryClient(nc), legacyPlatform()...)
require.Greater(t, engine.ec2ShadowBudget(), 5*time.Second)
ctx, cancel := context.WithTimeout(t.Context(), 200*time.Millisecond)
defer cancel()
started := time.Now()
result, err := engine.DryRun(ctx, testAccountID, "req-deadline")
require.NoError(t, err)
assert.Less(t, time.Since(started), 5*time.Second)
assert.Equal(t, noShadowResult(t, legacyPlatform()...), resultJSON(t, result))
assert.Equal(t, strs(ec2v1.PlatformReasonTimeout), group(t, shadowRecord(t, logs), "keyPair")["incompleteReasons"])
}
// A dry run whose own listing fails returns that error as before, and nothing
// is compared against a listing that does not exist.
func TestDryRunWithAFailedListingDoesNotCallTheInventory(t *testing.T) {
reapers := legacyPlatform()
reapers[2].(*fakeReaper).listErr = errors.New("describe failed")
inventory := &fakeInventory{reply: matchingInventory()}
engine, logs := shadowEngine(inventory, reapers...)
_, err := engine.DryRun(testCtx(t), testAccountID, "req-list")
require.ErrorContains(t, err, "inventory placement-group")
assert.Empty(t, inventory.calls())
assert.Empty(t, shadowRecords(t, logs))
}
// A reply that puts anything but the contract's fixed vocabulary where a
// reason, class or ID belongs never reaches the log; the IDs are counted.
func TestDryRunShadowLogsOnlyTheFixedVocabulary(t *testing.T) {
reply := matchingInventory()
setKind(reply.Kinds, ec2v1.PlatformKindKeyPair, ec2v1.PlatformStatusScanIncomplete, "read keys/000000000042/secret-key-name: denied")
reply.Counts = []ec2v1.PlatformRecordCount{{Kind: ec2v1.PlatformKindLaunchTemplate, Disposition: ec2v1.PlatformDispositionResidue, Class: "secret-template-name", Count: 1}}
reply.Items = append(reply.Items,
item(ec2v1.PlatformKindLaunchTemplate, "lt-secret-template-name", ec2v1.PlatformDispositionLive),
item(ec2v1.PlatformKindTagIndex, "pg-team=secret-tag-value", ec2v1.PlatformDispositionResidue),
)
reapers := legacyPlatform()
reapers[1].(*fakeReaper).resources = []Resource{{Kind: "key-pair", ID: "secret-key-name"}}
engine, logs := shadowEngine(&fakeInventory{reply: reply}, reapers...)
_, err := engine.DryRun(testCtx(t), testAccountID, "req-disclosure")
require.NoError(t, err)
assert.NotContains(t, logs.String(), "secret")
record := shadowRecord(t, logs)
assert.Equal(t, strs(ec2v1.PlatformReasonInvalidReply), group(t, record, "keyPair")["incompleteReasons"])
launchTemplate := group(t, record, "launchTemplate")
assert.Equal(t, map[string]any{shadowUnknownClass: float64(1)}, launchTemplate["hiddenRecords"])
assert.InDelta(t, 1, launchTemplate["unloggableIDCount"], 0)
assert.InDelta(t, 1, group(t, record, "tagIndex")["unloggableIDCount"], 0)
}
// End to end over the real owner stores and the NATS adapter: the record names
// the residue each reaper misses and nothing a customer named or stored.
func TestDryRunShadowOverTheOwnerStores(t *testing.T) {
o := newPlatformOwners(t)
ctx := testCtx(t)
inv := accountinventory.New(o.keys, o.groups, o.templates, o.tags)
sub, err := o.nc.QueueSubscribe(ec2v1.PlatformInventorySubject, "spinifex-workers", func(msg *nats.Msg) {
natsmsg.ServeNATSRequestCtx(msg, inv.PlatformInventory)
})
require.NoError(t, err)
t.Cleanup(func() { _ = sub.Unsubscribe() })
material := []byte("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl")
for _, name := range []string{"secret-key-name", "secret-orphan-key"} {
_, err = o.keys.ImportKeyPair(ctx, &ec2.ImportKeyPairInput{KeyName: aws.String(name), PublicKeyMaterial: material}, testAccountID)
require.NoError(t, err)
}
_, err = o.keys.DeleteKeyPair(ctx, &ec2.DeleteKeyPairInput{KeyName: aws.String("secret-orphan-key")}, testAccountID)
require.NoError(t, err)
// Residue an older release's failed deletes left behind.
_, err = o.store.PutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(platformBucket), Key: aws.String("keys/" + testAccountID + "/secret-orphan-key"), Body: bytes.NewReader(material),
})
require.NoError(t, err)
_, err = o.groups.CreatePlacementGroup(ctx, &ec2.CreatePlacementGroupInput{
GroupName: aws.String("secret-group-name"), Strategy: aws.String(ec2.PlacementStrategySpread),
}, testAccountID)
require.NoError(t, err)
doomed, err := o.groups.CreatePlacementGroup(ctx, &ec2.CreatePlacementGroupInput{
GroupName: aws.String("secret-doomed-group"), Strategy: aws.String(ec2.PlacementStrategySpread),
}, testAccountID)
require.NoError(t, err)
lt, err := o.templates.CreateLaunchTemplate(ctx, &ec2.CreateLaunchTemplateInput{
LaunchTemplateName: aws.String("secret-template-name"),
LaunchTemplateData: &ec2.RequestLaunchTemplateData{UserData: aws.String(base64.StdEncoding.EncodeToString([]byte("secret-user-data")))},
}, testAccountID)
require.NoError(t, err)
doomedID, ltID := aws.StringValue(doomed.PlacementGroup.GroupId), aws.StringValue(lt.LaunchTemplate.LaunchTemplateId)
_, err = o.tags.CreateTags(ctx, &ec2.CreateTagsInput{
Resources: []*string{aws.String(doomedID), aws.String(ltID)},
Tags: []*ec2.Tag{{Key: aws.String("secret-tag-key"), Value: aws.String("secret-tag-value")}},
}, testAccountID)
require.NoError(t, err)
_, err = o.groups.DeletePlacementGroup(ctx, &ec2.DeletePlacementGroupInput{GroupName: aws.String("secret-doomed-group")}, testAccountID)
require.NoError(t, err)
_, err = o.tags.CreateTags(ctx, &ec2.CreateTagsInput{
Resources: []*string{aws.String(doomedID)},
Tags: []*ec2.Tag{{Key: aws.String("secret-tag-key"), Value: aws.String("secret-tag-value")}},
}, testAccountID)
require.NoError(t, err)
engine, logs := shadowEngine(newEC2PlatformInventoryClient(o.nc), platformReapers(o.nc)...)
result, err := engine.DryRun(ctx, testAccountID, "req-owners")
require.NoError(t, err)
plain, err := quietEngine(newFakeAccounts(), platformReapers(o.nc)...).Inventory(ctx, testAccountID)
require.NoError(t, err)
assert.Equal(t, resultJSON(t, plain), resultJSON(t, result))
assert.NotContains(t, logs.String(), "secret")
assert.NotContains(t, logs.String(), base64.StdEncoding.EncodeToString([]byte("secret-user-data")))
record := shadowRecord(t, logs)
assert.Equal(t, shadowDiffer, record["outcome"])
keyPair := group(t, record, "keyPair")
assert.Equal(t, shadowAgree, keyPair["comparison"])
assert.Equal(t, map[string]any{ec2v1.PlatformClassPublicKeyWithoutMetadata: float64(1)}, keyPair["hiddenRecords"])
assert.Equal(t, shadowAgree, group(t, record, "placementGroup")["comparison"])
launchTemplate := group(t, record, "launchTemplate")
assert.Equal(t, shadowAgree, launchTemplate["comparison"])
assert.Equal(t, shadowBasisResourceID, launchTemplate["matchBasis"])
assert.Equal(t, strs(doomedID), group(t, record, "tagIndex")["residue"])
}
+19 -2
View File
@@ -29,9 +29,12 @@ type Engine struct {
// and this is the only view into it while it runs.
OnStage func(ctx context.Context, stage StageResult)
// ec2Inventory is EC2's read-only platform inventory. Bound by the cluster
// wiring but consulted by no teardown or inventory path yet.
// ec2Inventory is EC2's read-only platform inventory. Only DryRun consults
// it, and only to log a comparison; no teardown or Inventory call does.
ec2Inventory ec2PlatformInventory
// ec2PlatformShadowBudget overrides ec2PlatformShadowBudget when positive.
ec2PlatformShadowBudget time.Duration
}
// NewEngine builds an engine with the default timeouts.
@@ -87,6 +90,20 @@ func (e *Engine) Inventory(ctx context.Context, accountID string) (*Result, erro
return result, nil
}
// DryRun is the explicit operator dry run. It returns exactly what Inventory
// returns; beside it, EC2's platform inventory is compared with the legacy
// listings and only logged, under correlationRef, which authorises nothing.
func (e *Engine) DryRun(ctx context.Context, accountID, correlationRef string) (*Result, error) {
result, err := e.Inventory(ctx, accountID)
if err != nil {
return nil, err
}
if e.ec2Inventory != nil {
e.shadowEC2Platform(ctx, result, correlationRef)
}
return result, nil
}
// Teardown empties the account and then removes it.
//
// The account is marked TERMINATING before anything is deleted. Without that
@@ -34,27 +34,11 @@ import (
const platformBucket = "test-bucket"
// deleteFailingStore fails object deletes its predicate selects.
type deleteFailingStore struct {
*objectstore.MemoryObjectStore
failDelete func(key string) bool
}
var _ objectstore.ObjectStore = (*deleteFailingStore)(nil)
func (s *deleteFailingStore) DeleteObject(ctx context.Context, in *s3.DeleteObjectInput) (*s3.DeleteObjectOutput, error) {
if s.failDelete != nil && s.failDelete(aws.StringValue(in.Key)) {
return nil, errors.New("injected store failure")
}
return s.MemoryObjectStore.DeleteObject(ctx, in)
}
// platformOwners runs the real key, placement-group, launch-template and tag
// services behind the subjects the platform reapers call.
type platformOwners struct {
nc *nats.Conn
store *deleteFailingStore
store *objectstore.MemoryObjectStore
keys *ec2key.KeyServiceImpl
groups *ec2placementgroup.PlacementGroupServiceImpl
templates *ec2launchtemplate.LaunchTemplateServiceImpl
@@ -81,7 +65,7 @@ func newPlatformOwners(t *testing.T) *platformOwners {
t.Helper()
_, nc, js := testutil.StartTestJetStream(t)
ctx := t.Context()
o := &platformOwners{nc: nc, store: &deleteFailingStore{MemoryObjectStore: objectstore.NewMemoryObjectStore()}}
o := &platformOwners{nc: nc, store: objectstore.NewMemoryObjectStore()}
var err error
o.tagsKV, err = ec2tags.GetOrCreateTagsBucket(ctx, js)
@@ -92,8 +76,10 @@ func newPlatformOwners(t *testing.T) *platformOwners {
o.keys.SetCentralTagStore(o.tags)
o.groups, err = ec2placementgroup.NewPlacementGroupServiceImplWithNATS(ctx, nil, nc)
require.NoError(t, err)
o.groups.SetCentralTagStore(o.tags)
o.templates, err = ec2launchtemplate.NewLaunchTemplateServiceImplWithNATS(ctx, nil, nc)
require.NoError(t, err)
o.templates.SetCentralTagStore(o.tags)
o.pgKV, err = js.KeyValue(ctx, ec2placementgroup.KVBucketPlacementGroups)
require.NoError(t, err)
o.ltKV, err = js.KeyValue(ctx, ec2launchtemplate.KVBucketLaunchTemplates)
@@ -172,9 +158,18 @@ func TestCurrentBehaviour_AccountTermination_PlatformReapersReportDrainedOverHid
}, testAccountID)
require.NoError(t, err)
// Residue each owner's Describe skips.
publicKey := "keys/" + testAccountID + "/deploy"
o.store.failDelete = func(key string) bool { return key == publicKey }
// Residue each owner's Describe skips, as left by older releases or a
// crash between writes.
publicKey := "keys/" + testAccountID + "/old-deploy"
_, err = o.store.PutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(platformBucket), Key: aws.String(publicKey), Body: strings.NewReader("ssh-ed25519 AAAA"),
})
require.NoError(t, err)
staleTagged := "pg-0stale000000000"
_, err = o.tags.CreateTags(ctx, &ec2.CreateTagsInput{
Resources: []*string{aws.String(staleTagged)}, Tags: []*ec2.Tag{{Key: aws.String("team"), Value: aws.String("blue")}},
}, testAccountID)
require.NoError(t, err)
_, err = o.store.PutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(platformBucket), Key: aws.String("keys/" + testAccountID + "/key-0badbadbadbadbad0.json"),
Body: strings.NewReader("{not json"),
@@ -210,10 +205,6 @@ func TestCurrentBehaviour_AccountTermination_PlatformReapersReportDrainedOverHid
assert.ElementsMatch(t, []string{publicKey, "keys/" + testAccountID + "/key-0badbadbadbadbad0.json"}, o.objectKeys(t))
assert.Equal(t, []string{testAccountID + ".broken"}, accountKVKeys(t, o.pgKV))
assert.Equal(t, []string{orphanBody}, accountKVKeys(t, o.ltKV))
assert.Equal(t, sortedStrings(testAccountID+"."+pgID, testAccountID+"."+ltID), accountKVKeys(t, o.tagsKV))
}
func sortedStrings(s ...string) []string {
slices.Sort(s)
return s
// The owner deletes clear their own tags; only the stale entry remains.
assert.Equal(t, []string{testAccountID + "." + staleTagged}, accountKVKeys(t, o.tagsKV))
}
@@ -0,0 +1,61 @@
package accountteardown
//test:in-package — drives the real final-stage reapers against the real quota service to pin that teardown leaves the override bucket alone.
import (
"testing"
"github.com/mulgadc/spinifex/internal/testkit"
"github.com/mulgadc/spinifex/spinifex/domains/admission/quota"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
"github.com/nats-io/nats.go/jetstream"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// No reaper in this package references the quota override bucket or calls an
// override delete. Running the real engine and the real StageAccount reapers
// against an account with a raised vCPU override, with usage wired, shows the
// consequence: the override outlives the account record it belongs to, while
// the usage counter the reapers do own is removed.
func TestCurrentBehaviour_AccountTermination_QuotaOverridesSurviveTeardown(t *testing.T) {
_, _, js := testutil.StartTestJetStream(t)
ctx := testCtx(t)
usage, err := kvutil.GetOrCreateBucket(ctx, js, quota.KVBucketAccountUsage, 1)
require.NoError(t, err)
_, err = usage.Put(ctx, testAccountID, []byte(`{"vcpus":4}`))
require.NoError(t, err)
overridesKV, err := kvutil.GetOrCreateBucket(ctx, js, quota.KVBucketAccountQuota, 1)
require.NoError(t, err)
svc := quota.New(quota.Limits{Enabled: true, VCPUs: 16}, usage)
svc.SetOverrides(overridesKV)
unlimited := quota.Unlimited
require.NoError(t, svc.PutAccountQuota(ctx, testAccountID, quota.Overrides{VCPUs: &unlimited}, "operator"))
accounts := newFakeAccounts()
iam := &accountIAM{account: &handlers_iam.Account{AccountID: testAccountID, AccountName: testAccountName}}
engine := quietEngine(accounts, AccountReapers(iam, nil, usage)...)
result, err := engine.Teardown(ctx, request())
require.NoError(t, err)
assert.True(t, result.AccountDeleted)
assert.True(t, accounts.deleted)
assert.Zero(t, result.StuckCount())
// The reaper this package does own removed the usage counter, which shows
// which store teardown actually clears.
_, err = usage.Get(ctx, testAccountID)
assert.ErrorIs(t, err, jetstream.ErrKeyNotFound, "teardown's quota-usage reaper must remove the vCPU counter")
_, err = overridesKV.Get(ctx, testAccountID)
require.NoError(t, err, "the override key must still be present after the account is torn down")
over, effective, err := svc.GetAccountQuota(ctx, testAccountID)
require.NoError(t, err)
require.NotNil(t, over.VCPUs)
assert.Equal(t, quota.Unlimited, *over.VCPUs)
assert.Equal(t, quota.Unlimited, effective.VCPUs)
}
@@ -0,0 +1,89 @@
package accountteardown
//test:in-package — pins ADR-0003:S4 and ADR-0001 Context item 2 for the EC2
// settings seal: the legacy engine must reach the owner only through the
// versioned contract and NATS, never by reading the settings bucket itself.
import (
"go/ast"
"go/parser"
"go/token"
"io/fs"
"path/filepath"
"runtime"
"strconv"
"strings"
"testing"
)
// forbiddenSettingsImports are imports that would let this package reach the
// EC2 settings owner or the configured node set by a path other than the
// versioned contract and NewClusterEngine's parameters.
var forbiddenSettingsImports = []string{
"github.com/mulgadc/spinifex/spinifex/domains/ec2/account",
"github.com/mulgadc/spinifex/spinifex/bootstrap/config",
}
// forbiddenSettingsIdentifiers would let this package name the settings
// bucket or its key directly instead of reaching it only through
// contracts/ec2/v1 and NATS.
var forbiddenSettingsIdentifiers = []string{"spinifex-ec2-account-settings", "KVBucketAccountSettings"}
// Teardown reaches EC2 settings only over contracts/ec2/v1: no import of the
// settings owner or bootstrap/config (nodes arrive via NewClusterEngine), and
// no literal naming the settings bucket or its constant.
func TestAccountTeardownHasNoSettingsKVAccess(t *testing.T) {
_, file, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("cannot locate test source")
}
root := filepath.Dir(file)
fset := token.NewFileSet()
scanned := 0
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
return err
}
f, perr := parser.ParseFile(fset, path, nil, parser.ParseComments)
if perr != nil {
return perr
}
scanned++
for _, imp := range f.Imports {
p, _ := strconv.Unquote(imp.Path.Value)
for _, bad := range forbiddenSettingsImports {
if p == bad {
t.Errorf("%s: imports %s; the legacy engine must reach the EC2 settings owner only through contracts/ec2/v1 and the configured node set NewClusterEngine already receives",
fset.Position(imp.Pos()), p)
}
}
}
ast.Inspect(f, func(n ast.Node) bool {
lit, ok := n.(*ast.BasicLit)
if !ok || lit.Kind != token.STRING {
return true
}
value, uerr := strconv.Unquote(lit.Value)
if uerr != nil {
return true
}
for _, bad := range forbiddenSettingsIdentifiers {
if value == bad {
t.Errorf("%s: names %q; the settings bucket and its key are the owner's own, never this package's",
fset.Position(lit.Pos()), value)
}
}
return true
})
return nil
})
if err != nil {
t.Fatal(err)
}
if scanned == 0 {
t.Fatalf("scanned no Go files under %s; the invariant would pass vacuously", root)
}
}
+53 -7
View File
@@ -18,13 +18,29 @@ import (
// ClusterConfig is the root of spinifex.toml as returned by LoadConfig: the
// cluster-wide settings plus a [nodes.<name>] Config for every node.
type ClusterConfig struct {
Epoch uint64 `mapstructure:"epoch"` // bump when leader commits changes
Node string `mapstructure:"node"` // my node name
Version string `mapstructure:"version"` // spinifex version
Network NetworkConfig `mapstructure:"network"` // cluster-wide external network settings
Bootstrap BootstrapConfig `mapstructure:"bootstrap"` // default VPC IDs for OVN reconciliation
AWS AWSConfig `mapstructure:"aws"` // cluster-wide AWS-parity settings (region, endpoint suffix)
Nodes map[string]Config `mapstructure:"nodes"` // full config for every node
Epoch uint64 `mapstructure:"epoch"` // bump when leader commits changes
Node string `mapstructure:"node"` // my node name
Version string `mapstructure:"version"` // spinifex version
Network NetworkConfig `mapstructure:"network"` // cluster-wide external network settings
Bootstrap BootstrapConfig `mapstructure:"bootstrap"` // default VPC IDs for OVN reconciliation
AWS AWSConfig `mapstructure:"aws"` // cluster-wide AWS-parity settings (region, endpoint suffix)
Northstar NorthstarClusterConfig `mapstructure:"northstar"` // cluster-wide authoritative DNS settings
Nodes map[string]Config `mapstructure:"nodes"` // full config for every node
}
// MaxNameservers caps the nsN names a cluster publishes for its zones. Every
// northstar node still serves every zone; the cap bounds only the delegation a
// registrar has to carry.
const MaxNameservers = 4
// NorthstarClusterConfig is the cluster-wide [northstar] block.
type NorthstarClusterConfig struct {
// NameserverCount is how many nsN names to publish, 1 to MaxNameservers.
// Zero means one per northstar node, up to MaxNameservers.
NameserverCount int `mapstructure:"nameserver_count"`
// NameserverNodes pins ns1, ns2, ... to these nodes, in order. Unpinned
// slots are filled from the remaining northstar nodes in name order.
NameserverNodes []string `mapstructure:"nameserver_nodes"`
}
// Defaults for cluster-wide AWS-parity settings.
@@ -654,6 +670,32 @@ func LoadConfig(configPath string) (*ClusterConfig, error) {
return &config, nil
}
// validateNorthstarCluster checks the [northstar] nameserver settings against
// the cap and the configured nodes.
func validateNorthstarCluster(cc *ClusterConfig) error {
ns := cc.Northstar
if ns.NameserverCount < 0 || ns.NameserverCount > MaxNameservers {
return fmt.Errorf("config: [northstar] nameserver_count=%d out of range; use 1-%d, or remove the key for one per northstar node up to %d", ns.NameserverCount, MaxNameservers, MaxNameservers)
}
if len(ns.NameserverNodes) > MaxNameservers {
return fmt.Errorf("config: [northstar] nameserver_nodes lists %d nodes; at most %d are published", len(ns.NameserverNodes), MaxNameservers)
}
if ns.NameserverCount > 0 && len(ns.NameserverNodes) > ns.NameserverCount {
return fmt.Errorf("config: [northstar] nameserver_nodes lists %d nodes but nameserver_count is %d", len(ns.NameserverNodes), ns.NameserverCount)
}
seen := make(map[string]bool, len(ns.NameserverNodes))
for _, name := range ns.NameserverNodes {
if _, ok := cc.Nodes[name]; !ok {
return fmt.Errorf("config: [northstar] nameserver_nodes names %q, which is not a configured node", name)
}
if seen[name] {
return fmt.Errorf("config: [northstar] nameserver_nodes names %q twice", name)
}
seen[name] = true
}
return nil
}
// validateClusterConfig rejects legacy DHCP config keys and validates external pool ranges.
func validateClusterConfig(cc *ClusterConfig) error {
if viper.IsSet("network.external_dhcp") {
@@ -684,6 +726,10 @@ func validateClusterConfig(cc *ClusterConfig) error {
}
}
if err := validateNorthstarCluster(cc); err != nil {
return err
}
if len(cc.Network.NATExemptCIDRs) > 0 && cc.Network.ExternalMode != "nat" {
return fmt.Errorf("config: [network] nat_exempt_cidrs requires external_mode = \"nat\"")
}
+40
View File
@@ -1426,3 +1426,43 @@ exoscale_instance_id = "0f1e2d3c-0000-4000-8000-000000000001"
})
}
}
func TestLoadConfig_NorthstarNameservers(t *testing.T) {
load := func(t *testing.T, block string) (*ClusterConfig, error) {
t.Helper()
resetViper(t)
path := filepath.Join(t.TempDir(), "spinifex.toml")
body := "node = \"n1\"\n\n" + block + "\n[nodes.n1]\nregion = \"us-east-1\"\n\n[nodes.n2]\nregion = \"us-east-1\"\n"
require.NoError(t, os.WriteFile(path, []byte(body), 0600))
return LoadConfig(path)
}
t.Run("absent block means automatic", func(t *testing.T) {
cfg, err := load(t, "")
require.NoError(t, err)
assert.Zero(t, cfg.Northstar.NameserverCount)
assert.Empty(t, cfg.Northstar.NameserverNodes)
})
t.Run("count and pin load", func(t *testing.T) {
cfg, err := load(t, "[northstar]\nnameserver_count = 2\nnameserver_nodes = [\"n2\", \"n1\"]\n")
require.NoError(t, err)
assert.Equal(t, 2, cfg.Northstar.NameserverCount)
assert.Equal(t, []string{"n2", "n1"}, cfg.Northstar.NameserverNodes)
})
for name, tc := range map[string]struct{ block, want string }{
"count above the cap": {"[northstar]\nnameserver_count = 5\n", "nameserver_count=5"},
"negative count": {"[northstar]\nnameserver_count = -1\n", "nameserver_count=-1"},
"pin longer than count": {"[northstar]\nnameserver_count = 1\nnameserver_nodes = [\"n1\", \"n2\"]\n", "nameserver_count is 1"},
"pin names an unknown node": {"[northstar]\nnameserver_nodes = [\"n9\"]\n", `"n9"`},
"pin repeats a node": {"[northstar]\nnameserver_nodes = [\"n1\", \"n1\"]\n", "twice"},
"pin above the cap": {"[northstar]\nnameserver_nodes = [\"n1\", \"n2\", \"n3\", \"n4\", \"n5\"]\n", "at most 4"},
} {
t.Run(name, func(t *testing.T) {
_, err := load(t, tc.block)
require.Error(t, err)
assert.Contains(t, err.Error(), tc.want)
})
}
}
+66 -3
View File
@@ -71,6 +71,7 @@ import (
"github.com/mulgadc/spinifex/spinifex/domains/ochre"
ochrevector "github.com/mulgadc/spinifex/spinifex/domains/ochre/vector"
rdsengine "github.com/mulgadc/spinifex/spinifex/domains/rds/engine"
route53domain "github.com/mulgadc/spinifex/spinifex/domains/route53"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/mulgadc/spinifex/spinifex/foundation/state/clustersize"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
@@ -175,6 +176,8 @@ type Daemon struct {
bedrockReaper *ochre.Reaper
acmService *acmdomain.ACMServiceImpl
acmRenewalWorker *acmdomain.Worker
route53Service *route53domain.Service
route53Verifier *route53domain.Verifier
ochreVectorService ochrevector.VectorService
ochreAppliance *ochrevector.Appliance
ochreBackupService *ochrevector.BackupService
@@ -1026,6 +1029,7 @@ func (d *Daemon) subscribeAll() error {
{"ec2.GetSerialConsoleAccessStatus", handleNATSRequest(d.node, d.accountService.GetSerialConsoleAccessStatus), "spinifex-workers"},
{"ec2.EnableSerialConsoleAccess", handleNATSRequest(d.node, d.accountService.EnableSerialConsoleAccess), "spinifex-workers"},
{"ec2.DisableSerialConsoleAccess", handleNATSRequest(d.node, d.accountService.DisableSerialConsoleAccess), "spinifex-workers"},
{ec2v1.SealAccountSettingsSubject, handleNATSRequest(d.node, d.sealAccountSettings), "spinifex-workers"},
{clusterv1.NodeHealthSubject(d.node), d.handleHealthCheck, ""},
{clusterv1.NodesDiscoverSubject, d.handleNodeDiscover, ""},
{clusterv1.NodeStatusSubject, d.handleNodeStatus, ""},
@@ -1231,6 +1235,31 @@ func (d *Daemon) subscribeAll() error {
)
}
// Route 53 is global: any daemon serves any account's zones from shared KV.
if d.route53Service != nil {
subs = append(subs,
natsSub{"route53.CreateHostedZone", handleNATSRequest(d.node, d.route53Service.CreateHostedZone), "spinifex-workers"},
natsSub{"route53.GetHostedZone", handleNATSRequest(d.node, d.route53Service.GetHostedZone), "spinifex-workers"},
natsSub{"route53.GetHostedZoneCount", handleNATSRequest(d.node, d.route53Service.GetHostedZoneCount), "spinifex-workers"},
natsSub{"route53.ListHostedZones", handleNATSRequest(d.node, d.route53Service.ListHostedZones), "spinifex-workers"},
natsSub{"route53.ListHostedZonesByName", handleNATSRequest(d.node, d.route53Service.ListHostedZonesByName), "spinifex-workers"},
natsSub{"route53.UpdateHostedZoneComment", handleNATSRequest(d.node, d.route53Service.UpdateHostedZoneComment), "spinifex-workers"},
natsSub{"route53.DeleteHostedZone", handleNATSRequest(d.node, d.route53Service.DeleteHostedZone), "spinifex-workers"},
natsSub{"route53.ChangeResourceRecordSets", handleNATSRequest(d.node, d.route53Service.ChangeResourceRecordSets), "spinifex-workers"},
natsSub{"route53.ListResourceRecordSets", handleNATSRequest(d.node, d.route53Service.ListResourceRecordSets), "spinifex-workers"},
natsSub{"route53.GetChange", handleNATSRequest(d.node, d.route53Service.GetChange), "spinifex-workers"},
natsSub{"route53.ChangeTagsForResource", handleNATSRequest(d.node, d.route53Service.ChangeTagsForResource), "spinifex-workers"},
natsSub{"route53.ListTagsForResource", handleNATSRequest(d.node, d.route53Service.ListTagsForResource), "spinifex-workers"},
natsSub{"route53.ListTagsForResources", handleNATSRequest(d.node, d.route53Service.ListTagsForResources), "spinifex-workers"},
natsSub{"route53.GetHostedZoneActivation", handleNATSRequest(d.node, d.route53Service.GetHostedZoneActivation), "spinifex-workers"},
natsSub{"route53.VerifyHostedZoneDelegation", handleNATSRequest(d.node, d.route53Service.VerifyHostedZoneDelegation), "spinifex-workers"},
natsSub{"route53.admin.FindZones", handleNATSRequest(d.node, d.route53Service.AdminFindZones), "spinifex-workers"},
natsSub{"route53.admin.Verify", handleNATSRequest(d.node, d.route53Service.AdminVerify), "spinifex-workers"},
natsSub{"route53.admin.Override", handleNATSRequest(d.node, d.route53Service.AdminOverride), "spinifex-workers"},
natsSub{"route53.admin.Delegate", handleNATSRequest(d.node, d.route53Delegate), "spinifex-workers"},
)
}
// Ochre vector store tenant surface is deliberately NOT registered here:
// its subjects only exist once the platform appliance has finished
// launching (startOchreVector, run in the background after this method
@@ -1288,6 +1317,15 @@ func (d *Daemon) subscribeAll() error {
natsSub{viperblocklegacyv1.VolumeFencedSubject(d.node), d.handleVolumeFenced, ""},
)
// Per-node, no queue group: a second daemon claiming this node ID also
// answers, so the teardown probe sees a duplicate and refuses to seal. An
// invalid node ID serves nothing, so this daemon reads as incompatible.
if ec2v1.ValidSealCapabilityNode(d.node) {
subs = append(subs, natsSub{ec2v1.AccountSettingsSealCapabilitySubject(d.node), handleNATSRequest(d.node, d.accountSettingsSealCapability), ""})
} else {
slog.Error("Not serving account-settings seal capability: node ID is not a single NATS subject token", "node", d.node)
}
return d.registerNatsSubs(subs)
}
@@ -1825,9 +1863,6 @@ func (d *Daemon) startCluster() error {
return fmt.Errorf("failed to get tags KV bucket: %w", err)
}
d.tagsService = ec2tags.NewTagsServiceImpl(d.config, tagsKV)
// Key pairs keep their creation tags in their own metadata; project them so
// describe-tags sees them, and clear them when the key pair is deleted.
d.keyService.SetCentralTagStore(d.tagsService)
d.eigwService, err = initServiceWithRetry("EIGW service", func() (*ec2eigw.EgressOnlyIGWServiceImpl, error) {
return ec2eigw.NewEgressOnlyIGWServiceImplWithNATS(d.ctx, d.config, d.natsConn)
@@ -1856,6 +1891,7 @@ func (d *Daemon) startCluster() error {
if err != nil {
return fmt.Errorf("failed to initialize launch template service: %w", err)
}
d.wirePlatformTagIndex()
d.spotInstanceService, err = initServiceWithRetry("spot instance service", func() (*ec2spotinstance.SpotInstanceServiceImpl, error) {
return ec2spotinstance.NewSpotInstanceServiceImplWithNATS(d.ctx, d.config, d.natsConn)
@@ -1994,6 +2030,12 @@ func (d *Daemon) startCluster() error {
if err != nil {
return fmt.Errorf("failed to initialize account settings service: %w", err)
}
accountStatusJS, err := jetstream.New(d.natsConn)
if err != nil {
return fmt.Errorf("jetstream handle for account status reader: %w", err)
}
var _ ec2account.AccountTerminationChecker = (*handlers_iam.AccountStatusReader)(nil)
d.accountService.SetAccountTerminationChecker(handlers_iam.NewAccountStatusReader(accountStatusJS))
// ELBv2 resolves listener certificate private keys through its own ACM
// Store over the same JetStream bucket the ACM service writes, so it needs
@@ -2159,6 +2201,14 @@ func (d *Daemon) startCluster() error {
return dns.HostsZone(d.config, domain)
}
route53JS, err := jetstream.New(d.natsConn)
if err != nil {
return fmt.Errorf("failed to initialize Route 53 service: %w", err)
}
d.route53Service = d.newRoute53Service(route53JS)
d.route53Verifier = d.newRoute53Verifier()
d.route53Service.Verifier = d.route53Verifier
// The tenant private CA is optional, unlike the master key above: a
// deployment issuing only public (PROVIDER_API/MANUAL_TXT) certificates has
// no reason to have one, so its absence must not block daemon startup.
@@ -2301,6 +2351,10 @@ func (d *Daemon) startCluster() error {
go d.dnsReconciler.Run(d.ctx)
slog.Info("Started DNS reconcile backstop", "interval_ms", otelsetup.Millis(dns.DefaultReconcileInterval))
}
if d.route53Service != nil {
d.startRoute53Realizer()
go d.route53Verifier.Run(d.ctx)
}
// Initialize per-instance-type NATS subscriptions for capacity-aware routing.
d.resourceMgr.initSubscriptions(d.natsConn, d.handleEC2RunInstances, d.handleSystemLaunchInstance, d.node)
@@ -3628,3 +3682,12 @@ func probeGPU() gpuProbeResult {
r.Capable = len(r.Devices) > 0 && ((r.IOMMUActive && r.VFIOPresent) || hasMIG)
return r
}
// wirePlatformTagIndex gives the key-pair, placement-group and launch-template
// owners the tag index their deletes clear; key pairs also project their
// metadata-held creation tags into it so describe-tags sees them.
func (d *Daemon) wirePlatformTagIndex() {
d.keyService.SetCentralTagStore(d.tagsService)
d.placementGroupService.SetCentralTagStore(d.tagsService)
d.launchTemplateService.SetCentralTagStore(d.tagsService)
}
@@ -0,0 +1,23 @@
package daemon
import (
"context"
ec2v1 "github.com/mulgadc/spinifex/contracts/ec2/v1"
)
// sealAccountSettings serves the irreversible EC2 settings seal. The account
// comes from the request body, not X-Account-ID: the sealed account is part
// of the versioned contract, so the header is ignored.
func (d *Daemon) sealAccountSettings(ctx context.Context, req *ec2v1.SealAccountSettingsRequest, _ string) (*ec2v1.SealAccountSettingsReply, error) {
seal, err := d.accountService.SealAccount(ctx, req.AccountID)
if err != nil {
return nil, err
}
return &ec2v1.SealAccountSettingsReply{
AccountID: req.AccountID,
CorrelationRef: req.CorrelationRef,
Disposition: ec2v1.AccountSettingsDispositionSealed,
Revision: seal.Revision,
}, nil
}
@@ -0,0 +1,23 @@
package daemon
import (
"context"
ec2v1 "github.com/mulgadc/spinifex/contracts/ec2/v1"
awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
)
// accountSettingsSealCapability serves this daemon's read-only seal capability.
// It reads no KV and takes no account; a request naming another node is a
// caller mistake, since the subject is already addressed to this node.
func (d *Daemon) accountSettingsSealCapability(_ context.Context, req *ec2v1.AccountSettingsSealCapabilityRequest, _ string) (*ec2v1.AccountSettingsSealCapabilityReply, error) {
if req.Node != d.node {
return nil, awserrors.Errorf(awserrors.ErrorInvalidParameterValue,
"seal capability request for node %q answered by node %q", req.Node, d.node)
}
return &ec2v1.AccountSettingsSealCapabilityReply{
Node: d.node,
CapabilityVersion: ec2v1.AccountSettingsSealCapabilityVersion,
CorrelationRef: req.CorrelationRef,
}, nil
}
@@ -0,0 +1,115 @@
package daemon
import (
"encoding/json"
"testing"
"time"
ec2v1 "github.com/mulgadc/spinifex/contracts/ec2/v1"
"github.com/mulgadc/spinifex/internal/testkit"
awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/nats-io/nats.go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// newCapabilityTestDaemon builds a daemon serving its real subscription table
// with node as its configured node ID.
func newCapabilityTestDaemon(t *testing.T, node string) (*nats.Conn, *nats.Conn) {
t.Helper()
ns, nc, _ := testutil.StartTestJetStream(t)
d := newSubscribeTestDaemon(t, nc, "", "")
d.node = node
require.NoError(t, d.subscribeAll())
require.NoError(t, nc.Flush())
caller, err := nats.Connect(ns.ClientURL())
require.NoError(t, err)
t.Cleanup(caller.Close)
return nc, caller
}
func capabilityRequest(t *testing.T, nc *nats.Conn, node string) *nats.Msg {
t.Helper()
data, err := json.Marshal(ec2v1.AccountSettingsSealCapabilityRequest{Node: node, CorrelationRef: "req-1"})
require.NoError(t, err)
reply, err := nc.Request(ec2v1.AccountSettingsSealCapabilitySubject(node), data, 5*time.Second)
require.NoError(t, err)
return reply
}
// The responder answers its own node with version 1, the configured node and
// no KV access (no settings service is wired into the test daemon at all).
func TestAccountSettingsSealCapabilityAnswersOwnNode(t *testing.T) {
_, caller := newCapabilityTestDaemon(t, "node-1")
raw := capabilityRequest(t, caller, "node-1")
assert.Equal(t, "node-1", raw.Header.Get("X-Node-ID"))
var reply ec2v1.AccountSettingsSealCapabilityReply
require.NoError(t, json.Unmarshal(raw.Data, &reply))
assert.Equal(t, "node-1", reply.Node)
assert.Equal(t, ec2v1.AccountSettingsSealCapabilityVersion, reply.CapabilityVersion)
assert.Equal(t, "req-1", reply.CorrelationRef)
}
// A request naming a node other than this daemon's own is refused, not
// answered as if it were this node.
func TestAccountSettingsSealCapabilityRefusesOtherNode(t *testing.T) {
_, caller := newCapabilityTestDaemon(t, "node-1")
data, err := json.Marshal(ec2v1.AccountSettingsSealCapabilityRequest{Node: "node-2"})
require.NoError(t, err)
raw, err := caller.Request(ec2v1.AccountSettingsSealCapabilitySubject("node-1"), data, 5*time.Second)
require.NoError(t, err)
assert.Equal(t, awserrors.ErrorInvalidParameterValue, replyErrCode(t, raw.Data))
}
// The subject is per node with no queue group, so two daemons configured
// with the same node ID both answer a request addressed to that node.
func TestAccountSettingsSealCapabilityHasNoQueueGroup(t *testing.T) {
_, nc, _ := testutil.StartTestJetStream(t)
first := newSubscribeTestDaemon(t, nc, "", "")
first.node = "node-1"
require.NoError(t, first.subscribeAll())
second := newSubscribeTestDaemon(t, nc, "", "")
second.node = "node-1"
require.NoError(t, second.subscribeAll())
require.NoError(t, nc.Flush())
sub, err := nc.SubscribeSync(nats.NewInbox())
require.NoError(t, err)
t.Cleanup(func() { _ = sub.Unsubscribe() })
data, err := json.Marshal(ec2v1.AccountSettingsSealCapabilityRequest{Node: "node-1"})
require.NoError(t, err)
msg := nats.NewMsg(ec2v1.AccountSettingsSealCapabilitySubject("node-1"))
msg.Data = data
msg.Reply = sub.Subject
require.NoError(t, nc.PublishMsg(msg))
require.NoError(t, nc.Flush())
seen := 0
for {
_, err := sub.NextMsg(300 * time.Millisecond)
if err != nil {
break
}
seen++
}
assert.Equal(t, 2, seen)
}
// A wildcard node ID must not become a wildcard subscription: built unchecked,
// "node-*" would answer probes addressed to node-1 on node-1's behalf.
func TestAccountSettingsSealCapabilityNotServedForInvalidNodeID(t *testing.T) {
_, caller := newCapabilityTestDaemon(t, "node-*")
data, err := json.Marshal(ec2v1.AccountSettingsSealCapabilityRequest{Node: "node-1"})
require.NoError(t, err)
_, err = caller.Request(ec2v1.AccountSettingsSealCapabilitySubject("node-1"), data, time.Second)
require.ErrorIs(t, err, nats.ErrNoResponders)
}
@@ -0,0 +1,101 @@
package daemon
import (
"context"
"encoding/json"
"testing"
"time"
ec2v1 "github.com/mulgadc/spinifex/contracts/ec2/v1"
"github.com/mulgadc/spinifex/internal/testkit"
ec2account "github.com/mulgadc/spinifex/spinifex/domains/ec2/account"
awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/nats-io/nats.go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// sealTestChecker answers AccountTerminating from a fixed set, standing in
// for IAM in these daemon-level tests.
type sealTestChecker struct {
terminating map[string]bool
}
func (c *sealTestChecker) AccountTerminating(_ context.Context, accountID string) (bool, error) {
return c.terminating[accountID], nil
}
// newSealTestDaemon builds a daemon serving its real subscription table over
// a real account settings service, wired with checker as its termination
// precondition.
func newSealTestDaemon(t *testing.T, checker ec2account.AccountTerminationChecker) (*Daemon, *nats.Conn) {
t.Helper()
ns, nc, _ := testutil.StartTestJetStream(t)
acctSvc, err := ec2account.NewAccountSettingsServiceImplWithNATS(t.Context(), nil, nc)
require.NoError(t, err)
acctSvc.SetAccountTerminationChecker(checker)
d := newSubscribeTestDaemon(t, nc, "", "")
d.accountService = acctSvc
require.NoError(t, d.subscribeAll())
require.NoError(t, nc.Flush())
caller, err := nats.Connect(ns.ClientURL())
require.NoError(t, err)
t.Cleanup(caller.Close)
return d, caller
}
const (
sealTestTerminating = "111122223333"
sealTestActive = "444455556666"
)
func sealRequest(t *testing.T, nc *nats.Conn, body any, header string) []byte {
t.Helper()
data, err := json.Marshal(body)
require.NoError(t, err)
reply, err := natsRequestAs(nc, ec2v1.SealAccountSettingsSubject, data, header, 5*time.Second)
require.NoError(t, err)
return reply.Data
}
// The header is ignored: the body account is what gets sealed, even when
// X-Account-ID names a different one.
func TestSealAccountSettingsSubjectSealsTheBodyAccount(t *testing.T) {
_, nc := newSealTestDaemon(t, &sealTestChecker{terminating: map[string]bool{sealTestTerminating: true}})
raw := sealRequest(t, nc, ec2v1.SealAccountSettingsRequest{AccountID: sealTestTerminating, CorrelationRef: "req-1"}, sealTestActive)
require.Empty(t, replyErrCode(t, raw), string(raw))
var reply ec2v1.SealAccountSettingsReply
require.NoError(t, json.Unmarshal(raw, &reply))
assert.Equal(t, sealTestTerminating, reply.AccountID)
assert.Equal(t, "req-1", reply.CorrelationRef)
assert.Equal(t, ec2v1.AccountSettingsDispositionSealed, reply.Disposition)
assert.NotZero(t, reply.Revision)
}
// Only an account IAM holds as TERMINATING may be sealed; ACTIVE is refused
// before any write.
func TestSealAccountSettingsSubjectRefusesAnActiveAccount(t *testing.T) {
_, nc := newSealTestDaemon(t, &sealTestChecker{terminating: map[string]bool{sealTestTerminating: true}})
raw := sealRequest(t, nc, ec2v1.SealAccountSettingsRequest{AccountID: sealTestActive}, "")
assert.Equal(t, awserrors.ErrorIncorrectState, replyErrCode(t, raw))
}
// A request without a valid account is refused, not sealed.
func TestSealAccountSettingsSubjectRefusesAMissingAccount(t *testing.T) {
_, nc := newSealTestDaemon(t, &sealTestChecker{terminating: map[string]bool{sealTestTerminating: true}})
assert.Equal(t, awserrors.ErrorMissingParameter, replyErrCode(t, sealRequest(t, nc, ec2v1.SealAccountSettingsRequest{}, "")))
}
// A daemon that does not serve the subject leaves the request with no responder.
func TestSealAccountSettingsSubjectHasNoResponderWithoutTheDaemon(t *testing.T) {
_, nc, _ := testutil.StartTestJetStream(t)
_, err := natsRequestAs(nc, ec2v1.SealAccountSettingsSubject, []byte(`{"account_id":"111122223333"}`), "", time.Second)
require.ErrorIs(t, err, nats.ErrNoResponders)
}
@@ -164,6 +164,9 @@ func newInventoryCluster(t *testing.T) *inventoryCluster {
d.placementGroupService = groups
d.launchTemplateService = templates
require.NoError(t, d.subscribeAll())
// The caller is a separate connection, so the server must hold the SUBs
// before it publishes or the request can see no responders.
require.NoError(t, nc.Flush())
c.caller, err = nats.Connect(ns.ClientURL())
require.NoError(t, err)
@@ -7,7 +7,6 @@ import (
"bytes"
"encoding/json"
"slices"
"strings"
"testing"
"time"
@@ -56,12 +55,15 @@ func newDisclosureCluster(t *testing.T) *disclosureCluster {
require.NoError(t, err)
d.keyService = ec2key.NewKeyServiceImplWithStore(store, disclosureBucket)
d.tagsService = ec2tags.NewTagsServiceImplWithStore(cfg, store, tagsKV)
d.keyService.SetCentralTagStore(d.tagsService)
d.placementGroupService, err = ec2placementgroup.NewPlacementGroupServiceImplWithNATS(t.Context(), nil, nc)
require.NoError(t, err)
d.launchTemplateService, err = ec2launchtemplate.NewLaunchTemplateServiceImplWithNATS(t.Context(), nil, nc)
require.NoError(t, err)
d.wirePlatformTagIndex()
require.NoError(t, d.subscribeAll())
// The caller is a separate connection, so the server must hold the SUBs
// before it publishes or the request can see no responders.
require.NoError(t, nc.Flush())
caller, err := nats.Connect(ns.ClientURL())
require.NoError(t, err)
@@ -225,12 +227,13 @@ func TestCurrentBehaviour_AccountTermination_DirectNATSCallerCannotSeeHiddenResi
assert.Equal(t, before.taggedResources, after.taggedResources)
}
// Pins current behaviour that differs from AWS: deleting a placement group or
// launch template leaves its tag-index entry, which DescribeTags keeps
// returning with its values. A key-pair delete clears its entry.
func TestCurrentBehaviour_AccountTermination_PlatformDeletesLeaveTagIndexEntries(t *testing.T) {
// Deleting a key pair, placement group or launch template clears its
// tag-index entry, so DescribeTags no longer returns its rows, as in AWS.
func TestPlatformDeletesClearTagIndexEntries(t *testing.T) {
c := newDisclosureCluster(t)
s := c.seed(t, disclosureAccount, "a")
before := c.describe(t, disclosureAccount)
require.Equal(t, sortedIDs(s.keyID, s.groupID, s.templateID), before.taggedResources)
c.call(t, "ec2.DeleteKeyPair", disclosureAccount, &ec2.DeleteKeyPairInput{KeyName: aws.String(s.keyName)}, nil)
c.call(t, "ec2.DeletePlacementGroup", disclosureAccount, &ec2.DeletePlacementGroupInput{GroupName: aws.String(s.groupName)}, nil)
@@ -240,13 +243,8 @@ func TestCurrentBehaviour_AccountTermination_PlatformDeletesLeaveTagIndexEntries
assert.Empty(t, got.keyPairs)
assert.Empty(t, got.placementGroups)
assert.Empty(t, got.launchTemplates)
assert.Equal(t, sortedIDs(s.groupID, s.templateID), got.taggedResources)
assert.Empty(t, got.taggedResources)
var tags ec2.DescribeTagsOutput
c.call(t, "ec2.DescribeTags", disclosureAccount, &ec2.DescribeTagsInput{}, &tags)
for _, row := range tags.Tags {
assert.Equal(t, "owner", aws.StringValue(row.Key))
assert.Equal(t, "a", aws.StringValue(row.Value))
assert.True(t, strings.HasPrefix(aws.StringValue(row.ResourceId), "pg-") || strings.HasPrefix(aws.StringValue(row.ResourceId), "lt-"))
}
assert.Empty(t, tags.Tags)
}
+92
View File
@@ -0,0 +1,92 @@
package daemon
import (
"context"
"log/slog"
"github.com/mulgadc/spinifex/spinifex/bootstrap/config"
"github.com/mulgadc/spinifex/spinifex/domains/dns"
reconcilelock "github.com/mulgadc/spinifex/spinifex/domains/network/reconcile"
route53domain "github.com/mulgadc/spinifex/spinifex/domains/route53"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/nats-io/nats.go/jetstream"
)
var _ route53domain.Publisher = (*dns.ZonePublisher)(nil)
// newRoute53Service builds the Route 53 control plane. The nameservers and the
// reserved names are read from the cluster's DNS settings on every call, so a
// node joining or a domain change needs no restart.
func (d *Daemon) newRoute53Service(js jetstream.JetStream) *route53domain.Service {
svc := route53domain.NewService(route53domain.NewStore(js))
svc.Nameservers = func() []string {
base := dns.ResolveBaseDomain(d.config)
if base == "" || d.clusterConfig == nil {
return nil
}
published := dns.PublishedNameservers(d.clusterConfig)
hosts := make([]string, 0, len(published))
for _, ns := range published {
hosts = append(hosts, ns.Host+"."+base)
}
return hosts
}
svc.Reserved = func(name string) bool {
return dns.ReservedZoneName(name, dns.ResolveBaseDomain(d.config), dns.ResolveInternalDomain(d.config))
}
return svc
}
// startRoute53Realizer publishes ACTIVE hosted zones to northstar. Every node
// runs it, and a per-pass election picks the one that writes. Without a zone
// store the zones stay unpublished and their changes stay PENDING.
func (d *Daemon) startRoute53Realizer() {
pub, ok := dns.NewZonePublisher(d.config, func() *config.ClusterConfig { return d.clusterConfig }, d.natsConn)
if !ok {
slog.Info("Route 53 realizer not started: no northstar zone store is configured")
return
}
r := route53domain.NewRealizer(d.route53Service.Store(), pub)
r.Elect = func(ctx context.Context) (func(), bool) {
return reconcilelock.AcquireLeader(ctx, d.natsConn, route53domain.KVBucketRoute53Realize, d.node)
}
r.Reserved = d.route53Service.Reserved
go r.Run(d.ctx)
slog.Info("Started Route 53 realizer")
}
// newRoute53Verifier checks each zone's delegation: from this cluster's own
// records when the parent is a zone it serves, and from the parent's public
// servers otherwise.
func (d *Daemon) newRoute53Verifier() *route53domain.Verifier {
v := route53domain.NewVerifier(d.route53Service.Store())
v.Nameservers = d.route53Service.Nameservers
if d.dnsWriter.Enabled() {
v.Platform = func(_ context.Context, name string) (route53domain.Delegation, bool, error) {
servers, zone, ok, err := d.dnsWriter.PlatformDelegation(name)
return route53domain.Delegation{Source: "platform zone " + zone, Servers: servers}, ok, err
}
}
lookup := &dns.ParentLookup{}
v.Public = func(ctx context.Context, name string) (route53domain.Delegation, error) {
servers, parent, err := lookup.Delegation(ctx, name)
return route53domain.Delegation{Source: "the " + parent + " name servers", Servers: servers}, err
}
v.Elect = func(ctx context.Context) (func(), bool) {
return reconcilelock.AcquireLeader(ctx, d.natsConn, route53domain.KVBucketRoute53Verify, d.node)
}
return v
}
// route53Delegate writes or withdraws platform-zone delegations for the
// operator's spx admin dns delegate.
func (d *Daemon) route53Delegate(ctx context.Context, in *route53domain.AdminDelegateInput, _ string) (*route53domain.AdminDelegateOutput, error) {
if len(in.Names) == 0 {
return nil, awserrors.Errorf(awserrors.ErrorInvalidInput, "no names to delegate")
}
res, err := d.dnsWriter.Delegate(ctx, d.natsConn, d.node, in.Names, in.Remove)
if err != nil {
return nil, awserrors.Errorf(awserrors.ErrorInvalidInput, "%v", err)
}
return &route53domain.AdminDelegateOutput{Applied: res.Applied}, nil
}
+50
View File
@@ -0,0 +1,50 @@
//test:in-package — newRoute53Service is an unexported Daemon method and reads
//the unexported config and clusterConfig fields.
package daemon
import (
"context"
"testing"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/route53"
testutil "github.com/mulgadc/spinifex/internal/testkit"
"github.com/mulgadc/spinifex/spinifex/bootstrap/config"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestRoute53ServiceDelegatesToThePublishedNameservers(t *testing.T) {
_, _, js := testutil.StartTestJetStream(t)
ns := config.NorthstarConfig{ConfigPath: "/etc/spinifex/northstar.toml", DefaultDomain: "spx3.net"}
nodes := map[string]config.Config{
"node1": {AdvertiseIP: "10.0.0.1", Northstar: ns},
"node2": {AdvertiseIP: "10.0.0.2", Northstar: ns},
}
local := nodes["node1"]
d := &Daemon{config: &local, clusterConfig: &config.ClusterConfig{Node: "node1", Nodes: nodes}}
svc := d.newRoute53Service(js)
out, err := svc.CreateHostedZone(context.Background(), &route53.CreateHostedZoneInput{
Name: aws.String("example.com"), CallerReference: aws.String("ref-1"),
}, "000000000001")
require.NoError(t, err)
assert.Equal(t, []string{"ns1.spx3.net", "ns2.spx3.net"}, aws.StringValueSlice(out.DelegationSet.NameServers))
_, err = svc.CreateHostedZone(context.Background(), &route53.CreateHostedZoneInput{
Name: aws.String("lb.elb.spx3.net"), CallerReference: aws.String("ref-2"),
}, "000000000001")
assert.ErrorContains(t, err, "InvalidDomainName")
}
func TestRoute53ServiceRefusesZonesWithoutABaseDomain(t *testing.T) {
_, _, js := testutil.StartTestJetStream(t)
local := config.Config{AdvertiseIP: "10.0.0.1"}
d := &Daemon{config: &local, clusterConfig: &config.ClusterConfig{Node: "node1", Nodes: map[string]config.Config{"node1": local}}}
_, err := d.newRoute53Service(js).CreateHostedZone(context.Background(), &route53.CreateHostedZoneInput{
Name: aws.String("example.com"), CallerReference: aws.String("ref-1"),
}, "000000000001")
assert.ErrorContains(t, err, "no nameservers")
}
@@ -73,7 +73,9 @@ func TestEbsRequestWithTrace_ProducerConsumerLinked(t *testing.T) {
sr := withRecordedSpans(t)
daemon := createTestDaemon(t, sharedNATSURL)
consumerEnded := make(chan struct{})
sub, err := daemon.natsConn.Subscribe("ebs.node-1.unmount", func(msg *nats.Msg) {
defer close(consumerEnded)
// Mirrors viperblockd's ebs.unmount handler: open a consumer span
// joining whatever trace context the producer injected.
_, span := natsmsg.StartConsumerSpan(msg)
@@ -89,6 +91,9 @@ func TestEbsRequestWithTrace_ProducerConsumerLinked(t *testing.T) {
adapter := newVolumeMounterAdapter(daemon.natsConn, daemon.node, nil)
require.NoError(t, adapter.UnmountOne(t.Context(), "", viperblocklegacyv1.EBSRequest{Name: "vol-linked"}))
// The handler replies before its deferred span.End runs, so the consumer
// span may not be recorded yet when UnmountOne returns.
<-consumerEnded
spans := sr.Ended()
require.Len(t, spans, 2, "expected one producer span and one consumer span")
+25 -5
View File
@@ -39,7 +39,11 @@ func (s *Service) EnforceVPCs(ctx context.Context, natsConn *nats.Conn, accountI
if err != nil {
return err
}
return exceeds(len(out.Vpcs), want, s.limitsFor(ctx, accountID).VPCs)
limits, err := s.limitsFor(ctx, accountID)
if err != nil {
return err
}
return exceeds(len(out.Vpcs), want, limits.VPCs)
}
// EnforceSubnets gates CreateSubnet on the account's live DescribeSubnets count.
@@ -52,7 +56,11 @@ func (s *Service) EnforceSubnets(ctx context.Context, natsConn *nats.Conn, accou
if err != nil {
return err
}
return exceeds(len(out.Subnets), want, s.limitsFor(ctx, accountID).Subnets)
limits, err := s.limitsFor(ctx, accountID)
if err != nil {
return err
}
return exceeds(len(out.Subnets), want, limits.Subnets)
}
// EnforceEIPs gates AllocateAddress on the account's live DescribeAddresses count.
@@ -64,7 +72,11 @@ func (s *Service) EnforceEIPs(ctx context.Context, natsConn *nats.Conn, accountI
if err != nil {
return err
}
return exceeds(len(out.Addresses), want, s.limitsFor(ctx, accountID).EIPs)
limits, err := s.limitsFor(ctx, accountID)
if err != nil {
return err
}
return exceeds(len(out.Addresses), want, limits.EIPs)
}
// EnforceLoadBalancers gates CreateLoadBalancer on the account's live count.
@@ -77,7 +89,11 @@ func (s *Service) EnforceLoadBalancers(ctx context.Context, natsConn *nats.Conn,
if err != nil {
return err
}
return exceeds(len(out.LoadBalancers), want, s.limitsFor(ctx, accountID).LoadBalancers)
limits, err := s.limitsFor(ctx, accountID)
if err != nil {
return err
}
return exceeds(len(out.LoadBalancers), want, limits.LoadBalancers)
}
// EnforceRDSInstances gates CreateDBInstance on the account's live count. A DB
@@ -91,5 +107,9 @@ func (s *Service) EnforceRDSInstances(ctx context.Context, natsConn *nats.Conn,
if err != nil {
return err
}
return exceeds(len(out.DBInstances), want, s.limitsFor(ctx, accountID).RDSInstances)
limits, err := s.limitsFor(ctx, accountID)
if err != nil {
return err
}
return exceeds(len(out.DBInstances), want, limits.RDSInstances)
}
+159 -48
View File
@@ -8,12 +8,16 @@ import (
"log/slog"
"time"
awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
"github.com/nats-io/nats.go/jetstream"
)
// Overrides raises or lowers individual dimensions for one account. Every field
// is a pointer so an explicit 0 (a limit that denies everything) is distinct
// from an absent field, which inherits the configured value.
// from an absent field, which inherits the configured value. This is also the
// admin wire type: an admin request body can carry only these fields, so it can
// never set or clear a stored record's state.
type Overrides struct {
VCPUs *int `json:"vcpus,omitempty"`
VPCs *int `json:"vpcs,omitempty"`
@@ -29,11 +33,62 @@ type Overrides struct {
UpdatedBy string `json:"updated_by,omitempty"`
}
// Empty reports whether no dimension is overridden, in which case the record
// carries nothing but provenance and the account resolves to the config.
func (o Overrides) Empty() bool {
return o.VCPUs == nil && o.VPCs == nil && o.Subnets == nil && o.EIPs == nil &&
o.VolumesGiB == nil && o.Volumes == nil && o.RDSInstances == nil && o.LoadBalancers == nil
// OverrideState is a stored override record's lifecycle state. "" is active;
// a record written before the state field existed has none and stays active.
type OverrideState string
// OverrideStateSealed marks a record no writer may change or recreate.
const OverrideStateSealed OverrideState = "sealed"
// overrideRecord is the stored form: the admin-settable fields inline, plus a
// state no admin request can read or write.
type overrideRecord struct {
Overrides
State OverrideState `json:"state,omitempty"`
}
// errOverridesSealed marks a read or write that found a sealed record, so a
// caller maps it to Blocked without logging it as a fault.
var errOverridesSealed = errors.New(awserrors.ErrorBlocked)
// errOverridesContended marks a write whose CAS retries were exhausted under
// concurrent writers, bounding the retry loop instead of spinning.
var errOverridesContended = errors.New("quota override: CAS retries exhausted")
// overrideMaxAttempts bounds a write's retries against a revision that keeps
// moving under concurrent writers, so it fails closed instead of spinning.
const overrideMaxAttempts = 5
// checkActive rejects a sealed or unknown-state record; "" is active and
// passes through. A caller must never read or mutate a record that failed
// this as if it were active.
func checkActive(rec overrideRecord) error {
switch rec.State {
case "":
return nil
case OverrideStateSealed:
return errOverridesSealed
default:
return fmt.Errorf("quota override: unknown state %q", rec.State)
}
}
// mapOverrideError turns a recordFor, PutAccountQuota or clear failure into
// the AWS error a caller returns: sealed is an expected, unlogged Blocked;
// everything else is InternalError, logged with the account ID.
func mapOverrideError(ctx context.Context, accountID string, err error) error {
switch {
case err == nil:
return nil
case errors.Is(err, errOverridesSealed):
return errors.New(awserrors.ErrorBlocked)
case errors.Is(err, errOverridesContended):
slog.ErrorContext(ctx, "Quota override CAS retries exhausted under contention", "accountID", accountID)
default:
slog.ErrorContext(ctx, "Quota override operation failed", "accountID", accountID, "err", err)
}
return errors.New(awserrors.ErrorInternalError)
}
// apply overlays the set fields onto base, leaving Enabled and the Bedrock
@@ -60,72 +115,128 @@ func (o Overrides) apply(base Limits) Limits {
return out
}
// limitsFor resolves the effective limits for accountID: its overrides where
// set, the configured limits everywhere else. A read failure falls back to the
// configured limits rather than to no limit, so a KV outage cannot lift a cap.
func (s *Service) limitsFor(ctx context.Context, accountID string) Limits {
over, err := s.overridesFor(ctx, accountID)
if err != nil {
slog.WarnContext(ctx, "quota: override read failed, using configured limits",
"account", accountID, "err", err)
return s.limits
}
return over.apply(s.limits)
// Empty reports whether no dimension is overridden, in which case the record
// carries nothing but provenance and the account resolves to the config.
func (o Overrides) Empty() bool {
return o.VCPUs == nil && o.VPCs == nil && o.Subnets == nil && o.EIPs == nil &&
o.VolumesGiB == nil && o.Volumes == nil && o.RDSInstances == nil && o.LoadBalancers == nil
}
// overridesFor reads accountID's override record. An absent key is the normal
// case for nearly every account and yields the zero value, not an error; only a
// genuine read or decode failure is returned.
func (s *Service) overridesFor(ctx context.Context, accountID string) (Overrides, error) {
// recordFor reads and decodes accountID's stored override record. An absent
// key is the active zero value, not an error; only a genuine read or decode
// failure is returned.
func (s *Service) recordFor(ctx context.Context, accountID string) (overrideRecord, error) {
if s == nil || s.overrides == nil {
return Overrides{}, nil
return overrideRecord{}, nil
}
entry, err := s.overrides.Get(ctx, accountID)
if errors.Is(err, jetstream.ErrKeyNotFound) {
return Overrides{}, nil
return overrideRecord{}, nil
}
if err != nil {
return Overrides{}, fmt.Errorf("read quota override for %s: %w", accountID, err)
return overrideRecord{}, fmt.Errorf("read quota override for %s: %w", accountID, err)
}
var over Overrides
if err := json.Unmarshal(entry.Value(), &over); err != nil {
return Overrides{}, fmt.Errorf("decode quota override for %s: %w", accountID, err)
var rec overrideRecord
if err := json.Unmarshal(entry.Value(), &rec); err != nil {
return overrideRecord{}, fmt.Errorf("decode quota override for %s: %w", accountID, err)
}
return over, nil
return rec, nil
}
// GetAccountQuota returns accountID's stored overrides and the limits they
// resolve to. A read failure is surfaced here rather than masked, because an
// operator inspecting a quota must not be shown the fallback as if it were real.
// limitsFor resolves accountID's effective limits: overrides where set, the
// config elsewhere. A read or decode failure falls back to the config so an
// outage cannot lift a cap; a sealed or unknown-state record refuses instead.
func (s *Service) limitsFor(ctx context.Context, accountID string) (Limits, error) {
rec, err := s.recordFor(ctx, accountID)
if err != nil {
slog.WarnContext(ctx, "quota: override read failed, using configured limits",
"account", accountID, "err", err)
return s.limits, nil
}
if err := checkActive(rec); err != nil {
return Limits{}, mapOverrideError(ctx, accountID, err)
}
return rec.apply(s.limits), nil
}
// GetAccountQuota returns accountID's stored overrides and resolved limits.
// Read failures surface rather than show the fallback as real; a sealed or
// unknown-state record returns Blocked or InternalError.
func (s *Service) GetAccountQuota(ctx context.Context, accountID string) (Overrides, Limits, error) {
over, err := s.overridesFor(ctx, accountID)
rec, err := s.recordFor(ctx, accountID)
if err != nil {
return Overrides{}, Limits{}, err
}
return over, over.apply(s.limits), nil
if err := checkActive(rec); err != nil {
return Overrides{}, Limits{}, mapOverrideError(ctx, accountID, err)
}
return rec.Overrides, rec.apply(s.limits), nil
}
// PutAccountQuota stores accountID's overrides, stamping provenance. An empty
// set deletes the record so the account resolves to the config, which keeps
// "has this account been touched?" a single existence check.
// PutAccountQuota stores or clears accountID's overrides, stamping provenance
// on a store. Both branches are revision-guarded and refuse a sealed record,
// so a seal landing between a read and a write fails the CAS.
func (s *Service) PutAccountQuota(ctx context.Context, accountID string, over Overrides, updatedBy string) error {
if s == nil || s.overrides == nil {
return errors.New("quota: override bucket not configured")
}
if over.Empty() {
if err := s.overrides.Delete(ctx, accountID); err != nil && !errors.Is(err, jetstream.ErrKeyNotFound) {
return fmt.Errorf("clear quota override for %s: %w", accountID, err)
}
return nil
return s.clearAccountQuota(ctx, accountID)
}
over.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
over.UpdatedBy = updatedBy
data, err := json.Marshal(over)
if err != nil {
return fmt.Errorf("encode quota override for %s: %w", accountID, err)
}
if _, err := s.overrides.Put(ctx, accountID, data); err != nil {
return fmt.Errorf("store quota override for %s: %w", accountID, err)
}
return nil
_, err := kvutil.Update(ctx, s.overrides, accountID, kvutil.CASConfig{
CreateIfAbsent: true,
Attempts: overrideMaxAttempts,
Exhausted: func(string, int) error { return errOverridesContended },
}, func(rec *overrideRecord) (bool, error) {
if err := checkActive(*rec); err != nil {
return false, err
}
rec.Overrides = over
// Every success is a leader-accepted write over an active value: a
// seal landing between the read and the write fails this CAS.
return true, nil
})
return mapOverrideError(ctx, accountID, err)
}
// clearAccountQuota deletes an active override at the revision it read. An
// observed-absent key writes nothing, so a stale read cannot unseal; a sealed
// record refuses rather than being erased and later recreated active.
func (s *Service) clearAccountQuota(ctx context.Context, accountID string) error {
for range overrideMaxAttempts {
entry, err := s.overrides.Get(ctx, accountID)
if errors.Is(err, jetstream.ErrKeyNotFound) {
return nil
}
if err != nil {
return mapOverrideError(ctx, accountID, fmt.Errorf("read quota override for clear: %w", err))
}
var rec overrideRecord
if err := json.Unmarshal(entry.Value(), &rec); err != nil {
return mapOverrideError(ctx, accountID, fmt.Errorf("decode quota override for clear: %w", err))
}
if err := checkActive(rec); err != nil {
return mapOverrideError(ctx, accountID, err)
}
err = s.overrides.Delete(ctx, accountID, jetstream.LastRevision(entry.Revision()))
switch {
case err == nil:
return nil
case errors.Is(err, jetstream.ErrKeyNotFound):
return nil
case isRevisionConflict(err):
// A concurrent write landed between the read and the guarded
// delete; retry against whatever revision now holds.
continue
default:
return mapOverrideError(ctx, accountID, fmt.Errorf("clear quota override: %w", err))
}
}
return mapOverrideError(ctx, accountID, errOverridesContended)
}
@@ -31,7 +31,8 @@ func newOverrideService(t *testing.T) (*Service, jetstream.KeyValue) {
func TestLimitsForAbsentKeyInherits(t *testing.T) {
svc, _ := newOverrideService(t)
got := svc.limitsFor(t.Context(), "123456789012")
got, err := svc.limitsFor(t.Context(), "123456789012")
require.NoError(t, err)
require.Equal(t, baseLimits(), got)
}
@@ -44,10 +45,14 @@ func TestLimitsForIsPerAccount(t *testing.T) {
require.NoError(t, svc.PutAccountQuota(t.Context(), upgraded,
Overrides{VCPUs: ptr(32), RDSInstances: ptr(8)}, "operator"))
require.Equal(t, 32, svc.limitsFor(t.Context(), upgraded).VCPUs)
require.Equal(t, 8, svc.limitsFor(t.Context(), upgraded).RDSInstances)
require.Equal(t, baseLimits().VCPUs, svc.limitsFor(t.Context(), sandbox).VCPUs)
require.Equal(t, baseLimits().RDSInstances, svc.limitsFor(t.Context(), sandbox).RDSInstances)
upgradedLimits, err := svc.limitsFor(t.Context(), upgraded)
require.NoError(t, err)
sandboxLimits, err := svc.limitsFor(t.Context(), sandbox)
require.NoError(t, err)
require.Equal(t, 32, upgradedLimits.VCPUs)
require.Equal(t, 8, upgradedLimits.RDSInstances)
require.Equal(t, baseLimits().VCPUs, sandboxLimits.VCPUs)
require.Equal(t, baseLimits().RDSInstances, sandboxLimits.RDSInstances)
}
// A stored override must round-trip through KV with its provenance, and an
@@ -79,7 +84,9 @@ func TestPutAccountQuotaEmptyClears(t *testing.T) {
_, err := kv.Get(t.Context(), account)
require.ErrorIs(t, err, jetstream.ErrKeyNotFound)
require.Equal(t, baseLimits(), svc.limitsFor(t.Context(), account))
limits, err := svc.limitsFor(t.Context(), account)
require.NoError(t, err)
require.Equal(t, baseLimits(), limits)
}
// Clearing an account that never had a record must succeed rather than fail on
@@ -97,8 +104,10 @@ func TestPutAccountQuotaExplicitZero(t *testing.T) {
require.NoError(t, svc.PutAccountQuota(t.Context(), account, Overrides{VPCs: ptr(0)}, "operator"))
require.Equal(t, 0, svc.limitsFor(t.Context(), account).VPCs)
require.Error(t, exceeds(0, 1, svc.limitsFor(t.Context(), account).VPCs))
limits, err := svc.limitsFor(t.Context(), account)
require.NoError(t, err)
require.Equal(t, 0, limits.VPCs)
require.Error(t, exceeds(0, 1, limits.VPCs))
}
// A corrupt record is a read failure, not an absent one: it must fall back to
@@ -110,7 +119,9 @@ func TestLimitsForCorruptRecordFailsClosed(t *testing.T) {
_, err := kv.Put(t.Context(), account, []byte("{not json"))
require.NoError(t, err)
require.Equal(t, baseLimits(), svc.limitsFor(t.Context(), account),
limits, err := svc.limitsFor(t.Context(), account)
require.NoError(t, err, "a decode failure must fall back, not error, from limitsFor")
require.Equal(t, baseLimits(), limits,
"a corrupt override must fall back to the configured limits")
_, _, err = svc.GetAccountQuota(t.Context(), account)
@@ -126,7 +126,11 @@ func TestOverridesEmpty(t *testing.T) {
// fall through to the configured limits rather than panicking.
func TestLimitsForWithoutBucket(t *testing.T) {
svc := New(baseLimits(), nil)
if got := svc.limitsFor(t.Context(), "123456789012"); got != baseLimits() {
got, err := svc.limitsFor(t.Context(), "123456789012")
if err != nil {
t.Fatalf("limitsFor error = %v, want nil", err)
}
if got != baseLimits() {
t.Fatalf("limitsFor = %+v, want the configured limits", got)
}
}
@@ -0,0 +1,75 @@
package quota
//test:in-package — pins what AccountQuotaOverrideSealCapabilityVersion 1 will
// assert: nothing in this package writes the override bucket except through
// PutAccountQuota's CAS loop, clearAccountQuota's guarded delete or
// SealAccountOverrides' CAS loop.
import (
"go/ast"
"go/parser"
"go/token"
"path/filepath"
"runtime"
"testing"
)
// overrideWriteFiles are the only files in this package that touch the
// override bucket. The package also holds the vCPU usage and Bedrock usage
// buckets, which write through their own stores and are out of scope here;
// scanning the whole package directory, as the EC2 settings equivalent does,
// would false-flag those unrelated writes.
var overrideWriteFiles = []string{"override.go", "seal.go"}
func quotaPackageDir(t *testing.T) string {
t.Helper()
_, file, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("cannot locate test source")
}
return filepath.Dir(file)
}
// No file owning the override bucket may call Put, PutString, or Delete
// without a revision option: every write must go through PutAccountQuota's
// CAS loop, clearAccountQuota's guarded delete, or SealAccountOverrides' CAS
// loop.
func TestOverrideWritesAreRevisionGuarded(t *testing.T) {
root := quotaPackageDir(t)
fset := token.NewFileSet()
scanned := 0
for _, name := range overrideWriteFiles {
path := filepath.Join(root, name)
f, err := parser.ParseFile(fset, path, nil, 0)
if err != nil {
t.Fatalf("parse %s: %v", path, err)
}
scanned++
ast.Inspect(f, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok {
return true
}
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok {
return true
}
switch sel.Sel.Name {
case "Put", "PutString":
t.Errorf("%s: unconditional %s call bypasses the revision-guarded override write; use PutAccountQuota or SealAccountOverrides instead",
fset.Position(call.Pos()), sel.Sel.Name)
case "Delete":
if len(call.Args) < 3 {
t.Errorf("%s: Delete call with no revision option bypasses the guarded clear; pin it to the read revision",
fset.Position(call.Pos()))
}
}
return true
})
}
if scanned == 0 {
t.Fatalf("scanned no override-write files under %s; the invariant would pass vacuously", root)
}
}
+18
View File
@@ -90,6 +90,14 @@ type Service struct {
// overrides holds the per-account limit overrides. Nil leaves every account
// on the configured limits, which is also what an absent key means.
overrides jetstream.KeyValue
// js opens the override bucket for SealAccountOverrides when quotas were
// disabled at startup; nil makes such a seal refuse rather than create it.
js jetstream.KeyValueManager
// terminationChecker is the seal precondition; SealAccountOverrides
// refuses until it is wired.
terminationChecker AccountTerminationChecker
}
// New constructs a quota Service from the configured limits and the gateway-owned
@@ -125,6 +133,16 @@ func (s *Service) SetOverrides(kv jetstream.KeyValue) {
s.overrides = kv
}
// SetJetStream wires the client SealAccountOverrides uses to get-or-create
// the override bucket when this gateway disabled quotas. Called once at
// wiring time, regardless of whether quotas are enabled here.
func (s *Service) SetJetStream(js jetstream.KeyValueManager) {
if s == nil {
return
}
s.js = js
}
// Exempt returns true for the global/system account and whenever quotas are
// disabled, so those callers bypass every quota check. A nil Service (quotas
// never built) is treated as disabled so callers need no nil guard.
+163
View File
@@ -0,0 +1,163 @@
package quota
import (
"context"
"encoding/json"
"errors"
"log/slog"
awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
"github.com/nats-io/nats.go/jetstream"
)
// sealMaxAttempts bounds SealAccountOverrides' retry loop against a revision
// that keeps moving under concurrent writes, so it fails closed instead of
// spinning.
const sealMaxAttempts = 5
// sealedRecord is the fixed value a seal writes. No dimension or provenance
// survives, so no tenant commercial setting outlives the seal.
var sealedRecord = &overrideRecord{State: OverrideStateSealed}
// OverrideSeal is SealAccountOverrides' result: the revision at which the
// stream leader accepted the sealed value.
type OverrideSeal struct {
Revision uint64
}
// AccountTerminationChecker reports whether IAM holds accountID as
// TERMINATING. A missing record is (false, nil); only a failed read is an
// error.
type AccountTerminationChecker interface {
AccountTerminating(ctx context.Context, accountID string) (bool, error)
}
// SetAccountTerminationChecker wires the seal precondition; until it is set,
// SealAccountOverrides refuses.
func (s *Service) SetAccountTerminationChecker(c AccountTerminationChecker) {
if s == nil {
return
}
s.terminationChecker = c
}
// checkTerminating runs before any override-KV call, so a refusal never
// writes or opens the bucket. Not TERMINATING (incl. missing) is
// IncorrectState; a read failure or unwired checker is InternalError.
func (s *Service) checkTerminating(ctx context.Context, accountID string) error {
if s.terminationChecker == nil {
slog.ErrorContext(ctx, "Quota override seal has no termination checker wired", "accountID", accountID)
return errors.New(awserrors.ErrorInternalError)
}
terminating, err := s.terminationChecker.AccountTerminating(ctx, accountID)
if err != nil {
slog.ErrorContext(ctx, "Failed to read account status for seal precondition", "accountID", accountID, "err", err)
return errors.New(awserrors.ErrorInternalError)
}
if !terminating {
return awserrors.Errorf(awserrors.ErrorIncorrectState, "account %s is not TERMINATING", accountID)
}
return nil
}
// isRevisionConflict reports a lost CAS race to retry, including the bare
// wrong-last-sequence APIError from Create's retry against a delete marker.
// A private copy of domains/ec2/account's helper, which this package may not import.
func isRevisionConflict(err error) bool {
if errors.Is(err, jetstream.ErrKeyExists) || errors.Is(err, jetstream.ErrKeyRevisionMismatch) {
return true
}
if apiErr, ok := errors.AsType[*jetstream.APIError](err); ok {
return apiErr.ErrorCode == jetstream.JSErrCodeStreamWrongLastSequence ||
apiErr.ErrorCode == jetstream.JSErrCodeStreamWrongLastSequenceConstant
}
return false
}
// sealBucket returns this gateway's override bucket, or get-or-creates it
// when quotas are disabled here. Called only after checkTerminating accepts,
// so a refused seal never creates the bucket.
func (s *Service) sealBucket(ctx context.Context) (jetstream.KeyValue, error) {
if s.overrides != nil {
return s.overrides, nil
}
if s.js == nil {
slog.ErrorContext(ctx, "Quota override seal has no JetStream client wired to open the override bucket")
return nil, errors.New(awserrors.ErrorInternalError)
}
kv, err := kvutil.GetOrCreateBucket(ctx, s.js, KVBucketAccountQuota, 1)
if err != nil {
slog.ErrorContext(ctx, "Failed to open quota override bucket for seal", "err", err)
return nil, errors.New(awserrors.ErrorInternalError)
}
return kv, nil
}
// SealAccountOverrides seals accountID's override record so no later writer
// can recreate it. Success is only a leader-accepted write, evidenced by the
// revision; a missing key is created sealed, never reported from absence.
func (s *Service) SealAccountOverrides(ctx context.Context, accountID string) (*OverrideSeal, error) {
if accountID == "" {
return nil, awserrors.Errorf(awserrors.ErrorMissingParameter, "accountID is required to seal quota overrides")
}
if !awsidentifiers.IsAccountID(accountID) {
return nil, awserrors.Errorf(awserrors.ErrorInvalidParameterValue, "accountID %q is not a valid AWS account ID", accountID)
}
if accountID == awsidentifiers.GlobalAccountID {
return nil, awserrors.Errorf(awserrors.ErrorInvalidParameterValue, "quota override seal must not target the global account")
}
if err := s.checkTerminating(ctx, accountID); err != nil {
return nil, err
}
overrides, err := s.sealBucket(ctx)
if err != nil {
return nil, err
}
data, err := json.Marshal(sealedRecord)
if err != nil {
slog.ErrorContext(ctx, "Failed to encode sealed quota override record", "accountID", accountID, "err", err)
return nil, errors.New(awserrors.ErrorInternalError)
}
// accountID is never resolved through any fallback here: a seal must act
// on exactly accountID and never fall back to the global key.
for range sealMaxAttempts {
var rev uint64
entry, getErr := overrides.Get(ctx, accountID)
switch {
case getErr == nil:
rev = entry.Revision()
case errors.Is(getErr, jetstream.ErrKeyNotFound):
rev = 0
default:
slog.ErrorContext(ctx, "Failed to read quota override for seal", "accountID", accountID, "err", getErr)
return nil, errors.New(awserrors.ErrorInternalError)
}
var newRev uint64
var writeErr error
if rev == 0 {
newRev, writeErr = overrides.Create(ctx, accountID, data)
} else {
newRev, writeErr = overrides.Update(ctx, accountID, data, rev)
}
switch {
case writeErr == nil:
return &OverrideSeal{Revision: newRev}, nil
case isRevisionConflict(writeErr):
continue
default:
slog.ErrorContext(ctx, "Failed to write sealed quota override record", "accountID", accountID, "err", writeErr)
return nil, errors.New(awserrors.ErrorInternalError)
}
}
slog.ErrorContext(ctx, "Quota override seal exhausted attempts against concurrent writes", "accountID", accountID, "attempts", sealMaxAttempts)
return nil, errors.New(awserrors.ErrorInternalError)
}
@@ -0,0 +1,806 @@
package quota
import (
"context"
"encoding/json"
"errors"
"fmt"
"sync"
"testing"
"github.com/mulgadc/spinifex/internal/testkit"
awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
"github.com/nats-io/nats.go/jetstream"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// stubTerminationChecker answers AccountTerminating from a fixed set, for
// tests that exercise the KV paths rather than IAM itself.
type stubTerminationChecker struct {
terminating map[string]bool
err error
}
func (c *stubTerminationChecker) AccountTerminating(_ context.Context, accountID string) (bool, error) {
if c.err != nil {
return false, c.err
}
return c.terminating[accountID], nil
}
// terminatingChecker answers true for exactly accountID.
func terminatingChecker(accountID string) *stubTerminationChecker {
return &stubTerminationChecker{terminating: map[string]bool{accountID: true}}
}
// newSealService builds a Service over a real JetStream KV bucket with the
// override bucket already attached, mirroring a quota-enabled gateway.
func newSealService(t *testing.T) (*Service, jetstream.JetStream) {
t.Helper()
_, _, js := testutil.StartTestJetStream(t)
kv, err := js.CreateKeyValue(t.Context(), jetstream.KeyValueConfig{
Bucket: KVBucketAccountQuota,
History: 1,
})
require.NoError(t, err)
usage, err := js.CreateKeyValue(t.Context(), jetstream.KeyValueConfig{
Bucket: KVBucketAccountUsage,
History: 1,
})
require.NoError(t, err)
svc := New(baseLimits(), usage)
svc.SetOverrides(kv)
svc.SetJetStream(js)
return svc, js
}
// newDisabledSealService builds a Service with only the JetStream client
// wired, mirroring a quota-disabled gateway: the override bucket does not
// exist until a seal creates it.
func newDisabledSealService(t *testing.T) (*Service, jetstream.JetStream) {
t.Helper()
_, _, js := testutil.StartTestJetStream(t)
svc := New(baseLimits(), nil)
svc.SetJetStream(js)
return svc, js
}
// lastOverrideSeq reports the override stream's last sequence, so a test can
// assert a failed attempt wrote nothing.
func lastOverrideSeq(t *testing.T, js jetstream.JetStream) uint64 {
t.Helper()
stream, err := js.Stream(context.Background(), "KV_"+KVBucketAccountQuota)
require.NoError(t, err)
info, err := stream.Info(context.Background())
require.NoError(t, err)
return info.State.LastSeq
}
// mutationCase is one of PutAccountQuota's two mutations, called uniformly so
// a test can table over them.
type mutationCase struct {
name string
call func(svc *Service, ctx context.Context) error
}
func mutationTable() []mutationCase {
return []mutationCase{
{"Set", func(svc *Service, ctx context.Context) error {
return svc.PutAccountQuota(ctx, testAccount, Overrides{VCPUs: ptr(32)}, "operator")
}},
{"Clear", func(svc *Service, ctx context.Context) error {
return svc.PutAccountQuota(ctx, testAccount, Overrides{}, "operator")
}},
}
}
// seedOverrideState prepares testAccount's record for a race test: present
// (an active record), absent (never written) or tombstone (written then
// deleted, leaving a delete marker Get hides).
func seedOverrideState(t *testing.T, svc *Service, state string) {
t.Helper()
ctx := context.Background()
switch state {
case "present":
require.NoError(t, svc.PutAccountQuota(ctx, testAccount, Overrides{VCPUs: ptr(8)}, "operator"))
case "tombstone":
require.NoError(t, svc.PutAccountQuota(ctx, testAccount, Overrides{VCPUs: ptr(8)}, "operator"))
require.NoError(t, svc.overrides.Delete(ctx, testAccount))
case "absent":
default:
t.Fatalf("unknown seed state %q", state)
}
}
// sealRaceKV wraps a real KeyValue so a test can inject a deterministic race
// between a caller's read and its guarded write (Create, Update or Delete),
// instead of relying on goroutine timing.
type sealRaceKV struct {
jetstream.KeyValue
writeCalls int
beforeWrite func(attempt int) error
}
func (f *sealRaceKV) Create(ctx context.Context, key string, value []byte, opts ...jetstream.KVCreateOpt) (uint64, error) {
f.writeCalls++
if f.beforeWrite != nil {
if err := f.beforeWrite(f.writeCalls); err != nil {
return 0, err
}
}
return f.KeyValue.Create(ctx, key, value, opts...)
}
func (f *sealRaceKV) Update(ctx context.Context, key string, value []byte, revision uint64) (uint64, error) {
f.writeCalls++
if f.beforeWrite != nil {
if err := f.beforeWrite(f.writeCalls); err != nil {
return 0, err
}
}
return f.KeyValue.Update(ctx, key, value, revision)
}
func (f *sealRaceKV) Delete(ctx context.Context, key string, opts ...jetstream.KVDeleteOpt) error {
f.writeCalls++
if f.beforeWrite != nil {
if err := f.beforeWrite(f.writeCalls); err != nil {
return err
}
}
return f.KeyValue.Delete(ctx, key, opts...)
}
// staleGetKV always answers Get with a fixed snapshot taken at construction,
// so a caller's CAS keeps retrying against a revision that can never win.
type staleGetKV struct {
jetstream.KeyValue
entry jetstream.KeyValueEntry
}
func (f *staleGetKV) Get(context.Context, string) (jetstream.KeyValueEntry, error) {
return f.entry, nil
}
// staleNotFoundKV always answers Get as absent, regardless of what the real
// bucket holds.
type staleNotFoundKV struct {
jetstream.KeyValue
}
func (f *staleNotFoundKV) Get(context.Context, string) (jetstream.KeyValueEntry, error) {
return nil, jetstream.ErrKeyNotFound
}
// errGetKV always fails Get with a fixed store error.
type errGetKV struct {
jetstream.KeyValue
err error
}
func (f *errGetKV) Get(context.Context, string) (jetstream.KeyValueEntry, error) {
return nil, f.err
}
func decodeOverride(t *testing.T, entry jetstream.KeyValueEntry) overrideRecord {
t.Helper()
var rec overrideRecord
require.NoError(t, json.Unmarshal(entry.Value(), &rec))
return rec
}
// A seal landing between a mutation's read and its guarded write is not
// outlived: the mutation's retry sees the seal and is rejected, and nothing
// it wrote survives. Clear on an absent or tombstoned key never reaches a
// write at all (see TestClearOnMissingKeyWritesNothing), so only the four
// cells with a real write are exercised here.
func TestSealRaceMutation_SealLandsBetweenReadAndWrite(t *testing.T) {
cases := []struct {
name string
state string
call func(svc *Service, ctx context.Context) error
}{
{"Set_present", "present", mutationTable()[0].call},
{"Set_absent", "absent", mutationTable()[0].call},
{"Set_tombstone", "tombstone", mutationTable()[0].call},
{"Clear_present", "present", mutationTable()[1].call},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
svc, _ := newSealService(t)
ctx := context.Background()
seedOverrideState(t, svc, tc.state)
realKV := svc.overrides
sealer := New(baseLimits(), nil)
sealer.SetOverrides(realKV)
sealer.SetAccountTerminationChecker(terminatingChecker(testAccount))
var sealed *OverrideSeal
race := &sealRaceKV{KeyValue: realKV}
race.beforeWrite = func(attempt int) error {
if attempt != 1 {
return nil
}
var sealErr error
sealed, sealErr = sealer.SealAccountOverrides(ctx, testAccount)
return sealErr
}
svc.overrides = race
err := tc.call(svc, ctx)
require.Error(t, err)
assert.True(t, awserrors.IsErrorCode(err, awserrors.ErrorBlocked), "got %v", err)
require.NotNil(t, sealed)
entry, err := realKV.Get(ctx, testAccount)
require.NoError(t, err)
assert.Equal(t, OverrideStateSealed, decodeOverride(t, entry).State)
assert.Equal(t, sealed.Revision, entry.Revision(), "no write landed after the seal")
})
}
}
// A mutation landing inside a seal's read-write window does not survive it:
// the seal retries against the mutation's write and wins, leaving the sealed
// value with no overridden dimension.
func TestSealRaceMutation_MutationLandsBetweenSealReadAndWrite(t *testing.T) {
for _, m := range mutationTable() {
for _, state := range []string{"present", "absent", "tombstone"} {
t.Run(m.name+"_"+state, func(t *testing.T) {
svc, _ := newSealService(t)
ctx := context.Background()
seedOverrideState(t, svc, state)
realKV := svc.overrides
race := &sealRaceKV{KeyValue: realKV}
race.beforeWrite = func(attempt int) error {
if attempt != 1 {
return nil
}
_ = m.call(svc, ctx)
return nil
}
sealer := New(baseLimits(), nil)
sealer.SetOverrides(race)
sealer.SetAccountTerminationChecker(terminatingChecker(testAccount))
seal, err := sealer.SealAccountOverrides(ctx, testAccount)
require.NoError(t, err)
entry, err := realKV.Get(ctx, testAccount)
require.NoError(t, err)
rec := decodeOverride(t, entry)
assert.Equal(t, OverrideStateSealed, rec.State)
assert.Nil(t, rec.VCPUs)
assert.Equal(t, seal.Revision, entry.Revision())
})
}
}
}
// Neither mutation can act on an already-sealed record.
func TestMutationOnSealedRecordIsRejected(t *testing.T) {
for _, m := range mutationTable() {
t.Run(m.name, func(t *testing.T) {
svc, _ := newSealService(t)
ctx := context.Background()
svc.SetAccountTerminationChecker(terminatingChecker(testAccount))
_, err := svc.SealAccountOverrides(ctx, testAccount)
require.NoError(t, err)
before, err := svc.overrides.Get(ctx, testAccount)
require.NoError(t, err)
err = m.call(svc, ctx)
require.Error(t, err)
assert.True(t, awserrors.IsErrorCode(err, awserrors.ErrorBlocked), "got %v", err)
after, err := svc.overrides.Get(ctx, testAccount)
require.NoError(t, err)
assert.Equal(t, before.Revision(), after.Revision())
})
}
}
// Every override mutation races one seal on a real bucket; afterwards the
// record is sealed and stays sealed, so no success among them could have
// landed after the seal.
func TestSealConcurrentMutations(t *testing.T) {
svc, _ := newSealService(t)
ctx := context.Background()
svc.SetAccountTerminationChecker(terminatingChecker(testAccount))
require.NoError(t, svc.PutAccountQuota(ctx, testAccount, Overrides{VCPUs: ptr(8)}, "operator"))
mutations := mutationTable()
errs := make([]error, len(mutations))
var wg sync.WaitGroup
for i, m := range mutations {
wg.Add(1)
go func(i int, m mutationCase) {
defer wg.Done()
errs[i] = m.call(svc, ctx)
}(i, m)
}
var sealResult *OverrideSeal
var sealErr error
wg.Go(func() {
sealResult, sealErr = svc.SealAccountOverrides(ctx, testAccount)
})
wg.Wait()
require.NoError(t, sealErr)
require.NotNil(t, sealResult)
for i, err := range errs {
if err != nil {
assert.True(t, awserrors.IsErrorCode(err, awserrors.ErrorBlocked), "mutation %s: got %v", mutations[i].name, err)
}
}
entry, err := svc.overrides.Get(ctx, testAccount)
require.NoError(t, err)
assert.Equal(t, OverrideStateSealed, decodeOverride(t, entry).State)
assert.Equal(t, sealResult.Revision, entry.Revision())
_, _, err = svc.GetAccountQuota(ctx, testAccount)
assert.True(t, awserrors.IsErrorCode(err, awserrors.ErrorBlocked))
}
// A Get pinned to a revision the record has already moved past exhausts the
// mutation's retries without ever writing.
func TestMutationStaleReadExhaustsWithoutWriting(t *testing.T) {
svc, js := newSealService(t)
ctx := context.Background()
require.NoError(t, svc.PutAccountQuota(ctx, testAccount, Overrides{VCPUs: ptr(8)}, "operator"))
stale, err := svc.overrides.Get(ctx, testAccount)
require.NoError(t, err)
require.NoError(t, svc.PutAccountQuota(ctx, testAccount, Overrides{VCPUs: ptr(16)}, "operator"))
before := lastOverrideSeq(t, js)
svc.overrides = &staleGetKV{KeyValue: svc.overrides, entry: stale}
err = svc.PutAccountQuota(ctx, testAccount, Overrides{VCPUs: ptr(32)}, "operator")
assert.True(t, awserrors.IsErrorCode(err, awserrors.ErrorInternalError), "got %v", err)
assert.Equal(t, before, lastOverrideSeq(t, js))
}
// A Get that always reports absent, while a sealed record actually exists,
// never gets to recreate it: every Create attempt conflicts.
func TestMutationStaleAbsentDoesNotRecreate(t *testing.T) {
svc, js := newSealService(t)
ctx := context.Background()
svc.SetAccountTerminationChecker(terminatingChecker(testAccount))
_, err := svc.SealAccountOverrides(ctx, testAccount)
require.NoError(t, err)
before := lastOverrideSeq(t, js)
svc.overrides = &staleNotFoundKV{KeyValue: svc.overrides}
err = svc.PutAccountQuota(ctx, testAccount, Overrides{VCPUs: ptr(8)}, "operator")
assert.True(t, awserrors.IsErrorCode(err, awserrors.ErrorInternalError), "got %v", err)
assert.Equal(t, before, lastOverrideSeq(t, js))
}
// A revision that keeps moving under sustained concurrent writes must not let
// the seal spin forever: it gives up, bounded by its attempt budget, and its
// own write never lands.
func TestSealSustainedConflictFailsWithoutWriting(t *testing.T) {
svc, _ := newSealService(t)
ctx := context.Background()
svc.SetAccountTerminationChecker(terminatingChecker(testAccount))
require.NoError(t, svc.PutAccountQuota(ctx, testAccount, Overrides{VCPUs: ptr(8)}, "operator"))
realKV := svc.overrides
race := &sealRaceKV{KeyValue: realKV}
race.beforeWrite = func(int) error {
data, merr := json.Marshal(&overrideRecord{Overrides: Overrides{VCPUs: ptr(16)}})
require.NoError(t, merr)
_, perr := realKV.Put(ctx, testAccount, data)
return perr
}
svc.overrides = race
_, err := svc.SealAccountOverrides(ctx, testAccount)
assert.True(t, awserrors.IsErrorCode(err, awserrors.ErrorInternalError), "got %v", err)
assert.Equal(t, sealMaxAttempts, race.writeCalls)
entry, err := realKV.Get(ctx, testAccount)
require.NoError(t, err)
assert.NotEqual(t, OverrideStateSealed, decodeOverride(t, entry).State, "the seal's own write never landed")
}
// A store error on the seal's read reports failed, never written.
func TestSealStoreErrorWritesNothing(t *testing.T) {
svc, js := newSealService(t)
ctx := context.Background()
svc.SetAccountTerminationChecker(terminatingChecker(testAccount))
before := lastOverrideSeq(t, js)
svc.overrides = &errGetKV{KeyValue: svc.overrides, err: errors.New("kv unavailable")}
_, err := svc.SealAccountOverrides(ctx, testAccount)
assert.True(t, awserrors.IsErrorCode(err, awserrors.ErrorInternalError), "got %v", err)
assert.Equal(t, before, lastOverrideSeq(t, js))
}
func TestIsRevisionConflictRecognisesTombstoneRetry(t *testing.T) {
assert.True(t, isRevisionConflict(jetstream.ErrKeyExists))
assert.True(t, isRevisionConflict(jetstream.ErrKeyRevisionMismatch))
assert.True(t, isRevisionConflict(&jetstream.APIError{ErrorCode: jetstream.JSErrCodeStreamWrongLastSequence}))
assert.True(t, isRevisionConflict(&jetstream.APIError{ErrorCode: jetstream.JSErrCodeStreamWrongLastSequenceConstant}))
assert.True(t, isRevisionConflict(fmt.Errorf("wrapped: %w", &jetstream.APIError{ErrorCode: jetstream.JSErrCodeStreamWrongLastSequence})))
assert.False(t, isRevisionConflict(errors.New("unrelated failure")))
assert.False(t, isRevisionConflict(nil))
}
// Clearing never writes when there is nothing active to clear: an absent key,
// a tombstoned key, and a sealed key hidden behind a stale not-found Get all
// leave the stream exactly where they found it.
func TestClearOnMissingKeyWritesNothing(t *testing.T) {
t.Run("absent", func(t *testing.T) {
svc, js := newSealService(t)
ctx := context.Background()
before := lastOverrideSeq(t, js)
require.NoError(t, svc.PutAccountQuota(ctx, testAccount, Overrides{}, "operator"))
assert.Equal(t, before, lastOverrideSeq(t, js))
})
t.Run("tombstone", func(t *testing.T) {
svc, js := newSealService(t)
ctx := context.Background()
require.NoError(t, svc.PutAccountQuota(ctx, testAccount, Overrides{VCPUs: ptr(8)}, "operator"))
require.NoError(t, svc.overrides.Delete(ctx, testAccount))
before := lastOverrideSeq(t, js)
require.NoError(t, svc.PutAccountQuota(ctx, testAccount, Overrides{}, "operator"))
assert.Equal(t, before, lastOverrideSeq(t, js))
})
t.Run("sealed_hidden_by_stale_not_found", func(t *testing.T) {
svc, js := newSealService(t)
ctx := context.Background()
svc.SetAccountTerminationChecker(terminatingChecker(testAccount))
sealed, err := svc.SealAccountOverrides(ctx, testAccount)
require.NoError(t, err)
before := lastOverrideSeq(t, js)
realKV := svc.overrides
svc.overrides = &staleNotFoundKV{KeyValue: realKV}
require.NoError(t, svc.PutAccountQuota(ctx, testAccount, Overrides{}, "operator"))
assert.Equal(t, before, lastOverrideSeq(t, js))
entry, err := realKV.Get(ctx, testAccount)
require.NoError(t, err)
rec := decodeOverride(t, entry)
assert.Equal(t, OverrideStateSealed, rec.State)
assert.Equal(t, sealed.Revision, entry.Revision())
})
}
// A seal against an absent or a tombstoned key creates the sealed record.
func TestSealOnMissingKeyCreatesSealedRecord(t *testing.T) {
for _, state := range []string{"absent", "tombstone"} {
t.Run(state, func(t *testing.T) {
svc, _ := newSealService(t)
ctx := context.Background()
svc.SetAccountTerminationChecker(terminatingChecker(testAccount))
if state == "tombstone" {
require.NoError(t, svc.PutAccountQuota(ctx, testAccount, Overrides{VCPUs: ptr(8)}, "operator"))
require.NoError(t, svc.overrides.Delete(ctx, testAccount))
}
_, err := svc.overrides.Get(ctx, testAccount)
require.ErrorIs(t, err, jetstream.ErrKeyNotFound)
seal, err := svc.SealAccountOverrides(ctx, testAccount)
require.NoError(t, err)
entry, err := svc.overrides.Get(ctx, testAccount)
require.NoError(t, err)
assert.Equal(t, seal.Revision, entry.Revision())
assert.Equal(t, OverrideStateSealed, decodeOverride(t, entry).State)
})
}
}
// alwaysConflictThenErrorKV answers Get as absent and every Create attempt
// with a conflict, then a store error on the final attempt: a recording
// wrapper proving the seal tried a real write on every attempt before giving
// up, rather than ever reporting success from an observed absence.
type alwaysConflictThenErrorKV struct {
jetstream.KeyValue
calls int
}
func (f *alwaysConflictThenErrorKV) Get(context.Context, string) (jetstream.KeyValueEntry, error) {
return nil, jetstream.ErrKeyNotFound
}
func (f *alwaysConflictThenErrorKV) Create(context.Context, string, []byte, ...jetstream.KVCreateOpt) (uint64, error) {
f.calls++
if f.calls < sealMaxAttempts {
return 0, jetstream.ErrKeyExists
}
return 0, errors.New("store unavailable")
}
func TestSealNeverSucceedsWithoutAWrite(t *testing.T) {
svc, _ := newSealService(t)
ctx := context.Background()
svc.SetAccountTerminationChecker(terminatingChecker(testAccount))
fake := &alwaysConflictThenErrorKV{}
svc.overrides = fake
seal, err := svc.SealAccountOverrides(ctx, testAccount)
assert.Error(t, err)
assert.Nil(t, seal)
assert.Equal(t, sealMaxAttempts, fake.calls, "every attempt must have tried a real write before giving up")
}
// An account IAM does not hold as TERMINATING refuses the seal before any
// override-KV write, whether the key is present or absent.
func TestSealRefusedForActiveAccount(t *testing.T) {
for _, state := range []string{"present", "absent"} {
t.Run(state, func(t *testing.T) {
svc, js := newSealService(t)
ctx := context.Background()
if state == "present" {
require.NoError(t, svc.PutAccountQuota(ctx, testAccount, Overrides{VCPUs: ptr(8)}, "operator"))
}
svc.SetAccountTerminationChecker(&stubTerminationChecker{terminating: map[string]bool{}})
before := lastOverrideSeq(t, js)
_, err := svc.SealAccountOverrides(ctx, testAccount)
assert.True(t, awserrors.IsErrorCode(err, awserrors.ErrorIncorrectState), "got %v", err)
assert.Equal(t, before, lastOverrideSeq(t, js))
})
}
}
func TestSealRefusedForMissingIAMRecord(t *testing.T) {
svc, js := newSealService(t)
ctx := context.Background()
svc.SetAccountTerminationChecker(&stubTerminationChecker{terminating: map[string]bool{}})
before := lastOverrideSeq(t, js)
_, err := svc.SealAccountOverrides(ctx, testAccount)
assert.True(t, awserrors.IsErrorCode(err, awserrors.ErrorIncorrectState), "got %v", err)
assert.Equal(t, before, lastOverrideSeq(t, js))
}
func TestSealRefusedWhenIAMReadFails(t *testing.T) {
svc, js := newSealService(t)
ctx := context.Background()
svc.SetAccountTerminationChecker(&stubTerminationChecker{err: errors.New("iam unavailable")})
before := lastOverrideSeq(t, js)
_, err := svc.SealAccountOverrides(ctx, testAccount)
assert.True(t, awserrors.IsErrorCode(err, awserrors.ErrorInternalError), "got %v", err)
assert.Equal(t, before, lastOverrideSeq(t, js))
}
func TestSealRefusedWithNoCheckerWired(t *testing.T) {
svc, js := newSealService(t)
ctx := context.Background()
before := lastOverrideSeq(t, js)
_, err := svc.SealAccountOverrides(ctx, testAccount)
assert.True(t, awserrors.IsErrorCode(err, awserrors.ErrorInternalError), "got %v", err)
assert.Equal(t, before, lastOverrideSeq(t, js))
}
func TestSealAcceptedForTerminatingAccount(t *testing.T) {
svc, _ := newSealService(t)
ctx := context.Background()
svc.SetAccountTerminationChecker(terminatingChecker(testAccount))
seal, err := svc.SealAccountOverrides(ctx, testAccount)
require.NoError(t, err)
assert.NotZero(t, seal.Revision)
entry, err := svc.overrides.Get(ctx, testAccount)
require.NoError(t, err)
assert.Equal(t, seal.Revision, entry.Revision())
}
// A refused seal, on a gateway that disabled quotas and so holds no override
// bucket, never creates one.
func TestSealRefusalDoesNotCreateBucket(t *testing.T) {
t.Run("not_terminating", func(t *testing.T) {
svc, js := newDisabledSealService(t)
ctx := context.Background()
svc.SetAccountTerminationChecker(&stubTerminationChecker{terminating: map[string]bool{}})
_, err := svc.SealAccountOverrides(ctx, testAccount)
assert.True(t, awserrors.IsErrorCode(err, awserrors.ErrorIncorrectState), "got %v", err)
_, err = js.KeyValue(context.Background(), KVBucketAccountQuota)
assert.ErrorIs(t, err, jetstream.ErrBucketNotFound)
})
t.Run("iam_read_fails", func(t *testing.T) {
svc, js := newDisabledSealService(t)
ctx := context.Background()
svc.SetAccountTerminationChecker(&stubTerminationChecker{err: errors.New("iam unavailable")})
_, err := svc.SealAccountOverrides(ctx, testAccount)
assert.True(t, awserrors.IsErrorCode(err, awserrors.ErrorInternalError), "got %v", err)
_, err = js.KeyValue(context.Background(), KVBucketAccountQuota)
assert.ErrorIs(t, err, jetstream.ErrBucketNotFound)
})
}
// A seal accepted on a gateway whose quotas were disabled at startup creates
// the override bucket at seal time, never at startup.
func TestSealOnQuotaDisabledGatewayCreatesBucket(t *testing.T) {
svc, js := newDisabledSealService(t)
ctx := context.Background()
svc.SetAccountTerminationChecker(terminatingChecker(testAccount))
seal, err := svc.SealAccountOverrides(ctx, testAccount)
require.NoError(t, err)
kv, err := js.KeyValue(context.Background(), KVBucketAccountQuota)
require.NoError(t, err)
entry, err := kv.Get(context.Background(), testAccount)
require.NoError(t, err)
assert.Equal(t, seal.Revision, entry.Revision())
assert.Equal(t, OverrideStateSealed, decodeOverride(t, entry).State)
}
// A sealed record stays sealed after the service is rebuilt over the same
// JetStream, every read and write refuses it, and a re-seal gets a higher
// revision.
func TestSealSurvivesServiceReconstruction(t *testing.T) {
_, _, js := testutil.StartTestJetStream(t)
ctx := context.Background()
kv, err := js.CreateKeyValue(ctx, jetstream.KeyValueConfig{Bucket: KVBucketAccountQuota, History: 1})
require.NoError(t, err)
svc := New(baseLimits(), nil)
svc.SetOverrides(kv)
svc.SetJetStream(js)
svc.SetAccountTerminationChecker(terminatingChecker(testAccount))
first, err := svc.SealAccountOverrides(ctx, testAccount)
require.NoError(t, err)
rebuiltKV, err := js.KeyValue(ctx, KVBucketAccountQuota)
require.NoError(t, err)
rebuilt := New(baseLimits(), nil)
rebuilt.SetOverrides(rebuiltKV)
rebuilt.SetJetStream(js)
entry, err := rebuilt.overrides.Get(ctx, testAccount)
require.NoError(t, err)
assert.Equal(t, OverrideStateSealed, decodeOverride(t, entry).State)
_, _, err = rebuilt.GetAccountQuota(ctx, testAccount)
assert.True(t, awserrors.IsErrorCode(err, awserrors.ErrorBlocked))
assert.True(t, awserrors.IsErrorCode(rebuilt.PutAccountQuota(ctx, testAccount, Overrides{VCPUs: ptr(8)}, "operator"), awserrors.ErrorBlocked))
assert.True(t, awserrors.IsErrorCode(rebuilt.PutAccountQuota(ctx, testAccount, Overrides{}, "operator"), awserrors.ErrorBlocked))
limits, err := rebuilt.limitsFor(ctx, testAccount)
assert.True(t, awserrors.IsErrorCode(err, awserrors.ErrorBlocked))
assert.Zero(t, limits)
rebuilt.SetAccountTerminationChecker(terminatingChecker(testAccount))
second, err := rebuilt.SealAccountOverrides(ctx, testAccount)
require.NoError(t, err)
assert.Greater(t, second.Revision, first.Revision)
}
func TestSealRefusesEmptyInvalidAndGlobalAccount(t *testing.T) {
svc, _ := newSealService(t)
ctx := context.Background()
_, err := svc.SealAccountOverrides(ctx, "")
assert.True(t, awserrors.IsErrorCode(err, awserrors.ErrorMissingParameter), "got %v", err)
_, err = svc.SealAccountOverrides(ctx, "not-an-account")
assert.True(t, awserrors.IsErrorCode(err, awserrors.ErrorInvalidParameterValue), "got %v", err)
_, err = svc.SealAccountOverrides(ctx, awsidentifiers.GlobalAccountID)
assert.True(t, awserrors.IsErrorCode(err, awserrors.ErrorInvalidParameterValue), "got %v", err)
}
func TestSealLeavesOtherAccountsUntouched(t *testing.T) {
svc, _ := newSealService(t)
ctx := context.Background()
accountB := "222222222222"
require.NoError(t, svc.PutAccountQuota(ctx, accountB, Overrides{VCPUs: ptr(8)}, "operator"))
svc.SetAccountTerminationChecker(terminatingChecker(testAccount))
_, err := svc.SealAccountOverrides(ctx, testAccount)
require.NoError(t, err)
over, _, err := svc.GetAccountQuota(ctx, accountB)
require.NoError(t, err)
require.NotNil(t, over.VCPUs)
assert.Equal(t, 8, *over.VCPUs)
}
// A record written before the state field existed has none and is treated as
// active by both reads and writes; a write on it preserves the old shape.
func TestRecordWithoutStateIsActive(t *testing.T) {
svc, _ := newSealService(t)
ctx := context.Background()
data, err := json.Marshal(Overrides{VCPUs: ptr(8)})
require.NoError(t, err)
_, err = svc.overrides.Put(ctx, testAccount, data)
require.NoError(t, err)
over, limits, err := svc.GetAccountQuota(ctx, testAccount)
require.NoError(t, err)
require.NotNil(t, over.VCPUs)
assert.Equal(t, 8, limits.VCPUs)
require.NoError(t, svc.PutAccountQuota(ctx, testAccount, Overrides{VCPUs: ptr(16)}, "operator"))
entry, err := svc.overrides.Get(ctx, testAccount)
require.NoError(t, err)
assert.NotContains(t, string(entry.Value()), `"state"`)
}
// A record whose state is neither active nor sealed fails every read and
// write closed, rather than being treated as active.
func TestUnknownStateFailsClosed(t *testing.T) {
svc, _ := newSealService(t)
ctx := context.Background()
_, err := svc.overrides.Put(ctx, testAccount, []byte(`{"state":"frozen"}`))
require.NoError(t, err)
_, _, err = svc.GetAccountQuota(ctx, testAccount)
assert.True(t, awserrors.IsErrorCode(err, awserrors.ErrorInternalError), "got %v", err)
limits, err := svc.limitsFor(ctx, testAccount)
assert.True(t, awserrors.IsErrorCode(err, awserrors.ErrorInternalError), "got %v", err)
assert.Zero(t, limits)
assert.True(t, awserrors.IsErrorCode(svc.PutAccountQuota(ctx, testAccount, Overrides{VCPUs: ptr(8)}, "operator"), awserrors.ErrorInternalError))
assert.True(t, awserrors.IsErrorCode(svc.PutAccountQuota(ctx, testAccount, Overrides{}, "operator"), awserrors.ErrorInternalError))
}
// A sealed record is Blocked for admission's own resolver, which every
// caller (admin read, ELBv2 DescribeAccountLimits, every Enforce* method)
// shares.
func TestAdmissionOnSealedRecordIsBlocked(t *testing.T) {
svc, _ := newSealService(t)
ctx := context.Background()
svc.SetAccountTerminationChecker(terminatingChecker(testAccount))
_, err := svc.SealAccountOverrides(ctx, testAccount)
require.NoError(t, err)
limits, err := svc.limitsFor(ctx, testAccount)
assert.True(t, awserrors.IsErrorCode(err, awserrors.ErrorBlocked), "got %v", err)
assert.Zero(t, limits)
assert.Error(t, svc.CheckVCPU(ctx, testAccount, 1))
assert.True(t, awserrors.IsErrorCode(svc.CheckVCPU(ctx, testAccount, 1), awserrors.ErrorBlocked))
}
// The existing read-failure fallback in limitsFor (a decode or store error
// falls back to the configured limits) is unchanged by the seal work: it is
// distinct from a successfully read sealed record, which refuses instead.
func TestAdmissionReadFailureFallbackUnchanged(t *testing.T) {
svc, kv := newOverrideService(t)
_, err := kv.Put(t.Context(), testAccount, []byte("{not json"))
require.NoError(t, err)
limits, err := svc.limitsFor(t.Context(), testAccount)
require.NoError(t, err, "a decode failure must still fall back, not error, from limitsFor")
require.Equal(t, baseLimits(), limits)
}
+9 -2
View File
@@ -23,7 +23,10 @@ func (s *Service) EnforceVolumeCreate(ctx context.Context, natsConn *nats.Conn,
if err != nil {
return err
}
limits := s.limitsFor(ctx, accountID)
limits, err := s.limitsFor(ctx, accountID)
if err != nil {
return err
}
if err := exceeds(len(out.Volumes), 1, limits.Volumes); err != nil {
return err
}
@@ -43,7 +46,11 @@ func (s *Service) EnforceVolumeModify(ctx context.Context, natsConn *nats.Conn,
if err != nil {
return err
}
return exceeds(total-oldGiB, newGiB, s.limitsFor(ctx, accountID).VolumesGiB)
limits, err := s.limitsFor(ctx, accountID)
if err != nil {
return err
}
return exceeds(total-oldGiB, newGiB, limits.VolumesGiB)
}
// volumeUsage sums the account's volume sizes in GiB via a single DescribeVolumes
+5 -1
View File
@@ -37,7 +37,11 @@ func (s *Service) CheckVCPU(ctx context.Context, accountID string, want int) err
if err != nil {
return err
}
return exceeds(current, want, s.limitsFor(ctx, accountID).VCPUs)
limits, err := s.limitsFor(ctx, accountID)
if err != nil {
return err
}
return exceeds(current, want, limits.VCPUs)
}
// casVCPU applies next to accountID's counter under bounded JetStream CAS so
+104
View File
@@ -0,0 +1,104 @@
package dns
import (
"context"
"errors"
"fmt"
"strings"
"time"
nsconfig "github.com/mulgadc/northstar/pkg/config"
"github.com/nats-io/nats.go"
reconcilelock "github.com/mulgadc/spinifex/spinifex/domains/network/reconcile"
)
// delegateLockPoll is how often Delegate retries the zone-writer election
// while another node holds it.
var delegateLockPoll = time.Second
// PlatformDelegation returns the NS set the base zone publishes for name and
// the zone it read. ok is false when name is not strictly below the base
// domain, so the zone's parent is somewhere else.
func (w *Writer) PlatformDelegation(name string) (servers []string, zone string, ok bool, err error) {
if !w.Enabled() {
return nil, "", false, nil
}
base := normalizeZone(w.baseDomain)
n := normalizeZone(name)
if base == "" || !strings.HasSuffix(n, "."+base) {
return nil, "", false, nil
}
cfg, exists, err := nsconfig.ReadZoneRaw(w.s3cfg, base)
if err != nil {
return nil, base, true, fmt.Errorf("read zone %s: %w", base, err)
}
if !exists {
return nil, base, true, nil
}
label := relativeLabel(n, base)
for _, r := range cfg.Records {
if r.Type == nsconfig.TypeNS && strings.EqualFold(r.Domain, label) {
servers = append(servers, r.Address)
}
}
return servers, base, true, nil
}
// Delegate writes, or with remove withdraws, an NS set in the base zone that
// delegates each name to this cluster's nameservers. It takes the zone-writer
// election first, so it never races the reconcile's own writes to the zone.
func (w *Writer) Delegate(ctx context.Context, nc *nats.Conn, holder string, names []string, remove bool) (*ChangeResult, error) {
if !w.Enabled() {
return nil, errors.New("northstar is not configured on this node")
}
base := normalizeZone(w.baseDomain)
values := make([]string, 0, len(w.nameservers))
for _, ns := range w.nameservers {
values = append(values, ns.Host+"."+base+".")
}
batch := &ChangeBatch{}
for _, name := range names {
n := normalizeZone(name)
if !strings.HasSuffix(n, "."+base) {
return nil, fmt.Errorf("%s is not below the base domain %s", name, base)
}
if ReservedZoneName(n, base, w.internalDomain) {
return nil, fmt.Errorf("%s is a platform name and cannot be delegated", name)
}
if remove {
for _, v := range values {
batch.Changes = append(batch.Changes, Change{Action: ActionDelete, Zone: base, Name: n + ".", Type: "NS", Value: v})
}
continue
}
batch.Changes = append(batch.Changes, Change{Action: ActionUpsertSet, Zone: base, Name: n + ".", Type: "NS", Values: values})
}
if len(batch.Changes) == 0 {
return &ChangeResult{}, nil
}
release, err := w.awaitWriterElection(ctx, nc, holder)
if err != nil {
return nil, err
}
defer release()
return w.ApplyBatch(batch)
}
func (w *Writer) awaitWriterElection(ctx context.Context, nc *nats.Conn, holder string) (func(), error) {
if nc == nil {
return func() {}, nil
}
tick := time.NewTicker(delegateLockPoll)
defer tick.Stop()
for {
if release, ok := reconcilelock.AcquireLeader(ctx, nc, KVBucketDNSReconcile, holder); ok {
return release, nil
}
select {
case <-ctx.Done():
return nil, fmt.Errorf("another node is writing the zone: %w", ctx.Err())
case <-tick.C:
}
}
}
+82
View File
@@ -0,0 +1,82 @@
package dns
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
testutil "github.com/mulgadc/spinifex/internal/testkit"
reconcilelock "github.com/mulgadc/spinifex/spinifex/domains/network/reconcile"
)
func TestDelegateWritesAndWithdrawsPlatformDelegations(t *testing.T) {
w, _ := newMultiNodeTestWriter(t)
_, err := w.Delegate(t.Context(), nil, "node1", []string{"a.r53e2e.spx3.net", "B.r53e2e.spx3.net."}, false)
require.NoError(t, err)
for _, name := range []string{"a.r53e2e.spx3.net.", "b.r53e2e.spx3.net"} {
servers, zone, ok, err := w.PlatformDelegation(name)
require.NoError(t, err)
assert.True(t, ok)
assert.Equal(t, "spx3.net", zone)
assert.ElementsMatch(t, []string{"ns1.spx3.net.", "ns2.spx3.net."}, servers, name)
}
servers, _, ok, err := w.PlatformDelegation("c.r53e2e.spx3.net")
require.NoError(t, err)
assert.True(t, ok, "a name below the base domain is decided by the base zone")
assert.Empty(t, servers)
_, err = w.Delegate(t.Context(), nil, "node1", []string{"a.r53e2e.spx3.net"}, true)
require.NoError(t, err)
servers, _, _, err = w.PlatformDelegation("a.r53e2e.spx3.net")
require.NoError(t, err)
assert.Empty(t, servers)
servers, _, _, err = w.PlatformDelegation("b.r53e2e.spx3.net")
require.NoError(t, err)
assert.Len(t, servers, 2, "withdrawing one delegation leaves the others")
}
func TestPlatformDelegationIgnoresNamesOutsideTheBaseDomain(t *testing.T) {
w, _ := newMultiNodeTestWriter(t)
for _, name := range []string{"example.com", "spx3.net", "notspx3.net"} {
_, _, ok, err := w.PlatformDelegation(name)
require.NoError(t, err)
assert.False(t, ok, name)
}
}
func TestDelegateRefusesPlatformAndForeignNames(t *testing.T) {
w, _ := newMultiNodeTestWriter(t)
for _, name := range []string{"ns1.spx3.net", "x.elb.spx3.net", "compute.spx3.net", "example.com", "spx3.net"} {
_, err := w.Delegate(t.Context(), nil, "node1", []string{"ok.spx3.net", name}, false)
require.Error(t, err, name)
}
servers, _, _, err := w.PlatformDelegation("ok.spx3.net")
require.NoError(t, err)
assert.Empty(t, servers, "a refused batch writes nothing")
}
func TestDelegateWaitsForTheZoneWriterElection(t *testing.T) {
w, _ := newMultiNodeTestWriter(t)
_, nc, _ := testutil.StartTestJetStream(t)
prev := delegateLockPoll
delegateLockPoll = 10 * time.Millisecond
t.Cleanup(func() { delegateLockPoll = prev })
release, ok := reconcilelock.AcquireLeader(t.Context(), nc, KVBucketDNSReconcile, "node2")
require.True(t, ok)
ctx, cancel := context.WithTimeout(t.Context(), 100*time.Millisecond)
defer cancel()
_, err := w.Delegate(ctx, nc, "node1", []string{"a.spx3.net"}, false)
require.ErrorContains(t, err, "another node is writing the zone")
release()
_, err = w.Delegate(t.Context(), nc, "node1", []string{"a.spx3.net"}, false)
require.NoError(t, err)
servers, _, _, err := w.PlatformDelegation("a.spx3.net")
require.NoError(t, err)
assert.Len(t, servers, 2)
}
+63 -12
View File
@@ -10,17 +10,18 @@ import (
"github.com/mulgadc/spinifex/spinifex/bootstrap/config"
)
// NameserverSeeds derives one nameserver (nsN → node IP) per cluster node that
// runs northstar and is reachable by its peers, ordered deterministically so
// every node computes the same set. Falls back to the local node when none
// qualifies (single-node / dev).
// NameserverSeeds derives the published nameservers (nsN → node IP) from the
// cluster nodes that run northstar and are reachable by their peers: the
// [northstar] pins first, then the rest in name order, capped at
// nameserver_count or MaxNameservers. Every node computes the same set. Falls
// back to the local node when none qualifies (single-node / dev).
//
// Both zone creators build from this: the northstar bootstrap seed and the
// writer's on-demand materialisation. Both are create-if-absent and neither
// overwrites, so they must agree — a set derived independently would be baked
// into the zone permanently by whichever creator ran first.
func NameserverSeeds(cluster *config.ClusterConfig) []nsconfig.NameserverSeed {
names := usableNorthstarNodeNames(cluster)
names := publishedNameserverNodes(usableNorthstarNodeNames(cluster), cluster.Northstar)
// A zone needs an SOA and at least one NS whatever the topology, so unlike the
// resolver list this falls back to the local node. On single-node dev that node
// is loopback, which is correct there: it is the zone's only reader.
@@ -29,17 +30,37 @@ func NameserverSeeds(cluster *config.ClusterConfig) []nsconfig.NameserverSeed {
}
seeds := make([]nsconfig.NameserverSeed, 0, len(names))
for i, name := range names {
seeds = append(seeds, nsconfig.NameserverSeed{
Host: fmt.Sprintf("ns%d", i+1),
IP: nameserverIP(cluster.Nodes[name]),
})
for _, ns := range nameserverAssignments(cluster, names) {
seeds = append(seeds, nsconfig.NameserverSeed{Host: ns.Host, IP: ns.IP})
}
return seeds
}
// ResolverNameserverIPs returns the WAN IPs of cluster nodes running northstar,
// in the same deterministic order as the seeded nameservers. vpcd's per-tap DNS
// Nameserver is one published nameserver: its label under the base domain,
// the node it is assigned to and that node's address.
type Nameserver struct {
Host string
Node string
IP string
}
// PublishedNameservers returns the nsN assignment NameserverSeeds publishes,
// with the node behind each name. Unlike NameserverSeeds it has no local-node
// fallback, so an empty result means no node qualifies.
func PublishedNameservers(cluster *config.ClusterConfig) []Nameserver {
return nameserverAssignments(cluster, publishedNameserverNodes(usableNorthstarNodeNames(cluster), cluster.Northstar))
}
func nameserverAssignments(cluster *config.ClusterConfig, names []string) []Nameserver {
out := make([]Nameserver, 0, len(names))
for i, name := range names {
out = append(out, Nameserver{Host: fmt.Sprintf("ns%d", i+1), Node: name, IP: nameserverIP(cluster.Nodes[name])})
}
return out
}
// ResolverNameserverIPs returns the WAN IPs of every cluster node running
// northstar in name order, not only the published nameservers. vpcd's per-tap DNS
// shim uses these as forward targets (northstar's :5300 listener), so internal
// names resolve authoritatively and external names via upstream forwarders.
//
@@ -55,6 +76,36 @@ func ResolverNameserverIPs(cluster *config.ClusterConfig) []string {
return ips
}
// publishedNameserverNodes orders the usable northstar nodes for publication
// and truncates them to the configured count. A pinned node that is not usable
// is skipped rather than published with an address its peers cannot reach.
func publishedNameserverNodes(usable []string, ns config.NorthstarClusterConfig) []string {
limit := ns.NameserverCount
if limit <= 0 || limit > config.MaxNameservers {
limit = config.MaxNameservers
}
ok := make(map[string]bool, len(usable))
for _, name := range usable {
ok[name] = true
}
ordered := make([]string, 0, len(usable))
for _, name := range ns.NameserverNodes {
if ok[name] {
ordered = append(ordered, name)
delete(ok, name)
}
}
for _, name := range usable {
if ok[name] {
ordered = append(ordered, name)
}
}
if len(ordered) > limit {
ordered = ordered[:limit]
}
return ordered
}
// usableNorthstarNodeNames returns the deterministically ordered nodes that run
// northstar and can be reached by their peers.
//
+61
View File
@@ -1,8 +1,10 @@
package dns
import (
"fmt"
"testing"
nsconfig "github.com/mulgadc/northstar/pkg/config"
"github.com/mulgadc/spinifex/spinifex/bootstrap/config"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -132,3 +134,62 @@ func TestResolverNameserverIPs(t *testing.T) {
}}
assert.Empty(t, ResolverNameserverIPs(dev))
}
func northstarNodes(n int) map[string]config.Config {
nodes := make(map[string]config.Config, n)
for i := 1; i <= n; i++ {
nodes[fmt.Sprintf("node%02d", i)] = config.Config{
AdvertiseIP: fmt.Sprintf("10.0.0.%d", i),
Northstar: config.NorthstarConfig{ConfigPath: "/etc/n.toml"},
}
}
return nodes
}
func seedIPs(seeds []nsconfig.NameserverSeed) []string {
ips := make([]string, 0, len(seeds))
for i, s := range seeds {
if s.Host != fmt.Sprintf("ns%d", i+1) {
return []string{"misnumbered " + s.Host}
}
ips = append(ips, s.IP)
}
return ips
}
// A large cluster publishes at most four nameservers, while every northstar
// node stays a resolver backend for the guest shim.
func TestNameserverSeedsCappedAtFour(t *testing.T) {
cluster := &config.ClusterConfig{Node: "node01", Nodes: northstarNodes(12)}
assert.Equal(t, []string{"10.0.0.1", "10.0.0.2", "10.0.0.3", "10.0.0.4"}, seedIPs(NameserverSeeds(cluster)))
assert.Len(t, ResolverNameserverIPs(cluster), 12)
}
func TestNameserverSeedsHonourCountAndPins(t *testing.T) {
nodes := northstarNodes(6)
nodes["node06"] = config.Config{AdvertiseIP: "127.0.0.1", Northstar: config.NorthstarConfig{ConfigPath: "/etc/n.toml"}}
for name, tc := range map[string]struct {
ns config.NorthstarClusterConfig
want []string
}{
"count below the node count": {config.NorthstarClusterConfig{NameserverCount: 2}, []string{"10.0.0.1", "10.0.0.2"}},
"pins first, rest in name order": {
config.NorthstarClusterConfig{NameserverNodes: []string{"node05", "node03"}},
[]string{"10.0.0.5", "10.0.0.3", "10.0.0.1", "10.0.0.2"},
},
"pins fill the count": {
config.NorthstarClusterConfig{NameserverCount: 2, NameserverNodes: []string{"node04", "node02"}},
[]string{"10.0.0.4", "10.0.0.2"},
},
"unusable pin is skipped": {
config.NorthstarClusterConfig{NameserverCount: 2, NameserverNodes: []string{"node06", "node05"}},
[]string{"10.0.0.5", "10.0.0.1"},
},
} {
t.Run(name, func(t *testing.T) {
cluster := &config.ClusterConfig{Node: "node01", Nodes: nodes, Northstar: tc.ns}
assert.Equal(t, tc.want, seedIPs(NameserverSeeds(cluster)))
})
}
}
+179
View File
@@ -0,0 +1,179 @@
package dns
import (
"context"
"errors"
"fmt"
"net"
"slices"
"strings"
"time"
mdns "github.com/miekg/dns"
)
// RootServers are the IANA root servers, by IPv4 address.
var RootServers = []string{
"198.41.0.4", "170.247.170.2", "192.33.4.12", "199.7.91.13", "192.203.230.10",
"192.5.5.241", "192.112.36.4", "198.97.190.53", "192.36.148.17", "192.58.128.30",
"193.0.14.129", "199.7.83.42", "202.12.27.33",
}
const (
parentQueryTimeout = 3 * time.Second
maxReferrals = 16
)
// ParentLookup reads a domain's delegation from its parent zone's own servers,
// found by following referrals down from the roots. It never asks a recursive
// resolver, whose cache can hold a delegation the parent has since withdrawn.
type ParentLookup struct {
// Roots are the servers the walk starts from; nil means RootServers.
Roots []string
// Exchange sends one query; nil means UDP to port 53, retried over TCP
// when truncated.
Exchange func(ctx context.Context, m *mdns.Msg, server string) (*mdns.Msg, error)
// Resolve finds a name server's addresses when a referral has no glue.
Resolve func(ctx context.Context, host string) ([]string, error)
}
// Delegation returns the NS names the parent of name publishes for it and the
// parent zone that answered. An empty set with a nil error means the parent
// answered and does not delegate name.
func (p *ParentLookup) Delegation(ctx context.Context, name string) (servers []string, parent string, err error) {
target := mdns.Fqdn(strings.ToLower(name))
addrs := p.Roots
if addrs == nil {
addrs = RootServers
}
zone := "."
for range maxReferrals {
resp, err := p.ask(ctx, target, addrs)
if err != nil {
return nil, zone, fmt.Errorf("ask the %s servers: %w", zone, err)
}
owner, ns := referral(resp, target)
if owner == "" || resp.Authoritative {
// The servers hold target's own zone or its parent: what they list
// for target is the answer, and nothing listed is no delegation.
return answerNS(resp, target), strings.TrimSuffix(zone, "."), nil
}
if owner == target {
return ns, strings.TrimSuffix(zone, "."), nil
}
if !mdns.IsSubDomain(zone, owner) || owner == zone {
return nil, zone, fmt.Errorf("the %s servers referred %s back to %s", zone, target, owner)
}
zone = owner
if addrs, err = p.addresses(ctx, resp, ns); err != nil {
return nil, zone, err
}
}
return nil, zone, fmt.Errorf("more than %d referrals for %s", maxReferrals, target)
}
// ask sends the query to each server in turn and returns the first answer
// that is NOERROR or NXDOMAIN.
func (p *ParentLookup) ask(ctx context.Context, target string, servers []string) (*mdns.Msg, error) {
m := new(mdns.Msg)
m.SetQuestion(target, mdns.TypeNS)
m.RecursionDesired = false
exchange := p.Exchange
if exchange == nil {
exchange = exchangeUDP
}
lastErr := errors.New("no servers")
for _, s := range servers {
resp, err := exchange(ctx, m, s)
switch {
case err != nil:
lastErr = err
case resp.Rcode != mdns.RcodeSuccess && resp.Rcode != mdns.RcodeNameError:
lastErr = fmt.Errorf("%s answered %s", s, mdns.RcodeToString[resp.Rcode])
default:
return resp, nil
}
if ctx.Err() != nil {
return nil, ctx.Err()
}
}
return nil, lastErr
}
// referral returns the deepest zone at or above target that the response's
// authority section delegates, and its NS names.
func referral(resp *mdns.Msg, target string) (string, []string) {
var owner string
var ns []string
for _, rr := range resp.Ns {
r, ok := rr.(*mdns.NS)
if !ok {
continue
}
o := strings.ToLower(r.Hdr.Name)
if !mdns.IsSubDomain(o, target) {
continue
}
if o != owner && (owner == "" || mdns.CountLabel(o) > mdns.CountLabel(owner)) {
owner, ns = o, nil
}
if o == owner {
ns = append(ns, strings.ToLower(r.Ns))
}
}
return owner, ns
}
func answerNS(resp *mdns.Msg, target string) []string {
var ns []string
for _, rr := range append(resp.Answer, resp.Ns...) {
if r, ok := rr.(*mdns.NS); ok && strings.EqualFold(r.Hdr.Name, target) {
ns = append(ns, strings.ToLower(r.Ns))
}
}
return ns
}
// addresses returns where to send the next query: the referral's glue, or a
// lookup of the server names when the parent gave none.
func (p *ParentLookup) addresses(ctx context.Context, resp *mdns.Msg, ns []string) ([]string, error) {
var addrs []string
for _, rr := range resp.Extra {
if a, ok := rr.(*mdns.A); ok && slices.ContainsFunc(ns, func(n string) bool { return strings.EqualFold(n, a.Hdr.Name) }) {
addrs = append(addrs, a.A.String())
}
}
if len(addrs) > 0 {
return addrs, nil
}
resolve := p.Resolve
if resolve == nil {
resolve = net.DefaultResolver.LookupHost
}
var lastErr error
for _, host := range ns {
found, err := resolve(ctx, strings.TrimSuffix(host, "."))
if err != nil {
lastErr = err
continue
}
for _, a := range found {
if ip := net.ParseIP(a); ip != nil && ip.To4() != nil {
addrs = append(addrs, a)
}
}
}
if len(addrs) == 0 {
return nil, fmt.Errorf("no address for any of %s: %w", strings.Join(ns, ", "), lastErr)
}
return addrs, nil
}
func exchangeUDP(ctx context.Context, m *mdns.Msg, server string) (*mdns.Msg, error) {
addr := net.JoinHostPort(server, "53")
resp, _, err := (&mdns.Client{Timeout: parentQueryTimeout}).ExchangeContext(ctx, m, addr)
if err == nil && resp.Truncated {
resp, _, err = (&mdns.Client{Net: "tcp", Timeout: parentQueryTimeout}).ExchangeContext(ctx, m, addr)
}
return resp, err
}
+139
View File
@@ -0,0 +1,139 @@
package dns_test
import (
"context"
"errors"
"testing"
mdns "github.com/miekg/dns"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/mulgadc/spinifex/spinifex/domains/dns"
)
// fakeTree is the DNS hierarchy as each server's address answers it.
type fakeTree struct {
servers map[string]func(q mdns.Question) *mdns.Msg
asked []string
}
func (f *fakeTree) exchange(_ context.Context, m *mdns.Msg, server string) (*mdns.Msg, error) {
f.asked = append(f.asked, server)
if m.RecursionDesired {
return nil, errors.New("recursion requested")
}
h, ok := f.servers[server]
if !ok {
return nil, errors.New("i/o timeout")
}
resp := h(m.Question[0])
rcode, aa := resp.Rcode, resp.Authoritative
resp.SetReply(m)
resp.Rcode, resp.Authoritative = rcode, aa
return resp, nil
}
func ns(owner string, hosts ...string) []mdns.RR {
var out []mdns.RR
for _, h := range hosts {
out = append(out, &mdns.NS{Hdr: mdns.RR_Header{Name: owner, Rrtype: mdns.TypeNS, Class: mdns.ClassINET, Ttl: 172800}, Ns: h})
}
return out
}
func glue(host, ip string) mdns.RR {
rr, _ := mdns.NewRR(host + " 172800 IN A " + ip)
return rr
}
func referralTo(owner string, extra []mdns.RR, hosts ...string) *mdns.Msg {
return &mdns.Msg{Ns: ns(owner, hosts...), Extra: extra}
}
func authoritative(rcode int) *mdns.Msg {
m := &mdns.Msg{}
m.Authoritative = true
m.Rcode = rcode
return m
}
func newTree() *fakeTree {
return &fakeTree{servers: map[string]func(mdns.Question) *mdns.Msg{
"10.0.0.1": func(q mdns.Question) *mdns.Msg {
if mdns.IsSubDomain("org.", q.Name) {
return referralTo("org.", nil, "a0.org-servers.test.") // no glue
}
return referralTo("com.", []mdns.RR{glue("a.gtld.test.", "10.0.1.1")}, "a.gtld.test.")
},
"10.0.1.1": func(q mdns.Question) *mdns.Msg {
switch {
case mdns.IsSubDomain("example.com.", q.Name):
return referralTo("example.com.", nil, "NS1.spx3.net.", "ns2.spx3.net.")
case mdns.IsSubDomain("deep.com.", q.Name):
return referralTo("deep.com.", []mdns.RR{glue("ns.deep.com.", "10.0.2.1")}, "ns.deep.com.")
default:
return authoritative(mdns.RcodeNameError)
}
},
"10.0.2.1": func(q mdns.Question) *mdns.Msg {
if q.Name == "shop.deep.com." {
return referralTo("shop.deep.com.", nil, "ns1.spx3.net.")
}
return authoritative(mdns.RcodeSuccess) // NODATA: not delegated
},
"10.0.3.1": func(q mdns.Question) *mdns.Msg {
return referralTo(q.Name, nil, "ns3.spx3.net.")
},
}}
}
func TestParentLookupFollowsReferralsToTheParent(t *testing.T) {
tree := newTree()
p := &dns.ParentLookup{
Roots: []string{"10.0.0.9", "10.0.0.1"}, // the first root is down
Exchange: tree.exchange,
Resolve: func(_ context.Context, host string) ([]string, error) {
if host == "a0.org-servers.test" {
return []string{"2001:db8::1", "10.0.3.1"}, nil
}
return nil, errors.New("no such host")
},
}
cases := []struct {
name string
servers []string
parent string
}{
{"Example.COM", []string{"ns1.spx3.net.", "ns2.spx3.net."}, "com"},
{"shop.deep.com.", []string{"ns1.spx3.net."}, "deep.com"},
{"other.deep.com", nil, "deep.com"},
{"unregistered.com", nil, "com"},
{"example.org", []string{"ns3.spx3.net."}, "org"},
}
for _, tc := range cases {
servers, parent, err := p.Delegation(context.Background(), tc.name)
require.NoError(t, err, tc.name)
assert.Equal(t, tc.servers, servers, tc.name)
assert.Equal(t, tc.parent, parent, tc.name)
}
}
func TestParentLookupFailsWhenNoServerAnswers(t *testing.T) {
tree := newTree()
tree.servers["10.0.1.1"] = func(mdns.Question) *mdns.Msg { return &mdns.Msg{MsgHdr: mdns.MsgHdr{Rcode: mdns.RcodeServerFailure}} }
p := &dns.ParentLookup{Roots: []string{"10.0.0.1"}, Exchange: tree.exchange}
_, parent, err := p.Delegation(context.Background(), "example.com")
require.ErrorContains(t, err, "SERVFAIL")
assert.Equal(t, "com.", parent)
}
func TestParentLookupRefusesAReferralUpwards(t *testing.T) {
tree := newTree()
tree.servers["10.0.1.1"] = func(mdns.Question) *mdns.Msg {
return referralTo(".", []mdns.RR{glue("a.root.test.", "10.0.0.1")}, "a.root.test.")
}
p := &dns.ParentLookup{Roots: []string{"10.0.0.1"}, Exchange: tree.exchange}
_, _, err := p.Delegation(context.Background(), "example.com")
require.Error(t, err)
}
+78
View File
@@ -0,0 +1,78 @@
package dns
import (
"context"
"time"
nsconfig "github.com/mulgadc/northstar/pkg/config"
"github.com/mulgadc/spinifex/spinifex/bootstrap/config"
"github.com/nats-io/nats.go"
)
// zoneReloadTimeout bounds one wait for every northstar server to load a zone.
const zoneReloadTimeout = 5 * time.Second
// ZonePublisher writes whole zone documents to the northstar zone store and
// asks every northstar server to load them, for callers that own a zone
// outright rather than editing records in one.
type ZonePublisher struct {
s3 *nsconfig.S3Config
nc *nats.Conn
cluster func() *config.ClusterConfig
}
// NewZonePublisher returns a publisher, or false when northstar's S3 zone store
// is not configured on this node. cluster is read on every reload, so a node
// joining changes how many acknowledgements are awaited.
func NewZonePublisher(cfg *config.Config, cluster func() *config.ClusterConfig, nc *nats.Conn) (*ZonePublisher, bool) {
zoneCfg, ok := zoneS3Config(cfg)
if !ok || nc == nil {
return nil, false
}
return &ZonePublisher{s3: zoneCfg.s3, nc: nc, cluster: cluster}, true
}
// PutZone writes apex's zone document.
func (p *ZonePublisher) PutZone(_ context.Context, apex string, body []byte) error {
return nsconfig.WriteZoneFile(p.s3, apex, body)
}
// DeleteZone removes apex's zone document.
func (p *ZonePublisher) DeleteZone(_ context.Context, apex string) error {
return nsconfig.DeleteZoneFile(p.s3, apex)
}
// Reload asks every northstar server to load apex and returns the version each
// reports, "" where it serves nothing. A server that failed to load reports a
// value no digest can equal. complete is whether every server answered,
// counted by node so a repeated acknowledgement cannot stand in for another.
func (p *ZonePublisher) Reload(ctx context.Context, apex string) ([]string, bool, error) {
want := NorthstarServerCount(p.cluster())
ctx, cancel := context.WithTimeout(ctx, zoneReloadTimeout)
defer cancel()
acks, err := RequestZoneReload(ctx, p.nc, apex, want)
if err != nil {
return nil, false, err
}
served := make([]string, 0, len(acks))
nodes := map[string]bool{}
for _, ack := range acks {
nodes[ack.Node] = true
if ack.Error != "" {
served = append(served, "error: "+ack.Error)
continue
}
served = append(served, ack.ETag)
}
return served, len(nodes) >= want, nil
}
// NorthstarServerCount is how many northstar servers the cluster runs, and so
// how many must acknowledge a reload. A cluster with none configured still runs
// the local one.
func NorthstarServerCount(cluster *config.ClusterConfig) int {
if cluster == nil {
return 1
}
return max(1, len(configuredNorthstarNodeNames(cluster)))
}
+135
View File
@@ -0,0 +1,135 @@
package dns_test
import (
"context"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"slices"
"strings"
"sync"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
testutil "github.com/mulgadc/spinifex/internal/testkit"
"github.com/mulgadc/spinifex/spinifex/bootstrap/config"
"github.com/mulgadc/spinifex/spinifex/domains/dns"
)
// zoneBucket is a path-style S3 endpoint holding whole objects.
func zoneBucket(t *testing.T) (endpoint string, get func(key string) (string, bool)) {
t.Helper()
var mu sync.Mutex
objects := map[string]string{}
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
key := strings.TrimPrefix(r.URL.Path, "/northstar/")
mu.Lock()
defer mu.Unlock()
switch r.Method {
case http.MethodPut:
body, _ := io.ReadAll(r.Body)
objects[key] = string(body)
case http.MethodDelete:
delete(objects, key)
w.WriteHeader(http.StatusNoContent)
default:
http.Error(w, "unsupported", http.StatusMethodNotAllowed)
}
}))
t.Cleanup(srv.Close)
return srv.URL, func(key string) (string, bool) {
mu.Lock()
defer mu.Unlock()
v, ok := objects[key]
return v, ok
}
}
func publisherConfig(t *testing.T, endpoint string) *config.Config {
t.Helper()
path := filepath.Join(t.TempDir(), "northstar.toml")
body := fmt.Sprintf("listen = \"0.0.0.0:5300\"\ndefault_domain = \"spx3.net\"\n[s3]\nendpoint = %q\nbucket = \"northstar\"\nregion = \"us-east-1\"\naccess_key = \"RO\"\nsecret_key = \"RO\"\n", endpoint)
require.NoError(t, os.WriteFile(path, []byte(body), 0o600))
return &config.Config{
Predastore: config.PredastoreConfig{AccessKey: "SYSTEM", SecretKey: "SYSTEMSECRET"},
Northstar: config.NorthstarConfig{ConfigPath: path},
}
}
func cluster(nodes ...string) *config.ClusterConfig {
c := &config.ClusterConfig{Node: nodes[0], Nodes: map[string]config.Config{}}
for _, n := range nodes {
c.Nodes[n] = config.Config{Northstar: config.NorthstarConfig{ConfigPath: "/etc/spinifex/northstar.toml"}}
}
return c
}
func TestZonePublisherWritesAndDeletesZoneObjects(t *testing.T) {
endpoint, get := zoneBucket(t)
_, nc := testutil.StartTestNATS(t)
pub, ok := dns.NewZonePublisher(publisherConfig(t, endpoint), func() *config.ClusterConfig { return cluster("node1") }, nc)
require.True(t, ok)
require.NoError(t, pub.PutZone(context.Background(), "Example.com", []byte("body")))
body, ok := get("example.com.toml")
require.True(t, ok)
assert.Equal(t, "body", body)
require.NoError(t, pub.DeleteZone(context.Background(), "example.com"))
_, ok = get("example.com.toml")
assert.False(t, ok)
}
func TestZonePublisherNeedsTheZoneStore(t *testing.T) {
_, nc := testutil.StartTestNATS(t)
_, ok := dns.NewZonePublisher(&config.Config{}, func() *config.ClusterConfig { return nil }, nc)
assert.False(t, ok)
}
// A reload is complete only when every northstar node has answered; a failed
// node reports a version no digest matches, and a node answering twice does not
// stand in for one that never answered.
func TestZonePublisherReloadCountsEveryNorthstarNode(t *testing.T) {
endpoint, _ := zoneBucket(t)
nodes := cluster("node1", "node2", "node3")
cases := []struct {
name string
acks []dns.ZoneReloadAck
served []string
complete bool
}{
{"one failed, one silent", []dns.ZoneReloadAck{{Node: "node1", ETag: "v1"}, {Node: "node2", Error: "s3 down"}}, []string{"v1", "error: s3 down"}, false},
{"one node twice", []dns.ZoneReloadAck{{Node: "node1", ETag: "v1"}, {Node: "node1", ETag: "v1"}}, []string{"v1", "v1"}, false},
{"every node", []dns.ZoneReloadAck{{Node: "node1", ETag: "v1"}, {Node: "node2", ETag: "v1"}, {Node: "node3", ETag: "v1"}}, []string{"v1", "v1", "v1"}, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, nc := testutil.StartTestNATS(t)
pub, ok := dns.NewZonePublisher(publisherConfig(t, endpoint), func() *config.ClusterConfig { return nodes }, nc)
require.True(t, ok)
ackAs(t, nc, func(zone string) []dns.ZoneReloadAck {
acks := slices.Clone(tc.acks)
for i := range acks {
acks[i].Zone = zone
}
return acks
})
served, complete, err := pub.Reload(context.Background(), "example.com")
require.NoError(t, err)
assert.Equal(t, tc.complete, complete)
assert.ElementsMatch(t, tc.served, served)
})
}
}
func TestNorthstarServerCount(t *testing.T) {
assert.Equal(t, 1, dns.NorthstarServerCount(nil))
assert.Equal(t, 1, dns.NorthstarServerCount(&config.ClusterConfig{Nodes: map[string]config.Config{"node1": {}}}))
assert.Equal(t, 3, dns.NorthstarServerCount(cluster("node1", "node2", "node3")))
}
+46
View File
@@ -0,0 +1,46 @@
package dns
import (
"context"
"encoding/json"
"errors"
"fmt"
"github.com/nats-io/nats.go"
)
// RequestZoneReload asks every northstar server to reload zone and collects
// their acknowledgements until want have arrived or ctx ends. The acks gathered
// so far are returned either way; the error reports only a transport failure.
func RequestZoneReload(ctx context.Context, nc *nats.Conn, zone string, want int) ([]ZoneReloadAck, error) {
payload, err := json.Marshal(ZoneReload{Zone: zone})
if err != nil {
return nil, fmt.Errorf("marshal zone reload: %w", err)
}
inbox := nc.NewInbox()
sub, err := nc.SubscribeSync(inbox)
if err != nil {
return nil, fmt.Errorf("subscribe reload acks: %w", err)
}
defer func() { _ = sub.Unsubscribe() }()
if err := nc.PublishRequest(SubjectZoneReload, inbox, payload); err != nil {
return nil, fmt.Errorf("publish zone reload: %w", err)
}
var acks []ZoneReloadAck
for len(acks) < want {
msg, err := sub.NextMsgWithContext(ctx)
if err != nil {
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
return acks, nil
}
return acks, fmt.Errorf("receive reload ack: %w", err)
}
var ack ZoneReloadAck
if json.Unmarshal(msg.Data, &ack) != nil || ack.Zone != zone {
continue
}
acks = append(acks, ack)
}
return acks, nil
}
+87
View File
@@ -0,0 +1,87 @@
package dns_test
import (
"context"
"encoding/json"
"testing"
"time"
"github.com/nats-io/nats.go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
testutil "github.com/mulgadc/spinifex/internal/testkit"
"github.com/mulgadc/spinifex/spinifex/domains/dns"
)
// ackAs subscribes a fake northstar that answers every reload with the acks
// reply returns for the requested zone.
func ackAs(t *testing.T, nc *nats.Conn, reply func(zone string) []dns.ZoneReloadAck) {
t.Helper()
sub, err := nc.Subscribe(dns.SubjectZoneReload, func(msg *nats.Msg) {
var evt dns.ZoneReload
require.NoError(t, json.Unmarshal(msg.Data, &evt))
for _, ack := range reply(evt.Zone) {
body, err := json.Marshal(ack)
require.NoError(t, err)
require.NoError(t, msg.Respond(body))
}
})
require.NoError(t, err)
t.Cleanup(func() { _ = sub.Unsubscribe() })
}
func TestRequestZoneReloadCollectsOneAckPerServer(t *testing.T) {
_, nc := testutil.StartTestNATS(t)
for _, node := range []string{"node1", "node2", "node3"} {
ackAs(t, nc, func(zone string) []dns.ZoneReloadAck {
return []dns.ZoneReloadAck{{Zone: zone, Node: node, ETag: "v2"}}
})
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
acks, err := dns.RequestZoneReload(ctx, nc, "example.com", 3)
require.NoError(t, err)
require.Len(t, acks, 3)
nodes := []string{acks[0].Node, acks[1].Node, acks[2].Node}
assert.ElementsMatch(t, []string{"node1", "node2", "node3"}, nodes)
for _, ack := range acks {
assert.Equal(t, "v2", ack.ETag)
}
}
func TestRequestZoneReloadIgnoresAcksForOtherZones(t *testing.T) {
_, nc := testutil.StartTestNATS(t)
ackAs(t, nc, func(zone string) []dns.ZoneReloadAck {
return []dns.ZoneReloadAck{{Zone: "other.com", Node: "stray"}, {Zone: zone, Node: "node1"}}
})
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
acks, err := dns.RequestZoneReload(ctx, nc, "example.com", 1)
require.NoError(t, err)
require.Len(t, acks, 1)
assert.Equal(t, "node1", acks[0].Node)
}
func TestRequestZoneReloadReturnsPartialAcksAtDeadline(t *testing.T) {
_, nc := testutil.StartTestNATS(t)
ackAs(t, nc, func(zone string) []dns.ZoneReloadAck {
return []dns.ZoneReloadAck{{Zone: zone, Node: "node1", Error: "s3 unavailable"}}
})
ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond)
defer cancel()
acks, err := dns.RequestZoneReload(ctx, nc, "example.com", 2)
require.NoError(t, err)
require.Len(t, acks, 1)
assert.Equal(t, "s3 unavailable", acks[0].Error)
}
func TestRequestZoneReloadFailsOnClosedConnection(t *testing.T) {
_, nc := testutil.StartTestNATS(t)
nc.Close()
_, err := dns.RequestZoneReload(context.Background(), nc, "example.com", 1)
require.Error(t, err)
}
+44
View File
@@ -0,0 +1,44 @@
package dns
import (
"regexp"
"strings"
)
// managedLabels are the labels directly below the base domain under which the
// platform writes its own records.
var managedLabels = map[string]bool{"compute": true, "elb": true, "eks": true, "rds": true}
var nameserverLabel = regexp.MustCompile(`^ns[0-9]+$`)
// ReservedZoneName reports whether a tenant may not create a hosted zone named
// name. It refuses the base and internal domains, any ancestor of them, every
// name below the internal domain, and any name below the base domain that falls
// under a platform-managed label or a published nameserver.
func ReservedZoneName(name, baseDomain, internalDomain string) bool {
name = normalizeZone(name)
if name == "" {
return true
}
for _, zone := range []string{normalizeZone(baseDomain), normalizeZone(privateZoneOrDefault(internalDomain))} {
if zone == "" {
continue
}
if name == zone || strings.HasSuffix(zone, "."+name) {
return true
}
}
if internal := normalizeZone(privateZoneOrDefault(internalDomain)); strings.HasSuffix(name, "."+internal) {
return true
}
if base := normalizeZone(baseDomain); base != "" && strings.HasSuffix(name, "."+base) {
labels := strings.Split(strings.TrimSuffix(name, "."+base), ".")
top := labels[len(labels)-1]
return managedLabels[top] || nameserverLabel.MatchString(top)
}
return false
}
func normalizeZone(name string) string {
return strings.TrimSuffix(strings.ToLower(strings.TrimSpace(name)), ".")
}
+46
View File
@@ -0,0 +1,46 @@
package dns_test
import (
"testing"
"github.com/mulgadc/spinifex/spinifex/domains/dns"
"github.com/stretchr/testify/assert"
)
func TestReservedZoneName(t *testing.T) {
cases := []struct {
name string
reserved bool
}{
{"spx3.net.", true},
{"SPX3.NET", true},
{"net", true},
{"compute.internal", true},
{"ip-10-0-0-1.us-west-1.compute.internal.", true},
{"internal", true},
{"compute.spx3.net", true},
{"ec2-1-2-3-4.us-west-1.compute.spx3.net", true},
{"lb.us-west-1.elb.spx3.net", true},
{"x.eks.spx3.net", true},
{"db.rds.spx3.net", true},
{"ns1.spx3.net", true},
{"a.ns4.spx3.net", true},
{"app.spx3.net", false},
{"nsx.spx3.net", false},
{"compute.app.spx3.net", false},
{"example.com", false},
{"compute.example.com", false},
{"", true},
}
for _, c := range cases {
assert.Equal(t, c.reserved, dns.ReservedZoneName(c.name, "spx3.net", ""), c.name)
}
}
func TestReservedZoneNameHonoursTheConfiguredDomains(t *testing.T) {
assert.True(t, dns.ReservedZoneName("cloud.example.com", "cloud.example.com", "corp.internal"))
assert.True(t, dns.ReservedZoneName("example.com", "cloud.example.com", "corp.internal"))
assert.True(t, dns.ReservedZoneName("host.corp.internal", "cloud.example.com", "corp.internal"))
assert.False(t, dns.ReservedZoneName("spx3.net", "cloud.example.com", "corp.internal"))
assert.False(t, dns.ReservedZoneName("app.spx3.net", "", ""))
}
+10
View File
@@ -67,3 +67,13 @@ type ChangeResult struct {
type ZoneReload struct {
Zone string `json:"zone"`
}
// ZoneReloadAck is one northstar server's reply to a ZoneReload sent with a
// reply subject. ETag is the version of the zone it now serves, "" when the
// zone is absent; Error is set when the reload failed.
type ZoneReloadAck struct {
Zone string `json:"zone"`
Node string `json:"node"`
ETag string `json:"etag,omitempty"`
Error string `json:"error,omitempty"`
}
+11 -8
View File
@@ -19,14 +19,16 @@ import (
// of zone TOML files in s3://northstar/ using the system predastore credentials.
// Only the reconcile's elected node calls it, so a zone has one writer at a time.
type Writer struct {
enabled bool
s3cfg *nsconfig.S3Config
baseDomain string
nameservers []nsconfig.NameserverSeed
ttl uint32
nc *nats.Conn
quotaEnabled bool
quotas Quotas
enabled bool
s3cfg *nsconfig.S3Config
baseDomain string
// internalDomain is the private zone, for refusing reserved delegations.
internalDomain string
nameservers []nsconfig.NameserverSeed
ttl uint32
nc *nats.Conn
quotaEnabled bool
quotas Quotas
}
// NewWriter resolves the northstar S3 endpoint/bucket from the node's
@@ -45,6 +47,7 @@ func NewWriter(cfg *config.Config, cluster *config.ClusterConfig, nc *nats.Conn)
w.enabled = true
w.s3cfg = zoneCfg.s3
w.baseDomain = strings.TrimSpace(zoneCfg.server.DefaultDomain)
w.internalDomain = ResolveInternalDomain(cfg)
// Share the bootstrap's derivation so a zone this writer materialises carries
// the same NS topology as one the bootstrap seeds; whichever wins the race,
// the zone is correct.
+135
View File
@@ -0,0 +1,135 @@
package account
import (
"context"
"encoding/json"
"errors"
"log/slog"
awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
"github.com/nats-io/nats.go/jetstream"
)
// sealMaxAttempts bounds SealAccount's retry loop against a revision that
// keeps moving under concurrent writes, so it fails closed instead of
// spinning.
const sealMaxAttempts = 5
// sealedRecord is the fixed value a seal writes. Clearing both booleans
// retains no tenant setting; an older reader that ignores state sees the
// AWS defaults.
var sealedRecord = &AccountSettingsRecord{State: AccountSettingsStateSealed}
// AccountSettingsSeal is SealAccount's result: the revision at which the
// stream leader accepted the sealed value.
type AccountSettingsSeal struct {
Revision uint64
}
// AccountTerminationChecker reports whether IAM holds accountID as TERMINATING.
// A missing record is (false, nil); only a failed read is an error.
type AccountTerminationChecker interface {
AccountTerminating(ctx context.Context, accountID string) (bool, error)
}
// SetAccountTerminationChecker wires the precondition; until it is set, SealAccount refuses.
func (s *AccountSettingsServiceImpl) SetAccountTerminationChecker(c AccountTerminationChecker) {
s.terminationChecker = c
}
// checkTerminating runs before any settings-KV call, so a refusal never writes.
// Not TERMINATING, including a missing IAM record, is IncorrectState; a read
// failure or an unwired checker is InternalError.
func (s *AccountSettingsServiceImpl) checkTerminating(ctx context.Context, accountID string) error {
if s.terminationChecker == nil {
slog.ErrorContext(ctx, "Account settings seal has no termination checker wired", "accountID", accountID)
return errors.New(awserrors.ErrorInternalError)
}
terminating, err := s.terminationChecker.AccountTerminating(ctx, accountID)
if err != nil {
slog.ErrorContext(ctx, "Failed to read account status for seal precondition", "accountID", accountID, "err", err)
return errors.New(awserrors.ErrorInternalError)
}
if !terminating {
return awserrors.Errorf(awserrors.ErrorIncorrectState, "account %s is not TERMINATING", accountID)
}
return nil
}
// isRevisionConflict reports a lost CAS race SealAccount should retry. Besides
// the two sentinels, Create's retry against a delete marker returns a bare
// *jetstream.APIError carrying a wrong-last-sequence code.
func isRevisionConflict(err error) bool {
if errors.Is(err, jetstream.ErrKeyExists) || errors.Is(err, jetstream.ErrKeyRevisionMismatch) {
return true
}
if apiErr, ok := errors.AsType[*jetstream.APIError](err); ok {
return apiErr.ErrorCode == jetstream.JSErrCodeStreamWrongLastSequence ||
apiErr.ErrorCode == jetstream.JSErrCodeStreamWrongLastSequenceConstant
}
return false
}
// SealAccount seals accountID's settings record so no later mutation can
// recreate it. It never relies on a read for its claim: the only success is a
// leader-accepted write, evidenced by the returned revision.
func (s *AccountSettingsServiceImpl) SealAccount(ctx context.Context, accountID string) (*AccountSettingsSeal, error) {
if accountID == "" {
return nil, awserrors.Errorf(awserrors.ErrorMissingParameter, "accountID is required to seal settings")
}
if !awsidentifiers.IsAccountID(accountID) {
return nil, awserrors.Errorf(awserrors.ErrorInvalidParameterValue, "accountID %q is not a valid AWS account ID", accountID)
}
if accountID == awsidentifiers.GlobalAccountID {
return nil, awserrors.Errorf(awserrors.ErrorInvalidParameterValue, "settings seal must not target the global account")
}
if err := s.checkTerminating(ctx, accountID); err != nil {
return nil, err
}
data, err := json.Marshal(sealedRecord)
if err != nil {
slog.ErrorContext(ctx, "Failed to encode sealed account settings record", "accountID", accountID, "err", err)
return nil, errors.New(awserrors.ErrorInternalError)
}
// settingsKey is never used here: a seal must act on exactly accountID
// and never fall back to the global key.
for range sealMaxAttempts {
var rev uint64
entry, getErr := s.settingsKV.Get(ctx, accountID)
switch {
case getErr == nil:
rev = entry.Revision()
case errors.Is(getErr, jetstream.ErrKeyNotFound):
rev = 0
default:
slog.ErrorContext(ctx, "Failed to read account settings for seal", "accountID", accountID, "err", getErr)
return nil, errors.New(awserrors.ErrorInternalError)
}
var newRev uint64
var writeErr error
if rev == 0 {
newRev, writeErr = s.settingsKV.Create(ctx, accountID, data)
} else {
newRev, writeErr = s.settingsKV.Update(ctx, accountID, data, rev)
}
switch {
case writeErr == nil:
return &AccountSettingsSeal{Revision: newRev}, nil
case isRevisionConflict(writeErr):
continue
default:
slog.ErrorContext(ctx, "Failed to write sealed account settings record", "accountID", accountID, "err", writeErr)
return nil, errors.New(awserrors.ErrorInternalError)
}
}
slog.ErrorContext(ctx, "Account settings seal exhausted attempts against concurrent writes", "accountID", accountID, "attempts", sealMaxAttempts)
return nil, errors.New(awserrors.ErrorInternalError)
}
+559
View File
@@ -0,0 +1,559 @@
package account
import (
"context"
"encoding/json"
"errors"
"fmt"
"sync"
"testing"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/spinifex/internal/testkit"
awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
"github.com/nats-io/nats.go/jetstream"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// stubTerminationChecker answers AccountTerminating from a fixed set, for
// tests that exercise the KV paths rather than IAM itself.
type stubTerminationChecker struct {
terminating map[string]bool
err error
}
func (c *stubTerminationChecker) AccountTerminating(_ context.Context, accountID string) (bool, error) {
if c.err != nil {
return false, c.err
}
return c.terminating[accountID], nil
}
// terminatingChecker answers true for exactly accountID.
func terminatingChecker(accountID string) *stubTerminationChecker {
return &stubTerminationChecker{terminating: map[string]bool{accountID: true}}
}
// lastSettingsSeq reports the account-settings stream's last sequence, so a
// test can assert a failed attempt wrote nothing.
func lastSettingsSeq(t *testing.T, svc *AccountSettingsServiceImpl) uint64 {
t.Helper()
stream, err := svc.js.Stream(context.Background(), "KV_"+KVBucketAccountSettings)
require.NoError(t, err)
info, err := stream.Info(context.Background())
require.NoError(t, err)
return info.State.LastSeq
}
// mutationCase is one of the four settings mutations, called uniformly so a
// test can table over them.
type mutationCase struct {
name string
call func(svc *AccountSettingsServiceImpl, ctx context.Context) error
}
func mutationTable() []mutationCase {
return []mutationCase{
{"EnableEbs", func(svc *AccountSettingsServiceImpl, ctx context.Context) error {
_, err := svc.EnableEbsEncryptionByDefault(ctx, &ec2.EnableEbsEncryptionByDefaultInput{}, testAccountID)
return err
}},
{"DisableEbs", func(svc *AccountSettingsServiceImpl, ctx context.Context) error {
_, err := svc.DisableEbsEncryptionByDefault(ctx, &ec2.DisableEbsEncryptionByDefaultInput{}, testAccountID)
return err
}},
{"EnableSerial", func(svc *AccountSettingsServiceImpl, ctx context.Context) error {
_, err := svc.EnableSerialConsoleAccess(ctx, &ec2.EnableSerialConsoleAccessInput{}, testAccountID)
return err
}},
{"DisableSerial", func(svc *AccountSettingsServiceImpl, ctx context.Context) error {
_, err := svc.DisableSerialConsoleAccess(ctx, &ec2.DisableSerialConsoleAccessInput{}, testAccountID)
return err
}},
}
}
// seedSettingsState prepares testAccountID's record for a race test: present
// (an active record), absent (never written) or tombstone (written then
// deleted, leaving a delete marker Get hides).
func seedSettingsState(t *testing.T, svc *AccountSettingsServiceImpl, state string) {
t.Helper()
ctx := context.Background()
switch state {
case "present":
_, err := svc.EnableEbsEncryptionByDefault(ctx, &ec2.EnableEbsEncryptionByDefaultInput{}, testAccountID)
require.NoError(t, err)
case "tombstone":
_, err := svc.EnableEbsEncryptionByDefault(ctx, &ec2.EnableEbsEncryptionByDefaultInput{}, testAccountID)
require.NoError(t, err)
require.NoError(t, svc.settingsKV.Delete(ctx, testAccountID))
case "absent":
default:
t.Fatalf("unknown seed state %q", state)
}
}
// sealRaceKV wraps a real KeyValue so a test can inject a deterministic race
// between a caller's read and its guarded write (Create or Update), instead
// of relying on goroutine timing.
type sealRaceKV struct {
jetstream.KeyValue
writeCalls int
beforeWrite func(attempt int) error
}
func (f *sealRaceKV) Create(ctx context.Context, key string, value []byte, opts ...jetstream.KVCreateOpt) (uint64, error) {
f.writeCalls++
if f.beforeWrite != nil {
if err := f.beforeWrite(f.writeCalls); err != nil {
return 0, err
}
}
return f.KeyValue.Create(ctx, key, value, opts...)
}
func (f *sealRaceKV) Update(ctx context.Context, key string, value []byte, revision uint64) (uint64, error) {
f.writeCalls++
if f.beforeWrite != nil {
if err := f.beforeWrite(f.writeCalls); err != nil {
return 0, err
}
}
return f.KeyValue.Update(ctx, key, value, revision)
}
// staleGetKV always answers Get with a fixed snapshot taken at construction,
// so a caller's CAS keeps retrying against a revision that can never win.
type staleGetKV struct {
jetstream.KeyValue
entry jetstream.KeyValueEntry
}
func (f *staleGetKV) Get(context.Context, string) (jetstream.KeyValueEntry, error) {
return f.entry, nil
}
// staleNotFoundKV always answers Get as absent, regardless of what the real
// bucket holds.
type staleNotFoundKV struct {
jetstream.KeyValue
}
func (f *staleNotFoundKV) Get(context.Context, string) (jetstream.KeyValueEntry, error) {
return nil, jetstream.ErrKeyNotFound
}
// errGetKV always fails Get with a fixed store error.
type errGetKV struct {
jetstream.KeyValue
err error
}
func (f *errGetKV) Get(context.Context, string) (jetstream.KeyValueEntry, error) {
return nil, f.err
}
func decodeSettings(t *testing.T, entry jetstream.KeyValueEntry) AccountSettingsRecord {
t.Helper()
var rec AccountSettingsRecord
require.NoError(t, json.Unmarshal(entry.Value(), &rec))
return rec
}
// A seal landing between a mutation's read and its guarded write is not
// outlived: the mutation's retry sees the seal and is rejected, and nothing
// it wrote survives.
func TestSealRaceMutation_SealLandsBetweenReadAndWrite(t *testing.T) {
for _, m := range mutationTable() {
for _, state := range []string{"present", "absent", "tombstone"} {
t.Run(m.name+"_"+state, func(t *testing.T) {
svc := setupTestAccountService(t)
ctx := context.Background()
seedSettingsState(t, svc, state)
realKV := svc.settingsKV
sealer := &AccountSettingsServiceImpl{js: svc.js, settingsKV: realKV}
sealer.SetAccountTerminationChecker(terminatingChecker(testAccountID))
var sealed *AccountSettingsSeal
race := &sealRaceKV{KeyValue: realKV}
race.beforeWrite = func(attempt int) error {
if attempt != 1 {
return nil
}
var sealErr error
sealed, sealErr = sealer.SealAccount(ctx, testAccountID)
return sealErr
}
svc.settingsKV = race
err := m.call(svc, ctx)
require.Error(t, err)
assert.True(t, awserrors.IsErrorCode(err, awserrors.ErrorBlocked), "got %v", err)
require.NotNil(t, sealed)
entry, err := realKV.Get(ctx, testAccountID)
require.NoError(t, err)
assert.Equal(t, AccountSettingsStateSealed, decodeSettings(t, entry).State)
assert.Equal(t, sealed.Revision, entry.Revision(), "no write landed after the seal")
})
}
}
}
// A mutation landing inside a seal's read-write window does not survive it:
// the seal retries against the mutation's write and wins, leaving the
// sealed value with both booleans false.
func TestSealRaceMutation_MutationLandsBetweenSealReadAndWrite(t *testing.T) {
for _, m := range mutationTable() {
for _, state := range []string{"present", "absent", "tombstone"} {
t.Run(m.name+"_"+state, func(t *testing.T) {
svc := setupTestAccountService(t)
ctx := context.Background()
seedSettingsState(t, svc, state)
realKV := svc.settingsKV
race := &sealRaceKV{KeyValue: realKV}
race.beforeWrite = func(attempt int) error {
if attempt != 1 {
return nil
}
_ = m.call(svc, ctx)
return nil
}
sealer := &AccountSettingsServiceImpl{js: svc.js, settingsKV: race}
sealer.SetAccountTerminationChecker(terminatingChecker(testAccountID))
seal, err := sealer.SealAccount(ctx, testAccountID)
require.NoError(t, err)
entry, err := realKV.Get(ctx, testAccountID)
require.NoError(t, err)
rec := decodeSettings(t, entry)
assert.Equal(t, AccountSettingsStateSealed, rec.State)
assert.False(t, rec.EbsEncryptionByDefault)
assert.False(t, rec.SerialConsoleAccess)
assert.Equal(t, seal.Revision, entry.Revision())
})
}
}
}
// None of the four mutations can act on an already-sealed record.
func TestMutationOnSealedRecordIsRejected(t *testing.T) {
for _, m := range mutationTable() {
t.Run(m.name, func(t *testing.T) {
svc := setupTestAccountService(t)
ctx := context.Background()
svc.SetAccountTerminationChecker(terminatingChecker(testAccountID))
_, err := svc.SealAccount(ctx, testAccountID)
require.NoError(t, err)
before, err := svc.settingsKV.Get(ctx, testAccountID)
require.NoError(t, err)
err = m.call(svc, ctx)
require.Error(t, err)
assert.True(t, awserrors.IsErrorCode(err, awserrors.ErrorBlocked), "got %v", err)
after, err := svc.settingsKV.Get(ctx, testAccountID)
require.NoError(t, err)
assert.Equal(t, before.Revision(), after.Revision())
})
}
}
// Every settings mutation races one seal on a real bucket; afterwards the
// record is sealed and stays sealed, so no success among them could have
// landed after the seal.
func TestSealConcurrentMutations(t *testing.T) {
svc := setupTestAccountService(t)
ctx := context.Background()
svc.SetAccountTerminationChecker(terminatingChecker(testAccountID))
_, err := svc.EnableEbsEncryptionByDefault(ctx, &ec2.EnableEbsEncryptionByDefaultInput{}, testAccountID)
require.NoError(t, err)
mutations := mutationTable()
errs := make([]error, len(mutations))
var wg sync.WaitGroup
for i, m := range mutations {
wg.Add(1)
go func(i int, m mutationCase) {
defer wg.Done()
errs[i] = m.call(svc, ctx)
}(i, m)
}
var sealResult *AccountSettingsSeal
var sealErr error
wg.Go(func() {
sealResult, sealErr = svc.SealAccount(ctx, testAccountID)
})
wg.Wait()
require.NoError(t, sealErr)
require.NotNil(t, sealResult)
for i, err := range errs {
if err != nil {
assert.True(t, awserrors.IsErrorCode(err, awserrors.ErrorBlocked), "mutation %s: got %v", mutations[i].name, err)
}
}
entry, err := svc.settingsKV.Get(ctx, testAccountID)
require.NoError(t, err)
assert.Equal(t, AccountSettingsStateSealed, decodeSettings(t, entry).State)
assert.Equal(t, sealResult.Revision, entry.Revision())
_, err = svc.GetEbsEncryptionByDefault(ctx, &ec2.GetEbsEncryptionByDefaultInput{}, testAccountID)
assert.True(t, awserrors.IsErrorCode(err, awserrors.ErrorBlocked))
}
// A Get pinned to a revision the record has already moved past exhausts the
// mutation's retries without ever writing.
func TestMutationStaleReadExhaustsWithoutWriting(t *testing.T) {
svc := setupTestAccountService(t)
ctx := context.Background()
_, err := svc.EnableEbsEncryptionByDefault(ctx, &ec2.EnableEbsEncryptionByDefaultInput{}, testAccountID)
require.NoError(t, err)
stale, err := svc.settingsKV.Get(ctx, testAccountID)
require.NoError(t, err)
_, err = svc.DisableEbsEncryptionByDefault(ctx, &ec2.DisableEbsEncryptionByDefaultInput{}, testAccountID)
require.NoError(t, err)
before := lastSettingsSeq(t, svc)
svc.settingsKV = &staleGetKV{KeyValue: svc.settingsKV, entry: stale}
_, err = svc.EnableSerialConsoleAccess(ctx, &ec2.EnableSerialConsoleAccessInput{}, testAccountID)
assert.True(t, awserrors.IsErrorCode(err, awserrors.ErrorInternalError), "got %v", err)
assert.Equal(t, before, lastSettingsSeq(t, svc))
}
// A Get that always reports absent, while a sealed record actually exists,
// never gets to recreate it: every Create attempt conflicts.
func TestMutationStaleAbsentDoesNotRecreate(t *testing.T) {
svc := setupTestAccountService(t)
ctx := context.Background()
svc.SetAccountTerminationChecker(terminatingChecker(testAccountID))
_, err := svc.SealAccount(ctx, testAccountID)
require.NoError(t, err)
before := lastSettingsSeq(t, svc)
svc.settingsKV = &staleNotFoundKV{KeyValue: svc.settingsKV}
_, err = svc.EnableEbsEncryptionByDefault(ctx, &ec2.EnableEbsEncryptionByDefaultInput{}, testAccountID)
assert.True(t, awserrors.IsErrorCode(err, awserrors.ErrorInternalError), "got %v", err)
assert.Equal(t, before, lastSettingsSeq(t, svc))
}
// A revision that keeps moving under sustained concurrent writes must not
// let the seal spin forever: it gives up, bounded by its attempt budget,
// and its own write never lands.
func TestSealSustainedConflictFailsWithoutWriting(t *testing.T) {
svc := setupTestAccountService(t)
ctx := context.Background()
svc.SetAccountTerminationChecker(terminatingChecker(testAccountID))
_, err := svc.EnableEbsEncryptionByDefault(ctx, &ec2.EnableEbsEncryptionByDefaultInput{}, testAccountID)
require.NoError(t, err)
realKV := svc.settingsKV
race := &sealRaceKV{KeyValue: realKV}
race.beforeWrite = func(int) error {
data, merr := json.Marshal(&AccountSettingsRecord{SerialConsoleAccess: true})
require.NoError(t, merr)
_, perr := realKV.Put(ctx, testAccountID, data)
return perr
}
svc.settingsKV = race
_, err = svc.SealAccount(ctx, testAccountID)
assert.True(t, awserrors.IsErrorCode(err, awserrors.ErrorInternalError), "got %v", err)
assert.Equal(t, sealMaxAttempts, race.writeCalls)
entry, err := realKV.Get(ctx, testAccountID)
require.NoError(t, err)
assert.NotEqual(t, AccountSettingsStateSealed, decodeSettings(t, entry).State, "the seal's own write never landed")
}
// A store error on the seal's read reports failed, never written.
func TestSealStoreErrorWritesNothing(t *testing.T) {
svc := setupTestAccountService(t)
ctx := context.Background()
svc.SetAccountTerminationChecker(terminatingChecker(testAccountID))
before := lastSettingsSeq(t, svc)
svc.settingsKV = &errGetKV{KeyValue: svc.settingsKV, err: errors.New("kv unavailable")}
_, err := svc.SealAccount(ctx, testAccountID)
assert.True(t, awserrors.IsErrorCode(err, awserrors.ErrorInternalError), "got %v", err)
assert.Equal(t, before, lastSettingsSeq(t, svc))
}
func TestIsRevisionConflictRecognisesTombstoneRetry(t *testing.T) {
assert.True(t, isRevisionConflict(jetstream.ErrKeyExists))
assert.True(t, isRevisionConflict(jetstream.ErrKeyRevisionMismatch))
assert.True(t, isRevisionConflict(&jetstream.APIError{ErrorCode: jetstream.JSErrCodeStreamWrongLastSequence}))
assert.True(t, isRevisionConflict(&jetstream.APIError{ErrorCode: jetstream.JSErrCodeStreamWrongLastSequenceConstant}))
assert.True(t, isRevisionConflict(fmt.Errorf("wrapped: %w", &jetstream.APIError{ErrorCode: jetstream.JSErrCodeStreamWrongLastSequence})))
assert.False(t, isRevisionConflict(errors.New("unrelated failure")))
assert.False(t, isRevisionConflict(nil))
}
// An account IAM does not hold as TERMINATING refuses the seal before any
// settings-KV write, whether the key is present or absent.
func TestSealRefusedForActiveAccount(t *testing.T) {
for _, state := range []string{"present", "absent"} {
t.Run(state, func(t *testing.T) {
svc := setupTestAccountService(t)
ctx := context.Background()
if state == "present" {
_, err := svc.EnableEbsEncryptionByDefault(ctx, &ec2.EnableEbsEncryptionByDefaultInput{}, testAccountID)
require.NoError(t, err)
}
svc.SetAccountTerminationChecker(&stubTerminationChecker{terminating: map[string]bool{}})
before := lastSettingsSeq(t, svc)
_, err := svc.SealAccount(ctx, testAccountID)
assert.True(t, awserrors.IsErrorCode(err, awserrors.ErrorIncorrectState), "got %v", err)
assert.Equal(t, before, lastSettingsSeq(t, svc))
})
}
}
func TestSealRefusedForMissingIAMRecord(t *testing.T) {
svc := setupTestAccountService(t)
ctx := context.Background()
svc.SetAccountTerminationChecker(&stubTerminationChecker{terminating: map[string]bool{}})
before := lastSettingsSeq(t, svc)
_, err := svc.SealAccount(ctx, testAccountID)
assert.True(t, awserrors.IsErrorCode(err, awserrors.ErrorIncorrectState), "got %v", err)
assert.Equal(t, before, lastSettingsSeq(t, svc))
}
func TestSealRefusedWhenIAMReadFails(t *testing.T) {
svc := setupTestAccountService(t)
ctx := context.Background()
svc.SetAccountTerminationChecker(&stubTerminationChecker{err: errors.New("iam unavailable")})
before := lastSettingsSeq(t, svc)
_, err := svc.SealAccount(ctx, testAccountID)
assert.True(t, awserrors.IsErrorCode(err, awserrors.ErrorInternalError), "got %v", err)
assert.Equal(t, before, lastSettingsSeq(t, svc))
}
func TestSealRefusedWithNoCheckerWired(t *testing.T) {
svc := setupTestAccountService(t)
ctx := context.Background()
before := lastSettingsSeq(t, svc)
_, err := svc.SealAccount(ctx, testAccountID)
assert.True(t, awserrors.IsErrorCode(err, awserrors.ErrorInternalError), "got %v", err)
assert.Equal(t, before, lastSettingsSeq(t, svc))
}
func TestSealAcceptedForTerminatingAccount(t *testing.T) {
svc := setupTestAccountService(t)
ctx := context.Background()
svc.SetAccountTerminationChecker(terminatingChecker(testAccountID))
seal, err := svc.SealAccount(ctx, testAccountID)
require.NoError(t, err)
assert.NotZero(t, seal.Revision)
entry, err := svc.settingsKV.Get(ctx, testAccountID)
require.NoError(t, err)
assert.Equal(t, seal.Revision, entry.Revision())
}
// A sealed record stays sealed after the service is rebuilt over the same
// JetStream, and every one of the six settings actions still refuses it.
func TestSealSurvivesServiceReconstruction(t *testing.T) {
_, nc, _ := testutil.StartTestJetStream(t)
ctx := context.Background()
svc, err := NewAccountSettingsServiceImplWithNATS(ctx, nil, nc)
require.NoError(t, err)
svc.SetAccountTerminationChecker(terminatingChecker(testAccountID))
first, err := svc.SealAccount(ctx, testAccountID)
require.NoError(t, err)
rebuilt, err := NewAccountSettingsServiceImplWithNATS(ctx, nil, nc)
require.NoError(t, err)
entry, err := rebuilt.settingsKV.Get(ctx, testAccountID)
require.NoError(t, err)
assert.Equal(t, AccountSettingsStateSealed, decodeSettings(t, entry).State)
_, err = rebuilt.GetEbsEncryptionByDefault(ctx, &ec2.GetEbsEncryptionByDefaultInput{}, testAccountID)
assert.True(t, awserrors.IsErrorCode(err, awserrors.ErrorBlocked))
_, err = rebuilt.GetSerialConsoleAccessStatus(ctx, &ec2.GetSerialConsoleAccessStatusInput{}, testAccountID)
assert.True(t, awserrors.IsErrorCode(err, awserrors.ErrorBlocked))
_, err = rebuilt.EnableEbsEncryptionByDefault(ctx, &ec2.EnableEbsEncryptionByDefaultInput{}, testAccountID)
assert.True(t, awserrors.IsErrorCode(err, awserrors.ErrorBlocked))
_, err = rebuilt.DisableEbsEncryptionByDefault(ctx, &ec2.DisableEbsEncryptionByDefaultInput{}, testAccountID)
assert.True(t, awserrors.IsErrorCode(err, awserrors.ErrorBlocked))
_, err = rebuilt.EnableSerialConsoleAccess(ctx, &ec2.EnableSerialConsoleAccessInput{}, testAccountID)
assert.True(t, awserrors.IsErrorCode(err, awserrors.ErrorBlocked))
_, err = rebuilt.DisableSerialConsoleAccess(ctx, &ec2.DisableSerialConsoleAccessInput{}, testAccountID)
assert.True(t, awserrors.IsErrorCode(err, awserrors.ErrorBlocked))
rebuilt.SetAccountTerminationChecker(terminatingChecker(testAccountID))
second, err := rebuilt.SealAccount(ctx, testAccountID)
require.NoError(t, err)
assert.Greater(t, second.Revision, first.Revision)
}
func TestSealAbsentKeyCreatesSealedRecord(t *testing.T) {
svc := setupTestAccountService(t)
ctx := context.Background()
svc.SetAccountTerminationChecker(terminatingChecker(testAccountID))
_, err := svc.settingsKV.Get(ctx, testAccountID)
require.ErrorIs(t, err, jetstream.ErrKeyNotFound)
seal, err := svc.SealAccount(ctx, testAccountID)
require.NoError(t, err)
entry, err := svc.settingsKV.Get(ctx, testAccountID)
require.NoError(t, err)
assert.Equal(t, seal.Revision, entry.Revision())
assert.Equal(t, AccountSettingsStateSealed, decodeSettings(t, entry).State)
}
func TestSealRefusesEmptyInvalidAndGlobalAccount(t *testing.T) {
svc := setupTestAccountService(t)
ctx := context.Background()
_, err := svc.SealAccount(ctx, "")
assert.True(t, awserrors.IsErrorCode(err, awserrors.ErrorMissingParameter), "got %v", err)
_, err = svc.SealAccount(ctx, "not-an-account")
assert.True(t, awserrors.IsErrorCode(err, awserrors.ErrorInvalidParameterValue), "got %v", err)
_, err = svc.SealAccount(ctx, awsidentifiers.GlobalAccountID)
assert.True(t, awserrors.IsErrorCode(err, awserrors.ErrorInvalidParameterValue), "got %v", err)
}
func TestSealLeavesOtherAccountsUntouched(t *testing.T) {
svc := setupTestAccountService(t)
ctx := context.Background()
accountB := "222222222222"
_, err := svc.EnableEbsEncryptionByDefault(ctx, &ec2.EnableEbsEncryptionByDefaultInput{}, accountB)
require.NoError(t, err)
svc.SetAccountTerminationChecker(terminatingChecker(testAccountID))
_, err = svc.SealAccount(ctx, testAccountID)
require.NoError(t, err)
out, err := svc.GetEbsEncryptionByDefault(ctx, &ec2.GetEbsEncryptionByDefaultInput{}, accountB)
require.NoError(t, err)
assert.True(t, *out.EbsEncryptionByDefault)
}
+93 -55
View File
@@ -5,12 +5,13 @@ import (
"encoding/json"
"errors"
"fmt"
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
"log/slog"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/spinifex/spinifex/bootstrap/config"
awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
"github.com/mulgadc/spinifex/spinifex/foundation/state/migrate"
"github.com/nats-io/nats.go"
@@ -20,14 +21,19 @@ import (
const (
KVBucketAccountSettings = "spinifex-ec2-account-settings"
KVBucketAccountSettingsVersion = 1
KeyEbsEncryptionDefault = "ebs-encryption-default"
KeySerialConsoleAccess = "serial-console-access"
)
// AccountSettingsState is a settings record's lifecycle state. "" is active;
// a record written before the state field existed has none and stays active.
type AccountSettingsState string
const AccountSettingsStateSealed AccountSettingsState = "sealed"
// AccountSettingsRecord represents stored account settings.
type AccountSettingsRecord struct {
EbsEncryptionByDefault bool `json:"ebs_encryption_by_default"`
SerialConsoleAccess bool `json:"serial_console_access"`
EbsEncryptionByDefault bool `json:"ebs_encryption_by_default"`
SerialConsoleAccess bool `json:"serial_console_access"`
State AccountSettingsState `json:"state,omitempty"`
}
// AccountSettingsServiceImpl implements account settings operations with NATS JetStream persistence.
@@ -35,6 +41,10 @@ type AccountSettingsServiceImpl struct {
config *config.Config
js jetstream.JetStream
settingsKV jetstream.KeyValue
// terminationChecker is the seal precondition; SealAccount refuses until
// it is wired.
terminationChecker AccountTerminationChecker
}
var _ AccountSettingsService = (*AccountSettingsServiceImpl)(nil)
@@ -72,50 +82,99 @@ func settingsKey(accountID string) string {
return accountID
}
// getSettings retrieves current account settings.
// errSettingsSealed marks a mutation or read that found a sealed record, so
// callers map it to Blocked without logging it as a fault.
var errSettingsSealed = errors.New(awserrors.ErrorBlocked)
// errSettingsContended marks a mutation whose CAS retries were exhausted
// under concurrent writers, bounding the retry loop instead of spinning.
var errSettingsContended = errors.New("account settings: CAS retries exhausted")
// checkActive rejects a sealed or unknown-state record; "" is active and
// passes through. A mutation must never act on a record that failed this.
func checkActive(rec *AccountSettingsRecord) error {
switch rec.State {
case "":
return nil
case AccountSettingsStateSealed:
return errSettingsSealed
default:
return fmt.Errorf("account settings: unknown state %q", rec.State)
}
}
// mapSettingsError turns a mutateSettings or getSettings failure into the AWS
// error a caller returns: sealed is an expected, unlogged Blocked; everything
// else is InternalError, logged with the account ID.
func mapSettingsError(ctx context.Context, accountID string, err error) error {
switch {
case err == nil:
return nil
case errors.Is(err, errSettingsSealed):
return errors.New(awserrors.ErrorBlocked)
case errors.Is(err, errSettingsContended):
slog.ErrorContext(ctx, "Account settings CAS retries exhausted under contention", "accountID", accountID)
case errors.Is(err, kvutil.ErrRead):
slog.ErrorContext(ctx, "Failed to read account settings from KV", "accountID", accountID, "err", err)
case errors.Is(err, kvutil.ErrDecode):
slog.ErrorContext(ctx, "Corrupt account settings record in KV", "accountID", accountID, "err", err)
case errors.Is(err, kvutil.ErrEncode):
slog.ErrorContext(ctx, "Failed to marshal account settings record", "accountID", accountID, "err", err)
case errors.Is(err, kvutil.ErrWrite):
slog.ErrorContext(ctx, "Failed to write account settings to KV", "accountID", accountID, "err", err)
default:
slog.ErrorContext(ctx, "Account settings operation failed", "accountID", accountID, "err", err)
}
return errors.New(awserrors.ErrorInternalError)
}
// mutateSettings applies set to accountID's record at the revision it read.
// changed is always true, so every success is a leader-accepted write over an
// active value; a seal landing between the read and the write fails the CAS.
func (s *AccountSettingsServiceImpl) mutateSettings(ctx context.Context, accountID string, set func(*AccountSettingsRecord)) error {
_, err := kvutil.Update(ctx, s.settingsKV, settingsKey(accountID), kvutil.CASConfig{
CreateIfAbsent: true,
Exhausted: func(string, int) error { return errSettingsContended },
}, func(rec *AccountSettingsRecord) (bool, error) {
if err := checkActive(rec); err != nil {
return false, err
}
set(rec)
return true, nil
})
return mapSettingsError(ctx, accountID, err)
}
// getSettings retrieves current account settings, rejecting a sealed or
// unknown-state record so a read never answers from behind a seal. Absent is
// the AWS defaults; a read is a direct get and can be stale.
func (s *AccountSettingsServiceImpl) getSettings(ctx context.Context, accountID string) (*AccountSettingsRecord, error) {
entry, err := s.settingsKV.Get(ctx, settingsKey(accountID))
if err != nil {
if errors.Is(err, jetstream.ErrKeyNotFound) {
return &AccountSettingsRecord{
EbsEncryptionByDefault: false,
SerialConsoleAccess: false,
}, nil
return &AccountSettingsRecord{}, nil
}
return nil, fmt.Errorf("failed to get account settings: %w", err)
slog.ErrorContext(ctx, "Failed to read account settings from KV", "accountID", accountID, "err", err)
return nil, errors.New(awserrors.ErrorInternalError)
}
var settings AccountSettingsRecord
if err := json.Unmarshal(entry.Value(), &settings); err != nil {
return nil, err
slog.ErrorContext(ctx, "Corrupt account settings record in KV", "accountID", accountID, "err", err)
return nil, errors.New(awserrors.ErrorInternalError)
}
if err := checkActive(&settings); err != nil {
return nil, mapSettingsError(ctx, accountID, err)
}
return &settings, nil
}
// saveSettings saves current account settings.
func (s *AccountSettingsServiceImpl) saveSettings(ctx context.Context, settings *AccountSettingsRecord, accountID string) error {
data, err := json.Marshal(settings)
if err != nil {
return err
}
_, err = s.settingsKV.Put(ctx, settingsKey(accountID), data)
return err
}
// EnableEbsEncryptionByDefault enables EBS encryption by default for the account.
func (s *AccountSettingsServiceImpl) EnableEbsEncryptionByDefault(ctx context.Context, input *ec2.EnableEbsEncryptionByDefaultInput, accountID string) (*ec2.EnableEbsEncryptionByDefaultOutput, error) {
slog.InfoContext(ctx, "EnableEbsEncryptionByDefault called", "accountID", accountID)
settings, err := s.getSettings(ctx, accountID)
if err != nil {
return nil, err
}
settings.EbsEncryptionByDefault = true
if err := s.saveSettings(ctx, settings, accountID); err != nil {
if err := s.mutateSettings(ctx, accountID, func(rec *AccountSettingsRecord) { rec.EbsEncryptionByDefault = true }); err != nil {
return nil, err
}
@@ -128,14 +187,7 @@ func (s *AccountSettingsServiceImpl) EnableEbsEncryptionByDefault(ctx context.Co
func (s *AccountSettingsServiceImpl) DisableEbsEncryptionByDefault(ctx context.Context, input *ec2.DisableEbsEncryptionByDefaultInput, accountID string) (*ec2.DisableEbsEncryptionByDefaultOutput, error) {
slog.InfoContext(ctx, "DisableEbsEncryptionByDefault called", "accountID", accountID)
settings, err := s.getSettings(ctx, accountID)
if err != nil {
return nil, err
}
settings.EbsEncryptionByDefault = false
if err := s.saveSettings(ctx, settings, accountID); err != nil {
if err := s.mutateSettings(ctx, accountID, func(rec *AccountSettingsRecord) { rec.EbsEncryptionByDefault = false }); err != nil {
return nil, err
}
@@ -176,14 +228,7 @@ func (s *AccountSettingsServiceImpl) GetSerialConsoleAccessStatus(ctx context.Co
func (s *AccountSettingsServiceImpl) EnableSerialConsoleAccess(ctx context.Context, input *ec2.EnableSerialConsoleAccessInput, accountID string) (*ec2.EnableSerialConsoleAccessOutput, error) {
slog.InfoContext(ctx, "EnableSerialConsoleAccess called", "accountID", accountID)
settings, err := s.getSettings(ctx, accountID)
if err != nil {
return nil, err
}
settings.SerialConsoleAccess = true
if err := s.saveSettings(ctx, settings, accountID); err != nil {
if err := s.mutateSettings(ctx, accountID, func(rec *AccountSettingsRecord) { rec.SerialConsoleAccess = true }); err != nil {
return nil, err
}
@@ -196,14 +241,7 @@ func (s *AccountSettingsServiceImpl) EnableSerialConsoleAccess(ctx context.Conte
func (s *AccountSettingsServiceImpl) DisableSerialConsoleAccess(ctx context.Context, input *ec2.DisableSerialConsoleAccessInput, accountID string) (*ec2.DisableSerialConsoleAccessOutput, error) {
slog.InfoContext(ctx, "DisableSerialConsoleAccess called", "accountID", accountID)
settings, err := s.getSettings(ctx, accountID)
if err != nil {
return nil, err
}
settings.SerialConsoleAccess = false
if err := s.saveSettings(ctx, settings, accountID); err != nil {
if err := s.mutateSettings(ctx, accountID, func(rec *AccountSettingsRecord) { rec.SerialConsoleAccess = false }); err != nil {
return nil, err
}
@@ -2,11 +2,13 @@ package account
import (
"context"
"encoding/json"
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
"testing"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/spinifex/internal/testkit"
awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -143,3 +145,44 @@ func TestSettingsKey_EmptyAccountIDFallback(t *testing.T) {
assert.Equal(t, awsidentifiers.GlobalAccountID, settingsKey(""))
assert.Equal(t, "123456789012", settingsKey("123456789012"))
}
// A raw pre-seal record, with no state field at all, is readable and
// mutable, and a mutation writes it back without adding one.
func TestRecordWithoutStateIsActive(t *testing.T) {
svc := setupTestAccountService(t)
ctx := context.Background()
_, err := svc.settingsKV.Put(ctx, testAccountID, []byte(`{"ebs_encryption_by_default":true,"serial_console_access":false}`))
require.NoError(t, err)
out, err := svc.GetEbsEncryptionByDefault(ctx, &ec2.GetEbsEncryptionByDefaultInput{}, testAccountID)
require.NoError(t, err)
assert.True(t, *out.EbsEncryptionByDefault)
_, err = svc.EnableSerialConsoleAccess(ctx, &ec2.EnableSerialConsoleAccessInput{}, testAccountID)
require.NoError(t, err)
entry, err := svc.settingsKV.Get(ctx, testAccountID)
require.NoError(t, err)
assert.NotContains(t, string(entry.Value()), "state")
}
// A record with an unrecognised state fails closed rather than being treated
// as active or sealed, for both a read and a mutation.
func TestUnknownStateFailsClosed(t *testing.T) {
svc := setupTestAccountService(t)
ctx := context.Background()
_, err := svc.settingsKV.Put(ctx, testAccountID, []byte(`{"ebs_encryption_by_default":false,"serial_console_access":false,"state":"quarantined"}`))
require.NoError(t, err)
_, err = svc.GetEbsEncryptionByDefault(ctx, &ec2.GetEbsEncryptionByDefaultInput{}, testAccountID)
assert.True(t, awserrors.IsErrorCode(err, awserrors.ErrorInternalError), "got %v", err)
_, err = svc.EnableEbsEncryptionByDefault(ctx, &ec2.EnableEbsEncryptionByDefaultInput{}, testAccountID)
assert.True(t, awserrors.IsErrorCode(err, awserrors.ErrorInternalError), "got %v", err)
entry, err := svc.settingsKV.Get(ctx, testAccountID)
require.NoError(t, err)
var rec AccountSettingsRecord
require.NoError(t, json.Unmarshal(entry.Value(), &rec))
assert.EqualValues(t, "quarantined", rec.State, "the unknown-state record was never overwritten")
}
@@ -0,0 +1,68 @@
package account
//test:in-package — pins what AccountSettingsSealCapabilityVersion 1 asserts:
// nothing in this package writes the settings KV except through the
// revision-guarded helpers.
import (
"go/ast"
"go/parser"
"go/token"
"io/fs"
"path/filepath"
"runtime"
"strings"
"testing"
)
func accountPackageDir(t *testing.T) string {
t.Helper()
_, file, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("cannot locate test source")
}
return filepath.Dir(file)
}
// The capability daemons report as version 1 is a claim about every writer in
// the binary, not just the responder: no non-test file in this package may
// call Put or PutString, which bypass the revision-guarded mutate/seal paths.
func TestAccountSettingsWritesAreRevisionGuarded(t *testing.T) {
root := accountPackageDir(t)
fset := token.NewFileSet()
scanned := 0
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
if err != nil || d.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
return err
}
f, perr := parser.ParseFile(fset, path, nil, 0)
if perr != nil {
return perr
}
scanned++
ast.Inspect(f, func(n ast.Node) bool {
call, ok := n.(*ast.CallExpr)
if !ok {
return true
}
sel, ok := call.Fun.(*ast.SelectorExpr)
if !ok {
return true
}
if sel.Sel.Name == "Put" || sel.Sel.Name == "PutString" {
t.Errorf("%s: unconditional %s call bypasses the revision-guarded settings write; use mutateSettings or SealAccount's CAS loop instead",
fset.Position(call.Pos()), sel.Sel.Name)
}
return true
})
return nil
})
if err != nil {
t.Fatal(err)
}
if scanned == 0 {
t.Fatalf("scanned no Go files under %s; the invariant would pass vacuously", root)
}
}
@@ -0,0 +1,277 @@
// Package accountcleanup deletes the proven-safe subset of one account's EC2
// platform resources through the owners' own delete paths: placement groups
// with no members that no instance, spot request or live launch-template
// version names; key pairs none of those name; and launch-template residue no
// header owns. Live launch templates are never deleted: an EKS nodegroup may
// name one and EC2 cannot see it. A reference check that cannot complete
// deletes nothing of the kinds it guards. Every disposition is proved by an
// owner-store re-scan, never by Describe.
//
// It adds no caller, checks no authority and holds no store or transport of
// its own: the owners and reference readers are injected. Whoever binds it
// decides who may invoke it. It is not exposed on any NATS subject.
package accountcleanup
import (
"context"
"log/slog"
"slices"
"strings"
"time"
ec2v1 "github.com/mulgadc/spinifex/contracts/ec2/v1"
ec2key "github.com/mulgadc/spinifex/spinifex/domains/ec2/key"
ec2launchtemplate "github.com/mulgadc/spinifex/spinifex/domains/ec2/launchtemplate"
ec2placementgroup "github.com/mulgadc/spinifex/spinifex/domains/ec2/placementgroup"
ec2tags "github.com/mulgadc/spinifex/spinifex/domains/ec2/tags"
awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
otelsetup "github.com/mulgadc/spinifex/spinifex/foundation/telemetry"
)
// PlatformCleaner deletes one account's EC2 platform resources and reports
// what the owner stores hold afterwards. It is destructive.
type PlatformCleaner interface {
CleanupPlatform(ctx context.Context, req *Request) (*Result, error)
}
var _ PlatformCleaner = (*Cleanup)(nil)
// KeyPairOwner is the key-pair owner's account scan and cleanup.
type KeyPairOwner interface {
ScanAccount(ctx context.Context, accountID string) (*ec2key.AccountScan, error)
CleanupAccount(ctx context.Context, accountID string, settledBefore time.Time, referenced func(keyName string) string) (*ec2key.AccountCleanup, error)
}
// PlacementGroupOwner is the placement-group owner's account scan and cleanup.
type PlacementGroupOwner interface {
ScanAccount(ctx context.Context, accountID string) (*ec2placementgroup.AccountScan, error)
CleanupAccount(ctx context.Context, accountID string, referenced func(groupID, groupName string) string) (*ec2placementgroup.AccountCleanup, error)
}
// LaunchTemplateOwner is the launch-template owner's account scan and residue
// cleanup; live templates are never deleted by account cleanup.
type LaunchTemplateOwner interface {
ScanAccount(ctx context.Context, accountID string) (*ec2launchtemplate.AccountScan, error)
CleanupAccountResidue(ctx context.Context, accountID string, settledBefore time.Time) (*ec2launchtemplate.AccountCleanup, error)
}
// TagIndexOwner lists and clears the account's tag-index entries.
type TagIndexOwner interface {
ScanAccountResourceIDs(ctx context.Context, accountID string, prefixes ...string) ([]string, error)
DeleteAllTags(ctx context.Context, accountID, resourceID string) error
}
var (
_ KeyPairOwner = (*ec2key.KeyServiceImpl)(nil)
_ PlacementGroupOwner = (*ec2placementgroup.PlacementGroupServiceImpl)(nil)
_ LaunchTemplateOwner = (*ec2launchtemplate.LaunchTemplateServiceImpl)(nil)
_ TagIndexOwner = (*ec2tags.TagsServiceImpl)(nil)
)
// DefaultSettleWindow is how old headerless residue must be before it is
// removed; anything younger may be a create still in flight.
const DefaultSettleWindow = 10 * time.Minute
// Cleanup composes the owner cleanups and re-scans.
type Cleanup struct {
keys KeyPairOwner
groups PlacementGroupOwner
templates LaunchTemplateOwner
tags TagIndexOwner
refs ReferenceReaders
// SettleWindow overrides DefaultSettleWindow when positive.
SettleWindow time.Duration
now func() time.Time
}
// New builds a cleanup over the four owners and the reference readers. A nil
// reader counts as a failed check, so the kinds it guards delete nothing.
func New(keys KeyPairOwner, groups PlacementGroupOwner, templates LaunchTemplateOwner, tags TagIndexOwner, refs ReferenceReaders) *Cleanup {
return &Cleanup{keys: keys, groups: groups, templates: templates, tags: tags, refs: refs, now: time.Now}
}
// Resource-ID prefixes of the three kinds in the tag index.
const (
keyPairIDPrefix = "key-"
placementGroupIDPrefix = "pg-"
launchTemplateIDPrefix = "lt-"
)
// CleanupPlatform deletes the account's key pairs, empty placement groups and
// launch templates, removes settled residue and orphaned tag-index entries,
// then re-scans every owner store to build the result. It is safe to re-run.
func (c *Cleanup) CleanupPlatform(ctx context.Context, req *Request) (*Result, error) {
if req == nil || req.AccountID == "" {
return nil, awserrors.Errorf(awserrors.ErrorMissingParameter, "The request must specify an account ID.")
}
if !awsidentifiers.IsAccountID(req.AccountID) {
return nil, awserrors.Errorf(awserrors.ErrorInvalidParameterValue, "The account ID must be 12 digits.")
}
start := time.Now()
window := c.SettleWindow
if window <= 0 {
window = DefaultSettleWindow
}
settledBefore := c.now().Add(-window)
acct := req.AccountID
res := &Result{AccountID: acct, CorrelationRef: req.CorrelationRef}
refs := c.readReferences(ctx, acct)
keyRun := c.cleanKeyPairs(ctx, acct, settledBefore, refs)
groupRun := c.cleanPlacementGroups(ctx, acct, refs)
templateRun := c.cleanLaunchTemplates(ctx, acct, settledBefore)
// Tags are listed before the owner re-scans, so a resource created in
// between is seen as owned and keeps its tags.
tagKind := c.startKind(ec2v1.PlatformKindTagIndex)
tagged, tagErr := c.tags.ScanAccountResourceIDs(ctx, acct, keyPairIDPrefix, placementGroupIDPrefix, launchTemplateIDPrefix)
if tagErr != nil {
slog.WarnContext(ctx, "Platform cleanup: tag index listing failed", "err", tagErr)
tagKind.incomplete(ec2v1.PlatformReasonStoreListFailed)
}
owners := map[string]ownedIDs{
keyPairIDPrefix: c.finishKeyPairs(ctx, acct, keyRun, res),
placementGroupIDPrefix: c.finishPlacementGroups(ctx, acct, groupRun, res),
launchTemplateIDPrefix: c.finishLaunchTemplates(ctx, acct, templateRun, res),
}
res.Kinds = []KindReport{keyRun.kind.report, groupRun.kind.report, templateRun.kind.report}
res.Kinds = append(res.Kinds, c.cleanTagIndex(ctx, acct, tagKind, tagged, tagErr == nil, owners, res))
slog.InfoContext(ctx, "Platform cleanup completed", "accountID", acct, "correlationRef", req.CorrelationRef,
"items", len(res.Items), "duration_ms", otelsetup.Millis(time.Since(start)))
return res, nil
}
// ownedIDs is the set of IDs whose owner record a re-scan found, live or
// unreadable; resolved is false unless the re-scan listed and read every
// record, so an absent ID is not proof its tags are orphaned.
type ownedIDs struct {
ids map[string]bool
resolved bool
}
// kindRun accumulates one kind's report across its cleanup and re-scan.
type kindRun struct {
report KindReport
found bool
}
func (c *Cleanup) startKind(kind string) *kindRun {
return &kindRun{report: KindReport{Kind: kind, StartedAt: c.now().UTC()}}
}
func (k *kindRun) incomplete(reason string) {
if !slices.Contains(k.report.IncompleteReasons, reason) {
k.report.IncompleteReasons = append(k.report.IncompleteReasons, reason)
}
}
func (c *Cleanup) finishKind(k *kindRun) {
k.report.EndedAt = c.now().UTC()
switch {
case len(k.report.IncompleteReasons) > 0:
k.report.Status = ec2v1.PlatformStatusScanIncomplete
case k.found:
k.report.Status = ec2v1.PlatformStatusRecordsFound
default:
k.report.Status = ec2v1.PlatformStatusObservedClean
}
}
// attempts is what an owner said it did, by resource ID. kept holds items
// left on purpose, with the fixed reason.
type attempts struct {
deleted, failed map[string]bool
blocked map[string]int
kept map[string]string
}
func newAttempts() attempts {
return attempts{deleted: map[string]bool{}, failed: map[string]bool{}, blocked: map[string]int{}, kept: map[string]string{}}
}
// itemDisposition decides one ID from what the owner did and whether the
// re-scan still found it. Only a complete re-scan that misses an ID is deleted;
// an item kept on purpose stays blocked unless that re-scan shows it gone.
func itemDisposition(id string, did attempts, present, rescanned bool) (string, string) {
switch {
case did.kept[id] != "" && (present || !rescanned):
return DispositionBlocked, did.kept[id]
case !rescanned:
return DispositionFailed, ReasonAbsenceUnproven
case !present:
return DispositionDeleted, ""
case did.blocked[id] > 0:
return DispositionBlocked, ReasonInstanceMembers
case did.failed[id]:
return DispositionFailed, ReasonOwnerDeleteFailed
case did.deleted[id]:
return DispositionFailed, ReasonStillPresent
default:
return DispositionResidue, ReasonAppearedDuringCleanup
}
}
func (r *Result) addItem(k *kindRun, id, disposition, reason string, members int) {
if disposition != DispositionDeleted {
k.found = true
}
r.Items = append(r.Items, Item{ResourceID: id, Kind: k.report.Kind, Disposition: disposition, Reason: reason, MemberCount: members})
}
func (r *Result) addCount(k *kindRun, class, disposition, reason string, n int) {
if n <= 0 {
return
}
if disposition != DispositionDeleted {
k.found = true
}
r.Counts = append(r.Counts, Count{Kind: k.report.Kind, Class: class, Disposition: disposition, Reason: reason, Count: n})
}
// residueCounts reports one headerless class: the proven decrease as deleted
// (never more than the owner removed), and what remains split by cause, kept
// records first, by reason in a fixed order.
func (r *Result) residueCounts(k *kindRun, class string, before, after, removed, failed, unsettled int, kept map[string]int) {
r.addCount(k, class, DispositionDeleted, "", min(removed, max(0, before-after)))
left := after
for _, reason := range keptReasons {
n := min(left, kept[reason])
r.addCount(k, class, DispositionBlocked, reason, n)
left -= n
}
failedLeft := min(left, failed)
unsettledLeft := min(left-failedLeft, unsettled)
r.addCount(k, class, DispositionFailed, ReasonOwnerDeleteFailed, failedLeft)
r.addCount(k, class, DispositionResidue, ReasonRecentlyWritten, unsettledLeft)
r.addCount(k, class, DispositionFailed, ReasonStillPresent, left-failedLeft-unsettledLeft)
}
// keptReasons are the reasons an owner may keep a record, in report order.
var keptReasons = []string{ReasonReferenceCheckFailed, ReasonInstanceReference, ReasonSpotRequestReference, ReasonLaunchTemplateReference}
// sortedUnion returns the distinct IDs of all lists, sorted.
func sortedUnion(lists ...[]string) []string {
var out []string
for _, l := range lists {
for _, id := range l {
if !slices.Contains(out, id) {
out = append(out, id)
}
}
}
slices.Sort(out)
return out
}
func idPrefix(id string) string {
for _, prefix := range []string{keyPairIDPrefix, placementGroupIDPrefix, launchTemplateIDPrefix} {
if strings.HasPrefix(id, prefix) {
return prefix
}
}
return ""
}
@@ -0,0 +1,777 @@
package accountcleanup
//test:in-package — the clock is injected to place residue outside the settle
// window without waiting for it.
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"strings"
"testing"
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/aws/aws-sdk-go/service/s3"
ec2v1 "github.com/mulgadc/spinifex/contracts/ec2/v1"
testutil "github.com/mulgadc/spinifex/internal/testkit"
"github.com/mulgadc/spinifex/spinifex/bootstrap/config"
ec2key "github.com/mulgadc/spinifex/spinifex/domains/ec2/key"
ec2launchtemplate "github.com/mulgadc/spinifex/spinifex/domains/ec2/launchtemplate"
ec2placementgroup "github.com/mulgadc/spinifex/spinifex/domains/ec2/placementgroup"
ec2spotinstance "github.com/mulgadc/spinifex/spinifex/domains/ec2/spotinstance"
ec2tags "github.com/mulgadc/spinifex/spinifex/domains/ec2/tags"
awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
"github.com/mulgadc/spinifex/spinifex/providers/objectstore"
"github.com/nats-io/nats.go/jetstream"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const (
testAccount = "111122223333"
otherAccount = "999988887777"
testBucket = "test-bucket"
testPubKey = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl"
memberID = "i-0123456789abcdef0"
staleTagID = "pg-0stale000000000"
)
var errInjected = errors.New("injected failure")
// faultyStore fails object deletes its predicate selects.
type faultyStore struct {
*objectstore.MemoryObjectStore
failDelete func(key string) bool
}
var _ objectstore.ObjectStore = (*faultyStore)(nil)
func (f *faultyStore) DeleteObject(ctx context.Context, in *s3.DeleteObjectInput) (*s3.DeleteObjectOutput, error) {
if f.failDelete != nil && f.failDelete(aws.StringValue(in.Key)) {
return nil, errInjected
}
return f.MemoryObjectStore.DeleteObject(ctx, in)
}
// owners are the real EC2 owner services over an embedded JetStream, wired to
// the tag index as the daemon wires them.
type owners struct {
store *faultyStore
keys *ec2key.KeyServiceImpl
groups *ec2placementgroup.PlacementGroupServiceImpl
templates *ec2launchtemplate.LaunchTemplateServiceImpl
tags *ec2tags.TagsServiceImpl
spot *ec2spotinstance.SpotInstanceServiceImpl
instances *fakeInstances
js jetstream.JetStream
}
// fakeInstances stands in for the cluster-wide instance listing, which needs
// every daemon to answer; its own refusal is covered separately.
type fakeInstances struct {
refs *References
err error
}
func (f *fakeInstances) AccountInstanceReferences(context.Context, string) (*References, error) {
if f.err != nil {
return nil, f.err
}
return f.refs, nil
}
func newOwners(t *testing.T) *owners {
t.Helper()
_, nc, js := testutil.StartTestJetStream(t)
o := &owners{store: &faultyStore{MemoryObjectStore: objectstore.NewMemoryObjectStore()}}
tagsKV, err := ec2tags.GetOrCreateTagsBucket(t.Context(), js)
require.NoError(t, err)
o.tags = ec2tags.NewTagsServiceImplWithStore(&config.Config{Predastore: config.PredastoreConfig{Bucket: testBucket}}, o.store, tagsKV)
o.keys = ec2key.NewKeyServiceImplWithStore(o.store, testBucket)
o.keys.SetCentralTagStore(o.tags)
o.groups, err = ec2placementgroup.NewPlacementGroupServiceImplWithNATS(t.Context(), nil, nc)
require.NoError(t, err)
o.groups.SetCentralTagStore(o.tags)
o.templates, err = ec2launchtemplate.NewLaunchTemplateServiceImplWithNATS(t.Context(), nil, nc)
require.NoError(t, err)
o.templates.SetCentralTagStore(o.tags)
o.spot, err = ec2spotinstance.NewSpotInstanceServiceImplWithNATS(t.Context(), nil, nc)
require.NoError(t, err)
o.instances = &fakeInstances{refs: newReferences()}
o.js = js
return o
}
// readers are the real spot and launch-template checks and the instance fake.
func (o *owners) readers() ReferenceReaders {
return ReferenceReaders{Instances: o.instances, Spot: o.spot, Templates: o.templates}
}
func (o *owners) newCleanup() *Cleanup {
return New(o.keys, o.groups, o.templates, o.tags, o.readers())
}
// cleanup runs over the owners with residue of any age treated as settled.
func (o *owners) cleanup(t *testing.T) *Result {
t.Helper()
return run(t, o.newCleanup())
}
func run(t *testing.T, c *Cleanup) *Result {
t.Helper()
c.now = func() time.Time { return time.Now().Add(time.Hour) }
res, err := c.CleanupPlatform(t.Context(), &Request{AccountID: testAccount, CorrelationRef: "ref-1"})
require.NoError(t, err)
return res
}
func (o *owners) importKey(t *testing.T, account, name string) string {
t.Helper()
out, err := o.keys.ImportKeyPair(t.Context(), &ec2.ImportKeyPairInput{
KeyName: aws.String(name), PublicKeyMaterial: []byte(testPubKey),
TagSpecifications: []*ec2.TagSpecification{{ResourceType: aws.String("key-pair"), Tags: secretTags()}},
}, account)
require.NoError(t, err)
return aws.StringValue(out.KeyPairId)
}
func (o *owners) createGroup(t *testing.T, account, name string) string {
t.Helper()
out, err := o.groups.CreatePlacementGroup(t.Context(), &ec2.CreatePlacementGroupInput{
GroupName: aws.String(name), Strategy: aws.String(ec2.PlacementStrategyCluster),
}, account)
require.NoError(t, err)
id := aws.StringValue(out.PlacementGroup.GroupId)
o.tag(t, account, id)
return id
}
func (o *owners) addMember(t *testing.T, group string) {
t.Helper()
reserved, err := o.groups.ReserveClusterNode(t.Context(), &ec2placementgroup.ReserveClusterNodeInput{
GroupName: group, EligibleNodes: []string{"node-1"},
}, testAccount)
require.NoError(t, err)
_, err = o.groups.FinalizeClusterInstances(t.Context(), &ec2placementgroup.FinalizeClusterInstancesInput{
GroupName: group, NodeInstances: map[string][]string{reserved.TargetNode: {memberID}},
}, testAccount)
require.NoError(t, err)
}
func (o *owners) removeMember(t *testing.T, group string) {
t.Helper()
_, err := o.groups.RemoveInstance(t.Context(), &ec2placementgroup.RemoveInstanceInput{
GroupName: group, NodeName: "node-1", InstanceID: memberID,
}, testAccount)
require.NoError(t, err)
}
func (o *owners) createTemplate(t *testing.T, account, name string) string {
t.Helper()
out, err := o.templates.CreateLaunchTemplate(t.Context(), &ec2.CreateLaunchTemplateInput{
LaunchTemplateName: aws.String(name),
LaunchTemplateData: &ec2.RequestLaunchTemplateData{
ImageId: aws.String("ami-123"),
UserData: aws.String(base64.StdEncoding.EncodeToString([]byte("secret-user-data"))),
},
}, account)
require.NoError(t, err)
id := aws.StringValue(out.LaunchTemplate.LaunchTemplateId)
o.tag(t, account, id)
return id
}
func secretTags() []*ec2.Tag {
return []*ec2.Tag{{Key: aws.String("secret-tag-key"), Value: aws.String("secret-tag-value")}}
}
func (o *owners) tag(t *testing.T, account, id string) {
t.Helper()
_, err := o.tags.CreateTags(t.Context(), &ec2.CreateTagsInput{Resources: []*string{aws.String(id)}, Tags: secretTags()}, account)
require.NoError(t, err)
}
func (o *owners) putObject(t *testing.T, key, body string) {
t.Helper()
_, err := o.store.PutObject(t.Context(), &s3.PutObjectInput{
Bucket: aws.String(testBucket), Key: aws.String("keys/" + testAccount + "/" + key), Body: strings.NewReader(body),
})
require.NoError(t, err)
}
func statuses(res *Result) map[string]string {
out := map[string]string{}
for _, k := range res.Kinds {
out[k.Kind] = k.Status
}
return out
}
func allStatus(status string) map[string]string {
return map[string]string{
ec2v1.PlatformKindKeyPair: status, ec2v1.PlatformKindPlacementGroup: status,
ec2v1.PlatformKindLaunchTemplate: status, ec2v1.PlatformKindTagIndex: status,
}
}
func item(id, kind, disposition, reason string) Item {
return Item{ResourceID: id, Kind: kind, Disposition: disposition, Reason: reason}
}
// One run deletes every unreferenced key pair and empty placement group with
// its tag-index entry, removes settled hidden residue and orphaned tags,
// blocks on a group holding an instance, and leaves another account alone.
// The next run, once the member leaves, converges to clean.
func TestCleanupPlatformDeletesBlocksAndConverges(t *testing.T) {
o := newOwners(t)
keyID := o.importKey(t, testAccount, "secret-key-name")
emptyID := o.createGroup(t, testAccount, "secret-empty-group")
heldID := o.createGroup(t, testAccount, "secret-held-group")
o.addMember(t, "secret-held-group")
o.putObject(t, "secret-orphan-key", testPubKey)
o.tag(t, testAccount, staleTagID)
theirKey := o.importKey(t, otherAccount, "theirs")
o.createGroup(t, otherAccount, "theirs")
o.createTemplate(t, otherAccount, "theirs")
res := o.cleanup(t)
assert.Equal(t, testAccount, res.AccountID)
assert.Equal(t, "ref-1", res.CorrelationRef)
assert.ElementsMatch(t, []Item{
item(keyID, ec2v1.PlatformKindKeyPair, DispositionDeleted, ""),
item(emptyID, ec2v1.PlatformKindPlacementGroup, DispositionDeleted, ""),
{ResourceID: heldID, Kind: ec2v1.PlatformKindPlacementGroup, Disposition: DispositionBlocked, Reason: ReasonInstanceMembers, MemberCount: 1},
item(staleTagID, ec2v1.PlatformKindTagIndex, DispositionDeleted, ""),
}, res.Items)
assert.Equal(t, []Count{{
Kind: ec2v1.PlatformKindKeyPair, Class: ec2v1.PlatformClassPublicKeyWithoutMetadata, Disposition: DispositionDeleted, Count: 1,
}}, res.Counts)
// The held group's own tags are its, not residue.
assert.Equal(t, map[string]string{
ec2v1.PlatformKindKeyPair: ec2v1.PlatformStatusObservedClean,
ec2v1.PlatformKindPlacementGroup: ec2v1.PlatformStatusRecordsFound,
ec2v1.PlatformKindLaunchTemplate: ec2v1.PlatformStatusObservedClean,
ec2v1.PlatformKindTagIndex: ec2v1.PlatformStatusRecordsFound,
}, statuses(res))
theirs, err := o.keys.ScanAccount(t.Context(), otherAccount)
require.NoError(t, err)
assert.Equal(t, []string{theirKey}, theirs.LiveKeyPairIDs)
theirTags, err := o.tags.ScanAccountResourceIDs(t.Context(), otherAccount, "key-", "pg-", "lt-")
require.NoError(t, err)
assert.Len(t, theirTags, 3)
o.removeMember(t, "secret-held-group")
res = o.cleanup(t)
assert.Equal(t, []Item{item(heldID, ec2v1.PlatformKindPlacementGroup, DispositionDeleted, "")}, res.Items,
"items finished on an earlier run are not reported again")
assert.Empty(t, res.Counts)
assert.Equal(t, allStatus(ec2v1.PlatformStatusObservedClean), statuses(res))
res = o.cleanup(t)
assert.Empty(t, res.Items)
assert.Equal(t, allStatus(ec2v1.PlatformStatusObservedClean), statuses(res))
}
// The result carries IDs, kinds, dispositions, fixed reasons and counts only:
// no names, tags, key material, user data, member instance IDs or error text.
func TestCleanupPlatformResultDisclosesNoTenantData(t *testing.T) {
o := newOwners(t)
o.importKey(t, testAccount, "secret-key-name")
o.createGroup(t, testAccount, "secret-held-group")
o.addMember(t, "secret-held-group")
o.createTemplate(t, testAccount, "secret-template-name")
o.putObject(t, "secret-orphan-key", testPubKey)
o.putObject(t, "key-0badbadbadbadbad0.json", "{secret-broken")
failing := o.importKey(t, testAccount, "secret-failing-key")
o.store.failDelete = func(key string) bool { return strings.HasSuffix(key, failing+".json") }
o.importKey(t, testAccount, "secret-instance-key")
o.instances.refs.addKeys("secret-instance-key")
o.importKey(t, testAccount, "secret-spot-key")
o.openSpotRequest(t, "sir-0secret000000001", "secret-spot-key", "")
o.importKey(t, testAccount, "secret-template-key")
_, err := o.templates.CreateLaunchTemplate(t.Context(), &ec2.CreateLaunchTemplateInput{
LaunchTemplateName: aws.String("secret-referencing-template"),
LaunchTemplateData: &ec2.RequestLaunchTemplateData{KeyName: aws.String("secret-template-key")},
}, testAccount)
require.NoError(t, err)
body, err := json.Marshal(o.cleanup(t))
require.NoError(t, err)
for _, leak := range []string{
"secret", "AAAAC3Nz", memberID, "node-1", "injected", "sir-",
base64.StdEncoding.EncodeToString([]byte("secret-user-data")),
} {
assert.NotContains(t, string(body), leak)
}
assert.Contains(t, string(body), failing, "the IDs themselves are reported")
}
// A delete that fails part way is failed, not deleted; the re-run completes
// the partly deleted key pair and reports it deleted exactly once.
func TestCleanupPlatformInterruptedRunConverges(t *testing.T) {
o := newOwners(t)
keyID := o.importKey(t, testAccount, "deploy")
o.store.failDelete = func(key string) bool { return strings.HasSuffix(key, ".json") }
res := o.cleanup(t)
assert.Equal(t, []Item{item(keyID, ec2v1.PlatformKindKeyPair, DispositionFailed, ReasonOwnerDeleteFailed)}, res.Items)
assert.Equal(t, ec2v1.PlatformStatusRecordsFound, statuses(res)[ec2v1.PlatformKindKeyPair])
scan, err := o.keys.ScanAccount(t.Context(), testAccount)
require.NoError(t, err)
assert.Equal(t, []string{keyID}, scan.LiveKeyPairIDs, "the metadata anchors the unfinished delete")
o.store.failDelete = nil
res = o.cleanup(t)
assert.Equal(t, []Item{item(keyID, ec2v1.PlatformKindKeyPair, DispositionDeleted, "")}, res.Items)
assert.Equal(t, allStatus(ec2v1.PlatformStatusObservedClean), statuses(res))
}
// Records the owner cannot read are never deleted and stay reported as residue,
// and orphaned tags whose owner may be one of them are left.
func TestCleanupPlatformLeavesUnreadableRecords(t *testing.T) {
o := newOwners(t)
o.putObject(t, "key-0badbadbadbadbad0.json", "{not json")
o.putObject(t, "orphan", testPubKey)
res := o.cleanup(t)
assert.Empty(t, res.Items)
assert.ElementsMatch(t, []Count{
{Kind: ec2v1.PlatformKindKeyPair, Class: ec2v1.PlatformClassUnreadableRecord, Disposition: DispositionResidue, Reason: ReasonUnreadableRecord, Count: 1},
{Kind: ec2v1.PlatformKindKeyPair, Class: ec2v1.PlatformClassPublicKeyUnresolved, Disposition: DispositionResidue, Reason: ReasonOwnerRecordsUnresolved, Count: 1},
}, res.Counts)
assert.Equal(t, ec2v1.PlatformStatusRecordsFound, statuses(res)[ec2v1.PlatformKindKeyPair])
scan, err := o.keys.ScanAccount(t.Context(), testAccount)
require.NoError(t, err)
assert.Equal(t, []string{"key-0badbadbadbadbad0"}, scan.UnreadableKeyPairIDs)
}
// Residue younger than the settle window may be a create in flight: it is
// reported, not removed.
func TestCleanupPlatformKeepsUnsettledResidue(t *testing.T) {
o := newOwners(t)
o.putObject(t, "orphan", testPubKey)
c := o.newCleanup()
res, err := c.CleanupPlatform(t.Context(), &Request{AccountID: testAccount})
require.NoError(t, err)
assert.Equal(t, []Count{{
Kind: ec2v1.PlatformKindKeyPair, Class: ec2v1.PlatformClassPublicKeyWithoutMetadata,
Disposition: DispositionResidue, Reason: ReasonRecentlyWritten, Count: 1,
}}, res.Counts)
}
// lyingKeys reports every key pair deleted without deleting anything.
type lyingKeys struct{ *ec2key.KeyServiceImpl }
func (l lyingKeys) CleanupAccount(ctx context.Context, account string, _ time.Time, _ func(string) string) (*ec2key.AccountCleanup, error) {
scan, err := l.ScanAccount(ctx, account)
if err != nil {
return nil, err
}
return &ec2key.AccountCleanup{DeletedKeyPairIDs: scan.LiveKeyPairIDs}, nil
}
// An owner that reports a delete is not believed: the re-scan still finds the
// record, so it is failed and still present.
func TestCleanupPlatformProvesDeletesByRescan(t *testing.T) {
o := newOwners(t)
keyID := o.importKey(t, testAccount, "deploy")
res := run(t, New(lyingKeys{o.keys}, o.groups, o.templates, o.tags, o.readers()))
assert.Contains(t, res.Items, item(keyID, ec2v1.PlatformKindKeyPair, DispositionFailed, ReasonStillPresent))
assert.Equal(t, ec2v1.PlatformStatusRecordsFound, statuses(res)[ec2v1.PlatformKindKeyPair])
}
// failingRescan fails every scan after the first, so the cleanup deletes but
// cannot prove it.
type failingRescan struct {
*ec2launchtemplate.LaunchTemplateServiceImpl
scans int
}
func (f *failingRescan) ScanAccount(ctx context.Context, account string) (*ec2launchtemplate.AccountScan, error) {
if f.scans++; f.scans > 1 {
return nil, errInjected
}
return f.LaunchTemplateServiceImpl.ScanAccount(ctx, account)
}
// A failed re-scan is incomplete, never clean: the template stays blocked, and
// a tag of that kind no reported resource owns stays, as residue.
func TestCleanupPlatformFailedRescanIsIncomplete(t *testing.T) {
o := newOwners(t)
ltID := o.createTemplate(t, testAccount, "web")
o.tag(t, testAccount, "lt-0stale000000000")
res := run(t, New(o.keys, o.groups, &failingRescan{LaunchTemplateServiceImpl: o.templates}, o.tags, o.readers()))
assert.Equal(t, []Item{
item(ltID, ec2v1.PlatformKindLaunchTemplate, DispositionBlocked, ReasonReferenceUnknown),
item("lt-0stale000000000", ec2v1.PlatformKindTagIndex, DispositionResidue, ReasonOwnerRecordsUnresolved),
}, res.Items)
assert.Equal(t, ec2v1.PlatformStatusScanIncomplete, statuses(res)[ec2v1.PlatformKindLaunchTemplate])
assert.Equal(t, ec2v1.PlatformStatusScanIncomplete, statuses(res)[ec2v1.PlatformKindTagIndex])
tagged, err := o.tags.ScanAccountResourceIDs(t.Context(), testAccount, "lt-")
require.NoError(t, err)
assert.ElementsMatch(t, []string{"lt-0stale000000000", ltID}, tagged)
}
// failingTags fails every tag-index clear.
type failingTags struct{ *ec2tags.TagsServiceImpl }
func (failingTags) DeleteAllTags(context.Context, string, string) error { return errInjected }
// A tag-index entry that cannot be cleared is failed and the kind keeps
// records, never clean.
func TestCleanupPlatformReportsUnclearedTags(t *testing.T) {
o := newOwners(t)
o.tag(t, testAccount, staleTagID)
res := run(t, New(o.keys, o.groups, o.templates, failingTags{o.tags}, o.readers()))
assert.Equal(t, []Item{item(staleTagID, ec2v1.PlatformKindTagIndex, DispositionFailed, ReasonOwnerDeleteFailed)}, res.Items)
assert.Equal(t, ec2v1.PlatformStatusRecordsFound, statuses(res)[ec2v1.PlatformKindTagIndex])
}
// failingCleanup fails the owner cleanup's own listing.
type failingCleanup struct {
*ec2placementgroup.PlacementGroupServiceImpl
}
func (failingCleanup) CleanupAccount(context.Context, string, func(string, string) string) (*ec2placementgroup.AccountCleanup, error) {
return nil, errInjected
}
// An owner cleanup that cannot list deletes nothing and is incomplete; the
// group it could not reach is still reported, from the re-scan.
func TestCleanupPlatformOwnerListingFailureIsIncomplete(t *testing.T) {
o := newOwners(t)
groupID := o.createGroup(t, testAccount, "web")
res := run(t, New(o.keys, failingCleanup{o.groups}, o.templates, o.tags, o.readers()))
assert.Equal(t, ec2v1.PlatformStatusScanIncomplete, statuses(res)[ec2v1.PlatformKindPlacementGroup])
assert.Contains(t, res.Items, item(groupID, ec2v1.PlatformKindPlacementGroup, DispositionResidue, ReasonAppearedDuringCleanup))
}
func TestCleanupPlatformValidatesTheRequest(t *testing.T) {
c := New(nil, nil, nil, nil, ReferenceReaders{})
for name, tc := range map[string]struct {
req *Request
code string
}{
"nil": {nil, awserrors.ErrorMissingParameter},
"empty": {&Request{}, awserrors.ErrorMissingParameter},
"malformed": {&Request{AccountID: "acct-1"}, awserrors.ErrorInvalidParameterValue},
} {
t.Run(name, func(t *testing.T) {
_, err := c.CleanupPlatform(t.Context(), tc.req)
require.Error(t, err)
got, _ := awserrors.ResolveErrorCode(err)
assert.Equal(t, tc.code, got)
})
}
}
func (o *owners) openSpotRequest(t *testing.T, id, keyName, groupName string) {
t.Helper()
spec := &ec2.LaunchSpecification{ImageId: aws.String("ami-123"), KeyName: aws.String(keyName)}
if groupName != "" {
spec.Placement = &ec2.SpotPlacement{GroupName: aws.String(groupName)}
}
_, err := o.spot.PutSpotInstanceRequests(t.Context(), &ec2spotinstance.PutSpotRequestsInput{Requests: []*ec2.SpotInstanceRequest{{
SpotInstanceRequestId: aws.String(id), State: aws.String(ec2.SpotInstanceStateOpen), LaunchSpecification: spec,
}}}, testAccount)
require.NoError(t, err)
}
func (o *owners) putTemplateRecord(t *testing.T, key, value string) {
t.Helper()
kv, err := o.js.KeyValue(t.Context(), ec2launchtemplate.KVBucketLaunchTemplates)
require.NoError(t, err)
_, err = kv.Put(t.Context(), key, []byte(value))
require.NoError(t, err)
}
// A live launch template is never deleted: an EKS nodegroup may name it and
// EC2 cannot read EKS records. It stays blocked, described and tagged on every
// run; headerless version bodies and names, which nothing can resolve, are
// removed once settled.
func TestCleanupPlatformNeverDeletesLiveLaunchTemplates(t *testing.T) {
o := newOwners(t)
ltID := o.createTemplate(t, testAccount, "web")
o.putTemplateRecord(t, testAccount+".lt-0orphan000000000.v1", `{"version_number":1}`)
for range 2 {
res := o.cleanup(t)
assert.Equal(t, []Item{item(ltID, ec2v1.PlatformKindLaunchTemplate, DispositionBlocked, ReasonReferenceUnknown)}, res.Items)
assert.Equal(t, ec2v1.PlatformStatusRecordsFound, statuses(res)[ec2v1.PlatformKindLaunchTemplate])
assert.Equal(t, ec2v1.PlatformStatusRecordsFound, statuses(res)[ec2v1.PlatformKindTagIndex], "the template keeps its tags")
}
scan, err := o.templates.ScanAccount(t.Context(), testAccount)
require.NoError(t, err)
assert.Equal(t, []string{ltID}, scan.LiveTemplateIDs)
assert.Zero(t, scan.OrphanVersionBodies)
out, err := o.templates.DescribeLaunchTemplateVersions(t.Context(), &ec2.DescribeLaunchTemplateVersionsInput{LaunchTemplateId: aws.String(ltID)}, testAccount)
require.NoError(t, err)
assert.Len(t, out.LaunchTemplateVersions, 1, "the version body is untouched")
}
// A key pair a non-terminated instance names is blocked and kept, and so is an
// orphan public key the instance names, because IMDS serves it by name. A
// placement group a stopped instance names is blocked though it has no members.
func TestCleanupPlatformBlocksOnInstanceReferences(t *testing.T) {
o := newOwners(t)
usedKey := o.importKey(t, testAccount, "in-use")
freeKey := o.importKey(t, testAccount, "free")
o.putObject(t, "served-orphan", testPubKey)
usedGroup := o.createGroup(t, testAccount, "stopped-member")
o.instances.refs.addKeys("in-use", "served-orphan")
o.instances.refs.addGroups("stopped-member")
res := o.cleanup(t)
assert.ElementsMatch(t, []Item{
item(usedKey, ec2v1.PlatformKindKeyPair, DispositionBlocked, ReasonInstanceReference),
item(freeKey, ec2v1.PlatformKindKeyPair, DispositionDeleted, ""),
item(usedGroup, ec2v1.PlatformKindPlacementGroup, DispositionBlocked, ReasonInstanceReference),
}, res.Items)
assert.Equal(t, []Count{{
Kind: ec2v1.PlatformKindKeyPair, Class: ec2v1.PlatformClassPublicKeyWithoutMetadata,
Disposition: DispositionBlocked, Reason: ReasonInstanceReference, Count: 1,
}}, res.Counts)
scan, err := o.keys.ScanAccount(t.Context(), testAccount)
require.NoError(t, err)
assert.Equal(t, []string{usedKey}, scan.LiveKeyPairIDs)
assert.Equal(t, 1, scan.OrphanPublicKeys)
material, err := o.keys.GetPublicKeyMaterial(testAccount, "served-orphan")
require.NoError(t, err)
assert.Equal(t, testPubKey, material)
}
// An open spot request's launch specification blocks the key pair and the
// placement group it names until the request is cancelled.
func TestCleanupPlatformBlocksOnSpotRequests(t *testing.T) {
o := newOwners(t)
keyID := o.importKey(t, testAccount, "spot-key")
groupID := o.createGroup(t, testAccount, "spot-group")
o.openSpotRequest(t, "sir-0000000000000001", "spot-key", "spot-group")
res := o.cleanup(t)
assert.ElementsMatch(t, []Item{
item(keyID, ec2v1.PlatformKindKeyPair, DispositionBlocked, ReasonSpotRequestReference),
item(groupID, ec2v1.PlatformKindPlacementGroup, DispositionBlocked, ReasonSpotRequestReference),
}, res.Items)
_, err := o.spot.CancelSpotInstanceRequests(t.Context(), &ec2.CancelSpotInstanceRequestsInput{
SpotInstanceRequestIds: []*string{aws.String("sir-0000000000000001")},
}, testAccount)
require.NoError(t, err)
res = o.cleanup(t)
assert.ElementsMatch(t, []Item{
item(keyID, ec2v1.PlatformKindKeyPair, DispositionDeleted, ""),
item(groupID, ec2v1.PlatformKindPlacementGroup, DispositionDeleted, ""),
}, res.Items)
}
// failingTemplates fails the launch-template reference check.
type failingTemplates struct{}
func (failingTemplates) AccountTemplateReferences(context.Context, string) (*ec2launchtemplate.TemplateReferences, error) {
return nil, errInjected
}
// failingSpot fails the spot reference check.
type failingSpot struct{}
func (failingSpot) AccountLaunchReferences(context.Context, string) (*ec2spotinstance.LaunchReferences, error) {
return nil, errInjected
}
// A reference check that cannot complete is never read as "no references":
// key pairs, orphan public keys and placement groups are all kept, both kinds
// are incomplete, and launch-template residue, which no check guards, is
// still removed.
func TestCleanupPlatformFailedReferenceCheckDeletesNothing(t *testing.T) {
for name, build := range map[string]func(o *owners) *Cleanup{
"instance listing fails": func(o *owners) *Cleanup {
o.instances.err = errInjected
return o.newCleanup()
},
"spot listing fails": func(o *owners) *Cleanup {
r := o.readers()
r.Spot = failingSpot{}
return New(o.keys, o.groups, o.templates, o.tags, r)
},
"launch-template reading fails": func(o *owners) *Cleanup {
r := o.readers()
r.Templates = failingTemplates{}
return New(o.keys, o.groups, o.templates, o.tags, r)
},
"instance reader absent": func(o *owners) *Cleanup {
r := o.readers()
r.Instances = nil
return New(o.keys, o.groups, o.templates, o.tags, r)
},
"launch-template reader absent": func(o *owners) *Cleanup {
r := o.readers()
r.Templates = nil
return New(o.keys, o.groups, o.templates, o.tags, r)
},
} {
t.Run(name, func(t *testing.T) {
o := newOwners(t)
keyID := o.importKey(t, testAccount, "deploy")
o.putObject(t, "orphan", testPubKey)
groupID := o.createGroup(t, testAccount, "web")
o.putTemplateRecord(t, testAccount+".lt-0orphan000000000.v1", `{"version_number":1}`)
res := run(t, build(o))
assert.ElementsMatch(t, []Item{
item(keyID, ec2v1.PlatformKindKeyPair, DispositionBlocked, ReasonReferenceCheckFailed),
item(groupID, ec2v1.PlatformKindPlacementGroup, DispositionBlocked, ReasonReferenceCheckFailed),
}, res.Items)
assert.ElementsMatch(t, []Count{
{Kind: ec2v1.PlatformKindKeyPair, Class: ec2v1.PlatformClassPublicKeyWithoutMetadata, Disposition: DispositionBlocked, Reason: ReasonReferenceCheckFailed, Count: 1},
{Kind: ec2v1.PlatformKindLaunchTemplate, Class: ec2v1.PlatformClassVersionBodyWithoutHeader, Disposition: DispositionDeleted, Count: 1},
}, res.Counts)
for _, k := range res.Kinds {
if k.Kind == ec2v1.PlatformKindKeyPair || k.Kind == ec2v1.PlatformKindPlacementGroup {
assert.Equal(t, ec2v1.PlatformStatusScanIncomplete, k.Status, k.Kind)
assert.Equal(t, []string{ReasonReferenceCheckFailed}, k.IncompleteReasons, k.Kind)
}
}
keys, err := o.keys.ScanAccount(t.Context(), testAccount)
require.NoError(t, err)
assert.Equal(t, []string{keyID}, keys.LiveKeyPairIDs)
assert.Equal(t, 1, keys.OrphanPublicKeys)
groups, err := o.groups.ScanAccount(t.Context(), testAccount)
require.NoError(t, err)
assert.Len(t, groups.Groups, 1)
})
}
}
// A key pair or placement group named by any version of a live launch
// template, by name or group ID, is blocked: RunInstances may resolve any
// version by $Default, $Latest or number. The template itself stays blocked.
func TestCleanupPlatformBlocksOnLaunchTemplateReferences(t *testing.T) {
o := newOwners(t)
usedKey := o.importKey(t, testAccount, "template-key")
freeKey := o.importKey(t, testAccount, "free")
byName := o.createGroup(t, testAccount, "template-group")
byID := o.createGroup(t, testAccount, "by-id")
ltID := o.createTemplate(t, testAccount, "web")
_, err := o.templates.CreateLaunchTemplateVersion(t.Context(), &ec2.CreateLaunchTemplateVersionInput{
LaunchTemplateId: aws.String(ltID),
LaunchTemplateData: &ec2.RequestLaunchTemplateData{
KeyName: aws.String("template-key"),
Placement: &ec2.LaunchTemplatePlacementRequest{GroupName: aws.String("template-group")},
},
}, testAccount)
require.NoError(t, err)
_, err = o.templates.CreateLaunchTemplateVersion(t.Context(), &ec2.CreateLaunchTemplateVersionInput{
LaunchTemplateId: aws.String(ltID),
LaunchTemplateData: &ec2.RequestLaunchTemplateData{Placement: &ec2.LaunchTemplatePlacementRequest{GroupId: aws.String(byID)}},
}, testAccount)
require.NoError(t, err)
res := o.cleanup(t)
assert.ElementsMatch(t, []Item{
item(usedKey, ec2v1.PlatformKindKeyPair, DispositionBlocked, ReasonLaunchTemplateReference),
item(freeKey, ec2v1.PlatformKindKeyPair, DispositionDeleted, ""),
item(byName, ec2v1.PlatformKindPlacementGroup, DispositionBlocked, ReasonLaunchTemplateReference),
item(byID, ec2v1.PlatformKindPlacementGroup, DispositionBlocked, ReasonLaunchTemplateReference),
item(ltID, ec2v1.PlatformKindLaunchTemplate, DispositionBlocked, ReasonReferenceUnknown),
}, res.Items)
keys, err := o.keys.ScanAccount(t.Context(), testAccount)
require.NoError(t, err)
assert.Equal(t, []string{usedKey}, keys.LiveKeyPairIDs)
groups, err := o.groups.ScanAccount(t.Context(), testAccount)
require.NoError(t, err)
assert.Len(t, groups.Groups, 2)
}
// A live template version the owner cannot decode may name any key pair or
// group, so both kinds are blocked and incomplete and nothing is deleted.
func TestCleanupPlatformUndecodableTemplateVersionDeletesNothing(t *testing.T) {
o := newOwners(t)
keyID := o.importKey(t, testAccount, "deploy")
groupID := o.createGroup(t, testAccount, "web")
ltID := o.createTemplate(t, testAccount, "web")
o.putTemplateRecord(t, testAccount+"."+ltID+".v2", "{not json")
res := o.cleanup(t)
assert.Contains(t, res.Items, item(keyID, ec2v1.PlatformKindKeyPair, DispositionBlocked, ReasonReferenceCheckFailed))
assert.Contains(t, res.Items, item(groupID, ec2v1.PlatformKindPlacementGroup, DispositionBlocked, ReasonReferenceCheckFailed))
assert.Equal(t, ec2v1.PlatformStatusScanIncomplete, statuses(res)[ec2v1.PlatformKindKeyPair])
assert.Equal(t, ec2v1.PlatformStatusScanIncomplete, statuses(res)[ec2v1.PlatformKindPlacementGroup])
keys, err := o.keys.ScanAccount(t.Context(), testAccount)
require.NoError(t, err)
assert.Equal(t, []string{keyID}, keys.LiveKeyPairIDs)
}
// A tag-index entry whose owner re-scan holds an unreadable record is not
// cleared, because that record may be its owner; it is residue, not deleted.
func TestCleanupPlatformUnresolvedOwnerKeepsTags(t *testing.T) {
for name, seed := range map[string]func(t *testing.T, o *owners) string{
"key pair": func(t *testing.T, o *owners) string {
o.putObject(t, "key-0badbadbadbadbad0.json", "{not json")
return "key-0stale000000000"
},
"placement group": func(t *testing.T, o *owners) string {
kv, err := o.js.KeyValue(t.Context(), ec2placementgroup.KVBucketPlacementGroups)
require.NoError(t, err)
_, err = kv.Put(t.Context(), testAccount+".broken", []byte("{not json"))
require.NoError(t, err)
return staleTagID
},
"launch template": func(t *testing.T, o *owners) string {
o.putTemplateRecord(t, testAccount+".lt-0broken000000000", "{not json")
return "lt-0stale000000000"
},
} {
t.Run(name, func(t *testing.T) {
o := newOwners(t)
stale := seed(t, o)
o.tag(t, testAccount, stale)
res := o.cleanup(t)
assert.Contains(t, res.Items, item(stale, ec2v1.PlatformKindTagIndex, DispositionResidue, ReasonOwnerRecordsUnresolved))
assert.Equal(t, ec2v1.PlatformStatusScanIncomplete, statuses(res)[ec2v1.PlatformKindTagIndex])
tagged, err := o.tags.ScanAccountResourceIDs(t.Context(), testAccount, stale[:3])
require.NoError(t, err)
assert.Equal(t, []string{stale}, tagged)
})
}
}
// Settled launch-template residue the owner fails to remove is reported
// failed from the re-scan, never deleted.
func TestCleanupPlatformResidueRemovalFailureIsFailed(t *testing.T) {
o := newOwners(t)
o.putTemplateRecord(t, testAccount+".lt-0orphan000000000.v1", `{"version_number":1}`)
res := run(t, New(o.keys, o.groups, failingResidue{o.templates}, o.tags, o.readers()))
assert.Equal(t, []Count{{
Kind: ec2v1.PlatformKindLaunchTemplate, Class: ec2v1.PlatformClassVersionBodyWithoutHeader,
Disposition: DispositionFailed, Reason: ReasonOwnerDeleteFailed, Count: 1,
}}, res.Counts)
assert.Equal(t, ec2v1.PlatformStatusRecordsFound, statuses(res)[ec2v1.PlatformKindLaunchTemplate])
}
// failingResidue reports every settled orphan as a failed removal and removes nothing.
type failingResidue struct {
*ec2launchtemplate.LaunchTemplateServiceImpl
}
func (f failingResidue) CleanupAccountResidue(ctx context.Context, acct string, _ time.Time) (*ec2launchtemplate.AccountCleanup, error) {
scan, err := f.ScanAccount(ctx, acct)
if err != nil {
return nil, err
}
return &ec2launchtemplate.AccountCleanup{KeptTemplateIDs: scan.LiveTemplateIDs, OrphanBodiesFailed: scan.OrphanVersionBodies}, nil
}
@@ -0,0 +1,411 @@
package accountcleanup
import (
"context"
"log/slog"
"maps"
"slices"
"time"
ec2v1 "github.com/mulgadc/spinifex/contracts/ec2/v1"
ec2key "github.com/mulgadc/spinifex/spinifex/domains/ec2/key"
ec2launchtemplate "github.com/mulgadc/spinifex/spinifex/domains/ec2/launchtemplate"
ec2placementgroup "github.com/mulgadc/spinifex/spinifex/domains/ec2/placementgroup"
)
// Each kind runs scan, owner cleanup, then re-scan. The first scan only bounds
// how much nameless residue a run can claim to have removed.
type keyPairRun struct {
kind *kindRun
// unchecked: the reference check failed, so nothing was attempted.
unchecked bool
before *ec2key.AccountScan
cleaned *ec2key.AccountCleanup
did attempts
}
// cleanKeyPairs deletes only when the reference check completed; otherwise
// every key pair and orphan public key is kept and the kind is incomplete.
func (c *Cleanup) cleanKeyPairs(ctx context.Context, acct string, settledBefore time.Time, refs referenceCheck) *keyPairRun {
run := &keyPairRun{kind: c.startKind(ec2v1.PlatformKindKeyPair), did: newAttempts()}
if before, err := c.keys.ScanAccount(ctx, acct); err == nil {
run.before = before
}
if !refs.complete {
run.unchecked = true
run.kind.incomplete(ReasonReferenceCheckFailed)
return run
}
cleaned, err := c.keys.CleanupAccount(ctx, acct, settledBefore, refs.keyReason)
if err != nil {
slog.WarnContext(ctx, "Platform cleanup: key-pair cleanup failed", "err", err)
run.kind.incomplete(ec2v1.PlatformReasonStoreListFailed)
return run
}
run.cleaned = cleaned
markAll(run.did.deleted, cleaned.DeletedKeyPairIDs)
markAll(run.did.failed, cleaned.FailedKeyPairIDs)
maps.Copy(run.did.kept, cleaned.Referenced)
return run
}
func (c *Cleanup) finishKeyPairs(ctx context.Context, acct string, run *keyPairRun, res *Result) ownedIDs {
defer c.finishKind(run.kind)
after, err := c.keys.ScanAccount(ctx, acct)
if err != nil {
slog.WarnContext(ctx, "Platform cleanup: key-pair re-scan failed", "err", err)
run.kind.incomplete(ec2v1.PlatformReasonStoreListFailed)
after = nil
}
present := map[string]bool{}
if after != nil {
markAll(present, after.LiveKeyPairIDs)
markAll(present, after.UnreadableKeyPairIDs)
if after.ReadFailures > 0 {
run.kind.incomplete(ec2v1.PlatformReasonRecordReadFailed)
}
}
var beforeLive, afterLive, deleted, failed []string
if run.before != nil {
beforeLive = run.before.LiveKeyPairIDs
}
if after != nil {
afterLive = after.LiveKeyPairIDs
}
if run.cleaned != nil {
deleted, failed = run.cleaned.DeletedKeyPairIDs, run.cleaned.FailedKeyPairIDs
}
if run.unchecked {
keepAll(run.did.kept, beforeLive, ReasonReferenceCheckFailed)
keepAll(run.did.kept, afterLive, ReasonReferenceCheckFailed)
}
for _, id := range sortedUnion(beforeLive, deleted, failed, afterLive) {
disposition, reason := itemDisposition(id, run.did, present[id], after != nil)
res.addItem(run.kind, id, disposition, reason, 0)
}
if after == nil {
return ownedIDs{}
}
res.addCount(run.kind, ec2v1.PlatformClassUnreadableRecord, DispositionResidue, ReasonUnreadableRecord, len(after.UnreadableKeyPairIDs))
res.addCount(run.kind, ec2v1.PlatformClassPublicKeyUnresolved, DispositionResidue, ReasonOwnerRecordsUnresolved, after.UnresolvedPublicKeys)
var before, removed, failedN, unsettled int
kept := map[string]int{}
if run.before != nil {
before = run.before.OrphanPublicKeys
}
if run.cleaned != nil {
removed, failedN, unsettled = run.cleaned.OrphansRemoved, run.cleaned.OrphansFailed, run.cleaned.OrphansUnsettled
kept = run.cleaned.OrphansReferenced
} else if run.unchecked {
kept[ReasonReferenceCheckFailed] = after.OrphanPublicKeys
}
res.residueCounts(run.kind, ec2v1.PlatformClassPublicKeyWithoutMetadata, before, after.OrphanPublicKeys, removed, failedN, unsettled, kept)
return ownedIDs{ids: present, resolved: after.ReadFailures == 0 && len(after.UnreadableKeyPairIDs) == 0}
}
type placementGroupRun struct {
kind *kindRun
unchecked bool
before *ec2placementgroup.AccountScan
cleaned *ec2placementgroup.AccountCleanup
did attempts
}
// cleanPlacementGroups deletes only when the reference check completed.
func (c *Cleanup) cleanPlacementGroups(ctx context.Context, acct string, refs referenceCheck) *placementGroupRun {
run := &placementGroupRun{kind: c.startKind(ec2v1.PlatformKindPlacementGroup), did: newAttempts()}
if before, err := c.groups.ScanAccount(ctx, acct); err == nil {
run.before = before
}
if !refs.complete {
run.unchecked = true
run.kind.incomplete(ReasonReferenceCheckFailed)
return run
}
cleaned, err := c.groups.CleanupAccount(ctx, acct, refs.groupReason)
if err != nil {
slog.WarnContext(ctx, "Platform cleanup: placement-group cleanup failed", "err", err)
run.kind.incomplete(ec2v1.PlatformReasonStoreListFailed)
return run
}
run.cleaned = cleaned
markAll(run.did.deleted, cleaned.DeletedGroupIDs)
markAll(run.did.failed, cleaned.FailedGroupIDs)
for _, b := range cleaned.Blocked {
run.did.blocked[b.GroupID] = b.MemberCount
}
maps.Copy(run.did.kept, cleaned.Referenced)
return run
}
// finishPlacementGroups reports a group that still names instances as
// blocked with its member count, from the re-scan when it can.
func (c *Cleanup) finishPlacementGroups(ctx context.Context, acct string, run *placementGroupRun, res *Result) ownedIDs {
defer c.finishKind(run.kind)
after, err := c.groups.ScanAccount(ctx, acct)
if err != nil {
slog.WarnContext(ctx, "Platform cleanup: placement-group re-scan failed", "err", err)
run.kind.incomplete(ec2v1.PlatformReasonStoreListFailed)
after = nil
}
present := map[string]bool{}
members := map[string]int{}
var ids []string
if run.before != nil {
for _, g := range run.before.Groups {
ids = append(ids, g.GroupID)
}
}
if run.cleaned != nil {
ids = append(ids, run.cleaned.DeletedGroupIDs...)
ids = append(ids, run.cleaned.FailedGroupIDs...)
for _, b := range run.cleaned.Blocked {
ids = append(ids, b.GroupID)
}
for id := range run.cleaned.Referenced {
ids = append(ids, id)
}
}
if after != nil {
for _, g := range after.Groups {
present[g.GroupID] = true
members[g.GroupID] = g.MemberCount
ids = append(ids, g.GroupID)
}
if after.ReadFailures > 0 {
run.kind.incomplete(ec2v1.PlatformReasonRecordReadFailed)
}
}
if run.unchecked {
keepAll(run.did.kept, ids, ReasonReferenceCheckFailed)
}
for _, id := range sortedUnion(ids) {
if present[id] && (members[id] > 0 || run.did.blocked[id] > 0) {
count := members[id]
if count == 0 {
count = run.did.blocked[id]
}
res.addItem(run.kind, id, DispositionBlocked, ReasonInstanceMembers, count)
continue
}
disposition, reason := itemDisposition(id, run.did, present[id], after != nil)
res.addItem(run.kind, id, disposition, reason, 0)
}
if after == nil {
return ownedIDs{}
}
res.addCount(run.kind, ec2v1.PlatformClassUnreadableRecord, DispositionResidue, ReasonUnreadableRecord, after.Unreadable)
return ownedIDs{ids: present, resolved: after.ReadFailures == 0 && after.Unreadable == 0}
}
type launchTemplateRun struct {
kind *kindRun
before *ec2launchtemplate.AccountScan
cleaned *ec2launchtemplate.AccountCleanup
did attempts
}
func (c *Cleanup) cleanLaunchTemplates(ctx context.Context, acct string, settledBefore time.Time) *launchTemplateRun {
run := &launchTemplateRun{kind: c.startKind(ec2v1.PlatformKindLaunchTemplate), did: newAttempts()}
if before, err := c.templates.ScanAccount(ctx, acct); err == nil {
run.before = before
}
cleaned, err := c.templates.CleanupAccountResidue(ctx, acct, settledBefore)
if err != nil {
slog.WarnContext(ctx, "Platform cleanup: launch-template residue cleanup failed", "err", err)
run.kind.incomplete(ec2v1.PlatformReasonStoreListFailed)
return run
}
run.cleaned = cleaned
keepAll(run.did.kept, cleaned.KeptTemplateIDs, ReasonReferenceUnknown)
return run
}
func (c *Cleanup) finishLaunchTemplates(ctx context.Context, acct string, run *launchTemplateRun, res *Result) ownedIDs {
defer c.finishKind(run.kind)
after, err := c.templates.ScanAccount(ctx, acct)
if err != nil {
slog.WarnContext(ctx, "Platform cleanup: launch-template re-scan failed", "err", err)
run.kind.incomplete(ec2v1.PlatformReasonStoreListFailed)
after = nil
}
present := map[string]bool{}
if after != nil {
markAll(present, after.LiveTemplateIDs)
markAll(present, after.UnreadableTemplateIDs)
if after.ReadFailures > 0 {
run.kind.incomplete(ec2v1.PlatformReasonRecordReadFailed)
}
}
var beforeLive, afterLive, kept []string
if run.before != nil {
beforeLive = run.before.LiveTemplateIDs
}
if after != nil {
afterLive = after.LiveTemplateIDs
}
if run.cleaned != nil {
kept = run.cleaned.KeptTemplateIDs
}
// Live templates are never deleted here, so every one still present is
// blocked on the unknown EKS nodegroup reference, whenever it appeared.
for _, id := range afterLive {
run.did.kept[id] = ReasonReferenceUnknown
}
for _, id := range sortedUnion(beforeLive, kept, afterLive) {
disposition, reason := itemDisposition(id, run.did, present[id], after != nil)
res.addItem(run.kind, id, disposition, reason, 0)
}
if after == nil {
return ownedIDs{}
}
res.addCount(run.kind, ec2v1.PlatformClassUnreadableRecord, DispositionResidue, ReasonUnreadableRecord,
len(after.UnreadableTemplateIDs)+after.UnreadableNameIndexEntries)
res.addCount(run.kind, ec2v1.PlatformClassUnrecognisedTemplateEntry, DispositionResidue, ReasonUnrecognisedRecord, after.UnrecognisedEntries)
var c0 ec2launchtemplate.AccountCleanup
if run.cleaned != nil {
c0 = *run.cleaned
}
var beforeBodies, beforeNames int
if run.before != nil {
beforeBodies, beforeNames = run.before.OrphanVersionBodies, run.before.OrphanNameIndexEntries
}
res.residueCounts(run.kind, ec2v1.PlatformClassVersionBodyWithoutHeader, beforeBodies, after.OrphanVersionBodies,
c0.OrphanBodiesRemoved, c0.OrphanBodiesFailed, c0.OrphanBodiesUnsettled, nil)
res.residueCounts(run.kind, ec2v1.PlatformClassNameIndexWithoutHeader, beforeNames, after.OrphanNameIndexEntries,
c0.OrphanNamesRemoved, c0.OrphanNamesFailed, c0.OrphanNamesUnsettled, nil)
return ownedIDs{ids: present, resolved: after.ReadFailures == 0 && len(after.UnreadableTemplateIDs) == 0 &&
after.UnreadableNameIndexEntries == 0 && after.UnrecognisedEntries == 0}
}
// cleanTagIndex clears entries whose owner re-scan resolved and holds no
// record for the ID, then re-lists the index to prove each clear. Entries of
// resources that still exist are theirs, not residue, and are left alone.
func (c *Cleanup) cleanTagIndex(ctx context.Context, acct string, kind *kindRun, tagged []string, listed bool, owners map[string]ownedIDs, res *Result) KindReport {
if listed {
c.clearOrphanTags(ctx, acct, kind, tagged, owners, res)
}
c.finishKind(kind)
return kind.report
}
func (c *Cleanup) clearOrphanTags(ctx context.Context, acct string, kind *kindRun, tagged []string, owners map[string]ownedIDs, res *Result) {
// A resource this run reports as not deleted owns its tags even when its
// kind's re-scan failed.
reported := map[string]bool{}
for _, it := range res.Items {
if it.Disposition != DispositionDeleted {
reported[it.ResourceID] = true
}
}
var cleared []string
for _, id := range tagged {
owner := owners[idPrefix(id)]
switch {
case owner.ids[id], reported[id]:
kind.found = true
case !owner.resolved:
kind.incomplete(ec2v1.PlatformReasonOwnerRecordsUnresolved)
res.addItem(kind, id, DispositionResidue, ReasonOwnerRecordsUnresolved, 0)
default:
if err := c.tags.DeleteAllTags(ctx, acct, id); err != nil {
slog.WarnContext(ctx, "Platform cleanup: tag-index clear failed", "resourceId", id, "err", err)
res.addItem(kind, id, DispositionFailed, ReasonOwnerDeleteFailed, 0)
continue
}
cleared = append(cleared, id)
}
}
after, err := c.tags.ScanAccountResourceIDs(ctx, acct, keyPairIDPrefix, placementGroupIDPrefix, launchTemplateIDPrefix)
if err != nil {
slog.WarnContext(ctx, "Platform cleanup: tag index re-scan failed", "err", err)
kind.incomplete(ec2v1.PlatformReasonStoreListFailed)
}
for _, id := range cleared {
disposition, reason := itemDisposition(id, attempts{deleted: map[string]bool{id: true}}, slices.Contains(after, id), err == nil)
res.addItem(kind, id, disposition, reason, 0)
}
if len(after) > 0 {
kind.found = true
}
}
func keepAll(set map[string]string, ids []string, reason string) {
for _, id := range ids {
set[id] = reason
}
}
// referenceCheck is the outcome of reading what instance records, spot
// requests and launch-template versions name. complete is false when any
// reader failed or is absent.
type referenceCheck struct {
complete bool
instances, spot, template *References
}
// readReferences runs every reference check. A check that cannot complete is
// never read as "no references".
func (c *Cleanup) readReferences(ctx context.Context, acct string) referenceCheck {
r := c.refs
if r.Instances == nil || r.Spot == nil || r.Templates == nil {
slog.WarnContext(ctx, "Platform cleanup: reference reader not configured")
return referenceCheck{}
}
instances, err := r.Instances.AccountInstanceReferences(ctx, acct)
if err != nil || instances == nil {
slog.WarnContext(ctx, "Platform cleanup: instance reference check failed", "err", err)
return referenceCheck{}
}
launch, err := r.Spot.AccountLaunchReferences(ctx, acct)
if err != nil || launch == nil {
slog.WarnContext(ctx, "Platform cleanup: spot reference check failed", "err", err)
return referenceCheck{}
}
tmpl, err := r.Templates.AccountTemplateReferences(ctx, acct)
if err != nil || tmpl == nil {
slog.WarnContext(ctx, "Platform cleanup: launch-template reference check failed", "err", err)
return referenceCheck{}
}
inst := newReferences()
inst.merge(instances)
spot := newReferences()
spot.addKeys(launch.KeyNames...)
spot.addGroups(launch.PlacementGroupNames...)
template := newReferences()
template.addKeys(tmpl.KeyNames...)
template.addGroups(tmpl.PlacementGroupNames...)
template.addGroupIDs(tmpl.PlacementGroupIDs...)
return referenceCheck{complete: true, instances: inst, spot: spot, template: template}
}
func (r referenceCheck) keyReason(name string) string {
switch {
case r.instances.KeyNames[name]:
return ReasonInstanceReference
case r.spot.KeyNames[name]:
return ReasonSpotRequestReference
case r.template.KeyNames[name]:
return ReasonLaunchTemplateReference
}
return ""
}
func (r referenceCheck) groupReason(id, name string) string {
switch {
case r.instances.hasGroup(id, name):
return ReasonInstanceReference
case r.spot.hasGroup(id, name):
return ReasonSpotRequestReference
case r.template.hasGroup(id, name):
return ReasonLaunchTemplateReference
}
return ""
}
func markAll(set map[string]bool, ids []string) {
for _, id := range ids {
set[id] = true
}
}
@@ -0,0 +1,93 @@
package accountcleanup
import (
"context"
ec2launchtemplate "github.com/mulgadc/spinifex/spinifex/domains/ec2/launchtemplate"
ec2spotinstance "github.com/mulgadc/spinifex/spinifex/domains/ec2/spotinstance"
)
// References are the key-pair and placement-group names, and placement-group
// IDs, that other EC2 records still name. They select what to keep and are
// never reported.
type References struct {
KeyNames map[string]bool
PlacementGroupNames map[string]bool
PlacementGroupIDs map[string]bool
}
// InstanceReferenceReader reads the key-pair and placement-group names the
// account's non-terminated instance records carry, stopped ones included. It
// is complete or an error, never a partial answer. A later wiring supplies it.
type InstanceReferenceReader interface {
AccountInstanceReferences(ctx context.Context, accountID string) (*References, error)
}
// SpotReferenceReader reads the names the account's open or active spot
// requests would launch with, complete or an error.
type SpotReferenceReader interface {
AccountLaunchReferences(ctx context.Context, accountID string) (*ec2spotinstance.LaunchReferences, error)
}
// TemplateReferenceReader reads the names and IDs every version of every
// live launch template would launch with, complete or an error.
type TemplateReferenceReader interface {
AccountTemplateReferences(ctx context.Context, accountID string) (*ec2launchtemplate.TemplateReferences, error)
}
var (
_ SpotReferenceReader = (*ec2spotinstance.SpotInstanceServiceImpl)(nil)
_ TemplateReferenceReader = (*ec2launchtemplate.LaunchTemplateServiceImpl)(nil)
)
// ReferenceReaders are the checks run before key pairs and placement groups
// are deleted. A nil reader counts as a failed check.
type ReferenceReaders struct {
Instances InstanceReferenceReader
Spot SpotReferenceReader
Templates TemplateReferenceReader
}
func newReferences() *References {
return &References{KeyNames: map[string]bool{}, PlacementGroupNames: map[string]bool{}, PlacementGroupIDs: map[string]bool{}}
}
func (r *References) addKeys(names ...string) {
for _, name := range names {
if name != "" {
r.KeyNames[name] = true
}
}
}
func (r *References) addGroups(names ...string) {
for _, name := range names {
if name != "" {
r.PlacementGroupNames[name] = true
}
}
}
func (r *References) addGroupIDs(ids ...string) {
for _, id := range ids {
if id != "" {
r.PlacementGroupIDs[id] = true
}
}
}
func (r *References) merge(o *References) {
for name := range o.KeyNames {
r.addKeys(name)
}
for name := range o.PlacementGroupNames {
r.addGroups(name)
}
for id := range o.PlacementGroupIDs {
r.addGroupIDs(id)
}
}
func (r *References) hasGroup(id, name string) bool {
return r.PlacementGroupIDs[id] || r.PlacementGroupNames[name]
}
@@ -0,0 +1,97 @@
package accountcleanup
import "time"
// Request names the account to clean. CorrelationRef is an opaque caller
// reference echoed for logs; it grants nothing and is not a fence.
type Request struct {
AccountID string `json:"account_id"`
CorrelationRef string `json:"correlation_ref,omitempty"`
}
// Result is what the owner stores hold after one run. Kinds reuse the ec2.v1
// platform kind and status vocabulary; Items and Counts carry resource IDs,
// fixed reasons and counts only, never names, tags, key material or data.
type Result struct {
AccountID string `json:"account_id"`
CorrelationRef string `json:"correlation_ref,omitempty"`
Kinds []KindReport `json:"kinds"`
Items []Item `json:"items,omitempty"`
Counts []Count `json:"counts,omitempty"`
}
// KindReport is one kind's status after its re-scan. Clean means the re-scan
// read every store completely and found nothing; writers are not fenced, so
// it is a snapshot, not evidence of absence.
type KindReport struct {
Kind string `json:"kind"`
Status string `json:"status"`
IncompleteReasons []string `json:"incomplete_reasons,omitempty"`
StartedAt time.Time `json:"started_at"`
EndedAt time.Time `json:"ended_at"`
}
// Item is one resource by its AWS resource ID. MemberCount is set only for a
// placement group blocked by members; instance and request IDs are never reported.
type Item struct {
ResourceID string `json:"resource_id"`
Kind string `json:"kind"`
Disposition string `json:"disposition"`
Reason string `json:"reason,omitempty"`
MemberCount int `json:"member_count,omitempty"`
}
// Count is the number of records of one class with one disposition, for
// records that have no public identity.
type Count struct {
Kind string `json:"kind"`
Class string `json:"class"`
Disposition string `json:"disposition"`
Reason string `json:"reason,omitempty"`
Count int `json:"count"`
}
// Dispositions. Only a complete owner re-scan that no longer finds a record
// makes it deleted.
const (
DispositionDeleted = "deleted"
DispositionBlocked = "blocked"
DispositionFailed = "failed"
DispositionResidue = "residue"
)
// Reasons are fixed categories, never store error text.
const (
// ReasonInstanceMembers: a placement group still names instances.
ReasonInstanceMembers = "instance-members"
// ReasonOwnerDeleteFailed: the owner delete hit a store failure.
ReasonOwnerDeleteFailed = "owner-delete-failed"
// ReasonStillPresent: the owner reported a delete but the re-scan found it.
ReasonStillPresent = "still-present"
// ReasonAbsenceUnproven: the re-scan failed, so absence is not shown.
ReasonAbsenceUnproven = "absence-unproven"
// ReasonUnreadableRecord: a record the owner cannot read is never deleted.
ReasonUnreadableRecord = "unreadable-record"
// ReasonUnrecognisedRecord: an entry matching no owner record shape.
ReasonUnrecognisedRecord = "unrecognised-record"
// ReasonOwnerRecordsUnresolved: the record may belong to an unreadable one.
ReasonOwnerRecordsUnresolved = "owner-records-unresolved"
// ReasonRecentlyWritten: residue younger than the settle window, which may
// be a create still in flight.
ReasonRecentlyWritten = "recently-written"
// ReasonAppearedDuringCleanup: a record the run did not see until its re-scan.
ReasonAppearedDuringCleanup = "appeared-during-cleanup"
// ReasonInstanceReference: a non-terminated instance record names it.
ReasonInstanceReference = "instance-reference"
// ReasonSpotRequestReference: an open or active spot request names it.
ReasonSpotRequestReference = "spot-request-reference"
// ReasonLaunchTemplateReference: a version of a live launch template names it.
ReasonLaunchTemplateReference = "launch-template-reference"
// ReasonReferenceUnknown: a record EC2 cannot read may name it, so it is
// never deleted by account cleanup.
ReasonReferenceUnknown = "reference-unknown"
// ReasonReferenceCheckFailed: an instance, spot or launch-template reference
// check did not complete, so nothing it guards was deleted. Also a kind
// incomplete reason.
ReasonReferenceCheckFailed = "reference-check-failed"
)
@@ -3,7 +3,6 @@ package instance
import (
"context"
"encoding/json"
"errors"
"fmt"
"github.com/mulgadc/spinifex/spinifex/foundation/messaging/nats"
"log/slog"
@@ -101,10 +100,10 @@ func DescribeInstanceTypes(ctx context.Context, input *ec2.DescribeInstanceTypes
if missing := missingInstanceTypes(finalInstanceTypes, input.InstanceTypes); len(missing) > 0 {
if !proveAbsence || !everyNodeAnswered(nodeIDs, answered, sum) {
return nil, errors.Join(
return nil, awserrors.WithRetryAfter(
awserrors.Errorf(awserrors.ErrorServiceUnavailable,
"cannot confirm instance type(s) %s: describe fan-out did not complete", strings.Join(missing, ", ")),
awserrors.RetryAfter(awserrors.ErrorServiceUnavailable, describeIncompleteRetryAfter),
describeIncompleteRetryAfter,
)
}
return nil, awserrors.Errorf(awserrors.ErrorInvalidInstanceType,
@@ -126,10 +126,10 @@ func DescribeInstancesChecked(ctx context.Context, input *ec2.DescribeInstancesI
// An incomplete sweep cannot prove absence, so a missing id is
// unresolved, not gone. NotFound would be a false negative and an
// empty success would look identical to a real one, so return 503.
return nil, errors.Join(
return nil, awserrors.WithRetryAfter(
awserrors.Errorf(awserrors.ErrorServiceUnavailable,
"cannot confirm status of instance(s) %s: describe fan-out did not complete", strings.Join(missing, ", ")),
awserrors.RetryAfter(awserrors.ErrorServiceUnavailable, describeIncompleteRetryAfter),
describeIncompleteRetryAfter,
)
}
}
@@ -198,10 +198,10 @@ func DescribeAccountInstancesComplete(ctx context.Context, natsConn *nats.Conn,
}
func incompleteListingError() error {
return errors.Join(
return awserrors.WithRetryAfter(
awserrors.Errorf(awserrors.ErrorServiceUnavailable,
"cannot list instances: not every node and instance store answered"),
awserrors.RetryAfter(awserrors.ErrorServiceUnavailable, describeIncompleteRetryAfter),
describeIncompleteRetryAfter,
)
}
@@ -227,7 +227,7 @@ func aggregateResults(ctx context.Context, results []nodeLaunchResult, minCount
return nil, clientErr
}
// A launch that was attempted and failed is not a capacity shortage; surface the real cause.
if failErr := launchFailure(results); failErr != nil {
if failErr := launchFailure(ctx, results); failErr != nil {
slog.ErrorContext(ctx, "distributeInstances: launch fell short of minCount", "minCount", minCount, "launched", totalLaunched, "err", failErr)
return nil, failErr
}
@@ -321,7 +321,7 @@ func distributeInstancesSpread(ctx context.Context, input *ec2.RunInstancesInput
if clientErr := extractClientError(results); clientErr != nil {
return nil, clientErr
}
if failErr := launchFailure(results); failErr != nil {
if failErr := launchFailure(ctx, results); failErr != nil {
slog.ErrorContext(ctx, "distributeInstancesSpread: launch fell short of minCount", "minCount", minCount, "launched", totalLaunched, "err", failErr)
return nil, failErr
}
@@ -414,7 +414,7 @@ func distributeInstancesCluster(ctx context.Context, input *ec2.RunInstancesInpu
if clientErr := extractClientError(results); clientErr != nil {
return nil, clientErr
}
return nil, results[0].Err
return nil, launchFailure(ctx, results)
}
reservation := results[0].Reservation
@@ -468,17 +468,26 @@ func extractClientError(results []nodeLaunchResult) error {
return nil
}
// launchFailure returns the first node launch error, or nil when no node errored.
// A sub-minCount launch whose nodes actually errored is not a capacity shortage:
// genuine capacity is caught pre-launch in distributeInstances, and a node-side
// capacity race is surfaced by extractClientError. Surfacing the real error (RPC
// timeout, network/OVN failure) stops it masquerading as
// InsufficientInstanceCapacity — the mislabel that derails triage.
func launchFailure(results []nodeLaunchResult) error {
// launchFailure returns the first real node launch error so an RPC or OVN failure is
// not mislabelled as capacity; a node with no launch subscriber for the type cannot
// place it, so when only those failed the result is InsufficientInstanceCapacity.
func launchFailure(ctx context.Context, results []nodeLaunchResult) error {
var noResponders error
for _, r := range results {
if r.Err != nil {
return r.Err
if r.Err == nil {
continue
}
if errors.Is(r.Err, nats.ErrNoResponders) {
if noResponders == nil {
noResponders = r.Err
}
continue
}
return r.Err
}
if noResponders != nil {
slog.WarnContext(ctx, "distributeInstances: no launch subscriber on node, reporting InsufficientInstanceCapacity", "err", noResponders)
return errors.New(awserrors.ErrorInsufficientInstanceCapacity)
}
return nil
}
@@ -335,6 +335,32 @@ func TestAggregateResults_TimeoutSurfacedNotMasked(t *testing.T) {
assert.ErrorIs(t, err, context.DeadlineExceeded)
}
// A node with no subscriber on its launch subject surfaces as capacity, never the raw NATS error.
func TestAggregateResults_NoRespondersIsCapacity(t *testing.T) {
t.Parallel()
noResp := fmt.Errorf("launch on node-1: %w", fmt.Errorf("NATS request to ec2.RunInstances.t3.large.node-1: %w", nats.ErrNoResponders))
results := []nodeLaunchResult{
{NodeID: "node-1", Err: noResp},
}
_, err := aggregateResults(context.Background(), results, 1, nil, "")
require.EqualError(t, err, awserrors.ErrorInsufficientInstanceCapacity)
}
// A real launch failure on another node outranks a no-responders node.
func TestAggregateResults_RealFailureOutranksNoResponders(t *testing.T) {
t.Parallel()
noResp := fmt.Errorf("launch on node-1: %w", nats.ErrNoResponders)
timeoutErr := fmt.Errorf("launch on node-2: %w", context.DeadlineExceeded)
results := []nodeLaunchResult{
{NodeID: "node-1", Err: noResp},
{NodeID: "node-2", Err: timeoutErr},
}
_, err := aggregateResults(context.Background(), results, 1, nil, "")
assert.ErrorIs(t, err, context.DeadlineExceeded)
}
// Short launch with no node errors (nodes returned fewer instances than assigned
// without erroring) keeps the generic capacity code.
func TestAggregateResults_ShortWithoutNodeErrors(t *testing.T) {
@@ -1120,6 +1146,40 @@ func TestDistributeInstancesCluster_InsufficientCapacityOnPinnedNode(t *testing.
assert.Equal(t, awserrors.ErrorInsufficientInstanceCapacity, err.Error())
}
// A pinned node with no launch subscriber reports capacity, not the raw NATS error.
func TestDistributeInstancesCluster_NoLaunchSubscriberIsCapacity(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
statusSub, err := nc.Subscribe("spinifex.node.status", func(msg *nats.Msg) {
data, _ := json.Marshal(types.NodeStatusResponse{Node: "node-1", InstanceTypes: []types.InstanceTypeCap{{Name: "t3.micro", Available: 10}}})
_ = nc.Publish(msg.Reply, data)
})
require.NoError(t, err)
defer statusSub.Unsubscribe()
reserveSub, err := nc.QueueSubscribe("ec2.ReserveClusterNode", "spinifex-workers", func(msg *nats.Msg) {
data, _ := json.Marshal(struct {
TargetNode string `json:"target_node"`
}{TargetNode: "node-1"})
_ = msg.Respond(data)
})
require.NoError(t, err)
defer reserveSub.Unsubscribe()
require.NoError(t, nc.Flush())
input := &ec2.RunInstancesInput{
ImageId: aws.String("ami-test"),
InstanceType: aws.String("t3.micro"),
MinCount: aws.Int64(1),
MaxCount: aws.Int64(1),
}
_, err = distributeInstancesCluster(context.Background(), input, nc, "test-account", "my-cluster-group", 1)
require.EqualError(t, err, awserrors.ErrorInsufficientInstanceCapacity)
}
func TestDistributeInstancesCluster_PinnedNodeNotInCapacityResults(t *testing.T) {
t.Parallel()
// Pinned node has no capacity at all (not in fan-out results) → InsufficientInstanceCapacity
@@ -12,6 +12,8 @@ import (
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
"github.com/mulgadc/spinifex/spinifex/foundation/aws/tags"
"github.com/mulgadc/spinifex/spinifex/runtime/compute/cache"
"github.com/mulgadc/spinifex/spinifex/runtime/compute/vm"
"github.com/nats-io/nats.go"
@@ -237,6 +239,44 @@ func TestSynthesis_HonoursInstanceIDFilter(t *testing.T) {
require.Equal(t, "i-asked", aws.StringValue(out.InstanceStatuses[0].InstanceId))
}
// Synthesis shares the node path's selection, so a platform-managed VM on a
// stale node is hidden from the customer account it is recorded under.
func TestSynthesis_HidesManagedSystemVMFromCustomer(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
answerWith(t, nc, "node-active")
managed := cachedVM("i-ekscp", "node-dead", "az-a", vm.StateRunning)
managed.ManagedBy = tags.ManagedByEKS
synth := StatusSynthesis{
Records: fakeRecords{ready: true, vms: []*vm.VM{managed}},
Liveness: fakeLiveness{states: map[string]instancecache.NodeState{"node-dead": instancecache.NodeStale}},
}
out, err := DescribeInstanceStatus(context.Background(), &ec2.DescribeInstanceStatusInput{}, nc, 1, synthAccount, "az-a", synth)
require.NoError(t, err)
require.Empty(t, out.InstanceStatuses)
}
func TestSynthesis_RootSeesManagedSystemVM(t *testing.T) {
t.Parallel()
_, nc := startTestNATSServer(t)
answerWith(t, nc, "node-active")
managed := cachedVM("i-lb", "node-dead", "az-a", vm.StateRunning)
managed.AccountID = awsidentifiers.GlobalAccountID
managed.ManagedBy = tags.ManagedByELBv2
synth := StatusSynthesis{
Records: fakeRecords{ready: true, vms: []*vm.VM{managed}},
Liveness: fakeLiveness{states: map[string]instancecache.NodeState{"node-dead": instancecache.NodeStale}},
}
out, err := DescribeInstanceStatus(context.Background(), &ec2.DescribeInstanceStatusInput{}, nc, 1, awsidentifiers.GlobalAccountID, "az-a", synth)
require.NoError(t, err)
require.Len(t, out.InstanceStatuses, 1)
require.Equal(t, "i-lb", aws.StringValue(out.InstanceStatuses[0].InstanceId))
}
// A cache that has not finished its first whole-set sync cannot support a
// claim about what is missing, so it contributes nothing.
func TestSynthesis_ColdCacheContributesNothing(t *testing.T) {
@@ -0,0 +1,264 @@
package image_test
import (
"context"
"testing"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
testutil "github.com/mulgadc/spinifex/internal/testkit"
"github.com/mulgadc/spinifex/spinifex/bootstrap/config"
ebsmetadata "github.com/mulgadc/spinifex/spinifex/domains/ec2/ebs/metadata"
ec2image "github.com/mulgadc/spinifex/spinifex/domains/ec2/image"
ec2snapshot "github.com/mulgadc/spinifex/spinifex/domains/ec2/snapshot"
ec2volume "github.com/mulgadc/spinifex/spinifex/domains/ec2/volume"
awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
ebsprovider "github.com/mulgadc/spinifex/spinifex/providers/ebs"
"github.com/mulgadc/spinifex/spinifex/providers/objectstore"
"github.com/nats-io/nats.go/jetstream"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// The TestCurrentBehaviour_AccountTermination_ tests pin how volumes,
// snapshots and images guard one another today, wired the way the daemon wires
// them; a failure means a dependency rule changed.
const (
ctAccountID = "123456789012"
ctBucket = "ct-bucket"
ctAZ = "ct-az"
)
type storageServices struct {
volumes *ec2volume.VolumeServiceImpl
snapshots *ec2snapshot.SnapshotServiceImpl
images *ec2image.ImageServiceImpl
provider *ebsprovider.MemoryProvider
metadata *ebsmetadata.Store
}
// newStorageServices shares one object store, one provider and one
// volume-to-snapshot index between the three services. As in the daemon, the
// image service is given no handle on that index.
func newStorageServices(t *testing.T) storageServices {
t.Helper()
_, nc, js := testutil.StartTestJetStream(t)
refs, err := js.CreateKeyValue(context.Background(), jetstream.KeyValueConfig{Bucket: ec2snapshot.KVBucketVolumeSnapshots})
require.NoError(t, err)
store := objectstore.NewMemoryObjectStore()
provider := ebsprovider.NewMemoryProvider(ebsprovider.Capabilities{SnapshotEnumeration: true})
cfg := &config.Config{AZ: ctAZ, WalDir: t.TempDir(), Predastore: config.PredastoreConfig{Bucket: ctBucket}}
volumes := ec2volume.NewVolumeServiceImplWithStore(cfg, store, nc, refs)
volumes.SetEBSProvider(provider)
snapshots := ec2snapshot.NewSnapshotServiceImplWithStore(cfg, store, nc, refs)
snapshots.SetEBSProvider(provider)
images := ec2image.NewImageServiceImplWithConfig(cfg, store, nil)
images.SetEBSProvider(provider)
return storageServices{
volumes: volumes, snapshots: snapshots, images: images,
provider: provider, metadata: ebsmetadata.NewStore(store, ctBucket),
}
}
func (s storageServices) seedVolume(t *testing.T, volumeID string) {
t.Helper()
ctx := context.Background()
created, err := s.provider.CreateVolume(ctx, ebsprovider.CreateVolumeRequest{
Versioned: ebsprovider.NewVersioned(),
VolumeID: volumeID,
CapacityRange: ebsprovider.CapacityRange{RequiredBytes: 1 << 30},
})
require.NoError(t, err)
require.NoError(t, s.metadata.PutVolume(ctx, ebsmetadata.Volume{
VolumeID: volumeID, TenantID: ctAccountID, CapacityGiB: 1,
State: "available", ProviderHandle: created.Handle,
}))
}
func (s storageServices) createImage(t *testing.T, rootVolumeID, name string) string {
t.Helper()
out, err := s.images.CreateImageFromInstance(ec2image.CreateImageParams{
Input: &ec2.CreateImageInput{InstanceId: aws.String("i-0123456789abcdef0"), Name: aws.String(name)},
RootVolumeID: rootVolumeID,
}, ctAccountID)
require.NoError(t, err)
return aws.StringValue(out.ImageId)
}
func (s storageServices) deleteVolume(volumeID string) error {
_, err := s.volumes.DeleteVolume(context.Background(), &ec2.DeleteVolumeInput{VolumeId: aws.String(volumeID)}, ctAccountID)
return err
}
// A CreateSnapshot snapshot pins its source volume through the index, but a
// CreateImage snapshot is never added to it, so its source volume deletes and
// the image keeps naming a volume that no longer exists.
func TestCurrentBehaviour_AccountTermination_CreateImageSnapshotDoesNotPinSourceVolume(t *testing.T) {
s := newStorageServices(t)
ctx := context.Background()
s.seedVolume(t, "vol-snapshotted")
_, err := s.snapshots.CreateSnapshot(ctx, &ec2.CreateSnapshotInput{VolumeId: aws.String("vol-snapshotted")}, ctAccountID)
require.NoError(t, err)
assert.EqualError(t, s.deleteVolume("vol-snapshotted"), awserrors.ErrorVolumeInUse,
"a CreateSnapshot snapshot blocks deleting its source volume")
s.seedVolume(t, "vol-imaged")
imageID := s.createImage(t, "vol-imaged", "ct-image")
require.NoError(t, s.deleteVolume("vol-imaged"), "a CreateImage snapshot does not block deleting its source volume")
_, err = s.metadata.GetVolume(ctx, ctAccountID, "vol-imaged")
require.Error(t, err, "the source volume's document is gone")
images, err := s.images.DescribeImages(ctx, &ec2.DescribeImagesInput{ImageIds: []*string{aws.String(imageID)}}, ctAccountID)
require.NoError(t, err)
require.Len(t, images.Images, 1)
assert.Equal(t, ec2.ImageStateAvailable, aws.StringValue(images.Images[0].State), "the image still reports available")
source, err := s.images.GetAMISourceVolumeID(ctx, imageID)
require.NoError(t, err)
assert.Equal(t, "vol-imaged", source, "a launch from the image still resolves the deleted volume")
}
// DeleteSnapshot consults AMI documents as well as volumes cloned from the
// snapshot, so the snapshot under a live image refuses to delete until the
// image is deregistered.
func TestAccountTermination_DeleteSnapshotRefusesARegisteredImage(t *testing.T) {
s := newStorageServices(t)
ctx := context.Background()
s.seedVolume(t, "vol-registered")
snap, err := s.snapshots.CreateSnapshot(ctx, &ec2.CreateSnapshotInput{VolumeId: aws.String("vol-registered")}, ctAccountID)
require.NoError(t, err)
snapshotID := aws.StringValue(snap.SnapshotId)
registered, err := s.images.RegisterImage(ctx, &ec2.RegisterImageInput{
Name: aws.String("ct-registered"),
RootDeviceName: aws.String("/dev/sda1"),
BlockDeviceMappings: []*ec2.BlockDeviceMapping{{
DeviceName: aws.String("/dev/sda1"),
Ebs: &ec2.EbsBlockDevice{SnapshotId: aws.String(snapshotID)},
}},
}, ctAccountID)
require.NoError(t, err)
_, err = s.snapshots.DeleteSnapshot(ctx, &ec2.DeleteSnapshotInput{SnapshotId: aws.String(snapshotID)}, ctAccountID)
assert.EqualError(t, err, awserrors.ErrorInvalidSnapshotInUse, "the snapshot refuses to delete while an image is registered on it")
_, err = s.metadata.GetSnapshot(ctx, ctAccountID, snapshotID)
assert.NoError(t, err, "the snapshot document is still present")
_, held := s.provider.Snapshot(snapshotID)
assert.True(t, held, "the provider still holds the snapshot")
_, err = s.images.DeregisterImage(ctx, &ec2.DeregisterImageInput{ImageId: registered.ImageId}, ctAccountID)
require.NoError(t, err)
_, err = s.snapshots.DeleteSnapshot(ctx, &ec2.DeleteSnapshotInput{SnapshotId: aws.String(snapshotID)}, ctAccountID)
require.NoError(t, err, "the snapshot deletes once the image is deregistered")
}
// deleteDuringClone runs a hook once, after the provider has accepted a clone
// from a snapshot and before CreateVolume writes the clone's document.
type deleteDuringClone struct {
ebsprovider.EBSProvider
hook func()
fired bool
}
func (p *deleteDuringClone) CreateVolume(ctx context.Context, req ebsprovider.CreateVolumeRequest) (*ebsprovider.Volume, error) {
created, err := p.EBSProvider.CreateVolume(ctx, req)
if err == nil && req.SourceSnapshotID != "" && !p.fired {
p.fired = true
p.hook()
}
return created, err
}
// DeleteSnapshot's clone check reads volume documents, and CreateVolume writes
// the clone's document only after the provider has verified and cloned the
// snapshot, so a delete in that window passes the check and strands the clone.
func TestCurrentBehaviour_AccountTermination_DeleteSnapshotDuringCloneCreationStrandsTheClone(t *testing.T) {
s := newStorageServices(t)
ctx := context.Background()
createFrom := func(snapshotID string) (*ec2.Volume, error) {
return s.volumes.CreateVolume(ctx, &ec2.CreateVolumeInput{
AvailabilityZone: aws.String(ctAZ), SnapshotId: aws.String(snapshotID),
}, ctAccountID)
}
deleteSnapshot := func(snapshotID string) error {
_, err := s.snapshots.DeleteSnapshot(ctx, &ec2.DeleteSnapshotInput{SnapshotId: aws.String(snapshotID)}, ctAccountID)
return err
}
s.seedVolume(t, "vol-settled")
settled, err := s.snapshots.CreateSnapshot(ctx, &ec2.CreateSnapshotInput{VolumeId: aws.String("vol-settled")}, ctAccountID)
require.NoError(t, err)
_, err = createFrom(aws.StringValue(settled.SnapshotId))
require.NoError(t, err)
assert.True(t, awserrors.IsErrorCode(deleteSnapshot(aws.StringValue(settled.SnapshotId)), awserrors.ErrorInvalidSnapshotInUse),
"once the clone's document exists, the delete is refused")
s.seedVolume(t, "vol-raced")
raced, err := s.snapshots.CreateSnapshot(ctx, &ec2.CreateSnapshotInput{VolumeId: aws.String("vol-raced")}, ctAccountID)
require.NoError(t, err)
snapshotID := aws.StringValue(raced.SnapshotId)
var raceErr error
s.volumes.SetEBSProvider(&deleteDuringClone{EBSProvider: s.provider, hook: func() { raceErr = deleteSnapshot(snapshotID) }})
clone, err := createFrom(snapshotID)
require.NoError(t, err, "CreateVolume reports success")
require.NoError(t, raceErr, "the delete inside the window passes the clone check")
doc, err := s.metadata.GetVolume(ctx, ctAccountID, aws.StringValue(clone.VolumeId))
require.NoError(t, err)
assert.Equal(t, snapshotID, doc.SnapshotID, "the clone's document names the deleted snapshot")
_, err = s.metadata.GetSnapshot(ctx, ctAccountID, snapshotID)
require.Error(t, err, "the snapshot's document is gone")
_, held := s.provider.Snapshot(snapshotID)
assert.False(t, held, "the provider no longer holds the snapshot the clone was made from")
_, err = createFrom(snapshotID)
assert.EqualError(t, err, awserrors.ErrorInvalidSnapshotNotFound, "a clone started after the delete is refused")
}
// CreateImage on an instance re-reads the image the instance launched from,
// after the provider has taken the snapshot, so once that image is
// deregistered the call fails and leaves a provider snapshot with no document.
func TestCurrentBehaviour_AccountTermination_CreateImageOfRetainedInstanceNeedsItsLaunchImage(t *testing.T) {
s := newStorageServices(t)
ctx := context.Background()
s.seedVolume(t, "vol-launch-source")
launchImageID := s.createImage(t, "vol-launch-source", "ct-launch-image")
_, err := s.images.DeregisterImage(ctx, &ec2.DeregisterImageInput{ImageId: aws.String(launchImageID)}, ctAccountID)
require.NoError(t, err)
s.seedVolume(t, "vol-retained-root")
_, err = s.images.CreateImageFromInstance(ec2image.CreateImageParams{
Input: &ec2.CreateImageInput{InstanceId: aws.String("i-0123456789abcdef0"), Name: aws.String("ct-after-deregister")},
RootVolumeID: "vol-retained-root",
SourceImageID: launchImageID,
}, ctAccountID)
assert.EqualError(t, err, awserrors.ErrorServerInternal, "the instance's image can no longer be created")
listed, err := s.provider.ListSnapshots(ctx, ebsprovider.ListSnapshotsRequest{Versioned: ebsprovider.NewVersioned()})
require.NoError(t, err)
var orphans []string
for _, ref := range listed.Snapshots {
if ref.SourceVolumeID == "vol-retained-root" {
orphans = append(orphans, ref.ID)
}
}
require.Len(t, orphans, 1, "the provider kept the snapshot it took before the image read failed")
docs, err := s.metadata.ListSnapshotsStrict(ctx, ctAccountID)
require.NoError(t, err)
for _, doc := range docs {
assert.NotEqual(t, orphans[0], doc.SnapshotID, "no document names that snapshot")
}
}
@@ -4507,6 +4507,30 @@ func TestDescribeInstanceStatus_AccountFilteringHidesOtherTenant(t *testing.T) {
assert.Empty(t, out.InstanceStatuses)
}
// A platform-managed VM recorded under a customer account stays out of that
// customer's status listing, matching DescribeInstances.
func TestDescribeInstanceStatus_HidesManagedSystemVMFromCustomer(t *testing.T) {
owner := "111122223333"
v := runningVM("i-ekscp", owner)
v.ManagedBy = tags.ManagedByEKS
svc := instanceStatusService(t, "az-a", map[string]*vm.VM{v.ID: v})
out, err := svc.DescribeInstanceStatus(context.Background(), &ec2.DescribeInstanceStatusInput{}, owner)
require.NoError(t, err)
assert.Empty(t, out.InstanceStatuses, "managed system VM must not appear in customer status listing")
}
func TestDescribeInstanceStatus_RootSeesManagedSystemVM(t *testing.T) {
v := runningVM("i-lb", awsidentifiers.GlobalAccountID)
v.ManagedBy = tags.ManagedByELBv2
svc := instanceStatusService(t, "az-a", map[string]*vm.VM{v.ID: v})
out, err := svc.DescribeInstanceStatus(context.Background(), &ec2.DescribeInstanceStatusInput{}, awsidentifiers.GlobalAccountID)
require.NoError(t, err)
require.Len(t, out.InstanceStatuses, 1)
assert.Equal(t, "i-lb", *out.InstanceStatuses[0].InstanceId)
}
func TestDescribeInstanceStatus_StoppedExcludedByDefault(t *testing.T) {
owner := "111122223333"
stopped := runningVM("i-stop", owner)
@@ -59,7 +59,7 @@ func ParseStatusSelection(input *ec2.DescribeInstanceStatusInput, accountID stri
// status is built. Split out so a caller can test membership without paying
// for the projection.
func (s StatusSelection) Includes(v *vm.VM) bool {
if v == nil || !IsInstanceVisible(s.accountID, v.AccountID) {
if v == nil || !IsInstanceVisibleToCaller(s.accountID, v) {
return false
}
if len(s.instanceIDs) > 0 && !s.instanceIDs[v.ID] {
@@ -0,0 +1,75 @@
package key
import (
"context"
"log/slog"
"time"
)
// AccountCleanup is what one CleanupAccount run did. It holds key-pair IDs and
// counts only; it is the owner's account of its attempts, not proof of
// absence, which only a later ScanAccount gives.
type AccountCleanup struct {
// DeletedKeyPairIDs completed the same owner delete DeleteKeyPair runs.
DeletedKeyPairIDs []string
// FailedKeyPairIDs had a store failure part way; the metadata remains.
FailedKeyPairIDs []string
// Referenced maps a kept key-pair ID to the reason the caller's predicate
// gave for keeping it.
Referenced map[string]string
// Orphan public-key objects removed, that failed to delete, or left because
// they were written after settledBefore and may belong to an in-flight create.
OrphansRemoved, OrphansFailed, OrphansUnsettled int
// OrphansReferenced counts public-key objects kept, by predicate reason.
OrphansReferenced map[string]int
}
// CleanupAccount deletes every readable key pair of the account through the
// owner delete, and removes public-key objects no metadata names once they
// were last written before settledBefore. A key pair or public-key object
// for whose name referenced returns a reason is kept: IMDS serves the public
// key by name to any instance naming it. Unreadable metadata, and public keys that
// may belong to it, are left for the owner scan to report. A listing failure
// is returned as an error and nothing is deleted.
func (s *KeyServiceImpl) CleanupAccount(ctx context.Context, accountID string, settledBefore time.Time, referenced func(keyName string) string) (*AccountCleanup, error) {
records, err := s.classifyAccount(ctx, accountID)
if err != nil {
return nil, err
}
if referenced == nil {
referenced = func(string) string { return "" }
}
out := &AccountCleanup{Referenced: map[string]string{}, OrphansReferenced: map[string]int{}}
for _, kp := range records.live {
if reason := referenced(kp.name); reason != "" {
out.Referenced[kp.id] = reason
continue
}
if err := s.deleteKeyPairRecords(ctx, accountID, kp.name, kp.id); err != nil {
out.FailedKeyPairIDs = append(out.FailedKeyPairIDs, kp.id)
continue
}
out.DeletedKeyPairIDs = append(out.DeletedKeyPairIDs, kp.id)
}
for _, orphan := range records.orphans {
if reason := referenced(orphan.name); reason != "" {
out.OrphansReferenced[reason]++
continue
}
if orphan.modified == nil || !orphan.modified.Before(settledBefore) {
out.OrphansUnsettled++
continue
}
if err := s.deleteObject(ctx, orphan.key); err != nil {
slog.WarnContext(ctx, "Key-pair account cleanup: orphan public key delete failed", "err", err)
out.OrphansFailed++
continue
}
out.OrphansRemoved++
}
slog.InfoContext(ctx, "Key-pair account cleanup completed", "accountID", accountID,
"deleted", len(out.DeletedKeyPairIDs), "failed", len(out.FailedKeyPairIDs), "referenced", len(out.Referenced),
"orphansRemoved", out.OrphansRemoved, "orphansFailed", out.OrphansFailed,
"orphansUnsettled", out.OrphansUnsettled)
return out, nil
}
+52 -11
View File
@@ -6,6 +6,7 @@ import (
"io"
"log/slog"
"strings"
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/s3"
@@ -29,10 +30,49 @@ type AccountScan struct {
ReadFailures int
}
// liveKeyPair is a readable metadata record and the name it gives its key.
type liveKeyPair struct {
id, name string
}
// orphanObject is a public-key object no metadata names, the key name it is
// served under, and its last write time when the listing reported one.
type orphanObject struct {
key, name string
modified *time.Time
}
// accountRecords is the private classification ScanAccount and CleanupAccount
// share. It holds object keys and names, so it never leaves this package.
type accountRecords struct {
live []liveKeyPair
unreadable []string
orphans []orphanObject
unresolved int
readFailures int
}
// ScanAccount lists and classifies every object under keys/<accountID>/. A
// listing failure is returned as an error; per-object read failures are
// counted. It reads only objects named as metadata (key-*.json).
func (s *KeyServiceImpl) ScanAccount(ctx context.Context, accountID string) (*AccountScan, error) {
records, err := s.classifyAccount(ctx, accountID)
if err != nil {
return nil, err
}
scan := &AccountScan{
UnreadableKeyPairIDs: records.unreadable,
OrphanPublicKeys: len(records.orphans),
UnresolvedPublicKeys: records.unresolved,
ReadFailures: records.readFailures,
}
for _, kp := range records.live {
scan.LiveKeyPairIDs = append(scan.LiveKeyPairIDs, kp.id)
}
return scan, nil
}
func (s *KeyServiceImpl) classifyAccount(ctx context.Context, accountID string) (*accountRecords, error) {
prefix := fmt.Sprintf("keys/%s/", accountID)
objects, _, err := objectstore.ListAll(ctx, s.store, &s3.ListObjectsV2Input{
Bucket: aws.String(s.bucketName),
@@ -42,9 +82,9 @@ func (s *KeyServiceImpl) ScanAccount(ctx context.Context, accountID string) (*Ac
return nil, fmt.Errorf("list key-pair objects: %w", err)
}
scan := &AccountScan{}
records := &accountRecords{}
liveNames := make(map[string]bool)
var rest []string
var rest []*s3.Object
unreadable := make(map[string]bool)
for _, obj := range objects {
if obj == nil || obj.Key == nil {
@@ -53,24 +93,24 @@ func (s *KeyServiceImpl) ScanAccount(ctx context.Context, accountID string) (*Ac
name := strings.TrimPrefix(*obj.Key, prefix)
id, isJSON := strings.CutSuffix(name, ".json")
if !isJSON || !strings.HasPrefix(id, "key-") {
rest = append(rest, name)
rest = append(rest, obj)
continue
}
metadata, found, readErr := s.readScanMetadata(ctx, *obj.Key)
switch {
case readErr != nil:
scan.ReadFailures++
records.readFailures++
unreadable[name] = true
case !found:
// Removed between the listing and the read.
case metadata == nil || aws.StringValue(metadata.KeyPairId) != id || aws.StringValue(metadata.KeyName) == "":
unreadable[name] = true
default:
scan.LiveKeyPairIDs = append(scan.LiveKeyPairIDs, id)
records.live = append(records.live, liveKeyPair{id: id, name: *metadata.KeyName})
liveNames[*metadata.KeyName] = true
}
if unreadable[name] {
rest = append(rest, name)
rest = append(rest, obj)
}
}
@@ -81,19 +121,20 @@ func (s *KeyServiceImpl) ScanAccount(ctx context.Context, accountID string) (*Ac
delete(unreadable, name)
}
}
for _, name := range rest {
for _, obj := range rest {
name := strings.TrimPrefix(*obj.Key, prefix)
switch {
case liveNames[name]:
// The public key of a live key pair.
case unreadable[name]:
scan.UnreadableKeyPairIDs = append(scan.UnreadableKeyPairIDs, strings.TrimSuffix(name, ".json"))
records.unreadable = append(records.unreadable, strings.TrimSuffix(name, ".json"))
case len(unreadable) > 0:
scan.UnresolvedPublicKeys++
records.unresolved++
default:
scan.OrphanPublicKeys++
records.orphans = append(records.orphans, orphanObject{key: *obj.Key, name: name, modified: obj.LastModified})
}
}
return scan, nil
return records, nil
}
// readScanMetadata reads and decodes one metadata object. A decode failure is
@@ -33,15 +33,12 @@ func TestScanAccountClassifiesEveryObjectUnderTheAccountPrefix(t *testing.T) {
live := importKey(t, svc, ctAccount, "live")
dotted := importKey(t, svc, ctAccount, "web.json")
importKey(t, svc, ctAccount, "deleted")
store.failDelete = func(key string) bool { return key == prefix+"deleted" }
_, err := svc.DeleteKeyPair(context.Background(), &ec2.DeleteKeyPairInput{KeyName: aws.String("deleted")}, ctAccount)
require.NoError(t, err)
store.failDelete = nil
// A public key whose metadata is gone, as an interrupted delete leaves it.
putObject(t, store, prefix+"deleted", ctPubKey+"\n")
store.failPut = func(key string) bool { return strings.HasSuffix(key, ".json") }
store.failDelete = func(string) bool { return true }
_, err = svc.CreateKeyPair(context.Background(), &ec2.CreateKeyPairInput{KeyName: aws.String("half")}, ctAccount)
_, err := svc.CreateKeyPair(context.Background(), &ec2.CreateKeyPairInput{KeyName: aws.String("half")}, ctAccount)
require.Error(t, err)
store.failPut, store.failDelete = nil, nil
@@ -50,7 +47,7 @@ func TestScanAccountClassifiesEveryObjectUnderTheAccountPrefix(t *testing.T) {
require.NoError(t, err)
assert.ElementsMatch(t, []string{aws.StringValue(live.KeyPairId), aws.StringValue(dotted.KeyPairId)}, scan.LiveKeyPairIDs)
assert.Equal(t, 2, scan.OrphanPublicKeys, "the failed-delete and failed-create public keys")
assert.Equal(t, 2, scan.OrphanPublicKeys, "the interrupted-delete and failed-create public keys")
assert.Empty(t, scan.UnreadableKeyPairIDs)
assert.Zero(t, scan.UnresolvedPublicKeys)
assert.Zero(t, scan.ReadFailures)
@@ -7,6 +7,7 @@ import (
"slices"
"strings"
"testing"
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
@@ -19,8 +20,8 @@ import (
)
// The TestCurrentBehaviour_AccountTermination_ tests pin how key pairs are
// stored and what a delete or Describe leaves behind today. They describe
// current behaviour, not the target design; a failure means it changed.
// stored and what Describe leaves behind today; a failure means it changed.
// The TestAccountTermination_ tests pin the corrected owner delete.
const (
ctBucket = "test-bucket"
@@ -152,35 +153,97 @@ func TestCurrentBehaviour_AccountTermination_KeyPairStorageLayout(t *testing.T)
}
}
// Pins current behaviour that differs from AWS: a failed public-key delete is
// logged, the metadata delete proceeds and the call reports success. The
// leftover object still blocks the name and passes launch validation.
func TestCurrentBehaviour_AccountTermination_DeleteKeyPairLeavesPublicKeyWhenItsDeleteFails(t *testing.T) {
// A key pair is deleted public key first and metadata last, so a failed step
// returns an error and leaves the key pair described and found by name; a
// retried delete then removes both objects and frees the name.
func TestAccountTermination_DeleteKeyPairKeepsTheKeyPairWhenAStepFails(t *testing.T) {
prefix := "keys/" + ctAccount + "/"
tests := []struct {
name string
failOn func(keyPairID string) string
left func(keyPairID string) []string
}{
{"public key delete fails", func(string) string { return prefix + "deploy" },
func(id string) []string { return sortedStrings(prefix+"deploy", prefix+id+".json") }},
{"metadata delete fails", func(id string) string { return prefix + id + ".json" },
func(id string) []string { return []string{prefix + id + ".json"} }},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
store := newFaultyStore()
svc := ec2key.NewKeyServiceImplWithStore(store, ctBucket)
id := aws.StringValue(importKey(t, svc, ctAccount, "deploy").KeyPairId)
failing := tc.failOn(id)
store.failDelete = func(key string) bool { return key == failing }
_, err := svc.DeleteKeyPair(context.Background(), &ec2.DeleteKeyPairInput{KeyName: aws.String("deploy")}, ctAccount)
require.Error(t, err)
assert.Equal(t, awserrors.ErrorServerInternal, err.Error())
assert.Equal(t, tc.left(id), accountObjects(t, store, ctAccount))
assert.Equal(t, []string{"deploy"}, describedKeyNames(t, svc, ctAccount))
store.failDelete = nil
out, err := svc.DeleteKeyPair(context.Background(), &ec2.DeleteKeyPairInput{KeyName: aws.String("deploy")}, ctAccount)
require.NoError(t, err)
assert.Equal(t, id, aws.StringValue(out.KeyPairId))
assert.Empty(t, accountObjects(t, store, ctAccount))
assert.ErrorContains(t, svc.ValidateKeyPairExists(context.Background(), ctAccount, "deploy"), awserrors.ErrorInvalidKeyPairNotFound)
_, err = svc.CreateKeyPair(context.Background(), &ec2.CreateKeyPairInput{KeyName: aws.String("deploy")}, ctAccount)
assert.NoError(t, err, "the name is free again")
})
}
}
// A public key left with no metadata is that name's key: a delete by name
// removes it and reports success without a key pair ID, freeing the name.
func TestAccountTermination_DeleteKeyPairByNameRemovesALeftoverPublicKey(t *testing.T) {
store := newFaultyStore()
svc := ec2key.NewKeyServiceImplWithStore(store, ctBucket)
imported := importKey(t, svc, ctAccount, "deploy")
pubKeyPath := "keys/" + ctAccount + "/deploy"
store.failDelete = func(key string) bool { return key == pubKeyPath }
putObject(t, store, "keys/"+ctAccount+"/orphan", ctPubKey+"\n")
out, err := svc.DeleteKeyPair(context.Background(), &ec2.DeleteKeyPairInput{KeyName: aws.String("deploy")}, ctAccount)
out, err := svc.DeleteKeyPair(context.Background(), &ec2.DeleteKeyPairInput{KeyName: aws.String("orphan")}, ctAccount)
require.NoError(t, err)
assert.True(t, aws.BoolValue(out.Return))
assert.Equal(t, aws.StringValue(imported.KeyPairId), aws.StringValue(out.KeyPairId))
assert.Nil(t, out.KeyPairId)
assert.Empty(t, accountObjects(t, store, ctAccount))
_, err = svc.CreateKeyPair(context.Background(), &ec2.CreateKeyPairInput{KeyName: aws.String("orphan")}, ctAccount)
assert.NoError(t, err)
}
assert.Equal(t, []string{pubKeyPath}, accountObjects(t, store, ctAccount), "only the public key object remains")
assert.Empty(t, describedKeyNames(t, svc, ctAccount))
assert.NoError(t, svc.ValidateKeyPairExists(context.Background(), ctAccount, "deploy"))
// A by-name delete that cannot read some metadata cannot prove the name is
// absent, so it fails rather than reporting an idempotent success.
func TestAccountTermination_DeleteKeyPairByNameFailsWhenMetadataIsUnreadable(t *testing.T) {
store := newFaultyStore()
svc := ec2key.NewKeyServiceImplWithStore(store, ctBucket)
id := aws.StringValue(importKey(t, svc, ctAccount, "deploy").KeyPairId)
store.failGet = func(key string) bool { return strings.HasSuffix(key, id+".json") }
// A retried delete finds no metadata, reports success and removes nothing.
store.failDelete = nil
_, err = svc.DeleteKeyPair(context.Background(), &ec2.DeleteKeyPairInput{KeyName: aws.String("deploy")}, ctAccount)
require.NoError(t, err)
assert.Equal(t, []string{pubKeyPath}, accountObjects(t, store, ctAccount))
_, err = svc.CreateKeyPair(context.Background(), &ec2.CreateKeyPairInput{KeyName: aws.String("deploy")}, ctAccount)
_, err := svc.DeleteKeyPair(context.Background(), &ec2.DeleteKeyPairInput{KeyName: aws.String("deploy")}, ctAccount)
require.Error(t, err)
code, _ := awserrors.ResolveErrorCode(err)
assert.Equal(t, awserrors.ErrorInvalidKeyPairDuplicate, code)
assert.Equal(t, awserrors.ErrorServerInternal, err.Error())
assert.Len(t, accountObjects(t, store, ctAccount), 2, "nothing was deleted")
}
// Undecodable metadata may be the named key pair, so a by-name delete fails
// and leaves both the metadata and the same-name public key in place.
func TestAccountTermination_DeleteKeyPairByNameFailsOnUndecodableMetadata(t *testing.T) {
store := newFaultyStore()
svc := ec2key.NewKeyServiceImplWithStore(store, ctBucket)
broken := "keys/" + ctAccount + "/key-0badbadbadbadbad0.json"
pub := "keys/" + ctAccount + "/deploy"
putObject(t, store, broken, "{not json")
putObject(t, store, pub, ctPubKey+"\n")
_, err := svc.DeleteKeyPair(context.Background(), &ec2.DeleteKeyPairInput{KeyName: aws.String("deploy")}, ctAccount)
require.Error(t, err)
assert.Equal(t, awserrors.ErrorServerInternal, err.Error())
assert.Equal(t, sortedStrings(broken, pub), accountObjects(t, store, ctAccount))
}
func sortedStrings(s ...string) []string {
slices.Sort(s)
return s
}
// A create whose metadata write fails and whose cleanup delete also fails
@@ -231,3 +294,79 @@ func TestCurrentBehaviour_AccountTermination_DescribeKeyPairsHidesUnreadableReco
})
}
}
// Account cleanup deletes each readable key pair through the owner delete and
// removes a settled public key no metadata names. A younger orphan may be a
// create in flight and is left; unreadable metadata is never deleted.
func TestAccountCleanup_DeletesKeyPairsAndSettledOrphans(t *testing.T) {
store := newFaultyStore()
svc := ec2key.NewKeyServiceImplWithStore(store, ctBucket)
importKey(t, svc, ctAccount, "deploy")
importKey(t, svc, "999988887777", "theirs")
putObject(t, store, "keys/"+ctAccount+"/old-orphan", ctPubKey)
broken := "keys/" + ctAccount + "/key-0badbadbadbadbad0.json"
putObject(t, store, broken, "{not json")
out, err := svc.CleanupAccount(context.Background(), ctAccount, time.Now().Add(-time.Hour), nil)
require.NoError(t, err)
assert.Len(t, out.DeletedKeyPairIDs, 1)
assert.Zero(t, out.OrphansRemoved, "orphans are left while a public key may belong to unreadable metadata")
store.Clear()
importKey(t, svc, ctAccount, "deploy")
putObject(t, store, "keys/"+ctAccount+"/old-orphan", ctPubKey)
out, err = svc.CleanupAccount(context.Background(), ctAccount, time.Now().Add(-time.Hour), nil)
require.NoError(t, err)
assert.Equal(t, 1, out.OrphansUnsettled, "an orphan written inside the settle window is kept")
assert.Equal(t, []string{"keys/" + ctAccount + "/old-orphan"}, accountObjects(t, store, ctAccount))
out, err = svc.CleanupAccount(context.Background(), ctAccount, time.Now().Add(time.Second), nil)
require.NoError(t, err)
assert.Equal(t, 1, out.OrphansRemoved)
assert.Empty(t, accountObjects(t, store, ctAccount))
}
// A key pair, and an orphan public key, whose name the reference check names
// are kept with the given reason: IMDS serves the public key by name.
func TestAccountCleanup_KeepsReferencedKeyPairsAndPublicKeys(t *testing.T) {
store := newFaultyStore()
svc := ec2key.NewKeyServiceImplWithStore(store, ctBucket)
kp := importKey(t, svc, ctAccount, "in-use")
importKey(t, svc, ctAccount, "free")
putObject(t, store, "keys/"+ctAccount+"/served-orphan", ctPubKey)
referenced := func(name string) string {
if name == "in-use" || name == "served-orphan" {
return "instance-reference"
}
return ""
}
out, err := svc.CleanupAccount(context.Background(), ctAccount, time.Now().Add(time.Second), referenced)
require.NoError(t, err)
assert.Equal(t, map[string]string{aws.StringValue(kp.KeyPairId): "instance-reference"}, out.Referenced)
assert.Len(t, out.DeletedKeyPairIDs, 1)
assert.Equal(t, map[string]int{"instance-reference": 1}, out.OrphansReferenced)
assert.Zero(t, out.OrphansRemoved)
assert.Equal(t, []string{"in-use"}, describedKeyNames(t, svc, ctAccount))
assert.Contains(t, accountObjects(t, store, ctAccount), "keys/"+ctAccount+"/served-orphan")
}
// A key pair whose delete fails part way is reported failed with its metadata
// kept; the next run completes it.
func TestAccountCleanup_InterruptedKeyPairDeleteCompletesOnRerun(t *testing.T) {
store := newFaultyStore()
svc := ec2key.NewKeyServiceImplWithStore(store, ctBucket)
kp := importKey(t, svc, ctAccount, "deploy")
store.failDelete = func(key string) bool { return strings.HasSuffix(key, ".json") }
out, err := svc.CleanupAccount(context.Background(), ctAccount, time.Now(), nil)
require.NoError(t, err)
assert.Equal(t, []string{aws.StringValue(kp.KeyPairId)}, out.FailedKeyPairIDs)
assert.Equal(t, []string{"deploy"}, describedKeyNames(t, svc, ctAccount))
store.failDelete = nil
out, err = svc.CleanupAccount(context.Background(), ctAccount, time.Now(), nil)
require.NoError(t, err)
assert.Equal(t, []string{aws.StringValue(kp.KeyPairId)}, out.DeletedKeyPairIDs)
assert.Empty(t, accountObjects(t, store, ctAccount))
}

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