Commit Graph

69 Commits

Author SHA1 Message Date
joshsiv-mulga 5d60184e89 refactor: move EC2 compute and storage packages to the EC2 domain
Move handlers/ec2/{instance,volume,snapshot,image,tags} to
domains/ec2/<resource>, each named after its directory and imported as
ec2<resource>, which empties handlers/ec2. The daemon's e2e suite
selection now lists the moved EC2 resource paths in place of
handlers/ec2, so EC2-only changes select the daemon suites again.
2026-10-07 09:39:09 +10:00
joshsiv-mulga 20b5deb5c3 refactor: move EC2 VPC networking packages to the EC2 domain
Move handlers/ec2/{vpc,eigw,eip,igw,natgw,routetable} to
domains/ec2/<resource>, each named after its directory and imported as
ec2<resource>. The reconcile drift-watch source scan now matches the
ec2<resource> bucket qualifiers; its rule is unchanged.
2026-10-07 09:25:37 +10:00
joshsiv-mulga d8ab4017ad refactor: move standalone EC2 resource packages to the EC2 domain
Move handlers/ec2/{account,key,launchtemplate,placementgroup,
spotinstance,volumestate} to domains/ec2/<resource>. Each package is
named after its directory and callers import it as ec2<resource>;
volumestate already carried its directory name.
2026-10-07 09:15:14 +10:00
joshsiv-mulga 7003168bc5 refactor: move network packages under the network domain
Move spinifex/network/* to spinifex/domains/network/* as one enclosing
move so the L0-L5 layer invariants keep classifying every package from
a single root. Package names and dependency direction are unchanged;
invariant scan roots, the RG-10 sudo allow-list path, CODEOWNERS and
the repo-root walk in the IMDS netns unit test follow the new path.
2026-10-07 08:15:48 +10:00
joshsiv-mulga b5806ce630 refactor: move NATS transport to messaging foundation 2026-10-06 11:37:00 +10:00
joshsiv-mulga 02e0dd0dc6 refactor: move account identifiers to aws identifiers 2026-10-06 11:20:26 +10:00
joshsiv-mulga 7453385631 refactor: move error payload helpers to aws errors 2026-10-06 10:04:06 +10:00
joshsiv-mulga 931d86d5ce refactor: move awsgw role to runtime roles 2026-10-06 09:27:18 +10:00
joshsiv-mulga 3360382f51 refactor: consolidate idempotency vocabulary 2026-10-02 13:13:10 +10:00
joshsiv-mulga d3cd6d5208 refactor: move aws policy vocabulary to foundation 2026-10-02 10:39:03 +10:00
joshsiv-mulga 2b945ad886 refactor: move acm domain and action adapters 2026-10-02 09:54:16 +10:00
joshsiv-mulga 6ac02a8865 refactor: move cluster replication policy to state foundation 2026-10-02 09:43:17 +10:00
joshsiv-mulga 474c073167 refactor: move ecr auth bridge to domain 2026-10-01 14:48:07 +10:00
joshsiv-mulga 5e7311eb25 refactor: move ecr registry adapter to domain 2026-10-01 14:45:31 +10:00
joshsiv-mulga 51b5c55b95 refactor: move ecr aws actions to domain 2026-10-01 14:42:37 +10:00
joshsiv-mulga 93084043a9 refactor: move ecr resource owner to domain 2026-10-01 14:35:36 +10:00
joshsiv-mulga f119229861 refactor: move root configuration to bootstrap 2026-10-01 14:23:56 +10:00
joshsiv-mulga dbc62562c8 refactor: move object storage adapter to providers 2026-10-01 14:16:25 +10:00
joshsiv-mulga 3f91a3dbcd Merge remote-tracking branch 'origin/dev' into refactor/spinifex-package-boundaries
# Conflicts:
#	spinifex/daemon/daemon.go
#	spinifex/foundation/aws/arn/arn.go
#	spinifex/foundation/aws/arn/arn_test.go
#	spinifex/foundation/state/kvlease/bucket.go
#	spinifex/foundation/state/kvlease/bucket_test.go
#	spinifex/foundation/state/kvstore/wait.go
#	spinifex/foundation/state/kvstore/wait_test.go
#	spinifex/foundation/state/kvutil/bucket_creation_invariants_test.go
#	spinifex/foundation/state/kvutil/export_test.go
#	spinifex/foundation/state/kvutil/kvutil.go
#	spinifex/foundation/state/kvutil/kvutil_test.go
#	spinifex/foundation/state/kvutil/replicas.go
#	spinifex/foundation/state/kvutil/replicas_health_test.go
#	spinifex/foundation/state/kvutil/replicas_test.go
#	spinifex/foundation/state/kvutil/state_test.go
#	spinifex/gateway/auth.go
#	spinifex/gateway/bedrock/logging.go
#	spinifex/gateway/ec2_authz_test.go
#	spinifex/gateway/ecrapi/authz.go
#	spinifex/gateway/eks.go
#	spinifex/gateway/gateway_test.go
#	spinifex/handlers/ec2/eigw/service_impl.go
#	spinifex/handlers/ec2/igw/service_impl.go
#	spinifex/handlers/ec2/instance/service_impl.go
#	spinifex/handlers/ec2/launchtemplate/service_impl_test.go
#	spinifex/handlers/ec2/placementgroup/service_impl.go
#	spinifex/handlers/ec2/placementgroup/service_impl_test.go
#	spinifex/handlers/ec2/routetable/service_impl.go
#	spinifex/handlers/ec2/spotinstance/service_impl.go
#	spinifex/handlers/ec2/volume/service_impl.go
#	spinifex/handlers/ec2/vpc/eni.go
#	spinifex/handlers/ec2/vpc/security_group.go
#	spinifex/handlers/ec2/vpc/service_impl.go
#	spinifex/handlers/ecs/tags.go
#	spinifex/handlers/eks/k3s_ha_control_plane.go
#	spinifex/handlers/iam/groups.go
#	spinifex/handlers/iam/retry.go
#	spinifex/handlers/rds/subnetgroup_test.go
#	spinifex/handlers/sts/assume_role.go
#	spinifex/ingress/aws/query/parser_fuzz_test.go
#	spinifex/runtime/compute/cache/cache_test.go
#	spinifex/runtime/roles/nats/servername.go
#	spinifex/services/awsgw/awsgw.go
#	spinifex/services/viperblockd/volume_dirty.go
#	spinifex/utils/account.go
2026-10-01 13:31:45 +10:00
joshsiv-mulga 08bd335f0a refactor: establish node status contract 2026-10-01 08:22:26 +10:00
joshsiv-mulga aa0c36ff4e refactor: establish EC2 command contract 2026-09-30 14:46:31 +10:00
Julian Sommer 2b4ddad78a chore: drop dead and redundant nolint directives
Removes directives for linters that are not enabled (noctx, exhaustive,
deadcode) and gosec suppressions already covered by config exclusions.
Tags ochrevector QueryResult and Filter with their existing field names
so musttag passes without changing the NATS wire format, and bounds the
RDS window slot conversion instead of suppressing G115.
2026-09-30 13:46:11 +10:00
Julian Sommer 610ee9bab0 chore: use slices.Sorted 2026-09-30 11:44:17 +10:00
Julian Sommer 363aa1c4ab fix: match AWS's error codes and messages for bad CIDRs, missing gateways and routes, the default group name and malformed IDs 2026-09-30 10:42:33 +10:00
Julian Sommer 43e8d65247 fix: STS DurationSeconds and MFA member validation as AWS answers them (#1137) 2026-09-29 15:03:41 +10:00
joshsiv-mulga 94ddc91a18 refactor: move EBS provider contract to providers 2026-09-29 14:48:08 +10:00
joshsiv-mulga a74adc48b0 refactor: place EBS metadata in EC2 domain 2026-09-29 14:45:18 +10:00
joshsiv-mulga cbbf252e68 refactor: consolidate test helpers in internal testkit 2026-09-29 14:39:42 +10:00
Julian Sommer 9fe8c8b232 fix: IAM request validation as AWS answers it (#1135) 2026-09-29 14:06:03 +10:00
Julian Sommer 410faf3c83 fix: EC2 UnauthorizedOperation, MaxResults ranges and tag resource types as AWS answers them (#1134) 2026-09-29 12:06:48 +10:00
Julian Sommer ba2621ded5 docs: record the 2026-09-29 conformance baseline
Update request and EC2 error-code counts after the suite artefacts were removed, describe how the sweep now reaches its targets, and point at the plan that traces each finding.
2026-09-29 11:16:26 +10:00
Julian Sommer bfad1dc004 test: target fixture resources in generated conformance requests
Salts restart per operation, so tag, untag and update cases only reached a resource when the same-numbered create case had made one. Create an IAM, ECS and ECR fixture per run and point every operation but a create or delete at it, and give ECR's policy and manifest documents values AWS accepts.
2026-09-29 11:14:37 +10:00
Julian Sommer b3be052311 test: remove integration conformance suite artefacts
Clear the warnings the conformance suite caused itself, so what remains is a real divergence from AWS:

- Seed conditionally required members into the base request of RDS CreateDBInstance and RestoreDBInstanceFromDBSnapshot, ECS CreateCapacityProvider and CreateService, and EC2 CreateCapacityReservation.
- Give loosely modelled members values AWS accepts: ELBv2 ProtocolVersion and Matcher.HttpCode, EKS policyArn, addonName and access-entry type, EC2 SourceRegion and STS AccessKeyId.
- Import a CA-signed certificate, key and chain in ACM ImportCertificate.
- Rewrite ACM's lookahead domain pattern as RE2 so RequestCertificate is generated, and give the integration ACM service a tenant CA permitting example.com.
- Run the service daemon-lite behind the cross-tenant boundary test.
- Accept ELBv2's common parameter validation errors in responses.
2026-09-29 11:05:22 +10:00
joshsiv-mulga 956fe14902 refactor: move AWS errors to foundation 2026-09-29 10:51:28 +10:00
Ben Duncan 8ab96b29a1 fix: replicate every JetStream KV bucket across the cluster (#1117)
* fix: replicate every JetStream KV bucket across the cluster

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

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

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

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

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

* chore: name cell 33 in the nightly dispatch input

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

The surrounding prose reads from the operator's side rather than the
implementation's: what you see and what to do, not which consensus protocol is
underneath.
2026-09-28 12:34:55 -05:00
Julian Sommer 0fd0e2ba15 fix: apply PathPrefix, Scope and OnlyAttached in IAM ListPolicies 2026-09-28 15:36:22 +10:00
Julian Sommer 377c573bf6 feat: add IAM managed policy versions (#1124) 2026-09-28 15:28:20 +10:00
Julian Sommer 1426cdd072 fix: URL-encode IAM policy documents in Query API responses (#1123)
AWS percent-encodes every policy document it returns. Spinifex returned
raw JSON, so a client decoding once read '+' as a space and '%41' as 'A',
giving perpetual Terraform drift, and a lone '%' failed the read.

Encode in the gateway wrappers for CreateRole, GetRole, ListRoles,
GetRolePolicy, GetUserPolicy, GetGroupPolicy, GetPolicyVersion and the
instance-profile reads. The service layer keeps raw JSON because STS and
spx admin parse it in-process. The web UI now decodes role inline
policies like every other document.
2026-09-28 14:31:16 +10:00
Julian Sommer 2d2d2f27ed feat: model-driven request conformance in the integration suite (#1122) 2026-09-28 14:14:15 +10:00
Julian Sommer 6d4a66be90 fix(sts): reject session policies and tags as unsupported, and echo SourceIdentity (#1105) 2026-09-24 16:31:03 +10:00
Josh Siviour 0b93b1a957 fix: route the EKS update reads so they refuse instead of reading as a typo (#1084)
* fix: route the EKS update reads so they refuse instead of reading as a typo

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

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

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

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

Both name their cluster by a path segment that carries no account segment, so
neither can name another tenant's resource and neither needs a case of its own.
2026-09-18 17:10:48 +10:00
Josh Siviour 86014ac08a fix(ec2): serialise tag writes across the cluster (#1070)
CreateTags is a read-modify-write of one resource's whole tag map. It was
serialised by a sync.Mutex on the service struct, which exists once per
process, while the handler answers on the spinifex-workers queue group and so
runs on whichever node NATS picks. Two nodes read the same map, each added its
own key, and the later write won. Every call returned success.

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

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

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

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

The regression test drives six separate service values over one bucket,
because that is what makes it able to fail: with the old code every caller
inside one process was already serialised, and only a second node could lose an
update.
2026-09-17 13:53:47 +10:00
Josh Siviour 186f93a986 fix(iam): persist policy tags and count attachments across all principal kinds (#1040)
* fix(iam): persist policy tags and count attachments across all principal kinds

CreatePolicy built its record with an empty tag list and never read input.Tags, and GetPolicy, ListPolicies and CreatePolicy's own response projected no Tags field at all. Both halves are fixed, matching the role path.

buildAttachmentCounts scanned only the users bucket, so a policy attached to a role or group counted as unattached. That is not just a misleading number: DeletePolicy gates its conflict check on the same count, so an attached policy could be deleted, leaving the principal holding a dangling ARN.

The count and the reverse lookup are the same traversal with the names kept or discarded, so both now derive from one buildPolicyAttachments over users, roles and groups. Nothing is stored, so no counter can drift from the attachments it counts.

ListEntitiesForPolicy is implemented on that traversal, honouring EntityFilter and PathPrefix.

Closes: mulga-fvhlc
Closes: mulga-9i6uh
Closes: mulga-ryu30
Closes: mulga-8ranb

* test(iam): account for ListEntitiesForPolicy in the cross-tenant and policy-ARN gates

The action resolves PolicyArn through the same branch as GetPolicy, so it
belongs with that helper's siblings; it was in neither gate, which is what
the accounting test exists to catch.
2026-09-16 08:53:02 +10:00
Julian Sommer 0984eb31fb feat: implement ELBv2 DescribeAccountLimits (#1024) 2026-09-14 15:53:07 +10:00
Julian Sommer ba98509c5b fix: simplify mulga-docs build step by collapsing coverage paths + hardware 2026-09-14 12:41:02 +10:00
Julian Sommer fb6494da01 feat: coverage docs 2026-09-14 11:42:01 +10:00
Julian Sommer 0d8855d1aa fix: authorize EC2 PassRole against the stored role ARN (#1008) 2026-09-07 10:54:05 +10:00
Julian Sommer d8d3e2268d feat: partition EBS metadata keys by owning account (#989) 2026-09-03 15:38:23 +10:00
Julian Sommer a9a457489d fix: re-anchor a caller-supplied policy ARN onto the caller's account
The seven sourcePolicyARN actions resolved to the PolicyArn the caller sent,
so the gate decided against an ARN whose account the caller chose. The scope
now takes the ARN's path and name and rebuilds it on the caller's account, as
sourceOIDCARN beside it already does, and widens to account-wide when the ARN
carries no policy component. Lands with the cross-tenant integration suite that
found it.
2026-09-02 10:18:50 +10:00
Julian Sommer fae6b08acc fix: conform DescribeImages BootMode and IAM list required members
DescribeImages projected BootMode through aws.String unconditionally, so an
AMI recording none emitted "", which is outside the model enum. RegisterImage
takes BootMode as an optional input, so this is the normal path for an AMI
registered without one, not stale data. Set the member only when present.

ListAccessKeys and ListGroups built their results from a nil slice when
nothing matched. xmlutil drops a nil slice entirely rather than emitting an
empty element, so the response lost a member the model marks required. Start
both slices empty and non-nil.

Integration conformance drops from 18 violations to 15; the remainder are the
EC2 AccessDenied error-code findings, which are unrelated.
2026-08-27 11:46:39 +10:00