mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-30 16:06:22 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0fc93e975a |
@@ -59,9 +59,6 @@ var allowlist = []allowance{
|
||||
{From: "domains/ec2/awsapi/instance", To: "runtime/compute/cache", Debt: "domains/ec2/awsapi/instance (dependency)", Reason: "node liveness and VM state; needs an EC2-owned projection"},
|
||||
{From: "domains/ec2/awsapi/instance", To: "runtime/compute/vm", Debt: "domains/ec2/awsapi/instance (dependency)", Reason: "node liveness and VM state; needs an EC2-owned projection"},
|
||||
|
||||
{From: "domains/ec2/awsapi/spotinstance", To: "domains/admission/quota", Debt: "domains/ec2/awsapi/spotinstance (dependency)", Reason: "RequestSpotInstances.go uses IAMService and quota.Service"},
|
||||
{From: "domains/ec2/awsapi/spotinstance", To: "domains/iam", Debt: "domains/ec2/awsapi/spotinstance (dependency)", Reason: "RequestSpotInstances.go uses IAMService and quota.Service"},
|
||||
|
||||
{From: "domains/ec2/eip", To: "domains/network/topology", Debt: "EC2 → network implementation", Reason: "EC2 reads network implementation; clears with the EC2-owned projection"},
|
||||
|
||||
{From: "domains/ec2/guestmetadata", To: "domains/dns", Debt: "Guest metadata: the cross-domain and runtime imports above", Reason: "guest-metadata relocation residual; needs capabilities, projections or contracts"},
|
||||
@@ -103,25 +100,7 @@ var allowlist = []allowance{
|
||||
{From: "domains/ochre/vector", To: "domains/iam", Debt: "Cross-domain", Reason: "consumes another domain's implementation rather than a capability"},
|
||||
|
||||
{From: "gateway", To: "domains/ec2/awsapi", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
|
||||
{From: "gateway", To: "domains/ec2/awsapi/account", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
|
||||
{From: "gateway", To: "domains/ec2/awsapi/capacityreservation", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
|
||||
{From: "gateway", To: "domains/ec2/awsapi/eigw", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
|
||||
{From: "gateway", To: "domains/ec2/awsapi/eip", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
|
||||
{From: "gateway", To: "domains/ec2/awsapi/idem", Debt: "gateway/ec2.go (registration; ec2_idempotency.go)", Reason: "dispatch-time idempotency wrapper; moves with the EC2 dispatch map"},
|
||||
{From: "gateway", To: "domains/ec2/awsapi/igw", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
|
||||
{From: "gateway", To: "domains/ec2/awsapi/image", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
|
||||
{From: "gateway", To: "domains/ec2/awsapi/instance", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
|
||||
{From: "gateway", To: "domains/ec2/awsapi/key", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
|
||||
{From: "gateway", To: "domains/ec2/awsapi/launchtemplate", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
|
||||
{From: "gateway", To: "domains/ec2/awsapi/natgw", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
|
||||
{From: "gateway", To: "domains/ec2/awsapi/placementgroup", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
|
||||
{From: "gateway", To: "domains/ec2/awsapi/routetable", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
|
||||
{From: "gateway", To: "domains/ec2/awsapi/snapshot", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
|
||||
{From: "gateway", To: "domains/ec2/awsapi/spotinstance", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
|
||||
{From: "gateway", To: "domains/ec2/awsapi/tags", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
|
||||
{From: "gateway", To: "domains/ec2/awsapi/volume", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
|
||||
{From: "gateway", To: "domains/ec2/awsapi/vpc", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
|
||||
{From: "gateway", To: "domains/ec2/awsapi/zone", Debt: "gateway/ec2.go (registration)", Reason: "EC2 dispatch map stays in gateway until the ingress registration seam"},
|
||||
|
||||
{From: "domains/rds/awsapi", To: "domains/ec2/awsapi/instance", Debt: "Instance adapter consumers outside EC2", Reason: "enginecatalog.go calls the instance adapter; needs an EC2 capability"},
|
||||
|
||||
|
||||
@@ -365,6 +365,7 @@ while the branch is in flight; they do not describe the umbrella repository's
|
||||
| `spinifex#1348` | `spinifex/gateway/ecs.go` (`ECS_Request`) and `spinifex/gateway/eks.go` (`EKS_Request`, the EKS route table) | `spinifex/domains/ecs/awsapi` and `spinifex/domains/eks/awsapi` (`NewRegistration`, `Deps`, `OperationInventory`), composed by `spinifex/runtime/roles/awsgw` | ECS (AWS JSON 1.1) and EKS (REST JSON) register through the ADR-0002 seam with the JSON error envelopes they had, both declared as AWS JSON 1.1 (see the envelope divergence below). The check order and every response are kept, pinned by the pipeline characterisation tests committed first. EKS and ECS resolve roles through `foundation/aws/passrole` `Check` with `domains/iam` `RoleResolver`: a malformed or foreign role is refused first, a missing role is authorized as written before it is reported, and a resolver outage is returned unchanged. Role handling now follows the AWS capture of 2026-10-30. ECS RegisterTaskDefinition refuses a foreign role with `ClientException` "Role is not valid" before PassRole and accepts a nonexistent, invented-path or non-ARN role from a caller who can pass roles. ECS RunTask refuses a foreign `taskRoleArn` or `executionRoleArn` override the same way and answers a nonexistent or invented-path one with "ECS was unable to assume the role". EKS CreateCluster and CreateNodegroup answer a non-ARN or foreign role with `AccessDeniedException` "Cross-account pass role is not allowed.". EKS public routes now report every parameter fault as `InvalidParameterException`, the code the EKS model declares, while the internal control-plane routes keep `InvalidParameterValue` because the guest agents match on it. Two deliberate EKS divergences remain. First, CreateCluster still refuses a nonexistent or invented-path role with `InvalidParameterException` "role ... does not exist", where AWS accepts it and the cluster later ends FAILED. Our EKS only stores, validates and echoes the cluster role and never assumes it, so accepting would leave an ACTIVE cluster bound to a role that does not exist. Second, CreateNodegroup keeps its existing refusal of a nonexistent role because AWS was not captured for it. A third divergence is the error envelope. EKS is restJson1 but registers `ErrorEnvelopeJSON`, so errors carry `application/x-amz-json-1.1` and a `__type` body where AWS sends `application/json` and a `message` body. The `X-Amzn-Errortype` header is correct and SDKs read the code from it, so impact is low. Switching to `ErrorEnvelopeRESTJSON` also changes the throttle code and the default internal-error code (`ServiceException` where the EKS model declares `ServerException`), so it is left for its own change. Verified live on env14: the ECS and EKS role cases above, and a valid CreateCluster reaching ACTIVE with the control-plane agent publishing through the gateway. The EKS route table and its tests move with the registration, and the gateway keeps no ECS or EKS fallback. The `cmd/aws-model-coverage` named exception gains an `ecs` and an `eks` row, and the two `gateway` allowlist rows are removed. |
|
||||
| `spinifex#1350` | `spinifex/gateway` (`ecr.go`, `ecrauth_middleware.go`, the `/v2/token` exchange, `ecr_principal.go` with `ResolveECRPrincipal`, the per-operation `/v2` authorization, the registry host matcher, `ctxECRPrincipal`) | `spinifex/domains/ecr/registry` (`Bridge`, `NewBridge`, `BridgeConfig`, and its own `Principal`, `Rehydrator`, `Authorizer`, `Admission` types with `ErrInvalidCredential` and `ErrUnavailable`), `spinifex/ingress/http` (`HostMatcher`, `TargetAccount`, `RequestClientIP`; names no service), `spinifex/ingress/aws/credential` (`Rehydrator`, `Principal.CallerARN`), and under `spinifex/runtime/roles/awsgw`: `credverify` (`Rehydrate`, sharing the SigV4 key and session lookups), `policyauth`, `accountadmission`, `registryadapt` | ADR-0001 amendment 2026-10-29 and ADR-0005:S2: the OCI `/v2` bridge and its token auth move out of the gateway, and ECR resolves its principal through the credential capability instead of reading `IAMService` and `STSService` directly. ADR-0002:S5 keeps `/v2` outside the seam, so the gateway keeps only a `RegistryHandler` mount that answers 501 when unset. `domains/ecr/registry` imports no ingress package (`TestADR0001_Layering` unchanged): the principal, rehydrator and authorizer are registry-local types and `registryadapt` maps `credential.Failure` codes onto `ErrInvalidCredential` (401) and `ErrUnavailable` (503). No widening of `dispatch.Actor` or `dispatch.Invocation`. The SigV4 path and the bridge share one policy evaluator (`policyauth`) and one account-admission check. Deliberate change: the open-registry mode for a nil token verifier is deleted and `NewBridge` refuses a nil Verifier, Issuer, Rehydrator, Authorizer or Admission, so tests that want an unauthenticated registry mount `registry.Handler` directly. The pre-move pipeline characterisation (status codes, error bodies, the `WWW-Authenticate` challenge, referrers, token exchange, revoked and deleted principals) passes unchanged apart from setup, and the retired `ResolveECRPrincipal` parity rows live on as `credverify` `Rehydrate` rows and `Bridge` claim-check rows. Live on env14 (DHCP external IP): crane, skopeo and docker push and pull (`tests/e2e/ecr`), a cosign sign and verify in the legacy tag mode and in OCI 1.1 referrers mode with the signature returned by `GET /v2/{repo}/referrers/{digest}`, `/v2/token` refresh from fresh logins, and a deactivated access key answering 401 on `/v2/token`, manifest GET, crane pull and crane push, then 200 again once reactivated. The ECR signing-key deadlock bead (`mulga-6wqce`) is untouched. |
|
||||
| `spinifex#1351` | `spinifex/gateway/{bedrock,bedrockruntime,bedrockagent,bedrockagentruntime}.go` (`Bedrock_Request`, `BedrockRuntime_Request`, `BedrockAgent_Request`, `BedrockAgentRuntime_Request`, the four route tables), `spinifex/domains/ochre/authz.go`, and the 13 `Bedrock*` fields of `GatewayConfig` | `spinifex/domains/ochre/awsapi` (`NewRegistration`, `Deps`, `Quota`, `OperationInventory`, `ServiceName`), composed by `spinifex/runtime/roles/awsgw` | The four Bedrock services register through the ADR-0002 seam as one registration, per ADR-0002:S1 (one per signing service) and the ADR-0001 amendment of 2026-10-06: the service is `bedrock` and `bedrock-runtime`, `bedrock-agent` and `bedrock-agent-runtime` are aliases. A request signed with a sub-service scope is dispatched to that sub-service whatever its path, so a path owned by another sub-service answers `InvalidActionException` as before; only the shared `bedrock` scope is split by path. The pipeline characterisation tests committed first (status, error type and envelope per service, metering order, nil-capability answers) pass unchanged apart from setup. The 13 gateway fields become `awsapi.Deps`: `Credentials`, `Endpoints`, `Access`, `Recorder`, `Logging`, `Provisioned`, `Guardrails`, `Embedder`, `KB`, `DataSources`, `Vector` and `Quota`, with the documented nil fallbacks (no grants denies every model, a nil store errors rather than panics); `BedrockAccessAdmin` had no reader outside one test and is gone, and `Deps.NATS` is dropped because the nil-NATS answer is given by the gateway-wide gate before dispatch. `Logging`, `Provisioned`, `Guardrails`, `KB` and `DataSources` are the concrete exported store types, a temporary edge until Ochre exposes capability interfaces for them. The wire-typed action functions stay in the `domains/ochre` root and the SDK-typed adapters move; `authz.go` moves with them, which removes the `domains/ochre` to `ingress/aws/bodyscope` allowlist row, the only allowlist change. The legacy switch cases, the `supportedServices` and `jsonErrorService` entries and the path-based sub-service rewrite in `GetService` are removed, so `ec2` is the only service left on the legacy table; the rewrite's rows are pinned in `awsapi` as `resolveSubservice` and dispatch tests. The registration declares the AWS JSON 1.1 envelope it had and `cmd/aws-model-coverage` is not wired, so no coverage allowlist row is added. Recorded divergences, not changed by this move: Bedrock is restJson1 in AWS but answers with the JSON 1.1 envelope (`application/x-amz-json-1.1`, `__type` body), and CreateGuardrail answers 200 where AWS answers 202; both are tracked by `mulga-xug8b`. No change to `dispatch.Actor` or `dispatch.Invocation`. |
|
||||
| `spinifex#1354` | `spinifex/gateway/ec2.go` (the EC2 action table, `ec2Handler`, `ec2Actions`, the dispatch pipeline), `ec2_idempotency.go`, `ec2_validation.go`, and the EC2 token-bucket fields of `GatewayConfig` | `spinifex/domains/ec2/awsapi` (`Handler`, `NewHandler`, `Deps`, `Quota`, `Dispatch`, `ActionNames`, `InputHasField`), composed by `spinifex/gateway/ec2.go` | Slice 9a of the EC2 move: the capabilities first, with no behaviour change. The EC2 action table, idempotency and validation move to `domains/ec2/awsapi` behind `Deps{NATS, IAM, Quota, ActiveNodes, ExpectedNodes, NodeIDs, AZ, Endpoint, InstanceStatus}`; the gateway keeps the composition and `EC2_Request`, still on the legacy switch until slice 9b registers EC2 through the ADR-0002 seam. `Quota` is declared in `awsapi` because `domains/admission/quota` imports the EC2 action packages and cannot be imported back, and the gateway wraps `quota.Service` to supply the NATS instance-type resolver its retype gate takes. The instance and spot-instance adapters take the two-method `InstanceProfiles` interface (`ResolveInstanceProfile`, `CanonicalResourceARN`) instead of `iamdomain.IAMService`, which pays off the `spotinstance` to `domains/admission/quota` and `domains/iam` allowlist rows. Allowlist changes are 20 deletions and no additions: the 18 `gateway` to `domains/ec2/awsapi/*` rows and those 2. The pipeline characterisation tests committed first (error envelopes, check order, gateway-local actions, idempotent replay, PassRole, ImportKeyPair padding, launch-template expansion before authorization, RunInstances instance-profile refusal order) pass unchanged. Package-local tests drive every routed action through `Dispatch` against a stub daemon. 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 MaxResults 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. |
|
||||
| `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
|
||||
@@ -471,12 +472,11 @@ These residuals were recorded by individual slices and must be resolved once the
|
||||
- `domains/eks/cluster` (potential security risk): `ClusterMeta.ControlPlaneTemplate` (`domains/eks/cluster/service_impl.go`) clears the template's credentials but keeps `OIDCPrivateKeyPEM` and the k3s `JoinToken`, so both are persisted in the `eks-account-{acct}` cluster record, which the gateway also reads. Whether that record is encrypted at rest is unconfirmed; if it is not, a reader of the bucket can forge service-account tokens for the cluster's OIDC issuer and join nodes to it. This predates the refactor and is not fixed in it. Removal condition: the key and token are held by a dedicated secret store (a future KMS or Secrets Manager capability is the likely owner) and the cluster record keeps only a reference, with a test proving the persisted record contains neither secret.
|
||||
- `operator/cli` (dependency): the command tree still imports domain and service implementations directly (`handlers/{iam,eks}`, `domains/admission/quota`, `domains/ochre`, `vpcd`, the Viperblock adapter and runtime roles). The ADR's `cmd/spinifex` current-to-target map row requires command packages to reach domains through public capabilities; splitting supported use cases into `operator/*` packages is later work.
|
||||
- `providers/ebs/viperblock` → `runtime/compute/nbd` and `runtime/host/process` (dependency, moved unchanged from `services/viperblockd`): the adapter formats NBD endpoints and manages nbdkit processes through runtime packages. ADR-0001 does not list providers → runtime as forbidden and the layering test does not enforce it (same category as `providers/ebs/conformance` → `runtime/compute/nbd`), but a provider should not depend on node runtime. Clears when NBD endpoint naming has a provider-facing home (a candidate for `contracts/ebs/v1`) and process supervision is injected by the role. The adapter also imports `providers/objectstore`, a provider-to-provider edge that is allowed.
|
||||
- `gateway/ec2.go` (registration): the EC2 dispatch map stays a `GatewayConfig` method in `gateway` and imports every `domains/ec2/awsapi/*` package; `gateway/ec2_idempotency.go` also imports `domains/ec2/awsapi/idem` (`KVBucket`, `TTL`, `TokenAndParams`) for the dispatch-time idempotency wrapper. ADR-0001 makes the domain own its action registration; moving the map waits on the generic ingress registration seam and must not be done as a package move.
|
||||
- `gateway/ec2.go` (registration): the action table, idempotency and validation moved to `domains/ec2/awsapi` behind `Handler` and `Deps` (slice 9a, `spinifex#1354`), but the gateway still composes the handler and serves it from the legacy switch, so `gateway` keeps importing `domains/ec2/awsapi` and `domains/ec2/awsapi/instance`. Registering EC2 through the ADR-0002 seam and emptying the legacy switch is slice 9b.
|
||||
- `gateway` (service registration): the root `gateway` dispatch for IAM, STS, EKS, RDS and ECS imports `domains/{iam,sts,eks,rds,ecs}/awsapi`, which only AWSGW may wire. It clears when each service registers through the ADR-0002 ingress registration seam; as with EC2, moving the dispatch must not be done as a package move.
|
||||
- `operator/accountlifecycle` (dependency): its EC2 reapers call `domains/ec2/awsapi/*` action adapters directly, as they did under `gateway/ec2`. The amended ADR-0001 `accountteardown` row keeps replacing these with domain-owned cleanup capabilities as deferred debt; it was not a relocation prerequisite. It also holds a read-only consumer of the EC2 platform-inventory contract (`contracts/ec2/v1`, `ec2.v1.PlatformInventory`), served by `domains/ec2/accountinventory`; only the explicit dry run (`Engine.DryRun`) calls it, to log a shadow comparison, and the key-pair, placement-group and launch-template reapers still use the `awsapi` adapters. A domain-owned cleanup capability for the proven-safe subset of those three kinds (key pairs and empty placement groups no instance, spot request or live launch-template version names, and launch-template residue; never live launch templates) exists as `domains/ec2/accountcleanup` (a Go interface with injected owners and reference readers, no NATS subject); nothing calls it yet. It also holds the StageAccount settings-seal reaper, a consumer of `contracts/ec2/v1`'s `ec2.v1.SealAccountSettings` and per-node `ec2.v1.AccountSettingsSealCapability.<node>` subjects, served by `domains/ec2/account` through the daemon; the reaper reads no KV and imports no domain package, reaching both subjects only through NATS and the configured node set `NewClusterEngine` already receives.
|
||||
- `domains/admission/quota` (dependency, moved from `handlers/quota` unchanged): `live.go` imports `domains/elbv2/awsapi` and `domains/rds/instance` for its live ELBv2 and RDS counters; `records.go` imports `runtime/compute/vm` for the instance-record vCPU sweep; `live.go`, `vcpu.go` and `storage.go` call `domains/ec2/awsapi/{vpc,eip,volume}` describe adapters to count VPCs, subnets, addresses and volumes, as they did under `gateway/ec2`. The ADR's `handlers/quota` current-to-target map row replaces these with reader capabilities once their final locations exist.
|
||||
- `domains/ec2/awsapi/instance` (dependency): imports `domains/iam` (`IAMService` in `DescribeInstances.go`, `RunInstances.go` and `IamInstanceProfileAssociation.go`; `InstanceProfile` in the latter two), `runtime/compute/cache` (`NodeState`, `NodeUnknown`, `NodeLive`) and `runtime/compute/vm` (`VM`), both in `status_synthesis.go`. The IAM edge needs an IAM capability; the runtime edges are domain-to-runtime and need the instance record and node liveness exposed through an EC2-owned projection.
|
||||
- `domains/ec2/awsapi/spotinstance` (dependency): imports `domains/iam.IAMService` and `domains/admission/quota.Service` in `RequestSpotInstances.go`.
|
||||
- `domains/ec2/awsapi/instance` (dependency): imports `domains/iam` for the `InstanceProfile` type that `InstanceProfiles.ResolveInstanceProfile` returns (`RunInstances.go`, `IamInstanceProfileAssociation.go`), `runtime/compute/cache` (`NodeState`, `NodeUnknown`, `NodeLive`) and `runtime/compute/vm` (`VM`), both in `status_synthesis.go`. The IAM edge is now the two-method `InstanceProfiles` interface plus that type; the runtime edges are domain-to-runtime and need the instance record and node liveness exposed through an EC2-owned projection.
|
||||
- Instance adapter consumers outside EC2: `domains/rds/awsapi/enginecatalog.go`, `runtime/daemon/eks_cp_control.go`, `runtime/daemon/rds_deps.go`, `domains/admission/quota`'s `vcpu.go` and `gateway/iam.go` (`CountInstanceProfileAssociations`) call `domains/ec2/awsapi/instance` directly; each needs an EC2 capability rather than the AWS action adapter.
|
||||
- `runtime/daemon` (Q-28 ownership and deployment): the daemon moved whole, keeping its current ownership, deployment and `daemon → gateway → handlers` dependencies. Q-28's write-side consolidation and any daemon decomposition or split remain deferred by ADR-0001 and are not paid by the move.
|
||||
|
||||
|
||||
@@ -0,0 +1,631 @@
|
||||
// Package awsapi serves the EC2 query-protocol actions over composed capabilities.
|
||||
package awsapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strings"
|
||||
"uuid"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
ec2accountapi "github.com/mulgadc/spinifex/spinifex/domains/ec2/awsapi/account"
|
||||
ec2capacityreservationapi "github.com/mulgadc/spinifex/spinifex/domains/ec2/awsapi/capacityreservation"
|
||||
ec2eigwapi "github.com/mulgadc/spinifex/spinifex/domains/ec2/awsapi/eigw"
|
||||
ec2eipapi "github.com/mulgadc/spinifex/spinifex/domains/ec2/awsapi/eip"
|
||||
ec2igwapi "github.com/mulgadc/spinifex/spinifex/domains/ec2/awsapi/igw"
|
||||
ec2imageapi "github.com/mulgadc/spinifex/spinifex/domains/ec2/awsapi/image"
|
||||
ec2instanceapi "github.com/mulgadc/spinifex/spinifex/domains/ec2/awsapi/instance"
|
||||
ec2keyapi "github.com/mulgadc/spinifex/spinifex/domains/ec2/awsapi/key"
|
||||
ec2launchtemplateapi "github.com/mulgadc/spinifex/spinifex/domains/ec2/awsapi/launchtemplate"
|
||||
ec2natgwapi "github.com/mulgadc/spinifex/spinifex/domains/ec2/awsapi/natgw"
|
||||
ec2placementgroupapi "github.com/mulgadc/spinifex/spinifex/domains/ec2/awsapi/placementgroup"
|
||||
ec2routetableapi "github.com/mulgadc/spinifex/spinifex/domains/ec2/awsapi/routetable"
|
||||
ec2snapshotapi "github.com/mulgadc/spinifex/spinifex/domains/ec2/awsapi/snapshot"
|
||||
ec2spotinstanceapi "github.com/mulgadc/spinifex/spinifex/domains/ec2/awsapi/spotinstance"
|
||||
ec2tagsapi "github.com/mulgadc/spinifex/spinifex/domains/ec2/awsapi/tags"
|
||||
ec2volumeapi "github.com/mulgadc/spinifex/spinifex/domains/ec2/awsapi/volume"
|
||||
ec2vpcapi "github.com/mulgadc/spinifex/spinifex/domains/ec2/awsapi/vpc"
|
||||
ec2zoneapi "github.com/mulgadc/spinifex/spinifex/domains/ec2/awsapi/zone"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
awsxml "github.com/mulgadc/spinifex/spinifex/foundation/aws/xml"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/lifecycle/idempotency"
|
||||
"github.com/mulgadc/spinifex/spinifex/ingress/aws/query"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
// ec2Action parses once so authorization and dispatch share one typed input.
|
||||
type ec2Action struct {
|
||||
parse func(map[string]string) (any, error)
|
||||
dispatch func(ctx context.Context, action string, parsed any, c *call, accountID string) ([]byte, error)
|
||||
// idempotent records that this action honours ClientToken, so the set of
|
||||
// actions that do is assertable rather than folklore.
|
||||
idempotent bool
|
||||
}
|
||||
|
||||
func (h ec2Action) withQueryPreprocessor(preprocess func(map[string]string)) ec2Action {
|
||||
parse := h.parse
|
||||
h.parse = func(q map[string]string) (any, error) {
|
||||
preprocess(q)
|
||||
return parse(q)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// requestContext returns r's context, or Background for a nil request
|
||||
// (some callers and tests dispatch without an *http.Request). The SDK's
|
||||
// invocation id rides along: it is repeated across retries of one call, so it
|
||||
// is what lets a mutation recognise its own resend instead of doing the work
|
||||
// twice.
|
||||
func requestContext(r *http.Request) context.Context {
|
||||
if r == nil {
|
||||
return context.Background()
|
||||
}
|
||||
return idempotency.WithKey(r.Context(), r.Header.Get(idempotency.SDKInvocationIDHeader))
|
||||
}
|
||||
|
||||
// parseEC2Input parses query args into an action's input type. Shared by every
|
||||
// ec2Action constructor so they all consume the same parse.
|
||||
func parseEC2Input[In any](q map[string]string) (any, error) {
|
||||
input := new(In)
|
||||
if err := query.QueryParamsToStruct(q, input); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return input, nil
|
||||
}
|
||||
|
||||
// ec2Handler creates a type-safe EC2Handler. Parsing is separate from dispatch
|
||||
// so authorization and execution consume the exact same input.
|
||||
func ec2Handler[In any](handler func(ctx context.Context, input *In, c *call, accountID string) (any, error)) ec2Action {
|
||||
return ec2Action{
|
||||
parse: parseEC2Input[In],
|
||||
dispatch: func(ctx context.Context, action string, parsed any, c *call, accountID string) ([]byte, error) {
|
||||
input, ok := parsed.(*In)
|
||||
if !ok {
|
||||
return nil, errors.New(awserrors.ErrorInternalError)
|
||||
}
|
||||
output, err := handler(ctx, input, c, accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
xmlOutput, err := marshalEC2Response(action, output)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return xmlOutput, nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ec2ListsAsSet names the list fields AWS omits when empty instead of
|
||||
// rendering them. Each is rendered as its handler set it, so nil omits it.
|
||||
var ec2ListsAsSet = map[reflect.Type][]string{
|
||||
reflect.TypeFor[ec2.CreateKeyPairOutput](): {"Tags"},
|
||||
reflect.TypeFor[ec2.Image](): {"Tags", "ProductCodes"},
|
||||
reflect.TypeFor[ec2.CreateSecurityGroupOutput](): {"Tags"},
|
||||
reflect.TypeFor[ec2.SecurityGroup](): {"Tags"},
|
||||
reflect.TypeFor[ec2.SecurityGroupRule](): {"Tags"},
|
||||
reflect.TypeFor[ec2.RevokeSecurityGroupIngressOutput](): {"UnknownIpPermissions"},
|
||||
reflect.TypeFor[ec2.RevokeSecurityGroupEgressOutput](): {"UnknownIpPermissions"},
|
||||
reflect.TypeFor[ec2.NetworkInterface](): {"Ipv4Prefixes", "Ipv6Prefixes"},
|
||||
reflect.TypeFor[ec2.Vpc](): {"Ipv6CidrBlockAssociationSet"},
|
||||
reflect.TypeFor[ec2.Subnet](): {"Tags"},
|
||||
}
|
||||
|
||||
// marshalEC2Response renders an EC2 handler's output into the action's XML
|
||||
// envelope. Every EC2 action funnels through here (via ec2Handler and
|
||||
// ec2HandlerWithReq), so wire-format fixes belong here, not in each handler.
|
||||
func marshalEC2Response(action string, output any) ([]byte, error) {
|
||||
// BuildXML omits a nil slice's container element entirely but renders an
|
||||
// empty one for a non-nil empty slice; AWS mostly renders the latter.
|
||||
normalized := awsxml.NormalizeOutput(output, ec2ListsAsSet)
|
||||
// The SDK's generated output structs never carry a RequestId field.
|
||||
withRequestID := awsxml.WithRequestID(normalized, uuid.NewV4().String())
|
||||
payload := awsxml.ResponsePayload(action+"Response", withRequestID)
|
||||
xmlOutput, err := awsxml.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, errors.New("failed to marshal response to XML")
|
||||
}
|
||||
return xmlOutput, nil
|
||||
}
|
||||
|
||||
var ec2Actions = map[string]ec2Action{
|
||||
"DescribeInstances": ec2Handler(func(ctx context.Context, input *ec2.DescribeInstancesInput, c *call, accountID string) (any, error) {
|
||||
out, err := ec2instanceapi.DescribeInstancesChecked(ctx, input, c.NATS, 0, c.NodeIDs, accountID)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
ec2instanceapi.EnrichInstanceProfileIDs(out, c.IAM, accountID)
|
||||
return out, nil
|
||||
}),
|
||||
"RunInstances": ec2Handler(func(ctx context.Context, input *ec2.RunInstancesInput, c *call, accountID string) (any, error) {
|
||||
passRoleCheck := func(roleARN string) error {
|
||||
return c.passRole(roleARN)
|
||||
}
|
||||
launchQuotaCheck := func() error {
|
||||
return c.Quota.EnforceLaunch(ctx, accountID, aws.StringValue(input.InstanceType), int(aws.Int64Value(input.MaxCount)))
|
||||
}
|
||||
reservation, err := ec2instanceapi.RunInstances(ctx, input, c.NATS, c.IAM, accountID, passRoleCheck, launchQuotaCheck, c.ExpectedNodes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Charge the actual launched vCPUs; a counter write failure is drift for
|
||||
// reconcile to correct, so it must not fail the already-successful launch.
|
||||
if err := c.Quota.ChargeLaunch(ctx, accountID, &reservation); err != nil {
|
||||
slog.WarnContext(ctx, "RunInstances: vcpu quota charge failed, reconcile will correct", "account", accountID, "err", err)
|
||||
}
|
||||
return reservation, nil
|
||||
}),
|
||||
"AssociateIamInstanceProfile": ec2Handler(func(ctx context.Context, input *ec2.AssociateIamInstanceProfileInput, c *call, accountID string) (any, error) {
|
||||
passRoleCheck := func(roleARN string) error {
|
||||
return c.passRole(roleARN)
|
||||
}
|
||||
return ec2instanceapi.AssociateIamInstanceProfile(ctx, input, c.NATS, c.IAM, accountID, passRoleCheck)
|
||||
}),
|
||||
"DisassociateIamInstanceProfile": ec2Handler(func(ctx context.Context, input *ec2.DisassociateIamInstanceProfileInput, c *call, accountID string) (any, error) {
|
||||
return ec2instanceapi.DisassociateIamInstanceProfile(ctx, input, c.NATS, c.ActiveNodes(ctx), accountID)
|
||||
}),
|
||||
"ReplaceIamInstanceProfileAssociation": ec2Handler(func(ctx context.Context, input *ec2.ReplaceIamInstanceProfileAssociationInput, c *call, accountID string) (any, error) {
|
||||
passRoleCheck := func(roleARN string) error {
|
||||
return c.passRole(roleARN)
|
||||
}
|
||||
return ec2instanceapi.ReplaceIamInstanceProfileAssociation(ctx, input, c.NATS, c.IAM, c.ActiveNodes(ctx), accountID, passRoleCheck)
|
||||
}),
|
||||
"DescribeIamInstanceProfileAssociations": ec2Handler(func(ctx context.Context, input *ec2.DescribeIamInstanceProfileAssociationsInput, c *call, accountID string) (any, error) {
|
||||
return ec2instanceapi.DescribeIamInstanceProfileAssociations(ctx, input, c.NATS, c.ActiveNodes(ctx), accountID)
|
||||
}),
|
||||
"StartInstances": ec2Handler(func(ctx context.Context, input *ec2.StartInstancesInput, c *call, accountID string) (any, error) {
|
||||
return ec2instanceapi.StartInstances(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"StopInstances": ec2Handler(func(ctx context.Context, input *ec2.StopInstancesInput, c *call, accountID string) (any, error) {
|
||||
return ec2instanceapi.StopInstances(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"RebootInstances": ec2Handler(func(ctx context.Context, input *ec2.RebootInstancesInput, c *call, accountID string) (any, error) {
|
||||
return ec2instanceapi.RebootInstances(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"MonitorInstances": ec2Handler(func(ctx context.Context, input *ec2.MonitorInstancesInput, c *call, accountID string) (any, error) {
|
||||
return ec2instanceapi.MonitorInstances(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"UnmonitorInstances": ec2Handler(func(ctx context.Context, input *ec2.UnmonitorInstancesInput, c *call, accountID string) (any, error) {
|
||||
return ec2instanceapi.UnmonitorInstances(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"TerminateInstances": ec2Handler(func(ctx context.Context, input *ec2.TerminateInstancesInput, c *call, accountID string) (any, error) {
|
||||
return ec2instanceapi.TerminateInstances(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"DescribeInstanceTypes": ec2Handler(func(ctx context.Context, input *ec2.DescribeInstanceTypesInput, c *call, accountID string) (any, error) {
|
||||
return ec2instanceapi.DescribeInstanceTypes(ctx, input, c.NATS, c.ExpectedNodes, c.NodeIDs, accountID)
|
||||
}),
|
||||
"DescribeInstanceTypeOfferings": ec2Handler(func(ctx context.Context, input *ec2.DescribeInstanceTypeOfferingsInput, c *call, accountID string) (any, error) {
|
||||
return ec2instanceapi.DescribeInstanceTypeOfferings(ctx, input, c.NATS, c.ExpectedNodes, accountID, c.Region, c.AZ)
|
||||
}),
|
||||
"DescribeInstanceStatus": ec2Handler(func(ctx context.Context, input *ec2.DescribeInstanceStatusInput, c *call, accountID string) (any, error) {
|
||||
return ec2instanceapi.DescribeInstanceStatus(ctx, input, c.NATS, c.ActiveNodes(ctx), accountID, c.AZ, c.InstanceStatus)
|
||||
}),
|
||||
"GetConsoleOutput": ec2Handler(func(ctx context.Context, input *ec2.GetConsoleOutputInput, c *call, accountID string) (any, error) {
|
||||
return ec2instanceapi.GetConsoleOutput(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"GetPasswordData": ec2Handler(func(ctx context.Context, input *ec2.GetPasswordDataInput, c *call, accountID string) (any, error) {
|
||||
return ec2instanceapi.GetPasswordData(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"ModifyInstanceAttribute": ec2Handler(func(ctx context.Context, input *ec2.ModifyInstanceAttributeInput, c *call, accountID string) (any, error) {
|
||||
var delta int
|
||||
if input.InstanceType != nil {
|
||||
d, err := c.Quota.EnforceRetype(ctx, c.NATS, func() []string { return c.NodeIDs }, accountID, aws.StringValue(input.InstanceId), aws.StringValue(input.InstanceType.Value))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
delta = d
|
||||
}
|
||||
out, err := ec2instanceapi.ModifyInstanceAttribute(ctx, input, c.NATS, accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Charge the retype's vCPU growth; a counter write failure is drift for
|
||||
// reconcile to correct, so it must not fail the applied retype.
|
||||
if err := c.Quota.AddVCPU(ctx, accountID, delta); err != nil {
|
||||
slog.WarnContext(ctx, "ModifyInstanceAttribute: vcpu quota charge failed, reconcile will correct", "account", accountID, "err", err)
|
||||
}
|
||||
return out, nil
|
||||
}),
|
||||
"DescribeInstanceAttribute": ec2Handler(func(ctx context.Context, input *ec2.DescribeInstanceAttributeInput, c *call, accountID string) (any, error) {
|
||||
return ec2instanceapi.DescribeInstanceAttribute(ctx, input, c.NATS, c.ActiveNodes(ctx), accountID)
|
||||
}),
|
||||
"ModifyInstanceMetadataOptions": ec2Handler(func(ctx context.Context, input *ec2.ModifyInstanceMetadataOptionsInput, c *call, accountID string) (any, error) {
|
||||
return ec2instanceapi.ModifyInstanceMetadataOptions(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"DescribeInstanceCreditSpecifications": ec2Handler(func(ctx context.Context, input *ec2.DescribeInstanceCreditSpecificationsInput, c *call, accountID string) (any, error) {
|
||||
return ec2instanceapi.DescribeInstanceCreditSpecifications(input)
|
||||
}),
|
||||
"CreateKeyPair": ec2Handler(func(ctx context.Context, input *ec2.CreateKeyPairInput, c *call, accountID string) (any, error) {
|
||||
return ec2keyapi.CreateKeyPair(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"DeleteKeyPair": ec2Handler(func(ctx context.Context, input *ec2.DeleteKeyPairInput, c *call, accountID string) (any, error) {
|
||||
return ec2keyapi.DeleteKeyPair(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"DescribeKeyPairs": ec2Handler(func(ctx context.Context, input *ec2.DescribeKeyPairsInput, c *call, accountID string) (any, error) {
|
||||
return ec2keyapi.DescribeKeyPairs(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"ImportKeyPair": ec2Handler(func(ctx context.Context, input *ec2.ImportKeyPairInput, c *call, accountID string) (any, error) {
|
||||
return ec2keyapi.ImportKeyPair(ctx, input, c.NATS, accountID)
|
||||
}).withQueryPreprocessor(func(q map[string]string) {
|
||||
// Parser leaves Base64 padding URL-encoded; decode it before dispatch.
|
||||
if strings.HasSuffix(q["PublicKeyMaterial"], "%3D%3D") {
|
||||
q["PublicKeyMaterial"] = strings.Replace(q["PublicKeyMaterial"], "%3D%3D", "==", 1)
|
||||
}
|
||||
}),
|
||||
"DescribeImages": ec2Handler(func(ctx context.Context, input *ec2.DescribeImagesInput, c *call, accountID string) (any, error) {
|
||||
return ec2imageapi.DescribeImages(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"CreateImage": ec2Handler(func(ctx context.Context, input *ec2.CreateImageInput, c *call, accountID string) (any, error) {
|
||||
return ec2imageapi.CreateImage(ctx, input, c.NATS, c.ActiveNodes(ctx), accountID)
|
||||
}),
|
||||
"DeregisterImage": ec2Handler(func(ctx context.Context, input *ec2.DeregisterImageInput, c *call, accountID string) (any, error) {
|
||||
return ec2imageapi.DeregisterImage(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"RegisterImage": ec2Handler(func(ctx context.Context, input *ec2.RegisterImageInput, c *call, accountID string) (any, error) {
|
||||
return ec2imageapi.RegisterImage(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"CopyImage": ec2IdempotentHandler(func(ctx context.Context, input *ec2.CopyImageInput, c *call, accountID string) (ec2.CopyImageOutput, error) {
|
||||
return ec2imageapi.CopyImage(ctx, input, c.NATS, c.Region, accountID)
|
||||
}),
|
||||
"DescribeImageAttribute": ec2Handler(func(ctx context.Context, input *ec2.DescribeImageAttributeInput, c *call, accountID string) (any, error) {
|
||||
return ec2imageapi.DescribeImageAttribute(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"ModifyImageAttribute": ec2Handler(func(ctx context.Context, input *ec2.ModifyImageAttributeInput, c *call, accountID string) (any, error) {
|
||||
return ec2imageapi.ModifyImageAttribute(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"ResetImageAttribute": ec2Handler(func(ctx context.Context, input *ec2.ResetImageAttributeInput, c *call, accountID string) (any, error) {
|
||||
return ec2imageapi.ResetImageAttribute(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"DescribeRegions": ec2Handler(func(ctx context.Context, input *ec2.DescribeRegionsInput, c *call, accountID string) (any, error) {
|
||||
return ec2zoneapi.DescribeRegions(input, c.Region, c.Endpoint)
|
||||
}),
|
||||
"DescribeAvailabilityZones": ec2Handler(func(ctx context.Context, input *ec2.DescribeAvailabilityZonesInput, c *call, accountID string) (any, error) {
|
||||
return ec2zoneapi.DescribeAvailabilityZones(input, c.Region, c.AZ)
|
||||
}),
|
||||
"DescribeVolumes": ec2Handler(func(ctx context.Context, input *ec2.DescribeVolumesInput, c *call, accountID string) (any, error) {
|
||||
return ec2volumeapi.DescribeVolumes(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"ModifyVolume": ec2Handler(func(ctx context.Context, input *ec2.ModifyVolumeInput, c *call, accountID string) (any, error) {
|
||||
if err := c.Quota.EnforceVolumeModify(ctx, c.NATS, accountID, aws.StringValue(input.VolumeId), int(aws.Int64Value(input.Size))); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ec2volumeapi.ModifyVolume(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"CreateVolume": ec2IdempotentHandler(func(ctx context.Context, input *ec2.CreateVolumeInput, c *call, accountID string) (ec2.Volume, error) {
|
||||
if err := c.Quota.EnforceVolumeCreate(ctx, c.NATS, accountID, int(aws.Int64Value(input.Size))); err != nil {
|
||||
return ec2.Volume{}, err
|
||||
}
|
||||
return ec2volumeapi.CreateVolume(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"DeleteVolume": ec2Handler(func(ctx context.Context, input *ec2.DeleteVolumeInput, c *call, accountID string) (any, error) {
|
||||
return ec2volumeapi.DeleteVolume(ctx, input, c.NATS, c.NodeIDs, accountID)
|
||||
}),
|
||||
"AttachVolume": ec2Handler(func(ctx context.Context, input *ec2.AttachVolumeInput, c *call, accountID string) (any, error) {
|
||||
return ec2volumeapi.AttachVolume(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"DescribeVolumeStatus": ec2Handler(func(ctx context.Context, input *ec2.DescribeVolumeStatusInput, c *call, accountID string) (any, error) {
|
||||
return ec2volumeapi.DescribeVolumeStatus(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"DescribeVolumesModifications": ec2Handler(func(ctx context.Context, input *ec2.DescribeVolumesModificationsInput, c *call, accountID string) (any, error) {
|
||||
return ec2volumeapi.DescribeVolumesModifications(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"DetachVolume": ec2Handler(func(ctx context.Context, input *ec2.DetachVolumeInput, c *call, accountID string) (any, error) {
|
||||
return ec2volumeapi.DetachVolume(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"DescribeAccountAttributes": ec2Handler(func(ctx context.Context, input *ec2.DescribeAccountAttributesInput, c *call, accountID string) (any, error) {
|
||||
return ec2accountapi.DescribeAccountAttributes(input, func() (string, error) {
|
||||
return defaultVPCID(ctx, c.NATS, accountID)
|
||||
})
|
||||
}),
|
||||
"EnableEbsEncryptionByDefault": ec2Handler(func(ctx context.Context, input *ec2.EnableEbsEncryptionByDefaultInput, c *call, accountID string) (any, error) {
|
||||
return ec2accountapi.EnableEbsEncryptionByDefault(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"DisableEbsEncryptionByDefault": ec2Handler(func(ctx context.Context, input *ec2.DisableEbsEncryptionByDefaultInput, c *call, accountID string) (any, error) {
|
||||
return ec2accountapi.DisableEbsEncryptionByDefault(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"GetEbsEncryptionByDefault": ec2Handler(func(ctx context.Context, input *ec2.GetEbsEncryptionByDefaultInput, c *call, accountID string) (any, error) {
|
||||
return ec2accountapi.GetEbsEncryptionByDefault(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"GetSerialConsoleAccessStatus": ec2Handler(func(ctx context.Context, input *ec2.GetSerialConsoleAccessStatusInput, c *call, accountID string) (any, error) {
|
||||
return ec2accountapi.GetSerialConsoleAccessStatus(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"EnableSerialConsoleAccess": ec2Handler(func(ctx context.Context, input *ec2.EnableSerialConsoleAccessInput, c *call, accountID string) (any, error) {
|
||||
return ec2accountapi.EnableSerialConsoleAccess(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"DisableSerialConsoleAccess": ec2Handler(func(ctx context.Context, input *ec2.DisableSerialConsoleAccessInput, c *call, accountID string) (any, error) {
|
||||
return ec2accountapi.DisableSerialConsoleAccess(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"CreateTags": ec2Handler(func(ctx context.Context, input *ec2.CreateTagsInput, c *call, accountID string) (any, error) {
|
||||
return ec2tagsapi.CreateTags(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"DeleteTags": ec2Handler(func(ctx context.Context, input *ec2.DeleteTagsInput, c *call, accountID string) (any, error) {
|
||||
return ec2tagsapi.DeleteTags(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"DescribeTags": ec2Handler(func(ctx context.Context, input *ec2.DescribeTagsInput, c *call, accountID string) (any, error) {
|
||||
return ec2tagsapi.DescribeTags(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"CreateSnapshot": ec2Handler(func(ctx context.Context, input *ec2.CreateSnapshotInput, c *call, accountID string) (any, error) {
|
||||
return ec2snapshotapi.CreateSnapshot(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"DeleteSnapshot": ec2Handler(func(ctx context.Context, input *ec2.DeleteSnapshotInput, c *call, accountID string) (any, error) {
|
||||
return ec2snapshotapi.DeleteSnapshot(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"DescribeSnapshots": ec2Handler(func(ctx context.Context, input *ec2.DescribeSnapshotsInput, c *call, accountID string) (any, error) {
|
||||
return ec2snapshotapi.DescribeSnapshots(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"CopySnapshot": ec2Handler(func(ctx context.Context, input *ec2.CopySnapshotInput, c *call, accountID string) (any, error) {
|
||||
return ec2snapshotapi.CopySnapshot(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"CreateInternetGateway": ec2Handler(func(ctx context.Context, input *ec2.CreateInternetGatewayInput, c *call, accountID string) (any, error) {
|
||||
return ec2igwapi.CreateInternetGateway(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"DeleteInternetGateway": ec2Handler(func(ctx context.Context, input *ec2.DeleteInternetGatewayInput, c *call, accountID string) (any, error) {
|
||||
return ec2igwapi.DeleteInternetGateway(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"DescribeInternetGateways": ec2Handler(func(ctx context.Context, input *ec2.DescribeInternetGatewaysInput, c *call, accountID string) (any, error) {
|
||||
return ec2igwapi.DescribeInternetGateways(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"AttachInternetGateway": ec2Handler(func(ctx context.Context, input *ec2.AttachInternetGatewayInput, c *call, accountID string) (any, error) {
|
||||
return ec2igwapi.AttachInternetGateway(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"DetachInternetGateway": ec2Handler(func(ctx context.Context, input *ec2.DetachInternetGatewayInput, c *call, accountID string) (any, error) {
|
||||
return ec2igwapi.DetachInternetGateway(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"CreateEgressOnlyInternetGateway": ec2IdempotentHandler(func(ctx context.Context, input *ec2.CreateEgressOnlyInternetGatewayInput, c *call, accountID string) (ec2.CreateEgressOnlyInternetGatewayOutput, error) {
|
||||
return ec2eigwapi.CreateEgressOnlyInternetGateway(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"DeleteEgressOnlyInternetGateway": ec2Handler(func(ctx context.Context, input *ec2.DeleteEgressOnlyInternetGatewayInput, c *call, accountID string) (any, error) {
|
||||
return ec2eigwapi.DeleteEgressOnlyInternetGateway(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"DescribeEgressOnlyInternetGateways": ec2Handler(func(ctx context.Context, input *ec2.DescribeEgressOnlyInternetGatewaysInput, c *call, accountID string) (any, error) {
|
||||
return ec2eigwapi.DescribeEgressOnlyInternetGateways(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"CreatePlacementGroup": ec2Handler(func(ctx context.Context, input *ec2.CreatePlacementGroupInput, c *call, accountID string) (any, error) {
|
||||
return ec2placementgroupapi.CreatePlacementGroup(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"DeletePlacementGroup": ec2Handler(func(ctx context.Context, input *ec2.DeletePlacementGroupInput, c *call, accountID string) (any, error) {
|
||||
return ec2placementgroupapi.DeletePlacementGroup(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"DescribePlacementGroups": ec2Handler(func(ctx context.Context, input *ec2.DescribePlacementGroupsInput, c *call, accountID string) (any, error) {
|
||||
return ec2placementgroupapi.DescribePlacementGroups(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"CreateCapacityReservation": ec2IdempotentHandler(func(ctx context.Context, input *ec2.CreateCapacityReservationInput, c *call, accountID string) (ec2.CreateCapacityReservationOutput, error) {
|
||||
return ec2capacityreservationapi.CreateCapacityReservation(ctx, input, c.NATS, c.ExpectedNodes, accountID)
|
||||
}),
|
||||
"DescribeCapacityReservations": ec2Handler(func(ctx context.Context, input *ec2.DescribeCapacityReservationsInput, c *call, accountID string) (any, error) {
|
||||
return ec2capacityreservationapi.DescribeCapacityReservations(ctx, input, c.NATS, c.ExpectedNodes, accountID)
|
||||
}),
|
||||
"CancelCapacityReservation": ec2Handler(func(ctx context.Context, input *ec2.CancelCapacityReservationInput, c *call, accountID string) (any, error) {
|
||||
return ec2capacityreservationapi.CancelCapacityReservation(ctx, input, c.NATS, c.ExpectedNodes, accountID)
|
||||
}),
|
||||
"CreateLaunchTemplate": ec2Handler(func(ctx context.Context, input *ec2.CreateLaunchTemplateInput, c *call, accountID string) (any, error) {
|
||||
return ec2launchtemplateapi.CreateLaunchTemplate(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"CreateLaunchTemplateVersion": ec2IdempotentHandler(func(ctx context.Context, input *ec2.CreateLaunchTemplateVersionInput, c *call, accountID string) (ec2.CreateLaunchTemplateVersionOutput, error) {
|
||||
return ec2launchtemplateapi.CreateLaunchTemplateVersion(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"DeleteLaunchTemplate": ec2Handler(func(ctx context.Context, input *ec2.DeleteLaunchTemplateInput, c *call, accountID string) (any, error) {
|
||||
return ec2launchtemplateapi.DeleteLaunchTemplate(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"DeleteLaunchTemplateVersions": ec2Handler(func(ctx context.Context, input *ec2.DeleteLaunchTemplateVersionsInput, c *call, accountID string) (any, error) {
|
||||
return ec2launchtemplateapi.DeleteLaunchTemplateVersions(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"ModifyLaunchTemplate": ec2Handler(func(ctx context.Context, input *ec2.ModifyLaunchTemplateInput, c *call, accountID string) (any, error) {
|
||||
return ec2launchtemplateapi.ModifyLaunchTemplate(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"DescribeLaunchTemplates": ec2Handler(func(ctx context.Context, input *ec2.DescribeLaunchTemplatesInput, c *call, accountID string) (any, error) {
|
||||
return ec2launchtemplateapi.DescribeLaunchTemplates(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"DescribeLaunchTemplateVersions": ec2Handler(func(ctx context.Context, input *ec2.DescribeLaunchTemplateVersionsInput, c *call, accountID string) (any, error) {
|
||||
return ec2launchtemplateapi.DescribeLaunchTemplateVersions(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"RequestSpotInstances": ec2Handler(func(ctx context.Context, input *ec2.RequestSpotInstancesInput, c *call, accountID string) (any, error) {
|
||||
passRoleCheck := func(roleARN string) error {
|
||||
return c.passRole(roleARN)
|
||||
}
|
||||
return ec2spotinstanceapi.RequestSpotInstances(ctx, input, c.NATS, c.IAM, accountID, c.AZ, passRoleCheck, c.Quota, c.ExpectedNodes)
|
||||
}),
|
||||
"DescribeSpotInstanceRequests": ec2Handler(func(ctx context.Context, input *ec2.DescribeSpotInstanceRequestsInput, c *call, accountID string) (any, error) {
|
||||
return ec2spotinstanceapi.DescribeSpotInstanceRequests(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"CancelSpotInstanceRequests": ec2Handler(func(ctx context.Context, input *ec2.CancelSpotInstanceRequestsInput, c *call, accountID string) (any, error) {
|
||||
return ec2spotinstanceapi.CancelSpotInstanceRequests(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"CreateVpc": ec2Handler(func(ctx context.Context, input *ec2.CreateVpcInput, c *call, accountID string) (any, error) {
|
||||
if err := c.Quota.EnforceVPCs(ctx, c.NATS, accountID, 1); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ec2vpcapi.CreateVpc(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"DeleteVpc": ec2Handler(func(ctx context.Context, input *ec2.DeleteVpcInput, c *call, accountID string) (any, error) {
|
||||
return ec2vpcapi.DeleteVpc(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"DescribeVpcs": ec2Handler(func(ctx context.Context, input *ec2.DescribeVpcsInput, c *call, accountID string) (any, error) {
|
||||
return ec2vpcapi.DescribeVpcs(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"ModifyVpcAttribute": ec2Handler(func(ctx context.Context, input *ec2.ModifyVpcAttributeInput, c *call, accountID string) (any, error) {
|
||||
return ec2vpcapi.ModifyVpcAttribute(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"DescribeVpcAttribute": ec2Handler(func(ctx context.Context, input *ec2.DescribeVpcAttributeInput, c *call, accountID string) (any, error) {
|
||||
return ec2vpcapi.DescribeVpcAttribute(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
// No VPC endpoint can exist here, because CreateVpcEndpoint is not served, so
|
||||
// the empty list is the true answer rather than a stand-in for one. It has to
|
||||
// be answered all the same: the Terraform AWS provider calls this on every VPC
|
||||
// delete to sweep GuardDuty-managed endpoints, and an InvalidAction there fails
|
||||
// the destroy and strands the VPC.
|
||||
"DescribeVpcEndpoints": ec2Handler(func(_ context.Context, _ *ec2.DescribeVpcEndpointsInput, _ *call, _ string) (any, error) {
|
||||
return ec2.DescribeVpcEndpointsOutput{VpcEndpoints: []*ec2.VpcEndpoint{}}, nil
|
||||
}),
|
||||
"CreateSubnet": ec2Handler(func(ctx context.Context, input *ec2.CreateSubnetInput, c *call, accountID string) (any, error) {
|
||||
if err := c.Quota.EnforceSubnets(ctx, c.NATS, accountID, 1); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ec2vpcapi.CreateSubnet(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"DeleteSubnet": ec2Handler(func(ctx context.Context, input *ec2.DeleteSubnetInput, c *call, accountID string) (any, error) {
|
||||
return ec2vpcapi.DeleteSubnet(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"DescribeSubnets": ec2Handler(func(ctx context.Context, input *ec2.DescribeSubnetsInput, c *call, accountID string) (any, error) {
|
||||
return ec2vpcapi.DescribeSubnets(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"ModifySubnetAttribute": ec2Handler(func(ctx context.Context, input *ec2.ModifySubnetAttributeInput, c *call, accountID string) (any, error) {
|
||||
return ec2vpcapi.ModifySubnetAttribute(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"CreateRouteTable": ec2IdempotentHandler(func(ctx context.Context, input *ec2.CreateRouteTableInput, c *call, accountID string) (ec2.CreateRouteTableOutput, error) {
|
||||
return ec2routetableapi.CreateRouteTable(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"DeleteRouteTable": ec2Handler(func(ctx context.Context, input *ec2.DeleteRouteTableInput, c *call, accountID string) (any, error) {
|
||||
return ec2routetableapi.DeleteRouteTable(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"DescribeRouteTables": ec2Handler(func(ctx context.Context, input *ec2.DescribeRouteTablesInput, c *call, accountID string) (any, error) {
|
||||
return ec2routetableapi.DescribeRouteTables(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"CreateRoute": ec2Handler(func(ctx context.Context, input *ec2.CreateRouteInput, c *call, accountID string) (any, error) {
|
||||
return ec2routetableapi.CreateRoute(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"DeleteRoute": ec2Handler(func(ctx context.Context, input *ec2.DeleteRouteInput, c *call, accountID string) (any, error) {
|
||||
return ec2routetableapi.DeleteRoute(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"ReplaceRoute": ec2Handler(func(ctx context.Context, input *ec2.ReplaceRouteInput, c *call, accountID string) (any, error) {
|
||||
return ec2routetableapi.ReplaceRoute(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"AssociateRouteTable": ec2Handler(func(ctx context.Context, input *ec2.AssociateRouteTableInput, c *call, accountID string) (any, error) {
|
||||
return ec2routetableapi.AssociateRouteTable(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"DisassociateRouteTable": ec2Handler(func(ctx context.Context, input *ec2.DisassociateRouteTableInput, c *call, accountID string) (any, error) {
|
||||
return ec2routetableapi.DisassociateRouteTable(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"ReplaceRouteTableAssociation": ec2Handler(func(ctx context.Context, input *ec2.ReplaceRouteTableAssociationInput, c *call, accountID string) (any, error) {
|
||||
return ec2routetableapi.ReplaceRouteTableAssociation(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"CreateNetworkInterface": ec2IdempotentHandler(func(ctx context.Context, input *ec2.CreateNetworkInterfaceInput, c *call, accountID string) (ec2.CreateNetworkInterfaceOutput, error) {
|
||||
return ec2vpcapi.CreateNetworkInterface(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"DeleteNetworkInterface": ec2Handler(func(ctx context.Context, input *ec2.DeleteNetworkInterfaceInput, c *call, accountID string) (any, error) {
|
||||
return ec2vpcapi.DeleteNetworkInterface(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"DescribeNetworkInterfaces": ec2Handler(func(ctx context.Context, input *ec2.DescribeNetworkInterfacesInput, c *call, accountID string) (any, error) {
|
||||
return ec2vpcapi.DescribeNetworkInterfaces(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"ModifyNetworkInterfaceAttribute": ec2Handler(func(ctx context.Context, input *ec2.ModifyNetworkInterfaceAttributeInput, c *call, accountID string) (any, error) {
|
||||
return ec2vpcapi.ModifyNetworkInterfaceAttribute(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"AttachNetworkInterface": ec2Handler(func(ctx context.Context, input *ec2.AttachNetworkInterfaceInput, c *call, accountID string) (any, error) {
|
||||
return ec2vpcapi.AttachNetworkInterface(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"DetachNetworkInterface": ec2Handler(func(ctx context.Context, input *ec2.DetachNetworkInterfaceInput, c *call, accountID string) (any, error) {
|
||||
return ec2vpcapi.DetachNetworkInterface(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"CreateSecurityGroup": ec2Handler(func(ctx context.Context, input *ec2.CreateSecurityGroupInput, c *call, accountID string) (any, error) {
|
||||
return ec2vpcapi.CreateSecurityGroup(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"DeleteSecurityGroup": ec2Handler(func(ctx context.Context, input *ec2.DeleteSecurityGroupInput, c *call, accountID string) (any, error) {
|
||||
return ec2vpcapi.DeleteSecurityGroup(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"DescribeSecurityGroups": ec2Handler(func(ctx context.Context, input *ec2.DescribeSecurityGroupsInput, c *call, accountID string) (any, error) {
|
||||
return ec2vpcapi.DescribeSecurityGroups(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"DescribeSecurityGroupRules": ec2Handler(func(ctx context.Context, input *ec2.DescribeSecurityGroupRulesInput, c *call, accountID string) (any, error) {
|
||||
return ec2vpcapi.DescribeSecurityGroupRules(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"GetSecurityGroupsForVpc": ec2Handler(func(ctx context.Context, input *ec2.GetSecurityGroupsForVpcInput, c *call, accountID string) (any, error) {
|
||||
return ec2vpcapi.GetSecurityGroupsForVpc(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"AuthorizeSecurityGroupIngress": ec2Handler(func(ctx context.Context, input *ec2.AuthorizeSecurityGroupIngressInput, c *call, accountID string) (any, error) {
|
||||
return ec2vpcapi.AuthorizeSecurityGroupIngress(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"AuthorizeSecurityGroupEgress": ec2Handler(func(ctx context.Context, input *ec2.AuthorizeSecurityGroupEgressInput, c *call, accountID string) (any, error) {
|
||||
return ec2vpcapi.AuthorizeSecurityGroupEgress(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"RevokeSecurityGroupIngress": ec2Handler(func(ctx context.Context, input *ec2.RevokeSecurityGroupIngressInput, c *call, accountID string) (any, error) {
|
||||
return ec2vpcapi.RevokeSecurityGroupIngress(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"RevokeSecurityGroupEgress": ec2Handler(func(ctx context.Context, input *ec2.RevokeSecurityGroupEgressInput, c *call, accountID string) (any, error) {
|
||||
return ec2vpcapi.RevokeSecurityGroupEgress(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"UpdateSecurityGroupRuleDescriptionsIngress": ec2Handler(func(ctx context.Context, input *ec2.UpdateSecurityGroupRuleDescriptionsIngressInput, c *call, accountID string) (any, error) {
|
||||
return ec2vpcapi.UpdateSecurityGroupRuleDescriptionsIngress(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"UpdateSecurityGroupRuleDescriptionsEgress": ec2Handler(func(ctx context.Context, input *ec2.UpdateSecurityGroupRuleDescriptionsEgressInput, c *call, accountID string) (any, error) {
|
||||
return ec2vpcapi.UpdateSecurityGroupRuleDescriptionsEgress(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"ModifySecurityGroupRules": ec2Handler(func(ctx context.Context, input *ec2.ModifySecurityGroupRulesInput, c *call, accountID string) (any, error) {
|
||||
return ec2vpcapi.ModifySecurityGroupRules(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"AllocateAddress": ec2Handler(func(ctx context.Context, input *ec2.AllocateAddressInput, c *call, accountID string) (any, error) {
|
||||
if err := c.Quota.EnforceEIPs(ctx, c.NATS, accountID, 1); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ec2eipapi.AllocateAddress(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"ReleaseAddress": ec2Handler(func(ctx context.Context, input *ec2.ReleaseAddressInput, c *call, accountID string) (any, error) {
|
||||
return ec2eipapi.ReleaseAddress(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"AssociateAddress": ec2Handler(func(ctx context.Context, input *ec2.AssociateAddressInput, c *call, accountID string) (any, error) {
|
||||
return ec2eipapi.AssociateAddress(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"DisassociateAddress": ec2Handler(func(ctx context.Context, input *ec2.DisassociateAddressInput, c *call, accountID string) (any, error) {
|
||||
return ec2eipapi.DisassociateAddress(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"DescribeAddresses": ec2Handler(func(ctx context.Context, input *ec2.DescribeAddressesInput, c *call, accountID string) (any, error) {
|
||||
return ec2eipapi.DescribeAddresses(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"DescribeAddressesAttribute": ec2Handler(func(ctx context.Context, input *ec2.DescribeAddressesAttributeInput, c *call, accountID string) (any, error) {
|
||||
return ec2eipapi.DescribeAddressesAttribute(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"CreateNatGateway": ec2IdempotentHandler(func(ctx context.Context, input *ec2.CreateNatGatewayInput, c *call, accountID string) (ec2.CreateNatGatewayOutput, error) {
|
||||
return ec2natgwapi.CreateNatGateway(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"DeleteNatGateway": ec2Handler(func(ctx context.Context, input *ec2.DeleteNatGatewayInput, c *call, accountID string) (any, error) {
|
||||
return ec2natgwapi.DeleteNatGateway(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
"DescribeNatGateways": ec2Handler(func(ctx context.Context, input *ec2.DescribeNatGatewaysInput, c *call, accountID string) (any, error) {
|
||||
return ec2natgwapi.DescribeNatGateways(ctx, input, c.NATS, accountID)
|
||||
}),
|
||||
}
|
||||
|
||||
// defaultVPCID returns the ID of accountID's default VPC, or "" when it has
|
||||
// none, as the account's own DescribeVpcs sees it.
|
||||
func defaultVPCID(ctx context.Context, natsConn *nats.Conn, accountID string) (string, error) {
|
||||
if natsConn == nil {
|
||||
return "", errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
out, err := ec2vpcapi.DescribeVpcs(ctx, &ec2.DescribeVpcsInput{
|
||||
Filters: []*ec2.Filter{{Name: aws.String("is-default"), Values: aws.StringSlice([]string{"true"})}},
|
||||
}, natsConn, accountID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(out.Vpcs) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
return aws.StringValue(out.Vpcs[0].VpcId), nil
|
||||
}
|
||||
|
||||
// ec2LocalActions are actions that don't require NATS. DescribeAccountAttributes
|
||||
// needs it only for default-vpc.
|
||||
var ec2LocalActions = map[string]bool{
|
||||
"DescribeRegions": true,
|
||||
"DescribeAvailabilityZones": true,
|
||||
"DescribeAccountAttributes": true,
|
||||
}
|
||||
|
||||
// dryRunRequested reports whether a parsed EC2 input carries DryRun=true. Only
|
||||
// actions whose SDK input models the parameter can answer DryRunOperation.
|
||||
func dryRunRequested(input any) bool {
|
||||
v := reflect.ValueOf(input)
|
||||
if v.Kind() != reflect.Pointer || v.IsNil() || v.Elem().Kind() != reflect.Struct {
|
||||
return false
|
||||
}
|
||||
field := v.Elem().FieldByName("DryRun")
|
||||
if !field.IsValid() {
|
||||
return false
|
||||
}
|
||||
dryRun, ok := reflect.TypeAssert[*bool](field)
|
||||
return ok && aws.BoolValue(dryRun)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
//test:in-package — reads the unexported ec2Actions route table.
|
||||
|
||||
package awsapi
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestImportKeyPair_Base64PaddingWorkaround(t *testing.T) {
|
||||
// The ImportKeyPair handler decodes URL-encoded Base64 padding (%3D%3D → ==)
|
||||
// before passing to the generic handler.
|
||||
handler := ec2Actions["ImportKeyPair"]
|
||||
require.NotNil(t, handler)
|
||||
|
||||
q := map[string]string{
|
||||
"Action": "ImportKeyPair",
|
||||
"KeyName": "test-key",
|
||||
"PublicKeyMaterial": "c3NoLXJzYSBBQUFBQjNOemFDMXljMkVBQUFBREFRQUJBQUFCZ1FD%3D%3D",
|
||||
}
|
||||
|
||||
_, err := handler.parse(q)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.True(t, strings.HasSuffix(q["PublicKeyMaterial"], "=="),
|
||||
"Expected PublicKeyMaterial to end with == but got: %s", q["PublicKeyMaterial"])
|
||||
assert.NotContains(t, q["PublicKeyMaterial"], "%3D",
|
||||
"Expected no URL-encoded padding remaining")
|
||||
}
|
||||
+5
-6
@@ -1,12 +1,11 @@
|
||||
//test:in-package — ec2Actions is unexported here, and the completeness test
|
||||
// exists to compare it against the scope table.
|
||||
//test:in-package — ec2Actions is unexported, and the completeness test exists
|
||||
// to compare it against the scope table.
|
||||
|
||||
package gateway
|
||||
package awsapi
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
ec2awsapi "github.com/mulgadc/spinifex/spinifex/domains/ec2/awsapi"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
@@ -15,11 +14,11 @@ import (
|
||||
// behind by a deleted or renamed action fails too.
|
||||
func TestEC2ScopeTableIsExhaustive(t *testing.T) {
|
||||
for action := range ec2Actions {
|
||||
assert.True(t, ec2awsapi.HasScope(action),
|
||||
assert.True(t, HasScope(action),
|
||||
"ec2 action %q has no resource scope entry: add one to ec2Scopes in domains/ec2/awsapi/authz.go", action)
|
||||
}
|
||||
|
||||
for _, action := range ec2awsapi.ScopedActions() {
|
||||
for _, action := range ScopedActions() {
|
||||
_, ok := ec2Actions[action]
|
||||
assert.True(t, ok,
|
||||
"ec2Scopes has an entry for %q, which the dispatch table does not serve: remove it from domains/ec2/awsapi/authz.go", action)
|
||||
@@ -0,0 +1,65 @@
|
||||
package awsapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
ec2instanceapi "github.com/mulgadc/spinifex/spinifex/domains/ec2/awsapi/instance"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
// Quota is the account quota the EC2 actions consult and charge. It is declared
|
||||
// here because domains/admission/quota imports the EC2 action packages to count
|
||||
// live resources, and its retype gate takes a resolver type this package must
|
||||
// not name.
|
||||
type Quota interface {
|
||||
EnforceLaunch(ctx context.Context, accountID, instanceType string, maxCount int) error
|
||||
ChargeLaunch(ctx context.Context, accountID string, reservation *ec2.Reservation) error
|
||||
// EnforceRetype returns the vCPU delta AddVCPU charges once the retype applies.
|
||||
EnforceRetype(ctx context.Context, nc *nats.Conn, nodeIDs func() []string, accountID, instanceID, newType string) (int, error)
|
||||
AddVCPU(ctx context.Context, accountID string, delta int) error
|
||||
EnforceVolumeCreate(ctx context.Context, nc *nats.Conn, accountID string, requestedGiB int) error
|
||||
EnforceVolumeModify(ctx context.Context, nc *nats.Conn, accountID, volumeID string, newGiB int) error
|
||||
EnforceEIPs(ctx context.Context, nc *nats.Conn, accountID string, want int) error
|
||||
EnforceSubnets(ctx context.Context, nc *nats.Conn, accountID string, want int) error
|
||||
EnforceVPCs(ctx context.Context, nc *nats.Conn, accountID string, want int) error
|
||||
}
|
||||
|
||||
// Deps are the composed capabilities the EC2 control plane dispatches to.
|
||||
type Deps struct {
|
||||
NATS *nats.Conn
|
||||
IAM ec2instanceapi.InstanceProfiles
|
||||
Quota Quota
|
||||
// ActiveNodes is the number of daemons currently answering.
|
||||
ActiveNodes func(ctx context.Context) int
|
||||
ExpectedNodes int
|
||||
NodeIDs []string
|
||||
AZ string
|
||||
// Endpoint is the gateway's own dialable base URL DescribeRegions advertises.
|
||||
Endpoint string
|
||||
InstanceStatus ec2instanceapi.StatusSynthesis
|
||||
}
|
||||
|
||||
// Handler serves EC2 requests over its Deps. It binds the client-token store on
|
||||
// first use and is safe for concurrent requests.
|
||||
type Handler struct {
|
||||
Deps
|
||||
|
||||
tokenOnce sync.Once
|
||||
tokenKV jetstream.KeyValue
|
||||
tokenErr error
|
||||
}
|
||||
|
||||
// NewHandler returns the handler over deps.
|
||||
func NewHandler(deps Deps) *Handler { return &Handler{Deps: deps} }
|
||||
|
||||
// call is one request's view of the handler: the handler's capabilities plus the
|
||||
// region and the PassRole gate of the authenticated caller.
|
||||
type call struct {
|
||||
*Handler
|
||||
|
||||
Region string
|
||||
passRole func(roleARN string) error
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
//test:in-package — drives the unexported action table without a request.
|
||||
|
||||
package awsapi
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// DescribeRegions must advertise the endpoint the handler was composed with, so
|
||||
// a caller on a concrete-host deployment is never told to dial its own loopback.
|
||||
func TestDescribeRegions_UsesAdvertisedEndpoint(t *testing.T) {
|
||||
c := &call{Handler: NewHandler(Deps{Endpoint: "https://10.0.0.5:9999"}), Region: "ap-southeast-2"}
|
||||
h := ec2Actions["DescribeRegions"]
|
||||
input, err := h.parse(map[string]string{})
|
||||
require.NoError(t, err)
|
||||
xmlOutput, err := h.dispatch(t.Context(), "DescribeRegions", input, c, "acct-123")
|
||||
require.NoError(t, err)
|
||||
|
||||
body := string(xmlOutput)
|
||||
assert.Contains(t, body, "<regionEndpoint>https://10.0.0.5:9999</regionEndpoint>")
|
||||
assert.NotContains(t, body, "localhost")
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
//test:in-package — drives the unexported action table through Dispatch.
|
||||
|
||||
package awsapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/bluebottle/pkg/iampolicy"
|
||||
"github.com/mulgadc/spinifex/internal/testkit"
|
||||
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/arn"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
"github.com/mulgadc/spinifex/spinifex/ingress/aws/dispatch"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type allowQuota struct{}
|
||||
|
||||
var _ Quota = allowQuota{}
|
||||
|
||||
func (allowQuota) EnforceLaunch(context.Context, string, string, int) error { return nil }
|
||||
|
||||
func (allowQuota) ChargeLaunch(context.Context, string, *ec2.Reservation) error { return nil }
|
||||
|
||||
func (allowQuota) EnforceRetype(context.Context, *nats.Conn, func() []string, string, string, string) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (allowQuota) AddVCPU(context.Context, string, int) error { return nil }
|
||||
|
||||
func (allowQuota) EnforceVolumeCreate(context.Context, *nats.Conn, string, int) error { return nil }
|
||||
|
||||
func (allowQuota) EnforceVolumeModify(context.Context, *nats.Conn, string, string, int) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (allowQuota) EnforceEIPs(context.Context, *nats.Conn, string, int) error { return nil }
|
||||
|
||||
func (allowQuota) EnforceSubnets(context.Context, *nats.Conn, string, int) error { return nil }
|
||||
|
||||
func (allowQuota) EnforceVPCs(context.Context, *nats.Conn, string, int) error { return nil }
|
||||
|
||||
type noProfiles struct{}
|
||||
|
||||
func (noProfiles) ResolveInstanceProfile(string, string) (*iamdomain.InstanceProfile, error) {
|
||||
return nil, errors.New(awserrors.ErrorIAMNoSuchEntity)
|
||||
}
|
||||
|
||||
func (noProfiles) CanonicalResourceARN(string, arn.IAMResourceType, string) (string, error) {
|
||||
return "", errors.New(awserrors.ErrorIAMNoSuchEntity)
|
||||
}
|
||||
|
||||
func allowAll(string, string, []string, iampolicy.ConditionKeys) error { return nil }
|
||||
|
||||
func formRequest(body string) *http.Request {
|
||||
r := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))
|
||||
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
return r
|
||||
}
|
||||
|
||||
func newTestHandler(t *testing.T) *Handler {
|
||||
t.Helper()
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
// A daemon that answers every EC2 subject with an empty result, so each
|
||||
// action reaches its domain call and returns instead of waiting.
|
||||
sub, err := nc.Subscribe("ec2.>", func(m *nats.Msg) { _ = m.Respond([]byte("{}")) })
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = sub.Unsubscribe() })
|
||||
|
||||
return NewHandler(Deps{
|
||||
NATS: nc,
|
||||
IAM: noProfiles{},
|
||||
Quota: allowQuota{},
|
||||
ActiveNodes: func(context.Context) int { return 1 },
|
||||
ExpectedNodes: 1,
|
||||
NodeIDs: []string{"node-1"},
|
||||
AZ: "ap-southeast-2a",
|
||||
Endpoint: "https://localhost:9999",
|
||||
})
|
||||
}
|
||||
|
||||
// Every routed action must reach its handler through Dispatch. Inputs are empty,
|
||||
// so the domain call may reject them, but the wrapper must run and Dispatch must
|
||||
// answer with a result or an error rather than panic.
|
||||
func TestDispatch_EveryActionReachesItsHandler(t *testing.T) {
|
||||
h := newTestHandler(t)
|
||||
for _, action := range ActionNames() {
|
||||
t.Run(action, func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
inv := dispatch.Invocation{
|
||||
Request: formRequest("Action=" + action),
|
||||
AccountID: testAccountID,
|
||||
Service: ServiceName,
|
||||
Region: testRegion,
|
||||
Authorize: allowAll,
|
||||
}
|
||||
assert.NotPanics(t, func() { _ = h.Dispatch(w, inv) })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatch_RequestErrors(t *testing.T) {
|
||||
h := newTestHandler(t)
|
||||
deny := func(string, string, []string, iampolicy.ConditionKeys) error {
|
||||
return errors.New(awserrors.ErrorAccessDenied)
|
||||
}
|
||||
cases := []struct {
|
||||
name string
|
||||
body string
|
||||
accountID string
|
||||
authorize dispatch.AuthorizeFunc
|
||||
want string
|
||||
}{
|
||||
{"missing action", "Foo=bar", testAccountID, allowAll, awserrors.ErrorMissingAction},
|
||||
{"unknown action", "Action=NoSuchAction", testAccountID, allowAll, awserrors.ErrorInvalidAction},
|
||||
{"no account", "Action=DescribeVpcs", "", allowAll, awserrors.ErrorInternalError},
|
||||
{"policy denial", "Action=DescribeVpcs", testAccountID, deny, awserrors.ErrorUnauthorizedOperation},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := h.Dispatch(httptest.NewRecorder(), dispatch.Invocation{
|
||||
Request: formRequest(tc.body), AccountID: tc.accountID, Service: ServiceName,
|
||||
Region: testRegion, Authorize: tc.authorize,
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, tc.want, err.Error())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatch_SuccessWritesXML(t *testing.T) {
|
||||
h := newTestHandler(t)
|
||||
w := httptest.NewRecorder()
|
||||
err := h.Dispatch(w, dispatch.Invocation{
|
||||
Request: formRequest("Action=DescribeRegions"), AccountID: testAccountID,
|
||||
Service: ServiceName, Region: testRegion, Authorize: allowAll,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, http.StatusOK, w.Code)
|
||||
assert.Equal(t, "text/xml", w.Header().Get("Content-Type"))
|
||||
assert.Contains(t, w.Body.String(), "ap-southeast-2")
|
||||
}
|
||||
|
||||
// Without a NATS connection the gateway-local actions still answer and every
|
||||
// other action reports ServerInternal.
|
||||
func TestDispatch_NoNATS(t *testing.T) {
|
||||
h := NewHandler(Deps{AZ: "ap-southeast-2a", Endpoint: "https://localhost:9999"})
|
||||
inv := func(body string) dispatch.Invocation {
|
||||
return dispatch.Invocation{
|
||||
Request: formRequest(body), AccountID: testAccountID, Service: ServiceName,
|
||||
Region: testRegion, Authorize: allowAll,
|
||||
}
|
||||
}
|
||||
require.NoError(t, h.Dispatch(httptest.NewRecorder(), inv("Action=DescribeRegions")))
|
||||
err := h.Dispatch(httptest.NewRecorder(), inv("Action=DescribeVpcs"))
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, awserrors.ErrorServerInternal, err.Error())
|
||||
}
|
||||
|
||||
func TestInputHasField(t *testing.T) {
|
||||
assert.True(t, InputHasField("CreateVpc", "DryRun"))
|
||||
assert.False(t, InputHasField("CreateVpc", "NoSuchField"))
|
||||
assert.False(t, InputHasField("NoSuchAction", "DryRun"))
|
||||
}
|
||||
+4
-4
@@ -1,4 +1,4 @@
|
||||
package gateway
|
||||
package awsapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -13,17 +13,17 @@ import (
|
||||
)
|
||||
|
||||
// render drives a single EC2 action through ec2Handler exactly as the real
|
||||
// dispatch table does (see ec2Actions in ec2.go), with a stub inner handler
|
||||
// dispatch table does (see ec2Actions in actions.go), with a stub inner handler
|
||||
// standing in for the NATS round trip. This exercises the real marshal path
|
||||
// (currently marshalEC2Response) without needing a live NATS connection.
|
||||
func render[In any](t *testing.T, action string, out any) string {
|
||||
t.Helper()
|
||||
h := ec2Handler(func(_ context.Context, _ *In, _ *GatewayConfig, _ string) (any, error) {
|
||||
h := ec2Handler(func(_ context.Context, _ *In, _ *call, _ string) (any, error) {
|
||||
return out, nil
|
||||
})
|
||||
input, err := h.parse(map[string]string{})
|
||||
require.NoError(t, err)
|
||||
xmlOutput, err := h.dispatch(action, input, &GatewayConfig{}, "acct-123", nil)
|
||||
xmlOutput, err := h.dispatch(t.Context(), action, input, &call{Handler: NewHandler(Deps{})}, "acct-123")
|
||||
require.NoError(t, err)
|
||||
return string(xmlOutput)
|
||||
}
|
||||
@@ -1,11 +1,10 @@
|
||||
package gateway
|
||||
package awsapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
|
||||
ec2idemapi "github.com/mulgadc/spinifex/spinifex/domains/ec2/awsapi/idem"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
@@ -13,40 +12,39 @@ import (
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
)
|
||||
|
||||
// ec2ClientTokenBucket lazily binds the EC2 token bucket, once per gateway
|
||||
// clientTokenBucket lazily binds the EC2 token bucket, once per handler
|
||||
// rather than once per request.
|
||||
func (gw *GatewayConfig) ec2ClientTokenBucket(ctx context.Context) (jetstream.KeyValue, error) {
|
||||
gw.ec2TokenOnce.Do(func() {
|
||||
js, err := jetstream.New(gw.NATSConn)
|
||||
func (h *Handler) clientTokenBucket(ctx context.Context) (jetstream.KeyValue, error) {
|
||||
h.tokenOnce.Do(func() {
|
||||
js, err := jetstream.New(h.NATS)
|
||||
if err != nil {
|
||||
gw.ec2TokenErr = fmt.Errorf("clienttoken jetstream: %w", err)
|
||||
h.tokenErr = fmt.Errorf("clienttoken jetstream: %w", err)
|
||||
return
|
||||
}
|
||||
// The bind happens once, so it must not inherit the first caller's
|
||||
// cancellation: a client that disconnects mid-open would poison the
|
||||
// bucket for every later request. Deadline-free, so the open falls back
|
||||
// to the JetStream API's own timeout.
|
||||
gw.ec2TokenKV, gw.ec2TokenErr = idempotency.OpenBucket(
|
||||
h.tokenKV, h.tokenErr = idempotency.OpenBucket(
|
||||
context.WithoutCancel(ctx), js, ec2idemapi.KVBucket, ec2idemapi.TTL)
|
||||
})
|
||||
return gw.ec2TokenKV, gw.ec2TokenErr
|
||||
return h.tokenKV, h.tokenErr
|
||||
}
|
||||
|
||||
// ec2IdempotentHandler is ec2Handler for creates that honour ClientToken: a
|
||||
// repeat of the same token returns the first call's result instead of creating
|
||||
// a second resource. Out is pinned so the result can be stored and replayed.
|
||||
func ec2IdempotentHandler[In, Out any](handler func(ctx context.Context, input *In, gw *GatewayConfig, accountID string) (Out, error)) ec2Action {
|
||||
func ec2IdempotentHandler[In, Out any](handler func(ctx context.Context, input *In, c *call, accountID string) (Out, error)) ec2Action {
|
||||
return ec2Action{
|
||||
idempotent: true,
|
||||
parse: parseEC2Input[In],
|
||||
dispatch: func(action string, parsed any, gw *GatewayConfig, accountID string, r *http.Request) ([]byte, error) {
|
||||
dispatch: func(ctx context.Context, action string, parsed any, c *call, accountID string) ([]byte, error) {
|
||||
input, ok := parsed.(*In)
|
||||
if !ok {
|
||||
return nil, errors.New(awserrors.ErrorInternalError)
|
||||
}
|
||||
ctx := requestContext(r)
|
||||
output, err := runEC2Idempotent(ctx, action, gw, accountID, input, func() (Out, error) {
|
||||
return handler(ctx, input, gw, accountID)
|
||||
output, err := runEC2Idempotent(ctx, action, c, accountID, input, func() (Out, error) {
|
||||
return handler(ctx, input, c, accountID)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -61,7 +59,7 @@ func ec2IdempotentHandler[In, Out any](handler func(ctx context.Context, input *
|
||||
func runEC2Idempotent[In, Out any](
|
||||
ctx context.Context,
|
||||
action string,
|
||||
gw *GatewayConfig,
|
||||
c *call,
|
||||
accountID string,
|
||||
input *In,
|
||||
work func() (Out, error),
|
||||
@@ -74,7 +72,7 @@ func runEC2Idempotent[In, Out any](
|
||||
return work()
|
||||
}
|
||||
|
||||
kv, kerr := gw.ec2ClientTokenBucket(ctx)
|
||||
kv, kerr := c.clientTokenBucket(ctx)
|
||||
if kerr != nil {
|
||||
// Running anyway would create the duplicate the token was sent to
|
||||
// prevent, so this fails rather than degrading to no idempotency.
|
||||
+44
-45
@@ -1,8 +1,7 @@
|
||||
//test:in-package — the decorator, the action table and the gateway's token
|
||||
//bucket field are all unexported, and the point of these tests is that the
|
||||
//table's entries are wired to the decorator.
|
||||
//test:in-package — the decorator and the action table are unexported, and the
|
||||
// point of these tests is that the table entries are wired to the decorator.
|
||||
|
||||
package gateway
|
||||
package awsapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
@@ -60,32 +59,32 @@ func responseFields(t *testing.T, doc []byte) []string {
|
||||
return fields
|
||||
}
|
||||
|
||||
// newTokenGateway returns a gateway whose client-token bucket is already bound
|
||||
// newTokenCall returns a gateway whose client-token bucket is already bound
|
||||
// to a test JetStream, so dispatch never reaches a real NATS connection.
|
||||
func newTokenGateway(t *testing.T) *GatewayConfig {
|
||||
func newTokenCall(t *testing.T) *call {
|
||||
t.Helper()
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
kv, err := idempotency.OpenBucket(t.Context(), testutil.NewJetStream(t, nc), "gw-ec2-tokens", time.Minute)
|
||||
require.NoError(t, err)
|
||||
|
||||
gw := &GatewayConfig{}
|
||||
// Burns the Once as it binds, so ec2ClientTokenBucket returns this bucket
|
||||
h := NewHandler(Deps{})
|
||||
// Burns the Once as it binds, so clientTokenBucket returns this bucket
|
||||
// rather than dialing.
|
||||
gw.ec2TokenOnce.Do(func() { gw.ec2TokenKV = kv })
|
||||
return gw
|
||||
h.tokenOnce.Do(func() { h.tokenKV = kv })
|
||||
return &call{Handler: h}
|
||||
}
|
||||
|
||||
// dispatchTwice runs one action twice through the decorator with the same input
|
||||
// and reports how many times the create actually ran.
|
||||
func dispatchTwice[In, Out any](t *testing.T, gw *GatewayConfig, action string, input *In, result Out) (calls int, first, second []byte) {
|
||||
func dispatchTwice[In, Out any](t *testing.T, c *call, action string, input *In, result Out) (calls int, first, second []byte) {
|
||||
t.Helper()
|
||||
handler := ec2IdempotentHandler(func(ctx context.Context, in *In, gw *GatewayConfig, accountID string) (Out, error) {
|
||||
handler := ec2IdempotentHandler(func(ctx context.Context, in *In, c *call, accountID string) (Out, error) {
|
||||
calls++
|
||||
return result, nil
|
||||
})
|
||||
first, err := handler.dispatch(action, input, gw, idemTestAccount, nil)
|
||||
first, err := handler.dispatch(t.Context(), action, input, c, idemTestAccount)
|
||||
require.NoError(t, err)
|
||||
second, err = handler.dispatch(action, input, gw, idemTestAccount, nil)
|
||||
second, err = handler.dispatch(t.Context(), action, input, c, idemTestAccount)
|
||||
require.NoError(t, err)
|
||||
return calls, first, second
|
||||
}
|
||||
@@ -98,8 +97,8 @@ func TestIdempotentHandler_SameTokenCreatesOneResource(t *testing.T) {
|
||||
|
||||
t.Run("CreateVolume", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
gw := newTokenGateway(t)
|
||||
calls, first, second := dispatchTwice(t, gw, "CreateVolume",
|
||||
c := newTokenCall(t)
|
||||
calls, first, second := dispatchTwice(t, c, "CreateVolume",
|
||||
&ec2.CreateVolumeInput{Size: aws.Int64(8), ClientToken: tok},
|
||||
ec2.Volume{VolumeId: aws.String("vol-1")})
|
||||
assert.Equal(t, 1, calls)
|
||||
@@ -109,8 +108,8 @@ func TestIdempotentHandler_SameTokenCreatesOneResource(t *testing.T) {
|
||||
|
||||
t.Run("CreateNetworkInterface", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
gw := newTokenGateway(t)
|
||||
calls, first, second := dispatchTwice(t, gw, "CreateNetworkInterface",
|
||||
c := newTokenCall(t)
|
||||
calls, first, second := dispatchTwice(t, c, "CreateNetworkInterface",
|
||||
&ec2.CreateNetworkInterfaceInput{ClientToken: tok},
|
||||
ec2.CreateNetworkInterfaceOutput{
|
||||
NetworkInterface: &ec2.NetworkInterface{NetworkInterfaceId: aws.String("eni-1")},
|
||||
@@ -123,8 +122,8 @@ func TestIdempotentHandler_SameTokenCreatesOneResource(t *testing.T) {
|
||||
// Both IDs have to survive the replay: re-creating either one leaks it.
|
||||
t.Run("CreateNatGateway", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
gw := newTokenGateway(t)
|
||||
calls, first, second := dispatchTwice(t, gw, "CreateNatGateway",
|
||||
c := newTokenCall(t)
|
||||
calls, first, second := dispatchTwice(t, c, "CreateNatGateway",
|
||||
&ec2.CreateNatGatewayInput{ClientToken: tok},
|
||||
ec2.CreateNatGatewayOutput{
|
||||
NatGateway: &ec2.NatGateway{
|
||||
@@ -142,8 +141,8 @@ func TestIdempotentHandler_SameTokenCreatesOneResource(t *testing.T) {
|
||||
|
||||
t.Run("CreateRouteTable", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
gw := newTokenGateway(t)
|
||||
calls, first, second := dispatchTwice(t, gw, "CreateRouteTable",
|
||||
c := newTokenCall(t)
|
||||
calls, first, second := dispatchTwice(t, c, "CreateRouteTable",
|
||||
&ec2.CreateRouteTableInput{ClientToken: tok},
|
||||
ec2.CreateRouteTableOutput{
|
||||
RouteTable: &ec2.RouteTable{
|
||||
@@ -161,8 +160,8 @@ func TestIdempotentHandler_SameTokenCreatesOneResource(t *testing.T) {
|
||||
|
||||
t.Run("CreateEgressOnlyInternetGateway", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
gw := newTokenGateway(t)
|
||||
calls, first, second := dispatchTwice(t, gw, "CreateEgressOnlyInternetGateway",
|
||||
c := newTokenCall(t)
|
||||
calls, first, second := dispatchTwice(t, c, "CreateEgressOnlyInternetGateway",
|
||||
&ec2.CreateEgressOnlyInternetGatewayInput{ClientToken: tok},
|
||||
ec2.CreateEgressOnlyInternetGatewayOutput{
|
||||
EgressOnlyInternetGateway: &ec2.EgressOnlyInternetGateway{
|
||||
@@ -176,8 +175,8 @@ func TestIdempotentHandler_SameTokenCreatesOneResource(t *testing.T) {
|
||||
|
||||
t.Run("CopyImage", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
gw := newTokenGateway(t)
|
||||
calls, first, second := dispatchTwice(t, gw, "CopyImage",
|
||||
c := newTokenCall(t)
|
||||
calls, first, second := dispatchTwice(t, c, "CopyImage",
|
||||
&ec2.CopyImageInput{Name: aws.String("copy"), ClientToken: tok},
|
||||
ec2.CopyImageOutput{ImageId: aws.String("ami-1")})
|
||||
assert.Equal(t, 1, calls)
|
||||
@@ -189,8 +188,8 @@ func TestIdempotentHandler_SameTokenCreatesOneResource(t *testing.T) {
|
||||
// client that thought it made one call.
|
||||
t.Run("CreateLaunchTemplateVersion", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
gw := newTokenGateway(t)
|
||||
calls, first, second := dispatchTwice(t, gw, "CreateLaunchTemplateVersion",
|
||||
c := newTokenCall(t)
|
||||
calls, first, second := dispatchTwice(t, c, "CreateLaunchTemplateVersion",
|
||||
&ec2.CreateLaunchTemplateVersionInput{LaunchTemplateId: aws.String("lt-1"), ClientToken: tok},
|
||||
ec2.CreateLaunchTemplateVersionOutput{
|
||||
LaunchTemplateVersion: &ec2.LaunchTemplateVersion{
|
||||
@@ -205,8 +204,8 @@ func TestIdempotentHandler_SameTokenCreatesOneResource(t *testing.T) {
|
||||
|
||||
t.Run("CreateCapacityReservation", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
gw := newTokenGateway(t)
|
||||
calls, first, second := dispatchTwice(t, gw, "CreateCapacityReservation",
|
||||
c := newTokenCall(t)
|
||||
calls, first, second := dispatchTwice(t, c, "CreateCapacityReservation",
|
||||
&ec2.CreateCapacityReservationInput{ClientToken: tok},
|
||||
ec2.CreateCapacityReservationOutput{
|
||||
CapacityReservation: &ec2.CapacityReservation{
|
||||
@@ -223,14 +222,14 @@ func TestIdempotentHandler_SameTokenCreatesOneResource(t *testing.T) {
|
||||
// separate pieces of work rather than one replaying the other's response.
|
||||
func TestIdempotentHandler_TokensAreScopedPerAction(t *testing.T) {
|
||||
t.Parallel()
|
||||
gw := newTokenGateway(t)
|
||||
c := newTokenCall(t)
|
||||
tok := aws.String("shared-token")
|
||||
|
||||
volumeCalls, _, _ := dispatchTwice(t, gw, "CreateVolume",
|
||||
volumeCalls, _, _ := dispatchTwice(t, c, "CreateVolume",
|
||||
&ec2.CreateVolumeInput{ClientToken: tok}, ec2.Volume{VolumeId: aws.String("vol-1")})
|
||||
require.Equal(t, 1, volumeCalls)
|
||||
|
||||
eigwCalls, _, second := dispatchTwice(t, gw, "CreateEgressOnlyInternetGateway",
|
||||
eigwCalls, _, second := dispatchTwice(t, c, "CreateEgressOnlyInternetGateway",
|
||||
&ec2.CreateEgressOnlyInternetGatewayInput{ClientToken: tok},
|
||||
ec2.CreateEgressOnlyInternetGatewayOutput{
|
||||
EgressOnlyInternetGateway: &ec2.EgressOnlyInternetGateway{
|
||||
@@ -246,19 +245,19 @@ func TestIdempotentHandler_TokensAreScopedPerAction(t *testing.T) {
|
||||
// must not run.
|
||||
func TestIdempotentHandler_ParamMismatchIsRejected(t *testing.T) {
|
||||
t.Parallel()
|
||||
gw := newTokenGateway(t)
|
||||
c := newTokenCall(t)
|
||||
calls := 0
|
||||
handler := ec2IdempotentHandler(func(ctx context.Context, in *ec2.CreateVolumeInput, gw *GatewayConfig, accountID string) (ec2.Volume, error) {
|
||||
handler := ec2IdempotentHandler(func(ctx context.Context, in *ec2.CreateVolumeInput, c *call, accountID string) (ec2.Volume, error) {
|
||||
calls++
|
||||
return ec2.Volume{VolumeId: aws.String("vol-1")}, nil
|
||||
})
|
||||
|
||||
_, err := handler.dispatch("CreateVolume",
|
||||
&ec2.CreateVolumeInput{Size: aws.Int64(8), ClientToken: aws.String("tok-mm")}, gw, idemTestAccount, nil)
|
||||
_, err := handler.dispatch(t.Context(), "CreateVolume",
|
||||
&ec2.CreateVolumeInput{Size: aws.Int64(8), ClientToken: aws.String("tok-mm")}, c, idemTestAccount)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = handler.dispatch("CreateVolume",
|
||||
&ec2.CreateVolumeInput{Size: aws.Int64(16), ClientToken: aws.String("tok-mm")}, gw, idemTestAccount, nil)
|
||||
_, err = handler.dispatch(t.Context(), "CreateVolume",
|
||||
&ec2.CreateVolumeInput{Size: aws.Int64(16), ClientToken: aws.String("tok-mm")}, c, idemTestAccount)
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, awserrors.ErrorIdempotentParameterMismatch, err.Error())
|
||||
assert.Equal(t, 1, calls, "a mismatched token must not create anything")
|
||||
@@ -267,8 +266,8 @@ func TestIdempotentHandler_ParamMismatchIsRejected(t *testing.T) {
|
||||
// Without a token the client did not ask for idempotency, so each call creates.
|
||||
func TestIdempotentHandler_NoTokenCreatesEachTime(t *testing.T) {
|
||||
t.Parallel()
|
||||
gw := newTokenGateway(t)
|
||||
calls, _, _ := dispatchTwice(t, gw, "CreateVolume",
|
||||
c := newTokenCall(t)
|
||||
calls, _, _ := dispatchTwice(t, c, "CreateVolume",
|
||||
&ec2.CreateVolumeInput{Size: aws.Int64(8)}, ec2.Volume{VolumeId: aws.String("vol-1")})
|
||||
assert.Equal(t, 2, calls)
|
||||
}
|
||||
@@ -277,10 +276,10 @@ func TestIdempotentHandler_NoTokenCreatesEachTime(t *testing.T) {
|
||||
// again rather than replay the failure.
|
||||
func TestIdempotentHandler_FailedCreateIsRetryable(t *testing.T) {
|
||||
t.Parallel()
|
||||
gw := newTokenGateway(t)
|
||||
c := newTokenCall(t)
|
||||
boom := errors.New("create failed")
|
||||
calls := 0
|
||||
handler := ec2IdempotentHandler(func(ctx context.Context, in *ec2.CreateVolumeInput, gw *GatewayConfig, accountID string) (ec2.Volume, error) {
|
||||
handler := ec2IdempotentHandler(func(ctx context.Context, in *ec2.CreateVolumeInput, c *call, accountID string) (ec2.Volume, error) {
|
||||
calls++
|
||||
if calls == 1 {
|
||||
return ec2.Volume{}, boom
|
||||
@@ -289,10 +288,10 @@ func TestIdempotentHandler_FailedCreateIsRetryable(t *testing.T) {
|
||||
})
|
||||
input := &ec2.CreateVolumeInput{Size: aws.Int64(8), ClientToken: aws.String("tok-retry")}
|
||||
|
||||
_, err := handler.dispatch("CreateVolume", input, gw, idemTestAccount, nil)
|
||||
_, err := handler.dispatch(t.Context(), "CreateVolume", input, c, idemTestAccount)
|
||||
require.ErrorIs(t, err, boom, "the handler's own error reaches the caller unchanged")
|
||||
|
||||
out, err := handler.dispatch("CreateVolume", input, gw, idemTestAccount, nil)
|
||||
out, err := handler.dispatch(t.Context(), "CreateVolume", input, c, idemTestAccount)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 2, calls)
|
||||
assert.Contains(t, string(out), "vol-1")
|
||||
@@ -14,7 +14,6 @@ import (
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
@@ -465,7 +464,7 @@ func judgeIdentityCompleteness(ctx context.Context, frames []natsmsg.Frame, sum
|
||||
// that carries only an ARN. Results are cached per ARN to avoid repeated RPCs.
|
||||
// Misses are warn-logged and leave Id empty (graceful degradation for deleted profiles).
|
||||
// Safe to call with a nil output or nil IAMService (no-op).
|
||||
func EnrichInstanceProfileIDs(out *ec2.DescribeInstancesOutput, iamSvc iamdomain.IAMService, accountID string) {
|
||||
func EnrichInstanceProfileIDs(out *ec2.DescribeInstancesOutput, iamSvc InstanceProfiles, accountID string) {
|
||||
if out == nil || iamSvc == nil {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ const fanOutTimeout = 3 * time.Second
|
||||
|
||||
// resolveAndAuthorizeProfile resolves a profile (name or ARN) and enforces iam:PassRole.
|
||||
// Profiles without a role skip PassRole; missing profile → InvalidIamInstanceProfile.NotFound.
|
||||
func resolveAndAuthorizeProfile(spec *ec2.IamInstanceProfileSpecification, iamSvc iamdomain.IAMService, accountID string, passRoleCheck PassRoleChecker) (*iamdomain.InstanceProfile, error) {
|
||||
func resolveAndAuthorizeProfile(spec *ec2.IamInstanceProfileSpecification, iamSvc InstanceProfiles, accountID string, passRoleCheck PassRoleChecker) (*iamdomain.InstanceProfile, error) {
|
||||
nameOrARN := profileNameOrARN(spec)
|
||||
if nameOrARN == "" {
|
||||
return nil, errors.New(awserrors.ErrorMissingParameter)
|
||||
@@ -55,7 +55,7 @@ func resolveAndAuthorizeProfile(spec *ec2.IamInstanceProfileSpecification, iamSv
|
||||
// AssociateIamInstanceProfile attaches a profile to a running instance.
|
||||
// The gateway resolves the profile and enforces PassRole; the daemon validates
|
||||
// no existing profile and generates a fresh iip-assoc-… ID.
|
||||
func AssociateIamInstanceProfile(ctx context.Context, input *ec2.AssociateIamInstanceProfileInput, natsConn *nats.Conn, iamSvc iamdomain.IAMService, accountID string, passRoleCheck PassRoleChecker) (*ec2.AssociateIamInstanceProfileOutput, error) {
|
||||
func AssociateIamInstanceProfile(ctx context.Context, input *ec2.AssociateIamInstanceProfileInput, natsConn *nats.Conn, iamSvc InstanceProfiles, accountID string, passRoleCheck PassRoleChecker) (*ec2.AssociateIamInstanceProfileOutput, error) {
|
||||
if input == nil || input.InstanceId == nil || *input.InstanceId == "" {
|
||||
return nil, errors.New(awserrors.ErrorMissingParameter)
|
||||
}
|
||||
@@ -107,7 +107,7 @@ func DisassociateIamInstanceProfile(ctx context.Context, input *ec2.Disassociate
|
||||
|
||||
// ReplaceIamInstanceProfileAssociation swaps the profile for an existing association.
|
||||
// The gateway resolves the new profile and enforces PassRole; the daemon swaps atomically.
|
||||
func ReplaceIamInstanceProfileAssociation(ctx context.Context, input *ec2.ReplaceIamInstanceProfileAssociationInput, natsConn *nats.Conn, iamSvc iamdomain.IAMService, expectedNodes int, accountID string, passRoleCheck PassRoleChecker) (*ec2.ReplaceIamInstanceProfileAssociationOutput, error) {
|
||||
func ReplaceIamInstanceProfileAssociation(ctx context.Context, input *ec2.ReplaceIamInstanceProfileAssociationInput, natsConn *nats.Conn, iamSvc InstanceProfiles, expectedNodes int, accountID string, passRoleCheck PassRoleChecker) (*ec2.ReplaceIamInstanceProfileAssociationOutput, error) {
|
||||
if input == nil || input.AssociationId == nil || *input.AssociationId == "" {
|
||||
return nil, errors.New(awserrors.ErrorMissingParameter)
|
||||
}
|
||||
@@ -245,3 +245,10 @@ func broadcastDescribeAssociations(ctx context.Context, natsConn *nats.Conn, inp
|
||||
}
|
||||
return associations, nil
|
||||
}
|
||||
|
||||
// InstanceProfiles is the IAM capability the instance-profile actions consume:
|
||||
// resolving a profile and naming the role it holds.
|
||||
type InstanceProfiles interface {
|
||||
ResolveInstanceProfile(accountID, nameOrARN string) (*iamdomain.InstanceProfile, error)
|
||||
CanonicalResourceARN(accountID string, kind arn.IAMResourceType, name string) (string, error)
|
||||
}
|
||||
|
||||
@@ -131,7 +131,7 @@ func hasPinnedPrivateIP(input *ec2.RunInstancesInput) bool {
|
||||
// passRoleCheck may be nil to skip PassRole enforcement.
|
||||
// launchQuotaCheck may be nil to skip quota enforcement; when present it runs
|
||||
// after validation and PassRole authorization, but before any launch dispatch.
|
||||
func RunInstances(ctx context.Context, input *ec2.RunInstancesInput, natsConn *nats.Conn, iamSvc iamdomain.IAMService, accountID string, passRoleCheck PassRoleChecker, launchQuotaCheck LaunchQuotaChecker, expectedNodes int) (reservation ec2.Reservation, err error) {
|
||||
func RunInstances(ctx context.Context, input *ec2.RunInstancesInput, natsConn *nats.Conn, iamSvc InstanceProfiles, accountID string, passRoleCheck PassRoleChecker, launchQuotaCheck LaunchQuotaChecker, expectedNodes int) (reservation ec2.Reservation, err error) {
|
||||
// Expand a referenced launch template before validation so validation, quota,
|
||||
// and downstream routing all see the effective (post-merge) parameters.
|
||||
if err = ExpandLaunchTemplate(ctx, natsConn, input, accountID); err != nil {
|
||||
@@ -163,7 +163,7 @@ func RunInstances(ctx context.Context, input *ec2.RunInstancesInput, natsConn *n
|
||||
|
||||
// runInstancesInner performs the actual launch: profile resolution, placement
|
||||
// routing, and capacity-aware distribution. Wrapped by RunInstances for idempotency.
|
||||
func runInstancesInner(ctx context.Context, input *ec2.RunInstancesInput, natsConn *nats.Conn, iamSvc iamdomain.IAMService, accountID string, passRoleCheck PassRoleChecker, launchQuotaCheck LaunchQuotaChecker, expectedNodes int) (reservation ec2.Reservation, err error) {
|
||||
func runInstancesInner(ctx context.Context, input *ec2.RunInstancesInput, natsConn *nats.Conn, iamSvc InstanceProfiles, accountID string, passRoleCheck PassRoleChecker, launchQuotaCheck LaunchQuotaChecker, expectedNodes int) (reservation ec2.Reservation, err error) {
|
||||
resolvedProfile, err := resolveAndAuthorizeInstanceProfile(input, iamSvc, accountID, passRoleCheck)
|
||||
if err != nil {
|
||||
return reservation, err
|
||||
@@ -236,7 +236,7 @@ func runInstancesInner(ctx context.Context, input *ec2.RunInstancesInput, natsCo
|
||||
|
||||
// resolveAndAuthorizeInstanceProfile resolves an optional instance profile,
|
||||
// enforces PassRole, and normalises input to the canonical ARN only.
|
||||
func resolveAndAuthorizeInstanceProfile(input *ec2.RunInstancesInput, iamSvc iamdomain.IAMService, accountID string, passRoleCheck PassRoleChecker) (*iamdomain.InstanceProfile, error) {
|
||||
func resolveAndAuthorizeInstanceProfile(input *ec2.RunInstancesInput, iamSvc InstanceProfiles, accountID string, passRoleCheck PassRoleChecker) (*iamdomain.InstanceProfile, error) {
|
||||
if input.IamInstanceProfile == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
package awsapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"slices"
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/bluebottle/pkg/iampolicy"
|
||||
"github.com/mulgadc/bluebottle/pkg/sigv4"
|
||||
ec2instanceapi "github.com/mulgadc/spinifex/spinifex/domains/ec2/awsapi/instance"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
"github.com/mulgadc/spinifex/spinifex/ingress/aws/dispatch"
|
||||
"github.com/mulgadc/spinifex/spinifex/ingress/aws/query"
|
||||
)
|
||||
|
||||
const (
|
||||
// ServiceName is the SigV4 credential-scope service EC2 clients sign for.
|
||||
ServiceName = "ec2"
|
||||
|
||||
ec2ServicePrincipal = "ec2.amazonaws.com"
|
||||
)
|
||||
|
||||
// readQueryArgs parses the Query body and rewinds it, so the same bytes can be
|
||||
// read again. Any read or parse failure is a malformed query string.
|
||||
func readQueryArgs(r *http.Request) (map[string]string, error) {
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, sigv4.MaxPayloadLen+1))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
r.Body = io.NopCloser(bytes.NewReader(body))
|
||||
if int64(len(body)) > sigv4.MaxPayloadLen {
|
||||
return nil, errors.New(awserrors.ErrorRequestEntityTooLarge)
|
||||
}
|
||||
return query.ParseArgs(string(body))
|
||||
}
|
||||
|
||||
// Dispatch parses, authorizes and runs one EC2 request, then writes the XML
|
||||
// response. A returned error is an awserrors code the gateway renders.
|
||||
func (h *Handler) Dispatch(w http.ResponseWriter, inv dispatch.Invocation) error {
|
||||
r := inv.Request
|
||||
queryArgs, err := readQueryArgs(r)
|
||||
if err != nil {
|
||||
slog.Debug("EC2: malformed query string", "err", err)
|
||||
return errors.New(awserrors.ErrorMalformedQueryString)
|
||||
}
|
||||
|
||||
action := queryArgs["Action"]
|
||||
if action == "" {
|
||||
return errors.New(awserrors.ErrorMissingAction)
|
||||
}
|
||||
handler, ok := ec2Actions[action]
|
||||
if !ok {
|
||||
return errors.New(awserrors.ErrorInvalidAction)
|
||||
}
|
||||
|
||||
// Hoisted above the policy check because the resolver builds ARNs from it.
|
||||
// inv.Region is the gateway's own region, never the caller-supplied
|
||||
// credential-scope region, which would let a caller sign for another region
|
||||
// and slide out from under a Deny.
|
||||
accountID := inv.AccountID
|
||||
if accountID == "" {
|
||||
slog.Error("EC2 dispatch: no account ID in auth context")
|
||||
// InternalError, not ServerInternal: the policy gate used to reach this
|
||||
// case first and that is the code the caller has always seen.
|
||||
return errors.New(awserrors.ErrorInternalError)
|
||||
}
|
||||
|
||||
input, err := handler.parse(queryArgs)
|
||||
if err != nil {
|
||||
if errors.Is(err, query.ErrSliceTooLarge) {
|
||||
return errors.New(awserrors.ErrorMalformedQueryString)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Launch templates supply effective RunInstances resources. Resolve them
|
||||
// before authorization, and dispatch the same expanded input afterwards.
|
||||
if runInput, ok := input.(*ec2.RunInstancesInput); ok && action == "RunInstances" {
|
||||
if err := ec2instanceapi.ExpandLaunchTemplate(r.Context(), h.NATS, runInput, accountID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
resources, err := ResourceARNs(action, inv.Region, accountID, input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := inv.Authorize(ServiceName, action, resources, nil); err != nil {
|
||||
// EC2 answers a policy denial with UnauthorizedOperation, not AccessDenied.
|
||||
if code, _ := awserrors.ResolveErrorCode(err); code == awserrors.ErrorAccessDenied {
|
||||
return errors.New(awserrors.ErrorUnauthorizedOperation)
|
||||
}
|
||||
return err
|
||||
}
|
||||
// AWS refuses a bad tag resource type even on a DryRun request.
|
||||
if err := validateTagSpecificationTypes(action, input); err != nil {
|
||||
return err
|
||||
}
|
||||
if dryRunRequested(input) {
|
||||
return errors.New(awserrors.ErrorDryRunOperation)
|
||||
}
|
||||
if err := validateMaxResults(action, input); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if h.NATS == nil && !ec2LocalActions[action] {
|
||||
return errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
|
||||
c := &call{
|
||||
Handler: h,
|
||||
Region: inv.Region,
|
||||
passRole: func(roleARN string) error {
|
||||
return inv.Authorize("iam", "PassRole", []string{roleARN},
|
||||
iampolicy.ConditionKeys{iampolicy.KeyPassedToService: ec2ServicePrincipal})
|
||||
},
|
||||
}
|
||||
xmlOutput, err := handler.dispatch(requestContext(r), action, input, c, accountID)
|
||||
if err != nil {
|
||||
// A gateway iam:PassRole denial is EC2's UnauthorizedOperation too; an
|
||||
// AccessDenied a daemon returns is left as it is.
|
||||
if _, denied := errors.AsType[*dispatch.PolicyDenialError](err); denied {
|
||||
return errors.New(awserrors.ErrorUnauthorizedOperation)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/xml")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if _, err := w.Write(xmlOutput); err != nil {
|
||||
slog.Error("Failed to write EC2 response", "err", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ActionNames lists every action the handler dispatches, sorted.
|
||||
func ActionNames() []string {
|
||||
return slices.Sorted(maps.Keys(ec2Actions))
|
||||
}
|
||||
|
||||
// InputHasField reports whether the SDK input type an action parses into has
|
||||
// the named field, so callers can ask what the model declares for an action
|
||||
// without reaching the route table.
|
||||
func InputHasField(action, field string) bool {
|
||||
entry, ok := ec2Actions[action]
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
input, err := entry.parse(map[string]string{"Action": action})
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
_, has := reflect.TypeOf(input).Elem().FieldByName(field)
|
||||
return has
|
||||
}
|
||||
@@ -14,10 +14,8 @@ import (
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
admissionquota "github.com/mulgadc/spinifex/spinifex/domains/admission/quota"
|
||||
ec2instanceapi "github.com/mulgadc/spinifex/spinifex/domains/ec2/awsapi/instance"
|
||||
ec2spotinstance "github.com/mulgadc/spinifex/spinifex/domains/ec2/spotinstance"
|
||||
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
awsidentifiers "github.com/mulgadc/spinifex/spinifex/foundation/aws/identifiers"
|
||||
awstags "github.com/mulgadc/spinifex/spinifex/foundation/aws/tags"
|
||||
@@ -64,7 +62,7 @@ func ValidateRequestSpotInstancesInput(input *ec2.RequestSpotInstancesInput) err
|
||||
// ClientToken passed through, same vCPU quota gate), then builds and persists one
|
||||
// active/fulfilled SpotInstanceRequest per launched instance. On a launch failure
|
||||
// (including InsufficientInstanceCapacity) it returns the error and persists nothing.
|
||||
func RequestSpotInstances(ctx context.Context, input *ec2.RequestSpotInstancesInput, natsConn *nats.Conn, iamSvc iamdomain.IAMService, accountID, az string, passRoleCheck ec2instanceapi.PassRoleChecker, quota *admissionquota.Service, expectedNodes int) (ec2.RequestSpotInstancesOutput, error) {
|
||||
func RequestSpotInstances(ctx context.Context, input *ec2.RequestSpotInstancesInput, natsConn *nats.Conn, iamSvc ec2instanceapi.InstanceProfiles, accountID, az string, passRoleCheck ec2instanceapi.PassRoleChecker, quota LaunchQuota, expectedNodes int) (ec2.RequestSpotInstancesOutput, error) {
|
||||
var output ec2.RequestSpotInstancesOutput
|
||||
|
||||
if err := ValidateRequestSpotInstancesInput(input); err != nil {
|
||||
@@ -224,3 +222,9 @@ func buildSpotRequests(input *ec2.RequestSpotInstancesInput, runInput *ec2.RunIn
|
||||
}
|
||||
return requests
|
||||
}
|
||||
|
||||
// LaunchQuota is the vCPU quota a spot request launches under.
|
||||
type LaunchQuota interface {
|
||||
EnforceLaunch(ctx context.Context, accountID, instanceType string, maxCount int) error
|
||||
ChargeLaunch(ctx context.Context, accountID string, reservation *ec2.Reservation) error
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
package gateway
|
||||
package awsapi
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
@@ -0,0 +1,26 @@
|
||||
//test:in-package — walks the unexported ec2Actions route table.
|
||||
|
||||
package awsapi
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// An action added to the route table with TagSpecifications must say which
|
||||
// resource types it tags, or a wrong type would be silently dropped again.
|
||||
func TestEC2TaggableTypes_CoverEveryAction(t *testing.T) {
|
||||
for action, handler := range ec2Actions {
|
||||
input, err := handler.parse(map[string]string{"Action": action})
|
||||
require.NoError(t, err, action)
|
||||
_, tags := reflect.TypeOf(input).Elem().FieldByName("TagSpecifications")
|
||||
_, listed := ec2TaggableTypes[action]
|
||||
assert.Equal(t, tags, listed, "%s: TagSpecifications modelled=%v, listed in ec2TaggableTypes=%v", action, tags, listed)
|
||||
}
|
||||
for action := range ec2TaggableTypes {
|
||||
assert.Contains(t, ec2Actions, action, "ec2TaggableTypes lists an action the registration does not route")
|
||||
}
|
||||
}
|
||||
+36
-728
@@ -1,166 +1,30 @@
|
||||
// Package gateway provides the AWS-compatible API gateway for the Spinifex platform.
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"github.com/mulgadc/spinifex/spinifex/runtime/roles/awsgw/policyauth"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strings"
|
||||
"uuid"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/bluebottle/pkg/iampolicy"
|
||||
"github.com/mulgadc/spinifex/spinifex/domains/admission/quota"
|
||||
ec2awsapi "github.com/mulgadc/spinifex/spinifex/domains/ec2/awsapi"
|
||||
ec2accountapi "github.com/mulgadc/spinifex/spinifex/domains/ec2/awsapi/account"
|
||||
ec2capacityreservationapi "github.com/mulgadc/spinifex/spinifex/domains/ec2/awsapi/capacityreservation"
|
||||
ec2eigwapi "github.com/mulgadc/spinifex/spinifex/domains/ec2/awsapi/eigw"
|
||||
ec2eipapi "github.com/mulgadc/spinifex/spinifex/domains/ec2/awsapi/eip"
|
||||
ec2igwapi "github.com/mulgadc/spinifex/spinifex/domains/ec2/awsapi/igw"
|
||||
ec2imageapi "github.com/mulgadc/spinifex/spinifex/domains/ec2/awsapi/image"
|
||||
ec2instanceapi "github.com/mulgadc/spinifex/spinifex/domains/ec2/awsapi/instance"
|
||||
ec2keyapi "github.com/mulgadc/spinifex/spinifex/domains/ec2/awsapi/key"
|
||||
ec2launchtemplateapi "github.com/mulgadc/spinifex/spinifex/domains/ec2/awsapi/launchtemplate"
|
||||
ec2natgwapi "github.com/mulgadc/spinifex/spinifex/domains/ec2/awsapi/natgw"
|
||||
ec2placementgroupapi "github.com/mulgadc/spinifex/spinifex/domains/ec2/awsapi/placementgroup"
|
||||
ec2routetableapi "github.com/mulgadc/spinifex/spinifex/domains/ec2/awsapi/routetable"
|
||||
ec2snapshotapi "github.com/mulgadc/spinifex/spinifex/domains/ec2/awsapi/snapshot"
|
||||
ec2spotinstanceapi "github.com/mulgadc/spinifex/spinifex/domains/ec2/awsapi/spotinstance"
|
||||
ec2tagsapi "github.com/mulgadc/spinifex/spinifex/domains/ec2/awsapi/tags"
|
||||
ec2volumeapi "github.com/mulgadc/spinifex/spinifex/domains/ec2/awsapi/volume"
|
||||
ec2vpcapi "github.com/mulgadc/spinifex/spinifex/domains/ec2/awsapi/vpc"
|
||||
ec2zoneapi "github.com/mulgadc/spinifex/spinifex/domains/ec2/awsapi/zone"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
awsxml "github.com/mulgadc/spinifex/spinifex/foundation/aws/xml"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/lifecycle/idempotency"
|
||||
"github.com/mulgadc/spinifex/spinifex/ingress/aws/query"
|
||||
"github.com/mulgadc/spinifex/spinifex/ingress/aws/dispatch"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
// EC2Handler processes parsed query args and returns XML response bytes.
|
||||
// r is included for handlers that enforce iam:PassRole; most ignore it.
|
||||
type EC2Handler func(string, map[string]string, *GatewayConfig, string, *http.Request) ([]byte, error)
|
||||
// ec2Quota adapts the account quota service to the capability the EC2 actions
|
||||
// consume, building the NATS-backed instance-type resolver its retype gate takes.
|
||||
type ec2Quota struct{ *quota.Service }
|
||||
|
||||
// ec2Action parses once so authorization and dispatch share one typed input.
|
||||
type ec2Action struct {
|
||||
parse func(map[string]string) (any, error)
|
||||
dispatch func(string, any, *GatewayConfig, string, *http.Request) ([]byte, error)
|
||||
// idempotent records that this action honours ClientToken, so the set of
|
||||
// actions that do is assertable rather than folklore.
|
||||
idempotent bool
|
||||
var _ ec2awsapi.Quota = ec2Quota{}
|
||||
|
||||
func (q ec2Quota) EnforceRetype(ctx context.Context, nc *nats.Conn, nodeIDs func() []string, accountID, instanceID, newType string) (int, error) {
|
||||
return q.Service.EnforceRetype(ctx, quota.NATSInstanceTypeResolver(nc, nodeIDs), accountID, instanceID, newType)
|
||||
}
|
||||
|
||||
func (h ec2Action) withQueryPreprocessor(preprocess func(map[string]string)) ec2Action {
|
||||
parse := h.parse
|
||||
h.parse = func(q map[string]string) (any, error) {
|
||||
preprocess(q)
|
||||
return parse(q)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
// requestContext returns r's context, or Background for a nil request
|
||||
// (some callers and tests dispatch without an *http.Request). The SDK's
|
||||
// invocation id rides along: it is repeated across retries of one call, so it
|
||||
// is what lets a mutation recognise its own resend instead of doing the work
|
||||
// twice.
|
||||
func requestContext(r *http.Request) context.Context {
|
||||
if r == nil {
|
||||
return context.Background()
|
||||
}
|
||||
return idempotency.WithKey(r.Context(), r.Header.Get(idempotency.SDKInvocationIDHeader))
|
||||
}
|
||||
|
||||
// parseEC2Input parses query args into an action's input type. Shared by every
|
||||
// ec2Action constructor so they all consume the same parse.
|
||||
func parseEC2Input[In any](q map[string]string) (any, error) {
|
||||
input := new(In)
|
||||
if err := query.QueryParamsToStruct(q, input); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return input, nil
|
||||
}
|
||||
|
||||
// ec2Handler creates a type-safe EC2Handler. Parsing is separate from dispatch
|
||||
// so authorization and execution consume the exact same input.
|
||||
func ec2Handler[In any](handler func(ctx context.Context, input *In, gw *GatewayConfig, accountID string) (any, error)) ec2Action {
|
||||
return ec2Action{
|
||||
parse: parseEC2Input[In],
|
||||
dispatch: func(action string, parsed any, gw *GatewayConfig, accountID string, r *http.Request) ([]byte, error) {
|
||||
input, ok := parsed.(*In)
|
||||
if !ok {
|
||||
return nil, errors.New(awserrors.ErrorInternalError)
|
||||
}
|
||||
output, err := handler(requestContext(r), input, gw, accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
xmlOutput, err := marshalEC2Response(action, output)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return xmlOutput, nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ec2ListsAsSet names the list fields AWS omits when empty instead of
|
||||
// rendering them. Each is rendered as its handler set it, so nil omits it.
|
||||
var ec2ListsAsSet = map[reflect.Type][]string{
|
||||
reflect.TypeFor[ec2.CreateKeyPairOutput](): {"Tags"},
|
||||
reflect.TypeFor[ec2.Image](): {"Tags", "ProductCodes"},
|
||||
reflect.TypeFor[ec2.CreateSecurityGroupOutput](): {"Tags"},
|
||||
reflect.TypeFor[ec2.SecurityGroup](): {"Tags"},
|
||||
reflect.TypeFor[ec2.SecurityGroupRule](): {"Tags"},
|
||||
reflect.TypeFor[ec2.RevokeSecurityGroupIngressOutput](): {"UnknownIpPermissions"},
|
||||
reflect.TypeFor[ec2.RevokeSecurityGroupEgressOutput](): {"UnknownIpPermissions"},
|
||||
reflect.TypeFor[ec2.NetworkInterface](): {"Ipv4Prefixes", "Ipv6Prefixes"},
|
||||
reflect.TypeFor[ec2.Vpc](): {"Ipv6CidrBlockAssociationSet"},
|
||||
reflect.TypeFor[ec2.Subnet](): {"Tags"},
|
||||
}
|
||||
|
||||
// marshalEC2Response renders an EC2 handler's output into the action's XML
|
||||
// envelope. Every EC2 action funnels through here (via ec2Handler and
|
||||
// ec2HandlerWithReq), so wire-format fixes belong here, not in each handler.
|
||||
func marshalEC2Response(action string, output any) ([]byte, error) {
|
||||
// BuildXML omits a nil slice's container element entirely but renders an
|
||||
// empty one for a non-nil empty slice; AWS mostly renders the latter.
|
||||
normalized := awsxml.NormalizeOutput(output, ec2ListsAsSet)
|
||||
// The SDK's generated output structs never carry a RequestId field.
|
||||
withRequestID := awsxml.WithRequestID(normalized, uuid.NewV4().String())
|
||||
payload := awsxml.ResponsePayload(action+"Response", withRequestID)
|
||||
xmlOutput, err := awsxml.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, errors.New("failed to marshal response to XML")
|
||||
}
|
||||
return xmlOutput, nil
|
||||
}
|
||||
|
||||
// ec2HandlerWithReq is ec2Handler for actions that need the original *http.Request,
|
||||
// e.g. RunInstances which enforces iam:PassRole on the supplied instance profile ARN.
|
||||
func ec2HandlerWithReq[In any](handler func(ctx context.Context, input *In, gw *GatewayConfig, accountID string, r *http.Request) (any, error)) ec2Action {
|
||||
return ec2Action{
|
||||
parse: parseEC2Input[In],
|
||||
dispatch: func(action string, parsed any, gw *GatewayConfig, accountID string, r *http.Request) ([]byte, error) {
|
||||
input, ok := parsed.(*In)
|
||||
if !ok {
|
||||
return nil, errors.New(awserrors.ErrorInternalError)
|
||||
}
|
||||
output, err := handler(requestContext(r), input, gw, accountID, r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
xmlOutput, err := marshalEC2Response(action, output)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return xmlOutput, nil
|
||||
},
|
||||
}
|
||||
func (q ec2Quota) ChargeLaunch(ctx context.Context, accountID string, reservation *ec2.Reservation) error {
|
||||
return q.Service.ChargeLaunch(ctx, accountID, reservation)
|
||||
}
|
||||
|
||||
// advertisedEndpoint returns this gateway's own dialable base URL, from the
|
||||
@@ -181,588 +45,32 @@ func (gw *GatewayConfig) advertisedEndpoint() string {
|
||||
return "https://" + net.JoinHostPort(host, port)
|
||||
}
|
||||
|
||||
var ec2Actions = map[string]ec2Action{
|
||||
"DescribeInstances": ec2Handler(func(ctx context.Context, input *ec2.DescribeInstancesInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
out, err := ec2instanceapi.DescribeInstancesChecked(ctx, input, gw.NATSConn, 0, gw.NodeIDs, accountID)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
ec2instanceapi.EnrichInstanceProfileIDs(out, gw.IAMService, accountID)
|
||||
return out, nil
|
||||
}),
|
||||
"RunInstances": ec2HandlerWithReq(func(ctx context.Context, input *ec2.RunInstancesInput, gw *GatewayConfig, accountID string, r *http.Request) (any, error) {
|
||||
passRoleCheck := func(roleARN string) error {
|
||||
return gw.checkPassRole(r, roleARN, ec2ServicePrincipal)
|
||||
}
|
||||
launchQuotaCheck := func() error {
|
||||
return gw.Quota.EnforceLaunch(ctx, accountID, aws.StringValue(input.InstanceType), int(aws.Int64Value(input.MaxCount)))
|
||||
}
|
||||
reservation, err := ec2instanceapi.RunInstances(ctx, input, gw.NATSConn, gw.IAMService, accountID, passRoleCheck, launchQuotaCheck, gw.ExpectedNodes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Charge the actual launched vCPUs; a counter write failure is drift for
|
||||
// reconcile to correct, so it must not fail the already-successful launch.
|
||||
if err := gw.Quota.ChargeLaunch(ctx, accountID, &reservation); err != nil {
|
||||
slog.WarnContext(ctx, "RunInstances: vcpu quota charge failed, reconcile will correct", "account", accountID, "err", err)
|
||||
}
|
||||
return reservation, nil
|
||||
}),
|
||||
"AssociateIamInstanceProfile": ec2HandlerWithReq(func(ctx context.Context, input *ec2.AssociateIamInstanceProfileInput, gw *GatewayConfig, accountID string, r *http.Request) (any, error) {
|
||||
passRoleCheck := func(roleARN string) error {
|
||||
return gw.checkPassRole(r, roleARN, ec2ServicePrincipal)
|
||||
}
|
||||
return ec2instanceapi.AssociateIamInstanceProfile(ctx, input, gw.NATSConn, gw.IAMService, accountID, passRoleCheck)
|
||||
}),
|
||||
"DisassociateIamInstanceProfile": ec2Handler(func(ctx context.Context, input *ec2.DisassociateIamInstanceProfileInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2instanceapi.DisassociateIamInstanceProfile(ctx, input, gw.NATSConn, gw.DiscoverActiveNodes(ctx), accountID)
|
||||
}),
|
||||
"ReplaceIamInstanceProfileAssociation": ec2HandlerWithReq(func(ctx context.Context, input *ec2.ReplaceIamInstanceProfileAssociationInput, gw *GatewayConfig, accountID string, r *http.Request) (any, error) {
|
||||
passRoleCheck := func(roleARN string) error {
|
||||
return gw.checkPassRole(r, roleARN, ec2ServicePrincipal)
|
||||
}
|
||||
return ec2instanceapi.ReplaceIamInstanceProfileAssociation(ctx, input, gw.NATSConn, gw.IAMService, gw.DiscoverActiveNodes(ctx), accountID, passRoleCheck)
|
||||
}),
|
||||
"DescribeIamInstanceProfileAssociations": ec2Handler(func(ctx context.Context, input *ec2.DescribeIamInstanceProfileAssociationsInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2instanceapi.DescribeIamInstanceProfileAssociations(ctx, input, gw.NATSConn, gw.DiscoverActiveNodes(ctx), accountID)
|
||||
}),
|
||||
"StartInstances": ec2Handler(func(ctx context.Context, input *ec2.StartInstancesInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2instanceapi.StartInstances(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"StopInstances": ec2Handler(func(ctx context.Context, input *ec2.StopInstancesInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2instanceapi.StopInstances(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"RebootInstances": ec2Handler(func(ctx context.Context, input *ec2.RebootInstancesInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2instanceapi.RebootInstances(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"MonitorInstances": ec2Handler(func(ctx context.Context, input *ec2.MonitorInstancesInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2instanceapi.MonitorInstances(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"UnmonitorInstances": ec2Handler(func(ctx context.Context, input *ec2.UnmonitorInstancesInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2instanceapi.UnmonitorInstances(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"TerminateInstances": ec2Handler(func(ctx context.Context, input *ec2.TerminateInstancesInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2instanceapi.TerminateInstances(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DescribeInstanceTypes": ec2Handler(func(ctx context.Context, input *ec2.DescribeInstanceTypesInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2instanceapi.DescribeInstanceTypes(ctx, input, gw.NATSConn, gw.ExpectedNodes, gw.NodeIDs, accountID)
|
||||
}),
|
||||
"DescribeInstanceTypeOfferings": ec2Handler(func(ctx context.Context, input *ec2.DescribeInstanceTypeOfferingsInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2instanceapi.DescribeInstanceTypeOfferings(ctx, input, gw.NATSConn, gw.ExpectedNodes, accountID, gw.Region, gw.AZ)
|
||||
}),
|
||||
"DescribeInstanceStatus": ec2Handler(func(ctx context.Context, input *ec2.DescribeInstanceStatusInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2instanceapi.DescribeInstanceStatus(ctx, input, gw.NATSConn, gw.DiscoverActiveNodes(ctx), accountID, gw.AZ, gw.InstanceStatus)
|
||||
}),
|
||||
"GetConsoleOutput": ec2Handler(func(ctx context.Context, input *ec2.GetConsoleOutputInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2instanceapi.GetConsoleOutput(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"GetPasswordData": ec2Handler(func(ctx context.Context, input *ec2.GetPasswordDataInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2instanceapi.GetPasswordData(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"ModifyInstanceAttribute": ec2Handler(func(ctx context.Context, input *ec2.ModifyInstanceAttributeInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
var delta int
|
||||
if input.InstanceType != nil {
|
||||
resolve := quota.NATSInstanceTypeResolver(gw.NATSConn, func() []string { return gw.NodeIDs })
|
||||
d, err := gw.Quota.EnforceRetype(ctx, resolve, accountID, aws.StringValue(input.InstanceId), aws.StringValue(input.InstanceType.Value))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
delta = d
|
||||
}
|
||||
out, err := ec2instanceapi.ModifyInstanceAttribute(ctx, input, gw.NATSConn, accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Charge the retype's vCPU growth; a counter write failure is drift for
|
||||
// reconcile to correct, so it must not fail the applied retype.
|
||||
if err := gw.Quota.AddVCPU(ctx, accountID, delta); err != nil {
|
||||
slog.WarnContext(ctx, "ModifyInstanceAttribute: vcpu quota charge failed, reconcile will correct", "account", accountID, "err", err)
|
||||
}
|
||||
return out, nil
|
||||
}),
|
||||
"DescribeInstanceAttribute": ec2Handler(func(ctx context.Context, input *ec2.DescribeInstanceAttributeInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2instanceapi.DescribeInstanceAttribute(ctx, input, gw.NATSConn, gw.DiscoverActiveNodes(ctx), accountID)
|
||||
}),
|
||||
"ModifyInstanceMetadataOptions": ec2Handler(func(ctx context.Context, input *ec2.ModifyInstanceMetadataOptionsInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2instanceapi.ModifyInstanceMetadataOptions(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DescribeInstanceCreditSpecifications": ec2Handler(func(ctx context.Context, input *ec2.DescribeInstanceCreditSpecificationsInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2instanceapi.DescribeInstanceCreditSpecifications(input)
|
||||
}),
|
||||
"CreateKeyPair": ec2Handler(func(ctx context.Context, input *ec2.CreateKeyPairInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2keyapi.CreateKeyPair(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DeleteKeyPair": ec2Handler(func(ctx context.Context, input *ec2.DeleteKeyPairInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2keyapi.DeleteKeyPair(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DescribeKeyPairs": ec2Handler(func(ctx context.Context, input *ec2.DescribeKeyPairsInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2keyapi.DescribeKeyPairs(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"ImportKeyPair": ec2Handler(func(ctx context.Context, input *ec2.ImportKeyPairInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2keyapi.ImportKeyPair(ctx, input, gw.NATSConn, accountID)
|
||||
}).withQueryPreprocessor(func(q map[string]string) {
|
||||
// Parser leaves Base64 padding URL-encoded; decode it before dispatch.
|
||||
if strings.HasSuffix(q["PublicKeyMaterial"], "%3D%3D") {
|
||||
q["PublicKeyMaterial"] = strings.Replace(q["PublicKeyMaterial"], "%3D%3D", "==", 1)
|
||||
}
|
||||
}),
|
||||
"DescribeImages": ec2Handler(func(ctx context.Context, input *ec2.DescribeImagesInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2imageapi.DescribeImages(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"CreateImage": ec2Handler(func(ctx context.Context, input *ec2.CreateImageInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2imageapi.CreateImage(ctx, input, gw.NATSConn, gw.DiscoverActiveNodes(ctx), accountID)
|
||||
}),
|
||||
"DeregisterImage": ec2Handler(func(ctx context.Context, input *ec2.DeregisterImageInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2imageapi.DeregisterImage(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"RegisterImage": ec2Handler(func(ctx context.Context, input *ec2.RegisterImageInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2imageapi.RegisterImage(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"CopyImage": ec2IdempotentHandler(func(ctx context.Context, input *ec2.CopyImageInput, gw *GatewayConfig, accountID string) (ec2.CopyImageOutput, error) {
|
||||
return ec2imageapi.CopyImage(ctx, input, gw.NATSConn, gw.Region, accountID)
|
||||
}),
|
||||
"DescribeImageAttribute": ec2Handler(func(ctx context.Context, input *ec2.DescribeImageAttributeInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2imageapi.DescribeImageAttribute(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"ModifyImageAttribute": ec2Handler(func(ctx context.Context, input *ec2.ModifyImageAttributeInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2imageapi.ModifyImageAttribute(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"ResetImageAttribute": ec2Handler(func(ctx context.Context, input *ec2.ResetImageAttributeInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2imageapi.ResetImageAttribute(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DescribeRegions": ec2Handler(func(ctx context.Context, input *ec2.DescribeRegionsInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2zoneapi.DescribeRegions(input, gw.Region, gw.advertisedEndpoint())
|
||||
}),
|
||||
"DescribeAvailabilityZones": ec2Handler(func(ctx context.Context, input *ec2.DescribeAvailabilityZonesInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2zoneapi.DescribeAvailabilityZones(input, gw.Region, gw.AZ)
|
||||
}),
|
||||
"DescribeVolumes": ec2Handler(func(ctx context.Context, input *ec2.DescribeVolumesInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2volumeapi.DescribeVolumes(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"ModifyVolume": ec2Handler(func(ctx context.Context, input *ec2.ModifyVolumeInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
if err := gw.Quota.EnforceVolumeModify(ctx, gw.NATSConn, accountID, aws.StringValue(input.VolumeId), int(aws.Int64Value(input.Size))); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ec2volumeapi.ModifyVolume(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"CreateVolume": ec2IdempotentHandler(func(ctx context.Context, input *ec2.CreateVolumeInput, gw *GatewayConfig, accountID string) (ec2.Volume, error) {
|
||||
if err := gw.Quota.EnforceVolumeCreate(ctx, gw.NATSConn, accountID, int(aws.Int64Value(input.Size))); err != nil {
|
||||
return ec2.Volume{}, err
|
||||
}
|
||||
return ec2volumeapi.CreateVolume(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DeleteVolume": ec2Handler(func(ctx context.Context, input *ec2.DeleteVolumeInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2volumeapi.DeleteVolume(ctx, input, gw.NATSConn, gw.NodeIDs, accountID)
|
||||
}),
|
||||
"AttachVolume": ec2Handler(func(ctx context.Context, input *ec2.AttachVolumeInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2volumeapi.AttachVolume(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DescribeVolumeStatus": ec2Handler(func(ctx context.Context, input *ec2.DescribeVolumeStatusInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2volumeapi.DescribeVolumeStatus(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DescribeVolumesModifications": ec2Handler(func(ctx context.Context, input *ec2.DescribeVolumesModificationsInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2volumeapi.DescribeVolumesModifications(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DetachVolume": ec2Handler(func(ctx context.Context, input *ec2.DetachVolumeInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2volumeapi.DetachVolume(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DescribeAccountAttributes": ec2Handler(func(ctx context.Context, input *ec2.DescribeAccountAttributesInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2accountapi.DescribeAccountAttributes(input, func() (string, error) {
|
||||
return defaultVPCID(ctx, gw.NATSConn, accountID)
|
||||
})
|
||||
}),
|
||||
"EnableEbsEncryptionByDefault": ec2Handler(func(ctx context.Context, input *ec2.EnableEbsEncryptionByDefaultInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2accountapi.EnableEbsEncryptionByDefault(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DisableEbsEncryptionByDefault": ec2Handler(func(ctx context.Context, input *ec2.DisableEbsEncryptionByDefaultInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2accountapi.DisableEbsEncryptionByDefault(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"GetEbsEncryptionByDefault": ec2Handler(func(ctx context.Context, input *ec2.GetEbsEncryptionByDefaultInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2accountapi.GetEbsEncryptionByDefault(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"GetSerialConsoleAccessStatus": ec2Handler(func(ctx context.Context, input *ec2.GetSerialConsoleAccessStatusInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2accountapi.GetSerialConsoleAccessStatus(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"EnableSerialConsoleAccess": ec2Handler(func(ctx context.Context, input *ec2.EnableSerialConsoleAccessInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2accountapi.EnableSerialConsoleAccess(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DisableSerialConsoleAccess": ec2Handler(func(ctx context.Context, input *ec2.DisableSerialConsoleAccessInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2accountapi.DisableSerialConsoleAccess(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"CreateTags": ec2Handler(func(ctx context.Context, input *ec2.CreateTagsInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2tagsapi.CreateTags(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DeleteTags": ec2Handler(func(ctx context.Context, input *ec2.DeleteTagsInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2tagsapi.DeleteTags(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DescribeTags": ec2Handler(func(ctx context.Context, input *ec2.DescribeTagsInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2tagsapi.DescribeTags(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"CreateSnapshot": ec2Handler(func(ctx context.Context, input *ec2.CreateSnapshotInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2snapshotapi.CreateSnapshot(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DeleteSnapshot": ec2Handler(func(ctx context.Context, input *ec2.DeleteSnapshotInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2snapshotapi.DeleteSnapshot(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DescribeSnapshots": ec2Handler(func(ctx context.Context, input *ec2.DescribeSnapshotsInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2snapshotapi.DescribeSnapshots(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"CopySnapshot": ec2Handler(func(ctx context.Context, input *ec2.CopySnapshotInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2snapshotapi.CopySnapshot(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"CreateInternetGateway": ec2Handler(func(ctx context.Context, input *ec2.CreateInternetGatewayInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2igwapi.CreateInternetGateway(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DeleteInternetGateway": ec2Handler(func(ctx context.Context, input *ec2.DeleteInternetGatewayInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2igwapi.DeleteInternetGateway(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DescribeInternetGateways": ec2Handler(func(ctx context.Context, input *ec2.DescribeInternetGatewaysInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2igwapi.DescribeInternetGateways(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"AttachInternetGateway": ec2Handler(func(ctx context.Context, input *ec2.AttachInternetGatewayInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2igwapi.AttachInternetGateway(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DetachInternetGateway": ec2Handler(func(ctx context.Context, input *ec2.DetachInternetGatewayInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2igwapi.DetachInternetGateway(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"CreateEgressOnlyInternetGateway": ec2IdempotentHandler(func(ctx context.Context, input *ec2.CreateEgressOnlyInternetGatewayInput, gw *GatewayConfig, accountID string) (ec2.CreateEgressOnlyInternetGatewayOutput, error) {
|
||||
return ec2eigwapi.CreateEgressOnlyInternetGateway(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DeleteEgressOnlyInternetGateway": ec2Handler(func(ctx context.Context, input *ec2.DeleteEgressOnlyInternetGatewayInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2eigwapi.DeleteEgressOnlyInternetGateway(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DescribeEgressOnlyInternetGateways": ec2Handler(func(ctx context.Context, input *ec2.DescribeEgressOnlyInternetGatewaysInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2eigwapi.DescribeEgressOnlyInternetGateways(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"CreatePlacementGroup": ec2Handler(func(ctx context.Context, input *ec2.CreatePlacementGroupInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2placementgroupapi.CreatePlacementGroup(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DeletePlacementGroup": ec2Handler(func(ctx context.Context, input *ec2.DeletePlacementGroupInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2placementgroupapi.DeletePlacementGroup(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DescribePlacementGroups": ec2Handler(func(ctx context.Context, input *ec2.DescribePlacementGroupsInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2placementgroupapi.DescribePlacementGroups(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"CreateCapacityReservation": ec2IdempotentHandler(func(ctx context.Context, input *ec2.CreateCapacityReservationInput, gw *GatewayConfig, accountID string) (ec2.CreateCapacityReservationOutput, error) {
|
||||
return ec2capacityreservationapi.CreateCapacityReservation(ctx, input, gw.NATSConn, gw.ExpectedNodes, accountID)
|
||||
}),
|
||||
"DescribeCapacityReservations": ec2Handler(func(ctx context.Context, input *ec2.DescribeCapacityReservationsInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2capacityreservationapi.DescribeCapacityReservations(ctx, input, gw.NATSConn, gw.ExpectedNodes, accountID)
|
||||
}),
|
||||
"CancelCapacityReservation": ec2Handler(func(ctx context.Context, input *ec2.CancelCapacityReservationInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2capacityreservationapi.CancelCapacityReservation(ctx, input, gw.NATSConn, gw.ExpectedNodes, accountID)
|
||||
}),
|
||||
"CreateLaunchTemplate": ec2Handler(func(ctx context.Context, input *ec2.CreateLaunchTemplateInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2launchtemplateapi.CreateLaunchTemplate(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"CreateLaunchTemplateVersion": ec2IdempotentHandler(func(ctx context.Context, input *ec2.CreateLaunchTemplateVersionInput, gw *GatewayConfig, accountID string) (ec2.CreateLaunchTemplateVersionOutput, error) {
|
||||
return ec2launchtemplateapi.CreateLaunchTemplateVersion(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DeleteLaunchTemplate": ec2Handler(func(ctx context.Context, input *ec2.DeleteLaunchTemplateInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2launchtemplateapi.DeleteLaunchTemplate(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DeleteLaunchTemplateVersions": ec2Handler(func(ctx context.Context, input *ec2.DeleteLaunchTemplateVersionsInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2launchtemplateapi.DeleteLaunchTemplateVersions(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"ModifyLaunchTemplate": ec2Handler(func(ctx context.Context, input *ec2.ModifyLaunchTemplateInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2launchtemplateapi.ModifyLaunchTemplate(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DescribeLaunchTemplates": ec2Handler(func(ctx context.Context, input *ec2.DescribeLaunchTemplatesInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2launchtemplateapi.DescribeLaunchTemplates(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DescribeLaunchTemplateVersions": ec2Handler(func(ctx context.Context, input *ec2.DescribeLaunchTemplateVersionsInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2launchtemplateapi.DescribeLaunchTemplateVersions(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"RequestSpotInstances": ec2HandlerWithReq(func(ctx context.Context, input *ec2.RequestSpotInstancesInput, gw *GatewayConfig, accountID string, r *http.Request) (any, error) {
|
||||
passRoleCheck := func(roleARN string) error {
|
||||
return gw.checkPassRole(r, roleARN, ec2ServicePrincipal)
|
||||
}
|
||||
return ec2spotinstanceapi.RequestSpotInstances(ctx, input, gw.NATSConn, gw.IAMService, accountID, gw.AZ, passRoleCheck, gw.Quota, gw.ExpectedNodes)
|
||||
}),
|
||||
"DescribeSpotInstanceRequests": ec2Handler(func(ctx context.Context, input *ec2.DescribeSpotInstanceRequestsInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2spotinstanceapi.DescribeSpotInstanceRequests(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"CancelSpotInstanceRequests": ec2Handler(func(ctx context.Context, input *ec2.CancelSpotInstanceRequestsInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2spotinstanceapi.CancelSpotInstanceRequests(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"CreateVpc": ec2Handler(func(ctx context.Context, input *ec2.CreateVpcInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
if err := gw.Quota.EnforceVPCs(ctx, gw.NATSConn, accountID, 1); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ec2vpcapi.CreateVpc(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DeleteVpc": ec2Handler(func(ctx context.Context, input *ec2.DeleteVpcInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2vpcapi.DeleteVpc(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DescribeVpcs": ec2Handler(func(ctx context.Context, input *ec2.DescribeVpcsInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2vpcapi.DescribeVpcs(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"ModifyVpcAttribute": ec2Handler(func(ctx context.Context, input *ec2.ModifyVpcAttributeInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2vpcapi.ModifyVpcAttribute(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DescribeVpcAttribute": ec2Handler(func(ctx context.Context, input *ec2.DescribeVpcAttributeInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2vpcapi.DescribeVpcAttribute(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
// No VPC endpoint can exist here, because CreateVpcEndpoint is not served, so
|
||||
// the empty list is the true answer rather than a stand-in for one. It has to
|
||||
// be answered all the same: the Terraform AWS provider calls this on every VPC
|
||||
// delete to sweep GuardDuty-managed endpoints, and an InvalidAction there fails
|
||||
// the destroy and strands the VPC.
|
||||
"DescribeVpcEndpoints": ec2Handler(func(_ context.Context, _ *ec2.DescribeVpcEndpointsInput, _ *GatewayConfig, _ string) (any, error) {
|
||||
return ec2.DescribeVpcEndpointsOutput{VpcEndpoints: []*ec2.VpcEndpoint{}}, nil
|
||||
}),
|
||||
"CreateSubnet": ec2Handler(func(ctx context.Context, input *ec2.CreateSubnetInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
if err := gw.Quota.EnforceSubnets(ctx, gw.NATSConn, accountID, 1); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ec2vpcapi.CreateSubnet(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DeleteSubnet": ec2Handler(func(ctx context.Context, input *ec2.DeleteSubnetInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2vpcapi.DeleteSubnet(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DescribeSubnets": ec2Handler(func(ctx context.Context, input *ec2.DescribeSubnetsInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2vpcapi.DescribeSubnets(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"ModifySubnetAttribute": ec2Handler(func(ctx context.Context, input *ec2.ModifySubnetAttributeInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2vpcapi.ModifySubnetAttribute(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"CreateRouteTable": ec2IdempotentHandler(func(ctx context.Context, input *ec2.CreateRouteTableInput, gw *GatewayConfig, accountID string) (ec2.CreateRouteTableOutput, error) {
|
||||
return ec2routetableapi.CreateRouteTable(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DeleteRouteTable": ec2Handler(func(ctx context.Context, input *ec2.DeleteRouteTableInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2routetableapi.DeleteRouteTable(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DescribeRouteTables": ec2Handler(func(ctx context.Context, input *ec2.DescribeRouteTablesInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2routetableapi.DescribeRouteTables(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"CreateRoute": ec2Handler(func(ctx context.Context, input *ec2.CreateRouteInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2routetableapi.CreateRoute(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DeleteRoute": ec2Handler(func(ctx context.Context, input *ec2.DeleteRouteInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2routetableapi.DeleteRoute(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"ReplaceRoute": ec2Handler(func(ctx context.Context, input *ec2.ReplaceRouteInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2routetableapi.ReplaceRoute(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"AssociateRouteTable": ec2Handler(func(ctx context.Context, input *ec2.AssociateRouteTableInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2routetableapi.AssociateRouteTable(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DisassociateRouteTable": ec2Handler(func(ctx context.Context, input *ec2.DisassociateRouteTableInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2routetableapi.DisassociateRouteTable(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"ReplaceRouteTableAssociation": ec2Handler(func(ctx context.Context, input *ec2.ReplaceRouteTableAssociationInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2routetableapi.ReplaceRouteTableAssociation(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"CreateNetworkInterface": ec2IdempotentHandler(func(ctx context.Context, input *ec2.CreateNetworkInterfaceInput, gw *GatewayConfig, accountID string) (ec2.CreateNetworkInterfaceOutput, error) {
|
||||
return ec2vpcapi.CreateNetworkInterface(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DeleteNetworkInterface": ec2Handler(func(ctx context.Context, input *ec2.DeleteNetworkInterfaceInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2vpcapi.DeleteNetworkInterface(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DescribeNetworkInterfaces": ec2Handler(func(ctx context.Context, input *ec2.DescribeNetworkInterfacesInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2vpcapi.DescribeNetworkInterfaces(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"ModifyNetworkInterfaceAttribute": ec2Handler(func(ctx context.Context, input *ec2.ModifyNetworkInterfaceAttributeInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2vpcapi.ModifyNetworkInterfaceAttribute(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"AttachNetworkInterface": ec2Handler(func(ctx context.Context, input *ec2.AttachNetworkInterfaceInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2vpcapi.AttachNetworkInterface(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DetachNetworkInterface": ec2Handler(func(ctx context.Context, input *ec2.DetachNetworkInterfaceInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2vpcapi.DetachNetworkInterface(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"CreateSecurityGroup": ec2Handler(func(ctx context.Context, input *ec2.CreateSecurityGroupInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2vpcapi.CreateSecurityGroup(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DeleteSecurityGroup": ec2Handler(func(ctx context.Context, input *ec2.DeleteSecurityGroupInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2vpcapi.DeleteSecurityGroup(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DescribeSecurityGroups": ec2Handler(func(ctx context.Context, input *ec2.DescribeSecurityGroupsInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2vpcapi.DescribeSecurityGroups(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DescribeSecurityGroupRules": ec2Handler(func(ctx context.Context, input *ec2.DescribeSecurityGroupRulesInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2vpcapi.DescribeSecurityGroupRules(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"GetSecurityGroupsForVpc": ec2Handler(func(ctx context.Context, input *ec2.GetSecurityGroupsForVpcInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2vpcapi.GetSecurityGroupsForVpc(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"AuthorizeSecurityGroupIngress": ec2Handler(func(ctx context.Context, input *ec2.AuthorizeSecurityGroupIngressInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2vpcapi.AuthorizeSecurityGroupIngress(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"AuthorizeSecurityGroupEgress": ec2Handler(func(ctx context.Context, input *ec2.AuthorizeSecurityGroupEgressInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2vpcapi.AuthorizeSecurityGroupEgress(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"RevokeSecurityGroupIngress": ec2Handler(func(ctx context.Context, input *ec2.RevokeSecurityGroupIngressInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2vpcapi.RevokeSecurityGroupIngress(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"RevokeSecurityGroupEgress": ec2Handler(func(ctx context.Context, input *ec2.RevokeSecurityGroupEgressInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2vpcapi.RevokeSecurityGroupEgress(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"UpdateSecurityGroupRuleDescriptionsIngress": ec2Handler(func(ctx context.Context, input *ec2.UpdateSecurityGroupRuleDescriptionsIngressInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2vpcapi.UpdateSecurityGroupRuleDescriptionsIngress(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"UpdateSecurityGroupRuleDescriptionsEgress": ec2Handler(func(ctx context.Context, input *ec2.UpdateSecurityGroupRuleDescriptionsEgressInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2vpcapi.UpdateSecurityGroupRuleDescriptionsEgress(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"ModifySecurityGroupRules": ec2Handler(func(ctx context.Context, input *ec2.ModifySecurityGroupRulesInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2vpcapi.ModifySecurityGroupRules(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"AllocateAddress": ec2Handler(func(ctx context.Context, input *ec2.AllocateAddressInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
if err := gw.Quota.EnforceEIPs(ctx, gw.NATSConn, accountID, 1); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ec2eipapi.AllocateAddress(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"ReleaseAddress": ec2Handler(func(ctx context.Context, input *ec2.ReleaseAddressInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2eipapi.ReleaseAddress(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"AssociateAddress": ec2Handler(func(ctx context.Context, input *ec2.AssociateAddressInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2eipapi.AssociateAddress(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DisassociateAddress": ec2Handler(func(ctx context.Context, input *ec2.DisassociateAddressInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2eipapi.DisassociateAddress(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DescribeAddresses": ec2Handler(func(ctx context.Context, input *ec2.DescribeAddressesInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2eipapi.DescribeAddresses(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DescribeAddressesAttribute": ec2Handler(func(ctx context.Context, input *ec2.DescribeAddressesAttributeInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2eipapi.DescribeAddressesAttribute(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"CreateNatGateway": ec2IdempotentHandler(func(ctx context.Context, input *ec2.CreateNatGatewayInput, gw *GatewayConfig, accountID string) (ec2.CreateNatGatewayOutput, error) {
|
||||
return ec2natgwapi.CreateNatGateway(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DeleteNatGateway": ec2Handler(func(ctx context.Context, input *ec2.DeleteNatGatewayInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2natgwapi.DeleteNatGateway(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DescribeNatGateways": ec2Handler(func(ctx context.Context, input *ec2.DescribeNatGatewaysInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return ec2natgwapi.DescribeNatGateways(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
}
|
||||
|
||||
// defaultVPCID returns the ID of accountID's default VPC, or "" when it has
|
||||
// none, as the account's own DescribeVpcs sees it.
|
||||
func defaultVPCID(ctx context.Context, natsConn *nats.Conn, accountID string) (string, error) {
|
||||
if natsConn == nil {
|
||||
return "", errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
out, err := ec2vpcapi.DescribeVpcs(ctx, &ec2.DescribeVpcsInput{
|
||||
Filters: []*ec2.Filter{{Name: aws.String("is-default"), Values: aws.StringSlice([]string{"true"})}},
|
||||
}, natsConn, accountID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(out.Vpcs) == 0 {
|
||||
return "", nil
|
||||
}
|
||||
return aws.StringValue(out.Vpcs[0].VpcId), nil
|
||||
}
|
||||
|
||||
// ec2LocalActions are actions that don't require NATS. DescribeAccountAttributes
|
||||
// needs it only for default-vpc.
|
||||
var ec2LocalActions = map[string]bool{
|
||||
"DescribeRegions": true,
|
||||
"DescribeAvailabilityZones": true,
|
||||
"DescribeAccountAttributes": true,
|
||||
// ec2Deps composes the capabilities the EC2 actions dispatch to from the
|
||||
// gateway's own configuration.
|
||||
func (gw *GatewayConfig) ec2Deps() ec2awsapi.Deps {
|
||||
return ec2awsapi.Deps{
|
||||
NATS: gw.NATSConn,
|
||||
IAM: gw.IAMService,
|
||||
Quota: ec2Quota{gw.Quota},
|
||||
ActiveNodes: gw.DiscoverActiveNodes,
|
||||
ExpectedNodes: gw.ExpectedNodes,
|
||||
NodeIDs: gw.NodeIDs,
|
||||
AZ: gw.AZ,
|
||||
Endpoint: gw.advertisedEndpoint(),
|
||||
InstanceStatus: gw.InstanceStatus,
|
||||
}
|
||||
}
|
||||
|
||||
// EC2_Request serves one authenticated EC2 request.
|
||||
func (gw *GatewayConfig) EC2_Request(w http.ResponseWriter, r *http.Request) error {
|
||||
queryArgs, err := readQueryArgs(r)
|
||||
if err != nil {
|
||||
slog.Debug("EC2: malformed query string", "err", err)
|
||||
return errors.New(awserrors.ErrorMalformedQueryString)
|
||||
}
|
||||
|
||||
action := queryArgs["Action"]
|
||||
if action == "" {
|
||||
return errors.New(awserrors.ErrorMissingAction)
|
||||
}
|
||||
handler, ok := ec2Actions[action]
|
||||
if !ok {
|
||||
return errors.New(awserrors.ErrorInvalidAction)
|
||||
}
|
||||
|
||||
// Hoisted above the policy check because the resolver builds ARNs from it.
|
||||
// gw.Region, never the caller-supplied credential-scope region, which would
|
||||
// let a caller sign for another region and slide out from under a Deny.
|
||||
accountID := callerPrincipalOf(r).accountID
|
||||
if accountID == "" {
|
||||
slog.Error("EC2_Request: no account ID in auth context")
|
||||
// InternalError, not ServerInternal: the policy gate used to reach this
|
||||
// case first and that is the code the caller has always seen.
|
||||
return errors.New(awserrors.ErrorInternalError)
|
||||
}
|
||||
|
||||
input, err := handler.parse(queryArgs)
|
||||
if err != nil {
|
||||
if errors.Is(err, query.ErrSliceTooLarge) {
|
||||
return errors.New(awserrors.ErrorMalformedQueryString)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Launch templates supply effective RunInstances resources. Resolve them
|
||||
// before authorization, and dispatch the same expanded input afterwards.
|
||||
if runInput, ok := input.(*ec2.RunInstancesInput); ok && action == "RunInstances" {
|
||||
if err := ec2instanceapi.ExpandLaunchTemplate(r.Context(), gw.NATSConn, runInput, accountID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
resources, err := ec2awsapi.ResourceARNs(action, gw.Region, accountID, input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := gw.checkPolicyResources(r, "ec2", action, resources); err != nil {
|
||||
// EC2 answers a policy denial with UnauthorizedOperation, not AccessDenied.
|
||||
if code, _ := awserrors.ResolveErrorCode(err); code == awserrors.ErrorAccessDenied {
|
||||
return errors.New(awserrors.ErrorUnauthorizedOperation)
|
||||
}
|
||||
return err
|
||||
}
|
||||
// AWS refuses a bad tag resource type even on a DryRun request.
|
||||
if err := validateTagSpecificationTypes(action, input); err != nil {
|
||||
return err
|
||||
}
|
||||
if dryRunRequested(input) {
|
||||
return errors.New(awserrors.ErrorDryRunOperation)
|
||||
}
|
||||
if err := validateMaxResults(action, input); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if gw.NATSConn == nil && !ec2LocalActions[action] {
|
||||
return errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
|
||||
xmlOutput, err := handler.dispatch(action, input, gw, accountID, r)
|
||||
if err != nil {
|
||||
// A gateway iam:PassRole denial is EC2's UnauthorizedOperation too; an
|
||||
// AccessDenied a daemon returns is left as it is.
|
||||
if _, denied := errors.AsType[*policyauth.DenialError](err); denied {
|
||||
return errors.New(awserrors.ErrorUnauthorizedOperation)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "text/xml")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if _, err := w.Write(xmlOutput); err != nil {
|
||||
slog.Error("Failed to write EC2 response", "err", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// dryRunRequested reports whether a parsed EC2 input carries DryRun=true. Only
|
||||
// actions whose SDK input models the parameter can answer DryRunOperation.
|
||||
func dryRunRequested(input any) bool {
|
||||
v := reflect.ValueOf(input)
|
||||
if v.Kind() != reflect.Pointer || v.IsNil() || v.Elem().Kind() != reflect.Struct {
|
||||
return false
|
||||
}
|
||||
field := v.Elem().FieldByName("DryRun")
|
||||
if !field.IsValid() {
|
||||
return false
|
||||
}
|
||||
dryRun, ok := reflect.TypeAssert[*bool](field)
|
||||
return ok && aws.BoolValue(dryRun)
|
||||
gw.ec2Once.Do(func() { gw.ec2Handler = ec2awsapi.NewHandler(gw.ec2Deps()) })
|
||||
return gw.ec2Handler.Dispatch(w, dispatch.Invocation{
|
||||
Request: r,
|
||||
AccountID: callerPrincipalOf(r).accountID,
|
||||
Service: ec2awsapi.ServiceName,
|
||||
Region: gw.Region,
|
||||
Authorize: func(service, action string, resources []string, keys iampolicy.ConditionKeys) error {
|
||||
return gw.checkPolicyResourcesWithKeys(r, service, action, resources, keys)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import (
|
||||
|
||||
"github.com/mulgadc/spinifex/spinifex/gateway"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestAdvertisedEndpoint_ResolvesFromRegistryHost asserts DescribeRegions'
|
||||
@@ -33,17 +32,3 @@ func TestAdvertisedEndpoint_ResolvesFromRegistryHost(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestDescribeRegions_UsesAdvertisedEndpoint drives the real dispatch entry
|
||||
// EC2_Request uses and asserts the resolved gateway address reaches the wire
|
||||
// response. A caller on a concrete-host deployment must never be told to
|
||||
// dial its own loopback, which is the defect this guards against.
|
||||
func TestDescribeRegions_UsesAdvertisedEndpoint(t *testing.T) {
|
||||
gw := &gateway.GatewayConfig{Region: "ap-southeast-2", RegistryHost: "10.0.0.5", RegistryPort: "9999"}
|
||||
xmlOutput, err := gateway.DispatchEC2Action(gw, "DescribeRegions", map[string]string{}, "acct-123")
|
||||
require.NoError(t, err)
|
||||
|
||||
body := string(xmlOutput)
|
||||
assert.Contains(t, body, "<regionEndpoint>https://10.0.0.5:9999</regionEndpoint>")
|
||||
assert.NotContains(t, body, "localhost", "must not advertise the gateway's own loopback")
|
||||
}
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
//test:in-package — walks the unexported ec2Actions route table and drives
|
||||
// EC2_Request through the gateway's unexported test helpers.
|
||||
//test:in-package — drives EC2_Request through the gateway's unexported test
|
||||
// helpers.
|
||||
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"maps"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
ec2awsapi "github.com/mulgadc/spinifex/spinifex/domains/ec2/awsapi"
|
||||
"github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -18,16 +16,6 @@ import (
|
||||
|
||||
const dryRunMessage = "Request would have succeeded, but DryRun flag is set."
|
||||
|
||||
// modelsDryRun reports whether an action's SDK input carries a DryRun field,
|
||||
// independently of the gateway's own check.
|
||||
func modelsDryRun(t *testing.T, action string) bool {
|
||||
t.Helper()
|
||||
input, err := ec2Actions[action].parse(map[string]string{"Action": action})
|
||||
require.NoError(t, err)
|
||||
_, ok := reflect.TypeOf(input).Elem().FieldByName("DryRun")
|
||||
return ok
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -44,12 +32,12 @@ func serveEC2(gw *GatewayConfig, body string) *httptest.ResponseRecorder {
|
||||
func TestEC2Request_DryRunAnswersEveryAction(t *testing.T) {
|
||||
gw := withVerifier(&GatewayConfig{DisableLogging: true, Region: authzRegion, IAMService: allowAllIAMService()})
|
||||
|
||||
actions := slices.Sorted(maps.Keys(ec2Actions))
|
||||
actions := ec2awsapi.ActionNames()
|
||||
|
||||
for _, action := range actions {
|
||||
t.Run(action, func(t *testing.T) {
|
||||
w := serveEC2(gw, "Action="+action+"&DryRun=true")
|
||||
if !modelsDryRun(t, action) {
|
||||
if !ec2awsapi.InputHasField(action, "DryRun") {
|
||||
assert.NotContains(t, w.Body.String(), awserrors.ErrorDryRunOperation)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,460 @@
|
||||
// Characterization tests for EC2 through the full SigV4 pipeline as the legacy
|
||||
// gateway switch and ec2.go implement them today. They assert current
|
||||
// behaviour, not AWS parity.
|
||||
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/mulgadc/bluebottle/pkg/iampolicy"
|
||||
ec2v1 "github.com/mulgadc/spinifex/contracts/ec2/v1"
|
||||
"github.com/mulgadc/spinifex/internal/testkit"
|
||||
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
|
||||
awserrors "github.com/mulgadc/spinifex/spinifex/foundation/aws/errors"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// ec2Daemon stands in for the daemons behind the ec2.* NATS subjects. It
|
||||
// records what the gateway forwarded and answers every subject with an empty
|
||||
// output unless a case needs a real shape.
|
||||
type ec2Daemon struct {
|
||||
mu sync.Mutex
|
||||
calls map[string]int
|
||||
bodies map[string][]byte
|
||||
eigw int
|
||||
// replies overrides the canned answer for a subject.
|
||||
replies map[string]string
|
||||
}
|
||||
|
||||
func (d *ec2Daemon) reply(subject, body string) {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
d.replies[subject] = body
|
||||
}
|
||||
|
||||
func newEC2Daemon(t *testing.T, nc *nats.Conn) *ec2Daemon {
|
||||
t.Helper()
|
||||
d := &ec2Daemon{calls: map[string]int{}, bodies: map[string][]byte{}, replies: map[string]string{}}
|
||||
sub, err := nc.Subscribe("ec2.>", func(m *nats.Msg) {
|
||||
d.mu.Lock()
|
||||
d.calls[m.Subject]++
|
||||
d.bodies[m.Subject] = append([]byte(nil), m.Data...)
|
||||
reply := `{}`
|
||||
switch m.Subject {
|
||||
case "ec2.CreateEgressOnlyInternetGateway":
|
||||
d.eigw++
|
||||
reply = fmt.Sprintf(`{"EgressOnlyInternetGateway":{"EgressOnlyInternetGatewayId":"eigw-%04d"}}`, d.eigw)
|
||||
case "ec2.DescribeVpcs":
|
||||
reply = `{"Vpcs":[]}`
|
||||
}
|
||||
if r, ok := d.replies[m.Subject]; ok {
|
||||
reply = r
|
||||
}
|
||||
d.mu.Unlock()
|
||||
_ = m.Respond([]byte(reply))
|
||||
})
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = sub.Unsubscribe() })
|
||||
require.NoError(t, nc.Flush())
|
||||
return d
|
||||
}
|
||||
|
||||
func (d *ec2Daemon) count(subject string) int {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
return d.calls[subject]
|
||||
}
|
||||
|
||||
func (d *ec2Daemon) body(subject string) []byte {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
return d.bodies[subject]
|
||||
}
|
||||
|
||||
// ec2PipelineEnv is what a case may change before the app is built.
|
||||
type ec2PipelineEnv struct {
|
||||
NoNATS bool
|
||||
ResolveErr error
|
||||
}
|
||||
|
||||
type ec2PipelineApp struct {
|
||||
Handler http.Handler
|
||||
Daemon *ec2Daemon
|
||||
}
|
||||
|
||||
// ec2Pipeline wires SigV4AuthMiddleware -> Request as SetupRoutes does, with
|
||||
// EC2 on the legacy switch over a real JetStream and a stub daemon. Policy
|
||||
// statements are the caller's only IAM policy.
|
||||
func ec2Pipeline(t *testing.T, mutate func(*ec2PipelineEnv), statements ...iamdomain.Statement) ec2PipelineApp {
|
||||
t.Helper()
|
||||
encryptedSecret, err := iamdomain.EncryptSecret(testSecretKey, testMasterKey)
|
||||
require.NoError(t, err)
|
||||
docs := []iamdomain.PolicyDocument{{Version: "2012-10-17", Statement: statements}}
|
||||
env := &ec2PipelineEnv{}
|
||||
if mutate != nil {
|
||||
mutate(env)
|
||||
}
|
||||
svc := &profileIAMService{resolveErr: env.ResolveErr, policyMockIAMService: policyMockIAMService{
|
||||
mockIAMService: mockIAMService{
|
||||
masterKey: testMasterKey,
|
||||
accessKeys: map[string]*iamdomain.AccessKey{
|
||||
testAccessKey: {
|
||||
AccessKeyID: testAccessKey,
|
||||
SecretAccessKey: encryptedSecret,
|
||||
UserName: "alice",
|
||||
AccountID: authzAccountID,
|
||||
Status: "Active",
|
||||
},
|
||||
},
|
||||
},
|
||||
getUserPoliciesFn: func(_, _ string) ([]iamdomain.PolicyDocument, error) { return docs, nil },
|
||||
}}
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
daemon := newEC2Daemon(t, nc)
|
||||
|
||||
cfg := &GatewayConfig{
|
||||
DisableLogging: true,
|
||||
Region: testRegion,
|
||||
AZ: testRegion + "a",
|
||||
RegistryHost: "registry.example",
|
||||
RegistryPort: "443",
|
||||
NATSConn: nc,
|
||||
IAMService: svc,
|
||||
ExpectedNodes: 1,
|
||||
}
|
||||
if env.NoNATS {
|
||||
cfg.NATSConn = nil
|
||||
}
|
||||
gw := withVerifier(cfg)
|
||||
r := chi.NewRouter()
|
||||
r.Use(gw.SigV4AuthMiddleware())
|
||||
r.HandleFunc("/*", gw.Request)
|
||||
return ec2PipelineApp{Handler: r, Daemon: daemon}
|
||||
}
|
||||
|
||||
func signEC2Request(t *testing.T, body string) *http.Request {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(body))
|
||||
req.Host = "localhost:9999"
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=utf-8")
|
||||
signTestRequestAs(t, req, []byte(body), testAccessKey, testSecretKey, "ec2", time.Now().UTC())
|
||||
return req
|
||||
}
|
||||
|
||||
type ec2ErrorBody struct {
|
||||
XMLName xml.Name `xml:"Response"`
|
||||
Errors struct {
|
||||
Error struct {
|
||||
Code string `xml:"Code"`
|
||||
Message string `xml:"Message"`
|
||||
} `xml:"Error"`
|
||||
} `xml:"Errors"`
|
||||
RequestID string `xml:"RequestID"`
|
||||
}
|
||||
|
||||
type ec2Outcome struct {
|
||||
Status int
|
||||
ContentType string
|
||||
Body string
|
||||
Code string
|
||||
Message string
|
||||
RequestID string
|
||||
}
|
||||
|
||||
func probeEC2(t *testing.T, app http.Handler, body string) ec2Outcome {
|
||||
t.Helper()
|
||||
resp := doRequest(app, signEC2Request(t, body))
|
||||
raw, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
out := ec2Outcome{Status: resp.StatusCode, ContentType: resp.Header.Get("Content-Type"), Body: string(raw)}
|
||||
if resp.StatusCode >= 400 {
|
||||
var env ec2ErrorBody
|
||||
require.NoError(t, xml.Unmarshal(raw, &env), string(raw))
|
||||
out.Code, out.Message, out.RequestID = env.Errors.Error.Code, env.Errors.Error.Message, env.RequestID
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func allowEC2() iamdomain.Statement { return statement("Allow", "ec2:*", "*") }
|
||||
|
||||
// TestEC2Pipeline_ErrorEnvelopes pins status, code and the EC2 error envelope
|
||||
// for each way a request can be refused before or at dispatch.
|
||||
func TestEC2Pipeline_ErrorEnvelopes(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
noNATS bool
|
||||
deny bool
|
||||
body string
|
||||
status int
|
||||
code string
|
||||
}{
|
||||
{name: "missing action", body: "Version=2016-11-15", status: 400, code: "MissingAction"},
|
||||
{name: "invalid action", body: "Action=NoSuchAction", status: 400, code: "InvalidAction"},
|
||||
{name: "policy denial is UnauthorizedOperation", deny: true, body: "Action=DescribeKeyPairs", status: 403, code: "UnauthorizedOperation"},
|
||||
{name: "dry run", body: "Action=DescribeKeyPairs&DryRun=true", status: 412, code: "DryRunOperation"},
|
||||
{name: "max results out of range", body: "Action=DescribeInstances&MaxResults=1", status: 400, code: "InvalidParameterValue"},
|
||||
{name: "tag resource type not taggable", body: "Action=CreateKeyPair&KeyName=k&TagSpecification.1.ResourceType=instance", status: 400, code: "InvalidParameterValue"},
|
||||
{name: "cluster unavailable, non-local action", noNATS: true, body: "Action=DescribeKeyPairs", status: 503, code: "ServiceUnavailable"},
|
||||
{name: "cluster unavailable, local action", noNATS: true, body: "Action=DescribeRegions", status: 503, code: "ServiceUnavailable"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
var stmts []iamdomain.Statement
|
||||
if !c.deny {
|
||||
stmts = append(stmts, allowEC2())
|
||||
}
|
||||
app := ec2Pipeline(t, func(e *ec2PipelineEnv) { e.NoNATS = c.noNATS }, stmts...)
|
||||
got := probeEC2(t, app.Handler, c.body)
|
||||
require.Equal(t, c.status, got.Status, got.Body)
|
||||
assert.Equal(t, c.code, got.Code, got.Body)
|
||||
assert.NotEmpty(t, got.RequestID, "error body carries a request id: %s", got.Body)
|
||||
assert.True(t, strings.HasPrefix(got.Body, "<?xml"), got.Body)
|
||||
assert.Contains(t, got.ContentType, "xml")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestEC2Pipeline_CheckOrder pins which refusal wins when a request earns
|
||||
// several: policy, then tag types, then DryRun, then MaxResults.
|
||||
func TestEC2Pipeline_CheckOrder(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
deny bool
|
||||
body string
|
||||
code string
|
||||
}{
|
||||
{"policy denial beats every parameter error", true, "Action=CreateKeyPair&KeyName=k&DryRun=true&TagSpecification.1.ResourceType=instance", "UnauthorizedOperation"},
|
||||
{"tag type beats dry run", false, "Action=CreateKeyPair&KeyName=k&DryRun=true&TagSpecification.1.ResourceType=instance", "InvalidParameterValue"},
|
||||
{"dry run beats max results", false, "Action=DescribeInstances&DryRun=true&MaxResults=1", "DryRunOperation"},
|
||||
{"max results alone", false, "Action=DescribeInstances&MaxResults=1", "InvalidParameterValue"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
var stmts []iamdomain.Statement
|
||||
if !c.deny {
|
||||
stmts = append(stmts, allowEC2())
|
||||
}
|
||||
app := ec2Pipeline(t, nil, stmts...)
|
||||
got := probeEC2(t, app.Handler, c.body)
|
||||
assert.Equal(t, c.code, got.Code, got.Body)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestEC2Pipeline_Success pins the success envelope for a daemon-backed list
|
||||
// and for the gateway-local actions.
|
||||
func TestEC2Pipeline_Success(t *testing.T) {
|
||||
app := ec2Pipeline(t, nil, allowEC2())
|
||||
|
||||
t.Run("daemon backed list", func(t *testing.T) {
|
||||
got := probeEC2(t, app.Handler, "Action=DescribeKeyPairs")
|
||||
require.Equal(t, 200, got.Status, got.Body)
|
||||
assert.Equal(t, "text/xml", got.ContentType)
|
||||
assert.Contains(t, got.Body, "<DescribeKeyPairsResponse")
|
||||
assert.Contains(t, got.Body, "<requestId>")
|
||||
assert.Equal(t, 1, app.Daemon.count("ec2.DescribeKeyPairs"))
|
||||
})
|
||||
|
||||
t.Run("DescribeRegions needs no daemon", func(t *testing.T) {
|
||||
before := app.Daemon.count("ec2.DescribeRegions")
|
||||
got := probeEC2(t, app.Handler, "Action=DescribeRegions")
|
||||
require.Equal(t, 200, got.Status, got.Body)
|
||||
assert.Contains(t, got.Body, testRegion)
|
||||
assert.Contains(t, got.Body, "https://registry.example", "endpoint is built from the registry host and port")
|
||||
assert.Equal(t, before, app.Daemon.count("ec2.DescribeRegions"))
|
||||
})
|
||||
|
||||
t.Run("DescribeAvailabilityZones needs no daemon", func(t *testing.T) {
|
||||
got := probeEC2(t, app.Handler, "Action=DescribeAvailabilityZones")
|
||||
require.Equal(t, 200, got.Status, got.Body)
|
||||
assert.Contains(t, got.Body, testRegion+"a")
|
||||
})
|
||||
|
||||
t.Run("DescribeAccountAttributes looks up the default VPC", func(t *testing.T) {
|
||||
got := probeEC2(t, app.Handler, "Action=DescribeAccountAttributes")
|
||||
require.Equal(t, 200, got.Status, got.Body)
|
||||
assert.Contains(t, got.Body, "<DescribeAccountAttributesResponse")
|
||||
assert.Equal(t, 1, app.Daemon.count("ec2.DescribeVpcs"))
|
||||
})
|
||||
}
|
||||
|
||||
// TestEC2Pipeline_IdempotentCreate pins ClientToken handling on a creating
|
||||
// action: a replay returns the first result, changed parameters are refused,
|
||||
// and no token means no deduplication.
|
||||
func TestEC2Pipeline_IdempotentCreate(t *testing.T) {
|
||||
app := ec2Pipeline(t, nil, allowEC2())
|
||||
const subject = "ec2.CreateEgressOnlyInternetGateway"
|
||||
create := func(vpc, token string) ec2Outcome {
|
||||
body := "Action=CreateEgressOnlyInternetGateway&VpcId=" + vpc
|
||||
if token != "" {
|
||||
body += "&ClientToken=" + token
|
||||
}
|
||||
return probeEC2(t, app.Handler, body)
|
||||
}
|
||||
|
||||
first := create("vpc-1", "tok-1")
|
||||
require.Equal(t, 200, first.Status, first.Body)
|
||||
assert.Contains(t, first.Body, "eigw-0001")
|
||||
|
||||
replay := create("vpc-1", "tok-1")
|
||||
require.Equal(t, 200, replay.Status, replay.Body)
|
||||
assert.Contains(t, replay.Body, "eigw-0001", "same token returns the first result")
|
||||
assert.Equal(t, 1, app.Daemon.count(subject), "the replay does not reach the daemon")
|
||||
|
||||
mismatch := create("vpc-2", "tok-1")
|
||||
assert.Equal(t, "IdempotentParameterMismatch", mismatch.Code, mismatch.Body)
|
||||
assert.Equal(t, 400, mismatch.Status)
|
||||
assert.Equal(t, 1, app.Daemon.count(subject))
|
||||
|
||||
for range 2 {
|
||||
require.Equal(t, 200, create("vpc-1", "").Status)
|
||||
}
|
||||
assert.Equal(t, 3, app.Daemon.count(subject), "without a token each call creates")
|
||||
}
|
||||
|
||||
// TestEC2Pipeline_PassRole pins the gateway iam:PassRole gate on an action
|
||||
// that hands an instance profile over: a denied role is UnauthorizedOperation,
|
||||
// a permitted one reaches the daemon.
|
||||
func TestEC2Pipeline_PassRole(t *testing.T) {
|
||||
const body = "Action=AssociateIamInstanceProfile&InstanceId=i-dev&IamInstanceProfile.Name=web"
|
||||
passRole := statement("Allow", "iam:PassRole", "*")
|
||||
passRole.Condition = map[string]map[string]iamdomain.ConditionValue{
|
||||
iampolicy.OpStringEquals: {iampolicy.KeyPassedToService: {"ec2.amazonaws.com"}},
|
||||
}
|
||||
|
||||
t.Run("permitted role reaches the daemon", func(t *testing.T) {
|
||||
app := ec2Pipeline(t, nil, allowEC2(), passRole)
|
||||
got := probeEC2(t, app.Handler, body)
|
||||
assert.Less(t, got.Status, 500, got.Body)
|
||||
assert.NotEqual(t, "UnauthorizedOperation", got.Code, got.Body)
|
||||
assert.Equal(t, 1, app.Daemon.count(ec2v1.InstanceCommandSubject("i-dev")))
|
||||
})
|
||||
|
||||
t.Run("no PassRole", func(t *testing.T) {
|
||||
app := ec2Pipeline(t, nil, allowEC2())
|
||||
got := probeEC2(t, app.Handler, body)
|
||||
assert.Equal(t, 403, got.Status, got.Body)
|
||||
assert.Equal(t, "UnauthorizedOperation", got.Code, got.Body)
|
||||
assert.Zero(t, app.Daemon.count(ec2v1.InstanceCommandSubject("i-dev")))
|
||||
})
|
||||
|
||||
t.Run("PassRole for another service", func(t *testing.T) {
|
||||
other := statement("Allow", "iam:PassRole", "*")
|
||||
other.Condition = map[string]map[string]iamdomain.ConditionValue{
|
||||
iampolicy.OpStringEquals: {iampolicy.KeyPassedToService: {"ecs-tasks.amazonaws.com"}},
|
||||
}
|
||||
app := ec2Pipeline(t, nil, allowEC2(), other)
|
||||
got := probeEC2(t, app.Handler, body)
|
||||
assert.Equal(t, 403, got.Status, got.Body)
|
||||
assert.Equal(t, "UnauthorizedOperation", got.Code, got.Body)
|
||||
})
|
||||
}
|
||||
|
||||
// TestEC2Pipeline_ImportKeyPairPadding pins the base64 padding workaround: a
|
||||
// doubly encoded trailing "==" is decoded before the key reaches the daemon,
|
||||
// and a single trailing "=" is not.
|
||||
func TestEC2Pipeline_ImportKeyPairPadding(t *testing.T) {
|
||||
forwarded := func(t *testing.T, material string) string {
|
||||
t.Helper()
|
||||
app := ec2Pipeline(t, nil, allowEC2())
|
||||
body := "Action=ImportKeyPair&KeyName=k&PublicKeyMaterial=" + url.QueryEscape(material)
|
||||
got := probeEC2(t, app.Handler, body)
|
||||
require.Equal(t, 200, got.Status, got.Body)
|
||||
var in ec2.ImportKeyPairInput
|
||||
require.NoError(t, json.NewDecoder(bytes.NewReader(app.Daemon.body("ec2.ImportKeyPair"))).Decode(&in))
|
||||
return string(in.PublicKeyMaterial)
|
||||
}
|
||||
|
||||
t.Run("double padding", func(t *testing.T) {
|
||||
assert.Equal(t, "ssh-ed25519 AAAA==", forwarded(t, "ssh-ed25519 AAAA%3D%3D"))
|
||||
})
|
||||
t.Run("single padding is left encoded", func(t *testing.T) {
|
||||
assert.Equal(t, "ssh-ed25519 AAAA%3D", forwarded(t, "ssh-ed25519 AAAA%3D"))
|
||||
})
|
||||
t.Run("already decoded", func(t *testing.T) {
|
||||
assert.Equal(t, "ssh-ed25519 AAAA==", forwarded(t, "ssh-ed25519 AAAA=="))
|
||||
})
|
||||
}
|
||||
|
||||
const runInstancesBase = "Action=RunInstances&InstanceType=t3.micro&MinCount=1&MaxCount=1"
|
||||
|
||||
// TestEC2Pipeline_RunInstancesLaunchTemplateAuthorizedAfterExpansion pins that
|
||||
// a launch template is expanded before the policy gate, so a resource the
|
||||
// template supplies is authorized and a Deny on it fires.
|
||||
func TestEC2Pipeline_RunInstancesLaunchTemplateAuthorizedAfterExpansion(t *testing.T) {
|
||||
const subject = "ec2.DescribeLaunchTemplateVersions"
|
||||
template := func(image string) string {
|
||||
return `{"LaunchTemplateVersions":[{"LaunchTemplateData":{"ImageId":"` + image + `"}}]}`
|
||||
}
|
||||
const body = "Action=RunInstances&InstanceType=t3.micro&MinCount=1&MaxCount=1&LaunchTemplate.LaunchTemplateName=lt"
|
||||
deny := statement("Deny", "ec2:RunInstances", "arn:aws:ec2:*:*:image/ami-fenced")
|
||||
|
||||
t.Run("template image is denied", func(t *testing.T) {
|
||||
app := ec2Pipeline(t, nil, allowEC2(), deny)
|
||||
app.Daemon.reply(subject, template("ami-fenced"))
|
||||
got := probeEC2(t, app.Handler, body)
|
||||
assert.Equal(t, 403, got.Status, got.Body)
|
||||
assert.Equal(t, "UnauthorizedOperation", got.Code, got.Body)
|
||||
assert.Equal(t, 1, app.Daemon.count(subject))
|
||||
assert.Zero(t, app.Daemon.count("ec2.RunInstances"))
|
||||
})
|
||||
|
||||
t.Run("template image not fenced passes the gate", func(t *testing.T) {
|
||||
app := ec2Pipeline(t, nil, allowEC2(), deny)
|
||||
app.Daemon.reply(subject, template("ami-ok"))
|
||||
got := probeEC2(t, app.Handler, body)
|
||||
assert.NotEqual(t, "UnauthorizedOperation", got.Code, got.Body)
|
||||
})
|
||||
|
||||
t.Run("unknown template", func(t *testing.T) {
|
||||
app := ec2Pipeline(t, nil, allowEC2())
|
||||
app.Daemon.reply(subject, `{"LaunchTemplateVersions":[]}`)
|
||||
got := probeEC2(t, app.Handler, body)
|
||||
assert.Equal(t, "InvalidLaunchTemplateId.VersionNotFound", got.Code, got.Body)
|
||||
})
|
||||
}
|
||||
|
||||
// TestEC2Pipeline_RunInstancesInstanceProfile pins the current order of the
|
||||
// profile checks on RunInstances: the profile is resolved first, so an unknown
|
||||
// one is refused before iam:PassRole is consulted, and a resolved one is then
|
||||
// subject to PassRole on its role. This is the baseline for any parity change.
|
||||
func TestEC2Pipeline_RunInstancesInstanceProfile(t *testing.T) {
|
||||
const body = runInstancesBase + "&ImageId=ami-1&IamInstanceProfile.Name=web"
|
||||
|
||||
t.Run("unknown profile is refused before PassRole", func(t *testing.T) {
|
||||
app := ec2Pipeline(t, func(e *ec2PipelineEnv) { e.ResolveErr = errors.New(awserrors.ErrorIAMNoSuchEntity) }, allowEC2())
|
||||
got := probeEC2(t, app.Handler, body)
|
||||
assert.Equal(t, "InvalidIamInstanceProfile.NotFound", got.Code, got.Body)
|
||||
assert.Equal(t, 404, got.Status)
|
||||
})
|
||||
|
||||
t.Run("resolved profile without PassRole", func(t *testing.T) {
|
||||
app := ec2Pipeline(t, nil, allowEC2())
|
||||
got := probeEC2(t, app.Handler, body)
|
||||
assert.Equal(t, 403, got.Status, got.Body)
|
||||
assert.Equal(t, "UnauthorizedOperation", got.Code, got.Body)
|
||||
})
|
||||
|
||||
t.Run("other resolution failure is returned as is", func(t *testing.T) {
|
||||
app := ec2Pipeline(t, func(e *ec2PipelineEnv) { e.ResolveErr = errors.New(awserrors.ErrorServerInternal) }, allowEC2())
|
||||
got := probeEC2(t, app.Handler, body)
|
||||
assert.Equal(t, "ServerInternal", got.Code, got.Body)
|
||||
})
|
||||
}
|
||||
@@ -1,12 +1,11 @@
|
||||
//test:in-package — walks the unexported ec2Actions route table and drives
|
||||
// EC2_Request through the gateway's unexported test helpers.
|
||||
//test:in-package — drives EC2_Request through the gateway's unexported test
|
||||
// helpers.
|
||||
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"encoding/xml"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -15,21 +14,6 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// An action added to the route table with TagSpecifications must say which
|
||||
// resource types it tags, or a wrong type would be silently dropped again.
|
||||
func TestEC2TaggableTypes_CoverEveryAction(t *testing.T) {
|
||||
for action, handler := range ec2Actions {
|
||||
input, err := handler.parse(map[string]string{"Action": action})
|
||||
require.NoError(t, err, action)
|
||||
_, tags := reflect.TypeOf(input).Elem().FieldByName("TagSpecifications")
|
||||
_, listed := ec2TaggableTypes[action]
|
||||
assert.Equal(t, tags, listed, "%s: TagSpecifications modelled=%v, listed in ec2TaggableTypes=%v", action, tags, listed)
|
||||
}
|
||||
for action := range ec2TaggableTypes {
|
||||
assert.Contains(t, ec2Actions, action, "ec2TaggableTypes lists an action the gateway does not route")
|
||||
}
|
||||
}
|
||||
|
||||
func tagSpecBody(action, resourceType string) string {
|
||||
return "Action=" + action + "&TagSpecification.1.ResourceType=" + resourceType +
|
||||
"&TagSpecification.1.Tag.1.Key=k&TagSpecification.1.Tag.1.Value=v"
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -10,20 +9,6 @@ import (
|
||||
// external test package.
|
||||
func (gw *GatewayConfig) AdvertisedEndpoint() string { return gw.advertisedEndpoint() }
|
||||
|
||||
// DispatchEC2Action runs an action through the parse-then-dispatch entry
|
||||
// EC2_Request uses, without an *http.Request.
|
||||
func DispatchEC2Action(gw *GatewayConfig, action string, query map[string]string, accountID string) ([]byte, error) {
|
||||
h, ok := ec2Actions[action]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("no EC2 action %q", action)
|
||||
}
|
||||
input, err := h.parse(query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return h.dispatch(action, input, gw, accountID, nil)
|
||||
}
|
||||
|
||||
// SetDiscoverActiveNodesTimeoutForTest shortens the discovery fan-out for one
|
||||
// test. Tests that call it must not run in parallel.
|
||||
func SetDiscoverActiveNodesTimeoutForTest(tb testing.TB, d time.Duration) {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// Package gateway provides the AWS-compatible API gateway for the Spinifex platform.
|
||||
package gateway
|
||||
|
||||
import (
|
||||
@@ -10,7 +11,6 @@ import (
|
||||
authlimit "github.com/mulgadc/spinifex/spinifex/ingress/aws/ratelimit"
|
||||
"github.com/mulgadc/spinifex/spinifex/runtime/roles/awsgw/accountadmission"
|
||||
"github.com/mulgadc/spinifex/spinifex/runtime/roles/awsgw/policyauth"
|
||||
"io"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"net/http"
|
||||
@@ -23,9 +23,9 @@ import (
|
||||
"github.com/mulgadc/bluebottle/pkg/iampolicy"
|
||||
bbotel "github.com/mulgadc/bluebottle/pkg/otelsetup"
|
||||
"github.com/mulgadc/bluebottle/pkg/ratelimit"
|
||||
"github.com/mulgadc/bluebottle/pkg/sigv4"
|
||||
clusterv1 "github.com/mulgadc/spinifex/contracts/cluster/v1"
|
||||
"github.com/mulgadc/spinifex/spinifex/domains/admission/quota"
|
||||
ec2awsapi "github.com/mulgadc/spinifex/spinifex/domains/ec2/awsapi"
|
||||
ec2instanceapi "github.com/mulgadc/spinifex/spinifex/domains/ec2/awsapi/instance"
|
||||
iamdomain "github.com/mulgadc/spinifex/spinifex/domains/iam"
|
||||
stsdomain "github.com/mulgadc/spinifex/spinifex/domains/sts"
|
||||
@@ -39,7 +39,6 @@ import (
|
||||
"github.com/mulgadc/spinifex/spinifex/ingress/aws/envelope"
|
||||
"github.com/mulgadc/spinifex/spinifex/ingress/aws/query"
|
||||
"github.com/nats-io/nats.go"
|
||||
"github.com/nats-io/nats.go/jetstream"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
@@ -89,11 +88,10 @@ type GatewayConfig struct {
|
||||
activeNodesMu sync.RWMutex
|
||||
activeNodesCount int
|
||||
activeNodesAt time.Time
|
||||
// The EC2 client-token KV binding, bound on first use and reused after, so
|
||||
// the creates that honour ClientToken do not rebind it per request.
|
||||
ec2TokenOnce sync.Once
|
||||
ec2TokenKV jetstream.KeyValue
|
||||
ec2TokenErr error
|
||||
// The EC2 handler, built on first use so its client-token binding is reused
|
||||
// across requests.
|
||||
ec2Once sync.Once
|
||||
ec2Handler *ec2awsapi.Handler
|
||||
InternalSuffix string // Internal DNS suffix for AWS-parity endpoints (e.g. spinifex.internal)
|
||||
// RegistryPort is the gateway's advertised port, appended to the ECR
|
||||
// registry host so docker login/tag/push dial the right port. Empty or
|
||||
@@ -504,13 +502,6 @@ const (
|
||||
eksServicePrincipal = "eks.amazonaws.com"
|
||||
)
|
||||
|
||||
// checkPassRole enforces iam:PassRole on roleARN. iam:PassedToService is fixed
|
||||
// by the call site handing the role over, never read from the request.
|
||||
func (gw *GatewayConfig) checkPassRole(r *http.Request, roleARN, passedTo string) error {
|
||||
return gw.checkPolicyResourcesWithKeys(r, "iam", "PassRole", []string{roleARN},
|
||||
iampolicy.ConditionKeys{iampolicy.KeyPassedToService: passedTo})
|
||||
}
|
||||
|
||||
// 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 {
|
||||
@@ -631,24 +622,6 @@ func (gw *GatewayConfig) ErrorHandler(w http.ResponseWriter, r *http.Request, er
|
||||
}
|
||||
}
|
||||
|
||||
// readQueryArgs returns parsed query args from context (set by SigV4) or falls
|
||||
// back to parsing the body (unauthenticated/test paths only).
|
||||
func readQueryArgs(r *http.Request) (map[string]string, error) {
|
||||
if args, ok := r.Context().Value(ctxQueryArgs).(map[string]string); ok {
|
||||
return args, nil
|
||||
}
|
||||
// Same cap as the signed path: this fallback only runs when the context
|
||||
// carries no pre-parsed args, so the body has not been bounded upstream.
|
||||
body, err := io.ReadAll(io.LimitReader(r.Body, sigv4.MaxPayloadLen+1))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read body: %w", err)
|
||||
}
|
||||
if int64(len(body)) > sigv4.MaxPayloadLen {
|
||||
return nil, errors.New(awserrors.ErrorRequestEntityTooLarge)
|
||||
}
|
||||
return ParseAWSQueryArgs(string(body))
|
||||
}
|
||||
|
||||
// ParseAWSQueryArgs parses an AWS query-protocol body. Returns an error on
|
||||
// invalid percent-encoding so callers can surface MalformedQueryString.
|
||||
func ParseAWSQueryArgs(body string) (map[string]string, error) {
|
||||
|
||||
@@ -1271,27 +1271,6 @@ func TestCheckPolicy_NonTransientErrorStillFails(t *testing.T) {
|
||||
assert.Equal(t, awserrors.ErrorInternalError, err.Error())
|
||||
}
|
||||
|
||||
func TestImportKeyPair_Base64PaddingWorkaround(t *testing.T) {
|
||||
// The ImportKeyPair handler decodes URL-encoded Base64 padding (%3D%3D → ==)
|
||||
// before passing to the generic handler.
|
||||
handler := ec2Actions["ImportKeyPair"]
|
||||
require.NotNil(t, handler)
|
||||
|
||||
q := map[string]string{
|
||||
"Action": "ImportKeyPair",
|
||||
"KeyName": "test-key",
|
||||
"PublicKeyMaterial": "c3NoLXJzYSBBQUFBQjNOemFDMXljMkVBQUFBREFRQUJBQUFCZ1FD%3D%3D",
|
||||
}
|
||||
|
||||
_, err := handler.parse(q)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.True(t, strings.HasSuffix(q["PublicKeyMaterial"], "=="),
|
||||
"Expected PublicKeyMaterial to end with == but got: %s", q["PublicKeyMaterial"])
|
||||
assert.NotContains(t, q["PublicKeyMaterial"], "%3D",
|
||||
"Expected no URL-encoded padding remaining")
|
||||
}
|
||||
|
||||
// --- Throttle middleware integration tests ---
|
||||
|
||||
func TestThrottleKeyFuncs_ExtractsAccountAndAction(t *testing.T) {
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
package gateway
|
||||
|
||||
import (
|
||||
"maps"
|
||||
"slices"
|
||||
|
||||
ec2awsapi "github.com/mulgadc/spinifex/spinifex/domains/ec2/awsapi"
|
||||
"github.com/mulgadc/spinifex/spinifex/ingress/aws/dispatch"
|
||||
)
|
||||
|
||||
@@ -23,7 +23,7 @@ type ServiceOperationInventory struct {
|
||||
func AWSOperationInventory(registered map[string]dispatch.Inventory) map[string]ServiceOperationInventory {
|
||||
inventory := map[string]ServiceOperationInventory{
|
||||
"ec2": {
|
||||
Registered: mapKeys(ec2Actions),
|
||||
Registered: ec2awsapi.ActionNames(),
|
||||
},
|
||||
}
|
||||
for name, inv := range registered {
|
||||
@@ -35,7 +35,3 @@ func AWSOperationInventory(registered map[string]dispatch.Inventory) map[string]
|
||||
}
|
||||
return inventory
|
||||
}
|
||||
|
||||
func mapKeys[V any](values map[string]V) []string {
|
||||
return slices.Sorted(maps.Keys(values))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user