From ac6d982ddf9e2746ef51971401846ba1db2a21d5 Mon Sep 17 00:00:00 2001 From: Gajendra Malviya Date: Thu, 6 Aug 2026 02:18:34 +0530 Subject: [PATCH 1/3] feat(aws): add VPC Lattice SDK-compat parity (73 operations) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement the full aws-sdk-go-v2/service/vpclattice control-plane surface against the in-memory driver — all 73 SDK operations, no stubs. Covers service networks, services, listeners, rules (incl. BatchUpdateRule), target groups and targets, the three service-network association types, resource configurations, resource gateways, resource endpoint associations, access-log subscriptions, auth and resource policies, domain verifications, and tagging. VPC Lattice is the emulator's first REST-JSON (awsRestjson1) service: operations are routed by HTTP method + URL path rather than an X-Amz-Target header. The handler claims its top-level path prefixes and dispatches by segment + method; identifiers accept a bare ID or a full ARN. Union-typed fields (listener defaultAction, rule match/action, target-group config, resource-configuration definition) are stored as raw JSON and echoed back verbatim. Each resource group has a real-SDK round-trip lifecycle test, plus provider unit tests covering error paths, scoping, and clone-on-read isolation. --- docs/services.md | 50 +- go.mod | 7 +- go.sum | 14 +- providers/aws/aws.go | 3 + providers/aws/vpclattice/accesslogs.go | 107 +++ providers/aws/vpclattice/associations.go | 293 +++++++++ .../aws/vpclattice/domainverifications.go | 76 +++ providers/aws/vpclattice/listeners.go | 145 ++++ providers/aws/vpclattice/policies.go | 88 +++ providers/aws/vpclattice/resourceconfigs.go | 121 ++++ providers/aws/vpclattice/resourcegateways.go | 123 ++++ providers/aws/vpclattice/rules.go | 176 +++++ providers/aws/vpclattice/servicenetworks.go | 139 ++++ providers/aws/vpclattice/services.go | 125 ++++ providers/aws/vpclattice/tagging.go | 63 ++ providers/aws/vpclattice/targetgroups.go | 146 +++++ providers/aws/vpclattice/targets.go | 111 ++++ providers/aws/vpclattice/vpclattice.go | 106 +++ providers/aws/vpclattice/vpclattice_test.go | 346 ++++++++++ server/aws/aws.go | 14 + server/aws/vpclattice/accesslogs.go | 115 ++++ server/aws/vpclattice/associations.go | 347 ++++++++++ server/aws/vpclattice/domainverifications.go | 86 +++ server/aws/vpclattice/errors.go | 83 +++ server/aws/vpclattice/handler.go | 129 ++++ server/aws/vpclattice/listeners.go | 140 ++++ server/aws/vpclattice/policies.go | 132 ++++ server/aws/vpclattice/resources.go | 311 +++++++++ server/aws/vpclattice/rules.go | 189 ++++++ server/aws/vpclattice/sdk_roundtrip_test.go | 620 ++++++++++++++++++ server/aws/vpclattice/servicenetworks.go | 135 ++++ server/aws/vpclattice/services.go | 150 +++++ server/aws/vpclattice/tagging.go | 70 ++ server/aws/vpclattice/targetgroups.go | 245 +++++++ services/vpclattice/driver/driver.go | 521 +++++++++++++++ 35 files changed, 5516 insertions(+), 10 deletions(-) create mode 100644 providers/aws/vpclattice/accesslogs.go create mode 100644 providers/aws/vpclattice/associations.go create mode 100644 providers/aws/vpclattice/domainverifications.go create mode 100644 providers/aws/vpclattice/listeners.go create mode 100644 providers/aws/vpclattice/policies.go create mode 100644 providers/aws/vpclattice/resourceconfigs.go create mode 100644 providers/aws/vpclattice/resourcegateways.go create mode 100644 providers/aws/vpclattice/rules.go create mode 100644 providers/aws/vpclattice/servicenetworks.go create mode 100644 providers/aws/vpclattice/services.go create mode 100644 providers/aws/vpclattice/tagging.go create mode 100644 providers/aws/vpclattice/targetgroups.go create mode 100644 providers/aws/vpclattice/targets.go create mode 100644 providers/aws/vpclattice/vpclattice.go create mode 100644 providers/aws/vpclattice/vpclattice_test.go create mode 100644 server/aws/vpclattice/accesslogs.go create mode 100644 server/aws/vpclattice/associations.go create mode 100644 server/aws/vpclattice/domainverifications.go create mode 100644 server/aws/vpclattice/errors.go create mode 100644 server/aws/vpclattice/handler.go create mode 100644 server/aws/vpclattice/listeners.go create mode 100644 server/aws/vpclattice/policies.go create mode 100644 server/aws/vpclattice/resources.go create mode 100644 server/aws/vpclattice/rules.go create mode 100644 server/aws/vpclattice/sdk_roundtrip_test.go create mode 100644 server/aws/vpclattice/servicenetworks.go create mode 100644 server/aws/vpclattice/services.go create mode 100644 server/aws/vpclattice/tagging.go create mode 100644 server/aws/vpclattice/targetgroups.go create mode 100644 services/vpclattice/driver/driver.go diff --git a/docs/services.md b/docs/services.md index fb0a34ef..dbd75003 100644 --- a/docs/services.md +++ b/docs/services.md @@ -35,6 +35,7 @@ This document lists every service and operation available in CloudEmu across all | 22 | Machine Learning | `sagemaker` (+ `sagemaker-runtime`) | `azureai` (CognitiveServices + MachineLearningServices) | `vertexai` | | 23 | AI Search | — | `azuresearch` (Microsoft.Search) | — | | 24 | Container Orchestration | `ecs` | — | — | +| 25 | Application Networking | `vpclattice` | — | — | --- @@ -2218,6 +2219,52 @@ real EC2 instance subject to managed-resource visibility. --- +## 25. Application Networking + +**Driver interface:** `services/vpclattice/driver/driver.go` +**AWS:** VPC Lattice (REST-JSON, `awsRestjson1`) | **Azure:** — | **GCP:** — + +AWS-only. Real `aws-sdk-go-v2/service/vpclattice` clients work against the +SDK-compat server (`awsserver.Drivers{VPCLattice: cloud.VPCLattice}`). Full +parity: **all 73 SDK operations**, no stubs. + +Unlike the AWS JSON 1.1 services, VPC Lattice uses **REST-JSON**: the operation +is selected by HTTP method + URL path (e.g. `POST /services`, `GET +/services/{id}/listeners/{id}`, `PATCH /servicenetworks/{id}`) rather than an +`X-Amz-Target` header. The handler claims its top-level path prefixes and +dispatches by segment + method; identifiers accept either a bare ID or a full +ARN. Union-typed fields (a listener's `defaultAction`, a rule's +`match`/`action`, a target group's `config`, a resource configuration's +`resourceConfigurationDefinition`) are stored as raw JSON and echoed back +verbatim for full fidelity. Service-network association counts are computed on +read across the association stores. + +| Family | Operations | +|--------|-----------| +| Service networks | Create/Get/Update/Delete/ListServiceNetwork(s) | +| Services | Create/Get/Update/Delete/ListService(s) | +| Listeners | Create/Get/Update/Delete/ListListener(s) | +| Rules | Create/Get/Update/Delete/ListRule(s), BatchUpdateRule | +| Target groups & targets | Create/Get/Update/Delete/ListTargetGroup(s), Register/Deregister/ListTargets | +| Service-network associations | Create/Get/Update/Delete/List ServiceNetworkVpcAssociation(s) + ListServiceNetworkVpcEndpointAssociations; Create/Get/Delete/List ServiceNetworkService & ServiceNetworkResource Association(s) | +| Resource configurations | Create/Get/Update/Delete/ListResourceConfiguration(s) | +| Resource gateways | Create/Get/Update/Delete/ListResourceGateway(s) | +| Resource endpoint associations | List/DeleteResourceEndpointAssociation(s) | +| Access-log subscriptions | Create/Get/Update/Delete/ListAccessLogSubscription(s) | +| Auth & resource policies | Put/Get/DeleteAuthPolicy, Put/Get/DeleteResourcePolicy | +| Domain verifications | Start/Get/Delete/ListDomainVerification(s) | +| Tagging | TagResource, UntagResource, ListTagsForResource | + +*Accepted but not simulated* (stored/echoed so SDK calls succeed, no behavioral +effect): resources are created directly in a terminal `ACTIVE`/`PENDING` status +(no async state machine); VPC-endpoint and resource-endpoint associations are a +managed surface returned as empty lists; `Forward`/health-check targeting is +stored but not used to route real traffic. + +**Total: 73 operations.** + +--- + ## Provider-specific resources Resources below are served for one provider only, because the concept exists in @@ -2330,7 +2377,8 @@ still sees success. | Machine Learning — GCP Vertex AI (Go API/driver) | 128 | | AI Search — Azure AI Search (control + data plane) | 53 | | Container Orchestration — AWS ECS | 37 | -| **Grand Total** | **1562** (+138 optional) | +| Application Networking — AWS VPC Lattice | 73 | +| **Grand Total** | **1635** (+138 optional) | Optional operations are capabilities a driver may implement but is not required to; see the sections marked "optional capability". They are counted separately diff --git a/go.mod b/go.mod index 02946c50..11b1d3e9 100644 --- a/go.mod +++ b/go.mod @@ -37,7 +37,7 @@ require ( github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azsecrets v1.5.0 github.com/Azure/azure-sdk-for-go/sdk/storage/azblob v1.6.4 github.com/Azure/azure-sdk-for-go/sdk/storage/azqueue v1.0.1 - github.com/aws/aws-sdk-go-v2 v1.43.3 + github.com/aws/aws-sdk-go-v2 v1.43.4 github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 github.com/aws/aws-sdk-go-v2/config v1.32.14 github.com/aws/aws-sdk-go-v2/credentials v1.19.14 @@ -77,6 +77,7 @@ require ( github.com/aws/aws-sdk-go-v2/service/sqs v1.42.27 github.com/aws/aws-sdk-go-v2/service/ssm v1.71.0 github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 + github.com/aws/aws-sdk-go-v2/service/vpclattice v1.25.5 github.com/aws/smithy-go v1.27.6 github.com/databricks/databricks-sdk-go v0.144.0 github.com/fxamacker/cbor/v2 v2.9.1 @@ -107,8 +108,8 @@ require ( github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0 // indirect github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.34 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.34 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.35 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.35 // indirect github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.31 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.14 // indirect diff --git a/go.sum b/go.sum index 4ffd081a..e8866738 100644 --- a/go.sum +++ b/go.sum @@ -116,8 +116,8 @@ github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0 github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/cloudmock v0.55.0/go.mod h1:vB2GH9GAYYJTO3mEn8oYwzEdhlayZIdQz6zdzgUIRvA= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0 h1:0s6TxfCu2KHkkZPnBfsQ2y5qia0jl3MMrmBhu3nCOYk= github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc= -github.com/aws/aws-sdk-go-v2 v1.43.3 h1:XJIcfv8uDs2ukdQsoAC8/Ebu1ejxwzlayl2ZsiFns2A= -github.com/aws/aws-sdk-go-v2 v1.43.3/go.mod h1:70vwSy16txshwG+g55WkpgPKDIByzHI8ccBsOteo3bQ= +github.com/aws/aws-sdk-go-v2 v1.43.4 h1:b9FTvbRwy+JCsfp2Wp6wV/KbOx3Aj7nkoFb2cRX0IhE= +github.com/aws/aws-sdk-go-v2 v1.43.4/go.mod h1:70vwSy16txshwG+g55WkpgPKDIByzHI8ccBsOteo3bQ= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14 h1:3IZY0XAJquT3aHzbkHfPzy4ACPcEjVG0x87KOwtpqGY= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.14/go.mod h1:zwM6veDkhGgQFqkBy+uT28AAYpLu+uFMlPl+rCg/73E= github.com/aws/aws-sdk-go-v2/config v1.32.14 h1:opVIRo/ZbbI8OIqSOKmpFaY7IwfFUOCCXBsUpJOwDdI= @@ -126,10 +126,10 @@ github.com/aws/aws-sdk-go-v2/credentials v1.19.14 h1:n+UcGWAIZHkXzYt87uMFBv/l8TH github.com/aws/aws-sdk-go-v2/credentials v1.19.14/go.mod h1:cJKuyWB59Mqi0jM3nFYQRmnHVQIcgoxjEMAbLkpr62w= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21 h1:NUS3K4BTDArQqNu2ih7yeDLaS3bmHD0YndtA6UP884g= github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.21/go.mod h1:YWNWJQNjKigKY1RHVJCuupeWDrrHjRqHm0N9rdrWzYI= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.34 h1:vuIfjzoeqhQMGJyOBU3t0ZEjn2jrN8Bbg1N4CgjzM5Q= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.34/go.mod h1:hP28cN4CPJLZHirdQPrZR50JcLN4ApRJP2tzG8cRlhY= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.34 h1:9faHsnqxJ1vDvB4wMZy/ajIDyz5QhllQjjc72RJpXAw= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.34/go.mod h1:Yp6nIyejpa23nzlB/LhT63KTla9Jdi06nv/HH/OkAH8= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.35 h1:kzVuGlatQtYinwBJEEyLAbggepCoavosiaHHX9+fD+c= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.35/go.mod h1:0yLx0yEI+SfqeJMPvOtIEFoZbiQYXMGszBueiutQyaI= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.35 h1:WK6CjihTuLisCjSKKbildJ79sGZZgbBz3iNa7VsKIhU= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.35/go.mod h1:KYleN57luLoe97R7vTnx8PMcVrr9gAcRECtOjl91DNg= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 h1:qYQ4pzQ2Oz6WpQ8T3HvGHnZydA72MnLuFK9tJwmrbHw= github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY= github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.31 h1:3GUprIsfmGcC5SACIyB0e7E0BM1O1b3Erl5CePYIAeQ= @@ -222,6 +222,8 @@ github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19 h1:dzztQ1YmfPrxdrOiuZRMF6f github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.19/go.mod h1:YO8TrYtFdl5w/4vmjL8zaBSsiNp3w0L1FfKVKenZT7w= github.com/aws/aws-sdk-go-v2/service/sts v1.41.10 h1:p8ogvvLugcR/zLBXTXrTkj0RYBUdErbMnAFFp12Lm/U= github.com/aws/aws-sdk-go-v2/service/sts v1.41.10/go.mod h1:60dv0eZJfeVXfbT1tFJinbHrDfSJ2GZl4Q//OSSNAVw= +github.com/aws/aws-sdk-go-v2/service/vpclattice v1.25.5 h1:MYTOPMWy6a0zO3LxEAJbh/6FebfIRRjmR/CUgj3raLs= +github.com/aws/aws-sdk-go-v2/service/vpclattice v1.25.5/go.mod h1:7AHfzbcrbB4ZrwONKrCNdvIwTpyoGL+2CGSNYx1+wko= github.com/aws/smithy-go v1.27.6 h1:0zjT8jgK3jbrTT7JJ3EE6JsMhX8JTrZ+f1sEndYDXrA= github.com/aws/smithy-go v1.27.6/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= diff --git a/providers/aws/aws.go b/providers/aws/aws.go index 3ae0a79c..12a22ca6 100644 --- a/providers/aws/aws.go +++ b/providers/aws/aws.go @@ -36,6 +36,7 @@ import ( "github.com/stackshy/cloudemu/v2/providers/aws/sqs" "github.com/stackshy/cloudemu/v2/providers/aws/ssm" "github.com/stackshy/cloudemu/v2/providers/aws/vpc" + "github.com/stackshy/cloudemu/v2/providers/aws/vpclattice" "github.com/stackshy/cloudemu/v2/services/resourcediscovery" ) @@ -144,6 +145,7 @@ type Provider struct { SageMaker *sagemaker.Mock SSM *ssm.Mock ECS *ecs.Mock + VPCLattice *vpclattice.Mock ResourceDiscovery *resourcediscovery.Engine AccountID string Region string @@ -181,6 +183,7 @@ func New(opts ...config.Option) *Provider { SageMaker: sagemaker.New(o), SSM: ssm.New(o), ECS: ecs.New(o), + VPCLattice: vpclattice.New(o), AccountID: o.AccountID, Region: o.Region, } diff --git a/providers/aws/vpclattice/accesslogs.go b/providers/aws/vpclattice/accesslogs.go new file mode 100644 index 00000000..77e8bd44 --- /dev/null +++ b/providers/aws/vpclattice/accesslogs.go @@ -0,0 +1,107 @@ +package vpclattice + +import ( + "context" + "strings" + + "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/internal/idgen" + "github.com/stackshy/cloudemu/v2/services/vpclattice/driver" +) + +func accessLogSubNotFound(id string) error { + return errors.Newf(errors.NotFound, "access log subscription %q not found", id) +} + +func cloneAccessLogSub(a *driver.AccessLogSubscription) driver.AccessLogSubscription { return *a } + +func (m *Mock) CreateAccessLogSubscription( + _ context.Context, resourceIdentifier, destinationARN, logType string, _ map[string]string, +) (*driver.AccessLogSubscription, error) { + m.mu.Lock() + defer m.mu.Unlock() + + resourceARN := resourceIdentifier + if !strings.Contains(resourceARN, ":") { + resourceARN = "" + } + + id := idgen.GenerateID("als-") + a := &driver.AccessLogSubscription{ + ID: id, + ARN: m.arn("accesslogsubscription/" + id), + DestinationARN: destinationARN, + ResourceID: idFromIdentifier(resourceIdentifier), + ResourceARN: resourceARN, + ServiceNetworkLogType: logType, + CreatedAt: m.now(), + LastUpdatedAt: m.now(), + } + m.accessLogSubs.Set(id, a) + + out := cloneAccessLogSub(a) + + return &out, nil +} + +func (m *Mock) GetAccessLogSubscription(_ context.Context, identifier string) (*driver.AccessLogSubscription, error) { + m.mu.Lock() + defer m.mu.Unlock() + + id := idFromIdentifier(identifier) + + a, ok := m.accessLogSubs.Get(id) + if !ok { + return nil, accessLogSubNotFound(id) + } + + out := cloneAccessLogSub(a) + + return &out, nil +} + +func (m *Mock) UpdateAccessLogSubscription( + _ context.Context, identifier, destinationARN string, +) (*driver.AccessLogSubscription, error) { + m.mu.Lock() + defer m.mu.Unlock() + + id := idFromIdentifier(identifier) + + a, ok := m.accessLogSubs.Get(id) + if !ok { + return nil, accessLogSubNotFound(id) + } + + if destinationARN != "" { + a.DestinationARN = destinationARN + } + + a.LastUpdatedAt = m.now() + + out := cloneAccessLogSub(a) + + return &out, nil +} + +func (m *Mock) DeleteAccessLogSubscription(_ context.Context, identifier string) error { + m.mu.Lock() + defer m.mu.Unlock() + + id := idFromIdentifier(identifier) + + if !m.accessLogSubs.Has(id) { + return accessLogSubNotFound(id) + } + + m.accessLogSubs.Delete(id) + + return nil +} + +func (m *Mock) ListAccessLogSubscriptions(_ context.Context) ([]driver.AccessLogSubscription, error) { + m.mu.Lock() + defer m.mu.Unlock() + + return sortedValues(m.accessLogSubs.All(), cloneAccessLogSub), nil +} diff --git a/providers/aws/vpclattice/associations.go b/providers/aws/vpclattice/associations.go new file mode 100644 index 00000000..7037e5db --- /dev/null +++ b/providers/aws/vpclattice/associations.go @@ -0,0 +1,293 @@ +package vpclattice + +import ( + "context" + + "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/internal/idgen" + "github.com/stackshy/cloudemu/v2/services/vpclattice/driver" +) + +const assocStatusActive = "ACTIVE" + +func assocNotFound(id string) error { + return errors.Newf(errors.NotFound, "association %q not found", id) +} + +// snRef holds the resolved identity of a service network for embedding in an +// association record. +type snRef struct { + id, arn, name string +} + +// resolveSN resolves a service-network identifier, erroring if unknown. Caller +// holds m.mu. +func (m *Mock) resolveSN(identifier string) (snRef, error) { + id := idFromIdentifier(identifier) + + sn, ok := m.serviceNetworks.Get(id) + if !ok { + return snRef{}, serviceNetworkNotFound(id) + } + + return snRef{id: sn.ID, arn: sn.ARN, name: sn.Name}, nil +} + +// ---- SN ↔ VPC ---- + +func cloneSNVpc(a *driver.SNVpcAssociation) driver.SNVpcAssociation { + out := *a + out.SecurityGroupIDs = append([]string(nil), a.SecurityGroupIDs...) + + return out +} + +func (m *Mock) CreateSNVpcAssociation( + _ context.Context, in *driver.CreateSNVpcAssociationInput, +) (*driver.SNVpcAssociation, error) { + m.mu.Lock() + defer m.mu.Unlock() + + sn, err := m.resolveSN(in.ServiceNetworkID) + if err != nil { + return nil, err + } + + id := idgen.GenerateID("snva-") + a := &driver.SNVpcAssociation{ + ID: id, + ARN: m.arn("servicenetworkvpcassociation/" + id), + CreatedBy: m.opts.AccountID, + ServiceNetworkID: sn.id, + ServiceNetworkARN: sn.arn, + ServiceNetworkName: sn.name, + VpcID: in.VpcID, + SecurityGroupIDs: append([]string(nil), in.SecurityGroupIDs...), + PrivateDNSEnabled: in.PrivateDNSEnabled, + Status: assocStatusActive, + CreatedAt: m.now(), + LastUpdatedAt: m.now(), + } + m.snVpcAssocs.Set(id, a) + + out := cloneSNVpc(a) + + return &out, nil +} + +func (m *Mock) GetSNVpcAssociation(_ context.Context, id string) (*driver.SNVpcAssociation, error) { + m.mu.Lock() + defer m.mu.Unlock() + + a, ok := m.snVpcAssocs.Get(idFromIdentifier(id)) + if !ok { + return nil, assocNotFound(idFromIdentifier(id)) + } + + out := cloneSNVpc(a) + + return &out, nil +} + +func (m *Mock) UpdateSNVpcAssociation( + _ context.Context, id string, securityGroupIDs []string, +) (*driver.SNVpcAssociation, error) { + m.mu.Lock() + defer m.mu.Unlock() + + a, ok := m.snVpcAssocs.Get(idFromIdentifier(id)) + if !ok { + return nil, assocNotFound(idFromIdentifier(id)) + } + + if securityGroupIDs != nil { + a.SecurityGroupIDs = append([]string(nil), securityGroupIDs...) + } + + a.LastUpdatedAt = m.now() + + out := cloneSNVpc(a) + + return &out, nil +} + +func (m *Mock) DeleteSNVpcAssociation(_ context.Context, id string) error { + m.mu.Lock() + defer m.mu.Unlock() + + rid := idFromIdentifier(id) + if !m.snVpcAssocs.Has(rid) { + return assocNotFound(rid) + } + + m.snVpcAssocs.Delete(rid) + + return nil +} + +func (m *Mock) ListSNVpcAssociations(_ context.Context) ([]driver.SNVpcAssociation, error) { + m.mu.Lock() + defer m.mu.Unlock() + + return sortedValues(m.snVpcAssocs.All(), cloneSNVpc), nil +} + +func (*Mock) ListSNVpcEndpointAssociations( + _ context.Context, _ string, +) ([]driver.SNVpcAssociation, error) { + // VPC-endpoint associations are a distinct managed surface not modeled here; + // return an empty set so the operation succeeds. + return []driver.SNVpcAssociation{}, nil +} + +// ---- SN ↔ Service ---- + +func cloneSNSvc(a *driver.SNServiceAssociation) driver.SNServiceAssociation { return *a } + +func (m *Mock) CreateSNServiceAssociation( + _ context.Context, serviceNetworkID, serviceID string, _ map[string]string, +) (*driver.SNServiceAssociation, error) { + m.mu.Lock() + defer m.mu.Unlock() + + sn, err := m.resolveSN(serviceNetworkID) + if err != nil { + return nil, err + } + + svc, ok := m.services.Get(idFromIdentifier(serviceID)) + if !ok { + return nil, serviceNotFound(idFromIdentifier(serviceID)) + } + + id := idgen.GenerateID("snsa-") + a := &driver.SNServiceAssociation{ + ID: id, + ARN: m.arn("servicenetworkserviceassociation/" + id), + CreatedBy: m.opts.AccountID, + CustomDomainName: svc.CustomDomainName, + DNSName: svc.DNSName, + HostedZoneID: svc.HostedZoneID, + ServiceID: svc.ID, + ServiceARN: svc.ARN, + ServiceName: svc.Name, + ServiceNetworkID: sn.id, + ServiceNetworkARN: sn.arn, + ServiceNetworkName: sn.name, + Status: assocStatusActive, + CreatedAt: m.now(), + } + m.snSvcAssocs.Set(id, a) + + out := cloneSNSvc(a) + + return &out, nil +} + +func (m *Mock) GetSNServiceAssociation(_ context.Context, id string) (*driver.SNServiceAssociation, error) { + m.mu.Lock() + defer m.mu.Unlock() + + a, ok := m.snSvcAssocs.Get(idFromIdentifier(id)) + if !ok { + return nil, assocNotFound(idFromIdentifier(id)) + } + + out := cloneSNSvc(a) + + return &out, nil +} + +func (m *Mock) DeleteSNServiceAssociation(_ context.Context, id string) error { + m.mu.Lock() + defer m.mu.Unlock() + + rid := idFromIdentifier(id) + if !m.snSvcAssocs.Has(rid) { + return assocNotFound(rid) + } + + m.snSvcAssocs.Delete(rid) + + return nil +} + +func (m *Mock) ListSNServiceAssociations(_ context.Context) ([]driver.SNServiceAssociation, error) { + m.mu.Lock() + defer m.mu.Unlock() + + return sortedValues(m.snSvcAssocs.All(), cloneSNSvc), nil +} + +// ---- SN ↔ Resource ---- + +func cloneSNRes(a *driver.SNResourceAssociation) driver.SNResourceAssociation { return *a } + +func (m *Mock) CreateSNResourceAssociation( + _ context.Context, serviceNetworkID, resourceConfigID string, privateDNS bool, _ map[string]string, +) (*driver.SNResourceAssociation, error) { + m.mu.Lock() + defer m.mu.Unlock() + + sn, err := m.resolveSN(serviceNetworkID) + if err != nil { + return nil, err + } + + rcID := idFromIdentifier(resourceConfigID) + id := idgen.GenerateID("snra-") + a := &driver.SNResourceAssociation{ + ID: id, + ARN: m.arn("servicenetworkresourceassociation/" + id), + CreatedBy: m.opts.AccountID, + ResourceConfigurationID: rcID, + ResourceConfigurationARN: m.arn("resourceconfiguration/" + rcID), + ServiceNetworkID: sn.id, + ServiceNetworkARN: sn.arn, + ServiceNetworkName: sn.name, + PrivateDNSEnabled: privateDNS, + Status: assocStatusActive, + CreatedAt: m.now(), + LastUpdatedAt: m.now(), + } + m.snResAssocs.Set(id, a) + + out := cloneSNRes(a) + + return &out, nil +} + +func (m *Mock) GetSNResourceAssociation(_ context.Context, id string) (*driver.SNResourceAssociation, error) { + m.mu.Lock() + defer m.mu.Unlock() + + a, ok := m.snResAssocs.Get(idFromIdentifier(id)) + if !ok { + return nil, assocNotFound(idFromIdentifier(id)) + } + + out := cloneSNRes(a) + + return &out, nil +} + +func (m *Mock) DeleteSNResourceAssociation(_ context.Context, id string) error { + m.mu.Lock() + defer m.mu.Unlock() + + rid := idFromIdentifier(id) + if !m.snResAssocs.Has(rid) { + return assocNotFound(rid) + } + + m.snResAssocs.Delete(rid) + + return nil +} + +func (m *Mock) ListSNResourceAssociations(_ context.Context) ([]driver.SNResourceAssociation, error) { + m.mu.Lock() + defer m.mu.Unlock() + + return sortedValues(m.snResAssocs.All(), cloneSNRes), nil +} diff --git a/providers/aws/vpclattice/domainverifications.go b/providers/aws/vpclattice/domainverifications.go new file mode 100644 index 00000000..09623658 --- /dev/null +++ b/providers/aws/vpclattice/domainverifications.go @@ -0,0 +1,76 @@ +package vpclattice + +import ( + "context" + + "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/internal/idgen" + "github.com/stackshy/cloudemu/v2/services/vpclattice/driver" +) + +const domainVerificationStatusPending = "PENDING" + +func domainVerificationNotFound(id string) error { + return errors.Newf(errors.NotFound, "domain verification %q not found", id) +} + +func cloneDomainVerification(d *driver.DomainVerification) driver.DomainVerification { return *d } + +func (m *Mock) StartDomainVerification( + _ context.Context, domainName string, _ map[string]string, +) (*driver.DomainVerification, error) { + m.mu.Lock() + defer m.mu.Unlock() + + id := idgen.GenerateID("dv-") + d := &driver.DomainVerification{ + ID: id, + ARN: m.arn("domainverification/" + id), + DomainName: domainName, + Status: domainVerificationStatusPending, + CreatedAt: m.now(), + } + m.domainVerifs.Set(id, d) + + out := cloneDomainVerification(d) + + return &out, nil +} + +func (m *Mock) GetDomainVerification(_ context.Context, identifier string) (*driver.DomainVerification, error) { + m.mu.Lock() + defer m.mu.Unlock() + + id := idFromIdentifier(identifier) + + d, ok := m.domainVerifs.Get(id) + if !ok { + return nil, domainVerificationNotFound(id) + } + + out := cloneDomainVerification(d) + + return &out, nil +} + +func (m *Mock) DeleteDomainVerification(_ context.Context, identifier string) error { + m.mu.Lock() + defer m.mu.Unlock() + + id := idFromIdentifier(identifier) + + if !m.domainVerifs.Has(id) { + return domainVerificationNotFound(id) + } + + m.domainVerifs.Delete(id) + + return nil +} + +func (m *Mock) ListDomainVerifications(_ context.Context) ([]driver.DomainVerification, error) { + m.mu.Lock() + defer m.mu.Unlock() + + return sortedValues(m.domainVerifs.All(), cloneDomainVerification), nil +} diff --git a/providers/aws/vpclattice/listeners.go b/providers/aws/vpclattice/listeners.go new file mode 100644 index 00000000..d67f1f5c --- /dev/null +++ b/providers/aws/vpclattice/listeners.go @@ -0,0 +1,145 @@ +package vpclattice + +import ( + "context" + + "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/internal/idgen" + "github.com/stackshy/cloudemu/v2/services/vpclattice/driver" +) + +func listenerNotFound(id string) error { + return errors.Newf(errors.NotFound, "listener %q not found", id) +} + +func cloneListener(l *driver.Listener) driver.Listener { + out := *l + out.DefaultAction = append([]byte(nil), l.DefaultAction...) + + return out +} + +// serviceARNFor resolves a service identifier to its ARN, erroring if unknown. +// Caller holds m.mu. +func (m *Mock) serviceARNFor(serviceIdentifier string) (string, error) { + sid := idFromIdentifier(serviceIdentifier) + + svc, ok := m.services.Get(sid) + if !ok { + return "", serviceNotFound(sid) + } + + return svc.ARN, nil +} + +func (m *Mock) CreateListener(_ context.Context, in *driver.CreateListenerInput) (*driver.Listener, error) { + m.mu.Lock() + defer m.mu.Unlock() + + serviceARN, err := m.serviceARNFor(in.ServiceID) + if err != nil { + return nil, err + } + + sid := idFromIdentifier(in.ServiceID) + id := idgen.GenerateID("listener-") + l := &driver.Listener{ + ID: id, + ARN: m.arn("service/" + sid + "/listener/" + id), + Name: in.Name, + ServiceID: sid, + ServiceARN: serviceARN, + Protocol: in.Protocol, + Port: in.Port, + DefaultAction: append([]byte(nil), in.DefaultAction...), + CreatedAt: m.now(), + LastUpdatedAt: m.now(), + } + m.listeners.Set(id, l) + + out := cloneListener(l) + + return &out, nil +} + +// getListenerLocked resolves a listener scoped to a service. Caller holds m.mu. +func (m *Mock) getListenerLocked(serviceIdentifier, listenerIdentifier string) (*driver.Listener, error) { + sid := idFromIdentifier(serviceIdentifier) + lid := idFromIdentifier(listenerIdentifier) + + l, ok := m.listeners.Get(lid) + if !ok || l.ServiceID != sid { + return nil, listenerNotFound(lid) + } + + return l, nil +} + +func (m *Mock) GetListener(_ context.Context, serviceID, listenerID string) (*driver.Listener, error) { + m.mu.Lock() + defer m.mu.Unlock() + + l, err := m.getListenerLocked(serviceID, listenerID) + if err != nil { + return nil, err + } + + out := cloneListener(l) + + return &out, nil +} + +func (m *Mock) UpdateListener( + _ context.Context, serviceID, listenerID string, defaultAction []byte, +) (*driver.Listener, error) { + m.mu.Lock() + defer m.mu.Unlock() + + l, err := m.getListenerLocked(serviceID, listenerID) + if err != nil { + return nil, err + } + + if len(defaultAction) > 0 { + l.DefaultAction = append([]byte(nil), defaultAction...) + } + + l.LastUpdatedAt = m.now() + + out := cloneListener(l) + + return &out, nil +} + +func (m *Mock) DeleteListener(_ context.Context, serviceID, listenerID string) error { + m.mu.Lock() + defer m.mu.Unlock() + + l, err := m.getListenerLocked(serviceID, listenerID) + if err != nil { + return err + } + + m.listeners.Delete(l.ID) + + return nil +} + +func (m *Mock) ListListeners(_ context.Context, serviceID string) ([]driver.Listener, error) { + m.mu.Lock() + defer m.mu.Unlock() + + sid := idFromIdentifier(serviceID) + + all := sortedValues(m.listeners.All(), cloneListener) + + out := make([]driver.Listener, 0, len(all)) + + for i := range all { + if all[i].ServiceID == sid { + out = append(out, all[i]) + } + } + + return out, nil +} diff --git a/providers/aws/vpclattice/policies.go b/providers/aws/vpclattice/policies.go new file mode 100644 index 00000000..0faa4761 --- /dev/null +++ b/providers/aws/vpclattice/policies.go @@ -0,0 +1,88 @@ +package vpclattice + +import ( + "context" + + "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/services/vpclattice/driver" +) + +const ( + authPolicyStateActive = "Active" +) + +func (m *Mock) PutAuthPolicy(_ context.Context, resourceID, policy string) (*driver.AuthPolicy, error) { + m.mu.Lock() + defer m.mu.Unlock() + + key := idFromIdentifier(resourceID) + + p := &driver.AuthPolicy{ + Policy: policy, + State: authPolicyStateActive, + CreatedAt: m.now(), + LastUpdatedAt: m.now(), + } + if existing, ok := m.authPolicies.Get(key); ok { + p.CreatedAt = existing.CreatedAt + } + + m.authPolicies.Set(key, p) + + out := *p + + return &out, nil +} + +func (m *Mock) GetAuthPolicy(_ context.Context, resourceID string) (*driver.AuthPolicy, error) { + m.mu.Lock() + defer m.mu.Unlock() + + p, ok := m.authPolicies.Get(idFromIdentifier(resourceID)) + if !ok { + return nil, errors.Newf(errors.NotFound, "auth policy for %q not found", resourceID) + } + + out := *p + + return &out, nil +} + +func (m *Mock) DeleteAuthPolicy(_ context.Context, resourceID string) error { + m.mu.Lock() + defer m.mu.Unlock() + + m.authPolicies.Delete(idFromIdentifier(resourceID)) + + return nil +} + +func (m *Mock) PutResourcePolicy(_ context.Context, resourceARN, policy string) error { + m.mu.Lock() + defer m.mu.Unlock() + + m.resourcePolics.Set(resourceARN, policy) + + return nil +} + +func (m *Mock) GetResourcePolicy(_ context.Context, resourceARN string) (string, error) { + m.mu.Lock() + defer m.mu.Unlock() + + policy, ok := m.resourcePolics.Get(resourceARN) + if !ok { + return "", errors.Newf(errors.NotFound, "resource policy for %q not found", resourceARN) + } + + return policy, nil +} + +func (m *Mock) DeleteResourcePolicy(_ context.Context, resourceARN string) error { + m.mu.Lock() + defer m.mu.Unlock() + + m.resourcePolics.Delete(resourceARN) + + return nil +} diff --git a/providers/aws/vpclattice/resourceconfigs.go b/providers/aws/vpclattice/resourceconfigs.go new file mode 100644 index 00000000..8843fa42 --- /dev/null +++ b/providers/aws/vpclattice/resourceconfigs.go @@ -0,0 +1,121 @@ +package vpclattice + +import ( + "context" + + "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/internal/idgen" + "github.com/stackshy/cloudemu/v2/services/vpclattice/driver" +) + +const resourceStatusActive = "ACTIVE" + +func resourceConfigNotFound(id string) error { + return errors.Newf(errors.NotFound, "resource configuration %q not found", id) +} + +func cloneResourceConfig(c *driver.ResourceConfiguration) driver.ResourceConfiguration { + out := *c + out.PortRanges = append([]string(nil), c.PortRanges...) + out.Definition = append([]byte(nil), c.Definition...) + + return out +} + +func (m *Mock) CreateResourceConfiguration( + _ context.Context, in *driver.CreateResourceConfigurationInput, +) (*driver.ResourceConfiguration, error) { + m.mu.Lock() + defer m.mu.Unlock() + + id := idgen.GenerateID("rcfg-") + c := &driver.ResourceConfiguration{ + ID: id, + ARN: m.arn("resourceconfiguration/" + id), + Name: in.Name, + Type: in.Type, + Status: resourceStatusActive, + Protocol: in.Protocol, + CustomDomainName: in.CustomDomainName, + GroupDomain: in.GroupDomain, + PortRanges: append([]string(nil), in.PortRanges...), + Definition: append([]byte(nil), in.Definition...), + ResourceGatewayID: idFromIdentifier(in.ResourceGatewayID), + ResourceConfigGroupID: idFromIdentifier(in.ResourceConfigGroupID), + AllowAssociationToShared: in.AllowAssociationToShared, + CreatedAt: m.now(), + LastUpdatedAt: m.now(), + } + m.resourceConfigs.Set(id, c) + + out := cloneResourceConfig(c) + + return &out, nil +} + +func (m *Mock) GetResourceConfiguration(_ context.Context, identifier string) (*driver.ResourceConfiguration, error) { + m.mu.Lock() + defer m.mu.Unlock() + + id := idFromIdentifier(identifier) + + c, ok := m.resourceConfigs.Get(id) + if !ok { + return nil, resourceConfigNotFound(id) + } + + out := cloneResourceConfig(c) + + return &out, nil +} + +func (m *Mock) UpdateResourceConfiguration( + _ context.Context, in *driver.UpdateResourceConfigurationInput, +) (*driver.ResourceConfiguration, error) { + m.mu.Lock() + defer m.mu.Unlock() + + id := idFromIdentifier(in.ID) + + c, ok := m.resourceConfigs.Get(id) + if !ok { + return nil, resourceConfigNotFound(id) + } + + if in.PortRanges != nil { + c.PortRanges = append([]string(nil), in.PortRanges...) + } + + if len(in.Definition) > 0 { + c.Definition = append([]byte(nil), in.Definition...) + } + + c.AllowAssociationToShared = in.AllowAssociationToShared + c.LastUpdatedAt = m.now() + + out := cloneResourceConfig(c) + + return &out, nil +} + +func (m *Mock) DeleteResourceConfiguration(_ context.Context, identifier string) error { + m.mu.Lock() + defer m.mu.Unlock() + + id := idFromIdentifier(identifier) + + if !m.resourceConfigs.Has(id) { + return resourceConfigNotFound(id) + } + + m.resourceConfigs.Delete(id) + + return nil +} + +func (m *Mock) ListResourceConfigurations(_ context.Context) ([]driver.ResourceConfiguration, error) { + m.mu.Lock() + defer m.mu.Unlock() + + return sortedValues(m.resourceConfigs.All(), cloneResourceConfig), nil +} diff --git a/providers/aws/vpclattice/resourcegateways.go b/providers/aws/vpclattice/resourcegateways.go new file mode 100644 index 00000000..14318cba --- /dev/null +++ b/providers/aws/vpclattice/resourcegateways.go @@ -0,0 +1,123 @@ +package vpclattice + +import ( + "context" + + "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/internal/idgen" + "github.com/stackshy/cloudemu/v2/services/vpclattice/driver" +) + +func resourceGatewayNotFound(id string) error { + return errors.Newf(errors.NotFound, "resource gateway %q not found", id) +} + +func cloneResourceGateway(g *driver.ResourceGateway) driver.ResourceGateway { + out := *g + out.SecurityGroupIDs = append([]string(nil), g.SecurityGroupIDs...) + out.SubnetIDs = append([]string(nil), g.SubnetIDs...) + + return out +} + +func (m *Mock) CreateResourceGateway( + _ context.Context, in *driver.CreateResourceGatewayInput, +) (*driver.ResourceGateway, error) { + m.mu.Lock() + defer m.mu.Unlock() + + id := idgen.GenerateID("rgw-") + g := &driver.ResourceGateway{ + ID: id, + ARN: m.arn("resourcegateway/" + id), + Name: in.Name, + Status: resourceStatusActive, + IPAddressType: in.IPAddressType, + Ipv4AddressesPerEni: in.Ipv4AddressesPerEni, + ResourceConfigDNSResolution: in.ResourceConfigDNSResolution, + SecurityGroupIDs: append([]string(nil), in.SecurityGroupIDs...), + SubnetIDs: append([]string(nil), in.SubnetIDs...), + VpcID: idFromIdentifier(in.VpcID), + CreatedAt: m.now(), + LastUpdatedAt: m.now(), + } + m.resourceGws.Set(id, g) + + out := cloneResourceGateway(g) + + return &out, nil +} + +func (m *Mock) GetResourceGateway(_ context.Context, identifier string) (*driver.ResourceGateway, error) { + m.mu.Lock() + defer m.mu.Unlock() + + id := idFromIdentifier(identifier) + + g, ok := m.resourceGws.Get(id) + if !ok { + return nil, resourceGatewayNotFound(id) + } + + out := cloneResourceGateway(g) + + return &out, nil +} + +func (m *Mock) UpdateResourceGateway( + _ context.Context, identifier string, securityGroupIDs []string, +) (*driver.ResourceGateway, error) { + m.mu.Lock() + defer m.mu.Unlock() + + id := idFromIdentifier(identifier) + + g, ok := m.resourceGws.Get(id) + if !ok { + return nil, resourceGatewayNotFound(id) + } + + if securityGroupIDs != nil { + g.SecurityGroupIDs = append([]string(nil), securityGroupIDs...) + } + + g.LastUpdatedAt = m.now() + + out := cloneResourceGateway(g) + + return &out, nil +} + +func (m *Mock) DeleteResourceGateway(_ context.Context, identifier string) error { + m.mu.Lock() + defer m.mu.Unlock() + + id := idFromIdentifier(identifier) + + if !m.resourceGws.Has(id) { + return resourceGatewayNotFound(id) + } + + m.resourceGws.Delete(id) + + return nil +} + +func (m *Mock) ListResourceGateways(_ context.Context) ([]driver.ResourceGateway, error) { + m.mu.Lock() + defer m.mu.Unlock() + + return sortedValues(m.resourceGws.All(), cloneResourceGateway), nil +} + +// ---- Resource Endpoint Associations (not modeled; empty/no-op) ---- + +func (*Mock) ListResourceEndpointAssociations( + _ context.Context, +) ([]driver.ResourceEndpointAssociation, error) { + return []driver.ResourceEndpointAssociation{}, nil +} + +func (*Mock) DeleteResourceEndpointAssociation(_ context.Context, id string) error { + return errors.Newf(errors.NotFound, "resource endpoint association %q not found", id) +} diff --git a/providers/aws/vpclattice/rules.go b/providers/aws/vpclattice/rules.go new file mode 100644 index 00000000..73cd042c --- /dev/null +++ b/providers/aws/vpclattice/rules.go @@ -0,0 +1,176 @@ +package vpclattice + +import ( + "context" + + "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/internal/idgen" + "github.com/stackshy/cloudemu/v2/services/vpclattice/driver" +) + +func ruleNotFound(id string) error { + return errors.Newf(errors.NotFound, "rule %q not found", id) +} + +func cloneRule(r *driver.Rule) driver.Rule { + out := *r + out.Match = append([]byte(nil), r.Match...) + out.Action = append([]byte(nil), r.Action...) + + return out +} + +func (m *Mock) CreateRule(_ context.Context, in *driver.CreateRuleInput) (*driver.Rule, error) { + m.mu.Lock() + defer m.mu.Unlock() + + l, err := m.getListenerLocked(in.ServiceID, in.ListenerID) + if err != nil { + return nil, err + } + + id := idgen.GenerateID("rule-") + rule := &driver.Rule{ + ID: id, + ARN: m.arn("service/" + l.ServiceID + "/listener/" + l.ID + "/rule/" + id), + Name: in.Name, + ServiceID: l.ServiceID, + ListenerID: l.ID, + Priority: in.Priority, + Match: append([]byte(nil), in.Match...), + Action: append([]byte(nil), in.Action...), + CreatedAt: m.now(), + LastUpdatedAt: m.now(), + } + m.rules.Set(id, rule) + + out := cloneRule(rule) + + return &out, nil +} + +// getRuleLocked resolves a rule scoped to a listener+service. Caller holds m.mu. +func (m *Mock) getRuleLocked(serviceID, listenerID, ruleID string) (*driver.Rule, error) { + sid := idFromIdentifier(serviceID) + lid := idFromIdentifier(listenerID) + rid := idFromIdentifier(ruleID) + + rule, ok := m.rules.Get(rid) + if !ok || rule.ServiceID != sid || rule.ListenerID != lid { + return nil, ruleNotFound(rid) + } + + return rule, nil +} + +func (m *Mock) GetRule(_ context.Context, serviceID, listenerID, ruleID string) (*driver.Rule, error) { + m.mu.Lock() + defer m.mu.Unlock() + + rule, err := m.getRuleLocked(serviceID, listenerID, ruleID) + if err != nil { + return nil, err + } + + out := cloneRule(rule) + + return &out, nil +} + +// applyRuleUpdate mutates a stored rule with non-zero update fields. Caller +// holds m.mu. +func (m *Mock) applyRuleUpdate(rule *driver.Rule, priority int32, match, action []byte) { + if priority != 0 { + rule.Priority = priority + } + + if len(match) > 0 { + rule.Match = append([]byte(nil), match...) + } + + if len(action) > 0 { + rule.Action = append([]byte(nil), action...) + } + + rule.LastUpdatedAt = m.now() +} + +func (m *Mock) UpdateRule( + _ context.Context, serviceID, listenerID, ruleID string, priority int32, match, action []byte, +) (*driver.Rule, error) { + m.mu.Lock() + defer m.mu.Unlock() + + rule, err := m.getRuleLocked(serviceID, listenerID, ruleID) + if err != nil { + return nil, err + } + + m.applyRuleUpdate(rule, priority, match, action) + + out := cloneRule(rule) + + return &out, nil +} + +func (m *Mock) DeleteRule(_ context.Context, serviceID, listenerID, ruleID string) error { + m.mu.Lock() + defer m.mu.Unlock() + + rule, err := m.getRuleLocked(serviceID, listenerID, ruleID) + if err != nil { + return err + } + + m.rules.Delete(rule.ID) + + return nil +} + +func (m *Mock) ListRules(_ context.Context, serviceID, listenerID string) ([]driver.Rule, error) { + m.mu.Lock() + defer m.mu.Unlock() + + sid := idFromIdentifier(serviceID) + lid := idFromIdentifier(listenerID) + + all := sortedValues(m.rules.All(), cloneRule) + + out := make([]driver.Rule, 0, len(all)) + + for i := range all { + if all[i].ServiceID == sid && all[i].ListenerID == lid { + out = append(out, all[i]) + } + } + + return out, nil +} + +func (m *Mock) BatchUpdateRules( + _ context.Context, serviceID, listenerID string, updates []driver.RuleUpdate, +) ([]driver.Rule, []driver.RuleUpdateFailure, error) { + m.mu.Lock() + defer m.mu.Unlock() + + ok := make([]driver.Rule, 0, len(updates)) + fail := make([]driver.RuleUpdateFailure, 0) + + for i := range updates { + u := updates[i] + + rule, err := m.getRuleLocked(serviceID, listenerID, u.RuleID) + if err != nil { + fail = append(fail, driver.RuleUpdateFailure{ + RuleID: u.RuleID, FailureCode: "ResourceNotFoundException", FailureMessage: err.Error(), + }) + + continue + } + + m.applyRuleUpdate(rule, u.Priority, u.Match, u.Action) + ok = append(ok, cloneRule(rule)) + } + + return ok, fail, nil +} diff --git a/providers/aws/vpclattice/servicenetworks.go b/providers/aws/vpclattice/servicenetworks.go new file mode 100644 index 00000000..be197d9e --- /dev/null +++ b/providers/aws/vpclattice/servicenetworks.go @@ -0,0 +1,139 @@ +package vpclattice + +import ( + "context" + + "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/internal/idgen" + "github.com/stackshy/cloudemu/v2/services/vpclattice/driver" +) + +func serviceNetworkNotFound(id string) error { + return errors.Newf(errors.NotFound, "service network %q not found", id) +} + +func cloneServiceNetwork(s *driver.ServiceNetwork) driver.ServiceNetwork { return *s } + +// applyAssocCounts fills the association counters for a service network from +// the association stores. Caller holds m.mu. +func (m *Mock) applyAssocCounts(s *driver.ServiceNetwork) { + var svcs, vpcs, res int64 + + for _, a := range m.snSvcAssocs.All() { + if a.ServiceNetworkID == s.ID { + svcs++ + } + } + + for _, a := range m.snVpcAssocs.All() { + if a.ServiceNetworkID == s.ID { + vpcs++ + } + } + + for _, a := range m.snResAssocs.All() { + if a.ServiceNetworkID == s.ID { + res++ + } + } + + s.NumberOfAssociatedServices = svcs + s.NumberOfAssociatedVPCs = vpcs + s.NumberOfAssociatedResourceConfigurations = res +} + +func (m *Mock) CreateServiceNetwork( + _ context.Context, in *driver.CreateServiceNetworkInput, +) (*driver.ServiceNetwork, error) { + m.mu.Lock() + defer m.mu.Unlock() + + authType := in.AuthType + if authType == "" { + authType = authTypeNone + } + + id := idgen.GenerateID("sn-") + sn := &driver.ServiceNetwork{ + ID: id, + ARN: m.arn("servicenetwork/" + id), + Name: in.Name, + AuthType: authType, + SharingConfigEnabled: in.SharingConfigEnabled, + CreatedAt: m.now(), + LastUpdatedAt: m.now(), + } + m.serviceNetworks.Set(id, sn) + + out := cloneServiceNetwork(sn) + + return &out, nil +} + +func (m *Mock) GetServiceNetwork(_ context.Context, identifier string) (*driver.ServiceNetwork, error) { + m.mu.Lock() + defer m.mu.Unlock() + + id := idFromIdentifier(identifier) + + sn, ok := m.serviceNetworks.Get(id) + if !ok { + return nil, serviceNetworkNotFound(id) + } + + out := cloneServiceNetwork(sn) + m.applyAssocCounts(&out) + + return &out, nil +} + +func (m *Mock) UpdateServiceNetwork( + _ context.Context, identifier, authType string, +) (*driver.ServiceNetwork, error) { + m.mu.Lock() + defer m.mu.Unlock() + + id := idFromIdentifier(identifier) + + sn, ok := m.serviceNetworks.Get(id) + if !ok { + return nil, serviceNetworkNotFound(id) + } + + if authType != "" { + sn.AuthType = authType + } + + sn.LastUpdatedAt = m.now() + + out := cloneServiceNetwork(sn) + + return &out, nil +} + +func (m *Mock) DeleteServiceNetwork(_ context.Context, identifier string) error { + m.mu.Lock() + defer m.mu.Unlock() + + id := idFromIdentifier(identifier) + + if !m.serviceNetworks.Has(id) { + return serviceNetworkNotFound(id) + } + + m.serviceNetworks.Delete(id) + + return nil +} + +func (m *Mock) ListServiceNetworks(_ context.Context) ([]driver.ServiceNetwork, error) { + m.mu.Lock() + defer m.mu.Unlock() + + out := sortedValues(m.serviceNetworks.All(), cloneServiceNetwork) + for i := range out { + m.applyAssocCounts(&out[i]) + } + + return out, nil +} diff --git a/providers/aws/vpclattice/services.go b/providers/aws/vpclattice/services.go new file mode 100644 index 00000000..245418e5 --- /dev/null +++ b/providers/aws/vpclattice/services.go @@ -0,0 +1,125 @@ +package vpclattice + +import ( + "context" + + "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/internal/idgen" + "github.com/stackshy/cloudemu/v2/services/vpclattice/driver" +) + +const ( + serviceStatusActive = "ACTIVE" + serviceHostedZone = "Z1VPCLATTICE" + defaultIdleTimeoutSec = 60 +) + +func serviceNotFound(id string) error { + return errors.Newf(errors.NotFound, "service %q not found", id) +} + +func cloneService(s *driver.Service) driver.Service { return *s } + +func (m *Mock) CreateService(_ context.Context, in *driver.CreateServiceInput) (*driver.Service, error) { + m.mu.Lock() + defer m.mu.Unlock() + + authType := in.AuthType + if authType == "" { + authType = authTypeNone + } + + idle := in.IdleTimeoutSeconds + if idle == 0 { + idle = defaultIdleTimeoutSec + } + + id := idgen.GenerateID("svc-") + svc := &driver.Service{ + ID: id, + ARN: m.arn("service/" + id), + Name: in.Name, + AuthType: authType, + CertificateARN: in.CertificateARN, + CustomDomainName: in.CustomDomainName, + DNSName: id + ".vpc-lattice-svcs." + m.opts.Region + ".on.aws", + HostedZoneID: serviceHostedZone, + IdleTimeoutSeconds: idle, + Status: serviceStatusActive, + CreatedAt: m.now(), + LastUpdatedAt: m.now(), + } + m.services.Set(id, svc) + + out := cloneService(svc) + + return &out, nil +} + +func (m *Mock) GetService(_ context.Context, identifier string) (*driver.Service, error) { + m.mu.Lock() + defer m.mu.Unlock() + + id := idFromIdentifier(identifier) + + svc, ok := m.services.Get(id) + if !ok { + return nil, serviceNotFound(id) + } + + out := cloneService(svc) + + return &out, nil +} + +func (m *Mock) UpdateService(_ context.Context, in *driver.UpdateServiceInput) (*driver.Service, error) { + m.mu.Lock() + defer m.mu.Unlock() + + id := idFromIdentifier(in.ID) + + svc, ok := m.services.Get(id) + if !ok { + return nil, serviceNotFound(id) + } + + if in.AuthType != "" { + svc.AuthType = in.AuthType + } + + if in.CertificateARN != "" { + svc.CertificateARN = in.CertificateARN + } + + if in.IdleTimeoutSeconds != 0 { + svc.IdleTimeoutSeconds = in.IdleTimeoutSeconds + } + + svc.LastUpdatedAt = m.now() + + out := cloneService(svc) + + return &out, nil +} + +func (m *Mock) DeleteService(_ context.Context, identifier string) error { + m.mu.Lock() + defer m.mu.Unlock() + + id := idFromIdentifier(identifier) + + if !m.services.Has(id) { + return serviceNotFound(id) + } + + m.services.Delete(id) + + return nil +} + +func (m *Mock) ListServices(_ context.Context) ([]driver.Service, error) { + m.mu.Lock() + defer m.mu.Unlock() + + return sortedValues(m.services.All(), cloneService), nil +} diff --git a/providers/aws/vpclattice/tagging.go b/providers/aws/vpclattice/tagging.go new file mode 100644 index 00000000..5819520d --- /dev/null +++ b/providers/aws/vpclattice/tagging.go @@ -0,0 +1,63 @@ +package vpclattice + +import ( + "context" + + "github.com/stackshy/cloudemu/v2/services/vpclattice/driver" +) + +func (m *Mock) TagResource(_ context.Context, arn string, tags map[string]string) error { + m.mu.Lock() + defer m.mu.Unlock() + + cur, ok := m.tags.Get(arn) + if !ok { + cur = map[string]string{} + } + + for k, v := range tags { + cur[k] = v + } + + m.tags.Set(arn, cur) + + return nil +} + +func (m *Mock) UntagResource(_ context.Context, arn string, keys []string) error { + m.mu.Lock() + defer m.mu.Unlock() + + cur, ok := m.tags.Get(arn) + if !ok { + return nil + } + + for _, k := range keys { + delete(cur, k) + } + + m.tags.Set(arn, cur) + + return nil +} + +func (m *Mock) ListTagsForResource(_ context.Context, arn string) (map[string]string, error) { + m.mu.Lock() + defer m.mu.Unlock() + + cur, ok := m.tags.Get(arn) + if !ok { + return map[string]string{}, nil + } + + out := make(map[string]string, len(cur)) + for k, v := range cur { + out[k] = v + } + + return out, nil +} + +// compile-time assertion the tagging surface satisfies the driver contract. +var _ driver.Tagging = (*Mock)(nil) diff --git a/providers/aws/vpclattice/targetgroups.go b/providers/aws/vpclattice/targetgroups.go new file mode 100644 index 00000000..671cc4e0 --- /dev/null +++ b/providers/aws/vpclattice/targetgroups.go @@ -0,0 +1,146 @@ +package vpclattice + +import ( + "context" + "encoding/json" + + "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/internal/idgen" + "github.com/stackshy/cloudemu/v2/services/vpclattice/driver" +) + +const targetGroupStatusActive = "ACTIVE" + +func targetGroupNotFound(id string) error { + return errors.Newf(errors.NotFound, "target group %q not found", id) +} + +func cloneTargetGroup(t *driver.TargetGroup) driver.TargetGroup { + out := *t + out.Config = append([]byte(nil), t.Config...) + out.ServiceARNs = append([]string(nil), t.ServiceARNs...) + + return out +} + +// tgConfigExtract pulls the summary-relevant fields out of a raw config blob. +type tgConfigExtract struct { + Port int32 `json:"port"` + Protocol string `json:"protocol"` + VpcIdentifier string `json:"vpcIdentifier"` + IPAddressType string `json:"ipAddressType"` + LambdaEventStructureVersion string `json:"lambdaEventStructureVersion"` +} + +func (m *Mock) CreateTargetGroup(_ context.Context, in *driver.CreateTargetGroupInput) (*driver.TargetGroup, error) { + m.mu.Lock() + defer m.mu.Unlock() + + var cfg tgConfigExtract + if len(in.Config) > 0 { + _ = json.Unmarshal(in.Config, &cfg) + } + + id := idgen.GenerateID("tg-") + tg := &driver.TargetGroup{ + ID: id, + ARN: m.arn("targetgroup/" + id), + Name: in.Name, + Type: in.Type, + Status: targetGroupStatusActive, + Config: append([]byte(nil), in.Config...), + Port: cfg.Port, + Protocol: cfg.Protocol, + VpcID: cfg.VpcIdentifier, + IPAddressType: cfg.IPAddressType, + LambdaEventStructureVersion: cfg.LambdaEventStructureVersion, + CreatedAt: m.now(), + LastUpdatedAt: m.now(), + } + m.targetGroups.Set(id, tg) + m.targets.Set(id, nil) + + out := cloneTargetGroup(tg) + + return &out, nil +} + +func (m *Mock) GetTargetGroup(_ context.Context, identifier string) (*driver.TargetGroup, error) { + m.mu.Lock() + defer m.mu.Unlock() + + id := idFromIdentifier(identifier) + + tg, ok := m.targetGroups.Get(id) + if !ok { + return nil, targetGroupNotFound(id) + } + + out := cloneTargetGroup(tg) + + return &out, nil +} + +func (m *Mock) UpdateTargetGroup( + _ context.Context, identifier string, healthCheck []byte, +) (*driver.TargetGroup, error) { + m.mu.Lock() + defer m.mu.Unlock() + + id := idFromIdentifier(identifier) + + tg, ok := m.targetGroups.Get(id) + if !ok { + return nil, targetGroupNotFound(id) + } + + if len(healthCheck) > 0 { + tg.Config = mergeHealthCheck(tg.Config, healthCheck) + } + + tg.LastUpdatedAt = m.now() + + out := cloneTargetGroup(tg) + + return &out, nil +} + +// mergeHealthCheck sets the healthCheck member on a raw config blob. +func mergeHealthCheck(config, healthCheck []byte) []byte { + obj := map[string]json.RawMessage{} + if len(config) > 0 { + _ = json.Unmarshal(config, &obj) + } + + obj["healthCheck"] = json.RawMessage(healthCheck) + + merged, err := json.Marshal(obj) + if err != nil { + return config + } + + return merged +} + +func (m *Mock) DeleteTargetGroup(_ context.Context, identifier string) error { + m.mu.Lock() + defer m.mu.Unlock() + + id := idFromIdentifier(identifier) + + if !m.targetGroups.Has(id) { + return targetGroupNotFound(id) + } + + m.targetGroups.Delete(id) + m.targets.Delete(id) + + return nil +} + +func (m *Mock) ListTargetGroups(_ context.Context) ([]driver.TargetGroup, error) { + m.mu.Lock() + defer m.mu.Unlock() + + return sortedValues(m.targetGroups.All(), cloneTargetGroup), nil +} diff --git a/providers/aws/vpclattice/targets.go b/providers/aws/vpclattice/targets.go new file mode 100644 index 00000000..e4cb7643 --- /dev/null +++ b/providers/aws/vpclattice/targets.go @@ -0,0 +1,111 @@ +package vpclattice + +import ( + "context" + "strconv" + + "github.com/stackshy/cloudemu/v2/services/vpclattice/driver" +) + +const ( + targetStatusHealthy = "HEALTHY" + targetStatusDraining = "DRAINING" +) + +// targetKey uniquely identifies a target within a group by id+port. +func targetKey(t driver.RegisteredTarget) string { + return t.ID + "|" + strconv.Itoa(int(t.Port)) +} + +func (m *Mock) RegisterTargets( + _ context.Context, tgID string, in []driver.RegisteredTarget, +) ([]driver.RegisteredTarget, []driver.TargetFailure, error) { + m.mu.Lock() + defer m.mu.Unlock() + + id := idFromIdentifier(tgID) + + if !m.targetGroups.Has(id) { + return nil, nil, targetGroupNotFound(id) + } + + cur, _ := m.targets.Get(id) + index := make(map[string]int, len(cur)) + + for i := range cur { + index[targetKey(cur[i])] = i + } + + ok := make([]driver.RegisteredTarget, 0, len(in)) + + for _, t := range in { + t.Status = targetStatusHealthy + + if pos, exists := index[targetKey(t)]; exists { + cur[pos] = t + } else { + index[targetKey(t)] = len(cur) + cur = append(cur, t) + } + + ok = append(ok, t) + } + + m.targets.Set(id, cur) + + return ok, nil, nil +} + +func (m *Mock) DeregisterTargets( + _ context.Context, tgID string, in []driver.RegisteredTarget, +) ([]driver.RegisteredTarget, []driver.TargetFailure, error) { + m.mu.Lock() + defer m.mu.Unlock() + + id := idFromIdentifier(tgID) + + if !m.targetGroups.Has(id) { + return nil, nil, targetGroupNotFound(id) + } + + drop := make(map[string]struct{}, len(in)) + for _, t := range in { + drop[targetKey(t)] = struct{}{} + } + + cur, _ := m.targets.Get(id) + + kept := make([]driver.RegisteredTarget, 0, len(cur)) + + for i := range cur { + if _, gone := drop[targetKey(cur[i])]; !gone { + kept = append(kept, cur[i]) + } + } + + m.targets.Set(id, kept) + + ok := make([]driver.RegisteredTarget, 0, len(in)) + + for _, t := range in { + t.Status = targetStatusDraining + ok = append(ok, t) + } + + return ok, nil, nil +} + +func (m *Mock) ListTargets(_ context.Context, tgID string) ([]driver.RegisteredTarget, error) { + m.mu.Lock() + defer m.mu.Unlock() + + id := idFromIdentifier(tgID) + + if !m.targetGroups.Has(id) { + return nil, targetGroupNotFound(id) + } + + cur, _ := m.targets.Get(id) + + return append([]driver.RegisteredTarget(nil), cur...), nil +} diff --git a/providers/aws/vpclattice/vpclattice.go b/providers/aws/vpclattice/vpclattice.go new file mode 100644 index 00000000..5dee4cec --- /dev/null +++ b/providers/aws/vpclattice/vpclattice.go @@ -0,0 +1,106 @@ +// Package vpclattice provides an in-memory mock of the AWS VPC Lattice control +// plane. It satisfies services/vpclattice/driver so the real +// aws-sdk-go-v2/service/vpclattice client works against it via the AWS server +// (REST-JSON, path + method routing). +package vpclattice + +import ( + "sort" + "strings" + "sync" + "time" + + "github.com/stackshy/cloudemu/v2/config" + "github.com/stackshy/cloudemu/v2/internal/idgen" + "github.com/stackshy/cloudemu/v2/internal/memstore" + "github.com/stackshy/cloudemu/v2/services/vpclattice/driver" +) + +// Compile-time check that Mock implements the driver contract. +var _ driver.VPCLattice = (*Mock)(nil) + +const authTypeNone = "NONE" + +// Mock is an in-memory mock of AWS VPC Lattice. +type Mock struct { + serviceNetworks *memstore.Store[*driver.ServiceNetwork] // keyed by service-network ID + services *memstore.Store[*driver.Service] // keyed by service ID + listeners *memstore.Store[*driver.Listener] // keyed by listener ID + rules *memstore.Store[*driver.Rule] // keyed by rule ID + targetGroups *memstore.Store[*driver.TargetGroup] // keyed by target-group ID + targets *memstore.Store[[]driver.RegisteredTarget] // keyed by target-group ID + snVpcAssocs *memstore.Store[*driver.SNVpcAssociation] // keyed by association ID + snSvcAssocs *memstore.Store[*driver.SNServiceAssociation] + snResAssocs *memstore.Store[*driver.SNResourceAssociation] + resourceConfigs *memstore.Store[*driver.ResourceConfiguration] + resourceGws *memstore.Store[*driver.ResourceGateway] + accessLogSubs *memstore.Store[*driver.AccessLogSubscription] + authPolicies *memstore.Store[*driver.AuthPolicy] // keyed by resource identifier + resourcePolics *memstore.Store[string] // keyed by resource ARN + domainVerifs *memstore.Store[*driver.DomainVerification] // keyed by verification ID + tags *memstore.Store[map[string]string] // keyed by resource ARN + opts *config.Options + mu sync.Mutex // serializes read-modify-write on stored records +} + +// New returns a VPC Lattice mock. +func New(opts *config.Options) *Mock { + return &Mock{ + serviceNetworks: memstore.New[*driver.ServiceNetwork](), + services: memstore.New[*driver.Service](), + listeners: memstore.New[*driver.Listener](), + rules: memstore.New[*driver.Rule](), + targetGroups: memstore.New[*driver.TargetGroup](), + targets: memstore.New[[]driver.RegisteredTarget](), + snVpcAssocs: memstore.New[*driver.SNVpcAssociation](), + snSvcAssocs: memstore.New[*driver.SNServiceAssociation](), + snResAssocs: memstore.New[*driver.SNResourceAssociation](), + resourceConfigs: memstore.New[*driver.ResourceConfiguration](), + resourceGws: memstore.New[*driver.ResourceGateway](), + accessLogSubs: memstore.New[*driver.AccessLogSubscription](), + authPolicies: memstore.New[*driver.AuthPolicy](), + resourcePolics: memstore.New[string](), + domainVerifs: memstore.New[*driver.DomainVerification](), + tags: memstore.New[map[string]string](), + opts: opts, + } +} + +// now returns the current time (via the injectable clock) in RFC 3339 — the +// wire format the VPC Lattice REST-JSON deserializers parse for timestamps. +func (m *Mock) now() string { + return m.opts.Clock.Now().UTC().Format(time.RFC3339) +} + +// arn builds a vpc-lattice ARN for the given resource path. +func (m *Mock) arn(resource string) string { + return idgen.AWSARN("vpc-lattice", m.opts.Region, m.opts.AccountID, resource) +} + +// idFromIdentifier accepts either a bare ID or a full ARN and returns the ID +// (the segment after the last "/"). VPC Lattice APIs accept both forms. +func idFromIdentifier(identifier string) string { + if i := strings.LastIndex(identifier, "/"); i >= 0 { + return identifier[i+1:] + } + + return identifier +} + +// sortedValues returns a store's values sorted by key, each deep-copied via +// clone — the shared List implementation for every resource group. +func sortedValues[T any](all map[string]*T, clone func(*T) T) []T { + ids := make([]string, 0, len(all)) + for id := range all { + ids = append(ids, id) + } + + sort.Strings(ids) + + out := make([]T, 0, len(all)) + for _, id := range ids { + out = append(out, clone(all[id])) + } + + return out +} diff --git a/providers/aws/vpclattice/vpclattice_test.go b/providers/aws/vpclattice/vpclattice_test.go new file mode 100644 index 00000000..99e35d1f --- /dev/null +++ b/providers/aws/vpclattice/vpclattice_test.go @@ -0,0 +1,346 @@ +package vpclattice + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/stackshy/cloudemu/v2/config" + cerrors "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/services/vpclattice/driver" +) + +func newTestMock() *Mock { + fc := config.NewFakeClock(time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)) + opts := config.NewOptions(config.WithClock(fc), config.WithRegion("us-east-1")) + + return New(opts) +} + +func TestServiceNetworkCRUDAndIdentifierResolution(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + sn, err := m.CreateServiceNetwork(ctx, &driver.CreateServiceNetworkInput{Name: "sn"}) + require.NoError(t, err) + assert.Contains(t, sn.ID, "sn-") + assert.Equal(t, authTypeNone, sn.AuthType) + + // Get by ARN (not just ID) resolves via idFromIdentifier. + got, err := m.GetServiceNetwork(ctx, sn.ARN) + require.NoError(t, err) + assert.Equal(t, sn.ID, got.ID) + + _, err = m.GetServiceNetwork(ctx, "sn-missing") + assert.True(t, cerrors.IsNotFound(err)) + assert.Error(t, m.DeleteServiceNetwork(ctx, "sn-missing")) + _, err = m.UpdateServiceNetwork(ctx, "sn-missing", "AWS_IAM") + assert.True(t, cerrors.IsNotFound(err)) +} + +func TestServiceNetworkAssocCounts(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + sn, _ := m.CreateServiceNetwork(ctx, &driver.CreateServiceNetworkInput{Name: "sn"}) + svc, _ := m.CreateService(ctx, &driver.CreateServiceInput{Name: "svc"}) + + _, err := m.CreateSNVpcAssociation(ctx, &driver.CreateSNVpcAssociationInput{ServiceNetworkID: sn.ID, VpcID: "vpc-1"}) + require.NoError(t, err) + _, err = m.CreateSNServiceAssociation(ctx, sn.ID, svc.ID, nil) + require.NoError(t, err) + _, err = m.CreateSNResourceAssociation(ctx, sn.ID, "rcfg-1", true, nil) + require.NoError(t, err) + + got, err := m.GetServiceNetwork(ctx, sn.ID) + require.NoError(t, err) + assert.Equal(t, int64(1), got.NumberOfAssociatedVPCs) + assert.Equal(t, int64(1), got.NumberOfAssociatedServices) + assert.Equal(t, int64(1), got.NumberOfAssociatedResourceConfigurations) + + // Association against a missing network fails. + _, err = m.CreateSNVpcAssociation(ctx, &driver.CreateSNVpcAssociationInput{ServiceNetworkID: "sn-x", VpcID: "v"}) + assert.True(t, cerrors.IsNotFound(err)) +} + +func TestServiceDefaultsAndUpdate(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + svc, err := m.CreateService(ctx, &driver.CreateServiceInput{Name: "svc"}) + require.NoError(t, err) + assert.Equal(t, int32(defaultIdleTimeoutSec), svc.IdleTimeoutSeconds) + assert.Equal(t, serviceStatusActive, svc.Status) + assert.Contains(t, svc.DNSName, "vpc-lattice-svcs") + + upd, err := m.UpdateService(ctx, &driver.UpdateServiceInput{ID: svc.ID, IdleTimeoutSeconds: 120}) + require.NoError(t, err) + assert.Equal(t, int32(120), upd.IdleTimeoutSeconds) + + _, err = m.UpdateService(ctx, &driver.UpdateServiceInput{ID: "svc-missing"}) + assert.True(t, cerrors.IsNotFound(err)) +} + +func TestListenerScopingAndCloneIsolation(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + svc, _ := m.CreateService(ctx, &driver.CreateServiceInput{Name: "svc"}) + + // Create against a missing service fails. + _, err := m.CreateListener(ctx, &driver.CreateListenerInput{ServiceID: "svc-x"}) + assert.True(t, cerrors.IsNotFound(err)) + + l, err := m.CreateListener(ctx, &driver.CreateListenerInput{ + ServiceID: svc.ID, Name: "http", Protocol: "HTTP", Port: 80, + DefaultAction: []byte(`{"fixedResponse":{"statusCode":404}}`), + }) + require.NoError(t, err) + + // A listener is not reachable via a different service ID. + other, _ := m.CreateService(ctx, &driver.CreateServiceInput{Name: "svc2"}) + _, err = m.GetListener(ctx, other.ID, l.ID) + assert.True(t, cerrors.IsNotFound(err)) + + // Mutating a returned copy's raw action must not corrupt the store. + got, _ := m.GetListener(ctx, svc.ID, l.ID) + got.DefaultAction[0] = 'X' + reread, _ := m.GetListener(ctx, svc.ID, l.ID) + assert.Equal(t, byte('{'), reread.DefaultAction[0]) +} + +func TestRuleBatchUpdatePartial(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + svc, _ := m.CreateService(ctx, &driver.CreateServiceInput{Name: "svc"}) + l, _ := m.CreateListener(ctx, &driver.CreateListenerInput{ServiceID: svc.ID, Protocol: "HTTP", Port: 80}) + rule, err := m.CreateRule(ctx, &driver.CreateRuleInput{ServiceID: svc.ID, ListenerID: l.ID, Name: "r", Priority: 10}) + require.NoError(t, err) + + ok, fail, err := m.BatchUpdateRules(ctx, svc.ID, l.ID, []driver.RuleUpdate{ + {RuleID: rule.ID, Priority: 20}, + {RuleID: "rule-missing", Priority: 30}, + }) + require.NoError(t, err) + assert.Len(t, ok, 1) + assert.Len(t, fail, 1) + assert.Equal(t, "ResourceNotFoundException", fail[0].FailureCode) + + // Create rule under a missing listener fails. + _, err = m.CreateRule(ctx, &driver.CreateRuleInput{ServiceID: svc.ID, ListenerID: "listener-x"}) + assert.True(t, cerrors.IsNotFound(err)) +} + +func TestTargetGroupConfigExtractAndTargets(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + tg, err := m.CreateTargetGroup(ctx, &driver.CreateTargetGroupInput{ + Name: "tg", Type: "IP", + Config: []byte(`{"port":443,"protocol":"HTTPS","vpcIdentifier":"vpc-1"}`), + }) + require.NoError(t, err) + assert.Equal(t, int32(443), tg.Port) + assert.Equal(t, "HTTPS", tg.Protocol) + assert.Equal(t, "vpc-1", tg.VpcID) + + // Register dedups by id|port; deregister removes. + _, _, err = m.RegisterTargets(ctx, tg.ID, []driver.RegisteredTarget{{ID: "10.0.0.1", Port: 443}, {ID: "10.0.0.1", Port: 443}}) + require.NoError(t, err) + ts, _ := m.ListTargets(ctx, tg.ID) + assert.Len(t, ts, 1) + assert.Equal(t, targetStatusHealthy, ts[0].Status) + + _, _, err = m.DeregisterTargets(ctx, tg.ID, []driver.RegisteredTarget{{ID: "10.0.0.1", Port: 443}}) + require.NoError(t, err) + ts, _ = m.ListTargets(ctx, tg.ID) + assert.Empty(t, ts) + + _, _, err = m.RegisterTargets(ctx, "tg-missing", nil) + assert.True(t, cerrors.IsNotFound(err)) + + // UpdateTargetGroup merges healthCheck into the config blob. + upd, err := m.UpdateTargetGroup(ctx, tg.ID, []byte(`{"enabled":true}`)) + require.NoError(t, err) + assert.Contains(t, string(upd.Config), "healthCheck") +} + +func TestPoliciesAndTagging(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + p, err := m.PutAuthPolicy(ctx, "sn-1", `{"v":1}`) + require.NoError(t, err) + assert.Equal(t, authPolicyStateActive, p.State) + + got, err := m.GetAuthPolicy(ctx, "sn-1") + require.NoError(t, err) + assert.Equal(t, `{"v":1}`, got.Policy) + + require.NoError(t, m.DeleteAuthPolicy(ctx, "sn-1")) + _, err = m.GetAuthPolicy(ctx, "sn-1") + assert.True(t, cerrors.IsNotFound(err)) + + _, err = m.GetResourcePolicy(ctx, "arn:none") + assert.True(t, cerrors.IsNotFound(err)) + require.NoError(t, m.PutResourcePolicy(ctx, "arn:x", "pol")) + rp, err := m.GetResourcePolicy(ctx, "arn:x") + require.NoError(t, err) + assert.Equal(t, "pol", rp) + + const arn = "arn:aws:vpc-lattice:us-east-1:123456789012:servicenetwork/sn-1" + require.NoError(t, m.TagResource(ctx, arn, map[string]string{"a": "1", "b": "2"})) + require.NoError(t, m.TagResource(ctx, arn, map[string]string{"a": "9"})) + require.NoError(t, m.UntagResource(ctx, arn, []string{"b"})) + + tags, err := m.ListTagsForResource(ctx, arn) + require.NoError(t, err) + assert.Equal(t, map[string]string{"a": "9"}, tags) +} + +func TestResourceEndpointAssociationEmpty(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + as, err := m.ListResourceEndpointAssociations(ctx) + require.NoError(t, err) + assert.Empty(t, as) + + assert.True(t, cerrors.IsNotFound(m.DeleteResourceEndpointAssociation(ctx, "rea-1"))) +} + +func TestResourceConfigGatewayCRUD(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + gw, err := m.CreateResourceGateway(ctx, &driver.CreateResourceGatewayInput{ + Name: "gw", VpcID: "vpc-1", SubnetIDs: []string{"s-1"}, SecurityGroupIDs: []string{"sg-1"}, IPAddressType: "IPV4", + }) + require.NoError(t, err) + assert.Contains(t, gw.ID, "rgw-") + + ugw, err := m.UpdateResourceGateway(ctx, gw.ID, []string{"sg-1", "sg-2"}) + require.NoError(t, err) + assert.Len(t, ugw.SecurityGroupIDs, 2) + + gotGw, err := m.GetResourceGateway(ctx, gw.ARN) // by ARN + require.NoError(t, err) + assert.Equal(t, gw.ID, gotGw.ID) + + gws, err := m.ListResourceGateways(ctx) + require.NoError(t, err) + assert.Len(t, gws, 1) + + _, err = m.GetResourceGateway(ctx, "rgw-missing") + assert.True(t, cerrors.IsNotFound(err)) + require.NoError(t, m.DeleteResourceGateway(ctx, gw.ID)) + assert.True(t, cerrors.IsNotFound(m.DeleteResourceGateway(ctx, gw.ID))) + + rc, err := m.CreateResourceConfiguration(ctx, &driver.CreateResourceConfigurationInput{ + Name: "rc", Type: "SINGLE", Protocol: "TCP", PortRanges: []string{"443"}, + Definition: []byte(`{"ipResource":{"ipAddress":"10.0.0.9"}}`), + }) + require.NoError(t, err) + assert.Contains(t, rc.ID, "rcfg-") + + urc, err := m.UpdateResourceConfiguration(ctx, &driver.UpdateResourceConfigurationInput{ID: rc.ID, PortRanges: []string{"443", "8443"}}) + require.NoError(t, err) + assert.Len(t, urc.PortRanges, 2) + + _, err = m.GetResourceConfiguration(ctx, rc.ID) + require.NoError(t, err) + rcs, err := m.ListResourceConfigurations(ctx) + require.NoError(t, err) + assert.Len(t, rcs, 1) + + _, err = m.GetResourceConfiguration(ctx, "rcfg-missing") + assert.True(t, cerrors.IsNotFound(err)) + require.NoError(t, m.DeleteResourceConfiguration(ctx, rc.ID)) + _, err = m.UpdateResourceConfiguration(ctx, &driver.UpdateResourceConfigurationInput{ID: "rcfg-missing"}) + assert.True(t, cerrors.IsNotFound(err)) +} + +func TestAccessLogAndDomainVerificationCRUD(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + als, err := m.CreateAccessLogSubscription(ctx, + "arn:aws:vpc-lattice:us-east-1:123456789012:servicenetwork/sn-1", "arn:aws:s3:::bucket", "SERVICE", nil) + require.NoError(t, err) + assert.Contains(t, als.ID, "als-") + assert.Equal(t, "sn-1", als.ResourceID) + assert.NotEmpty(t, als.ResourceARN) + + uals, err := m.UpdateAccessLogSubscription(ctx, als.ID, "arn:aws:logs:::lg") + require.NoError(t, err) + assert.Equal(t, "arn:aws:logs:::lg", uals.DestinationARN) + + _, err = m.GetAccessLogSubscription(ctx, als.ID) + require.NoError(t, err) + subs, err := m.ListAccessLogSubscriptions(ctx) + require.NoError(t, err) + assert.Len(t, subs, 1) + + _, err = m.GetAccessLogSubscription(ctx, "als-missing") + assert.True(t, cerrors.IsNotFound(err)) + require.NoError(t, m.DeleteAccessLogSubscription(ctx, als.ID)) + assert.True(t, cerrors.IsNotFound(m.DeleteAccessLogSubscription(ctx, als.ID))) + + dv, err := m.StartDomainVerification(ctx, "example.com", nil) + require.NoError(t, err) + assert.Equal(t, domainVerificationStatusPending, dv.Status) + + _, err = m.GetDomainVerification(ctx, dv.ID) + require.NoError(t, err) + dvs, err := m.ListDomainVerifications(ctx) + require.NoError(t, err) + assert.Len(t, dvs, 1) + + _, err = m.GetDomainVerification(ctx, "dv-missing") + assert.True(t, cerrors.IsNotFound(err)) + require.NoError(t, m.DeleteDomainVerification(ctx, dv.ID)) + assert.True(t, cerrors.IsNotFound(m.DeleteDomainVerification(ctx, "dv-missing"))) +} + +func TestListenerAndRuleListAndUpdate(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + svc, _ := m.CreateService(ctx, &driver.CreateServiceInput{Name: "svc"}) + l, _ := m.CreateListener(ctx, &driver.CreateListenerInput{ServiceID: svc.ID, Protocol: "HTTP", Port: 80}) + + ul, err := m.UpdateListener(ctx, svc.ID, l.ID, []byte(`{"fixedResponse":{"statusCode":200}}`)) + require.NoError(t, err) + assert.Contains(t, string(ul.DefaultAction), "200") + + ls, err := m.ListListeners(ctx, svc.ID) + require.NoError(t, err) + assert.Len(t, ls, 1) + require.NoError(t, m.DeleteListener(ctx, svc.ID, l.ID)) + + l2, _ := m.CreateListener(ctx, &driver.CreateListenerInput{ServiceID: svc.ID, Protocol: "HTTP", Port: 81}) + r, _ := m.CreateRule(ctx, &driver.CreateRuleInput{ServiceID: svc.ID, ListenerID: l2.ID, Name: "r", Priority: 5}) + ur, err := m.UpdateRule(ctx, svc.ID, l2.ID, r.ID, 7, []byte(`{"httpMatch":{}}`), nil) + require.NoError(t, err) + assert.Equal(t, int32(7), ur.Priority) + rs, err := m.ListRules(ctx, svc.ID, l2.ID) + require.NoError(t, err) + assert.Len(t, rs, 1) + require.NoError(t, m.DeleteRule(ctx, svc.ID, l2.ID, r.ID)) + _, err = m.GetRule(ctx, svc.ID, l2.ID, r.ID) + assert.True(t, cerrors.IsNotFound(err)) + + // SN-VPC association update + endpoint-assoc empty list. + sn, _ := m.CreateServiceNetwork(ctx, &driver.CreateServiceNetworkInput{Name: "sn"}) + a, _ := m.CreateSNVpcAssociation(ctx, &driver.CreateSNVpcAssociationInput{ServiceNetworkID: sn.ID, VpcID: "vpc-1"}) + ua, err := m.UpdateSNVpcAssociation(ctx, a.ID, []string{"sg-9"}) + require.NoError(t, err) + assert.Equal(t, []string{"sg-9"}, ua.SecurityGroupIDs) + ep, err := m.ListSNVpcEndpointAssociations(ctx, sn.ID) + require.NoError(t, err) + assert.Empty(t, ep) +} diff --git a/server/aws/aws.go b/server/aws/aws.go index 3b0e6f24..105747bd 100644 --- a/server/aws/aws.go +++ b/server/aws/aws.go @@ -40,6 +40,7 @@ import ( "github.com/stackshy/cloudemu/v2/server/aws/sqs" ssmsrv "github.com/stackshy/cloudemu/v2/server/aws/ssm" stssrv "github.com/stackshy/cloudemu/v2/server/aws/sts" + vpclatticesrv "github.com/stackshy/cloudemu/v2/server/aws/vpclattice" bedrockdriver "github.com/stackshy/cloudemu/v2/services/bedrock/driver" bedrockagentdriver "github.com/stackshy/cloudemu/v2/services/bedrockagent/driver" bedrockagentruntimedriver "github.com/stackshy/cloudemu/v2/services/bedrockagentruntime/driver" @@ -68,6 +69,7 @@ import ( secretsdriver "github.com/stackshy/cloudemu/v2/services/secrets/driver" sdrv "github.com/stackshy/cloudemu/v2/services/serverless/driver" storagedriver "github.com/stackshy/cloudemu/v2/services/storage/driver" + vpclatticedriver "github.com/stackshy/cloudemu/v2/services/vpclattice/driver" ) // Drivers bundles the driver interfaces the AWS server can expose. Leave a @@ -93,6 +95,10 @@ type Drivers struct { // ECS serves the Amazon ECS JSON 1.1 protocol (X-Amz-Target prefix // AmazonEC2ContainerServiceV20141113.) against the ecs driver. ECS ecsdriver.ECS + + // VPCLattice serves the AWS VPC Lattice REST-JSON API (path + method + // routing) against the vpclattice driver. + VPCLattice vpclatticedriver.VPCLattice // SecretsManager serves the Secrets Manager JSON 1.1 protocol against // the secrets driver. SecretsManager secretsdriver.Secrets @@ -167,6 +173,7 @@ func DriversFrom(p *awsprovider.Provider) Drivers { BedrockAgentRuntime: p.BedrockAgentRuntime, SageMaker: p.SageMaker, ECS: p.ECS, + VPCLattice: p.VPCLattice, SecretsManager: p.SecretsManager, SSM: p.SSM, CloudWatchLogs: p.CloudWatchLogs, @@ -273,6 +280,13 @@ func New(d Drivers) *server.Server { srv.Register(ecssrv.New(d.ECS)) } + // VPC Lattice is a REST/JSON service rooted at path prefixes like + // /servicenetworks, /services, /targetgroups — disjoint from every + // X-Amz-Target service; its Matches predicate claims those prefixes. + if d.VPCLattice != nil { + srv.Register(vpclatticesrv.New(d.VPCLattice)) + } + // SSM Parameter Store matches the X-Amz-Target prefix "AmazonSSM." — // disjoint from DynamoDB, SQS, ECR, SageMaker, Secrets Manager, EventBridge, // CloudWatch Logs, and the tagging API. diff --git a/server/aws/vpclattice/accesslogs.go b/server/aws/vpclattice/accesslogs.go new file mode 100644 index 00000000..07ea80c1 --- /dev/null +++ b/server/aws/vpclattice/accesslogs.go @@ -0,0 +1,115 @@ +package vpclattice + +import ( + "net/http" + + "github.com/stackshy/cloudemu/v2/services/vpclattice/driver" +) + +type wireAccessLogSub struct { + Arn string `json:"arn,omitempty"` + ID string `json:"id,omitempty"` + DestinationArn string `json:"destinationArn,omitempty"` + ResourceArn string `json:"resourceArn,omitempty"` + ResourceID string `json:"resourceId,omitempty"` + ServiceNetworkLogType string `json:"serviceNetworkLogType,omitempty"` + CreatedAt string `json:"createdAt,omitempty"` + LastUpdatedAt string `json:"lastUpdatedAt,omitempty"` +} + +func accessLogSubToWire(a *driver.AccessLogSubscription) wireAccessLogSub { + return wireAccessLogSub{ + Arn: a.ARN, ID: a.ID, DestinationArn: a.DestinationARN, ResourceArn: a.ResourceARN, + ResourceID: a.ResourceID, ServiceNetworkLogType: a.ServiceNetworkLogType, + CreatedAt: a.CreatedAt, LastUpdatedAt: a.LastUpdatedAt, + } +} + +func (h *Handler) serveAccessLogSubs(w http.ResponseWriter, r *http.Request, rest []string) { + if len(rest) == 0 { + routeCollection(w, r, h.createAccessLogSub, h.listAccessLogSubs) + + return + } + + routeByID(w, r, rest[0], h.getAccessLogSub, h.updateAccessLogSub, h.deleteAccessLogSub) +} + +func (h *Handler) createAccessLogSub(w http.ResponseWriter, r *http.Request) { + var req struct { + DestinationArn string `json:"destinationArn"` + ResourceIdentifier string `json:"resourceIdentifier"` + ServiceNetworkLogType string `json:"serviceNetworkLogType"` + Tags map[string]string `json:"tags"` + } + + if !decodeJSON(w, r, &req) { + return + } + + a, err := h.lattice.CreateAccessLogSubscription(r.Context(), + req.ResourceIdentifier, req.DestinationArn, req.ServiceNetworkLogType, req.Tags) + if err != nil { + writeErr(w, err) + + return + } + + writeJSON(w, accessLogSubToWire(a)) +} + +func (h *Handler) getAccessLogSub(w http.ResponseWriter, r *http.Request, id string) { + a, err := h.lattice.GetAccessLogSubscription(r.Context(), id) + if err != nil { + writeErr(w, err) + + return + } + + writeJSON(w, accessLogSubToWire(a)) +} + +func (h *Handler) updateAccessLogSub(w http.ResponseWriter, r *http.Request, id string) { + var req struct { + DestinationArn string `json:"destinationArn"` + } + + if !decodeJSON(w, r, &req) { + return + } + + a, err := h.lattice.UpdateAccessLogSubscription(r.Context(), id, req.DestinationArn) + if err != nil { + writeErr(w, err) + + return + } + + writeJSON(w, accessLogSubToWire(a)) +} + +func (h *Handler) deleteAccessLogSub(w http.ResponseWriter, r *http.Request, id string) { + if err := h.lattice.DeleteAccessLogSubscription(r.Context(), id); err != nil { + writeErr(w, err) + + return + } + + writeJSON(w, struct{}{}) +} + +func (h *Handler) listAccessLogSubs(w http.ResponseWriter, r *http.Request) { + as, err := h.lattice.ListAccessLogSubscriptions(r.Context()) + if err != nil { + writeErr(w, err) + + return + } + + items := make([]wireAccessLogSub, 0, len(as)) + for i := range as { + items = append(items, accessLogSubToWire(&as[i])) + } + + writeJSON(w, map[string]any{"items": items}) +} diff --git a/server/aws/vpclattice/associations.go b/server/aws/vpclattice/associations.go new file mode 100644 index 00000000..34f5ade1 --- /dev/null +++ b/server/aws/vpclattice/associations.go @@ -0,0 +1,347 @@ +package vpclattice + +import ( + "net/http" + + "github.com/stackshy/cloudemu/v2/services/vpclattice/driver" +) + +// --- SN ↔ VPC --- + +type wireSNVpcAssoc struct { + Arn string `json:"arn,omitempty"` + ID string `json:"id,omitempty"` + CreatedBy string `json:"createdBy,omitempty"` + ServiceNetworkArn string `json:"serviceNetworkArn,omitempty"` + ServiceNetworkID string `json:"serviceNetworkId,omitempty"` + ServiceNetworkName string `json:"serviceNetworkName,omitempty"` + VpcID string `json:"vpcId,omitempty"` + SecurityGroupIDs []string `json:"securityGroupIds,omitempty"` + PrivateDNSEnabled bool `json:"privateDnsEnabled"` + Status string `json:"status,omitempty"` + CreatedAt string `json:"createdAt,omitempty"` + LastUpdatedAt string `json:"lastUpdatedAt,omitempty"` +} + +func snVpcToWire(a *driver.SNVpcAssociation) wireSNVpcAssoc { + return wireSNVpcAssoc{ + Arn: a.ARN, ID: a.ID, CreatedBy: a.CreatedBy, + ServiceNetworkArn: a.ServiceNetworkARN, ServiceNetworkID: a.ServiceNetworkID, + ServiceNetworkName: a.ServiceNetworkName, VpcID: a.VpcID, + SecurityGroupIDs: a.SecurityGroupIDs, PrivateDNSEnabled: a.PrivateDNSEnabled, + Status: a.Status, CreatedAt: a.CreatedAt, LastUpdatedAt: a.LastUpdatedAt, + } +} + +func (h *Handler) serveSNVpcAssociations(w http.ResponseWriter, r *http.Request, rest []string) { + if len(rest) == 0 { + routeCollection(w, r, h.createSNVpcAssoc, h.listSNVpcAssocs) + + return + } + + routeByID(w, r, rest[0], h.getSNVpcAssoc, h.updateSNVpcAssoc, h.deleteSNVpcAssoc) +} + +func (h *Handler) createSNVpcAssoc(w http.ResponseWriter, r *http.Request) { + var req struct { + ServiceNetworkIdentifier string `json:"serviceNetworkIdentifier"` + VpcIdentifier string `json:"vpcIdentifier"` + SecurityGroupIDs []string `json:"securityGroupIds"` + PrivateDNSEnabled bool `json:"privateDnsEnabled"` + Tags map[string]string `json:"tags"` + } + + if !decodeJSON(w, r, &req) { + return + } + + a, err := h.lattice.CreateSNVpcAssociation(r.Context(), &driver.CreateSNVpcAssociationInput{ + ServiceNetworkID: req.ServiceNetworkIdentifier, VpcID: req.VpcIdentifier, + SecurityGroupIDs: req.SecurityGroupIDs, PrivateDNSEnabled: req.PrivateDNSEnabled, Tags: req.Tags, + }) + if err != nil { + writeErr(w, err) + + return + } + + writeJSON(w, snVpcToWire(a)) +} + +func (h *Handler) getSNVpcAssoc(w http.ResponseWriter, r *http.Request, id string) { + a, err := h.lattice.GetSNVpcAssociation(r.Context(), id) + if err != nil { + writeErr(w, err) + + return + } + + writeJSON(w, snVpcToWire(a)) +} + +func (h *Handler) updateSNVpcAssoc(w http.ResponseWriter, r *http.Request, id string) { + var req struct { + SecurityGroupIDs []string `json:"securityGroupIds"` + } + + if !decodeJSON(w, r, &req) { + return + } + + a, err := h.lattice.UpdateSNVpcAssociation(r.Context(), id, req.SecurityGroupIDs) + if err != nil { + writeErr(w, err) + + return + } + + writeJSON(w, snVpcToWire(a)) +} + +func (h *Handler) deleteSNVpcAssoc(w http.ResponseWriter, r *http.Request, id string) { + if err := h.lattice.DeleteSNVpcAssociation(r.Context(), id); err != nil { + writeErr(w, err) + + return + } + + writeJSON(w, struct{}{}) +} + +func (h *Handler) listSNVpcAssocs(w http.ResponseWriter, r *http.Request) { + as, err := h.lattice.ListSNVpcAssociations(r.Context()) + if err != nil { + writeErr(w, err) + + return + } + + items := make([]wireSNVpcAssoc, 0, len(as)) + for i := range as { + items = append(items, snVpcToWire(&as[i])) + } + + writeJSON(w, map[string]any{"items": items}) +} + +func (h *Handler) serveSNVpcEndpointAssociations(w http.ResponseWriter, r *http.Request, rest []string) { + if len(rest) != 0 || r.Method != http.MethodGet { + methodNotAllowed(w) + + return + } + + as, err := h.lattice.ListSNVpcEndpointAssociations(r.Context(), r.URL.Query().Get("serviceNetworkIdentifier")) + if err != nil { + writeErr(w, err) + + return + } + + items := make([]wireSNVpcAssoc, 0, len(as)) + for i := range as { + items = append(items, snVpcToWire(&as[i])) + } + + writeJSON(w, map[string]any{"items": items}) +} + +// --- SN ↔ Service --- + +type wireSNSvcAssoc struct { + Arn string `json:"arn,omitempty"` + ID string `json:"id,omitempty"` + CreatedBy string `json:"createdBy,omitempty"` + CustomDomainName string `json:"customDomainName,omitempty"` + DNSEntry *wireDNSEntry `json:"dnsEntry,omitempty"` + ServiceArn string `json:"serviceArn,omitempty"` + ServiceID string `json:"serviceId,omitempty"` + ServiceName string `json:"serviceName,omitempty"` + ServiceNetworkArn string `json:"serviceNetworkArn,omitempty"` + ServiceNetworkID string `json:"serviceNetworkId,omitempty"` + ServiceNetworkName string `json:"serviceNetworkName,omitempty"` + Status string `json:"status,omitempty"` + CreatedAt string `json:"createdAt,omitempty"` +} + +func snSvcToWire(a *driver.SNServiceAssociation) wireSNSvcAssoc { + w := wireSNSvcAssoc{ + Arn: a.ARN, ID: a.ID, CreatedBy: a.CreatedBy, CustomDomainName: a.CustomDomainName, + ServiceArn: a.ServiceARN, ServiceID: a.ServiceID, ServiceName: a.ServiceName, + ServiceNetworkArn: a.ServiceNetworkARN, ServiceNetworkID: a.ServiceNetworkID, + ServiceNetworkName: a.ServiceNetworkName, Status: a.Status, CreatedAt: a.CreatedAt, + } + if a.DNSName != "" { + w.DNSEntry = &wireDNSEntry{DomainName: a.DNSName, HostedZoneID: a.HostedZoneID} + } + + return w +} + +func (h *Handler) serveSNServiceAssociations(w http.ResponseWriter, r *http.Request, rest []string) { + if len(rest) == 0 { + routeCollection(w, r, h.createSNSvcAssoc, h.listSNSvcAssocs) + + return + } + + routeByID(w, r, rest[0], h.getSNSvcAssoc, nil, h.deleteSNSvcAssoc) +} + +func (h *Handler) createSNSvcAssoc(w http.ResponseWriter, r *http.Request) { + var req struct { + ServiceIdentifier string `json:"serviceIdentifier"` + ServiceNetworkIdentifier string `json:"serviceNetworkIdentifier"` + Tags map[string]string `json:"tags"` + } + + if !decodeJSON(w, r, &req) { + return + } + + a, err := h.lattice.CreateSNServiceAssociation(r.Context(), req.ServiceNetworkIdentifier, req.ServiceIdentifier, req.Tags) + if err != nil { + writeErr(w, err) + + return + } + + writeJSON(w, snSvcToWire(a)) +} + +func (h *Handler) getSNSvcAssoc(w http.ResponseWriter, r *http.Request, id string) { + a, err := h.lattice.GetSNServiceAssociation(r.Context(), id) + if err != nil { + writeErr(w, err) + + return + } + + writeJSON(w, snSvcToWire(a)) +} + +func (h *Handler) deleteSNSvcAssoc(w http.ResponseWriter, r *http.Request, id string) { + if err := h.lattice.DeleteSNServiceAssociation(r.Context(), id); err != nil { + writeErr(w, err) + + return + } + + writeJSON(w, struct{}{}) +} + +func (h *Handler) listSNSvcAssocs(w http.ResponseWriter, r *http.Request) { + as, err := h.lattice.ListSNServiceAssociations(r.Context()) + if err != nil { + writeErr(w, err) + + return + } + + items := make([]wireSNSvcAssoc, 0, len(as)) + for i := range as { + items = append(items, snSvcToWire(&as[i])) + } + + writeJSON(w, map[string]any{"items": items}) +} + +// --- SN ↔ Resource --- + +type wireSNResAssoc struct { + Arn string `json:"arn,omitempty"` + ID string `json:"id,omitempty"` + CreatedBy string `json:"createdBy,omitempty"` + ResourceConfigurationArn string `json:"resourceConfigurationArn,omitempty"` + ResourceConfigurationID string `json:"resourceConfigurationId,omitempty"` + ResourceConfigurationName string `json:"resourceConfigurationName,omitempty"` + ServiceNetworkArn string `json:"serviceNetworkArn,omitempty"` + ServiceNetworkID string `json:"serviceNetworkId,omitempty"` + ServiceNetworkName string `json:"serviceNetworkName,omitempty"` + PrivateDNSEnabled bool `json:"privateDnsEnabled"` + Status string `json:"status,omitempty"` + CreatedAt string `json:"createdAt,omitempty"` + LastUpdatedAt string `json:"lastUpdatedAt,omitempty"` +} + +func snResToWire(a *driver.SNResourceAssociation) wireSNResAssoc { + return wireSNResAssoc{ + Arn: a.ARN, ID: a.ID, CreatedBy: a.CreatedBy, + ResourceConfigurationArn: a.ResourceConfigurationARN, ResourceConfigurationID: a.ResourceConfigurationID, + ResourceConfigurationName: a.ResourceConfigurationName, ServiceNetworkArn: a.ServiceNetworkARN, + ServiceNetworkID: a.ServiceNetworkID, ServiceNetworkName: a.ServiceNetworkName, + PrivateDNSEnabled: a.PrivateDNSEnabled, Status: a.Status, + CreatedAt: a.CreatedAt, LastUpdatedAt: a.LastUpdatedAt, + } +} + +func (h *Handler) serveSNResourceAssociations(w http.ResponseWriter, r *http.Request, rest []string) { + if len(rest) == 0 { + routeCollection(w, r, h.createSNResAssoc, h.listSNResAssocs) + + return + } + + routeByID(w, r, rest[0], h.getSNResAssoc, nil, h.deleteSNResAssoc) +} + +func (h *Handler) createSNResAssoc(w http.ResponseWriter, r *http.Request) { + var req struct { + ResourceConfigurationIdentifier string `json:"resourceConfigurationIdentifier"` + ServiceNetworkIdentifier string `json:"serviceNetworkIdentifier"` + PrivateDNSEnabled bool `json:"privateDnsEnabled"` + Tags map[string]string `json:"tags"` + } + + if !decodeJSON(w, r, &req) { + return + } + + a, err := h.lattice.CreateSNResourceAssociation(r.Context(), + req.ServiceNetworkIdentifier, req.ResourceConfigurationIdentifier, req.PrivateDNSEnabled, req.Tags) + if err != nil { + writeErr(w, err) + + return + } + + writeJSON(w, snResToWire(a)) +} + +func (h *Handler) getSNResAssoc(w http.ResponseWriter, r *http.Request, id string) { + a, err := h.lattice.GetSNResourceAssociation(r.Context(), id) + if err != nil { + writeErr(w, err) + + return + } + + writeJSON(w, snResToWire(a)) +} + +func (h *Handler) deleteSNResAssoc(w http.ResponseWriter, r *http.Request, id string) { + if err := h.lattice.DeleteSNResourceAssociation(r.Context(), id); err != nil { + writeErr(w, err) + + return + } + + writeJSON(w, struct{}{}) +} + +func (h *Handler) listSNResAssocs(w http.ResponseWriter, r *http.Request) { + as, err := h.lattice.ListSNResourceAssociations(r.Context()) + if err != nil { + writeErr(w, err) + + return + } + + items := make([]wireSNResAssoc, 0, len(as)) + for i := range as { + items = append(items, snResToWire(&as[i])) + } + + writeJSON(w, map[string]any{"items": items}) +} diff --git a/server/aws/vpclattice/domainverifications.go b/server/aws/vpclattice/domainverifications.go new file mode 100644 index 00000000..f2d63187 --- /dev/null +++ b/server/aws/vpclattice/domainverifications.go @@ -0,0 +1,86 @@ +package vpclattice + +import ( + "net/http" + + "github.com/stackshy/cloudemu/v2/services/vpclattice/driver" +) + +type wireDomainVerification struct { + Arn string `json:"arn,omitempty"` + ID string `json:"id,omitempty"` + DomainName string `json:"domainName,omitempty"` + Status string `json:"status,omitempty"` + CreatedAt string `json:"createdAt,omitempty"` +} + +func domainVerificationToWire(d *driver.DomainVerification) wireDomainVerification { + return wireDomainVerification{Arn: d.ARN, ID: d.ID, DomainName: d.DomainName, Status: d.Status, CreatedAt: d.CreatedAt} +} + +func (h *Handler) serveDomainVerifications(w http.ResponseWriter, r *http.Request, rest []string) { + if len(rest) == 0 { + routeCollection(w, r, h.startDomainVerification, h.listDomainVerifications) + + return + } + + routeByID(w, r, rest[0], h.getDomainVerification, nil, h.deleteDomainVerification) +} + +func (h *Handler) startDomainVerification(w http.ResponseWriter, r *http.Request) { + var req struct { + DomainName string `json:"domainName"` + Tags map[string]string `json:"tags"` + } + + if !decodeJSON(w, r, &req) { + return + } + + d, err := h.lattice.StartDomainVerification(r.Context(), req.DomainName, req.Tags) + if err != nil { + writeErr(w, err) + + return + } + + writeJSON(w, domainVerificationToWire(d)) +} + +func (h *Handler) getDomainVerification(w http.ResponseWriter, r *http.Request, id string) { + d, err := h.lattice.GetDomainVerification(r.Context(), id) + if err != nil { + writeErr(w, err) + + return + } + + writeJSON(w, domainVerificationToWire(d)) +} + +func (h *Handler) deleteDomainVerification(w http.ResponseWriter, r *http.Request, id string) { + if err := h.lattice.DeleteDomainVerification(r.Context(), id); err != nil { + writeErr(w, err) + + return + } + + writeJSON(w, struct{}{}) +} + +func (h *Handler) listDomainVerifications(w http.ResponseWriter, r *http.Request) { + ds, err := h.lattice.ListDomainVerifications(r.Context()) + if err != nil { + writeErr(w, err) + + return + } + + items := make([]wireDomainVerification, 0, len(ds)) + for i := range ds { + items = append(items, domainVerificationToWire(&ds[i])) + } + + writeJSON(w, map[string]any{"items": items}) +} diff --git a/server/aws/vpclattice/errors.go b/server/aws/vpclattice/errors.go new file mode 100644 index 00000000..45e9cca1 --- /dev/null +++ b/server/aws/vpclattice/errors.go @@ -0,0 +1,83 @@ +package vpclattice + +import ( + "encoding/json" + "net/http" + + cerrors "github.com/stackshy/cloudemu/v2/errors" +) + +const contentTypeJSON = "application/json" + +const maxBodyBytes = 5 << 20 + +// errorBody is the restJson1 error body. The SDK reads the X-Amzn-Errortype +// header to select a typed exception, falling back to the body's __type. +type errorBody struct { + Type string `json:"__type"` + Message string `json:"message"` +} + +// writeError writes a restJson1 error response. +func writeError(w http.ResponseWriter, status int, errType, msg string) { + w.Header().Set("Content-Type", contentTypeJSON) + w.Header().Set("X-Amzn-Errortype", errType) + w.WriteHeader(status) + + _ = json.NewEncoder(w).Encode(errorBody{Type: errType, Message: msg}) +} + +// writeErr maps a canonical cloudemu error to the closest VPC Lattice exception. +func writeErr(w http.ResponseWriter, err error) { + switch { + case cerrors.IsNotFound(err): + writeError(w, http.StatusNotFound, "ResourceNotFoundException", err.Error()) + case cerrors.IsAlreadyExists(err): + writeError(w, http.StatusConflict, "ConflictException", err.Error()) + case cerrors.IsInvalidArgument(err): + writeError(w, http.StatusBadRequest, "ValidationException", err.Error()) + case cerrors.IsPermissionDenied(err): + writeError(w, http.StatusForbidden, "AccessDeniedException", err.Error()) + case cerrors.IsFailedPrecondition(err): + writeError(w, http.StatusConflict, "ConflictException", err.Error()) + case cerrors.IsThrottled(err): + writeError(w, http.StatusTooManyRequests, "ThrottlingException", err.Error()) + default: + writeError(w, http.StatusInternalServerError, "InternalServerException", err.Error()) + } +} + +func notFound(w http.ResponseWriter, path string) { + writeError(w, http.StatusNotFound, "ResourceNotFoundException", "unsupported path: "+path) +} + +func methodNotAllowed(w http.ResponseWriter) { + writeError(w, http.StatusMethodNotAllowed, "ValidationException", "method not allowed") +} + +// decodeJSON decodes the request body into v, writing a ValidationException on +// failure. An empty body is treated as an empty object (some ops take no body). +func decodeJSON(w http.ResponseWriter, r *http.Request, v any) bool { + r.Body = http.MaxBytesReader(w, r.Body, maxBodyBytes) + + dec := json.NewDecoder(r.Body) + + if err := dec.Decode(v); err != nil { + if err.Error() == "EOF" { + return true + } + + writeError(w, http.StatusBadRequest, "ValidationException", "invalid JSON: "+err.Error()) + + return false + } + + return true +} + +// writeJSON writes a 200 restJson1 success body. +func writeJSON(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", contentTypeJSON) + w.WriteHeader(http.StatusOK) + _ = json.NewEncoder(w).Encode(v) +} diff --git a/server/aws/vpclattice/handler.go b/server/aws/vpclattice/handler.go new file mode 100644 index 00000000..e4bf29e1 --- /dev/null +++ b/server/aws/vpclattice/handler.go @@ -0,0 +1,129 @@ +// Package vpclattice implements the AWS VPC Lattice control-plane API +// (REST-JSON, awsRestjson1) as a server.Handler. Point the real +// aws-sdk-go-v2/service/vpclattice client at a Server registered with this +// handler and the operations work end-to-end against an in-memory driver. +// +// VPC Lattice uses path + HTTP-method routing (no X-Amz-Target). The Matches +// predicate claims the service's top-level collection prefixes so it does not +// shadow other handlers; ServeHTTP dispatches on the first path segment. +package vpclattice + +import ( + "net/http" + "strings" + + "github.com/stackshy/cloudemu/v2/services/vpclattice/driver" +) + +// Handler serves VPC Lattice requests against a driver. +type Handler struct { + lattice driver.VPCLattice + routes map[string]segmentHandler +} + +// segmentHandler serves a request whose first path segment has been consumed; +// rest holds the remaining segments. +type segmentHandler func(w http.ResponseWriter, r *http.Request, rest []string) + +// New returns a VPC Lattice handler backed by d. +func New(d driver.VPCLattice) *Handler { + h := &Handler{lattice: d} + h.routes = map[string]segmentHandler{ + "servicenetworks": h.serveServiceNetworks, + "services": h.serveServices, + "targetgroups": h.serveTargetGroups, + "servicenetworkvpcassociations": h.serveSNVpcAssociations, + "servicenetworkvpcendpointassociations": h.serveSNVpcEndpointAssociations, + "servicenetworkserviceassociations": h.serveSNServiceAssociations, + "servicenetworkresourceassociations": h.serveSNResourceAssociations, + "resourceconfigurations": h.serveResourceConfigurations, + "resourcegateways": h.serveResourceGateways, + "resourceendpointassociations": h.serveResourceEndpointAssociations, + "accesslogsubscriptions": h.serveAccessLogSubs, + "authpolicy": h.serveAuthPolicy, + "resourcepolicy": h.serveResourcePolicy, + "domainverifications": h.serveDomainVerifications, + "tags": h.serveTags, + } + + return h +} + +// Matches claims requests whose first path segment belongs to VPC Lattice. +func (h *Handler) Matches(r *http.Request) bool { + _, ok := h.routes[firstSegment(r.URL.Path)] + + return ok +} + +// ServeHTTP dispatches on the first path segment via the routes table. +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + segs := splitPath(r.URL.Path) + if len(segs) == 0 { + notFound(w, r.URL.Path) + + return + } + + if serve, ok := h.routes[segs[0]]; ok { + serve(w, r, segs[1:]) + + return + } + + notFound(w, r.URL.Path) +} + +// idHandler handles a request scoped to a single resource identifier. +type idHandler func(http.ResponseWriter, *http.Request, string) + +// plainHandler handles a request with no path identifier. +type plainHandler func(http.ResponseWriter, *http.Request) + +// routeCollection dispatches a collection path: POST→create, GET→list. A nil +// handler means the method is unsupported for that collection. +func routeCollection(w http.ResponseWriter, r *http.Request, create, list plainHandler) { + switch { + case r.Method == http.MethodPost && create != nil: + create(w, r) + case r.Method == http.MethodGet && list != nil: + list(w, r) + default: + methodNotAllowed(w) + } +} + +// routeByID dispatches a resource path: GET→get, PATCH→update, DELETE→delete. +// A nil handler means the method is unsupported for that resource. +func routeByID(w http.ResponseWriter, r *http.Request, id string, get, update, del idHandler) { + switch { + case r.Method == http.MethodGet && get != nil: + get(w, r, id) + case r.Method == http.MethodPatch && update != nil: + update(w, r, id) + case r.Method == http.MethodDelete && del != nil: + del(w, r, id) + default: + methodNotAllowed(w) + } +} + +// firstSegment returns the first non-empty path segment, or "". +func firstSegment(p string) string { + segs := splitPath(p) + if len(segs) == 0 { + return "" + } + + return segs[0] +} + +// splitPath splits a URL path into its non-empty segments. +func splitPath(p string) []string { + p = strings.Trim(p, "/") + if p == "" { + return nil + } + + return strings.Split(p, "/") +} diff --git a/server/aws/vpclattice/listeners.go b/server/aws/vpclattice/listeners.go new file mode 100644 index 00000000..a3e03ddd --- /dev/null +++ b/server/aws/vpclattice/listeners.go @@ -0,0 +1,140 @@ +package vpclattice + +import ( + "encoding/json" + "net/http" + + "github.com/stackshy/cloudemu/v2/services/vpclattice/driver" +) + +type wireListener struct { + Arn string `json:"arn,omitempty"` + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Protocol string `json:"protocol,omitempty"` + Port int32 `json:"port,omitempty"` + DefaultAction json.RawMessage `json:"defaultAction,omitempty"` + ServiceArn string `json:"serviceArn,omitempty"` + ServiceID string `json:"serviceId,omitempty"` + CreatedAt string `json:"createdAt,omitempty"` + LastUpdatedAt string `json:"lastUpdatedAt,omitempty"` +} + +func listenerToWire(l *driver.Listener) wireListener { + w := wireListener{ + Arn: l.ARN, ID: l.ID, Name: l.Name, Protocol: l.Protocol, Port: l.Port, + ServiceArn: l.ServiceARN, ServiceID: l.ServiceID, + CreatedAt: l.CreatedAt, LastUpdatedAt: l.LastUpdatedAt, + } + if len(l.DefaultAction) > 0 { + w.DefaultAction = json.RawMessage(l.DefaultAction) + } + + return w +} + +// serveListeners routes /services/{serviceID}/listeners[/{id}[/rules...]]. +func (h *Handler) serveListeners(w http.ResponseWriter, r *http.Request, serviceID string, rest []string) { + if len(rest) == 0 { + routeCollection(w, r, + func(w http.ResponseWriter, r *http.Request) { h.createListener(w, r, serviceID) }, + func(w http.ResponseWriter, r *http.Request) { h.listListeners(w, r, serviceID) }) + + return + } + + listenerID := rest[0] + + if len(rest) >= 2 && rest[1] == "rules" { + h.serveRules(w, r, serviceID, listenerID, rest[2:]) + + return + } + + routeByID(w, r, listenerID, + func(w http.ResponseWriter, r *http.Request, id string) { h.getListener(w, r, serviceID, id) }, + func(w http.ResponseWriter, r *http.Request, id string) { h.updateListener(w, r, serviceID, id) }, + func(w http.ResponseWriter, r *http.Request, id string) { h.deleteListener(w, r, serviceID, id) }) +} + +func (h *Handler) createListener(w http.ResponseWriter, r *http.Request, serviceID string) { + var req struct { + Name string `json:"name"` + Protocol string `json:"protocol"` + Port int32 `json:"port"` + DefaultAction json.RawMessage `json:"defaultAction"` + Tags map[string]string `json:"tags"` + } + + if !decodeJSON(w, r, &req) { + return + } + + l, err := h.lattice.CreateListener(r.Context(), &driver.CreateListenerInput{ + ServiceID: serviceID, Name: req.Name, Protocol: req.Protocol, Port: req.Port, + DefaultAction: req.DefaultAction, Tags: req.Tags, + }) + if err != nil { + writeErr(w, err) + + return + } + + writeJSON(w, listenerToWire(l)) +} + +func (h *Handler) getListener(w http.ResponseWriter, r *http.Request, serviceID, id string) { + l, err := h.lattice.GetListener(r.Context(), serviceID, id) + if err != nil { + writeErr(w, err) + + return + } + + writeJSON(w, listenerToWire(l)) +} + +func (h *Handler) updateListener(w http.ResponseWriter, r *http.Request, serviceID, id string) { + var req struct { + DefaultAction json.RawMessage `json:"defaultAction"` + } + + if !decodeJSON(w, r, &req) { + return + } + + l, err := h.lattice.UpdateListener(r.Context(), serviceID, id, req.DefaultAction) + if err != nil { + writeErr(w, err) + + return + } + + writeJSON(w, listenerToWire(l)) +} + +func (h *Handler) deleteListener(w http.ResponseWriter, r *http.Request, serviceID, id string) { + if err := h.lattice.DeleteListener(r.Context(), serviceID, id); err != nil { + writeErr(w, err) + + return + } + + writeJSON(w, struct{}{}) +} + +func (h *Handler) listListeners(w http.ResponseWriter, r *http.Request, serviceID string) { + ls, err := h.lattice.ListListeners(r.Context(), serviceID) + if err != nil { + writeErr(w, err) + + return + } + + items := make([]wireListener, 0, len(ls)) + for i := range ls { + items = append(items, listenerToWire(&ls[i])) + } + + writeJSON(w, map[string]any{"items": items}) +} diff --git a/server/aws/vpclattice/policies.go b/server/aws/vpclattice/policies.go new file mode 100644 index 00000000..48ff462a --- /dev/null +++ b/server/aws/vpclattice/policies.go @@ -0,0 +1,132 @@ +package vpclattice + +import ( + "net/http" + "strings" +) + +// serveAuthPolicy routes PUT/GET/DELETE /authpolicy/{resourceIdentifier}. The +// identifier is the whole remainder (it may be an ARN containing slashes). +func (h *Handler) serveAuthPolicy(w http.ResponseWriter, r *http.Request, rest []string) { + if len(rest) == 0 { + notFound(w, r.URL.Path) + + return + } + + resourceID := strings.Join(rest, "/") + + switch r.Method { + case http.MethodPut: + h.putAuthPolicy(w, r, resourceID) + case http.MethodGet: + h.getAuthPolicy(w, r, resourceID) + case http.MethodDelete: + h.deleteAuthPolicy(w, r, resourceID) + default: + methodNotAllowed(w) + } +} + +func (h *Handler) putAuthPolicy(w http.ResponseWriter, r *http.Request, resourceID string) { + var req struct { + Policy string `json:"policy"` + } + + if !decodeJSON(w, r, &req) { + return + } + + p, err := h.lattice.PutAuthPolicy(r.Context(), resourceID, req.Policy) + if err != nil { + writeErr(w, err) + + return + } + + writeJSON(w, map[string]any{"policy": p.Policy, "state": p.State}) +} + +func (h *Handler) getAuthPolicy(w http.ResponseWriter, r *http.Request, resourceID string) { + p, err := h.lattice.GetAuthPolicy(r.Context(), resourceID) + if err != nil { + writeErr(w, err) + + return + } + + writeJSON(w, map[string]any{ + "policy": p.Policy, "state": p.State, "createdAt": p.CreatedAt, "lastUpdatedAt": p.LastUpdatedAt, + }) +} + +func (h *Handler) deleteAuthPolicy(w http.ResponseWriter, r *http.Request, resourceID string) { + if err := h.lattice.DeleteAuthPolicy(r.Context(), resourceID); err != nil { + writeErr(w, err) + + return + } + + writeJSON(w, struct{}{}) +} + +// serveResourcePolicy routes PUT/GET/DELETE /resourcepolicy/{resourceArn}. +func (h *Handler) serveResourcePolicy(w http.ResponseWriter, r *http.Request, rest []string) { + if len(rest) == 0 { + notFound(w, r.URL.Path) + + return + } + + arn := strings.Join(rest, "/") + + switch r.Method { + case http.MethodPut: + h.putResourcePolicy(w, r, arn) + case http.MethodGet: + h.getResourcePolicy(w, r, arn) + case http.MethodDelete: + h.deleteResourcePolicy(w, r, arn) + default: + methodNotAllowed(w) + } +} + +func (h *Handler) putResourcePolicy(w http.ResponseWriter, r *http.Request, arn string) { + var req struct { + Policy string `json:"policy"` + } + + if !decodeJSON(w, r, &req) { + return + } + + if err := h.lattice.PutResourcePolicy(r.Context(), arn, req.Policy); err != nil { + writeErr(w, err) + + return + } + + writeJSON(w, struct{}{}) +} + +func (h *Handler) getResourcePolicy(w http.ResponseWriter, r *http.Request, arn string) { + policy, err := h.lattice.GetResourcePolicy(r.Context(), arn) + if err != nil { + writeErr(w, err) + + return + } + + writeJSON(w, map[string]any{"policy": policy}) +} + +func (h *Handler) deleteResourcePolicy(w http.ResponseWriter, r *http.Request, arn string) { + if err := h.lattice.DeleteResourcePolicy(r.Context(), arn); err != nil { + writeErr(w, err) + + return + } + + writeJSON(w, struct{}{}) +} diff --git a/server/aws/vpclattice/resources.go b/server/aws/vpclattice/resources.go new file mode 100644 index 00000000..9bc1ea86 --- /dev/null +++ b/server/aws/vpclattice/resources.go @@ -0,0 +1,311 @@ +package vpclattice + +import ( + "encoding/json" + "net/http" + + "github.com/stackshy/cloudemu/v2/services/vpclattice/driver" +) + +// --- resource configurations --- + +type wireResourceConfig struct { + Arn string `json:"arn,omitempty"` + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Type string `json:"type,omitempty"` + Status string `json:"status,omitempty"` + Protocol string `json:"protocol,omitempty"` + CustomDomainName string `json:"customDomainName,omitempty"` + GroupDomain string `json:"groupDomain,omitempty"` + PortRanges []string `json:"portRanges,omitempty"` + Definition json.RawMessage `json:"resourceConfigurationDefinition,omitempty"` + ResourceGatewayID string `json:"resourceGatewayId,omitempty"` + ResourceConfigGroupID string `json:"resourceConfigurationGroupId,omitempty"` + AllowAssociationToShared bool `json:"allowAssociationToShareableServiceNetwork"` + CreatedAt string `json:"createdAt,omitempty"` + LastUpdatedAt string `json:"lastUpdatedAt,omitempty"` +} + +func resourceConfigToWire(c *driver.ResourceConfiguration) wireResourceConfig { + w := wireResourceConfig{ + Arn: c.ARN, ID: c.ID, Name: c.Name, Type: c.Type, Status: c.Status, Protocol: c.Protocol, + CustomDomainName: c.CustomDomainName, GroupDomain: c.GroupDomain, PortRanges: c.PortRanges, + ResourceGatewayID: c.ResourceGatewayID, ResourceConfigGroupID: c.ResourceConfigGroupID, + AllowAssociationToShared: c.AllowAssociationToShared, CreatedAt: c.CreatedAt, LastUpdatedAt: c.LastUpdatedAt, + } + if len(c.Definition) > 0 { + w.Definition = json.RawMessage(c.Definition) + } + + return w +} + +func (h *Handler) serveResourceConfigurations(w http.ResponseWriter, r *http.Request, rest []string) { + if len(rest) == 0 { + routeCollection(w, r, h.createResourceConfig, h.listResourceConfigs) + + return + } + + routeByID(w, r, rest[0], h.getResourceConfig, h.updateResourceConfig, h.deleteResourceConfig) +} + +func (h *Handler) createResourceConfig(w http.ResponseWriter, r *http.Request) { + var req struct { + Name string `json:"name"` + Type string `json:"type"` + Protocol string `json:"protocol"` + CustomDomainName string `json:"customDomainName"` + GroupDomain string `json:"groupDomain"` + PortRanges []string `json:"portRanges"` + Definition json.RawMessage `json:"resourceConfigurationDefinition"` + ResourceGatewayID string `json:"resourceGatewayIdentifier"` + ResourceConfigGroupID string `json:"resourceConfigurationGroupIdentifier"` + AllowAssociationToShared bool `json:"allowAssociationToShareableServiceNetwork"` + Tags map[string]string + } + + if !decodeJSON(w, r, &req) { + return + } + + c, err := h.lattice.CreateResourceConfiguration(r.Context(), &driver.CreateResourceConfigurationInput{ + Name: req.Name, Type: req.Type, Protocol: req.Protocol, CustomDomainName: req.CustomDomainName, + GroupDomain: req.GroupDomain, PortRanges: req.PortRanges, Definition: req.Definition, + ResourceGatewayID: req.ResourceGatewayID, ResourceConfigGroupID: req.ResourceConfigGroupID, + AllowAssociationToShared: req.AllowAssociationToShared, Tags: req.Tags, + }) + if err != nil { + writeErr(w, err) + + return + } + + writeJSON(w, resourceConfigToWire(c)) +} + +func (h *Handler) getResourceConfig(w http.ResponseWriter, r *http.Request, id string) { + c, err := h.lattice.GetResourceConfiguration(r.Context(), id) + if err != nil { + writeErr(w, err) + + return + } + + writeJSON(w, resourceConfigToWire(c)) +} + +func (h *Handler) updateResourceConfig(w http.ResponseWriter, r *http.Request, id string) { + var req struct { + PortRanges []string `json:"portRanges"` + Definition json.RawMessage `json:"resourceConfigurationDefinition"` + AllowAssociationToShared bool `json:"allowAssociationToShareableServiceNetwork"` + } + + if !decodeJSON(w, r, &req) { + return + } + + c, err := h.lattice.UpdateResourceConfiguration(r.Context(), &driver.UpdateResourceConfigurationInput{ + ID: id, PortRanges: req.PortRanges, Definition: req.Definition, + AllowAssociationToShared: req.AllowAssociationToShared, + }) + if err != nil { + writeErr(w, err) + + return + } + + writeJSON(w, resourceConfigToWire(c)) +} + +func (h *Handler) deleteResourceConfig(w http.ResponseWriter, r *http.Request, id string) { + if err := h.lattice.DeleteResourceConfiguration(r.Context(), id); err != nil { + writeErr(w, err) + + return + } + + writeJSON(w, struct{}{}) +} + +func (h *Handler) listResourceConfigs(w http.ResponseWriter, r *http.Request) { + cs, err := h.lattice.ListResourceConfigurations(r.Context()) + if err != nil { + writeErr(w, err) + + return + } + + items := make([]wireResourceConfig, 0, len(cs)) + for i := range cs { + items = append(items, resourceConfigToWire(&cs[i])) + } + + writeJSON(w, map[string]any{"items": items}) +} + +// --- resource gateways --- + +type wireResourceGateway struct { + Arn string `json:"arn,omitempty"` + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Status string `json:"status,omitempty"` + IPAddressType string `json:"ipAddressType,omitempty"` + Ipv4AddressesPerEni int32 `json:"ipv4AddressesPerEni,omitempty"` + ResourceConfigDNSResolution string `json:"resourceConfigDnsResolution,omitempty"` + SecurityGroupIDs []string `json:"securityGroupIds,omitempty"` + SubnetIDs []string `json:"subnetIds,omitempty"` + VpcIdentifier string `json:"vpcIdentifier,omitempty"` + CreatedAt string `json:"createdAt,omitempty"` + LastUpdatedAt string `json:"lastUpdatedAt,omitempty"` +} + +func resourceGatewayToWire(g *driver.ResourceGateway) wireResourceGateway { + return wireResourceGateway{ + Arn: g.ARN, ID: g.ID, Name: g.Name, Status: g.Status, IPAddressType: g.IPAddressType, + Ipv4AddressesPerEni: g.Ipv4AddressesPerEni, ResourceConfigDNSResolution: g.ResourceConfigDNSResolution, + SecurityGroupIDs: g.SecurityGroupIDs, SubnetIDs: g.SubnetIDs, VpcIdentifier: g.VpcID, + CreatedAt: g.CreatedAt, LastUpdatedAt: g.LastUpdatedAt, + } +} + +func (h *Handler) serveResourceGateways(w http.ResponseWriter, r *http.Request, rest []string) { + if len(rest) == 0 { + routeCollection(w, r, h.createResourceGateway, h.listResourceGateways) + + return + } + + routeByID(w, r, rest[0], h.getResourceGateway, h.updateResourceGateway, h.deleteResourceGateway) +} + +func (h *Handler) createResourceGateway(w http.ResponseWriter, r *http.Request) { + var req struct { + Name string `json:"name"` + IPAddressType string `json:"ipAddressType"` + Ipv4AddressesPerEni int32 `json:"ipv4AddressesPerEni"` + ResourceConfigDNSResolution string `json:"resourceConfigDnsResolution"` + SecurityGroupIDs []string `json:"securityGroupIds"` + SubnetIDs []string `json:"subnetIds"` + VpcIdentifier string `json:"vpcIdentifier"` + Tags map[string]string `json:"tags"` + } + + if !decodeJSON(w, r, &req) { + return + } + + g, err := h.lattice.CreateResourceGateway(r.Context(), &driver.CreateResourceGatewayInput{ + Name: req.Name, IPAddressType: req.IPAddressType, Ipv4AddressesPerEni: req.Ipv4AddressesPerEni, + ResourceConfigDNSResolution: req.ResourceConfigDNSResolution, SecurityGroupIDs: req.SecurityGroupIDs, + SubnetIDs: req.SubnetIDs, VpcID: req.VpcIdentifier, Tags: req.Tags, + }) + if err != nil { + writeErr(w, err) + + return + } + + writeJSON(w, resourceGatewayToWire(g)) +} + +func (h *Handler) getResourceGateway(w http.ResponseWriter, r *http.Request, id string) { + g, err := h.lattice.GetResourceGateway(r.Context(), id) + if err != nil { + writeErr(w, err) + + return + } + + writeJSON(w, resourceGatewayToWire(g)) +} + +func (h *Handler) updateResourceGateway(w http.ResponseWriter, r *http.Request, id string) { + var req struct { + SecurityGroupIDs []string `json:"securityGroupIds"` + } + + if !decodeJSON(w, r, &req) { + return + } + + g, err := h.lattice.UpdateResourceGateway(r.Context(), id, req.SecurityGroupIDs) + if err != nil { + writeErr(w, err) + + return + } + + writeJSON(w, resourceGatewayToWire(g)) +} + +func (h *Handler) deleteResourceGateway(w http.ResponseWriter, r *http.Request, id string) { + if err := h.lattice.DeleteResourceGateway(r.Context(), id); err != nil { + writeErr(w, err) + + return + } + + writeJSON(w, struct{}{}) +} + +func (h *Handler) listResourceGateways(w http.ResponseWriter, r *http.Request) { + gs, err := h.lattice.ListResourceGateways(r.Context()) + if err != nil { + writeErr(w, err) + + return + } + + items := make([]wireResourceGateway, 0, len(gs)) + for i := range gs { + items = append(items, resourceGatewayToWire(&gs[i])) + } + + writeJSON(w, map[string]any{"items": items}) +} + +// --- resource endpoint associations --- + +func (h *Handler) serveResourceEndpointAssociations(w http.ResponseWriter, r *http.Request, rest []string) { + if len(rest) == 0 { + if r.Method != http.MethodGet { + methodNotAllowed(w) + + return + } + + as, err := h.lattice.ListResourceEndpointAssociations(r.Context()) + if err != nil { + writeErr(w, err) + + return + } + + items := make([]map[string]any, 0, len(as)) + for i := range as { + items = append(items, map[string]any{"id": as[i].ID, "arn": as[i].ARN}) + } + + writeJSON(w, map[string]any{"items": items}) + + return + } + + if r.Method != http.MethodDelete { + methodNotAllowed(w) + + return + } + + if err := h.lattice.DeleteResourceEndpointAssociation(r.Context(), rest[0]); err != nil { + writeErr(w, err) + + return + } + + writeJSON(w, struct{}{}) +} diff --git a/server/aws/vpclattice/rules.go b/server/aws/vpclattice/rules.go new file mode 100644 index 00000000..ab038132 --- /dev/null +++ b/server/aws/vpclattice/rules.go @@ -0,0 +1,189 @@ +package vpclattice + +import ( + "encoding/json" + "net/http" + + "github.com/stackshy/cloudemu/v2/services/vpclattice/driver" +) + +type wireRule struct { + Arn string `json:"arn,omitempty"` + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Priority int32 `json:"priority,omitempty"` + IsDefault bool `json:"isDefault"` + Match json.RawMessage `json:"match,omitempty"` + Action json.RawMessage `json:"action,omitempty"` + CreatedAt string `json:"createdAt,omitempty"` + LastUpdatedAt string `json:"lastUpdatedAt,omitempty"` +} + +func ruleToWire(r *driver.Rule) wireRule { + w := wireRule{ + Arn: r.ARN, ID: r.ID, Name: r.Name, Priority: r.Priority, IsDefault: r.IsDefault, + CreatedAt: r.CreatedAt, LastUpdatedAt: r.LastUpdatedAt, + } + if len(r.Match) > 0 { + w.Match = json.RawMessage(r.Match) + } + + if len(r.Action) > 0 { + w.Action = json.RawMessage(r.Action) + } + + return w +} + +// serveRules routes /services/{sid}/listeners/{lid}/rules[/{id}]. A PATCH on +// the bare collection path is BatchUpdateRule. +func (h *Handler) serveRules(w http.ResponseWriter, r *http.Request, serviceID, listenerID string, rest []string) { + if len(rest) == 0 { + switch r.Method { + case http.MethodPost: + h.createRule(w, r, serviceID, listenerID) + case http.MethodGet: + h.listRules(w, r, serviceID, listenerID) + case http.MethodPatch: + h.batchUpdateRule(w, r, serviceID, listenerID) + default: + methodNotAllowed(w) + } + + return + } + + routeByID(w, r, rest[0], + func(w http.ResponseWriter, r *http.Request, id string) { h.getRule(w, r, serviceID, listenerID, id) }, + func(w http.ResponseWriter, r *http.Request, id string) { h.updateRule(w, r, serviceID, listenerID, id) }, + func(w http.ResponseWriter, r *http.Request, id string) { h.deleteRule(w, r, serviceID, listenerID, id) }) +} + +func (h *Handler) createRule(w http.ResponseWriter, r *http.Request, serviceID, listenerID string) { + var req struct { + Name string `json:"name"` + Priority int32 `json:"priority"` + Match json.RawMessage `json:"match"` + Action json.RawMessage `json:"action"` + Tags map[string]string `json:"tags"` + } + + if !decodeJSON(w, r, &req) { + return + } + + rule, err := h.lattice.CreateRule(r.Context(), &driver.CreateRuleInput{ + ServiceID: serviceID, ListenerID: listenerID, Name: req.Name, Priority: req.Priority, + Match: req.Match, Action: req.Action, Tags: req.Tags, + }) + if err != nil { + writeErr(w, err) + + return + } + + writeJSON(w, ruleToWire(rule)) +} + +func (h *Handler) getRule(w http.ResponseWriter, r *http.Request, serviceID, listenerID, id string) { + rule, err := h.lattice.GetRule(r.Context(), serviceID, listenerID, id) + if err != nil { + writeErr(w, err) + + return + } + + writeJSON(w, ruleToWire(rule)) +} + +func (h *Handler) updateRule(w http.ResponseWriter, r *http.Request, serviceID, listenerID, id string) { + var req struct { + Priority int32 `json:"priority"` + Match json.RawMessage `json:"match"` + Action json.RawMessage `json:"action"` + } + + if !decodeJSON(w, r, &req) { + return + } + + rule, err := h.lattice.UpdateRule(r.Context(), serviceID, listenerID, id, req.Priority, req.Match, req.Action) + if err != nil { + writeErr(w, err) + + return + } + + writeJSON(w, ruleToWire(rule)) +} + +func (h *Handler) deleteRule(w http.ResponseWriter, r *http.Request, serviceID, listenerID, id string) { + if err := h.lattice.DeleteRule(r.Context(), serviceID, listenerID, id); err != nil { + writeErr(w, err) + + return + } + + writeJSON(w, struct{}{}) +} + +func (h *Handler) listRules(w http.ResponseWriter, r *http.Request, serviceID, listenerID string) { + rules, err := h.lattice.ListRules(r.Context(), serviceID, listenerID) + if err != nil { + writeErr(w, err) + + return + } + + items := make([]wireRule, 0, len(rules)) + for i := range rules { + items = append(items, ruleToWire(&rules[i])) + } + + writeJSON(w, map[string]any{"items": items}) +} + +func (h *Handler) batchUpdateRule(w http.ResponseWriter, r *http.Request, serviceID, listenerID string) { + var req struct { + Rules []struct { + RuleIdentifier string `json:"ruleIdentifier"` + Priority int32 `json:"priority"` + Match json.RawMessage `json:"match"` + Action json.RawMessage `json:"action"` + } `json:"rules"` + } + + if !decodeJSON(w, r, &req) { + return + } + + updates := make([]driver.RuleUpdate, 0, len(req.Rules)) + for _, u := range req.Rules { + updates = append(updates, driver.RuleUpdate{ + RuleID: u.RuleIdentifier, Priority: u.Priority, Match: u.Match, Action: u.Action, + }) + } + + ok, fail, err := h.lattice.BatchUpdateRules(r.Context(), serviceID, listenerID, updates) + if err != nil { + writeErr(w, err) + + return + } + + successful := make([]wireRule, 0, len(ok)) + for i := range ok { + successful = append(successful, ruleToWire(&ok[i])) + } + + unsuccessful := make([]map[string]any, 0, len(fail)) + for i := range fail { + unsuccessful = append(unsuccessful, map[string]any{ + "ruleIdentifier": fail[i].RuleID, + "failureCode": fail[i].FailureCode, + "failureMessage": fail[i].FailureMessage, + }) + } + + writeJSON(w, map[string]any{"successful": successful, "unsuccessful": unsuccessful}) +} diff --git a/server/aws/vpclattice/sdk_roundtrip_test.go b/server/aws/vpclattice/sdk_roundtrip_test.go new file mode 100644 index 00000000..66a31a0f --- /dev/null +++ b/server/aws/vpclattice/sdk_roundtrip_test.go @@ -0,0 +1,620 @@ +package vpclattice_test + +import ( + "context" + "errors" + "net/http/httptest" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + awsconfig "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" + awsvpcl "github.com/aws/aws-sdk-go-v2/service/vpclattice" + vpcltypes "github.com/aws/aws-sdk-go-v2/service/vpclattice/types" + + "github.com/stackshy/cloudemu/v2" + awsserver "github.com/stackshy/cloudemu/v2/server/aws" +) + +// newClient builds an httptest-backed VPC Lattice client driving the real +// aws-sdk-go-v2 client against the in-memory driver. +func newClient(t *testing.T) *awsvpcl.Client { + t.Helper() + + cloud := cloudemu.NewAWS() + srv := awsserver.New(awsserver.Drivers{VPCLattice: cloud.VPCLattice}) + + ts := httptest.NewServer(srv) + t.Cleanup(ts.Close) + + cfg, err := awsconfig.LoadDefaultConfig(context.Background(), + awsconfig.WithRegion("us-east-1"), + awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider("test", "test", "")), + ) + if err != nil { + t.Fatalf("aws config: %v", err) + } + + return awsvpcl.NewFromConfig(cfg, func(o *awsvpcl.Options) { + o.BaseEndpoint = aws.String(ts.URL) + }) +} + +func TestSDKServiceNetworkLifecycle(t *testing.T) { + client := newClient(t) + ctx := context.Background() + + created, err := client.CreateServiceNetwork(ctx, &awsvpcl.CreateServiceNetworkInput{ + Name: aws.String("sn-app"), + }) + if err != nil { + t.Fatalf("CreateServiceNetwork: %v", err) + } + + id := aws.ToString(created.Id) + if id == "" || aws.ToString(created.Arn) == "" { + t.Fatalf("CreateServiceNetwork: empty id/arn %+v", created) + } + + got, err := client.GetServiceNetwork(ctx, &awsvpcl.GetServiceNetworkInput{ + ServiceNetworkIdentifier: aws.String(id), + }) + if err != nil || aws.ToString(got.Name) != "sn-app" { + t.Fatalf("GetServiceNetwork: %v %+v", err, got) + } + + upd, err := client.UpdateServiceNetwork(ctx, &awsvpcl.UpdateServiceNetworkInput{ + ServiceNetworkIdentifier: aws.String(id), + AuthType: vpcltypes.AuthTypeAwsIam, + }) + if err != nil || upd.AuthType != vpcltypes.AuthTypeAwsIam { + t.Fatalf("UpdateServiceNetwork: %v %+v", err, upd) + } + + list, err := client.ListServiceNetworks(ctx, &awsvpcl.ListServiceNetworksInput{}) + if err != nil || len(list.Items) != 1 || aws.ToString(list.Items[0].Id) != id { + t.Fatalf("ListServiceNetworks: %v %+v", err, list.Items) + } + + if _, err = client.DeleteServiceNetwork(ctx, &awsvpcl.DeleteServiceNetworkInput{ + ServiceNetworkIdentifier: aws.String(id), + }); err != nil { + t.Fatalf("DeleteServiceNetwork: %v", err) + } + + _, err = client.GetServiceNetwork(ctx, &awsvpcl.GetServiceNetworkInput{ + ServiceNetworkIdentifier: aws.String(id), + }) + var nfe *vpcltypes.ResourceNotFoundException + if !errors.As(err, &nfe) { + t.Fatalf("expected ResourceNotFoundException after delete, got %v", err) + } +} + +func TestSDKServiceLifecycle(t *testing.T) { + client := newClient(t) + ctx := context.Background() + + created, err := client.CreateService(ctx, &awsvpcl.CreateServiceInput{ + Name: aws.String("svc-web"), + CustomDomainName: aws.String("web.example.com"), + }) + if err != nil { + t.Fatalf("CreateService: %v", err) + } + + id := aws.ToString(created.Id) + if created.Status != vpcltypes.ServiceStatusActive || created.DnsEntry == nil { + t.Fatalf("CreateService: status/dns %+v", created) + } + + got, err := client.GetService(ctx, &awsvpcl.GetServiceInput{ServiceIdentifier: aws.String(id)}) + if err != nil || aws.ToString(got.Name) != "svc-web" || aws.ToInt32(got.IdleTimeoutSeconds) != 60 { + t.Fatalf("GetService: %v %+v", err, got) + } + + upd, err := client.UpdateService(ctx, &awsvpcl.UpdateServiceInput{ + ServiceIdentifier: aws.String(id), + AuthType: vpcltypes.AuthTypeAwsIam, + IdleTimeoutSeconds: aws.Int32(120), + }) + if err != nil || upd.AuthType != vpcltypes.AuthTypeAwsIam || aws.ToInt32(upd.IdleTimeoutSeconds) != 120 { + t.Fatalf("UpdateService: %v %+v", err, upd) + } + + list, err := client.ListServices(ctx, &awsvpcl.ListServicesInput{}) + if err != nil || len(list.Items) != 1 { + t.Fatalf("ListServices: %v %+v", err, list.Items) + } + + if _, err = client.DeleteService(ctx, &awsvpcl.DeleteServiceInput{ServiceIdentifier: aws.String(id)}); err != nil { + t.Fatalf("DeleteService: %v", err) + } + + _, err = client.GetService(ctx, &awsvpcl.GetServiceInput{ServiceIdentifier: aws.String(id)}) + var nfe *vpcltypes.ResourceNotFoundException + if !errors.As(err, &nfe) { + t.Fatalf("expected ResourceNotFoundException, got %v", err) + } +} + +func TestSDKListenerLifecycle(t *testing.T) { + client := newClient(t) + ctx := context.Background() + + svc, err := client.CreateService(ctx, &awsvpcl.CreateServiceInput{Name: aws.String("svc-l")}) + if err != nil { + t.Fatalf("CreateService: %v", err) + } + sid := aws.ToString(svc.Id) + + created, err := client.CreateListener(ctx, &awsvpcl.CreateListenerInput{ + ServiceIdentifier: aws.String(sid), + Name: aws.String("http"), + Protocol: vpcltypes.ListenerProtocolHttp, + Port: aws.Int32(80), + DefaultAction: &vpcltypes.RuleActionMemberFixedResponse{ + Value: vpcltypes.FixedResponseAction{StatusCode: aws.Int32(404)}, + }, + }) + if err != nil { + t.Fatalf("CreateListener: %v", err) + } + lid := aws.ToString(created.Id) + + got, err := client.GetListener(ctx, &awsvpcl.GetListenerInput{ + ServiceIdentifier: aws.String(sid), ListenerIdentifier: aws.String(lid), + }) + if err != nil || aws.ToInt32(got.Port) != 80 || got.Protocol != vpcltypes.ListenerProtocolHttp { + t.Fatalf("GetListener: %v %+v", err, got) + } + fr, ok := got.DefaultAction.(*vpcltypes.RuleActionMemberFixedResponse) + if !ok || aws.ToInt32(fr.Value.StatusCode) != 404 { + t.Fatalf("GetListener defaultAction round-trip: %+v", got.DefaultAction) + } + + if _, err = client.UpdateListener(ctx, &awsvpcl.UpdateListenerInput{ + ServiceIdentifier: aws.String(sid), ListenerIdentifier: aws.String(lid), + DefaultAction: &vpcltypes.RuleActionMemberFixedResponse{ + Value: vpcltypes.FixedResponseAction{StatusCode: aws.Int32(500)}, + }, + }); err != nil { + t.Fatalf("UpdateListener: %v", err) + } + + list, err := client.ListListeners(ctx, &awsvpcl.ListListenersInput{ServiceIdentifier: aws.String(sid)}) + if err != nil || len(list.Items) != 1 { + t.Fatalf("ListListeners: %v %+v", err, list.Items) + } + + if _, err = client.DeleteListener(ctx, &awsvpcl.DeleteListenerInput{ + ServiceIdentifier: aws.String(sid), ListenerIdentifier: aws.String(lid), + }); err != nil { + t.Fatalf("DeleteListener: %v", err) + } + + // Listener scoped to unknown service → ResourceNotFoundException. + _, err = client.GetListener(ctx, &awsvpcl.GetListenerInput{ + ServiceIdentifier: aws.String(sid), ListenerIdentifier: aws.String(lid), + }) + var nfe *vpcltypes.ResourceNotFoundException + if !errors.As(err, &nfe) { + t.Fatalf("expected ResourceNotFoundException, got %v", err) + } +} + +func TestSDKRuleLifecycle(t *testing.T) { + client := newClient(t) + ctx := context.Background() + + svc, err := client.CreateService(ctx, &awsvpcl.CreateServiceInput{Name: aws.String("svc-r")}) + if err != nil { + t.Fatalf("CreateService: %v", err) + } + sid := aws.ToString(svc.Id) + + ln, err := client.CreateListener(ctx, &awsvpcl.CreateListenerInput{ + ServiceIdentifier: aws.String(sid), Name: aws.String("http"), + Protocol: vpcltypes.ListenerProtocolHttp, Port: aws.Int32(80), + DefaultAction: &vpcltypes.RuleActionMemberFixedResponse{ + Value: vpcltypes.FixedResponseAction{StatusCode: aws.Int32(404)}, + }, + }) + if err != nil { + t.Fatalf("CreateListener: %v", err) + } + lid := aws.ToString(ln.Id) + + match := &vpcltypes.RuleMatchMemberHttpMatch{Value: vpcltypes.HttpMatch{ + PathMatch: &vpcltypes.PathMatch{Match: &vpcltypes.PathMatchTypeMemberExact{Value: "/api"}}, + }} + action := &vpcltypes.RuleActionMemberFixedResponse{Value: vpcltypes.FixedResponseAction{StatusCode: aws.Int32(200)}} + + created, err := client.CreateRule(ctx, &awsvpcl.CreateRuleInput{ + ServiceIdentifier: aws.String(sid), ListenerIdentifier: aws.String(lid), + Name: aws.String("r1"), Priority: aws.Int32(10), Match: match, Action: action, + }) + if err != nil { + t.Fatalf("CreateRule: %v", err) + } + rid := aws.ToString(created.Id) + + got, err := client.GetRule(ctx, &awsvpcl.GetRuleInput{ + ServiceIdentifier: aws.String(sid), ListenerIdentifier: aws.String(lid), RuleIdentifier: aws.String(rid), + }) + if err != nil || aws.ToInt32(got.Priority) != 10 { + t.Fatalf("GetRule: %v %+v", err, got) + } + gm, ok := got.Match.(*vpcltypes.RuleMatchMemberHttpMatch) + if !ok || gm.Value.PathMatch == nil { + t.Fatalf("GetRule match round-trip: %+v", got.Match) + } + + if _, err = client.UpdateRule(ctx, &awsvpcl.UpdateRuleInput{ + ServiceIdentifier: aws.String(sid), ListenerIdentifier: aws.String(lid), RuleIdentifier: aws.String(rid), + Priority: aws.Int32(20), + }); err != nil { + t.Fatalf("UpdateRule: %v", err) + } + + list, err := client.ListRules(ctx, &awsvpcl.ListRulesInput{ + ServiceIdentifier: aws.String(sid), ListenerIdentifier: aws.String(lid), + }) + if err != nil || len(list.Items) != 1 { + t.Fatalf("ListRules: %v %+v", err, list.Items) + } + + // BatchUpdateRule: one success, one failure (unknown id). + bur, err := client.BatchUpdateRule(ctx, &awsvpcl.BatchUpdateRuleInput{ + ServiceIdentifier: aws.String(sid), ListenerIdentifier: aws.String(lid), + Rules: []vpcltypes.RuleUpdate{ + {RuleIdentifier: aws.String(rid), Priority: aws.Int32(30)}, + {RuleIdentifier: aws.String("rule-missing"), Priority: aws.Int32(40)}, + }, + }) + if err != nil || len(bur.Successful) != 1 || len(bur.Unsuccessful) != 1 { + t.Fatalf("BatchUpdateRule: %v succ=%+v fail=%+v", err, bur.Successful, bur.Unsuccessful) + } + + if _, err = client.DeleteRule(ctx, &awsvpcl.DeleteRuleInput{ + ServiceIdentifier: aws.String(sid), ListenerIdentifier: aws.String(lid), RuleIdentifier: aws.String(rid), + }); err != nil { + t.Fatalf("DeleteRule: %v", err) + } +} + +func TestSDKTargetGroupAndTargets(t *testing.T) { + client := newClient(t) + ctx := context.Background() + + created, err := client.CreateTargetGroup(ctx, &awsvpcl.CreateTargetGroupInput{ + Name: aws.String("tg-1"), + Type: vpcltypes.TargetGroupTypeIp, + Config: &vpcltypes.TargetGroupConfig{ + Port: aws.Int32(443), + Protocol: vpcltypes.TargetGroupProtocolHttps, + VpcIdentifier: aws.String("vpc-123"), + }, + }) + if err != nil || created.Config == nil { + t.Fatalf("CreateTargetGroup: %v %+v", err, created) + } + id := aws.ToString(created.Id) + + got, err := client.GetTargetGroup(ctx, &awsvpcl.GetTargetGroupInput{TargetGroupIdentifier: aws.String(id)}) + if err != nil || aws.ToInt32(got.Config.Port) != 443 || got.Config.Protocol != vpcltypes.TargetGroupProtocolHttps { + t.Fatalf("GetTargetGroup config round-trip: %v %+v", err, got) + } + + if _, err = client.UpdateTargetGroup(ctx, &awsvpcl.UpdateTargetGroupInput{ + TargetGroupIdentifier: aws.String(id), + HealthCheck: &vpcltypes.HealthCheckConfig{Enabled: aws.Bool(true)}, + }); err != nil { + t.Fatalf("UpdateTargetGroup: %v", err) + } + + reg, err := client.RegisterTargets(ctx, &awsvpcl.RegisterTargetsInput{ + TargetGroupIdentifier: aws.String(id), + Targets: []vpcltypes.Target{ + {Id: aws.String("10.0.0.1"), Port: aws.Int32(443)}, + {Id: aws.String("10.0.0.2"), Port: aws.Int32(443)}, + }, + }) + if err != nil || len(reg.Successful) != 2 { + t.Fatalf("RegisterTargets: %v %+v", err, reg.Successful) + } + + lt, err := client.ListTargets(ctx, &awsvpcl.ListTargetsInput{TargetGroupIdentifier: aws.String(id)}) + if err != nil || len(lt.Items) != 2 { + t.Fatalf("ListTargets: %v %+v", err, lt.Items) + } + + if _, err = client.DeregisterTargets(ctx, &awsvpcl.DeregisterTargetsInput{ + TargetGroupIdentifier: aws.String(id), + Targets: []vpcltypes.Target{{Id: aws.String("10.0.0.1"), Port: aws.Int32(443)}}, + }); err != nil { + t.Fatalf("DeregisterTargets: %v", err) + } + + lt, err = client.ListTargets(ctx, &awsvpcl.ListTargetsInput{TargetGroupIdentifier: aws.String(id)}) + if err != nil || len(lt.Items) != 1 { + t.Fatalf("ListTargets after deregister: %v %+v", err, lt.Items) + } + + tgs, err := client.ListTargetGroups(ctx, &awsvpcl.ListTargetGroupsInput{}) + if err != nil || len(tgs.Items) != 1 || aws.ToInt32(tgs.Items[0].Port) != 443 { + t.Fatalf("ListTargetGroups summary: %v %+v", err, tgs.Items) + } + + if _, err = client.DeleteTargetGroup(ctx, &awsvpcl.DeleteTargetGroupInput{TargetGroupIdentifier: aws.String(id)}); err != nil { + t.Fatalf("DeleteTargetGroup: %v", err) + } +} + +func TestSDKAssociations(t *testing.T) { + client := newClient(t) + ctx := context.Background() + + sn, err := client.CreateServiceNetwork(ctx, &awsvpcl.CreateServiceNetworkInput{Name: aws.String("sn-a")}) + if err != nil { + t.Fatalf("CreateServiceNetwork: %v", err) + } + snID := aws.ToString(sn.Id) + + svc, err := client.CreateService(ctx, &awsvpcl.CreateServiceInput{Name: aws.String("svc-a")}) + if err != nil { + t.Fatalf("CreateService: %v", err) + } + svcID := aws.ToString(svc.Id) + + // SN ↔ VPC + vpcA, err := client.CreateServiceNetworkVpcAssociation(ctx, &awsvpcl.CreateServiceNetworkVpcAssociationInput{ + ServiceNetworkIdentifier: aws.String(snID), + VpcIdentifier: aws.String("vpc-1"), + SecurityGroupIds: []string{"sg-1"}, + }) + if err != nil { + t.Fatalf("CreateSNVpcAssociation: %v", err) + } + vpcAID := aws.ToString(vpcA.Id) + + if _, err = client.UpdateServiceNetworkVpcAssociation(ctx, &awsvpcl.UpdateServiceNetworkVpcAssociationInput{ + ServiceNetworkVpcAssociationIdentifier: aws.String(vpcAID), + SecurityGroupIds: []string{"sg-1", "sg-2"}, + }); err != nil { + t.Fatalf("UpdateSNVpcAssociation: %v", err) + } + + gotVpc, err := client.GetServiceNetworkVpcAssociation(ctx, &awsvpcl.GetServiceNetworkVpcAssociationInput{ + ServiceNetworkVpcAssociationIdentifier: aws.String(vpcAID), + }) + if err != nil || aws.ToString(gotVpc.VpcId) != "vpc-1" || len(gotVpc.SecurityGroupIds) != 2 { + t.Fatalf("GetSNVpcAssociation: %v %+v", err, gotVpc) + } + + // SN ↔ Service + svcA, err := client.CreateServiceNetworkServiceAssociation(ctx, &awsvpcl.CreateServiceNetworkServiceAssociationInput{ + ServiceNetworkIdentifier: aws.String(snID), ServiceIdentifier: aws.String(svcID), + }) + if err != nil { + t.Fatalf("CreateSNServiceAssociation: %v", err) + } + + // SN ↔ Resource + resA, err := client.CreateServiceNetworkResourceAssociation(ctx, &awsvpcl.CreateServiceNetworkResourceAssociationInput{ + ServiceNetworkIdentifier: aws.String(snID), + ResourceConfigurationIdentifier: aws.String("rcfg-1"), + }) + if err != nil { + t.Fatalf("CreateSNResourceAssociation: %v", err) + } + + // Counts reflected on the service network. + gotSN, err := client.GetServiceNetwork(ctx, &awsvpcl.GetServiceNetworkInput{ServiceNetworkIdentifier: aws.String(snID)}) + if err != nil || aws.ToInt64(gotSN.NumberOfAssociatedServices) != 1 || aws.ToInt64(gotSN.NumberOfAssociatedVPCs) != 1 { + t.Fatalf("GetServiceNetwork counts: %v svc=%d vpc=%d", err, + aws.ToInt64(gotSN.NumberOfAssociatedServices), aws.ToInt64(gotSN.NumberOfAssociatedVPCs)) + } + + if lv, e := client.ListServiceNetworkVpcAssociations(ctx, &awsvpcl.ListServiceNetworkVpcAssociationsInput{}); e != nil || len(lv.Items) != 1 { + t.Fatalf("ListSNVpcAssociations: %v %+v", e, lv) + } + if _, e := client.ListServiceNetworkVpcEndpointAssociations(ctx, &awsvpcl.ListServiceNetworkVpcEndpointAssociationsInput{ServiceNetworkIdentifier: aws.String(snID)}); e != nil { + t.Fatalf("ListSNVpcEndpointAssociations: %v", e) + } + if ls, e := client.ListServiceNetworkServiceAssociations(ctx, &awsvpcl.ListServiceNetworkServiceAssociationsInput{}); e != nil || len(ls.Items) != 1 { + t.Fatalf("ListSNServiceAssociations: %v %+v", e, ls) + } + if lr, e := client.ListServiceNetworkResourceAssociations(ctx, &awsvpcl.ListServiceNetworkResourceAssociationsInput{}); e != nil || len(lr.Items) != 1 { + t.Fatalf("ListSNResourceAssociations: %v %+v", e, lr) + } + + // Deletes. + if _, e := client.DeleteServiceNetworkVpcAssociation(ctx, &awsvpcl.DeleteServiceNetworkVpcAssociationInput{ServiceNetworkVpcAssociationIdentifier: aws.String(vpcAID)}); e != nil { + t.Fatalf("DeleteSNVpcAssociation: %v", e) + } + if _, e := client.DeleteServiceNetworkServiceAssociation(ctx, &awsvpcl.DeleteServiceNetworkServiceAssociationInput{ServiceNetworkServiceAssociationIdentifier: svcA.Id}); e != nil { + t.Fatalf("DeleteSNServiceAssociation: %v", e) + } + if _, e := client.DeleteServiceNetworkResourceAssociation(ctx, &awsvpcl.DeleteServiceNetworkResourceAssociationInput{ServiceNetworkResourceAssociationIdentifier: resA.Id}); e != nil { + t.Fatalf("DeleteSNResourceAssociation: %v", e) + } +} + +func TestSDKResourceConfigGatewayEndpoint(t *testing.T) { + client := newClient(t) + ctx := context.Background() + + gw, err := client.CreateResourceGateway(ctx, &awsvpcl.CreateResourceGatewayInput{ + Name: aws.String("rgw-1"), + VpcIdentifier: aws.String("vpc-1"), + SubnetIds: []string{"subnet-1"}, + SecurityGroupIds: []string{"sg-1"}, + IpAddressType: vpcltypes.ResourceGatewayIpAddressTypeIpv4, + }) + if err != nil { + t.Fatalf("CreateResourceGateway: %v", err) + } + gwID := aws.ToString(gw.Id) + + if _, err = client.UpdateResourceGateway(ctx, &awsvpcl.UpdateResourceGatewayInput{ + ResourceGatewayIdentifier: aws.String(gwID), SecurityGroupIds: []string{"sg-1", "sg-2"}, + }); err != nil { + t.Fatalf("UpdateResourceGateway: %v", err) + } + + gotGw, err := client.GetResourceGateway(ctx, &awsvpcl.GetResourceGatewayInput{ResourceGatewayIdentifier: aws.String(gwID)}) + if err != nil || len(gotGw.SecurityGroupIds) != 2 { + t.Fatalf("GetResourceGateway: %v %+v", err, gotGw) + } + + rc, err := client.CreateResourceConfiguration(ctx, &awsvpcl.CreateResourceConfigurationInput{ + Name: aws.String("rc-1"), + Type: vpcltypes.ResourceConfigurationTypeSingle, + Protocol: vpcltypes.ProtocolTypeTcp, + PortRanges: []string{"443"}, + ResourceGatewayIdentifier: aws.String(gwID), + ResourceConfigurationDefinition: &vpcltypes.ResourceConfigurationDefinitionMemberIpResource{ + Value: vpcltypes.IpResource{IpAddress: aws.String("10.0.0.9")}, + }, + }) + if err != nil { + t.Fatalf("CreateResourceConfiguration: %v", err) + } + rcID := aws.ToString(rc.Id) + + gotRc, err := client.GetResourceConfiguration(ctx, &awsvpcl.GetResourceConfigurationInput{ + ResourceConfigurationIdentifier: aws.String(rcID), + }) + if err != nil || gotRc.Type != vpcltypes.ResourceConfigurationTypeSingle { + t.Fatalf("GetResourceConfiguration: %v %+v", err, gotRc) + } + def, ok := gotRc.ResourceConfigurationDefinition.(*vpcltypes.ResourceConfigurationDefinitionMemberIpResource) + if !ok || aws.ToString(def.Value.IpAddress) != "10.0.0.9" { + t.Fatalf("ResourceConfigurationDefinition round-trip: %+v", gotRc.ResourceConfigurationDefinition) + } + + if _, err = client.UpdateResourceConfiguration(ctx, &awsvpcl.UpdateResourceConfigurationInput{ + ResourceConfigurationIdentifier: aws.String(rcID), PortRanges: []string{"443", "8443"}, + }); err != nil { + t.Fatalf("UpdateResourceConfiguration: %v", err) + } + + if lc, e := client.ListResourceConfigurations(ctx, &awsvpcl.ListResourceConfigurationsInput{}); e != nil || len(lc.Items) != 1 { + t.Fatalf("ListResourceConfigurations: %v %+v", e, lc) + } + if lg, e := client.ListResourceGateways(ctx, &awsvpcl.ListResourceGatewaysInput{}); e != nil || len(lg.Items) != 1 { + t.Fatalf("ListResourceGateways: %v %+v", e, lg) + } + if le, e := client.ListResourceEndpointAssociations(ctx, &awsvpcl.ListResourceEndpointAssociationsInput{ResourceConfigurationIdentifier: aws.String(rcID)}); e != nil || len(le.Items) != 0 { + t.Fatalf("ListResourceEndpointAssociations: %v %+v", e, le) + } + + if _, e := client.DeleteResourceConfiguration(ctx, &awsvpcl.DeleteResourceConfigurationInput{ResourceConfigurationIdentifier: aws.String(rcID)}); e != nil { + t.Fatalf("DeleteResourceConfiguration: %v", e) + } + if _, e := client.DeleteResourceGateway(ctx, &awsvpcl.DeleteResourceGatewayInput{ResourceGatewayIdentifier: aws.String(gwID)}); e != nil { + t.Fatalf("DeleteResourceGateway: %v", e) + } +} + +func TestSDKAccessLogsPoliciesDomainsTags(t *testing.T) { + client := newClient(t) + ctx := context.Background() + + sn, err := client.CreateServiceNetwork(ctx, &awsvpcl.CreateServiceNetworkInput{Name: aws.String("sn-x")}) + if err != nil { + t.Fatalf("CreateServiceNetwork: %v", err) + } + snArn := aws.ToString(sn.Arn) + + // Access-log subscription + als, err := client.CreateAccessLogSubscription(ctx, &awsvpcl.CreateAccessLogSubscriptionInput{ + ResourceIdentifier: aws.String(snArn), + DestinationArn: aws.String("arn:aws:logs:us-east-1:123456789012:log-group:lattice"), + }) + if err != nil { + t.Fatalf("CreateAccessLogSubscription: %v", err) + } + alsID := aws.ToString(als.Id) + if _, err = client.UpdateAccessLogSubscription(ctx, &awsvpcl.UpdateAccessLogSubscriptionInput{ + AccessLogSubscriptionIdentifier: aws.String(alsID), + DestinationArn: aws.String("arn:aws:s3:::lattice-logs"), + }); err != nil { + t.Fatalf("UpdateAccessLogSubscription: %v", err) + } + if _, err = client.GetAccessLogSubscription(ctx, &awsvpcl.GetAccessLogSubscriptionInput{AccessLogSubscriptionIdentifier: aws.String(alsID)}); err != nil { + t.Fatalf("GetAccessLogSubscription: %v", err) + } + if la, e := client.ListAccessLogSubscriptions(ctx, &awsvpcl.ListAccessLogSubscriptionsInput{ResourceIdentifier: aws.String(snArn)}); e != nil || len(la.Items) != 1 { + t.Fatalf("ListAccessLogSubscriptions: %v %+v", e, la) + } + + // Auth policy (keyed by service-network ARN) + pa, err := client.PutAuthPolicy(ctx, &awsvpcl.PutAuthPolicyInput{ + ResourceIdentifier: aws.String(snArn), Policy: aws.String(`{"Version":"2012-10-17"}`), + }) + if err != nil || pa.State != vpcltypes.AuthPolicyStateActive { + t.Fatalf("PutAuthPolicy: %v %+v", err, pa) + } + ga, err := client.GetAuthPolicy(ctx, &awsvpcl.GetAuthPolicyInput{ResourceIdentifier: aws.String(snArn)}) + if err != nil || aws.ToString(ga.Policy) != `{"Version":"2012-10-17"}` { + t.Fatalf("GetAuthPolicy: %v %+v", err, ga) + } + + // Resource policy + if _, err = client.PutResourcePolicy(ctx, &awsvpcl.PutResourcePolicyInput{ + ResourceArn: aws.String(snArn), Policy: aws.String(`{"rp":true}`), + }); err != nil { + t.Fatalf("PutResourcePolicy: %v", err) + } + gr, err := client.GetResourcePolicy(ctx, &awsvpcl.GetResourcePolicyInput{ResourceArn: aws.String(snArn)}) + if err != nil || aws.ToString(gr.Policy) != `{"rp":true}` { + t.Fatalf("GetResourcePolicy: %v %+v", err, gr) + } + + // Domain verification + dv, err := client.StartDomainVerification(ctx, &awsvpcl.StartDomainVerificationInput{DomainName: aws.String("example.com")}) + if err != nil || dv.Status != vpcltypes.VerificationStatusPending { + t.Fatalf("StartDomainVerification: %v %+v", err, dv) + } + dvID := aws.ToString(dv.Id) + if _, err = client.GetDomainVerification(ctx, &awsvpcl.GetDomainVerificationInput{DomainVerificationIdentifier: aws.String(dvID)}); err != nil { + t.Fatalf("GetDomainVerification: %v", err) + } + if ld, e := client.ListDomainVerifications(ctx, &awsvpcl.ListDomainVerificationsInput{}); e != nil || len(ld.Items) != 1 { + t.Fatalf("ListDomainVerifications: %v %+v", e, ld) + } + + // Tagging (ARN contains slashes) + if _, err = client.TagResource(ctx, &awsvpcl.TagResourceInput{ + ResourceArn: aws.String(snArn), Tags: map[string]string{"team": "net", "env": "test"}, + }); err != nil { + t.Fatalf("TagResource: %v", err) + } + lt, err := client.ListTagsForResource(ctx, &awsvpcl.ListTagsForResourceInput{ResourceArn: aws.String(snArn)}) + if err != nil || len(lt.Tags) != 2 { + t.Fatalf("ListTagsForResource: %v %+v", err, lt.Tags) + } + if _, err = client.UntagResource(ctx, &awsvpcl.UntagResourceInput{ResourceArn: aws.String(snArn), TagKeys: []string{"team"}}); err != nil { + t.Fatalf("UntagResource: %v", err) + } + lt, err = client.ListTagsForResource(ctx, &awsvpcl.ListTagsForResourceInput{ResourceArn: aws.String(snArn)}) + if err != nil || len(lt.Tags) != 1 { + t.Fatalf("ListTagsForResource after untag: %v %+v", err, lt.Tags) + } + + // Deletes. + if _, e := client.DeleteAccessLogSubscription(ctx, &awsvpcl.DeleteAccessLogSubscriptionInput{AccessLogSubscriptionIdentifier: aws.String(alsID)}); e != nil { + t.Fatalf("DeleteAccessLogSubscription: %v", e) + } + if _, e := client.DeleteAuthPolicy(ctx, &awsvpcl.DeleteAuthPolicyInput{ResourceIdentifier: aws.String(snArn)}); e != nil { + t.Fatalf("DeleteAuthPolicy: %v", e) + } + if _, e := client.DeleteResourcePolicy(ctx, &awsvpcl.DeleteResourcePolicyInput{ResourceArn: aws.String(snArn)}); e != nil { + t.Fatalf("DeleteResourcePolicy: %v", e) + } + if _, e := client.DeleteDomainVerification(ctx, &awsvpcl.DeleteDomainVerificationInput{DomainVerificationIdentifier: aws.String(dvID)}); e != nil { + t.Fatalf("DeleteDomainVerification: %v", e) + } +} diff --git a/server/aws/vpclattice/servicenetworks.go b/server/aws/vpclattice/servicenetworks.go new file mode 100644 index 00000000..4912c20f --- /dev/null +++ b/server/aws/vpclattice/servicenetworks.go @@ -0,0 +1,135 @@ +package vpclattice + +import ( + "net/http" + + "github.com/stackshy/cloudemu/v2/services/vpclattice/driver" +) + +// --- wire shapes (camelCase member names per the SDK) --- + +type wireSharingConfig struct { + Enabled bool `json:"enabled"` +} + +type wireServiceNetwork struct { + Arn string `json:"arn,omitempty"` + AuthType string `json:"authType,omitempty"` + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + SharingConfig *wireSharingConfig `json:"sharingConfig,omitempty"` + CreatedAt string `json:"createdAt,omitempty"` + LastUpdatedAt string `json:"lastUpdatedAt,omitempty"` + NumberOfAssociatedServices int64 `json:"numberOfAssociatedServices"` + NumberOfAssociatedVPCs int64 `json:"numberOfAssociatedVPCs"` + NumberOfAssociatedResourceConfigurations int64 `json:"numberOfAssociatedResourceConfigurations"` +} + +func serviceNetworkToWire(s *driver.ServiceNetwork) wireServiceNetwork { + return wireServiceNetwork{ + Arn: s.ARN, + AuthType: s.AuthType, + ID: s.ID, + Name: s.Name, + SharingConfig: &wireSharingConfig{Enabled: s.SharingConfigEnabled}, + CreatedAt: s.CreatedAt, + LastUpdatedAt: s.LastUpdatedAt, + NumberOfAssociatedServices: s.NumberOfAssociatedServices, + NumberOfAssociatedVPCs: s.NumberOfAssociatedVPCs, + NumberOfAssociatedResourceConfigurations: s.NumberOfAssociatedResourceConfigurations, + } +} + +// serveServiceNetworks routes /servicenetworks[/{id}]. +func (h *Handler) serveServiceNetworks(w http.ResponseWriter, r *http.Request, rest []string) { + if len(rest) == 0 { + routeCollection(w, r, h.createServiceNetwork, h.listServiceNetworks) + + return + } + + routeByID(w, r, rest[0], h.getServiceNetwork, h.updateServiceNetwork, h.deleteServiceNetwork) +} + +func (h *Handler) createServiceNetwork(w http.ResponseWriter, r *http.Request) { + var req struct { + Name string `json:"name"` + AuthType string `json:"authType"` + SharingConfig *wireSharingConfig `json:"sharingConfig"` + Tags map[string]string `json:"tags"` + } + + if !decodeJSON(w, r, &req) { + return + } + + in := &driver.CreateServiceNetworkInput{Name: req.Name, AuthType: req.AuthType, Tags: req.Tags} + if req.SharingConfig != nil { + in.SharingConfigEnabled = req.SharingConfig.Enabled + } + + sn, err := h.lattice.CreateServiceNetwork(r.Context(), in) + if err != nil { + writeErr(w, err) + + return + } + + writeJSON(w, serviceNetworkToWire(sn)) +} + +func (h *Handler) getServiceNetwork(w http.ResponseWriter, r *http.Request, id string) { + sn, err := h.lattice.GetServiceNetwork(r.Context(), id) + if err != nil { + writeErr(w, err) + + return + } + + writeJSON(w, serviceNetworkToWire(sn)) +} + +func (h *Handler) updateServiceNetwork(w http.ResponseWriter, r *http.Request, id string) { + var req struct { + AuthType string `json:"authType"` + } + + if !decodeJSON(w, r, &req) { + return + } + + sn, err := h.lattice.UpdateServiceNetwork(r.Context(), id, req.AuthType) + if err != nil { + writeErr(w, err) + + return + } + + writeJSON(w, serviceNetworkToWire(sn)) +} + +func (h *Handler) deleteServiceNetwork(w http.ResponseWriter, r *http.Request, id string) { + if err := h.lattice.DeleteServiceNetwork(r.Context(), id); err != nil { + writeErr(w, err) + + return + } + + writeJSON(w, struct{}{}) +} + +func (h *Handler) listServiceNetworks(w http.ResponseWriter, r *http.Request) { + sns, err := h.lattice.ListServiceNetworks(r.Context()) + if err != nil { + writeErr(w, err) + + return + } + + items := make([]wireServiceNetwork, 0, len(sns)) + for i := range sns { + items = append(items, serviceNetworkToWire(&sns[i])) + } + + writeJSON(w, map[string]any{"items": items}) +} diff --git a/server/aws/vpclattice/services.go b/server/aws/vpclattice/services.go new file mode 100644 index 00000000..f5edc5df --- /dev/null +++ b/server/aws/vpclattice/services.go @@ -0,0 +1,150 @@ +package vpclattice + +import ( + "net/http" + + "github.com/stackshy/cloudemu/v2/services/vpclattice/driver" +) + +type wireDNSEntry struct { + DomainName string `json:"domainName,omitempty"` + HostedZoneID string `json:"hostedZoneId,omitempty"` +} + +type wireService struct { + Arn string `json:"arn,omitempty"` + AuthType string `json:"authType,omitempty"` + CertificateArn string `json:"certificateArn,omitempty"` + CustomDomainName string `json:"customDomainName,omitempty"` + DNSEntry *wireDNSEntry `json:"dnsEntry,omitempty"` + ID string `json:"id,omitempty"` + IdleTimeoutSeconds int32 `json:"idleTimeoutSeconds"` + Name string `json:"name,omitempty"` + Status string `json:"status,omitempty"` + CreatedAt string `json:"createdAt,omitempty"` + LastUpdatedAt string `json:"lastUpdatedAt,omitempty"` +} + +func serviceToWire(s *driver.Service) wireService { + w := wireService{ + Arn: s.ARN, + AuthType: s.AuthType, + CertificateArn: s.CertificateARN, + CustomDomainName: s.CustomDomainName, + ID: s.ID, + IdleTimeoutSeconds: s.IdleTimeoutSeconds, + Name: s.Name, + Status: s.Status, + CreatedAt: s.CreatedAt, + LastUpdatedAt: s.LastUpdatedAt, + } + if s.DNSName != "" { + w.DNSEntry = &wireDNSEntry{DomainName: s.DNSName, HostedZoneID: s.HostedZoneID} + } + + return w +} + +// serveServices routes /services[/{id}[/listeners...]]. +func (h *Handler) serveServices(w http.ResponseWriter, r *http.Request, rest []string) { + if len(rest) == 0 { + routeCollection(w, r, h.createService, h.listServices) + + return + } + + if len(rest) >= 2 && rest[1] == "listeners" { + h.serveListeners(w, r, rest[0], rest[2:]) + + return + } + + routeByID(w, r, rest[0], h.getService, h.updateService, h.deleteService) +} + +func (h *Handler) createService(w http.ResponseWriter, r *http.Request) { + var req struct { + Name string `json:"name"` + AuthType string `json:"authType"` + CertificateArn string `json:"certificateArn"` + CustomDomainName string `json:"customDomainName"` + IdleTimeoutSeconds int32 `json:"idleTimeoutSeconds"` + Tags map[string]string `json:"tags"` + } + + if !decodeJSON(w, r, &req) { + return + } + + svc, err := h.lattice.CreateService(r.Context(), &driver.CreateServiceInput{ + Name: req.Name, AuthType: req.AuthType, CertificateARN: req.CertificateArn, + CustomDomainName: req.CustomDomainName, IdleTimeoutSeconds: req.IdleTimeoutSeconds, Tags: req.Tags, + }) + if err != nil { + writeErr(w, err) + + return + } + + writeJSON(w, serviceToWire(svc)) +} + +func (h *Handler) getService(w http.ResponseWriter, r *http.Request, id string) { + svc, err := h.lattice.GetService(r.Context(), id) + if err != nil { + writeErr(w, err) + + return + } + + writeJSON(w, serviceToWire(svc)) +} + +func (h *Handler) updateService(w http.ResponseWriter, r *http.Request, id string) { + var req struct { + AuthType string `json:"authType"` + CertificateArn string `json:"certificateArn"` + IdleTimeoutSeconds int32 `json:"idleTimeoutSeconds"` + } + + if !decodeJSON(w, r, &req) { + return + } + + svc, err := h.lattice.UpdateService(r.Context(), &driver.UpdateServiceInput{ + ID: id, AuthType: req.AuthType, CertificateARN: req.CertificateArn, IdleTimeoutSeconds: req.IdleTimeoutSeconds, + }) + if err != nil { + writeErr(w, err) + + return + } + + writeJSON(w, serviceToWire(svc)) +} + +func (h *Handler) deleteService(w http.ResponseWriter, r *http.Request, id string) { + if err := h.lattice.DeleteService(r.Context(), id); err != nil { + writeErr(w, err) + + return + } + + writeJSON(w, struct{}{}) +} + +func (h *Handler) listServices(w http.ResponseWriter, r *http.Request) { + svcs, err := h.lattice.ListServices(r.Context()) + if err != nil { + writeErr(w, err) + + return + } + + items := make([]wireService, 0, len(svcs)) + for i := range svcs { + items = append(items, serviceToWire(&svcs[i])) + } + + writeJSON(w, map[string]any{"items": items}) +} diff --git a/server/aws/vpclattice/tagging.go b/server/aws/vpclattice/tagging.go new file mode 100644 index 00000000..0f48560a --- /dev/null +++ b/server/aws/vpclattice/tagging.go @@ -0,0 +1,70 @@ +package vpclattice + +import ( + "net/http" + "strings" +) + +// serveTags routes /tags/{resourceArn}: POST=Tag, GET=List, DELETE=Untag. The +// ARN is the whole remainder (it contains slashes). +func (h *Handler) serveTags(w http.ResponseWriter, r *http.Request, rest []string) { + if len(rest) == 0 { + notFound(w, r.URL.Path) + + return + } + + arn := strings.Join(rest, "/") + + switch r.Method { + case http.MethodPost: + h.tagResource(w, r, arn) + case http.MethodGet: + h.listTagsForResource(w, r, arn) + case http.MethodDelete: + h.untagResource(w, r, arn) + default: + methodNotAllowed(w) + } +} + +func (h *Handler) tagResource(w http.ResponseWriter, r *http.Request, arn string) { + var req struct { + Tags map[string]string `json:"tags"` + } + + if !decodeJSON(w, r, &req) { + return + } + + if err := h.lattice.TagResource(r.Context(), arn, req.Tags); err != nil { + writeErr(w, err) + + return + } + + writeJSON(w, struct{}{}) +} + +func (h *Handler) listTagsForResource(w http.ResponseWriter, r *http.Request, arn string) { + tags, err := h.lattice.ListTagsForResource(r.Context(), arn) + if err != nil { + writeErr(w, err) + + return + } + + writeJSON(w, map[string]any{"tags": tags}) +} + +func (h *Handler) untagResource(w http.ResponseWriter, r *http.Request, arn string) { + keys := r.URL.Query()["tagKeys"] + + if err := h.lattice.UntagResource(r.Context(), arn, keys); err != nil { + writeErr(w, err) + + return + } + + writeJSON(w, struct{}{}) +} diff --git a/server/aws/vpclattice/targetgroups.go b/server/aws/vpclattice/targetgroups.go new file mode 100644 index 00000000..6d0eabc5 --- /dev/null +++ b/server/aws/vpclattice/targetgroups.go @@ -0,0 +1,245 @@ +package vpclattice + +import ( + "encoding/json" + "net/http" + + "github.com/stackshy/cloudemu/v2/services/vpclattice/driver" +) + +type wireTargetGroup struct { + Arn string `json:"arn,omitempty"` + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Type string `json:"type,omitempty"` + Status string `json:"status,omitempty"` + Config json.RawMessage `json:"config,omitempty"` + ServiceArns []string `json:"serviceArns,omitempty"` + CreatedAt string `json:"createdAt,omitempty"` + LastUpdatedAt string `json:"lastUpdatedAt,omitempty"` + Port int32 `json:"port,omitempty"` + Protocol string `json:"protocol,omitempty"` + VpcIdentifier string `json:"vpcIdentifier,omitempty"` + IPAddressType string `json:"ipAddressType,omitempty"` + LambdaEventStructureVersion string `json:"lambdaEventStructureVersion,omitempty"` +} + +func targetGroupToWire(t *driver.TargetGroup) wireTargetGroup { + w := wireTargetGroup{ + Arn: t.ARN, ID: t.ID, Name: t.Name, Type: t.Type, Status: t.Status, + ServiceArns: t.ServiceARNs, CreatedAt: t.CreatedAt, LastUpdatedAt: t.LastUpdatedAt, + } + if len(t.Config) > 0 { + w.Config = json.RawMessage(t.Config) + } + + return w +} + +func targetGroupToSummary(t *driver.TargetGroup) wireTargetGroup { + return wireTargetGroup{ + Arn: t.ARN, ID: t.ID, Name: t.Name, Type: t.Type, Status: t.Status, + Port: t.Port, Protocol: t.Protocol, VpcIdentifier: t.VpcID, + IPAddressType: t.IPAddressType, LambdaEventStructureVersion: t.LambdaEventStructureVersion, + ServiceArns: t.ServiceARNs, CreatedAt: t.CreatedAt, LastUpdatedAt: t.LastUpdatedAt, + } +} + +// serveTargetGroups routes /targetgroups[/{id}[/{action}]]. +func (h *Handler) serveTargetGroups(w http.ResponseWriter, r *http.Request, rest []string) { + if len(rest) == 0 { + routeCollection(w, r, h.createTargetGroup, h.listTargetGroups) + + return + } + + id := rest[0] + + if len(rest) > 1 { + h.serveTargetAction(w, r, id, rest[1]) + + return + } + + routeByID(w, r, id, h.getTargetGroup, h.updateTargetGroup, h.deleteTargetGroup) +} + +// serveTargetAction routes the POST target subpaths of a target group. +func (h *Handler) serveTargetAction(w http.ResponseWriter, r *http.Request, id, action string) { + if r.Method != http.MethodPost { + methodNotAllowed(w) + + return + } + + switch action { + case "registertargets": + h.registerTargets(w, r, id) + case "deregistertargets": + h.deregisterTargets(w, r, id) + case "listtargets": + h.listTargets(w, r, id) + default: + notFound(w, r.URL.Path) + } +} + +func (h *Handler) createTargetGroup(w http.ResponseWriter, r *http.Request) { + var req struct { + Name string `json:"name"` + Type string `json:"type"` + Config json.RawMessage `json:"config"` + Tags map[string]string `json:"tags"` + } + + if !decodeJSON(w, r, &req) { + return + } + + tg, err := h.lattice.CreateTargetGroup(r.Context(), &driver.CreateTargetGroupInput{ + Name: req.Name, Type: req.Type, Config: req.Config, Tags: req.Tags, + }) + if err != nil { + writeErr(w, err) + + return + } + + writeJSON(w, targetGroupToWire(tg)) +} + +func (h *Handler) getTargetGroup(w http.ResponseWriter, r *http.Request, id string) { + tg, err := h.lattice.GetTargetGroup(r.Context(), id) + if err != nil { + writeErr(w, err) + + return + } + + writeJSON(w, targetGroupToWire(tg)) +} + +func (h *Handler) updateTargetGroup(w http.ResponseWriter, r *http.Request, id string) { + var req struct { + HealthCheck json.RawMessage `json:"healthCheck"` + } + + if !decodeJSON(w, r, &req) { + return + } + + tg, err := h.lattice.UpdateTargetGroup(r.Context(), id, req.HealthCheck) + if err != nil { + writeErr(w, err) + + return + } + + writeJSON(w, targetGroupToWire(tg)) +} + +func (h *Handler) deleteTargetGroup(w http.ResponseWriter, r *http.Request, id string) { + if err := h.lattice.DeleteTargetGroup(r.Context(), id); err != nil { + writeErr(w, err) + + return + } + + writeJSON(w, struct{}{}) +} + +func (h *Handler) listTargetGroups(w http.ResponseWriter, r *http.Request) { + tgs, err := h.lattice.ListTargetGroups(r.Context()) + if err != nil { + writeErr(w, err) + + return + } + + items := make([]wireTargetGroup, 0, len(tgs)) + for i := range tgs { + items = append(items, targetGroupToSummary(&tgs[i])) + } + + writeJSON(w, map[string]any{"items": items}) +} + +// --- targets --- + +type wireTarget struct { + ID string `json:"id,omitempty"` + Port int32 `json:"port,omitempty"` + Status string `json:"status,omitempty"` + ReasonCode string `json:"reasonCode,omitempty"` +} + +func decodeTargets(w http.ResponseWriter, r *http.Request) ([]driver.RegisteredTarget, bool) { + var req struct { + Targets []wireTarget `json:"targets"` + } + + if !decodeJSON(w, r, &req) { + return nil, false + } + + out := make([]driver.RegisteredTarget, 0, len(req.Targets)) + for _, t := range req.Targets { + out = append(out, driver.RegisteredTarget{ID: t.ID, Port: t.Port}) + } + + return out, true +} + +func targetsToWire(ts []driver.RegisteredTarget) []wireTarget { + out := make([]wireTarget, 0, len(ts)) + for i := range ts { + out = append(out, wireTarget{ + ID: ts[i].ID, Port: ts[i].Port, Status: ts[i].Status, ReasonCode: ts[i].ReasonCode, + }) + } + + return out +} + +func (h *Handler) registerTargets(w http.ResponseWriter, r *http.Request, id string) { + in, ok := decodeTargets(w, r) + if !ok { + return + } + + success, _, err := h.lattice.RegisterTargets(r.Context(), id, in) + if err != nil { + writeErr(w, err) + + return + } + + writeJSON(w, map[string]any{"successful": targetsToWire(success), "unsuccessful": []any{}}) +} + +func (h *Handler) deregisterTargets(w http.ResponseWriter, r *http.Request, id string) { + in, ok := decodeTargets(w, r) + if !ok { + return + } + + success, _, err := h.lattice.DeregisterTargets(r.Context(), id, in) + if err != nil { + writeErr(w, err) + + return + } + + writeJSON(w, map[string]any{"successful": targetsToWire(success), "unsuccessful": []any{}}) +} + +func (h *Handler) listTargets(w http.ResponseWriter, r *http.Request, id string) { + ts, err := h.lattice.ListTargets(r.Context(), id) + if err != nil { + writeErr(w, err) + + return + } + + writeJSON(w, map[string]any{"items": targetsToWire(ts)}) +} diff --git a/services/vpclattice/driver/driver.go b/services/vpclattice/driver/driver.go new file mode 100644 index 00000000..1272ce5e --- /dev/null +++ b/services/vpclattice/driver/driver.go @@ -0,0 +1,521 @@ +// Package driver defines the in-memory contract for the AWS VPC Lattice control +// plane. The AWS REST-JSON server handler depends on this interface; the aws +// provider implements it. +// +// The interface is composed from one sub-interface per resource group (service +// networks, services, listeners, rules, target groups, associations, resource +// configurations/gateways, access-log subscriptions, auth/resource policies, +// domain verifications, tagging) so groups can be added incrementally. +package driver + +import "context" + +// VPCLattice is the full VPC Lattice control-plane surface. +type VPCLattice interface { + ServiceNetworks + Services + Listeners + Rules + TargetGroups + Associations + ResourceConfigurations + ResourceGateways + ResourceEndpointAssociations + AccessLogSubscriptions + Policies + DomainVerifications + Tagging +} + +// ---- Service Networks ------------------------------------------------------ + +// ServiceNetwork is a VPC Lattice service network. +type ServiceNetwork struct { + ID string + ARN string + Name string + AuthType string + SharingConfigEnabled bool + NumberOfAssociatedServices int64 + NumberOfAssociatedVPCs int64 + NumberOfAssociatedResourceConfigurations int64 + CreatedAt string + LastUpdatedAt string +} + +// CreateServiceNetworkInput carries the create request fields. +type CreateServiceNetworkInput struct { + Name string + AuthType string + SharingConfigEnabled bool + Tags map[string]string +} + +// ServiceNetworks is the service-network resource group. +type ServiceNetworks interface { + CreateServiceNetwork(ctx context.Context, in *CreateServiceNetworkInput) (*ServiceNetwork, error) + GetServiceNetwork(ctx context.Context, id string) (*ServiceNetwork, error) + UpdateServiceNetwork(ctx context.Context, id, authType string) (*ServiceNetwork, error) + DeleteServiceNetwork(ctx context.Context, id string) error + ListServiceNetworks(ctx context.Context) ([]ServiceNetwork, error) +} + +// ---- Services -------------------------------------------------------------- + +// Service is a VPC Lattice service. +type Service struct { + ID string + ARN string + Name string + AuthType string + CertificateARN string + CustomDomainName string + DNSName string + HostedZoneID string + IdleTimeoutSeconds int32 + Status string + CreatedAt string + LastUpdatedAt string +} + +// CreateServiceInput carries the create request fields. +type CreateServiceInput struct { + Name string + AuthType string + CertificateARN string + CustomDomainName string + IdleTimeoutSeconds int32 + Tags map[string]string +} + +// UpdateServiceInput carries the mutable service fields; zero = unchanged. +type UpdateServiceInput struct { + ID string + AuthType string + CertificateARN string + IdleTimeoutSeconds int32 +} + +// Services is the service resource group. +type Services interface { + CreateService(ctx context.Context, in *CreateServiceInput) (*Service, error) + GetService(ctx context.Context, id string) (*Service, error) + UpdateService(ctx context.Context, in *UpdateServiceInput) (*Service, error) + DeleteService(ctx context.Context, id string) error + ListServices(ctx context.Context) ([]Service, error) +} + +// ---- Listeners ------------------------------------------------------------- + +// Listener is a VPC Lattice service listener. DefaultAction is stored as raw +// JSON (a forward/fixedResponse union) and echoed back verbatim. +type Listener struct { + ID string + ARN string + Name string + ServiceID string + ServiceARN string + Protocol string + Port int32 + DefaultAction []byte + CreatedAt string + LastUpdatedAt string +} + +// CreateListenerInput carries the create request fields. +type CreateListenerInput struct { + ServiceID string + Name string + Protocol string + Port int32 + DefaultAction []byte + Tags map[string]string +} + +// Listeners is the listener resource group (nested under a service). +type Listeners interface { + CreateListener(ctx context.Context, in *CreateListenerInput) (*Listener, error) + GetListener(ctx context.Context, serviceID, listenerID string) (*Listener, error) + UpdateListener(ctx context.Context, serviceID, listenerID string, defaultAction []byte) (*Listener, error) + DeleteListener(ctx context.Context, serviceID, listenerID string) error + ListListeners(ctx context.Context, serviceID string) ([]Listener, error) +} + +// ---- Rules ----------------------------------------------------------------- + +// Rule is a listener rule. Match and Action are stored as raw JSON (unions) +// and echoed back verbatim. +type Rule struct { + ID string + ARN string + Name string + ServiceID string + ListenerID string + Priority int32 + IsDefault bool + Match []byte + Action []byte + CreatedAt string + LastUpdatedAt string +} + +// CreateRuleInput carries the create request fields. +type CreateRuleInput struct { + ServiceID string + ListenerID string + Name string + Priority int32 + Match []byte + Action []byte + Tags map[string]string +} + +// RuleUpdate is one entry of a BatchUpdateRule request. +type RuleUpdate struct { + RuleID string + Priority int32 + Match []byte + Action []byte +} + +// RuleUpdateFailure describes a rejected BatchUpdateRule entry. +type RuleUpdateFailure struct { + RuleID string + FailureCode string + FailureMessage string +} + +// Rules is the listener-rule resource group (nested under a listener). +type Rules interface { + CreateRule(ctx context.Context, in *CreateRuleInput) (*Rule, error) + GetRule(ctx context.Context, serviceID, listenerID, ruleID string) (*Rule, error) + UpdateRule(ctx context.Context, serviceID, listenerID, ruleID string, priority int32, match, action []byte) (*Rule, error) + DeleteRule(ctx context.Context, serviceID, listenerID, ruleID string) error + ListRules(ctx context.Context, serviceID, listenerID string) ([]Rule, error) + BatchUpdateRules(ctx context.Context, serviceID, listenerID string, updates []RuleUpdate) ([]Rule, []RuleUpdateFailure, error) +} + +// ---- Target Groups & Targets ----------------------------------------------- + +// TargetGroup is a VPC Lattice target group. Config is stored as raw JSON and +// echoed back verbatim; the summary fields are extracted from it on create. +type TargetGroup struct { + ID string + ARN string + Name string + Type string + Status string + Config []byte + Port int32 + Protocol string + VpcID string + IPAddressType string + LambdaEventStructureVersion string + ServiceARNs []string + CreatedAt string + LastUpdatedAt string +} + +// CreateTargetGroupInput carries the create request fields. +type CreateTargetGroupInput struct { + Name string + Type string + Config []byte + Tags map[string]string +} + +// RegisteredTarget is a target registered with a target group. +type RegisteredTarget struct { + ID string + Port int32 + Status string + ReasonCode string +} + +// TargetFailure describes a rejected register/deregister target entry. +type TargetFailure struct { + ID string + Port int32 + FailureCode string + FailureMessage string +} + +// TargetGroups is the target-group resource group and its target membership. +type TargetGroups interface { + CreateTargetGroup(ctx context.Context, in *CreateTargetGroupInput) (*TargetGroup, error) + GetTargetGroup(ctx context.Context, id string) (*TargetGroup, error) + UpdateTargetGroup(ctx context.Context, id string, healthCheck []byte) (*TargetGroup, error) + DeleteTargetGroup(ctx context.Context, id string) error + ListTargetGroups(ctx context.Context) ([]TargetGroup, error) + RegisterTargets(ctx context.Context, tgID string, targets []RegisteredTarget) ([]RegisteredTarget, []TargetFailure, error) + DeregisterTargets(ctx context.Context, tgID string, targets []RegisteredTarget) ([]RegisteredTarget, []TargetFailure, error) + ListTargets(ctx context.Context, tgID string) ([]RegisteredTarget, error) +} + +// ---- Service-Network Associations ------------------------------------------ + +// SNVpcAssociation associates a VPC with a service network. +type SNVpcAssociation struct { + ID string + ARN string + CreatedBy string + ServiceNetworkID string + ServiceNetworkARN string + ServiceNetworkName string + VpcID string + SecurityGroupIDs []string + PrivateDNSEnabled bool + Status string + CreatedAt string + LastUpdatedAt string +} + +// SNServiceAssociation associates a service with a service network. +type SNServiceAssociation struct { + ID string + ARN string + CreatedBy string + CustomDomainName string + DNSName string + HostedZoneID string + ServiceID string + ServiceARN string + ServiceName string + ServiceNetworkID string + ServiceNetworkARN string + ServiceNetworkName string + Status string + CreatedAt string +} + +// SNResourceAssociation associates a resource configuration with a network. +type SNResourceAssociation struct { + ID string + ARN string + CreatedBy string + ResourceConfigurationID string + ResourceConfigurationARN string + ResourceConfigurationName string + ServiceNetworkID string + ServiceNetworkARN string + ServiceNetworkName string + PrivateDNSEnabled bool + Status string + CreatedAt string + LastUpdatedAt string +} + +// CreateSNVpcAssociationInput carries the SN↔VPC create fields. +type CreateSNVpcAssociationInput struct { + ServiceNetworkID string + VpcID string + SecurityGroupIDs []string + PrivateDNSEnabled bool + Tags map[string]string +} + +// Associations is the service-network association surface (VPC, service, and +// resource-configuration associations). +type Associations interface { + CreateSNVpcAssociation(ctx context.Context, in *CreateSNVpcAssociationInput) (*SNVpcAssociation, error) + GetSNVpcAssociation(ctx context.Context, id string) (*SNVpcAssociation, error) + UpdateSNVpcAssociation(ctx context.Context, id string, securityGroupIDs []string) (*SNVpcAssociation, error) + DeleteSNVpcAssociation(ctx context.Context, id string) error + ListSNVpcAssociations(ctx context.Context) ([]SNVpcAssociation, error) + ListSNVpcEndpointAssociations(ctx context.Context, serviceNetworkID string) ([]SNVpcAssociation, error) + + CreateSNServiceAssociation(ctx context.Context, serviceNetworkID, serviceID string, tags map[string]string) (*SNServiceAssociation, error) + GetSNServiceAssociation(ctx context.Context, id string) (*SNServiceAssociation, error) + DeleteSNServiceAssociation(ctx context.Context, id string) error + ListSNServiceAssociations(ctx context.Context) ([]SNServiceAssociation, error) + + CreateSNResourceAssociation( + ctx context.Context, serviceNetworkID, resourceConfigID string, privateDNS bool, tags map[string]string, + ) (*SNResourceAssociation, error) + GetSNResourceAssociation(ctx context.Context, id string) (*SNResourceAssociation, error) + DeleteSNResourceAssociation(ctx context.Context, id string) error + ListSNResourceAssociations(ctx context.Context) ([]SNResourceAssociation, error) +} + +// ---- Resource Configurations ----------------------------------------------- + +// ResourceConfiguration is a VPC Lattice resource configuration. Definition is +// stored as raw JSON (a union) and echoed back verbatim. +type ResourceConfiguration struct { + ID string + ARN string + Name string + Type string + Status string + Protocol string + CustomDomainName string + GroupDomain string + PortRanges []string + Definition []byte + ResourceGatewayID string + ResourceConfigGroupID string + AllowAssociationToShared bool + CreatedAt string + LastUpdatedAt string +} + +// CreateResourceConfigurationInput carries the create request fields. +type CreateResourceConfigurationInput struct { + Name string + Type string + Protocol string + CustomDomainName string + GroupDomain string + PortRanges []string + Definition []byte + ResourceGatewayID string + ResourceConfigGroupID string + AllowAssociationToShared bool + Tags map[string]string +} + +// UpdateResourceConfigurationInput carries the mutable fields. +type UpdateResourceConfigurationInput struct { + ID string + PortRanges []string + Definition []byte + AllowAssociationToShared bool +} + +// ResourceConfigurations is the resource-configuration resource group. +type ResourceConfigurations interface { + CreateResourceConfiguration(ctx context.Context, in *CreateResourceConfigurationInput) (*ResourceConfiguration, error) + GetResourceConfiguration(ctx context.Context, id string) (*ResourceConfiguration, error) + UpdateResourceConfiguration(ctx context.Context, in *UpdateResourceConfigurationInput) (*ResourceConfiguration, error) + DeleteResourceConfiguration(ctx context.Context, id string) error + ListResourceConfigurations(ctx context.Context) ([]ResourceConfiguration, error) +} + +// ---- Resource Gateways ----------------------------------------------------- + +// ResourceGateway is a VPC Lattice resource gateway. +type ResourceGateway struct { + ID string + ARN string + Name string + Status string + IPAddressType string + Ipv4AddressesPerEni int32 + ResourceConfigDNSResolution string + SecurityGroupIDs []string + SubnetIDs []string + VpcID string + CreatedAt string + LastUpdatedAt string +} + +// CreateResourceGatewayInput carries the create request fields. +type CreateResourceGatewayInput struct { + Name string + IPAddressType string + Ipv4AddressesPerEni int32 + ResourceConfigDNSResolution string + SecurityGroupIDs []string + SubnetIDs []string + VpcID string + Tags map[string]string +} + +// ResourceGateways is the resource-gateway resource group. +type ResourceGateways interface { + CreateResourceGateway(ctx context.Context, in *CreateResourceGatewayInput) (*ResourceGateway, error) + GetResourceGateway(ctx context.Context, id string) (*ResourceGateway, error) + UpdateResourceGateway(ctx context.Context, id string, securityGroupIDs []string) (*ResourceGateway, error) + DeleteResourceGateway(ctx context.Context, id string) error + ListResourceGateways(ctx context.Context) ([]ResourceGateway, error) +} + +// ---- Resource Endpoint Associations ---------------------------------------- + +// ResourceEndpointAssociation associates a VPC endpoint with a resource +// configuration. The mock does not synthesize these (they are created out of +// band by VPC endpoint services), so the list is empty and delete is a no-op +// resolving to NotFound. +type ResourceEndpointAssociation struct { + ID string + ARN string +} + +// ResourceEndpointAssociations is the resource-endpoint-association group. +type ResourceEndpointAssociations interface { + ListResourceEndpointAssociations(ctx context.Context) ([]ResourceEndpointAssociation, error) + DeleteResourceEndpointAssociation(ctx context.Context, id string) error +} + +// ---- Access-Log Subscriptions ---------------------------------------------- + +// AccessLogSubscription streams access logs for a service network or service. +type AccessLogSubscription struct { + ID string + ARN string + DestinationARN string + ResourceID string + ResourceARN string + ServiceNetworkLogType string + CreatedAt string + LastUpdatedAt string +} + +// AccessLogSubscriptions is the access-log-subscription resource group. +type AccessLogSubscriptions interface { + CreateAccessLogSubscription( + ctx context.Context, resourceIdentifier, destinationARN, logType string, tags map[string]string, + ) (*AccessLogSubscription, error) + GetAccessLogSubscription(ctx context.Context, id string) (*AccessLogSubscription, error) + UpdateAccessLogSubscription(ctx context.Context, id, destinationARN string) (*AccessLogSubscription, error) + DeleteAccessLogSubscription(ctx context.Context, id string) error + ListAccessLogSubscriptions(ctx context.Context) ([]AccessLogSubscription, error) +} + +// ---- Auth & Resource Policies ---------------------------------------------- + +// AuthPolicy is the IAM auth policy attached to a service network or service. +type AuthPolicy struct { + Policy string + State string + CreatedAt string + LastUpdatedAt string +} + +// Policies is the auth-policy and resource-policy surface. +type Policies interface { + PutAuthPolicy(ctx context.Context, resourceID, policy string) (*AuthPolicy, error) + GetAuthPolicy(ctx context.Context, resourceID string) (*AuthPolicy, error) + DeleteAuthPolicy(ctx context.Context, resourceID string) error + PutResourcePolicy(ctx context.Context, resourceARN, policy string) error + GetResourcePolicy(ctx context.Context, resourceARN string) (string, error) + DeleteResourcePolicy(ctx context.Context, resourceARN string) error +} + +// ---- Domain Verifications -------------------------------------------------- + +// DomainVerification tracks ownership verification of a custom domain. +type DomainVerification struct { + ID string + ARN string + DomainName string + Status string + CreatedAt string +} + +// DomainVerifications is the domain-verification resource group. +type DomainVerifications interface { + StartDomainVerification(ctx context.Context, domainName string, tags map[string]string) (*DomainVerification, error) + GetDomainVerification(ctx context.Context, id string) (*DomainVerification, error) + DeleteDomainVerification(ctx context.Context, id string) error + ListDomainVerifications(ctx context.Context) ([]DomainVerification, error) +} + +// ---- Tagging --------------------------------------------------------------- + +// Tagging is the resource-tagging surface (ARN-addressed). +type Tagging interface { + TagResource(ctx context.Context, arn string, tags map[string]string) error + UntagResource(ctx context.Context, arn string, keys []string) error + ListTagsForResource(ctx context.Context, arn string) (map[string]string, error) +} From b6bfd1472283182fc99176a0079f01d6969ee6c2 Mon Sep 17 00:00:00 2001 From: Gajendra Malviya Date: Thu, 6 Aug 2026 17:47:33 +0530 Subject: [PATCH 2/3] =?UTF-8?q?fix(aws):=20address=20VPC=20Lattice=20revie?= =?UTF-8?q?w=20=E2=80=94=20tags,=20cascade,=20counts,=20routing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolve the CHANGES_REQUESTED review on #331: - [High] every Create*/Start*/Associate* now persists in.Tags, so the standard create-with-tags → ListTagsForResource flow round-trips across all resources. - Deletes: DeleteServiceNetwork/DeleteService block on live associations (ConflictException); DeleteService cascades its listeners+rules; DeleteListener cascades its rules. - UpdateServiceNetwork now recomputes association counts; applyAssocCounts skips associations whose target service/resource no longer exists (no phantoms). - UpdateResourceConfiguration.AllowAssociationToShared is *bool — a partial update no longer resets a previously-true value. - Matches gates on method+shape so path-style S3 ops on buckets named like Lattice roots (PUT /services/key, GET /tags) fall through to the S3 catch-all. - snake_case filenames (access_logs, service_networks, resource_gateways, resource_configs, domain_verifications, target_groups) in both layers. - Register/Deregister wire the driver failure list; ListTargets sorted; AccessLogSubscription.ResourceARN no longer blanked from a bare ID. Adds provider unit tests + a Matches shadow test + wire coverage for the three previously-unasserted association Get/Delete ops. Full gate green. --- .../{accesslogs.go => access_logs.go} | 11 +-- providers/aws/vpclattice/associations.go | 7 +- ...rifications.go => domain_verifications.go} | 3 +- providers/aws/vpclattice/listeners.go | 8 ++ ...resourceconfigs.go => resource_configs.go} | 6 +- ...sourcegateways.go => resource_gateways.go} | 1 + providers/aws/vpclattice/rules.go | 1 + ...servicenetworks.go => service_networks.go} | 39 ++++++++- providers/aws/vpclattice/services.go | 24 ++++++ .../{targetgroups.go => target_groups.go} | 1 + providers/aws/vpclattice/targets.go | 8 +- providers/aws/vpclattice/vpclattice.go | 15 ++++ providers/aws/vpclattice/vpclattice_test.go | 86 ++++++++++++++++++- .../{accesslogs.go => access_logs.go} | 0 ...rifications.go => domain_verifications.go} | 0 server/aws/vpclattice/handler.go | 55 +++++++++--- server/aws/vpclattice/resources.go | 2 +- server/aws/vpclattice/sdk_roundtrip_test.go | 49 +++++++++++ ...servicenetworks.go => service_networks.go} | 0 .../{targetgroups.go => target_groups.go} | 20 ++++- services/vpclattice/driver/driver.go | 6 +- 21 files changed, 306 insertions(+), 36 deletions(-) rename providers/aws/vpclattice/{accesslogs.go => access_logs.go} (92%) rename providers/aws/vpclattice/{domainverifications.go => domain_verifications.go} (95%) rename providers/aws/vpclattice/{resourceconfigs.go => resource_configs.go} (95%) rename providers/aws/vpclattice/{resourcegateways.go => resource_gateways.go} (99%) rename providers/aws/vpclattice/{servicenetworks.go => service_networks.go} (72%) rename providers/aws/vpclattice/{targetgroups.go => target_groups.go} (99%) rename server/aws/vpclattice/{accesslogs.go => access_logs.go} (100%) rename server/aws/vpclattice/{domainverifications.go => domain_verifications.go} (100%) rename server/aws/vpclattice/{servicenetworks.go => service_networks.go} (100%) rename server/aws/vpclattice/{targetgroups.go => target_groups.go} (91%) diff --git a/providers/aws/vpclattice/accesslogs.go b/providers/aws/vpclattice/access_logs.go similarity index 92% rename from providers/aws/vpclattice/accesslogs.go rename to providers/aws/vpclattice/access_logs.go index 77e8bd44..bf2b1bdf 100644 --- a/providers/aws/vpclattice/accesslogs.go +++ b/providers/aws/vpclattice/access_logs.go @@ -2,7 +2,6 @@ package vpclattice import ( "context" - "strings" "github.com/stackshy/cloudemu/v2/errors" "github.com/stackshy/cloudemu/v2/internal/idgen" @@ -16,28 +15,24 @@ func accessLogSubNotFound(id string) error { func cloneAccessLogSub(a *driver.AccessLogSubscription) driver.AccessLogSubscription { return *a } func (m *Mock) CreateAccessLogSubscription( - _ context.Context, resourceIdentifier, destinationARN, logType string, _ map[string]string, + _ context.Context, resourceIdentifier, destinationARN, logType string, tags map[string]string, ) (*driver.AccessLogSubscription, error) { m.mu.Lock() defer m.mu.Unlock() - resourceARN := resourceIdentifier - if !strings.Contains(resourceARN, ":") { - resourceARN = "" - } - id := idgen.GenerateID("als-") a := &driver.AccessLogSubscription{ ID: id, ARN: m.arn("accesslogsubscription/" + id), DestinationARN: destinationARN, ResourceID: idFromIdentifier(resourceIdentifier), - ResourceARN: resourceARN, + ResourceARN: resourceIdentifier, ServiceNetworkLogType: logType, CreatedAt: m.now(), LastUpdatedAt: m.now(), } m.accessLogSubs.Set(id, a) + m.writeTags(a.ARN, tags) out := cloneAccessLogSub(a) diff --git a/providers/aws/vpclattice/associations.go b/providers/aws/vpclattice/associations.go index 7037e5db..fea441c9 100644 --- a/providers/aws/vpclattice/associations.go +++ b/providers/aws/vpclattice/associations.go @@ -69,6 +69,7 @@ func (m *Mock) CreateSNVpcAssociation( LastUpdatedAt: m.now(), } m.snVpcAssocs.Set(id, a) + m.writeTags(a.ARN, in.Tags) out := cloneSNVpc(a) @@ -145,7 +146,7 @@ func (*Mock) ListSNVpcEndpointAssociations( func cloneSNSvc(a *driver.SNServiceAssociation) driver.SNServiceAssociation { return *a } func (m *Mock) CreateSNServiceAssociation( - _ context.Context, serviceNetworkID, serviceID string, _ map[string]string, + _ context.Context, serviceNetworkID, serviceID string, tags map[string]string, ) (*driver.SNServiceAssociation, error) { m.mu.Lock() defer m.mu.Unlock() @@ -178,6 +179,7 @@ func (m *Mock) CreateSNServiceAssociation( CreatedAt: m.now(), } m.snSvcAssocs.Set(id, a) + m.writeTags(a.ARN, tags) out := cloneSNSvc(a) @@ -224,7 +226,7 @@ func (m *Mock) ListSNServiceAssociations(_ context.Context) ([]driver.SNServiceA func cloneSNRes(a *driver.SNResourceAssociation) driver.SNResourceAssociation { return *a } func (m *Mock) CreateSNResourceAssociation( - _ context.Context, serviceNetworkID, resourceConfigID string, privateDNS bool, _ map[string]string, + _ context.Context, serviceNetworkID, resourceConfigID string, privateDNS bool, tags map[string]string, ) (*driver.SNResourceAssociation, error) { m.mu.Lock() defer m.mu.Unlock() @@ -251,6 +253,7 @@ func (m *Mock) CreateSNResourceAssociation( LastUpdatedAt: m.now(), } m.snResAssocs.Set(id, a) + m.writeTags(a.ARN, tags) out := cloneSNRes(a) diff --git a/providers/aws/vpclattice/domainverifications.go b/providers/aws/vpclattice/domain_verifications.go similarity index 95% rename from providers/aws/vpclattice/domainverifications.go rename to providers/aws/vpclattice/domain_verifications.go index 09623658..0d4f0db1 100644 --- a/providers/aws/vpclattice/domainverifications.go +++ b/providers/aws/vpclattice/domain_verifications.go @@ -17,7 +17,7 @@ func domainVerificationNotFound(id string) error { func cloneDomainVerification(d *driver.DomainVerification) driver.DomainVerification { return *d } func (m *Mock) StartDomainVerification( - _ context.Context, domainName string, _ map[string]string, + _ context.Context, domainName string, tags map[string]string, ) (*driver.DomainVerification, error) { m.mu.Lock() defer m.mu.Unlock() @@ -31,6 +31,7 @@ func (m *Mock) StartDomainVerification( CreatedAt: m.now(), } m.domainVerifs.Set(id, d) + m.writeTags(d.ARN, tags) out := cloneDomainVerification(d) diff --git a/providers/aws/vpclattice/listeners.go b/providers/aws/vpclattice/listeners.go index d67f1f5c..060214b3 100644 --- a/providers/aws/vpclattice/listeners.go +++ b/providers/aws/vpclattice/listeners.go @@ -56,6 +56,7 @@ func (m *Mock) CreateListener(_ context.Context, in *driver.CreateListenerInput) LastUpdatedAt: m.now(), } m.listeners.Set(id, l) + m.writeTags(l.ARN, in.Tags) out := cloneListener(l) @@ -120,6 +121,13 @@ func (m *Mock) DeleteListener(_ context.Context, serviceID, listenerID string) e return err } + // Cascade the listener's contained rules. + for rid, r := range m.rules.All() { + if r.ListenerID == l.ID { + m.rules.Delete(rid) + } + } + m.listeners.Delete(l.ID) return nil diff --git a/providers/aws/vpclattice/resourceconfigs.go b/providers/aws/vpclattice/resource_configs.go similarity index 95% rename from providers/aws/vpclattice/resourceconfigs.go rename to providers/aws/vpclattice/resource_configs.go index 8843fa42..097a41e0 100644 --- a/providers/aws/vpclattice/resourceconfigs.go +++ b/providers/aws/vpclattice/resource_configs.go @@ -47,6 +47,7 @@ func (m *Mock) CreateResourceConfiguration( LastUpdatedAt: m.now(), } m.resourceConfigs.Set(id, c) + m.writeTags(c.ARN, in.Tags) out := cloneResourceConfig(c) @@ -90,7 +91,10 @@ func (m *Mock) UpdateResourceConfiguration( c.Definition = append([]byte(nil), in.Definition...) } - c.AllowAssociationToShared = in.AllowAssociationToShared + if in.AllowAssociationToShared != nil { + c.AllowAssociationToShared = *in.AllowAssociationToShared + } + c.LastUpdatedAt = m.now() out := cloneResourceConfig(c) diff --git a/providers/aws/vpclattice/resourcegateways.go b/providers/aws/vpclattice/resource_gateways.go similarity index 99% rename from providers/aws/vpclattice/resourcegateways.go rename to providers/aws/vpclattice/resource_gateways.go index 14318cba..95adfbf3 100644 --- a/providers/aws/vpclattice/resourcegateways.go +++ b/providers/aws/vpclattice/resource_gateways.go @@ -42,6 +42,7 @@ func (m *Mock) CreateResourceGateway( LastUpdatedAt: m.now(), } m.resourceGws.Set(id, g) + m.writeTags(g.ARN, in.Tags) out := cloneResourceGateway(g) diff --git a/providers/aws/vpclattice/rules.go b/providers/aws/vpclattice/rules.go index 73cd042c..5f78a722 100644 --- a/providers/aws/vpclattice/rules.go +++ b/providers/aws/vpclattice/rules.go @@ -43,6 +43,7 @@ func (m *Mock) CreateRule(_ context.Context, in *driver.CreateRuleInput) (*drive LastUpdatedAt: m.now(), } m.rules.Set(id, rule) + m.writeTags(rule.ARN, in.Tags) out := cloneRule(rule) diff --git a/providers/aws/vpclattice/servicenetworks.go b/providers/aws/vpclattice/service_networks.go similarity index 72% rename from providers/aws/vpclattice/servicenetworks.go rename to providers/aws/vpclattice/service_networks.go index be197d9e..b481ceba 100644 --- a/providers/aws/vpclattice/servicenetworks.go +++ b/providers/aws/vpclattice/service_networks.go @@ -19,8 +19,10 @@ func cloneServiceNetwork(s *driver.ServiceNetwork) driver.ServiceNetwork { retur func (m *Mock) applyAssocCounts(s *driver.ServiceNetwork) { var svcs, vpcs, res int64 + // Skip associations whose target resource no longer exists so a Get never + // reports a phantom associated service/resource. for _, a := range m.snSvcAssocs.All() { - if a.ServiceNetworkID == s.ID { + if a.ServiceNetworkID == s.ID && m.services.Has(a.ServiceID) { svcs++ } } @@ -32,7 +34,7 @@ func (m *Mock) applyAssocCounts(s *driver.ServiceNetwork) { } for _, a := range m.snResAssocs.All() { - if a.ServiceNetworkID == s.ID { + if a.ServiceNetworkID == s.ID && m.resourceConfigs.Has(a.ResourceConfigurationID) { res++ } } @@ -64,6 +66,7 @@ func (m *Mock) CreateServiceNetwork( LastUpdatedAt: m.now(), } m.serviceNetworks.Set(id, sn) + m.writeTags(sn.ARN, in.Tags) out := cloneServiceNetwork(sn) @@ -107,6 +110,7 @@ func (m *Mock) UpdateServiceNetwork( sn.LastUpdatedAt = m.now() out := cloneServiceNetwork(sn) + m.applyAssocCounts(&out) return &out, nil } @@ -121,11 +125,42 @@ func (m *Mock) DeleteServiceNetwork(_ context.Context, identifier string) error return serviceNetworkNotFound(id) } + // Real AWS refuses to delete a service network that still has live + // associations. + if m.serviceNetworkHasAssociations(id) { + return errors.Newf(errors.FailedPrecondition, + "service network %q still has active associations", id) + } + m.serviceNetworks.Delete(id) return nil } +// serviceNetworkHasAssociations reports whether any VPC/service/resource +// association still references the network. Caller holds m.mu. +func (m *Mock) serviceNetworkHasAssociations(snID string) bool { + for _, a := range m.snVpcAssocs.All() { + if a.ServiceNetworkID == snID { + return true + } + } + + for _, a := range m.snSvcAssocs.All() { + if a.ServiceNetworkID == snID { + return true + } + } + + for _, a := range m.snResAssocs.All() { + if a.ServiceNetworkID == snID { + return true + } + } + + return false +} + func (m *Mock) ListServiceNetworks(_ context.Context) ([]driver.ServiceNetwork, error) { m.mu.Lock() defer m.mu.Unlock() diff --git a/providers/aws/vpclattice/services.go b/providers/aws/vpclattice/services.go index 245418e5..368adb56 100644 --- a/providers/aws/vpclattice/services.go +++ b/providers/aws/vpclattice/services.go @@ -50,6 +50,7 @@ func (m *Mock) CreateService(_ context.Context, in *driver.CreateServiceInput) ( LastUpdatedAt: m.now(), } m.services.Set(id, svc) + m.writeTags(svc.ARN, in.Tags) out := cloneService(svc) @@ -112,6 +113,29 @@ func (m *Mock) DeleteService(_ context.Context, identifier string) error { return serviceNotFound(id) } + // Real AWS refuses to delete a service still associated with a network. + for _, a := range m.snSvcAssocs.All() { + if a.ServiceID == id { + return errors.Newf(errors.FailedPrecondition, + "service %q is still associated with a service network", id) + } + } + + // Cascade the service's contained listeners and their rules. + for lid, l := range m.listeners.All() { + if l.ServiceID != id { + continue + } + + for rid, r := range m.rules.All() { + if r.ServiceID == id && r.ListenerID == lid { + m.rules.Delete(rid) + } + } + + m.listeners.Delete(lid) + } + m.services.Delete(id) return nil diff --git a/providers/aws/vpclattice/targetgroups.go b/providers/aws/vpclattice/target_groups.go similarity index 99% rename from providers/aws/vpclattice/targetgroups.go rename to providers/aws/vpclattice/target_groups.go index 671cc4e0..4207e4f0 100644 --- a/providers/aws/vpclattice/targetgroups.go +++ b/providers/aws/vpclattice/target_groups.go @@ -59,6 +59,7 @@ func (m *Mock) CreateTargetGroup(_ context.Context, in *driver.CreateTargetGroup } m.targetGroups.Set(id, tg) m.targets.Set(id, nil) + m.writeTags(tg.ARN, in.Tags) out := cloneTargetGroup(tg) diff --git a/providers/aws/vpclattice/targets.go b/providers/aws/vpclattice/targets.go index e4cb7643..e698369c 100644 --- a/providers/aws/vpclattice/targets.go +++ b/providers/aws/vpclattice/targets.go @@ -2,6 +2,7 @@ package vpclattice import ( "context" + "sort" "strconv" "github.com/stackshy/cloudemu/v2/services/vpclattice/driver" @@ -107,5 +108,10 @@ func (m *Mock) ListTargets(_ context.Context, tgID string) ([]driver.RegisteredT cur, _ := m.targets.Get(id) - return append([]driver.RegisteredTarget(nil), cur...), nil + out := append([]driver.RegisteredTarget(nil), cur...) + sort.Slice(out, func(i, j int) bool { + return targetKey(out[i]) < targetKey(out[j]) + }) + + return out, nil } diff --git a/providers/aws/vpclattice/vpclattice.go b/providers/aws/vpclattice/vpclattice.go index 5dee4cec..3e3e8a1a 100644 --- a/providers/aws/vpclattice/vpclattice.go +++ b/providers/aws/vpclattice/vpclattice.go @@ -77,6 +77,21 @@ func (m *Mock) arn(resource string) string { return idgen.AWSARN("vpc-lattice", m.opts.Region, m.opts.AccountID, resource) } +// writeTags stores create-time tags against a resource ARN so the standard +// create-with-tags → ListTagsForResource flow round-trips. Caller holds m.mu. +func (m *Mock) writeTags(arn string, tags map[string]string) { + if len(tags) == 0 { + return + } + + cur := make(map[string]string, len(tags)) + for k, v := range tags { + cur[k] = v + } + + m.tags.Set(arn, cur) +} + // idFromIdentifier accepts either a bare ID or a full ARN and returns the ID // (the segment after the last "/"). VPC Lattice APIs accept both forms. func idFromIdentifier(identifier string) string { diff --git a/providers/aws/vpclattice/vpclattice_test.go b/providers/aws/vpclattice/vpclattice_test.go index 99e35d1f..b9dc05d9 100644 --- a/providers/aws/vpclattice/vpclattice_test.go +++ b/providers/aws/vpclattice/vpclattice_test.go @@ -20,6 +20,8 @@ func newTestMock() *Mock { return New(opts) } +func ptr[T any](v T) *T { return &v } + func TestServiceNetworkCRUDAndIdentifierResolution(t *testing.T) { m := newTestMock() ctx := context.Background() @@ -47,12 +49,13 @@ func TestServiceNetworkAssocCounts(t *testing.T) { sn, _ := m.CreateServiceNetwork(ctx, &driver.CreateServiceNetworkInput{Name: "sn"}) svc, _ := m.CreateService(ctx, &driver.CreateServiceInput{Name: "svc"}) + rc, _ := m.CreateResourceConfiguration(ctx, &driver.CreateResourceConfigurationInput{Name: "rc", Type: "SINGLE"}) _, err := m.CreateSNVpcAssociation(ctx, &driver.CreateSNVpcAssociationInput{ServiceNetworkID: sn.ID, VpcID: "vpc-1"}) require.NoError(t, err) _, err = m.CreateSNServiceAssociation(ctx, sn.ID, svc.ID, nil) require.NoError(t, err) - _, err = m.CreateSNResourceAssociation(ctx, sn.ID, "rcfg-1", true, nil) + _, err = m.CreateSNResourceAssociation(ctx, sn.ID, rc.ID, true, nil) require.NoError(t, err) got, err := m.GetServiceNetwork(ctx, sn.ID) @@ -61,6 +64,12 @@ func TestServiceNetworkAssocCounts(t *testing.T) { assert.Equal(t, int64(1), got.NumberOfAssociatedServices) assert.Equal(t, int64(1), got.NumberOfAssociatedResourceConfigurations) + // A resource association whose target no longer exists is not counted. + require.NoError(t, m.DeleteResourceConfiguration(ctx, rc.ID)) + got, err = m.GetServiceNetwork(ctx, sn.ID) + require.NoError(t, err) + assert.Equal(t, int64(0), got.NumberOfAssociatedResourceConfigurations) + // Association against a missing network fails. _, err = m.CreateSNVpcAssociation(ctx, &driver.CreateSNVpcAssociationInput{ServiceNetworkID: "sn-x", VpcID: "v"}) assert.True(t, cerrors.IsNotFound(err)) @@ -344,3 +353,78 @@ func TestListenerAndRuleListAndUpdate(t *testing.T) { require.NoError(t, err) assert.Empty(t, ep) } + +// --- review-driven correctness behaviors --- + +func TestCreateWritesTags(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + sn, err := m.CreateServiceNetwork(ctx, &driver.CreateServiceNetworkInput{ + Name: "sn", Tags: map[string]string{"team": "net", "env": "test"}, + }) + require.NoError(t, err) + + tags, err := m.ListTagsForResource(ctx, sn.ARN) + require.NoError(t, err) + assert.Equal(t, map[string]string{"team": "net", "env": "test"}, tags) +} + +func TestServiceNetworkDeleteBlockedByAssociation(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + sn, _ := m.CreateServiceNetwork(ctx, &driver.CreateServiceNetworkInput{Name: "sn"}) + _, err := m.CreateSNVpcAssociation(ctx, &driver.CreateSNVpcAssociationInput{ServiceNetworkID: sn.ID, VpcID: "vpc-1"}) + require.NoError(t, err) + + err = m.DeleteServiceNetwork(ctx, sn.ID) + assert.True(t, cerrors.IsFailedPrecondition(err)) +} + +func TestServiceDeleteGuardAndCascade(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + sn, _ := m.CreateServiceNetwork(ctx, &driver.CreateServiceNetworkInput{Name: "sn"}) + svc, _ := m.CreateService(ctx, &driver.CreateServiceInput{Name: "svc"}) + + // Associated service can't be deleted. + _, err := m.CreateSNServiceAssociation(ctx, sn.ID, svc.ID, nil) + require.NoError(t, err) + assert.True(t, cerrors.IsFailedPrecondition(m.DeleteService(ctx, svc.ID))) + + // A standalone service with a listener + rule cascades on delete. + svc2, _ := m.CreateService(ctx, &driver.CreateServiceInput{Name: "svc2"}) + l, _ := m.CreateListener(ctx, &driver.CreateListenerInput{ServiceID: svc2.ID, Protocol: "HTTP", Port: 80}) + r, _ := m.CreateRule(ctx, &driver.CreateRuleInput{ServiceID: svc2.ID, ListenerID: l.ID, Name: "r", Priority: 1}) + + require.NoError(t, m.DeleteService(ctx, svc2.ID)) + _, err = m.GetListener(ctx, svc2.ID, l.ID) + assert.True(t, cerrors.IsNotFound(err)) + _, err = m.GetRule(ctx, svc2.ID, l.ID, r.ID) + assert.True(t, cerrors.IsNotFound(err)) +} + +func TestUpdateResourceConfigKeepsAllowFlag(t *testing.T) { + m := newTestMock() + ctx := context.Background() + + rc, _ := m.CreateResourceConfiguration(ctx, &driver.CreateResourceConfigurationInput{ + Name: "rc", Type: "SINGLE", AllowAssociationToShared: true, + }) + + // A partial update that omits the flag (nil) must not reset it to false. + upd, err := m.UpdateResourceConfiguration(ctx, &driver.UpdateResourceConfigurationInput{ + ID: rc.ID, PortRanges: []string{"443"}, + }) + require.NoError(t, err) + assert.True(t, upd.AllowAssociationToShared) + + // An explicit false clears it. + upd, err = m.UpdateResourceConfiguration(ctx, &driver.UpdateResourceConfigurationInput{ + ID: rc.ID, AllowAssociationToShared: ptr(false), + }) + require.NoError(t, err) + assert.False(t, upd.AllowAssociationToShared) +} diff --git a/server/aws/vpclattice/accesslogs.go b/server/aws/vpclattice/access_logs.go similarity index 100% rename from server/aws/vpclattice/accesslogs.go rename to server/aws/vpclattice/access_logs.go diff --git a/server/aws/vpclattice/domainverifications.go b/server/aws/vpclattice/domain_verifications.go similarity index 100% rename from server/aws/vpclattice/domainverifications.go rename to server/aws/vpclattice/domain_verifications.go diff --git a/server/aws/vpclattice/handler.go b/server/aws/vpclattice/handler.go index e4bf29e1..b814faff 100644 --- a/server/aws/vpclattice/handler.go +++ b/server/aws/vpclattice/handler.go @@ -49,11 +49,50 @@ func New(d driver.VPCLattice) *Handler { return h } -// Matches claims requests whose first path segment belongs to VPC Lattice. +// Matches claims a request only when its path root, HTTP method, and segment +// shape all correspond to a real VPC Lattice route. This is deliberately strict +// so that path-style S3 requests against a bucket literally named like a Lattice +// root (e.g. `PUT /services/key`, `GET /tags`) are NOT hijacked and instead fall +// through to the S3 catch-all handler registered after this one. +// +// A residual ambiguity remains for verbs Lattice and S3 share on an identical +// path (e.g. `GET /services` — list-services vs. S3 list-bucket-"services"), +// which is unavoidable for two REST services co-located on one endpoint. func (h *Handler) Matches(r *http.Request) bool { - _, ok := h.routes[firstSegment(r.URL.Path)] + segs := splitPath(r.URL.Path) + if len(segs) == 0 { + return false + } - return ok + if _, ok := h.routes[segs[0]]; !ok { + return false + } + + return latticeClaims(r.Method, segs) +} + +// latticeClaims reports whether (method, segments) maps to a served route. +func latticeClaims(method string, segs []string) bool { + rest := segs[1:] + + switch segs[0] { + case "authpolicy", "resourcepolicy": + // //{resourceArn}: an identifier is always present; PUT/GET/DELETE. + return len(rest) >= 1 && + (method == http.MethodGet || method == http.MethodPut || method == http.MethodDelete) + case "tags": + // /tags/{resourceArn}: an identifier is always present; POST/GET/DELETE. + return len(rest) >= 1 && + (method == http.MethodGet || method == http.MethodPost || method == http.MethodDelete) + default: + return isLatticeMethod(method) + } +} + +// isLatticeMethod reports whether m is one of the verbs the collection/resource +// routes use (PUT is used only by the policy roots, handled separately). +func isLatticeMethod(m string) bool { + return m == http.MethodGet || m == http.MethodPost || m == http.MethodPatch || m == http.MethodDelete } // ServeHTTP dispatches on the first path segment via the routes table. @@ -108,16 +147,6 @@ func routeByID(w http.ResponseWriter, r *http.Request, id string, get, update, d } } -// firstSegment returns the first non-empty path segment, or "". -func firstSegment(p string) string { - segs := splitPath(p) - if len(segs) == 0 { - return "" - } - - return segs[0] -} - // splitPath splits a URL path into its non-empty segments. func splitPath(p string) []string { p = strings.Trim(p, "/") diff --git a/server/aws/vpclattice/resources.go b/server/aws/vpclattice/resources.go index 9bc1ea86..6454eef4 100644 --- a/server/aws/vpclattice/resources.go +++ b/server/aws/vpclattice/resources.go @@ -100,7 +100,7 @@ func (h *Handler) updateResourceConfig(w http.ResponseWriter, r *http.Request, i var req struct { PortRanges []string `json:"portRanges"` Definition json.RawMessage `json:"resourceConfigurationDefinition"` - AllowAssociationToShared bool `json:"allowAssociationToShareableServiceNetwork"` + AllowAssociationToShared *bool `json:"allowAssociationToShareableServiceNetwork"` } if !decodeJSON(w, r, &req) { diff --git a/server/aws/vpclattice/sdk_roundtrip_test.go b/server/aws/vpclattice/sdk_roundtrip_test.go index 66a31a0f..1fef071c 100644 --- a/server/aws/vpclattice/sdk_roundtrip_test.go +++ b/server/aws/vpclattice/sdk_roundtrip_test.go @@ -3,6 +3,7 @@ package vpclattice_test import ( "context" "errors" + "net/http" "net/http/httptest" "testing" @@ -14,8 +15,34 @@ import ( "github.com/stackshy/cloudemu/v2" awsserver "github.com/stackshy/cloudemu/v2/server/aws" + vpclatticesrv "github.com/stackshy/cloudemu/v2/server/aws/vpclattice" ) +func TestMatchesDoesNotShadowS3(t *testing.T) { + h := vpclatticesrv.New(nil) + + cases := []struct { + method, path string + claim bool + }{ + {http.MethodGet, "/servicenetworks", true}, // Lattice ListServiceNetworks + {http.MethodPost, "/services", true}, // Lattice CreateService + {http.MethodPatch, "/servicenetworks/sn-1", true}, // Lattice UpdateServiceNetwork + {http.MethodGet, "/tags/arn%3Aaws%3A%2Fsvc", true}, // Lattice ListTagsForResource + {http.MethodPut, "/services/mykey", false}, // S3 PutObject into bucket "services" + {http.MethodGet, "/tags", false}, // S3 ListObjects on bucket "tags" + {http.MethodPost, "/tags", false}, // S3 op on bucket "tags" (no ARN) + {http.MethodGet, "/notalatticeroot", false}, // unrelated + } + + for _, c := range cases { + got := h.Matches(httptest.NewRequest(c.method, c.path, nil)) + if got != c.claim { + t.Errorf("Matches(%s %s) = %v, want %v", c.method, c.path, got, c.claim) + } + } +} + // newClient builds an httptest-backed VPC Lattice client driving the real // aws-sdk-go-v2 client against the in-memory driver. func newClient(t *testing.T) *awsvpcl.Client { @@ -429,6 +456,28 @@ func TestSDKAssociations(t *testing.T) { t.Fatalf("ListSNResourceAssociations: %v %+v", e, lr) } + // Get by id (wire-level coverage for both association Get paths). + if gs, e := client.GetServiceNetworkServiceAssociation(ctx, &awsvpcl.GetServiceNetworkServiceAssociationInput{ + ServiceNetworkServiceAssociationIdentifier: svcA.Id, + }); e != nil || aws.ToString(gs.ServiceId) != svcID { + t.Fatalf("GetSNServiceAssociation: %v %+v", e, gs) + } + if _, e := client.GetServiceNetworkResourceAssociation(ctx, &awsvpcl.GetServiceNetworkResourceAssociationInput{ + ServiceNetworkResourceAssociationIdentifier: resA.Id, + }); e != nil { + t.Fatalf("GetSNResourceAssociation: %v", e) + } + + // ResourceEndpointAssociations aren't synthesized by the emulator, so a + // delete resolves to NotFound — exercises the otherwise-unreachable handler. + _, e := client.DeleteResourceEndpointAssociation(ctx, &awsvpcl.DeleteResourceEndpointAssociationInput{ + ResourceEndpointAssociationIdentifier: aws.String("rea-none"), + }) + var nfe *vpcltypes.ResourceNotFoundException + if !errors.As(e, &nfe) { + t.Fatalf("DeleteResourceEndpointAssociation: expected ResourceNotFoundException, got %v", e) + } + // Deletes. if _, e := client.DeleteServiceNetworkVpcAssociation(ctx, &awsvpcl.DeleteServiceNetworkVpcAssociationInput{ServiceNetworkVpcAssociationIdentifier: aws.String(vpcAID)}); e != nil { t.Fatalf("DeleteSNVpcAssociation: %v", e) diff --git a/server/aws/vpclattice/servicenetworks.go b/server/aws/vpclattice/service_networks.go similarity index 100% rename from server/aws/vpclattice/servicenetworks.go rename to server/aws/vpclattice/service_networks.go diff --git a/server/aws/vpclattice/targetgroups.go b/server/aws/vpclattice/target_groups.go similarity index 91% rename from server/aws/vpclattice/targetgroups.go rename to server/aws/vpclattice/target_groups.go index 6d0eabc5..11a2f445 100644 --- a/server/aws/vpclattice/targetgroups.go +++ b/server/aws/vpclattice/target_groups.go @@ -201,20 +201,32 @@ func targetsToWire(ts []driver.RegisteredTarget) []wireTarget { return out } +func targetFailuresToWire(fs []driver.TargetFailure) []map[string]any { + out := make([]map[string]any, 0, len(fs)) + for i := range fs { + out = append(out, map[string]any{ + "id": fs[i].ID, "port": fs[i].Port, + "failureCode": fs[i].FailureCode, "failureMessage": fs[i].FailureMessage, + }) + } + + return out +} + func (h *Handler) registerTargets(w http.ResponseWriter, r *http.Request, id string) { in, ok := decodeTargets(w, r) if !ok { return } - success, _, err := h.lattice.RegisterTargets(r.Context(), id, in) + success, failed, err := h.lattice.RegisterTargets(r.Context(), id, in) if err != nil { writeErr(w, err) return } - writeJSON(w, map[string]any{"successful": targetsToWire(success), "unsuccessful": []any{}}) + writeJSON(w, map[string]any{"successful": targetsToWire(success), "unsuccessful": targetFailuresToWire(failed)}) } func (h *Handler) deregisterTargets(w http.ResponseWriter, r *http.Request, id string) { @@ -223,14 +235,14 @@ func (h *Handler) deregisterTargets(w http.ResponseWriter, r *http.Request, id s return } - success, _, err := h.lattice.DeregisterTargets(r.Context(), id, in) + success, failed, err := h.lattice.DeregisterTargets(r.Context(), id, in) if err != nil { writeErr(w, err) return } - writeJSON(w, map[string]any{"successful": targetsToWire(success), "unsuccessful": []any{}}) + writeJSON(w, map[string]any{"successful": targetsToWire(success), "unsuccessful": targetFailuresToWire(failed)}) } func (h *Handler) listTargets(w http.ResponseWriter, r *http.Request, id string) { diff --git a/services/vpclattice/driver/driver.go b/services/vpclattice/driver/driver.go index 1272ce5e..90eba241 100644 --- a/services/vpclattice/driver/driver.go +++ b/services/vpclattice/driver/driver.go @@ -374,12 +374,14 @@ type CreateResourceConfigurationInput struct { Tags map[string]string } -// UpdateResourceConfigurationInput carries the mutable fields. +// UpdateResourceConfigurationInput carries the mutable fields. A nil pointer +// means "absent, leave unchanged" so a partial update never resets a +// previously-true value. type UpdateResourceConfigurationInput struct { ID string PortRanges []string Definition []byte - AllowAssociationToShared bool + AllowAssociationToShared *bool } // ResourceConfigurations is the resource-configuration resource group. From 541d4a69c21cb14841d9ab2d7378e62f7f8fcabf Mon Sep 17 00:00:00 2001 From: Gajendra Malviya Date: Thu, 6 Aug 2026 22:07:50 +0530 Subject: [PATCH 3/3] fix(vpclattice): close S3-shadow residual by gating on identifier shape (M5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The re-review flagged that the earlier latticeClaims fix still let a path-style S3 object op on a bucket named exactly like a Lattice root through — e.g. `GET /services/mykey` or `DELETE /targetgroups/mykey` were claimed by this handler (registered before S3) and failed, so such a bucket couldn't do object GET/DELETE. Resource-scoped routes now require the id segment to be Lattice-shaped (a generated ID prefix like sn-/svc-/tg-/listener-/rule-/… or a vpc-lattice ARN); an arbitrary S3 key ("mykey") no longer matches and falls through to the S3 catch-all. The tags/authpolicy/resourcepolicy roots likewise require a Lattice id/ARN identifier. The one residual — a bare `GET /` list vs. an S3 list-bucket on an identically-named bucket — is unavoidable for two REST services on one endpoint and is now documented. Extended TestMatchesDoesNotShadowS3 with the object-op fall-through cases (GET/DELETE //mykey → not claimed; // → claimed). --- docs/services.md | 9 ++- server/aws/vpclattice/handler.go | 64 ++++++++++++++++++--- server/aws/vpclattice/sdk_roundtrip_test.go | 22 ++++--- 3 files changed, 77 insertions(+), 18 deletions(-) diff --git a/docs/services.md b/docs/services.md index 8883cca5..da41c31b 100644 --- a/docs/services.md +++ b/docs/services.md @@ -2323,9 +2323,12 @@ parity: **all 73 SDK operations**, no stubs. Unlike the AWS JSON 1.1 services, VPC Lattice uses **REST-JSON**: the operation is selected by HTTP method + URL path (e.g. `POST /services`, `GET /services/{id}/listeners/{id}`, `PATCH /servicenetworks/{id}`) rather than an -`X-Amz-Target` header. The handler gates on path root + method + segment shape -so path-style S3 requests on like-named buckets fall through to the S3 -catch-all; identifiers accept either a bare ID or a full ARN. Union-typed fields +`X-Amz-Target` header. The handler gates on path root + method + **identifier +shape**, so a path-style S3 object op on a bucket named like a Lattice root +(e.g. `GET /services/mykey`) falls through to the S3 catch-all — only a +Lattice-shaped id (a known prefix or a `vpc-lattice` ARN) is claimed. The single +unavoidable residual is a bare `GET /` (list) vs. an S3 list-bucket on an +identically-named bucket. Identifiers accept either a bare ID or a full ARN. Union-typed fields (a listener's `defaultAction`, a rule's `match`/`action`, a target group's `config`, a resource configuration's `resourceConfigurationDefinition`) are stored as raw JSON and echoed back verbatim. Create-time tags are persisted; diff --git a/server/aws/vpclattice/handler.go b/server/aws/vpclattice/handler.go index b814faff..35c6aca6 100644 --- a/server/aws/vpclattice/handler.go +++ b/server/aws/vpclattice/handler.go @@ -72,29 +72,79 @@ func (h *Handler) Matches(r *http.Request) bool { } // latticeClaims reports whether (method, segments) maps to a served route. +// +// The resource-scoped forms additionally require the identifier segment to look +// like a VPC Lattice identifier (a known ID prefix or a vpc-lattice ARN). This +// is what lets a path-style S3 object op on a bucket named exactly like a +// Lattice root — e.g. `GET /services/mykey`, `DELETE /targetgroups/mykey` — fall +// through to the S3 catch-all instead of being mis-claimed here. func latticeClaims(method string, segs []string) bool { rest := segs[1:] switch segs[0] { case "authpolicy", "resourcepolicy": - // //{resourceArn}: an identifier is always present; PUT/GET/DELETE. - return len(rest) >= 1 && - (method == http.MethodGet || method == http.MethodPut || method == http.MethodDelete) + return claimsPolicyRoot(method, rest) case "tags": - // /tags/{resourceArn}: an identifier is always present; POST/GET/DELETE. - return len(rest) >= 1 && - (method == http.MethodGet || method == http.MethodPost || method == http.MethodDelete) + return claimsTagsRoot(method, rest) default: - return isLatticeMethod(method) + return claimsResourceRoot(method, rest) } } +// claimsPolicyRoot: /authpolicy|/resourcepolicy/{resourceIdentifier} — +// PUT/GET/DELETE, identifier required and Lattice-shaped. +func claimsPolicyRoot(method string, rest []string) bool { + return len(rest) >= 1 && isLatticeIdentifier(strings.Join(rest, "/")) && + (method == http.MethodGet || method == http.MethodPut || method == http.MethodDelete) +} + +// claimsTagsRoot: /tags/{resourceArn} — POST/GET/DELETE, ARN required. +func claimsTagsRoot(method string, rest []string) bool { + return len(rest) >= 1 && isLatticeIdentifier(strings.Join(rest, "/")) && + (method == http.MethodGet || method == http.MethodPost || method == http.MethodDelete) +} + +// claimsResourceRoot handles the collection/resource roots. A bare collection +// path is POST create / GET list — `GET /` still overlaps an S3 +// list-bucket on a like-named bucket, the one unavoidable residual for two REST +// services sharing an endpoint. A resource-scoped path is claimed only when the +// id segment is Lattice-shaped, so an S3 object key (e.g. "mykey") falls +// through to the S3 catch-all. +func claimsResourceRoot(method string, rest []string) bool { + if len(rest) == 0 { + return method == http.MethodPost || method == http.MethodGet + } + + return isLatticeMethod(method) && isLatticeIdentifier(rest[0]) +} + // isLatticeMethod reports whether m is one of the verbs the collection/resource // routes use (PUT is used only by the policy roots, handled separately). func isLatticeMethod(m string) bool { return m == http.MethodGet || m == http.MethodPost || m == http.MethodPatch || m == http.MethodDelete } +// isLatticeIdentifier reports whether s looks like a VPC Lattice resource +// identifier — a generated ID prefix or a vpc-lattice ARN — rather than an +// arbitrary S3 object key. Used to keep resource-scoped routes from claiming +// path-style S3 requests on buckets named like a Lattice root. +func isLatticeIdentifier(s string) bool { + if strings.Contains(s, "arn:aws:vpc-lattice") { + return true + } + + for _, p := range []string{ + "sn-", "svc-", "listener-", "rule-", "tg-", + "snva-", "snsa-", "snra-", "rcfg-", "rgw-", "als-", "dv-", "rea-", + } { + if strings.HasPrefix(s, p) { + return true + } + } + + return false +} + // ServeHTTP dispatches on the first path segment via the routes table. func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { segs := splitPath(r.URL.Path) diff --git a/server/aws/vpclattice/sdk_roundtrip_test.go b/server/aws/vpclattice/sdk_roundtrip_test.go index 1fef071c..9c427465 100644 --- a/server/aws/vpclattice/sdk_roundtrip_test.go +++ b/server/aws/vpclattice/sdk_roundtrip_test.go @@ -25,14 +25,20 @@ func TestMatchesDoesNotShadowS3(t *testing.T) { method, path string claim bool }{ - {http.MethodGet, "/servicenetworks", true}, // Lattice ListServiceNetworks - {http.MethodPost, "/services", true}, // Lattice CreateService - {http.MethodPatch, "/servicenetworks/sn-1", true}, // Lattice UpdateServiceNetwork - {http.MethodGet, "/tags/arn%3Aaws%3A%2Fsvc", true}, // Lattice ListTagsForResource - {http.MethodPut, "/services/mykey", false}, // S3 PutObject into bucket "services" - {http.MethodGet, "/tags", false}, // S3 ListObjects on bucket "tags" - {http.MethodPost, "/tags", false}, // S3 op on bucket "tags" (no ARN) - {http.MethodGet, "/notalatticeroot", false}, // unrelated + {http.MethodGet, "/servicenetworks", true}, // Lattice ListServiceNetworks + {http.MethodPost, "/services", true}, // Lattice CreateService + {http.MethodPatch, "/servicenetworks/sn-1", true}, // Lattice UpdateServiceNetwork (id-shaped) + {http.MethodGet, "/services/svc-123", true}, // Lattice GetService (id-shaped) + {http.MethodDelete, "/targetgroups/tg-1", true}, // Lattice DeleteTargetGroup (id-shaped) + {http.MethodGet, "/tags/arn:aws:vpc-lattice:us-east-1:1:servicenetwork%2Fsn-1", true}, // ListTagsForResource + {http.MethodPut, "/services/mykey", false}, // S3 PutObject into bucket "services" + {http.MethodGet, "/services/mykey", false}, // S3 GetObject in bucket "services" + {http.MethodDelete, "/targetgroups/mykey", false}, // S3 DeleteObject in bucket "targetgroups" + {http.MethodGet, "/servicenetworks/mykey", false}, // S3 GetObject in bucket "servicenetworks" + {http.MethodGet, "/tags", false}, // S3 ListObjects on bucket "tags" + {http.MethodGet, "/tags/mykey", false}, // S3 GetObject in bucket "tags" + {http.MethodPost, "/tags", false}, // S3 op on bucket "tags" (no ARN) + {http.MethodGet, "/notalatticeroot", false}, // unrelated } for _, c := range cases {