mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-25 21:49:38 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ab93d1fdcb | |||
| 3a79c1b866 | |||
| 8eefbc405f |
@@ -34,7 +34,7 @@ var allowlist = []allowance{
|
||||
|
||||
{From: "daemon", To: "domains/ec2/awsapi/instance", Debt: "Instance adapter consumers outside EC2", Reason: "daemon/eks_cp_control.go and rds_deps.go call the instance adapter; needs an EC2 capability"},
|
||||
|
||||
{From: "domains/acm", To: "domains/iam", Debt: "ACM: the IAM AES-GCM helper import (ACM relocation residual)", Reason: "IAM AES-GCM helpers; needs a shared crypto boundary"},
|
||||
{From: "domains/acm", To: "handlers/iam", Debt: "ACM: the IAM AES-GCM helper import (ACM relocation residual)", Reason: "IAM AES-GCM helpers; needs a shared crypto boundary"},
|
||||
|
||||
{From: "domains/admission/quota", To: "domains/ec2/awsapi/eip", Debt: "domains/admission/quota (dependency, moved from handlers/quota unchanged)", Reason: "describe adapter used as a counter; needs a reader capability"},
|
||||
{From: "domains/admission/quota", To: "domains/ec2/awsapi/instance", Debt: "domains/admission/quota (dependency, moved from handlers/quota unchanged)", Reason: "vcpu.go counts instances through the instance adapter; needs an EC2 reader capability"},
|
||||
@@ -48,17 +48,17 @@ var allowlist = []allowance{
|
||||
{From: "domains/dns", To: "domains/network/reconcile", Debt: "domains/dns", Reason: "takes the reconcile lock from network's implementation"},
|
||||
{From: "domains/dns", To: "runtime/compute/vm", Debt: "domains/dns", Reason: "domain-to-runtime import of VM state"},
|
||||
|
||||
{From: "domains/ec2/awsapi/instance", To: "domains/iam", Debt: "domains/ec2/awsapi/instance (dependency)", Reason: "IAM edge needs an IAM capability"},
|
||||
{From: "domains/ec2/awsapi/instance", To: "handlers/iam", Debt: "domains/ec2/awsapi/instance (dependency)", Reason: "IAM edge needs an IAM capability"},
|
||||
{From: "domains/ec2/awsapi/instance", To: "runtime/compute/cache", Debt: "domains/ec2/awsapi/instance (dependency)", Reason: "node liveness and VM state; needs an EC2-owned projection"},
|
||||
{From: "domains/ec2/awsapi/instance", To: "runtime/compute/vm", Debt: "domains/ec2/awsapi/instance (dependency)", Reason: "node liveness and VM state; needs an EC2-owned projection"},
|
||||
|
||||
{From: "domains/ec2/awsapi/spotinstance", To: "domains/admission/quota", Debt: "domains/ec2/awsapi/spotinstance (dependency)", Reason: "RequestSpotInstances.go uses IAMService and quota.Service"},
|
||||
{From: "domains/ec2/awsapi/spotinstance", To: "domains/iam", Debt: "domains/ec2/awsapi/spotinstance (dependency)", Reason: "RequestSpotInstances.go uses IAMService and quota.Service"},
|
||||
{From: "domains/ec2/awsapi/spotinstance", To: "handlers/iam", Debt: "domains/ec2/awsapi/spotinstance (dependency)", Reason: "RequestSpotInstances.go uses IAMService and quota.Service"},
|
||||
|
||||
{From: "domains/ec2/eip", To: "domains/network/topology", Debt: "EC2 → network implementation", Reason: "EC2 reads network implementation; clears with the EC2-owned projection"},
|
||||
|
||||
{From: "domains/ec2/guestmetadata", To: "domains/dns", Debt: "Guest metadata: the cross-domain and runtime imports above", Reason: "guest-metadata relocation residual; needs capabilities, projections or contracts"},
|
||||
{From: "domains/ec2/guestmetadata", To: "domains/iam", Debt: "Guest metadata: the cross-domain and runtime imports above", Reason: "guest-metadata relocation residual; needs capabilities, projections or contracts"},
|
||||
{From: "domains/ec2/guestmetadata", To: "handlers/iam", Debt: "Guest metadata: the cross-domain and runtime imports above", Reason: "guest-metadata relocation residual; needs capabilities, projections or contracts"},
|
||||
{From: "domains/ec2/guestmetadata", To: "handlers/sts", Debt: "Guest metadata: the cross-domain and runtime imports above", Reason: "guest-metadata relocation residual; needs capabilities, projections or contracts"},
|
||||
{From: "domains/ec2/guestmetadata", To: "runtime/compute/vm", Debt: "Guest metadata: the cross-domain and runtime imports above", Reason: "guest-metadata relocation residual; needs capabilities, projections or contracts"},
|
||||
|
||||
@@ -76,7 +76,7 @@ var allowlist = []allowance{
|
||||
{From: "domains/ec2/vpc", To: "domains/network/identifiers", Debt: "EC2 → network implementation", Reason: "EC2 reads network implementation; clears with the EC2-owned projection"},
|
||||
{From: "domains/ec2/vpc", To: "domains/network/topology", Debt: "EC2 → network implementation", Reason: "EC2 reads network implementation; clears with the EC2-owned projection"},
|
||||
|
||||
{From: "domains/ecr/auth", To: "domains/iam", Debt: "Cross-domain", Reason: "IAM implementation import; needs an IAM capability"},
|
||||
{From: "domains/ecr/auth", To: "handlers/iam", Debt: "Cross-domain", Reason: "IAM implementation import; needs an IAM capability"},
|
||||
|
||||
{From: "domains/network/host", To: "runtime/compute/vm", Debt: "domains/network", Reason: "domain-to-runtime import"},
|
||||
{From: "domains/network/host", To: "runtime/host/command", Debt: "domains/network", Reason: "domain-to-runtime import"},
|
||||
@@ -93,7 +93,7 @@ var allowlist = []allowance{
|
||||
{From: "domains/ochre", To: "gateway/bedrock", Debt: "gateway/bedrock", Reason: "Ochre domain core still lives in gateway/bedrock; needs the recorded split"},
|
||||
{From: "domains/ochre", To: "runtime/compute/gpu", Debt: "Domain → runtime", Reason: "GPU runtime state read directly; needs a domain-owned projection"},
|
||||
|
||||
{From: "domains/ochre/vector", To: "domains/iam", Debt: "Cross-domain", Reason: "consumes another domain's implementation rather than a capability"},
|
||||
{From: "domains/ochre/vector", To: "handlers/iam", Debt: "Cross-domain", Reason: "consumes another domain's implementation rather than a capability"},
|
||||
|
||||
{From: "gateway", To: "domains/ec2/awsapi", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
|
||||
{From: "gateway", To: "domains/ec2/awsapi/account", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
|
||||
@@ -116,17 +116,17 @@ var allowlist = []allowance{
|
||||
{From: "gateway", To: "domains/ec2/awsapi/vpc", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
|
||||
{From: "gateway", To: "domains/ec2/awsapi/zone", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
|
||||
|
||||
{From: "gateway/bedrock", To: "domains/iam", Debt: "IAM implementation consumers in STS, Ochre and ELBv2", Reason: "secret encrypt/decrypt helpers; needs a shared crypto boundary"},
|
||||
{From: "gateway/bedrock", To: "handlers/iam", Debt: "IAM implementation consumers in STS, Ochre and ELBv2", Reason: "secret encrypt/decrypt helpers; needs a shared crypto boundary"},
|
||||
|
||||
{From: "gateway/rds", To: "domains/ec2/awsapi/instance", Debt: "Instance adapter consumers outside EC2", Reason: "enginecatalog.go calls the instance adapter; needs an EC2 capability"},
|
||||
|
||||
{From: "gateway/sts", To: "domains/iam", Debt: "IAM implementation consumers in STS, Ochre and ELBv2", Reason: "GetCallerIdentity takes IAMService; needs an IAM capability"},
|
||||
{From: "gateway/sts", To: "handlers/iam", Debt: "IAM implementation consumers in STS, Ochre and ELBv2", Reason: "GetCallerIdentity takes IAMService; needs an IAM capability"},
|
||||
|
||||
{From: "handlers/ecs", To: "domains/ec2/ebs/metadata", Debt: "close-out: handlers/ecs (ADR-0004 blocked-dependency list; inventory-ecs boundary imports)", Reason: "legacy ECS calls another domain's implementation as an internal API"},
|
||||
{From: "handlers/ecs", To: "domains/ec2/eip", Debt: "close-out: handlers/ecs (ADR-0004 blocked-dependency list; inventory-ecs boundary imports)", Reason: "legacy ECS calls another domain's implementation as an internal API"},
|
||||
{From: "handlers/ecs", To: "domains/ec2/instancetypes", Debt: "close-out: handlers/ecs (ADR-0004 blocked-dependency list; inventory-ecs boundary imports)", Reason: "legacy ECS calls another domain's implementation as an internal API"},
|
||||
{From: "handlers/ecs", To: "handlers/elbv2", Debt: "close-out: handlers/ecs (ADR-0004 blocked-dependency list; inventory-ecs boundary imports)", Reason: "legacy ECS calls another domain's implementation as an internal API"},
|
||||
{From: "handlers/ecs", To: "domains/iam", Debt: "close-out: handlers/ecs (ADR-0004 blocked-dependency list; inventory-ecs boundary imports)", Reason: "legacy ECS calls another domain's implementation as an internal API"},
|
||||
{From: "handlers/ecs", To: "handlers/iam", Debt: "close-out: handlers/ecs (ADR-0004 blocked-dependency list; inventory-ecs boundary imports)", Reason: "legacy ECS calls another domain's implementation as an internal API"},
|
||||
|
||||
{From: "handlers/eks", To: "domains/dns", Debt: "close-out: handlers/eks (ADR-0004 blocked-dependency list; inventory-eks boundary imports)", Reason: "legacy EKS calls another domain's or runtime implementation directly"},
|
||||
{From: "handlers/eks", To: "domains/ec2/ebs/metadata", Debt: "close-out: handlers/eks (ADR-0004 blocked-dependency list; inventory-eks boundary imports)", Reason: "legacy EKS calls another domain's or runtime implementation directly"},
|
||||
@@ -134,7 +134,7 @@ var allowlist = []allowance{
|
||||
{From: "handlers/eks", To: "domains/ec2/placementgroup", Debt: "close-out: handlers/eks (ADR-0004 blocked-dependency list; inventory-eks boundary imports)", Reason: "legacy EKS calls another domain's or runtime implementation directly"},
|
||||
{From: "handlers/eks", To: "domains/ec2/systeminstance", Debt: "close-out: handlers/eks (ADR-0004 blocked-dependency list; inventory-eks boundary imports)", Reason: "legacy EKS calls another domain's or runtime implementation directly"},
|
||||
{From: "handlers/eks", To: "domains/network/systemvpc", Debt: "close-out: handlers/eks (ADR-0004 blocked-dependency list; inventory-eks boundary imports)", Reason: "legacy EKS calls another domain's or runtime implementation directly"},
|
||||
{From: "handlers/eks", To: "domains/iam", Debt: "close-out: handlers/eks (ADR-0004 blocked-dependency list; inventory-eks boundary imports)", Reason: "legacy EKS calls another domain's or runtime implementation directly"},
|
||||
{From: "handlers/eks", To: "handlers/iam", Debt: "close-out: handlers/eks (ADR-0004 blocked-dependency list; inventory-eks boundary imports)", Reason: "legacy EKS calls another domain's or runtime implementation directly"},
|
||||
{From: "handlers/eks", To: "runtime/compute/vm", Debt: "close-out: handlers/eks (ADR-0004 blocked-dependency list; inventory-eks boundary imports)", Reason: "legacy EKS calls another domain's or runtime implementation directly"},
|
||||
|
||||
{From: "handlers/elbv2", To: "domains/acm", Debt: "handlers/elbv2 (cross-domain)", Reason: "legacy ELBv2 cross-domain import; clears when ELBv2 is extracted behind consumer-owned capabilities"},
|
||||
@@ -142,7 +142,7 @@ var allowlist = []allowance{
|
||||
{From: "handlers/elbv2", To: "domains/ec2/systeminstance", Debt: "handlers/elbv2 (cross-domain)", Reason: "legacy ELBv2 cross-domain import; clears when ELBv2 is extracted behind consumer-owned capabilities"},
|
||||
{From: "handlers/elbv2", To: "domains/ec2/vpc", Debt: "handlers/elbv2 (cross-domain)", Reason: "legacy ELBv2 cross-domain import; clears when ELBv2 is extracted behind consumer-owned capabilities"},
|
||||
{From: "handlers/elbv2", To: "domains/network/topology", Debt: "handlers/elbv2 (cross-domain)", Reason: "legacy ELBv2 cross-domain import; clears when ELBv2 is extracted behind consumer-owned capabilities"},
|
||||
{From: "handlers/elbv2", To: "domains/iam", Debt: "IAM implementation consumers in STS, Ochre and ELBv2", Reason: "system-instance role ensurer; needs an IAM capability"},
|
||||
{From: "handlers/elbv2", To: "handlers/iam", Debt: "IAM implementation consumers in STS, Ochre and ELBv2", Reason: "system-instance role ensurer; needs an IAM capability"},
|
||||
{From: "handlers/elbv2", To: "lbagent", Debt: "handlers/elbv2/{agent_types,haproxy,health_checker,nginx}.go import lbagent", Reason: "health-report and cert/PID-path vocabulary; needs a guest/controller contract"},
|
||||
|
||||
{From: "handlers/rds", To: "domains/dns", Debt: "close-out: handlers/rds (ADR-0004 blocked-dependency list; inventory-rds boundary imports)", Reason: "legacy RDS calls another domain's or runtime implementation directly"},
|
||||
@@ -150,14 +150,15 @@ var allowlist = []allowance{
|
||||
{From: "handlers/rds", To: "domains/ec2/instancetypes", Debt: "close-out: handlers/rds (ADR-0004 blocked-dependency list; inventory-rds boundary imports)", Reason: "legacy RDS calls another domain's or runtime implementation directly"},
|
||||
{From: "handlers/rds", To: "domains/ec2/systeminstance", Debt: "close-out: handlers/rds (ADR-0004 blocked-dependency list; inventory-rds boundary imports)", Reason: "legacy RDS calls another domain's or runtime implementation directly"},
|
||||
{From: "handlers/rds", To: "domains/network/systemvpc", Debt: "close-out: handlers/rds (ADR-0004 blocked-dependency list; inventory-rds boundary imports)", Reason: "legacy RDS calls another domain's or runtime implementation directly"},
|
||||
{From: "handlers/rds", To: "domains/iam", Debt: "close-out: handlers/rds (ADR-0004 blocked-dependency list; inventory-rds boundary imports)", Reason: "legacy RDS calls another domain's or runtime implementation directly"},
|
||||
{From: "handlers/rds", To: "handlers/iam", Debt: "close-out: handlers/rds (ADR-0004 blocked-dependency list; inventory-rds boundary imports)", Reason: "legacy RDS calls another domain's or runtime implementation directly"},
|
||||
{From: "handlers/rds", To: "runtime/compute/vm", Debt: "close-out: handlers/rds (ADR-0004 blocked-dependency list; inventory-rds boundary imports)", Reason: "legacy RDS calls another domain's or runtime implementation directly"},
|
||||
|
||||
{From: "handlers/sts", To: "domains/iam", Debt: "IAM implementation consumers in STS, Ochre and ELBv2", Reason: "STS reads IAM service, trust-policy and OIDC-provider state; needs an IAM capability"},
|
||||
{From: "handlers/sts", To: "handlers/iam", Debt: "IAM implementation consumers in STS, Ochre and ELBv2", Reason: "STS reads IAM service, trust-policy and OIDC-provider state; needs an IAM capability"},
|
||||
|
||||
{From: "operator/cli", To: "accountteardown", Debt: "operator/cli (dependency)", Reason: "command tree imports implementations; needs public capabilities"},
|
||||
{From: "operator/cli", To: "daemon", Debt: "operator/cli (dependency)", Reason: "command tree imports implementations; needs public capabilities"},
|
||||
{From: "operator/cli", To: "gateway", Debt: "operator/cli (dependency)", Reason: "command tree imports implementations; needs public capabilities"},
|
||||
{From: "operator/cli", To: "gateway/bedrock", Debt: "operator/cli (dependency)", Reason: "command tree imports implementations; needs public capabilities"},
|
||||
{From: "operator/cli", To: "handlers/eks", Debt: "operator/cli (dependency)", Reason: "command tree imports implementations; needs public capabilities"},
|
||||
{From: "operator/cli", To: "handlers/iam", Debt: "operator/cli (dependency)", Reason: "command tree imports implementations; needs public capabilities"},
|
||||
}
|
||||
|
||||
@@ -24,6 +24,8 @@ var frozenLegacyPackages = []string{
|
||||
"handlers/ecs",
|
||||
"handlers/eks",
|
||||
"handlers/elbv2",
|
||||
"handlers/iam",
|
||||
"handlers/iam/mock",
|
||||
"handlers/rds",
|
||||
"handlers/sts",
|
||||
"lbagent",
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,31 +0,0 @@
|
||||
// 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"`
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
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))
|
||||
}
|
||||
@@ -65,7 +65,7 @@ An incompatible change to the subject, fields or vocabulary needs a new major.
|
||||
|
||||
`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.
|
||||
`accountteardown` is the intended consumer from a later release; no teardown calls it yet.
|
||||
|
||||
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.
|
||||
@@ -82,20 +82,3 @@ The subject name carries the contract major; `account_settings_seal_test.go` pre
|
||||
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.
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
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"`
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,11 @@ type EBSRequest struct {
|
||||
NBDURI string `json:"NBDURI"`
|
||||
DeviceName string `json:"DeviceName"`
|
||||
HotplugPort int `json:"HotplugPort,omitempty"`
|
||||
// BlockDevice and BlockSizeBytes describe a host block device export, the
|
||||
// alternative to NBDURI; the guest sees BlockSizeBytes of it. Exactly one
|
||||
// of NBDURI and BlockDevice may be set.
|
||||
BlockDevice string `json:"BlockDevice,omitempty"`
|
||||
BlockSizeBytes uint64 `json:"BlockSizeBytes,omitempty"`
|
||||
}
|
||||
|
||||
// EBSMountResponse is the response to an EBSRequest on MountSubject.
|
||||
|
||||
@@ -35,6 +35,18 @@ func TestEBSRequestJSONContract(t *testing.T) {
|
||||
"NBDURI":"nbd:unix:/run/spinifex/vol-123.sock",
|
||||
"DeviceName":"/dev/sdf", "HotplugPort":3
|
||||
}`, string(data))
|
||||
|
||||
block, err := json.Marshal(EBSRequest{
|
||||
Name: "vol-456",
|
||||
BlockDevice: "/dev/sdx",
|
||||
BlockSizeBytes: 10 << 30,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.JSONEq(t, `{
|
||||
"Name":"vol-456", "VolType":"", "Boot":false, "EFI":false,
|
||||
"DeleteOnTermination":false, "NBDURI":"", "DeviceName":"",
|
||||
"BlockDevice":"/dev/sdx", "BlockSizeBytes":10737418240
|
||||
}`, string(block))
|
||||
}
|
||||
|
||||
func TestVolumeResponseJSONContracts(t *testing.T) {
|
||||
|
||||
@@ -340,7 +340,6 @@ while the branch is in flight; they do not describe the umbrella repository's
|
||||
| `0d830e901` | — | `spinifex/domains/tagging` (`TaggedLister`, `TaggedResource`) | The read-only list capability for the Resource Groups Tagging aggregation, declared by its consumer per ADR-0001. `ListTagged` takes the caller's account and the lower-cased `ResourceTypeFilters` set, where an entry is a full `service:type` or a bare service, and returns ARNs with read-only tag maps. The package imports no domain, handler or contract package and the layering ratchet classifies it with no allowlist row. It carries no mutation, limit or ever-tagged surface. |
|
||||
| `f3545e3ea` | `spinifex/gateway/tagging` (`listEC2`, `buildEC2Mappings`, `ec2ARN`, `elbv2Resources`, `matchesType`), copied | `spinifex/runtime/roles/awsgw` (`ec2IndexLister`, `elbv2Lister`), `spinifex/domains/tagging` (`MatchesType`) | AWSGW-side NATS client adapters implement `tagging.TaggedLister`, each with its compile-time check beside it in the composition root; no owner package carries one. The EC2 index adapter wraps `ec2.DescribeTags` and builds each ARN in the gateway's region from the stored resource type, including the unresolved-ID shapes today's code produces. The ELBv2 adapter lists load balancers and target groups admitted by the type filter, tagged or not, and reads tags in `DescribeTags` batches of 20. Both reuse existing subjects and clients, so no wire contract is added, and each call builds fresh tag maps. Type matching is exported once from `domains/tagging` so no copy survives the move. Embedded-JetStream tests cover type filters including a bare service, untagged ELBv2 resources, EC2 ARN mapping, account scoping, batch sizes and owner error propagation. |
|
||||
| `aedd0b2c4` | `spinifex/gateway/tagging` (`GetResources` parsing, tag filtering, sorting, offset token, authorization, response writing) | `spinifex/domains/tagging` (`Aggregator`, `NewAggregator`, `HasScope`, `ScopedActions`), wired by `spinifex/runtime/roles/awsgw` | The aggregation moves unchanged and consumes `[]TaggedLister`: ELBv2 is called before EC2, the non-stable ARN sort is kept, lister errors propagate with the same codes, and tag maps return as key-sorted tag lists with `[]` for untagged resources. AWSGW builds the aggregator with both adapters and sets the new `GatewayConfig.TaggingResources` field; the dispatch in `gateway/tagging.go` stays and still answers `ServerInternal` when NATS or the aggregator is not wired. The `TestCurrentBehaviour_Tagging_GetResources*` tests move to `runtime/roles/awsgw` with unchanged assertions, seeding EC2 tags through the existing EC2 tags client over NATS; pure aggregator tests move to `domains/tagging` against fake listers; the gateway keeps dispatch and error-path tests. The gateway's private copies of the listing helpers and the legacy NATS lister are deleted with their tests, `gateway/tagging` is removed, and its two allowlist rows (`gateway/tagging` → `domains/ec2/tags`, `gateway/tagging` → `handlers/elbv2`) and its frozen-legacy entry go with it. |
|
||||
| `spinifex#1249` | `spinifex/handlers/iam`, `spinifex/handlers/iam/mock` | `spinifex/domains/iam` (`iam`, imported as `iamdomain`), `spinifex/domains/iam/mock` | A mechanical relocation of IAM's implementation to its ADR-0001 home; APIs, NATS subjects, KV buckets and behaviour are unchanged and only the package name changed from `handlers_iam`. Per-consumer IAM capabilities remain dependency-rule debt but were not a prerequisite. The two frozen-legacy entries are removed, the cross-domain allowlist entries now target `domains/iam`, and the `operator/cli` → `handlers/iam` entry is removed because an operator import of a domain package does not break the legacy-implementation rule. |
|
||||
The EC2 contract row is intentionally different from the directory moves: it creates
|
||||
a compatibility boundary. `ec2.cmd.*` retains its deployed one-token NATS
|
||||
subscription and permission shape, while callers use
|
||||
@@ -408,7 +407,7 @@ These residuals were recorded by individual slices and must be resolved once the
|
||||
|
||||
- Guest metadata: the cross-domain and runtime imports above.
|
||||
- EC2 → network implementation: `domains/ec2/vpc/eni.go:18` and `eni_lease_rebind.go:11` import `domains/network/topology`, `eni.go:17` also imports `domains/network/identifiers`, `external_ipam.go:10-11` imports `domains/network/external` and `domains/network/external/dhcp`, `domains/ec2/eip/service_impl.go:17` and `lease_rebind.go:11` import `domains/network/topology`, and `domains/ec2/instance/service_impl.go:31` imports `domains/network/topology`. EC2 reads network's implementation directly rather than through a capability or projection; this clears when EC2's network-facing reads and writes go through the EC2-owned projection recorded above (step 7).
|
||||
- Cross-domain: `domains/ecr/auth/signingkey.go:24` and `domains/ochre/vector/appliance.go:15` import `domains/iam`; this needs an IAM capability rather than the IAM implementation package. `domains/ochre/launch.go:17-18` and `domains/ochre/vpc.go:9` import `domains/ec2/systeminstance` and `domains/network/systemvpc`; Ochre consumes managed-service launch and system-VPC realisation as domain implementations rather than capabilities.
|
||||
- Cross-domain: `domains/ecr/auth/signingkey.go:24` and `domains/ochre/vector/appliance.go:15` import `handlers/iam`; this needs an IAM capability rather than the IAM implementation package. `domains/ochre/launch.go:17-18` and `domains/ochre/vpc.go:9` import `domains/ec2/systeminstance` and `domains/network/systemvpc`; Ochre consumes managed-service launch and system-VPC realisation as domain implementations rather than capabilities.
|
||||
- Domain → runtime: `domains/ochre/capacity.go:9` imports `runtime/compute/gpu`; `domains/ec2/volume/service_impl.go:30` and `volume_leak_reaper.go:8` import `runtime/compute/vm`; `domains/ec2/instance/status_selection.go:9` also imports `runtime/compute/vm`. Each reads VM or GPU runtime state directly instead of through a domain-owned projection.
|
||||
- STS web-identity verification never consults cluster status: tokens verify while a cluster is DELETING until teardown sweeps the prefix, and a FAILED launch keeps its JWKS. AWS behaviour must be established before calling it a divergence. Removal condition: AWS behaviour verified, and the resolver or EKS lifecycle aligned with it.
|
||||
- The public `/keys` endpoint (root `gateway`, `oidc_discovery.go`) serves malformed stored JWKS bytes with HTTP 200. Removal condition: validate before serving, or record it as accepted.
|
||||
@@ -447,10 +446,10 @@ 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`; 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.
|
||||
- `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.
|
||||
- `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 `domains/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 `domains/iam.IAMService` and `domains/admission/quota.Service` in `RequestSpotInstances.go`.
|
||||
- `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`.
|
||||
- Instance adapter consumers outside EC2: `gateway/rds/enginecatalog.go`, `daemon/eks_cp_control.go`, `daemon/rds_deps.go`, `domains/admission/quota`'s `vcpu.go` and `gateway/iam.go` (`CountInstanceProfileAssociations`) call `domains/ec2/awsapi/instance` directly; each needs an EC2 capability rather than the AWS action adapter.
|
||||
|
||||
Enforcement: the owner decided the layering test is blocking now, with a ratchet. Its allowlist mirrors this debt list exactly, each edge carrying its owner and removal condition; any new unrecorded forbidden edge fails local preflight and CI, and an allowlisted edge that no longer exists must be removed from the list.
|
||||
@@ -464,7 +463,7 @@ Enforcement: the owner decided the layering test is blocking now, with a ratchet
|
||||
- `agents/rds/agent` (dependency, narrowed): engine metadata now arrives through the agent-declared `EngineCatalog`, but the agent still imports `handlers/rds` for the command protocol, wire and record types (`Command*`, `Parameter`, `EngineHealth*`, `BootstrapMode*`) and API output types. This clears when those move behind a guest/controller wire contract.
|
||||
- `gateway/rds` → `handlers/rds` (`InstanceSizing`): the gateway obtains its `Sizing` through the RDS handler package's adapter rather than its own composition root. This clears when the gateway's RDS composition moves to `runtime/roles/awsgw` and builds `Sizing` there.
|
||||
- `domains/admission/quota` → `domains/ec2/instancetypes`: `reconcile.go`, `records.go` and `vcpu.go` read `DefaultVCPUs` to size instances without a recorded vCPU count. This clears when quota receives vCPU sizing through a consumer-owned capability or an EC2 projection.
|
||||
- IAM implementation consumers in STS, Ochre and ELBv2: `handlers/sts` imports `domains/iam` across five files (`IAMService`, `SessionRevoker`, `EncryptSecret`/`DecryptSecret`, `AccessKeyStatusActive`, `ValidateTrustPolicyDocument`, `PolicyEffectAllow`/`PolicyEffectDeny`, `IAMAccountBucketName`, `OIDCProviderARN`, `OIDCProviderKey`), not only `oidc_jwks.go`; `gateway/sts/GetCallerIdentity.go` takes `IAMService`; `gateway/bedrock/credentials.go` uses `EncryptSecret`/`DecryptSecret`; and `handlers/elbv2/{lb_role,service_impl}.go` use `EnsureSystemInstanceProfile` and `SystemInstanceRoleEnsurer`. Each clears when its consumer receives a narrow IAM capability, or a shared crypto boundary for the secret helpers, rather than importing the IAM implementation.
|
||||
- IAM implementation consumers in STS, Ochre and ELBv2: `handlers/sts` imports `handlers/iam` across five files (`IAMService`, `SessionRevoker`, `EncryptSecret`/`DecryptSecret`, `AccessKeyStatusActive`, `ValidateTrustPolicyDocument`, `PolicyEffectAllow`/`PolicyEffectDeny`, `IAMAccountBucketName`, `OIDCProviderARN`, `OIDCProviderKey`), not only `oidc_jwks.go`; `gateway/sts/GetCallerIdentity.go` takes `IAMService`; `gateway/bedrock/credentials.go` uses `EncryptSecret`/`DecryptSecret`; and `handlers/elbv2/{lb_role,service_impl}.go` use `EnsureSystemInstanceProfile` and `SystemInstanceRoleEnsurer`. Each clears when its consumer receives a narrow IAM capability, or a shared crypto boundary for the secret helpers, rather than importing the IAM implementation.
|
||||
- `handlers/elbv2` (cross-domain): `service_impl.go` imports `domains/acm` (`Store`, `NewStore`), `domains/dns` (`ELBChanges`, `ELBName`, `Change`, `ActionUpsert`, `ResolveBaseDomain`), `domains/ec2/vpc` (`VPCServiceImpl`) and `domains/network/topology` (`Port`), and `launcher.go` re-exports `domains/ec2/systeminstance` launch types as aliases. These clear when ELBv2 is extracted and each edge becomes a consumer-owned capability, which ADR-0001 sequences after the ELBv2 data-plane boundary is settled.
|
||||
- `docs/service-interfaces.yaml` (ECS coverage gap, closed): the traced `ecs.bus.*` family and the ECS AWS-API action subjects are both listed. The action subjects are declared as `ecs.*`, the same wildcard convention as `rds.*`: `handlers/ecs/service_nats.go` publishes them from the gateway and `daemon/daemon.go` subscribes to them. Declaring `ecs.bus.*` alone had made `ecs` a managed prefix, so `manifest-lint` failed CI on all 34 undeclared action subjects until this was added.
|
||||
- `contracts/ecs/v1` (dead routes): four of five bus subjects have no producer. The scheduler leader still subscribes to `instance-register`, `instance-heartbeat` and `task-state`, which can never fire, and `assign` has neither producer nor consumer; only `service-reconcile` is live. Removal condition: an owner decision to retire these routes (a successor contract under the compatibility rules) or to adopt them in the agent.
|
||||
@@ -651,13 +650,13 @@ series above; "needs decision" is open.
|
||||
|
||||
| Legacy path | What remains | What blocks it |
|
||||
|---|---|---|
|
||||
| `handlers/iam` | — | Done. The implementation moved to `domains/iam` (package `iam`) in `spinifex#1249` with no API, NATS subject, storage or behaviour change. ADR-0001's row says a shared IAM contract is not inferred from current KV readers, and the move adds none. The cross-domain readers (the EC2 instance and spot-instance AWS adapters, `handlers/sts`, `gateway/sts`, `gateway/bedrock`, the ELBv2 system-instance role path and ECR/Ochre signing-key crypto) still import the implementation and each still needs an IAM capability; that is recorded dependency-rule debt, with its allowlist entries now targeting `domains/iam`, not a prerequisite to relocating IAM's implementation. |
|
||||
| `handlers/sts` | Everything except the EKS OIDC JWKS lookup, which now reaches `handlers/eks` only through the injected `ClusterJWKSResolver`. | Deferred: per-consumer IAM capabilities (dependency-rule debt above); `handlers/sts` still imports `domains/iam` directly. |
|
||||
| `handlers/iam` | Private identity state and credential issuance; consumed by the EC2 instance and spot-instance AWS adapters, `handlers/sts`, `gateway/sts`, `gateway/bedrock`, the ELBv2 system-instance role path, ECR/Ochre signing-key crypto and `operator/cli`. | Deferred: the ADR's `handlers/iam` row states a shared IAM contract is not inferred from current KV readers; each cross-domain reader needs an IAM capability first. |
|
||||
| `handlers/sts` | Everything except the EKS OIDC JWKS lookup, which now reaches `handlers/eks` only through the injected `ClusterJWKSResolver`. | Deferred: per-consumer IAM capabilities (dependency-rule debt above); `handlers/sts` still imports `handlers/iam` directly. |
|
||||
| `handlers/ecs` | Task, service and capacity resources (`taskdefinition` already extracted to `domains/ecs/taskdefinition`). | Deferred: ADR-0004 blocked-dependency list — `iam:PassRole` in the gateway adapter and EC2/ELBv2/EIP adapters called as internal APIs. |
|
||||
| `handlers/eks` | Nodegroup and cluster resources (`access` and `addon` already extracted). | Deferred: ADR-0004 blocked-dependency list — IAM role/instance-profile creation, Bluebottle `auth`, the NATS CA, and EKS identity (token review, OIDC). |
|
||||
| `handlers/rds` | The RDS instance resource (subnet group, parameter group and engine catalogue already extracted). | Deferred: ADR-0004 blocked-dependency list — quota admission, the duplicated expected-node fan-out, the IAM ensurer and the Bluebottle master key. |
|
||||
| `handlers/elbv2` | Everything; not yet touched. | Deferred: ADR-0001 sequences this after the ELBv2 data-plane boundary is settled. Needs decision: what evidence establishes that boundary is settled. |
|
||||
| `handlers/iam/mock` | — | Done. Moved with the implementation to `domains/iam/mock`. |
|
||||
| `handlers/iam/mock` | A test double for `handlers/iam`. | Moves with `handlers/iam`; it has no independent target. |
|
||||
| `gateway` (root) | Generic HTTP route assembly, SigV4/registry authentication, the policy gate, request-audit middleware, request-context keys, and the legacy per-service dispatch switch for every service not yet registered. | Deferred: the ADR-0002 phases above (auth/IAM and request-context boundary design) gate moving the audit, context-key and legacy-dispatch mechanics. |
|
||||
| `gateway/bedrock` | The Ochre domain core, its stores and the inference engine, together with the Bedrock action layer, in one package with internal cycles. | Deferred: needs the split plan recorded above — domain core and stores to `domains/ochre/<resource>`, inference to an Ochre-owned package, and only the action layer to `domains/ochre/awsapi`. |
|
||||
| `gateway/{ecs,eks,rds,iam,sts,elbv2}` | Each service's AWS action adapters and dispatch, not yet registered through ADR-0002. | Deferred: the ADR-0002 registration contract has only reached ECR and ACM so far; each service needs its own registration slice. |
|
||||
@@ -683,9 +682,9 @@ ADR-0001 completion is structural, so none of these is cleared by remediating se
|
||||
| Explicitly deferred by ADR-0001 | `daemon` (Q-28), `lbagent` (Q-30) | Closed for this refactor; the ADR names the prerequisite. |
|
||||
| No missing boundary (candidate moves) | `handlers/ecs/bus` → `contracts/ecs/v1` (`a130f6b8e`); `admin/images_promote.go` → `domains/ec2/image` (`263c58167`); `admin` bootstrap account identifiers → `foundation/aws/identifiers` (`3fdaa54d9`); `admin` credential-format generators → `foundation/aws/credentials` `0368e861a`, retiring `admin` | Done. |
|
||||
| One narrow missing boundary | Done: `handlers/sts` EKS JWKS lookup → `handlers/eks` `ClusterJWKSResolver` (`254d898a3`); `gateway/tagging` → `domains/tagging` with AWSGW-composed listers (`0d830e901`, `f3545e3ea`, `aedd0b2c4`). Open: `gateway/spx` (owner of operator-plane dispatch registration) | Each needs one decision or one capability, then a mechanical move. |
|
||||
| Broader boundary or follows another package | `handlers/{ecs,eks,rds}` (ADR-0004 blocked-dependency lists); `handlers/elbv2` (data-plane boundary evidence); `accountteardown` (domain cleanup capabilities); `gateway` root (ADR-0002 auth and request-context phase); `gateway/{iam,sts,ecs,eks,rds,elbv2}` (each follows its `handlers/<svc>`, because a new-path `awsapi` cannot import a legacy handler); `gateway/bedrock` (the recorded Ochre split plan) | Recorded debt with removal conditions above. |
|
||||
| Broader boundary or follows another package | `handlers/iam` and `handlers/iam/mock` (per-consumer IAM capabilities); `handlers/{ecs,eks,rds}` (ADR-0004 blocked-dependency lists); `handlers/elbv2` (data-plane boundary evidence); `accountteardown` (domain cleanup capabilities); `gateway` root (ADR-0002 auth and request-context phase); `gateway/{iam,sts,ecs,eks,rds,elbv2}` (each follows its `handlers/<svc>`, because a new-path `awsapi` cannot import a legacy handler); `gateway/bedrock` (the recorded Ochre split plan) | Recorded debt with removal conditions above. |
|
||||
|
||||
Per-consumer IAM capabilities are the widest unblocker: they gate STS's IAM reads, Ochre's IAM reads, the ELBv2 role path and the ECS, EKS and RDS ensurers.
|
||||
Per-consumer IAM capabilities are the widest unblocker: they gate `handlers/iam` itself, STS's IAM reads, Ochre's IAM reads, the ELBv2 role path and the ECS, EKS and RDS ensurers.
|
||||
|
||||
`runtime/roles/awsgw` carries two recorded residuals from its own move (`931d86d5c`
|
||||
above) that are not yet re-homed: `runQuotaReconcile`/`quotaKeyFor` belong to
|
||||
|
||||
@@ -76,7 +76,7 @@ services:
|
||||
- spinifex/domains/ec2/vpc
|
||||
- spinifex/handlers/elbv2
|
||||
- spinifex/domains/acm
|
||||
- spinifex/domains/iam
|
||||
- spinifex/handlers/iam
|
||||
- spinifex/handlers/ecs
|
||||
- spinifex/handlers/rds
|
||||
- spinifex/domains/rds/subnetgroup
|
||||
|
||||
@@ -6,8 +6,8 @@ import (
|
||||
"log/slog"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/domains/admission/quota"
|
||||
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
@@ -22,7 +22,7 @@ func NewClusterEngine(
|
||||
nc *nats.Conn,
|
||||
expectedNodes int,
|
||||
nodeIDs []string,
|
||||
svc iamdomain.IAMService,
|
||||
svc handlers_iam.IAMService,
|
||||
buckets BucketStore,
|
||||
) (*Engine, error) {
|
||||
// Refused rather than skipped. A teardown missing its bucket reaper still
|
||||
@@ -37,7 +37,7 @@ func NewClusterEngine(
|
||||
return nil, fmt.Errorf("get JetStream context: %w", err)
|
||||
}
|
||||
|
||||
names, err := iamdomain.NewAccountNameIndex(ctx, js)
|
||||
names, err := handlers_iam.NewAccountNameIndex(ctx, js)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open account name index: %w", err)
|
||||
}
|
||||
@@ -70,11 +70,6 @@ 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...)
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/mulgadc/spinifex/internal/testkit"
|
||||
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
|
||||
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -18,9 +18,9 @@ import (
|
||||
func TestNewClusterEngineCoversEveryStage(t *testing.T) {
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
|
||||
masterKey, err := iamdomain.GenerateMasterKey()
|
||||
masterKey, err := handlers_iam.GenerateMasterKey()
|
||||
require.NoError(t, err)
|
||||
svc, err := iamdomain.NewIAMServiceImpl(t.Context(), nc, masterKey)
|
||||
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())
|
||||
@@ -41,35 +41,14 @@ 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 := iamdomain.GenerateMasterKey()
|
||||
require.NoError(t, err)
|
||||
svc, err := iamdomain.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) {
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
|
||||
masterKey, err := iamdomain.GenerateMasterKey()
|
||||
masterKey, err := handlers_iam.GenerateMasterKey()
|
||||
require.NoError(t, err)
|
||||
svc, err := iamdomain.NewIAMServiceImpl(t.Context(), nc, masterKey)
|
||||
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())
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
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),
|
||||
}}
|
||||
}
|
||||
@@ -1,190 +0,0 @@
|
||||
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,
|
||||
)
|
||||
}
|
||||
@@ -1,311 +0,0 @@
|
||||
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))
|
||||
}
|
||||
@@ -1,273 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -17,9 +17,9 @@ import (
|
||||
ec2v1 "github.com/mulgadc/spinifex/contracts/ec2/v1"
|
||||
"github.com/mulgadc/spinifex/internal/testkit"
|
||||
"github.com/mulgadc/spinifex/spinifex/domains/ec2/accountinventory"
|
||||
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
|
||||
awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
natsmsg "github.com/mulgadc/spinifex/spinifex/foundation/messaging/nats"
|
||||
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -165,9 +165,9 @@ func TestEC2PlatformInventoryClientDistrustsAMalformedReply(t *testing.T) {
|
||||
// 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 := iamdomain.GenerateMasterKey()
|
||||
masterKey, err := handlers_iam.GenerateMasterKey()
|
||||
require.NoError(t, err)
|
||||
svc, err := iamdomain.NewIAMServiceImpl(t.Context(), nc, masterKey)
|
||||
svc, err := handlers_iam.NewIAMServiceImpl(t.Context(), nc, masterKey)
|
||||
require.NoError(t, err)
|
||||
cluster, err := NewClusterEngine(t.Context(), nc, 1, []string{"node-1"}, svc, newFakeBuckets())
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// AccountStatusTerminating mirrors iamdomain.AccountStatusTerminating.
|
||||
// AccountStatusTerminating mirrors handlers_iam.AccountStatusTerminating.
|
||||
// Redeclared so this package does not depend on the IAM service to be tested.
|
||||
const AccountStatusTerminating = "TERMINATING"
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
|
||||
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -129,11 +129,11 @@ func TestProtectedAccountsMatchTheRestOfTheSystem(t *testing.T) {
|
||||
if _, protected := protectedAccountIDs[id]; !protected {
|
||||
t.Fatalf("account %s is not protected by teardown", id)
|
||||
}
|
||||
if _, protected := iamdomain.UndeletableAccountIDs[id]; !protected {
|
||||
if _, protected := handlers_iam.UndeletableAccountIDs[id]; !protected {
|
||||
t.Fatalf("account %s is not protected by the IAM service", id)
|
||||
}
|
||||
}
|
||||
if len(protectedAccountIDs) != len(iamdomain.UndeletableAccountIDs) {
|
||||
if len(protectedAccountIDs) != len(handlers_iam.UndeletableAccountIDs) {
|
||||
t.Fatal("teardown and the IAM service disagree on which accounts are protected")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,13 +5,13 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
|
||||
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
// AccountReapers returns the final-stage reapers: the per-account counters and
|
||||
// reservations that outlive every resource but must not outlive the account.
|
||||
func AccountReapers(svc iamdomain.IAMService, names *iamdomain.AccountNameIndex, usage jetstream.KeyValue) []Reaper {
|
||||
func AccountReapers(svc handlers_iam.IAMService, names *handlers_iam.AccountNameIndex, usage jetstream.KeyValue) []Reaper {
|
||||
return []Reaper{
|
||||
"aUsageReaper{usage: usage},
|
||||
&nameReservationReaper{svc: svc, names: names},
|
||||
@@ -53,8 +53,8 @@ func (r *quotaUsageReaper) Delete(ctx context.Context, _ string, resource Resour
|
||||
// up again. A customer who deletes an account and returns must not be told the
|
||||
// address is taken by a tenant that no longer exists.
|
||||
type nameReservationReaper struct {
|
||||
svc iamdomain.IAMService
|
||||
names *iamdomain.AccountNameIndex
|
||||
svc handlers_iam.IAMService
|
||||
names *handlers_iam.AccountNameIndex
|
||||
}
|
||||
|
||||
func (r *nameReservationReaper) Kind() string { return "name-reservation" }
|
||||
@@ -92,12 +92,12 @@ func (r *nameReservationReaper) Delete(ctx context.Context, accountID string, re
|
||||
// IAMAccounts adapts the IAM service to the engine's AccountStore. The engine
|
||||
// declares its own Account type so its tests need no cluster; this is the one
|
||||
// place the two meet.
|
||||
type IAMAccounts struct{ svc iamdomain.IAMService }
|
||||
type IAMAccounts struct{ svc handlers_iam.IAMService }
|
||||
|
||||
var _ AccountStore = (*IAMAccounts)(nil)
|
||||
|
||||
// NewIAMAccounts wraps svc as the engine's AccountStore.
|
||||
func NewIAMAccounts(svc iamdomain.IAMService) *IAMAccounts {
|
||||
func NewIAMAccounts(svc handlers_iam.IAMService) *IAMAccounts {
|
||||
return &IAMAccounts{svc: svc}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,8 +10,8 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/mulgadc/spinifex/internal/testkit"
|
||||
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
|
||||
"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"
|
||||
@@ -22,20 +22,20 @@ import (
|
||||
type accountIAM struct {
|
||||
fakeIAM
|
||||
|
||||
account *iamdomain.Account
|
||||
account *handlers_iam.Account
|
||||
getErr error
|
||||
deleted bool
|
||||
setStatus string
|
||||
}
|
||||
|
||||
func (f *accountIAM) GetAccount(string) (*iamdomain.Account, error) {
|
||||
func (f *accountIAM) GetAccount(string) (*handlers_iam.Account, error) {
|
||||
if f.getErr != nil {
|
||||
return nil, f.getErr
|
||||
}
|
||||
return f.account, nil
|
||||
}
|
||||
|
||||
func (f *accountIAM) SetAccountStatus(_, status string) (*iamdomain.Account, error) {
|
||||
func (f *accountIAM) SetAccountStatus(_, status string) (*handlers_iam.Account, error) {
|
||||
f.setStatus = status
|
||||
updated := *f.account
|
||||
updated.Status = status
|
||||
@@ -102,12 +102,12 @@ func TestNameReservationReaperReleasesTheName(t *testing.T) {
|
||||
_, js, _ := testKV(t, "spinifex-name-reaper-warmup")
|
||||
ctx := testCtx(t)
|
||||
|
||||
names, err := iamdomain.NewAccountNameIndex(ctx, js)
|
||||
names, err := handlers_iam.NewAccountNameIndex(ctx, js)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, names.Reserve(ctx, "tenant@example.com", "token-1"))
|
||||
require.NoError(t, names.Commit(ctx, "tenant@example.com", "000000000042", "token-1"))
|
||||
|
||||
svc := &accountIAM{account: &iamdomain.Account{
|
||||
svc := &accountIAM{account: &handlers_iam.Account{
|
||||
AccountID: "000000000042", AccountName: "tenant@example.com",
|
||||
}}
|
||||
reaper := &nameReservationReaper{svc: svc, names: names}
|
||||
@@ -130,12 +130,12 @@ func TestNameReservationReaperIgnoresAnotherAccountsName(t *testing.T) {
|
||||
_, js, _ := testKV(t, "spinifex-name-reaper-other")
|
||||
ctx := testCtx(t)
|
||||
|
||||
names, err := iamdomain.NewAccountNameIndex(ctx, js)
|
||||
names, err := handlers_iam.NewAccountNameIndex(ctx, js)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, names.Reserve(ctx, "tenant@example.com", "token-1"))
|
||||
require.NoError(t, names.Commit(ctx, "tenant@example.com", "000000000099", "token-1"))
|
||||
|
||||
svc := &accountIAM{account: &iamdomain.Account{
|
||||
svc := &accountIAM{account: &handlers_iam.Account{
|
||||
AccountID: "000000000042", AccountName: "tenant@example.com",
|
||||
}}
|
||||
|
||||
@@ -154,7 +154,7 @@ func TestNameReservationReaperReportsAMissingAccount(t *testing.T) {
|
||||
_, js, _ := testKV(t, "spinifex-name-reaper-missing")
|
||||
ctx := testCtx(t)
|
||||
|
||||
names, err := iamdomain.NewAccountNameIndex(ctx, js)
|
||||
names, err := handlers_iam.NewAccountNameIndex(ctx, js)
|
||||
require.NoError(t, err)
|
||||
|
||||
svc := &accountIAM{getErr: errors.New("account not found")}
|
||||
@@ -177,10 +177,10 @@ func TestAccountReapersRunLast(t *testing.T) {
|
||||
// The adapter is the one place the engine's Account and the IAM record meet,
|
||||
// so a field dropped in translation would silently break the name confirmation.
|
||||
func TestIAMAccountsAdapterCarriesEveryField(t *testing.T) {
|
||||
svc := &accountIAM{account: &iamdomain.Account{
|
||||
svc := &accountIAM{account: &handlers_iam.Account{
|
||||
AccountID: "000000000042",
|
||||
AccountName: "tenant@example.com",
|
||||
Status: iamdomain.AccountStatusActive,
|
||||
Status: handlers_iam.AccountStatusActive,
|
||||
}}
|
||||
accounts := NewIAMAccounts(svc)
|
||||
|
||||
@@ -188,12 +188,12 @@ func TestIAMAccountsAdapterCarriesEveryField(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "000000000042", account.AccountID)
|
||||
assert.Equal(t, "tenant@example.com", account.AccountName)
|
||||
assert.Equal(t, iamdomain.AccountStatusActive, account.Status)
|
||||
assert.Equal(t, handlers_iam.AccountStatusActive, account.Status)
|
||||
|
||||
updated, err := accounts.SetAccountStatus("000000000042", AccountStatusTerminating)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, AccountStatusTerminating, updated.Status)
|
||||
assert.Equal(t, iamdomain.AccountStatusTerminating, svc.setStatus)
|
||||
assert.Equal(t, handlers_iam.AccountStatusTerminating, svc.setStatus)
|
||||
|
||||
require.NoError(t, accounts.DeleteAccount("000000000042"))
|
||||
assert.True(t, svc.deleted)
|
||||
@@ -203,5 +203,5 @@ func TestIAMAccountsAdapterCarriesEveryField(t *testing.T) {
|
||||
// two must not drift: a mismatch would mark accounts with a status the auth
|
||||
// gate does not recognise, and they would keep authenticating.
|
||||
func TestTerminatingStatusMatchesTheIAMRecord(t *testing.T) {
|
||||
assert.Equal(t, iamdomain.AccountStatusTerminating, AccountStatusTerminating)
|
||||
assert.Equal(t, handlers_iam.AccountStatusTerminating, AccountStatusTerminating)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package accountteardown
|
||||
|
||||
//test:in-package — drives the real final-stage reapers against the real
|
||||
// EC2 account-settings service to pin that teardown leaves the bucket alone.
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/spinifex/internal/testkit"
|
||||
ec2account "github.com/mulgadc/spinifex/spinifex/domains/ec2/account"
|
||||
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// No reaper in this package references the EC2 account-settings bucket or
|
||||
// calls a settings delete. Running the real engine and the real StageAccount
|
||||
// reapers against an account with settings enabled shows the consequence:
|
||||
// the settings record outlives the account record it belongs to.
|
||||
func TestCurrentBehaviour_AccountTermination_EC2AccountSettingsSurviveTeardown(t *testing.T) {
|
||||
_, nc, js := testutil.StartTestJetStream(t)
|
||||
ctx := testCtx(t)
|
||||
|
||||
settings, err := ec2account.NewAccountSettingsServiceImplWithNATS(ctx, nil, nc)
|
||||
require.NoError(t, err)
|
||||
_, err = settings.EnableEbsEncryptionByDefault(ctx, &ec2.EnableEbsEncryptionByDefaultInput{}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
_, err = settings.EnableSerialConsoleAccess(ctx, &ec2.EnableSerialConsoleAccessInput{}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
accounts := newFakeAccounts()
|
||||
iam := &accountIAM{account: &handlers_iam.Account{AccountID: testAccountID, AccountName: testAccountName}}
|
||||
engine := quietEngine(accounts, AccountReapers(iam, nil, nil)...)
|
||||
|
||||
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())
|
||||
|
||||
bucket, err := js.KeyValue(ctx, ec2account.KVBucketAccountSettings)
|
||||
require.NoError(t, err)
|
||||
_, err = bucket.Get(ctx, testAccountID)
|
||||
require.NoError(t, err, "the settings key must still be present after the account is torn down")
|
||||
|
||||
out, err := settings.GetEbsEncryptionByDefault(ctx, &ec2.GetEbsEncryptionByDefaultInput{}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, *out.EbsEncryptionByDefault)
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/iam"
|
||||
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
|
||||
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
|
||||
)
|
||||
|
||||
// IAMReapers returns the identity-stage reapers in teardown order.
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
// Access keys go first and deliberately: it is the second quiesce, and unlike
|
||||
// the account-status gate it holds even against a caller that reached the
|
||||
// cluster some other way.
|
||||
func IAMReapers(svc iamdomain.IAMService) []Reaper {
|
||||
func IAMReapers(svc handlers_iam.IAMService) []Reaper {
|
||||
return []Reaper{
|
||||
&accessKeyReaper{svc: svc},
|
||||
&instanceProfileReaper{svc: svc},
|
||||
@@ -34,7 +34,7 @@ func IAMReapers(svc iamdomain.IAMService) []Reaper {
|
||||
// They survive their cluster's deletion, so nothing else in teardown reaches
|
||||
// them and an account would be left holding a trust anchor for a cluster that
|
||||
// no longer exists.
|
||||
type oidcProviderReaper struct{ svc iamdomain.IAMService }
|
||||
type oidcProviderReaper struct{ svc handlers_iam.IAMService }
|
||||
|
||||
func (r *oidcProviderReaper) Kind() string { return "oidc-provider" }
|
||||
func (r *oidcProviderReaper) Stage() Stage { return StagePlatform }
|
||||
@@ -62,7 +62,7 @@ func (r *oidcProviderReaper) Delete(_ context.Context, accountID string, resourc
|
||||
return ignoreAlreadyGone(err)
|
||||
}
|
||||
|
||||
type accessKeyReaper struct{ svc iamdomain.IAMService }
|
||||
type accessKeyReaper struct{ svc handlers_iam.IAMService }
|
||||
|
||||
func (r *accessKeyReaper) Kind() string { return "access-key" }
|
||||
func (r *accessKeyReaper) Stage() Stage { return StageIdentity }
|
||||
@@ -102,7 +102,7 @@ func (r *accessKeyReaper) Delete(_ context.Context, accountID string, resource R
|
||||
return ignoreAlreadyGone(err)
|
||||
}
|
||||
|
||||
type iamUserReaper struct{ svc iamdomain.IAMService }
|
||||
type iamUserReaper struct{ svc handlers_iam.IAMService }
|
||||
|
||||
func (r *iamUserReaper) Kind() string { return "iam-user" }
|
||||
func (r *iamUserReaper) Stage() Stage { return StageIdentity }
|
||||
@@ -166,7 +166,7 @@ func (r *iamUserReaper) Delete(_ context.Context, accountID string, resource Res
|
||||
return ignoreAlreadyGone(err)
|
||||
}
|
||||
|
||||
type iamRoleReaper struct{ svc iamdomain.IAMService }
|
||||
type iamRoleReaper struct{ svc handlers_iam.IAMService }
|
||||
|
||||
func (r *iamRoleReaper) Kind() string { return "iam-role" }
|
||||
func (r *iamRoleReaper) Stage() Stage { return StageIdentity }
|
||||
@@ -228,7 +228,7 @@ func (r *iamRoleReaper) Delete(_ context.Context, accountID string, resource Res
|
||||
return ignoreAlreadyGone(err)
|
||||
}
|
||||
|
||||
type iamGroupReaper struct{ svc iamdomain.IAMService }
|
||||
type iamGroupReaper struct{ svc handlers_iam.IAMService }
|
||||
|
||||
func (r *iamGroupReaper) Kind() string { return "iam-group" }
|
||||
func (r *iamGroupReaper) Stage() Stage { return StageIdentity }
|
||||
@@ -254,7 +254,7 @@ func (r *iamGroupReaper) Delete(_ context.Context, accountID string, resource Re
|
||||
return ignoreAlreadyGone(err)
|
||||
}
|
||||
|
||||
type iamPolicyReaper struct{ svc iamdomain.IAMService }
|
||||
type iamPolicyReaper struct{ svc handlers_iam.IAMService }
|
||||
|
||||
func (r *iamPolicyReaper) Kind() string { return "iam-policy" }
|
||||
func (r *iamPolicyReaper) Stage() Stage { return StageIdentity }
|
||||
@@ -302,7 +302,7 @@ func (r *iamPolicyReaper) Delete(_ context.Context, accountID string, resource R
|
||||
return ignoreAlreadyGone(err)
|
||||
}
|
||||
|
||||
type instanceProfileReaper struct{ svc iamdomain.IAMService }
|
||||
type instanceProfileReaper struct{ svc handlers_iam.IAMService }
|
||||
|
||||
func (r *instanceProfileReaper) Kind() string { return "instance-profile" }
|
||||
func (r *instanceProfileReaper) Stage() Stage { return StageIdentity }
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/iam"
|
||||
"github.com/mulgadc/spinifex/internal/testkit"
|
||||
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
|
||||
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -21,7 +21,7 @@ import (
|
||||
// stay unimplemented — calling one is a test failure by panic, which is the
|
||||
// honest outcome for a reaper reaching somewhere it was not meant to.
|
||||
type fakeIAM struct {
|
||||
iamdomain.IAMService
|
||||
handlers_iam.IAMService
|
||||
|
||||
calls []string
|
||||
|
||||
@@ -396,9 +396,9 @@ func TestPolicyReaperAsksOnlyForLocalPolicies(t *testing.T) {
|
||||
// DeletePolicy refuses it until they are gone.
|
||||
func TestPolicyReaperDeletesAPolicyWithSeveralVersions(t *testing.T) {
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
masterKey, err := iamdomain.GenerateMasterKey()
|
||||
masterKey, err := handlers_iam.GenerateMasterKey()
|
||||
require.NoError(t, err)
|
||||
svc, err := iamdomain.NewIAMServiceImpl(t.Context(), nc, masterKey)
|
||||
svc, err := handlers_iam.NewIAMServiceImpl(t.Context(), nc, masterKey)
|
||||
require.NoError(t, err)
|
||||
|
||||
const accountID = "000000000042"
|
||||
|
||||
@@ -7,8 +7,8 @@ import (
|
||||
|
||||
"github.com/mulgadc/spinifex/internal/testkit"
|
||||
"github.com/mulgadc/spinifex/spinifex/domains/admission/quota"
|
||||
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
|
||||
"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"
|
||||
@@ -36,7 +36,7 @@ func TestCurrentBehaviour_AccountTermination_QuotaOverridesSurviveTeardown(t *te
|
||||
require.NoError(t, svc.PutAccountQuota(ctx, testAccountID, quota.Overrides{VCPUs: &unlimited}, "operator"))
|
||||
|
||||
accounts := newFakeAccounts()
|
||||
iam := &accountIAM{account: &iamdomain.Account{AccountID: testAccountID, AccountName: testAccountName}}
|
||||
iam := &accountIAM{account: &handlers_iam.Account{AccountID: testAccountID, AccountName: testAccountName}}
|
||||
engine := quietEngine(accounts, AccountReapers(iam, nil, usage)...)
|
||||
|
||||
result, err := engine.Teardown(ctx, request())
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -63,7 +63,6 @@ import (
|
||||
ec2volume "github.com/mulgadc/spinifex/spinifex/domains/ec2/volume"
|
||||
ec2vpc "github.com/mulgadc/spinifex/spinifex/domains/ec2/vpc"
|
||||
"github.com/mulgadc/spinifex/spinifex/domains/ecr"
|
||||
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
|
||||
"github.com/mulgadc/spinifex/spinifex/domains/network/external"
|
||||
"github.com/mulgadc/spinifex/spinifex/domains/network/external/dhcp"
|
||||
"github.com/mulgadc/spinifex/spinifex/domains/network/external/exonet"
|
||||
@@ -79,6 +78,7 @@ import (
|
||||
handlers_ecs "github.com/mulgadc/spinifex/spinifex/handlers/ecs"
|
||||
handlers_eks "github.com/mulgadc/spinifex/spinifex/handlers/eks"
|
||||
handlers_elbv2 "github.com/mulgadc/spinifex/spinifex/handlers/elbv2"
|
||||
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
|
||||
handlers_rds "github.com/mulgadc/spinifex/spinifex/handlers/rds"
|
||||
"github.com/mulgadc/spinifex/spinifex/providers/ebs"
|
||||
"github.com/mulgadc/spinifex/spinifex/providers/objectstore"
|
||||
@@ -297,7 +297,7 @@ type Daemon struct {
|
||||
|
||||
// iamEnsurerMu guards the lazily-built system-role IAM service (systemRoleEnsurer).
|
||||
iamEnsurerMu sync.Mutex
|
||||
iamEnsurerCached iamdomain.SystemInstanceRoleEnsurer
|
||||
iamEnsurerCached handlers_iam.SystemInstanceRoleEnsurer
|
||||
|
||||
mu sync.Mutex
|
||||
}
|
||||
@@ -1289,15 +1289,6 @@ 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)
|
||||
}
|
||||
|
||||
@@ -2006,8 +1997,8 @@ func (d *Daemon) startCluster() error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("jetstream handle for account status reader: %w", err)
|
||||
}
|
||||
var _ ec2account.AccountTerminationChecker = (*iamdomain.AccountStatusReader)(nil)
|
||||
d.accountService.SetAccountTerminationChecker(iamdomain.NewAccountStatusReader(accountStatusJS))
|
||||
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
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -16,11 +16,11 @@ import (
|
||||
ec2eip "github.com/mulgadc/spinifex/spinifex/domains/ec2/eip"
|
||||
"github.com/mulgadc/spinifex/spinifex/domains/ec2/systeminstance"
|
||||
ec2vpc "github.com/mulgadc/spinifex/spinifex/domains/ec2/vpc"
|
||||
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
|
||||
"github.com/mulgadc/spinifex/spinifex/domains/network/external"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/tags"
|
||||
"github.com/mulgadc/spinifex/spinifex/handlers/elbv2"
|
||||
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
|
||||
"github.com/mulgadc/spinifex/spinifex/runtime/compute/vm"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -623,7 +623,7 @@ func newRefreshTestDaemon(t *testing.T) (*Daemon, *nats.Conn, string) {
|
||||
tmpDir := t.TempDir()
|
||||
t.Setenv("XDG_RUNTIME_DIR", tmpDir)
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
masterKey, err := iamdomain.GenerateMasterKey()
|
||||
masterKey, err := handlers_iam.GenerateMasterKey()
|
||||
require.NoError(t, err)
|
||||
svc, err := handlers_elbv2.NewELBv2ServiceImplWithNATS(nil, nc, masterKey)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -7,8 +7,8 @@ import (
|
||||
"github.com/mulgadc/spinifex/internal/testkit"
|
||||
"github.com/mulgadc/spinifex/spinifex/bootstrap/config"
|
||||
ec2eip "github.com/mulgadc/spinifex/spinifex/domains/ec2/eip"
|
||||
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
|
||||
handlers_elbv2 "github.com/mulgadc/spinifex/spinifex/handlers/elbv2"
|
||||
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -20,7 +20,7 @@ func newWireLBTestDaemon(t *testing.T, cfg *config.Config) *Daemon {
|
||||
t.Helper()
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
|
||||
masterKey, err := iamdomain.GenerateMasterKey()
|
||||
masterKey, err := handlers_iam.GenerateMasterKey()
|
||||
require.NoError(t, err)
|
||||
svc, err := handlers_elbv2.NewELBv2ServiceImplWithNATS(nil, nc, masterKey)
|
||||
require.NoError(t, err)
|
||||
@@ -203,7 +203,7 @@ func TestWireLBAgentConfig_GatewayURL_SingleNodeBindMatchesAdvertise(t *testing.
|
||||
// newSubscribeTestDaemon creates a minimal Daemon wired enough for subscribeAll.
|
||||
func newSubscribeTestDaemon(t *testing.T, nc *nats.Conn, gatewayURL, accessKey string) *Daemon {
|
||||
t.Helper()
|
||||
masterKey, err := iamdomain.GenerateMasterKey()
|
||||
masterKey, err := handlers_iam.GenerateMasterKey()
|
||||
require.NoError(t, err)
|
||||
svc, err := handlers_elbv2.NewELBv2ServiceImplWithNATS(nil, nc, masterKey)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -9,10 +9,10 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/mulgadc/spinifex/internal/testkit"
|
||||
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
handlers_eks "github.com/mulgadc/spinifex/spinifex/handlers/eks"
|
||||
handlers_elbv2 "github.com/mulgadc/spinifex/spinifex/handlers/elbv2"
|
||||
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
|
||||
handlers_rds "github.com/mulgadc/spinifex/spinifex/handlers/rds"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -81,7 +81,7 @@ func TestElbv2WatchBuckets_NoStoreIsNilSafe(t *testing.T) {
|
||||
|
||||
func TestElbv2WatchBuckets_WithStoreReturnsWatchBucket(t *testing.T) {
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
masterKey, err := iamdomain.GenerateMasterKey()
|
||||
masterKey, err := handlers_iam.GenerateMasterKey()
|
||||
require.NoError(t, err)
|
||||
svc, err := handlers_elbv2.NewELBv2ServiceImplWithNATS(nil, nc, masterKey)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -6,8 +6,8 @@ import (
|
||||
"path/filepath"
|
||||
|
||||
"github.com/mulgadc/bluebottle/pkg/masterkey"
|
||||
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
|
||||
handlers_ecs "github.com/mulgadc/spinifex/spinifex/handlers/ecs"
|
||||
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
|
||||
)
|
||||
|
||||
// buildECSServiceDeps assembles the ECS service Deps for ProvisionCapacity: the
|
||||
@@ -47,8 +47,8 @@ func (d *Daemon) buildECSServiceDeps() handlers_ecs.Deps {
|
||||
// to have responders. A daemon that started first got one attempt, failed
|
||||
// it, and had capacity provisioning off for the life of the process with no
|
||||
// symptom but a 500 from ProvisionCapacity.
|
||||
iamSvc, iamErr := initServiceWithRetry("ECS IAM service", func() (*iamdomain.IAMServiceImpl, error) {
|
||||
return iamdomain.NewIAMServiceImpl(d.ctx, d.natsConn, masterKey)
|
||||
iamSvc, iamErr := initServiceWithRetry("ECS IAM service", func() (*handlers_iam.IAMServiceImpl, error) {
|
||||
return handlers_iam.NewIAMServiceImpl(d.ctx, d.natsConn, masterKey)
|
||||
})
|
||||
if iamErr != nil {
|
||||
// Off rather than fatal: ECS CRUD, EKS and EC2 do not need this, and a
|
||||
|
||||
@@ -10,8 +10,8 @@ import (
|
||||
"github.com/mulgadc/bluebottle/pkg/masterkey"
|
||||
"github.com/mulgadc/spinifex/spinifex/bootstrap/config"
|
||||
ec2placementgroup "github.com/mulgadc/spinifex/spinifex/domains/ec2/placementgroup"
|
||||
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
|
||||
handlers_eks "github.com/mulgadc/spinifex/spinifex/handlers/eks"
|
||||
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
|
||||
"github.com/mulgadc/spinifex/spinifex/providers/objectstore"
|
||||
)
|
||||
|
||||
@@ -24,7 +24,7 @@ import (
|
||||
// first-use (an LB/cluster create) the backend is up. Returns nil (caller falls
|
||||
// back to static creds) until a build succeeds; retried on each call until then,
|
||||
// cached once it works.
|
||||
func (d *Daemon) systemRoleEnsurer() iamdomain.SystemInstanceRoleEnsurer {
|
||||
func (d *Daemon) systemRoleEnsurer() handlers_iam.SystemInstanceRoleEnsurer {
|
||||
d.iamEnsurerMu.Lock()
|
||||
defer d.iamEnsurerMu.Unlock()
|
||||
if d.iamEnsurerCached != nil {
|
||||
@@ -36,7 +36,7 @@ func (d *Daemon) systemRoleEnsurer() iamdomain.SystemInstanceRoleEnsurer {
|
||||
"err", err)
|
||||
return nil
|
||||
}
|
||||
iamSvc, iamErr := iamdomain.NewIAMServiceImpl(d.ctx, d.natsConn, masterKey)
|
||||
iamSvc, iamErr := handlers_iam.NewIAMServiceImpl(d.ctx, d.natsConn, masterKey)
|
||||
if iamErr != nil {
|
||||
slog.Warn("System role ensurer: IAM service init failed (retried on next launch); system VMs fall back to baked static creds",
|
||||
"err", iamErr)
|
||||
|
||||
@@ -21,9 +21,9 @@ import (
|
||||
"github.com/aws/aws-sdk-go/private/protocol/json/jsonutil"
|
||||
"github.com/aws/aws-sdk-go/service/acm"
|
||||
"github.com/mulgadc/spinifex/internal/testkit"
|
||||
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/arn"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -33,7 +33,7 @@ const testAccountID = "000000000001"
|
||||
func setupACMService(t *testing.T) *ACMServiceImpl {
|
||||
t.Helper()
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
masterKey, err := iamdomain.GenerateMasterKey()
|
||||
masterKey, err := handlers_iam.GenerateMasterKey()
|
||||
require.NoError(t, err)
|
||||
svc, err := NewACMServiceImplWithNATS(t.Context(), nil, nc, masterKey)
|
||||
require.NoError(t, err)
|
||||
@@ -307,7 +307,7 @@ func TestImportCertificate_PrivateKeyEncryptedAtRest(t *testing.T) {
|
||||
// TestDescribeCertificate_DoesNotLeakPrivateKey asserts the public
|
||||
// CertificateDetail response can never carry key material, however the
|
||||
// record is stored. Precedent: TestSensitiveDataNotLogged_MasterKey in
|
||||
// domains/iam/service_impl_test.go.
|
||||
// handlers/iam/service_impl_test.go.
|
||||
func TestDescribeCertificate_DoesNotLeakPrivateKey(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc := setupACMService(t)
|
||||
@@ -331,7 +331,7 @@ func TestDescribeCertificate_DoesNotLeakPrivateKey(t *testing.T) {
|
||||
// TestSensitiveDataNotLogged_ACMPrivateKey asserts ImportCertificate and
|
||||
// DescribeCertificate never place the raw private key PEM in log output.
|
||||
// Precedent: TestSensitiveDataNotLogged_MasterKey in
|
||||
// domains/iam/service_impl_test.go.
|
||||
// handlers/iam/service_impl_test.go.
|
||||
// fakeCertAuthority is a minimal CertAuthority for testing RequestCertificate's
|
||||
// PRIVATE_CA path without the real tenant CA implementation (domains/acm/privateca.go
|
||||
// lives on a separate branch).
|
||||
|
||||
@@ -11,10 +11,10 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/arn"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
@@ -161,7 +161,7 @@ type CertRecord struct {
|
||||
type Store struct {
|
||||
store *kvstore.Store[CertRecord]
|
||||
// masterKey encrypts/decrypts CertRecord.PrivateKey at rest (AES-256-GCM via
|
||||
// iamdomain.EncryptSecret/DecryptSecret). Every Store over the ACM bucket
|
||||
// handlers_iam.EncryptSecret/DecryptSecret). Every Store over the ACM bucket
|
||||
// — the ACM service and ELBv2's independent read-only Store alike — must be
|
||||
// constructed with the same deployment key: a keyed writer and an unkeyed (or
|
||||
// differently keyed) reader of the same bucket disagree silently, with the
|
||||
@@ -221,7 +221,7 @@ func certKey(certArn string) string {
|
||||
// via the legacy-passthrough path in decryptPrivateKey is therefore
|
||||
// re-encrypted the next time it is put.
|
||||
func (s *Store) PutCert(ctx context.Context, rec *CertRecord) error {
|
||||
ciphertext, err := iamdomain.EncryptSecret(rec.PrivateKey, s.masterKey)
|
||||
ciphertext, err := handlers_iam.EncryptSecret(rec.PrivateKey, s.masterKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encrypt private key: %w", err)
|
||||
}
|
||||
@@ -324,7 +324,7 @@ func isPlaintextPrivateKeyPEM(raw string) bool {
|
||||
// ciphertext, garbage) is a hard error rather than a silent plaintext
|
||||
// fallback.
|
||||
func (s *Store) decryptPrivateKey(rec *CertRecord) error {
|
||||
if plaintext, err := iamdomain.DecryptSecret(rec.PrivateKey, s.masterKey); err == nil {
|
||||
if plaintext, err := handlers_iam.DecryptSecret(rec.PrivateKey, s.masterKey); err == nil {
|
||||
rec.PrivateKey = plaintext
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/mulgadc/spinifex/internal/testkit"
|
||||
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
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"
|
||||
@@ -21,7 +21,7 @@ import (
|
||||
func setupACMStore(t *testing.T) *Store {
|
||||
t.Helper()
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
masterKey, err := iamdomain.GenerateMasterKey()
|
||||
masterKey, err := handlers_iam.GenerateMasterKey()
|
||||
require.NoError(t, err)
|
||||
store, err := NewStore(t.Context(), nc, masterKey)
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package quota
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
// OverrideCleanupDisposition is CleanupAccount's outcome for an account's
|
||||
// quota override key.
|
||||
type OverrideCleanupDisposition string
|
||||
|
||||
const (
|
||||
OverrideCleanupDeleted OverrideCleanupDisposition = "deleted"
|
||||
OverrideCleanupAbsent OverrideCleanupDisposition = "absent"
|
||||
OverrideCleanupFailed OverrideCleanupDisposition = "failed"
|
||||
)
|
||||
|
||||
// evidenceRevisionGuardedDelete and evidenceObservedAbsent are the fixed
|
||||
// evidence bases CleanupAccount reports; see kvstore/bucket.go:112-120 for why
|
||||
// a plain read cannot support a stronger absence claim.
|
||||
const (
|
||||
evidenceRevisionGuardedDelete = "revision-guarded-delete"
|
||||
evidenceObservedAbsent = "observed-absent-at-scan"
|
||||
)
|
||||
|
||||
// cleanupMaxAttempts bounds retries against a revision that keeps moving
|
||||
// under concurrent writes, so cleanup fails closed instead of spinning.
|
||||
const cleanupMaxAttempts = 5
|
||||
|
||||
// OverrideCleanup is CleanupAccount's result: what happened to an account's
|
||||
// quota override key, and the fixed evidence basis for that claim.
|
||||
type OverrideCleanup struct {
|
||||
Disposition OverrideCleanupDisposition
|
||||
Evidence string
|
||||
Revision uint64
|
||||
}
|
||||
|
||||
// CleanupAccount removes accountID's quota override key so a reused account ID
|
||||
// does not inherit a deleted account's raised or lowered limits. It is
|
||||
// unwired: nothing calls it yet.
|
||||
func (s *Service) CleanupAccount(ctx context.Context, accountID string) (*OverrideCleanup, error) {
|
||||
if accountID == "" {
|
||||
return nil, awserrors.Errorf(awserrors.ErrorMissingParameter, "accountID is required for quota override cleanup")
|
||||
}
|
||||
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 cleanup must not target the global account")
|
||||
}
|
||||
if s == nil || s.overrides == nil {
|
||||
return &OverrideCleanup{Disposition: OverrideCleanupFailed}, errors.New("quota: override bucket not configured")
|
||||
}
|
||||
|
||||
// accountID is never resolved through any fallback here: cleanup must act
|
||||
// on exactly accountID and never fall back to the global key.
|
||||
for range cleanupMaxAttempts {
|
||||
entry, err := s.overrides.Get(ctx, accountID)
|
||||
if err != nil {
|
||||
if errors.Is(err, jetstream.ErrKeyNotFound) {
|
||||
return &OverrideCleanup{Disposition: OverrideCleanupAbsent, Evidence: evidenceObservedAbsent}, nil
|
||||
}
|
||||
return &OverrideCleanup{Disposition: OverrideCleanupFailed}, fmt.Errorf("get quota override for cleanup: %w", err)
|
||||
}
|
||||
rev := entry.Revision()
|
||||
|
||||
err = s.overrides.Delete(ctx, accountID, jetstream.LastRevision(rev))
|
||||
switch {
|
||||
case err == nil:
|
||||
return &OverrideCleanup{
|
||||
Disposition: OverrideCleanupDeleted,
|
||||
Evidence: evidenceRevisionGuardedDelete,
|
||||
Revision: rev,
|
||||
}, nil
|
||||
case errors.Is(err, jetstream.ErrKeyNotFound):
|
||||
return &OverrideCleanup{Disposition: OverrideCleanupAbsent, Evidence: evidenceObservedAbsent}, nil
|
||||
case errors.Is(err, jetstream.ErrKeyRevisionMismatch):
|
||||
// A concurrent write landed between the read and the guarded
|
||||
// delete; retry against whatever revision now holds.
|
||||
continue
|
||||
default:
|
||||
return &OverrideCleanup{Disposition: OverrideCleanupFailed}, fmt.Errorf("delete quota override for cleanup: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return &OverrideCleanup{Disposition: OverrideCleanupFailed},
|
||||
fmt.Errorf("quota override cleanup for account: exhausted %d attempts against concurrent writes", cleanupMaxAttempts)
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
//test:in-package — CleanupAccount deletes through the unexported overrides field, and the retry/race behaviour is only observable from inside the package.
|
||||
package quota
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
// overrideCleanupFakeKV wraps a real KeyValue so a test can inject a race
|
||||
// between CleanupAccount's read and its guarded delete, or a store failure,
|
||||
// deterministically rather than through goroutine timing.
|
||||
type overrideCleanupFakeKV struct {
|
||||
jetstream.KeyValue
|
||||
|
||||
deleteCalls int
|
||||
beforeDelete func(attempt int) error
|
||||
getErr error
|
||||
}
|
||||
|
||||
func (f *overrideCleanupFakeKV) Get(ctx context.Context, key string) (jetstream.KeyValueEntry, error) {
|
||||
if f.getErr != nil {
|
||||
return nil, f.getErr
|
||||
}
|
||||
return f.KeyValue.Get(ctx, key)
|
||||
}
|
||||
|
||||
func (f *overrideCleanupFakeKV) Delete(ctx context.Context, key string, opts ...jetstream.KVDeleteOpt) error {
|
||||
f.deleteCalls++
|
||||
if f.beforeDelete != nil {
|
||||
if err := f.beforeDelete(f.deleteCalls); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return f.KeyValue.Delete(ctx, key, opts...)
|
||||
}
|
||||
|
||||
func TestCleanupAccount_DeletesTheOverrideKey(t *testing.T) {
|
||||
svc, _ := newOverrideService(t)
|
||||
ctx := t.Context()
|
||||
require.NoError(t, svc.PutAccountQuota(ctx, testAccount, Overrides{VCPUs: ptr(32)}, "operator"))
|
||||
|
||||
result, err := svc.CleanupAccount(ctx, testAccount)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, OverrideCleanupDeleted, result.Disposition)
|
||||
assert.Equal(t, evidenceRevisionGuardedDelete, result.Evidence)
|
||||
assert.NotZero(t, result.Revision)
|
||||
|
||||
_, err = svc.overrides.Get(ctx, testAccount)
|
||||
assert.ErrorIs(t, err, jetstream.ErrKeyNotFound)
|
||||
}
|
||||
|
||||
func TestCleanupAccount_RepeatCallIsIdempotent(t *testing.T) {
|
||||
svc, _ := newOverrideService(t)
|
||||
ctx := t.Context()
|
||||
require.NoError(t, svc.PutAccountQuota(ctx, testAccount, Overrides{VCPUs: ptr(32)}, "operator"))
|
||||
|
||||
first, err := svc.CleanupAccount(ctx, testAccount)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, OverrideCleanupDeleted, first.Disposition)
|
||||
|
||||
second, err := svc.CleanupAccount(ctx, testAccount)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, OverrideCleanupAbsent, second.Disposition)
|
||||
assert.Equal(t, evidenceObservedAbsent, second.Evidence)
|
||||
}
|
||||
|
||||
func TestCleanupAccount_AbsentKeyIsIdempotent(t *testing.T) {
|
||||
svc, _ := newOverrideService(t)
|
||||
result, err := svc.CleanupAccount(t.Context(), "333333333333")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, OverrideCleanupAbsent, result.Disposition)
|
||||
assert.Equal(t, evidenceObservedAbsent, result.Evidence)
|
||||
}
|
||||
|
||||
func TestCleanupAccount_OtherAccountsUntouched(t *testing.T) {
|
||||
svc, _ := newOverrideService(t)
|
||||
ctx := t.Context()
|
||||
accountA, accountB := testAccount, "222222222222"
|
||||
require.NoError(t, svc.PutAccountQuota(ctx, accountA, Overrides{VCPUs: ptr(32)}, "operator"))
|
||||
require.NoError(t, svc.PutAccountQuota(ctx, accountB, Overrides{VCPUs: ptr(8)}, "operator"))
|
||||
|
||||
result, err := svc.CleanupAccount(ctx, accountA)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, OverrideCleanupDeleted, result.Disposition)
|
||||
|
||||
over, _, err := svc.GetAccountQuota(ctx, accountB)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, over.VCPUs)
|
||||
assert.Equal(t, 8, *over.VCPUs)
|
||||
}
|
||||
|
||||
func TestCleanupAccount_RefusesGlobalAccountID(t *testing.T) {
|
||||
svc, _ := newOverrideService(t)
|
||||
result, err := svc.CleanupAccount(t.Context(), awsidentifiers.GlobalAccountID)
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
}
|
||||
|
||||
func TestCleanupAccount_RefusesEmptyAccountID(t *testing.T) {
|
||||
svc, _ := newOverrideService(t)
|
||||
result, err := svc.CleanupAccount(t.Context(), "")
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
}
|
||||
|
||||
func TestCleanupAccount_RefusesInvalidAccountID(t *testing.T) {
|
||||
svc, _ := newOverrideService(t)
|
||||
result, err := svc.CleanupAccount(t.Context(), "not-an-account")
|
||||
assert.Error(t, err)
|
||||
assert.Nil(t, result)
|
||||
}
|
||||
|
||||
func TestCleanupAccount_WithoutBucketFails(t *testing.T) {
|
||||
svc := New(baseLimits(), nil)
|
||||
result, err := svc.CleanupAccount(t.Context(), testAccount)
|
||||
assert.Error(t, err)
|
||||
require.NotNil(t, result)
|
||||
assert.Equal(t, OverrideCleanupFailed, result.Disposition)
|
||||
}
|
||||
|
||||
// A concurrent Put landing between CleanupAccount's read and its
|
||||
// revision-guarded delete must not be lost silently: the delete is rejected
|
||||
// and retried against the revision the write left behind.
|
||||
func TestCleanupAccount_ConcurrentPutDuringCleanupRetriesAndDeletes(t *testing.T) {
|
||||
svc, _ := newOverrideService(t)
|
||||
ctx := t.Context()
|
||||
require.NoError(t, svc.PutAccountQuota(ctx, testAccount, Overrides{VCPUs: ptr(32)}, "operator"))
|
||||
|
||||
fake := &overrideCleanupFakeKV{KeyValue: svc.overrides}
|
||||
fake.beforeDelete = func(attempt int) error {
|
||||
if attempt != 1 {
|
||||
return nil
|
||||
}
|
||||
// Simulate a racing override write landing after CleanupAccount's
|
||||
// read but before its guarded delete, through the real KV so the
|
||||
// delete's expected revision is now stale.
|
||||
return svc.PutAccountQuota(ctx, testAccount, Overrides{VCPUs: ptr(8)}, "operator")
|
||||
}
|
||||
svc.overrides = fake
|
||||
|
||||
result, err := svc.CleanupAccount(ctx, testAccount)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, OverrideCleanupDeleted, result.Disposition)
|
||||
assert.Equal(t, 2, fake.deleteCalls, "the first delete is rejected by the race and retried once")
|
||||
}
|
||||
|
||||
// A revision that keeps moving under sustained concurrent writes must not
|
||||
// spin forever: cleanup gives up and reports failed, bounded by attempts.
|
||||
func TestCleanupAccount_PersistentConcurrentWritesFailsBounded(t *testing.T) {
|
||||
svc, _ := newOverrideService(t)
|
||||
ctx := t.Context()
|
||||
require.NoError(t, svc.PutAccountQuota(ctx, testAccount, Overrides{VCPUs: ptr(32)}, "operator"))
|
||||
|
||||
fake := &overrideCleanupFakeKV{KeyValue: svc.overrides}
|
||||
fake.beforeDelete = func(int) error {
|
||||
return svc.PutAccountQuota(ctx, testAccount, Overrides{VCPUs: ptr(8)}, "operator")
|
||||
}
|
||||
svc.overrides = fake
|
||||
|
||||
result, err := svc.CleanupAccount(ctx, testAccount)
|
||||
assert.Error(t, err)
|
||||
require.NotNil(t, result)
|
||||
assert.Equal(t, OverrideCleanupFailed, result.Disposition)
|
||||
assert.Equal(t, cleanupMaxAttempts, fake.deleteCalls)
|
||||
}
|
||||
|
||||
// A store error must report failed, never absent: absence is a claim about
|
||||
// what the store holds, which an error gives no basis to make.
|
||||
func TestCleanupAccount_StoreErrorIsFailedNotAbsent(t *testing.T) {
|
||||
svc, _ := newOverrideService(t)
|
||||
fake := &overrideCleanupFakeKV{KeyValue: svc.overrides, getErr: errors.New("kv unavailable")}
|
||||
svc.overrides = fake
|
||||
|
||||
result, err := svc.CleanupAccount(t.Context(), testAccount)
|
||||
assert.Error(t, err)
|
||||
require.NotNil(t, result)
|
||||
assert.Equal(t, OverrideCleanupFailed, result.Disposition)
|
||||
}
|
||||
@@ -39,11 +39,7 @@ func (s *Service) EnforceVPCs(ctx context.Context, natsConn *nats.Conn, accountI
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
limits, err := s.limitsFor(ctx, accountID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return exceeds(len(out.Vpcs), want, limits.VPCs)
|
||||
return exceeds(len(out.Vpcs), want, s.limitsFor(ctx, accountID).VPCs)
|
||||
}
|
||||
|
||||
// EnforceSubnets gates CreateSubnet on the account's live DescribeSubnets count.
|
||||
@@ -56,11 +52,7 @@ func (s *Service) EnforceSubnets(ctx context.Context, natsConn *nats.Conn, accou
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
limits, err := s.limitsFor(ctx, accountID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return exceeds(len(out.Subnets), want, limits.Subnets)
|
||||
return exceeds(len(out.Subnets), want, s.limitsFor(ctx, accountID).Subnets)
|
||||
}
|
||||
|
||||
// EnforceEIPs gates AllocateAddress on the account's live DescribeAddresses count.
|
||||
@@ -72,11 +64,7 @@ func (s *Service) EnforceEIPs(ctx context.Context, natsConn *nats.Conn, accountI
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
limits, err := s.limitsFor(ctx, accountID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return exceeds(len(out.Addresses), want, limits.EIPs)
|
||||
return exceeds(len(out.Addresses), want, s.limitsFor(ctx, accountID).EIPs)
|
||||
}
|
||||
|
||||
// EnforceLoadBalancers gates CreateLoadBalancer on the account's live count.
|
||||
@@ -89,11 +77,7 @@ func (s *Service) EnforceLoadBalancers(ctx context.Context, natsConn *nats.Conn,
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
limits, err := s.limitsFor(ctx, accountID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return exceeds(len(out.LoadBalancers), want, limits.LoadBalancers)
|
||||
return exceeds(len(out.LoadBalancers), want, s.limitsFor(ctx, accountID).LoadBalancers)
|
||||
}
|
||||
|
||||
// EnforceRDSInstances gates CreateDBInstance on the account's live count. A DB
|
||||
@@ -107,9 +91,5 @@ func (s *Service) EnforceRDSInstances(ctx context.Context, natsConn *nats.Conn,
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
limits, err := s.limitsFor(ctx, accountID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return exceeds(len(out.DBInstances), want, limits.RDSInstances)
|
||||
return exceeds(len(out.DBInstances), want, s.limitsFor(ctx, accountID).RDSInstances)
|
||||
}
|
||||
|
||||
@@ -8,16 +8,12 @@ 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. 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.
|
||||
// from an absent field, which inherits the configured value.
|
||||
type Overrides struct {
|
||||
VCPUs *int `json:"vcpus,omitempty"`
|
||||
VPCs *int `json:"vpcs,omitempty"`
|
||||
@@ -33,62 +29,11 @@ type Overrides struct {
|
||||
UpdatedBy string `json:"updated_by,omitempty"`
|
||||
}
|
||||
|
||||
// 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)
|
||||
// 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
|
||||
}
|
||||
|
||||
// apply overlays the set fields onto base, leaving Enabled and the Bedrock
|
||||
@@ -115,128 +60,72 @@ func (o Overrides) apply(base Limits) Limits {
|
||||
return out
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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 overrideRecord{}, nil
|
||||
}
|
||||
entry, err := s.overrides.Get(ctx, accountID)
|
||||
if errors.Is(err, jetstream.ErrKeyNotFound) {
|
||||
return overrideRecord{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return overrideRecord{}, fmt.Errorf("read 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 rec, nil
|
||||
}
|
||||
|
||||
// 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)
|
||||
// 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, nil
|
||||
return s.limits
|
||||
}
|
||||
if err := checkActive(rec); err != nil {
|
||||
return Limits{}, mapOverrideError(ctx, accountID, err)
|
||||
}
|
||||
return rec.apply(s.limits), nil
|
||||
return over.apply(s.limits)
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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) {
|
||||
if s == nil || s.overrides == nil {
|
||||
return Overrides{}, nil
|
||||
}
|
||||
entry, err := s.overrides.Get(ctx, accountID)
|
||||
if errors.Is(err, jetstream.ErrKeyNotFound) {
|
||||
return Overrides{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return Overrides{}, 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)
|
||||
}
|
||||
return over, 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.
|
||||
func (s *Service) GetAccountQuota(ctx context.Context, accountID string) (Overrides, Limits, error) {
|
||||
rec, err := s.recordFor(ctx, accountID)
|
||||
over, err := s.overridesFor(ctx, accountID)
|
||||
if err != nil {
|
||||
return Overrides{}, Limits{}, err
|
||||
}
|
||||
if err := checkActive(rec); err != nil {
|
||||
return Overrides{}, Limits{}, mapOverrideError(ctx, accountID, err)
|
||||
}
|
||||
return rec.Overrides, rec.apply(s.limits), nil
|
||||
return over, over.apply(s.limits), nil
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
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() {
|
||||
return s.clearAccountQuota(ctx, accountID)
|
||||
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
|
||||
}
|
||||
|
||||
over.UpdatedAt = time.Now().UTC().Format(time.RFC3339)
|
||||
over.UpdatedBy = updatedBy
|
||||
|
||||
_, 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))
|
||||
}
|
||||
data, err := json.Marshal(over)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode quota override for %s: %w", accountID, err)
|
||||
}
|
||||
return mapOverrideError(ctx, accountID, errOverridesContended)
|
||||
if _, err := s.overrides.Put(ctx, accountID, data); err != nil {
|
||||
return fmt.Errorf("store quota override for %s: %w", accountID, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -31,8 +31,7 @@ func newOverrideService(t *testing.T) (*Service, jetstream.KeyValue) {
|
||||
func TestLimitsForAbsentKeyInherits(t *testing.T) {
|
||||
svc, _ := newOverrideService(t)
|
||||
|
||||
got, err := svc.limitsFor(t.Context(), "123456789012")
|
||||
require.NoError(t, err)
|
||||
got := svc.limitsFor(t.Context(), "123456789012")
|
||||
require.Equal(t, baseLimits(), got)
|
||||
}
|
||||
|
||||
@@ -45,14 +44,10 @@ func TestLimitsForIsPerAccount(t *testing.T) {
|
||||
require.NoError(t, svc.PutAccountQuota(t.Context(), upgraded,
|
||||
Overrides{VCPUs: ptr(32), RDSInstances: ptr(8)}, "operator"))
|
||||
|
||||
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)
|
||||
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)
|
||||
}
|
||||
|
||||
// A stored override must round-trip through KV with its provenance, and an
|
||||
@@ -84,9 +79,7 @@ func TestPutAccountQuotaEmptyClears(t *testing.T) {
|
||||
|
||||
_, err := kv.Get(t.Context(), account)
|
||||
require.ErrorIs(t, err, jetstream.ErrKeyNotFound)
|
||||
limits, err := svc.limitsFor(t.Context(), account)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, baseLimits(), limits)
|
||||
require.Equal(t, baseLimits(), svc.limitsFor(t.Context(), account))
|
||||
}
|
||||
|
||||
// Clearing an account that never had a record must succeed rather than fail on
|
||||
@@ -104,10 +97,8 @@ func TestPutAccountQuotaExplicitZero(t *testing.T) {
|
||||
|
||||
require.NoError(t, svc.PutAccountQuota(t.Context(), account, Overrides{VPCs: ptr(0)}, "operator"))
|
||||
|
||||
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))
|
||||
require.Equal(t, 0, svc.limitsFor(t.Context(), account).VPCs)
|
||||
require.Error(t, exceeds(0, 1, svc.limitsFor(t.Context(), account).VPCs))
|
||||
}
|
||||
|
||||
// A corrupt record is a read failure, not an absent one: it must fall back to
|
||||
@@ -119,9 +110,7 @@ func TestLimitsForCorruptRecordFailsClosed(t *testing.T) {
|
||||
_, err := kv.Put(t.Context(), account, []byte("{not json"))
|
||||
require.NoError(t, err)
|
||||
|
||||
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,
|
||||
require.Equal(t, baseLimits(), svc.limitsFor(t.Context(), account),
|
||||
"a corrupt override must fall back to the configured limits")
|
||||
|
||||
_, _, err = svc.GetAccountQuota(t.Context(), account)
|
||||
|
||||
@@ -126,11 +126,7 @@ func TestOverridesEmpty(t *testing.T) {
|
||||
// fall through to the configured limits rather than panicking.
|
||||
func TestLimitsForWithoutBucket(t *testing.T) {
|
||||
svc := New(baseLimits(), nil)
|
||||
got, err := svc.limitsFor(t.Context(), "123456789012")
|
||||
if err != nil {
|
||||
t.Fatalf("limitsFor error = %v, want nil", err)
|
||||
}
|
||||
if got != baseLimits() {
|
||||
if got := svc.limitsFor(t.Context(), "123456789012"); got != baseLimits() {
|
||||
t.Fatalf("limitsFor = %+v, want the configured limits", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -90,14 +90,6 @@ 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
|
||||
@@ -133,16 +125,6 @@ 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.
|
||||
|
||||
@@ -1,163 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,806 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -23,10 +23,7 @@ func (s *Service) EnforceVolumeCreate(ctx context.Context, natsConn *nats.Conn,
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
limits, err := s.limitsFor(ctx, accountID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
limits := s.limitsFor(ctx, accountID)
|
||||
if err := exceeds(len(out.Volumes), 1, limits.Volumes); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -46,11 +43,7 @@ func (s *Service) EnforceVolumeModify(ctx context.Context, natsConn *nats.Conn,
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
limits, err := s.limitsFor(ctx, accountID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return exceeds(total-oldGiB, newGiB, limits.VolumesGiB)
|
||||
return exceeds(total-oldGiB, newGiB, s.limitsFor(ctx, accountID).VolumesGiB)
|
||||
}
|
||||
|
||||
// volumeUsage sums the account's volume sizes in GiB via a single DescribeVolumes
|
||||
|
||||
@@ -37,11 +37,7 @@ func (s *Service) CheckVCPU(ctx context.Context, accountID string, want int) err
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
limits, err := s.limitsFor(ctx, accountID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return exceeds(current, want, limits.VCPUs)
|
||||
return exceeds(current, want, s.limitsFor(ctx, accountID).VCPUs)
|
||||
}
|
||||
|
||||
// casVCPU applies next to accountID's counter under bounded JetStream CAS so
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package instance
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/messaging/nats"
|
||||
"log/slog"
|
||||
@@ -100,10 +101,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, awserrors.WithRetryAfter(
|
||||
return nil, errors.Join(
|
||||
awserrors.Errorf(awserrors.ErrorServiceUnavailable,
|
||||
"cannot confirm instance type(s) %s: describe fan-out did not complete", strings.Join(missing, ", ")),
|
||||
describeIncompleteRetryAfter,
|
||||
awserrors.RetryAfter(awserrors.ErrorServiceUnavailable, describeIncompleteRetryAfter),
|
||||
)
|
||||
}
|
||||
return nil, awserrors.Errorf(awserrors.ErrorInvalidInstanceType,
|
||||
|
||||
@@ -14,8 +14,8 @@ import (
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
@@ -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, awserrors.WithRetryAfter(
|
||||
return nil, errors.Join(
|
||||
awserrors.Errorf(awserrors.ErrorServiceUnavailable,
|
||||
"cannot confirm status of instance(s) %s: describe fan-out did not complete", strings.Join(missing, ", ")),
|
||||
describeIncompleteRetryAfter,
|
||||
awserrors.RetryAfter(awserrors.ErrorServiceUnavailable, describeIncompleteRetryAfter),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -198,10 +198,10 @@ func DescribeAccountInstancesComplete(ctx context.Context, natsConn *nats.Conn,
|
||||
}
|
||||
|
||||
func incompleteListingError() error {
|
||||
return awserrors.WithRetryAfter(
|
||||
return errors.Join(
|
||||
awserrors.Errorf(awserrors.ErrorServiceUnavailable,
|
||||
"cannot list instances: not every node and instance store answered"),
|
||||
describeIncompleteRetryAfter,
|
||||
awserrors.RetryAfter(awserrors.ErrorServiceUnavailable, describeIncompleteRetryAfter),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -477,7 +477,7 @@ func judgeIdentityCompleteness(ctx context.Context, frames []natsmsg.Frame, sum
|
||||
// that carries only an ARN. Results are cached per ARN to avoid repeated RPCs.
|
||||
// Misses are warn-logged and leave Id empty (graceful degradation for deleted profiles).
|
||||
// Safe to call with a nil output or nil IAMService (no-op).
|
||||
func EnrichInstanceProfileIDs(out *ec2.DescribeInstancesOutput, iamSvc iamdomain.IAMService, accountID string) {
|
||||
func EnrichInstanceProfileIDs(out *ec2.DescribeInstancesOutput, iamSvc handlers_iam.IAMService, accountID string) {
|
||||
if out == nil || iamSvc == nil {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -11,8 +11,8 @@ import (
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -751,9 +751,9 @@ func TestEnrichInstanceProfileIDs_CachesPerARN(t *testing.T) {
|
||||
arn := "arn:aws:iam::123456789012:instance-profile/shared"
|
||||
var calls int
|
||||
svc := &fakeIAMService{
|
||||
resolveFn: func(_, nameOrARN string) (*iamdomain.InstanceProfile, error) {
|
||||
resolveFn: func(_, nameOrARN string) (*handlers_iam.InstanceProfile, error) {
|
||||
calls++
|
||||
return &iamdomain.InstanceProfile{ARN: nameOrARN, InstanceProfileID: "AIPAEXAMPLE"}, nil
|
||||
return &handlers_iam.InstanceProfile{ARN: nameOrARN, InstanceProfileID: "AIPAEXAMPLE"}, nil
|
||||
},
|
||||
}
|
||||
|
||||
@@ -771,11 +771,11 @@ func TestEnrichInstanceProfileIDs_ResolveErrorLeavesIdEmpty(t *testing.T) {
|
||||
failingARN := "arn:aws:iam::123456789012:instance-profile/missing"
|
||||
okARN := "arn:aws:iam::123456789012:instance-profile/present"
|
||||
svc := &fakeIAMService{
|
||||
resolveFn: func(_, nameOrARN string) (*iamdomain.InstanceProfile, error) {
|
||||
resolveFn: func(_, nameOrARN string) (*handlers_iam.InstanceProfile, error) {
|
||||
if nameOrARN == failingARN {
|
||||
return nil, errors.New(awserrors.ErrorIAMNoSuchEntity)
|
||||
}
|
||||
return &iamdomain.InstanceProfile{ARN: nameOrARN, InstanceProfileID: "AIPAOK"}, nil
|
||||
return &handlers_iam.InstanceProfile{ARN: nameOrARN, InstanceProfileID: "AIPAOK"}, nil
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -12,9 +12,9 @@ import (
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/spinifex/contracts/ec2/v1"
|
||||
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/arn"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
@@ -24,7 +24,7 @@ const fanOutTimeout = 3 * time.Second
|
||||
|
||||
// resolveAndAuthorizeProfile resolves a profile (name or ARN) and enforces iam:PassRole.
|
||||
// Profiles without a role skip PassRole; missing profile → InvalidIamInstanceProfile.NotFound.
|
||||
func resolveAndAuthorizeProfile(spec *ec2.IamInstanceProfileSpecification, iamSvc iamdomain.IAMService, accountID string, passRoleCheck PassRoleChecker) (*iamdomain.InstanceProfile, error) {
|
||||
func resolveAndAuthorizeProfile(spec *ec2.IamInstanceProfileSpecification, iamSvc handlers_iam.IAMService, accountID string, passRoleCheck PassRoleChecker) (*handlers_iam.InstanceProfile, error) {
|
||||
nameOrARN := profileNameOrARN(spec)
|
||||
if nameOrARN == "" {
|
||||
return nil, errors.New(awserrors.ErrorMissingParameter)
|
||||
@@ -55,7 +55,7 @@ func resolveAndAuthorizeProfile(spec *ec2.IamInstanceProfileSpecification, iamSv
|
||||
// AssociateIamInstanceProfile attaches a profile to a running instance.
|
||||
// The gateway resolves the profile and enforces PassRole; the daemon validates
|
||||
// no existing profile and generates a fresh iip-assoc-… ID.
|
||||
func AssociateIamInstanceProfile(ctx context.Context, input *ec2.AssociateIamInstanceProfileInput, natsConn *nats.Conn, iamSvc iamdomain.IAMService, accountID string, passRoleCheck PassRoleChecker) (*ec2.AssociateIamInstanceProfileOutput, error) {
|
||||
func AssociateIamInstanceProfile(ctx context.Context, input *ec2.AssociateIamInstanceProfileInput, natsConn *nats.Conn, iamSvc handlers_iam.IAMService, accountID string, passRoleCheck PassRoleChecker) (*ec2.AssociateIamInstanceProfileOutput, error) {
|
||||
if input == nil || input.InstanceId == nil || *input.InstanceId == "" {
|
||||
return nil, errors.New(awserrors.ErrorMissingParameter)
|
||||
}
|
||||
@@ -107,7 +107,7 @@ func DisassociateIamInstanceProfile(ctx context.Context, input *ec2.Disassociate
|
||||
|
||||
// ReplaceIamInstanceProfileAssociation swaps the profile for an existing association.
|
||||
// The gateway resolves the new profile and enforces PassRole; the daemon swaps atomically.
|
||||
func ReplaceIamInstanceProfileAssociation(ctx context.Context, input *ec2.ReplaceIamInstanceProfileAssociationInput, natsConn *nats.Conn, iamSvc iamdomain.IAMService, expectedNodes int, accountID string, passRoleCheck PassRoleChecker) (*ec2.ReplaceIamInstanceProfileAssociationOutput, error) {
|
||||
func ReplaceIamInstanceProfileAssociation(ctx context.Context, input *ec2.ReplaceIamInstanceProfileAssociationInput, natsConn *nats.Conn, iamSvc handlers_iam.IAMService, expectedNodes int, accountID string, passRoleCheck PassRoleChecker) (*ec2.ReplaceIamInstanceProfileAssociationOutput, error) {
|
||||
if input == nil || input.AssociationId == nil || *input.AssociationId == "" {
|
||||
return nil, errors.New(awserrors.ErrorMissingParameter)
|
||||
}
|
||||
@@ -173,7 +173,7 @@ func DescribeIamInstanceProfileAssociations(ctx context.Context, input *ec2.Desc
|
||||
|
||||
// enrichProfileID fills IamInstanceProfile.Id from the resolved profile.
|
||||
// Daemons have no IAM access. Safe to call with a nil association.
|
||||
func enrichProfileID(assoc *ec2.IamInstanceProfileAssociation, profile *iamdomain.InstanceProfile) {
|
||||
func enrichProfileID(assoc *ec2.IamInstanceProfileAssociation, profile *handlers_iam.InstanceProfile) {
|
||||
if assoc == nil || profile == nil {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -11,10 +11,10 @@ import (
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/spinifex/contracts/ec2/v1"
|
||||
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/arn"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
|
||||
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -35,13 +35,13 @@ const (
|
||||
// fakeIAMService implements profile and canonical role lookups.
|
||||
// Other methods nil-panic to surface unexpected calls.
|
||||
type fakeIAMService struct {
|
||||
iamdomain.IAMService
|
||||
handlers_iam.IAMService
|
||||
|
||||
resolveFn func(accountID, nameOrARN string) (*iamdomain.InstanceProfile, error)
|
||||
resolveFn func(accountID, nameOrARN string) (*handlers_iam.InstanceProfile, error)
|
||||
canonicalFn func(accountID string, kind arn.IAMResourceType, name string) (string, error)
|
||||
}
|
||||
|
||||
func (f *fakeIAMService) ResolveInstanceProfile(accountID, nameOrARN string) (*iamdomain.InstanceProfile, error) {
|
||||
func (f *fakeIAMService) ResolveInstanceProfile(accountID, nameOrARN string) (*handlers_iam.InstanceProfile, error) {
|
||||
if f.resolveFn != nil {
|
||||
return f.resolveFn(accountID, nameOrARN)
|
||||
}
|
||||
@@ -58,11 +58,11 @@ func (f *fakeIAMService) CanonicalResourceARN(accountID string, kind arn.IAMReso
|
||||
return "", errors.New(awserrors.ErrorIAMNoSuchEntity)
|
||||
}
|
||||
|
||||
var _ iamdomain.IAMService = (*fakeIAMService)(nil)
|
||||
var _ handlers_iam.IAMService = (*fakeIAMService)(nil)
|
||||
|
||||
// profileWithRole builds a resolved profile fixture pointing at the test role.
|
||||
func profileWithRole() *iamdomain.InstanceProfile {
|
||||
return &iamdomain.InstanceProfile{
|
||||
func profileWithRole() *handlers_iam.InstanceProfile {
|
||||
return &handlers_iam.InstanceProfile{
|
||||
InstanceProfileName: testProfileNameApp,
|
||||
InstanceProfileID: testProfileIDApp,
|
||||
AccountID: testGwAccountID,
|
||||
@@ -73,8 +73,8 @@ func profileWithRole() *iamdomain.InstanceProfile {
|
||||
|
||||
// profileNoRole builds a resolved profile fixture with no role attached.
|
||||
// resolveAndAuthorizeProfile must NOT invoke PassRole for this profile.
|
||||
func profileNoRole() *iamdomain.InstanceProfile {
|
||||
return &iamdomain.InstanceProfile{
|
||||
func profileNoRole() *handlers_iam.InstanceProfile {
|
||||
return &handlers_iam.InstanceProfile{
|
||||
InstanceProfileName: testProfileNameOther,
|
||||
InstanceProfileID: testProfileIDOther,
|
||||
AccountID: testGwAccountID,
|
||||
@@ -88,7 +88,7 @@ func profileNoRole() *iamdomain.InstanceProfile {
|
||||
func TestResolveAndAuthorizeProfile_NameForm(t *testing.T) {
|
||||
t.Parallel()
|
||||
gotNameOrARN := ""
|
||||
svc := &fakeIAMService{resolveFn: func(_, nameOrARN string) (*iamdomain.InstanceProfile, error) {
|
||||
svc := &fakeIAMService{resolveFn: func(_, nameOrARN string) (*handlers_iam.InstanceProfile, error) {
|
||||
gotNameOrARN = nameOrARN
|
||||
return profileWithRole(), nil
|
||||
}}
|
||||
@@ -112,7 +112,7 @@ func TestResolveAndAuthorizeProfile_NameForm(t *testing.T) {
|
||||
func TestResolveAndAuthorizeProfile_ArnForm(t *testing.T) {
|
||||
t.Parallel()
|
||||
gotNameOrARN := ""
|
||||
svc := &fakeIAMService{resolveFn: func(_, nameOrARN string) (*iamdomain.InstanceProfile, error) {
|
||||
svc := &fakeIAMService{resolveFn: func(_, nameOrARN string) (*handlers_iam.InstanceProfile, error) {
|
||||
gotNameOrARN = nameOrARN
|
||||
return profileWithRole(), nil
|
||||
}}
|
||||
@@ -144,7 +144,7 @@ func TestResolveAndAuthorizeProfile_NilIAMService(t *testing.T) {
|
||||
|
||||
func TestResolveAndAuthorizeProfile_NotFoundMapsToEC2Error(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc := &fakeIAMService{resolveFn: func(string, string) (*iamdomain.InstanceProfile, error) {
|
||||
svc := &fakeIAMService{resolveFn: func(string, string) (*handlers_iam.InstanceProfile, error) {
|
||||
return nil, errors.New(awserrors.ErrorIAMNoSuchEntity)
|
||||
}}
|
||||
_, err := resolveAndAuthorizeProfile(
|
||||
@@ -157,7 +157,7 @@ func TestResolveAndAuthorizeProfile_NotFoundMapsToEC2Error(t *testing.T) {
|
||||
|
||||
func TestResolveAndAuthorizeProfile_PassthroughOtherIAMError(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc := &fakeIAMService{resolveFn: func(string, string) (*iamdomain.InstanceProfile, error) {
|
||||
svc := &fakeIAMService{resolveFn: func(string, string) (*handlers_iam.InstanceProfile, error) {
|
||||
return nil, errors.New(awserrors.ErrorAccessDenied)
|
||||
}}
|
||||
_, err := resolveAndAuthorizeProfile(
|
||||
@@ -170,7 +170,7 @@ func TestResolveAndAuthorizeProfile_PassthroughOtherIAMError(t *testing.T) {
|
||||
|
||||
func TestResolveAndAuthorizeProfile_PassRoleDenied(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc := &fakeIAMService{resolveFn: func(string, string) (*iamdomain.InstanceProfile, error) {
|
||||
svc := &fakeIAMService{resolveFn: func(string, string) (*handlers_iam.InstanceProfile, error) {
|
||||
return profileWithRole(), nil
|
||||
}}
|
||||
check := func(string) error { return errors.New(awserrors.ErrorAccessDenied) }
|
||||
@@ -183,7 +183,7 @@ func TestResolveAndAuthorizeProfile_PassRoleDenied(t *testing.T) {
|
||||
|
||||
func TestResolveAndAuthorizeProfile_NoRoleSkipsPassRole(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc := &fakeIAMService{resolveFn: func(string, string) (*iamdomain.InstanceProfile, error) {
|
||||
svc := &fakeIAMService{resolveFn: func(string, string) (*handlers_iam.InstanceProfile, error) {
|
||||
return profileNoRole(), nil
|
||||
}}
|
||||
checkCalled := false
|
||||
@@ -265,7 +265,7 @@ func TestAssociateIamInstanceProfile_ProfileNotFound(t *testing.T) {
|
||||
|
||||
func TestAssociateIamInstanceProfile_PassRoleDenied(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc := &fakeIAMService{resolveFn: func(string, string) (*iamdomain.InstanceProfile, error) {
|
||||
svc := &fakeIAMService{resolveFn: func(string, string) (*handlers_iam.InstanceProfile, error) {
|
||||
return profileWithRole(), nil
|
||||
}}
|
||||
in := &ec2.AssociateIamInstanceProfileInput{
|
||||
@@ -281,7 +281,7 @@ func TestAssociateIamInstanceProfile_PassRoleDenied(t *testing.T) {
|
||||
func TestAssociateIamInstanceProfile_NoResponders(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, nc := startTestNATSServer(t)
|
||||
svc := &fakeIAMService{resolveFn: func(string, string) (*iamdomain.InstanceProfile, error) {
|
||||
svc := &fakeIAMService{resolveFn: func(string, string) (*handlers_iam.InstanceProfile, error) {
|
||||
return profileNoRole(), nil
|
||||
}}
|
||||
in := &ec2.AssociateIamInstanceProfileInput{
|
||||
@@ -299,7 +299,7 @@ func TestAssociateIamInstanceProfile_NoResponders(t *testing.T) {
|
||||
func TestAssociateIamInstanceProfile_Success(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, nc := startTestNATSServer(t)
|
||||
svc := &fakeIAMService{resolveFn: func(string, string) (*iamdomain.InstanceProfile, error) {
|
||||
svc := &fakeIAMService{resolveFn: func(string, string) (*handlers_iam.InstanceProfile, error) {
|
||||
return profileWithRole(), nil
|
||||
}}
|
||||
const instanceID = "i-assoc-success"
|
||||
@@ -345,7 +345,7 @@ func TestAssociateIamInstanceProfile_Success(t *testing.T) {
|
||||
func TestAssociateIamInstanceProfile_DaemonAlreadyAssociated(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, nc := startTestNATSServer(t)
|
||||
svc := &fakeIAMService{resolveFn: func(string, string) (*iamdomain.InstanceProfile, error) {
|
||||
svc := &fakeIAMService{resolveFn: func(string, string) (*handlers_iam.InstanceProfile, error) {
|
||||
return profileNoRole(), nil
|
||||
}}
|
||||
const instanceID = "i-already-bound"
|
||||
@@ -474,7 +474,7 @@ func TestReplaceIamInstanceProfileAssociation_MissingProfileSpec(t *testing.T) {
|
||||
func TestReplaceIamInstanceProfileAssociation_PassRoleDeniedBeforeBroadcast(t *testing.T) {
|
||||
t.Parallel()
|
||||
// nc=nil proves the call is rejected before any NATS dispatch.
|
||||
svc := &fakeIAMService{resolveFn: func(string, string) (*iamdomain.InstanceProfile, error) {
|
||||
svc := &fakeIAMService{resolveFn: func(string, string) (*handlers_iam.InstanceProfile, error) {
|
||||
return profileWithRole(), nil
|
||||
}}
|
||||
check := func(string) error { return errors.New(awserrors.ErrorAccessDenied) }
|
||||
@@ -489,7 +489,7 @@ func TestReplaceIamInstanceProfileAssociation_PassRoleDeniedBeforeBroadcast(t *t
|
||||
func TestReplaceIamInstanceProfileAssociation_Success(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, nc := startTestNATSServer(t)
|
||||
svc := &fakeIAMService{resolveFn: func(string, string) (*iamdomain.InstanceProfile, error) {
|
||||
svc := &fakeIAMService{resolveFn: func(string, string) (*handlers_iam.InstanceProfile, error) {
|
||||
return profileWithRole(), nil
|
||||
}}
|
||||
const oldID = "iip-assoc-old00000000001"
|
||||
@@ -530,7 +530,7 @@ func TestReplaceIamInstanceProfileAssociation_Success(t *testing.T) {
|
||||
func TestReplaceIamInstanceProfileAssociation_NoSuchAssociation(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, nc := startTestNATSServer(t)
|
||||
svc := &fakeIAMService{resolveFn: func(string, string) (*iamdomain.InstanceProfile, error) {
|
||||
svc := &fakeIAMService{resolveFn: func(string, string) (*handlers_iam.InstanceProfile, error) {
|
||||
return profileNoRole(), nil
|
||||
}}
|
||||
_, err := nc.Subscribe("ec2.IamProfileAssociation.replace", func(msg *nats.Msg) {
|
||||
@@ -700,7 +700,7 @@ func TestResolveAndAuthorizeProfile_CanonicalRoleARN(t *testing.T) {
|
||||
t.Parallel()
|
||||
storedARN := "arn:aws:iam::111122223333:role" + path + "app-role"
|
||||
svc := &fakeIAMService{
|
||||
resolveFn: func(string, string) (*iamdomain.InstanceProfile, error) {
|
||||
resolveFn: func(string, string) (*handlers_iam.InstanceProfile, error) {
|
||||
return profileWithRole(), nil
|
||||
},
|
||||
canonicalFn: func(accountID string, kind arn.IAMResourceType, name string) (string, error) {
|
||||
@@ -729,7 +729,7 @@ func TestResolveAndAuthorizeProfile_CanonicalLookupError(t *testing.T) {
|
||||
t.Run(lookupErr.Error(), func(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc := &fakeIAMService{
|
||||
resolveFn: func(string, string) (*iamdomain.InstanceProfile, error) {
|
||||
resolveFn: func(string, string) (*handlers_iam.InstanceProfile, error) {
|
||||
return profileWithRole(), nil
|
||||
},
|
||||
canonicalFn: func(string, arn.IAMResourceType, string) (string, error) {
|
||||
|
||||
@@ -20,8 +20,8 @@ import (
|
||||
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"
|
||||
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
@@ -131,7 +131,7 @@ func hasPinnedPrivateIP(input *ec2.RunInstancesInput) bool {
|
||||
// passRoleCheck may be nil to skip PassRole enforcement.
|
||||
// launchQuotaCheck may be nil to skip quota enforcement; when present it runs
|
||||
// after validation and PassRole authorization, but before any launch dispatch.
|
||||
func RunInstances(ctx context.Context, input *ec2.RunInstancesInput, natsConn *nats.Conn, iamSvc iamdomain.IAMService, accountID string, passRoleCheck PassRoleChecker, launchQuotaCheck LaunchQuotaChecker, expectedNodes int) (reservation ec2.Reservation, err error) {
|
||||
func RunInstances(ctx context.Context, input *ec2.RunInstancesInput, natsConn *nats.Conn, iamSvc handlers_iam.IAMService, accountID string, passRoleCheck PassRoleChecker, launchQuotaCheck LaunchQuotaChecker, expectedNodes int) (reservation ec2.Reservation, err error) {
|
||||
// Expand a referenced launch template before validation so validation, quota,
|
||||
// and downstream routing all see the effective (post-merge) parameters.
|
||||
if err = ExpandLaunchTemplate(ctx, natsConn, input, accountID); err != nil {
|
||||
@@ -163,7 +163,7 @@ func RunInstances(ctx context.Context, input *ec2.RunInstancesInput, natsConn *n
|
||||
|
||||
// runInstancesInner performs the actual launch: profile resolution, placement
|
||||
// routing, and capacity-aware distribution. Wrapped by RunInstances for idempotency.
|
||||
func runInstancesInner(ctx context.Context, input *ec2.RunInstancesInput, natsConn *nats.Conn, iamSvc iamdomain.IAMService, accountID string, passRoleCheck PassRoleChecker, launchQuotaCheck LaunchQuotaChecker, expectedNodes int) (reservation ec2.Reservation, err error) {
|
||||
func runInstancesInner(ctx context.Context, input *ec2.RunInstancesInput, natsConn *nats.Conn, iamSvc handlers_iam.IAMService, accountID string, passRoleCheck PassRoleChecker, launchQuotaCheck LaunchQuotaChecker, expectedNodes int) (reservation ec2.Reservation, err error) {
|
||||
resolvedProfile, err := resolveAndAuthorizeInstanceProfile(input, iamSvc, accountID, passRoleCheck)
|
||||
if err != nil {
|
||||
return reservation, err
|
||||
@@ -236,7 +236,7 @@ func runInstancesInner(ctx context.Context, input *ec2.RunInstancesInput, natsCo
|
||||
|
||||
// resolveAndAuthorizeInstanceProfile resolves an optional instance profile,
|
||||
// enforces PassRole, and normalises input to the canonical ARN only.
|
||||
func resolveAndAuthorizeInstanceProfile(input *ec2.RunInstancesInput, iamSvc iamdomain.IAMService, accountID string, passRoleCheck PassRoleChecker) (*iamdomain.InstanceProfile, error) {
|
||||
func resolveAndAuthorizeInstanceProfile(input *ec2.RunInstancesInput, iamSvc handlers_iam.IAMService, accountID string, passRoleCheck PassRoleChecker) (*handlers_iam.InstanceProfile, error) {
|
||||
if input.IamInstanceProfile == nil {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -261,7 +261,7 @@ func profileNameOrARN(spec *ec2.IamInstanceProfileSpecification) string {
|
||||
|
||||
// enrichReservationWithProfileID fills IamInstanceProfile.Id on every instance.
|
||||
// Daemons emit Arn only (no IAM access); the gateway adds Id from the resolved profile.
|
||||
func enrichReservationWithProfileID(reservation *ec2.Reservation, profile *iamdomain.InstanceProfile) {
|
||||
func enrichReservationWithProfileID(reservation *ec2.Reservation, profile *handlers_iam.InstanceProfile) {
|
||||
if reservation == nil || profile == nil {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -9,8 +9,8 @@ import (
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/spinifex/spinifex/domains/ec2/ebs/metadata"
|
||||
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -385,7 +385,7 @@ func TestResolveAndAuthorizeInstanceProfile_Absent(t *testing.T) {
|
||||
|
||||
func TestResolveAndAuthorizeInstanceProfile_NameForm_NormalisesToARN(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc := &fakeIAMService{resolveFn: func(_, nameOrARN string) (*iamdomain.InstanceProfile, error) {
|
||||
svc := &fakeIAMService{resolveFn: func(_, nameOrARN string) (*handlers_iam.InstanceProfile, error) {
|
||||
assert.Equal(t, testProfileNameApp, nameOrARN)
|
||||
return profileWithRole(), nil
|
||||
}}
|
||||
@@ -404,7 +404,7 @@ func TestResolveAndAuthorizeInstanceProfile_NameForm_NormalisesToARN(t *testing.
|
||||
|
||||
func TestResolveAndAuthorizeInstanceProfile_ArnForm(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc := &fakeIAMService{resolveFn: func(_, nameOrARN string) (*iamdomain.InstanceProfile, error) {
|
||||
svc := &fakeIAMService{resolveFn: func(_, nameOrARN string) (*handlers_iam.InstanceProfile, error) {
|
||||
assert.Equal(t, testProfileARNApp, nameOrARN)
|
||||
return profileWithRole(), nil
|
||||
}}
|
||||
@@ -433,7 +433,7 @@ func TestResolveAndAuthorizeInstanceProfile_NilIAMService(t *testing.T) {
|
||||
|
||||
func TestResolveAndAuthorizeInstanceProfile_NotFoundMapsToEC2Error(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc := &fakeIAMService{resolveFn: func(string, string) (*iamdomain.InstanceProfile, error) {
|
||||
svc := &fakeIAMService{resolveFn: func(string, string) (*handlers_iam.InstanceProfile, error) {
|
||||
return nil, errors.New(awserrors.ErrorIAMNoSuchEntity)
|
||||
}}
|
||||
in := runValidInputWithProfile(&ec2.IamInstanceProfileSpecification{Name: aws.String("ghost")})
|
||||
@@ -445,7 +445,7 @@ func TestResolveAndAuthorizeInstanceProfile_NotFoundMapsToEC2Error(t *testing.T)
|
||||
func TestResolveAndAuthorizeInstanceProfile_CrossAccountARNPassthrough(t *testing.T) {
|
||||
t.Parallel()
|
||||
// IAM service rejects cross-account; gateway surfaces the AccessDenied verbatim.
|
||||
svc := &fakeIAMService{resolveFn: func(string, string) (*iamdomain.InstanceProfile, error) {
|
||||
svc := &fakeIAMService{resolveFn: func(string, string) (*handlers_iam.InstanceProfile, error) {
|
||||
return nil, errors.New(awserrors.ErrorAccessDenied)
|
||||
}}
|
||||
in := runValidInputWithProfile(&ec2.IamInstanceProfileSpecification{Arn: aws.String(testCrossAccountARN)})
|
||||
@@ -456,7 +456,7 @@ func TestResolveAndAuthorizeInstanceProfile_CrossAccountARNPassthrough(t *testin
|
||||
|
||||
func TestResolveAndAuthorizeInstanceProfile_PassRoleDenied(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc := &fakeIAMService{resolveFn: func(string, string) (*iamdomain.InstanceProfile, error) {
|
||||
svc := &fakeIAMService{resolveFn: func(string, string) (*handlers_iam.InstanceProfile, error) {
|
||||
return profileWithRole(), nil
|
||||
}}
|
||||
check := func(roleARN string) error {
|
||||
@@ -471,7 +471,7 @@ func TestResolveAndAuthorizeInstanceProfile_PassRoleDenied(t *testing.T) {
|
||||
|
||||
func TestResolveAndAuthorizeInstanceProfile_NoRoleSkipsPassRole(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc := &fakeIAMService{resolveFn: func(string, string) (*iamdomain.InstanceProfile, error) {
|
||||
svc := &fakeIAMService{resolveFn: func(string, string) (*handlers_iam.InstanceProfile, error) {
|
||||
return profileNoRole(), nil
|
||||
}}
|
||||
checkCalled := false
|
||||
|
||||
@@ -17,10 +17,10 @@ import (
|
||||
admissionquota "github.com/mulgadc/spinifex/spinifex/domains/admission/quota"
|
||||
ec2instanceapi "github.com/mulgadc/spinifex/spinifex/domains/ec2/awsapi/instance"
|
||||
ec2spotinstance "github.com/mulgadc/spinifex/spinifex/domains/ec2/spotinstance"
|
||||
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
|
||||
awstags "github.com/mulgadc/spinifex/spinifex/foundation/aws/tags"
|
||||
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
@@ -64,7 +64,7 @@ func ValidateRequestSpotInstancesInput(input *ec2.RequestSpotInstancesInput) err
|
||||
// ClientToken passed through, same vCPU quota gate), then builds and persists one
|
||||
// active/fulfilled SpotInstanceRequest per launched instance. On a launch failure
|
||||
// (including InsufficientInstanceCapacity) it returns the error and persists nothing.
|
||||
func RequestSpotInstances(ctx context.Context, input *ec2.RequestSpotInstancesInput, natsConn *nats.Conn, iamSvc iamdomain.IAMService, accountID, az string, passRoleCheck ec2instanceapi.PassRoleChecker, quota *admissionquota.Service, expectedNodes int) (ec2.RequestSpotInstancesOutput, error) {
|
||||
func RequestSpotInstances(ctx context.Context, input *ec2.RequestSpotInstancesInput, natsConn *nats.Conn, iamSvc handlers_iam.IAMService, accountID, az string, passRoleCheck ec2instanceapi.PassRoleChecker, quota *admissionquota.Service, expectedNodes int) (ec2.RequestSpotInstancesOutput, error) {
|
||||
var output ec2.RequestSpotInstancesOutput
|
||||
|
||||
if err := ValidateRequestSpotInstancesInput(input); err != nil {
|
||||
|
||||
@@ -13,8 +13,8 @@ import (
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/iam"
|
||||
"github.com/aws/aws-sdk-go/service/sts"
|
||||
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -62,13 +62,13 @@ func (f *fakeResolver) resolveVPCCIDR(_ context.Context, _, _ string) (string, e
|
||||
}
|
||||
|
||||
type fakeIAM struct {
|
||||
profile *iamdomain.InstanceProfile
|
||||
profile *handlers_iam.InstanceProfile
|
||||
profileErr error
|
||||
roleARN string
|
||||
roleErr error
|
||||
}
|
||||
|
||||
func (f *fakeIAM) ResolveInstanceProfile(_ context.Context, _, _ string) (*iamdomain.InstanceProfile, error) {
|
||||
func (f *fakeIAM) ResolveInstanceProfile(_ context.Context, _, _ string) (*handlers_iam.InstanceProfile, error) {
|
||||
return f.profile, f.profileErr
|
||||
}
|
||||
|
||||
@@ -991,8 +991,8 @@ func TestHTTP_PublicKeysInstanceBackendError500(t *testing.T) {
|
||||
|
||||
// ----- IAM / credentials -------------------------------------------------
|
||||
|
||||
func profileFixture() *iamdomain.InstanceProfile {
|
||||
return &iamdomain.InstanceProfile{
|
||||
func profileFixture() *handlers_iam.InstanceProfile {
|
||||
return &handlers_iam.InstanceProfile{
|
||||
InstanceProfileName: "app-profile",
|
||||
InstanceProfileID: "AIPAEXAMPLE",
|
||||
AccountID: "111122223333",
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"github.com/aws/aws-sdk-go/service/iam"
|
||||
"github.com/aws/aws-sdk-go/service/sts"
|
||||
ec2key "github.com/mulgadc/spinifex/spinifex/domains/ec2/key"
|
||||
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
|
||||
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
|
||||
handlers_sts "github.com/mulgadc/spinifex/spinifex/handlers/sts"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
@@ -50,7 +50,7 @@ func (a *NATSSTSAssumer) AssumeRoleForInstance(ctx context.Context, accountID, r
|
||||
// NATSProfileLookup is the NATS-backed profileLookup with a short-TTL cache.
|
||||
type NATSProfileLookup struct {
|
||||
nc *nats.Conn
|
||||
profiles *ttlCache[*iamdomain.InstanceProfile]
|
||||
profiles *ttlCache[*handlers_iam.InstanceProfile]
|
||||
roles *ttlCache[*iam.GetRoleOutput]
|
||||
}
|
||||
|
||||
@@ -60,17 +60,17 @@ var _ profileLookup = (*NATSProfileLookup)(nil)
|
||||
func NewNATSProfileLookup(nc *nats.Conn) *NATSProfileLookup {
|
||||
return &NATSProfileLookup{
|
||||
nc: nc,
|
||||
profiles: newTTLCache[*iamdomain.InstanceProfile](profileCacheTTL),
|
||||
profiles: newTTLCache[*handlers_iam.InstanceProfile](profileCacheTTL),
|
||||
roles: newTTLCache[*iam.GetRoleOutput](profileCacheTTL),
|
||||
}
|
||||
}
|
||||
|
||||
func (p *NATSProfileLookup) ResolveInstanceProfile(ctx context.Context, accountID, nameOrARN string) (*iamdomain.InstanceProfile, error) {
|
||||
func (p *NATSProfileLookup) ResolveInstanceProfile(ctx context.Context, accountID, nameOrARN string) (*handlers_iam.InstanceProfile, error) {
|
||||
key := accountID + "\x00" + nameOrARN
|
||||
if v, ok := p.profiles.get(key); ok {
|
||||
return v, nil
|
||||
}
|
||||
out, err := natsmsg.NATSRequest[iamdomain.InstanceProfile](ctx, p.nc, iamdomain.SubjectResolveInstanceProfile, iamdomain.ResolveInstanceProfileRequest{
|
||||
out, err := natsmsg.NATSRequest[handlers_iam.InstanceProfile](ctx, p.nc, handlers_iam.SubjectResolveInstanceProfile, handlers_iam.ResolveInstanceProfileRequest{
|
||||
AccountID: accountID,
|
||||
NameOrARN: nameOrARN,
|
||||
}, imdsRPCTimeout, accountID)
|
||||
@@ -86,7 +86,7 @@ func (p *NATSProfileLookup) GetRole(ctx context.Context, accountID string, input
|
||||
if v, ok := p.roles.get(key); ok {
|
||||
return v, nil
|
||||
}
|
||||
out, err := natsmsg.NATSRequest[iam.GetRoleOutput](ctx, p.nc, iamdomain.SubjectGetRole, iamdomain.GetRoleRequest{
|
||||
out, err := natsmsg.NATSRequest[iam.GetRoleOutput](ctx, p.nc, handlers_iam.SubjectGetRole, handlers_iam.GetRoleRequest{
|
||||
AccountID: accountID,
|
||||
Input: input,
|
||||
}, imdsRPCTimeout, accountID)
|
||||
|
||||
@@ -13,8 +13,8 @@ import (
|
||||
"github.com/aws/aws-sdk-go/service/sts"
|
||||
"github.com/mulgadc/spinifex/internal/testkit"
|
||||
ec2key "github.com/mulgadc/spinifex/spinifex/domains/ec2/key"
|
||||
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
|
||||
handlers_sts "github.com/mulgadc/spinifex/spinifex/handlers/sts"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -72,9 +72,9 @@ func TestNATSSTSAssumer_RoundTrip(t *testing.T) {
|
||||
func TestNATSProfileLookup_CachesProfile(t *testing.T) {
|
||||
_, nc := testutil.StartTestNATS(t)
|
||||
|
||||
calls := serveCounted(t, nc, iamdomain.SubjectResolveInstanceProfile,
|
||||
func(req *iamdomain.ResolveInstanceProfileRequest) (*iamdomain.InstanceProfile, error) {
|
||||
return &iamdomain.InstanceProfile{
|
||||
calls := serveCounted(t, nc, handlers_iam.SubjectResolveInstanceProfile,
|
||||
func(req *handlers_iam.ResolveInstanceProfileRequest) (*handlers_iam.InstanceProfile, error) {
|
||||
return &handlers_iam.InstanceProfile{
|
||||
InstanceProfileName: "app",
|
||||
AccountID: req.AccountID,
|
||||
RoleName: "app-role",
|
||||
@@ -94,8 +94,8 @@ func TestNATSProfileLookup_CachesProfile(t *testing.T) {
|
||||
func TestNATSProfileLookup_CachesRole(t *testing.T) {
|
||||
_, nc := testutil.StartTestNATS(t)
|
||||
|
||||
calls := serveCounted(t, nc, iamdomain.SubjectGetRole,
|
||||
func(req *iamdomain.GetRoleRequest) (*iam.GetRoleOutput, error) {
|
||||
calls := serveCounted(t, nc, handlers_iam.SubjectGetRole,
|
||||
func(req *handlers_iam.GetRoleRequest) (*iam.GetRoleOutput, error) {
|
||||
return &iam.GetRoleOutput{Role: &iam.Role{
|
||||
RoleName: req.Input.RoleName,
|
||||
Arn: aws.String("arn:aws:iam::123456789012:role/app-role"),
|
||||
@@ -114,8 +114,8 @@ func TestNATSProfileLookup_CachesRole(t *testing.T) {
|
||||
func TestNATSProfileLookup_PropagatesError(t *testing.T) {
|
||||
_, nc := testutil.StartTestNATS(t)
|
||||
|
||||
serveCounted(t, nc, iamdomain.SubjectResolveInstanceProfile,
|
||||
func(_ *iamdomain.ResolveInstanceProfileRequest) (*iamdomain.InstanceProfile, error) {
|
||||
serveCounted(t, nc, handlers_iam.SubjectResolveInstanceProfile,
|
||||
func(_ *handlers_iam.ResolveInstanceProfileRequest) (*handlers_iam.InstanceProfile, error) {
|
||||
return nil, assert.AnError
|
||||
})
|
||||
|
||||
|
||||
@@ -11,8 +11,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -47,7 +47,7 @@ func TestProfileForReportsWhyTheRoleIsMissing(t *testing.T) {
|
||||
{
|
||||
name: "role resolved",
|
||||
res: &fakeResolver{eni: testENI(), inst: &instanceFacts{iamInstanceProfileArn: "arn:aws:iam::111122223333:instance-profile/app"}},
|
||||
iam: &fakeIAM{profile: &iamdomain.InstanceProfile{ARN: "arn", InstanceProfileID: "AIPA", RoleName: "app"}},
|
||||
iam: &fakeIAM{profile: &handlers_iam.InstanceProfile{ARN: "arn", InstanceProfileID: "AIPA", RoleName: "app"}},
|
||||
want: roleMissNone,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -13,9 +13,9 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/iam"
|
||||
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/telemetry"
|
||||
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
@@ -35,7 +35,7 @@ type listTapsFunc func(ctx context.Context) (map[string]string, error)
|
||||
|
||||
// profileLookup is the IAMService slice needed for credential + iam/* paths.
|
||||
type profileLookup interface {
|
||||
ResolveInstanceProfile(ctx context.Context, accountID, nameOrARN string) (*iamdomain.InstanceProfile, error)
|
||||
ResolveInstanceProfile(ctx context.Context, accountID, nameOrARN string) (*handlers_iam.InstanceProfile, error)
|
||||
GetRole(ctx context.Context, accountID string, input *iam.GetRoleInput) (*iam.GetRoleOutput, error)
|
||||
}
|
||||
|
||||
|
||||
@@ -20,8 +20,8 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
@@ -176,7 +176,7 @@ func deleteSigningKey(ctx context.Context, kv jetstream.KeyValue, kid string) er
|
||||
// decodeSigningKey decrypts and parses one stored signing key's encrypted PKCS#8
|
||||
// PEM bytes into a SigningKey under kid.
|
||||
func decodeSigningKey(kid string, ciphertext []byte, masterKey []byte) (*SigningKey, error) {
|
||||
pemStr, err := iamdomain.DecryptSecret(string(ciphertext), masterKey)
|
||||
pemStr, err := handlers_iam.DecryptSecret(string(ciphertext), masterKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decrypt signing key %s: %w", kid, err)
|
||||
}
|
||||
@@ -203,7 +203,7 @@ func generateSigningKey(ctx context.Context, kv jetstream.KeyValue, masterKey []
|
||||
return nil, fmt.Errorf("marshal pkcs8 private key: %w", err)
|
||||
}
|
||||
pemBytes := pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: pkcs8})
|
||||
ciphertext, err := iamdomain.EncryptSecret(string(pemBytes), masterKey)
|
||||
ciphertext, err := handlers_iam.EncryptSecret(string(pemBytes), masterKey)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encrypt signing key: %w", err)
|
||||
}
|
||||
|
||||
@@ -11,8 +11,8 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
@@ -73,7 +73,7 @@ const appliancePasswordAlphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrs
|
||||
|
||||
// ApplianceRecord is the persisted singleton record for the platform Postgres
|
||||
// appliance. EncryptedPassword is AES-256-GCM ciphertext (base64, via
|
||||
// iamdomain.EncryptSecret/DecryptSecret) — the plaintext master password
|
||||
// handlers_iam.EncryptSecret/DecryptSecret) — the plaintext master password
|
||||
// is never stored, logged, or held anywhere outside a launch/Connect call.
|
||||
//
|
||||
// No exported Appliance method ever returns an ApplianceRecord: Ensure
|
||||
@@ -496,7 +496,7 @@ func (a *Appliance) getRecord(ctx context.Context) (*ApplianceRecord, uint64, er
|
||||
// decryptPassword resolves rec's encrypted master password to plaintext. The
|
||||
// error path never includes the ciphertext or any decrypted material.
|
||||
func (a *Appliance) decryptPassword(rec *ApplianceRecord) (string, error) {
|
||||
plaintext, err := iamdomain.DecryptSecret(rec.EncryptedPassword, a.masterKey)
|
||||
plaintext, err := handlers_iam.DecryptSecret(rec.EncryptedPassword, a.masterKey)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("ochrevector: decrypt appliance master password: %w", err)
|
||||
}
|
||||
@@ -511,7 +511,7 @@ func newApplianceRecord(masterKey []byte) (ApplianceRecord, string, error) {
|
||||
if err != nil {
|
||||
return ApplianceRecord{}, "", err
|
||||
}
|
||||
encrypted, err := iamdomain.EncryptSecret(password, masterKey)
|
||||
encrypted, err := handlers_iam.EncryptSecret(password, masterKey)
|
||||
if err != nil {
|
||||
return ApplianceRecord{}, "", fmt.Errorf("ochrevector: encrypt appliance master password: %w", err)
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/mulgadc/spinifex/internal/testkit"
|
||||
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
|
||||
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"
|
||||
@@ -110,7 +110,7 @@ func TestCredentialRoundTrip(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, password, appliancePasswordLength)
|
||||
|
||||
encrypted, err := iamdomain.EncryptSecret(password, masterKey)
|
||||
encrypted, err := handlers_iam.EncryptSecret(password, masterKey)
|
||||
require.NoError(t, err)
|
||||
assert.NotEqual(t, password, encrypted)
|
||||
assert.NotContains(t, encrypted, password)
|
||||
@@ -196,7 +196,7 @@ func TestEnsure_StaleProvisioningIsResumed(t *testing.T) {
|
||||
|
||||
seedPassword, err := generateMasterPassword()
|
||||
require.NoError(t, err)
|
||||
encrypted, err := iamdomain.EncryptSecret(seedPassword, masterKey)
|
||||
encrypted, err := handlers_iam.EncryptSecret(seedPassword, masterKey)
|
||||
require.NoError(t, err)
|
||||
|
||||
stale := time.Now().UTC().Add(-2 * applianceStaleAfter)
|
||||
@@ -236,7 +236,7 @@ func TestEnsure_FreshProvisioningIsWaitedNotResumed(t *testing.T) {
|
||||
|
||||
seedPassword, err := generateMasterPassword()
|
||||
require.NoError(t, err)
|
||||
encrypted, err := iamdomain.EncryptSecret(seedPassword, masterKey)
|
||||
encrypted, err := handlers_iam.EncryptSecret(seedPassword, masterKey)
|
||||
require.NoError(t, err)
|
||||
|
||||
fresh := time.Now().UTC()
|
||||
@@ -328,7 +328,7 @@ func TestResume_LaunchFailureLeavesRecordProvisioning(t *testing.T) {
|
||||
|
||||
seedPassword, err := generateMasterPassword()
|
||||
require.NoError(t, err)
|
||||
encrypted, err := iamdomain.EncryptSecret(seedPassword, masterKey)
|
||||
encrypted, err := handlers_iam.EncryptSecret(seedPassword, masterKey)
|
||||
require.NoError(t, err)
|
||||
|
||||
stale := time.Now().UTC().Add(-2 * applianceStaleAfter)
|
||||
@@ -379,7 +379,7 @@ func TestConnect_NotAvailable(t *testing.T) {
|
||||
|
||||
password, err := generateMasterPassword()
|
||||
require.NoError(t, err)
|
||||
encrypted, err := iamdomain.EncryptSecret(password, masterKey)
|
||||
encrypted, err := handlers_iam.EncryptSecret(password, masterKey)
|
||||
require.NoError(t, err)
|
||||
|
||||
now := time.Now().UTC()
|
||||
@@ -418,7 +418,7 @@ func seedAvailableAppliance(t *testing.T, appliance *Appliance, masterKey []byte
|
||||
t.Helper()
|
||||
password, err := generateMasterPassword()
|
||||
require.NoError(t, err)
|
||||
encrypted, err := iamdomain.EncryptSecret(password, masterKey)
|
||||
encrypted, err := handlers_iam.EncryptSecret(password, masterKey)
|
||||
require.NoError(t, err)
|
||||
|
||||
now := time.Now().UTC()
|
||||
@@ -800,7 +800,7 @@ func TestDecryptPassword_WrongKeyErrors(t *testing.T) {
|
||||
|
||||
password, err := generateMasterPassword()
|
||||
require.NoError(t, err)
|
||||
encrypted, err := iamdomain.EncryptSecret(password, rightKey)
|
||||
encrypted, err := handlers_iam.EncryptSecret(password, rightKey)
|
||||
require.NoError(t, err)
|
||||
|
||||
appliance := &Appliance{masterKey: wrongKey}
|
||||
|
||||
@@ -658,22 +658,13 @@ func HasDependencies(kind, id string) error {
|
||||
// retryableError wraps a registered code with a suggested Retry-After
|
||||
// duration, for a 503 that a client can expect to clear on its own within a
|
||||
// bounded window (e.g. a warm-up race) rather than an open-ended outage.
|
||||
// Error() returns only the code, or the wrapped error's text, so code
|
||||
// resolution is unaffected.
|
||||
// Error() returns only the code, so code resolution is unaffected.
|
||||
type retryableError struct {
|
||||
code string
|
||||
err error
|
||||
retryAfter time.Duration
|
||||
}
|
||||
|
||||
func (e *retryableError) Error() string {
|
||||
if e.err != nil {
|
||||
return e.err.Error()
|
||||
}
|
||||
return e.code
|
||||
}
|
||||
|
||||
func (e *retryableError) Unwrap() error { return e.err }
|
||||
func (e *retryableError) Error() string { return e.code }
|
||||
|
||||
// RetryAfter wraps code (a registered ErrorLookup key) with a suggested
|
||||
// Retry-After duration for ErrorHandler to surface as a response header.
|
||||
@@ -681,13 +672,6 @@ func RetryAfter(code string, d time.Duration) error {
|
||||
return &retryableError{code: code, retryAfter: d}
|
||||
}
|
||||
|
||||
// WithRetryAfter attaches a suggested Retry-After duration to err without
|
||||
// changing its text, unlike joining err with RetryAfter, which appends the
|
||||
// bare code on a second line.
|
||||
func WithRetryAfter(err error, d time.Duration) error {
|
||||
return &retryableError{err: err, retryAfter: d}
|
||||
}
|
||||
|
||||
// ResolveRetryAfter returns the Retry-After duration attached to err via
|
||||
// RetryAfter, if any, walking the same unwrap tree ResolveErrorDetail does.
|
||||
func ResolveRetryAfter(err error) (time.Duration, bool) {
|
||||
|
||||
@@ -176,24 +176,6 @@ func TestResolveRetryAfter_WrappedAndResolvable(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// WithRetryAfter keeps the coded error's text and message while still
|
||||
// carrying the hint: a joined RetryAfter would log the code twice.
|
||||
func TestWithRetryAfter_KeepsTextAndResolves(t *testing.T) {
|
||||
inner := Errorf(ErrorServiceUnavailable, "probe incomplete")
|
||||
err := WithRetryAfter(inner, 5*time.Second)
|
||||
|
||||
if err.Error() != inner.Error() {
|
||||
t.Fatalf("Error() = %q, want %q", err.Error(), inner.Error())
|
||||
}
|
||||
if d, ok := ResolveRetryAfter(fmt.Errorf("wrapped: %w", err)); !ok || d != 5*time.Second {
|
||||
t.Fatalf("ResolveRetryAfter() = (%v, %v), want (5s, true)", d, ok)
|
||||
}
|
||||
code, msg, ok := ResolveErrorDetail(err)
|
||||
if !ok || code != ErrorServiceUnavailable || msg != "probe incomplete" {
|
||||
t.Fatalf("ResolveErrorDetail() = (%q, %q, %v), want (%q, %q, true)", code, msg, ok, ErrorServiceUnavailable, "probe incomplete")
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveRetryAfter_PlainErrorNotFound is the compatibility case: an
|
||||
// ordinary errors.New(code) -- the overwhelming majority of call sites --
|
||||
// carries no Retry-After hint.
|
||||
|
||||
@@ -5,22 +5,22 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/mulgadc/bluebottle/pkg/iampolicy"
|
||||
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
|
||||
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
|
||||
)
|
||||
|
||||
// doc builds a single-statement policy document.
|
||||
func doc(effect, action, resource string) iamdomain.PolicyDocument {
|
||||
return iamdomain.PolicyDocument{
|
||||
func doc(effect, action, resource string) handlers_iam.PolicyDocument {
|
||||
return handlers_iam.PolicyDocument{
|
||||
Version: "2012-10-17",
|
||||
Statement: []iamdomain.Statement{
|
||||
{Effect: effect, Action: iamdomain.StringOrArr{action}, Resource: iamdomain.StringOrArr{resource}},
|
||||
Statement: []handlers_iam.Statement{
|
||||
{Effect: effect, Action: handlers_iam.StringOrArr{action}, Resource: handlers_iam.StringOrArr{resource}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// The deny-wins algorithm and wildcard matcher are unit-tested in predastore's
|
||||
// pkg/iampolicy. These tests pin spinifex's own action-string conventions end to
|
||||
// end through iampolicy.Evaluate, exercising the aliased domains/iam DTOs.
|
||||
// end through iampolicy.Evaluate, exercising the aliased handlers_iam DTOs.
|
||||
|
||||
// TestEvaluate_IAMInstanceProfileActionStrings pins the action strings for
|
||||
// iam:PassRole + the four EC2 instance-profile association actions so a rename of
|
||||
@@ -34,7 +34,7 @@ func TestEvaluate_IAMInstanceProfileActionStrings(t *testing.T) {
|
||||
"ec2:ReplaceIamInstanceProfileAssociation",
|
||||
"ec2:DescribeIamInstanceProfileAssociations",
|
||||
}
|
||||
policies := []iamdomain.PolicyDocument{
|
||||
policies := []handlers_iam.PolicyDocument{
|
||||
doc("Allow", "*", "*"),
|
||||
}
|
||||
for _, a := range actions {
|
||||
@@ -43,7 +43,7 @@ func TestEvaluate_IAMInstanceProfileActionStrings(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
scoped := []iamdomain.PolicyDocument{
|
||||
scoped := []handlers_iam.PolicyDocument{
|
||||
doc("Allow", "ec2:*IamInstanceProfile*", "*"),
|
||||
doc("Allow", "iam:PassRole", "arn:aws:iam::*:role/*"),
|
||||
}
|
||||
@@ -80,8 +80,8 @@ func TestEvaluate_STSActionStrings(t *testing.T) {
|
||||
"sts:GetCallerIdentity",
|
||||
}
|
||||
|
||||
wildcard := []iamdomain.PolicyDocument{doc("Allow", "*", "*")}
|
||||
scoped := []iamdomain.PolicyDocument{doc("Allow", "sts:*", "*")}
|
||||
wildcard := []handlers_iam.PolicyDocument{doc("Allow", "*", "*")}
|
||||
scoped := []handlers_iam.PolicyDocument{doc("Allow", "sts:*", "*")}
|
||||
|
||||
for _, a := range actions {
|
||||
if got := iampolicy.EvaluateWithKeys(a, "*", wildcard, nil); got != iampolicy.Allow {
|
||||
@@ -119,7 +119,7 @@ func TestEvaluate_ResourceScopedStatementsNeedARealARN(t *testing.T) {
|
||||
const prod = "arn:aws:ec2:ap-southeast-2:123456789012:instance/i-prod"
|
||||
const dev = "arn:aws:ec2:ap-southeast-2:123456789012:instance/i-dev"
|
||||
|
||||
fenced := []iamdomain.PolicyDocument{
|
||||
fenced := []handlers_iam.PolicyDocument{
|
||||
doc("Allow", "ec2:*", "*"),
|
||||
doc("Deny", "ec2:TerminateInstances", "arn:aws:ec2:*:*:instance/i-prod"),
|
||||
}
|
||||
@@ -133,7 +133,7 @@ func TestEvaluate_ResourceScopedStatementsNeedARealARN(t *testing.T) {
|
||||
t.Errorf("fenced policy against an unfenced instance: got %v, want Allow", got)
|
||||
}
|
||||
|
||||
scopedAllow := []iamdomain.PolicyDocument{
|
||||
scopedAllow := []handlers_iam.PolicyDocument{
|
||||
doc("Allow", "ec2:*", "arn:aws:ec2:*:*:instance/i-dev"),
|
||||
}
|
||||
if got := iampolicy.EvaluateWithKeys("ec2:TerminateInstances", "*", scopedAllow, nil); got != iampolicy.Deny {
|
||||
@@ -161,7 +161,7 @@ func TestEvaluate_IAMResourceARNs(t *testing.T) {
|
||||
"arn:aws:iam::123456789012:oidc-provider/issuer.example/id/cluster",
|
||||
}
|
||||
for _, resource := range resources {
|
||||
policies := []iamdomain.PolicyDocument{doc("Allow", "iam:Delete*", resource)}
|
||||
policies := []handlers_iam.PolicyDocument{doc("Allow", "iam:Delete*", resource)}
|
||||
if got := iampolicy.EvaluateWithKeys("iam:DeleteRole", resource, policies, nil); got != iampolicy.Allow {
|
||||
t.Errorf("IAM scoped policy against %q: got %v, want Allow", resource, got)
|
||||
}
|
||||
@@ -176,7 +176,7 @@ func TestEvaluate_PassingARealARNCannotWithdrawAccess(t *testing.T) {
|
||||
"arn:aws:ec2:ap-southeast-2:123456789012:instance/i-abc/admin",
|
||||
"not-an-arn",
|
||||
}
|
||||
policies := [][]iamdomain.PolicyDocument{
|
||||
policies := [][]handlers_iam.PolicyDocument{
|
||||
{doc("Allow", "ec2:*", "*")},
|
||||
{doc("Allow", "*", "*")},
|
||||
}
|
||||
|
||||
@@ -287,7 +287,7 @@ func TestNATSRequest_ErrorResponse(t *testing.T) {
|
||||
}
|
||||
|
||||
// TestNATSRequest_DecodedErrorContainsCode pins the shape strings.Contains(err.Error(), code)
|
||||
// classifiers depend on (e.g. domains/iam/provision.go's isAlreadyExists,
|
||||
// classifiers depend on (e.g. handlers/iam/provision.go's isAlreadyExists,
|
||||
// gateway/auth.go's IAM-not-found branch): the decoded error's Error() text
|
||||
// must still contain the wire code even when a message rides alongside it.
|
||||
func TestNATSRequest_DecodedErrorContainsCode(t *testing.T) {
|
||||
|
||||
@@ -17,8 +17,8 @@ import (
|
||||
"github.com/mulgadc/bluebottle/pkg/sigv4"
|
||||
"github.com/mulgadc/spinifex/internal/testkit"
|
||||
acmawsapi "github.com/mulgadc/spinifex/spinifex/domains/acm/awsapi"
|
||||
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/telemetry"
|
||||
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
|
||||
"github.com/mulgadc/spinifex/spinifex/ingress/aws/envelope"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -31,9 +31,9 @@ import (
|
||||
// acmPipelineApp wires SigV4AuthMiddleware -> Request as SetupRoutes does, on
|
||||
// live test NATS, with docs as every principal's policy. The region is
|
||||
// testRegion because signTestRequestAs always signs for it.
|
||||
func acmPipelineApp(t *testing.T, docs []iamdomain.PolicyDocument) (*GatewayConfig, http.Handler) {
|
||||
func acmPipelineApp(t *testing.T, docs []handlers_iam.PolicyDocument) (*GatewayConfig, http.Handler) {
|
||||
t.Helper()
|
||||
encryptedSecret, err := iamdomain.EncryptSecret(testSecretKey, testMasterKey)
|
||||
encryptedSecret, err := handlers_iam.EncryptSecret(testSecretKey, testMasterKey)
|
||||
require.NoError(t, err)
|
||||
_, nc := testutil.StartTestNATS(t)
|
||||
|
||||
@@ -44,7 +44,7 @@ func acmPipelineApp(t *testing.T, docs []iamdomain.PolicyDocument) (*GatewayConf
|
||||
IAMService: &policyMockIAMService{
|
||||
mockIAMService: mockIAMService{
|
||||
masterKey: testMasterKey,
|
||||
accessKeys: map[string]*iamdomain.AccessKey{
|
||||
accessKeys: map[string]*handlers_iam.AccessKey{
|
||||
testAccessKey: {
|
||||
AccessKeyID: testAccessKey,
|
||||
SecretAccessKey: encryptedSecret,
|
||||
@@ -54,7 +54,7 @@ func acmPipelineApp(t *testing.T, docs []iamdomain.PolicyDocument) (*GatewayConf
|
||||
},
|
||||
},
|
||||
},
|
||||
getUserPoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) { return docs, nil },
|
||||
getUserPoliciesFn: func(_, _ string) ([]handlers_iam.PolicyDocument, error) { return docs, nil },
|
||||
},
|
||||
}
|
||||
|
||||
@@ -68,11 +68,11 @@ func acmPipelineApp(t *testing.T, docs []iamdomain.PolicyDocument) (*GatewayConf
|
||||
|
||||
// allowAllACMDocs is the policy acmPipelineApp uses when the test is not
|
||||
// exercising authorization itself.
|
||||
func allowAllACMDocs() []iamdomain.PolicyDocument {
|
||||
return []iamdomain.PolicyDocument{{
|
||||
func allowAllACMDocs() []handlers_iam.PolicyDocument {
|
||||
return []handlers_iam.PolicyDocument{{
|
||||
Version: "2012-10-17",
|
||||
Statement: []iamdomain.Statement{
|
||||
{Effect: "Allow", Action: iamdomain.StringOrArr{"*"}, Resource: iamdomain.StringOrArr{"*"}},
|
||||
Statement: []handlers_iam.Statement{
|
||||
{Effect: "Allow", Action: handlers_iam.StringOrArr{"*"}, Resource: handlers_iam.StringOrArr{"*"}},
|
||||
},
|
||||
}}
|
||||
}
|
||||
@@ -209,13 +209,13 @@ func TestACMPipeline_OversizedBody_413OverHTTP(t *testing.T) {
|
||||
// dispatch runs, and denies, before the NATS-backed action handler is ever
|
||||
// invoked, through the full pipeline.
|
||||
func TestACMPipeline_AuthzGateBlocksAction(t *testing.T) {
|
||||
scopedDocs := []iamdomain.PolicyDocument{{
|
||||
scopedDocs := []handlers_iam.PolicyDocument{{
|
||||
Version: "2012-10-17",
|
||||
Statement: []iamdomain.Statement{
|
||||
Statement: []handlers_iam.Statement{
|
||||
{
|
||||
Effect: "Allow",
|
||||
Action: iamdomain.StringOrArr{"acm:DeleteCertificate"},
|
||||
Resource: iamdomain.StringOrArr{"arn:aws:acm:" + testRegion + ":" + authzAccountID + ":certificate/other"},
|
||||
Action: handlers_iam.StringOrArr{"acm:DeleteCertificate"},
|
||||
Resource: handlers_iam.StringOrArr{"arn:aws:acm:" + testRegion + ":" + authzAccountID + ":certificate/other"},
|
||||
},
|
||||
},
|
||||
}}
|
||||
|
||||
@@ -43,9 +43,6 @@ func (gw *GatewayConfig) adminGetAccountQuota(ctx context.Context, body []byte)
|
||||
|
||||
over, limits, err := gw.Quota.GetAccountQuota(ctx, accountID)
|
||||
if err != nil {
|
||||
if awserrors.IsErrorCode(err, awserrors.ErrorBlocked) {
|
||||
return nil, err
|
||||
}
|
||||
slog.ErrorContext(ctx, "GetAccountQuota: failed to read override", "accountID", accountID, "err", err)
|
||||
return nil, errors.New(awserrors.ErrorInternalError)
|
||||
}
|
||||
@@ -64,9 +61,6 @@ func (gw *GatewayConfig) adminPutAccountQuota(ctx context.Context, body []byte)
|
||||
// The caller's own identity, so a quota change names who made it.
|
||||
updatedBy, _ := ctx.Value(ctxIdentity).(string)
|
||||
if err := gw.Quota.PutAccountQuota(ctx, accountID, over, updatedBy); err != nil {
|
||||
if awserrors.IsErrorCode(err, awserrors.ErrorBlocked) {
|
||||
return nil, err
|
||||
}
|
||||
slog.ErrorContext(ctx, "PutAccountQuota: failed to store override", "accountID", accountID, "err", err)
|
||||
return nil, errors.New(awserrors.ErrorInternalError)
|
||||
}
|
||||
@@ -75,9 +69,6 @@ func (gw *GatewayConfig) adminPutAccountQuota(ctx context.Context, body []byte)
|
||||
|
||||
stored, limits, err := gw.Quota.GetAccountQuota(ctx, accountID)
|
||||
if err != nil {
|
||||
if awserrors.IsErrorCode(err, awserrors.ErrorBlocked) {
|
||||
return nil, err
|
||||
}
|
||||
slog.ErrorContext(ctx, "PutAccountQuota: failed to read back override", "accountID", accountID, "err", err)
|
||||
return nil, errors.New(awserrors.ErrorInternalError)
|
||||
}
|
||||
|
||||
@@ -9,8 +9,8 @@ import (
|
||||
|
||||
"github.com/mulgadc/spinifex/internal/testkit"
|
||||
admissionquota "github.com/mulgadc/spinifex/spinifex/domains/admission/quota"
|
||||
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -25,11 +25,11 @@ type quotaIAMService struct {
|
||||
known string
|
||||
}
|
||||
|
||||
func (m *quotaIAMService) GetAccount(accountID string) (*iamdomain.Account, error) {
|
||||
func (m *quotaIAMService) GetAccount(accountID string) (*handlers_iam.Account, error) {
|
||||
if accountID != m.known {
|
||||
return nil, errors.New(awserrors.ErrorIAMNoSuchEntity)
|
||||
}
|
||||
return &iamdomain.Account{AccountID: accountID, Status: iamdomain.AccountStatusActive}, nil
|
||||
return &handlers_iam.Account{AccountID: accountID, Status: handlers_iam.AccountStatusActive}, nil
|
||||
}
|
||||
|
||||
// quotaTestGateway wires a real quota Service over a real JetStream KV bucket,
|
||||
@@ -205,56 +205,3 @@ func TestAccountQuotaMethodsAreRegistered(t *testing.T) {
|
||||
require.Contains(t, AdminMethodNames(), "GetAccountQuota")
|
||||
require.Contains(t, AdminMethodNames(), "PutAccountQuota")
|
||||
}
|
||||
|
||||
// quotaStubChecker answers AccountTerminating from a fixed set, so these
|
||||
// tests can seal a record without a real IAM bucket.
|
||||
type quotaStubChecker struct {
|
||||
terminating map[string]bool
|
||||
}
|
||||
|
||||
func (c *quotaStubChecker) AccountTerminating(_ context.Context, accountID string) (bool, error) {
|
||||
return c.terminating[accountID], nil
|
||||
}
|
||||
|
||||
// A request body cannot carry or alter the stored state: an attempted
|
||||
// "state":"sealed" in the overrides object is simply unknown to the wire
|
||||
// type, the stored record stays active, and the response never shows state.
|
||||
func TestAdminRequestCannotCarryState(t *testing.T) {
|
||||
gw, account := quotaTestGateway(t)
|
||||
ctx := context.WithValue(t.Context(), ctxIdentity, "operator")
|
||||
|
||||
body := []byte(`{"accountId":"` + account + `","overrides":{"vcpus":32,"state":"sealed"}}`)
|
||||
output, err := gw.adminPutAccountQuota(ctx, body)
|
||||
require.NoError(t, err)
|
||||
|
||||
resp, ok := output.(*AccountQuotaResponse)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, 32, resp.Limits["vcpus"])
|
||||
|
||||
data, err := json.Marshal(resp.Overrides)
|
||||
require.NoError(t, err)
|
||||
require.NotContains(t, string(data), "state")
|
||||
|
||||
_, _, err = gw.Quota.GetAccountQuota(ctx, account)
|
||||
require.NoError(t, err, "the record must still be active, not sealed")
|
||||
}
|
||||
|
||||
// Admin get and the put read-back on a sealed record must return Blocked,
|
||||
// not the generic InternalError every other read failure maps to.
|
||||
func TestAdminQuotaReadsMapBlocked(t *testing.T) {
|
||||
gw, account := quotaTestGateway(t)
|
||||
gw.Quota.SetAccountTerminationChecker("aStubChecker{terminating: map[string]bool{account: true}})
|
||||
_, err := gw.Quota.SealAccountOverrides(t.Context(), account)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = gw.adminGetAccountQuota(t.Context(), quotaRequestBody(t, AccountQuotaRequest{AccountID: account}))
|
||||
require.Error(t, err)
|
||||
require.Equal(t, awserrors.ErrorBlocked, err.Error())
|
||||
|
||||
ctx := context.WithValue(t.Context(), ctxIdentity, "operator")
|
||||
_, err = gw.adminPutAccountQuota(ctx, quotaRequestBody(t, AccountQuotaRequest{
|
||||
AccountID: account, Overrides: admissionquota.Overrides{VCPUs: quotaPtr(8)},
|
||||
}))
|
||||
require.Error(t, err)
|
||||
require.Equal(t, awserrors.ErrorBlocked, err.Error())
|
||||
}
|
||||
|
||||
@@ -13,9 +13,9 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/mulgadc/spinifex/contracts/ec2/v1"
|
||||
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
@@ -141,7 +141,7 @@ func (gw *GatewayConfig) adminCreateAccount(ctx context.Context, body []byte) (a
|
||||
|
||||
record, err := json.Marshal(adminIdemRecord{
|
||||
State: adminIdemDone,
|
||||
Name: iamdomain.NormalizeAccountName(req.Name),
|
||||
Name: handlers_iam.NormalizeAccountName(req.Name),
|
||||
Response: &stored,
|
||||
StartedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
})
|
||||
@@ -163,12 +163,12 @@ func (gw *GatewayConfig) adminCreateAccount(ctx context.Context, body []byte) (a
|
||||
// admin identity and waits for the default VPC.
|
||||
func (gw *GatewayConfig) provisionNewAccount(
|
||||
ctx context.Context,
|
||||
names *iamdomain.AccountNameIndex,
|
||||
names *handlers_iam.AccountNameIndex,
|
||||
req *CreateAccountRequest,
|
||||
) (*CreateAccountResponse, error) {
|
||||
// Accounts that predate the reservation index have no entry to collide
|
||||
// with, so they are found by name scan. The same listing bounds the cap.
|
||||
existing, err := iamdomain.FindAccountByName(gw.IAMService, req.Name)
|
||||
existing, err := handlers_iam.FindAccountByName(gw.IAMService, req.Name)
|
||||
if err != nil {
|
||||
slog.Error("CreateAccount: failed to list accounts", "err", err)
|
||||
return nil, errors.New(awserrors.ErrorInternalError)
|
||||
@@ -181,16 +181,16 @@ func (gw *GatewayConfig) provisionNewAccount(
|
||||
}
|
||||
|
||||
switch err := names.Reserve(ctx, req.Name, req.ClientToken); {
|
||||
case errors.Is(err, iamdomain.ErrNameTaken):
|
||||
case errors.Is(err, handlers_iam.ErrNameTaken):
|
||||
return nil, errors.New(awserrors.ErrorAccountAlreadyExists)
|
||||
case errors.Is(err, iamdomain.ErrNameInFlight):
|
||||
case errors.Is(err, handlers_iam.ErrNameInFlight):
|
||||
return nil, errors.New(awserrors.ErrorOperationInProgress)
|
||||
case err != nil:
|
||||
slog.Error("CreateAccount: failed to reserve account name", "err", err)
|
||||
return nil, errors.New(awserrors.ErrorInternalError)
|
||||
}
|
||||
|
||||
account, err := gw.IAMService.CreateAccount(iamdomain.NormalizeAccountName(req.Name))
|
||||
account, err := gw.IAMService.CreateAccount(handlers_iam.NormalizeAccountName(req.Name))
|
||||
if err != nil {
|
||||
if relErr := names.Release(ctx, req.Name, req.ClientToken); relErr != nil {
|
||||
slog.Error("CreateAccount: failed to release name reservation", "err", relErr)
|
||||
@@ -208,7 +208,7 @@ func (gw *GatewayConfig) provisionNewAccount(
|
||||
return nil, errors.New(awserrors.ErrorInternalError)
|
||||
}
|
||||
|
||||
provisioned, err := iamdomain.ProvisionAccount(gw.IAMService, account.AccountID, account.AccountName)
|
||||
provisioned, err := handlers_iam.ProvisionAccount(gw.IAMService, account.AccountID, account.AccountName)
|
||||
if err != nil {
|
||||
slog.Error("CreateAccount: failed to provision account",
|
||||
"accountID", account.AccountID, "err", err)
|
||||
@@ -239,7 +239,7 @@ func (gw *GatewayConfig) provisionNewAccount(
|
||||
// response when the token has already completed, meaning the caller should
|
||||
// replay it rather than provision again.
|
||||
func claimClientToken(ctx context.Context, idem jetstream.KeyValue, clientToken, name string) (*CreateAccountResponse, error) {
|
||||
normalized := iamdomain.NormalizeAccountName(name)
|
||||
normalized := handlers_iam.NormalizeAccountName(name)
|
||||
claim, err := json.Marshal(adminIdemRecord{
|
||||
State: adminIdemInProgress,
|
||||
Name: normalized,
|
||||
@@ -340,14 +340,14 @@ func (gw *GatewayConfig) ensureDefaultVPC(ctx context.Context, accountID string)
|
||||
// adminStores opens the name index and the idempotency bucket. Both are opened
|
||||
// per request rather than cached on the gateway so a bucket created after
|
||||
// startup (a cluster upgraded into this feature) is picked up without a restart.
|
||||
func (gw *GatewayConfig) adminStores(ctx context.Context) (*iamdomain.AccountNameIndex, jetstream.KeyValue, error) {
|
||||
func (gw *GatewayConfig) adminStores(ctx context.Context) (*handlers_iam.AccountNameIndex, jetstream.KeyValue, error) {
|
||||
js, err := jetstream.New(gw.NATSConn)
|
||||
if err != nil {
|
||||
slog.Error("CreateAccount: failed to get JetStream context", "err", err)
|
||||
return nil, nil, errors.New(awserrors.ErrorServiceUnavailable)
|
||||
}
|
||||
|
||||
names, err := iamdomain.NewAccountNameIndex(ctx, js)
|
||||
names, err := handlers_iam.NewAccountNameIndex(ctx, js)
|
||||
if err != nil {
|
||||
slog.Error("CreateAccount: failed to open account name index", "err", err)
|
||||
return nil, nil, errors.New(awserrors.ErrorServiceUnavailable)
|
||||
|
||||
@@ -14,9 +14,9 @@ import (
|
||||
|
||||
"github.com/mulgadc/spinifex/contracts/ec2/v1"
|
||||
"github.com/mulgadc/spinifex/internal/testkit"
|
||||
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -27,17 +27,17 @@ const testClientToken = "0123456789abcdef0123456789abcdef"
|
||||
|
||||
// capMockIAMService reports a fixed account count for the cap check.
|
||||
type capMockIAMService struct {
|
||||
iamdomain.IAMService
|
||||
handlers_iam.IAMService
|
||||
|
||||
accounts int
|
||||
err error
|
||||
}
|
||||
|
||||
func (m *capMockIAMService) ListAccounts() ([]*iamdomain.Account, error) {
|
||||
func (m *capMockIAMService) ListAccounts() ([]*handlers_iam.Account, error) {
|
||||
if m.err != nil {
|
||||
return nil, m.err
|
||||
}
|
||||
return make([]*iamdomain.Account, m.accounts), nil
|
||||
return make([]*handlers_iam.Account, m.accounts), nil
|
||||
}
|
||||
|
||||
func TestValidateCreateAccountRequest(t *testing.T) {
|
||||
@@ -239,9 +239,9 @@ func newCreateAccountGateway(t *testing.T) *GatewayConfig {
|
||||
t.Helper()
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
|
||||
masterKey, err := iamdomain.GenerateMasterKey()
|
||||
masterKey, err := handlers_iam.GenerateMasterKey()
|
||||
require.NoError(t, err)
|
||||
svc, err := iamdomain.NewIAMServiceImpl(t.Context(), nc, masterKey)
|
||||
svc, err := handlers_iam.NewIAMServiceImpl(t.Context(), nc, masterKey)
|
||||
require.NoError(t, err)
|
||||
|
||||
sub, err := nc.Subscribe(ec2v1.EnsureDefaultVpcSubject, func(msg *nats.Msg) {
|
||||
@@ -276,7 +276,7 @@ func TestAdminCreateAccountProvisionsAndReplays(t *testing.T) {
|
||||
|
||||
assert.NotEmpty(t, created.AccountID)
|
||||
assert.Equal(t, "ben@example.com", created.AccountName)
|
||||
assert.Equal(t, iamdomain.AdminUserName, created.AdminUser)
|
||||
assert.Equal(t, handlers_iam.AdminUserName, created.AdminUser)
|
||||
assert.NotEmpty(t, created.AccessKeyID)
|
||||
assert.NotEmpty(t, created.SecretAccessKey)
|
||||
assert.Equal(t, "vpc-0a1b2c3d", created.DefaultVpcID)
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/accountteardown"
|
||||
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
|
||||
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -23,14 +23,14 @@ const dryRunShadowMessage = "Account teardown dry-run EC2 platform shadow compar
|
||||
// deleteAccountGrant is the real IAM service with a policy granting
|
||||
// spinifex:DeleteAccount, so a request clears the admin gates.
|
||||
type deleteAccountGrant struct {
|
||||
iamdomain.IAMService
|
||||
handlers_iam.IAMService
|
||||
}
|
||||
|
||||
func (deleteAccountGrant) GetUserPolicies(string, string) ([]iamdomain.PolicyDocument, error) {
|
||||
return []iamdomain.PolicyDocument{{
|
||||
func (deleteAccountGrant) GetUserPolicies(string, string) ([]handlers_iam.PolicyDocument, error) {
|
||||
return []handlers_iam.PolicyDocument{{
|
||||
Version: "2012-10-17",
|
||||
Statement: []iamdomain.Statement{{
|
||||
Effect: "Allow", Action: iamdomain.StringOrArr{"spinifex:DeleteAccount"}, Resource: iamdomain.StringOrArr{"*"},
|
||||
Statement: []handlers_iam.Statement{{
|
||||
Effect: "Allow", Action: handlers_iam.StringOrArr{"spinifex:DeleteAccount"}, Resource: handlers_iam.StringOrArr{"*"},
|
||||
}},
|
||||
}}, nil
|
||||
}
|
||||
|
||||
@@ -12,12 +12,11 @@ import (
|
||||
"time"
|
||||
|
||||
"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/accountteardown"
|
||||
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/messaging/nats"
|
||||
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
|
||||
"github.com/mulgadc/spinifex/spinifex/providers/objectstore"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
@@ -32,10 +31,6 @@ const deleteClientToken = "8b9c1d2e3f4a5b6c7d8e9f0a1b2c3d4e"
|
||||
// to keep step with the reapers NewClusterEngine wires.
|
||||
var teardownSubjects = []string{"ec2.>", "ecs.>", "eks.>", "rds.>", "elbv2.>", "acm.>", "bedrock.>"}
|
||||
|
||||
// sealCapabilitySubjectPrefix matches every per-node seal-capability subject,
|
||||
// which the generic ec2.> stub below must not also answer.
|
||||
const sealCapabilitySubjectPrefix = "ec2.v1.AccountSettingsSealCapability."
|
||||
|
||||
// newDeleteAccountGateway wires a gateway against a real IAM service and
|
||||
// embedded JetStream, with a stub cluster answering the subjects the reapers
|
||||
// drive. The tenant has no resources, so every stage drains empty and the test
|
||||
@@ -44,19 +39,15 @@ func newDeleteAccountGateway(t *testing.T) *GatewayConfig {
|
||||
t.Helper()
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
|
||||
masterKey, err := iamdomain.GenerateMasterKey()
|
||||
masterKey, err := handlers_iam.GenerateMasterKey()
|
||||
require.NoError(t, err)
|
||||
svc, err := iamdomain.NewIAMServiceImpl(t.Context(), nc, masterKey)
|
||||
svc, err := handlers_iam.NewIAMServiceImpl(t.Context(), nc, masterKey)
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, subject := range teardownSubjects {
|
||||
// Replies carry a node ID: the instance inventory only counts a frame
|
||||
// it can attribute to a configured node. The seal subjects have their
|
||||
// own responders below, which a second reply here would race.
|
||||
// it can attribute to a configured node.
|
||||
sub, err := nc.Subscribe(subject, func(msg *nats.Msg) {
|
||||
if msg.Subject == ec2v1.SealAccountSettingsSubject || strings.HasPrefix(msg.Subject, sealCapabilitySubjectPrefix) {
|
||||
return
|
||||
}
|
||||
reply := nats.NewMsg(msg.Reply)
|
||||
reply.Data = []byte(`{}`)
|
||||
reply.Header.Set(natsmsg.NodeIDHeader, "node-1")
|
||||
@@ -65,38 +56,6 @@ func newDeleteAccountGateway(t *testing.T) *GatewayConfig {
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = sub.Unsubscribe() })
|
||||
}
|
||||
|
||||
// The seal reaper needs a real sealed disposition and revision, and every
|
||||
// configured node answering capability version 1, or it never finalises:
|
||||
// the generic `{}` stub above is neither.
|
||||
sealSub, err := nc.Subscribe(ec2v1.SealAccountSettingsSubject, func(msg *nats.Msg) {
|
||||
var req ec2v1.SealAccountSettingsRequest
|
||||
if json.Unmarshal(msg.Data, &req) != nil {
|
||||
return
|
||||
}
|
||||
data, _ := json.Marshal(ec2v1.SealAccountSettingsReply{
|
||||
AccountID: req.AccountID, CorrelationRef: req.CorrelationRef,
|
||||
Disposition: ec2v1.AccountSettingsDispositionSealed, Revision: 1,
|
||||
})
|
||||
reply := nats.NewMsg(msg.Reply)
|
||||
reply.Data = data
|
||||
reply.Header.Set(natsmsg.NodeIDHeader, "node-1")
|
||||
_ = msg.RespondMsg(reply)
|
||||
})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = sealSub.Unsubscribe() })
|
||||
|
||||
for _, node := range []string{"node-1"} {
|
||||
capSub, err := nc.Subscribe(ec2v1.AccountSettingsSealCapabilitySubject(node), func(msg *nats.Msg) {
|
||||
data, _ := json.Marshal(ec2v1.AccountSettingsSealCapabilityReply{Node: node, CapabilityVersion: ec2v1.AccountSettingsSealCapabilityVersion})
|
||||
reply := nats.NewMsg(msg.Reply)
|
||||
reply.Data = data
|
||||
reply.Header.Set(natsmsg.NodeIDHeader, node)
|
||||
_ = msg.RespondMsg(reply)
|
||||
})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = capSub.Unsubscribe() })
|
||||
}
|
||||
require.NoError(t, nc.Flush())
|
||||
|
||||
return &GatewayConfig{
|
||||
@@ -139,7 +98,7 @@ func (emptyBucketStore) DeleteBucket(context.Context, string) error { return nil
|
||||
// newTenantAccount creates an account that is safe to delete. Account ids are
|
||||
// sequential and the first belongs to the super admin, which is protected, so
|
||||
// the tenant under test can never be the first one created.
|
||||
func newTenantAccount(t *testing.T, gw *GatewayConfig, name string) *iamdomain.Account {
|
||||
func newTenantAccount(t *testing.T, gw *GatewayConfig, name string) *handlers_iam.Account {
|
||||
t.Helper()
|
||||
if accounts, err := gw.IAMService.ListAccounts(); err == nil && len(accounts) == 0 {
|
||||
_, err := gw.IAMService.CreateAccount("super-admin@example.com")
|
||||
@@ -207,7 +166,7 @@ func TestAdminDeleteAccountDryRunChangesNothing(t *testing.T) {
|
||||
|
||||
stored, err := gw.IAMService.GetAccount(account.AccountID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, iamdomain.AccountStatusActive, stored.Status)
|
||||
assert.Equal(t, handlers_iam.AccountStatusActive, stored.Status)
|
||||
|
||||
// No job record means a later real delete is not answered as a replay.
|
||||
_, err = gw.adminDescribeAccountDeletion(t.Context(),
|
||||
@@ -428,7 +387,7 @@ func TestAdminDeleteAccountRefusesAMismatchedName(t *testing.T) {
|
||||
|
||||
stored, err := gw.IAMService.GetAccount(account.AccountID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, iamdomain.AccountStatusActive, stored.Status)
|
||||
assert.Equal(t, handlers_iam.AccountStatusActive, stored.Status)
|
||||
}
|
||||
|
||||
func TestAdminDeleteAccountRejectsAnUnknownAccount(t *testing.T) {
|
||||
|
||||
@@ -13,8 +13,8 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/accountteardown"
|
||||
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -23,11 +23,11 @@ import (
|
||||
type listAccountsIAMService struct {
|
||||
policyMockIAMService
|
||||
|
||||
accounts []*iamdomain.Account
|
||||
accounts []*handlers_iam.Account
|
||||
err error
|
||||
}
|
||||
|
||||
func (m *listAccountsIAMService) ListAccounts() ([]*iamdomain.Account, error) {
|
||||
func (m *listAccountsIAMService) ListAccounts() ([]*handlers_iam.Account, error) {
|
||||
return m.accounts, m.err
|
||||
}
|
||||
|
||||
@@ -177,10 +177,10 @@ func TestTeardownErrorMapping(t *testing.T) {
|
||||
// that is how a stuck TERMINATING account is noticed — and no key material.
|
||||
func TestAdminListAccountsReportsStatus(t *testing.T) {
|
||||
gw := &GatewayConfig{DisableLogging: true, IAMService: &listAccountsIAMService{
|
||||
accounts: []*iamdomain.Account{
|
||||
{AccountID: "000000000042", AccountName: "t@example.com", Status: iamdomain.AccountStatusActive},
|
||||
accounts: []*handlers_iam.Account{
|
||||
{AccountID: "000000000042", AccountName: "t@example.com", Status: handlers_iam.AccountStatusActive},
|
||||
nil,
|
||||
{AccountID: "000000000043", AccountName: "u@example.com", Status: iamdomain.AccountStatusTerminating},
|
||||
{AccountID: "000000000043", AccountName: "u@example.com", Status: handlers_iam.AccountStatusTerminating},
|
||||
},
|
||||
}}
|
||||
|
||||
@@ -190,8 +190,8 @@ func TestAdminListAccountsReportsStatus(t *testing.T) {
|
||||
response, ok := output.(*ListAccountsResponse)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, 2, response.Count)
|
||||
assert.Equal(t, iamdomain.AccountStatusActive, response.Accounts[0].Status)
|
||||
assert.Equal(t, iamdomain.AccountStatusTerminating, response.Accounts[1].Status)
|
||||
assert.Equal(t, handlers_iam.AccountStatusActive, response.Accounts[0].Status)
|
||||
assert.Equal(t, handlers_iam.AccountStatusTerminating, response.Accounts[1].Status)
|
||||
}
|
||||
|
||||
// Failing closed matters here: an empty listing reads as "no accounts", which
|
||||
|
||||
@@ -14,9 +14,9 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
|
||||
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -47,19 +47,19 @@ func TestAdminPathMethod(t *testing.T) {
|
||||
|
||||
// createAccountPolicy grants exactly the action the signup principal holds.
|
||||
func createAccountPolicy() *policyMockIAMService {
|
||||
allow := []iamdomain.PolicyDocument{{
|
||||
allow := []handlers_iam.PolicyDocument{{
|
||||
Version: "2012-10-17",
|
||||
Statement: []iamdomain.Statement{
|
||||
Statement: []handlers_iam.Statement{
|
||||
{
|
||||
Effect: "Allow",
|
||||
Action: iamdomain.StringOrArr{"spinifex:CreateAccount"},
|
||||
Resource: iamdomain.StringOrArr{"*"},
|
||||
Action: handlers_iam.StringOrArr{"spinifex:CreateAccount"},
|
||||
Resource: handlers_iam.StringOrArr{"*"},
|
||||
},
|
||||
},
|
||||
}}
|
||||
return &policyMockIAMService{
|
||||
getUserPoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) { return allow, nil },
|
||||
getRolePoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) { return allow, nil },
|
||||
getUserPoliciesFn: func(_, _ string) ([]handlers_iam.PolicyDocument, error) { return allow, nil },
|
||||
getRolePoliciesFn: func(_, _ string) ([]handlers_iam.PolicyDocument, error) { return allow, nil },
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,9 +13,9 @@ import (
|
||||
"uuid"
|
||||
|
||||
"github.com/mulgadc/bluebottle/pkg/sigv4"
|
||||
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
telemetry "github.com/mulgadc/spinifex/spinifex/foundation/telemetry"
|
||||
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
|
||||
handlers_sts "github.com/mulgadc/spinifex/spinifex/handlers/sts"
|
||||
"github.com/mulgadc/spinifex/spinifex/ingress/aws/dispatch"
|
||||
"github.com/mulgadc/spinifex/spinifex/ingress/aws/envelope"
|
||||
@@ -383,7 +383,7 @@ func (gw *GatewayConfig) resolveLongLivedAKID(accessKeyID string, src authSource
|
||||
slog.Error("IAM lookup failed", "accessKeyID", accessKeyID, "err", err)
|
||||
return "", principalContext{}, awserrors.ErrorInternalError
|
||||
}
|
||||
if ak.Status != iamdomain.AccessKeyStatusActive {
|
||||
if ak.Status != handlers_iam.AccessKeyStatusActive {
|
||||
slog.Warn("Auth failure: access key inactive", "accessKeyID", accessKeyID, "sourceIP", src.logIP)
|
||||
gw.RateLimiter.RecordFailure(src.limitKey, authlimit.Fingerprint("akid-inactive", accessKeyID))
|
||||
return "", principalContext{}, awserrors.ErrorInvalidClientTokenId
|
||||
|
||||
@@ -5,8 +5,8 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
|
||||
)
|
||||
|
||||
// accountActiveTTL bounds how long an account is trusted to still be ACTIVE
|
||||
@@ -86,7 +86,7 @@ func (gw *GatewayConfig) checkAccountActive(accountID string, src authSource) st
|
||||
return awserrors.ErrorInternalError
|
||||
}
|
||||
|
||||
if account.Status != iamdomain.AccountStatusActive {
|
||||
if account.Status != handlers_iam.AccountStatusActive {
|
||||
slog.Warn("Auth failure: account is not active",
|
||||
"accountID", accountID, "status", account.Status, "sourceIP", src.logIP)
|
||||
return awserrors.ErrorAccessDenied
|
||||
|
||||
@@ -11,12 +11,12 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
|
||||
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
|
||||
)
|
||||
|
||||
// statusGateRouter builds a signed-request harness whose only job is to report
|
||||
// whether the auth middleware let the request through.
|
||||
func statusGateRouter(t *testing.T, svc iamdomain.IAMService) (chi.Router, *http.Request) {
|
||||
func statusGateRouter(t *testing.T, svc handlers_iam.IAMService) (chi.Router, *http.Request) {
|
||||
t.Helper()
|
||||
|
||||
gw := &GatewayConfig{DisableLogging: true, Region: testRegion, IAMService: svc}
|
||||
@@ -35,29 +35,29 @@ func statusGateRouter(t *testing.T, svc iamdomain.IAMService) (chi.Router, *http
|
||||
func statusGateService(t *testing.T, accountID, status string) *mockIAMService {
|
||||
t.Helper()
|
||||
|
||||
encryptedSecret, err := iamdomain.EncryptSecret(testSecretKey, testMasterKey)
|
||||
encryptedSecret, err := handlers_iam.EncryptSecret(testSecretKey, testMasterKey)
|
||||
if err != nil {
|
||||
t.Fatalf("encrypt secret: %v", err)
|
||||
}
|
||||
return &mockIAMService{
|
||||
masterKey: testMasterKey,
|
||||
accessKeys: map[string]*iamdomain.AccessKey{
|
||||
accessKeys: map[string]*handlers_iam.AccessKey{
|
||||
testAccessKey: {
|
||||
AccessKeyID: testAccessKey,
|
||||
SecretAccessKey: encryptedSecret,
|
||||
UserName: "alice",
|
||||
AccountID: accountID,
|
||||
Status: iamdomain.AccessKeyStatusActive,
|
||||
Status: handlers_iam.AccessKeyStatusActive,
|
||||
},
|
||||
},
|
||||
accounts: map[string]*iamdomain.Account{
|
||||
accounts: map[string]*handlers_iam.Account{
|
||||
accountID: {AccountID: accountID, Status: status},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthAllowsAnActiveAccount(t *testing.T) {
|
||||
r, req := statusGateRouter(t, statusGateService(t, "123456789012", iamdomain.AccountStatusActive))
|
||||
r, req := statusGateRouter(t, statusGateService(t, "123456789012", handlers_iam.AccountStatusActive))
|
||||
|
||||
if resp := doRequest(r, req); resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("expected 200 for an ACTIVE account, got %d", resp.StatusCode)
|
||||
@@ -67,7 +67,7 @@ func TestAuthAllowsAnActiveAccount(t *testing.T) {
|
||||
// Until this gate existed, account status was enforced only in ECR's principal
|
||||
// resolution, so suspending an account left EC2, S3 and STS wide open.
|
||||
func TestAuthDeniesASuspendedAccount(t *testing.T) {
|
||||
r, req := statusGateRouter(t, statusGateService(t, "123456789012", iamdomain.AccountStatusSuspended))
|
||||
r, req := statusGateRouter(t, statusGateService(t, "123456789012", handlers_iam.AccountStatusSuspended))
|
||||
|
||||
if resp := doRequest(r, req); resp.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("expected 403 for a SUSPENDED account, got %d", resp.StatusCode)
|
||||
@@ -77,7 +77,7 @@ func TestAuthDeniesASuspendedAccount(t *testing.T) {
|
||||
// The quiesce that makes teardown converge: a terminating account must not be
|
||||
// able to create resources the cascade has already walked past.
|
||||
func TestAuthDeniesATerminatingAccount(t *testing.T) {
|
||||
r, req := statusGateRouter(t, statusGateService(t, "123456789012", iamdomain.AccountStatusTerminating))
|
||||
r, req := statusGateRouter(t, statusGateService(t, "123456789012", handlers_iam.AccountStatusTerminating))
|
||||
|
||||
if resp := doRequest(r, req); resp.StatusCode != http.StatusForbidden {
|
||||
t.Fatalf("expected 403 for a TERMINATING account, got %d", resp.StatusCode)
|
||||
@@ -89,13 +89,13 @@ type unreadableAccountService struct {
|
||||
*mockIAMService
|
||||
}
|
||||
|
||||
func (s *unreadableAccountService) GetAccount(string) (*iamdomain.Account, error) {
|
||||
func (s *unreadableAccountService) GetAccount(string) (*handlers_iam.Account, error) {
|
||||
return nil, errors.New("account store unavailable")
|
||||
}
|
||||
|
||||
// An account we cannot read is not evidence of an account in good standing.
|
||||
func TestAuthFailsClosedWhenTheAccountCannotBeRead(t *testing.T) {
|
||||
svc := &unreadableAccountService{statusGateService(t, "123456789012", iamdomain.AccountStatusActive)}
|
||||
svc := &unreadableAccountService{statusGateService(t, "123456789012", handlers_iam.AccountStatusActive)}
|
||||
r, req := statusGateRouter(t, svc)
|
||||
|
||||
if resp := doRequest(r, req); resp.StatusCode == http.StatusOK {
|
||||
|
||||
@@ -28,11 +28,11 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/mulgadc/spinifex/internal/testkit"
|
||||
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
|
||||
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"
|
||||
handlers_eks "github.com/mulgadc/spinifex/spinifex/handlers/eks"
|
||||
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
|
||||
handlers_sts "github.com/mulgadc/spinifex/spinifex/handlers/sts"
|
||||
)
|
||||
|
||||
@@ -56,7 +56,7 @@ type cbCreds struct {
|
||||
// cbDecryptCounter is the real IAM service with DecryptSecret counted, so a test
|
||||
// can observe whether the door decrypted a secret before rejecting a request.
|
||||
type cbDecryptCounter struct {
|
||||
*iamdomain.IAMServiceImpl
|
||||
*handlers_iam.IAMServiceImpl
|
||||
|
||||
decrypts atomic.Int32
|
||||
}
|
||||
@@ -79,7 +79,7 @@ type cbEnv struct {
|
||||
nc *nats.Conn // the gateway's connection
|
||||
iamNC *nats.Conn // IAM/STS connection; == nc unless separateIAMConn
|
||||
js jetstream.JetStream
|
||||
iam *iamdomain.IAMServiceImpl
|
||||
iam *handlers_iam.IAMServiceImpl
|
||||
sts *handlers_sts.STSServiceImpl
|
||||
counter *cbDecryptCounter
|
||||
h http.Handler
|
||||
@@ -100,9 +100,9 @@ func newCBEnv(t *testing.T, opts cbEnvOpts) *cbEnv {
|
||||
e.iamNC = iamNC
|
||||
}
|
||||
|
||||
masterKey, err := iamdomain.GenerateMasterKey()
|
||||
masterKey, err := handlers_iam.GenerateMasterKey()
|
||||
require.NoError(t, err)
|
||||
e.iam, err = iamdomain.NewIAMServiceImpl(t.Context(), e.iamNC, masterKey)
|
||||
e.iam, err = handlers_iam.NewIAMServiceImpl(t.Context(), e.iamNC, masterKey)
|
||||
require.NoError(t, err)
|
||||
iamJS := testutil.NewJetStream(t, e.iamNC)
|
||||
e.sts, err = handlers_sts.NewSTSServiceImpl(t.Context(), e.iamNC, e.iam, masterKey, handlers_eks.NewOIDCJWKSReader(iamJS))
|
||||
@@ -111,9 +111,9 @@ func newCBEnv(t *testing.T, opts cbEnvOpts) *cbEnv {
|
||||
e.iam.SetSessionRevoker(e.sts)
|
||||
}
|
||||
|
||||
encrypted, err := iamdomain.EncryptSecret(cbRootSecret, masterKey)
|
||||
encrypted, err := handlers_iam.EncryptSecret(cbRootSecret, masterKey)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, e.iam.SeedBootstrap(&iamdomain.BootstrapData{
|
||||
require.NoError(t, e.iam.SeedBootstrap(&handlers_iam.BootstrapData{
|
||||
AccessKeyID: cbRootAKID, EncryptedSecret: encrypted, AccountID: awsidentifiers.GlobalAccountID,
|
||||
}))
|
||||
|
||||
@@ -365,7 +365,7 @@ func TestCurrentBehaviour_Auth_LongLivedRejections(t *testing.T) {
|
||||
creds := e.newKey(t, "alice")
|
||||
require.Equal(t, http.StatusOK, e.callerIdentity(t, creds).Code)
|
||||
_, err := e.iam.UpdateAccessKey(e.accountID, &iam.UpdateAccessKeyInput{
|
||||
UserName: aws.String("alice"), AccessKeyId: aws.String(creds.akid), Status: aws.String(iamdomain.AccessKeyStatusInactive),
|
||||
UserName: aws.String("alice"), AccessKeyId: aws.String(creds.akid), Status: aws.String(handlers_iam.AccessKeyStatusInactive),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, cbRegistry(awserrors.ErrorInvalidClientTokenId), cbOutcomeOf(e.callerIdentity(t, creds)))
|
||||
@@ -462,7 +462,7 @@ func TestCurrentBehaviour_Session_GetSessionTokenOutlivesMintingKey(t *testing.T
|
||||
assert.Equal(t, e.aliceID, cbXML(rec.Body.String(), "UserId"))
|
||||
|
||||
_, err := e.iam.UpdateAccessKey(e.accountID, &iam.UpdateAccessKeyInput{
|
||||
UserName: aws.String("alice"), AccessKeyId: aws.String(mintKey.akid), Status: aws.String(iamdomain.AccessKeyStatusInactive),
|
||||
UserName: aws.String("alice"), AccessKeyId: aws.String(mintKey.akid), Status: aws.String(handlers_iam.AccessKeyStatusInactive),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
_, err = e.iam.DeleteAccessKey(e.accountID, &iam.DeleteAccessKeyInput{
|
||||
@@ -486,9 +486,9 @@ func TestCurrentBehaviour_Session_WebIdentityCredentials(t *testing.T) {
|
||||
issuer := testDiscIssuer
|
||||
|
||||
iamKV, err := kvutil.GetOrCreateBucket(t.Context(), e.js,
|
||||
iamdomain.IAMAccountBucketName(e.accountID), iamdomain.KVBucketIAMAccountVersion)
|
||||
handlers_iam.IAMAccountBucketName(e.accountID), handlers_iam.KVBucketIAMAccountVersion)
|
||||
require.NoError(t, err)
|
||||
_, err = iamKV.Put(t.Context(), iamdomain.OIDCProviderKey(issuer), []byte(`{"registered":true}`))
|
||||
_, err = iamKV.Put(t.Context(), handlers_iam.OIDCProviderKey(issuer), []byte(`{"registered":true}`))
|
||||
require.NoError(t, err)
|
||||
|
||||
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
@@ -506,7 +506,7 @@ func TestCurrentBehaviour_Session_WebIdentityCredentials(t *testing.T) {
|
||||
_, err = eksKV.Put(t.Context(), handlers_eks.OIDCJWKSKey(testDiscCluster), jwks)
|
||||
require.NoError(t, err)
|
||||
|
||||
federated := iamdomain.OIDCProviderARN(e.accountID, strings.TrimPrefix(issuer, "https://"))
|
||||
federated := handlers_iam.OIDCProviderARN(e.accountID, strings.TrimPrefix(issuer, "https://"))
|
||||
role, err := e.iam.CreateRole(e.accountID, &iam.CreateRoleInput{
|
||||
RoleName: aws.String("irsa"),
|
||||
AssumeRolePolicyDocument: aws.String(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Federated":"` +
|
||||
@@ -640,11 +640,11 @@ func TestCurrentBehaviour_Revocation_OrphanedKeyAuthenticatesAsRecreatedUser(t *
|
||||
// access-key records in place, the state an interrupted DeleteAccessKey leaves.
|
||||
func (e *cbEnv) orphanKeys(t *testing.T, user string) {
|
||||
t.Helper()
|
||||
usersKV, err := e.js.KeyValue(t.Context(), iamdomain.KVBucketUsers)
|
||||
usersKV, err := e.js.KeyValue(t.Context(), handlers_iam.KVBucketUsers)
|
||||
require.NoError(t, err)
|
||||
entry, err := usersKV.Get(t.Context(), e.accountID+"."+user)
|
||||
require.NoError(t, err)
|
||||
var u iamdomain.User
|
||||
var u handlers_iam.User
|
||||
require.NoError(t, json.Unmarshal(entry.Value(), &u))
|
||||
u.AccessKeys = []string{}
|
||||
data, err := json.Marshal(u)
|
||||
@@ -744,7 +744,7 @@ func TestCurrentBehaviour_Auth_BackingStateUnavailable(t *testing.T) {
|
||||
|
||||
t.Run("access_key_bucket_deleted", func(t *testing.T) {
|
||||
e := newCBEnv(t, cbEnvOpts{})
|
||||
require.NoError(t, e.js.DeleteKeyValue(t.Context(), iamdomain.KVBucketAccessKeys))
|
||||
require.NoError(t, e.js.DeleteKeyValue(t.Context(), handlers_iam.KVBucketAccessKeys))
|
||||
for name, c := range map[string]cbCreds{"known": e.alice, "unknown": {akid: cbUnknownKey, secret: cbBadSecret}} {
|
||||
assert.Equal(t, internal, cbOutcomeOf(e.request(t, e.h, c, "sts", gci)), name)
|
||||
}
|
||||
@@ -760,7 +760,7 @@ func TestCurrentBehaviour_Auth_BackingStateUnavailable(t *testing.T) {
|
||||
|
||||
t.Run("accounts_bucket_deleted", func(t *testing.T) {
|
||||
e := newCBEnv(t, cbEnvOpts{})
|
||||
require.NoError(t, e.js.DeleteKeyValue(t.Context(), iamdomain.KVBucketAccounts))
|
||||
require.NoError(t, e.js.DeleteKeyValue(t.Context(), handlers_iam.KVBucketAccounts))
|
||||
// Fails closed only once the signature holds; a bad one still reads as such.
|
||||
assert.Equal(t, internal, cbOutcomeOf(e.callerIdentity(t, e.alice)))
|
||||
assert.Equal(t, cbRegistry(awserrors.ErrorSignatureDoesNotMatch),
|
||||
@@ -812,7 +812,7 @@ func TestCurrentBehaviour_AntiEnumeration_RejectionShapes(t *testing.T) {
|
||||
e := newCBEnv(t, cbEnvOpts{})
|
||||
bobKey, _ := e.newUser(t, "bob")
|
||||
_, err := e.iam.UpdateAccessKey(e.accountID, &iam.UpdateAccessKeyInput{
|
||||
UserName: aws.String("bob"), AccessKeyId: aws.String(bobKey.akid), Status: aws.String(iamdomain.AccessKeyStatusInactive),
|
||||
UserName: aws.String("bob"), AccessKeyId: aws.String(bobKey.akid), Status: aws.String(handlers_iam.AccessKeyStatusInactive),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
carolKey, _ := e.newUser(t, "carol")
|
||||
@@ -858,7 +858,7 @@ func TestCurrentBehaviour_AntiEnumeration_RejectionShapes(t *testing.T) {
|
||||
func TestCurrentBehaviour_Cache_AccountStatusIsTheOnlyAuthCache(t *testing.T) {
|
||||
require.Equal(t, 5*time.Second, accountActiveTTL)
|
||||
|
||||
for _, status := range []string{iamdomain.AccountStatusSuspended, iamdomain.AccountStatusTerminating} {
|
||||
for _, status := range []string{handlers_iam.AccountStatusSuspended, handlers_iam.AccountStatusTerminating} {
|
||||
t.Run(status, func(t *testing.T) {
|
||||
e := newCBEnv(t, cbEnvOpts{})
|
||||
require.Equal(t, http.StatusOK, e.callerIdentity(t, e.alice).Code)
|
||||
|
||||
+104
-104
@@ -30,8 +30,8 @@ import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/mulgadc/bluebottle/pkg/sigv4"
|
||||
"github.com/mulgadc/spinifex/internal/testkit"
|
||||
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
|
||||
handlers_sts "github.com/mulgadc/spinifex/spinifex/handlers/sts"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -45,27 +45,27 @@ const (
|
||||
testService = "ec2"
|
||||
)
|
||||
|
||||
// mockIAMService implements iamdomain.IAMService for auth tests. It embeds
|
||||
// mockIAMService implements handlers_iam.IAMService for auth tests. It embeds
|
||||
// the interface so it satisfies the full contract; the SigV4 middleware drives
|
||||
// LookupAccessKey, DecryptSecret and GetUser, so those are the methods wired.
|
||||
// Any other method nil-panics, surfacing an unexpected call.
|
||||
type mockIAMService struct {
|
||||
iamdomain.IAMService
|
||||
handlers_iam.IAMService
|
||||
|
||||
accessKeys map[string]*iamdomain.AccessKey
|
||||
accessKeys map[string]*handlers_iam.AccessKey
|
||||
masterKey []byte
|
||||
|
||||
// accounts overrides the status the auth gate reads. An empty map means
|
||||
// every account is ACTIVE, which is what most tests here are asserting
|
||||
// around rather than asserting about.
|
||||
accounts map[string]*iamdomain.Account
|
||||
accounts map[string]*handlers_iam.Account
|
||||
|
||||
// getUserFn overrides the synthetic user record below, for a test that cares
|
||||
// what aws:userid resolves to or that it fails to resolve at all.
|
||||
getUserFn func(accountID string, input *iam.GetUserInput) (*iam.GetUserOutput, error)
|
||||
}
|
||||
|
||||
func (m *mockIAMService) LookupAccessKey(accessKeyID string) (*iamdomain.AccessKey, error) {
|
||||
func (m *mockIAMService) LookupAccessKey(accessKeyID string) (*handlers_iam.AccessKey, error) {
|
||||
ak, ok := m.accessKeys[accessKeyID]
|
||||
if !ok {
|
||||
return nil, errors.New(awserrors.ErrorIAMNoSuchEntity)
|
||||
@@ -74,14 +74,14 @@ func (m *mockIAMService) LookupAccessKey(accessKeyID string) (*iamdomain.AccessK
|
||||
}
|
||||
|
||||
func (m *mockIAMService) DecryptSecret(ciphertext string) (string, error) {
|
||||
return iamdomain.DecryptSecret(ciphertext, m.masterKey)
|
||||
return handlers_iam.DecryptSecret(ciphertext, m.masterKey)
|
||||
}
|
||||
|
||||
func (m *mockIAMService) GetAccount(accountID string) (*iamdomain.Account, error) {
|
||||
func (m *mockIAMService) GetAccount(accountID string) (*handlers_iam.Account, error) {
|
||||
if account, ok := m.accounts[accountID]; ok {
|
||||
return account, nil
|
||||
}
|
||||
return &iamdomain.Account{AccountID: accountID, Status: iamdomain.AccountStatusActive}, nil
|
||||
return &handlers_iam.Account{AccountID: accountID, Status: handlers_iam.AccountStatusActive}, nil
|
||||
}
|
||||
|
||||
// GetUser answers with a unique ID derived from the name, so aws:userid resolves
|
||||
@@ -102,7 +102,7 @@ var testMasterKey []byte
|
||||
|
||||
func init() {
|
||||
var err error
|
||||
testMasterKey, err = iamdomain.GenerateMasterKey()
|
||||
testMasterKey, err = handlers_iam.GenerateMasterKey()
|
||||
if err != nil {
|
||||
panic("failed to generate test master key: " + err.Error())
|
||||
}
|
||||
@@ -136,14 +136,14 @@ func signTestRequestAs(t *testing.T, req *http.Request, body []byte, accessKey,
|
||||
}
|
||||
|
||||
func setupTestApp(accessKey, secretKey string) http.Handler {
|
||||
encryptedSecret, err := iamdomain.EncryptSecret(secretKey, testMasterKey)
|
||||
encryptedSecret, err := handlers_iam.EncryptSecret(secretKey, testMasterKey)
|
||||
if err != nil {
|
||||
panic("failed to encrypt test secret: " + err.Error())
|
||||
}
|
||||
|
||||
mockSvc := &mockIAMService{
|
||||
masterKey: testMasterKey,
|
||||
accessKeys: map[string]*iamdomain.AccessKey{
|
||||
accessKeys: map[string]*handlers_iam.AccessKey{
|
||||
accessKey: {
|
||||
AccessKeyID: accessKey,
|
||||
SecretAccessKey: encryptedSecret,
|
||||
@@ -172,10 +172,10 @@ func setupTestApp(accessKey, secretKey string) http.Handler {
|
||||
// the aws:userid branch that a user principal reaches in production.
|
||||
func userIDApp(t *testing.T, svc *mockIAMService) (http.Handler, *string) {
|
||||
t.Helper()
|
||||
encryptedSecret, err := iamdomain.EncryptSecret(testSecretKey, testMasterKey)
|
||||
encryptedSecret, err := handlers_iam.EncryptSecret(testSecretKey, testMasterKey)
|
||||
require.NoError(t, err)
|
||||
svc.masterKey = testMasterKey
|
||||
svc.accessKeys = map[string]*iamdomain.AccessKey{
|
||||
svc.accessKeys = map[string]*handlers_iam.AccessKey{
|
||||
testAccessKey: {
|
||||
AccessKeyID: testAccessKey,
|
||||
SecretAccessKey: encryptedSecret,
|
||||
@@ -371,14 +371,14 @@ func TestSigV4Auth_ValidSignatureWithQueryString(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSigV4Auth_InactiveAccessKey(t *testing.T) {
|
||||
encryptedSecret, err := iamdomain.EncryptSecret(testSecretKey, testMasterKey)
|
||||
encryptedSecret, err := handlers_iam.EncryptSecret(testSecretKey, testMasterKey)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to encrypt secret: %v", err)
|
||||
}
|
||||
|
||||
mockSvc := &mockIAMService{
|
||||
masterKey: testMasterKey,
|
||||
accessKeys: map[string]*iamdomain.AccessKey{
|
||||
accessKeys: map[string]*handlers_iam.AccessKey{
|
||||
testAccessKey: {
|
||||
AccessKeyID: testAccessKey,
|
||||
SecretAccessKey: encryptedSecret,
|
||||
@@ -480,18 +480,18 @@ func TestParseAWSQueryArgs_URLDecoding(t *testing.T) {
|
||||
func TestSigV4Auth_DecryptFailure(t *testing.T) {
|
||||
// Secret encrypted under a rotated master key — unverifiable credential must
|
||||
// return 403 InvalidClientTokenId, not 500, so the client re-authenticates.
|
||||
otherKey, err := iamdomain.GenerateMasterKey()
|
||||
otherKey, err := handlers_iam.GenerateMasterKey()
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateMasterKey() error: %v", err)
|
||||
}
|
||||
encryptedWithOther, err := iamdomain.EncryptSecret(testSecretKey, otherKey)
|
||||
encryptedWithOther, err := handlers_iam.EncryptSecret(testSecretKey, otherKey)
|
||||
if err != nil {
|
||||
t.Fatalf("EncryptSecret() error: %v", err)
|
||||
}
|
||||
|
||||
mockSvc := &mockIAMService{
|
||||
masterKey: testMasterKey,
|
||||
accessKeys: map[string]*iamdomain.AccessKey{
|
||||
accessKeys: map[string]*handlers_iam.AccessKey{
|
||||
testAccessKey: {
|
||||
AccessKeyID: testAccessKey,
|
||||
SecretAccessKey: encryptedWithOther, // encrypted with wrong key
|
||||
@@ -832,14 +832,14 @@ func TestSigV4Auth_S3ScopeReturnsFlatS3Error(t *testing.T) {
|
||||
// --- Context Propagation Tests ---
|
||||
|
||||
func TestSigV4Auth_ContextPropagation(t *testing.T) {
|
||||
encryptedSecret, err := iamdomain.EncryptSecret(testSecretKey, testMasterKey)
|
||||
encryptedSecret, err := handlers_iam.EncryptSecret(testSecretKey, testMasterKey)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to encrypt secret: %v", err)
|
||||
}
|
||||
|
||||
mockSvc := &mockIAMService{
|
||||
masterKey: testMasterKey,
|
||||
accessKeys: map[string]*iamdomain.AccessKey{
|
||||
accessKeys: map[string]*handlers_iam.AccessKey{
|
||||
testAccessKey: {
|
||||
AccessKeyID: testAccessKey,
|
||||
SecretAccessKey: encryptedSecret,
|
||||
@@ -902,20 +902,20 @@ func TestSigV4Auth_ContextPropagation(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSigV4Auth_ContextDoesNotLeakBetweenRequests(t *testing.T) {
|
||||
encryptedSecret, err := iamdomain.EncryptSecret(testSecretKey, testMasterKey)
|
||||
encryptedSecret, err := handlers_iam.EncryptSecret(testSecretKey, testMasterKey)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to encrypt secret: %v", err)
|
||||
}
|
||||
|
||||
secondKey := "AKIAIOSFODNN7SECOND0"
|
||||
encryptedSecret2, err := iamdomain.EncryptSecret(testSecretKey, testMasterKey)
|
||||
encryptedSecret2, err := handlers_iam.EncryptSecret(testSecretKey, testMasterKey)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to encrypt secret: %v", err)
|
||||
}
|
||||
|
||||
mockSvc := &mockIAMService{
|
||||
masterKey: testMasterKey,
|
||||
accessKeys: map[string]*iamdomain.AccessKey{
|
||||
accessKeys: map[string]*handlers_iam.AccessKey{
|
||||
testAccessKey: {
|
||||
AccessKeyID: testAccessKey,
|
||||
SecretAccessKey: encryptedSecret,
|
||||
@@ -1034,14 +1034,14 @@ func TestSigV4Auth_PathWithSpecialCharacters(t *testing.T) {
|
||||
|
||||
func TestSigV4Auth_SignedContentType(t *testing.T) {
|
||||
// Verify signature works when content-type is included in signed headers.
|
||||
encryptedSecret, err := iamdomain.EncryptSecret(testSecretKey, testMasterKey)
|
||||
encryptedSecret, err := handlers_iam.EncryptSecret(testSecretKey, testMasterKey)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to encrypt secret: %v", err)
|
||||
}
|
||||
|
||||
mockSvc := &mockIAMService{
|
||||
masterKey: testMasterKey,
|
||||
accessKeys: map[string]*iamdomain.AccessKey{
|
||||
accessKeys: map[string]*handlers_iam.AccessKey{
|
||||
testAccessKey: {
|
||||
AccessKeyID: testAccessKey,
|
||||
SecretAccessKey: encryptedSecret,
|
||||
@@ -1146,18 +1146,18 @@ func TestSigV4Auth_QueryStringEdgeCases(t *testing.T) {
|
||||
type policyMockIAMService struct {
|
||||
mockIAMService
|
||||
|
||||
getUserPoliciesFn func(accountID, userName string) ([]iamdomain.PolicyDocument, error)
|
||||
getRolePoliciesFn func(accountID, roleName string) ([]iamdomain.PolicyDocument, error)
|
||||
getUserPoliciesFn func(accountID, userName string) ([]handlers_iam.PolicyDocument, error)
|
||||
getRolePoliciesFn func(accountID, roleName string) ([]handlers_iam.PolicyDocument, error)
|
||||
}
|
||||
|
||||
func (m *policyMockIAMService) GetUserPolicies(accountID, userName string) ([]iamdomain.PolicyDocument, error) {
|
||||
func (m *policyMockIAMService) GetUserPolicies(accountID, userName string) ([]handlers_iam.PolicyDocument, error) {
|
||||
if m.getUserPoliciesFn != nil {
|
||||
return m.getUserPoliciesFn(accountID, userName)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *policyMockIAMService) GetRolePolicies(accountID, roleName string) ([]iamdomain.PolicyDocument, error) {
|
||||
func (m *policyMockIAMService) GetRolePolicies(accountID, roleName string) ([]handlers_iam.PolicyDocument, error) {
|
||||
if m.getRolePoliciesFn != nil {
|
||||
return m.getRolePoliciesFn(accountID, roleName)
|
||||
}
|
||||
@@ -1171,7 +1171,7 @@ type errorLookupMockIAMService struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (m *errorLookupMockIAMService) LookupAccessKey(_ string) (*iamdomain.AccessKey, error) {
|
||||
func (m *errorLookupMockIAMService) LookupAccessKey(_ string) (*handlers_iam.AccessKey, error) {
|
||||
return nil, m.err
|
||||
}
|
||||
|
||||
@@ -1190,7 +1190,7 @@ func setupPolicyTestHandler(gw *GatewayConfig) http.Handler {
|
||||
}
|
||||
|
||||
func TestCheckPolicy_NonRootNoPolicies_Denied(t *testing.T) {
|
||||
encryptedSecret, err := iamdomain.EncryptSecret(testSecretKey, testMasterKey)
|
||||
encryptedSecret, err := handlers_iam.EncryptSecret(testSecretKey, testMasterKey)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to encrypt secret: %v", err)
|
||||
}
|
||||
@@ -1198,7 +1198,7 @@ func TestCheckPolicy_NonRootNoPolicies_Denied(t *testing.T) {
|
||||
mockSvc := &policyMockIAMService{
|
||||
mockIAMService: mockIAMService{
|
||||
masterKey: testMasterKey,
|
||||
accessKeys: map[string]*iamdomain.AccessKey{
|
||||
accessKeys: map[string]*handlers_iam.AccessKey{
|
||||
testAccessKey: {
|
||||
AccessKeyID: testAccessKey,
|
||||
SecretAccessKey: encryptedSecret,
|
||||
@@ -1208,7 +1208,7 @@ func TestCheckPolicy_NonRootNoPolicies_Denied(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
getUserPoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) {
|
||||
getUserPoliciesFn: func(_, _ string) ([]handlers_iam.PolicyDocument, error) {
|
||||
return nil, nil // no policies
|
||||
},
|
||||
}
|
||||
@@ -1239,7 +1239,7 @@ func TestCheckPolicy_NonRootNoPolicies_Denied(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCheckPolicy_NonRootWithAllow_Passes(t *testing.T) {
|
||||
encryptedSecret, err := iamdomain.EncryptSecret(testSecretKey, testMasterKey)
|
||||
encryptedSecret, err := handlers_iam.EncryptSecret(testSecretKey, testMasterKey)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to encrypt secret: %v", err)
|
||||
}
|
||||
@@ -1247,7 +1247,7 @@ func TestCheckPolicy_NonRootWithAllow_Passes(t *testing.T) {
|
||||
mockSvc := &policyMockIAMService{
|
||||
mockIAMService: mockIAMService{
|
||||
masterKey: testMasterKey,
|
||||
accessKeys: map[string]*iamdomain.AccessKey{
|
||||
accessKeys: map[string]*handlers_iam.AccessKey{
|
||||
testAccessKey: {
|
||||
AccessKeyID: testAccessKey,
|
||||
SecretAccessKey: encryptedSecret,
|
||||
@@ -1257,15 +1257,15 @@ func TestCheckPolicy_NonRootWithAllow_Passes(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
getUserPoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) {
|
||||
return []iamdomain.PolicyDocument{
|
||||
getUserPoliciesFn: func(_, _ string) ([]handlers_iam.PolicyDocument, error) {
|
||||
return []handlers_iam.PolicyDocument{
|
||||
{
|
||||
Version: "2012-10-17",
|
||||
Statement: []iamdomain.Statement{
|
||||
Statement: []handlers_iam.Statement{
|
||||
{
|
||||
Effect: "Allow",
|
||||
Action: iamdomain.StringOrArr{"ec2:RunInstances"},
|
||||
Resource: iamdomain.StringOrArr{"*"},
|
||||
Action: handlers_iam.StringOrArr{"ec2:RunInstances"},
|
||||
Resource: handlers_iam.StringOrArr{"*"},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1294,7 +1294,7 @@ func TestCheckPolicy_NonRootWithAllow_Passes(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCheckPolicy_NonRootWithExplicitDeny_Denied(t *testing.T) {
|
||||
encryptedSecret, err := iamdomain.EncryptSecret(testSecretKey, testMasterKey)
|
||||
encryptedSecret, err := handlers_iam.EncryptSecret(testSecretKey, testMasterKey)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to encrypt secret: %v", err)
|
||||
}
|
||||
@@ -1302,7 +1302,7 @@ func TestCheckPolicy_NonRootWithExplicitDeny_Denied(t *testing.T) {
|
||||
mockSvc := &policyMockIAMService{
|
||||
mockIAMService: mockIAMService{
|
||||
masterKey: testMasterKey,
|
||||
accessKeys: map[string]*iamdomain.AccessKey{
|
||||
accessKeys: map[string]*handlers_iam.AccessKey{
|
||||
testAccessKey: {
|
||||
AccessKeyID: testAccessKey,
|
||||
SecretAccessKey: encryptedSecret,
|
||||
@@ -1312,20 +1312,20 @@ func TestCheckPolicy_NonRootWithExplicitDeny_Denied(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
getUserPoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) {
|
||||
return []iamdomain.PolicyDocument{
|
||||
getUserPoliciesFn: func(_, _ string) ([]handlers_iam.PolicyDocument, error) {
|
||||
return []handlers_iam.PolicyDocument{
|
||||
{
|
||||
Version: "2012-10-17",
|
||||
Statement: []iamdomain.Statement{
|
||||
Statement: []handlers_iam.Statement{
|
||||
{
|
||||
Effect: "Allow",
|
||||
Action: iamdomain.StringOrArr{"ec2:*"},
|
||||
Resource: iamdomain.StringOrArr{"*"},
|
||||
Action: handlers_iam.StringOrArr{"ec2:*"},
|
||||
Resource: handlers_iam.StringOrArr{"*"},
|
||||
},
|
||||
{
|
||||
Effect: "Deny",
|
||||
Action: iamdomain.StringOrArr{"ec2:RunInstances"},
|
||||
Resource: iamdomain.StringOrArr{"*"},
|
||||
Action: handlers_iam.StringOrArr{"ec2:RunInstances"},
|
||||
Resource: handlers_iam.StringOrArr{"*"},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1359,7 +1359,7 @@ func TestCheckPolicy_NonRootWithExplicitDeny_Denied(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCheckPolicy_RootGlobalAccount_Bypasses(t *testing.T) {
|
||||
encryptedSecret, err := iamdomain.EncryptSecret(testSecretKey, testMasterKey)
|
||||
encryptedSecret, err := handlers_iam.EncryptSecret(testSecretKey, testMasterKey)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to encrypt secret: %v", err)
|
||||
}
|
||||
@@ -1367,7 +1367,7 @@ func TestCheckPolicy_RootGlobalAccount_Bypasses(t *testing.T) {
|
||||
mockSvc := &policyMockIAMService{
|
||||
mockIAMService: mockIAMService{
|
||||
masterKey: testMasterKey,
|
||||
accessKeys: map[string]*iamdomain.AccessKey{
|
||||
accessKeys: map[string]*handlers_iam.AccessKey{
|
||||
testAccessKey: {
|
||||
AccessKeyID: testAccessKey,
|
||||
SecretAccessKey: encryptedSecret,
|
||||
@@ -1377,16 +1377,16 @@ func TestCheckPolicy_RootGlobalAccount_Bypasses(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
getUserPoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) {
|
||||
getUserPoliciesFn: func(_, _ string) ([]handlers_iam.PolicyDocument, error) {
|
||||
// Return explicit deny — root should bypass anyway
|
||||
return []iamdomain.PolicyDocument{
|
||||
return []handlers_iam.PolicyDocument{
|
||||
{
|
||||
Version: "2012-10-17",
|
||||
Statement: []iamdomain.Statement{
|
||||
Statement: []handlers_iam.Statement{
|
||||
{
|
||||
Effect: "Deny",
|
||||
Action: iamdomain.StringOrArr{"*"},
|
||||
Resource: iamdomain.StringOrArr{"*"},
|
||||
Action: handlers_iam.StringOrArr{"*"},
|
||||
Resource: handlers_iam.StringOrArr{"*"},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -1415,7 +1415,7 @@ func TestCheckPolicy_RootGlobalAccount_Bypasses(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCheckPolicy_MissingAccountID_InternalError(t *testing.T) {
|
||||
encryptedSecret, err := iamdomain.EncryptSecret(testSecretKey, testMasterKey)
|
||||
encryptedSecret, err := handlers_iam.EncryptSecret(testSecretKey, testMasterKey)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to encrypt secret: %v", err)
|
||||
}
|
||||
@@ -1424,7 +1424,7 @@ func TestCheckPolicy_MissingAccountID_InternalError(t *testing.T) {
|
||||
mockSvc := &policyMockIAMService{
|
||||
mockIAMService: mockIAMService{
|
||||
masterKey: testMasterKey,
|
||||
accessKeys: map[string]*iamdomain.AccessKey{
|
||||
accessKeys: map[string]*handlers_iam.AccessKey{
|
||||
testAccessKey: {
|
||||
AccessKeyID: testAccessKey,
|
||||
SecretAccessKey: encryptedSecret,
|
||||
@@ -1457,7 +1457,7 @@ func TestCheckPolicy_MissingAccountID_InternalError(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCheckPolicy_GetUserPoliciesError_InternalError(t *testing.T) {
|
||||
encryptedSecret, err := iamdomain.EncryptSecret(testSecretKey, testMasterKey)
|
||||
encryptedSecret, err := handlers_iam.EncryptSecret(testSecretKey, testMasterKey)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to encrypt secret: %v", err)
|
||||
}
|
||||
@@ -1465,7 +1465,7 @@ func TestCheckPolicy_GetUserPoliciesError_InternalError(t *testing.T) {
|
||||
mockSvc := &policyMockIAMService{
|
||||
mockIAMService: mockIAMService{
|
||||
masterKey: testMasterKey,
|
||||
accessKeys: map[string]*iamdomain.AccessKey{
|
||||
accessKeys: map[string]*handlers_iam.AccessKey{
|
||||
testAccessKey: {
|
||||
AccessKeyID: testAccessKey,
|
||||
SecretAccessKey: encryptedSecret,
|
||||
@@ -1475,7 +1475,7 @@ func TestCheckPolicy_GetUserPoliciesError_InternalError(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
getUserPoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) {
|
||||
getUserPoliciesFn: func(_, _ string) ([]handlers_iam.PolicyDocument, error) {
|
||||
return nil, errors.New("nats: timeout")
|
||||
},
|
||||
}
|
||||
@@ -1503,7 +1503,7 @@ func TestCheckPolicy_GetUserPoliciesError_InternalError(t *testing.T) {
|
||||
func TestCheckPolicy_RootNonGlobalAccount_StillEvaluated(t *testing.T) {
|
||||
// A user named "root" but on a non-global account should still have
|
||||
// policies evaluated (only root + GlobalAccountID bypasses).
|
||||
encryptedSecret, err := iamdomain.EncryptSecret(testSecretKey, testMasterKey)
|
||||
encryptedSecret, err := handlers_iam.EncryptSecret(testSecretKey, testMasterKey)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to encrypt secret: %v", err)
|
||||
}
|
||||
@@ -1511,7 +1511,7 @@ func TestCheckPolicy_RootNonGlobalAccount_StillEvaluated(t *testing.T) {
|
||||
mockSvc := &policyMockIAMService{
|
||||
mockIAMService: mockIAMService{
|
||||
masterKey: testMasterKey,
|
||||
accessKeys: map[string]*iamdomain.AccessKey{
|
||||
accessKeys: map[string]*handlers_iam.AccessKey{
|
||||
testAccessKey: {
|
||||
AccessKeyID: testAccessKey,
|
||||
SecretAccessKey: encryptedSecret,
|
||||
@@ -1521,7 +1521,7 @@ func TestCheckPolicy_RootNonGlobalAccount_StillEvaluated(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
getUserPoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) {
|
||||
getUserPoliciesFn: func(_, _ string) ([]handlers_iam.PolicyDocument, error) {
|
||||
return nil, nil // no policies → should deny
|
||||
},
|
||||
}
|
||||
@@ -1728,7 +1728,7 @@ const (
|
||||
// real AES-GCM so the SigV4 verify path exercises the actual decrypt code.
|
||||
func setupSessionTestApp(t *testing.T, expiresAt time.Time) (http.Handler, *mockSTSService) {
|
||||
t.Helper()
|
||||
encryptedSecret, err := iamdomain.EncryptSecret(testSecretKey, testMasterKey)
|
||||
encryptedSecret, err := handlers_iam.EncryptSecret(testSecretKey, testMasterKey)
|
||||
require.NoError(t, err)
|
||||
|
||||
cred := &handlers_sts.SessionCredential{
|
||||
@@ -1752,7 +1752,7 @@ func setupSessionTestApp(t *testing.T, expiresAt time.Time) (http.Handler, *mock
|
||||
// principal context; varies cred to drive the principal-type branch in resolveSessionAKID.
|
||||
func mountSessionGateway(t *testing.T, cred *handlers_sts.SessionCredential) (http.Handler, *mockSTSService) {
|
||||
t.Helper()
|
||||
encryptedSecret, err := iamdomain.EncryptSecret(testSecretKey, testMasterKey)
|
||||
encryptedSecret, err := handlers_iam.EncryptSecret(testSecretKey, testMasterKey)
|
||||
require.NoError(t, err)
|
||||
|
||||
stsMock := &mockSTSService{
|
||||
@@ -1762,7 +1762,7 @@ func mountSessionGateway(t *testing.T, cred *handlers_sts.SessionCredential) (ht
|
||||
|
||||
iamMock := &mockIAMService{
|
||||
masterKey: testMasterKey,
|
||||
accessKeys: map[string]*iamdomain.AccessKey{
|
||||
accessKeys: map[string]*handlers_iam.AccessKey{
|
||||
testAccessKey: {
|
||||
AccessKeyID: testAccessKey,
|
||||
SecretAccessKey: encryptedSecret,
|
||||
@@ -1824,7 +1824,7 @@ func TestSigV4Auth_Session_ValidSignature(t *testing.T) {
|
||||
func TestSigV4Auth_Session_UserPrincipal(t *testing.T) {
|
||||
// GetSessionToken session (PrincipalType "user") must resolve back to the IAM
|
||||
// user with no assumed-role fields, so buildCallerARN yields arn:aws:iam::A:user/N.
|
||||
encryptedSecret, err := iamdomain.EncryptSecret(testSecretKey, testMasterKey)
|
||||
encryptedSecret, err := handlers_iam.EncryptSecret(testSecretKey, testMasterKey)
|
||||
require.NoError(t, err)
|
||||
cred := &handlers_sts.SessionCredential{
|
||||
AccessKeyID: testSessionAKID,
|
||||
@@ -1856,7 +1856,7 @@ func TestSigV4Auth_Session_UserPrincipal(t *testing.T) {
|
||||
|
||||
func TestSigV4Auth_Session_EmptyPrincipalType_ResolvesAssumedRole(t *testing.T) {
|
||||
// Pre-PrincipalType records have an empty value and must still resolve as assumed-role.
|
||||
encryptedSecret, err := iamdomain.EncryptSecret(testSecretKey, testMasterKey)
|
||||
encryptedSecret, err := handlers_iam.EncryptSecret(testSecretKey, testMasterKey)
|
||||
require.NoError(t, err)
|
||||
cred := &handlers_sts.SessionCredential{
|
||||
AccessKeyID: testSessionAKID,
|
||||
@@ -2111,12 +2111,12 @@ func TestSigV4Auth_UnknownAKIDPrefix_NoLookup(t *testing.T) {
|
||||
|
||||
func TestSigV4Auth_AKIA_PrincipalTypeUser(t *testing.T) {
|
||||
// Long-lived AKIA path must set ctxPrincipalType=user for correct policy gating.
|
||||
encryptedSecret, err := iamdomain.EncryptSecret(testSecretKey, testMasterKey)
|
||||
encryptedSecret, err := handlers_iam.EncryptSecret(testSecretKey, testMasterKey)
|
||||
require.NoError(t, err)
|
||||
|
||||
iamMock := &mockIAMService{
|
||||
masterKey: testMasterKey,
|
||||
accessKeys: map[string]*iamdomain.AccessKey{
|
||||
accessKeys: map[string]*handlers_iam.AccessKey{
|
||||
testAccessKey: {
|
||||
AccessKeyID: testAccessKey,
|
||||
SecretAccessKey: encryptedSecret,
|
||||
@@ -2150,11 +2150,11 @@ func TestSigV4Auth_AKIA_PrincipalTypeUser(t *testing.T) {
|
||||
|
||||
func TestSigV4Auth_Session_STSServiceNil(t *testing.T) {
|
||||
// Session AKID with nil STSService is a misconfiguration — must fail with InternalError.
|
||||
encryptedSecret, err := iamdomain.EncryptSecret(testSecretKey, testMasterKey)
|
||||
encryptedSecret, err := handlers_iam.EncryptSecret(testSecretKey, testMasterKey)
|
||||
require.NoError(t, err)
|
||||
iamMock := &mockIAMService{
|
||||
masterKey: testMasterKey,
|
||||
accessKeys: map[string]*iamdomain.AccessKey{},
|
||||
accessKeys: map[string]*handlers_iam.AccessKey{},
|
||||
}
|
||||
_ = encryptedSecret
|
||||
gw := &GatewayConfig{
|
||||
@@ -2184,7 +2184,7 @@ func TestSigV4Auth_Session_STSServiceNil(t *testing.T) {
|
||||
// that grants nothing. The enforcement path must resolve the underlying role —
|
||||
// never the SessionName — so the request is denied and GetUserPolicies is never called.
|
||||
func TestCheckPolicy_SessionNameCollision(t *testing.T) {
|
||||
encryptedSecret, err := iamdomain.EncryptSecret(testSecretKey, testMasterKey)
|
||||
encryptedSecret, err := handlers_iam.EncryptSecret(testSecretKey, testMasterKey)
|
||||
require.NoError(t, err)
|
||||
|
||||
var userPoliciesCalled bool
|
||||
@@ -2195,7 +2195,7 @@ func TestCheckPolicy_SessionNameCollision(t *testing.T) {
|
||||
iamMock := &policyMockIAMService{
|
||||
mockIAMService: mockIAMService{
|
||||
masterKey: testMasterKey,
|
||||
accessKeys: map[string]*iamdomain.AccessKey{
|
||||
accessKeys: map[string]*handlers_iam.AccessKey{
|
||||
testAccessKey: {
|
||||
AccessKeyID: testAccessKey,
|
||||
SecretAccessKey: encryptedSecret,
|
||||
@@ -2205,11 +2205,11 @@ func TestCheckPolicy_SessionNameCollision(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
getUserPoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) {
|
||||
getUserPoliciesFn: func(_, _ string) ([]handlers_iam.PolicyDocument, error) {
|
||||
userPoliciesCalled = true
|
||||
return allowPolicy("ec2:*"), nil
|
||||
},
|
||||
getRolePoliciesFn: func(_, roleName string) ([]iamdomain.PolicyDocument, error) {
|
||||
getRolePoliciesFn: func(_, roleName string) ([]handlers_iam.PolicyDocument, error) {
|
||||
resolvedRoleName = roleName
|
||||
return nil, nil
|
||||
},
|
||||
@@ -2254,18 +2254,18 @@ func TestCheckPolicy_SessionNameCollision(t *testing.T) {
|
||||
}
|
||||
|
||||
// allowPolicy returns a single-statement Allow policy granting action on "*".
|
||||
func allowPolicy(action string) []iamdomain.PolicyDocument {
|
||||
func allowPolicy(action string) []handlers_iam.PolicyDocument {
|
||||
return allowPolicyResource(action, "*")
|
||||
}
|
||||
|
||||
// allowPolicyResource returns a single-statement Allow policy for action on resource.
|
||||
func allowPolicyResource(action, resource string) []iamdomain.PolicyDocument {
|
||||
return []iamdomain.PolicyDocument{{
|
||||
func allowPolicyResource(action, resource string) []handlers_iam.PolicyDocument {
|
||||
return []handlers_iam.PolicyDocument{{
|
||||
Version: "2012-10-17",
|
||||
Statement: []iamdomain.Statement{{
|
||||
Statement: []handlers_iam.Statement{{
|
||||
Effect: "Allow",
|
||||
Action: iamdomain.StringOrArr{action},
|
||||
Resource: iamdomain.StringOrArr{resource},
|
||||
Action: handlers_iam.StringOrArr{action},
|
||||
Resource: handlers_iam.StringOrArr{resource},
|
||||
}},
|
||||
}}
|
||||
}
|
||||
@@ -2288,16 +2288,16 @@ func assumedRoleSessionCred(sessionName, underlyingRoleARN, accountID string) *h
|
||||
// newAssumedRoleEnforcementGateway wires a gateway for assumed-role checkPolicy tests.
|
||||
// The user resolver fails the test if called — an assumed-role must never be evaluated as a user.
|
||||
func newAssumedRoleEnforcementGateway(t *testing.T, cred *handlers_sts.SessionCredential,
|
||||
getRoleFn func(accountID, roleName string) ([]iamdomain.PolicyDocument, error),
|
||||
getRoleFn func(accountID, roleName string) ([]handlers_iam.PolicyDocument, error),
|
||||
) *GatewayConfig {
|
||||
t.Helper()
|
||||
encryptedSecret, err := iamdomain.EncryptSecret(testSecretKey, testMasterKey)
|
||||
encryptedSecret, err := handlers_iam.EncryptSecret(testSecretKey, testMasterKey)
|
||||
require.NoError(t, err)
|
||||
cred.SecretEncrypted = encryptedSecret
|
||||
|
||||
iamMock := &policyMockIAMService{
|
||||
mockIAMService: mockIAMService{masterKey: testMasterKey},
|
||||
getUserPoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) {
|
||||
getUserPoliciesFn: func(_, _ string) ([]handlers_iam.PolicyDocument, error) {
|
||||
t.Errorf("priv-esc: assumed-role principal must not consult GetUserPolicies")
|
||||
return nil, nil
|
||||
},
|
||||
@@ -2344,7 +2344,7 @@ func doSessionPolicyRequest(t *testing.T, handler http.Handler, akid string) *ht
|
||||
// predastore's assumed-role S3 enforcement.
|
||||
func TestCheckPolicy_AssumedRole_AllowedByRolePolicy(t *testing.T) {
|
||||
cred := assumedRoleSessionCred("app", "arn:aws:iam::123456789012:role/app-role", "123456789012")
|
||||
gw := newAssumedRoleEnforcementGateway(t, cred, func(_, roleName string) ([]iamdomain.PolicyDocument, error) {
|
||||
gw := newAssumedRoleEnforcementGateway(t, cred, func(_, roleName string) ([]handlers_iam.PolicyDocument, error) {
|
||||
require.Equal(t, "app-role", roleName)
|
||||
return allowPolicy("ec2:RunInstances"), nil
|
||||
})
|
||||
@@ -2358,7 +2358,7 @@ func TestCheckPolicy_AssumedRole_AllowedByRolePolicy(t *testing.T) {
|
||||
// action nor an explicit Deny, so the default deny applies.
|
||||
func TestCheckPolicy_AssumedRole_ImplicitDeny(t *testing.T) {
|
||||
cred := assumedRoleSessionCred("app", "arn:aws:iam::123456789012:role/app-role", "123456789012")
|
||||
gw := newAssumedRoleEnforcementGateway(t, cred, func(_, _ string) ([]iamdomain.PolicyDocument, error) {
|
||||
gw := newAssumedRoleEnforcementGateway(t, cred, func(_, _ string) ([]handlers_iam.PolicyDocument, error) {
|
||||
return allowPolicy("ec2:DescribeInstances"), nil // does not cover RunInstances
|
||||
})
|
||||
|
||||
@@ -2372,12 +2372,12 @@ func TestCheckPolicy_AssumedRole_ImplicitDeny(t *testing.T) {
|
||||
// overrides any Allow.
|
||||
func TestCheckPolicy_AssumedRole_ExplicitDeny(t *testing.T) {
|
||||
cred := assumedRoleSessionCred("app", "arn:aws:iam::123456789012:role/app-role", "123456789012")
|
||||
gw := newAssumedRoleEnforcementGateway(t, cred, func(_, _ string) ([]iamdomain.PolicyDocument, error) {
|
||||
return []iamdomain.PolicyDocument{{
|
||||
gw := newAssumedRoleEnforcementGateway(t, cred, func(_, _ string) ([]handlers_iam.PolicyDocument, error) {
|
||||
return []handlers_iam.PolicyDocument{{
|
||||
Version: "2012-10-17",
|
||||
Statement: []iamdomain.Statement{
|
||||
{Effect: "Allow", Action: iamdomain.StringOrArr{"ec2:*"}, Resource: iamdomain.StringOrArr{"*"}},
|
||||
{Effect: "Deny", Action: iamdomain.StringOrArr{"ec2:RunInstances"}, Resource: iamdomain.StringOrArr{"*"}},
|
||||
Statement: []handlers_iam.Statement{
|
||||
{Effect: "Allow", Action: handlers_iam.StringOrArr{"ec2:*"}, Resource: handlers_iam.StringOrArr{"*"}},
|
||||
{Effect: "Deny", Action: handlers_iam.StringOrArr{"ec2:RunInstances"}, Resource: handlers_iam.StringOrArr{"*"}},
|
||||
},
|
||||
}}, nil
|
||||
})
|
||||
@@ -2393,7 +2393,7 @@ func TestCheckPolicy_AssumedRole_ExplicitDeny(t *testing.T) {
|
||||
// permissions.
|
||||
func TestCheckPolicy_AssumedRole_ZeroPolicyRole_DenyAll(t *testing.T) {
|
||||
cred := assumedRoleSessionCred("app", "arn:aws:iam::123456789012:role/app-role", "123456789012")
|
||||
gw := newAssumedRoleEnforcementGateway(t, cred, func(_, _ string) ([]iamdomain.PolicyDocument, error) {
|
||||
gw := newAssumedRoleEnforcementGateway(t, cred, func(_, _ string) ([]handlers_iam.PolicyDocument, error) {
|
||||
return nil, nil // role exists, zero attached policies
|
||||
})
|
||||
|
||||
@@ -2411,7 +2411,7 @@ func TestCheckPolicy_AssumedRole_SessionNamedRoot_NoBypass(t *testing.T) {
|
||||
cred := assumedRoleSessionCred("root",
|
||||
"arn:aws:iam::"+awsidentifiers.GlobalAccountID+":role/sneaky-role", awsidentifiers.GlobalAccountID)
|
||||
var roleResolved bool
|
||||
gw := newAssumedRoleEnforcementGateway(t, cred, func(_, roleName string) ([]iamdomain.PolicyDocument, error) {
|
||||
gw := newAssumedRoleEnforcementGateway(t, cred, func(_, roleName string) ([]handlers_iam.PolicyDocument, error) {
|
||||
roleResolved = true
|
||||
require.Equal(t, "sneaky-role", roleName)
|
||||
return nil, nil // role grants nothing
|
||||
@@ -2440,7 +2440,7 @@ func TestCheckPolicy_AssumedRole_EdgeARNs_Denied(t *testing.T) {
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
cred := assumedRoleSessionCred("app", tc.underlyingRoleARN, "123456789012")
|
||||
gw := newAssumedRoleEnforcementGateway(t, cred, func(_, _ string) ([]iamdomain.PolicyDocument, error) {
|
||||
gw := newAssumedRoleEnforcementGateway(t, cred, func(_, _ string) ([]handlers_iam.PolicyDocument, error) {
|
||||
t.Errorf("role resolver must not be consulted for an edge-case ARN (%s)", tc.name)
|
||||
return nil, nil
|
||||
})
|
||||
@@ -2457,7 +2457,7 @@ func TestCheckPolicy_AssumedRole_EdgeARNs_Denied(t *testing.T) {
|
||||
// resolve error fails closed — never an allow-on-error.
|
||||
func TestCheckPolicy_AssumedRole_ResolveError_InternalError(t *testing.T) {
|
||||
cred := assumedRoleSessionCred("app", "arn:aws:iam::123456789012:role/app-role", "123456789012")
|
||||
gw := newAssumedRoleEnforcementGateway(t, cred, func(_, _ string) ([]iamdomain.PolicyDocument, error) {
|
||||
gw := newAssumedRoleEnforcementGateway(t, cred, func(_, _ string) ([]handlers_iam.PolicyDocument, error) {
|
||||
return nil, errors.New(awserrors.ErrorIAMNoSuchEntity) // role deleted after mint
|
||||
})
|
||||
|
||||
@@ -2473,7 +2473,7 @@ func TestCheckPolicy_AssumedRole_TransientNATS_RetriesThenFails(t *testing.T) {
|
||||
shortenPolicyResolveRetry(t)
|
||||
cred := assumedRoleSessionCred("app", "arn:aws:iam::123456789012:role/app-role", "123456789012")
|
||||
var calls int
|
||||
gw := newAssumedRoleEnforcementGateway(t, cred, func(_, _ string) ([]iamdomain.PolicyDocument, error) {
|
||||
gw := newAssumedRoleEnforcementGateway(t, cred, func(_, _ string) ([]handlers_iam.PolicyDocument, error) {
|
||||
calls++
|
||||
return nil, nats.ErrNoResponders
|
||||
})
|
||||
@@ -2491,7 +2491,7 @@ func TestCheckPolicy_AssumedRole_TransientNATS_RetriesThenFails(t *testing.T) {
|
||||
func TestCheckPolicy_AssumedRole_PassRole_ScopedToTargetARN_Allowed(t *testing.T) {
|
||||
const targetRoleARN = "arn:aws:iam::123456789012:role/instance-role"
|
||||
cred := assumedRoleSessionCred("app", "arn:aws:iam::123456789012:role/launcher-role", "123456789012")
|
||||
gw := newAssumedRoleEnforcementGateway(t, cred, func(_, _ string) ([]iamdomain.PolicyDocument, error) {
|
||||
gw := newAssumedRoleEnforcementGateway(t, cred, func(_, _ string) ([]handlers_iam.PolicyDocument, error) {
|
||||
return allowPolicyResource("iam:PassRole", targetRoleARN), nil
|
||||
})
|
||||
|
||||
@@ -2506,7 +2506,7 @@ func TestCheckPolicy_AssumedRole_PassRole_ScopedToTargetARN_Allowed(t *testing.T
|
||||
func TestCheckPolicy_AssumedRole_PassRole_ScopedToOtherARN_Denied(t *testing.T) {
|
||||
const targetRoleARN = "arn:aws:iam::123456789012:role/instance-role"
|
||||
cred := assumedRoleSessionCred("app", "arn:aws:iam::123456789012:role/launcher-role", "123456789012")
|
||||
gw := newAssumedRoleEnforcementGateway(t, cred, func(_, _ string) ([]iamdomain.PolicyDocument, error) {
|
||||
gw := newAssumedRoleEnforcementGateway(t, cred, func(_, _ string) ([]handlers_iam.PolicyDocument, error) {
|
||||
return allowPolicyResource("iam:PassRole", "arn:aws:iam::123456789012:role/some-other-role"), nil
|
||||
})
|
||||
|
||||
@@ -2523,7 +2523,7 @@ func TestCheckPolicy_AssumedRole_PassRole_ScopedToOtherARN_Denied(t *testing.T)
|
||||
func TestCheckPolicy_AssumedRole_PassRole_WildcardResource_Allowed(t *testing.T) {
|
||||
const targetRoleARN = "arn:aws:iam::123456789012:role/instance-role"
|
||||
cred := assumedRoleSessionCred("app", "arn:aws:iam::123456789012:role/launcher-role", "123456789012")
|
||||
gw := newAssumedRoleEnforcementGateway(t, cred, func(_, _ string) ([]iamdomain.PolicyDocument, error) {
|
||||
gw := newAssumedRoleEnforcementGateway(t, cred, func(_, _ string) ([]handlers_iam.PolicyDocument, error) {
|
||||
return allowPolicyResource("iam:PassRole", "*"), nil
|
||||
})
|
||||
|
||||
@@ -2737,12 +2737,12 @@ func TestSigV4Auth_ServedScopeSignatureMismatchStillSignatureDoesNotMatch(t *tes
|
||||
// so before the fix ctxAction stayed empty and every such request shared the
|
||||
// throttle bucket "<account>:unknown", which no per-action limit can match.
|
||||
func TestSigV4Auth_ResolvesCtxActionForNonQueryServices(t *testing.T) {
|
||||
encryptedSecret, err := iamdomain.EncryptSecret(testSecretKey, testMasterKey)
|
||||
encryptedSecret, err := handlers_iam.EncryptSecret(testSecretKey, testMasterKey)
|
||||
require.NoError(t, err)
|
||||
|
||||
mockSvc := &mockIAMService{
|
||||
masterKey: testMasterKey,
|
||||
accessKeys: map[string]*iamdomain.AccessKey{
|
||||
accessKeys: map[string]*handlers_iam.AccessKey{
|
||||
testAccessKey: {
|
||||
AccessKeyID: testAccessKey,
|
||||
SecretAccessKey: encryptedSecret,
|
||||
|
||||
@@ -5,8 +5,8 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvstore"
|
||||
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
@@ -63,7 +63,7 @@ func (s *CredentialStore) Resolve(ctx context.Context, accountID, vendor string)
|
||||
entry, err := kv.Get(ctx, credentialKey(accountID, vendor))
|
||||
switch {
|
||||
case err == nil:
|
||||
plaintext, err := iamdomain.DecryptSecret(string(entry.Value()), s.masterKey)
|
||||
plaintext, err := handlers_iam.DecryptSecret(string(entry.Value()), s.masterKey)
|
||||
if err != nil {
|
||||
return "", false, fmt.Errorf("decrypt credential for %s/%s: %w", accountID, vendor, err)
|
||||
}
|
||||
@@ -84,7 +84,7 @@ func (s *CredentialStore) PutCredential(ctx context.Context, accountID, vendor,
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ciphertext, err := iamdomain.EncryptSecret(key, s.masterKey)
|
||||
ciphertext, err := handlers_iam.EncryptSecret(key, s.masterKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("encrypt credential for %s/%s: %w", accountID, vendor, err)
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/mulgadc/spinifex/internal/testkit"
|
||||
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
|
||||
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -106,11 +106,11 @@ func TestEncryptDecryptSecret_RoundTrip(t *testing.T) {
|
||||
key := fixedMasterKey()
|
||||
plaintext := "sk-ant-test-secret"
|
||||
|
||||
ciphertext, err := iamdomain.EncryptSecret(plaintext, key)
|
||||
ciphertext, err := handlers_iam.EncryptSecret(plaintext, key)
|
||||
require.NoError(t, err)
|
||||
assert.NotEqual(t, plaintext, ciphertext)
|
||||
|
||||
decrypted, err := iamdomain.DecryptSecret(ciphertext, key)
|
||||
decrypted, err := handlers_iam.DecryptSecret(ciphertext, key)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, plaintext, decrypted)
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/mulgadc/bluebottle/pkg/iampolicy"
|
||||
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
|
||||
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -176,7 +176,7 @@ func TestValidatePolicyDocument_AcceptsExactlyTheSupportedConditions(t *testing.
|
||||
for _, key := range allConditionKeys {
|
||||
for op, value := range withIfExists(operatorValues) {
|
||||
doc := conditionDocument(t, op, key, value)
|
||||
_, err := iamdomain.ValidatePolicyDocument(doc)
|
||||
_, err := handlers_iam.ValidatePolicyDocument(doc)
|
||||
|
||||
if iampolicy.SupportedCondition(op, key) {
|
||||
assert.NoError(t, err,
|
||||
@@ -204,7 +204,7 @@ func TestValidatePolicyDocument_AcceptsExactlyTheSubstitutableVariables(t *testi
|
||||
resourceDocument(t, "arn:aws:s3:::reports/"+reference+"/*"),
|
||||
conditionDocument(t, iampolicy.OpStringLike, iampolicy.KeyS3Prefix, reference+"/*"),
|
||||
} {
|
||||
_, err := iamdomain.ValidatePolicyDocument(doc)
|
||||
_, err := handlers_iam.ValidatePolicyDocument(doc)
|
||||
if unresolvable {
|
||||
assert.Error(t, err,
|
||||
"the write path accepts %s, which the evaluator cannot resolve: the policy would "+
|
||||
@@ -220,7 +220,7 @@ func TestValidatePolicyDocument_AcceptsExactlyTheSubstitutableVariables(t *testi
|
||||
|
||||
// A malformed reference resolves nowhere and is rejected on every path.
|
||||
func TestValidatePolicyDocument_RejectsUnterminatedVariable(t *testing.T) {
|
||||
_, err := iamdomain.ValidatePolicyDocument(
|
||||
_, err := handlers_iam.ValidatePolicyDocument(
|
||||
resourceDocument(t, "arn:aws:s3:::reports/"+iampolicy.VariablePrefix+iampolicy.KeyUsername))
|
||||
assert.ErrorContains(t, err, "unterminated policy variable reference")
|
||||
}
|
||||
|
||||
@@ -16,8 +16,8 @@ import (
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/iam"
|
||||
"github.com/mulgadc/bluebottle/pkg/iampolicy"
|
||||
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -279,15 +279,15 @@ func TestRequestConditionKeys_SourceIPWithoutAuthMiddleware(t *testing.T) {
|
||||
|
||||
// conditionedPolicyIAMService serves one statement carrying cond, so a test can
|
||||
// drive a real condition through checkPolicyResources.
|
||||
func conditionedPolicyIAMService(effect string, cond map[string]map[string]iamdomain.ConditionValue) *policyMockIAMService {
|
||||
docs := []iamdomain.PolicyDocument{{
|
||||
func conditionedPolicyIAMService(effect string, cond map[string]map[string]handlers_iam.ConditionValue) *policyMockIAMService {
|
||||
docs := []handlers_iam.PolicyDocument{{
|
||||
Version: "2012-10-17",
|
||||
Statement: []iamdomain.Statement{
|
||||
{Effect: "Allow", Action: iamdomain.StringOrArr{"*"}, Resource: iamdomain.StringOrArr{"*"}},
|
||||
Statement: []handlers_iam.Statement{
|
||||
{Effect: "Allow", Action: handlers_iam.StringOrArr{"*"}, Resource: handlers_iam.StringOrArr{"*"}},
|
||||
{
|
||||
Effect: effect,
|
||||
Action: iamdomain.StringOrArr{"ec2:*"},
|
||||
Resource: iamdomain.StringOrArr{"*"},
|
||||
Action: handlers_iam.StringOrArr{"ec2:*"},
|
||||
Resource: handlers_iam.StringOrArr{"*"},
|
||||
Condition: cond,
|
||||
},
|
||||
},
|
||||
@@ -296,8 +296,8 @@ func conditionedPolicyIAMService(effect string, cond map[string]map[string]iamdo
|
||||
docs[0].Statement = docs[0].Statement[1:]
|
||||
}
|
||||
return &policyMockIAMService{
|
||||
getUserPoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) { return docs, nil },
|
||||
getRolePoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) { return docs, nil },
|
||||
getUserPoliciesFn: func(_, _ string) ([]handlers_iam.PolicyDocument, error) { return docs, nil },
|
||||
getRolePoliciesFn: func(_, _ string) ([]handlers_iam.PolicyDocument, error) { return docs, nil },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -307,7 +307,7 @@ func conditionedPolicyIAMService(effect string, cond map[string]map[string]iamdo
|
||||
func TestCheckPolicyResources_ConditionalAllowHonoursSourceIP(t *testing.T) {
|
||||
gw := &GatewayConfig{
|
||||
DisableLogging: true,
|
||||
IAMService: conditionedPolicyIAMService("Allow", map[string]map[string]iamdomain.ConditionValue{
|
||||
IAMService: conditionedPolicyIAMService("Allow", map[string]map[string]handlers_iam.ConditionValue{
|
||||
iampolicy.OpIPAddress: {iampolicy.KeySourceIP: {"10.0.0.0/8"}},
|
||||
}),
|
||||
}
|
||||
@@ -346,7 +346,7 @@ func TestCheckPolicyResources_ConditionalAllowHonoursSourceIP(t *testing.T) {
|
||||
func TestCheckPolicyResources_ConditionalDenyHonoursSourceIP(t *testing.T) {
|
||||
gw := &GatewayConfig{
|
||||
DisableLogging: true,
|
||||
IAMService: conditionedPolicyIAMService("Deny", map[string]map[string]iamdomain.ConditionValue{
|
||||
IAMService: conditionedPolicyIAMService("Deny", map[string]map[string]handlers_iam.ConditionValue{
|
||||
iampolicy.OpIPAddress: {iampolicy.KeySourceIP: {"203.0.113.0/24"}},
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -18,9 +18,9 @@ import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/mulgadc/bluebottle/pkg/iampolicy"
|
||||
"github.com/mulgadc/spinifex/internal/testkit"
|
||||
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/telemetry"
|
||||
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
|
||||
"github.com/mulgadc/spinifex/spinifex/ingress/aws/dispatch"
|
||||
"github.com/mulgadc/spinifex/spinifex/ingress/aws/envelope"
|
||||
"github.com/stretchr/testify/assert"
|
||||
@@ -135,9 +135,9 @@ func fakeNoResolverRegistration(c *dispatchCapture) dispatch.Registration {
|
||||
// dispatchSeamApp wires SigV4AuthMiddleware -> traceActionEnricher -> Request
|
||||
// on live test NATS, with regs registered through dispatch.NewBuilder, so a
|
||||
// fake service's full pipeline mirrors ecrPipelineApp for a real one.
|
||||
func dispatchSeamApp(t *testing.T, docs []iamdomain.PolicyDocument, regs ...dispatch.Registration) (*GatewayConfig, http.Handler) {
|
||||
func dispatchSeamApp(t *testing.T, docs []handlers_iam.PolicyDocument, regs ...dispatch.Registration) (*GatewayConfig, http.Handler) {
|
||||
t.Helper()
|
||||
encryptedSecret, err := iamdomain.EncryptSecret(testSecretKey, testMasterKey)
|
||||
encryptedSecret, err := handlers_iam.EncryptSecret(testSecretKey, testMasterKey)
|
||||
require.NoError(t, err)
|
||||
_, nc := testutil.StartTestNATS(t)
|
||||
|
||||
@@ -154,7 +154,7 @@ func dispatchSeamApp(t *testing.T, docs []iamdomain.PolicyDocument, regs ...disp
|
||||
IAMService: &policyMockIAMService{
|
||||
mockIAMService: mockIAMService{
|
||||
masterKey: testMasterKey,
|
||||
accessKeys: map[string]*iamdomain.AccessKey{
|
||||
accessKeys: map[string]*handlers_iam.AccessKey{
|
||||
testAccessKey: {
|
||||
AccessKeyID: testAccessKey,
|
||||
SecretAccessKey: encryptedSecret,
|
||||
@@ -164,7 +164,7 @@ func dispatchSeamApp(t *testing.T, docs []iamdomain.PolicyDocument, regs ...disp
|
||||
},
|
||||
},
|
||||
},
|
||||
getUserPoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) { return docs, nil },
|
||||
getUserPoliciesFn: func(_, _ string) ([]handlers_iam.PolicyDocument, error) { return docs, nil },
|
||||
},
|
||||
}
|
||||
|
||||
@@ -257,10 +257,10 @@ func TestDispatchSeam_AuthorizeDeniesOnMismatchAllowsOnMatch(t *testing.T) {
|
||||
|
||||
t.Run("scoped to a different resource is denied", func(t *testing.T) {
|
||||
capt := &dispatchCapture{authorizeResource: allowedResource}
|
||||
scopedDocs := []iamdomain.PolicyDocument{{
|
||||
scopedDocs := []handlers_iam.PolicyDocument{{
|
||||
Version: "2012-10-17",
|
||||
Statement: []iamdomain.Statement{
|
||||
{Effect: "Allow", Action: iamdomain.StringOrArr{"testsvc:FakeAction"}, Resource: iamdomain.StringOrArr{otherResource}},
|
||||
Statement: []handlers_iam.Statement{
|
||||
{Effect: "Allow", Action: handlers_iam.StringOrArr{"testsvc:FakeAction"}, Resource: handlers_iam.StringOrArr{otherResource}},
|
||||
},
|
||||
}}
|
||||
_, app := dispatchSeamApp(t, scopedDocs, fakeJSONRegistration(capt))
|
||||
@@ -275,10 +275,10 @@ func TestDispatchSeam_AuthorizeDeniesOnMismatchAllowsOnMatch(t *testing.T) {
|
||||
|
||||
t.Run("matching policy allows", func(t *testing.T) {
|
||||
capt := &dispatchCapture{authorizeResource: allowedResource}
|
||||
matchingDocs := []iamdomain.PolicyDocument{{
|
||||
matchingDocs := []handlers_iam.PolicyDocument{{
|
||||
Version: "2012-10-17",
|
||||
Statement: []iamdomain.Statement{
|
||||
{Effect: "Allow", Action: iamdomain.StringOrArr{"testsvc:FakeAction"}, Resource: iamdomain.StringOrArr{allowedResource}},
|
||||
Statement: []handlers_iam.Statement{
|
||||
{Effect: "Allow", Action: handlers_iam.StringOrArr{"testsvc:FakeAction"}, Resource: handlers_iam.StringOrArr{allowedResource}},
|
||||
},
|
||||
}}
|
||||
_, app := dispatchSeamApp(t, matchingDocs, fakeJSONRegistration(capt))
|
||||
@@ -365,7 +365,7 @@ func TestDispatchSeam_UnregisteredUnknownServiceStillInvalidAction(t *testing.T)
|
||||
b := dispatch.NewBuilder()
|
||||
require.NoError(t, b.Register(fakeJSONRegistration(&dispatchCapture{})))
|
||||
|
||||
encryptedSecret, err := iamdomain.EncryptSecret(testSecretKey, testMasterKey)
|
||||
encryptedSecret, err := handlers_iam.EncryptSecret(testSecretKey, testMasterKey)
|
||||
require.NoError(t, err)
|
||||
gw := &GatewayConfig{
|
||||
DisableLogging: true,
|
||||
@@ -373,7 +373,7 @@ func TestDispatchSeam_UnregisteredUnknownServiceStillInvalidAction(t *testing.T)
|
||||
Services: b.Build(),
|
||||
IAMService: &mockIAMService{
|
||||
masterKey: testMasterKey,
|
||||
accessKeys: map[string]*iamdomain.AccessKey{
|
||||
accessKeys: map[string]*handlers_iam.AccessKey{
|
||||
testAccessKey: {AccessKeyID: testAccessKey, SecretAccessKey: encryptedSecret, UserName: "root", Status: "Active"},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -10,9 +10,9 @@ import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/arn"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
|
||||
"github.com/mulgadc/spinifex/spinifex/ingress/aws/query"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -24,23 +24,23 @@ const (
|
||||
)
|
||||
|
||||
// scopedPolicyGateway serves one policy document to every principal.
|
||||
func scopedPolicyGateway(statements ...iamdomain.Statement) *GatewayConfig {
|
||||
docs := []iamdomain.PolicyDocument{{Version: "2012-10-17", Statement: statements}}
|
||||
func scopedPolicyGateway(statements ...handlers_iam.Statement) *GatewayConfig {
|
||||
docs := []handlers_iam.PolicyDocument{{Version: "2012-10-17", Statement: statements}}
|
||||
return &GatewayConfig{
|
||||
DisableLogging: true,
|
||||
Region: authzRegion,
|
||||
IAMService: &policyMockIAMService{
|
||||
getUserPoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) { return docs, nil },
|
||||
getRolePoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) { return docs, nil },
|
||||
getUserPoliciesFn: func(_, _ string) ([]handlers_iam.PolicyDocument, error) { return docs, nil },
|
||||
getRolePoliciesFn: func(_, _ string) ([]handlers_iam.PolicyDocument, error) { return docs, nil },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func statement(effect, action, resource string) iamdomain.Statement {
|
||||
return iamdomain.Statement{
|
||||
func statement(effect, action, resource string) handlers_iam.Statement {
|
||||
return handlers_iam.Statement{
|
||||
Effect: effect,
|
||||
Action: iamdomain.StringOrArr{action},
|
||||
Resource: iamdomain.StringOrArr{resource},
|
||||
Action: handlers_iam.StringOrArr{action},
|
||||
Resource: handlers_iam.StringOrArr{resource},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -250,7 +250,7 @@ func TestEC2Request_RejectsOversizedResourceList(t *testing.T) {
|
||||
// per-service beads rest on: passing a real ARN where "*" was passed cannot
|
||||
// withdraw access that works today.
|
||||
func TestEC2Request_AccountWideAllowIsUnaffected(t *testing.T) {
|
||||
for _, policy := range []iamdomain.Statement{
|
||||
for _, policy := range []handlers_iam.Statement{
|
||||
statement("Allow", "ec2:*", "*"),
|
||||
statement("Allow", "*", "*"),
|
||||
} {
|
||||
@@ -318,11 +318,11 @@ type profileIAMService struct {
|
||||
resolveErr error
|
||||
}
|
||||
|
||||
func (s *profileIAMService) ResolveInstanceProfile(accountID, _ string) (*iamdomain.InstanceProfile, error) {
|
||||
func (s *profileIAMService) ResolveInstanceProfile(accountID, _ string) (*handlers_iam.InstanceProfile, error) {
|
||||
if s.resolveErr != nil {
|
||||
return nil, s.resolveErr
|
||||
}
|
||||
return &iamdomain.InstanceProfile{AccountID: accountID, RoleName: "app"}, nil
|
||||
return &handlers_iam.InstanceProfile{AccountID: accountID, RoleName: "app"}, nil
|
||||
}
|
||||
|
||||
func (s *profileIAMService) CanonicalResourceARN(accountID string, _ arn.IAMResourceType, name string) (string, error) {
|
||||
@@ -331,7 +331,7 @@ func (s *profileIAMService) CanonicalResourceARN(accountID string, _ arn.IAMReso
|
||||
|
||||
func passRoleDeniedGateway(t *testing.T, resolveErr error) *GatewayConfig {
|
||||
t.Helper()
|
||||
docs := []iamdomain.PolicyDocument{{Version: "2012-10-17", Statement: []iamdomain.Statement{
|
||||
docs := []handlers_iam.PolicyDocument{{Version: "2012-10-17", Statement: []handlers_iam.Statement{
|
||||
statement("Allow", "ec2:*", "*"),
|
||||
statement("Deny", "iam:PassRole", "*"),
|
||||
}}}
|
||||
@@ -341,7 +341,7 @@ func passRoleDeniedGateway(t *testing.T, resolveErr error) *GatewayConfig {
|
||||
NATSConn: startTestNATS(t),
|
||||
IAMService: &profileIAMService{
|
||||
policyMockIAMService: policyMockIAMService{
|
||||
getUserPoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) { return docs, nil },
|
||||
getUserPoliciesFn: func(_, _ string) ([]handlers_iam.PolicyDocument, error) { return docs, nil },
|
||||
},
|
||||
resolveErr: resolveErr,
|
||||
},
|
||||
|
||||
@@ -7,8 +7,8 @@ import (
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
@@ -22,19 +22,19 @@ const (
|
||||
// auth_test.go's allowPolicy/allowPolicyResource helpers because those are
|
||||
// fixed to a single action string; ECR route classification can require
|
||||
// multiple actions per operation (e.g. cross-repo mount).
|
||||
func ecrAllowPolicy(actions, resources []string) iamdomain.PolicyDocument {
|
||||
return iamdomain.PolicyDocument{
|
||||
func ecrAllowPolicy(actions, resources []string) handlers_iam.PolicyDocument {
|
||||
return handlers_iam.PolicyDocument{
|
||||
Version: "2012-10-17",
|
||||
Statement: []iamdomain.Statement{
|
||||
Statement: []handlers_iam.Statement{
|
||||
{Effect: "Allow", Action: actions, Resource: resources},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func ecrDenyPolicy(actions, resources []string) iamdomain.PolicyDocument {
|
||||
return iamdomain.PolicyDocument{
|
||||
func ecrDenyPolicy(actions, resources []string) handlers_iam.PolicyDocument {
|
||||
return handlers_iam.PolicyDocument{
|
||||
Version: "2012-10-17",
|
||||
Statement: []iamdomain.Statement{
|
||||
Statement: []handlers_iam.Statement{
|
||||
{Effect: "Deny", Action: actions, Resource: resources},
|
||||
},
|
||||
}
|
||||
@@ -43,7 +43,7 @@ func ecrDenyPolicy(actions, resources []string) iamdomain.PolicyDocument {
|
||||
// ecrAuthzTestGateway builds a GatewayConfig wired for ecrOperationAuthorization
|
||||
// tests: a non-nil ECRTokenVerifier (so the middleware is active) and an
|
||||
// ecrMockIAMService seeded with ecrAuthzTestUser's policy documents.
|
||||
func ecrAuthzTestGateway(t *testing.T, policies []iamdomain.PolicyDocument) (*GatewayConfig, *ecrMockIAMService) {
|
||||
func ecrAuthzTestGateway(t *testing.T, policies []handlers_iam.PolicyDocument) (*GatewayConfig, *ecrMockIAMService) {
|
||||
t.Helper()
|
||||
_, verify := newECRAuth(t)
|
||||
iamSvc := newECRMockIAMService()
|
||||
@@ -76,7 +76,7 @@ func TestECROperationAuthorization_PullOnlyMatrix(t *testing.T) {
|
||||
[]string{"ecr:GetDownloadUrlForLayer", "ecr:BatchGetImage", "ecr:BatchCheckLayerAvailability"},
|
||||
[]string{repoARN},
|
||||
)
|
||||
gw, _ := ecrAuthzTestGateway(t, []iamdomain.PolicyDocument{pullOnly})
|
||||
gw, _ := ecrAuthzTestGateway(t, []handlers_iam.PolicyDocument{pullOnly})
|
||||
|
||||
allowed := []struct{ method, path string }{
|
||||
{http.MethodHead, "/v2/team/app/blobs/sha256:abc"},
|
||||
@@ -126,7 +126,7 @@ func TestECROperationAuthorization_ReadOnlyMatrix(t *testing.T) {
|
||||
},
|
||||
[]string{repoARN, wildcardARN},
|
||||
)
|
||||
gw, _ := ecrAuthzTestGateway(t, []iamdomain.PolicyDocument{readOnly})
|
||||
gw, _ := ecrAuthzTestGateway(t, []handlers_iam.PolicyDocument{readOnly})
|
||||
|
||||
allowed := []struct{ method, path string }{
|
||||
{http.MethodGet, "/v2/_catalog"},
|
||||
@@ -166,7 +166,7 @@ func TestECROperationAuthorization_ReadOnlyMatrix(t *testing.T) {
|
||||
func TestECROperationAuthorization_RepoScopedAllowDoesNotCrossRepos(t *testing.T) {
|
||||
repoAARN := "arn:aws:ecr:" + ecrTestRegion + ":" + ecrAuthzTestAccount + ":repository/team/a"
|
||||
policy := ecrAllowPolicy([]string{"ecr:BatchGetImage"}, []string{repoAARN})
|
||||
gw, _ := ecrAuthzTestGateway(t, []iamdomain.PolicyDocument{policy})
|
||||
gw, _ := ecrAuthzTestGateway(t, []handlers_iam.PolicyDocument{policy})
|
||||
|
||||
t.Run("repo A allowed", func(t *testing.T) {
|
||||
next, called := okHandler()
|
||||
@@ -191,7 +191,7 @@ func TestECROperationAuthorization_ExplicitDenyWins(t *testing.T) {
|
||||
repoARN := "arn:aws:ecr:" + ecrTestRegion + ":" + ecrAuthzTestAccount + ":repository/team/app"
|
||||
allow := ecrAllowPolicy([]string{"ecr:BatchGetImage"}, []string{"arn:aws:ecr:" + ecrTestRegion + ":" + ecrAuthzTestAccount + ":repository/*"})
|
||||
deny := ecrDenyPolicy([]string{"ecr:BatchGetImage"}, []string{repoARN})
|
||||
gw, _ := ecrAuthzTestGateway(t, []iamdomain.PolicyDocument{allow, deny})
|
||||
gw, _ := ecrAuthzTestGateway(t, []handlers_iam.PolicyDocument{allow, deny})
|
||||
|
||||
next, called := okHandler()
|
||||
w := httptest.NewRecorder()
|
||||
@@ -207,7 +207,7 @@ func TestECROperationAuthorization_CrossRepoMount(t *testing.T) {
|
||||
sourceARN := "arn:aws:ecr:" + ecrTestRegion + ":" + ecrAuthzTestAccount + ":repository/team/base"
|
||||
|
||||
t.Run("destination write only: denied, never dispatches", func(t *testing.T) {
|
||||
gw, _ := ecrAuthzTestGateway(t, []iamdomain.PolicyDocument{
|
||||
gw, _ := ecrAuthzTestGateway(t, []handlers_iam.PolicyDocument{
|
||||
ecrAllowPolicy([]string{"ecr:InitiateLayerUpload"}, []string{destARN}),
|
||||
})
|
||||
next, called := okHandler()
|
||||
@@ -219,7 +219,7 @@ func TestECROperationAuthorization_CrossRepoMount(t *testing.T) {
|
||||
})
|
||||
|
||||
t.Run("destination write + source read: allowed", func(t *testing.T) {
|
||||
gw, _ := ecrAuthzTestGateway(t, []iamdomain.PolicyDocument{
|
||||
gw, _ := ecrAuthzTestGateway(t, []handlers_iam.PolicyDocument{
|
||||
ecrAllowPolicy([]string{"ecr:InitiateLayerUpload"}, []string{destARN}),
|
||||
ecrAllowPolicy([]string{"ecr:BatchCheckLayerAvailability"}, []string{sourceARN}),
|
||||
})
|
||||
|
||||
@@ -21,9 +21,9 @@ import (
|
||||
"github.com/mulgadc/spinifex/internal/testkit"
|
||||
"github.com/mulgadc/spinifex/spinifex/domains/ecr"
|
||||
awsapi "github.com/mulgadc/spinifex/spinifex/domains/ecr/awsapi"
|
||||
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/telemetry"
|
||||
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
|
||||
"github.com/mulgadc/spinifex/spinifex/ingress/aws/envelope"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -45,9 +45,9 @@ func decodeAuthToken(token string) (string, error) {
|
||||
// ecrPipelineApp wires SigV4AuthMiddleware -> Request as SetupRoutes does, on
|
||||
// live test NATS so Request dispatches. docs is every principal's policy; the
|
||||
// region is testRegion because signTestRequestAs always signs for it.
|
||||
func ecrPipelineApp(t *testing.T, docs []iamdomain.PolicyDocument) (*GatewayConfig, http.Handler) {
|
||||
func ecrPipelineApp(t *testing.T, docs []handlers_iam.PolicyDocument) (*GatewayConfig, http.Handler) {
|
||||
t.Helper()
|
||||
encryptedSecret, err := iamdomain.EncryptSecret(testSecretKey, testMasterKey)
|
||||
encryptedSecret, err := handlers_iam.EncryptSecret(testSecretKey, testMasterKey)
|
||||
require.NoError(t, err)
|
||||
_, nc := testutil.StartTestNATS(t)
|
||||
|
||||
@@ -59,7 +59,7 @@ func ecrPipelineApp(t *testing.T, docs []iamdomain.PolicyDocument) (*GatewayConf
|
||||
IAMService: &policyMockIAMService{
|
||||
mockIAMService: mockIAMService{
|
||||
masterKey: testMasterKey,
|
||||
accessKeys: map[string]*iamdomain.AccessKey{
|
||||
accessKeys: map[string]*handlers_iam.AccessKey{
|
||||
testAccessKey: {
|
||||
AccessKeyID: testAccessKey,
|
||||
SecretAccessKey: encryptedSecret,
|
||||
@@ -69,7 +69,7 @@ func ecrPipelineApp(t *testing.T, docs []iamdomain.PolicyDocument) (*GatewayConf
|
||||
},
|
||||
},
|
||||
},
|
||||
getUserPoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) { return docs, nil },
|
||||
getUserPoliciesFn: func(_, _ string) ([]handlers_iam.PolicyDocument, error) { return docs, nil },
|
||||
},
|
||||
}
|
||||
|
||||
@@ -83,11 +83,11 @@ func ecrPipelineApp(t *testing.T, docs []iamdomain.PolicyDocument) (*GatewayConf
|
||||
|
||||
// allowAllECRDocs is the policy ecrPipelineApp uses when the test is not
|
||||
// exercising authorization itself.
|
||||
func allowAllECRDocs() []iamdomain.PolicyDocument {
|
||||
return []iamdomain.PolicyDocument{{
|
||||
func allowAllECRDocs() []handlers_iam.PolicyDocument {
|
||||
return []handlers_iam.PolicyDocument{{
|
||||
Version: "2012-10-17",
|
||||
Statement: []iamdomain.Statement{
|
||||
{Effect: "Allow", Action: iamdomain.StringOrArr{"*"}, Resource: iamdomain.StringOrArr{"*"}},
|
||||
Statement: []handlers_iam.Statement{
|
||||
{Effect: "Allow", Action: handlers_iam.StringOrArr{"*"}, Resource: handlers_iam.StringOrArr{"*"}},
|
||||
},
|
||||
}}
|
||||
}
|
||||
@@ -362,13 +362,13 @@ func (s *recordingRepoStore) DeleteRepo(context.Context, string, string) error {
|
||||
// ECR dispatch runs, and denies, before a composed capability (here
|
||||
// RepositoryActionService backing CreateRepository) is ever invoked.
|
||||
func TestECRPipeline_AuthzGateBlocksComposedAction(t *testing.T) {
|
||||
scopedDocs := []iamdomain.PolicyDocument{{
|
||||
scopedDocs := []handlers_iam.PolicyDocument{{
|
||||
Version: "2012-10-17",
|
||||
Statement: []iamdomain.Statement{
|
||||
Statement: []handlers_iam.Statement{
|
||||
{
|
||||
Effect: "Allow",
|
||||
Action: iamdomain.StringOrArr{"ecr:CreateRepository"},
|
||||
Resource: iamdomain.StringOrArr{"arn:aws:ecr:" + testRegion + ":" + ecrTestAccount + ":repository/other"},
|
||||
Action: handlers_iam.StringOrArr{"ecr:CreateRepository"},
|
||||
Resource: handlers_iam.StringOrArr{"arn:aws:ecr:" + testRegion + ":" + ecrTestAccount + ":repository/other"},
|
||||
},
|
||||
},
|
||||
}}
|
||||
|
||||
@@ -9,8 +9,8 @@ import (
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/iam"
|
||||
ecrauth "github.com/mulgadc/spinifex/spinifex/domains/ecr/auth"
|
||||
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
|
||||
handlers_sts "github.com/mulgadc/spinifex/spinifex/handlers/sts"
|
||||
)
|
||||
|
||||
@@ -103,7 +103,7 @@ func (gw *GatewayConfig) resolveECRLongLivedPrincipal(claims *ecrauth.Claims) (p
|
||||
if err != nil {
|
||||
return principalContext{}, classifyIAMLookupErr(err, "access key")
|
||||
}
|
||||
if ak.Status != iamdomain.AccessKeyStatusActive {
|
||||
if ak.Status != handlers_iam.AccessKeyStatusActive {
|
||||
return principalContext{}, ecrInvalidPrincipal("access key %q is inactive", claims.AccessKeyID)
|
||||
}
|
||||
if ak.AccountID != claims.AccountID {
|
||||
@@ -238,7 +238,7 @@ func (gw *GatewayConfig) requireActiveECRAccount(accountID string) error {
|
||||
if err != nil {
|
||||
return classifyIAMLookupErr(err, "account")
|
||||
}
|
||||
if account.Status != iamdomain.AccountStatusActive {
|
||||
if account.Status != handlers_iam.AccountStatusActive {
|
||||
return ecrInvalidPrincipal("account %q is not active", accountID)
|
||||
}
|
||||
return nil
|
||||
|
||||
@@ -10,26 +10,26 @@ import (
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/iam"
|
||||
ecrauth "github.com/mulgadc/spinifex/spinifex/domains/ecr/auth"
|
||||
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
|
||||
handlers_sts "github.com/mulgadc/spinifex/spinifex/handlers/sts"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// ecrMockIAMService implements iamdomain.IAMService for ECR principal
|
||||
// ecrMockIAMService implements handlers_iam.IAMService for ECR principal
|
||||
// rehydration tests. Only the methods resolveECRPrincipal calls are wired;
|
||||
// any other method nil-panics on the embedded nil interface, surfacing an
|
||||
// unexpected call rather than silently returning a zero value.
|
||||
type ecrMockIAMService struct {
|
||||
iamdomain.IAMService
|
||||
handlers_iam.IAMService
|
||||
|
||||
accessKeys map[string]*iamdomain.AccessKey
|
||||
accessKeys map[string]*handlers_iam.AccessKey
|
||||
users map[string]*iam.User
|
||||
roles map[string]*iam.Role
|
||||
accounts map[string]*iamdomain.Account
|
||||
userPolicies map[string][]iamdomain.PolicyDocument
|
||||
rolePolicies map[string][]iamdomain.PolicyDocument
|
||||
accounts map[string]*handlers_iam.Account
|
||||
userPolicies map[string][]handlers_iam.PolicyDocument
|
||||
rolePolicies map[string][]handlers_iam.PolicyDocument
|
||||
|
||||
// genericErr, when set, is returned by LookupAccessKey/GetUser/GetRole/
|
||||
// GetAccount/GetUserPolicies/GetRolePolicies instead of the usual
|
||||
@@ -40,16 +40,16 @@ type ecrMockIAMService struct {
|
||||
|
||||
func newECRMockIAMService() *ecrMockIAMService {
|
||||
return &ecrMockIAMService{
|
||||
accessKeys: map[string]*iamdomain.AccessKey{},
|
||||
accessKeys: map[string]*handlers_iam.AccessKey{},
|
||||
users: map[string]*iam.User{},
|
||||
roles: map[string]*iam.Role{},
|
||||
accounts: map[string]*iamdomain.Account{},
|
||||
userPolicies: map[string][]iamdomain.PolicyDocument{},
|
||||
rolePolicies: map[string][]iamdomain.PolicyDocument{},
|
||||
accounts: map[string]*handlers_iam.Account{},
|
||||
userPolicies: map[string][]handlers_iam.PolicyDocument{},
|
||||
rolePolicies: map[string][]handlers_iam.PolicyDocument{},
|
||||
}
|
||||
}
|
||||
|
||||
func (m *ecrMockIAMService) LookupAccessKey(accessKeyID string) (*iamdomain.AccessKey, error) {
|
||||
func (m *ecrMockIAMService) LookupAccessKey(accessKeyID string) (*handlers_iam.AccessKey, error) {
|
||||
if m.genericErr != nil {
|
||||
return nil, m.genericErr
|
||||
}
|
||||
@@ -82,7 +82,7 @@ func (m *ecrMockIAMService) GetRole(accountID string, input *iam.GetRoleInput) (
|
||||
return &iam.GetRoleOutput{Role: r}, nil
|
||||
}
|
||||
|
||||
func (m *ecrMockIAMService) GetAccount(accountID string) (*iamdomain.Account, error) {
|
||||
func (m *ecrMockIAMService) GetAccount(accountID string) (*handlers_iam.Account, error) {
|
||||
if m.genericErr != nil {
|
||||
return nil, m.genericErr
|
||||
}
|
||||
@@ -93,14 +93,14 @@ func (m *ecrMockIAMService) GetAccount(accountID string) (*iamdomain.Account, er
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (m *ecrMockIAMService) GetUserPolicies(accountID, userName string) ([]iamdomain.PolicyDocument, error) {
|
||||
func (m *ecrMockIAMService) GetUserPolicies(accountID, userName string) ([]handlers_iam.PolicyDocument, error) {
|
||||
if m.genericErr != nil {
|
||||
return nil, m.genericErr
|
||||
}
|
||||
return m.userPolicies[accountID+"|"+userName], nil
|
||||
}
|
||||
|
||||
func (m *ecrMockIAMService) GetRolePolicies(accountID, roleName string) ([]iamdomain.PolicyDocument, error) {
|
||||
func (m *ecrMockIAMService) GetRolePolicies(accountID, roleName string) ([]handlers_iam.PolicyDocument, error) {
|
||||
if m.genericErr != nil {
|
||||
return nil, m.genericErr
|
||||
}
|
||||
@@ -150,18 +150,18 @@ const (
|
||||
)
|
||||
|
||||
func seedECRTestUser(iamSvc *ecrMockIAMService, accountID, userName, akid string) {
|
||||
iamSvc.accessKeys[akid] = &iamdomain.AccessKey{
|
||||
iamSvc.accessKeys[akid] = &handlers_iam.AccessKey{
|
||||
AccessKeyID: akid,
|
||||
UserName: userName,
|
||||
AccountID: accountID,
|
||||
Status: iamdomain.AccessKeyStatusActive,
|
||||
Status: handlers_iam.AccessKeyStatusActive,
|
||||
}
|
||||
iamSvc.users[accountID+"|"+userName] = &iam.User{
|
||||
UserName: aws.String(userName),
|
||||
UserId: aws.String("AIDA" + strings.ToUpper(userName)),
|
||||
Arn: aws.String("arn:aws:iam::" + accountID + ":user/" + userName),
|
||||
}
|
||||
iamSvc.accounts[accountID] = &iamdomain.Account{AccountID: accountID, Status: iamdomain.AccountStatusActive}
|
||||
iamSvc.accounts[accountID] = &handlers_iam.Account{AccountID: accountID, Status: handlers_iam.AccountStatusActive}
|
||||
}
|
||||
|
||||
func TestResolveECRPrincipal_LongLivedUser(t *testing.T) {
|
||||
@@ -278,7 +278,7 @@ func TestResolveECRPrincipal_LongLivedUser_Rejections(t *testing.T) {
|
||||
t.Run("suspended account", func(t *testing.T) {
|
||||
iamSvc := newECRMockIAMService()
|
||||
seedECRTestUser(iamSvc, ecrPrincipalTestAccount, "dev", ecrPrincipalTestAKID)
|
||||
iamSvc.accounts[ecrPrincipalTestAccount].Status = iamdomain.AccountStatusSuspended
|
||||
iamSvc.accounts[ecrPrincipalTestAccount].Status = handlers_iam.AccountStatusSuspended
|
||||
gw := &GatewayConfig{IAMService: iamSvc}
|
||||
_, err := gw.resolveECRPrincipal(validClaims())
|
||||
require.Error(t, err)
|
||||
@@ -290,7 +290,7 @@ func TestResolveECRPrincipal_LongLivedUser_Rejections(t *testing.T) {
|
||||
t.Run("terminating account", func(t *testing.T) {
|
||||
iamSvc := newECRMockIAMService()
|
||||
seedECRTestUser(iamSvc, ecrPrincipalTestAccount, "dev", ecrPrincipalTestAKID)
|
||||
iamSvc.accounts[ecrPrincipalTestAccount].Status = iamdomain.AccountStatusTerminating
|
||||
iamSvc.accounts[ecrPrincipalTestAccount].Status = handlers_iam.AccountStatusTerminating
|
||||
gw := &GatewayConfig{IAMService: iamSvc}
|
||||
_, err := gw.resolveECRPrincipal(validClaims())
|
||||
require.Error(t, err)
|
||||
@@ -340,7 +340,7 @@ func seedECRSessionUser(iamSvc *ecrMockIAMService, stsSvc *ecrMockSTSService, ac
|
||||
UserId: aws.String("AIDA" + strings.ToUpper(userName)),
|
||||
Arn: aws.String("arn:aws:iam::" + accountID + ":user/" + userName),
|
||||
}
|
||||
iamSvc.accounts[accountID] = &iamdomain.Account{AccountID: accountID, Status: iamdomain.AccountStatusActive}
|
||||
iamSvc.accounts[accountID] = &handlers_iam.Account{AccountID: accountID, Status: handlers_iam.AccountStatusActive}
|
||||
stsSvc.sessions[asid] = &handlers_sts.SessionCredential{
|
||||
AccessKeyID: asid,
|
||||
AccountID: accountID,
|
||||
@@ -464,7 +464,7 @@ func seedECRAssumedRole(iamSvc *ecrMockIAMService, stsSvc *ecrMockSTSService, ac
|
||||
Arn: aws.String(roleARN),
|
||||
RoleId: aws.String(roleID),
|
||||
}
|
||||
iamSvc.accounts[accountID] = &iamdomain.Account{AccountID: accountID, Status: iamdomain.AccountStatusActive}
|
||||
iamSvc.accounts[accountID] = &handlers_iam.Account{AccountID: accountID, Status: handlers_iam.AccountStatusActive}
|
||||
// AssumeRole session ARNs use the "assumed-role" resource type; the
|
||||
// underlying role (resolved via GetRole) stays a role/ ARN, matching the
|
||||
// production shape.
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ecs"
|
||||
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
|
||||
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -44,19 +44,19 @@ func registerTaskDefinitionBody(t *testing.T, taskRoleARN string) string {
|
||||
// unconditionally (the action-level check gateway/ecs.go:39 always runs
|
||||
// first) plus iam:PassRole scoped to passRoleResource, so tests can isolate
|
||||
// the resource-level PassRole decision from the action-level one.
|
||||
func allowRegisterTaskDefAndPassRole(passRoleResource string) []iamdomain.PolicyDocument {
|
||||
return []iamdomain.PolicyDocument{{
|
||||
func allowRegisterTaskDefAndPassRole(passRoleResource string) []handlers_iam.PolicyDocument {
|
||||
return []handlers_iam.PolicyDocument{{
|
||||
Version: "2012-10-17",
|
||||
Statement: []iamdomain.Statement{
|
||||
{Effect: "Allow", Action: iamdomain.StringOrArr{"ecs:RegisterTaskDefinition"}, Resource: iamdomain.StringOrArr{"*"}},
|
||||
{Effect: "Allow", Action: iamdomain.StringOrArr{"iam:PassRole"}, Resource: iamdomain.StringOrArr{passRoleResource}},
|
||||
Statement: []handlers_iam.Statement{
|
||||
{Effect: "Allow", Action: handlers_iam.StringOrArr{"ecs:RegisterTaskDefinition"}, Resource: handlers_iam.StringOrArr{"*"}},
|
||||
{Effect: "Allow", Action: handlers_iam.StringOrArr{"iam:PassRole"}, Resource: handlers_iam.StringOrArr{passRoleResource}},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
func TestECSRegisterTaskDefinition_DeniedWithoutPassRole(t *testing.T) {
|
||||
mock := &policyMockIAMService{
|
||||
getUserPoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) {
|
||||
getUserPoliciesFn: func(_, _ string) ([]handlers_iam.PolicyDocument, error) {
|
||||
// ecs:RegisterTaskDefinition is granted; iam:PassRole is scoped to a
|
||||
// different role, so the target ARN is specifically denied.
|
||||
return allowRegisterTaskDefAndPassRole("arn:aws:iam::123456789012:role/some-other-role"), nil
|
||||
@@ -73,7 +73,7 @@ func TestECSRegisterTaskDefinition_DeniedWithoutPassRole(t *testing.T) {
|
||||
|
||||
func TestECSRegisterTaskDefinition_AllowedWithPassRole(t *testing.T) {
|
||||
mock := &policyMockIAMService{
|
||||
getUserPoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) {
|
||||
getUserPoliciesFn: func(_, _ string) ([]handlers_iam.PolicyDocument, error) {
|
||||
return allowRegisterTaskDefAndPassRole(testECSPassRoleTargetARN), nil
|
||||
},
|
||||
}
|
||||
@@ -91,7 +91,7 @@ func TestECSRegisterTaskDefinition_AllowedWithPassRole(t *testing.T) {
|
||||
|
||||
func TestECSRegisterTaskDefinition_NoRoleSkipsPassRole(t *testing.T) {
|
||||
mock := &policyMockIAMService{
|
||||
getUserPoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) {
|
||||
getUserPoliciesFn: func(_, _ string) ([]handlers_iam.PolicyDocument, error) {
|
||||
// No iam:PassRole grant at all — if the gateway still consulted
|
||||
// PassRole for a roleless task definition this would deny it.
|
||||
return allowPolicyResource("ecs:RegisterTaskDefinition", "*"), nil
|
||||
@@ -110,7 +110,7 @@ func TestECSRegisterTaskDefinition_NoRoleSkipsPassRole(t *testing.T) {
|
||||
// policy denials stay opaque while retaining the service's 403 envelope.
|
||||
func TestECSRegisterTaskDefinition_DenialRendersAccessDenied403(t *testing.T) {
|
||||
mock := &policyMockIAMService{
|
||||
getUserPoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) {
|
||||
getUserPoliciesFn: func(_, _ string) ([]handlers_iam.PolicyDocument, error) {
|
||||
return allowPolicyResource("ecs:RegisterTaskDefinition", "*"), nil
|
||||
},
|
||||
}
|
||||
|
||||
@@ -5,9 +5,9 @@ import (
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/arn"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -37,11 +37,11 @@ func (s *roleStoreIAMService) CanonicalResourceARN(account string, _ arn.IAMReso
|
||||
|
||||
// The foreign account holds a role of the same name, so only the gateway's
|
||||
// account check stops its ARN from resolving.
|
||||
func eksPassRoleService(statements ...iamdomain.Statement) *roleStoreIAMService {
|
||||
docs := []iamdomain.PolicyDocument{{Version: "2012-10-17", Statement: statements}}
|
||||
func eksPassRoleService(statements ...handlers_iam.Statement) *roleStoreIAMService {
|
||||
docs := []handlers_iam.PolicyDocument{{Version: "2012-10-17", Statement: statements}}
|
||||
return &roleStoreIAMService{
|
||||
policyMockIAMService: policyMockIAMService{
|
||||
getUserPoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) { return docs, nil },
|
||||
getUserPoliciesFn: func(_, _ string) ([]handlers_iam.PolicyDocument, error) { return docs, nil },
|
||||
},
|
||||
roles: map[string]map[string]string{
|
||||
authzAccountID: {"node-role": eksNodeRoleARN},
|
||||
@@ -50,7 +50,7 @@ func eksPassRoleService(statements ...iamdomain.Statement) *roleStoreIAMService
|
||||
}
|
||||
}
|
||||
|
||||
func eksPassRoleGateway(statements ...iamdomain.Statement) *GatewayConfig {
|
||||
func eksPassRoleGateway(statements ...handlers_iam.Statement) *GatewayConfig {
|
||||
return eksGatewayWithIAM(eksPassRoleService(statements...))
|
||||
}
|
||||
|
||||
|
||||
@@ -187,9 +187,6 @@ func accountLoadBalancerLimit(ctx context.Context, gw *GatewayConfig, accountID
|
||||
}
|
||||
_, limits, err := gw.Quota.GetAccountQuota(ctx, accountID)
|
||||
if err != nil {
|
||||
if awserrors.IsErrorCode(err, awserrors.ErrorBlocked) {
|
||||
return 0, err
|
||||
}
|
||||
slog.ErrorContext(ctx, "DescribeAccountLimits: failed to read account quota", "accountID", accountID, "err", err)
|
||||
return 0, errors.New(awserrors.ErrorInternalError)
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/elbv2"
|
||||
"github.com/mulgadc/spinifex/spinifex/domains/admission/quota"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
gateway_elbv2 "github.com/mulgadc/spinifex/spinifex/gateway/elbv2"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -122,20 +121,6 @@ func TestELBv2AccountLoadBalancerLimitDefaultsForExemptAccount(t *testing.T) {
|
||||
assert.Equal(t, gateway_elbv2.DefaultLoadBalancerLimit, limit)
|
||||
}
|
||||
|
||||
// A sealed override must refuse the load balancer cap with Blocked rather
|
||||
// than the generic InternalError every other read failure maps to.
|
||||
func TestELBv2AccountLoadBalancerLimitBlockedOnSealedRecord(t *testing.T) {
|
||||
gw, account := quotaTestGateway(t)
|
||||
gw.Quota.SetAccountTerminationChecker("aStubChecker{terminating: map[string]bool{account: true}})
|
||||
_, err := gw.Quota.SealAccountOverrides(t.Context(), account)
|
||||
require.NoError(t, err)
|
||||
|
||||
limit, err := accountLoadBalancerLimit(t.Context(), gw, account)
|
||||
require.Error(t, err)
|
||||
require.Equal(t, awserrors.ErrorBlocked, err.Error())
|
||||
assert.Zero(t, limit)
|
||||
}
|
||||
|
||||
// The dispatch entry must accept the paging parameters the model defines and
|
||||
// still answer with the whole list.
|
||||
func TestELBv2DescribeAccountLimitsAcceptsPaging(t *testing.T) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user