diff --git a/docs/architecture.md b/docs/architecture.md index c927d989c..b283b764b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -57,7 +57,7 @@ Until every service has landed, OCI diverges from the pattern above in two docum Sitting above Layer 2 (driver interfaces) are **cross-service engines** that consume driver interfaces directly without going through the portable API. They're peers of each other, not layers in the three-layer stack. Two exist today: - `features/topology/` -- reads from compute, networking, and DNS drivers to simulate real network connectivity (`CanConnect`, `TraceRoute`, `Resolve`, security-group and NACL evaluation). See [topology.md](topology.md). -- `server/` -- exposes driver interfaces over HTTP in each cloud's native SDK wire format, so real `aws-sdk-go-v2`, `azure-sdk-for-go`, and `cloud.google.com/go` clients work against CloudEmu by only changing the endpoint. Covers storage, compute, relational and NoSQL databases (incl. Redis/MemoryDB and Cassandra — Keyspaces and Azure Managed Cassandra), networking, monitoring/logging, serverless, containers (registry + ECS + Kubernetes), messaging, streaming (Kinesis Data Streams and MSK / managed Kafka), secrets, key management (KMS), certificate management (ACM), web application firewall (WAFv2), workflow orchestration (Step Functions), search/analytics (OpenSearch), audit logging (CloudTrail), configuration management (AWS Config), data integration / ETL and the Data Catalog (Glue), threat detection (GuardDuty), file systems (EFS), email (SES v2), IAM, resource discovery, and AI/ML (Bedrock, SageMaker, Azure AI, Vertex AI) across all 3 providers. Uses a pluggable `Handler` registry so new services drop in as self-contained packages without touching the core. See [sdk-server.md](sdk-server.md) and the full catalog in [services.md](services.md). +- `server/` -- exposes driver interfaces over HTTP in each cloud's native SDK wire format, so real `aws-sdk-go-v2`, `azure-sdk-for-go`, and `cloud.google.com/go` clients work against CloudEmu by only changing the endpoint. Covers storage, compute, relational and NoSQL databases (incl. Redis/MemoryDB and Cassandra — Keyspaces and Azure Managed Cassandra), networking, monitoring/logging, serverless, containers (registry + ECS + Kubernetes + App Runner), messaging, streaming (Kinesis Data Streams and MSK / managed Kafka), secrets, key management (KMS), certificate management (ACM), web application firewall (WAFv2), workflow orchestration (Step Functions), search/analytics (OpenSearch), audit logging (CloudTrail), configuration management (AWS Config), data integration / ETL and the Data Catalog (Glue), threat detection (GuardDuty), file systems (EFS), email (SES v2), IAM, resource discovery, and AI/ML (Bedrock, SageMaker, Azure AI, Vertex AI) across all 3 providers. Uses a pluggable `Handler` registry so new services drop in as self-contained packages without touching the core. See [sdk-server.md](sdk-server.md) and the full catalog in [services.md](services.md). Both engines depend only on Layer 2 interfaces -- never on concrete provider types -- so they work uniformly across AWS, Azure, and GCP backends. diff --git a/docs/coverage/README.md b/docs/coverage/README.md index 19cda07a2..4533ba8df 100644 --- a/docs/coverage/README.md +++ b/docs/coverage/README.md @@ -10,6 +10,7 @@ capability the code does not implement. Machine-readable: [`coverage.json`](./co | Service | AWS | Azure | GCP | OCI | Operations | | --- | --- | --- | --- | --- | --- | | `acm` | [ACM](./aws/acm.md) | — | — | — | 17 | +| `apprunner` | [AppRunner](./aws/apprunner.md) | — | — | — | 37 | | `azureai` | — | [AI](./azure/ai.md) | — | — | 92 | | `azuresearch` | — | [Search](./azure/search.md) | — | — | 53 | | `bedrock` | [Bedrock](./aws/bedrock.md) | — | — | — | 65 | diff --git a/docs/coverage/aws/README.md b/docs/coverage/aws/README.md index b9c1844bd..d144b932e 100644 --- a/docs/coverage/aws/README.md +++ b/docs/coverage/aws/README.md @@ -6,6 +6,7 @@ Services cloudemu emulates for AWS, by native name. Back to the [cross-provider | AWS service | Portable service | Operations | | --- | --- | --- | | [ACM](./acm.md) | `acm` | 17 | +| [AppRunner](./apprunner.md) | `apprunner` | 37 | | [Bedrock](./bedrock.md) | `bedrock` | 65 | | [BedrockAgent](./bedrockagent.md) | `bedrockagent` | 29 | | [BedrockAgentRuntime](./bedrockagentruntime.md) | `bedrockagentruntime` | 3 | diff --git a/docs/coverage/aws/apprunner.md b/docs/coverage/aws/apprunner.md new file mode 100644 index 000000000..d61ff5aad --- /dev/null +++ b/docs/coverage/aws/apprunner.md @@ -0,0 +1,50 @@ + +# AppRunner + +AWS's `apprunner` service · portable interface `driver.AppRunner` · [AWS index](./README.md) + +## Operations (37) + +| Operation | Description | +| --- | --- | +| `AssociateCustomDomain` | CustomDomain. The dnsTarget is the App Runner subdomain the | +| `CreateAutoScalingConfiguration` | AutoScalingConfiguration. | +| `CreateConnection` | Connection. | +| `CreateObservabilityConfiguration` | ObservabilityConfiguration. | +| `CreateService` | Service. | +| `CreateVpcConnector` | VpcConnector. | +| `CreateVpcIngressConnection` | VpcIngressConnection. | +| `DeleteAutoScalingConfiguration` | | +| `DeleteConnection` | | +| `DeleteObservabilityConfiguration` | | +| `DeleteService` | | +| `DeleteVpcConnector` | | +| `DeleteVpcIngressConnection` | | +| `DescribeAutoScalingConfiguration` | | +| `DescribeCustomDomains` | | +| `DescribeObservabilityConfiguration` | | +| `DescribeService` | | +| `DescribeVpcConnector` | | +| `DescribeVpcIngressConnection` | | +| `DisassociateCustomDomain` | | +| `ListAutoScalingConfigurations` | | +| `ListConnections` | | +| `ListObservabilityConfigurations` | | +| `ListOperations` | Operations. | +| `ListServices` | | +| `ListServicesForAutoScalingConfiguration` | | +| `ListTagsForResource` | | +| `ListVpcConnectors` | | +| `ListVpcIngressConnections` | | +| `PauseService` | | +| `ResumeService` | | +| `StartDeployment` | | +| `TagResource` | Tags. | +| `UntagResource` | | +| `UpdateDefaultAutoScalingConfiguration` | | +| `UpdateService` | | +| `UpdateVpcIngressConnection` | | + +## Not in scope + +_Not documented yet. See the [emulator boundary](../../../README.md) for cloudemu-wide non-goals._ diff --git a/docs/coverage/coverage.json b/docs/coverage/coverage.json index 0b0ff993e..b09060681 100644 --- a/docs/coverage/coverage.json +++ b/docs/coverage/coverage.json @@ -59,6 +59,135 @@ "aws": "ACM" } }, + { + "service": "apprunner", + "interface": "AppRunner", + "operations": [ + { + "name": "AssociateCustomDomain", + "doc": "CustomDomain. The dnsTarget is the App Runner subdomain the" + }, + { + "name": "CreateAutoScalingConfiguration", + "doc": "AutoScalingConfiguration." + }, + { + "name": "CreateConnection", + "doc": "Connection." + }, + { + "name": "CreateObservabilityConfiguration", + "doc": "ObservabilityConfiguration." + }, + { + "name": "CreateService", + "doc": "Service." + }, + { + "name": "CreateVpcConnector", + "doc": "VpcConnector." + }, + { + "name": "CreateVpcIngressConnection", + "doc": "VpcIngressConnection." + }, + { + "name": "DeleteAutoScalingConfiguration" + }, + { + "name": "DeleteConnection" + }, + { + "name": "DeleteObservabilityConfiguration" + }, + { + "name": "DeleteService" + }, + { + "name": "DeleteVpcConnector" + }, + { + "name": "DeleteVpcIngressConnection" + }, + { + "name": "DescribeAutoScalingConfiguration" + }, + { + "name": "DescribeCustomDomains" + }, + { + "name": "DescribeObservabilityConfiguration" + }, + { + "name": "DescribeService" + }, + { + "name": "DescribeVpcConnector" + }, + { + "name": "DescribeVpcIngressConnection" + }, + { + "name": "DisassociateCustomDomain" + }, + { + "name": "ListAutoScalingConfigurations" + }, + { + "name": "ListConnections" + }, + { + "name": "ListObservabilityConfigurations" + }, + { + "name": "ListOperations", + "doc": "Operations." + }, + { + "name": "ListServices" + }, + { + "name": "ListServicesForAutoScalingConfiguration" + }, + { + "name": "ListTagsForResource" + }, + { + "name": "ListVpcConnectors" + }, + { + "name": "ListVpcIngressConnections" + }, + { + "name": "PauseService" + }, + { + "name": "ResumeService" + }, + { + "name": "StartDeployment" + }, + { + "name": "TagResource", + "doc": "Tags." + }, + { + "name": "UntagResource" + }, + { + "name": "UpdateDefaultAutoScalingConfiguration" + }, + { + "name": "UpdateService" + }, + { + "name": "UpdateVpcIngressConnection" + } + ], + "providers": { + "aws": "AppRunner" + } + }, { "service": "azureai", "interface": "AzureAI", diff --git a/docs/services.md b/docs/services.md index 55f03355f..94535d762 100644 --- a/docs/services.md +++ b/docs/services.md @@ -50,6 +50,7 @@ This document lists every service and operation available in CloudEmu across all | 37 | Data Integration (ETL / Data Catalog) | `glue` | — | — | | 38 | Threat Detection | `guardduty` | — | — | | 39 | Streaming (Managed Kafka) | `kafka` | — | — | +| 40 | Container App Hosting | `apprunner` | — | — | --- @@ -3147,6 +3148,43 @@ delete) is still enforced. **Total: 59 operations.** +## 40. Container App Hosting (App Runner) + +**Driver interface:** `services/apprunner/driver/` +**AWS:** App Runner (AWS JSON 1.0, `X-Amz-Target: AppRunner.`) | **Azure:** — | **GCP:** — + +AWS-only. Real `aws-sdk-go-v2/service/apprunner` clients (and the `aws apprunner` +CLI) work against the SDK-compat server (`awsserver.Drivers{AppRunner: cloud.AppRunner}`). +The handler matches on the `X-Amz-Target` prefix `AppRunner.`, so it is +unambiguous with the S3 catch-all. Full `aws-sdk-go-v2/service/apprunner` +parity — every one of the 37 client operations is implemented. + +**Services behave realistically.** `CreateService` mints a service ARN + ID and +returns a service that is immediately `RUNNING` (deterministic — no wall-clock +provisioning) with a synthesized `ServiceUrl`. `PauseService`/`ResumeService` +enforce the state machine (`InvalidStateException` from an illegal state), and +every mutation records an `OperationSummary` surfaced by `ListOperations`. +Auto-scaling, observability, and VPC-connector configurations are revision-managed +(shared name, incrementing revision, one `Latest`); reads deep-copy. Errors map +per-op to the exception each operation actually models — a missing resource on an +op that models `ResourceNotFoundException` returns that, while ops that don't +(e.g. `AssociateCustomDomain`, `CreateVpcIngressConnection`) return +`InvalidRequestException`. + +| Family | Operations | +|--------|-----------| +| Services | CreateService, DescribeService, UpdateService, DeleteService, ListServices, PauseService, ResumeService, StartDeployment | +| Auto-scaling configs | CreateAutoScalingConfiguration, DescribeAutoScalingConfiguration, DeleteAutoScalingConfiguration, ListAutoScalingConfigurations, UpdateDefaultAutoScalingConfiguration, ListServicesForAutoScalingConfiguration | +| Connections | CreateConnection, DeleteConnection, ListConnections | +| Observability configs | Create/Describe/Delete/ListObservabilityConfigurations | +| VPC connectors | Create/Describe/Delete/ListVpcConnectors | +| VPC ingress connections | Create/Describe/Delete/List/UpdateVpcIngressConnection | +| Custom domains | AssociateCustomDomain, DisassociateCustomDomain, DescribeCustomDomains | +| Operations | ListOperations | +| Tags | ListTagsForResource, TagResource, UntagResource | + +**Total: 37 operations.** + ## Summary | Service | Operations | @@ -3203,7 +3241,8 @@ delete) is still enforced. | Data Integration — AWS Glue | 299 | | Threat Detection — Amazon GuardDuty | 87 | | Streaming — Amazon MSK | 59 | -| **Grand Total** | **2749** (+138 optional) | +| Container App Hosting — AWS App Runner | 37 | +| **Grand Total** | **2786** (+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 c36101209..32d13ec42 100644 --- a/go.mod +++ b/go.mod @@ -43,6 +43,7 @@ require ( github.com/aws/aws-sdk-go-v2/config v1.32.14 github.com/aws/aws-sdk-go-v2/credentials v1.19.14 github.com/aws/aws-sdk-go-v2/service/acm v1.38.3 + github.com/aws/aws-sdk-go-v2/service/apprunner v1.39.14 github.com/aws/aws-sdk-go-v2/service/autoscaling v1.66.1 github.com/aws/aws-sdk-go-v2/service/bedrock v1.64.0 github.com/aws/aws-sdk-go-v2/service/bedrockagent v1.58.0 diff --git a/go.sum b/go.sum index 58a180141..3f33b2715 100644 --- a/go.sum +++ b/go.sum @@ -136,6 +136,8 @@ github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.36 h1:jbGY4CXLzZElOXgGsexlC3Hi+3Y github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.36/go.mod h1:uBu/9aKsS/UQGc72RAt3y54kjgYQxmhut8ZD2dXCDNE= github.com/aws/aws-sdk-go-v2/service/acm v1.38.3 h1:Fzab84hCu3rw9R9Y3mH7SHfr/cSEHnCB0Mq1JCdr9t0= github.com/aws/aws-sdk-go-v2/service/acm v1.38.3/go.mod h1:yCteizCNPaHt0SnNusoGGHvy0JDB0tvGDTVhEt5anZM= +github.com/aws/aws-sdk-go-v2/service/apprunner v1.39.14 h1:C8XTHmSZQtTrOcmYXkzQUiHD97mINN/MnoOG7/vv9pk= +github.com/aws/aws-sdk-go-v2/service/apprunner v1.39.14/go.mod h1:7QwBy8GT8JH3GJKNcn7gEKqJhoTC7MvLeD94S4Balo0= github.com/aws/aws-sdk-go-v2/service/autoscaling v1.66.1 h1:kGlbhb5GMfkP/bcqcbt3oDi50kwDTpRmNzYUY9LqbLk= github.com/aws/aws-sdk-go-v2/service/autoscaling v1.66.1/go.mod h1:z45kurrOonQepd3SN5LIgropAn1NGHwBn1yOMF+QVFU= github.com/aws/aws-sdk-go-v2/service/bedrock v1.64.0 h1:jZOg03lM41zl89h17avGr6AFqAb9g3s/rZytTQslz14= @@ -174,10 +176,10 @@ github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2 v1.56.0 h1:OJRqQ6G7R github.com/aws/aws-sdk-go-v2/service/elasticloadbalancingv2 v1.56.0/go.mod h1:qNnJkZTDHDL2sO8hyVH2yILcfSEkjP/pIns2JsF1g1o= github.com/aws/aws-sdk-go-v2/service/eventbridge v1.47.0 h1:LuPLDBMCmldgg779sq8swfEdRNMeKlaT852uxKYCOkk= github.com/aws/aws-sdk-go-v2/service/eventbridge v1.47.0/go.mod h1:3g/foYPw/4CT8yV7/A1QsbvnhDZW/2x2uzl4vkqX49o= -github.com/aws/aws-sdk-go-v2/service/guardduty v1.79.3 h1:AGUhgPCJKoXbNbk93EtLt6nzu7WoaFyln1g753W9E6g= -github.com/aws/aws-sdk-go-v2/service/guardduty v1.79.3/go.mod h1:OOwqiU2hPl8v3FomNDgNPj3RCCsTfrsjzgWmuqHidhw= github.com/aws/aws-sdk-go-v2/service/glue v1.152.0 h1:82K11SuRv2c5gmlwJXImQrs2+nzro1KF/MOjr5qzVZ0= github.com/aws/aws-sdk-go-v2/service/glue v1.152.0/go.mod h1:3kgUanQ41XP3SMxOurl1gu9cQ2B4AltelI+0srIz0Yg= +github.com/aws/aws-sdk-go-v2/service/guardduty v1.79.3 h1:AGUhgPCJKoXbNbk93EtLt6nzu7WoaFyln1g753W9E6g= +github.com/aws/aws-sdk-go-v2/service/guardduty v1.79.3/go.mod h1:OOwqiU2hPl8v3FomNDgNPj3RCCsTfrsjzgWmuqHidhw= github.com/aws/aws-sdk-go-v2/service/iam v1.53.10 h1:kcN3I3llO7VwIY5w3Pc5FmEonpsr23Ou7Cwk4qf7dik= github.com/aws/aws-sdk-go-v2/service/iam v1.53.10/go.mod h1:1vkJzjCYC3byO0kIrBqLPzvZpuvYhPXkuyARs6E7tM4= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.14 h1:SA43nfaY7+1jjMNIc2ywu99JLJLButtIdLP6j+bT870= diff --git a/providers/aws/apprunner/apprunner.go b/providers/aws/apprunner/apprunner.go new file mode 100644 index 000000000..747d944c3 --- /dev/null +++ b/providers/aws/apprunner/apprunner.go @@ -0,0 +1,171 @@ +// Package apprunner provides an in-memory mock implementation of AWS App Runner. +// +// The mock does not build or run containers: CreateService completes +// deterministically (Status immediately RUNNING) with a synthesized ServiceUrl, +// and lifecycle transitions (Pause/Resume/Delete) mutate stored state directly. +// Large nested configuration blocks are stored verbatim as json.RawMessage. +// +// Every App Runner operation is implemented: services, auto scaling +// configurations, connections, observability configurations, VPC connectors, VPC +// ingress connections, per-service custom domains, per-service operations, and +// resource tags. +package apprunner + +import ( + "encoding/json" + "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/apprunner/driver" +) + +// Compile-time check that Mock implements driver.AppRunner. +var _ driver.AppRunner = (*Mock)(nil) + +const ( + // defaultPageSize is the ListX page size when MaxResults is unset. + defaultPageSize = 100 + // service segment of App Runner ARNs. + serviceSegment = "apprunner" +) + +// Mock is an in-memory implementation of AWS App Runner. +type Mock struct { + services *memstore.Store[*serviceData] + connections *memstore.Store[*connectionData] + + // ascMu guards the auto scaling configuration revision map: revision + // counters must be managed atomically under one lock so concurrent + // same-name creates get distinct, incrementing revisions. + ascMu sync.Mutex + // ascByArn stores every ASC revision keyed by its full ARN. + ascByArn map[string]*driver.AutoScalingConfiguration + // ascLatestRev tracks the highest revision minted per configuration name. + ascLatestRev map[string]int32 + + // obsMu guards the observability configuration revision map, mirroring the + // ASC revision model (concurrent same-name creates increment atomically). + obsMu sync.Mutex + // obsByArn stores every observability configuration revision by its ARN. + obsByArn map[string]*driver.ObservabilityConfiguration + // obsLatestRev tracks the highest revision minted per configuration name. + obsLatestRev map[string]int32 + + // vpcConnectors holds VPC connector revisions keyed by their ARN. App Runner + // supports one revision per name today, so a plain store is sufficient. + vpcConnectors *memstore.Store[*vpcConnectorData] + + // vpcIngress holds VPC ingress connections keyed by their ARN. + vpcIngress *memstore.Store[*vpcIngressData] + + opts *config.Options +} + +// serviceData is a service plus its own lock, including the per-service +// operations list and custom-domain map surfaced by ListOperations and +// DescribeCustomDomains. +type serviceData struct { + svc driver.Service + ops []driver.OperationSummary + domains map[string]*driver.CustomDomain + mu sync.RWMutex +} + +// vpcConnectorData is a VPC connector plus its own lock. +type vpcConnectorData struct { + vc driver.VpcConnector + mu sync.RWMutex +} + +// vpcIngressData is a VPC ingress connection plus its own lock. +type vpcIngressData struct { + vic driver.VpcIngressConnection + mu sync.RWMutex +} + +// connectionData is a connection plus its own lock. +type connectionData struct { + conn driver.Connection + mu sync.RWMutex +} + +// New creates a new App Runner mock with the given configuration options. +func New(opts *config.Options) *Mock { + return &Mock{ + services: memstore.New[*serviceData](), + connections: memstore.New[*connectionData](), + ascByArn: make(map[string]*driver.AutoScalingConfiguration), + ascLatestRev: make(map[string]int32), + obsByArn: make(map[string]*driver.ObservabilityConfiguration), + obsLatestRev: make(map[string]int32), + vpcConnectors: memstore.New[*vpcConnectorData](), + vpcIngress: memstore.New[*vpcIngressData](), + opts: opts, + } +} + +func (m *Mock) now() time.Time { + return m.opts.Clock.Now().UTC() +} + +func (m *Mock) serviceArn(name, id string) string { + return idgen.AWSARN(serviceSegment, m.opts.Region, m.opts.AccountID, "service/"+name+"/"+id) +} + +func (m *Mock) connectionArn(name string) string { + return idgen.AWSARN(serviceSegment, m.opts.Region, m.opts.AccountID, "connection/"+name+"/"+idgen.GenerateID("")) +} + +func (m *Mock) ascArn(name string, revision int32) string { + return idgen.AWSARN(serviceSegment, m.opts.Region, m.opts.AccountID, + "autoscalingconfiguration/"+name+"/"+itoa(revision)+"/"+idgen.GenerateID("")) +} + +func (m *Mock) obsArn(name string, revision int32) string { + return idgen.AWSARN(serviceSegment, m.opts.Region, m.opts.AccountID, + "observabilityconfiguration/"+name+"/"+itoa(revision)+"/"+idgen.GenerateID("")) +} + +func (m *Mock) vpcConnectorArn(name string, revision int32) string { + return idgen.AWSARN(serviceSegment, m.opts.Region, m.opts.AccountID, + "vpcconnector/"+name+"/"+itoa(revision)+"/"+idgen.GenerateID("")) +} + +func (m *Mock) vpcIngressArn(name string) string { + return idgen.AWSARN(serviceSegment, m.opts.Region, m.opts.AccountID, + "vpcingressconnection/"+name+"/"+idgen.GenerateID("")) +} + +func copyTags(in map[string]string) map[string]string { + if in == nil { + return nil + } + + out := make(map[string]string, len(in)) + for k, v := range in { + out[k] = v + } + + return out +} + +// copyRaw deep-copies a json.RawMessage so Describe/List callers can't mutate +// stored bytes. +func copyRaw(in json.RawMessage) json.RawMessage { + if in == nil { + return nil + } + + return append(json.RawMessage(nil), in...) +} + +func copyStrings(in []string) []string { + if in == nil { + return nil + } + + return append([]string(nil), in...) +} diff --git a/providers/aws/apprunner/apprunner_test.go b/providers/aws/apprunner/apprunner_test.go new file mode 100644 index 000000000..13b9f4327 --- /dev/null +++ b/providers/aws/apprunner/apprunner_test.go @@ -0,0 +1,750 @@ +package apprunner_test + +import ( + "context" + "encoding/json" + "errors" + "strconv" + "sync" + "testing" + "time" + + "github.com/stackshy/cloudemu/v2/config" + "github.com/stackshy/cloudemu/v2/providers/aws/apprunner" + "github.com/stackshy/cloudemu/v2/services/apprunner/driver" +) + +func newMock(t *testing.T) *apprunner.Mock { + t.Helper() + + opts := config.NewOptions( + config.WithRegion("us-east-1"), + config.WithAccountID("123456789012"), + config.WithClock(config.NewFakeClock(time.Unix(1700000000, 0).UTC())), + ) + + return apprunner.New(opts) +} + +func createService(t *testing.T, m *apprunner.Mock, name string) *driver.Service { + t.Helper() + + res, err := m.CreateService(context.Background(), driver.CreateServiceInput{ + ServiceName: name, + SourceConfiguration: json.RawMessage(`{"ImageRepository":{"ImageIdentifier":"public.ecr.aws/x/y:latest"}}`), + }) + if err != nil { + t.Fatalf("CreateService: %v", err) + } + + return res.Service +} + +func exceptionOf(t *testing.T, err error) string { + t.Helper() + + var apiErr *driver.APIError + if !errors.As(err, &apiErr) { + t.Fatalf("want *driver.APIError, got %v", err) + } + + return apiErr.Exception +} + +func TestServiceCRUD(t *testing.T) { + ctx := context.Background() + m := newMock(t) + + svc := createService(t, m, "web") + if svc.Status != driver.ServiceStatusRunning { + t.Fatalf("status = %s, want RUNNING", svc.Status) + } + + if svc.ServiceURL == "" || svc.ServiceID == "" { + t.Fatal("expected synthesized ServiceUrl and ServiceId") + } + + got, err := m.DescribeService(ctx, svc.ServiceArn) + if err != nil { + t.Fatalf("DescribeService: %v", err) + } + + if got.ServiceName != "web" { + t.Fatalf("name = %s", got.ServiceName) + } + + up, err := m.UpdateService(ctx, driver.UpdateServiceInput{ + ServiceArn: svc.ServiceArn, InstanceConfiguration: json.RawMessage(`{"Cpu":"2 vCPU"}`), + }) + if err != nil { + t.Fatalf("UpdateService: %v", err) + } + + if up.OperationID == "" { + t.Fatal("UpdateService: expected an OperationId") + } + + del, err := m.DeleteService(ctx, svc.ServiceArn) + if err != nil { + t.Fatalf("DeleteService: %v", err) + } + + if del.Service.Status != driver.ServiceStatusDeleted { + t.Fatalf("after delete status = %s, want DELETED", del.Service.Status) + } +} + +func TestDescribeMissingService(t *testing.T) { + m := newMock(t) + + _, err := m.DescribeService(context.Background(), "arn:aws:apprunner:us-east-1:123456789012:service/x/missing") + if err == nil { + t.Fatal("expected error for missing service") + } + + if got := exceptionOf(t, err); got != driver.ExResourceNotFound { + t.Fatalf("exception = %s, want ResourceNotFoundException", got) + } +} + +func TestPauseIllegalState(t *testing.T) { + ctx := context.Background() + m := newMock(t) + svc := createService(t, m, "svc") + + if _, err := m.PauseService(ctx, svc.ServiceArn); err != nil { + t.Fatalf("PauseService (RUNNING->PAUSED): %v", err) + } + + // Pausing an already-PAUSED service is an illegal transition. + _, err := m.PauseService(ctx, svc.ServiceArn) + if err == nil { + t.Fatal("expected InvalidStateException pausing a PAUSED service") + } + + if got := exceptionOf(t, err); got != driver.ExInvalidState { + t.Fatalf("exception = %s, want InvalidStateException", got) + } + + if _, err := m.ResumeService(ctx, svc.ServiceArn); err != nil { + t.Fatalf("ResumeService (PAUSED->RUNNING): %v", err) + } +} + +func TestStartDeploymentRequiresRunning(t *testing.T) { + ctx := context.Background() + m := newMock(t) + svc := createService(t, m, "svc") + + opID, err := m.StartDeployment(ctx, svc.ServiceArn) + if err != nil || opID == "" { + t.Fatalf("StartDeployment: id=%q err=%v", opID, err) + } + + if _, err := m.PauseService(ctx, svc.ServiceArn); err != nil { + t.Fatalf("PauseService: %v", err) + } + + if _, err := m.StartDeployment(ctx, svc.ServiceArn); err == nil { + t.Fatal("expected InvalidStateException deploying a PAUSED service") + } +} + +func TestListServicesPagination(t *testing.T) { + ctx := context.Background() + m := newMock(t) + + for i := 0; i < 3; i++ { + createService(t, m, "svc") + } + + page, token, err := m.ListServices(ctx, "", 2) + if err != nil { + t.Fatalf("ListServices: %v", err) + } + + if len(page) != 2 || token == "" { + t.Fatalf("first page = %d items, token=%q; want 2 + token", len(page), token) + } + + rest, token2, err := m.ListServices(ctx, token, 2) + if err != nil { + t.Fatalf("ListServices(page2): %v", err) + } + + if len(rest) != 1 || token2 != "" { + t.Fatalf("second page = %d items, token=%q; want 1 + empty", len(rest), token2) + } +} + +func TestASCRevisionIncrements(t *testing.T) { + ctx := context.Background() + m := newMock(t) + + c1, err := m.CreateAutoScalingConfiguration(ctx, "hi-traffic", 100, 25, 1) + if err != nil { + t.Fatalf("Create ASC r1: %v", err) + } + + if c1.Revision != 1 || !c1.Latest { + t.Fatalf("first revision = %d latest=%v, want 1/true", c1.Revision, c1.Latest) + } + + c2, err := m.CreateAutoScalingConfiguration(ctx, "hi-traffic", 200, 25, 1) + if err != nil { + t.Fatalf("Create ASC r2: %v", err) + } + + if c2.Revision != 2 || !c2.Latest { + t.Fatalf("second revision = %d latest=%v, want 2/true", c2.Revision, c2.Latest) + } + + // The prior revision must no longer be Latest. + got1, err := m.DescribeAutoScalingConfiguration(ctx, c1.Arn) + if err != nil { + t.Fatalf("Describe ASC r1: %v", err) + } + + if got1.Latest { + t.Fatal("revision 1 should no longer be Latest after revision 2") + } + + // A different name starts back at revision 1. + c3, err := m.CreateAutoScalingConfiguration(ctx, "lo-traffic", 50, 10, 1) + if err != nil { + t.Fatalf("Create ASC other name: %v", err) + } + + if c3.Revision != 1 { + t.Fatalf("new-name revision = %d, want 1", c3.Revision) + } +} + +func TestUpdateDefaultASC(t *testing.T) { + ctx := context.Background() + m := newMock(t) + + c1, _ := m.CreateAutoScalingConfiguration(ctx, "a", 100, 25, 1) + c2, _ := m.CreateAutoScalingConfiguration(ctx, "b", 100, 25, 1) + + if _, err := m.UpdateDefaultAutoScalingConfiguration(ctx, c1.Arn); err != nil { + t.Fatalf("UpdateDefault c1: %v", err) + } + + if _, err := m.UpdateDefaultAutoScalingConfiguration(ctx, c2.Arn); err != nil { + t.Fatalf("UpdateDefault c2: %v", err) + } + + got1, _ := m.DescribeAutoScalingConfiguration(ctx, c1.Arn) + if got1.IsDefault { + t.Fatal("c1 should no longer be default after c2 was set") + } + + got2, _ := m.DescribeAutoScalingConfiguration(ctx, c2.Arn) + if !got2.IsDefault { + t.Fatal("c2 should be default") + } +} + +func TestConnectionCRUD(t *testing.T) { + ctx := context.Background() + m := newMock(t) + + conn, err := m.CreateConnection(ctx, "gh", driver.ProviderTypeGitHub, nil) + if err != nil { + t.Fatalf("CreateConnection: %v", err) + } + + if conn.Status != driver.ConnectionStatusPendingHandshake { + t.Fatalf("status = %s, want PENDING_HANDSHAKE", conn.Status) + } + + list, _, err := m.ListConnections(ctx, "", "", 0) + if err != nil || len(list) != 1 { + t.Fatalf("ListConnections: n=%d err=%v", len(list), err) + } + + if _, err := m.DeleteConnection(ctx, conn.Arn); err != nil { + t.Fatalf("DeleteConnection: %v", err) + } + + list, _, _ = m.ListConnections(ctx, "", "", 0) + if len(list) != 0 { + t.Fatalf("after delete want 0 connections, got %d", len(list)) + } +} + +func TestConnectionInvalidProvider(t *testing.T) { + m := newMock(t) + + _, err := m.CreateConnection(context.Background(), "x", "GITLAB", nil) + if err == nil { + t.Fatal("expected InvalidRequestException for unknown provider") + } + + if got := exceptionOf(t, err); got != driver.ExInvalidRequest { + t.Fatalf("exception = %s, want InvalidRequestException", got) + } +} + +func TestObservabilityRevisions(t *testing.T) { + ctx := context.Background() + m := newMock(t) + + trace := json.RawMessage(`{"Vendor":"AWSXRAY"}`) + + c1, err := m.CreateObservabilityConfiguration(ctx, "obs", trace, nil) + if err != nil || c1.Revision != 1 || !c1.Latest || c1.Status != driver.StatusActive { + t.Fatalf("Create obs r1: %+v err=%v", c1, err) + } + + c2, err := m.CreateObservabilityConfiguration(ctx, "obs", trace, nil) + if err != nil || c2.Revision != 2 { + t.Fatalf("Create obs r2: %+v err=%v", c2, err) + } + + got1, _ := m.DescribeObservabilityConfiguration(ctx, c1.Arn) + if got1.Latest { + t.Fatal("obs r1 should no longer be Latest") + } + + del, err := m.DeleteObservabilityConfiguration(ctx, c1.Arn) + if err != nil || del.Status != driver.StatusInactive { + t.Fatalf("Delete obs: %+v err=%v", del, err) + } + + list, _, err := m.ListObservabilityConfigurations(ctx, "obs", true, "", 0) + if err != nil { + t.Fatalf("List obs: %v", err) + } + + if len(list) != 1 || list[0].Revision != 2 { + t.Fatalf("latestOnly list = %d items rev=%v", len(list), list) + } +} + +func TestObservabilityReservedName(t *testing.T) { + m := newMock(t) + + _, err := m.CreateObservabilityConfiguration(context.Background(), "DefaultConfiguration", nil, nil) + if err == nil || exceptionOf(t, err) != driver.ExInvalidRequest { + t.Fatalf("expected InvalidRequestException for reserved name, got %v", err) + } +} + +func TestVpcConnectorCRUD(t *testing.T) { + ctx := context.Background() + m := newMock(t) + + vc, err := m.CreateVpcConnector(ctx, "vc", []string{"subnet-1", "subnet-2"}, []string{"sg-1"}, nil) + if err != nil || vc.Status != driver.StatusActive || vc.Revision != 1 { + t.Fatalf("CreateVpcConnector: %+v err=%v", vc, err) + } + + got, err := m.DescribeVpcConnector(ctx, vc.Arn) + if err != nil || len(got.Subnets) != 2 { + t.Fatalf("DescribeVpcConnector: %+v err=%v", got, err) + } + + // Mutating the returned slice must not affect stored state. + got.Subnets[0] = "MUTATED" + + again, _ := m.DescribeVpcConnector(ctx, vc.Arn) + if again.Subnets[0] == "MUTATED" { + t.Fatal("VpcConnector Subnets slice was aliased") + } + + del, err := m.DeleteVpcConnector(ctx, vc.Arn) + if err != nil || del.Status != driver.StatusInactive { + t.Fatalf("DeleteVpcConnector: %+v err=%v", del, err) + } + + list, _, _ := m.ListVpcConnectors(ctx, "", 0) + if len(list) != 1 { + t.Fatalf("ListVpcConnectors = %d, want 1", len(list)) + } +} + +func TestVpcConnectorMissing(t *testing.T) { + m := newMock(t) + + _, err := m.DescribeVpcConnector(context.Background(), "arn:aws:apprunner:us-east-1:123456789012:vpcconnector/x/1/y") + if err == nil || exceptionOf(t, err) != driver.ExResourceNotFound { + t.Fatalf("expected ResourceNotFoundException, got %v", err) + } +} + +func TestVpcIngressCRUDAndFilter(t *testing.T) { + ctx := context.Background() + m := newMock(t) + svc := createService(t, m, "ingress-svc") + + cfg := json.RawMessage(`{"VpcId":"vpc-1","VpcEndpointId":"vpce-1"}`) + + vic, err := m.CreateVpcIngressConnection(ctx, "vic", svc.ServiceArn, cfg, nil) + if err != nil || vic.Status != driver.VpcIngressStatusAvailable || vic.DomainName == "" { + t.Fatalf("CreateVpcIngressConnection: %+v err=%v", vic, err) + } + + // Filter by service ARN. + byService, _, _ := m.ListVpcIngressConnections(ctx, svc.ServiceArn, "", "", 0) + if len(byService) != 1 { + t.Fatalf("filter by service = %d, want 1", len(byService)) + } + + // Filter by VPC endpoint id. + byEndpoint, _, _ := m.ListVpcIngressConnections(ctx, "", "vpce-1", "", 0) + if len(byEndpoint) != 1 { + t.Fatalf("filter by endpoint = %d, want 1", len(byEndpoint)) + } + + miss, _, _ := m.ListVpcIngressConnections(ctx, "", "vpce-none", "", 0) + if len(miss) != 0 { + t.Fatalf("filter by missing endpoint = %d, want 0", len(miss)) + } + + up, err := m.UpdateVpcIngressConnection(ctx, vic.Arn, json.RawMessage(`{"VpcId":"vpc-2","VpcEndpointId":"vpce-2"}`)) + if err != nil { + t.Fatalf("UpdateVpcIngressConnection: %v", err) + } + + if string(up.IngressVpcConfiguration) != `{"VpcId":"vpc-2","VpcEndpointId":"vpce-2"}` { + t.Fatalf("update did not mutate config: %s", up.IngressVpcConfiguration) + } + + del, err := m.DeleteVpcIngressConnection(ctx, vic.Arn) + if err != nil || del.Status != driver.VpcIngressStatusDeleted { + t.Fatalf("DeleteVpcIngressConnection: %+v err=%v", del, err) + } +} + +func TestVpcIngressMissingService(t *testing.T) { + m := newMock(t) + + // CreateVpcIngressConnection does not model ResourceNotFoundException, so a + // missing target service is an InvalidRequestException. + _, err := m.CreateVpcIngressConnection(context.Background(), "vic", "arn:missing", nil, nil) + if err == nil || exceptionOf(t, err) != driver.ExInvalidRequest { + t.Fatalf("expected InvalidRequestException, got %v", err) + } +} + +func TestCustomDomainLifecycle(t *testing.T) { + ctx := context.Background() + m := newMock(t) + svc := createService(t, m, "domain-svc") + + cd, dnsTarget, err := m.AssociateCustomDomain(ctx, svc.ServiceArn, "example.com", true) + if err != nil || cd.Status != driver.CustomDomainStatusActive || dnsTarget == "" { + t.Fatalf("AssociateCustomDomain: %+v dns=%q err=%v", cd, dnsTarget, err) + } + + if len(cd.CertificateValidationRecords) == 0 { + t.Fatal("expected synthesized certificate validation records") + } + + // Duplicate association is rejected. + if _, _, err := m.AssociateCustomDomain(ctx, svc.ServiceArn, "example.com", true); err == nil { + t.Fatal("expected error associating a duplicate domain") + } + + domains, target, _, err := m.DescribeCustomDomains(ctx, svc.ServiceArn, "", 0) + if err != nil || len(domains) != 1 || target == "" { + t.Fatalf("DescribeCustomDomains: n=%d target=%q err=%v", len(domains), target, err) + } + + if _, _, err := m.DisassociateCustomDomain(ctx, svc.ServiceArn, "example.com"); err != nil { + t.Fatalf("DisassociateCustomDomain: %v", err) + } + + domains, _, _, _ = m.DescribeCustomDomains(ctx, svc.ServiceArn, "", 0) + if len(domains) != 0 { + t.Fatalf("after disassociate want 0 domains, got %d", len(domains)) + } +} + +func TestDescribeCustomDomainsMissingService(t *testing.T) { + m := newMock(t) + + _, _, _, err := m.DescribeCustomDomains(context.Background(), "arn:missing", "", 0) + if err == nil || exceptionOf(t, err) != driver.ExResourceNotFound { + t.Fatalf("expected ResourceNotFoundException, got %v", err) + } +} + +func TestListOperationsRecordsMutations(t *testing.T) { + ctx := context.Background() + m := newMock(t) + svc := createService(t, m, "ops-svc") + + if _, err := m.PauseService(ctx, svc.ServiceArn); err != nil { + t.Fatalf("PauseService: %v", err) + } + + if _, err := m.ResumeService(ctx, svc.ServiceArn); err != nil { + t.Fatalf("ResumeService: %v", err) + } + + ops, _, err := m.ListOperations(ctx, svc.ServiceArn, "", 0) + if err != nil { + t.Fatalf("ListOperations: %v", err) + } + + // CREATE_SERVICE + PAUSE_SERVICE + RESUME_SERVICE. + if len(ops) != 3 { + t.Fatalf("ListOperations = %d ops, want 3", len(ops)) + } + + for _, o := range ops { + if o.ID == "" || o.Status != driver.OperationStatusSucceeded { + t.Fatalf("bad operation: %+v", o) + } + } +} + +func TestListOperationsMissingService(t *testing.T) { + m := newMock(t) + + _, _, err := m.ListOperations(context.Background(), "arn:missing", "", 0) + if err == nil || exceptionOf(t, err) != driver.ExResourceNotFound { + t.Fatalf("expected ResourceNotFoundException, got %v", err) + } +} + +func TestTagsAcrossResourceKinds(t *testing.T) { + ctx := context.Background() + m := newMock(t) + + svc := createService(t, m, "tagged") + asc, _ := m.CreateAutoScalingConfiguration(ctx, "tagged-asc", 100, 25, 1) + obs, _ := m.CreateObservabilityConfiguration(ctx, "tagged-obs", nil, nil) + vc, _ := m.CreateVpcConnector(ctx, "tagged-vc", []string{"subnet-1"}, nil, nil) + conn, _ := m.CreateConnection(ctx, "tagged-conn", driver.ProviderTypeGitHub, nil) + vic, _ := m.CreateVpcIngressConnection(ctx, "tagged-vic", svc.ServiceArn, nil, nil) + + arns := []string{svc.ServiceArn, asc.Arn, obs.Arn, vc.Arn, conn.Arn, vic.Arn} + + for _, arn := range arns { + if err := m.TagResource(ctx, arn, map[string]string{"env": "prod", "team": "core"}); err != nil { + t.Fatalf("TagResource(%s): %v", arn, err) + } + + got, err := m.ListTagsForResource(ctx, arn) + if err != nil || got["env"] != "prod" || got["team"] != "core" { + t.Fatalf("ListTagsForResource(%s) = %v err=%v", arn, got, err) + } + + if err := m.UntagResource(ctx, arn, []string{"team"}); err != nil { + t.Fatalf("UntagResource(%s): %v", arn, err) + } + + got, _ = m.ListTagsForResource(ctx, arn) + if _, ok := got["team"]; ok || got["env"] != "prod" { + t.Fatalf("after untag(%s) = %v", arn, got) + } + } +} + +func TestTagsUnknownArn(t *testing.T) { + m := newMock(t) + + err := m.TagResource(context.Background(), "arn:aws:apprunner:us-east-1:123456789012:unknownkind/x", nil) + if err == nil || exceptionOf(t, err) != driver.ExResourceNotFound { + t.Fatalf("expected ResourceNotFoundException, got %v", err) + } +} + +// TestConcurrentTagNoLostUpdate proves the tag read-modify-write is atomic under +// one lock: N goroutines each adding a distinct key must all survive. Run under +// -race. +func TestConcurrentTagNoLostUpdate(t *testing.T) { + ctx := context.Background() + m := newMock(t) + svc := createService(t, m, "concurrent") + + const n = 50 + + var wg sync.WaitGroup + + wg.Add(n) + + for i := 0; i < n; i++ { + go func(i int) { + defer wg.Done() + + key := "k" + itoaTest(i) + if err := m.TagResource(ctx, svc.ServiceArn, map[string]string{key: "v"}); err != nil { + t.Errorf("TagResource: %v", err) + } + }(i) + } + + wg.Wait() + + got, err := m.ListTagsForResource(ctx, svc.ServiceArn) + if err != nil { + t.Fatalf("ListTagsForResource: %v", err) + } + + if len(got) != n { + t.Fatalf("concurrent tags = %d, want %d (lost updates)", len(got), n) + } +} + +func itoaTest(v int) string { + return strconv.Itoa(v) +} + +// TestNoAliasOnRead proves Describe/List return deep copies: mutating a returned +// value's slices/maps must not affect stored state. Run under -race. +func TestNoAliasOnRead(t *testing.T) { + ctx := context.Background() + m := newMock(t) + svc := createService(t, m, "alias") + + got, err := m.DescribeService(ctx, svc.ServiceArn) + if err != nil { + t.Fatalf("DescribeService: %v", err) + } + + // Mutate the returned raw config bytes; the store must not alias them. + if len(got.SourceConfiguration) > 0 { + got.SourceConfiguration[0] = 'X' + } + + again, err := m.DescribeService(ctx, svc.ServiceArn) + if err != nil { + t.Fatalf("DescribeService(2): %v", err) + } + + if len(again.SourceConfiguration) > 0 && again.SourceConfiguration[0] == 'X' { + t.Fatal("stored SourceConfiguration was aliased and mutated by the caller") + } +} + +// createServiceWith creates a service with the given extra input fields. +func createServiceWith(t *testing.T, m *apprunner.Mock, in driver.CreateServiceInput) *driver.Service { + t.Helper() + + in.SourceConfiguration = json.RawMessage(`{"ImageRepository":{"ImageIdentifier":"public.ecr.aws/x/y:latest"}}`) + + res, err := m.CreateService(context.Background(), in) + if err != nil { + t.Fatalf("CreateService: %v", err) + } + + return res.Service +} + +func TestStartDeploymentIllegalStateIsInvalidRequest(t *testing.T) { + ctx := context.Background() + m := newMock(t) + svc := createService(t, m, "sd") + + if _, err := m.PauseService(ctx, svc.ServiceArn); err != nil { + t.Fatalf("PauseService: %v", err) + } + + // StartDeployment does not model InvalidStateException, so an illegal state + // is InvalidRequestException. + if _, err := m.StartDeployment(ctx, svc.ServiceArn); exceptionOf(t, err) != driver.ExInvalidRequest { + t.Fatalf("StartDeployment on PAUSED = %v, want InvalidRequestException", err) + } +} + +func TestDeleteAutoScalingConfigurationInUse(t *testing.T) { + ctx := context.Background() + m := newMock(t) + + asc, err := m.CreateAutoScalingConfiguration(ctx, "in-use", 100, 25, 1) + if err != nil { + t.Fatalf("CreateAutoScalingConfiguration: %v", err) + } + + createServiceWith(t, m, driver.CreateServiceInput{ + ServiceName: "consumer", AutoScalingConfigArn: asc.Arn, + }) + + if _, err := m.DeleteAutoScalingConfiguration(ctx, asc.Arn, false); exceptionOf(t, err) != driver.ExInvalidRequest { + t.Fatalf("delete in-use ASC = %v, want InvalidRequestException", err) + } +} + +func TestDeleteVpcConnectorInUse(t *testing.T) { + ctx := context.Background() + m := newMock(t) + + vc, err := m.CreateVpcConnector(ctx, "in-use-vc", []string{"subnet-1"}, nil, nil) + if err != nil { + t.Fatalf("CreateVpcConnector: %v", err) + } + + createServiceWith(t, m, driver.CreateServiceInput{ + ServiceName: "vc-consumer", + NetworkConfiguration: json.RawMessage( + `{"EgressConfiguration":{"EgressType":"VPC","VpcConnectorArn":"` + vc.Arn + `"}}`), + }) + + if _, err := m.DeleteVpcConnector(ctx, vc.Arn); exceptionOf(t, err) != driver.ExInvalidRequest { + t.Fatalf("delete in-use VPC connector = %v, want InvalidRequestException", err) + } +} + +func TestDisassociateCustomDomainMissingServiceIsNotFound(t *testing.T) { + m := newMock(t) + + _, _, err := m.DisassociateCustomDomain(context.Background(), "arn:missing", "example.com") + if exceptionOf(t, err) != driver.ExResourceNotFound { + t.Fatalf("disassociate on missing service = %v, want ResourceNotFoundException", err) + } +} + +// TestNoAliasTags asserts that the Tags map returned by auto-scaling-config and +// connection reads is a copy, so a caller mutating it cannot corrupt the store. +func TestNoAliasTags(t *testing.T) { + ctx := context.Background() + m := newMock(t) + + asc, err := m.CreateAutoScalingConfiguration(ctx, "tags-asc", 100, 25, 1) + if err != nil { + t.Fatalf("CreateAutoScalingConfiguration: %v", err) + } + + if err := m.TagResource(ctx, asc.Arn, map[string]string{"env": "test"}); err != nil { + t.Fatalf("TagResource(asc): %v", err) + } + + got, err := m.DescribeAutoScalingConfiguration(ctx, asc.Arn) + if err != nil { + t.Fatalf("DescribeAutoScalingConfiguration: %v", err) + } + + got.Tags["env"] = "mutated" + got.Tags["injected"] = "x" + + again, _ := m.DescribeAutoScalingConfiguration(ctx, asc.Arn) + if again.Tags["env"] != "test" || again.Tags["injected"] != "" { + t.Fatalf("ASC Tags aliased: %v", again.Tags) + } + + conn, err := m.CreateConnection(ctx, "tags-conn", driver.ProviderTypeGitHub, map[string]string{"team": "data"}) + if err != nil { + t.Fatalf("CreateConnection: %v", err) + } + + list, _, err := m.ListConnections(ctx, "", "", 0) + if err != nil { + t.Fatalf("ListConnections: %v", err) + } + + for i := range list { + if list[i].Arn == conn.Arn { + list[i].Tags["team"] = "mutated" + } + } + + tags, err := m.ListTagsForResource(ctx, conn.Arn) + if err != nil || tags["team"] != "data" { + t.Fatalf("connection Tags aliased via ListConnections: %v %v", tags, err) + } +} diff --git a/providers/aws/apprunner/autoscaling.go b/providers/aws/apprunner/autoscaling.go new file mode 100644 index 000000000..32738dc57 --- /dev/null +++ b/providers/aws/apprunner/autoscaling.go @@ -0,0 +1,277 @@ +package apprunner + +import ( + "context" + "sort" + "time" + + "github.com/stackshy/cloudemu/v2/services/apprunner/driver" +) + +const ( + defaultASCMaxConcurrency = 100 + defaultASCMaxSize = 25 + defaultASCMinSize = 1 + // defaultASCName is the App Runner-managed default configuration name. + defaultASCName = "DefaultConfiguration" +) + +// ascInUse returns the ARN of a service that references the given auto-scaling +// configuration, or "". When specificArn is set only that exact revision counts; +// otherwise any revision sharing name counts (for a delete-all-revisions). The +// caller must hold ascMu. +func (m *Mock) ascInUse(name, specificArn string) string { + for _, sd := range m.services.SortedValues() { + sd.mu.RLock() + ref := sd.svc.AutoScalingConfigArn + svcArn := sd.svc.ServiceArn + sd.mu.RUnlock() + + if ref == "" { + continue + } + + if specificArn != "" { + if ref == specificArn { + return svcArn + } + + continue + } + + if c, ok := m.ascByArn[ref]; ok && c.Name == name { + return svcArn + } + } + + return "" +} + +// copyASC returns a deep copy of a stored auto-scaling configuration so a reader +// cannot alias its Tags map. +func copyASC(c *driver.AutoScalingConfiguration) *driver.AutoScalingConfiguration { + out := *c + out.Tags = copyTags(c.Tags) + + return &out +} + +// CreateAutoScalingConfiguration mints a new revision. Revisions sharing a Name +// increment atomically under ascMu, and the new revision becomes the Latest for +// its name (the prior Latest is cleared). This mirrors App Runner's revision +// model where multiple revisions share a name and exactly one is Latest. +func (m *Mock) CreateAutoScalingConfiguration( + _ context.Context, name string, maxConcurrency, maxSize, minSize int32, +) (*driver.AutoScalingConfiguration, error) { + if name == "" { + return nil, invalidRequest("AutoScalingConfigurationName is required") + } + + if minSize > 0 && maxSize > 0 && minSize > maxSize { + return nil, invalidRequest("MinSize %d must not exceed MaxSize %d", minSize, maxSize) + } + + m.ascMu.Lock() + defer m.ascMu.Unlock() + + // Clear the prior Latest for this name; the newest revision becomes Latest. + for _, c := range m.ascByArn { + if c.Name == name && c.Latest { + c.Latest = false + } + } + + revision := m.ascLatestRev[name] + 1 + m.ascLatestRev[name] = revision + + cfg := &driver.AutoScalingConfiguration{ + Arn: m.ascArn(name, revision), Name: name, Revision: revision, Status: driver.StatusActive, + MaxConcurrency: orDefault(maxConcurrency, defaultASCMaxConcurrency), + MaxSize: orDefault(maxSize, defaultASCMaxSize), + MinSize: orDefault(minSize, defaultASCMinSize), + Latest: true, CreatedAt: m.now(), + } + m.ascByArn[cfg.Arn] = cfg + + return copyASC(cfg), nil +} + +func orDefault(v, def int32) int32 { + if v <= 0 { + return def + } + + return v +} + +func (m *Mock) DescribeAutoScalingConfiguration( + _ context.Context, arn string, +) (*driver.AutoScalingConfiguration, error) { + m.ascMu.Lock() + defer m.ascMu.Unlock() + + cfg, ok := m.ascByArn[arn] + if !ok { + return nil, notFound("no auto scaling configuration found for ARN %q", arn) + } + + return copyASC(cfg), nil +} + +// DeleteAutoScalingConfiguration marks a revision INACTIVE (or, when +// deleteAllRevisions is set, every revision sharing its name). It returns the +// (highest) affected revision. +func (m *Mock) DeleteAutoScalingConfiguration( + _ context.Context, arn string, deleteAllRevisions bool, +) (*driver.AutoScalingConfiguration, error) { + m.ascMu.Lock() + defer m.ascMu.Unlock() + + cfg, ok := m.ascByArn[arn] + if !ok { + return nil, notFound("no auto scaling configuration found for ARN %q", arn) + } + + // Reject deleting a configuration a service still uses. DeleteAutoScalingConfiguration + // does not model InvalidStateException, so an in-use config is an InvalidRequestException. + inUse := arn + if deleteAllRevisions { + inUse = "" // any revision of cfg.Name counts as in use. + } + + if used := m.ascInUse(cfg.Name, inUse); used != "" { + return nil, invalidRequest("auto scaling configuration is in use by service %q", used) + } + + now := m.now() + deactivate(cfg, now) + + if deleteAllRevisions { + for _, c := range m.ascByArn { + if c.Name == cfg.Name { + deactivate(c, now) + } + } + } + + return copyASC(cfg), nil +} + +// deactivate marks a revision INACTIVE and stamps its deletion time. Deleted +// (INACTIVE) revisions can't be used but remain queryable for a time. +func deactivate(c *driver.AutoScalingConfiguration, now time.Time) { + c.Status = driver.StatusInactive + c.Latest = false + + if c.DeletedAt.IsZero() { + c.DeletedAt = now + } +} + +// ListAutoScalingConfigurations lists ACTIVE revisions, optionally filtered by +// name and to the latest revision per name, paginated by ARN. +// +//nolint:dupl // near-identical to ListObservabilityConfigurations by API shape (both revision-managed). +func (m *Mock) ListAutoScalingConfigurations( + _ context.Context, name string, latestOnly bool, nextToken string, maxResults int32, +) ([]driver.AutoScalingConfiguration, string, error) { + m.ascMu.Lock() + all := make([]driver.AutoScalingConfiguration, 0, len(m.ascByArn)) + + for _, c := range m.ascByArn { + if name != "" && c.Name != name { + continue + } + + if latestOnly && !c.Latest { + continue + } + + all = append(all, *copyASC(c)) + } + m.ascMu.Unlock() + + sort.Slice(all, func(i, j int) bool { return all[i].Arn < all[j].Arn }) + + return paginate(all, nextToken, maxResults, func(c driver.AutoScalingConfiguration) string { return c.Arn }) +} + +// UpdateDefaultAutoScalingConfiguration marks one revision as the account +// default for its region, clearing the prior default. +func (m *Mock) UpdateDefaultAutoScalingConfiguration( + _ context.Context, arn string, +) (*driver.AutoScalingConfiguration, error) { + m.ascMu.Lock() + defer m.ascMu.Unlock() + + target, ok := m.ascByArn[arn] + if !ok { + return nil, notFound("no auto scaling configuration found for ARN %q", arn) + } + + for _, c := range m.ascByArn { + c.IsDefault = false + } + + target.IsDefault = true + + return copyASC(target), nil +} + +// ListServicesForAutoScalingConfiguration returns the ARNs of services using +// the given ASC revision, paginated by ARN. +func (m *Mock) ListServicesForAutoScalingConfiguration( + _ context.Context, arn, nextToken string, maxResults int32, +) (serviceArns []string, next string, err error) { + m.ascMu.Lock() + _, ok := m.ascByArn[arn] + m.ascMu.Unlock() + + if !ok { + return nil, "", notFound("no auto scaling configuration found for ARN %q", arn) + } + + var arns []string + + for _, sd := range m.services.SortedValues() { + sd.mu.RLock() + if sd.svc.AutoScalingConfigArn == arn { + arns = append(arns, sd.svc.ServiceArn) + } + sd.mu.RUnlock() + } + + sort.Strings(arns) + + return paginate(arns, nextToken, maxResults, func(s string) string { return s }) +} + +// attachDefaultASC fills a service's auto scaling configuration summary. If the +// service specified an ASC ARN, its name/revision are resolved; otherwise the +// account default (or a synthesized DefaultConfiguration) is attached. +func (m *Mock) attachDefaultASC(svc *driver.Service) { + m.ascMu.Lock() + defer m.ascMu.Unlock() + + if svc.AutoScalingConfigArn != "" { + if cfg, ok := m.ascByArn[svc.AutoScalingConfigArn]; ok { + svc.AutoScalingConfigName = cfg.Name + svc.AutoScalingConfigRevision = cfg.Revision + } + + return + } + + for _, c := range m.ascByArn { + if c.IsDefault { + svc.AutoScalingConfigArn = c.Arn + svc.AutoScalingConfigName = c.Name + svc.AutoScalingConfigRevision = c.Revision + + return + } + } + + svc.AutoScalingConfigName = defaultASCName + svc.AutoScalingConfigRevision = 1 +} diff --git a/providers/aws/apprunner/connections.go b/providers/aws/apprunner/connections.go new file mode 100644 index 000000000..3aeb0b656 --- /dev/null +++ b/providers/aws/apprunner/connections.go @@ -0,0 +1,87 @@ +package apprunner + +import ( + "context" + + "github.com/stackshy/cloudemu/v2/services/apprunner/driver" +) + +// copyConnection returns a deep copy of a connection so a reader cannot alias its +// Tags map. +// +//nolint:gocritic // hugeParam: takes a value to snapshot a copy of stored state. +func copyConnection(c driver.Connection) driver.Connection { + out := c + out.Tags = copyTags(c.Tags) + + return out +} + +// CreateConnection registers a source-repository connection. Real App Runner +// connections start PENDING_HANDSHAKE until the user completes the OAuth +// handshake; the emulator has no handshake surface, so the connection is created +// PENDING_HANDSHAKE (the SDK-visible initial state) keyed by its server-minted +// ARN. ProviderType is validated against the modeled enum. +func (m *Mock) CreateConnection( + _ context.Context, name, providerType string, tags map[string]string, +) (*driver.Connection, error) { + if name == "" { + return nil, invalidRequest("ConnectionName is required") + } + + if providerType != driver.ProviderTypeGitHub && providerType != driver.ProviderTypeBitbucket { + return nil, invalidRequest("ProviderType %q is not one of GITHUB, BITBUCKET", providerType) + } + + conn := driver.Connection{ + Arn: m.connectionArn(name), Name: name, ProviderType: providerType, + Status: driver.ConnectionStatusPendingHandshake, CreatedAt: m.now(), Tags: copyTags(tags), + } + + if !m.connections.SetIfAbsent(conn.Arn, &connectionData{conn: conn}) { + return nil, invalidRequest("connection ARN collision for %q", conn.Arn) + } + + out := copyConnection(conn) + + return &out, nil +} + +// DeleteConnection moves a connection to DELETED and returns its final state. +func (m *Mock) DeleteConnection(_ context.Context, arn string) (*driver.Connection, error) { + if arn == "" { + return nil, invalidRequest("ConnectionArn is required") + } + + cd, ok := m.connections.Get(arn) + if !ok { + return nil, notFound("no connection found for ARN %q", arn) + } + + cd.mu.Lock() + defer cd.mu.Unlock() + + cd.conn.Status = driver.ConnectionStatusDeleted + out := copyConnection(cd.conn) + + m.connections.Delete(arn) + + return &out, nil +} + +func (m *Mock) ListConnections( + _ context.Context, name, nextToken string, maxResults int32, +) ([]driver.Connection, string, error) { + all := m.connections.SortedValues() + out := make([]driver.Connection, 0, len(all)) + + for _, cd := range all { + cd.mu.RLock() + if name == "" || cd.conn.Name == name { + out = append(out, copyConnection(cd.conn)) + } + cd.mu.RUnlock() + } + + return paginate(out, nextToken, maxResults, func(c driver.Connection) string { return c.Arn }) +} diff --git a/providers/aws/apprunner/custom_domains.go b/providers/aws/apprunner/custom_domains.go new file mode 100644 index 000000000..555b2859f --- /dev/null +++ b/providers/aws/apprunner/custom_domains.go @@ -0,0 +1,114 @@ +package apprunner + +import ( + "context" + "sort" + + "github.com/stackshy/cloudemu/v2/internal/idgen" + "github.com/stackshy/cloudemu/v2/services/apprunner/driver" +) + +// AssociateCustomDomain associates a custom domain with a service. The domain is +// stored under the resolved service's lock (children live with their parent), +// created PENDING_CERTIFICATE_DNS_VALIDATION and immediately settled ACTIVE with +// a synthesized validation record. AssociateCustomDomain does not model +// ResourceNotFoundException, so a missing service is an InvalidRequestException. +func (m *Mock) AssociateCustomDomain( + _ context.Context, serviceArn, domainName string, enableWWW bool, +) (*driver.CustomDomain, string, error) { + if domainName == "" { + return nil, "", invalidRequest("DomainName is required") + } + + sd, ok := m.services.Get(serviceArn) + if !ok { + return nil, "", invalidRequest("no App Runner service found for ARN %q", serviceArn) + } + + sd.mu.Lock() + defer sd.mu.Unlock() + + if _, exists := sd.domains[domainName]; exists { + return nil, "", invalidRequest("custom domain %q is already associated", domainName) + } + + cd := &driver.CustomDomain{ + DomainName: domainName, EnableWWWSubdomain: enableWWW, + Status: driver.CustomDomainStatusActive, ServiceArn: serviceArn, + CertificateValidationRecords: []driver.CertificateValidationRecord{{ + Name: "_" + idgen.GenerateID("") + "." + domainName, Type: "CNAME", + Value: "_" + idgen.GenerateID("") + ".acm-validations.aws.", + Status: driver.CertValidationStatusSuccess, + }}, + } + sd.domains[domainName] = cd + + return copyCustomDomain(cd), sd.svc.ServiceURL, nil +} + +// DisassociateCustomDomain removes a custom domain from a service, returning its +// final DELETING state. +func (m *Mock) DisassociateCustomDomain( + _ context.Context, serviceArn, domainName string, +) (*driver.CustomDomain, string, error) { + // DisassociateCustomDomain models ResourceNotFoundException, so a missing + // service returns that (unlike AssociateCustomDomain, which does not model RNF). + sd, ok := m.services.Get(serviceArn) + if !ok { + return nil, "", notFound("no App Runner service found for ARN %q", serviceArn) + } + + sd.mu.Lock() + defer sd.mu.Unlock() + + cd, ok := sd.domains[domainName] + if !ok { + return nil, "", invalidRequest("custom domain %q is not associated", domainName) + } + + cd.Status = driver.CustomDomainStatusDeleting + out := copyCustomDomain(cd) + + delete(sd.domains, domainName) + + return out, sd.svc.ServiceURL, nil +} + +// DescribeCustomDomains lists a service's custom domains plus the service DNS +// target, paginated by domain name. It models ResourceNotFoundException for a +// missing service. +func (m *Mock) DescribeCustomDomains( + _ context.Context, serviceArn, nextToken string, maxResults int32, +) (domains []driver.CustomDomain, dnsTarget, token string, err error) { + sd, err := m.getService(serviceArn) + if err != nil { + return nil, "", "", err + } + + sd.mu.RLock() + dnsTarget = sd.svc.ServiceURL + all := make([]driver.CustomDomain, 0, len(sd.domains)) + + for _, cd := range sd.domains { + all = append(all, *copyCustomDomain(cd)) + } + sd.mu.RUnlock() + + sort.Slice(all, func(i, j int) bool { return all[i].DomainName < all[j].DomainName }) + + page, token, err := paginate(all, nextToken, maxResults, func(c driver.CustomDomain) string { return c.DomainName }) + + return page, dnsTarget, token, err +} + +// copyCustomDomain deep-copies a custom domain, including its validation records. +func copyCustomDomain(c *driver.CustomDomain) *driver.CustomDomain { + out := *c + + if c.CertificateValidationRecords != nil { + out.CertificateValidationRecords = append( + []driver.CertificateValidationRecord(nil), c.CertificateValidationRecords...) + } + + return &out +} diff --git a/providers/aws/apprunner/errors.go b/providers/aws/apprunner/errors.go new file mode 100644 index 000000000..bfb7ed3cb --- /dev/null +++ b/providers/aws/apprunner/errors.go @@ -0,0 +1,36 @@ +package apprunner + +import ( + "strconv" + + "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/services/apprunner/driver" +) + +// notFound builds a ResourceNotFoundException-tagged error. +func notFound(format string, args ...any) error { + return &driver.APIError{ + Exception: driver.ExResourceNotFound, + Err: errors.Newf(errors.NotFound, format, args...), + } +} + +// invalidRequest builds an InvalidRequestException-tagged error. +func invalidRequest(format string, args ...any) error { + return &driver.APIError{ + Exception: driver.ExInvalidRequest, + Err: errors.Newf(errors.InvalidArgument, format, args...), + } +} + +// invalidState builds an InvalidStateException-tagged error. +func invalidState(format string, args ...any) error { + return &driver.APIError{ + Exception: driver.ExInvalidState, + Err: errors.Newf(errors.FailedPrecondition, format, args...), + } +} + +func itoa(v int32) string { + return strconv.FormatInt(int64(v), 10) +} diff --git a/providers/aws/apprunner/observability.go b/providers/aws/apprunner/observability.go new file mode 100644 index 000000000..d64fe2dca --- /dev/null +++ b/providers/aws/apprunner/observability.go @@ -0,0 +1,128 @@ +package apprunner + +import ( + "context" + "encoding/json" + "sort" + "time" + + "github.com/stackshy/cloudemu/v2/services/apprunner/driver" +) + +// reservedObsName is the App Runner-managed default name that can't be created. +const reservedObsName = "DefaultConfiguration" + +// CreateObservabilityConfiguration mints a new revision, mirroring the ASC +// revision model: revisions sharing a Name increment atomically under obsMu and +// the newest becomes Latest (the prior Latest is cleared). +func (m *Mock) CreateObservabilityConfiguration( + _ context.Context, name string, trace json.RawMessage, tags map[string]string, +) (*driver.ObservabilityConfiguration, error) { + if name == "" { + return nil, invalidRequest("ObservabilityConfigurationName is required") + } + + if name == reservedObsName { + return nil, invalidRequest("ObservabilityConfigurationName %q is reserved", name) + } + + m.obsMu.Lock() + defer m.obsMu.Unlock() + + for _, c := range m.obsByArn { + if c.Name == name && c.Latest { + c.Latest = false + } + } + + revision := m.obsLatestRev[name] + 1 + m.obsLatestRev[name] = revision + + cfg := &driver.ObservabilityConfiguration{ + Arn: m.obsArn(name, revision), Name: name, Revision: revision, + Status: driver.StatusActive, Latest: true, + TraceConfiguration: copyRaw(trace), CreatedAt: m.now(), Tags: copyTags(tags), + } + m.obsByArn[cfg.Arn] = cfg + + return copyObs(cfg), nil +} + +func (m *Mock) DescribeObservabilityConfiguration( + _ context.Context, arn string, +) (*driver.ObservabilityConfiguration, error) { + m.obsMu.Lock() + defer m.obsMu.Unlock() + + cfg, ok := m.obsByArn[arn] + if !ok { + return nil, notFound("no observability configuration found for ARN %q", arn) + } + + return copyObs(cfg), nil +} + +// DeleteObservabilityConfiguration marks a revision INACTIVE and stamps its +// deletion time. +func (m *Mock) DeleteObservabilityConfiguration( + _ context.Context, arn string, +) (*driver.ObservabilityConfiguration, error) { + m.obsMu.Lock() + defer m.obsMu.Unlock() + + cfg, ok := m.obsByArn[arn] + if !ok { + return nil, notFound("no observability configuration found for ARN %q", arn) + } + + deactivateObs(cfg, m.now()) + + return copyObs(cfg), nil +} + +func deactivateObs(c *driver.ObservabilityConfiguration, now time.Time) { + c.Status = driver.StatusInactive + c.Latest = false + + if c.DeletedAt.IsZero() { + c.DeletedAt = now + } +} + +// ListObservabilityConfigurations lists ACTIVE revisions, optionally filtered by +// name and to the latest revision per name, paginated by ARN. +// +//nolint:dupl // near-identical to ListAutoScalingConfigurations by API shape (both revision-managed). +func (m *Mock) ListObservabilityConfigurations( + _ context.Context, name string, latestOnly bool, nextToken string, maxResults int32, +) ([]driver.ObservabilityConfiguration, string, error) { + m.obsMu.Lock() + all := make([]driver.ObservabilityConfiguration, 0, len(m.obsByArn)) + + for _, c := range m.obsByArn { + if name != "" && c.Name != name { + continue + } + + if latestOnly && !c.Latest { + continue + } + + all = append(all, *copyObs(c)) + } + m.obsMu.Unlock() + + sort.Slice(all, func(i, j int) bool { return all[i].Arn < all[j].Arn }) + + return paginate(all, nextToken, maxResults, func(c driver.ObservabilityConfiguration) string { return c.Arn }) +} + +// copyObs deep-copies an observability configuration, including its trace +// configuration bytes. +func copyObs(c *driver.ObservabilityConfiguration) *driver.ObservabilityConfiguration { + out := *c + out.TraceConfiguration = copyRaw(c.TraceConfiguration) + out.Tags = copyTags(c.Tags) + + return &out +} diff --git a/providers/aws/apprunner/operations.go b/providers/aws/apprunner/operations.go new file mode 100644 index 000000000..d58ccf25b --- /dev/null +++ b/providers/aws/apprunner/operations.go @@ -0,0 +1,29 @@ +package apprunner + +import ( + "context" + "sort" + + "github.com/stackshy/cloudemu/v2/services/apprunner/driver" +) + +// ListOperations returns the per-service operations recorded by the service +// mutations (Create/Update/Pause/Resume/Delete/StartDeployment), paginated by +// operation id. The service must exist. +func (m *Mock) ListOperations( + _ context.Context, serviceArn, nextToken string, maxResults int32, +) ([]driver.OperationSummary, string, error) { + sd, err := m.getService(serviceArn) + if err != nil { + return nil, "", err + } + + sd.mu.RLock() + out := make([]driver.OperationSummary, len(sd.ops)) + copy(out, sd.ops) + sd.mu.RUnlock() + + sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) + + return paginate(out, nextToken, maxResults, func(o driver.OperationSummary) string { return o.ID }) +} diff --git a/providers/aws/apprunner/services.go b/providers/aws/apprunner/services.go new file mode 100644 index 000000000..cc7c93746 --- /dev/null +++ b/providers/aws/apprunner/services.go @@ -0,0 +1,289 @@ +package apprunner + +import ( + "context" + "sort" + "time" + + "github.com/stackshy/cloudemu/v2/internal/idgen" + "github.com/stackshy/cloudemu/v2/services/apprunner/driver" +) + +func (m *Mock) getService(arn string) (*serviceData, error) { + if arn == "" { + return nil, invalidRequest("ServiceArn is required") + } + + sd, ok := m.services.Get(arn) + if !ok { + return nil, notFound("no App Runner service found for ARN %q", arn) + } + + return sd, nil +} + +// CreateService registers a new service. App Runner keys services by their +// server-minted ARN (duplicate names are allowed), so SetIfAbsent by ARN is the +// uniqueness invariant. The service comes up RUNNING immediately and records a +// CREATE_SERVICE operation. +// +//nolint:gocritic // hugeParam: signature is fixed by the driver.AppRunner interface (by-value input). +func (m *Mock) CreateService(_ context.Context, in driver.CreateServiceInput) (*driver.ServiceResult, error) { + if in.ServiceName == "" { + return nil, invalidRequest("ServiceName is required") + } + + if len(in.SourceConfiguration) == 0 { + return nil, invalidRequest("SourceConfiguration is required") + } + + now := m.now() + id := idgen.GenerateID("") + arn := m.serviceArn(in.ServiceName, id) + + svc := driver.Service{ + ServiceArn: arn, ServiceID: id, ServiceName: in.ServiceName, + ServiceURL: id + "." + m.opts.Region + ".awsapprunner.com", + Status: driver.ServiceStatusRunning, CreatedAt: now, UpdatedAt: now, + AutoScalingConfigArn: in.AutoScalingConfigArn, + SourceConfiguration: copyRaw(in.SourceConfiguration), + InstanceConfiguration: copyRaw(in.InstanceConfiguration), + NetworkConfiguration: copyRaw(in.NetworkConfiguration), + HealthCheckConfiguration: copyRaw(in.HealthCheckConfiguration), + EncryptionConfiguration: copyRaw(in.EncryptionConfiguration), + ObservabilityConfiguration: copyRaw(in.ObservabilityConfiguration), + Tags: copyTags(in.Tags), + } + m.attachDefaultASC(&svc) + + op := m.newOperation(arn, driver.OperationTypeCreateService, now) + sd := &serviceData{ + svc: svc, ops: []driver.OperationSummary{op}, + domains: make(map[string]*driver.CustomDomain), + } + + if !m.services.SetIfAbsent(arn, sd) { + // ARNs are server-minted and unique; a collision is an internal fault. + return nil, invalidRequest("service ARN collision for %q", arn) + } + + out := copyService(&svc) + + return &driver.ServiceResult{Service: &out, OperationID: op.ID}, nil +} + +func (m *Mock) DescribeService(_ context.Context, arn string) (*driver.Service, error) { + sd, err := m.getService(arn) + if err != nil { + return nil, err + } + + sd.mu.RLock() + defer sd.mu.RUnlock() + + out := copyService(&sd.svc) + + return &out, nil +} + +// DeleteService moves the service to DELETED and records a DELETE operation. It +// is not removed from the store so DescribeService can still report the deleted +// service (App Runner returns deleted services from DescribeService). +func (m *Mock) DeleteService(_ context.Context, arn string) (*driver.ServiceResult, error) { + sd, err := m.getService(arn) + if err != nil { + return nil, err + } + + sd.mu.Lock() + defer sd.mu.Unlock() + + if sd.svc.Status == driver.ServiceStatusDeleted { + return nil, invalidState("service %q is already deleted", arn) + } + + now := m.now() + sd.svc.Status = driver.ServiceStatusDeleted + sd.svc.UpdatedAt = now + sd.svc.DeletedAt = now + op := m.newOperation(arn, driver.OperationTypeDeleteService, now) + sd.ops = append(sd.ops, op) + + out := copyService(&sd.svc) + + return &driver.ServiceResult{Service: &out, OperationID: op.ID}, nil +} + +// UpdateService mutates the supplied configuration blocks in place and records +// an UPDATE operation. Empty inputs leave the corresponding block unchanged. +// +//nolint:gocritic // hugeParam: signature is fixed by the driver.AppRunner interface (by-value input). +func (m *Mock) UpdateService(_ context.Context, in driver.UpdateServiceInput) (*driver.ServiceResult, error) { + sd, err := m.getService(in.ServiceArn) + if err != nil { + return nil, err + } + + sd.mu.Lock() + defer sd.mu.Unlock() + + applyUpdate(&sd.svc, &in) + + now := m.now() + sd.svc.UpdatedAt = now + op := m.newOperation(in.ServiceArn, driver.OperationTypeUpdateService, now) + sd.ops = append(sd.ops, op) + + out := copyService(&sd.svc) + + return &driver.ServiceResult{Service: &out, OperationID: op.ID}, nil +} + +func applyUpdate(svc *driver.Service, in *driver.UpdateServiceInput) { + if len(in.SourceConfiguration) > 0 { + svc.SourceConfiguration = copyRaw(in.SourceConfiguration) + } + + if len(in.InstanceConfiguration) > 0 { + svc.InstanceConfiguration = copyRaw(in.InstanceConfiguration) + } + + if len(in.NetworkConfiguration) > 0 { + svc.NetworkConfiguration = copyRaw(in.NetworkConfiguration) + } + + if len(in.HealthCheckConfiguration) > 0 { + svc.HealthCheckConfiguration = copyRaw(in.HealthCheckConfiguration) + } + + if len(in.ObservabilityConfiguration) > 0 { + svc.ObservabilityConfiguration = copyRaw(in.ObservabilityConfiguration) + } + + if in.AutoScalingConfigArn != "" { + svc.AutoScalingConfigArn = in.AutoScalingConfigArn + } +} + +func (m *Mock) ListServices( + _ context.Context, nextToken string, maxResults int32, +) ([]driver.Service, string, error) { + all := m.services.SortedValues() + out := make([]driver.Service, 0, len(all)) + + for _, sd := range all { + sd.mu.RLock() + out = append(out, copyService(&sd.svc)) + sd.mu.RUnlock() + } + + page, token, err := paginate(out, nextToken, maxResults, func(s driver.Service) string { return s.ServiceArn }) + + return page, token, err +} + +// PauseService moves a RUNNING service to PAUSED. Any other state is an illegal +// transition (InvalidStateException). +func (m *Mock) PauseService(_ context.Context, arn string) (*driver.ServiceResult, error) { + return m.transition(arn, driver.ServiceStatusRunning, driver.ServiceStatusPaused, driver.OperationTypePauseService) +} + +// ResumeService moves a PAUSED service to RUNNING. +func (m *Mock) ResumeService(_ context.Context, arn string) (*driver.ServiceResult, error) { + return m.transition(arn, driver.ServiceStatusPaused, driver.ServiceStatusRunning, driver.OperationTypeResumeService) +} + +func (m *Mock) transition(arn, from, to, opType string) (*driver.ServiceResult, error) { + sd, err := m.getService(arn) + if err != nil { + return nil, err + } + + sd.mu.Lock() + defer sd.mu.Unlock() + + if sd.svc.Status != from { + return nil, invalidState("service %q is %s; %s requires %s", arn, sd.svc.Status, opType, from) + } + + now := m.now() + sd.svc.Status = to + sd.svc.UpdatedAt = now + op := m.newOperation(arn, opType, now) + sd.ops = append(sd.ops, op) + + out := copyService(&sd.svc) + + return &driver.ServiceResult{Service: &out, OperationID: op.ID}, nil +} + +// StartDeployment records a deployment operation on a RUNNING service and +// returns its operation id. +func (m *Mock) StartDeployment(_ context.Context, arn string) (string, error) { + sd, err := m.getService(arn) + if err != nil { + return "", err + } + + sd.mu.Lock() + defer sd.mu.Unlock() + + // StartDeployment does not model InvalidStateException (only RNF / + // InvalidRequest / Internal), so an illegal state is an InvalidRequestException. + if sd.svc.Status != driver.ServiceStatusRunning { + return "", invalidRequest("service %q is %s; StartDeployment requires RUNNING", arn, sd.svc.Status) + } + + op := m.newOperation(arn, driver.OperationTypeStartDeployment, m.now()) + sd.ops = append(sd.ops, op) + + return op.ID, nil +} + +// newOperation builds a succeeded operation summary. Deployments complete +// deterministically in the emulator, so operations are recorded as SUCCEEDED. +func (*Mock) newOperation(targetArn, opType string, now time.Time) driver.OperationSummary { + return driver.OperationSummary{ + ID: idgen.GenerateID(""), Type: opType, Status: driver.OperationStatusSucceeded, + TargetArn: targetArn, StartedAt: now, EndedAt: now, UpdatedAt: now, + } +} + +func copyService(s *driver.Service) driver.Service { + out := *s + out.Tags = copyTags(s.Tags) + out.SourceConfiguration = copyRaw(s.SourceConfiguration) + out.InstanceConfiguration = copyRaw(s.InstanceConfiguration) + out.NetworkConfiguration = copyRaw(s.NetworkConfiguration) + out.HealthCheckConfiguration = copyRaw(s.HealthCheckConfiguration) + out.EncryptionConfiguration = copyRaw(s.EncryptionConfiguration) + out.ObservabilityConfiguration = copyRaw(s.ObservabilityConfiguration) + + return out +} + +// paginate returns a deterministic page of items starting after nextToken (an +// opaque key), plus the token to resume from (empty when exhausted). Items must +// already be sorted by key. +func paginate[T any](items []T, nextToken string, maxResults int32, key func(T) string, +) (page []T, next string, err error) { + size := int(maxResults) + if size <= 0 { + size = defaultPageSize + } + + start := 0 + if nextToken != "" { + start = sort.Search(len(items), func(i int) bool { return key(items[i]) >= nextToken }) + if start >= len(items) || key(items[start]) != nextToken { + return nil, "", invalidRequest("invalid NextToken %q", nextToken) + } + } + + end := start + size + if end >= len(items) { + return items[start:], "", nil + } + + return items[start:end], key(items[end]), nil +} diff --git a/providers/aws/apprunner/tags.go b/providers/aws/apprunner/tags.go new file mode 100644 index 000000000..bedee9849 --- /dev/null +++ b/providers/aws/apprunner/tags.go @@ -0,0 +1,194 @@ +package apprunner + +import ( + "context" + "strings" +) + +// App Runner ARN resource-kind segments. The kind is the first path element of +// the ARN's resource part (arn:aws:apprunner:region:account:/...). +const ( + kindService = "service" + kindASC = "autoscalingconfiguration" + kindObserver = "observabilityconfiguration" + kindVpcConn = "vpcconnector" + kindVpcIngress = "vpcingressconnection" + kindConnection = "connection" + arnResourceSep = ":" + arnPathSegments = 6 +) + +// TagResource merges tags into the resource owning arn. The read-merge-write is +// performed atomically under the owning resource's single lock so concurrent +// tag updates can't lose each other. +func (m *Mock) TagResource(_ context.Context, arn string, tags map[string]string) error { + return m.withTags(arn, func(existing map[string]string) map[string]string { + out := copyTags(existing) + if out == nil { + out = make(map[string]string, len(tags)) + } + + for k, v := range tags { + out[k] = v + } + + return out + }) +} + +// UntagResource removes the given keys from the resource owning arn, atomically. +func (m *Mock) UntagResource(_ context.Context, arn string, tagKeys []string) error { + return m.withTags(arn, func(existing map[string]string) map[string]string { + out := copyTags(existing) + for _, k := range tagKeys { + delete(out, k) + } + + return out + }) +} + +// ListTagsForResource returns a deep copy of the resource's tags. +func (m *Mock) ListTagsForResource(_ context.Context, arn string) (map[string]string, error) { + var result map[string]string + + err := m.withTags(arn, func(existing map[string]string) map[string]string { + result = copyTags(existing) + + return existing + }) + if err != nil { + return nil, err + } + + return result, nil +} + +// withTags resolves arn to its owning resource and applies fn to that resource's +// tag map under a SINGLE hold of the resource's lock (read-modify-write), so +// concurrent tag mutations don't lose updates. Unknown ARNs are +// ResourceNotFoundException (the exception the tag ops model). +func (m *Mock) withTags(arn string, fn func(map[string]string) map[string]string) error { + kind, err := arnKind(arn) + if err != nil { + return err + } + + switch kind { + case kindService: + return m.tagService(arn, fn) + case kindConnection: + return m.tagConnection(arn, fn) + case kindVpcIngress: + return m.tagVpcIngress(arn, fn) + case kindASC: + return m.tagASC(arn, fn) + case kindObserver: + return m.tagObs(arn, fn) + case kindVpcConn: + return m.tagVpcConnector(arn, fn) + default: + return notFound("unsupported App Runner resource ARN %q", arn) + } +} + +// arnKind extracts the resource-kind segment from an App Runner ARN. +func arnKind(arn string) (string, error) { + parts := strings.SplitN(arn, arnResourceSep, arnPathSegments) + if len(parts) < arnPathSegments || parts[2] != serviceSegment { + return "", notFound("not an App Runner resource ARN: %q", arn) + } + + resource := parts[arnPathSegments-1] + + kind, _, ok := strings.Cut(resource, "/") + if !ok { + return "", notFound("malformed App Runner resource ARN: %q", arn) + } + + return kind, nil +} + +func (m *Mock) tagService(arn string, fn func(map[string]string) map[string]string) error { + sd, ok := m.services.Get(arn) + if !ok { + return notFound("no App Runner service found for ARN %q", arn) + } + + sd.mu.Lock() + defer sd.mu.Unlock() + + sd.svc.Tags = fn(sd.svc.Tags) + + return nil +} + +func (m *Mock) tagConnection(arn string, fn func(map[string]string) map[string]string) error { + cd, ok := m.connections.Get(arn) + if !ok { + return notFound("no connection found for ARN %q", arn) + } + + cd.mu.Lock() + defer cd.mu.Unlock() + + cd.conn.Tags = fn(cd.conn.Tags) + + return nil +} + +func (m *Mock) tagVpcIngress(arn string, fn func(map[string]string) map[string]string) error { + vd, ok := m.vpcIngress.Get(arn) + if !ok { + return notFound("no VPC ingress connection found for ARN %q", arn) + } + + vd.mu.Lock() + defer vd.mu.Unlock() + + vd.vic.Tags = fn(vd.vic.Tags) + + return nil +} + +func (m *Mock) tagVpcConnector(arn string, fn func(map[string]string) map[string]string) error { + vd, ok := m.vpcConnectors.Get(arn) + if !ok { + return notFound("no VPC connector found for ARN %q", arn) + } + + vd.mu.Lock() + defer vd.mu.Unlock() + + vd.vc.Tags = fn(vd.vc.Tags) + + return nil +} + +func (m *Mock) tagASC(arn string, fn func(map[string]string) map[string]string) error { + m.ascMu.Lock() + defer m.ascMu.Unlock() + + cfg, ok := m.ascByArn[arn] + if !ok { + return notFound("no auto scaling configuration found for ARN %q", arn) + } + + cfg.Tags = fn(cfg.Tags) + + return nil +} + +func (m *Mock) tagObs(arn string, fn func(map[string]string) map[string]string) error { + m.obsMu.Lock() + defer m.obsMu.Unlock() + + cfg, ok := m.obsByArn[arn] + if !ok { + return notFound("no observability configuration found for ARN %q", arn) + } + + cfg.Tags = fn(cfg.Tags) + + return nil +} diff --git a/providers/aws/apprunner/vpc_connectors.go b/providers/aws/apprunner/vpc_connectors.go new file mode 100644 index 000000000..096ca665a --- /dev/null +++ b/providers/aws/apprunner/vpc_connectors.go @@ -0,0 +1,127 @@ +package apprunner + +import ( + "context" + "encoding/json" + + "github.com/stackshy/cloudemu/v2/services/apprunner/driver" +) + +// vpcConnectorInUse returns the ARN of a service whose egress NetworkConfiguration +// references the given VPC connector, or "". +func (m *Mock) vpcConnectorInUse(connectorArn string) string { + for _, sd := range m.services.SortedValues() { + sd.mu.RLock() + netCfg := sd.svc.NetworkConfiguration + svcArn := sd.svc.ServiceArn + sd.mu.RUnlock() + + if len(netCfg) == 0 { + continue + } + + var nc struct { + EgressConfiguration struct { + VpcConnectorArn string `json:"VpcConnectorArn"` + } `json:"EgressConfiguration"` + } + + if err := json.Unmarshal(netCfg, &nc); err == nil && + nc.EgressConfiguration.VpcConnectorArn == connectorArn { + return svcArn + } + } + + return "" +} + +// CreateVpcConnector registers a VPC connector. It comes up ACTIVE immediately +// and is keyed by its server-minted ARN (SetIfAbsent enforces uniqueness). The +// Subnets and SecurityGroups slices are deep-copied so callers can't mutate +// stored state. +func (m *Mock) CreateVpcConnector( + _ context.Context, name string, subnets, securityGroups []string, tags map[string]string, +) (*driver.VpcConnector, error) { + if name == "" { + return nil, invalidRequest("VpcConnectorName is required") + } + + if len(subnets) == 0 { + return nil, invalidRequest("Subnets must contain at least one subnet") + } + + vc := driver.VpcConnector{ + Arn: m.vpcConnectorArn(name, 1), Name: name, Revision: 1, + Status: driver.StatusActive, Subnets: copyStrings(subnets), + SecurityGroups: copyStrings(securityGroups), CreatedAt: m.now(), Tags: copyTags(tags), + } + + if !m.vpcConnectors.SetIfAbsent(vc.Arn, &vpcConnectorData{vc: vc}) { + return nil, invalidRequest("VPC connector ARN collision for %q", vc.Arn) + } + + return copyVpcConnector(&vc), nil +} + +func (m *Mock) DescribeVpcConnector(_ context.Context, arn string) (*driver.VpcConnector, error) { + vd, ok := m.vpcConnectors.Get(arn) + if !ok { + return nil, notFound("no VPC connector found for ARN %q", arn) + } + + vd.mu.RLock() + defer vd.mu.RUnlock() + + return copyVpcConnector(&vd.vc), nil +} + +// DeleteVpcConnector marks a connector INACTIVE and stamps its deletion time. +func (m *Mock) DeleteVpcConnector(_ context.Context, arn string) (*driver.VpcConnector, error) { + vd, ok := m.vpcConnectors.Get(arn) + if !ok { + return nil, notFound("no VPC connector found for ARN %q", arn) + } + + // Reject deleting a connector a service still references via its egress + // NetworkConfiguration. DeleteVpcConnector does not model InvalidStateException, + // so an in-use connector is an InvalidRequestException. + if used := m.vpcConnectorInUse(arn); used != "" { + return nil, invalidRequest("VPC connector is in use by service %q", used) + } + + vd.mu.Lock() + defer vd.mu.Unlock() + + vd.vc.Status = driver.StatusInactive + if vd.vc.DeletedAt.IsZero() { + vd.vc.DeletedAt = m.now() + } + + return copyVpcConnector(&vd.vc), nil +} + +func (m *Mock) ListVpcConnectors( + _ context.Context, nextToken string, maxResults int32, +) ([]driver.VpcConnector, string, error) { + all := m.vpcConnectors.SortedValues() + out := make([]driver.VpcConnector, 0, len(all)) + + for _, vd := range all { + vd.mu.RLock() + out = append(out, *copyVpcConnector(&vd.vc)) + vd.mu.RUnlock() + } + + return paginate(out, nextToken, maxResults, func(v driver.VpcConnector) string { return v.Arn }) +} + +// copyVpcConnector deep-copies a connector, including its Subnets and +// SecurityGroups slices. +func copyVpcConnector(v *driver.VpcConnector) *driver.VpcConnector { + out := *v + out.Subnets = copyStrings(v.Subnets) + out.SecurityGroups = copyStrings(v.SecurityGroups) + out.Tags = copyTags(v.Tags) + + return &out +} diff --git a/providers/aws/apprunner/vpc_ingress.go b/providers/aws/apprunner/vpc_ingress.go new file mode 100644 index 000000000..2409236c4 --- /dev/null +++ b/providers/aws/apprunner/vpc_ingress.go @@ -0,0 +1,151 @@ +package apprunner + +import ( + "context" + "encoding/json" + + "github.com/stackshy/cloudemu/v2/services/apprunner/driver" +) + +// CreateVpcIngressConnection registers a VPC ingress connection. It comes up +// AVAILABLE immediately with a synthesized DomainName and is keyed by its +// server-minted ARN (SetIfAbsent enforces uniqueness). The service it targets +// must exist. +func (m *Mock) CreateVpcIngressConnection( + _ context.Context, name, serviceArn string, ingressVpcConfig json.RawMessage, tags map[string]string, +) (*driver.VpcIngressConnection, error) { + if name == "" { + return nil, invalidRequest("VpcIngressConnectionName is required") + } + + // CreateVpcIngressConnection does not model ResourceNotFoundException, so a + // missing target service is an InvalidRequestException (not RNF). + if _, ok := m.services.Get(serviceArn); !ok { + return nil, invalidRequest("no App Runner service found for ARN %q", serviceArn) + } + + arn := m.vpcIngressArn(name) + vic := driver.VpcIngressConnection{ + Arn: arn, Name: name, Status: driver.VpcIngressStatusAvailable, + AccountID: m.opts.AccountID, + DomainName: name + "." + m.opts.Region + ".vpc.awsapprunner.com", + ServiceArn: serviceArn, IngressVpcConfiguration: copyRaw(ingressVpcConfig), + CreatedAt: m.now(), Tags: copyTags(tags), + } + + if !m.vpcIngress.SetIfAbsent(arn, &vpcIngressData{vic: vic}) { + return nil, invalidRequest("VPC ingress connection ARN collision for %q", arn) + } + + return copyVpcIngress(&vic), nil +} + +func (m *Mock) DescribeVpcIngressConnection(_ context.Context, arn string) (*driver.VpcIngressConnection, error) { + vd, ok := m.vpcIngress.Get(arn) + if !ok { + return nil, notFound("no VPC ingress connection found for ARN %q", arn) + } + + vd.mu.RLock() + defer vd.mu.RUnlock() + + return copyVpcIngress(&vd.vic), nil +} + +// DeleteVpcIngressConnection marks a connection DELETED and stamps its deletion +// time. +func (m *Mock) DeleteVpcIngressConnection(_ context.Context, arn string) (*driver.VpcIngressConnection, error) { + vd, ok := m.vpcIngress.Get(arn) + if !ok { + return nil, notFound("no VPC ingress connection found for ARN %q", arn) + } + + vd.mu.Lock() + defer vd.mu.Unlock() + + vd.vic.Status = driver.VpcIngressStatusDeleted + if vd.vic.DeletedAt.IsZero() { + vd.vic.DeletedAt = m.now() + } + + return copyVpcIngress(&vd.vic), nil +} + +// ListVpcIngressConnections lists connections, optionally filtered by owning +// service ARN and/or VPC endpoint id, paginated by ARN. +func (m *Mock) ListVpcIngressConnections( + _ context.Context, serviceArn, vpcEndpointID, nextToken string, maxResults int32, +) ([]driver.VpcIngressConnection, string, error) { + all := m.vpcIngress.SortedValues() + out := make([]driver.VpcIngressConnection, 0, len(all)) + + for _, vd := range all { + vd.mu.RLock() + if matchVpcIngress(&vd.vic, serviceArn, vpcEndpointID) { + out = append(out, *copyVpcIngress(&vd.vic)) + } + vd.mu.RUnlock() + } + + return paginate(out, nextToken, maxResults, func(v driver.VpcIngressConnection) string { return v.Arn }) +} + +// matchVpcIngress reports whether a connection passes the optional service-ARN +// and VPC-endpoint-id filters. +func matchVpcIngress(vic *driver.VpcIngressConnection, serviceArn, vpcEndpointID string) bool { + if serviceArn != "" && vic.ServiceArn != serviceArn { + return false + } + + if vpcEndpointID != "" && !ingressHasEndpoint(vic, vpcEndpointID) { + return false + } + + return true +} + +// ingressHasEndpoint reports whether the stored ingress config names the given +// VPC endpoint id. +func ingressHasEndpoint(vic *driver.VpcIngressConnection, vpcEndpointID string) bool { + var cfg struct { + VpcEndpointID string `json:"VpcEndpointId"` + } + + if err := json.Unmarshal(vic.IngressVpcConfiguration, &cfg); err != nil { + return false + } + + return cfg.VpcEndpointID == vpcEndpointID +} + +// UpdateVpcIngressConnection replaces the stored ingress VPC configuration. +func (m *Mock) UpdateVpcIngressConnection( + _ context.Context, arn string, ingressVpcConfig json.RawMessage, +) (*driver.VpcIngressConnection, error) { + vd, ok := m.vpcIngress.Get(arn) + if !ok { + return nil, notFound("no VPC ingress connection found for ARN %q", arn) + } + + vd.mu.Lock() + defer vd.mu.Unlock() + + if vd.vic.Status == driver.VpcIngressStatusDeleted { + return nil, invalidState("VPC ingress connection %q is deleted", arn) + } + + if len(ingressVpcConfig) > 0 { + vd.vic.IngressVpcConfiguration = copyRaw(ingressVpcConfig) + } + + return copyVpcIngress(&vd.vic), nil +} + +// copyVpcIngress deep-copies a connection, including its ingress config bytes. +func copyVpcIngress(v *driver.VpcIngressConnection) *driver.VpcIngressConnection { + out := *v + out.IngressVpcConfiguration = copyRaw(v.IngressVpcConfiguration) + out.Tags = copyTags(v.Tags) + + return &out +} diff --git a/providers/aws/aws.go b/providers/aws/aws.go index f77edd513..6e01323ad 100644 --- a/providers/aws/aws.go +++ b/providers/aws/aws.go @@ -8,6 +8,7 @@ import ( "github.com/stackshy/cloudemu/v2/config" cerrors "github.com/stackshy/cloudemu/v2/errors" "github.com/stackshy/cloudemu/v2/providers/aws/acm" + "github.com/stackshy/cloudemu/v2/providers/aws/apprunner" "github.com/stackshy/cloudemu/v2/providers/aws/bedrock" "github.com/stackshy/cloudemu/v2/providers/aws/bedrockagent" "github.com/stackshy/cloudemu/v2/providers/aws/bedrockagentruntime" @@ -170,6 +171,7 @@ type Provider struct { WAFv2 *wafv2.Mock Route53Resolver *route53resolver.Mock SFN *sfn.Mock + AppRunner *apprunner.Mock CloudTrail *cloudtrail.Mock Glue *glue.Mock Config *configservice.Mock @@ -222,6 +224,7 @@ func New(opts ...config.Option) *Provider { WAFv2: wafv2.New(o), Route53Resolver: route53resolver.New(o), SFN: sfn.New(o), + AppRunner: apprunner.New(o), CloudTrail: cloudtrail.New(o), Glue: glue.New(o), Config: configservice.New(o), diff --git a/server/aws/apprunner/handler.go b/server/aws/apprunner/handler.go new file mode 100644 index 000000000..931f1b82b --- /dev/null +++ b/server/aws/apprunner/handler.go @@ -0,0 +1,160 @@ +// Package apprunner implements the AWS App Runner JSON 1.0 protocol as a +// server.Handler. Point the real aws-sdk-go-v2/service/apprunner client (or the +// `aws apprunner` CLI) at a Server registered with this handler and service, +// auto-scaling-configuration, and connection operations run against an in-memory +// App Runner driver. +// +// App Runner uses the AWS JSON 1.0 wire shape (POST + JSON body dispatched on +// the X-Amz-Target header, prefix "AppRunner."). +package apprunner + +import ( + "context" + "errors" + "net/http" + "strings" + + cerrors "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/server/wire" + ardriver "github.com/stackshy/cloudemu/v2/services/apprunner/driver" +) + +const targetPrefix = "AppRunner." + +// Handler serves App Runner JSON-RPC requests against an AppRunner driver. +type Handler struct { + ar ardriver.AppRunner + routes map[string]http.HandlerFunc +} + +// New returns an App Runner handler backed by d. +func New(d ardriver.AppRunner) *Handler { + h := &Handler{ar: d} + h.routes = map[string]http.HandlerFunc{ + // Service. + "CreateService": h.createService, + "DescribeService": h.describeService, + "DeleteService": h.deleteService, + "UpdateService": h.updateService, + "ListServices": h.listServices, + "PauseService": h.pauseService, + "ResumeService": h.resumeService, + "StartDeployment": h.startDeployment, + // AutoScalingConfiguration. + "CreateAutoScalingConfiguration": h.createASC, + "DescribeAutoScalingConfiguration": h.describeASC, + "DeleteAutoScalingConfiguration": h.deleteASC, + "ListAutoScalingConfigurations": h.listASC, + "UpdateDefaultAutoScalingConfiguration": h.updateDefaultASC, + "ListServicesForAutoScalingConfiguration": h.listServicesForASC, + // Connection. + "CreateConnection": h.createConnection, + "DeleteConnection": h.deleteConnection, + "ListConnections": h.listConnections, + // ObservabilityConfiguration. + "CreateObservabilityConfiguration": h.createObs, + "DescribeObservabilityConfiguration": h.describeObs, + "DeleteObservabilityConfiguration": h.deleteObs, + "ListObservabilityConfigurations": h.listObs, + // VpcConnector. + "CreateVpcConnector": h.createVpcConnector, + "DescribeVpcConnector": h.describeVpcConnector, + "DeleteVpcConnector": h.deleteVpcConnector, + "ListVpcConnectors": h.listVpcConnectors, + // VpcIngressConnection. + "CreateVpcIngressConnection": h.createVpcIngress, + "DescribeVpcIngressConnection": h.describeVpcIngress, + "DeleteVpcIngressConnection": h.deleteVpcIngress, + "ListVpcIngressConnections": h.listVpcIngress, + "UpdateVpcIngressConnection": h.updateVpcIngress, + // CustomDomain. + "AssociateCustomDomain": h.associateCustomDomain, + "DisassociateCustomDomain": h.disassociateCustomDomain, + "DescribeCustomDomains": h.describeCustomDomains, + // Operations. + "ListOperations": h.listOperations, + // Tags. + "ListTagsForResource": h.listTags, + "TagResource": h.tagResource, + "UntagResource": h.untagResource, + } + + return h +} + +// Matches returns true for App Runner-shaped requests (X-Amz-Target of +// "AppRunner."). Because App Runner is X-Amz-Target dispatched, its +// predicate is disjoint from S3 (which rejects any X-Amz-Target) and from every +// other JSON-RPC service (distinct target prefix), so registration order is free. +func (*Handler) Matches(r *http.Request) bool { + return strings.HasPrefix(r.Header.Get("X-Amz-Target"), targetPrefix) +} + +// ServeHTTP dispatches App Runner operations based on X-Amz-Target. +func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + op := strings.TrimPrefix(r.Header.Get("X-Amz-Target"), targetPrefix) + + if fn, ok := h.routes[op]; ok { + fn(w, r) + + return + } + + wire.WriteJSONError(w, http.StatusBadRequest, "UnknownOperationException", + "unsupported App Runner operation: "+r.Header.Get("X-Amz-Target")) +} + +// dispatch decodes a JSON request of type Req, invokes call, and writes the +// returned value as JSON (or maps the error). +func dispatch[Req any]( + h *Handler, w http.ResponseWriter, r *http.Request, + call func(*Handler, context.Context, *Req) (any, error), +) { + var req Req + if !wire.DecodeJSON(w, r, &req) { + return + } + + out, err := call(h, r.Context(), &req) + if err != nil { + writeErr(w, err) + + return + } + + wire.WriteJSON(w, out) +} + +// writeErr maps a driver error to the closest App Runner JSON error type. Errors +// tagged with a specific App Runner exception (via driver.APIError) take +// precedence so distinct exceptions like ResourceNotFoundException / +// InvalidStateException surface as themselves. +func writeErr(w http.ResponseWriter, err error) { + var apiErr *ardriver.APIError + if errors.As(err, &apiErr) { + wire.WriteJSONError(w, statusFor(apiErr.Exception), apiErr.Exception, err.Error()) + + return + } + + switch { + case cerrors.IsNotFound(err): + wire.WriteJSONError(w, http.StatusBadRequest, ardriver.ExResourceNotFound, err.Error()) + case cerrors.IsInvalidArgument(err): + wire.WriteJSONError(w, http.StatusBadRequest, ardriver.ExInvalidRequest, err.Error()) + case cerrors.IsFailedPrecondition(err): + wire.WriteJSONError(w, http.StatusBadRequest, ardriver.ExInvalidState, err.Error()) + default: + wire.WriteJSONError(w, http.StatusInternalServerError, ardriver.ExInternalServiceError, err.Error()) + } +} + +// statusFor maps an App Runner exception to its HTTP status. App Runner's +// modeled client exceptions are 400; the internal-service exception is 500. +func statusFor(exception string) int { + if exception == ardriver.ExInternalServiceError { + return http.StatusInternalServerError + } + + return http.StatusBadRequest +} diff --git a/server/aws/apprunner/operations_config.go b/server/aws/apprunner/operations_config.go new file mode 100644 index 000000000..21fe0ca8c --- /dev/null +++ b/server/aws/apprunner/operations_config.go @@ -0,0 +1,159 @@ +package apprunner + +import ( + "context" + "net/http" + + ardriver "github.com/stackshy/cloudemu/v2/services/apprunner/driver" +) + +// --- AutoScalingConfiguration --- + +func (h *Handler) createASC(w http.ResponseWriter, r *http.Request) { + dispatch(h, w, r, func(h *Handler, ctx context.Context, req *createASCRequest) (any, error) { + cfg, err := h.ar.CreateAutoScalingConfiguration( + ctx, req.AutoScalingConfigurationName, req.MaxConcurrency, req.MaxSize, req.MinSize, + ) + if err != nil { + return nil, err + } + + return ascResponse{AutoScalingConfiguration: ascToWire(cfg)}, nil + }) +} + +func (h *Handler) describeASC(w http.ResponseWriter, r *http.Request) { + dispatch(h, w, r, func(h *Handler, ctx context.Context, req *ascArnRequest) (any, error) { + cfg, err := h.ar.DescribeAutoScalingConfiguration(ctx, req.AutoScalingConfigurationArn) + if err != nil { + return nil, err + } + + return ascResponse{AutoScalingConfiguration: ascToWire(cfg)}, nil + }) +} + +func (h *Handler) deleteASC(w http.ResponseWriter, r *http.Request) { + dispatch(h, w, r, func(h *Handler, ctx context.Context, req *ascArnRequest) (any, error) { + cfg, err := h.ar.DeleteAutoScalingConfiguration(ctx, req.AutoScalingConfigurationArn, req.DeleteAllRevisions) + if err != nil { + return nil, err + } + + return ascResponse{AutoScalingConfiguration: ascToWire(cfg)}, nil + }) +} + +func (h *Handler) listASC(w http.ResponseWriter, r *http.Request) { + dispatch(h, w, r, func(h *Handler, ctx context.Context, req *listASCRequest) (any, error) { + cfgs, token, err := h.ar.ListAutoScalingConfigurations( + ctx, req.AutoScalingConfigurationName, req.LatestOnly, req.NextToken, req.MaxResults, + ) + if err != nil { + return nil, err + } + + items := make([]ascSummaryItem, 0, len(cfgs)) + + for i := range cfgs { + c := &cfgs[i] + items = append(items, ascSummaryItem{ + AutoScalingConfigurationArn: c.Arn, AutoScalingConfigurationName: c.Name, + AutoScalingConfigurationRevision: c.Revision, Status: c.Status, + IsDefault: c.IsDefault, HasAssociatedService: c.HasAssociatedService, CreatedAt: epoch(c.CreatedAt), + }) + } + + return listASCResponse{AutoScalingConfigurationSummaryList: items, NextToken: token}, nil + }) +} + +func (h *Handler) updateDefaultASC(w http.ResponseWriter, r *http.Request) { + dispatch(h, w, r, func(h *Handler, ctx context.Context, req *ascArnRequest) (any, error) { + cfg, err := h.ar.UpdateDefaultAutoScalingConfiguration(ctx, req.AutoScalingConfigurationArn) + if err != nil { + return nil, err + } + + return ascResponse{AutoScalingConfiguration: ascToWire(cfg)}, nil + }) +} + +func (h *Handler) listServicesForASC(w http.ResponseWriter, r *http.Request) { + dispatch(h, w, r, func(h *Handler, ctx context.Context, req *listServicesForASCRequest) (any, error) { + arns, token, err := h.ar.ListServicesForAutoScalingConfiguration( + ctx, req.AutoScalingConfigurationArn, req.NextToken, req.MaxResults, + ) + if err != nil { + return nil, err + } + + if arns == nil { + arns = []string{} + } + + return listServicesForASCResponse{ServiceArnList: arns, NextToken: token}, nil + }) +} + +func ascToWire(c *ardriver.AutoScalingConfiguration) wireASC { + return wireASC{ + AutoScalingConfigurationArn: c.Arn, AutoScalingConfigurationName: c.Name, + AutoScalingConfigurationRevision: c.Revision, Status: c.Status, + MaxConcurrency: c.MaxConcurrency, MaxSize: c.MaxSize, MinSize: c.MinSize, + IsDefault: c.IsDefault, Latest: c.Latest, HasAssociatedService: c.HasAssociatedService, + CreatedAt: epoch(c.CreatedAt), DeletedAt: epoch(c.DeletedAt), + } +} + +// --- Connection --- + +func (h *Handler) createConnection(w http.ResponseWriter, r *http.Request) { + dispatch(h, w, r, func(h *Handler, ctx context.Context, req *createConnectionRequest) (any, error) { + conn, err := h.ar.CreateConnection(ctx, req.ConnectionName, req.ProviderType, tagsToMap(req.Tags)) + if err != nil { + return nil, err + } + + return connectionResponse{Connection: connectionToWire(conn)}, nil + }) +} + +func (h *Handler) deleteConnection(w http.ResponseWriter, r *http.Request) { + dispatch(h, w, r, func(h *Handler, ctx context.Context, req *connectionArnRequest) (any, error) { + conn, err := h.ar.DeleteConnection(ctx, req.ConnectionArn) + if err != nil { + return nil, err + } + + return connectionResponse{Connection: connectionToWire(conn)}, nil + }) +} + +func (h *Handler) listConnections(w http.ResponseWriter, r *http.Request) { + dispatch(h, w, r, func(h *Handler, ctx context.Context, req *listConnectionsRequest) (any, error) { + conns, token, err := h.ar.ListConnections(ctx, req.ConnectionName, req.NextToken, req.MaxResults) + if err != nil { + return nil, err + } + + items := make([]connectionSummaryItem, 0, len(conns)) + + for i := range conns { + c := &conns[i] + items = append(items, connectionSummaryItem{ + ConnectionArn: c.Arn, ConnectionName: c.Name, ProviderType: c.ProviderType, + Status: c.Status, CreatedAt: epoch(c.CreatedAt), + }) + } + + return listConnectionsResponse{ConnectionSummaryList: items, NextToken: token}, nil + }) +} + +func connectionToWire(c *ardriver.Connection) wireConnection { + return wireConnection{ + ConnectionArn: c.Arn, ConnectionName: c.Name, ProviderType: c.ProviderType, + Status: c.Status, CreatedAt: epoch(c.CreatedAt), + } +} diff --git a/server/aws/apprunner/operations_misc.go b/server/aws/apprunner/operations_misc.go new file mode 100644 index 000000000..585faeae5 --- /dev/null +++ b/server/aws/apprunner/operations_misc.go @@ -0,0 +1,342 @@ +package apprunner + +import ( + "context" + "net/http" + + ardriver "github.com/stackshy/cloudemu/v2/services/apprunner/driver" +) + +// --- ObservabilityConfiguration --- + +func (h *Handler) createObs(w http.ResponseWriter, r *http.Request) { + dispatch(h, w, r, func(h *Handler, ctx context.Context, req *createObsRequest) (any, error) { + cfg, err := h.ar.CreateObservabilityConfiguration( + ctx, req.ObservabilityConfigurationName, req.TraceConfiguration, tagsToMap(req.Tags), + ) + if err != nil { + return nil, err + } + + return obsResponse{ObservabilityConfiguration: obsToWire(cfg)}, nil + }) +} + +func (h *Handler) describeObs(w http.ResponseWriter, r *http.Request) { + dispatch(h, w, r, func(h *Handler, ctx context.Context, req *obsArnRequest) (any, error) { + cfg, err := h.ar.DescribeObservabilityConfiguration(ctx, req.ObservabilityConfigurationArn) + if err != nil { + return nil, err + } + + return obsResponse{ObservabilityConfiguration: obsToWire(cfg)}, nil + }) +} + +func (h *Handler) deleteObs(w http.ResponseWriter, r *http.Request) { + dispatch(h, w, r, func(h *Handler, ctx context.Context, req *obsArnRequest) (any, error) { + cfg, err := h.ar.DeleteObservabilityConfiguration(ctx, req.ObservabilityConfigurationArn) + if err != nil { + return nil, err + } + + return obsResponse{ObservabilityConfiguration: obsToWire(cfg)}, nil + }) +} + +func (h *Handler) listObs(w http.ResponseWriter, r *http.Request) { + dispatch(h, w, r, func(h *Handler, ctx context.Context, req *listObsRequest) (any, error) { + cfgs, token, err := h.ar.ListObservabilityConfigurations( + ctx, req.ObservabilityConfigurationName, req.LatestOnly, req.NextToken, req.MaxResults, + ) + if err != nil { + return nil, err + } + + items := make([]obsSummaryItem, 0, len(cfgs)) + + for i := range cfgs { + c := &cfgs[i] + items = append(items, obsSummaryItem{ + ObservabilityConfigurationArn: c.Arn, ObservabilityConfigurationName: c.Name, + ObservabilityConfigurationRevision: c.Revision, + }) + } + + return listObsResponse{ObservabilityConfigurationSummaryList: items, NextToken: token}, nil + }) +} + +func obsToWire(c *ardriver.ObservabilityConfiguration) wireObs { + return wireObs{ + ObservabilityConfigurationArn: c.Arn, ObservabilityConfigurationName: c.Name, + ObservabilityConfigurationRevision: c.Revision, Status: c.Status, Latest: c.Latest, + TraceConfiguration: c.TraceConfiguration, + CreatedAt: epoch(c.CreatedAt), DeletedAt: epoch(c.DeletedAt), + } +} + +// --- VpcConnector --- + +func (h *Handler) createVpcConnector(w http.ResponseWriter, r *http.Request) { + dispatch(h, w, r, func(h *Handler, ctx context.Context, req *createVpcConnectorRequest) (any, error) { + vc, err := h.ar.CreateVpcConnector(ctx, req.VpcConnectorName, req.Subnets, req.SecurityGroups, tagsToMap(req.Tags)) + if err != nil { + return nil, err + } + + return vpcConnectorResponse{VpcConnector: vpcConnectorToWire(vc)}, nil + }) +} + +func (h *Handler) describeVpcConnector(w http.ResponseWriter, r *http.Request) { + dispatch(h, w, r, func(h *Handler, ctx context.Context, req *vpcConnectorArnRequest) (any, error) { + vc, err := h.ar.DescribeVpcConnector(ctx, req.VpcConnectorArn) + if err != nil { + return nil, err + } + + return vpcConnectorResponse{VpcConnector: vpcConnectorToWire(vc)}, nil + }) +} + +func (h *Handler) deleteVpcConnector(w http.ResponseWriter, r *http.Request) { + dispatch(h, w, r, func(h *Handler, ctx context.Context, req *vpcConnectorArnRequest) (any, error) { + vc, err := h.ar.DeleteVpcConnector(ctx, req.VpcConnectorArn) + if err != nil { + return nil, err + } + + return vpcConnectorResponse{VpcConnector: vpcConnectorToWire(vc)}, nil + }) +} + +func (h *Handler) listVpcConnectors(w http.ResponseWriter, r *http.Request) { + dispatch(h, w, r, func(h *Handler, ctx context.Context, req *listVpcConnectorsRequest) (any, error) { + vcs, token, err := h.ar.ListVpcConnectors(ctx, req.NextToken, req.MaxResults) + if err != nil { + return nil, err + } + + items := make([]wireVpcConnector, 0, len(vcs)) + + for i := range vcs { + items = append(items, vpcConnectorToWire(&vcs[i])) + } + + return listVpcConnectorsResponse{VpcConnectors: items, NextToken: token}, nil + }) +} + +func vpcConnectorToWire(v *ardriver.VpcConnector) wireVpcConnector { + return wireVpcConnector{ + VpcConnectorArn: v.Arn, VpcConnectorName: v.Name, VpcConnectorRevision: v.Revision, + Status: v.Status, Subnets: v.Subnets, SecurityGroups: v.SecurityGroups, + CreatedAt: epoch(v.CreatedAt), DeletedAt: epoch(v.DeletedAt), + } +} + +// --- VpcIngressConnection --- + +func (h *Handler) createVpcIngress(w http.ResponseWriter, r *http.Request) { + dispatch(h, w, r, func(h *Handler, ctx context.Context, req *createVpcIngressRequest) (any, error) { + vic, err := h.ar.CreateVpcIngressConnection( + ctx, req.VpcIngressConnectionName, req.ServiceArn, req.IngressVpcConfiguration, tagsToMap(req.Tags), + ) + if err != nil { + return nil, err + } + + return vpcIngressResponse{VpcIngressConnection: vpcIngressToWire(vic)}, nil + }) +} + +func (h *Handler) describeVpcIngress(w http.ResponseWriter, r *http.Request) { + dispatch(h, w, r, func(h *Handler, ctx context.Context, req *vpcIngressArnRequest) (any, error) { + vic, err := h.ar.DescribeVpcIngressConnection(ctx, req.VpcIngressConnectionArn) + if err != nil { + return nil, err + } + + return vpcIngressResponse{VpcIngressConnection: vpcIngressToWire(vic)}, nil + }) +} + +func (h *Handler) deleteVpcIngress(w http.ResponseWriter, r *http.Request) { + dispatch(h, w, r, func(h *Handler, ctx context.Context, req *vpcIngressArnRequest) (any, error) { + vic, err := h.ar.DeleteVpcIngressConnection(ctx, req.VpcIngressConnectionArn) + if err != nil { + return nil, err + } + + return vpcIngressResponse{VpcIngressConnection: vpcIngressToWire(vic)}, nil + }) +} + +func (h *Handler) updateVpcIngress(w http.ResponseWriter, r *http.Request) { + dispatch(h, w, r, func(h *Handler, ctx context.Context, req *updateVpcIngressRequest) (any, error) { + vic, err := h.ar.UpdateVpcIngressConnection(ctx, req.VpcIngressConnectionArn, req.IngressVpcConfiguration) + if err != nil { + return nil, err + } + + return vpcIngressResponse{VpcIngressConnection: vpcIngressToWire(vic)}, nil + }) +} + +func (h *Handler) listVpcIngress(w http.ResponseWriter, r *http.Request) { + dispatch(h, w, r, func(h *Handler, ctx context.Context, req *listVpcIngressRequest) (any, error) { + var serviceArn, endpointID string + if req.Filter != nil { + serviceArn, endpointID = req.Filter.ServiceArn, req.Filter.VpcEndpointID + } + + vics, token, err := h.ar.ListVpcIngressConnections(ctx, serviceArn, endpointID, req.NextToken, req.MaxResults) + if err != nil { + return nil, err + } + + items := make([]vpcIngressSummaryItem, 0, len(vics)) + + for i := range vics { + items = append(items, vpcIngressSummaryItem{ + VpcIngressConnectionArn: vics[i].Arn, ServiceArn: vics[i].ServiceArn, + }) + } + + return listVpcIngressResponse{VpcIngressConnectionSummaryList: items, NextToken: token}, nil + }) +} + +func vpcIngressToWire(v *ardriver.VpcIngressConnection) wireVpcIngress { + return wireVpcIngress{ + VpcIngressConnectionArn: v.Arn, VpcIngressConnectionName: v.Name, ServiceArn: v.ServiceArn, + Status: v.Status, AccountID: v.AccountID, DomainName: v.DomainName, + IngressVpcConfiguration: v.IngressVpcConfiguration, + CreatedAt: epoch(v.CreatedAt), DeletedAt: epoch(v.DeletedAt), + } +} + +// --- CustomDomain --- + +func (h *Handler) associateCustomDomain(w http.ResponseWriter, r *http.Request) { + dispatch(h, w, r, func(h *Handler, ctx context.Context, req *associateCustomDomainRequest) (any, error) { + enableWWW := req.EnableWWWSubdomain == nil || *req.EnableWWWSubdomain + + cd, dnsTarget, err := h.ar.AssociateCustomDomain(ctx, req.ServiceArn, req.DomainName, enableWWW) + if err != nil { + return nil, err + } + + return associateCustomDomainResponse{ + CustomDomain: customDomainToWire(cd), DNSTarget: dnsTarget, + ServiceArn: req.ServiceArn, VpcDNSTargets: []any{}, + }, nil + }) +} + +func (h *Handler) disassociateCustomDomain(w http.ResponseWriter, r *http.Request) { + dispatch(h, w, r, func(h *Handler, ctx context.Context, req *disassociateCustomDomainRequest) (any, error) { + cd, dnsTarget, err := h.ar.DisassociateCustomDomain(ctx, req.ServiceArn, req.DomainName) + if err != nil { + return nil, err + } + + return associateCustomDomainResponse{ + CustomDomain: customDomainToWire(cd), DNSTarget: dnsTarget, + ServiceArn: req.ServiceArn, VpcDNSTargets: []any{}, + }, nil + }) +} + +func (h *Handler) describeCustomDomains(w http.ResponseWriter, r *http.Request) { + dispatch(h, w, r, func(h *Handler, ctx context.Context, req *describeCustomDomainsRequest) (any, error) { + domains, dnsTarget, token, err := h.ar.DescribeCustomDomains( + ctx, req.ServiceArn, req.NextToken, req.MaxResults, + ) + if err != nil { + return nil, err + } + + items := make([]wireCustomDomain, 0, len(domains)) + + for i := range domains { + items = append(items, customDomainToWire(&domains[i])) + } + + return describeCustomDomainsResponse{ + CustomDomains: items, DNSTarget: dnsTarget, ServiceArn: req.ServiceArn, + VpcDNSTargets: []any{}, NextToken: token, + }, nil + }) +} + +func customDomainToWire(c *ardriver.CustomDomain) wireCustomDomain { + records := make([]wireCertRecord, 0, len(c.CertificateValidationRecords)) + for _, rec := range c.CertificateValidationRecords { + records = append(records, wireCertRecord{ + Name: rec.Name, Type: rec.Type, Value: rec.Value, Status: rec.Status, + }) + } + + return wireCustomDomain{ + DomainName: c.DomainName, EnableWWWSubdomain: c.EnableWWWSubdomain, + Status: c.Status, CertificateValidationRecords: records, + } +} + +// --- Operations --- + +func (h *Handler) listOperations(w http.ResponseWriter, r *http.Request) { + dispatch(h, w, r, func(h *Handler, ctx context.Context, req *listOperationsRequest) (any, error) { + ops, token, err := h.ar.ListOperations(ctx, req.ServiceArn, req.NextToken, req.MaxResults) + if err != nil { + return nil, err + } + + items := make([]wireOperation, 0, len(ops)) + + for i := range ops { + o := &ops[i] + items = append(items, wireOperation{ + ID: o.ID, Type: o.Type, Status: o.Status, TargetArn: o.TargetArn, + StartedAt: epoch(o.StartedAt), EndedAt: epoch(o.EndedAt), UpdatedAt: epoch(o.UpdatedAt), + }) + } + + return listOperationsResponse{OperationSummaryList: items, NextToken: token}, nil + }) +} + +// --- Tags --- + +func (h *Handler) tagResource(w http.ResponseWriter, r *http.Request) { + dispatch(h, w, r, func(h *Handler, ctx context.Context, req *tagResourceRequest) (any, error) { + if err := h.ar.TagResource(ctx, req.ResourceArn, tagsToMap(req.Tags)); err != nil { + return nil, err + } + + return struct{}{}, nil + }) +} + +func (h *Handler) untagResource(w http.ResponseWriter, r *http.Request) { + dispatch(h, w, r, func(h *Handler, ctx context.Context, req *untagResourceRequest) (any, error) { + if err := h.ar.UntagResource(ctx, req.ResourceArn, req.TagKeys); err != nil { + return nil, err + } + + return struct{}{}, nil + }) +} + +func (h *Handler) listTags(w http.ResponseWriter, r *http.Request) { + dispatch(h, w, r, func(h *Handler, ctx context.Context, req *listTagsRequest) (any, error) { + tags, err := h.ar.ListTagsForResource(ctx, req.ResourceArn) + if err != nil { + return nil, err + } + + return listTagsResponse{Tags: mapToTags(tags)}, nil + }) +} diff --git a/server/aws/apprunner/operations_service.go b/server/aws/apprunner/operations_service.go new file mode 100644 index 000000000..67c931da7 --- /dev/null +++ b/server/aws/apprunner/operations_service.go @@ -0,0 +1,146 @@ +package apprunner + +import ( + "context" + "net/http" + + ardriver "github.com/stackshy/cloudemu/v2/services/apprunner/driver" +) + +func (h *Handler) createService(w http.ResponseWriter, r *http.Request) { + dispatch(h, w, r, func(h *Handler, ctx context.Context, req *createServiceRequest) (any, error) { + res, err := h.ar.CreateService(ctx, ardriver.CreateServiceInput{ + ServiceName: req.ServiceName, + SourceConfiguration: req.SourceConfiguration, + InstanceConfiguration: req.InstanceConfiguration, + NetworkConfiguration: req.NetworkConfiguration, + HealthCheckConfiguration: req.HealthCheckConfiguration, + EncryptionConfiguration: req.EncryptionConfiguration, + ObservabilityConfiguration: req.ObservabilityConfiguration, + AutoScalingConfigArn: req.AutoScalingConfigurationArn, + Tags: tagsToMap(req.Tags), + }) + if err != nil { + return nil, err + } + + return serviceOpResponse{Service: serviceToWire(res.Service), OperationID: res.OperationID}, nil + }) +} + +func (h *Handler) describeService(w http.ResponseWriter, r *http.Request) { + dispatch(h, w, r, func(h *Handler, ctx context.Context, req *serviceArnRequest) (any, error) { + svc, err := h.ar.DescribeService(ctx, req.ServiceArn) + if err != nil { + return nil, err + } + + return describeServiceResponse{Service: serviceToWire(svc)}, nil + }) +} + +func (h *Handler) deleteService(w http.ResponseWriter, r *http.Request) { + dispatch(h, w, r, func(h *Handler, ctx context.Context, req *serviceArnRequest) (any, error) { + res, err := h.ar.DeleteService(ctx, req.ServiceArn) + if err != nil { + return nil, err + } + + return serviceOpResponse{Service: serviceToWire(res.Service), OperationID: res.OperationID}, nil + }) +} + +func (h *Handler) updateService(w http.ResponseWriter, r *http.Request) { + dispatch(h, w, r, func(h *Handler, ctx context.Context, req *updateServiceRequest) (any, error) { + res, err := h.ar.UpdateService(ctx, ardriver.UpdateServiceInput{ + ServiceArn: req.ServiceArn, + SourceConfiguration: req.SourceConfiguration, + InstanceConfiguration: req.InstanceConfiguration, + NetworkConfiguration: req.NetworkConfiguration, + HealthCheckConfiguration: req.HealthCheckConfiguration, + ObservabilityConfiguration: req.ObservabilityConfiguration, + AutoScalingConfigArn: req.AutoScalingConfigurationArn, + }) + if err != nil { + return nil, err + } + + return serviceOpResponse{Service: serviceToWire(res.Service), OperationID: res.OperationID}, nil + }) +} + +func (h *Handler) listServices(w http.ResponseWriter, r *http.Request) { + dispatch(h, w, r, func(h *Handler, ctx context.Context, req *listServicesRequest) (any, error) { + svcs, token, err := h.ar.ListServices(ctx, req.NextToken, req.MaxResults) + if err != nil { + return nil, err + } + + items := make([]serviceSummary, 0, len(svcs)) + + for i := range svcs { + s := &svcs[i] + items = append(items, serviceSummary{ + ServiceArn: s.ServiceArn, ServiceID: s.ServiceID, ServiceName: s.ServiceName, + ServiceURL: s.ServiceURL, Status: s.Status, + CreatedAt: epoch(s.CreatedAt), UpdatedAt: epoch(s.UpdatedAt), + }) + } + + return listServicesResponse{ServiceSummaryList: items, NextToken: token}, nil + }) +} + +func (h *Handler) pauseService(w http.ResponseWriter, r *http.Request) { + h.serviceTransition(w, r, h.ar.PauseService) +} + +func (h *Handler) resumeService(w http.ResponseWriter, r *http.Request) { + h.serviceTransition(w, r, h.ar.ResumeService) +} + +func (h *Handler) serviceTransition( + w http.ResponseWriter, r *http.Request, + call func(context.Context, string) (*ardriver.ServiceResult, error), +) { + dispatch(h, w, r, func(_ *Handler, ctx context.Context, req *serviceArnRequest) (any, error) { + res, err := call(ctx, req.ServiceArn) + if err != nil { + return nil, err + } + + return serviceOpResponse{Service: serviceToWire(res.Service), OperationID: res.OperationID}, nil + }) +} + +func (h *Handler) startDeployment(w http.ResponseWriter, r *http.Request) { + dispatch(h, w, r, func(h *Handler, ctx context.Context, req *serviceArnRequest) (any, error) { + opID, err := h.ar.StartDeployment(ctx, req.ServiceArn) + if err != nil { + return nil, err + } + + return startDeploymentResponse{OperationID: opID}, nil + }) +} + +func serviceToWire(s *ardriver.Service) wireService { + out := wireService{ + ServiceArn: s.ServiceArn, ServiceID: s.ServiceID, ServiceName: s.ServiceName, + ServiceURL: s.ServiceURL, Status: s.Status, + CreatedAt: epoch(s.CreatedAt), UpdatedAt: epoch(s.UpdatedAt), DeletedAt: epoch(s.DeletedAt), + SourceConfiguration: s.SourceConfiguration, + InstanceConfiguration: s.InstanceConfiguration, + NetworkConfiguration: s.NetworkConfiguration, + HealthCheckConfiguration: s.HealthCheckConfiguration, + EncryptionConfiguration: s.EncryptionConfiguration, + ObservabilityConfiguration: s.ObservabilityConfiguration, + } + out.AutoScalingConfigurationSummary = &ascSummary{ + AutoScalingConfigurationArn: s.AutoScalingConfigArn, + AutoScalingConfigurationName: s.AutoScalingConfigName, + AutoScalingConfigurationRevision: s.AutoScalingConfigRevision, + } + + return out +} diff --git a/server/aws/apprunner/sdk_roundtrip_test.go b/server/aws/apprunner/sdk_roundtrip_test.go new file mode 100644 index 000000000..8de4b3fa1 --- /dev/null +++ b/server/aws/apprunner/sdk_roundtrip_test.go @@ -0,0 +1,446 @@ +package apprunner_test + +import ( + "context" + "errors" + "net/http/httptest" + "strings" + "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" + awsapprunner "github.com/aws/aws-sdk-go-v2/service/apprunner" + artypes "github.com/aws/aws-sdk-go-v2/service/apprunner/types" + "github.com/aws/smithy-go" + + "github.com/stackshy/cloudemu/v2" + awsserver "github.com/stackshy/cloudemu/v2/server/aws" +) + +func newClient(t *testing.T) *awsapprunner.Client { + t.Helper() + + cloud := cloudemu.NewAWS() + srv := awsserver.New(awsserver.Drivers{AppRunner: cloud.AppRunner}) + + 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 awsapprunner.NewFromConfig(cfg, func(o *awsapprunner.Options) { + o.BaseEndpoint = aws.String(ts.URL) + }) +} + +func createSvc(t *testing.T, c *awsapprunner.Client, name string) *artypes.Service { + t.Helper() + + out, err := c.CreateService(context.Background(), &awsapprunner.CreateServiceInput{ + ServiceName: aws.String(name), + SourceConfiguration: &artypes.SourceConfiguration{ + ImageRepository: &artypes.ImageRepository{ + ImageIdentifier: aws.String("public.ecr.aws/aws-containers/hello-app-runner:latest"), + ImageRepositoryType: artypes.ImageRepositoryTypeEcrPublic, + }, + }, + }) + if err != nil { + t.Fatalf("CreateService: %v", err) + } + + return out.Service +} + +func TestSDKServiceLifecycle(t *testing.T) { + ctx := context.Background() + c := newClient(t) + + svc := createSvc(t, c, "web") + if svc.Status != artypes.ServiceStatusRunning { + t.Fatalf("status = %s, want RUNNING", svc.Status) + } + + arn := aws.ToString(svc.ServiceArn) + if !strings.Contains(arn, ":apprunner:") { + t.Fatalf("unexpected ARN: %s", arn) + } + + if aws.ToString(svc.ServiceUrl) == "" { + t.Fatal("expected a synthesized ServiceUrl") + } + + desc, err := c.DescribeService(ctx, &awsapprunner.DescribeServiceInput{ServiceArn: aws.String(arn)}) + if err != nil { + t.Fatalf("DescribeService: %v", err) + } + + if desc.Service.SourceConfiguration == nil || desc.Service.SourceConfiguration.ImageRepository == nil { + t.Fatal("SourceConfiguration did not round-trip") + } + + if got := aws.ToString(desc.Service.SourceConfiguration.ImageRepository.ImageIdentifier); !strings.Contains( + got, "hello-app-runner") { + t.Fatalf("ImageIdentifier = %q", got) + } + + if _, err := c.PauseService(ctx, &awsapprunner.PauseServiceInput{ServiceArn: aws.String(arn)}); err != nil { + t.Fatalf("PauseService: %v", err) + } + + resumed, err := c.ResumeService(ctx, &awsapprunner.ResumeServiceInput{ServiceArn: aws.String(arn)}) + if err != nil { + t.Fatalf("ResumeService: %v", err) + } + + if resumed.Service.Status != artypes.ServiceStatusRunning { + t.Fatalf("after resume status = %s", resumed.Service.Status) + } + + del, err := c.DeleteService(ctx, &awsapprunner.DeleteServiceInput{ServiceArn: aws.String(arn)}) + if err != nil { + t.Fatalf("DeleteService: %v", err) + } + + if del.Service.Status != artypes.ServiceStatusDeleted { + t.Fatalf("after delete status = %s, want DELETED", del.Service.Status) + } +} + +func TestSDKListServices(t *testing.T) { + ctx := context.Background() + c := newClient(t) + + createSvc(t, c, "a") + createSvc(t, c, "b") + + out, err := c.ListServices(ctx, &awsapprunner.ListServicesInput{}) + if err != nil { + t.Fatalf("ListServices: %v", err) + } + + if len(out.ServiceSummaryList) != 2 { + t.Fatalf("want 2 services, got %d", len(out.ServiceSummaryList)) + } +} + +func TestSDKAutoScalingConfiguration(t *testing.T) { + ctx := context.Background() + c := newClient(t) + + c1, err := c.CreateAutoScalingConfiguration(ctx, &awsapprunner.CreateAutoScalingConfigurationInput{ + AutoScalingConfigurationName: aws.String("hi"), + MaxConcurrency: aws.Int32(150), + MaxSize: aws.Int32(20), + MinSize: aws.Int32(2), + }) + if err != nil { + t.Fatalf("CreateAutoScalingConfiguration: %v", err) + } + + if aws.ToInt32(c1.AutoScalingConfiguration.AutoScalingConfigurationRevision) != 1 { + t.Fatalf("revision = %d, want 1", aws.ToInt32(c1.AutoScalingConfiguration.AutoScalingConfigurationRevision)) + } + + c2, err := c.CreateAutoScalingConfiguration(ctx, &awsapprunner.CreateAutoScalingConfigurationInput{ + AutoScalingConfigurationName: aws.String("hi"), + }) + if err != nil { + t.Fatalf("CreateAutoScalingConfiguration r2: %v", err) + } + + if aws.ToInt32(c2.AutoScalingConfiguration.AutoScalingConfigurationRevision) != 2 { + t.Fatalf("revision = %d, want 2", aws.ToInt32(c2.AutoScalingConfiguration.AutoScalingConfigurationRevision)) + } + + arn := aws.ToString(c2.AutoScalingConfiguration.AutoScalingConfigurationArn) + + desc, err := c.DescribeAutoScalingConfiguration(ctx, &awsapprunner.DescribeAutoScalingConfigurationInput{ + AutoScalingConfigurationArn: aws.String(arn), + }) + if err != nil { + t.Fatalf("DescribeAutoScalingConfiguration: %v", err) + } + + if desc.AutoScalingConfiguration.Status != artypes.AutoScalingConfigurationStatusActive { + t.Fatalf("status = %s, want ACTIVE", desc.AutoScalingConfiguration.Status) + } + + list, err := c.ListAutoScalingConfigurations(ctx, &awsapprunner.ListAutoScalingConfigurationsInput{}) + if err != nil { + t.Fatalf("ListAutoScalingConfigurations: %v", err) + } + + if len(list.AutoScalingConfigurationSummaryList) != 2 { + t.Fatalf("want 2 ASC revisions, got %d", len(list.AutoScalingConfigurationSummaryList)) + } +} + +func TestSDKConnection(t *testing.T) { + ctx := context.Background() + c := newClient(t) + + out, err := c.CreateConnection(ctx, &awsapprunner.CreateConnectionInput{ + ConnectionName: aws.String("gh"), + ProviderType: artypes.ProviderTypeGithub, + }) + if err != nil { + t.Fatalf("CreateConnection: %v", err) + } + + if out.Connection.ProviderType != artypes.ProviderTypeGithub { + t.Fatalf("provider = %s", out.Connection.ProviderType) + } + + list, err := c.ListConnections(ctx, &awsapprunner.ListConnectionsInput{}) + if err != nil { + t.Fatalf("ListConnections: %v", err) + } + + if len(list.ConnectionSummaryList) != 1 { + t.Fatalf("want 1 connection, got %d", len(list.ConnectionSummaryList)) + } +} + +func TestSDKDescribeMissingServiceTypedError(t *testing.T) { + ctx := context.Background() + c := newClient(t) + + _, err := c.DescribeService(ctx, &awsapprunner.DescribeServiceInput{ + ServiceArn: aws.String("arn:aws:apprunner:us-east-1:123456789012:service/missing/deadbeef"), + }) + if err == nil { + t.Fatal("expected error for missing service") + } + + var nfe *artypes.ResourceNotFoundException + if !errors.As(err, &nfe) { + var apiErr smithy.APIError + if errors.As(err, &apiErr) { + t.Fatalf("want ResourceNotFoundException, got %q", apiErr.ErrorCode()) + } + + t.Fatalf("want ResourceNotFoundException, got %v", err) + } +} + +func TestSDKObservabilityConfiguration(t *testing.T) { + ctx := context.Background() + c := newClient(t) + + out, err := c.CreateObservabilityConfiguration(ctx, &awsapprunner.CreateObservabilityConfigurationInput{ + ObservabilityConfigurationName: aws.String("obs"), + TraceConfiguration: &artypes.TraceConfiguration{Vendor: artypes.TracingVendorAwsxray}, + }) + if err != nil { + t.Fatalf("CreateObservabilityConfiguration: %v", err) + } + + oc := out.ObservabilityConfiguration + if oc.Status != artypes.ObservabilityConfigurationStatusActive { + t.Fatalf("status = %s, want ACTIVE", oc.Status) + } + + if oc.TraceConfiguration == nil || oc.TraceConfiguration.Vendor != artypes.TracingVendorAwsxray { + t.Fatal("TraceConfiguration did not round-trip") + } + + list, err := c.ListObservabilityConfigurations(ctx, &awsapprunner.ListObservabilityConfigurationsInput{}) + if err != nil { + t.Fatalf("ListObservabilityConfigurations: %v", err) + } + + if len(list.ObservabilityConfigurationSummaryList) != 1 { + t.Fatalf("want 1 obs config, got %d", len(list.ObservabilityConfigurationSummaryList)) + } +} + +func TestSDKVpcConnector(t *testing.T) { + ctx := context.Background() + c := newClient(t) + + out, err := c.CreateVpcConnector(ctx, &awsapprunner.CreateVpcConnectorInput{ + VpcConnectorName: aws.String("vc"), + Subnets: []string{"subnet-1", "subnet-2"}, + SecurityGroups: []string{"sg-1"}, + }) + if err != nil { + t.Fatalf("CreateVpcConnector: %v", err) + } + + if out.VpcConnector.Status != artypes.VpcConnectorStatusActive { + t.Fatalf("status = %s", out.VpcConnector.Status) + } + + if len(out.VpcConnector.Subnets) != 2 { + t.Fatalf("subnets = %v", out.VpcConnector.Subnets) + } + + list, err := c.ListVpcConnectors(ctx, &awsapprunner.ListVpcConnectorsInput{}) + if err != nil || len(list.VpcConnectors) != 1 { + t.Fatalf("ListVpcConnectors: n=%d err=%v", len(list.VpcConnectors), err) + } +} + +func TestSDKVpcIngressConnection(t *testing.T) { + ctx := context.Background() + c := newClient(t) + svc := createSvc(t, c, "ingress") + arn := aws.ToString(svc.ServiceArn) + + out, err := c.CreateVpcIngressConnection(ctx, &awsapprunner.CreateVpcIngressConnectionInput{ + VpcIngressConnectionName: aws.String("vic"), + ServiceArn: aws.String(arn), + IngressVpcConfiguration: &artypes.IngressVpcConfiguration{ + VpcId: aws.String("vpc-1"), VpcEndpointId: aws.String("vpce-1"), + }, + }) + if err != nil { + t.Fatalf("CreateVpcIngressConnection: %v", err) + } + + vicArn := aws.ToString(out.VpcIngressConnection.VpcIngressConnectionArn) + if out.VpcIngressConnection.Status != artypes.VpcIngressConnectionStatusAvailable { + t.Fatalf("status = %s", out.VpcIngressConnection.Status) + } + + list, err := c.ListVpcIngressConnections(ctx, &awsapprunner.ListVpcIngressConnectionsInput{ + Filter: &artypes.ListVpcIngressConnectionsFilter{ServiceArn: aws.String(arn)}, + }) + if err != nil || len(list.VpcIngressConnectionSummaryList) != 1 { + t.Fatalf("ListVpcIngressConnections: n=%d err=%v", len(list.VpcIngressConnectionSummaryList), err) + } + + upd, err := c.UpdateVpcIngressConnection(ctx, &awsapprunner.UpdateVpcIngressConnectionInput{ + VpcIngressConnectionArn: aws.String(vicArn), + IngressVpcConfiguration: &artypes.IngressVpcConfiguration{ + VpcId: aws.String("vpc-2"), VpcEndpointId: aws.String("vpce-2"), + }, + }) + if err != nil { + t.Fatalf("UpdateVpcIngressConnection: %v", err) + } + + if aws.ToString(upd.VpcIngressConnection.IngressVpcConfiguration.VpcEndpointId) != "vpce-2" { + t.Fatal("update did not round-trip the new endpoint id") + } + + del, err := c.DeleteVpcIngressConnection(ctx, &awsapprunner.DeleteVpcIngressConnectionInput{ + VpcIngressConnectionArn: aws.String(vicArn), + }) + if err != nil || del.VpcIngressConnection.Status != artypes.VpcIngressConnectionStatusDeleted { + t.Fatalf("DeleteVpcIngressConnection: %v", err) + } +} + +func TestSDKCustomDomain(t *testing.T) { + ctx := context.Background() + c := newClient(t) + svc := createSvc(t, c, "cd") + arn := aws.ToString(svc.ServiceArn) + + assoc, err := c.AssociateCustomDomain(ctx, &awsapprunner.AssociateCustomDomainInput{ + ServiceArn: aws.String(arn), DomainName: aws.String("example.com"), + }) + if err != nil { + t.Fatalf("AssociateCustomDomain: %v", err) + } + + if aws.ToString(assoc.DNSTarget) == "" || assoc.CustomDomain == nil { + t.Fatal("expected DNSTarget and CustomDomain") + } + + desc, err := c.DescribeCustomDomains(ctx, &awsapprunner.DescribeCustomDomainsInput{ServiceArn: aws.String(arn)}) + if err != nil || len(desc.CustomDomains) != 1 { + t.Fatalf("DescribeCustomDomains: n=%d err=%v", len(desc.CustomDomains), err) + } + + if _, err := c.DisassociateCustomDomain(ctx, &awsapprunner.DisassociateCustomDomainInput{ + ServiceArn: aws.String(arn), DomainName: aws.String("example.com"), + }); err != nil { + t.Fatalf("DisassociateCustomDomain: %v", err) + } +} + +func TestSDKListOperations(t *testing.T) { + ctx := context.Background() + c := newClient(t) + svc := createSvc(t, c, "ops") + arn := aws.ToString(svc.ServiceArn) + + if _, err := c.PauseService(ctx, &awsapprunner.PauseServiceInput{ServiceArn: aws.String(arn)}); err != nil { + t.Fatalf("PauseService: %v", err) + } + + out, err := c.ListOperations(ctx, &awsapprunner.ListOperationsInput{ServiceArn: aws.String(arn)}) + if err != nil { + t.Fatalf("ListOperations: %v", err) + } + + if len(out.OperationSummaryList) != 2 { + t.Fatalf("want 2 operations (CREATE + PAUSE), got %d", len(out.OperationSummaryList)) + } +} + +func TestSDKTags(t *testing.T) { + ctx := context.Background() + c := newClient(t) + svc := createSvc(t, c, "tagged") + arn := aws.ToString(svc.ServiceArn) + + if _, err := c.TagResource(ctx, &awsapprunner.TagResourceInput{ + ResourceArn: aws.String(arn), + Tags: []artypes.Tag{{Key: aws.String("env"), Value: aws.String("prod")}}, + }); err != nil { + t.Fatalf("TagResource: %v", err) + } + + list, err := c.ListTagsForResource(ctx, &awsapprunner.ListTagsForResourceInput{ResourceArn: aws.String(arn)}) + if err != nil || len(list.Tags) != 1 || aws.ToString(list.Tags[0].Value) != "prod" { + t.Fatalf("ListTagsForResource: %v err=%v", list.Tags, err) + } + + if _, err := c.UntagResource(ctx, &awsapprunner.UntagResourceInput{ + ResourceArn: aws.String(arn), TagKeys: []string{"env"}, + }); err != nil { + t.Fatalf("UntagResource: %v", err) + } + + list, _ = c.ListTagsForResource(ctx, &awsapprunner.ListTagsForResourceInput{ResourceArn: aws.String(arn)}) + if len(list.Tags) != 0 { + t.Fatalf("after untag want 0 tags, got %d", len(list.Tags)) + } +} + +func TestSDKPauseIllegalStateTypedError(t *testing.T) { + ctx := context.Background() + c := newClient(t) + svc := createSvc(t, c, "svc") + arn := aws.ToString(svc.ServiceArn) + + if _, err := c.PauseService(ctx, &awsapprunner.PauseServiceInput{ServiceArn: aws.String(arn)}); err != nil { + t.Fatalf("PauseService: %v", err) + } + + _, err := c.PauseService(ctx, &awsapprunner.PauseServiceInput{ServiceArn: aws.String(arn)}) + if err == nil { + t.Fatal("expected InvalidStateException pausing a PAUSED service") + } + + var ise *artypes.InvalidStateException + if !errors.As(err, &ise) { + var apiErr smithy.APIError + if errors.As(err, &apiErr) { + t.Fatalf("want InvalidStateException, got %q", apiErr.ErrorCode()) + } + + t.Fatalf("want InvalidStateException, got %v", err) + } +} diff --git a/server/aws/apprunner/types.go b/server/aws/apprunner/types.go new file mode 100644 index 000000000..4c5348c18 --- /dev/null +++ b/server/aws/apprunner/types.go @@ -0,0 +1,250 @@ +package apprunner + +import ( + "encoding/json" + "sort" + "time" +) + +// epoch renders a time as AWS JSON 1.0 epoch seconds (fractional). A zero time +// serializes as nil so optional timestamps are omitted. +func epoch(t time.Time) *float64 { + if t.IsZero() { + return nil + } + + secs := float64(t.UnixNano()) / float64(time.Second) + + return &secs +} + +// tag is the App Runner wire tag shape. Unlike Step Functions (lowercase +// key/value), App Runner capitalizes Key/Value. +type tag struct { + Key string `json:"Key"` + Value string `json:"Value"` +} + +func tagsToMap(tags []tag) map[string]string { + if len(tags) == 0 { + return nil + } + + out := make(map[string]string, len(tags)) + for _, t := range tags { + out[t.Key] = t.Value + } + + return out +} + +// mapToTags renders a tag map as the App Runner wire tag list, ordered by key +// for deterministic output. +func mapToTags(m map[string]string) []tag { + out := make([]tag, 0, len(m)) + for k, v := range m { + out = append(out, tag{Key: k, Value: v}) + } + + sort.Slice(out, func(i, j int) bool { return out[i].Key < out[j].Key }) + + return out +} + +// --- Service request shapes --- + +type createServiceRequest struct { + ServiceName string `json:"ServiceName"` + SourceConfiguration json.RawMessage `json:"SourceConfiguration"` + InstanceConfiguration json.RawMessage `json:"InstanceConfiguration"` + NetworkConfiguration json.RawMessage `json:"NetworkConfiguration"` + HealthCheckConfiguration json.RawMessage `json:"HealthCheckConfiguration"` + EncryptionConfiguration json.RawMessage `json:"EncryptionConfiguration"` + ObservabilityConfiguration json.RawMessage `json:"ObservabilityConfiguration"` + AutoScalingConfigurationArn string `json:"AutoScalingConfigurationArn"` + Tags []tag `json:"Tags"` +} + +type serviceArnRequest struct { + ServiceArn string `json:"ServiceArn"` +} + +type updateServiceRequest struct { + ServiceArn string `json:"ServiceArn"` + SourceConfiguration json.RawMessage `json:"SourceConfiguration"` + InstanceConfiguration json.RawMessage `json:"InstanceConfiguration"` + NetworkConfiguration json.RawMessage `json:"NetworkConfiguration"` + HealthCheckConfiguration json.RawMessage `json:"HealthCheckConfiguration"` + ObservabilityConfiguration json.RawMessage `json:"ObservabilityConfiguration"` + AutoScalingConfigurationArn string `json:"AutoScalingConfigurationArn"` +} + +type listServicesRequest struct { + NextToken string `json:"NextToken"` + MaxResults int32 `json:"MaxResults"` +} + +// --- Service response shapes --- + +// wireService is the full Service wire shape. Modeled-verbatim config blocks are +// emitted as raw JSON; identity/status/timestamps are modeled explicitly. +type wireService struct { + ServiceArn string `json:"ServiceArn"` + ServiceID string `json:"ServiceId"` + ServiceName string `json:"ServiceName"` + ServiceURL string `json:"ServiceUrl,omitempty"` + Status string `json:"Status"` + CreatedAt *float64 `json:"CreatedAt"` + UpdatedAt *float64 `json:"UpdatedAt"` + DeletedAt *float64 `json:"DeletedAt,omitempty"` + SourceConfiguration json.RawMessage `json:"SourceConfiguration,omitempty"` + InstanceConfiguration json.RawMessage `json:"InstanceConfiguration,omitempty"` + NetworkConfiguration json.RawMessage `json:"NetworkConfiguration,omitempty"` + HealthCheckConfiguration json.RawMessage `json:"HealthCheckConfiguration,omitempty"` + EncryptionConfiguration json.RawMessage `json:"EncryptionConfiguration,omitempty"` + ObservabilityConfiguration json.RawMessage `json:"ObservabilityConfiguration,omitempty"` + AutoScalingConfigurationSummary *ascSummary `json:"AutoScalingConfigurationSummary,omitempty"` +} + +type ascSummary struct { + AutoScalingConfigurationArn string `json:"AutoScalingConfigurationArn,omitempty"` + AutoScalingConfigurationName string `json:"AutoScalingConfigurationName,omitempty"` + AutoScalingConfigurationRevision int32 `json:"AutoScalingConfigurationRevision,omitempty"` +} + +type serviceOpResponse struct { + Service wireService `json:"Service"` + OperationID string `json:"OperationId,omitempty"` +} + +type describeServiceResponse struct { + Service wireService `json:"Service"` +} + +type serviceSummary struct { + ServiceArn string `json:"ServiceArn"` + ServiceID string `json:"ServiceId"` + ServiceName string `json:"ServiceName"` + ServiceURL string `json:"ServiceUrl,omitempty"` + Status string `json:"Status"` + CreatedAt *float64 `json:"CreatedAt"` + UpdatedAt *float64 `json:"UpdatedAt"` +} + +type listServicesResponse struct { + ServiceSummaryList []serviceSummary `json:"ServiceSummaryList"` + NextToken string `json:"NextToken,omitempty"` +} + +type startDeploymentResponse struct { + OperationID string `json:"OperationId"` +} + +// --- AutoScalingConfiguration shapes --- + +type createASCRequest struct { + AutoScalingConfigurationName string `json:"AutoScalingConfigurationName"` + MaxConcurrency int32 `json:"MaxConcurrency"` + MaxSize int32 `json:"MaxSize"` + MinSize int32 `json:"MinSize"` +} + +type ascArnRequest struct { + AutoScalingConfigurationArn string `json:"AutoScalingConfigurationArn"` + DeleteAllRevisions bool `json:"DeleteAllRevisions"` +} + +type listASCRequest struct { + AutoScalingConfigurationName string `json:"AutoScalingConfigurationName"` + LatestOnly bool `json:"LatestOnly"` + NextToken string `json:"NextToken"` + MaxResults int32 `json:"MaxResults"` +} + +type listServicesForASCRequest struct { + AutoScalingConfigurationArn string `json:"AutoScalingConfigurationArn"` + NextToken string `json:"NextToken"` + MaxResults int32 `json:"MaxResults"` +} + +type wireASC struct { + AutoScalingConfigurationArn string `json:"AutoScalingConfigurationArn"` + AutoScalingConfigurationName string `json:"AutoScalingConfigurationName"` + AutoScalingConfigurationRevision int32 `json:"AutoScalingConfigurationRevision"` + Status string `json:"Status"` + MaxConcurrency int32 `json:"MaxConcurrency"` + MaxSize int32 `json:"MaxSize"` + MinSize int32 `json:"MinSize"` + IsDefault bool `json:"IsDefault"` + Latest bool `json:"Latest"` + HasAssociatedService bool `json:"HasAssociatedService"` + CreatedAt *float64 `json:"CreatedAt"` + DeletedAt *float64 `json:"DeletedAt,omitempty"` +} + +type ascResponse struct { + AutoScalingConfiguration wireASC `json:"AutoScalingConfiguration"` +} + +type ascSummaryItem struct { + AutoScalingConfigurationArn string `json:"AutoScalingConfigurationArn"` + AutoScalingConfigurationName string `json:"AutoScalingConfigurationName"` + AutoScalingConfigurationRevision int32 `json:"AutoScalingConfigurationRevision"` + Status string `json:"Status"` + IsDefault bool `json:"IsDefault"` + HasAssociatedService bool `json:"HasAssociatedService"` + CreatedAt *float64 `json:"CreatedAt"` +} + +type listASCResponse struct { + AutoScalingConfigurationSummaryList []ascSummaryItem `json:"AutoScalingConfigurationSummaryList"` + NextToken string `json:"NextToken,omitempty"` +} + +type listServicesForASCResponse struct { + ServiceArnList []string `json:"ServiceArnList"` + NextToken string `json:"NextToken,omitempty"` +} + +// --- Connection shapes --- + +type createConnectionRequest struct { + ConnectionName string `json:"ConnectionName"` + ProviderType string `json:"ProviderType"` + Tags []tag `json:"Tags"` +} + +type connectionArnRequest struct { + ConnectionArn string `json:"ConnectionArn"` +} + +type listConnectionsRequest struct { + ConnectionName string `json:"ConnectionName"` + NextToken string `json:"NextToken"` + MaxResults int32 `json:"MaxResults"` +} + +type wireConnection struct { + ConnectionArn string `json:"ConnectionArn"` + ConnectionName string `json:"ConnectionName"` + ProviderType string `json:"ProviderType"` + Status string `json:"Status"` + CreatedAt *float64 `json:"CreatedAt"` +} + +type connectionResponse struct { + Connection wireConnection `json:"Connection"` +} + +type connectionSummaryItem struct { + ConnectionArn string `json:"ConnectionArn"` + ConnectionName string `json:"ConnectionName"` + ProviderType string `json:"ProviderType"` + Status string `json:"Status"` + CreatedAt *float64 `json:"CreatedAt"` +} + +type listConnectionsResponse struct { + ConnectionSummaryList []connectionSummaryItem `json:"ConnectionSummaryList"` + NextToken string `json:"NextToken,omitempty"` +} diff --git a/server/aws/apprunner/types_misc.go b/server/aws/apprunner/types_misc.go new file mode 100644 index 000000000..5c9c0fef2 --- /dev/null +++ b/server/aws/apprunner/types_misc.go @@ -0,0 +1,232 @@ +package apprunner + +import "encoding/json" + +// --- ObservabilityConfiguration shapes --- + +type createObsRequest struct { + ObservabilityConfigurationName string `json:"ObservabilityConfigurationName"` + TraceConfiguration json.RawMessage `json:"TraceConfiguration"` + Tags []tag `json:"Tags"` +} + +type obsArnRequest struct { + ObservabilityConfigurationArn string `json:"ObservabilityConfigurationArn"` +} + +type listObsRequest struct { + ObservabilityConfigurationName string `json:"ObservabilityConfigurationName"` + LatestOnly bool `json:"LatestOnly"` + NextToken string `json:"NextToken"` + MaxResults int32 `json:"MaxResults"` +} + +type wireObs struct { + ObservabilityConfigurationArn string `json:"ObservabilityConfigurationArn"` + ObservabilityConfigurationName string `json:"ObservabilityConfigurationName"` + ObservabilityConfigurationRevision int32 `json:"ObservabilityConfigurationRevision"` + Status string `json:"Status"` + Latest bool `json:"Latest"` + TraceConfiguration json.RawMessage `json:"TraceConfiguration,omitempty"` + CreatedAt *float64 `json:"CreatedAt"` + DeletedAt *float64 `json:"DeletedAt,omitempty"` +} + +type obsResponse struct { + ObservabilityConfiguration wireObs `json:"ObservabilityConfiguration"` +} + +type obsSummaryItem struct { + ObservabilityConfigurationArn string `json:"ObservabilityConfigurationArn"` + ObservabilityConfigurationName string `json:"ObservabilityConfigurationName"` + ObservabilityConfigurationRevision int32 `json:"ObservabilityConfigurationRevision"` +} + +type listObsResponse struct { + ObservabilityConfigurationSummaryList []obsSummaryItem `json:"ObservabilityConfigurationSummaryList"` + NextToken string `json:"NextToken,omitempty"` +} + +// --- VpcConnector shapes --- + +type createVpcConnectorRequest struct { + VpcConnectorName string `json:"VpcConnectorName"` + Subnets []string `json:"Subnets"` + SecurityGroups []string `json:"SecurityGroups"` + Tags []tag `json:"Tags"` +} + +type vpcConnectorArnRequest struct { + VpcConnectorArn string `json:"VpcConnectorArn"` +} + +type listVpcConnectorsRequest struct { + NextToken string `json:"NextToken"` + MaxResults int32 `json:"MaxResults"` +} + +type wireVpcConnector struct { + VpcConnectorArn string `json:"VpcConnectorArn"` + VpcConnectorName string `json:"VpcConnectorName"` + VpcConnectorRevision int32 `json:"VpcConnectorRevision"` + Status string `json:"Status"` + Subnets []string `json:"Subnets"` + SecurityGroups []string `json:"SecurityGroups"` + CreatedAt *float64 `json:"CreatedAt"` + DeletedAt *float64 `json:"DeletedAt,omitempty"` +} + +type vpcConnectorResponse struct { + VpcConnector wireVpcConnector `json:"VpcConnector"` +} + +type listVpcConnectorsResponse struct { + VpcConnectors []wireVpcConnector `json:"VpcConnectors"` + NextToken string `json:"NextToken,omitempty"` +} + +// --- VpcIngressConnection shapes --- + +type createVpcIngressRequest struct { + VpcIngressConnectionName string `json:"VpcIngressConnectionName"` + ServiceArn string `json:"ServiceArn"` + IngressVpcConfiguration json.RawMessage `json:"IngressVpcConfiguration"` + Tags []tag `json:"Tags"` +} + +type vpcIngressArnRequest struct { + VpcIngressConnectionArn string `json:"VpcIngressConnectionArn"` +} + +type updateVpcIngressRequest struct { + VpcIngressConnectionArn string `json:"VpcIngressConnectionArn"` + IngressVpcConfiguration json.RawMessage `json:"IngressVpcConfiguration"` +} + +type listVpcIngressRequest struct { + Filter *vpcIngressFilter `json:"Filter"` + NextToken string `json:"NextToken"` + MaxResults int32 `json:"MaxResults"` +} + +type vpcIngressFilter struct { + ServiceArn string `json:"ServiceArn"` + VpcEndpointID string `json:"VpcEndpointId"` +} + +type wireVpcIngress struct { + VpcIngressConnectionArn string `json:"VpcIngressConnectionArn"` + VpcIngressConnectionName string `json:"VpcIngressConnectionName"` + ServiceArn string `json:"ServiceArn"` + Status string `json:"Status"` + AccountID string `json:"AccountId,omitempty"` + DomainName string `json:"DomainName,omitempty"` + IngressVpcConfiguration json.RawMessage `json:"IngressVpcConfiguration,omitempty"` + CreatedAt *float64 `json:"CreatedAt"` + DeletedAt *float64 `json:"DeletedAt,omitempty"` +} + +type vpcIngressResponse struct { + VpcIngressConnection wireVpcIngress `json:"VpcIngressConnection"` +} + +type vpcIngressSummaryItem struct { + VpcIngressConnectionArn string `json:"VpcIngressConnectionArn"` + ServiceArn string `json:"ServiceArn"` +} + +type listVpcIngressResponse struct { + VpcIngressConnectionSummaryList []vpcIngressSummaryItem `json:"VpcIngressConnectionSummaryList"` + NextToken string `json:"NextToken,omitempty"` +} + +// --- CustomDomain shapes --- + +type associateCustomDomainRequest struct { + ServiceArn string `json:"ServiceArn"` + DomainName string `json:"DomainName"` + EnableWWWSubdomain *bool `json:"EnableWWWSubdomain"` +} + +type disassociateCustomDomainRequest struct { + ServiceArn string `json:"ServiceArn"` + DomainName string `json:"DomainName"` +} + +type describeCustomDomainsRequest struct { + ServiceArn string `json:"ServiceArn"` + NextToken string `json:"NextToken"` + MaxResults int32 `json:"MaxResults"` +} + +type wireCertRecord struct { + Name string `json:"Name,omitempty"` + Type string `json:"Type,omitempty"` + Value string `json:"Value,omitempty"` + Status string `json:"Status,omitempty"` +} + +type wireCustomDomain struct { + DomainName string `json:"DomainName"` + EnableWWWSubdomain bool `json:"EnableWWWSubdomain"` + Status string `json:"Status"` + CertificateValidationRecords []wireCertRecord `json:"CertificateValidationRecords,omitempty"` +} + +type associateCustomDomainResponse struct { + CustomDomain wireCustomDomain `json:"CustomDomain"` + DNSTarget string `json:"DNSTarget"` + ServiceArn string `json:"ServiceArn"` + VpcDNSTargets []any `json:"VpcDNSTargets"` +} + +type describeCustomDomainsResponse struct { + CustomDomains []wireCustomDomain `json:"CustomDomains"` + DNSTarget string `json:"DNSTarget"` + ServiceArn string `json:"ServiceArn"` + VpcDNSTargets []any `json:"VpcDNSTargets"` + NextToken string `json:"NextToken,omitempty"` +} + +// --- Operations shapes --- + +type listOperationsRequest struct { + ServiceArn string `json:"ServiceArn"` + NextToken string `json:"NextToken"` + MaxResults int32 `json:"MaxResults"` +} + +type wireOperation struct { + ID string `json:"Id"` + Type string `json:"Type"` + Status string `json:"Status"` + TargetArn string `json:"TargetArn,omitempty"` + StartedAt *float64 `json:"StartedAt"` + EndedAt *float64 `json:"EndedAt,omitempty"` + UpdatedAt *float64 `json:"UpdatedAt,omitempty"` +} + +type listOperationsResponse struct { + OperationSummaryList []wireOperation `json:"OperationSummaryList"` + NextToken string `json:"NextToken,omitempty"` +} + +// --- Tags shapes --- + +type tagResourceRequest struct { + ResourceArn string `json:"ResourceArn"` + Tags []tag `json:"Tags"` +} + +type untagResourceRequest struct { + ResourceArn string `json:"ResourceArn"` + TagKeys []string `json:"TagKeys"` +} + +type listTagsRequest struct { + ResourceArn string `json:"ResourceArn"` +} + +type listTagsResponse struct { + Tags []tag `json:"Tags"` +} diff --git a/server/aws/aws.go b/server/aws/aws.go index 39550511e..84873c96f 100644 --- a/server/aws/aws.go +++ b/server/aws/aws.go @@ -11,6 +11,7 @@ import ( eksdriver "github.com/stackshy/cloudemu/v2/providers/aws/eks/driver" "github.com/stackshy/cloudemu/v2/server" acmsrv "github.com/stackshy/cloudemu/v2/server/aws/acm" + apprunnersrv "github.com/stackshy/cloudemu/v2/server/aws/apprunner" "github.com/stackshy/cloudemu/v2/server/aws/bedrock" "github.com/stackshy/cloudemu/v2/server/aws/bedrockagent" "github.com/stackshy/cloudemu/v2/server/aws/bedrockagentruntime" @@ -56,6 +57,7 @@ import ( vpclatticesrv "github.com/stackshy/cloudemu/v2/server/aws/vpclattice" wafv2srv "github.com/stackshy/cloudemu/v2/server/aws/wafv2" acmdriver "github.com/stackshy/cloudemu/v2/services/acm/driver" + apprunnerdriver "github.com/stackshy/cloudemu/v2/services/apprunner/driver" 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" @@ -207,6 +209,9 @@ type Drivers struct { // SFN serves the Step Functions JSON 1.0 protocol (X-Amz-Target prefix // "AWSStepFunctions.") against the sfn driver. SFN sfndriver.SFN + // AppRunner serves the AWS App Runner JSON 1.0 protocol (X-Amz-Target prefix + // "AppRunner.") against the apprunner driver. + AppRunner apprunnerdriver.AppRunner // STS serves the AWS STS query protocol (GetCallerIdentity, AssumeRole, // GetSessionToken). It has no backing driver — identity is derived from // AccountID and Region — so it is gated on this bool. Enable it so SDK code @@ -276,6 +281,7 @@ func DriversFrom(p *awsprovider.Provider) Drivers { NetworkFirewall: p.NetworkFirewall, SNS: p.SNS, SFN: p.SFN, + AppRunner: p.AppRunner, STS: true, K8sAPI: nil, // injected by the caller when a shared cluster is desired ResourceDiscovery: p.ResourceDiscovery, @@ -382,6 +388,13 @@ func New(d Drivers) *server.Server { srv.Register(sfnsrv.New(d.SFN)) } + // App Runner matches the X-Amz-Target prefix "AppRunner." — disjoint from + // every other JSON-RPC service and from S3 (which rejects any X-Amz-Target), + // so registration order is unconstrained. + if d.AppRunner != nil { + srv.Register(apprunnersrv.New(d.AppRunner)) + } + // Kinesis matches the X-Amz-Target prefix "Kinesis_20131202." — disjoint // from the other JSON 1.1 services, so registration order is unconstrained. if d.Kinesis != nil { diff --git a/services/apprunner/driver/driver.go b/services/apprunner/driver/driver.go new file mode 100644 index 000000000..25eeedc70 --- /dev/null +++ b/services/apprunner/driver/driver.go @@ -0,0 +1,82 @@ +package driver + +import ( + "context" + "encoding/json" +) + +// AppRunner is the interface an App Runner backend implements, covering +// services, auto scaling configurations, connections, observability +// configurations, VPC connectors, VPC ingress connections, custom domains, +// per-service operations, and tags. +type AppRunner interface { + // Service. + CreateService(ctx context.Context, in CreateServiceInput) (*ServiceResult, error) + DescribeService(ctx context.Context, arn string) (*Service, error) + DeleteService(ctx context.Context, arn string) (*ServiceResult, error) + UpdateService(ctx context.Context, in UpdateServiceInput) (*ServiceResult, error) + ListServices(ctx context.Context, nextToken string, maxResults int32) ([]Service, string, error) + PauseService(ctx context.Context, arn string) (*ServiceResult, error) + ResumeService(ctx context.Context, arn string) (*ServiceResult, error) + StartDeployment(ctx context.Context, arn string) (operationID string, err error) + + // AutoScalingConfiguration. + CreateAutoScalingConfiguration(ctx context.Context, name string, maxConcurrency, maxSize, minSize int32, + ) (*AutoScalingConfiguration, error) + DescribeAutoScalingConfiguration(ctx context.Context, arn string) (*AutoScalingConfiguration, error) + DeleteAutoScalingConfiguration(ctx context.Context, arn string, deleteAllRevisions bool, + ) (*AutoScalingConfiguration, error) + ListAutoScalingConfigurations(ctx context.Context, name string, latestOnly bool, nextToken string, maxResults int32, + ) ([]AutoScalingConfiguration, string, error) + UpdateDefaultAutoScalingConfiguration(ctx context.Context, arn string) (*AutoScalingConfiguration, error) + ListServicesForAutoScalingConfiguration(ctx context.Context, arn, nextToken string, maxResults int32, + ) ([]string, string, error) + + // Connection. + CreateConnection(ctx context.Context, name, providerType string, tags map[string]string) (*Connection, error) + DeleteConnection(ctx context.Context, arn string) (*Connection, error) + ListConnections(ctx context.Context, name, nextToken string, maxResults int32) ([]Connection, string, error) + + // ObservabilityConfiguration. + CreateObservabilityConfiguration(ctx context.Context, name string, trace json.RawMessage, tags map[string]string, + ) (*ObservabilityConfiguration, error) + DescribeObservabilityConfiguration(ctx context.Context, arn string) (*ObservabilityConfiguration, error) + DeleteObservabilityConfiguration(ctx context.Context, arn string) (*ObservabilityConfiguration, error) + ListObservabilityConfigurations(ctx context.Context, name string, latestOnly bool, nextToken string, maxResults int32, + ) ([]ObservabilityConfiguration, string, error) + + // VpcConnector. + CreateVpcConnector(ctx context.Context, name string, subnets, securityGroups []string, tags map[string]string, + ) (*VpcConnector, error) + DescribeVpcConnector(ctx context.Context, arn string) (*VpcConnector, error) + DeleteVpcConnector(ctx context.Context, arn string) (*VpcConnector, error) + ListVpcConnectors(ctx context.Context, nextToken string, maxResults int32) ([]VpcConnector, string, error) + + // VpcIngressConnection. + CreateVpcIngressConnection(ctx context.Context, name, serviceArn string, ingressVpcConfig json.RawMessage, + tags map[string]string) (*VpcIngressConnection, error) + DescribeVpcIngressConnection(ctx context.Context, arn string) (*VpcIngressConnection, error) + DeleteVpcIngressConnection(ctx context.Context, arn string) (*VpcIngressConnection, error) + ListVpcIngressConnections(ctx context.Context, serviceArn, vpcEndpointID, nextToken string, maxResults int32, + ) ([]VpcIngressConnection, string, error) + UpdateVpcIngressConnection(ctx context.Context, arn string, ingressVpcConfig json.RawMessage, + ) (*VpcIngressConnection, error) + + // CustomDomain. The dnsTarget is the App Runner subdomain the + // custom domain maps to (the service's own URL). + AssociateCustomDomain(ctx context.Context, serviceArn, domainName string, enableWWW bool, + ) (cd *CustomDomain, dnsTarget string, err error) + DisassociateCustomDomain(ctx context.Context, serviceArn, domainName string, + ) (cd *CustomDomain, dnsTarget string, err error) + DescribeCustomDomains(ctx context.Context, serviceArn, nextToken string, maxResults int32, + ) (domains []CustomDomain, dnsTarget, token string, err error) + + // Operations. + ListOperations(ctx context.Context, serviceArn, nextToken string, maxResults int32, + ) ([]OperationSummary, string, error) + + // Tags. + TagResource(ctx context.Context, arn string, tags map[string]string) error + UntagResource(ctx context.Context, arn string, tagKeys []string) error + ListTagsForResource(ctx context.Context, arn string) (map[string]string, error) +} diff --git a/services/apprunner/driver/errors.go b/services/apprunner/driver/errors.go new file mode 100644 index 000000000..47b720b79 --- /dev/null +++ b/services/apprunner/driver/errors.go @@ -0,0 +1,23 @@ +package driver + +// App Runner modeled exception names, used to select the precise __type at the +// wire layer. App Runner speaks AWS JSON 1.0 and models a small, fixed set of +// exceptions; errors carry the exception they concern so the server emits the +// right __type while GetCode still resolves the HTTP status through Unwrap. +const ( + ExInternalServiceError = "InternalServiceErrorException" + ExInvalidRequest = "InvalidRequestException" + ExInvalidState = "InvalidStateException" + ExResourceNotFound = "ResourceNotFoundException" +) + +// APIError tags a canonical cloudemu error with the App Runner exception name it +// concerns, so the server can emit the right __type while GetCode still resolves +// the HTTP status through Unwrap. +type APIError struct { + Exception string + Err error +} + +func (e *APIError) Error() string { return e.Err.Error() } +func (e *APIError) Unwrap() error { return e.Err } diff --git a/services/apprunner/driver/types.go b/services/apprunner/driver/types.go new file mode 100644 index 000000000..b697a6a6d --- /dev/null +++ b/services/apprunner/driver/types.go @@ -0,0 +1,239 @@ +// Package driver defines the interface and types for AWS App Runner +// implementations. It models App Runner services (with their source, +// instance, network, and observability configuration), auto scaling +// configurations (with revision management), connections, observability +// configurations, VPC connectors, VPC ingress connections, custom domains, +// per-service operations, and tags. +// +// The emulator does NOT actually build or run containers: CreateService +// completes deterministically (Status immediately RUNNING) with a synthesized +// ServiceUrl, and lifecycle transitions (Pause/Resume/Delete) mutate stored +// state directly. Large, open-ended nested configuration blocks +// (SourceConfiguration, NetworkConfiguration, health-check, etc.) are carried +// verbatim as json.RawMessage so the SDK wire shapes round-trip without +// modeling every leaf field. +package driver + +import ( + "encoding/json" + "time" +) + +// Service statuses (types.ServiceStatus). +const ( + ServiceStatusCreateFailed = "CREATE_FAILED" + ServiceStatusRunning = "RUNNING" + ServiceStatusDeleted = "DELETED" + ServiceStatusDeleteFailed = "DELETE_FAILED" + ServiceStatusPaused = "PAUSED" + ServiceStatusOperationInProgress = "OPERATION_IN_PROGRESS" +) + +// Auto scaling / observability / VPC-connector statuses. +const ( + StatusActive = "ACTIVE" + StatusInactive = "INACTIVE" +) + +// Connection statuses (types.ConnectionStatus). +const ( + ConnectionStatusPendingHandshake = "PENDING_HANDSHAKE" + ConnectionStatusAvailable = "AVAILABLE" + ConnectionStatusError = "ERROR" + ConnectionStatusDeleted = "DELETED" +) + +// Provider types (types.ProviderType). +const ( + ProviderTypeGitHub = "GITHUB" + ProviderTypeBitbucket = "BITBUCKET" +) + +// VPC ingress connection statuses (types.VpcIngressConnectionStatus). +const ( + VpcIngressStatusAvailable = "AVAILABLE" + VpcIngressStatusDeleted = "DELETED" +) + +// Custom domain association statuses (types.CustomDomainAssociationStatus). The +// emulator synthesizes the DNS-validation flow: a domain is created pending +// validation and immediately settles ACTIVE. +const ( + CustomDomainStatusPendingCertValidation = "PENDING_CERTIFICATE_DNS_VALIDATION" + CustomDomainStatusActive = "ACTIVE" + CustomDomainStatusDeleting = "DELETING" +) + +// CertificateValidationRecordStatus values. +const ( + CertValidationStatusSuccess = "SUCCESS" +) + +// Operation types (types.OperationType). +const ( + OperationTypeStartDeployment = "START_DEPLOYMENT" + OperationTypeCreateService = "CREATE_SERVICE" + OperationTypePauseService = "PAUSE_SERVICE" + OperationTypeResumeService = "RESUME_SERVICE" + OperationTypeDeleteService = "DELETE_SERVICE" + OperationTypeUpdateService = "UPDATE_SERVICE" +) + +// Operation statuses (types.OperationStatus). +const ( + OperationStatusPending = "PENDING" + OperationStatusInProgress = "IN_PROGRESS" + OperationStatusSucceeded = "SUCCEEDED" + OperationStatusFailed = "FAILED" +) + +// Service is the full description of an App Runner service. Large nested +// configuration blocks are stored verbatim as json.RawMessage so the SDK wire +// shapes round-trip without modeling every leaf field. +type Service struct { + ServiceArn string + ServiceID string + ServiceName string + ServiceURL string + Status string + CreatedAt time.Time + UpdatedAt time.Time + DeletedAt time.Time + AutoScalingConfigArn string + AutoScalingConfigName string + AutoScalingConfigRevision int32 + SourceConfiguration json.RawMessage + InstanceConfiguration json.RawMessage + NetworkConfiguration json.RawMessage + HealthCheckConfiguration json.RawMessage + EncryptionConfiguration json.RawMessage + ObservabilityConfiguration json.RawMessage + Tags map[string]string +} + +// CreateServiceInput describes a service to create. +type CreateServiceInput struct { + ServiceName string + SourceConfiguration json.RawMessage + InstanceConfiguration json.RawMessage + NetworkConfiguration json.RawMessage + HealthCheckConfiguration json.RawMessage + EncryptionConfiguration json.RawMessage + ObservabilityConfiguration json.RawMessage + AutoScalingConfigArn string + Tags map[string]string +} + +// UpdateServiceInput describes a mutation of an existing service. Empty +// json.RawMessage / string fields leave the corresponding block unchanged. +type UpdateServiceInput struct { + ServiceArn string + SourceConfiguration json.RawMessage + InstanceConfiguration json.RawMessage + NetworkConfiguration json.RawMessage + HealthCheckConfiguration json.RawMessage + ObservabilityConfiguration json.RawMessage + AutoScalingConfigArn string +} + +// AutoScalingConfiguration is an auto scaling configuration revision. +type AutoScalingConfiguration struct { + Arn string + Name string + Revision int32 + Status string + MaxConcurrency int32 + MaxSize int32 + MinSize int32 + IsDefault bool + Latest bool + HasAssociatedService bool + CreatedAt time.Time + DeletedAt time.Time + Tags map[string]string +} + +// Connection is an App Runner source-repository connection. +type Connection struct { + Arn string + Name string + ProviderType string + Status string + CreatedAt time.Time + Tags map[string]string +} + +// ObservabilityConfiguration is an observability configuration revision. +type ObservabilityConfiguration struct { + Arn string + Name string + Revision int32 + Status string + Latest bool + TraceConfiguration json.RawMessage + CreatedAt time.Time + DeletedAt time.Time + Tags map[string]string +} + +// VpcConnector is an App Runner VPC connector revision. +type VpcConnector struct { + Arn string + Name string + Revision int32 + Status string + Subnets []string + SecurityGroups []string + CreatedAt time.Time + DeletedAt time.Time + Tags map[string]string +} + +// VpcIngressConnection is an App Runner VPC ingress connection. +type VpcIngressConnection struct { + Arn string + Name string + Status string + AccountID string + DomainName string + ServiceArn string + IngressVpcConfiguration json.RawMessage + CreatedAt time.Time + DeletedAt time.Time + Tags map[string]string +} + +// CustomDomain is a custom domain associated with a service. +type CustomDomain struct { + DomainName string + EnableWWWSubdomain bool + Status string + ServiceArn string + CertificateValidationRecords []CertificateValidationRecord +} + +// CertificateValidationRecord is a CNAME record the customer adds to their DNS +// to validate a custom-domain certificate. +type CertificateValidationRecord struct { + Name string + Type string + Value string + Status string +} + +// OperationSummary summarizes an operation that occurred on a service. +type OperationSummary struct { + ID string + Type string + Status string + TargetArn string + StartedAt time.Time + EndedAt time.Time + UpdatedAt time.Time +} + +// ServiceResult carries a service plus the operation id a mutating op returns. +type ServiceResult struct { + Service *Service + OperationID string +}