mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-11-01 16:59:45 +00:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 04c4229c90 | |||
| 99b9fc982b | |||
| b845f7c802 | |||
| 497a8b8bf3 | |||
| 9d3abdecb1 | |||
| 6d3ed2011d | |||
| 25a0f66bbe | |||
| 71193ec3bc |
@@ -192,30 +192,29 @@ func TestADR0001_LayeringCheckerDetectsFaults(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestADR0001_InterimDaemonDirectionCoversRuntimeDaemon: the daemon left its
|
||||
// legacy root, so gateway importing runtime/daemon must still be rejected, and
|
||||
// the former handlers packages are held to it by R5 at their domain homes.
|
||||
func TestADR0001_InterimDaemonDirectionCoversRuntimeDaemon(t *testing.T) {
|
||||
daemon, _ := classify("spinifex/runtime/daemon")
|
||||
checks := []struct{ rule, from string }{
|
||||
{"Interim_DaemonGatewayHandlersDirection", "spinifex/gateway"},
|
||||
{"R5_DomainImportsNoRuntimeOrOperator", "spinifex/domains/ecs"},
|
||||
// TestADR0001_R2_IngressRejectsRuntimeOperatorAndDomains: the request pipeline
|
||||
// lives under ingress/aws, so it must not import the daemon, any runtime or
|
||||
// operator package, or a domain implementation.
|
||||
func TestADR0001_R2_IngressRejectsRuntimeOperatorAndDomains(t *testing.T) {
|
||||
r := ruleByID("R2_IngressImportsNoImplementation")
|
||||
from := "spinifex/ingress/aws/pipeline"
|
||||
f, ok := classify(from)
|
||||
if !ok || !r.subject(f, from) {
|
||||
t.Fatalf("%s is not governed by R2", from)
|
||||
}
|
||||
for _, c := range checks {
|
||||
r := ruleByID(c.rule)
|
||||
f, _ := classify(c.from)
|
||||
if !r.subject(f, c.from) || !r.forbids(f, daemon, c.from) {
|
||||
t.Errorf("%s -> runtime/daemon is not rejected by %s", c.from, c.rule)
|
||||
for _, to := range []string{
|
||||
"spinifex/runtime/daemon", "spinifex/runtime/host/process", "spinifex/runtime/roles/awsgw",
|
||||
"spinifex/operator/admin", "spinifex/domains/ecs", "spinifex/domains/iam/awsapi",
|
||||
} {
|
||||
target, _ := classify(to)
|
||||
if !r.forbids(f, target, from) {
|
||||
t.Errorf("%s -> %s is not rejected by R2", from, to)
|
||||
}
|
||||
}
|
||||
other, _ := classify("spinifex/runtime/host/process")
|
||||
if gw, _ := classify("spinifex/gateway"); ruleByID("Interim_DaemonGatewayHandlersDirection").forbids(gw, other, "spinifex/gateway") {
|
||||
t.Error("gateway -> runtime/host/process is rejected, but it is not the daemon")
|
||||
}
|
||||
}
|
||||
|
||||
// TestADR0002_S7_RequestRecordRejectsOutsiders: the request record is readable
|
||||
// by the gateway and ingress/aws only.
|
||||
// by packages under ingress/aws only.
|
||||
func TestADR0002_S7_RequestRecordRejectsOutsiders(t *testing.T) {
|
||||
record, ok := classify("spinifex/ingress/aws/reqctx")
|
||||
if !ok || !record.requestRecord {
|
||||
@@ -226,7 +225,6 @@ func TestADR0002_S7_RequestRecordRejectsOutsiders(t *testing.T) {
|
||||
"domains/ecs/awsapi": true,
|
||||
"runtime/roles/awsgw": true,
|
||||
"operator/admin": true,
|
||||
"gateway": false,
|
||||
"ingress/aws/dispatch": false,
|
||||
} {
|
||||
from, ok := classify(fullPath(name))
|
||||
|
||||
@@ -29,12 +29,11 @@ const (
|
||||
type domainKind int
|
||||
|
||||
const (
|
||||
notDomain domainKind = iota
|
||||
domainImpl // domains/<d>/... resource implementation
|
||||
domainAWSAPI // domains/<d>/awsapi/... AWS action adapters
|
||||
domainProj // domains/<d>/projection/... authorised view
|
||||
legacyImpl // handlers/<d>/... legacy domain implementation
|
||||
legacyAdapter // gateway/<d>/... legacy AWS action adapters
|
||||
notDomain domainKind = iota
|
||||
domainImpl // domains/<d>/... resource implementation
|
||||
domainAWSAPI // domains/<d>/awsapi/... AWS action adapters
|
||||
domainProj // domains/<d>/projection/... authorised view
|
||||
legacyImpl // handlers/<d>/... legacy domain implementation
|
||||
)
|
||||
|
||||
type class struct {
|
||||
@@ -60,17 +59,8 @@ func (c class) isLegacy(roots ...string) bool {
|
||||
return slices.Contains(roots, c.legacy)
|
||||
}
|
||||
|
||||
// legacyRoots are pre-ADR-0001 application roots still standing.
|
||||
var legacyRoots = map[string]bool{
|
||||
"gateway": true,
|
||||
}
|
||||
|
||||
// gatewayDomains names the domain each gateway/<service> adapter belongs to;
|
||||
// Bedrock is the AWS surface of the Ochre domain.
|
||||
var gatewayDomains = map[string]string{
|
||||
"bedrock": "ochre", "ecs": "ecs", "eks": "eks", "elbv2": "elbv2", "iam": "iam",
|
||||
"rds": "rds", "sts": "sts",
|
||||
}
|
||||
// legacyRoots are pre-ADR-0001 application roots still standing. None remain.
|
||||
var legacyRoots = map[string]bool{}
|
||||
|
||||
// classify maps a module-relative package directory to its layer. ok is false
|
||||
// for a package that has no deliberate classification.
|
||||
@@ -142,8 +132,6 @@ func classifyApp(app []string) (class, bool) {
|
||||
switch {
|
||||
case app[0] == "handlers" && len(app) >= 2:
|
||||
c.domain, c.kind = app[1], legacyImpl
|
||||
case app[0] == "gateway" && len(app) >= 2 && gatewayDomains[app[1]] != "":
|
||||
c.domain, c.kind = gatewayDomains[app[1]], legacyAdapter
|
||||
}
|
||||
return c, true
|
||||
}
|
||||
|
||||
@@ -9,13 +9,11 @@ import (
|
||||
// frozenLegacyPackages is every production package still under a legacy root.
|
||||
// It only shrinks: a new entry is a new legacy package, a missing one is a
|
||||
// completed move whose line must be deleted.
|
||||
var frozenLegacyPackages = []string{
|
||||
"gateway",
|
||||
}
|
||||
var frozenLegacyPackages = []string{}
|
||||
|
||||
// retiredLegacyRoots were legacy roots whose every package has moved to its
|
||||
// ADR-0001 target; recreating one would reopen a closed migration row.
|
||||
var retiredLegacyRoots = []string{"accountteardown", "admin", "daemon", "handlers", "lbagent", "services", "utils", "vpcd"}
|
||||
var retiredLegacyRoots = []string{"accountteardown", "admin", "daemon", "gateway", "handlers", "lbagent", "services", "utils", "vpcd"}
|
||||
|
||||
// TestADR0001_LegacyRootsFrozen: the current-to-target map gives every legacy
|
||||
// root a destination, so none may gain a package and no retired root may return.
|
||||
@@ -35,9 +33,6 @@ func TestADR0001_LegacyRootsFrozen(t *testing.T) {
|
||||
present[name] = true
|
||||
}
|
||||
}
|
||||
if len(present) == 0 {
|
||||
t.Fatal("found no legacy packages; the freeze would pass vacuously")
|
||||
}
|
||||
frozen := map[string]bool{}
|
||||
for _, p := range frozenLegacyPackages {
|
||||
frozen[p] = true
|
||||
|
||||
@@ -126,10 +126,8 @@ var rules = []rule{
|
||||
case t.layer == layerAgents:
|
||||
return true
|
||||
case t.layer == layerIngress:
|
||||
return f.kind != domainAWSAPI && f.kind != legacyAdapter
|
||||
case t.isLegacy("gateway") && !t.domainShaped():
|
||||
return true
|
||||
case t.isLegacy("handlers", "gateway"):
|
||||
return f.kind != domainAWSAPI
|
||||
case t.isLegacy("handlers"):
|
||||
return f.layer == layerDomain
|
||||
}
|
||||
return false
|
||||
@@ -156,29 +154,14 @@ var rules = []rule{
|
||||
},
|
||||
{
|
||||
id: "S7_RequestRecordPrivateToIngress",
|
||||
clause: "ADR-0002:S7: the request record is private to ingress, so no domain, runtime or operator package reads it; only the gateway and packages under ingress/aws may import ingress/aws/reqctx",
|
||||
clause: "ADR-0002:S7: the request record is private to ingress, so no domain, runtime or operator package reads it; only packages under ingress/aws may import ingress/aws/reqctx",
|
||||
hint: "receive what you need through dispatch.Invocation instead of the request record",
|
||||
subject: func(_ class, _ string) bool { return true },
|
||||
forbids: func(_, t class, fromName string) bool {
|
||||
if !t.requestRecord {
|
||||
return false
|
||||
}
|
||||
return fromName != "gateway" && !strings.HasPrefix(fromName, "ingress/aws/")
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "Interim_DaemonGatewayHandlersDirection",
|
||||
clause: "during migration the existing daemon -> gateway -> handlers rule remains the guardrail (Q-20, Q-29)",
|
||||
hint: "keep the dependency pointing daemon -> gateway -> handlers; invert it through a callback or capability",
|
||||
subject: func(f class, _ string) bool { return f.isLegacy("handlers", "gateway") },
|
||||
forbids: func(f, t class, _ string) bool {
|
||||
switch {
|
||||
case f.isLegacy("handlers"):
|
||||
return t.isLegacy("gateway") || t.daemon
|
||||
case f.isLegacy("gateway"):
|
||||
return t.daemon
|
||||
}
|
||||
return false
|
||||
return !strings.HasPrefix(fromName, "ingress/aws/")
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -18,7 +18,6 @@ import (
|
||||
rdsawsapi "github.com/mulgadc/spinifex/spinifex/domains/rds/awsapi"
|
||||
route53awsapi "github.com/mulgadc/spinifex/spinifex/domains/route53/awsapi"
|
||||
stsawsapi "github.com/mulgadc/spinifex/spinifex/domains/sts/awsapi"
|
||||
"github.com/mulgadc/spinifex/spinifex/gateway"
|
||||
"github.com/mulgadc/spinifex/spinifex/ingress/aws/dispatch"
|
||||
|
||||
_ "github.com/mulgadc/bluebottle/pkg/fipsboot"
|
||||
@@ -57,7 +56,7 @@ func fail(err error) {
|
||||
func compareAll() ([]awsmodel.OperationCoverage, error) {
|
||||
// Registered services contribute their declared inventory, which needs no
|
||||
// live dependencies; nothing here serves a request.
|
||||
inventories := gateway.AWSOperationInventory(map[string]dispatch.Inventory{
|
||||
inventories := dispatch.AWSOperationInventory(map[string]dispatch.Inventory{
|
||||
awsapi.ServiceName: awsapi.OperationInventory(),
|
||||
acmawsapi.ServiceName: acmawsapi.OperationInventory(),
|
||||
route53awsapi.ServiceName: route53awsapi.OperationInventory(),
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
//
|
||||
// eks-gateway-fetch -resource addons # GET /clusters/{cluster}/internal-addons/{accountId}
|
||||
// eks-gateway-fetch -resource recovery -instance-id i-… # GET /clusters/{cluster}/internal-recovery/{accountId}/{instanceId}
|
||||
// eks-gateway-fetch -resource workers # GET /clusters/{cluster}/internal-workers/{accountId}
|
||||
//
|
||||
// Flags default to environment variables seeded by cloud-init:
|
||||
// EKS_GATEWAY_URL, EKS_GATEWAY_CA, EKS_ACCESS_KEY, EKS_SECRET_KEY, EKS_REGION,
|
||||
@@ -33,7 +34,7 @@ func main() {
|
||||
flag.StringVar(&cfg.AccountID, "account-id", os.Getenv("EKS_ACCOUNT_ID"), "Cluster account ID")
|
||||
flag.StringVar(&cfg.ClusterName, "cluster", os.Getenv("EKS_CLUSTER_NAME"), "Cluster name")
|
||||
flag.StringVar(&cfg.InstanceID, "instance-id", os.Getenv("EKS_INSTANCE_ID"), "This member's EC2 instance ID (recovery resource)")
|
||||
flag.StringVar(&cfg.Resource, "resource", "addons", "Resource to fetch: addons | recovery")
|
||||
flag.StringVar(&cfg.Resource, "resource", "addons", "Resource to fetch: addons | recovery | workers")
|
||||
flag.Parse()
|
||||
|
||||
if err := gatewayfetch.Run(context.Background(), cfg); err != nil {
|
||||
|
||||
+20
-53
@@ -33,67 +33,32 @@ The gateway (`spinifex/runtime/roles/awsgw/awsgw.go`) is the entry point:
|
||||
// Connect to NATS (retries while the local broker comes up)
|
||||
natsConn, err := natsmsg.ConnectNATSWithRetry(...)
|
||||
|
||||
// Load IAM (master key + JetStream KV) and create the gateway
|
||||
gw := gateway.GatewayConfig{
|
||||
NATSConn: natsConn,
|
||||
Config: nodeConfig.AWSGW.Config,
|
||||
IAMService: iamService,
|
||||
// Load IAM (master key + JetStream KV), then compose the gateway: composeGateway
|
||||
// sets every production hook and wires the service registry from each domain's
|
||||
// awsapi registration.
|
||||
gw, err := composeGateway(gatewayInputs{
|
||||
NATS: natsConn,
|
||||
IAM: iamService,
|
||||
Credentials: credentials,
|
||||
Admission: admission,
|
||||
// ...
|
||||
}
|
||||
}, ecrControlPlane, acmControlPlane, ec2ControlPlane /* ... */)
|
||||
|
||||
// Serve over TLS
|
||||
server.ListenAndServeTLS("", "")
|
||||
```
|
||||
|
||||
Request routing (`spinifex/gateway/gateway.go`):
|
||||
Request routing (`spinifex/ingress/aws/pipeline/gateway.go`):
|
||||
|
||||
1. **Authentication**: SigV4 middleware validates AWS credentials and resolves the account ID
|
||||
2. **Throttling**: Per-account+action token bucket rejects bursts post-auth
|
||||
3. **Service Detection**: Extracts service name from the Authorization header
|
||||
4. **Action Dispatch**: Routes to service-specific handler. A service registered through the ingress dispatch registry (`ingress/aws/dispatch`, wired by `runtime/roles/awsgw`) is dispatched by its registration; currently those are the ECR and ACM control planes, registered from `domains/ecr/awsapi` and `domains/acm/awsapi`. Every other service uses the legacy switch:
|
||||
|
||||
```go
|
||||
switch svc {
|
||||
case "ec2":
|
||||
err = gw.EC2_Request(w, r)
|
||||
case "iam":
|
||||
err = gw.IAM_Request(w, r)
|
||||
case "sts":
|
||||
err = gw.STS_Request(w, r)
|
||||
case "elasticloadbalancing":
|
||||
err = gw.ELBv2_Request(w, r)
|
||||
case "eks":
|
||||
err = gw.EKS_Request(w, r)
|
||||
case "bedrock":
|
||||
err = gw.Bedrock_Request(w, r)
|
||||
// ... bedrock-runtime, bedrock-agent, bedrock-agent-runtime
|
||||
case "ecs":
|
||||
err = gw.ECS_Request(w, r)
|
||||
case "rds":
|
||||
err = gw.RDS_Request(w, r)
|
||||
case "tagging":
|
||||
err = gw.Tagging_Request(w, r)
|
||||
case "spinifex":
|
||||
err = gw.Spinifex_Request(w, r)
|
||||
}
|
||||
```
|
||||
1. **Public routes**: `/oidc/eks/...` and `/v2` (the ECR registry) are mounted ahead of SigV4 by `runtime/roles/awsgw`
|
||||
2. **Authentication**: SigV4 middleware validates AWS credentials and resolves the account ID
|
||||
3. **Throttling**: Per-account+action token bucket rejects bursts post-auth
|
||||
4. **Service Detection**: Extracts service name from the Authorization header
|
||||
5. **Action Dispatch**: Every service is a registration (`ingress/aws/dispatch`) built by its domain's `awsapi.NewRegistration` and wired by `runtime/roles/awsgw`. The pipeline dispatches by registered service name and holds no per-service code. The private `/admin/<Method>` surface dispatches to the registration `awsgw` names as `Admin.Service`.
|
||||
|
||||
### 3. EC2 Handler
|
||||
|
||||
The EC2 handler (`spinifex/gateway/ec2.go`) parses the `Action` parameter and delegates to specific handlers via the generic `ec2Handler` and `ec2HandlerWithReq` wrappers. The account ID resolved by SigV4 auth is threaded into every handler so requests are scoped per IAM principal:
|
||||
|
||||
```go
|
||||
"RunInstances": ec2HandlerWithReq(func(ctx context.Context, input *ec2.RunInstancesInput, gw *GatewayConfig, accountID string, r *http.Request) (any, error) {
|
||||
// ... PassRole and launch-quota checks, then the vCPU quota charge
|
||||
return ec2instanceapi.RunInstances(ctx, input, gw.NATSConn, gw.IAMService, accountID, passRoleCheck, launchQuotaCheck, gw.ExpectedNodes)
|
||||
}),
|
||||
"DescribeInstances": ec2Handler(func(ctx context.Context, input *ec2.DescribeInstancesInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2instanceapi.DescribeInstancesChecked(ctx, input, gw.NATSConn, 0, gw.NodeIDs, accountID)
|
||||
}),
|
||||
// ... + volumes, snapshots, VPCs, subnets, route tables, IGWs, NAT gateways,
|
||||
// security groups, network interfaces, elastic IPs, placement groups, key pairs,
|
||||
// images, tags, capacity reservations, spot instances
|
||||
```
|
||||
The EC2 registration (`spinifex/domains/ec2/awsapi`, `NewRegistration`) parses the `Action` parameter and looks the handler up in its action table. The account ID resolved by SigV4 auth is threaded into every handler so requests are scoped per IAM principal. Each action's handler is built from the owning EC2 resource package (`domains/ec2/instance`, `volume`, `vpc` and so on), with PassRole, quota and client-token idempotency wrapped on at the action table rather than inside the resource.
|
||||
|
||||
### 4. NATS Messaging
|
||||
|
||||
@@ -238,12 +203,14 @@ Viperblock responds with an NBD URI that QEMU uses to access the block device.
|
||||
|
||||
A direct-boot microVM keeps the launcher's saved `vm.Config` as a clean base, and `startQEMU` adds data volumes only to a launch-only copy of it (`runtime/compute/vm/lifecycle.go`).
|
||||
At each start the daemon takes one snapshot of the instance's already-mounted data requests, probes each NBD or host block export from that snapshot, and builds the command from the same snapshot, so the launched endpoint is the one that was checked and is never written back into the saved base.
|
||||
Each data volume becomes a `virtio-blk-device` on the MMIO bus with the same id, node name, I/O thread, serial and `werror=stop,rerror=stop` policy as the PCI device, and takes no PCIe hot-plug port.
|
||||
The microVM boots with `pcie=on`, so its NICs are `virtio-net-pci` devices with MSI-X interrupts instead of `virtio-net-device` MMIO devices.
|
||||
Each data volume becomes a `virtio-blk-pci` device on the default PCIe bus with the same id, node name, I/O thread, serial and `werror=stop,rerror=stop` policy as the q35 device, and takes no PCIe hot-plug port.
|
||||
A microVM started without `pcie=on` keeps the `virtio-*-device` MMIO transports.
|
||||
|
||||
Boot and EFI requests are rejected on this path, and a direct-boot instance with no data requests launches its base unchanged.
|
||||
Disk-boot (q35) instances keep the existing PCI hot-plug path.
|
||||
|
||||
Hot-attaching a volume to a running direct-boot microVM is not supported, because the MMIO bus has no PCIe root port to plug into.
|
||||
Hot-attaching a volume to a running direct-boot microVM is not supported, because the microVM has no PCIe hot-plug root port to plug into.
|
||||
`AttachVolume` refuses it before mounting the volume or changing any instance or volume state, and the EC2 API returns `UnsupportedOperation`.
|
||||
|
||||
This covers only requests that are already mounted when the instance starts; nothing yet supplies data volumes to a system instance, and the path is verified with fake-QEMU unit tests, not with a real EBS volume in a guest.
|
||||
|
||||
@@ -369,6 +369,7 @@ while the branch is in flight; they do not describe the umbrella repository's
|
||||
| `spinifex#1355` | `spinifex/gateway/ec2.go` (`EC2_Request` in production, the legacy service switch, its `supportedServices` and JSON tables) and the `Quota` field of `GatewayConfig` | `spinifex/domains/ec2/awsapi` (`NewRegistration`, `OperationInventory`, `ServiceName`), composed by `spinifex/runtime/roles/awsgw` (`ec2.go`: `newEC2Registration`, `advertisedEndpoint`; `ec2quota`) | Slice 9b of the EC2 move: EC2 registers through the ADR-0002 seam as service `ec2` and the legacy gateway switch is empty. The EC2 query-protocol error envelope is kept through `ErrorEnvelopeXML`, and the policy denial match is now `*dispatch.PolicyDenialError`. The characterisation tests committed first also pin the Authorize service name and action, the trace span and audit action, the throttle key (account plus action) and the cluster-unavailable gate, which runs before policy and action lookup for gateway-local and NATS-backed actions alike. They and the 9a tests pass unchanged apart from setup: STS and EC2 test setups now register the services they sign as, and the two tests that claimed a legacy service name are removed because no legacy name remains. `EC2_Request` survives only as a test shim. Allowlist changes are two deletions (the `gateway` to `domains/ec2/awsapi` and `domains/ec2/awsapi/instance` rows) and one addition, the per-service model-coverage row `cmd/aws-model-coverage` to `domains/ec2/awsapi`. `startInstanceStatus` stays in `awsgw.go` because ADR-0007:S6 records the raw instance-state uses by file. No change to `dispatch.Actor` or `dispatch.Invocation`. Live on env14: DescribeRegions, DescribeAvailabilityZones and DescribeAccountAttributes, an idempotent CreateVolume replay and its parameter mismatch, the DryRun and InvalidVolume.NotFound errors, a no-policy user refused with UnauthorizedOperation (including on the gateway-local DescribeRegions), an assumed-role session allowed and refused per its policy, and one RunInstances to running then terminated. The gateway request logs carry `service=ec2` and the per-action label. Not exercised live: a throttled request (the default limits are not reachable from one client; the throttle key is pinned by the characterisation tests) and an agent-signed EC2 call. |
|
||||
| `spinifex#1363` | `spinifex/gateway` context keys (`ctxService`, `ctxRegion`, `ctxAction`, the principal and access key id keys, and the unread `ctxQueryArgs`), the duplicate `callerPrincipal` and `principalContext` types, `gateway/caller_arn.go` (`buildCallerARN`), and the direct `policyauth`, `accountadmission` and NATS-connection reads in the auth pipeline | `spinifex/ingress/aws/reqctx` (the request record), `credverify.CallerARN`, and the gateway-consumed capabilities `PolicyEvaluator`, `AccountAdmission` and `Readiness`, composed by `spinifex/runtime/roles/awsgw` (`clusterready`) | Slice 11a of the gateway removal: the request-context record and the ADR-0002:S8 capabilities, with no behaviour change. The record is a copy-on-write value under one context key; the principal is sealed once and a second `WithPrincipal` keeps the first caller. The layering rule `S7_RequestRecordPrivateToIngress` lets only `gateway` and `ingress/aws/*` import `reqctx`, with a fault-injection test, and slice 11c moves the package to `ingress/aws/internal/reqctx` and drops the rule. The caller ARN is built once in `credverify` (root cases kept) and filled on `Principal.CallerARN`. The policy evaluator, account admission (`credential.ErrAccountNotActive`, with `accountadmission.ErrNotActive` an alias) and cluster readiness (`Available() bool`) are interfaces the gateway consumes and fails closed on when nil. The step 1 characterisation tests (throttle against both readiness gates and the policy gate for ECS and IAM, `/admin` skipping `GetService` and the second gate, the nil-connection difference between the two gates, five error writers each minting a request id that differs from the audit record, and the credential-scope region reaching only the span) pass unchanged apart from setup. No allowlist rows change, and there is no change to `dispatch.Actor` or `dispatch.Invocation`. Debt for slice 11c: the test-only `defaultCapabilities` seam (a package variable set by a test `init` so about 60 gateway test files can build a config from `IAMService` alone), the nil-connection case of the early readiness gate, the remaining `NATSConn` readers (`DiscoverActiveNodes` and the field that `awsgw` and the integration harness set), and the `IAMService`, `STSService`, `Config`, `RootCAs` and `InternalSuffix` fields of `GatewayConfig`, which no production code reads. Live on env14: DescribeRegions, DescribeAvailabilityZones and DescribeAccountAttributes, an idempotent CreateVolume replay and its parameter mismatch, the DryRun and InvalidVolume.NotFound errors, a bad access key refused with InvalidClientTokenId on a Query service and InvalidClientTokenIdException on a JSON service, a no-policy user refused with UnauthorizedOperation, and an assumed-role session allowed and refused per its policy. Not exercised live: a suspended-account refusal, a throttled request and the NATS-down 503, which the step 1 tests pin. |
|
||||
| `spinifex#1378` | `spinifex/gateway` `oidc_discovery.go` and `registry.go` (the `/oidc/eks` routes and the `/v2` mount), the `DiscoverActiveNodes` method and its cache, and the `GatewayConfig` fields `Config`, `RootCAs`, `InternalSuffix`, `Version`, `Commit`, `IAMService`, `STSService`, `NATSConn` and `ExpectedNodes` | `spinifex/runtime/roles/awsgw` (`PublicRoutes`, `NodeDiscovery.ActiveNodes`, `composeGateway`); the gateway keeps one `PublicRoutes` mount hook and the `Admin` registration name supplied by the role |
|
||||
| `spinifex#1384` | `spinifex/gateway` (`GatewayConfig`, the SigV4 and policy middleware, `Request`, the admin dispatch, `AWSOperationInventory`) | `spinifex/ingress/aws/pipeline` (`Config`) and `spinifex/ingress/aws/dispatch` (`AWSOperationInventory`); the gateway layering rows are retired and R2 binds the pipeline |
|
||||
| `spinifex#1334` | `spinifex/gateway/oidc_discovery.go` (`eksAccountKV`, the discovery-document and JWKS read and construction) | `spinifex/domains/eks/cluster` (`OIDCPublisher`, `OIDCDiscoveryDocument`), composed by `spinifex/runtime/roles/awsgw` | The EKS domain now reads its own account bucket and builds the per-cluster discovery document and JWKS responses (ADR-0005:S4), and the gateway only maps the unauthenticated `/oidc/eks/...` routes onto an `OIDCPublisher` interface it is handed. Responses are byte-identical, pinned by `oidc_discovery_current_behaviour_test.go`, whose assertions are unchanged. The gateway no longer imports `domains/eks/cluster` in production code. `STS` keeps its own `ClusterJWKSResolver` (`OIDCJWKSReader`), untouched. Recorded debt, not repaired here: a stored JWKS is served verbatim with 200 even when it is not valid JSON. |
|
||||
The EC2 contract row is intentionally different from the directory moves: it creates
|
||||
a compatibility boundary. `ec2.cmd.*` retains its deployed one-token NATS
|
||||
@@ -687,7 +688,7 @@ series above; "needs decision" is open.
|
||||
| `handlers/rds` | — | Done. The remaining RDS instance resource moved to `domains/rds/instance` (package `instance`) in `spinifex#1251` with no API, NATS subject, storage or behaviour change, fulfilling ADR-0001's organise-by-resource condition alongside the already-extracted subnet group, parameter group and engine catalogue. The ADR-0004 blocked-dependency imports (quota admission, the duplicated expected-node fan-out, the IAM ensurer and the Bluebottle master key) remain dependency-rule debt, with their allowlist entries now sourced from `domains/rds/instance`. |
|
||||
| `handlers/elbv2` | — | Done. The implementation moved to `domains/elbv2` (package `elbv2`) with no API, NATS subject, storage or behaviour change. Q-30's data-plane boundary remains deferred debt; the amended ADR-0001 row makes it not a relocation prerequisite. Its cross-domain, IAM and `agents/elbv2` imports remain dependency-rule debt, with their allowlist entries now sourced from `domains/elbv2`. |
|
||||
| `handlers/iam/mock` | — | Done. Moved with the implementation to `domains/iam/mock`. |
|
||||
| `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` (root) | Generic HTTP route assembly, SigV4 authentication, the policy gate, request-audit middleware and request-context keys. | Done and deleted. The request pipeline moved into `ingress/aws/pipeline` (as `pipeline.Config`) and the operation inventory into `ingress/aws/dispatch` in `spinifex#1384`; the `/oidc`, `/v2` and node-discovery code had already moved to `runtime/roles/awsgw` in `spinifex#1378`. The layering test binds the pipeline with R2 and `gateway` is a retired legacy root. |
|
||||
| `gateway/bedrock` | — | Done. The whole package moved into `domains/ochre` in `spinifex#1266` with its package-private core and tests intact and no behaviour change. Separating the action adapters into `domains/ochre/awsapi` remains deferred, recorded as `domains/ochre` (action adapters) debt above. The action adapters then moved to `domains/ochre/awsapi` behind one ADR-0002 registration in `spinifex#1351`. |
|
||||
| `gateway/{iam,sts,eks,rds}` | — | Done. The action adapters moved to `domains/{iam,sts,eks,rds}/awsapi` in `spinifex#1265` with no API, NATS subject, storage or behaviour change. ADR-0001 does not make ADR-0002 registration a prerequisite for relocating a domain's adapters, as the EC2 adapter moves already showed; the root `gateway` dispatch that still imports them is recorded `gateway` (service registration) debt. |
|
||||
| `gateway/{ecs,elbv2}` | — | Done. The action adapters moved to `domains/{ecs,elbv2}/awsapi` in `spinifex#1273` with no API, NATS subject, storage or behaviour change; the root `gateway` dispatch that still imports them is recorded `gateway` (service registration) debt. |
|
||||
|
||||
@@ -164,7 +164,7 @@ On Oracle Linux the installer makes these changes, which Debian hosts do not nee
|
||||
- **Repositories.** It enables `ol10_UEKR8` and `ol10_kvm_utils`, and adds `qemu-kvm*` and `qemu-img*` to the `excludepkgs` of `ol10_appstream`, keeping any excludes already set, so a later AppStream build can never replace the KVM Utilities QEMU.
|
||||
- **NFV SIG.** It writes `/etc/yum.repos.d/spinifex-nfv-sig.repo` with an `includepkgs=` list of the OVS and OVN packages, and imports the SIG key to `/etc/pki/rpm-gpg/RPM-GPG-KEY-CentOS-SIG-NFV` only when its fingerprint is `35154228174901BEFA8E69A621465E289D2A76A7`.
|
||||
- **OVS and OVN user.** Both run as root, as on Debian, through `OVS_USER_ID` and `OVN_USER_ID` in `/etc/sysconfig/openvswitch` and `/etc/sysconfig/ovn`.
|
||||
- **DNS.** Where systemd-resolved is installed but not running, it saves `/etc/resolv.conf` to `/var/lib/spinifex-reset/resolv.conf.orig`, starts resolved behind NetworkManager and links `/etc/resolv.conf` to the resolved stub. It then checks that names still resolve and stops, printing the commands that put DNS back, if they do not.
|
||||
- **DNS.** Where systemd-resolved is installed but not running, it saves `/etc/resolv.conf` to `/var/lib/spinifex-reset/resolv.conf.orig`, starts resolved behind NetworkManager, tells NetworkManager in `/etc/NetworkManager/conf.d/90-spinifex-dns.conf` to leave `/etc/resolv.conf` alone, and links `/etc/resolv.conf` to the resolved stub. It then checks that names still resolve and stops, printing the commands that put DNS back, if they do not.
|
||||
- **chrony.** It appends `confdir /etc/chrony.d` to `/etc/chrony.conf` once, and writes its step policy to `/etc/chrony.d/spinifex.conf`.
|
||||
- **`spx` under `sudo`.** Oracle Linux's `sudo` path leaves out `/usr/local/bin`, so the installer links `/usr/bin/spx` to `/usr/local/bin/spx`.
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ There is no generation, so the record mixes desired fields with the latest obser
|
||||
|
||||
| Item | Present behaviour | Evidence |
|
||||
|---|---|---|
|
||||
| Identity | Cluster name plus add-on name; one add-on per name per cluster. ARN `arn:aws:eks:{region}:{account}:addon/{cluster}/{addon}` (`foundation/aws/arn/eks.go` `FormatEKSAddon`), deterministic by name, with no per-incarnation suffix. The gateway authorizer derives the same ARN (`gateway/eks/authz.go` `addonARN`). | `handlers/eks/addon_lifecycle_test.go` `TestAddonPersistedLayout_RecordAndManifestKeysAndFieldNames`, `gateway/eks/authz_test.go` `TestResourceARNsFidelity` |
|
||||
| Identity | Cluster name plus add-on name; one add-on per name per cluster. ARN `arn:aws:eks:{region}:{account}:addon/{cluster}/{addon}` (`foundation/aws/arn/eks.go` `FormatEKSAddon`), deterministic by name, with no per-incarnation suffix. The gateway authorizer derives the same ARN (`domains/eks/awsapi/authz.go` `addonARN`). | `handlers/eks/addon_lifecycle_test.go` `TestAddonPersistedLayout_RecordAndManifestKeysAndFieldNames`, `domains/eks/awsapi/authz_test.go` `TestResourceARNsFidelity` |
|
||||
| Scope | Account and Region, within one cluster: keys live under `clusters/{cluster}/addons/` in the `eks-account-{account}` bucket. | `TestAddonPersistedLayout_RecordAndManifestKeysAndFieldNames`, `TestDeleteClusterPrefix_SweepsAddonRecordsAndManifestsScopedToCluster` |
|
||||
| Durable record | `clusters/{cluster}/addons/{addon}` holds `Record` (`domains/eks/addon/record.go`): `addonName`, `addonVersion`, `status`, `serviceAccountRoleArn`, `configurationValues`, `health`, `arn`, `tags`, `createdAt`, `modifiedAt` (the optional ones `omitempty`). `clusters/{cluster}/addons/{addon}/manifest` holds `Manifest` (`domains/eks/addon/record.go`): `addonName`, `addonVersion`, `serviceAccountRoleArn`, `configurationValues`. Listing skips the manifest sub-key by segment count. | `TestAddonPersistedLayout_RecordAndManifestKeysAndFieldNames`, `TestAddonRecord_DecodesHandWrittenJSON`, `domains/eks/addon/store_test.go` `TestRecord_RoundTrip`, `TestList_SkipsManifestSubKeys` |
|
||||
| Writers | Record: the owner in `domains/eks/addon/owner.go`, reached from the `CreateAddon`, `UpdateAddon` and `DeleteAddon` handlers, the cluster reconciler's report relay and the GPU node group launch: `Owner.Create` (unconditional put), `Owner.Update` and its failure marking (revision CAS), `Owner.Delete` (get then delete), `Owner.ApplyReport` (revision CAS), `Owner.EnsureGPUDevicePlugin` through `Owner.Create`; and the cluster prefix purge (`DeleteClusterPrefix`). Manifest: `StagingInstaller.Install` (unconditional put on create and update), `StagingInstaller.Uninstall` (`domains/eks/addon/delivery.go`), `Owner.Delete` and the prefix purge. | `TestAddonPersistedLayout_RecordAndManifestKeysAndFieldNames`, `TestUpdateAddon_ReportsSuccessfulWhileRecordIsUpdating`, `TestDeleteAddon_ReturnsDeletingAfterErasingRecordAndManifest`, `TestDeleteClusterPrefix_SweepsAddonRecordsAndManifestsScopedToCluster`, `domains/eks/addon/store_test.go` `TestCasUpdate`, `TestDeleteRecord_RemovesRecordAndManifest` |
|
||||
@@ -29,16 +29,16 @@ There is no generation, so the record mixes desired fields with the latest obser
|
||||
| Readiness | `ready` maps any non-`ACTIVE` status to `ACTIVE` and clears `health`; `applied` and unknown phases change nothing. The agent reports `ready` when K3s records the add-on's `gvks` annotation, or after a 90 s grace once the bundle's workloads are rolled out; the check is per rendered file, not per version. | `domains/eks/addon/owner_test.go` `TestNextStatus`, `TestOwnerApplyReport`, `handlers/eks/addon_status_reconciler_test.go` `TestApplyAddonStatusReport`, `tests/e2e/eks/eks_test.go` `TestEKS/AddonDelivery` |
|
||||
| Failure | Staging failure on create or update marks the record `CREATE_FAILED` with the error as `health` and returns the raw error. A `failed` report maps `ACTIVE` to `DEGRADED`, keeps `DEGRADED` and `CREATE_FAILED`, and maps anything else (including `UPDATING`) to `CREATE_FAILED`. Neither failure state is terminal: a later `ready` report returns the add-on to `ACTIVE`. | `TestCreateAddon_InstallerFailureLeavesDurableCreateFailedRecord`, `TestUpdateAddon_InstallerFailureMarksCreateFailed`, `TestAddonStatusReport_MismatchedVersionIsAccepted`, `handlers/eks/addons_test.go` `TestCreateAddon_InstallerFailureMarksCreateFailed` |
|
||||
| Delete | Read the record, build a `DELETING` response from it, delete the manifest, then delete the record and manifest again (`Owner.Delete`). Both keys are gone when the call returns. The guest later notices the missing manifest, removes the rendered file and runs `kubectl delete --wait=false`; it sends no report, so nothing observes removal. | `TestDeleteAddon_ReturnsDeletingAfterErasingRecordAndManifest`, `TestListStagedAddonManifests`, `tests/e2e/eks/eks_test.go` `TestEKS/AddonDelivery` |
|
||||
| Guest fetch | `GET /clusters/{cluster}/internal-addons/{accountId}` (`gateway/eks.go`, `ListInternalAddons`) is admitted only for a control-plane agent serving that cluster (`AuthorizeInternal`), relays to `eks.ListStagedAddonManifests`, and wraps the result as `contracts/eks/v1` `InternalAddonsResponse`. The body is written by the SDK REST-JSON marshaller, which ignores the `json` tags: it is `{"Addons":[{"AddonName","AddonVersion","ServiceAccountRoleArn","ConfigurationValues"}]}` with empty strings present, sorted by add-on name. | `gateway/eks_addon_wire_test.go` `TestEKSRequest_InternalAddonsWireBody`, `contracts/eks/v1` `TestInternalAddonsResponse_RESTJSONBody`, `gateway/eks_authz_test.go` `TestEKSRequest_InternalRoutesAdmitTheCPAgentPrincipal`, `gateway/eks/internal_authz_test.go` `TestAuthorizeInternal_RejectsClusterTheCallerDoesNotServe` |
|
||||
| Guest fetch | `GET /clusters/{cluster}/internal-addons/{accountId}` (`domains/eks/awsapi/internal_addons.go`, `ListInternalAddons`) is admitted only for a control-plane agent serving that cluster (`AuthorizeInternal`), relays to `eks.ListStagedAddonManifests`, and wraps the result as `contracts/eks/v1` `InternalAddonsResponse`. The body is written by the SDK REST-JSON marshaller, which ignores the `json` tags: it is `{"Addons":[{"AddonName","AddonVersion","ServiceAccountRoleArn","ConfigurationValues"}]}` with empty strings present, sorted by add-on name. | `domains/eks/awsapi/gateway_wire_test.go` `TestEKSRequest_InternalAddonsWireBody`, `contracts/eks/v1` `TestInternalAddonsResponse_RESTJSONBody`, `domains/eks/awsapi/gateway_authz_test.go` `TestEKSRequest_InternalRoutesAdmitTheCPAgentPrincipal`, `domains/eks/awsapi/internal_authz_test.go` `TestAuthorizeInternal_RejectsClusterTheCallerDoesNotServe` |
|
||||
| TSV conversion | `eks-gateway-fetch -resource addons` (`agents/eks/gatewayfetch`) decodes the body into its own copy of the shape (matching the Go field names only because `encoding/json` is case-insensitive) and writes `name\tversion\troleArn\tbase64(config)` per add-on; an empty set writes nothing. It retries the GET up to 30 times at 5 s. | `agents/eks/gatewayfetch/addons_wire_test.go` `TestEmitAddonsTSV_GatewayWireBody`, `agents/eks/gatewayfetch/emit_test.go` `TestEmitAddonsTSV`, `TestEmitAddonsTSV_Empty`, `agents/eks/gatewayfetch/fetch_test.go` `TestRun_RendersAddons`, `TestRun_FailsAfterAttemptBudget` |
|
||||
| Guest render and report | Every 30 s (`ADDON_SYNC_INTERVAL`) the agent renders each staged add-on, substituting the role ARN into web-identity env when set, then publishes `{"addon","version","phase","message","ts"}` through `eks-gateway-publish -channel addon`. The gateway relays the raw payload to core NATS `eks.addon.{account}.{cluster}.status` (`gateway/eks/internal_publish.go`). A fetch failure keeps the current render. | `gateway/eks/internal_publish_test.go` `TestPublishInternal_RelaysToSubjects`, `agents/eks/gatewaypublish/publish_test.go` `TestRun_PublishesWrappedPayload`, `TestRun_FailsAfterAttemptBudget` |
|
||||
| Guest render and report | Every 30 s (`ADDON_SYNC_INTERVAL`) the agent renders each staged add-on, substituting the role ARN into web-identity env when set, then publishes `{"addon","version","phase","message","ts"}` through `eks-gateway-publish -channel addon`. The gateway relays the raw payload to core NATS `eks.addon.{account}.{cluster}.status` (`domains/eks/awsapi/internal_publish.go`). A fetch failure keeps the current render. | `domains/eks/awsapi/internal_publish_test.go` `TestPublishInternal_RelaysToSubjects`, `agents/eks/gatewaypublish/publish_test.go` `TestRun_PublishesWrappedPayload`, `TestRun_FailsAfterAttemptBudget` |
|
||||
| Retry meaning | Reports are level-triggered: each tick re-sends the current phase for every staged add-on, so a lost report is repeated on the next tick. `eks-gateway-publish` retries the POST up to 30 times at 5 s and the agent ignores its failure. Core NATS keeps nothing: a report published while no reconciler holds the cluster's lease, or after the reconciler stops on a terminal cluster status, is dropped. | none |
|
||||
| Report ordering | The reconciler subscribes plainly (no queue group) on the lease holder and applies reports in arrival order through a revision CAS. Nothing orders a report against a later update: a report produced for the previous version settles the new `UPDATING` record, and a stale `failed` turns it `CREATE_FAILED`. A report for a deleted add-on is a no-op. | `TestAddonStatusReport_MismatchedVersionIsAccepted`, `TestDeleteAddon_ReturnsDeletingAfterErasingRecordAndManifest` |
|
||||
| Cross-resource dependency | A GPU node group's launch calls `stageGPUDeviceAddon`, which checks the cluster exists and calls `Owner.EnsureGPUDevicePlugin` for `nvidia-device-plugin`; an existing record is a no-op and every other error is logged only. Deleting the node group never unstages it, and an existing record in any status, including `CREATE_FAILED`, blocks re-staging. | `handlers/eks/nodegroup_test.go` `TestCreateNodegroup_GPUNodegroupStagesDevicePluginAddon`, `domains/eks/addon/owner_test.go` `TestOwnerEnsureGPUDevicePlugin_StagesDefaultVersionOnceAndKeepsAnyExistingRecord`, `TestCreateNodegroup_NonGPUNodegroupDoesNotStageDevicePluginAddon`, `tests/e2e/gpu/eks_gpu_test.go` `TestEKSGPUPodExposure` |
|
||||
| Idempotency | `clientRequestToken` is ignored. A duplicate create, including an SDK retry with the same token, returns `ResourceInUseException`. A repeated update re-stages and returns another `Successful` with the same id. A repeated delete returns `ResourceNotFoundException`. | `TestCreateAddon_RepeatedClientRequestTokenIsResourceInUse`, `TestUpdateAddon_ReportsSuccessfulWhileRecordIsUpdating`, `TestDeleteAddon_ReturnsDeletingAfterErasingRecordAndManifest`, `handlers/eks/addons_test.go` `TestCreateAddon_CreatesStagingAndDefaultsVersion` |
|
||||
| Absent resource | An unknown cluster returns `ResourceNotFoundException` for every action, including the internal manifest listing. A missing add-on returns `ResourceNotFoundException` from describe, update and delete. | `handlers/eks/addons_test.go` `TestCreateAddon_UnknownClusterIsNotFound`, `TestDescribeDeleteAddon_MissingIsNotFound`, `TestUpdateAddon_MissingIsNotFound`, `TestListStagedAddonManifests` |
|
||||
| Dependencies | Only the cluster meta's existence is checked; cluster status is not read, so add-ons can be created on a `DELETING` cluster. | `TestAddonActionsIgnoreClusterLifecycleStatus` |
|
||||
| Authorization | Gateway policy uses the add-on ARN above. `serviceAccountRoleArn` requires `iam:PassRole` on `CreateAddon` and `UpdateAddon` (`gateway/eks/passrole.go`), the same check `CreateCluster`/`CreateNodegroup` apply, including that the role exists. Tag actions on an add-on ARN return `NotImplemented`. | `gateway/eks_addon_wire_test.go` `TestPassedRoleARNs_AddonServiceAccountRoleIsChecked`, `gateway/eks_passrole_test.go` `TestEKSCreateAddon_PassRole`, `TestEKSUpdateAddon_PassRole`, `gateway/eks/authz_test.go` `TestResourceARNsFidelity` |
|
||||
| Authorization | Gateway policy uses the add-on ARN above. `serviceAccountRoleArn` requires `iam:PassRole` on `CreateAddon` and `UpdateAddon` (`domains/eks/awsapi/passrole.go`), the same check `CreateCluster`/`CreateNodegroup` apply, including that the role exists. Tag actions on an add-on ARN return `NotImplemented`. | `domains/eks/awsapi/gateway_wire_test.go` `TestPassedRoleARNs_AddonServiceAccountRoleIsChecked`, `domains/eks/awsapi/gateway_passrole_test.go` `TestEKSCreateAddon_PassRole`, `TestEKSUpdateAddon_PassRole`, `domains/eks/awsapi/authz_test.go` `TestResourceARNsFidelity` |
|
||||
| Cluster deletion | Add-on records and manifests are erased only by the cluster prefix purge, after the cluster's infrastructure teardown succeeds; there is no per-add-on delete. Reclaiming a failed cluster keeps the meta and does not purge, so the recreated cluster inherits the add-ons. | `TestDeleteClusterPrefix_SweepsAddonRecordsAndManifestsScopedToCluster`, `handlers/eks/cluster_state_test.go` `TestDeleteClusterPrefix_SweepsEveryKey` |
|
||||
| Restart and interruption | Create interrupted between the record put and staging leaves a `CREATING` record with no manifest that nothing re-stages and that blocks a retry. Delete interrupted after unstaging leaves a record in its previous status while the guest removes the software. An update interrupted after the CAS leaves `UPDATING` with the previous manifest. A daemon restart loses nothing durable, but reports sent while no reconciler runs are lost until the next tick. | none |
|
||||
| End-to-end | `tests/e2e/eks/eks_test.go` `TestEKS/AddonDelivery` (create to `ACTIVE`, objects present, delete removes the namespace) and `tests/e2e/gpu/eks_gpu_test.go` `TestEKSGPUPodExposure`; `tests/integration/cross_tenant_test.go` `TestCrossTenant_EveryScopedActionIsAccountedFor`. No E2E for update, failure, configuration or the role. No Terraform apply/destroy evidence for `aws_eks_addon`. | |
|
||||
@@ -52,7 +52,7 @@ There is no generation, so the record mixes desired fields with the latest obser
|
||||
5. `DELETING` exists only in the delete response; the record and manifest are erased before any guest removal, so `DescribeAddon` returns not found immediately.
|
||||
6. No observed deletion acknowledgement: the agent removes objects with `--wait=false` and reports nothing.
|
||||
7. `configurationValues` is stored, staged and transported but discarded by the agent; `resolveConflicts`, `preserve` and `podIdentityAssociations` are accepted and never used.
|
||||
8. Fixed: `serviceAccountRoleArn` now requires `iam:PassRole` on `CreateAddon` and `UpdateAddon`, matching `CreateCluster`/`CreateNodegroup` (`gateway/eks/passrole.go`).
|
||||
8. Fixed: `serviceAccountRoleArn` now requires `iam:PassRole` on `CreateAddon` and `UpdateAddon`, matching `CreateCluster`/`CreateNodegroup` (`domains/eks/awsapi/passrole.go`).
|
||||
9. AWS `UPDATE_FAILED` and `DELETE_FAILED` are not represented: a failed update becomes `CREATE_FAILED` and a failed delete leaves the prior status.
|
||||
10. The add-on ARN lacks the per-incarnation suffix AWS appends, so a recreated add-on reuses its predecessor's ARN. This is a compatibility gap; extraction must keep the current ARN.
|
||||
11. The guest/controller wire shape is versioned as `contracts/eks/v1` (subject, delivery phases, status report, staged manifest, internal-addons body), used directly by the handler, gateway and `agents/eks/gatewayfetch`. The add-on owner keeps its own `Manifest` and `Report`, and the shell agent still builds the report with `printf`. The HTTP body uses Go field names because the REST-JSON marshaller ignores the `json` tags, and the agent decodes it only through case-insensitive matching; the contract pins that form rather than changing it.
|
||||
|
||||
@@ -18,7 +18,7 @@ The characterization tests below were written in `handlers/rds` before the move;
|
||||
## Discovered and corrected divergence
|
||||
|
||||
`ValidateMasterUserPassword` built its empty-password error with a bare `errors.New`, which `awserrors` does not resolve, so `CreateDBInstance` without `MasterUserPassword` returned HTTP 500 `ServerInternal` where AWS returns a 400 client error.
|
||||
It now returns 400 `InvalidParameterValue`; `gateway/rds_test.go` `TestRDSRequest_CreateDBInstance_EmptyMasterUserPasswordReturns400` covers the gateway, NATS and RDS service path.
|
||||
It now returns 400 `InvalidParameterValue`; `ingress/aws/pipeline/rds_test.go` `TestRDSRequest_CreateDBInstance_EmptyMasterUserPasswordReturns400` covers the gateway, NATS and RDS service path.
|
||||
|
||||
## Boundaries kept by the move
|
||||
|
||||
|
||||
@@ -123,6 +123,10 @@ Internal and internet-facing ALB and NLB, HTTPS listeners, listener rules, `Modi
|
||||
Unhealthy-target routing: an NLB with an HTTP health check on a TCP target group stops sending traffic to a target that fails it while still accepting TCP, sends traffic again once it recovers, and fails open when every target is unhealthy.
|
||||
An ALB whose targets all fail their health check keeps serving from them instead of answering 503.
|
||||
|
||||
`TestInternalALBPrivateSubnet` launches an internal ALB in a private subnet of a non-default VPC with no internet gateway and no public route.
|
||||
The load balancer must reach active with a healthy target, which needs the owner stamp on its ENI to resolve, and a client inside the VPC must be served by it on every request.
|
||||
The client reports on its serial console because the runner has no route into the VPC.
|
||||
|
||||
### `eks`, `ecs`, `ecr` — container services
|
||||
|
||||
`eks`: cluster create/delete, access entries, addon delivery, `GetToken`, kubeconfig artifacts, the EBS CSI volume path, an IRSA web-identity exchange under the stock AWS trust policy (`StringEquals` on the issuer host-path `:sub`/`:aud`, a mismatched `:sub` denied with `AccessDenied`, a `StringLike` `:sub` granted), and an IRSA pod wired with a projected SA token. Worker IMDS is covered too: a managed nodegroup worker reports IMDSv2 required with hop limit 2, a pod with no IRSA resolves to the node role through IMDS, and a nodegroup built from a launch template takes the template's metadata options. Control-plane isolation is covered as well: no control-plane host registers as a Node, a DaemonSet tolerating every taint runs on workers only, metrics-server is Ready, and logs work for every kube-system pod. `ecs`: clusters, container instances, the three network modes (`awsvpc`, `bridge`, `host`), task-role credentials fetched from the container credentials endpoint, a service behind an ELB, and ENI release on terminate. `ecr`: control-plane CRUD plus a real OCI data plane — docker login/build/push/pull round trips, multi-layer images, and raw blob HTTP.
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
package testutil
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
// StaleReadKV wraps a bucket so that the first Get of a key after a write to it
|
||||
// returns the entry as it stood before that write, as a lagging replica does
|
||||
// when it answers a direct get. Later Gets see the current entry.
|
||||
type StaleReadKV struct {
|
||||
jetstream.KeyValue
|
||||
|
||||
mu sync.Mutex
|
||||
stale map[string]jetstream.KeyValueEntry
|
||||
}
|
||||
|
||||
var _ jetstream.KeyValue = (*StaleReadKV)(nil)
|
||||
|
||||
// NewStaleReadKV wraps kv.
|
||||
func NewStaleReadKV(kv jetstream.KeyValue) *StaleReadKV {
|
||||
return &StaleReadKV{KeyValue: kv, stale: map[string]jetstream.KeyValueEntry{}}
|
||||
}
|
||||
|
||||
// remember captures the entry about to be overwritten.
|
||||
func (k *StaleReadKV) remember(ctx context.Context, key string) jetstream.KeyValueEntry {
|
||||
prev, err := k.KeyValue.Get(ctx, key)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return prev
|
||||
}
|
||||
|
||||
func (k *StaleReadKV) arm(key string, prev jetstream.KeyValueEntry) {
|
||||
if prev == nil {
|
||||
return
|
||||
}
|
||||
k.mu.Lock()
|
||||
k.stale[key] = prev
|
||||
k.mu.Unlock()
|
||||
}
|
||||
|
||||
func (k *StaleReadKV) Get(ctx context.Context, key string) (jetstream.KeyValueEntry, error) {
|
||||
k.mu.Lock()
|
||||
prev, ok := k.stale[key]
|
||||
delete(k.stale, key)
|
||||
k.mu.Unlock()
|
||||
if ok {
|
||||
return prev, nil
|
||||
}
|
||||
return k.KeyValue.Get(ctx, key)
|
||||
}
|
||||
|
||||
func (k *StaleReadKV) Update(ctx context.Context, key string, value []byte, last uint64) (uint64, error) {
|
||||
prev := k.remember(ctx, key)
|
||||
rev, err := k.KeyValue.Update(ctx, key, value, last)
|
||||
if err == nil {
|
||||
k.arm(key, prev)
|
||||
}
|
||||
return rev, err
|
||||
}
|
||||
|
||||
func (k *StaleReadKV) Put(ctx context.Context, key string, value []byte) (uint64, error) {
|
||||
prev := k.remember(ctx, key)
|
||||
rev, err := k.KeyValue.Put(ctx, key, value)
|
||||
if err == nil {
|
||||
k.arm(key, prev)
|
||||
}
|
||||
return rev, err
|
||||
}
|
||||
|
||||
// RejectingKV wraps a bucket so that writes for which reject returns an error
|
||||
// fail with it, and every other operation passes through.
|
||||
type RejectingKV struct {
|
||||
jetstream.KeyValue
|
||||
|
||||
reject func(key string, value []byte) error
|
||||
}
|
||||
|
||||
var _ jetstream.KeyValue = (*RejectingKV)(nil)
|
||||
|
||||
// NewRejectingKV wraps kv. reject runs before each Put, Create and Update.
|
||||
func NewRejectingKV(kv jetstream.KeyValue, reject func(key string, value []byte) error) *RejectingKV {
|
||||
return &RejectingKV{KeyValue: kv, reject: reject}
|
||||
}
|
||||
|
||||
func (k *RejectingKV) Put(ctx context.Context, key string, value []byte) (uint64, error) {
|
||||
if err := k.reject(key, value); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return k.KeyValue.Put(ctx, key, value)
|
||||
}
|
||||
|
||||
func (k *RejectingKV) Create(ctx context.Context, key string, value []byte, opts ...jetstream.KVCreateOpt) (uint64, error) {
|
||||
if err := k.reject(key, value); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return k.KeyValue.Create(ctx, key, value, opts...)
|
||||
}
|
||||
|
||||
func (k *RejectingKV) Update(ctx context.Context, key string, value []byte, last uint64) (uint64, error) {
|
||||
if err := k.reject(key, value); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return k.KeyValue.Update(ctx, key, value, last)
|
||||
}
|
||||
+1
-1
@@ -6,7 +6,7 @@ Node installation, dev-environment lifecycle, guest image builds, and verificati
|
||||
|
||||
| Script | What it does |
|
||||
|---|---|
|
||||
| `setup.sh` | The production binary installer, served from `install.mulgadc.com`. Installs deps, users, sudoers, the host firewall policy, files, systemd units, logrotate and udev rules, and loads `openvswitch`, `geneve`, `vhost_net` and `tun`, listing them in `/etc/modules-load.d/spinifex.conf` for later boots. It masks firewalld wherever it is installed, since the Spinifex nftables policy is the only supported node firewall. It writes a needrestart drop-in so an apt upgrade never restarts the Spinifex, OVS or OVN units; restarting them after an upgrade is left to the operator. An unset `--firewall` keeps the mode recorded in `/etc/spinifex/firewall/mode`, and otherwise arms the policy when firewalld was active. Where systemd-resolved is installed but not running, resolvconf is absent and NetworkManager runs, it hands host DNS to resolved: it saves `/etc/resolv.conf` once to `/var/lib/spinifex-reset/resolv.conf.orig`, links `/etc/resolv.conf` to the resolved stub, and stops unless resolved has upstream servers and the package repository host still resolves. It links `/usr/bin/spx` to `/usr/local/bin/spx` where sudo's PATH leaves out `/usr/local/bin`, and takes the nbdkit plugin directory from `nbdkit --dump-config`, stopping when nbdkit cannot say. `SETUP_STAGES` runs a subset; `ISO_BUILD=1` runs it inside the ISO builder's chroot. |
|
||||
| `setup.sh` | The production binary installer, served from `install.mulgadc.com`. Installs deps, users, sudoers, the host firewall policy, files, systemd units, logrotate and udev rules, and loads `openvswitch`, `geneve`, `vhost_net` and `tun`, listing them in `/etc/modules-load.d/spinifex.conf` for later boots. It masks firewalld wherever it is installed, since the Spinifex nftables policy is the only supported node firewall. It writes a needrestart drop-in so an apt upgrade never restarts the Spinifex, OVS or OVN units; restarting them after an upgrade is left to the operator. An unset `--firewall` keeps the mode recorded in `/etc/spinifex/firewall/mode`, and otherwise arms the policy when firewalld was active. Where systemd-resolved is installed but not running, resolvconf is absent and NetworkManager runs, it hands host DNS to resolved: it saves `/etc/resolv.conf` once to `/var/lib/spinifex-reset/resolv.conf.orig`, writes `/etc/NetworkManager/conf.d/90-spinifex-dns.conf` (`dns=systemd-resolved`, `rc-manager=unmanaged`) so NetworkManager never rewrites `/etc/resolv.conf`, links `/etc/resolv.conf` to the resolved stub, and stops unless the link holds, resolved has upstream servers and the package repository host still resolves. A rerun after that handover adds a missing drop-in, reloads NetworkManager and relinks the stub. It links `/usr/bin/spx` to `/usr/local/bin/spx` where sudo's PATH leaves out `/usr/local/bin`, and takes the nbdkit plugin directory from `nbdkit --dump-config`, stopping when nbdkit cannot say. `SETUP_STAGES` runs a subset; `ISO_BUILD=1` runs it inside the ISO builder's chroot. |
|
||||
| `setup-ovn.sh` | Bootstraps a compute node for OVN VPC networking: installs OVN/OVS, creates `br-int`, configures the WAN bridge (bridged, DHCP, or routed-NAT uplink), sets the chassis identity, and applies overlay sysctls. It runs OVS and OVN as root, setting `OVS_USER_ID` and `OVN_USER_ID` where `/etc/sysconfig` holds them, and hooks the socket-permission helper onto `ovs-vswitchd`, `ovn-controller`, `openvswitch-ipsec` and, on EL, `ovn-northd`. `--management` also starts the OVN central services, writes their `ovn-ctl` flags to `/etc/default/ovn-central` (Debian) or `/etc/sysconfig/ovn-northd` (EL), and records the role in `/etc/spinifex/ovn-role`. With clustered databases it returns only once this node's NB and SB members are back in their RAFT clusters under a known leader, and fails after `OVN_RAFT_REJOIN_WAIT` seconds (default 60) otherwise, so reruns one node at a time never take two members of a cluster down. A later run without `--management` refuses while that file, or the `OVN_CTL_OPTS` line older nodes carry in `/etc/default/ovn-central`, records the role. It adds `maxsize 100M` to the OVS and OVN logrotate files, plus `rotate 5` where the file sets no count, and rotates as root, matching the daemons. On a host whose IPsec stack is libreswan it enables, stops and masks `openvswitch-ipsec.service` and `ipsec.service` for the daemon to unmask when IPsec is on, unless `openvswitch-ipsec.service` is already running; elsewhere it enables and starts `openvswitch-ipsec.service`, unless the daemon has masked it to keep IPsec off. The links it creates persist through systemd-networkd where that is enabled, and otherwise through `/etc/spinifex/hostnet.json`, which `spinifex-hostnet.service` (`spx admin hostnet apply`) replays at boot and on every OVS start, starting `ovn-controller` again when an OVS stop took it down; the first run records the choice in `/etc/spinifex/hostnet-backend`. Where NetworkManager runs, it leaves the links Spinifex owns unmanaged. |
|
||||
| `install-node.sh` | Forms a multi-node cluster across hosts that are already running Spinifex: rebuilds the OVN database in clustered form, runs `spx admin init` on the first host and `spx admin join` on the rest, then verifies. Before touching any host it refuses hosts of different OS families (`distro_os_family`) or `spx` versions, and `--ipsec on` when any host uses libreswan. The scripted form of [install-multi-node](../docs/install/install-multi-node/README.md). Destroys the joining nodes' logical network state and crypto — see its `--help`. |
|
||||
| `dev-install.sh` | Full local dev setup through the production installer: builds from source, packs a tarball, runs `setup.sh`, initialises the cluster, and starts services under systemd. |
|
||||
|
||||
@@ -12,11 +12,12 @@ set -eu
|
||||
# once.
|
||||
#
|
||||
# server → eks-token-webhook, k3s (server), k3s-first-boot (bootstrap
|
||||
# publish), mulga-eks-state-report
|
||||
# publish), mulga-eks-state-report, mulga-eks-node-gc
|
||||
# server-join → eks-token-webhook, k3s (server, joins the first server's etcd
|
||||
# quorum), mulga-eks-state-report. NO k3s-first-boot: the first
|
||||
# server already publishes the cluster-identical bootstrap
|
||||
# artifacts; a join re-publish only races the bootstrap bus.
|
||||
# quorum), mulga-eks-state-report, mulga-eks-node-gc. NO
|
||||
# k3s-first-boot: the first server already publishes the
|
||||
# cluster-identical bootstrap artifacts; a join re-publish only
|
||||
# races the bootstrap bus.
|
||||
# agent → k3s-agent
|
||||
#
|
||||
# Paths are overridable via env so the selection logic is unit-testable (bats)
|
||||
@@ -140,6 +141,7 @@ case "${ROLE}" in
|
||||
# writer owns. HA multi-server addon delivery is tracked separately
|
||||
# Server-join nodes do not run this setup.
|
||||
svc_enable mulga-eks-addon-sync
|
||||
svc_enable mulga-eks-node-gc
|
||||
svc_start eks-token-webhook
|
||||
svc_start mulga-eks-k3s-recovery
|
||||
svc_start k3s
|
||||
@@ -147,6 +149,7 @@ case "${ROLE}" in
|
||||
svc_start k3s-first-boot
|
||||
svc_start mulga-eks-state-report
|
||||
svc_start mulga-eks-addon-sync
|
||||
svc_start mulga-eks-node-gc
|
||||
;;
|
||||
server-join)
|
||||
log "configuring server-join role"
|
||||
@@ -158,11 +161,13 @@ case "${ROLE}" in
|
||||
svc_enable k3s
|
||||
svc_enable konnectivity-server
|
||||
svc_enable mulga-eks-state-report
|
||||
svc_enable mulga-eks-node-gc
|
||||
svc_start eks-token-webhook
|
||||
svc_start mulga-eks-k3s-recovery
|
||||
svc_start k3s
|
||||
svc_start konnectivity-server
|
||||
svc_start mulga-eks-state-report
|
||||
svc_start mulga-eks-node-gc
|
||||
;;
|
||||
agent)
|
||||
log "configuring agent role"
|
||||
|
||||
@@ -141,6 +141,8 @@ for INIT in openrc systemd; do
|
||||
END { print (rn > 0 && kn > 0 && rn < kn) ? "yes" : "no" }
|
||||
' "${CALLS}"
|
||||
)"
|
||||
check "server(${INIT}): enables node-gc" "yes" "$(present "${CALLS}" "$(enable_cmd "${INIT}" mulga-eks-node-gc)")"
|
||||
check "server(${INIT}): starts node-gc" "yes" "$(present "${CALLS}" "$(start_cmd "${INIT}" mulga-eks-node-gc)")"
|
||||
check "server(${INIT}): no k3s-agent" "no" "$(present "${CALLS}" "$(enable_cmd "${INIT}" k3s-agent)")"
|
||||
check "server(${INIT}): self-disables" "yes" "$(present "${CALLS}" "$(disable_cmd "${INIT}" eks-node-role)")"
|
||||
|
||||
@@ -153,6 +155,8 @@ for INIT in openrc systemd; do
|
||||
check "server-join(${INIT}): enables webhook" "yes" "$(present "${CALLS}" "$(enable_cmd "${INIT}" eks-token-webhook)")"
|
||||
check "server-join(${INIT}): enables k3s" "yes" "$(present "${CALLS}" "$(enable_cmd "${INIT}" k3s)")"
|
||||
check "server-join(${INIT}): enables state-report" "yes" "$(present "${CALLS}" "$(enable_cmd "${INIT}" mulga-eks-state-report)")"
|
||||
check "server-join(${INIT}): enables node-gc" "yes" "$(present "${CALLS}" "$(enable_cmd "${INIT}" mulga-eks-node-gc)")"
|
||||
check "server-join(${INIT}): starts node-gc" "yes" "$(present "${CALLS}" "$(start_cmd "${INIT}" mulga-eks-node-gc)")"
|
||||
check "server-join(${INIT}): enables konnectivity-server" "yes" "$(present "${CALLS}" "$(enable_cmd "${INIT}" konnectivity-server)")"
|
||||
check "server-join(${INIT}): enables k3s-recovery" "yes" "$(present "${CALLS}" "$(enable_cmd "${INIT}" mulga-eks-k3s-recovery)")"
|
||||
check "server-join(${INIT}): starts k3s-recovery" "yes" "$(present "${CALLS}" "$(start_cmd "${INIT}" mulga-eks-k3s-recovery)")"
|
||||
@@ -170,7 +174,7 @@ for INIT in openrc systemd; do
|
||||
check "agent(${INIT}): exit 0" 0 "${rc}"
|
||||
check "agent(${INIT}): role file" "agent" "$(cat "${ROLE_FILE}")"
|
||||
check "agent(${INIT}): enables k3s-agent" "yes" "$(present "${CALLS}" "$(enable_cmd "${INIT}" k3s-agent)")"
|
||||
check "agent(${INIT}): no server services" "yes" "$(absent_all "${CALLS}" "eks-token-webhook" "k3s-first-boot" "$(enable_cmd "${INIT}" k3s)")"
|
||||
check "agent(${INIT}): no server services" "yes" "$(absent_all "${CALLS}" "eks-token-webhook" "k3s-first-boot" "mulga-eks-node-gc" "$(enable_cmd "${INIT}" k3s)")"
|
||||
|
||||
# --- agent (inferred from agent.env, no explicit role) ---
|
||||
run_case "${INIT}" agent_inferred
|
||||
|
||||
@@ -42,6 +42,8 @@ scripts/images/eks-node/mulga-eks-state-report.initd:/etc/init.d/mulga-eks-state
|
||||
scripts/images/eks-node/mulga-eks-state-report.sh:/usr/local/sbin/mulga-eks-state-report \
|
||||
scripts/images/eks-node/mulga-eks-addon-sync.initd:/etc/init.d/mulga-eks-addon-sync \
|
||||
scripts/images/eks-node/mulga-eks-addon-sync.sh:/usr/local/sbin/mulga-eks-addon-sync \
|
||||
scripts/images/eks-node/mulga-eks-node-gc.initd:/etc/init.d/mulga-eks-node-gc \
|
||||
scripts/images/eks-node/mulga-eks-node-gc.sh:/usr/local/sbin/mulga-eks-node-gc \
|
||||
scripts/images/eks-node/konnectivity-server.initd:/etc/init.d/konnectivity-server \
|
||||
scripts/images/eks-node/konnectivity-server-prestart.sh:/usr/local/sbin/konnectivity-server-prestart \
|
||||
scripts/images/eks-node/konnectivity/konnectivity-agent.yaml:/usr/share/spinifex-eks/konnectivity/konnectivity-agent.yaml \
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
#!/sbin/openrc-run
|
||||
# mulga-eks-node-gc — server-role service that deletes the Node of every
|
||||
# nodegroup worker the nodegroup has terminated, every NODE_GC_INTERVAL
|
||||
# seconds. Enabled on every server role by eks-node-role.sh.
|
||||
|
||||
description="Mulga EKS terminated-worker Node cleanup"
|
||||
command="/usr/local/sbin/mulga-eks-node-gc"
|
||||
command_background="yes"
|
||||
pidfile="/run/mulga-eks-node-gc.pid"
|
||||
output_log="/var/log/mulga-eks-node-gc.log"
|
||||
error_log="/var/log/mulga-eks-node-gc.log"
|
||||
|
||||
# A Node goes NotReady about 40s after its worker stops, so 30s deletes it
|
||||
# within a couple of minutes of the terminate.
|
||||
export NODE_GC_INTERVAL="${NODE_GC_INTERVAL:-30}"
|
||||
|
||||
depend() {
|
||||
need net
|
||||
after k3s
|
||||
}
|
||||
|
||||
start_pre() {
|
||||
if [ ! -f /etc/spinifex-eks/first-boot.env ]; then
|
||||
eerror "/etc/spinifex-eks/first-boot.env not present — cloud-init did not seed gateway creds"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
# mulga-eks-node-gc — deletes the Node of a nodegroup worker whose instance the
|
||||
# nodegroup has terminated (DeleteNodegroup, scale-down, reclaim), as the AWS
|
||||
# cloud controller deletes a Node whose instance no longer exists. K3s runs no
|
||||
# AWS cloud provider: its own reports every instance as existing, so without
|
||||
# this a terminated worker stays a NotReady Node forever, its pods stay bound to
|
||||
# it, and once most Nodes are NotReady the node lifecycle controller stops
|
||||
# evicting at all.
|
||||
#
|
||||
# Pull (host→VM): GET /clusters/{cluster}/internal-workers/{accountId}, the
|
||||
# instance ID of every worker the cluster's nodegroups still own.
|
||||
#
|
||||
# A Node is deleted only when all of these hold, so a live worker is never
|
||||
# touched: it carries the eks.amazonaws.com/nodegroup label, its providerID is
|
||||
# aws:///<az>/<instance-id>, it is not Ready, and its instance is not in the
|
||||
# fetched set. A failed fetch deletes nothing.
|
||||
#
|
||||
# Runs on every server; deletes are idempotent. Loops every NODE_GC_INTERVAL
|
||||
# seconds in service mode, one-shot otherwise.
|
||||
|
||||
ENVFILE=${ENVFILE:-/etc/spinifex-eks/first-boot.env}
|
||||
[ -f "${ENVFILE}" ] || { logger -t mulga-eks-node-gc "${ENVFILE} missing — exiting"; exit 0; }
|
||||
# set -a so the sourced creds are exported to the eks-gateway-fetch child.
|
||||
set -a
|
||||
# shellcheck disable=SC1090
|
||||
. "${ENVFILE}"
|
||||
set +a
|
||||
|
||||
: "${EKS_GATEWAY_URL:?}"
|
||||
: "${EKS_ACCOUNT_ID:?}"
|
||||
: "${EKS_CLUSTER_NAME:?}"
|
||||
|
||||
KUBECONFIG=${KUBECONFIG:-/etc/rancher/k3s/k3s.yaml}
|
||||
export KUBECONFIG
|
||||
|
||||
# One "name|providerID|nodegroup|Ready" row per Node.
|
||||
NODE_ROWS='jsonpath={range .items[*]}{.metadata.name}{"|"}{.spec.providerID}{"|"}{.metadata.labels.eks\.amazonaws\.com/nodegroup}{"|"}{.status.conditions[?(@.type=="Ready")].status}{"\n"}{end}'
|
||||
|
||||
log() {
|
||||
logger -t mulga-eks-node-gc "$*"
|
||||
echo "[mulga-eks-node-gc] $*" > "${CONSOLE:-/dev/console}" 2>/dev/null || true
|
||||
}
|
||||
|
||||
# stale_nodes LIVE_FILE — reads Node rows on stdin and prints the name of each
|
||||
# Node to delete: a nodegroup worker, not Ready, whose instance is not listed
|
||||
# in LIVE_FILE.
|
||||
stale_nodes() {
|
||||
awk -F'|' -v live="$1" '
|
||||
BEGIN { while ((getline id < live) > 0) keep[id] = 1 }
|
||||
$3 == "" || $4 == "True" { next }
|
||||
$2 !~ /^aws:\/\/\/[^\/]+\/i-[0-9a-f]+$/ { next }
|
||||
{ n = split($2, p, "/"); if (!(p[n] in keep)) print $1 }
|
||||
'
|
||||
}
|
||||
|
||||
sync_once() {
|
||||
# Nodes are listed before the workers are fetched. A worker is recorded on
|
||||
# its nodegroup before it boots, so every Node listed here is already in a
|
||||
# set fetched after.
|
||||
if ! rows=$(kubectl get nodes -o "${NODE_ROWS}" 2>/dev/null); then
|
||||
log "kubectl get nodes failed; skipping this pass"
|
||||
return 0
|
||||
fi
|
||||
live=$(mktemp)
|
||||
if ! eks-gateway-fetch -resource workers > "${live}" 2>/dev/null; then
|
||||
log "fetch of the cluster's nodegroup workers failed; deleting nothing"
|
||||
rm -f "${live}"
|
||||
return 0
|
||||
fi
|
||||
printf '%s\n' "${rows}" | stale_nodes "${live}" | while read -r node; do
|
||||
if kubectl delete node "${node}" --ignore-not-found --wait=false >/dev/null 2>&1; then
|
||||
log "deleted Node ${node}: its instance is no longer a nodegroup worker"
|
||||
else
|
||||
log "delete of Node ${node} failed; retrying next pass"
|
||||
fi
|
||||
done
|
||||
rm -f "${live}"
|
||||
}
|
||||
|
||||
if [ -n "${NODE_GC_INTERVAL:-}" ] && [ "${NODE_GC_INTERVAL}" -gt 0 ] 2>/dev/null; then
|
||||
while true; do
|
||||
sync_once
|
||||
sleep "${NODE_GC_INTERVAL}"
|
||||
done
|
||||
else
|
||||
sync_once
|
||||
fi
|
||||
@@ -0,0 +1,107 @@
|
||||
#!/bin/sh
|
||||
# Self-contained POSIX test for mulga-eks-node-gc.sh. kubectl, eks-gateway-fetch
|
||||
# and logger are stubbed on PATH: kubectl serves canned Node rows and records
|
||||
# every delete, and eks-gateway-fetch serves a canned worker set or fails.
|
||||
#
|
||||
# Run: sh scripts/images/eks-node/mulga-eks-node-gc_test.sh
|
||||
set -eu
|
||||
|
||||
SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd)
|
||||
AGENT="${SCRIPT_DIR}/mulga-eks-node-gc.sh"
|
||||
WORK=$(mktemp -d)
|
||||
trap 'rm -rf "${WORK}"' EXIT
|
||||
|
||||
STUBBIN="${WORK}/bin"
|
||||
mkdir -p "${STUBBIN}"
|
||||
|
||||
cat > "${STUBBIN}/kubectl" <<'EOF'
|
||||
#!/bin/sh
|
||||
case "$1 $2" in
|
||||
"get nodes")
|
||||
[ "${NODES_RC:-0}" -eq 0 ] || exit "${NODES_RC}"
|
||||
printf '%s\n' "${NODES_BODY:-}" ;;
|
||||
"delete node") echo "$3" >> "${DELETED}" ;;
|
||||
esac
|
||||
EOF
|
||||
|
||||
cat > "${STUBBIN}/eks-gateway-fetch" <<'EOF'
|
||||
#!/bin/sh
|
||||
echo "$*" >> "${FETCHED}"
|
||||
[ "${FETCH_RC:-0}" -eq 0 ] || exit "${FETCH_RC}"
|
||||
printf '%s' "${LIVE_BODY:-}"
|
||||
EOF
|
||||
|
||||
cat > "${STUBBIN}/logger" <<'EOF'
|
||||
#!/bin/sh
|
||||
:
|
||||
EOF
|
||||
|
||||
chmod +x "${STUBBIN}"/*
|
||||
PATH="${STUBBIN}:${PATH}"
|
||||
export PATH
|
||||
|
||||
ENVFILE="${WORK}/first-boot.env"
|
||||
cat > "${ENVFILE}" <<'EOF'
|
||||
EKS_GATEWAY_URL=https://gw.invalid:9999
|
||||
EKS_ACCOUNT_ID=000000000001
|
||||
EKS_CLUSTER_NAME=demo3
|
||||
EOF
|
||||
CONSOLE="${WORK}/console"
|
||||
DELETED="${WORK}/deleted"
|
||||
FETCHED="${WORK}/fetched"
|
||||
export ENVFILE CONSOLE DELETED FETCHED
|
||||
|
||||
PASS=0
|
||||
FAIL=0
|
||||
check() {
|
||||
if [ "$2" = "$3" ]; then
|
||||
PASS=$((PASS + 1))
|
||||
else
|
||||
FAIL=$((FAIL + 1))
|
||||
printf 'FAIL: %s\n want: %s\n got: %s\n' "$1" "$2" "$3"
|
||||
fi
|
||||
}
|
||||
|
||||
run_agent() {
|
||||
: > "${DELETED}"
|
||||
: > "${FETCHED}"
|
||||
sh "${AGENT}"
|
||||
}
|
||||
|
||||
deleted() { tr '\n' ' ' < "${DELETED}" | sed 's/ $//'; }
|
||||
|
||||
# Every way a Node can look: only a NotReady nodegroup worker whose instance the
|
||||
# cluster's nodegroups no longer own is deleted.
|
||||
NODES_BODY='live|aws:///z1/i-0aaa|ng1|True
|
||||
gone|aws:///z1/i-0bbb|ng1|Unknown
|
||||
gone-never-reported|aws:///z1/i-0ddd|ng2|
|
||||
owned-notready|aws:///z1/i-0ccc|ng1|False
|
||||
gone-but-ready|aws:///z1/i-0eee|ng1|True
|
||||
self-managed|aws:///z1/i-0fff||Unknown
|
||||
k3s-provider|k3s://k3s-node|ng1|Unknown'
|
||||
LIVE_BODY='i-0aaa
|
||||
i-0ccc
|
||||
'
|
||||
export NODES_BODY LIVE_BODY
|
||||
|
||||
run_agent
|
||||
check "deletes only NotReady nodegroup Nodes whose instance is gone" "gone gone-never-reported" "$(deleted)"
|
||||
check "fetches the workers resource" "-resource workers" "$(cat "${FETCHED}")"
|
||||
|
||||
# A cluster with no nodegroups left: every NotReady nodegroup Node goes.
|
||||
LIVE_BODY='' run_agent
|
||||
check "empty worker set deletes every NotReady nodegroup Node" "gone gone-never-reported owned-notready" "$(deleted)"
|
||||
|
||||
# A failed fetch is not an empty set: nothing is deleted.
|
||||
FETCH_RC=1 run_agent
|
||||
check "failed fetch deletes nothing" "" "$(deleted)"
|
||||
|
||||
# Without a Node listing the workers are not fetched, so a set fetched before a
|
||||
# Node registered can never be compared against it.
|
||||
NODES_RC=1 run_agent
|
||||
check "failed node listing deletes nothing" "" "$(deleted)"
|
||||
check "failed node listing does not fetch" "" "$(cat "${FETCHED}")"
|
||||
|
||||
echo "---"
|
||||
echo "PASS=${PASS} FAIL=${FAIL}"
|
||||
[ "${FAIL}" -eq 0 ]
|
||||
@@ -44,9 +44,10 @@ ln -sf /usr/local/bin/k3s /usr/local/bin/ctr
|
||||
# role's services are baked; the selector enables the right ones at first boot.
|
||||
chmod 0755 /etc/init.d/eks-node-role /etc/init.d/k3s /etc/init.d/k3s-agent \
|
||||
/etc/init.d/eks-token-webhook /etc/init.d/k3s-first-boot /etc/init.d/mulga-eks-state-report \
|
||||
/etc/init.d/mulga-eks-addon-sync /etc/init.d/konnectivity-server
|
||||
/etc/init.d/mulga-eks-addon-sync /etc/init.d/mulga-eks-node-gc /etc/init.d/konnectivity-server
|
||||
chmod 0755 /usr/local/sbin/eks-node-role /usr/local/sbin/k3s-first-boot \
|
||||
/usr/local/sbin/mulga-eks-state-report /usr/local/sbin/mulga-eks-addon-sync
|
||||
/usr/local/sbin/mulga-eks-state-report /usr/local/sbin/mulga-eks-addon-sync \
|
||||
/usr/local/sbin/mulga-eks-node-gc
|
||||
# start_pre helpers shared with the systemd wrappers that will eventually
|
||||
# replace these runscripts.
|
||||
chmod 0755 /usr/local/sbin/k3s-prestart /usr/local/sbin/k3s-agent-prestart \
|
||||
|
||||
+57
-2
@@ -1014,6 +1014,9 @@ RESOLV_CONF="${SPINIFEX_RESOLV_CONF:-/etc/resolv.conf}"
|
||||
RESOLVED_STUB=/run/systemd/resolve/stub-resolv.conf
|
||||
# node-reset.sh keeps /var/lib/spinifex-reset, so the copy survives until uninstall.
|
||||
RESOLV_CONF_SAVED="${SPINIFEX_RESOLV_CONF_SAVED:-/var/lib/spinifex-reset/resolv.conf.orig}"
|
||||
# NetworkManager started while resolv.conf is a plain file rewrites it on its
|
||||
# next DNS update, replacing the stub link; this drop-in stops that.
|
||||
NM_DNS_DROPIN="${SPINIFEX_NM_DNS_DROPIN:-/etc/NetworkManager/conf.d/90-spinifex-dns.conf}"
|
||||
|
||||
# systemd-resolved ships resolvconf as a link to resolvectl, which only works
|
||||
# while resolved runs, so that one is not a resolvconf backend.
|
||||
@@ -1066,10 +1069,52 @@ dns_handover_failed() {
|
||||
fatal "Host DNS through systemd-resolved does not work: $1.
|
||||
To put host DNS back as it was before this installer:
|
||||
sudo systemctl disable --now systemd-resolved
|
||||
sudo rm -f $NM_DNS_DROPIN
|
||||
sudo cp -a --remove-destination $RESOLV_CONF_SAVED $RESOLV_CONF
|
||||
sudo systemctl restart NetworkManager"
|
||||
}
|
||||
|
||||
# Prints the rc-manager NetworkManager would use if it is not unmanaged, which
|
||||
# means a file read after the Spinifex drop-in overrides it.
|
||||
nm_rc_manager_override() {
|
||||
local config
|
||||
config=$($SUDO NetworkManager --print-config) || return 1
|
||||
printf '%s\n' "$config" | awk -F= '
|
||||
/^\[/ { main = ($0 == "[main]"); next }
|
||||
main && $1 == "rc-manager" { rc = $2 }
|
||||
END { if (rc != "unmanaged") print "rc-manager=" (rc == "" ? "symlink" : rc) }'
|
||||
}
|
||||
|
||||
# Writes the NetworkManager drop-in and checks nothing overrides it. Returns 1
|
||||
# when it was already in place.
|
||||
install_nm_dns_dropin() {
|
||||
local want override changed=1
|
||||
want=$(printf '[main]\ndns=systemd-resolved\nrc-manager=unmanaged')
|
||||
if [ "$(cat "$NM_DNS_DROPIN" 2>/dev/null)" != "$want" ]; then
|
||||
$SUDO mkdir -p "$(dirname "$NM_DNS_DROPIN")"
|
||||
printf '%s\n' "$want" | $SUDO tee "$NM_DNS_DROPIN" > /dev/null || dns_handover_failed "cannot write $NM_DNS_DROPIN"
|
||||
info " $NM_DNS_DROPIN: NetworkManager leaves $RESOLV_CONF alone"
|
||||
changed=0
|
||||
fi
|
||||
override=$(nm_rc_manager_override) || dns_handover_failed "cannot read NetworkManager's configuration with 'NetworkManager --print-config'"
|
||||
if [ -n "$override" ]; then
|
||||
dns_handover_failed "NetworkManager reads $override from a file after $NM_DNS_DROPIN, so it would replace the $RESOLV_CONF link. Remove that setting ('NetworkManager --print-config' shows the files it reads) and re-run this installer"
|
||||
fi
|
||||
return "$changed"
|
||||
}
|
||||
|
||||
# Links the stub and checks the link is still there a second later, relinking a
|
||||
# bounded number of times in case a write NetworkManager had queued lands first.
|
||||
link_resolved_stub() {
|
||||
local i
|
||||
for i in 1 2 3 4 5; do
|
||||
$SUDO ln -sfn "$RESOLVED_STUB" "$RESOLV_CONF"
|
||||
sleep 1
|
||||
[ "$(readlink "$RESOLV_CONF")" = "$RESOLVED_STUB" ] && return 0
|
||||
done
|
||||
dns_handover_failed "$RESOLV_CONF is not a link to $RESOLVED_STUB 5 seconds after linking it, so something keeps rewriting it"
|
||||
}
|
||||
|
||||
# Checks resolved has upstream servers and still resolves the package
|
||||
# repository host, the one name this host is known to have resolved.
|
||||
check_resolved_dns() {
|
||||
@@ -1103,8 +1148,13 @@ hand_dns_to_resolved() {
|
||||
[ "${ISO_BUILD:-0}" = "1" ] && return 0
|
||||
|
||||
if systemctl is-active --quiet systemd-resolved.service; then
|
||||
# A handover made by an earlier run is checked again on every run.
|
||||
# A handover made by an earlier run is checked again, and repaired, on
|
||||
# every run: installs before the drop-in could lose the link.
|
||||
if [ -e "$RESOLV_CONF_SAVED" ] || [ -L "$RESOLV_CONF_SAVED" ]; then
|
||||
if install_nm_dns_dropin && systemctl is-active --quiet NetworkManager.service; then
|
||||
$SUDO systemctl reload NetworkManager.service || dns_handover_failed "NetworkManager did not reload"
|
||||
fi
|
||||
[ "$(readlink "$RESOLV_CONF")" = "$RESOLVED_STUB" ] || link_resolved_stub
|
||||
check_resolved_dns
|
||||
fi
|
||||
return 0
|
||||
@@ -1131,10 +1181,15 @@ hand_dns_to_resolved() {
|
||||
$SUDO cp -a "$RESOLV_CONF" "$RESOLV_CONF_SAVED" || fatal "Cannot save $RESOLV_CONF to $RESOLV_CONF_SAVED"
|
||||
info " saved $RESOLV_CONF to $RESOLV_CONF_SAVED"
|
||||
fi
|
||||
install_nm_dns_dropin || true
|
||||
$SUDO systemctl enable --now systemd-resolved.service > /dev/null 2>&1 \
|
||||
|| dns_handover_failed "systemd-resolved did not start"
|
||||
$SUDO systemctl restart NetworkManager.service || dns_handover_failed "NetworkManager did not restart"
|
||||
$SUDO ln -sfn "$RESOLVED_STUB" "$RESOLV_CONF"
|
||||
# Best effort: a link with no DHCP server can hold startup past the timeout.
|
||||
if command -v nm-online > /dev/null; then
|
||||
nm-online -s -q --timeout=15 || true
|
||||
fi
|
||||
link_resolved_stub
|
||||
check_resolved_dns
|
||||
info " $RESOLV_CONF -> $RESOLVED_STUB"
|
||||
}
|
||||
|
||||
@@ -20,9 +20,10 @@ fail() { echo " FAIL - $1"; fails=$((fails + 1)); }
|
||||
echo "-- setup.sh host DNS, chrony, needrestart and nbdkit"
|
||||
|
||||
mkdir -p "$TMP/sys"
|
||||
for tool in bash awk sed grep readlink basename dirname cp ln mkdir cat head tail wc tee rm chmod install; do
|
||||
for tool in bash awk sed grep readlink basename dirname cp ln mkdir cat head tail wc tee rm chmod install mv; do
|
||||
ln -s "$(command -v "$tool")" "$TMP/sys/$tool"
|
||||
done
|
||||
ln -s "$(command -v sleep)" "$TMP/sys/realsleep"
|
||||
|
||||
# stub <name> <body> — a stub command; $S is the fixture's state directory.
|
||||
stub() {
|
||||
@@ -30,10 +31,17 @@ stub() {
|
||||
chmod +x "$TMP/bin/$1"
|
||||
}
|
||||
|
||||
# nm_settle — waits for the fixture's NetworkManager to finish writing.
|
||||
nm_settle() {
|
||||
n=0
|
||||
while [ -e "$TMP/state/nm-pending" ] && [ $n -lt 500 ]; do "$TMP/sys/realsleep" 0.01; n=$((n + 1)); done
|
||||
}
|
||||
|
||||
# host — a fresh fixture: resolved installed and inactive, resolvconf the
|
||||
# resolvectl link, NetworkManager running with no DNS settings, a dnf host
|
||||
# whose first enabled repository is yum.oracle.com.
|
||||
host() {
|
||||
nm_settle
|
||||
rm -rf "${TMP:?}/state" "${TMP:?}/root" "${TMP:?}/bin"
|
||||
mkdir -p "$TMP/state/active" "$TMP/root/etc" "$TMP/root/usr/lib/systemd/system" "$TMP/bin"
|
||||
: > "$TMP/root/usr/lib/systemd/system/systemd-resolved.service"
|
||||
@@ -42,11 +50,28 @@ host() {
|
||||
printf '[main]\n# dns=default\nconfigure-and-quit=no\n' > "$TMP/state/nm-config"
|
||||
printf 'Global:\nLink 2 (br-wan): 192.168.1.1\n' > "$TMP/state/resolvectl-dns"
|
||||
printf '[ol10_UEKR8]\nbaseurl = https://mirror.example/uek/\nenabled = 0\n[ol10_baseos_latest]\nbaseurl = https://yum.oracle.com/repo/OracleLinux/OL10/baseos/latest/x86_64/\nenabled = 1\n' > "$TMP/state/dnf-dump"
|
||||
# A (re)started NetworkManager whose rc-manager is not unmanaged writes
|
||||
# resolv.conf as a plain file on its first DNS update, which can land just
|
||||
# after the installer links the stub.
|
||||
stub systemctl '[ "$1" = is-active ] && { [ -e "$S/active/$3" ]; exit; }
|
||||
echo "$*" >> "$S/calls"
|
||||
[ "$1 $2" = "enable --now" ] && : > "$S/active/$3"
|
||||
case "$1 $2" in
|
||||
"restart NetworkManager.service" | "reload NetworkManager.service")
|
||||
NetworkManager --print-config | grep -qx rc-manager=unmanaged && exit 0
|
||||
: > "$S/nm-pending"
|
||||
(
|
||||
n=0
|
||||
while [ ! -L "$S/../root/etc/resolv.conf" ] && [ $n -lt 300 ]; do realsleep 0.01; n=$((n + 1)); done
|
||||
printf "nameserver 192.168.1.1\n" > "$S/nm-resolv.conf"
|
||||
mv -f "$S/nm-resolv.conf" "$S/../root/etc/resolv.conf"
|
||||
rm -f "$S/nm-pending"
|
||||
) > /dev/null 2>&1 &
|
||||
;;
|
||||
esac
|
||||
exit 0'
|
||||
stub NetworkManager 'cat "$S/nm-config"'
|
||||
stub NetworkManager 'cat "$S/nm-config"; cat "$S/../root/nm/90-spinifex-dns.conf" "$S/nm-config-late" 2>/dev/null; :'
|
||||
stub nm-online ':'
|
||||
stub resolvectl '[ "$1" = dns ] && cat "$S/resolvectl-dns"'
|
||||
stub resolvectl-real ':'
|
||||
ln -s resolvectl "$TMP/bin/resolvconf"
|
||||
@@ -60,10 +85,17 @@ run() {
|
||||
RC=0
|
||||
OUT=$(PATH="$TMP/bin:$TMP/sys" DISTRO_ROOT="$TMP/root" \
|
||||
SPINIFEX_RESOLV_CONF="$TMP/root/etc/resolv.conf" SPINIFEX_RESOLV_CONF_SAVED="$TMP/root/saved/resolv.conf.orig" \
|
||||
SPINIFEX_NM_DNS_DROPIN="$TMP/root/nm/90-spinifex-dns.conf" \
|
||||
INSTALL_SPINIFEX_LIB_ONLY=1 bash -c '. "$1"; shift; for f in "$@"; do $f; done' _ "$SETUP" "$@" 2>&1) || RC=$?
|
||||
}
|
||||
|
||||
calls() { cat "$TMP/state/calls" 2>/dev/null || true; }
|
||||
# linked — whether resolv.conf is still the stub link once NetworkManager settles.
|
||||
linked() {
|
||||
nm_settle
|
||||
[ "$(readlink "$TMP/root/etc/resolv.conf")" = /run/systemd/resolve/stub-resolv.conf ]
|
||||
}
|
||||
dropin() { cat "$TMP/root/nm/90-spinifex-dns.conf" 2>/dev/null || echo "<none>"; }
|
||||
saved() { cat "$TMP/root/saved/resolv.conf.orig" 2>/dev/null || echo "<none>"; }
|
||||
expect_untouched() {
|
||||
if [ "$RC" = 0 ] && [ -z "$(calls)" ] && [ ! -L "$TMP/root/etc/resolv.conf" ] && [ "$(saved)" = "<none>" ]; then
|
||||
@@ -86,7 +118,7 @@ expect_fatal() { # expect_fatal <desc> <text>...
|
||||
# --------------------------------------------------------------------------
|
||||
host
|
||||
run hand_dns_to_resolved
|
||||
if [ "$RC" = 0 ] && [ "$(readlink "$TMP/root/etc/resolv.conf")" = /run/systemd/resolve/stub-resolv.conf ] \
|
||||
if [ "$RC" = 0 ] && linked \
|
||||
&& [ "$(saved)" = "$(printf 'nameserver 192.168.1.1\nsearch example.lan')" ] \
|
||||
&& [ "$(calls)" = "$(printf 'enable --now systemd-resolved.service\nrestart NetworkManager.service')" ] \
|
||||
&& [ "$(cat "$TMP/state/looked-up")" = yum.oracle.com ]; then
|
||||
@@ -98,12 +130,44 @@ fi
|
||||
: > "$TMP/state/calls"
|
||||
rm -f "$TMP/state/looked-up"
|
||||
run hand_dns_to_resolved
|
||||
if [ "$RC" = 0 ] && [ -z "$(calls)" ] && [ "$(cat "$TMP/state/looked-up")" = yum.oracle.com ]; then
|
||||
if [ "$RC" = 0 ] && [ -z "$(calls)" ] && linked && [ "$(cat "$TMP/state/looked-up")" = yum.oracle.com ]; then
|
||||
pass "a rerun after a handover checks DNS again and changes nothing"
|
||||
else
|
||||
fail "rerun after handover: rc=$RC calls='$(calls)': $OUT"
|
||||
fi
|
||||
|
||||
if [ "$(dropin)" = "$(printf '[main]\ndns=systemd-resolved\nrc-manager=unmanaged')" ]; then
|
||||
pass "the handover tells NetworkManager to send DNS to resolved and leave resolv.conf alone"
|
||||
else
|
||||
fail "NetworkManager drop-in: '$(dropin)'"
|
||||
fi
|
||||
|
||||
# An install made before the drop-in, whose NetworkManager replaced the link.
|
||||
host
|
||||
mkdir -p "$TMP/root/saved"
|
||||
echo "the original" > "$TMP/root/saved/resolv.conf.orig"
|
||||
: > "$TMP/state/active/systemd-resolved.service"
|
||||
run hand_dns_to_resolved
|
||||
if [ "$RC" = 0 ] && linked && [ "$(calls)" = "reload NetworkManager.service" ] \
|
||||
&& [ "$(dropin)" = "$(printf '[main]\ndns=systemd-resolved\nrc-manager=unmanaged')" ]; then
|
||||
pass "a rerun after a handover whose link NetworkManager replaced adds the drop-in, reloads NM and relinks the stub"
|
||||
else
|
||||
fail "rerun repair: rc=$RC calls='$(calls)' link='$(readlink "$TMP/root/etc/resolv.conf")' dropin='$(dropin)': $OUT"
|
||||
fi
|
||||
|
||||
host
|
||||
printf '[main]\nrc-manager=symlink\n' > "$TMP/state/nm-config-late"
|
||||
run hand_dns_to_resolved
|
||||
expect_fatal "a NetworkManager file read after the drop-in that sets rc-manager stops the install, naming it" \
|
||||
"rc-manager=symlink" "rm -f $TMP/root/nm/90-spinifex-dns.conf"
|
||||
case "$(calls)" in *"restart NetworkManager"*) fail "rc-manager override: restarted NetworkManager first: $(calls)" ;; esac
|
||||
|
||||
host
|
||||
stub ln ':'
|
||||
run hand_dns_to_resolved
|
||||
expect_fatal "a resolv.conf that will not stay linked to the stub stops the install" \
|
||||
"is not a link to /run/systemd/resolve/stub-resolv.conf"
|
||||
|
||||
host
|
||||
mkdir -p "$TMP/root/saved"
|
||||
echo "the original" > "$TMP/root/saved/resolv.conf.orig"
|
||||
@@ -155,7 +219,8 @@ host
|
||||
printf 'Global:\nLink 2 (br-wan):\n' > "$TMP/state/resolvectl-dns"
|
||||
run hand_dns_to_resolved
|
||||
expect_fatal "no upstream servers stops the install, naming the check and printing the restore" \
|
||||
"'resolvectl dns' lists no DNS servers" "systemctl disable --now systemd-resolved" "cp -a --remove-destination $TMP/root/saved/resolv.conf.orig"
|
||||
"'resolvectl dns' lists no DNS servers" "systemctl disable --now systemd-resolved" "cp -a --remove-destination $TMP/root/saved/resolv.conf.orig" \
|
||||
"rm -f $TMP/root/nm/90-spinifex-dns.conf"
|
||||
|
||||
host
|
||||
: > "$TMP/state/getent-fail"
|
||||
|
||||
@@ -16,6 +16,9 @@
|
||||
//
|
||||
// The recovery resource returns a single epoch\taction\tsnapshot line the on-VM
|
||||
// k3s-recovery agent applies before k3s starts (etcd cluster-reset / wipe-rejoin).
|
||||
//
|
||||
// The workers resource returns one instance ID per line: every worker the
|
||||
// cluster's nodegroups still own, which the on-VM node-gc agent keeps Nodes for.
|
||||
package gatewayfetch
|
||||
|
||||
import (
|
||||
@@ -46,7 +49,7 @@ type Config struct {
|
||||
Region string
|
||||
AccountID string
|
||||
ClusterName string
|
||||
// Resource is "addons" or "recovery".
|
||||
// Resource is "addons", "recovery" or "workers".
|
||||
Resource string
|
||||
InstanceID string
|
||||
// Out receives the rendered TSV.
|
||||
@@ -61,8 +64,8 @@ func Run(ctx context.Context, cfg Config) error {
|
||||
return errors.New("--account-id is required (or set EKS_ACCOUNT_ID)")
|
||||
case cfg.ClusterName == "":
|
||||
return errors.New("--cluster is required (or set EKS_CLUSTER_NAME)")
|
||||
case cfg.Resource != "addons" && cfg.Resource != "recovery":
|
||||
return errors.New("--resource must be addons or recovery")
|
||||
case cfg.Resource != "addons" && cfg.Resource != "recovery" && cfg.Resource != "workers":
|
||||
return errors.New("--resource must be addons, recovery or workers")
|
||||
case cfg.Resource == "recovery" && cfg.InstanceID == "":
|
||||
return errors.New("--instance-id is required for --resource recovery (or set EKS_INSTANCE_ID)")
|
||||
}
|
||||
@@ -80,6 +83,9 @@ func Run(ctx context.Context, cfg Config) error {
|
||||
case "recovery":
|
||||
path = "/clusters/" + cfg.ClusterName + "/internal-recovery/" + cfg.AccountID + "/" + cfg.InstanceID
|
||||
emit = emitRecoveryTSV
|
||||
case "workers":
|
||||
path = "/clusters/" + cfg.ClusterName + "/internal-workers/" + cfg.AccountID
|
||||
emit = emitWorkers
|
||||
default:
|
||||
path = "/clusters/" + cfg.ClusterName + "/internal-addons/" + cfg.AccountID
|
||||
emit = emitAddonsTSV
|
||||
@@ -163,3 +169,24 @@ func emitRecoveryTSV(out io.Writer, body []byte) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type internalWorkersResponse struct {
|
||||
InstanceIDs []string `json:"instanceIds"`
|
||||
}
|
||||
|
||||
// emitWorkers writes one managed-worker instance ID per line. An empty set
|
||||
// writes nothing, which the node-gc agent tells apart from a failed fetch by
|
||||
// this command's exit status, never by the output being empty.
|
||||
func emitWorkers(out io.Writer, body []byte) error {
|
||||
var resp internalWorkersResponse
|
||||
if err := json.Unmarshal(body, &resp); err != nil {
|
||||
return fmt.Errorf("unmarshal internal-workers response: %w", err)
|
||||
}
|
||||
w := bufio.NewWriter(out)
|
||||
for _, id := range resp.InstanceIDs {
|
||||
if _, err := fmt.Fprintln(w, id); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return w.Flush()
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ func TestRun_RejectsBeforeContactingGateway(t *testing.T) {
|
||||
}{
|
||||
{"account", func(c *Config) { c.AccountID = "" }, "--account-id is required (or set EKS_ACCOUNT_ID)"},
|
||||
{"cluster", func(c *Config) { c.ClusterName = "" }, "--cluster is required (or set EKS_CLUSTER_NAME)"},
|
||||
{"resource", func(c *Config) { c.Resource = "secrets" }, "--resource must be addons or recovery"},
|
||||
{"resource", func(c *Config) { c.Resource = "secrets" }, "--resource must be addons, recovery or workers"},
|
||||
{"recovery instance", func(c *Config) { c.Resource = "recovery" }, "--instance-id is required for --resource recovery (or set EKS_INSTANCE_ID)"},
|
||||
{"gateway client", func(c *Config) { c.GatewayURL = "" }, "build gateway client: eksgw: baseURL is required"},
|
||||
}
|
||||
@@ -92,6 +92,26 @@ func TestRun_RendersRecoveryDirective(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The gateway's restjson marshaler names the field InstanceIDs; both that and
|
||||
// the json-tag spelling decode, so the agent reads either.
|
||||
func TestRun_RendersManagedWorkers(t *testing.T) {
|
||||
for _, body := range []string{
|
||||
`{"InstanceIDs":["i-0aaa","i-0bbb"]}`,
|
||||
`{"instanceIds":["i-0aaa","i-0bbb"]}`,
|
||||
} {
|
||||
var hits atomic.Int32
|
||||
cfg, out := gateway(t, "/clusters/demo/internal-workers/123456789012", http.StatusOK, body, &hits)
|
||||
cfg.Resource = "workers"
|
||||
|
||||
if err := Run(context.Background(), cfg); err != nil {
|
||||
t.Fatalf("Run: %v", err)
|
||||
}
|
||||
if got, want := out.String(), "i-0aaa\ni-0bbb\n"; got != want {
|
||||
t.Fatalf("stdout = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRun_RenderFailureIsNotRetried(t *testing.T) {
|
||||
var hits atomic.Int32
|
||||
cfg, _ := gateway(t, "/clusters/demo/internal-addons/123456789012", http.StatusOK, `{not-json`, &hits)
|
||||
|
||||
@@ -694,43 +694,39 @@ func eniMatchesFilters(record *ENIRecord, filters map[string][]string) bool {
|
||||
// AttachENI marks an ENI as attached to an instance (internal use by RunInstances).
|
||||
// accountID scopes the lookup to the correct KV key.
|
||||
func (s *VPCServiceImpl) AttachENI(accountID, eniId, instanceId string, deviceIndex int64) (string, error) {
|
||||
return s.attachENI(context.Background(), accountID, eniId, instanceId, deviceIndex)
|
||||
return s.attachENI(context.Background(), accountID, eniId, instanceId, deviceIndex, "")
|
||||
}
|
||||
|
||||
func (s *VPCServiceImpl) attachENI(ctx context.Context, accountID, eniId, instanceId string, deviceIndex int64) (string, error) {
|
||||
key := kvutil.AccountKey(accountID, eniId)
|
||||
entry, err := s.eniKV.Get(ctx, key)
|
||||
if err != nil {
|
||||
return "", eniNotFoundError(eniId)
|
||||
}
|
||||
|
||||
var record ENIRecord
|
||||
if err := json.Unmarshal(entry.Value(), &record); err != nil {
|
||||
return "", errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
|
||||
if record.Status == "in-use" {
|
||||
return "", errors.New(awserrors.ErrorInvalidNetworkInterfaceInUse)
|
||||
}
|
||||
// AttachENIWithOwner attaches the ENI and records instanceOwnerID as the owner
|
||||
// of the attached instance in the same write, so no later write can lose the
|
||||
// stamp. An empty owner leaves the record's owner untouched.
|
||||
func (s *VPCServiceImpl) AttachENIWithOwner(accountID, eniId, instanceId string, deviceIndex int64, instanceOwnerID string) (string, error) {
|
||||
return s.attachENI(context.Background(), accountID, eniId, instanceId, deviceIndex, instanceOwnerID)
|
||||
}
|
||||
|
||||
func (s *VPCServiceImpl) attachENI(ctx context.Context, accountID, eniId, instanceId string, deviceIndex int64, instanceOwnerID string) (string, error) {
|
||||
attachmentId := awsidentifiers.GenerateResourceID("eni-attach")
|
||||
record.Status = "in-use"
|
||||
record.AttachmentId = attachmentId
|
||||
record.InstanceId = instanceId
|
||||
record.DeviceIndex = deviceIndex
|
||||
// Default DeleteOnTermination to true on first attach, matching what
|
||||
// DescribeNetworkInterfaces already advertises; a record carrying an
|
||||
// explicit value from an earlier attach keeps it across re-attach.
|
||||
if record.DeleteOnTermination == nil {
|
||||
record.DeleteOnTermination = aws.Bool(true)
|
||||
}
|
||||
|
||||
data, err := json.Marshal(record)
|
||||
err := s.casENI(ctx, kvutil.AccountKey(accountID, eniId), eniId, func(record *ENIRecord) error {
|
||||
if record.Status == "in-use" {
|
||||
return errors.New(awserrors.ErrorInvalidNetworkInterfaceInUse)
|
||||
}
|
||||
record.Status = "in-use"
|
||||
record.AttachmentId = attachmentId
|
||||
record.InstanceId = instanceId
|
||||
record.DeviceIndex = deviceIndex
|
||||
if instanceOwnerID != "" {
|
||||
record.InstanceOwnerId = instanceOwnerID
|
||||
}
|
||||
// Default DeleteOnTermination to true on first attach, matching what
|
||||
// DescribeNetworkInterfaces already advertises; a record carrying an
|
||||
// explicit value from an earlier attach keeps it across re-attach.
|
||||
if record.DeleteOnTermination == nil {
|
||||
record.DeleteOnTermination = aws.Bool(true)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal ENI record: %w", err)
|
||||
}
|
||||
if _, err := s.eniKV.Update(ctx, key, data, entry.Revision()); err != nil {
|
||||
return "", errors.New(awserrors.ErrorServerInternal)
|
||||
return "", err
|
||||
}
|
||||
|
||||
slog.InfoContext(ctx, "ENI attached", "eniId", eniId, "instanceId", instanceId, "attachmentId", attachmentId)
|
||||
@@ -796,24 +792,69 @@ func (s *VPCServiceImpl) UpdateENI(accountID, eniId string, fn func(*ENIRecord))
|
||||
}
|
||||
|
||||
func (s *VPCServiceImpl) updateENI(ctx context.Context, accountID, eniId string, fn func(*ENIRecord)) error {
|
||||
key := kvutil.AccountKey(accountID, eniId)
|
||||
entry, err := s.eniKV.Get(ctx, key)
|
||||
if err != nil {
|
||||
return eniNotFoundError(eniId)
|
||||
return s.casENI(ctx, kvutil.AccountKey(accountID, eniId), eniId, func(r *ENIRecord) error {
|
||||
fn(r)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
const (
|
||||
eniCASAttempts = 3
|
||||
eniCASBackoff = 10 * time.Millisecond
|
||||
)
|
||||
|
||||
// storeError reports ServerInternal to API callers while keeping the store
|
||||
// failure reachable through errors.Is for the caller's log.
|
||||
type storeError struct{ cause error }
|
||||
|
||||
func (e *storeError) Error() string { return awserrors.ErrorServerInternal }
|
||||
func (e *storeError) Unwrap() error { return e.cause }
|
||||
|
||||
// casENI reads the ENI record, applies mutate and writes it back under the
|
||||
// revision it read. A direct get can be answered by a replica that has not yet
|
||||
// applied the caller's own earlier write, so a revision conflict re-reads and
|
||||
// re-applies mutate, bounded. mutate may run more than once and must only
|
||||
// depend on the record it is given.
|
||||
func (s *VPCServiceImpl) casENI(ctx context.Context, key, eniId string, mutate func(*ENIRecord) error) error {
|
||||
var lastErr error
|
||||
attempts := 0
|
||||
for attempt := range eniCASAttempts {
|
||||
attempts = attempt + 1
|
||||
if attempt > 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return &storeError{ctx.Err()}
|
||||
case <-time.After(time.Duration(attempt) * eniCASBackoff):
|
||||
}
|
||||
}
|
||||
entry, err := s.eniKV.Get(ctx, key)
|
||||
if err != nil {
|
||||
return eniNotFoundError(eniId)
|
||||
}
|
||||
var record ENIRecord
|
||||
if err := json.Unmarshal(entry.Value(), &record); err != nil {
|
||||
return errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
if err := mutate(&record); err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := json.Marshal(record)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal ENI record: %w", err)
|
||||
}
|
||||
if _, err = s.eniKV.Update(ctx, key, data, entry.Revision()); err == nil {
|
||||
if attempt > 0 {
|
||||
slog.InfoContext(ctx, "ENI write succeeded after revision conflict", "eniId", eniId, "attempt", attempt+1)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
lastErr = err
|
||||
if !errors.Is(err, jetstream.ErrKeyExists) {
|
||||
break
|
||||
}
|
||||
}
|
||||
var record ENIRecord
|
||||
if err := json.Unmarshal(entry.Value(), &record); err != nil {
|
||||
return errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
fn(&record)
|
||||
data, err := json.Marshal(record)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal ENI record: %w", err)
|
||||
}
|
||||
if _, err := s.eniKV.Update(ctx, key, data, entry.Revision()); err != nil {
|
||||
return errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
return nil
|
||||
slog.ErrorContext(ctx, "ENI write failed", "eniId", eniId, "attempts", attempts, "err", lastErr)
|
||||
return &storeError{lastErr}
|
||||
}
|
||||
|
||||
// ListInstanceENIs returns every ENIRecord in accountID currently attached to
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
package vpc
|
||||
|
||||
import "github.com/nats-io/nats.go/jetstream"
|
||||
|
||||
// WrapENIKVForTest replaces the ENI bucket handle with wrap(current), so a test
|
||||
// can inject the read and write faults a replicated bucket produces.
|
||||
func (s *VPCServiceImpl) WrapENIKVForTest(wrap func(jetstream.KeyValue) jetstream.KeyValue) {
|
||||
s.eniKV = wrap(s.eniKV)
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package vpc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
testutil "github.com/mulgadc/spinifex/internal/testkit"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/state/kvutil"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// A replicated bucket can answer a direct get from a replica that has not yet
|
||||
// applied the caller's own previous write. The next compare-and-swap then
|
||||
// carries a revision that is already superseded.
|
||||
func newStaleReadENI(t *testing.T) (*VPCServiceImpl, string, func() ENIRecord) {
|
||||
t.Helper()
|
||||
svc := setupTestVPCService(t)
|
||||
vpcID := createTestVPC(t, svc, "10.0.0.0/16")
|
||||
subnetID := createTestSubnet(t, svc, vpcID, "10.0.1.0/24")
|
||||
eniID := createTestENI(t, svc, subnetID)
|
||||
var inner jetstream.KeyValue
|
||||
svc.WrapENIKVForTest(func(kv jetstream.KeyValue) jetstream.KeyValue {
|
||||
inner = kv
|
||||
return testutil.NewStaleReadKV(kv)
|
||||
})
|
||||
// Reads the stored record directly: the wrapped bucket would serve the
|
||||
// check itself from a lagging replica.
|
||||
stored := func() ENIRecord {
|
||||
entry, err := inner.Get(context.Background(), kvutil.AccountKey(testAccountID, eniID))
|
||||
require.NoError(t, err)
|
||||
var rec ENIRecord
|
||||
require.NoError(t, json.Unmarshal(entry.Value(), &rec))
|
||||
return rec
|
||||
}
|
||||
return svc, eniID, stored
|
||||
}
|
||||
|
||||
func TestUpdateENIAfterOwnWriteSurvivesStaleReplicaRead(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc, eniID, stored := newStaleReadENI(t)
|
||||
|
||||
_, err := svc.AttachENI(testAccountID, eniID, "i-test123", 0)
|
||||
require.NoError(t, err)
|
||||
|
||||
err = svc.UpdateENI(testAccountID, eniID, func(r *ENIRecord) { r.InstanceOwnerId = "000000000000" })
|
||||
require.NoError(t, err, "a read served from a lagging replica must not lose the update")
|
||||
|
||||
rec := stored()
|
||||
assert.Equal(t, "000000000000", rec.InstanceOwnerId)
|
||||
assert.Equal(t, "i-test123", rec.InstanceId, "the attach must survive the update")
|
||||
}
|
||||
|
||||
func TestAttachENIAfterOwnWriteSurvivesStaleReplicaRead(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc, eniID, stored := newStaleReadENI(t)
|
||||
|
||||
require.NoError(t, svc.UpdateENI(testAccountID, eniID, func(r *ENIRecord) { r.Description = "managed" }))
|
||||
|
||||
_, err := svc.AttachENI(testAccountID, eniID, "i-test123", 0)
|
||||
require.NoError(t, err, "a read served from a lagging replica must not fail the attach")
|
||||
|
||||
rec := stored()
|
||||
assert.Equal(t, "in-use", rec.Status)
|
||||
assert.Equal(t, "managed", rec.Description)
|
||||
}
|
||||
|
||||
func TestUpdateENIReportsTheStoreError(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc := setupTestVPCService(t)
|
||||
vpcID := createTestVPC(t, svc, "10.0.0.0/16")
|
||||
subnetID := createTestSubnet(t, svc, vpcID, "10.0.1.0/24")
|
||||
eniID := createTestENI(t, svc, subnetID)
|
||||
|
||||
cause := errors.New("stream unavailable")
|
||||
svc.WrapENIKVForTest(func(kv jetstream.KeyValue) jetstream.KeyValue {
|
||||
return testutil.NewRejectingKV(kv, func(string, []byte) error { return cause })
|
||||
})
|
||||
|
||||
err := svc.UpdateENI(testAccountID, eniID, func(r *ENIRecord) { r.Description = "x" })
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, cause, "the caller logs this error, so it must name the store failure")
|
||||
}
|
||||
|
||||
func TestAttachENIWithOwnerStampsTheOwnerInTheAttachWrite(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc := setupTestVPCService(t)
|
||||
vpcID := createTestVPC(t, svc, "10.0.0.0/16")
|
||||
subnetID := createTestSubnet(t, svc, vpcID, "10.0.1.0/24")
|
||||
eniID := createTestENI(t, svc, subnetID)
|
||||
|
||||
var writes []ENIRecord
|
||||
svc.WrapENIKVForTest(func(kv jetstream.KeyValue) jetstream.KeyValue {
|
||||
return testutil.NewRejectingKV(kv, func(_ string, value []byte) error {
|
||||
var rec ENIRecord
|
||||
require.NoError(t, json.Unmarshal(value, &rec))
|
||||
writes = append(writes, rec)
|
||||
return nil
|
||||
})
|
||||
})
|
||||
|
||||
_, err := svc.AttachENIWithOwner(testAccountID, eniID, "i-sys", 0, "000000000000")
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Len(t, writes, 1, "attach and owner stamp are one write")
|
||||
assert.Equal(t, "in-use", writes[0].Status)
|
||||
assert.Equal(t, "000000000000", writes[0].InstanceOwnerId)
|
||||
}
|
||||
|
||||
func TestCASENIGivesUpAfterBoundedConflicts(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc, eniID, _ := newStaleReadENI(t)
|
||||
conflicts := 0
|
||||
err := svc.casENI(t.Context(), kvutil.AccountKey(testAccountID, eniID), eniID, func(*ENIRecord) error {
|
||||
conflicts++
|
||||
// Move the record under the next compare-and-swap, as a competing writer does.
|
||||
return svc.UpdateENI(testAccountID, eniID, func(r *ENIRecord) { r.Description = "other writer" })
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.ErrorIs(t, err, jetstream.ErrKeyExists, "the conflict is reported, not hidden")
|
||||
assert.Equal(t, eniCASAttempts, conflicts)
|
||||
}
|
||||
@@ -50,10 +50,11 @@ var eksScopes = map[string][]resourceSource{
|
||||
"ListClusters": {sourceAny},
|
||||
|
||||
// Internal control-plane routes. The cluster's owning account is a path
|
||||
// segment on these two, and the ARN names that account rather than the
|
||||
// segment on these three, and the ARN names that account rather than the
|
||||
// system caller's, because that is the account the handler reads.
|
||||
"ListInternalAddons": {sourceInternalCluster},
|
||||
"GetRecoveryDirective": {sourceInternalCluster},
|
||||
"ListInternalWorkers": {sourceInternalCluster},
|
||||
// The owning account arrives in the body on these two, and the ARN names
|
||||
// that account for the same reason: it is the account the handler acts in.
|
||||
"PublishInternal": {sourceInternalBodyCluster},
|
||||
|
||||
@@ -179,6 +179,10 @@ func TestResourceARNsInternalRoutesUsePathAccount(t *testing.T) {
|
||||
assert.Equal(t,
|
||||
[]string{"arn:aws:eks:ap-southeast-2:999999999999:cluster/prod"},
|
||||
resolveARNs(t, "GetRecoveryDirective", []string{"prod", "999999999999", "i-abc"}, ""))
|
||||
|
||||
assert.Equal(t,
|
||||
[]string{"arn:aws:eks:ap-southeast-2:999999999999:cluster/prod"},
|
||||
resolveARNs(t, "ListInternalWorkers", []string{"prod", "999999999999"}, ""))
|
||||
}
|
||||
|
||||
// An identifier the gate cannot read authorizes account-wide, leaving the
|
||||
|
||||
@@ -93,6 +93,46 @@ func TestEKSRequest_InternalAddonsWireBody(t *testing.T) {
|
||||
rec.Body.String())
|
||||
}
|
||||
|
||||
// The node-gc agent reads the same REST-JSON shape: the Go field name, sorted
|
||||
// IDs drawn from every nodegroup record of the cluster.
|
||||
func TestEKSRequest_InternalWorkersWireBody(t *testing.T) {
|
||||
const cpInstanceID = "i-cp0000000000001"
|
||||
_, nc, js := testutil.StartTestJetStream(t)
|
||||
kv, err := ekscluster.GetOrCreateAccountBucket(t.Context(), js, authzAccountID)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, ekscluster.PutClusterMeta(t.Context(), kv, &ekscluster.ClusterMeta{
|
||||
Name: "prod",
|
||||
Status: ekscluster.ClusterStatusActive,
|
||||
ControlPlaneNodes: []ekscluster.ControlPlaneNode{{InstanceID: cpInstanceID}},
|
||||
}))
|
||||
for _, rec := range []*ekscluster.NodegroupRecord{
|
||||
{ClusterName: "prod", Name: "ng-b", InstanceIDs: []string{"i-0ccc", "i-0aaa"}},
|
||||
{ClusterName: "prod", Name: "ng-a", InstanceIDs: []string{"i-0bbb"}},
|
||||
} {
|
||||
require.NoError(t, ekscluster.PutNodegroupRecord(t.Context(), kv, rec))
|
||||
}
|
||||
|
||||
svc, err := ekscluster.NewEKSServiceImpl(ekscluster.EKSServiceDeps{NATSConn: nc, Region: authzRegion})
|
||||
require.NoError(t, err)
|
||||
sub, err := nc.Subscribe("eks.ListManagedWorkers", func(m *nats.Msg) {
|
||||
acct := natsmsg.AccountIDFromMsg(m)
|
||||
natsmsg.ServeNATSRequestCtx(m, func(ctx context.Context, in *ekscluster.ListManagedWorkersInput) (*ekscluster.ListManagedWorkersOutput, error) {
|
||||
return svc.ListManagedWorkers(ctx, in, acct)
|
||||
})
|
||||
})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = sub.Unsubscribe() })
|
||||
|
||||
gw := scopedEKS(gwtest.Statement("Allow", "eks:*", "*"))
|
||||
gw.nc = nc
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/clusters/prod/internal-workers/"+authzAccountID, nil)
|
||||
require.NoError(t, gw.as(cpAgentContext(cpInstanceID)).serve(rec, req))
|
||||
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
assert.Equal(t, `{"InstanceIDs":["i-0aaa","i-0bbb","i-0ccc"]}`, rec.Body.String())
|
||||
}
|
||||
|
||||
// CreateAddon and UpdateAddon hand their service-account role to iam:PassRole,
|
||||
// same as CreateCluster's roleArn and CreateNodegroup's nodeRole; an absent
|
||||
// role passes nothing.
|
||||
|
||||
@@ -97,6 +97,8 @@ func TestAuthorizeInternal_RejectsNonCPPrincipals(t *testing.T) {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assertDenied(t, eksawsapi.AuthorizeInternal(t.Context(), nil, "ListInternalAddons",
|
||||
tc.caller, []string{"alpha", tenantAccount}, nil))
|
||||
assertDenied(t, eksawsapi.AuthorizeInternal(t.Context(), nil, "ListInternalWorkers",
|
||||
tc.caller, []string{"alpha", tenantAccount}, nil))
|
||||
assertDenied(t, eksawsapi.AuthorizeInternal(t.Context(), nil, "GetRecoveryDirective",
|
||||
tc.caller, []string{"alpha", tenantAccount, cpInstanceID}, nil))
|
||||
assertDenied(t, eksawsapi.AuthorizeInternal(t.Context(), nil, "PublishInternal",
|
||||
@@ -124,6 +126,8 @@ func TestAuthorizeInternal_AllowsClusterMember(t *testing.T) {
|
||||
|
||||
require.NoError(t, eksawsapi.AuthorizeInternal(t.Context(), nc, "ListInternalAddons",
|
||||
cpAgent(cpInstanceID), []string{"alpha", tenantAccount}, nil))
|
||||
require.NoError(t, eksawsapi.AuthorizeInternal(t.Context(), nc, "ListInternalWorkers",
|
||||
cpAgent(cpInstanceID), []string{"alpha", tenantAccount}, nil))
|
||||
require.NoError(t, eksawsapi.AuthorizeInternal(t.Context(), nc, "GetRecoveryDirective",
|
||||
cpAgent(cpInstanceID), []string{"alpha", tenantAccount, cpInstanceID}, nil))
|
||||
require.NoError(t, eksawsapi.AuthorizeInternal(t.Context(), nc, "PublishInternal",
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package awsapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
ekscluster "github.com/mulgadc/spinifex/spinifex/domains/eks/cluster"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
// internalWorkersOutput is the body returned to the on-VM node-gc agent: the
|
||||
// instance ID of every worker the cluster's nodegroups still own.
|
||||
type internalWorkersOutput struct {
|
||||
InstanceIDs []string `json:"instanceIds"`
|
||||
}
|
||||
|
||||
// ListInternalWorkers — GET /clusters/{name}/internal-workers/{acct}.
|
||||
// Internal control-plane VM route (not an AWS-SDK action): same carve-out as
|
||||
// ListInternalAddons, gated by AuthorizeInternal, which admits only a CP agent
|
||||
// serving that account's cluster.
|
||||
func ListInternalWorkers(ctx context.Context, natsConn *nats.Conn, clusterName, accountID string) (*internalWorkersOutput, error) {
|
||||
if natsConn == nil {
|
||||
return nil, errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
if clusterName == "" || accountID == "" {
|
||||
return nil, errors.New(awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
out, err := ekscluster.NewNATSEKSService(natsConn).ListManagedWorkers(ctx,
|
||||
&ekscluster.ListManagedWorkersInput{ClusterName: clusterName}, accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &internalWorkersOutput{InstanceIDs: out.InstanceIDs}, nil
|
||||
}
|
||||
@@ -60,6 +60,14 @@ var routes = []route{
|
||||
return GetRecoveryDirective(ctx, nc, p[0], p[1], p[2])
|
||||
}},
|
||||
|
||||
// Internal control-plane route: the on-VM node-gc agent pulls the cluster's
|
||||
// live nodegroup workers to delete the Nodes of terminated ones. Same
|
||||
// system-cred carve-out as internal-addons.
|
||||
{Method: "GET", Pattern: "/clusters/{clusterName}/internal-workers/{accountId}", Action: "ListInternalWorkers",
|
||||
Handler: func(ctx context.Context, nc *nats.Conn, acct, callerARN string, p []string, b []byte) (any, error) {
|
||||
return ListInternalWorkers(ctx, nc, p[0], p[1])
|
||||
}},
|
||||
|
||||
// Nodegroup
|
||||
{Method: "POST", Pattern: "/clusters/{clusterName}/node-groups", Action: "CreateNodegroup",
|
||||
Handler: func(ctx context.Context, nc *nats.Conn, acct, callerARN string, p []string, b []byte) (any, error) {
|
||||
|
||||
@@ -108,6 +108,7 @@ func TestLookupEKSAction_CoversAllActions(t *testing.T) {
|
||||
"WebhookTokenReview": false,
|
||||
"ListInternalAddons": false,
|
||||
"GetRecoveryDirective": false,
|
||||
"ListInternalWorkers": false,
|
||||
"DescribeCluster": false,
|
||||
"ListClusters": false,
|
||||
"DeleteCluster": false,
|
||||
|
||||
@@ -22,9 +22,10 @@ const (
|
||||
// WebhookTokenReview (the eks-token-webhook relays `aws eks get-token` bearer
|
||||
// tokens to the token-review broker for host-side STS verification), and
|
||||
// GetRecoveryDirective (the k3s-recovery agent pulls its per-member etcd
|
||||
// recovery directive at boot). The gateway evaluates these per request against
|
||||
// the role's policies.
|
||||
eksServerInlinePolicy = `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["eks:PublishInternal","eks:ListInternalAddons","eks:WebhookTokenReview","eks:GetRecoveryDirective"],"Resource":"*"}]}`
|
||||
// recovery directive at boot), and ListInternalWorkers (the node-gc agent
|
||||
// pulls the live nodegroup workers). The gateway evaluates these per request
|
||||
// against the role's policies.
|
||||
eksServerInlinePolicy = `{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["eks:PublishInternal","eks:ListInternalAddons","eks:WebhookTokenReview","eks:GetRecoveryDirective","eks:ListInternalWorkers"],"Resource":"*"}]}`
|
||||
)
|
||||
|
||||
// ensureCPInstanceProfile returns the CP instance-profile ARN, or "" to signal
|
||||
@@ -35,7 +36,7 @@ func (s *EKSServiceImpl) ensureCPInstanceProfile(accountID string) string {
|
||||
iam := s.iamEnsurer()
|
||||
if iam == nil {
|
||||
slog.Warn("EKS: IAM service unwired; CP VM falls back to baked static gateway creds and "+
|
||||
"will be denied eks:PublishInternal, eks:WebhookTokenReview, eks:ListInternalAddons and eks:GetRecoveryDirective (no instance identity to bind)",
|
||||
"will be denied eks:PublishInternal, eks:WebhookTokenReview, eks:ListInternalAddons, eks:GetRecoveryDirective and eks:ListInternalWorkers (no instance identity to bind)",
|
||||
"accountID", accountID)
|
||||
return ""
|
||||
}
|
||||
@@ -43,7 +44,7 @@ func (s *EKSServiceImpl) ensureCPInstanceProfile(accountID string) string {
|
||||
CPInstanceRoleName, eksServerInlinePolicyName, eksServerInlinePolicy)
|
||||
if err != nil {
|
||||
slog.Error("EKS: ensure CP instance profile failed; CP VM falls back to baked static gateway creds and "+
|
||||
"will be denied eks:PublishInternal, eks:WebhookTokenReview, eks:ListInternalAddons and eks:GetRecoveryDirective (no instance identity to bind)",
|
||||
"will be denied eks:PublishInternal, eks:WebhookTokenReview, eks:ListInternalAddons, eks:GetRecoveryDirective and eks:ListInternalWorkers (no instance identity to bind)",
|
||||
"accountID", accountID, "role", CPInstanceRoleName, "err", err)
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -38,6 +38,9 @@ func TestEnsureK3sServerInstanceProfile_CreatesRolePolicyProfile(t *testing.T) {
|
||||
// these IMDS creds; without this action the gateway 403s the boot-time fetch
|
||||
// and a wedged control plane can never be auto-reset.
|
||||
assert.Contains(t, policy, "eks:GetRecoveryDirective")
|
||||
// The node-gc agent pulls the live nodegroup workers with these IMDS creds;
|
||||
// without this action a terminated worker's Node is never deleted.
|
||||
assert.Contains(t, policy, "eks:ListInternalWorkers")
|
||||
|
||||
require.NotNil(t, f.Profiles[CPInstanceRoleName])
|
||||
assert.Len(t, f.Profiles[CPInstanceRoleName].Roles, 1)
|
||||
|
||||
@@ -44,6 +44,10 @@ type EKSService interface {
|
||||
// AWS-SDK action): the on-VM addon-sync agent fetches staged manifests for a
|
||||
// cluster via the internal-addons gateway route.
|
||||
ListStagedAddonManifests(ctx context.Context, input *ListStagedAddonManifestsInput, accountID string) (*ListStagedAddonManifestsOutput, error)
|
||||
// ListManagedWorkers is an internal control-plane method (not an AWS-SDK
|
||||
// action): the on-VM node-gc agent fetches the cluster's live nodegroup
|
||||
// workers via the internal-workers gateway route.
|
||||
ListManagedWorkers(ctx context.Context, input *ListManagedWorkersInput, accountID string) (*ListManagedWorkersOutput, error)
|
||||
|
||||
// GetRecoveryDirective / SetRecoveryDirective are internal control-plane
|
||||
// methods (not AWS-SDK actions): the on-VM k3s-recovery agent fetches its
|
||||
|
||||
@@ -129,6 +129,10 @@ func (s *NATSEKSService) ListStagedAddonManifests(ctx context.Context, input *Li
|
||||
return natsmsg.NATSRequest[ListStagedAddonManifestsOutput](ctx, s.natsConn, "eks.ListStagedAddonManifests", input, defaultTimeout, accountID)
|
||||
}
|
||||
|
||||
func (s *NATSEKSService) ListManagedWorkers(ctx context.Context, input *ListManagedWorkersInput, accountID string) (*ListManagedWorkersOutput, error) {
|
||||
return natsmsg.NATSRequest[ListManagedWorkersOutput](ctx, s.natsConn, "eks.ListManagedWorkers", input, defaultTimeout, accountID)
|
||||
}
|
||||
|
||||
func (s *NATSEKSService) UpdateAddon(ctx context.Context, input *eks.UpdateAddonInput, accountID string) (*eks.UpdateAddonOutput, error) {
|
||||
return natsmsg.NATSRequest[eks.UpdateAddonOutput](ctx, s.natsConn, "eks.UpdateAddon", input, defaultTimeout, accountID)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
package cluster
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"slices"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
)
|
||||
|
||||
// ListManagedWorkersInput names the cluster whose managed-nodegroup workers to
|
||||
// list. It is an internal control-plane request (not an AWS-SDK shape), served
|
||||
// over NATS for the guest node-gc agent via the internal-workers route.
|
||||
type ListManagedWorkersInput struct {
|
||||
ClusterName string `json:"clusterName"`
|
||||
}
|
||||
|
||||
// ListManagedWorkersOutput carries the instance ID of every worker a managed
|
||||
// nodegroup of the cluster still owns, sorted.
|
||||
type ListManagedWorkersOutput struct {
|
||||
InstanceIDs []string `json:"instanceIds"`
|
||||
}
|
||||
|
||||
// ListManagedWorkers returns every worker instance ID recorded on the cluster's
|
||||
// nodegroups. A nodegroup drops an ID only after terminating it (DeleteNodegroup,
|
||||
// scale-down, reclaim), so the guest deletes a nodegroup Node whose instance is
|
||||
// absent here, as the AWS cloud controller deletes a Node whose instance is gone.
|
||||
func (s *EKSServiceImpl) ListManagedWorkers(ctx context.Context, input *ListManagedWorkersInput, accountID string) (*ListManagedWorkersOutput, error) {
|
||||
if input == nil || input.ClusterName == "" {
|
||||
return nil, errors.New(awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
acctKV, err := s.acctKVForCluster(ctx, accountID, input.ClusterName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
recs, err := ListNodegroupRecords(ctx, acctKV, input.ClusterName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids := make([]string, 0)
|
||||
for _, rec := range recs {
|
||||
ids = append(ids, rec.InstanceIDs...)
|
||||
}
|
||||
slices.Sort(ids)
|
||||
return &ListManagedWorkersOutput{InstanceIDs: slices.Compact(ids)}, nil
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package cluster
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/eks"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func managedWorkers(t *testing.T, f *eksServiceFixture, cluster string) []string {
|
||||
t.Helper()
|
||||
out, err := f.svc.ListManagedWorkers(context.Background(), &ListManagedWorkersInput{ClusterName: cluster}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
return out.InstanceIDs
|
||||
}
|
||||
|
||||
// A worker leaves the managed set exactly when its nodegroup terminates it, by
|
||||
// scale-down or DeleteNodegroup, so the guest node-gc deletes its Node then and
|
||||
// leaves every worker still owned by a nodegroup alone.
|
||||
func TestListManagedWorkers_DropsWorkersTheNodegroupTerminated(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := newEKSServiceFixture(t)
|
||||
seedActiveClusterWithToken(t, f, "c1")
|
||||
for _, ng := range []string{"ng1", "ng2"} {
|
||||
_, err := f.svc.CreateNodegroup(context.Background(), createNGInput("c1", ng, 2), testAccountID)
|
||||
require.NoError(t, err)
|
||||
markWorkersReady(t, f, "c1", ng, 2)
|
||||
f.svc.WaitLaunches()
|
||||
}
|
||||
ng1, err := GetNodegroupRecord(t.Context(), f.kv, "c1", "ng1")
|
||||
require.NoError(t, err)
|
||||
ng2, err := GetNodegroupRecord(t.Context(), f.kv, "c1", "ng2")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, ng1.InstanceIDs, 2)
|
||||
assert.ElementsMatch(t, append(append([]string{}, ng1.InstanceIDs...), ng2.InstanceIDs...), managedWorkers(t, f, "c1"))
|
||||
|
||||
_, err = f.svc.UpdateNodegroupConfig(context.Background(), &eks.UpdateNodegroupConfigInput{
|
||||
ClusterName: aws.String("c1"),
|
||||
NodegroupName: aws.String("ng1"),
|
||||
ScalingConfig: &eks.NodegroupScalingConfig{DesiredSize: aws.Int64(1)},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, f.worker.terminateCalls, 1)
|
||||
scaledIn := f.worker.terminateCalls[0]
|
||||
got := managedWorkers(t, f, "c1")
|
||||
assert.Len(t, got, 3)
|
||||
for _, id := range scaledIn {
|
||||
assert.NotContains(t, got, id, "a worker terminated by scale-down must leave the managed set")
|
||||
}
|
||||
|
||||
_, err = f.svc.DeleteNodegroup(context.Background(), &eks.DeleteNodegroupInput{
|
||||
ClusterName: aws.String("c1"), NodegroupName: aws.String("ng2"),
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
got = managedWorkers(t, f, "c1")
|
||||
assert.Len(t, got, 1)
|
||||
for _, id := range ng2.InstanceIDs {
|
||||
assert.NotContains(t, got, id, "a deleted nodegroup's workers must leave the managed set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListManagedWorkers_UnknownClusterIsNotFound(t *testing.T) {
|
||||
t.Parallel()
|
||||
f := newEKSServiceFixture(t)
|
||||
|
||||
_, err := f.svc.ListManagedWorkers(context.Background(), &ListManagedWorkersInput{ClusterName: "ghost"}, testAccountID)
|
||||
code, ok := awserrors.ResolveErrorCode(err)
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, awserrors.ErrorEKSResourceNotFound, code)
|
||||
}
|
||||
@@ -2,9 +2,12 @@ package elbv2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -80,3 +83,80 @@ func TestStartLifecycleReaper(t *testing.T) {
|
||||
|
||||
cancel()
|
||||
}
|
||||
|
||||
// A load balancer the reaper gives up on must not leave its managed VM running:
|
||||
// the VM holds capacity and no customer data, and nothing else would stop it.
|
||||
func TestReapStuckProvisioningLBsTerminatesOnlyTheFailedLBsVM(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc := setupTestService(t)
|
||||
launcher := &mockSystemInstanceLauncher{}
|
||||
svc.InstanceLauncher = launcher
|
||||
now := time.Now().UTC()
|
||||
|
||||
stuck := testLBRecord("stuck0000000000", StateProvisioning, now.Add(-10*time.Minute), time.Time{})
|
||||
stuck.InstanceID = "i-stuck"
|
||||
noVM := testLBRecord("novm0000000000", StateProvisioning, now.Add(-10*time.Minute), time.Time{})
|
||||
fresh := testLBRecord("fresh0000000000", StateProvisioning, now, time.Time{})
|
||||
fresh.InstanceID = "i-fresh"
|
||||
active := testLBRecord("active000000000", StateActive, now.Add(-10*time.Minute), time.Time{})
|
||||
active.InstanceID = "i-active"
|
||||
heartbeating := testLBRecord("heartbeat0000000", StateProvisioning, now.Add(-10*time.Minute), now.Add(-time.Second))
|
||||
heartbeating.InstanceID = "i-heartbeating"
|
||||
for _, lb := range []*LoadBalancerRecord{stuck, noVM, fresh, active, heartbeating} {
|
||||
require.NoError(t, svc.store.PutLoadBalancer(t.Context(), lb))
|
||||
}
|
||||
|
||||
svc.reapStuckProvisioningLBs(t.Context(), now)
|
||||
|
||||
got, err := svc.store.GetLoadBalancer(t.Context(), stuck.LoadBalancerID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, StateFailed, got.State)
|
||||
assert.NotEmpty(t, got.StateReason, "the failure reason stays on the LB")
|
||||
|
||||
terminated := func() []string {
|
||||
launcher.mu.Lock()
|
||||
defer launcher.mu.Unlock()
|
||||
return append([]string(nil), launcher.terminateCalls...)
|
||||
}
|
||||
require.Eventually(t, func() bool { return len(terminated()) > 0 }, 2*time.Second, 10*time.Millisecond,
|
||||
"the reaper must terminate the failed LB's VM")
|
||||
assert.Equal(t, []string{"i-stuck"}, terminated(),
|
||||
"only the failed LB's own VM is terminated, never another LB's")
|
||||
}
|
||||
|
||||
func TestReapStuckProvisioningLBsDetachesTheFailedLBsENIs(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
terminateErr error
|
||||
wantStatus string
|
||||
}{
|
||||
{"terminated VM releases its ENI", nil, "available"},
|
||||
{"a VM that cannot be stopped keeps its ENI attached", errors.New("nats timeout"), "in-use"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
svc, vpcSvc, subnetID := setupMicrovmTestService(t)
|
||||
svc.InstanceLauncher = &mockSystemInstanceLauncher{terminateErr: tc.terminateErr}
|
||||
|
||||
eniOut, err := vpcSvc.CreateNetworkInterface(t.Context(), &ec2.CreateNetworkInterfaceInput{SubnetId: aws.String(subnetID)}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
eniID := aws.StringValue(eniOut.NetworkInterface.NetworkInterfaceId)
|
||||
_, err = vpcSvc.AttachENI(testAccountID, eniID, "i-stuck", 0)
|
||||
require.NoError(t, err)
|
||||
|
||||
now := time.Now().UTC()
|
||||
lb := testLBRecord("enirel0000000000", StateProvisioning, now.Add(-10*time.Minute), time.Time{})
|
||||
lb.AccountID = testAccountID
|
||||
lb.InstanceID = "i-stuck"
|
||||
lb.ENIs = []string{eniID}
|
||||
require.NoError(t, svc.store.PutLoadBalancer(t.Context(), lb))
|
||||
|
||||
svc.reapStuckProvisioningLBs(t.Context(), now)
|
||||
|
||||
rec, err := vpcSvc.GetENIRecord(testAccountID, eniID)
|
||||
require.NoError(t, err, "the ENI stays for DeleteLoadBalancer to remove")
|
||||
assert.Equal(t, tc.wantStatus, rec.Status)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1772,6 +1772,31 @@ func (s *ELBv2ServiceImpl) reapStuckProvisioningLBs(ctx context.Context, now tim
|
||||
"lbArn", lb.LoadBalancerArn, "lbId", lb.LoadBalancerID,
|
||||
"instanceId", lb.InstanceID, "createdAt", lb.CreatedAt)
|
||||
s.markLBFailed(ctx, lb.LoadBalancerArn, reason)
|
||||
s.releaseFailedLBVM(ctx, lb)
|
||||
}
|
||||
}
|
||||
|
||||
// releaseFailedLBVM stops the failed LB's own managed VM and detaches its ENIs,
|
||||
// so a failed LB holds no running capacity. The ENIs stay on the record for
|
||||
// DeleteLoadBalancer to remove. A VM that cannot be stopped keeps its ENIs
|
||||
// attached, since detaching under a running guest would only hide the leak.
|
||||
func (s *ELBv2ServiceImpl) releaseFailedLBVM(ctx context.Context, lb *LoadBalancerRecord) {
|
||||
if lb.InstanceID == "" || s.InstanceLauncher == nil {
|
||||
return
|
||||
}
|
||||
if err := s.InstanceLauncher.TerminateSystemInstance(lb.InstanceID); err != nil {
|
||||
slog.Error("ELBv2 lifecycle reaper: failed to terminate failed LB VM",
|
||||
"lbId", lb.LoadBalancerID, "instanceId", lb.InstanceID, "err", err)
|
||||
return
|
||||
}
|
||||
if s.VPCService == nil {
|
||||
return
|
||||
}
|
||||
for _, eniID := range lb.ENIs {
|
||||
if err := s.VPCService.DetachENI(ctx, lb.AccountID, eniID); err != nil && !awserrors.IsNotFound(err) {
|
||||
slog.Warn("ELBv2 lifecycle reaper: failed to detach failed LB ENI",
|
||||
"lbId", lb.LoadBalancerID, "eniId", eniID, "err", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -216,7 +216,7 @@ func (denyAllAccessResolver) Granted(_ context.Context, _, _ string) (bool, erro
|
||||
}
|
||||
|
||||
// DenyAllAccessResolver grants no model to any account. It is the fallback for
|
||||
// a nil GatewayConfig.BedrockAccess: the insecure direction of this default is
|
||||
// a nil pipeline.Config.BedrockAccess: the insecure direction of this default is
|
||||
// "nothing works", which surfaces immediately, rather than "everything works",
|
||||
// which surfaces as a breach.
|
||||
var DenyAllAccessResolver AccessResolver = denyAllAccessResolver{}
|
||||
|
||||
@@ -71,14 +71,14 @@ type grantAllModels struct{}
|
||||
|
||||
func (grantAllModels) Granted(_ context.Context, _, _ string) (bool, error) { return true, nil }
|
||||
|
||||
// newBedrockRequestGateway builds a GatewayConfig with a real NATS connection
|
||||
// newBedrockRequestGateway builds a pipeline.Config with a real NATS connection
|
||||
// (satisfying Bedrock_Request/BedrockRuntime_Request's nil-check only — no
|
||||
// NATS subject handling is required for these routes), an allow-everything
|
||||
// IAMService so the policy gate passes, BedrockEndpoints pinned at the given
|
||||
// vLLM stub, and every model granted — access is deny-by-default, so without a
|
||||
// resolver these routes would all return before reaching the code under test.
|
||||
// ListFoundationModels/GetFoundationModel read the weights resolver through a
|
||||
// package-level setter rather than a GatewayConfig field, so it is installed
|
||||
// package-level setter rather than a pipeline.Config field, so it is installed
|
||||
// here and reset on cleanup.
|
||||
func newBedrockRequestGateway(t *testing.T, vllmURL string) *testGateway {
|
||||
t.Helper()
|
||||
|
||||
@@ -22,7 +22,7 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// newBedrockAgentRequestGateway builds a GatewayConfig with fresh
|
||||
// newBedrockAgentRequestGateway builds a pipeline.Config with fresh
|
||||
// gateway-owned KB/DataSource stores over a real (embedded) JetStream and an
|
||||
// allow-everything IAMService, so the policy gate passes on a real evaluation
|
||||
// rather than a bypass, mirroring newBedrockRequestGateway.
|
||||
|
||||
@@ -23,7 +23,7 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// newBedrockAgentRuntimeRequestGateway builds a GatewayConfig wired the same
|
||||
// newBedrockAgentRuntimeRequestGateway builds a pipeline.Config wired the same
|
||||
// way as newBedrockAgentRequestGateway (real JetStream-backed KB/DataSource
|
||||
// stores, allow-everything IAMService) plus newBedrockRequestGateway's vLLM
|
||||
// routing (BedrockEndpoints/BedrockAccess), since RetrieveAndGenerate's
|
||||
|
||||
@@ -106,5 +106,5 @@ func (noopCredentialResolver) Resolve(_ context.Context, _, _ string) (string, b
|
||||
}
|
||||
|
||||
// NoopCredentialResolver resolves no vendors. Used as the fallback wherever
|
||||
// no CredentialStore is configured (nil GatewayConfig.BedrockCredentials).
|
||||
// no CredentialStore is configured (nil pipeline.Config.BedrockCredentials).
|
||||
var NoopCredentialResolver CredentialResolver = noopCredentialResolver{}
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
package gateway
|
||||
|
||||
import ()
|
||||
@@ -1,10 +1,6 @@
|
||||
package gateway
|
||||
package dispatch
|
||||
|
||||
import (
|
||||
"slices"
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/ingress/aws/dispatch"
|
||||
)
|
||||
import "slices"
|
||||
|
||||
// ServiceOperationInventory describes the authoritative dispatch state for an
|
||||
// AWS-compatible service. Registered operations minus Stubbed and Unsupported
|
||||
@@ -19,7 +15,7 @@ type ServiceOperationInventory struct {
|
||||
// declared inventory of each registered service. S3 is intentionally absent:
|
||||
// Spinifex does not dispatch S3 operations and delegates that REST surface to
|
||||
// Predastore.
|
||||
func AWSOperationInventory(registered map[string]dispatch.Inventory) map[string]ServiceOperationInventory {
|
||||
func AWSOperationInventory(registered map[string]Inventory) map[string]ServiceOperationInventory {
|
||||
inventory := make(map[string]ServiceOperationInventory, len(registered))
|
||||
for name, inv := range registered {
|
||||
inventory[name] = ServiceOperationInventory{
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
package gateway
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
@@ -30,7 +30,7 @@ func TestACMActionFromTarget_FormsAndTelemetryAgree(t *testing.T) {
|
||||
{"bare action", "ListCertificates", "ListCertificates"},
|
||||
{"trailing dot", "CertificateManager.", ""},
|
||||
}
|
||||
acmEntry, ok := withACM(&GatewayConfig{}, acmawsapi.Deps{}).registered(acmawsapi.ServiceName)
|
||||
acmEntry, ok := withACM(&Config{}, acmawsapi.Deps{}).registered(acmawsapi.ServiceName)
|
||||
require.True(t, ok)
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
package gateway
|
||||
package pipeline
|
||||
|
||||
//test:in-package — this characterization reads unexported request-context keys.
|
||||
|
||||
@@ -95,7 +95,7 @@ func actorProbeGateway(t *testing.T, e *cbEnv) (http.Handler, *[]actorObservatio
|
||||
return nil
|
||||
},
|
||||
}))
|
||||
gw := gwfix.WithDeps(&GatewayConfig{
|
||||
gw := gwfix.WithDeps(&Config{
|
||||
DisableLogging: true,
|
||||
Region: gwfix.Region,
|
||||
}, gwfix.Deps{NATS: e.nc, Nodes: 1, IAM: e.counter, STS: e.sts})
|
||||
@@ -1,4 +1,4 @@
|
||||
package gateway
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -23,7 +23,7 @@ type AdminSurface struct {
|
||||
// here so it keeps its place ahead of the service selector; the work is the
|
||||
// spinifex registration's, dispatched exactly as a Query request is. Only the
|
||||
// error rendering differs: this surface answers in JSON with a request ID.
|
||||
func (gw *GatewayConfig) Admin_Request(w http.ResponseWriter, r *http.Request) {
|
||||
func (gw *Config) Admin_Request(w http.ResponseWriter, r *http.Request) {
|
||||
requestID := uuid.NewV4().String()
|
||||
w.Header().Set("X-Amzn-Requestid", requestID)
|
||||
if gw.Admin.WithRequestID != nil {
|
||||
@@ -62,7 +62,7 @@ type adminErrorDetail struct {
|
||||
// writeAdminErrorFor resolves an error to its code and any message its call
|
||||
// site attached, so a wrapped error renders as its own code instead of falling
|
||||
// through to InternalError.
|
||||
func (gw *GatewayConfig) writeAdminErrorFor(w http.ResponseWriter, requestID string, err error) {
|
||||
func (gw *Config) writeAdminErrorFor(w http.ResponseWriter, requestID string, err error) {
|
||||
code, message, ok := awserrors.ResolveErrorDetail(err)
|
||||
if !ok {
|
||||
// The cause reaches neither the wire nor the envelope's own unmapped-code
|
||||
@@ -76,7 +76,7 @@ func (gw *GatewayConfig) writeAdminErrorFor(w http.ResponseWriter, requestID str
|
||||
// writeAdminError writes the JSON error envelope. message overrides the
|
||||
// registry text when a specific cause is safe to disclose; a policy denial
|
||||
// names principal, action and resource, all of them caller-derivable.
|
||||
func (gw *GatewayConfig) writeAdminError(w http.ResponseWriter, requestID, code, message string) {
|
||||
func (gw *Config) writeAdminError(w http.ResponseWriter, requestID, code, message string) {
|
||||
detail, ok := awserrors.ErrorLookup[code]
|
||||
if !ok {
|
||||
slog.Error("Admin: unmapped error code", "code", code)
|
||||
+3
-3
@@ -1,4 +1,4 @@
|
||||
package gateway
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -49,13 +49,13 @@ func TestAdminOutcome_LogFollowsTheRealResult(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
accountID string
|
||||
mutate func(*GatewayConfig)
|
||||
mutate func(*Config)
|
||||
wantStatus int
|
||||
wantLevel string
|
||||
}{
|
||||
{"success", awsidentifiers.DefaultAccountID(), nil, http.StatusOK, ""},
|
||||
{"denied caller", authzAccountID, nil, http.StatusForbidden, "INFO"},
|
||||
{"cluster fault", awsidentifiers.DefaultAccountID(), func(gw *GatewayConfig) { gwfix.DepsOf(gw).NATS = nil }, http.StatusServiceUnavailable, "ERROR"},
|
||||
{"cluster fault", awsidentifiers.DefaultAccountID(), func(gw *Config) { gwfix.DepsOf(gw).NATS = nil }, http.StatusServiceUnavailable, "ERROR"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
@@ -1,4 +1,4 @@
|
||||
package gateway
|
||||
package pipeline
|
||||
|
||||
//test:in-package — the authorization gates read unexported context keys and
|
||||
// principal-type constants, and asserting on them through the router alone
|
||||
@@ -41,7 +41,7 @@ func createAccountPolicy() *gwfix.PolicyIAM {
|
||||
}
|
||||
|
||||
// adminRequest drives Admin_Request with the chi route param the router sets.
|
||||
func adminRequest(gw *GatewayConfig, method string, body string) *httptest.ResponseRecorder {
|
||||
func adminRequest(gw *Config, method string, body string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/"+method, strings.NewReader(body))
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("method", method)
|
||||
@@ -62,7 +62,7 @@ func decodeAdminError(t *testing.T, rec *httptest.ResponseRecorder) adminErrorBo
|
||||
// An unknown method is rejected before authorization, so it never discloses
|
||||
// whether the caller would have been permitted.
|
||||
func TestAdminRequestUnknownMethod(t *testing.T) {
|
||||
gw := withOperator(withRealAuth(gwfix.WithDeps(&GatewayConfig{DisableLogging: true}, gwfix.Deps{IAM: allowAllIAMService()})))
|
||||
gw := withOperator(withRealAuth(gwfix.WithDeps(&Config{DisableLogging: true}, gwfix.Deps{IAM: allowAllIAMService()})))
|
||||
|
||||
rec := adminRequest(gw, "PurgeEverything", `{}`)
|
||||
|
||||
@@ -71,7 +71,7 @@ func TestAdminRequestUnknownMethod(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAdminRequestDeniesUnauthorizedCaller(t *testing.T) {
|
||||
gw := withOperator(withRealAuth(gwfix.WithDeps(&GatewayConfig{DisableLogging: true}, gwfix.Deps{IAM: allowAllIAMService()})))
|
||||
gw := withOperator(withRealAuth(gwfix.WithDeps(&Config{DisableLogging: true}, gwfix.Deps{IAM: allowAllIAMService()})))
|
||||
|
||||
rec := adminRequest(gw, "CreateAccount", `{}`)
|
||||
|
||||
@@ -83,7 +83,7 @@ func TestAdminRequestDeniesUnauthorizedCaller(t *testing.T) {
|
||||
// The request ID ties a client failure to the gateway log line, so it must be
|
||||
// present on the header and in the body even for a denial.
|
||||
func TestAdminRequestAlwaysCarriesRequestID(t *testing.T) {
|
||||
gw := withOperator(withRealAuth(gwfix.WithDeps(&GatewayConfig{DisableLogging: true}, gwfix.Deps{IAM: allowAllIAMService()})))
|
||||
gw := withOperator(withRealAuth(gwfix.WithDeps(&Config{DisableLogging: true}, gwfix.Deps{IAM: allowAllIAMService()})))
|
||||
|
||||
rec := adminRequest(gw, "CreateAccount", `{}`)
|
||||
|
||||
@@ -94,7 +94,7 @@ func TestAdminRequestAlwaysCarriesRequestID(t *testing.T) {
|
||||
|
||||
// An unmapped code must not escape as a 200 or an empty body.
|
||||
func TestWriteAdminErrorFallsBackOnUnknownCode(t *testing.T) {
|
||||
gw := &GatewayConfig{DisableLogging: true}
|
||||
gw := &Config{DisableLogging: true}
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
gw.writeAdminError(rec, "req-1", "NotARegisteredCode", "")
|
||||
@@ -106,7 +106,7 @@ func TestWriteAdminErrorFallsBackOnUnknownCode(t *testing.T) {
|
||||
// The router serves every verb from one pattern, so the handler is what
|
||||
// refuses a GET — a second route would shadow the POST one.
|
||||
func TestAdminRequestRejectsNonPost(t *testing.T) {
|
||||
gw := withOperator(withRealAuth(gwfix.WithDeps(&GatewayConfig{DisableLogging: true}, gwfix.Deps{IAM: allowAllIAMService()})))
|
||||
gw := withOperator(withRealAuth(gwfix.WithDeps(&Config{DisableLogging: true}, gwfix.Deps{IAM: allowAllIAMService()})))
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/CreateAccount", nil)
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("method", "CreateAccount")
|
||||
@@ -121,13 +121,13 @@ func TestAdminRequestRejectsNonPost(t *testing.T) {
|
||||
|
||||
// authorizedAdminRequest builds a request that clears every authorization gate,
|
||||
// so the guards behind them can be tested on their own.
|
||||
func authorizedAdminRequest(gw *GatewayConfig, body string) *httptest.ResponseRecorder {
|
||||
func authorizedAdminRequest(gw *Config, body string) *httptest.ResponseRecorder {
|
||||
return authorizedAdminRequestForMethod(gw, "CreateAccount", body)
|
||||
}
|
||||
|
||||
// authorizedAdminRequestForMethod is the same for a named method, so a grant
|
||||
// that covers one method can be shown not to cover another.
|
||||
func authorizedAdminRequestForMethod(gw *GatewayConfig, method, body string) *httptest.ResponseRecorder {
|
||||
func authorizedAdminRequestForMethod(gw *Config, method, body string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodPost, "/admin/"+method, strings.NewReader(body))
|
||||
rctx := chi.NewRouteContext()
|
||||
rctx.URLParams.Add("method", method)
|
||||
@@ -146,7 +146,7 @@ func authorizedAdminRequestForMethod(gw *GatewayConfig, method, body string) *ht
|
||||
// An authorized caller must still get a clear answer when the cluster is not
|
||||
// reachable, rather than a panic or a silent success.
|
||||
func TestAdminRequestReportsClusterUnavailable(t *testing.T) {
|
||||
gw := withOperator(withRealAuth(gwfix.WithDeps(&GatewayConfig{DisableLogging: true}, gwfix.Deps{IAM: createAccountPolicy()})))
|
||||
gw := withOperator(withRealAuth(gwfix.WithDeps(&Config{DisableLogging: true}, gwfix.Deps{IAM: createAccountPolicy()})))
|
||||
|
||||
rec := authorizedAdminRequest(gw, `{}`)
|
||||
|
||||
@@ -156,7 +156,7 @@ func TestAdminRequestReportsClusterUnavailable(t *testing.T) {
|
||||
// Each admin method is a separate grant. The signup Worker holds
|
||||
// spinifex:CreateAccount and must not reach deletion with it.
|
||||
func TestCreateAccountGrantDoesNotAllowDeleteAccount(t *testing.T) {
|
||||
gw := withOperator(withRealAuth(gwfix.WithDeps(&GatewayConfig{DisableLogging: true}, gwfix.Deps{IAM: createAccountPolicy()})))
|
||||
gw := withOperator(withRealAuth(gwfix.WithDeps(&Config{DisableLogging: true}, gwfix.Deps{IAM: createAccountPolicy()})))
|
||||
|
||||
for _, method := range []string{"DeleteAccount", "DescribeAccountDeletion", "ListAccounts"} {
|
||||
t.Run(method, func(t *testing.T) {
|
||||
@@ -1,4 +1,4 @@
|
||||
package gateway
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -31,7 +31,7 @@ type authSource struct {
|
||||
}
|
||||
|
||||
// SigV4AuthMiddleware returns stdlib middleware that validates AWS Signature V4 authentication.
|
||||
func (gw *GatewayConfig) SigV4AuthMiddleware() func(http.Handler) http.Handler {
|
||||
func (gw *Config) SigV4AuthMiddleware() func(http.Handler) http.Handler {
|
||||
if gw.RateLimiter == nil {
|
||||
gw.RateLimiter = authlimit.NewAuthRateLimiter()
|
||||
}
|
||||
@@ -249,7 +249,7 @@ func signingTime(r *http.Request) string {
|
||||
// mismatchAction recovers the Action for a rejected query-protocol request so the log names
|
||||
// the operation. Auth fails before the dispatcher parses it, which otherwise leaves the
|
||||
// trace as a bare "POST /". A scope whose body is object data is skipped.
|
||||
func (gw *GatewayConfig) mismatchAction(r *http.Request, service string) string {
|
||||
func (gw *Config) mismatchAction(r *http.Request, service string) string {
|
||||
if traits, ok := gw.selector().Traits(service); ok && traits.PayloadBody {
|
||||
return ""
|
||||
}
|
||||
@@ -274,7 +274,7 @@ func (gw *GatewayConfig) mismatchAction(r *http.Request, service string) string
|
||||
}
|
||||
|
||||
// recordCredentialFailure counts a verifier rejection toward the source's lockout.
|
||||
func (gw *GatewayConfig) recordCredentialFailure(src authSource, f *credential.Failure) {
|
||||
func (gw *Config) recordCredentialFailure(src authSource, f *credential.Failure) {
|
||||
if f.FingerprintKind == "" {
|
||||
return
|
||||
}
|
||||
@@ -287,7 +287,7 @@ func (gw *GatewayConfig) recordCredentialFailure(src authSource, f *credential.F
|
||||
// request. message overrides the registry text from ErrorLookup when the
|
||||
// call site knows a more truthful reason to give; pass "" to use the
|
||||
// registry text unchanged.
|
||||
func (gw *GatewayConfig) writeSigV4Error(w http.ResponseWriter, r *http.Request, errorCode, message string) {
|
||||
func (gw *Config) writeSigV4Error(w http.ResponseWriter, r *http.Request, errorCode, message string) {
|
||||
requestID := uuid.NewV4().String()
|
||||
audit.From(r.Context()).SetAuthError(errorCode)
|
||||
|
||||
@@ -361,7 +361,7 @@ func signedService(r *http.Request) string {
|
||||
// service must carry: the global service's signing region when the client
|
||||
// claimed it, else the gateway's own region. A client given an endpoint
|
||||
// override signs with its own configured region, so both are accepted.
|
||||
func (gw *GatewayConfig) expectedSigningRegion(service, claimed string) string {
|
||||
func (gw *Config) expectedSigningRegion(service, claimed string) string {
|
||||
if traits, ok := gw.selector().Traits(service); ok && traits.GlobalSigningRegion != "" && claimed == traits.GlobalSigningRegion {
|
||||
return traits.GlobalSigningRegion
|
||||
}
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
package gateway
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"errors"
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
//
|
||||
// This is the only enforcement point for account status on the AWS surface:
|
||||
// until it existed, suspending an account blocked ECR and nothing else.
|
||||
func (gw *GatewayConfig) checkAccountActive(accountID string, src authSource) string {
|
||||
func (gw *Config) checkAccountActive(accountID string, src authSource) string {
|
||||
if gw.Admission == nil {
|
||||
return awserrors.ErrorInternalError
|
||||
}
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
package gateway
|
||||
package pipeline
|
||||
|
||||
//test:in-package — the status gate is an unexported step of the auth
|
||||
// middleware, and its cache is deliberately not part of the package's API.
|
||||
@@ -19,7 +19,7 @@ import (
|
||||
func statusGateRouter(t *testing.T, svc iamdomain.IAMService) (chi.Router, *http.Request) {
|
||||
t.Helper()
|
||||
|
||||
gw := withEC2(withRealAuth(gwfix.WithDeps(&GatewayConfig{DisableLogging: true, Region: gwfix.Region}, gwfix.Deps{IAM: svc})))
|
||||
gw := withEC2(withRealAuth(gwfix.WithDeps(&Config{DisableLogging: true, Region: gwfix.Region}, gwfix.Deps{IAM: svc})))
|
||||
r := chi.NewRouter()
|
||||
r.Use(gw.SigV4AuthMiddleware())
|
||||
r.HandleFunc("/*", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) })
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
package gateway
|
||||
package pipeline
|
||||
|
||||
//test:in-package — this characterization drives unexported gateway wiring.
|
||||
|
||||
@@ -125,7 +125,7 @@ func newCBEnv(t *testing.T, opts cbEnvOpts) *cbEnv {
|
||||
|
||||
// gateway builds a router over the env's services with its own (cold) caches.
|
||||
func (e *cbEnv) gateway() http.Handler {
|
||||
gw := gwfix.WithDeps(&GatewayConfig{
|
||||
gw := gwfix.WithDeps(&Config{
|
||||
DisableLogging: true,
|
||||
Region: gwfix.Region,
|
||||
}, gwfix.Deps{NATS: e.nc, Nodes: 1, IAM: e.counter, STS: e.sts})
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
package gateway
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -99,7 +99,7 @@ func TestSigV4Auth_FailureLogsLoopbackGatedClientIP(t *testing.T) {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rl := authlimit.NewAuthRateLimiter()
|
||||
defer rl.Stop()
|
||||
gw := gwfix.WithDeps(&GatewayConfig{
|
||||
gw := gwfix.WithDeps(&Config{
|
||||
DisableLogging: true,
|
||||
Region: gwfix.Region,
|
||||
Credentials: credverify.New(&gwfix.IAM{MasterKey: gwfix.MasterKey}, &gwfix.STS{}),
|
||||
@@ -1,4 +1,4 @@
|
||||
package gateway
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -58,7 +58,7 @@ func setupTestApp(accessKey, secretKey string) http.Handler {
|
||||
},
|
||||
}
|
||||
|
||||
gw := withRealAuth(gwfix.WithDeps(&GatewayConfig{
|
||||
gw := withRealAuth(gwfix.WithDeps(&Config{
|
||||
DisableLogging: true,
|
||||
Region: gwfix.Region,
|
||||
}, gwfix.Deps{IAM: mockSvc}))
|
||||
@@ -90,7 +90,7 @@ func userIDApp(t *testing.T, svc *gwfix.IAM) (http.Handler, *string) {
|
||||
},
|
||||
}
|
||||
|
||||
gw := withEC2(withRealAuth(gwfix.WithDeps(&GatewayConfig{DisableLogging: true, Region: gwfix.Region}, gwfix.Deps{IAM: svc})))
|
||||
gw := withEC2(withRealAuth(gwfix.WithDeps(&Config{DisableLogging: true, Region: gwfix.Region}, gwfix.Deps{IAM: svc})))
|
||||
seen := new(string)
|
||||
r := chi.NewRouter()
|
||||
r.Use(gw.SigV4AuthMiddleware())
|
||||
@@ -185,7 +185,7 @@ func TestSigV4Auth_ValidSignatureWithQueryString(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestSigV4Auth_NilIAMService(t *testing.T) {
|
||||
gw := withEC2(withRealAuth(gwfix.WithDeps(&GatewayConfig{
|
||||
gw := withEC2(withRealAuth(gwfix.WithDeps(&Config{
|
||||
DisableLogging: true,
|
||||
Region: gwfix.Region,
|
||||
}, gwfix.Deps{IAM: nil})))
|
||||
@@ -416,7 +416,7 @@ func TestSigV4Auth_ContextPropagation(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
gw := withEC2RealAuth(gwfix.WithDeps(&GatewayConfig{
|
||||
gw := withEC2RealAuth(gwfix.WithDeps(&Config{
|
||||
DisableLogging: true,
|
||||
Region: gwfix.Region,
|
||||
}, gwfix.Deps{IAM: mockSvc}))
|
||||
@@ -497,7 +497,7 @@ func TestSigV4Auth_ContextDoesNotLeakBetweenRequests(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
gw := withEC2RealAuth(gwfix.WithDeps(&GatewayConfig{
|
||||
gw := withEC2RealAuth(gwfix.WithDeps(&Config{
|
||||
DisableLogging: true,
|
||||
Region: gwfix.Region,
|
||||
}, gwfix.Deps{IAM: mockSvc}))
|
||||
@@ -536,7 +536,7 @@ func TestSigV4Auth_ContextDoesNotLeakBetweenRequests(t *testing.T) {
|
||||
|
||||
func TestSigV4Auth_LookupAccessKeyUnexpectedError(t *testing.T) {
|
||||
// A non-NoSuchEntity error from LookupAccessKey should yield 500 InternalError.
|
||||
gw := withEC2RealAuth(gwfix.WithDeps(&GatewayConfig{
|
||||
gw := withEC2RealAuth(gwfix.WithDeps(&Config{
|
||||
DisableLogging: true,
|
||||
Region: gwfix.Region,
|
||||
}, gwfix.Deps{IAM: &gwfix.ErrorLookupIAM{Err: errors.New("nats: connection closed")}}))
|
||||
@@ -612,7 +612,7 @@ func TestSigV4Auth_SignedContentType(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
gw := withEC2RealAuth(gwfix.WithDeps(&GatewayConfig{
|
||||
gw := withEC2RealAuth(gwfix.WithDeps(&Config{
|
||||
DisableLogging: true,
|
||||
Region: gwfix.Region,
|
||||
}, gwfix.Deps{IAM: mockSvc}))
|
||||
@@ -701,7 +701,7 @@ func TestSigV4Auth_QueryStringEdgeCases(t *testing.T) {
|
||||
// --- Policy Enforcement Tests (checkPolicy) ---
|
||||
|
||||
// setupPolicyTestHandler creates an http.Handler that authenticates with SigV4 then calls checkPolicy.
|
||||
func setupPolicyTestHandler(gw *GatewayConfig) http.Handler {
|
||||
func setupPolicyTestHandler(gw *Config) http.Handler {
|
||||
r := chi.NewRouter()
|
||||
r.Use(gw.SigV4AuthMiddleware())
|
||||
r.HandleFunc("/*", func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -738,7 +738,7 @@ func TestCheckPolicy_NonRootNoPolicies_Denied(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
gw := withEC2RealAuth(gwfix.WithDeps(&GatewayConfig{
|
||||
gw := withEC2RealAuth(gwfix.WithDeps(&Config{
|
||||
DisableLogging: true,
|
||||
Region: gwfix.Region,
|
||||
}, gwfix.Deps{IAM: mockSvc}))
|
||||
@@ -797,7 +797,7 @@ func TestCheckPolicy_NonRootWithAllow_Passes(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
gw := withEC2RealAuth(gwfix.WithDeps(&GatewayConfig{
|
||||
gw := withEC2RealAuth(gwfix.WithDeps(&Config{
|
||||
DisableLogging: true,
|
||||
Region: gwfix.Region,
|
||||
}, gwfix.Deps{IAM: mockSvc}))
|
||||
@@ -856,7 +856,7 @@ func TestCheckPolicy_NonRootWithExplicitDeny_Denied(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
gw := withEC2RealAuth(gwfix.WithDeps(&GatewayConfig{
|
||||
gw := withEC2RealAuth(gwfix.WithDeps(&Config{
|
||||
DisableLogging: true,
|
||||
Region: gwfix.Region,
|
||||
}, gwfix.Deps{IAM: mockSvc}))
|
||||
@@ -916,7 +916,7 @@ func TestCheckPolicy_RootGlobalAccount_Bypasses(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
gw := withEC2RealAuth(gwfix.WithDeps(&GatewayConfig{
|
||||
gw := withEC2RealAuth(gwfix.WithDeps(&Config{
|
||||
DisableLogging: true,
|
||||
Region: gwfix.Region,
|
||||
}, gwfix.Deps{IAM: mockSvc}))
|
||||
@@ -957,7 +957,7 @@ func TestCheckPolicy_MissingAccountID_InternalError(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
gw := withEC2RealAuth(gwfix.WithDeps(&GatewayConfig{
|
||||
gw := withEC2RealAuth(gwfix.WithDeps(&Config{
|
||||
DisableLogging: true,
|
||||
Region: gwfix.Region,
|
||||
}, gwfix.Deps{IAM: mockSvc}))
|
||||
@@ -1000,7 +1000,7 @@ func TestCheckPolicy_GetUserPoliciesError_InternalError(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
gw := withEC2RealAuth(gwfix.WithDeps(&GatewayConfig{
|
||||
gw := withEC2RealAuth(gwfix.WithDeps(&Config{
|
||||
DisableLogging: true,
|
||||
Region: gwfix.Region,
|
||||
}, gwfix.Deps{IAM: mockSvc}))
|
||||
@@ -1029,7 +1029,7 @@ func TestSigV4Auth_NATSDisconnectedShortCircuit(t *testing.T) {
|
||||
}
|
||||
nc.Close() // disconnected — IsConnected() returns false
|
||||
|
||||
gw := withEC2RealAuth(gwfix.WithDeps(&GatewayConfig{
|
||||
gw := withEC2RealAuth(gwfix.WithDeps(&Config{
|
||||
DisableLogging: true,
|
||||
Region: gwfix.Region,
|
||||
}, gwfix.Deps{NATS: nc, IAM: nil}))
|
||||
@@ -1113,7 +1113,7 @@ func mountSessionGateway(t *testing.T, cred *stsdomain.SessionCredential) (http.
|
||||
},
|
||||
}
|
||||
|
||||
gw := withEC2RealAuth(gwfix.WithDeps(&GatewayConfig{
|
||||
gw := withEC2RealAuth(gwfix.WithDeps(&Config{
|
||||
DisableLogging: true,
|
||||
Region: gwfix.Region,
|
||||
}, gwfix.Deps{IAM: iamMock, STS: stsMock}))
|
||||
@@ -1279,7 +1279,7 @@ func TestSigV4Auth_AKIA_PrincipalTypeUser(t *testing.T) {
|
||||
},
|
||||
},
|
||||
}
|
||||
gw := withEC2RealAuth(gwfix.WithDeps(&GatewayConfig{DisableLogging: true, Region: gwfix.Region}, gwfix.Deps{IAM: iamMock}))
|
||||
gw := withEC2RealAuth(gwfix.WithDeps(&Config{DisableLogging: true, Region: gwfix.Region}, gwfix.Deps{IAM: iamMock}))
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Use(gw.SigV4AuthMiddleware())
|
||||
@@ -1309,7 +1309,7 @@ func TestSigV4Auth_Session_STSServiceNil(t *testing.T) {
|
||||
AccessKeys: map[string]*iamdomain.AccessKey{},
|
||||
}
|
||||
_ = encryptedSecret
|
||||
gw := withEC2RealAuth(gwfix.WithDeps(&GatewayConfig{
|
||||
gw := withEC2RealAuth(gwfix.WithDeps(&Config{
|
||||
DisableLogging: true,
|
||||
Region: gwfix.Region,
|
||||
}, gwfix.Deps{IAM: iamMock, STS: nil}))
|
||||
@@ -1381,7 +1381,7 @@ func TestCheckPolicy_SessionNameCollision(t *testing.T) {
|
||||
Tokens: map[string]string{gwfix.SessionAKID: gwfix.SessionToken},
|
||||
}
|
||||
|
||||
gw := withEC2RealAuth(gwfix.WithDeps(&GatewayConfig{
|
||||
gw := withEC2RealAuth(gwfix.WithDeps(&Config{
|
||||
DisableLogging: true,
|
||||
Region: gwfix.Region,
|
||||
}, gwfix.Deps{IAM: iamMock, STS: stsMock}))
|
||||
@@ -1405,7 +1405,7 @@ func TestCheckPolicy_SessionNameCollision(t *testing.T) {
|
||||
// The user resolver fails the test if called — an assumed-role must never be evaluated as a user.
|
||||
func newAssumedRoleEnforcementGateway(t *testing.T, cred *stsdomain.SessionCredential,
|
||||
getRoleFn func(accountID, roleName string) ([]iamdomain.PolicyDocument, error),
|
||||
) *GatewayConfig {
|
||||
) *Config {
|
||||
t.Helper()
|
||||
encryptedSecret, err := iamdomain.EncryptSecret(gwfix.SecretKey, gwfix.MasterKey)
|
||||
require.NoError(t, err)
|
||||
@@ -1423,7 +1423,7 @@ func newAssumedRoleEnforcementGateway(t *testing.T, cred *stsdomain.SessionCrede
|
||||
Sessions: map[string]*stsdomain.SessionCredential{cred.AccessKeyID: cred},
|
||||
Tokens: map[string]string{cred.AccessKeyID: gwfix.SessionToken},
|
||||
}
|
||||
return withEC2RealAuth(gwfix.WithDeps(&GatewayConfig{
|
||||
return withEC2RealAuth(gwfix.WithDeps(&Config{
|
||||
DisableLogging: true,
|
||||
Region: gwfix.Region,
|
||||
}, gwfix.Deps{IAM: iamMock, STS: stsMock}))
|
||||
@@ -1431,7 +1431,7 @@ func newAssumedRoleEnforcementGateway(t *testing.T, cred *stsdomain.SessionCrede
|
||||
|
||||
// setupPolicyResourceTestHandler is setupPolicyTestHandler for resource-scoped
|
||||
// checks (e.g. iam:PassRole on a specific role ARN).
|
||||
func setupPolicyResourceTestHandler(gw *GatewayConfig, service, action, resource string) http.Handler {
|
||||
func setupPolicyResourceTestHandler(gw *Config, service, action, resource string) http.Handler {
|
||||
r := chi.NewRouter()
|
||||
r.Use(gw.SigV4AuthMiddleware())
|
||||
r.HandleFunc("/*", func(w http.ResponseWriter, req *http.Request) {
|
||||
@@ -1652,7 +1652,7 @@ func TestMismatchAction_RecoversActionAndRewindsBody(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
gw := withServices(&GatewayConfig{})
|
||||
gw := withServices(&Config{})
|
||||
assert.Equal(t, "ImportKeyPair", gw.mismatchAction(req, "ec2"))
|
||||
|
||||
// The error path still writes a response, so the body must survive the peek.
|
||||
@@ -1664,7 +1664,7 @@ func TestMismatchAction_RecoversActionAndRewindsBody(t *testing.T) {
|
||||
func TestMismatchAction_SkipsS3AndPrefersQueryString(t *testing.T) {
|
||||
// An S3 body is object data, so it must never be parsed as query args.
|
||||
s3req := httptest.NewRequest(http.MethodPut, "/bucket/key", bytes.NewReader([]byte("Action=NotReally")))
|
||||
gw := withServices(&GatewayConfig{})
|
||||
gw := withServices(&Config{})
|
||||
assert.Empty(t, gw.mismatchAction(s3req, "s3"))
|
||||
|
||||
qreq := httptest.NewRequest(http.MethodGet, "/?Action=DescribeInstances", nil)
|
||||
@@ -1810,7 +1810,7 @@ func TestSigV4Auth_ResolvesCtxActionForNonQueryServices(t *testing.T) {
|
||||
},
|
||||
},
|
||||
}
|
||||
gw := withEC2RealAuth(gwfix.WithDeps(&GatewayConfig{DisableLogging: true, Region: gwfix.Region}, gwfix.Deps{IAM: mockSvc}))
|
||||
gw := withEC2RealAuth(gwfix.WithDeps(&Config{DisableLogging: true, Region: gwfix.Region}, gwfix.Deps{IAM: mockSvc}))
|
||||
b := dispatch.NewBuilder()
|
||||
require.NoError(t, b.Register(awsapi.NewRegistration(awsapi.Deps{})))
|
||||
require.NoError(t, b.Register(acmawsapi.NewRegistration(acmawsapi.Deps{})))
|
||||
@@ -1869,7 +1869,7 @@ func TestSigV4Auth_ResolvesCtxActionForNonQueryServices(t *testing.T) {
|
||||
// default endpoint and for the configured region against an override. Every
|
||||
// other service stays pinned to the gateway's region.
|
||||
func TestExpectedSigningRegion(t *testing.T) {
|
||||
gw := withServices(&GatewayConfig{Region: "ap-southeast-2"}, route53awsapi.NewRegistration(route53awsapi.Deps{}))
|
||||
gw := withServices(&Config{Region: "ap-southeast-2"}, route53awsapi.NewRegistration(route53awsapi.Deps{}))
|
||||
for _, tc := range []struct {
|
||||
service, claimed, want string
|
||||
}{
|
||||
@@ -1904,7 +1904,7 @@ func TestSigV4Auth_SessionPrincipalVerdict_RateLimitRecording(t *testing.T) {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rl := authlimit.NewAuthRateLimiter()
|
||||
defer rl.Stop()
|
||||
gw := &GatewayConfig{DisableLogging: true, RateLimiter: rl}
|
||||
gw := &Config{DisableLogging: true, RateLimiter: rl}
|
||||
|
||||
// The real verifier decides whether a verdict counts; the gateway records it.
|
||||
encrypted, err := iamdomain.EncryptSecret(gwfix.SecretKey, gwfix.MasterKey)
|
||||
@@ -1951,7 +1951,7 @@ func TestSigV4Auth_SessionPrincipalVerdict_RateLimitRecording(t *testing.T) {
|
||||
// envelope by exactly the path it did before the fallback was added — a
|
||||
// regression guard proving the fallback widened nothing for served scopes.
|
||||
func TestWriteSigV4Error_JSONErrorServicesUnaffectedByFallback(t *testing.T) {
|
||||
gw := &GatewayConfig{DisableLogging: true}
|
||||
gw := &Config{DisableLogging: true}
|
||||
b := dispatch.NewBuilder()
|
||||
require.NoError(t, b.Register(awsapi.NewRegistration(awsapi.Deps{})))
|
||||
require.NoError(t, b.Register(acmawsapi.NewRegistration(acmawsapi.Deps{})))
|
||||
@@ -2003,7 +2003,7 @@ func TestCheckPolicy_RootNonGlobalAccount_StillEvaluated(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
gw := withEC2RealAuth(gwfix.WithDeps(&GatewayConfig{
|
||||
gw := withEC2RealAuth(gwfix.WithDeps(&Config{
|
||||
DisableLogging: true,
|
||||
Region: gwfix.Region,
|
||||
}, gwfix.Deps{IAM: mockSvc}))
|
||||
@@ -2148,7 +2148,7 @@ func TestSigV4Auth_UnknownAKIDPrefix_NoLookup(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestWriteSigV4Error_ResponseFormat(t *testing.T) {
|
||||
gw := withEC2RealAuth(gwfix.WithDeps(&GatewayConfig{DisableLogging: true, Region: gwfix.Region}, gwfix.Deps{IAM: allowAllIAMService()}))
|
||||
gw := withEC2RealAuth(gwfix.WithDeps(&Config{DisableLogging: true, Region: gwfix.Region}, gwfix.Deps{IAM: allowAllIAMService()}))
|
||||
|
||||
testCases := []struct {
|
||||
errorCode string
|
||||
@@ -2199,7 +2199,7 @@ func TestWriteSigV4Error_ResponseFormat(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestWriteSigV4Error_IgnoresClientRequestID(t *testing.T) {
|
||||
gw := withEC2RealAuth(gwfix.WithDeps(&GatewayConfig{DisableLogging: true, Region: gwfix.Region}, gwfix.Deps{IAM: allowAllIAMService()}))
|
||||
gw := withEC2RealAuth(gwfix.WithDeps(&Config{DisableLogging: true, Region: gwfix.Region}, gwfix.Deps{IAM: allowAllIAMService()}))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
+10
-10
@@ -1,7 +1,7 @@
|
||||
package gateway
|
||||
package pipeline
|
||||
|
||||
//test:in-package — the gate steps under test are unexported and take their
|
||||
// capabilities through the pipeline.PolicyEvaluator and pipeline.AccountAdmission interfaces.
|
||||
// capabilities through the PolicyEvaluator and AccountAdmission interfaces.
|
||||
|
||||
import (
|
||||
"errors"
|
||||
@@ -37,7 +37,7 @@ func (f fakeAdmission) Admit(string, string) error { return f.err }
|
||||
|
||||
func TestCheckPolicy_UsesTheSuppliedEvaluator(t *testing.T) {
|
||||
policy := &fakePolicy{}
|
||||
gw := &GatewayConfig{Policy: policy}
|
||||
gw := &Config{Policy: policy}
|
||||
req := withTestIdentity(httptest.NewRequest(http.MethodPost, "/", nil))
|
||||
req = req.WithContext(withPrincipalField(req.Context(), fieldAccountID, "123456789012"))
|
||||
|
||||
@@ -51,7 +51,7 @@ func TestCheckPolicy_UsesTheSuppliedEvaluator(t *testing.T) {
|
||||
|
||||
func TestCheckPolicy_ReturnsTheEvaluatorVerdict(t *testing.T) {
|
||||
verdict := errors.New(awserrors.ErrorAccessDenied)
|
||||
gw := &GatewayConfig{Policy: &fakePolicy{err: verdict}}
|
||||
gw := &Config{Policy: &fakePolicy{err: verdict}}
|
||||
req := withTestIdentity(httptest.NewRequest(http.MethodPost, "/", nil))
|
||||
req = req.WithContext(withPrincipalField(req.Context(), fieldAccountID, "123456789012"))
|
||||
|
||||
@@ -59,7 +59,7 @@ func TestCheckPolicy_ReturnsTheEvaluatorVerdict(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCheckPolicy_NoEvaluatorFailsClosed(t *testing.T) {
|
||||
gw := &GatewayConfig{}
|
||||
gw := &Config{}
|
||||
req := withTestIdentity(httptest.NewRequest(http.MethodPost, "/", nil))
|
||||
|
||||
err := gw.checkPolicyResources(req, "ec2", "DescribeVpcs", []string{"*"})
|
||||
@@ -71,13 +71,13 @@ func TestCheckAccountActive_MapsAdmissionVerdicts(t *testing.T) {
|
||||
src := authSource{}
|
||||
cases := []struct {
|
||||
name string
|
||||
gw *GatewayConfig
|
||||
gw *Config
|
||||
want string
|
||||
}{
|
||||
{"active proceeds", &GatewayConfig{Admission: fakeAdmission{}}, ""},
|
||||
{"not active is denied", &GatewayConfig{Admission: fakeAdmission{err: credential.ErrAccountNotActive}}, awserrors.ErrorAccessDenied},
|
||||
{"an outage is an internal error", &GatewayConfig{Admission: fakeAdmission{err: errors.New("iam down")}}, awserrors.ErrorInternalError},
|
||||
{"no admission fails closed", &GatewayConfig{}, awserrors.ErrorInternalError},
|
||||
{"active proceeds", &Config{Admission: fakeAdmission{}}, ""},
|
||||
{"not active is denied", &Config{Admission: fakeAdmission{err: credential.ErrAccountNotActive}}, awserrors.ErrorAccessDenied},
|
||||
{"an outage is an internal error", &Config{Admission: fakeAdmission{err: errors.New("iam down")}}, awserrors.ErrorInternalError},
|
||||
{"no admission fails closed", &Config{}, awserrors.ErrorInternalError},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
+4
-4
@@ -1,4 +1,4 @@
|
||||
package gateway
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"github.com/mulgadc/spinifex/internal/testkit/gwfix"
|
||||
@@ -29,14 +29,14 @@ type allowAdmission struct{}
|
||||
|
||||
func (allowAdmission) Admit(string, string) error { return nil }
|
||||
|
||||
type natsReadiness struct{ gw *GatewayConfig }
|
||||
type natsReadiness struct{ gw *Config }
|
||||
|
||||
func (n natsReadiness) Available() bool {
|
||||
return gwfix.DepsOf(n.gw).NATS != nil && gwfix.DepsOf(n.gw).NATS.IsConnected()
|
||||
}
|
||||
|
||||
// withFakeCapabilities fills whichever capability a test left unset.
|
||||
func withFakeCapabilities(gw *GatewayConfig) *GatewayConfig {
|
||||
func withFakeCapabilities(gw *Config) *Config {
|
||||
withTestAdmin(gw)
|
||||
if gw.Policy == nil {
|
||||
gw.Policy = allowPolicy{}
|
||||
@@ -52,7 +52,7 @@ func withFakeCapabilities(gw *GatewayConfig) *GatewayConfig {
|
||||
|
||||
// withTestAdmin points the /admin surface at the spinifex registration, as the
|
||||
// composing role does.
|
||||
func withTestAdmin(gw *GatewayConfig) {
|
||||
func withTestAdmin(gw *Config) {
|
||||
if gw.Admin.Service == "" {
|
||||
gw.Admin = AdminSurface{Service: operatoradmin.ServiceName, WithRequestID: operatoradmin.WithRequestID}
|
||||
}
|
||||
+2
-2
@@ -2,7 +2,7 @@
|
||||
// constants are unexported, and the gate exists to compare what they emit with
|
||||
// the evaluator's registries.
|
||||
|
||||
package gateway
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
@@ -122,7 +122,7 @@ func emittedKeys(t *testing.T) map[string]string {
|
||||
},
|
||||
}
|
||||
|
||||
gw := withVerifier(gwfix.WithDeps(&GatewayConfig{DisableLogging: true}, gwfix.Deps{IAM: &gwfix.IAM{}}))
|
||||
gw := withVerifier(gwfix.WithDeps(&Config{DisableLogging: true}, gwfix.Deps{IAM: &gwfix.IAM{}}))
|
||||
union := make(map[string]string)
|
||||
for name, principal := range principals {
|
||||
r := httptest.NewRequest(http.MethodPost, "/?prefix=home/&max-keys=100", nil)
|
||||
+3
-3
@@ -2,7 +2,7 @@
|
||||
//and ctxAccountID are all unexported, and the file reuses the in-package
|
||||
//gwfix.PolicyIAM and withTestIdentity doubles.
|
||||
|
||||
package gateway
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
@@ -194,7 +194,7 @@ func conditionedPolicyIAMService(effect string, cond map[string]map[string]iamdo
|
||||
// nil keys from checkPolicyResources would leave every other test green while
|
||||
// silently killing every conditional Allow on this path.
|
||||
func TestCheckPolicyResources_ConditionalAllowHonoursSourceIP(t *testing.T) {
|
||||
gw := withRealAuth(gwfix.WithDeps(&GatewayConfig{
|
||||
gw := withRealAuth(gwfix.WithDeps(&Config{
|
||||
DisableLogging: true,
|
||||
}, gwfix.Deps{IAM: conditionedPolicyIAMService("Allow", map[string]map[string]iamdomain.ConditionValue{
|
||||
iampolicy.OpIPAddress: {iampolicy.KeySourceIP: {"10.0.0.0/8"}},
|
||||
@@ -232,7 +232,7 @@ func TestCheckPolicyResources_ConditionalAllowHonoursSourceIP(t *testing.T) {
|
||||
// the one direction where an absent key widens access. Pin it so the omission
|
||||
// is a decision rather than an accident.
|
||||
func TestCheckPolicyResources_ConditionalDenyHonoursSourceIP(t *testing.T) {
|
||||
gw := withRealAuth(gwfix.WithDeps(&GatewayConfig{
|
||||
gw := withRealAuth(gwfix.WithDeps(&Config{
|
||||
DisableLogging: true,
|
||||
}, gwfix.Deps{IAM: conditionedPolicyIAMService("Deny", map[string]map[string]iamdomain.ConditionValue{
|
||||
iampolicy.OpIPAddress: {iampolicy.KeySourceIP: {"203.0.113.0/24"}},
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
// Tests that a dispatch failure is logged at the level the status the caller
|
||||
// receives implies, so a client's own 4xx does not read as a fault of ours.
|
||||
|
||||
package gateway
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
+7
-7
@@ -2,7 +2,7 @@
|
||||
// with fake services registered only here, independent of any production
|
||||
// registration.
|
||||
|
||||
package gateway
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -137,7 +137,7 @@ 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 []iamdomain.PolicyDocument, regs ...dispatch.Registration) (*Config, http.Handler) {
|
||||
t.Helper()
|
||||
encryptedSecret, err := iamdomain.EncryptSecret(gwfix.SecretKey, gwfix.MasterKey)
|
||||
require.NoError(t, err)
|
||||
@@ -148,7 +148,7 @@ func dispatchSeamApp(t *testing.T, docs []iamdomain.PolicyDocument, regs ...disp
|
||||
require.NoError(t, b.Register(reg))
|
||||
}
|
||||
|
||||
gw := withRealAuth(gwfix.WithDeps(&GatewayConfig{
|
||||
gw := withRealAuth(gwfix.WithDeps(&Config{
|
||||
DisableLogging: true,
|
||||
Region: gwfix.Region,
|
||||
Services: b.Build(),
|
||||
@@ -340,7 +340,7 @@ func TestDispatchSeam_NATSDownGetsSame503AsLegacy(t *testing.T) {
|
||||
b := dispatch.NewBuilder()
|
||||
require.NoError(t, b.Register(fakeJSONRegistration(&dispatchCapture{})))
|
||||
|
||||
gw := withRealAuth(gwfix.WithDeps(&GatewayConfig{DisableLogging: true, Services: b.Build()}, gwfix.Deps{IAM: allowAllIAMService()}))
|
||||
gw := withRealAuth(gwfix.WithDeps(&Config{DisableLogging: true, Services: b.Build()}, gwfix.Deps{IAM: allowAllIAMService()}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/", nil)
|
||||
ctx := reqctx.WithService(req.Context(), fakeJSONService)
|
||||
@@ -367,7 +367,7 @@ func TestDispatchSeam_UnregisteredUnknownServiceStillInvalidAction(t *testing.T)
|
||||
|
||||
encryptedSecret, err := iamdomain.EncryptSecret(gwfix.SecretKey, gwfix.MasterKey)
|
||||
require.NoError(t, err)
|
||||
gw := withRealAuth(gwfix.WithDeps(&GatewayConfig{
|
||||
gw := withRealAuth(gwfix.WithDeps(&Config{
|
||||
DisableLogging: true,
|
||||
Region: gwfix.Region,
|
||||
Services: b.Build(),
|
||||
@@ -406,12 +406,12 @@ func TestDispatchSeam_UnregisteredUnknownServiceStillInvalidAction(t *testing.T)
|
||||
|
||||
func TestValidateServices(t *testing.T) {
|
||||
t.Run("nil registry passes", func(t *testing.T) {
|
||||
gw := &GatewayConfig{}
|
||||
gw := &Config{}
|
||||
assert.NoError(t, gw.ValidateServices())
|
||||
})
|
||||
|
||||
t.Run("empty registry passes", func(t *testing.T) {
|
||||
gw := &GatewayConfig{Services: dispatch.NewBuilder().Build()}
|
||||
gw := &Config{Services: dispatch.NewBuilder().Build()}
|
||||
assert.NoError(t, gw.ValidateServices())
|
||||
})
|
||||
}
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
package gateway
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -49,7 +49,7 @@ func TestDeleteVolumeAction_MissingConfiguredNodeIsRetryableAndDeletesNothing(t
|
||||
}
|
||||
require.NoError(t, nc.Flush())
|
||||
|
||||
gw := gwfix.WithDeps(&GatewayConfig{
|
||||
gw := gwfix.WithDeps(&Config{
|
||||
DisableLogging: true,
|
||||
Credentials: credverify.New(allowAllIAMService(), nil),
|
||||
}, gwfix.Deps{NATS: nc, IAM: allowAllIAMService()})
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
package gateway
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"github.com/mulgadc/spinifex/internal/testkit/gwfix"
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
// The gateway has no NATS connection, so any request that reached its handler
|
||||
// would fail ServerInternal; DryRunOperation proves nothing was dispatched.
|
||||
func TestEC2Request_DryRunAnswersEveryAction(t *testing.T) {
|
||||
gw := withVerifier(gwfix.WithDeps(&GatewayConfig{DisableLogging: true, Region: authzRegion}, gwfix.Deps{IAM: allowAllIAMService()}))
|
||||
gw := withVerifier(gwfix.WithDeps(&Config{DisableLogging: true, Region: authzRegion}, gwfix.Deps{IAM: allowAllIAMService()}))
|
||||
|
||||
actions := ec2awsapi.ActionNames()
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package gateway
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"github.com/mulgadc/bluebottle/pkg/ratelimit"
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package gateway
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
+7
-7
@@ -1,4 +1,4 @@
|
||||
package gateway
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"github.com/mulgadc/spinifex/internal/testkit/gwfix"
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
// Captured from AWS: the combination is refused even when MaxResults is also
|
||||
// out of range.
|
||||
func TestEC2Request_DescribeInstancesMaxResultsWithInstanceIds(t *testing.T) {
|
||||
gw := withVerifier(gwfix.WithDeps(&GatewayConfig{DisableLogging: true, Region: authzRegion}, gwfix.Deps{IAM: allowAllIAMService()}))
|
||||
gw := withVerifier(gwfix.WithDeps(&Config{DisableLogging: true, Region: authzRegion}, gwfix.Deps{IAM: allowAllIAMService()}))
|
||||
|
||||
for _, n := range []string{"2", "5"} {
|
||||
w := serveEC2(gw, "Action=DescribeInstances&InstanceId.1=i-0123456789abcdef0&MaxResults="+n)
|
||||
@@ -23,12 +23,12 @@ func TestEC2Request_DescribeInstancesMaxResultsWithInstanceIds(t *testing.T) {
|
||||
|
||||
// AWS answers DryRun ahead of a MaxResults out of range.
|
||||
func TestEC2Request_MaxResultsAfterDryRun(t *testing.T) {
|
||||
gw := withVerifier(gwfix.WithDeps(&GatewayConfig{DisableLogging: true, Region: authzRegion}, gwfix.Deps{IAM: allowAllIAMService()}))
|
||||
gw := withVerifier(gwfix.WithDeps(&Config{DisableLogging: true, Region: authzRegion}, gwfix.Deps{IAM: allowAllIAMService()}))
|
||||
assertDryRun(t, dispatchEC2(t, gw, "Action=DescribeLaunchTemplates&MaxResults=0&DryRun=true"))
|
||||
}
|
||||
|
||||
func TestEC2Request_MaxResultsInRange(t *testing.T) {
|
||||
gw := withVerifier(gwfix.WithDeps(&GatewayConfig{DisableLogging: true, Region: authzRegion}, gwfix.Deps{IAM: allowAllIAMService()}))
|
||||
gw := withVerifier(gwfix.WithDeps(&Config{DisableLogging: true, Region: authzRegion}, gwfix.Deps{IAM: allowAllIAMService()}))
|
||||
|
||||
for _, body := range []string{
|
||||
"Action=DescribeCapacityReservations&MaxResults=1",
|
||||
@@ -49,7 +49,7 @@ func TestEC2Request_MaxResultsInRange(t *testing.T) {
|
||||
|
||||
// The refusals are AWS's own, captured by scripts/aws-compliance/aws-response-diff/scen_e.py.
|
||||
func TestEC2Request_MaxResultsOutOfRange(t *testing.T) {
|
||||
gw := withVerifier(gwfix.WithDeps(&GatewayConfig{DisableLogging: true, Region: authzRegion}, gwfix.Deps{IAM: allowAllIAMService()}))
|
||||
gw := withVerifier(gwfix.WithDeps(&Config{DisableLogging: true, Region: authzRegion}, gwfix.Deps{IAM: allowAllIAMService()}))
|
||||
|
||||
cases := []struct {
|
||||
action string
|
||||
@@ -86,7 +86,7 @@ func TestEC2Request_MaxResultsOutOfRange(t *testing.T) {
|
||||
// Each of an action's own types reaches the handler, which fails only on the
|
||||
// absent NATS connection.
|
||||
func TestEC2Request_TagSpecificationAllowedTypes(t *testing.T) {
|
||||
gw := withVerifier(gwfix.WithDeps(&GatewayConfig{DisableLogging: true, Region: authzRegion}, gwfix.Deps{IAM: allowAllIAMService()}))
|
||||
gw := withVerifier(gwfix.WithDeps(&Config{DisableLogging: true, Region: authzRegion}, gwfix.Deps{IAM: allowAllIAMService()}))
|
||||
|
||||
for _, rt := range []string{"instance", "volume", "network-interface", "spot-instances-request"} {
|
||||
assertPermitted(t, dispatchEC2(t, gw, tagSpecBody("RunInstances", rt)+"&ImageId=ami-1&MinCount=1&MaxCount=1"))
|
||||
@@ -95,7 +95,7 @@ func TestEC2Request_TagSpecificationAllowedTypes(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestEC2Request_TagSpecificationResourceType(t *testing.T) {
|
||||
gw := withVerifier(gwfix.WithDeps(&GatewayConfig{DisableLogging: true, Region: authzRegion}, gwfix.Deps{IAM: allowAllIAMService()}))
|
||||
gw := withVerifier(gwfix.WithDeps(&Config{DisableLogging: true, Region: authzRegion}, gwfix.Deps{IAM: allowAllIAMService()}))
|
||||
|
||||
cases := []struct {
|
||||
name, body, code, resourceType string
|
||||
+10
-9
@@ -1,4 +1,4 @@
|
||||
package gateway
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
awsapi "github.com/mulgadc/spinifex/spinifex/domains/ecr/awsapi"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/telemetry"
|
||||
"github.com/mulgadc/spinifex/spinifex/ingress/aws/dispatch"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.opentelemetry.io/otel"
|
||||
@@ -33,7 +34,7 @@ func TestECRActionFromTarget_FormsAndTelemetryAgree(t *testing.T) {
|
||||
{"bare action", "ListRepositories", "ListRepositories"},
|
||||
{"trailing dot", awsapi.TargetPrefix + ".", ""},
|
||||
}
|
||||
ecrEntry, ok := withECR(&GatewayConfig{}, awsapi.Deps{}).registered(awsapi.ServiceName)
|
||||
ecrEntry, ok := withECR(&Config{}, awsapi.Deps{}).registered(awsapi.ServiceName)
|
||||
require.True(t, ok)
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
@@ -51,7 +52,7 @@ func TestECRActionFromTarget_FormsAndTelemetryAgree(t *testing.T) {
|
||||
// that nothing intercepts ahead of the handler table, i.e. what the HTTP
|
||||
// pipeline actually answers 501 for today.
|
||||
func TestECRInventory_ReachableStubsAnswer501(t *testing.T) {
|
||||
reachableStubs := AWSOperationInventory(ecrRegistrationInventory())["ecr"].Stubbed
|
||||
reachableStubs := dispatch.AWSOperationInventory(ecrRegistrationInventory())["ecr"].Stubbed
|
||||
want := []string{
|
||||
"ListRepositories", "BatchCheckLayerAvailability", "GetDownloadUrlForLayer",
|
||||
"InitiateLayerUpload", "UploadLayerPart", "CompleteLayerUpload", "GetRegistryPolicy",
|
||||
@@ -59,7 +60,7 @@ func TestECRInventory_ReachableStubsAnswer501(t *testing.T) {
|
||||
}
|
||||
assert.ElementsMatch(t, want, reachableStubs)
|
||||
|
||||
gw := withVerifier(gwfix.WithDeps(&GatewayConfig{DisableLogging: true}, gwfix.Deps{IAM: allowAllIAMService()}))
|
||||
gw := withVerifier(gwfix.WithDeps(&Config{DisableLogging: true}, gwfix.Deps{IAM: allowAllIAMService()}))
|
||||
for _, action := range reachableStubs {
|
||||
t.Run(action, func(t *testing.T) {
|
||||
err := gw.serveECR(httptest.NewRecorder(),
|
||||
@@ -74,7 +75,7 @@ func TestECRInventory_ReachableStubsAnswer501(t *testing.T) {
|
||||
// unsupported scanning action answers OperationNotSupportedException (400),
|
||||
// not NotImplemented (501).
|
||||
func TestECRInventory_ScanningActionsReturnOperationNotSupported(t *testing.T) {
|
||||
gw := withVerifier(gwfix.WithDeps(&GatewayConfig{DisableLogging: true}, gwfix.Deps{IAM: allowAllIAMService()}))
|
||||
gw := withVerifier(gwfix.WithDeps(&Config{DisableLogging: true}, gwfix.Deps{IAM: allowAllIAMService()}))
|
||||
for _, action := range awsapi.UnsupportedActionNames() {
|
||||
t.Run(action, func(t *testing.T) {
|
||||
err := gw.serveECR(httptest.NewRecorder(),
|
||||
@@ -122,7 +123,7 @@ func TestECRPipeline_TraceActionEnricher_SpanNamedServiceAction(t *testing.T) {
|
||||
// carrying another service's prefix but naming an action outside awsapi.Actions
|
||||
// fails as an unrecognised ECR action, not as a cross-service error.
|
||||
func TestECRRequest_ECSPrefixedNonECRActionIsInvalidAction(t *testing.T) {
|
||||
gw := withVerifier(gwfix.WithDeps(&GatewayConfig{DisableLogging: true}, gwfix.Deps{IAM: allowAllIAMService()}))
|
||||
gw := withVerifier(gwfix.WithDeps(&Config{DisableLogging: true}, gwfix.Deps{IAM: allowAllIAMService()}))
|
||||
err := gw.serveECR(httptest.NewRecorder(),
|
||||
setupECRRequest("AmazonEC2ContainerServiceV20141113.ListClusters", "{}"))
|
||||
require.Error(t, err)
|
||||
@@ -132,7 +133,7 @@ func TestECRRequest_ECSPrefixedNonECRActionIsInvalidAction(t *testing.T) {
|
||||
// TestECRRequest_TrailingDotIsMissingAction pins that a target ending in "."
|
||||
// resolves to an empty action, the same as an absent target.
|
||||
func TestECRRequest_TrailingDotIsMissingAction(t *testing.T) {
|
||||
gw := withVerifier(gwfix.WithDeps(&GatewayConfig{DisableLogging: true}, gwfix.Deps{IAM: allowAllIAMService()}))
|
||||
gw := withVerifier(gwfix.WithDeps(&Config{DisableLogging: true}, gwfix.Deps{IAM: allowAllIAMService()}))
|
||||
err := gw.serveECR(httptest.NewRecorder(),
|
||||
setupECRRequest(awsapi.TargetPrefix+".", "{}"))
|
||||
require.Error(t, err)
|
||||
@@ -143,7 +144,7 @@ func TestECRRequest_TrailingDotIsMissingAction(t *testing.T) {
|
||||
// any "<prefix>.<Action>" shape (not just the ECR prefix) dispatches on the
|
||||
// bare action name today.
|
||||
func TestECRRequest_UnrelatedPrefixDispatchesByBareAction(t *testing.T) {
|
||||
gw := withVerifier(gwfix.WithDeps(&GatewayConfig{DisableLogging: true}, gwfix.Deps{IAM: allowAllIAMService()}))
|
||||
gw := withVerifier(gwfix.WithDeps(&Config{DisableLogging: true}, gwfix.Deps{IAM: allowAllIAMService()}))
|
||||
err := gw.serveECR(httptest.NewRecorder(), setupECRRequest("a.b.ListRepositories", "{}"))
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, awserrors.ErrorNotImplemented, err.Error())
|
||||
@@ -154,7 +155,7 @@ func TestECRRequest_UnrelatedPrefixDispatchesByBareAction(t *testing.T) {
|
||||
func TestGetAuthorizationToken_IgnoresRequestSuppliedIdentity(t *testing.T) {
|
||||
iss, verify := newECRAuth(t)
|
||||
endpoint := awsapi.RepositoryEndpoint{Region: ecrTestRegion, ServicesDomain: ecrTestSuffix}
|
||||
gw := withECR(withVerifier(gwfix.WithDeps(&GatewayConfig{
|
||||
gw := withECR(withVerifier(gwfix.WithDeps(&Config{
|
||||
Region: ecrTestRegion,
|
||||
DisableLogging: true,
|
||||
}, gwfix.Deps{IAM: allowAllIAMService()})), awsapi.Deps{AuthorizationToken: awsapi.NewAuthorizationTokenActionService(iss, endpoint)})
|
||||
+7
-7
@@ -1,4 +1,4 @@
|
||||
package gateway
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
@@ -17,7 +17,7 @@ import (
|
||||
|
||||
func TestECRRequest_GetAuthorizationTokenDispatched(t *testing.T) {
|
||||
iss, _ := newECRAuth(t)
|
||||
gw := withECR(withVerifier(gwfix.WithDeps(&GatewayConfig{
|
||||
gw := withECR(withVerifier(gwfix.WithDeps(&Config{
|
||||
Region: ecrTestRegion,
|
||||
DisableLogging: true,
|
||||
}, gwfix.Deps{IAM: allowAllIAMService()})), awsapi.Deps{AuthorizationToken: awsapi.NewAuthorizationTokenActionService(iss, awsapi.RepositoryEndpoint{
|
||||
@@ -32,7 +32,7 @@ func TestECRRequest_GetAuthorizationTokenDispatched(t *testing.T) {
|
||||
func TestGetAuthorizationToken_MintsUsableToken(t *testing.T) {
|
||||
iss, verify := newECRAuth(t)
|
||||
endpoint := awsapi.RepositoryEndpoint{Region: ecrTestRegion, ServicesDomain: ecrTestSuffix}
|
||||
gw := withECR(withVerifier(gwfix.WithDeps(&GatewayConfig{
|
||||
gw := withECR(withVerifier(gwfix.WithDeps(&Config{
|
||||
Region: ecrTestRegion,
|
||||
DisableLogging: true,
|
||||
}, gwfix.Deps{IAM: allowAllIAMService()})), awsapi.Deps{AuthorizationToken: awsapi.NewAuthorizationTokenActionService(iss, endpoint)})
|
||||
@@ -76,7 +76,7 @@ func TestGetAuthorizationToken_MintsUsableToken(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGetAuthorizationToken_MissingComposition(t *testing.T) {
|
||||
gw := withVerifier(gwfix.WithDeps(&GatewayConfig{DisableLogging: true}, gwfix.Deps{IAM: allowAllIAMService()}))
|
||||
gw := withVerifier(gwfix.WithDeps(&Config{DisableLogging: true}, gwfix.Deps{IAM: allowAllIAMService()}))
|
||||
req := setupECRRequest(awsapi.TargetPrefix+".GetAuthorizationToken", "{}")
|
||||
err := gw.serveECR(httptest.NewRecorder(), req)
|
||||
require.Error(t, err)
|
||||
@@ -84,7 +84,7 @@ func TestGetAuthorizationToken_MissingComposition(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGetAuthorizationToken_NoIssuerNotImplemented(t *testing.T) {
|
||||
gw := withECR(withVerifier(gwfix.WithDeps(&GatewayConfig{
|
||||
gw := withECR(withVerifier(gwfix.WithDeps(&Config{
|
||||
DisableLogging: true,
|
||||
}, gwfix.Deps{IAM: allowAllIAMService()})), awsapi.Deps{AuthorizationToken: awsapi.NewAuthorizationTokenActionService(nil, awsapi.RepositoryEndpoint{})})
|
||||
req := setupECRRequest(awsapi.TargetPrefix+".GetAuthorizationToken", "{}")
|
||||
@@ -104,7 +104,7 @@ func TestGetAuthorizationToken_PathedUserRoundTrips(t *testing.T) {
|
||||
iamSvc.userPolicies[ecrPrincipalTestAccount+"|dev"] = allow
|
||||
iss, verify := newECRAuth(t)
|
||||
endpoint := awsapi.RepositoryEndpoint{Region: ecrTestRegion, ServicesDomain: ecrTestSuffix}
|
||||
gw := withECR(withVerifier(gwfix.WithDeps(&GatewayConfig{
|
||||
gw := withECR(withVerifier(gwfix.WithDeps(&Config{
|
||||
Region: ecrTestRegion,
|
||||
DisableLogging: true,
|
||||
}, gwfix.Deps{IAM: iamSvc})), awsapi.Deps{AuthorizationToken: awsapi.NewAuthorizationTokenActionService(iss, endpoint)})
|
||||
@@ -142,7 +142,7 @@ func TestGetAuthorizationToken_PathedUserRoundTrips(t *testing.T) {
|
||||
func TestGetAuthorizationToken_ProxyEndpointCarriesPort(t *testing.T) {
|
||||
iss, _ := newECRAuth(t)
|
||||
endpoint := awsapi.RepositoryEndpoint{Region: ecrTestRegion, ServicesDomain: ecrTestSuffix, RegistryPort: "9999"}
|
||||
gw := withECR(withVerifier(gwfix.WithDeps(&GatewayConfig{
|
||||
gw := withECR(withVerifier(gwfix.WithDeps(&Config{
|
||||
Region: ecrTestRegion,
|
||||
DisableLogging: true,
|
||||
}, gwfix.Deps{IAM: allowAllIAMService()})), awsapi.Deps{AuthorizationToken: awsapi.NewAuthorizationTokenActionService(iss, endpoint)})
|
||||
@@ -1,4 +1,4 @@
|
||||
package gateway
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"github.com/mulgadc/spinifex/internal/testkit/gwfix"
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
package gateway
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -19,7 +19,7 @@ func TestECSRegisterTaskDefinition_DenialRendersAccessDenied403(t *testing.T) {
|
||||
return gwfix.AllowPolicyResource("ecs:RegisterTaskDefinition", "*"), nil
|
||||
},
|
||||
}
|
||||
gw := withRealAuth(gwfix.WithDeps(&GatewayConfig{DisableLogging: true}, gwfix.Deps{IAM: mock}))
|
||||
gw := withRealAuth(gwfix.WithDeps(&Config{DisableLogging: true}, gwfix.Deps{IAM: mock}))
|
||||
req := setupECSUserRequest("AmazonEC2ContainerServiceV20141113.RegisterTaskDefinition",
|
||||
registerTaskDefinitionBody(t, testECSPassRoleTargetARN))
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package gateway
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"github.com/mulgadc/spinifex/internal/testkit/gwfix"
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
|
||||
// An action whose real handler has not yet landed still resolves to the 501 stub.
|
||||
func TestECSRequest_KnownActionNotImplemented(t *testing.T) {
|
||||
gw := withVerifier(gwfix.WithDeps(&GatewayConfig{DisableLogging: true}, gwfix.Deps{IAM: allowAllIAMService()}))
|
||||
gw := withVerifier(gwfix.WithDeps(&Config{DisableLogging: true}, gwfix.Deps{IAM: allowAllIAMService()}))
|
||||
err := gw.serveECS(httptest.NewRecorder(),
|
||||
setupECSRequest("AmazonEC2ContainerServiceV20141113.UpdateCluster", "{}"))
|
||||
require.Error(t, err)
|
||||
@@ -28,7 +28,7 @@ func TestECSRequest_MissingAccountID(t *testing.T) {
|
||||
req.Header.Set("X-Amz-Target", "AmazonEC2ContainerServiceV20141113.ListClusters")
|
||||
req = withTestIdentity(req.WithContext(reqctx.WithService(req.Context(), "ecs")))
|
||||
|
||||
gw := withVerifier(gwfix.WithDeps(&GatewayConfig{DisableLogging: true}, gwfix.Deps{IAM: allowAllIAMService()}))
|
||||
gw := withVerifier(gwfix.WithDeps(&Config{DisableLogging: true}, gwfix.Deps{IAM: allowAllIAMService()}))
|
||||
// Account-less requests are rejected by the policy gate, which runs ahead of
|
||||
// the handler's own account guard.
|
||||
err := gw.serveECS(httptest.NewRecorder(), req)
|
||||
@@ -37,14 +37,14 @@ func TestECSRequest_MissingAccountID(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestECSRequest_MissingTarget(t *testing.T) {
|
||||
gw := withVerifier(gwfix.WithDeps(&GatewayConfig{DisableLogging: true}, gwfix.Deps{IAM: allowAllIAMService()}))
|
||||
gw := withVerifier(gwfix.WithDeps(&Config{DisableLogging: true}, gwfix.Deps{IAM: allowAllIAMService()}))
|
||||
err := gw.serveECS(httptest.NewRecorder(), setupECSRequest("", ""))
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, awserrors.ErrorMissingAction, err.Error())
|
||||
}
|
||||
|
||||
func TestECSRequest_UnknownAction(t *testing.T) {
|
||||
gw := withVerifier(gwfix.WithDeps(&GatewayConfig{DisableLogging: true}, gwfix.Deps{IAM: allowAllIAMService()}))
|
||||
gw := withVerifier(gwfix.WithDeps(&Config{DisableLogging: true}, gwfix.Deps{IAM: allowAllIAMService()}))
|
||||
err := gw.serveECS(httptest.NewRecorder(),
|
||||
setupECSRequest("AmazonEC2ContainerServiceV20141113.MadeUpAction", "{}"))
|
||||
require.Error(t, err)
|
||||
+4
-4
@@ -1,4 +1,4 @@
|
||||
package gateway
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
@@ -30,9 +30,9 @@ func TestELBv2DescribeAccountLimitsAcceptsPaging(t *testing.T) {
|
||||
func TestELBv2DescribeAccountLimitsDefaultsWhenQuotaDisabled(t *testing.T) {
|
||||
disabled := quota.New(quota.Limits{LoadBalancers: 0}, nil)
|
||||
|
||||
for name, gw := range map[string]*GatewayConfig{
|
||||
"disabled": withFakeCapabilities(withQuota(&GatewayConfig{DisableLogging: true}, disabled)),
|
||||
"unwired": withFakeCapabilities(&GatewayConfig{DisableLogging: true}),
|
||||
for name, gw := range map[string]*Config{
|
||||
"disabled": withFakeCapabilities(withQuota(&Config{DisableLogging: true}, disabled)),
|
||||
"unwired": withFakeCapabilities(&Config{DisableLogging: true}),
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
byName := describeAccountLimits(t, gw, "000000000042")
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package gateway
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"github.com/go-chi/chi/v5"
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
// Drives the real AWS SDK v2 clients against the gateway's error envelopes and
|
||||
// asserts they decode them into their modelled errors.
|
||||
|
||||
package gateway
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -73,7 +73,7 @@ func throttleApp(t *testing.T) aws.Config {
|
||||
require.NoError(t, b.Register(envelopeRegistration("sqs", dispatch.ErrorEnvelopeJSON10Query, c,
|
||||
func(r *dispatch.Registration) { r.ErrorNamespace = "com.amazonaws.sqs" })))
|
||||
require.NoError(t, b.Register(envelopeRegistration("lambda", dispatch.ErrorEnvelopeRESTJSON, c, nil)))
|
||||
gw := &GatewayConfig{DisableLogging: true, Services: b.Build()}
|
||||
gw := &Config{DisableLogging: true, Services: b.Build()}
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
svc := credentialScopeService(r)
|
||||
gw.writeThrottleError(w, r.WithContext(reqctx.WithService(r.Context(), svc)))
|
||||
+3
-3
@@ -1,7 +1,7 @@
|
||||
// Tests that the gateway renders dispatcher, throttle, cluster-unavailable and
|
||||
// oversize-body errors through the envelope a registration declares.
|
||||
|
||||
package gateway
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -57,7 +57,7 @@ func throttleResponse(t *testing.T, regs ...dispatch.Registration) func(svc stri
|
||||
for _, reg := range regs {
|
||||
require.NoError(t, b.Register(reg))
|
||||
}
|
||||
gw := &GatewayConfig{DisableLogging: true, Services: b.Build()}
|
||||
gw := &Config{DisableLogging: true, Services: b.Build()}
|
||||
return func(svc string) *http.Response {
|
||||
req := httptest.NewRequest(http.MethodPost, "/", nil)
|
||||
req = req.WithContext(reqctx.WithService(req.Context(), svc))
|
||||
@@ -237,7 +237,7 @@ func TestErrorEnvelope_ClusterUnavailable(t *testing.T) {
|
||||
b := dispatch.NewBuilder()
|
||||
require.NoError(t, b.Register(envelopeRegistration(restJSONService, dispatch.ErrorEnvelopeRESTJSON, c, nil)))
|
||||
require.NoError(t, b.Register(envelopeRegistration(json10Service, dispatch.ErrorEnvelopeJSON10Query, c, nil)))
|
||||
gw := withVerifier(gwfix.WithDeps(&GatewayConfig{DisableLogging: true, Services: b.Build()}, gwfix.Deps{IAM: allowAllIAMService()}))
|
||||
gw := withVerifier(gwfix.WithDeps(&Config{DisableLogging: true, Services: b.Build()}, gwfix.Deps{IAM: allowAllIAMService()}))
|
||||
|
||||
call := func(svc string) *http.Response {
|
||||
req := httptest.NewRequest(http.MethodPost, "/", nil)
|
||||
@@ -1,11 +1,9 @@
|
||||
// Package gateway provides the AWS-compatible API gateway for the Spinifex platform.
|
||||
package gateway
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"github.com/mulgadc/spinifex/spinifex/ingress/aws/pipeline"
|
||||
authlimit "github.com/mulgadc/spinifex/spinifex/ingress/aws/ratelimit"
|
||||
"github.com/mulgadc/spinifex/spinifex/ingress/aws/reqctx"
|
||||
"log/slog"
|
||||
@@ -29,10 +27,10 @@ import (
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
// GatewayConfig is the AWS API gateway's shared state: its NATS connection, region and cluster
|
||||
// Config is the AWS API gateway's shared state: its NATS connection, region and cluster
|
||||
// facts, auth and throttling collaborators, and lazily built caches. Its methods are the
|
||||
// per-service request handlers.
|
||||
type GatewayConfig struct {
|
||||
type Config struct {
|
||||
Debug bool `json:"debug"`
|
||||
DisableLogging bool `json:"disable_logging"`
|
||||
Region string // Region this gateway is running in
|
||||
@@ -46,14 +44,14 @@ type GatewayConfig struct {
|
||||
Throttler *ratelimit.Throttler // Per-account+action API request throttler
|
||||
// Admission decides whether a request's account may proceed. Nil fails every
|
||||
// authenticated request closed with InternalError.
|
||||
Admission pipeline.AccountAdmission
|
||||
Admission AccountAdmission
|
||||
// Policy decides authorization for a verified principal. Nil fails every
|
||||
// authorization closed with InternalError.
|
||||
Policy pipeline.PolicyEvaluator
|
||||
Policy PolicyEvaluator
|
||||
// Readiness reports whether the message bus is reachable. Nil skips the
|
||||
// early refusal in the auth middleware and makes the service gate answer
|
||||
// cluster-unavailable.
|
||||
Readiness pipeline.Readiness
|
||||
Readiness Readiness
|
||||
// PublicRoutes mounts the routes served ahead of SigV4 and the throttle.
|
||||
// They authenticate themselves or need no authentication. Nil mounts none.
|
||||
PublicRoutes func(chi.Router)
|
||||
@@ -70,25 +68,25 @@ type GatewayConfig struct {
|
||||
|
||||
// selector answers service selection over Services. It holds only the one
|
||||
// pointer, so it is built per call and needs no cache.
|
||||
func (gw *GatewayConfig) selector() *dispatch.Selector {
|
||||
func (gw *Config) selector() *dispatch.Selector {
|
||||
return dispatch.NewSelector(gw.Services, nil)
|
||||
}
|
||||
|
||||
// registered reports whether svc is served through the registration seam. A
|
||||
// nil registry (no registrations) always reports false.
|
||||
func (gw *GatewayConfig) registered(svc string) (*dispatch.Entry, bool) {
|
||||
func (gw *Config) registered(svc string) (*dispatch.Entry, bool) {
|
||||
return gw.selector().Lookup(svc)
|
||||
}
|
||||
|
||||
// ValidateServices fails if the registry's service names are inconsistent.
|
||||
func (gw *GatewayConfig) ValidateServices() error {
|
||||
func (gw *Config) ValidateServices() error {
|
||||
return gw.selector().Validate()
|
||||
}
|
||||
|
||||
// registeredActorFunc returns the lazy Actor resolver a registered
|
||||
// dispatcher's Invocation carries. It reads the SigV4 identity context through
|
||||
// the caller ARN the verifier built, so nothing is computed until called.
|
||||
func (gw *GatewayConfig) registeredActorFunc(r *http.Request) dispatch.ActorFunc {
|
||||
func (gw *Config) registeredActorFunc(r *http.Request) dispatch.ActorFunc {
|
||||
return func() (dispatch.Actor, error) {
|
||||
return callerActor(r.Context())
|
||||
}
|
||||
@@ -99,10 +97,10 @@ func (gw *GatewayConfig) registeredActorFunc(r *http.Request) dispatch.ActorFunc
|
||||
// could be composed, so a reader that needs no ARN may ignore it.
|
||||
func callerActor(ctx context.Context) (dispatch.Actor, error) {
|
||||
p, _ := reqctx.Principal(ctx)
|
||||
return pipeline.ActorFor(p, reqctx.AccessKeyID(ctx))
|
||||
return ActorFor(p, reqctx.AccessKeyID(ctx))
|
||||
}
|
||||
|
||||
func (gw *GatewayConfig) SetupRoutes() http.Handler {
|
||||
func (gw *Config) SetupRoutes() http.Handler {
|
||||
var logLevel slog.Level
|
||||
|
||||
if gw.Debug {
|
||||
@@ -172,7 +170,7 @@ func (gw *GatewayConfig) SetupRoutes() http.Handler {
|
||||
// preAuthInterceptor offers each request to the pre-authentication handlers in
|
||||
// order; one that takes it ends the chain, and its errors render under the
|
||||
// service it declares.
|
||||
func (gw *GatewayConfig) preAuthInterceptor(next http.Handler) http.Handler {
|
||||
func (gw *Config) preAuthInterceptor(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
for _, pre := range gw.PreAuth {
|
||||
handled, err := pre.Serve(w, r)
|
||||
@@ -190,7 +188,7 @@ func (gw *GatewayConfig) preAuthInterceptor(next http.Handler) http.Handler {
|
||||
|
||||
// throttleKeyFuncs returns the KeyFunc slice for the API throttle middleware,
|
||||
// keyed by account-id and action from the SigV4 auth context.
|
||||
func (gw *GatewayConfig) throttleKeyFuncs() []ratelimit.KeyFunc {
|
||||
func (gw *Config) throttleKeyFuncs() []ratelimit.KeyFunc {
|
||||
return []ratelimit.KeyFunc{
|
||||
func(r *http.Request) (string, error) {
|
||||
acct := callerOf(r).AccountID
|
||||
@@ -211,13 +209,13 @@ func (gw *GatewayConfig) throttleKeyFuncs() []ratelimit.KeyFunc {
|
||||
|
||||
// errorProtocol reports the JSON wire format svc's errors use; ok is false when
|
||||
// they render as XML. A registered service's declared envelope wins.
|
||||
func (gw *GatewayConfig) errorProtocol(svc string) (p envelope.Protocol, ok bool) {
|
||||
func (gw *Config) errorProtocol(svc string) (p envelope.Protocol, ok bool) {
|
||||
return gw.selector().ErrorProtocol(svc)
|
||||
}
|
||||
|
||||
// writeJSONError renders a gateway-raised error in svc's JSON protocol. The
|
||||
// code gets the common-error "Exception" suffix unless verbatim.
|
||||
func (gw *GatewayConfig) writeJSONError(w http.ResponseWriter, p envelope.Protocol, svc string, e envelope.JSONError) {
|
||||
func (gw *Config) writeJSONError(w http.ResponseWriter, p envelope.Protocol, svc string, e envelope.JSONError) {
|
||||
if entry, ok := gw.registered(svc); ok {
|
||||
if internal, own := entry.InternalErrorCode(); own && e.Code == awserrors.ErrorInternalError {
|
||||
e.Code, e.Verbatim = internal, true
|
||||
@@ -234,7 +232,7 @@ const clusterUnavailableMsg = "cluster unavailable: NATS disconnected — check
|
||||
|
||||
// writeClusterUnavailable writes a 503 ServiceUnavailable in the service-appropriate
|
||||
// format, carrying the /local/status hint in <Message>.
|
||||
func (gw *GatewayConfig) writeClusterUnavailable(w http.ResponseWriter, r *http.Request, svc string) {
|
||||
func (gw *Config) writeClusterUnavailable(w http.ResponseWriter, r *http.Request, svc string) {
|
||||
requestID := uuid.NewV4().String()
|
||||
|
||||
// JSON services get a body in their own protocol.
|
||||
@@ -257,7 +255,7 @@ func (gw *GatewayConfig) writeClusterUnavailable(w http.ResponseWriter, r *http.
|
||||
}
|
||||
|
||||
// writeThrottleError writes the service-appropriate throttle rejection response.
|
||||
func (gw *GatewayConfig) writeThrottleError(w http.ResponseWriter, r *http.Request) {
|
||||
func (gw *Config) writeThrottleError(w http.ResponseWriter, r *http.Request) {
|
||||
requestID := uuid.NewV4().String()
|
||||
svc := reqctx.Service(r.Context())
|
||||
|
||||
@@ -291,7 +289,7 @@ func (gw *GatewayConfig) writeThrottleError(w http.ResponseWriter, r *http.Reque
|
||||
}
|
||||
}
|
||||
|
||||
func (gw *GatewayConfig) Request(w http.ResponseWriter, r *http.Request) {
|
||||
func (gw *Config) Request(w http.ResponseWriter, r *http.Request) {
|
||||
svc, err := gw.GetService(r)
|
||||
action := reqctx.Action(r.Context())
|
||||
slog.Info("Request", "service", svc, "action", action, "method", r.Method, "path", r.URL.Path)
|
||||
@@ -340,7 +338,7 @@ func serviceErrorLevel(svc string, err error) slog.Level {
|
||||
// dispatchRegistered hands the authenticated request to a registered service
|
||||
// with the caller's account, the gateway Region and ingress-owned
|
||||
// authorization and actor capabilities.
|
||||
func (gw *GatewayConfig) dispatchRegistered(entry *dispatch.Entry, w http.ResponseWriter, r *http.Request) error {
|
||||
func (gw *Config) dispatchRegistered(entry *dispatch.Entry, w http.ResponseWriter, r *http.Request) error {
|
||||
return entry.Dispatch(w, dispatch.Invocation{
|
||||
Request: r,
|
||||
AccountID: callerOf(r).AccountID,
|
||||
@@ -358,7 +356,7 @@ func requestService(r *http.Request) string {
|
||||
return reqctx.Service(r.Context())
|
||||
}
|
||||
|
||||
func (gw *GatewayConfig) GetService(r *http.Request) (string, error) {
|
||||
func (gw *Config) GetService(r *http.Request) (string, error) {
|
||||
svc := reqctx.Service(r.Context())
|
||||
if svc == "" {
|
||||
return "", errors.New(awserrors.ErrorAuthFailure)
|
||||
@@ -376,28 +374,28 @@ func (gw *GatewayConfig) GetService(r *http.Request) (string, error) {
|
||||
// Prefer checkPolicyResources with the request's real ARNs: where the
|
||||
// identifier arrives in a JSON body, read the body ahead of the gate and
|
||||
// resolve it there rather than settling for "*".
|
||||
func (gw *GatewayConfig) checkPolicy(r *http.Request, service, action string) error {
|
||||
func (gw *Config) checkPolicy(r *http.Request, service, action string) error {
|
||||
return gw.checkPolicyResources(r, service, action, []string{"*"})
|
||||
}
|
||||
|
||||
// checkPolicyResources evaluates every resource against one resolved policy
|
||||
// snapshot. Used when one API request authorizes multiple resource ARNs.
|
||||
func (gw *GatewayConfig) checkPolicyResources(r *http.Request, service, action string, resources []string) error {
|
||||
func (gw *Config) checkPolicyResources(r *http.Request, service, action string, resources []string) error {
|
||||
return gw.checkPolicyResourcesWithKeys(r, service, action, resources, nil)
|
||||
}
|
||||
|
||||
// checkPolicyResourcesWithKeys is checkPolicyResources with action-scoped
|
||||
// condition keys layered over the request's own.
|
||||
func (gw *GatewayConfig) checkPolicyResourcesWithKeys(r *http.Request, service, action string, resources []string, actionKeys iampolicy.ConditionKeys) error {
|
||||
func (gw *Config) checkPolicyResourcesWithKeys(r *http.Request, service, action string, resources []string, actionKeys iampolicy.ConditionKeys) error {
|
||||
// Every dispatcher — query-protocol and REST-JSON alike — reaches this
|
||||
// point with its resolved action, so telemetry enrichment lives here.
|
||||
recordResolvedAction(r.Context(), service, action)
|
||||
|
||||
caller, authenticated := reqctx.Principal(r.Context())
|
||||
return pipeline.Authorize(gw.Policy, r, caller, authenticated, service, action, resources, actionKeys)
|
||||
return Authorize(gw.Policy, r, caller, authenticated, service, action, resources, actionKeys)
|
||||
}
|
||||
|
||||
func (gw *GatewayConfig) ErrorHandler(w http.ResponseWriter, r *http.Request, err error) {
|
||||
func (gw *Config) ErrorHandler(w http.ResponseWriter, r *http.Request, err error) {
|
||||
svc, _ := gw.GetService(r)
|
||||
slog.Debug("ErrorHandler", "service", svc, "error", err.Error())
|
||||
|
||||
@@ -462,7 +460,7 @@ func ParseAWSQueryArgs(body string) (map[string]string, error) {
|
||||
// serve) declares. JSON services never reach here — every caller checks
|
||||
// errorProtocol first. No scope yet means the signature never parsed; that
|
||||
// keeps EC2's shape rather than changing a path this is not about.
|
||||
func (gw *GatewayConfig) xmlErrorBody(svc, code, message, requestID, resource string) []byte {
|
||||
func (gw *Config) xmlErrorBody(svc, code, message, requestID, resource string) []byte {
|
||||
if svc == "" {
|
||||
return envelope.EC2Body(code, message, requestID)
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
package gateway
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -75,9 +75,9 @@ func TestXMLErrorBody_EnvelopePerService(t *testing.T) {
|
||||
{"route53", "ErrorResponse"},
|
||||
}
|
||||
|
||||
gw := withServices(gwfix.WithDeps(&GatewayConfig{DisableLogging: true}, gwfix.Deps{IAM: allowAllIAMService()}),
|
||||
ec2Registration(&GatewayConfig{}), operatoradmin.NewRegistration(operatoradmin.Deps{}),
|
||||
iamRegistration(&GatewayConfig{}), stsRegistration(&GatewayConfig{}),
|
||||
gw := withServices(gwfix.WithDeps(&Config{DisableLogging: true}, gwfix.Deps{IAM: allowAllIAMService()}),
|
||||
ec2Registration(&Config{}), operatoradmin.NewRegistration(operatoradmin.Deps{}),
|
||||
iamRegistration(&Config{}), stsRegistration(&Config{}),
|
||||
elbv2awsapi.NewRegistration(elbv2awsapi.Deps{}), rdsawsapi.NewRegistration(rdsawsapi.Deps{}),
|
||||
route53awsapi.NewRegistration(route53awsapi.Deps{}))
|
||||
|
||||
@@ -92,7 +92,7 @@ func TestXMLErrorBody_EnvelopePerService(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestErrorHandler_IAMService(t *testing.T) {
|
||||
gw := withIAM(withEC2Verifier(gwfix.WithDeps(&GatewayConfig{DisableLogging: true}, gwfix.Deps{IAM: allowAllIAMService()})))
|
||||
gw := withIAM(withEC2Verifier(gwfix.WithDeps(&Config{DisableLogging: true}, gwfix.Deps{IAM: allowAllIAMService()})))
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := reqctx.WithService(r.Context(), "iam")
|
||||
@@ -114,7 +114,7 @@ func TestErrorHandler_IAMService(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestErrorHandler_UnknownError(t *testing.T) {
|
||||
gw := withEC2Verifier(gwfix.WithDeps(&GatewayConfig{DisableLogging: true}, gwfix.Deps{IAM: allowAllIAMService()}))
|
||||
gw := withEC2Verifier(gwfix.WithDeps(&Config{DisableLogging: true}, gwfix.Deps{IAM: allowAllIAMService()}))
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := reqctx.WithService(r.Context(), "ec2")
|
||||
@@ -135,7 +135,7 @@ func TestErrorHandler_UnknownError(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestErrorHandler_WrappedErrorCode(t *testing.T) {
|
||||
gw := withEC2Verifier(gwfix.WithDeps(&GatewayConfig{DisableLogging: true}, gwfix.Deps{IAM: allowAllIAMService()}))
|
||||
gw := withEC2Verifier(gwfix.WithDeps(&Config{DisableLogging: true}, gwfix.Deps{IAM: allowAllIAMService()}))
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := reqctx.WithService(r.Context(), "ec2")
|
||||
@@ -159,7 +159,7 @@ func TestErrorHandler_WrappedErrorCode(t *testing.T) {
|
||||
// fidelity gap: a call site that names the blocking resource via awserrors.Errorf
|
||||
// must have that wording reach the client instead of the generic ErrorLookup text.
|
||||
func TestErrorHandler_PrefersCallSiteMessage(t *testing.T) {
|
||||
gw := withEC2Verifier(gwfix.WithDeps(&GatewayConfig{DisableLogging: true}, gwfix.Deps{IAM: allowAllIAMService()}))
|
||||
gw := withEC2Verifier(gwfix.WithDeps(&Config{DisableLogging: true}, gwfix.Deps{IAM: allowAllIAMService()}))
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := reqctx.WithService(r.Context(), "ec2")
|
||||
@@ -184,7 +184,7 @@ func TestErrorHandler_PrefersCallSiteMessage(t *testing.T) {
|
||||
// ResourceInUseException collision check: ACM's DeleteCertificate must not
|
||||
// surface EKS's "cluster already exists" wording for the shared wire code.
|
||||
func TestErrorHandler_ACMResourceInUse_UsesACMWording(t *testing.T) {
|
||||
gw := withACM(withEC2Verifier(gwfix.WithDeps(&GatewayConfig{DisableLogging: true}, gwfix.Deps{IAM: allowAllIAMService()})), acmawsapi.Deps{})
|
||||
gw := withACM(withEC2Verifier(gwfix.WithDeps(&Config{DisableLogging: true}, gwfix.Deps{IAM: allowAllIAMService()})), acmawsapi.Deps{})
|
||||
req := httptest.NewRequest(http.MethodPost, "/", nil)
|
||||
ctx := reqctx.WithService(req.Context(), "acm")
|
||||
req = req.WithContext(ctx)
|
||||
@@ -205,7 +205,7 @@ func TestErrorHandler_ACMResourceInUse_UsesACMWording(t *testing.T) {
|
||||
// own ResourceInUseException must keep its existing wording, unaffected by the
|
||||
// ACM-specific override.
|
||||
func TestErrorHandler_EKSResourceInUse_UsesEKSWording(t *testing.T) {
|
||||
gw := withContainers(withEC2Verifier(gwfix.WithDeps(&GatewayConfig{DisableLogging: true}, gwfix.Deps{IAM: allowAllIAMService()})))
|
||||
gw := withContainers(withEC2Verifier(gwfix.WithDeps(&Config{DisableLogging: true}, gwfix.Deps{IAM: allowAllIAMService()})))
|
||||
req := httptest.NewRequest(http.MethodPost, "/", nil)
|
||||
ctx := reqctx.WithService(req.Context(), "eks")
|
||||
req = req.WithContext(ctx)
|
||||
@@ -225,7 +225,7 @@ func TestErrorHandler_EKSResourceInUse_UsesEKSWording(t *testing.T) {
|
||||
// case: an error path that supplies no message (the vast majority of call
|
||||
// sites) must keep rendering exactly today's ErrorLookup text, unchanged.
|
||||
func TestErrorHandler_NoMessageSupplied_MatchesErrorLookup(t *testing.T) {
|
||||
gw := withEC2Verifier(gwfix.WithDeps(&GatewayConfig{DisableLogging: true}, gwfix.Deps{IAM: allowAllIAMService()}))
|
||||
gw := withEC2Verifier(gwfix.WithDeps(&Config{DisableLogging: true}, gwfix.Deps{IAM: allowAllIAMService()}))
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := reqctx.WithService(r.Context(), "ec2")
|
||||
@@ -247,7 +247,7 @@ func TestErrorHandler_NoMessageSupplied_MatchesErrorLookup(t *testing.T) {
|
||||
// validation code outside EC2: the default must not be EC2's Reserved Instance
|
||||
// or resource-ID wording, whichever envelope the service uses.
|
||||
func TestErrorHandler_NonEC2ValidationDefaults_DropEC2Wording(t *testing.T) {
|
||||
gw := withEC2Verifier(gwfix.WithDeps(&GatewayConfig{DisableLogging: true}, gwfix.Deps{IAM: allowAllIAMService()}))
|
||||
gw := withEC2Verifier(gwfix.WithDeps(&Config{DisableLogging: true}, gwfix.Deps{IAM: allowAllIAMService()}))
|
||||
b := dispatch.NewBuilder()
|
||||
require.NoError(t, b.Register(awsapi.NewRegistration(awsapi.Deps{})))
|
||||
require.NoError(t, b.Register(acmawsapi.NewRegistration(acmawsapi.Deps{})))
|
||||
@@ -274,7 +274,7 @@ func TestErrorHandler_NonEC2ValidationDefaults_DropEC2Wording(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestErrorHandler_ELBv2Service(t *testing.T) {
|
||||
gw := withELBv2(withEC2Verifier(gwfix.WithDeps(&GatewayConfig{DisableLogging: true}, gwfix.Deps{IAM: allowAllIAMService()})))
|
||||
gw := withELBv2(withEC2Verifier(gwfix.WithDeps(&Config{DisableLogging: true}, gwfix.Deps{IAM: allowAllIAMService()})))
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := reqctx.WithService(r.Context(), "elasticloadbalancing")
|
||||
@@ -296,7 +296,7 @@ func TestErrorHandler_ELBv2Service(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestErrorHandler_EC2Service(t *testing.T) {
|
||||
gw := withEC2Verifier(gwfix.WithDeps(&GatewayConfig{DisableLogging: true}, gwfix.Deps{IAM: allowAllIAMService()}))
|
||||
gw := withEC2Verifier(gwfix.WithDeps(&Config{DisableLogging: true}, gwfix.Deps{IAM: allowAllIAMService()}))
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := reqctx.WithService(r.Context(), "ec2")
|
||||
@@ -316,7 +316,7 @@ func TestErrorHandler_EC2Service(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestErrorHandler_IgnoresClientRequestID(t *testing.T) {
|
||||
gw := withEC2Verifier(gwfix.WithDeps(&GatewayConfig{DisableLogging: true}, gwfix.Deps{IAM: allowAllIAMService()}))
|
||||
gw := withEC2Verifier(gwfix.WithDeps(&Config{DisableLogging: true}, gwfix.Deps{IAM: allowAllIAMService()}))
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := reqctx.WithService(r.Context(), "ec2")
|
||||
@@ -334,7 +334,7 @@ func TestErrorHandler_IgnoresClientRequestID(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestErrorHandler_ContentTypeXML(t *testing.T) {
|
||||
gw := withEC2Verifier(gwfix.WithDeps(&GatewayConfig{DisableLogging: true}, gwfix.Deps{IAM: allowAllIAMService()}))
|
||||
gw := withEC2Verifier(gwfix.WithDeps(&Config{DisableLogging: true}, gwfix.Deps{IAM: allowAllIAMService()}))
|
||||
|
||||
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx := reqctx.WithService(r.Context(), "ec2")
|
||||
@@ -574,7 +574,7 @@ func TestParseAWSQueryArgs_CapturedClientBodies(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRequest_UnsupportedService(t *testing.T) {
|
||||
gw := withEC2Verifier(gwfix.WithDeps(&GatewayConfig{DisableLogging: true}, gwfix.Deps{IAM: allowAllIAMService()}))
|
||||
gw := withEC2Verifier(gwfix.WithDeps(&Config{DisableLogging: true}, gwfix.Deps{IAM: allowAllIAMService()}))
|
||||
req := httptest.NewRequest(http.MethodPost, "/", nil)
|
||||
ctx := reqctx.WithService(req.Context(), "s3")
|
||||
req = req.WithContext(ctx)
|
||||
@@ -602,7 +602,7 @@ func TestRequest_MalformedQueryString_EndToEnd(t *testing.T) {
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// A live NATS connection bypasses the cluster-unavailable gate.
|
||||
gw := withEC2Verifier(gwfix.WithDeps(&GatewayConfig{DisableLogging: true}, gwfix.Deps{NATS: connectedNATS(t), IAM: allowAllIAMService()}))
|
||||
gw := withEC2Verifier(gwfix.WithDeps(&Config{DisableLogging: true}, gwfix.Deps{NATS: connectedNATS(t), IAM: allowAllIAMService()}))
|
||||
if tc.service == "elasticloadbalancing" {
|
||||
withELBv2(gw)
|
||||
}
|
||||
@@ -629,7 +629,7 @@ func TestRequest_MalformedQueryString_EndToEnd(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRequest_EC2MissingAction(t *testing.T) {
|
||||
gw := withEC2Verifier(gwfix.WithDeps(&GatewayConfig{DisableLogging: true}, gwfix.Deps{NATS: connectedNATS(t), IAM: allowAllIAMService()}))
|
||||
gw := withEC2Verifier(gwfix.WithDeps(&Config{DisableLogging: true}, gwfix.Deps{NATS: connectedNATS(t), IAM: allowAllIAMService()}))
|
||||
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(""))
|
||||
ctx := reqctx.WithService(req.Context(), "ec2")
|
||||
ctx = withPrincipalField(ctx, fieldAccountID, "123456789012")
|
||||
@@ -646,7 +646,7 @@ func TestRequest_EC2MissingAction(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRequest_IAMNilService(t *testing.T) {
|
||||
gw := withIAM(withEC2Verifier(gwfix.WithDeps(&GatewayConfig{DisableLogging: true}, gwfix.Deps{IAM: nil, NATS: connectedNATS(t)})))
|
||||
gw := withIAM(withEC2Verifier(gwfix.WithDeps(&Config{DisableLogging: true}, gwfix.Deps{IAM: nil, NATS: connectedNATS(t)})))
|
||||
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("Action=CreateUser&UserName=test"))
|
||||
ctx := reqctx.WithService(req.Context(), "iam")
|
||||
ctx = withPrincipalField(ctx, fieldAccountID, "123456789012")
|
||||
@@ -684,7 +684,7 @@ func setupEC2Request(body string, accountID string) *http.Request {
|
||||
}
|
||||
|
||||
func TestEC2Request_MissingAction(t *testing.T) {
|
||||
gw := withEC2Verifier(gwfix.WithDeps(&GatewayConfig{DisableLogging: true}, gwfix.Deps{IAM: allowAllIAMService()}))
|
||||
gw := withEC2Verifier(gwfix.WithDeps(&Config{DisableLogging: true}, gwfix.Deps{IAM: allowAllIAMService()}))
|
||||
req := setupEC2Request("", "123456789012")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
@@ -694,7 +694,7 @@ func TestEC2Request_MissingAction(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestEC2Request_UnknownAction(t *testing.T) {
|
||||
gw := withEC2Verifier(gwfix.WithDeps(&GatewayConfig{DisableLogging: true}, gwfix.Deps{IAM: allowAllIAMService()}))
|
||||
gw := withEC2Verifier(gwfix.WithDeps(&Config{DisableLogging: true}, gwfix.Deps{IAM: allowAllIAMService()}))
|
||||
req := setupEC2Request("Action=FakeAction", "123456789012")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
@@ -704,7 +704,7 @@ func TestEC2Request_UnknownAction(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestEC2Request_NilNATSNonLocalAction(t *testing.T) {
|
||||
gw := withEC2Verifier(gwfix.WithDeps(&GatewayConfig{DisableLogging: true}, gwfix.Deps{NATS: nil, IAM: allowAllIAMService()}))
|
||||
gw := withEC2Verifier(gwfix.WithDeps(&Config{DisableLogging: true}, gwfix.Deps{NATS: nil, IAM: allowAllIAMService()}))
|
||||
req := setupEC2Request("Action=DescribeInstances", "123456789012")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
@@ -714,7 +714,7 @@ func TestEC2Request_NilNATSNonLocalAction(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestEC2Request_NilNATSLocalAction(t *testing.T) {
|
||||
gw := withEC2(withEC2Verifier(gwfix.WithDeps(&GatewayConfig{
|
||||
gw := withEC2(withEC2Verifier(gwfix.WithDeps(&Config{
|
||||
DisableLogging: true,
|
||||
Region: "us-east-1",
|
||||
}, gwfix.Deps{NATS: nil, IAM: allowAllIAMService()})), func(d *ec2awsapi.Deps) { d.AZ = "us-east-1a" })
|
||||
@@ -733,7 +733,7 @@ func TestEC2Request_NilNATSLocalAction(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestEC2Request_MissingAccountID(t *testing.T) {
|
||||
gw := withEC2Verifier(gwfix.WithDeps(&GatewayConfig{DisableLogging: true}, gwfix.Deps{NATS: nil, IAM: allowAllIAMService()}))
|
||||
gw := withEC2Verifier(gwfix.WithDeps(&Config{DisableLogging: true}, gwfix.Deps{NATS: nil, IAM: allowAllIAMService()}))
|
||||
// Use a local action so we don't fail on nil NATS first
|
||||
req := setupEC2Request("Action=DescribeRegions", "")
|
||||
w := httptest.NewRecorder()
|
||||
@@ -746,7 +746,7 @@ func TestEC2Request_MissingAccountID(t *testing.T) {
|
||||
|
||||
// Static attributes are served without NATS; only default-vpc needs a lookup.
|
||||
func TestEC2Request_DescribeAccountAttributes(t *testing.T) {
|
||||
gw := withEC2Verifier(gwfix.WithDeps(&GatewayConfig{DisableLogging: true}, gwfix.Deps{NATS: nil, IAM: allowAllIAMService()}))
|
||||
gw := withEC2Verifier(gwfix.WithDeps(&Config{DisableLogging: true}, gwfix.Deps{NATS: nil, IAM: allowAllIAMService()}))
|
||||
req := setupEC2Request("Action=DescribeAccountAttributes&AttributeName.1=max-instances", "123456789012")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
@@ -778,7 +778,7 @@ func TestEC2Request_DescribeAccountAttributes_DefaultVPC(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = sub.Unsubscribe() })
|
||||
|
||||
gw := withEC2Verifier(gwfix.WithDeps(&GatewayConfig{DisableLogging: true}, gwfix.Deps{NATS: nc, IAM: allowAllIAMService()}))
|
||||
gw := withEC2Verifier(gwfix.WithDeps(&Config{DisableLogging: true}, gwfix.Deps{NATS: nc, IAM: allowAllIAMService()}))
|
||||
req := setupEC2Request("Action=DescribeAccountAttributes&AttributeName.1=default-vpc", "123456789012")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
@@ -788,7 +788,7 @@ func TestEC2Request_DescribeAccountAttributes_DefaultVPC(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestEC2Request_DescribeAvailabilityZones(t *testing.T) {
|
||||
gw := withEC2(withEC2Verifier(gwfix.WithDeps(&GatewayConfig{
|
||||
gw := withEC2(withEC2Verifier(gwfix.WithDeps(&Config{
|
||||
DisableLogging: true,
|
||||
Region: "us-east-1",
|
||||
}, gwfix.Deps{NATS: nil, IAM: allowAllIAMService()})), func(d *ec2awsapi.Deps) { d.AZ = "us-east-1a" })
|
||||
@@ -818,7 +818,7 @@ func shortenPolicyResolveRetry(t *testing.T) {
|
||||
// --- Throttle middleware integration tests ---
|
||||
|
||||
func TestThrottleKeyFuncs_ExtractsAccountAndAction(t *testing.T) {
|
||||
gw := withEC2Verifier(gwfix.WithDeps(&GatewayConfig{DisableLogging: true}, gwfix.Deps{IAM: allowAllIAMService()}))
|
||||
gw := withEC2Verifier(gwfix.WithDeps(&Config{DisableLogging: true}, gwfix.Deps{IAM: allowAllIAMService()}))
|
||||
keyFuncs := gw.throttleKeyFuncs()
|
||||
require.Len(t, keyFuncs, 2)
|
||||
|
||||
@@ -837,7 +837,7 @@ func TestThrottleKeyFuncs_ExtractsAccountAndAction(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestThrottleKeyFuncs_UnknownAction(t *testing.T) {
|
||||
gw := withEC2Verifier(gwfix.WithDeps(&GatewayConfig{DisableLogging: true}, gwfix.Deps{IAM: allowAllIAMService()}))
|
||||
gw := withEC2Verifier(gwfix.WithDeps(&Config{DisableLogging: true}, gwfix.Deps{IAM: allowAllIAMService()}))
|
||||
keyFuncs := gw.throttleKeyFuncs()
|
||||
|
||||
// No ctxAction in context — should return "unknown".
|
||||
@@ -851,7 +851,7 @@ func TestThrottleKeyFuncs_UnknownAction(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestThrottleKeyFuncs_MissingAccountID(t *testing.T) {
|
||||
gw := withEC2Verifier(gwfix.WithDeps(&GatewayConfig{DisableLogging: true}, gwfix.Deps{IAM: allowAllIAMService()}))
|
||||
gw := withEC2Verifier(gwfix.WithDeps(&Config{DisableLogging: true}, gwfix.Deps{IAM: allowAllIAMService()}))
|
||||
keyFuncs := gw.throttleKeyFuncs()
|
||||
|
||||
// No ctxAccountID in context — should return an error.
|
||||
@@ -863,7 +863,7 @@ func TestThrottleKeyFuncs_MissingAccountID(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestWriteThrottleError_EC2(t *testing.T) {
|
||||
gw := withEC2Verifier(gwfix.WithDeps(&GatewayConfig{DisableLogging: true}, gwfix.Deps{IAM: allowAllIAMService()}))
|
||||
gw := withEC2Verifier(gwfix.WithDeps(&Config{DisableLogging: true}, gwfix.Deps{IAM: allowAllIAMService()}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/", nil)
|
||||
ctx := reqctx.WithService(req.Context(), "ec2")
|
||||
@@ -881,7 +881,7 @@ func TestWriteThrottleError_EC2(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestWriteThrottleError_IAM(t *testing.T) {
|
||||
gw := withIAM(withEC2Verifier(gwfix.WithDeps(&GatewayConfig{DisableLogging: true}, gwfix.Deps{IAM: allowAllIAMService()})))
|
||||
gw := withIAM(withEC2Verifier(gwfix.WithDeps(&Config{DisableLogging: true}, gwfix.Deps{IAM: allowAllIAMService()})))
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/", nil)
|
||||
ctx := reqctx.WithService(req.Context(), "iam")
|
||||
@@ -901,7 +901,7 @@ func TestWriteThrottleError_IAM(t *testing.T) {
|
||||
// Route 53 throttles with a 400 carrying the Throttling code, which SDK
|
||||
// retryers classify by code rather than status.
|
||||
func TestWriteThrottleError_Route53(t *testing.T) {
|
||||
gw := withServices(&GatewayConfig{DisableLogging: true}, route53awsapi.NewRegistration(route53awsapi.Deps{}))
|
||||
gw := withServices(&Config{DisableLogging: true}, route53awsapi.NewRegistration(route53awsapi.Deps{}))
|
||||
req := httptest.NewRequest(http.MethodGet, "/2013-04-01/hostedzone", nil)
|
||||
req = req.WithContext(reqctx.WithService(req.Context(), "route53"))
|
||||
w := httptest.NewRecorder()
|
||||
@@ -916,7 +916,7 @@ func TestWriteThrottleError_Route53(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestWriteThrottleError_Bedrock(t *testing.T) {
|
||||
gw := withServices(&GatewayConfig{DisableLogging: true}, ochreawsapi.NewRegistration(ochreawsapi.Deps{}))
|
||||
gw := withServices(&Config{DisableLogging: true}, ochreawsapi.NewRegistration(ochreawsapi.Deps{}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/model/meta.llama3-2-1b-instruct-v1%3A0/converse", nil)
|
||||
req = req.WithContext(reqctx.WithService(req.Context(), "bedrock"))
|
||||
@@ -942,7 +942,7 @@ func TestThrottleMiddleware_Integration(t *testing.T) {
|
||||
throttler := ratelimit.New(cfg)
|
||||
defer throttler.Stop()
|
||||
|
||||
gw := withEC2Verifier(gwfix.WithDeps(&GatewayConfig{
|
||||
gw := withEC2Verifier(gwfix.WithDeps(&Config{
|
||||
DisableLogging: true,
|
||||
Throttler: throttler,
|
||||
}, gwfix.Deps{IAM: allowAllIAMService()}))
|
||||
@@ -982,7 +982,7 @@ func TestThrottleMiddleware_Integration(t *testing.T) {
|
||||
|
||||
func TestThrottleMiddleware_DisabledConfig(t *testing.T) {
|
||||
// When Throttler is nil, SetupRoutes skips middleware — no panic.
|
||||
gw := withEC2Verifier(gwfix.WithDeps(&GatewayConfig{DisableLogging: true, Throttler: nil}, gwfix.Deps{IAM: allowAllIAMService()}))
|
||||
gw := withEC2Verifier(gwfix.WithDeps(&Config{DisableLogging: true, Throttler: nil}, gwfix.Deps{IAM: allowAllIAMService()}))
|
||||
handler := gw.SetupRoutes()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("Action=DescribeInstances"))
|
||||
@@ -1006,7 +1006,7 @@ func TestThrottleMiddleware_PerActionIsolation(t *testing.T) {
|
||||
throttler := ratelimit.New(cfg)
|
||||
defer throttler.Stop()
|
||||
|
||||
gw := withEC2Verifier(gwfix.WithDeps(&GatewayConfig{DisableLogging: true}, gwfix.Deps{IAM: allowAllIAMService()}))
|
||||
gw := withEC2Verifier(gwfix.WithDeps(&Config{DisableLogging: true}, gwfix.Deps{IAM: allowAllIAMService()}))
|
||||
|
||||
inner := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
@@ -1037,7 +1037,7 @@ func TestThrottleMiddleware_PerActionIsolation(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRequest_ClusterUnavailableNilConn_EC2(t *testing.T) {
|
||||
gw := withEC2Verifier(gwfix.WithDeps(&GatewayConfig{DisableLogging: true}, gwfix.Deps{IAM: allowAllIAMService()}))
|
||||
gw := withEC2Verifier(gwfix.WithDeps(&Config{DisableLogging: true}, gwfix.Deps{IAM: allowAllIAMService()}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/", nil)
|
||||
ctx := reqctx.WithService(req.Context(), "ec2")
|
||||
@@ -1057,7 +1057,7 @@ func TestRequest_ClusterUnavailableNilConn_EC2(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRequest_ClusterUnavailableNilConn_IAM(t *testing.T) {
|
||||
gw := withIAM(withEC2Verifier(gwfix.WithDeps(&GatewayConfig{DisableLogging: true}, gwfix.Deps{IAM: allowAllIAMService()})))
|
||||
gw := withIAM(withEC2Verifier(gwfix.WithDeps(&Config{DisableLogging: true}, gwfix.Deps{IAM: allowAllIAMService()})))
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/", nil)
|
||||
ctx := reqctx.WithService(req.Context(), "iam")
|
||||
@@ -1080,7 +1080,7 @@ func TestRequest_ClusterUnavailableClosedConn(t *testing.T) {
|
||||
require.NoError(t, err)
|
||||
nc.Close()
|
||||
|
||||
gw := withEC2Verifier(gwfix.WithDeps(&GatewayConfig{DisableLogging: true}, gwfix.Deps{NATS: nc, IAM: allowAllIAMService()}))
|
||||
gw := withEC2Verifier(gwfix.WithDeps(&Config{DisableLogging: true}, gwfix.Deps{NATS: nc, IAM: allowAllIAMService()}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/", nil)
|
||||
ctx := reqctx.WithService(req.Context(), "ec2")
|
||||
@@ -1094,7 +1094,7 @@ func TestRequest_ClusterUnavailableClosedConn(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestWriteSigV4Error_BedrockEmitsJSON(t *testing.T) {
|
||||
gw := withServices(&GatewayConfig{DisableLogging: true}, ochreawsapi.NewRegistration(ochreawsapi.Deps{}))
|
||||
gw := withServices(&Config{DisableLogging: true}, ochreawsapi.NewRegistration(ochreawsapi.Deps{}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/model/meta.llama3-2-1b-instruct-v1%3A0/converse", nil)
|
||||
req = req.WithContext(reqctx.WithService(req.Context(), "bedrock"))
|
||||
+53
-53
@@ -1,4 +1,4 @@
|
||||
package gateway
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -66,7 +66,7 @@ import (
|
||||
|
||||
// withACM registers the ACM control plane on gw backed by deps, as the awsgw
|
||||
// role composes it. An unset deps.NATS takes gw.NATSConn.
|
||||
func withACM(gw *GatewayConfig, deps acmawsapi.Deps) *GatewayConfig {
|
||||
func withACM(gw *Config, deps acmawsapi.Deps) *Config {
|
||||
if deps.NATS == nil {
|
||||
deps.NATS = gwfix.DepsOf(gw).NATS
|
||||
}
|
||||
@@ -81,13 +81,13 @@ func withACM(gw *GatewayConfig, deps acmawsapi.Deps) *GatewayConfig {
|
||||
// acmPipelineApp wires SigV4AuthMiddleware -> Request as SetupRoutes does, on
|
||||
// live test NATS, with docs as every principal's policy. The region is
|
||||
// gwfix.Region because gwfix.SignRequestAs always signs for it.
|
||||
func acmPipelineApp(t *testing.T, docs []iamdomain.PolicyDocument) (*GatewayConfig, http.Handler) {
|
||||
func acmPipelineApp(t *testing.T, docs []iamdomain.PolicyDocument) (*Config, http.Handler) {
|
||||
t.Helper()
|
||||
encryptedSecret, err := iamdomain.EncryptSecret(gwfix.SecretKey, gwfix.MasterKey)
|
||||
require.NoError(t, err)
|
||||
_, nc := testutil.StartTestNATS(t)
|
||||
|
||||
gw := withVerifier(gwfix.WithDeps(&GatewayConfig{
|
||||
gw := withVerifier(gwfix.WithDeps(&Config{
|
||||
DisableLogging: true,
|
||||
Region: gwfix.Region,
|
||||
}, gwfix.Deps{NATS: nc, IAM: &gwfix.PolicyIAM{
|
||||
@@ -166,7 +166,7 @@ type rdsPolicyProbe struct{ consulted int }
|
||||
|
||||
// A gateway whose IAM service answers with policies, so the RDS dispatch path
|
||||
// runs a real evaluation for either principal branch.
|
||||
func newRDSPolicyGateway(policies []iamdomain.PolicyDocument) (*GatewayConfig, *rdsPolicyProbe) {
|
||||
func newRDSPolicyGateway(policies []iamdomain.PolicyDocument) (*Config, *rdsPolicyProbe) {
|
||||
return newRDSGatewayWithResolver(func(_, _ string) ([]iamdomain.PolicyDocument, error) {
|
||||
return policies, nil
|
||||
})
|
||||
@@ -187,12 +187,12 @@ const rdsTestAccountID = "123456789012"
|
||||
// rdsRegistrationInventory is the inventory the RDS registration declares to a
|
||||
// registry, keyed as AWSOperationInventory expects.
|
||||
func rdsRegistrationInventory() map[string]dispatch.Inventory {
|
||||
return withRDS(&GatewayConfig{}).Services.Inventory()
|
||||
return withRDS(&Config{}).Services.Inventory()
|
||||
}
|
||||
|
||||
// serveRDS dispatches r to the registered RDS service exactly as Request does,
|
||||
// registering it first from the fields gw holds when the test has not.
|
||||
func (gw *GatewayConfig) serveRDS(w http.ResponseWriter, r *http.Request) error {
|
||||
func (gw *Config) serveRDS(w http.ResponseWriter, r *http.Request) error {
|
||||
if gw.Services == nil {
|
||||
withRDS(gw)
|
||||
}
|
||||
@@ -205,7 +205,7 @@ func (gw *GatewayConfig) serveRDS(w http.ResponseWriter, r *http.Request) error
|
||||
|
||||
func newRDSGatewayWithResolver(
|
||||
resolve func(accountID, name string) ([]iamdomain.PolicyDocument, error),
|
||||
) (*GatewayConfig, *rdsPolicyProbe) {
|
||||
) (*Config, *rdsPolicyProbe) {
|
||||
probe := &rdsPolicyProbe{}
|
||||
counted := func(accountID, name string) ([]iamdomain.PolicyDocument, error) {
|
||||
probe.consulted++
|
||||
@@ -213,7 +213,7 @@ func newRDSGatewayWithResolver(
|
||||
}
|
||||
// Both branches are wired: an assumed-role principal resolves by role, and a
|
||||
// test that only stubbed users would miss it entirely.
|
||||
return withRealAuth(gwfix.WithDeps(&GatewayConfig{
|
||||
return withRealAuth(gwfix.WithDeps(&Config{
|
||||
DisableLogging: true,
|
||||
Region: "ap-southeast-2",
|
||||
}, gwfix.Deps{IAM: &gwfix.PolicyIAM{
|
||||
@@ -224,7 +224,7 @@ func newRDSGatewayWithResolver(
|
||||
|
||||
// withRDS registers RDS on gw with the NATS connection and node count it holds,
|
||||
// as the awsgw role composes it. Set both on gw before calling.
|
||||
func withRDS(gw *GatewayConfig) *GatewayConfig {
|
||||
func withRDS(gw *Config) *Config {
|
||||
deps := rdsawsapi.Deps{NATS: gwfix.DepsOf(gw).NATS, ExpectedNodes: gwfix.DepsOf(gw).Nodes}
|
||||
if quotaFor(gw) != nil {
|
||||
deps.Quota = quotaFor(gw)
|
||||
@@ -239,7 +239,7 @@ func withRDS(gw *GatewayConfig) *GatewayConfig {
|
||||
|
||||
// withELBv2 registers ELBv2 on gw with the NATS connection and quota it holds,
|
||||
// as the awsgw role composes it. Set both on gw before calling.
|
||||
func withELBv2(gw *GatewayConfig) *GatewayConfig {
|
||||
func withELBv2(gw *Config) *Config {
|
||||
b := dispatch.NewBuilder()
|
||||
reg := elbv2awsapi.NewRegistration(elbv2awsapi.Deps{NATS: gwfix.DepsOf(gw).NATS, Quota: quotaFor(gw)})
|
||||
if err := b.Register(reg); err != nil {
|
||||
@@ -251,7 +251,7 @@ func withELBv2(gw *GatewayConfig) *GatewayConfig {
|
||||
|
||||
// serveAccountLimits runs DescribeAccountLimits through the service with live
|
||||
// NATS, so the request is dispatched rather than stopped at the NATS guard.
|
||||
func serveAccountLimits(t *testing.T, gw *GatewayConfig, accountID, body string) ([]byte, error) {
|
||||
func serveAccountLimits(t *testing.T, gw *Config, accountID, body string) ([]byte, error) {
|
||||
t.Helper()
|
||||
_, nc := testutil.StartTestNATS(t)
|
||||
gwfix.DepsOf(gw).NATS = nc
|
||||
@@ -277,7 +277,7 @@ type accountLimitsResponse struct {
|
||||
|
||||
// describeAccountLimits decodes the XML DescribeAccountLimits writes, keyed by
|
||||
// limit name.
|
||||
func describeAccountLimits(t *testing.T, gw *GatewayConfig, accountID string) map[string]string {
|
||||
func describeAccountLimits(t *testing.T, gw *Config, accountID string) map[string]string {
|
||||
t.Helper()
|
||||
|
||||
body, err := serveAccountLimits(t, gw, accountID, "Action=DescribeAccountLimits")
|
||||
@@ -296,13 +296,13 @@ func describeAccountLimits(t *testing.T, gw *GatewayConfig, accountID string) ma
|
||||
|
||||
// elbv2PipelineApp wires SigV4AuthMiddleware -> Request as SetupRoutes does, on
|
||||
// live test NATS, with docs as every principal's policy.
|
||||
func elbv2PipelineApp(t *testing.T, docs []iamdomain.PolicyDocument, quota *admissionquota.Service) (*GatewayConfig, http.Handler) {
|
||||
func elbv2PipelineApp(t *testing.T, docs []iamdomain.PolicyDocument, quota *admissionquota.Service) (*Config, http.Handler) {
|
||||
t.Helper()
|
||||
encryptedSecret, err := iamdomain.EncryptSecret(gwfix.SecretKey, gwfix.MasterKey)
|
||||
require.NoError(t, err)
|
||||
_, nc := testutil.StartTestNATS(t)
|
||||
|
||||
gw := withVerifier(gwfix.WithDeps(&GatewayConfig{
|
||||
gw := withVerifier(gwfix.WithDeps(&Config{
|
||||
DisableLogging: true,
|
||||
Region: gwfix.Region,
|
||||
}, gwfix.Deps{NATS: nc, IAM: &gwfix.PolicyIAM{
|
||||
@@ -347,7 +347,7 @@ func signELBv2Request(t *testing.T, body string) *http.Request {
|
||||
// serveELBv2 dispatches r to the registered ELBv2 service exactly as Request
|
||||
// does past its NATS gate, registering it first from the fields gw holds when
|
||||
// the test has not.
|
||||
func (gw *GatewayConfig) serveELBv2(w http.ResponseWriter, r *http.Request) error {
|
||||
func (gw *Config) serveELBv2(w http.ResponseWriter, r *http.Request) error {
|
||||
if gw.Services == nil {
|
||||
withELBv2(gw)
|
||||
}
|
||||
@@ -368,7 +368,7 @@ func elbv2Request(accountID, body string) *http.Request {
|
||||
}
|
||||
|
||||
// withServices makes regs the gateway's registered services.
|
||||
func withServices(gw *GatewayConfig, regs ...dispatch.Registration) *GatewayConfig {
|
||||
func withServices(gw *Config, regs ...dispatch.Registration) *Config {
|
||||
b := dispatch.NewBuilder()
|
||||
for _, reg := range regs {
|
||||
if err := b.Register(reg); err != nil {
|
||||
@@ -385,13 +385,13 @@ func withServices(gw *GatewayConfig, regs ...dispatch.Registration) *GatewayConf
|
||||
}
|
||||
|
||||
// withIAM registers IAM alone on gw.
|
||||
func withIAM(gw *GatewayConfig) *GatewayConfig {
|
||||
func withIAM(gw *Config) *Config {
|
||||
return withServices(gw, iamRegistration(gw))
|
||||
}
|
||||
|
||||
// iamRegistration is the IAM registration over the IAMService, NATS connection
|
||||
// and node count gw holds, as the awsgw role composes it. Set them on gw first.
|
||||
func iamRegistration(gw *GatewayConfig) dispatch.Registration {
|
||||
func iamRegistration(gw *Config) dispatch.Registration {
|
||||
return iamawsapi.NewRegistration(iamawsapi.Deps{
|
||||
IAM: gwfix.DepsOf(gw).IAM,
|
||||
CountInstanceProfileAssociations: func(ctx context.Context, accountID, profileARN string) (int, error) {
|
||||
@@ -587,7 +587,7 @@ func (m *flexMockIAMService) UpdateAccessKey(accountID string, input *iam.Update
|
||||
|
||||
// IAM_Request dispatches r to the registered IAM service exactly as Request
|
||||
// does, registering it first from the fields gw holds when the test has not.
|
||||
func (gw *GatewayConfig) IAM_Request(w http.ResponseWriter, r *http.Request) error {
|
||||
func (gw *Config) IAM_Request(w http.ResponseWriter, r *http.Request) error {
|
||||
if gw.Services == nil {
|
||||
withIAM(gw)
|
||||
}
|
||||
@@ -787,7 +787,7 @@ func (svc *echoIAMService) UpdateAssumeRolePolicy(accountID string, input *iam.U
|
||||
|
||||
// setupIAMRequestHandler wires an http.Handler for IAM_Request with injected SigV4 context values.
|
||||
func setupIAMRequestHandler(svc iamdomain.IAMService) http.Handler {
|
||||
gw := gwfix.WithDeps(&GatewayConfig{
|
||||
gw := gwfix.WithDeps(&Config{
|
||||
DisableLogging: true,
|
||||
Credentials: credverify.New(svc, nil),
|
||||
}, gwfix.Deps{IAM: svc})
|
||||
@@ -882,27 +882,27 @@ type iamExample struct {
|
||||
|
||||
// stsRegistration is the STS registration over the IAMService and STSService
|
||||
// gw holds, as the awsgw role composes it. Set them on gw first.
|
||||
func stsRegistration(gw *GatewayConfig) dispatch.Registration {
|
||||
func stsRegistration(gw *Config) dispatch.Registration {
|
||||
return stsawsapi.NewRegistration(stsawsapi.Deps{STS: gwfix.DepsOf(gw).STS, IAM: gwfix.DepsOf(gw).IAM})
|
||||
}
|
||||
|
||||
// withIAMAndSTS registers IAM and STS together on gw, with the unsigned
|
||||
// web-identity capability, as awsgw composes them.
|
||||
func withIAMAndSTS(gw *GatewayConfig) *GatewayConfig {
|
||||
func withIAMAndSTS(gw *Config) *Config {
|
||||
gw.PreAuth = append(gw.PreAuth, stsawsapi.NewPreAuth(stsawsapi.Deps{STS: gwfix.DepsOf(gw).STS, IAM: gwfix.DepsOf(gw).IAM}))
|
||||
return withServices(gw, iamRegistration(gw), stsRegistration(gw))
|
||||
}
|
||||
|
||||
// withSTS registers STS alone on gw and composes its unsigned web-identity
|
||||
// capability, as awsgw does.
|
||||
func withSTS(gw *GatewayConfig) *GatewayConfig {
|
||||
func withSTS(gw *Config) *Config {
|
||||
gw.PreAuth = append(gw.PreAuth, stsawsapi.NewPreAuth(stsawsapi.Deps{STS: gwfix.DepsOf(gw).STS, IAM: gwfix.DepsOf(gw).IAM}))
|
||||
return withServices(gw, stsRegistration(gw))
|
||||
}
|
||||
|
||||
// STS_Request dispatches r to the registered STS service exactly as Request
|
||||
// does, registering it first from the fields gw holds when the test has not.
|
||||
func (gw *GatewayConfig) STS_Request(w http.ResponseWriter, r *http.Request) error {
|
||||
func (gw *Config) STS_Request(w http.ResponseWriter, r *http.Request) error {
|
||||
if gw.Services == nil {
|
||||
withSTS(gw)
|
||||
}
|
||||
@@ -1069,7 +1069,7 @@ func setupSTSRequestHandler(p stsRequestParams) http.Handler {
|
||||
if p.iamSvc == nil {
|
||||
p.iamSvc = &flexMockIAMService{}
|
||||
}
|
||||
gw := withRealAuth(gwfix.WithDeps(&GatewayConfig{
|
||||
gw := withRealAuth(gwfix.WithDeps(&Config{
|
||||
DisableLogging: true,
|
||||
}, gwfix.Deps{STS: p.stsSvc, IAM: p.iamSvc}))
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -1146,19 +1146,19 @@ const taggingTargetPrefix = "ResourceGroupsTaggingAPI_20170126."
|
||||
|
||||
// withTagging registers the Resource Groups Tagging API on gw backed by agg, as
|
||||
// the awsgw role composes it.
|
||||
func withTagging(gw *GatewayConfig, agg *tagging.Aggregator) *GatewayConfig {
|
||||
func withTagging(gw *Config, agg *tagging.Aggregator) *Config {
|
||||
return withServices(gw, taggingRegistration(agg))
|
||||
}
|
||||
|
||||
// taggingPipelineApp wires SigV4AuthMiddleware -> Request as SetupRoutes does,
|
||||
// on live test NATS, with docs as every principal's policy.
|
||||
func taggingPipelineApp(t *testing.T, docs []iamdomain.PolicyDocument, agg *tagging.Aggregator) (*GatewayConfig, http.Handler) {
|
||||
func taggingPipelineApp(t *testing.T, docs []iamdomain.PolicyDocument, agg *tagging.Aggregator) (*Config, http.Handler) {
|
||||
t.Helper()
|
||||
encryptedSecret, err := iamdomain.EncryptSecret(gwfix.SecretKey, gwfix.MasterKey)
|
||||
require.NoError(t, err)
|
||||
_, nc := testutil.StartTestNATS(t)
|
||||
|
||||
gw := withVerifier(gwfix.WithDeps(&GatewayConfig{
|
||||
gw := withVerifier(gwfix.WithDeps(&Config{
|
||||
DisableLogging: true,
|
||||
Region: gwfix.Region,
|
||||
}, gwfix.Deps{NATS: nc, IAM: &gwfix.PolicyIAM{
|
||||
@@ -1199,7 +1199,7 @@ func signTaggingRequest(t *testing.T, target string, body []byte) *http.Request
|
||||
|
||||
// newTaggingCBGateway serves ec2.CreateTags from a real TagsServiceImpl
|
||||
// over a fresh JetStream bucket, with no legacy object store.
|
||||
func newTaggingCBGateway(t *testing.T) (*GatewayConfig, *ec2tags.TagsServiceImpl) {
|
||||
func newTaggingCBGateway(t *testing.T) (*Config, *ec2tags.TagsServiceImpl) {
|
||||
t.Helper()
|
||||
_, nc, js := testutil.StartTestJetStream(t)
|
||||
kv, err := ec2tags.GetOrCreateTagsBucket(t.Context(), js)
|
||||
@@ -1217,11 +1217,11 @@ func newTaggingCBGateway(t *testing.T) (*GatewayConfig, *ec2tags.TagsServiceImpl
|
||||
})
|
||||
})
|
||||
|
||||
gw := withVerifier(gwfix.WithDeps(&GatewayConfig{DisableLogging: true, Region: authzRegion}, gwfix.Deps{IAM: allowAllIAMService(), NATS: nc}))
|
||||
gw := withVerifier(gwfix.WithDeps(&Config{DisableLogging: true, Region: authzRegion}, gwfix.Deps{IAM: allowAllIAMService(), NATS: nc}))
|
||||
return gw, svc
|
||||
}
|
||||
|
||||
func requireEC2OK(t *testing.T, gw *GatewayConfig, body string) {
|
||||
func requireEC2OK(t *testing.T, gw *Config, body string) {
|
||||
t.Helper()
|
||||
w := serveEC2(gw, body)
|
||||
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
|
||||
@@ -1255,13 +1255,13 @@ func centralTagCount(t *testing.T, svc *ec2tags.TagsServiceImpl, resourceID stri
|
||||
// SetupRoutes does, on live test NATS, with docs as every principal's policy.
|
||||
// accountID owns the signing key; the region is gwfix.Region because
|
||||
// gwfix.SignRequestAs always signs for it.
|
||||
func operatorPipelineApp(t *testing.T, accountID string, docs []iamdomain.PolicyDocument, mutate func(*GatewayConfig)) (*GatewayConfig, http.Handler) {
|
||||
func operatorPipelineApp(t *testing.T, accountID string, docs []iamdomain.PolicyDocument, mutate func(*Config)) (*Config, http.Handler) {
|
||||
t.Helper()
|
||||
encryptedSecret, err := iamdomain.EncryptSecret(gwfix.SecretKey, gwfix.MasterKey)
|
||||
require.NoError(t, err)
|
||||
_, nc := testutil.StartTestNATS(t)
|
||||
|
||||
gw := gwfix.WithDeps(&GatewayConfig{
|
||||
gw := gwfix.WithDeps(&Config{
|
||||
DisableLogging: true,
|
||||
Region: gwfix.Region,
|
||||
}, gwfix.Deps{NATS: nc, IAM: &gwfix.PolicyIAM{
|
||||
@@ -1361,7 +1361,7 @@ var spinifexAdminActions = func() map[string]bool {
|
||||
|
||||
// withOperator registers the spinifex Query service on gw from the fields gw
|
||||
// holds, as awsgw composes it.
|
||||
func withOperator(gw *GatewayConfig) *GatewayConfig {
|
||||
func withOperator(gw *Config) *Config {
|
||||
b := dispatch.NewBuilder()
|
||||
deps := operatoradmin.Deps{
|
||||
Version: "v9.9.9-test",
|
||||
@@ -1494,14 +1494,14 @@ func decodeJSONError(t *testing.T, resp *http.Response) jsonErrorEnvelope {
|
||||
// ecrRegistrationInventory is the inventory the ECR registration declares to
|
||||
// a registry, keyed as AWSOperationInventory expects.
|
||||
func ecrRegistrationInventory() map[string]dispatch.Inventory {
|
||||
inventory := withECR(&GatewayConfig{}, awsapi.Deps{}).Services.Inventory()
|
||||
maps.Copy(inventory, withContainers(&GatewayConfig{}).Services.Inventory())
|
||||
inventory := withECR(&Config{}, awsapi.Deps{}).Services.Inventory()
|
||||
maps.Copy(inventory, withContainers(&Config{}).Services.Inventory())
|
||||
return inventory
|
||||
}
|
||||
|
||||
// serveECR dispatches r to gw's registered ECR service exactly as Request does
|
||||
// past its NATS gate, first registering one with no capabilities if needed.
|
||||
func (gw *GatewayConfig) serveECR(w http.ResponseWriter, r *http.Request) error {
|
||||
func (gw *Config) serveECR(w http.ResponseWriter, r *http.Request) error {
|
||||
entry, ok := gw.registered(awsapi.ServiceName)
|
||||
if !ok {
|
||||
withECR(gw, awsapi.Deps{})
|
||||
@@ -1518,7 +1518,7 @@ type jsonErrorEnvelope struct {
|
||||
|
||||
// withECR registers the ECR control plane on gw backed by deps, as the awsgw
|
||||
// role composes it. An unset deps.NATS takes gw.NATSConn.
|
||||
func withECR(gw *GatewayConfig, deps awsapi.Deps) *GatewayConfig {
|
||||
func withECR(gw *Config, deps awsapi.Deps) *Config {
|
||||
if deps.NATS == nil {
|
||||
deps.NATS = gwfix.DepsOf(gw).NATS
|
||||
}
|
||||
@@ -1533,13 +1533,13 @@ func withECR(gw *GatewayConfig, deps awsapi.Deps) *GatewayConfig {
|
||||
// ecrPipelineApp wires SigV4AuthMiddleware -> Request as SetupRoutes does, on
|
||||
// live test NATS so Request dispatches. docs is every principal's policy; the
|
||||
// region is gwfix.Region because gwfix.SignRequestAs always signs for it.
|
||||
func ecrPipelineApp(t *testing.T, docs []iamdomain.PolicyDocument) (*GatewayConfig, http.Handler) {
|
||||
func ecrPipelineApp(t *testing.T, docs []iamdomain.PolicyDocument) (*Config, http.Handler) {
|
||||
t.Helper()
|
||||
encryptedSecret, err := iamdomain.EncryptSecret(gwfix.SecretKey, gwfix.MasterKey)
|
||||
require.NoError(t, err)
|
||||
_, nc := testutil.StartTestNATS(t)
|
||||
|
||||
gw := withVerifier(gwfix.WithDeps(&GatewayConfig{
|
||||
gw := withVerifier(gwfix.WithDeps(&Config{
|
||||
DisableLogging: true,
|
||||
Region: gwfix.Region,
|
||||
}, gwfix.Deps{NATS: nc, IAM: &gwfix.PolicyIAM{
|
||||
@@ -1727,13 +1727,13 @@ func (m *ecrMockIAMService) LookupAccessKey(accessKeyID string) (*iamdomain.Acce
|
||||
return ak, nil
|
||||
}
|
||||
|
||||
func (gw *GatewayConfig) serveECS(w http.ResponseWriter, r *http.Request) error {
|
||||
func (gw *Config) serveECS(w http.ResponseWriter, r *http.Request) error {
|
||||
return gw.serveRegistered(ecsawsapi.ServiceName, w, r)
|
||||
}
|
||||
|
||||
// withContainers registers ECS and EKS on gw with the NATS connection and
|
||||
// mock IAM service it holds, as the awsgw role composes them.
|
||||
func withContainers(gw *GatewayConfig) *GatewayConfig {
|
||||
func withContainers(gw *Config) *Config {
|
||||
b := dispatch.NewBuilder()
|
||||
if err := b.Register(ecsawsapi.NewRegistration(ecsawsapi.Deps{NATS: gwfix.DepsOf(gw).NATS})); err != nil {
|
||||
panic(err)
|
||||
@@ -1774,7 +1774,7 @@ const testECSPassRoleTargetARN = "arn:aws:iam::123456789012:role/ecs-task-role"
|
||||
|
||||
const ecsPipelineTarget = "AmazonEC2ContainerServiceV20141113."
|
||||
|
||||
func (gw *GatewayConfig) serveRegistered(service string, w http.ResponseWriter, r *http.Request) error {
|
||||
func (gw *Config) serveRegistered(service string, w http.ResponseWriter, r *http.Request) error {
|
||||
if gw.Services == nil {
|
||||
withContainers(gw)
|
||||
}
|
||||
@@ -1832,7 +1832,7 @@ const (
|
||||
|
||||
// serveEC2 runs a request through EC2_Request and, on error, ErrorHandler, so
|
||||
// the assertion sees the status and body a client would.
|
||||
func serveEC2(gw *GatewayConfig, body string) *httptest.ResponseRecorder {
|
||||
func serveEC2(gw *Config, body string) *httptest.ResponseRecorder {
|
||||
w := httptest.NewRecorder()
|
||||
r := setupEC2Request(body, authzAccountID)
|
||||
if err := gw.EC2_Request(w, r); err != nil {
|
||||
@@ -1844,7 +1844,7 @@ func serveEC2(gw *GatewayConfig, body string) *httptest.ResponseRecorder {
|
||||
// ec2Registration composes the EC2 registration from the NATS connection, IAM
|
||||
// service and node count gw holds, as the awsgw role does. mutate adjusts the
|
||||
// rest.
|
||||
func ec2Registration(gw *GatewayConfig, mutate ...func(*ec2awsapi.Deps)) dispatch.Registration {
|
||||
func ec2Registration(gw *Config, mutate ...func(*ec2awsapi.Deps)) dispatch.Registration {
|
||||
deps := ec2awsapi.Deps{
|
||||
NATS: gwfix.DepsOf(gw).NATS,
|
||||
IAM: gwfix.DepsOf(gw).IAM,
|
||||
@@ -1860,12 +1860,12 @@ func ec2Registration(gw *GatewayConfig, mutate ...func(*ec2awsapi.Deps)) dispatc
|
||||
|
||||
// withEC2Verifier is withVerifier for a test that signs as EC2: the service
|
||||
// must be registered for the auth gate to serve its credential scope.
|
||||
func withEC2Verifier(gw *GatewayConfig) *GatewayConfig {
|
||||
func withEC2Verifier(gw *Config) *Config {
|
||||
return withEC2(withVerifier(gw))
|
||||
}
|
||||
|
||||
// withEC2 registers EC2 alone on gw.
|
||||
func withEC2(gw *GatewayConfig, mutate ...func(*ec2awsapi.Deps)) *GatewayConfig {
|
||||
func withEC2(gw *Config, mutate ...func(*ec2awsapi.Deps)) *Config {
|
||||
return withServices(gw, ec2Registration(gw, mutate...))
|
||||
}
|
||||
|
||||
@@ -1899,13 +1899,13 @@ func assertDenied(t *testing.T, err error) {
|
||||
// dispatchEC2 drives the gateway with no NATS connection. A permitted request
|
||||
// therefore reaches the handler and fails there, which is what proves the policy
|
||||
// check ran ahead of the resource existing at all.
|
||||
func dispatchEC2(t *testing.T, gw *GatewayConfig, body string) error {
|
||||
func dispatchEC2(t *testing.T, gw *Config, body string) error {
|
||||
t.Helper()
|
||||
return gw.EC2_Request(httptest.NewRecorder(), setupEC2Request(body, authzAccountID))
|
||||
}
|
||||
|
||||
func ec2RegistrationInventory() map[string]dispatch.Inventory {
|
||||
return withEC2(&GatewayConfig{}).Services.Inventory()
|
||||
return withEC2(&Config{}).Services.Inventory()
|
||||
}
|
||||
|
||||
// profileIAMService resolves every instance profile to one holding role app, or
|
||||
@@ -1930,7 +1930,7 @@ func (s *profileIAMService) ResolveInstanceProfile(accountID, _ string) (*iamdom
|
||||
// EC2_Request dispatches r to the registered EC2 service exactly as Request
|
||||
// does past its NATS gate, registering it first from the fields gw holds when
|
||||
// the test has not.
|
||||
func (gw *GatewayConfig) EC2_Request(w http.ResponseWriter, r *http.Request) error {
|
||||
func (gw *Config) EC2_Request(w http.ResponseWriter, r *http.Request) error {
|
||||
if _, ok := gw.registered(ec2awsapi.ServiceName); !ok {
|
||||
withEC2(gw)
|
||||
}
|
||||
@@ -1978,7 +1978,7 @@ func ec2Pipeline(t *testing.T, mutate func(*ec2PipelineEnv), statements ...iamdo
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
daemon := newEC2Daemon(t, nc)
|
||||
|
||||
cfg := gwfix.WithDeps(&GatewayConfig{
|
||||
cfg := gwfix.WithDeps(&Config{
|
||||
DisableLogging: true,
|
||||
Region: gwfix.Region,
|
||||
}, gwfix.Deps{NATS: nc, IAM: svc, Nodes: 1})
|
||||
@@ -2145,6 +2145,6 @@ func (d *ec2Daemon) reply(subject, body string) {
|
||||
|
||||
// activeNodes answers the configured node count, which is what discovery falls
|
||||
// back to when no daemon answers; no gateway test runs a responder.
|
||||
func activeNodes(gw *GatewayConfig) func(context.Context) int {
|
||||
func activeNodes(gw *Config) func(context.Context) int {
|
||||
return func(context.Context) int { return gwfix.DepsOf(gw).Nodes }
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package gateway
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
+3
-3
@@ -1,4 +1,4 @@
|
||||
package gateway
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"errors"
|
||||
@@ -164,7 +164,7 @@ func TestIAMRequest_ListUsers_Success(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestIAMRequest_MissingAccountID(t *testing.T) {
|
||||
gw := gwfix.WithDeps(&GatewayConfig{
|
||||
gw := gwfix.WithDeps(&Config{
|
||||
DisableLogging: true,
|
||||
Credentials: credverify.New(&flexMockIAMService{}, nil),
|
||||
}, gwfix.Deps{IAM: &flexMockIAMService{}})
|
||||
@@ -187,7 +187,7 @@ func TestIAMRequest_MissingAccountID(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestIAMRequest_NilService(t *testing.T) {
|
||||
gw := gwfix.WithDeps(&GatewayConfig{
|
||||
gw := gwfix.WithDeps(&Config{
|
||||
DisableLogging: true,
|
||||
Credentials: credverify.New(nil, nil),
|
||||
}, gwfix.Deps{IAM: nil})
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
package gateway
|
||||
package pipeline
|
||||
|
||||
//test:in-package — pins the route order of the unauthenticated mounts against
|
||||
// the SigV4 group and the throttle, through the real router.
|
||||
@@ -63,7 +63,7 @@ func TestMounts_AdminSitsBehindSigV4(t *testing.T) {
|
||||
// Without an admin registration name the surface refuses, rather than
|
||||
// dispatching to whatever service the empty name selects.
|
||||
func TestAdmin_UnconfiguredSurfaceAnswersInternalError(t *testing.T) {
|
||||
gw := withServices(&GatewayConfig{DisableLogging: true})
|
||||
gw := withServices(&Config{DisableLogging: true})
|
||||
rec := adminRequest(gw, "CreateAccount", `{}`)
|
||||
assert.Equal(t, http.StatusInternalServerError, rec.Code)
|
||||
assert.Equal(t, awserrors.ErrorInternalError, decodeAdminError(t, rec).Error.Code)
|
||||
+6
-4
@@ -1,6 +1,7 @@
|
||||
package gateway
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"github.com/mulgadc/spinifex/spinifex/ingress/aws/dispatch"
|
||||
"maps"
|
||||
"testing"
|
||||
|
||||
@@ -12,7 +13,7 @@ func TestAWSOperationInventoryClassifiesDispatchers(t *testing.T) {
|
||||
registered := ecrRegistrationInventory()
|
||||
maps.Copy(registered, rdsRegistrationInventory())
|
||||
maps.Copy(registered, ec2RegistrationInventory())
|
||||
inventory := AWSOperationInventory(registered)
|
||||
inventory := dispatch.AWSOperationInventory(registered)
|
||||
|
||||
require.Contains(t, inventory["ec2"].Registered, "DescribeInstances")
|
||||
require.NotContains(t, inventory["ec2"].Stubbed, "DescribeInstances")
|
||||
@@ -40,7 +41,7 @@ func TestAWSOperationInventoryClassifiesDispatchers(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAWSOperationInventoryMeasuresEKSAgainstTheModel(t *testing.T) {
|
||||
inventory := AWSOperationInventory(ecrRegistrationInventory())
|
||||
inventory := dispatch.AWSOperationInventory(ecrRegistrationInventory())
|
||||
|
||||
eks, err := awsmodel.CompareOperations(awsmodel.EKS, awsmodel.DispatchInventory{
|
||||
Registered: inventory["eks"].Registered,
|
||||
@@ -54,6 +55,7 @@ func TestAWSOperationInventoryMeasuresEKSAgainstTheModel(t *testing.T) {
|
||||
require.ElementsMatch(t, []string{
|
||||
"GetRecoveryDirective",
|
||||
"ListInternalAddons",
|
||||
"ListInternalWorkers",
|
||||
"PublishInternal",
|
||||
"WebhookTokenReview",
|
||||
}, eks.Extra)
|
||||
@@ -63,7 +65,7 @@ func TestAWSOperationInventoryMeasuresEKSAgainstTheModel(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestAWSOperationInventoryStatusesAreDisjointSubsets(t *testing.T) {
|
||||
for service, inventory := range AWSOperationInventory(ecrRegistrationInventory()) {
|
||||
for service, inventory := range dispatch.AWSOperationInventory(ecrRegistrationInventory()) {
|
||||
t.Run(service, func(t *testing.T) {
|
||||
registered := stringSet(inventory.Registered)
|
||||
for _, action := range inventory.Stubbed {
|
||||
+5
-5
@@ -1,4 +1,4 @@
|
||||
package gateway
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
@@ -17,7 +17,7 @@ import (
|
||||
// An empty or non-JSON body is InvalidRequest on every body-reading method
|
||||
// that parses one, and MissingParameter where a required field is absent.
|
||||
func TestOperatorAdmin_BodyErrors(t *testing.T) {
|
||||
_, app := operatorPipelineApp(t, awsidentifiers.DefaultAccountID(), allowAllOperatorDocs(), func(gw *GatewayConfig) {
|
||||
_, app := operatorPipelineApp(t, awsidentifiers.DefaultAccountID(), allowAllOperatorDocs(), func(gw *Config) {
|
||||
withQuota(gw, admissionquota.New(admissionquota.Limits{Enabled: true}, nil))
|
||||
})
|
||||
|
||||
@@ -71,7 +71,7 @@ func TestOperatorAdmin_EveryGateDeniesWithTheSameAnswer(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestOperatorAdmin_GetAccountQuota_DefaultsComeFromConfig(t *testing.T) {
|
||||
_, app := operatorPipelineApp(t, awsidentifiers.DefaultAccountID(), allowAllOperatorDocs(), func(gw *GatewayConfig) {
|
||||
_, app := operatorPipelineApp(t, awsidentifiers.DefaultAccountID(), allowAllOperatorDocs(), func(gw *Config) {
|
||||
withQuota(gw, admissionquota.New(admissionquota.Limits{Enabled: true}, nil))
|
||||
})
|
||||
resp := adminCall(t, app, http.MethodPost, "GetAccountQuota", "spinifex", `{"accountId":"000000000002"}`)
|
||||
@@ -102,7 +102,7 @@ func TestOperatorAdmin_ListAccounts_JSONSuccess(t *testing.T) {
|
||||
// Unlike the Query surface, /admin authorizes first and only then checks the
|
||||
// cluster, so NATS down is visible only to an authorized caller.
|
||||
func TestOperatorAdmin_NATSDownAnswers503OnlyAfterAuthorization(t *testing.T) {
|
||||
down := func(gw *GatewayConfig) { gwfix.DepsOf(gw).NATS = nil }
|
||||
down := func(gw *Config) { gwfix.DepsOf(gw).NATS = nil }
|
||||
|
||||
_, app := operatorPipelineApp(t, awsidentifiers.DefaultAccountID(), allowAllOperatorDocs(), down)
|
||||
resp := adminCall(t, app, http.MethodPost, "ListAccounts", "spinifex", `{}`)
|
||||
@@ -228,7 +228,7 @@ func TestOperatorQuery_ModelAccessWithoutStoreIsServerInternal(t *testing.T) {
|
||||
// The cluster-unavailable check is in the shared Request path, ahead of
|
||||
// authorization, so even a caller with no grant sees 503 when NATS is down.
|
||||
func TestOperatorQuery_NATSDownAnswers503BeforeAuthorization(t *testing.T) {
|
||||
_, app := operatorPipelineApp(t, authzAccountID, nil, func(gw *GatewayConfig) { gwfix.DepsOf(gw).NATS = nil })
|
||||
_, app := operatorPipelineApp(t, authzAccountID, nil, func(gw *Config) { gwfix.DepsOf(gw).NATS = nil })
|
||||
resp := doRequest(app, spinifexQuery(t, "GetVersion", nil))
|
||||
|
||||
require.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package gateway
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package gateway
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"github.com/mulgadc/spinifex/internal/testkit/gwfix"
|
||||
@@ -1,4 +1,4 @@
|
||||
package gateway
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package gateway
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
+5
-5
@@ -1,4 +1,4 @@
|
||||
package gateway
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -21,12 +21,12 @@ func quotaPtr(v int) *int { return &v }
|
||||
// longer holds one: the registration helpers read it back to compose Deps.
|
||||
var quotaByGateway sync.Map
|
||||
|
||||
func withQuota(gw *GatewayConfig, q *admissionquota.Service) *GatewayConfig {
|
||||
func withQuota(gw *Config, q *admissionquota.Service) *Config {
|
||||
quotaByGateway.Store(gw, q)
|
||||
return gw
|
||||
}
|
||||
|
||||
func quotaFor(gw *GatewayConfig) *admissionquota.Service {
|
||||
func quotaFor(gw *Config) *admissionquota.Service {
|
||||
q, _ := quotaByGateway.Load(gw)
|
||||
svc, _ := q.(*admissionquota.Service)
|
||||
return svc
|
||||
@@ -47,7 +47,7 @@ func (m *quotaIAMService) GetAccount(accountID string) (*iamdomain.Account, erro
|
||||
}
|
||||
|
||||
// quotaTestGateway wires a real quota Service over a real JetStream KV bucket.
|
||||
func quotaTestGateway(t *testing.T) (*GatewayConfig, string) {
|
||||
func quotaTestGateway(t *testing.T) (*Config, string) {
|
||||
t.Helper()
|
||||
const account = "000000000042"
|
||||
|
||||
@@ -65,7 +65,7 @@ func quotaTestGateway(t *testing.T) (*GatewayConfig, string) {
|
||||
quota.SetOverrides(kv)
|
||||
|
||||
svc := "aIAMService{known: account}
|
||||
return withQuota(withVerifier(gwfix.WithDeps(&GatewayConfig{
|
||||
return withQuota(withVerifier(gwfix.WithDeps(&Config{
|
||||
DisableLogging: true,
|
||||
}, gwfix.Deps{IAM: svc})), quota), account
|
||||
}
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
package gateway
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"github.com/mulgadc/spinifex/internal/testkit/gwfix"
|
||||
@@ -49,7 +49,7 @@ func setupTestAppWithRateLimiter(t *testing.T, accessKey, secretKey string, rl *
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(nc.Close)
|
||||
|
||||
gw := gwfix.WithDeps(&GatewayConfig{
|
||||
gw := gwfix.WithDeps(&Config{
|
||||
DisableLogging: true,
|
||||
Region: gwfix.Region,
|
||||
Credentials: credverify.New(mockSvc, nil),
|
||||
@@ -1,4 +1,4 @@
|
||||
package gateway
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -31,7 +31,7 @@ func TestRDSRequest_CreateDBInstance_EmptyMasterUserPasswordReturns400(t *testin
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = sub.Unsubscribe() })
|
||||
|
||||
gw := withVerifier(gwfix.WithDeps(&GatewayConfig{DisableLogging: true}, gwfix.Deps{NATS: nc, IAM: allowAllIAMService()}))
|
||||
gw := withVerifier(gwfix.WithDeps(&Config{DisableLogging: true}, gwfix.Deps{NATS: nc, IAM: allowAllIAMService()}))
|
||||
req := setupRDSRequest("Action=CreateDBInstance&DBInstanceIdentifier=orders-db&Engine=postgres&" +
|
||||
"DBInstanceClass=db.t3.medium&AllocatedStorage=20&MasterUsername=appuser&DBName=orders")
|
||||
|
||||
@@ -46,7 +46,7 @@ func TestRDSRequest_CreateDBInstance_EmptyMasterUserPasswordReturns400(t *testin
|
||||
|
||||
func TestRDSRequest_MalformedScalarReturnsHTTP400(t *testing.T) {
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
gw := withVerifier(gwfix.WithDeps(&GatewayConfig{DisableLogging: true}, gwfix.Deps{NATS: nc, IAM: allowAllIAMService()}))
|
||||
gw := withVerifier(gwfix.WithDeps(&Config{DisableLogging: true}, gwfix.Deps{NATS: nc, IAM: allowAllIAMService()}))
|
||||
for _, body := range []string{
|
||||
"Action=CreateDBInstance&AllocatedStorage=abc",
|
||||
"Action=CreateDBInstance&MultiAZ=yes",
|
||||
+4
-4
@@ -1,4 +1,4 @@
|
||||
package gateway
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"github.com/mulgadc/spinifex/internal/testkit/gwfix"
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
// admission. Only tests that assert a verdict which depends on gateway
|
||||
// internals (the middleware stash, the admin method gate, the account gate)
|
||||
// use it; every other gateway test runs on the fake verifier.
|
||||
func withRealAuth(gw *GatewayConfig) *GatewayConfig {
|
||||
func withRealAuth(gw *Config) *Config {
|
||||
withTestAdmin(gw)
|
||||
gw.Credentials = credverify.New(gwfix.DepsOf(gw).IAM, gwfix.DepsOf(gw).STS)
|
||||
gw.Policy = policyauth.New(gwfix.DepsOf(gw).IAM, policyauth.WithRetryBase(policyRetryBase))
|
||||
@@ -24,13 +24,13 @@ func withRealAuth(gw *GatewayConfig) *GatewayConfig {
|
||||
}
|
||||
|
||||
// withEC2RealAuth is withEC2Verifier on the production capabilities.
|
||||
func withEC2RealAuth(gw *GatewayConfig) *GatewayConfig {
|
||||
func withEC2RealAuth(gw *Config) *Config {
|
||||
return withEC2(withRealAuth(gw))
|
||||
}
|
||||
|
||||
// realReadiness reads the bus connection when asked, so a test that drops it
|
||||
// after wiring sees the cluster as unavailable.
|
||||
type realReadiness struct{ gw *GatewayConfig }
|
||||
type realReadiness struct{ gw *Config }
|
||||
|
||||
func (r realReadiness) Available() bool {
|
||||
return gwfix.DepsOf(r.gw).NATS != nil && clusterready.New(gwfix.DepsOf(r.gw).NATS).Available()
|
||||
+4
-4
@@ -5,7 +5,7 @@
|
||||
// the credential-scope Region against the configured one. They assert current
|
||||
// behaviour, not AWS parity.
|
||||
|
||||
package gateway
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -65,7 +65,7 @@ type reqctxGatewayEnv struct {
|
||||
|
||||
// reqctxGateway builds a gateway with ECS, IAM, the operator surface and an
|
||||
// optional route53 probe over a caller who holds only the given statements.
|
||||
func reqctxGateway(t *testing.T, env reqctxGatewayEnv, statements ...iamdomain.Statement) *GatewayConfig {
|
||||
func reqctxGateway(t *testing.T, env reqctxGatewayEnv, statements ...iamdomain.Statement) *Config {
|
||||
t.Helper()
|
||||
encryptedSecret, err := iamdomain.EncryptSecret(gwfix.SecretKey, gwfix.MasterKey)
|
||||
require.NoError(t, err)
|
||||
@@ -97,7 +97,7 @@ func reqctxGateway(t *testing.T, env reqctxGatewayEnv, statements ...iamdomain.S
|
||||
nc.Close()
|
||||
}
|
||||
}
|
||||
gw := withRealAuth(gwfix.WithDeps(&GatewayConfig{
|
||||
gw := withRealAuth(gwfix.WithDeps(&Config{
|
||||
DisableLogging: true,
|
||||
Region: reqctxRegion,
|
||||
Throttler: env.Throttler,
|
||||
@@ -311,7 +311,7 @@ const edgeRequestID = "4f1c2b7e9a0d4e6f8b3c5a7d9e1f2a3b"
|
||||
// audit record the gateway wrote for the request. SetupRoutes installs its own
|
||||
// audit.Middleware, so the probe reads the record from the dispatch path's
|
||||
// context through a tap installed on the same chain.
|
||||
func auditedPipeline(t *testing.T, gw *GatewayConfig) (http.Handler, func() *audit.Record) {
|
||||
func auditedPipeline(t *testing.T, gw *Config) (http.Handler, func() *audit.Record) {
|
||||
t.Helper()
|
||||
var got *audit.Record
|
||||
r := chi.NewRouter()
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
package gateway
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -29,7 +29,7 @@ func auditRouter(t *testing.T, keys map[string]*iamdomain.AccessKey) (http.Handl
|
||||
rl := authlimit.NewAuthRateLimiter()
|
||||
t.Cleanup(rl.Stop)
|
||||
|
||||
gw := gwfix.WithDeps(&GatewayConfig{
|
||||
gw := gwfix.WithDeps(&Config{
|
||||
DisableLogging: true,
|
||||
Region: gwfix.Region,
|
||||
Credentials: credverify.New(&gwfix.IAM{MasterKey: gwfix.MasterKey, AccessKeys: keys}, nil),
|
||||
+8
-8
@@ -1,4 +1,4 @@
|
||||
package gateway
|
||||
package pipeline
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -28,17 +28,17 @@ func TestWriteThrottleError_ByService(t *testing.T) {
|
||||
svc, code, contains string
|
||||
status int
|
||||
contentType string
|
||||
with func(*GatewayConfig) *GatewayConfig
|
||||
with func(*Config) *Config
|
||||
}{
|
||||
{"iam", awserrors.ErrorThrottling, "<ErrorResponse>", http.StatusServiceUnavailable, "application/xml", withIAM},
|
||||
{"sts", awserrors.ErrorThrottling, "<ErrorResponse>", http.StatusServiceUnavailable, "application/xml", withSTS},
|
||||
{"eks", "", `"__type"`, 0, envelope.JSONContentType, withContainers},
|
||||
{"s3", awserrors.ErrorRequestLimitExceeded, "<Error>", http.StatusServiceUnavailable, "application/xml", func(gw *GatewayConfig) *GatewayConfig { return withServices(gw) }},
|
||||
{"s3", awserrors.ErrorRequestLimitExceeded, "<Error>", http.StatusServiceUnavailable, "application/xml", func(gw *Config) *Config { return withServices(gw) }},
|
||||
{"unserved-service", awserrors.ErrorRequestLimitExceeded, "<ErrorResponse>", http.StatusServiceUnavailable, "application/xml", nil},
|
||||
{"", awserrors.ErrorRequestLimitExceeded, "<Response>", http.StatusServiceUnavailable, "application/xml", nil},
|
||||
} {
|
||||
t.Run(tc.svc, func(t *testing.T) {
|
||||
gw := &GatewayConfig{DisableLogging: true}
|
||||
gw := &Config{DisableLogging: true}
|
||||
if tc.with != nil {
|
||||
gw = tc.with(gw)
|
||||
}
|
||||
@@ -63,7 +63,7 @@ func TestWriteThrottleError_ByService(t *testing.T) {
|
||||
|
||||
// An unparsed signature leaves no service, and the rejection keeps EC2's shape.
|
||||
func TestXMLErrorBody_EmptyServiceKeepsEC2Shape(t *testing.T) {
|
||||
body := (&GatewayConfig{}).xmlErrorBody("", "AccessDenied", "denied", "req-1", "/")
|
||||
body := (&Config{}).xmlErrorBody("", "AccessDenied", "denied", "req-1", "/")
|
||||
|
||||
assert.Equal(t, "Response", xmlRootName(t, body))
|
||||
assert.Contains(t, string(body), "<Errors>")
|
||||
@@ -99,7 +99,7 @@ func TestReqctxPipeline_GlobalSigningRegionRejectsOthers(t *testing.T) {
|
||||
// The anonymous exchange runs ahead of authentication, so an oversize body is
|
||||
// handed back unread to the signed chain instead of being dispatched.
|
||||
func TestAnonymousSTS_OversizeBodyPassesThroughUnread(t *testing.T) {
|
||||
gw := &GatewayConfig{DisableLogging: true, PreAuth: []dispatch.PreAuth{stsawsapi.NewPreAuth(stsawsapi.Deps{})}}
|
||||
gw := &Config{DisableLogging: true, PreAuth: []dispatch.PreAuth{stsawsapi.NewPreAuth(stsawsapi.Deps{})}}
|
||||
big := "Action=AssumeRoleWithWebIdentity&WebIdentityToken=" + strings.Repeat("a", int(sigv4.MaxPayloadLen)+1)
|
||||
|
||||
var seen []byte
|
||||
@@ -114,7 +114,7 @@ func TestAnonymousSTS_OversizeBodyPassesThroughUnread(t *testing.T) {
|
||||
|
||||
// With no anonymous capability composed, the action fails closed.
|
||||
func TestAnonymousSTS_NilCapabilityIsAnInternalError(t *testing.T) {
|
||||
gw := &GatewayConfig{DisableLogging: true, PreAuth: []dispatch.PreAuth{stsawsapi.NewPreAuth(stsawsapi.Deps{})}}
|
||||
gw := &Config{DisableLogging: true, PreAuth: []dispatch.PreAuth{stsawsapi.NewPreAuth(stsawsapi.Deps{})}}
|
||||
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader("Action=AssumeRoleWithWebIdentity&WebIdentityToken=x"))
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
@@ -130,7 +130,7 @@ func TestAnonymousSTS_NilCapabilityIsAnInternalError(t *testing.T) {
|
||||
// throttle, so an exhausted bucket never reaches it.
|
||||
func TestPreAuth_RunsAheadOfSigV4AndThrottle(t *testing.T) {
|
||||
served := 0
|
||||
gw := withIAM(withVerifier(gwfix.WithDeps(&GatewayConfig{
|
||||
gw := withIAM(withVerifier(gwfix.WithDeps(&Config{
|
||||
DisableLogging: true,
|
||||
Throttler: newThrottler(t),
|
||||
PreAuth: []dispatch.PreAuth{{
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user